606 lines
33 KiB
TypeScript
606 lines
33 KiB
TypeScript
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<HTMLDivElement | null>;
|
||
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<NonNullable<CanvasNodeData["metadata"]>["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<string | null>(null);
|
||
const [containerWidth, setContainerWidth] = useState(1000);
|
||
const [anchor, setAnchor] = useState<{ left: number; top: number } | null>(null);
|
||
const toolbarRef = useRef<HTMLDivElement>(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<HTMLElement>(`[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<HTMLElement>("[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<ToolbarHandlers> 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 (
|
||
<div
|
||
ref={toolbarRef}
|
||
className="canvas-node-toolbar absolute z-[var(--z-node-toolbar)] -translate-x-1/2 -translate-y-full"
|
||
style={{ left: 0, top: 0, transform: `translate3d(${anchor.left}px, ${anchor.top}px, 0)`, width: "max-content", maxWidth: "calc(100% - 20px)", color: theme.node.text }}
|
||
onMouseEnter={() => 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(); }}
|
||
>
|
||
<div
|
||
role="toolbar"
|
||
aria-label="节点快捷工具"
|
||
className="flex h-11 max-w-full items-center gap-0.5 overflow-visible rounded-[var(--dock-radius-tight)] px-2 backdrop-blur-2xl"
|
||
style={{ ...dockStyle, border: 0 }}
|
||
>
|
||
{primaryTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||
{portraitTools.length ? <NodeDockMenuButton menuId="portrait" label="人像调整" icon={<UserRound className="size-3.5" />} tools={portraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
|
||
{viewpointTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||
{processTools.length ? <NodeDockMenuButton menuId="process" label={processMenuLabel} icon={isVideo ? <Images className="size-3.5" /> : <SlidersHorizontal className="size-3.5" />} tools={processTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
|
||
{workspaceTools.length ? <span aria-hidden className="aceternity-dock-separator mx-1 h-5 w-px shrink-0" /> : null}
|
||
{workspaceTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||
{utilityTools.length || moreTools.length ? <span aria-hidden className="aceternity-dock-separator mx-1 h-5 w-px shrink-0" /> : null}
|
||
{utilityTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} iconOnly />)}
|
||
{moreTools.length ? (
|
||
<NodeDockMenuButton menuId="more" label="更多" icon={<Ellipsis className="size-3.5" />} tools={moreTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} placement="topRight" iconOnly />
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NodeDockToolButton({ tool, iconOnly = false }: { tool: ToolbarTool; iconOnly?: boolean }) {
|
||
return (
|
||
<Tooltip title={tool.description ? `${tool.label}:${tool.description}` : tool.label}>
|
||
<button
|
||
type="button"
|
||
className={`aceternity-dock-command is-labeled pointer-events-auto inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2.5 outline-none ${tool.active ? "is-active" : ""} ${tool.danger ? "is-danger" : ""}`}
|
||
aria-label={tool.label}
|
||
aria-pressed={tool.active}
|
||
disabled={tool.disabled}
|
||
onClick={tool.onClick}
|
||
>
|
||
<span className="grid size-3.5 shrink-0 place-items-center">{tool.icon}</span>
|
||
{!iconOnly ? <span className="inline-flex h-4 items-center whitespace-nowrap text-[var(--fs-label)] font-medium leading-none">{tool.label}</span> : null}
|
||
</button>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
|
||
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<HTMLButtonElement>(null);
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const frame = requestAnimationFrame(() => triggerRef.current?.focus());
|
||
return () => cancelAnimationFrame(frame);
|
||
}, [open]);
|
||
const sections = new Map<string, ToolbarTool[]>();
|
||
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: <div><span className="inline-flex items-center gap-2">{tool.label}{tool.active ? <Check className="size-3.5" /> : null}</span>{tool.description ? <div className="text-[var(--fs-tiny)] opacity-60">{tool.description}</div> : null}</div>, disabled: tool.disabled, danger: tool.danger, onClick: () => { onOpenChange(menuId, false); tool.onClick(); } })),
|
||
}));
|
||
return (
|
||
<Dropdown open={open} trigger={["click"]} placement={placement} onOpenChange={(nextOpen) => onOpenChange(menuId, nextOpen)} menu={{ items }} autoFocus popupRender={(menu) => <div className="canvas-node-toolbar-menu" data-canvas-no-zoom data-canvas-wheel-scroll onPointerDown={(event) => 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}</div>}>
|
||
<button
|
||
ref={triggerRef}
|
||
type="button"
|
||
className={`aceternity-dock-command is-labeled pointer-events-auto inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2.5 outline-none ${open ? "is-active" : ""}`}
|
||
aria-label={label}
|
||
aria-expanded={open}
|
||
aria-haspopup="menu"
|
||
title={label}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "ArrowDown" && open) {
|
||
event.preventDefault();
|
||
document.querySelector<HTMLElement>(".ant-dropdown:not(.ant-dropdown-hidden) .canvas-node-toolbar-menu [role='menuitem']:not([aria-disabled='true'])")?.focus();
|
||
}
|
||
if (event.key === "Escape" && open) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
onOpenChange(menuId, false);
|
||
}
|
||
}}
|
||
>
|
||
<span className="grid size-3.5 shrink-0 place-items-center">{icon}</span>
|
||
{!iconOnly ? <><span className="inline-flex h-4 items-center whitespace-nowrap text-[var(--fs-label)] font-medium leading-none">{label}</span><ChevronDown className="size-3 shrink-0 opacity-55" /></> : null}
|
||
</button>
|
||
</Dropdown>
|
||
);
|
||
}
|
||
|
||
export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, readOnly = false, onUnauthorized }: { node: CanvasNodeData | null; open: boolean; onClose: () => void; onMetadataChange?: (nodeId: string, metadata: Partial<CanvasNodeMetadata>) => void; readOnly?: boolean; onUnauthorized?: () => void }) {
|
||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||
const [assetTags, setAssetTags] = useState<string[]>([]);
|
||
const [assetTagInput, setAssetTagInput] = useState("");
|
||
const [assetCategory, setAssetCategory] = useState<CanvasAssetCategory>("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 = (
|
||
<div className="canvas-node-inspector-title">
|
||
<div className="min-w-0">
|
||
<div className="text-[var(--fs-heading-lg)] font-semibold">节点信息</div>
|
||
{node ? <div className="canvas-node-inspector-id">{node.id}</div> : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<Modal
|
||
className="workspace-modal canvas-node-info-modal"
|
||
title={title}
|
||
open={open && Boolean(node)}
|
||
centered
|
||
footer={null}
|
||
onCancel={onClose}
|
||
width="min(920px, calc(100vw - 32px))"
|
||
styles={{ body: { paddingTop: 4 } }}
|
||
>
|
||
{node ? (
|
||
<div className="canvas-node-inspector" style={{ color: theme.node.text }}>
|
||
<div className="thin-scrollbar canvas-node-inspector-scroll">
|
||
<section className="canvas-node-inspector-section">
|
||
<div className="canvas-node-inspector-section-heading"><span>基础信息</span><em>{node.metadata?.status || "idle"}</em></div>
|
||
<div className="canvas-node-inspector-facts">
|
||
<InfoRow label="类型" value={nodeTypeLabel} />
|
||
<InfoRow label="尺寸" value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
|
||
<InfoRow label="位置" value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
|
||
{batchCount > 1 ? <InfoRow label="图片组" value={`${batchCount} 张`} /> : null}
|
||
{imageBytes ? <InfoRow label="图片大小" value={formatBytes(imageBytes)} /> : null}
|
||
</div>
|
||
</section>
|
||
|
||
{node.type === CanvasNodeType.Image ? (
|
||
<section className="canvas-node-inspector-section">
|
||
<div className="canvas-node-inspector-section-heading"><span>项目资产分类</span></div>
|
||
<div className="canvas-node-inspector-options">
|
||
{assetCategoryOptions.map((option) => {
|
||
const active = assetCategory === option.value;
|
||
return <button key={option.value} type="button" disabled={readOnly} aria-pressed={active} onClick={() => saveAssetCategory(option.value)} className={active ? "is-active" : ""}>{option.label}</button>;
|
||
})}
|
||
</div>
|
||
<p className="canvas-node-inspector-help">生成后会按此分类进入项目资产;角色、场景和画风工作流会自动预填。</p>
|
||
</section>
|
||
) : null}
|
||
|
||
{node.metadata?.prompt ? (
|
||
<section className="canvas-node-inspector-section">
|
||
<div className="canvas-node-inspector-section-heading"><span>提示词</span></div>
|
||
<div className="canvas-node-inspector-copy canvas-node-inspector-prompt">{node.metadata.prompt}</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{nodeGenerationRows(node).length ? (
|
||
<section className="canvas-node-inspector-section">
|
||
<div className="canvas-node-inspector-section-heading"><span>生成信息</span></div>
|
||
<div className="canvas-node-inspector-facts">
|
||
{nodeGenerationRows(node).map((item) => <InfoRow key={item.label} label={item.label} value={item.value} />)}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{node.type === CanvasNodeType.Skill && node.metadata?.skillSnapshot ? (
|
||
<section className="canvas-node-inspector-section">
|
||
<div className="canvas-node-inspector-section-heading"><span>技能模板</span></div>
|
||
<div className="canvas-node-inspector-copy">{node.metadata.skillSnapshot.template}</div>
|
||
{node.metadata.skillSnapshot.outputContract ? <><div className="canvas-node-inspector-subheading">输出约束</div><div className="canvas-node-inspector-copy">{node.metadata.skillSnapshot.outputContract}</div></> : null}
|
||
</section>
|
||
) : null}
|
||
|
||
{node.type === CanvasNodeType.Image ? (
|
||
<section className="canvas-node-inspector-section">
|
||
<div className="canvas-node-inspector-section-heading">
|
||
<div>
|
||
<span>资产标签</span>
|
||
<p>一条标签描述一个角色、环境、道具或镜头用途。</p>
|
||
</div>
|
||
<em>{assetTags.length} 条</em>
|
||
</div>
|
||
{readOnly ? (
|
||
<div className="canvas-node-inspector-notice">分享画布为只读,标签无法编辑。</div>
|
||
) : (
|
||
<div className="canvas-node-inspector-tag-editor">
|
||
<Input
|
||
value={assetTagInput}
|
||
placeholder="例如:角色: 张三"
|
||
onChange={(event) => setAssetTagInput(event.target.value)}
|
||
onPressEnter={addAssetTag}
|
||
/>
|
||
<Button type="primary" icon={<Plus className="size-4" />} disabled={!assetTagInput.trim()} onClick={addAssetTag}>
|
||
加入
|
||
</Button>
|
||
</div>
|
||
)}
|
||
<div className="canvas-node-inspector-tags">
|
||
{assetTags.length ? (
|
||
assetTags.map((tag) => (
|
||
<Tag key={tag} closable={!readOnly} onClose={() => (readOnly ? onUnauthorized?.() : removeAssetTag(tag))} className="!m-0 !rounded-lg !px-2 !py-1 !text-sm">
|
||
{tag}
|
||
</Tag>
|
||
))
|
||
) : (
|
||
<span className="canvas-node-inspector-empty-label">{readOnly ? "暂无标签" : "还没有标签,输入后点击“加入”或按 Enter。"}</span>
|
||
)}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{node.metadata?.errorDetails ? (
|
||
<section className="canvas-node-inspector-error">
|
||
{generationErrorMessage(node.metadata.errorDetails)}
|
||
</section>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function InfoRow({ label, value }: { label: string; value: ReactNode }) {
|
||
return (
|
||
<div className="canvas-node-inspector-fact">
|
||
<div>{label}</div>
|
||
<strong>{value}</strong>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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;
|
||
}
|