initial public release.
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { Playhead } from '../common';
|
||||
import SelectionBox from './SelectionBox';
|
||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface CursorPosition {
|
||||
beat: number;
|
||||
pitch: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const PianoGrid: React.FC<PianoGridProps> = ({
|
||||
gridRef,
|
||||
children,
|
||||
onDoubleClick,
|
||||
onClick,
|
||||
onMouseDown,
|
||||
isBoxSelecting,
|
||||
selectionBox,
|
||||
regionStartBeat = 0
|
||||
}) => {
|
||||
const [cursorPosition, setCursorPosition] = useState<CursorPosition | null>(null);
|
||||
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="piano-grid-container">
|
||||
<div
|
||||
className={`piano-grid ${isModifierPressed ? 'pencil-cursor' : ''}`}
|
||||
ref={gridRef}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onClick={onClick}
|
||||
onMouseDown={(e) => onMouseDown(e)}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* 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
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Playhead */}
|
||||
<Playhead context="piano-roll" regionStartBeat={regionStartBeat} />
|
||||
|
||||
{children}
|
||||
<SelectionBox
|
||||
isSelecting={isBoxSelecting}
|
||||
selectionBox={selectionBox}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PianoGrid;
|
||||
@@ -0,0 +1,180 @@
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { DEBUG_MODE } from '../../constants';
|
||||
|
||||
interface PianoGridHeaderProps {
|
||||
maxBars: number;
|
||||
timeSignature?: { numerator: number; denominator: number };
|
||||
}
|
||||
|
||||
const PianoGridHeader: React.FC<PianoGridHeaderProps> = ({
|
||||
maxBars,
|
||||
timeSignature = { numerator: 4, denominator: 4 } // Default to 4/4 if not provided
|
||||
}) => {
|
||||
// Get store access for playhead position updates
|
||||
const { setPlayheadPosition } = useProjectStore();
|
||||
|
||||
// Refs for drag functionality
|
||||
const isDraggingRef = useRef(false);
|
||||
const headerElementRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Utility function to calculate snapped beat position (based on useNoteOperations.ts)
|
||||
const getSnappedBeatPosition = (beatPosition: number): 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
|
||||
// snapStep should ALWAYS be 4 / denominator regardless of time signature
|
||||
const snapStep = 4 / denominator;
|
||||
|
||||
// Use round snapping for playhead positioning
|
||||
const snappedPosition = Math.round(beatPosition / snapStep) * snapStep;
|
||||
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`Piano Grid Header Snapping: ${beatPosition} -> ${snappedPosition} (snap: ${currentSnap}, step: ${snapStep})`);
|
||||
}
|
||||
|
||||
return snappedPosition;
|
||||
};
|
||||
|
||||
// Utility function to calculate playhead position from mouse coordinates
|
||||
const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => {
|
||||
if (!headerElementRef.current) return null;
|
||||
|
||||
const rect = headerElementRef.current.getBoundingClientRect();
|
||||
const relativeX = clientX - rect.left;
|
||||
|
||||
// Account for the piano keys width offset
|
||||
const pianoKeysWidth = parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
|
||||
) || 60;
|
||||
|
||||
const adjustedX = relativeX - pianoKeysWidth;
|
||||
|
||||
// If the click is in the piano keys area (left side), ignore it
|
||||
if (adjustedX < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate the width of each beat
|
||||
const beatWidth = parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
|
||||
) || 40;
|
||||
|
||||
// Calculate the raw beat position using the adjusted X position
|
||||
const rawBeatPosition = adjustedX / beatWidth;
|
||||
|
||||
// Apply quantization if enabled
|
||||
return getSnappedBeatPosition(rawBeatPosition);
|
||||
}, []);
|
||||
|
||||
// Handle mouse down to start dragging
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Only handle left mouse button
|
||||
if (e.button !== 0) return;
|
||||
|
||||
isDraggingRef.current = true;
|
||||
|
||||
// Calculate and set initial playhead position
|
||||
const newPosition = calculatePlayheadFromMouse(e.clientX);
|
||||
if (newPosition !== null) {
|
||||
setPlayheadPosition(newPosition);
|
||||
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`Piano Grid Header drag started - Initial position: ${newPosition}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent text selection during drag
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// Handle click (when not dragging) - this will be the fallback for simple clicks
|
||||
const handlePianoGridHeaderClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// If we were dragging, don't process as a click
|
||||
if (isDraggingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newPosition = calculatePlayheadFromMouse(e.clientX);
|
||||
if (newPosition !== null) {
|
||||
const core = KGCore.instance();
|
||||
const currentPlayheadPosition = core.getPlayheadPosition();
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const currentBarNumber = Math.floor(currentPlayheadPosition / beatsPerBar) + 1; // 1-indexed
|
||||
const destinationBarNumber = Math.floor(newPosition / beatsPerBar) + 1; // 1-indexed
|
||||
|
||||
// Debug logging
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`Piano Grid Header click - Position: ${newPosition}`);
|
||||
console.log(`Current bar: ${currentBarNumber} (beat ${currentPlayheadPosition})`);
|
||||
console.log(`Destination bar: ${destinationBarNumber} (beat ${newPosition})`);
|
||||
}
|
||||
|
||||
setPlayheadPosition(newPosition);
|
||||
}
|
||||
};
|
||||
|
||||
// Global mouse move and mouse up handlers
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDraggingRef.current) return;
|
||||
|
||||
const newPosition = calculatePlayheadFromMouse(e.clientX);
|
||||
if (newPosition !== null) {
|
||||
setPlayheadPosition(newPosition);
|
||||
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`Piano Grid Header drag - Position: ${newPosition}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (isDraggingRef.current) {
|
||||
isDraggingRef.current = false;
|
||||
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log('Piano Grid Header drag ended');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add global event listeners for drag functionality
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
// Cleanup event listeners on unmount
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [calculatePlayheadFromMouse, setPlayheadPosition]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="piano-grid-header"
|
||||
ref={headerElementRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handlePianoGridHeaderClick}
|
||||
>
|
||||
{Array.from({ length: maxBars }, (_, i) => (
|
||||
<div key={i} className="piano-bar-number">{i + 1}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PianoGridHeader;
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
import { noteNameToPitch, midiPercussionKeyMap, pitchToNoteNameString } from '../../util/midiUtil';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
|
||||
interface PianoKeysProps {
|
||||
activeRegion: KGMidiRegion | null;
|
||||
}
|
||||
|
||||
const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
||||
const [pressedKeys, setPressedKeys] = useState<Set<string>>(new Set());
|
||||
const pressedKeysRef = useRef<Set<string>>(new Set());
|
||||
const { tracks } = useProjectStore();
|
||||
|
||||
// Check if current active region belongs to a drum track
|
||||
const isDrumTrack = React.useMemo(() => {
|
||||
if (!activeRegion) return false;
|
||||
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
|
||||
return track instanceof KGMidiTrack && track.getInstrument() === 'standard';
|
||||
}, [activeRegion, tracks]);
|
||||
|
||||
// Handle mouse down on piano key
|
||||
const handleKeyMouseDown = (keyId: string) => {
|
||||
// Prevent double pressing the same key
|
||||
if (pressedKeysRef.current.has(keyId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the track ID from active region
|
||||
if (!activeRegion) {
|
||||
console.warn('No active region, cannot play piano key');
|
||||
return;
|
||||
}
|
||||
|
||||
const trackId = activeRegion.getTrackId();
|
||||
|
||||
try {
|
||||
// Convert note name to pitch (keyId is always a note name like "C4")
|
||||
const pitch = noteNameToPitch(keyId);
|
||||
|
||||
// Get audio interface and start playing the note
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
if (audioInterface.getIsInitialized()) {
|
||||
// Try to start audio context if not started yet
|
||||
if (!audioInterface.getIsAudioContextStarted()) {
|
||||
audioInterface.startAudioContext().catch(() => {
|
||||
// Silently fail if still not allowed - browser policy
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger note attack if audio context is ready
|
||||
if (audioInterface.getIsAudioContextStarted()) {
|
||||
audioInterface.triggerNoteAttack(trackId, pitch, 127);
|
||||
|
||||
// Update pressed keys state
|
||||
const newPressedKeys = new Set(pressedKeysRef.current);
|
||||
newPressedKeys.add(keyId);
|
||||
pressedKeysRef.current = newPressedKeys;
|
||||
setPressedKeys(newPressedKeys);
|
||||
|
||||
console.log(`Started playing piano key: ${keyId} (pitch ${pitch})`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error playing piano key ${keyId}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle mouse up on piano key
|
||||
const handleKeyMouseUp = (keyId: string) => {
|
||||
// Only release if key was actually pressed
|
||||
if (!pressedKeysRef.current.has(keyId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the track ID from active region
|
||||
if (!activeRegion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trackId = activeRegion.getTrackId();
|
||||
|
||||
try {
|
||||
// Convert note name to pitch (keyId is always a note name like "C4")
|
||||
const pitch = noteNameToPitch(keyId);
|
||||
|
||||
// Get audio interface and stop playing the note
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
if (audioInterface.getIsInitialized() && audioInterface.getIsAudioContextStarted()) {
|
||||
audioInterface.releaseNote(trackId, pitch);
|
||||
|
||||
// Update pressed keys state
|
||||
const newPressedKeys = new Set(pressedKeysRef.current);
|
||||
newPressedKeys.delete(keyId);
|
||||
pressedKeysRef.current = newPressedKeys;
|
||||
setPressedKeys(newPressedKeys);
|
||||
|
||||
console.log(`Stopped playing piano key: ${keyId} (pitch ${pitch})`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error releasing piano key ${keyId}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle mouse leave to ensure keys are released
|
||||
const handleKeyMouseLeave = (keyId: string) => {
|
||||
handleKeyMouseUp(keyId);
|
||||
};
|
||||
|
||||
// Generate piano keys (C0 to C7)
|
||||
const generatePianoKeys = () => {
|
||||
const octaves = [];
|
||||
const notes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
|
||||
// Generate octaves from C0 to C7
|
||||
for (let octave = 0; octave <= 7; octave++) {
|
||||
const octaveKeys = [];
|
||||
|
||||
// Add keys in reverse order (B to C) for each octave
|
||||
for (let i = notes.length - 1; i >= 0; i--) {
|
||||
const note = notes[i];
|
||||
const isSharp = note.includes('#');
|
||||
const keyId = `${note}${octave}`;
|
||||
const isPressed = pressedKeys.has(keyId);
|
||||
const keyClass = `piano-key ${isSharp ? 'sharp' : 'natural'} ${isPressed ? 'pressed' : ''}`;
|
||||
const isC = note === 'C';
|
||||
|
||||
// For drum tracks, show drum labels when available
|
||||
let labelContent = null;
|
||||
if (isDrumTrack) {
|
||||
const pitch = noteNameToPitch(keyId);
|
||||
const drumInfo = midiPercussionKeyMap[pitch];
|
||||
if (drumInfo) {
|
||||
labelContent = <span className="key-label">{drumInfo.shortName}</span>;
|
||||
}
|
||||
} else if (isC) {
|
||||
labelContent = <span className="key-label">C{octave}</span>;
|
||||
}
|
||||
|
||||
octaveKeys.push(
|
||||
<div
|
||||
key={keyId}
|
||||
className={keyClass}
|
||||
data-note={keyId}
|
||||
onMouseDown={() => handleKeyMouseDown(keyId)}
|
||||
onMouseUp={() => handleKeyMouseUp(keyId)}
|
||||
onMouseLeave={() => handleKeyMouseLeave(keyId)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none' // Prevent text selection
|
||||
}}
|
||||
>
|
||||
{labelContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add each octave to the beginning of the array
|
||||
octaves.unshift(
|
||||
<div key={`octave-${octave}`} className="piano-octave">
|
||||
{octaveKeys}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return octaves;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="piano-keys-container">
|
||||
{generatePianoKeys()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PianoKeys;
|
||||
@@ -0,0 +1,268 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { PIANO_ROLL_CONSTANTS, DEBUG_MODE } from '../../constants';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
|
||||
interface PianoNoteProps {
|
||||
id: string;
|
||||
index: number;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
onResizeStart?: (noteId: string, resizeEdge: 'start' | 'end', initialX: number) => void;
|
||||
onResize?: (noteId: string, resizeEdge: 'start' | 'end', deltaX: number) => void;
|
||||
onResizeEnd?: (noteId: string, resizeEdge: 'start' | 'end') => void;
|
||||
onDragStart?: (noteId: string, initialX: number, initialY: number) => void;
|
||||
onDrag?: (noteId: string, deltaX: number, deltaY: number) => void;
|
||||
onDragEnd?: (noteId: string) => void;
|
||||
onClick?: (noteId: string, e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const PianoNote: React.FC<PianoNoteProps> = ({
|
||||
id,
|
||||
index,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
onResizeStart,
|
||||
onResize,
|
||||
onResizeEnd,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
onDragEnd,
|
||||
onClick
|
||||
}) => {
|
||||
// Get selection state from store
|
||||
const { selectedNoteIds } = useProjectStore();
|
||||
const isSelected = selectedNoteIds.includes(id);
|
||||
|
||||
const [cursor, setCursor] = useState<string>('pointer');
|
||||
const [resizeEdge, setResizeEdge] = useState<'none' | 'start' | 'end'>('none');
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Use refs to track states for immediate access
|
||||
const isResizingRef = useRef<boolean>(false);
|
||||
const isDraggingRef = useRef<boolean>(false);
|
||||
const initialMousePosRef = useRef<{x: number, y: number}>({x: 0, y: 0});
|
||||
const hasMovedRef = useRef<boolean>(false);
|
||||
|
||||
// Handle mouse movement to detect edge proximity
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Skip if already resizing or dragging
|
||||
if (isResizingRef.current || isDraggingRef.current) return;
|
||||
|
||||
const noteElement = e.currentTarget;
|
||||
const rect = noteElement.getBoundingClientRect();
|
||||
|
||||
// Calculate distance from left and right edges
|
||||
const distanceFromLeft = e.clientX - rect.left;
|
||||
const distanceFromRight = rect.right - e.clientX;
|
||||
|
||||
// Use the edge threshold constant from constants file
|
||||
const edgeThreshold = PIANO_ROLL_CONSTANTS.NOTE_EDGE_OFFSET;
|
||||
|
||||
if (distanceFromLeft <= edgeThreshold) {
|
||||
// Near left edge - resize from start
|
||||
setCursor('ew-resize');
|
||||
setResizeEdge('start');
|
||||
} else if (distanceFromRight <= edgeThreshold) {
|
||||
// Near right edge - resize from end
|
||||
setCursor('ew-resize');
|
||||
setResizeEdge('end');
|
||||
} else {
|
||||
// Middle area - move
|
||||
setCursor('grab');
|
||||
setResizeEdge('none');
|
||||
}
|
||||
};
|
||||
|
||||
// Reset cursor when mouse leaves
|
||||
const handleMouseLeave = () => {
|
||||
if (!isResizingRef.current && !isDraggingRef.current) {
|
||||
setCursor('default');
|
||||
setResizeEdge('none');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle mouse down for resize or drag
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Prevent text selection during resize/drag
|
||||
e.preventDefault();
|
||||
|
||||
// Reset movement tracking
|
||||
hasMovedRef.current = false;
|
||||
|
||||
// Store initial mouse position
|
||||
initialMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
|
||||
if (resizeEdge !== 'none') {
|
||||
// Start resizing
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`NOTE RESIZE START: noteId=${id}, edge=${resizeEdge}`);
|
||||
}
|
||||
|
||||
setIsResizing(true);
|
||||
isResizingRef.current = true;
|
||||
|
||||
// Call the onResizeStart callback if provided
|
||||
if (onResizeStart && (resizeEdge === 'start' || resizeEdge === 'end')) {
|
||||
onResizeStart(id, resizeEdge, e.clientX);
|
||||
}
|
||||
} else {
|
||||
// Start dragging
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`NOTE DRAG START: noteId=${id}`);
|
||||
}
|
||||
|
||||
setIsDragging(true);
|
||||
isDraggingRef.current = true;
|
||||
|
||||
// Change cursor to grabbing during drag
|
||||
setCursor('grabbing');
|
||||
|
||||
// Call the onDragStart callback if provided
|
||||
if (onDragStart) {
|
||||
onDragStart(id, e.clientX, e.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
// Add global event listeners for mouse move and up
|
||||
document.addEventListener('mousemove', handleGlobalMouseMove);
|
||||
document.addEventListener('mouseup', handleGlobalMouseUp);
|
||||
};
|
||||
|
||||
// Handle global mouse move for resize or drag
|
||||
const handleGlobalMouseMove = (e: MouseEvent) => {
|
||||
// Set the hasMovedRef to true as soon as there's movement
|
||||
hasMovedRef.current = true;
|
||||
|
||||
if (isResizingRef.current) {
|
||||
// Handle resize
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`NOTE RESIZE MOVE: noteId=${id}, edge=${resizeEdge}`);
|
||||
}
|
||||
|
||||
// Calculate delta from initial position
|
||||
const deltaX = e.clientX - initialMousePosRef.current.x;
|
||||
|
||||
// Call the onResize callback if provided
|
||||
if (onResize && (resizeEdge === 'start' || resizeEdge === 'end')) {
|
||||
onResize(id, resizeEdge, deltaX);
|
||||
}
|
||||
} else if (isDraggingRef.current) {
|
||||
// Handle drag
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`NOTE DRAG MOVE: noteId=${id}`);
|
||||
}
|
||||
|
||||
// Calculate delta from initial position
|
||||
const deltaX = e.clientX - initialMousePosRef.current.x;
|
||||
const deltaY = e.clientY - initialMousePosRef.current.y;
|
||||
|
||||
// Call the onDrag callback if provided
|
||||
if (onDrag) {
|
||||
onDrag(id, deltaX, deltaY);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle global mouse up to end resize or drag
|
||||
const handleGlobalMouseUp = (e: MouseEvent) => {
|
||||
if (isResizingRef.current) {
|
||||
// End resizing
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`NOTE RESIZE END: noteId=${id}`);
|
||||
}
|
||||
|
||||
setIsResizing(false);
|
||||
isResizingRef.current = false;
|
||||
|
||||
// Call the onResizeEnd callback if provided
|
||||
if (onResizeEnd && (resizeEdge === 'start' || resizeEdge === 'end')) {
|
||||
onResizeEnd(id, resizeEdge);
|
||||
}
|
||||
} else if (isDraggingRef.current) {
|
||||
// End dragging
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`NOTE DRAG END: noteId=${id}`);
|
||||
}
|
||||
|
||||
setIsDragging(false);
|
||||
isDraggingRef.current = false;
|
||||
|
||||
// Reset cursor after drag
|
||||
setCursor('grab');
|
||||
|
||||
// Call the onDragEnd callback if provided
|
||||
if (onDragEnd) {
|
||||
onDragEnd(id);
|
||||
}
|
||||
|
||||
// If there was no movement, treat it as a click
|
||||
if (!hasMovedRef.current && onClick) {
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`NOTE CLICKED: noteId=${id}`);
|
||||
}
|
||||
// We can't pass the original event here since it's a MouseEvent, not a React.MouseEvent
|
||||
// But we can create a synthetic event with the current mouse position
|
||||
const clickEvent = {
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
target: e.target,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
shiftKey: e.shiftKey // Pass the shift key state
|
||||
} as unknown as React.MouseEvent;
|
||||
|
||||
onClick(id, clickEvent);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove global event listeners
|
||||
document.removeEventListener('mousemove', handleGlobalMouseMove);
|
||||
document.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
};
|
||||
|
||||
// Clean up event listeners on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleGlobalMouseMove);
|
||||
document.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keep the refs in sync with the states
|
||||
useEffect(() => {
|
||||
isResizingRef.current = isResizing;
|
||||
}, [isResizing]);
|
||||
|
||||
useEffect(() => {
|
||||
isDraggingRef.current = isDragging;
|
||||
}, [isDragging]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`piano-note ${isDragging ? 'dragging' : ''} ${isResizing ? 'resizing' : ''} ${isSelected ? 'selected' : ''}`}
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
cursor: cursor
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseDown={handleMouseDown}
|
||||
id={id}
|
||||
data-note-index={index}
|
||||
data-resize-edge={resizeEdge}
|
||||
data-is-resizing={isResizing}
|
||||
data-is-dragging={isDragging}
|
||||
data-is-selected={isSelected}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PianoNote;
|
||||
@@ -0,0 +1,793 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { FaGripLines } from 'react-icons/fa';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { DEBUG_MODE, PIANO_ROLL_CONSTANTS } from '../../constants';
|
||||
import PianoRollHeader from './PianoRollHeader';
|
||||
import PianoRollToolbar from './PianoRollToolbar';
|
||||
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';
|
||||
|
||||
interface PianoRollProps {
|
||||
onClose: () => void;
|
||||
regionId: string | null;
|
||||
initialPosition?: { x: number; y: number };
|
||||
initialSize?: { width: number; height: number };
|
||||
}
|
||||
|
||||
const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
onClose,
|
||||
regionId,
|
||||
initialPosition,
|
||||
initialSize
|
||||
}) => {
|
||||
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection } = useProjectStore();
|
||||
|
||||
// Tool state for piano roll
|
||||
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
|
||||
|
||||
// Quantization state
|
||||
const [quantPosition, setQuantPosition] = useState<string>('1/8');
|
||||
const [quantLength, setQuantLength] = useState<string>('1/8');
|
||||
|
||||
// Snapping state
|
||||
const [snapping, setSnapping] = useState<string>('NO SNAP');
|
||||
|
||||
// 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<string | null>(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<KGMidiRegion | null>(null);
|
||||
const pianoRollRef = useRef<HTMLDivElement>(null);
|
||||
const pianoRollContentRef = useRef<HTMLDivElement>(null);
|
||||
const pianoGridRef = useRef<HTMLDivElement>(null);
|
||||
const wasDraggingRef = useRef<boolean>(false);
|
||||
|
||||
// Ref for storing the setNoteUpdateCounter function
|
||||
const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | 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) 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;
|
||||
}
|
||||
|
||||
// Find the region in the tracks
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === regionId);
|
||||
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
setActiveRegion(region);
|
||||
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`Active region set in PianoRoll: ${region.getId()}`);
|
||||
console.log(`Region details: name=${region.getName()}, trackId=${region.getTrackId()}, trackIndex=${region.getTrackIndex()}`);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [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
|
||||
|
||||
// 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 = () => {
|
||||
// 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 = window.prompt("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);
|
||||
// Optionally show user-friendly error message
|
||||
alert('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}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handler for receiving the setNoteUpdateCounter function from PianoRollContent
|
||||
const handleSetNoteUpdateTrigger = (setNoteFn: React.Dispatch<React.SetStateAction<number>>) => {
|
||||
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]);
|
||||
|
||||
// Calculate C4 position and scroll to it when piano roll opens
|
||||
useEffect(() => {
|
||||
if (pianoRollContentRef.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 = pianoRollContentRef.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
|
||||
pianoRollContentRef.current.scrollTop = Math.max(0, scrollPosition);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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()) {
|
||||
// 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 (!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 (
|
||||
<div
|
||||
className="piano-roll-panel"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: `${size.width}px`,
|
||||
height: `${size.height}px`,
|
||||
zIndex: 2000
|
||||
}}
|
||||
ref={pianoRollRef}
|
||||
>
|
||||
<PianoRollHeader
|
||||
onClose={onClose}
|
||||
title={getPianoRollTitle()}
|
||||
onTitleClick={handleTitleClick}
|
||||
onMouseDown={(e) => handleMouseDown(e, 'drag')}
|
||||
/>
|
||||
|
||||
<PianoRollToolbar
|
||||
activeTool={activeTool}
|
||||
onToolSelect={handleToolSelect}
|
||||
quantPosition={quantPosition}
|
||||
quantLength={quantLength}
|
||||
onQuantSelect={handleQuantSelect}
|
||||
snapping={snapping}
|
||||
onSnappingSelect={handleSnappingSelect}
|
||||
blinkButton={blinkButton}
|
||||
/>
|
||||
|
||||
<PianoRollContent
|
||||
contentRef={pianoRollContentRef}
|
||||
pianoGridRef={pianoGridRef}
|
||||
maxBars={maxBars}
|
||||
timeSignature={timeSignature}
|
||||
activeRegion={activeRegion}
|
||||
updateTrack={updateTrack}
|
||||
tracks={tracks}
|
||||
onSetNoteUpdateTrigger={handleSetNoteUpdateTrigger}
|
||||
onSetDeleteNotesTrigger={handleSetDeleteNotesTrigger}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="resize-handle"
|
||||
onMouseDown={(e) => handleMouseDown(e, 'resize')}
|
||||
>
|
||||
<FaGripLines />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PianoRoll;
|
||||
@@ -0,0 +1,238 @@
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { DEBUG_MODE } 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 PianoNote from './PianoNote';
|
||||
import PianoKeys from './PianoKeys';
|
||||
import PianoGridHeader from './PianoGridHeader';
|
||||
import PianoGrid from './PianoGrid';
|
||||
import { useNoteOperations } from '../../hooks/useNoteOperations';
|
||||
import { useNoteSelection } from '../../hooks/useNoteSelection';
|
||||
|
||||
interface PianoRollContentProps {
|
||||
contentRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
pianoGridRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
maxBars: number;
|
||||
timeSignature: { numerator: number; denominator: number };
|
||||
activeRegion: KGMidiRegion | null;
|
||||
updateTrack: (track: KGTrack) => void;
|
||||
tracks: KGTrack[];
|
||||
onSetNoteUpdateTrigger?: (setNoteFn: React.Dispatch<React.SetStateAction<number>>) => void;
|
||||
onSetDeleteNotesTrigger?: (deleteFn: () => boolean) => void;
|
||||
}
|
||||
|
||||
const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
||||
contentRef,
|
||||
pianoGridRef,
|
||||
maxBars,
|
||||
timeSignature,
|
||||
activeRegion,
|
||||
updateTrack,
|
||||
tracks,
|
||||
onSetNoteUpdateTrigger,
|
||||
onSetDeleteNotesTrigger
|
||||
}) => {
|
||||
// Get KGCore instance
|
||||
const core = KGCore.instance();
|
||||
|
||||
// Use the note operations hook for resize and drag functionality
|
||||
const {
|
||||
resizingNoteId,
|
||||
draggingNoteId,
|
||||
tempNoteStyles,
|
||||
noteUpdateCounter,
|
||||
setNoteUpdateCounter,
|
||||
handleGridDoubleClick,
|
||||
handleGridClick,
|
||||
handleNoteResizeStart,
|
||||
handleNoteResize,
|
||||
handleNoteResizeEnd,
|
||||
handleNoteDragStart,
|
||||
handleNoteDrag,
|
||||
handleNoteDragEnd,
|
||||
deleteSelectedNotes
|
||||
} = useNoteOperations({
|
||||
activeRegion,
|
||||
timeSignature,
|
||||
updateTrack,
|
||||
tracks,
|
||||
pianoGridRef
|
||||
});
|
||||
|
||||
// Expose setNoteUpdateCounter to parent component
|
||||
useEffect(() => {
|
||||
if (onSetNoteUpdateTrigger) {
|
||||
onSetNoteUpdateTrigger(setNoteUpdateCounter);
|
||||
}
|
||||
}, [onSetNoteUpdateTrigger, setNoteUpdateCounter]);
|
||||
|
||||
// Expose deleteSelectedNotes to parent component
|
||||
useEffect(() => {
|
||||
if (onSetDeleteNotesTrigger) {
|
||||
onSetDeleteNotesTrigger(deleteSelectedNotes);
|
||||
}
|
||||
}, [onSetDeleteNotesTrigger, deleteSelectedNotes]);
|
||||
|
||||
// Use the note selection hook for selection functionality
|
||||
const {
|
||||
selectedNoteIds,
|
||||
isBoxSelectingRef,
|
||||
selectionBoxRef,
|
||||
selectionBoxRender,
|
||||
handleNoteClick,
|
||||
handleBackgroundClick,
|
||||
handleBackgroundMouseDown,
|
||||
cleanupSelectionListeners
|
||||
} = useNoteSelection({
|
||||
activeRegion,
|
||||
updateTrack,
|
||||
tracks
|
||||
});
|
||||
|
||||
// Combined click handler for both pointer and pencil modes
|
||||
const handleCombinedClick = (e: React.MouseEvent) => {
|
||||
// Handle selection click (pointer mode)
|
||||
handleBackgroundClick(e);
|
||||
// Handle pencil mode note creation
|
||||
handleGridClick(e);
|
||||
};
|
||||
|
||||
// Sync selected notes with KGCore on mount and when selection changes
|
||||
useEffect(() => {
|
||||
if (!activeRegion) return;
|
||||
|
||||
// Get currently selected items from KGCore
|
||||
const selectedItems = core.getSelectedItems();
|
||||
|
||||
// Filter for KGMidiNote items that belong to this region
|
||||
const selectedNotes = selectedItems.filter(item =>
|
||||
item instanceof KGMidiNote &&
|
||||
activeRegion.getNotes().some(note => note.getId() === item.getId())
|
||||
) as KGMidiNote[];
|
||||
|
||||
// Make sure the note objects have the correct selection state
|
||||
activeRegion.getNotes().forEach(note => {
|
||||
const isSelected = selectedNotes.some(selectedNote => selectedNote.getId() === note.getId());
|
||||
if (isSelected && !note.isSelected()) {
|
||||
note.select();
|
||||
} else if (!isSelected && note.isSelected()) {
|
||||
note.deselect();
|
||||
}
|
||||
});
|
||||
}, [activeRegion]);
|
||||
|
||||
// Clean up event listeners on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupSelectionListeners();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Memoize the notes rendering to prevent unnecessary recalculations
|
||||
const memoizedNotes = useMemo(() => {
|
||||
if (!activeRegion) return null;
|
||||
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`Rendering notes for region: ${activeRegion.getId()}`);
|
||||
console.log(`Number of notes: ${activeRegion.getNotes().length}`);
|
||||
console.log(`Note update counter: ${noteUpdateCounter}`);
|
||||
console.log(`Selected notes: ${Array.from(selectedNoteIds).join(', ')}`);
|
||||
}
|
||||
|
||||
const notes = activeRegion.getNotes();
|
||||
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();
|
||||
|
||||
return notes.map((note, index) => {
|
||||
// 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)
|
||||
// We need to find the index of the pitch in our piano roll
|
||||
const pitchIndex = 107 - pitch; // Reverse the pitch to get the index (B7 is 107)
|
||||
|
||||
// Calculate position and dimensions
|
||||
const left = startBeat * beatWidth;
|
||||
const top = pitchIndex * noteHeight;
|
||||
const width = (endBeat - startBeat) * beatWidth;
|
||||
|
||||
const noteId = note.getId();
|
||||
|
||||
// Check if this note is being resized or dragged and has a temporary style
|
||||
if ((resizingNoteId === noteId || draggingNoteId === noteId) && tempNoteStyles[noteId]) {
|
||||
// Use the temporary style for position and size
|
||||
const tempStyle = tempNoteStyles[noteId];
|
||||
|
||||
return (
|
||||
<PianoNote
|
||||
key={`note-${noteId}`}
|
||||
id={noteId}
|
||||
index={index}
|
||||
left={parseFloat(tempStyle.left as string)}
|
||||
top={parseFloat(tempStyle.top as string)}
|
||||
width={parseFloat(tempStyle.width as string)}
|
||||
height={noteHeight}
|
||||
onResizeStart={handleNoteResizeStart}
|
||||
onResize={handleNoteResize}
|
||||
onResizeEnd={handleNoteResizeEnd}
|
||||
onDragStart={handleNoteDragStart}
|
||||
onDrag={handleNoteDrag}
|
||||
onDragEnd={handleNoteDragEnd}
|
||||
onClick={handleNoteClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PianoNote
|
||||
key={`note-${noteId}`}
|
||||
id={noteId}
|
||||
index={index}
|
||||
left={left}
|
||||
top={top}
|
||||
width={width}
|
||||
height={noteHeight}
|
||||
onResizeStart={handleNoteResizeStart}
|
||||
onResize={handleNoteResize}
|
||||
onResizeEnd={handleNoteResizeEnd}
|
||||
onDragStart={handleNoteDragStart}
|
||||
onDrag={handleNoteDrag}
|
||||
onDragEnd={handleNoteDragEnd}
|
||||
onClick={handleNoteClick}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}, [activeRegion, noteUpdateCounter, resizingNoteId, draggingNoteId, tempNoteStyles, selectedNoteIds, selectionBoxRender, tracks]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="piano-roll-content"
|
||||
ref={contentRef}
|
||||
>
|
||||
<PianoGridHeader maxBars={maxBars} timeSignature={timeSignature} />
|
||||
|
||||
<div className="piano-roll-body">
|
||||
<PianoKeys activeRegion={activeRegion} />
|
||||
|
||||
<PianoGrid
|
||||
gridRef={pianoGridRef}
|
||||
onDoubleClick={handleGridDoubleClick}
|
||||
onClick={handleCombinedClick}
|
||||
onMouseDown={handleBackgroundMouseDown}
|
||||
isBoxSelecting={isBoxSelectingRef.current}
|
||||
selectionBox={selectionBoxRef.current}
|
||||
regionStartBeat={activeRegion?.getStartFromBeat() || 0}
|
||||
>
|
||||
{memoizedNotes}
|
||||
</PianoGrid>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PianoRollContent;
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
|
||||
interface PianoRollHeaderProps {
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
onTitleClick: () => void;
|
||||
onMouseDown: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const PianoRollHeader: React.FC<PianoRollHeaderProps> = ({
|
||||
onClose,
|
||||
title,
|
||||
onTitleClick,
|
||||
onMouseDown
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="piano-roll-header"
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
<button
|
||||
className="close-button"
|
||||
onClick={onClose}
|
||||
>
|
||||
<FaTimes />
|
||||
</button>
|
||||
<div
|
||||
className="piano-roll-title"
|
||||
onClick={onTitleClick}
|
||||
title="Click to rename region"
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PianoRollHeader;
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { FaMousePointer, FaPencilAlt } from 'react-icons/fa';
|
||||
import { KGDropdown } from '../common';
|
||||
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||
|
||||
interface PianoRollToolbarProps {
|
||||
activeTool: 'pointer' | 'pencil';
|
||||
onToolSelect: (tool: 'pointer' | 'pencil') => void;
|
||||
quantPosition: string;
|
||||
quantLength: string;
|
||||
onQuantSelect: (type: 'position' | 'length', value: string) => void;
|
||||
snapping: string;
|
||||
onSnappingSelect: (value: string) => void;
|
||||
blinkButton?: string | null;
|
||||
}
|
||||
|
||||
const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
activeTool,
|
||||
onToolSelect,
|
||||
quantPosition,
|
||||
quantLength,
|
||||
onQuantSelect,
|
||||
snapping,
|
||||
onSnappingSelect,
|
||||
blinkButton = null
|
||||
}) => {
|
||||
return (
|
||||
<div className="piano-roll-toolbar">
|
||||
<div className="toolbar-left">
|
||||
{/* Left section - can add more tools later */}
|
||||
</div>
|
||||
|
||||
<div className="toolbar-center">
|
||||
{/* Center section with pointer and pencil tools */}
|
||||
<button
|
||||
className={`tool-button ${activeTool === 'pointer' ? 'active' : ''}`}
|
||||
onClick={() => onToolSelect('pointer')}
|
||||
title="Pointer Tool"
|
||||
>
|
||||
<FaMousePointer />
|
||||
</button>
|
||||
<button
|
||||
className={`tool-button ${activeTool === 'pencil' ? 'active' : ''}`}
|
||||
onClick={() => onToolSelect('pencil')}
|
||||
title="Pencil Tool"
|
||||
>
|
||||
<FaPencilAlt />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-right">
|
||||
{/* Right section with quantization options */}
|
||||
<KGDropdown
|
||||
options={KGPianoRollState.SNAP_OPTIONS}
|
||||
value={snapping}
|
||||
onChange={(value) => onSnappingSelect(value)}
|
||||
label="Snap"
|
||||
buttonClassName="snapping"
|
||||
showValueAsLabel={true}
|
||||
/>
|
||||
|
||||
<KGDropdown
|
||||
options={KGPianoRollState.QUANT_POS_OPTIONS}
|
||||
value={quantPosition}
|
||||
onChange={(value) => onQuantSelect('position', value)}
|
||||
label="Qua. Pos."
|
||||
buttonClassName={`quant-position ${blinkButton === 'quant-position' ? 'button-blink' : ''}`}
|
||||
/>
|
||||
|
||||
<KGDropdown
|
||||
options={KGPianoRollState.QUANT_LEN_OPTIONS}
|
||||
value={quantLength}
|
||||
onChange={(value) => onQuantSelect('length', value)}
|
||||
label="Qua. Len."
|
||||
buttonClassName={`quant-length ${blinkButton === 'quant-length' ? 'button-blink' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PianoRollToolbar;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SelectionBoxProps {
|
||||
isSelecting: boolean;
|
||||
selectionBox: {
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
};
|
||||
}
|
||||
|
||||
const SelectionBox: React.FC<SelectionBoxProps> = ({ isSelecting, selectionBox }) => {
|
||||
if (!isSelecting) return null;
|
||||
|
||||
// Calculate the normalized coordinates (top-left to bottom-right)
|
||||
const { startX, startY, endX, endY } = selectionBox;
|
||||
const left = Math.min(startX, endX);
|
||||
const top = Math.min(startY, endY);
|
||||
const width = Math.abs(endX - startX);
|
||||
const height = Math.abs(endY - startY);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="selection-box"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
border: '1px solid white',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
pointerEvents: 'none', // Allow clicks to pass through
|
||||
zIndex: 50
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectionBox;
|
||||
Reference in New Issue
Block a user