"use client"; import React, { useRef, useState, useEffect, useCallback } from "react"; import { X, Pencil, Circle, ArrowRight, Type, Check, Undo, Trash2, Scissors, Sparkles } from "lucide-react"; import { Button } from "./ui/button"; import { toast } from "sonner"; 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(null); const containerRef = useRef(null); const [ctx, setCtx] = useState(null); const [image, setImage] = useState(null); const [isDrawing, setIsDrawing] = useState(false); const [tool, setTool] = useState("pen"); const [color, setColor] = useState("#FF0000"); const [lineWidth, setLineWidth] = useState(4); const [actions, setActions] = useState([]); const [currentAction, setCurrentAction] = useState(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); const [isRemovingBg, setIsRemovingBg] = useState(false); const [isEnhancing, setIsEnhancing] = useState(false); // 加载图片 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 handleRemoveBackground = async () => { if (!image || !canvasRef.current) return; setIsRemovingBg(true); try { // 获取当前画布的图片(包含标注) const canvas = canvasRef.current; const currentImageData = canvas.toDataURL("image/png"); // 提取Base64数据(去掉data:image/png;base64,前缀) const base64Data = currentImageData.split(",")[1]; // 调用抠图API const response = await fetch("/api/rmbg", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ image_base64: base64Data, prompt: "画图标注-抠图处理", // 可选:添加提示词 }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || "抠图失败"); } const result = await response.json(); if (result.success && result.image_base64) { // 将返回的Base64图片加载为新图片 const processedImage = new window.Image(); processedImage.crossOrigin = "anonymous"; processedImage.onload = () => { // 更新图片和画布 setImage(processedImage); // 清空所有标注(因为背景已移除) setActions([]); toast.success(`抠图完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`); }; processedImage.onerror = () => { throw new Error("加载处理后的图片失败"); }; // 优先使用S3 URL(如果可用),否则使用Base64 if (result.original_url) { processedImage.src = result.original_url; } else { processedImage.src = `data:image/png;base64,${result.image_base64}`; } } else { throw new Error(result.error || "抠图处理失败"); } } catch (error) { console.error("[抠图] 错误:", error); toast.error(error instanceof Error ? error.message : "抠图失败,请稍后重试"); } finally { setIsRemovingBg(false); } }; // 变清晰处理 const handleEnhance = async () => { if (!image || !canvasRef.current) return; setIsEnhancing(true); try { // 获取当前画布的图片(包含标注) const canvas = canvasRef.current; const currentImageData = canvas.toDataURL("image/png"); // 提取Base64数据(去掉data:image/png;base64,前缀) const base64Data = currentImageData.split(",")[1]; // 调用变清晰API const response = await fetch("/api/enhance", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ image_base64: base64Data, prompt: "画图标注-变清晰处理", // 可选:添加提示词 }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || "变清晰处理失败"); } const result = await response.json(); if (result.success && result.image_base64) { // 将返回的Base64图片加载为新图片 const processedImage = new window.Image(); processedImage.crossOrigin = "anonymous"; processedImage.onload = () => { // 更新图片和画布 setImage(processedImage); // 清空所有标注(因为图片已更新) setActions([]); toast.success(`变清晰完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`); }; processedImage.onerror = () => { throw new Error("加载处理后的图片失败"); }; // 优先使用S3 URL(如果可用),否则使用Base64 if (result.original_url) { processedImage.src = result.original_url; } else { processedImage.src = `data:image/jpeg;base64,${result.image_base64}`; } } else { throw new Error(result.error || "变清晰处理失败"); } } catch (error) { console.error("[变清晰] 错误:", error); toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试"); } finally { setIsEnhancing(false); } }; // 确认 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 (
{/* 弹窗容器 */}
e.stopPropagation()} > {/* 顶部标题栏 */}
图片标注
{/* 工具选择栏 */}
{/* 工具按钮 */}
{/* 颜色选择 */}
{COLORS.map((c) => (
{/* 线宽选择 */}
{LINE_WIDTHS.map((w) => ( ))}
{/* 画布区域 */}
{isLoading ? ( /* 加载动画 */
正在加载图片...
) : ( )}
{/* 文字输入弹窗 */} {textPosition && (
setTextPosition(null)}>
e.stopPropagation()}>
输入标注文字
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 />
)}
); }