702 lines
24 KiB
TypeScript
702 lines
24 KiB
TypeScript
"use client";
|
||
|
||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
||
import { X, Pencil, Circle, ArrowRight, Type, Check, Undo, Trash2, Scissors, Sparkles, Download } 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<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);
|
||
const [isRemovingBg, setIsRemovingBg] = useState(false);
|
||
const [isEnhancing, setIsEnhancing] = useState(false);
|
||
const [originalImageUrl, setOriginalImageUrl] = useState<string>(imageUrl);
|
||
const [processedImageUrl, setProcessedImageUrl] = useState<string | null>(null);
|
||
const [processedImageBase64, setProcessedImageBase64] = useState<string | null>(null);
|
||
const [processType, setProcessType] = useState<"rmbg" | "enhance" | null>(null);
|
||
|
||
// 加载图片
|
||
useEffect(() => {
|
||
setIsLoading(true);
|
||
setOriginalImageUrl(imageUrl);
|
||
setProcessedImageUrl(null);
|
||
setProcessedImageBase64(null);
|
||
setProcessType(null);
|
||
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 = processedImageBase64 ? 550 : 1100; // 如果有处理后图片,每个区域550px;否则1100px
|
||
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) {
|
||
// 保存处理后的图片URL和Base64
|
||
if (result.original_url) {
|
||
setProcessedImageUrl(result.original_url);
|
||
} else {
|
||
setProcessedImageUrl(null);
|
||
}
|
||
setProcessedImageBase64(result.image_base64);
|
||
setProcessType("rmbg");
|
||
|
||
toast.success(`抠图完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
|
||
} 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) {
|
||
// 保存处理后的图片URL和Base64
|
||
if (result.original_url) {
|
||
setProcessedImageUrl(result.original_url);
|
||
} else {
|
||
setProcessedImageUrl(null);
|
||
}
|
||
setProcessedImageBase64(result.image_base64);
|
||
setProcessType("enhance");
|
||
|
||
toast.success(`变清晰完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
|
||
} else {
|
||
throw new Error(result.error || "变清晰处理失败");
|
||
}
|
||
} catch (error) {
|
||
console.error("[变清晰] 错误:", error);
|
||
toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试");
|
||
} finally {
|
||
setIsEnhancing(false);
|
||
}
|
||
};
|
||
|
||
// 下载处理后的图片
|
||
const handleDownloadProcessed = () => {
|
||
if (!processedImageBase64) return;
|
||
|
||
try {
|
||
// 确定文件类型和MIME类型
|
||
const isPng = processType === "rmbg";
|
||
const mimeType = isPng ? "image/png" : "image/jpeg";
|
||
const fileExtension = isPng ? "png" : "jpg";
|
||
const fileName = processType === "rmbg" ? `抠图-${Date.now()}.${fileExtension}` : `变清晰-${Date.now()}.${fileExtension}`;
|
||
|
||
// 创建下载链接
|
||
const dataUrl = `data:${mimeType};base64,${processedImageBase64}`;
|
||
const link = document.createElement("a");
|
||
link.href = dataUrl;
|
||
link.download = fileName;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
|
||
toast.success("图片下载成功");
|
||
} catch (error) {
|
||
console.error("[下载] 错误:", error);
|
||
toast.error("下载失败,请稍后重试");
|
||
}
|
||
};
|
||
|
||
// 确认
|
||
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: "1200px", 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={handleRemoveBackground}
|
||
disabled={isRemovingBg || isEnhancing || !image}
|
||
title="移除背景(抠图)"
|
||
>
|
||
<Scissors className={`mr-1 h-4 w-4 ${isRemovingBg ? "animate-spin" : ""}`} />
|
||
{isRemovingBg ? "处理中..." : "抠图"}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={handleEnhance}
|
||
disabled={isEnhancing || isRemovingBg || !image}
|
||
title="图片变清晰"
|
||
>
|
||
<Sparkles className={`mr-1 h-4 w-4 ${isEnhancing ? "animate-spin" : ""}`} />
|
||
{isEnhancing ? "处理中..." : "变清晰"}
|
||
</Button>
|
||
<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 gap-4 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>
|
||
) : (
|
||
<>
|
||
{/* 原图画布 */}
|
||
<div className="flex flex-1 flex-col items-center justify-center">
|
||
<div className="mb-2 text-xs text-muted-foreground">原图</div>
|
||
<canvas
|
||
ref={canvasRef}
|
||
className="cursor-crosshair rounded-lg shadow-lg max-w-full"
|
||
onMouseDown={handleStart}
|
||
onMouseMove={handleMove}
|
||
onMouseUp={handleEnd}
|
||
onMouseLeave={handleEnd}
|
||
onTouchStart={handleStart}
|
||
onTouchMove={handleMove}
|
||
onTouchEnd={handleEnd}
|
||
/>
|
||
</div>
|
||
|
||
{/* 处理后的图片展示区域 */}
|
||
{processedImageBase64 && (
|
||
<div className="flex flex-1 flex-col items-center justify-center">
|
||
<div className="mb-2 flex items-center gap-2">
|
||
<span className="text-xs text-muted-foreground">
|
||
{processType === "rmbg" ? "抠图结果" : "清晰结果"}
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={handleDownloadProcessed}
|
||
className="h-7 px-2"
|
||
title="下载处理后的图片"
|
||
>
|
||
<Download className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
<div className="relative rounded-lg border-2 border-primary/50 bg-background p-2 shadow-lg">
|
||
<img
|
||
src={processedImageUrl || `data:image/${processType === "rmbg" ? "png" : "jpeg"};base64,${processedImageBase64}`}
|
||
alt={processType === "rmbg" ? "抠图结果" : "清晰结果"}
|
||
className="max-h-[350px] max-w-full rounded-lg"
|
||
style={{ maxWidth: canvasRef.current?.width || "400px" }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</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>
|
||
);
|
||
}
|