refactor(canvas): 节点注册表 - 收敛节点展示与几何知识到单一数据源

每加一种画布节点,叫什么、长什么样、能不能自由拉伸要在 4-5 处各写一遍。
实际后果是节点标签出现三份且互相矛盾(NODE_DEFAULT_SIZE.title / 创建菜单 label /
搜索弹窗 nodeTypeLabel),canvas-node-size.ts 里还有第四份硬编码兜底。

新增 lib/canvas/node-registry/,镜像现有 tool-registry/ 的结构:
静态定义带 satisfies Record<CanvasNodeType, ...> 做编译期穷尽性检查
(往枚举加成员不补定义即编译失败),再注册进模块级 Map 并按 ownerId 归属,
为后续扩展留出隔离位。

尺寸与初始 metadata 的数据源仍是 constant/canvas.ts 的 NODE_SPECS,定义文件读它
而非复制,现有约 40 处布局计算未动;渲染层 nodeContentRenderers 已是查表结构,
本次不改。

标签刻意分三个字段各司其职,界面文案零变化:
- defaultTitle 新建节点默认名(Note/Audio/未命名背板 原样保留)
- label UI 短标签
- listLabel 列表标签,缺省派生为 label + 节点;背板显式钉住为背板

接入 3 处调用点:创建菜单标签与图标、搜索弹窗类型标签、canvas-node.tsx 的
minWidth/minHeight 与 keepRatio。导演台落在节点分区但开的是工作台、不是节点类型,
故不走注册表。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dlh
2026-08-20 22:22:28 +08:00
parent 7a817d6847
commit af84cf5772
8 changed files with 232 additions and 29 deletions

View File

