diff --git a/src/components/piano-roll/PianoGridHeader.tsx b/src/components/piano-roll/PianoGridHeader.tsx index 441b8df..180abfb 100644 --- a/src/components/piano-roll/PianoGridHeader.tsx +++ b/src/components/piano-roll/PianoGridHeader.tsx @@ -3,6 +3,7 @@ import { KGCore } from '../../core/KGCore'; import { KGPianoRollState } from '../../core/state/KGPianoRollState'; import { useProjectStore } from '../../stores/projectStore'; import { DEBUG_MODE } from '../../constants'; +import { getSnappedBeatPosition } from './pianoRollSnap'; interface PianoGridHeaderProps { maxBars: number; @@ -20,35 +21,6 @@ const PianoGridHeader: React.FC = ({ const isDraggingRef = useRef(false); const headerElementRef = useRef(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 const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => { if (!headerElementRef.current) return null; @@ -77,7 +49,7 @@ const PianoGridHeader: React.FC = ({ const rawBeatPosition = adjustedX / beatWidth; // Apply quantization if enabled - return getSnappedBeatPosition(rawBeatPosition); + return getSnappedBeatPosition(rawBeatPosition, KGPianoRollState.instance().getCurrentSnap()); }, []); // Handle mouse down to start dragging @@ -178,4 +150,4 @@ const PianoGridHeader: React.FC = ({ ); }; -export default PianoGridHeader; \ No newline at end of file +export default PianoGridHeader; diff --git a/src/components/piano-roll/PianoRoll.css b/src/components/piano-roll/PianoRoll.css index 6ec135b..d5664d9 100644 --- a/src/components/piano-roll/PianoRoll.css +++ b/src/components/piano-roll/PianoRoll.css @@ -457,6 +457,10 @@ cursor: crosshair; } +.piano-roll-automation-scroll-layer.pencil-cursor { + cursor: crosshair; +} + /* Piano Grid Cursor Highlights */ .piano-grid-pitch-highlight { position: absolute; @@ -570,10 +574,20 @@ position: absolute; inset: 0; overflow: visible; + z-index: 1; } .piano-roll-automation-line { 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 { @@ -585,6 +599,14 @@ 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 { position: absolute; inset: 0; diff --git a/src/components/piano-roll/PianoRollAutomationLane.test.tsx b/src/components/piano-roll/PianoRollAutomationLane.test.tsx index d9fe49c..f124089 100644 --- a/src/components/piano-roll/PianoRollAutomationLane.test.tsx +++ b/src/components/piano-roll/PianoRollAutomationLane.test.tsx @@ -1,25 +1,82 @@ import React from 'react'; -import { describe, expect, it } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +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[], + 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[], + 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 { KGMidiControllerEvent } from '../../core/midi/KGMidiControllerEvent'; -import { - createMockMidiControllerEvent, - createMockMidiPitchBend, - createMockMidiRegion, -} from '../../test/utils/mock-data'; +import { getControllerNumberForAutomationType } from './pianoRollAutomation'; 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({ + trackId: '1', + trackIndex: 0, startFromBeat: 4, pitchBends: [ createMockMidiPitchBend({ id: 'bend-1', beat: 0.5, value: 8192 }), 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( { expect(screen.getByLabelText('Pitch Bend automation lane')).toBeInTheDocument(); expect(screen.getByText('0')).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', () => { - const controllerEventsByType: KGMidiControllerEvent[][] = Array.from({ length: 128 }, () => []); - controllerEventsByType[7] = [ + it('creates a snapped pitch bend on double click and selects it', async () => { + const region = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + trackIndex: 0, + startFromBeat: 4, + pitchBends: [], + }); + storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })]; + coreMock.currentProjectTracks = storeState.tracks; + + const { container } = render( + + ); + + 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( + + ); + + 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[]); + controllerEventsByType[controller] = [ createMockMidiControllerEvent({ id: 'cc7-1', beat: 0.25, value: 57 }), createMockMidiControllerEvent({ id: 'cc7-2', beat: 2, value: 82 }), ]; - const region = createMockMidiRegion({ + trackId: '1', + trackIndex: 0, startFromBeat: 0, controllerEventsByType, }); + storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })]; + coreMock.currentProjectTracks = storeState.tracks; + storeState.selectedControllerEventIds = ['cc7-1']; - render( + const { container } = render( { /> ); - expect(screen.getByLabelText('CC7 automation lane')).toBeInTheDocument(); - expect(screen.getByText('57')).toBeInTheDocument(); - expect(screen.getByText('82')).toBeInTheDocument(); - }); + const points = container.querySelectorAll('.piano-roll-automation-point'); + fireEvent.mouseDown(points[1], { shiftKey: true, clientX: 0, clientY: 0 }); + fireEvent.mouseUp(document); - it('shows an empty lane shell when the selected automation type has no events', () => { - const region = createMockMidiRegion(); - - render( - - ); - - expect(screen.getByText('No CC64 events in this region')).toBeInTheDocument(); + await waitFor(() => { + expect(coreMock.addSelectedItems).toHaveBeenCalled(); + const selectedItems = coreMock.addSelectedItems.mock.calls.at(-1)?.[0] ?? []; + expect(selectedItems).toHaveLength(2); + }); }); 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[]); controllerEventsByType[64] = [ createMockMidiControllerEvent({ id: 'cc64-1', beat: 0.5, value: 127 }), createMockMidiControllerEvent({ id: 'cc64-2', beat: 2, value: 0 }), ]; const region = createMockMidiRegion({ + trackId: '1', + trackIndex: 0, startFromBeat: 0, controllerEventsByType, }); + storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })]; + coreMock.currentProjectTracks = storeState.tracks; const { container } = render( { expect(container.querySelector('polyline.piano-roll-automation-line')).toBeNull(); 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( + + ); + + 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( + + ); + + 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); + }); + }); }); diff --git a/src/components/piano-roll/PianoRollAutomationLane.tsx b/src/components/piano-roll/PianoRollAutomationLane.tsx index ee4ef85..32b4427 100644 --- a/src/components/piano-roll/PianoRollAutomationLane.tsx +++ b/src/components/piano-roll/PianoRollAutomationLane.tsx @@ -1,10 +1,23 @@ 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 { MIDI_PITCH_BEND_MAX, MIDI_PITCH_BEND_MIN, + clampMidiControllerValue, + clampMidiPitchBendValue, midiPitchBendToSignedValue, } from '../../util/midiUtil'; +import { isModifierKeyPressed } from '../../util/osUtil'; +import { useProjectStore } from '../../stores/projectStore'; +import { PIANO_ROLL_CONSTANTS } from '../../constants'; +import { getSnappedBeatPosition } from './pianoRollSnap'; import { getAutomationInterpolationMode, getControllerNumberForAutomationType, @@ -14,6 +27,9 @@ import { interface AutomationPoint { id: string; + kind: 'pitch-bend' | 'controller'; + controller: number | null; + relativeBeat: number; absoluteBeat: number; value: number; label: string; @@ -30,9 +46,23 @@ interface PianoRollAutomationLaneProps { 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 SELECTED_POINT_COLOR = '#FFFFFF'; const LANE_PADDING_Y = 16; const MIN_LANE_HEIGHT = 160; +const POINT_RADIUS = 5; const PianoRollAutomationLane: React.FC = ({ activeRegion, @@ -44,10 +74,38 @@ const PianoRollAutomationLane: React.FC = ({ onHorizontalWheel, }) => { const laneRef = useRef(null); + const isLassoSelectingRef = useRef(false); + const lassoShiftKeyRef = useRef(false); + const [isModifierPressed, setIsModifierPressed] = useState(false); + const selectionBoxRef = useRef({ 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>({}); + const previewPointsRef = useRef>({}); const [laneHeight, setLaneHeight] = useState(MIN_LANE_HEIGHT); 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 { + tracks, + updateTrack, + refreshProjectState, + bumpAutomationRedrawVersion, + selectedPitchBendIds, + selectedControllerEventIds, + } = useProjectStore(); + useEffect(() => { const element = laneRef.current; if (!element) { @@ -92,6 +150,50 @@ const PianoRollAutomationLane: React.FC = ({ }; }, [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(() => { if (!activeRegion) { return []; @@ -101,6 +203,9 @@ const PianoRollAutomationLane: React.FC = ({ if (automationType === 'pitch-bend') { return activeRegion.getPitchBends().map((pitchBend) => ({ id: pitchBend.getId(), + kind: 'pitch-bend' as const, + controller: null, + relativeBeat: pitchBend.getBeat(), absoluteBeat: regionStartBeat + pitchBend.getBeat(), value: pitchBend.getValue(), label: `${midiPitchBendToSignedValue(pitchBend.getValue())}`, @@ -114,69 +219,553 @@ const PianoRollAutomationLane: React.FC = ({ return activeRegion.getControllerEvents(controller).map((event) => ({ id: event.getId(), + kind: 'controller' as const, + controller, + relativeBeat: event.getBeat(), absoluteBeat: regionStartBeat + event.getBeat(), value: event.getValue(), label: `${event.getValue()}`, })); }, [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 laneLabel = selectedOption?.label ?? automationType; const interpolationMode = getAutomationInterpolationMode(automationType); const totalBeats = maxBars * timeSignature.numerator; 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 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 normalized = (value - minValue) / (maxValue - minValue); 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 { ...point, - x: point.absoluteBeat * beatWidth + keyWidth, - y: toY(point.value), + absoluteBeat, + 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 = (() => { - if (interpolationMode === 'step') { + if (interpolationMode === 'step' || renderedPointsSorted.length === 0) { return ''; } - if (svgPoints.length === 0) { - return ''; - } - - const renderedPoints = [...svgPoints]; - const lastPoint = renderedPoints[renderedPoints.length - 1]; - renderedPoints.push({ + const linePoints = [...renderedPointsSorted]; + const lastPoint = linePoints[linePoints.length - 1]; + linePoints.push({ ...lastPoint, id: `${lastPoint.id}-tail`, x: beatWidth * totalBeats + keyWidth, }); - return renderedPoints - .map(point => `${point.x},${point.y}`) - .join(' '); + return linePoints.map(point => `${point.x},${point.y}`).join(' '); })(); const stepSegments = (() => { - if (interpolationMode !== 'step' || svgPoints.length === 0) { + if (interpolationMode !== 'step' || renderedPointsSorted.length === 0) { return []; } - return svgPoints.map((point, index) => ({ + return renderedPointsSorted.map((point, index) => ({ id: `${point.id}-step`, x1: point.x, 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) => { + 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 = []; + 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 => { + 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 = {}; + dragState.selectedPoints.forEach(point => { + nextPreview[point.id] = { + absoluteBeat: point.absoluteBeat + beatDelta, + value: point.value + valueDelta, + }; + }); + + return nextPreview; + }; + + const applyPreviewPoints = (nextPreview: Record) => { + 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) => { + 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(); + + 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) => { + 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) => { + 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) => { + 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 (
= ({
{laneLabel}
{ void handleBackgroundClick(event); }} + onDoubleClick={(event) => { void handleBackgroundDoubleClick(event); }} >
= ({ viewBox={`0 0 ${beatWidth * totalBeats + keyWidth} ${laneHeight}`} preserveAspectRatio="none" > - {interpolationMode === 'linear' && points.length > 0 && ( + {interpolationMode === 'linear' && renderedPoints.length > 0 && ( = ({ strokeWidth="2" /> ))} - {svgPoints.map(point => { + {renderedPoints.map(point => { const labelY = Math.max(14, Math.min(laneHeight - 6, point.y - 10)); return ( { void handlePointMouseDown(point.id, event); }} /> {point.label} @@ -246,8 +839,15 @@ const PianoRollAutomationLane: React.FC = ({ ); })} + {isLassoSelectingRef.current && ( +
+ )}
- {points.length === 0 && ( + {renderedPointsSorted.length === 0 && (
No {laneLabel} events in this region
diff --git a/src/components/piano-roll/pianoRollSnap.ts b/src/components/piano-roll/pianoRollSnap.ts new file mode 100644 index 0000000..ec98ef8 --- /dev/null +++ b/src/components/piano-roll/pianoRollSnap.ts @@ -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; +} diff --git a/src/hooks/useNoteOperations.ts b/src/hooks/useNoteOperations.ts index 3e29bde..3a36b0b 100644 --- a/src/hooks/useNoteOperations.ts +++ b/src/hooks/useNoteOperations.ts @@ -11,6 +11,11 @@ import { KGPianoRollState } from '../core/state/KGPianoRollState'; import { KGAudioInterface } from '../core/audio-interface/KGAudioInterface'; import { CreateNoteCommand, DeleteNotesCommand, ResizeNotesCommand, MoveNotesCommand } from '../core/commands'; 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 { activeRegion: KGMidiRegion | null; @@ -53,79 +58,6 @@ export const useNoteOperations = ({ // Get KGCore instance for accessing selected items 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 const deleteSelectedNotes = () => { if (!activeRegion) return false; @@ -136,22 +68,36 @@ export const useNoteOperations = ({ item instanceof KGMidiNote && activeRegion.getNotes().some(note => note.getId() === item.getId()) ) 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) { - console.log('No notes selected for deletion'); + console.log('No MIDI events selected for deletion'); } return false; } 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 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); + if (pitchBendIds.length > 0 || controllerEventIds.length > 0) { + useProjectStore.getState().bumpAutomationRedrawVersion(); + } // Find the track that contains this region and update it for UI sync const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId()); @@ -163,7 +109,7 @@ export const useNoteOperations = ({ setNoteUpdateCounter(prev => prev + 1); if (DEBUG_MODE.PIANO_ROLL) { - console.log(`Deleted ${selectedNotes.length} notes using DeleteNotesCommand`); + console.log('Deleted selected MIDI events using command'); } return true; @@ -189,7 +135,7 @@ export const useNoteOperations = ({ const currentSnap = KGPianoRollState.instance().getCurrentSnap(); const beatNumber = currentSnap === 'NO SNAP' ? 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) // 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 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; // 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 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 if (resizeEdge === 'end') { @@ -691,7 +645,10 @@ export const useNoteOperations = ({ // Apply horizontal snapping based on current snap setting const regionStartBeat = activeRegion.getStartFromBeat(); const rawBeatPosition = (rawNewLeft / beatWidth) - regionStartBeat; - const snappedBeatPosition = getSnappedBeatPosition(rawBeatPosition, timeSignature); + const snappedBeatPosition = getSnappedBeatPosition( + rawBeatPosition, + KGPianoRollState.instance().getCurrentSnap(), + ); const snappedLeft = (snappedBeatPosition + regionStartBeat) * beatWidth; // Use snapped horizontal position, but keep raw vertical position @@ -894,4 +851,4 @@ export const useNoteOperations = ({ handleNoteDragEnd, deleteSelectedNotes }; -}; \ No newline at end of file +}; diff --git a/src/hooks/useNoteSelection.ts b/src/hooks/useNoteSelection.ts index 6469e78..a372c2a 100644 --- a/src/hooks/useNoteSelection.ts +++ b/src/hooks/useNoteSelection.ts @@ -41,6 +41,19 @@ export const useNoteSelection = ({ // Get 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 const handleNoteClick = (noteId: string, e: React.MouseEvent) => { @@ -58,6 +71,8 @@ export const useNoteSelection = ({ if (DEBUG_MODE.PIANO_ROLL) { console.log(`NOTE CLICKED: noteId=${noteId}, shift key: ${e.shiftKey}`); } + + deselectAutomationEvents(); // Create a new Set for selected note IDs let newSelectedNoteIds: Set; @@ -110,6 +125,7 @@ export const useNoteSelection = ({ // Clear KGCore selection using store method clearAllSelections(); + deselectAutomationEvents(); // Create new selection with only this note newSelectedNoteIds = new Set([noteId]); @@ -191,6 +207,8 @@ export const useNoteSelection = ({ if (DEBUG_MODE.PIANO_ROLL) { console.log('Starting box selection'); } + + deselectAutomationEvents(); // Store shift key state for box selection isShiftKeyPressedRef.current = e.shiftKey; @@ -295,6 +313,7 @@ export const useNoteSelection = ({ // Clear KGCore selection using store method clearAllSelections(); + deselectAutomationEvents(); // Start with empty selection newSelectedNoteIds = new Set(); @@ -444,4 +463,4 @@ export const useNoteSelection = ({ handleBackgroundMouseDown, cleanupSelectionListeners }; -}; \ No newline at end of file +};