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:
爱笑的毛毛虫andsikongyue authored and GitHub committed 2026-08-03 23:22:44 +08:00
1 parent acaa4bb69f
commit 0c4c258a52
15 files changed
+1112 -112

No files matched your search

@@ -0,0 +1,22 @@
import { Clapperboard, FolderOpen, Image as ImageIcon, Layers3, Music2, Palette, PanelTop, Pencil, Type, UploadCloud, UserRound, Video } from "lucide-react";
import { registerAddNodeMenuCommands, type AddNodeMenuCommand } from "@/lib/canvas/tool-registry";
export const addNodeMenuCommands: AddNodeMenuCommand[] = [
// 创作节点
{ 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: "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: "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() },
{ id: "assets", label: "素材库", icon: <FolderOpen />, section: "resource", defaultOrder: 30, applicable: (ctx) => !ctx.isProjectLinked, run: (ctx) => ctx.handlers.onOpenMyAssets() },
];
registerAddNodeMenuCommands(addNodeMenuCommands);
@@ -0,0 +1,8 @@
/**
* 导入此模块会触发所有工具定义的注册。
* 在应用入口或工具栏组件中导入一次即可。
*/
import "./main-toolbar-tools";
import "./selection-toolbar-tools";
import "./node-hover-tools";
import "./add-node-menu-tools";
@@ -0,0 +1,125 @@
import { Eraser, FolderOpen, Hand, Palette, Plus, Redo2, Settings2, SquareDashedMousePointer, Trash2, Undo2, X } from "lucide-react";
import { registerToolbarTools, type ToolDefinition } from "@/lib/canvas/tool-registry";
export const mainToolbarTools: ToolDefinition[] = [
{
id: "tool-move",
toolbar: "main",
category: "navigation",
label: (ctx) => ctx.canvasTool === "box-select" ? "移动与选择" : ctx.selectedCount ? `取消选择${ctx.selectedCount > 1 ? ` ${ctx.selectedCount} 个节点` : ""}` : "移动与选择",
icon: (ctx) => ctx.canvasTool === "box-select" ? <Hand /> : ctx.selectedCount ? <X /> : <Hand />,
defaultVisible: true,
defaultOrder: 10,
active: (ctx) => ctx.canvasTool === "move",
run: (ctx) => {
if (ctx.canvasTool !== "move") ctx.handlers.onToolChange("move");
else ctx.handlers.onDeselect();
},
},
{
id: "tool-box-select",
toolbar: "main",
category: "navigation",
label: "框选",
icon: <SquareDashedMousePointer />,
defaultVisible: true,
defaultOrder: 20,
active: (ctx) => ctx.canvasTool === "box-select",
run: (ctx) => ctx.handlers.onToolChange(ctx.canvasTool === "box-select" ? "move" : "box-select"),
},
{
id: "tool-undo",
toolbar: "main",
category: "history",
label: "撤销",
icon: <Undo2 />,
defaultVisible: true,
defaultOrder: 30,
disabled: (ctx) => !ctx.canUndo,
run: (ctx) => ctx.handlers.onUndo(),
},
{
id: "tool-redo",
toolbar: "main",
category: "history",
label: "重做",
icon: <Redo2 />,
defaultVisible: true,
defaultOrder: 40,
disabled: (ctx) => !ctx.canRedo,
run: (ctx) => ctx.handlers.onRedo(),
},
{
id: "tool-add",
toolbar: "main",
category: "create",
label: "添加节点",
icon: <Plus />,
defaultVisible: true,
defaultOrder: 50,
expands: true,
active: (ctx) => ctx.addPanelOpen,
run: (ctx, event) => ctx.handlers.onToggleAddPanel(event!),
},
{
id: "tool-assets",
toolbar: "main",
category: "resource",
label: "素材库",
icon: <FolderOpen />,
defaultVisible: true,
defaultOrder: 60,
applicable: (ctx) => !ctx.isProjectLinked,
run: (ctx) => ctx.handlers.onOpenMyAssets(),
},
{
id: "tool-style",
toolbar: "main",
category: "appearance",
label: "画布外观",
icon: <Palette />,
defaultVisible: true,
defaultOrder: 70,
expands: true,
active: (ctx) => ctx.appearancePanelOpen,
run: (ctx, event) => ctx.handlers.onToggleAppearancePanel(event!),
},
{
id: "tool-settings",
toolbar: "main",
category: "appearance",
label: "工具栏设置",
icon: <Settings2 />,
defaultVisible: true,
defaultOrder: 80,
expands: true,
active: (ctx) => ctx.settingsPanelOpen,
run: (ctx) => ctx.handlers.onToggleSettingsPanel(),
},
{
id: "tool-delete",
toolbar: "main",
category: "danger",
label: (ctx) => ctx.selectedCount > 1 ? `删除 ${ctx.selectedCount} 个节点` : "删除选中节点",
icon: <Trash2 />,
defaultVisible: true,
defaultOrder: 90,
danger: true,
applicable: (ctx) => ctx.selectedCount > 0,
run: (ctx) => ctx.handlers.onDeleteSelected(),
},
{
id: "tool-clear",
toolbar: "main",
category: "danger",
label: "清空画布",
icon: <Eraser />,
defaultVisible: true,
defaultOrder: 100,
danger: true,
run: (ctx) => ctx.handlers.onClear(),
},
];
registerToolbarTools(mainToolbarTools);
@@ -0,0 +1,223 @@
import { Download, FolderPlus, GalleryHorizontalEnd, Image as ImageIcon, Info, LoaderCircle, Lock, Maximize2, MessageSquare, Minus, Music2, Plus, RefreshCw, Settings2, Trash2, Unlock, Upload, UserRound, Video } from "lucide-react";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { registerToolbarTools, type ToolContext, type ToolDefinition } from "@/lib/canvas/tool-registry";
import { CanvasNodeType } from "@/types/canvas";
// 节点状态判定辅助函数——从 ToolContext 派生
function isImage(ctx: ToolContext) { return ctx.node?.type === CanvasNodeType.Image; }
function isVideo(ctx: ToolContext) { return ctx.node?.type === CanvasNodeType.Video; }
function isAudio(ctx: ToolContext) { return ctx.node?.type === CanvasNodeType.Audio; }
function isText(ctx: ToolContext) { return ctx.node?.type === CanvasNodeType.Text; }
function isConfig(ctx: ToolContext) { return ctx.node?.type === CanvasNodeType.Config; }
function hasImage(ctx: ToolContext) { return isImage(ctx) && Boolean(ctx.nodeMetadata?.content); }
function hasVideo(ctx: ToolContext) { return isVideo(ctx) && Boolean(ctx.nodeMetadata?.content); }
function hasAudio(ctx: ToolContext) { return isAudio(ctx) && Boolean(ctx.nodeMetadata?.content); }
function isCharacterReference(ctx: ToolContext) { return isText(ctx) && ctx.nodeMetadata?.workflowKind === "character" && Boolean(ctx.nodeMetadata?.characterAssetId); }
function isEditableText(ctx: ToolContext) { return isText(ctx) && !isCharacterReference(ctx); }
function canOpenDialog(ctx: ToolContext) { return isEditableText(ctx) || isImage(ctx) || isVideo(ctx); }
function simpleMode(ctx: ToolContext) { return ctx.workspaceMode === "simple"; }
function canRetry(ctx: ToolContext) {
const requiresPromptChange = ctx.nodeMetadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(ctx.nodeMetadata?.errorDetails);
return ctx.nodeMetadata?.status === "error" && !requiresPromptChange;
}
export const nodeHoverToolbarTools: ToolDefinition[] = [
// 基础工具组
{
id: "info",
toolbar: "node-hover",
category: "node-state",
label: (ctx) => isCharacterReference(ctx) ? "查看角色详情" : "查看节点信息",
displayLabel: (ctx) => isCharacterReference(ctx) ? "角色详情" : "信息",
icon: (ctx) => isCharacterReference(ctx) ? <UserRound className="size-3.5" /> : <Info className="size-3.5" />,
defaultVisible: true,
defaultOrder: 10,
run: (ctx) => ctx.handlers.onNodeInfo(ctx.node!),
},
{
id: "delete",
toolbar: "node-hover",
category: "node-state",
label: "移除节点",
displayLabel: "删除",
icon: <Trash2 className="size-3.5" />,
defaultVisible: true,
defaultOrder: 20,
danger: true,
run: (ctx) => ctx.handlers.onNodeDelete(ctx.node!),
},
// 节点操作工具组——通过 applicable 谓词实现上下文感知
{
id: "retry",
toolbar: "node-hover",
category: "node-state",
label: "重新生成",
displayLabel: "重试",
icon: <RefreshCw className="size-3.5" />,
defaultVisible: true,
defaultOrder: 30,
applicable: canRetry,
run: (ctx) => ctx.handlers.onNodeRetry(ctx.node!),
},
{
id: "extractLastFrame",
toolbar: "node-hover",
category: "node-state",
label: (ctx) => ctx.extractingVideoFrame ? "正在截取尾帧" : "截取尾帧",
displayLabel: (ctx) => ctx.extractingVideoFrame ? "截取中" : "尾帧",
icon: (ctx) => ctx.extractingVideoFrame ? <LoaderCircle className="size-3.5 animate-spin" /> : <GalleryHorizontalEnd className="size-3.5" />,
defaultVisible: true,
defaultOrder: 40,
applicable: (ctx) => hasVideo(ctx) && !simpleMode(ctx),
disabled: (ctx) => ctx.extractingVideoFrame,
run: (ctx) => ctx.handlers.onNodeExtractVideoLastFrame(ctx.node!),
},
{
id: "saveAsset",
toolbar: "node-hover",
category: "node-state",
label: "加入我的素材",
displayLabel: "存素材",
icon: <FolderPlus className="size-3.5" />,
defaultVisible: true,
defaultOrder: 50,
applicable: (ctx) => hasImage(ctx) || hasVideo(ctx) || isEditableText(ctx),
run: (ctx) => ctx.handlers.onNodeSaveAsset(ctx.node!),
},
{
id: "download",
toolbar: "node-hover",
category: "node-state",
label: (ctx) => hasAudio(ctx) ? "下载音频" : hasVideo(ctx) ? "下载视频" : "下载图片",
displayLabel: "下载",
icon: <Download className="size-3.5" />,
defaultVisible: true,
defaultOrder: 60,
applicable: (ctx) => hasImage(ctx) || hasVideo(ctx) || hasAudio(ctx),
run: (ctx) => ctx.handlers.onNodeDownload(ctx.node!),
},
{
id: "edit",
toolbar: "node-hover",
category: "node-state",
label: (ctx) => isEditableText(ctx) ? "调用文本模型生成内容" : "编辑",
displayLabel: (ctx) => isEditableText(ctx) ? "文本生成" : "编辑",
icon: <MessageSquare className="size-3.5" />,
defaultVisible: true,
defaultOrder: 70,
applicable: canOpenDialog,
run: (ctx) => ctx.handlers.onNodeToggleDialog(ctx.node!),
},
{
id: "editText",
toolbar: "node-hover",
category: "node-state",
label: "放大编辑文本",
displayLabel: "放大编辑",
icon: <Maximize2 className="size-3.5" />,
defaultVisible: true,
defaultOrder: 80,
applicable: isEditableText,
run: (ctx) => ctx.handlers.onNodeEditText(ctx.node!),
},
{
id: "generateImage",
toolbar: "node-hover",
category: "node-state",
label: "用文本生图",
displayLabel: "生图",
icon: <ImageIcon className="size-3.5" />,
defaultVisible: true,
defaultOrder: 90,
applicable: isEditableText,
run: (ctx) => ctx.handlers.onNodeGenerateImage(ctx.node!),
},
{
id: "config",
toolbar: "node-hover",
category: "node-state",
label: "生成配置",
displayLabel: "生成配置",
icon: <Settings2 className="size-3.5" />,
defaultVisible: true,
defaultOrder: 100,
applicable: (ctx) => isConfig(ctx) && !simpleMode(ctx),
run: (ctx) => ctx.handlers.onNodeToggleDialog(ctx.node!),
},
{
id: "decreaseFont",
toolbar: "node-hover",
category: "node-state",
label: "减小字号",
displayLabel: "缩小",
icon: <Minus className="size-3.5" />,
defaultVisible: true,
defaultOrder: 110,
applicable: (ctx) => isEditableText(ctx) && !simpleMode(ctx),
run: (ctx) => ctx.handlers.onNodeDecreaseFont(ctx.node!),
},
{
id: "increaseFont",
toolbar: "node-hover",
category: "node-state",
label: "增大字号",
displayLabel: "放大",
icon: <Plus className="size-3.5" />,
defaultVisible: true,
defaultOrder: 120,
applicable: (ctx) => isEditableText(ctx) && !simpleMode(ctx),
run: (ctx) => ctx.handlers.onNodeIncreaseFont(ctx.node!),
},
{
id: "uploadImage",
toolbar: "node-hover",
category: "node-state",
label: "上传图片",
displayLabel: "上传图片",
icon: <Upload className="size-3.5" />,
defaultVisible: true,
defaultOrder: 130,
applicable: (ctx) => isImage(ctx) && !hasImage(ctx),
run: (ctx) => ctx.handlers.onNodeUpload(ctx.node!),
},
{
id: "uploadVideo",
toolbar: "node-hover",
category: "node-state",
label: (ctx) => hasVideo(ctx) ? "替换视频" : "上传视频",
displayLabel: (ctx) => hasVideo(ctx) ? "替换视频" : "上传视频",
icon: <Video className="size-3.5" />,
defaultVisible: true,
defaultOrder: 140,
applicable: isVideo,
run: (ctx) => ctx.handlers.onNodeUpload(ctx.node!),
},
{
id: "uploadAudio",
toolbar: "node-hover",
category: "node-state",
label: (ctx) => hasAudio(ctx) ? "替换音频" : "上传音频",
displayLabel: (ctx) => hasAudio(ctx) ? "替换音频" : "上传音频",
icon: <Music2 className="size-3.5" />,
defaultVisible: true,
defaultOrder: 150,
applicable: isAudio,
run: (ctx) => ctx.handlers.onNodeUpload(ctx.node!),
},
// 节点锁定——独立分类,自动插入前置分隔符
{
id: "node-lock",
toolbar: "node-hover",
category: "navigation",
label: (ctx) => ctx.nodeMetadata?.locked ? "解锁节点" : "锁定位置和尺寸",
displayLabel: (ctx) => ctx.nodeMetadata?.locked ? "解锁" : "锁定",
icon: (ctx) => ctx.nodeMetadata?.locked ? <Unlock className="size-3.5" /> : <Lock className="size-3.5" />,
defaultVisible: true,
defaultOrder: 160,
active: (ctx) => Boolean(ctx.nodeMetadata?.locked),
run: (ctx) => ctx.handlers.onNodeToggleLocked(ctx.node!),
},
];
registerToolbarTools(nodeHoverToolbarTools);
@@ -0,0 +1,37 @@
import { AlignHorizontalJustifyCenter, AlignHorizontalJustifyEnd, AlignHorizontalJustifyStart, AlignHorizontalSpaceAround, AlignHorizontalSpaceBetween, AlignVerticalJustifyCenter, AlignVerticalJustifyEnd, AlignVerticalJustifyStart, AlignVerticalSpaceAround, AlignVerticalSpaceBetween, Film, FolderTree, Grid3X3, LayoutTemplate, LoaderCircle, Workflow } from "lucide-react";
import { registerToolbarTools, type ToolDefinition } from "@/lib/canvas/tool-registry";
export const selectionToolbarTools: ToolDefinition[] = [
// 对齐组
{ id: "selection-align-left", toolbar: "selection", category: "layout", label: "左对齐", icon: <AlignHorizontalJustifyStart />, defaultVisible: true, defaultOrder: 10, run: (ctx) => ctx.handlers.onAlign("left") },
{ id: "selection-align-center-x", toolbar: "selection", category: "layout", label: "水平居中", icon: <AlignHorizontalJustifyCenter />, defaultVisible: true, defaultOrder: 20, run: (ctx) => ctx.handlers.onAlign("centerX") },
{ id: "selection-align-right", toolbar: "selection", category: "layout", label: "右对齐", icon: <AlignHorizontalJustifyEnd />, defaultVisible: true, defaultOrder: 30, run: (ctx) => ctx.handlers.onAlign("right") },
{ id: "selection-align-top", toolbar: "selection", category: "layout", label: "顶对齐", icon: <AlignVerticalJustifyStart />, defaultVisible: true, defaultOrder: 40, run: (ctx) => ctx.handlers.onAlign("top") },
{ id: "selection-align-center-y", toolbar: "selection", category: "layout", label: "垂直居中", icon: <AlignVerticalJustifyCenter />, defaultVisible: true, defaultOrder: 50, run: (ctx) => ctx.handlers.onAlign("centerY") },
{ id: "selection-align-bottom", toolbar: "selection", category: "layout", label: "底对齐", icon: <AlignVerticalJustifyEnd />, defaultVisible: true, defaultOrder: 60, run: (ctx) => ctx.handlers.onAlign("bottom") },
{ id: "selection-distribute-x", toolbar: "selection", category: "layout", label: "水平等距", icon: <AlignHorizontalSpaceBetween />, defaultVisible: true, defaultOrder: 70, disabled: (ctx) => ctx.selectedCount < 3, run: (ctx) => ctx.handlers.onAlign("distributeX") },
{ id: "selection-distribute-y", toolbar: "selection", category: "layout", label: "垂直等距", icon: <AlignVerticalSpaceBetween />, defaultVisible: true, defaultOrder: 80, disabled: (ctx) => ctx.selectedCount < 3, run: (ctx) => ctx.handlers.onAlign("distributeY") },
// 排列组
{ id: "selection-arrange-row", toolbar: "selection", category: "arrange", label: "横向排列", icon: <AlignHorizontalSpaceAround />, defaultVisible: true, defaultOrder: 90, run: (ctx) => ctx.handlers.onArrange("row") },
{ id: "selection-arrange-column", toolbar: "selection", category: "arrange", label: "纵向排列", icon: <AlignVerticalSpaceAround />, defaultVisible: true, defaultOrder: 100, run: (ctx) => ctx.handlers.onArrange("column") },
{ id: "selection-arrange-grid", toolbar: "selection", category: "arrange", label: "宫格排列", icon: <Grid3X3 />, defaultVisible: true, defaultOrder: 110, run: (ctx) => ctx.handlers.onArrange("grid") },
{ id: "selection-arrange-flow", toolbar: "selection", category: "arrange", label: "按连线整理", icon: <Workflow />, defaultVisible: true, defaultOrder: 120, run: (ctx) => ctx.handlers.onArrange("flow") },
// 分组组
{ id: "selection-create-storyboard", toolbar: "selection", category: "selection", label: "创建分镜组", icon: <LayoutTemplate />, defaultVisible: true, defaultOrder: 130, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onCreateStoryboard() },
{ id: "selection-create-reference-group", toolbar: "selection", category: "selection", label: "创建引用组", icon: <FolderTree />, defaultVisible: true, defaultOrder: 140, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onCreateReferenceGroup() },
{
id: "selection-merge-videos",
toolbar: "selection",
category: "selection",
label: (ctx) => `合并选中视频(${ctx.selectedVideoCount})`,
icon: (ctx) => ctx.mergingVideos ? <LoaderCircle className="animate-spin" /> : <Film />,
defaultVisible: true,
defaultOrder: 150,
applicable: (ctx) => ctx.selectedVideoCount >= 2,
disabled: (ctx) => ctx.mergingVideos,
run: (ctx) => ctx.handlers.onMergeVideos(),
},
];
registerToolbarTools(selectionToolbarTools);
@@ -0,0 +1,4 @@
export type { AddNodeMenuCommand, 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";
@@ -0,0 +1,159 @@
import type { ReactNode } from "react";
import type { MouseEvent } from "react";
import type { CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import type { CanvasNodeData, CanvasNodeMetadata, CanvasNodeType, CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas";
/** 工具栏标识——每个工具栏有独立的注册表与偏好 */
export type ToolbarId = "main" | "selection" | "node-hover" | "add-node-menu";
/** 工具分类——用于分组渲染、危险隔离与 separator 自动插入 */
export type ToolCategory =
| "navigation"
| "history"
| "create"
| "appearance"
| "selection"
| "layout"
| "arrange"
| "danger"
| "node-state"
| "resource";
/** 所有工具栏回调的聚合类型。工具定义通过 ctx.handlers.onXxx 访问 */
export type ToolbarHandlers = {
// 主工具栏——画布操作
onToolChange: (tool: CanvasToolMode) => void;
onDeselect: () => void;
onUndo: () => void;
onRedo: () => void;
onClear: () => void;
// 主工具栏——创建节点
onAddText: () => void;
onAddImage: () => void;
onAddVideo: () => void;
onAddAudio: () => void;
onAddScript: () => void;
onAddFrame: () => void;
onAddDrawing: () => void;
onChooseStyle: () => void;
onOpenDirector: () => void;
// 主工具栏——资源
onUpload: () => void;
onOpenMyAssets: () => void;
onOpenProjectCharacters: () => void;
// 主工具栏——外观
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
onShowImageInfoChange: (show: boolean) => void;
// 主工具栏——面板开关(组件内部状态,由组件实现)
onToggleAddPanel: (event: MouseEvent<HTMLElement>) => void;
onToggleAppearancePanel: (event: MouseEvent<HTMLElement>) => void;
onToggleSettingsPanel: () => void;
// 主工具栏——删除选中
onDeleteSelected: () => void;
// 多选工具栏
onAlign: (mode: CanvasAlignmentMode) => void;
onArrange: (mode: "row" | "column" | "grid" | "flow") => void;
onCreateStoryboard: () => void;
onCreateReferenceGroup: () => void;
onMergeVideos: () => void;
// 节点悬停工具栏——节点操作(均接收当前节点)
onNodeInfo: (node: CanvasNodeData) => void;
onNodeDelete: (node: CanvasNodeData) => void;
onNodeRetry: (node: CanvasNodeData) => void;
onNodeEditText: (node: CanvasNodeData) => void;
onNodeDecreaseFont: (node: CanvasNodeData) => void;
onNodeIncreaseFont: (node: CanvasNodeData) => void;
onNodeToggleDialog: (node: CanvasNodeData) => void;
onNodeAnnotate: (node: CanvasNodeData) => void;
onNodeGenerateImage: (node: CanvasNodeData) => void;
onNodeUpload: (node: CanvasNodeData) => void;
onNodeDownload: (node: CanvasNodeData) => void;
onNodeSaveAsset: (node: CanvasNodeData) => void;
onNodeMaskEdit: (node: CanvasNodeData) => void;
onNodeEmotion: (node: CanvasNodeData) => void;
onNodePortraitTexture: (node: CanvasNodeData) => void;
onNodeCrop: (node: CanvasNodeData) => void;
onNodeSplit: (node: CanvasNodeData) => void;
onNodeUpscale: (node: CanvasNodeData) => void;
onNodeSuperResolve: (node: CanvasNodeData) => void;
onNodeAngle: (node: CanvasNodeData) => void;
onNodeViewImage: (node: CanvasNodeData) => void;
onNodeExtractVideoLastFrame: (node: CanvasNodeData) => void;
onNodeReversePrompt: (node: CanvasNodeData) => void;
onNodeToggleFreeResize: (node: CanvasNodeData) => void;
onNodeToggleLocked: (node: CanvasNodeData) => void;
onNodeCopyPrompt: (node: CanvasNodeData) => void;
};
/** 工具运行时可见的上下文。工具定义通过纯函数读取状态 */
export type ToolContext = {
selectedCount: number;
selectedNodeTypes: Set<CanvasNodeType>;
selectedVideoCount: number;
canvasTool: CanvasToolMode;
workspaceMode: CanvasWorkspaceMode;
isProjectLinked: boolean;
canUndo: boolean;
canRedo: boolean;
/** 节点悬停工具栏专用——当前悬停/选中的节点 */
node?: CanvasNodeData;
/** 便捷访问 node.metadata(node 为空时为 undefined) */
nodeMetadata?: CanvasNodeMetadata;
/** 视频尾帧提取中(节点悬停工具栏用) */
extractingVideoFrame: boolean;
/** 合并视频中(多选工具栏用) */
mergingVideos: boolean;
/** 主工具栏面板开关状态(仅主工具栏使用) */
addPanelOpen: boolean;
appearancePanelOpen: boolean;
settingsPanelOpen: boolean;
handlers: ToolbarHandlers;
};
/** 工具定义——注册表的基本单元 */
export type ToolDefinition = {
id: string;
toolbar: ToolbarId;
category: ToolCategory;
label: string | ((ctx: ToolContext) => string);
/** Dock 标签模式下的短文案,缺省用 label */
displayLabel?: string | ((ctx: ToolContext) => string);
icon: ReactNode | ((ctx: ToolContext) => ReactNode);
/** 用户默认可见(可被 prefs 覆盖) */
defaultVisible: boolean;
/** 默认排序权重,升序 */
defaultOrder: number;
active?: (ctx: ToolContext) => boolean;
disabled?: (ctx: ToolContext) => boolean;
/** 危险操作——渲染时隔离到独立分组 */
danger?: boolean;
/** 面板展开型工具——使用 aria-expanded 而非 aria-pressed */
expands?: boolean;
/** 上下文可见性谓词——返回 false 时工具不渲染(不受 prefs 控制) */
applicable?: (ctx: ToolContext) => boolean;
/** 执行动作。event 来自 Dock 按钮点击 */
run: (ctx: ToolContext, event?: MouseEvent<HTMLElement>) => void;
};
/** 添加节点菜单命令(复用 CanvasCreateCommand 结构 + 上下文谓词) */
export type AddNodeMenuCommand = {
id: string;
label: string;
icon: ReactNode;
badge?: string;
/** 菜单分区:创作节点 / 导入资源 */
section: "node" | "resource";
defaultOrder: number;
applicable?: (ctx: ToolContext) => boolean;
run: (ctx: ToolContext) => void;
};
/** 用户偏好——排序与显隐 */
export type ToolbarPrefs = {
/** 工具 id 按显示顺序排列 */
order: string[];
/** 用户隐藏的工具 id */
hidden: string[];
};
@@ -0,0 +1,41 @@
import { scopedLocalStorage } from "@/lib/user-scope";
import type { ToolbarId, ToolbarPrefs } from "./tool-definition";
const STORAGE_VERSION = "v1";
const storageKey = (toolbar: ToolbarId) => `canvas-toolbar-prefs-${toolbar}-${STORAGE_VERSION}`;
/**
* 读取工具栏偏好。缺失或解析失败时返回 null(由调用方决定是否用默认值)
*/
export function readToolbarPrefs(toolbar: ToolbarId): ToolbarPrefs | null {
try {
const stored = scopedLocalStorage.getItem(storageKey(toolbar));
if (!stored) return null;
const parsed = JSON.parse(stored) as unknown;
if (!isPrefsShape(parsed)) return null;
return { order: Array.isArray(parsed.order) ? parsed.order.filter((id) => typeof id === "string") : [], hidden: Array.isArray(parsed.hidden) ? parsed.hidden.filter((id) => typeof id === "string") : [] };
} catch {
return null;
}
}
export function persistToolbarPrefs(toolbar: ToolbarId, prefs: ToolbarPrefs) {
try {
scopedLocalStorage.setItem(storageKey(toolbar), JSON.stringify(prefs));
} catch {
// 浏览器禁用本地存储时保留当前会话内的选择
}
}
export function clearToolbarPrefs(toolbar: ToolbarId) {
try {
scopedLocalStorage.removeItem(storageKey(toolbar));
} catch {
// 忽略
}
}
function isPrefsShape(value: unknown): value is Partial<ToolbarPrefs> {
return typeof value === "object" && value !== null;
}
@@ -0,0 +1,114 @@
import type { FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
import type { AddNodeMenuCommand, ToolCategory, ToolContext, ToolDefinition, ToolbarId, ToolbarPrefs } from "./tool-definition";
/** 模块级注册表 */
const registry = new Map<ToolbarId, ToolDefinition[]>();
const addNodeMenuRegistry: AddNodeMenuCommand[] = [];
/** 批量注册工具到指定工具栏 */
export function registerToolbarTools(tools: ToolDefinition[]) {
for (const tool of tools) {
const list = registry.get(tool.toolbar) ?? [];
list.push(tool);
registry.set(tool.toolbar, list);
}
}
/** 注册添加节点菜单命令 */
export function registerAddNodeMenuCommands(commands: AddNodeMenuCommand[]) {
addNodeMenuRegistry.push(...commands);
}
/** 获取某工具栏全部已注册工具(按 defaultOrder 升序) */
export function getToolbarTools(toolbar: ToolbarId): ToolDefinition[] {
const tools = registry.get(toolbar) ?? [];
return [...tools].sort((a, b) => a.defaultOrder - b.defaultOrder);
}
/** 获取添加节点菜单全部命令(按 defaultOrder 升序) */
export function getAddNodeMenuCommands(): AddNodeMenuCommand[] {
return [...addNodeMenuRegistry].sort((a, b) => a.defaultOrder - b.defaultOrder);
}
/** 默认偏好:全部工具按 defaultOrder 排列,全部可见 */
export function defaultToolbarPrefs(toolbar: ToolbarId): ToolbarPrefs {
const tools = getToolbarTools(toolbar);
return { order: tools.map((tool) => tool.id), hidden: [] };
}
/**
* 解析工具栏条目——核心函数
*
* 流程:过滤 applicable → 应用用户排序 → 过滤用户隐藏 → 生成 FloatingDockEntry(含 separator 分组)
*/
export function resolveToolbarEntries(toolbar: ToolbarId, ctx: ToolContext, prefs: ToolbarPrefs | null): FloatingDockEntry[] {
const tools = resolveToolbarTools(toolbar, ctx, prefs);
return buildEntriesWithSeparators(tools, ctx);
}
/**
* 解析工具栏工具——返回过滤排序后的 ToolDefinition[](不含 separator)。
* 供需要后处理工具列表的场景使用(如节点悬停工具栏合并图片工具)。
*/
export function resolveToolbarTools(toolbar: ToolbarId, ctx: ToolContext, prefs: ToolbarPrefs | null): ToolDefinition[] {
const allTools = getToolbarTools(toolbar);
const applicableTools = allTools.filter((tool) => !tool.applicable || tool.applicable(ctx));
const effectivePrefs = prefs ?? defaultToolbarPrefs(toolbar);
const hiddenSet = new Set(effectivePrefs.hidden);
const visibleTools = applicableTools.filter((tool) => !hiddenSet.has(tool.id));
const orderIndex = new Map(effectivePrefs.order.map((id, index) => [id, index]));
return [...visibleTools].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;
});
}
/** 解析添加节点菜单命令——仅 applicable 过滤,不参与排序/显隐 */
export function resolveAddNodeMenuCommands(ctx: ToolContext): AddNodeMenuCommand[] {
return getAddNodeMenuCommands().filter((command) => !command.applicable || command.applicable(ctx));
}
/**
* 将工具列表转为 FloatingDockEntry,按 category 边界自动插入 separator。
* danger 类工具会被包裹在 is-danger-group 容器中实现视觉隔离。
*/
function buildEntriesWithSeparators(tools: ToolDefinition[], ctx: ToolContext): FloatingDockEntry[] {
const entries: FloatingDockEntry[] = [];
let prevCategory: ToolCategory | null = null;
let separatorIndex = 0;
for (const tool of tools) {
if (prevCategory !== null && prevCategory !== tool.category) {
entries.push({ kind: "separator", id: `sep-${tool.toolbar}-${separatorIndex}` });
separatorIndex += 1;
}
entries.push(toolToEntry(tool, ctx));
prevCategory = tool.category;
}
return entries;
}
function toolToEntry(tool: ToolDefinition, ctx: ToolContext): FloatingDockEntry {
return {
kind: "command",
id: tool.id,
label: resolveText(tool.label, ctx),
displayLabel: tool.displayLabel ? resolveText(tool.displayLabel, ctx) : undefined,
icon: resolveIcon(tool.icon, ctx),
active: tool.active?.(ctx),
disabled: tool.disabled?.(ctx),
danger: tool.danger,
expands: tool.expands,
onClick: (event) => tool.run(ctx, event),
};
}
function resolveText(value: string | ((ctx: ToolContext) => string), ctx: ToolContext): string {
return typeof value === "function" ? value(ctx) : value;
}
function resolveIcon(value: ToolDefinition["icon"], ctx: ToolContext) {
return typeof value === "function" ? value(ctx) : value;
}