initial public release.

This commit is contained in:
Xiaohan-Tian
2025-08-11 18:37:21 -07:00
commit de51967b49
186 changed files with 32322 additions and 0 deletions
+498
View File
@@ -0,0 +1,498 @@
import React, { useState, useRef, useEffect } from 'react';
import { FaPencilAlt } from 'react-icons/fa';
import type { ResizeAction } from '../interfaces';
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { useProjectStore } from '../../stores/projectStore';
import { KGMainContentState } from '../../core/state/KGMainContentState';
interface RegionItemProps {
id: string;
name: string;
style: React.CSSProperties;
// Additional props that will be needed for resize functionality
barNumber?: number;
length?: number;
trackIndex?: number;
onResizeStart?: (regionId: string, resizeAction: ResizeAction, initialX: number) => void;
onResize?: (regionId: string, resizeAction: ResizeAction, deltaX: number) => void;
onResizeEnd?: (regionId: string, resizeAction: ResizeAction) => void;
// Drag props
onDragStart?: (regionId: string, initialX: number, initialY: number) => void;
onDrag?: (regionId: string, deltaX: number, deltaY: number) => void;
onDragEnd?: (regionId: string) => void;
// Click prop
onClick?: (regionId: string) => void;
// Explicit open piano roll action from header pencil icon
onOpenPianoRoll?: (regionId: string) => void;
// MIDI region data for rendering notes
midiRegion?: KGMidiRegion;
}
const RegionItem: React.FC<RegionItemProps> = ({
id,
name,
style,
barNumber,
length,
trackIndex,
onResizeStart,
onResize,
onResizeEnd,
onDragStart,
onDrag,
onDragEnd,
onClick,
onOpenPianoRoll,
midiRegion
}) => {
// Get selection state and time signature from store
const { selectedRegionIds, timeSignature } = useProjectStore();
const isSelected = selectedRegionIds.includes(id);
const [cursor, setCursor] = useState<string>('pointer');
const [resizeEdge, setResizeEdge] = useState<ResizeAction>('none');
const [isResizing, setIsResizing] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const initialMousePosRef = useRef<{x: number, y: number}>({x: 0, y: 0});
// Use refs to track states for immediate access
const isResizingRef = useRef<boolean>(false);
const isDraggingRef = useRef<boolean>(false);
const hasMovedRef = useRef<boolean>(false);
// Canvas ref for note visualization
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const regionContentRef = useRef<HTMLDivElement | null>(null);
// Function to render notes on canvas
const renderNotesOnCanvas = () => {
if (!canvasRef.current || !regionContentRef.current || !midiRegion) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Get the current dimensions of the region content
const contentRect = regionContentRef.current.getBoundingClientRect();
const width = contentRect.width;
const height = contentRect.height;
// Set canvas size to match the region content
canvas.width = width;
canvas.height = height;
// Clear the canvas
ctx.clearRect(0, 0, width, height);
// Get notes from the MIDI region
const notes = midiRegion.getNotes();
if (notes.length === 0) {
// No notes to render, but we can draw a reference grid or just return
return;
}
// Calculate note dimensions and positioning
const regionLengthInBeats = midiRegion.getLength();
const beatsPerBar = timeSignature.numerator;
const regionLengthInBars = regionLengthInBeats / beatsPerBar;
// Calculate beats per pixel
const beatsPerPixel = regionLengthInBeats / width;
// Analyze the pitch range of notes in the region
const notePitches = notes.map(note => note.getPitch());
const minNotePitch = Math.min(...notePitches);
const maxNotePitch = Math.max(...notePitches);
const averagePitch = notePitches.reduce((sum, pitch) => sum + pitch, 0) / notePitches.length;
const noteRange = maxNotePitch - minNotePitch;
// Define display parameters
const c4Pitch = 60; // C4 reference
const defaultPitchSpacing = 2; // pixels per semitone
const minPitchSpacing = 1; // minimum pixels per semitone
const paddingSemitones = 6; // padding above and below note range
// Calculate optimal pitch range and centering
let displayMinPitch, displayMaxPitch, centerPitch, pitchSpacing;
if (noteRange === 0) {
// Single note or all notes have same pitch
centerPitch = averagePitch;
// Show 2 octaves around the note
displayMinPitch = Math.max(0, centerPitch - 12);
displayMaxPitch = Math.min(127, centerPitch + 12);
pitchSpacing = defaultPitchSpacing;
} else {
// Multiple notes with different pitches
const expandedMinPitch = Math.max(0, minNotePitch - paddingSemitones);
const expandedMaxPitch = Math.min(127, maxNotePitch + paddingSemitones);
const expandedRange = expandedMaxPitch - expandedMinPitch;
// Check if we can fit all notes with default spacing
const requiredHeight = expandedRange * defaultPitchSpacing;
if (requiredHeight <= height) {
// Notes fit with default spacing, center around average pitch
displayMinPitch = expandedMinPitch;
displayMaxPitch = expandedMaxPitch;
centerPitch = averagePitch;
pitchSpacing = defaultPitchSpacing;
} else {
// Notes don't fit, need to compress spacing
displayMinPitch = expandedMinPitch;
displayMaxPitch = expandedMaxPitch;
centerPitch = averagePitch;
pitchSpacing = Math.max(minPitchSpacing, height / expandedRange);
if (DEBUG_MODE.REGION_ITEM) {
console.log(`Compressing pitch spacing to ${pitchSpacing.toFixed(2)}px per semitone to fit range ${expandedRange}`);
}
}
}
// If the note range is small, fall back to centering around C4 if it's reasonable
const displayRange = displayMaxPitch - displayMinPitch;
if (displayRange < 24 && Math.abs(averagePitch - c4Pitch) > 12) {
// Note range is small but far from C4, use a compromise
const compromiseCenter = averagePitch > c4Pitch ?
Math.min(averagePitch, c4Pitch + 12) :
Math.max(averagePitch, c4Pitch - 12);
displayMinPitch = Math.max(0, compromiseCenter - 12);
displayMaxPitch = Math.min(127, compromiseCenter + 12);
centerPitch = compromiseCenter;
if (DEBUG_MODE.REGION_ITEM) {
console.log(`Using compromise center ${compromiseCenter} between notes (${averagePitch.toFixed(1)}) and C4 (${c4Pitch})`);
}
}
const finalPitchRange = displayMaxPitch - displayMinPitch;
if (DEBUG_MODE.REGION_ITEM) {
console.log(`Pitch analysis: notes=${minNotePitch}-${maxNotePitch} (avg=${averagePitch.toFixed(1)}), display=${displayMinPitch}-${displayMaxPitch}, spacing=${pitchSpacing.toFixed(2)}px`);
}
// Set note rendering style
ctx.fillStyle = 'white';
const noteHeight = 2; // Fixed 2px height as requested
// Render each note
notes.forEach(note => {
const startBeat = note.getStartBeat();
const endBeat = note.getEndBeat();
const pitch = note.getPitch();
// Only render notes within our display pitch range
if (pitch < displayMinPitch || pitch > displayMaxPitch) return;
// Calculate note position and size
const noteStartX = startBeat / beatsPerPixel;
const noteWidth = (endBeat - startBeat) / beatsPerPixel;
// Calculate Y position based on pitch using dynamic spacing
// Higher pitches should be at the top (lower Y values)
const pitchIndex = pitch - displayMinPitch;
const noteY = height - (pitchIndex * pitchSpacing) - (noteHeight / 2);
// Draw the note as a white horizontal line
ctx.fillRect(noteStartX, noteY, noteWidth, noteHeight);
});
if (DEBUG_MODE.REGION_ITEM) {
console.log(`Rendered ${notes.length} notes on canvas for region ${id}`);
}
};
// Create a stable reference to track note changes
const notesRef = useRef<string>('');
const [noteUpdateTrigger, setNoteUpdateTrigger] = useState(0);
// Check for changes in notes and trigger re-render when needed
useEffect(() => {
if (!midiRegion) return;
// Create a signature of all notes for change detection
const notesSignature = midiRegion.getNotes()
.map(n => `${n.getId()}-${n.getStartBeat()}-${n.getEndBeat()}-${n.getPitch()}`)
.join(',');
// If notes have changed, trigger a re-render
if (notesRef.current !== notesSignature) {
notesRef.current = notesSignature;
setNoteUpdateTrigger(prev => prev + 1);
}
});
// Set up canvas when component mounts or updates
useEffect(() => {
renderNotesOnCanvas();
}, [midiRegion, timeSignature, id, noteUpdateTrigger]);
// Re-render canvas when region content size changes
useEffect(() => {
if (!regionContentRef.current) return;
const resizeObserver = new ResizeObserver(() => {
renderNotesOnCanvas();
});
resizeObserver.observe(regionContentRef.current);
return () => {
if (regionContentRef.current) {
resizeObserver.unobserve(regionContentRef.current);
}
};
}, [midiRegion, timeSignature]);
// 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;
// Disable move and resize when pencil tool is active
const activeTool = KGMainContentState.instance().getActiveTool();
if (activeTool === 'pencil') {
setCursor('pointer');
setResizeEdge('none');
return;
}
const regionElement = e.currentTarget;
const rect = regionElement.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 = REGION_CONSTANTS.EDGE_THRESHOLD;
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>) => {
// Disable move and resize when pencil tool is active
const activeTool = KGMainContentState.instance().getActiveTool();
if (activeTool === 'pencil') {
// Still allow click events to pass through for region selection
if (!hasMovedRef.current && onClick) {
if (DEBUG_MODE.REGION_ITEM) {
console.log(`REGION CLICKED (pencil mode): regionId=${id}`);
}
onClick(id);
}
return;
}
// 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.REGION_ITEM) {
console.log(`RESIZE START: regionId=${id}, edge=${resizeEdge}`);
}
setIsResizing(true);
isResizingRef.current = true;
// Call the onResizeStart callback if provided
if (onResizeStart) {
onResizeStart(id, resizeEdge, e.clientX);
}
} else {
// Start dragging
if (DEBUG_MODE.REGION_ITEM) {
console.log(`DRAG START: regionId=${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.REGION_ITEM) {
console.log(`RESIZE MOVE: regionId=${id}, edge=${resizeEdge}`);
}
// Calculate delta from initial position
const deltaX = e.clientX - initialMousePosRef.current.x;
// Call the onResize callback if provided
if (onResize) {
onResize(id, resizeEdge, deltaX);
}
} else if (isDraggingRef.current) {
// Handle drag
if (DEBUG_MODE.REGION_ITEM) {
console.log(`DRAG MOVE: regionId=${id}, trackIndex=${trackIndex}`);
}
// 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.REGION_ITEM) {
console.log(`RESIZE END: regionId=${id}`);
}
setIsResizing(false);
isResizingRef.current = false;
// Call the onResizeEnd callback if provided
if (onResizeEnd) {
onResizeEnd(id, resizeEdge);
}
} else if (isDraggingRef.current) {
// End dragging
if (DEBUG_MODE.REGION_ITEM) {
console.log(`DRAG END: regionId=${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.REGION_ITEM) {
console.log(`REGION CLICKED: regionId=${id}`);
}
onClick(id);
}
}
// 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
key={id}
className={`track-region ${isDragging ? 'dragging' : ''} ${isSelected ? 'selected' : ''}`}
style={{ ...style, cursor }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onMouseDown={handleMouseDown}
data-region-id={id}
data-resize-edge={resizeEdge}
data-is-resizing={isResizing}
data-is-dragging={isDragging}
>
<div className="region-header">
{name}
</div>
<div className="region-content" ref={regionContentRef}>
<button
className="region-pencil-btn"
title="Edit notes"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (DEBUG_MODE.REGION_ITEM) {
console.log(`Pencil clicked: open piano roll for region ${id}`);
}
if (onOpenPianoRoll) {
onOpenPianoRoll(id);
} else if (onClick) {
onClick(id);
}
}}
aria-label="Open piano roll"
>
<FaPencilAlt size={10} />
</button>
<canvas ref={canvasRef} />
</div>
</div>
);
};
export default RegionItem;
+546
View File
@@ -0,0 +1,546 @@
import React, { useEffect, useState, useRef } from 'react';
import { KGTrack } from '../../core/track/KGTrack';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import RegionItem from './RegionItem';
import type { RegionUI, ResizeAction } from '../interfaces';
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
import { KGMainContentState } from '../../core/state/KGMainContentState';
import { isModifierKeyPressed } from '../../util/osUtil';
interface TrackGridItemProps {
track: KGTrack;
index: number;
isDragging: boolean;
isDragOver: boolean;
regions: RegionUI[];
maxBars: number;
selectedRegionId: string | null;
gridContainerRef: React.RefObject<HTMLDivElement | null>;
onDoubleClick: (e: React.MouseEvent<HTMLDivElement>, index: number) => void;
onClick?: (e: React.MouseEvent<HTMLDivElement>, index: number) => void;
onRegionResize?: (regionId: string, newBarNumber: number, newLength: number) => void;
onRegionResizeEnd?: (regionId: string, finalBarNumber: number, finalLength: number) => void;
onRegionDrag?: (regionId: string, newBarNumber: number, newTrackIndex: number) => void;
onRegionDragEnd?: (regionId: string, finalBarNumber: number, finalTrackIndex: number) => void;
onRegionClick?: (regionId: string) => void;
onOpenPianoRoll?: (regionId: string) => void;
allTracks?: KGTrack[]; // Added to access all tracks for drag operations
}
const TrackGridItem: React.FC<TrackGridItemProps> = ({
track,
index,
isDragging,
isDragOver,
regions,
maxBars,
selectedRegionId,
gridContainerRef,
onDoubleClick,
onClick,
onRegionResize,
onRegionResizeEnd,
onRegionDrag,
onRegionDragEnd,
onRegionClick,
onOpenPianoRoll,
allTracks
}) => {
const [containerWidth, setContainerWidth] = useState(0);
const [resizingRegion, setResizingRegion] = useState<string | null>(null);
const [draggingRegion, setDraggingRegion] = useState<string | null>(null);
const [tempRegionStyles, setTempRegionStyles] = useState<Record<string, React.CSSProperties>>({});
const [isModifierPressed, setIsModifierPressed] = useState(false);
// Refs for resize operations
const mouseMoved = useRef(false);
const currentResizeWidth = useRef<number | null>(null);
const currentResizeLeft = useRef<number | null>(null);
const currentResizeRegion = useRef<RegionUI | null>(null);
const initialBarNumberRef = useRef<number | null>(null);
const initialLengthRef = useRef<number | null>(null);
// Refs for drag operations
const currentDragLeft = useRef<number | null>(null);
const currentDragTop = useRef<number | null>(null);
const currentDragRegion = useRef<RegionUI | null>(null);
const trackElementRef = useRef<HTMLDivElement | null>(null);
// Update container width when the grid container changes size
useEffect(() => {
if (!gridContainerRef.current) return;
setContainerWidth(gridContainerRef.current.clientWidth);
const resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
}
});
resizeObserver.observe(gridContainerRef.current);
return () => {
if (gridContainerRef.current) {
resizeObserver.unobserve(gridContainerRef.current);
}
};
}, [gridContainerRef]);
// Track modifier key state for cursor feedback
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isModifierKeyPressed(e)) {
setIsModifierPressed(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
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);
};
}, []);
// Calculate region position and style
const getRegionStyle = (region: RegionUI) => {
// Check if there's a temporary style for this region during resize or drag
if ((resizingRegion === region.id || draggingRegion === region.id) && tempRegionStyles[region.id]) {
return tempRegionStyles[region.id];
}
// Calculate the width of each bar
const barWidth = containerWidth / maxBars;
// Calculate left position (0-indexed bar number)
const left = (region.barNumber - 1) * barWidth;
// Calculate width based on region length
const width = region.length * barWidth;
return {
left: `${left}px`,
width: `${width}px`,
position: 'absolute' as const, // Fixed: Use const assertion
};
};
// Handle region resize start
const handleRegionResizeStart = (regionId: string, resizeAction: ResizeAction, initialX: number) => {
// Disable resizing in pencil mode
if (KGMainContentState.instance().getActiveTool() === 'pencil') {
return;
}
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`RESIZE START: regionId=${regionId}, action=${resizeAction}`);
}
setResizingRegion(regionId);
// Reset the mouse moved flag
mouseMoved.current = false;
// Find the region being resized
const region = regions.find(r => r.id === regionId);
if (!region) return;
// Store the region for reference
currentResizeRegion.current = region;
initialBarNumberRef.current = region.barNumber;
initialLengthRef.current = region.length;
// Calculate the width of each bar
const barWidth = containerWidth / maxBars;
// Store the initial width and left position
currentResizeWidth.current = region.length * barWidth;
currentResizeLeft.current = (region.barNumber - 1) * barWidth;
// Set initial style to current position/size
const initialStyle = {
left: `${currentResizeLeft.current}px`,
width: `${currentResizeWidth.current}px`,
position: 'absolute' as const, // Fixed: Use const assertion
};
setTempRegionStyles(prev => ({
...prev,
[regionId]: initialStyle
}));
};
// Handle region resize
const handleRegionResize = (regionId: string, resizeAction: ResizeAction, deltaX: number) => {
// Set the mouse moved flag to true
mouseMoved.current = true;
// Find the region being resized
const region = regions.find(r => r.id === regionId);
if (!region) return;
// Calculate the width of each bar
const barWidth = containerWidth / maxBars;
// Get initial values
const originalWidth = initialLengthRef.current! * barWidth;
const originalLeft = (initialBarNumberRef.current! - 1) * barWidth;
let newLeft = originalLeft;
let newWidth = originalWidth;
if (resizeAction === 'end') {
// End resize: adjust width only
newWidth = Math.max(barWidth * REGION_CONSTANTS.MIN_REGION_LENGTH, originalWidth + deltaX);
} else if (resizeAction === 'start') {
// Start resize: adjust both left position and width
// Calculate maximum delta to prevent negative width
const maxDelta = originalWidth - barWidth * REGION_CONSTANTS.MIN_REGION_LENGTH;
const clampedDeltaX = Math.min(maxDelta, deltaX);
// Adjust left position and width
newLeft = originalLeft + clampedDeltaX;
newWidth = originalWidth - clampedDeltaX;
}
// Store the current values in refs
currentResizeWidth.current = newWidth;
currentResizeLeft.current = newLeft;
// Calculate the new bar number and length (not rounded yet, for smooth resizing)
const newBarNumber = (newLeft / barWidth) + 1;
const newLength = newWidth / barWidth;
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`RESIZE: regionId=${regionId}, action=${resizeAction}, deltaX=${deltaX}, newBarNumber=${newBarNumber}, newLength=${newLength}`);
}
// Update the temporary style for this region
const newStyle = {
left: `${newLeft}px`,
width: `${newWidth}px`,
position: 'absolute' as const, // Fixed: Use const assertion
};
setTempRegionStyles(prev => ({
...prev,
[regionId]: newStyle
}));
// Notify parent about resize
if (onRegionResize) {
onRegionResize(regionId, newBarNumber, newLength);
}
};
// Handle region resize end
const handleRegionResizeEnd = (regionId: string, resizeAction: ResizeAction) => {
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`RESIZE END: regionId=${regionId}, action=${resizeAction}, mouseMoved=${mouseMoved.current}`);
console.log('Current resize width from ref:', currentResizeWidth.current);
console.log('Current resize left from ref:', currentResizeLeft.current);
}
// Find the region being resized
const region = regions.find(r => r.id === regionId) || currentResizeRegion.current;
if (!region) {
console.error(`Region not found: ${regionId}`);
return;
}
// Calculate the width of each bar
const barWidth = containerWidth / maxBars;
let newBarNumber = region.barNumber; // Default to current bar number
let newLength = region.length; // Default to current length
// If the mouse was moved and we have current values, calculate the new values
if (mouseMoved.current && currentResizeWidth.current !== null && currentResizeLeft.current !== null) {
if (resizeAction === 'end') {
// End resize: round length to nearest bar
newLength = Math.max(REGION_CONSTANTS.MIN_REGION_LENGTH, Math.round(currentResizeWidth.current / barWidth));
} else if (resizeAction === 'start') {
// Start resize: round bar number and adjust length accordingly
const rawBarNumber = currentResizeLeft.current / barWidth + 1;
newBarNumber = Math.max(1, Math.round(rawBarNumber));
// Calculate the difference from the initial position
const barDiff = initialBarNumberRef.current! - newBarNumber;
// Adjust length to maintain the end position
newLength = initialLengthRef.current! + barDiff;
// Ensure minimum length
if (newLength < REGION_CONSTANTS.MIN_REGION_LENGTH) {
newLength = REGION_CONSTANTS.MIN_REGION_LENGTH;
newBarNumber = initialBarNumberRef.current! + initialLengthRef.current! - REGION_CONSTANTS.MIN_REGION_LENGTH;
}
}
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`Calculated new values: barNumber=${newBarNumber}, length=${newLength}`);
}
} else if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`Using existing values: barNumber=${newBarNumber}, length=${newLength}`);
}
// Clear resizing state
setResizingRegion(null);
setTempRegionStyles(prev => {
const updated = { ...prev };
delete updated[regionId];
return updated;
});
currentResizeWidth.current = null;
currentResizeLeft.current = null;
currentResizeRegion.current = null;
initialBarNumberRef.current = null;
initialLengthRef.current = null;
// Notify parent about resize end with rounded values
if (onRegionResizeEnd) {
onRegionResizeEnd(regionId, newBarNumber, newLength);
}
};
// Handle region drag start
const handleRegionDragStart = (regionId: string, initialX: number, initialY: number) => {
// Disable dragging in pencil mode
if (KGMainContentState.instance().getActiveTool() === 'pencil') {
return;
}
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`DRAG START: regionId=${regionId}`);
}
setDraggingRegion(regionId);
// Reset the mouse moved flag
mouseMoved.current = false;
// Find the region being dragged
const region = regions.find(r => r.id === regionId);
if (!region) return;
// Store the region for reference
currentDragRegion.current = region;
// Calculate the width of each bar
const barWidth = containerWidth / maxBars;
// Calculate the left position
const left = (region.barNumber - 1) * barWidth;
const width = region.length * barWidth;
// Store the initial position
currentDragLeft.current = left;
currentDragTop.current = 0; // Initially at the top of the current track
// Set initial style
const initialStyle = {
left: `${left}px`,
width: `${width}px`,
position: 'absolute' as const, // Fixed: Use const assertion
zIndex: 100, // Bring to front during drag
};
setTempRegionStyles(prev => ({
...prev,
[regionId]: initialStyle
}));
};
// Handle region drag
const handleRegionDrag = (regionId: string, deltaX: number, deltaY: number) => {
// Set the mouse moved flag to true
mouseMoved.current = true;
// Find the region being dragged
const region = regions.find(r => r.id === regionId);
if (!region) return;
// Calculate the width of each bar
const barWidth = containerWidth / maxBars;
// Get the initial left position
const initialLeft = (region.barNumber - 1) * barWidth;
// Calculate new left position
const newLeft = initialLeft + deltaX;
// Calculate the new bar number (not rounded yet, for smooth dragging)
const newBarNumber = (newLeft / barWidth) + 1;
// Store the current drag position for use in handleRegionDragEnd
currentDragLeft.current = newLeft;
currentDragTop.current = deltaY;
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`DRAG: regionId=${regionId}, deltaX=${deltaX}, deltaY=${deltaY}, newBarNumber=${newBarNumber}`);
}
// Update the temporary style for this region
const newStyle = {
left: `${newLeft}px`,
width: `${region.length * barWidth}px`,
position: 'absolute' as const,
zIndex: 100, // Keep on top during drag
transform: `translateY(${deltaY}px)`,
};
setTempRegionStyles(prev => ({
...prev,
[regionId]: newStyle
}));
// We'll calculate the track index on drag end, but still notify parent about the drag
if (onRegionDrag) {
onRegionDrag(regionId, newBarNumber, region.trackIndex);
}
};
// Handle region drag end
const handleRegionDragEnd = (regionId: string) => {
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`DRAG END: regionId=${regionId}, mouseMoved=${mouseMoved.current}`);
}
// Find the region being dragged
const region = regions.find(r => r.id === regionId) || currentDragRegion.current;
if (!region) {
console.error(`Region not found: ${regionId}`);
return;
}
// Calculate the width of each bar
const barWidth = containerWidth / maxBars;
// Default to current position
let finalBarNumber = region.barNumber;
let finalTrackIndex = region.trackIndex;
// If the mouse was moved, calculate the final position
if (mouseMoved.current && currentDragLeft.current !== null && currentDragTop.current !== null) {
// Calculate the new bar number and round to nearest integer
const rawBarNumber = (currentDragLeft.current / barWidth) + 1;
finalBarNumber = Math.max(1, Math.round(rawBarNumber));
// Calculate the closest track based on vertical position
if (allTracks && allTracks.length > 0 && gridContainerRef.current) {
const trackHeight = gridContainerRef.current.clientHeight / allTracks.length;
// Calculate the absolute vertical position
const originTrackTop = region.trackIndex * trackHeight;
const absoluteY = originTrackTop + currentDragTop.current;
// Find the closest track index
const closestTrackIndex = Math.max(0, Math.min(
allTracks.length - 1,
Math.round(absoluteY / trackHeight)
));
finalTrackIndex = closestTrackIndex;
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`Calculated closest track: ${finalTrackIndex} (from Y=${currentDragTop.current}, absoluteY=${absoluteY}, trackHeight=${trackHeight})`);
if (finalTrackIndex !== region.trackIndex) {
console.log(`Track change: from trackIndex=${region.trackIndex} (trackId=${region.trackId}) to trackIndex=${finalTrackIndex} (trackId=${allTracks[finalTrackIndex].getId()})`);
}
}
}
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`Final position: barNumber=${finalBarNumber}, trackIndex=${finalTrackIndex}`);
}
}
// Clear dragging state
setDraggingRegion(null);
setTempRegionStyles(prev => {
const updated = { ...prev };
delete updated[regionId];
return updated;
});
currentDragLeft.current = null;
currentDragTop.current = null;
currentDragRegion.current = null;
// Notify parent about drag end with final values
if (onRegionDragEnd) {
onRegionDragEnd(regionId, finalBarNumber, finalTrackIndex);
}
};
// Handle region click
const handleRegionClick = (regionId: string) => {
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`Region clicked: ${regionId}`);
}
if (onRegionClick) {
onRegionClick(regionId);
}
};
// Filter regions for this track
const trackRegions = regions.filter(region => region.trackIndex === index);
return (
<div
className={`track-grid ${isDragOver ? 'drag-over' : ''} ${isDragging ? 'dragging' : ''} ${isModifierPressed ? 'pencil-cursor' : ''}`}
data-test-id={`track-grid-${track.getId()}`}
onDoubleClick={(e) => onDoubleClick(e, index)}
onClick={(e) => onClick && onClick(e, index)}
ref={trackElementRef}
>
{/* Render regions for this track */}
{trackRegions.map(region => {
// Find the corresponding KGMidiRegion in the track
const midiRegion = track.getRegions().find(r => r.getId() === region.id) as KGMidiRegion | undefined;
return (
<RegionItem
key={region.id}
id={region.id}
name={region.name}
style={getRegionStyle(region)}
barNumber={region.barNumber}
length={region.length}
trackIndex={index}
onResizeStart={handleRegionResizeStart}
onResize={handleRegionResize}
onResizeEnd={handleRegionResizeEnd}
onDragStart={handleRegionDragStart}
onDrag={handleRegionDrag}
onDragEnd={handleRegionDragEnd}
// Keep onClick for selection-only logic if needed by parent
onClick={handleRegionClick}
// New explicit pencil action
onOpenPianoRoll={(regionId) => {
if (onOpenPianoRoll) {
onOpenPianoRoll(regionId);
} else if (onRegionClick) {
// Fallback to legacy behavior
onRegionClick(regionId);
}
}}
midiRegion={midiRegion}
/>
);
})}
</div>
);
};
export default TrackGridItem;
+332
View File
@@ -0,0 +1,332 @@
import React, { useRef } from 'react';
import { KGTrack } from '../../core/track/KGTrack';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import TrackGridItem from './TrackGridItem';
import { Playhead } from '../common';
import type { RegionUI } from '../interfaces';
import { DEBUG_MODE } from '../../constants';
import { KGMainContentState } from '../../core/state/KGMainContentState';
import { isModifierKeyPressed } from '../../util/osUtil';
import { CreateRegionCommand, ResizeRegionCommand, MoveRegionCommand } from '../../core/commands';
import { KGCore } from '../../core/KGCore';
interface TrackGridPanelProps {
tracks: KGTrack[];
regions: RegionUI[];
maxBars: number;
timeSignature: { numerator: number; denominator: number };
draggedTrackIndex: number | null;
dragOverTrackIndex: number | null;
selectedRegionId: string | null;
onRegionCreated: (trackIndex: number, region: RegionUI, midiRegion: KGMidiRegion) => void;
onRegionUpdated?: (regionId: string, updates: Partial<RegionUI>, expectedModelUpdates?: { startBeat: number, length: number }) => void;
onRegionClick?: (regionId: string) => void;
onOpenPianoRoll?: (regionId: string) => void;
}
const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
tracks,
regions,
maxBars,
timeSignature,
draggedTrackIndex,
dragOverTrackIndex,
selectedRegionId,
onRegionCreated,
onRegionUpdated,
onRegionClick,
onOpenPianoRoll
}) => {
const gridContainerRef = useRef<HTMLDivElement>(null);
// Utility function to create a region at a specific position
const createRegionAtPosition = (e: React.MouseEvent<HTMLDivElement>, trackIndex: number) => {
// Get the grid container element
const gridContainer = e.currentTarget.closest('.grid-container');
if (!gridContainer) return;
// Get the grid container's bounding rectangle
const gridRect = gridContainer.getBoundingClientRect();
// Calculate the relative X position within the grid
const relativeX = e.clientX - gridRect.left;
// Calculate the width of each bar
const barWidth = gridContainer.clientWidth / maxBars;
// Calculate which bar was clicked (0-indexed)
const barIndex = Math.floor(relativeX / barWidth);
// Add 1 to convert to 1-indexed bar number
const barNumber = barIndex + 1;
// Get the track and its ID
const track = tracks[trackIndex];
const trackId = track.getId().toString();
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Creating region on track ${trackIndex + 1}, bar ${barNumber}`);
}
// Get beats per bar from the time signature
const beatsPerBar = timeSignature.numerator;
// Create and execute the region creation command
const command = CreateRegionCommand.fromBarCoordinates(
trackId,
trackIndex,
barNumber,
1, // Default to 1 bar length
beatsPerBar,
`${track.getName()} Region`
);
KGCore.instance().executeCommand(command);
// Get the created region for the UI callback
const createdRegion = command.getCreatedRegion();
if (createdRegion) {
// Create the region UI object for the parent component
const newRegionUI: RegionUI = {
id: createdRegion.getId(),
trackId: trackId,
trackIndex,
barNumber,
length: 1,
name: createdRegion.getName()
};
// Notify parent about the new region (for UI state updates)
onRegionCreated(trackIndex, newRegionUI, createdRegion);
}
};
// Handle double click on track grid to create region
const handleTrackGridDoubleClick = (e: React.MouseEvent<HTMLDivElement>, trackIndex: number) => {
// Only allow double-click creation in pointer mode
if (KGMainContentState.instance().getActiveTool() === 'pencil') {
return;
}
createRegionAtPosition(e, trackIndex);
};
// Handle single click on track grid for pencil mode or modifier+click
const handleTrackGridClick = (e: React.MouseEvent<HTMLDivElement>, trackIndex: number) => {
// Create region on single click in pencil mode OR when modifier key is pressed
if (KGMainContentState.instance().getActiveTool() === 'pencil' || isModifierKeyPressed(e)) {
createRegionAtPosition(e, trackIndex);
}
};
// Handle region resize during drag
const handleRegionResize = (regionId: string, newBarNumber: number, newLength: number) => {
// This is just for live visual updates, we don't update the model yet
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Resizing region ${regionId} to barNumber ${newBarNumber}, length ${newLength}`);
}
};
// Handle region resize end
const handleRegionResizeEnd = (regionId: string, finalBarNumber: number, finalLength: number) => {
// Now we update the model with the final rounded values
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Finished resizing region ${regionId} to barNumber ${finalBarNumber}, length ${finalLength}`);
}
// Find the region
const region = regions.find(r => r.id === regionId);
if (!region) return;
// Calculate new start and length in beats
const beatsPerBar = timeSignature.numerator;
const newStartBeat = (finalBarNumber - 1) * beatsPerBar;
const newLengthInBeats = finalLength * beatsPerBar;
// Find the track that contains this region
const track = tracks.find(t => t.getId().toString() === region.trackId);
if (!track) return;
// Update the region in the track's model
const trackRegions = track.getRegions();
const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined;
if (midiRegion) {
const oldStartBeat = midiRegion.getStartFromBeat();
const oldBarNumber = region.barNumber;
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Updating KGRegion model - Before: startBeat=${oldStartBeat}, length=${midiRegion.getLength()}`);
console.log(`Bar numbers - old: ${oldBarNumber}, new: ${finalBarNumber}`);
}
// Use command pattern to update the region position and length (note adjustments handled inside command)
try {
const command = ResizeRegionCommand.fromBarCoordinates(
regionId,
finalBarNumber,
finalLength,
timeSignature
);
KGCore.instance().executeCommand(command);
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Executed ResizeRegionCommand: region ${regionId} resized using command pattern`);
// Verify the command worked
const updatedRegion = track.getRegions().find(r => r.getId() === regionId);
console.log(`Verified region in track: ${updatedRegion ? 'found' : 'not found'}, startBeat=${updatedRegion?.getStartFromBeat()}, length=${updatedRegion?.getLength()}`);
}
} catch (error) {
console.error('Error resizing region:', error);
return;
}
}
// Update the region in the parent component with expected model values
if (onRegionUpdated) {
onRegionUpdated(
regionId,
{ barNumber: finalBarNumber, length: finalLength },
{ startBeat: newStartBeat, length: newLengthInBeats }
);
}
};
// Handle region drag during movement
const handleRegionDrag = (regionId: string, newBarNumber: number, trackIndex: number) => {
// This is just for live visual updates, we don't update the model yet
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Dragging region ${regionId} to barNumber ${newBarNumber}, trackIndex ${trackIndex}`);
}
// We don't need to update any temporary state in the parent component anymore
// The region will follow the mouse directly using transform in the TrackGridItem component
};
// Handle region drag end
const handleRegionDragEnd = (regionId: string, finalBarNumber: number, finalTrackIndex: number) => {
// Now we update the model with the final rounded values
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Finished dragging region ${regionId} to barNumber ${finalBarNumber}, trackIndex ${finalTrackIndex}`);
}
// Find the region
const region = regions.find(r => r.id === regionId);
if (!region) return;
// Get the target track
const targetTrack = tracks[finalTrackIndex];
if (!targetTrack) return;
// Use command pattern to move the region
try {
const command = MoveRegionCommand.fromBarCoordinates(
regionId,
finalBarNumber,
targetTrack.getId().toString(),
finalTrackIndex,
timeSignature
);
KGCore.instance().executeCommand(command);
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Executed MoveRegionCommand: region ${regionId} moved using command pattern`);
// Verify the command worked
const movedRegion = command.getTargetRegion();
console.log(`Verified region: ${movedRegion ? 'found' : 'not found'}, startBeat=${movedRegion?.getStartFromBeat()}, trackId=${movedRegion?.getTrackId()}`);
}
} catch (error) {
console.error('Error moving region:', error);
return;
}
// Calculate new start in beats for UI update
const beatsPerBar = timeSignature.numerator;
const startBeat = (finalBarNumber - 1) * beatsPerBar;
// Update the region in the parent component with expected model values
if (onRegionUpdated) {
// Find the updated region to get its length
const updatedTrack = tracks[finalTrackIndex];
const updatedRegions = updatedTrack.getRegions();
const updatedRegion = updatedRegions.find(r => r.getId() === regionId);
onRegionUpdated(
regionId,
{
trackId: targetTrack.getId().toString(),
trackIndex: finalTrackIndex,
barNumber: finalBarNumber
},
{
startBeat,
length: updatedRegion ? updatedRegion.getLength() : 0
}
);
}
};
// Handle region click
const handleRegionClick = (regionId: string) => {
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Region clicked in panel: ${regionId}`);
}
// Find the region
const region = regions.find(r => r.id === regionId);
if (!region) return;
// Find the track that contains this region
const track = tracks.find(t => t.getId().toString() === region.trackId);
if (!track) return;
// Find the region in the track's model
const trackRegions = track.getRegions();
const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined;
if (midiRegion && DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Found region in model: ${midiRegion.getId()}, trackId=${midiRegion.getTrackId()}, name=${midiRegion.getName()}`);
}
// Notify parent about the click
if (onRegionClick) {
onRegionClick(regionId);
}
};
return (
<div className="grid-container" ref={gridContainerRef}>
{/* Playhead */}
<Playhead context="main-grid" />
{/* Track grids */}
{tracks.map((track, index) => (
<TrackGridItem
key={track.getId()}
track={track}
index={index}
isDragging={draggedTrackIndex === index}
isDragOver={dragOverTrackIndex === index}
regions={regions}
maxBars={maxBars}
selectedRegionId={selectedRegionId}
gridContainerRef={gridContainerRef}
onDoubleClick={handleTrackGridDoubleClick}
onClick={handleTrackGridClick}
onRegionResize={handleRegionResize}
onRegionResizeEnd={handleRegionResizeEnd}
onRegionDrag={handleRegionDrag}
onRegionDragEnd={handleRegionDragEnd}
onRegionClick={handleRegionClick}
onOpenPianoRoll={onOpenPianoRoll}
allTracks={tracks}
/>
))}
</div>
);
};
export default TrackGridPanel;
+319
View File
@@ -0,0 +1,319 @@
import React, { useState, useRef, useEffect } from 'react';
import { KGTrack } from '../../core/track/KGTrack';
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
import { useProjectStore } from '../../stores/projectStore';
import { TbPiano } from 'react-icons/tb';
import { TbSettings } from 'react-icons/tb';
import KGDropdown from '../common/KGDropdown';
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
import { DEBUG_MODE } from '../../constants/uiConstants';
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
interface TrackInfoItemProps {
track: KGTrack;
index: number;
isDragging: boolean;
isDragOver: boolean;
onTrackClick?: () => void;
onTrackNameEdit: (track: KGTrack, newName: string) => void; // eslint-disable-line no-unused-vars
onDragStart: (e: React.DragEvent<HTMLDivElement>, index: number) => void; // eslint-disable-line no-unused-vars
onDragOver: (e: React.DragEvent<HTMLDivElement>, index: number) => void; // eslint-disable-line no-unused-vars
onDrop: (e: React.DragEvent<HTMLDivElement>) => void; // eslint-disable-line no-unused-vars
onDragEnd: (e: React.DragEvent<HTMLDivElement>) => void; // eslint-disable-line no-unused-vars
}
const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
track,
index,
isDragging,
isDragOver,
onTrackClick,
onTrackNameEdit,
onDragStart,
onDragOver,
onDrop,
onDragEnd
}) => {
const { selectedTrackId, setSelectedTrack, removeTrack, toggleInstrumentSelectionForTrack, tracks: allTracks } = useProjectStore();
const isSelected = selectedTrackId === track.getId().toString();
// Inline instrument dropdown removed; use InstrumentSelection panel instead
// Initialize current instrument from track data
const getTrackInstrument = () => {
if (track instanceof KGMidiTrack) {
return track.getInstrument();
}
return 'acoustic_grand_piano'; // Default fallback
};
const [currentInstrument, setCurrentInstrument] = useState(getTrackInstrument());
const [showSettingsDropdown, setShowSettingsDropdown] = useState(false);
const settingsDropdownRef = useRef<HTMLDivElement>(null);
const suppressDragRef = useRef(false);
const [volume, setVolume] = useState(track.getVolume());
// Local flag to track slider interaction; not used for rendering
const isAdjustingVolumeRef = useRef(false);
const [muted, setMuted] = useState(false);
const [solo, setSolo] = useState(false);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
showSettingsDropdown &&
settingsDropdownRef.current &&
!settingsDropdownRef.current.contains(event.target as Node)
) {
setShowSettingsDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showSettingsDropdown]);
// Sync currentInstrument state with actual track instrument value
const instrumentFromTrack = track instanceof KGMidiTrack ? track.getInstrument() : 'acoustic_grand_piano';
useEffect(() => {
setCurrentInstrument(instrumentFromTrack);
}, [instrumentFromTrack]);
// Sync volume UI with model when tracks state changes (e.g., load, undo/redo, external updates)
useEffect(() => {
setVolume(track.getVolume());
}, [allTracks, track]);
// Handle track name edit within the component
const handleTrackNameClick = (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent opening piano roll when clicking track name
const newName = prompt("Enter track name:", track.getName());
if (newName) {
// Call the parent handler with the new name
onTrackNameEdit(track, newName);
}
};
// Prevent drag reordering when interacting with interactive controls
const handleMouseDownCapture = (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
const isInteractive = !!target.closest(
'input, button, .volume-slider, .instrument-dropdown, .settings-dropdown'
);
suppressDragRef.current = isInteractive;
};
const handleDragStartWrapper = (e: React.DragEvent<HTMLDivElement>) => {
if (suppressDragRef.current) {
e.preventDefault();
e.stopPropagation();
suppressDragRef.current = false;
return;
}
onDragStart(e, index);
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
const next = Number(e.target.value) / 100;
isAdjustingVolumeRef.current = true;
setVolume(next);
try {
// Live preview: update audio only while sliding
KGAudioInterface.instance().setTrackVolume(track.getId().toString(), next);
} catch (err) {
console.error('Failed to update live volume:', err);
}
};
const commitVolumeChange = () => {
// Only commit if value actually changed from model to avoid extra commands
const modelVolume = track.getVolume();
if (Math.abs(modelVolume - volume) < 1e-6) {
isAdjustingVolumeRef.current = false;
return;
}
try {
useProjectStore.getState().updateTrackProperties(track.getId(), { volume });
} catch (err) {
console.error('Failed to persist volume:', err);
} finally {
isAdjustingVolumeRef.current = false;
}
};
const handleResetVolume = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const defaultVolume = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
setVolume(defaultVolume);
try {
useProjectStore.getState().updateTrackProperties(track.getId(), { volume: defaultVolume });
} catch (err) {
console.error('Failed to reset volume:', err);
}
};
const handleToggleMute = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const next = !muted;
setMuted(next);
try {
KGAudioInterface.instance().setTrackMute(track.getId().toString(), next);
} catch (err) {
console.error('Failed to toggle mute:', err);
}
};
const handleToggleSolo = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const next = !solo;
setSolo(next);
try {
KGAudioInterface.instance().setTrackSolo(track.getId().toString(), next);
} catch (err) {
console.error('Failed to toggle solo:', err);
}
};
// Handle track click
const handleTrackClick = () => {
// Select this track when clicked
setSelectedTrack(track.getId().toString());
if (onTrackClick) {
onTrackClick();
}
};
// Inline instrument change removed; handled by InstrumentSelection panel
// Handle piano button click
const handlePianoButtonClick = (e: React.MouseEvent) => {
e.stopPropagation();
// Select this track as active when opening instrument panel
setSelectedTrack(track.getId().toString());
// Toggle global InstrumentSelection panel for this track
toggleInstrumentSelectionForTrack(track.getId().toString());
};
// Handle settings button click
const handleSettingsButtonClick = (e: React.MouseEvent) => {
e.stopPropagation();
setShowSettingsDropdown(!showSettingsDropdown);
};
// Handle settings action
const handleSettingsAction = async (action: string) => {
if (action === 'Delete Track') {
const confirmed = window.confirm(`Are you sure you want to delete track "${track.getName()}"?`);
if (confirmed) {
try {
if (DEBUG_MODE.TRACK_INFO) {
console.log('Delete track confirmed for:', track.getName());
}
// Clear selection if this track is selected
if (selectedTrackId === track.getId().toString()) {
setSelectedTrack(null);
}
// Delete the track using the command system
await removeTrack(track.getId());
// Close the settings dropdown
setShowSettingsDropdown(false);
} catch (error) {
console.error('Failed to delete track:', error);
alert('Failed to delete track. Please try again.');
}
}
}
};
return (
<div
className={`track-info ${isDragOver ? 'drag-over' : ''} ${isDragging ? 'dragging' : ''} ${isSelected ? 'selected' : ''}`}
data-test-id={`track-info-${track.getId()}`}
onClick={handleTrackClick}
onMouseDownCapture={handleMouseDownCapture}
draggable={true}
onDragStart={handleDragStartWrapper}
onDragOver={(e) => onDragOver(e, index)}
onDrop={onDrop}
onDragEnd={onDragEnd}
>
<div className="track-controls">
<div className="track-name-and-volume">
<div className="instrument-image">
<img
src={`/resources/instruments/${String(FLUIDR3_INSTRUMENT_MAP[currentInstrument as keyof typeof FLUIDR3_INSTRUMENT_MAP]?.image || 'piano.png')}`}
alt={String(FLUIDR3_INSTRUMENT_MAP[currentInstrument as keyof typeof FLUIDR3_INSTRUMENT_MAP]?.displayName || currentInstrument)}
width="64"
height="64"
/>
</div>
<div className="track-name-and-controls">
<div
className="track-name"
onClick={handleTrackNameClick}
>
{track.getName()}
</div>
<div className="volume-slider">
<input
type="range"
min="0"
max="100"
value={Math.round(volume * 100)}
onChange={handleVolumeChange}
onMouseDown={(e) => { e.stopPropagation(); isAdjustingVolumeRef.current = true; }}
onMouseUp={(e) => { e.stopPropagation(); commitVolumeChange(); }}
onTouchStart={(e) => { e.stopPropagation(); isAdjustingVolumeRef.current = true; }}
onTouchEnd={(e) => { e.stopPropagation(); commitVolumeChange(); }}
onBlur={commitVolumeChange}
onClick={(e) => e.stopPropagation()}
/>
<button
className="reset-volume"
title="Reset volume"
aria-label="Reset volume"
onClick={handleResetVolume}
>
</button>
</div>
</div>
</div>
<div className="pan-controls">
<button className={`solo${solo ? ' active' : ''}`} onClick={handleToggleSolo}>S</button>
<button className={`mute${muted ? ' active' : ''}`} onClick={handleToggleMute}>M</button>
<div>
<button className="instrument" onClick={handlePianoButtonClick}>
<TbPiano />
</button>
</div>
<div style={{ position: 'relative' }} ref={settingsDropdownRef}>
<button className="settings" onClick={handleSettingsButtonClick}>
<TbSettings />
</button>
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10000 }}>
<KGDropdown
options={['Delete Track']}
value={''}
onChange={handleSettingsAction}
label="Settings"
hideButton={true}
isOpen={showSettingsDropdown}
onToggle={setShowSettingsDropdown}
className="settings-dropdown"
/>
</div>
</div>
</div>
</div>
</div>
);
};
export default TrackInfoItem;
+107
View File
@@ -0,0 +1,107 @@
import React, { useState } from 'react';
import { KGTrack } from '../../core/track/KGTrack';
import { useProjectStore } from '../../stores/projectStore';
import TrackInfoItem from './TrackInfoItem';
interface TrackInfoPanelProps {
tracks: KGTrack[];
onTrackClick?: () => void;
onTrackNameEdit: (track: KGTrack, newName: string) => void;
onTracksReordered: (fromIndex: number, toIndex: number) => void;
}
const TrackInfoPanel: React.FC<TrackInfoPanelProps> = ({
tracks,
onTrackClick,
onTrackNameEdit,
onTracksReordered
}) => {
const { setSelectedTrack } = useProjectStore();
// Drag state for track reordering
const [draggedTrackIndex, setDraggedTrackIndex] = useState<number | null>(null);
const [dragOverTrackIndex, setDragOverTrackIndex] = useState<number | null>(null);
// Handle track drag events
const handleTrackDragStart = (e: React.DragEvent<HTMLDivElement>, index: number) => {
setDraggedTrackIndex(index);
// Set a custom drag image or data if needed
e.dataTransfer.setData('text/plain', index.toString());
e.dataTransfer.effectAllowed = 'move';
// Add a class to the dragged element - store a reference to avoid null issues
const element = e.currentTarget;
if (element) {
// Add class immediately instead of using setTimeout
element.classList.add('dragging');
}
};
const handleTrackDragOver = (e: React.DragEvent<HTMLDivElement>, index: number) => {
e.preventDefault(); // Necessary to allow dropping
// Only update if the drag over index has changed
if (dragOverTrackIndex !== index) {
setDragOverTrackIndex(index);
}
e.dataTransfer.dropEffect = 'move';
};
const handleTrackDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (draggedTrackIndex !== null && dragOverTrackIndex !== null && draggedTrackIndex !== dragOverTrackIndex) {
// Notify parent component about the reordering
onTracksReordered(draggedTrackIndex, dragOverTrackIndex);
// Select the track that was moved (it's now at the dragOverTrackIndex position)
const movedTrack = tracks[draggedTrackIndex];
if (movedTrack) {
setSelectedTrack(movedTrack.getId().toString());
}
}
// Reset drag state
setDraggedTrackIndex(null);
setDragOverTrackIndex(null);
// Remove the dragging class from all elements
document.querySelectorAll('.track-info.dragging').forEach(el => {
el.classList.remove('dragging');
});
};
const handleTrackDragEnd = (e: React.DragEvent<HTMLDivElement>) => {
// Reset drag state
setDraggedTrackIndex(null);
setDragOverTrackIndex(null);
// Remove the dragging class
if (e.currentTarget) {
e.currentTarget.classList.remove('dragging');
}
};
return (
<div className="info-container">
{tracks.map((track, index) => (
<TrackInfoItem
key={track.getId()}
track={track}
index={index}
isDragging={draggedTrackIndex === index}
isDragOver={dragOverTrackIndex === index}
onTrackClick={onTrackClick}
onTrackNameEdit={onTrackNameEdit}
onDragStart={handleTrackDragStart}
onDragOver={handleTrackDragOver}
onDrop={handleTrackDrop}
onDragEnd={handleTrackDragEnd}
/>
))}
</div>
);
};
export default TrackInfoPanel;