initial public release.
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user