import React, { useState, useRef, useEffect } from 'react'; import './Region.css'; 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 = ({ 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('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 hasMovedRef = useRef(false); // Canvas ref for note visualization const canvasRef = useRef(null); const regionContentRef = useRef(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(''); 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) => { // 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) => { // 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 (
{name}
); }; export default RegionItem;