feat(canvas): PR #500 - 合并多角度与切分并保留本地 Agent 改造
This commit is contained in:
32
web/src/lib/canvas/canvas-grid-split.test.ts
Normal file
32
web/src/lib/canvas/canvas-grid-split.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
web/src/lib/canvas/canvas-grid-split.ts
Normal file
49
web/src/lib/canvas/canvas-grid-split.ts
Normal 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 }));
|
||||
}
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user