'use client'; import { nanoid } from 'nanoid'; import { useEffect, useRef, useState } from 'react'; import type { Annotation, AnnotationShape } from '../../types'; export type AnnotationTool = AnnotationShape | 'select'; interface AnnotationsProps { annotations: Annotation[]; editable?: boolean; tool?: AnnotationTool; color?: string; strokeWidth?: number; onChange?: (next: Annotation[]) => void; } /** * SVG markup overlay. Renders (and, when editable, lets you draw) rectangles, * ellipses, lines, arrows, double-arrows with a centered measurement label, * text and freehand strokes. Geometry is stored normalized (0..1) so it scales * with the image. Read-only mode (editable=false) ignores pointer events. */ export function Annotations({ annotations, editable = false, tool = 'select', color = '#ff3b30', strokeWidth = 3, onChange, }: AnnotationsProps) { const ref = useRef(null); const [size, setSize] = useState({ w: 0, h: 0 }); const [draft, setDraft] = useState(null); const [editingId, setEditingId] = useState(null); const drawing = useRef(false); useEffect(() => { const el = ref.current; if (!el) return; const update = () => setSize({ w: el.clientWidth, h: el.clientHeight }); update(); const ro = new ResizeObserver(update); ro.observe(el); return () => ro.disconnect(); }, []); const norm = (e: React.PointerEvent) => { const rect = ref.current!.getBoundingClientRect(); return { x: clamp01((e.clientX - rect.left) / rect.width), y: clamp01((e.clientY - rect.top) / rect.height), }; }; const commit = (next: Annotation[]) => onChange?.(next); const onPointerDown = (e: React.PointerEvent) => { if (!editable || tool === 'select') return; e.preventDefault(); (e.target as Element).setPointerCapture?.(e.pointerId); const p = norm(e); drawing.current = true; const base: Annotation = { id: nanoid(8), shape: tool, color, strokeWidth, x1: p.x, y1: p.y, x2: p.x, y2: p.y, }; if (tool === 'freehand') base.points = [{ x: p.x, y: p.y }]; if (tool === 'text') { // Place immediately and open the inline editor. base.text = ''; commit([...annotations, base]); setEditingId(base.id); drawing.current = false; return; } setDraft(base); }; const onPointerMove = (e: React.PointerEvent) => { if (!drawing.current || !draft) return; const p = norm(e); setDraft((d) => d ? { ...d, x2: p.x, y2: p.y, points: d.shape === 'freehand' ? [...(d.points ?? []), { x: p.x, y: p.y }] : d.points, } : d, ); }; const onPointerUp = () => { if (!drawing.current || !draft) return; drawing.current = false; const moved = Math.hypot(draft.x2 - draft.x1, draft.y2 - draft.y1) > 0.01 || draft.shape === 'freehand'; if (moved) { commit([...annotations, draft]); if (draft.shape === 'double-arrow') { draft.text = ''; setEditingId(draft.id); } } setDraft(null); }; const updateText = (id: string, text: string) => commit(annotations.map((a) => (a.id === id ? { ...a, text } : a))); const finishEditing = (id: string) => { const a = annotations.find((x) => x.id === id); if (a && a.shape === 'text' && !a.text?.trim()) commit(annotations.filter((x) => x.id !== id)); setEditingId(null); }; const all = draft ? [...annotations, draft] : annotations; const editingAnn = editingId ? annotations.find((a) => a.id === editingId) : null; return (
{size.w > 0 ? ( {all.map((a) => ( ))} ) : null} {editable && editingAnn ? ( updateText(editingAnn.id, e.target.value)} onBlur={() => finishEditing(editingAnn.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === 'Escape') finishEditing(editingAnn.id); }} placeholder={editingAnn.shape === 'double-arrow' ? 'e.g. 12 cm' : 'Text'} style={{ position: 'absolute', left: `${((editingAnn.x1 + (editingAnn.shape === 'double-arrow' ? editingAnn.x2 : editingAnn.x1)) / 2) * 100}%`, top: `${((editingAnn.y1 + (editingAnn.shape === 'double-arrow' ? editingAnn.y2 : editingAnn.y1)) / 2) * 100}%`, transform: 'translate(-50%, -50%)', font: '600 14px system-ui, sans-serif', color: editingAnn.color, background: 'rgba(255,255,255,0.95)', border: `2px solid ${editingAnn.color}`, borderRadius: 6, padding: '2px 6px', minWidth: 60, outline: 'none', zIndex: 2, }} /> ) : null}
); } function Shape({ a, w, h }: { a: Annotation; w: number; h: number }) { const x1 = a.x1 * w; const y1 = a.y1 * h; const x2 = a.x2 * w; const y2 = a.y2 * h; const common = { stroke: a.color, strokeWidth: a.strokeWidth, fill: 'none' as const }; const headSize = 9 + a.strokeWidth * 2; switch (a.shape) { case 'rect': return ( ); case 'ellipse': return ( ); case 'line': return ; case 'arrow': return ( ); case 'double-arrow': return ( {a.text ? ( {a.text} ) : null} ); case 'text': return a.text ? ( {a.text} ) : null; case 'freehand': return ( `${p.x * w},${p.y * h}`).join(' ')} {...common} strokeLinecap="round" strokeLinejoin="round" /> ); default: return null; } } /** Triangle polygon for an arrowhead pointing from (fx,fy) toward (tx,ty). */ function arrowHead(fx: number, fy: number, tx: number, ty: number, size: number): string { const ang = Math.atan2(ty - fy, tx - fx); const a1 = ang + Math.PI - 0.45; const a2 = ang + Math.PI + 0.45; const p1 = `${tx},${ty}`; const p2 = `${tx + size * Math.cos(a1)},${ty + size * Math.sin(a1)}`; const p3 = `${tx + size * Math.cos(a2)},${ty + size * Math.sin(a2)}`; return `${p1} ${p2} ${p3}`; } const clamp01 = (v: number) => Math.max(0, Math.min(1, v));