From 9ed3e579075c0d57a475256a5b1b88239505c5cb Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Tue, 26 May 2026 19:23:18 -0700 Subject: [PATCH] feat: added Global tab to Event List panel --- src/components/EventListPanel.css | 6 +- src/components/EventListPanel.test.tsx | 186 +++- src/components/EventListPanel.tsx | 31 +- .../event-list-panel/GlobalEventListTab.tsx | 886 ++++++++++++++++++ 4 files changed, 1101 insertions(+), 8 deletions(-) create mode 100644 src/components/event-list-panel/GlobalEventListTab.tsx diff --git a/src/components/EventListPanel.css b/src/components/EventListPanel.css index bf4ef49..4630b97 100644 --- a/src/components/EventListPanel.css +++ b/src/components/EventListPanel.css @@ -214,6 +214,8 @@ background-color: #232323; border: 1px solid #373737; border-radius: 6px; + user-select: none; + -webkit-user-select: none; } .event-list-table { @@ -278,4 +280,6 @@ font-size: 11px; line-height: 16px; outline: none; -} \ No newline at end of file + user-select: text; + -webkit-user-select: text; +} diff --git a/src/components/EventListPanel.test.tsx b/src/components/EventListPanel.test.tsx index 584cc26..28215b7 100644 --- a/src/components/EventListPanel.test.tsx +++ b/src/components/EventListPanel.test.tsx @@ -5,7 +5,16 @@ import EventListPanel from './EventListPanel'; import { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent'; import { KGMidiNote } from '../core/midi/KGMidiNote'; import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend'; +import { KGProject } from '../core/KGProject'; +import { KGChordRegion } from '../core/region/KGChordRegion'; +import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion'; +import { KGMarkerRegion } from '../core/region/KGMarkerRegion'; import { KGRegion } from '../core/region/KGRegion'; +import { KGTempoRegion } from '../core/region/KGTempoRegion'; +import { KGChordTrack } from '../core/global-track/KGChordTrack'; +import { KGMarkerTrack } from '../core/global-track/KGMarkerTrack'; +import { KGSignatureTrack } from '../core/global-track/KGSignatureTrack'; +import { KGTempoTrack } from '../core/global-track/KGTempoTrack'; import { KGAudioRegion } from '../core/region/KGAudioRegion'; import { KGAudioTrack } from '../core/track/KGAudioTrack'; import { KGTrackAutomationPoint } from '../core/track/KGTrackAutomationPoint'; @@ -16,6 +25,7 @@ import { createMockMidiRegion, createMockMidiTrack, } from '../test/utils/mock-data'; +import { showAlert } from '../util/dialogUtil'; const clickDropdownOption = (label: string) => { const option = Array.from(document.querySelectorAll('.quant-option')) @@ -58,8 +68,23 @@ const audioTrack = new KGAudioTrack('Audio Track', 2, -3); audioTrack.setTrackIndex(1); audioTrack.setRegions([audioRegion]); +const markerTrack = new KGMarkerTrack(); +const tempoTrack = new KGTempoTrack(); +const signatureTrack = new KGSignatureTrack(); +const chordTrack = new KGChordTrack(); + +const markerRegion = new KGMarkerRegion('marker-1', markerTrack.getId(), markerTrack.getTrackIndex(), 'Intro', 0, 4); +const tempoRegionA = new KGTempoRegion('tempo-1', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 4, 4); +const tempoRegionB = new KGTempoRegion('tempo-2', tempoTrack.getId(), tempoTrack.getTrackIndex(), 140, 4, 28, 4); +const keySignatureRegionA = new KGKeySignatureRegion('signature-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 4, 4); +const keySignatureRegionB = new KGKeySignatureRegion('signature-2', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 4, 28, 4); +const chordRegion = new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Bm7b5', 4, 4); + +let project = new KGProject(); + type MockStoreState = { tracks: Array; + globalTracks: Array; activeRegionId: string | null; selectedRegionIds: string[]; selectedTrackId: string | null; @@ -77,6 +102,7 @@ type MockStoreState = { const storeState: MockStoreState = { tracks: [midiTrack, audioTrack], + globalTracks: [markerTrack, tempoTrack, signatureTrack, chordTrack], activeRegionId: 'region-1', selectedRegionIds: ['region-1'], selectedTrackId: '1', @@ -92,7 +118,9 @@ const storeState: MockStoreState = { bumpTrackAutomationRedrawVersion: vi.fn(), }; -let selectedItems: Array = []; +let selectedItems: Array< + KGRegion | KGMidiNote | KGMidiPitchBend | KGMidiControllerEvent | KGTrackAutomationPoint +> = []; const syncStoreSelectionFromCore = () => { storeState.selectedRegionIds = selectedItems @@ -151,19 +179,37 @@ vi.mock('../core/KGCore', () => ({ command.execute(); syncStoreSelectionFromCore(); }, - getCurrentProject: () => ({ - getTracks: () => storeState.tracks, - }), + getCurrentProject: () => project, })), }, })); describe('EventListPanel', () => { beforeEach(() => { + project = new KGProject( + 'Test Project', + 32, + 0, + 120, + { numerator: 4, denominator: 4 }, + 'C major', + 'ionian', + false, + [0, 0], + 1, + [midiTrack, audioTrack], + ); + selectedItems = [midiRegion]; midiRegion.select(); secondMidiRegion.deselect(); audioRegion.deselect(); + markerRegion.deselect(); + tempoRegionA.deselect(); + tempoRegionB.deselect(); + keySignatureRegionA.deselect(); + keySignatureRegionB.deselect(); + chordRegion.deselect(); midiRegion.setStartFromBeat(4); midiRegion.setLength(4); @@ -183,9 +229,34 @@ describe('EventListPanel', () => { midiTrack.getVolumeAutomation().forEach(point => point.deselect()); midiTrack.getPanAutomation().forEach(point => point.deselect()); + markerRegion.setName('Intro'); + markerRegion.setStartFromBeat(0); + markerRegion.setLength(4); + markerTrack.setRegions([markerRegion]); + + tempoRegionA.setBpm(120); + tempoRegionA.setBarRange(0, 4, 4); + tempoRegionB.setBpm(140); + tempoRegionB.setBarRange(4, 28, 4); + tempoTrack.setRegions([tempoRegionA, tempoRegionB]); + + keySignatureRegionA.setKeySignature('C major'); + keySignatureRegionA.setBarRange(0, 4, 4); + keySignatureRegionB.setKeySignature('G major'); + keySignatureRegionB.setBarRange(4, 28, 4); + signatureTrack.setRegions([keySignatureRegionA, keySignatureRegionB]); + + chordRegion.setSymbol('Bm7b5'); + chordRegion.setStartFromBeat(4); + chordRegion.setLength(4); + chordTrack.setRegions([chordRegion]); + + project.setGlobalTracks([markerTrack, tempoTrack, signatureTrack, chordTrack]); + storeState.activeRegionId = 'region-1'; storeState.selectedRegionIds = ['region-1']; storeState.selectedTrackId = '1'; + storeState.globalTracks = [markerTrack, tempoTrack, signatureTrack, chordTrack]; storeState.selectedNoteIds = []; storeState.selectedPitchBendIds = []; storeState.selectedControllerEventIds = []; @@ -195,6 +266,7 @@ describe('EventListPanel', () => { storeState.refreshProjectState.mockClear(); storeState.bumpAutomationRedrawVersion.mockClear(); storeState.bumpTrackAutomationRedrawVersion.mockClear(); + vi.mocked(showAlert).mockClear(); }); it('defaults to Region tab and preserves existing event rows', () => { @@ -202,6 +274,7 @@ describe('EventListPanel', () => { expect(screen.getByRole('button', { name: 'Region' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Track' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Global' })).toBeInTheDocument(); expect(screen.getByText('Pitch Bend')).toBeInTheDocument(); expect(screen.getByText('Raw 12288 | 0.500 | 1.00 st')).toBeInTheDocument(); }); @@ -376,4 +449,109 @@ describe('EventListPanel', () => { await waitFor(() => expect(midiTrack.getVolumeAutomation()[0].getValue()).toBe(-3)); }); + + it('switches to Global tab and lists global track rows without requiring selection', () => { + storeState.selectedTrackId = null; + storeState.activeRegionId = null; + storeState.selectedRegionIds = []; + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Global' })); + + expect(screen.getAllByRole('button', { name: 'Marker' })[0]).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Tempo' })[0]).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Key Signature' })[0]).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Chord' })[0]).toBeInTheDocument(); + expect(screen.getByText('Intro')).toBeInTheDocument(); + expect(screen.getByText('120')).toBeInTheDocument(); + expect(screen.getByText('G major')).toBeInTheDocument(); + expect(screen.getByText('Bm7b5')).toBeInTheDocument(); + expect(screen.queryByText('Qua. Pos.')).not.toBeInTheDocument(); + expect(screen.queryByText('Qua. Len.')).not.toBeInTheDocument(); + }); + + it('toggles global filters independently', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Global' })); + fireEvent.click(screen.getAllByRole('button', { name: 'Marker' })[0]); + + expect(screen.queryByText('Intro')).not.toBeInTheDocument(); + expect(screen.getByText('120')).toBeInTheDocument(); + expect(screen.getByText('Bm7b5')).toBeInTheDocument(); + }); + + it('creates marker and tempo rows from the Global tab with type-specific snapping', async () => { + const { rerender } = render(); + fireEvent.click(screen.getByRole('button', { name: 'Global' })); + + storeState.playheadPosition = 5.6; + rerender(); + fireEvent.click(screen.getByTitle('Add marker region at playhead')); + await waitFor(() => expect(markerTrack.getRegions()).toHaveLength(2)); + const createdMarker = markerTrack.getRegions().find(region => region.getId() !== 'marker-1'); + expect(createdMarker?.getStartFromBeat()).toBe(6); + + fireEvent.click(screen.getAllByRole('button', { name: 'Marker' })[1]); + clickDropdownOption('Tempo'); + storeState.playheadPosition = 6.2; + rerender(); + fireEvent.click(screen.getByTitle('Add tempo region at playhead')); + + await waitFor(() => expect(tempoTrack.getRegions()).toHaveLength(3)); + const createdTempo = tempoTrack.getRegions().find(region => region.getId() !== 'tempo-1' && region.getId() !== 'tempo-2') as KGTempoRegion | undefined; + expect(createdTempo?.getStartBar()).toBe(2); + }); + + it('edits global values inline and shows validation dialogs for invalid input', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Global' })); + + fireEvent.doubleClick(screen.getByText('Intro')); + const markerInput = screen.getByDisplayValue('Intro'); + fireEvent.change(markerInput, { target: { value: 'Verse A' } }); + fireEvent.keyDown(markerInput, { key: 'Enter' }); + await waitFor(() => expect(markerRegion.getName()).toBe('Verse A')); + + fireEvent.doubleClick(screen.getByText('140')); + const tempoInput = screen.getByDisplayValue('140'); + fireEvent.change(tempoInput, { target: { value: 'fast' } }); + fireEvent.keyDown(tempoInput, { key: 'Enter' }); + await waitFor(() => expect(vi.mocked(showAlert)).toHaveBeenCalledWith(expect.stringContaining('Example: 128'))); + expect(tempoRegionB.getBpm()).toBe(140); + }); + + it('edits global position and length inline', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Global' })); + + fireEvent.doubleClick(screen.getByText('2 1 0')); + const positionInput = screen.getByDisplayValue('2 1 0'); + fireEvent.change(positionInput, { target: { value: '3 1 0' } }); + fireEvent.keyDown(positionInput, { key: 'Enter' }); + await waitFor(() => expect(chordRegion.getStartFromBeat()).toBe(8)); + + fireEvent.doubleClick(screen.getAllByText('4 0')[0]); + const lengthInput = screen.getByDisplayValue('4 0'); + fireEvent.change(lengthInput, { target: { value: '8 0' } }); + fireEvent.keyDown(lengthInput, { key: 'Enter' }); + await waitFor(() => expect(markerRegion.getLength()).toBe(8)); + }); + + it('deletes mixed global selections with type-aware commands', async () => { + const { rerender } = render(); + fireEvent.click(screen.getByRole('button', { name: 'Global' })); + + fireEvent.click(screen.getByText('Intro').closest('tr')!); + rerender(); + fireEvent.click(screen.getByText('120').closest('tr')!, { ctrlKey: true }); + rerender(); + fireEvent.click(screen.getByTitle('Delete visible selected rows')); + + await waitFor(() => { + expect(markerTrack.getRegions()).toHaveLength(0); + expect(tempoTrack.getRegions()).toHaveLength(1); + expect(tempoTrack.getRegions()[0].getId()).toBe('tempo-2'); + expect((tempoTrack.getRegions()[0] as KGTempoRegion).getStartBar()).toBe(0); + }); + }); }); diff --git a/src/components/EventListPanel.tsx b/src/components/EventListPanel.tsx index 67d10bf..747bc4e 100644 --- a/src/components/EventListPanel.tsx +++ b/src/components/EventListPanel.tsx @@ -6,15 +6,25 @@ import { KGMidiTrack } from '../core/track/KGMidiTrack'; import { KGAudioTrack } from '../core/track/KGAudioTrack'; import RegionEventListTab from './event-list-panel/RegionEventListTab'; import TrackEventListTab from './event-list-panel/TrackEventListTab'; +import GlobalEventListTab from './event-list-panel/GlobalEventListTab'; interface EventListPanelProps { isVisible: boolean; } -type ScopeTab = 'region' | 'track'; +type ScopeTab = 'region' | 'track' | 'global'; const EventListPanel: React.FC = ({ isVisible }) => { - const { tracks, activeRegionId, selectedRegionIds, selectedTrackId } = useProjectStore(); + const { + tracks, + globalTracks, + activeRegionId, + selectedRegionIds, + selectedTrackId, + timeSignature, + playheadPosition, + refreshProjectState, + } = useProjectStore(); const [scopeTab, setScopeTab] = useState('region'); const resolvedRegionId = selectedRegionIds.length > 1 @@ -63,13 +73,28 @@ const EventListPanel: React.FC = ({ isVisible }) => { > Track +
{scopeTab === 'region' ? ( - ) : ( + ) : scopeTab === 'track' ? ( + ) : ( + )}
diff --git a/src/components/event-list-panel/GlobalEventListTab.tsx b/src/components/event-list-panel/GlobalEventListTab.tsx new file mode 100644 index 0000000..2d6d067 --- /dev/null +++ b/src/components/event-list-panel/GlobalEventListTab.tsx @@ -0,0 +1,886 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { FaPlus, FaTrash } from 'react-icons/fa'; +import KGDropdown from '../common/KGDropdown'; +import { KGCore } from '../../core/KGCore'; +import type { KeySignature } from '../../core/KGProject'; +import { KGGlobalTrack, GlobalTrackType } from '../../core/global-track'; +import { KGChordRegion } from '../../core/region/KGChordRegion'; +import { KGGlobalRegion } from '../../core/region/KGGlobalRegion'; +import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion'; +import { KGMarkerRegion } from '../../core/region/KGMarkerRegion'; +import { KGRegion } from '../../core/region/KGRegion'; +import { KGTempoRegion } from '../../core/region/KGTempoRegion'; +import { + CreateChordRegionCommand, + CreateGlobalMarkerRegionCommand, + CreateKeySignatureRegionCommand, + CreateTempoRegionCommand, + DeleteMultipleGlobalRegionsCommand, + DeleteMultipleKeySignatureRegionsCommand, + DeleteMultipleTempoRegionsCommand, + DeleteTempoRegionCommand, + DeleteKeySignatureRegionCommand, + InsertChordRegionAtBeatCommand, + MoveGlobalRegionCommand, + ResizeGlobalRegionCommand, + ResizeKeySignatureRegionCommand, + ResizeTempoRegionCommand, + UpdateChordRegionCommand, + UpdateGlobalRegionTextCommand, + UpdateKeySignatureRegionCommand, + UpdateTempoRegionCommand, +} from '../../core/commands'; +import { TIME_CONSTANTS, KEY_SIGNATURE_MAP } from '../../constants/coreConstants'; +import { + formatMidiEventLength, + formatMidiEventPosition, + MIDI_EVENT_TICKS_PER_BEAT, + parseMidiEventLength, + parseMidiEventLengthDelta, + parseMidiEventPosition, + parseMidiEventPositionDelta, +} from '../../util/midiUtil'; +import { isModifierKeyPressed } from '../../util/osUtil'; +import { parseChordSymbol } from '../../util/chordUtil'; +import { showAlert } from '../../util/dialogUtil'; +import { getSortedKeySignatureRegions, getSortedTempoRegions } from '../../util/globalTrackUtil'; + +interface GlobalEventListTabProps { + globalTracks: KGGlobalTrack[]; + selectedRegionIds: string[]; + timeSignature: { numerator: number; denominator: number }; + playheadPosition: number; + refreshProjectState: () => void; +} + +type GlobalLaneFilter = 'marker' | 'tempo' | 'key-signature' | 'chord'; +type AddGlobalItemType = GlobalLaneFilter; +type GlobalEditableColumn = 'position' | 'val' | 'length'; + +interface MarkerRowData { + id: string; + type: 'marker'; + region: KGMarkerRegion; + absoluteStartBeat: number; + durationBeats: number; +} + +interface TempoRowData { + id: string; + type: 'tempo'; + region: KGTempoRegion; + absoluteStartBeat: number; + durationBeats: number; +} + +interface KeySignatureRowData { + id: string; + type: 'key-signature'; + region: KGKeySignatureRegion; + absoluteStartBeat: number; + durationBeats: number; +} + +interface ChordRowData { + id: string; + type: 'chord'; + region: KGChordRegion; + absoluteStartBeat: number; + durationBeats: number; +} + +type GlobalRowData = MarkerRowData | TempoRowData | KeySignatureRowData | ChordRowData; + +interface GlobalEditingCell { + rowId: string; + column: GlobalEditableColumn; + value: string; +} + +const ADD_GLOBAL_ITEM_OPTIONS: Array<{ label: string; value: AddGlobalItemType }> = [ + { label: 'Marker', value: 'marker' }, + { label: 'Tempo', value: 'tempo' }, + { label: 'Key Signature', value: 'key-signature' }, + { label: 'Chord', value: 'chord' }, +]; + +const GLOBAL_TYPE_ORDER: Record = { + marker: 0, + tempo: 1, + 'key-signature': 2, + chord: 3, +}; + +const CANONICAL_KEY_SIGNATURES = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[]; + +const getRowStatus = (row: GlobalRowData): string => { + switch (row.type) { + case 'marker': + return 'Marker'; + case 'tempo': + return 'Tempo'; + case 'key-signature': + return 'Key Signature'; + case 'chord': + return 'Chord'; + } +}; + +const getRowValue = (row: GlobalRowData): string => { + switch (row.type) { + case 'marker': + return row.region.getName(); + case 'tempo': + return row.region.getBpm().toString(); + case 'key-signature': + return row.region.getKeySignature(); + case 'chord': + return row.region.getSymbol(); + } +}; + +const findRegionRowType = (region: KGGlobalRegion): GlobalRowData['type'] | null => { + if (region instanceof KGMarkerRegion) return 'marker'; + if (region instanceof KGTempoRegion) return 'tempo'; + if (region instanceof KGKeySignatureRegion) return 'key-signature'; + if (region instanceof KGChordRegion) return 'chord'; + return null; +}; + +const buildValueValidationMessage = (type: GlobalRowData['type']): string => { + switch (type) { + case 'marker': + return 'Please enter a marker label. Expected a non-empty text label. Example: Intro'; + case 'tempo': + return `Please enter a BPM value using digits only. Expected a whole number between ${TIME_CONSTANTS.MIN_BPM + 1} and ${TIME_CONSTANTS.MAX_BPM - 1}. Example: 128`; + case 'key-signature': + return 'Please enter one exact key signature name. Expected a canonical value such as "C major" or "F# minor". Example: F# minor'; + case 'chord': + return 'Please enter a valid chord symbol. Expected a chord representation the app can parse. Example: Bm7b5'; + } +}; + +const GlobalEventListTab: React.FC = ({ + globalTracks, + selectedRegionIds, + timeSignature, + playheadPosition, + refreshProjectState, +}) => { + const [showMarkers, setShowMarkers] = useState(true); + const [showTempo, setShowTempo] = useState(true); + const [showKeySignature, setShowKeySignature] = useState(true); + const [showChords, setShowChords] = useState(true); + const [addGlobalItemType, setAddGlobalItemType] = useState('marker'); + const [editingCell, setEditingCell] = useState(null); + const rangeAnchorRowIdRef = useRef(null); + const editInputRef = useRef(null); + const suppressBlurCommitRef = useRef(false); + const pendingSingleClickSelectionRef = useRef(null); + + const markerTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Marker) ?? null; + const tempoTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Tempo) ?? null; + const signatureTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Signature) ?? null; + const chordTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Chord) ?? null; + + const markerRows = useMemo(() => ( + showMarkers && markerTrack + ? markerTrack.getRegions().filter((region): region is KGMarkerRegion => region instanceof KGMarkerRegion).map(region => ({ + id: region.getId(), + type: 'marker', + region, + absoluteStartBeat: region.getStartFromBeat(), + durationBeats: region.getLength(), + })) + : [] + ), [globalTracks, markerTrack, showMarkers]); + + const tempoRows = useMemo(() => ( + showTempo && tempoTrack + ? getSortedTempoRegions(tempoTrack, timeSignature.numerator).map(region => ({ + id: region.getId(), + type: 'tempo', + region, + absoluteStartBeat: region.getStartFromBeat(), + durationBeats: region.getLength(), + })) + : [] + ), [globalTracks, showTempo, tempoTrack, timeSignature.numerator]); + + const keySignatureRows = useMemo(() => ( + showKeySignature && signatureTrack + ? getSortedKeySignatureRegions(signatureTrack, timeSignature.numerator).map(region => ({ + id: region.getId(), + type: 'key-signature', + region, + absoluteStartBeat: region.getStartFromBeat(), + durationBeats: region.getLength(), + })) + : [] + ), [globalTracks, showKeySignature, signatureTrack, timeSignature.numerator]); + + const chordRows = useMemo(() => ( + showChords && chordTrack + ? chordTrack.getRegions().filter((region): region is KGChordRegion => region instanceof KGChordRegion).map(region => ({ + id: region.getId(), + type: 'chord', + region, + absoluteStartBeat: region.getStartFromBeat(), + durationBeats: region.getLength(), + })) + : [] + ), [chordTrack, globalTracks, showChords]); + + const globalRows: GlobalRowData[] = useMemo(() => ( + [...markerRows, ...tempoRows, ...keySignatureRows, ...chordRows].sort((a, b) => { + if (a.absoluteStartBeat !== b.absoluteStartBeat) { + return a.absoluteStartBeat - b.absoluteStartBeat; + } + const typeDelta = GLOBAL_TYPE_ORDER[a.type] - GLOBAL_TYPE_ORDER[b.type]; + if (typeDelta !== 0) { + return typeDelta; + } + return a.id.localeCompare(b.id); + }) + ), [chordRows, keySignatureRows, markerRows, tempoRows]); + + const visibleRowIds = new Set(globalRows.map(row => row.id)); + const selectedRowIdSet = new Set(selectedRegionIds.filter(regionId => visibleRowIds.has(regionId))); + const visibleSelectedRows = globalRows.filter(row => selectedRowIdSet.has(row.id)); + + React.useEffect(() => { + if (editingCell) { + editInputRef.current?.focus(); + editInputRef.current?.select(); + } + }, [editingCell?.rowId, editingCell?.column]); + + React.useEffect(() => ( + () => { + if (pendingSingleClickSelectionRef.current !== null) { + window.clearTimeout(pendingSingleClickSelectionRef.current); + } + } + ), []); + + const clearPendingSingleClickSelection = () => { + if (pendingSingleClickSelectionRef.current !== null) { + window.clearTimeout(pendingSingleClickSelectionRef.current); + pendingSingleClickSelectionRef.current = null; + } + }; + + const commitSelection = (nextSelectedIds: Set) => { + const core = KGCore.instance(); + const selectedRegions = globalRows + .filter(row => nextSelectedIds.has(row.id)) + .map(row => row.region); + const previouslySelectedRegions = core.getSelectedItems().filter(item => item instanceof KGRegion); + + globalTracks.forEach(track => { + track.getRegions().forEach(region => { + if (!(region instanceof KGGlobalRegion)) { + return; + } + if (nextSelectedIds.has(region.getId())) { + region.select(); + } else if (visibleRowIds.has(region.getId())) { + region.deselect(); + } + }); + }); + + if (previouslySelectedRegions.length > 0) { + core.removeSelectedItems(previouslySelectedRegions); + } + if (selectedRegions.length > 0) { + core.addSelectedItems(selectedRegions); + } + }; + + const getTargetRowsForEdit = (row: GlobalRowData, column: GlobalEditableColumn): GlobalRowData[] => { + if (!selectedRowIdSet.has(row.id)) { + return [row]; + } + + const sameTypeRows = globalRows.filter(candidate => ( + candidate.type === row.type && selectedRowIdSet.has(candidate.id) + )); + + if (sameTypeRows.length <= 1) { + return [row]; + } + + if (column === 'val') { + return sameTypeRows; + } + + if (row.type === 'marker' || row.type === 'chord') { + return sameTypeRows; + } + + return [row]; + }; + + const startEditingCell = (rowId: string, column: GlobalEditableColumn, value: string) => { + clearPendingSingleClickSelection(); + setEditingCell({ rowId, column, value }); + }; + + const cancelEditingCell = () => { + setEditingCell(null); + }; + + const selectCreatedOrExistingRegion = (region: KGGlobalRegion) => { + rangeAnchorRowIdRef.current = region.getId(); + commitSelection(new Set([region.getId()])); + const rowType = findRegionRowType(region); + if (!rowType) { + return; + } + const value = region instanceof KGMarkerRegion + ? region.getName() + : region instanceof KGTempoRegion + ? region.getBpm().toString() + : region instanceof KGKeySignatureRegion + ? region.getKeySignature() + : region instanceof KGChordRegion + ? region.getSymbol() + : region.getName(); + setEditingCell({ rowId: region.getId(), column: 'val', value }); + }; + + const commitEditingCell = async () => { + if (!editingCell) return; + + const row = globalRows.find(candidate => candidate.id === editingCell.rowId); + if (!row) { + setEditingCell(null); + return; + } + + const trimmedValue = editingCell.value.trim(); + const isDeltaEdit = trimmedValue.startsWith('+') || trimmedValue.startsWith('-'); + const targetRows = getTargetRowsForEdit(row, editingCell.column); + + try { + if (editingCell.column === 'position') { + if (isDeltaEdit) { + const parsed = parseMidiEventPositionDelta(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + + for (const targetRow of targetRows) { + const nextBeat = targetRow.absoluteStartBeat + parsed.deltaBeats; + if (nextBeat < 0) { + await showAlert('Please enter a position at or after the start of the project. Expected a non-negative location. Example: 1 1 0'); + return; + } + } + + for (const targetRow of targetRows) { + if (targetRow.type === 'marker' || targetRow.type === 'chord') { + KGCore.instance().executeCommand(new MoveGlobalRegionCommand(targetRow.id, Math.round(targetRow.absoluteStartBeat + parsed.deltaBeats))); + } else if (targetRow.type === 'tempo') { + KGCore.instance().executeCommand(new ResizeTempoRegionCommand( + targetRow.id, + 'start', + Math.round((targetRow.absoluteStartBeat + parsed.deltaBeats) / timeSignature.numerator) + )); + } else { + KGCore.instance().executeCommand(new ResizeKeySignatureRegionCommand( + targetRow.id, + 'start', + Math.round((targetRow.absoluteStartBeat + parsed.deltaBeats) / timeSignature.numerator) + )); + } + } + } else { + const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + if (parsed.absoluteBeat < 0) { + await showAlert('Please enter a position at or after the start of the project. Expected a non-negative location. Example: 1 1 0'); + return; + } + + for (const targetRow of targetRows) { + if (targetRow.type === 'marker' || targetRow.type === 'chord') { + KGCore.instance().executeCommand(new MoveGlobalRegionCommand(targetRow.id, Math.round(parsed.absoluteBeat))); + } else if (targetRow.type === 'tempo') { + KGCore.instance().executeCommand(new ResizeTempoRegionCommand( + targetRow.id, + 'start', + Math.round(parsed.absoluteBeat / timeSignature.numerator) + )); + } else { + KGCore.instance().executeCommand(new ResizeKeySignatureRegionCommand( + targetRow.id, + 'start', + Math.round(parsed.absoluteBeat / timeSignature.numerator) + )); + } + } + } + } + + if (editingCell.column === 'val') { + if (row.type === 'marker') { + const normalized = trimmedValue.replace(/\r?\n/g, ' ').trim(); + if (!normalized) { + await showAlert(buildValueValidationMessage('marker')); + return; + } + + for (const targetRow of targetRows) { + if (targetRow.type === 'marker' && targetRow.region.getName() !== normalized) { + KGCore.instance().executeCommand(new UpdateGlobalRegionTextCommand(targetRow.id, normalized)); + } + } + } else if (row.type === 'tempo') { + if (!/^\d+$/.test(trimmedValue)) { + await showAlert(buildValueValidationMessage('tempo')); + return; + } + const nextBpm = parseInt(trimmedValue, 10); + if ( + Number.isNaN(nextBpm) + || nextBpm <= TIME_CONSTANTS.MIN_BPM + || nextBpm >= TIME_CONSTANTS.MAX_BPM + ) { + await showAlert(buildValueValidationMessage('tempo')); + return; + } + + for (const targetRow of targetRows) { + if (targetRow.type === 'tempo' && targetRow.region.getBpm() !== nextBpm) { + KGCore.instance().executeCommand(new UpdateTempoRegionCommand(targetRow.id, nextBpm)); + } + } + } else if (row.type === 'key-signature') { + if (!CANONICAL_KEY_SIGNATURES.includes(trimmedValue as KeySignature)) { + await showAlert(buildValueValidationMessage('key-signature')); + return; + } + const keySignature = trimmedValue as KeySignature; + for (const targetRow of targetRows) { + if (targetRow.type === 'key-signature' && targetRow.region.getKeySignature() !== keySignature) { + KGCore.instance().executeCommand(new UpdateKeySignatureRegionCommand(targetRow.id, keySignature)); + } + } + } else if (parseChordSymbol(trimmedValue) === null) { + await showAlert(buildValueValidationMessage('chord')); + return; + } else { + for (const targetRow of targetRows) { + if (targetRow.type === 'chord' && targetRow.region.getSymbol() !== trimmedValue) { + KGCore.instance().executeCommand(new UpdateChordRegionCommand(targetRow.id, trimmedValue)); + } + } + } + } + + if (editingCell.column === 'length') { + if (isDeltaEdit) { + const parsed = parseMidiEventLengthDelta(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + + for (const targetRow of targetRows) { + if (targetRow.durationBeats + parsed.deltaBeats <= 0) { + await showAlert('Please enter a positive length. Expected a duration greater than zero. Example: 4 0'); + return; + } + } + + for (const targetRow of targetRows) { + if (targetRow.type === 'marker' || targetRow.type === 'chord') { + KGCore.instance().executeCommand(new ResizeGlobalRegionCommand( + targetRow.id, + 'end', + Math.round(targetRow.absoluteStartBeat + targetRow.durationBeats + parsed.deltaBeats) + )); + } else if (targetRow.type === 'tempo') { + KGCore.instance().executeCommand(new ResizeTempoRegionCommand( + targetRow.id, + 'end', + Math.round((targetRow.absoluteStartBeat + targetRow.durationBeats + parsed.deltaBeats) / timeSignature.numerator) + )); + } else { + KGCore.instance().executeCommand(new ResizeKeySignatureRegionCommand( + targetRow.id, + 'end', + Math.round((targetRow.absoluteStartBeat + targetRow.durationBeats + parsed.deltaBeats) / timeSignature.numerator) + )); + } + } + } else { + const parsed = parseMidiEventLength(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + if (parsed.duration <= 0) { + await showAlert('Please enter a positive length. Expected a duration greater than zero. Example: 4 0'); + return; + } + + for (const targetRow of targetRows) { + if (targetRow.type === 'marker' || targetRow.type === 'chord') { + KGCore.instance().executeCommand(new ResizeGlobalRegionCommand( + targetRow.id, + 'end', + Math.round(targetRow.absoluteStartBeat + parsed.duration) + )); + } else if (targetRow.type === 'tempo') { + KGCore.instance().executeCommand(new ResizeTempoRegionCommand( + targetRow.id, + 'end', + Math.round((targetRow.absoluteStartBeat + parsed.duration) / timeSignature.numerator) + )); + } else { + KGCore.instance().executeCommand(new ResizeKeySignatureRegionCommand( + targetRow.id, + 'end', + Math.round((targetRow.absoluteStartBeat + parsed.duration) / timeSignature.numerator) + )); + } + } + } + } + } catch (error) { + console.error('Error editing global event list cell:', error); + } + + refreshProjectState(); + setEditingCell(null); + }; + + const handleRowClick = (rowId: string, rowIndex: number, event: React.MouseEvent) => { + event.stopPropagation(); + if (editingCell) return; + + const isModifierPressed = isModifierKeyPressed(event); + const nextSelectedIds = new Set(selectedRowIdSet); + const isAlreadySelected = selectedRowIdSet.has(rowId); + const hasMultiSelection = selectedRowIdSet.size > 1; + + if (event.shiftKey) { + clearPendingSingleClickSelection(); + const anchorIndex = globalRows.findIndex(row => row.id === rangeAnchorRowIdRef.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(globalRows[index].id); + } + } else if (isModifierPressed) { + clearPendingSingleClickSelection(); + if (nextSelectedIds.has(rowId)) nextSelectedIds.delete(rowId); + else nextSelectedIds.add(rowId); + rangeAnchorRowIdRef.current = rowId; + } else { + if (isAlreadySelected && hasMultiSelection) { + clearPendingSingleClickSelection(); + pendingSingleClickSelectionRef.current = window.setTimeout(() => { + const delayedSelection = new Set([rowId]); + rangeAnchorRowIdRef.current = rowId; + commitSelection(delayedSelection); + pendingSingleClickSelectionRef.current = null; + }, 220); + return; + } + + clearPendingSingleClickSelection(); + nextSelectedIds.clear(); + nextSelectedIds.add(rowId); + rangeAnchorRowIdRef.current = rowId; + } + + if (event.shiftKey && rangeAnchorRowIdRef.current === null) { + rangeAnchorRowIdRef.current = rowId; + } + + commitSelection(nextSelectedIds); + }; + + const handleTableBackgroundMouseDown = (event: React.MouseEvent) => { + event.stopPropagation(); + if (event.target !== event.currentTarget) return; + clearPendingSingleClickSelection(); + rangeAnchorRowIdRef.current = null; + commitSelection(new Set()); + }; + + const handleEditInputKeyDown = async (event: React.KeyboardEvent) => { + event.stopPropagation(); + + if (event.key === 'Enter') { + event.preventDefault(); + await commitEditingCell(); + } + + if (event.key === 'Escape') { + event.preventDefault(); + suppressBlurCommitRef.current = true; + cancelEditingCell(); + } + }; + + const handleEditInputBlur = () => { + if (suppressBlurCommitRef.current) { + suppressBlurCommitRef.current = false; + return; + } + + void commitEditingCell(); + }; + + const handleAddGlobalItem = async (event: React.MouseEvent) => { + event.stopPropagation(); + + try { + if (addGlobalItemType === 'marker') { + const startBeat = Math.max(0, Math.round(playheadPosition)); + const existing = markerRows.find(row => row.absoluteStartBeat === startBeat)?.region ?? null; + if (existing) { + selectCreatedOrExistingRegion(existing); + refreshProjectState(); + return; + } + + const command = new CreateGlobalMarkerRegionCommand(startBeat, timeSignature.numerator, 'Marker'); + KGCore.instance().executeCommand(command); + const createdRegion = command.getCreatedRegion(); + if (createdRegion) { + selectCreatedOrExistingRegion(createdRegion); + } + } else if (addGlobalItemType === 'tempo') { + const startBar = Math.max(0, Math.round(playheadPosition / timeSignature.numerator)); + const existing = tempoRows.find(row => row.region.getStartBar() === startBar)?.region ?? null; + if (existing) { + selectCreatedOrExistingRegion(existing); + refreshProjectState(); + return; + } + + const command = new CreateTempoRegionCommand(startBar); + KGCore.instance().executeCommand(command); + const createdRegion = command.getCreatedRegion(); + if (createdRegion) { + selectCreatedOrExistingRegion(createdRegion); + } + } else if (addGlobalItemType === 'key-signature') { + const startBar = Math.max(0, Math.round(playheadPosition / timeSignature.numerator)); + const existing = keySignatureRows.find(row => row.region.getStartBar() === startBar)?.region ?? null; + if (existing) { + selectCreatedOrExistingRegion(existing); + refreshProjectState(); + return; + } + + const command = new CreateKeySignatureRegionCommand(startBar); + KGCore.instance().executeCommand(command); + const createdRegion = command.getCreatedRegion(); + if (createdRegion) { + selectCreatedOrExistingRegion(createdRegion); + } + } else { + const startBeat = Math.max(0, Math.round(playheadPosition)); + const exactRegion = chordRows.find(row => row.absoluteStartBeat === startBeat)?.region ?? null; + if (exactRegion) { + selectCreatedOrExistingRegion(exactRegion); + refreshProjectState(); + return; + } + + const occupiedRegion = chordRows.find(row => ( + startBeat > row.absoluteStartBeat && startBeat < row.absoluteStartBeat + row.durationBeats + ))?.region ?? null; + const command = occupiedRegion + ? new InsertChordRegionAtBeatCommand(startBeat, 'C') + : new CreateChordRegionCommand(startBeat, timeSignature.numerator, 'C'); + KGCore.instance().executeCommand(command); + const createdRegion = command.getCreatedRegion(); + if (createdRegion) { + selectCreatedOrExistingRegion(createdRegion); + } + } + } catch (error) { + console.error('Error adding global event list item:', error); + } + + refreshProjectState(); + }; + + const handleDeleteSelectedRows = async (event: React.MouseEvent) => { + event.stopPropagation(); + if (visibleSelectedRows.length === 0) return; + + const markerIds = visibleSelectedRows.filter((row): row is MarkerRowData => row.type === 'marker').map(row => row.id); + const chordIds = visibleSelectedRows.filter((row): row is ChordRowData => row.type === 'chord').map(row => row.id); + const tempoIds = visibleSelectedRows.filter((row): row is TempoRowData => row.type === 'tempo').map(row => row.id); + const keySignatureIds = visibleSelectedRows.filter((row): row is KeySignatureRowData => row.type === 'key-signature').map(row => row.id); + + if (markerIds.length > 0 || chordIds.length > 0) { + KGCore.instance().executeCommand(new DeleteMultipleGlobalRegionsCommand([...markerIds, ...chordIds])); + } + if (tempoIds.length === 1) { + KGCore.instance().executeCommand(new DeleteTempoRegionCommand(tempoIds[0])); + } else if (tempoIds.length > 1) { + KGCore.instance().executeCommand(new DeleteMultipleTempoRegionsCommand(tempoIds)); + } + if (keySignatureIds.length === 1) { + KGCore.instance().executeCommand(new DeleteKeySignatureRegionCommand(keySignatureIds[0])); + } else if (keySignatureIds.length > 1) { + KGCore.instance().executeCommand(new DeleteMultipleKeySignatureRegionsCommand(keySignatureIds)); + } + + rangeAnchorRowIdRef.current = null; + refreshProjectState(); + }; + + return ( + <> +
+ + + + +
+ +
+
+ + setAddGlobalItemType(value as AddGlobalItemType)} + label="Add" + buttonClassName="event-list-type-button" + showValueAsLabel + /> +
+ +
+ +
+
+ +
+ + + + + + + + + + + {globalRows.map((row, index) => { + const positionText = formatMidiEventPosition(row.absoluteStartBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); + const statusText = getRowStatus(row); + const valText = getRowValue(row); + const lengthText = formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT); + const isEditingPosition = editingCell?.rowId === row.id && editingCell.column === 'position'; + const isEditingVal = editingCell?.rowId === row.id && editingCell.column === 'val'; + const isEditingLength = editingCell?.rowId === row.id && editingCell.column === 'length'; + + return ( + handleRowClick(row.id, index, event)} + onDoubleClick={(event) => { + event.stopPropagation(); + clearPendingSingleClickSelection(); + }} + > + + + + + + ); + })} + +
PositionStatusValLength/Info
{ event.stopPropagation(); startEditingCell(row.id, 'position', positionText); }}> + {isEditingPosition ? ( + setEditingCell({ ...editingCell, value: event.target.value })} + onBlur={handleEditInputBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { void handleEditInputKeyDown(event); }} + /> + ) : positionText} + {statusText} { event.stopPropagation(); startEditingCell(row.id, 'val', valText); }}> + {isEditingVal ? ( + setEditingCell({ ...editingCell, value: event.target.value })} + onBlur={handleEditInputBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { void handleEditInputKeyDown(event); }} + /> + ) : valText} + { event.stopPropagation(); startEditingCell(row.id, 'length', lengthText); }}> + {isEditingLength ? ( + setEditingCell({ ...editingCell, value: event.target.value })} + onBlur={handleEditInputBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { void handleEditInputKeyDown(event); }} + /> + ) : lengthText} +
+
+ + ); +}; + +export default GlobalEventListTab;