fix(canvas): 添加节点菜单 - 统一命令入口并修复文字溢出与分区拥挤

This commit is contained in:
ddCat
2026-08-05 14:23:57 +08:00
parent b91298dbdb
commit cd326609fa
11 changed files with 182 additions and 110 deletions

View File

@@ -1,12 +1,13 @@
import { AnimatePresence, useReducedMotion } from "motion/react";
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import { ArrowLeft, Check, ChevronRight, Clapperboard, Clipboard, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, Link2, Maximize2, Music2, PanelTop, Pencil, Plus, Redo2, Tags, Trash2, Type, Undo2, Upload, UserRound, Video } from "lucide-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 { CanvasCreateCommandGrid, type CanvasCreateCommand } from "@/components/canvas/canvas-create-command-grid";
import { canvasThemes } from "@/lib/canvas-theme";
import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset";
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";
@@ -32,6 +33,7 @@ type CanvasNodeContextMenuProps = {
canPaste: boolean;
onClose: () => void;
onAddNode: (type: CanvasNodeType) => void;
onChooseStyle: () => void;
onOpenDirector: (position: Position) => void;
onUpload: () => void;
onOpenAssets: () => void;
@@ -63,6 +65,7 @@ export function CanvasNodeContextMenu({
canPaste,
onClose,
onAddNode,
onChooseStyle,
onOpenDirector,
onUpload,
onOpenAssets,
@@ -234,6 +237,7 @@ export function CanvasNodeContextMenu({
isProjectLinked={isProjectLinked}
reducedMotion={Boolean(reducedMotion)}
onAddNode={(type) => runAction(() => onAddNode(type))}
onChooseStyle={() => runAction(onChooseStyle)}
onOpenDirector={() => runAction(() => onOpenDirector(menu.position))}
onUpload={() => runAction(onUpload)}
onOpenAssets={() => runAction(onOpenAssets)}
@@ -245,27 +249,35 @@ export function CanvasNodeContextMenu({
);
}
function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, reducedMotion, onAddNode, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; reducedMotion: boolean; onAddNode: (type: CanvasNodeType) => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) {
function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, reducedMotion, onAddNode, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; reducedMotion: boolean; onAddNode: (type: CanvasNodeType) => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const left = getSubmenuLeft(parentPosition.left);
const simpleMode = workspaceMode === "simple";
const nodeCommands: CanvasCreateCommand[] = [
{ id: "text", label: "文本", icon: <Type />, onClick: () => onAddNode(CanvasNodeType.Text) },
{ id: "drawing", label: "绘图", icon: <Pencil />, onClick: () => onAddNode(CanvasNodeType.Drawing) },
{ id: "script", label: "分镜脚本", icon: <Clapperboard />, badge: "核心", onClick: () => onAddNode(CanvasNodeType.Script) },
...(!simpleMode ? [{ id: "frame", label: "背板", icon: <PanelTop />, onClick: () => onAddNode(CanvasNodeType.Frame) }] : []),
{ id: "image", label: "图片", icon: <ImageIcon />, onClick: () => onAddNode(CanvasNodeType.Image) },
{ id: "video", label: "视频", icon: <Video />, onClick: () => onAddNode(CanvasNodeType.Video) },
...(!simpleMode ? [
{ id: "director", label: "导演台", icon: <Layers3 />, badge: "3D", onClick: onOpenDirector },
{ id: "audio", label: "音频", icon: <Music2 />, onClick: () => onAddNode(CanvasNodeType.Audio) },
] : []),
];
const resourceCommands: CanvasCreateCommand[] = [
{ id: "upload", label: "上传文件", icon: <Upload />, onClick: onUpload },
...(isProjectLinked ? [{ id: "project-character", label: "添加角色卡", icon: <UserRound />, onClick: onOpenProjectCharacters }] : []),
...(!isProjectLinked ? [{ id: "assets", label: "素材库", icon: <FolderOpen />, onClick: onOpenAssets }] : []),
];
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),
onAddDrawing: () => onAddNode(CanvasNodeType.Drawing),
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
@@ -280,13 +292,7 @@ function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, re
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>
<MenuHeader title="添加节点" />
<MenuSection label="创作节点" />
<CanvasCreateCommandGrid commands={nodeCommands} />
<MenuSection label="导入资源" />
<CanvasCreateCommandGrid commands={resourceCommands} variant="resource" />
</div>
<CanvasCreateMenu commands={commands} />
</SpotlightSurface>
);
}

View File

@@ -1,49 +0,0 @@
import { motion, useReducedMotion } from "motion/react";
import type { CSSProperties, ReactNode } from "react";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { canvasThemes } from "@/lib/canvas-theme";
import { cn } from "@/lib/utils";
import { useThemeStore } from "@/stores/use-theme-store";
export type CanvasCreateCommand = {
id: string;
label: string;
icon: ReactNode;
badge?: string;
onClick: () => void;
};
export function CanvasCreateCommandGrid({ commands, variant = "node" }: { commands: CanvasCreateCommand[]; variant?: "node" | "resource" }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
return (
<div className={cn("grid gap-1", variant === "node" ? "grid-cols-4" : "grid-cols-2")}>
{commands.map((command) => (
<motion.button
key={command.id}
type="button"
whileHover={reducedMotion ? undefined : { y: -2, scale: 1.02 }}
whileTap={reducedMotion ? undefined : { scale: 0.96 }}
transition={aceternityMotion.spring.dock}
className={cn(
"group relative min-w-0 overflow-hidden border border-black/10 bg-white/70 text-center outline-none transition-colors hover:border-black/20 hover:bg-black/5 focus-visible:ring-2 dark:border-white/10 dark:bg-white/[.04] dark:hover:border-white/20 dark:hover:bg-white/8",
variant === "node" ? "flex h-[var(--dock-create-node-height)] flex-col items-center rounded-[var(--dock-item-radius)]" : "flex h-9 items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2",
)}
style={{ color: theme.node.text, "--tw-ring-color": theme.node.muted } as CSSProperties}
title={command.label}
onMouseDown={(event) => event.stopPropagation()}
onClick={command.onClick}
>
{variant === "node" ? <span className="flex h-4 w-full shrink-0 items-start justify-end px-1 pt-1">
{command.badge ? <span className="inline-flex max-w-full items-center rounded-full border px-1 py-0.5 text-[var(--fs-nano)] font-bold leading-none" style={{ background: theme.toolbar.activeBg, borderColor: theme.toolbar.border, color: theme.node.muted }}>{command.badge}</span> : null}
</span> : null}
<span className="flex min-h-0 w-full min-w-0 flex-1 flex-col items-center justify-center gap-1 overflow-hidden px-1">
<span className="grid size-5 shrink-0 place-items-center opacity-65 transition-opacity group-hover:opacity-100 [&_svg]:size-3.5">{command.icon}</span>
<span className="block max-w-full truncate whitespace-nowrap text-[var(--fs-micro)] font-semibold leading-none" title={command.label}>{command.label}</span>
</span>
</motion.button>
))}
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { motion, useReducedMotion } from "motion/react";
import type { CSSProperties, ReactNode } from "react";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { canvasThemes } from "@/lib/canvas-theme";
import { cn } from "@/lib/utils";
import { useThemeStore } from "@/stores/use-theme-store";
export type CanvasCreateCommand = {
id: string;
label: string;
icon: ReactNode;
badge?: string;
section: "node" | "project" | "resource";
onClick: () => void;
};
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 resourceCommands = commands.filter((command) => command.section === "resource");
return (
<div>
<header className="flex min-h-7 items-center justify-between gap-2 border-b pb-2" style={{ borderColor: theme.toolbar.border }}>
<h2 className="font-semibold leading-none" style={{ fontSize: "var(--fs-caption)" }}>添加节点</h2>
{projectCommands.map((command) => (
<button
key={command.id}
type="button"
className="inline-flex h-6 min-w-0 items-center gap-1 rounded-[var(--dock-item-radius)] px-1.5 font-medium outline-none transition-colors hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/8 [&_svg]:size-3"
style={{ color: theme.node.muted, fontSize: "var(--fs-tiny)", "--tw-ring-color": theme.node.muted } as CSSProperties}
title={command.label}
onMouseDown={(event) => event.stopPropagation()}
onClick={command.onClick}
>
{command.icon}
<span className="whitespace-nowrap">{command.label}</span>
</button>
))}
</header>
<MenuSection title="创作节点" color={theme.node.muted} />
<CanvasCreateCommandGrid commands={nodeCommands} variant="node" />
<MenuSection title="导入资源" color={theme.node.muted} spaced />
<CanvasCreateCommandGrid commands={resourceCommands} variant="resource" />
</div>
);
}
function CanvasCreateCommandGrid({ commands, variant }: { commands: CanvasCreateCommand[]; variant: "node" | "resource" }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
return (
<div className={cn("grid gap-1", variant === "node" ? "grid-cols-4" : "grid-cols-2")}>
{commands.map((command) => (
<motion.button
key={command.id}
type="button"
whileHover={reducedMotion ? undefined : { y: -1 }}
whileTap={reducedMotion ? undefined : { scale: 0.98 }}
transition={aceternityMotion.spring.dock}
className={cn(
"group min-w-0 overflow-hidden border border-black/10 bg-white/70 outline-none transition-colors hover:border-black/20 hover:bg-black/5 focus-visible:ring-2 dark:border-white/10 dark:bg-white/[.04] dark:hover:border-white/20 dark:hover:bg-white/8",
variant === "node"
? "flex h-[var(--canvas-create-node-height)] flex-col items-start justify-between rounded-[var(--dock-item-radius)] px-1 py-1.5 text-left"
: "flex h-[var(--canvas-create-resource-height)] items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2 text-center",
)}
style={{ color: theme.node.text, "--tw-ring-color": theme.node.muted } as CSSProperties}
title={command.label}
onMouseDown={(event) => event.stopPropagation()}
onClick={command.onClick}
>
{variant === "node" ? (
<>
<span className="flex w-full min-w-0 items-center justify-between gap-1">
<span className="grid size-4 shrink-0 place-items-center opacity-65 transition-opacity group-hover:opacity-100 [&_svg]:size-4">{command.icon}</span>
{command.badge ? <span className="shrink-0 font-medium leading-none" style={{ color: theme.node.muted, fontSize: "var(--fs-tiny)" }}>{command.badge}</span> : null}
</span>
<span className="block w-full overflow-hidden text-ellipsis whitespace-nowrap font-medium leading-none" style={{ fontSize: "var(--fs-label)" }}>{command.label}</span>
</>
) : (
<>
<span className="grid size-4 shrink-0 place-items-center opacity-65 transition-opacity group-hover:opacity-100 [&_svg]:size-3.5">{command.icon}</span>
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-medium leading-none" style={{ fontSize: "var(--fs-label)" }}>{command.label}</span>
</>
)}
</motion.button>
))}
</div>
);
}
function MenuSection({ title, color, spaced = false }: { title: string; color: string; spaced?: boolean }) {
return <h3 className="mb-1 mt-2 px-1 font-medium leading-none" style={{ color, fontSize: "var(--fs-tiny)", marginTop: spaced ? "var(--space-4)" : "var(--space-2)" }}>{title}</h3>;
}

View File

@@ -1,12 +1,12 @@
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, Plus, Palette, Sun, Square, Info } from "lucide-react";
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 { CanvasCreateCommandGrid, type CanvasCreateCommand } from "@/components/canvas/canvas-create-command-grid";
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";
@@ -169,10 +169,10 @@ export function CanvasToolbar({
label: cmd.label,
icon: cmd.icon,
badge: cmd.badge,
section: cmd.section,
onClick: () => runAddAction(() => cmd.run(ctx)),
});
const nodeCommands = addNodeCommands.filter((cmd) => cmd.section === "node").map(toCommand);
const resourceCommands = addNodeCommands.filter((cmd) => cmd.section === "resource").map(toCommand);
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">
@@ -181,8 +181,7 @@ export function CanvasToolbar({
<AddNodeMenu
x={panelX}
theme={theme}
nodeCommands={nodeCommands}
resourceCommands={resourceCommands}
commands={createCommands}
/>
) : null}
</AnimatePresence>
@@ -224,20 +223,15 @@ export function CanvasToolbar({
);
}
function AddNodeMenu({ x, theme, nodeCommands, resourceCommands }: {
function AddNodeMenu({ x, theme, commands }: {
x: number;
theme: CanvasTheme;
nodeCommands: CanvasCreateCommand[];
resourceCommands: CanvasCreateCommand[];
commands: CanvasCreateCommand[];
}) {
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: aceternityMotion.duration.instant }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[260px] max-w-[calc(100vw-24px)] -translate-x-1/2" style={{ left: x || "50%" }}>
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ y: 6, scale: 0.97 }} animate={{ y: 0, scale: 1 }} exit={{ y: 4, scale: 0.98 }} 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, boxShadow: `0 24px 64px ${theme.spatial.shadow}` }} onWheel={(event) => event.stopPropagation()}>
<PanelHeading icon={<Plus className="size-4" />} title="创建内容" subtitle="选择节点类型" theme={theme} />
<MenuSection title="创作节点" />
<CanvasCreateCommandGrid commands={nodeCommands} />
<MenuSection title="导入资源" />
<CanvasCreateCommandGrid commands={resourceCommands} variant="resource" />
<CanvasCreateMenu commands={commands} />
</SpotlightSurface>
</motion.div>
);
@@ -252,10 +246,6 @@ function PanelHeading({ icon, title, subtitle, theme }: { icon: ReactNode; title
);
}
function MenuSection({ title }: { title: string }) {
return <div className="mb-1 mt-3 px-1 text-[var(--fs-micro)] font-semibold uppercase opacity-42">{title}</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;