feat(canvas): 画布工具 - 抓手框选改开关并精简多选整理

将底部抓手与区域选择收成胶囊开关,精简框选悬浮栏,并在右键提供按相对布局加大边距的自适应整理。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-08 23:53:22 +08:00
parent f176043ac4
commit 94dba61a6c
19 changed files with 533 additions and 39 deletions

View File

@@ -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;
}

View File

@@ -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<string, Position>();
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";

View File

@@ -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"],
},

View File

@@ -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: <MousePointer2 />, value: "box-select" },
{ id: "move", label: "抓手工具", icon: <Hand />, value: "move" },
];
export const mainToolbarTools: ToolDefinition[] = [
{
id: "tool-move",
id: "tool-canvas-mode",
toolbar: "main",
category: "navigation",
label: "抓手工具",
icon: <Hand />,
label: "抓手 / 框选",
icon: <MousePointer2 />,
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: <SquareDashedMousePointer />,
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",

View File

@@ -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: <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") },
@@ -12,14 +11,12 @@ export const selectionToolbarTools: ToolDefinition[] = [
{ 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-arrange-row", toolbar: "selection", category: "arrange", label: "横向排列", icon: <AlignHorizontalSpaceAround />, defaultVisible: false, defaultOrder: 90, run: (ctx) => ctx.handlers.onArrange("row") },
{ id: "selection-arrange-column", toolbar: "selection", category: "arrange", label: "纵向排列", icon: <AlignVerticalSpaceAround />, defaultVisible: false, defaultOrder: 100, run: (ctx) => ctx.handlers.onArrange("column") },
{ id: "selection-arrange-grid", toolbar: "selection", category: "arrange", label: "宫格排列", icon: <Grid3X3 />, defaultVisible: false, defaultOrder: 110, run: (ctx) => ctx.handlers.onArrange("grid") },
{ id: "selection-arrange-flow", toolbar: "selection", category: "arrange", label: "按连线整理", icon: <Workflow />, defaultVisible: false, 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-create-reference-group", toolbar: "selection", category: "selection", label: "创建引用组", icon: <FolderTree />, defaultVisible: false, defaultOrder: 140, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onCreateReferenceGroup() },
{ id: "selection-batch-connect", toolbar: "selection", category: "selection", label: "批量连接", icon: <Link2 />, defaultVisible: true, defaultOrder: 145, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onBatchConnect() },
{
id: "selection-merge-videos",

View File

@@ -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";

View File

@@ -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 按钮点击 */

View File

@@ -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<ToolbarPrefs> {
return typeof value === "object" && value !== null;
}
/** 把旧的抓手/框选两个独立按钮偏好合并成一个开关项 */
export function migrateToolbarPrefs(toolbar: ToolbarId, prefs: ToolbarPrefs): ToolbarPrefs {
if (toolbar !== "main") return prefs;
const legacyIds = new Set<string>(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 };
}

View File

@@ -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,