在已落地的节点注册表上加两层节点:展示层(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>
377 lines
23 KiB
TypeScript
377 lines
23 KiB
TypeScript
import { AnimatePresence, useReducedMotion } from "motion/react";
|
|
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
|
import { ArrowLeft, Check, ChevronRight, Clipboard, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, Link2, Maximize2, PanelTop, Pencil, Plus, Redo2, Tags, Trash2, Undo2, Upload, UserRound } from "lucide-react";
|
|
|
|
import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu";
|
|
import { aceternityMotion } from "@/lib/aceternity-motion";
|
|
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
|
|
import { canvasThemes } from "@/lib/canvas-theme";
|
|
import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset";
|
|
import { isCanvasFolderNode } from "@/lib/canvas/canvas-frame";
|
|
import { resolveAddNodeMenuCommands, type AddNodeMenuContext } from "@/lib/canvas/tool-registry";
|
|
import { useThemeStore } from "@/stores/use-theme-store";
|
|
import { CanvasNodeType, type CanvasNodeData, type CanvasWorkspaceMode, type ContextMenuState, type Position } from "@/types/canvas";
|
|
|
|
type CanvasAssetCategory = NonNullable<NonNullable<CanvasNodeData["metadata"]>["assetCategory"]>;
|
|
|
|
const assetCategoryOptions: Array<{ value: CanvasAssetCategory; label: string }> = [
|
|
{ value: "character", label: "角色" },
|
|
{ value: "environment", label: "场景" },
|
|
{ value: "wardrobe", label: "服饰" },
|
|
{ value: "prop", label: "道具" },
|
|
{ value: "weapon", label: "武器" },
|
|
{ value: "style", label: "画风" },
|
|
{ value: "other", label: "其他" },
|
|
];
|
|
|
|
type CanvasNodeContextMenuProps = {
|
|
menu: ContextMenuState;
|
|
node?: CanvasNodeData | null;
|
|
workspaceMode?: CanvasWorkspaceMode;
|
|
isProjectLinked?: boolean;
|
|
canUndo: boolean;
|
|
canRedo: boolean;
|
|
canPaste: boolean;
|
|
onClose: () => void;
|
|
onAddNode: (type: CanvasNodeType) => void;
|
|
onAddFolder: () => void;
|
|
onChooseStyle: () => void;
|
|
onOpenDirector: (position: Position) => void;
|
|
onUpload: () => void;
|
|
onOpenAssets: () => void;
|
|
onOpenProjectCharacters: () => void;
|
|
onUndo: () => void;
|
|
onRedo: () => void;
|
|
onPaste: () => void;
|
|
onCopyNode: () => void;
|
|
onDuplicate: () => void;
|
|
onDelete: () => void;
|
|
onSaveAsset: () => void;
|
|
onViewMedia: () => void;
|
|
onEditText: () => void;
|
|
onOpenDrawing: () => void;
|
|
onGenerateImage: () => void;
|
|
onCopyContent: () => void;
|
|
onCopyMediaUrl: () => void;
|
|
onSetAssetCategory: (category: CanvasAssetCategory) => void;
|
|
onToggleFrame: () => void;
|
|
};
|
|
|
|
export function CanvasNodeContextMenu({
|
|
menu,
|
|
node,
|
|
workspaceMode = "professional",
|
|
isProjectLinked = false,
|
|
canUndo,
|
|
canRedo,
|
|
canPaste,
|
|
onClose,
|
|
onAddNode,
|
|
onAddFolder,
|
|
onChooseStyle,
|
|
onOpenDirector,
|
|
onUpload,
|
|
onOpenAssets,
|
|
onOpenProjectCharacters,
|
|
onUndo,
|
|
onRedo,
|
|
onPaste,
|
|
onCopyNode,
|
|
onDuplicate,
|
|
onDelete,
|
|
onSaveAsset,
|
|
onViewMedia,
|
|
onEditText,
|
|
onOpenDrawing,
|
|
onGenerateImage,
|
|
onCopyContent,
|
|
onCopyMediaUrl,
|
|
onSetAssetCategory,
|
|
onToggleFrame,
|
|
}: CanvasNodeContextMenuProps) {
|
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
|
const reducedMotion = useReducedMotion();
|
|
const [addOpen, setAddOpen] = useState(false);
|
|
const [categoryOpen, setCategoryOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const close = (event: PointerEvent) => {
|
|
const target = event.target;
|
|
if (target instanceof Element && target.closest(".ant-popover")) return;
|
|
onClose();
|
|
};
|
|
const closeOnEscape = (event: KeyboardEvent) => {
|
|
if (event.key !== "Escape") return;
|
|
if (categoryOpen) setCategoryOpen(false);
|
|
else onClose();
|
|
};
|
|
window.addEventListener("pointerdown", close);
|
|
window.addEventListener("keydown", closeOnEscape);
|
|
return () => {
|
|
window.removeEventListener("pointerdown", close);
|
|
window.removeEventListener("keydown", closeOnEscape);
|
|
};
|
|
}, [categoryOpen, onClose]);
|
|
|
|
useEffect(() => {
|
|
setAddOpen(false);
|
|
setCategoryOpen(false);
|
|
}, [menu.type, menu.x, menu.y]);
|
|
|
|
const runAction = (action: () => void) => {
|
|
action();
|
|
onClose();
|
|
};
|
|
const nodeContent = typeof node?.metadata?.content === "string" ? node.metadata.content : "";
|
|
const isImage = node?.type === CanvasNodeType.Image;
|
|
const isText = node?.type === CanvasNodeType.Text;
|
|
const isCharacterReference = Boolean(isText && node?.metadata?.workflowKind === "character" && node.metadata.characterAssetId);
|
|
const isDrawing = node?.type === CanvasNodeType.Drawing;
|
|
const isVideo = node?.type === CanvasNodeType.Video;
|
|
const isMedia = isImage || isVideo;
|
|
const isAudio = node?.type === CanvasNodeType.Audio;
|
|
const isFrame = node?.type === CanvasNodeType.Frame;
|
|
const isFolder = isCanvasFolderNode(node);
|
|
const hasNodeContent = isText ? Boolean(nodeContent.trim()) : Boolean(nodeContent);
|
|
const canSaveAsset = Boolean(node && !isCharacterReference && (isText ? hasNodeContent : hasNodeContent && (isImage || isVideo || isAudio)));
|
|
const canOpenPreview = Boolean(isMedia && hasNodeContent);
|
|
const canGenerateFromText = Boolean(isText && !isCharacterReference && hasNodeContent);
|
|
const canCopyMediaUrl = Boolean(isMedia && hasNodeContent);
|
|
const assetCategory = node ? canvasNodeAssetCategory(node) : "other";
|
|
const position = getContextMenuPosition(menu);
|
|
|
|
return (
|
|
<>
|
|
<SpotlightSurface
|
|
spotlightColor={theme.toolbar.itemHover}
|
|
data-canvas-context-menu
|
|
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.97, x: -3, y: -3 }}
|
|
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
|
|
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
|
|
className="aceternity-floating-panel fixed z-[var(--z-popover)] flex w-[224px] max-h-[calc(100vh-56px)] origin-top-left flex-col overflow-hidden rounded-xl border p-1.5 backdrop-blur-2xl"
|
|
style={{ left: position.left, top: position.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }}
|
|
onContextMenu={(event) => event.preventDefault()}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="absolute inset-x-8 top-0 h-px" style={{ background: `linear-gradient(90deg, transparent, ${theme.toolbar.border}, transparent)` }} />
|
|
<div className="thin-scrollbar min-h-0 overflow-y-auto">
|
|
{menu.type === "node" && isMedia && categoryOpen ? (
|
|
<>
|
|
<MenuHeader title="设置资产分类" description={node?.title || nodeTypeLabel(node)} onBack={() => setCategoryOpen(false)} />
|
|
<MenuSection label="项目用途" />
|
|
{assetCategoryOptions.map((option) => (
|
|
<MenuButton
|
|
key={option.value}
|
|
icon={assetCategory === option.value ? <Check /> : <Tags />}
|
|
label={option.label}
|
|
active={assetCategory === option.value}
|
|
onClick={() => runAction(() => onSetAssetCategory(option.value))}
|
|
/>
|
|
))}
|
|
</>
|
|
) : menu.type === "canvas" ? (
|
|
<>
|
|
<MenuHeader title="画布命令" />
|
|
<MenuButton icon={<Plus className="size-4" />} label="添加节点" chevron active={addOpen} onClick={() => setAddOpen((value) => !value)} />
|
|
<MenuButton icon={<Upload className="size-4" />} label="上传到这里" onClick={() => runAction(onUpload)} />
|
|
{!isProjectLinked ? <MenuButton icon={<FolderOpen className="size-4" />} label="从素材库插入" onClick={() => runAction(onOpenAssets)} /> : null}
|
|
<MenuDivider />
|
|
<MenuSection label="历史与剪贴板" />
|
|
<MenuButton icon={<Undo2 className="size-4" />} label="撤销" shortcut="⌘Z" disabled={!canUndo} onClick={() => runAction(onUndo)} />
|
|
<MenuButton icon={<Redo2 className="size-4" />} label="重做" shortcut="⇧⌘Z" disabled={!canRedo} onClick={() => runAction(onRedo)} />
|
|
<MenuButton icon={<Clipboard className="size-4" />} label="粘贴" shortcut="⌘V" disabled={!canPaste} onClick={() => runAction(onPaste)} />
|
|
</>
|
|
) : menu.type === "node" ? (
|
|
<>
|
|
{isCharacterReference ? (
|
|
<>
|
|
<MenuHeader title="角色卡" description={node?.metadata?.characterName || node?.title} />
|
|
<MenuSection label="角色引用" />
|
|
<MenuButton icon={<UserRound />} label="查看角色详情" onClick={() => runAction(onEditText)} />
|
|
<MenuDivider />
|
|
<MenuSection label="节点" />
|
|
<MenuButton icon={<Copy />} label="复制角色引用" shortcut="⌘C" onClick={() => runAction(onCopyNode)} />
|
|
<MenuButton icon={<Layers3 />} label="创建引用副本" shortcut="⌘D" onClick={() => runAction(onDuplicate)} />
|
|
<MenuButton icon={<Trash2 />} label="删除节点" danger onClick={() => runAction(onDelete)} />
|
|
</>
|
|
) : isMedia ? (
|
|
<>
|
|
<MenuHeader title={isImage ? "图片" : "视频"} description={node?.title || nodeTypeLabel(node)} />
|
|
<MenuSection label="查看与归档" />
|
|
<MenuButton icon={<Maximize2 />} label="进入全景预览" disabled={!canOpenPreview} onClick={() => runAction(onViewMedia)} />
|
|
<MenuButton icon={<Tags />} label="设置资产分类" chevron onClick={() => setCategoryOpen(true)} />
|
|
<MenuDivider />
|
|
<MenuSection label="节点" />
|
|
<MenuButton icon={<Copy />} label="复制节点" shortcut="⌘C" onClick={() => runAction(onCopyNode)} />
|
|
<MenuButton icon={<Link2 />} label={isImage ? "复制图片地址" : "复制视频地址"} disabled={!canCopyMediaUrl} onClick={() => runAction(onCopyMediaUrl)} />
|
|
<MenuButton icon={<Layers3 />} label="创建参数变体" shortcut="⌘D" onClick={() => runAction(onDuplicate)} />
|
|
<MenuButton icon={<Trash2 />} label="删除节点" danger onClick={() => runAction(onDelete)} />
|
|
</>
|
|
) : (
|
|
<>
|
|
<MenuHeader title={node?.title || nodeTypeLabel(node)} />
|
|
<MenuSection label="节点操作" />
|
|
{isFrame ? <MenuButton icon={isFolder ? <FolderOpen /> : <PanelTop />} label={node?.metadata?.frame?.collapsed ? `展开${isFolder ? "文件夹" : "背板"}` : `折叠${isFolder ? "文件夹" : "背板"}`} onClick={() => runAction(onToggleFrame)} /> : <MenuButton icon={<FolderPlus />} label="保存到我的素材" disabled={!canSaveAsset} onClick={() => runAction(onSaveAsset)} />}
|
|
{isText ? <MenuButton icon={<Maximize2 />} label="放大编辑" onClick={() => runAction(onEditText)} /> : null}
|
|
{isDrawing ? <MenuButton icon={<Pencil />} label="打开绘图" onClick={() => runAction(onOpenDrawing)} /> : null}
|
|
{isText ? <MenuButton icon={<ImageIcon />} label="用文本生图" disabled={!canGenerateFromText} onClick={() => runAction(onGenerateImage)} /> : null}
|
|
<MenuDivider />
|
|
<MenuSection label="副本与内容" />
|
|
<MenuButton icon={<Copy />} label={isFrame ? `复制${isFolder ? "文件夹" : "背板"}及内容` : "复制节点"} shortcut="⌘C" onClick={() => runAction(onCopyNode)} />
|
|
{isText ? <MenuButton icon={<Clipboard />} label="复制文本" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null}
|
|
<MenuButton icon={<Copy />} label={isFrame ? `创建${isFolder ? "文件夹" : "背板"}副本` : "创建参数变体"} shortcut="⌘D" onClick={() => runAction(onDuplicate)} />
|
|
<MenuButton icon={<Clipboard />} label="粘贴" shortcut="⌘V" disabled={!canPaste} onClick={() => runAction(onPaste)} />
|
|
<MenuButton icon={<Trash2 />} label={isFrame ? `删除${isFolder ? "文件夹" : "背板"}` : "删除节点"} danger onClick={() => runAction(onDelete)} />
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<MenuHeader title="连接" />
|
|
<MenuButton icon={<Trash2 className="size-4" />} label="删除连接" danger onClick={() => runAction(onDelete)} />
|
|
</>
|
|
)}
|
|
</div>
|
|
</SpotlightSurface>
|
|
|
|
<AnimatePresence>
|
|
{menu.type === "canvas" && addOpen ? (
|
|
<AddNodeContextMenu
|
|
parentPosition={position}
|
|
workspaceMode={workspaceMode}
|
|
isProjectLinked={isProjectLinked}
|
|
reducedMotion={Boolean(reducedMotion)}
|
|
onAddNode={(type) => runAction(() => onAddNode(type))}
|
|
onAddFolder={() => runAction(onAddFolder)}
|
|
onChooseStyle={() => runAction(onChooseStyle)}
|
|
onOpenDirector={() => runAction(() => onOpenDirector(menu.position))}
|
|
onUpload={() => runAction(onUpload)}
|
|
onOpenAssets={() => runAction(onOpenAssets)}
|
|
onOpenProjectCharacters={() => runAction(onOpenProjectCharacters)}
|
|
/>
|
|
) : null}
|
|
</AnimatePresence>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, reducedMotion, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; reducedMotion: boolean; onAddNode: (type: CanvasNodeType) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) {
|
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
|
const left = getSubmenuLeft(parentPosition.left);
|
|
const createContext: AddNodeMenuContext = {
|
|
workspaceMode,
|
|
isProjectLinked,
|
|
handlers: {
|
|
onAddText: () => onAddNode(CanvasNodeType.Text),
|
|
onAddImage: () => onAddNode(CanvasNodeType.Image),
|
|
onAddVideo: () => onAddNode(CanvasNodeType.Video),
|
|
onAddAudio: () => onAddNode(CanvasNodeType.Audio),
|
|
onAddScript: () => onAddNode(CanvasNodeType.Script),
|
|
onAddFrame: () => onAddNode(CanvasNodeType.Frame),
|
|
onAddFolder,
|
|
onAddDrawing: () => onAddNode(CanvasNodeType.Drawing),
|
|
onAddExtensionNode: onAddNode,
|
|
onChooseStyle,
|
|
onOpenDirector,
|
|
onUpload,
|
|
onOpenMyAssets: onOpenAssets,
|
|
onOpenProjectCharacters,
|
|
},
|
|
};
|
|
const commands: CanvasCreateCommand[] = resolveAddNodeMenuCommands(createContext).map((command) => ({
|
|
id: command.id,
|
|
label: command.label,
|
|
icon: command.icon,
|
|
badge: command.badge,
|
|
section: command.section,
|
|
onClick: () => command.run(createContext),
|
|
}));
|
|
|
|
return (
|
|
<SpotlightSurface
|
|
spotlightColor={theme.toolbar.itemHover}
|
|
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, x: left > parentPosition.left ? -5 : 5, scale: 0.97 }}
|
|
animate={{ opacity: 1, x: 0, scale: 1 }}
|
|
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, x: left > parentPosition.left ? -4 : 4, scale: 0.98 }}
|
|
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
|
|
className="aceternity-floating-panel fixed z-[var(--z-popover)] w-[260px] origin-top overflow-hidden rounded-[var(--dock-radius)] border p-2 backdrop-blur-2xl"
|
|
style={{ left, top: parentPosition.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }}
|
|
onContextMenu={(event) => event.preventDefault()}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="absolute inset-x-8 top-0 h-px" style={{ background: `linear-gradient(90deg, transparent, ${theme.toolbar.border}, transparent)` }} />
|
|
<CanvasCreateMenu commands={commands} />
|
|
</SpotlightSurface>
|
|
);
|
|
}
|
|
|
|
function MenuHeader({ title, description, onBack }: { title: string; description?: string; onBack?: () => void }) {
|
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
|
return (
|
|
<div className="mb-0.5 flex items-start gap-1 px-1.5 py-1.5">
|
|
{onBack ? <button type="button" onClick={onBack} className="mt-0.5 grid size-6 shrink-0 place-items-center rounded-md outline-none hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/8" aria-label="返回媒体操作"><ArrowLeft className="size-3.5" /></button> : null}
|
|
<span className="min-w-0"><span className="block truncate text-xs font-semibold">{title}</span>{description && description !== title ? <span className="mt-0.5 block truncate text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>{description}</span> : null}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MenuSection({ label }: { label: string }) {
|
|
return <div className="px-2 pb-1 pt-1.5 text-[var(--fs-micro)] font-medium opacity-45">{label}</div>;
|
|
}
|
|
|
|
function MenuButton({ icon, label, detail, shortcut, badge, chevron = false, active = false, disabled = false, danger = false, onClick }: { icon: ReactNode; label: string; detail?: string; shortcut?: string; badge?: string; chevron?: boolean; active?: boolean; disabled?: boolean; danger?: boolean; onClick?: () => void }) {
|
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
|
const color = danger ? theme.accent.danger : theme.node.text;
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="canvas-menu-item group flex min-h-9 w-full items-center gap-2 rounded-lg border border-transparent px-1.5 py-1 text-left outline-none enabled:hover:border-black/10 enabled:hover:bg-black/5 focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-35 dark:enabled:hover:border-white/10 dark:enabled:hover:bg-white/8"
|
|
style={{ color, background: active ? theme.toolbar.activeBg : undefined, "--tw-ring-color": theme.node.muted } as CSSProperties}
|
|
disabled={disabled}
|
|
onClick={onClick}
|
|
>
|
|
<span className="canvas-menu-item-icon grid size-7 shrink-0 place-items-center rounded-md border opacity-75 group-hover:opacity-100 [&_svg]:size-3.5" style={{ background: danger ? `${theme.accent.danger}12` : theme.spatial.surface, borderColor: danger ? `${theme.accent.danger}33` : theme.toolbar.border, color: danger ? theme.accent.danger : theme.node.text }}>{icon}</span>
|
|
<span className="min-w-0 flex-1"><span className="flex items-center gap-1 text-xs font-medium"><span className="truncate">{label}</span>{badge ? <span className="rounded-full border px-1 py-0.5 text-[var(--fs-nano)] font-bold" style={{ background: theme.toolbar.activeBg, borderColor: theme.toolbar.border, color: theme.node.muted }}>{badge}</span> : null}</span>{detail ? <span className="mt-0.5 block truncate text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>{detail}</span> : null}</span>
|
|
{shortcut ? <span className="shrink-0 text-[var(--fs-micro)] opacity-38">{shortcut}</span> : null}
|
|
{chevron ? <ChevronRight className="size-3 shrink-0 opacity-45 transition-transform group-hover:translate-x-0.5" /> : null}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function MenuDivider() {
|
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
|
return <div className="mx-1.5 my-1 h-px" style={{ background: `linear-gradient(90deg, transparent, ${theme.toolbar.border}, transparent)` }} />;
|
|
}
|
|
|
|
function getContextMenuPosition(menu: ContextMenuState) {
|
|
if (typeof window === "undefined") return { left: menu.x, top: menu.y };
|
|
const width = 224;
|
|
const estimatedHeight = menu.type === "node" ? Math.min(360, window.innerHeight - 72) : menu.type === "canvas" ? 250 : 84;
|
|
return {
|
|
left: clamp(menu.x, 12, Math.max(12, window.innerWidth - width - 12)),
|
|
top: clamp(menu.y, 68, Math.max(68, window.innerHeight - estimatedHeight - 12)),
|
|
};
|
|
}
|
|
|
|
function getSubmenuLeft(parentLeft: number) {
|
|
if (typeof window === "undefined") return parentLeft + 192;
|
|
return parentLeft + 224 + 8 + 260 <= window.innerWidth - 12 ? parentLeft + 232 : Math.max(12, parentLeft - 268);
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number) {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
function nodeTypeLabel(node?: CanvasNodeData | null) {
|
|
if (!node) return "节点";
|
|
if (node.type === CanvasNodeType.Image) return "图片节点";
|
|
if (node.type === CanvasNodeType.Text) return "文本节点";
|
|
if (node.type === CanvasNodeType.Script) return "分镜脚本节点";
|
|
if (node.type === CanvasNodeType.Skill) return "技能节点";
|
|
if (node.type === CanvasNodeType.Video) return "视频节点";
|
|
if (node.type === CanvasNodeType.Audio) return "音频节点";
|
|
if (node.type === CanvasNodeType.Drawing) return "绘图节点";
|
|
if (node.type === CanvasNodeType.Frame) return isCanvasFolderNode(node) ? "文件夹" : "背板";
|
|
return "生成配置节点";
|
|
}
|