"use client"; import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; import { createPortal } from "react-dom"; import { ImagePlus, Loader2, AlertCircle, MoreVertical, MoreHorizontal, Scissors, Eraser, Sparkles, Expand, Maximize2, Box, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X, Type, StickyNote, Scan, Layers } 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, NormalizedCropRect } from "@/services/canvas"; import type { CanvasNode, ImageNodeData, ImageTextEditItem } from "@/types/canvas"; import { cn } from "@/lib/utils"; import { inferOriginalImageUrl } from "@/lib/image-url"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { AngleEditDialog } from "@/components/angle-edit-dialog"; import { SceneFusionDialog } from "@/components/scene-fusion-dialog"; import { ImageAnnotator } from "@/components/image-annotator"; import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog"; import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer"; import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer"; import type { PickedObject } from "@/store/canvas"; import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter"; import { buildDetailUiDslV2 } from "@/lib/detail-ui-dsl-v2-builder"; import type { DetailUiDslV2Document } from "@/lib/detail-ui-dsl-v2"; import { normalizeDetailUiSubjectAnalysis, type DetailUiSubjectAnalysis, } from "@/lib/detail-ui-subject-analysis"; // 已标注的物品标记(本地状态) interface PickedMarker { clickX: number; clickY: number; objectName: string; index: number; } interface ImageDisplayMetrics { offsetX: number; offsetY: number; renderedWidth: number; renderedHeight: number; naturalWidth: number; naturalHeight: number; } interface LayerSplitBounds { x: number; y: number; width: number; height: number; originalWidth: number; originalHeight: number; coverage: number; } interface BoxCutoutPoint { x: number; y: number; } const BOX_CUTOUT_DEFAULT_PADDING_RATIO = 0.08; const BOX_CUTOUT_MIN_EDGE_PX = 24; const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); const createNormalizedRect = ( start: BoxCutoutPoint, end: BoxCutoutPoint ): NormalizedCropRect => { const x = Math.min(start.x, end.x); const y = Math.min(start.y, end.y); const right = Math.max(start.x, end.x); const bottom = Math.max(start.y, end.y); return { x, y, width: clampNumber(right - x, 0, 1), height: clampNumber(bottom - y, 0, 1), }; }; 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 normalizeMediaUrl = (url: string): string => { const raw = String(url || "").trim(); if (!raw) return ""; try { const parsed = new URL(raw); return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "").toLowerCase(); } catch { return raw .split("#")[0] .split("?")[0] .replace(/\/+$/, "") .toLowerCase(); } }; const normalizeImageIdentityUrl = (url: string): string => { const raw = String(url || "").trim(); if (!raw) return ""; const canonical = inferOriginalImageUrl(raw); return normalizeMediaUrl(canonical || raw); }; 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 { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; const loadUrl = buildImageProxyUrl(imageUrl); 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 = loadUrl; }); } 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 [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false); const [textEditItems, setTextEditItems] = useState( () => normalizeTextEditItems(data.textEditItems), ); const [textEditError, setTextEditError] = useState(null); const [isDetectingText, setIsDetectingText] = useState(false); const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false); const [deletedTextItems, setDeletedTextItems] = useState>([]); const [isEditingTitle, setIsEditingTitle] = useState(false); const [draftTitle, setDraftTitle] = useState(""); const [draftNote, setDraftNote] = useState(""); const [isAddingToChat, setIsAddingToChat] = useState(false); const [isCompareMode, setIsCompareMode] = useState(false); const [compareSplitPercent, setCompareSplitPercent] = useState(50); const [toolbarSize, setToolbarSize] = useState({ width: 0, height: 0 }); const [boxCutoutToolbarSize, setBoxCutoutToolbarSize] = useState({ width: 0, height: 0 }); const [boxCutoutMode, setBoxCutoutMode] = useState(false); const [isBoxCutoutDrawing, setIsBoxCutoutDrawing] = useState(false); const [boxCutoutSelection, setBoxCutoutSelection] = useState(null); const [boxCutoutDragStart, setBoxCutoutDragStart] = useState(null); const [boxCutoutDragEnd, setBoxCutoutDragEnd] = useState(null); const [boxCutoutMetrics, setBoxCutoutMetrics] = useState(null); // 文件输入的 ref const fileInputRef = useRef(null); const containerRef = useRef(null); const displayImageRef = useRef(null); const compareFrameRef = useRef(null); const toolbarRef = useRef(null); const boxCutoutToolbarRef = useRef(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 imageNodeActionRequest = useCanvasStore((state) => state.imageNodeActionRequest); const clearImageNodeActionRequest = useCanvasStore((state) => state.clearImageNodeActionRequest); const allNodes = useCanvasStore((state) => state.nodes); // 🆕 物品拾取相关状态 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); const displayTitle = getImageNodeTitle(data, node.id); const agentExecutionSnapshotId = typeof data.metadata?.agentExecutionSnapshotId === "string" ? data.metadata.agentExecutionSnapshotId.trim() : ""; const detailUiScreenOverlay = useMemo(() => { const raw = data.metadata?.detailUiScreenOverlay; if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; return raw as DetailUiScreenOverlaySnapshot; }, [data.metadata]); const detailSubjectAnalysis = useMemo(() => { return normalizeDetailUiSubjectAnalysis(data.metadata?.detailSubjectAnalysis); }, [data.metadata]); const detailUiDslDocumentFromMetadata = useMemo(() => { const raw = data.metadata?.detailUiDslDocument; if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; return raw as DetailUiDslV2Document; }, [data.metadata]); const detailUiDslDocument = useMemo(() => { if (detailUiDslDocumentFromMetadata) return detailUiDslDocumentFromMetadata; if (!detailUiScreenOverlay) return null; return buildDetailUiDslV2({ overlay: detailUiScreenOverlay, subjectAnalysis: detailSubjectAnalysis, }); }, [detailSubjectAnalysis, detailUiDslDocumentFromMetadata, detailUiScreenOverlay]); const shouldRenderDetailOverlay = data.metadata?.detailGenerationMode === "agent_layout" && Boolean(detailUiScreenOverlay); const layerSplitBounds = useMemo(() => { const raw = data.metadata?.bounds; if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; const x = Number((raw as any).x); const y = Number((raw as any).y); const width = Number((raw as any).width); const height = Number((raw as any).height); const originalWidth = Number((raw as any).originalWidth); const originalHeight = Number((raw as any).originalHeight); const coverage = Number((raw as any).coverage); if ( !Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(originalWidth) || !Number.isFinite(originalHeight) || width <= 0 || height <= 0 || originalWidth <= 0 || originalHeight <= 0 ) { return null; } return { x, y, width, height, originalWidth, originalHeight, coverage: Number.isFinite(coverage) ? coverage : 0, }; }, [data.metadata]); const layerSplitCropStyle = useMemo(() => { if (!layerSplitBounds) return undefined; return { position: "absolute", width: `${(layerSplitBounds.originalWidth / layerSplitBounds.width) * 100}%`, height: `${(layerSplitBounds.originalHeight / layerSplitBounds.height) * 100}%`, maxWidth: "none", maxHeight: "none", left: `${-(layerSplitBounds.x / layerSplitBounds.width) * 100}%`, top: `${-(layerSplitBounds.y / layerSplitBounds.height) * 100}%`, }; }, [layerSplitBounds]); const shortAgentExecutionSnapshotId = agentExecutionSnapshotId ? agentExecutionSnapshotId.slice(0, 8) : ""; const isMultiSelected = isSelected && selectedNodeIds.length > 1; const compareSource = useMemo(() => { const targetIdentity = normalizeImageIdentityUrl(data.src || ""); if (!targetIdentity) return ""; const candidates: string[] = []; const pushCandidate = (value: unknown) => { const raw = String(value || "").trim(); if (!raw) return; candidates.push(raw); }; const visited = new Set([node.id]); const queue = Array.isArray(node.connections) ? [...node.connections] : []; let safety = 0; while (queue.length > 0 && safety < 120) { safety += 1; const sourceNodeId = queue.shift(); if (!sourceNodeId || visited.has(sourceNodeId)) continue; visited.add(sourceNodeId); const sourceNode = allNodes.find((item) => item.id === sourceNodeId); if (!sourceNode || sourceNode.type !== "image") continue; const sourceData = (sourceNode.data as any) || {}; pushCandidate(sourceData.src); pushCandidate(sourceData.originalSrc); pushCandidate(sourceData.metadata?.compareSourceUrl); if (Array.isArray(sourceNode.connections) && sourceNode.connections.length > 0) { queue.push(...sourceNode.connections); } } pushCandidate(data.originalSrc); pushCandidate(data.metadata?.compareSourceUrl); for (const candidate of candidates) { const identity = normalizeImageIdentityUrl(candidate); if (!identity) continue; if (identity !== targetIdentity) { return candidate; } } return ""; }, [allNodes, data.metadata, data.originalSrc, data.src, node.connections, node.id]); const hasCompareSource = Boolean(data.src) && Boolean(compareSource); const compareButtonVisualSize = useMemo(() => { const minSide = Math.max(1, Math.min(node.size.width || 0, node.size.height || 0)); return Math.round(Math.max(24, Math.min(32, minSide * 0.09))); }, [node.size.height, node.size.width]); const compareButtonHitSize = useMemo( () => Math.max(34, compareButtonVisualSize + 8), [compareButtonVisualSize] ); const compareButtonIconSize = useMemo( () => Math.round(Math.max(12, Math.min(16, compareButtonVisualSize * 0.5))), [compareButtonVisualSize] ); const compareButtonInset = useMemo( () => Math.max(6, Math.min(10, Math.round(compareButtonVisualSize * 0.22))), [compareButtonVisualSize] ); const displayImageWidth = useMemo( () => Math.max(640, Math.min(2200, Math.round((node.size.width || 320) * 2))), [node.size.width] ); const displayImageSrc = useMemo( () => buildImageProxyUrl(data.src, { width: displayImageWidth, quality: 86, format: "webp", }), [data.src, displayImageWidth] ); const displayCompareSrc = useMemo( () => buildImageProxyUrl(compareSource, { width: displayImageWidth, quality: 86, format: "webp", }), [compareSource, displayImageWidth] ); const [loadedResolution, setLoadedResolution] = useState<{ width: number; height: number } | null>(null); const metadataResolution = useMemo(() => { const rawWidth = Number((data.metadata as any)?.originalWidth ?? 0); const rawHeight = Number((data.metadata as any)?.originalHeight ?? 0); const width = Number.isFinite(rawWidth) ? Math.round(rawWidth) : 0; const height = Number.isFinite(rawHeight) ? Math.round(rawHeight) : 0; if (width > 0 && height > 0) { return { width, height }; } return null; }, [data.metadata]); const resolutionLabel = useMemo(() => { const source = metadataResolution || loadedResolution; if (!source) return ""; return `${source.width} x ${source.height}`; }, [loadedResolution, metadataResolution]); const boxCutoutDraftRect = useMemo(() => { if (!boxCutoutMode || !boxCutoutDragStart || !boxCutoutDragEnd) { return null; } return createNormalizedRect(boxCutoutDragStart, boxCutoutDragEnd); }, [boxCutoutDragEnd, boxCutoutDragStart, boxCutoutMode]); const activeBoxCutoutRect = boxCutoutSelection || boxCutoutDraftRect; const activeBoxCutoutRectStyle = useMemo(() => { if (!boxCutoutMetrics || !activeBoxCutoutRect) return null; return { left: boxCutoutMetrics.offsetX + activeBoxCutoutRect.x * boxCutoutMetrics.renderedWidth, top: boxCutoutMetrics.offsetY + activeBoxCutoutRect.y * boxCutoutMetrics.renderedHeight, width: activeBoxCutoutRect.width * boxCutoutMetrics.renderedWidth, height: activeBoxCutoutRect.height * boxCutoutMetrics.renderedHeight, }; }, [activeBoxCutoutRect, boxCutoutMetrics]); const resetBoxCutoutState = useCallback(() => { setBoxCutoutMode(false); setIsBoxCutoutDrawing(false); setBoxCutoutSelection(null); setBoxCutoutDragStart(null); setBoxCutoutDragEnd(null); }, []); const refreshBoxCutoutMetrics = useCallback(() => { const imageElement = displayImageRef.current; if (!imageElement) { setBoxCutoutMetrics(null); return; } const naturalWidth = imageElement.naturalWidth || metadataResolution?.width || loadedResolution?.width || 0; const naturalHeight = imageElement.naturalHeight || metadataResolution?.height || loadedResolution?.height || 0; const boxWidth = imageElement.clientWidth; const boxHeight = imageElement.clientHeight; if (!naturalWidth || !naturalHeight || !boxWidth || !boxHeight) { setBoxCutoutMetrics(null); return; } const displayScale = Math.min(boxWidth / naturalWidth, boxHeight / naturalHeight); const renderedWidth = naturalWidth * displayScale; const renderedHeight = naturalHeight * displayScale; setBoxCutoutMetrics({ offsetX: (boxWidth - renderedWidth) / 2, offsetY: (boxHeight - renderedHeight) / 2, renderedWidth, renderedHeight, naturalWidth, naturalHeight, }); }, [loadedResolution, metadataResolution]); const getBoxCutoutPointerPoint = useCallback( (clientX: number, clientY: number) => { const imageElement = displayImageRef.current; if (!imageElement) return null; const currentMetrics = (() => { if ( boxCutoutMetrics && boxCutoutMetrics.renderedWidth > 0 && boxCutoutMetrics.renderedHeight > 0 ) { return boxCutoutMetrics; } const naturalWidth = imageElement.naturalWidth || metadataResolution?.width || loadedResolution?.width || 0; const naturalHeight = imageElement.naturalHeight || metadataResolution?.height || loadedResolution?.height || 0; const boxWidth = imageElement.clientWidth; const boxHeight = imageElement.clientHeight; if (!naturalWidth || !naturalHeight || !boxWidth || !boxHeight) { return null; } const displayScale = Math.min(boxWidth / naturalWidth, boxHeight / naturalHeight); return { offsetX: (boxWidth - naturalWidth * displayScale) / 2, offsetY: (boxHeight - naturalHeight * displayScale) / 2, renderedWidth: naturalWidth * displayScale, renderedHeight: naturalHeight * displayScale, naturalWidth, naturalHeight, } satisfies ImageDisplayMetrics; })(); if (!currentMetrics) return null; const rect = imageElement.getBoundingClientRect(); const rawX = clientX - rect.left - currentMetrics.offsetX; const rawY = clientY - rect.top - currentMetrics.offsetY; const clampedX = clampNumber(rawX, 0, currentMetrics.renderedWidth); const clampedY = clampNumber(rawY, 0, currentMetrics.renderedHeight); return { point: { x: currentMetrics.renderedWidth > 0 ? clampedX / currentMetrics.renderedWidth : 0, y: currentMetrics.renderedHeight > 0 ? clampedY / currentMetrics.renderedHeight : 0, } satisfies BoxCutoutPoint, insideImage: rawX >= 0 && rawX <= currentMetrics.renderedWidth && rawY >= 0 && rawY <= currentMetrics.renderedHeight, metrics: currentMetrics, }; }, [boxCutoutMetrics, loadedResolution, metadataResolution] ); // 退出拾取模式时清空状态 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); resetBoxCutoutState(); } }, [isSelected, resetBoxCutoutState]); useEffect(() => { if (!hasCompareSource || isThisNodePicking) { setIsCompareMode(false); } }, [hasCompareSource, isThisNodePicking]); useEffect(() => { if ((isThisNodePicking || isCompareMode || sceneFusionDialogOpen || promptDialogOpen || notePanelOpen) && boxCutoutMode) { resetBoxCutoutState(); } }, [ boxCutoutMode, isCompareMode, isThisNodePicking, notePanelOpen, promptDialogOpen, resetBoxCutoutState, sceneFusionDialogOpen, ]); useEffect(() => { if (!boxCutoutMode || typeof window === "undefined") return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); resetBoxCutoutState(); } }; window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("keydown", handleKeyDown); }; }, [boxCutoutMode, resetBoxCutoutState]); useEffect(() => { setLoadedResolution(null); if (!isSelected || !data.src || metadataResolution) return; let canceled = false; const img = new Image(); img.onload = () => { if (canceled) return; const width = Math.round(img.naturalWidth || 0); const height = Math.round(img.naturalHeight || 0); if (width > 0 && height > 0) { setLoadedResolution({ width, height }); } }; img.onerror = () => { if (!canceled) { setLoadedResolution(null); } }; img.src = data.src; return () => { canceled = true; }; }, [data.src, isSelected, metadataResolution]); useEffect(() => { if (typeof window === "undefined") return; let rafId: number | null = null; const scheduleRefresh = () => { if (rafId !== null) { window.cancelAnimationFrame(rafId); } rafId = window.requestAnimationFrame(() => { refreshBoxCutoutMetrics(); }); }; scheduleRefresh(); window.addEventListener("resize", scheduleRefresh); const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(scheduleRefresh) : null; if (containerRef.current) { resizeObserver?.observe(containerRef.current); } if (displayImageRef.current) { resizeObserver?.observe(displayImageRef.current); } return () => { if (rafId !== null) { window.cancelAnimationFrame(rafId); } window.removeEventListener("resize", scheduleRefresh); resizeObserver?.disconnect(); }; }, [data.src, refreshBoxCutoutMetrics]); const updateCompareSplitByClientX = useCallback((clientX: number) => { const frame = compareFrameRef.current; if (!frame) return; const rect = frame.getBoundingClientRect(); if (rect.width <= 0) return; const rawPercent = ((clientX - rect.left) / rect.width) * 100; setCompareSplitPercent(Math.max(0, Math.min(100, rawPercent))); }, []); useEffect(() => { if (!isCompareMode) return; const handlePointerDownOutside = (event: PointerEvent) => { const target = event.target as Node | null; if (!target) return; const container = containerRef.current; if (container && container.contains(target)) return; setIsCompareMode(false); }; document.addEventListener("pointerdown", handlePointerDownOutside, true); return () => { document.removeEventListener("pointerdown", handlePointerDownOutside, true); }; }, [isCompareMode]); 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]); useEffect(() => { if ( typeof window === "undefined" || !isSelected || isMultiSelected || !data.src || isEditingTitle || sceneFusionDialogOpen || promptDialogOpen || notePanelOpen || boxCutoutMode ) { return; } const toolbarEl = toolbarRef.current; if (!toolbarEl) return; let rafId: number | null = null; const measure = () => { const rect = toolbarEl.getBoundingClientRect(); const next = { width: rect.width, height: rect.height }; setToolbarSize((prev) => Math.abs(prev.width - next.width) < 0.5 && Math.abs(prev.height - next.height) < 0.5 ? prev : next ); }; const scheduleMeasure = () => { if (rafId !== null) window.cancelAnimationFrame(rafId); rafId = window.requestAnimationFrame(measure); }; scheduleMeasure(); window.addEventListener("resize", scheduleMeasure); const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(scheduleMeasure) : null; resizeObserver?.observe(toolbarEl); return () => { window.removeEventListener("resize", scheduleMeasure); resizeObserver?.disconnect(); if (rafId !== null) window.cancelAnimationFrame(rafId); }; }, [ boxCutoutMode, data.src, isEditingTitle, isMultiSelected, isSelected, notePanelOpen, promptDialogOpen, sceneFusionDialogOpen, ]); useEffect(() => { if ( typeof window === "undefined" || !isSelected || isMultiSelected || !data.src || !boxCutoutMode ) { return; } const toolbarEl = boxCutoutToolbarRef.current; if (!toolbarEl) return; let rafId: number | null = null; const measure = () => { const rect = toolbarEl.getBoundingClientRect(); const next = { width: rect.width, height: rect.height }; setBoxCutoutToolbarSize((prev) => Math.abs(prev.width - next.width) < 0.5 && Math.abs(prev.height - next.height) < 0.5 ? prev : next ); }; const scheduleMeasure = () => { if (rafId !== null) window.cancelAnimationFrame(rafId); rafId = window.requestAnimationFrame(measure); }; scheduleMeasure(); window.addEventListener("resize", scheduleMeasure); const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(scheduleMeasure) : null; resizeObserver?.observe(toolbarEl); return () => { window.removeEventListener("resize", scheduleMeasure); resizeObserver?.disconnect(); if (rafId !== null) window.cancelAnimationFrame(rafId); }; }, [boxCutoutMode, data.src, isMultiSelected, isSelected]); // 🆕 处理图片点击标注 const handlePickerClick = useCallback(async (e: React.MouseEvent) => { 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 handleEnterBoxCutoutMode = useCallback( (event?: React.MouseEvent | React.PointerEvent) => { event?.stopPropagation(); if (!data.src) { toast.error("请先上传图片"); return; } if (isProcessing) { toast.info("正在处理中,请稍候..."); return; } if (isThisNodePicking) { setPickerMode(false); } setIsCompareMode(false); setBoxCutoutSelection(null); setBoxCutoutDragStart(null); setBoxCutoutDragEnd(null); setIsBoxCutoutDrawing(false); setBoxCutoutMode(true); window.requestAnimationFrame(() => { refreshBoxCutoutMetrics(); }); }, [data.src, isProcessing, isThisNodePicking, refreshBoxCutoutMetrics, setPickerMode] ); const handleBoxCutoutPointerDown = useCallback( (event: React.PointerEvent) => { if (!boxCutoutMode || isProcessing) return; event.preventDefault(); event.stopPropagation(); const pointerResult = getBoxCutoutPointerPoint(event.clientX, event.clientY); if (!pointerResult?.insideImage) { return; } setBoxCutoutSelection(null); setBoxCutoutDragStart(pointerResult.point); setBoxCutoutDragEnd(pointerResult.point); setIsBoxCutoutDrawing(true); event.currentTarget.setPointerCapture?.(event.pointerId); }, [boxCutoutMode, getBoxCutoutPointerPoint, isProcessing] ); const handleBoxCutoutPointerMove = useCallback( (event: React.PointerEvent) => { if (!boxCutoutMode || !isBoxCutoutDrawing) return; event.preventDefault(); event.stopPropagation(); const pointerResult = getBoxCutoutPointerPoint(event.clientX, event.clientY); if (!pointerResult) return; setBoxCutoutDragEnd(pointerResult.point); }, [boxCutoutMode, getBoxCutoutPointerPoint, isBoxCutoutDrawing] ); const finalizeBoxCutoutSelection = useCallback( (clientX: number, clientY: number) => { if (!boxCutoutMode || !boxCutoutDragStart) { setIsBoxCutoutDrawing(false); return; } const pointerResult = getBoxCutoutPointerPoint(clientX, clientY); if (!pointerResult) { setIsBoxCutoutDrawing(false); setBoxCutoutDragStart(null); setBoxCutoutDragEnd(null); return; } const nextRect = createNormalizedRect(boxCutoutDragStart, pointerResult.point); const nextWidthPx = nextRect.width * pointerResult.metrics.naturalWidth; const nextHeightPx = nextRect.height * pointerResult.metrics.naturalHeight; if (nextWidthPx >= BOX_CUTOUT_MIN_EDGE_PX && nextHeightPx >= BOX_CUTOUT_MIN_EDGE_PX) { setBoxCutoutSelection(nextRect); } else { setBoxCutoutSelection(null); } setBoxCutoutDragStart(null); setBoxCutoutDragEnd(null); setIsBoxCutoutDrawing(false); }, [boxCutoutDragStart, boxCutoutMode, getBoxCutoutPointerPoint] ); const handleBoxCutoutPointerUp = useCallback( (event: React.PointerEvent) => { if (!boxCutoutMode) return; event.preventDefault(); event.stopPropagation(); finalizeBoxCutoutSelection(event.clientX, event.clientY); event.currentTarget.releasePointerCapture?.(event.pointerId); }, [boxCutoutMode, finalizeBoxCutoutSelection] ); const handleBoxCutoutPointerCancel = useCallback( (event: React.PointerEvent) => { if (!boxCutoutMode) return; event.preventDefault(); event.stopPropagation(); setIsBoxCutoutDrawing(false); setBoxCutoutDragStart(null); setBoxCutoutDragEnd(null); event.currentTarget.releasePointerCapture?.(event.pointerId); }, [boxCutoutMode] ); const handleConfirmBoxCutout = useCallback(async () => { if (!data.src) { toast.error("请先上传图片"); return; } if (!boxCutoutSelection) { toast.info("请先框选要抠出的元素"); return; } if (isProcessing) { toast.info("正在处理中,请稍候..."); return; } setIsProcessing(true); const actionName = "框选抠图"; 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, metadata: { cropRect: boxCutoutSelection, paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO, }, }, connections: [node.id], }); try { const result = await imageGenerationService.boxCutout({ imageUrl: data.src, cropRect: boxCutoutSelection, paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO, sourceNodeId: node.id, }); if (result.success && result.data?.url) { updateNode(newNodeId, { data: { src: result.data.url, status: "completed", prompt: `${actionName}结果`, title: `${displayTitle}-${actionName}`, originalSrc: data.src, metadata: { cropRect: boxCutoutSelection, cropRectApplied: result.data.cropRectApplied, paddedCropRectApplied: result.data.paddedCropRectApplied, paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO, editMode: "box-cutout", }, }, }); toast.success(`${actionName}完成`); } else { updateNode(newNodeId, { data: { src: "", status: "failed", prompt: `${actionName}失败`, title: `${displayTitle}-${actionName}-失败`, originalSrc: data.src, metadata: { cropRect: boxCutoutSelection, paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO, }, }, }); toast.error(result.error || `${actionName}失败`); } } catch (error) { console.error("框选抠图失败:", error); updateNode(newNodeId, { data: { src: "", status: "failed", prompt: `${actionName}失败`, title: `${displayTitle}-${actionName}-失败`, originalSrc: data.src, metadata: { cropRect: boxCutoutSelection, paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO, }, }, }); toast.error(`${actionName}失败,请重试`); } finally { setIsProcessing(false); resetBoxCutoutState(); } }, [ addNode, boxCutoutSelection, data.src, displayTitle, isProcessing, node.id, node.position.x, node.position.y, node.size.height, node.size.width, resetBoxCutoutState, updateNode, ]); 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((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 getFloatingViewportBounds = (toolbarHeight: number) => { const viewportPadding = 12; let unsafeBottomTop = window.innerHeight; const bottomOverlaySelectors = ['[data-canvas-floating-toolbar="true"]']; for (const selector of bottomOverlaySelectors) { const elements = Array.from(document.querySelectorAll(selector)); for (const element of elements) { const style = window.getComputedStyle(element); if (style.display === "none" || style.visibility === "hidden") continue; const overlayRect = element.getBoundingClientRect(); if (overlayRect.width <= 0 || overlayRect.height <= 0) continue; if (overlayRect.bottom <= 0 || overlayRect.top >= window.innerHeight) continue; if (overlayRect.top < window.innerHeight * 0.55) continue; unsafeBottomTop = Math.min(unsafeBottomTop, overlayRect.top); } } const safeBottom = Math.max( viewportPadding, Math.min(window.innerHeight - viewportPadding, unsafeBottomTop - viewportPadding) ); const stageElement = document.querySelector('[data-canvas-stage="true"]') as HTMLElement | null; const panelElement = document.querySelector('[data-canvas-right-panel="true"]') as HTMLElement | null; let safeLeft = viewportPadding; let safeRight = window.innerWidth - viewportPadding; if (stageElement) { const stageRect = stageElement.getBoundingClientRect(); if (stageRect.width > 0) { safeLeft = Math.max(safeLeft, stageRect.left + viewportPadding); safeRight = Math.min(safeRight, stageRect.right - viewportPadding); } } if (panelElement) { const panelRect = panelElement.getBoundingClientRect(); if (panelRect.width > 0 && panelRect.left < window.innerWidth) { safeRight = Math.min(safeRight, panelRect.left - viewportPadding); } } if (safeRight <= safeLeft + 8) { safeLeft = viewportPadding; safeRight = window.innerWidth - viewportPadding; } return { viewportPadding, safeBottom, safeLeft, safeRight, maxTop: Math.max(viewportPadding, safeBottom - toolbarHeight), }; }; // 获取工具栏位置 - 在渲染时直接计算 const getToolbarPosition = () => { if (containerRef.current && typeof window !== "undefined") { const rect = containerRef.current.getBoundingClientRect(); const toolbarScale = 0.8; const toolbarWidth = Math.max(280, toolbarSize.width * toolbarScale || 980); const toolbarHeight = Math.max(42, toolbarSize.height * toolbarScale || 48); const titleSafeHeight = 34; const gap = 12; const { viewportPadding, maxTop, safeLeft, safeRight } = getFloatingViewportBounds(toolbarHeight); let top = rect.top - toolbarHeight - titleSafeHeight - gap; if (top < viewportPadding) { top = rect.bottom + gap; } top = Math.min(Math.max(top, viewportPadding), maxTop); const maxToolbarWidth = Math.max(180, safeRight - safeLeft); const effectiveToolbarWidth = Math.min(toolbarWidth, maxToolbarWidth); const halfToolbar = effectiveToolbarWidth / 2; let left = rect.left + rect.width / 2; if (effectiveToolbarWidth >= maxToolbarWidth - 1) { left = (safeLeft + safeRight) / 2; } else { left = Math.min(Math.max(left, safeLeft + halfToolbar), safeRight - halfToolbar); } return { top, left, maxWidth: maxToolbarWidth, }; } return { top: 0, left: 0, maxWidth: 480 }; }; const getBoxCutoutToolbarPosition = () => { if (containerRef.current && typeof window !== "undefined") { const rect = containerRef.current.getBoundingClientRect(); const toolbarScale = 0.8; const toolbarWidth = Math.max(240, boxCutoutToolbarSize.width * toolbarScale || 460); const toolbarHeight = Math.max(42, boxCutoutToolbarSize.height * toolbarScale || 48); const gap = 14; const { viewportPadding, safeBottom, safeLeft, safeRight, maxTop } = getFloatingViewportBounds(toolbarHeight); let top = rect.bottom + gap; if (top + toolbarHeight > safeBottom) { top = rect.top - toolbarHeight - gap; } top = Math.min(Math.max(top, viewportPadding), maxTop); const maxToolbarWidth = Math.max(180, safeRight - safeLeft); const effectiveToolbarWidth = Math.min(toolbarWidth, maxToolbarWidth); const halfToolbar = effectiveToolbarWidth / 2; let left = rect.left + rect.width / 2; if (effectiveToolbarWidth >= maxToolbarWidth - 1) { left = (safeLeft + safeRight) / 2; } else { left = Math.min(Math.max(left, safeLeft + halfToolbar), safeRight - halfToolbar); } return { top, left, maxWidth: maxToolbarWidth, }; } return { top: 0, left: 0, maxWidth: 460 }; }; 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]); useEffect(() => { if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) { return; } clearImageNodeActionRequest(); if (imageNodeActionRequest.action === "box-cutout") { handleEnterBoxCutoutMode(); return; } if (imageNodeActionRequest.action === "text-edit") { void handleOpenTextEdit(); } }, [ clearImageNodeActionRequest, handleEnterBoxCutoutMode, handleOpenTextEdit, imageNodeActionRequest, node.id, ]); 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) => { 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, assetId: result.data.assetId, 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 = { 'rmbg': '抠图', 'watermark': '去印', 'enhance': '增强', 'extend': '扩图', 'resize': '调尺', 'angle': '视角', 'layer-split': '图层拆分', 'scene-fusion': '批量处理', 'annotate': '标注', 'text-edit': '改字', 'note': '备注', 'prompt-regenerate': '重绘', 'box-cutout': '框选抠图', }; 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 === "layer-split") { setIsProcessing(true); try { const result = await imageGenerationService.splitImageLayers(data.src); const layers = result.data?.layers || []; if (!result.success || layers.length === 0) { toast.error(result.error || "图层拆分失败"); return; } const addedLayerCount = handleLayerSplitComplete( layers.map((layer) => ({ originalUrl: layer.originalUrl, title: layer.title, layerIndex: layer.layerIndex, totalLayers: layer.totalLayers, bounds: layer.bounds ?? null, })) ); toast.success(`图层拆分完成,已新增 ${addedLayerCount} 个图层结果`); } catch (error) { console.error("图层拆分失败:", error); toast.error("图层拆分失败"); } finally { setIsProcessing(false); } return; } if (action === "box-cutout") { handleEnterBoxCutoutMode(); 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 handleSceneFusionBatchAddToCanvas = useCallback((imageUrls: string[]) => { const validUrls = imageUrls.filter((url) => typeof url === "string" && url.trim().length > 0); if (validUrls.length === 0) { throw new Error("没有可添加到画布的图片"); } const maxColumns = 3; const gap = 20; const baseX = node.position.x + node.size.width + gap; const baseY = node.position.y; validUrls.forEach((url, index) => { const column = index % maxColumns; const row = Math.floor(index / maxColumns); addNode({ type: "image", position: { x: baseX + column * (node.size.width + gap), y: baseY + row * (node.size.height + gap), }, size: { width: node.size.width, height: node.size.height }, data: { src: url, status: "completed", prompt: "批量处理结果", title: `${displayTitle}-批量${index + 1}`, originalSrc: data.src, }, connections: [node.id], }); }); }, [addNode, data.src, displayTitle, node.id, node.position.x, node.position.y, node.size.height, node.size.width]); const handleLayerSplitComplete = useCallback( ( layers: Array<{ originalUrl: string; title?: string; layerIndex: number; totalLayers: number; bounds?: { x: number; y: number; width: number; height: number; originalWidth: number; originalHeight: number; coverage: number; } | null; }> ) => { const validLayers = layers.filter( (item) => typeof item?.originalUrl === "string" && item.originalUrl.trim().length > 0 ); if (validLayers.length === 0) { throw new Error("没有可添加到画布的图层结果"); } const isFullFrameLayer = (bounds?: LayerSplitBounds | null) => { if (!bounds) return false; return ( bounds.coverage >= 0.92 && bounds.width >= bounds.originalWidth - 2 && bounds.height >= bounds.originalHeight - 2 ); }; const drawableLayers = validLayers.filter((layer) => !isFullFrameLayer(layer.bounds)); const orderedLayers = (drawableLayers.length > 0 ? drawableLayers : validLayers).sort( (left, right) => (left.layerIndex || 0) - (right.layerIndex || 0) ); orderedLayers.forEach((layer, index) => { const layerTitle = `${displayTitle}-${layer.title || `图层${layer.layerIndex || index + 1}`}`; const bounds = layer.bounds; const hasUsableBounds = bounds && bounds.originalWidth > 0 && bounds.originalHeight > 0 && bounds.width > 0 && bounds.height > 0; const scaleX = hasUsableBounds ? node.size.width / bounds.originalWidth : 1; const scaleY = hasUsableBounds ? node.size.height / bounds.originalHeight : 1; const targetPosition = hasUsableBounds ? { x: node.position.x + bounds.x * scaleX, y: node.position.y + bounds.y * scaleY, } : { x: node.position.x, y: node.position.y, }; const targetSize = hasUsableBounds ? { width: Math.max(1, bounds.width * scaleX), height: Math.max(1, bounds.height * scaleY), } : { width: node.size.width, height: node.size.height }; addNode({ type: "image", position: targetPosition, size: targetSize, data: { src: layer.originalUrl, status: "completed", prompt: `图层拆分结果 ${layer.layerIndex}/${layer.totalLayers}`, title: layerTitle, originalSrc: data.src, metadata: { layerIndex: layer.layerIndex, totalLayers: layer.totalLayers, bounds: layer.bounds, allowOverlap: true, placementMode: "overlay", }, }, connections: [node.id], }); }); return orderedLayers.length; }, [addNode, data.src, displayTitle, node.id, node.position.x, node.position.y, node.size.height, node.size.width] ); // 图片标注完成回调 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 fetchUploadWithRetry(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 fetchUploadWithRetry(formData); if (!uploadResponse.ok) { throw new Error("上传失败"); } const result = await uploadResponse.json(); return result.url as string; }, []); const handleResizeEditorComplete = useCallback( async (resizedImageUrl: string) => { 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], }); setResizeAnnotatorOpen(false); toast.success("尺寸调整结果已发送到画布"); }, [ addNode, 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'); } }; const toolbarPosition = getToolbarPosition(); const boxCutoutToolbarPosition = getBoxCutoutToolbarPosition(); const boxCutoutHintText = boxCutoutSelection ? "确认后将仅抠出框选区域" : isBoxCutoutDrawing ? "松开完成框选" : "在图片中拖拽矩形框"; return ( <> {/* 选中时在图片上方显示标题 */} {isSelected && !isMultiSelected && data.src && !sceneFusionDialogOpen && (
{isEditingTitle ? ( 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); } }} /> ) : ( { e.stopPropagation(); setDraftTitle(displayTitle); setIsEditingTitle(true); }} > {displayTitle} )}
{resolutionLabel ? ( {resolutionLabel} ) : null}
)}
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {/* 图片内容 - 使用 object-contain 保持原始比例 */} {data.src ? (
{isCompareMode && hasCompareSource ? (
{ event.stopPropagation(); updateCompareSplitByClientX(event.clientX); }} onPointerMove={(event) => { updateCompareSplitByClientX(event.clientX); }} >
{displayTitle} { if (event.currentTarget.src !== data.src) { event.currentTarget.src = data.src; } }} />
{`${displayTitle}-原图`} { if (event.currentTarget.src !== compareSource) { event.currentTarget.src = compareSource; } }} />
新图
原图
) : ( <> {displayTitle} { if (event.currentTarget.src !== data.src) { event.currentTarget.src = data.src; } }} onClick={isThisNodePicking && !boxCutoutMode ? handlePickerClick : undefined} onDoubleClick={(e) => { if (!isThisNodePicking && !boxCutoutMode) { e.stopPropagation(); setFullscreenPreviewOpen(true); } }} /> {shouldRenderDetailOverlay ? ( detailUiDslDocument ? ( ) : ( ) ) : null} )} {boxCutoutMode && !isThisNodePicking && !isCompareMode && ( <>
{ event.preventDefault(); event.stopPropagation(); }} /> {activeBoxCutoutRectStyle ? (
) : (
)} )} {/* 🆕 拾取模式:当前点击位置(正在识别) */} {isThisNodePicking && currentClick && (
)} {/* 🆕 拾取模式:状态栏 */} {isThisNodePicking && (
{isPickerLoading ? '正在识别...' : '点击标注物品'}
)} {hasCompareSource && !isThisNodePicking && !isCompareMode && !boxCutoutMode && isSelected && !isMultiSelected && ( )}
) : data.status === 'generating' ? (
{detailProgressLabel || "生成中..."}
) : data.status === 'failed' ? (
生成失败
) : (
点击上传图片 {/* 隐藏的文件输入 */} {/* 选择文件按钮 */}
)} {/* 处理中遮罩 */} {isProcessing && (
处理中...
)} {/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */} {/* 悬浮时的更多操作按钮 (右上角) */} {isHovered && data.src && !isProcessing && !isSelected && (
handleAction('rmbg')}> 抠图 handleAction('watermark')}> 去印 handleAction('enhance')}> 增强 handleAction('note')}> 备注 handleAction('layer-split')}> 图层拆分 handleAction('resize')}> 调尺 handleAction('angle')}> 视角 handleAction('prompt-regenerate')}> 重绘 handleAction('text-edit')}> 改字 handleAction('extend')}> 扩图 { setDraftTitle(displayTitle); setIsEditingTitle(true); }} > 改名 下载 deleteNode(node.id)} > 删除
)} {/* 提示词显示 */} {data.prompt && isHovered && !isProcessing && (

{data.prompt}

)} {data.note && !isSelected && (
备注
)} {shortAgentExecutionSnapshotId ? ( ) : null}
{/* 角度编辑对话框 */} {data.src && ( )} {/* 批量处理对话框 */} {/* 提示词重绘对话框 */} 提示词重绘

在保留当前图片主体的基础上,用新提示词生成一张新图。原图不会被覆盖。