feat: added track level list event tab
This commit is contained in:
@@ -45,6 +45,35 @@
|
|||||||
flex-shrink: 0;
|
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 {
|
.list-event-tab {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
@@ -55,7 +84,7 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
padding: 5px 6px;
|
padding: 5px 6px;
|
||||||
cursor: default;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
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 ListEventPanel from './ListEventPanel';
|
||||||
import { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent';
|
import { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent';
|
||||||
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
||||||
import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
|
import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
|
||||||
import { KGRegion } from '../core/region/KGRegion';
|
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 {
|
import {
|
||||||
createMockMidiControllerEvent,
|
createMockMidiControllerEvent,
|
||||||
createMockMidiNote,
|
createMockMidiNote,
|
||||||
@@ -14,7 +17,14 @@ import {
|
|||||||
createMockMidiTrack,
|
createMockMidiTrack,
|
||||||
} from '../test/utils/mock-data';
|
} 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',
|
id: 'region-1',
|
||||||
trackId: '1',
|
trackId: '1',
|
||||||
trackIndex: 0,
|
trackIndex: 0,
|
||||||
@@ -25,37 +35,64 @@ const region = createMockMidiRegion({
|
|||||||
index === 11 ? [createMockMidiControllerEvent({ id: 'cc11-1', beat: 0.75, value: 100 })] : []
|
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 = {
|
type MockStoreState = {
|
||||||
tracks: typeof track[];
|
tracks: Array<typeof midiTrack | typeof audioTrack>;
|
||||||
activeRegionId: string | null;
|
activeRegionId: string | null;
|
||||||
selectedRegionIds: string[];
|
selectedRegionIds: string[];
|
||||||
|
selectedTrackId: string | null;
|
||||||
timeSignature: { numerator: number; denominator: number };
|
timeSignature: { numerator: number; denominator: number };
|
||||||
selectedNoteIds: string[];
|
selectedNoteIds: string[];
|
||||||
selectedPitchBendIds: string[];
|
selectedPitchBendIds: string[];
|
||||||
selectedControllerEventIds: string[];
|
selectedControllerEventIds: string[];
|
||||||
|
selectedTrackAutomationPointIds: string[];
|
||||||
playheadPosition: number;
|
playheadPosition: number;
|
||||||
updateTrack: ReturnType<typeof vi.fn>;
|
updateTrack: ReturnType<typeof vi.fn>;
|
||||||
refreshProjectState: ReturnType<typeof vi.fn>;
|
refreshProjectState: ReturnType<typeof vi.fn>;
|
||||||
bumpAutomationRedrawVersion: ReturnType<typeof vi.fn>;
|
bumpAutomationRedrawVersion: ReturnType<typeof vi.fn>;
|
||||||
|
bumpTrackAutomationRedrawVersion: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const storeState: MockStoreState = {
|
const storeState: MockStoreState = {
|
||||||
tracks: [track],
|
tracks: [midiTrack, audioTrack],
|
||||||
activeRegionId: 'region-1',
|
activeRegionId: 'region-1',
|
||||||
selectedRegionIds: ['region-1'],
|
selectedRegionIds: ['region-1'],
|
||||||
|
selectedTrackId: '1',
|
||||||
timeSignature: { numerator: 4, denominator: 4 },
|
timeSignature: { numerator: 4, denominator: 4 },
|
||||||
selectedNoteIds: [],
|
selectedNoteIds: [],
|
||||||
selectedPitchBendIds: [],
|
selectedPitchBendIds: [],
|
||||||
selectedControllerEventIds: [],
|
selectedControllerEventIds: [],
|
||||||
|
selectedTrackAutomationPointIds: [],
|
||||||
playheadPosition: 4,
|
playheadPosition: 4,
|
||||||
updateTrack: vi.fn().mockResolvedValue(undefined),
|
updateTrack: vi.fn().mockResolvedValue(undefined),
|
||||||
refreshProjectState: vi.fn(),
|
refreshProjectState: vi.fn(),
|
||||||
bumpAutomationRedrawVersion: vi.fn(),
|
bumpAutomationRedrawVersion: vi.fn(),
|
||||||
|
bumpTrackAutomationRedrawVersion: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let selectedItems: Array<KGRegion | KGMidiNote | KGMidiPitchBend | KGMidiControllerEvent> = [];
|
let selectedItems: Array<KGRegion | KGMidiNote | KGMidiPitchBend | KGMidiControllerEvent | KGTrackAutomationPoint> = [];
|
||||||
|
|
||||||
const syncStoreSelectionFromCore = () => {
|
const syncStoreSelectionFromCore = () => {
|
||||||
storeState.selectedRegionIds = selectedItems
|
storeState.selectedRegionIds = selectedItems
|
||||||
@@ -70,91 +107,273 @@ const syncStoreSelectionFromCore = () => {
|
|||||||
storeState.selectedControllerEventIds = selectedItems
|
storeState.selectedControllerEventIds = selectedItems
|
||||||
.filter(item => item instanceof KGMidiControllerEvent)
|
.filter(item => item instanceof KGMidiControllerEvent)
|
||||||
.map(item => item.getId());
|
.map(item => item.getId());
|
||||||
|
storeState.selectedTrackAutomationPointIds = selectedItems
|
||||||
|
.filter(item => item instanceof KGTrackAutomationPoint)
|
||||||
|
.map(item => item.getId());
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('../stores/projectStore', () => ({
|
vi.mock('../stores/projectStore', () => ({
|
||||||
useProjectStore: () => storeState,
|
useProjectStore: () => storeState,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../util/dialogUtil', () => ({
|
||||||
|
showAlert: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../core/KGCore', () => ({
|
vi.mock('../core/KGCore', () => ({
|
||||||
KGCore: {
|
KGCore: {
|
||||||
instance: vi.fn(() => ({
|
instance: vi.fn(() => ({
|
||||||
getSelectedItems: () => selectedItems,
|
getSelectedItems: () => selectedItems,
|
||||||
|
addSelectedItem: (item: typeof selectedItems[number]) => {
|
||||||
|
selectedItems = selectedItems.filter(candidate => candidate.getId() !== item.getId());
|
||||||
|
selectedItems.push(item);
|
||||||
|
syncStoreSelectionFromCore();
|
||||||
|
},
|
||||||
addSelectedItems: (items: typeof selectedItems) => {
|
addSelectedItems: (items: typeof selectedItems) => {
|
||||||
const nextIds = new Set(items.map(item => item.getId()));
|
const nextIds = new Set(items.map(item => item.getId()));
|
||||||
selectedItems = [...selectedItems.filter(item => !nextIds.has(item.getId())), ...items];
|
selectedItems = [...selectedItems.filter(item => !nextIds.has(item.getId())), ...items];
|
||||||
syncStoreSelectionFromCore();
|
syncStoreSelectionFromCore();
|
||||||
},
|
},
|
||||||
|
removeSelectedItem: (item: typeof selectedItems[number]) => {
|
||||||
|
selectedItems = selectedItems.filter(candidate => candidate.getId() !== item.getId());
|
||||||
|
syncStoreSelectionFromCore();
|
||||||
|
},
|
||||||
removeSelectedItems: (items: typeof selectedItems) => {
|
removeSelectedItems: (items: typeof selectedItems) => {
|
||||||
const removedIds = new Set(items.map(item => item.getId()));
|
const removedIds = new Set(items.map(item => item.getId()));
|
||||||
selectedItems = selectedItems.filter(item => !removedIds.has(item.getId()));
|
selectedItems = selectedItems.filter(item => !removedIds.has(item.getId()));
|
||||||
syncStoreSelectionFromCore();
|
syncStoreSelectionFromCore();
|
||||||
},
|
},
|
||||||
|
clearSelectedItems: () => {
|
||||||
|
selectedItems = [];
|
||||||
|
syncStoreSelectionFromCore();
|
||||||
|
},
|
||||||
|
executeCommand: (command: { execute: () => void }) => {
|
||||||
|
command.execute();
|
||||||
|
syncStoreSelectionFromCore();
|
||||||
|
},
|
||||||
|
getCurrentProject: () => ({
|
||||||
|
getTracks: () => storeState.tracks,
|
||||||
|
}),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('ListEventPanel', () => {
|
describe('ListEventPanel', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
selectedItems = [region];
|
selectedItems = [midiRegion];
|
||||||
region.select();
|
midiRegion.select();
|
||||||
region.getNotes().forEach(note => note.deselect());
|
secondMidiRegion.deselect();
|
||||||
region.getPitchBends().forEach(pitchBend => pitchBend.deselect());
|
audioRegion.deselect();
|
||||||
region.getControllerEventsByType().forEach(events => events.forEach(controllerEvent => controllerEvent.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.activeRegionId = 'region-1';
|
||||||
storeState.selectedRegionIds = ['region-1'];
|
storeState.selectedRegionIds = ['region-1'];
|
||||||
|
storeState.selectedTrackId = '1';
|
||||||
storeState.selectedNoteIds = [];
|
storeState.selectedNoteIds = [];
|
||||||
storeState.selectedPitchBendIds = [];
|
storeState.selectedPitchBendIds = [];
|
||||||
storeState.selectedControllerEventIds = [];
|
storeState.selectedControllerEventIds = [];
|
||||||
|
storeState.selectedTrackAutomationPointIds = [];
|
||||||
|
storeState.playheadPosition = 4;
|
||||||
storeState.updateTrack.mockClear();
|
storeState.updateTrack.mockClear();
|
||||||
storeState.refreshProjectState.mockClear();
|
storeState.refreshProjectState.mockClear();
|
||||||
storeState.bumpAutomationRedrawVersion.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(<ListEventPanel isVisible={true} />);
|
render(<ListEventPanel isVisible={true} />);
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: 'Note' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Region' })).toBeInTheDocument();
|
||||||
expect(screen.getByTitle('Delete visible selected rows')).toBeDisabled();
|
expect(screen.getByRole('button', { name: 'Track' })).toBeInTheDocument();
|
||||||
expect(screen.getByText('Pitch Bend')).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();
|
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', () => {
|
it('switches to Track tab and lists selected track regions', () => {
|
||||||
|
render(<ListEventPanel isVisible={true} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Track' }));
|
||||||
|
|
||||||
|
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('toggles track filters independently', () => {
|
||||||
|
render(<ListEventPanel isVisible={true} />);
|
||||||
|
|
||||||
|
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(<ListEventPanel isVisible={true} />);
|
||||||
|
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(<ListEventPanel isVisible={true} />);
|
const { rerender } = render(<ListEventPanel isVisible={true} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('C4').closest('tr')!);
|
fireEvent.click(screen.getByRole('button', { name: 'Track' }));
|
||||||
|
fireEvent.click(screen.getByText('Second Region').closest('tr')!);
|
||||||
rerender(<ListEventPanel isVisible={true} />);
|
rerender(<ListEventPanel isVisible={true} />);
|
||||||
|
|
||||||
expect(storeState.selectedRegionIds).toEqual(['region-1']);
|
expect(storeState.selectedRegionIds).toEqual(['region-2']);
|
||||||
expect(storeState.selectedNoteIds).toEqual(['note-1']);
|
expect(screen.getByText('Second Region').closest('tr')).toHaveClass('selected');
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports additive row selection without dropping the owning region', () => {
|
it('syncs Track Volume row selection to selectedTrackAutomationPointIds', () => {
|
||||||
const { rerender } = render(<ListEventPanel isVisible={true} />);
|
const { rerender } = render(<ListEventPanel isVisible={true} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('C4').closest('tr')!);
|
fireEvent.click(screen.getByRole('button', { name: 'Track' }));
|
||||||
rerender(<ListEventPanel isVisible={true} />);
|
fireEvent.click(screen.getByText('-6.0dB').closest('tr')!);
|
||||||
fireEvent.click(screen.getByText('Pitch Bend').closest('tr')!, { shiftKey: true });
|
|
||||||
rerender(<ListEventPanel isVisible={true} />);
|
rerender(<ListEventPanel isVisible={true} />);
|
||||||
|
|
||||||
expect(storeState.selectedRegionIds).toEqual(['region-1']);
|
expect(storeState.selectedTrackAutomationPointIds).toEqual(['vol-1']);
|
||||||
expect(storeState.selectedNoteIds).toEqual(['note-1']);
|
expect(screen.getByText('-6.0dB').closest('tr')).toHaveClass('selected');
|
||||||
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');
|
it('syncs Track Pan row selection to selectedTrackAutomationPointIds', () => {
|
||||||
|
const { rerender } = render(<ListEventPanel isVisible={true} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Track' }));
|
||||||
|
fireEvent.click(screen.getByText('-32').closest('tr')!);
|
||||||
|
rerender(<ListEventPanel isVisible={true} />);
|
||||||
|
|
||||||
|
expect(storeState.selectedTrackAutomationPointIds).toEqual(['pan-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides MIDI Region add option for audio tracks', () => {
|
||||||
|
storeState.selectedTrackId = '2';
|
||||||
|
|
||||||
|
render(<ListEventPanel isVisible={true} />);
|
||||||
|
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(<ListEventPanel isVisible={true} />);
|
||||||
|
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(<ListEventPanel isVisible={true} />);
|
||||||
|
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(<ListEventPanel isVisible={true} />);
|
||||||
|
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(<ListEventPanel isVisible={true} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Track' }));
|
||||||
|
fireEvent.click(screen.getByText('-6.0dB').closest('tr')!);
|
||||||
|
rerender(<ListEventPanel isVisible={true} />);
|
||||||
|
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(<ListEventPanel isVisible={true} />);
|
||||||
|
|
||||||
|
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(<ListEventPanel isVisible={true} />);
|
||||||
|
|
||||||
|
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));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+35
-1163
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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<TrackListEventTabProps> = ({ 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<AddTrackItemType>('midi-region');
|
||||||
|
const [editingCell, setEditingCell] = useState<TrackEditingCell | null>(null);
|
||||||
|
const rangeAnchorRowIdRef = useRef<string | null>(null);
|
||||||
|
const editInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const suppressBlurCommitRef = useRef(false);
|
||||||
|
const pendingSingleClickSelectionRef = useRef<number | null>(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<string>) => {
|
||||||
|
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<string>) => {
|
||||||
|
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<string>) => {
|
||||||
|
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<HTMLTableRowElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (editingCell) return;
|
||||||
|
|
||||||
|
const isModifierPressed = isModifierKeyPressed(event);
|
||||||
|
const nextSelectedIds = new Set(selectedRowIdSet);
|
||||||
|
const isAlreadySelected = selectedRowIdSet.has(rowId);
|
||||||
|
const hasMultiSelection = selectedRowIdSet.size > 1;
|
||||||
|
|
||||||
|
if (event.shiftKey) {
|
||||||
|
clearPendingSingleClickSelection();
|
||||||
|
const anchorIndex = 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<string>([rowId]);
|
||||||
|
rangeAnchorRowIdRef.current = rowId;
|
||||||
|
commitSelection(delayedSelection);
|
||||||
|
pendingSingleClickSelectionRef.current = null;
|
||||||
|
}, 220);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPendingSingleClickSelection();
|
||||||
|
nextSelectedIds.clear();
|
||||||
|
nextSelectedIds.add(rowId);
|
||||||
|
rangeAnchorRowIdRef.current = rowId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.shiftKey && rangeAnchorRowIdRef.current === null) {
|
||||||
|
rangeAnchorRowIdRef.current = rowId;
|
||||||
|
}
|
||||||
|
|
||||||
|
commitSelection(nextSelectedIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTableBackgroundMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.target !== event.currentTarget) return;
|
||||||
|
clearPendingSingleClickSelection();
|
||||||
|
rangeAnchorRowIdRef.current = null;
|
||||||
|
commitSelection(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditInputKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
await commitEditingCell();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
suppressBlurCommitRef.current = true;
|
||||||
|
cancelEditingCell();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditInputBlur = () => {
|
||||||
|
if (suppressBlurCommitRef.current) {
|
||||||
|
suppressBlurCommitRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void commitEditingCell();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddTrackItem = async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
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<HTMLButtonElement>) => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="list-event-tabs" role="tablist" aria-label="Track list modes">
|
||||||
|
<button className={`list-event-tab${showRegions ? ' active' : ''}`} aria-pressed={showRegions} type="button" onClick={() => setShowRegions(value => !value)}>Regions</button>
|
||||||
|
<button className={`list-event-tab${showVolume ? ' active' : ''}`} aria-pressed={showVolume} type="button" onClick={() => setShowVolume(value => !value)}>Volume</button>
|
||||||
|
<button className={`list-event-tab${showPan ? ' active' : ''}`} aria-pressed={showPan} type="button" onClick={() => setShowPan(value => !value)}>Pan</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!liveSelectedTrack ? (
|
||||||
|
<div className="list-event-empty-state">
|
||||||
|
Please select a track to view regions and track automation.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="list-event-toolbar">
|
||||||
|
<div className="list-event-toolbar-group">
|
||||||
|
<button
|
||||||
|
className="list-event-add-button"
|
||||||
|
title={addTrackItemType === 'midi-region' ? 'Add MIDI region at playhead' : addTrackItemType === 'volume' ? 'Add volume automation point at playhead' : 'Add pan automation point at playhead'}
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddTrackItem}
|
||||||
|
>
|
||||||
|
<FaPlus />
|
||||||
|
</button>
|
||||||
|
<KGDropdown
|
||||||
|
options={availableAddOptions}
|
||||||
|
value={addTrackItemType}
|
||||||
|
onChange={(value) => setAddTrackItemType(value as AddTrackItemType)}
|
||||||
|
label="Add"
|
||||||
|
buttonClassName="list-event-type-button"
|
||||||
|
showValueAsLabel
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="list-event-toolbar-group list-event-toolbar-group-right">
|
||||||
|
<button
|
||||||
|
className="list-event-delete-button"
|
||||||
|
title="Delete visible selected rows"
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteSelectedRows}
|
||||||
|
disabled={visibleSelectedRows.length === 0}
|
||||||
|
>
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="list-event-table-shell" onMouseDown={handleTableBackgroundMouseDown}>
|
||||||
|
<table className="list-event-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Position</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Val</th>
|
||||||
|
<th>Length/Info</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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 (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className={selectedRowIdSet.has(row.id) ? 'selected' : ''}
|
||||||
|
onClick={(event) => handleRowClick(row.id, index, event)}
|
||||||
|
onDoubleClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
clearPendingSingleClickSelection();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td title={positionText} onDoubleClick={(event) => { event.stopPropagation(); startEditingCell(row.id, 'position', positionText); }}>
|
||||||
|
{isEditingPosition ? (
|
||||||
|
<input
|
||||||
|
ref={editInputRef}
|
||||||
|
className="list-event-cell-input"
|
||||||
|
value={editingCell.value}
|
||||||
|
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||||
|
onBlur={handleEditInputBlur}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||||
|
/>
|
||||||
|
) : positionText}
|
||||||
|
</td>
|
||||||
|
<td title={statusText}>{statusText}</td>
|
||||||
|
<td title={valText} onDoubleClick={(event) => {
|
||||||
|
if (row.type === 'region') return;
|
||||||
|
event.stopPropagation();
|
||||||
|
startEditingCell(row.id, 'val', valText);
|
||||||
|
}}>
|
||||||
|
{isEditingVal ? (
|
||||||
|
<input
|
||||||
|
ref={editInputRef}
|
||||||
|
className="list-event-cell-input"
|
||||||
|
value={editingCell.value}
|
||||||
|
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||||
|
onBlur={handleEditInputBlur}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||||
|
/>
|
||||||
|
) : valText}
|
||||||
|
</td>
|
||||||
|
<td title={infoText} onDoubleClick={(event) => {
|
||||||
|
if (row.type !== 'region') return;
|
||||||
|
event.stopPropagation();
|
||||||
|
startEditingCell(row.id, 'length', infoText);
|
||||||
|
}}>
|
||||||
|
{isEditingLength ? (
|
||||||
|
<input
|
||||||
|
ref={editInputRef}
|
||||||
|
className="list-event-cell-input"
|
||||||
|
value={editingCell.value}
|
||||||
|
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||||
|
onBlur={handleEditInputBlur}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||||
|
/>
|
||||||
|
) : infoText}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrackListEventTab;
|
||||||
@@ -41,6 +41,8 @@ const mockCore = {
|
|||||||
getRedoDescription: () => '',
|
getRedoDescription: () => '',
|
||||||
setOnCommandHistoryChanged: vi.fn(),
|
setOnCommandHistoryChanged: vi.fn(),
|
||||||
executeCommand: vi.fn(),
|
executeCommand: vi.fn(),
|
||||||
|
undo: vi.fn(() => true),
|
||||||
|
redo: vi.fn(() => true),
|
||||||
clearSelectedItems: vi.fn(),
|
clearSelectedItems: vi.fn(),
|
||||||
getStatus: () => 'Ready',
|
getStatus: () => 'Ready',
|
||||||
getPlayheadPosition: () => 0,
|
getPlayheadPosition: () => 0,
|
||||||
@@ -83,6 +85,10 @@ describe('projectStore piano roll state', () => {
|
|||||||
mockCore.startPlaying.mockResolvedValue(undefined);
|
mockCore.startPlaying.mockResolvedValue(undefined);
|
||||||
mockCore.stopPlaying.mockReset();
|
mockCore.stopPlaying.mockReset();
|
||||||
mockCore.stopPlaying.mockResolvedValue(undefined);
|
mockCore.stopPlaying.mockResolvedValue(undefined);
|
||||||
|
mockCore.undo.mockReset();
|
||||||
|
mockCore.undo.mockReturnValue(true);
|
||||||
|
mockCore.redo.mockReset();
|
||||||
|
mockCore.redo.mockReturnValue(true);
|
||||||
mockCore.setLoopBoundaryReachedCallback.mockReset();
|
mockCore.setLoopBoundaryReachedCallback.mockReset();
|
||||||
mockAudioInterface.startAudioRecording.mockReset();
|
mockAudioInterface.startAudioRecording.mockReset();
|
||||||
mockAudioInterface.startAudioRecording.mockResolvedValue({ usedDeviceId: 'default', fellBackToDefault: false });
|
mockAudioInterface.startAudioRecording.mockResolvedValue({ usedDeviceId: 'default', fellBackToDefault: false });
|
||||||
@@ -226,4 +232,22 @@ describe('projectStore piano roll state', () => {
|
|||||||
expect(useProjectStore.getState().recordingMode).toBeNull();
|
expect(useProjectStore.getState().recordingMode).toBeNull();
|
||||||
expect(useProjectStore.getState().playheadPosition).toBe(8);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1676,6 +1676,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
if (core.undo()) {
|
if (core.undo()) {
|
||||||
// Use centralized refresh method
|
// Use centralized refresh method
|
||||||
get().refreshProjectState();
|
get().refreshProjectState();
|
||||||
|
get().bumpTrackAutomationRedrawVersion();
|
||||||
console.log('Undo completed');
|
console.log('Undo completed');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1685,6 +1686,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
if (core.redo()) {
|
if (core.redo()) {
|
||||||
// Use centralized refresh method
|
// Use centralized refresh method
|
||||||
get().refreshProjectState();
|
get().refreshProjectState();
|
||||||
|
get().bumpTrackAutomationRedrawVersion();
|
||||||
console.log('Redo completed');
|
console.log('Redo completed');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user