import type { Bounds, EditorElement, ElementId } from "./index.js"; export type SelectionBoundsMode = "contains" | "intersects"; export type SelectionAlignment = | "left" | "center-x" | "right" | "top" | "center-y" | "bottom"; export type SelectionDistributionAxis = "x" | "y"; export interface SelectionBoundsChange { readonly id: ElementId; readonly bounds: Bounds; } export interface SelectionPropertiesChange { readonly id: ElementId; readonly properties: Readonly>; } function normalizedBounds(bounds: Bounds): Bounds { const right = bounds.x + bounds.width; const bottom = bounds.y + bounds.height; const x = Math.min(bounds.x, right); const y = Math.min(bounds.y, bottom); return { x, y, width: Math.abs(bounds.width), height: Math.abs(bounds.height), }; } function right(bounds: Bounds): number { return bounds.x + bounds.width; } function bottom(bounds: Bounds): number { return bounds.y + bounds.height; } export function selectionBounds( elements: readonly Pick[], ): Bounds | null { if (elements.length === 0) { return null; } let minX = Number.POSITIVE_INFINITY; let minY = Number.POSITIVE_INFINITY; let maxX = Number.NEGATIVE_INFINITY; let maxY = Number.NEGATIVE_INFINITY; for (const element of elements) { const bounds = normalizedBounds(element.bounds); minX = Math.min(minX, bounds.x); minY = Math.min(minY, bounds.y); maxX = Math.max(maxX, right(bounds)); maxY = Math.max(maxY, bottom(bounds)); } return { x: minX, y: minY, width: maxX - minX, height: maxY - minY, }; } export function selectElementsInBounds( elements: readonly EditorElement[], marquee: Bounds, mode: SelectionBoundsMode = "intersects", ): readonly ElementId[] { const selection = normalizedBounds(marquee); const selectionRight = right(selection); const selectionBottom = bottom(selection); return elements .filter((element) => { const bounds = normalizedBounds(element.bounds); if (mode === "contains") { return ( bounds.x >= selection.x && bounds.y >= selection.y && right(bounds) <= selectionRight && bottom(bounds) <= selectionBottom ); } return ( bounds.x < selectionRight && right(bounds) > selection.x && bounds.y < selectionBottom && bottom(bounds) > selection.y ); }) .map((element) => element.id); } function alignedBounds( element: EditorElement, union: Bounds, alignment: SelectionAlignment, ): Bounds { const bounds = normalizedBounds(element.bounds); let x = bounds.x; let y = bounds.y; if (alignment === "left") { x = union.x; } else if (alignment === "center-x") { x = union.x + (union.width - bounds.width) / 2; } else if (alignment === "right") { x = right(union) - bounds.width; } else if (alignment === "top") { y = union.y; } else if (alignment === "center-y") { y = union.y + (union.height - bounds.height) / 2; } else if (alignment === "bottom") { y = bottom(union) - bounds.height; } return { ...bounds, x, y }; } export function alignElements( elements: readonly EditorElement[], alignment: SelectionAlignment, ): readonly SelectionBoundsChange[] { const union = selectionBounds(elements); if (union === null) { return []; } return elements.map((element) => ({ id: element.id, bounds: alignedBounds(element, union, alignment), })); } export function distributeElements( elements: readonly EditorElement[], axis: SelectionDistributionAxis, ): readonly SelectionBoundsChange[] { if (elements.length < 3) { return elements.map((element) => ({ id: element.id, bounds: normalizedBounds(element.bounds), })); } const ordered = elements .map((element, index) => ({ element, index, bounds: normalizedBounds(element.bounds) })) .sort((first, second) => { const firstPosition = axis === "x" ? first.bounds.x : first.bounds.y; const secondPosition = axis === "x" ? second.bounds.x : second.bounds.y; return firstPosition - secondPosition || first.index - second.index; }); const first = ordered[0]!.bounds; const last = ordered[ordered.length - 1]!.bounds; const totalSize = ordered.reduce( (sum, entry) => sum + (axis === "x" ? entry.bounds.width : entry.bounds.height), 0, ); const outerSize = axis === "x" ? right(last) - first.x : bottom(last) - first.y; const gap = (outerSize - totalSize) / (ordered.length - 1); let cursor = axis === "x" ? first.x : first.y; const changes = new Map(); for (const entry of ordered) { const bounds = entry.bounds; const nextBounds = axis === "x" ? { ...bounds, x: cursor } : { ...bounds, y: cursor }; changes.set(entry.element.id, nextBounds); cursor += (axis === "x" ? bounds.width : bounds.height) + gap; } return elements.map((element) => ({ id: element.id, bounds: changes.get(element.id)!, })); } export function isElementLocked( element: Pick, ): boolean { return element.properties.locked === true; } export function setElementsLocked( elements: readonly EditorElement[], locked: boolean, ): readonly SelectionPropertiesChange[] { return elements.map((element) => ({ id: element.id, properties: { ...element.properties, locked }, })); } export function groupElements( elements: readonly EditorElement[], groupId: string, ): readonly SelectionPropertiesChange[] { return elements.map((element) => ({ id: element.id, properties: { ...element.properties, groupId }, })); } export function ungroupElements( elements: readonly EditorElement[], ): readonly SelectionPropertiesChange[] { return elements.map((element) => { const properties = { ...element.properties }; delete properties.groupId; return { id: element.id, properties }; }); }