From 3743f9cab2fac7511496ef6e3cd66a0d05b97cf7 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Tue, 5 May 2026 17:17:08 -0700 Subject: [PATCH] feat: added v1 read-only version list event panel --- src/App.tsx | 4 +- src/components/ListEventPanel.css | 189 +++++++++++++ src/components/ListEventPanel.tsx | 345 ++++++++++++++++++++++++ src/components/Toolbar.tsx | 28 +- src/components/piano-roll/PianoRoll.tsx | 4 +- src/stores/projectStore.ts | 25 +- 6 files changed, 584 insertions(+), 11 deletions(-) create mode 100644 src/components/ListEventPanel.css create mode 100644 src/components/ListEventPanel.tsx diff --git a/src/App.tsx b/src/App.tsx index aea7230..fc5038c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import ChatBox from './components/ChatBox'; import { SettingsPanel } from './components/settings'; import LoadingOverlay from './components/common/LoadingOverlay'; import KGOnePanel from './components/KGOnePanel'; +import ListEventPanel from './components/ListEventPanel'; import { useEffect as useEffectReact, useState, useRef } from 'react'; import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool'; import { KGOfflineRenderer } from './core/audio-interface/KGOfflineRenderer'; @@ -30,7 +31,7 @@ function App() { const { refreshStatus, loadProject, showChatBox, showSettings, setShowSettings, initializeFromConfig, - showInstrumentSelection, showKGOnePanel + showInstrumentSelection, showKGOnePanel, showListEventPanel } = useProjectStore(); // Track if app has been initialized to prevent multiple initializations @@ -168,6 +169,7 @@ function App() { )} + diff --git a/src/components/ListEventPanel.css b/src/components/ListEventPanel.css new file mode 100644 index 0000000..d0119f5 --- /dev/null +++ b/src/components/ListEventPanel.css @@ -0,0 +1,189 @@ +.list-event-panel { + display: flex; + flex-direction: column; + width: var(--chat-box-width); + background-color: #2d2d2d; + border-left: 1px solid #3a3a3a; + flex-shrink: 0; + overflow: hidden; +} + +.list-event-panel.is-hidden { + display: none; +} + +.list-event-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + background-color: #3a3a3a; + height: 40px; + border-bottom: 1px solid #4a4a4a; + padding: 0 15px; + flex-shrink: 0; +} + +.list-event-panel-header h3 { + color: #e0e0e0; + font-size: 12px; + font-weight: bold; + margin: 0; +} + +.list-event-panel-body { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 12px; + gap: 12px; +} + +.list-event-tabs { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.list-event-tab { + flex: 1; + background-color: #1e1e1e; + color: #999; + border: none; + border-radius: 6px; + font-size: 11px; + font-weight: 500; + min-height: 20px; + padding: 5px 6px; + cursor: default; + transition: all 0.2s ease; +} + +.list-event-tab:hover { + color: #e0e0e0; +} + +.list-event-tab.active { + background-color: #5a9fd4; + color: #fff; +} + +.list-event-empty-state { + color: #888; + font-size: 11px; + line-height: 1.5; + text-align: center; + padding: 20px 8px; + background-color: #252525; + border-radius: 6px; +} + +.list-event-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-shrink: 0; +} + +.list-event-toolbar-group { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.list-event-toolbar-group-right { + margin-left: auto; +} + +.list-event-add-button { + width: 22px; + height: 20px; + border: 1px solid #444; + border-radius: 3px; + background-color: #3a3a3a; + color: #e0e0e0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: background-color 0.18s ease, border-color 0.18s ease; + padding: 0; + font-size: 11px; +} + +.list-event-add-button:hover { + background-color: #464646; + border-color: #5a5a5a; +} + +.list-event-dropdown-button, +.list-event-quant-button { + min-width: 92px; + font-size: 11px; +} + +.list-event-dropdown-button { + margin-left: 0; +} + +.list-event-table-shell { + flex: 1; + min-height: 0; + overflow: auto; + background-color: #232323; + border: 1px solid #373737; + border-radius: 6px; +} + +.list-event-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.list-event-table thead th { + position: sticky; + top: 0; + z-index: 1; + background-color: #343434; + color: #bfc6d2; + font-size: 11px; + font-weight: 600; + text-align: left; + height: 20px; + padding: 2px 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.list-event-table tbody tr { + color: #e0e0e0; + cursor: default; +} + +.list-event-table tbody tr:nth-child(odd) { + background-color: #282828; +} + +.list-event-table tbody tr:nth-child(even) { + background-color: #303030; +} + +.list-event-table tbody tr.selected { + background-color: #5a9fd4; + color: #fff; +} + +.list-event-table td { + height: 20px; + padding: 2px 12px; + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 0; +} diff --git a/src/components/ListEventPanel.tsx b/src/components/ListEventPanel.tsx new file mode 100644 index 0000000..3db4fbd --- /dev/null +++ b/src/components/ListEventPanel.tsx @@ -0,0 +1,345 @@ +import React, { useRef, useState } from 'react'; +import './ListEventPanel.css'; +import { FaPlus } from 'react-icons/fa'; +import KGDropdown from './common/KGDropdown'; +import { useProjectStore } from '../stores/projectStore'; +import { KGCore } from '../core/KGCore'; +import { KGMidiRegion } from '../core/region/KGMidiRegion'; +import { KGMidiNote } from '../core/midi/KGMidiNote'; +import { KGPianoRollState } from '../core/state/KGPianoRollState'; +import { beatsToBar, pitchToNoteNameString } from '../util/midiUtil'; +import { isModifierKeyPressed } from '../util/osUtil'; +import { PIANO_ROLL_CONSTANTS } from '../constants'; + +interface ListEventPanelProps { + isVisible: boolean; +} + +interface NoteRowData { + id: string; + note: KGMidiNote; + absoluteStartBeat: number; + durationBeats: number; +} + +const EVENT_TYPE_OPTIONS = [{ label: 'Notes', value: 'notes' }]; +const EVENT_POSITION_TICKS_PER_BEAT = 480; + +const formatEventPosition = ( + beats: number, + timeSignature: { numerator: number; denominator: number } +): string => { + const { bar, beatInBar } = beatsToBar(beats, timeSignature); + const beatInteger = Math.floor(beatInBar); + let tick = Math.round((beatInBar - beatInteger) * EVENT_POSITION_TICKS_PER_BEAT); + let normalizedBeat = beatInteger; + let normalizedBar = bar; + + if (tick >= EVENT_POSITION_TICKS_PER_BEAT) { + tick = 0; + normalizedBeat += 1; + } + + if (normalizedBeat >= timeSignature.numerator) { + normalizedBeat = 0; + normalizedBar += 1; + } + + return `${normalizedBar + 1} ${normalizedBeat + 1} ${tick}`; +}; + +const formatEventLength = ( + beats: number +): string => { + const fullBeats = Math.floor(beats); + let tick = Math.round((beats - fullBeats) * EVENT_POSITION_TICKS_PER_BEAT); + let normalizedBeats = fullBeats; + + if (tick >= EVENT_POSITION_TICKS_PER_BEAT) { + tick = 0; + normalizedBeats += 1; + } + + return `${normalizedBeats} ${tick}`; +}; + +const ListEventPanel: React.FC = ({ isVisible }) => { + const { + tracks, + activeRegionId, + selectedRegionIds, + timeSignature, + selectedNoteIds, + updateTrack, + refreshProjectState + } = useProjectStore(); + + const [eventType, setEventType] = useState('notes'); + const [quantPosition, setQuantPosition] = useState('1/8'); + const [quantLength, setQuantLength] = useState('1/8'); + const rangeAnchorNoteIdRef = useRef(null); + + const resolvedRegionId = selectedRegionIds.length > 1 + ? activeRegionId + : selectedRegionIds.length === 1 + ? selectedRegionIds[0] + : activeRegionId; + + let activeMidiRegion: KGMidiRegion | null = null; + let parentTrack = null as typeof tracks[number] | null; + + if (resolvedRegionId) { + for (const track of tracks) { + const region = track.getRegions().find(candidate => candidate.getId() === resolvedRegionId); + if (region instanceof KGMidiRegion) { + activeMidiRegion = region; + parentTrack = track; + break; + } + } + } + + const noteRows: NoteRowData[] = activeMidiRegion + ? [...activeMidiRegion.getNotes()] + .sort((a, b) => { + if (a.getStartBeat() !== b.getStartBeat()) return a.getStartBeat() - b.getStartBeat(); + if (a.getPitch() !== b.getPitch()) return a.getPitch() - b.getPitch(); + return a.getId().localeCompare(b.getId()); + }) + .map(note => ({ + id: note.getId(), + note, + absoluteStartBeat: activeMidiRegion!.getStartFromBeat() + note.getStartBeat(), + durationBeats: note.getEndBeat() - note.getStartBeat() + })) + : []; + + const selectedNoteIdSet = new Set(selectedNoteIds); + + const commitSelection = (nextSelectedIds: Set) => { + if (!activeMidiRegion || !parentTrack) return; + + const selectedNotes = activeMidiRegion.getNotes().filter(note => nextSelectedIds.has(note.getId())); + const core = KGCore.instance(); + + activeMidiRegion.getNotes().forEach(note => { + if (nextSelectedIds.has(note.getId())) { + note.select(); + } else { + note.deselect(); + } + }); + + core.clearSelectedItems(); + if (selectedNotes.length > 0) { + core.addSelectedItems(selectedNotes); + } + + void updateTrack(parentTrack); + }; + + const handleRowClick = (noteId: string, rowIndex: number, event: React.MouseEvent) => { + if (!activeMidiRegion) return; + + const isModifierPressed = isModifierKeyPressed(event); + const nextSelectedIds = new Set(selectedNoteIdSet); + + if (event.shiftKey) { + const anchorIndex = noteRows.findIndex(row => row.id === rangeAnchorNoteIdRef.current); + const rangeStartIndex = anchorIndex >= 0 ? Math.min(anchorIndex, rowIndex) : rowIndex; + const rangeEndIndex = anchorIndex >= 0 ? Math.max(anchorIndex, rowIndex) : rowIndex; + + if (!isModifierPressed) { + nextSelectedIds.clear(); + } + + for (let index = rangeStartIndex; index <= rangeEndIndex; index += 1) { + nextSelectedIds.add(noteRows[index].id); + } + } else if (isModifierPressed) { + if (nextSelectedIds.has(noteId)) { + nextSelectedIds.delete(noteId); + } else { + nextSelectedIds.add(noteId); + } + rangeAnchorNoteIdRef.current = noteId; + } else { + nextSelectedIds.clear(); + nextSelectedIds.add(noteId); + rangeAnchorNoteIdRef.current = noteId; + } + + if (event.shiftKey && rangeAnchorNoteIdRef.current === null) { + rangeAnchorNoteIdRef.current = noteId; + } + + commitSelection(nextSelectedIds); + }; + + const handleTableBackgroundMouseDown = (event: React.MouseEvent) => { + if (event.target !== event.currentTarget) return; + rangeAnchorNoteIdRef.current = null; + commitSelection(new Set()); + }; + + const quantizeSelectedNotes = (quantValue: string) => { + if (!activeMidiRegion || !parentTrack) return; + + const denominator = parseInt(quantValue.split('/')[1], 10); + if (Number.isNaN(denominator)) return; + + const selectedNotes = activeMidiRegion + .getNotes() + .filter(note => selectedNoteIdSet.has(note.getId())); + + if (selectedNotes.length === 0) return; + + const quantizationStep = 4 / denominator; + selectedNotes.forEach(note => { + const currentStartBeat = note.getStartBeat(); + const duration = note.getEndBeat() - currentStartBeat; + const quantizedStartBeat = Math.round(currentStartBeat / quantizationStep) * quantizationStep; + note.setStartBeat(quantizedStartBeat); + note.setEndBeat(quantizedStartBeat + duration); + }); + + void updateTrack(parentTrack); + refreshProjectState(); + }; + + const quantizeSelectedNoteLengths = (quantValue: string) => { + if (!activeMidiRegion || !parentTrack) return; + + const denominator = parseInt(quantValue.split('/')[1], 10); + if (Number.isNaN(denominator)) return; + + const selectedNotes = activeMidiRegion + .getNotes() + .filter(note => selectedNoteIdSet.has(note.getId())); + + if (selectedNotes.length === 0) return; + + const quantizationStep = 4 / denominator; + selectedNotes.forEach(note => { + const startBeat = note.getStartBeat(); + const currentDuration = note.getEndBeat() - startBeat; + let quantizedDuration = currentDuration < quantizationStep + ? quantizationStep + : Math.round(currentDuration / quantizationStep) * quantizationStep; + + quantizedDuration = Math.max(PIANO_ROLL_CONSTANTS.MIN_NOTE_LENGTH, quantizedDuration); + note.setEndBeat(startBeat + quantizedDuration); + }); + + void updateTrack(parentTrack); + refreshProjectState(); + }; + + return ( +
+
+

