diff --git a/web/src/components/canvas/canvas-context-menu.tsx b/web/src/components/canvas/canvas-context-menu.tsx
index b1dff8817..51d230c26 100644
--- a/web/src/components/canvas/canvas-context-menu.tsx
+++ b/web/src/components/canvas/canvas-context-menu.tsx
@@ -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: , onClick: () => onAddNode(CanvasNodeType.Text) },
- { id: "drawing", label: "绘图", icon: , onClick: () => onAddNode(CanvasNodeType.Drawing) },
- { id: "script", label: "分镜脚本", icon: , badge: "核心", onClick: () => onAddNode(CanvasNodeType.Script) },
- ...(!simpleMode ? [{ id: "frame", label: "背板", icon: , onClick: () => onAddNode(CanvasNodeType.Frame) }] : []),
- { id: "image", label: "图片", icon: , onClick: () => onAddNode(CanvasNodeType.Image) },
- { id: "video", label: "视频", icon: , onClick: () => onAddNode(CanvasNodeType.Video) },
- ...(!simpleMode ? [
- { id: "director", label: "导演台", icon: , badge: "3D", onClick: onOpenDirector },
- { id: "audio", label: "音频", icon: , onClick: () => onAddNode(CanvasNodeType.Audio) },
- ] : []),
- ];
- const resourceCommands: CanvasCreateCommand[] = [
- { id: "upload", label: "上传文件", icon: , onClick: onUpload },
- ...(isProjectLinked ? [{ id: "project-character", label: "添加角色卡", icon: , onClick: onOpenProjectCharacters }] : []),
- ...(!isProjectLinked ? [{ id: "assets", label: "素材库", icon: , 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 (
event.stopPropagation()}
>
-
-
-
-
-
-
-
+
);
}
diff --git a/web/src/components/canvas/canvas-create-command-grid.tsx b/web/src/components/canvas/canvas-create-command-grid.tsx
deleted file mode 100644
index 3284086ab..000000000
--- a/web/src/components/canvas/canvas-create-command-grid.tsx
+++ /dev/null
@@ -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 (
-
- {commands.map((command) => (
- event.stopPropagation()}
- onClick={command.onClick}
- >
- {variant === "node" ?
- {command.badge ? {command.badge} : null}
- : null}
-
- {command.icon}
- {command.label}
-
-
- ))}
-
- );
-}
diff --git a/web/src/components/canvas/canvas-create-menu.tsx b/web/src/components/canvas/canvas-create-menu.tsx
new file mode 100644
index 000000000..ebcb858a3
--- /dev/null
+++ b/web/src/components/canvas/canvas-create-menu.tsx
@@ -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 (
+
+
+ 添加节点
+ {projectCommands.map((command) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+}
+
+function CanvasCreateCommandGrid({ commands, variant }: { commands: CanvasCreateCommand[]; variant: "node" | "resource" }) {
+ const theme = canvasThemes[useThemeStore((state) => state.theme)];
+ const reducedMotion = useReducedMotion();
+
+ return (
+
+ {commands.map((command) => (
+ event.stopPropagation()}
+ onClick={command.onClick}
+ >
+ {variant === "node" ? (
+ <>
+
+ {command.icon}
+ {command.badge ? {command.badge} : null}
+
+ {command.label}
+ >
+ ) : (
+ <>
+ {command.icon}
+ {command.label}
+ >
+ )}
+
+ ))}
+
+ );
+}
+
+function MenuSection({ title, color, spaced = false }: { title: string; color: string; spaced?: boolean }) {
+ return {title}
;
+}
diff --git a/web/src/components/canvas/canvas-toolbar.tsx b/web/src/components/canvas/canvas-toolbar.tsx
index 6d282fa77..c3132f623 100644
--- a/web/src/components/canvas/canvas-toolbar.tsx
+++ b/web/src/components/canvas/canvas-toolbar.tsx
@@ -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 (
@@ -181,8 +181,7 @@ export function CanvasToolbar({
) : null}
@@ -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 (
event.stopPropagation()}>
- } title="创建内容" subtitle="选择节点类型" theme={theme} />
-
-
-
-
+
);
@@ -252,10 +246,6 @@ function PanelHeading({ icon, title, subtitle, theme }: { icon: ReactNode; title
);
}
-function MenuSection({ title }: { title: string }) {
- return
{title}
;
-}
-
function CanvasThemeButton({ colorTheme, targetTheme, onThemeChange, children }: { colorTheme: CanvasColorTheme; targetTheme: CanvasColorTheme; onThemeChange: (theme: CanvasColorTheme) => void; children: ReactNode }) {
const theme = canvasThemes[colorTheme];
const active = colorTheme === targetTheme;
diff --git a/web/src/lib/canvas/tool-registry/definitions/add-node-menu-tools.tsx b/web/src/lib/canvas/tool-registry/definitions/add-node-menu-tools.tsx
index ec25b5196..86e459391 100644
--- a/web/src/lib/canvas/tool-registry/definitions/add-node-menu-tools.tsx
+++ b/web/src/lib/canvas/tool-registry/definitions/add-node-menu-tools.tsx
@@ -3,16 +3,17 @@ import { Clapperboard, FolderOpen, Image as ImageIcon, Layers3, Music2, Palette,
import { registerAddNodeMenuCommands, type AddNodeMenuCommand } from "@/lib/canvas/tool-registry";
export const addNodeMenuCommands: AddNodeMenuCommand[] = [
+ // 项目级动作不占用节点网格,保证 8 个节点稳定保持四列两行。
+ { id: "style", label: "项目画风", icon:
, section: "project", defaultOrder: 10, applicable: (ctx) => !ctx.isProjectLinked, run: (ctx) => ctx.handlers.onChooseStyle() },
// 创作节点
{ id: "text", label: "文本", icon:
, section: "node", defaultOrder: 10, run: (ctx) => ctx.handlers.onAddText() },
- { id: "style", label: "项目画风", icon:
, section: "node", defaultOrder: 20, applicable: (ctx) => !ctx.isProjectLinked, run: (ctx) => ctx.handlers.onChooseStyle() },
+ { id: "drawing", label: "绘图", icon:
, section: "node", defaultOrder: 20, run: (ctx) => ctx.handlers.onAddDrawing() },
{ id: "script", label: "分镜脚本", icon:
, badge: "核心", section: "node", defaultOrder: 30, run: (ctx) => ctx.handlers.onAddScript() },
{ id: "frame", label: "背板", icon:
, section: "node", defaultOrder: 40, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddFrame() },
- { id: "drawing", label: "绘图", icon:
, section: "node", defaultOrder: 50, run: (ctx) => ctx.handlers.onAddDrawing() },
- { id: "image", label: "图片", icon:
, section: "node", defaultOrder: 60, run: (ctx) => ctx.handlers.onAddImage() },
- { id: "video", label: "视频", icon:
, section: "node", defaultOrder: 70, run: (ctx) => ctx.handlers.onAddVideo() },
- { id: "director", label: "导演台", icon:
, badge: "3D", section: "node", defaultOrder: 80, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onOpenDirector() },
- { id: "audio", label: "音频", icon:
, section: "node", defaultOrder: 90, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddAudio() },
+ { id: "image", label: "图片", icon:
, section: "node", defaultOrder: 50, run: (ctx) => ctx.handlers.onAddImage() },
+ { id: "video", label: "视频", icon:
, section: "node", defaultOrder: 60, run: (ctx) => ctx.handlers.onAddVideo() },
+ { id: "director", label: "导演台", icon:
, badge: "3D", section: "node", defaultOrder: 70, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onOpenDirector() },
+ { id: "audio", label: "音频", icon:
, section: "node", defaultOrder: 80, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddAudio() },
// 导入资源
{ id: "upload", label: "上传文件", icon:
, section: "resource", defaultOrder: 10, run: (ctx) => ctx.handlers.onUpload() },
{ id: "project-character", label: "添加角色卡", icon:
, section: "resource", defaultOrder: 20, applicable: (ctx) => ctx.isProjectLinked, run: (ctx) => ctx.handlers.onOpenProjectCharacters() },
diff --git a/web/src/lib/canvas/tool-registry/index.ts b/web/src/lib/canvas/tool-registry/index.ts
index 3d1af39b3..e718ab5f2 100644
--- a/web/src/lib/canvas/tool-registry/index.ts
+++ b/web/src/lib/canvas/tool-registry/index.ts
@@ -1,4 +1,4 @@
-export type { AddNodeMenuCommand, ToolbarHandlers, ToolbarId, ToolbarPrefs, ToolCategory, ToolContext, ToolDefinition } from "./tool-definition";
+export type { AddNodeMenuCommand, AddNodeMenuContext, ToolbarHandlers, ToolbarId, ToolbarPrefs, ToolCategory, ToolContext, ToolDefinition } from "./tool-definition";
export { clearToolbarPrefs, persistToolbarPrefs, readToolbarPrefs } from "./tool-persistence";
export { defaultToolbarPrefs, getAddNodeMenuCommands, getToolbarTools, registerAddNodeMenuCommands, registerToolbarTools, resolveAddNodeMenuCommands, resolveToolbarEntries, resolveToolbarTools } from "./tool-registry";
import "./definitions";
diff --git a/web/src/lib/canvas/tool-registry/tool-definition.ts b/web/src/lib/canvas/tool-registry/tool-definition.ts
index 1c0061d4c..ea5ec23e3 100644
--- a/web/src/lib/canvas/tool-registry/tool-definition.ts
+++ b/web/src/lib/canvas/tool-registry/tool-definition.ts
@@ -112,6 +112,26 @@ export type ToolContext = {
handlers: ToolbarHandlers;
};
+/** 添加节点菜单只依赖创建动作,避免右键菜单为工具栏状态补无意义字段。 */
+export type AddNodeMenuContext = {
+ workspaceMode: CanvasWorkspaceMode;
+ isProjectLinked: boolean;
+ handlers: Pick
;
+};
+
/** 工具定义——注册表的基本单元 */
export type ToolDefinition = {
id: string;
@@ -137,17 +157,16 @@ export type ToolDefinition = {
run: (ctx: ToolContext, event?: MouseEvent) => void;
};
-/** 添加节点菜单命令(复用 CanvasCreateCommand 结构 + 上下文谓词) */
+/** 添加节点菜单命令:项目级动作与真正的节点创建分开呈现。 */
export type AddNodeMenuCommand = {
id: string;
label: string;
icon: ReactNode;
badge?: string;
- /** 菜单分区:创作节点 / 导入资源 */
- section: "node" | "resource";
+ section: "node" | "project" | "resource";
defaultOrder: number;
- applicable?: (ctx: ToolContext) => boolean;
- run: (ctx: ToolContext) => void;
+ applicable?: (ctx: AddNodeMenuContext) => boolean;
+ run: (ctx: AddNodeMenuContext) => void;
};
/** 用户偏好——排序与显隐 */
diff --git a/web/src/lib/canvas/tool-registry/tool-registry.ts b/web/src/lib/canvas/tool-registry/tool-registry.ts
index 8ce06616d..d1a2c23d9 100644
--- a/web/src/lib/canvas/tool-registry/tool-registry.ts
+++ b/web/src/lib/canvas/tool-registry/tool-registry.ts
@@ -1,6 +1,6 @@
import type { FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
-import type { AddNodeMenuCommand, ToolCategory, ToolContext, ToolDefinition, ToolbarId, ToolbarPrefs } from "./tool-definition";
+import type { AddNodeMenuCommand, AddNodeMenuContext, ToolCategory, ToolContext, ToolDefinition, ToolbarId, ToolbarPrefs } from "./tool-definition";
/** 模块级注册表 */
const registry = new Map();
@@ -67,7 +67,7 @@ export function resolveToolbarTools(toolbar: ToolbarId, ctx: ToolContext, prefs:
}
/** 解析添加节点菜单命令——仅 applicable 过滤,不参与排序/显隐 */
-export function resolveAddNodeMenuCommands(ctx: ToolContext): AddNodeMenuCommand[] {
+export function resolveAddNodeMenuCommands(ctx: AddNodeMenuContext): AddNodeMenuCommand[] {
return getAddNodeMenuCommands().filter((command) => !command.applicable || command.applicable(ctx));
}
diff --git a/web/src/pages/canvas/canvas-project-context-menu.tsx b/web/src/pages/canvas/canvas-project-context-menu.tsx
index 320f2cd17..3563ee4df 100644
--- a/web/src/pages/canvas/canvas-project-context-menu.tsx
+++ b/web/src/pages/canvas/canvas-project-context-menu.tsx
@@ -14,6 +14,7 @@ type CanvasProjectContextMenuProps = {
screenToCanvas: (clientX: number, clientY: number) => Position;
onClose: () => void;
onAddNode: (type: CanvasNodeType, position: Position) => void;
+ onChooseStyle: () => void;
onOpenDirector: (position?: Position) => void;
onUpload: (nodeId: string | undefined, position: Position) => void;
onOpenAssets: (position: Position) => void;
@@ -52,6 +53,7 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props
onAddNode={(type) => {
if (menu.type === "canvas") props.onAddNode(type, menu.position);
}}
+ onChooseStyle={props.onChooseStyle}
onOpenDirector={props.onOpenDirector}
onUpload={() => props.onUpload(menu.type === "node" ? menu.nodeId : undefined, menuPosition())}
onOpenAssets={() => props.onOpenAssets(menuPosition())}
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index ef5d93c09..33ad22d7a 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -1662,6 +1662,7 @@ function InfiniteCanvasPage() {
screenToCanvas={screenToCanvas}
onClose={() => setContextMenu(null)}
onAddNode={(type, position) => createNode(type, position)}
+ onChooseStyle={() => setStylePickerOpen(true)}
onOpenDirector={createDirectorShot}
onUpload={(nodeId, position) => handleUploadRequest(nodeId, position)}
onOpenAssets={openAssetsAtPosition}
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index b7335fe01..4ef6e1d23 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -486,9 +486,12 @@
--dock-item-radius: var(--r-md); /* 8px */
--dock-item-radius-labeled: var(--r-lg); /* 12px */
--dock-precision-width: var(--space-12);
- --dock-create-node-height: calc(var(--space-12) + var(--space-2));
--dock-z-popover: 30; /* dock 子面板在 toolbar 根 stacking context 内的层级(见 ADR-001)*/
+ /* Canvas 创建菜单:固定行高保证图标、徽标和单行标签互不挤占 */
+ --canvas-create-node-height: var(--space-12);
+ --canvas-create-resource-height: var(--space-8);
+
/* Node 组件(画布节点,对应 #97 节点视觉规范)*/
--node-radius: var(--r-2xl); /* 16px */
--node-border-width: var(--stroke-1); /* 1px */