feat(canvas): PR #500 - 合并多角度与切分并保留本地 Agent 改造

This commit is contained in:
ddCat
2026-09-14 19:48:17 +08:00
26 changed files with 1431 additions and 153 deletions

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import { clampGridSplitSize, isValidGridSplit, layoutGridSplitCells } from "./canvas-grid-split";
describe("canvas-grid-split", () => {
test("clamps size into 1..5", () => {
expect(clampGridSplitSize(0)).toBe(1);
expect(clampGridSplitSize(8)).toBe(5);
expect(clampGridSplitSize(2.6)).toBe(3);
});
test("rejects a single cell", () => {
expect(isValidGridSplit({ rows: 1, columns: 1 })).toBe(false);
expect(isValidGridSplit({ rows: 1, columns: 2 })).toBe(true);
expect(isValidGridSplit({ rows: 3, columns: 2 })).toBe(true);
});
test("lays out fitted cells without overlap", () => {
const cells = [
{ row: 0, column: 0, width: 592, height: 432 },
{ row: 0, column: 1, width: 592, height: 432 },
{ row: 1, column: 0, width: 592, height: 432 },
{ row: 1, column: 1, width: 592, height: 432 },
];
const positions = layoutGridSplitCells({ x: 100, y: 40 }, cells, 48);
expect(positions[1]).toEqual({ x: 100 + 592 + 48, y: 40 });
expect(positions[2]).toEqual({ x: 100, y: 40 + 432 + 48 });
expect(positions[3]).toEqual({ x: 100 + 592 + 48, y: 40 + 432 + 48 });
expect(positions[1]!.x).toBeGreaterThanOrEqual(positions[0]!.x + cells[0]!.width + 48);
expect(positions[2]!.y).toBeGreaterThanOrEqual(positions[0]!.y + cells[0]!.height + 48);
});
});

View File

@@ -0,0 +1,49 @@
import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data";
export const CANVAS_GRID_SPLIT_MAX = 5;
export const CANVAS_GRID_SPLIT_GAP = 48;
export const CANVAS_GRID_SPLIT_PRESETS: Array<{ rows: number; columns: number; label: string }> = [
{ rows: 2, columns: 2, label: "4宫格 (2×2)" },
{ rows: 3, columns: 3, label: "9宫格 (3×3)" },
{ rows: 4, columns: 4, label: "16宫格 (4×4)" },
{ rows: 5, columns: 5, label: "25宫格 (5×5)" },
];
export type GridSplitCellSize = {
row: number;
column: number;
width: number;
height: number;
};
export function clampGridSplitSize(value: number) {
if (!Number.isFinite(value)) return 2;
return Math.max(1, Math.min(CANVAS_GRID_SPLIT_MAX, Math.round(value)));
}
export function isValidGridSplit(params: ImageSplitParams) {
const rows = clampGridSplitSize(params.rows);
const columns = clampGridSplitSize(params.columns);
return rows * columns >= 2;
}
export function layoutGridSplitCells(origin: { x: number; y: number }, cells: GridSplitCellSize[], gap = CANVAS_GRID_SPLIT_GAP) {
const columnCount = Math.max(0, ...cells.map((cell) => cell.column)) + 1;
const rowCount = Math.max(0, ...cells.map((cell) => cell.row)) + 1;
const columnWidth = Array.from({ length: columnCount }, (_, column) => Math.max(1, ...cells.filter((cell) => cell.column === column).map((cell) => cell.width)));
const rowHeight = Array.from({ length: rowCount }, (_, row) => Math.max(1, ...cells.filter((cell) => cell.row === row).map((cell) => cell.height)));
const columnX: number[] = [];
let x = origin.x;
for (let column = 0; column < columnCount; column += 1) {
columnX.push(x);
x += (columnWidth[column] || 1) + gap;
}
const rowY: number[] = [];
let y = origin.y;
for (let row = 0; row < rowCount; row += 1) {
rowY.push(y);
y += (rowHeight[row] || 1) + gap;
}
return cells.map((cell) => ({ x: columnX[cell.column] || origin.x, y: rowY[cell.row] || origin.y }));
}

