ai-chatbot/components/image-annotator.tsx
Your Name 5b19e6deba
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
优化图片标注和加载组件
2025-12-19 01:20:58 +08:00

502 lines
17 KiB
TypeScript

"use client";
import React, { useRef, useState, useEffect, useCallback } from "react";
import { X, Pencil, Circle, ArrowRight, Type, Check, Undo, Trash2 } from "lucide-react";
import { Button } from "./ui/button";
type Tool = "pen" | "arrow" | "circle" | "text";
type DrawingAction = {
type: Tool;
color: string;
lineWidth: number;
points?: { x: number; y: number }[];
startX?: number;
startY?: number;
endX?: number;
endY?: number;
radius?: number;
text?: string;
fontSize?: number;
};
interface ImageAnnotatorProps {
imageUrl: string;
onConfirm: (annotatedImageUrl: string) => void;
onCancel: () => void;
}
const COLORS = [
"#FF0000", // 红色
"#FF6B00", // 橙色
"#FFCC00", // 黄色
"#00CC00", // 绿色
"#0099FF", // 蓝色
"#9933FF", // 紫色
"#FF0099", // 粉色
"#FFFFFF", // 白色
"#000000", // 黑色
];
const LINE_WIDTHS = [2, 4, 6, 8];
export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotatorProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [ctx, setCtx] = useState<CanvasRenderingContext2D | null>(null);
const [image, setImage] = useState<HTMLImageElement | null>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [tool, setTool] = useState<Tool>("pen");
const [color, setColor] = useState("#FF0000");
const [lineWidth, setLineWidth] = useState(4);
const [actions, setActions] = useState<DrawingAction[]>([]);
const [currentAction, setCurrentAction] = useState<DrawingAction | null>(null);
const [textInput, setTextInput] = useState("");
const [textPosition, setTextPosition] = useState<{ x: number; y: number } | null>(null);
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [isLoading, setIsLoading] = useState(true);
// 加载图片
useEffect(() => {
setIsLoading(true);
const img = new window.Image();
img.crossOrigin = "anonymous";
img.onload = () => {
setImage(img);
setIsLoading(false);
};
img.onerror = () => {
setIsLoading(false);
};
img.src = imageUrl;
}, [imageUrl]);
// 初始化 canvas - 适配弹窗大小
useEffect(() => {
if (!canvasRef.current || !image || !containerRef.current) return;
const canvas = canvasRef.current;
const container = containerRef.current;
const context = canvas.getContext("2d");
if (!context) return;
// 弹窗内画布区域大小(减去工具栏和边距)
const containerWidth = 800; // 850 - padding
const containerHeight = 350; // 550 - 工具栏高度
const scaleX = containerWidth / image.width;
const scaleY = containerHeight / image.height;
const newScale = Math.min(scaleX, scaleY, 1); // 不放大,只缩小
setScale(newScale);
canvas.width = image.width * newScale;
canvas.height = image.height * newScale;
// 计算居中偏移
const offsetX = (containerWidth - canvas.width) / 2;
const offsetY = (containerHeight - canvas.height) / 2;
setOffset({ x: Math.max(0, offsetX), y: Math.max(0, offsetY) });
setCtx(context);
redrawCanvas(context, image, newScale, []);
}, [image]);
// 重绘画布
const redrawCanvas = useCallback(
(context: CanvasRenderingContext2D, img: HTMLImageElement, currentScale: number, drawActions: DrawingAction[]) => {
const canvas = context.canvas;
context.clearRect(0, 0, canvas.width, canvas.height);
// 绘制图片
context.drawImage(img, 0, 0, canvas.width, canvas.height);
// 绘制所有动作
for (const action of drawActions) {
drawAction(context, action, currentScale);
}
},
[]
);
// 当 actions 变化时重绘
useEffect(() => {
if (ctx && image) {
redrawCanvas(ctx, image, scale, actions);
}
}, [actions, ctx, image, scale, redrawCanvas]);
// 绘制单个动作
const drawAction = (context: CanvasRenderingContext2D, action: DrawingAction, currentScale: number) => {
context.strokeStyle = action.color;
context.fillStyle = action.color;
context.lineWidth = action.lineWidth;
context.lineCap = "round";
context.lineJoin = "round";
switch (action.type) {
case "pen":
if (action.points && action.points.length > 0) {
context.beginPath();
context.moveTo(action.points[0].x * currentScale, action.points[0].y * currentScale);
for (let i = 1; i < action.points.length; i++) {
context.lineTo(action.points[i].x * currentScale, action.points[i].y * currentScale);
}
context.stroke();
}
break;
case "arrow":
if (action.startX !== undefined && action.startY !== undefined && action.endX !== undefined && action.endY !== undefined) {
const sx = action.startX * currentScale;
const sy = action.startY * currentScale;
const ex = action.endX * currentScale;
const ey = action.endY * currentScale;
// 绘制线条
context.beginPath();
context.moveTo(sx, sy);
context.lineTo(ex, ey);
context.stroke();
// 绘制箭头
const angle = Math.atan2(ey - sy, ex - sx);
const arrowLength = 15 + action.lineWidth * 2;
context.beginPath();
context.moveTo(ex, ey);
context.lineTo(ex - arrowLength * Math.cos(angle - Math.PI / 6), ey - arrowLength * Math.sin(angle - Math.PI / 6));
context.lineTo(ex - arrowLength * Math.cos(angle + Math.PI / 6), ey - arrowLength * Math.sin(angle + Math.PI / 6));
context.closePath();
context.fill();
}
break;
case "circle":
if (action.startX !== undefined && action.startY !== undefined && action.radius !== undefined) {
context.beginPath();
context.arc(action.startX * currentScale, action.startY * currentScale, action.radius * currentScale, 0, 2 * Math.PI);
context.stroke();
}
break;
case "text":
if (action.startX !== undefined && action.startY !== undefined && action.text) {
const fontSize = (action.fontSize || 20) * currentScale;
context.font = `bold ${fontSize}px sans-serif`;
context.fillText(action.text, action.startX * currentScale, action.startY * currentScale);
}
break;
}
};
// 获取鼠标/触摸位置(转换为原始图片坐标)
const getPosition = (e: React.MouseEvent | React.TouchEvent) => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
let clientX: number;
let 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) / scale,
y: (clientY - rect.top) / scale,
};
};
// 开始绘制
const handleStart = (e: React.MouseEvent | React.TouchEvent) => {
if (tool === "text") {
const pos = getPosition(e);
setTextPosition(pos);
return;
}
setIsDrawing(true);
const pos = getPosition(e);
const newAction: DrawingAction = {
type: tool,
color,
lineWidth,
};
if (tool === "pen") {
newAction.points = [pos];
} else {
newAction.startX = pos.x;
newAction.startY = pos.y;
newAction.endX = pos.x;
newAction.endY = pos.y;
if (tool === "circle") {
newAction.radius = 0;
}
}
setCurrentAction(newAction);
};
// 绘制中
const handleMove = (e: React.MouseEvent | React.TouchEvent) => {
if (!isDrawing || !currentAction || !ctx || !image) return;
const pos = getPosition(e);
const updatedAction = { ...currentAction };
if (tool === "pen" && updatedAction.points) {
updatedAction.points = [...updatedAction.points, pos];
} else if (tool === "arrow") {
updatedAction.endX = pos.x;
updatedAction.endY = pos.y;
} else if (tool === "circle" && updatedAction.startX !== undefined && updatedAction.startY !== undefined) {
const dx = pos.x - updatedAction.startX;
const dy = pos.y - updatedAction.startY;
updatedAction.radius = Math.sqrt(dx * dx + dy * dy);
}
setCurrentAction(updatedAction);
// 实时重绘
redrawCanvas(ctx, image, scale, [...actions, updatedAction]);
};
// 结束绘制
const handleEnd = () => {
if (!isDrawing || !currentAction) return;
setIsDrawing(false);
setActions((prev) => [...prev, currentAction]);
setCurrentAction(null);
};
// 添加文字
const handleAddText = () => {
if (!textInput.trim() || !textPosition) return;
const textAction: DrawingAction = {
type: "text",
color,
lineWidth,
startX: textPosition.x,
startY: textPosition.y,
text: textInput,
fontSize: 20 + lineWidth * 2,
};
setActions((prev) => [...prev, textAction]);
setTextInput("");
setTextPosition(null);
};
// 撤销
const handleUndo = () => {
setActions((prev) => prev.slice(0, -1));
};
// 清空
const handleClear = () => {
setActions([]);
};
// 确认
const handleConfirm = () => {
const canvas = canvasRef.current;
if (!canvas || !image) return;
// 创建一个新的 canvas 用于导出原始尺寸
const exportCanvas = document.createElement("canvas");
exportCanvas.width = image.width;
exportCanvas.height = image.height;
const exportCtx = exportCanvas.getContext("2d");
if (!exportCtx) return;
// 绘制原始图片
exportCtx.drawImage(image, 0, 0);
// 绘制所有动作(使用原始尺寸)
for (const action of actions) {
drawAction(exportCtx, action, 1);
}
// 导出为 data URL
const dataUrl = exportCanvas.toDataURL("image/png");
onConfirm(dataUrl);
};
return (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={onCancel}>
{/* 弹窗容器 */}
<div
className="flex flex-col overflow-hidden rounded-xl border border-border bg-background shadow-2xl"
style={{ width: "850px", height: "550px" }}
onClick={(e) => e.stopPropagation()}
>
{/* 顶部标题栏 */}
<div className="flex items-center justify-between border-b px-4 py-3">
<span className="text-base font-medium">图片标注</span>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={handleUndo} disabled={actions.length === 0}>
<Undo className="mr-1 h-4 w-4" />
撤销
</Button>
<Button variant="ghost" size="sm" onClick={handleClear} disabled={actions.length === 0}>
<Trash2 className="mr-1 h-4 w-4" />
清空
</Button>
<Button variant="ghost" size="sm" onClick={onCancel}>
<X className="mr-1 h-4 w-4" />
取消
</Button>
<Button size="sm" onClick={handleConfirm} className="bg-primary text-primary-foreground hover:bg-primary/90">
<Check className="mr-1 h-4 w-4" />
确定
</Button>
</div>
</div>
{/* 工具选择栏 */}
<div className="flex flex-wrap items-center justify-center gap-3 border-b px-4 py-2">
{/* 工具按钮 */}
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${tool === "pen" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
onClick={() => setTool("pen")}
title="画笔"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${tool === "arrow" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
onClick={() => setTool("arrow")}
title="箭头"
>
<ArrowRight className="h-4 w-4" />
</button>
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${tool === "circle" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
onClick={() => setTool("circle")}
title="圆圈"
>
<Circle className="h-4 w-4" />
</button>
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${tool === "text" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
onClick={() => setTool("text")}
title="文字"
>
<Type className="h-4 w-4" />
</button>
</div>
{/* 颜色选择 */}
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
{COLORS.map((c) => (
<button
key={c}
type="button"
className={`h-6 w-6 rounded-full border-2 transition-transform ${color === c ? "scale-110 border-primary" : "border-transparent hover:scale-105"}`}
style={{ backgroundColor: c }}
onClick={() => setColor(c)}
title={c}
/>
))}
</div>
{/* 线宽选择 */}
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
{LINE_WIDTHS.map((w) => (
<button
key={w}
type="button"
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${lineWidth === w ? "bg-primary/20" : "hover:bg-muted-foreground/10"}`}
onClick={() => setLineWidth(w)}
title={`线宽 ${w}px`}
>
<div className="rounded-full bg-foreground" style={{ width: w + 2, height: w + 2 }} />
</button>
))}
</div>
</div>
{/* 画布区域 */}
<div ref={containerRef} className="flex flex-1 items-center justify-center overflow-hidden bg-muted/30 p-4">
{isLoading ? (
/* 加载动画 */
<div className="flex flex-col items-center justify-center gap-3">
<div className="flex items-center gap-1">
<span
className="h-2 w-2 rounded-full bg-primary animate-bounce"
style={{ animationDelay: "0ms" }}
/>
<span
className="h-2 w-2 rounded-full bg-primary animate-bounce"
style={{ animationDelay: "150ms" }}
/>
<span
className="h-2 w-2 rounded-full bg-primary animate-bounce"
style={{ animationDelay: "300ms" }}
/>
</div>
<span className="text-sm text-muted-foreground">正在加载图片...</span>
</div>
) : (
<canvas
ref={canvasRef}
className="cursor-crosshair rounded-lg shadow-lg"
onMouseDown={handleStart}
onMouseMove={handleMove}
onMouseUp={handleEnd}
onMouseLeave={handleEnd}
onTouchStart={handleStart}
onTouchMove={handleMove}
onTouchEnd={handleEnd}
/>
)}
</div>
</div>
{/* 文字输入弹窗 */}
{textPosition && (
<div className="fixed inset-0 z-[310] flex items-center justify-center bg-black/50" onClick={() => setTextPosition(null)}>
<div className="rounded-lg border bg-background p-4 shadow-xl" onClick={(e) => e.stopPropagation()}>
<div className="mb-3 text-sm font-medium">输入标注文字</div>
<input
type="text"
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddText();
}}
className="mb-3 w-64 rounded-md border bg-muted px-3 py-2 text-foreground outline-none focus:border-primary"
placeholder="输入文字..."
autoFocus
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setTextPosition(null)}>
取消
</Button>
<Button size="sm" onClick={handleAddText} disabled={!textInput.trim()}>
添加
</Button>
</div>
</div>
</div>
)}
</div>
);
}