rspace-online/lib/mi-selection-transforms.ts

194 lines
5.6 KiB
TypeScript

/**
* Selection Transforms — align, distribute, arrange, and match-size
* operations on canvas shape elements.
*
* Each function accepts an array of shape elements (with x, y, width, height)
* and mutates their positions in place.
*
* Exposed on `window.__miSelectionTransforms` so the MiActionExecutor can
* invoke them by name.
*/
interface ShapeEl {
x: number;
y: number;
width: number;
height: number;
}
// ── Align ──
export function alignLeft(shapes: ShapeEl[]) {
if (shapes.length < 2) return;
const minX = Math.min(...shapes.map((s) => s.x));
for (const s of shapes) s.x = minX;
}
export function alignRight(shapes: ShapeEl[]) {
if (shapes.length < 2) return;
const maxRight = Math.max(...shapes.map((s) => s.x + s.width));
for (const s of shapes) s.x = maxRight - s.width;
}
export function alignCenterH(shapes: ShapeEl[]) {
if (shapes.length < 2) return;
const centers = shapes.map((s) => s.x + s.width / 2);
const avg = centers.reduce((a, b) => a + b, 0) / centers.length;
for (const s of shapes) s.x = avg - s.width / 2;
}
export function alignTop(shapes: ShapeEl[]) {
if (shapes.length < 2) return;
const minY = Math.min(...shapes.map((s) => s.y));
for (const s of shapes) s.y = minY;
}
export function alignBottom(shapes: ShapeEl[]) {
if (shapes.length < 2) return;
const maxBottom = Math.max(...shapes.map((s) => s.y + s.height));
for (const s of shapes) s.y = maxBottom - s.height;
}
export function alignCenterV(shapes: ShapeEl[]) {
if (shapes.length < 2) return;
const centers = shapes.map((s) => s.y + s.height / 2);
const avg = centers.reduce((a, b) => a + b, 0) / centers.length;
for (const s of shapes) s.y = avg - s.height / 2;
}
// ── Distribute ──
export function distributeH(shapes: ShapeEl[]) {
if (shapes.length < 3) return;
const sorted = [...shapes].sort((a, b) => a.x - b.x);
const first = sorted[0];
const last = sorted[sorted.length - 1];
const totalWidth = sorted.reduce((s, el) => s + el.width, 0);
const span = last.x + last.width - first.x;
const gap = (span - totalWidth) / (sorted.length - 1);
let cursor = first.x;
for (const s of sorted) {
s.x = cursor;
cursor += s.width + gap;
}
}
export function distributeV(shapes: ShapeEl[]) {
if (shapes.length < 3) return;
const sorted = [...shapes].sort((a, b) => a.y - b.y);
const first = sorted[0];
const last = sorted[sorted.length - 1];
const totalHeight = sorted.reduce((s, el) => s + el.height, 0);
const span = last.y + last.height - first.y;
const gap = (span - totalHeight) / (sorted.length - 1);
let cursor = first.y;
for (const s of sorted) {
s.y = cursor;
cursor += s.height + gap;
}
}
// ── Arrange ──
export function arrangeRow(shapes: ShapeEl[]) {
if (shapes.length < 2) return;
const gap = 30;
const baseY = Math.min(...shapes.map((s) => s.y));
let cursor = shapes[0].x;
for (const s of shapes) {
s.x = cursor;
s.y = baseY;
cursor += s.width + gap;
}
}
export function arrangeColumn(shapes: ShapeEl[]) {
if (shapes.length < 2) return;
const gap = 30;
const baseX = Math.min(...shapes.map((s) => s.x));
let cursor = shapes[0].y;
for (const s of shapes) {
s.x = baseX;
s.y = cursor;
cursor += s.height + gap;
}
}
export function arrangeGrid(shapes: ShapeEl[]) {
if (shapes.length < 2) return;
const cols = Math.ceil(Math.sqrt(shapes.length));
const gap = 30;
const baseX = Math.min(...shapes.map((s) => s.x));
const baseY = Math.min(...shapes.map((s) => s.y));
const maxW = Math.max(...shapes.map((s) => s.width));
const maxH = Math.max(...shapes.map((s) => s.height));
for (let i = 0; i < shapes.length; i++) {
const col = i % cols;
const row = Math.floor(i / cols);
shapes[i].x = baseX + col * (maxW + gap);
shapes[i].y = baseY + row * (maxH + gap);
}
}
export function arrangeCircle(shapes: ShapeEl[]) {
if (shapes.length < 2) return;
const cx = shapes.reduce((s, el) => s + el.x + el.width / 2, 0) / shapes.length;
const cy = shapes.reduce((s, el) => s + el.y + el.height / 2, 0) / shapes.length;
const maxDim = Math.max(...shapes.map((s) => Math.max(s.width, s.height)));
const radius = Math.max(maxDim * shapes.length / (2 * Math.PI), 150);
const step = (2 * Math.PI) / shapes.length;
for (let i = 0; i < shapes.length; i++) {
const angle = step * i - Math.PI / 2;
shapes[i].x = cx + radius * Math.cos(angle) - shapes[i].width / 2;
shapes[i].y = cy + radius * Math.sin(angle) - shapes[i].height / 2;
}
}
// ── Match Size ──
export function matchWidth(shapes: ShapeEl[]) {
if (shapes.length < 2) return;
const maxW = Math.max(...shapes.map((s) => s.width));
for (const s of shapes) s.width = maxW;
}
export function matchHeight(shapes: ShapeEl[]) {
if (shapes.length < 2) return;
const maxH = Math.max(...shapes.map((s) => s.height));
for (const s of shapes) s.height = maxH;
}
export function matchSize(shapes: ShapeEl[]) {
matchWidth(shapes);
matchHeight(shapes);
}
// ── Registry: kebab-name → function ──
const TRANSFORM_MAP: Record<string, (shapes: ShapeEl[]) => void> = {
"align-left": alignLeft,
"align-right": alignRight,
"align-center-h": alignCenterH,
"align-top": alignTop,
"align-bottom": alignBottom,
"align-center-v": alignCenterV,
"distribute-h": distributeH,
"distribute-v": distributeV,
"arrange-row": arrangeRow,
"arrange-column": arrangeColumn,
"arrange-grid": arrangeGrid,
"arrange-circle": arrangeCircle,
"match-width": matchWidth,
"match-height": matchHeight,
"match-size": matchSize,
};
/** Install the transform map on window so the action executor can find them. */
export function installSelectionTransforms() {
(window as any).__miSelectionTransforms = TRANSFORM_MAP;
}