diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx
index 9a1aa0853..71643c729 100644
--- a/docs/content/docs/overview/features.mdx
+++ b/docs/content/docs/overview/features.mdx
@@ -5,6 +5,19 @@ description: 当前项目已实现的核心工作台能力与使用入口
# 功能清单
+## 画布抓手与框选
+
+画布底部主工具栏用一个胶囊开关切换「区域选择」和「抓手工具」。默认是区域选择,空白处拖动框选节点;切到抓手后拖动平移画布。按住空格或中键仍可临时平移,不改变当前工具。
+
+框选多个节点后,悬浮栏默认提供对齐、等距、创建分镜组和批量连接。右键选区或画布空白处可「自适应整理画布」,按当前相对布局加大边距,而不是重排成宫格或媒体泳道。
+
+### 相关实现
+
+- `web/src/lib/canvas/tool-registry/definitions/main-toolbar-tools.tsx`:抓手与框选作为同一开关项注册。
+- `web/src/components/ui/aceternity/floating-dock.tsx`:dock 内胶囊开关与圆形滑块。
+- `web/src/lib/canvas/tool-registry/definitions/selection-toolbar-tools.tsx`:多选悬浮栏默认工具。
+- `web/src/lib/canvas/canvas-layout.ts`:`spreadCanvasNodes` 按相对占位加大边距。
+
## 系统渠道排序与前台别名
后台系统渠道及渠道模型列表通过“设置排序”打开完整列表,支持拖动、上移和下移,点击“保存排序”统一生效;取消不会修改线上顺序。界面不显示内部排序数字,排序不受筛选及分页影响;并发修改会提示重新加载,避免覆盖其他管理员的调整。系统渠道模式下,用户端模型选择器沿用同一渠道及模型顺序。模型排序独立保存,不改变价格、能力版本或启用状态。
diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx
index 41c7173ab..0063ebc58 100644
--- a/docs/content/docs/progress/pending-test.mdx
+++ b/docs/content/docs/progress/pending-test.mdx
@@ -5,6 +5,35 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试
+## 框选工具栏精简与自适应整理
+
+- 多选悬浮栏默认只保留对齐、等距、分镜组和批量连接;横向/纵向/宫格/连线排列和引用组改到工具栏设置里,需要时再打开。
+- 框选多个节点后,在节点或画布空白处右键会出现「自适应整理画布」:保持现有相对占位,把间距至少加到约 56px,不按媒体类型重排。
+- 右键已选中的节点不再拆掉多选。
+- [ ] 登录画布框选一组松散节点,确认悬浮栏变短,右键整理后左右上下关系不变、间距变大,撤销可恢复。
+- [ ] 工具栏设置里仍能重新打开隐藏的排列入口。
+
+## 画布抓手 / 框选胶囊开关
+
+- 主工具栏左侧的抓手工具和区域选择合并为一个胶囊开关:选中项是白色圆形滑块,未选项直接落在纯黑轨道上;左右留白比参考图更松。
+- 默认仍是区域选择;切换到抓手后空白处拖动平移画布,切回后空白处拖动框选。空格 / 中键临时平移不受影响。
+- 旧工具栏偏好里的 `tool-move` / `tool-box-select` 会合并成 `tool-canvas-mode`;两个都隐藏时开关才隐藏。
+- [ ] 登录画布验证明暗主题、开关切换后光标和拖动语义、工具栏设置中的显隐与排序。
+- [ ] 触屏或窄屏下开关仍可点按,不与 dock 横向滚动冲突。
+
+## 画布本地媒体转换节点(PR #446)
+
+- 合并本地媒体转换节点与 Canvas Agent Runtime:灰度、Canny、AI 线稿、Depth Anything V2、OpenPose;官方插件注册;图片输入约束、结果指纹与缓存。
+- 合并冲突处理:保留主分支连线创建菜单的展开描述样式,并补上「转换」选项。
+- 修复:完成后的转换结果可进入下游生成参考图;stale 写入不再覆盖 processing;hover「转换」识别 `storageKey` 媒体;pose `personCount` 允许 0。
+- 已知边界:抠图(cutout)与视频转换仍标记为暂不可用;插件开关未像肖像排查那样隐藏创建入口。
+- 修复:POSIX 主机上 Windows 风格绝对路径环境变量不再被 `path.resolve` 错误拼接工作目录。
+- [x] `bun test test/media-conversion.test.ts`:7 passed。
+- [x] canvas-agent depth/lineart/pose 路径覆盖测试:通过。
+- [ ] 登录画布:图片 → 转换(灰度/Canny)→ 下游图片生成,确认参考图实际进入请求。
+- [ ] 本机 Agent:深度 / 线稿 / 姿态转换;无人物姿态显示 skipped。
+- [ ] 更换上游图片后转换节点变 stale,重新运行后下游可继续生成。
+
## Agent 聊天附件、局部重绘次数与多图展开
- Agent 输入框附件改为 80px 预览,标签在图片下方,不再盖住画面;点击/双击放大,可用 `@图片N` 插入正文,鼠标移开后图片保持可见。
diff --git a/web/src/components/canvas/canvas-context-menu.tsx b/web/src/components/canvas/canvas-context-menu.tsx
index e321dded4..933ace7fa 100644
--- a/web/src/components/canvas/canvas-context-menu.tsx
+++ b/web/src/components/canvas/canvas-context-menu.tsx
@@ -1,6 +1,6 @@
import { AnimatePresence } from "motion/react";
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
-import { ArrowLeft, Check, ChevronRight, Clipboard, CloudUpload, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, Link2, Maximize2, PanelTop, Pencil, Plus, Redo2, Tags, Trash2, Undo2, Upload, UserRound } from "lucide-react";
+import { ArrowLeft, Check, ChevronRight, Clipboard, CloudUpload, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, LayoutTemplate, Link2, Maximize2, PanelTop, Pencil, Plus, Redo2, Tags, Trash2, Undo2, Upload, UserRound } from "lucide-react";
import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu";
import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category";
@@ -26,6 +26,7 @@ type CanvasNodeContextMenuProps = {
canUndo: boolean;
canRedo: boolean;
canPaste: boolean;
+ selectedCount?: number;
onClose: () => void;
onAddNode: (type: CanvasNodeTypeId) => void;
onAddFolder: () => void;
@@ -51,6 +52,9 @@ type CanvasNodeContextMenuProps = {
onUploadToArkPrivateAsset: () => void;
onSetAssetCategory: (category: CanvasAssetCategory) => void;
onToggleFrame: () => void;
+ onSpreadSelection?: () => void;
+ onCopySelection?: () => void;
+ onDeleteSelection?: () => void;
};
export function CanvasNodeContextMenu({
@@ -61,6 +65,7 @@ export function CanvasNodeContextMenu({
canUndo,
canRedo,
canPaste,
+ selectedCount = 0,
onClose,
onAddNode,
onAddFolder,
@@ -86,6 +91,9 @@ export function CanvasNodeContextMenu({
onUploadToArkPrivateAsset,
onSetAssetCategory,
onToggleFrame,
+ onSpreadSelection,
+ onCopySelection,
+ onDeleteSelection,
}: CanvasNodeContextMenuProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [addOpen, setAddOpen] = useState(false);
@@ -135,7 +143,8 @@ export function CanvasNodeContextMenu({
const canGenerateFromText = Boolean(isText && !isCharacterReference && hasNodeContent);
const canCopyMediaUrl = Boolean(isMedia && hasNodeContent);
const assetCategory = node ? canvasNodeAssetCategory(node) : "other";
- const position = getContextMenuPosition(menu);
+ const hasMultiSelection = selectedCount >= 2;
+ const position = getContextMenuPosition(menu, hasMultiSelection);
return (
<>
@@ -169,6 +178,12 @@ export function CanvasNodeContextMenu({
) : menu.type === "canvas" ? (
<>
+ {hasMultiSelection ? (
+ <>
+ } label="自适应整理画布" detail="保持相对布局并加大边距" disabled={!onSpreadSelection} onClick={() => onSpreadSelection && runAction(onSpreadSelection)} />
+
+ >
+ ) : null}
} label="添加节点" chevron active={addOpen} onClick={() => setAddOpen((value) => !value)} />
} label="上传到这里" onClick={() => runAction(onUpload)} />
{!isProjectLinked ? } label="从素材库插入" onClick={() => runAction(onOpenAssets)} /> : null}
@@ -178,6 +193,15 @@ export function CanvasNodeContextMenu({
} label="重做" shortcut="⇧⌘Z" disabled={!canRedo} onClick={() => runAction(onRedo)} />
} label="粘贴" shortcut="⌘V" disabled={!canPaste} onClick={() => runAction(onPaste)} />
>
+ ) : menu.type === "node" && hasMultiSelection ? (
+ <>
+
+
+ } label="自适应整理画布" detail="保持相对布局并加大边距" disabled={!onSpreadSelection} onClick={() => onSpreadSelection && runAction(onSpreadSelection)} />
+
+ } label={`复制 ${selectedCount} 个节点`} shortcut="⌘C" disabled={!onCopySelection} onClick={() => onCopySelection && runAction(onCopySelection)} />
+ } label={`删除 ${selectedCount} 个节点`} danger disabled={!onDeleteSelection} onClick={() => onDeleteSelection && runAction(onDeleteSelection)} />
+ >
) : menu.type === "node" ? (
<>
{isCharacterReference ? (
@@ -345,10 +369,10 @@ function MenuDivider() {
return
;
}
-function getContextMenuPosition(menu: ContextMenuState) {
+function getContextMenuPosition(menu: ContextMenuState, hasMultiSelection = false) {
if (typeof window === "undefined") return { left: menu.x, top: menu.y };
const width = 224;
- const estimatedHeight = menu.type === "node" ? Math.min(360, window.innerHeight - 72) : menu.type === "canvas" ? 250 : 84;
+ const estimatedHeight = menu.type === "node" ? Math.min(hasMultiSelection ? 240 : 360, window.innerHeight - 72) : menu.type === "canvas" ? (hasMultiSelection ? 300 : 250) : 84;
return {
left: clamp(menu.x, 12, Math.max(12, window.innerWidth - width - 12)),
top: clamp(menu.y, 68, Math.max(68, window.innerHeight - estimatedHeight - 12)),
diff --git a/web/src/components/ui/aceternity/floating-dock.tsx b/web/src/components/ui/aceternity/floating-dock.tsx
index 820c0f429..acf4e8130 100644
--- a/web/src/components/ui/aceternity/floating-dock.tsx
+++ b/web/src/components/ui/aceternity/floating-dock.tsx
@@ -20,7 +20,23 @@ export type FloatingDockCommand = {
expands?: boolean;
};
-export type FloatingDockEntry = FloatingDockCommand | { kind: "separator"; id: string };
+export type FloatingDockSwitchOption = {
+ id: string;
+ label: string;
+ icon: ReactNode;
+ value: string;
+};
+
+export type FloatingDockSwitch = {
+ kind: "switch";
+ id: string;
+ label: string;
+ value: string;
+ options: FloatingDockSwitchOption[];
+ onChange: (value: string) => void;
+};
+
+export type FloatingDockEntry = FloatingDockCommand | FloatingDockSwitch | { kind: "separator"; id: string };
type FloatingDockProps = {
items: FloatingDockEntry[];
@@ -160,6 +176,12 @@ function renderDockItems(items: FloatingDockEntry[], props: DockItemRenderProps)
result.push();
continue;
}
+ if (item.kind === "switch") {
+ flushDangerGroup();
+ result.push();
+ index += 1;
+ continue;
+ }
if (item.danger) {
dangerGroup.push(item);
index += 1;
@@ -264,6 +286,91 @@ function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, s
);
}
+function DockSwitch({ entry, compact, showLabel, motionEnabled, metrics }: { entry: FloatingDockSwitch; compact: boolean; showLabel: boolean; motionEnabled: boolean; metrics: DockMetrics }) {
+ const reducedMotion = useReducedMotion();
+ const [hoveredId, setHoveredId] = useState(null);
+ const [focusedId, setFocusedId] = useState(null);
+ const selectedIndex = Math.max(0, entry.options.findIndex((option) => option.value === entry.value));
+ const touch = metrics.base >= 40;
+ const slot = touch ? 32 : compact ? 24 : 26;
+ const gap = touch ? 10 : compact ? 8 : 10;
+ const padX = touch ? 7 : compact ? 6 : 7;
+
+ return (
+ {
+ if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
+ event.preventDefault();
+ const direction = event.key === "ArrowRight" ? 1 : -1;
+ const next = entry.options[(selectedIndex + direction + entry.options.length) % entry.options.length];
+ if (next) entry.onChange(next.value);
+ }}
+ >
+
+
+ {entry.options.map((option) => {
+ const checked = option.value === entry.value;
+ const showTooltip = !showLabel && (hoveredId === option.id || focusedId === option.id);
+ return (
+
+
+
+ {showTooltip ? (
+
+ {option.label}
+
+ ) : null}
+
+
+ );
+ })}
+
+
+ );
+}
+
function DockSeparator({ compact, labeled }: { compact: boolean; labeled: boolean }) {
return ;
}
diff --git a/web/src/lib/canvas/canvas-aceternity-style.ts b/web/src/lib/canvas/canvas-aceternity-style.ts
index 76a4ad2fa..a6183c6b7 100644
--- a/web/src/lib/canvas/canvas-aceternity-style.ts
+++ b/web/src/lib/canvas/canvas-aceternity-style.ts
@@ -15,5 +15,8 @@ export function canvasDockStyle(theme: CanvasTheme, color: string = theme.toolba
"--dock-command-danger": theme.accent.danger,
"--dock-tooltip-bg": theme.spatial.elevated,
"--dock-tooltip-border": theme.toolbar.border,
+ "--dock-switch-track": "#000000",
+ "--dock-switch-thumb": "#ffffff",
+ "--dock-switch-thumb-text": "#131313",
} as CSSProperties;
}
diff --git a/web/src/lib/canvas/canvas-layout.ts b/web/src/lib/canvas/canvas-layout.ts
index 274fc32a5..2ff34794e 100644
--- a/web/src/lib/canvas/canvas-layout.ts
+++ b/web/src/lib/canvas/canvas-layout.ts
@@ -141,6 +141,66 @@ export function layoutCanvasAuto(nodes: CanvasNodeData[], connections: CanvasCon
return hasConnections ? layoutCanvasFlow(candidates, connections) : layoutCanvasNodesByMediaType(candidates);
}
+export const CANVAS_SPREAD_SCALE = 1.4;
+export const CANVAS_SPREAD_MIN_GAP = 56;
+
+/**
+ * 保持选区里节点的相对占位,只把彼此间距拉开。
+ * 先按选区左上角等比展开,再把仍然过近的盒子沿原有主方向推开,最后把整组钉回原来的左上角。
+ */
+export function spreadCanvasNodes(nodes: CanvasNodeData[], options?: { scale?: number; minGap?: number }) {
+ const result = new Map();
+ if (nodes.length < 2) return result;
+
+ const scale = options?.scale ?? CANVAS_SPREAD_SCALE;
+ const minGap = options?.minGap ?? CANVAS_SPREAD_MIN_GAP;
+ const originX = Math.min(...nodes.map((node) => node.position.x));
+ const originY = Math.min(...nodes.map((node) => node.position.y));
+ const items = nodes.map((node) => ({
+ id: node.id,
+ width: node.width,
+ height: node.height,
+ x: originX + (node.position.x - originX) * scale,
+ y: originY + (node.position.y - originY) * scale,
+ }));
+
+ for (let pass = 0; pass < 12; pass++) {
+ let moved = false;
+ for (let i = 0; i < items.length; i++) {
+ for (let j = i + 1; j < items.length; j++) {
+ const first = items[i]!;
+ const second = items[j]!;
+ const overlapX = Math.min(first.x + first.width + minGap, second.x + second.width + minGap) - Math.max(first.x, second.x);
+ const overlapY = Math.min(first.y + first.height + minGap, second.y + second.height + minGap) - Math.max(first.y, second.y);
+ if (overlapX <= 0 || overlapY <= 0) continue;
+
+ const centerDx = first.x + first.width / 2 - (second.x + second.width / 2);
+ const centerDy = first.y + first.height / 2 - (second.y + second.height / 2);
+ if (Math.abs(centerDx) >= Math.abs(centerDy)) {
+ const direction = centerDx === 0 ? (first.id < second.id ? 1 : -1) : centerDx > 0 ? 1 : -1;
+ const push = overlapX / 2;
+ first.x += direction * push;
+ second.x -= direction * push;
+ } else {
+ const direction = centerDy === 0 ? (first.id < second.id ? 1 : -1) : centerDy > 0 ? 1 : -1;
+ const push = overlapY / 2;
+ first.y += direction * push;
+ second.y -= direction * push;
+ }
+ moved = true;
+ }
+ }
+ if (!moved) break;
+ }
+
+ const nextOriginX = Math.min(...items.map((item) => item.x));
+ const nextOriginY = Math.min(...items.map((item) => item.y));
+ const deltaX = originX - nextOriginX;
+ const deltaY = originY - nextOriginY;
+ items.forEach((item) => result.set(item.id, { x: item.x + deltaX, y: item.y + deltaY }));
+ return result;
+}
+
export function canvasLayoutLane(node: CanvasNodeData): CanvasLayoutLane {
if (node.type === CanvasNodeType.Video) return "video";
if (node.type === CanvasNodeType.Audio) return "audio";
diff --git a/web/src/lib/canvas/canvas-shortcuts.ts b/web/src/lib/canvas/canvas-shortcuts.ts
index 38d8174ea..ed21680ad 100644
--- a/web/src/lib/canvas/canvas-shortcuts.ts
+++ b/web/src/lib/canvas/canvas-shortcuts.ts
@@ -61,7 +61,7 @@ export const CANVAS_SHORTCUTS: CanvasShortcutItem[] = [
id: "pan",
category: "navigation",
title: "平移视图",
- description: "触控板双指滑动,或按住空格键 / 中键拖动画布",
+ description: "触控板双指滑动,按住空格键 / 中键拖动,或切到抓手工具后拖动画布",
keys: [["触控板双指"], ["Space", "左键拖动"], ["中键拖动"]],
keywords: ["移动", "画布", "触控板", "pan"],
},
@@ -109,7 +109,7 @@ export const CANVAS_SHORTCUTS: CanvasShortcutItem[] = [
id: "box-select-tool",
category: "selection",
title: "使用区域选择工具",
- description: "保持区域选择模式,可连续框选多个范围",
+ description: "底部工具栏的胶囊开关切到区域选择后,空白处拖动可连续框选",
keys: [["区域选择", "拖动"]],
keywords: ["工具栏", "多选", "selection"],
},
diff --git a/web/src/lib/canvas/tool-registry/definitions/main-toolbar-tools.tsx b/web/src/lib/canvas/tool-registry/definitions/main-toolbar-tools.tsx
index 548cc90d0..6b4b3f7c0 100644
--- a/web/src/lib/canvas/tool-registry/definitions/main-toolbar-tools.tsx
+++ b/web/src/lib/canvas/tool-registry/definitions/main-toolbar-tools.tsx
@@ -1,29 +1,28 @@
-import { Eraser, FolderOpen, Hand, Palette, Plus, Redo2, Settings2, SquareDashedMousePointer, Trash2, Undo2 } from "lucide-react";
+import { Eraser, FolderOpen, Hand, MousePointer2, Palette, Plus, Redo2, Settings2, Trash2, Undo2 } from "lucide-react";
import { registerToolbarTools, type ToolDefinition } from "@/lib/canvas/tool-registry";
+import type { CanvasToolMode } from "@/types/canvas";
+
+const canvasModeOptions = [
+ { id: "box-select", label: "区域选择", icon: , value: "box-select" },
+ { id: "move", label: "抓手工具", icon: , value: "move" },
+];
export const mainToolbarTools: ToolDefinition[] = [
{
- id: "tool-move",
+ id: "tool-canvas-mode",
toolbar: "main",
category: "navigation",
- label: "抓手工具",
- icon: ,
+ label: "抓手 / 框选",
+ icon: ,
defaultVisible: true,
defaultOrder: 10,
- active: (ctx) => ctx.canvasTool === "move",
- run: (ctx) => ctx.handlers.onToolChange("move"),
- },
- {
- id: "tool-box-select",
- toolbar: "main",
- category: "navigation",
- label: "区域选择",
- icon: ,
- defaultVisible: true,
- defaultOrder: 20,
- active: (ctx) => ctx.canvasTool === "box-select",
- run: (ctx) => ctx.handlers.onToolChange("box-select"),
+ switchGroup: {
+ value: (ctx) => ctx.canvasTool,
+ options: canvasModeOptions,
+ onChange: (ctx, value) => ctx.handlers.onToolChange(value as CanvasToolMode),
+ },
+ run: (ctx) => ctx.handlers.onToolChange(ctx.canvasTool === "move" ? "box-select" : "move"),
},
{
id: "tool-undo",
diff --git a/web/src/lib/canvas/tool-registry/definitions/selection-toolbar-tools.tsx b/web/src/lib/canvas/tool-registry/definitions/selection-toolbar-tools.tsx
index f903dfe69..de1c54a18 100644
--- a/web/src/lib/canvas/tool-registry/definitions/selection-toolbar-tools.tsx
+++ b/web/src/lib/canvas/tool-registry/definitions/selection-toolbar-tools.tsx
@@ -3,7 +3,6 @@ import { AlignHorizontalJustifyCenter, AlignHorizontalJustifyEnd, AlignHorizonta
import { registerToolbarTools, type ToolDefinition } from "@/lib/canvas/tool-registry";
export const selectionToolbarTools: ToolDefinition[] = [
- // 对齐组
{ id: "selection-align-left", toolbar: "selection", category: "layout", label: "左对齐", icon: , defaultVisible: true, defaultOrder: 10, run: (ctx) => ctx.handlers.onAlign("left") },
{ id: "selection-align-center-x", toolbar: "selection", category: "layout", label: "水平居中", icon: , defaultVisible: true, defaultOrder: 20, run: (ctx) => ctx.handlers.onAlign("centerX") },
{ id: "selection-align-right", toolbar: "selection", category: "layout", label: "右对齐", icon: , defaultVisible: true, defaultOrder: 30, run: (ctx) => ctx.handlers.onAlign("right") },
@@ -12,14 +11,12 @@ export const selectionToolbarTools: ToolDefinition[] = [
{ id: "selection-align-bottom", toolbar: "selection", category: "layout", label: "底对齐", icon: , defaultVisible: true, defaultOrder: 60, run: (ctx) => ctx.handlers.onAlign("bottom") },
{ id: "selection-distribute-x", toolbar: "selection", category: "layout", label: "水平等距", icon: , 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: , 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: , defaultVisible: true, defaultOrder: 90, run: (ctx) => ctx.handlers.onArrange("row") },
- { id: "selection-arrange-column", toolbar: "selection", category: "arrange", label: "纵向排列", icon: , defaultVisible: true, defaultOrder: 100, run: (ctx) => ctx.handlers.onArrange("column") },
- { id: "selection-arrange-grid", toolbar: "selection", category: "arrange", label: "宫格排列", icon: , defaultVisible: true, defaultOrder: 110, run: (ctx) => ctx.handlers.onArrange("grid") },
- { id: "selection-arrange-flow", toolbar: "selection", category: "arrange", label: "按连线整理", icon: , defaultVisible: true, defaultOrder: 120, run: (ctx) => ctx.handlers.onArrange("flow") },
- // 分组组
+ { id: "selection-arrange-row", toolbar: "selection", category: "arrange", label: "横向排列", icon: , defaultVisible: false, defaultOrder: 90, run: (ctx) => ctx.handlers.onArrange("row") },
+ { id: "selection-arrange-column", toolbar: "selection", category: "arrange", label: "纵向排列", icon: , defaultVisible: false, defaultOrder: 100, run: (ctx) => ctx.handlers.onArrange("column") },
+ { id: "selection-arrange-grid", toolbar: "selection", category: "arrange", label: "宫格排列", icon: , defaultVisible: false, defaultOrder: 110, run: (ctx) => ctx.handlers.onArrange("grid") },
+ { id: "selection-arrange-flow", toolbar: "selection", category: "arrange", label: "按连线整理", icon: , defaultVisible: false, defaultOrder: 120, run: (ctx) => ctx.handlers.onArrange("flow") },
{ id: "selection-create-storyboard", toolbar: "selection", category: "selection", label: "创建分镜组", icon: , 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: , defaultVisible: true, defaultOrder: 140, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onCreateReferenceGroup() },
+ { id: "selection-create-reference-group", toolbar: "selection", category: "selection", label: "创建引用组", icon: , defaultVisible: false, defaultOrder: 140, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onCreateReferenceGroup() },
{ id: "selection-batch-connect", toolbar: "selection", category: "selection", label: "批量连接", icon: , defaultVisible: true, defaultOrder: 145, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onBatchConnect() },
{
id: "selection-merge-videos",
diff --git a/web/src/lib/canvas/tool-registry/index.ts b/web/src/lib/canvas/tool-registry/index.ts
index 7133323c3..bdb05f207 100644
--- a/web/src/lib/canvas/tool-registry/index.ts
+++ b/web/src/lib/canvas/tool-registry/index.ts
@@ -1,4 +1,4 @@
export type { AddNodeMenuCommand, AddNodeMenuContext, NodeToolbarGroup, ToolbarHandlers, ToolbarId, ToolbarPrefs, ToolCategory, ToolContext, ToolDefinition } from "./tool-definition";
-export { clearToolbarPrefs, persistToolbarPrefs, readToolbarPrefs } from "./tool-persistence";
+export { CANVAS_MODE_TOOL_ID, LEGACY_CANVAS_MODE_TOOL_IDS, clearToolbarPrefs, migrateToolbarPrefs, persistToolbarPrefs, readToolbarPrefs } from "./tool-persistence";
export { defaultToolbarPrefs, getAddNodeMenuCommands, getToolbarTools, registerAddNodeMenuCommands, registerToolbarTools, resolveAddNodeMenuCommands, resolveNodeToolbarPlacement, resolveToolbarEntries, resolveToolbarTools } from "./tool-registry";
import "./definitions";
diff --git a/web/src/lib/canvas/tool-registry/tool-definition.ts b/web/src/lib/canvas/tool-registry/tool-definition.ts
index fcb5183ba..74cf489b0 100644
--- a/web/src/lib/canvas/tool-registry/tool-definition.ts
+++ b/web/src/lib/canvas/tool-registry/tool-definition.ts
@@ -182,6 +182,12 @@ export type ToolDefinition = {
danger?: boolean;
/** 面板展开型工具——使用 aria-expanded 而非 aria-pressed */
expands?: boolean;
+ /** 互斥开关:在 dock 中渲染为分段切换,而不是两个独立按钮 */
+ switchGroup?: {
+ value: (ctx: ToolContext) => string;
+ options: Array<{ id: string; label: string; icon: ReactNode; value: string }>;
+ onChange: (ctx: ToolContext, value: string) => void;
+ };
/** 上下文可见性谓词——返回 false 时工具不渲染(不受 prefs 控制) */
applicable?: (ctx: ToolContext) => boolean;
/** 执行动作。event 来自 Dock 按钮点击 */
diff --git a/web/src/lib/canvas/tool-registry/tool-persistence.ts b/web/src/lib/canvas/tool-registry/tool-persistence.ts
index b1671f6f6..f6e6fe982 100644
--- a/web/src/lib/canvas/tool-registry/tool-persistence.ts
+++ b/web/src/lib/canvas/tool-registry/tool-persistence.ts
@@ -5,6 +5,9 @@ import type { ToolbarId, ToolbarPrefs } from "./tool-definition";
const STORAGE_VERSION = "v1";
const storageKey = (toolbar: ToolbarId) => `canvas-toolbar-prefs-${toolbar}-${STORAGE_VERSION}`;
+export const CANVAS_MODE_TOOL_ID = "tool-canvas-mode";
+export const LEGACY_CANVAS_MODE_TOOL_IDS = ["tool-move", "tool-box-select"] as const;
+
/**
* 读取工具栏偏好。缺失或解析失败时返回 null(由调用方决定是否用默认值)
*/
@@ -14,7 +17,8 @@ export function readToolbarPrefs(toolbar: ToolbarId): ToolbarPrefs | null {
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") : [] };
+ const prefs: ToolbarPrefs = { order: Array.isArray(parsed.order) ? parsed.order.filter((id) => typeof id === "string") : [], hidden: Array.isArray(parsed.hidden) ? parsed.hidden.filter((id) => typeof id === "string") : [] };
+ return migrateToolbarPrefs(toolbar, prefs);
} catch {
return null;
}
@@ -39,3 +43,22 @@ export function clearToolbarPrefs(toolbar: ToolbarId) {
function isPrefsShape(value: unknown): value is Partial {
return typeof value === "object" && value !== null;
}
+
+/** 把旧的抓手/框选两个独立按钮偏好合并成一个开关项 */
+export function migrateToolbarPrefs(toolbar: ToolbarId, prefs: ToolbarPrefs): ToolbarPrefs {
+ if (toolbar !== "main") return prefs;
+ const legacyIds = new Set(LEGACY_CANVAS_MODE_TOOL_IDS);
+ const hasLegacy = prefs.order.some((id) => legacyIds.has(id)) || prefs.hidden.some((id) => legacyIds.has(id));
+ if (!hasLegacy) return prefs;
+
+ const order = prefs.order.filter((id) => id !== CANVAS_MODE_TOOL_ID && !legacyIds.has(id));
+ const firstLegacyIndex = prefs.order.findIndex((id) => legacyIds.has(id));
+ const insertAt = firstLegacyIndex >= 0 ? Math.min(firstLegacyIndex, order.length) : 0;
+ order.splice(insertAt, 0, CANVAS_MODE_TOOL_ID);
+
+ const hiddenLegacy = LEGACY_CANVAS_MODE_TOOL_IDS.filter((id) => prefs.hidden.includes(id));
+ const hidden = prefs.hidden.filter((id) => id !== CANVAS_MODE_TOOL_ID && !legacyIds.has(id));
+ if (hiddenLegacy.length === LEGACY_CANVAS_MODE_TOOL_IDS.length) hidden.push(CANVAS_MODE_TOOL_ID);
+
+ return { order, hidden };
+}
diff --git a/web/src/lib/canvas/tool-registry/tool-registry.ts b/web/src/lib/canvas/tool-registry/tool-registry.ts
index 36c5f76e1..0381ed55f 100644
--- a/web/src/lib/canvas/tool-registry/tool-registry.ts
+++ b/web/src/lib/canvas/tool-registry/tool-registry.ts
@@ -58,10 +58,13 @@ function getPluginNodeMenuCommands(): AddNodeMenuCommand[] {
});
}
-/** 默认偏好:全部工具按 defaultOrder 排列,全部可见 */
+/** 默认偏好:全部工具按 defaultOrder 排列;defaultVisible 为 false 的进入 hidden */
export function defaultToolbarPrefs(toolbar: ToolbarId): ToolbarPrefs {
const tools = getToolbarTools(toolbar);
- return { order: tools.map((tool) => tool.id), hidden: [] };
+ return {
+ order: tools.map((tool) => tool.id),
+ hidden: tools.filter((tool) => !tool.defaultVisible).map((tool) => tool.id),
+ };
}
/**
@@ -127,6 +130,16 @@ function buildEntriesWithSeparators(tools: ToolDefinition[], ctx: ToolContext):
}
function toolToEntry(tool: ToolDefinition, ctx: ToolContext): FloatingDockEntry {
+ if (tool.switchGroup) {
+ return {
+ kind: "switch",
+ id: tool.id,
+ label: resolveText(tool.label, ctx),
+ value: tool.switchGroup.value(ctx),
+ options: tool.switchGroup.options,
+ onChange: (value) => tool.switchGroup?.onChange(ctx, value),
+ };
+ }
return {
kind: "command",
id: tool.id,
diff --git a/web/src/pages/canvas/canvas-project-context-menu.tsx b/web/src/pages/canvas/canvas-project-context-menu.tsx
index e50c88a07..6732eb1af 100644
--- a/web/src/pages/canvas/canvas-project-context-menu.tsx
+++ b/web/src/pages/canvas/canvas-project-context-menu.tsx
@@ -11,6 +11,7 @@ type CanvasProjectContextMenuProps = {
canUndo: boolean;
canRedo: boolean;
canPaste: boolean;
+ selectedCount: number;
screenToCanvas: (clientX: number, clientY: number) => Position;
onClose: () => void;
onAddNode: (type: CanvasNodeTypeId, position: Position) => void;
@@ -38,6 +39,9 @@ type CanvasProjectContextMenuProps = {
onUploadToArkPrivateAsset: (node: CanvasNodeData) => void;
onSetAssetCategory: (nodeId: string, category: CanvasAssetCategory) => void;
onToggleFrame: (node: CanvasNodeData) => void;
+ onSpreadSelection: () => void;
+ onCopySelection: () => void;
+ onDeleteSelection: () => void;
};
export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props }: CanvasProjectContextMenuProps) {
@@ -52,6 +56,7 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props
canUndo={props.canUndo}
canRedo={props.canRedo}
canPaste={props.canPaste}
+ selectedCount={props.selectedCount}
onClose={props.onClose}
onAddNode={(type) => {
if (menu.type === "canvas") props.onAddNode(type, menu.position);
@@ -106,6 +111,9 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props
onToggleFrame={() => {
if (node?.type === CanvasNodeType.Frame) props.onToggleFrame(node);
}}
+ onSpreadSelection={props.onSpreadSelection}
+ onCopySelection={props.onCopySelection}
+ onDeleteSelection={props.onDeleteSelection}
/>
);
}
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index f6567e039..f47c37531 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -947,6 +947,7 @@ function InfiniteCanvasPage() {
alignSelectedNodes,
autoArrangeCanvasNodes,
arrangeSelectedNodes,
+ spreadSelectedNodes,
copyNodesToClipboard,
copySelectedNodes,
createFolder,
@@ -1769,7 +1770,10 @@ function InfiniteCanvasPage() {
(event: ReactMouseEvent, id: string) => {
event.preventDefault();
event.stopPropagation();
- setSelectedNodeIds(new Set([id]));
+ setSelectedNodeIds((current) => {
+ if (current.has(id) && current.size > 1) return current;
+ return new Set([id]);
+ });
setSelectedConnectionId(null);
closeConnectionCreateMenu();
setToolbarNodeId(null);
@@ -2660,6 +2664,7 @@ function InfiniteCanvasPage() {
canUndo={historyState.canUndo}
canRedo={historyState.canRedo}
canPaste={hasCopiedNodes || Boolean(navigator.clipboard)}
+ selectedCount={selectedNodeIds.size}
screenToCanvas={screenToCanvas}
onClose={() => setContextMenu(null)}
onAddNode={(type, position) => createNode(type, position)}
@@ -2693,6 +2698,9 @@ function InfiniteCanvasPage() {
onUploadToArkPrivateAsset={confirmUploadNodeImageToArkPrivateAsset}
onSetAssetCategory={(nodeId, assetCategory) => handleConfigNodeChange(nodeId, { assetCategory })}
onToggleFrame={(node) => handleFrameToggle(node.id)}
+ onSpreadSelection={spreadSelectedNodes}
+ onCopySelection={copySelectedNodes}
+ onDeleteSelection={() => deleteNodes(selectedNodeIds)}
/>
diff --git a/web/src/pages/canvas/use-canvas-node-operations.ts b/web/src/pages/canvas/use-canvas-node-operations.ts
index 0e16916c7..396d1e795 100644
--- a/web/src/pages/canvas/use-canvas-node-operations.ts
+++ b/web/src/pages/canvas/use-canvas-node-operations.ts
@@ -5,7 +5,7 @@ import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_HEADER_HEIGHT, getFrameChildIds, getFrameChildren, isFrameNode } from "@/lib/canvas/canvas-frame";
-import { alignCanvasNodes, layoutCanvasAuto, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
+import { alignCanvasNodes, layoutCanvasAuto, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, spreadCanvasNodes, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
import { applyCanvasConnectionPromptSync } from "@/lib/canvas/canvas-resource-references";
import { createCanvasNode, isHiddenBatchChild, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain";
import { isolateCopiedNodeMetadata, nextCopiedNodeTitle } from "@/lib/canvas/canvas-node-copy";
@@ -206,6 +206,19 @@ export function useCanvasNodeOperations({
message.success(hasSelection ? "已按媒体分类整理选中节点" : "已按媒体分类整理画布");
}, [commitNodes, connectionsRef, message, nodesRef, selectedNodeIdsRef]);
+ const spreadSelectedNodes = useCallback(() => {
+ const currentNodes = nodesRef.current;
+ const selected = currentNodes.filter((node) => selectedNodeIdsRef.current.has(node.id) && !node.metadata?.locked && !isFrameNode(node) && !isHiddenBatchChild(node, currentNodes));
+ if (selected.length < 2) {
+ message.info("请至少选择两个可整理节点");
+ return;
+ }
+ const positions = spreadCanvasNodes(selected);
+ if (!positions.size) return;
+ commitNodes(currentNodes.map((node) => positions.has(node.id) ? { ...node, position: positions.get(node.id)! } : node));
+ message.success("已按相对布局加大间距");
+ }, [commitNodes, message, nodesRef, selectedNodeIdsRef]);
+
const alignSelectedNodes = useCallback((mode: CanvasAlignmentMode) => {
const selected = nodesRef.current.filter((node) => selectedNodeIdsRef.current.has(node.id) && !node.metadata?.locked && !isFrameNode(node));
if (selected.length < 2 || ((mode === "distributeX" || mode === "distributeY") && selected.length < 3)) return;
@@ -541,6 +554,7 @@ export function useCanvasNodeOperations({
alignSelectedNodes,
autoArrangeCanvasNodes,
arrangeSelectedNodes,
+ spreadSelectedNodes,
copyNodesToClipboard,
copySelectedNodes,
createFolder,
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index e12f31e6b..255bf0e83 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -951,6 +951,7 @@
--dock-tooltip-z: 140;
--dock-precision-width: var(--space-12);
--dock-z-popover: 30; /* dock 子面板在 toolbar 根 stacking context 内的层级(见 ADR-001)*/
+ --dock-switch-track: #000000; /* 抓手/框选胶囊轨道:纯黑,和工具栏表面分开 */
/* 页面顶部栏(PageHeader / 工作台头部)*/
--page-topbar-h: 64px;
@@ -6740,6 +6741,43 @@
background: color-mix(in srgb, currentColor 13%, transparent);
}
+ .aceternity-dock-switch-track {
+ border-radius: 999px;
+ background: var(--dock-switch-track, #000000);
+ }
+
+ .aceternity-dock-switch-thumb {
+ background: var(--dock-switch-thumb, #ffffff);
+ }
+
+ .aceternity-dock-switch-option {
+ color: #ffffff;
+ background: transparent;
+ opacity: 0.62;
+ transition:
+ color var(--motion-instant) ease-out,
+ opacity var(--motion-instant) ease-out;
+ }
+
+ .aceternity-dock-switch-option:hover:not([aria-checked="true"]) {
+ opacity: 0.88;
+ }
+
+ .aceternity-dock-switch-option[aria-checked="true"] {
+ color: var(--dock-switch-thumb-text, var(--background));
+ opacity: 1;
+ }
+
+ .aceternity-dock-switch-option > span svg {
+ width: 100%;
+ height: 100%;
+ stroke-width: 1.9;
+ }
+
+ .aceternity-dock-switch-option:focus-visible {
+ box-shadow: 0 0 0 2px var(--dock-command-bg), 0 0 0 4px var(--dock-switch-thumb, var(--workspace-accent));
+ }
+
.aceternity-dock-tooltip {
border-color: var(--dock-tooltip-border);
background: var(--dock-tooltip-bg);
diff --git a/web/test/canvas-layout-categories.test.ts b/web/test/canvas-layout-categories.test.ts
index cd1e5a594..ecec74430 100644
--- a/web/test/canvas-layout-categories.test.ts
+++ b/web/test/canvas-layout-categories.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
-import { canvasLayoutLane, layoutCanvasAuto, layoutCanvasFlow, layoutCanvasNodesByMediaType } from "@/lib/canvas/canvas-layout";
+import { canvasLayoutLane, layoutCanvasAuto, layoutCanvasFlow, layoutCanvasNodesByMediaType, spreadCanvasNodes } from "@/lib/canvas/canvas-layout";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
function node(id: string, type: CanvasNodeType, width = 320, height = 180, locked = false): CanvasNodeData {
@@ -63,3 +63,29 @@ describe("canvas media-aware layout", () => {
expect(canvasLayoutLane(node("audio", CanvasNodeType.Audio))).toBe("audio");
});
});
+
+describe("canvas relative-layout spread", () => {
+ test("keeps relative occupancy and increases the gap between neighbors", () => {
+ const left: CanvasNodeData = { ...node("left", CanvasNodeType.Image, 100, 80), position: { x: 0, y: 0 } };
+ const right: CanvasNodeData = { ...node("right", CanvasNodeType.Image, 100, 80), position: { x: 120, y: 10 } };
+ const positions = spreadCanvasNodes([left, right], { scale: 1.5, minGap: 40 });
+
+ expect(positions.get("left")!.x).toBe(0);
+ expect(positions.get("right")!.x).toBeGreaterThan(120);
+ expect(positions.get("right")!.x).toBeGreaterThan(positions.get("left")!.x);
+ expect(positions.get("right")!.x - (positions.get("left")!.x + left.width)).toBeGreaterThanOrEqual(40);
+ });
+
+ test("keeps a left node left of a right node after resolving overlap", () => {
+ const first: CanvasNodeData = { ...node("first", CanvasNodeType.Image, 120, 90), position: { x: 40, y: 20 } };
+ const second: CanvasNodeData = { ...node("second", CanvasNodeType.Image, 120, 90), position: { x: 80, y: 30 } };
+ const positions = spreadCanvasNodes([first, second], { scale: 1, minGap: 48 });
+
+ expect(positions.get("first")!.x).toBeLessThan(positions.get("second")!.x);
+ expect(positions.get("second")!.x - (positions.get("first")!.x + first.width)).toBeGreaterThanOrEqual(48);
+ });
+
+ test("returns no moves for a single node", () => {
+ expect(spreadCanvasNodes([node("only", CanvasNodeType.Text)]).size).toBe(0);
+ });
+});
diff --git a/web/test/canvas-toolbar-mode-switch.test.tsx b/web/test/canvas-toolbar-mode-switch.test.tsx
new file mode 100644
index 000000000..4df271ffb
--- /dev/null
+++ b/web/test/canvas-toolbar-mode-switch.test.tsx
@@ -0,0 +1,126 @@
+import { describe, expect, test } from "bun:test";
+import { renderToStaticMarkup } from "react-dom/server";
+import { Hand, MousePointer2 } from "lucide-react";
+
+import { FloatingDock } from "@/components/ui/aceternity/floating-dock";
+import { CANVAS_MODE_TOOL_ID, defaultToolbarPrefs, migrateToolbarPrefs, resolveToolbarEntries, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry";
+
+function createMainContext(canvasTool: "move" | "box-select" = "box-select"): ToolContext {
+ return {
+ selectedCount: 0,
+ selectedNodeTypes: new Set(),
+ selectedVideoCount: 0,
+ canvasTool,
+ workspaceMode: "professional",
+ isProjectLinked: false,
+ canUndo: false,
+ canRedo: false,
+ extractingVideoFrames: false,
+ extractingAudio: false,
+ trimmingVideo: false,
+ mergingVideos: false,
+ addPanelOpen: false,
+ appearancePanelOpen: false,
+ settingsPanelOpen: false,
+ handlers: {} as ToolbarHandlers,
+ };
+}
+
+describe("canvas toolbar mode switch", () => {
+ test("renders grab and box-select as one dock switch", () => {
+ const entries = resolveToolbarEntries("main", createMainContext("box-select"), null);
+ const modeSwitch = entries.find((entry) => entry.kind === "switch" && entry.id === CANVAS_MODE_TOOL_ID);
+
+ expect(modeSwitch?.kind).toBe("switch");
+ if (modeSwitch?.kind !== "switch") return;
+ expect(modeSwitch.value).toBe("box-select");
+ expect(modeSwitch.options.map((option) => option.value)).toEqual(["box-select", "move"]);
+ expect(entries.some((entry) => entry.id === "tool-move" || entry.id === "tool-box-select")).toBe(false);
+ });
+
+ test("reflects the active canvas tool on the switch", () => {
+ const entries = resolveToolbarEntries("main", createMainContext("move"), null);
+ const modeSwitch = entries.find((entry) => entry.kind === "switch");
+ expect(modeSwitch?.kind === "switch" && modeSwitch.value).toBe("move");
+ });
+
+ test("migrates legacy separate tool prefs into the switch item", () => {
+ const migrated = migrateToolbarPrefs("main", {
+ order: ["tool-move", "tool-box-select", "tool-undo", "tool-add"],
+ hidden: [],
+ });
+ expect(migrated.order[0]).toBe(CANVAS_MODE_TOOL_ID);
+ expect(migrated.order).not.toContain("tool-move");
+ expect(migrated.order).not.toContain("tool-box-select");
+ expect(migrated.hidden).toEqual([]);
+ });
+
+ test("hides the switch only when both legacy tools were hidden", () => {
+ const oneHidden = migrateToolbarPrefs("main", {
+ order: ["tool-move", "tool-box-select", "tool-undo"],
+ hidden: ["tool-move"],
+ });
+ expect(oneHidden.hidden).toEqual([]);
+
+ const bothHidden = migrateToolbarPrefs("main", {
+ order: ["tool-undo", "tool-move", "tool-box-select"],
+ hidden: ["tool-move", "tool-box-select"],
+ });
+ expect(bothHidden.order[1]).toBe(CANVAS_MODE_TOOL_ID);
+ expect(bothHidden.hidden).toEqual([CANVAS_MODE_TOOL_ID]);
+ });
+
+ test("leaves non-main toolbar prefs unchanged", () => {
+ const prefs = { order: ["tool-move"], hidden: ["tool-box-select"] };
+ expect(migrateToolbarPrefs("selection", prefs)).toEqual(prefs);
+ });
+
+ test("keeps the switch visible in default main toolbar prefs", () => {
+ expect(defaultToolbarPrefs("main").order[0]).toBe(CANVAS_MODE_TOOL_ID);
+ expect(defaultToolbarPrefs("main").hidden).toEqual([]);
+ });
+
+ test("hides rarely used arrange tools from the selection toolbar by default", () => {
+ const prefs = defaultToolbarPrefs("selection");
+ expect(prefs.hidden).toEqual(expect.arrayContaining([
+ "selection-arrange-row",
+ "selection-arrange-column",
+ "selection-arrange-grid",
+ "selection-arrange-flow",
+ "selection-create-reference-group",
+ ]));
+
+ const entries = resolveToolbarEntries("selection", createMainContext(), null);
+ expect(entries.some((entry) => entry.id === "selection-align-left")).toBe(true);
+ expect(entries.some((entry) => entry.id === "selection-distribute-x")).toBe(true);
+ expect(entries.some((entry) => entry.id === "selection-batch-connect")).toBe(true);
+ expect(entries.some((entry) => entry.id === "selection-arrange-grid")).toBe(false);
+ expect(entries.some((entry) => entry.id === "selection-create-reference-group")).toBe(false);
+ });
+
+ test("renders the pill switch with an active circular thumb", () => {
+ const html = renderToStaticMarkup(
+ , value: "box-select" },
+ { id: "move", label: "抓手工具", icon: , value: "move" },
+ ],
+ onChange: () => {},
+ }]}
+ />,
+ );
+
+ expect(html).toContain('role="radiogroup"');
+ expect(html).toContain("aceternity-dock-switch-track");
+ expect(html).toContain("aceternity-dock-switch-thumb");
+ expect(html).toContain('aria-label="区域选择"');
+ expect(html).toContain('aria-label="抓手工具"');
+ expect(html).toContain('aria-checked="true"');
+ expect(html).toContain('aria-checked="false"');
+ });
+});