lovart3/components/image-annotator.tsx
Your Name 4fc33d719d 添加对话框图片尺寸调整按钮和优化用户体验
- 在对话框图片上添加'尺寸调整'按钮,点击后直接打开图片编辑器并进入尺寸调整模式
- 为确认调整按钮添加处理中状态,显示旋转加载图标和'处理中...'文字
- 处理期间禁用按钮,防止重复点击
- 改善用户交互体验,让用户清楚知道系统正在处理
2025-12-30 00:37:21 +08:00

1435 lines
56 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 { X, Pencil, Circle, ArrowRight, Type, Check, Undo, Trash2, Scissors, Sparkles, Download, Expand, Move, Maximize2, ZoomIn, ZoomOut, Loader2 } from "lucide-react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
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;
initialResizeMode?: boolean; // 是否直接进入尺寸调整模式
}
const COLORS = [
"#FF0000", // 红色
"#FF6B00", // 橙色
"#FFCC00", // 黄色
"#00CC00", // 绿色
"#0099FF", // 蓝色
"#9933FF", // 紫色
"#FF0099", // 粉色
"#FFFFFF", // 白色
"#000000", // 黑色
];
const LINE_WIDTHS = [2, 4, 6, 8];
export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMode = false }: 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 [isExtending, setIsExtending] = 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" | "extend" | "resize" | null>(null);
const [mounted, setMounted] = useState(false);
// 尺寸调整相关状态
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 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);
}, []);
// 加载图片
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);
// 如果指定了初始进入尺寸调整模式,自动进入
if (initialResizeMode && img) {
setIsResizeMode(true);
setResizeCanvasWidth(img.width);
setResizeCanvasHeight(img.height);
setSelectedAspectRatio(null);
}
};
img.onerror = () => {
setIsLoading(false);
};
img.src = imageUrl;
}, [imageUrl, initialResizeMode]);
// 初始化 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([]);
};
// 抠图功能
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(优先使用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);
}
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(优先使用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);
}
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 handleResize = () => {
if (!image) return;
setIsResizeMode(true);
// 初始化尺寸为原图尺寸
setResizeCanvasWidth(image.width);
setResizeCanvasHeight(image.height);
setSelectedAspectRatio(null); // 重置比例选择
// 初始化图片位置为画布中心
setResizeImagePosition({ x: 0, y: 0 });
setResizeImageScale(1);
};
// 尺寸调整:处理图片拖拽
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);
};
// 尺寸调整:放大图片
const handleResizeZoomIn = () => {
if (!isResizeMode) return;
setResizeImageScale((prev) => Math.min(prev * 1.2, 5)); // 最大5倍
};
// 尺寸调整:缩小图片
const handleResizeZoomOut = () => {
if (!isResizeMode) return;
setResizeImageScale((prev) => Math.max(prev / 1.2, 0.1)); // 最小0.1倍
};
// 尺寸调整:处理图片缩放(鼠标滚轮)
const handleResizeImageWheel = (e: React.WheelEvent) => {
if (!isResizeMode || !image) return;
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
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 || !canvasRef.current) return;
setIsExtending(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/extend", {
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.base64) {
// 保存处理后的图片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);
}
setProcessedImageBase64(result.base64);
setProcessType("extend");
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 {
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}`
: `图像拓展-${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(检查URL中是否包含thumb标识)
let downloadUrl = processedImageUrl;
if (downloadUrl.includes("-thumb") || downloadUrl.includes("/thumb/") || downloadUrl.includes("thumbnail")) {
// 如果URL包含thumb标识,尝试转换为原图URL
downloadUrl = downloadUrl
.replace(/-thumb-/g, "-orig-")
.replace(/\/thumb\//g, "/orig/")
.replace(/thumbnail/g, "original");
console.warn("[下载] ⚠️ 检测到缩略图URL,已转换为原图URL:", downloadUrl);
}
console.log("[下载] 下载URL:", downloadUrl);
// 从原图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 : "请稍后重试"}`);
}
};
// 确认
const handleConfirm = () => {
// 优先使用处理后的图片(如果有)
if (processedImageUrl) {
// 如果有处理后的图片URL,直接使用它
onConfirm(processedImageUrl);
return;
}
if (processedImageBase64) {
// 如果有处理后的图片Base64,转换为data URL
const mimeType = processType === "rmbg" || processType === "resize" ? "image/png" : "image/jpeg";
const dataUrl = `data:${mimeType};base64,${processedImageBase64}`;
onConfirm(dataUrl);
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");
onConfirm(dataUrl);
};
// 弹窗内容
const modalContent = (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-background"
onClick={onCancel}
>
{/* 弹窗容器 - 全屏显示 */}
<div
className="flex flex-col overflow-hidden w-full h-full"
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 || isExtending || isResizeMode || !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 || isExtending || isResizeMode || !image}
title="图片变清晰"
>
<Sparkles className={`mr-1 h-4 w-4 ${isEnhancing ? "animate-spin" : ""}`} />
{isEnhancing ? "处理中..." : "变清晰"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleExtend}
disabled={isExtending || isRemovingBg || isEnhancing || isResizeMode || !image}
title="图像拓展"
>
<Expand className={`mr-1 h-4 w-4 ${isExtending ? "animate-spin" : ""}`} />
{isExtending ? "处理中..." : "图像拓展"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleResize}
disabled={isResizeMode || isRemovingBg || isEnhancing || isExtending || !image}
title="尺寸调整"
>
<Maximize2 className="mr-1 h-4 w-4" />
尺寸调整
</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>
{/* 工具选择栏 / 尺寸调整工具栏 */}
{isResizeMode ? (
/* 尺寸调整模式下显示尺寸调整工具栏 */
<div className="flex flex-col gap-3 border-b px-4 py-3 bg-muted/30">
{/* 比例选择和尺寸输入在同一行 */}
<div className="flex items-center gap-4 flex-wrap">
{/* 比例选择 */}
<div className="flex items-center gap-2">
<label className="text-sm font-medium whitespace-nowrap">选择比例:</label>
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{aspectRatios.map((ratio) => (
<button
key={ratio.value}
type="button"
onClick={() => handleAspectRatioSelect(ratio.value === selectedAspectRatio ? null : ratio.value)}
className={`flex flex-col items-center justify-center gap-1 px-3 py-2 rounded-lg border transition-colors whitespace-nowrap ${
selectedAspectRatio === ratio.value
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-muted border-border"
}`}
title={ratio.label}
>
<div
className={`border-2 rounded flex items-center justify-center ${
selectedAspectRatio === ratio.value ? "border-primary-foreground" : "border-foreground"
}`}
style={{
width: ratio.value === "smart" ? "16px" : ratio.ratio && ratio.ratio > 1 ? "24px" : ratio.ratio && ratio.ratio < 1 ? "12px" : "16px",
height: ratio.value === "smart" ? "16px" : ratio.ratio && ratio.ratio > 1 ? "12px" : ratio.ratio && ratio.ratio < 1 ? "24px" : "16px",
}}
>
{ratio.value === "smart" && (
<Maximize2 className="h-3 w-3" />
)}
</div>
<span className="text-xs">{ratio.label}</span>
</button>
))}
</div>
</div>
{/* 尺寸输入 */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm font-medium">宽度:</label>
<Input
type="number"
value={resizeCanvasWidth}
onChange={(e) => handleWidthChange(Number.parseInt(e.target.value) || 1)}
className="w-24 h-8"
min={1}
max={10000}
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium">高度:</label>
<Input
type="number"
value={resizeCanvasHeight}
onChange={(e) => handleHeightChange(Number.parseInt(e.target.value) || 1)}
className="w-24 h-8"
min={1}
max={10000}
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium">背景:</label>
<input
type="color"
value={resizeBackgroundColor}
onChange={(e) => setResizeBackgroundColor(e.target.value)}
disabled={resizeBackgroundTransparent}
className="w-12 h-8 rounded border cursor-pointer disabled:opacity-50"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium flex items-center gap-1">
<input
type="checkbox"
checked={resizeBackgroundTransparent}
onChange={(e) => setResizeBackgroundTransparent(e.target.checked)}
className="w-4 h-4"
/>
透明
</label>
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
size="sm"
onClick={handleResizeZoomOut}
title="缩小"
className="h-8 w-8 p-0"
>
<ZoomOut className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleResizeZoomIn}
title="放大"
className="h-8 w-8 p-0"
>
<ZoomIn className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setIsResizeMode(false);
setResizeImageScale(1);
setResizeImagePosition({ x: 0, y: 0 });
}}
>
取消
</Button>
<Button
size="sm"
onClick={handleResizeConfirm}
disabled={isResizing}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isResizing ? (
<>
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
处理中...
</>
) : (
"确认调整"
)}
</Button>
</div>
</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-2 overflow-hidden bg-muted/30 p-2">
{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>
) : isResizeMode ? (
/* 尺寸调整模式 - 左右对比布局 */
<>
{/* 左侧:原图 */}
<div className="flex flex-col border-2 border-dashed border-border rounded-lg bg-muted/20 p-2 overflow-hidden" style={{ width: "714px", height: "570px" }}>
<div className="mb-1 text-xs font-medium text-muted-foreground text-center">原图</div>
<div className="flex-1 flex items-center justify-center overflow-auto min-h-0" style={{ height: "calc(570px - 2rem)" }}>
{image && (
<img
src={image.src}
alt="原图"
className="rounded-lg shadow-lg"
style={{
objectFit: "contain",
maxHeight: "calc(570px - 2rem)",
maxWidth: "100%",
width: "auto",
height: "auto"
}}
/>
)}
</div>
</div>
{/* 右侧:尺寸调整预览画布 */}
<div className="flex flex-col border-2 border-dashed border-primary/50 rounded-lg bg-muted/30 p-2 overflow-hidden" style={{ width: "714px", height: "570px" }}>
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">调整尺寸</span>
{(processedImageUrl || processedImageBase64) && processType === "resize" && (
<Button
variant="default"
size="sm"
onClick={handleDownloadProcessed}
className="h-7 px-3 bg-primary text-primary-foreground hover:bg-primary/90 shadow-md flex items-center gap-1.5 font-medium"
title="下载调整后的图片"
>
<Download className="h-3.5 w-3.5" />
<span className="text-xs">下载</span>
</Button>
)}
</div>
<div
ref={resizeContainerRef}
className="flex items-center justify-center overflow-auto"
style={{ width: "100%", height: "calc(570px - 2rem)" }}
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 className="absolute top-2 left-2 bg-black/70 text-white text-xs px-2 py-1 rounded">
画布: {resizeCanvasWidth} x {resizeCanvasHeight} | 缩放: {(resizeImageScale * 100).toFixed(0)}% | 拖拽移动图片,滚轮缩放
</div>
</div>
</div>
</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 justify-between gap-2 w-full">
<span className="text-xs text-muted-foreground">
{processType === "rmbg" ? "抠图结果" : processType === "enhance" ? "清晰结果" : processType === "resize" ? "尺寸调整结果" : "拓展结果"}
</span>
<Button
variant="default"
size="sm"
onClick={handleDownloadProcessed}
className="h-8 px-3 bg-primary text-primary-foreground hover:bg-primary/90 shadow-md flex items-center gap-1.5 font-medium"
title="下载处理后的图片"
>
<Download className="h-4 w-4" />
<span className="text-xs">下载</span>
</Button>
</div>
<div className="relative rounded-lg border-2 border-primary/50 bg-background p-2 shadow-lg">
{processedImageUrl ? (
<img
src={processedImageUrl}
alt={processType === "rmbg" ? "抠图结果" : processType === "enhance" ? "清晰结果" : processType === "resize" ? "尺寸调整结果" : "拓展结果"}
className="max-h-[350px] max-w-full rounded-lg"
style={{ maxWidth: canvasRef.current?.width || "400px" }}
/>
) : processedImageBase64 ? (
<img
src={`data:image/${processType === "rmbg" || processType === "resize" ? "png" : "jpeg"};base64,${processedImageBase64}`}
alt={processType === "rmbg" ? "抠图结果" : processType === "enhance" ? "清晰结果" : processType === "resize" ? "尺寸调整结果" : "拓展结果"}
className="max-h-[350px] max-w-full rounded-lg"
style={{ maxWidth: canvasRef.current?.width || "400px" }}
/>
) : null}
</div>
</div>
)}
</>
)}
</div>
</div>
{/* 文字输入弹窗 */}
{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>
)}
</div>
);
// 使用 Portal 渲染到 document.body,避免受父元素定位影响
if (!mounted) {
return null;
}
return createPortal(modalContent, document.body);
}