每加一种画布节点,叫什么、长什么样、能不能自由拉伸要在 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>
50 lines
3.7 KiB
TypeScript
50 lines
3.7 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
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 }) {
|
|
const [query, setQuery] = useState("");
|
|
const results = useMemo(() => {
|
|
const keyword = query.trim().toLocaleLowerCase();
|
|
if (!keyword) return nodes.slice(0, 40);
|
|
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]);
|
|
|
|
return (
|
|
<Modal title="搜索画布节点" open={open} footer={null} width="min(680px, 90vw)" onCancel={onClose} afterClose={() => setQuery("")} centered>
|
|
<Input autoFocus allowClear value={query} onChange={(event) => setQuery(event.target.value)} prefix={<Search className="size-4 opacity-50" />} placeholder="搜索节点、章节、镜头、模型或标签" />
|
|
<div className="thin-scrollbar mt-3 max-h-[50vh] overflow-y-auto border-t pt-2">
|
|
{results.length ? results.map((node) => (
|
|
<button
|
|
key={node.id}
|
|
type="button"
|
|
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition hover:bg-black/5 dark:hover:bg-white/10"
|
|
onClick={() => { onFocus(node.id); onClose(); }}
|
|
>
|
|
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-black/5 dark:bg-white/10">
|
|
{node.type === CanvasNodeType.Image ? <Image className="size-4" /> : node.type === CanvasNodeType.Video ? <Video className="size-4" /> : node.type === CanvasNodeType.Drawing ? <Pencil className="size-4" /> : <FileText className="size-4" />}
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm font-medium">{node.title}</span>
|
|
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs opacity-55">
|
|
{node.metadata?.chapterTitle ? <BookOpenText className="size-3 shrink-0" /> : null}
|
|
<span className="truncate">{nodeContextLabel(node)}</span>
|
|
</span>
|
|
</span>
|
|
</button>
|
|
)) : <WorkspaceState icon="canvas" compact title="没有匹配节点" description="换一个节点、章节、镜头、模型或标签继续搜索。" />}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
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 || getNodeListLabel(node.type);
|
|
}
|