feat(canvas): 工具栏统一化 — 注册表驱动+上下文感知+自定义排序+危险隔离 #95 (#105)

将三个硬编码工具栏(主工具栏/多选工具栏/节点悬停工具栏)统一改造为注册表驱动体系:

- 新增 tool-registry 核心基础设施(ToolDefinition/ToolContext/ToolbarHandlers 类型、
  注册表解析器 resolveToolbarEntries/resolveToolbarTools、偏好持久化 readToolbarPrefs/persistToolbarPrefs)
- 提取四个工具定义文件(main/selection/node-hover/add-node-menu),通过 applicable 谓词实现上下文感知
- FloatingDock 新增 expands 字段(aria-expanded 语义修正)和 danger-group 分组渲染(危险操作视觉隔离)
- 主工具栏新增工具栏设置面板(ToolbarSettingsModal),支持拖拽排序+显隐控制(motion/react Reorder,零新增依赖)
- 偏好持久化到 scopedLocalStorage,按工具栏独立存储

Co-authored-by: sikongyue <3155157671@qq.com>
This commit is contained in:
爱笑的毛毛虫
2026-08-03 23:22:44 +08:00
committed by GitHub
parent acaa4bb69f
commit 0c4c258a52
15 changed files with 1112 additions and 112 deletions

View File

