2072 lines
69 KiB
TypeScript
2072 lines
69 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { ImagePlus, Loader2, AlertCircle, MoreVertical, MoreHorizontal, Scissors, Eraser, Sparkles, Expand, Maximize2, Move3D, 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 GENERIC_CANVAS_PROMPTS = new Set([
|
||
"对话生成",
|
||
"图片生成",
|
||
"AI生成",
|
||
"智能生成",
|
||
"自动生成",
|
||
]);
|
||
|
||
const normalizePromptText = (value: unknown): string => {
|
||
return typeof value === "string" ? value.trim() : "";
|
||
};
|
||
|
||
const resolveRegeneratePromptSeed = (data: ImageNodeData): string => {
|
||
const metadataPrompt = normalizePromptText(data.metadata?.generationPrompt);
|
||
if (metadataPrompt) return metadataPrompt;
|
||
|
||
const nodePrompt = normalizePromptText(data.prompt);
|
||
if (nodePrompt && !GENERIC_CANVAS_PROMPTS.has(nodePrompt)) {
|
||
return nodePrompt;
|
||
}
|
||
|
||
return nodePrompt;
|
||
};
|
||
|
||
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 detailProgressLabel =
|
||
typeof data.metadata?.detailProgressLabel === "string"
|
||
? data.metadata.detailProgressLabel.trim()
|
||
: "";
|
||
const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds);
|
||
const [isHovered, setIsHovered] = useState(false);
|
||
const [isProcessing, setIsProcessing] = useState(false);
|
||
const [angleDialogOpen, setAngleDialogOpen] = useState(false);
|
||
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
|
||
const [resizeAnnotatorOpen, setResizeAnnotatorOpen] = useState(false);
|
||
const [promptDialogOpen, setPromptDialogOpen] = useState(false);
|
||
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("");
|
||
const [isAddingToChat, setIsAddingToChat] = useState(false);
|
||
|
||
// 文件输入的 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);
|
||
}
|
||
}, [isSelected]);
|
||
|
||
useEffect(() => {
|
||
if (!isEditingTitle) {
|
||
setDraftTitle(getImageNodeTitle(data, node.id));
|
||
}
|
||
}, [data, isEditingTitle, node.id]);
|
||
|
||
useEffect(() => {
|
||
if (promptDialogOpen) {
|
||
setRegeneratePrompt(resolveRegeneratePromptSeed(data));
|
||
}
|
||
}, [data, 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 displayTitle = getImageNodeTitle(data, node.id);
|
||
const isMultiSelected = isSelected && selectedNodeIds.length > 1;
|
||
|
||
const animateAddToChat = useCallback(async () => {
|
||
if (!containerRef.current || !data.src || typeof window === "undefined") {
|
||
return false;
|
||
}
|
||
|
||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||
return false;
|
||
}
|
||
|
||
const target = document.querySelector('[data-canvas-chat-input-anchor="true"]') as HTMLElement | null;
|
||
if (!target) return false;
|
||
|
||
const sourceImage =
|
||
(containerRef.current.querySelector("img") as HTMLImageElement | null) || containerRef.current;
|
||
const sourceRect = sourceImage.getBoundingClientRect();
|
||
const targetRect = target.getBoundingClientRect();
|
||
|
||
if (sourceRect.width <= 0 || sourceRect.height <= 0 || targetRect.width <= 0 || targetRect.height <= 0) {
|
||
return false;
|
||
}
|
||
|
||
const waitForAnimation = (animation: Animation | null, fallbackMs: number) =>
|
||
new Promise<void>((resolve) => {
|
||
if (!animation) {
|
||
resolve();
|
||
return;
|
||
}
|
||
let finished = false;
|
||
const done = () => {
|
||
if (finished) return;
|
||
finished = true;
|
||
resolve();
|
||
};
|
||
const timeout = window.setTimeout(done, fallbackMs);
|
||
animation.addEventListener(
|
||
"finish",
|
||
() => {
|
||
window.clearTimeout(timeout);
|
||
done();
|
||
},
|
||
{ once: true }
|
||
);
|
||
animation.addEventListener(
|
||
"cancel",
|
||
() => {
|
||
window.clearTimeout(timeout);
|
||
done();
|
||
},
|
||
{ once: true }
|
||
);
|
||
});
|
||
|
||
const sourceCenterX = sourceRect.left + sourceRect.width / 2;
|
||
const sourceCenterY = sourceRect.top + sourceRect.height / 2;
|
||
|
||
const labelText = displayTitle.length > 12 ? `${displayTitle.slice(0, 10)}...` : displayTitle;
|
||
const tagWidth = Math.min(172, Math.max(112, 58 + labelText.length * 8));
|
||
const tagHeight = 34;
|
||
const tagLeft = Math.min(
|
||
Math.max(sourceCenterX - tagWidth / 2, 8),
|
||
window.innerWidth - tagWidth - 8
|
||
);
|
||
const defaultTagTop = sourceRect.top - 46;
|
||
const tagTop =
|
||
defaultTagTop < 10
|
||
? Math.min(sourceRect.bottom + 10, window.innerHeight - tagHeight - 10)
|
||
: defaultTagTop;
|
||
|
||
const flightTag = document.createElement("div");
|
||
flightTag.style.position = "fixed";
|
||
flightTag.style.left = `${sourceRect.left}px`;
|
||
flightTag.style.top = `${sourceRect.top}px`;
|
||
flightTag.style.width = `${sourceRect.width}px`;
|
||
flightTag.style.height = `${sourceRect.height}px`;
|
||
flightTag.style.borderRadius = "14px";
|
||
flightTag.style.overflow = "hidden";
|
||
flightTag.style.pointerEvents = "none";
|
||
flightTag.style.zIndex = "99999";
|
||
flightTag.style.background = "#ffffff";
|
||
flightTag.style.border = "1px solid rgba(148, 163, 184, 0.24)";
|
||
flightTag.style.boxShadow = "0 14px 30px rgba(15, 23, 42, 0.28)";
|
||
flightTag.style.transformOrigin = "center center";
|
||
flightTag.style.willChange = "transform, opacity, filter";
|
||
|
||
const previewImage = document.createElement("img");
|
||
previewImage.src = data.src;
|
||
previewImage.alt = "";
|
||
previewImage.style.width = "100%";
|
||
previewImage.style.height = "100%";
|
||
previewImage.style.objectFit = "cover";
|
||
flightTag.appendChild(previewImage);
|
||
document.body.appendChild(flightTag);
|
||
|
||
const compressDx = tagLeft + tagWidth / 2 - sourceCenterX;
|
||
const compressDy = tagTop + tagHeight / 2 - sourceCenterY;
|
||
const compressScaleX = tagWidth / sourceRect.width;
|
||
const compressScaleY = tagHeight / sourceRect.height;
|
||
|
||
const compressAnimation = flightTag.animate(
|
||
[
|
||
{
|
||
transform: "translate(0px, 0px) scale(1, 1)",
|
||
borderRadius: "14px",
|
||
boxShadow: "0 14px 30px rgba(15, 23, 42, 0.28)",
|
||
},
|
||
{
|
||
offset: 0.72,
|
||
transform: `translate(${compressDx * 1.03}px, ${compressDy * 1.03}px) scale(${compressScaleX * 1.05}, ${compressScaleY * 1.08})`,
|
||
borderRadius: "999px",
|
||
boxShadow: "0 10px 24px rgba(59, 130, 246, 0.24)",
|
||
},
|
||
{
|
||
transform: `translate(${compressDx}px, ${compressDy}px) scale(${compressScaleX}, ${compressScaleY})`,
|
||
borderRadius: "999px",
|
||
boxShadow: "0 8px 20px rgba(59, 130, 246, 0.2)",
|
||
},
|
||
],
|
||
{
|
||
duration: 320,
|
||
easing: "cubic-bezier(0.2, 0.9, 0.2, 1.2)",
|
||
fill: "forwards",
|
||
}
|
||
);
|
||
await waitForAnimation(compressAnimation, 420);
|
||
|
||
flightTag.style.left = `${tagLeft}px`;
|
||
flightTag.style.top = `${tagTop}px`;
|
||
flightTag.style.width = `${tagWidth}px`;
|
||
flightTag.style.height = `${tagHeight}px`;
|
||
flightTag.style.transform = "none";
|
||
flightTag.style.borderRadius = "999px";
|
||
flightTag.style.boxShadow = "0 8px 20px rgba(59, 130, 246, 0.2)";
|
||
flightTag.style.display = "flex";
|
||
flightTag.style.alignItems = "center";
|
||
flightTag.style.gap = "6px";
|
||
flightTag.style.padding = "4px 10px 4px 4px";
|
||
flightTag.style.background = "rgba(255,255,255,0.96)";
|
||
flightTag.style.backdropFilter = "blur(6px)";
|
||
flightTag.innerHTML = "";
|
||
|
||
const chipImage = document.createElement("img");
|
||
chipImage.src = data.src;
|
||
chipImage.alt = "";
|
||
chipImage.style.width = "24px";
|
||
chipImage.style.height = "24px";
|
||
chipImage.style.objectFit = "cover";
|
||
chipImage.style.borderRadius = "999px";
|
||
chipImage.style.border = "1px solid rgba(148, 163, 184, 0.35)";
|
||
|
||
const chipLabel = document.createElement("span");
|
||
chipLabel.textContent = labelText || "图片";
|
||
chipLabel.style.fontSize = "12px";
|
||
chipLabel.style.fontWeight = "500";
|
||
chipLabel.style.color = "rgb(51, 65, 85)";
|
||
chipLabel.style.whiteSpace = "nowrap";
|
||
chipLabel.style.overflow = "hidden";
|
||
chipLabel.style.textOverflow = "ellipsis";
|
||
|
||
flightTag.appendChild(chipImage);
|
||
flightTag.appendChild(chipLabel);
|
||
|
||
const tagCenterX = tagLeft + tagWidth / 2;
|
||
const tagCenterY = tagTop + tagHeight / 2;
|
||
const targetCenterX = targetRect.left + targetRect.width / 2;
|
||
const targetCenterY = targetRect.top + targetRect.height * 0.55;
|
||
const flyDx = targetCenterX - tagCenterX;
|
||
const flyDy = targetCenterY - tagCenterY;
|
||
|
||
const flyAnimation = flightTag.animate(
|
||
[
|
||
{ transform: "translate(0px, 0px) scale(1)", opacity: 1, filter: "blur(0px)" },
|
||
{
|
||
offset: 0.55,
|
||
transform: `translate(${flyDx * 0.64}px, ${flyDy * 0.62 - 46}px) scale(0.94)`,
|
||
opacity: 0.98,
|
||
filter: "blur(0px)",
|
||
},
|
||
{
|
||
offset: 0.84,
|
||
transform: `translate(${flyDx + 9}px, ${flyDy - 7}px) scale(0.74)`,
|
||
opacity: 0.82,
|
||
filter: "blur(0.4px)",
|
||
},
|
||
{
|
||
transform: `translate(${flyDx}px, ${flyDy}px) scale(0.64)`,
|
||
opacity: 0.15,
|
||
filter: "blur(1.2px)",
|
||
},
|
||
],
|
||
{
|
||
duration: 760,
|
||
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
|
||
fill: "forwards",
|
||
}
|
||
);
|
||
|
||
target.animate(
|
||
[
|
||
{ boxShadow: "0 0 0 0 rgba(59,130,246,0)", transform: "scale(1)" },
|
||
{ offset: 0.58, boxShadow: "0 0 0 8px rgba(59,130,246,0.14)", transform: "scale(1.02)" },
|
||
{ boxShadow: "0 0 0 0 rgba(59,130,246,0)", transform: "scale(1)" },
|
||
],
|
||
{ duration: 720, easing: "cubic-bezier(0.2, 0.9, 0.2, 1.2)" }
|
||
);
|
||
|
||
await waitForAnimation(flyAnimation, 860);
|
||
flightTag.remove();
|
||
|
||
return true;
|
||
}, [data.src, displayTitle]);
|
||
|
||
const handleAddToChat = useCallback(async () => {
|
||
if (!data.src || isProcessing || isAddingToChat) return;
|
||
setIsAddingToChat(true);
|
||
|
||
try {
|
||
await animateAddToChat();
|
||
addPendingChatAttachment({
|
||
url: data.src,
|
||
name: displayTitle,
|
||
contentType: "image/jpeg",
|
||
});
|
||
toast.success("已添加到对话框");
|
||
} finally {
|
||
setIsAddingToChat(false);
|
||
}
|
||
}, [addPendingChatAttachment, animateAddToChat, data.src, displayTitle, isAddingToChat, isProcessing]);
|
||
|
||
// 获取工具栏位置 - 在渲染时直接计算
|
||
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 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') {
|
||
setResizeAnnotatorOpen(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(resolveRegeneratePromptSeed(data));
|
||
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 uploadBase64ToStorage = useCallback(async (base64Url: string) => {
|
||
const response = await fetch(base64Url);
|
||
const blob = await response.blob();
|
||
const formData = new FormData();
|
||
formData.append("file", blob, `canvas-resize-${Date.now()}.png`);
|
||
|
||
const uploadResponse = await fetch("/api/files/upload", {
|
||
method: "POST",
|
||
body: formData,
|
||
});
|
||
|
||
if (!uploadResponse.ok) {
|
||
throw new Error("上传失败");
|
||
}
|
||
|
||
const result = await uploadResponse.json();
|
||
return result.url as string;
|
||
}, []);
|
||
|
||
const handleResizeEditorComplete = useCallback(
|
||
async (resizedImageUrl: string) => {
|
||
setResizeAnnotatorOpen(false);
|
||
let finalUrl = resizedImageUrl;
|
||
|
||
if (resizedImageUrl.startsWith("data:")) {
|
||
try {
|
||
toast.info("正在上传尺寸调整结果...");
|
||
finalUrl = await uploadBase64ToStorage(resizedImageUrl);
|
||
} catch (error) {
|
||
console.error("上传尺寸调整图片失败:", error);
|
||
toast.error("尺寸调整结果上传失败");
|
||
return;
|
||
}
|
||
}
|
||
|
||
const title = `${displayTitle}-尺寸调整`;
|
||
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: finalUrl,
|
||
status: "completed",
|
||
prompt: "尺寸调整结果",
|
||
title,
|
||
originalSrc: data.src,
|
||
},
|
||
connections: [node.id],
|
||
});
|
||
|
||
addPendingChatAttachment({
|
||
url: finalUrl,
|
||
name: title,
|
||
contentType: "image/jpeg",
|
||
});
|
||
toast.success("尺寸调整结果已发送到对话");
|
||
},
|
||
[
|
||
addNode,
|
||
addPendingChatAttachment,
|
||
data.src,
|
||
displayTitle,
|
||
node.id,
|
||
node.position.x,
|
||
node.position.y,
|
||
node.size.height,
|
||
node.size.width,
|
||
uploadBase64ToStorage,
|
||
],
|
||
);
|
||
|
||
// 基于当前图片+新提示词重绘
|
||
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 && !isMultiSelected && data.src && (
|
||
<div className="absolute -top-7 left-0 right-0 flex items-center 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
|
||
data-canvas-no-drag="true"
|
||
className="h-6 px-2 py-0 text-xs w-[160px]"
|
||
onChange={(e) => setDraftTitle(e.target.value)}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
onClick={(e) => e.stopPropagation()}
|
||
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>
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
ref={containerRef}
|
||
className={cn(
|
||
"relative w-full h-full bg-card rounded-[4px] overflow-hidden border shadow-sm transition-colors",
|
||
isSelected
|
||
? "rounded-none border-[2px] border-[#27a8ff] shadow-none"
|
||
: "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 text-center px-3">
|
||
{detailProgressLabel || "生成中..."}
|
||
</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('note')}>
|
||
<StickyNote 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('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('extend')}>
|
||
<Expand 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={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 && !isMultiSelected && data.src && !isEditingTitle && typeof document !== 'undefined' && createPortal(
|
||
<TooltipProvider delayDuration={200}>
|
||
<div
|
||
className="fixed z-[9999] flex items-center gap-1 rounded-[22px] border border-slate-300 bg-slate-50/95 px-3 py-2 text-gray-800 shadow-[0_8px_24px_rgba(15,23,42,0.14)] backdrop-blur-sm dark:border-slate-600 dark:bg-slate-900/95 dark:text-gray-100"
|
||
style={{
|
||
top: getToolbarPosition().top,
|
||
left: getToolbarPosition().left,
|
||
transform: 'translateX(-50%)',
|
||
pointerEvents: 'auto',
|
||
}}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<Button
|
||
variant="ghost"
|
||
disabled={isProcessing || isAddingToChat}
|
||
className="h-9 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-white hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
|
||
onClick={handleAddToChat}
|
||
>
|
||
{isAddingToChat ? (
|
||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<MessageSquarePlus className="mr-1.5 h-4 w-4 text-blue-600" />
|
||
)}
|
||
对话
|
||
</Button>
|
||
|
||
<div className="mx-1 h-5 w-px bg-slate-200 dark:bg-slate-700" />
|
||
|
||
<Button
|
||
variant="ghost"
|
||
disabled={isProcessing}
|
||
className="h-9 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-white hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
|
||
onClick={() => handleAction('enhance')}
|
||
>
|
||
<span className="mr-1.5 rounded-md border border-slate-300 px-1.5 text-[11px] leading-5 text-slate-600 dark:border-slate-500 dark:text-slate-100">HD</span>
|
||
增强
|
||
</Button>
|
||
|
||
<Button
|
||
variant="ghost"
|
||
disabled={isProcessing}
|
||
className="h-9 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-white hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
|
||
onClick={() => handleAction('rmbg')}
|
||
>
|
||
<Scissors className="mr-1.5 h-4 w-4" />
|
||
抠图
|
||
</Button>
|
||
|
||
<Button
|
||
variant="ghost"
|
||
disabled={isProcessing}
|
||
className="h-9 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-white hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
|
||
onClick={() => handleAction('watermark')}
|
||
>
|
||
<Eraser className="mr-1.5 h-4 w-4" />
|
||
去印
|
||
</Button>
|
||
|
||
<Button
|
||
variant="ghost"
|
||
disabled={isProcessing}
|
||
className="h-9 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-white hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
|
||
onClick={() => {
|
||
setPickerMode(true, node.id);
|
||
toast.info('点击图片中的物品进行拾取');
|
||
}}
|
||
>
|
||
<Crosshair className="mr-1.5 h-4 w-4 text-orange-600" />
|
||
拾物
|
||
</Button>
|
||
|
||
<Button
|
||
variant="ghost"
|
||
disabled={isProcessing || isDetectingText || isApplyingTextEdit}
|
||
className="h-9 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-white hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
|
||
onClick={() => handleAction('text-edit')}
|
||
>
|
||
<Type className="mr-1.5 h-4 w-4" />
|
||
改字
|
||
</Button>
|
||
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
disabled={isProcessing}
|
||
className="h-9 w-9 rounded-xl text-slate-700 hover:bg-white hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
|
||
>
|
||
<MoreHorizontal className="h-4 w-4" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="center" side="bottom">
|
||
<DropdownMenuItem onClick={() => handleAction('extend')}>
|
||
<Expand className="mr-2 h-4 w-4" />
|
||
扩图
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={() => handleAction('prompt-regenerate')}>
|
||
<RefreshCw className="mr-2 h-4 w-4" />
|
||
重绘
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={() => handleAction('angle')}>
|
||
<Move3D className="mr-2 h-4 w-4" />
|
||
视角
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={() => handleAction('resize')}>
|
||
<Maximize2 className="mr-2 h-4 w-4" />
|
||
调尺
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={() => handleAction('note')}>
|
||
<StickyNote className="mr-2 h-4 w-4" />
|
||
备注
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
className="text-red-600 focus:text-red-700"
|
||
onClick={() => deleteNode(node.id)}
|
||
>
|
||
<Trash2 className="mr-2 h-4 w-4" />
|
||
删除
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
disabled={isProcessing}
|
||
className="h-9 w-9 rounded-xl text-slate-700 hover:bg-white hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
|
||
onClick={handleDownload}
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</TooltipProvider>,
|
||
document.body
|
||
)}
|
||
|
||
{/* 图片备注对话框 */}
|
||
<Dialog open={notePanelOpen} onOpenChange={setNotePanelOpen}>
|
||
<DialogContent className="sm:max-w-[560px] max-h-[85vh] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<StickyNote className="h-5 w-5" />
|
||
图片备注
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-3 py-1">
|
||
<Textarea
|
||
value={draftNote}
|
||
onChange={(e) => setDraftNote(e.target.value)}
|
||
placeholder="记录这张图的想法、待办或交付要求..."
|
||
rows={8}
|
||
className="resize-y min-h-[180px]"
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
备注仅在你点开时显示,不会直接覆盖在画面上。
|
||
</p>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setNotePanelOpen(false)}>
|
||
取消
|
||
</Button>
|
||
<Button onClick={handleSaveNote}>
|
||
保存备注
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 文字编辑面板(显示在图片节点旁边) */}
|
||
{isSelected && textEditPanelOpen && data.src && typeof document !== "undefined" && createPortal(
|
||
<div
|
||
className="fixed z-[9999] w-[360px] rounded-xl border border-border bg-background/90 backdrop-blur shadow-2xl"
|
||
style={{
|
||
top: getTextPanelPosition().top,
|
||
left: getTextPanelPosition().left,
|
||
}}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
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)}
|
||
/>
|
||
)}
|
||
|
||
{resizeAnnotatorOpen && data.src && (
|
||
<ImageAnnotator
|
||
imageUrl={data.src}
|
||
initialResizeMode
|
||
confirmButtonText="发送到对话"
|
||
onConfirm={handleResizeEditorComplete}
|
||
onCancel={() => setResizeAnnotatorOpen(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
|
||
)}
|
||
</>
|
||
);
|
||
}
|