diff --git a/src/components/ListEventPanel.css b/src/components/ListEventPanel.css index e9ed29a..eff384e 100644 --- a/src/components/ListEventPanel.css +++ b/src/components/ListEventPanel.css @@ -45,6 +45,35 @@ flex-shrink: 0; } +.list-event-scope-tabs { + display: flex; + background-color: #2d2d2d; + border-bottom: 1px solid #3a3a3a; + flex-shrink: 0; +} + +.list-event-scope-tab { + flex: 1; + background: transparent; + color: #999; + border: none; + border-bottom: 2px solid transparent; + font-size: 11px; + font-weight: 500; + padding: 8px 4px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} + +.list-event-scope-tab:hover { + color: #ccc; +} + +.list-event-scope-tab.active { + color: #e0e0e0; + border-bottom-color: #5a9fd4; +} + .list-event-tab { flex: 1; background-color: #1e1e1e; @@ -55,7 +84,7 @@ font-weight: 500; min-height: 20px; padding: 5px 6px; - cursor: default; + cursor: pointer; transition: all 0.2s ease; } diff --git a/src/components/ListEventPanel.test.tsx b/src/components/ListEventPanel.test.tsx index d76e889..abd8212 100644 --- a/src/components/ListEventPanel.test.tsx +++ b/src/components/ListEventPanel.test.tsx @@ -1,11 +1,14 @@ import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import ListEventPanel from './ListEventPanel'; import { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent'; import { KGMidiNote } from '../core/midi/KGMidiNote'; import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend'; import { KGRegion } from '../core/region/KGRegion'; +import { KGAudioRegion } from '../core/region/KGAudioRegion'; +import { KGAudioTrack } from '../core/track/KGAudioTrack'; +import { KGTrackAutomationPoint } from '../core/track/KGTrackAutomationPoint'; import { createMockMidiControllerEvent, createMockMidiNote, @@ -14,7 +17,14 @@ import { createMockMidiTrack, } from '../test/utils/mock-data'; -const region = createMockMidiRegion({ +const clickDropdownOption = (label: string) => { + const option = Array.from(document.querySelectorAll('.quant-option')) + .find(element => element.textContent?.trim() === label); + expect(option).toBeTruthy(); + fireEvent.click(option!); +}; + +const midiRegion = createMockMidiRegion({ id: 'region-1', trackId: '1', trackIndex: 0, @@ -25,37 +35,64 @@ const region = createMockMidiRegion({ index === 11 ? [createMockMidiControllerEvent({ id: 'cc11-1', beat: 0.75, value: 100 })] : [] )), }); -const track = createMockMidiTrack({ id: 1, regions: [region] }); +const secondMidiRegion = createMockMidiRegion({ + id: 'region-2', + trackId: '1', + trackIndex: 0, + name: 'Second Region', + startFromBeat: 12, + length: 8, +}); +const midiTrack = createMockMidiTrack({ id: 1, volume: -6, regions: [midiRegion, secondMidiRegion] }); +midiTrack.setTrackIndex(0); +midiTrack.setVolumeAutomation([ + new KGTrackAutomationPoint('vol-1', 2, -6), +]); +midiTrack.setPanAutomation([ + new KGTrackAutomationPoint('pan-1', 1, -0.5), + new KGTrackAutomationPoint('pan-2', 6, 0.25), +]); + +const audioRegion = new KGAudioRegion('audio-region-1', '2', 1, 'Audio Clip', 8, 4); +const audioTrack = new KGAudioTrack('Audio Track', 2, -3); +audioTrack.setTrackIndex(1); +audioTrack.setRegions([audioRegion]); type MockStoreState = { - tracks: typeof track[]; + tracks: Array; activeRegionId: string | null; selectedRegionIds: string[]; + selectedTrackId: string | null; timeSignature: { numerator: number; denominator: number }; selectedNoteIds: string[]; selectedPitchBendIds: string[]; selectedControllerEventIds: string[]; + selectedTrackAutomationPointIds: string[]; playheadPosition: number; updateTrack: ReturnType; refreshProjectState: ReturnType; bumpAutomationRedrawVersion: ReturnType; + bumpTrackAutomationRedrawVersion: ReturnType; }; const storeState: MockStoreState = { - tracks: [track], + tracks: [midiTrack, audioTrack], activeRegionId: 'region-1', selectedRegionIds: ['region-1'], + selectedTrackId: '1', timeSignature: { numerator: 4, denominator: 4 }, selectedNoteIds: [], selectedPitchBendIds: [], selectedControllerEventIds: [], + selectedTrackAutomationPointIds: [], playheadPosition: 4, updateTrack: vi.fn().mockResolvedValue(undefined), refreshProjectState: vi.fn(), bumpAutomationRedrawVersion: vi.fn(), + bumpTrackAutomationRedrawVersion: vi.fn(), }; -let selectedItems: Array = []; +let selectedItems: Array = []; const syncStoreSelectionFromCore = () => { storeState.selectedRegionIds = selectedItems @@ -70,91 +107,273 @@ const syncStoreSelectionFromCore = () => { storeState.selectedControllerEventIds = selectedItems .filter(item => item instanceof KGMidiControllerEvent) .map(item => item.getId()); + storeState.selectedTrackAutomationPointIds = selectedItems + .filter(item => item instanceof KGTrackAutomationPoint) + .map(item => item.getId()); }; vi.mock('../stores/projectStore', () => ({ useProjectStore: () => storeState, })); +vi.mock('../util/dialogUtil', () => ({ + showAlert: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../core/KGCore', () => ({ KGCore: { instance: vi.fn(() => ({ getSelectedItems: () => selectedItems, + addSelectedItem: (item: typeof selectedItems[number]) => { + selectedItems = selectedItems.filter(candidate => candidate.getId() !== item.getId()); + selectedItems.push(item); + syncStoreSelectionFromCore(); + }, addSelectedItems: (items: typeof selectedItems) => { const nextIds = new Set(items.map(item => item.getId())); selectedItems = [...selectedItems.filter(item => !nextIds.has(item.getId())), ...items]; syncStoreSelectionFromCore(); }, + removeSelectedItem: (item: typeof selectedItems[number]) => { + selectedItems = selectedItems.filter(candidate => candidate.getId() !== item.getId()); + syncStoreSelectionFromCore(); + }, removeSelectedItems: (items: typeof selectedItems) => { const removedIds = new Set(items.map(item => item.getId())); selectedItems = selectedItems.filter(item => !removedIds.has(item.getId())); syncStoreSelectionFromCore(); }, + clearSelectedItems: () => { + selectedItems = []; + syncStoreSelectionFromCore(); + }, + executeCommand: (command: { execute: () => void }) => { + command.execute(); + syncStoreSelectionFromCore(); + }, + getCurrentProject: () => ({ + getTracks: () => storeState.tracks, + }), })), }, })); describe('ListEventPanel', () => { beforeEach(() => { - selectedItems = [region]; - region.select(); - region.getNotes().forEach(note => note.deselect()); - region.getPitchBends().forEach(pitchBend => pitchBend.deselect()); - region.getControllerEventsByType().forEach(events => events.forEach(controllerEvent => controllerEvent.deselect())); + selectedItems = [midiRegion]; + midiRegion.select(); + secondMidiRegion.deselect(); + audioRegion.deselect(); + + midiRegion.setStartFromBeat(4); + midiRegion.setLength(4); + secondMidiRegion.setStartFromBeat(12); + secondMidiRegion.setLength(8); + midiTrack.setRegions([midiRegion, secondMidiRegion]); + + midiTrack.setVolumeAutomation([new KGTrackAutomationPoint('vol-1', 2, -6)]); + midiTrack.setPanAutomation([ + new KGTrackAutomationPoint('pan-1', 1, -0.5), + new KGTrackAutomationPoint('pan-2', 6, 0.25), + ]); + + midiRegion.getNotes().forEach(note => note.deselect()); + midiRegion.getPitchBends().forEach(pitchBend => pitchBend.deselect()); + midiRegion.getControllerEventsByType().forEach(events => events.forEach(controllerEvent => controllerEvent.deselect())); + midiTrack.getVolumeAutomation().forEach(point => point.deselect()); + midiTrack.getPanAutomation().forEach(point => point.deselect()); + storeState.activeRegionId = 'region-1'; storeState.selectedRegionIds = ['region-1']; + storeState.selectedTrackId = '1'; storeState.selectedNoteIds = []; storeState.selectedPitchBendIds = []; storeState.selectedControllerEventIds = []; + storeState.selectedTrackAutomationPointIds = []; + storeState.playheadPosition = 4; storeState.updateTrack.mockClear(); storeState.refreshProjectState.mockClear(); storeState.bumpAutomationRedrawVersion.mockClear(); + storeState.bumpTrackAutomationRedrawVersion.mockClear(); }); - it('renders note and pitch bend rows and toggles them independently', () => { + it('defaults to Region tab and preserves existing event rows', () => { render(); - expect(screen.getByRole('button', { name: 'Note' })).toBeInTheDocument(); - expect(screen.getByTitle('Delete visible selected rows')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Region' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Track' })).toBeInTheDocument(); expect(screen.getByText('Pitch Bend')).toBeInTheDocument(); - expect(screen.getAllByText('Note').length).toBeGreaterThan(0); expect(screen.getByText('Raw 12288 | 0.500 | 1.00 st')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Pitch Bends' })); - expect(screen.queryByText('Pitch Bend')).not.toBeInTheDocument(); - expect(screen.getByText('C4')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Notes' })); - expect(screen.queryByText('C4')).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Pitch Bends' })); - expect(screen.getByText('Pitch Bend')).toBeInTheDocument(); }); - it('keeps the region selected while selecting rows', () => { - const { rerender } = render(); + it('switches to Track tab and lists selected track regions', () => { + render(); - fireEvent.click(screen.getByText('C4').closest('tr')!); - rerender(); + fireEvent.click(screen.getByRole('button', { name: 'Track' })); - expect(storeState.selectedRegionIds).toEqual(['region-1']); - expect(storeState.selectedNoteIds).toEqual(['note-1']); - expect(screen.queryByText('Please select a MIDI region, or open one in the Piano Roll, to view its event list.')).not.toBeInTheDocument(); - expect(screen.getByText('C4').closest('tr')).toHaveClass('selected'); + expect(screen.getByRole('button', { name: 'Regions' })).toBeInTheDocument(); + expect(screen.getByText('Second Region')).toBeInTheDocument(); + expect(screen.getAllByText('MIDI')).toHaveLength(2); + expect(screen.getByText('-6.0dB')).toBeInTheDocument(); + expect(screen.getByText('-32')).toBeInTheDocument(); }); - it('supports additive row selection without dropping the owning region', () => { + it('toggles track filters independently', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.click(screen.getByRole('button', { name: 'Regions' })); + + expect(screen.queryByText('Second Region')).not.toBeInTheDocument(); + expect(screen.getByText('-6.0dB')).toBeInTheDocument(); + expect(screen.getByText('-32')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Volume' })); + + expect(screen.queryByText('-6.0dB')).not.toBeInTheDocument(); + expect(screen.getByText('-32')).toBeInTheDocument(); + }); + + it('shows track empty state when no track is selected', () => { + storeState.selectedTrackId = null; + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + + expect(screen.getByText('Please select a track to view regions and track automation.')).toBeInTheDocument(); + }); + + it('syncs Track Regions row selection to selectedRegionIds', () => { const { rerender } = render(); - fireEvent.click(screen.getByText('C4').closest('tr')!); - rerender(); - fireEvent.click(screen.getByText('Pitch Bend').closest('tr')!, { shiftKey: true }); + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.click(screen.getByText('Second Region').closest('tr')!); rerender(); - expect(storeState.selectedRegionIds).toEqual(['region-1']); - expect(storeState.selectedNoteIds).toEqual(['note-1']); - expect(storeState.selectedPitchBendIds).toEqual(['bend-1']); - expect(screen.getByText('C4').closest('tr')).toHaveClass('selected'); - expect(screen.getByText('Raw 12288 | 0.500 | 1.00 st').closest('tr')).toHaveClass('selected'); + expect(storeState.selectedRegionIds).toEqual(['region-2']); + expect(screen.getByText('Second Region').closest('tr')).toHaveClass('selected'); + }); + + it('syncs Track Volume row selection to selectedTrackAutomationPointIds', () => { + const { rerender } = render(); + + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.click(screen.getByText('-6.0dB').closest('tr')!); + rerender(); + + expect(storeState.selectedTrackAutomationPointIds).toEqual(['vol-1']); + expect(screen.getByText('-6.0dB').closest('tr')).toHaveClass('selected'); + }); + + it('syncs Track Pan row selection to selectedTrackAutomationPointIds', () => { + const { rerender } = render(); + + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.click(screen.getByText('-32').closest('tr')!); + rerender(); + + expect(storeState.selectedTrackAutomationPointIds).toEqual(['pan-1']); + }); + + it('hides MIDI Region add option for audio tracks', () => { + storeState.selectedTrackId = '2'; + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.click(screen.getAllByRole('button', { name: 'Volume' })[1]); + + expect(screen.queryByText('MIDI Region')).not.toBeInTheDocument(); + expect( + Array.from(document.querySelectorAll('.quant-option')).some(element => element.textContent?.trim() === 'Pan') + ).toBe(true); + }); + + it('creates a 1-bar MIDI region at the playhead from the Track tab', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.click(screen.getByTitle('Add MIDI region at playhead')); + + await waitFor(() => expect(midiTrack.getRegions()).toHaveLength(3)); + + const createdRegion = midiTrack.getRegions()[2]; + expect(createdRegion.getStartFromBeat()).toBe(4); + expect(createdRegion.getLength()).toBe(4); + }); + + it('creates a volume automation point using the track base volume', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.click(screen.getByTitle('Add MIDI region at playhead')); + fireEvent.click(screen.getByRole('button', { name: 'MIDI Region' })); + clickDropdownOption('Volume'); + fireEvent.click(screen.getByTitle('Add volume automation point at playhead')); + + await waitFor(() => expect(midiTrack.getVolumeAutomation()).toHaveLength(2)); + + const createdPoint = midiTrack.getVolumeAutomation().find(point => point.getBeat() === 4); + expect(createdPoint?.getValue()).toBe(-6); + }); + + it('creates a pan automation point using the nearest earlier pan value or zero', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.click(screen.getByTitle('Add MIDI region at playhead')); + fireEvent.click(screen.getByRole('button', { name: 'MIDI Region' })); + clickDropdownOption('Pan'); + fireEvent.click(screen.getByTitle('Add pan automation point at playhead')); + + await waitFor(() => expect(midiTrack.getPanAutomation()).toHaveLength(3)); + + const createdPoint = midiTrack.getPanAutomation().find(point => point.getBeat() === 4); + expect(createdPoint?.getValue()).toBe(-0.5); + }); + + it('deletes selected track automation points from the Track tab', async () => { + const { rerender } = render(); + + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.click(screen.getByText('-6.0dB').closest('tr')!); + rerender(); + fireEvent.click(screen.getByTitle('Delete visible selected rows')); + + await waitFor(() => expect(midiTrack.getVolumeAutomation()).toHaveLength(0)); + }); + + it('edits track region position and length inline', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.doubleClick(screen.getByText('4 1 0')); + const positionInput = screen.getByDisplayValue('4 1 0'); + fireEvent.change(positionInput, { target: { value: '3 1 0' } }); + fireEvent.keyDown(positionInput, { key: 'Enter' }); + + await waitFor(() => expect(secondMidiRegion.getStartFromBeat()).toBe(8)); + + fireEvent.doubleClick(screen.getByText('8 0')); + const lengthInput = screen.getByDisplayValue('8 0'); + fireEvent.change(lengthInput, { target: { value: '4 0' } }); + fireEvent.keyDown(lengthInput, { key: 'Enter' }); + + await waitFor(() => expect(secondMidiRegion.getLength()).toBe(4)); + }); + + it('edits track automation position and value inline', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.doubleClick(screen.getByText('1 3 0')); + const positionInput = screen.getByDisplayValue('1 3 0'); + fireEvent.change(positionInput, { target: { value: '2 1 0' } }); + fireEvent.keyDown(positionInput, { key: 'Enter' }); + + await waitFor(() => expect(midiTrack.getVolumeAutomation()[0].getBeat()).toBe(4)); + + fireEvent.doubleClick(screen.getByText('-6.0dB')); + const valueInput = screen.getByDisplayValue('-6.0dB'); + fireEvent.change(valueInput, { target: { value: '-3' } }); + fireEvent.keyDown(valueInput, { key: 'Enter' }); + + await waitFor(() => expect(midiTrack.getVolumeAutomation()[0].getValue()).toBe(-3)); }); }); diff --git a/src/components/ListEventPanel.tsx b/src/components/ListEventPanel.tsx index 34e0f07..8f0fac1 100644 --- a/src/components/ListEventPanel.tsx +++ b/src/components/ListEventPanel.tsx @@ -1,212 +1,21 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import './ListEventPanel.css'; -import { FaPlus, FaTrash } 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 { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent'; -import { KGMidiNote } from '../core/midi/KGMidiNote'; -import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend'; -import { KGPianoRollState } from '../core/state/KGPianoRollState'; -import { - clampMidiControllerValue, - clampMidiPitchBendValue, - formatMidiEventLength, - formatMidiEventPosition, - MIDI_EVENT_TICKS_PER_BEAT, - MIDI_PITCH_BEND_CENTER, - MIDI_PITCH_BEND_MAX_SIGNED, - MIDI_PITCH_BEND_MIN_SIGNED, - midiPitchBendToNormalized, - midiPitchBendToSignedValue, - noteNameToPitch, - parseMidiEventLengthDelta, - parseMidiEventLength, - parseMidiEventPositionDelta, - parseMidiEventPosition, - pitchToNoteNameString, - signedPitchBendToMidiValue -} from '../util/midiUtil'; -import { isModifierKeyPressed } from '../util/osUtil'; -import { PIANO_ROLL_CONSTANTS } from '../constants'; -import { CreateMidiEventsCommand, CreateNoteCommand, DeleteMidiEventsCommand } from '../core/commands'; -import { UpdateControllerEventPropertiesCommand } from '../core/commands/note/UpdateControllerEventPropertiesCommand'; -import { UpdateNotePropertiesCommand } from '../core/commands/note/UpdateNotePropertiesCommand'; -import { UpdatePitchBendPropertiesCommand } from '../core/commands/note/UpdatePitchBendPropertiesCommand'; -import { showAlert } from '../util/dialogUtil'; +import { KGMidiTrack } from '../core/track/KGMidiTrack'; +import { KGAudioTrack } from '../core/track/KGAudioTrack'; +import RegionListEventTab from './list-event-panel/RegionListEventTab'; +import TrackListEventTab from './list-event-panel/TrackListEventTab'; interface ListEventPanelProps { isVisible: boolean; } -interface NoteRowData { - id: string; - type: 'note'; - note: KGMidiNote; - absoluteStartBeat: number; - durationBeats: number; -} - -interface PitchBendRowData { - id: string; - type: 'pitch-bend'; - pitchBend: KGMidiPitchBend; - absoluteBeat: number; -} - -interface ControllerRowData { - id: string; - type: 'controller'; - controller: number; - controllerEvent: KGMidiControllerEvent; - absoluteBeat: number; -} - -type EventRowData = NoteRowData | PitchBendRowData | ControllerRowData; -type EditableColumn = 'position' | 'num' | 'val' | 'length'; - -interface EditingCell { - eventId: string; - column: EditableColumn; - value: string; -} - -type AddEventType = 'note' | 'pitch-bend' | 'controller'; - -const ADD_EVENT_TYPE_OPTIONS = [ - { label: 'Note', value: 'note' }, - { label: 'Pitch Bend', value: 'pitch-bend' }, - { label: 'Controller', value: 'controller' }, -] as const; - -const parseVelocityInput = (raw: string): { velocity: number } | { error: string } => { - const trimmed = raw.trim(); - if (!/^\d+$/.test(trimmed)) { - return { error: 'Velocity must be an integer between 0 and 127.' }; - } - - const velocity = parseInt(trimmed, 10); - if (velocity < 0 || velocity > 127) { - return { error: 'Velocity must be between 0 and 127.' }; - } - - return { velocity }; -}; - -const parseVelocityDeltaInput = (raw: string): { delta: number } | { error: string } => { - const trimmed = raw.trim(); - if (!/^[+-]\d+$/.test(trimmed)) { - return { error: 'Use velocity delta like +10 or -5.' }; - } - - return { delta: parseInt(trimmed, 10) }; -}; - -const parseNoteNameInput = (raw: string): { pitch: number } | { error: string } => { - try { - return { pitch: noteNameToPitch(raw.trim()) }; - } catch { - return { error: 'Use note names like C3, C#3, or Cb3.' }; - } -}; - -const parsePitchDeltaInput = (raw: string): { delta: number } | { error: string } => { - const trimmed = raw.trim(); - if (!/^[+-]\d+$/.test(trimmed)) { - return { error: 'Use note delta like +2 or -1 when editing Num in delta mode.' }; - } - - return { delta: parseInt(trimmed, 10) }; -}; - -const parsePitchBendInput = (raw: string): { value: number } | { error: string } => { - const trimmed = raw.trim(); - if (!/^[+-]?\d+$/.test(trimmed)) { - return { error: `Pitch bend value must be an integer between ${MIDI_PITCH_BEND_MIN_SIGNED} and ${MIDI_PITCH_BEND_MAX_SIGNED}.` }; - } - - const signedValue = parseInt(trimmed, 10); - if (signedValue < MIDI_PITCH_BEND_MIN_SIGNED || signedValue > MIDI_PITCH_BEND_MAX_SIGNED) { - return { error: `Pitch bend value must be between ${MIDI_PITCH_BEND_MIN_SIGNED} and ${MIDI_PITCH_BEND_MAX_SIGNED}.` }; - } - - return { value: signedPitchBendToMidiValue(signedValue) }; -}; - -const parsePitchBendDeltaInput = (raw: string): { delta: number } | { error: string } => { - const trimmed = raw.trim(); - if (!/^[+-]\d+$/.test(trimmed)) { - return { error: 'Use pitch bend delta like +256 or -512.' }; - } - - return { delta: parseInt(trimmed, 10) }; -}; - -const formatPitchBendInfo = (value: number): string => { - const normalized = midiPitchBendToNormalized(value); - const semitones = normalized * 2; - return `Raw ${value} | ${normalized.toFixed(3)} | ${semitones.toFixed(2)} st`; -}; - -const parseControllerNumberInput = (raw: string): { controller: number } | { error: string } => { - const trimmed = raw.trim(); - if (!/^\d+$/.test(trimmed)) { - return { error: 'Controller number must be an integer between 0 and 127.' }; - } - - const controller = parseInt(trimmed, 10); - if (controller < 0 || controller > 127) { - return { error: 'Controller number must be between 0 and 127.' }; - } - - return { controller }; -}; - -const parseControllerValueInput = (raw: string): { value: number } | { error: string } => { - const trimmed = raw.trim(); - if (!/^\d+$/.test(trimmed)) { - return { error: 'Controller value must be an integer between 0 and 127.' }; - } - - return { value: clampMidiControllerValue(parseInt(trimmed, 10)) }; -}; - -const parseControllerValueDeltaInput = (raw: string): { delta: number } | { error: string } => { - const trimmed = raw.trim(); - if (!/^[+-]\d+$/.test(trimmed)) { - return { error: 'Use controller value delta like +10 or -5.' }; - } - - return { delta: parseInt(trimmed, 10) }; -}; +type ScopeTab = 'region' | 'track'; const ListEventPanel: React.FC = ({ isVisible }) => { - const { - tracks, - activeRegionId, - selectedRegionIds, - timeSignature, - selectedNoteIds, - selectedPitchBendIds, - selectedControllerEventIds, - playheadPosition, - updateTrack, - refreshProjectState, - bumpAutomationRedrawVersion - } = useProjectStore(); - - const [showNotes, setShowNotes] = useState(true); - const [showPitchBends, setShowPitchBends] = useState(true); - const [showControllers, setShowControllers] = useState(true); - const [quantPosition, setQuantPosition] = useState('1/8'); - const [quantLength, setQuantLength] = useState('1/8'); - const [addEventType, setAddEventType] = useState('note'); - const [editingCell, setEditingCell] = useState(null); - const rangeAnchorEventIdRef = useRef(null); - const editInputRef = useRef(null); - const suppressBlurCommitRef = useRef(false); - const pendingSingleClickSelectionRef = useRef(null); + const { tracks, activeRegionId, selectedRegionIds, selectedTrackId } = useProjectStore(); + const [scopeTab, setScopeTab] = useState('region'); const resolvedRegionId = selectedRegionIds.length > 1 ? activeRegionId @@ -214,13 +23,18 @@ const ListEventPanel: React.FC = ({ isVisible }) => { ? selectedRegionIds[0] : activeRegionId; + const selectedTrack = useMemo(() => { + const track = tracks.find(candidate => candidate.getId().toString() === selectedTrackId) ?? null; + return track instanceof KGMidiTrack || track instanceof KGAudioTrack ? track : null; + }, [tracks, selectedTrackId]); + let activeMidiRegion: KGMidiRegion | null = null; - let parentTrack = null as typeof tracks[number] | null; + let parentTrack: KGMidiTrack | null = null; if (resolvedRegionId) { for (const track of tracks) { const region = track.getRegions().find(candidate => candidate.getId() === resolvedRegionId); - if (region instanceof KGMidiRegion) { + if (region instanceof KGMidiRegion && track instanceof KGMidiTrack) { activeMidiRegion = region; parentTrack = track; break; @@ -228,976 +42,34 @@ const ListEventPanel: React.FC = ({ isVisible }) => { } } - const noteRows: NoteRowData[] = activeMidiRegion - ? activeMidiRegion.getNotes().map(note => ({ - id: note.getId(), - type: 'note', - note, - absoluteStartBeat: activeMidiRegion!.getStartFromBeat() + note.getStartBeat(), - durationBeats: note.getEndBeat() - note.getStartBeat(), - })) - : []; - - const pitchBendRows: PitchBendRowData[] = activeMidiRegion - ? activeMidiRegion.getPitchBends().map(pitchBend => ({ - id: pitchBend.getId(), - type: 'pitch-bend', - pitchBend, - absoluteBeat: activeMidiRegion!.getStartFromBeat() + pitchBend.getBeat(), - })) - : []; - - const controllerRows: ControllerRowData[] = activeMidiRegion - ? activeMidiRegion.getAllControllerEventsFlattened().map(({ controller, event }) => ({ - id: event.getId(), - type: 'controller', - controller, - controllerEvent: event, - absoluteBeat: activeMidiRegion!.getStartFromBeat() + event.getBeat(), - })) - : []; - - const eventRows: EventRowData[] = [ - ...(showNotes ? noteRows : []), - ...(showPitchBends ? pitchBendRows : []), - ...(showControllers ? controllerRows : []), - ].sort((a, b) => { - const beatDelta = (a.type === 'note' ? a.absoluteStartBeat : a.absoluteBeat) - - (b.type === 'note' ? b.absoluteStartBeat : b.absoluteBeat); - if (beatDelta !== 0) return beatDelta; - if (a.type !== b.type) return a.type === 'pitch-bend' ? -1 : 1; - return a.id.localeCompare(b.id); - }); - - const selectedNoteIdSet = new Set(selectedNoteIds); - const selectedPitchBendIdSet = new Set(selectedPitchBendIds); - const selectedControllerEventIdSet = new Set(selectedControllerEventIds); - const selectedEventIdSet = new Set([...selectedNoteIds, ...selectedPitchBendIds, ...selectedControllerEventIds]); - const visibleSelectedRows = eventRows.filter(row => selectedEventIdSet.has(row.id)); - - useEffect(() => { - if (editingCell) { - editInputRef.current?.focus(); - editInputRef.current?.select(); - } - }, [editingCell?.eventId, editingCell?.column]); - - useEffect(() => { - return () => { - if (pendingSingleClickSelectionRef.current !== null) { - window.clearTimeout(pendingSingleClickSelectionRef.current); - } - }; - }, []); - - const commitSelection = (nextSelectedIds: Set) => { - if (!activeMidiRegion || !parentTrack) return; - - const selectedEvents = eventRows - .filter(row => nextSelectedIds.has(row.id)) - .map(row => row.type === 'note' ? row.note : row.type === 'pitch-bend' ? row.pitchBend : row.controllerEvent); - const core = KGCore.instance(); - const previouslySelectedRegionEvents = core.getSelectedItems().filter(item => - (item instanceof KGMidiNote && activeMidiRegion.getNotes().some(note => note.getId() === item.getId())) || - (item instanceof KGMidiPitchBend && activeMidiRegion.getPitchBends().some(pitchBend => pitchBend.getId() === item.getId())) || - (item instanceof KGMidiControllerEvent && activeMidiRegion.getAllControllerEventsFlattened().some(({ event }) => event.getId() === item.getId())) - ); - - activeMidiRegion.getNotes().forEach(note => { - if (nextSelectedIds.has(note.getId())) note.select(); - else note.deselect(); - }); - activeMidiRegion.getPitchBends().forEach(pitchBend => { - if (nextSelectedIds.has(pitchBend.getId())) pitchBend.select(); - else pitchBend.deselect(); - }); - activeMidiRegion.getControllerEventsByType().forEach(events => { - events.forEach(controllerEvent => { - if (nextSelectedIds.has(controllerEvent.getId())) controllerEvent.select(); - else controllerEvent.deselect(); - }); - }); - - if (previouslySelectedRegionEvents.length > 0) { - core.removeSelectedItems(previouslySelectedRegionEvents); - } - if (selectedEvents.length > 0) { - core.addSelectedItems(selectedEvents); - } - - void updateTrack(parentTrack); - }; - - const clearPendingSingleClickSelection = () => { - if (pendingSingleClickSelectionRef.current !== null) { - window.clearTimeout(pendingSingleClickSelectionRef.current); - pendingSingleClickSelectionRef.current = null; - } - }; - - const startEditingCell = (eventId: string, column: EditableColumn, value: string) => { - clearPendingSingleClickSelection(); - setEditingCell({ eventId, column, value }); - }; - - const cancelEditingCell = () => { - setEditingCell(null); - }; - - const commitEditingCell = async () => { - if (!editingCell || !activeMidiRegion || !parentTrack) return; - - const row = eventRows.find(candidate => candidate.id === editingCell.eventId); - if (!row) { - setEditingCell(null); - return; - } - - const trimmedValue = editingCell.value.trim(); - const isDeltaEdit = trimmedValue.startsWith('+') || trimmedValue.startsWith('-'); - - if (row.type === 'note') { - const note = row.note; - const targetNotes = selectedNoteIdSet.has(note.getId()) && selectedNoteIds.length > 1 - ? activeMidiRegion.getNotes().filter(candidate => selectedNoteIdSet.has(candidate.getId())) - : [note]; - - const snapshots = targetNotes.map(targetNote => ({ - noteId: targetNote.getId(), - pitch: targetNote.getPitch(), - velocity: targetNote.getVelocity(), - startBeat: targetNote.getStartBeat(), - endBeat: targetNote.getEndBeat() - })); - - const updates: Array<{ noteId: string; pitch?: number; velocity?: number; startBeat?: number; endBeat?: number }> = []; - - 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 targetNote of targetNotes) { - const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat(); - const nextStartBeat = targetNote.getStartBeat() + parsed.deltaBeats; - if (nextStartBeat < 0) { - await showAlert('Position delta would move one or more notes before the start of the current MIDI region.'); - return; - } - - updates.push({ - noteId: targetNote.getId(), - startBeat: nextStartBeat, - endBeat: nextStartBeat + currentDuration - }); - } - } else { - const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); - if ('error' in parsed) { - await showAlert(parsed.error); - return; - } - - const relativeStartBeat = parsed.absoluteBeat - activeMidiRegion.getStartFromBeat(); - if (relativeStartBeat < 0) { - await showAlert('Position cannot be earlier than the start of the current MIDI region.'); - return; - } - - for (const targetNote of targetNotes) { - const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat(); - updates.push({ - noteId: targetNote.getId(), - startBeat: relativeStartBeat, - endBeat: relativeStartBeat + currentDuration - }); - } - } - } - - if (editingCell.column === 'num') { - if (isDeltaEdit) { - const parsed = parsePitchDeltaInput(trimmedValue); - if ('error' in parsed) { - await showAlert(parsed.error); - return; - } - - for (const targetNote of targetNotes) { - const nextPitch = targetNote.getPitch() + parsed.delta; - if (nextPitch < 0 || nextPitch > 127) { - await showAlert('Num delta would move one or more notes outside the MIDI pitch range 0–127.'); - return; - } - updates.push({ noteId: targetNote.getId(), pitch: nextPitch }); - } - } else { - const parsed = parseNoteNameInput(trimmedValue); - if ('error' in parsed) { - await showAlert(parsed.error); - return; - } - - for (const targetNote of targetNotes) { - updates.push({ noteId: targetNote.getId(), pitch: parsed.pitch }); - } - } - } - - if (editingCell.column === 'val') { - if (isDeltaEdit) { - const parsed = parseVelocityDeltaInput(trimmedValue); - if ('error' in parsed) { - await showAlert(parsed.error); - return; - } - - for (const targetNote of targetNotes) { - const nextVelocity = targetNote.getVelocity() + parsed.delta; - if (nextVelocity < 0 || nextVelocity > 127) { - await showAlert('Velocity delta would move one or more notes outside the valid range 0–127.'); - return; - } - updates.push({ noteId: targetNote.getId(), velocity: nextVelocity }); - } - } else { - const parsed = parseVelocityInput(trimmedValue); - if ('error' in parsed) { - await showAlert(parsed.error); - return; - } - - for (const targetNote of targetNotes) { - updates.push({ noteId: targetNote.getId(), velocity: parsed.velocity }); - } - } - } - - 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 targetNote of targetNotes) { - const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat(); - const nextDuration = currentDuration + parsed.deltaBeats; - if (nextDuration <= 0) { - await showAlert('Length delta would make one or more notes non-positive in duration.'); - return; - } - - updates.push({ - noteId: targetNote.getId(), - endBeat: targetNote.getStartBeat() + nextDuration - }); - } - } else { - const parsed = parseMidiEventLength(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT); - if ('error' in parsed) { - await showAlert(parsed.error); - return; - } - - for (const targetNote of targetNotes) { - updates.push({ - noteId: targetNote.getId(), - endBeat: targetNote.getStartBeat() + parsed.duration - }); - } - } - } - - if (updates.length === 0) { - setEditingCell(null); - return; - } - - KGCore.instance().executeCommand(new UpdateNotePropertiesCommand(activeMidiRegion.getId(), snapshots, updates)); - } else if (row.type === 'pitch-bend') { - const pitchBend = row.pitchBend; - const targetPitchBends = selectedPitchBendIdSet.has(pitchBend.getId()) && selectedPitchBendIds.length > 1 - ? activeMidiRegion.getPitchBends().filter(candidate => selectedPitchBendIdSet.has(candidate.getId())) - : [pitchBend]; - - const snapshots = targetPitchBends.map(targetPitchBend => ({ - pitchBendId: targetPitchBend.getId(), - beat: targetPitchBend.getBeat(), - value: targetPitchBend.getValue(), - })); - const updates: Array<{ pitchBendId: string; beat?: number; value?: number }> = []; - - 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 targetPitchBend of targetPitchBends) { - const nextBeat = targetPitchBend.getBeat() + parsed.deltaBeats; - if (nextBeat < 0) { - await showAlert('Position delta would move one or more pitch bends before the start of the current MIDI region.'); - return; - } - updates.push({ pitchBendId: targetPitchBend.getId(), beat: nextBeat }); - } - } else { - const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); - if ('error' in parsed) { - await showAlert(parsed.error); - return; - } - - const relativeBeat = parsed.absoluteBeat - activeMidiRegion.getStartFromBeat(); - if (relativeBeat < 0) { - await showAlert('Position cannot be earlier than the start of the current MIDI region.'); - return; - } - - for (const targetPitchBend of targetPitchBends) { - updates.push({ pitchBendId: targetPitchBend.getId(), beat: relativeBeat }); - } - } - } - - if (editingCell.column === 'val') { - if (isDeltaEdit) { - const parsed = parsePitchBendDeltaInput(trimmedValue); - if ('error' in parsed) { - await showAlert(parsed.error); - return; - } - - for (const targetPitchBend of targetPitchBends) { - const nextSignedValue = midiPitchBendToSignedValue(targetPitchBend.getValue()) + parsed.delta; - if (nextSignedValue < MIDI_PITCH_BEND_MIN_SIGNED || nextSignedValue > MIDI_PITCH_BEND_MAX_SIGNED) { - await showAlert(`Pitch bend delta would move one or more events outside the valid range ${MIDI_PITCH_BEND_MIN_SIGNED}–${MIDI_PITCH_BEND_MAX_SIGNED}.`); - return; - } - updates.push({ - pitchBendId: targetPitchBend.getId(), - value: signedPitchBendToMidiValue(nextSignedValue), - }); - } - } else { - const parsed = parsePitchBendInput(trimmedValue); - if ('error' in parsed) { - await showAlert(parsed.error); - return; - } - - for (const targetPitchBend of targetPitchBends) { - updates.push({ pitchBendId: targetPitchBend.getId(), value: clampMidiPitchBendValue(parsed.value) }); - } - } - } - - if (updates.length === 0) { - setEditingCell(null); - return; - } - - KGCore.instance().executeCommand(new UpdatePitchBendPropertiesCommand(activeMidiRegion.getId(), snapshots, updates)); - bumpAutomationRedrawVersion(); - } else { - const controllerEvent = row.controllerEvent; - const targetControllerEvents = selectedControllerEventIdSet.has(controllerEvent.getId()) && selectedControllerEventIds.length > 1 - ? activeMidiRegion.getAllControllerEventsFlattened() - .filter(candidate => selectedControllerEventIdSet.has(candidate.event.getId())) - : [{ controller: row.controller, event: controllerEvent }]; - - const snapshots = targetControllerEvents.map(({ controller, event }) => ({ - controllerEventId: event.getId(), - controller, - beat: event.getBeat(), - value: event.getValue(), - })); - const updates: Array<{ controllerEventId: string; controller?: number; beat?: number; value?: number }> = []; - - 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 { event } of targetControllerEvents) { - const nextBeat = event.getBeat() + parsed.deltaBeats; - if (nextBeat < 0) { - await showAlert('Position delta would move one or more controller events before the start of the current MIDI region.'); - return; - } - updates.push({ controllerEventId: event.getId(), beat: nextBeat }); - } - } else { - const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); - if ('error' in parsed) { - await showAlert(parsed.error); - return; - } - - const relativeBeat = parsed.absoluteBeat - activeMidiRegion.getStartFromBeat(); - if (relativeBeat < 0) { - await showAlert('Position cannot be earlier than the start of the current MIDI region.'); - return; - } - - for (const { event } of targetControllerEvents) { - updates.push({ controllerEventId: event.getId(), beat: relativeBeat }); - } - } - } - - if (editingCell.column === 'num') { - const parsed = parseControllerNumberInput(trimmedValue); - if ('error' in parsed) { - await showAlert(parsed.error); - return; - } - - for (const { event } of targetControllerEvents) { - updates.push({ controllerEventId: event.getId(), controller: parsed.controller }); - } - } - - if (editingCell.column === 'val') { - if (isDeltaEdit) { - const parsed = parseControllerValueDeltaInput(trimmedValue); - if ('error' in parsed) { - await showAlert(parsed.error); - return; - } - - for (const { event } of targetControllerEvents) { - const nextValue = event.getValue() + parsed.delta; - if (nextValue < 0 || nextValue > 127) { - await showAlert('Controller delta would move one or more events outside the valid range 0–127.'); - return; - } - updates.push({ controllerEventId: event.getId(), value: clampMidiControllerValue(nextValue) }); - } - } else { - const parsed = parseControllerValueInput(trimmedValue); - if ('error' in parsed) { - await showAlert(parsed.error); - return; - } - - for (const { event } of targetControllerEvents) { - updates.push({ controllerEventId: event.getId(), value: parsed.value }); - } - } - } - - if (updates.length === 0) { - setEditingCell(null); - return; - } - - KGCore.instance().executeCommand(new UpdateControllerEventPropertiesCommand(activeMidiRegion.getId(), snapshots, updates)); - bumpAutomationRedrawVersion(); - } - - await updateTrack(parentTrack); - refreshProjectState(); - setEditingCell(null); - }; - - const handleRowClick = (eventId: string, rowIndex: number, event: React.MouseEvent) => { - event.stopPropagation(); - if (editingCell) return; - - const isModifierPressed = isModifierKeyPressed(event); - const nextSelectedIds = new Set(selectedEventIdSet); - const isAlreadySelected = selectedEventIdSet.has(eventId); - const hasMultiSelection = selectedEventIdSet.size > 1; - - if (event.shiftKey) { - clearPendingSingleClickSelection(); - const anchorIndex = eventRows.findIndex(row => row.id === rangeAnchorEventIdRef.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(eventRows[index].id); - } - } else if (isModifierPressed) { - clearPendingSingleClickSelection(); - if (nextSelectedIds.has(eventId)) { - nextSelectedIds.delete(eventId); - } else { - nextSelectedIds.add(eventId); - } - rangeAnchorEventIdRef.current = eventId; - } else { - if (isAlreadySelected && hasMultiSelection) { - clearPendingSingleClickSelection(); - pendingSingleClickSelectionRef.current = window.setTimeout(() => { - const delayedSelection = new Set([eventId]); - rangeAnchorEventIdRef.current = eventId; - commitSelection(delayedSelection); - pendingSingleClickSelectionRef.current = null; - }, 220); - return; - } - - clearPendingSingleClickSelection(); - nextSelectedIds.clear(); - nextSelectedIds.add(eventId); - rangeAnchorEventIdRef.current = eventId; - } - - if (event.shiftKey && rangeAnchorEventIdRef.current === null) { - rangeAnchorEventIdRef.current = eventId; - } - - commitSelection(nextSelectedIds); - }; - - const handleTableBackgroundMouseDown = (event: React.MouseEvent) => { - event.stopPropagation(); - if (event.target !== event.currentTarget) return; - clearPendingSingleClickSelection(); - rangeAnchorEventIdRef.current = null; - commitSelection(new Set()); - }; - - const handleTableShellClick = (event: React.MouseEvent) => { - event.stopPropagation(); - }; - - 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 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(); - }; - - const handleAddEvent = async (event: React.MouseEvent) => { - event.stopPropagation(); - if (!activeMidiRegion || !parentTrack) return; - - const regionRelativePlayhead = Math.max(0, playheadPosition - activeMidiRegion.getStartFromBeat()); - - if (addEventType === 'note') { - const lastSelectedNoteId = [...selectedNoteIds] - .reverse() - .find(noteId => activeMidiRegion.getNotes().some(note => note.getId() === noteId)); - const lastSelectedNote = lastSelectedNoteId - ? activeMidiRegion.getNotes().find(note => note.getId() === lastSelectedNoteId) ?? null - : null; - - const defaultLength = lastSelectedNote - ? lastSelectedNote.getEndBeat() - lastSelectedNote.getStartBeat() - : KGPianoRollState.instance().getLastEditedNoteLength(); - const defaultPitch = lastSelectedNote ? lastSelectedNote.getPitch() : noteNameToPitch('C4'); - const defaultVelocity = lastSelectedNote ? lastSelectedNote.getVelocity() : 127; - - const command = new CreateNoteCommand( - activeMidiRegion.getId(), - regionRelativePlayhead, - regionRelativePlayhead + defaultLength, - defaultPitch, - defaultVelocity - ); - - KGCore.instance().executeCommand(command); - KGPianoRollState.instance().setLastEditedNoteLength(defaultLength); - const createdNote = command.getCreatedNote(); - if (createdNote) { - createdNote.select(); - KGCore.instance().clearSelectedItems(); - KGCore.instance().addSelectedItem(createdNote); - rangeAnchorEventIdRef.current = createdNote.getId(); - } - } else if (addEventType === 'pitch-bend') { - const command = new CreateMidiEventsCommand([], [{ - regionId: activeMidiRegion.getId(), - beat: regionRelativePlayhead, - value: MIDI_PITCH_BEND_CENTER, - }]); - - KGCore.instance().executeCommand(command); - bumpAutomationRedrawVersion(); - const createdPitchBend = command.getCreatedPitchBends()[0]?.pitchBend; - if (createdPitchBend) { - createdPitchBend.select(); - KGCore.instance().clearSelectedItems(); - KGCore.instance().addSelectedItem(createdPitchBend); - rangeAnchorEventIdRef.current = createdPitchBend.getId(); - } - } else { - const lastSelectedController = [...selectedControllerEventIds] - .reverse() - .map(id => activeMidiRegion.getAllControllerEventsFlattened().find(candidate => candidate.event.getId() === id)) - .find(Boolean)?.controller ?? 11; - const command = new CreateMidiEventsCommand([], [], [{ - regionId: activeMidiRegion.getId(), - controller: lastSelectedController, - beat: regionRelativePlayhead, - value: 127, - }]); - - KGCore.instance().executeCommand(command); - bumpAutomationRedrawVersion(); - const createdControllerEvent = command.getCreatedControllerEvents()[0]?.controllerEvent; - if (createdControllerEvent) { - createdControllerEvent.select(); - KGCore.instance().clearSelectedItems(); - KGCore.instance().addSelectedItem(createdControllerEvent); - rangeAnchorEventIdRef.current = createdControllerEvent.getId(); - } - } - - await updateTrack(parentTrack); - refreshProjectState(); - }; - - const handleDeleteSelectedRows = async (event: React.MouseEvent) => { - event.stopPropagation(); - if (!activeMidiRegion || !parentTrack || visibleSelectedRows.length === 0) return; - - const noteIds = visibleSelectedRows - .filter((row): row is NoteRowData => row.type === 'note') - .map(row => row.note.getId()); - const pitchBendIds = visibleSelectedRows - .filter((row): row is PitchBendRowData => row.type === 'pitch-bend') - .map(row => row.pitchBend.getId()); - const controllerEventIds = visibleSelectedRows - .filter((row): row is ControllerRowData => row.type === 'controller') - .map(row => row.controllerEvent.getId()); - - KGCore.instance().executeCommand(new DeleteMidiEventsCommand(noteIds, pitchBendIds, controllerEventIds)); - if (pitchBendIds.length > 0 || controllerEventIds.length > 0) { - bumpAutomationRedrawVersion(); - } - rangeAnchorEventIdRef.current = null; - await updateTrack(parentTrack); - refreshProjectState(); - }; - return (

List Event

+
+ + +
+
-
- - - -
- - {!activeMidiRegion ? ( -
- Please select a MIDI region, or open one in the Piano Roll, to view its event list. -
+ {scopeTab === 'region' ? ( + ) : ( - <> -
-
- - setAddEventType(value as AddEventType)} - label="Note" - buttonClassName="list-event-type-button" - showValueAsLabel - /> -
- -
- { - setQuantPosition(value); - quantizeSelectedNotes(value); - }} - label="Qua. Pos." - buttonClassName="list-event-quant-button" - /> - { - setQuantLength(value); - quantizeSelectedNoteLengths(value); - }} - label="Qua. Len." - buttonClassName="list-event-quant-button" - /> - -
-
- -
- - - - - - - - - - - - {eventRows.map((row, index) => { - const absoluteBeat = row.type === 'note' ? row.absoluteStartBeat : row.absoluteBeat; - const positionText = formatMidiEventPosition(absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); - const statusText = row.type === 'note' ? 'Note' : row.type === 'pitch-bend' ? 'Pitch Bend' : 'Controller'; - const numText = row.type === 'note' - ? pitchToNoteNameString(row.note.getPitch()) - : row.type === 'controller' - ? String(row.controller) - : ''; - const valText = row.type === 'note' - ? String(row.note.getVelocity()) - : row.type === 'pitch-bend' - ? String(midiPitchBendToSignedValue(row.pitchBend.getValue())) - : String(row.controllerEvent.getValue()); - const lengthText = row.type === 'note' - ? formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT) - : row.type === 'pitch-bend' - ? formatPitchBendInfo(row.pitchBend.getValue()) - : `Raw ${row.controllerEvent.getValue()}`; - const isEditingPosition = editingCell?.eventId === row.id && editingCell.column === 'position'; - const isEditingNum = editingCell?.eventId === row.id && editingCell.column === 'num'; - const isEditingVal = editingCell?.eventId === row.id && editingCell.column === 'val'; - const isEditingLength = editingCell?.eventId === row.id && editingCell.column === 'length'; - - return ( - handleRowClick(row.id, index, event)} - onDoubleClick={(event) => { - event.stopPropagation(); - clearPendingSingleClickSelection(); - }} - > - - - - - - - ); - })} - -
PositionStatusNumValLength/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} { - if (row.type === 'pitch-bend') return; - event.stopPropagation(); - startEditingCell(row.id, 'num', numText); - }} - > - {isEditingNum ? ( - setEditingCell({ ...editingCell, value: event.target.value })} - onBlur={handleEditInputBlur} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - onKeyDown={(event) => { void handleEditInputKeyDown(event); }} - /> - ) : numText} - { - 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} - { - if (row.type !== 'note') return; - 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} -
-
- + )}
diff --git a/src/components/list-event-panel/RegionListEventTab.tsx b/src/components/list-event-panel/RegionListEventTab.tsx new file mode 100644 index 0000000..11d8387 --- /dev/null +++ b/src/components/list-event-panel/RegionListEventTab.tsx @@ -0,0 +1,1135 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { FaPlus, FaTrash } 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 { KGMidiControllerEvent } from '../../core/midi/KGMidiControllerEvent'; +import { KGMidiNote } from '../../core/midi/KGMidiNote'; +import { KGMidiPitchBend } from '../../core/midi/KGMidiPitchBend'; +import { KGMidiTrack } from '../../core/track/KGMidiTrack'; +import { KGPianoRollState } from '../../core/state/KGPianoRollState'; +import { + clampMidiControllerValue, + clampMidiPitchBendValue, + formatMidiEventLength, + formatMidiEventPosition, + MIDI_EVENT_TICKS_PER_BEAT, + MIDI_PITCH_BEND_CENTER, + MIDI_PITCH_BEND_MAX_SIGNED, + MIDI_PITCH_BEND_MIN_SIGNED, + midiPitchBendToNormalized, + midiPitchBendToSignedValue, + noteNameToPitch, + parseMidiEventLengthDelta, + parseMidiEventLength, + parseMidiEventPositionDelta, + parseMidiEventPosition, + pitchToNoteNameString, + signedPitchBendToMidiValue +} from '../../util/midiUtil'; +import { isModifierKeyPressed } from '../../util/osUtil'; +import { PIANO_ROLL_CONSTANTS } from '../../constants'; +import { CreateMidiEventsCommand, CreateNoteCommand, DeleteMidiEventsCommand } from '../../core/commands'; +import { UpdateControllerEventPropertiesCommand } from '../../core/commands/note/UpdateControllerEventPropertiesCommand'; +import { UpdateNotePropertiesCommand } from '../../core/commands/note/UpdateNotePropertiesCommand'; +import { UpdatePitchBendPropertiesCommand } from '../../core/commands/note/UpdatePitchBendPropertiesCommand'; +import { showAlert } from '../../util/dialogUtil'; + +interface RegionListEventTabProps { + activeMidiRegion: KGMidiRegion | null; + parentTrack: KGMidiTrack | null; +} + +interface NoteRowData { + id: string; + type: 'note'; + note: KGMidiNote; + absoluteStartBeat: number; + durationBeats: number; +} + +interface PitchBendRowData { + id: string; + type: 'pitch-bend'; + pitchBend: KGMidiPitchBend; + absoluteBeat: number; +} + +interface ControllerRowData { + id: string; + type: 'controller'; + controller: number; + controllerEvent: KGMidiControllerEvent; + absoluteBeat: number; +} + +type EventRowData = NoteRowData | PitchBendRowData | ControllerRowData; +type EditableColumn = 'position' | 'num' | 'val' | 'length'; +type AddEventType = 'note' | 'pitch-bend' | 'controller'; + +interface EditingCell { + eventId: string; + column: EditableColumn; + value: string; +} + +const ADD_EVENT_TYPE_OPTIONS = [ + { label: 'Note', value: 'note' }, + { label: 'Pitch Bend', value: 'pitch-bend' }, + { label: 'Controller', value: 'controller' }, +] as const; + +const parseVelocityInput = (raw: string): { velocity: number } | { error: string } => { + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed)) { + return { error: 'Velocity must be an integer between 0 and 127.' }; + } + + const velocity = parseInt(trimmed, 10); + if (velocity < 0 || velocity > 127) { + return { error: 'Velocity must be between 0 and 127.' }; + } + + return { velocity }; +}; + +const parseVelocityDeltaInput = (raw: string): { delta: number } | { error: string } => { + const trimmed = raw.trim(); + if (!/^[+-]\d+$/.test(trimmed)) { + return { error: 'Use velocity delta like +10 or -5.' }; + } + + return { delta: parseInt(trimmed, 10) }; +}; + +const parseNoteNameInput = (raw: string): { pitch: number } | { error: string } => { + try { + return { pitch: noteNameToPitch(raw.trim()) }; + } catch { + return { error: 'Use note names like C3, C#3, or Cb3.' }; + } +}; + +const parsePitchDeltaInput = (raw: string): { delta: number } | { error: string } => { + const trimmed = raw.trim(); + if (!/^[+-]\d+$/.test(trimmed)) { + return { error: 'Use note delta like +2 or -1 when editing Num in delta mode.' }; + } + + return { delta: parseInt(trimmed, 10) }; +}; + +const parsePitchBendInput = (raw: string): { value: number } | { error: string } => { + const trimmed = raw.trim(); + if (!/^[+-]?\d+$/.test(trimmed)) { + return { error: `Pitch bend value must be an integer between ${MIDI_PITCH_BEND_MIN_SIGNED} and ${MIDI_PITCH_BEND_MAX_SIGNED}.` }; + } + + const signedValue = parseInt(trimmed, 10); + if (signedValue < MIDI_PITCH_BEND_MIN_SIGNED || signedValue > MIDI_PITCH_BEND_MAX_SIGNED) { + return { error: `Pitch bend value must be between ${MIDI_PITCH_BEND_MIN_SIGNED} and ${MIDI_PITCH_BEND_MAX_SIGNED}.` }; + } + + return { value: signedPitchBendToMidiValue(signedValue) }; +}; + +const parsePitchBendDeltaInput = (raw: string): { delta: number } | { error: string } => { + const trimmed = raw.trim(); + if (!/^[+-]\d+$/.test(trimmed)) { + return { error: 'Use pitch bend delta like +256 or -512.' }; + } + + return { delta: parseInt(trimmed, 10) }; +}; + +const formatPitchBendInfo = (value: number): string => { + const normalized = midiPitchBendToNormalized(value); + const semitones = normalized * 2; + return `Raw ${value} | ${normalized.toFixed(3)} | ${semitones.toFixed(2)} st`; +}; + +const parseControllerNumberInput = (raw: string): { controller: number } | { error: string } => { + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed)) { + return { error: 'Controller number must be an integer between 0 and 127.' }; + } + + const controller = parseInt(trimmed, 10); + if (controller < 0 || controller > 127) { + return { error: 'Controller number must be between 0 and 127.' }; + } + + return { controller }; +}; + +const parseControllerValueInput = (raw: string): { value: number } | { error: string } => { + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed)) { + return { error: 'Controller value must be an integer between 0 and 127.' }; + } + + return { value: clampMidiControllerValue(parseInt(trimmed, 10)) }; +}; + +const parseControllerValueDeltaInput = (raw: string): { delta: number } | { error: string } => { + const trimmed = raw.trim(); + if (!/^[+-]\d+$/.test(trimmed)) { + return { error: 'Use controller value delta like +10 or -5.' }; + } + + return { delta: parseInt(trimmed, 10) }; +}; + +const RegionListEventTab: React.FC = ({ activeMidiRegion, parentTrack }) => { + const { + selectedNoteIds, + selectedPitchBendIds, + selectedControllerEventIds, + selectedRegionIds, + activeRegionId, + timeSignature, + playheadPosition, + updateTrack, + refreshProjectState, + bumpAutomationRedrawVersion + } = useProjectStore(); + + const [showNotes, setShowNotes] = useState(true); + const [showPitchBends, setShowPitchBends] = useState(true); + const [showControllers, setShowControllers] = useState(true); + const [quantPosition, setQuantPosition] = useState('1/8'); + const [quantLength, setQuantLength] = useState('1/8'); + const [addEventType, setAddEventType] = useState('note'); + const [editingCell, setEditingCell] = useState(null); + const rangeAnchorEventIdRef = useRef(null); + const editInputRef = useRef(null); + const suppressBlurCommitRef = useRef(false); + const pendingSingleClickSelectionRef = useRef(null); + + const noteRows: NoteRowData[] = activeMidiRegion + ? activeMidiRegion.getNotes().map(note => ({ + id: note.getId(), + type: 'note', + note, + absoluteStartBeat: activeMidiRegion.getStartFromBeat() + note.getStartBeat(), + durationBeats: note.getEndBeat() - note.getStartBeat(), + })) + : []; + + const pitchBendRows: PitchBendRowData[] = activeMidiRegion + ? activeMidiRegion.getPitchBends().map(pitchBend => ({ + id: pitchBend.getId(), + type: 'pitch-bend', + pitchBend, + absoluteBeat: activeMidiRegion.getStartFromBeat() + pitchBend.getBeat(), + })) + : []; + + const controllerRows: ControllerRowData[] = activeMidiRegion + ? activeMidiRegion.getAllControllerEventsFlattened().map(({ controller, event }) => ({ + id: event.getId(), + type: 'controller', + controller, + controllerEvent: event, + absoluteBeat: activeMidiRegion.getStartFromBeat() + event.getBeat(), + })) + : []; + + const eventRows: EventRowData[] = [ + ...(showNotes ? noteRows : []), + ...(showPitchBends ? pitchBendRows : []), + ...(showControllers ? controllerRows : []), + ].sort((a, b) => { + const beatDelta = (a.type === 'note' ? a.absoluteStartBeat : a.absoluteBeat) + - (b.type === 'note' ? b.absoluteStartBeat : b.absoluteBeat); + if (beatDelta !== 0) return beatDelta; + if (a.type !== b.type) return a.type === 'pitch-bend' ? -1 : 1; + return a.id.localeCompare(b.id); + }); + + const selectedNoteIdSet = new Set(selectedNoteIds); + const selectedPitchBendIdSet = new Set(selectedPitchBendIds); + const selectedControllerEventIdSet = new Set(selectedControllerEventIds); + const selectedEventIdSet = new Set([...selectedNoteIds, ...selectedPitchBendIds, ...selectedControllerEventIds]); + const visibleSelectedRows = eventRows.filter(row => selectedEventIdSet.has(row.id)); + + useEffect(() => { + if (editingCell) { + editInputRef.current?.focus(); + editInputRef.current?.select(); + } + }, [editingCell?.eventId, editingCell?.column]); + + useEffect(() => { + return () => { + if (pendingSingleClickSelectionRef.current !== null) { + window.clearTimeout(pendingSingleClickSelectionRef.current); + } + }; + }, []); + + const commitSelection = (nextSelectedIds: Set) => { + if (!activeMidiRegion || !parentTrack) return; + + const selectedEvents = eventRows + .filter(row => nextSelectedIds.has(row.id)) + .map(row => row.type === 'note' ? row.note : row.type === 'pitch-bend' ? row.pitchBend : row.controllerEvent); + const core = KGCore.instance(); + const previouslySelectedRegionEvents = core.getSelectedItems().filter(item => + (item instanceof KGMidiNote && activeMidiRegion.getNotes().some(note => note.getId() === item.getId())) || + (item instanceof KGMidiPitchBend && activeMidiRegion.getPitchBends().some(pitchBend => pitchBend.getId() === item.getId())) || + (item instanceof KGMidiControllerEvent && activeMidiRegion.getAllControllerEventsFlattened().some(({ event }) => event.getId() === item.getId())) + ); + + activeMidiRegion.getNotes().forEach(note => { + if (nextSelectedIds.has(note.getId())) note.select(); + else note.deselect(); + }); + activeMidiRegion.getPitchBends().forEach(pitchBend => { + if (nextSelectedIds.has(pitchBend.getId())) pitchBend.select(); + else pitchBend.deselect(); + }); + activeMidiRegion.getControllerEventsByType().forEach(events => { + events.forEach(controllerEvent => { + if (nextSelectedIds.has(controllerEvent.getId())) controllerEvent.select(); + else controllerEvent.deselect(); + }); + }); + + if (previouslySelectedRegionEvents.length > 0) { + core.removeSelectedItems(previouslySelectedRegionEvents); + } + if (selectedEvents.length > 0) { + core.addSelectedItems(selectedEvents); + } + + void updateTrack(parentTrack); + }; + + const clearPendingSingleClickSelection = () => { + if (pendingSingleClickSelectionRef.current !== null) { + window.clearTimeout(pendingSingleClickSelectionRef.current); + pendingSingleClickSelectionRef.current = null; + } + }; + + const startEditingCell = (eventId: string, column: EditableColumn, value: string) => { + clearPendingSingleClickSelection(); + setEditingCell({ eventId, column, value }); + }; + + const cancelEditingCell = () => { + setEditingCell(null); + }; + + const commitEditingCell = async () => { + if (!editingCell || !activeMidiRegion || !parentTrack) return; + + const row = eventRows.find(candidate => candidate.id === editingCell.eventId); + if (!row) { + setEditingCell(null); + return; + } + + const trimmedValue = editingCell.value.trim(); + const isDeltaEdit = trimmedValue.startsWith('+') || trimmedValue.startsWith('-'); + + if (row.type === 'note') { + const note = row.note; + const targetNotes = selectedNoteIdSet.has(note.getId()) && selectedNoteIds.length > 1 + ? activeMidiRegion.getNotes().filter(candidate => selectedNoteIdSet.has(candidate.getId())) + : [note]; + + const snapshots = targetNotes.map(targetNote => ({ + noteId: targetNote.getId(), + pitch: targetNote.getPitch(), + velocity: targetNote.getVelocity(), + startBeat: targetNote.getStartBeat(), + endBeat: targetNote.getEndBeat() + })); + + const updates: Array<{ noteId: string; pitch?: number; velocity?: number; startBeat?: number; endBeat?: number }> = []; + + 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 targetNote of targetNotes) { + const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat(); + const nextStartBeat = targetNote.getStartBeat() + parsed.deltaBeats; + if (nextStartBeat < 0) { + await showAlert('Position delta would move one or more notes before the start of the current MIDI region.'); + return; + } + + updates.push({ + noteId: targetNote.getId(), + startBeat: nextStartBeat, + endBeat: nextStartBeat + currentDuration + }); + } + } else { + const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + + const relativeStartBeat = parsed.absoluteBeat - activeMidiRegion.getStartFromBeat(); + if (relativeStartBeat < 0) { + await showAlert('Position cannot be earlier than the start of the current MIDI region.'); + return; + } + + for (const targetNote of targetNotes) { + const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat(); + updates.push({ + noteId: targetNote.getId(), + startBeat: relativeStartBeat, + endBeat: relativeStartBeat + currentDuration + }); + } + } + } + + if (editingCell.column === 'num') { + if (isDeltaEdit) { + const parsed = parsePitchDeltaInput(trimmedValue); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + + for (const targetNote of targetNotes) { + const nextPitch = targetNote.getPitch() + parsed.delta; + if (nextPitch < 0 || nextPitch > 127) { + await showAlert('Num delta would move one or more notes outside the MIDI pitch range 0–127.'); + return; + } + updates.push({ noteId: targetNote.getId(), pitch: nextPitch }); + } + } else { + const parsed = parseNoteNameInput(trimmedValue); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + + for (const targetNote of targetNotes) { + updates.push({ noteId: targetNote.getId(), pitch: parsed.pitch }); + } + } + } + + if (editingCell.column === 'val') { + if (isDeltaEdit) { + const parsed = parseVelocityDeltaInput(trimmedValue); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + + for (const targetNote of targetNotes) { + const nextVelocity = targetNote.getVelocity() + parsed.delta; + if (nextVelocity < 0 || nextVelocity > 127) { + await showAlert('Velocity delta would move one or more notes outside the valid range 0–127.'); + return; + } + updates.push({ noteId: targetNote.getId(), velocity: nextVelocity }); + } + } else { + const parsed = parseVelocityInput(trimmedValue); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + + for (const targetNote of targetNotes) { + updates.push({ noteId: targetNote.getId(), velocity: parsed.velocity }); + } + } + } + + 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 targetNote of targetNotes) { + const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat(); + const nextDuration = currentDuration + parsed.deltaBeats; + if (nextDuration <= 0) { + await showAlert('Length delta would make one or more notes non-positive in duration.'); + return; + } + + updates.push({ + noteId: targetNote.getId(), + endBeat: targetNote.getStartBeat() + nextDuration + }); + } + } else { + const parsed = parseMidiEventLength(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + + for (const targetNote of targetNotes) { + updates.push({ + noteId: targetNote.getId(), + endBeat: targetNote.getStartBeat() + parsed.duration + }); + } + } + } + + if (updates.length === 0) { + setEditingCell(null); + return; + } + + KGCore.instance().executeCommand(new UpdateNotePropertiesCommand(activeMidiRegion.getId(), snapshots, updates)); + } else if (row.type === 'pitch-bend') { + const pitchBend = row.pitchBend; + const targetPitchBends = selectedPitchBendIdSet.has(pitchBend.getId()) && selectedPitchBendIds.length > 1 + ? activeMidiRegion.getPitchBends().filter(candidate => selectedPitchBendIdSet.has(candidate.getId())) + : [pitchBend]; + + const snapshots = targetPitchBends.map(targetPitchBend => ({ + pitchBendId: targetPitchBend.getId(), + beat: targetPitchBend.getBeat(), + value: targetPitchBend.getValue(), + })); + const updates: Array<{ pitchBendId: string; beat?: number; value?: number }> = []; + + 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 targetPitchBend of targetPitchBends) { + const nextBeat = targetPitchBend.getBeat() + parsed.deltaBeats; + if (nextBeat < 0) { + await showAlert('Position delta would move one or more pitch bends before the start of the current MIDI region.'); + return; + } + updates.push({ pitchBendId: targetPitchBend.getId(), beat: nextBeat }); + } + } else { + const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + + const relativeBeat = parsed.absoluteBeat - activeMidiRegion.getStartFromBeat(); + if (relativeBeat < 0) { + await showAlert('Position cannot be earlier than the start of the current MIDI region.'); + return; + } + + for (const targetPitchBend of targetPitchBends) { + updates.push({ pitchBendId: targetPitchBend.getId(), beat: relativeBeat }); + } + } + } + + if (editingCell.column === 'val') { + if (isDeltaEdit) { + const parsed = parsePitchBendDeltaInput(trimmedValue); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + + for (const targetPitchBend of targetPitchBends) { + const nextSignedValue = midiPitchBendToSignedValue(targetPitchBend.getValue()) + parsed.delta; + if (nextSignedValue < MIDI_PITCH_BEND_MIN_SIGNED || nextSignedValue > MIDI_PITCH_BEND_MAX_SIGNED) { + await showAlert(`Pitch bend delta would move one or more events outside the valid range ${MIDI_PITCH_BEND_MIN_SIGNED}–${MIDI_PITCH_BEND_MAX_SIGNED}.`); + return; + } + updates.push({ + pitchBendId: targetPitchBend.getId(), + value: signedPitchBendToMidiValue(nextSignedValue), + }); + } + } else { + const parsed = parsePitchBendInput(trimmedValue); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + + for (const targetPitchBend of targetPitchBends) { + updates.push({ pitchBendId: targetPitchBend.getId(), value: clampMidiPitchBendValue(parsed.value) }); + } + } + } + + if (updates.length === 0) { + setEditingCell(null); + return; + } + + KGCore.instance().executeCommand(new UpdatePitchBendPropertiesCommand(activeMidiRegion.getId(), snapshots, updates)); + bumpAutomationRedrawVersion(); + } else { + const controllerEvent = row.controllerEvent; + const targetControllerEvents = selectedControllerEventIdSet.has(controllerEvent.getId()) && selectedControllerEventIds.length > 1 + ? activeMidiRegion.getAllControllerEventsFlattened() + .filter(candidate => selectedControllerEventIdSet.has(candidate.event.getId())) + : [{ controller: row.controller, event: controllerEvent }]; + + const snapshots = targetControllerEvents.map(({ controller, event }) => ({ + controllerEventId: event.getId(), + controller, + beat: event.getBeat(), + value: event.getValue(), + })); + const updates: Array<{ controllerEventId: string; controller?: number; beat?: number; value?: number }> = []; + + 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 { event } of targetControllerEvents) { + const nextBeat = event.getBeat() + parsed.deltaBeats; + if (nextBeat < 0) { + await showAlert('Position delta would move one or more controller events before the start of the current MIDI region.'); + return; + } + updates.push({ controllerEventId: event.getId(), beat: nextBeat }); + } + } else { + const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + + const relativeBeat = parsed.absoluteBeat - activeMidiRegion.getStartFromBeat(); + if (relativeBeat < 0) { + await showAlert('Position cannot be earlier than the start of the current MIDI region.'); + return; + } + + for (const { event } of targetControllerEvents) { + updates.push({ controllerEventId: event.getId(), beat: relativeBeat }); + } + } + } + + if (editingCell.column === 'num') { + const parsed = parseControllerNumberInput(trimmedValue); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + + for (const { event } of targetControllerEvents) { + updates.push({ controllerEventId: event.getId(), controller: parsed.controller }); + } + } + + if (editingCell.column === 'val') { + if (isDeltaEdit) { + const parsed = parseControllerValueDeltaInput(trimmedValue); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + + for (const { event } of targetControllerEvents) { + const nextValue = event.getValue() + parsed.delta; + if (nextValue < 0 || nextValue > 127) { + await showAlert('Controller delta would move one or more events outside the valid range 0–127.'); + return; + } + updates.push({ controllerEventId: event.getId(), value: clampMidiControllerValue(nextValue) }); + } + } else { + const parsed = parseControllerValueInput(trimmedValue); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + + for (const { event } of targetControllerEvents) { + updates.push({ controllerEventId: event.getId(), value: parsed.value }); + } + } + } + + if (updates.length === 0) { + setEditingCell(null); + return; + } + + KGCore.instance().executeCommand(new UpdateControllerEventPropertiesCommand(activeMidiRegion.getId(), snapshots, updates)); + bumpAutomationRedrawVersion(); + } + + await updateTrack(parentTrack); + refreshProjectState(); + setEditingCell(null); + }; + + const handleRowClick = (eventId: string, rowIndex: number, event: React.MouseEvent) => { + event.stopPropagation(); + if (editingCell) return; + + const isModifierPressed = isModifierKeyPressed(event); + const nextSelectedIds = new Set(selectedEventIdSet); + const isAlreadySelected = selectedEventIdSet.has(eventId); + const hasMultiSelection = selectedEventIdSet.size > 1; + + if (event.shiftKey) { + clearPendingSingleClickSelection(); + const anchorIndex = eventRows.findIndex(row => row.id === rangeAnchorEventIdRef.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(eventRows[index].id); + } + } else if (isModifierPressed) { + clearPendingSingleClickSelection(); + if (nextSelectedIds.has(eventId)) { + nextSelectedIds.delete(eventId); + } else { + nextSelectedIds.add(eventId); + } + rangeAnchorEventIdRef.current = eventId; + } else { + if (isAlreadySelected && hasMultiSelection) { + clearPendingSingleClickSelection(); + pendingSingleClickSelectionRef.current = window.setTimeout(() => { + const delayedSelection = new Set([eventId]); + rangeAnchorEventIdRef.current = eventId; + commitSelection(delayedSelection); + pendingSingleClickSelectionRef.current = null; + }, 220); + return; + } + + clearPendingSingleClickSelection(); + nextSelectedIds.clear(); + nextSelectedIds.add(eventId); + rangeAnchorEventIdRef.current = eventId; + } + + if (event.shiftKey && rangeAnchorEventIdRef.current === null) { + rangeAnchorEventIdRef.current = eventId; + } + + commitSelection(nextSelectedIds); + }; + + const handleTableBackgroundMouseDown = (event: React.MouseEvent) => { + event.stopPropagation(); + if (event.target !== event.currentTarget) return; + clearPendingSingleClickSelection(); + rangeAnchorEventIdRef.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 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(); + }; + + const handleAddEvent = async (event: React.MouseEvent) => { + event.stopPropagation(); + if (!activeMidiRegion || !parentTrack) return; + + const regionRelativePlayhead = Math.max(0, playheadPosition - activeMidiRegion.getStartFromBeat()); + + if (addEventType === 'note') { + const lastSelectedNoteId = [...selectedNoteIds] + .reverse() + .find(noteId => activeMidiRegion.getNotes().some(note => note.getId() === noteId)); + const lastSelectedNote = lastSelectedNoteId + ? activeMidiRegion.getNotes().find(note => note.getId() === lastSelectedNoteId) ?? null + : null; + + const defaultLength = lastSelectedNote + ? lastSelectedNote.getEndBeat() - lastSelectedNote.getStartBeat() + : KGPianoRollState.instance().getLastEditedNoteLength(); + const defaultPitch = lastSelectedNote ? lastSelectedNote.getPitch() : noteNameToPitch('C4'); + const defaultVelocity = lastSelectedNote ? lastSelectedNote.getVelocity() : 127; + + const command = new CreateNoteCommand( + activeMidiRegion.getId(), + regionRelativePlayhead, + regionRelativePlayhead + defaultLength, + defaultPitch, + defaultVelocity + ); + + KGCore.instance().executeCommand(command); + KGPianoRollState.instance().setLastEditedNoteLength(defaultLength); + const createdNote = command.getCreatedNote(); + if (createdNote) { + createdNote.select(); + KGCore.instance().clearSelectedItems(); + if (selectedRegionIds.includes(activeRegionId ?? '')) { + activeMidiRegion.select(); + KGCore.instance().addSelectedItem(activeMidiRegion); + } + KGCore.instance().addSelectedItem(createdNote); + rangeAnchorEventIdRef.current = createdNote.getId(); + } + } else if (addEventType === 'pitch-bend') { + const command = new CreateMidiEventsCommand([], [{ + regionId: activeMidiRegion.getId(), + beat: regionRelativePlayhead, + value: MIDI_PITCH_BEND_CENTER, + }]); + + KGCore.instance().executeCommand(command); + bumpAutomationRedrawVersion(); + const createdPitchBend = command.getCreatedPitchBends()[0]?.pitchBend; + if (createdPitchBend) { + createdPitchBend.select(); + KGCore.instance().clearSelectedItems(); + if (selectedRegionIds.includes(activeRegionId ?? '')) { + activeMidiRegion.select(); + KGCore.instance().addSelectedItem(activeMidiRegion); + } + KGCore.instance().addSelectedItem(createdPitchBend); + rangeAnchorEventIdRef.current = createdPitchBend.getId(); + } + } else { + const lastSelectedController = [...selectedControllerEventIds] + .reverse() + .map(id => activeMidiRegion.getAllControllerEventsFlattened().find(candidate => candidate.event.getId() === id)) + .find(Boolean)?.controller ?? 11; + const command = new CreateMidiEventsCommand([], [], [{ + regionId: activeMidiRegion.getId(), + controller: lastSelectedController, + beat: regionRelativePlayhead, + value: 127, + }]); + + KGCore.instance().executeCommand(command); + bumpAutomationRedrawVersion(); + const createdControllerEvent = command.getCreatedControllerEvents()[0]?.controllerEvent; + if (createdControllerEvent) { + createdControllerEvent.select(); + KGCore.instance().clearSelectedItems(); + if (selectedRegionIds.includes(activeRegionId ?? '')) { + activeMidiRegion.select(); + KGCore.instance().addSelectedItem(activeMidiRegion); + } + KGCore.instance().addSelectedItem(createdControllerEvent); + rangeAnchorEventIdRef.current = createdControllerEvent.getId(); + } + } + + await updateTrack(parentTrack); + refreshProjectState(); + }; + + const handleDeleteSelectedRows = async (event: React.MouseEvent) => { + event.stopPropagation(); + if (!activeMidiRegion || !parentTrack || visibleSelectedRows.length === 0) return; + + const noteIds = visibleSelectedRows + .filter((row): row is NoteRowData => row.type === 'note') + .map(row => row.note.getId()); + const pitchBendIds = visibleSelectedRows + .filter((row): row is PitchBendRowData => row.type === 'pitch-bend') + .map(row => row.pitchBend.getId()); + const controllerEventIds = visibleSelectedRows + .filter((row): row is ControllerRowData => row.type === 'controller') + .map(row => row.controllerEvent.getId()); + + KGCore.instance().executeCommand(new DeleteMidiEventsCommand(noteIds, pitchBendIds, controllerEventIds)); + if (pitchBendIds.length > 0 || controllerEventIds.length > 0) { + bumpAutomationRedrawVersion(); + } + rangeAnchorEventIdRef.current = null; + await updateTrack(parentTrack); + refreshProjectState(); + }; + + return ( + <> +
+ + + +
+ + {!activeMidiRegion ? ( +
+ Please select a MIDI region, or open one in the Piano Roll, to view its event list. +
+ ) : ( + <> +
+
+ + setAddEventType(value as AddEventType)} + label="Note" + buttonClassName="list-event-type-button" + showValueAsLabel + /> +
+ +
+ { + setQuantPosition(value); + quantizeSelectedNotes(value); + }} + label="Qua. Pos." + buttonClassName="list-event-quant-button" + /> + { + setQuantLength(value); + quantizeSelectedNoteLengths(value); + }} + label="Qua. Len." + buttonClassName="list-event-quant-button" + /> + +
+
+ +
+ + + + + + + + + + + + {eventRows.map((row, index) => { + const absoluteBeat = row.type === 'note' ? row.absoluteStartBeat : row.absoluteBeat; + const positionText = formatMidiEventPosition(absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); + const statusText = row.type === 'note' ? 'Note' : row.type === 'pitch-bend' ? 'Pitch Bend' : 'Controller'; + const numText = row.type === 'note' + ? pitchToNoteNameString(row.note.getPitch()) + : row.type === 'controller' + ? String(row.controller) + : ''; + const valText = row.type === 'note' + ? String(row.note.getVelocity()) + : row.type === 'pitch-bend' + ? String(midiPitchBendToSignedValue(row.pitchBend.getValue())) + : String(row.controllerEvent.getValue()); + const lengthText = row.type === 'note' + ? formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT) + : row.type === 'pitch-bend' + ? formatPitchBendInfo(row.pitchBend.getValue()) + : `Raw ${row.controllerEvent.getValue()}`; + const isEditingPosition = editingCell?.eventId === row.id && editingCell.column === 'position'; + const isEditingNum = editingCell?.eventId === row.id && editingCell.column === 'num'; + const isEditingVal = editingCell?.eventId === row.id && editingCell.column === 'val'; + const isEditingLength = editingCell?.eventId === row.id && editingCell.column === 'length'; + + return ( + handleRowClick(row.id, index, event)} + onDoubleClick={(event) => { + event.stopPropagation(); + clearPendingSingleClickSelection(); + }} + > + + + + + + + ); + })} + +
PositionStatusNumValLength/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} { + if (row.type === 'pitch-bend') return; + event.stopPropagation(); + startEditingCell(row.id, 'num', numText); + }}> + {isEditingNum ? ( + setEditingCell({ ...editingCell, value: event.target.value })} + onBlur={handleEditInputBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { void handleEditInputKeyDown(event); }} + /> + ) : numText} + { + 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} + { + if (row.type !== 'note') return; + 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 RegionListEventTab; diff --git a/src/components/list-event-panel/TrackListEventTab.tsx b/src/components/list-event-panel/TrackListEventTab.tsx new file mode 100644 index 0000000..71818c7 --- /dev/null +++ b/src/components/list-event-panel/TrackListEventTab.tsx @@ -0,0 +1,800 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { FaPlus, FaTrash } from 'react-icons/fa'; +import KGDropdown from '../common/KGDropdown'; +import { useProjectStore } from '../../stores/projectStore'; +import { KGCore } from '../../core/KGCore'; +import { KGAudioRegion } from '../../core/region/KGAudioRegion'; +import { KGRegion } from '../../core/region/KGRegion'; +import { KGTrackAutomationPoint, type TrackAutomationType } from '../../core/track/KGTrackAutomationPoint'; +import { KGMidiTrack } from '../../core/track/KGMidiTrack'; +import { KGAudioTrack } from '../../core/track/KGAudioTrack'; +import { + CreateRegionCommand, + CreateTrackAutomationPointsCommand, + DeleteMultipleRegionsCommand, + DeleteTrackAutomationPointsCommand, + MoveRegionCommand, + ResizeRegionCommand, + UpdateTrackAutomationPointsCommand, +} from '../../core/commands'; +import { + formatMidiEventLength, + formatMidiEventPosition, + MIDI_EVENT_TICKS_PER_BEAT, + parseMidiEventLengthDelta, + parseMidiEventLength, + parseMidiEventPositionDelta, + parseMidiEventPosition, +} from '../../util/midiUtil'; +import { isModifierKeyPressed } from '../../util/osUtil'; +import { showAlert } from '../../util/dialogUtil'; +import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants'; + +interface TrackListEventTabProps { + selectedTrack: KGMidiTrack | KGAudioTrack | null; +} + +type AddTrackItemType = 'midi-region' | 'volume' | 'pan'; +type TrackEditableColumn = 'position' | 'val' | 'length'; + +interface TrackRegionRowData { + id: string; + type: 'region'; + region: KGRegion; + absoluteStartBeat: number; + durationBeats: number; + statusLabel: 'MIDI' | 'Audio'; +} + +interface TrackAutomationRowData { + id: string; + type: 'automation'; + automationType: TrackAutomationType; + point: KGTrackAutomationPoint; + absoluteBeat: number; +} + +type TrackRowData = TrackRegionRowData | TrackAutomationRowData; + +interface TrackEditingCell { + rowId: string; + column: TrackEditableColumn; + value: string; +} + +const formatTrackAutomationValue = (automationType: TrackAutomationType, value: number): string => { + if (automationType === 'volume') { + if (value <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB) { + return '−∞'; + } + + return `${value >= 0 ? '+' : ''}${value.toFixed(1)}dB`; + } + + const logicPanValue = value <= 0 ? Math.round(value * 64) : Math.round(value * 63); + return `${logicPanValue >= 0 ? '+' : ''}${logicPanValue}`; +}; + +const formatTrackAutomationInfo = (automationType: TrackAutomationType, value: number): string => { + return automationType === 'volume' ? `Raw ${value.toFixed(3)} dB` : `Raw ${value.toFixed(3)}`; +}; + +const parseTrackAutomationValueInput = ( + automationType: TrackAutomationType, + raw: string +): { value: number } | { error: string } => { + const trimmed = raw.trim().replace(/db$/i, ''); + + if (automationType === 'volume') { + if (trimmed === '−∞' || trimmed.toLowerCase() === '-inf' || trimmed.toLowerCase() === '-infinity') { + return { value: AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB }; + } + + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { + return { error: `Volume must be a number between ${AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB} and ${AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB}.` }; + } + if (parsed < AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB || parsed > AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB) { + return { error: `Volume must be between ${AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB} and ${AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB}.` }; + } + return { value: parsed }; + } + + if (!/^[+-]?\d+$/.test(trimmed)) { + return { error: 'Pan must be an integer between -64 and +63.' }; + } + + const parsed = parseInt(trimmed, 10); + if (parsed < -64 || parsed > 63) { + return { error: 'Pan must be between -64 and +63.' }; + } + + const normalized = parsed <= 0 ? parsed / 64 : parsed / 63; + return { value: Math.max(-1, Math.min(1, normalized)) }; +}; + +const findPreviousPanValue = (points: KGTrackAutomationPoint[], beat: number): number => { + const previousPoint = [...points].filter(point => point.getBeat() <= beat).sort((a, b) => b.getBeat() - a.getBeat())[0]; + return previousPoint?.getValue() ?? 0; +}; + +const TrackListEventTab: React.FC = ({ selectedTrack }) => { + const { + tracks, + playheadPosition, + timeSignature, + selectedRegionIds, + selectedTrackAutomationPointIds, + updateTrack, + refreshProjectState, + bumpTrackAutomationRedrawVersion, + } = useProjectStore(); + + const [showRegions, setShowRegions] = useState(true); + const [showVolume, setShowVolume] = useState(true); + const [showPan, setShowPan] = useState(true); + const [addTrackItemType, setAddTrackItemType] = useState('midi-region'); + const [editingCell, setEditingCell] = useState(null); + const rangeAnchorRowIdRef = useRef(null); + const editInputRef = useRef(null); + const suppressBlurCommitRef = useRef(false); + const pendingSingleClickSelectionRef = useRef(null); + + useEffect(() => { + if (selectedTrack instanceof KGAudioTrack && addTrackItemType === 'midi-region') { + setAddTrackItemType('volume'); + } + }, [selectedTrack, addTrackItemType]); + + useEffect(() => { + if (editingCell) { + editInputRef.current?.focus(); + editInputRef.current?.select(); + } + }, [editingCell?.rowId, editingCell?.column]); + + useEffect(() => { + return () => { + if (pendingSingleClickSelectionRef.current !== null) { + window.clearTimeout(pendingSingleClickSelectionRef.current); + } + }; + }, []); + + const availableAddOptions = useMemo(() => { + const options: Array<{ label: string; value: AddTrackItemType }> = []; + if (selectedTrack instanceof KGMidiTrack) { + options.push({ label: 'MIDI Region', value: 'midi-region' }); + } + options.push({ label: 'Volume', value: 'volume' }); + options.push({ label: 'Pan', value: 'pan' }); + return options; + }, [selectedTrack]); + + const liveSelectedTrack = useMemo(() => { + if (!selectedTrack) { + return null; + } + + const matchedTrack = tracks.find(track => track.getId() === selectedTrack.getId()) ?? null; + return matchedTrack instanceof KGMidiTrack || matchedTrack instanceof KGAudioTrack + ? matchedTrack + : selectedTrack; + }, [selectedTrack, tracks]); + + const trackRows: TrackRowData[] = useMemo(() => { + if (!liveSelectedTrack) return []; + + const regionRows: TrackRegionRowData[] = showRegions + ? liveSelectedTrack.getRegions().map(region => ({ + id: region.getId(), + type: 'region', + region, + absoluteStartBeat: region.getStartFromBeat(), + durationBeats: region.getLength(), + statusLabel: region instanceof KGAudioRegion ? 'Audio' : 'MIDI', + })) + : []; + + const volumeRows: TrackAutomationRowData[] = showVolume + ? liveSelectedTrack.getAutomationPoints('volume').map(point => ({ + id: point.getId(), + type: 'automation', + automationType: 'volume', + point, + absoluteBeat: point.getBeat(), + })) + : []; + + const panRows: TrackAutomationRowData[] = showPan + ? liveSelectedTrack.getAutomationPoints('pan').map(point => ({ + id: point.getId(), + type: 'automation', + automationType: 'pan', + point, + absoluteBeat: point.getBeat(), + })) + : []; + + return [...regionRows, ...volumeRows, ...panRows].sort((a, b) => { + const beatA = a.type === 'region' ? a.absoluteStartBeat : a.absoluteBeat; + const beatB = b.type === 'region' ? b.absoluteStartBeat : b.absoluteBeat; + if (beatA !== beatB) return beatA - beatB; + if (a.type !== b.type) return a.type === 'region' ? -1 : 1; + if (a.type === 'automation' && b.type === 'automation' && a.automationType !== b.automationType) { + return a.automationType.localeCompare(b.automationType); + } + return a.id.localeCompare(b.id); + }); + }, [liveSelectedTrack, showPan, showRegions, showVolume, tracks]); + + const selectedRowIdSet = new Set([...selectedRegionIds, ...selectedTrackAutomationPointIds]); + const visibleSelectedRows = trackRows.filter(row => selectedRowIdSet.has(row.id)); + + const clearPendingSingleClickSelection = () => { + if (pendingSingleClickSelectionRef.current !== null) { + window.clearTimeout(pendingSingleClickSelectionRef.current); + pendingSingleClickSelectionRef.current = null; + } + }; + + const commitTrackRegionSelection = (nextSelectedIds: Set) => { + if (!liveSelectedTrack) return; + + const core = KGCore.instance(); + const selectedRegions = liveSelectedTrack.getRegions().filter(region => nextSelectedIds.has(region.getId())); + const previouslySelectedRegions = core.getSelectedItems().filter(item => item instanceof KGRegion); + + liveSelectedTrack.getRegions().forEach(region => { + if (nextSelectedIds.has(region.getId())) region.select(); + else region.deselect(); + }); + + if (previouslySelectedRegions.length > 0) { + core.removeSelectedItems(previouslySelectedRegions); + } + if (selectedRegions.length > 0) { + core.addSelectedItems(selectedRegions); + } + + void updateTrack(liveSelectedTrack); + }; + + const commitTrackAutomationSelection = (nextSelectedIds: Set) => { + if (!liveSelectedTrack) return; + + const core = KGCore.instance(); + const points = [ + ...liveSelectedTrack.getAutomationPoints('volume'), + ...liveSelectedTrack.getAutomationPoints('pan'), + ]; + const selectedPoints = points.filter(point => nextSelectedIds.has(point.getId())); + const previouslySelectedPoints = core.getSelectedItems().filter(item => item instanceof KGTrackAutomationPoint); + + points.forEach(point => { + if (nextSelectedIds.has(point.getId())) point.select(); + else point.deselect(); + }); + + if (previouslySelectedPoints.length > 0) { + core.removeSelectedItems(previouslySelectedPoints); + } + if (selectedPoints.length > 0) { + core.addSelectedItems(selectedPoints); + } + + void updateTrack(liveSelectedTrack); + }; + + const commitSelection = (nextSelectedIds: Set) => { + commitTrackRegionSelection(nextSelectedIds); + commitTrackAutomationSelection(nextSelectedIds); + }; + + const startEditingCell = (rowId: string, column: TrackEditableColumn, value: string) => { + clearPendingSingleClickSelection(); + setEditingCell({ rowId, column, value }); + }; + + const cancelEditingCell = () => { + setEditingCell(null); + }; + + const commitEditingCell = async () => { + if (!editingCell || !liveSelectedTrack) return; + + const row = trackRows.find(candidate => candidate.id === editingCell.rowId); + if (!row) { + setEditingCell(null); + return; + } + + const trimmedValue = editingCell.value.trim(); + const isDeltaEdit = trimmedValue.startsWith('+') || trimmedValue.startsWith('-'); + + if (row.type === 'region') { + const targetRows: TrackRegionRowData[] = selectedRowIdSet.has(row.id) && selectedRegionIds.length > 1 + ? trackRows.filter((candidate): candidate is TrackRegionRowData => candidate.type === 'region' && selectedRowIdSet.has(candidate.id)) + : [row]; + + 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) { + if (targetRow.region.getStartFromBeat() + parsed.deltaBeats < 0) { + await showAlert('Position delta would move one or more regions before the start of the project.'); + return; + } + } + + targetRows.forEach(targetRow => { + KGCore.instance().executeCommand(new MoveRegionCommand( + targetRow.region.getId(), + targetRow.region.getStartFromBeat() + parsed.deltaBeats, + targetRow.region.getTrackId(), + targetRow.region.getTrackIndex() + )); + }); + } 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('Position cannot be earlier than the start of the project.'); + return; + } + + targetRows.forEach(targetRow => { + KGCore.instance().executeCommand(new MoveRegionCommand( + targetRow.region.getId(), + parsed.absoluteBeat, + targetRow.region.getTrackId(), + targetRow.region.getTrackIndex() + )); + }); + } + } + + 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.region.getLength() + parsed.deltaBeats <= 0) { + await showAlert('Length delta would make one or more regions non-positive in duration.'); + return; + } + } + + targetRows.forEach(targetRow => { + KGCore.instance().executeCommand(new ResizeRegionCommand( + targetRow.region.getId(), + targetRow.region.getStartFromBeat(), + targetRow.region.getLength() + parsed.deltaBeats + )); + }); + } 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('Length must be positive.'); + return; + } + + targetRows.forEach(targetRow => { + KGCore.instance().executeCommand(new ResizeRegionCommand( + targetRow.region.getId(), + targetRow.region.getStartFromBeat(), + parsed.duration + )); + }); + } + } + } else { + const automationType = row.automationType; + const targetRows: TrackAutomationRowData[] = selectedRowIdSet.has(row.id) && selectedTrackAutomationPointIds.length > 1 + ? trackRows.filter((candidate): candidate is TrackAutomationRowData => ( + candidate.type === 'automation' + && candidate.automationType === automationType + && selectedRowIdSet.has(candidate.id) + )) + : [row]; + + const snapshots = targetRows.map(targetRow => ({ + pointId: targetRow.point.getId(), + beat: targetRow.point.getBeat(), + value: targetRow.point.getValue(), + })); + const updates: Array<{ pointId: string; beat?: number; value?: number }> = []; + + 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.point.getBeat() + parsed.deltaBeats; + if (nextBeat < 0) { + await showAlert('Position delta would move one or more automation points before the start of the project.'); + return; + } + updates.push({ pointId: targetRow.point.getId(), beat: nextBeat }); + } + } 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('Position cannot be earlier than the start of the project.'); + return; + } + + for (const targetRow of targetRows) { + updates.push({ pointId: targetRow.point.getId(), beat: parsed.absoluteBeat }); + } + } + } + + if (editingCell.column === 'val') { + const parsed = parseTrackAutomationValueInput(automationType, trimmedValue); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + for (const targetRow of targetRows) { + updates.push({ pointId: targetRow.point.getId(), value: parsed.value }); + } + } + + if (updates.length > 0) { + KGCore.instance().executeCommand(new UpdateTrackAutomationPointsCommand( + liveSelectedTrack.getId(), + automationType, + snapshots, + updates + )); + bumpTrackAutomationRedrawVersion(); + } + } + + await updateTrack(liveSelectedTrack); + 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 = trackRows.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(trackRows[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 handleAddTrackItem = async (event: React.MouseEvent) => { + event.stopPropagation(); + if (!liveSelectedTrack) return; + + if (addTrackItemType === 'midi-region') { + if (!(liveSelectedTrack instanceof KGMidiTrack)) return; + + const command = new CreateRegionCommand( + liveSelectedTrack.getId().toString(), + liveSelectedTrack.getTrackIndex(), + playheadPosition, + timeSignature.numerator + ); + KGCore.instance().executeCommand(command); + const createdRegion = command.getCreatedRegion(); + if (createdRegion) { + KGCore.instance().clearSelectedItems(); + createdRegion.select(); + KGCore.instance().addSelectedItem(createdRegion); + rangeAnchorRowIdRef.current = createdRegion.getId(); + } + } else { + const automationType: TrackAutomationType = addTrackItemType; + const value = automationType === 'volume' + ? liveSelectedTrack.getVolume() + : findPreviousPanValue(liveSelectedTrack.getPanAutomation(), playheadPosition); + + const command = new CreateTrackAutomationPointsCommand( + liveSelectedTrack.getId(), + automationType, + [{ beat: playheadPosition, value }] + ); + KGCore.instance().executeCommand(command); + const createdPointId = command.getCreatedPointIds()[0]; + const createdPoint = createdPointId + ? liveSelectedTrack.getAutomationPoints(automationType).find(point => point.getId() === createdPointId) ?? null + : null; + if (createdPoint) { + KGCore.instance().clearSelectedItems(); + createdPoint.select(); + KGCore.instance().addSelectedItem(createdPoint); + rangeAnchorRowIdRef.current = createdPoint.getId(); + } + bumpTrackAutomationRedrawVersion(); + } + + await updateTrack(liveSelectedTrack); + refreshProjectState(); + }; + + const handleDeleteSelectedRows = async (event: React.MouseEvent) => { + event.stopPropagation(); + if (!liveSelectedTrack || visibleSelectedRows.length === 0) return; + + const regionIds = visibleSelectedRows + .filter((row): row is TrackRegionRowData => row.type === 'region') + .map(row => row.region.getId()); + const volumePointIds = visibleSelectedRows + .filter((row): row is TrackAutomationRowData => row.type === 'automation' && row.automationType === 'volume') + .map(row => row.point.getId()); + const panPointIds = visibleSelectedRows + .filter((row): row is TrackAutomationRowData => row.type === 'automation' && row.automationType === 'pan') + .map(row => row.point.getId()); + + if (regionIds.length > 0) { + KGCore.instance().executeCommand(new DeleteMultipleRegionsCommand( + regionIds + )); + } + if (volumePointIds.length > 0) { + KGCore.instance().executeCommand(new DeleteTrackAutomationPointsCommand( + liveSelectedTrack.getId(), + 'volume', + volumePointIds + )); + } + if (panPointIds.length > 0) { + KGCore.instance().executeCommand(new DeleteTrackAutomationPointsCommand( + liveSelectedTrack.getId(), + 'pan', + panPointIds + )); + } + if (volumePointIds.length > 0 || panPointIds.length > 0) { + bumpTrackAutomationRedrawVersion(); + } + + rangeAnchorRowIdRef.current = null; + await updateTrack(liveSelectedTrack); + refreshProjectState(); + }; + + return ( + <> +
+ + + +
+ + {!liveSelectedTrack ? ( +
+ Please select a track to view regions and track automation. +
+ ) : ( + <> +
+
+ + setAddTrackItemType(value as AddTrackItemType)} + label="Add" + buttonClassName="list-event-type-button" + showValueAsLabel + /> +
+ +
+ +
+
+ +
+ + + + + + + + + + + {trackRows.map((row, index) => { + const positionText = formatMidiEventPosition(row.type === 'region' ? row.absoluteStartBeat : row.absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); + const statusText = row.type === 'region' ? row.statusLabel : row.automationType === 'volume' ? 'Volume' : 'Pan'; + const valText = row.type === 'region' ? row.region.getName() : formatTrackAutomationValue(row.automationType, row.point.getValue()); + const infoText = row.type === 'region' ? formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT) : formatTrackAutomationInfo(row.automationType, row.point.getValue()); + 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} { + if (row.type === 'region') return; + 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} + { + if (row.type !== 'region') return; + event.stopPropagation(); + startEditingCell(row.id, 'length', infoText); + }}> + {isEditingLength ? ( + setEditingCell({ ...editingCell, value: event.target.value })} + onBlur={handleEditInputBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { void handleEditInputKeyDown(event); }} + /> + ) : infoText} +
+
+ + )} + + ); +}; + +export default TrackListEventTab; diff --git a/src/stores/projectStore.test.ts b/src/stores/projectStore.test.ts index 2cfa240..5457b24 100644 --- a/src/stores/projectStore.test.ts +++ b/src/stores/projectStore.test.ts @@ -41,6 +41,8 @@ const mockCore = { getRedoDescription: () => '', setOnCommandHistoryChanged: vi.fn(), executeCommand: vi.fn(), + undo: vi.fn(() => true), + redo: vi.fn(() => true), clearSelectedItems: vi.fn(), getStatus: () => 'Ready', getPlayheadPosition: () => 0, @@ -83,6 +85,10 @@ describe('projectStore piano roll state', () => { mockCore.startPlaying.mockResolvedValue(undefined); mockCore.stopPlaying.mockReset(); mockCore.stopPlaying.mockResolvedValue(undefined); + mockCore.undo.mockReset(); + mockCore.undo.mockReturnValue(true); + mockCore.redo.mockReset(); + mockCore.redo.mockReturnValue(true); mockCore.setLoopBoundaryReachedCallback.mockReset(); mockAudioInterface.startAudioRecording.mockReset(); mockAudioInterface.startAudioRecording.mockResolvedValue({ usedDeviceId: 'default', fellBackToDefault: false }); @@ -226,4 +232,22 @@ describe('projectStore piano roll state', () => { expect(useProjectStore.getState().recordingMode).toBeNull(); expect(useProjectStore.getState().playheadPosition).toBe(8); }); + + it('bumps track automation redraw version on undo and redo', async () => { + const { useProjectStore } = await import('./projectStore'); + + const initialVersion = useProjectStore.getState().trackAutomationRedrawVersion; + + act(() => { + useProjectStore.getState().undo(); + }); + + expect(useProjectStore.getState().trackAutomationRedrawVersion).toBe(initialVersion + 1); + + act(() => { + useProjectStore.getState().redo(); + }); + + expect(useProjectStore.getState().trackAutomationRedrawVersion).toBe(initialVersion + 2); + }); }); diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index fbf5f1e..9540cdc 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -1676,6 +1676,7 @@ export const useProjectStore = create((set, get) => { if (core.undo()) { // Use centralized refresh method get().refreshProjectState(); + get().bumpTrackAutomationRedrawVersion(); console.log('Undo completed'); } }, @@ -1685,6 +1686,7 @@ export const useProjectStore = create((set, get) => { if (core.redo()) { // Use centralized refresh method get().refreshProjectState(); + get().bumpTrackAutomationRedrawVersion(); console.log('Redo completed'); } },