Files
KGStudio/src/hooks/useNoteOperations.ts
T

855 lines
32 KiB
TypeScript

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<HTMLDivElement | null>;
}
export const useNoteOperations = ({
activeRegion,
timeSignature,
updateTrack,
tracks,
pianoGridRef
}: UseNoteOperationsProps) => {
// State for note resizing
const [resizingNoteId, setResizingNoteId] = useState<string | null>(null);
const [tempNoteStyles, setTempNoteStyles] = useState<Record<string, React.CSSProperties>>({});
// State for note dragging
const [draggingNoteId, setDraggingNoteId] = useState<string | null>(null);
// Refs for resize operations
const currentResizeWidth = useRef<number | null>(null);
const currentResizeLeft = useRef<number | null>(null);
const initialStartBeatRef = useRef<number | null>(null);
const initialEndBeatRef = useRef<number | null>(null);
// Refs for drag operations
const initialDragLeft = useRef<number | null>(null);
const initialDragTop = useRef<number | null>(null);
const currentDragLeft = useRef<number | null>(null);
const currentDragTop = useRef<number | null>(null);
const initialPitchRef = useRef<number | null>(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
};
};