feat: implemented editable automation lane in piano roll window
This commit is contained in:
@@ -3,6 +3,7 @@ import { KGCore } from '../../core/KGCore';
|
|||||||
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||||
import { useProjectStore } from '../../stores/projectStore';
|
import { useProjectStore } from '../../stores/projectStore';
|
||||||
import { DEBUG_MODE } from '../../constants';
|
import { DEBUG_MODE } from '../../constants';
|
||||||
|
import { getSnappedBeatPosition } from './pianoRollSnap';
|
||||||
|
|
||||||
interface PianoGridHeaderProps {
|
interface PianoGridHeaderProps {
|
||||||
maxBars: number;
|
maxBars: number;
|
||||||
@@ -20,35 +21,6 @@ const PianoGridHeader: React.FC<PianoGridHeaderProps> = ({
|
|||||||
const isDraggingRef = useRef(false);
|
const isDraggingRef = useRef(false);
|
||||||
const headerElementRef = useRef<HTMLDivElement | null>(null);
|
const headerElementRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Utility function to calculate snapped beat position (based on useNoteOperations.ts)
|
|
||||||
const getSnappedBeatPosition = (beatPosition: number): number => {
|
|
||||||
const currentSnap = KGPianoRollState.instance().getCurrentSnap();
|
|
||||||
|
|
||||||
// If no snapping is enabled, return the original position
|
|
||||||
if (currentSnap === 'NO SNAP') {
|
|
||||||
return beatPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the snap value (e.g., "1/4", "1/8", "1/16", "1/32")
|
|
||||||
const denominator = parseInt(currentSnap.split('/')[1]);
|
|
||||||
if (isNaN(denominator)) {
|
|
||||||
return beatPosition; // Fallback to no snapping if invalid
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the snap step in beats
|
|
||||||
// snapStep should ALWAYS be 4 / denominator regardless of time signature
|
|
||||||
const snapStep = 4 / denominator;
|
|
||||||
|
|
||||||
// Use round snapping for playhead positioning
|
|
||||||
const snappedPosition = Math.round(beatPosition / snapStep) * snapStep;
|
|
||||||
|
|
||||||
if (DEBUG_MODE.PIANO_ROLL) {
|
|
||||||
console.log(`Piano Grid Header Snapping: ${beatPosition} -> ${snappedPosition} (snap: ${currentSnap}, step: ${snapStep})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return snappedPosition;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Utility function to calculate playhead position from mouse coordinates
|
// Utility function to calculate playhead position from mouse coordinates
|
||||||
const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => {
|
const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => {
|
||||||
if (!headerElementRef.current) return null;
|
if (!headerElementRef.current) return null;
|
||||||
@@ -77,7 +49,7 @@ const PianoGridHeader: React.FC<PianoGridHeaderProps> = ({
|
|||||||
const rawBeatPosition = adjustedX / beatWidth;
|
const rawBeatPosition = adjustedX / beatWidth;
|
||||||
|
|
||||||
// Apply quantization if enabled
|
// Apply quantization if enabled
|
||||||
return getSnappedBeatPosition(rawBeatPosition);
|
return getSnappedBeatPosition(rawBeatPosition, KGPianoRollState.instance().getCurrentSnap());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle mouse down to start dragging
|
// Handle mouse down to start dragging
|
||||||
|
|||||||
@@ -457,6 +457,10 @@
|
|||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.piano-roll-automation-scroll-layer.pencil-cursor {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
/* Piano Grid Cursor Highlights */
|
/* Piano Grid Cursor Highlights */
|
||||||
.piano-grid-pitch-highlight {
|
.piano-grid-pitch-highlight {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -570,10 +574,20 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.piano-roll-automation-line {
|
.piano-roll-automation-line {
|
||||||
filter: drop-shadow(0 0 2px rgba(135, 206, 250, 0.3));
|
filter: drop-shadow(0 0 2px rgba(135, 206, 250, 0.3));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.piano-roll-automation-point {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.piano-roll-automation-point.selected {
|
||||||
|
filter: drop-shadow(0 0 3px rgba(255, 255, 255, 0.6));
|
||||||
}
|
}
|
||||||
|
|
||||||
.piano-roll-automation-value {
|
.piano-roll-automation-value {
|
||||||
@@ -585,6 +599,14 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.piano-roll-automation-selection-box {
|
||||||
|
position: absolute;
|
||||||
|
border: 1px solid rgba(135, 206, 250, 0.95);
|
||||||
|
background: rgba(135, 206, 250, 0.18);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.piano-roll-automation-empty-state {
|
.piano-roll-automation-empty-state {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -1,25 +1,82 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||||
|
import { createMockMidiControllerEvent, createMockMidiPitchBend, createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data';
|
||||||
|
|
||||||
|
const coreMock = {
|
||||||
|
selectedItems: [] as Array<{ getId(): string }>,
|
||||||
|
currentProjectTracks: [] as ReturnType<typeof createMockMidiTrack>[],
|
||||||
|
getSelectedItems: vi.fn(() => coreMock.selectedItems),
|
||||||
|
getCurrentProject: vi.fn(() => ({
|
||||||
|
getTracks: () => coreMock.currentProjectTracks,
|
||||||
|
})),
|
||||||
|
clearSelectedItems: vi.fn(() => {
|
||||||
|
coreMock.selectedItems = [];
|
||||||
|
}),
|
||||||
|
addSelectedItems: vi.fn((items: Array<{ getId(): string }>) => {
|
||||||
|
coreMock.selectedItems = items;
|
||||||
|
}),
|
||||||
|
executeCommand: vi.fn((command: { execute(): void }) => {
|
||||||
|
command.execute();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const storeState = {
|
||||||
|
tracks: [] as ReturnType<typeof createMockMidiTrack>[],
|
||||||
|
updateTrack: vi.fn().mockResolvedValue(undefined),
|
||||||
|
refreshProjectState: vi.fn(),
|
||||||
|
bumpAutomationRedrawVersion: vi.fn(),
|
||||||
|
selectedPitchBendIds: [] as string[],
|
||||||
|
selectedControllerEventIds: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../core/KGCore', () => ({
|
||||||
|
KGCore: {
|
||||||
|
instance: () => coreMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../stores/projectStore', () => ({
|
||||||
|
useProjectStore: () => storeState,
|
||||||
|
}));
|
||||||
|
|
||||||
import PianoRollAutomationLane from './PianoRollAutomationLane';
|
import PianoRollAutomationLane from './PianoRollAutomationLane';
|
||||||
import { KGMidiControllerEvent } from '../../core/midi/KGMidiControllerEvent';
|
import { getControllerNumberForAutomationType } from './pianoRollAutomation';
|
||||||
import {
|
|
||||||
createMockMidiControllerEvent,
|
|
||||||
createMockMidiPitchBend,
|
|
||||||
createMockMidiRegion,
|
|
||||||
} from '../../test/utils/mock-data';
|
|
||||||
|
|
||||||
describe('PianoRollAutomationLane', () => {
|
describe('PianoRollAutomationLane', () => {
|
||||||
it('renders pitch bend points with signed labels', () => {
|
beforeEach(() => {
|
||||||
|
coreMock.selectedItems = [];
|
||||||
|
coreMock.currentProjectTracks = [];
|
||||||
|
coreMock.getSelectedItems.mockClear();
|
||||||
|
coreMock.getCurrentProject.mockClear();
|
||||||
|
coreMock.clearSelectedItems.mockClear();
|
||||||
|
coreMock.addSelectedItems.mockClear();
|
||||||
|
coreMock.executeCommand.mockClear();
|
||||||
|
storeState.updateTrack.mockClear();
|
||||||
|
storeState.refreshProjectState.mockClear();
|
||||||
|
storeState.bumpAutomationRedrawVersion.mockClear();
|
||||||
|
storeState.selectedPitchBendIds = [];
|
||||||
|
storeState.selectedControllerEventIds = [];
|
||||||
|
KGPianoRollState.instance().setActiveTool('pointer');
|
||||||
|
KGPianoRollState.instance().setCurrentSnap('1/4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders pitch bend points with signed labels and selected styling', () => {
|
||||||
const region = createMockMidiRegion({
|
const region = createMockMidiRegion({
|
||||||
|
trackId: '1',
|
||||||
|
trackIndex: 0,
|
||||||
startFromBeat: 4,
|
startFromBeat: 4,
|
||||||
pitchBends: [
|
pitchBends: [
|
||||||
createMockMidiPitchBend({ id: 'bend-1', beat: 0.5, value: 8192 }),
|
createMockMidiPitchBend({ id: 'bend-1', beat: 0.5, value: 8192 }),
|
||||||
createMockMidiPitchBend({ id: 'bend-2', beat: 1.5, value: 12288 }),
|
createMockMidiPitchBend({ id: 'bend-2', beat: 1.5, value: 12288 }),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
|
||||||
|
coreMock.currentProjectTracks = storeState.tracks;
|
||||||
|
storeState.selectedPitchBendIds = ['bend-1'];
|
||||||
|
|
||||||
render(
|
const { container } = render(
|
||||||
<PianoRollAutomationLane
|
<PianoRollAutomationLane
|
||||||
activeRegion={region}
|
activeRegion={region}
|
||||||
automationType="pitch-bend"
|
automationType="pitch-bend"
|
||||||
@@ -31,22 +88,107 @@ describe('PianoRollAutomationLane', () => {
|
|||||||
expect(screen.getByLabelText('Pitch Bend automation lane')).toBeInTheDocument();
|
expect(screen.getByLabelText('Pitch Bend automation lane')).toBeInTheDocument();
|
||||||
expect(screen.getByText('0')).toBeInTheDocument();
|
expect(screen.getByText('0')).toBeInTheDocument();
|
||||||
expect(screen.getByText('4096')).toBeInTheDocument();
|
expect(screen.getByText('4096')).toBeInTheDocument();
|
||||||
expect(document.querySelector('.piano-roll-automation-line')).not.toBeNull();
|
expect(container.querySelector('.piano-roll-automation-line')).not.toBeNull();
|
||||||
|
|
||||||
|
const selectedPoint = container.querySelector('.piano-roll-automation-point.selected');
|
||||||
|
expect(selectedPoint).not.toBeNull();
|
||||||
|
expect(selectedPoint).toHaveAttribute('fill', '#FFFFFF');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders controller values for the selected CC bucket', () => {
|
it('creates a snapped pitch bend on double click and selects it', async () => {
|
||||||
const controllerEventsByType: KGMidiControllerEvent[][] = Array.from({ length: 128 }, () => []);
|
const region = createMockMidiRegion({
|
||||||
controllerEventsByType[7] = [
|
id: 'region-1',
|
||||||
|
trackId: '1',
|
||||||
|
trackIndex: 0,
|
||||||
|
startFromBeat: 4,
|
||||||
|
pitchBends: [],
|
||||||
|
});
|
||||||
|
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
|
||||||
|
coreMock.currentProjectTracks = storeState.tracks;
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<PianoRollAutomationLane
|
||||||
|
activeRegion={region}
|
||||||
|
automationType="pitch-bend"
|
||||||
|
maxBars={8}
|
||||||
|
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const lane = screen.getByLabelText('Pitch Bend automation lane');
|
||||||
|
Object.defineProperty(lane, 'getBoundingClientRect', {
|
||||||
|
value: () => ({ left: 0, top: 0, right: 1000, bottom: 200, width: 1000, height: 200 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.doubleClick(container.querySelector('.piano-roll-automation-scroll-layer')!, {
|
||||||
|
clientX: 170,
|
||||||
|
clientY: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(coreMock.executeCommand).toHaveBeenCalledTimes(1);
|
||||||
|
expect(region.getPitchBends()).toHaveLength(1);
|
||||||
|
expect(region.getPitchBends()[0].getBeat()).toBe(-1);
|
||||||
|
expect(coreMock.addSelectedItems).toHaveBeenCalled();
|
||||||
|
expect(storeState.bumpAutomationRedrawVersion).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an unsnapped pitch bend at the raw horizontal beat when snapping is off', async () => {
|
||||||
|
const region = createMockMidiRegion({
|
||||||
|
id: 'region-raw',
|
||||||
|
trackId: '1',
|
||||||
|
trackIndex: 0,
|
||||||
|
startFromBeat: 4,
|
||||||
|
pitchBends: [],
|
||||||
|
});
|
||||||
|
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
|
||||||
|
coreMock.currentProjectTracks = storeState.tracks;
|
||||||
|
KGPianoRollState.instance().setCurrentSnap('NO SNAP');
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<PianoRollAutomationLane
|
||||||
|
activeRegion={region}
|
||||||
|
automationType="pitch-bend"
|
||||||
|
maxBars={8}
|
||||||
|
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const lane = screen.getByLabelText('Pitch Bend automation lane');
|
||||||
|
Object.defineProperty(lane, 'getBoundingClientRect', {
|
||||||
|
value: () => ({ left: 0, top: 0, right: 1000, bottom: 200, width: 1000, height: 200 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.doubleClick(container.querySelector('.piano-roll-automation-scroll-layer')!, {
|
||||||
|
clientX: 150,
|
||||||
|
clientY: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(region.getPitchBends()).toHaveLength(1);
|
||||||
|
expect(region.getPitchBends()[0].getBeat()).toBeCloseTo(-1.75);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shift-click adds controller events to the selection', async () => {
|
||||||
|
const controller = getControllerNumberForAutomationType('cc-7')!;
|
||||||
|
const controllerEventsByType = Array.from({ length: 128 }, () => [] as ReturnType<typeof createMockMidiControllerEvent>[]);
|
||||||
|
controllerEventsByType[controller] = [
|
||||||
createMockMidiControllerEvent({ id: 'cc7-1', beat: 0.25, value: 57 }),
|
createMockMidiControllerEvent({ id: 'cc7-1', beat: 0.25, value: 57 }),
|
||||||
createMockMidiControllerEvent({ id: 'cc7-2', beat: 2, value: 82 }),
|
createMockMidiControllerEvent({ id: 'cc7-2', beat: 2, value: 82 }),
|
||||||
];
|
];
|
||||||
|
|
||||||
const region = createMockMidiRegion({
|
const region = createMockMidiRegion({
|
||||||
|
trackId: '1',
|
||||||
|
trackIndex: 0,
|
||||||
startFromBeat: 0,
|
startFromBeat: 0,
|
||||||
controllerEventsByType,
|
controllerEventsByType,
|
||||||
});
|
});
|
||||||
|
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
|
||||||
|
coreMock.currentProjectTracks = storeState.tracks;
|
||||||
|
storeState.selectedControllerEventIds = ['cc7-1'];
|
||||||
|
|
||||||
render(
|
const { container } = render(
|
||||||
<PianoRollAutomationLane
|
<PianoRollAutomationLane
|
||||||
activeRegion={region}
|
activeRegion={region}
|
||||||
automationType="cc-7"
|
automationType="cc-7"
|
||||||
@@ -55,37 +197,32 @@ describe('PianoRollAutomationLane', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByLabelText('CC7 automation lane')).toBeInTheDocument();
|
const points = container.querySelectorAll('.piano-roll-automation-point');
|
||||||
expect(screen.getByText('57')).toBeInTheDocument();
|
fireEvent.mouseDown(points[1], { shiftKey: true, clientX: 0, clientY: 0 });
|
||||||
expect(screen.getByText('82')).toBeInTheDocument();
|
fireEvent.mouseUp(document);
|
||||||
});
|
|
||||||
|
|
||||||
it('shows an empty lane shell when the selected automation type has no events', () => {
|
await waitFor(() => {
|
||||||
const region = createMockMidiRegion();
|
expect(coreMock.addSelectedItems).toHaveBeenCalled();
|
||||||
|
const selectedItems = coreMock.addSelectedItems.mock.calls.at(-1)?.[0] ?? [];
|
||||||
render(
|
expect(selectedItems).toHaveLength(2);
|
||||||
<PianoRollAutomationLane
|
});
|
||||||
activeRegion={region}
|
|
||||||
automationType="cc-64"
|
|
||||||
maxBars={8}
|
|
||||||
timeSignature={{ numerator: 4, denominator: 4 }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('No CC64 events in this region')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders step-style hold segments for non-interpolatable automation', () => {
|
it('renders step-style hold segments for non-interpolatable automation', () => {
|
||||||
const controllerEventsByType: KGMidiControllerEvent[][] = Array.from({ length: 128 }, () => []);
|
const controllerEventsByType = Array.from({ length: 128 }, () => [] as ReturnType<typeof createMockMidiControllerEvent>[]);
|
||||||
controllerEventsByType[64] = [
|
controllerEventsByType[64] = [
|
||||||
createMockMidiControllerEvent({ id: 'cc64-1', beat: 0.5, value: 127 }),
|
createMockMidiControllerEvent({ id: 'cc64-1', beat: 0.5, value: 127 }),
|
||||||
createMockMidiControllerEvent({ id: 'cc64-2', beat: 2, value: 0 }),
|
createMockMidiControllerEvent({ id: 'cc64-2', beat: 2, value: 0 }),
|
||||||
];
|
];
|
||||||
|
|
||||||
const region = createMockMidiRegion({
|
const region = createMockMidiRegion({
|
||||||
|
trackId: '1',
|
||||||
|
trackIndex: 0,
|
||||||
startFromBeat: 0,
|
startFromBeat: 0,
|
||||||
controllerEventsByType,
|
controllerEventsByType,
|
||||||
});
|
});
|
||||||
|
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
|
||||||
|
coreMock.currentProjectTracks = storeState.tracks;
|
||||||
|
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<PianoRollAutomationLane
|
<PianoRollAutomationLane
|
||||||
@@ -100,4 +237,75 @@ describe('PianoRollAutomationLane', () => {
|
|||||||
expect(container.querySelector('polyline.piano-roll-automation-line')).toBeNull();
|
expect(container.querySelector('polyline.piano-roll-automation-line')).toBeNull();
|
||||||
expect(container.querySelectorAll('line.piano-roll-automation-line')).toHaveLength(2);
|
expect(container.querySelectorAll('line.piano-roll-automation-line')).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('redraws connection lines using sorted preview positions when a point crosses another point', async () => {
|
||||||
|
const region = createMockMidiRegion({
|
||||||
|
trackId: '1',
|
||||||
|
trackIndex: 0,
|
||||||
|
startFromBeat: 0,
|
||||||
|
pitchBends: [
|
||||||
|
createMockMidiPitchBend({ id: 'bend-1', beat: 0, value: 8192 }),
|
||||||
|
createMockMidiPitchBend({ id: 'bend-2', beat: 2, value: 12288 }),
|
||||||
|
createMockMidiPitchBend({ id: 'bend-3', beat: 4, value: 4096 }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
|
||||||
|
coreMock.currentProjectTracks = storeState.tracks;
|
||||||
|
storeState.selectedPitchBendIds = ['bend-2'];
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<PianoRollAutomationLane
|
||||||
|
activeRegion={region}
|
||||||
|
automationType="pitch-bend"
|
||||||
|
maxBars={8}
|
||||||
|
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const points = container.querySelectorAll('.piano-roll-automation-point');
|
||||||
|
fireEvent.mouseDown(points[1], { clientX: 140, clientY: 70 });
|
||||||
|
fireEvent.mouseMove(document, { clientX: 30, clientY: 70 });
|
||||||
|
|
||||||
|
const polyline = container.querySelector('polyline.piano-roll-automation-line');
|
||||||
|
expect(polyline).not.toBeNull();
|
||||||
|
|
||||||
|
const pointsAttr = polyline?.getAttribute('points') ?? '';
|
||||||
|
const xValues = pointsAttr.split(' ').map(point => parseFloat(point.split(',')[0]));
|
||||||
|
|
||||||
|
expect(xValues[0]).toBeLessThanOrEqual(xValues[1]);
|
||||||
|
expect(xValues[1]).toBeLessThanOrEqual(xValues[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the create cursor when the modifier key is held', async () => {
|
||||||
|
const region = createMockMidiRegion({
|
||||||
|
trackId: '1',
|
||||||
|
trackIndex: 0,
|
||||||
|
startFromBeat: 0,
|
||||||
|
pitchBends: [],
|
||||||
|
});
|
||||||
|
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
|
||||||
|
coreMock.currentProjectTracks = storeState.tracks;
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<PianoRollAutomationLane
|
||||||
|
activeRegion={region}
|
||||||
|
automationType="pitch-bend"
|
||||||
|
maxBars={8}
|
||||||
|
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const getScrollLayer = () => container.querySelector('.piano-roll-automation-scroll-layer');
|
||||||
|
expect(getScrollLayer()?.classList.contains('pencil-cursor')).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.keyDown(window, { key: 'Control', ctrlKey: true });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getScrollLayer()?.classList.contains('pencil-cursor')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyUp(window, { key: 'Control', ctrlKey: false });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getScrollLayer()?.classList.contains('pencil-cursor')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { KGCore } from '../../core/KGCore';
|
||||||
|
import { CreateMidiEventsCommand } from '../../core/commands/note/CreateMidiEventsCommand';
|
||||||
|
import { UpdateControllerEventPropertiesCommand } from '../../core/commands/note/UpdateControllerEventPropertiesCommand';
|
||||||
|
import { UpdatePitchBendPropertiesCommand } from '../../core/commands/note/UpdatePitchBendPropertiesCommand';
|
||||||
|
import { KGMidiControllerEvent } from '../../core/midi/KGMidiControllerEvent';
|
||||||
|
import { KGMidiPitchBend } from '../../core/midi/KGMidiPitchBend';
|
||||||
|
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||||
import {
|
import {
|
||||||
MIDI_PITCH_BEND_MAX,
|
MIDI_PITCH_BEND_MAX,
|
||||||
MIDI_PITCH_BEND_MIN,
|
MIDI_PITCH_BEND_MIN,
|
||||||
|
clampMidiControllerValue,
|
||||||
|
clampMidiPitchBendValue,
|
||||||
midiPitchBendToSignedValue,
|
midiPitchBendToSignedValue,
|
||||||
} from '../../util/midiUtil';
|
} from '../../util/midiUtil';
|
||||||
|
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||||
|
import { useProjectStore } from '../../stores/projectStore';
|
||||||
|
import { PIANO_ROLL_CONSTANTS } from '../../constants';
|
||||||
|
import { getSnappedBeatPosition } from './pianoRollSnap';
|
||||||
import {
|
import {
|
||||||
getAutomationInterpolationMode,
|
getAutomationInterpolationMode,
|
||||||
getControllerNumberForAutomationType,
|
getControllerNumberForAutomationType,
|
||||||
@@ -14,6 +27,9 @@ import {
|
|||||||
|
|
||||||
interface AutomationPoint {
|
interface AutomationPoint {
|
||||||
id: string;
|
id: string;
|
||||||
|
kind: 'pitch-bend' | 'controller';
|
||||||
|
controller: number | null;
|
||||||
|
relativeBeat: number;
|
||||||
absoluteBeat: number;
|
absoluteBeat: number;
|
||||||
value: number;
|
value: number;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -30,9 +46,23 @@ interface PianoRollAutomationLaneProps {
|
|||||||
onHorizontalWheel?: (delta: number) => void;
|
onHorizontalWheel?: (delta: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SelectionBoxState {
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
endX: number;
|
||||||
|
endY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewPoint {
|
||||||
|
absoluteBeat: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
const AUTOMATION_COLOR = '#87CEFA';
|
const AUTOMATION_COLOR = '#87CEFA';
|
||||||
|
const SELECTED_POINT_COLOR = '#FFFFFF';
|
||||||
const LANE_PADDING_Y = 16;
|
const LANE_PADDING_Y = 16;
|
||||||
const MIN_LANE_HEIGHT = 160;
|
const MIN_LANE_HEIGHT = 160;
|
||||||
|
const POINT_RADIUS = 5;
|
||||||
|
|
||||||
const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
||||||
activeRegion,
|
activeRegion,
|
||||||
@@ -44,10 +74,38 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
|||||||
onHorizontalWheel,
|
onHorizontalWheel,
|
||||||
}) => {
|
}) => {
|
||||||
const laneRef = useRef<HTMLDivElement | null>(null);
|
const laneRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const isLassoSelectingRef = useRef(false);
|
||||||
|
const lassoShiftKeyRef = useRef(false);
|
||||||
|
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
||||||
|
const selectionBoxRef = useRef<SelectionBoxState>({ startX: 0, startY: 0, endX: 0, endY: 0 });
|
||||||
|
const [selectionBoxRenderTick, setSelectionBoxRenderTick] = useState(0);
|
||||||
|
const preventBackgroundClearRef = useRef(false);
|
||||||
|
const dragStateRef = useRef<{
|
||||||
|
primaryPointId: string;
|
||||||
|
originAbsoluteBeat: number;
|
||||||
|
originValue: number;
|
||||||
|
originClientX: number;
|
||||||
|
originClientY: number;
|
||||||
|
selectedPoints: AutomationPoint[];
|
||||||
|
minDeltaValue: number;
|
||||||
|
maxDeltaValue: number;
|
||||||
|
hasMoved: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const [previewPoints, setPreviewPoints] = useState<Record<string, PreviewPoint>>({});
|
||||||
|
const previewPointsRef = useRef<Record<string, PreviewPoint>>({});
|
||||||
const [laneHeight, setLaneHeight] = useState(MIN_LANE_HEIGHT);
|
const [laneHeight, setLaneHeight] = useState(MIN_LANE_HEIGHT);
|
||||||
const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40;
|
const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40;
|
||||||
const keyWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')) || 60;
|
const keyWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')) || 60;
|
||||||
|
|
||||||
|
const {
|
||||||
|
tracks,
|
||||||
|
updateTrack,
|
||||||
|
refreshProjectState,
|
||||||
|
bumpAutomationRedrawVersion,
|
||||||
|
selectedPitchBendIds,
|
||||||
|
selectedControllerEventIds,
|
||||||
|
} = useProjectStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const element = laneRef.current;
|
const element = laneRef.current;
|
||||||
if (!element) {
|
if (!element) {
|
||||||
@@ -92,6 +150,50 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
|||||||
};
|
};
|
||||||
}, [onHorizontalWheel]);
|
}, [onHorizontalWheel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isTypingTarget = (target: EventTarget | null): boolean => {
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
target.tagName === 'INPUT' ||
|
||||||
|
target.tagName === 'TEXTAREA' ||
|
||||||
|
target.contentEditable === 'true' ||
|
||||||
|
target.hasAttribute('data-chatbox-input') ||
|
||||||
|
target.closest('.chatbox-input') !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (isTypingTarget(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isModifierKeyPressed(event)) {
|
||||||
|
setIsModifierPressed(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyUp = (event: KeyboardEvent) => {
|
||||||
|
if (isTypingTarget(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isModifierKeyPressed(event)) {
|
||||||
|
setIsModifierPressed(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
window.addEventListener('keyup', handleKeyUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
window.removeEventListener('keyup', handleKeyUp);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const points = useMemo<AutomationPoint[]>(() => {
|
const points = useMemo<AutomationPoint[]>(() => {
|
||||||
if (!activeRegion) {
|
if (!activeRegion) {
|
||||||
return [];
|
return [];
|
||||||
@@ -101,6 +203,9 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
|||||||
if (automationType === 'pitch-bend') {
|
if (automationType === 'pitch-bend') {
|
||||||
return activeRegion.getPitchBends().map((pitchBend) => ({
|
return activeRegion.getPitchBends().map((pitchBend) => ({
|
||||||
id: pitchBend.getId(),
|
id: pitchBend.getId(),
|
||||||
|
kind: 'pitch-bend' as const,
|
||||||
|
controller: null,
|
||||||
|
relativeBeat: pitchBend.getBeat(),
|
||||||
absoluteBeat: regionStartBeat + pitchBend.getBeat(),
|
absoluteBeat: regionStartBeat + pitchBend.getBeat(),
|
||||||
value: pitchBend.getValue(),
|
value: pitchBend.getValue(),
|
||||||
label: `${midiPitchBendToSignedValue(pitchBend.getValue())}`,
|
label: `${midiPitchBendToSignedValue(pitchBend.getValue())}`,
|
||||||
@@ -114,69 +219,553 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
|||||||
|
|
||||||
return activeRegion.getControllerEvents(controller).map((event) => ({
|
return activeRegion.getControllerEvents(controller).map((event) => ({
|
||||||
id: event.getId(),
|
id: event.getId(),
|
||||||
|
kind: 'controller' as const,
|
||||||
|
controller,
|
||||||
|
relativeBeat: event.getBeat(),
|
||||||
absoluteBeat: regionStartBeat + event.getBeat(),
|
absoluteBeat: regionStartBeat + event.getBeat(),
|
||||||
value: event.getValue(),
|
value: event.getValue(),
|
||||||
label: `${event.getValue()}`,
|
label: `${event.getValue()}`,
|
||||||
}));
|
}));
|
||||||
}, [activeRegion, automationType, redrawVersion]);
|
}, [activeRegion, automationType, redrawVersion]);
|
||||||
|
|
||||||
|
const selectedPointIds = automationType === 'pitch-bend'
|
||||||
|
? selectedPitchBendIds
|
||||||
|
: selectedControllerEventIds.filter(id => points.some(point => point.id === id));
|
||||||
|
const selectedPointIdSet = new Set(selectedPointIds);
|
||||||
|
const pointMap = new Map(points.map(point => [point.id, point]));
|
||||||
|
const parentTrack = activeRegion
|
||||||
|
? tracks.find(track => track.getId().toString() === activeRegion.getTrackId()) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
const selectedOption = PIANO_ROLL_AUTOMATION_OPTIONS.find(option => option.value === automationType);
|
const selectedOption = PIANO_ROLL_AUTOMATION_OPTIONS.find(option => option.value === automationType);
|
||||||
const laneLabel = selectedOption?.label ?? automationType;
|
const laneLabel = selectedOption?.label ?? automationType;
|
||||||
const interpolationMode = getAutomationInterpolationMode(automationType);
|
const interpolationMode = getAutomationInterpolationMode(automationType);
|
||||||
const totalBeats = maxBars * timeSignature.numerator;
|
const totalBeats = maxBars * timeSignature.numerator;
|
||||||
const totalWidth = 'calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width))';
|
const totalWidth = 'calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width))';
|
||||||
|
const minValue = automationType === 'pitch-bend' ? MIDI_PITCH_BEND_MIN : 0;
|
||||||
|
const maxValue = automationType === 'pitch-bend' ? MIDI_PITCH_BEND_MAX : 127;
|
||||||
|
|
||||||
const toY = (value: number): number => {
|
const toY = (value: number): number => {
|
||||||
const minValue = automationType === 'pitch-bend' ? MIDI_PITCH_BEND_MIN : 0;
|
|
||||||
const maxValue = automationType === 'pitch-bend' ? MIDI_PITCH_BEND_MAX : 127;
|
|
||||||
const usableHeight = laneHeight - LANE_PADDING_Y * 2;
|
const usableHeight = laneHeight - LANE_PADDING_Y * 2;
|
||||||
const normalized = (value - minValue) / (maxValue - minValue);
|
const normalized = (value - minValue) / (maxValue - minValue);
|
||||||
return laneHeight - LANE_PADDING_Y - normalized * usableHeight;
|
return laneHeight - LANE_PADDING_Y - normalized * usableHeight;
|
||||||
};
|
};
|
||||||
|
|
||||||
const svgPoints = points.map(point => {
|
const toValue = (y: number): number => {
|
||||||
|
const usableHeight = laneHeight - LANE_PADDING_Y * 2;
|
||||||
|
const clampedY = Math.min(laneHeight - LANE_PADDING_Y, Math.max(LANE_PADDING_Y, y));
|
||||||
|
const normalized = (laneHeight - LANE_PADDING_Y - clampedY) / usableHeight;
|
||||||
|
const rawValue = minValue + normalized * (maxValue - minValue);
|
||||||
|
return automationType === 'pitch-bend'
|
||||||
|
? clampMidiPitchBendValue(Math.round(rawValue))
|
||||||
|
: clampMidiControllerValue(Math.round(rawValue));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPointLabel = (value: number): string => (
|
||||||
|
automationType === 'pitch-bend' ? `${midiPitchBendToSignedValue(value)}` : `${value}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderedPoints = points.map(point => {
|
||||||
|
const preview = previewPoints[point.id];
|
||||||
|
const absoluteBeat = preview?.absoluteBeat ?? point.absoluteBeat;
|
||||||
|
const value = preview?.value ?? point.value;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...point,
|
...point,
|
||||||
x: point.absoluteBeat * beatWidth + keyWidth,
|
absoluteBeat,
|
||||||
y: toY(point.value),
|
value,
|
||||||
|
label: getPointLabel(value),
|
||||||
|
x: absoluteBeat * beatWidth + keyWidth,
|
||||||
|
y: toY(value),
|
||||||
|
isSelected: selectedPointIdSet.has(point.id),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
const renderedPointsSorted = [...renderedPoints].sort((leftPoint, rightPoint) => {
|
||||||
|
if (leftPoint.absoluteBeat !== rightPoint.absoluteBeat) {
|
||||||
|
return leftPoint.absoluteBeat - rightPoint.absoluteBeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
return leftPoint.id.localeCompare(rightPoint.id);
|
||||||
|
});
|
||||||
|
|
||||||
const polylinePoints = (() => {
|
const polylinePoints = (() => {
|
||||||
if (interpolationMode === 'step') {
|
if (interpolationMode === 'step' || renderedPointsSorted.length === 0) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (svgPoints.length === 0) {
|
const linePoints = [...renderedPointsSorted];
|
||||||
return '';
|
const lastPoint = linePoints[linePoints.length - 1];
|
||||||
}
|
linePoints.push({
|
||||||
|
|
||||||
const renderedPoints = [...svgPoints];
|
|
||||||
const lastPoint = renderedPoints[renderedPoints.length - 1];
|
|
||||||
renderedPoints.push({
|
|
||||||
...lastPoint,
|
...lastPoint,
|
||||||
id: `${lastPoint.id}-tail`,
|
id: `${lastPoint.id}-tail`,
|
||||||
x: beatWidth * totalBeats + keyWidth,
|
x: beatWidth * totalBeats + keyWidth,
|
||||||
});
|
});
|
||||||
|
|
||||||
return renderedPoints
|
return linePoints.map(point => `${point.x},${point.y}`).join(' ');
|
||||||
.map(point => `${point.x},${point.y}`)
|
|
||||||
.join(' ');
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const stepSegments = (() => {
|
const stepSegments = (() => {
|
||||||
if (interpolationMode !== 'step' || svgPoints.length === 0) {
|
if (interpolationMode !== 'step' || renderedPointsSorted.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return svgPoints.map((point, index) => ({
|
return renderedPointsSorted.map((point, index) => ({
|
||||||
id: `${point.id}-step`,
|
id: `${point.id}-step`,
|
||||||
x1: point.x,
|
x1: point.x,
|
||||||
y1: point.y,
|
y1: point.y,
|
||||||
x2: index < svgPoints.length - 1 ? svgPoints[index + 1].x : beatWidth * totalBeats + keyWidth,
|
x2: index < renderedPointsSorted.length - 1 ? renderedPointsSorted[index + 1].x : beatWidth * totalBeats + keyWidth,
|
||||||
}));
|
}));
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const commitSelection = async (nextSelectedIds: Set<string>) => {
|
||||||
|
if (!activeRegion || !parentTrack) return;
|
||||||
|
|
||||||
|
activeRegion.getNotes().forEach(note => note.deselect());
|
||||||
|
activeRegion.getPitchBends().forEach(pitchBend => {
|
||||||
|
if (nextSelectedIds.has(pitchBend.getId())) pitchBend.select();
|
||||||
|
else pitchBend.deselect();
|
||||||
|
});
|
||||||
|
activeRegion.getControllerEventsByType().forEach(events => {
|
||||||
|
events.forEach(controllerEvent => {
|
||||||
|
if (nextSelectedIds.has(controllerEvent.getId())) controllerEvent.select();
|
||||||
|
else controllerEvent.deselect();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedEvents: Array<KGMidiPitchBend | KGMidiControllerEvent> = [];
|
||||||
|
activeRegion.getPitchBends().forEach(pitchBend => {
|
||||||
|
if (nextSelectedIds.has(pitchBend.getId())) {
|
||||||
|
selectedEvents.push(pitchBend);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
activeRegion.getControllerEventsByType().forEach(events => {
|
||||||
|
events.forEach(controllerEvent => {
|
||||||
|
if (nextSelectedIds.has(controllerEvent.getId())) {
|
||||||
|
selectedEvents.push(controllerEvent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const core = KGCore.instance();
|
||||||
|
core.clearSelectedItems();
|
||||||
|
if (selectedEvents.length > 0) {
|
||||||
|
core.addSelectedItems(selectedEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateTrack(parentTrack);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeRegion || !parentTrack) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visiblePointIds = new Set(points.map(point => point.id));
|
||||||
|
const hiddenSelection = automationType === 'pitch-bend'
|
||||||
|
? selectedControllerEventIds.length > 0
|
||||||
|
: selectedPitchBendIds.length > 0 || selectedControllerEventIds.some(id => !visiblePointIds.has(id));
|
||||||
|
|
||||||
|
if (!hiddenSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeRegion.getPitchBends().forEach(pitchBend => {
|
||||||
|
if (!visiblePointIds.has(pitchBend.getId())) {
|
||||||
|
pitchBend.deselect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
activeRegion.getControllerEventsByType().forEach(events => {
|
||||||
|
events.forEach(controllerEvent => {
|
||||||
|
if (!visiblePointIds.has(controllerEvent.getId())) {
|
||||||
|
controllerEvent.deselect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const core = KGCore.instance();
|
||||||
|
const visibleSelectedItems = core.getSelectedItems().filter(item => visiblePointIds.has(item.getId()));
|
||||||
|
core.clearSelectedItems();
|
||||||
|
if (visibleSelectedItems.length > 0) {
|
||||||
|
core.addSelectedItems(visibleSelectedItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateTrack(parentTrack);
|
||||||
|
}, [activeRegion, automationType, parentTrack, points, selectedControllerEventIds, selectedPitchBendIds, updateTrack]);
|
||||||
|
|
||||||
|
const getLaneCoordinates = (clientX: number, clientY: number) => {
|
||||||
|
if (!laneRef.current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = laneRef.current.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: clientX - rect.left + horizontalScrollLeft,
|
||||||
|
y: clientY - rect.top,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPreviewFromDrag = (clientX: number, clientY: number): Record<string, PreviewPoint> => {
|
||||||
|
const dragState = dragStateRef.current;
|
||||||
|
const coordinates = getLaneCoordinates(clientX, clientY);
|
||||||
|
if (!dragState || !coordinates) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawAbsoluteBeat = (coordinates.x - keyWidth) / beatWidth;
|
||||||
|
const snappedAbsoluteBeat = KGPianoRollState.instance().getCurrentSnap() === 'NO SNAP'
|
||||||
|
? rawAbsoluteBeat
|
||||||
|
: getSnappedBeatPosition(rawAbsoluteBeat, KGPianoRollState.instance().getCurrentSnap());
|
||||||
|
const beatDelta = snappedAbsoluteBeat - dragState.originAbsoluteBeat;
|
||||||
|
const rawDeltaValue = toValue(coordinates.y) - dragState.originValue;
|
||||||
|
const valueDelta = Math.min(dragState.maxDeltaValue, Math.max(dragState.minDeltaValue, rawDeltaValue));
|
||||||
|
|
||||||
|
const nextPreview: Record<string, PreviewPoint> = {};
|
||||||
|
dragState.selectedPoints.forEach(point => {
|
||||||
|
nextPreview[point.id] = {
|
||||||
|
absoluteBeat: point.absoluteBeat + beatDelta,
|
||||||
|
value: point.value + valueDelta,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextPreview;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPreviewPoints = (nextPreview: Record<string, PreviewPoint>) => {
|
||||||
|
previewPointsRef.current = nextPreview;
|
||||||
|
setPreviewPoints(nextPreview);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragMove = (event: MouseEvent) => {
|
||||||
|
if (!dragStateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragState = dragStateRef.current;
|
||||||
|
const movedX = Math.abs(event.clientX - dragState.originClientX);
|
||||||
|
const movedY = Math.abs(event.clientY - dragState.originClientY);
|
||||||
|
if (movedX >= PIANO_ROLL_CONSTANTS.DRAG_THRESHOLD || movedY >= PIANO_ROLL_CONSTANTS.DRAG_THRESHOLD) {
|
||||||
|
dragState.hasMoved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPreviewPoints(buildPreviewFromDrag(event.clientX, event.clientY));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupDragListeners = () => {
|
||||||
|
document.removeEventListener('mousemove', handleDragMove);
|
||||||
|
document.removeEventListener('mouseup', handleDragEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = async () => {
|
||||||
|
const dragState = dragStateRef.current;
|
||||||
|
cleanupDragListeners();
|
||||||
|
|
||||||
|
if (!dragState || !activeRegion || !parentTrack) {
|
||||||
|
dragStateRef.current = null;
|
||||||
|
applyPreviewPoints({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingPreview = Object.keys(previewPointsRef.current).length > 0 ? previewPointsRef.current : buildPreviewFromDrag(
|
||||||
|
dragState.originClientX,
|
||||||
|
dragState.originClientY,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dragState.hasMoved && Object.keys(pendingPreview).length > 0) {
|
||||||
|
if (automationType === 'pitch-bend') {
|
||||||
|
const snapshots = dragState.selectedPoints.map(point => ({
|
||||||
|
pitchBendId: point.id,
|
||||||
|
beat: point.relativeBeat,
|
||||||
|
value: point.value,
|
||||||
|
}));
|
||||||
|
const updates = dragState.selectedPoints.map(point => {
|
||||||
|
const preview = pendingPreview[point.id];
|
||||||
|
return {
|
||||||
|
pitchBendId: point.id,
|
||||||
|
beat: preview.absoluteBeat - activeRegion.getStartFromBeat(),
|
||||||
|
value: preview.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
KGCore.instance().executeCommand(new UpdatePitchBendPropertiesCommand(activeRegion.getId(), snapshots, updates));
|
||||||
|
} else {
|
||||||
|
const controller = getControllerNumberForAutomationType(automationType);
|
||||||
|
if (controller !== null) {
|
||||||
|
const snapshots = dragState.selectedPoints.map(point => ({
|
||||||
|
controllerEventId: point.id,
|
||||||
|
controller,
|
||||||
|
beat: point.relativeBeat,
|
||||||
|
value: point.value,
|
||||||
|
}));
|
||||||
|
const updates = dragState.selectedPoints.map(point => {
|
||||||
|
const preview = pendingPreview[point.id];
|
||||||
|
return {
|
||||||
|
controllerEventId: point.id,
|
||||||
|
controller,
|
||||||
|
beat: preview.absoluteBeat - activeRegion.getStartFromBeat(),
|
||||||
|
value: preview.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
KGCore.instance().executeCommand(new UpdateControllerEventPropertiesCommand(activeRegion.getId(), snapshots, updates));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bumpAutomationRedrawVersion();
|
||||||
|
await updateTrack(parentTrack);
|
||||||
|
refreshProjectState();
|
||||||
|
preventBackgroundClearRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
dragStateRef.current = null;
|
||||||
|
applyPreviewPoints({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointMouseDown = async (pointId: string, event: React.MouseEvent<SVGCircleElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!activeRegion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const point = pointMap.get(pointId);
|
||||||
|
if (!point) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextSelection = new Set(selectedPointIdSet);
|
||||||
|
if (event.shiftKey) {
|
||||||
|
if (nextSelection.has(pointId)) {
|
||||||
|
nextSelection.delete(pointId);
|
||||||
|
await commitSelection(nextSelection);
|
||||||
|
preventBackgroundClearRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSelection.add(pointId);
|
||||||
|
} else if (!nextSelection.has(pointId)) {
|
||||||
|
nextSelection = new Set([pointId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await commitSelection(nextSelection);
|
||||||
|
|
||||||
|
const dragSelectedPoints = points.filter(candidate => nextSelection.has(candidate.id));
|
||||||
|
const minDeltaValue = dragSelectedPoints.reduce((currentMin, candidate) => (
|
||||||
|
Math.max(currentMin, minValue - candidate.value)
|
||||||
|
), Number.NEGATIVE_INFINITY);
|
||||||
|
const maxDeltaValue = dragSelectedPoints.reduce((currentMax, candidate) => (
|
||||||
|
Math.min(currentMax, maxValue - candidate.value)
|
||||||
|
), Number.POSITIVE_INFINITY);
|
||||||
|
|
||||||
|
dragStateRef.current = {
|
||||||
|
primaryPointId: pointId,
|
||||||
|
originAbsoluteBeat: point.absoluteBeat,
|
||||||
|
originValue: point.value,
|
||||||
|
originClientX: event.clientX,
|
||||||
|
originClientY: event.clientY,
|
||||||
|
selectedPoints: dragSelectedPoints,
|
||||||
|
minDeltaValue,
|
||||||
|
maxDeltaValue,
|
||||||
|
hasMoved: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
applyPreviewPoints({});
|
||||||
|
document.addEventListener('mousemove', handleDragMove);
|
||||||
|
document.addEventListener('mouseup', handleDragEnd);
|
||||||
|
preventBackgroundClearRef.current = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLassoMouseMove = (event: MouseEvent) => {
|
||||||
|
if (!isLassoSelectingRef.current || !laneRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = laneRef.current.getBoundingClientRect();
|
||||||
|
selectionBoxRef.current = {
|
||||||
|
...selectionBoxRef.current,
|
||||||
|
endX: event.clientX - rect.left,
|
||||||
|
endY: event.clientY - rect.top,
|
||||||
|
};
|
||||||
|
setSelectionBoxRenderTick(tick => tick + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupLassoListeners = () => {
|
||||||
|
document.removeEventListener('mousemove', handleLassoMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleLassoMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLassoMouseUp = async () => {
|
||||||
|
cleanupLassoListeners();
|
||||||
|
|
||||||
|
if (!isLassoSelectingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startX, startY, endX, endY } = selectionBoxRef.current;
|
||||||
|
const left = Math.min(startX, endX);
|
||||||
|
const right = Math.max(startX, endX);
|
||||||
|
const top = Math.min(startY, endY);
|
||||||
|
const bottom = Math.max(startY, endY);
|
||||||
|
const isClick = (right - left < PIANO_ROLL_CONSTANTS.DRAG_THRESHOLD)
|
||||||
|
&& (bottom - top < PIANO_ROLL_CONSTANTS.DRAG_THRESHOLD);
|
||||||
|
|
||||||
|
isLassoSelectingRef.current = false;
|
||||||
|
setSelectionBoxRenderTick(tick => tick + 1);
|
||||||
|
|
||||||
|
if (isClick) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextSelection = lassoShiftKeyRef.current ? new Set(selectedPointIdSet) : new Set<string>();
|
||||||
|
|
||||||
|
renderedPointsSorted.forEach(point => {
|
||||||
|
const isIntersecting = (
|
||||||
|
point.x + POINT_RADIUS >= left &&
|
||||||
|
point.x - POINT_RADIUS <= right &&
|
||||||
|
point.y + POINT_RADIUS >= top &&
|
||||||
|
point.y - POINT_RADIUS <= bottom
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isIntersecting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lassoShiftKeyRef.current && nextSelection.has(point.id)) {
|
||||||
|
nextSelection.delete(point.id);
|
||||||
|
} else {
|
||||||
|
nextSelection.add(point.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await commitSelection(nextSelection);
|
||||||
|
preventBackgroundClearRef.current = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackgroundMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isPointTarget(event.target) || dragStateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (KGPianoRollState.instance().getActiveTool() === 'pencil' || isModifierKeyPressed(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
selectionBoxRef.current = {
|
||||||
|
startX: event.clientX - rect.left,
|
||||||
|
startY: event.clientY - rect.top,
|
||||||
|
endX: event.clientX - rect.left,
|
||||||
|
endY: event.clientY - rect.top,
|
||||||
|
};
|
||||||
|
lassoShiftKeyRef.current = event.shiftKey;
|
||||||
|
isLassoSelectingRef.current = true;
|
||||||
|
setSelectionBoxRenderTick(tick => tick + 1);
|
||||||
|
document.addEventListener('mousemove', handleLassoMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleLassoMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreatePoint = async (clientX: number, clientY: number) => {
|
||||||
|
if (!activeRegion || !parentTrack) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinates = getLaneCoordinates(clientX, clientY);
|
||||||
|
if (!coordinates) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawAbsoluteBeat = (coordinates.x - keyWidth) / beatWidth;
|
||||||
|
const currentSnap = KGPianoRollState.instance().getCurrentSnap();
|
||||||
|
const absoluteBeat = currentSnap === 'NO SNAP'
|
||||||
|
? rawAbsoluteBeat
|
||||||
|
: getSnappedBeatPosition(rawAbsoluteBeat, currentSnap);
|
||||||
|
const relativeBeat = absoluteBeat - activeRegion.getStartFromBeat();
|
||||||
|
const value = toValue(coordinates.y);
|
||||||
|
|
||||||
|
if (automationType === 'pitch-bend') {
|
||||||
|
const command = new CreateMidiEventsCommand([], [{
|
||||||
|
regionId: activeRegion.getId(),
|
||||||
|
beat: relativeBeat,
|
||||||
|
value,
|
||||||
|
}]);
|
||||||
|
KGCore.instance().executeCommand(command);
|
||||||
|
const createdPitchBend = command.getCreatedPitchBends()[0]?.pitchBend;
|
||||||
|
if (createdPitchBend) {
|
||||||
|
await commitSelection(new Set([createdPitchBend.getId()]));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const controller = getControllerNumberForAutomationType(automationType);
|
||||||
|
if (controller === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new CreateMidiEventsCommand([], [], [{
|
||||||
|
regionId: activeRegion.getId(),
|
||||||
|
controller,
|
||||||
|
beat: relativeBeat,
|
||||||
|
value,
|
||||||
|
}]);
|
||||||
|
KGCore.instance().executeCommand(command);
|
||||||
|
const createdControllerEvent = command.getCreatedControllerEvents()[0]?.controllerEvent;
|
||||||
|
if (createdControllerEvent) {
|
||||||
|
await commitSelection(new Set([createdControllerEvent.getId()]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bumpAutomationRedrawVersion();
|
||||||
|
await updateTrack(parentTrack);
|
||||||
|
refreshProjectState();
|
||||||
|
preventBackgroundClearRef.current = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackgroundClick = async (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isPointTarget(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preventBackgroundClearRef.current) {
|
||||||
|
preventBackgroundClearRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (KGPianoRollState.instance().getActiveTool() === 'pencil' || isModifierKeyPressed(event)) {
|
||||||
|
if (event.detail > 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleCreatePoint(event.clientX, event.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLassoSelectingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await commitSelection(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackgroundDoubleClick = async (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isPointTarget(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleCreatePoint(event.clientX, event.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
cleanupDragListeners();
|
||||||
|
cleanupLassoListeners();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectionBoxStyle = {
|
||||||
|
left: `${Math.min(selectionBoxRef.current.startX, selectionBoxRef.current.endX)}px`,
|
||||||
|
top: `${Math.min(selectionBoxRef.current.startY, selectionBoxRef.current.endY)}px`,
|
||||||
|
width: `${Math.abs(selectionBoxRef.current.endX - selectionBoxRef.current.startX)}px`,
|
||||||
|
height: `${Math.abs(selectionBoxRef.current.endY - selectionBoxRef.current.startY)}px`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPointTarget = (target: EventTarget | null): boolean => (
|
||||||
|
target instanceof Element && target.closest('.piano-roll-automation-point') !== null
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="piano-roll-automation-lane"
|
className="piano-roll-automation-lane"
|
||||||
@@ -188,8 +777,11 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
|||||||
<div className="piano-roll-automation-gutter" />
|
<div className="piano-roll-automation-gutter" />
|
||||||
<div className="piano-roll-automation-lane-label">{laneLabel}</div>
|
<div className="piano-roll-automation-lane-label">{laneLabel}</div>
|
||||||
<div
|
<div
|
||||||
className="piano-roll-automation-scroll-layer"
|
className={`piano-roll-automation-scroll-layer ${isModifierPressed ? 'pencil-cursor' : ''}`}
|
||||||
style={{ transform: `translateX(-${horizontalScrollLeft}px)` }}
|
style={{ transform: `translateX(-${horizontalScrollLeft}px)` }}
|
||||||
|
onMouseDown={handleBackgroundMouseDown}
|
||||||
|
onClick={(event) => { void handleBackgroundClick(event); }}
|
||||||
|
onDoubleClick={(event) => { void handleBackgroundDoubleClick(event); }}
|
||||||
>
|
>
|
||||||
<div className="piano-roll-automation-grid" />
|
<div className="piano-roll-automation-grid" />
|
||||||
<svg
|
<svg
|
||||||
@@ -199,7 +791,7 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
|||||||
viewBox={`0 0 ${beatWidth * totalBeats + keyWidth} ${laneHeight}`}
|
viewBox={`0 0 ${beatWidth * totalBeats + keyWidth} ${laneHeight}`}
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
>
|
>
|
||||||
{interpolationMode === 'linear' && points.length > 0 && (
|
{interpolationMode === 'linear' && renderedPoints.length > 0 && (
|
||||||
<polyline
|
<polyline
|
||||||
className="piano-roll-automation-line"
|
className="piano-roll-automation-line"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -220,25 +812,26 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
|||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{svgPoints.map(point => {
|
{renderedPoints.map(point => {
|
||||||
const labelY = Math.max(14, Math.min(laneHeight - 6, point.y - 10));
|
const labelY = Math.max(14, Math.min(laneHeight - 6, point.y - 10));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={point.id}>
|
<g key={point.id}>
|
||||||
<circle
|
<circle
|
||||||
className="piano-roll-automation-point"
|
className={`piano-roll-automation-point${point.isSelected ? ' selected' : ''}`}
|
||||||
cx={point.x}
|
cx={point.x}
|
||||||
cy={point.y}
|
cy={point.y}
|
||||||
r="5"
|
r={POINT_RADIUS}
|
||||||
fill={AUTOMATION_COLOR}
|
fill={point.isSelected ? SELECTED_POINT_COLOR : AUTOMATION_COLOR}
|
||||||
stroke="#1d2428"
|
stroke={point.isSelected ? SELECTED_POINT_COLOR : '#1d2428'}
|
||||||
strokeWidth="2"
|
strokeWidth={point.isSelected ? 3 : 2}
|
||||||
|
onMouseDown={(event) => { void handlePointMouseDown(point.id, event); }}
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
className="piano-roll-automation-value"
|
className="piano-roll-automation-value"
|
||||||
x={point.x + 6}
|
x={point.x + 6}
|
||||||
y={labelY}
|
y={labelY}
|
||||||
fill={AUTOMATION_COLOR}
|
fill={point.isSelected ? SELECTED_POINT_COLOR : AUTOMATION_COLOR}
|
||||||
>
|
>
|
||||||
{point.label}
|
{point.label}
|
||||||
</text>
|
</text>
|
||||||
@@ -246,8 +839,15 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
|
{isLassoSelectingRef.current && (
|
||||||
|
<div
|
||||||
|
key={selectionBoxRenderTick}
|
||||||
|
className="piano-roll-automation-selection-box"
|
||||||
|
style={selectionBoxStyle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{points.length === 0 && (
|
{renderedPointsSorted.length === 0 && (
|
||||||
<div className="piano-roll-automation-empty-state">
|
<div className="piano-roll-automation-empty-state">
|
||||||
No {laneLabel} events in this region
|
No {laneLabel} events in this region
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { DEBUG_MODE } from '../../constants';
|
||||||
|
|
||||||
|
export function getSnapStep(currentSnap: string): number | null {
|
||||||
|
if (currentSnap === 'NO SNAP') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const denominator = parseInt(currentSnap.split('/')[1], 10);
|
||||||
|
if (Number.isNaN(denominator)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 4 / denominator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSnappedBeatPosition(
|
||||||
|
beatPosition: number,
|
||||||
|
currentSnap: string,
|
||||||
|
useFloorSnapping: boolean = false,
|
||||||
|
): number {
|
||||||
|
const snapStep = getSnapStep(currentSnap);
|
||||||
|
if (snapStep === null) {
|
||||||
|
return beatPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snappedPosition = useFloorSnapping
|
||||||
|
? Math.floor(beatPosition / snapStep) * snapStep
|
||||||
|
: Math.round(beatPosition / snapStep) * snapStep;
|
||||||
|
|
||||||
|
if (DEBUG_MODE.PIANO_ROLL) {
|
||||||
|
console.log(
|
||||||
|
`Snapping (${useFloorSnapping ? 'floor' : 'round'}): ${beatPosition} -> ${snappedPosition} (snap: ${currentSnap}, step: ${snapStep})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return snappedPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSnappedLength(length: number, currentSnap: string, minimumLength: number): number {
|
||||||
|
const snapStep = getSnapStep(currentSnap);
|
||||||
|
if (snapStep === null) {
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snappedLength = Math.round(length / snapStep) * snapStep;
|
||||||
|
const finalLength = Math.max(minimumLength, snappedLength);
|
||||||
|
|
||||||
|
if (DEBUG_MODE.PIANO_ROLL) {
|
||||||
|
console.log(`Length snapping: ${length} -> ${finalLength} (snap: ${currentSnap}, step: ${snapStep})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalLength;
|
||||||
|
}
|
||||||
@@ -11,6 +11,11 @@ import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
|||||||
import { KGAudioInterface } from '../core/audio-interface/KGAudioInterface';
|
import { KGAudioInterface } from '../core/audio-interface/KGAudioInterface';
|
||||||
import { CreateNoteCommand, DeleteNotesCommand, ResizeNotesCommand, MoveNotesCommand } from '../core/commands';
|
import { CreateNoteCommand, DeleteNotesCommand, ResizeNotesCommand, MoveNotesCommand } from '../core/commands';
|
||||||
import { CreateNotesCommand } from '../core/commands/note/CreateNotesCommand';
|
import { CreateNotesCommand } from '../core/commands/note/CreateNotesCommand';
|
||||||
|
import { DeleteMidiEventsCommand } from '../core/commands/note/DeleteMidiEventsCommand';
|
||||||
|
import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
|
||||||
|
import { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent';
|
||||||
|
import { getSnappedBeatPosition, getSnappedLength } from '../components/piano-roll/pianoRollSnap';
|
||||||
|
import { useProjectStore } from '../stores/projectStore';
|
||||||
|
|
||||||
interface UseNoteOperationsProps {
|
interface UseNoteOperationsProps {
|
||||||
activeRegion: KGMidiRegion | null;
|
activeRegion: KGMidiRegion | null;
|
||||||
@@ -53,79 +58,6 @@ export const useNoteOperations = ({
|
|||||||
// Get KGCore instance for accessing selected items
|
// Get KGCore instance for accessing selected items
|
||||||
const core = KGCore.instance();
|
const core = KGCore.instance();
|
||||||
|
|
||||||
// Utility function to calculate snapped beat position
|
|
||||||
const getSnappedBeatPosition = (beatPosition: number, timeSignature: { numerator: number; denominator: number }, useFloorSnapping: boolean = false): number => {
|
|
||||||
const currentSnap = KGPianoRollState.instance().getCurrentSnap();
|
|
||||||
|
|
||||||
// If no snapping is enabled, return the original position
|
|
||||||
if (currentSnap === 'NO SNAP') {
|
|
||||||
return beatPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the snap value (e.g., "1/4", "1/8", "1/16", "1/32")
|
|
||||||
const denominator = parseInt(currentSnap.split('/')[1]);
|
|
||||||
if (isNaN(denominator)) {
|
|
||||||
return beatPosition; // Fallback to no snapping if invalid
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the snap step in beats
|
|
||||||
// In a 4/4 time signature, a quarter note (1/4) is 1 beat
|
|
||||||
// In a 6/8 time signature, an eighth note (1/8) is 1 beat
|
|
||||||
// const { numerator: timeSigNumerator, denominator: timeSigDenominator } = timeSignature;
|
|
||||||
|
|
||||||
// Calculate beats per whole note based on time signature
|
|
||||||
// const beatsPerWholeNote = timeSigNumerator * (4 / timeSigDenominator);
|
|
||||||
|
|
||||||
// Calculate the snap step in beats
|
|
||||||
// snapStep should ALWAYS be 4 / denominator regardless of time signature
|
|
||||||
const snapStep = 4 / denominator;
|
|
||||||
|
|
||||||
// Choose snapping method: floor for note creation, round for dragging
|
|
||||||
const snappedPosition = useFloorSnapping
|
|
||||||
? Math.floor(beatPosition / snapStep) * snapStep
|
|
||||||
: Math.round(beatPosition / snapStep) * snapStep;
|
|
||||||
|
|
||||||
if (DEBUG_MODE.PIANO_ROLL) {
|
|
||||||
console.log(`Snapping (${useFloorSnapping ? 'floor' : 'round'}): ${beatPosition} -> ${snappedPosition} (snap: ${currentSnap}, step: ${snapStep})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return snappedPosition;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Utility function to calculate snapped note length
|
|
||||||
const getSnappedLength = (length: number, timeSignature: { numerator: number; denominator: number }): number => {
|
|
||||||
const currentSnap = KGPianoRollState.instance().getCurrentSnap();
|
|
||||||
|
|
||||||
// If no snapping is enabled, return the original length
|
|
||||||
if (currentSnap === 'NO SNAP') {
|
|
||||||
return length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the snap value (e.g., "1/4", "1/8", "1/16", "1/32")
|
|
||||||
const denominator = parseInt(currentSnap.split('/')[1]);
|
|
||||||
if (isNaN(denominator)) {
|
|
||||||
return length; // Fallback to no snapping if invalid
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the snap step in beats using same formula as position snapping
|
|
||||||
// const { numerator, denominator: timeSigDenominator } = timeSignature;
|
|
||||||
// const beatsPerWholeNote = numerator * (4 / timeSigDenominator);
|
|
||||||
// snapStep should ALWAYS be 4 / denominator regardless of time signature
|
|
||||||
const snapStep = 4 / denominator;
|
|
||||||
|
|
||||||
// Snap length to nearest multiple of snap step
|
|
||||||
const snappedLength = Math.round(length / snapStep) * snapStep;
|
|
||||||
|
|
||||||
// Ensure minimum note length is respected
|
|
||||||
const finalLength = Math.max(PIANO_ROLL_CONSTANTS.MIN_NOTE_LENGTH, snappedLength);
|
|
||||||
|
|
||||||
if (DEBUG_MODE.PIANO_ROLL) {
|
|
||||||
console.log(`Length snapping: ${length} -> ${finalLength} (snap: ${currentSnap}, step: ${snapStep})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalLength;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 = () => {
|
||||||
if (!activeRegion) return false;
|
if (!activeRegion) return false;
|
||||||
@@ -136,22 +68,36 @@ export const useNoteOperations = ({
|
|||||||
item instanceof KGMidiNote &&
|
item instanceof KGMidiNote &&
|
||||||
activeRegion.getNotes().some(note => note.getId() === item.getId())
|
activeRegion.getNotes().some(note => note.getId() === item.getId())
|
||||||
) as KGMidiNote[];
|
) as KGMidiNote[];
|
||||||
|
const selectedPitchBends = selectedItems.filter(item =>
|
||||||
|
item instanceof KGMidiPitchBend &&
|
||||||
|
activeRegion.getPitchBends().some(pitchBend => pitchBend.getId() === item.getId())
|
||||||
|
) as KGMidiPitchBend[];
|
||||||
|
const selectedControllerEvents = selectedItems.filter(item =>
|
||||||
|
item instanceof KGMidiControllerEvent &&
|
||||||
|
activeRegion.getAllControllerEventsFlattened().some(({ event }) => event.getId() === item.getId())
|
||||||
|
) as KGMidiControllerEvent[];
|
||||||
|
|
||||||
if (selectedNotes.length === 0) {
|
if (selectedNotes.length === 0 && selectedPitchBends.length === 0 && selectedControllerEvents.length === 0) {
|
||||||
if (DEBUG_MODE.PIANO_ROLL) {
|
if (DEBUG_MODE.PIANO_ROLL) {
|
||||||
console.log('No notes selected for deletion');
|
console.log('No MIDI events selected for deletion');
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DEBUG_MODE.PIANO_ROLL) {
|
if (DEBUG_MODE.PIANO_ROLL) {
|
||||||
console.log(`Deleting ${selectedNotes.length} selected notes using command`);
|
console.log(`Deleting MIDI events: notes=${selectedNotes.length}, pitchBends=${selectedPitchBends.length}, controllers=${selectedControllerEvents.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and execute the delete notes command
|
|
||||||
const noteIds = selectedNotes.map(note => note.getId());
|
const noteIds = selectedNotes.map(note => note.getId());
|
||||||
const command = new DeleteNotesCommand(noteIds);
|
const pitchBendIds = selectedPitchBends.map(pitchBend => pitchBend.getId());
|
||||||
|
const controllerEventIds = selectedControllerEvents.map(controllerEvent => controllerEvent.getId());
|
||||||
|
const command = pitchBendIds.length > 0 || controllerEventIds.length > 0
|
||||||
|
? new DeleteMidiEventsCommand(noteIds, pitchBendIds, controllerEventIds)
|
||||||
|
: new DeleteNotesCommand(noteIds);
|
||||||
core.executeCommand(command);
|
core.executeCommand(command);
|
||||||
|
if (pitchBendIds.length > 0 || controllerEventIds.length > 0) {
|
||||||
|
useProjectStore.getState().bumpAutomationRedrawVersion();
|
||||||
|
}
|
||||||
|
|
||||||
// Find the track that contains this region and update it for UI sync
|
// Find the track that contains this region and update it for UI sync
|
||||||
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
|
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
|
||||||
@@ -163,7 +109,7 @@ export const useNoteOperations = ({
|
|||||||
setNoteUpdateCounter(prev => prev + 1);
|
setNoteUpdateCounter(prev => prev + 1);
|
||||||
|
|
||||||
if (DEBUG_MODE.PIANO_ROLL) {
|
if (DEBUG_MODE.PIANO_ROLL) {
|
||||||
console.log(`Deleted ${selectedNotes.length} notes using DeleteNotesCommand`);
|
console.log('Deleted selected MIDI events using command');
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -189,7 +135,7 @@ export const useNoteOperations = ({
|
|||||||
const currentSnap = KGPianoRollState.instance().getCurrentSnap();
|
const currentSnap = KGPianoRollState.instance().getCurrentSnap();
|
||||||
const beatNumber = currentSnap === 'NO SNAP'
|
const beatNumber = currentSnap === 'NO SNAP'
|
||||||
? Math.floor(rawBeatNumber) // Snap to 1-beat grid when no snapping is selected
|
? Math.floor(rawBeatNumber) // Snap to 1-beat grid when no snapping is selected
|
||||||
: getSnappedBeatPosition(rawBeatNumber, timeSignature, true); // Use floor snapping for note creation
|
: getSnappedBeatPosition(rawBeatNumber, currentSnap, true); // Use floor snapping for note creation
|
||||||
|
|
||||||
// Calculate the pitch (MIDI note number)
|
// Calculate the pitch (MIDI note number)
|
||||||
// The piano roll is drawn from bottom to top, with higher notes at the top
|
// The piano roll is drawn from bottom to top, with higher notes at the top
|
||||||
@@ -418,7 +364,11 @@ export const useNoteOperations = ({
|
|||||||
|
|
||||||
// Apply length snapping to the visual feedback
|
// Apply length snapping to the visual feedback
|
||||||
const newLengthInBeats = newWidth / beatWidth;
|
const newLengthInBeats = newWidth / beatWidth;
|
||||||
const snappedLengthInBeats = getSnappedLength(newLengthInBeats, timeSignature);
|
const snappedLengthInBeats = getSnappedLength(
|
||||||
|
newLengthInBeats,
|
||||||
|
KGPianoRollState.instance().getCurrentSnap(),
|
||||||
|
PIANO_ROLL_CONSTANTS.MIN_NOTE_LENGTH,
|
||||||
|
);
|
||||||
const snappedWidth = snappedLengthInBeats * beatWidth;
|
const snappedWidth = snappedLengthInBeats * beatWidth;
|
||||||
|
|
||||||
// Adjust position if necessary for start resize to maintain snapped length
|
// Adjust position if necessary for start resize to maintain snapped length
|
||||||
@@ -507,7 +457,11 @@ export const useNoteOperations = ({
|
|||||||
|
|
||||||
// Apply length snapping to the final resize commit
|
// Apply length snapping to the final resize commit
|
||||||
const originalLength = newEndBeat - newStartBeat;
|
const originalLength = newEndBeat - newStartBeat;
|
||||||
const snappedLength = getSnappedLength(originalLength, timeSignature);
|
const snappedLength = getSnappedLength(
|
||||||
|
originalLength,
|
||||||
|
KGPianoRollState.instance().getCurrentSnap(),
|
||||||
|
PIANO_ROLL_CONSTANTS.MIN_NOTE_LENGTH,
|
||||||
|
);
|
||||||
|
|
||||||
// Adjust the note bounds to use the snapped length
|
// Adjust the note bounds to use the snapped length
|
||||||
if (resizeEdge === 'end') {
|
if (resizeEdge === 'end') {
|
||||||
@@ -691,7 +645,10 @@ export const useNoteOperations = ({
|
|||||||
// Apply horizontal snapping based on current snap setting
|
// Apply horizontal snapping based on current snap setting
|
||||||
const regionStartBeat = activeRegion.getStartFromBeat();
|
const regionStartBeat = activeRegion.getStartFromBeat();
|
||||||
const rawBeatPosition = (rawNewLeft / beatWidth) - regionStartBeat;
|
const rawBeatPosition = (rawNewLeft / beatWidth) - regionStartBeat;
|
||||||
const snappedBeatPosition = getSnappedBeatPosition(rawBeatPosition, timeSignature);
|
const snappedBeatPosition = getSnappedBeatPosition(
|
||||||
|
rawBeatPosition,
|
||||||
|
KGPianoRollState.instance().getCurrentSnap(),
|
||||||
|
);
|
||||||
const snappedLeft = (snappedBeatPosition + regionStartBeat) * beatWidth;
|
const snappedLeft = (snappedBeatPosition + regionStartBeat) * beatWidth;
|
||||||
|
|
||||||
// Use snapped horizontal position, but keep raw vertical position
|
// Use snapped horizontal position, but keep raw vertical position
|
||||||
|
|||||||
@@ -42,6 +42,19 @@ export const useNoteSelection = ({
|
|||||||
// Get KGCore instance
|
// Get KGCore instance
|
||||||
const core = KGCore.instance();
|
const core = KGCore.instance();
|
||||||
|
|
||||||
|
const deselectAutomationEvents = () => {
|
||||||
|
if (!activeRegion) return;
|
||||||
|
|
||||||
|
activeRegion.getPitchBends().forEach(pitchBend => {
|
||||||
|
pitchBend.deselect();
|
||||||
|
});
|
||||||
|
activeRegion.getControllerEventsByType().forEach(events => {
|
||||||
|
events.forEach(controllerEvent => {
|
||||||
|
controllerEvent.deselect();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Handle note click for selection
|
// Handle note click for selection
|
||||||
const handleNoteClick = (noteId: string, e: React.MouseEvent) => {
|
const handleNoteClick = (noteId: string, e: React.MouseEvent) => {
|
||||||
if (!activeRegion) return;
|
if (!activeRegion) return;
|
||||||
@@ -59,6 +72,8 @@ export const useNoteSelection = ({
|
|||||||
console.log(`NOTE CLICKED: noteId=${noteId}, shift key: ${e.shiftKey}`);
|
console.log(`NOTE CLICKED: noteId=${noteId}, shift key: ${e.shiftKey}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deselectAutomationEvents();
|
||||||
|
|
||||||
// Create a new Set for selected note IDs
|
// Create a new Set for selected note IDs
|
||||||
let newSelectedNoteIds: Set<string>;
|
let newSelectedNoteIds: Set<string>;
|
||||||
|
|
||||||
@@ -110,6 +125,7 @@ export const useNoteSelection = ({
|
|||||||
|
|
||||||
// Clear KGCore selection using store method
|
// Clear KGCore selection using store method
|
||||||
clearAllSelections();
|
clearAllSelections();
|
||||||
|
deselectAutomationEvents();
|
||||||
|
|
||||||
// Create new selection with only this note
|
// Create new selection with only this note
|
||||||
newSelectedNoteIds = new Set([noteId]);
|
newSelectedNoteIds = new Set([noteId]);
|
||||||
@@ -192,6 +208,8 @@ export const useNoteSelection = ({
|
|||||||
console.log('Starting box selection');
|
console.log('Starting box selection');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deselectAutomationEvents();
|
||||||
|
|
||||||
// Store shift key state for box selection
|
// Store shift key state for box selection
|
||||||
isShiftKeyPressedRef.current = e.shiftKey;
|
isShiftKeyPressedRef.current = e.shiftKey;
|
||||||
|
|
||||||
@@ -295,6 +313,7 @@ export const useNoteSelection = ({
|
|||||||
|
|
||||||
// Clear KGCore selection using store method
|
// Clear KGCore selection using store method
|
||||||
clearAllSelections();
|
clearAllSelections();
|
||||||
|
deselectAutomationEvents();
|
||||||
|
|
||||||
// Start with empty selection
|
// Start with empty selection
|
||||||
newSelectedNoteIds = new Set<string>();
|
newSelectedNoteIds = new Set<string>();
|
||||||
|
|||||||
Reference in New Issue
Block a user