/** * Immutable canvas operations (T-167). Every op returns a NEW Canvas (the * input is never mutated) and throws on referential errors — the API layer * persists the result only on success, so a throw = no partial write. * * Grouping is a FIRST-CLASS element (type "group" with a children id list), * not just a selection concept: the group carries bounds + z, so it can be * moved/transformed as one and re-bounded. Children keep their world coords * (no nesting transform) — the group is a selection/transform container. */ import { CANVAS_VERSION, type Canvas, type CanvasElement, type ElementLink } from "./schema.js"; import { unionAll, elementBounds } from "./geometry.js"; export class CanvasOpError extends Error {} /** * A patch for {@link updateElement}: every optional per-type field (the * runtime per-type guard below rejects a field on the wrong type). A single * flat type — `Partial>` over a discriminated union * would not allow reading the per-type keys at all. */ export type ElementPatch = { x?: number; y?: number; w?: number; h?: number; z?: number; text?: string; color?: string; fontSize?: number; fill?: string | null; stroke?: string | null; from?: { elementId: string; anchor: import("./schema.js").Anchor }; to?: { elementId: string; anchor: import("./schema.js").Anchor }; kind?: "straight" | "orthogonal"; arrow?: boolean; points?: number[]; strokeWidth?: number; children?: string[]; /** Base field (T-170): set the element's link, or `null` to clear it. Valid on ALL types. */ link?: ElementLink | null; }; /** Deep clone of a single element (array/nested-object fields copied). */ function cloneElement(el: CanvasElement): CanvasElement { if (el.type === "freehand") return { ...el, points: [...el.points] }; if (el.type === "group") return { ...el, children: [...el.children] }; if (el.type === "connector") return { ...el, from: { ...el.from }, to: { ...el.to }, ...(el.link ? { link: { ...el.link } } : {}) }; return { ...el, ...(el.link ? { link: { ...el.link } } : {}) }; } /** Deep-ish clone of the canvas document (plain objects; no class instances). */ export function cloneCanvas(c: Canvas): Canvas { const elements: Record = {}; for (const [id, el] of Object.entries(c.elements)) elements[id] = cloneElement(el); const out: Canvas = { version: CANVAS_VERSION, elements }; if (c.selection) out.selection = [...c.selection]; return out; } export function nextZ(c: Canvas): number { let max = 0; for (const el of Object.values(c.elements)) if (el.z > max) max = el.z; return max + 1; } export function addElement(c: Canvas, el: CanvasElement): Canvas { if (Object.prototype.hasOwnProperty.call(c.elements, el.id)) throw new CanvasOpError(`element id already exists: ${el.id}`); const out = cloneCanvas(c); out.elements[el.id] = cloneElement(el); return out; } export function updateElement(c: Canvas, id: string, patch: ElementPatch): Canvas { const prev0 = c.elements[id]; if (!prev0) throw new CanvasOpError(`element not found: ${id}`); const out = cloneCanvas(c); const prev = out.elements[id]!; // cloneCanvas preserves ids const merged = { ...prev, ...patch, id: prev.id, type: prev.type } as CanvasElement; if (patch.link === null) delete (merged as unknown as Record).link; // clear, not store null // a patch that changes geometry must keep the document valid if (patch.points !== undefined && prev.type !== "freehand") throw new CanvasOpError(`points only valid on freehand (${id})`); if (patch.children !== undefined && prev.type !== "group") throw new CanvasOpError(`children only valid on group (${id})`); if (patch.from !== undefined || patch.to !== undefined || patch.kind !== undefined || patch.arrow !== undefined) { if (prev.type !== "connector") throw new CanvasOpError(`connector fields only valid on connector (${id})`); } if (patch.text !== undefined && prev.type !== "sticky" && prev.type !== "text") throw new CanvasOpError(`text only valid on sticky/text (${id})`); out.elements[id] = merged; return out; } export function removeElement(c: Canvas, id: string): Canvas { if (!c.elements[id]) throw new CanvasOpError(`element not found: ${id}`); const out = cloneCanvas(c); // connectors pointing at the removed element die with it (no dangling refs) for (const [cid, el] of Object.entries(out.elements)) { if (el.type === "connector" && (el.from.elementId === id || el.to.elementId === id)) delete out.elements[cid]; } // groups containing it drop it for (const el of Object.values(out.elements)) { if (el.type === "group" && el.children.includes(id)) el.children = el.children.filter((x) => x !== id); } delete out.elements[id]; if (out.selection) out.selection = out.selection.filter((x) => x !== id); return out; } export function moveElement(c: Canvas, id: string, dx: number, dy: number): Canvas { const src = c.elements[id]; if (!src) throw new CanvasOpError(`element not found: ${id}`); const out = updateElement(c, id, { x: src.x + dx, y: src.y + dy }); // freehand strokes translate their points too const el = out.elements[id] as Extract | undefined; if (el && el.type === "freehand") { const points = el.points.slice(); for (let i = 0; i < points.length; i += 2) { points[i] = (points[i] ?? 0) + dx; points[i + 1] = (points[i + 1] ?? 0) + dy; } el.points = points; } return out; } /** Move a whole set (e.g. a selection) by a delta. Connectors move their * endpoint ATTACHMENTS implicitly (endpoints reference ids, not coords). */ export function moveMany(c: Canvas, ids: string[], dx: number, dy: number): Canvas { let out = c; for (const id of ids) out = moveElement(out, id, dx, dy); return out; } /** * Group selected ids into a first-class group element. The group's bounds * are the union of its children (stored so the renderer can hit-test the * group shell); children keep their own world coords. */ export function groupElements(c: Canvas, group: { id: string; x?: number; y?: number; w?: number; h?: number; z?: number }, childIds: string[]): Canvas { if (childIds.length < 2) throw new CanvasOpError("a group needs ≥ 2 children"); for (const id of childIds) { const el = c.elements[id]; if (!el) throw new CanvasOpError(`group child not found: ${id}`); if (el.type === "group") throw new CanvasOpError(`cannot nest a group: ${id}`); } const bounds = unionAll(childIds.map((id) => elementBounds(c.elements[id]!))); const g: CanvasElement = { id: group.id, type: "group", x: group.x ?? bounds.x, y: group.y ?? bounds.y, w: group.w ?? bounds.w, h: group.h ?? bounds.h, z: group.z ?? nextZ(c), children: [...childIds] }; return addElement(c, g); } export function ungroupElement(c: Canvas, groupId: string): Canvas { const g = c.elements[groupId]; if (!g || g.type !== "group") throw new CanvasOpError(`not a group: ${groupId}`); // children stay; only the container is removed return removeElement(c, groupId); } /** Re-bounds a group to the union of its (possibly moved) children. */ export function rebindGroup(c: Canvas, groupId: string): Canvas { const g = c.elements[groupId]; if (!g || g.type !== "group") throw new CanvasOpError(`not a group: ${groupId}`); const bounds = unionAll(g.children.map((id) => elementBounds(c.elements[id]!))); return updateElement(c, groupId, { x: bounds.x, y: bounds.y, w: bounds.w, h: bounds.h }); } /** Re-z-order: bring the given ids to the top (in the given order). */ export function bringToFront(c: Canvas, ids: string[]): Canvas { let out = c; let z = nextZ(c); for (const id of ids) { if (!out.elements[id]) throw new CanvasOpError(`element not found: ${id}`); out = updateElement(out, id, { z: z++ }); } return out; } /** Clear the transient selection (optional convenience — the renderer owns it). */ export function setSelection(c: Canvas, ids: string[]): Canvas { const out = cloneCanvas(c); out.selection = [...ids]; return out; }