feat(canvas): 画布节点与背景 - 扩展节点能力并优化连接与网格

This commit is contained in:
ddCat
2026-08-22 12:52:58 +08:00
35 changed files with 1243 additions and 53 deletions

View File

@@ -42,7 +42,26 @@ export const ConnectionPath = React.memo(function ConnectionPath({
<stop offset="48%" stopColor={theme.accent.primary} stopOpacity={0.58} />
<stop offset="100%" stopColor={theme.accent.primary} stopOpacity={0.34} />
</linearGradient>
{/* 流光头部的软化渐变:两端透明、中间亮,避免短划线看起来是硬色块 */}
<linearGradient id={`${gradientId}-comet`} x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={theme.accent.primary} stopOpacity={0} />
<stop offset="45%" stopColor={theme.accent.primary} stopOpacity={0.95} />
<stop offset="100%" stopColor={theme.accent.primary} stopOpacity={0} />
</linearGradient>
</defs> : null}
{/* 光晕:只在强调态渲染。blur 是 filter,成本随线条数量线性上升,
常态几十条线全开会明显掉帧,所以刻意只给悬停/选中的那一条。
垫在底衬描边之下,不改动常态可读性那几层。 */}
{emphasized ? <path
d={pathD}
stroke={theme.accent.primary}
strokeWidth="8"
vectorEffect="non-scaling-stroke"
strokeOpacity={0.18}
fill="none"
strokeLinecap="round"
style={{ pointerEvents: "none", filter: "blur(3px)" }}
/> : null}
<path
data-connection-id={connection.id}
d={pathD}
@@ -102,37 +121,58 @@ export const ConnectionPath = React.memo(function ConnectionPath({
strokeLinejoin="round"
style={{ pointerEvents: "none" }}
/> : null}
{/* 流光:一小段高亮沿路径跑。周期与虚线流动刻意不同(2.1s vs 1.25s),
两者错拍才像有光在走;同频会锁成一条整体平移的虚线。 */}
{emphasized ? <path
className="canvas-connection-comet"
d={pathD}
stroke={`url(#${gradientId}-comet)`}
strokeWidth="2.6"
vectorEffect="non-scaling-stroke"
strokeDasharray="16 118"
fill="none"
strokeLinecap="round"
style={{ pointerEvents: "none" }}
/> : null}
</g>
);
}, (previous, next) => previous.connection === next.connection && previous.from === next.from && previous.to === next.to && previous.active === next.active && previous.visualMode === next.visualMode && previous.fromScrollTop === next.fromScrollTop && previous.toScrollTop === next.toScrollTop);
export function canvasConnectionPath(connection: CanvasConnection, from: CanvasNodeData, to: CanvasNodeData, fromScrollTop = 0, toScrollTop = 0) {
const startX = from.position.x + from.width;
const startY = connectionHandleY(from, connection.fromHandleId, fromScrollTop, connection.fromAnchorRatio);
const startY = connectionHandleY(from, connection.fromHandleId, fromScrollTop);
const endX = to.position.x;
const endY = connectionHandleY(to, connection.toHandleId, toScrollTop, connection.toAnchorRatio);
const endY = connectionHandleY(to, connection.toHandleId, toScrollTop);
const dx = Math.abs(endX - startX);
const curvature = Math.max(dx * 0.5, 50);
return { pathD: `M ${startX} ${startY} C ${startX + curvature} ${startY}, ${endX - curvature} ${endY}, ${endX} ${endY}`, startX, startY, endX, endY };
}
export function activeConnectionPath(node: CanvasNodeData | undefined, handle: ConnectionHandle, mouseWorld: Position, target?: CanvasNodeData, nodeScrollTop = 0, targetAnchorRatio?: number) {
export function activeConnectionPath(node: CanvasNodeData | undefined, handle: ConnectionHandle, mouseWorld: Position, target?: CanvasNodeData, nodeScrollTop = 0) {
if (!node) return "";
const startX = handle.handleType === "source" ? node.position.x + node.width : mouseWorld.x;
const startY = handle.handleType === "source" ? connectionHandleY(node, handle.handleId, nodeScrollTop, handle.anchorRatio) : mouseWorld.y;
const startY = handle.handleType === "source" ? connectionHandleY(node, handle.handleId, nodeScrollTop) : mouseWorld.y;
const endX = handle.handleType === "source" ? mouseWorld.x : node.position.x;
const endY = handle.handleType === "source" ? mouseWorld.y : connectionHandleY(node, handle.handleId, nodeScrollTop, handle.anchorRatio);
const endY = handle.handleType === "source" ? mouseWorld.y : connectionHandleY(node, handle.handleId, nodeScrollTop);
const snappedStartX = handle.handleType === "target" && target ? target.position.x + target.width : startX;
const snappedStartY = handle.handleType === "target" && target ? connectionHandleY(target, undefined, 0, targetAnchorRatio) : startY;
const snappedStartY = handle.handleType === "target" && target ? connectionHandleY(target) : startY;
const snappedEndX = handle.handleType === "source" && target ? target.position.x : endX;
const snappedEndY = handle.handleType === "source" && target ? connectionHandleY(target, undefined, 0, targetAnchorRatio) : endY;
const snappedEndY = handle.handleType === "source" && target ? connectionHandleY(target) : endY;
const distance = Math.abs(snappedEndX - snappedStartX);
return `M ${snappedStartX} ${snappedStartY} C ${snappedStartX + distance * 0.5} ${snappedStartY}, ${snappedEndX - distance * 0.5} ${snappedEndY}, ${snappedEndX} ${snappedEndY}`;
}
export function connectionHandleY(node: CanvasNodeData, handleId?: string, scrollTop = 0, anchorRatio?: number) {
/**
* 连线在节点上的接入 Y。
*
* 单端口的一侧(左右各一个出入口,除分镜脚本外都是)**永远取边的正中**:
* 之前按鼠标落点比例取 Y,多条线接同一个端口就会沿着边散开,视觉上像节点有很多端口。
* 真正的多端口只存在于分镜脚本的 `row:` 句柄,走下面的分支——所以这里不再接受
* anchorRatio 参数,避免「传了却被忽略」。
*/
export function connectionHandleY(node: CanvasNodeData, handleId?: string, scrollTop = 0) {
if (handleId === "storyboard:context") return node.position.y + node.height - (node.metadata?.storyboardComposerHeight || 104) / 2;
if (!handleId?.startsWith("row:")) return node.position.y + node.height * normalizedAnchorRatio(anchorRatio);
if (!handleId?.startsWith("row:")) return node.position.y + node.height / 2;
const rowId = handleId.slice(4);
const index = (node.metadata?.storyboard?.rows || []).findIndex((row) => row.id === rowId);
if (index < 0) return node.position.y + node.height / 2;
@@ -140,8 +180,3 @@ export function connectionHandleY(node: CanvasNodeData, handleId?: string, scrol
const localY = Math.min(Math.max(index * STORYBOARD_ROW_HEIGHT + STORYBOARD_ROW_HEIGHT / 2 - scrollTop, 4), tableHeight - 4);
return node.position.y + STORYBOARD_HEADER_HEIGHT + localY;
}
function normalizedAnchorRatio(value?: number) {
if (typeof value !== "number" || !Number.isFinite(value)) return 0.5;
return Math.min(0.94, Math.max(0.06, value));
}

View File

@@ -270,6 +270,7 @@ function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, re
onAddFrame: () => onAddNode(CanvasNodeType.Frame),
onAddFolder,
onAddDrawing: () => onAddNode(CanvasNodeType.Drawing),
onAddExtensionNode: onAddNode,
onChooseStyle,
onOpenDirector,
onUpload,

View File

@@ -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[]
<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>

View File

@@ -234,7 +234,6 @@ function syncOverlayContent(scene: OverlayScene, props: CanvasLeaferGraphicsLaye
props.mouseWorld,
props.connectionTargetNodeId ? props.nodeById.get(props.connectionTargetNodeId) : undefined,
props.scriptScrollTopById[connecting.nodeId] || 0,
props.connectionTargetAnchorRatio,
),
stroke: props.theme.accent.primary,
strokeCap: "round",
@@ -253,7 +252,7 @@ function syncOverlayContent(scene: OverlayScene, props: CanvasLeaferGraphicsLaye
if (!source) return;
const handle: ConnectionHandle = { nodeId: source.id, handleType: "source" };
scene.batchDrafts.add(new Path({
path: activeConnectionPath(source, handle, batch.mouseWorld, target, props.scriptScrollTopById[source.id] || 0, batch.targetAnchorRatio),
path: activeConnectionPath(source, handle, batch.mouseWorld, target, props.scriptScrollTopById[source.id] || 0),
stroke,
strokeWidth: 1.4,
strokeScaleFixed: true,

View File

@@ -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,10 @@ export type CanvasNodeActionContextValue = {
download?: (node: CanvasNodeData) => void;
duplicate?: (node: CanvasNodeData) => void;
deleteNode?: (node: CanvasNodeData) => void;
/** 合并式更新节点 metadata;扩展节点(如调色)在自己的面板里改参数时用。 */
updateMetadata?: (nodeId: string, patch: CanvasNodeMetadata) => void;
/** 改节点宽高;图片首次量到真实尺寸后按比例校正节点用。 */
resizeNode?: (nodeId: string, size: { width: number; height: number }) => void;
};
export const CanvasNodeActionContext = createContext<CanvasNodeActionContextValue>({});

View File

@@ -5,6 +5,7 @@ import { VideoPlayer } from "@/components/video-player";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel, isGenerationTaskSubmissionUncertain } from "@/lib/generation-task-display";
import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
import { buildLibTVImagePreviewUrl } from "@/lib/canvas/libtv-import";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
@@ -16,7 +17,15 @@ import { cacheResourceObjectUrl, getCachedResourceObjectUrl } from "@/services/r
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { createDefaultSubtitleStyle } from "@/types/timeline";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import { useCanvasNodeActions } from "./canvas-node-action-context";
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 +74,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<CanvasNodeType, (props: CanvasNodeContentProps) => ReactNode>;
function DrawingContent({ node, theme, drawingProjectId }: CanvasNodeContentProps) {
@@ -418,10 +434,36 @@ 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";
const { resizeNode, updateMetadata } = useCanvasNodeActions();
/**
* 让节点跟随图片真实比例。
*
* 上传接口的 width/height 是可选的(services/api/resources.ts),拿不到时节点会落到
* 默认的横向比例,竖图就被放进一个宽盒子、两侧留黑。这里在图片解码后量真实尺寸并校正。
*
* 判据是「用户有没有手动定过尺寸」,**不是**「有没有量过尺寸」——后者会让所有已经
* 存过 naturalWidth 的旧节点永远得不到修正(第一版就是这么写的,所以没生效)。
* 手动拉过(manualSize)或自由比例(freeResize)的节点只补记尺寸、不动宽高。
*/
const fitToImage = (element: HTMLImageElement) => {
const naturalWidth = element.naturalWidth;
const naturalHeight = element.naturalHeight;
if (!naturalWidth || !naturalHeight) return;
if (node.metadata?.naturalWidth !== naturalWidth || node.metadata?.naturalHeight !== naturalHeight) {
updateMetadata?.(node.id, { naturalWidth, naturalHeight });
}
if (node.metadata?.freeResize || node.metadata?.manualSize) return;
const size = fitNodeSize(naturalWidth, naturalHeight);
// 差不到 1px 就别动,避免无意义的状态写入。
if (Math.abs(size.width - node.width) < 1 && Math.abs(size.height - node.height) < 1) return;
resizeNode?.(node.id, size);
};
return (
<BatchFrame batchCount={isBatchRoot ? batchCount : 0} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} theme={theme} onToggleBatch={onToggleBatch}>
<div ref={imageContainerRef} className="h-full w-full overflow-hidden rounded-[var(--node-radius)]">
{url ? <img src={url} alt={node.title} loading={importedFromLibTV ? "eager" : "lazy"} decoding="async" draggable={false} onDragStart={(event) => event.preventDefault()} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> : <div className="grid size-full place-items-center" style={{ color: theme.node.muted }}>{loading ? <LoaderCircle className="size-5 animate-spin" /> : <ImageIcon className="size-5 opacity-45" />}</div>}
{url ? <img src={url} alt={node.title} loading={importedFromLibTV ? "eager" : "lazy"} decoding="async" draggable={false} onDragStart={(event) => event.preventDefault()} onLoad={(event) => fitToImage(event.currentTarget)} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> : <div className="grid size-full place-items-center" style={{ color: theme.node.muted }}>{loading ? <LoaderCircle className="size-5 animate-spin" /> : <ImageIcon className="size-5 opacity-45" />}</div>}
</div>
</BatchFrame>
);

View File

@@ -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);
}

View File

@@ -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<CanvasNodeGraphContextValue>({});
/** 取该节点的直接上游素材节点;无 Provider 或无上游时返回空数组。 */
export function useUpstreamNodes(nodeId: string) {
const { getUpstreamNodes } = useContext(CanvasNodeGraphContext);
return getUpstreamNodes?.(nodeId) ?? [];
}

View File

@@ -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,

View File

@@ -39,7 +39,7 @@ type PinchState = {
initialScale: number;
};
export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "dots", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, boxSelectEnabled = false, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: InfiniteCanvasProps) {
export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, boxSelectEnabled = false, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: InfiniteCanvasProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const panState = useRef({
isPanning: false,

View File

@@ -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<string, string | number>;
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<string, unknown>): 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 (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.muted }}>
<ChartColumn className="size-5 opacity-60" />
<span style={{ fontSize: "var(--fs-label)" }}>{source.trim() ? "无法解析为图表数据(需要 JSON 数组或 CSV)" : "连接输出 JSON 数组或 CSV 的文本节点"}</span>
</div>
);
}
return (
<div
className="h-full w-full px-2 py-2"
data-canvas-no-zoom
style={{ color: theme.node.text }}
onWheel={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
>
<ResponsiveContainer width="100%" height="100%">
{asLine ? (
<LineChart data={parsed.rows}>
<CartesianGrid stroke={theme.node.stroke} strokeDasharray="3 3" />
<XAxis dataKey={parsed.xKey} tick={{ fill: theme.node.muted, fontSize: 11 }} />
<YAxis tick={{ fill: theme.node.muted, fontSize: 11 }} />
<Tooltip />
{parsed.series.length > 1 ? <Legend /> : null}
{parsed.series.map((key, index) => <Line key={key} type="monotone" dataKey={key} stroke={SERIES_COLORS[index % SERIES_COLORS.length]} dot={false} />)}
</LineChart>
) : (
<BarChart data={parsed.rows}>
<CartesianGrid stroke={theme.node.stroke} strokeDasharray="3 3" />
<XAxis dataKey={parsed.xKey} tick={{ fill: theme.node.muted, fontSize: 11 }} />
<YAxis tick={{ fill: theme.node.muted, fontSize: 11 }} />
<Tooltip />
{parsed.series.length > 1 ? <Legend /> : null}
{parsed.series.map((key, index) => <Bar key={key} dataKey={key} fill={SERIES_COLORS[index % SERIES_COLORS.length]} />)}
</BarChart>
)}
</ResponsiveContainer>
</div>
);
}

View File

@@ -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 (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.muted }}>
<Palette className="size-5 opacity-60" />
<span style={{ fontSize: "var(--fs-label)" }}>连接一张图片即可调色</span>
</div>
);
}
const editable = Boolean(updateMetadata);
const update = (key: keyof CanvasColorGrade, value: number) => updateMetadata?.(node.id, { colorGrade: { ...grade, [key]: value } });
return (
<div className="flex h-full w-full flex-col overflow-hidden" style={{ background: theme.node.fill }}>
<div className="relative min-h-0 flex-1">
<img src={url} alt={node.title || "调色"} className="h-full w-full object-contain" draggable={false} style={{ filter: colorGradeCssFilter(grade) }} />
{isNeutralColorGrade(grade) ? (
<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)" }}>未调色</span>
) : null}
</div>
{editable ? (
<div
className="shrink-0 border-t px-2 py-1.5"
data-canvas-no-zoom
style={{ borderColor: theme.node.stroke }}
onWheel={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
>
{SLIDERS.map((slider) => (
<label key={slider.key} className="flex items-center gap-2" style={{ fontSize: "var(--fs-tiny)", color: theme.node.muted }}>
<span className="w-6 shrink-0">{slider.label}</span>
<input
type="range"
className="min-w-0 flex-1"
min={slider.min}
max={slider.max}
value={grade[slider.key]}
onChange={(event) => update(slider.key, Number(event.target.value))}
/>
<span className="w-8 shrink-0 text-right tabular-nums">{grade[slider.key]}</span>
</label>
))}
<button
type="button"
className="mt-1 inline-flex items-center gap-1 rounded px-1 outline-none transition-colors hover:bg-black/5 dark:hover:bg-white/10"
style={{ fontSize: "var(--fs-tiny)", color: theme.node.muted }}
onClick={() => updateMetadata?.(node.id, { colorGrade: DEFAULT_COLOR_GRADE })}
>
<RotateCcw className="size-3" />复位
</button>
</div>
) : null}
</div>
);
}

