initial public release.

This commit is contained in:
Xiaohan-Tian
2025-08-11 18:37:21 -07:00
commit de51967b49
186 changed files with 32322 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
import { useState, useEffect, useCallback } from 'react';
import { ConfigManager } from '../core/config/ConfigManager';
/**
* Custom hook for managing configuration values with automatic loading and saving
* @param configKey - The dot-notation key for the configuration value
* @param defaultValue - Default value to use if config is not loaded
* @param debounceMs - Debounce time in milliseconds for saving (default: 500ms)
*/
export function useConfig<T>(
configKey: string,
defaultValue: T,
debounceMs: number = 500
) {
const [value, setValue] = useState<T>(defaultValue);
const [isLoading, setIsLoading] = useState(true);
const configManager = ConfigManager.instance();
// Load configuration value on mount
useEffect(() => {
const loadConfig = async () => {
try {
if (!configManager.getIsInitialized()) {
await configManager.initialize();
}
const loadedValue = configManager.get(configKey) as T;
setValue(loadedValue ?? defaultValue);
} catch (error) {
console.error(`Failed to load config for ${configKey}:`, error);
setValue(defaultValue);
} finally {
setIsLoading(false);
}
};
loadConfig();
}, [configKey, defaultValue, configManager]);
// Debounced save function
const debouncedSave = useCallback((newValue: T) => {
const timeoutId = setTimeout(async () => {
try {
await configManager.set(configKey, newValue);
console.log(`Config saved: ${configKey} = ${newValue}`);
} catch (error) {
console.error(`Failed to save config for ${configKey}:`, error);
}
}, debounceMs);
return () => clearTimeout(timeoutId);
}, [configKey, configManager, debounceMs]);
// Update function that immediately updates state and debounces save
const updateValue = useCallback((newValue: T) => {
setValue(newValue);
debouncedSave(newValue);
}, [debouncedSave]);
// Immediate save function (for dropdowns, checkboxes, etc.)
const saveImmediately = useCallback(async (newValue: T) => {
setValue(newValue);
try {
await configManager.set(configKey, newValue);
console.log(`Config saved immediately: ${configKey} = ${newValue}`);
} catch (error) {
console.error(`Failed to save config immediately for ${configKey}:`, error);
}
}, [configKey, configManager]);
return {
value,
setValue: updateValue,
saveImmediately,
isLoading
};
}
+151
View File
@@ -0,0 +1,151 @@
import { useEffect } from 'react';
import { matchesKeyboardShortcut } from '../util/osUtil';
import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil';
import { saveProject } from '../util/saveUtil';
import { ConfigManager } from '../core/config/ConfigManager';
import { useProjectStore } from '../stores/projectStore';
/**
* Global keyboard handler for copy/paste, undo/redo, play/pause, and save operations
* Handles keyboard shortcuts defined in the configuration
*/
export const useGlobalKeyboardHandler = () => {
const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, projectName } = useProjectStore();
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Skip if user is typing in an input field
const target = event.target as HTMLElement;
if (target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.contentEditable === 'true' ||
target.hasAttribute('data-chatbox-input') ||
target.closest('.chatbox-input')
)) {
return;
}
// Enhanced prevention for system shortcuts like Cmd+S/Ctrl+S
const isCtrlOrCmd = event.ctrlKey || event.metaKey;
const isSKey = event.key.toLowerCase() === 's';
if (isCtrlOrCmd && isSKey) {
// Use multiple prevention methods for better browser compatibility
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
// Early return to handle save immediately
try {
saveProject(projectName, setStatus);
} catch (error) {
console.error('Save failed:', error);
setStatus('Save failed');
}
return false;
}
// Get keyboard shortcuts from config
const configManager = ConfigManager.instance();
if (!configManager.getIsInitialized()) {
return;
}
const undoShortcut = configManager.get('hotkeys.main.undo') as string;
const redoShortcut = configManager.get('hotkeys.main.redo') as string;
const copyShortcut = configManager.get('hotkeys.main.copy') as string;
const pasteShortcut = configManager.get('hotkeys.main.paste') as string;
const playShortcut = configManager.get('hotkeys.main.play') as string;
const saveShortcut = configManager.get('hotkeys.main.save') as string;
// Check for undo shortcut
if (undoShortcut && matchesKeyboardShortcut(event, undoShortcut)) {
event.preventDefault();
try {
undo();
setStatus('Undo performed');
} catch (error) {
console.error('Undo failed:', error);
setStatus('Undo failed');
}
return;
}
// Check for redo shortcut
if (redoShortcut && matchesKeyboardShortcut(event, redoShortcut)) {
event.preventDefault();
try {
redo();
setStatus('Redo performed');
} catch (error) {
console.error('Redo failed:', error);
setStatus('Redo failed');
}
return;
}
// Check for copy shortcut
if (copyShortcut && matchesKeyboardShortcut(event, copyShortcut)) {
event.preventDefault();
const copied = handleCopyOperation();
if (copied) {
setStatus('Items copied to clipboard');
} else {
setStatus('No items selected to copy');
}
return;
}
// Check for paste shortcut
if (pasteShortcut && matchesKeyboardShortcut(event, pasteShortcut)) {
event.preventDefault();
const pasted = handlePasteOperation();
if (pasted) {
setStatus('Items pasted from clipboard');
} else {
setStatus('Cannot paste - no valid clipboard content or context');
}
return;
}
// Check for play/pause shortcut
if (playShortcut && matchesKeyboardShortcut(event, playShortcut)) {
event.preventDefault();
try {
if (!isPlaying) {
startPlaying();
setStatus('Playback started');
} else {
stopPlaying();
setStatus('Playback stopped');
}
} catch (error) {
console.error('Play/pause failed:', error);
setStatus('Play/pause failed');
}
return;
}
// Check for save shortcut
if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) {
event.preventDefault();
try {
saveProject(projectName, setStatus);
} catch (error) {
console.error('Save failed:', error);
setStatus('Save failed');
}
return;
}
};
// Add global event listener with capture for earlier interception
document.addEventListener('keydown', handleKeyDown, { capture: true });
// Cleanup on unmount
return () => {
document.removeEventListener('keydown', handleKeyDown, { capture: true });
};
}, [undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, projectName]); // Include dependencies for store actions
};
+852
View File
@@ -0,0 +1,852 @@
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';
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 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;
// 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[];
if (selectedNotes.length === 0) {
if (DEBUG_MODE.PIANO_ROLL) {
console.log('No notes selected for deletion');
}
return false;
}
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Deleting ${selectedNotes.length} selected notes using command`);
}
// Create and execute the delete notes command
const noteIds = selectedNotes.map(note => note.getId());
const command = new DeleteNotesCommand(noteIds);
core.executeCommand(command);
// 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 ${selectedNotes.length} notes using DeleteNotesCommand`);
}
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, timeSignature, 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
// Create and execute the note creation command
const command = new CreateNoteCommand(
activeRegion.getId(),
noteStartBeat,
noteEndBeat,
pitch,
velocity
);
core.executeCommand(command);
// Get the created note for audio preview
const createdNote = command.getCreatedNote();
// 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 (createdNote) {
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(), createdNote);
}
}
}
}
};
// 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, timeSignature);
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, timeSignature);
// 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, timeSignature);
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
};
};
+447
View File
@@ -0,0 +1,447 @@
import { useState, useRef } from 'react';
import { DEBUG_MODE, PIANO_ROLL_CONSTANTS } from '../constants';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
import { KGMidiNote } from '../core/midi/KGMidiNote';
import { KGTrack } from '../core/track/KGTrack';
import { KGCore } from '../core/KGCore';
import { KGPianoRollState } from '../core/state/KGPianoRollState';
import { useProjectStore } from '../stores/projectStore';
interface UseNoteSelectionProps {
activeRegion: KGMidiRegion | null;
updateTrack: (track: KGTrack) => void;
tracks: KGTrack[];
}
export const useNoteSelection = ({
activeRegion,
updateTrack,
tracks
}: UseNoteSelectionProps) => {
// Get store actions
const { clearAllSelections } = useProjectStore();
// State for note selection
const [selectedNoteIds, setSelectedNoteIds] = useState<Set<string>>(new Set());
// Use refs for box selection to avoid async state update issues
const isBoxSelectingRef = useRef<boolean>(false);
const selectionBoxRef = useRef<{ startX: number, startY: number, endX: number, endY: number }>({
startX: 0, startY: 0, endX: 0, endY: 0
});
// We still need a state for the selection box to trigger re-renders
const [selectionBoxRender, setSelectionBoxRender] = useState<number>(0);
// Flag to prevent deselection after box selection
const preventDeselectRef = useRef<boolean>(false);
// Track shift key state for box selection
const isShiftKeyPressedRef = useRef<boolean>(false);
// Get KGCore instance
const core = KGCore.instance();
// Handle note click for selection
const handleNoteClick = (noteId: string, e: React.MouseEvent) => {
if (!activeRegion) return;
// Disable note selection in pencil mode
if (KGPianoRollState.instance().getActiveTool() === 'pencil') {
return;
}
// Find the note
const note = activeRegion.getNotes().find(n => n.getId() === noteId);
if (!note) return;
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`NOTE CLICKED: noteId=${noteId}, shift key: ${e.shiftKey}`);
}
// Create a new Set for selected note IDs
let newSelectedNoteIds: Set<string>;
// Check if shift key is pressed for multi-selection
if (e.shiftKey) {
// Shift key pressed - add/remove to existing selection
newSelectedNoteIds = new Set(selectedNoteIds);
if (newSelectedNoteIds.has(noteId)) {
// Deselect the note
newSelectedNoteIds.delete(noteId);
// Update the note's selection state
note.deselect();
// Remove from KGCore selection
core.removeSelectedItem(note);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Deselected note: ${noteId}`);
}
} else {
// Add to selection
newSelectedNoteIds.add(noteId);
// Update the note's selection state
note.select();
// Add to KGCore selection
core.addSelectedItem(note);
// Update last edited note length
const noteLength = note.getEndBeat() - note.getStartBeat();
KGPianoRollState.instance().setLastEditedNoteLength(noteLength);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Added note to selection: ${noteId}`);
}
}
} else {
// Shift key not pressed - replace selection
// First deselect all currently selected notes
activeRegion.getNotes().forEach(n => {
if (n.isSelected()) {
n.deselect();
}
});
// Clear KGCore selection using store method
clearAllSelections();
// Create new selection with only this note
newSelectedNoteIds = new Set([noteId]);
// Select the note
note.select();
// Add to KGCore selection
core.addSelectedItem(note);
// Update last edited note length
const noteLength = note.getEndBeat() - note.getStartBeat();
KGPianoRollState.instance().setLastEditedNoteLength(noteLength);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Selected note (replacing previous selection): ${noteId}`);
}
}
// Update selection state
setSelectedNoteIds(newSelectedNoteIds);
// Update the track to persist the selection state
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
if (track) {
updateTrack(track);
}
};
// Handle click on piano grid (background) to start box selection or deselect all notes
const handleBackgroundClick = (e: React.MouseEvent) => {
// Only handle direct clicks on the piano grid, not on notes
if (e.target === e.currentTarget) {
if (DEBUG_MODE.PIANO_ROLL) {
console.log('Piano grid background clicked');
}
// Check if we should prevent deselection (right after box selection)
if (preventDeselectRef.current) {
if (DEBUG_MODE.PIANO_ROLL) {
console.log('Preventing deselection after box selection');
}
preventDeselectRef.current = false;
return;
}
// If we're not starting a box selection, deselect all notes
if (!isBoxSelectingRef.current) {
if (activeRegion) {
// Deselect all notes in the region
activeRegion.getNotes().forEach(note => {
note.deselect();
});
// Update the track to persist the selection state
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
if (track) {
updateTrack(track);
}
}
// Clear local selection
setSelectedNoteIds(new Set());
// Clear KGCore selection using store method
clearAllSelections();
}
}
};
// Handle mouse down on piano grid for box selection
const handleBackgroundMouseDown = (e: React.MouseEvent) => {
// Only handle direct clicks on the piano grid, not on notes
if (e.target === e.currentTarget) {
// Disable box selection in pencil mode
if (KGPianoRollState.instance().getActiveTool() === 'pencil') {
return;
}
if (DEBUG_MODE.PIANO_ROLL) {
console.log('Starting box selection');
}
// Store shift key state for box selection
isShiftKeyPressedRef.current = e.shiftKey;
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Box selection with shift key: ${isShiftKeyPressedRef.current}`);
}
// Get the grid element's bounding rectangle
const rect = e.currentTarget.getBoundingClientRect();
if (!rect) return;
// Calculate relative position within the grid
const startX = e.clientX - rect.left;
const startY = e.clientY - rect.top;
// Initialize selection box using ref for immediate update
selectionBoxRef.current = {
startX,
startY,
endX: startX,
endY: startY
};
// Set box selection mode
isBoxSelectingRef.current = true;
// Force a re-render to show the initial selection box
setSelectionBoxRender(prev => prev + 1);
// Add global event listeners for mouse move and up
document.addEventListener('mousemove', handleBoxSelectionMouseMove);
document.addEventListener('mouseup', handleBoxSelectionMouseUp);
}
};
// Handle mouse move during box selection
const handleBoxSelectionMouseMove = (e: MouseEvent) => {
if (!isBoxSelectingRef.current) return;
// Get the grid element's bounding rectangle
const gridElement = document.querySelector('.piano-grid');
if (!gridElement) return;
const rect = gridElement.getBoundingClientRect();
// Calculate relative position within the grid
const endX = e.clientX - rect.left;
const endY = e.clientY - rect.top;
// Update selection box using ref for immediate update
selectionBoxRef.current = {
...selectionBoxRef.current,
endX,
endY
};
// Force a re-render to update the selection box
setSelectionBoxRender(prev => prev + 1);
};
// Handle mouse up to end box selection
const handleBoxSelectionMouseUp = (e: MouseEvent) => {
if (!isBoxSelectingRef.current || !activeRegion) return;
// Calculate the normalized coordinates (top-left to bottom-right)
const { startX, startY, endX, endY } = selectionBoxRef.current;
const left = Math.min(startX, endX);
const top = Math.min(startY, endY);
const right = Math.max(startX, endX);
const bottom = Math.max(startY, endY);
const mouseReleaseX = endX;
const mouseReleaseY = endY;
if (DEBUG_MODE.PIANO_ROLL) {
console.log('Box selection ended');
console.log(`Selection box coordinates: left=${left}, top=${top}, right=${right}, bottom=${bottom}`);
console.log(`Width: ${right - left}, Height: ${bottom - top}`);
console.log(`Shift key was pressed: ${isShiftKeyPressedRef.current}`);
}
// Check if this is a click (very small box) or an actual selection
const isClick = (right - left < PIANO_ROLL_CONSTANTS.DRAG_THRESHOLD) && (bottom - top < PIANO_ROLL_CONSTANTS.DRAG_THRESHOLD);
// Only proceed with selection if this is not just a click
if (!isClick) {
// Get all notes in the region
const notes = activeRegion.getNotes();
// Create a new set for selected note IDs
let newSelectedNoteIds: Set<string>;
// If shift key is pressed, start with current selection
if (isShiftKeyPressedRef.current) {
newSelectedNoteIds = new Set(selectedNoteIds);
} else {
// If shift key is not pressed, deselect all notes first
notes.forEach(note => {
note.deselect();
});
// Clear KGCore selection using store method
clearAllSelections();
// Start with empty selection
newSelectedNoteIds = new Set<string>();
}
// Get the beat width and note height for position calculations
const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40;
const noteHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20;
const regionStartBeat = activeRegion.getStartFromBeat();
// Track notes that are in the box selection
const notesInBox = new Set<string>();
// Track notes with their distances to mouse release position for finding closest
const notesWithDistances: { note: KGMidiNote, distance: number }[] = [];
// Check each note to see if any part of it is inside the selection box
notes.forEach(note => {
// Calculate position and size
const startBeat = note.getStartBeat() + regionStartBeat; // Absolute beat position
const endBeat = note.getEndBeat() + regionStartBeat; // Absolute beat position
const pitch = note.getPitch();
// Convert pitch to y position (higher notes have lower y values)
const pitchIndex = 107 - pitch; // Reverse the pitch to get the index (B7 is 107)
// Calculate position and dimensions of the note
const noteLeft = startBeat * beatWidth;
const noteTop = pitchIndex * noteHeight;
const noteRight = endBeat * beatWidth;
const noteBottom = noteTop + noteHeight;
// Check if any part of the note is inside the selection box
// This checks if any of the corners or edges of the note are inside the box
const isIntersecting = !(
noteRight < left || // Note is completely to the left of the box
noteLeft > right || // Note is completely to the right of the box
noteBottom < top || // Note is completely above the box
noteTop > bottom // Note is completely below the box
);
if (isIntersecting) {
// Add to the set of notes in the box
notesInBox.add(note.getId());
// Calculate distance from mouse release position to note center
const noteCenterX = (noteLeft + noteRight) / 2;
const noteCenterY = (noteTop + noteBottom) / 2;
const distance = Math.sqrt(
Math.pow(mouseReleaseX - noteCenterX, 2) +
Math.pow(mouseReleaseY - noteCenterY, 2)
);
notesWithDistances.push({ note, distance });
if (isShiftKeyPressedRef.current) {
// If shift is pressed, toggle the selection
if (newSelectedNoteIds.has(note.getId())) {
// Deselect the note
newSelectedNoteIds.delete(note.getId());
note.deselect();
core.removeSelectedItem(note);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Deselected note by shift+box selection: ${note.getId()}`);
}
} else {
// Select the note
newSelectedNoteIds.add(note.getId());
note.select();
core.addSelectedItem(note);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Selected note by shift+box selection: ${note.getId()}`);
}
}
} else {
// Regular box selection - always select
newSelectedNoteIds.add(note.getId());
note.select();
core.addSelectedItem(note);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Selected note by box selection: ${note.getId()}`);
}
}
} else if (!isShiftKeyPressedRef.current) {
// For non-shift selection, make sure notes outside the box are deselected
note.deselect();
}
});
// Update selection state
setSelectedNoteIds(newSelectedNoteIds);
// Find the closest note to mouse release position and update last edited note length
if (notesWithDistances.length > 0) {
const closestNote = notesWithDistances.reduce((closest, current) =>
current.distance < closest.distance ? current : closest
).note;
const noteLength = closestNote.getEndBeat() - closestNote.getStartBeat();
KGPianoRollState.instance().setLastEditedNoteLength(noteLength);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Updated last edited note length to ${noteLength} from closest note: ${closestNote.getId()}`);
}
}
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Selected ${newSelectedNoteIds.size} notes with box selection`);
}
// Update the track to persist the selection state
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
if (track) {
updateTrack(track);
}
// Set the flag to prevent immediate deselection
preventDeselectRef.current = true;
}
// End box selection
isBoxSelectingRef.current = false;
// Force a re-render to hide the selection box
setSelectionBoxRender(prev => prev + 1);
// Remove global event listeners
document.removeEventListener('mousemove', handleBoxSelectionMouseMove);
document.removeEventListener('mouseup', handleBoxSelectionMouseUp);
};
// Clean up function to remove event listeners
const cleanupSelectionListeners = () => {
document.removeEventListener('mousemove', handleBoxSelectionMouseMove);
document.removeEventListener('mouseup', handleBoxSelectionMouseUp);
};
return {
selectedNoteIds,
isBoxSelectingRef,
selectionBoxRef,
selectionBoxRender,
handleNoteClick,
handleBackgroundClick,
handleBackgroundMouseDown,
cleanupSelectionListeners
};
};
+115
View File
@@ -0,0 +1,115 @@
import { useCallback } from 'react';
import { DEBUG_MODE } from '../constants';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
import { KGTrack } from '../core/track/KGTrack';
import { KGCore } from '../core/KGCore';
import type { RegionUI } from '../components/interfaces';
import { DeleteMultipleRegionsCommand } from '../core/commands';
interface UseRegionOperationsProps {
tracks: KGTrack[];
updateTrack: (track: KGTrack) => void;
setRegions: React.Dispatch<React.SetStateAction<RegionUI[]>>;
selectedRegionId: string | null;
setSelectedRegionId: React.Dispatch<React.SetStateAction<string | null>>;
showPianoRoll: boolean;
setShowPianoRoll: (show: boolean) => void;
activeRegionId: string | null;
setActiveRegionId: (regionId: string | null) => void;
}
export const useRegionOperations = ({
tracks,
updateTrack,
setRegions,
selectedRegionId,
setSelectedRegionId,
showPianoRoll,
setShowPianoRoll,
activeRegionId,
setActiveRegionId
}: UseRegionOperationsProps) => {
// Get KGCore instance for accessing selected items
const core = KGCore.instance();
/**
* Utility function to delete selected regions using commands
* This function:
* 1. Gets all selected regions from KGCore
* 2. Creates a DeleteMultipleRegionsCommand with the region IDs
* 3. Executes the command (which handles the model updates)
* 4. Updates the UI state to reflect the deletion
* 5. Updates tracks in the store and manages UI state
*
* @returns {boolean} True if regions were deleted, false if no regions were selected
*/
const deleteSelectedRegions = useCallback(() => {
// Get all selected regions from KGCore
const selectedItems = core.getSelectedItems();
const selectedRegions = selectedItems.filter(item =>
item instanceof KGMidiRegion
) as KGMidiRegion[];
if (selectedRegions.length === 0) {
if (DEBUG_MODE.MAIN_CONTENT) {
console.log('No regions selected for deletion');
}
return false;
}
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Deleting ${selectedRegions.length} selected regions`);
}
// Get the region IDs for the command
const regionIds = selectedRegions.map(region => region.getId());
// Create and execute the delete command
const command = new DeleteMultipleRegionsCommand(regionIds);
core.executeCommand(command);
// Update tracks in the store to reflect the changes
const deletedRegionData = command.getDeletedRegionData();
const uniqueTrackIds = new Set(deletedRegionData.map(data => data.trackId));
uniqueTrackIds.forEach(trackId => {
const track = tracks.find(t => t.getId().toString() === trackId);
if (track) {
updateTrack(track);
}
});
// Update regions state to reflect the deletion
setRegions(prevRegions => {
const selectedRegionIds = new Set(regionIds);
const filteredRegions = prevRegions.filter(region => !selectedRegionIds.has(region.id));
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Updated regions state: ${prevRegions.length} -> ${filteredRegions.length} regions`);
}
return filteredRegions;
});
// Clear selected region ID if it was deleted
if (selectedRegionId && regionIds.includes(selectedRegionId)) {
setSelectedRegionId(null);
}
// Close piano roll if the active region was deleted
if (activeRegionId && regionIds.includes(activeRegionId)) {
setShowPianoRoll(false);
setActiveRegionId(null);
}
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Deleted ${selectedRegions.length} regions using command`);
}
return true;
}, [tracks, selectedRegionId, activeRegionId, updateTrack, setRegions, setSelectedRegionId, setShowPianoRoll, setActiveRegionId, core]);
return {
deleteSelectedRegions
};
};