From 93450aebec3df52cb480f7d7d89721f485415c26 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:28:56 -0700 Subject: [PATCH] feat: misc behavior changes --- .../event-list-panel/RegionEventListTab.tsx | 5 +- src/core/state/KGPianoRollState.ts | 9 + src/hooks/useNoteOperations.test.ts | 157 +++++++++++++++++- src/hooks/useNoteOperations.ts | 7 +- src/hooks/useNoteSelection.ts | 3 + src/stores/projectStore.test.ts | 106 +++++++++++- src/stores/projectStore.ts | 22 ++- 7 files changed, 297 insertions(+), 12 deletions(-) diff --git a/src/components/event-list-panel/RegionEventListTab.tsx b/src/components/event-list-panel/RegionEventListTab.tsx index 561f831..b411d08 100644 --- a/src/components/event-list-panel/RegionEventListTab.tsx +++ b/src/components/event-list-panel/RegionEventListTab.tsx @@ -843,7 +843,9 @@ const RegionEventListTab: React.FC = ({ activeMidiRegio ? lastSelectedNote.getEndBeat() - lastSelectedNote.getStartBeat() : KGPianoRollState.instance().getLastEditedNoteLength(); const defaultPitch = lastSelectedNote ? lastSelectedNote.getPitch() : noteNameToPitch('C4'); - const defaultVelocity = lastSelectedNote ? lastSelectedNote.getVelocity() : 127; + const defaultVelocity = lastSelectedNote + ? lastSelectedNote.getVelocity() + : KGPianoRollState.instance().getLastEditedNoteVelocity(); const command = new CreateNoteCommand( activeMidiRegion.getId(), @@ -855,6 +857,7 @@ const RegionEventListTab: React.FC = ({ activeMidiRegio KGCore.instance().executeCommand(command); KGPianoRollState.instance().setLastEditedNoteLength(defaultLength); + KGPianoRollState.instance().setLastEditedNoteVelocity(defaultVelocity); const createdNote = command.getCreatedNote(); if (createdNote) { createdNote.select(); diff --git a/src/core/state/KGPianoRollState.ts b/src/core/state/KGPianoRollState.ts index e52a7d0..4e74757 100644 --- a/src/core/state/KGPianoRollState.ts +++ b/src/core/state/KGPianoRollState.ts @@ -55,6 +55,7 @@ export class KGPianoRollState { private activeTool: string = "pointer"; private currentSnap: PianoRollSnapValue = PIANO_ROLL_NO_SNAP; private lastEditedNoteLength: number = 1; // Default to 1 beat + private lastEditedNoteVelocity: number = 127; private currentMode: string = "ionian"; // Default mode private automationViewEnabled: boolean = false; private currentAutomationType: string = "pitch-bend"; @@ -108,6 +109,14 @@ export class KGPianoRollState { this.lastEditedNoteLength = length; } + public getLastEditedNoteVelocity(): number { + return this.lastEditedNoteVelocity; + } + + public setLastEditedNoteVelocity(velocity: number): void { + this.lastEditedNoteVelocity = velocity; + } + public getCurrentMode(): string { return this.currentMode; } diff --git a/src/hooks/useNoteOperations.test.ts b/src/hooks/useNoteOperations.test.ts index d0a3c6f..dcc9c15 100644 --- a/src/hooks/useNoteOperations.test.ts +++ b/src/hooks/useNoteOperations.test.ts @@ -59,10 +59,22 @@ vi.mock('../stores/projectStore', () => ({ }), })); +vi.mock('../core/audio-interface/KGAudioInterface', () => ({ + KGAudioInterface: { + instance: () => ({ + getIsInitialized: () => false, + getIsAudioContextStarted: () => false, + startAudioContext: vi.fn(), + triggerNote: vi.fn(), + }), + }, +})); + import { useNoteOperations } from './useNoteOperations'; import { KGCore } from '../core/KGCore'; import { KGPianoRollState } from '../core/state/KGPianoRollState'; -import { MoveNotesCommand, ResizeNotesCommand } from '../core/commands'; +import { CreateNoteCommand, MoveNotesCommand, ResizeNotesCommand } from '../core/commands'; +import { CreateNotesCommand } from '../core/commands/note/CreateNotesCommand'; import { useProjectStore } from '../stores/projectStore'; import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack } from '../test/utils/mock-data'; @@ -91,20 +103,157 @@ describe('useNoteOperations', () => { KGPianoRollState.instance().setActiveTool('pointer'); KGPianoRollState.instance().setCurrentSnap('1/4'); + KGPianoRollState.instance().setLastEditedNoteLength(1); + KGPianoRollState.instance().setLastEditedNoteVelocity(127); + KGPianoRollState.instance().setCurrentMatchingChords([]); + KGPianoRollState.instance().setCurrentSelectedChordIndex(0); + KGPianoRollState.instance().setCurrentChordCursorPitch(null); }); - const renderNoteOperations = (activeRegion: ReturnType, track = createMockMidiTrack({ id: 1, regions: [activeRegion] }), updateTrack = vi.fn()) => { + const createPianoGridRef = () => ({ + current: { + getBoundingClientRect: () => ({ + left: 0, + top: 0, + right: 800, + bottom: 600, + width: 800, + height: 600, + x: 0, + y: 0, + toJSON: () => ({}), + }), + } as HTMLDivElement, + }); + + const createGridClickEvent = (overrides: Partial = {}): React.MouseEvent => ({ + clientX: 80, + clientY: 120, + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + ...overrides, + } as React.MouseEvent); + + const renderNoteOperations = ( + activeRegion: ReturnType, + track = createMockMidiTrack({ id: 1, regions: [activeRegion] }), + updateTrack = vi.fn(), + pianoGridRef = createPianoGridRef(), + ) => { const hook = renderHook(() => useNoteOperations({ activeRegion, timeSignature: { numerator: 4, denominator: 4 }, updateTrack, tracks: [track], - pianoGridRef: { current: null }, + pianoGridRef, })); - return { ...hook, track, updateTrack }; + return { ...hook, track, updateTrack, pianoGridRef }; }; + it('uses the last selected note velocity when creating after deselecting', () => { + const selectedNote = createMockMidiNote({ id: 'note-a', velocity: 91, pitch: 60 }); + const activeRegion = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + notes: [selectedNote], + }); + + selectedNote.select(); + KGCore.instance().addSelectedItem(selectedNote); + KGPianoRollState.instance().setLastEditedNoteVelocity(selectedNote.getVelocity()); + KGCore.instance().clearSelectedItems(); + + const { result } = renderNoteOperations(activeRegion); + + act(() => { + result.current.handleGridDoubleClick(createGridClickEvent()); + }); + + expect(coreState.executeCommand).toHaveBeenCalledTimes(1); + const createCommand = coreState.executeCommand.mock.calls[0][0] as CreateNoteCommand & { velocity: number }; + expect(createCommand).toBeInstanceOf(CreateNoteCommand); + expect(createCommand.velocity).toBe(91); + }); + + it('uses the most recently selected note velocity after deselecting a multi-selection', () => { + const noteA = createMockMidiNote({ id: 'note-a', velocity: 40, pitch: 60 }); + const noteB = createMockMidiNote({ id: 'note-b', velocity: 105, pitch: 64 }); + const activeRegion = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + notes: [noteA, noteB], + }); + + noteA.select(); + noteB.select(); + KGCore.instance().addSelectedItem(noteA); + KGCore.instance().addSelectedItem(noteB); + KGPianoRollState.instance().setLastEditedNoteVelocity(noteB.getVelocity()); + KGCore.instance().clearSelectedItems(); + + const { result } = renderNoteOperations(activeRegion); + + act(() => { + result.current.handleGridDoubleClick(createGridClickEvent()); + }); + + expect(coreState.executeCommand).toHaveBeenCalledTimes(1); + const createCommand = coreState.executeCommand.mock.calls[0][0] as CreateNoteCommand & { velocity: number }; + expect(createCommand).toBeInstanceOf(CreateNoteCommand); + expect(createCommand.velocity).toBe(105); + }); + + it('falls back to velocity 127 when creating a manual note with no selection', () => { + const activeRegion = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + notes: [], + }); + + const { result } = renderNoteOperations(activeRegion); + + act(() => { + result.current.handleGridDoubleClick(createGridClickEvent()); + }); + + expect(coreState.executeCommand).toHaveBeenCalledTimes(1); + const createCommand = coreState.executeCommand.mock.calls[0][0] as CreateNoteCommand & { velocity: number }; + expect(createCommand).toBeInstanceOf(CreateNoteCommand); + expect(createCommand.velocity).toBe(127); + }); + + it('applies the cached velocity to every note in manual chord creation after deselecting', () => { + const selectedNote = createMockMidiNote({ id: 'note-a', velocity: 73, pitch: 60 }); + const activeRegion = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + notes: [selectedNote], + }); + + selectedNote.select(); + KGCore.instance().addSelectedItem(selectedNote); + KGPianoRollState.instance().setLastEditedNoteVelocity(selectedNote.getVelocity()); + KGCore.instance().clearSelectedItems(); + KGPianoRollState.instance().setCurrentMatchingChords([[0, 4, 7]]); + KGPianoRollState.instance().setCurrentSelectedChordIndex(0); + KGPianoRollState.instance().setCurrentChordCursorPitch(72); + + const { result } = renderNoteOperations(activeRegion); + + act(() => { + result.current.handleGridDoubleClick(createGridClickEvent({ clientY: 706 })); + }); + + expect(coreState.executeCommand).toHaveBeenCalledTimes(1); + const createCommand = coreState.executeCommand.mock.calls[0][0] as CreateNotesCommand; + expect(createCommand).toBeInstanceOf(CreateNotesCommand); + expect(createCommand.getNoteCreationData()).toHaveLength(3); + expect(createCommand.getNoteCreationData().every(note => note.velocity === 73)).toBe(true); + }); + it('selects the grabbed note before resizing when it was not part of the current selection', () => { const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 }); const noteB = createMockMidiNote({ id: 'note-b', startBeat: 1, endBeat: 2, pitch: 62 }); diff --git a/src/hooks/useNoteOperations.ts b/src/hooks/useNoteOperations.ts index 63dcaac..2cac957 100644 --- a/src/hooks/useNoteOperations.ts +++ b/src/hooks/useNoteOperations.ts @@ -192,10 +192,10 @@ export const useNoteOperations = ({ // Calculate note timing relative to the region const regionStartBeat = activeRegion.getStartFromBeat(); const noteStartBeat = beatNumber - regionStartBeat; // relative beat position - const lastEditedLength = KGPianoRollState.instance().getLastEditedNoteLength(); - const noteEndBeat = noteStartBeat + lastEditedLength; // Use last edited note length - const velocity = 127; // Maximum velocity const pianoRollState = KGPianoRollState.instance(); + const lastEditedLength = pianoRollState.getLastEditedNoteLength(); + const noteEndBeat = noteStartBeat + lastEditedLength; // Use last edited note length + const velocity = pianoRollState.getLastEditedNoteVelocity(); const matchingChordPitches = pianoRollState.getCurrentMatchingChords(); const selectedChordIndex = pianoRollState.getCurrentSelectedChordIndex(); const cursorChordPitch = pianoRollState.getCurrentChordCursorPitch(); @@ -333,6 +333,7 @@ export const useNoteOperations = ({ note.select(); core.addSelectedItem(note); KGPianoRollState.instance().setLastEditedNoteLength(note.getEndBeat() - note.getStartBeat()); + KGPianoRollState.instance().setLastEditedNoteVelocity(note.getVelocity()); const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId()); if (track) { diff --git a/src/hooks/useNoteSelection.ts b/src/hooks/useNoteSelection.ts index a372c2a..c686418 100644 --- a/src/hooks/useNoteSelection.ts +++ b/src/hooks/useNoteSelection.ts @@ -108,6 +108,7 @@ export const useNoteSelection = ({ // Update last edited note length const noteLength = note.getEndBeat() - note.getStartBeat(); KGPianoRollState.instance().setLastEditedNoteLength(noteLength); + KGPianoRollState.instance().setLastEditedNoteVelocity(note.getVelocity()); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Added note to selection: ${noteId}`); @@ -139,6 +140,7 @@ export const useNoteSelection = ({ // Update last edited note length const noteLength = note.getEndBeat() - note.getStartBeat(); KGPianoRollState.instance().setLastEditedNoteLength(noteLength); + KGPianoRollState.instance().setLastEditedNoteVelocity(note.getVelocity()); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Selected note (replacing previous selection): ${noteId}`); @@ -416,6 +418,7 @@ export const useNoteSelection = ({ const noteLength = closestNote.getEndBeat() - closestNote.getStartBeat(); KGPianoRollState.instance().setLastEditedNoteLength(noteLength); + KGPianoRollState.instance().setLastEditedNoteVelocity(closestNote.getVelocity()); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Updated last edited note length to ${noteLength} from closest note: ${closestNote.getId()}`); diff --git a/src/stores/projectStore.test.ts b/src/stores/projectStore.test.ts index 7afff29..c389d82 100644 --- a/src/stores/projectStore.test.ts +++ b/src/stores/projectStore.test.ts @@ -4,6 +4,8 @@ import { KGTrack } from '../core/track/KGTrack'; import { KGMidiTrack } from '../core/track/KGMidiTrack'; import { KGAudioTrack } from '../core/track/KGAudioTrack'; import { KGAudioRegion } from '../core/region/KGAudioRegion'; +import { KGMidiRegion } from '../core/region/KGMidiRegion'; +import { KGMidiNote } from '../core/midi/KGMidiNote'; import { createDefaultGlobalTracks } from '../core/global-track'; import { getAudioRegionDisplayLengthBeats } from '../util/globalTrackUtil'; @@ -35,6 +37,9 @@ let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano let mockIsMetronomeEnabled = false; let mockShowGlobalTracks = false; let mockPlayheadPosition = 0; +let mockSelectedItems: Array<{ getId: () => string; select: () => void; deselect: () => void; isSelected: () => boolean }> = []; +let mockCopiedItems: Array<{ getId: () => string }> = []; +const selectionChangedCallbacks: Array<() => void> = []; const mockProject = { getTimeSignature: () => ({ numerator: 4, denominator: 4 }), getMaxBars: () => 32, @@ -90,8 +95,11 @@ const mockCore = { setPlayheadUpdateCallback: vi.fn(), setPlaybackStateChangeCallback: vi.fn(), setLoopBoundaryReachedCallback: vi.fn(), - getSelectedItems: () => [], - onSelectionChanged: vi.fn(), + getSelectedItems: () => mockSelectedItems, + getCopiedItems: () => mockCopiedItems, + onSelectionChanged: vi.fn((callback: () => void) => { + selectionChangedCallbacks.push(callback); + }), canUndo: () => false, canRedo: () => false, getUndoDescription: () => '', @@ -100,7 +108,16 @@ const mockCore = { executeCommand: vi.fn(), undo: vi.fn(() => true), redo: vi.fn(() => true), - clearSelectedItems: vi.fn(), + clearSelectedItems: vi.fn(() => { + mockSelectedItems = []; + selectionChangedCallbacks.forEach(callback => callback()); + }), + addSelectedItems: vi.fn((items: Array<{ getId: () => string; select: () => void; deselect: () => void; isSelected: () => boolean }>) => { + const incomingIds = new Set(items.map(item => item.getId())); + mockSelectedItems = mockSelectedItems.filter(item => !incomingIds.has(item.getId())); + mockSelectedItems.push(...items); + selectionChangedCallbacks.forEach(callback => callback()); + }), getStatus: () => 'Ready', setStatus: vi.fn(), getPlayheadPosition: () => mockPlayheadPosition, @@ -197,6 +214,12 @@ describe('projectStore piano roll state', () => { audioStorageMocks.loadAudioFile.mockReset(); toneMocks.decodeAudioData.mockReset(); toneMocks.toneBufferSet.mockReset(); + mockSelectedItems = []; + mockCopiedItems = []; + selectionChangedCallbacks.length = 0; + mockCore.onSelectionChanged.mockClear(); + mockCore.clearSelectedItems.mockClear(); + mockCore.addSelectedItems.mockClear(); mockIsMetronomeEnabled = false; mockShowGlobalTracks = false; mockProject.setIsMetronomeEnabled.mockClear(); @@ -737,4 +760,81 @@ describe('projectStore piano roll state', () => { expect(state.maxBars).toBe(16); expect(state.playheadPosition).toBe(64); }); + + it('selects only the newly pasted notes after pasting into the active MIDI region', async () => { + const { KGMidiTrack: TestMidiTrack } = await import('../core/track/KGMidiTrack'); + const { KGMidiRegion: TestMidiRegion } = await import('../core/region/KGMidiRegion'); + const { KGMidiNote: TestMidiNote } = await import('../core/midi/KGMidiNote'); + const track = new TestMidiTrack('Track 1', 1, 'acoustic_grand_piano'); + track.setTrackIndex(0); + const region = new TestMidiRegion('region-1', '1', 0, 'Region 1', 0, 16); + const existingSelectedNote = new TestMidiNote('existing-note', 0, 1, 60, 100); + existingSelectedNote.select(); + region.addNote(existingSelectedNote); + track.setRegions([region]); + + mockTracks = [track]; + currentProject = { + ...mockProject, + getTracks: () => mockTracks, + } as typeof mockProject; + + mockSelectedItems = [existingSelectedNote]; + mockCopiedItems = [ + new TestMidiNote('copied-a', 2, 3, 64, 110), + new TestMidiNote('copied-b', 3, 4, 67, 120), + ]; + mockCore.executeCommand.mockImplementation((command: { execute: () => void }) => command.execute()); + + const { useProjectStore } = await import('./projectStore'); + + act(() => { + useProjectStore.getState().pasteNotesToActiveRegion(region.getId(), 8); + }); + + const pastedNotes = region.getNotes().filter(note => note.getId() !== existingSelectedNote.getId()); + const pastedNoteIds = pastedNotes.map(note => note.getId()); + const state = useProjectStore.getState(); + + expect(mockCore.executeCommand).toHaveBeenCalledTimes(1); + expect(existingSelectedNote.isSelected()).toBe(false); + expect(pastedNotes).toHaveLength(2); + expect(pastedNotes.every(note => note.isSelected())).toBe(true); + expect(mockCore.clearSelectedItems).toHaveBeenCalledTimes(1); + expect(mockCore.addSelectedItems).toHaveBeenCalledWith(pastedNotes); + expect(state.selectedNoteIds).toEqual(pastedNoteIds); + }); + + it('keeps selection unchanged when note paste has no clipboard notes', async () => { + const { KGMidiTrack: TestMidiTrack } = await import('../core/track/KGMidiTrack'); + const { KGMidiRegion: TestMidiRegion } = await import('../core/region/KGMidiRegion'); + const { KGMidiNote: TestMidiNote } = await import('../core/midi/KGMidiNote'); + const track = new TestMidiTrack('Track 1', 1, 'acoustic_grand_piano'); + track.setTrackIndex(0); + const region = new TestMidiRegion('region-1', '1', 0, 'Region 1', 0, 16); + const existingSelectedNote = new TestMidiNote('existing-note', 0, 1, 60, 100); + existingSelectedNote.select(); + region.addNote(existingSelectedNote); + track.setRegions([region]); + + mockTracks = [track]; + currentProject = { + ...mockProject, + getTracks: () => mockTracks, + } as typeof mockProject; + + mockSelectedItems = [existingSelectedNote]; + mockCopiedItems = []; + + const { useProjectStore } = await import('./projectStore'); + + act(() => { + useProjectStore.getState().pasteNotesToActiveRegion(region.getId(), 8); + }); + + expect(mockCore.executeCommand).not.toHaveBeenCalled(); + expect(mockCore.clearSelectedItems).not.toHaveBeenCalled(); + expect(mockCore.addSelectedItems).not.toHaveBeenCalled(); + expect(existingSelectedNote.isSelected()).toBe(true); + }); }); diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 11e3a72..6ec6eca 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -1052,6 +1052,7 @@ export const useProjectStore = create((set, get) => { // Reset piano roll state for new/loaded project KGPianoRollState.instance().setLastEditedNoteLength(1); + KGPianoRollState.instance().setLastEditedNoteVelocity(127); KGPianoRollState.instance().setPianoRollZoom(projectToLoad.getPianoRollZoom()); // Add a status message @@ -1904,7 +1905,26 @@ export const useProjectStore = create((set, get) => { } try { - KGCore.instance().executeCommand(command); + const core = KGCore.instance(); + core.executeCommand(command); + + const createdNoteIds = new Set(command.getCreatedNotes().map(note => note.noteId)); + const targetRegion = command.getTargetRegion(); + const createdNotes = targetRegion + ? targetRegion.getNotes().filter(note => createdNoteIds.has(note.getId())) + : []; + + if (createdNotes.length > 0) { + core.getSelectedItems().forEach(item => { + item.deselect(); + }); + core.clearSelectedItems(); + + createdNotes.forEach(note => { + note.select(); + }); + core.addSelectedItems(createdNotes); + } // Update the store to trigger re-render const { tracks } = get();