feat(canvas): 画布工具 - 抓手框选改开关并精简多选整理
将底部抓手与区域选择收成胶囊开关,精简框选悬浮栏,并在右键提供按相对布局加大边距的自适应整理。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 按钮点击 */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user