From 94dba61a6c3c00e77c975b3c4c122c57eb10edbf Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Tue, 8 Sep 2026 23:53:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E7=94=BB=E5=B8=83=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=20-=20=E6=8A=93=E6=89=8B=E6=A1=86=E9=80=89=E6=94=B9?= =?UTF-8?q?=E5=BC=80=E5=85=B3=E5=B9=B6=E7=B2=BE=E7=AE=80=E5=A4=9A=E9=80=89?= =?UTF-8?q?=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将底部抓手与区域选择收成胶囊开关,精简框选悬浮栏,并在右键提供按相对布局加大边距的自适应整理。 Co-authored-by: Cursor --- docs/content/docs/overview/features.mdx | 13 ++ docs/content/docs/progress/pending-test.mdx | 29 ++++ .../components/canvas/canvas-context-menu.tsx | 32 ++++- .../ui/aceternity/floating-dock.tsx | 109 ++++++++++++++- web/src/lib/canvas/canvas-aceternity-style.ts | 3 + web/src/lib/canvas/canvas-layout.ts | 60 +++++++++ web/src/lib/canvas/canvas-shortcuts.ts | 4 +- .../definitions/main-toolbar-tools.tsx | 33 +++-- .../definitions/selection-toolbar-tools.tsx | 13 +- web/src/lib/canvas/tool-registry/index.ts | 2 +- .../canvas/tool-registry/tool-definition.ts | 6 + .../canvas/tool-registry/tool-persistence.ts | 25 +++- .../lib/canvas/tool-registry/tool-registry.ts | 17 ++- .../canvas/canvas-project-context-menu.tsx | 8 ++ web/src/pages/canvas/project.tsx | 10 +- .../canvas/use-canvas-node-operations.ts | 16 ++- web/src/styles/globals.css | 38 ++++++ web/test/canvas-layout-categories.test.ts | 28 +++- web/test/canvas-toolbar-mode-switch.test.tsx | 126 ++++++++++++++++++ 19 files changed, 533 insertions(+), 39 deletions(-) create mode 100644 web/test/canvas-toolbar-mode-switch.test.tsx 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"'); + }); +});