View File

@@ -14,6 +14,7 @@ export type CanvasNodeDragPreview = {
type NodeDragPreviewDomState = {
elementsById: Map<string, HTMLElement>;
previousIds: Set<string>;
selectionBounds: HTMLElement | null;
};
type NodeSelectionPreviewDomState = {
@@ -83,13 +84,14 @@ export function applyCanvasNodeDragPreview(container: HTMLDivElement | null, pre
let state = nodeDragPreviewDomStates.get(container);
if (!state) {
state = { elementsById: new Map(), previousIds: new Set() };
state = { elementsById: new Map(), previousIds: new Set(), selectionBounds: null };
nodeDragPreviewDomStates.set(container, state);
}
for (const nodeId of state.previousIds) {
state.elementsById.get(nodeId)?.style.removeProperty("translate");
}
state.selectionBounds?.style.removeProperty("translate");
state.previousIds.clear();
if (preview) {
@@ -100,6 +102,7 @@ export function applyCanvasNodeDragPreview(container: HTMLDivElement | null, pre
const nodeId = element.dataset.nodeId;
if (nodeId) state?.elementsById.set(nodeId, element);
});
state.selectionBounds = container.querySelector<HTMLElement>("[data-canvas-selection-bounds]");
}
for (const nodeId of preview.nodeIds) {
const element = state.elementsById.get(nodeId);
@@ -107,8 +110,13 @@ export function applyCanvasNodeDragPreview(container: HTMLDivElement | null, pre
element.style.setProperty("translate", `${preview.x}px ${preview.y}px`);
state.previousIds.add(nodeId);
}
if (!state.selectionBounds?.isConnected) {
state.selectionBounds = container.querySelector<HTMLElement>("[data-canvas-selection-bounds]");
}
state.selectionBounds?.style.setProperty("translate", `${preview.x}px ${preview.y}px`);
} else {
state.elementsById.clear();
state.selectionBounds = null;
}
container.dispatchEvent(new CustomEvent<CanvasNodeDragPreview | null>(CANVAS_NODE_DRAG_PREVIEW_EVENT, { detail: preview }));

View File

@@ -131,3 +131,28 @@ export function resolveCanvasSelectionPreviewDelta(initialSelection: ReadonlySet
}
return { includeNodeIds, removeNodeIds };
}
export type SelectedNodeBounds = {
left: number;
top: number;
width: number;
height: number;
count: number;
};
export function selectedNodesWorldBounds(
nodes: Array<{ id: string; position: { x: number; y: number }; width: number; height: number }>,
preview?: { x: number; y: number; nodeIds: ReadonlySet<string> } | null,
): SelectedNodeBounds | null {
if (nodes.length < 2) return null;
const left = Math.min(...nodes.map((node) => node.position.x + (preview?.nodeIds.has(node.id) ? preview.x : 0)));
const top = Math.min(...nodes.map((node) => node.position.y + (preview?.nodeIds.has(node.id) ? preview.y : 0)));
const right = Math.max(...nodes.map((node) => node.position.x + (preview?.nodeIds.has(node.id) ? preview.x : 0) + node.width));
const bottom = Math.max(...nodes.map((node) => node.position.y + (preview?.nodeIds.has(node.id) ? preview.y : 0) + node.height));
return { left, top, width: right - left, height: bottom - top, count: nodes.length };
}
export function offsetSelectedNodeBounds(bounds: SelectedNodeBounds, preview: { x: number; y: number } | null | undefined): SelectedNodeBounds {
if (!preview || (preview.x === 0 && preview.y === 0)) return bounds;
return { ...bounds, left: bounds.left + preview.x, top: bounds.top + preview.y };
}