'use client'; import { useRef, useEffect } from 'react'; import { AnnotationData } from '../../lib/api'; export const COLORS = [ { name: 'Red', value: '#ef4444' }, { name: 'Orange', value: '#f97316' }, { name: 'Yellow', value: '#eab308' }, { name: 'Green', value: '#22c55e' }, { name: 'Blue', value: '#3b82f6' }, { name: 'Purple', value: '#a855f7' }, { name: 'White', value: '#ffffff' }, ]; export type Tool = 'pen' | 'arrow' | 'rect' | 'ellipse'; // ── Standalone render function (used by both draw canvas and display canvas) ── export function drawShape(ctx: CanvasRenderingContext2D, ann: AnnotationData) { ctx.save(); ctx.strokeStyle = ann.color; ctx.fillStyle = ann.color; ctx.lineWidth = 3; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; if (ann.type === 'pen' && ann.points && ann.points.length >= 2) { ctx.beginPath(); ctx.moveTo(ann.points[0][0] * ctx.canvas.width, ann.points[0][1] * ctx.canvas.height); for (let i = 1; i < ann.points.length; i++) { ctx.lineTo(ann.points[i][0] * ctx.canvas.width, ann.points[i][1] * ctx.canvas.height); } ctx.stroke(); } else if (ann.type === 'arrow' && ann.points && ann.points.length >= 2) { const [x1, y1] = ann.points[0]; const [x2, y2] = ann.points[ann.points.length - 1]; const sx = x1 * ctx.canvas.width, sy = y1 * ctx.canvas.height; const ex = x2 * ctx.canvas.width, ey = y2 * ctx.canvas.height; const angle = Math.atan2(ey - sy, ex - sx); const headLen = 16; ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke(); ctx.beginPath(); ctx.moveTo(ex, ey); ctx.lineTo(ex - headLen * Math.cos(angle - Math.PI / 6), ey - headLen * Math.sin(angle - Math.PI / 6)); ctx.moveTo(ex, ey); ctx.lineTo(ex - headLen * Math.cos(angle + Math.PI / 6), ey - headLen * Math.sin(angle + Math.PI / 6)); ctx.stroke(); } else if (ann.type === 'rect' && ann.boundingBox) { const { x, y, width: w, height: h } = ann.boundingBox; ctx.strokeRect(x * ctx.canvas.width, y * ctx.canvas.height, w * ctx.canvas.width, h * ctx.canvas.height); } else if (ann.type === 'ellipse' && ann.boundingBox) { const { x, y, width: w, height: h } = ann.boundingBox; ctx.beginPath(); ctx.ellipse( (x + w / 2) * ctx.canvas.width, (y + h / 2) * ctx.canvas.height, (w / 2) * ctx.canvas.width, (h / 2) * ctx.canvas.height, 0, 0, 2 * Math.PI ); ctx.stroke(); } ctx.restore(); } interface Props { isActive: boolean; tool: Tool; color: string; width: number; height: number; // Already-saved strokes to keep on canvas until Save/Undo pendingStrokes: AnnotationData[]; onStrokeComplete: (stroke: AnnotationData) => void; } interface DrawState { type: Tool; color: string; startX: number; startY: number; points: [number, number][]; } export function AnnotationCanvas({ isActive, tool, color, width, height, pendingStrokes, onStrokeComplete, }: Props) { const canvasRef = useRef(null); const isDrawingRef = useRef(false); const drawRef = useRef(null); // ── Full canvas redraw (clear + all saved strokes + live stroke) ───────────── function redraw() { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); // Draw all strokes that are still pending (not yet saved) for (const stroke of pendingStrokes) { drawShape(ctx, stroke); } // Draw the stroke currently being drawn if (drawRef.current) drawShape(ctx, toAnnotation(drawRef.current)); } // ── Convert draw state → AnnotationData ──────────────────────────────────── function toAnnotation(ds: DrawState): AnnotationData { if (ds.type === 'rect') { const minX = Math.min(ds.startX, ds.points[ds.points.length - 1][0]); const minY = Math.min(ds.startY, ds.points[ds.points.length - 1][1]); const maxX = Math.max(ds.startX, ds.points[ds.points.length - 1][0]); const maxY = Math.max(ds.startY, ds.points[ds.points.length - 1][1]); return { type: 'rect', color: ds.color, boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, points: ds.points }; } if (ds.type === 'ellipse') { const minX = Math.min(ds.startX, ds.points[ds.points.length - 1][0]); const minY = Math.min(ds.startY, ds.points[ds.points.length - 1][1]); const maxX = Math.max(ds.startX, ds.points[ds.points.length - 1][0]); const maxY = Math.max(ds.startY, ds.points[ds.points.length - 1][1]); return { type: 'ellipse', color: ds.color, boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, points: ds.points }; } return { type: ds.type === 'arrow' ? 'arrow' : 'pen', color: ds.color, points: ds.points }; } // ── Canvas resize ──────────────────────────────────────────────────────────── useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; canvas.width = width; canvas.height = height; redraw(); }, [width, height, pendingStrokes]); // ── Normalise mouse / touch position ──────────────────────────────────────── function pos(e: React.MouseEvent): [number, number] { const rect = canvasRef.current!.getBoundingClientRect(); return [ (e.clientX - rect.left) / rect.width, (e.clientY - rect.top) / rect.height, ]; } // ── Mouse handlers ─────────────────────────────────────────────────────────── const onDown = (e: React.MouseEvent) => { if (!isActive) return; e.preventDefault(); e.stopPropagation(); const [x, y] = pos(e); isDrawingRef.current = true; drawRef.current = { type: tool, color, startX: x, startY: y, points: [[x, y]] }; }; const onMove = (e: React.MouseEvent) => { if (!isDrawingRef.current || !drawRef.current) return; e.preventDefault(); e.stopPropagation(); const [x, y] = pos(e); const pts = drawRef.current.points; drawRef.current = { ...drawRef.current, points: [...pts, [x, y]] }; redraw(); }; const onUp = (e: React.MouseEvent) => { if (!isDrawingRef.current || !drawRef.current) return; e.preventDefault(); e.stopPropagation(); isDrawingRef.current = false; const [x, y] = pos(e); const pts = [...drawRef.current.points, [x, y] as [number, number]]; drawRef.current = { ...drawRef.current, points: pts }; onStrokeComplete(toAnnotation(drawRef.current)); drawRef.current = null; redraw(); }; return ( { if (isActive) e.stopPropagation(); }} onMouseDown={onDown} onMouseMove={onMove} onMouseUp={onUp} onMouseLeave={onUp} /> ); }