ai-chatbot/components/image-annotator.tsx
Your Name 20cb4f0149
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix(image-load): route canvas cross-origin loads through image proxy
2026-03-05 16:37:21 +08:00

1992 lines
79 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useRef, useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import Link from "next/link";
import { useTheme } from "next-themes";
import { X, Pencil, Circle, ArrowRight, Type, Check, Undo, Trash2, Scissors, Sparkles, Download, Expand, Move, Maximize2, ZoomIn, ZoomOut, Loader2, Move3D, Layers, Upload as UploadIcon, Image as ImageIcon, Droplet, Redo, MousePointer2 } from "lucide-react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Slider } from "./ui/slider";
import { toast } from "sonner";
import { AngleEditDialog } from "./angle-edit-dialog";
import { SceneFusionView } from "./scene-fusion-view";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { requestLongTaskResult } from "@/lib/client/long-task";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { buildImageProxyUrl } from "@/lib/image-delivery";
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;
onHomeClick?: () => void;
initialResizeMode?: boolean; // 是否直接进入尺寸调整模式
confirmButtonText?: string;
open?: boolean; // 兼容 Dialog 模式
onOpenChange?: (open: boolean) => 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,
onHomeClick,
initialResizeMode = false,
confirmButtonText = "发送到新对话",
open = true,
onOpenChange,
}: 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(!!imageUrl); // 如果有 imageUrl 则加载中
const [isRemovingBg, setIsRemovingBg] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [isExtending, setIsExtending] = useState(false);
const [isRemovingWatermark, setIsRemovingWatermark] = useState(false);
const [showAngleEditDialog, setShowAngleEditDialog] = useState(false);
const [showSceneFusion, setShowSceneFusion] = useState(false);
const [originalImageUrl, setOriginalImageUrl] = useState<string | undefined>(imageUrl);
const [processedImageUrl, setProcessedImageUrl] = useState<string | null>(null);
const [processedImageBase64, setProcessedImageBase64] = useState<string | null>(null);
const [processType, setProcessType] = useState<"rmbg" | "enhance" | "extend" | "resize" | "watermark" | null>(null);
const [mounted, setMounted] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Handle open change
const handleOpenChange = (newOpen: boolean) => {
onOpenChange?.(newOpen);
if (!newOpen) {
onCancel();
}
};
// 上传图片到服务器获取 URL 的辅助函数
const uploadImageFile = async (file: File): Promise<string> => {
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
if (!response.ok) {
throw new Error("上传失败");
}
const result = await response.json();
return result.url;
};
// 处理拖拽上传 - 先上传到服务器获取URL
const handleDrop = useCallback(async (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (!file.type.startsWith('image/')) {
toast.error("请上传图片文件");
return;
}
if (file.size > 50 * 1024 * 1024) {
toast.error("图片大小不能超过 50MB");
return;
}
try {
setIsLoading(true);
toast.info("正在上传图片...");
const imageUrl = await uploadImageFile(file);
setOriginalImageUrl(imageUrl);
setProcessedImageUrl(null);
setProcessedImageBase64(null);
setProcessType(null);
setActions([]);
setCurrentAction(null);
const img = new window.Image();
img.crossOrigin = "anonymous";
img.onload = () => {
setImage(img);
setIsLoading(false);
};
img.onerror = () => {
setIsLoading(false);
toast.error("图片加载失败");
};
img.src = toCanvasLoadUrl(imageUrl);
toast.success("图片上传成功");
} catch (error) {
console.error("上传图片失败:", error);
toast.error("上传失败,请重试");
setIsLoading(false);
}
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
}, []);
// 处理粘贴上传 - 先上传到服务器获取URL
useEffect(() => {
if (!mounted || originalImageUrl) return;
const handlePaste = async (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (!file) continue;
if (file.size > 50 * 1024 * 1024) {
toast.error("图片大小不能超过 50MB");
return;
}
try {
setIsLoading(true);
toast.info("正在上传图片...");
const imageUrl = await uploadImageFile(file);
setOriginalImageUrl(imageUrl);
setProcessedImageUrl(null);
setProcessedImageBase64(null);
setProcessType(null);
setActions([]);
setCurrentAction(null);
const img = new window.Image();
img.crossOrigin = "anonymous";
img.onload = () => {
setImage(img);
setIsLoading(false);
};
img.onerror = () => {
setIsLoading(false);
toast.error("图片加载失败");
};
img.src = toCanvasLoadUrl(imageUrl);
toast.success("图片上传成功");
} catch (error) {
console.error("上传图片失败:", error);
toast.error("上传失败,请重试");
setIsLoading(false);
}
break;
}
}
};
document.addEventListener('paste', handlePaste);
return () => document.removeEventListener('paste', handlePaste);
}, [mounted, originalImageUrl]);
// 尺寸调整相关状态
const [isResizeMode, setIsResizeMode] = useState(false);
const [resizeCanvasWidth, setResizeCanvasWidth] = useState<number>(1920);
const [resizeCanvasHeight, setResizeCanvasHeight] = useState<number>(1080);
const [resizeBackgroundColor, setResizeBackgroundColor] = useState<string>("#FFFFFF");
const [resizeBackgroundTransparent, setResizeBackgroundTransparent] = useState<boolean>(false);
const [resizeImageScale, setResizeImageScale] = useState<number>(1);
const [resizeImagePosition, setResizeImagePosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const [isDraggingResize, setIsDraggingResize] = useState(false);
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null);
const [selectedAspectRatio, setSelectedAspectRatio] = useState<string | null>(null); // 选中的比例,null表示自定义
const [isResizing, setIsResizing] = useState(false); // 尺寸调整处理中状态
const resizeCanvasRef = useRef<HTMLCanvasElement>(null);
const resizeContainerRef = useRef<HTMLDivElement>(null);
// 网站设置相关状态
const [siteName, setSiteName] = useState<string>("AI Chatbot");
const [siteLogo, setSiteLogo] = useState<string | null>(null);
const [siteLogoDark, setSiteLogoDark] = useState<string | null>(null);
const { resolvedTheme } = useTheme();
// 比例选项定义
const aspectRatios = [
{ label: "智能", value: "smart", ratio: null }, // 智能:根据原图比例
{ label: "21:9", value: "21:9", ratio: 21 / 9 },
{ label: "16:9", value: "16:9", ratio: 16 / 9 },
{ label: "3:2", value: "3:2", ratio: 3 / 2 },
{ label: "4:3", value: "4:3", ratio: 4 / 3 },
{ label: "1:1", value: "1:1", ratio: 1 / 1 },
{ label: "3:4", value: "3:4", ratio: 3 / 4 },
{ label: "2:3", value: "2:3", ratio: 2 / 3 },
{ label: "9:16", value: "9:16", ratio: 9 / 16 },
];
// 根据比例和宽度计算高度
const calculateHeightFromWidth = (width: number, ratio: number | null) => {
if (ratio === null) {
// 智能模式:使用原图比例
if (image) {
return Math.round(width * (image.height / image.width));
}
return resizeCanvasHeight; // 如果没有原图,保持当前高度
}
return Math.round(width / ratio);
};
// 处理宽度变化
const handleWidthChange = (newWidth: number) => {
setResizeCanvasWidth(newWidth);
if (selectedAspectRatio) {
const ratioConfig = aspectRatios.find((r) => r.value === selectedAspectRatio);
if (ratioConfig) {
const newHeight = calculateHeightFromWidth(newWidth, ratioConfig.ratio);
setResizeCanvasHeight(newHeight);
}
}
};
// 处理高度变化(如果用户手动修改高度,取消比例锁定)
const handleHeightChange = (newHeight: number) => {
setResizeCanvasHeight(newHeight);
// 如果用户手动修改高度,取消比例锁定
if (selectedAspectRatio) {
setSelectedAspectRatio(null);
}
};
// 处理比例选择
const handleAspectRatioSelect = (value: string | null) => {
setSelectedAspectRatio(value);
if (value) {
const ratioConfig = aspectRatios.find((r) => r.value === value);
if (ratioConfig) {
const newHeight = calculateHeightFromWidth(resizeCanvasWidth, ratioConfig.ratio);
setResizeCanvasHeight(newHeight);
}
}
};
// 确保在客户端渲染
useEffect(() => {
setMounted(true);
// 获取网站设置
fetch(`/api/site-settings?t=${Date.now()}`)
.then((res) => res.json())
.then((data) => {
if (data.siteName) setSiteName(data.siteName);
if (data.siteLogo) setSiteLogo(data.siteLogo);
if (data.siteLogoDark) setSiteLogoDark(data.siteLogoDark);
})
.catch((err) => {
console.error("[ImageAnnotator] Failed to fetch site settings:", err);
});
}, []);
// 处理本地图片上传 - 先上传到服务器获取URL
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 50 * 1024 * 1024) {
toast.error("图片大小不能超过 50MB");
e.target.value = "";
return;
}
try {
setIsLoading(true);
toast.info("正在上传图片...");
const imageUrl = await uploadImageFile(file);
setOriginalImageUrl(imageUrl);
// 重置状态
setProcessedImageUrl(null);
setProcessedImageBase64(null);
setProcessType(null);
setActions([]);
setCurrentAction(null);
// 触发图片加载逻辑
const img = new window.Image();
img.crossOrigin = "anonymous";
img.onload = () => {
setImage(img);
setIsLoading(false);
};
img.onerror = () => {
setIsLoading(false);
toast.error("图片加载失败");
};
img.src = toCanvasLoadUrl(imageUrl);
toast.success("图片上传成功");
} catch (error) {
console.error("上传图片失败:", error);
toast.error("上传失败,请重试");
setIsLoading(false);
}
}
// 重置 input
e.target.value = "";
};
// 加载图片
useEffect(() => {
if (!imageUrl) return; // 如果没有初始图片,不执行加载逻辑
setIsLoading(true);
setOriginalImageUrl(imageUrl);
setProcessedImageUrl(null);
setProcessedImageBase64(null);
setProcessType(null);
const img = new window.Image();
img.crossOrigin = "anonymous";
img.onload = () => {
setImage(img);
setIsLoading(false);
// 如果指定了初始进入尺寸调整模式,自动进入
if (initialResizeMode && img) {
setIsResizeMode(true);
setResizeCanvasWidth(img.width);
setResizeCanvasHeight(img.height);
setSelectedAspectRatio(null);
}
};
img.onerror = () => {
setIsLoading(false);
};
img.src = toCanvasLoadUrl(imageUrl);
}, [imageUrl, initialResizeMode, toCanvasLoadUrl]);
// 初始化 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;
// 固定弹窗内画布区域大小(减去工具栏和边距,考虑并排显示)
// 弹窗固定为 1200px x 550px,画布区域约为 1100px x 350px(单图)或 550px x 350px(双图)
const containerWidth = processedImageBase64 ? 550 : 1100; // 如果有处理后图片,每个区域550px;否则1100px
const containerHeight = 350; // 550 - 工具栏高度(约200px)
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([]);
};
// 将图片URL转换为Base64(获取原图数据)
const imageUrlToBase64 = async (url: string): Promise<string> => {
// 如果已经是data URL,直接返回
if (url.startsWith("data:")) {
return url.split(",")[1];
}
// 如果是HTTP URL,需要先fetch获取
const response = await fetch(url);
if (!response.ok) {
throw new Error(`获取原图失败: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const base64String = (reader.result as string).split(",")[1];
resolve(base64String);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
};
// 抠图功能
const handleRemoveBackground = async () => {
if (!image || !originalImageUrl) return;
setIsRemovingBg(true);
try {
// 使用原图URL获取原图数据,而不是canvas的预览图
console.log("[抠图] 使用原图URL获取数据:", originalImageUrl);
const base64Data = await imageUrlToBase64(originalImageUrl);
console.log("[抠图] 原图Base64长度:", base64Data.length, "字符");
// 调用抠图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(优先使用original_url,确保下载的是原图)
console.log("[抠图] API返回结果:", {
hasOriginalUrl: !!result.original_url,
hasThumbnailUrl: !!result.thumbnail_url,
originalUrl: result.original_url?.substring(0, 100),
thumbnailUrl: result.thumbnail_url?.substring(0, 100)
});
if (result.original_url) {
console.log("[抠图] ✅ 使用原图URL:", result.original_url);
setProcessedImageUrl(result.original_url);
} else if (result.thumbnail_url) {
console.warn("[抠图] ⚠️ 未找到original_url,使用thumbnail_url作为备选:", result.thumbnail_url);
setProcessedImageUrl(result.thumbnail_url);
} else {
console.warn("[抠图] ⚠️ 未找到图片URL");
setProcessedImageUrl(null);
}
setProcessedImageBase64(result.image_base64);
setProcessType("rmbg");
toast.success(`抠图完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
} else {
throw new Error(result.error || result.detail || "抠图处理失败");
}
} catch (error) {
console.error("[抠图] 错误:", error);
toast.error(error instanceof Error ? error.message : "抠图失败,请稍后重试");
} finally {
setIsRemovingBg(false);
}
};
// 去水印功能
const handleWatermarkRemove = async () => {
if (!image || !originalImageUrl) return;
setIsRemovingWatermark(true);
try {
console.log("[去水印] 使用原图URL:", originalImageUrl);
const result = await requestLongTaskResult({
endpoint: "/api/watermark-remove",
payload: {
image_url: originalImageUrl,
},
});
if (result.success && result.original_url) {
console.log("[去水印] API返回结果:", {
originalUrl: result.original_url?.substring(0, 100),
thumbnailUrl: result.thumbnail_url?.substring(0, 100)
});
setProcessedImageUrl(result.original_url);
// 下载图片获取base64用于显示
try {
const imgResponse = await fetch(result.original_url);
const blob = await imgResponse.blob();
const reader = new FileReader();
reader.onloadend = () => {
const base64 = (reader.result as string).split(',')[1];
setProcessedImageBase64(base64);
};
reader.readAsDataURL(blob);
} catch {
console.warn("[去水印] 无法获取Base64,仅使用URL");
}
setProcessType("watermark");
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 {
setIsRemovingWatermark(false);
}
};
// 变清晰处理
const handleEnhance = async () => {
if (!image || !originalImageUrl) return;
setIsEnhancing(true);
try {
// 使用原图URL获取原图数据,而不是canvas的预览图
console.log("[变清晰] 使用原图URL获取数据:", originalImageUrl);
const base64Data = await imageUrlToBase64(originalImageUrl);
console.log("[变清晰] 原图Base64长度:", base64Data.length, "字符");
const result = await requestLongTaskResult({
endpoint: "/api/enhance",
payload: {
image_base64: base64Data,
prompt: "画图标注-变清晰处理",
},
pollingParams: {
prompt: "画图标注-变清晰处理",
},
});
if (result.success && result.image_base64) {
// 保存处理后的图片URL和Base64(优先使用original_url,确保下载的是处理后的原图)
console.log("[变清晰] API返回结果:", {
hasOriginalUrl: !!result.original_url,
hasThumbnailUrl: !!result.thumbnail_url,
originalUrl: result.original_url,
thumbnailUrl: result.thumbnail_url,
base64Length: result.image_base64?.length || 0
});
// ✅ 验证:检查返回的URL是否是处理后的图片
if (result.original_url) {
console.log("[变清晰] ✅ 使用处理后的原图URL:", result.original_url);
console.log("[变清晰] ✅ URL应该包含 'enhance-orig' 标识,确认是处理后的图片");
// 验证URL格式(应该包含enhance-orig)
if (!result.original_url.includes("enhance-orig") && !result.original_url.includes("enhance-orig")) {
console.warn("[变清晰] ⚠️ 警告:URL不包含 'enhance-orig' 标识,可能不是处理后的图片!");
}
setProcessedImageUrl(result.original_url);
} else if (result.thumbnail_url) {
console.warn("[变清晰] ⚠️ 未找到original_url,使用thumbnail_url作为备选:", result.thumbnail_url);
setProcessedImageUrl(result.thumbnail_url);
} else {
console.warn("[变清晰] ⚠️ 未找到图片URL");
setProcessedImageUrl(null);
}
setProcessedImageBase64(result.image_base64);
setProcessType("enhance");
toast.success(`变清晰完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
} else {
throw new Error(result.error || result.detail || "变清晰处理失败");
}
} catch (error) {
console.error("[变清晰] 错误:", error);
toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试");
} finally {
setIsEnhancing(false);
}
};
// 尺寸调整功能
const handleResize = () => {
if (!image) return;
setIsResizeMode(true);
// 初始化尺寸为原图尺寸
setResizeCanvasWidth(image.width);
setResizeCanvasHeight(image.height);
setSelectedAspectRatio(null); // 重置比例选择
// 初始化图片位置为画布中心
setResizeImagePosition({ x: 0, y: 0 });
setResizeImageScale(1);
};
// 角度调整处理
const handleAngleEdit = () => {
if (!image) return;
setShowAngleEditDialog(true);
};
// 角度调整完成回调
const handleAngleEditComplete = (resultUrl: string) => {
setProcessedImageUrl(resultUrl);
setProcessType("enhance"); // 使用现有的 processType 状态
setShowAngleEditDialog(false);
toast.success("角度调整完成!图片已保存到相册。");
};
// 尺寸调整:处理图片拖拽
const handleResizeImageDragStart = (e: React.MouseEvent | React.TouchEvent) => {
if (!isResizeMode || !resizeCanvasRef.current) return;
e.stopPropagation();
setIsDraggingResize(true);
const canvas = resizeCanvasRef.current;
const rect = canvas.getBoundingClientRect();
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
// 计算相对于画布的坐标
const canvasX = ((clientX - rect.left) / rect.width) * canvas.width;
const canvasY = ((clientY - rect.top) / rect.height) * canvas.height;
// 计算画布中心到图片中心的偏移(考虑显示缩放)
const containerWidth = resizeContainerRef.current?.clientWidth || canvas.width;
const containerHeight = resizeContainerRef.current?.clientHeight || canvas.height;
const canvasAspect = resizeCanvasWidth / resizeCanvasHeight;
const containerAspect = containerWidth / containerHeight;
const displayScale = canvasAspect > containerAspect
? (containerWidth / resizeCanvasWidth)
: (containerHeight / resizeCanvasHeight);
// 保存拖拽起始点(相对于画布中心)
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const offsetX = (canvasX - centerX) / displayScale - resizeImagePosition.x;
const offsetY = (canvasY - centerY) / displayScale - resizeImagePosition.y;
setDragStart({ x: offsetX, y: offsetY });
};
const handleResizeImageDrag = (e: React.MouseEvent | React.TouchEvent) => {
if (!isDraggingResize || !dragStart || !resizeCanvasRef.current) return;
e.preventDefault();
e.stopPropagation();
const canvas = resizeCanvasRef.current;
const rect = canvas.getBoundingClientRect();
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
// 计算相对于画布的坐标
const canvasX = ((clientX - rect.left) / rect.width) * canvas.width;
const canvasY = ((clientY - rect.top) / rect.height) * canvas.height;
// 计算显示缩放
const containerWidth = resizeContainerRef.current?.clientWidth || canvas.width;
const containerHeight = resizeContainerRef.current?.clientHeight || canvas.height;
const canvasAspect = resizeCanvasWidth / resizeCanvasHeight;
const containerAspect = containerWidth / containerHeight;
const displayScale = canvasAspect > containerAspect
? (containerWidth / resizeCanvasWidth)
: (containerHeight / resizeCanvasHeight);
// 计算新的图片位置(相对于画布中心)
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const newX = ((canvasX - centerX) / displayScale) - dragStart.x;
const newY = ((canvasY - centerY) / displayScale) - dragStart.y;
setResizeImagePosition({ x: newX, y: newY });
};
const handleResizeImageDragEnd = () => {
setIsDraggingResize(false);
setDragStart(null);
};
// 尺寸调整:放大图片(每次2%)
const handleResizeZoomIn = () => {
if (!isResizeMode) return;
setResizeImageScale((prev) => Math.min(prev * 1.02, 5)); // 最大5倍,每次放大2%
};
// 尺寸调整:缩小图片(每次2%)
const handleResizeZoomOut = () => {
if (!isResizeMode) return;
setResizeImageScale((prev) => Math.max(prev / 1.02, 0.1)); // 最小0.1倍,每次缩小2%
};
// 尺寸调整:处理图片缩放(鼠标滚轮,每次2%)
const handleResizeImageWheel = (e: React.WheelEvent) => {
if (!isResizeMode || !image) return;
e.preventDefault();
const delta = e.deltaY > 0 ? -0.02 : 0.02; // 滚轮每次2%
const newScale = Math.max(0.1, Math.min(5, resizeImageScale + delta));
setResizeImageScale(newScale);
};
// 尺寸调整:渲染预览
useEffect(() => {
if (!isResizeMode || !resizeCanvasRef.current || !image || !resizeContainerRef.current) return;
const canvas = resizeCanvasRef.current;
const container = resizeContainerRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// 计算画布显示尺寸(适应容器)
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight;
const canvasAspect = resizeCanvasWidth / resizeCanvasHeight;
const containerAspect = containerWidth / containerHeight;
let displayWidth: number;
let displayHeight: number;
let displayScale: number;
if (canvasAspect > containerAspect) {
displayWidth = containerWidth;
displayHeight = containerWidth / canvasAspect;
displayScale = displayWidth / resizeCanvasWidth;
} else {
displayHeight = containerHeight;
displayWidth = containerHeight * canvasAspect;
displayScale = displayHeight / resizeCanvasHeight;
}
canvas.width = displayWidth;
canvas.height = displayHeight;
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制背景(如果设置了背景颜色且不是透明)
if (!resizeBackgroundTransparent) {
ctx.fillStyle = resizeBackgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// 绘制图片(考虑缩放和位置)
const imageDisplayWidth = image.width * resizeImageScale * displayScale;
const imageDisplayHeight = image.height * resizeImageScale * displayScale;
const imageX = (canvas.width / 2) + (resizeImagePosition.x * displayScale);
const imageY = (canvas.height / 2) + (resizeImagePosition.y * displayScale);
// 绘制图片(不设置透明度,保持原图清晰度)
ctx.drawImage(
image,
imageX - imageDisplayWidth / 2,
imageY - imageDisplayHeight / 2,
imageDisplayWidth,
imageDisplayHeight
);
}, [isResizeMode, resizeCanvasWidth, resizeCanvasHeight, resizeBackgroundColor, resizeBackgroundTransparent, resizeImageScale, resizeImagePosition, image]);
// 尺寸调整:导出为指定尺寸的图片并上传到存储桶
const handleResizeConfirm = async () => {
if (!image || !isResizeMode || isResizing) return;
setIsResizing(true);
try {
// 创建指定尺寸的画布
const exportCanvas = document.createElement("canvas");
exportCanvas.width = resizeCanvasWidth;
exportCanvas.height = resizeCanvasHeight;
const exportCtx = exportCanvas.getContext("2d");
if (!exportCtx) return;
// 绘制背景
if (!resizeBackgroundTransparent) {
exportCtx.fillStyle = resizeBackgroundColor;
exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
}
// 计算图片在画布上的实际尺寸和位置
const imageWidth = image.width * resizeImageScale;
const imageHeight = image.height * resizeImageScale;
const imageX = (exportCanvas.width / 2) + resizeImagePosition.x;
const imageY = (exportCanvas.height / 2) + resizeImagePosition.y;
// 绘制图片
exportCtx.drawImage(
image,
imageX - imageWidth / 2,
imageY - imageHeight / 2,
imageWidth,
imageHeight
);
// 导出为图片
const mimeType = resizeBackgroundTransparent ? "image/png" : "image/jpeg";
const dataUrl = exportCanvas.toDataURL(mimeType, 0.95);
const base64Data = dataUrl.split(",")[1];
// 调用API上传到存储桶并保存到数据库
const response = await fetch("/api/resize", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_base64: base64Data,
prompt: `尺寸调整处理 - ${resizeCanvasWidth}x${resizeCanvasHeight}`,
canvasWidth: resizeCanvasWidth,
canvasHeight: resizeCanvasHeight,
mimeType: mimeType,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "上传失败");
}
const result = await response.json();
if (result.success) {
// 保存处理后的图片URL和Base64(优先使用original_url,确保下载的是原图)
if (result.original_url) {
console.log("[尺寸调整] 原图URL:", result.original_url);
setProcessedImageUrl(result.original_url);
} else {
setProcessedImageUrl(null);
}
setProcessedImageBase64(base64Data);
setProcessType("resize");
// 不退出尺寸调整模式,保持原图显示
// setIsResizeMode(false);
toast.success(`尺寸调整完成!图片已调整为 ${resizeCanvasWidth}x${resizeCanvasHeight} 像素,已保存到相册`);
} else {
throw new Error(result.error || "尺寸调整处理失败");
}
} catch (error) {
console.error("[尺寸调整] 错误:", error);
toast.error(error instanceof Error ? error.message : "尺寸调整失败,请稍后重试");
} finally {
setIsResizing(false);
}
};
// 图像拓展处理
const handleExtend = async () => {
if (!image) return;
setIsExtending(true);
try {
// 直接使用原图URL,而不是转换为base64
console.log("[图像拓展] 使用原图URL:", originalImageUrl);
// 调用图像拓展API(直接发送URL)
const response = await fetch("/api/extend", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_url: originalImageUrl, // 直接发送图片URL
prompt: "画图标注-图像拓展处理", // 可选:添加提示词
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "图像拓展处理失败");
}
const result = await response.json();
if (result.success) {
// 保存处理后的图片URL和Base64(优先使用original_url,确保下载的是原图)
if (result.original_url) {
console.log("[图像拓展] 原图URL:", result.original_url);
setProcessedImageUrl(result.original_url);
} else if (result.thumbnail_url) {
console.warn("[图像拓展] ⚠️ 未找到original_url,使用thumbnail_url作为备选");
setProcessedImageUrl(result.thumbnail_url);
} else {
console.warn("[图像拓展] ⚠️ 未找到图片URL");
setProcessedImageUrl(null);
}
// 优先使用返回的base64,如果没有则使用thumbnail_url或original_url
if (result.base64) {
setProcessedImageBase64(result.base64);
}
setProcessType("extend");
toast.success(`图像拓展完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
} else {
throw new Error(result.error || result.detail || "图像拓展处理失败");
}
} catch (error) {
console.error("[图像拓展] 错误:", error);
toast.error(error instanceof Error ? error.message : "图像拓展失败,请稍后重试");
} finally {
setIsExtending(false);
}
};
// 下载处理后的图片(必须使用原图URL或Base64)
const handleDownloadProcessed = async () => {
try {
// 确定文件类型和MIME类型
const isPng = processType === "rmbg" || processType === "resize";
const fileExtension = isPng ? "png" : "jpg";
const mimeType = isPng ? "image/png" : "image/jpeg";
const fileName = processType === "rmbg"
? `抠图-${Date.now()}.${fileExtension}`
: processType === "enhance"
? `变清晰-${Date.now()}.${fileExtension}`
: processType === "resize"
? `尺寸调整-${resizeCanvasWidth}x${resizeCanvasHeight}-${Date.now()}.${fileExtension}`
: processType === "watermark"
? `去水印-${Date.now()}.${fileExtension}`
: `图像拓展-${Date.now()}.${fileExtension}`;
// 如果尺寸调整使用Base64,直接下载(优先使用URL,如果没有URL则使用Base64)
if (processType === "resize") {
if (processedImageUrl) {
// 如果有URL,使用URL下载
const response = await fetch(processedImageUrl);
if (!response.ok) {
throw new Error(`下载失败: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast.success("图片下载成功");
return;
} else if (processedImageBase64) {
// 如果没有URL,使用Base64下载
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("图片下载成功");
return;
} else {
toast.error("图片数据不可用,无法下载");
return;
}
}
// 其他情况使用URL下载
if (!processedImageUrl) {
toast.error("原图地址不可用,无法下载");
return;
}
// ✅ 验证:确认下载的是处理后的图片URL
console.log("[下载] 📥 开始下载,processType:", processType);
console.log("[下载] 📥 processedImageUrl:", processedImageUrl);
console.log("[下载] 📥 这个URL应该是处理后的图片,不是原图!");
// 确保使用的是处理后的原图URL(检查URL中是否包含thumb标识)
let downloadUrl = processedImageUrl;
// 更完善的缩略图URL转换逻辑
if (downloadUrl.includes("-thumb") || downloadUrl.includes("/thumb/") || downloadUrl.includes("thumbnail")) {
// 如果URL包含thumb标识,尝试转换为原图URL
downloadUrl = downloadUrl
.replace(/-thumb-/g, "-orig-")
.replace(/-thumb\./g, "-orig.")
.replace(/\/thumb\//g, "/orig/")
.replace(/thumbnail/g, "original");
console.warn("[下载] ⚠️ 检测到缩略图URL,已转换为原图URL:", downloadUrl);
}
// 如果转换后的URL仍然包含thumb标识,尝试更激进的转换
if (downloadUrl.includes("thumb")) {
try {
// 尝试直接替换文件名中的thumb为orig
const urlObj = new URL(downloadUrl);
const pathname = urlObj.pathname;
if (pathname.includes("thumb")) {
const newPathname = pathname.replace(/thumb/g, "orig");
downloadUrl = downloadUrl.replace(pathname, newPathname);
console.warn("[下载] ⚠️ 二次转换原图URL:", downloadUrl);
}
} catch (urlError) {
// 如果URL解析失败,尝试简单的字符串替换
downloadUrl = downloadUrl.replace(/thumb/g, "orig");
console.warn("[下载] ⚠️ 使用简单替换转换原图URL:", downloadUrl);
}
}
// ✅ 验证:确认下载URL是正确的
console.log("[下载] ✅ 最终下载URL:", downloadUrl);
console.log("[下载] ✅ 这个URL应该指向处理后的图片(不是原图)");
// 如果是变清晰,验证URL应该包含enhance相关标识
if (processType === "enhance") {
if (!downloadUrl.includes("enhance") && !downloadUrl.includes("1767358346590")) {
console.warn("[下载] ⚠️ 警告:变清晰下载URL不包含 'enhance' 标识,可能下载了错误的图片!");
}
}
// 从原图URL下载(原图已上传到S3)
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`下载原图失败: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
console.log("[下载] 下载的图片大小:", blob.size, "bytes");
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast.success("原图下载成功");
} catch (error) {
console.error("[下载] 错误:", error);
toast.error(`下载失败: ${error instanceof Error ? error.message : "请稍后重试"}`);
}
};
// 上传图片到服务器获取URL的辅助函数
const uploadImageToServer = async (dataUrl: string, filename: string): Promise<string> => {
try {
// 将 data URL 转换为 Blob
const response = await fetch(dataUrl);
const blob = await response.blob();
// 创建 FormData 上传
const formData = new FormData();
formData.append("file", blob, filename);
const uploadResponse = await fetchUploadWithRetry(formData);
if (!uploadResponse.ok) {
throw new Error("上传失败");
}
const uploadResult = await uploadResponse.json();
return uploadResult.url;
} catch (error) {
console.error("上传图片失败:", error);
throw error;
}
};
// 确认
const handleConfirm = async () => {
// 优先使用处理后的图片(如果有)
if (processedImageUrl) {
// 如果有处理后的图片URL,直接使用它
onConfirm(processedImageUrl);
return;
}
if (processedImageBase64) {
// 如果有处理后的图片Base64,先上传到服务器获取URL
const mimeType = processType === "rmbg" || processType === "resize" ? "image/png" : "image/jpeg";
const dataUrl = `data:${mimeType};base64,${processedImageBase64}`;
const ext = processType === "rmbg" || processType === "resize" ? "png" : "jpg";
const filename = `processed-${Date.now()}.${ext}`;
try {
toast.info("正在上传图片...");
const uploadedUrl = await uploadImageToServer(dataUrl, filename);
onConfirm(uploadedUrl);
} catch {
toast.error("上传失败,请重试");
}
return;
}
// 否则,导出标注后的图片
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");
const filename = `annotated-${Date.now()}.png`;
try {
toast.info("正在上传图片...");
const uploadedUrl = await uploadImageToServer(dataUrl, filename);
onConfirm(uploadedUrl);
} catch {
toast.error("上传失败,请重试");
}
};
if (!mounted) {
return null;
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="h-[95vh] w-[98vw] max-w-[1800px] overflow-hidden rounded-[20px] border-0 bg-background p-0 shadow-2xl sm:rounded-[20px] md:rounded-[24px]">
<DialogHeader className="sr-only">
<DialogTitle>图片编辑器</DialogTitle>
<DialogDescription>编辑、标注或处理图片。</DialogDescription>
</DialogHeader>
{/* Top Header - Minimal */}
<div className="absolute left-0 right-0 top-0 z-50 flex h-14 items-center justify-start px-6 bg-gradient-to-b from-black/20 to-transparent pointer-events-none">
<div className="pointer-events-auto flex items-center gap-3 rounded-full bg-background/95 backdrop-blur-md px-4 py-1.5 border border-border/50 shadow-sm">
<span className="font-semibold text-sm">图片编辑</span>
<div className="h-4 w-[1px] bg-border" />
<span className="text-xs text-muted-foreground">
{isResizeMode ? "调整尺寸" : "图片标注与处理"}
</span>
</div>
</div>
{/* Floating Toolbar - Top (Tools & Actions) */}
{!showSceneFusion && (
<div className="absolute left-1/2 top-[72px] z-20 -translate-x-1/2 pointer-events-none w-full max-w-[90vw] flex justify-center">
<div className="pointer-events-auto flex items-center gap-1 rounded-[30px] border border-black/10 bg-white/95 p-2 shadow-xl backdrop-blur-xl dark:bg-zinc-900/95 dark:border-white/10 overflow-x-auto max-w-full scrollbar-hide">
{!isResizeMode && (
<>
{/* Drawing Tools */}
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
tool === "pen" && "bg-black text-white hover:bg-black dark:bg-white dark:text-black dark:hover:bg-white"
)}
onClick={() => setTool("pen")}
>
<Pencil className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>画笔</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
tool === "arrow" && "bg-black text-white hover:bg-black dark:bg-white dark:text-black dark:hover:bg-white"
)}
onClick={() => setTool("arrow")}
>
<ArrowRight className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>箭头</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
tool === "circle" && "bg-black text-white hover:bg-black dark:bg-white dark:text-black dark:hover:bg-white"
)}
onClick={() => setTool("circle")}
>
<Circle className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>圆圈</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
tool === "text" && "bg-black text-white hover:bg-black dark:bg-white dark:text-black dark:hover:bg-white"
)}
onClick={() => setTool("text")}
>
<Type className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>文字</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="mx-1 h-7 w-px bg-black/10 dark:bg-white/10" />
{/* Colors */}
<div className="flex items-center gap-1 px-1">
{COLORS.slice(0, 5).map((c) => (
<button
key={c}
type="button"
className={cn(
"h-5 w-5 rounded-full border border-black/15 transition-all",
color === c && "scale-110 ring-2 ring-black/25 dark:ring-white/25"
)}
style={{ backgroundColor: c }}
onClick={() => setColor(c)}
title={c}
/>
))}
<input
type="color"
value={color}
onChange={(event) => setColor(event.target.value)}
className="h-5 w-5 cursor-pointer rounded-full border border-black/15 bg-transparent p-0"
title="自定义颜色"
/>
</div>
<div className="mx-1 h-7 w-px bg-black/10 dark:bg-white/10" />
{/* Line Width */}
<div className="flex items-center gap-1">
{LINE_WIDTHS.map((w) => (
<button
key={w}
type="button"
className={cn(
"flex h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800",
lineWidth === w && "bg-zinc-200 dark:bg-zinc-700"
)}
onClick={() => setLineWidth(w)}
title={`线宽 ${w}px`}
>
<div className="rounded-full bg-zinc-800 dark:bg-zinc-200" style={{ width: w + 2, height: w + 2 }} />
</button>
))}
</div>
<div className="mx-1 h-7 w-px bg-black/10 dark:bg-white/10" />
</>
)}
{/* AI Actions */}
<TooltipProvider delayDuration={0}>
{!isResizeMode && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
onClick={handleRemoveBackground}
disabled={isRemovingBg}
>
<Scissors className={cn("h-5 w-5", isRemovingBg && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent>移除背景</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
onClick={handleEnhance}
disabled={isEnhancing}
>
<Sparkles className={cn("h-5 w-5", isEnhancing && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent>变清晰</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
onClick={handleExtend}
disabled={isExtending}
>
<Expand className={cn("h-5 w-5", isExtending && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent>图像拓展</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
onClick={handleWatermarkRemove}
disabled={isRemovingWatermark}
>
<Droplet className={cn("h-5 w-5", isRemovingWatermark && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent>去水印</TooltipContent>
</Tooltip>
<div className="mx-1 h-7 w-px bg-black/10 dark:bg-white/10" />
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
onClick={handleResize}
>
<Maximize2 className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>尺寸调整</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
onClick={handleAngleEdit}
>
<Move3D className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>角度调整</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
onClick={() => setShowSceneFusion(true)}
>
<Layers className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>详情替换</TooltipContent>
</Tooltip>
<div className="mx-1 h-7 w-px bg-black/10 dark:bg-white/10" />
</>
)}
{/* History */}
{!isResizeMode && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
onClick={handleUndo}
disabled={actions.length === 0}
>
<Undo className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>撤销</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
onClick={handleClear}
disabled={actions.length === 0}
>
<Trash2 className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>清空</TooltipContent>
</Tooltip>
</>
)}
{/* Resize Mode specific tools */}
{isResizeMode && (
<>
<div className="flex items-center gap-2 px-2">
{aspectRatios.slice(0, 4).map((ratio) => (
<Button
key={ratio.value}
variant={selectedAspectRatio === ratio.value ? "secondary" : "ghost"}
size="sm"
onClick={() => handleAspectRatioSelect(ratio.value === selectedAspectRatio ? null : ratio.value)}
className="h-8 text-xs"
>
{ratio.label}
</Button>
))}
</div>
<div className="mx-1 h-7 w-px bg-black/10 dark:bg-white/10" />
<div className="flex items-center gap-2 px-2">
<Input
type="number"
value={resizeCanvasWidth}
onChange={(e) => handleWidthChange(Number.parseInt(e.target.value) || 1)}
className="h-8 w-20 text-xs"
/>
<span className="text-zinc-400">x</span>
<Input
type="number"
value={resizeCanvasHeight}
onChange={(e) => handleHeightChange(Number.parseInt(e.target.value) || 1)}
className="h-8 w-20 text-xs"
/>
</div>
<div className="mx-1 h-7 w-px bg-black/10 dark:bg-white/10" />
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
onClick={handleResizeZoomOut}
>
<ZoomOut className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>缩小</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
onClick={handleResizeZoomIn}
>
<ZoomIn className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>放大</TooltipContent>
</Tooltip>
</>
)}
</TooltipProvider>
</div>
</div>
)}
{/* Main Content Area */}
<div className="relative flex h-full w-full bg-[#f4f4f5] dark:bg-[#09090b]">
{showSceneFusion ? (
<div className="flex-1 overflow-hidden relative">
<SceneFusionView
onBack={() => setShowSceneFusion(false)}
initialProductImage={processedImageUrl ?? originalImageUrl ?? null}
/>
</div>
) : (
<div ref={containerRef} className="flex flex-1 items-center justify-center gap-2 overflow-hidden p-0 relative">
<div className="absolute inset-0 pattern-grid-lg opacity-[0.03] pointer-events-none" />
{/* Main Canvas / Image Area */}
<div className="relative z-10 flex items-center justify-center w-full h-full p-8 pb-24">
{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>
) : !image ? (
<div className="flex flex-col items-center justify-center p-8">
<div
className="w-full max-w-lg border-2 border-dashed border-muted-foreground/25 rounded-xl p-12 text-center cursor-pointer hover:border-primary/50 hover:bg-muted/20 transition-all group"
onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
<div className="bg-background rounded-full p-4 w-20 h-20 mx-auto mb-6 shadow-sm group-hover:scale-105 transition-transform flex items-center justify-center">
<UploadIcon className="w-10 h-10 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<h3 className="text-xl font-medium mb-2">上传图片以开始编辑</h3>
<p className="text-muted-foreground mb-2">拖拽图片到此处、点击选择或 Ctrl+V 粘贴</p>
<p className="text-muted-foreground/60 text-sm mb-6">支持 JPG, PNG, WebP 格式,最大 50MB</p>
<Button variant="default">选择图片</Button>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/png,image/jpeg,image/webp"
onChange={handleImageUpload}
/>
</div>
</div>
) : isResizeMode ? (
/* Resize Mode View */
<div className="flex gap-4 h-full w-full items-center justify-center">
{/* Source Image */}
<div className="flex flex-col border border-border/40 rounded-xl bg-white/50 dark:bg-zinc-900/50 p-2 overflow-hidden shadow-sm h-full max-h-[80vh] flex-1">
<div className="mb-2 text-xs font-medium text-muted-foreground text-center bg-black/5 dark:bg-white/5 py-1 rounded">原图</div>
<div className="flex-1 flex items-center justify-center overflow-auto min-h-0">
{image && (
<img
src={image.src}
alt="原图"
className="rounded-lg shadow-sm"
style={{ objectFit: "contain", maxHeight: "100%", maxWidth: "100%" }}
/>
)}
</div>
</div>
{/* Preview Canvas */}
<div className="flex flex-col border border-primary/20 rounded-xl bg-white/50 dark:bg-zinc-900/50 p-2 overflow-hidden shadow-sm h-full max-h-[80vh] flex-1">
<div className="mb-2 flex items-center justify-between bg-black/5 dark:bg-white/5 py-1 px-2 rounded">
<span className="text-xs font-medium text-muted-foreground">调整预览</span>
{(processedImageUrl || processedImageBase64) && processType === "resize" && (
<Button
variant="ghost"
size="sm"
onClick={handleDownloadProcessed}
className="h-6 px-2 text-xs hover:bg-background"
>
<Download className="h-3 w-3 mr-1" />
下载
</Button>
)}
</div>
<div
ref={resizeContainerRef}
className="flex-1 flex items-center justify-center overflow-hidden relative"
onWheel={handleResizeImageWheel}
>
<div className="relative inline-block">
<canvas
ref={resizeCanvasRef}
className="cursor-move rounded-lg shadow-lg"
onMouseDown={handleResizeImageDragStart}
onMouseMove={handleResizeImageDrag}
onMouseUp={handleResizeImageDragEnd}
onMouseLeave={handleResizeImageDragEnd}
onTouchStart={handleResizeImageDragStart}
onTouchMove={handleResizeImageDrag}
onTouchEnd={handleResizeImageDragEnd}
/>
</div>
</div>
</div>
</div>
) : (
/* Normal Mode View */
<div className="flex gap-4 h-full w-full items-center justify-center">
<div className="relative flex items-center justify-center max-h-full max-w-full">
<canvas
ref={canvasRef}
className={cn(
"touch-none shadow-2xl rounded-lg block bg-white dark:bg-[#18181b]",
(tool === "pen" || tool === "arrow" || tool === "circle" || tool === "text") ? "cursor-crosshair" : "cursor-default"
)}
onMouseDown={handleStart}
onMouseMove={handleMove}
onMouseUp={handleEnd}
onMouseLeave={handleEnd}
onTouchStart={handleStart}
onTouchMove={handleMove}
onTouchEnd={handleEnd}
/>
{/* Processed Result Overlay/Side-by-side could go here if we want splitscreen, but ImageAnnotator usually replaces or shows result in place?
Currently it shows result in a separate box if processed.
Let's keep the logic: if processedImage exists, show it side-by-side or replace?
Original code showed side-by-side.
*/}
</div>
{/* Processed Result (Side by Side) */}
{(processedImageUrl || processedImageBase64) && (
<div className="relative flex flex-col items-center justify-center max-h-full max-w-full ml-4 border-l border-border/20 pl-4">
<div className="mb-2 text-xs font-medium text-muted-foreground bg-black/5 dark:bg-white/5 px-2 py-1 rounded">
{processType === "rmbg" ? "抠图结果" : processType === "enhance" ? "清晰结果" : processType === "resize" ? "尺寸调整结果" : processType === "watermark" ? "去水印结果" : "拓展结果"}
</div>
<div className="relative rounded-lg shadow-2xl overflow-hidden bg-white dark:bg-[#18181b]">
{processedImageUrl ? (
<img
src={processedImageUrl}
alt="Result"
className="max-h-[70vh] max-w-[40vw] object-contain"
/>
) : processedImageBase64 ? (
<img
src={`data:image/${processType === "rmbg" || processType === "resize" ? "png" : "jpeg"};base64,${processedImageBase64}`}
alt="Result"
className="max-h-[70vh] max-w-[40vw] object-contain"
/>
) : null}
<Button
size="icon"
variant="secondary"
className="absolute bottom-4 right-4 shadow-lg"
onClick={handleDownloadProcessed}
>
<Download className="h-5 w-5" />
</Button>
</div>
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
{/* Floating Toolbar - Bottom (Confirm/Cancel) */}
{!showSceneFusion && (
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-20 pointer-events-none">
<div className="pointer-events-auto flex items-center gap-2 p-1.5 bg-background/95 backdrop-blur-xl border border-border/50 shadow-2xl rounded-[24px]">
<Button
variant="ghost"
onClick={onCancel}
className="rounded-xl px-4 text-muted-foreground hover:text-foreground"
>
取消
</Button>
<div className="h-4 w-px bg-border/50" />
<Button
onClick={isResizeMode ? handleResizeConfirm : handleConfirm}
disabled={isResizing}
className="rounded-xl px-6 bg-primary text-primary-foreground hover:bg-primary/90"
>
{isResizing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
处理中...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
{confirmButtonText}
</>
)}
</Button>
</div>
</div>
)}
{/* Text Input Modal */}
{textPosition && (
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 backdrop-blur-sm" 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>
)}
{/* Angle Edit Dialog */}
{showAngleEditDialog && originalImageUrl && (
<div onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<AngleEditDialog
open={showAngleEditDialog}
onOpenChange={setShowAngleEditDialog}
imageUrl={originalImageUrl}
onComplete={handleAngleEditComplete}
/>
</div>
)}
</DialogContent>
</Dialog>
);
}
const toCanvasLoadUrl = useCallback((url: string) => {
return buildImageProxyUrl(url);
}, []);