Files
KGStudio/src/components/piano-roll/PianoGrid.tsx
T
2026-05-26 23:23:01 -07:00

324 lines
11 KiB
TypeScript

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<HTMLDivElement | null>;
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<PianoGridProps> = ({
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<CursorPosition | null>(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 (
<div className="piano-grid-container">
<div
className={`piano-grid ${isModifierPressed ? 'pencil-cursor' : ''}`}
ref={gridRef}
style={{ backgroundImage: audioRegion ? undefined : backgroundImage }}
onDoubleClick={onDoubleClick}
onClick={onClick}
onMouseDown={(e) => onMouseDown(e)}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
{/* Spectrogram layer — rendered at z-index 0, behind all highlights and notes */}
{audioRegion && mode === 'audio-waveform' && trackId && projectName && (
<AudioWaveformCanvas
audioRegion={audioRegion}
trackId={trackId}
projectName={projectName}
zoom={pianoRollZoom}
/>
)}
{audioRegion && (mode === 'spectrogram' || mode === 'hybrid') && trackId && projectName && (
<SpectrogramCanvas
audioRegion={audioRegion}
trackId={trackId}
projectName={projectName}
bpm={bpm}
thresholdDb={spectrogramThresholdDb}
power={spectrogramPower}
zoom={pianoRollZoom}
heightResolution={spectrogramHeightResolution}
onLoadingChange={onSpectrogramLoadingChange}
/>
)}
{/* Cursor Highlights */}
{cursorPosition && (
<>
{/* Horizontal pitch row highlight */}
<div
className="piano-grid-pitch-highlight"
style={{
top: Math.floor(cursorPosition.y / (parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20)) * (parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20),
height: parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20
}}
/>
{/* Vertical beat column highlight */}
<div
className="piano-grid-beat-highlight"
style={{
left: cursorPosition.beat * (parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40),
width: parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40
}}
/>
{/* 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 (
<div
key={`chord-highlight-${index}`}
className="piano-grid-chord-highlight"
style={{
top: yPosition,
left: highlight.beat * beatWidth,
width: beatWidth * lastEditedNoteLength,
height: noteHeight
}}
/>
);
})}
</>
)}
{/* Playhead */}
<Playhead context="piano-roll" regionStartBeat={regionStartBeat} />
{children}
<SelectionBox
isSelecting={isBoxSelecting}
selectionBox={selectionBox}
/>
</div>
</div>
);
};
export default PianoGrid;