diff --git a/web/src/components/canvas/canvas-node-generation.ts b/web/src/components/canvas/canvas-node-generation.ts
index 0c4abe418..eaf1ee1bf 100644
--- a/web/src/components/canvas/canvas-node-generation.ts
+++ b/web/src/components/canvas/canvas-node-generation.ts
@@ -4,7 +4,9 @@ import { seedanceReferenceLabel } from "@/lib/seedance-video";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
import { CanvasNodeType, type CanvasConnection, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas";
-import { getGenerationResourceNodes } from "@/lib/canvas/canvas-resource-references";
+import { getGenerationResourceNodes, getContextResourceNodes } from "@/lib/canvas/canvas-resource-references";
+import { isNeutralColorGrade, resolveCanvasColorGradeReference } from "@/lib/canvas/canvas-color-grade";
+import { getNodeResourceKind } from "@/lib/canvas/node-registry";
import { resolveCanvasDrawingReference } from "@/lib/canvas/canvas-drawing-reference";
import { compileCharacterReferencePrompt } from "@/lib/canvas/canvas-character-reference";
import { nodeReferenceImage } from "@/lib/canvas/canvas-project-generation";
@@ -227,8 +229,10 @@ export function buildNodeGenerationInputs(nodeId: string, nodes: CanvasNodeData[
return resourceNodes.flatMap((node): NodeGenerationInput[] => {
const character = readCharacterReference(node);
if (character) return [{ nodeId: node.id, type: "character" as const, title: node.title, character }];
- const image = readReferenceImage(node);
- if (image) return [{ nodeId: node.id, type: "image" as const, sourceKind: image.source?.kind, title: node.title, image }];
+ const image = readReferenceImage(node, nodes, connections);
+ // sourceKind 只是「标签用绘图N而不是参考图N」的覆盖开关,不是来源全集。
+ // 调色节点在下游就是一张普通参考图,按 image 标签即正确。
+ if (image) return [{ nodeId: node.id, type: "image" as const, sourceKind: image.source?.kind === "drawing" ? "drawing" : undefined, title: node.title, image }];
const video = readReferenceVideo(node);
if (video) return [{ nodeId: node.id, type: "video" as const, title: node.title, video }];
const audio = readReferenceAudio(node);
@@ -311,6 +315,7 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
let referenceImages = await Promise.all(
context.referenceImages.map(async (image) => {
if (image.source?.kind === "drawing") return resolveCanvasDrawingReference(projectId, image);
+ if (image.source?.kind === "colorgrade") return resolveCanvasColorGradeReference(image);
return { ...image, dataUrl: await imageToDataUrl(image) };
}),
);
@@ -448,7 +453,7 @@ function generationLabel(type: NodeGenerationInput["type"] | "drawing", index: n
return `文本${index + 1}`;
}
-function readReferenceImage(node: CanvasNodeData): ReferenceImage | null {
+function readReferenceImage(node: CanvasNodeData, nodes: CanvasNodeData[], connections: CanvasConnection[]): ReferenceImage | null {
if (node.type === CanvasNodeType.Drawing && node.metadata?.drawingId) {
return {
id: node.id,
@@ -463,6 +468,29 @@ function readReferenceImage(node: CanvasNodeData): ReferenceImage | null {
},
};
}
+ if (node.type === CanvasNodeType.ColorGrade) {
+ // 调色节点自己不存图:源图在上游,参数在 metadata。没连线就跳过这条输入,
+ // 而不是抛错——画布上放一个还没连线的调色节点是很正常的中间状态。
+ // 判据与节点渲染保持一致(都要求上游是带 content 的图片),
+ // 否则会出现「预览里看到了、生成时却没用上」这类不报错的偏差。
+ const source = getContextResourceNodes(node.id, nodes, connections).find((item) => getNodeResourceKind(item) === "image" && item.metadata?.content);
+ const url = source?.metadata?.content;
+ if (!source || !url) return null;
+
+ const grade = node.metadata?.colorGrade;
+ // 未调色时直接透传源图,省掉一次渲染与上传(也就不占文件容量)。
+ if (!grade || isNeutralColorGrade(grade)) {
+ const passthrough = nodeReferenceImage(source);
+ return passthrough ? { ...passthrough, id: node.id, name: node.title || `调色-${node.id}` } : null;
+ }
+ return {
+ id: node.id,
+ name: node.title || `调色-${node.id}`,
+ type: "image/png",
+ dataUrl: "",
+ source: { kind: "colorgrade", url, grade },
+ };
+ }
return nodeReferenceImage(node);
}
diff --git a/web/src/components/canvas/canvas-node-graph-context.ts b/web/src/components/canvas/canvas-node-graph-context.ts
new file mode 100644
index 000000000..db02f367c
--- /dev/null
+++ b/web/src/components/canvas/canvas-node-graph-context.ts
@@ -0,0 +1,19 @@
+import { createContext, useContext } from "react";
+
+import type { CanvasNodeData } from "@/types/canvas";
+
+// 扩展节点(对比/图表/调色等)要读自己的上游才能工作,但节点经 CanvasProjectWorldLayers
+// 渲染、CanvasNodeContentProps 里只有 node 本身,没有 nodes/connections。
+// 与 canvas-node-action-context 同一个理由:通过 Context 注入,避免改动 world-layers 的透传链。
+// 无 Provider 时静默降级为「没有上游」,节点自行显示空状态而不是崩。
+export type CanvasNodeGraphContextValue = {
+ getUpstreamNodes?: (nodeId: string) => CanvasNodeData[];
+};
+
+export const CanvasNodeGraphContext = createContext
({});
+
+/** 取该节点的直接上游素材节点;无 Provider 或无上游时返回空数组。 */
+export function useUpstreamNodes(nodeId: string) {
+ const { getUpstreamNodes } = useContext(CanvasNodeGraphContext);
+ return getUpstreamNodes?.(nodeId) ?? [];
+}
diff --git a/web/src/components/canvas/canvas-toolbar.tsx b/web/src/components/canvas/canvas-toolbar.tsx
index ba98b2caf..91824df54 100644
--- a/web/src/components/canvas/canvas-toolbar.tsx
+++ b/web/src/components/canvas/canvas-toolbar.tsx
@@ -13,7 +13,7 @@ import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
import { defaultToolbarPrefs, readToolbarPrefs, resolveAddNodeMenuCommands, resolveToolbarEntries, type AddNodeMenuCommand, type ToolContext, type ToolbarHandlers, type ToolbarPrefs } from "@/lib/canvas/tool-registry";
import { useThemeStore } from "@/stores/use-theme-store";
-import type { CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas";
+import type { CanvasNodeType, CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas";
export function CanvasToolbar({
selectedCount,
@@ -34,6 +34,7 @@ export function CanvasToolbar({
onAddFrame,
onAddFolder,
onAddDrawing,
+ onAddExtensionNode,
onOpenDirector,
onUndo,
onRedo,
@@ -64,6 +65,7 @@ export function CanvasToolbar({
onAddFrame: () => void;
onAddFolder: () => void;
onAddDrawing: () => void;
+ onAddExtensionNode: (type: CanvasNodeType) => void;
onOpenDirector: () => void;
onUndo: () => void;
onRedo: () => void;
@@ -126,6 +128,7 @@ export function CanvasToolbar({
onAddFrame,
onAddFolder,
onAddDrawing,
+ onAddExtensionNode,
onChooseStyle,
onOpenDirector,
onUpload,
diff --git a/web/src/components/canvas/nodes/chart-node.tsx b/web/src/components/canvas/nodes/chart-node.tsx
new file mode 100644
index 000000000..6695e765c
--- /dev/null
+++ b/web/src/components/canvas/nodes/chart-node.tsx
@@ -0,0 +1,124 @@
+import { ChartColumn } from "lucide-react";
+import { Bar, BarChart, CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
+
+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 ChartNodeContentProps = {
+ node: CanvasNodeData;
+ theme: CanvasTheme;
+};
+
+type ChartRow = Record;
+type ParsedChart = { rows: ChartRow[]; xKey: string; series: string[] };
+
+/**
+ * 把一段文本解析成图表数据;解析不出来返回 null,由调用方显示错误态。
+ *
+ * 这个函数**必须不抛**:数据来自模型输出,非法 JSON 是常态而不是异常,
+ * 抛出来会连带整个画布白屏。
+ */
+export function parseChartSource(text: string): ParsedChart | null {
+ const trimmed = text.trim();
+ if (!trimmed) return null;
+ const rows = parseJsonRows(trimmed) ?? parseCsvRows(trimmed);
+ if (!rows?.length) return null;
+
+ const keys = Object.keys(rows[0]);
+ if (!keys.length) return null;
+ // 数值列作为系列,其余第一列作为横轴;全是数值时用行号当横轴。
+ const series = keys.filter((key) => rows.every((row) => typeof row[key] === "number"));
+ const xKey = keys.find((key) => !series.includes(key)) || "__index";
+ if (!series.length) return null;
+ if (xKey === "__index") rows.forEach((row, index) => { row.__index = index + 1; });
+ return { rows, xKey, series };
+}
+
+function parseJsonRows(text: string): ChartRow[] | null {
+ try {
+ const parsed: unknown = JSON.parse(text);
+ if (!Array.isArray(parsed)) return null;
+ const rows = parsed.filter((item): item is ChartRow => Boolean(item) && typeof item === "object" && !Array.isArray(item));
+ return rows.length ? rows.map(normalizeRow) : null;
+ } catch {
+ return null;
+ }
+}
+
+function parseCsvRows(text: string): ChartRow[] | null {
+ const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
+ if (lines.length < 2) return null;
+ const header = lines[0].split(",").map((cell) => cell.trim());
+ if (header.length < 2) return null;
+ return lines.slice(1).map((line) => {
+ const cells = line.split(",").map((cell) => cell.trim());
+ return normalizeRow(Object.fromEntries(header.map((key, index) => [key, cells[index] ?? ""])));
+ });
+}
+
+/** 把看起来是数字的值转成数字,否则 recharts 会把 "12" 当类别处理。 */
+function normalizeRow(row: Record): ChartRow {
+ return Object.fromEntries(Object.entries(row).map(([key, value]) => {
+ if (typeof value === "number") return [key, value];
+ const text = String(value ?? "").trim();
+ const numeric = text !== "" && Number.isFinite(Number(text));
+ return [key, numeric ? Number(text) : text];
+ }));
+}
+
+const SERIES_COLORS = ["#f59e0b", "#22c55e", "#06b6d4", "#a855f7", "#ef4444"];
+
+/**
+ * 图表节点:把上游文本(JSON 数组或 CSV)渲染成柱状图/折线图。
+ * metadata.chartKind 为 "line" 时画折线,缺省柱状。
+ */
+export function ChartNodeContent({ node, theme }: ChartNodeContentProps) {
+ const upstream = useUpstreamNodes(node.id);
+ const inherited = upstream.find((item) => getNodeResourceKind(item) === "text");
+ const source = node.metadata?.content || inherited?.metadata?.content || inherited?.metadata?.prompt || "";
+ const parsed = parseChartSource(source);
+ const asLine = node.metadata?.chartKind === "line";
+
+ if (!parsed) {
+ return (
+
+
+ {source.trim() ? "无法解析为图表数据(需要 JSON 数组或 CSV)" : "连接输出 JSON 数组或 CSV 的文本节点"}
+
+ );
+ }
+
+ return (
+ event.stopPropagation()}
+ onMouseDown={(event) => event.stopPropagation()}
+ >
+
+ {asLine ? (
+
+
+
+
+
+ {parsed.series.length > 1 ? : null}
+ {parsed.series.map((key, index) => )}
+
+ ) : (
+
+
+
+
+
+ {parsed.series.length > 1 ? : null}
+ {parsed.series.map((key, index) => )}
+
+ )}
+
+
+ );
+}
diff --git a/web/src/components/canvas/nodes/color-grade-node.tsx b/web/src/components/canvas/nodes/color-grade-node.tsx
new file mode 100644
index 000000000..f1031f186
--- /dev/null
+++ b/web/src/components/canvas/nodes/color-grade-node.tsx
@@ -0,0 +1,93 @@
+import { Palette, RotateCcw } from "lucide-react";
+
+import { useUpstreamNodes } from "@/components/canvas/canvas-node-graph-context";
+import { useCanvasNodeActions } from "@/components/canvas/canvas-node-action-context";
+import { colorGradeCssFilter, DEFAULT_COLOR_GRADE, isNeutralColorGrade, type CanvasColorGrade } from "@/lib/canvas/canvas-color-grade";
+import { getNodeResourceKind } from "@/lib/canvas/node-registry";
+import type { CanvasTheme } from "@/lib/canvas-theme";
+import type { CanvasNodeData } from "@/types/canvas";
+
+type ColorGradeNodeContentProps = {
+ node: CanvasNodeData;
+ theme: CanvasTheme;
+};
+
+const SLIDERS: Array<{ key: keyof CanvasColorGrade; label: string; min: number; max: number }> = [
+ { key: "brightness", label: "亮度", min: 0, max: 200 },
+ { key: "contrast", label: "对比", min: 0, max: 200 },
+ { key: "saturate", label: "饱和", min: 0, max: 200 },
+ { key: "hueRotate", label: "色相", min: -180, max: 180 },
+];
+
+/**
+ * 调色节点:吃上游图片,本地预览调色结果,并作为图片素材喂给下游。
+ *
+ * 预览只用 CSS filter,不上传任何东西;真正落地成资源要等到生成时——
+ * 见 canvas-color-grade.ts 的 resolveCanvasColorGradeReference。拖滑杆不产生上传,
+ * 也不消耗文件容量。
+ */
+export function ColorGradeNodeContent({ node, theme }: ColorGradeNodeContentProps) {
+ const { updateMetadata } = useCanvasNodeActions();
+ const upstream = useUpstreamNodes(node.id);
+ // 判据要与 canvas-node-generation 的 readReferenceImage 完全一致,
+ // 否则会出现「预览有图、生成却没用上」这种不报错的偏差。
+ const inherited = upstream.find((item) => getNodeResourceKind(item) === "image" && item.metadata?.content);
+ const url = inherited?.metadata?.content || "";
+ const grade = node.metadata?.colorGrade || DEFAULT_COLOR_GRADE;
+
+ if (!url) {
+ return (
+
+ );
+ }
+
+ const editable = Boolean(updateMetadata);
+ const update = (key: keyof CanvasColorGrade, value: number) => updateMetadata?.(node.id, { colorGrade: { ...grade, [key]: value } });
+
+ return (
+
+
+

+ {isNeutralColorGrade(grade) ? (
+
未调色
+ ) : null}
+
+
+ {editable ? (
+
event.stopPropagation()}
+ onMouseDown={(event) => event.stopPropagation()}
+ >
+ {SLIDERS.map((slider) => (
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/components/canvas/nodes/compare-node.tsx b/web/src/components/canvas/nodes/compare-node.tsx
new file mode 100644
index 000000000..5ab4eb0fa
--- /dev/null
+++ b/web/src/components/canvas/nodes/compare-node.tsx
@@ -0,0 +1,72 @@
+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(null);
+ const draggingRef = useRef(false);
+
+ const before = images[0]?.metadata?.content || "";
+ const after = images[1]?.metadata?.content || "";
+
+ if (!before || !after) {
+ return (
+
+
+ {images.length === 1 ? "再连一张图片即可对比" : "连接两张图片进行对比"}
+
+ );
+ }
+
+ 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 (
+ 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);
+ }}
+ >
+

+ {/* 上层按分割位置裁切,露出下层的 B */}
+

+
+
A
+
B
+
+ );
+}
diff --git a/web/src/components/canvas/nodes/html-node.tsx b/web/src/components/canvas/nodes/html-node.tsx
new file mode 100644
index 000000000..f0dc72b4b
--- /dev/null
+++ b/web/src/components/canvas/nodes/html-node.tsx
@@ -0,0 +1,47 @@
+import { Code } 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";
+
+import { SandboxedFrame } from "./sandboxed-frame";
+
+type HtmlNodeContentProps = {
+ node: CanvasNodeData;
+ theme: CanvasTheme;
+};
+
+/**
+ * HTML 展示节点:在沙箱 iframe 里预览 HTML。
+ *
+ * 源码取自身 metadata.content,为空时回落上游文本(与 SVG / Markdown 一致)。
+ * 自身有源码时,上游文本作为数据填进 `{{input}}`,所以「模板 + 数据」两条线可以分开接。
+ *
+ * 脚本可执行(allow-scripts),但 SandboxedFrame 结构上不允许同时给 allow-same-origin
+ * —— 否则页面能读到本源登录态。
+ */
+export function HtmlNodeContent({ node, theme }: HtmlNodeContentProps) {
+ const upstream = useUpstreamNodes(node.id);
+ const inherited = upstream.find((item) => getNodeResourceKind(item) === "text");
+ const upstreamText = inherited?.metadata?.content || inherited?.metadata?.prompt || "";
+ const own = node.metadata?.content || "";
+ // 与 SVG / Markdown 一致:自身为空时把上游文本当源码,这样「AI 写页面 → 连线 → 看效果」
+ // 不需要先把源码搬进本节点。
+ const source = own || upstreamText;
+ // 源码本身就来自上游时,{{input}} 没有第二份数据可填——替换成空串,
+ // 否则会把同一段内容再塞回自己里。
+ const input = own ? upstreamText : "";
+
+ if (!source.trim()) {
+ return (
+
+
+ 连接输出 HTML 的文本节点
+ 源码里的 {"{{input}}"} 会替换为上游文本
+
+ );
+ }
+
+ return ;
+}
diff --git a/web/src/components/canvas/nodes/markdown-node.tsx b/web/src/components/canvas/nodes/markdown-node.tsx
new file mode 100644
index 000000000..2a8b00875
--- /dev/null
+++ b/web/src/components/canvas/nodes/markdown-node.tsx
@@ -0,0 +1,48 @@
+import { FileText } from "lucide-react";
+
+import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
+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 MarkdownNodeContentProps = {
+ node: CanvasNodeData;
+ theme: CanvasTheme;
+};
+
+/**
+ * Markdown 展示节点。
+ *
+ * 内容取自身 metadata.content;为空时回落到上游文本素材——这样「AI 写文档 → 连一根线 →
+ * 排版好的文档」不需要先把内容搬进本节点。上游取用统一经 CanvasNodeGraphContext,
+ * 判定文本素材复用注册表的 resourceKind,不在这里另立一套判断。
+ */
+export function MarkdownNodeContent({ node, theme }: MarkdownNodeContentProps) {
+ const upstream = useUpstreamNodes(node.id);
+ const own = node.metadata?.content || "";
+ const inherited = upstream.find((item) => getNodeResourceKind(item) === "text");
+ const source = own || inherited?.metadata?.content || inherited?.metadata?.prompt || "";
+
+ if (!source.trim()) {
+ return (
+
+
+ 连接文本节点,或双击编辑 Markdown
+
+ );
+ }
+
+ return (
+ // 滚动区要吞掉 wheel 并标 data-canvas-no-zoom,否则滚动会被画布缩放拦走。
+ event.stopPropagation()}
+ onMouseDown={(event) => event.stopPropagation()}
+ >
+
{source}
+
+ );
+}
diff --git a/web/src/components/canvas/nodes/panorama-node.tsx b/web/src/components/canvas/nodes/panorama-node.tsx
new file mode 100644
index 000000000..94055d497
--- /dev/null
+++ b/web/src/components/canvas/nodes/panorama-node.tsx
@@ -0,0 +1,205 @@
+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 (
+
+
+ 连接一张 360° 等距柱状全景图
+
+ );
+ }
+
+ if (!active) {
+ return (
+
+

+
+
+ {failed ? "全景加载失败(图片可能不允许跨域读取)" : reduceMediaEffects ? "性能模式下仅显示预览" : "360° 全景"}
+
+ {reduceMediaEffects ? null : (
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+
{ setFailed(true); setActive(false); }} />
+
+
+ );
+}
+
+function PanoramaViewport({ url, onError }: { url: string; onError: () => void }) {
+ const hostRef = useRef(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 | 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 event.stopPropagation()} />;
+}
diff --git a/web/src/components/canvas/nodes/sandboxed-frame.tsx b/web/src/components/canvas/nodes/sandboxed-frame.tsx
new file mode 100644
index 000000000..361ac604a
--- /dev/null
+++ b/web/src/components/canvas/nodes/sandboxed-frame.tsx
@@ -0,0 +1,41 @@
+import type { CanvasTheme } from "@/lib/canvas-theme";
+
+type SandboxedFrameProps = {
+ /** 完整的 HTML 文档字符串,作为 iframe 的 srcDoc */
+ srcDoc: string;
+ theme: CanvasTheme;
+ /**
+ * 是否允许文档内脚本执行。
+ *
+ * ⚠️ 只给 allow-scripts,**绝不同时给 allow-same-origin** —— 两者并存等于取消沙箱:
+ * 文档能拿到本源 DOM 与存储(含登录态)。这里刻意不暴露 sandbox 的完整取值,
+ * 就是为了让这条约束没有被绕过的入口。
+ */
+ allowScripts?: boolean;
+};
+
+/**
+ * 沙箱 iframe —— SVG 与 HTML 节点共用。
+ *
+ * 依赖里没有 DOMPurify,手写消毒不可靠(script / on* / foreignObject 等绕法很多),
+ * 所以两种节点都不走 innerHTML,统一塞进沙箱 iframe 渲染。
+ */
+export function SandboxedFrame({ srcDoc, theme, allowScripts = false }: SandboxedFrameProps) {
+ return (
+