initial public release.
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user