feat: preview all the notes/regions during resizing/moving multiple notes and regions
This commit is contained in:
@@ -239,8 +239,8 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
|||||||
|
|
||||||
const noteId = note.getId();
|
const noteId = note.getId();
|
||||||
|
|
||||||
// Check if this note is being resized or dragged and has a temporary style
|
// Use preview geometry whenever this note has an active temporary style.
|
||||||
if ((resizingNoteId === noteId || draggingNoteId === noteId) && tempNoteStyles[noteId]) {
|
if (tempNoteStyles[noteId]) {
|
||||||
// Use the temporary style for position and size
|
// Use the temporary style for position and size
|
||||||
const tempStyle = tempNoteStyles[noteId];
|
const tempStyle = tempNoteStyles[noteId];
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,69 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
import { act, render } from '@testing-library/react';
|
||||||
import { render } from '@testing-library/react';
|
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import TrackGridItem from './TrackGridItem';
|
import TrackGridItem from './TrackGridItem';
|
||||||
import { KGAudioTrack } from '../../core/track/KGAudioTrack';
|
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<string, Record<string, unknown>>();
|
||||||
|
|
||||||
vi.mock('../../stores/projectStore', () => ({
|
vi.mock('../../stores/projectStore', () => ({
|
||||||
useProjectStore: (selector?: (state: {
|
useProjectStore: (selector?: (state: typeof storeState) => unknown) => (
|
||||||
selectedRegionIds: string[];
|
selector ? selector(storeState) : storeState
|
||||||
activeTrackAutomationTrackId: string | null;
|
),
|
||||||
activeTrackAutomationType: null;
|
}));
|
||||||
trackAutomationRedrawVersion: number;
|
|
||||||
recordingMode: 'audio' | 'midi' | null;
|
vi.mock('./RegionItem', () => ({
|
||||||
recordingTargetTrackIndex: number | null;
|
default: (props: Record<string, unknown>) => {
|
||||||
recordingCommitStartBeatAbsolute: number;
|
regionItemProps.set(props.id as string, props);
|
||||||
recordingAudioPreviewCurrentBeat: number;
|
return (
|
||||||
recordingAudioPreviewPeaks: Array<{ min: number; max: number }>;
|
<div
|
||||||
recordingAudioPreviewFileName: string | null;
|
data-region-id={props.id as string}
|
||||||
timeSignature: { numerator: number; denominator: number };
|
data-preview-region={(props.isPreview as boolean | undefined) ? 'true' : 'false'}
|
||||||
}) => unknown) => {
|
style={props.style as React.CSSProperties}
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
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(() => {
|
beforeAll(() => {
|
||||||
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
|
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
|
||||||
value: vi.fn(() => ({
|
value: vi.fn(() => ({
|
||||||
@@ -57,11 +85,97 @@ describe('TrackGridItem recording preview', () => {
|
|||||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
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<Record<string, React.CSSProperties>>({});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TrackGridItem
|
||||||
|
track={trackA}
|
||||||
|
index={0}
|
||||||
|
previewRegionStyles={previewRegionStyles}
|
||||||
|
setPreviewRegionStyles={setPreviewRegionStyles}
|
||||||
|
{...baseProps}
|
||||||
|
/>
|
||||||
|
<TrackGridItem
|
||||||
|
track={trackB}
|
||||||
|
index={1}
|
||||||
|
previewRegionStyles={previewRegionStyles}
|
||||||
|
setPreviewRegionStyles={setPreviewRegionStyles}
|
||||||
|
{...baseProps}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<SharedPreviewHarness />);
|
||||||
|
|
||||||
|
return baseProps;
|
||||||
|
};
|
||||||
|
|
||||||
it('renders a non-interactive preview region on the recording audio track', () => {
|
it('renders a non-interactive preview region on the recording audio track', () => {
|
||||||
const track = new KGAudioTrack('Audio Track', 1);
|
const track = new KGAudioTrack('Audio Track', 1);
|
||||||
track.setTrackIndex(0);
|
track.setTrackIndex(0);
|
||||||
|
|
||||||
const view = render(
|
render(
|
||||||
<TrackGridItem
|
<TrackGridItem
|
||||||
track={track}
|
track={track}
|
||||||
index={0}
|
index={0}
|
||||||
@@ -70,12 +184,165 @@ describe('TrackGridItem recording preview', () => {
|
|||||||
regions={[]}
|
regions={[]}
|
||||||
maxBars={8}
|
maxBars={8}
|
||||||
selectedRegionId={null}
|
selectedRegionId={null}
|
||||||
gridContainerRef={{ current: document.createElement('div') }}
|
gridContainerRef={createGridContainerRef()}
|
||||||
onDoubleClick={vi.fn()}
|
onDoubleClick={vi.fn()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const previewRegion = view.container.querySelector('[data-preview-region="true"]');
|
expect(regionItemProps.get('audio-recording-preview')).toBeTruthy();
|
||||||
expect(previewRegion).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(
|
||||||
|
<TrackGridItem
|
||||||
|
track={track}
|
||||||
|
index={0}
|
||||||
|
isDragging={false}
|
||||||
|
isDragOver={false}
|
||||||
|
regions={[regionC]}
|
||||||
|
maxBars={8}
|
||||||
|
selectedRegionId={null}
|
||||||
|
gridContainerRef={gridContainerRef}
|
||||||
|
onDoubleClick={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,22 @@ import { KGMainContentState } from '../../core/state/KGMainContentState';
|
|||||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||||
import { useProjectStore } from '../../stores/projectStore';
|
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 {
|
interface TrackGridItemProps {
|
||||||
track: KGTrack;
|
track: KGTrack;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -35,6 +51,8 @@ interface TrackGridItemProps {
|
|||||||
onOpenHybrid?: (regionId: string) => void;
|
onOpenHybrid?: (regionId: string) => void;
|
||||||
allTracks?: KGTrack[]; // Added to access all tracks for drag operations
|
allTracks?: KGTrack[]; // Added to access all tracks for drag operations
|
||||||
onKGOneClipDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
|
onKGOneClipDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
|
||||||
|
previewRegionStyles?: Record<string, React.CSSProperties>;
|
||||||
|
setPreviewRegionStyles?: React.Dispatch<React.SetStateAction<Record<string, React.CSSProperties>>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||||
@@ -61,6 +79,8 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
onOpenHybrid,
|
onOpenHybrid,
|
||||||
allTracks,
|
allTracks,
|
||||||
onKGOneClipDrop,
|
onKGOneClipDrop,
|
||||||
|
previewRegionStyles,
|
||||||
|
setPreviewRegionStyles,
|
||||||
}) => {
|
}) => {
|
||||||
const selectedRegionIds = useProjectStore(state => state.selectedRegionIds);
|
const selectedRegionIds = useProjectStore(state => state.selectedRegionIds);
|
||||||
const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId);
|
const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId);
|
||||||
@@ -76,7 +96,7 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
const [containerWidth, setContainerWidth] = useState(0);
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
const [resizingRegion, setResizingRegion] = useState<string | null>(null);
|
const [resizingRegion, setResizingRegion] = useState<string | null>(null);
|
||||||
const [draggingRegion, setDraggingRegion] = useState<string | null>(null);
|
const [draggingRegion, setDraggingRegion] = useState<string | null>(null);
|
||||||
const [tempRegionStyles, setTempRegionStyles] = useState<Record<string, React.CSSProperties>>({});
|
const [localTempRegionStyles, setLocalTempRegionStyles] = useState<Record<string, React.CSSProperties>>({});
|
||||||
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
||||||
|
|
||||||
// Refs for resize operations
|
// Refs for resize operations
|
||||||
@@ -86,14 +106,41 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
const currentResizeRegion = useRef<RegionUI | null>(null);
|
const currentResizeRegion = useRef<RegionUI | null>(null);
|
||||||
const initialBarNumberRef = useRef<number | null>(null);
|
const initialBarNumberRef = useRef<number | null>(null);
|
||||||
const initialLengthRef = useRef<number | null>(null);
|
const initialLengthRef = useRef<number | null>(null);
|
||||||
|
const resizePreviewBaselinesRef = useRef<RegionResizePreviewBaseline[]>([]);
|
||||||
|
const resizePreviewRegionIdsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
// Refs for drag operations
|
// Refs for drag operations
|
||||||
const currentDragLeft = useRef<number | null>(null);
|
const currentDragLeft = useRef<number | null>(null);
|
||||||
const currentDragTop = useRef<number | null>(null);
|
const currentDragTop = useRef<number | null>(null);
|
||||||
const currentDragRegion = useRef<RegionUI | null>(null);
|
const currentDragRegion = useRef<RegionUI | null>(null);
|
||||||
|
const dragPreviewBaselinesRef = useRef<RegionDragPreviewBaseline[]>([]);
|
||||||
|
const dragPreviewRegionIdsRef = useRef<string[]>([]);
|
||||||
const trackElementRef = useRef<HTMLDivElement | null>(null);
|
const trackElementRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const isBulkRegionEdit = (regionId: string) => selectedRegionIds.length > 1 && selectedRegionIds.includes(regionId);
|
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
|
// Update container width when the grid container changes size
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -144,7 +191,7 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
// Calculate region position and style
|
// Calculate region position and style
|
||||||
const getRegionStyle = (region: RegionUI) => {
|
const getRegionStyle = (region: RegionUI) => {
|
||||||
// Check if there's a temporary style for this region during resize or drag
|
// 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];
|
return tempRegionStyles[region.id];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,17 +242,30 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
// Store the initial width and left position
|
// Store the initial width and left position
|
||||||
currentResizeWidth.current = region.length * barWidth;
|
currentResizeWidth.current = region.length * barWidth;
|
||||||
currentResizeLeft.current = (region.barNumber - 1) * barWidth;
|
currentResizeLeft.current = (region.barNumber - 1) * barWidth;
|
||||||
|
|
||||||
// Set initial style to current position/size
|
const previewRegionIds = getPreviewRegionIds(regionId);
|
||||||
const initialStyle = {
|
resizePreviewBaselinesRef.current = previewRegionIds
|
||||||
left: `${currentResizeLeft.current}px`,
|
.map(id => regions.find(candidate => candidate.id === id))
|
||||||
width: `${currentResizeWidth.current}px`,
|
.filter((candidate): candidate is RegionUI => candidate !== undefined)
|
||||||
position: 'absolute' as const, // Fixed: Use const assertion
|
.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 => ({
|
setTempRegionStyles(prev => ({
|
||||||
...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<TrackGridItemProps> = ({
|
|||||||
console.log(`RESIZE: regionId=${regionId}, action=${resizeAction}, deltaX=${deltaX}, newBarNumber=${newBarNumber}, newLength=${newLength}`);
|
console.log(`RESIZE: regionId=${regionId}, action=${resizeAction}, deltaX=${deltaX}, newBarNumber=${newBarNumber}, newLength=${newLength}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the temporary style for this region
|
const leftDelta = newLeft - originalLeft;
|
||||||
const newStyle = {
|
const widthDelta = newWidth - originalWidth;
|
||||||
left: `${newLeft}px`,
|
const previewBaselines = resizePreviewBaselinesRef.current.length > 0
|
||||||
width: `${newWidth}px`,
|
? resizePreviewBaselinesRef.current
|
||||||
position: 'absolute' as const, // Fixed: Use const assertion
|
: [{
|
||||||
};
|
regionId,
|
||||||
|
originalBarNumber: region.barNumber,
|
||||||
|
originalLength: region.length,
|
||||||
|
originalLeft,
|
||||||
|
originalWidth,
|
||||||
|
}];
|
||||||
|
|
||||||
setTempRegionStyles(prev => ({
|
setTempRegionStyles(prev => ({
|
||||||
...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
|
// Notify parent about resize
|
||||||
@@ -328,16 +400,14 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
|
|
||||||
// Clear resizing state
|
// Clear resizing state
|
||||||
setResizingRegion(null);
|
setResizingRegion(null);
|
||||||
setTempRegionStyles(prev => {
|
clearTempRegionStyles(resizePreviewRegionIdsRef.current);
|
||||||
const updated = { ...prev };
|
|
||||||
delete updated[regionId];
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
currentResizeWidth.current = null;
|
currentResizeWidth.current = null;
|
||||||
currentResizeLeft.current = null;
|
currentResizeLeft.current = null;
|
||||||
currentResizeRegion.current = null;
|
currentResizeRegion.current = null;
|
||||||
initialBarNumberRef.current = null;
|
initialBarNumberRef.current = null;
|
||||||
initialLengthRef.current = null;
|
initialLengthRef.current = null;
|
||||||
|
resizePreviewBaselinesRef.current = [];
|
||||||
|
resizePreviewRegionIdsRef.current = [];
|
||||||
|
|
||||||
// Notify parent about resize end with rounded values
|
// Notify parent about resize end with rounded values
|
||||||
if (onRegionResizeEnd) {
|
if (onRegionResizeEnd) {
|
||||||
@@ -378,18 +448,32 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
// Store the initial position
|
// Store the initial position
|
||||||
currentDragLeft.current = left;
|
currentDragLeft.current = left;
|
||||||
currentDragTop.current = 0; // Initially at the top of the current track
|
currentDragTop.current = 0; // Initially at the top of the current track
|
||||||
|
|
||||||
// Set initial style
|
const previewRegionIds = getPreviewRegionIds(regionId);
|
||||||
const initialStyle = {
|
dragPreviewBaselinesRef.current = previewRegionIds
|
||||||
left: `${left}px`,
|
.map(id => regions.find(candidate => candidate.id === id))
|
||||||
width: `${width}px`,
|
.filter((candidate): candidate is RegionUI => candidate !== undefined)
|
||||||
position: 'absolute' as const, // Fixed: Use const assertion
|
.map(candidate => ({
|
||||||
zIndex: 100, // Bring to front during drag
|
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 => ({
|
setTempRegionStyles(prev => ({
|
||||||
...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<TrackGridItemProps> = ({
|
|||||||
console.log(`DRAG: regionId=${regionId}, deltaX=${deltaX}, deltaY=${deltaY}, newBarNumber=${newBarNumber}`);
|
console.log(`DRAG: regionId=${regionId}, deltaX=${deltaX}, deltaY=${deltaY}, newBarNumber=${newBarNumber}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the temporary style for this region
|
const leftDelta = newLeft - initialLeft;
|
||||||
const newStyle = {
|
const previewBaselines = dragPreviewBaselinesRef.current.length > 0
|
||||||
left: `${newLeft}px`,
|
? dragPreviewBaselinesRef.current
|
||||||
width: `${region.length * barWidth}px`,
|
: [{
|
||||||
position: 'absolute' as const,
|
regionId,
|
||||||
zIndex: 100, // Keep on top during drag
|
originalBarNumber: region.barNumber,
|
||||||
transform: `translateY(${appliedDeltaY}px)`,
|
originalTrackIndex: region.trackIndex,
|
||||||
};
|
originalLeft: initialLeft,
|
||||||
|
originalWidth: region.length * barWidth,
|
||||||
|
}];
|
||||||
|
|
||||||
setTempRegionStyles(prev => ({
|
setTempRegionStyles(prev => ({
|
||||||
...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
|
// We'll calculate the track index on drag end, but still notify parent about the drag
|
||||||
@@ -506,14 +601,12 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
|
|
||||||
// Clear dragging state
|
// Clear dragging state
|
||||||
setDraggingRegion(null);
|
setDraggingRegion(null);
|
||||||
setTempRegionStyles(prev => {
|
clearTempRegionStyles(dragPreviewRegionIdsRef.current);
|
||||||
const updated = { ...prev };
|
|
||||||
delete updated[regionId];
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
currentDragLeft.current = null;
|
currentDragLeft.current = null;
|
||||||
currentDragTop.current = null;
|
currentDragTop.current = null;
|
||||||
currentDragRegion.current = null;
|
currentDragRegion.current = null;
|
||||||
|
dragPreviewBaselinesRef.current = [];
|
||||||
|
dragPreviewRegionIdsRef.current = [];
|
||||||
|
|
||||||
if (finalBarNumber === region.barNumber && finalTrackIndex === region.trackIndex) {
|
if (finalBarNumber === region.barNumber && finalTrackIndex === region.trackIndex) {
|
||||||
if (DEBUG_MODE.TRACK_GRID_ITEM) {
|
if (DEBUG_MODE.TRACK_GRID_ITEM) {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
const refreshProjectState = useProjectStore(state => state.refreshProjectState);
|
const refreshProjectState = useProjectStore(state => state.refreshProjectState);
|
||||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
|
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
|
||||||
|
const [previewRegionStyles, setPreviewRegionStyles] = useState<Record<string, React.CSSProperties>>({});
|
||||||
const pendingAudioImportRef = useRef<{ barNumber: number; trackIndex: number } | null>(null);
|
const pendingAudioImportRef = useRef<{ barNumber: number; trackIndex: number } | null>(null);
|
||||||
const isLassoSelectingRef = useRef(false);
|
const isLassoSelectingRef = useRef(false);
|
||||||
const isLassoShiftPressedRef = useRef(false);
|
const isLassoShiftPressedRef = useRef(false);
|
||||||
@@ -907,6 +908,8 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
onOpenHybrid={onOpenHybrid}
|
onOpenHybrid={onOpenHybrid}
|
||||||
allTracks={tracks}
|
allTracks={tracks}
|
||||||
onKGOneClipDrop={handleExternalDrop}
|
onKGOneClipDrop={handleExternalDrop}
|
||||||
|
previewRegionStyles={previewRegionStyles}
|
||||||
|
setPreviewRegionStyles={setPreviewRegionStyles}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ vi.mock('../stores/projectStore', () => ({
|
|||||||
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 { ResizeNotesCommand } from '../core/commands';
|
import { MoveNotesCommand, ResizeNotesCommand } from '../core/commands';
|
||||||
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';
|
||||||
|
|
||||||
@@ -90,8 +90,21 @@ describe('useNoteOperations', () => {
|
|||||||
} as CSSStyleDeclaration);
|
} as CSSStyleDeclaration);
|
||||||
|
|
||||||
KGPianoRollState.instance().setActiveTool('pointer');
|
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', () => {
|
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 });
|
||||||
@@ -108,13 +121,7 @@ describe('useNoteOperations', () => {
|
|||||||
noteB.select();
|
noteB.select();
|
||||||
KGCore.instance().addSelectedItems([noteA, noteB]);
|
KGCore.instance().addSelectedItems([noteA, noteB]);
|
||||||
|
|
||||||
const { result } = renderHook(() => useNoteOperations({
|
const { result } = renderNoteOperations(activeRegion, track, updateTrack);
|
||||||
activeRegion,
|
|
||||||
timeSignature: { numerator: 4, denominator: 4 },
|
|
||||||
updateTrack,
|
|
||||||
tracks: [track],
|
|
||||||
pianoGridRef: { current: null },
|
|
||||||
}));
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.handleNoteResizeStart(noteC.getId(), 'end', 120);
|
result.current.handleNoteResizeStart(noteC.getId(), 'end', 120);
|
||||||
@@ -152,13 +159,7 @@ describe('useNoteOperations', () => {
|
|||||||
noteB.select();
|
noteB.select();
|
||||||
KGCore.instance().addSelectedItems([noteA, noteB]);
|
KGCore.instance().addSelectedItems([noteA, noteB]);
|
||||||
|
|
||||||
const { result } = renderHook(() => useNoteOperations({
|
const { result } = renderNoteOperations(activeRegion, track, updateTrack);
|
||||||
activeRegion,
|
|
||||||
timeSignature: { numerator: 4, denominator: 4 },
|
|
||||||
updateTrack,
|
|
||||||
tracks: [track],
|
|
||||||
pianoGridRef: { current: null },
|
|
||||||
}));
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.handleNoteResizeStart(noteA.getId(), 'end', 0);
|
result.current.handleNoteResizeStart(noteA.getId(), 'end', 0);
|
||||||
@@ -179,4 +180,252 @@ describe('useNoteOperations', () => {
|
|||||||
expect(resizeCommand).toBeInstanceOf(ResizeNotesCommand);
|
expect(resizeCommand).toBeInstanceOf(ResizeNotesCommand);
|
||||||
expect((resizeCommand as ResizeNotesCommand).getNoteIdsToResize()).toEqual([noteA.getId(), noteB.getId()]);
|
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
@@ -25,6 +25,25 @@ interface UseNoteOperationsProps {
|
|||||||
pianoGridRef: MutableRefObject<HTMLDivElement | null>;
|
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 = ({
|
export const useNoteOperations = ({
|
||||||
activeRegion,
|
activeRegion,
|
||||||
timeSignature,
|
timeSignature,
|
||||||
@@ -44,6 +63,8 @@ export const useNoteOperations = ({
|
|||||||
const currentResizeLeft = useRef<number | null>(null);
|
const currentResizeLeft = useRef<number | null>(null);
|
||||||
const initialStartBeatRef = useRef<number | null>(null);
|
const initialStartBeatRef = useRef<number | null>(null);
|
||||||
const initialEndBeatRef = useRef<number | null>(null);
|
const initialEndBeatRef = useRef<number | null>(null);
|
||||||
|
const resizePreviewBaselinesRef = useRef<ResizePreviewBaseline[]>([]);
|
||||||
|
const resizePreviewNoteIdsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
// Refs for drag operations
|
// Refs for drag operations
|
||||||
const initialDragLeft = useRef<number | null>(null);
|
const initialDragLeft = useRef<number | null>(null);
|
||||||
@@ -51,12 +72,29 @@ export const useNoteOperations = ({
|
|||||||
const currentDragLeft = useRef<number | null>(null);
|
const currentDragLeft = useRef<number | null>(null);
|
||||||
const currentDragTop = useRef<number | null>(null);
|
const currentDragTop = useRef<number | null>(null);
|
||||||
const initialPitchRef = 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
|
// Counter to trigger re-renders when notes are updated
|
||||||
const [noteUpdateCounter, setNoteUpdateCounter] = useState(0);
|
const [noteUpdateCounter, setNoteUpdateCounter] = useState(0);
|
||||||
|
|
||||||
// Get KGCore instance for accessing selected items
|
// Get KGCore instance for accessing selected items
|
||||||
const core = KGCore.instance();
|
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
|
// Utility function to delete selected notes from the active region using commands
|
||||||
const deleteSelectedNotes = () => {
|
const deleteSelectedNotes = () => {
|
||||||
@@ -301,6 +339,10 @@ export const useNoteOperations = ({
|
|||||||
updateTrack(track);
|
updateTrack(track);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resizeTargetNotes = isResizedNoteSelected
|
||||||
|
? selectedNotesInRegion
|
||||||
|
: [note];
|
||||||
|
|
||||||
// Store the initial start and end beats
|
// Store the initial start and end beats
|
||||||
initialStartBeatRef.current = note.getStartBeat();
|
initialStartBeatRef.current = note.getStartBeat();
|
||||||
@@ -324,15 +366,28 @@ export const useNoteOperations = ({
|
|||||||
currentResizeWidth.current = width;
|
currentResizeWidth.current = width;
|
||||||
currentResizeLeft.current = left;
|
currentResizeLeft.current = left;
|
||||||
|
|
||||||
// Set initial style to current position/size
|
resizePreviewBaselinesRef.current = resizeTargetNotes.map(targetNote => {
|
||||||
const initialStyle = {
|
const targetAbsStartBeat = targetNote.getStartBeat() + regionStartBeat;
|
||||||
left: `${left}px`,
|
const targetAbsEndBeat = targetNote.getEndBeat() + regionStartBeat;
|
||||||
width: `${width}px`,
|
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 => ({
|
setTempNoteStyles(prev => ({
|
||||||
...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;
|
currentResizeWidth.current = snappedWidth;
|
||||||
currentResizeLeft.current = newLeft;
|
currentResizeLeft.current = newLeft;
|
||||||
|
|
||||||
// Update the temporary style for this note
|
const startBeatDelta = resizeEdge === 'start'
|
||||||
const newStyle = {
|
? (newLeft - originalLeft) / beatWidth
|
||||||
left: `${newLeft}px`,
|
: 0;
|
||||||
width: `${snappedWidth}px`,
|
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 => ({
|
setTempNoteStyles(prev => ({
|
||||||
...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) {
|
if (DEBUG_MODE.PIANO_ROLL) {
|
||||||
@@ -440,11 +521,9 @@ export const useNoteOperations = ({
|
|||||||
initialStartBeatRef.current === null || initialEndBeatRef.current === null) {
|
initialStartBeatRef.current === null || initialEndBeatRef.current === null) {
|
||||||
// Reset resizing state
|
// Reset resizing state
|
||||||
setResizingNoteId(null);
|
setResizingNoteId(null);
|
||||||
setTempNoteStyles(prev => {
|
clearTempNoteStyles(resizePreviewNoteIdsRef.current);
|
||||||
const updated = { ...prev };
|
resizePreviewBaselinesRef.current = [];
|
||||||
delete updated[noteId];
|
resizePreviewNoteIdsRef.current = [];
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,15 +614,13 @@ export const useNoteOperations = ({
|
|||||||
console.error('Error resizing notes:', error);
|
console.error('Error resizing notes:', error);
|
||||||
// Reset resizing state and return early on error
|
// Reset resizing state and return early on error
|
||||||
setResizingNoteId(null);
|
setResizingNoteId(null);
|
||||||
setTempNoteStyles(prev => {
|
clearTempNoteStyles(resizePreviewNoteIdsRef.current);
|
||||||
const updated = { ...prev };
|
|
||||||
delete updated[noteId];
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
currentResizeWidth.current = null;
|
currentResizeWidth.current = null;
|
||||||
currentResizeLeft.current = null;
|
currentResizeLeft.current = null;
|
||||||
initialStartBeatRef.current = null;
|
initialStartBeatRef.current = null;
|
||||||
initialEndBeatRef.current = null;
|
initialEndBeatRef.current = null;
|
||||||
|
resizePreviewBaselinesRef.current = [];
|
||||||
|
resizePreviewNoteIdsRef.current = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,15 +637,13 @@ export const useNoteOperations = ({
|
|||||||
|
|
||||||
// Reset resizing state
|
// Reset resizing state
|
||||||
setResizingNoteId(null);
|
setResizingNoteId(null);
|
||||||
setTempNoteStyles(prev => {
|
clearTempNoteStyles(resizePreviewNoteIdsRef.current);
|
||||||
const updated = { ...prev };
|
|
||||||
delete updated[noteId];
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
currentResizeWidth.current = null;
|
currentResizeWidth.current = null;
|
||||||
currentResizeLeft.current = null;
|
currentResizeLeft.current = null;
|
||||||
initialStartBeatRef.current = null;
|
initialStartBeatRef.current = null;
|
||||||
initialEndBeatRef.current = null;
|
initialEndBeatRef.current = null;
|
||||||
|
resizePreviewBaselinesRef.current = [];
|
||||||
|
resizePreviewNoteIdsRef.current = [];
|
||||||
|
|
||||||
// Increment the note update counter to trigger a re-render
|
// Increment the note update counter to trigger a re-render
|
||||||
setNoteUpdateCounter(prev => prev + 1);
|
setNoteUpdateCounter(prev => prev + 1);
|
||||||
@@ -598,6 +673,15 @@ export const useNoteOperations = ({
|
|||||||
// Find the note being dragged
|
// Find the note being dragged
|
||||||
const note = activeRegion.getNotes().find(n => n.getId() === noteId);
|
const note = activeRegion.getNotes().find(n => n.getId() === noteId);
|
||||||
if (!note) return;
|
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
|
// Store the initial pitch
|
||||||
initialPitchRef.current = note.getPitch();
|
initialPitchRef.current = note.getPitch();
|
||||||
@@ -625,19 +709,37 @@ export const useNoteOperations = ({
|
|||||||
initialDragTop.current = top;
|
initialDragTop.current = top;
|
||||||
currentDragLeft.current = left;
|
currentDragLeft.current = left;
|
||||||
currentDragTop.current = top;
|
currentDragTop.current = top;
|
||||||
|
|
||||||
// Set initial style
|
dragPreviewBaselinesRef.current = dragTargetNotes.map(targetNote => {
|
||||||
const initialStyle = {
|
const targetAbsStartBeat = targetNote.getStartBeat() + regionStartBeat;
|
||||||
left: `${left}px`,
|
const targetWidth = (targetNote.getEndBeat() - targetNote.getStartBeat()) * beatWidth;
|
||||||
top: `${top}px`,
|
const targetTop = (107 - targetNote.getPitch()) * noteHeight;
|
||||||
width: `${width}px`,
|
|
||||||
height: `${noteHeight}px`,
|
return {
|
||||||
zIndex: 100, // Bring to front during drag
|
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 => ({
|
setTempNoteStyles(prev => ({
|
||||||
...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;
|
currentDragLeft.current = newLeft;
|
||||||
currentDragTop.current = newTop;
|
currentDragTop.current = newTop;
|
||||||
|
|
||||||
// Calculate width based on note duration
|
const previewBaselines = dragPreviewBaselinesRef.current.length > 0
|
||||||
const width = (note.getEndBeat() - note.getStartBeat()) * beatWidth;
|
? dragPreviewBaselinesRef.current
|
||||||
|
: [{
|
||||||
// Update the temporary style for this note
|
noteId,
|
||||||
const newStyle = {
|
originalStartBeat: note.getStartBeat(),
|
||||||
left: `${newLeft}px`,
|
originalEndBeat: note.getEndBeat(),
|
||||||
top: `${newTop}px`,
|
originalPitch: note.getPitch(),
|
||||||
width: `${width}px`,
|
originalLeft,
|
||||||
height: `${noteHeight}px`,
|
originalTop,
|
||||||
zIndex: 100, // Keep on top during drag
|
originalWidth: (note.getEndBeat() - note.getStartBeat()) * beatWidth,
|
||||||
};
|
originalHeight: noteHeight,
|
||||||
|
}];
|
||||||
|
const leftDelta = newLeft - originalLeft;
|
||||||
|
const topDelta = newTop - originalTop;
|
||||||
|
|
||||||
setTempNoteStyles(prev => ({
|
setTempNoteStyles(prev => ({
|
||||||
...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) {
|
if (DEBUG_MODE.PIANO_ROLL) {
|
||||||
@@ -727,11 +841,9 @@ export const useNoteOperations = ({
|
|||||||
initialPitchRef.current === null) {
|
initialPitchRef.current === null) {
|
||||||
// Reset dragging state
|
// Reset dragging state
|
||||||
setDraggingNoteId(null);
|
setDraggingNoteId(null);
|
||||||
setTempNoteStyles(prev => {
|
clearTempNoteStyles(dragPreviewNoteIdsRef.current);
|
||||||
const updated = { ...prev };
|
dragPreviewBaselinesRef.current = [];
|
||||||
delete updated[noteId];
|
dragPreviewNoteIdsRef.current = [];
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,16 +905,14 @@ export const useNoteOperations = ({
|
|||||||
console.error('Error moving notes:', error);
|
console.error('Error moving notes:', error);
|
||||||
// Reset dragging state and return early on error
|
// Reset dragging state and return early on error
|
||||||
setDraggingNoteId(null);
|
setDraggingNoteId(null);
|
||||||
setTempNoteStyles(prev => {
|
clearTempNoteStyles(dragPreviewNoteIdsRef.current);
|
||||||
const updated = { ...prev };
|
|
||||||
delete updated[noteId];
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
currentDragLeft.current = null;
|
currentDragLeft.current = null;
|
||||||
currentDragTop.current = null;
|
currentDragTop.current = null;
|
||||||
initialDragLeft.current = null;
|
initialDragLeft.current = null;
|
||||||
initialDragTop.current = null;
|
initialDragTop.current = null;
|
||||||
initialPitchRef.current = null;
|
initialPitchRef.current = null;
|
||||||
|
dragPreviewBaselinesRef.current = [];
|
||||||
|
dragPreviewNoteIdsRef.current = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,16 +930,14 @@ export const useNoteOperations = ({
|
|||||||
|
|
||||||
// Reset dragging state
|
// Reset dragging state
|
||||||
setDraggingNoteId(null);
|
setDraggingNoteId(null);
|
||||||
setTempNoteStyles(prev => {
|
clearTempNoteStyles(dragPreviewNoteIdsRef.current);
|
||||||
const updated = { ...prev };
|
|
||||||
delete updated[noteId];
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
currentDragLeft.current = null;
|
currentDragLeft.current = null;
|
||||||
currentDragTop.current = null;
|
currentDragTop.current = null;
|
||||||
initialDragLeft.current = null;
|
initialDragLeft.current = null;
|
||||||
initialDragTop.current = null;
|
initialDragTop.current = null;
|
||||||
initialPitchRef.current = null;
|
initialPitchRef.current = null;
|
||||||
|
dragPreviewBaselinesRef.current = [];
|
||||||
|
dragPreviewNoteIdsRef.current = [];
|
||||||
|
|
||||||
// Increment the note update counter to trigger a re-render
|
// Increment the note update counter to trigger a re-render
|
||||||
setNoteUpdateCounter(prev => prev + 1);
|
setNoteUpdateCounter(prev => prev + 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user