import React, { useState, useEffect, useMemo } from 'react'; import type { MutableRefObject } from 'react'; import { Playhead } from '../common'; import SelectionBox from './SelectionBox'; import { isModifierKeyPressed } from '../../util/osUtil'; import { generatePianoGridBackground, getMatchingChordsForPitch } from '../../util/scaleUtil'; import type { KeySignature } from '../../core/KGProject'; import { KGPianoRollState } from '../../core/state/KGPianoRollState'; import SpectrogramCanvas from './SpectrogramCanvas'; import AudioWaveformCanvas from './AudioWaveformCanvas'; import type { KGAudioRegion } from '../../core/region/KGAudioRegion'; import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil'; interface PianoGridProps { gridRef: MutableRefObject; children: React.ReactNode; onDoubleClick: (e: React.MouseEvent) => void; onClick: (e: React.MouseEvent) => void; onMouseDown: (e: React.MouseEvent) => void; isBoxSelecting: boolean; selectionBox: { startX: number; startY: number; endX: number; endY: number; }; regionStartBeat?: number; selectedMode: string; keySignature: KeySignature; chordGuide: string; audioRegion?: KGAudioRegion; trackId?: string; projectName?: string; bpm?: number; spectrogramThresholdDb?: number; spectrogramPower?: number; spectrogramHeightResolution?: SpectrogramHeightResolution; pianoRollZoom?: number; mode?: 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid'; onSpectrogramLoadingChange?: (loading: boolean) => void; } interface CursorPosition { beat: number; pitch: number; x: number; y: number; } const PianoGrid: React.FC = ({ gridRef, children, onDoubleClick, onClick, onMouseDown, isBoxSelecting, selectionBox, regionStartBeat = 0, selectedMode, keySignature, chordGuide, audioRegion, trackId, projectName, bpm = 120, spectrogramThresholdDb = -25, spectrogramPower = 0.5, spectrogramHeightResolution = 3, pianoRollZoom = 1, mode = 'midi-edit', onSpectrogramLoadingChange, }) => { const [cursorPosition, setCursorPosition] = useState(null); const [isModifierPressed, setIsModifierPressed] = useState(false); const [selectedChordIndex, setSelectedChordIndex] = useState(0); // Generate background with scale highlighting - only regenerate when mode or key changes const backgroundImage = useMemo(() => { return generatePianoGridBackground(selectedMode, keySignature); }, [selectedMode, keySignature]); // Track modifier key state useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Skip if user is typing in an input field (including ChatBox) const target = e.target as HTMLElement; if (target && ( target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.contentEditable === 'true' || target.hasAttribute('data-chatbox-input') || target.closest('.chatbox-input') )) { return; } if (isModifierKeyPressed(e)) { setIsModifierPressed(true); } }; const handleKeyUp = (e: KeyboardEvent) => { // Skip if user is typing in an input field (including ChatBox) const target = e.target as HTMLElement; if (target && ( target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.contentEditable === 'true' || target.hasAttribute('data-chatbox-input') || target.closest('.chatbox-input') )) { return; } if (!isModifierKeyPressed(e)) { setIsModifierPressed(false); } }; // Add global event listeners window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); // Cleanup listeners on unmount return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); }; }, []); const handleMouseMove = (e: React.MouseEvent) => { if (!gridRef.current) return; const rect = gridRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // Get CSS variables 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 beat and pitch const beat = Math.floor(x / beatWidth); const pitch = 107 - Math.floor(y / noteHeight); // B7 = 107, reverse for display // Only update if position changed and cursor is within valid range if (beat >= 0 && pitch >= 0 && pitch <= 127) { setCursorPosition({ beat, pitch, x, y }); } }; const handleMouseLeave = () => { setCursorPosition(null); }; // Get all matching chords for the current hover position const matchingChords = useMemo(() => { if (!cursorPosition) return []; // If chord guide is disabled, return empty array if (chordGuide === 'N') return []; // Use the utility function to get matching chords const functionType = chordGuide as 'T' | 'S' | 'D'; return getMatchingChordsForPitch(cursorPosition.pitch, keySignature, selectedMode, functionType); }, [cursorPosition, chordGuide, keySignature, selectedMode]); // Calculate chord highlights based on selected chord index const chordHighlights = useMemo(() => { if (matchingChords.length === 0) return []; // Use the selected chord index (wrap around if needed) const chordIndex = selectedChordIndex % matchingChords.length; const matchedChordPitches = matchingChords[chordIndex]; // Convert pitch classes to actual pitches in the same octave as cursor const highlights: Array<{ pitch: number; beat: number }> = []; const cursorOctave = Math.floor(cursorPosition!.pitch / 12); // For each pitch class in the chord, create highlights for (const pitchClass of matchedChordPitches) { // Calculate the actual pitch in the cursor's octave const actualPitch = cursorOctave * 12 + pitchClass; // Ensure the pitch is in valid MIDI range (0-127) if (actualPitch >= 0 && actualPitch <= 127) { highlights.push({ pitch: actualPitch, beat: cursorPosition!.beat }); } } return highlights; }, [cursorPosition, matchingChords, selectedChordIndex]); const cursorPitch = cursorPosition?.pitch ?? null; useEffect(() => { const pianoRollState = KGPianoRollState.instance(); pianoRollState.setCurrentMatchingChords(matchingChords); pianoRollState.setCurrentChordCursorPitch(cursorPitch); }, [matchingChords, cursorPitch]); useEffect(() => { KGPianoRollState.instance().setCurrentSelectedChordIndex(selectedChordIndex); }, [selectedChordIndex]); const lastEditedNoteLength = KGPianoRollState.instance().getLastEditedNoteLength(); // Reset selected chord index when cursor moves to a different pitch or beat useEffect(() => { setSelectedChordIndex(0); }, [cursorPosition?.pitch, cursorPosition?.beat]); // Expose switchChord function via window for hotkey handler useEffect(() => { const switchChord = () => { if (matchingChords.length > 1) { setSelectedChordIndex(prev => (prev + 1) % matchingChords.length); } }; // Store the function on window object so PianoRoll can call it // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).__pianoGridSwitchChord = switchChord; return () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (window as any).__pianoGridSwitchChord; }; }, [matchingChords.length]); return (
onMouseDown(e)} onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} > {/* Spectrogram layer — rendered at z-index 0, behind all highlights and notes */} {audioRegion && mode === 'audio-waveform' && trackId && projectName && ( )} {audioRegion && (mode === 'spectrogram' || mode === 'hybrid') && trackId && projectName && ( )} {/* Cursor Highlights */} {cursorPosition && ( <> {/* Horizontal pitch row highlight */}
{/* Vertical beat column highlight */}
{/* Chord highlights - render red boxes for each note in the matched chord */} {chordHighlights.map((highlight, index) => { const noteHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20; const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40; const yPosition = (107 - highlight.pitch) * noteHeight; // B7 = 107, reverse for display return (
); })} )} {/* Playhead */} {children}
); }; export default PianoGrid;