'use client'; import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'; import { Button } from '@/components/ui/button'; import { Undo2, Trash2, Minus, Plus, X } from 'lucide-react'; export interface AnnotationStroke { points: { x: number; y: number }[]; color: string; width: number; } export interface AnnotationCanvasHandle { getStrokes: () => AnnotationStroke[]; } interface AnnotationCanvasProps { mode: 'draw' | 'view'; strokes?: AnnotationStroke[]; onConfirm?: (strokes: AnnotationStroke[]) => void; onCancel?: () => void; onDismiss?: () => void; // For view mode, close overlay } const COLORS = ['#FF3B30', '#FF9500', '#FFCC00', '#34C759', '#007AFF', '#AF52DE', '#FFFFFF']; const DEFAULT_COLOR = '#FF3B30'; const DEFAULT_WIDTH = 3; const MIN_WIDTH = 1; const MAX_WIDTH = 10; // Reference canvas width for stroke scaling const REF_WIDTH = 1000; export const AnnotationCanvas = forwardRef(function AnnotationCanvas({ mode, strokes: initialStrokes, onConfirm, onCancel, onDismiss }, ref) { const canvasRef = useRef(null); const containerRef = useRef(null); const [strokes, setStrokes] = useState(initialStrokes || []); const [currentStroke, setCurrentStroke] = useState(null); const [color, setColor] = useState(DEFAULT_COLOR); const [width, setWidth] = useState(DEFAULT_WIDTH); const isDrawingRef = useRef(false); void onConfirm; // Expose getStrokes so parent can grab current drawing without confirm useImperativeHandle(ref, () => ({ getStrokes: () => strokes, }), [strokes]); // Render all strokes const renderStrokes = useCallback((ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, strokeList: AnnotationStroke[], active?: AnnotationStroke | null) => { ctx.clearRect(0, 0, canvas.width, canvas.height); const scale = canvas.width / REF_WIDTH; const draw = (s: AnnotationStroke) => { if (s.points.length < 2) return; ctx.strokeStyle = s.color; ctx.lineWidth = s.width * scale; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(s.points[0].x * canvas.width, s.points[0].y * canvas.height); for (let i = 1; i < s.points.length; i++) { ctx.lineTo(s.points[i].x * canvas.width, s.points[i].y * canvas.height); } ctx.stroke(); }; strokeList.forEach(draw); if (active) draw(active); }, []); // Keep a stable ref to the latest strokes/currentStroke so the resize // handler can redraw without being listed as a dependency (which would // re-register the listener on every stroke change). const strokesRef = useRef(strokes); const currentStrokeRef = useRef(currentStroke); useEffect(() => { strokesRef.current = strokes; }, [strokes]); useEffect(() => { currentStrokeRef.current = currentStroke; }, [currentStroke]); // Resize canvas to match container — listener registered once, never re-added. useEffect(() => { const resizeCanvas = () => { const canvas = canvasRef.current; const container = containerRef.current; if (!canvas || !container) return; const rect = container.getBoundingClientRect(); canvas.width = rect.width; canvas.height = rect.height; const ctx = canvas.getContext('2d'); if (ctx) renderStrokes(ctx, canvas, strokesRef.current, currentStrokeRef.current); }; resizeCanvas(); window.addEventListener('resize', resizeCanvas); return () => window.removeEventListener('resize', resizeCanvas); }, [renderStrokes]); // renderStrokes is stable (useCallback with no deps that change) // Re-render on stroke changes useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; renderStrokes(ctx, canvas, strokes, currentStroke); }, [strokes, currentStroke, renderStrokes]); const getPoint = useCallback((e: React.MouseEvent | React.TouchEvent) => { const canvas = canvasRef.current; if (!canvas) return null; const rect = canvas.getBoundingClientRect(); let clientX: number, clientY: number; if ('touches' in e) { clientX = e.touches[0].clientX; clientY = e.touches[0].clientY; } else { clientX = e.clientX; clientY = e.clientY; } return { x: (clientX - rect.left) / rect.width, y: (clientY - rect.top) / rect.height, }; }, []); const handlePointerDown = useCallback((e: React.MouseEvent | React.TouchEvent) => { if (mode !== 'draw') return; e.preventDefault(); e.stopPropagation(); const pt = getPoint(e); if (!pt) return; isDrawingRef.current = true; setCurrentStroke({ points: [pt], color, width }); }, [mode, color, width, getPoint]); const handlePointerMove = useCallback((e: React.MouseEvent | React.TouchEvent) => { if (mode !== 'draw' || !isDrawingRef.current) return; e.preventDefault(); e.stopPropagation(); const pt = getPoint(e); if (!pt) return; setCurrentStroke(prev => { if (!prev) return prev; return { ...prev, points: [...prev.points, pt] }; }); }, [mode, getPoint]); const handlePointerUp = useCallback((e: React.MouseEvent | React.TouchEvent) => { if (mode !== 'draw' || !isDrawingRef.current) return; e.preventDefault(); e.stopPropagation(); isDrawingRef.current = false; setCurrentStroke(prev => { if (prev && prev.points.length >= 2) { setStrokes(s => [...s, prev]); } return null; }); }, [mode]); const handleUndo = useCallback(() => { setStrokes(prev => prev.slice(0, -1)); }, []); const handleClear = useCallback(() => { setStrokes([]); }, []); // View mode: click to dismiss const handleViewClick = useCallback((e: React.MouseEvent) => { if (mode === 'view') { e.stopPropagation(); onDismiss?.(); } }, [mode, onDismiss]); if (mode === 'view') { return (
); } return (
e.stopPropagation()} > {/* Toolbar */}
{/* Colors */}
{COLORS.map(c => (
{/* Brush size */}
{width}
{/* Actions */}
{/* Close */}
); });