diff --git a/src/components/piano-roll/PianoRollContent.tsx b/src/components/piano-roll/PianoRollContent.tsx index bae4605..40916c0 100644 --- a/src/components/piano-roll/PianoRollContent.tsx +++ b/src/components/piano-roll/PianoRollContent.tsx @@ -239,8 +239,8 @@ const PianoRollContent: React.FC = ({ const noteId = note.getId(); - // Check if this note is being resized or dragged and has a temporary style - if ((resizingNoteId === noteId || draggingNoteId === noteId) && tempNoteStyles[noteId]) { + // Use preview geometry whenever this note has an active temporary style. + if (tempNoteStyles[noteId]) { // Use the temporary style for position and size const tempStyle = tempNoteStyles[noteId]; diff --git a/src/components/track/TrackGridItem.test.tsx b/src/components/track/TrackGridItem.test.tsx index b92c91f..b26e3f6 100644 --- a/src/components/track/TrackGridItem.test.tsx +++ b/src/components/track/TrackGridItem.test.tsx @@ -1,41 +1,69 @@ -import React from 'react'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; -import { render } from '@testing-library/react'; +import React, { useState } from 'react'; +import { act, render } from '@testing-library/react'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import TrackGridItem from './TrackGridItem'; import { KGAudioTrack } from '../../core/track/KGAudioTrack'; +import { KGMainContentState } from '../../core/state/KGMainContentState'; +import { createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data'; + +const storeState = { + selectedRegionIds: [] as string[], + activeTrackAutomationTrackId: null as string | null, + activeTrackAutomationType: null, + trackAutomationRedrawVersion: 0, + recordingMode: 'audio' as 'audio' | 'midi' | null, + recordingTargetTrackIndex: 0, + recordingCommitStartBeatAbsolute: 4, + recordingAudioPreviewCurrentBeat: 8, + recordingAudioPreviewPeaks: [{ min: -0.5, max: 0.5 }], + recordingAudioPreviewFileName: 'Recording' as string | null, + timeSignature: { numerator: 4, denominator: 4 }, +}; + +const regionItemProps = new Map>(); vi.mock('../../stores/projectStore', () => ({ - useProjectStore: (selector?: (state: { - selectedRegionIds: string[]; - activeTrackAutomationTrackId: string | null; - activeTrackAutomationType: null; - trackAutomationRedrawVersion: number; - recordingMode: 'audio' | 'midi' | null; - recordingTargetTrackIndex: number | null; - recordingCommitStartBeatAbsolute: number; - recordingAudioPreviewCurrentBeat: number; - recordingAudioPreviewPeaks: Array<{ min: number; max: number }>; - recordingAudioPreviewFileName: string | null; - timeSignature: { numerator: number; denominator: number }; - }) => unknown) => { - const state = { - selectedRegionIds: [], - activeTrackAutomationTrackId: null, - activeTrackAutomationType: null, - trackAutomationRedrawVersion: 0, - recordingMode: 'audio' as const, - recordingTargetTrackIndex: 0, - recordingCommitStartBeatAbsolute: 4, - recordingAudioPreviewCurrentBeat: 8, - recordingAudioPreviewPeaks: [{ min: -0.5, max: 0.5 }], - recordingAudioPreviewFileName: 'Recording', - timeSignature: { numerator: 4, denominator: 4 }, - }; - return selector ? selector(state) : state; + useProjectStore: (selector?: (state: typeof storeState) => unknown) => ( + selector ? selector(storeState) : storeState + ), +})); + +vi.mock('./RegionItem', () => ({ + default: (props: Record) => { + regionItemProps.set(props.id as string, props); + return ( +
+ ); }, })); -describe('TrackGridItem recording preview', () => { +vi.mock('./TrackAutomationLane', () => ({ + default: () => null, +})); + +vi.mock('../../core/audio-interface/KGAudioInterface', () => ({ + KGAudioInterface: { + instance: () => ({ + getAudioBuffer: () => undefined, + }), + }, +})); + +describe('TrackGridItem preview behavior', () => { + const getRegionItem = (regionId: string) => regionItemProps.get(regionId) as { + style: React.CSSProperties; + onResizeStart?: (regionId: string, resizeAction: 'start' | 'end', initialX: number) => void; + onResize?: (regionId: string, resizeAction: 'start' | 'end', deltaX: number) => void; + onResizeEnd?: (regionId: string, resizeAction: 'start' | 'end') => void; + onDragStart?: (regionId: string, initialX: number, initialY: number) => void; + onDrag?: (regionId: string, deltaX: number, deltaY: number) => void; + onDragEnd?: (regionId: string) => void; + }; + beforeAll(() => { Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { value: vi.fn(() => ({ @@ -57,11 +85,97 @@ describe('TrackGridItem recording preview', () => { vi.stubGlobal('ResizeObserver', ResizeObserverMock); }); + beforeEach(() => { + regionItemProps.clear(); + storeState.selectedRegionIds = []; + storeState.activeTrackAutomationTrackId = null; + storeState.activeTrackAutomationType = null; + storeState.trackAutomationRedrawVersion = 0; + storeState.recordingMode = 'audio'; + storeState.recordingTargetTrackIndex = 0; + storeState.recordingCommitStartBeatAbsolute = 4; + storeState.recordingAudioPreviewCurrentBeat = 8; + storeState.recordingAudioPreviewPeaks = [{ min: -0.5, max: 0.5 }]; + storeState.recordingAudioPreviewFileName = 'Recording'; + storeState.timeSignature = { numerator: 4, denominator: 4 }; + KGMainContentState.instance().setActiveTool('pointer'); + KGMainContentState.instance().setSnapping(true); + }); + + const createGridContainerRef = () => { + const gridElement = document.createElement('div'); + Object.defineProperty(gridElement, 'clientWidth', { configurable: true, value: 800 }); + Object.defineProperty(gridElement, 'clientHeight', { configurable: true, value: 240 }); + return { current: gridElement }; + }; + + const renderSharedPreviewHarness = ( + selectedRegionIds: string[] = [], + regionOverrides: Array<{ id: string; trackId: string; trackIndex: number; barNumber: number; length: number; name: string }> = [ + { id: 'region-a', trackId: '1', trackIndex: 0, barNumber: 1, length: 1, name: 'Region A' }, + { id: 'region-b', trackId: '2', trackIndex: 1, barNumber: 3, length: 2, name: 'Region B' }, + ], + ) => { + storeState.selectedRegionIds = selectedRegionIds; + + const regionAData = regionOverrides.find(region => region.id === 'region-a')!; + const regionBData = regionOverrides.find(region => region.id === 'region-b')!; + const regionA = createMockMidiRegion({ id: 'region-a', trackId: '1', trackIndex: 0, startFromBeat: (regionAData.barNumber - 1) * 4, length: regionAData.length * 4 }); + const regionB = createMockMidiRegion({ id: 'region-b', trackId: '2', trackIndex: 1, startFromBeat: (regionBData.barNumber - 1) * 4, length: regionBData.length * 4 }); + const trackA = createMockMidiTrack({ id: 1, regions: [regionA] }); + const trackB = createMockMidiTrack({ id: 2, regions: [regionB] }); + trackA.setTrackIndex(0); + trackB.setTrackIndex(1); + + const gridContainerRef = createGridContainerRef(); + const baseProps = { + isDragging: false, + isDragOver: false, + regions: regionOverrides, + maxBars: 8, + selectedRegionId: null, + gridContainerRef, + onDoubleClick: vi.fn(), + onRegionResize: vi.fn(), + onRegionResizeEnd: vi.fn(), + onRegionDrag: vi.fn(), + onRegionDragEnd: vi.fn(), + allTracks: [trackA, trackB], + }; + + const SharedPreviewHarness = () => { + const [previewRegionStyles, setPreviewRegionStyles] = useState>({}); + + return ( + <> + + + + ); + }; + + render(); + + return baseProps; + }; + it('renders a non-interactive preview region on the recording audio track', () => { const track = new KGAudioTrack('Audio Track', 1); track.setTrackIndex(0); - const view = render( + render( { regions={[]} maxBars={8} selectedRegionId={null} - gridContainerRef={{ current: document.createElement('div') }} + gridContainerRef={createGridContainerRef()} onDoubleClick={vi.fn()} /> ); - const previewRegion = view.container.querySelector('[data-preview-region="true"]'); - expect(previewRegion).toBeTruthy(); + expect(regionItemProps.get('audio-recording-preview')).toBeTruthy(); + }); + + it('previews end resize for all selected regions across track rows', () => { + renderSharedPreviewHarness(['region-a', 'region-b']); + + act(() => { + getRegionItem('region-a').onResizeStart?.('region-a', 'end', 0); + getRegionItem('region-a').onResize?.('region-a', 'end', 40); + }); + + expect(getRegionItem('region-a').style).toEqual({ + left: '0px', + width: '140px', + position: 'absolute', + }); + expect(getRegionItem('region-b').style).toEqual({ + left: '200px', + width: '240px', + position: 'absolute', + }); + }); + + it('previews start resize for all selected regions across track rows', () => { + renderSharedPreviewHarness( + ['region-a', 'region-b'], + [ + { id: 'region-a', trackId: '1', trackIndex: 0, barNumber: 2, length: 2, name: 'Region A' }, + { id: 'region-b', trackId: '2', trackIndex: 1, barNumber: 4, length: 3, name: 'Region B' }, + ], + ); + + act(() => { + getRegionItem('region-a').onResizeStart?.('region-a', 'start', 0); + getRegionItem('region-a').onResize?.('region-a', 'start', 40); + }); + + expect(getRegionItem('region-a').style).toEqual({ + left: '140px', + width: '160px', + position: 'absolute', + }); + expect(getRegionItem('region-b').style).toEqual({ + left: '340px', + width: '260px', + position: 'absolute', + }); + }); + + it('previews drag movement for all selected regions across track rows', () => { + renderSharedPreviewHarness(['region-a', 'region-b']); + + act(() => { + getRegionItem('region-a').onDragStart?.('region-a', 0, 0); + getRegionItem('region-a').onDrag?.('region-a', 50, 60); + }); + + expect(getRegionItem('region-a').style).toEqual({ + left: '50px', + width: '100px', + position: 'absolute', + zIndex: 100, + transform: 'translateY(0px)', + }); + expect(getRegionItem('region-b').style).toEqual({ + left: '250px', + width: '200px', + position: 'absolute', + zIndex: 100, + transform: 'translateY(0px)', + }); + }); + + it('previews only the grabbed region when it is outside the current selection', () => { + renderSharedPreviewHarness(['region-a', 'region-b']); + + const regionC = { + id: 'region-c', + trackId: '1', + trackIndex: 0, + barNumber: 5, + length: 1, + name: 'Region C', + }; + + const track = createMockMidiTrack({ id: 1, regions: [createMockMidiRegion({ id: 'region-c', trackId: '1', trackIndex: 0, startFromBeat: 16, length: 4 })] }); + track.setTrackIndex(0); + const gridContainerRef = createGridContainerRef(); + + render( + + ); + + act(() => { + getRegionItem('region-c').onDragStart?.('region-c', 0, 0); + getRegionItem('region-c').onDrag?.('region-c', 50, 60); + }); + + expect(getRegionItem('region-c').style).toEqual({ + left: '450px', + width: '100px', + position: 'absolute', + zIndex: 100, + transform: 'translateY(60px)', + }); + }); + + it('clears preview styles for the full cohort after drag and resize end', () => { + const { onRegionResizeEnd, onRegionDragEnd } = renderSharedPreviewHarness(['region-a', 'region-b']); + + act(() => { + getRegionItem('region-a').onResizeStart?.('region-a', 'end', 0); + getRegionItem('region-a').onResize?.('region-a', 'end', 40); + getRegionItem('region-a').onResizeEnd?.('region-a', 'end'); + }); + + expect(getRegionItem('region-a').style).toEqual({ + left: '0px', + width: '100px', + position: 'absolute', + }); + expect(getRegionItem('region-b').style).toEqual({ + left: '200px', + width: '200px', + position: 'absolute', + }); + expect(onRegionResizeEnd).toHaveBeenCalledWith('region-a', 1, 1); + + act(() => { + getRegionItem('region-a').onDragStart?.('region-a', 0, 0); + getRegionItem('region-a').onDrag?.('region-a', 50, 60); + getRegionItem('region-a').onDragEnd?.('region-a'); + }); + + expect(getRegionItem('region-a').style).toEqual({ + left: '0px', + width: '100px', + position: 'absolute', + }); + expect(getRegionItem('region-b').style).toEqual({ + left: '200px', + width: '200px', + position: 'absolute', + }); + expect(onRegionDragEnd).toHaveBeenCalledWith('region-a', 2, 0); }); }); diff --git a/src/components/track/TrackGridItem.tsx b/src/components/track/TrackGridItem.tsx index f436272..8fd9889 100644 --- a/src/components/track/TrackGridItem.tsx +++ b/src/components/track/TrackGridItem.tsx @@ -11,6 +11,22 @@ import { KGMainContentState } from '../../core/state/KGMainContentState'; import { isModifierKeyPressed } from '../../util/osUtil'; import { useProjectStore } from '../../stores/projectStore'; +interface RegionResizePreviewBaseline { + regionId: string; + originalBarNumber: number; + originalLength: number; + originalLeft: number; + originalWidth: number; +} + +interface RegionDragPreviewBaseline { + regionId: string; + originalBarNumber: number; + originalTrackIndex: number; + originalLeft: number; + originalWidth: number; +} + interface TrackGridItemProps { track: KGTrack; index: number; @@ -35,6 +51,8 @@ interface TrackGridItemProps { onOpenHybrid?: (regionId: string) => void; allTracks?: KGTrack[]; // Added to access all tracks for drag operations onKGOneClipDrop?: (e: React.DragEvent, trackIndex: number) => void; + previewRegionStyles?: Record; + setPreviewRegionStyles?: React.Dispatch>>; } const TrackGridItem: React.FC = ({ @@ -61,6 +79,8 @@ const TrackGridItem: React.FC = ({ onOpenHybrid, allTracks, onKGOneClipDrop, + previewRegionStyles, + setPreviewRegionStyles, }) => { const selectedRegionIds = useProjectStore(state => state.selectedRegionIds); const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId); @@ -76,7 +96,7 @@ const TrackGridItem: React.FC = ({ const [containerWidth, setContainerWidth] = useState(0); const [resizingRegion, setResizingRegion] = useState(null); const [draggingRegion, setDraggingRegion] = useState(null); - const [tempRegionStyles, setTempRegionStyles] = useState>({}); + const [localTempRegionStyles, setLocalTempRegionStyles] = useState>({}); const [isModifierPressed, setIsModifierPressed] = useState(false); // Refs for resize operations @@ -86,14 +106,41 @@ const TrackGridItem: React.FC = ({ const currentResizeRegion = useRef(null); const initialBarNumberRef = useRef(null); const initialLengthRef = useRef(null); + const resizePreviewBaselinesRef = useRef([]); + const resizePreviewRegionIdsRef = useRef([]); // Refs for drag operations const currentDragLeft = useRef(null); const currentDragTop = useRef(null); const currentDragRegion = useRef(null); + const dragPreviewBaselinesRef = useRef([]); + const dragPreviewRegionIdsRef = useRef([]); const trackElementRef = useRef(null); const isBulkRegionEdit = (regionId: string) => selectedRegionIds.length > 1 && selectedRegionIds.includes(regionId); + const tempRegionStyles = previewRegionStyles ?? localTempRegionStyles; + const setTempRegionStyles = setPreviewRegionStyles ?? setLocalTempRegionStyles; + + const getPreviewRegionIds = (regionId: string) => ( + selectedRegionIds.length > 1 && selectedRegionIds.includes(regionId) + ? selectedRegionIds + : [regionId] + ); + + const clearTempRegionStyles = (regionIds?: string[]) => { + if (!regionIds || regionIds.length === 0) { + setTempRegionStyles({}); + return; + } + + setTempRegionStyles(prev => { + const updated = { ...prev }; + regionIds.forEach(id => { + delete updated[id]; + }); + return updated; + }); + }; // Update container width when the grid container changes size useEffect(() => { @@ -144,7 +191,7 @@ const TrackGridItem: React.FC = ({ // Calculate region position and style const getRegionStyle = (region: RegionUI) => { // Check if there's a temporary style for this region during resize or drag - if ((resizingRegion === region.id || draggingRegion === region.id) && tempRegionStyles[region.id]) { + if (tempRegionStyles[region.id]) { return tempRegionStyles[region.id]; } @@ -195,17 +242,30 @@ const TrackGridItem: React.FC = ({ // Store the initial width and left position currentResizeWidth.current = region.length * barWidth; currentResizeLeft.current = (region.barNumber - 1) * barWidth; - - // Set initial style to current position/size - const initialStyle = { - left: `${currentResizeLeft.current}px`, - width: `${currentResizeWidth.current}px`, - position: 'absolute' as const, // Fixed: Use const assertion - }; - + + const previewRegionIds = getPreviewRegionIds(regionId); + resizePreviewBaselinesRef.current = previewRegionIds + .map(id => regions.find(candidate => candidate.id === id)) + .filter((candidate): candidate is RegionUI => candidate !== undefined) + .map(candidate => ({ + regionId: candidate.id, + originalBarNumber: candidate.barNumber, + originalLength: candidate.length, + originalLeft: (candidate.barNumber - 1) * barWidth, + originalWidth: candidate.length * barWidth, + })); + resizePreviewRegionIdsRef.current = resizePreviewBaselinesRef.current.map(baseline => baseline.regionId); + setTempRegionStyles(prev => ({ ...prev, - [regionId]: initialStyle + ...Object.fromEntries(resizePreviewBaselinesRef.current.map(baseline => [ + baseline.regionId, + { + left: `${baseline.originalLeft}px`, + width: `${baseline.originalWidth}px`, + position: 'absolute' as const, + }, + ])), })); }; @@ -254,16 +314,28 @@ const TrackGridItem: React.FC = ({ console.log(`RESIZE: regionId=${regionId}, action=${resizeAction}, deltaX=${deltaX}, newBarNumber=${newBarNumber}, newLength=${newLength}`); } - // Update the temporary style for this region - const newStyle = { - left: `${newLeft}px`, - width: `${newWidth}px`, - position: 'absolute' as const, // Fixed: Use const assertion - }; - + const leftDelta = newLeft - originalLeft; + const widthDelta = newWidth - originalWidth; + const previewBaselines = resizePreviewBaselinesRef.current.length > 0 + ? resizePreviewBaselinesRef.current + : [{ + regionId, + originalBarNumber: region.barNumber, + originalLength: region.length, + originalLeft, + originalWidth, + }]; + setTempRegionStyles(prev => ({ ...prev, - [regionId]: newStyle + ...Object.fromEntries(previewBaselines.map(baseline => [ + baseline.regionId, + { + left: `${resizeAction === 'start' ? baseline.originalLeft + leftDelta : baseline.originalLeft}px`, + width: `${baseline.originalWidth + widthDelta}px`, + position: 'absolute' as const, + }, + ])), })); // Notify parent about resize @@ -328,16 +400,14 @@ const TrackGridItem: React.FC = ({ // Clear resizing state setResizingRegion(null); - setTempRegionStyles(prev => { - const updated = { ...prev }; - delete updated[regionId]; - return updated; - }); + clearTempRegionStyles(resizePreviewRegionIdsRef.current); currentResizeWidth.current = null; currentResizeLeft.current = null; currentResizeRegion.current = null; initialBarNumberRef.current = null; initialLengthRef.current = null; + resizePreviewBaselinesRef.current = []; + resizePreviewRegionIdsRef.current = []; // Notify parent about resize end with rounded values if (onRegionResizeEnd) { @@ -378,18 +448,32 @@ const TrackGridItem: React.FC = ({ // Store the initial position currentDragLeft.current = left; currentDragTop.current = 0; // Initially at the top of the current track - - // Set initial style - const initialStyle = { - left: `${left}px`, - width: `${width}px`, - position: 'absolute' as const, // Fixed: Use const assertion - zIndex: 100, // Bring to front during drag - }; - + + const previewRegionIds = getPreviewRegionIds(regionId); + dragPreviewBaselinesRef.current = previewRegionIds + .map(id => regions.find(candidate => candidate.id === id)) + .filter((candidate): candidate is RegionUI => candidate !== undefined) + .map(candidate => ({ + regionId: candidate.id, + originalBarNumber: candidate.barNumber, + originalTrackIndex: candidate.trackIndex, + originalLeft: (candidate.barNumber - 1) * barWidth, + originalWidth: candidate.length * barWidth, + })); + dragPreviewRegionIdsRef.current = dragPreviewBaselinesRef.current.map(baseline => baseline.regionId); + setTempRegionStyles(prev => ({ ...prev, - [regionId]: initialStyle + ...Object.fromEntries(dragPreviewBaselinesRef.current.map(baseline => [ + baseline.regionId, + { + left: `${baseline.originalLeft}px`, + width: `${baseline.originalWidth}px`, + position: 'absolute' as const, + zIndex: 100, + transform: 'translateY(0px)', + }, + ])), })); }; @@ -425,18 +509,29 @@ const TrackGridItem: React.FC = ({ console.log(`DRAG: regionId=${regionId}, deltaX=${deltaX}, deltaY=${deltaY}, newBarNumber=${newBarNumber}`); } - // Update the temporary style for this region - const newStyle = { - left: `${newLeft}px`, - width: `${region.length * barWidth}px`, - position: 'absolute' as const, - zIndex: 100, // Keep on top during drag - transform: `translateY(${appliedDeltaY}px)`, - }; - + const leftDelta = newLeft - initialLeft; + const previewBaselines = dragPreviewBaselinesRef.current.length > 0 + ? dragPreviewBaselinesRef.current + : [{ + regionId, + originalBarNumber: region.barNumber, + originalTrackIndex: region.trackIndex, + originalLeft: initialLeft, + originalWidth: region.length * barWidth, + }]; + setTempRegionStyles(prev => ({ ...prev, - [regionId]: newStyle + ...Object.fromEntries(previewBaselines.map(baseline => [ + baseline.regionId, + { + left: `${baseline.originalLeft + leftDelta}px`, + width: `${baseline.originalWidth}px`, + position: 'absolute' as const, + zIndex: 100, + transform: `translateY(${appliedDeltaY}px)`, + }, + ])), })); // We'll calculate the track index on drag end, but still notify parent about the drag @@ -506,14 +601,12 @@ const TrackGridItem: React.FC = ({ // Clear dragging state setDraggingRegion(null); - setTempRegionStyles(prev => { - const updated = { ...prev }; - delete updated[regionId]; - return updated; - }); + clearTempRegionStyles(dragPreviewRegionIdsRef.current); currentDragLeft.current = null; currentDragTop.current = null; currentDragRegion.current = null; + dragPreviewBaselinesRef.current = []; + dragPreviewRegionIdsRef.current = []; if (finalBarNumber === region.barNumber && finalTrackIndex === region.trackIndex) { if (DEBUG_MODE.TRACK_GRID_ITEM) { diff --git a/src/components/track/TrackGridPanel.tsx b/src/components/track/TrackGridPanel.tsx index a14020f..7204b9a 100644 --- a/src/components/track/TrackGridPanel.tsx +++ b/src/components/track/TrackGridPanel.tsx @@ -66,6 +66,7 @@ const TrackGridPanel: React.FC = ({ const refreshProjectState = useProjectStore(state => state.refreshProjectState); const gridContainerRef = useRef(null); const [showAudioImportModal, setShowAudioImportModal] = useState(false); + const [previewRegionStyles, setPreviewRegionStyles] = useState>({}); const pendingAudioImportRef = useRef<{ barNumber: number; trackIndex: number } | null>(null); const isLassoSelectingRef = useRef(false); const isLassoShiftPressedRef = useRef(false); @@ -907,6 +908,8 @@ const TrackGridPanel: React.FC = ({ onOpenHybrid={onOpenHybrid} allTracks={tracks} onKGOneClipDrop={handleExternalDrop} + previewRegionStyles={previewRegionStyles} + setPreviewRegionStyles={setPreviewRegionStyles} /> ))} diff --git a/src/hooks/useNoteOperations.test.ts b/src/hooks/useNoteOperations.test.ts index ae111ea..d0a3c6f 100644 --- a/src/hooks/useNoteOperations.test.ts +++ b/src/hooks/useNoteOperations.test.ts @@ -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, 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); + }); }); diff --git a/src/hooks/useNoteOperations.ts b/src/hooks/useNoteOperations.ts index 8a7c040..275e23f 100644 --- a/src/hooks/useNoteOperations.ts +++ b/src/hooks/useNoteOperations.ts @@ -25,6 +25,25 @@ interface UseNoteOperationsProps { pianoGridRef: MutableRefObject; } +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(null); const initialStartBeatRef = useRef(null); const initialEndBeatRef = useRef(null); + const resizePreviewBaselinesRef = useRef([]); + const resizePreviewNoteIdsRef = useRef([]); // Refs for drag operations const initialDragLeft = useRef(null); @@ -51,12 +72,29 @@ export const useNoteOperations = ({ const currentDragLeft = useRef(null); const currentDragTop = useRef(null); const initialPitchRef = useRef(null); + const dragPreviewBaselinesRef = useRef([]); + const dragPreviewNoteIdsRef = useRef([]); // 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);