'use client'; import * as React from 'react'; import { cn } from '@/lib/utils/cn'; import type { AutomationLane as AutomationLaneType, AutomationPoint as AutomationPointType } from '@/types/automation'; import { AutomationPoint } from './AutomationPoint'; export interface AutomationLaneProps { lane: AutomationLaneType; duration: number; // Total timeline duration in seconds zoom: number; // Zoom factor currentTime?: number; // Playhead position onUpdateLane?: (updates: Partial) => void; onAddPoint?: (time: number, value: number) => void; onUpdatePoint?: (pointId: string, updates: Partial) => void; onRemovePoint?: (pointId: string) => void; className?: string; } export function AutomationLane({ lane, duration, zoom, currentTime = 0, onUpdateLane, onAddPoint, onUpdatePoint, onRemovePoint, className, }: AutomationLaneProps) { const canvasRef = React.useRef(null); const containerRef = React.useRef(null); const [selectedPointId, setSelectedPointId] = React.useState(null); const [isDraggingPoint, setIsDraggingPoint] = React.useState(false); // Convert time to X pixel position const timeToX = React.useCallback( (time: number): number => { if (!containerRef.current) return 0; const width = containerRef.current.clientWidth; return (time / duration) * width * zoom; }, [duration, zoom] ); // Convert value (0-1) to Y pixel position (inverted: 0 at bottom, 1 at top) const valueToY = React.useCallback( (value: number): number => { if (!containerRef.current) return 0; const height = lane.height; return height * (1 - value); }, [lane.height] ); // Convert X pixel position to time const xToTime = React.useCallback( (x: number): number => { if (!containerRef.current) return 0; const width = containerRef.current.clientWidth; return (x / (width * zoom)) * duration; }, [duration, zoom] ); // Convert Y pixel position to value (0-1) const yToValue = React.useCallback( (y: number): number => { const height = lane.height; return Math.max(0, Math.min(1, 1 - y / height)); }, [lane.height] ); // Draw automation curve React.useEffect(() => { if (!canvasRef.current || !lane.visible) return; const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); if (!ctx) return; const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); const width = rect.width; const height = rect.height; // Clear canvas ctx.clearRect(0, 0, width, height); // Background ctx.fillStyle = getComputedStyle(canvas).getPropertyValue('--color-background') || 'rgb(15, 23, 42)'; ctx.fillRect(0, 0, width, height); // Grid lines (horizontal value guides) ctx.strokeStyle = 'rgba(148, 163, 184, 0.1)'; ctx.lineWidth = 1; for (let i = 0; i <= 4; i++) { const y = (height / 4) * i; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); } // Draw automation curve if (lane.points.length > 0) { const color = lane.color || 'rgb(59, 130, 246)'; ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath(); // Sort points by time const sortedPoints = [...lane.points].sort((a, b) => a.time - b.time); // Draw lines between points for (let i = 0; i < sortedPoints.length; i++) { const point = sortedPoints[i]; const x = timeToX(point.time); const y = valueToY(point.value); if (i === 0) { // Start from left edge at first point's value ctx.moveTo(0, y); ctx.lineTo(x, y); } else { const prevPoint = sortedPoints[i - 1]; const prevX = timeToX(prevPoint.time); const prevY = valueToY(prevPoint.value); if (point.curve === 'step') { // Step curve: horizontal then vertical ctx.lineTo(x, prevY); ctx.lineTo(x, y); } else { // Linear curve (bezier not implemented yet) ctx.lineTo(x, y); } } // Extend to right edge from last point if (i === sortedPoints.length - 1) { ctx.lineTo(width, y); } } ctx.stroke(); // Fill area under curve ctx.globalAlpha = 0.2; ctx.fillStyle = color; ctx.lineTo(width, height); ctx.lineTo(0, height); ctx.closePath(); ctx.fill(); ctx.globalAlpha = 1.0; } // Draw playhead if (currentTime >= 0 && duration > 0) { const playheadX = timeToX(currentTime); if (playheadX >= 0 && playheadX <= width) { ctx.strokeStyle = 'rgba(239, 68, 68, 0.8)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(playheadX, 0); ctx.lineTo(playheadX, height); ctx.stroke(); } } }, [lane, duration, zoom, currentTime, timeToX, valueToY]); // Handle canvas click to add point const handleCanvasClick = React.useCallback( (e: React.MouseEvent) => { if (isDraggingPoint || !onAddPoint) return; const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const time = xToTime(x); const value = yToValue(y); onAddPoint(time, value); }, [isDraggingPoint, onAddPoint, xToTime, yToValue] ); // Handle point drag const handlePointDragStart = React.useCallback((pointId: string) => { setIsDraggingPoint(true); setSelectedPointId(pointId); }, []); const handlePointDrag = React.useCallback( (pointId: string, deltaX: number, deltaY: number) => { if (!containerRef.current || !onUpdatePoint) return; const point = lane.points.find((p) => p.id === pointId); if (!point) return; const rect = containerRef.current.getBoundingClientRect(); const width = rect.width; // Calculate new time and value const timePerPixel = duration / (width * zoom); const valuePerPixel = 1 / lane.height; const newTime = Math.max(0, Math.min(duration, point.time + deltaX * timePerPixel)); const newValue = Math.max(0, Math.min(1, point.value - deltaY * valuePerPixel)); onUpdatePoint(pointId, { time: newTime, value: newValue }); }, [lane.points, lane.height, duration, zoom, onUpdatePoint] ); const handlePointDragEnd = React.useCallback(() => { setIsDraggingPoint(false); }, []); // Handle point click (select) const handlePointClick = React.useCallback((pointId: string, event: React.MouseEvent) => { event.stopPropagation(); setSelectedPointId(pointId); }, []); // Handle point double-click (delete) const handlePointDoubleClick = React.useCallback( (pointId: string) => { if (onRemovePoint) { onRemovePoint(pointId); } }, [onRemovePoint] ); // Handle keyboard delete React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.key === 'Delete' || e.key === 'Backspace') && selectedPointId && onRemovePoint) { e.preventDefault(); onRemovePoint(selectedPointId); setSelectedPointId(null); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedPointId, onRemovePoint]); // Get current value at playhead (interpolated) const getCurrentValue = React.useCallback((): number | undefined => { if (lane.points.length === 0) return undefined; const sortedPoints = [...lane.points].sort((a, b) => a.time - b.time); // Find surrounding points let prevPoint = sortedPoints[0]; let nextPoint = sortedPoints[sortedPoints.length - 1]; for (let i = 0; i < sortedPoints.length - 1; i++) { if (sortedPoints[i].time <= currentTime && sortedPoints[i + 1].time >= currentTime) { prevPoint = sortedPoints[i]; nextPoint = sortedPoints[i + 1]; break; } } // Interpolate if (currentTime <= prevPoint.time) return prevPoint.value; if (currentTime >= nextPoint.time) return nextPoint.value; const timeDelta = nextPoint.time - prevPoint.time; const valueDelta = nextPoint.value - prevPoint.value; const progress = (currentTime - prevPoint.time) / timeDelta; return prevPoint.value + valueDelta * progress; }, [lane.points, currentTime]); if (!lane.visible) return null; return (
{/* Automation points */} {lane.points.map((point) => ( ))}
); }