Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-toolbar.tsx
dlh 53d05d632b feat(canvas): 扩展节点 - 新增 7 种展示与加工节点并补齐上游读取基建
在已落地的节点注册表上加两层节点:展示层(Markdown / SVG / HTML / 全景)与
接入生成链的加工层(对比 / 图表 / 调色)。全部是第一方内置节点,不引入远程插件
加载,因此不涉及沙箱信任模型,也不需要把 CanvasNodeType 放开成任意字符串。

刻意保留穷尽性检查而不是改成运行时注册。全库只有 4 张按类型建的穷眉表
(NODE_DEFAULT_SIZE / NODE_SPECS / 注册表定义 / nodeContentRenderers),
让编译器强制每个新节点填满它们,比少改几个文件更值 —— 加第一个枚举成员时
编译器点出了 5 处,其中 pages/canvas/shared.tsx 是原先没编目的那一处;
换成运行时注册,那里会静默回落成「未知节点」。

两处前置基建:
- CanvasNodeGraphContext:渲染器只拿到 node、没有 connections,而加工层三个节点
  都要读上游。仿已有的 canvas-node-action-context(同一个问题:节点经
  CanvasProjectWorldLayers 渲染不便逐个透传),取上游复用
  canvas-resource-references 的 getContextResourceNodes,不另写一份。
  Provider 值必须 memo —— 每帧新对象会让所有节点跟着重渲染。
- ToolbarHandlers.onAddExtensionNode(type):一个泛用入口,不给每种扩展节点单开
  一个 onAddXxx。菜单另开「展示与加工」分区,不挤占上方调好的四列创作网格。

SVG 与 HTML 统一走 sandbox iframe:依赖里没有 DOMPurify,手写 SVG 消毒不可靠
(script / on* / foreignObject)。SandboxedFrame 只暴露 allowScripts 布尔而不是
sandbox 取值,让「同时给 allow-same-origin」这条没有入口。

全景节点三个取舍:点击才激活 WebGL(浏览器对同时存在的上下文有硬上限,画布可以有
任意多个全景节点,自动激活必然在某个数量后整片黑);用原生 three 而非 fiber
(需要精确 dispose,forceContextLoss 才真正释放上下文);性能模式下只给静态预览。

调色是唯一碰生成路径的节点,照 Drawing 的先例:拖滑杆只做本地 CSS filter 预览,
落地成资源要等到生成消费它时才走 uploadResourceFile。预览与导出共用同一个
filter 字符串,两处各写一份就会「看到的和用的悄悄不一致」;未调色时直接透传源图,
省掉一次渲染与上传。ctx.filter 不受支持时明确报错而不是导出一张没调色的图。

resourceKind 与 inputKind 分开两个字段:空图片节点不是素材(不进 @ 引用),
但照样计入 imageCount。调色节点的 resourceKind 无条件返回 image —— 它的图来自
上游,按「有没有 content」判断会让它永远进不了生成输入,这是不报错的静默失效。

测试:新增 test/canvas-extension-nodes.test.ts(12 用例 / 64 断言),覆盖 7 个节点的
判据矩阵、调色参数、以及图表解析。图表解析函数刻意不抛 —— 数据来自模型输出,
非法 JSON 是常态而不是异常,抛出来会连带整个画布白屏,有 7 条断言钉这个。
测试列表是显式枚举,已把新文件加进 package.json,不加进去不会跑。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 12:29:24 +08:00

