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 = ({ 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('pointer'); const [resizeEdge, setResizeEdge] = useState('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(false); const isDraggingRef = useRef(false); const isPendingDragRef = useRef(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(null); const previewContentRef = useRef(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(''); 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) => { // 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) => { // 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) => { 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 (
{name}
{!isPreview &&
{!audioRegion && ( )} {audioRegion && ( )} {audioRegion && ( )} {showHybridButton && ( )}
{isFineDragging && ( {fineDeltaDisplay} )}
}
); }; export default RegionItem;