feat: added adding pitch bend feature and delete selected events feature to the list event panel
This commit is contained in:
@@ -14,6 +14,7 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
AGENTS.md
|
||||||
DEPLOYMENT.md
|
DEPLOYMENT.md
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
|
|||||||
@@ -93,13 +93,18 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-event-toolbar-group:first-child {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.list-event-toolbar-group-right {
|
.list-event-toolbar-group-right {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-add-button {
|
.list-event-add-button {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 20px;
|
height: 22px;
|
||||||
border: 1px solid #444;
|
border: 1px solid #444;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background-color: #3a3a3a;
|
background-color: #3a3a3a;
|
||||||
@@ -120,8 +125,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-event-dropdown-button,
|
.list-event-dropdown-button,
|
||||||
.list-event-quant-button {
|
.list-event-quant-button,
|
||||||
min-width: 92px;
|
.list-event-type-button {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +134,43 @@
|
|||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-event-quant-button {
|
||||||
|
min-width: 78px;
|
||||||
|
padding: 3px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-event-type-button {
|
||||||
|
min-width: 88px;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-event-delete-button {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background-color 0.18s ease, border-color 0.18s ease, opacity 0.18s ease;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-event-delete-button:hover:not(:disabled) {
|
||||||
|
background-color: #464646;
|
||||||
|
border-color: #5a5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-event-delete-button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.list-event-table-shell {
|
.list-event-table-shell {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -200,4 +242,4 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
@@ -32,16 +32,18 @@ describe('ListEventPanel', () => {
|
|||||||
it('renders note and pitch bend rows and toggles them independently', () => {
|
it('renders note and pitch bend rows and toggles them independently', () => {
|
||||||
render(<ListEventPanel isVisible={true} />);
|
render(<ListEventPanel isVisible={true} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Note' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByTitle('Delete visible selected rows')).toBeDisabled();
|
||||||
expect(screen.getByText('Pitch Bend')).toBeInTheDocument();
|
expect(screen.getByText('Pitch Bend')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Note')).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' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Pitch Bends' }));
|
||||||
expect(screen.queryByText('Pitch Bend')).not.toBeInTheDocument();
|
expect(screen.queryByText('Pitch Bend')).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('Note')).toBeInTheDocument();
|
expect(screen.getByText('C4')).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Notes' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Notes' }));
|
||||||
expect(screen.queryByText('Note')).not.toBeInTheDocument();
|
expect(screen.queryByText('C4')).not.toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Pitch Bends' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Pitch Bends' }));
|
||||||
expect(screen.getByText('Pitch Bend')).toBeInTheDocument();
|
expect(screen.getByText('Pitch Bend')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import './ListEventPanel.css';
|
import './ListEventPanel.css';
|
||||||
import { FaPlus } from 'react-icons/fa';
|
import { FaPlus, FaTrash } from 'react-icons/fa';
|
||||||
import KGDropdown from './common/KGDropdown';
|
import KGDropdown from './common/KGDropdown';
|
||||||
import { useProjectStore } from '../stores/projectStore';
|
import { useProjectStore } from '../stores/projectStore';
|
||||||
import { KGCore } from '../core/KGCore';
|
import { KGCore } from '../core/KGCore';
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
} from '../util/midiUtil';
|
} from '../util/midiUtil';
|
||||||
import { isModifierKeyPressed } from '../util/osUtil';
|
import { isModifierKeyPressed } from '../util/osUtil';
|
||||||
import { PIANO_ROLL_CONSTANTS } from '../constants';
|
import { PIANO_ROLL_CONSTANTS } from '../constants';
|
||||||
import { CreateNoteCommand } from '../core/commands';
|
import { CreateMidiEventsCommand, CreateNoteCommand, DeleteMidiEventsCommand } from '../core/commands';
|
||||||
import { UpdateNotePropertiesCommand } from '../core/commands/note/UpdateNotePropertiesCommand';
|
import { UpdateNotePropertiesCommand } from '../core/commands/note/UpdateNotePropertiesCommand';
|
||||||
import { UpdatePitchBendPropertiesCommand } from '../core/commands/note/UpdatePitchBendPropertiesCommand';
|
import { UpdatePitchBendPropertiesCommand } from '../core/commands/note/UpdatePitchBendPropertiesCommand';
|
||||||
import { showAlert } from '../util/dialogUtil';
|
import { showAlert } from '../util/dialogUtil';
|
||||||
@@ -61,6 +61,13 @@ interface EditingCell {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AddEventType = 'note' | 'pitch-bend';
|
||||||
|
|
||||||
|
const ADD_EVENT_TYPE_OPTIONS = [
|
||||||
|
{ label: 'Note', value: 'note' },
|
||||||
|
{ label: 'Pitch Bend', value: 'pitch-bend' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
const parseVelocityInput = (raw: string): { velocity: number } | { error: string } => {
|
const parseVelocityInput = (raw: string): { velocity: number } | { error: string } => {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!/^\d+$/.test(trimmed)) {
|
if (!/^\d+$/.test(trimmed)) {
|
||||||
@@ -147,6 +154,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
const [showPitchBends, setShowPitchBends] = useState(true);
|
const [showPitchBends, setShowPitchBends] = useState(true);
|
||||||
const [quantPosition, setQuantPosition] = useState<string>('1/8');
|
const [quantPosition, setQuantPosition] = useState<string>('1/8');
|
||||||
const [quantLength, setQuantLength] = useState<string>('1/8');
|
const [quantLength, setQuantLength] = useState<string>('1/8');
|
||||||
|
const [addEventType, setAddEventType] = useState<AddEventType>('note');
|
||||||
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
||||||
const rangeAnchorEventIdRef = useRef<string | null>(null);
|
const rangeAnchorEventIdRef = useRef<string | null>(null);
|
||||||
const editInputRef = useRef<HTMLInputElement | null>(null);
|
const editInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
@@ -206,6 +214,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
const selectedNoteIdSet = new Set(selectedNoteIds);
|
const selectedNoteIdSet = new Set(selectedNoteIds);
|
||||||
const selectedPitchBendIdSet = new Set(selectedPitchBendIds);
|
const selectedPitchBendIdSet = new Set(selectedPitchBendIds);
|
||||||
const selectedEventIdSet = new Set([...selectedNoteIds, ...selectedPitchBendIds]);
|
const selectedEventIdSet = new Set([...selectedNoteIds, ...selectedPitchBendIds]);
|
||||||
|
const visibleSelectedRows = eventRows.filter(row => selectedEventIdSet.has(row.id));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingCell) {
|
if (editingCell) {
|
||||||
@@ -675,41 +684,77 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
refreshProjectState();
|
refreshProjectState();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddNote = async (event: React.MouseEvent<HTMLButtonElement>) => {
|
const handleAddEvent = async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (!activeMidiRegion || !parentTrack) return;
|
if (!activeMidiRegion || !parentTrack) return;
|
||||||
|
|
||||||
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 regionRelativePlayhead = Math.max(0, playheadPosition - activeMidiRegion.getStartFromBeat());
|
const regionRelativePlayhead = Math.max(0, playheadPosition - activeMidiRegion.getStartFromBeat());
|
||||||
const command = new CreateNoteCommand(
|
|
||||||
activeMidiRegion.getId(),
|
|
||||||
regionRelativePlayhead,
|
|
||||||
regionRelativePlayhead + defaultLength,
|
|
||||||
defaultPitch,
|
|
||||||
defaultVelocity
|
|
||||||
);
|
|
||||||
|
|
||||||
KGCore.instance().executeCommand(command);
|
if (addEventType === 'note') {
|
||||||
KGPianoRollState.instance().setLastEditedNoteLength(defaultLength);
|
const lastSelectedNoteId = [...selectedNoteIds]
|
||||||
const createdNote = command.getCreatedNote();
|
.reverse()
|
||||||
if (createdNote) {
|
.find(noteId => activeMidiRegion.getNotes().some(note => note.getId() === noteId));
|
||||||
createdNote.select();
|
const lastSelectedNote = lastSelectedNoteId
|
||||||
KGCore.instance().clearSelectedItems();
|
? activeMidiRegion.getNotes().find(note => note.getId() === lastSelectedNoteId) ?? null
|
||||||
KGCore.instance().addSelectedItem(createdNote);
|
: null;
|
||||||
rangeAnchorEventIdRef.current = createdNote.getId();
|
|
||||||
|
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 {
|
||||||
|
const command = new CreateMidiEventsCommand([], [{
|
||||||
|
regionId: activeMidiRegion.getId(),
|
||||||
|
beat: regionRelativePlayhead,
|
||||||
|
value: MIDI_PITCH_BEND_CENTER,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
KGCore.instance().executeCommand(command);
|
||||||
|
const createdPitchBend = command.getCreatedPitchBends()[0]?.pitchBend;
|
||||||
|
if (createdPitchBend) {
|
||||||
|
createdPitchBend.select();
|
||||||
|
KGCore.instance().clearSelectedItems();
|
||||||
|
KGCore.instance().addSelectedItem(createdPitchBend);
|
||||||
|
rangeAnchorEventIdRef.current = createdPitchBend.getId();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await updateTrack(parentTrack);
|
||||||
|
refreshProjectState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSelectedRows = async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
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());
|
||||||
|
|
||||||
|
KGCore.instance().executeCommand(new DeleteMidiEventsCommand(noteIds, pitchBendIds));
|
||||||
|
rangeAnchorEventIdRef.current = null;
|
||||||
await updateTrack(parentTrack);
|
await updateTrack(parentTrack);
|
||||||
refreshProjectState();
|
refreshProjectState();
|
||||||
};
|
};
|
||||||
@@ -751,12 +796,20 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
<div className="list-event-toolbar-group">
|
<div className="list-event-toolbar-group">
|
||||||
<button
|
<button
|
||||||
className="list-event-add-button"
|
className="list-event-add-button"
|
||||||
title="Add note at playhead"
|
title={addEventType === 'note' ? 'Add note at playhead' : 'Add pitch bend at playhead'}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAddNote}
|
onClick={handleAddEvent}
|
||||||
>
|
>
|
||||||
<FaPlus />
|
<FaPlus />
|
||||||
</button>
|
</button>
|
||||||
|
<KGDropdown
|
||||||
|
options={[...ADD_EVENT_TYPE_OPTIONS]}
|
||||||
|
value={addEventType}
|
||||||
|
onChange={(value) => setAddEventType(value as AddEventType)}
|
||||||
|
label="Note"
|
||||||
|
buttonClassName="list-event-type-button"
|
||||||
|
showValueAsLabel
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="list-event-toolbar-group list-event-toolbar-group-right">
|
<div className="list-event-toolbar-group list-event-toolbar-group-right">
|
||||||
@@ -780,6 +833,15 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
label="Qua. Len."
|
label="Qua. Len."
|
||||||
buttonClassName="list-event-quant-button"
|
buttonClassName="list-event-quant-button"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
className="list-event-delete-button"
|
||||||
|
title="Delete visible selected rows"
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteSelectedRows}
|
||||||
|
disabled={visibleSelectedRows.length === 0}
|
||||||
|
>
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
|
|||||||
|
|
||||||
// Note commands
|
// Note commands
|
||||||
export { CreateNoteCommand } from './note/CreateNoteCommand';
|
export { CreateNoteCommand } from './note/CreateNoteCommand';
|
||||||
|
export { DeleteMidiEventsCommand } from './note/DeleteMidiEventsCommand';
|
||||||
export { DeleteNotesCommand, DeleteNoteCommand } from './note/DeleteNotesCommand';
|
export { DeleteNotesCommand, DeleteNoteCommand } from './note/DeleteNotesCommand';
|
||||||
export { ResizeNotesCommand } from './note/ResizeNotesCommand';
|
export { ResizeNotesCommand } from './note/ResizeNotesCommand';
|
||||||
export { MoveNotesCommand } from './note/MoveNotesCommand';
|
export { MoveNotesCommand } from './note/MoveNotesCommand';
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ export class CreateMidiEventsCommand extends KGCommand {
|
|||||||
return this.createdNotes;
|
return this.createdNotes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getCreatedPitchBends(): Array<{ pitchBend: KGMidiPitchBend; regionId: string }> {
|
||||||
|
return this.createdPitchBends;
|
||||||
|
}
|
||||||
|
|
||||||
public getCreatedNoteIds(): string[] {
|
public getCreatedNoteIds(): string[] {
|
||||||
return this.noteCreationData.map(data => data.noteId!);
|
return this.noteCreationData.map(data => data.noteId!);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { DeleteMidiEventsCommand } from './DeleteMidiEventsCommand';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
|
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||||
|
import { createMockMidiRegion, createMockMidiTrack, createMockProject } from '../../../test/utils/mock-data';
|
||||||
|
|
||||||
|
vi.mock('../../KGCore', () => ({
|
||||||
|
KGCore: {
|
||||||
|
instance: vi.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface MockCore {
|
||||||
|
getCurrentProject: ReturnType<typeof vi.fn>;
|
||||||
|
getSelectedItems: ReturnType<typeof vi.fn>;
|
||||||
|
removeSelectedItem: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DeleteMidiEventsCommand', () => {
|
||||||
|
let mockCore: MockCore;
|
||||||
|
let note: KGMidiNote;
|
||||||
|
let pitchBend: KGMidiPitchBend;
|
||||||
|
let region: ReturnType<typeof createMockMidiRegion>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
note = new KGMidiNote('note-1', 1, 2, 60, 100);
|
||||||
|
pitchBend = new KGMidiPitchBend('bend-1', 1.5, 12288);
|
||||||
|
|
||||||
|
region = createMockMidiRegion({
|
||||||
|
id: 'region-1',
|
||||||
|
trackId: 'track-1',
|
||||||
|
trackIndex: 0,
|
||||||
|
notes: [note],
|
||||||
|
pitchBends: [pitchBend],
|
||||||
|
});
|
||||||
|
|
||||||
|
const track = createMockMidiTrack({
|
||||||
|
id: 1,
|
||||||
|
regions: [region],
|
||||||
|
});
|
||||||
|
|
||||||
|
const project = createMockProject({
|
||||||
|
tracks: [track],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockCore = {
|
||||||
|
getCurrentProject: vi.fn().mockReturnValue(project),
|
||||||
|
getSelectedItems: vi.fn(() => [note, pitchBend]),
|
||||||
|
removeSelectedItem: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes selected notes and pitch bends together', () => {
|
||||||
|
const command = new DeleteMidiEventsCommand(['note-1'], ['bend-1']);
|
||||||
|
|
||||||
|
command.execute();
|
||||||
|
|
||||||
|
expect(region.getNotes()).toHaveLength(0);
|
||||||
|
expect(region.getPitchBends()).toHaveLength(0);
|
||||||
|
expect(mockCore.removeSelectedItem).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores deleted notes and pitch bends on undo', () => {
|
||||||
|
const command = new DeleteMidiEventsCommand(['note-1'], ['bend-1']);
|
||||||
|
|
||||||
|
command.execute();
|
||||||
|
command.undo();
|
||||||
|
|
||||||
|
expect(region.getNotes()).toHaveLength(1);
|
||||||
|
expect(region.getNotes()[0].getId()).toBe('note-1');
|
||||||
|
expect(region.getPitchBends()).toHaveLength(1);
|
||||||
|
expect(region.getPitchBends()[0].getId()).toBe('bend-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { KGCommand } from '../KGCommand';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
|
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||||
|
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||||
|
|
||||||
|
interface DeletedNoteData {
|
||||||
|
note: KGMidiNote;
|
||||||
|
regionId: string;
|
||||||
|
originalIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeletedPitchBendData {
|
||||||
|
pitchBend: KGMidiPitchBend;
|
||||||
|
regionId: string;
|
||||||
|
originalIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeleteMidiEventsCommand extends KGCommand {
|
||||||
|
private noteIds: string[];
|
||||||
|
private pitchBendIds: string[];
|
||||||
|
private deletedNoteData: DeletedNoteData[] = [];
|
||||||
|
private deletedPitchBendData: DeletedPitchBendData[] = [];
|
||||||
|
|
||||||
|
constructor(noteIds: string[] = [], pitchBendIds: string[] = []) {
|
||||||
|
super();
|
||||||
|
this.noteIds = noteIds;
|
||||||
|
this.pitchBendIds = pitchBendIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
const core = KGCore.instance();
|
||||||
|
const tracks = core.getCurrentProject().getTracks();
|
||||||
|
|
||||||
|
this.deletedNoteData = [];
|
||||||
|
this.deletedPitchBendData = [];
|
||||||
|
|
||||||
|
for (const noteId of this.noteIds) {
|
||||||
|
for (const track of tracks) {
|
||||||
|
for (const region of track.getRegions()) {
|
||||||
|
if (!(region instanceof KGMidiRegion)) continue;
|
||||||
|
|
||||||
|
const noteIndex = region.getNotes().findIndex(note => note.getId() === noteId);
|
||||||
|
if (noteIndex === -1) continue;
|
||||||
|
|
||||||
|
this.deletedNoteData.push({
|
||||||
|
note: region.getNotes()[noteIndex],
|
||||||
|
regionId: region.getId(),
|
||||||
|
originalIndex: noteIndex,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pitchBendId of this.pitchBendIds) {
|
||||||
|
for (const track of tracks) {
|
||||||
|
for (const region of track.getRegions()) {
|
||||||
|
if (!(region instanceof KGMidiRegion)) continue;
|
||||||
|
|
||||||
|
const pitchBendIndex = region.getPitchBends().findIndex(pitchBend => pitchBend.getId() === pitchBendId);
|
||||||
|
if (pitchBendIndex === -1) continue;
|
||||||
|
|
||||||
|
this.deletedPitchBendData.push({
|
||||||
|
pitchBend: region.getPitchBends()[pitchBendIndex],
|
||||||
|
regionId: region.getId(),
|
||||||
|
originalIndex: pitchBendIndex,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.deletedNoteData.length === 0 && this.deletedPitchBendData.length === 0) {
|
||||||
|
throw new Error('No MIDI events found to delete');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deletedNoteData.sort((a, b) => b.originalIndex - a.originalIndex);
|
||||||
|
this.deletedPitchBendData.sort((a, b) => b.originalIndex - a.originalIndex);
|
||||||
|
|
||||||
|
for (const data of this.deletedNoteData) {
|
||||||
|
const region = this.resolveRegion(tracks, data.regionId);
|
||||||
|
region.removeNote(data.note.getId());
|
||||||
|
|
||||||
|
const selectedNote = core.getSelectedItems().find(item => item instanceof KGMidiNote && item.getId() === data.note.getId());
|
||||||
|
if (selectedNote) {
|
||||||
|
core.removeSelectedItem(selectedNote);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const data of this.deletedPitchBendData) {
|
||||||
|
const region = this.resolveRegion(tracks, data.regionId);
|
||||||
|
region.removePitchBend(data.pitchBend.getId());
|
||||||
|
|
||||||
|
const selectedPitchBend = core.getSelectedItems().find(
|
||||||
|
item => item instanceof KGMidiPitchBend && item.getId() === data.pitchBend.getId()
|
||||||
|
);
|
||||||
|
if (selectedPitchBend) {
|
||||||
|
core.removeSelectedItem(selectedPitchBend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
const tracks = KGCore.instance().getCurrentProject().getTracks();
|
||||||
|
|
||||||
|
for (const data of [...this.deletedNoteData].sort((a, b) => a.originalIndex - b.originalIndex)) {
|
||||||
|
const region = this.resolveRegion(tracks, data.regionId);
|
||||||
|
const notes = region.getNotes();
|
||||||
|
if (data.originalIndex >= 0 && data.originalIndex <= notes.length) {
|
||||||
|
notes.splice(data.originalIndex, 0, data.note);
|
||||||
|
region.setNotes(notes);
|
||||||
|
} else {
|
||||||
|
region.addNote(data.note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const data of [...this.deletedPitchBendData].sort((a, b) => a.originalIndex - b.originalIndex)) {
|
||||||
|
const region = this.resolveRegion(tracks, data.regionId);
|
||||||
|
const pitchBends = region.getPitchBends();
|
||||||
|
if (data.originalIndex >= 0 && data.originalIndex <= pitchBends.length) {
|
||||||
|
pitchBends.splice(data.originalIndex, 0, data.pitchBend);
|
||||||
|
region.setPitchBends(pitchBends);
|
||||||
|
} else {
|
||||||
|
region.addPitchBend(data.pitchBend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
const noteCount = this.noteIds.length;
|
||||||
|
const pitchBendCount = this.pitchBendIds.length;
|
||||||
|
|
||||||
|
if (noteCount > 0 && pitchBendCount > 0) {
|
||||||
|
return `Delete ${noteCount} note${noteCount === 1 ? '' : 's'} and ${pitchBendCount} pitch bend${pitchBendCount === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
if (pitchBendCount > 0) {
|
||||||
|
return pitchBendCount === 1 ? 'Delete pitch bend' : `Delete ${pitchBendCount} pitch bends`;
|
||||||
|
}
|
||||||
|
return noteCount === 1 ? 'Delete note' : `Delete ${noteCount} notes`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveRegion(tracks: Array<{ getRegions(): unknown[] }>, regionId: string): KGMidiRegion {
|
||||||
|
for (const track of tracks) {
|
||||||
|
const region = track.getRegions().find(candidate => candidate instanceof KGMidiRegion && candidate.getId() === regionId);
|
||||||
|
if (region instanceof KGMidiRegion) {
|
||||||
|
return region;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`MIDI region with ID ${regionId} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user