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;
|
||||
|
||||
Reference in New Issue
Block a user