194 lines
5.6 KiB
TypeScript
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;
|
|
}
|