@@ -1,9 +1,10 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
import { App, Button, Dropdown, Input, Modal, Segmented, Tag } from "antd";
import { Download, Ellipsis, FolderPlus, GalleryHorizontalEnd, Image as ImageIcon, Info, LoaderCircle, Lock, Maximize2, MessageSquare, Minus, Music2, Plus, RefreshCw, Settings2, Trash2, Unlock, Upload, UserRound, Video } from "lucide-react";
import { Ellipsis, Lock, Plus, Settings2, Unlock } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { defaultToolbarPrefs, readToolbarPrefs, resolveToolbarTools, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry";
import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset";
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
@@ -232,28 +233,55 @@ export function CanvasNodeHoverToolbar({
setImageToolSettingsOpen(true);
}
const baseToolbarTools: ToolbarTool[] = [
{ id: "info", title: isCharacterReference ? "查看角色详情" : "查看节点信息", label: isCharacterReference ? "角色详情" : "信息", icon: isCharacterReference ? <UserRound className="size-3.5" /> : <Info className="size-3.5" />, onClick: () => onInfo(node) },
{ id: "delete", title: "移除节点", label: "删除", icon: <Trash2 className="size-3.5" />, onClick: () => onDelete(node), danger: true },
];
const nodeToolbarTools: ToolbarTool[] = [
...(canRetry ? [{ id: "retry", title: "重新生成", label: "重试", icon: <RefreshCw className="size-3.5" />, onClick: () => onRetry(node) }] : []),
...(hasVideo && !simpleMode ? [{ id: "extractLastFrame", title: extractingVideoFrame ? "正在截取尾帧" : "截取尾帧", label: extractingVideoFrame ? "截取中" : "尾帧", icon: extractingVideoFrame ? <LoaderCircle className="size-3.5 animate-spin" /> : <GalleryHorizontalEnd className="size-3.5" />, onClick: () => onExtractVideoLastFrame(node), disabled: extractingVideoFrame }] : []),
...(hasImage || hasVideo || isEditableText ? [{ id: "saveAsset", title: "加入我的素材", label: "存素材", icon: <FolderPlus className="size-3.5" />, onClick: () => onSaveAsset(node) }] : []),
...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片", label: "下载", icon: <Download className="size-3.5" />, onClick: () => onDownload(node) }] : []),
...(canOpenDialog ? [{ id: "edit", title: isEditableText ? "调用文本模型生成内容" : "编辑", label: isEditableText ? "文本生成" : "编辑", icon: <MessageSquare className="size-3.5" />, onClick: () => onToggleDialog(node) }] : []),
...(isEditableText ? [{ id: "editText", title: "放大编辑文本", label: "放大编辑", icon: <Maximize2 className="size-3.5" />, onClick: () => onEditText(node) }] : []),
...(isEditableText ? [{ id: "generateImage", title: "用文本生图", label: "生图", icon: <ImageIcon className="size-3.5" />, onClick: () => onGenerateImage(node) }] : []),
...(isConfig && !simpleMode ? [{ id: "config", title: "生成配置", label: "生成配置", icon: <Settings2 className="size-3.5" />, onClick: () => onToggleDialog(node) }] : []),
...(isEditableText && !simpleMode ? [{ id: "decreaseFont", title: "减小字号", label: "缩小", icon: <Minus className="size-3.5" />, onClick: () => onDecreaseFont(node) }] : []),
...(isEditableText && !simpleMode ? [{ id: "increaseFont", title: "增大字号", label: "放大", icon: <Plus className="size-3.5" />, onClick: () => onIncreaseFont(node) }] : []),
...(isImage && !hasImage ? [{ id: "uploadImage", title: "上传图片", label: "上传图片", icon: <Upload className="size-3.5" />, onClick: () => onUpload(node) }] : []),
...(isVideo ? [{ id: "uploadVideo", title: hasVideo ? "替换视频" : "上传视频", label: hasVideo ? "替换视频" : "上传视频", icon: <Video className="size-3.5" />, onClick: () => onUpload(node) }] : []),
...(isAudio ? [{ id: "uploadAudio", title: hasAudio ? "替换音频" : "上传音频", label: hasAudio ? "替换音频" : "上传音频", icon: <Music2 className="size-3.5" />, onClick: () => onUpload(node) }] : []),
...(hasImage && !simpleMode ? imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, onClick: tool.onClick })) : []),
];
const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools];
const selectableImageToolbarTools = [...baseToolbarTools, ...nodeToolbarTools].filter((tool): tool is ToolbarTool & { id: ImageQuickToolId } => isImageQuickToolId(tool.id));
// 构建 ToolContext——供注册表解析工具
const nodeHoverHandlers = {
onNodeInfo: onInfo, onNodeDelete: onDelete, onNodeRetry: onRetry, onNodeEditText: onEditText, onNodeDecreaseFont: onDecreaseFont, onNodeIncreaseFont: onIncreaseFont,
onNodeToggleDialog: onToggleDialog, onNodeAnnotate: onAnnotate, onNodeGenerateImage: onGenerateImage, onNodeUpload: onUpload, onNodeDownload: onDownload,
onNodeSaveAsset: onSaveAsset, onNodeMaskEdit: onMaskEdit, onNodeEmotion: onEmotion, onNodePortraitTexture: onPortraitTexture, onNodeCrop: onCrop,
onNodeSplit: onSplit, onNodeUpscale: onUpscale, onNodeSuperResolve: onSuperResolve, onNodeAngle: onAngle, onNodeViewImage: onViewImage,
onNodeExtractVideoLastFrame: onExtractVideoLastFrame, onNodeReversePrompt: onReversePrompt, onNodeToggleFreeResize: onToggleFreeResize,
onNodeToggleLocked: onToggleLocked, onNodeCopyPrompt: copyImagePrompt,
} as Partial<ToolbarHandlers> as ToolbarHandlers;
const nodeHoverCtx: ToolContext = {
selectedCount: 0,
selectedNodeTypes: new Set(),
selectedVideoCount: 0,
canvasTool: "move",
workspaceMode: workspaceMode || "professional",
isProjectLinked: false,
canUndo: false,
canRedo: false,
node,
nodeMetadata: node.metadata,
extractingVideoFrame,
mergingVideos: false,
addPanelOpen: false,
appearancePanelOpen: false,
settingsPanelOpen: false,
handlers: nodeHoverHandlers,
};
// 从注册表解析工具(已按 applicable 过滤、用户偏好排序)
const registryTools = resolveToolbarTools("node-hover", nodeHoverCtx, readToolbarPrefs("node-hover") ?? defaultToolbarPrefs("node-hover"));
// 分离 node-lock(始终显示,不参与 quickImageToolIds 过滤)
const otherRegistryTools = registryTools.filter((tool) => tool.id !== "node-lock");
// 转为 ToolbarTool 供组件内部逻辑使用
const otherTools: ToolbarTool[] = otherRegistryTools.map((tool) => ({
id: tool.id,
title: typeof tool.label === "function" ? tool.label(nodeHoverCtx) : tool.label,
label: tool.displayLabel ? (typeof tool.displayLabel === "function" ? tool.displayLabel(nodeHoverCtx) : tool.displayLabel) : (typeof tool.label === "function" ? tool.label(nodeHoverCtx) : tool.label),
icon: typeof tool.icon === "function" ? tool.icon(nodeHoverCtx) : tool.icon,
active: tool.active?.(nodeHoverCtx),
danger: tool.danger,
disabled: tool.disabled?.(nodeHoverCtx),
onClick: () => tool.run(nodeHoverCtx),
}));
// 合并图片工具(当 hasImage && !simpleMode 时追加到 otherTools 末尾)
const allTools: ToolbarTool[] = hasImage && !simpleMode ? [...otherTools, ...imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, danger: undefined, disabled: undefined, onClick: tool.onClick }))] : otherTools;
// hasImage 时按 quickImageToolIds 过滤
const toolbarTools = hasImage ? allTools.filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : allTools;
const selectableImageToolbarTools = allTools.filter((tool): tool is ToolbarTool & { id: ImageQuickToolId } => isImageQuickToolId(tool.id));
const temporaryImageToolbarTools = selectableImageToolbarTools.filter((tool) => !quickImageToolIdSet.has(tool.id));
const dockItems: FloatingDockEntry[] = [
...toolbarTools.map((tool) => ({ id: tool.id, label: tool.title, displayLabel: tool.label, icon: tool.icon, active: tool.active, danger: tool.danger, disabled: tool.disabled, onClick: () => tool.onClick() })),

View File

@@ -1,15 +1,17 @@
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, Clapperboard, Eraser, FolderOpen, Grid2x2, Hand, Image as ImageIcon, Info, Layers3, Moon, Music2, Palette, PanelTop, Pencil, Plus, Redo2, Square, SquareDashedMousePointer, Sun, Trash2, Type, Undo2, UploadCloud, UserRound, Video, X } from "lucide-react";
import { CircleDot, Grid2x2, Moon, Plus, Palette, Sun, Square, Info } from "lucide-react";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
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 { ToolbarSettingsModal } from "@/components/canvas/toolbars/toolbar-settings-modal";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
import { defaultToolbarPrefs, readToolbarPrefs, resolveAddNodeMenuCommands, resolveToolbarEntries, type AddNodeMenuCommand, type ToolContext, type ToolbarHandlers, type ToolbarPrefs } from "@/lib/canvas/tool-registry";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas";
@@ -79,7 +81,14 @@ export function CanvasToolbar({
const theme = canvasThemes[colorTheme];
const [addOpen, setAddOpen] = useState(false);
const [appearanceOpen, setAppearanceOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [panelX, setPanelX] = useState(0);
const [prefs, setPrefs] = useState<ToolbarPrefs | null>(() => readToolbarPrefs("main"));
// 设置面板关闭后重新读取偏好(用户可能调整了排序/显隐)
useEffect(() => {
if (!settingsOpen) setPrefs(readToolbarPrefs("main"));
}, [settingsOpen]);
const placePanel = (event: ReactMouseEvent<HTMLElement>) => setPanelX(getPanelX(dockRef.current, event.currentTarget));
const runAddAction = (action: () => void) => {
@@ -87,6 +96,7 @@ export function CanvasToolbar({
setAddOpen(false);
};
// 点击外部关闭浮层面板
useEffect(() => {
if (!addOpen && !appearanceOpen) return;
const closeFloatingPanels = (event: PointerEvent) => {
@@ -99,19 +109,70 @@ export function CanvasToolbar({
return () => document.removeEventListener("pointerdown", closeFloatingPanels, true);
}, [addOpen, appearanceOpen]);
const items: FloatingDockEntry[] = [
{ id: canvasTool === "box-select" ? "tool-move" : selectedCount ? "tool-close-selection" : "tool-move", label: canvasTool === "box-select" ? "移动与选择" : selectedCount ? `取消选择${selectedCount > 1 ? ` ${selectedCount} 个节点` : ""}` : "移动与选择", icon: canvasTool === "box-select" ? <Hand /> : selectedCount ? <X /> : <Hand />, active: canvasTool === "move", onClick: () => { if (canvasTool !== "move") onToolChange("move"); else onDeselect(); } },
{ id: "tool-box-select", label: "框选", icon: <SquareDashedMousePointer />, active: canvasTool === "box-select", onClick: () => onToolChange(canvasTool === "box-select" ? "move" : "box-select") },
{ kind: "separator", id: "history-separator" },
{ id: "tool-undo", label: "撤销", icon: <Undo2 />, disabled: !canUndo, onClick: () => onUndo() },
{ id: "tool-redo", label: "重做", icon: <Redo2 />, disabled: !canRedo, onClick: () => onRedo() },
{ kind: "separator", id: "create-separator" },
{ id: "tool-add", label: "添加节点", icon: <Plus />, active: addOpen, onClick: (event) => { placePanel(event); setAppearanceOpen(false); setAddOpen((value) => !value); } },
...(!isProjectLinked ? [{ id: "tool-assets", label: "素材库", icon: <FolderOpen />, onClick: () => onOpenMyAssets() }] : []),
{ id: "tool-style", label: "画布外观", icon: <Palette />, active: appearanceOpen, onClick: (event) => { placePanel(event); setAddOpen(false); setAppearanceOpen((value) => !value); } },
...(selectedCount ? [{ kind: "separator" as const, id: "selection-separator" }, { id: "tool-delete", label: selectedCount > 1 ? `删除 ${selectedCount} 个节点` : "删除选中节点", icon: <Trash2 />, danger: true, onClick: () => onDelete() }] : []),
{ id: "tool-clear", label: "清空画布", icon: <Eraser />, danger: true, onClick: () => onClear() },
];
// 构建 handlers(主工具栏只需要部分回调,其余用 no-op 占位满足类型)
const handlers: ToolbarHandlers = {
onToolChange,
onDeselect,
onUndo,
onRedo,
onClear,
onAddText,
onAddImage,
onAddVideo,
onAddAudio,
onAddScript,
onAddFrame,
onAddDrawing,
onChooseStyle,
onOpenDirector,
onUpload,
onOpenMyAssets,
onOpenProjectCharacters,
onBackgroundModeChange,
onShowImageInfoChange,
onToggleAddPanel: (event: ReactMouseEvent<HTMLElement>) => { placePanel(event); setAppearanceOpen(false); setSettingsOpen(false); setAddOpen((value) => !value); },
onToggleAppearancePanel: (event: ReactMouseEvent<HTMLElement>) => { placePanel(event); setAddOpen(false); setSettingsOpen(false); setAppearanceOpen((value) => !value); },
onToggleSettingsPanel: () => { setAddOpen(false); setAppearanceOpen(false); setSettingsOpen((value) => !value); },
onDeleteSelected: onDelete,
// 以下为多选/节点悬停工具栏回调,主工具栏不使用,用 no-op 占位
onAlign: () => {}, onArrange: () => {}, onCreateStoryboard: () => {}, onCreateReferenceGroup: () => {}, onMergeVideos: () => {},
onNodeInfo: () => {}, onNodeDelete: () => {}, onNodeRetry: () => {}, onNodeEditText: () => {}, onNodeDecreaseFont: () => {}, onNodeIncreaseFont: () => {},
onNodeToggleDialog: () => {}, onNodeAnnotate: () => {}, onNodeGenerateImage: () => {}, onNodeUpload: () => {}, onNodeDownload: () => {}, onNodeSaveAsset: () => {},
onNodeMaskEdit: () => {}, onNodeEmotion: () => {}, onNodePortraitTexture: () => {}, onNodeCrop: () => {}, onNodeSplit: () => {}, onNodeUpscale: () => {},
onNodeSuperResolve: () => {}, onNodeAngle: () => {}, onNodeViewImage: () => {}, onNodeExtractVideoLastFrame: () => {}, onNodeReversePrompt: () => {},
onNodeToggleFreeResize: () => {}, onNodeToggleLocked: () => {}, onNodeCopyPrompt: () => {},
} as ToolbarHandlers;
const ctx: ToolContext = {
selectedCount,
selectedNodeTypes: new Set(),
selectedVideoCount: 0,
canvasTool,
workspaceMode,
isProjectLinked,
canUndo,
canRedo,
extractingVideoFrame: false,
mergingVideos: false,
addPanelOpen: addOpen,
appearancePanelOpen: appearanceOpen,
settingsPanelOpen: settingsOpen,
handlers,
};
const items = resolveToolbarEntries("main", ctx, prefs ?? defaultToolbarPrefs("main"));
// 解析添加节点菜单命令——onClick 绑定到 runAddAction 以在执行后关闭面板
const addNodeCommands = resolveAddNodeMenuCommands(ctx);
const toCommand = (cmd: AddNodeMenuCommand): CanvasCreateCommand => ({
id: cmd.id,
label: cmd.label,
icon: cmd.icon,
badge: cmd.badge,
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);
return (
<div ref={rootRef} data-canvas-no-zoom className="pointer-events-none absolute inset-x-4 bottom-4 z-50 flex justify-center">
@@ -120,20 +181,8 @@ export function CanvasToolbar({
<AddNodeMenu
x={panelX}
theme={theme}
workspaceMode={workspaceMode}
isProjectLinked={isProjectLinked}
onAddText={() => runAddAction(onAddText)}
onChooseStyle={() => runAddAction(onChooseStyle)}
onAddScript={() => runAddAction(onAddScript)}
onAddFrame={() => runAddAction(onAddFrame)}
onAddDrawing={() => runAddAction(onAddDrawing)}
onAddImage={() => runAddAction(onAddImage)}
onAddVideo={() => runAddAction(onAddVideo)}
onAddAudio={() => runAddAction(onAddAudio)}
onOpenDirector={() => runAddAction(onOpenDirector)}
onUpload={() => runAddAction(onUpload)}
onOpenAssets={() => runAddAction(onOpenMyAssets)}
onOpenProjectCharacters={() => runAddAction(onOpenProjectCharacters)}
nodeCommands={nodeCommands}
resourceCommands={resourceCommands}
/>
) : null}
</AnimatePresence>
@@ -169,49 +218,18 @@ export function CanvasToolbar({
</motion.div>
) : null}
</AnimatePresence>
<ToolbarSettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} toolbar="main" />
</div>
);
}
function AddNodeMenu({ x, theme, workspaceMode, isProjectLinked, onAddText, onChooseStyle, onAddScript, onAddFrame, onAddDrawing, onAddImage, onAddVideo, onAddAudio, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: {
function AddNodeMenu({ x, theme, nodeCommands, resourceCommands }: {
x: number;
theme: CanvasTheme;
workspaceMode: CanvasWorkspaceMode;
isProjectLinked: boolean;
onAddText: () => void;
onChooseStyle: () => void;
onAddScript: () => void;
onAddFrame: () => void;
onAddDrawing: () => void;
onAddImage: () => void;
onAddVideo: () => void;
onAddAudio: () => void;
onOpenDirector: () => void;
onUpload: () => void;
onOpenAssets: () => void;
onOpenProjectCharacters: () => void;
nodeCommands: CanvasCreateCommand[];
resourceCommands: CanvasCreateCommand[];
}) {
const simpleMode = workspaceMode === "simple";
const nodeCommands: CanvasCreateCommand[] = [
{ id: "text", label: "文本", icon: <Type />, onClick: onAddText },
...(!isProjectLinked ? [
{ id: "style", label: "项目画风", icon: <Palette />, onClick: onChooseStyle },
] : []),
{ id: "script", label: "分镜脚本", icon: <Clapperboard />, badge: "核心", onClick: onAddScript },
...(!simpleMode ? [{ id: "frame", label: "背板", icon: <PanelTop />, onClick: onAddFrame }] : []),
{ id: "drawing", label: "绘图", icon: <Pencil />, onClick: onAddDrawing },
{ id: "image", label: "图片", icon: <ImageIcon />, onClick: onAddImage },
{ id: "video", label: "视频", icon: <Video />, onClick: onAddVideo },
...(!simpleMode ? [
{ id: "director", label: "导演台", icon: <Layers3 />, badge: "3D", onClick: onOpenDirector },
{ id: "audio", label: "音频", icon: <Music2 />, onClick: onAddAudio },
] : []),
];
const resourceCommands: CanvasCreateCommand[] = [
{ id: "upload", label: "上传文件", icon: <UploadCloud />, onClick: onUpload },
...(isProjectLinked ? [{ id: "project-character", label: "添加角色卡", icon: <UserRound />, onClick: onOpenProjectCharacters }] : []),
...(!isProjectLinked ? [{ id: "assets", label: "素材库", icon: <FolderOpen />, onClick: onOpenAssets }] : []),
];
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: aceternityMotion.duration.instant }} className="pointer-events-auto absolute bottom-[50px] z-40 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()}>

View File

@@ -0,0 +1,159 @@
import { Modal, Switch } from "antd";
import { GripVertical, RotateCcw } from "lucide-react";
import { Reorder, useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import { canvasThemes } from "@/lib/canvas-theme";
import { defaultToolbarPrefs, getToolbarTools, persistToolbarPrefs, readToolbarPrefs, type ToolbarId, type ToolbarPrefs, type ToolContext, type ToolDefinition } from "@/lib/canvas/tool-registry";
import { useThemeStore } from "@/stores/use-theme-store";
type ToolbarSettingsModalProps = {
open: boolean;
onClose: () => void;
toolbar: ToolbarId;
};
/** 设置面板用的最小化上下文——仅用于解析工具的 label/icon */
const settingsMockContext: ToolContext = {
selectedCount: 0,
selectedNodeTypes: new Set(),
selectedVideoCount: 0,
canvasTool: "move",
workspaceMode: "professional",
isProjectLinked: false,
canUndo: false,
canRedo: false,
extractingVideoFrame: false,
mergingVideos: false,
addPanelOpen: false,
appearancePanelOpen: false,
settingsPanelOpen: false,
handlers: {} as ToolContext["handlers"],
};
type SettingsItem = {
id: string;
label: string;
icon: React.ReactNode;
visible: boolean;
};
export function ToolbarSettingsModal({ open, onClose, toolbar }: ToolbarSettingsModalProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
const [items, setItems] = useState<SettingsItem[]>([]);
const [toolbarId, setToolbarId] = useState<ToolbarId>(toolbar);
// 当 modal 打开或 toolbar 变化时,加载工具列表与偏好
useEffect(() => {
if (!open) return;
setToolbarId(toolbar);
const tools = getToolbarTools(toolbar);
const prefs = readToolbarPrefs(toolbar) ?? defaultToolbarPrefs(toolbar);
const hiddenSet = new Set(prefs.hidden);
const orderIndex = new Map(prefs.order.map((id, index) => [id, index]));
const sorted = [...tools].sort((a, b) => {
const ai = orderIndex.has(a.id) ? orderIndex.get(a.id)! : Number.MAX_SAFE_INTEGER;
const bi = orderIndex.has(b.id) ? orderIndex.get(b.id)! : Number.MAX_SAFE_INTEGER;
if (ai !== bi) return ai - bi;
return a.defaultOrder - b.defaultOrder;
});
setItems(sorted.map((tool) => ({
id: tool.id,
label: resolveLabel(tool, settingsMockContext),
icon: resolveIcon(tool, settingsMockContext),
visible: !hiddenSet.has(tool.id),
})));
}, [open, toolbar]);
const handleReorder = (newOrder: SettingsItem[]) => {
setItems(newOrder);
persistCurrent(newOrder);
};
const handleToggleVisible = (id: string, visible: boolean) => {
setItems((prev) => {
const next = prev.map((item) => item.id === id ? { ...item, visible } : item);
persistCurrent(next);
return next;
});
};
const handleReset = () => {
const defaults = defaultToolbarPrefs(toolbarId);
const tools = getToolbarTools(toolbarId);
const hiddenSet = new Set(defaults.hidden);
setItems(tools.map((tool) => ({
id: tool.id,
label: resolveLabel(tool, settingsMockContext),
icon: resolveIcon(tool, settingsMockContext),
visible: !hiddenSet.has(tool.id),
})));
persistToolbarPrefs(toolbarId, defaults);
};
const persistCurrent = (currentItems: SettingsItem[]) => {
const prefs: ToolbarPrefs = {
order: currentItems.map((item) => item.id),
hidden: currentItems.filter((item) => !item.visible).map((item) => item.id),
};
persistToolbarPrefs(toolbarId, prefs);
};
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
title="工具栏设置"
width={360}
centered
destroyOnClose
styles={{ body: { padding: 0 } }}
>
<div className="flex items-center justify-between px-4 pb-2 pt-1">
<span className="text-[var(--fs-label)]" style={{ color: theme.node.muted }}>拖拽排序 · 开关控制显隐</span>
<button
type="button"
onClick={handleReset}
className="inline-flex items-center gap-1 rounded-[var(--dock-item-radius)] px-2 py-1 text-[var(--fs-tiny)] font-semibold transition-colors hover:bg-current/5"
style={{ color: theme.node.muted }}
>
<RotateCcw className="size-3" />
恢复默认
</button>
</div>
<Reorder.Group
axis="y"
values={items}
onReorder={handleReorder}
className="thin-scrollbar max-h-[60vh] space-y-1 overflow-y-auto px-3 pb-4"
>
{items.map((item) => (
<Reorder.Item
key={item.id}
value={item}
initial={false}
animate={reducedMotion ? undefined : { opacity: 1 }}
dragTransition={reducedMotion ? undefined : { bounceStiffness: 600, bounceDamping: 40 }}
className="flex items-center gap-2 rounded-[var(--dock-item-radius)] border px-2.5 py-2"
style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border, color: theme.node.text }}
>
<GripVertical className="size-4 shrink-0 cursor-grab opacity-40 active:cursor-grabbing" />
<span className="grid size-5 shrink-0 place-items-center opacity-70 [&_svg]:size-3.5">{item.icon}</span>
<span className="min-w-0 flex-1 truncate text-[var(--fs-label)] font-medium">{item.label}</span>
<Switch size="small" checked={item.visible} onChange={(checked) => handleToggleVisible(item.id, checked)} />
</Reorder.Item>
))}
</Reorder.Group>
</Modal>
);
}
function resolveLabel(tool: ToolDefinition, ctx: ToolContext): string {
return typeof tool.label === "function" ? tool.label(ctx) : tool.label;
}
function resolveIcon(tool: ToolDefinition, ctx: ToolContext): React.ReactNode {
return typeof tool.icon === "function" ? tool.icon(ctx) : tool.icon;
}

View File

@@ -14,6 +14,8 @@ export type FloatingDockCommand = {
active?: boolean;
disabled?: boolean;
danger?: boolean;
/** 面板展开型工具——使用 aria-expanded 而非 aria-pressed */
expands?: boolean;
};
export type FloatingDockEntry = FloatingDockCommand | { kind: "separator"; id: string };
@@ -90,11 +92,60 @@ export const FloatingDock = forwardRef<HTMLDivElement, FloatingDockProps>(functi
}}
onPointerLeave={() => mouseX.set(Number.POSITIVE_INFINITY)}
>
{items.map((item) => item.kind === "separator" ? <DockSeparator key={item.id} compact={size === "compact"} labeled={showLabels} /> : <DockCommandButton key={item.id} command={item} mouseX={mouseX} metrics={metrics} motionEnabled={motionEnabled && !showLabels} compact={size === "compact"} showLabel={showLabels} />)}
{renderDockItems(items, { mouseX, metrics, motionEnabled: motionEnabled && !showLabels, compact: size === "compact", showLabel: showLabels })}
</motion.div>
);
});
type DockItemRenderProps = {
mouseX: MotionValue<number>;
metrics: DockMetrics;
motionEnabled: boolean;
compact: boolean;
showLabel: boolean;
};
/**
* 渲染 Dock 条目,将连续的 danger 命令包裹在 is-danger-group 容器中实现视觉隔离。
* 满足"危险操作必须与常规按钮隔离"的硬约束。
*/
function renderDockItems(items: FloatingDockEntry[], props: DockItemRenderProps) {
const result: ReactNode[] = [];
let dangerGroup: FloatingDockCommand[] = [];
let index = 0;
const flushDangerGroup = () => {
if (!dangerGroup.length) return;
const groupKey = `danger-group-${index}`;
result.push(
<span key={groupKey} className="aceternity-dock-danger-group flex shrink-0 items-end gap-0.5 rounded-[calc(var(--dock-item-radius)+2px)] px-0.5">
{dangerGroup.map((command) => (
<DockCommandButton key={command.id} command={command} mouseX={props.mouseX} metrics={props.metrics} motionEnabled={props.motionEnabled} compact={props.compact} showLabel={props.showLabel} />
))}
</span>,
);
dangerGroup = [];
};
for (const item of items) {
if (item.kind === "separator") {
flushDangerGroup();
result.push(<DockSeparator key={item.id} compact={props.compact} labeled={props.showLabel} />);
continue;
}
if (item.danger) {
dangerGroup.push(item);
index += 1;
continue;
}
flushDangerGroup();
result.push(<DockCommandButton key={item.id} command={item} mouseX={props.mouseX} metrics={props.metrics} motionEnabled={props.motionEnabled} compact={props.compact} showLabel={props.showLabel} />);
index += 1;
}
flushDangerGroup();
return result;
}
function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, showLabel }: { command: FloatingDockCommand; mouseX: MotionValue<number>; metrics: DockMetrics; motionEnabled: boolean; compact: boolean; showLabel: boolean }) {
const ref = useRef<HTMLSpanElement>(null);
const [focused, setFocused] = useState(false);
@@ -116,7 +167,8 @@ function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, s
<motion.button
type="button"
aria-label={command.label}
aria-pressed={command.active || undefined}
aria-expanded={command.expands ? command.active || undefined : undefined}
aria-pressed={command.expands ? undefined : command.active || undefined}
disabled={command.disabled}
className={cn("aceternity-dock-command is-labeled group inline-flex h-8 items-center justify-center gap-1.5 whitespace-nowrap rounded-[var(--dock-item-radius)] border-0 px-2.5 outline-none", command.active && "is-active", command.danger && "is-danger")}
whileTap={!command.disabled ? { scale: 0.96 } : undefined}
@@ -140,7 +192,8 @@ function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, s
<motion.button
type="button"
aria-label={command.label}
aria-pressed={command.active || undefined}
aria-expanded={command.expands ? command.active || undefined : undefined}
aria-pressed={command.expands ? undefined : command.active || undefined}
disabled={command.disabled}
className={cn("aceternity-dock-command group relative grid size-full place-items-center rounded-full border outline-none", command.active && "is-active", command.danger && "is-danger")}
whileTap={motionEnabled && !command.disabled ? { scale: 0.92 } : undefined}