import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react"; import { App, Button, Dropdown, Input, Modal, Segmented, Tag } from "antd"; import { Download, Ellipsis, FolderPlus, GalleryHorizontalEnd, Image as ImageIcon, Info, LoaderCircle, Lock, Maximize2, MessageSquare, Minus, Music2, Plus, RefreshCw, Settings2, Trash2, Unlock, Upload, UserRound, Video } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style"; import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport"; import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset"; import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils"; import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error"; import { useCopyText } from "@/hooks/use-copy-text"; import { useThemeStore } from "@/stores/use-theme-store"; import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock"; import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode, type ViewportTransform } from "@/types/canvas"; import { ImageToolSettingsModal } from "./canvas-image-toolbar-settings-modal"; import { IMAGE_QUICK_TOOLS_STORAGE_KEY, buildImageToolbarTools, defaultImageQuickToolIds, isImageQuickToolId, readImageQuickToolsConfig, type ImageQuickToolId } from "./canvas-image-toolbar-tools"; type CanvasNodeHoverToolbarProps = { node: CanvasNodeData | null; viewport: ViewportTransform; containerRef: RefObject; onKeep: (nodeId: string) => void; onLeave: () => void; onInfo: (node: CanvasNodeData) => void; onEditText: (node: CanvasNodeData) => void; onDecreaseFont: (node: CanvasNodeData) => void; onIncreaseFont: (node: CanvasNodeData) => void; onToggleDialog: (node: CanvasNodeData) => void; onAnnotate: (node: CanvasNodeData) => void; onGenerateImage: (node: CanvasNodeData) => void; onUpload: (node: CanvasNodeData) => void; onDownload: (node: CanvasNodeData) => void; onSaveAsset: (node: CanvasNodeData) => void; onMaskEdit: (node: CanvasNodeData) => void; onEmotion: (node: CanvasNodeData) => void; onPortraitTexture: (node: CanvasNodeData) => void; onCrop: (node: CanvasNodeData) => void; onSplit: (node: CanvasNodeData) => void; onUpscale: (node: CanvasNodeData) => void; onSuperResolve: (node: CanvasNodeData) => void; onAngle: (node: CanvasNodeData) => void; onViewImage: (node: CanvasNodeData) => void; onExtractVideoLastFrame: (node: CanvasNodeData) => void; extractingVideoFrame: boolean; onReversePrompt: (node: CanvasNodeData) => void; onRetry: (node: CanvasNodeData) => void; onToggleFreeResize: (node: CanvasNodeData) => void; onToggleLocked: (node: CanvasNodeData) => void; onDelete: (node: CanvasNodeData) => void; workspaceMode?: CanvasWorkspaceMode; }; type CanvasAssetCategory = NonNullable["assetCategory"]>; const assetCategoryOptions: Array<{ value: CanvasAssetCategory; label: string }> = [ { value: "character", label: "角色" }, { value: "environment", label: "场景" }, { value: "wardrobe", label: "服饰" }, { value: "prop", label: "道具" }, { value: "weapon", label: "武器" }, { value: "style", label: "画风" }, { value: "other", label: "其他" }, ]; type ToolbarTool = { id: string; title: string; label: string; icon: ReactNode; onClick: () => void; active?: boolean; danger?: boolean; disabled?: boolean; }; const MAX_IMAGE_QUICK_TOOLS = 7; const NODE_DOCK_LABELS_STORAGE_KEY = "canvas-node-dock-show-labels-v1"; export function CanvasNodeHoverToolbar({ node, viewport, containerRef, onKeep, onLeave, onInfo, onEditText, onDecreaseFont, onIncreaseFont, onToggleDialog, onAnnotate, onGenerateImage, onUpload, onDownload, onSaveAsset, onMaskEdit, onEmotion, onPortraitTexture, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onViewImage, onExtractVideoLastFrame, extractingVideoFrame, onReversePrompt, onRetry, onToggleFreeResize, onToggleLocked, onDelete, workspaceMode = "professional", }: CanvasNodeHoverToolbarProps) { const [quickImageToolIds, setQuickImageToolIds] = useState(defaultImageQuickToolIds); const [draftImageToolIds, setDraftImageToolIds] = useState(defaultImageQuickToolIds); const [showDockLabels, setShowDockLabels] = useState(true); const [draftShowDockLabels, setDraftShowDockLabels] = useState(true); const [imageToolSettingsOpen, setImageToolSettingsOpen] = useState(false); const [imageToolMenuOpen, setImageToolMenuOpen] = useState(false); const [anchor, setAnchor] = useState<{ left: number; top: number } | null>(null); const toolbarRef = useRef(null); const { message } = App.useApp(); const copyText = useCopyText(); const theme = canvasThemes[useThemeStore((state) => state.theme)]; const simpleMode = workspaceMode === "simple"; useEffect(() => { try { const stored = window.localStorage.getItem(IMAGE_QUICK_TOOLS_STORAGE_KEY); if (!stored) return; const parsed = JSON.parse(stored) as unknown; setQuickImageToolIds(readImageQuickToolsConfig(parsed)); } catch { window.localStorage.removeItem(IMAGE_QUICK_TOOLS_STORAGE_KEY); } }, []); useEffect(() => { setShowDockLabels(window.localStorage.getItem(NODE_DOCK_LABELS_STORAGE_KEY) !== "0"); }, []); useEffect(() => { setImageToolSettingsOpen(false); setImageToolMenuOpen(false); }, [node?.id]); useLayoutEffect(() => { const container = containerRef.current; if (!node || !container) { setAnchor(null); return; } const element = container.querySelector(`[data-node-id="${CSS.escape(node.id)}"]`); if (!element) { setAnchor(null); return; } const update = () => { const nodeRect = element.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const preferredLeft = nodeRect.left - containerRect.left + nodeRect.width / 2; const toolbarWidth = toolbarRef.current?.offsetWidth || 0; const halfToolbar = toolbarWidth / 2; const canClamp = toolbarWidth > 0 && toolbarWidth <= containerRect.width - 20; const left = canClamp ? Math.min(Math.max(preferredLeft, halfToolbar + 10), containerRect.width - halfToolbar - 10) : preferredLeft; // 外置节点标题占用节点上方一行,工具 Dock 再向上错开,避免遮挡名称编辑入口。 const top = nodeRect.top - containerRect.top - 42; if (toolbarRef.current) { toolbarRef.current.style.left = `${left}px`; toolbarRef.current.style.top = `${top}px`; return; } setAnchor((current) => current?.left === left && current.top === top ? current : { left, top }); }; update(); const resizeObserver = new ResizeObserver(update); resizeObserver.observe(element); resizeObserver.observe(container); if (toolbarRef.current) resizeObserver.observe(toolbarRef.current); const viewportLayer = element.parentElement; const mutationObserver = new MutationObserver(update); if (viewportLayer) mutationObserver.observe(viewportLayer, { attributes: true, attributeFilter: ["style"] }); const unsubscribeViewport = subscribeCanvasViewportPreview(container, update); window.addEventListener("resize", update); return () => { resizeObserver.disconnect(); mutationObserver.disconnect(); unsubscribeViewport(); window.removeEventListener("resize", update); }; }, [anchor === null, containerRef, node, showDockLabels, viewport.k, viewport.x, viewport.y]); if (!node || !anchor) return null; const activeNode = node; const isImage = node.type === CanvasNodeType.Image; const isVideo = node.type === CanvasNodeType.Video; const isAudio = node.type === CanvasNodeType.Audio; const hasImage = isImage && Boolean(node.metadata?.content); const hasVideo = isVideo && Boolean(node.metadata?.content); const hasAudio = isAudio && Boolean(node.metadata?.content); const isText = node.type === CanvasNodeType.Text; const isCharacterReference = isText && node.metadata?.workflowKind === "character" && Boolean(node.metadata.characterAssetId); const isEditableText = isText && !isCharacterReference; const isConfig = node.type === CanvasNodeType.Config; const canOpenDialog = isEditableText || isImage || isVideo; const requiresPromptChange = node.metadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(node.metadata?.errorDetails); const canRetry = node.metadata?.status === "error" && !requiresPromptChange; const quickImageToolIdSet = new Set(quickImageToolIds); const copyImagePrompt = (target: CanvasNodeData) => { const prompt = target.metadata?.prompt?.trim(); if (!prompt) { message.warning("暂无可复制的提示词"); return; } copyText(prompt, "提示词已复制"); }; const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onMaskEdit, onEmotion, onPortraitTexture, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt }); function openImageToolSettings() { onKeep(activeNode.id); setImageToolMenuOpen(false); setDraftImageToolIds(quickImageToolIds); setDraftShowDockLabels(showDockLabels); setImageToolSettingsOpen(true); } const baseToolbarTools: ToolbarTool[] = [ { id: "info", title: isCharacterReference ? "查看角色详情" : "查看节点信息", label: isCharacterReference ? "角色详情" : "信息", icon: isCharacterReference ? : , onClick: () => onInfo(node) }, { id: "delete", title: "移除节点", label: "删除", icon: , onClick: () => onDelete(node), danger: true }, ]; const nodeToolbarTools: ToolbarTool[] = [ ...(canRetry ? [{ id: "retry", title: "重新生成", label: "重试", icon: , onClick: () => onRetry(node) }] : []), ...(hasVideo && !simpleMode ? [{ id: "extractLastFrame", title: extractingVideoFrame ? "正在截取尾帧" : "截取尾帧", label: extractingVideoFrame ? "截取中" : "尾帧", icon: extractingVideoFrame ? : , onClick: () => onExtractVideoLastFrame(node), disabled: extractingVideoFrame }] : []), ...(hasImage || hasVideo || isEditableText ? [{ id: "saveAsset", title: "加入我的素材", label: "存素材", icon: , onClick: () => onSaveAsset(node) }] : []), ...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片", label: "下载", icon: , onClick: () => onDownload(node) }] : []), ...(canOpenDialog ? [{ id: "edit", title: isEditableText ? "调用文本模型生成内容" : "编辑", label: isEditableText ? "文本生成" : "编辑", icon: , onClick: () => onToggleDialog(node) }] : []), ...(isEditableText ? [{ id: "editText", title: "放大编辑文本", label: "放大编辑", icon: , onClick: () => onEditText(node) }] : []), ...(isEditableText ? [{ id: "generateImage", title: "用文本生图", label: "生图", icon: , onClick: () => onGenerateImage(node) }] : []), ...(isConfig && !simpleMode ? [{ id: "config", title: "生成配置", label: "生成配置", icon: , onClick: () => onToggleDialog(node) }] : []), ...(isEditableText && !simpleMode ? [{ id: "decreaseFont", title: "减小字号", label: "缩小", icon: , onClick: () => onDecreaseFont(node) }] : []), ...(isEditableText && !simpleMode ? [{ id: "increaseFont", title: "增大字号", label: "放大", icon: , onClick: () => onIncreaseFont(node) }] : []), ...(isImage && !hasImage ? [{ id: "uploadImage", title: "上传图片", label: "上传图片", icon: , onClick: () => onUpload(node) }] : []), ...(isVideo ? [{ id: "uploadVideo", title: hasVideo ? "替换视频" : "上传视频", label: hasVideo ? "替换视频" : "上传视频", icon: