lovart3/components/canvas/nodes/ImageNode.tsx

2029 lines
70 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, { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { ImagePlus, Loader2, AlertCircle, MoreVertical, Scissors, Eraser, Sparkles, Expand, Maximize2, Move3D, Layers, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X, Type, StickyNote } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas";
import { imageGenerationService } from "@/services/canvas";
import type { CanvasTextEditPayloadItem } from "@/services/canvas";
import type { CanvasNode, ImageNodeData, ImageTextEditItem } from "@/types/canvas";
import { cn } from "@/lib/utils";
import { AngleEditDialog } from "@/components/angle-edit-dialog";
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
import { ImageAnnotator } from "@/components/image-annotator";
import type { PickedObject } from "@/store/canvas";
// 已标注的物品标记(本地状态)
interface PickedMarker {
clickX: number;
clickY: number;
objectName: string;
index: number;
}
const normalizeTextEditItems = (items: ImageTextEditItem[] | undefined): ImageTextEditItem[] => {
if (!items || items.length === 0) return [];
return items
.map((item, index) => {
const originalText = (item.originalText || "").trim();
if (!originalText) return null;
return {
id: item.id || String(index + 1),
originalText,
editedText: (item.editedText || item.originalText || "").trim(),
confidence: item.confidence,
} as ImageTextEditItem;
})
.filter((item): item is ImageTextEditItem => !!item);
};
const isHttpImageUrl = (url?: string) => !!url && /^https?:\/\//i.test(url);
const normalizeImageTitle = (raw?: string) => {
const title = (raw || "").trim();
return title || "";
};
const getImageNodeTitle = (data: ImageNodeData, nodeId: string) => {
return (
normalizeImageTitle(data.title) ||
normalizeImageTitle(data.prompt) ||
`图片-${nodeId.slice(0, 6)}`
);
};
const sanitizeFileName = (name: string) => {
const safe = name
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "-")
.replace(/\s+/g, " ")
.trim()
.slice(0, 120);
return safe || "image";
};
const COMMON_ASPECT_RATIOS = [
"1:1",
"2:3",
"3:2",
"3:4",
"4:3",
"4:5",
"5:4",
"9:16",
"16:9",
"21:9",
] as const;
function guessNearestAspectRatio(width: number, height: number): string {
if (!width || !height) return "auto";
const target = width / height;
let best = "auto";
let minDiff = Number.POSITIVE_INFINITY;
for (const ratio of COMMON_ASPECT_RATIOS) {
const [w, h] = ratio.split(":").map(Number);
const value = w / h;
const diff = Math.abs(target - value);
if (diff < minDiff) {
minDiff = diff;
best = ratio;
}
}
return best;
}
/**
* 在图片上绘制标记点,返回带标记的图片 base64
*/
async function drawMarkerOnImage(
imageUrl: string,
clickX: number,
clickY: number
): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('无法创建 canvas'));
return;
}
// 压缩图片尺寸(最大 800px)
const maxSize = 800;
let width = img.width;
let height = img.height;
if (width > maxSize || height > maxSize) {
if (width > height) {
height = (height / width) * maxSize;
width = maxSize;
} else {
width = (width / height) * maxSize;
height = maxSize;
}
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
// 计算标记位置
const markerX = clickX * width;
const markerY = clickY * height;
const markerRadius = Math.max(12, Math.min(width, height) * 0.025);
// 绘制红色标记
ctx.beginPath();
ctx.arc(markerX, markerY, markerRadius + 3, 0, Math.PI * 2);
ctx.fillStyle = 'white';
ctx.fill();
ctx.beginPath();
ctx.arc(markerX, markerY, markerRadius, 0, Math.PI * 2);
ctx.fillStyle = '#FF0000';
ctx.fill();
// 绘制十字线
ctx.strokeStyle = '#FF0000';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(markerX - markerRadius * 2, markerY);
ctx.lineTo(markerX - markerRadius - 2, markerY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(markerX + markerRadius + 2, markerY);
ctx.lineTo(markerX + markerRadius * 2, markerY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(markerX, markerY - markerRadius * 2);
ctx.lineTo(markerX, markerY - markerRadius - 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(markerX, markerY + markerRadius + 2);
ctx.lineTo(markerX, markerY + markerRadius * 2);
ctx.stroke();
resolve(canvas.toDataURL('image/jpeg', 0.85));
};
img.onerror = () => reject(new Error('图片加载失败'));
img.src = imageUrl;
});
}
interface ImageNodeProps {
node: CanvasNode;
isSelected: boolean;
}
export function ImageNode({ node, isSelected }: ImageNodeProps) {
const data = node.data as ImageNodeData;
const [isHovered, setIsHovered] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [angleDialogOpen, setAngleDialogOpen] = useState(false);
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
const [resizeDialogOpen, setResizeDialogOpen] = useState(false);
const [promptDialogOpen, setPromptDialogOpen] = useState(false);
const [resizeWidth, setResizeWidth] = useState(1024);
const [resizeHeight, setResizeHeight] = useState(1024);
const [regeneratePrompt, setRegeneratePrompt] = useState("");
const [isUploading, setIsUploading] = useState(false);
const [annotatorOpen, setAnnotatorOpen] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
const [textEditPanelOpen, setTextEditPanelOpen] = useState(false);
const [notePanelOpen, setNotePanelOpen] = useState(false);
const [textEditItems, setTextEditItems] = useState<ImageTextEditItem[]>(
() => normalizeTextEditItems(data.textEditItems),
);
const [textEditError, setTextEditError] = useState<string | null>(null);
const [isDetectingText, setIsDetectingText] = useState(false);
const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false);
const [deletedTextItems, setDeletedTextItems] = useState<Array<{ id: string; originalText: string }>>([]);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const [draftNote, setDraftNote] = useState("");
// 文件输入的 ref
const fileInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const updateNode = useCanvasStore((state) => state.updateNode);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const addNode = useCanvasStore((state) => state.addNode);
const addPendingChatAttachment = useCanvasStore((state) => state.addPendingChatAttachment);
const setPickerMode = useCanvasStore((state) => state.setPickerMode);
// 🆕 物品拾取相关状态
const isPickerMode = useCanvasStore((state) => state.isPickerMode);
const pickerTargetNodeId = useCanvasStore((state) => state.pickerTargetNodeId);
const isPickerLoading = useCanvasStore((state) => state.isPickerLoading);
const setPickerLoading = useCanvasStore((state) => state.setPickerLoading);
const addPendingPickedObject = useCanvasStore((state) => state.addPendingPickedObject);
// 当前节点是否处于拾取模式
const isThisNodePicking = isPickerMode && pickerTargetNodeId === node.id;
// 当前正在识别的点击位置
const [currentClick, setCurrentClick] = useState<{ x: number; y: number } | null>(null);
// 退出拾取模式时清空状态
useEffect(() => {
if (!isThisNodePicking) {
setCurrentClick(null);
}
}, [isThisNodePicking]);
useEffect(() => {
if (!textEditPanelOpen) {
setTextEditItems(normalizeTextEditItems(data.textEditItems));
setDeletedTextItems([]);
setTextEditError(null);
}
}, [data.textEditItems, textEditPanelOpen]);
useEffect(() => {
if (!isSelected) {
setTextEditPanelOpen(false);
setIsEditingTitle(false);
setPromptDialogOpen(false);
setNotePanelOpen(false);
}
}, [isSelected]);
useEffect(() => {
if (!isEditingTitle) {
setDraftTitle(getImageNodeTitle(data, node.id));
}
}, [data, isEditingTitle, node.id]);
useEffect(() => {
if (promptDialogOpen) {
setRegeneratePrompt(data.prompt || "");
}
}, [data.prompt, promptDialogOpen]);
useEffect(() => {
if (notePanelOpen) {
setDraftNote(data.note || "");
}
}, [data.note, notePanelOpen]);
// 🆕 处理图片点击标注
const handlePickerClick = useCallback(async (e: React.MouseEvent<HTMLImageElement>) => {
if (!isThisNodePicking || isPickerLoading || !data.src) return;
e.stopPropagation();
e.preventDefault();
const img = e.currentTarget;
const rect = img.getBoundingClientRect();
// 计算相对坐标 (0-1)
const clickX = (e.clientX - rect.left) / rect.width;
const clickY = (e.clientY - rect.top) / rect.height;
setCurrentClick({ x: clickX, y: clickY });
setPickerLoading(true);
try {
// 在图片上绘制红色标记点
const markedImageBase64 = await drawMarkerOnImage(data.src, clickX, clickY);
const response = await fetch('/api/pick-object', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
imageData: markedImageBase64,
clickX,
clickY,
}),
});
if (!response.ok) throw new Error('识别失败');
const result = await response.json();
if (result.success && result.object) {
const picked: PickedObject = {
imageUrl: data.src,
imageNodeId: node.id,
clickX,
clickY,
objectName: result.object.name,
objectPosition: result.object.position,
confidence: result.object.confidence,
boundingBox: result.object.boundingBox,
};
addPendingPickedObject(picked);
// 标注成功后自动关闭拾取模式,直接生成标签
setPickerMode(false);
setCurrentClick(null);
toast.success(`已添加: ${result.object.name}`);
} else {
toast.error(result.error || '未能识别物品');
setCurrentClick(null);
}
} catch (error) {
console.error('物品拾取失败:', error);
toast.error('识别失败,请重试');
setCurrentClick(null);
} finally {
setPickerLoading(false);
}
}, [isThisNodePicking, isPickerLoading, data.src, node.id, setPickerLoading, addPendingPickedObject, setPickerMode]);
// 🆕 取消标注
const handleCancelPicking = useCallback(() => {
setPickerMode(false);
}, [setPickerMode]);
// 获取工具栏位置 - 在渲染时直接计算
const getToolbarPosition = () => {
if (containerRef.current && typeof window !== "undefined") {
const rect = containerRef.current.getBoundingClientRect();
const toolbarHeight = 40;
const titleHeight = 28;
const gap = 10;
let top = rect.top - toolbarHeight - titleHeight - gap;
if (top < 12) {
top = rect.top + 8;
}
const minLeft = 80;
const maxLeft = window.innerWidth - 80;
const left = Math.min(Math.max(rect.left + rect.width / 2, minLeft), maxLeft);
return {
top,
left,
};
}
return { top: 0, left: 0 };
};
const getTextPanelPosition = () => {
if (!containerRef.current || typeof window === "undefined") {
return { top: 0, left: 0 };
}
const rect = containerRef.current.getBoundingClientRect();
const panelWidth = 360;
const spacing = 12;
const maxTop = Math.max(12, window.innerHeight - 460);
let left = rect.right + spacing;
if (left + panelWidth > window.innerWidth - 12) {
left = Math.max(12, rect.left - panelWidth - spacing);
}
const top = Math.min(Math.max(12, rect.top), maxTop);
return { top, left };
};
const getNotePanelPosition = () => {
if (!containerRef.current || typeof window === "undefined") {
return { top: 0, left: 0 };
}
const rect = containerRef.current.getBoundingClientRect();
const panelWidth = 300;
const spacing = 12;
const maxTop = Math.max(12, window.innerHeight - 360);
let left = rect.right + spacing;
if (left + panelWidth > window.innerWidth - 12) {
left = Math.max(12, rect.left - panelWidth - spacing);
}
const top = Math.min(Math.max(12, rect.top + 8), maxTop);
return { top, left };
};
const displayTitle = getImageNodeTitle(data, node.id);
const handleSaveTitle = useCallback(() => {
const nextTitle = normalizeImageTitle(draftTitle) || displayTitle;
updateNode(node.id, {
data: {
...data,
title: nextTitle,
},
});
setDraftTitle(nextTitle);
setIsEditingTitle(false);
toast.success("图片名称已更新");
}, [data, displayTitle, draftTitle, node.id, updateNode]);
const handleOpenNotePanel = useCallback(() => {
setDraftNote(data.note || "");
setNotePanelOpen(true);
}, [data.note]);
const handleSaveNote = useCallback(() => {
const nextNote = draftNote.trim();
updateNode(node.id, {
data: {
...data,
note: nextNote || undefined,
},
});
setDraftNote(nextNote);
setNotePanelOpen(false);
toast.success(nextNote ? "备注已保存" : "备注已清除");
}, [data, draftNote, node.id, updateNode]);
const detectTextItems = useCallback(
async (forceRefresh: boolean = false) => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!isHttpImageUrl(data.src)) {
toast.error("仅支持 URL 图片,不允许 base64 传输");
return;
}
if (isDetectingText) {
return;
}
if (!forceRefresh && data.textEditSource === data.src) {
const cached = normalizeTextEditItems(data.textEditItems);
if (cached.length > 0) {
setTextEditItems(cached);
setDeletedTextItems([]);
setTextEditError(null);
return;
}
}
setIsDetectingText(true);
setTextEditError(null);
try {
const result = await imageGenerationService.detectCanvasText(data.src);
if (!result.success) {
throw new Error(result.error || "文字识别失败");
}
const detectedItems: ImageTextEditItem[] = (result.data?.items || [])
.map((item, index) => {
const text = (item.text || "").trim();
if (!text) return null;
return {
id: item.id || String(index + 1),
originalText: text,
editedText: text,
confidence: item.confidence,
} as ImageTextEditItem;
})
.filter((item): item is ImageTextEditItem => !!item);
setTextEditItems(detectedItems);
setDeletedTextItems([]);
updateNode(node.id, {
data: {
...data,
textEditItems: detectedItems,
textEditSource: data.src,
textEditUpdatedAt: new Date().toISOString(),
},
});
if (detectedItems.length === 0) {
toast.info("未识别到可编辑文字");
} else {
toast.success(`已识别 ${detectedItems.length} 段文字`);
}
} catch (error) {
const message = error instanceof Error ? error.message : "文字识别失败";
setTextEditError(message);
toast.error(message);
} finally {
setIsDetectingText(false);
}
},
[data, isDetectingText, node.id, updateNode],
);
const handleOpenTextEdit = useCallback(async () => {
setTextEditPanelOpen(true);
setTextEditError(null);
const cached = normalizeTextEditItems(data.textEditItems);
if (cached.length > 0 && data.textEditSource === data.src) {
setTextEditItems(cached);
setDeletedTextItems([]);
return;
}
await detectTextItems(true);
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
const handleTextItemChange = (id: string, value: string) => {
setTextEditItems((prev) =>
prev.map((item) => (item.id === id ? { ...item, editedText: value } : item)),
);
};
const handleRemoveTextItem = (id: string) => {
setTextEditItems((prev) => {
const target = prev.find((item) => item.id === id);
if (target?.originalText) {
setDeletedTextItems((deletedPrev) => {
if (deletedPrev.some((item) => item.id === id)) return deletedPrev;
return [...deletedPrev, { id, originalText: target.originalText }];
});
}
return prev.filter((item) => item.id !== id);
});
};
const handleApplyTextEdit = useCallback(async () => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!isHttpImageUrl(data.src)) {
toast.error("仅支持 URL 图片,不允许 base64 传输");
return;
}
const changedItems: CanvasTextEditPayloadItem[] = textEditItems
.map((item) => {
const editedText = item.editedText.trim();
const action: "replace" | "delete" = editedText.length === 0 ? "delete" : "replace";
return {
id: item.id,
originalText: item.originalText.trim(),
editedText,
action,
};
})
.filter(
(item) =>
item.originalText.length > 0 &&
((item.action === "delete") || (item.editedText && item.editedText.length > 0 && item.originalText !== item.editedText)),
);
const removedItems: CanvasTextEditPayloadItem[] = deletedTextItems
.map((item) => ({
id: item.id,
originalText: item.originalText.trim(),
action: "delete" as const,
}))
.filter((item) => item.originalText.length > 0);
const requestItems = [...changedItems, ...removedItems];
if (requestItems.length === 0) {
toast.info("请先修改或删除至少一段文字");
return;
}
if (isApplyingTextEdit) {
return;
}
setIsApplyingTextEdit(true);
setIsProcessing(true);
setTextEditError(null);
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "",
status: "generating",
prompt: "文字替换处理中...",
title: `${displayTitle}-文字替换`,
originalSrc: data.src,
},
connections: [node.id],
});
try {
const result = await imageGenerationService.editCanvasText({
imageUrl: data.src,
items: requestItems,
});
if (!result.success || !result.data?.originalUrl) {
throw new Error(result.error || "文字替换失败");
}
updateNode(newNodeId, {
data: {
src: result.data.originalUrl,
status: "completed",
prompt: "文字替换结果",
title: `${displayTitle}-文字替换`,
originalSrc: data.src,
model: result.data.model,
},
});
updateNode(node.id, {
data: {
...data,
textEditItems,
textEditSource: data.src,
textEditUpdatedAt: new Date().toISOString(),
},
});
setTextEditPanelOpen(false);
setDeletedTextItems([]);
toast.success("文字替换已完成");
} catch (error) {
const message = error instanceof Error ? error.message : "文字替换失败";
setTextEditError(message);
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: "文字替换失败",
title: `${displayTitle}-文字替换失败`,
originalSrc: data.src,
},
});
toast.error(message);
} finally {
setIsApplyingTextEdit(false);
setIsProcessing(false);
}
}, [addNode, data, deletedTextItems, isApplyingTextEdit, node.id, node.position.x, node.position.y, node.size.height, node.size.width, textEditItems, updateNode]);
// 触发文件选择
const handleSelectFile = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
fileInputRef.current?.click();
};
// 处理图片上传 - 上传到存储桶,不使用 Base64
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 验证文件类型
if (!file.type.startsWith('image/')) {
toast.error('请上传图片文件');
return;
}
// 验证文件大小 (50MB)
if (file.size > 50 * 1024 * 1024) {
toast.error('图片大小不能超过 50MB');
return;
}
setIsUploading(true);
// 先显示上传中状态
updateNode(node.id, {
data: { ...data, status: 'generating' },
});
try {
const fileBaseName = file.name.replace(/\.[^.]+$/, "");
// 上传到存储桶
const result = await imageGenerationService.uploadImage(file);
if (result.success && result.data?.url) {
updateNode(node.id, {
data: {
...data,
src: result.data.url,
status: 'completed',
title: normalizeImageTitle(data.title) || fileBaseName,
},
});
toast.success('图片上传成功');
} else {
updateNode(node.id, {
data: { ...data, status: 'failed' },
});
toast.error(result.error || '图片上传失败');
}
} catch (error) {
console.error('图片上传失败:', error);
updateNode(node.id, {
data: { ...data, status: 'failed' },
});
toast.error('图片上传失败,请重试');
} finally {
setIsUploading(false);
}
// 重置 input
e.target.value = '';
};
// 获取操作的中文名称
const getActionName = (action: string): string => {
const names: Record<string, string> = {
'rmbg': '抠图',
'watermark': '去水印',
'enhance': '变清晰',
'extend': '图片扩展',
'resize': '尺寸调整',
'angle': '角度编辑',
'scene-fusion': '详情替换',
'annotate': '图片标注',
'text-edit': '编辑文字',
'note': '图片备注',
'prompt-regenerate': '提示词重绘',
};
return names[action] || action;
};
// 处理图片编辑操作 - 集成我们现有的 API
const handleAction = async (action: string) => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
// 对话框类型的操作
if (action === 'angle') {
setAngleDialogOpen(true);
return;
}
if (action === 'scene-fusion') {
setSceneFusionDialogOpen(true);
return;
}
if (action === 'resize') {
setResizeDialogOpen(true);
return;
}
if (action === 'annotate') {
setAnnotatorOpen(true);
return;
}
if (action === 'text-edit') {
await handleOpenTextEdit();
return;
}
if (action === "note") {
handleOpenNotePanel();
return;
}
if (action === "prompt-regenerate") {
setRegeneratePrompt(data.prompt || "");
setPromptDialogOpen(true);
return;
}
const actionName = getActionName(action);
setIsProcessing(true);
// 先创建一个"处理中"状态的节点,显示在原图右侧
const newNodeId = addNode({
type: 'image',
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y
},
size: { width: node.size.width, height: node.size.height },
data: {
src: '', // 暂时没有图片
status: 'generating',
prompt: `${actionName}处理中...`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
},
connections: [node.id],
});
let result;
try {
switch (action) {
case 'rmbg':
result = await imageGenerationService.removeBackground(data.src);
break;
case 'watermark':
result = await imageGenerationService.removeWatermark(data.src);
break;
case 'enhance':
result = await imageGenerationService.enhanceImage(data.src);
break;
case 'extend':
result = await imageGenerationService.extendImage(data.src);
break;
default:
// 删除创建的节点
if (newNodeId) deleteNode(newNodeId);
return;
}
if (result?.success && result.data?.url) {
// 更新节点为完成状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: result.data.url,
status: 'completed',
prompt: `${actionName}结果`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
},
});
}
toast.success(`${actionName}处理完成`);
} else {
// 更新节点为失败状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: '',
status: 'failed',
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
},
});
}
toast.error(result?.error || "处理失败");
}
} catch (error) {
console.error(`${action} 处理失败:`, error);
// 更新节点为失败状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: '',
status: 'failed',
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
},
});
}
toast.error("处理失败,请重试");
} finally {
setIsProcessing(false);
}
};
useEffect(() => {
if (typeof window === "undefined") return;
const onExternalAction = (event: Event) => {
const customEvent = event as CustomEvent<{ nodeId?: string; action?: string }>;
const targetNodeId = customEvent.detail?.nodeId;
const action = customEvent.detail?.action;
if (!targetNodeId || !action || targetNodeId !== node.id) return;
void handleAction(action);
};
window.addEventListener("canvas:image-action", onExternalAction as EventListener);
return () => {
window.removeEventListener("canvas:image-action", onExternalAction as EventListener);
};
}, [handleAction, node.id]);
// 角度编辑完成回调
const handleAngleEditComplete = (resultUrl: string) => {
// 创建新的图片节点显示结果
addNode({
type: 'image',
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y
},
size: { width: node.size.width, height: node.size.height },
data: {
src: resultUrl,
status: 'completed',
prompt: '角度编辑结果',
title: `${displayTitle}-角度编辑`,
originalSrc: data.src,
},
connections: [node.id],
});
};
// 图片标注完成回调
const handleAnnotateComplete = async (annotatedImageUrl: string) => {
setAnnotatorOpen(false);
// 如果仍然是 base64 格式,先上传到服务器(ImageAnnotator 应该已经处理了,这里作为保护)
let finalUrl = annotatedImageUrl;
if (annotatedImageUrl.startsWith("data:")) {
try {
toast.info("正在上传图片...");
const response = await fetch(annotatedImageUrl);
const blob = await response.blob();
const formData = new FormData();
formData.append("file", blob, `annotated-${Date.now()}.png`);
const uploadResponse = await fetch("/api/files/upload", {
method: "POST",
body: formData,
});
if (uploadResponse.ok) {
const result = await uploadResponse.json();
finalUrl = result.url;
} else {
toast.error("上传失败");
return;
}
} catch (error) {
console.error("上传标注图片失败:", error);
toast.error("上传失败");
return;
}
}
// 更新当前节点,标记为已标注图片
// 标注后的图片会带有视觉标记(箭头、圆圈、文字等)
// 这些标记指示了用户希望 AI 重点关注/修改的区域
updateNode(node.id, {
data: {
...data,
src: finalUrl,
hasAnnotations: true, // 🆕 标记为已标注
originalSrc: data.originalSrc || data.src, // 保留原始图片
prompt: data.prompt ? `${data.prompt} (已标注)` : '已标注图片',
},
});
toast.success('标注已保存!发送对话时将重点处理标注区域');
};
// 尺寸调整执行
const handleResize = async () => {
if (!data.src) return;
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
setResizeDialogOpen(false);
setIsProcessing(true);
// 创建处理中节点
const newNodeId = addNode({
type: 'image',
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y
},
size: { width: resizeWidth / 3, height: resizeHeight / 3 }, // 按比例显示
data: {
src: '',
status: 'generating',
prompt: `尺寸调整 ${resizeWidth}x${resizeHeight}`,
title: `${displayTitle}-${resizeWidth}x${resizeHeight}`,
originalSrc: data.src,
},
connections: [node.id],
});
try {
const result = await imageGenerationService.resizeImage(data.src, resizeWidth, resizeHeight);
if (result?.success && result.data?.url) {
updateNode(newNodeId, {
data: {
src: result.data.url,
status: 'completed',
prompt: `尺寸调整 ${resizeWidth}x${resizeHeight}`,
title: `${displayTitle}-${resizeWidth}x${resizeHeight}`,
originalSrc: data.src,
},
});
toast.success('尺寸调整完成');
} else {
updateNode(newNodeId, {
data: {
src: '',
status: 'failed',
prompt: '尺寸调整失败',
title: `${displayTitle}-尺寸调整失败`,
originalSrc: data.src,
},
});
toast.error(result?.error || '尺寸调整失败');
}
} catch (error) {
console.error('尺寸调整失败:', error);
updateNode(newNodeId, {
data: {
src: '',
status: 'failed',
prompt: '尺寸调整失败',
title: `${displayTitle}-尺寸调整失败`,
originalSrc: data.src,
},
});
toast.error('尺寸调整失败');
} finally {
setIsProcessing(false);
}
};
// 基于当前图片+新提示词重绘
const handlePromptRegenerate = async () => {
if (!data.src) return;
if (!isHttpImageUrl(data.src)) {
toast.error("仅支持 URL 图片,不允许 base64 传输");
return;
}
const promptText = regeneratePrompt.trim();
if (!promptText) {
toast.error("请输入提示词");
return;
}
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
setPromptDialogOpen(false);
setIsProcessing(true);
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "",
status: "generating",
prompt: promptText,
title: `${displayTitle}-重绘`,
originalSrc: data.src,
},
connections: [node.id],
});
try {
const aspectRatio = guessNearestAspectRatio(node.size.width, node.size.height);
const result = await imageGenerationService.generateImage({
prompt: promptText,
referenceImage: data.src,
aspectRatio,
imageCount: 1,
});
const image = result.data?.images?.[0];
if (result.success && image?.originalUrl) {
updateNode(newNodeId, {
data: {
src: image.originalUrl,
status: "completed",
prompt: promptText,
title: `${promptText.slice(0, 24) || displayTitle}-重绘`,
originalSrc: data.src,
},
});
toast.success("已根据新提示词生成图片");
} else {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: "提示词重绘失败",
title: `${displayTitle}-重绘失败`,
originalSrc: data.src,
},
});
toast.error(result.error || "重绘失败");
}
} catch (error) {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: "提示词重绘失败",
title: `${displayTitle}-重绘失败`,
originalSrc: data.src,
},
});
toast.error("重绘失败");
} finally {
setIsProcessing(false);
}
};
// 下载图片 - 通过 fetch 获取图片数据实现真正的下载(避免跨域问题导致跳转)
const handleDownload = async () => {
if (!data.src) return;
try {
toast.info('正在准备下载...');
// 通过 fetch 获取图片数据
const response = await fetch(data.src);
const blob = await response.blob();
// 创建 Blob URL
const blobUrl = URL.createObjectURL(blob);
// 创建下载链接
const link = document.createElement('a');
link.href = blobUrl;
// 从 URL 中提取文件扩展名,默认使用 png
const urlParts = data.src.split('.');
const ext = urlParts[urlParts.length - 1]?.split('?')[0] || 'png';
link.download = `${sanitizeFileName(displayTitle)}.${ext}`;
// 触发下载
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 释放 Blob URL
URL.revokeObjectURL(blobUrl);
toast.success('下载成功');
} catch (error) {
console.error('下载失败:', error);
toast.error('下载失败,请重试');
// 降级方案:直接打开图片
window.open(data.src, '_blank');
}
};
return (
<>
{/* 选中时在图片上方显示标题和尺寸 */}
{isSelected && data.src && (
<div className="absolute -top-7 left-0 right-0 flex items-center justify-between text-xs text-muted-foreground px-1 z-10">
<div className="flex items-center gap-1 min-w-0">
<ImagePlus className="h-3 w-3" />
{isEditingTitle ? (
<Input
value={draftTitle}
autoFocus
className="h-6 px-2 py-0 text-xs w-[160px]"
onChange={(e) => setDraftTitle(e.target.value)}
onBlur={handleSaveTitle}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSaveTitle();
} else if (e.key === "Escape") {
e.preventDefault();
setDraftTitle(displayTitle);
setIsEditingTitle(false);
}
}}
/>
) : (
<span
className="truncate max-w-[150px] cursor-text"
title="双击重命名"
onDoubleClick={(e) => {
e.stopPropagation();
setDraftTitle(displayTitle);
setIsEditingTitle(true);
}}
>
{displayTitle}
</span>
)}
</div>
<span>{node.size.width} × {node.size.height}</span>
</div>
)}
<div
ref={containerRef}
className={cn(
"relative w-full h-full bg-card rounded-lg overflow-hidden border-2 shadow-sm transition-colors",
isSelected
? "border-transparent shadow-[0_0_0_1px_rgba(59,130,246,0.18),0_14px_28px_-18px_rgba(59,130,246,0.45)]"
: "border-transparent"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
{data.src ? (
<div className="relative w-full h-full">
<img
src={data.src}
alt={displayTitle}
className={cn(
"w-full h-full object-contain bg-muted/30",
isThisNodePicking ? "cursor-crosshair" : "cursor-pointer"
)}
draggable={false}
onClick={isThisNodePicking ? handlePickerClick : undefined}
onDoubleClick={(e) => {
if (!isThisNodePicking) {
e.stopPropagation();
setFullscreenPreviewOpen(true);
}
}}
/>
{/* 🆕 拾取模式:当前点击位置(正在识别) */}
{isThisNodePicking && currentClick && (
<div
className="absolute pointer-events-none"
style={{
left: `${currentClick.x * 100}%`,
top: `${currentClick.y * 100}%`,
transform: 'translate(-50%, -50%)',
}}
>
<div className="w-6 h-6 rounded-full border-2 border-orange-500 border-dashed animate-spin" />
</div>
)}
{/* 🆕 拾取模式:状态栏 */}
{isThisNodePicking && (
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex items-center gap-2 bg-white rounded-full px-3 py-1.5 shadow-lg">
<Crosshair className="h-4 w-4 text-orange-500" />
<span className="text-xs font-medium">
{isPickerLoading ? '正在识别...' : '点击标注物品'}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-full hover:bg-gray-100"
onClick={(e) => {
e.stopPropagation();
handleCancelPicking();
}}
>
<X className="h-3 w-3" />
</Button>
</div>
)}
</div>
) : data.status === 'generating' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-muted">
<Loader2 className="h-8 w-8 animate-spin text-primary mb-2" />
<span className="text-sm text-muted-foreground">生成中...</span>
</div>
) : data.status === 'failed' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-destructive/10">
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
<span className="text-sm text-destructive">生成失败</span>
</div>
) : (
<div className="w-full h-full flex flex-col items-center justify-center bg-muted">
<ImagePlus className="h-8 w-8 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground mb-3">点击上传图片</span>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
/>
{/* 选择文件按钮 */}
<button
type="button"
onClick={handleSelectFile}
className="px-4 py-2 bg-primary text-primary-foreground text-sm rounded-md hover:bg-primary/90 transition-colors cursor-pointer"
>
选择文件
</button>
</div>
)}
{/* 处理中遮罩 */}
{isProcessing && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<div className="flex flex-col items-center">
<Loader2 className="h-8 w-8 animate-spin text-white mb-2" />
<span className="text-sm text-white">处理中...</span>
</div>
</div>
)}
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
{/* 悬浮时的更多操作按钮 (右上角) */}
{isHovered && data.src && !isProcessing && !isSelected && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="h-7 w-7">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleAction('rmbg')}>
<Scissors className="h-4 w-4 mr-2" />
抠图
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('watermark')}>
<Eraser className="h-4 w-4 mr-2" />
去水印
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('enhance')}>
<Sparkles className="h-4 w-4 mr-2" />
变清晰
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('extend')}>
<Expand className="h-4 w-4 mr-2" />
图片扩展
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('resize')}>
<Maximize2 className="h-4 w-4 mr-2" />
尺寸调整
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('angle')}>
<Move3D className="h-4 w-4 mr-2" />
角度编辑
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('scene-fusion')}>
<Layers className="h-4 w-4 mr-2" />
详情替换
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('prompt-regenerate')}>
<RefreshCw className="h-4 w-4 mr-2" />
提示词重绘
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('text-edit')}>
<Type className="h-4 w-4 mr-2" />
编辑文字
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('note')}>
<StickyNote className="h-4 w-4 mr-2" />
图片备注
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setDraftTitle(displayTitle);
setIsEditingTitle(true);
}}
>
<Pencil className="h-4 w-4 mr-2" />
重命名
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
下载
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => deleteNode(node.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* 提示词显示 */}
{data.prompt && isHovered && !isProcessing && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
<p className="text-xs text-white line-clamp-2">{data.prompt}</p>
</div>
)}
{data.note && !isSelected && (
<div
className="pointer-events-none absolute top-2 left-2 inline-flex items-center gap-1 rounded-full bg-amber-100/95 px-2 py-1 text-[11px] text-amber-700 shadow-sm"
title="该图片含有备注"
>
<StickyNote className="h-3 w-3" />
备注
</div>
)}
</div>
{/* 角度编辑对话框 */}
{data.src && (
<AngleEditDialog
open={angleDialogOpen}
onOpenChange={setAngleDialogOpen}
imageUrl={data.src}
onComplete={handleAngleEditComplete}
/>
)}
{/* 详情替换对话框 */}
<SceneFusionDialog
open={sceneFusionDialogOpen}
onOpenChange={setSceneFusionDialogOpen}
/>
{/* 尺寸调整对话框 */}
<Dialog open={resizeDialogOpen} onOpenChange={setResizeDialogOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Maximize2 className="h-5 w-5" />
尺寸调整
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="width">宽度 (px)</Label>
<Input
id="width"
type="number"
value={resizeWidth}
onChange={(e) => setResizeWidth(parseInt(e.target.value) || 1024)}
min={100}
max={4096}
/>
</div>
<div className="space-y-2">
<Label htmlFor="height">高度 (px)</Label>
<Input
id="height"
type="number"
value={resizeHeight}
onChange={(e) => setResizeHeight(parseInt(e.target.value) || 1024)}
min={100}
max={4096}
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => { setResizeWidth(512); setResizeHeight(512); }}>512x512</Button>
<Button variant="outline" size="sm" onClick={() => { setResizeWidth(1024); setResizeHeight(1024); }}>1024x1024</Button>
<Button variant="outline" size="sm" onClick={() => { setResizeWidth(1920); setResizeHeight(1080); }}>1920x1080</Button>
<Button variant="outline" size="sm" onClick={() => { setResizeWidth(1080); setResizeHeight(1920); }}>1080x1920</Button>
<Button variant="outline" size="sm" onClick={() => { setResizeWidth(2048); setResizeHeight(2048); }}>2048x2048</Button>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setResizeDialogOpen(false)}>取消</Button>
<Button onClick={handleResize}>确定</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 提示词重绘对话框 */}
<Dialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RefreshCw className="h-5 w-5" />
提示词重绘
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-sm text-muted-foreground">
在保留当前图片主体的基础上,用新提示词生成一张新图。原图不会被覆盖。
</p>
<div className="space-y-2">
<Label htmlFor={`prompt-regenerate-${node.id}`}>新提示词</Label>
<Textarea
id={`prompt-regenerate-${node.id}`}
value={regeneratePrompt}
onChange={(e) => setRegeneratePrompt(e.target.value)}
placeholder="例如:保持主体不变,把背景改成黄昏海边,氛围光影更柔和,细节更丰富"
rows={6}
className="resize-y"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPromptDialogOpen(false)}>取消</Button>
<Button onClick={handlePromptRegenerate} disabled={isProcessing || !regeneratePrompt.trim()}>
{isProcessing ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <RefreshCw className="h-4 w-4 mr-2" />}
开始重绘
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
{isSelected && data.src && !isEditingTitle && typeof document !== 'undefined' && createPortal(
<TooltipProvider delayDuration={200}>
<div
className="fixed flex items-center gap-0.5 rounded-full shadow-xl px-2 py-1 z-[9999] bg-white/95 border border-gray-200 text-gray-800 dark:bg-gray-900/95 dark:border-gray-700 dark:text-gray-100"
style={{
top: getToolbarPosition().top,
left: getToolbarPosition().left,
transform: 'translateX(-50%)',
pointerEvents: 'auto',
}}
onClick={(e) => e.stopPropagation()}
>
{/* 添加到对话 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
className="h-8 w-8 rounded-full hover:bg-blue-50 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 dark:hover:bg-blue-900/30 disabled:opacity-50 disabled:pointer-events-none"
onClick={() => {
addPendingChatAttachment({
url: data.src!,
name: displayTitle,
contentType: 'image/jpeg',
});
toast.success('已添加到对话框');
}}
>
<MessageSquarePlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">添加到对话</TooltipContent>
</Tooltip>
{/* 🆕 物品拾取 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
className="h-8 w-8 rounded-full hover:bg-orange-50 text-orange-600 hover:text-orange-700 dark:text-orange-400 dark:hover:text-orange-300 dark:hover:bg-orange-900/30 disabled:opacity-50 disabled:pointer-events-none"
onClick={() => {
setPickerMode(true, node.id);
toast.info('点击图片中的物品进行拾取');
}}
>
<Crosshair className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">拾取物品</TooltipContent>
</Tooltip>
{/* 分隔线 */}
<div className="h-5 w-px bg-gray-200 dark:bg-gray-700 mx-0.5" />
{/* 放大/高清 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
onClick={() => handleAction('enhance')}
>
<span className="text-[10px] font-medium text-gray-500 border border-gray-300 rounded px-1 dark:text-gray-200 dark:border-gray-600">HD</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">高清放大</TooltipContent>
</Tooltip>
{/* 移除背景/抠图 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
onClick={() => handleAction('rmbg')}
>
<Scissors className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">移除背景</TooltipContent>
</Tooltip>
{/* 去水印 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
onClick={() => handleAction('watermark')}
>
<Eraser className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">去水印</TooltipContent>
</Tooltip>
{/* 场景融合 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
onClick={() => handleAction('scene-fusion')}
>
<Layers className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">场景融合</TooltipContent>
</Tooltip>
{/* 文字编辑 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing || isDetectingText || isApplyingTextEdit}
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
onClick={() => handleAction('text-edit')}
>
<Type className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">编辑文字</TooltipContent>
</Tooltip>
{/* 图片备注 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
className={cn(
"h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none",
data.note && "text-amber-600 dark:text-amber-300"
)}
onClick={() => handleAction('note')}
>
<StickyNote className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">图片备注</TooltipContent>
</Tooltip>
{/* 提示词重绘 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
onClick={() => handleAction('prompt-regenerate')}
>
<RefreshCw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">提示词重绘</TooltipContent>
</Tooltip>
{/* 角度编辑 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
onClick={() => handleAction('angle')}
>
<Move3D className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">角度编辑</TooltipContent>
</Tooltip>
{/* 扩展 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
onClick={() => handleAction('extend')}
>
<Expand className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">图片扩展</TooltipContent>
</Tooltip>
{/* 尺寸调整 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
onClick={() => handleAction('resize')}
>
<Maximize2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">尺寸调整</TooltipContent>
</Tooltip>
{/* 图片标注 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
className={cn("h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none", data.hasAnnotations && "text-primary")}
onClick={() => handleAction('annotate')}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">标注重点区域</TooltipContent>
</Tooltip>
{/* 下载 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
onClick={handleDownload}
>
<Download className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">下载图片</TooltipContent>
</Tooltip>
{/* 删除 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
className="h-8 w-8 rounded-full hover:bg-red-50 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-900/30 disabled:opacity-50 disabled:pointer-events-none"
onClick={() => deleteNode(node.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">删除</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>,
document.body
)}
{/* 图片备注面板 */}
{isSelected && notePanelOpen && typeof document !== "undefined" && createPortal(
<div
className="fixed z-[9999] w-[300px] rounded-xl border border-amber-200 bg-amber-50/95 shadow-2xl backdrop-blur"
style={{
top: getNotePanelPosition().top,
left: getNotePanelPosition().left,
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-amber-200/80">
<div className="flex items-center gap-2 text-amber-800">
<StickyNote className="h-4 w-4" />
<span className="text-sm font-medium">图片备注</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-amber-700 hover:text-amber-900 hover:bg-amber-100"
onClick={() => setNotePanelOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="px-4 py-3">
<Textarea
value={draftNote}
onChange={(e) => setDraftNote(e.target.value)}
placeholder="记录这张图的想法、待办或交付要求..."
rows={6}
className="resize-y bg-white/80 border-amber-200 focus-visible:ring-amber-300"
/>
<p className="mt-2 text-[11px] text-amber-700/80">
备注仅在你点开时显示,不会直接覆盖在画面上。
</p>
</div>
<div className="flex items-center justify-between gap-2 px-4 py-3 border-t border-amber-200/80">
<Button
variant="ghost"
size="sm"
className="text-amber-700 hover:text-amber-900 hover:bg-amber-100"
onClick={() => setNotePanelOpen(false)}
>
取消
</Button>
<Button
size="sm"
className="bg-amber-600 text-white hover:bg-amber-700"
onClick={handleSaveNote}
>
保存备注
</Button>
</div>
</div>,
document.body
)}
{/* 文字编辑面板(显示在图片节点旁边) */}
{isSelected && textEditPanelOpen && data.src && typeof document !== "undefined" && createPortal(
<div
className="fixed z-[9999] w-[360px] rounded-xl border border-border bg-background/95 backdrop-blur shadow-2xl"
style={{
top: getTextPanelPosition().top,
left: getTextPanelPosition().left,
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-border/70">
<div className="flex items-center gap-2">
<Type className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">编辑文字</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setTextEditPanelOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="px-4 py-3 space-y-3 max-h-[360px] overflow-y-auto">
<p className="text-xs text-muted-foreground">
识别图片文字后可逐条修改或删除,应用后会在保持画面风格的前提下完成文字更新。
</p>
{isDetectingText && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
正在识别图片文字...
</div>
)}
{textEditError && !isDetectingText && (
<div className="rounded-md bg-destructive/10 text-destructive text-xs px-3 py-2">
{textEditError}
</div>
)}
{!isDetectingText && textEditItems.length === 0 && !textEditError && (
<div className="rounded-md bg-muted/50 text-muted-foreground text-xs px-3 py-3">
未识别到可编辑文字,可点击“重新识别”再试一次。
</div>
)}
{textEditItems.map((item, index) => (
<div key={item.id} className="rounded-lg border border-border/70 p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-[11px] text-muted-foreground">
文本 {index + 1}
{typeof item.confidence === "number" && ` · 置信度 ${(item.confidence * 100).toFixed(0)}%`}
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveTextItem(item.id)}
title="删除该条文字"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="text-xs text-muted-foreground line-clamp-2">
原文:{item.originalText}
</div>
<Input
value={item.editedText}
onChange={(e) => handleTextItemChange(item.id, e.target.value)}
placeholder="输入替换后的文字"
/>
</div>
))}
{deletedTextItems.length > 0 && (
<div className="rounded-md border border-dashed border-orange-300 bg-orange-50/60 px-3 py-2 text-xs text-orange-700">
已标记删除 {deletedTextItems.length} 条文字,应用时会明确告诉生图模型移除这些文字并修复背景。
</div>
)}
</div>
<div className="flex items-center justify-between gap-2 px-4 py-3 border-t border-border/70">
<Button
variant="outline"
size="sm"
disabled={isDetectingText || isApplyingTextEdit}
onClick={() => detectTextItems(true)}
>
{isDetectingText ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 mr-1" />}
重新识别
</Button>
<Button
size="sm"
disabled={
isDetectingText ||
isApplyingTextEdit ||
(textEditItems.length === 0 && deletedTextItems.length === 0)
}
onClick={handleApplyTextEdit}
>
{isApplyingTextEdit ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Check className="h-3.5 w-3.5 mr-1" />}
确认替换
</Button>
</div>
</div>,
document.body
)}
{/* 图片标注器 */}
{annotatorOpen && data.src && (
<ImageAnnotator
imageUrl={data.src}
onConfirm={handleAnnotateComplete}
onCancel={() => setAnnotatorOpen(false)}
/>
)}
{/* 双击放大预览弹窗 */}
{fullscreenPreviewOpen && data.src && createPortal(
<div
className="fixed inset-0 z-[9999] bg-black/90 flex items-center justify-center cursor-zoom-out"
onClick={() => setFullscreenPreviewOpen(false)}
>
{/* 关闭按钮 */}
<button
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
onClick={() => setFullscreenPreviewOpen(false)}
>
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* 图片 */}
<img
src={data.src}
alt={data.prompt || "图片预览"}
className="max-w-[95vw] max-h-[95vh] object-contain"
onClick={(e) => e.stopPropagation()}
/>
{/* 底部提示 */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm">
点击任意位置关闭
</div>
</div>,
document.body
)}
</>
);
}