"use client"; import { useRef, useState, useEffect, useCallback, type MouseEvent } from "react"; import type { Annotation } from "@/lib/types"; import { generateId } from "@/lib/store"; type Tool = "rect" | "arrow" | "freehand" | "text"; interface AnnotationCanvasProps { imageUrl: string; onComplete: (annotations: Annotation[], snapshot: string) => void; onCancel: () => void; } export function AnnotationCanvas({ imageUrl, onComplete, onCancel }: AnnotationCanvasProps) { const containerRef = useRef(null); const canvasRef = useRef(null); const imageRef = useRef(null); const [tool, setTool] = useState("rect"); const [annotations, setAnnotations] = useState([]); const [drawing, setDrawing] = useState(false); const [startPos, setStartPos] = useState<{ x: number; y: number } | null>(null); const [currentPos, setCurrentPos] = useState<{ x: number; y: number } | null>(null); const [freehandPoints, setFreehandPoints] = useState<{ x: number; y: number }[]>([]); const [editingAnnotation, setEditingAnnotation] = useState(null); const [editText, setEditText] = useState(""); const [undoStack, setUndoStack] = useState([]); const [imgLoaded, setImgLoaded] = useState(false); // 获取鼠标相对于 canvas 的坐标(归一化到图片尺寸) const getRelPos = useCallback((e: MouseEvent): { x: number; y: number } | null => { const canvas = canvasRef.current; if (!canvas) return null; const rect = canvas.getBoundingClientRect(); return { x: (e.clientX - rect.left) / rect.width, y: (e.clientY - rect.top) / rect.height, }; }, []); // 加载图片 useEffect(() => { const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => { imageRef.current = img; setImgLoaded(true); }; img.src = imageUrl; }, [imageUrl]); // 渲染 canvas const render = useCallback(() => { const canvas = canvasRef.current; const img = imageRef.current; if (!canvas || !img) return; const ctx = canvas.getContext("2d"); if (!ctx) return; canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; ctx.drawImage(img, 0, 0); const w = canvas.width; const h = canvas.height; // 绘制已有标注 for (const ann of annotations) { drawAnnotation(ctx, ann, w, h, false); } // 绘制当前正在创建的标注 if (drawing && startPos && currentPos) { ctx.strokeStyle = "#f43f5e"; ctx.lineWidth = Math.max(2, w * 0.003); ctx.setLineDash([w * 0.005, w * 0.003]); if (tool === "rect") { const rx = startPos.x * w, ry = startPos.y * h; const rw = (currentPos.x - startPos.x) * w; const rh = (currentPos.y - startPos.y) * h; ctx.strokeRect(rx, ry, rw, rh); } else if (tool === "arrow") { ctx.beginPath(); ctx.moveTo(startPos.x * w, startPos.y * h); ctx.lineTo(currentPos.x * w, currentPos.y * h); ctx.stroke(); drawArrowHead(ctx, startPos.x * w, startPos.y * h, currentPos.x * w, currentPos.y * h, w * 0.015); } else if (tool === "freehand" && freehandPoints.length > 1) { ctx.beginPath(); ctx.moveTo(freehandPoints[0].x * w, freehandPoints[0].y * h); for (let i = 1; i < freehandPoints.length; i++) { ctx.lineTo(freehandPoints[i].x * w, freehandPoints[i].y * h); } ctx.stroke(); } ctx.setLineDash([]); } }, [annotations, drawing, startPos, currentPos, freehandPoints, tool]); useEffect(() => { if (imgLoaded) render(); }, [imgLoaded, render]); // 鼠标事件 const handleMouseDown = (e: MouseEvent) => { if (tool === "text") { const pos = getRelPos(e); if (!pos) return; const ann: Annotation = { id: generateId("ann-"), type: "text", x: pos.x, y: pos.y, text: "", }; pushUndo(); setAnnotations((prev) => [...prev, ann]); setEditingAnnotation(ann.id); setEditText(""); return; } const pos = getRelPos(e); if (!pos) return; setDrawing(true); setStartPos(pos); setCurrentPos(pos); if (tool === "freehand") { setFreehandPoints([pos]); } }; const handleMouseMove = (e: MouseEvent) => { if (!drawing) return; const pos = getRelPos(e); if (!pos) return; setCurrentPos(pos); if (tool === "freehand") { setFreehandPoints((prev) => [...prev, pos]); } }; const handleMouseUp = () => { if (!drawing || !startPos || !currentPos) { setDrawing(false); return; } pushUndo(); if (tool === "rect") { const ann: Annotation = { id: generateId("ann-"), type: "rect", x: Math.min(startPos.x, currentPos.x), y: Math.min(startPos.y, currentPos.y), w: Math.abs(currentPos.x - startPos.x), h: Math.abs(currentPos.y - startPos.y), text: "", }; setAnnotations((prev) => [...prev, ann]); setEditingAnnotation(ann.id); setEditText(""); } else if (tool === "arrow") { const ann: Annotation = { id: generateId("ann-"), type: "arrow", x: startPos.x, y: startPos.y, w: currentPos.x - startPos.x, h: currentPos.y - startPos.y, text: "", }; setAnnotations((prev) => [...prev, ann]); setEditingAnnotation(ann.id); setEditText(""); } else if (tool === "freehand") { const ann: Annotation = { id: generateId("ann-"), type: "freehand", x: freehandPoints[0]?.x ?? 0, y: freehandPoints[0]?.y ?? 0, points: [...freehandPoints], text: "", }; setAnnotations((prev) => [...prev, ann]); setFreehandPoints([]); } setDrawing(false); setStartPos(null); setCurrentPos(null); }; const pushUndo = () => { setUndoStack((prev) => [...prev, [...annotations]]); }; const handleUndo = () => { if (undoStack.length === 0) return; const prev = undoStack[undoStack.length - 1]; setUndoStack((s) => s.slice(0, -1)); setAnnotations(prev); setEditingAnnotation(null); }; const handleClear = () => { pushUndo(); setAnnotations([]); setEditingAnnotation(null); }; const commitText = () => { if (!editingAnnotation) return; setAnnotations((prev) => prev.map((a) => (a.id === editingAnnotation ? { ...a, text: editText } : a)) ); setEditingAnnotation(null); setEditText(""); }; const handleComplete = () => { // 先提交正在编辑的文本 let finalAnnotations = annotations; if (editingAnnotation) { finalAnnotations = annotations.map((a) => a.id === editingAnnotation ? { ...a, text: editText } : a ); } const canvas = canvasRef.current; if (!canvas) return; // 最终渲染一次(含所有文本) const img = imageRef.current; if (img) { const ctx = canvas.getContext("2d"); if (ctx) { canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; ctx.drawImage(img, 0, 0); for (const ann of finalAnnotations) { drawAnnotation(ctx, ann, canvas.width, canvas.height, true); } } } const snapshot = canvas.toDataURL("image/png"); onComplete(finalAnnotations, snapshot); }; const TOOLS: { key: Tool; label: string; icon: string }[] = [ { key: "rect", label: "矩形", icon: "□" }, { key: "arrow", label: "箭头", icon: "→" }, { key: "freehand", label: "画笔", icon: "✎" }, { key: "text", label: "文字", icon: "T" }, ]; return (
{/* 工具栏 */}
{TOOLS.map((t) => ( ))}
{/* Canvas 区域 */}
{/* 文字输入弹出框 */} {editingAnnotation && (() => { const ann = annotations.find((a) => a.id === editingAnnotation); if (!ann) return null; const canvas = canvasRef.current; if (!canvas) return null; const rect = canvas.getBoundingClientRect(); const container = containerRef.current; if (!container) return null; const containerRect = container.getBoundingClientRect(); let px: number, py: number; if (ann.type === "rect") { px = (ann.x + (ann.w ?? 0)) * rect.width + (rect.left - containerRect.left); py = ann.y * rect.height + (rect.top - containerRect.top); } else { px = ann.x * rect.width + (rect.left - containerRect.left) + 10; py = ann.y * rect.height + (rect.top - containerRect.top); } return (
setEditText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") commitText(); if (e.key === "Escape") { setAnnotations((prev) => prev.filter((a) => a.id !== editingAnnotation)); setEditingAnnotation(null); } }} placeholder="输入批注..." className="w-full px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border)] rounded text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] focus:outline-none focus:border-[var(--accent)]" />
); })()}
); } // --- 绘制辅助函数 --- function drawAnnotation( ctx: CanvasRenderingContext2D, ann: Annotation, w: number, h: number, isFinal: boolean, ) { const lineWidth = Math.max(2, w * 0.003); ctx.strokeStyle = "#f43f5e"; ctx.fillStyle = "#f43f5e"; ctx.lineWidth = lineWidth; ctx.setLineDash([]); switch (ann.type) { case "rect": ctx.strokeRect(ann.x * w, ann.y * h, (ann.w ?? 0) * w, (ann.h ?? 0) * h); break; case "arrow": { const x1 = ann.x * w, y1 = ann.y * h; const x2 = (ann.x + (ann.w ?? 0)) * w, y2 = (ann.y + (ann.h ?? 0)) * h; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); drawArrowHead(ctx, x1, y1, x2, y2, w * 0.015); break; } case "freehand": if (ann.points && ann.points.length > 1) { ctx.beginPath(); ctx.moveTo(ann.points[0].x * w, ann.points[0].y * h); for (let i = 1; i < ann.points.length; i++) { ctx.lineTo(ann.points[i].x * w, ann.points[i].y * h); } ctx.stroke(); } break; case "text": break; } // 绘制文字标签 if (ann.text) { const fontSize = Math.max(12, w * 0.018); ctx.font = `bold ${fontSize}px system-ui, sans-serif`; const metrics = ctx.measureText(ann.text); const padding = fontSize * 0.4; let tx: number, ty: number; if (ann.type === "rect") { tx = ann.x * w; ty = ann.y * h - padding; } else { tx = ann.x * w; ty = ann.y * h - padding; } // 文字背景 ctx.fillStyle = "rgba(244, 63, 94, 0.85)"; ctx.fillRect( tx - padding * 0.5, ty - fontSize, metrics.width + padding, fontSize + padding ); // 文字 ctx.fillStyle = "#ffffff"; ctx.fillText(ann.text, tx, ty); } } function drawArrowHead( ctx: CanvasRenderingContext2D, fromX: number, fromY: number, toX: number, toY: number, size: number, ) { const angle = Math.atan2(toY - fromY, toX - fromX); ctx.beginPath(); ctx.moveTo(toX, toY); ctx.lineTo(toX - size * Math.cos(angle - Math.PI / 6), toY - size * Math.sin(angle - Math.PI / 6)); ctx.lineTo(toX - size * Math.cos(angle + Math.PI / 6), toY - size * Math.sin(angle + Math.PI / 6)); ctx.closePath(); ctx.fill(); }