From 90d6c58de9ac8673a743abab75b27ecef0bd9b5b Mon Sep 17 00:00:00 2001 From: dlh <2773843782@qq.com> Date: Sat, 22 Aug 2026 10:54:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E6=89=A9=E5=B1=95=E8=8A=82?= =?UTF-8?q?=E7=82=B9=20-=20=E6=96=B0=E5=A2=9E=207=20=E7=A7=8D=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E4=B8=8E=E5=8A=A0=E5=B7=A5=E8=8A=82=E7=82=B9=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E4=B8=8A=E6=B8=B8=E8=AF=BB=E5=8F=96=E5=9F=BA?= =?UTF-8?q?=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在已落地的节点注册表上加两层节点:展示层(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 --- web/package.json | 2 +- .../components/canvas/canvas-context-menu.tsx | 1 + .../components/canvas/canvas-create-menu.tsx | 10 +- .../canvas/canvas-node-action-context.ts | 4 +- .../components/canvas/canvas-node-content.tsx | 15 ++ .../canvas/canvas-node-generation.ts | 36 ++- .../canvas/canvas-node-graph-context.ts | 19 ++ web/src/components/canvas/canvas-toolbar.tsx | 5 +- .../components/canvas/nodes/chart-node.tsx | 124 +++++++++++ .../canvas/nodes/color-grade-node.tsx | 93 ++++++++ .../components/canvas/nodes/compare-node.tsx | 72 ++++++ web/src/components/canvas/nodes/html-node.tsx | 47 ++++ .../components/canvas/nodes/markdown-node.tsx | 48 ++++ .../components/canvas/nodes/panorama-node.tsx | 205 ++++++++++++++++++ .../canvas/nodes/sandboxed-frame.tsx | 41 ++++ web/src/components/canvas/nodes/svg-node.tsx | 45 ++++ web/src/constant/canvas.ts | 35 +++ web/src/lib/canvas/canvas-color-grade.ts | 87 ++++++++ .../lib/canvas/canvas-resource-references.ts | 6 +- .../definitions/builtin-nodes.tsx | 63 +++++- .../definitions/add-node-menu-tools.tsx | 16 ++ .../canvas/tool-registry/tool-definition.ts | 10 +- web/src/pages/canvas/project.tsx | 14 +- web/src/types/canvas.ts | 14 ++ web/src/types/image.ts | 24 +- web/test/canvas-extension-nodes.test.ts | 112 ++++++++++ 26 files changed, 1128 insertions(+), 20 deletions(-) create mode 100644 web/src/components/canvas/canvas-node-graph-context.ts create mode 100644 web/src/components/canvas/nodes/chart-node.tsx create mode 100644 web/src/components/canvas/nodes/color-grade-node.tsx create mode 100644 web/src/components/canvas/nodes/compare-node.tsx create mode 100644 web/src/components/canvas/nodes/html-node.tsx create mode 100644 web/src/components/canvas/nodes/markdown-node.tsx create mode 100644 web/src/components/canvas/nodes/panorama-node.tsx create mode 100644 web/src/components/canvas/nodes/sandboxed-frame.tsx create mode 100644 web/src/components/canvas/nodes/svg-node.tsx create mode 100644 web/src/lib/canvas/canvas-color-grade.ts create mode 100644 web/test/canvas-extension-nodes.test.ts diff --git a/web/package.json b/web/package.json index c2e821cf6..612961150 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", + "test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/src/components/canvas/canvas-context-menu.tsx b/web/src/components/canvas/canvas-context-menu.tsx index 75b6cd637..626dbd741 100644 --- a/web/src/components/canvas/canvas-context-menu.tsx +++ b/web/src/components/canvas/canvas-context-menu.tsx @@ -270,6 +270,7 @@ function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, re onAddFrame: () => onAddNode(CanvasNodeType.Frame), onAddFolder, onAddDrawing: () => onAddNode(CanvasNodeType.Drawing), + onAddExtensionNode: onAddNode, onChooseStyle, onOpenDirector, onUpload, diff --git a/web/src/components/canvas/canvas-create-menu.tsx b/web/src/components/canvas/canvas-create-menu.tsx index ebcb858a3..5db2e63d7 100644 --- a/web/src/components/canvas/canvas-create-menu.tsx +++ b/web/src/components/canvas/canvas-create-menu.tsx @@ -11,7 +11,7 @@ export type CanvasCreateCommand = { label: string; icon: ReactNode; badge?: string; - section: "node" | "project" | "resource"; + section: "node" | "extension" | "project" | "resource"; onClick: () => void; }; @@ -19,6 +19,7 @@ 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 ( @@ -44,6 +45,13 @@ export function CanvasCreateMenu({ commands }: { commands: CanvasCreateCommand[] + {extensionCommands.length ? ( + <> + + + + ) : null} + diff --git a/web/src/components/canvas/canvas-node-action-context.ts b/web/src/components/canvas/canvas-node-action-context.ts index 4a5fe7704..f0d69993e 100644 --- a/web/src/components/canvas/canvas-node-action-context.ts +++ b/web/src/components/canvas/canvas-node-action-context.ts @@ -1,6 +1,6 @@ import { createContext, useContext } from "react"; -import type { CanvasNodeData } from "@/types/canvas"; +import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas"; // 批次子图操作条(下载/创建副本/删除)与主图位下载需要调用画布级动作, // 但画布节点经 CanvasProjectWorldLayers 渲染、不便逐个透传 handler, @@ -9,6 +9,8 @@ export type CanvasNodeActionContextValue = { download?: (node: CanvasNodeData) => void; duplicate?: (node: CanvasNodeData) => void; deleteNode?: (node: CanvasNodeData) => void; + /** 合并式更新节点 metadata;扩展节点(如调色)在自己的面板里改参数时用。 */ + updateMetadata?: (nodeId: string, patch: CanvasNodeMetadata) => void; }; export const CanvasNodeActionContext = createContext({}); diff --git a/web/src/components/canvas/canvas-node-content.tsx b/web/src/components/canvas/canvas-node-content.tsx index ccd9456f0..41eb8f7cb 100644 --- a/web/src/components/canvas/canvas-node-content.tsx +++ b/web/src/components/canvas/canvas-node-content.tsx @@ -17,6 +17,13 @@ import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; import { createDefaultSubtitleStyle } from "@/types/timeline"; import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; import { CanvasSubtitleOverlay } from "./canvas-subtitle-overlay"; +import { MarkdownNodeContent } from "./nodes/markdown-node"; +import { ChartNodeContent } from "./nodes/chart-node"; +import { CompareNodeContent } from "./nodes/compare-node"; +import { ColorGradeNodeContent } from "./nodes/color-grade-node"; +import { HtmlNodeContent } from "./nodes/html-node"; +import { PanoramaNodeContent } from "./nodes/panorama-node"; +import { SvgNodeContent } from "./nodes/svg-node"; type CanvasNodeContentProps = { node: CanvasNodeData; @@ -65,6 +72,13 @@ const nodeContentRenderers = { [CanvasNodeType.Audio]: AudioNodeContent, [CanvasNodeType.Drawing]: DrawingContent, [CanvasNodeType.Frame]: UnknownNodeContent, + [CanvasNodeType.Markdown]: MarkdownNodeContent, + [CanvasNodeType.Svg]: SvgNodeContent, + [CanvasNodeType.Html]: HtmlNodeContent, + [CanvasNodeType.Panorama]: PanoramaNodeContent, + [CanvasNodeType.Compare]: CompareNodeContent, + [CanvasNodeType.Chart]: ChartNodeContent, + [CanvasNodeType.ColorGrade]: ColorGradeNodeContent, } satisfies Record ReactNode>; function DrawingContent({ node, theme, drawingProjectId }: CanvasNodeContentProps) { @@ -418,6 +432,7 @@ function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, bat const nearViewport = useNearViewport(imageContainerRef); const { url, loading } = useNodeResourceUrl(node, nearViewport); const importedFromLibTV = node.metadata?.importSource?.provider === "libtv"; + return (
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 ( +
+
+ {node.title + {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 + {/* 上层按分割位置裁切,露出下层的 B */} + A +
+ 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 ( +
+ {node.title +
+ + {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 ( +