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>(new Set()); // Use refs for box selection to avoid async state update issues const isBoxSelectingRef = useRef(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(0); // Flag to prevent deselection after box selection const preventDeselectRef = useRef(false); // Track shift key state for box selection const isShiftKeyPressedRef = useRef(false); // Get KGCore instance const core = KGCore.instance(); const deselectAutomationEvents = () => { if (!activeRegion) return; activeRegion.getPitchBends().forEach(pitchBend => { pitchBend.deselect(); }); activeRegion.getControllerEventsByType().forEach(events => { events.forEach(controllerEvent => { controllerEvent.deselect(); }); }); }; // Handle note click for selection const handleNoteClick = (noteId: string, e: React.MouseEvent) => { 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}`); } deselectAutomationEvents(); // Create a new Set for selected note IDs let newSelectedNoteIds: Set; // 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(); deselectAutomationEvents(); // 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'); } deselectAutomationEvents(); // 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; // 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(); deselectAutomationEvents(); // Start with empty selection newSelectedNoteIds = new Set(); } // 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(); // 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 }; };