feat(canvas): 画布工具 - 抓手框选改开关并精简多选整理
将底部抓手与区域选择收成胶囊开关,精简框选悬浮栏,并在右键提供按相对布局加大边距的自适应整理。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
126
web/test/canvas-toolbar-mode-switch.test.tsx
Normal file
126
web/test/canvas-toolbar-mode-switch.test.tsx
Normal file
@@ -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(
|
||||
<FloatingDock
|
||||
items={[{
|
||||
kind: "switch",
|
||||
id: CANVAS_MODE_TOOL_ID,
|
||||
label: "抓手 / 框选",
|
||||
value: "box-select",
|
||||
options: [
|
||||
{ id: "box-select", label: "区域选择", icon: <MousePointer2 />, value: "box-select" },
|
||||
{ id: "move", label: "抓手工具", icon: <Hand />, 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"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user