在已落地的节点注册表上加两层节点:展示层(Markdown / SVG / HTML / 全景)与 接入生成链的加工层(对比 / 图表 / 调色)。全部是第一方内置节点,不引入远程插件 加载,因此不涉及沙箱信任模型,也不需要把 CanvasNodeType 放开成任意字符串。 刻意保留穷尽性检查而不是改成运行时注册。全库只有 4 张按类型建的穷眉表 (NODE_DEFAULT_SIZE / NODE_SPECS / 注册表定义 / nodeContentRenderers), 让编译器强制每个新节点填满它们,比少改几个文件更值 —— 加第一个枚举成员时 编译器点出了 5 处,其中 pages/canvas/shared.tsx 是原先没编目的那一处; 换成运行时注册,那里会静默回落成「未知节点」。 两处前置基建: - CanvasNodeGraphContext:渲染器只拿到 node、没有 connections,而加工层三个节点 都要读上游。仿已有的 canvas-node-action-context(同一个问题:节点经 CanvasProjectWorldLayers 渲染不便逐个透传),取上游复用 canvas-resource-references 的 getContextResourceNodes,不另写一份。 Provider 值必须 memo —— 每帧新对象会让所有节点跟着重渲染。 - ToolbarHandlers.onAddExtensionNode(type):一个泛用入口,不给每种扩展节点单开 一个 onAddXxx。菜单另开「展示与加工」分区,不挤占上方调好的四列创作网格。 SVG 与 HTML 统一走 sandbox iframe:依赖里没有 DOMPurify,手写 SVG 消毒不可靠 (script / on* / foreignObject)。SandboxedFrame 只暴露 allowScripts 布尔而不是 sandbox 取值,让「同时给 allow-same-origin」这条没有入口。 全景节点三个取舍:点击才激活 WebGL(浏览器对同时存在的上下文有硬上限,画布可以有 任意多个全景节点,自动激活必然在某个数量后整片黑);用原生 three 而非 fiber (需要精确 dispose,forceContextLoss 才真正释放上下文);性能模式下只给静态预览。 调色是唯一碰生成路径的节点,照 Drawing 的先例:拖滑杆只做本地 CSS filter 预览, 落地成资源要等到生成消费它时才走 uploadResourceFile。预览与导出共用同一个 filter 字符串,两处各写一份就会「看到的和用的悄悄不一致」;未调色时直接透传源图, 省掉一次渲染与上传。ctx.filter 不受支持时明确报错而不是导出一张没调色的图。 resourceKind 与 inputKind 分开两个字段:空图片节点不是素材(不进 @ 引用), 但照样计入 imageCount。调色节点的 resourceKind 无条件返回 image —— 它的图来自 上游,按「有没有 content」判断会让它永远进不了生成输入,这是不报错的静默失效。 测试:新增 test/canvas-extension-nodes.test.ts(12 用例 / 64 断言),覆盖 7 个节点的 判据矩阵、调色参数、以及图表解析。图表解析函数刻意不抛 —— 数据来自模型输出, 非法 JSON 是常态而不是异常,抛出来会连带整个画布白屏,有 7 条断言钉这个。 测试列表是显式枚举,已把新文件加进 package.json,不加进去不会跑。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
108 lines
6.1 KiB
TypeScript
108 lines
6.1 KiB
TypeScript
import { motion, useReducedMotion } from "motion/react";
|
|
import type { CSSProperties, ReactNode } from "react";
|
|
|
|
import { aceternityMotion } from "@/lib/aceternity-motion";
|
|
import { canvasThemes } from "@/lib/canvas-theme";
|
|
import { cn } from "@/lib/utils";
|
|
import { useThemeStore } from "@/stores/use-theme-store";
|
|
|
|
export type CanvasCreateCommand = {
|
|
id: string;
|
|
label: string;
|
|
icon: ReactNode;
|
|
badge?: string;
|
|
section: "node" | "extension" | "project" | "resource";
|
|
onClick: () => void;
|
|
};
|
|
|
|
export function CanvasCreateMenu({ commands }: { commands: CanvasCreateCommand[] }) {
|
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
|
const projectCommands = commands.filter((command) => command.section === "project");
|
|
const nodeCommands = commands.filter((command) => command.section === "node");
|
|
const extensionCommands = commands.filter((command) => command.section === "extension");
|
|
const resourceCommands = commands.filter((command) => command.section === "resource");
|
|
|
|
return (
|
|
<div>
|
|
<header className="flex min-h-7 items-center justify-between gap-2 border-b pb-2" style={{ borderColor: theme.toolbar.border }}>
|
|
<h2 className="font-semibold leading-none" style={{ fontSize: "var(--fs-caption)" }}>添加节点</h2>
|
|
{projectCommands.map((command) => (
|
|
<button
|
|
key={command.id}
|
|
type="button"
|
|
className="inline-flex h-6 min-w-0 items-center gap-1 rounded-[var(--dock-item-radius)] px-1.5 font-medium outline-none transition-colors hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/8 [&_svg]:size-3"
|
|
style={{ color: theme.node.muted, fontSize: "var(--fs-tiny)", "--tw-ring-color": theme.node.muted } as CSSProperties}
|
|
title={command.label}
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
onClick={command.onClick}
|
|
>
|
|
{command.icon}
|
|
<span className="whitespace-nowrap">{command.label}</span>
|
|
</button>
|
|
))}
|
|
</header>
|
|
|
|
<MenuSection title="创作节点" color={theme.node.muted} />
|
|
<CanvasCreateCommandGrid commands={nodeCommands} variant="node" />
|
|
|
|
{extensionCommands.length ? (
|
|
<>
|
|
<MenuSection title="展示与加工" color={theme.node.muted} spaced />
|
|
<CanvasCreateCommandGrid commands={extensionCommands} variant="node" />
|
|
</>
|
|
) : null}
|
|
|
|
<MenuSection title="导入资源" color={theme.node.muted} spaced />
|
|
<CanvasCreateCommandGrid commands={resourceCommands} variant="resource" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CanvasCreateCommandGrid({ commands, variant }: { commands: CanvasCreateCommand[]; variant: "node" | "resource" }) {
|
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
|
const reducedMotion = useReducedMotion();
|
|
|
|
return (
|
|
<div className={cn("grid gap-1", variant === "node" ? "grid-cols-4" : "grid-cols-2")}>
|
|
{commands.map((command) => (
|
|
<motion.button
|
|
key={command.id}
|
|
type="button"
|
|
whileHover={reducedMotion ? undefined : { y: -1 }}
|
|
whileTap={reducedMotion ? undefined : { scale: 0.98 }}
|
|
transition={aceternityMotion.spring.dock}
|
|
className={cn(
|
|
"group min-w-0 overflow-hidden border border-black/10 bg-white/70 outline-none transition-colors hover:border-black/20 hover:bg-black/5 focus-visible:ring-2 dark:border-white/10 dark:bg-white/[.04] dark:hover:border-white/20 dark:hover:bg-white/8",
|
|
variant === "node"
|
|
? "flex h-[var(--canvas-create-node-height)] flex-col items-start justify-between rounded-[var(--dock-item-radius)] px-1 py-1.5 text-left"
|
|
: "flex h-[var(--canvas-create-resource-height)] items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2 text-center",
|
|
)}
|
|
style={{ color: theme.node.text, "--tw-ring-color": theme.node.muted } as CSSProperties}
|
|
title={command.label}
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
onClick={command.onClick}
|
|
>
|
|
{variant === "node" ? (
|
|
<>
|
|
<span className="flex w-full min-w-0 items-center justify-between gap-1">
|
|
<span className="grid size-4 shrink-0 place-items-center opacity-65 transition-opacity group-hover:opacity-100 [&_svg]:size-4">{command.icon}</span>
|
|
{command.badge ? <span className="shrink-0 font-medium leading-none" style={{ color: theme.node.muted, fontSize: "var(--fs-tiny)" }}>{command.badge}</span> : null}
|
|
</span>
|
|
<span className="block w-full overflow-hidden text-ellipsis whitespace-nowrap font-medium leading-none" style={{ fontSize: "var(--fs-label)" }}>{command.label}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="grid size-4 shrink-0 place-items-center opacity-65 transition-opacity group-hover:opacity-100 [&_svg]:size-3.5">{command.icon}</span>
|
|
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-medium leading-none" style={{ fontSize: "var(--fs-label)" }}>{command.label}</span>
|
|
</>
|
|
)}
|
|
</motion.button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MenuSection({ title, color, spaced = false }: { title: string; color: string; spaced?: boolean }) {
|
|
return <h3 className="mb-1 mt-2 px-1 font-medium leading-none" style={{ color, fontSize: "var(--fs-tiny)", marginTop: spaced ? "var(--space-4)" : "var(--space-2)" }}>{title}</h3>;
|
|
}
|