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 { Ellipsis, Lock, Plus, Settings2, Unlock } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style"; import { defaultToolbarPrefs, readToolbarPrefs, resolveToolbarTools, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry"; 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, generationErrorMessage, 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 CanvasNodeToolbarProps = { 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; onExtractAudioFromVideo: (node: CanvasNodeData) => void; onTrimVideoRegenerate: (node: CanvasNodeData) => void; onSubtitles: (node: CanvasNodeData) => void; onTimeline: (node: CanvasNodeData) => void; extractingVideoFrame: boolean; extractingAudio: boolean; trimmingVideo: 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 CanvasNodeToolbar({ 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, onExtractAudioFromVideo, onTrimVideoRegenerate, onSubtitles, onTimeline, extractingVideoFrame, extractingAudio, trimmingVideo, onReversePrompt, onRetry, onToggleFreeResize, onToggleLocked, onDelete, workspaceMode = "professional", }: CanvasNodeToolbarProps) { 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); // Dropdown 关闭与菜单点击处于同一事件批次,ref 用来同步守住即将打开的 Modal,避免工具栏先被卸载。 const imageToolSettingsOpenRef = useRef(false); const imageToolMenuOpenRef = useRef(false); const { message } = App.useApp(); const copyText = useCopyText(); const themeName = useThemeStore((state) => state.theme); const theme = canvasThemes[themeName]; 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(() => { imageToolSettingsOpenRef.current = false; imageToolMenuOpenRef.current = false; 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; // 外置标题固定占 24px,额外保留 6px 即可兼顾层级和名称编辑入口。 const top = nodeRect.top - containerRect.top - 30; 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() { imageToolSettingsOpenRef.current = true; imageToolMenuOpenRef.current = false; onKeep(activeNode.id); setImageToolMenuOpen(false); setDraftImageToolIds(quickImageToolIds); setDraftShowDockLabels(showDockLabels); setImageToolSettingsOpen(true); } // 构建 ToolContext——供注册表解析工具 const nodeHoverHandlers = { onNodeInfo: onInfo, onNodeDelete: onDelete, onNodeRetry: onRetry, onNodeEditText: onEditText, onNodeDecreaseFont: onDecreaseFont, onNodeIncreaseFont: onIncreaseFont, onNodeToggleDialog: onToggleDialog, onNodeAnnotate: onAnnotate, onNodeGenerateImage: onGenerateImage, onNodeUpload: onUpload, onNodeDownload: onDownload, onNodeSaveAsset: onSaveAsset, onNodeMaskEdit: onMaskEdit, onNodeEmotion: onEmotion, onNodePortraitTexture: onPortraitTexture, onNodeCrop: onCrop, onNodeSplit: onSplit, onNodeUpscale: onUpscale, onNodeSuperResolve: onSuperResolve, onNodeAngle: onAngle, onNodeViewImage: onViewImage, onNodeExtractVideoLastFrame: onExtractVideoLastFrame, onNodeExtractAudioFromVideo: onExtractAudioFromVideo, onNodeTrimVideoRegenerate: onTrimVideoRegenerate, onNodeReversePrompt: onReversePrompt, onNodeToggleFreeResize: onToggleFreeResize, onNodeSubtitles: onSubtitles, onNodeTimeline: onTimeline, onNodeToggleLocked: onToggleLocked, onNodeCopyPrompt: copyImagePrompt, } as Partial as ToolbarHandlers; const nodeHoverCtx: ToolContext = { selectedCount: 0, selectedNodeTypes: new Set(), selectedVideoCount: 0, canvasTool: "move", workspaceMode: workspaceMode || "professional", isProjectLinked: false, canUndo: false, canRedo: false, node, nodeMetadata: node.metadata, extractingVideoFrame, extractingAudio, trimmingVideo, mergingVideos: false, addPanelOpen: false, appearancePanelOpen: false, settingsPanelOpen: false, handlers: nodeHoverHandlers, }; // 从注册表解析工具(已按 applicable 过滤、用户偏好排序) const registryTools = resolveToolbarTools("node-hover", nodeHoverCtx, readToolbarPrefs("node-hover") ?? defaultToolbarPrefs("node-hover")); // 分离 node-lock(始终显示,不参与 quickImageToolIds 过滤) const otherRegistryTools = registryTools.filter((tool) => tool.id !== "node-lock"); // 转为 ToolbarTool 供组件内部逻辑使用 const otherTools: ToolbarTool[] = otherRegistryTools.map((tool) => ({ id: tool.id, title: typeof tool.label === "function" ? tool.label(nodeHoverCtx) : tool.label, label: tool.displayLabel ? (typeof tool.displayLabel === "function" ? tool.displayLabel(nodeHoverCtx) : tool.displayLabel) : (typeof tool.label === "function" ? tool.label(nodeHoverCtx) : tool.label), icon: typeof tool.icon === "function" ? tool.icon(nodeHoverCtx) : tool.icon, active: tool.active?.(nodeHoverCtx), danger: tool.danger, disabled: tool.disabled?.(nodeHoverCtx), onClick: () => tool.run(nodeHoverCtx), })); // 合并图片工具(当 hasImage && !simpleMode 时追加到 otherTools 末尾) const allTools: ToolbarTool[] = hasImage && !simpleMode ? [...otherTools, ...imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, danger: undefined, disabled: undefined, onClick: tool.onClick }))] : otherTools; // hasImage 时按 quickImageToolIds 过滤 const toolbarTools = hasImage ? allTools.filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : allTools; const selectableImageToolbarTools = allTools.filter((tool): tool is ToolbarTool & { id: ImageQuickToolId } => isImageQuickToolId(tool.id)); const temporaryImageToolbarTools = selectableImageToolbarTools.filter((tool) => !quickImageToolIdSet.has(tool.id)); const dockItems: FloatingDockEntry[] = [ ...toolbarTools.map((tool) => ({ id: tool.id, label: tool.title, displayLabel: tool.label, icon: tool.icon, active: tool.active, danger: tool.danger, disabled: tool.disabled, onClick: () => tool.onClick() })), { kind: "separator", id: "node-state-separator" }, { id: "node-lock", label: node.metadata?.locked ? "解锁节点" : "锁定位置和尺寸", displayLabel: node.metadata?.locked ? "解锁" : "锁定", icon: node.metadata?.locked ? : , active: Boolean(node.metadata?.locked), onClick: () => onToggleLocked(node) }, ]; const closeImageToolSettings = () => { imageToolSettingsOpenRef.current = false; setImageToolSettingsOpen(false); onLeave(); }; const runTemporaryImageTool = (tool: ToolbarTool) => { imageToolMenuOpenRef.current = false; setImageToolMenuOpen(false); tool.onClick(); }; const handleImageToolMenuOpenChange = (open: boolean) => { imageToolMenuOpenRef.current = open; setImageToolMenuOpen(open); if (open) onKeep(activeNode.id); else if (!imageToolSettingsOpenRef.current) onLeave(); }; const setDraftImageToolVisible = (id: ImageQuickToolId, visible: boolean) => { setDraftImageToolIds((current) => { const selected = new Set(current); if (visible && selected.size >= MAX_IMAGE_QUICK_TOOLS) { message.warning(`最多固定 ${MAX_IMAGE_QUICK_TOOLS} 个快捷工具`); return current; } if (visible) selected.add(id); else selected.delete(id); return selectableImageToolbarTools.filter((tool) => selected.has(tool.id)).map((tool) => tool.id); }); }; const saveImageToolSettings = () => { setQuickImageToolIds(draftImageToolIds); setShowDockLabels(draftShowDockLabels); window.localStorage.setItem(IMAGE_QUICK_TOOLS_STORAGE_KEY, JSON.stringify(draftImageToolIds)); window.localStorage.setItem(NODE_DOCK_LABELS_STORAGE_KEY, draftShowDockLabels ? "1" : "0"); closeImageToolSettings(); }; const dockShellStyle = canvasDockStyle(theme, theme.node.text); const embeddedDockStyle = { ...dockShellStyle, background: "transparent", borderColor: "transparent", boxShadow: "none" }; const labeledDockStyle = { ...dockShellStyle, boxShadow: themeName === "dark" ? `0 18px 52px ${theme.spatial.shadow}` : "0 10px 30px rgba(15,23,42,.12), 0 1px 2px rgba(15,23,42,.05), inset 0 1px 0 rgba(255,255,255,.7)", }; return ( <>
onKeep(node.id)} onMouseLeave={() => { if (!imageToolSettingsOpenRef.current && !imageToolMenuOpenRef.current) onLeave(); }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} >
{dockItems.length ? : null} {hasImage && !simpleMode ? ( ({ key: tool.id, icon: tool.icon, label: tool.label, danger: tool.danger, onClick: () => runTemporaryImageTool(tool) })), ...(temporaryImageToolbarTools.length ? [{ type: "divider" as const }] : []), { key: "manage-image-quick-tools", icon: , label: "管理快捷工具", onClick: openImageToolSettings }, ], }} > ) : null}
{hasImage ? ( ) : null} ); } export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, readOnly = false, onUnauthorized }: { node: CanvasNodeData | null; open: boolean; onClose: () => void; onMetadataChange?: (nodeId: string, metadata: Partial) => void; readOnly?: boolean; onUnauthorized?: () => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const [view, setView] = useState<"info" | "json">("info"); const [assetTags, setAssetTags] = useState([]); const [assetTagInput, setAssetTagInput] = useState(""); const [assetCategory, setAssetCategory] = useState("other"); const imageBytes = node?.type === CanvasNodeType.Image && node.metadata?.content ? getDataUrlByteSize(node.metadata.content) : 0; const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.batchChildIds?.length || 0 : 0; const nodeTypeLabel = node?.type === CanvasNodeType.Text ? "文本" : node?.type === CanvasNodeType.Script ? "分镜脚本" : node?.type === CanvasNodeType.Skill ? "技能" : node?.type === CanvasNodeType.Image ? "图片" : node?.type === CanvasNodeType.Video ? "视频" : node?.type === CanvasNodeType.Audio ? "音频" : node?.type === CanvasNodeType.Drawing ? "绘图" : node?.type === CanvasNodeType.Frame ? "背板" : "生成配置"; const json = useMemo(() => { if (!node) return ""; return JSON.stringify( node, (key, value) => { if (key === "title") return undefined; if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) { return "[base64 image]"; } return value; }, 2, ); }, [node]); useEffect(() => { if (open) setView("info"); }, [node?.id, open]); useEffect(() => { setAssetTags(node?.metadata?.assetTags || []); setAssetTagInput(""); setAssetCategory(node ? canvasNodeAssetCategory(node) : "other"); }, [node?.id, node?.metadata?.assetCategory, node?.metadata?.assetTags]); const saveAssetCategory = (category: CanvasAssetCategory) => { if (!node || node.type !== CanvasNodeType.Image) return; setAssetCategory(category); onMetadataChange?.(node.id, { assetCategory: category }); }; const saveAssetTags = (nextTags: string[]) => { if (!node || node.type !== CanvasNodeType.Image) return; const tags = Array.from(new Set(nextTags.map((item) => item.trim()).filter(Boolean))); setAssetTags(tags); onMetadataChange?.(node.id, { assetTags: tags }); }; const addAssetTag = () => { const tags = assetTagInput .split(/\n|,|,/) .map((item) => item.trim()) .filter(Boolean); if (!tags.length) return; saveAssetTags([...assetTags, ...tags]); setAssetTagInput(""); }; const removeAssetTag = (tag: string) => { saveAssetTags(assetTags.filter((item) => item !== tag)); }; const title = (
节点信息
{node ?
{node.id}
: null}
setView(value as "info" | "json")} options={[ { label: "信息", value: "info" }, { label: "JSON", value: "json" }, ]} />
); return ( {node ? (
{view === "info" ? (
{batchCount > 1 ? : null} {imageBytes ? : null}
{node.type === CanvasNodeType.Image ? (
项目资产分类
{assetCategoryOptions.map((option) => { const active = assetCategory === option.value; return ; })}
生成后会按此分类进入项目资产;角色、场景和画风工作流会自动预填。
) : null} {node.metadata?.prompt ? (
提示词
{node.metadata.prompt}
) : null} {node.type === CanvasNodeType.Skill && node.metadata?.skillSnapshot ? (
技能模板
{node.metadata.skillSnapshot.template}
{node.metadata.skillSnapshot.outputContract ? ( <>
输出约束
{node.metadata.skillSnapshot.outputContract}
) : null}
) : null}
{node.type === CanvasNodeType.Image ? (
资产标签
一条标签描述一个角色、环境、道具或镜头用途。
{assetTags.length} 条
{readOnly ? (
分享画布为只读,标签无法编辑。
) : (
setAssetTagInput(event.target.value)} onPressEnter={addAssetTag} />
)}
{assetTags.length ? ( assetTags.map((tag) => ( (readOnly ? onUnauthorized?.() : removeAssetTag(tag))} className="!m-0 !rounded-lg !px-2 !py-1 !text-sm"> {tag} )) ) : ( {readOnly ? "暂无标签" : "还没有标签,输入后点击“加入”或按 Enter。"} )}
) : null} {node.metadata?.errorDetails ? (
{generationErrorMessage(node.metadata.errorDetails)}
) : null}
) : (
                            {json}
                        
)}
) : null}
); } function InfoRow({ label, value }: { label: string; value: ReactNode }) { return (
{label}
{value}
); }