1
11
12import { CANVAS_VERSION, type Canvas, type CanvasElement, type ElementLink } from "./schema.js";
13import { unionAll, elementBounds } from "./geometry.js";
14
15export class CanvasOpError extends Error {}
16
17
23export type ElementPatch = {
24 x?: number;
25 y?: number;
26 w?: number;
27 h?: number;
28 z?: number;
29 text?: string;
30 color?: string;
31 fontSize?: number;
32 fill?: string | null;
33 stroke?: string | null;
34 from?: { elementId: string; anchor: import("./schema.js").Anchor };
35 to?: { elementId: string; anchor: import("./schema.js").Anchor };
36 kind?: "straight" | "orthogonal";
37 arrow?: boolean;
38 points?: number[];
39 strokeWidth?: number;
40 children?: string[];
41
42 link?: ElementLink | null;
43};
44
45
46function cloneElement(el: CanvasElement): CanvasElement {
47 if (el.type === "freehand") return { ...el, points: [...el.points] };
48 if (el.type === "group") return { ...el, children: [...el.children] };
49 if (el.type === "connector") return { ...el, from: { ...el.from }, to: { ...el.to }, ...(el.link ? { link: { ...el.link } } : {}) };
50 return { ...el, ...(el.link ? { link: { ...el.link } } : {}) };
51}
52
53
54export function cloneCanvas(c: Canvas): Canvas {
55 const elements: Record<string, CanvasElement> = {};
56 for (const [id, el] of Object.entries(c.elements)) elements[id] = cloneElement(el);
57 const out: Canvas = { version: CANVAS_VERSION, elements };
58 if (c.selection) out.selection = [...c.selection];
59 return out;
60}
61
62export function nextZ(c: Canvas): number {
63 let max = 0;
64 for (const el of Object.values(c.elements)) if (el.z > max) max = el.z;
65 return max + 1;
66}
67
68export function addElement(c: Canvas, el: CanvasElement): Canvas {
69 if (Object.prototype.hasOwnProperty.call(c.elements, el.id)) throw new CanvasOpError(`element id already exists: ${el.id}`);
70 const out = cloneCanvas(c);
71 out.elements[el.id] = cloneElement(el);
72 return out;
73}
74
75export function updateElement(c: Canvas, id: string, patch: ElementPatch): Canvas {
76 const prev0 = c.elements[id];
77 if (!prev0) throw new CanvasOpError(`element not found: ${id}`);
78 const out = cloneCanvas(c);
79 const prev = out.elements[id]!;
80 const merged = { ...prev, ...patch, id: prev.id, type: prev.type } as CanvasElement;
81 if (patch.link === null) delete (merged as unknown as Record<string, unknown>).link;
82
83 if (patch.points !== undefined && prev.type !== "freehand") throw new CanvasOpError(`points only valid on freehand (${id})`);
84 if (patch.children !== undefined && prev.type !== "group") throw new CanvasOpError(`children only valid on group (${id})`);
85 if (patch.from !== undefined || patch.to !== undefined || patch.kind !== undefined || patch.arrow !== undefined) {
86 if (prev.type !== "connector") throw new CanvasOpError(`connector fields only valid on connector (${id})`);
87 }
88 if (patch.text !== undefined && prev.type !== "sticky" && prev.type !== "text") throw new CanvasOpError(`text only valid on sticky/text (${id})`);
89 out.elements[id] = merged;
90 return out;
91}
92
93export function removeElement(c: Canvas, id: string): Canvas {
94 if (!c.elements[id]) throw new CanvasOpError(`element not found: ${id}`);
95 const out = cloneCanvas(c);
96
97 for (const [cid, el] of Object.entries(out.elements)) {
98 if (el.type === "connector" && (el.from.elementId === id || el.to.elementId === id)) delete out.elements[cid];
99 }
100
101 for (const el of Object.values(out.elements)) {
102 if (el.type === "group" && el.children.includes(id)) el.children = el.children.filter((x) => x !== id);
103 }
104 delete out.elements[id];
105 if (out.selection) out.selection = out.selection.filter((x) => x !== id);
106 return out;
107}
108
109export function moveElement(c: Canvas, id: string, dx: number, dy: number): Canvas {
110 const src = c.elements[id];
111 if (!src) throw new CanvasOpError(`element not found: ${id}`);
112 const out = updateElement(c, id, { x: src.x + dx, y: src.y + dy });
113
114 const el = out.elements[id] as Extract<CanvasElement, { type: "freehand" }> | undefined;
115 if (el && el.type === "freehand") {
116 const points = el.points.slice();
117 for (let i = 0; i < points.length; i += 2) {
118 points[i] = (points[i] ?? 0) + dx;
119 points[i + 1] = (points[i + 1] ?? 0) + dy;
120 }
121 el.points = points;
122 }
123 return out;
124}
125
126
128export function moveMany(c: Canvas, ids: string[], dx: number, dy: number): Canvas {
129 let out = c;
130 for (const id of ids) out = moveElement(out, id, dx, dy);
131 return out;
132}
133
134
139export function groupElements(c: Canvas, group: { id: string; x?: number; y?: number; w?: number; h?: number; z?: number }, childIds: string[]): Canvas {
140 if (childIds.length < 2) throw new CanvasOpError("a group needs ≥ 2 children");
141 for (const id of childIds) {
142 const el = c.elements[id];
143 if (!el) throw new CanvasOpError(`group child not found: ${id}`);
144 if (el.type === "group") throw new CanvasOpError(`cannot nest a group: ${id}`);
145 }
146 const bounds = unionAll(childIds.map((id) => elementBounds(c.elements[id]!)));
147 const g: CanvasElement = {
148 id: group.id,
149 type: "group",
150 x: group.x ?? bounds.x,
151 y: group.y ?? bounds.y,
152 w: group.w ?? bounds.w,
153 h: group.h ?? bounds.h,
154 z: group.z ?? nextZ(c),
155 children: [...childIds]
156 };
157 return addElement(c, g);
158}
159
160export function ungroupElement(c: Canvas, groupId: string): Canvas {
161 const g = c.elements[groupId];
162 if (!g || g.type !== "group") throw new CanvasOpError(`not a group: ${groupId}`);
163
164 return removeElement(c, groupId);
165}
166
167
168export function rebindGroup(c: Canvas, groupId: string): Canvas {
169 const g = c.elements[groupId];
170 if (!g || g.type !== "group") throw new CanvasOpError(`not a group: ${groupId}`);
171 const bounds = unionAll(g.children.map((id) => elementBounds(c.elements[id]!)));
172 return updateElement(c, groupId, { x: bounds.x, y: bounds.y, w: bounds.w, h: bounds.h });
173}
174
175
176export function bringToFront(c: Canvas, ids: string[]): Canvas {
177 let out = c;
178 let z = nextZ(c);
179 for (const id of ids) {
180 if (!out.elements[id]) throw new CanvasOpError(`element not found: ${id}`);
181 out = updateElement(out, id, { z: z++ });
182 }
183 return out;
184}
185
186
187export function setSelection(c: Canvas, ids: string[]): Canvas {
188 const out = cloneCanvas(c);
189 out.selection = [...ids];
190 return out;
191}
192