fix(canvas): 添加节点菜单 - 统一命令入口并修复文字溢出与分区拥挤
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
99
web/src/components/canvas/canvas-create-menu.tsx
Normal file
99
web/src/components/canvas/canvas-create-menu.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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: <Palette />, section: "project", defaultOrder: 10, applicable: (ctx) => !ctx.isProjectLinked, run: (ctx) => ctx.handlers.onChooseStyle() },
|
||||
// 创作节点
|
||||
{ id: "text", label: "文本", icon: <Type />, section: "node", defaultOrder: 10, run: (ctx) => ctx.handlers.onAddText() },
|
||||
{ id: "style", label: "项目画风", icon: <Palette />, section: "node", defaultOrder: 20, applicable: (ctx) => !ctx.isProjectLinked, run: (ctx) => ctx.handlers.onChooseStyle() },
|
||||
{ id: "drawing", label: "绘图", icon: <Pencil />, section: "node", defaultOrder: 20, run: (ctx) => ctx.handlers.onAddDrawing() },
|
||||
{ id: "script", label: "分镜脚本", icon: <Clapperboard />, badge: "核心", section: "node", defaultOrder: 30, run: (ctx) => ctx.handlers.onAddScript() },
|
||||
{ id: "frame", label: "背板", icon: <PanelTop />, section: "node", defaultOrder: 40, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddFrame() },
|
||||
{ id: "drawing", label: "绘图", icon: <Pencil />, section: "node", defaultOrder: 50, run: (ctx) => ctx.handlers.onAddDrawing() },
|
||||
{ id: "image", label: "图片", icon: <ImageIcon />, section: "node", defaultOrder: 60, run: (ctx) => ctx.handlers.onAddImage() },
|
||||
{ id: "video", label: "视频", icon: <Video />, section: "node", defaultOrder: 70, run: (ctx) => ctx.handlers.onAddVideo() },
|
||||
{ id: "director", label: "导演台", icon: <Layers3 />, badge: "3D", section: "node", defaultOrder: 80, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onOpenDirector() },
|
||||
{ id: "audio", label: "音频", icon: <Music2 />, section: "node", defaultOrder: 90, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddAudio() },
|
||||
{ id: "image", label: "图片", icon: <ImageIcon />, section: "node", defaultOrder: 50, run: (ctx) => ctx.handlers.onAddImage() },
|
||||
{ id: "video", label: "视频", icon: <Video />, section: "node", defaultOrder: 60, run: (ctx) => ctx.handlers.onAddVideo() },
|
||||
{ id: "director", label: "导演台", icon: <Layers3 />, badge: "3D", section: "node", defaultOrder: 70, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onOpenDirector() },
|
||||
{ id: "audio", label: "音频", icon: <Music2 />, section: "node", defaultOrder: 80, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddAudio() },
|
||||
// 导入资源
|
||||
{ id: "upload", label: "上传文件", icon: <UploadCloud />, section: "resource", defaultOrder: 10, run: (ctx) => ctx.handlers.onUpload() },
|
||||
{ id: "project-character", label: "添加角色卡", icon: <UserRound />, section: "resource", defaultOrder: 20, applicable: (ctx) => ctx.isProjectLinked, run: (ctx) => ctx.handlers.onOpenProjectCharacters() },
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -112,6 +112,26 @@ export type ToolContext = {
|
||||
handlers: ToolbarHandlers;
|
||||
};
|
||||
|
||||
/** 添加节点菜单只依赖创建动作,避免右键菜单为工具栏状态补无意义字段。 */
|
||||
export type AddNodeMenuContext = {
|
||||
workspaceMode: CanvasWorkspaceMode;
|
||||
isProjectLinked: boolean;
|
||||
handlers: Pick<ToolbarHandlers,
|
||||
| "onAddText"
|
||||
| "onAddImage"
|
||||
| "onAddVideo"
|
||||
| "onAddAudio"
|
||||
| "onAddScript"
|
||||
| "onAddFrame"
|
||||
| "onAddDrawing"
|
||||
| "onChooseStyle"
|
||||
| "onOpenDirector"
|
||||
| "onUpload"
|
||||
| "onOpenMyAssets"
|
||||
| "onOpenProjectCharacters"
|
||||
>;
|
||||
};
|
||||
|
||||
/** 工具定义——注册表的基本单元 */
|
||||
export type ToolDefinition = {
|
||||
id: string;
|
||||
@@ -137,17 +157,16 @@ export type ToolDefinition = {
|
||||
run: (ctx: ToolContext, event?: MouseEvent<HTMLElement>) => 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;
|
||||
};
|
||||
|
||||
/** 用户偏好——排序与显隐 */
|
||||
|
||||
@@ -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<ToolbarId, ToolDefinition[]>();
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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())}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user