281 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
import { Segmented, Switch } from "antd";
import { CircleDot, Grid2x2, Moon, Palette, Sun, Square, Info } from "lucide-react";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { FloatingDock } from "@/components/ui/aceternity/floating-dock";
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu";
import { ToolbarSettingsModal } from "@/components/canvas/toolbars/toolbar-settings-modal";
import { aceternityMotion } from "@/lib/aceternity-motion";
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 { CanvasNodeType, CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas";
export function CanvasToolbar({
selectedCount,
workspaceMode,
canvasTool,
onToolChange,
isProjectLinked,
canUndo,
canRedo,
backgroundMode,
showImageInfo,
onAddImage,
onAddVideo,
onAddAudio,
onAddText,
onChooseStyle,
onAddScript,
onAddFrame,
onAddFolder,
onAddDrawing,
onAddExtensionNode,
onOpenDirector,
onUndo,
onRedo,
onUpload,
onDelete,
onClear,
onDeselect,
onBackgroundModeChange,
onShowImageInfoChange,
onOpenMyAssets,
onOpenProjectCharacters,
}: {
selectedCount: number;
workspaceMode: CanvasWorkspaceMode;
canvasTool: CanvasToolMode;
onToolChange: (tool: CanvasToolMode) => void;
isProjectLinked: boolean;
canUndo: boolean;
canRedo: boolean;
backgroundMode: CanvasBackgroundMode;
showImageInfo: boolean;
onAddImage: () => void;
onAddVideo: () => void;
onAddAudio: () => void;
onAddText: () => void;
onChooseStyle: () => void;
onAddScript: () => void;
onAddFrame: () => void;
onAddFolder: () => void;
onAddDrawing: () => void;
onAddExtensionNode: (type: CanvasNodeType) => void;
onOpenDirector: () => void;
onUndo: () => void;
onRedo: () => void;
onUpload: () => void;
onDelete: () => void;
onClear: () => void;
onDeselect: () => void;
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
onShowImageInfoChange: (show: boolean) => void;
onOpenMyAssets: () => void;
onOpenProjectCharacters: () => void;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const dockRef = useRef<HTMLDivElement>(null);
const colorTheme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const theme = canvasThemes[colorTheme];
const [addOpen, setAddOpen] = useState(false);
const [appearanceOpen, setAppearanceOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [panelX, setPanelX] = useState(0);
const [prefs, setPrefs] = useState<ToolbarPrefs | null>(() => readToolbarPrefs("main"));
// 设置面板关闭后重新读取偏好(用户可能调整了排序/显隐)
useEffect(() => {
if (!settingsOpen) setPrefs(readToolbarPrefs("main"));
}, [settingsOpen]);
const placePanel = (event: ReactMouseEvent<HTMLElement>) => setPanelX(getPanelX(dockRef.current, event.currentTarget));
const runAddAction = (action: () => void) => {
action();
setAddOpen(false);
};
// 点击外部关闭浮层面板
useEffect(() => {
if (!addOpen && !appearanceOpen) return;
const closeFloatingPanels = (event: PointerEvent) => {
const target = event.target instanceof Node ? event.target : null;
if (target && rootRef.current?.contains(target)) return;
setAddOpen(false);
setAppearanceOpen(false);
};
document.addEventListener("pointerdown", closeFloatingPanels, true);
return () => document.removeEventListener("pointerdown", closeFloatingPanels, true);
}, [addOpen, appearanceOpen]);
// 构建 handlers(主工具栏只需要部分回调,其余用 no-op 占位满足类型)
const handlers: ToolbarHandlers = {
onToolChange,
onDeselect,
onUndo,
onRedo,
onClear,
onAddText,
onAddImage,
onAddVideo,
onAddAudio,
onAddScript,
onAddFrame,
onAddFolder,
onAddDrawing,
onAddExtensionNode,
onChooseStyle,
onOpenDirector,
onUpload,
onOpenMyAssets,
onOpenProjectCharacters,
onBackgroundModeChange,
onShowImageInfoChange,
onToggleAddPanel: (event: ReactMouseEvent<HTMLElement>) => { placePanel(event); setAppearanceOpen(false); setSettingsOpen(false); setAddOpen((value) => !value); },
onToggleAppearancePanel: (event: ReactMouseEvent<HTMLElement>) => { placePanel(event); setAddOpen(false); setSettingsOpen(false); setAppearanceOpen((value) => !value); },
onToggleSettingsPanel: () => { setAddOpen(false); setAppearanceOpen(false); setSettingsOpen((value) => !value); },
onDeleteSelected: onDelete,
// 以下为多选/节点悬停工具栏回调,主工具栏不使用,用 no-op 占位
onAlign: () => {}, onArrange: () => {}, onCreateStoryboard: () => {}, onCreateReferenceGroup: () => {}, onBatchConnect: () => {}, onMergeVideos: () => {},
onNodeInfo: () => {}, onNodeDelete: () => {}, onNodeRetry: () => {}, onNodeEditText: () => {}, onNodeDecreaseFont: () => {}, onNodeIncreaseFont: () => {},
onNodeToggleDialog: () => {}, onNodeAnnotate: () => {}, onNodeGenerateImage: () => {}, onNodeUpload: () => {}, onNodeDownload: () => {}, onNodeSaveAsset: () => {},
onNodeMaskEdit: () => {}, onNodeEmotion: () => {}, onNodePortraitTexture: () => {}, onNodeCrop: () => {}, onNodeSplit: () => {}, onNodeUpscale: () => {},
onNodeSuperResolve: () => {}, onNodeAngle: () => {}, onNodeViewImage: () => {}, onNodeExtractVideoLastFrame: () => {}, onNodeExtractAudioFromVideo: () => {}, onNodeTrimVideoRegenerate: () => {}, onNodeSubtitles: () => {}, onNodeTimeline: () => {}, onNodeReversePrompt: () => {},
onNodeToggleFreeResize: () => {}, onNodeToggleLocked: () => {}, onNodeCopyPrompt: () => {},
} as ToolbarHandlers;
const ctx: ToolContext = {
selectedCount,
selectedNodeTypes: new Set(),
selectedVideoCount: 0,
canvasTool,
workspaceMode,
isProjectLinked,
canUndo,
canRedo,
extractingVideoFrame: false,
extractingAudio: false,
trimmingVideo: false,
mergingVideos: false,
addPanelOpen: addOpen,
appearancePanelOpen: appearanceOpen,
settingsPanelOpen: settingsOpen,
handlers,
};
const items = resolveToolbarEntries("main", ctx, prefs ?? defaultToolbarPrefs("main"));
// 解析添加节点菜单命令——onClick 绑定到 runAddAction 以在执行后关闭面板
const addNodeCommands = resolveAddNodeMenuCommands(ctx);
const toCommand = (cmd: AddNodeMenuCommand): CanvasCreateCommand => ({
id: cmd.id,
label: cmd.label,
icon: cmd.icon,
badge: cmd.badge,
section: cmd.section,
onClick: () => runAddAction(() => cmd.run(ctx)),
});
const createCommands = addNodeCommands.map(toCommand);
return (
<div ref={rootRef} data-canvas-no-zoom className="pointer-events-none absolute inset-x-[var(--canvas-inset-x)] bottom-[var(--canvas-inset-y)] z-[var(--z-toolbar)] flex justify-center">
<AnimatePresence>
{addOpen ? (
<AddNodeMenu
x={panelX}
theme={theme}
commands={createCommands}
/>
) : null}
</AnimatePresence>
<FloatingDock ref={dockRef} items={items} className="canvas-floating-dock pointer-events-auto max-w-full" style={canvasDockStyle(theme)} />
<AnimatePresence>
{appearanceOpen ? (
<motion.div initial={{ opacity: 0, scaleY: 0.9, y: 8 }} animate={{ opacity: 1, scaleY: 1, y: 0 }} exit={{ opacity: 0, scaleY: 0.92, y: 6 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[224px] max-w-[calc(100vw-24px)]" style={{ left: panelX || "50%", transformOrigin: "bottom center", x: "-50%" }}>
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ opacity: 0, scale: 0.97 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.97, transition: { duration: 0 } }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2.5 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.toolbar.item }} onWheel={(event) => event.stopPropagation()}>
<PanelHeading icon={<Palette className="size-4" />} title="画布外观" subtitle="调整整个创作空间" theme={theme} />
<div className="mt-3 text-[var(--fs-micro)] font-semibold uppercase opacity-45">主题模式</div>
<div className="mt-1 grid grid-cols-2 gap-1 rounded-[var(--dock-item-radius-labeled)] border p-1" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
<CanvasThemeButton colorTheme={colorTheme} targetTheme="light" onThemeChange={setTheme}><Sun className="size-3.5" />浅色</CanvasThemeButton>
<CanvasThemeButton colorTheme={colorTheme} targetTheme="dark" onThemeChange={setTheme}><Moon className="size-3.5" />深色</CanvasThemeButton>
</div>
<div className="mt-3 text-[var(--fs-micro)] font-semibold uppercase opacity-45">空间网格</div>
<Segmented
className="mt-1 w-full !rounded-[var(--dock-item-radius-labeled)] !p-0.5 [&_.ant-segmented-group]:!flex [&_.ant-segmented-item]:!min-h-7 [&_.ant-segmented-item]:!flex-1 [&_.ant-segmented-item-label]:!min-h-7 [&_.ant-segmented-item-label]:!text-[var(--fs-tiny)] [&_.ant-segmented-item-label]:!leading-7"
value={backgroundMode}
onChange={(value) => onBackgroundModeChange(value as CanvasBackgroundMode)}
options={[
{ value: "dots", label: <span className="inline-flex items-center gap-1.5"><CircleDot className="size-3.5" />点</span> },
{ value: "lines", label: <span className="inline-flex items-center gap-1.5"><Grid2x2 className="size-3.5" />线</span> },
{ value: "blank", label: <span className="inline-flex items-center gap-1.5"><Square className="size-3.5" />空白</span> },
]}
/>
<div className="mt-2.5 flex items-center justify-between gap-2 rounded-[var(--dock-item-radius-labeled)] border px-2.5 py-2" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
<span className="inline-flex min-w-0 items-center gap-1.5 text-[var(--fs-tiny)] font-semibold"><Info className="size-3" />图片信息</span>
<Switch size="small" checked={showImageInfo} onChange={onShowImageInfoChange} />
</div>
</SpotlightSurface>
</motion.div>
) : null}
</AnimatePresence>
<ToolbarSettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} toolbar="main" />
</div>
);
}
function AddNodeMenu({ x, theme, commands }: {
x: number;
theme: CanvasTheme;
commands: CanvasCreateCommand[];
}) {
return (
<motion.div initial={{ opacity: 0, scaleY: 0.9, y: 8 }} animate={{ opacity: 1, scaleY: 1, y: 0 }} exit={{ opacity: 0, scaleY: 0.92, y: 6 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[260px] max-w-[calc(100vw-24px)]" style={{ left: x || "50%", transformOrigin: "bottom center", x: "-50%" }}>
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ opacity: 0, scale: 0.97 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.97, transition: { duration: 0 } }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
<CanvasCreateMenu commands={commands} />
</SpotlightSurface>
</motion.div>
);
}
function PanelHeading({ icon, title, subtitle, theme }: { icon: ReactNode; title: string; subtitle: string; theme: CanvasTheme }) {
return (
<div className="flex items-center gap-2">
<span className="grid size-8 shrink-0 place-items-center rounded-[var(--dock-item-radius)] border opacity-75 [&_svg]:size-3.5" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>{icon}</span>
<span className="min-w-0"><span className="block text-xs font-semibold">{title}</span><span className="mt-0.5 block text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>{subtitle}</span></span>
</div>
);
}
function CanvasThemeButton({ colorTheme, targetTheme, onThemeChange, children }: { colorTheme: CanvasColorTheme; targetTheme: CanvasColorTheme; onThemeChange: (theme: CanvasColorTheme) => void; children: ReactNode }) {
const theme = canvasThemes[colorTheme];
const active = colorTheme === targetTheme;
return (
<AnimatedThemeToggler
theme={colorTheme}
targetTheme={targetTheme}
onThemeChange={onThemeChange}
className="inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2 text-xs font-semibold transition-colors"
style={active ? { background: theme.node.text, color: theme.node.panel } : { color: theme.toolbar.item }}
aria-label={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
title={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
>
{children}
</AnimatedThemeToggler>
);
}
function getPanelX(dock: HTMLDivElement | null, target: HTMLElement) {
if (!dock) return 0;
const rootBox = dock.parentElement?.getBoundingClientRect() || dock.getBoundingClientRect();
const box = target.getBoundingClientRect();
return box.left - rootBox.left + box.width / 2;
}