View File

@@ -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<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>
);
}

View File

@@ -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 (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.muted }}>
<Code className="size-5 opacity-60" />
<span style={{ fontSize: "var(--fs-label)" }}>连接输出 HTML 的文本节点</span>
<span style={{ fontSize: "var(--fs-tiny)" }}>源码里的 {"{{input}}"} 会替换为上游文本</span>
</div>
);
}
return <SandboxedFrame srcDoc={source.replaceAll("{{input}}", input)} theme={theme} allowScripts />;
}

View File

@@ -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 (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.muted }}>
<FileText className="size-5 opacity-60" />
<span style={{ fontSize: "var(--fs-label)" }}>连接文本节点,或双击编辑 Markdown</span>
</div>
);
}
return (
// 滚动区要吞掉 wheel 并标 data-canvas-no-zoom,否则滚动会被画布缩放拦走。
<div
className="h-full w-full overflow-y-auto overflow-x-hidden px-4 py-3"
data-canvas-no-zoom
style={{ color: theme.node.text }}
onWheel={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
>
<AIMessageMarkdown>{source}</AIMessageMarkdown>
</div>
);
}

View File

@@ -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 (
<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()} />;
}

View File

@@ -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 (
<iframe
title="节点预览"
className="h-full w-full border-0"
sandbox={allowScripts ? "allow-scripts" : ""}
srcDoc={srcDoc}
data-canvas-no-zoom
style={{ background: theme.node.fill }}
// 指针交给文档自己处理,但按下时别让画布把它当成拖拽节点。
onMouseDown={(event) => event.stopPropagation()}
/>
);
}
/** 把一段片段包成可渲染的最小文档;居中且不溢出节点。 */
export function centeredFrameDocument(body: string, extraCss = "") {
return `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%;background:transparent}body{display:grid;place-items:center;overflow:hidden}svg,img{max-width:100%;max-height:100%}${extraCss}</style></head><body>${body}</body></html>`;
}

