From fc1a044e98e481aea21503deba533138d003f439 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Wed, 6 May 2026 17:36:54 -0700 Subject: [PATCH] feat: added adding pitch bend feature and delete selected events feature to the list event panel --- .gitignore | 1 + src/components/ListEventPanel.css | 50 +++++- src/components/ListEventPanel.test.tsx | 8 +- src/components/ListEventPanel.tsx | 128 +++++++++++---- src/core/commands/index.ts | 1 + .../commands/note/CreateMidiEventsCommand.ts | 4 + .../note/DeleteMidiEventsCommand.test.ts | 77 +++++++++ .../commands/note/DeleteMidiEventsCommand.ts | 153 ++++++++++++++++++ 8 files changed, 382 insertions(+), 40 deletions(-) create mode 100644 src/core/commands/note/DeleteMidiEventsCommand.test.ts create mode 100644 src/core/commands/note/DeleteMidiEventsCommand.ts diff --git a/.gitignore b/.gitignore index 16c6194..837cae3 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ dist dist-ssr *.local CLAUDE.md +AGENTS.md DEPLOYMENT.md # Editor directories and files diff --git a/src/components/ListEventPanel.css b/src/components/ListEventPanel.css index 4c09cb1..cf15413 100644 --- a/src/components/ListEventPanel.css +++ b/src/components/ListEventPanel.css @@ -93,13 +93,18 @@ min-width: 0; } +.list-event-toolbar-group:first-child { + gap: 0; +} + .list-event-toolbar-group-right { margin-left: auto; + gap: 8px; } .list-event-add-button { width: 22px; - height: 20px; + height: 22px; border: 1px solid #444; border-radius: 3px; background-color: #3a3a3a; @@ -120,8 +125,8 @@ } .list-event-dropdown-button, -.list-event-quant-button { - min-width: 92px; +.list-event-quant-button, +.list-event-type-button { font-size: 11px; } @@ -129,6 +134,43 @@ 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 { flex: 1; min-height: 0; @@ -200,4 +242,4 @@ font-size: 11px; line-height: 16px; outline: none; -} +} \ No newline at end of file diff --git a/src/components/ListEventPanel.test.tsx b/src/components/ListEventPanel.test.tsx index 0c3d784..e612ef7 100644 --- a/src/components/ListEventPanel.test.tsx +++ b/src/components/ListEventPanel.test.tsx @@ -32,16 +32,18 @@ describe('ListEventPanel', () => { it('renders note and pitch bend rows and toggles them independently', () => { render(); + expect(screen.getByRole('button', { name: 'Note' })).toBeInTheDocument(); + expect(screen.getByTitle('Delete visible selected rows')).toBeDisabled(); 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(); fireEvent.click(screen.getByRole('button', { name: 'Pitch Bends' })); expect(screen.queryByText('Pitch Bend')).not.toBeInTheDocument(); - expect(screen.getByText('Note')).toBeInTheDocument(); + expect(screen.getByText('C4')).toBeInTheDocument(); 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' })); expect(screen.getByText('Pitch Bend')).toBeInTheDocument(); diff --git a/src/components/ListEventPanel.tsx b/src/components/ListEventPanel.tsx index 7db66dc..78ed952 100644 --- a/src/components/ListEventPanel.tsx +++ b/src/components/ListEventPanel.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import './ListEventPanel.css'; -import { FaPlus } from 'react-icons/fa'; +import { FaPlus, FaTrash } from 'react-icons/fa'; import KGDropdown from './common/KGDropdown'; import { useProjectStore } from '../stores/projectStore'; import { KGCore } from '../core/KGCore'; @@ -28,7 +28,7 @@ import { } from '../util/midiUtil'; import { isModifierKeyPressed } from '../util/osUtil'; 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 { UpdatePitchBendPropertiesCommand } from '../core/commands/note/UpdatePitchBendPropertiesCommand'; import { showAlert } from '../util/dialogUtil'; @@ -61,6 +61,13 @@ interface EditingCell { 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 trimmed = raw.trim(); if (!/^\d+$/.test(trimmed)) { @@ -147,6 +154,7 @@ const ListEventPanel: React.FC = ({ isVisible }) => { const [showPitchBends, setShowPitchBends] = 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); @@ -206,6 +214,7 @@ const ListEventPanel: React.FC = ({ isVisible }) => { const selectedNoteIdSet = new Set(selectedNoteIds); const selectedPitchBendIdSet = new Set(selectedPitchBendIds); const selectedEventIdSet = new Set([...selectedNoteIds, ...selectedPitchBendIds]); + const visibleSelectedRows = eventRows.filter(row => selectedEventIdSet.has(row.id)); useEffect(() => { if (editingCell) { @@ -675,41 +684,77 @@ const ListEventPanel: React.FC = ({ isVisible }) => { refreshProjectState(); }; - const handleAddNote = async (event: React.MouseEvent) => { + const handleAddEvent = async (event: React.MouseEvent) => { event.stopPropagation(); 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 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(); + 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 { + 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) => { + 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); refreshProjectState(); }; @@ -751,12 +796,20 @@ const ListEventPanel: React.FC = ({ isVisible }) => {
+ setAddEventType(value as AddEventType)} + label="Note" + buttonClassName="list-event-type-button" + showValueAsLabel + />
@@ -780,6 +833,15 @@ const ListEventPanel: React.FC = ({ isVisible }) => { label="Qua. Len." buttonClassName="list-event-quant-button" /> +
diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts index 9817ec7..5dc6515 100644 --- a/src/core/commands/index.ts +++ b/src/core/commands/index.ts @@ -30,6 +30,7 @@ export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand'; // Note commands export { CreateNoteCommand } from './note/CreateNoteCommand'; +export { DeleteMidiEventsCommand } from './note/DeleteMidiEventsCommand'; export { DeleteNotesCommand, DeleteNoteCommand } from './note/DeleteNotesCommand'; export { ResizeNotesCommand } from './note/ResizeNotesCommand'; export { MoveNotesCommand } from './note/MoveNotesCommand'; diff --git a/src/core/commands/note/CreateMidiEventsCommand.ts b/src/core/commands/note/CreateMidiEventsCommand.ts index a9185eb..43c42da 100644 --- a/src/core/commands/note/CreateMidiEventsCommand.ts +++ b/src/core/commands/note/CreateMidiEventsCommand.ts @@ -114,6 +114,10 @@ export class CreateMidiEventsCommand extends KGCommand { return this.createdNotes; } + public getCreatedPitchBends(): Array<{ pitchBend: KGMidiPitchBend; regionId: string }> { + return this.createdPitchBends; + } + public getCreatedNoteIds(): string[] { return this.noteCreationData.map(data => data.noteId!); } diff --git a/src/core/commands/note/DeleteMidiEventsCommand.test.ts b/src/core/commands/note/DeleteMidiEventsCommand.test.ts new file mode 100644 index 0000000..f19140f --- /dev/null +++ b/src/core/commands/note/DeleteMidiEventsCommand.test.ts @@ -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; + getSelectedItems: ReturnType; + removeSelectedItem: ReturnType; +} + +describe('DeleteMidiEventsCommand', () => { + let mockCore: MockCore; + let note: KGMidiNote; + let pitchBend: KGMidiPitchBend; + let region: ReturnType; + + 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'); + }); +}); diff --git a/src/core/commands/note/DeleteMidiEventsCommand.ts b/src/core/commands/note/DeleteMidiEventsCommand.ts new file mode 100644 index 0000000..30f5a4c --- /dev/null +++ b/src/core/commands/note/DeleteMidiEventsCommand.ts @@ -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`); + } +}