feat: misc behavior changes
This commit is contained in:
@@ -843,7 +843,9 @@ const RegionEventListTab: React.FC<RegionEventListTabProps> = ({ activeMidiRegio
|
|||||||
? lastSelectedNote.getEndBeat() - lastSelectedNote.getStartBeat()
|
? lastSelectedNote.getEndBeat() - lastSelectedNote.getStartBeat()
|
||||||
: KGPianoRollState.instance().getLastEditedNoteLength();
|
: KGPianoRollState.instance().getLastEditedNoteLength();
|
||||||
const defaultPitch = lastSelectedNote ? lastSelectedNote.getPitch() : noteNameToPitch('C4');
|
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(
|
const command = new CreateNoteCommand(
|
||||||
activeMidiRegion.getId(),
|
activeMidiRegion.getId(),
|
||||||
@@ -855,6 +857,7 @@ const RegionEventListTab: React.FC<RegionEventListTabProps> = ({ activeMidiRegio
|
|||||||
|
|
||||||
KGCore.instance().executeCommand(command);
|
KGCore.instance().executeCommand(command);
|
||||||
KGPianoRollState.instance().setLastEditedNoteLength(defaultLength);
|
KGPianoRollState.instance().setLastEditedNoteLength(defaultLength);
|
||||||
|
KGPianoRollState.instance().setLastEditedNoteVelocity(defaultVelocity);
|
||||||
const createdNote = command.getCreatedNote();
|
const createdNote = command.getCreatedNote();
|
||||||
if (createdNote) {
|
if (createdNote) {
|
||||||
createdNote.select();
|
createdNote.select();
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export class KGPianoRollState {
|
|||||||
private activeTool: string = "pointer";
|
private activeTool: string = "pointer";
|
||||||
private currentSnap: PianoRollSnapValue = PIANO_ROLL_NO_SNAP;
|
private currentSnap: PianoRollSnapValue = PIANO_ROLL_NO_SNAP;
|
||||||
private lastEditedNoteLength: number = 1; // Default to 1 beat
|
private lastEditedNoteLength: number = 1; // Default to 1 beat
|
||||||
|
private lastEditedNoteVelocity: number = 127;
|
||||||
private currentMode: string = "ionian"; // Default mode
|
private currentMode: string = "ionian"; // Default mode
|
||||||
private automationViewEnabled: boolean = false;
|
private automationViewEnabled: boolean = false;
|
||||||
private currentAutomationType: string = "pitch-bend";
|
private currentAutomationType: string = "pitch-bend";
|
||||||
@@ -108,6 +109,14 @@ export class KGPianoRollState {
|
|||||||
this.lastEditedNoteLength = length;
|
this.lastEditedNoteLength = length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getLastEditedNoteVelocity(): number {
|
||||||
|
return this.lastEditedNoteVelocity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setLastEditedNoteVelocity(velocity: number): void {
|
||||||
|
this.lastEditedNoteVelocity = velocity;
|
||||||
|
}
|
||||||
|
|
||||||
public getCurrentMode(): string {
|
public getCurrentMode(): string {
|
||||||
return this.currentMode;
|
return this.currentMode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useNoteOperations } from './useNoteOperations';
|
||||||
import { KGCore } from '../core/KGCore';
|
import { KGCore } from '../core/KGCore';
|
||||||
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
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 { useProjectStore } from '../stores/projectStore';
|
||||||
import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack } from '../test/utils/mock-data';
|
import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack } from '../test/utils/mock-data';
|
||||||
|
|
||||||
@@ -91,20 +103,157 @@ describe('useNoteOperations', () => {
|
|||||||
|
|
||||||
KGPianoRollState.instance().setActiveTool('pointer');
|
KGPianoRollState.instance().setActiveTool('pointer');
|
||||||
KGPianoRollState.instance().setCurrentSnap('1/4');
|
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<typeof createMockMidiRegion>, 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> = {}): React.MouseEvent => ({
|
||||||
|
clientX: 80,
|
||||||
|
clientY: 120,
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
altKey: false,
|
||||||
|
...overrides,
|
||||||
|
} as React.MouseEvent);
|
||||||
|
|
||||||
|
const renderNoteOperations = (
|
||||||
|
activeRegion: ReturnType<typeof createMockMidiRegion>,
|
||||||
|
track = createMockMidiTrack({ id: 1, regions: [activeRegion] }),
|
||||||
|
updateTrack = vi.fn(),
|
||||||
|
pianoGridRef = createPianoGridRef(),
|
||||||
|
) => {
|
||||||
const hook = renderHook(() => useNoteOperations({
|
const hook = renderHook(() => useNoteOperations({
|
||||||
activeRegion,
|
activeRegion,
|
||||||
timeSignature: { numerator: 4, denominator: 4 },
|
timeSignature: { numerator: 4, denominator: 4 },
|
||||||
updateTrack,
|
updateTrack,
|
||||||
tracks: [track],
|
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', () => {
|
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 noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 });
|
||||||
const noteB = createMockMidiNote({ id: 'note-b', startBeat: 1, endBeat: 2, pitch: 62 });
|
const noteB = createMockMidiNote({ id: 'note-b', startBeat: 1, endBeat: 2, pitch: 62 });
|
||||||
|
|||||||
@@ -192,10 +192,10 @@ export const useNoteOperations = ({
|
|||||||
// Calculate note timing relative to the region
|
// Calculate note timing relative to the region
|
||||||
const regionStartBeat = activeRegion.getStartFromBeat();
|
const regionStartBeat = activeRegion.getStartFromBeat();
|
||||||
const noteStartBeat = beatNumber - regionStartBeat; // relative beat position
|
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 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 matchingChordPitches = pianoRollState.getCurrentMatchingChords();
|
||||||
const selectedChordIndex = pianoRollState.getCurrentSelectedChordIndex();
|
const selectedChordIndex = pianoRollState.getCurrentSelectedChordIndex();
|
||||||
const cursorChordPitch = pianoRollState.getCurrentChordCursorPitch();
|
const cursorChordPitch = pianoRollState.getCurrentChordCursorPitch();
|
||||||
@@ -333,6 +333,7 @@ export const useNoteOperations = ({
|
|||||||
note.select();
|
note.select();
|
||||||
core.addSelectedItem(note);
|
core.addSelectedItem(note);
|
||||||
KGPianoRollState.instance().setLastEditedNoteLength(note.getEndBeat() - note.getStartBeat());
|
KGPianoRollState.instance().setLastEditedNoteLength(note.getEndBeat() - note.getStartBeat());
|
||||||
|
KGPianoRollState.instance().setLastEditedNoteVelocity(note.getVelocity());
|
||||||
|
|
||||||
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
|
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
|
||||||
if (track) {
|
if (track) {
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export const useNoteSelection = ({
|
|||||||
// Update last edited note length
|
// Update last edited note length
|
||||||
const noteLength = note.getEndBeat() - note.getStartBeat();
|
const noteLength = note.getEndBeat() - note.getStartBeat();
|
||||||
KGPianoRollState.instance().setLastEditedNoteLength(noteLength);
|
KGPianoRollState.instance().setLastEditedNoteLength(noteLength);
|
||||||
|
KGPianoRollState.instance().setLastEditedNoteVelocity(note.getVelocity());
|
||||||
|
|
||||||
if (DEBUG_MODE.PIANO_ROLL) {
|
if (DEBUG_MODE.PIANO_ROLL) {
|
||||||
console.log(`Added note to selection: ${noteId}`);
|
console.log(`Added note to selection: ${noteId}`);
|
||||||
@@ -139,6 +140,7 @@ export const useNoteSelection = ({
|
|||||||
// Update last edited note length
|
// Update last edited note length
|
||||||
const noteLength = note.getEndBeat() - note.getStartBeat();
|
const noteLength = note.getEndBeat() - note.getStartBeat();
|
||||||
KGPianoRollState.instance().setLastEditedNoteLength(noteLength);
|
KGPianoRollState.instance().setLastEditedNoteLength(noteLength);
|
||||||
|
KGPianoRollState.instance().setLastEditedNoteVelocity(note.getVelocity());
|
||||||
|
|
||||||
if (DEBUG_MODE.PIANO_ROLL) {
|
if (DEBUG_MODE.PIANO_ROLL) {
|
||||||
console.log(`Selected note (replacing previous selection): ${noteId}`);
|
console.log(`Selected note (replacing previous selection): ${noteId}`);
|
||||||
@@ -416,6 +418,7 @@ export const useNoteSelection = ({
|
|||||||
|
|
||||||
const noteLength = closestNote.getEndBeat() - closestNote.getStartBeat();
|
const noteLength = closestNote.getEndBeat() - closestNote.getStartBeat();
|
||||||
KGPianoRollState.instance().setLastEditedNoteLength(noteLength);
|
KGPianoRollState.instance().setLastEditedNoteLength(noteLength);
|
||||||
|
KGPianoRollState.instance().setLastEditedNoteVelocity(closestNote.getVelocity());
|
||||||
|
|
||||||
if (DEBUG_MODE.PIANO_ROLL) {
|
if (DEBUG_MODE.PIANO_ROLL) {
|
||||||
console.log(`Updated last edited note length to ${noteLength} from closest note: ${closestNote.getId()}`);
|
console.log(`Updated last edited note length to ${noteLength} from closest note: ${closestNote.getId()}`);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { KGTrack } from '../core/track/KGTrack';
|
|||||||
import { KGMidiTrack } from '../core/track/KGMidiTrack';
|
import { KGMidiTrack } from '../core/track/KGMidiTrack';
|
||||||
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
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 { createDefaultGlobalTracks } from '../core/global-track';
|
||||||
import { getAudioRegionDisplayLengthBeats } from '../util/globalTrackUtil';
|
import { getAudioRegionDisplayLengthBeats } from '../util/globalTrackUtil';
|
||||||
|
|
||||||
@@ -35,6 +37,9 @@ let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano
|
|||||||
let mockIsMetronomeEnabled = false;
|
let mockIsMetronomeEnabled = false;
|
||||||
let mockShowGlobalTracks = false;
|
let mockShowGlobalTracks = false;
|
||||||
let mockPlayheadPosition = 0;
|
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 = {
|
const mockProject = {
|
||||||
getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
|
getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
|
||||||
getMaxBars: () => 32,
|
getMaxBars: () => 32,
|
||||||
@@ -90,8 +95,11 @@ const mockCore = {
|
|||||||
setPlayheadUpdateCallback: vi.fn(),
|
setPlayheadUpdateCallback: vi.fn(),
|
||||||
setPlaybackStateChangeCallback: vi.fn(),
|
setPlaybackStateChangeCallback: vi.fn(),
|
||||||
setLoopBoundaryReachedCallback: vi.fn(),
|
setLoopBoundaryReachedCallback: vi.fn(),
|
||||||
getSelectedItems: () => [],
|
getSelectedItems: () => mockSelectedItems,
|
||||||
onSelectionChanged: vi.fn(),
|
getCopiedItems: () => mockCopiedItems,
|
||||||
|
onSelectionChanged: vi.fn((callback: () => void) => {
|
||||||
|
selectionChangedCallbacks.push(callback);
|
||||||
|
}),
|
||||||
canUndo: () => false,
|
canUndo: () => false,
|
||||||
canRedo: () => false,
|
canRedo: () => false,
|
||||||
getUndoDescription: () => '',
|
getUndoDescription: () => '',
|
||||||
@@ -100,7 +108,16 @@ const mockCore = {
|
|||||||
executeCommand: vi.fn(),
|
executeCommand: vi.fn(),
|
||||||
undo: vi.fn(() => true),
|
undo: vi.fn(() => true),
|
||||||
redo: 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',
|
getStatus: () => 'Ready',
|
||||||
setStatus: vi.fn(),
|
setStatus: vi.fn(),
|
||||||
getPlayheadPosition: () => mockPlayheadPosition,
|
getPlayheadPosition: () => mockPlayheadPosition,
|
||||||
@@ -197,6 +214,12 @@ describe('projectStore piano roll state', () => {
|
|||||||
audioStorageMocks.loadAudioFile.mockReset();
|
audioStorageMocks.loadAudioFile.mockReset();
|
||||||
toneMocks.decodeAudioData.mockReset();
|
toneMocks.decodeAudioData.mockReset();
|
||||||
toneMocks.toneBufferSet.mockReset();
|
toneMocks.toneBufferSet.mockReset();
|
||||||
|
mockSelectedItems = [];
|
||||||
|
mockCopiedItems = [];
|
||||||
|
selectionChangedCallbacks.length = 0;
|
||||||
|
mockCore.onSelectionChanged.mockClear();
|
||||||
|
mockCore.clearSelectedItems.mockClear();
|
||||||
|
mockCore.addSelectedItems.mockClear();
|
||||||
mockIsMetronomeEnabled = false;
|
mockIsMetronomeEnabled = false;
|
||||||
mockShowGlobalTracks = false;
|
mockShowGlobalTracks = false;
|
||||||
mockProject.setIsMetronomeEnabled.mockClear();
|
mockProject.setIsMetronomeEnabled.mockClear();
|
||||||
@@ -737,4 +760,81 @@ describe('projectStore piano roll state', () => {
|
|||||||
expect(state.maxBars).toBe(16);
|
expect(state.maxBars).toBe(16);
|
||||||
expect(state.playheadPosition).toBe(64);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1052,6 +1052,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
|
|
||||||
// Reset piano roll state for new/loaded project
|
// Reset piano roll state for new/loaded project
|
||||||
KGPianoRollState.instance().setLastEditedNoteLength(1);
|
KGPianoRollState.instance().setLastEditedNoteLength(1);
|
||||||
|
KGPianoRollState.instance().setLastEditedNoteVelocity(127);
|
||||||
KGPianoRollState.instance().setPianoRollZoom(projectToLoad.getPianoRollZoom());
|
KGPianoRollState.instance().setPianoRollZoom(projectToLoad.getPianoRollZoom());
|
||||||
|
|
||||||
// Add a status message
|
// Add a status message
|
||||||
@@ -1904,7 +1905,26 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
// Update the store to trigger re-render
|
||||||
const { tracks } = get();
|
const { tracks } = get();
|
||||||
|
|||||||
Reference in New Issue
Block a user