import { useState, useRef } from 'react'; import { DEBUG_MODE, PIANO_ROLL_CONSTANTS } from '../constants'; import { KGMidiRegion } from '../core/region/KGMidiRegion'; import { beatsToBar, pitchToNoteNameString, pianoRollIndexToPitch, pitchToNoteName } from '../util/midiUtil'; import { isModifierKeyPressed } from '../util/osUtil'; import { KGTrack } from '../core/track/KGTrack'; import { KGMidiNote } from '../core/midi/KGMidiNote'; import type { MutableRefObject } from 'react'; import { KGCore } from '../core/KGCore'; 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; timeSignature: { numerator: number; denominator: number }; updateTrack: (track: KGTrack) => void; tracks: KGTrack[]; pianoGridRef: MutableRefObject; } export const useNoteOperations = ({ activeRegion, timeSignature, updateTrack, tracks, pianoGridRef }: UseNoteOperationsProps) => { // State for note resizing const [resizingNoteId, setResizingNoteId] = useState(null); const [tempNoteStyles, setTempNoteStyles] = useState>({}); // State for note dragging const [draggingNoteId, setDraggingNoteId] = useState(null); // Refs for resize operations const currentResizeWidth = useRef(null); const currentResizeLeft = useRef(null); const initialStartBeatRef = useRef(null); const initialEndBeatRef = useRef(null); // Refs for drag operations const initialDragLeft = useRef(null); const initialDragTop = useRef(null); const currentDragLeft = useRef(null); const currentDragTop = useRef(null); const initialPitchRef = useRef(null); // Counter to trigger re-renders when notes are updated const [noteUpdateCounter, setNoteUpdateCounter] = useState(0); // Get KGCore instance for accessing selected items const core = KGCore.instance(); // Utility function to delete selected notes from the active region using commands const deleteSelectedNotes = () => { if (!activeRegion) return false; // Get all selected notes const selectedItems = core.getSelectedItems(); const selectedNotes = selectedItems.filter(item => 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 && selectedPitchBends.length === 0 && selectedControllerEvents.length === 0) { if (DEBUG_MODE.PIANO_ROLL) { console.log('No MIDI events selected for deletion'); } return false; } if (DEBUG_MODE.PIANO_ROLL) { console.log(`Deleting MIDI events: notes=${selectedNotes.length}, pitchBends=${selectedPitchBends.length}, controllers=${selectedControllerEvents.length}`); } const noteIds = selectedNotes.map(note => note.getId()); 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()); if (track) { updateTrack(track); } // Trigger a re-render by incrementing the note update counter setNoteUpdateCounter(prev => prev + 1); if (DEBUG_MODE.PIANO_ROLL) { console.log('Deleted selected MIDI events using command'); } return true; }; // Utility function to add a note to the active region using commands const addNoteToActiveRegion = (mouseX: number, mouseY: number) => { if (!pianoGridRef.current || !activeRegion) return; // Get the grid element's bounding rectangle const rect = pianoGridRef.current.getBoundingClientRect(); // Calculate relative position within the grid const x = mouseX - rect.left; const y = mouseY - rect.top + PIANO_ROLL_CONSTANTS.PIANO_KEY_CLICK_Y_OFFSET; // Get the CSS variables for beat width and note height const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40; const noteHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20; // Calculate the beat number (0-indexed) and apply floor snapping for note creation const rawBeatNumber = x / beatWidth; 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, 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 const pitch = pianoRollIndexToPitch(Math.floor(y / noteHeight)); // Get the note name for logging const { note, octave } = pitchToNoteName(pitch); const { bar, beatInBar } = beatsToBar(beatNumber, timeSignature); // Log the information if debug mode is on if (DEBUG_MODE.PIANO_ROLL) { console.log(`Creating note at position: x=${x}, y=${y}`); console.log(`Beat: ${beatNumber} (${bar + 1}:${beatInBar + 1}), Pitch: ${pitch} (${note}${octave})`); } // Calculate note timing relative to the region const regionStartBeat = activeRegion.getStartFromBeat(); const noteStartBeat = beatNumber - regionStartBeat; // relative beat position const lastEditedLength = KGPianoRollState.instance().getLastEditedNoteLength(); const noteEndBeat = noteStartBeat + lastEditedLength; // Use last edited note length const velocity = 127; // Maximum velocity const pianoRollState = KGPianoRollState.instance(); const matchingChordPitches = pianoRollState.getCurrentMatchingChords(); const selectedChordIndex = pianoRollState.getCurrentSelectedChordIndex(); const cursorChordPitch = pianoRollState.getCurrentChordCursorPitch(); const chordIndex = matchingChordPitches.length > 0 ? ((selectedChordIndex % matchingChordPitches.length) + matchingChordPitches.length) % matchingChordPitches.length : 0; let chordNotePitches: number[] = []; if ( matchingChordPitches.length > 0 && cursorChordPitch !== null && Math.abs(cursorChordPitch - pitch) <= 1 ) { const cursorOctave = Math.floor(cursorChordPitch / 12); const pitchClasses = matchingChordPitches[chordIndex] || []; chordNotePitches = pitchClasses .map(actualPitchClass => { const actualPitch = cursorOctave * 12 + actualPitchClass; return actualPitch >= 0 && actualPitch <= 127 ? actualPitch : null; }) .filter((value): value is number => value !== null); chordNotePitches = Array.from(new Set(chordNotePitches)); } const notePitchesToCreate = chordNotePitches.length > 0 ? chordNotePitches : [pitch]; const isChordCreation = notePitchesToCreate.length > 1; let createdNotes: KGMidiNote[] = []; if (isChordCreation) { const chordCommand = new CreateNotesCommand( notePitchesToCreate.map(notePitch => ({ regionId: activeRegion.getId(), startBeat: noteStartBeat, endBeat: noteEndBeat, pitch: notePitch, velocity })) ); core.executeCommand(chordCommand); createdNotes = chordCommand.getCreatedNotes().map(({ note }) => note); } else { const singleNoteCommand = new CreateNoteCommand( activeRegion.getId(), noteStartBeat, noteEndBeat, notePitchesToCreate[0], velocity ); core.executeCommand(singleNoteCommand); const createdNote = singleNoteCommand.getCreatedNote(); if (createdNote) { createdNotes = [createdNote]; } } // Increment the note update counter to trigger a re-render setNoteUpdateCounter((prev: number) => prev + 1); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Created note using command: startBeat=${noteStartBeat}, endBeat=${noteEndBeat}, pitch=${pitch}`); } // Find the track that contains this region and update it for UI sync const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId()); if (track) { updateTrack(track); // Play note preview if audio interface is ready and note was created if (createdNotes.length > 0) { const audioInterface = KGAudioInterface.instance(); if (audioInterface.getIsInitialized()) { if (!audioInterface.getIsAudioContextStarted()) { audioInterface.startAudioContext().catch(() => { // Silently fail if still not allowed - browser policy }); } if (audioInterface.getIsAudioContextStarted()) { createdNotes.forEach(note => { audioInterface.triggerNote(track.getId().toString(), note); }); } } } } }; // Handle double click on the piano grid const handleGridDoubleClick = (e: React.MouseEvent) => { addNoteToActiveRegion(e.clientX, e.clientY); }; // Handle single click on the piano grid for pencil mode or modifier+click const handleGridClick = (e: React.MouseEvent) => { // Create notes on single click in pencil mode OR when modifier key is pressed if (KGPianoRollState.instance().getActiveTool() === 'pencil' || isModifierKeyPressed(e)) { addNoteToActiveRegion(e.clientX, e.clientY); } }; // Handle note resize start const handleNoteResizeStart = (noteId: string, resizeEdge: 'start' | 'end', initialX: number) => { if (!activeRegion) return; // Disable resizing in pencil mode if (KGPianoRollState.instance().getActiveTool() === 'pencil') { return; } if (DEBUG_MODE.PIANO_ROLL) { console.log(`NOTE RESIZE START: noteId=${noteId}, edge=${resizeEdge}`); } setResizingNoteId(noteId); // Find the note being resized const note = activeRegion.getNotes().find(n => n.getId() === noteId); if (!note) return; // Store the initial start and end beats initialStartBeatRef.current = note.getStartBeat(); initialEndBeatRef.current = note.getEndBeat(); // Get the beat width const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40; // Calculate the region's start beat (for absolute positioning) const regionStartBeat = activeRegion.getStartFromBeat(); // Calculate the absolute start and end beats const absStartBeat = note.getStartBeat() + regionStartBeat; const absEndBeat = note.getEndBeat() + regionStartBeat; // Calculate the note's position and dimensions const left = absStartBeat * beatWidth; const width = (absEndBeat - absStartBeat) * beatWidth; // Store the initial width and left position currentResizeWidth.current = width; currentResizeLeft.current = left; // Set initial style to current position/size const initialStyle = { left: `${left}px`, width: `${width}px`, }; setTempNoteStyles(prev => ({ ...prev, [noteId]: initialStyle })); }; // Handle note resize const handleNoteResize = (noteId: string, resizeEdge: 'start' | 'end', deltaX: number) => { if (!activeRegion) return; // Find the note being resized const note = activeRegion.getNotes().find(n => n.getId() === noteId); if (!note) return; // Get the beat width const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40; // Calculate the region's start beat (for absolute positioning) const regionStartBeat = activeRegion.getStartFromBeat(); // Get initial values if (initialStartBeatRef.current === null || initialEndBeatRef.current === null || currentResizeWidth.current === null || currentResizeLeft.current === null) { return; } // Calculate the absolute start and end beats const absStartBeat = initialStartBeatRef.current + regionStartBeat; const absEndBeat = initialEndBeatRef.current + regionStartBeat; // Calculate the original position and dimensions const originalLeft = absStartBeat * beatWidth; const originalWidth = (absEndBeat - absStartBeat) * beatWidth; // Calculate minimum width in pixels const minWidth = beatWidth * PIANO_ROLL_CONSTANTS.MIN_NOTE_LENGTH; let newLeft = originalLeft; let newWidth = originalWidth; if (resizeEdge === 'end') { // End resize: adjust width only newWidth = Math.max(minWidth, originalWidth + deltaX); } else if (resizeEdge === 'start') { // Start resize: adjust both left position and width // Calculate maximum delta to prevent negative width const maxDelta = originalWidth - minWidth; const clampedDeltaX = Math.min(maxDelta, deltaX); // Adjust left position and width newLeft = originalLeft + clampedDeltaX; newWidth = originalWidth - clampedDeltaX; } // Apply length snapping to the visual feedback const newLengthInBeats = newWidth / beatWidth; 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 if (resizeEdge === 'start') { newLeft = originalLeft + originalWidth - snappedWidth; } // Store the current values in refs (use snapped values) currentResizeWidth.current = snappedWidth; currentResizeLeft.current = newLeft; // Update the temporary style for this note const newStyle = { left: `${newLeft}px`, width: `${snappedWidth}px`, }; setTempNoteStyles(prev => ({ ...prev, [noteId]: newStyle })); if (DEBUG_MODE.PIANO_ROLL) { console.log(`NOTE RESIZE: noteId=${noteId}, edge=${resizeEdge}, deltaX=${deltaX}, newLeft=${newLeft}, newWidth=${newWidth}`); } }; // Handle note resize end const handleNoteResizeEnd = (noteId: string, resizeEdge: 'start' | 'end') => { if (!activeRegion) return; if (DEBUG_MODE.PIANO_ROLL) { console.log(`NOTE RESIZE END: noteId=${noteId}, edge=${resizeEdge}`); } // Find the note being resized const note = activeRegion.getNotes().find(n => n.getId() === noteId); if (!note) return; // Get the beat width const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40; // Calculate the region's start beat (for absolute positioning) const regionStartBeat = activeRegion.getStartFromBeat(); if (currentResizeWidth.current === null || currentResizeLeft.current === null || initialStartBeatRef.current === null || initialEndBeatRef.current === null) { // Reset resizing state setResizingNoteId(null); setTempNoteStyles(prev => { const updated = { ...prev }; delete updated[noteId]; return updated; }); return; } // Calculate the new start and end beats based on the current position and width let newStartBeat, newEndBeat; if (resizeEdge === 'end') { // End resize: only the end beat changes newStartBeat = note.getStartBeat(); // Keep the original start beat newEndBeat = newStartBeat + (currentResizeWidth.current / beatWidth); // Calculate new end beat // Ensure minimum note length if (newEndBeat - newStartBeat < PIANO_ROLL_CONSTANTS.MIN_NOTE_LENGTH) { newEndBeat = newStartBeat + PIANO_ROLL_CONSTANTS.MIN_NOTE_LENGTH; } } else if (resizeEdge === 'start') { // Start resize: both start and end beats change // Calculate the new start beat (relative to region) newStartBeat = (currentResizeLeft.current / beatWidth) - regionStartBeat; // Keep the original end beat newEndBeat = initialEndBeatRef.current; // Ensure minimum note length if (newEndBeat - newStartBeat < PIANO_ROLL_CONSTANTS.MIN_NOTE_LENGTH) { newStartBeat = newEndBeat - PIANO_ROLL_CONSTANTS.MIN_NOTE_LENGTH; } } else { // This shouldn't happen, but just in case newStartBeat = note.getStartBeat(); newEndBeat = note.getEndBeat(); } // Apply length snapping to the final resize commit const originalLength = newEndBeat - newStartBeat; 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') { // For end resize, keep start beat and adjust end beat newEndBeat = newStartBeat + snappedLength; } else if (resizeEdge === 'start') { // For start resize, keep end beat and adjust start beat newStartBeat = newEndBeat - snappedLength; } // Note: Delta calculations are now handled inside ResizeNotesCommand // Determine which notes to resize (capture selection at command creation time) const selectedItems = core.getSelectedItems(); const selectedNotes = selectedItems.filter(item => item instanceof KGMidiNote && activeRegion.getNotes().some(n => n.getId() === item.getId()) ) as KGMidiNote[]; // If no notes are selected, just resize the primary note // If notes are selected, resize all selected notes (including the primary) const noteIdsToResize = selectedNotes.length > 0 ? selectedNotes.map(n => n.getId()) : [noteId]; // Use command pattern to resize notes with undo support try { const command = ResizeNotesCommand.fromNoteResize( noteId, resizeEdge, initialStartBeatRef.current, initialEndBeatRef.current, newStartBeat, newEndBeat, activeRegion.getId(), noteIdsToResize ); KGCore.instance().executeCommand(command); // Update last edited note length const noteLength = newEndBeat - newStartBeat; KGPianoRollState.instance().setLastEditedNoteLength(noteLength); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Executed ResizeNotesCommand: resized notes using command pattern`); } } catch (error) { console.error('Error resizing notes:', error); // Reset resizing state and return early on error setResizingNoteId(null); setTempNoteStyles(prev => { const updated = { ...prev }; delete updated[noteId]; return updated; }); currentResizeWidth.current = null; currentResizeLeft.current = null; initialStartBeatRef.current = null; initialEndBeatRef.current = null; return; } const { bar: startBar, beatInBar: startBeatInBar } = beatsToBar(newStartBeat + regionStartBeat, timeSignature); const { bar: endBar, beatInBar: endBeatInBar } = beatsToBar(newEndBeat + regionStartBeat, timeSignature); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Updated note: noteId=${noteId}, resizeEdge=${resizeEdge}, startBeat=${newStartBeat}, endBeat=${newEndBeat} absolute startBeat=${newStartBeat + regionStartBeat}, absolute endBeat=${newEndBeat + regionStartBeat} absolute startBar=${startBar + 1}:${(startBeatInBar + 1).toFixed(3)}, absolute endBar=${endBar + 1}:${(endBeatInBar + 1).toFixed(3)}`); } // Reset resizing state setResizingNoteId(null); setTempNoteStyles(prev => { const updated = { ...prev }; delete updated[noteId]; return updated; }); currentResizeWidth.current = null; currentResizeLeft.current = null; initialStartBeatRef.current = null; initialEndBeatRef.current = null; // Increment the note update counter to trigger a re-render setNoteUpdateCounter(prev => prev + 1); // Find the track that contains this region and update it const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId()); if (track) { updateTrack(track); } }; // Handle note drag start const handleNoteDragStart = (noteId: string, initialX: number, initialY: number) => { if (!activeRegion) return; // Disable dragging in pencil mode if (KGPianoRollState.instance().getActiveTool() === 'pencil') { return; } if (DEBUG_MODE.PIANO_ROLL) { console.log(`NOTE DRAG START: noteId=${noteId}`); } setDraggingNoteId(noteId); // Find the note being dragged const note = activeRegion.getNotes().find(n => n.getId() === noteId); if (!note) return; // Store the initial pitch initialPitchRef.current = note.getPitch(); // Get the beat width and note height const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40; const noteHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20; // Calculate the region's start beat (for absolute positioning) const regionStartBeat = activeRegion.getStartFromBeat(); // Calculate the absolute start and end beats const absStartBeat = note.getStartBeat() + regionStartBeat; // Calculate the note's position const left = absStartBeat * beatWidth; const width = (note.getEndBeat() - note.getStartBeat()) * beatWidth; // Convert pitch to y position (higher notes have lower y values) const pitchIndex = 107 - note.getPitch(); // Reverse the pitch to get the index (B7 is 107) const top = pitchIndex * noteHeight; // Store the initial position initialDragLeft.current = left; initialDragTop.current = top; currentDragLeft.current = left; currentDragTop.current = top; // Set initial style const initialStyle = { left: `${left}px`, top: `${top}px`, width: `${width}px`, height: `${noteHeight}px`, zIndex: 100, // Bring to front during drag }; setTempNoteStyles(prev => ({ ...prev, [noteId]: initialStyle })); }; // Handle note drag const handleNoteDrag = (noteId: string, deltaX: number, deltaY: number) => { if (!activeRegion) return; // Find the note being dragged const note = activeRegion.getNotes().find(n => n.getId() === noteId); if (!note) return; // Get the beat width and note height const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40; const noteHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20; // Get the initial left and top positions if (initialDragLeft.current === null || initialDragTop.current === null) return; if (currentDragLeft.current === null || currentDragTop.current === null) return; // Calculate new position const originalLeft = initialDragLeft.current; const originalTop = initialDragTop.current; // Calculate the raw new horizontal position const rawNewLeft = originalLeft + deltaX; // Apply horizontal snapping based on current snap setting const regionStartBeat = activeRegion.getStartFromBeat(); const rawBeatPosition = (rawNewLeft / beatWidth) - regionStartBeat; const snappedBeatPosition = getSnappedBeatPosition( rawBeatPosition, KGPianoRollState.instance().getCurrentSnap(), ); const snappedLeft = (snappedBeatPosition + regionStartBeat) * beatWidth; // Use snapped horizontal position, but keep raw vertical position const newLeft = snappedLeft; const newTop = originalTop + deltaY; // Update the current position refs with the new position currentDragLeft.current = newLeft; currentDragTop.current = newTop; // Calculate width based on note duration const width = (note.getEndBeat() - note.getStartBeat()) * beatWidth; // Update the temporary style for this note const newStyle = { left: `${newLeft}px`, top: `${newTop}px`, width: `${width}px`, height: `${noteHeight}px`, zIndex: 100, // Keep on top during drag }; setTempNoteStyles(prev => ({ ...prev, [noteId]: newStyle })); if (DEBUG_MODE.PIANO_ROLL) { console.log(`NOTE DRAG: noteId=${noteId}, originalLeft=${originalLeft}, originalTop=${originalTop}, deltaX=${deltaX}, deltaY=${deltaY}, newLeft=${newLeft}, newTop=${newTop}`); } }; // Handle note drag end const handleNoteDragEnd = (noteId: string) => { if (!activeRegion) return; if (DEBUG_MODE.PIANO_ROLL) { console.log(`NOTE DRAG END: noteId=${noteId}`); } // Find the note being dragged const note = activeRegion.getNotes().find(n => n.getId() === noteId); if (!note) return; // Get the beat width and note height const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40; const noteHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20; // Calculate the region's start beat (for absolute positioning) const regionStartBeat = activeRegion.getStartFromBeat(); if (currentDragLeft.current === null || currentDragTop.current === null || initialDragLeft.current === null || initialDragTop.current === null || initialPitchRef.current === null) { // Reset dragging state setDraggingNoteId(null); setTempNoteStyles(prev => { const updated = { ...prev }; delete updated[noteId]; return updated; }); return; } if (DEBUG_MODE.PIANO_ROLL) { console.log(`NOTE DRAG DONE: noteId=${noteId}, finalLeft=${currentDragLeft.current}, finalTop=${currentDragTop.current}`); } // Calculate the new start beat based on the current position (no quantization for horizontal) const newStartBeat = (currentDragLeft.current / beatWidth) - regionStartBeat; // Calculate the new end beat (maintain the same duration) const duration = note.getEndBeat() - note.getStartBeat(); const newEndBeat = newStartBeat + duration; // Calculate the new pitch based on the current position (quantize to nearest pitch) // The piano roll is drawn from bottom to top, with higher notes at the top // We need to convert the y position to a pitch const pitchIndex = Math.round(currentDragTop.current / noteHeight); const newPitch = 107 - pitchIndex; // Convert index back to pitch (B7 is 107) // Calculate the original start beat for the command const originalStartBeat = (initialDragLeft.current / beatWidth) - regionStartBeat; // Determine which notes to move (capture selection at command creation time) const selectedItems = core.getSelectedItems(); const selectedNotes = selectedItems.filter(item => item instanceof KGMidiNote && activeRegion.getNotes().some(n => n.getId() === item.getId()) ) as KGMidiNote[]; // If no notes are selected, just move the primary note // If notes are selected, move all selected notes (including the primary) const noteIdsToMove = selectedNotes.some(n => n.getId() === noteId) ? selectedNotes.map(n => n.getId()) : [noteId]; // Use command pattern to move notes with undo support try { const command = MoveNotesCommand.fromNoteDrag( noteId, originalStartBeat, initialPitchRef.current, newStartBeat, newPitch, activeRegion.getId(), noteIdsToMove ); KGCore.instance().executeCommand(command); // Update last edited note length const noteLength = newEndBeat - newStartBeat; KGPianoRollState.instance().setLastEditedNoteLength(noteLength); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Executed MoveNotesCommand: moved notes using command pattern`); } } catch (error) { console.error('Error moving notes:', error); // Reset dragging state and return early on error setDraggingNoteId(null); setTempNoteStyles(prev => { const updated = { ...prev }; delete updated[noteId]; return updated; }); currentDragLeft.current = null; currentDragTop.current = null; initialDragLeft.current = null; initialDragTop.current = null; initialPitchRef.current = null; return; } const { bar: startBar, beatInBar: startBeatInBar } = beatsToBar(newStartBeat + regionStartBeat, timeSignature); const { bar: endBar, beatInBar: endBeatInBar } = beatsToBar(newEndBeat + regionStartBeat, timeSignature); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Updated note position: noteId=${noteId}, startBeat=${newStartBeat}, endBeat=${newEndBeat}, pitch=${newPitch} absolute startBeat=${newStartBeat + regionStartBeat}, absolute endBeat=${newEndBeat + regionStartBeat} absolute startBar=${startBar + 1}:${(startBeatInBar + 1).toFixed(3)}, absolute endBar=${endBar + 1}:${(endBeatInBar + 1).toFixed(3)} original pitch=${initialPitchRef.current} (${pitchToNoteNameString(initialPitchRef.current)}), new pitch=${newPitch} (${pitchToNoteNameString(newPitch)})`); } // Reset dragging state setDraggingNoteId(null); setTempNoteStyles(prev => { const updated = { ...prev }; delete updated[noteId]; return updated; }); currentDragLeft.current = null; currentDragTop.current = null; initialDragLeft.current = null; initialDragTop.current = null; initialPitchRef.current = null; // Increment the note update counter to trigger a re-render setNoteUpdateCounter(prev => prev + 1); // Find the track that contains this region and update it const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId()); if (track) { updateTrack(track); // Play note preview if audio interface is ready const audioInterface = KGAudioInterface.instance(); if (audioInterface.getIsInitialized()) { // Try to start audio context if not started yet (user interaction will allow this) if (!audioInterface.getIsAudioContextStarted()) { audioInterface.startAudioContext().catch(() => { // Silently fail if still not allowed - browser policy }); } // Trigger note if audio context is now started if (audioInterface.getIsAudioContextStarted()) { audioInterface.triggerNote(track.getId().toString(), note); } } } }; return { resizingNoteId, draggingNoteId, tempNoteStyles, noteUpdateCounter, setNoteUpdateCounter, handleGridDoubleClick, handleGridClick, handleNoteResizeStart, handleNoteResize, handleNoteResizeEnd, handleNoteDragStart, handleNoteDrag, handleNoteDragEnd, deleteSelectedNotes }; };