import React, { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo } from 'react'; import './PianoRoll.css'; import type { MouseEvent } from 'react'; import { useProjectStore } from '../../stores/projectStore'; import { FaGripLines } from 'react-icons/fa'; import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import type { KGAudioRegion } from '../../core/region/KGAudioRegion'; import { DEBUG_MODE, PIANO_ROLL_CONSTANTS, TOOLBAR_CONSTANTS } from '../../constants'; import PianoRollHeader from './PianoRollHeader'; import PianoRollToolbar from './PianoRollToolbar'; import NoteAttributeBar from './NoteAttributeBar'; import PianoRollContent from './PianoRollContent'; import { KGCore } from '../../core/KGCore'; import { KGMidiNote } from '../../core/midi/KGMidiNote'; import { KGPianoRollState } from '../../core/state/KGPianoRollState'; import { ConfigManager } from '../../core/config/ConfigManager'; import { beatsToBar } from '../../util/midiUtil'; import { UpdateRegionCommand } from '../../core/commands'; import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil'; import { showAlert, showPrompt } from '../../util/dialogUtil'; import { normalizeSpectrogramHeightResolution, type SpectrogramHeightResolution, } from '../../util/spectrogramUtil'; import type { PianoRollAutomationType } from './pianoRollAutomation'; interface PianoRollProps { onClose: () => void; regionId: string | null; initialPosition?: { x: number; y: number }; initialSize?: { width: number; height: number }; mode?: 'midi-edit' | 'spectrogram' | 'hybrid'; audioRegion?: KGAudioRegion; trackId?: string; projectName?: string; } const PianoRoll: React.FC = ({ onClose, regionId, initialPosition, initialSize, mode = 'midi-edit', audioRegion, trackId, projectName, }) => { const isSpectrogram = mode === 'spectrogram'; const isHybrid = mode === 'hybrid'; const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showKGOnePanel, showListEventPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds, automationRedrawVersion } = useProjectStore(); // Tool state for piano roll const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer'); // Spectrogram controls (only used in spectrogram mode) const [spectrogramThresholdDb, setSpectrogramThresholdDb] = useState(-25); const [spectrogramPower, setSpectrogramPower] = useState(0.5); const [spectrogramHeightResolution, setSpectrogramHeightResolution] = useState(3); // Piano roll zoom (1x–8x); updates --region-grid-beat-width CSS variable const [pianoRollZoom, setPianoRollZoom] = useState(1); const [automationEnabled, setAutomationEnabled] = useState(false); const [automationType, setAutomationType] = useState('pitch-bend'); // Quantization state const [quantPosition, setQuantPosition] = useState('1/8'); const [quantLength, setQuantLength] = useState('1/8'); // Snapping state const [snapping, setSnapping] = useState('NO SNAP'); // Chord guide state const [chordGuide, setChordGuide] = useState('N'); // Piano roll state with temporary initial values const [position, setPosition] = useState(initialPosition || { x: 0, y: 0 }); // Blink effect state for toolbar button feedback const [blinkButton, setBlinkButton] = useState(null); const [size, setSize] = useState(initialSize || { width: 800, height: PIANO_ROLL_CONSTANTS.PIANO_ROLL_HEIGHT }); const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [activeRegion, setActiveRegion] = useState(null); const selectedNotes = useMemo( () => activeRegion?.getNotes().filter(n => selectedNoteIds.includes(n.getId())) ?? [], [activeRegion, selectedNoteIds] ); const pianoRollRef = useRef(null); const pianoRollContentRef = useRef(null); const pianoRollNoteScrollRef = useRef(null); const pianoGridRef = useRef(null); const wasDraggingRef = useRef(false); // Refs for auto-scroll during playback const pianoRollExpectedScrollLeftRef = useRef(-1); const pianoRollIsPlayingRef = useRef(false); const pendingZoomAnchorBeatRef = useRef(null); // Ref for storing the setNoteUpdateCounter function const triggerNoteUpdateRef = useRef> | null>(null); // Ref for storing the deleteSelectedNotes function const deleteSelectedNotesRef = useRef<(() => boolean) | null>(null); // Calculate initial position and size once on mount useEffect(() => { // Skip if initialPosition or initialSize were provided as props if (!initialPosition || !initialSize) { // Calculate initial position based on window dimensions const calculateInitialPosition = () => { // Dynamically get heights from CSS computed styles const statusBarElement = document.querySelector('.status-bar'); const trackControlElement = document.querySelector('.track-control'); // Get actual heights from DOM elements, or use fallback values if elements don't exist yet const statusBarHeight = statusBarElement ? statusBarElement.clientHeight : 30; const trackControlHeight = trackControlElement ? trackControlElement.clientHeight : 30; const pianoRollHeight = PIANO_ROLL_CONSTANTS.PIANO_ROLL_HEIGHT; // Compute left offset when instrument selection panel is open const rootStyles = getComputedStyle(document.documentElement); const instrumentPanelWidthStr = rootStyles.getPropertyValue('--instrument-selection-width') || '300px'; const instrumentPanelWidth = parseInt(instrumentPanelWidthStr, 10) || 300; if (DEBUG_MODE.PIANO_ROLL) { console.log(`Positioning piano roll with heights - statusBar: ${statusBarHeight}px, trackControl: ${trackControlHeight}px, pianoRoll: ${pianoRollHeight}px`); } return { x: showInstrumentSelection ? instrumentPanelWidth : 0, y: window.innerHeight - statusBarHeight - trackControlHeight - pianoRollHeight }; }; const calculateInitialSize = () => { const rootStyles = getComputedStyle(document.documentElement); const chatBoxWidthStr = rootStyles.getPropertyValue('--chat-box-width') || '350px'; const instrumentPanelWidthStr = rootStyles.getPropertyValue('--instrument-selection-width') || '300px'; const chatBoxWidth = parseInt(chatBoxWidthStr, 10) || 350; const instrumentPanelWidth = parseInt(instrumentPanelWidthStr, 10) || 300; let availableWidth = window.innerWidth; if (showChatBox || showKGOnePanel || showListEventPanel) availableWidth -= chatBoxWidth; if (showInstrumentSelection) availableWidth -= instrumentPanelWidth; // Ensure a sensible minimum starting width const clampedWidth = Math.max(400, availableWidth); return { width: clampedWidth, height: PIANO_ROLL_CONSTANTS.PIANO_ROLL_HEIGHT }; }; // Set position and size only if not provided as props if (!initialPosition) { setPosition(calculateInitialPosition()); } if (!initialSize) { setSize(calculateInitialSize()); } } // Intentionally run once on mount to capture layout at open time // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Empty dependency array means this runs once on mount // Find and set the active region when regionId changes useEffect(() => { if (!regionId) { setActiveRegion(null); return; } let found: KGMidiRegion | null = null; for (const track of tracks) { const region = track.getRegions().find(r => r.getId() === regionId); if (region && region instanceof KGMidiRegion) { found = region; break; } } // Always update — clears stale MIDI region when switching to an audio region setActiveRegion(found); if (found && DEBUG_MODE.PIANO_ROLL) { console.log(`Active region set in PianoRoll: ${found.getId()}`); console.log(`Region details: name=${found.getName()}, trackId=${found.getTrackId()}, trackIndex=${found.getTrackIndex()}`); } }, [regionId, tracks]); // Sync local state with KGPianoRollState on mount useEffect(() => { const pianoRollState = KGPianoRollState.instance(); // Sync snapping state const currentSnap = pianoRollState.getCurrentSnap(); setSnapping(currentSnap); // Sync tool state const currentTool = pianoRollState.getActiveTool() as 'pointer' | 'pencil'; setActiveTool(currentTool); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Synced piano roll state on mount - snap: ${currentSnap}, tool: ${currentTool}`); } }, []); // Empty dependency array means this runs once on mount useEffect(() => { let unsubscribe: (() => void) | undefined; const initializeSpectrogramHeightResolution = async () => { const configManager = ConfigManager.instance(); if (!configManager.getIsInitialized()) { await configManager.initialize(); } const applyResolutionFromConfig = () => { setSpectrogramHeightResolution( normalizeSpectrogramHeightResolution( configManager.get('editor.spectrogram_height_resolution') ) ); }; applyResolutionFromConfig(); unsubscribe = configManager.addChangeListener((changedKeys) => { if ( changedKeys.includes('__all__') || changedKeys.includes('editor.spectrogram_height_resolution') ) { applyResolutionFromConfig(); } }); }; void initializeSpectrogramHeightResolution(); return () => { unsubscribe?.(); }; }, []); // Add keyboard event listener for Escape useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // Close on Escape key if (event.key === 'Escape') { if (DEBUG_MODE.PIANO_ROLL) { console.log('Closing piano roll with ESC key'); } onClose(); } }; // Add event listener window.addEventListener('keydown', handleKeyDown); // Remove event listener on cleanup return () => { window.removeEventListener('keydown', handleKeyDown); }; }, [onClose]); // Handle mouse events for dragging and resizing const handleMouseDown = (e: React.MouseEvent, action: 'drag' | 'resize') => { if (action === 'drag') { setIsDragging(true); wasDraggingRef.current = false; // Reset the dragging flag if (pianoRollRef.current) { const rect = pianoRollRef.current.getBoundingClientRect(); setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top }); } } else if (action === 'resize') { setIsResizing(true); e.preventDefault(); } }; useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (isDragging) { // Set the flag to true as soon as any movement happens wasDraggingRef.current = true; setPosition({ x: e.clientX - dragOffset.x, y: e.clientY - dragOffset.y }); } else if (isResizing) { setSize({ width: Math.max(400, e.clientX - position.x), height: Math.max(300, e.clientY - position.y) }); } }; const handleMouseUp = () => { setIsDragging(false); setIsResizing(false); // We keep wasDraggingRef.current as is - it will be used in handleTitleClick // and reset on the next mousedown }; if (isDragging || isResizing) { document.addEventListener('mousemove', handleMouseMove as unknown as EventListener); document.addEventListener('mouseup', handleMouseUp); } return () => { document.removeEventListener('mousemove', handleMouseMove as unknown as EventListener); document.removeEventListener('mouseup', handleMouseUp); }; }, [isDragging, isResizing, dragOffset, position]); // Handle title click to rename the region const handleTitleClick = async () => { // If we were just dragging, don't show the rename dialog if (wasDraggingRef.current) { if (DEBUG_MODE.PIANO_ROLL) { console.log("Skipping rename dialog because the window was just dragged"); } return; } if (!activeRegion) return; // Show a prompt to get the new name const newName = await showPrompt("Enter a new name for the region:", activeRegion.getName()); // If the user clicked Cancel or entered an empty string, do nothing if (!newName || newName.trim() === '' || newName === activeRegion.getName()) return; // Use command pattern to update the region name with undo support try { const command = new UpdateRegionCommand(activeRegion.getId(), { name: newName.trim() }); KGCore.instance().executeCommand(command); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Executed UpdateRegionCommand: renamed region ${activeRegion.getId()} to "${newName}" using command pattern`); } // Update the store to trigger re-render const updatedTracks = [...tracks]; useProjectStore.setState({ tracks: updatedTracks }); } catch (error) { console.error('Error renaming region:', error); await showAlert('Failed to rename region. Please try again.'); } }; // Handle tool selection const handleToolSelect = (tool: 'pointer' | 'pencil') => { setActiveTool(tool); KGPianoRollState.instance().setActiveTool(tool); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Selected tool: ${tool}`); } }; // Handle snapping selection const handleSnappingSelect = useCallback((value: string) => { setSnapping(value); KGPianoRollState.instance().setCurrentSnap(value); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Selected snapping: ${value}`); } }, []); // Handle mode selection const handleModeSelect = useCallback((value: string) => { setSelectedMode(value); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Selected mode: ${value}`); } }, [setSelectedMode]); // Handle chord guide selection const handleChordGuideSelect = useCallback((value: string) => { setChordGuide(value); }, []); // Update suitable chords whenever chord guide, key signature, or mode changes useEffect(() => { const pianoRollState = KGPianoRollState.instance(); if (chordGuide === 'N') { // Disabled - clear chord data pianoRollState.setCurrentSuitableChords({}); pianoRollState.setCurrentSuitableChordsPitchClasses({}); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Chord guide disabled - cleared suitable chords`); } } else { // Get suitable chords for the selected function (T/S/D) const functionType = chordGuide as 'T' | 'S' | 'D'; const suitableChords = getSuitableChords(keySignature, selectedMode, functionType); // Convert note names to pitch classes (ensuring ascending order) const chordsPitchClasses: Record = {}; for (const [chordSymbol, noteNames] of Object.entries(suitableChords)) { const pitchClasses: number[] = []; let previousPitch = -1; for (const noteName of noteNames) { let pitchClass = noteNameToPitchClass(noteName); // If this pitch is lower than the previous one, add an octave if (previousPitch >= 0 && pitchClass <= previousPitch) { pitchClass += 12; } pitchClasses.push(pitchClass); previousPitch = pitchClass; } chordsPitchClasses[chordSymbol] = pitchClasses; } // Update piano roll state pianoRollState.setCurrentSuitableChords(suitableChords); pianoRollState.setCurrentSuitableChordsPitchClasses(chordsPitchClasses); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Chord guide updated: ${chordGuide} (${functionType})`); console.log(`Suitable chords for ${keySignature} in ${selectedMode} mode:`, suitableChords); console.log(`Pitch classes:`, chordsPitchClasses); } } }, [chordGuide, keySignature, selectedMode]); // Handler for receiving the setNoteUpdateCounter function from PianoRollContent const handleSetNoteUpdateTrigger = (setNoteFn: React.Dispatch>) => { triggerNoteUpdateRef.current = setNoteFn; }; // Handler for receiving the deleteSelectedNotes function from PianoRollContent const handleSetDeleteNotesTrigger = (deleteFn: () => boolean) => { deleteSelectedNotesRef.current = deleteFn; }; // Quantize selected notes based on the selected quantization value const quantizeSelectedNotes = useCallback((quantValue: string) => { if (!activeRegion) return; // Get the KGCore instance const core = KGCore.instance(); // 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 quantization'); } return; } if (DEBUG_MODE.PIANO_ROLL) { console.log(`Quantizing ${selectedNotes.length} selected notes with value: ${quantValue}`); } // Parse the quantization value (e.g., "1/4", "1/8", "1/16", "1/32") const denominator = parseInt(quantValue.split('/')[1]); if (isNaN(denominator)) { console.error(`Invalid quantization value: ${quantValue}`); return; } // Calculate the quantization 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, denominator: timeSigDenominator } = timeSignature; // Calculate beats per whole note based on time signature // In 4/4, a whole note is 4 beats // In 6/8, a whole note is 6 beats (because each beat is an eighth note) const beatsPerWholeNote = numerator * (4 / timeSigDenominator); // Calculate the quantization step in beats // quantizationStep should ALWAYS be 4 / denominator regardless of time signature const quantizationStep = 4 / denominator; if (DEBUG_MODE.PIANO_ROLL) { console.log(`Time signature: ${numerator}/${timeSigDenominator}`); console.log(`Beats per whole note: ${beatsPerWholeNote}`); console.log(`Quantization step: ${quantizationStep} beats`); } // Apply quantization to each selected note selectedNotes.forEach(note => { // Get the current start beat const currentStartBeat = note.getStartBeat(); // Calculate the quantized start beat const quantizedStartBeat = Math.round(currentStartBeat / quantizationStep) * quantizationStep; // Calculate the duration of the note const duration = note.getEndBeat() - currentStartBeat; // Set the new start beat and maintain the duration note.setStartBeat(quantizedStartBeat); note.setEndBeat(quantizedStartBeat + duration); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Quantized note ${note.getId()}: ${currentStartBeat} -> ${quantizedStartBeat}`); } }); // Find the track that contains this region and update it const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId()); if (track) { updateTrack(track); } // Trigger a re-render by incrementing the note update counter if (triggerNoteUpdateRef.current) { triggerNoteUpdateRef.current(prev => prev + 1); if (DEBUG_MODE.PIANO_ROLL) { console.log('Triggered note update to re-render quantized notes'); } } }, [activeRegion, timeSignature, updateTrack, tracks]); // Quantize selected notes length based on the selected quantization value const quantizeNoteLength = useCallback((quantValue: string) => { if (!activeRegion) return; // Get the KGCore instance const core = KGCore.instance(); // 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 length quantization'); } return; } if (DEBUG_MODE.PIANO_ROLL) { console.log(`Quantizing length of ${selectedNotes.length} selected notes with value: ${quantValue}`); } // Parse the quantization value (e.g., "1/1", "1/2", "1/4", "1/8", "1/16", "1/32") const denominator = parseInt(quantValue.split('/')[1]); if (isNaN(denominator)) { console.error(`Invalid quantization value: ${quantValue}`); return; } // Calculate the quantization 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, denominator: timeSigDenominator } = timeSignature; // Calculate beats per whole note based on time signature // In 4/4, a whole note is 4 beats // In 6/8, a whole note is 6 beats (because each beat is an eighth note) const beatsPerWholeNote = numerator * (4 / timeSigDenominator); // Calculate the quantization step in beats // quantizationStep should ALWAYS be 4 / denominator regardless of time signature const quantizationStep = 4 / denominator; if (DEBUG_MODE.PIANO_ROLL) { console.log(`Time signature: ${numerator}/${timeSigDenominator}`); console.log(`Beats per whole note: ${beatsPerWholeNote}`); console.log(`Length quantization step: ${quantizationStep} beats`); } // Apply quantization to each selected note selectedNotes.forEach(note => { // Get the current start and end beats const startBeat = note.getStartBeat(); const currentEndBeat = note.getEndBeat(); // Calculate the current duration const currentDuration = currentEndBeat - startBeat; // Calculate the quantized duration // If the current duration is less than the quantization step, // extend it to match the quantization step exactly // Otherwise, round to the nearest multiple of quantizationStep let quantizedDuration; if (currentDuration < quantizationStep) { // For notes shorter than the quantization step, extend to exactly one step quantizedDuration = quantizationStep; if (DEBUG_MODE.PIANO_ROLL) { console.log(`Extending short note ${note.getId()} from ${currentDuration} to ${quantizedDuration}`); } } else { // For longer notes, round to nearest multiple of quantizationStep quantizedDuration = Math.round(currentDuration / quantizationStep) * quantizationStep; } // Ensure minimum note length quantizedDuration = Math.max(PIANO_ROLL_CONSTANTS.MIN_NOTE_LENGTH, quantizedDuration); // Set the new end beat while maintaining the start beat note.setEndBeat(startBeat + quantizedDuration); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Quantized note length ${note.getId()}: ${currentDuration} -> ${quantizedDuration}`); } }); // Find the track that contains this region and update it const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId()); if (track) { updateTrack(track); } // Trigger a re-render by incrementing the note update counter if (triggerNoteUpdateRef.current) { triggerNoteUpdateRef.current(prev => prev + 1); if (DEBUG_MODE.PIANO_ROLL) { console.log('Triggered note update to re-render quantized note lengths'); } } }, [activeRegion, timeSignature, updateTrack, tracks]); // Handle quantization selection const handleQuantSelect = useCallback((type: 'position' | 'length', value: string) => { if (type === 'position') { setQuantPosition(value); // Apply quantization immediately when position quantization is changed quantizeSelectedNotes(value); if (DEBUG_MODE.PIANO_ROLL) { console.log(`quant-position selected: ${value}`); } } else { setQuantLength(value); // Apply length quantization immediately when length quantization is changed quantizeNoteLength(value); if (DEBUG_MODE.PIANO_ROLL) { console.log(`quant-length selected: ${value}`); } } }, [quantizeSelectedNotes, quantizeNoteLength]); const handleZoomChange = useCallback((nextZoom: number) => { if (nextZoom === pianoRollZoom) return; const container = pianoRollContentRef.current; pendingZoomAnchorBeatRef.current = null; if (container) { const keysWidth = parseInt( getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width') ) || 60; const visibleMusicWidth = Math.max(0, container.clientWidth - keysWidth); const beatWidth = parseInt( getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') ) || TOOLBAR_CONSTANTS.BASE_BAR_WIDTH; if (visibleMusicWidth > 0 && beatWidth > 0) { pendingZoomAnchorBeatRef.current = (container.scrollLeft + visibleMusicWidth / 2) / beatWidth; } else { pendingZoomAnchorBeatRef.current = null; } } setPianoRollZoom(nextZoom); }, [pianoRollZoom]); const handleAutomationToggle = useCallback(() => { setAutomationEnabled(current => !current); }, []); const handleAutomationTypeChange = useCallback((value: PianoRollAutomationType) => { setAutomationType(value); }, []); // Calculate C4 position and scroll to it when piano roll opens useEffect(() => { if (pianoRollNoteScrollRef.current) { // Calculate position of C4 // We have 8 octaves (0-7), and C4 is in the middle // Each octave has 12 notes, each note is piano key height // C4 is in octave 4, and C is the first note in each octave // Calculate from the bottom: // - Octaves 0-3 = 4 octaves = 4 * 12 * piano key height // - Within octave 4, C is the first note (from bottom), so 0px additional const keyHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20; const c4Position = 4 * 12 * keyHeight; // pixels from bottom // Total height of all notes (8 octaves * 12 notes * piano key height) const totalHeight = 8 * 12 * keyHeight; // Get the viewport height of the piano roll content const viewportHeight = pianoRollNoteScrollRef.current.clientHeight; // Calculate scroll position to center C4 // We need to scroll from the top, so we calculate: // (total height - C4 position) - (viewport height / 2) const scrollPosition = (totalHeight - c4Position) - (viewportHeight / 2); // Scroll to the calculated position pianoRollNoteScrollRef.current.scrollTop = Math.max(0, scrollPosition); } }, []); // Sync isPlayingRef for use inside scroll event closure useEffect(() => { pianoRollIsPlayingRef.current = isPlaying; }, [isPlaying]); // Detect manual horizontal scroll during playback useEffect(() => { const container = pianoRollContentRef.current; if (!container) return; const handleScroll = () => { if (!pianoRollIsPlayingRef.current) return; if (Math.abs(container.scrollLeft - pianoRollExpectedScrollLeftRef.current) < 1) return; useProjectStore.getState().setAutoScrollEnabled(false); }; container.addEventListener('scroll', handleScroll); return () => container.removeEventListener('scroll', handleScroll); }, []); // Auto-scroll to keep playhead centered during playback useEffect(() => { if (!isPlaying || !autoScrollEnabled) return; const container = pianoRollContentRef.current; if (!container) return; const beatWidth = parseInt( getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') ) || 40; const playheadPixel = playheadPosition * beatWidth; // Center the playhead in the visible grid area (excluding the 60px sticky piano keys panel) const keysWidth = parseInt( getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width') ) || 60; const targetScrollLeft = playheadPixel - (container.clientWidth - keysWidth) / 2; const clampedScrollLeft = Math.max( 0, Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth) ); pianoRollExpectedScrollLeftRef.current = clampedScrollLeft; container.scrollLeft = clampedScrollLeft; }, [playheadPosition, isPlaying, autoScrollEnabled]); // Handle scroll requests from main content bar numbers clicks useEffect(() => { if (pianoRollScrollRequest === null) return; const container = pianoRollContentRef.current; if (!container) return; const beatWidth = parseInt( getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') ) || 40; const playheadPixel = pianoRollScrollRequest * beatWidth; // Center the playhead in the visible grid area (excluding the 60px sticky piano keys panel) const keysWidth = parseInt( getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width') ) || 60; const targetScrollLeft = playheadPixel - (container.clientWidth - keysWidth) / 2; const clampedScrollLeft = Math.max( 0, Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth) ); container.scrollLeft = clampedScrollLeft; // Clear the request after handling useProjectStore.setState({ pianoRollScrollRequest: null }); }, [pianoRollScrollRequest]); // Update --region-grid-beat-width when zoom changes and preserve the centered beat position. useLayoutEffect(() => { const beatWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * pianoRollZoom; document.documentElement.style.setProperty('--region-grid-beat-width', `${beatWidth}px`); if (triggerNoteUpdateRef.current) { triggerNoteUpdateRef.current(prev => prev + 1); } const anchorBeat = pendingZoomAnchorBeatRef.current; const container = pianoRollContentRef.current; if (anchorBeat !== null && container) { const keysWidth = parseInt( getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width') ) || 60; const visibleMusicWidth = Math.max(0, container.clientWidth - keysWidth); const targetPixel = anchorBeat * beatWidth; const targetScrollLeft = targetPixel - visibleMusicWidth / 2; const clampedScrollLeft = Math.max( 0, Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth) ); pianoRollExpectedScrollLeftRef.current = clampedScrollLeft; container.scrollLeft = clampedScrollLeft; pendingZoomAnchorBeatRef.current = null; } return () => { document.documentElement.style.setProperty('--region-grid-beat-width', '40px'); }; }, [pianoRollZoom]); // Scroll horizontally to the active region's starting bar useEffect(() => { if (pianoRollContentRef.current && activeRegion) { // Get the starting beat of the region const startBeat = activeRegion.getStartFromBeat(); // Get the time signature to calculate beats per bar const beatsPerBar = timeSignature.numerator; // Calculate the bar number (0-indexed) const barNumber = Math.floor(startBeat / beatsPerBar); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Scrolling to region's starting bar: ${barNumber + 1} (startBeat: ${startBeat}, beatsPerBar: ${beatsPerBar})`); } // Calculate the pixel position (each bar is --region-grid-bar-width wide, which is 160px by default) const barWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-bar-width')) || 160; // Calculate the scroll position to scroll to the starting bar const scrollPosition = barNumber * barWidth; // Scroll to the calculated position pianoRollContentRef.current.scrollLeft = Math.max(0, scrollPosition); } }, [activeRegion, timeSignature]); // Add keyboard event listener for piano roll hotkeys (snapping and quantization) useEffect(() => { const handlePianoRollKeyDown = (event: KeyboardEvent) => { // Skip if user is typing in an input field (including ChatBox) 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; } // Handle delete key for selected notes if (event.key === 'Backspace' || event.key === 'Delete') { if (deleteSelectedNotesRef.current) { const deleted = deleteSelectedNotesRef.current(); if (deleted) { // Prevent default behavior only if notes were actually deleted event.preventDefault(); } } return; } // Handle piano roll hotkeys const configManager = ConfigManager.instance(); if (configManager.getIsInitialized()) { // Chord guide switch hotkey const switch_key = configManager.get('hotkeys.piano_roll.switch') as string; if (event.key && event.key.toLowerCase() === switch_key.toLowerCase()) { // Call the switchChord function exposed by PianoGrid // eslint-disable-next-line @typescript-eslint/no-explicit-any const switchChord = (window as any).__pianoGridSwitchChord; if (typeof switchChord === 'function') { switchChord(); event.preventDefault(); } return; } // Snapping hotkeys const snap_none_key = configManager.get('hotkeys.piano_roll.snap_none') as string; const snap_1_4_key = configManager.get('hotkeys.piano_roll.snap_1_4') as string; const snap_1_8_key = configManager.get('hotkeys.piano_roll.snap_1_8') as string; const snap_1_16_key = configManager.get('hotkeys.piano_roll.snap_1_16') as string; // Quantize position hotkeys const qua_pos_1_4_key = configManager.get('hotkeys.piano_roll.qua_pos_1_4') as string; const qua_pos_1_8_key = configManager.get('hotkeys.piano_roll.qua_pos_1_8') as string; const qua_pos_1_16_key = configManager.get('hotkeys.piano_roll.qua_pos_1_16') as string; // Quantize length hotkeys const qua_len_1_4_key = configManager.get('hotkeys.piano_roll.qua_len_1_4') as string; const qua_len_1_8_key = configManager.get('hotkeys.piano_roll.qua_len_1_8') as string; const qua_len_1_16_key = configManager.get('hotkeys.piano_roll.qua_len_1_16') as string; let actionType: 'snap' | 'quantize' | null = null; let actionValue: string | null = null; let quantType: 'position' | 'length' | null = null; // Check snapping hotkeys if (event.key === snap_none_key) { actionType = 'snap'; actionValue = 'NO SNAP'; } else if (event.key === snap_1_4_key) { actionType = 'snap'; actionValue = '1/4'; } else if (event.key === snap_1_8_key) { actionType = 'snap'; actionValue = '1/8'; } else if (event.key === snap_1_16_key) { actionType = 'snap'; actionValue = '1/16'; } // Check quantize position hotkeys else if (event.key === qua_pos_1_4_key) { actionType = 'quantize'; actionValue = '1/4'; quantType = 'position'; } else if (event.key === qua_pos_1_8_key) { actionType = 'quantize'; actionValue = '1/8'; quantType = 'position'; } else if (event.key === qua_pos_1_16_key) { actionType = 'quantize'; actionValue = '1/16'; quantType = 'position'; } // Check quantize length hotkeys else if (event.key === qua_len_1_4_key) { actionType = 'quantize'; actionValue = '1/4'; quantType = 'length'; } else if (event.key === qua_len_1_8_key) { actionType = 'quantize'; actionValue = '1/8'; quantType = 'length'; } else if (event.key === qua_len_1_16_key) { actionType = 'quantize'; actionValue = '1/16'; quantType = 'length'; } if (actionType && actionValue) { // Prevent default behavior event.preventDefault(); if (actionType === 'snap') { // Validate the snap value exists in snap options if (KGPianoRollState.SNAP_OPTIONS.includes(actionValue)) { // Change snapping value handleSnappingSelect(actionValue); // Trigger blink effect for visual feedback setBlinkButton('snapping'); setTimeout(() => setBlinkButton(null), 200); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Snap hotkey triggered: ${event.key} → ${actionValue}`); } } } else if (actionType === 'quantize' && quantType) { // Validate the quantValue exists in the appropriate options const validOptions = quantType === 'length' ? KGPianoRollState.QUANT_LEN_OPTIONS : KGPianoRollState.QUANT_POS_OPTIONS; if (validOptions.includes(actionValue)) { // Apply quantization handleQuantSelect(quantType, actionValue); // Trigger blink effect for visual feedback const buttonName = quantType === 'length' ? 'quant-length' : 'quant-position'; setBlinkButton(buttonName); setTimeout(() => setBlinkButton(null), 200); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Quantize ${quantType} hotkey triggered: ${event.key} → ${actionValue}`); } } } } } }; // Add event listener window.addEventListener('keydown', handlePianoRollKeyDown); // Remove event listener on cleanup return () => { window.removeEventListener('keydown', handlePianoRollKeyDown); }; }, [handleQuantSelect, handleSnappingSelect]); // Get the title for the piano roll based on the active region const getPianoRollTitle = () => { if (isSpectrogram) return audioRegion ? `SPECTROGRAM — ${audioRegion.getName()}` : 'SPECTROGRAM'; if (isHybrid) { const midiName = activeRegion?.getName() ?? 'MIDI'; const audioName = audioRegion?.getName() ?? 'Audio'; return `${midiName} + ${audioName}`; } if (!activeRegion) return "EDIT NOTE CLIP"; // Calculate the bar and beat position of the region const startBeat = activeRegion.getStartFromBeat(); const { bar, beatInBar } = beatsToBar(startBeat, timeSignature); // Format as 1-indexed bar and beat (bar + 1, beatInBar + 1) const barNumber = bar + 1; const beatNumber = beatInBar + 1; return `${activeRegion.getName()} (at ${barNumber}:${beatNumber})`; }; return (
handleMouseDown(e, 'drag')} />
handleMouseDown(e, 'resize')} >
); }; export default PianoRoll;