@@ -3,6 +3,7 @@ import { Input, Modal } from "antd";
import { BookOpenText, FileText, Image, Pencil, Search, Video } from "lucide-react";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { getNodeListLabel } from "@/lib/canvas/node-registry";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
export function CanvasNodeSearchModal({ open, nodes, onClose, onFocus }: { open: boolean; nodes: CanvasNodeData[]; onClose: () => void; onFocus: (nodeId: string) => void }) {
@@ -10,7 +11,7 @@ export function CanvasNodeSearchModal({ open, nodes, onClose, onFocus }: { open:
const results = useMemo(() => {
const keyword = query.trim().toLocaleLowerCase();
if (!keyword) return nodes.slice(0, 40);
return nodes.filter((node) => [node.title, node.type, nodeTypeLabel(node.type), node.metadata?.prompt, node.metadata?.composerContent, node.metadata?.model, node.metadata?.chapterTitle, node.metadata?.workflowTitle, node.metadata?.workflowDescription, typeof node.metadata?.shotIndex === "number" ? `镜头 ${node.metadata.shotIndex + 1}` : "", ...(node.metadata?.assetTags || [])]
return nodes.filter((node) => [node.title, node.type, getNodeListLabel(node.type), node.metadata?.prompt, node.metadata?.composerContent, node.metadata?.model, node.metadata?.chapterTitle, node.metadata?.workflowTitle, node.metadata?.workflowDescription, typeof node.metadata?.shotIndex === "number" ? `镜头 ${node.metadata.shotIndex + 1}` : "", ...(node.metadata?.assetTags || [])]
.some((value) => typeof value === "string" && value.toLocaleLowerCase().includes(keyword))).slice(0, 80);
}, [nodes, query]);
@@ -44,19 +45,5 @@ export function CanvasNodeSearchModal({ open, nodes, onClose, onFocus }: { open:
function nodeContextLabel(node: CanvasNodeData) {
const location = [node.metadata?.chapterTitle, typeof node.metadata?.shotIndex === "number" ? `镜头 ${node.metadata.shotIndex + 1}` : ""].filter(Boolean).join(" · ");
return location || node.metadata?.prompt || node.metadata?.composerContent || node.metadata?.workflowDescription || nodeTypeLabel(node.type);
}
function nodeTypeLabel(type: CanvasNodeType) {
return ({
[CanvasNodeType.Image]: "图片节点",
[CanvasNodeType.Text]: "文本节点",
[CanvasNodeType.Drawing]: "绘图节点",
[CanvasNodeType.Script]: "分镜脚本节点",
[CanvasNodeType.Skill]: "技能节点",
[CanvasNodeType.Config]: "生成配置节点",
[CanvasNodeType.Video]: "视频节点",
[CanvasNodeType.Audio]: "音频节点",
[CanvasNodeType.Frame]: "背板",
} as Record<CanvasNodeType, string>)[type] || "未知节点";
return location || node.metadata?.prompt || node.metadata?.composerContent || node.metadata?.workflowDescription || getNodeListLabel(node.type);
}

View File

@@ -10,7 +10,7 @@ import { useThemeStore } from "@/stores/use-theme-store";
import { storyboardMinNodeHeight } from "./canvas-script-node";
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size";
import { getNodeMinSize, shouldKeepAspectRatio } from "@/lib/canvas/node-registry";
import { CanvasNodeContent, CanvasNodeImageInfo } from "./canvas-node-content";
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
@@ -176,9 +176,10 @@ export const CanvasNode = React.memo(function CanvasNode({
const dx = (event.clientX - resizeRef.current.startX) / scale;
const dy = (event.clientY - resizeRef.current.startY) / scale;
const isMediaNode = data.type === CanvasNodeType.Image || data.type === CanvasNodeType.Video;
const minWidth = data.type === CanvasNodeType.Script ? 800 : isMediaNode ? MEDIA_NODE_MIN_SIZE.width : 220;
const minHeight = scriptMinHeight || (isMediaNode ? MEDIA_NODE_MIN_SIZE.height : 160);
const minSize = getNodeMinSize(data.type);
const minWidth = minSize.width;
// 分镜脚本的高度由表格内容动态撑开,覆盖注册表里的静态下限。
const minHeight = scriptMinHeight || minSize.height;
const startRight = resizeRef.current.startLeft + resizeRef.current.startWidth;
const startBottom = resizeRef.current.startTop + resizeRef.current.startHeight;
const fromLeft = resizeRef.current.corner.includes("left");
@@ -230,7 +231,7 @@ export const CanvasNode = React.memo(function CanvasNode({
startTop: data.position.y,
startWidth: data.width,
startHeight: data.height,
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video,
keepRatio: shouldKeepAspectRatio(data),
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
};
window.addEventListener("mousemove", handleResizeMove);

View File

@@ -0,0 +1,97 @@
import { Clapperboard, Image as ImageIcon, Music2, PanelTop, Pencil, Settings2, Sparkles, Type, Video } from "lucide-react";
import { NODE_SPECS } from "@/constant/canvas";
import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import type { CanvasNodeDefinition } from "../node-definition";
import { registerNodeDefinitions } from "../node-registry";
/** 非媒体节点的拉伸下限 */
const DEFAULT_MIN_SIZE = { width: 220, height: 160 } as const;
/**
* 每种内置节点的自有特征。
*
* 尺寸、默认标题与初始 metadata 不在这里重复——它们的数据源是 constant/canvas.ts 的
* NODE_SPECS,下方装配时统一读取。
*
* `satisfies Record<CanvasNodeType, …>` 是刻意的:往 CanvasNodeType 加成员后若不补定义,
* 这里会编译失败,而不是在运行期悄悄回落成兜底值。
*/
const BUILTIN_NODE_TRAITS = {
[CanvasNodeType.Image]: {
label: "图片",
icon: <ImageIcon />,
minSize: MEDIA_NODE_MIN_SIZE,
keepAspectRatio: (node: CanvasNodeData) => !node.metadata?.freeResize,
showInCreateMenu: true,
},
[CanvasNodeType.Text]: {
label: "文本",
icon: <Type />,
minSize: DEFAULT_MIN_SIZE,
showInCreateMenu: true,
},
[CanvasNodeType.Drawing]: {
label: "绘图",
icon: <Pencil />,
minSize: DEFAULT_MIN_SIZE,
showInCreateMenu: true,
},
[CanvasNodeType.Script]: {
label: "分镜脚本",
icon: <Clapperboard />,
// 分镜脚本的表格布局需要更宽的下限;高度仍由内容动态撑开(见 canvas-node.tsx)。
minSize: { width: 800, height: DEFAULT_MIN_SIZE.height },
showInCreateMenu: true,
},
[CanvasNodeType.Skill]: {
label: "技能",
icon: <Sparkles />,
minSize: DEFAULT_MIN_SIZE,
// 技能节点由技能库插入,不占创建菜单格位。
showInCreateMenu: false,
},
[CanvasNodeType.Config]: {
label: "生成配置",
icon: <Settings2 />,
minSize: DEFAULT_MIN_SIZE,
// 生成配置由连线时自动创建。
showInCreateMenu: false,
},
[CanvasNodeType.Video]: {
label: "视频",
icon: <Video />,
minSize: MEDIA_NODE_MIN_SIZE,
keepAspectRatio: () => true,
showInCreateMenu: true,
},
[CanvasNodeType.Audio]: {
label: "音频",
icon: <Music2 />,
minSize: DEFAULT_MIN_SIZE,
showInCreateMenu: true,
},
[CanvasNodeType.Frame]: {
label: "背板",
// 背板在列表里就叫「背板」,不是「背板节点」——显式钉住,避免被 label 派生改写。
listLabel: "背板",
icon: <PanelTop />,
minSize: DEFAULT_MIN_SIZE,
showInCreateMenu: true,
},
} satisfies Record<CanvasNodeType, Omit<CanvasNodeDefinition, "type" | "defaultTitle" | "defaultSize" | "defaultMetadata">>;
export const BUILTIN_NODE_DEFINITIONS: CanvasNodeDefinition[] = (Object.keys(BUILTIN_NODE_TRAITS) as CanvasNodeType[]).map((type) => {
const spec = NODE_SPECS[type];
return {
type,
...BUILTIN_NODE_TRAITS[type],
defaultTitle: spec.title,
defaultSize: { width: spec.width, height: spec.height },
defaultMetadata: spec.metadata,
};
});
registerNodeDefinitions(BUILTIN_NODE_DEFINITIONS);

View File

@@ -0,0 +1,4 @@
/**
* 导入此模块会触发所有内置节点定义的注册。
*/
import "./builtin-nodes";

View File

@@ -0,0 +1,3 @@
export type { CanvasNodeDefinition } from "./node-definition";
export { getNodeDefinition, getNodeIcon, getNodeLabel, getNodeListLabel, getNodeMinSize, getNodeOwnerId, listCreatableNodeDefinitions, listNodeDefinitions, registerNodeDefinitions, shouldKeepAspectRatio, unregisterNodeDefinitions } from "./node-registry";
import "./definitions";

View File

@@ -0,0 +1,29 @@
import type { ReactNode } from "react";
import type { CanvasNodeData, CanvasNodeMetadata, CanvasNodeType } from "@/types/canvas";
/**
* 节点定义——注册表的基本单元。
*
* 一种画布节点类型的静态知识(叫什么、长什么样、能拉多小、是否锁比例)集中在这里,
* 避免同一份知识散落到创建菜单、搜索弹窗、拉伸逻辑等多处后各自漂移。
*/
export type CanvasNodeDefinition = {
type: CanvasNodeType;
/** UI 短标签——创建菜单等处显示,如「文本」 */
label: string;
/** 列表/搜索标签,缺省派生为 `${label}节点` */
listLabel?: string;
/** 不带 className——由渲染处用 [&_svg]:size-* 统一控制尺寸 */
icon: ReactNode;
/** 新建节点的默认标题(与 label 分开:菜单叫「文本」,新建出来的节点名是「Note」) */
defaultTitle: string;
defaultSize: { width: number; height: number };
defaultMetadata?: CanvasNodeMetadata;
/** 手动拉伸的最小尺寸 */
minSize: { width: number; height: number };
/** 拉伸时是否锁定宽高比,缺省不锁 */
keepAspectRatio?: (node: CanvasNodeData) => boolean;
/** 是否出现在添加节点菜单(技能、生成配置由其他入口创建) */
showInCreateMenu: boolean;
};

View File

@@ -0,0 +1,74 @@
import type { CanvasNodeData, CanvasNodeType } from "@/types/canvas";
import type { CanvasNodeDefinition } from "./node-definition";
/** 模块级注册表 */
const definitions = new Map<CanvasNodeType, CanvasNodeDefinition>();
/** 节点类型 → 归属方。内置节点归属 "builtin",为后续扩展留出隔离位。 */
const ownerByType = new Map<CanvasNodeType, string>();
/** 未注册类型的兜底最小尺寸——与非媒体节点的历史下限一致 */
const FALLBACK_MIN_SIZE = { width: 220, height: 160 } as const;
/** 批量注册节点定义 */
export function registerNodeDefinitions(defs: CanvasNodeDefinition[], ownerId = "builtin") {
for (const def of defs) {
definitions.set(def.type, def);
ownerByType.set(def.type, ownerId);
}
}
/**
* 注销某归属方的全部节点定义。
* 只删归属该 ownerId 的条目——内置节点不会被其他归属方的增删波及。
*/
export function unregisterNodeDefinitions(ownerId: string) {
for (const [type, owner] of ownerByType) {
if (owner !== ownerId) continue;
definitions.delete(type);
ownerByType.delete(type);
}
}
export function getNodeDefinition(type: CanvasNodeType) {
return definitions.get(type);
}
export function getNodeOwnerId(type: CanvasNodeType) {
return ownerByType.get(type) || "builtin";
}
export function listNodeDefinitions() {
return [...definitions.values()];
}
/** 添加节点菜单可见的节点定义 */
export function listCreatableNodeDefinitions() {
return listNodeDefinitions().filter((def) => def.showInCreateMenu);
}
/** UI 短标签 */
export function getNodeLabel(type: CanvasNodeType) {
return definitions.get(type)?.label || "未知节点";
}
/** 列表/搜索标签,缺省派生自 label */
export function getNodeListLabel(type: CanvasNodeType) {
const def = definitions.get(type);
if (!def) return "未知节点";
return def.listLabel || `${def.label}节点`;
}
export function getNodeIcon(type: CanvasNodeType) {
return definitions.get(type)?.icon ?? null;
}
/** 手动拉伸的最小尺寸 */
export function getNodeMinSize(type: CanvasNodeType) {
return definitions.get(type)?.minSize ?? FALLBACK_MIN_SIZE;
}
/** 拉伸时是否锁定宽高比 */
export function shouldKeepAspectRatio(node: CanvasNodeData) {
return definitions.get(node.type)?.keepAspectRatio?.(node) ?? false;
}

View File

@@ -1,19 +1,27 @@
import { Clapperboard, FolderOpen, Image as ImageIcon, Layers3, Music2, Palette, PanelTop, Pencil, Type, UploadCloud, UserRound, Video } from "lucide-react";
import { FolderOpen, Layers3, Palette, UploadCloud, UserRound } from "lucide-react";
import { getNodeIcon, getNodeLabel } from "@/lib/canvas/node-registry";
import { registerAddNodeMenuCommands, type AddNodeMenuCommand } from "@/lib/canvas/tool-registry";
import { CanvasNodeType } from "@/types/canvas";
/** 真正创建节点的命令,文案与图标统一取自节点注册表。 */
function nodeCommand(type: CanvasNodeType, rest: Omit<AddNodeMenuCommand, "id" | "label" | "icon" | "section">): AddNodeMenuCommand {
return { id: type, label: getNodeLabel(type), icon: getNodeIcon(type), section: "node", ...rest };
}
export const addNodeMenuCommands: AddNodeMenuCommand[] = [
// 项目级动作不占用节点网格,保证 8 个节点稳定保持四列两行。
{ id: "style", label: "项目画风", icon: <Palette />, section: "project", defaultOrder: 10, applicable: (ctx) => !ctx.isProjectLinked, run: (ctx) => ctx.handlers.onChooseStyle() },
// 创作节点
{ id: "text", label: "文本", icon: <Type />, section: "node", defaultOrder: 10, run: (ctx) => ctx.handlers.onAddText() },
{ id: "drawing", label: "绘图", icon: <Pencil />, section: "node", defaultOrder: 20, run: (ctx) => ctx.handlers.onAddDrawing() },
{ id: "script", label: "分镜脚本", icon: <Clapperboard />, badge: "核心", section: "node", defaultOrder: 30, run: (ctx) => ctx.handlers.onAddScript() },
{ id: "frame", label: "背板", icon: <PanelTop />, section: "node", defaultOrder: 40, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddFrame() },
{ id: "image", label: "图片", icon: <ImageIcon />, section: "node", defaultOrder: 50, run: (ctx) => ctx.handlers.onAddImage() },
{ id: "video", label: "视频", icon: <Video />, section: "node", defaultOrder: 60, run: (ctx) => ctx.handlers.onAddVideo() },
nodeCommand(CanvasNodeType.Text, { defaultOrder: 10, run: (ctx) => ctx.handlers.onAddText() }),
nodeCommand(CanvasNodeType.Drawing, { defaultOrder: 20, run: (ctx) => ctx.handlers.onAddDrawing() }),
nodeCommand(CanvasNodeType.Script, { badge: "核心", defaultOrder: 30, run: (ctx) => ctx.handlers.onAddScript() }),
nodeCommand(CanvasNodeType.Frame, { defaultOrder: 40, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddFrame() }),
nodeCommand(CanvasNodeType.Image, { defaultOrder: 50, run: (ctx) => ctx.handlers.onAddImage() }),
nodeCommand(CanvasNodeType.Video, { defaultOrder: 60, run: (ctx) => ctx.handlers.onAddVideo() }),
// 导演台落在节点分区,但它开的是导演工作台、不是某种画布节点,故不走注册表。
{ id: "director", label: "导演台", icon: <Layers3 />, badge: "3D", section: "node", defaultOrder: 70, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onOpenDirector() },
{ id: "audio", label: "音频", icon: <Music2 />, section: "node", defaultOrder: 80, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddAudio() },
nodeCommand(CanvasNodeType.Audio, { defaultOrder: 80, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddAudio() }),
// 导入资源
{ id: "upload", label: "上传文件", icon: <UploadCloud />, section: "resource", defaultOrder: 10, run: (ctx) => ctx.handlers.onUpload() },
{ id: "project-character", label: "添加角色卡", icon: <UserRound />, section: "resource", defaultOrder: 20, applicable: (ctx) => ctx.isProjectLinked, run: (ctx) => ctx.handlers.onOpenProjectCharacters() },