Files
EPEEAIKit/art-agent/frontend/src/components/detail/annotation-canvas.tsx

488 lines
15 KiB
TypeScript

"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<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const [tool, setTool] = useState<Tool>("rect");
const [annotations, setAnnotations] = useState<Annotation[]>([]);
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<string | null>(null);
const [editText, setEditText] = useState("");
const [undoStack, setUndoStack] = useState<Annotation[][]>([]);
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 (
<div className="flex flex-col h-full">
{/* 工具栏 */}
<div className="flex items-center gap-1 px-3 py-2 border-b border-[var(--border)] bg-[var(--bg-tertiary)] flex-wrap">
{TOOLS.map((t) => (
<button
key={t.key}
onClick={() => setTool(t.key)}
className={`px-2.5 py-1 text-xs rounded cursor-pointer transition-colors ${
tool === t.key
? "bg-[var(--accent)] text-white"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-primary)]"
}`}
title={t.label}
>
{t.icon} {t.label}
</button>
))}
<div className="w-px h-4 bg-[var(--border)] mx-1" />
<button
onClick={handleUndo}
disabled={undoStack.length === 0}
className="px-2 py-1 text-xs rounded text-[var(--text-secondary)]
hover:text-[var(--text-primary)] bg-[var(--bg-primary)]
disabled:opacity-30 cursor-pointer"
>
</button>
<button
onClick={handleClear}
disabled={annotations.length === 0}
className="px-2 py-1 text-xs rounded text-[var(--text-secondary)]
hover:text-[var(--text-primary)] bg-[var(--bg-primary)]
disabled:opacity-30 cursor-pointer"
>
</button>
<div className="flex-1" />
<button
onClick={onCancel}
className="px-2.5 py-1 text-xs rounded text-[var(--text-secondary)]
hover:text-[var(--text-primary)] cursor-pointer"
>
</button>
<button
onClick={handleComplete}
className="px-3 py-1 text-xs rounded bg-[var(--accent)] text-white
hover:bg-[var(--accent-hover)] cursor-pointer"
>
</button>
</div>
{/* Canvas 区域 */}
<div ref={containerRef} className="flex-1 overflow-auto p-3 relative">
<canvas
ref={canvasRef}
className="max-w-full rounded-lg cursor-crosshair"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
{/* 文字输入弹出框 */}
{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 (
<div
className="absolute z-10 bg-[var(--bg-secondary)] border border-[var(--accent)]
rounded-lg shadow-lg p-2 min-w-[180px]"
style={{ left: px, top: py }}
>
<input
autoFocus
value={editText}
onChange={(e) => 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)]"
/>
<div className="flex justify-end gap-1 mt-1.5">
<button
onClick={() => {
setAnnotations((prev) => prev.filter((a) => a.id !== editingAnnotation));
setEditingAnnotation(null);
}}
className="px-2 py-0.5 text-[10px] text-[var(--text-secondary)] cursor-pointer"
>
</button>
<button
onClick={commitText}
className="px-2 py-0.5 text-[10px] bg-[var(--accent)] text-white
rounded cursor-pointer"
>
</button>
</div>
</div>
);
})()}
</div>
</div>
);
}
// --- 绘制辅助函数 ---
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();
}