feat: preview all the notes/regions during resizing/moving multiple notes and regions

This commit is contained in:
Xiaohan-Tian
2026-05-19 17:35:09 -07:00
parent 06e2162eea
commit 4b5b5c8d76
6 changed files with 889 additions and 169 deletions
+264 -15
View File
@@ -62,7 +62,7 @@ vi.mock('../stores/projectStore', () => ({
import { useNoteOperations } from './useNoteOperations';
import { KGCore } from '../core/KGCore';
import { KGPianoRollState } from '../core/state/KGPianoRollState';
import { ResizeNotesCommand } from '../core/commands';
import { MoveNotesCommand, ResizeNotesCommand } from '../core/commands';
import { useProjectStore } from '../stores/projectStore';
import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack } from '../test/utils/mock-data';
@@ -90,8 +90,21 @@ describe('useNoteOperations', () => {
} as CSSStyleDeclaration);
KGPianoRollState.instance().setActiveTool('pointer');
KGPianoRollState.instance().setCurrentSnap('1/4');
});
const renderNoteOperations = (activeRegion: ReturnType<typeof createMockMidiRegion>, track = createMockMidiTrack({ id: 1, regions: [activeRegion] }), updateTrack = vi.fn()) => {
const hook = renderHook(() => useNoteOperations({
activeRegion,
timeSignature: { numerator: 4, denominator: 4 },
updateTrack,
tracks: [track],
pianoGridRef: { current: null },
}));
return { ...hook, track, updateTrack };
};
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 });
@@ -108,13 +121,7 @@ describe('useNoteOperations', () => {
noteB.select();
KGCore.instance().addSelectedItems([noteA, noteB]);
const { result } = renderHook(() => useNoteOperations({
activeRegion,
timeSignature: { numerator: 4, denominator: 4 },
updateTrack,
tracks: [track],
pianoGridRef: { current: null },
}));
const { result } = renderNoteOperations(activeRegion, track, updateTrack);
act(() => {
result.current.handleNoteResizeStart(noteC.getId(), 'end', 120);
@@ -152,13 +159,7 @@ describe('useNoteOperations', () => {
noteB.select();
KGCore.instance().addSelectedItems([noteA, noteB]);
const { result } = renderHook(() => useNoteOperations({
activeRegion,
timeSignature: { numerator: 4, denominator: 4 },
updateTrack,
tracks: [track],
pianoGridRef: { current: null },
}));
const { result } = renderNoteOperations(activeRegion, track, updateTrack);
act(() => {
result.current.handleNoteResizeStart(noteA.getId(), 'end', 0);
@@ -179,4 +180,252 @@ describe('useNoteOperations', () => {
expect(resizeCommand).toBeInstanceOf(ResizeNotesCommand);
expect((resizeCommand as ResizeNotesCommand).getNoteIdsToResize()).toEqual([noteA.getId(), noteB.getId()]);
});
it('previews end resize for all notes in the active multi-selection', () => {
const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 });
const noteB = createMockMidiNote({ id: 'note-b', startBeat: 2, endBeat: 3, pitch: 62 });
const activeRegion = createMockMidiRegion({
id: 'region-1',
trackId: '1',
startFromBeat: 4,
notes: [noteA, noteB],
});
noteA.select();
noteB.select();
KGCore.instance().addSelectedItems([noteA, noteB]);
const { result } = renderNoteOperations(activeRegion);
act(() => {
result.current.handleNoteResizeStart(noteA.getId(), 'end', 0);
result.current.handleNoteResize(noteA.getId(), 'end', 20);
});
expect(result.current.tempNoteStyles).toEqual({
'note-a': { left: '160px', width: '80px' },
'note-b': { left: '240px', width: '80px' },
});
});
it('previews start resize for all notes in the active multi-selection', () => {
const noteA = createMockMidiNote({ id: 'note-a', startBeat: 1, endBeat: 3, pitch: 60 });
const noteB = createMockMidiNote({ id: 'note-b', startBeat: 4, endBeat: 6, pitch: 62 });
const activeRegion = createMockMidiRegion({
id: 'region-1',
trackId: '1',
startFromBeat: 2,
notes: [noteA, noteB],
});
noteA.select();
noteB.select();
KGCore.instance().addSelectedItems([noteA, noteB]);
const { result } = renderNoteOperations(activeRegion);
act(() => {
result.current.handleNoteResizeStart(noteA.getId(), 'start', 0);
result.current.handleNoteResize(noteA.getId(), 'start', 20);
});
expect(result.current.tempNoteStyles).toEqual({
'note-a': { left: '120px', width: '80px' },
'note-b': { left: '240px', width: '80px' },
});
});
it('previews only the grabbed note when it was outside 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 });
const noteC = createMockMidiNote({ id: 'note-c', startBeat: 2, endBeat: 3, pitch: 64 });
const activeRegion = createMockMidiRegion({
id: 'region-1',
trackId: '1',
notes: [noteA, noteB, noteC],
});
noteA.select();
noteB.select();
KGCore.instance().addSelectedItems([noteA, noteB]);
const { result } = renderNoteOperations(activeRegion);
act(() => {
result.current.handleNoteResizeStart(noteC.getId(), 'end', 0);
result.current.handleNoteResize(noteC.getId(), 'end', 20);
});
expect(useProjectStore.getState().selectedNoteIds).toEqual([noteC.getId()]);
expect(result.current.tempNoteStyles).toEqual({
'note-c': { left: '80px', width: '80px' },
});
});
it('clears resize preview styles after committing a multi-note resize', () => {
const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 });
const noteB = createMockMidiNote({ id: 'note-b', startBeat: 2, endBeat: 3, pitch: 62 });
const activeRegion = createMockMidiRegion({
id: 'region-1',
trackId: '1',
notes: [noteA, noteB],
});
noteA.select();
noteB.select();
KGCore.instance().addSelectedItems([noteA, noteB]);
const { result } = renderNoteOperations(activeRegion);
act(() => {
result.current.handleNoteResizeStart(noteA.getId(), 'end', 0);
result.current.handleNoteResize(noteA.getId(), 'end', 20);
});
expect(Object.keys(result.current.tempNoteStyles)).toEqual(['note-a', 'note-b']);
act(() => {
result.current.handleNoteResizeEnd(noteA.getId(), 'end');
});
expect(result.current.tempNoteStyles).toEqual({});
});
it('commits the same snapped resize delta that is shown in the preview', () => {
const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 });
const noteB = createMockMidiNote({ id: 'note-b', startBeat: 2, endBeat: 3, pitch: 62 });
const activeRegion = createMockMidiRegion({
id: 'region-1',
trackId: '1',
notes: [noteA, noteB],
});
noteA.select();
noteB.select();
KGCore.instance().addSelectedItems([noteA, noteB]);
const { result } = renderNoteOperations(activeRegion);
act(() => {
result.current.handleNoteResizeStart(noteA.getId(), 'end', 0);
result.current.handleNoteResize(noteA.getId(), 'end', 20);
result.current.handleNoteResizeEnd(noteA.getId(), 'end');
});
expect(coreState.executeCommand).toHaveBeenCalledTimes(1);
const resizeCommand = coreState.executeCommand.mock.calls[0][0] as ResizeNotesCommand;
expect(resizeCommand.getNoteIdsToResize()).toEqual([noteA.getId(), noteB.getId()]);
expect((resizeCommand as unknown as { primaryEndBeatDelta: number }).primaryEndBeatDelta).toBe(1);
});
it('previews drag movement for all notes in the active multi-selection', () => {
const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 });
const noteB = createMockMidiNote({ id: 'note-b', startBeat: 2, endBeat: 3, pitch: 64 });
const activeRegion = createMockMidiRegion({
id: 'region-1',
trackId: '1',
startFromBeat: 4,
notes: [noteA, noteB],
});
noteA.select();
noteB.select();
KGCore.instance().addSelectedItems([noteA, noteB]);
const { result } = renderNoteOperations(activeRegion);
act(() => {
result.current.handleNoteDragStart(noteA.getId(), 0, 0);
result.current.handleNoteDrag(noteA.getId(), 20, 15);
});
expect(result.current.tempNoteStyles).toEqual({
'note-a': { left: '200px', top: '955px', width: '40px', height: '20px', zIndex: 100 },
'note-b': { left: '280px', top: '875px', width: '40px', height: '20px', zIndex: 100 },
});
});
it('previews only the grabbed note during drag when it was outside 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 });
const noteC = createMockMidiNote({ id: 'note-c', startBeat: 2, endBeat: 3, pitch: 64 });
const activeRegion = createMockMidiRegion({
id: 'region-1',
trackId: '1',
notes: [noteA, noteB, noteC],
});
noteA.select();
noteB.select();
KGCore.instance().addSelectedItems([noteA, noteB]);
const { result } = renderNoteOperations(activeRegion);
act(() => {
result.current.handleNoteDragStart(noteC.getId(), 0, 0);
result.current.handleNoteDrag(noteC.getId(), 20, 15);
});
expect(result.current.tempNoteStyles).toEqual({
'note-c': { left: '120px', top: '875px', width: '40px', height: '20px', zIndex: 100 },
});
});
it('clears drag preview styles after committing a multi-note move', () => {
const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 });
const noteB = createMockMidiNote({ id: 'note-b', startBeat: 2, endBeat: 3, pitch: 64 });
const activeRegion = createMockMidiRegion({
id: 'region-1',
trackId: '1',
notes: [noteA, noteB],
});
noteA.select();
noteB.select();
KGCore.instance().addSelectedItems([noteA, noteB]);
const { result } = renderNoteOperations(activeRegion);
act(() => {
result.current.handleNoteDragStart(noteA.getId(), 0, 0);
result.current.handleNoteDrag(noteA.getId(), 20, 15);
});
expect(Object.keys(result.current.tempNoteStyles)).toEqual(['note-a', 'note-b']);
act(() => {
result.current.handleNoteDragEnd(noteA.getId());
});
expect(result.current.tempNoteStyles).toEqual({});
});
it('commits the same drag cohort and deltas that are shown in the preview', () => {
const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 });
const noteB = createMockMidiNote({ id: 'note-b', startBeat: 2, endBeat: 3, pitch: 64 });
const activeRegion = createMockMidiRegion({
id: 'region-1',
trackId: '1',
notes: [noteA, noteB],
});
noteA.select();
noteB.select();
KGCore.instance().addSelectedItems([noteA, noteB]);
const { result } = renderNoteOperations(activeRegion);
act(() => {
result.current.handleNoteDragStart(noteA.getId(), 0, 0);
result.current.handleNoteDrag(noteA.getId(), 20, 15);
result.current.handleNoteDragEnd(noteA.getId());
});
expect(coreState.executeCommand).toHaveBeenCalledTimes(1);
const moveCommand = coreState.executeCommand.mock.calls[0][0] as MoveNotesCommand;
expect(moveCommand).toBeInstanceOf(MoveNotesCommand);
expect(moveCommand.getNoteIdsToMove()).toEqual([noteA.getId(), noteB.getId()]);
expect(moveCommand.getStartBeatDelta()).toBe(1);
expect(moveCommand.getPitchDelta()).toBe(-1);
});
});
+176 -68
View File
@@ -25,6 +25,25 @@ interface UseNoteOperationsProps {
pianoGridRef: MutableRefObject<HTMLDivElement | null>;
}
interface ResizePreviewBaseline {
noteId: string;
originalStartBeat: number;
originalEndBeat: number;
originalLeft: number;
originalWidth: number;
}
interface DragPreviewBaseline {
noteId: string;
originalStartBeat: number;
originalEndBeat: number;
originalPitch: number;
originalLeft: number;
originalTop: number;
originalWidth: number;
originalHeight: number;
}
export const useNoteOperations = ({
activeRegion,
timeSignature,
@@ -44,6 +63,8 @@ export const useNoteOperations = ({
const currentResizeLeft = useRef<number | null>(null);
const initialStartBeatRef = useRef<number | null>(null);
const initialEndBeatRef = useRef<number | null>(null);
const resizePreviewBaselinesRef = useRef<ResizePreviewBaseline[]>([]);
const resizePreviewNoteIdsRef = useRef<string[]>([]);
// Refs for drag operations
const initialDragLeft = useRef<number | null>(null);
@@ -51,12 +72,29 @@ export const useNoteOperations = ({
const currentDragLeft = useRef<number | null>(null);
const currentDragTop = useRef<number | null>(null);
const initialPitchRef = useRef<number | null>(null);
const dragPreviewBaselinesRef = useRef<DragPreviewBaseline[]>([]);
const dragPreviewNoteIdsRef = useRef<string[]>([]);
// Counter to trigger re-renders when notes are updated
const [noteUpdateCounter, setNoteUpdateCounter] = useState(0);
// Get KGCore instance for accessing selected items
const core = KGCore.instance();
const clearTempNoteStyles = (noteIds?: string[]) => {
if (!noteIds || noteIds.length === 0) {
setTempNoteStyles({});
return;
}
setTempNoteStyles(prev => {
const updated = { ...prev };
noteIds.forEach(id => {
delete updated[id];
});
return updated;
});
};
// Utility function to delete selected notes from the active region using commands
const deleteSelectedNotes = () => {
@@ -301,6 +339,10 @@ export const useNoteOperations = ({
updateTrack(track);
}
}
const resizeTargetNotes = isResizedNoteSelected
? selectedNotesInRegion
: [note];
// Store the initial start and end beats
initialStartBeatRef.current = note.getStartBeat();
@@ -324,15 +366,28 @@ export const useNoteOperations = ({
currentResizeWidth.current = width;
currentResizeLeft.current = left;
// Set initial style to current position/size
const initialStyle = {
left: `${left}px`,
width: `${width}px`,
};
resizePreviewBaselinesRef.current = resizeTargetNotes.map(targetNote => {
const targetAbsStartBeat = targetNote.getStartBeat() + regionStartBeat;
const targetAbsEndBeat = targetNote.getEndBeat() + regionStartBeat;
return {
noteId: targetNote.getId(),
originalStartBeat: targetNote.getStartBeat(),
originalEndBeat: targetNote.getEndBeat(),
originalLeft: targetAbsStartBeat * beatWidth,
originalWidth: (targetAbsEndBeat - targetAbsStartBeat) * beatWidth,
};
});
resizePreviewNoteIdsRef.current = resizeTargetNotes.map(targetNote => targetNote.getId());
setTempNoteStyles(prev => ({
...prev,
[noteId]: initialStyle
...Object.fromEntries(resizePreviewBaselinesRef.current.map(baseline => [
baseline.noteId,
{
left: `${baseline.originalLeft}px`,
width: `${baseline.originalWidth}px`,
},
])),
}));
};
@@ -402,15 +457,41 @@ export const useNoteOperations = ({
currentResizeWidth.current = snappedWidth;
currentResizeLeft.current = newLeft;
// Update the temporary style for this note
const newStyle = {
left: `${newLeft}px`,
width: `${snappedWidth}px`,
};
const startBeatDelta = resizeEdge === 'start'
? (newLeft - originalLeft) / beatWidth
: 0;
const endBeatDelta = resizeEdge === 'end'
? (snappedWidth - originalWidth) / beatWidth
: 0;
const previewBaselines = resizePreviewBaselinesRef.current.length > 0
? resizePreviewBaselinesRef.current
: [{
noteId,
originalStartBeat: note.getStartBeat(),
originalEndBeat: note.getEndBeat(),
originalLeft: originalLeft,
originalWidth: originalWidth,
}];
setTempNoteStyles(prev => ({
...prev,
[noteId]: newStyle
...Object.fromEntries(previewBaselines.map(baseline => {
const previewLeft = resizeEdge === 'start'
? baseline.originalLeft + (startBeatDelta * beatWidth)
: baseline.originalLeft;
const previewWidth = resizeEdge === 'end'
? baseline.originalWidth + (endBeatDelta * beatWidth)
: baseline.originalWidth - (startBeatDelta * beatWidth);
return [
baseline.noteId,
{
left: `${previewLeft}px`,
width: `${previewWidth}px`,
},
];
})),
}));
if (DEBUG_MODE.PIANO_ROLL) {
@@ -440,11 +521,9 @@ export const useNoteOperations = ({
initialStartBeatRef.current === null || initialEndBeatRef.current === null) {
// Reset resizing state
setResizingNoteId(null);
setTempNoteStyles(prev => {
const updated = { ...prev };
delete updated[noteId];
return updated;
});
clearTempNoteStyles(resizePreviewNoteIdsRef.current);
resizePreviewBaselinesRef.current = [];
resizePreviewNoteIdsRef.current = [];
return;
}
@@ -535,15 +614,13 @@ export const useNoteOperations = ({
console.error('Error resizing notes:', error);
// Reset resizing state and return early on error
setResizingNoteId(null);
setTempNoteStyles(prev => {
const updated = { ...prev };
delete updated[noteId];
return updated;
});
clearTempNoteStyles(resizePreviewNoteIdsRef.current);
currentResizeWidth.current = null;
currentResizeLeft.current = null;
initialStartBeatRef.current = null;
initialEndBeatRef.current = null;
resizePreviewBaselinesRef.current = [];
resizePreviewNoteIdsRef.current = [];
return;
}
@@ -560,15 +637,13 @@ export const useNoteOperations = ({
// Reset resizing state
setResizingNoteId(null);
setTempNoteStyles(prev => {
const updated = { ...prev };
delete updated[noteId];
return updated;
});
clearTempNoteStyles(resizePreviewNoteIdsRef.current);
currentResizeWidth.current = null;
currentResizeLeft.current = null;
initialStartBeatRef.current = null;
initialEndBeatRef.current = null;
resizePreviewBaselinesRef.current = [];
resizePreviewNoteIdsRef.current = [];
// Increment the note update counter to trigger a re-render
setNoteUpdateCounter(prev => prev + 1);
@@ -598,6 +673,15 @@ export const useNoteOperations = ({
// Find the note being dragged
const note = activeRegion.getNotes().find(n => n.getId() === noteId);
if (!note) return;
const selectedNotesInRegion = core.getSelectedItems().filter(item =>
item instanceof KGMidiNote &&
activeRegion.getNotes().some(regionNote => regionNote.getId() === item.getId())
) as KGMidiNote[];
const isDraggedNoteSelected = selectedNotesInRegion.some(selectedNote => selectedNote.getId() === noteId);
const dragTargetNotes = isDraggedNoteSelected
? selectedNotesInRegion
: [note];
// Store the initial pitch
initialPitchRef.current = note.getPitch();
@@ -625,19 +709,37 @@ export const useNoteOperations = ({
initialDragTop.current = top;
currentDragLeft.current = left;
currentDragTop.current = top;
// Set initial style
const initialStyle = {
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${noteHeight}px`,
zIndex: 100, // Bring to front during drag
};
dragPreviewBaselinesRef.current = dragTargetNotes.map(targetNote => {
const targetAbsStartBeat = targetNote.getStartBeat() + regionStartBeat;
const targetWidth = (targetNote.getEndBeat() - targetNote.getStartBeat()) * beatWidth;
const targetTop = (107 - targetNote.getPitch()) * noteHeight;
return {
noteId: targetNote.getId(),
originalStartBeat: targetNote.getStartBeat(),
originalEndBeat: targetNote.getEndBeat(),
originalPitch: targetNote.getPitch(),
originalLeft: targetAbsStartBeat * beatWidth,
originalTop: targetTop,
originalWidth: targetWidth,
originalHeight: noteHeight,
};
});
dragPreviewNoteIdsRef.current = dragTargetNotes.map(targetNote => targetNote.getId());
setTempNoteStyles(prev => ({
...prev,
[noteId]: initialStyle
...Object.fromEntries(dragPreviewBaselinesRef.current.map(baseline => [
baseline.noteId,
{
left: `${baseline.originalLeft}px`,
top: `${baseline.originalTop}px`,
width: `${baseline.originalWidth}px`,
height: `${baseline.originalHeight}px`,
zIndex: 100,
},
])),
}));
};
@@ -681,21 +783,33 @@ export const useNoteOperations = ({
currentDragLeft.current = newLeft;
currentDragTop.current = newTop;
// Calculate width based on note duration
const width = (note.getEndBeat() - note.getStartBeat()) * beatWidth;
// Update the temporary style for this note
const newStyle = {
left: `${newLeft}px`,
top: `${newTop}px`,
width: `${width}px`,
height: `${noteHeight}px`,
zIndex: 100, // Keep on top during drag
};
const previewBaselines = dragPreviewBaselinesRef.current.length > 0
? dragPreviewBaselinesRef.current
: [{
noteId,
originalStartBeat: note.getStartBeat(),
originalEndBeat: note.getEndBeat(),
originalPitch: note.getPitch(),
originalLeft,
originalTop,
originalWidth: (note.getEndBeat() - note.getStartBeat()) * beatWidth,
originalHeight: noteHeight,
}];
const leftDelta = newLeft - originalLeft;
const topDelta = newTop - originalTop;
setTempNoteStyles(prev => ({
...prev,
[noteId]: newStyle
...Object.fromEntries(previewBaselines.map(baseline => [
baseline.noteId,
{
left: `${baseline.originalLeft + leftDelta}px`,
top: `${baseline.originalTop + topDelta}px`,
width: `${baseline.originalWidth}px`,
height: `${baseline.originalHeight}px`,
zIndex: 100,
},
])),
}));
if (DEBUG_MODE.PIANO_ROLL) {
@@ -727,11 +841,9 @@ export const useNoteOperations = ({
initialPitchRef.current === null) {
// Reset dragging state
setDraggingNoteId(null);
setTempNoteStyles(prev => {
const updated = { ...prev };
delete updated[noteId];
return updated;
});
clearTempNoteStyles(dragPreviewNoteIdsRef.current);
dragPreviewBaselinesRef.current = [];
dragPreviewNoteIdsRef.current = [];
return;
}
@@ -793,16 +905,14 @@ export const useNoteOperations = ({
console.error('Error moving notes:', error);
// Reset dragging state and return early on error
setDraggingNoteId(null);
setTempNoteStyles(prev => {
const updated = { ...prev };
delete updated[noteId];
return updated;
});
clearTempNoteStyles(dragPreviewNoteIdsRef.current);
currentDragLeft.current = null;
currentDragTop.current = null;
initialDragLeft.current = null;
initialDragTop.current = null;
initialPitchRef.current = null;
dragPreviewBaselinesRef.current = [];
dragPreviewNoteIdsRef.current = [];
return;
}
@@ -820,16 +930,14 @@ export const useNoteOperations = ({
// Reset dragging state
setDraggingNoteId(null);
setTempNoteStyles(prev => {
const updated = { ...prev };
delete updated[noteId];
return updated;
});
clearTempNoteStyles(dragPreviewNoteIdsRef.current);
currentDragLeft.current = null;
currentDragTop.current = null;
initialDragLeft.current = null;
initialDragTop.current = null;
initialPitchRef.current = null;
dragPreviewBaselinesRef.current = [];
dragPreviewNoteIdsRef.current = [];
// Increment the note update counter to trigger a re-render
setNoteUpdateCounter(prev => prev + 1);