agentic-canvas 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,723 @@
1
+ // src/core/principal.ts
2
+ import { PRINCIPAL_KINDS, isMachineMade } from "agentic-identity";
3
+ import { z } from "zod";
4
+ var principalSchema = z.object({ kind: z.enum(PRINCIPAL_KINDS), id: z.string().min(1).max(200) }).strict();
5
+
6
+ // src/core/board.ts
7
+ import { randomUUID } from "crypto";
8
+ import * as Y from "yjs";
9
+ import { z as z2 } from "zod";
10
+ var CanvasError = class extends Error {
11
+ constructor(code, message) {
12
+ super(message);
13
+ this.code = code;
14
+ this.name = "CanvasError";
15
+ }
16
+ code;
17
+ };
18
+ var SHAPE_TYPES = ["rect", "ellipse", "diamond", "text", "sticky", "frame", "image"];
19
+ var MAX_ELEMENTS = 1e4;
20
+ var MAX_TEXT = 1e4;
21
+ var id = z2.string().regex(/^[A-Za-z0-9_-]{1,64}$/);
22
+ var coord = z2.number().finite().min(-1e7).max(1e7);
23
+ var size = z2.number().finite().gt(0).max(1e7);
24
+ var text = z2.string().max(MAX_TEXT);
25
+ var COLOR_PATTERN = /^(#[0-9a-fA-F]{3,8}|[a-z]{3,20})$/;
26
+ var IMAGE_PATTERN = /^(data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/]+=*|blob:[A-Za-z0-9._-]{1,200})$/;
27
+ var color = z2.string().regex(COLOR_PATTERN);
28
+ var imageSrc = z2.string().max(2e6).regex(IMAGE_PATTERN);
29
+ var endpoint = z2.union([z2.object({ id }).strict(), z2.object({ x: coord, y: coord }).strict()]);
30
+ var shapeInput = z2.object({
31
+ id: id.optional(),
32
+ type: z2.enum(SHAPE_TYPES),
33
+ x: coord,
34
+ y: coord,
35
+ w: size.default(120),
36
+ h: size.default(60),
37
+ text: text.default(""),
38
+ fill: color.optional(),
39
+ stroke: color.optional(),
40
+ src: imageSrc.optional()
41
+ }).strict().refine((s) => s.type === "image" === (s.src !== void 0), {
42
+ message: "src is required for image shapes and allowed on no other type"
43
+ });
44
+ var connectorInput = z2.object({
45
+ id: id.optional(),
46
+ from: endpoint,
47
+ to: endpoint,
48
+ text: text.default(""),
49
+ startArrow: z2.boolean().default(false),
50
+ endArrow: z2.boolean().default(true),
51
+ stroke: color.optional()
52
+ }).strict();
53
+ var shapePatch = z2.object({ x: coord, y: coord, w: size, h: size, text, fill: color, stroke: color, src: imageSrc }).partial().strict();
54
+ var connectorPatch = z2.object({ from: endpoint, to: endpoint, text, startArrow: z2.boolean(), endArrow: z2.boolean(), stroke: color }).partial().strict();
55
+ var delta = z2.number().finite().min(-2e7).max(2e7);
56
+ var MAX_Z = 1e9;
57
+ var stampFields = {
58
+ id,
59
+ z: z2.number().finite().min(-MAX_Z).max(MAX_Z),
60
+ groupId: id.optional(),
61
+ author: principalSchema,
62
+ editedBy: principalSchema
63
+ };
64
+ var storedShape = z2.object({ ...stampFields, type: z2.enum(SHAPE_TYPES), x: coord, y: coord, w: size, h: size, text, fill: color.optional(), stroke: color.optional(), src: imageSrc.optional() }).refine((s) => s.type === "image" === (s.src !== void 0), { message: "src is required for image shapes and allowed on no other type" });
65
+ var storedConnector = z2.object({
66
+ ...stampFields,
67
+ type: z2.literal("connector"),
68
+ from: endpoint,
69
+ to: endpoint,
70
+ text,
71
+ startArrow: z2.boolean(),
72
+ endArrow: z2.boolean(),
73
+ stroke: color.optional()
74
+ });
75
+ var storedGroup = z2.object({ ...stampFields, type: z2.literal("group") });
76
+ var storedElement = z2.union([storedShape, storedConnector, storedGroup]);
77
+ var titleSchema = z2.string().max(200);
78
+ function parse(schema, value) {
79
+ const result = schema.safeParse(value);
80
+ if (!result.success) {
81
+ const issues = result.error.issues.map((i) => `${i.path.join(".") || "input"}: ${i.message}`);
82
+ throw new CanvasError("INVALID", issues.join("; "));
83
+ }
84
+ return result.data;
85
+ }
86
+ var isBindable = (type) => typeof type === "string" && type !== "connector" && type !== "group";
87
+ var isValidZ = (value) => typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= MAX_Z;
88
+ var clampZ = (value) => Math.min(MAX_Z, Math.max(-MAX_Z, value));
89
+ var samePrincipal = (a, b) => a.kind === b.kind && a.id === b.id;
90
+ var hasId = (e) => typeof e === "object" && e !== null && typeof e.id === "string";
91
+ var Board = class _Board {
92
+ doc;
93
+ elements;
94
+ meta;
95
+ maxElements;
96
+ constructor(doc = new Y.Doc(), options = {}) {
97
+ this.maxElements = Math.min(options.maxElements ?? MAX_ELEMENTS, MAX_ELEMENTS);
98
+ this.doc = doc;
99
+ this.elements = doc.getMap("elements");
100
+ this.meta = doc.getMap("meta");
101
+ }
102
+ /** Load a saved board with no element checks. Only the owner repair path uses it. */
103
+ static open(update) {
104
+ const doc = new Y.Doc();
105
+ Y.applyUpdate(doc, update);
106
+ return new _Board(doc);
107
+ }
108
+ /**
109
+ * Load a saved board. Refuses a doc past the element cap. Then it deletes every entry that fails
110
+ * the element checks, and repairs what concurrent writers left broken.
111
+ */
112
+ static decode(update) {
113
+ const board = _Board.open(update);
114
+ if (board.elements.size > MAX_ELEMENTS) throw new CanvasError("LIMIT", `a board holds at most ${MAX_ELEMENTS} elements`);
115
+ board.repair();
116
+ return board;
117
+ }
118
+ encode() {
119
+ return Y.encodeStateAsUpdate(this.doc);
120
+ }
121
+ /** An independent copy. Edits to it leave this board alone. */
122
+ clone() {
123
+ const copy = _Board.decode(this.encode());
124
+ copy.maxElements = this.maxElements;
125
+ return copy;
126
+ }
127
+ get title() {
128
+ const value = titleSchema.safeParse(this.meta.get("title"));
129
+ return value.success ? value.data : "";
130
+ }
131
+ get titleBy() {
132
+ const value = principalSchema.safeParse(this.meta.get("titleBy"));
133
+ return value.success ? value.data : void 0;
134
+ }
135
+ setTitle(title, actor) {
136
+ const t = parse(z2.string().max(200), title);
137
+ const by = parse(principalSchema, actor);
138
+ this.doc.transact(() => {
139
+ this.meta.set("title", t);
140
+ this.meta.set("titleBy", by);
141
+ });
142
+ }
143
+ /** One element. An invalid entry, or a connector with a missing bound end, is hidden. */
144
+ get(elementId) {
145
+ const el = this.stored(elementId);
146
+ return el && !this.isDetached(el, (endId) => this.stored(endId)) ? el : void 0;
147
+ }
148
+ /** All visible elements from back to front. */
149
+ list() {
150
+ const all = this.storedList();
151
+ const byId = new Map(all.map((e) => [e.id, e]));
152
+ return all.filter((e) => !this.isDetached(e, (endId) => byId.get(endId)));
153
+ }
154
+ /**
155
+ * Delete what peers can leave broken: entries that fail the element checks, connectors whose
156
+ * bound end is gone, and members that name a deleted group. Idempotent, so every peer may run it.
157
+ * `drop` names elements the owner removes too, with what they hold: a shape takes its connectors,
158
+ * a group takes its members. Refuses a result still past the cap, and then changes nothing.
159
+ * Returns the ids fixed.
160
+ */
161
+ repair(drop = []) {
162
+ const all = this.allStored();
163
+ const byId = new Map(all.map((e) => [e.id, e]));
164
+ const doomed = new Set([...this.elements.keys()].filter((key) => !byId.has(key)));
165
+ for (const name of drop) {
166
+ if (byId.has(name)) this.subtreeIn(all, name).forEach((d) => doomed.add(d));
167
+ else if (!this.elements.has(name)) throw new CanvasError("NOT_FOUND", `no element "${name}"`);
168
+ }
169
+ const alive = all.filter((e) => !doomed.has(e.id));
170
+ const aliveById = new Map(alive.map((e) => [e.id, e]));
171
+ const detached = new Set(alive.filter((e) => this.isDetached(e, (endId) => aliveById.get(endId))).map((e) => e.id));
172
+ const kept = alive.filter((e) => !detached.has(e.id));
173
+ if (kept.length > this.maxElements) throw new CanvasError("LIMIT", `a board holds at most ${this.maxElements} elements`);
174
+ const loose = kept.filter((e) => e.groupId !== void 0 && aliveById.get(e.groupId)?.type !== "group").map((e) => e.id);
175
+ this.doc.transact(() => {
176
+ [...doomed, ...detached].forEach((d) => this.elements.delete(d));
177
+ loose.forEach((l) => this.elements.get(l)?.delete("groupId"));
178
+ });
179
+ return [.../* @__PURE__ */ new Set([...doomed, ...detached, ...loose])].sort();
180
+ }
181
+ /**
182
+ * Make the author stamps of a merged board honest. `prior` is the board before the merge, and `trusted` says whether
183
+ * a principal really wrote in this session. A new element stamped by anyone else is deleted. An old element keeps its
184
+ * author, and a changed one needs a trusted editor or it goes back to its prior state. An editor stamp changed alone
185
+ * to an untrusted principal is put back. Unknown keys, nested Yjs types, and bad or foreign title values are deleted
186
+ * or undone. Returns the ids it deleted or restored.
187
+ */
188
+ restamp(prior, trusted) {
189
+ const before = new Map(prior.allStored().map((e) => [e.id, e]));
190
+ const body = ({ author: _a, editedBy: _e, ...rest }) => JSON.stringify(rest);
191
+ const fixed = [];
192
+ this.doc.transact(() => {
193
+ for (const el of this.allStored()) {
194
+ const old = before.get(el.id);
195
+ if (!old) {
196
+ if (trusted(el.author) && trusted(el.editedBy)) continue;
197
+ this.elements.delete(el.id);
198
+ } else {
199
+ const entry = this.elements.get(el.id);
200
+ const authorKept = samePrincipal(el.author, old.author);
201
+ const editorOk = trusted(el.editedBy) || samePrincipal(el.editedBy, old.editedBy);
202
+ const changed = body(el) !== body(old);
203
+ if (authorKept && editorOk && !(changed && !trusted(el.editedBy))) continue;
204
+ if (changed && !trusted(el.editedBy)) this.elements.set(el.id, new Y.Map(Object.entries(old).filter(([, v]) => v !== void 0)));
205
+ else {
206
+ if (!authorKept) entry.set("author", old.author);
207
+ if (!editorOk) entry.set("editedBy", old.editedBy);
208
+ }
209
+ }
210
+ fixed.push(el.id);
211
+ }
212
+ for (const el of this.allStored()) {
213
+ const entry = this.elements.get(el.id);
214
+ const extra = [...entry.keys()].filter((key) => !(key in el) || entry.get(key) instanceof Y.AbstractType);
215
+ extra.forEach((key) => entry.delete(key));
216
+ if (extra.length > 0 && !fixed.includes(el.id)) fixed.push(el.id);
217
+ }
218
+ const same = (key) => !(this.meta.get(key) instanceof Y.AbstractType) && JSON.stringify(this.meta.get(key)) === JSON.stringify(prior.meta.get(key));
219
+ const by = this.titleBy;
220
+ const title = this.meta.get("title");
221
+ const honest = by !== void 0 && trusted(by) && (title === void 0 || titleSchema.safeParse(title).success);
222
+ if (!honest && !(same("title") && same("titleBy"))) {
223
+ for (const key of ["title", "titleBy"]) {
224
+ const old = prior.meta.get(key);
225
+ if (old === void 0) this.meta.delete(key);
226
+ else this.meta.set(key, old);
227
+ }
228
+ fixed.push("title");
229
+ }
230
+ for (const key of [...this.meta.keys()]) {
231
+ if (key === "title" || key === "titleBy") continue;
232
+ this.meta.delete(key);
233
+ fixed.push(`meta:${key}`);
234
+ }
235
+ });
236
+ return fixed;
237
+ }
238
+ addShape(input, actor) {
239
+ const { id: given, ...fields } = parse(shapeInput, input);
240
+ return this.create({ ...fields, id: given }, actor);
241
+ }
242
+ addConnector(input, actor) {
243
+ const { id: given, ...fields } = parse(connectorInput, input);
244
+ this.checkEnd(fields.from);
245
+ this.checkEnd(fields.to);
246
+ return this.create({ ...fields, type: "connector", id: given }, actor);
247
+ }
248
+ update(elementId, patch, actor) {
249
+ const by = parse(principalSchema, actor);
250
+ const el = this.require(elementId);
251
+ if (el.type === "group") throw new CanvasError("INVALID", "a group has no fields to update");
252
+ const fields = parse(el.type === "connector" ? connectorPatch : shapePatch, patch);
253
+ if (Object.keys(fields).length === 0) throw new CanvasError("INVALID", "patch is empty");
254
+ for (const [key, value] of Object.entries(fields)) {
255
+ if (value === void 0 && key !== "fill" && key !== "stroke") throw new CanvasError("INVALID", `${key} cannot be cleared`);
256
+ }
257
+ if ("src" in fields && el.type !== "image") throw new CanvasError("INVALID", "only an image has a src");
258
+ if (el.type === "connector") {
259
+ this.checkEnd(fields.from);
260
+ this.checkEnd(fields.to);
261
+ }
262
+ this.write([[elementId, fields]], by);
263
+ return this.get(elementId);
264
+ }
265
+ /** Shift an element, or a whole group. Returns the ids that moved. Bound connector ends follow their shapes. */
266
+ move(elementId, dx, dy, actor) {
267
+ const by = parse(principalSchema, actor);
268
+ const [mx, my] = parse(z2.tuple([delta, delta]), [dx, dy]);
269
+ this.require(elementId);
270
+ const free = (e) => hasId(e) ? e : { x: e.x + mx, y: e.y + my };
271
+ const plan = [];
272
+ for (const memberId of this.subtree(elementId)) {
273
+ const el = this.get(memberId);
274
+ if (!el) continue;
275
+ if (el.type === "connector") {
276
+ const patch = parse(connectorPatch, { from: free(el.from), to: free(el.to) });
277
+ if (!hasId(el.from) || !hasId(el.to)) plan.push([memberId, patch]);
278
+ } else if (el.type !== "group") {
279
+ plan.push([memberId, parse(shapePatch, { x: el.x + mx, y: el.y + my })]);
280
+ }
281
+ }
282
+ this.write(plan, by);
283
+ return plan.map(([memberId]) => memberId);
284
+ }
285
+ /** Delete an element. A shape takes its connectors and a group takes its members. Returns the ids removed. */
286
+ remove(elementId) {
287
+ this.require(elementId);
288
+ const doomed = new Set(this.subtree(elementId));
289
+ for (const el of this.storedList()) {
290
+ if (el.type === "connector" && [el.from, el.to].some((e) => hasId(e) && doomed.has(e.id))) doomed.add(el.id);
291
+ }
292
+ this.doc.transact(() => doomed.forEach((d) => this.elements.delete(d)));
293
+ return [...doomed].sort();
294
+ }
295
+ group(ids, actor, groupId) {
296
+ const by = parse(principalSchema, actor);
297
+ const members = parse(z2.array(id).min(2).max(MAX_ELEMENTS), ids);
298
+ const gid = parse(id.optional(), groupId);
299
+ if (new Set(members).size !== members.length) throw new CanvasError("INVALID", "member ids repeat");
300
+ const parents = new Set(members.map((m) => this.require(m).groupId));
301
+ if (parents.size > 1) throw new CanvasError("BAD_REFERENCE", "members must share one parent group");
302
+ const [parent] = parents;
303
+ const group = this.create({ type: "group", id: gid, groupId: parent }, by);
304
+ this.write(members.map((m) => [m, { groupId: group.id }]), by);
305
+ return group;
306
+ }
307
+ /** Remove a group and hand its members to the parent group. */
308
+ ungroup(groupId, actor) {
309
+ const by = parse(principalSchema, actor);
310
+ const group = this.require(groupId);
311
+ if (group.type !== "group") throw new CanvasError("INVALID", `"${groupId}" is not a group`);
312
+ const members = this.list().filter((e) => e.groupId === groupId);
313
+ this.doc.transact(() => {
314
+ this.write(members.map((m) => [m.id, { groupId: group.groupId }]), by);
315
+ this.elements.delete(groupId);
316
+ });
317
+ }
318
+ /** Put an element, or a group with its inner order kept, in front of or behind every other element. */
319
+ reorder(elementId, to, actor) {
320
+ const by = parse(principalSchema, actor);
321
+ this.require(elementId);
322
+ const inner = new Set(this.subtree(elementId));
323
+ const members = this.list().filter((e) => inner.has(e.id) && e.type !== "group");
324
+ const start = to === "front" ? this.edgeZ(1) : this.edgeZ(-1) - (members.length - 1);
325
+ if (start < -MAX_Z || start + members.length - 1 > MAX_Z) throw new CanvasError("LIMIT", `z order is full: values must stay within ${MAX_Z}`);
326
+ this.write(members.map((m, i) => [m.id, { z: start + i }]), by);
327
+ }
328
+ /** One stored element that passes the checks and whose id matches its key, or undefined. */
329
+ stored(key) {
330
+ const entry = this.elements.get(key);
331
+ if (!(entry instanceof Y.Map)) return void 0;
332
+ const parsed = storedElement.safeParse(entry.toJSON());
333
+ return parsed.success && parsed.data.id === key ? parsed.data : void 0;
334
+ }
335
+ /** Every stored element that passes the checks, from back to front. Refuses a doc past the cap. */
336
+ storedList() {
337
+ if (this.elements.size > MAX_ELEMENTS) throw new CanvasError("LIMIT", `a board holds at most ${MAX_ELEMENTS} elements`);
338
+ return this.allStored();
339
+ }
340
+ /** Like storedList, with no cap check. Repair needs every entry, even on a board past the cap. */
341
+ allStored() {
342
+ const all = [];
343
+ for (const key of this.elements.keys()) {
344
+ const el = this.stored(key);
345
+ if (el) all.push(el);
346
+ }
347
+ return all.sort((a, b) => a.z - b.z || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
348
+ }
349
+ /** A connector is detached when a bound end does not name a stored shape. */
350
+ isDetached(el, lookup) {
351
+ if (el.type !== "connector") return false;
352
+ return [el.from, el.to].some((e) => hasId(e) && !isBindable(lookup(e.id)?.type));
353
+ }
354
+ require(elementId) {
355
+ const el = this.get(elementId);
356
+ if (!el) throw new CanvasError("NOT_FOUND", `no element "${elementId}"`);
357
+ return el;
358
+ }
359
+ checkEnd(end) {
360
+ if (!end || !hasId(end)) return;
361
+ if (!isBindable(this.stored(end.id)?.type)) {
362
+ throw new CanvasError("BAD_REFERENCE", `"${end.id}" is not a shape a connector can bind to`);
363
+ }
364
+ }
365
+ /** The z value just past the top or the bottom. Not clamped: the caller checks the range. */
366
+ edgeZ(direction) {
367
+ let z3 = 0;
368
+ for (const m of this.elements.values()) {
369
+ const value = m instanceof Y.Map ? m.get("z") : void 0;
370
+ if (isValidZ(value)) z3 = direction === 1 ? Math.max(z3, value) : Math.min(z3, value);
371
+ }
372
+ return z3 + direction;
373
+ }
374
+ /** The element and, for a group, everything below it. Cycle-safe. */
375
+ subtree(elementId) {
376
+ return this.subtreeIn(this.storedList(), elementId);
377
+ }
378
+ subtreeIn(all, elementId) {
379
+ const children = /* @__PURE__ */ new Map();
380
+ for (const { id: childId, groupId: parent } of all) {
381
+ if (parent !== void 0) children.set(parent, [...children.get(parent) ?? [], childId]);
382
+ }
383
+ const seen = /* @__PURE__ */ new Set([elementId]);
384
+ for (const next of seen) for (const child of children.get(next) ?? []) seen.add(child);
385
+ return [...seen];
386
+ }
387
+ create(fields, actor) {
388
+ const by = parse(principalSchema, actor);
389
+ const elementId = fields.id ?? randomUUID();
390
+ if (this.elements.has(elementId)) throw new CanvasError("DUPLICATE_ID", `element "${elementId}" already exists`);
391
+ if (this.elements.size >= this.maxElements) throw new CanvasError("LIMIT", `a board holds at most ${this.maxElements} elements`);
392
+ const record = { ...fields, id: elementId, z: clampZ(this.edgeZ(1)), author: by, editedBy: by };
393
+ const entries = Object.entries(record).filter(([, v]) => v !== void 0);
394
+ this.doc.transact(() => this.elements.set(elementId, new Y.Map(entries)));
395
+ return this.get(elementId);
396
+ }
397
+ /** Apply field changes and stamp the editor, all in one transaction. An undefined value clears the field. */
398
+ write(plan, actor) {
399
+ this.doc.transact(() => {
400
+ for (const [elementId, fields] of plan) {
401
+ const m = this.elements.get(elementId);
402
+ for (const [key, value] of Object.entries(fields)) {
403
+ if (value === void 0) m.delete(key);
404
+ else m.set(key, value);
405
+ }
406
+ m.set("editedBy", actor);
407
+ }
408
+ });
409
+ }
410
+ };
411
+
412
+ // src/core/flow.ts
413
+ import { randomUUID as randomUUID2 } from "crypto";
414
+ import dagre from "@dagrejs/dagre";
415
+ import * as Y2 from "yjs";
416
+ var MAX_FLOW_NODES = 500;
417
+ var MAX_FLOW_EDGES = 2e3;
418
+ var MAX_FLOW_CHARS = 2e5;
419
+ var NODE = String.raw`([A-Za-z0-9_-]{1,48})(?:\[([^\]]*)\]|\(([^)]*)\)|\{([^}]*)\})?`;
420
+ var EDGE = new RegExp(String.raw`^${NODE}\s*->\s*${NODE}\s*(?::(.*))?$`);
421
+ var ALONE = new RegExp(String.raw`^${NODE}$`);
422
+ var SHAPES = ["rect", "ellipse", "diamond"];
423
+ function parseFlow(source) {
424
+ if (source.length > MAX_FLOW_CHARS) throw new CanvasError("LIMIT", `flow text is over ${MAX_FLOW_CHARS} characters`);
425
+ const graph = { direction: "TB", nodes: [], edges: [] };
426
+ const byId = /* @__PURE__ */ new Map();
427
+ const fail = (line, message) => {
428
+ throw new CanvasError("INVALID", `line ${line}: ${message}`);
429
+ };
430
+ const node = (line, m, at) => {
431
+ const id2 = m[at];
432
+ if (/^edge_\d+$/.test(id2)) fail(line, `"${id2}" is reserved for connectors. Pick another node id.`);
433
+ const brackets = m.slice(at + 1, at + 4);
434
+ const open = brackets.findIndex((b) => b !== void 0);
435
+ const shape = SHAPES[Math.max(open, 0)];
436
+ const text2 = (open < 0 ? "" : brackets[open].trim()) || id2;
437
+ const known = byId.get(id2);
438
+ if (!known) {
439
+ const created = { id: id2, text: text2, shape, explicit: open >= 0 };
440
+ byId.set(id2, created);
441
+ graph.nodes.push(created);
442
+ } else if (open >= 0) {
443
+ if (known.explicit && (known.text !== text2 || known.shape !== shape)) fail(line, `"${id2}" is defined twice with different text`);
444
+ Object.assign(known, { text: text2, shape, explicit: true });
445
+ }
446
+ return id2;
447
+ };
448
+ source.replace(/^/, "").split(/\r?\n/).forEach((raw, index) => {
449
+ const line = index + 1;
450
+ const text2 = raw.trim();
451
+ if (text2 === "" || text2.startsWith("#") || text2.startsWith("//")) return;
452
+ if (/[\u0000-\u0008\u000a-\u001f\u007f]/.test(text2)) fail(line, "control characters are not allowed");
453
+ const direction = /^direction\s+(\S+)$/.exec(text2);
454
+ if (direction) {
455
+ if (direction[1] !== "TB" && direction[1] !== "LR") fail(line, `direction must be TB or LR, got "${direction[1]}"`);
456
+ graph.direction = direction[1];
457
+ return;
458
+ }
459
+ const edge = EDGE.exec(text2);
460
+ if (edge) {
461
+ const from = node(line, edge, 1);
462
+ const to = node(line, edge, 5);
463
+ graph.edges.push({ from, to, text: (edge[9] ?? "").trim() });
464
+ } else {
465
+ const alone = ALONE.exec(text2);
466
+ if (!alone) fail(line, `cannot read "${text2.slice(0, 40)}". Use "A -> B: label".`);
467
+ else node(line, alone, 1);
468
+ }
469
+ if (graph.nodes.length > MAX_FLOW_NODES) throw new CanvasError("LIMIT", `a flow holds at most ${MAX_FLOW_NODES} nodes`);
470
+ if (graph.edges.length > MAX_FLOW_EDGES) throw new CanvasError("LIMIT", `a flow holds at most ${MAX_FLOW_EDGES} edges`);
471
+ });
472
+ if (graph.nodes.length === 0) throw new CanvasError("INVALID", "the flow has no nodes");
473
+ return { ...graph, nodes: graph.nodes.map(({ id: id2, text: text2, shape }) => ({ id: id2, text: text2, shape })) };
474
+ }
475
+ function sizeOf(n) {
476
+ const w = Math.min(360, Math.max(120, 24 + 8 * n.text.length));
477
+ return n.shape === "diamond" ? { w: Math.min(360, Math.round(w * 1.5)), h: 90 } : { w, h: 60 };
478
+ }
479
+ function layoutFlow(graph) {
480
+ const g = new dagre.graphlib.Graph({ multigraph: true });
481
+ g.setGraph({ rankdir: graph.direction, nodesep: 40, ranksep: 70, marginx: 0, marginy: 0 });
482
+ g.setDefaultEdgeLabel(() => ({}));
483
+ const index = new Map(graph.nodes.map((n, i) => [n.id, `n${i}`]));
484
+ const sizes = graph.nodes.map(sizeOf);
485
+ sizes.forEach(({ w, h }, i) => g.setNode(`n${i}`, { width: w, height: h }));
486
+ graph.edges.forEach((e, i) => g.setEdge(index.get(e.from), index.get(e.to), {}, String(i)));
487
+ dagre.layout(g);
488
+ const boxes = graph.nodes.map((n, i) => {
489
+ const { w, h } = sizes[i];
490
+ const c = g.node(`n${i}`);
491
+ return { id: n.id, x: Math.round(c.x - w / 2), y: Math.round(c.y - h / 2), w, h };
492
+ });
493
+ const minX = Math.min(...boxes.map((b) => b.x));
494
+ const minY = Math.min(...boxes.map((b) => b.y));
495
+ return boxes.map((b) => ({ ...b, x: b.x - minX, y: b.y - minY }));
496
+ }
497
+ function drawFlow(board, source, actor, options = {}) {
498
+ const prefix = options.idPrefix ?? `${randomUUID2().slice(0, 8)}-`;
499
+ if (!/^[A-Za-z0-9_-]{0,16}$/.test(prefix)) throw new CanvasError("INVALID", "idPrefix must be up to 16 of A-Z, a-z, 0-9, _ and -");
500
+ const graph = parseFlow(source);
501
+ const placed = layoutFlow(graph);
502
+ const shapes = board.list().flatMap((e) => e.type === "connector" || e.type === "group" ? [] : [e]);
503
+ const origin = options.origin ?? {
504
+ x: shapes.length ? Math.min(...shapes.map((s) => s.x)) : 0,
505
+ y: shapes.length ? Math.max(...shapes.map((s) => s.y + s.h)) + 80 : 0
506
+ };
507
+ const work = board.clone();
508
+ const text2 = new Map(graph.nodes.map((n) => [n.id, n]));
509
+ for (const p of placed) {
510
+ const n = text2.get(p.id);
511
+ work.addShape({ id: prefix + p.id, type: n.shape, text: n.text, x: p.x + origin.x, y: p.y + origin.y, w: p.w, h: p.h }, actor);
512
+ }
513
+ const edges = graph.edges.map((e, i) => {
514
+ const id2 = `${prefix}edge_${i + 1}`;
515
+ work.addConnector({ id: id2, from: { id: prefix + e.from }, to: { id: prefix + e.to }, text: e.text }, actor);
516
+ return id2;
517
+ });
518
+ Y2.applyUpdate(board.doc, Y2.encodeStateAsUpdate(work.doc, Y2.encodeStateVector(board.doc)));
519
+ return { nodes: placed.map((p) => prefix + p.id), edges };
520
+ }
521
+
522
+ // src/core/read.ts
523
+ function readBoard(board) {
524
+ const data = { title: board.title, nodes: [], edges: [], groups: [], texts: [] };
525
+ const all = board.list();
526
+ for (const el of all) {
527
+ if (el.type === "group") {
528
+ data.groups.push({ id: el.id, members: all.filter((m) => m.groupId === el.id).map((m) => m.id) });
529
+ continue;
530
+ }
531
+ const labels = { machineMade: isMachineMade(el.author.kind), lastEditMachineMade: isMachineMade(el.editedBy.kind) };
532
+ if (el.text !== "") data.texts.push(el.text);
533
+ if (el.type === "connector") data.edges.push({ ...el, ...labels });
534
+ else {
535
+ const src = el.src?.startsWith("data:") ? `${el.src.slice(0, el.src.indexOf(","))},(${el.src.length} characters left out)` : el.src;
536
+ data.nodes.push({ ...el, ...src === void 0 ? {} : { src }, ...labels });
537
+ }
538
+ }
539
+ return data;
540
+ }
541
+
542
+ // src/core/svg.ts
543
+ var LINE = 17;
544
+ var num = (n) => String(Math.round(n * 100) / 100);
545
+ var esc = (value) => (typeof value === "string" ? value : "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f￾￿]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g, "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
546
+ var paint = (value, fallback) => typeof value === "string" && COLOR_PATTERN.test(value) ? value : fallback;
547
+ var str = (value) => typeof value === "string" ? value : "";
548
+ var within = (n, low, high) => typeof n === "number" && Number.isFinite(n) && n >= low && n <= high;
549
+ var LIMIT_COORD = 1e7;
550
+ var TYPES = /* @__PURE__ */ new Set([...SHAPE_TYPES, "connector", "group"]);
551
+ var sane = (s) => within(s.x, -LIMIT_COORD, LIMIT_COORD) && within(s.y, -LIMIT_COORD, LIMIT_COORD) && within(s.w, 1e-6, LIMIT_COORD) && within(s.h, 1e-6, LIMIT_COORD);
552
+ var isShape = (v) => "w" in v;
553
+ var center = (s) => ({ x: s.x + s.w / 2, y: s.y + s.h / 2 });
554
+ function edgePoint(s, toward) {
555
+ const c = center(s);
556
+ const dx = toward.x - c.x;
557
+ const dy = toward.y - c.y;
558
+ if (dx === 0 && dy === 0) return c;
559
+ const a = s.w / 2;
560
+ const b = s.h / 2;
561
+ const k = s.type === "ellipse" ? 1 / Math.hypot(dx / a, dy / b) : s.type === "diamond" ? 1 / (Math.abs(dx) / a + Math.abs(dy) / b) : Math.min(a / Math.abs(dx), b / Math.abs(dy));
562
+ return { x: c.x + dx * k, y: c.y + dy * k };
563
+ }
564
+ function wrap(text2, width) {
565
+ const max = Math.max(4, Math.floor((width - 16) / 7.5));
566
+ const out = [];
567
+ for (const paragraph of text2.split(/\r?\n/)) {
568
+ let line = "";
569
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
570
+ const chars = [...word];
571
+ while (chars.length > max) {
572
+ if (line) out.push(line);
573
+ line = "";
574
+ out.push(chars.splice(0, max).join(""));
575
+ }
576
+ const rest = chars.join("");
577
+ if (line && [...line].length + 1 + chars.length > max) {
578
+ out.push(line);
579
+ line = rest;
580
+ } else line = line ? `${line} ${rest}` : rest;
581
+ }
582
+ out.push(line);
583
+ }
584
+ return out.slice(0, 40);
585
+ }
586
+ function label(text2, cx, cy, width, extra = "") {
587
+ if (text2 === "") return "";
588
+ const lines = wrap(text2, width);
589
+ const top = cy - (lines.length - 1) * LINE / 2;
590
+ const spans = lines.map((l, i) => `<tspan x="${num(cx)}" y="${num(top + i * LINE)}">${esc(l)}</tspan>`);
591
+ return `<text text-anchor="middle" dominant-baseline="central"${extra}>${spans.join("")}</text>`;
592
+ }
593
+ function shapeBody(s) {
594
+ const { x, y, w, h } = s;
595
+ const stroke = paint(s.stroke, "#1f2937");
596
+ const look = (fill, more = "") => `fill="${paint(s.fill, fill)}" stroke="${stroke}" stroke-width="2"${more}`;
597
+ const text2 = label(str(s.text), x + w / 2, y + h / 2, w);
598
+ switch (s.type) {
599
+ case "ellipse":
600
+ return `<ellipse cx="${num(x + w / 2)}" cy="${num(y + h / 2)}" rx="${num(w / 2)}" ry="${num(h / 2)}" ${look("#ffffff")}/>${text2}`;
601
+ case "diamond": {
602
+ const pts = [[x + w / 2, y], [x + w, y + h / 2], [x + w / 2, y + h], [x, y + h / 2]];
603
+ return `<polygon points="${pts.map(([px, py]) => `${num(px)},${num(py)}`).join(" ")}" ${look("#ffffff")}/>${text2}`;
604
+ }
605
+ case "text":
606
+ return text2;
607
+ case "frame":
608
+ return `<rect x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" ${look("none", ' stroke-dasharray="8 6"')}/>${label(str(s.text), x + w / 2, y - 12, w)}`;
609
+ case "image":
610
+ if (typeof s.src === "string" && s.src.startsWith("data:") && IMAGE_PATTERN.test(s.src)) {
611
+ return `<image href="${s.src}" x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" preserveAspectRatio="xMidYMid meet"/>`;
612
+ }
613
+ return `<rect x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" ${look("#f3f4f6", ' stroke-dasharray="4 4"')}/>${label("image", x + w / 2, y + h / 2, w)}`;
614
+ default:
615
+ return `<rect x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" rx="${s.type === "sticky" ? 2 : 6}" ${look(s.type === "sticky" ? "#fde68a" : "#ffffff")}/>${text2}`;
616
+ }
617
+ }
618
+ var endKey = (e) => "id" in e ? e.id : `${e.x},${e.y}`;
619
+ var wellFormed = (e) => typeof e === "object" && e !== null && (typeof e.id === "string" || within(e.x, -LIMIT_COORD, LIMIT_COORD) && within(e.y, -LIMIT_COORD, LIMIT_COORD));
620
+ function connectorBody(c, shapes, lane) {
621
+ const resolve = (e) => "id" in e ? shapes.get(e.id) : e;
622
+ const a = resolve(c.from);
623
+ const b = resolve(c.to);
624
+ if (!a || !b) return "";
625
+ let d;
626
+ let at;
627
+ if (isShape(a) && a === b) {
628
+ const x0 = a.x + a.w * 0.35;
629
+ const x1 = a.x + a.w * 0.65;
630
+ const lift = 45 + 32 * lane.index;
631
+ d = `M${num(x0)} ${num(a.y)}C${num(x0)} ${num(a.y - lift)} ${num(x1)} ${num(a.y - lift)} ${num(x1)} ${num(a.y)}`;
632
+ at = { x: a.x + a.w / 2, y: a.y - lift * 0.75 - 6 };
633
+ } else {
634
+ const ca = isShape(a) ? center(a) : a;
635
+ const cb = isShape(b) ? center(b) : b;
636
+ const flip = endKey(c.from) > endKey(c.to);
637
+ const dx = (flip ? ca.x - cb.x : cb.x - ca.x) || 0;
638
+ const dy = (flip ? ca.y - cb.y : cb.y - ca.y) || 0;
639
+ const len = Math.hypot(dx, dy) || 1;
640
+ const offset = (lane.index - (lane.count - 1) / 2) * 44;
641
+ const mid = { x: (ca.x + cb.x) / 2, y: (ca.y + cb.y) / 2 };
642
+ const ctrl = { x: mid.x - dy / len * 2 * offset, y: mid.y + dx / len * 2 * offset };
643
+ const p = isShape(a) ? edgePoint(a, ctrl) : a;
644
+ const q = isShape(b) ? edgePoint(b, ctrl) : b;
645
+ d = offset === 0 ? `M${num(p.x)} ${num(p.y)}L${num(q.x)} ${num(q.y)}` : `M${num(p.x)} ${num(p.y)}Q${num(ctrl.x)} ${num(ctrl.y)} ${num(q.x)} ${num(q.y)}`;
646
+ at = offset === 0 ? { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 } : { x: (p.x + q.x) / 4 + ctrl.x / 2, y: (p.y + q.y) / 4 + ctrl.y / 2 };
647
+ }
648
+ const marks = `${c.startArrow ? ' marker-start="url(#arrow)"' : ""}${c.endArrow ? ' marker-end="url(#arrow)"' : ""}`;
649
+ const halo = ' stroke="#ffffff" stroke-width="4" paint-order="stroke"';
650
+ return `<path d="${d}" fill="none" stroke="${paint(c.stroke, "#1f2937")}" stroke-width="2"${marks}/>${label(str(c.text), at.x, at.y, 160, halo)}`;
651
+ }
652
+ var drawable = (el) => TYPES.has(el.type) && (el.type === "connector" ? wellFormed(el.from) && wellFormed(el.to) : el.type === "group" || sane(el));
653
+ function toSvg(board) {
654
+ const listed = board.list();
655
+ if (listed.length > MAX_ELEMENTS) throw new CanvasError("LIMIT", `a board holds at most ${MAX_ELEMENTS} elements`);
656
+ const all = listed.filter((el) => drawable(el));
657
+ const shapes = /* @__PURE__ */ new Map();
658
+ const box = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
659
+ const grow = (x, y) => {
660
+ box.minX = Math.min(box.minX, x);
661
+ box.minY = Math.min(box.minY, y);
662
+ box.maxX = Math.max(box.maxX, x);
663
+ box.maxY = Math.max(box.maxY, y);
664
+ };
665
+ for (const el of all) {
666
+ if (el.type === "connector") {
667
+ for (const e of [el.from, el.to]) if (!("id" in e)) grow(e.x, e.y);
668
+ } else if (el.type !== "group") {
669
+ shapes.set(el.id, el);
670
+ grow(el.x, el.y);
671
+ grow(el.x + el.w, el.y + el.h);
672
+ }
673
+ }
674
+ const pad = 80;
675
+ const any = box.minX !== Infinity;
676
+ const minX = any ? box.minX - pad : 0;
677
+ const minY = any ? box.minY - pad : 0;
678
+ const width = any ? Math.ceil(box.maxX + pad - minX) : 240;
679
+ const height = any ? Math.ceil(box.maxY + pad - minY) : 120;
680
+ const lanes = /* @__PURE__ */ new Map();
681
+ const groups = /* @__PURE__ */ new Map();
682
+ for (const el of all) {
683
+ if (el.type !== "connector") continue;
684
+ const key = [endKey(el.from), endKey(el.to)].sort().join("|");
685
+ groups.set(key, [...groups.get(key) ?? [], el.id]);
686
+ }
687
+ for (const ids of groups.values()) ids.forEach((id2, index) => lanes.set(id2, { index, count: ids.length }));
688
+ const body = all.map((el) => {
689
+ if (el.type === "group") return "";
690
+ const inner = el.type === "connector" ? connectorBody(el, shapes, lanes.get(el.id)) : shapeBody(el);
691
+ if (inner === "") return "";
692
+ const kind = el.author.kind;
693
+ const mark = `data-id="${esc(el.id)}" data-type="${el.type}" data-author-kind="${kind}" data-machine-made="${kind !== "human"}"`;
694
+ return `<g ${mark}>${inner}</g>`;
695
+ });
696
+ const title = esc(board.title) || "Whiteboard";
697
+ return [
698
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="${num(minX)} ${num(minY)} ${width} ${height}" font-family="sans-serif" font-size="14" fill="#111827" role="img" aria-label="${title}">`,
699
+ `<title>${title}</title>`,
700
+ '<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="#1f2937"/></marker></defs>',
701
+ `<rect x="${num(minX)}" y="${num(minY)}" width="${width}" height="${height}" fill="#ffffff"/>`,
702
+ ...body.filter(Boolean),
703
+ "</svg>"
704
+ ].join("\n");
705
+ }
706
+
707
+ export {
708
+ PRINCIPAL_KINDS,
709
+ isMachineMade,
710
+ principalSchema,
711
+ CanvasError,
712
+ SHAPE_TYPES,
713
+ MAX_ELEMENTS,
714
+ MAX_TEXT,
715
+ Board,
716
+ MAX_FLOW_NODES,
717
+ MAX_FLOW_EDGES,
718
+ parseFlow,
719
+ layoutFlow,
720
+ drawFlow,
721
+ readBoard,
722
+ toSvg
723
+ };