在已落地的节点注册表上加两层节点:展示层(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>
206 lines
9.8 KiB
TypeScript
206 lines
9.8 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { Globe, X } 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 PanoramaNodeContentProps = {
|
|
node: CanvasNodeData;
|
|
theme: CanvasTheme;
|
|
reduceMediaEffects?: boolean;
|
|
};
|
|
|
|
/**
|
|
* 360° 全景查看器节点:取上游图片,拖拽环视。
|
|
*
|
|
* 三个刻意的取舍:
|
|
* 1. **点击才激活 WebGL**。浏览器对同时存在的 WebGL 上下文有硬上限(十几个),
|
|
* 画布上可以有任意多个全景节点,自动激活必然在某个数量后整片黑。默认只显示平面预览,
|
|
* 与既有的 DeferredMediaLoad「点击加载」是同一套交互习惯。
|
|
* 2. **用原生 three 而不是 @react-three/fiber**。这里需要精确控制 dispose——
|
|
* 上下文/纹理/几何体漏一个就逼近上限,节点还会被频繁增删。
|
|
* 3. **性能模式下不提供激活入口**。错题本里多条崩溃出在画布高频渲染,
|
|
* reduceMediaEffects 时只给静态预览。
|
|
*/
|
|
export function PanoramaNodeContent({ node, theme, reduceMediaEffects }: PanoramaNodeContentProps) {
|
|
const upstream = useUpstreamNodes(node.id);
|
|
const inherited = upstream.find((item) => getNodeResourceKind(item) === "image");
|
|
const url = node.metadata?.content || inherited?.metadata?.content || "";
|
|
const [active, setActive] = useState(false);
|
|
const [failed, setFailed] = useState(false);
|
|
|
|
// 上游换图后退出环视,避免旧上下文继续持有已被替换的纹理。
|
|
useEffect(() => {
|
|
setActive(false);
|
|
setFailed(false);
|
|
}, [url]);
|
|
|
|
if (!url) {
|
|
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 }}>
|
|
<Globe className="size-5 opacity-60" />
|
|
<span style={{ fontSize: "var(--fs-label)" }}>连接一张 360° 等距柱状全景图</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!active) {
|
|
return (
|
|
<div className="relative h-full w-full overflow-hidden" style={{ background: theme.node.fill }}>
|
|
<img src={url} alt={node.title || "全景"} className="h-full w-full object-cover opacity-80" draggable={false} />
|
|
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 px-3 py-2" style={{ background: "linear-gradient(to top, rgba(0,0,0,.85), rgba(0,0,0,.35))", boxShadow: "0 -1px 0 rgba(0,0,0,.25)" }}>
|
|
<span className="min-w-0 truncate text-white" style={{ fontSize: "var(--fs-label)", textShadow: "0 1px 3px rgba(0,0,0,.85)" }}>
|
|
{failed ? "全景加载失败(图片可能不允许跨域读取)" : reduceMediaEffects ? "性能模式下仅显示预览" : "360° 全景"}
|
|
</span>
|
|
{reduceMediaEffects ? null : (
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded-[var(--r-md)] px-2 py-1 font-medium text-white outline-none transition-colors"
|
|
style={{ fontSize: "var(--fs-label)", background: "rgba(0,0,0,.78)", boxShadow: "0 1px 4px rgba(0,0,0,.5), inset 0 0 0 1px rgba(255,255,255,.32)" }}
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
onClick={() => { setFailed(false); setActive(true); }}
|
|
>
|
|
进入环视
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="relative h-full w-full overflow-hidden" style={{ background: "#000" }}>
|
|
<PanoramaViewport url={url} onError={() => { setFailed(true); setActive(false); }} />
|
|
<button
|
|
type="button"
|
|
aria-label="退出环视"
|
|
className="absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-black/70 text-white outline-none transition-colors hover:bg-black/90"
|
|
style={{ boxShadow: "0 1px 4px rgba(0,0,0,.5)" }}
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
onClick={() => setActive(false)}
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PanoramaViewport({ url, onError }: { url: string; onError: () => void }) {
|
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
// onError 是内联箭头函数,每次渲染都是新引用;直接放进 effect 依赖会让
|
|
// 父级任何重渲染(悬停/选中/画布状态)都销毁重建 WebGL 上下文 ——
|
|
// 表现就是环视画面闪动、视角回到初始位置。用 ref 稳定它。
|
|
const onErrorRef = useRef(onError);
|
|
onErrorRef.current = onError;
|
|
|
|
useEffect(() => {
|
|
const host = hostRef.current;
|
|
if (!host) return;
|
|
let disposed = false;
|
|
let frame = 0;
|
|
// three 体积大,按需加载,不进主包。
|
|
const teardown: Array<() => void> = [];
|
|
|
|
void (async () => {
|
|
const THREE = await import("three");
|
|
if (disposed) return;
|
|
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
const scene = new THREE.Scene();
|
|
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1100);
|
|
|
|
const loader = new THREE.TextureLoader();
|
|
loader.setCrossOrigin("anonymous");
|
|
const texture = await new Promise<InstanceType<typeof THREE.Texture> | null>((resolve) => {
|
|
loader.load(url, resolve, undefined, () => resolve(null));
|
|
});
|
|
if (disposed) { renderer.dispose(); return; }
|
|
if (!texture) { renderer.dispose(); onErrorRef.current(); return; }
|
|
|
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
const geometry = new THREE.SphereGeometry(500, 60, 40);
|
|
const material = new THREE.MeshBasicMaterial({ map: texture, side: THREE.BackSide });
|
|
scene.add(new THREE.Mesh(geometry, material));
|
|
host.appendChild(renderer.domElement);
|
|
renderer.domElement.style.cssText = "width:100%;height:100%;display:block;cursor:grab";
|
|
|
|
let lon = 0;
|
|
let lat = 0;
|
|
let dragging = false;
|
|
let lastX = 0;
|
|
let lastY = 0;
|
|
|
|
const onPointerDown = (event: PointerEvent) => {
|
|
dragging = true;
|
|
lastX = event.clientX;
|
|
lastY = event.clientY;
|
|
renderer.domElement.setPointerCapture(event.pointerId);
|
|
event.stopPropagation();
|
|
};
|
|
const onPointerMove = (event: PointerEvent) => {
|
|
if (!dragging) return;
|
|
lon -= (event.clientX - lastX) * 0.18;
|
|
lat += (event.clientY - lastY) * 0.18;
|
|
lat = Math.max(-85, Math.min(85, lat));
|
|
lastX = event.clientX;
|
|
lastY = event.clientY;
|
|
};
|
|
const onPointerUp = (event: PointerEvent) => {
|
|
dragging = false;
|
|
if (renderer.domElement.hasPointerCapture(event.pointerId)) renderer.domElement.releasePointerCapture(event.pointerId);
|
|
};
|
|
// 滚轮留给画布缩放,全景不吃 wheel —— 节点里再嵌一层缩放会让手感彻底乱。
|
|
renderer.domElement.addEventListener("pointerdown", onPointerDown);
|
|
renderer.domElement.addEventListener("pointermove", onPointerMove);
|
|
renderer.domElement.addEventListener("pointerup", onPointerUp);
|
|
renderer.domElement.addEventListener("pointercancel", onPointerUp);
|
|
|
|
const resize = () => {
|
|
const { clientWidth, clientHeight } = host;
|
|
if (!clientWidth || !clientHeight) return;
|
|
renderer.setSize(clientWidth, clientHeight, false);
|
|
camera.aspect = clientWidth / clientHeight;
|
|
camera.updateProjectionMatrix();
|
|
};
|
|
const observer = new ResizeObserver(resize);
|
|
observer.observe(host);
|
|
resize();
|
|
|
|
const tick = () => {
|
|
const phi = THREE.MathUtils.degToRad(90 - lat);
|
|
const theta = THREE.MathUtils.degToRad(lon);
|
|
camera.lookAt(500 * Math.sin(phi) * Math.cos(theta), 500 * Math.cos(phi), 500 * Math.sin(phi) * Math.sin(theta));
|
|
renderer.render(scene, camera);
|
|
frame = requestAnimationFrame(tick);
|
|
};
|
|
tick();
|
|
|
|
teardown.push(() => {
|
|
cancelAnimationFrame(frame);
|
|
observer.disconnect();
|
|
renderer.domElement.removeEventListener("pointerdown", onPointerDown);
|
|
renderer.domElement.removeEventListener("pointermove", onPointerMove);
|
|
renderer.domElement.removeEventListener("pointerup", onPointerUp);
|
|
renderer.domElement.removeEventListener("pointercancel", onPointerUp);
|
|
renderer.domElement.remove();
|
|
geometry.dispose();
|
|
material.dispose();
|
|
texture.dispose();
|
|
// forceContextLoss 才真正释放上下文;只 dispose 在部分浏览器上仍占着配额。
|
|
renderer.forceContextLoss();
|
|
renderer.dispose();
|
|
});
|
|
})();
|
|
|
|
return () => {
|
|
disposed = true;
|
|
teardown.forEach((fn) => fn());
|
|
};
|
|
}, [url]);
|
|
|
|
return <div ref={hostRef} className="h-full w-full" data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} />;
|
|
}
|