import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react"; import { App, Button, Dropdown, Input, Modal, Tag, Tooltip } from "antd"; import type { MenuProps } from "antd"; import { Check, ChevronDown, Ellipsis, Images, Plus, SlidersHorizontal, UserRound } from "lucide-react"; import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style"; import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category"; import { canvasThemes } from "@/lib/canvas-theme"; import { resolveNodeToolbarPlacement, resolveToolbarTools, type NodeToolbarGroup, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry"; import { subscribeCanvasGraphicsViewportPreview } from "@/lib/canvas/canvas-live-viewport"; import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset"; import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils"; import { generationErrorMessage } from "@/lib/generation-error"; import { useCopyText } from "@/hooks/use-copy-text"; import { useThemeStore } from "@/stores/use-theme-store"; import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode, type ViewportTransform } from "@/types/canvas"; import { buildImageToolbarTools } 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; onCreateConversion?: (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; onExtractVideoFrames: (node: CanvasNodeData) => void; onExtractAudioFromVideo: (node: CanvasNodeData) => void; onTrimVideoSegments: (node: CanvasNodeData) => void; onSubtitles: (node: CanvasNodeData) => void; onTimeline: (node: CanvasNodeData) => void; extractingVideoFrames: 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 }> = ASSET_CATEGORY_OPTIONS; type ToolbarTool = { section?: string; description?: string; id: string; label: string; icon: ReactNode; onClick: () => void; group: NodeToolbarGroup; order: number; active?: boolean; danger?: boolean; disabled?: boolean; }; export function CanvasNodeToolbar({ node, viewport, containerRef, onKeep, onLeave, onInfo, onEditText, onDecreaseFont, onIncreaseFont, onToggleDialog, onAnnotate, onGenerateImage, onUpload, onDownload, onSaveAsset, onCreateConversion, onMaskEdit, onEmotion, onPortraitTexture, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onViewImage, onExtractVideoFrames, onExtractAudioFromVideo, onTrimVideoSegments, onSubtitles, onTimeline, extractingVideoFrames, extractingAudio, trimmingVideo, onReversePrompt, onRetry, onToggleFreeResize, onToggleLocked, onDelete, workspaceMode = "professional", }: CanvasNodeToolbarProps) { const [openMenuId, setOpenMenuId] = useState(null); const [containerWidth, setContainerWidth] = useState(1000); const [anchor, setAnchor] = useState<{ left: number; top: number } | null>(null); const toolbarRef = useRef(null); const { message } = App.useApp(); const copyText = useCopyText(); const themeName = useThemeStore((state) => state.theme); const theme = canvasThemes[themeName]; const simpleMode = workspaceMode === "simple"; useEffect(() => { setOpenMenuId(null); }, [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; } let disposed = false; let queued = false; let containerRect = container.getBoundingClientRect(); let toolbarWidth = toolbarRef.current?.offsetWidth || 0; let toolbarHeight = toolbarRef.current?.offsetHeight || 44; const update = () => { const nodeRect = element.getBoundingClientRect(); const preferredLeft = nodeRect.left - containerRect.left + nodeRect.width / 2; const halfToolbar = toolbarWidth / 2; const canClamp = toolbarWidth > 0 && toolbarWidth <= containerRect.width - 20; let left = canClamp ? Math.min(Math.max(preferredLeft, halfToolbar + 10), containerRect.width - halfToolbar - 10) : preferredLeft; const above = nodeRect.top - containerRect.top - 30; let top = Math.max(toolbarHeight + 8, Math.min(above, containerRect.height - 8)); for (const panel of container.querySelectorAll("[data-canvas-node-panel]")) { const panelRect = panel.getBoundingClientRect(); const panelLeft = panelRect.left - containerRect.left; const panelRight = panelRect.right - containerRect.left; const panelTop = panelRect.top - containerRect.top; const panelBottom = panelRect.bottom - containerRect.top; if (left + halfToolbar <= panelLeft || left - halfToolbar >= panelRight || top <= panelTop || top - toolbarHeight >= panelBottom) continue; if (panelLeft >= toolbarWidth + 18) left = panelLeft - halfToolbar - 8; else if (containerRect.width - panelRight >= toolbarWidth + 18) left = panelRight + halfToolbar + 8; else if (panelTop >= toolbarHeight + 16) top = panelTop - 8; else if (containerRect.height - panelBottom >= toolbarHeight + 16) top = panelBottom + toolbarHeight + 8; } if (toolbarRef.current) { toolbarRef.current.style.transform = `translate3d(${left}px, ${top}px, 0)`; return; } setAnchor((current) => current?.left === left && current.top === top ? current : { left, top }); }; const scheduleUpdate = () => { if (queued || disposed) return; queued = true; queueMicrotask(() => { queued = false; if (!disposed) update(); }); }; const measure = () => { containerRect = container.getBoundingClientRect(); toolbarWidth = toolbarRef.current?.offsetWidth || 0; toolbarHeight = toolbarRef.current?.offsetHeight || 44; setContainerWidth(containerRect.width); scheduleUpdate(); }; measure(); const resizeObserver = new ResizeObserver(measure); resizeObserver.observe(element); resizeObserver.observe(container); if (toolbarRef.current) resizeObserver.observe(toolbarRef.current); const unsubscribeViewport = subscribeCanvasGraphicsViewportPreview(container, scheduleUpdate); window.addEventListener("resize", measure); return () => { disposed = true; resizeObserver.disconnect(); unsubscribeViewport(); window.removeEventListener("resize", measure); }; }, [anchor === null, containerRef, node, viewport.k, viewport.x, viewport.y]); if (!node || !anchor) return null; 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 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 }); // 构建 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, onNodeCreateConversion: onCreateConversion, onNodeMaskEdit: onMaskEdit, onNodeEmotion: onEmotion, onNodePortraitTexture: onPortraitTexture, onNodeCrop: onCrop, onNodeSplit: onSplit, onNodeUpscale: onUpscale, onNodeSuperResolve: onSuperResolve, onNodeAngle: onAngle, onNodeViewImage: onViewImage, onNodeExtractVideoFrames: onExtractVideoFrames, onNodeExtractAudioFromVideo: onExtractAudioFromVideo, onNodeTrimVideoSegments: onTrimVideoSegments, 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, extractingVideoFrames, extractingAudio, trimmingVideo, mergingVideos: false, addPanelOpen: false, appearancePanelOpen: false, settingsPanelOpen: false, handlers: nodeHoverHandlers, }; // 注册表统一提供动作合同、适用性和节点 Dock 层级。 const registryTools = resolveToolbarTools("node-hover", nodeHoverCtx, null); const registryToolbarTools: ToolbarTool[] = registryTools.map((tool) => { const placement = resolveNodeToolbarPlacement(tool, nodeHoverCtx); return { id: tool.id, 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, group: placement.group, order: placement.order, section: tool.nodeToolbar?.section, description: tool.nodeToolbar?.description, active: tool.active?.(nodeHoverCtx), danger: tool.danger, disabled: tool.disabled?.(nodeHoverCtx), onClick: () => tool.run(nodeHoverCtx), }; }); const allTools: ToolbarTool[] = hasImage && !simpleMode ? [...registryToolbarTools, ...imageTools] : registryToolbarTools; const compact = containerWidth < 640; const narrow = containerWidth < 420; const inGroup = (group: NodeToolbarGroup) => allTools.filter((tool) => tool.group === group).sort(compareToolbarTools); const primary = inGroup("primary"); const primaryTools = narrow ? primary.slice(0, 1) : primary; const portraitTools = compact ? [] : inGroup("portrait"); const viewpointTools = compact ? [] : inGroup("viewpoint"); const processTools = compact ? [...inGroup("portrait"), ...inGroup("viewpoint"), ...inGroup("process")] : inGroup("process"); const workspaceTools = narrow ? [] : inGroup("workspace"); const utilityTools = inGroup("utility"); const moreTools = [...(narrow ? [...primary.slice(1), ...inGroup("workspace")] : []), ...inGroup("more")]; const processMenuLabel = compact ? "工具" : isVideo ? "提取素材" : isImage ? "图片工具" : isAudio ? "音频处理" : "文本调整"; const handleMenuOpenChange = (menuId: string, open: boolean) => { setOpenMenuId((current) => open ? menuId : current === menuId ? null : current); if (open) onKeep(node.id); else if (!toolbarRef.current?.contains(document.activeElement)) onLeave(); }; const dockStyle = canvasDockStyle(theme, theme.node.text); return (
onKeep(node.id)} onMouseLeave={() => { if (!openMenuId) onLeave(); }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} data-canvas-no-zoom onKeyDown={(event) => event.stopPropagation()} onFocus={() => onKeep(node.id)} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget) && !openMenuId) onLeave(); }} >
{primaryTools.map((tool) => )} {portraitTools.length ? } tools={portraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null} {viewpointTools.map((tool) => )} {processTools.length ? : } tools={processTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null} {workspaceTools.length ? : null} {workspaceTools.map((tool) => )} {utilityTools.length || moreTools.length ? : null} {utilityTools.map((tool) => )} {moreTools.length ? ( } tools={moreTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} placement="topRight" iconOnly /> ) : null}
); } function NodeDockToolButton({ tool, iconOnly = false }: { tool: ToolbarTool; iconOnly?: boolean }) { return ( ); } function compareToolbarTools(left: ToolbarTool, right: ToolbarTool) { if (left.danger !== right.danger) return left.danger ? 1 : -1; return left.order - right.order; } function NodeDockMenuButton({ menuId, label, icon, tools, openMenuId, onOpenChange, placement = "top", iconOnly = false }: { menuId: string; label: string; icon: ReactNode; tools: ToolbarTool[]; openMenuId: string | null; onOpenChange: (menuId: string, open: boolean) => void; placement?: "top" | "topRight"; iconOnly?: boolean }) { const open = openMenuId === menuId; const triggerRef = useRef(null); useEffect(() => { if (!open) return; const frame = requestAnimationFrame(() => triggerRef.current?.focus()); return () => cancelAnimationFrame(frame); }, [open]); const sections = new Map(); for (const tool of tools) { const section = tool.danger ? "危险操作" : tool.section || "常用操作"; sections.set(section, [...(sections.get(section) || []), tool]); } const items: MenuProps["items"] = [...sections].sort(([left], [right]) => Number(left === "危险操作") - Number(right === "危险操作")).map(([section, entries]) => ({ type: "group", key: section, label: section, children: entries.map((tool) => ({ key: tool.id, icon: tool.icon, label:
{tool.label}{tool.active ? : null}{tool.description ?
{tool.description}
: null}
, disabled: tool.disabled, danger: tool.danger, onClick: () => { onOpenChange(menuId, false); tool.onClick(); } })), })); return ( onOpenChange(menuId, nextOpen)} menu={{ items }} autoFocus popupRender={(menu) =>
event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} onKeyDownCapture={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); triggerRef.current?.focus(); onOpenChange(menuId, false); } }} onKeyDown={(event) => event.stopPropagation()}>{menu}
}>
); } 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 [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 ? "背板" : node?.type === CanvasNodeType.MediaConversion ? "转换" : "生成配置"; 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}
); return ( {node ? (
基础信息{node.metadata?.status || "idle"}
{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} {nodeGenerationRows(node).length ? (
生成信息
{nodeGenerationRows(node).map((item) => )}
) : 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}
) : null}
); } function InfoRow({ label, value }: { label: string; value: ReactNode }) { return (
{label}
{value}
); } function nodeGenerationRows(node: CanvasNodeData) { const metadata = node.metadata; if (!metadata) return [] as Array<{ label: string; value: string }>; const rows: Array<{ label: string; value: string }> = []; const add = (label: string, value: unknown) => { if (value === undefined || value === null || value === "") return; rows.push({ label, value: String(value) }); }; const addTime = (label: string, value?: string) => { if (!value) return; const timestamp = Date.parse(value); add(label, Number.isFinite(timestamp) ? new Date(timestamp).toLocaleString() : value); }; const addDuration = (value?: number) => { if (typeof value !== "number" || !Number.isFinite(value)) return; const totalSeconds = Math.max(0, Math.round(value / 1000)); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; add("耗时", minutes ? `${minutes}分 ${seconds}秒` : `${seconds}秒`); }; add("模型", metadata.model); add("生成尺寸", metadata.size); add("分辨率", metadata.vquality || metadata.quality); add("秒数", metadata.seconds ? `${metadata.seconds} 秒` : undefined); add("生成声音", metadata.generateAudio === undefined ? undefined : metadata.generateAudio === "true" ? "开启" : "关闭"); add("水印", metadata.watermark === undefined ? undefined : metadata.watermark === "true" ? "开启" : "关闭"); if (metadata.references?.length) { const referenceNames = metadata.references.slice(0, 3).map((reference) => reference.split("/").pop() || reference).join("、"); add("引用素材", `${metadata.references.length} 个${referenceNames ? `(${referenceNames}${metadata.references.length > 3 ? "…" : ""})` : ""}`); } addTime("创建时间", metadata.taskCreatedAt); addTime("开始时间", metadata.taskStartedAt); addTime("完成时间", metadata.taskCompletedAt); addDuration(metadata.taskDurationMs); return rows; }