feat: added Global tab to Event List panel

This commit is contained in:
Xiaohan-Tian
2026-05-26 19:23:18 -07:00
parent 395032390a
commit 9ed3e57907
4 changed files with 1101 additions and 8 deletions
+5 -1
View File
@@ -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;
}
user-select: text;
-webkit-user-select: text;
}
+182 -4
View File
@@ -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<typeof midiTrack | typeof audioTrack>;
globalTracks: Array<typeof markerTrack | typeof tempoTrack | typeof signatureTrack | typeof chordTrack>;
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<KGRegion | KGMidiNote | KGMidiPitchBend | KGMidiControllerEvent | KGTrackAutomationPoint> = [];
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(<EventListPanel isVisible={true} />);
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(<EventListPanel isVisible={true} />);
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(<EventListPanel isVisible={true} />);
fireEvent.click(screen.getByRole('button', { name: 'Global' }));
storeState.playheadPosition = 5.6;
rerender(<EventListPanel isVisible={true} />);
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(<EventListPanel isVisible={true} />);
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(<EventListPanel isVisible={true} />);
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(<EventListPanel isVisible={true} />);
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(<EventListPanel isVisible={true} />);
fireEvent.click(screen.getByRole('button', { name: 'Global' }));
fireEvent.click(screen.getByText('Intro').closest('tr')!);
rerender(<EventListPanel isVisible={true} />);
fireEvent.click(screen.getByText('120').closest('tr')!, { ctrlKey: true });
rerender(<EventListPanel isVisible={true} />);
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);
});
});
});
+28 -3
View File
@@ -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<EventListPanelProps> = ({ isVisible }) => {
const { tracks, activeRegionId, selectedRegionIds, selectedTrackId } = useProjectStore();
const {
tracks,
globalTracks,
activeRegionId,
selectedRegionIds,
selectedTrackId,
timeSignature,
playheadPosition,
refreshProjectState,
} = useProjectStore();
const [scopeTab, setScopeTab] = useState<ScopeTab>('region');
const resolvedRegionId = selectedRegionIds.length > 1
@@ -63,13 +73,28 @@ const EventListPanel: React.FC<EventListPanelProps> = ({ isVisible }) => {
>
Track
</button>
<button
className={`event-list-scope-tab${scopeTab === 'global' ? ' active' : ''}`}
type="button"
onClick={() => setScopeTab('global')}
>
Global
</button>
</div>
<div className="event-list-panel-body">
{scopeTab === 'region' ? (
<RegionEventListTab activeMidiRegion={activeMidiRegion} parentTrack={parentTrack} />
) : (
) : scopeTab === 'track' ? (
<TrackEventListTab selectedTrack={selectedTrack} />
) : (
<GlobalEventListTab
globalTracks={globalTracks}
selectedRegionIds={selectedRegionIds}
timeSignature={timeSignature}
playheadPosition={playheadPosition}
refreshProjectState={refreshProjectState}
/>
)}
</div>
</div>
@@ -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<GlobalRowData['type'], number> = {
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<GlobalEventListTabProps> = ({
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<AddGlobalItemType>('marker');
const [editingCell, setEditingCell] = useState<GlobalEditingCell | null>(null);
const rangeAnchorRowIdRef = useRef<string | null>(null);
const editInputRef = useRef<HTMLInputElement | null>(null);
const suppressBlurCommitRef = useRef(false);
const pendingSingleClickSelectionRef = useRef<number | null>(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<MarkerRowData[]>(() => (
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<TempoRowData[]>(() => (
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<KeySignatureRowData[]>(() => (
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<ChordRowData[]>(() => (
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<string>) => {
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<HTMLTableRowElement>) => {
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<string>([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<HTMLDivElement>) => {
event.stopPropagation();
if (event.target !== event.currentTarget) return;
clearPendingSingleClickSelection();
rangeAnchorRowIdRef.current = null;
commitSelection(new Set());
};
const handleEditInputKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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 (
<>
<div className="event-list-tabs" role="tablist" aria-label="Global event filters">
<button className={`event-list-tab${showMarkers ? ' active' : ''}`} aria-pressed={showMarkers} type="button" onClick={() => setShowMarkers(value => !value)}>Marker</button>
<button className={`event-list-tab${showTempo ? ' active' : ''}`} aria-pressed={showTempo} type="button" onClick={() => setShowTempo(value => !value)}>Tempo</button>
<button className={`event-list-tab${showKeySignature ? ' active' : ''}`} aria-pressed={showKeySignature} type="button" onClick={() => setShowKeySignature(value => !value)}>Key Signature</button>
<button className={`event-list-tab${showChords ? ' active' : ''}`} aria-pressed={showChords} type="button" onClick={() => setShowChords(value => !value)}>Chord</button>
</div>
<div className="event-list-toolbar">
<div className="event-list-toolbar-group">
<button
className="event-list-add-button"
title={
addGlobalItemType === 'marker'
? 'Add marker region at playhead'
: addGlobalItemType === 'tempo'
? 'Add tempo region at playhead'
: addGlobalItemType === 'key-signature'
? 'Add key signature region at playhead'
: 'Add chord region at playhead'
}
type="button"
onClick={handleAddGlobalItem}
>
<FaPlus />
</button>
<KGDropdown
options={ADD_GLOBAL_ITEM_OPTIONS}
value={addGlobalItemType}
onChange={(value) => setAddGlobalItemType(value as AddGlobalItemType)}
label="Add"
buttonClassName="event-list-type-button"
showValueAsLabel
/>
</div>
<div className="event-list-toolbar-group event-list-toolbar-group-right">
<button
className="event-list-delete-button"
title="Delete visible selected rows"
type="button"
onClick={handleDeleteSelectedRows}
disabled={visibleSelectedRows.length === 0}
>
<FaTrash />
</button>
</div>
</div>
<div className="event-list-table-shell" onMouseDown={handleTableBackgroundMouseDown}>
<table className="event-list-table">
<thead>
<tr>
<th>Position</th>
<th>Status</th>
<th>Val</th>
<th>Length/Info</th>
</tr>
</thead>
<tbody>
{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 (
<tr
key={row.id}
className={selectedRowIdSet.has(row.id) ? 'selected' : ''}
onClick={(event) => handleRowClick(row.id, index, event)}
onDoubleClick={(event) => {
event.stopPropagation();
clearPendingSingleClickSelection();
}}
>
<td title={positionText} onDoubleClick={(event) => { event.stopPropagation(); startEditingCell(row.id, 'position', positionText); }}>
{isEditingPosition ? (
<input
ref={editInputRef}
className="event-list-cell-input"
value={editingCell.value}
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
onBlur={handleEditInputBlur}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
/>
) : positionText}
</td>
<td title={statusText}>{statusText}</td>
<td title={valText} onDoubleClick={(event) => { event.stopPropagation(); startEditingCell(row.id, 'val', valText); }}>
{isEditingVal ? (
<input
ref={editInputRef}
className="event-list-cell-input"
value={editingCell.value}
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
onBlur={handleEditInputBlur}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
/>
) : valText}
</td>
<td title={lengthText} onDoubleClick={(event) => { event.stopPropagation(); startEditingCell(row.id, 'length', lengthText); }}>
{isEditingLength ? (
<input
ref={editInputRef}
className="event-list-cell-input"
value={editingCell.value}
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
onBlur={handleEditInputBlur}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
/>
) : lengthText}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
);
};
export default GlobalEventListTab;