View File

@@ -0,0 +1,45 @@
import { Shapes } 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 { centeredFrameDocument, SandboxedFrame } from "./sandboxed-frame";
type SvgNodeContentProps = {
node: CanvasNodeData;
theme: CanvasTheme;
};
/** 只取到第一个 <svg> 根元素,避免上游文本里的说明文字被一起塞进文档。 */
function extractSvgSource(text: string) {
const start = text.indexOf("<svg");
if (start < 0) return "";
const end = text.lastIndexOf("</svg>");
return end > start ? text.slice(start, end + "</svg>".length) : "";
}
/**
* SVG 展示节点:把 SVG 源码渲染成图形。
*
* 源码取自身 metadata.content,为空时回落上游文本素材——「让 AI 输出 SVG 代码 → 连线 → 看图」
* 是这个节点存在的理由。渲染走沙箱 iframe(见 sandboxed-frame),不给脚本权限。
*/
export function SvgNodeContent({ node, theme }: SvgNodeContentProps) {
const upstream = useUpstreamNodes(node.id);
const inherited = upstream.find((item) => getNodeResourceKind(item) === "text");
const raw = node.metadata?.content || inherited?.metadata?.content || inherited?.metadata?.prompt || "";
const svg = extractSvgSource(raw);
if (!svg) {
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 }}>
<Shapes className="size-5 opacity-60" />
<span style={{ fontSize: "var(--fs-label)" }}>{raw.trim() ? "上游内容里没有找到 <svg> 源码" : "连接输出 SVG 的文本节点"}</span>
</div>
);
}
return <SandboxedFrame srcDoc={centeredFrameDocument(svg)} theme={theme} />;
}