在已落地的节点注册表上加两层节点:展示层(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>
73 lines
3.5 KiB
TypeScript
73 lines
3.5 KiB
TypeScript
import { useRef, useState } from "react";
|
||
import { Columns2 } from "lucide-react";
|
||
|
||
import { useUpstreamNodes } from "@/components/canvas/canvas-node-graph-context";
|
||
import { getNodeResourceKind } from "@/lib/canvas/node-registry";
|
||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||
import type { CanvasNodeData } from "@/types/canvas";
|
||
|
||
type CompareNodeContentProps = {
|
||
node: CanvasNodeData;
|
||
theme: CanvasTheme;
|
||
};
|
||
|
||
/**
|
||
* A/B 对比节点:吃两个图片上游,中间一根滑杆左右拖动对比。
|
||
*
|
||
* 分割位置只存组件内 state,不落 metadata——它是「看的时候临时挪一下」,
|
||
* 不是画布内容;CanvasNodeContentProps 也只给了 onContentChange(content),
|
||
* 为它另开一条写元数据的通道不值得。
|
||
*/
|
||
export function CompareNodeContent({ node, theme }: CompareNodeContentProps) {
|
||
const upstream = useUpstreamNodes(node.id);
|
||
const images = upstream.filter((item) => getNodeResourceKind(item) === "image");
|
||
const [split, setSplit] = useState(50);
|
||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||
const draggingRef = useRef(false);
|
||
|
||
const before = images[0]?.metadata?.content || "";
|
||
const after = images[1]?.metadata?.content || "";
|
||
|
||
if (!before || !after) {
|
||
return (
|
||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.muted }}>
|
||
<Columns2 className="size-5 opacity-60" />
|
||
<span style={{ fontSize: "var(--fs-label)" }}>{images.length === 1 ? "再连一张图片即可对比" : "连接两张图片进行对比"}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const moveTo = (clientX: number) => {
|
||
const rect = hostRef.current?.getBoundingClientRect();
|
||
if (!rect?.width) return;
|
||
setSplit(Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100)));
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={hostRef}
|
||
className="relative h-full w-full select-none overflow-hidden"
|
||
data-canvas-no-zoom
|
||
style={{ background: theme.node.fill, cursor: "ew-resize" }}
|
||
onMouseDown={(event) => event.stopPropagation()}
|
||
onPointerDown={(event) => {
|
||
draggingRef.current = true;
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
moveTo(event.clientX);
|
||
}}
|
||
onPointerMove={(event) => { if (draggingRef.current) moveTo(event.clientX); }}
|
||
onPointerUp={(event) => {
|
||
draggingRef.current = false;
|
||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||
}}
|
||
>
|
||
<img src={after} alt="B" className="absolute inset-0 h-full w-full object-contain" draggable={false} />
|
||
{/* 上层按分割位置裁切,露出下层的 B */}
|
||
<img src={before} alt="A" className="absolute inset-0 h-full w-full object-contain" draggable={false} style={{ clipPath: `inset(0 ${100 - split}% 0 0)` }} />
|
||
<div className="pointer-events-none absolute inset-y-0 w-px bg-white/90 shadow-[0_0_0_1px_rgba(0,0,0,.35)]" style={{ left: `${split}%` }} />
|
||
<span className="pointer-events-none absolute left-2 top-2 rounded bg-black/55 px-1.5 py-0.5 text-white" style={{ fontSize: "var(--fs-tiny)" }}>A</span>
|
||
<span className="pointer-events-none absolute right-2 top-2 rounded bg-black/55 px-1.5 py-0.5 text-white" style={{ fontSize: "var(--fs-tiny)" }}>B</span>
|
||
</div>
|
||
);
|
||
}
|