List Event

+
+ +
+
+ + + +
+ + {!activeMidiRegion ? ( +
+ Please select a MIDI region, or open one in the Piano Roll, to view its event list. +
+ ) : ( + <> +
+
+ + +
+ +
+ { + setQuantPosition(value); + quantizeSelectedNotes(value); + }} + label="Qua. Pos." + buttonClassName="list-event-quant-button" + /> + { + setQuantLength(value); + quantizeSelectedNoteLengths(value); + }} + label="Qua. Len." + buttonClassName="list-event-quant-button" + /> +
+
+ +
+ + + + + + + + + + + + {noteRows.map((row, index) => ( + (() => { + const positionText = formatEventPosition(row.absoluteStartBeat, timeSignature); + const statusText = 'Note'; + const noteText = pitchToNoteNameString(row.note.getPitch()); + const velocityText = String(row.note.getVelocity()); + const lengthText = formatEventLength(row.durationBeats); + + return ( + handleRowClick(row.id, index, event)} + > + + + + + + + ); + })() + ))} + +
PositionStatusNumValLength/Info
{positionText}{statusText}{noteText}{velocityText}{lengthText}
+
+ + )} +
+
+ ); +}; + +export default ListEventPanel; diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index bbebe48..78a0225 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -18,7 +18,7 @@ import { KGProject, type KeySignature } from '../core/KGProject'; import { KGMidiInput } from '../core/midi-input/KGMidiInput'; import { KGMidiRegion } from '../core/region/KGMidiRegion'; import { plainToInstance } from 'class-transformer'; -import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles } from 'react-icons/fa6'; +import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles, FaListUl } from 'react-icons/fa6'; import { KGMainContentState } from '../core/state/KGMainContentState'; import { regionDeleteManager } from '../util/regionDeleteUtil'; import { SplitRegionCommand } from '../core/commands/region/SplitRegionCommand'; @@ -48,7 +48,7 @@ const Toolbar: React.FC = () => { barWidthMultiplier, setBarWidthMultiplier, isLooping, toggleLoop, canUndo, canRedo, undoDescription, redoDescription, undo, redo, - toggleChatBox, toggleSettings, toggleKGOnePanel, showKGOnePanel, cleanupProjectState, toggleMetronome, isMetronomeEnabled, + toggleChatBox, toggleSettings, toggleKGOnePanel, toggleListEventPanel, showKGOnePanel, showListEventPanel, showChatBox, cleanupProjectState, toggleMetronome, isMetronomeEnabled, isRecording, startRecording, stopRecording, // Piano roll state/actions showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId, @@ -939,6 +939,13 @@ const Toolbar: React.FC = () => { toggleKGOnePanel(); }; + const handleListEventClick = () => { + if (DEBUG_MODE.TOOLBAR) { + console.log("List Event button clicked"); + } + toggleListEventPanel(); + }; + // Handle Piano button click: open piano roll if closed, targeting active or selected region const handlePianoButtonClick = async () => { if (DEBUG_MODE.TOOLBAR) { @@ -1058,6 +1065,7 @@ const Toolbar: React.FC = () => { +
@@ -1188,7 +1196,6 @@ const Toolbar: React.FC = () => { - - + + diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index eaedc6e..dad61c5 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -46,7 +46,7 @@ const PianoRoll: React.FC = ({ }) => { const isSpectrogram = mode === 'spectrogram'; const isHybrid = mode === 'hybrid'; - const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds } = useProjectStore(); + const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showKGOnePanel, showListEventPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds } = useProjectStore(); // Tool state for piano roll const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer'); @@ -140,7 +140,7 @@ const PianoRoll: React.FC = ({ const instrumentPanelWidth = parseInt(instrumentPanelWidthStr, 10) || 300; let availableWidth = window.innerWidth; - if (showChatBox) availableWidth -= chatBoxWidth; + if (showChatBox || showKGOnePanel || showListEventPanel) availableWidth -= chatBoxWidth; if (showInstrumentSelection) availableWidth -= instrumentPanelWidth; // Ensure a sensible minimum starting width diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 0e2193f..821f1e4 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -87,6 +87,9 @@ interface ProjectState { // K.G.One panel state showKGOnePanel: boolean; + // List event panel state + showListEventPanel: boolean; + // Instrument selection panel state showInstrumentSelection: boolean; // instrumentSelectionTrackId removed; panel now follows selectedTrackId @@ -169,6 +172,9 @@ interface ProjectState { // K.G.One panel actions toggleKGOnePanel: () => void; + // List event panel actions + toggleListEventPanel: () => void; + // Instrument selection panel actions openInstrumentSelectionForTrack: () => void; toggleInstrumentSelectionForTrack: () => void; @@ -348,6 +354,9 @@ export const useProjectStore = create((set, get) => { // Initial K.G.One panel state showKGOnePanel: false, + // Initial List Event panel state + showListEventPanel: false, + // Initial Instrument Selection panel state showInstrumentSelection: initialShowInstrumentSelection, @@ -1130,17 +1139,26 @@ export const useProjectStore = create((set, get) => { // ChatBox action implementations setShowChatBox: (show: boolean) => { - set({ showChatBox: show }); + set({ + showChatBox: show, + showKGOnePanel: show ? false : get().showKGOnePanel, + showListEventPanel: show ? false : get().showListEventPanel + }); }, toggleChatBox: () => { const { showChatBox } = get(); - set({ showChatBox: !showChatBox, showKGOnePanel: false }); + set({ showChatBox: !showChatBox, showKGOnePanel: false, showListEventPanel: false }); }, toggleKGOnePanel: () => { const { showKGOnePanel } = get(); - set({ showKGOnePanel: !showKGOnePanel, showChatBox: false }); + set({ showKGOnePanel: !showKGOnePanel, showChatBox: false, showListEventPanel: false }); + }, + + toggleListEventPanel: () => { + const { showListEventPanel } = get(); + set({ showListEventPanel: !showListEventPanel, showChatBox: false, showKGOnePanel: false }); }, // Instrument selection panel actions @@ -1281,4 +1299,3 @@ export const useProjectStore = create((set, get) => { }); -