809 lines
28 KiB
TypeScript
809 lines
28 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import './Region.css';
|
|
import { FaPencilAlt, FaPlus } from 'react-icons/fa';
|
|
import { MdGraphicEq, MdSwapHoriz } from 'react-icons/md';
|
|
import type { RegionClickOptions, ResizeAction } from '../interfaces';
|
|
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
|
|
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
|
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
|
import { useProjectStore } from '../../stores/projectStore';
|
|
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
|
import type { AudioRecordingPeak } from '../../core/audio-interface/KGAudioRecorder';
|
|
import type { RegionPreviewContentStyle } from '../interfaces';
|
|
import { KGCore } from '../../core/KGCore';
|
|
import { beatRangeToSeconds } from '../../util/globalTrackUtil';
|
|
|
|
const DRAG_START_THRESHOLD_PX = 4;
|
|
|
|
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, options: RegionClickOptions) => void;
|
|
// Explicit open piano roll action from header pencil icon
|
|
onOpenPianoRoll?: (regionId: string) => void;
|
|
// Open spectrogram viewer for audio regions
|
|
onOpenSpectrogram?: (regionId: string) => void;
|
|
// Enter hybrid mode (show + when piano roll is open with the opposite region type selected)
|
|
showHybridButton?: boolean;
|
|
onOpenHybrid?: (regionId: string) => void;
|
|
// Fine-move end callback — passes raw (unscaled) mouse pixel delta
|
|
onFineMoveEnd?: (regionId: string, rawPixelDelta: number) => void;
|
|
// MIDI region data for rendering notes
|
|
midiRegion?: KGMidiRegion;
|
|
// Audio region data for rendering waveform
|
|
audioRegion?: KGAudioRegion;
|
|
audioBuffer?: AudioBuffer;
|
|
previewWaveformPeaks?: AudioRecordingPeak[];
|
|
isPreview?: boolean;
|
|
isAudioRegion?: boolean;
|
|
previewContentStyle?: RegionPreviewContentStyle;
|
|
redrawVersion?: number;
|
|
}
|
|
|
|
const RegionItem: React.FC<RegionItemProps> = ({
|
|
id,
|
|
name,
|
|
style,
|
|
barNumber,
|
|
length,
|
|
trackIndex,
|
|
onResizeStart,
|
|
onResize,
|
|
onResizeEnd,
|
|
onDragStart,
|
|
onDrag,
|
|
onDragEnd,
|
|
onClick,
|
|
onOpenPianoRoll,
|
|
onOpenSpectrogram,
|
|
showHybridButton,
|
|
onOpenHybrid,
|
|
onFineMoveEnd,
|
|
midiRegion,
|
|
audioRegion,
|
|
audioBuffer,
|
|
previewWaveformPeaks,
|
|
isPreview = false,
|
|
isAudioRegion = false,
|
|
previewContentStyle,
|
|
redrawVersion = 0,
|
|
}) => {
|
|
// Get selection state and time signature from store
|
|
const { selectedRegionIds, timeSignature, bpm } = useProjectStore();
|
|
const isSelected = selectedRegionIds.includes(id);
|
|
const isPrimarySelected = isSelected && selectedRegionIds[selectedRegionIds.length - 1] === 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 isPendingDragRef = useRef<boolean>(false);
|
|
|
|
// Fine-move state
|
|
const [isFineDragging, setIsFineDragging] = useState(false);
|
|
const [fineDeltaDisplay, setFineDeltaDisplay] = useState('+0.00');
|
|
const [fineTranslateX, setFineTranslateX] = useState(0);
|
|
const isFineDraggingRef = useRef(false);
|
|
const fineMouseStartXRef = useRef(0);
|
|
const fineRawDeltaRef = useRef(0);
|
|
|
|
// Canvas ref for note visualization
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
const previewContentRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
// Function to render notes on canvas
|
|
const renderNotesOnCanvas = () => {
|
|
if (!canvasRef.current || !previewContentRef.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 = previewContentRef.current.getBoundingClientRect();
|
|
const width = Math.max(1, Math.round(contentRect.width));
|
|
const height = Math.max(1, Math.round(contentRect.height));
|
|
|
|
// Set canvas size to match the region content
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
canvas.style.width = `${width}px`;
|
|
canvas.style.height = `${height}px`;
|
|
|
|
// 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}`);
|
|
}
|
|
};
|
|
|
|
// Function to render audio waveform on canvas
|
|
const renderWaveformOnCanvas = () => {
|
|
if (!canvasRef.current || !previewContentRef.current || !audioBuffer) return;
|
|
|
|
const canvas = canvasRef.current;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
const contentRect = previewContentRef.current.getBoundingClientRect();
|
|
const width = Math.max(1, Math.round(contentRect.width));
|
|
const height = Math.max(1, Math.round(contentRect.height));
|
|
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
canvas.style.width = `${width}px`;
|
|
canvas.style.height = `${height}px`;
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
// Get channel data (use first channel)
|
|
const channelData = audioBuffer.getChannelData(0);
|
|
const totalSamples = channelData.length;
|
|
const sampleRate = audioBuffer.sampleRate;
|
|
|
|
// Calculate visible portion based on clip offset
|
|
const clipStartOffsetSeconds = audioRegion ? audioRegion.getClipStartOffsetSeconds() : 0;
|
|
const clipStartSample = Math.floor(clipStartOffsetSeconds * sampleRate);
|
|
|
|
const currentProject = KGCore.instance().getCurrentProject();
|
|
const regionLengthBeats = audioRegion ? audioRegion.getLength() : 0;
|
|
const visibleDurationSeconds = audioRegion
|
|
? Math.min(
|
|
beatRangeToSeconds(
|
|
currentProject,
|
|
audioRegion.getStartFromBeat(),
|
|
audioRegion.getStartFromBeat() + regionLengthBeats
|
|
),
|
|
Math.max(0, audioRegion.getAudioDurationSeconds() - clipStartOffsetSeconds)
|
|
)
|
|
: 0;
|
|
const visibleSamples = Math.floor(visibleDurationSeconds * sampleRate);
|
|
|
|
// Clamp to buffer boundaries
|
|
const renderStartSample = Math.max(0, Math.min(clipStartSample, totalSamples));
|
|
const renderEndSample = Math.min(renderStartSample + visibleSamples, totalSamples);
|
|
const renderSampleCount = renderEndSample - renderStartSample;
|
|
|
|
if (renderSampleCount <= 0) return;
|
|
|
|
// Downsample visible portion to canvas width
|
|
const samplesPerPixel = Math.max(1, Math.floor(renderSampleCount / width));
|
|
const centerY = height / 2;
|
|
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
|
|
for (let x = 0; x < width; x++) {
|
|
const startSample = renderStartSample + Math.floor(x * samplesPerPixel);
|
|
const endSample = Math.min(startSample + samplesPerPixel, renderEndSample);
|
|
|
|
let min = 0;
|
|
let max = 0;
|
|
for (let i = startSample; i < endSample; i++) {
|
|
const val = channelData[i];
|
|
if (val < min) min = val;
|
|
if (val > max) max = val;
|
|
}
|
|
|
|
// Draw vertical line from min to max amplitude
|
|
const yMin = centerY - max * centerY;
|
|
const yMax = centerY - min * centerY;
|
|
|
|
ctx.moveTo(x, yMin);
|
|
ctx.lineTo(x, yMax);
|
|
}
|
|
|
|
ctx.stroke();
|
|
};
|
|
|
|
const renderPreviewWaveformOnCanvas = () => {
|
|
if (!canvasRef.current || !previewContentRef.current || !previewWaveformPeaks || previewWaveformPeaks.length === 0) return;
|
|
|
|
const canvas = canvasRef.current;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
const contentRect = previewContentRef.current.getBoundingClientRect();
|
|
const width = Math.max(1, Math.round(contentRect.width));
|
|
const height = Math.max(1, Math.round(contentRect.height));
|
|
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
canvas.style.width = `${width}px`;
|
|
canvas.style.height = `${height}px`;
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
const centerY = height / 2;
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.85)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
|
|
for (let x = 0; x < width; x++) {
|
|
const peakIndex = Math.min(
|
|
previewWaveformPeaks.length - 1,
|
|
Math.floor((x / Math.max(1, width)) * previewWaveformPeaks.length)
|
|
);
|
|
const peak = previewWaveformPeaks[peakIndex];
|
|
const yMin = centerY - peak.max * centerY;
|
|
const yMax = centerY - peak.min * centerY;
|
|
ctx.moveTo(x, yMin);
|
|
ctx.lineTo(x, yMax);
|
|
}
|
|
|
|
ctx.stroke();
|
|
};
|
|
|
|
// 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(() => {
|
|
if (previewWaveformPeaks && previewWaveformPeaks.length > 0) {
|
|
renderPreviewWaveformOnCanvas();
|
|
} else if (audioRegion && audioBuffer) {
|
|
renderWaveformOnCanvas();
|
|
} else {
|
|
renderNotesOnCanvas();
|
|
}
|
|
}, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length, previewContentStyle?.width, redrawVersion]);
|
|
|
|
// Re-render canvas when region content size changes
|
|
useEffect(() => {
|
|
if (!previewContentRef.current) return;
|
|
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
if (previewWaveformPeaks && previewWaveformPeaks.length > 0) {
|
|
renderPreviewWaveformOnCanvas();
|
|
} else if (audioRegion && audioBuffer) {
|
|
renderWaveformOnCanvas();
|
|
} else {
|
|
renderNotesOnCanvas();
|
|
}
|
|
});
|
|
|
|
resizeObserver.observe(previewContentRef.current);
|
|
|
|
return () => {
|
|
if (previewContentRef.current) {
|
|
resizeObserver.unobserve(previewContentRef.current);
|
|
}
|
|
};
|
|
}, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, previewContentStyle?.width, redrawVersion]);
|
|
|
|
// 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;
|
|
if (isPreview) 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
|
|
if (isPreview) {
|
|
return;
|
|
}
|
|
|
|
const activeTool = KGMainContentState.instance().getActiveTool();
|
|
if (activeTool === 'pencil') {
|
|
// Still allow click events to pass through for region selection
|
|
if (onClick) {
|
|
if (DEBUG_MODE.REGION_ITEM) {
|
|
console.log(`REGION CLICKED (pencil mode): regionId=${id}`);
|
|
}
|
|
onClick(id, { shiftKey: e.shiftKey });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Prevent text selection during resize/drag
|
|
e.preventDefault();
|
|
|
|
// Reset movement tracking
|
|
isPendingDragRef.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 {
|
|
// Wait for actual pointer movement before promoting this gesture to a drag.
|
|
if (DEBUG_MODE.REGION_ITEM) {
|
|
console.log(`PENDING REGION INTERACTION: regionId=${id}`);
|
|
}
|
|
|
|
isPendingDragRef.current = true;
|
|
}
|
|
|
|
// 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) => {
|
|
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 || isPendingDragRef.current) {
|
|
const deltaX = e.clientX - initialMousePosRef.current.x;
|
|
const deltaY = e.clientY - initialMousePosRef.current.y;
|
|
const movedEnough = Math.hypot(deltaX, deltaY) >= DRAG_START_THRESHOLD_PX;
|
|
|
|
if (!isDraggingRef.current) {
|
|
if (!movedEnough) {
|
|
return;
|
|
}
|
|
|
|
if (DEBUG_MODE.REGION_ITEM) {
|
|
console.log(`DRAG START: regionId=${id}`);
|
|
}
|
|
|
|
isPendingDragRef.current = false;
|
|
setIsDragging(true);
|
|
isDraggingRef.current = true;
|
|
setCursor('grabbing');
|
|
|
|
if (onDragStart) {
|
|
onDragStart(id, initialMousePosRef.current.x, initialMousePosRef.current.y);
|
|
}
|
|
}
|
|
|
|
// Handle drag
|
|
if (DEBUG_MODE.REGION_ITEM) {
|
|
console.log(`DRAG MOVE: regionId=${id}, trackIndex=${trackIndex}`);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
} else if (isPendingDragRef.current && onClick) {
|
|
if (DEBUG_MODE.REGION_ITEM) {
|
|
console.log(`REGION CLICKED: regionId=${id}`);
|
|
}
|
|
onClick(id, { shiftKey: e.shiftKey });
|
|
}
|
|
|
|
isPendingDragRef.current = false;
|
|
|
|
// Remove global event listeners
|
|
document.removeEventListener('mousemove', handleGlobalMouseMove);
|
|
document.removeEventListener('mouseup', handleGlobalMouseUp);
|
|
};
|
|
|
|
// Fine-move handlers
|
|
const handleFineMoveGlobalMouseMove = (e: MouseEvent) => {
|
|
if (!isFineDraggingRef.current) return;
|
|
const rawDelta = e.clientX - fineMouseStartXRef.current;
|
|
fineRawDeltaRef.current = rawDelta;
|
|
const scaledDelta = rawDelta * REGION_CONSTANTS.FINE_MOVE_SPEED_RATIO;
|
|
setFineTranslateX(scaledDelta);
|
|
setFineDeltaDisplay(scaledDelta >= 0 ? `+${scaledDelta.toFixed(2)}` : `${scaledDelta.toFixed(2)}`);
|
|
};
|
|
|
|
const handleFineMoveGlobalMouseUp = () => {
|
|
if (!isFineDraggingRef.current) return;
|
|
isFineDraggingRef.current = false;
|
|
setIsFineDragging(false);
|
|
setFineTranslateX(0);
|
|
document.removeEventListener('mousemove', handleFineMoveGlobalMouseMove);
|
|
document.removeEventListener('mouseup', handleFineMoveGlobalMouseUp);
|
|
if (fineRawDeltaRef.current !== 0 && onFineMoveEnd) {
|
|
onFineMoveEnd(id, fineRawDeltaRef.current);
|
|
}
|
|
};
|
|
|
|
const handleFineMoveMouseDown = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (isFineDraggingRef.current) return;
|
|
fineMouseStartXRef.current = e.clientX;
|
|
fineRawDeltaRef.current = 0;
|
|
isFineDraggingRef.current = true;
|
|
setIsFineDragging(true);
|
|
setFineDeltaDisplay('+0.00');
|
|
document.addEventListener('mousemove', handleFineMoveGlobalMouseMove);
|
|
document.addEventListener('mouseup', handleFineMoveGlobalMouseUp);
|
|
};
|
|
|
|
// Clean up event listeners on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
document.removeEventListener('mousemove', handleGlobalMouseMove);
|
|
document.removeEventListener('mouseup', handleGlobalMouseUp);
|
|
document.removeEventListener('mousemove', handleFineMoveGlobalMouseMove);
|
|
document.removeEventListener('mouseup', handleFineMoveGlobalMouseUp);
|
|
};
|
|
}, []);
|
|
|
|
// 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 ? (isPrimarySelected ? 'selected' : 'selected-secondary') : ''} ${(audioRegion || isAudioRegion) ? 'audio-region' : ''}`}
|
|
style={{ ...style, cursor: isPreview ? 'default' : cursor, ...(isFineDragging ? { transform: `translateX(${fineTranslateX}px)`, zIndex: 100 } : {}) }}
|
|
onMouseMove={isPreview ? undefined : handleMouseMove}
|
|
onMouseLeave={isPreview ? undefined : handleMouseLeave}
|
|
onMouseDown={isPreview ? undefined : handleMouseDown}
|
|
data-region-id={id}
|
|
data-preview-region={isPreview ? 'true' : 'false'}
|
|
data-resize-edge={resizeEdge}
|
|
data-is-resizing={isResizing}
|
|
data-is-dragging={isDragging}
|
|
>
|
|
<div className="region-header">
|
|
{name}
|
|
</div>
|
|
<div className={`region-content${(audioRegion || isAudioRegion) ? ' audio-region-content' : ''}`}>
|
|
{!isPreview && <div className="region-left-buttons">
|
|
{!audioRegion && (
|
|
<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, { shiftKey: e.shiftKey });
|
|
}
|
|
}}
|
|
aria-label="Open piano roll"
|
|
>
|
|
<FaPencilAlt size={10} />
|
|
</button>
|
|
)}
|
|
{audioRegion && (
|
|
<button
|
|
className="region-waveform-btn"
|
|
title="View waveform"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
aria-label="View waveform"
|
|
>
|
|
<MdGraphicEq size={10} />
|
|
</button>
|
|
)}
|
|
{audioRegion && (
|
|
<button
|
|
className="region-spectrogram-btn"
|
|
title="View melodic spectrogram"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (onOpenSpectrogram) {
|
|
onOpenSpectrogram(id);
|
|
}
|
|
}}
|
|
aria-label="View spectrogram"
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
|
|
<rect x="3" y="0.5" width="6.5" height="2.5" rx="0.4"/>
|
|
<rect x="1.5" y="3.75" width="6.5" height="2.5" rx="0.4"/>
|
|
<rect x="0" y="7" width="6.5" height="2.5" rx="0.4"/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
{showHybridButton && (
|
|
<button
|
|
className="region-hybrid-btn"
|
|
title="Open in hybrid mode"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (onOpenHybrid) {
|
|
onOpenHybrid(id);
|
|
}
|
|
}}
|
|
aria-label="Open hybrid mode"
|
|
>
|
|
<FaPlus size={10} />
|
|
</button>
|
|
)}
|
|
<div className="region-fine-move-widget">
|
|
<button
|
|
className="region-fine-move-btn"
|
|
title="Fine move"
|
|
onMouseDown={handleFineMoveMouseDown}
|
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
|
aria-label="Fine move region"
|
|
>
|
|
<MdSwapHoriz size={10} />
|
|
</button>
|
|
{isFineDragging && (
|
|
<span className="region-fine-move-label">{fineDeltaDisplay}</span>
|
|
)}
|
|
</div>
|
|
</div>}
|
|
<div
|
|
className="region-preview-content"
|
|
ref={previewContentRef}
|
|
style={previewContentStyle}
|
|
data-preview-content-active={previewContentStyle ? 'true' : 'false'}
|
|
>
|
|
<canvas ref={canvasRef} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RegionItem;
|