将三个硬编码工具栏(主工具栏/多选工具栏/节点悬停工具栏)统一改造为注册表驱动体系: - 新增 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:
@@ -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() })),
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
159
web/src/components/canvas/toolbars/toolbar-settings-modal.tsx
Normal file
159
web/src/components/canvas/toolbars/toolbar-settings-modal.tsx
Normal 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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
8
web/src/lib/canvas/tool-registry/definitions/index.ts
Normal file
8
web/src/lib/canvas/tool-registry/definitions/index.ts
Normal file
@@ -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);
|
||||
4
web/src/lib/canvas/tool-registry/index.ts
Normal file
4
web/src/lib/canvas/tool-registry/index.ts
Normal file
@@ -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";
|
||||
159
web/src/lib/canvas/tool-registry/tool-definition.ts
Normal file
159
web/src/lib/canvas/tool-registry/tool-definition.ts
Normal file
@@ -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[];
|
||||
};
|
||||
41
web/src/lib/canvas/tool-registry/tool-persistence.ts
Normal file
41
web/src/lib/canvas/tool-registry/tool-persistence.ts
Normal file
@@ -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;
|
||||
}
|
||||
114
web/src/lib/canvas/tool-registry/tool-registry.ts
Normal file
114
web/src/lib/canvas/tool-registry/tool-registry.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { RefObject } from "react";
|
||||
import { AlignHorizontalJustifyCenter, AlignHorizontalJustifyEnd, AlignHorizontalJustifyStart, AlignHorizontalSpaceAround, AlignHorizontalSpaceBetween, AlignVerticalJustifyCenter, AlignVerticalJustifyEnd, AlignVerticalJustifyStart, AlignVerticalSpaceAround, AlignVerticalSpaceBetween, Film, FolderTree, Grid3X3, LayoutTemplate, LoaderCircle, Workflow } from "lucide-react";
|
||||
|
||||
import { CanvasSelectionToolbar } from "@/components/canvas/canvas-workspace-overlays";
|
||||
import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
|
||||
import { FloatingDock } from "@/components/ui/aceternity/floating-dock";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
|
||||
import { defaultToolbarPrefs, readToolbarPrefs, resolveToolbarEntries, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry";
|
||||
import type { CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
|
||||
@@ -23,25 +23,30 @@ type CanvasProjectSelectionToolbarProps = {
|
||||
|
||||
export function CanvasProjectSelectionToolbar({ anchorRef, containerRef, count, selectedVideoCount, mergingVideos, onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onMergeVideos }: CanvasProjectSelectionToolbarProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const items: FloatingDockEntry[] = [
|
||||
{ id: "selection-align-left", label: "左对齐", icon: <AlignHorizontalJustifyStart />, onClick: () => onAlign("left") },
|
||||
{ id: "selection-align-center-x", label: "水平居中", icon: <AlignHorizontalJustifyCenter />, onClick: () => onAlign("centerX") },
|
||||
{ id: "selection-align-right", label: "右对齐", icon: <AlignHorizontalJustifyEnd />, onClick: () => onAlign("right") },
|
||||
{ id: "selection-align-top", label: "顶对齐", icon: <AlignVerticalJustifyStart />, onClick: () => onAlign("top") },
|
||||
{ id: "selection-align-center-y", label: "垂直居中", icon: <AlignVerticalJustifyCenter />, onClick: () => onAlign("centerY") },
|
||||
{ id: "selection-align-bottom", label: "底对齐", icon: <AlignVerticalJustifyEnd />, onClick: () => onAlign("bottom") },
|
||||
{ id: "selection-distribute-x", label: "水平等距", icon: <AlignHorizontalSpaceBetween />, disabled: count < 3, onClick: () => onAlign("distributeX") },
|
||||
{ id: "selection-distribute-y", label: "垂直等距", icon: <AlignVerticalSpaceBetween />, disabled: count < 3, onClick: () => onAlign("distributeY") },
|
||||
{ kind: "separator", id: "selection-arrange-separator" },
|
||||
{ id: "selection-arrange-row", label: "横向排列", icon: <AlignHorizontalSpaceAround />, onClick: () => onArrange("row") },
|
||||
{ id: "selection-arrange-column", label: "纵向排列", icon: <AlignVerticalSpaceAround />, onClick: () => onArrange("column") },
|
||||
{ id: "selection-arrange-grid", label: "宫格排列", icon: <Grid3X3 />, onClick: () => onArrange("grid") },
|
||||
{ id: "selection-arrange-flow", label: "按连线整理", icon: <Workflow />, onClick: () => onArrange("flow") },
|
||||
{ kind: "separator", id: "selection-group-separator" },
|
||||
{ id: "selection-create-storyboard", label: "创建分镜组", icon: <LayoutTemplate />, disabled: count < 2, onClick: onCreateStoryboard },
|
||||
{ id: "selection-create-reference-group", label: "创建引用组", icon: <FolderTree />, disabled: count < 2, onClick: onCreateReferenceGroup },
|
||||
...(selectedVideoCount >= 2 ? [{ id: "selection-merge-videos", label: `合并选中视频(${selectedVideoCount})`, icon: mergingVideos ? <LoaderCircle className="animate-spin" /> : <Film />, disabled: mergingVideos, onClick: onMergeVideos }] : []),
|
||||
];
|
||||
|
||||
const handlers = {
|
||||
onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onMergeVideos,
|
||||
} as Partial<ToolbarHandlers> as ToolbarHandlers;
|
||||
|
||||
const ctx: ToolContext = {
|
||||
selectedCount: count,
|
||||
selectedNodeTypes: new Set(),
|
||||
selectedVideoCount,
|
||||
canvasTool: "move",
|
||||
workspaceMode: "professional",
|
||||
isProjectLinked: false,
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
extractingVideoFrame: false,
|
||||
mergingVideos,
|
||||
addPanelOpen: false,
|
||||
appearancePanelOpen: false,
|
||||
settingsPanelOpen: false,
|
||||
handlers,
|
||||
};
|
||||
|
||||
const prefs = readToolbarPrefs("selection") ?? defaultToolbarPrefs("selection");
|
||||
const items = resolveToolbarEntries("selection", ctx, prefs);
|
||||
|
||||
return (
|
||||
<CanvasSelectionToolbar anchorRef={anchorRef} containerRef={containerRef} count={count}>
|
||||
|
||||
@@ -3475,6 +3475,10 @@
|
||||
color: var(--dock-command-danger);
|
||||
}
|
||||
|
||||
.aceternity-dock-danger-group {
|
||||
background: color-mix(in srgb, var(--dock-command-danger) 8%, transparent);
|
||||
}
|
||||
|
||||
.aceternity-dock-command:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--dock-command-bg), 0 0 0 4px var(--dock-command-active);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user