@scrawl-board/board 0.1.0-beta.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.
package/dist/core.js ADDED
@@ -0,0 +1,2253 @@
1
+ //#region src/core-internal/assets.ts
2
+ var ASSET_REF_PATTERN = /^asset:[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?:[A-Za-z0-9._~-]+$/;
3
+ function isAssetRef(value) {
4
+ return typeof value === "string" && ASSET_REF_PATTERN.test(value) && new TextEncoder().encode(value).length <= 512;
5
+ }
6
+ //#endregion
7
+ //#region src/core-internal/identifiers.ts
8
+ function documentId(value) {
9
+ if (!value) throw new TypeError("DocumentId cannot be empty");
10
+ return value;
11
+ }
12
+ function strokeId(value) {
13
+ if (!value) throw new TypeError("StrokeId cannot be empty");
14
+ return value;
15
+ }
16
+ //#endregion
17
+ //#region src/document-schema.ts
18
+ var CURRENT_DOCUMENT_SCHEMA_VERSION = 1;
19
+ var DocumentRecoveryError = class extends Error {
20
+ constructor(code, message, originalBytes, path) {
21
+ super(message);
22
+ this.code = code;
23
+ this.originalBytes = originalBytes;
24
+ this.path = path;
25
+ this.name = "DocumentRecoveryError";
26
+ }
27
+ };
28
+ function loadDocumentBytes(originalBytes) {
29
+ let raw;
30
+ try {
31
+ raw = JSON.parse(originalBytes);
32
+ } catch {
33
+ return failure("DOCUMENT_JSON_INVALID", "Document is not valid JSON", originalBytes);
34
+ }
35
+ const result = migrateDocument(raw);
36
+ if (result.ok) return result;
37
+ return {
38
+ ok: false,
39
+ error: new DocumentRecoveryError(result.error.code, result.error.message, originalBytes, result.error.path)
40
+ };
41
+ }
42
+ function migrateDocument(raw) {
43
+ if (!isRecord(raw)) return failure("DOCUMENT_VALIDATION_FAILED", "Document must be an object");
44
+ const version = raw.schemaVersion ?? 0;
45
+ if (version !== 0 && version !== 1) return failure("DOCUMENT_VERSION_UNSUPPORTED", `Document schema version ${String(version)} is unsupported`, void 0, "schemaVersion");
46
+ if (!isJsonValue(raw)) return failure("DOCUMENT_VALIDATION_FAILED", "Document must contain JSON values only");
47
+ const strokes = validateStrokes(raw.strokes);
48
+ if (strokes instanceof DocumentRecoveryError) return {
49
+ ok: false,
50
+ error: strokes
51
+ };
52
+ const collections = {
53
+ notes: validateCollection(raw, "notes", version, validateNote),
54
+ textBlocks: validateCollection(raw, "textBlocks", version, validateText),
55
+ tables: validateCollection(raw, "tables", version, validateTable),
56
+ images: validateCollection(raw, "images", version, validateImage),
57
+ timers: validateCollection(raw, "timers", version, validateTimer),
58
+ customObjects: validateCollection(raw, "customObjects", version, validateCustomObject, false)
59
+ };
60
+ if (collections.notes instanceof DocumentRecoveryError) return {
61
+ ok: false,
62
+ error: collections.notes
63
+ };
64
+ if (collections.textBlocks instanceof DocumentRecoveryError) return {
65
+ ok: false,
66
+ error: collections.textBlocks
67
+ };
68
+ if (collections.tables instanceof DocumentRecoveryError) return {
69
+ ok: false,
70
+ error: collections.tables
71
+ };
72
+ if (collections.images instanceof DocumentRecoveryError) return {
73
+ ok: false,
74
+ error: collections.images
75
+ };
76
+ if (collections.timers instanceof DocumentRecoveryError) return {
77
+ ok: false,
78
+ error: collections.timers
79
+ };
80
+ if (collections.customObjects instanceof DocumentRecoveryError) return {
81
+ ok: false,
82
+ error: collections.customObjects
83
+ };
84
+ return {
85
+ ok: true,
86
+ migratedFrom: version,
87
+ document: {
88
+ schemaVersion: 1,
89
+ strokes,
90
+ notes: collections.notes,
91
+ textBlocks: collections.textBlocks,
92
+ tables: collections.tables,
93
+ images: collections.images,
94
+ timers: collections.timers,
95
+ customObjects: collections.customObjects
96
+ }
97
+ };
98
+ }
99
+ function serializeDocument(document) {
100
+ const result = migrateDocument(document);
101
+ if (!result.ok || result.migratedFrom !== 1) throw result.ok ? new DocumentRecoveryError("DOCUMENT_VALIDATION_FAILED", "Saving requires the current Document schema") : result.error;
102
+ return JSON.stringify(result.document);
103
+ }
104
+ function validateCollection(raw, name, version, validator, requiredAtCurrentVersion = true) {
105
+ if (requiredAtCurrentVersion && version === 1 && !(name in raw)) return validation(`${name} is required`, name);
106
+ const value = raw[name] ?? [];
107
+ if (!Array.isArray(value)) return validation(`${name} must be an array`, name);
108
+ for (let index = 0; index < value.length; index += 1) if (!isRecord(value[index]) || !validator(value[index])) return validation(`invalid ${name} entry`, `${name}[${index}]`);
109
+ return structuredClone(value);
110
+ }
111
+ function validateStrokes(raw) {
112
+ if (!Array.isArray(raw)) return validation("strokes must be an array", "strokes");
113
+ const strokes = [];
114
+ for (let index = 0; index < raw.length; index += 1) {
115
+ const value = raw[index];
116
+ const path = `strokes[${index}]`;
117
+ if (!isRecord(value) || !nonempty(value.id) || !nonempty(value.color) || !positive(value.baseWidth) || !optionalEnum(value.tool, ["marker", "highlighter"]) || !validLock(value) || !optionalString(value.clusterId) || !validMatrix(value.matrix) || !Array.isArray(value.points)) return validation("invalid stroke", path);
118
+ const points = [];
119
+ for (let pointIndex = 0; pointIndex < value.points.length; pointIndex += 1) {
120
+ const point = normalizePoint(value.points[pointIndex]);
121
+ if (!point) return validation("invalid stroke point", `${path}.points[${pointIndex}]`);
122
+ points.push(point);
123
+ }
124
+ strokes.push({
125
+ ...structuredClone(value),
126
+ id: strokeId(value.id),
127
+ points
128
+ });
129
+ }
130
+ return strokes;
131
+ }
132
+ function normalizePoint(raw) {
133
+ const values = Array.isArray(raw) ? raw : isRecord(raw) ? [
134
+ raw.x,
135
+ raw.y,
136
+ raw.pressure,
137
+ raw.erase ?? 0
138
+ ] : [];
139
+ if (values.length !== 4 || !values.every(finite) || !unit(values[2]) || !unit(values[3])) return null;
140
+ return values;
141
+ }
142
+ function validateNote(v) {
143
+ return basePosition(v) && positive(v.size) && finite(v.zOffset) && nonempty(v.color) && typeof v.text === "string" && validLock(v) && (v.votes === void 0 || Array.isArray(v.votes) && v.votes.every((vote) => isRecord(vote) && nonempty(vote.userId) && nonempty(vote.name) && nonempty(vote.color)));
144
+ }
145
+ function validateText(v) {
146
+ return basePosition(v) && typeof v.text === "string" && positive(v.fontSize) && nonempty(v.color) && validLock(v) && optionalString(v.clusterId);
147
+ }
148
+ function validateTable(v) {
149
+ return basePosition(v) && integer(v.rows) && integer(v.cols) && numberArray(v.colWidths, v.cols) && numberArray(v.rowHeights, v.rows) && isStringRecord(v.cells) && validLock(v) && optionalString(v.color) && optionalString(v.backgroundColor) && optionalString(v.clusterId);
150
+ }
151
+ function validateImage(v) {
152
+ const identifiesAnImage = v.ref !== void 0 ? isAssetRef(v.ref) : nonempty(v.src);
153
+ return basePosition(v) && identifiesAnImage && positive(v.width) && positive(v.height) && positive(v.aspectRatio) && validLock(v) && optionalString(v.name) && optionalString(v.createdAt) && optionalString(v.stamp);
154
+ }
155
+ function validateTimer(v) {
156
+ return basePosition(v) && positive(v.size) && positive(v.durationMs) && finite(v.remainingMs) && v.remainingMs >= 0 && (v.runningSince === void 0 || finite(v.runningSince)) && validLock(v);
157
+ }
158
+ function validateCustomObject(v) {
159
+ return nonempty(v.id) && nonempty(v.type) && integer(v.schemaVersion) && validMatrix(v.transform) && Array.isArray(v.transform) && validCustomFallback(v.fallback) && (v.lock === void 0 || validCustomLock(v.lock)) && isJsonValue(v.props);
160
+ }
161
+ function validCustomFallback(v) {
162
+ if (!isRecord(v)) return false;
163
+ const bounds = v.bounds;
164
+ return isRecord(bounds) && finite(bounds.x) && finite(bounds.y) && finite(bounds.width) && finite(bounds.height) && optionalString(v.label);
165
+ }
166
+ function validCustomLock(v) {
167
+ return isRecord(v) && nonempty(v.holderId) && finite(v.acquiredAt);
168
+ }
169
+ function basePosition(v) {
170
+ return nonempty(v.id) && finite(v.x) && finite(v.y);
171
+ }
172
+ function validLock(v) {
173
+ return (v.locked === void 0 || typeof v.locked === "boolean") && optionalString(v.lockedBy) && optionalString(v.lockedByName);
174
+ }
175
+ function validMatrix(v) {
176
+ return v === void 0 || Array.isArray(v) && v.length === 6 && v.every(finite);
177
+ }
178
+ function numberArray(v, length) {
179
+ return Array.isArray(v) && v.length === length && v.every(positive);
180
+ }
181
+ function isStringRecord(v) {
182
+ return isRecord(v) && Object.values(v).every((item) => typeof item === "string");
183
+ }
184
+ function optionalString(v) {
185
+ return v === void 0 || typeof v === "string";
186
+ }
187
+ function optionalEnum(v, values) {
188
+ return v === void 0 || typeof v === "string" && values.includes(v);
189
+ }
190
+ function nonempty(v) {
191
+ return typeof v === "string" && v.length > 0;
192
+ }
193
+ function finite(v) {
194
+ return typeof v === "number" && Number.isFinite(v);
195
+ }
196
+ function positive(v) {
197
+ return finite(v) && v > 0;
198
+ }
199
+ function integer(v) {
200
+ return Number.isInteger(v) && v > 0;
201
+ }
202
+ function unit(v) {
203
+ return finite(v) && v >= 0 && v <= 1;
204
+ }
205
+ function isRecord(v) {
206
+ if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
207
+ const prototype = Object.getPrototypeOf(v);
208
+ return prototype === Object.prototype || prototype === null;
209
+ }
210
+ function isJsonValue(v) {
211
+ if (v === null || typeof v === "string" || typeof v === "boolean" || finite(v)) return true;
212
+ if (Array.isArray(v)) return v.every(isJsonValue);
213
+ return isRecord(v) && Object.values(v).every(isJsonValue);
214
+ }
215
+ function failure(code, message, originalBytes, path) {
216
+ return {
217
+ ok: false,
218
+ error: new DocumentRecoveryError(code, message, originalBytes, path)
219
+ };
220
+ }
221
+ function validation(message, path) {
222
+ return new DocumentRecoveryError("DOCUMENT_VALIDATION_FAILED", message, void 0, path);
223
+ }
224
+ //#endregion
225
+ //#region src/core-internal/affine2d.ts
226
+ var IDENTITY = [
227
+ 1,
228
+ 0,
229
+ 0,
230
+ 1,
231
+ 0,
232
+ 0
233
+ ];
234
+ function isIdentity(m) {
235
+ return m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1 && m[4] === 0 && m[5] === 0;
236
+ }
237
+ /** Compose: apply `first`, then `second`. */
238
+ function mul(second, first) {
239
+ const [a2, b2, c2, d2, tx2, ty2] = second;
240
+ const [a1, b1, c1, d1, tx1, ty1] = first;
241
+ return [
242
+ a2 * a1 + c2 * b1,
243
+ b2 * a1 + d2 * b1,
244
+ a2 * c1 + c2 * d1,
245
+ b2 * c1 + d2 * d1,
246
+ a2 * tx1 + c2 * ty1 + tx2,
247
+ b2 * tx1 + d2 * ty1 + ty2
248
+ ];
249
+ }
250
+ function invert(m) {
251
+ const [a, b, c, d, tx, ty] = m;
252
+ const det = a * d - b * c;
253
+ const ia = d / det;
254
+ const ib = -b / det;
255
+ const ic = -c / det;
256
+ const id = a / det;
257
+ return [
258
+ ia,
259
+ ib,
260
+ ic,
261
+ id,
262
+ -(ia * tx + ic * ty),
263
+ -(ib * tx + id * ty)
264
+ ];
265
+ }
266
+ function apply(m, p) {
267
+ return {
268
+ x: m[0] * p.x + m[2] * p.y + m[4],
269
+ y: m[1] * p.x + m[3] * p.y + m[5]
270
+ };
271
+ }
272
+ function translation(dx, dy) {
273
+ return [
274
+ 1,
275
+ 0,
276
+ 0,
277
+ 1,
278
+ dx,
279
+ dy
280
+ ];
281
+ }
282
+ function rotationAbout(angle, cx, cy) {
283
+ const cos = Math.cos(angle);
284
+ const sin = Math.sin(angle);
285
+ return [
286
+ cos,
287
+ sin,
288
+ -sin,
289
+ cos,
290
+ cx - cos * cx + sin * cy,
291
+ cy - sin * cx - cos * cy
292
+ ];
293
+ }
294
+ function scalingAbout(sx, sy, cx, cy) {
295
+ return [
296
+ sx,
297
+ 0,
298
+ 0,
299
+ sy,
300
+ cx - sx * cx,
301
+ cy - sy * cy
302
+ ];
303
+ }
304
+ /** Approximate uniform length scale (average of the axis scales). */
305
+ function avgScale(m) {
306
+ return (Math.hypot(m[0], m[1]) + Math.hypot(m[2], m[3])) / 2;
307
+ }
308
+ //#endregion
309
+ //#region src/core-internal/boardSearch.ts
310
+ function hay(s) {
311
+ return s.toLowerCase();
312
+ }
313
+ function snippet(text, q, width = 72) {
314
+ const i = hay(text).indexOf(q);
315
+ if (i < 0) return text.slice(0, width);
316
+ const start = Math.max(0, i - 16);
317
+ const slice = text.slice(start, start + width).trim();
318
+ return (start > 0 ? "…" : "") + slice;
319
+ }
320
+ /**
321
+ * Find notes, text, table cells, stamps, and comments whose text contains
322
+ * `query`. Empty / whitespace queries return nothing.
323
+ */
324
+ function searchBoard(query, board) {
325
+ const q = hay(query.trim());
326
+ if (!q) return [];
327
+ const hits = [];
328
+ for (const note of board.notes) if (hay(note.text).includes(q)) hits.push({
329
+ kind: "note",
330
+ id: note.id,
331
+ title: "Note",
332
+ snippet: snippet(note.text, q) || "(empty note)",
333
+ x: note.x,
334
+ y: note.y
335
+ });
336
+ for (const block of board.texts) if (hay(block.text).includes(q)) hits.push({
337
+ kind: "text",
338
+ id: block.id,
339
+ title: "Text",
340
+ snippet: snippet(block.text, q),
341
+ x: block.x,
342
+ y: block.y
343
+ });
344
+ for (const table of board.tables) {
345
+ const cells = Object.entries(table.cells).filter(([, text]) => hay(text).includes(q)).map(([, text]) => text);
346
+ if (cells.length) hits.push({
347
+ kind: "table",
348
+ id: table.id,
349
+ title: "Table",
350
+ snippet: snippet(cells.join(" · "), q),
351
+ x: table.x,
352
+ y: table.y
353
+ });
354
+ }
355
+ for (const image of board.images) if (hay(`${image.stamp ?? ""} ${image.name ?? ""}`).includes(q)) hits.push({
356
+ kind: "stamp",
357
+ id: image.id,
358
+ title: image.stamp ? "Stamp" : "Image",
359
+ snippet: image.name ?? image.stamp ?? "image",
360
+ x: image.x,
361
+ y: image.y
362
+ });
363
+ for (const comment of board.comments) if (hay([comment.text, ...comment.replies.map((r) => r.text)].join("\n")).includes(q) || hay(comment.authorName).includes(q)) hits.push({
364
+ kind: "comment",
365
+ id: comment.id,
366
+ title: comment.authorName,
367
+ snippet: snippet(comment.text, q),
368
+ x: comment.x,
369
+ y: comment.y
370
+ });
371
+ return hits;
372
+ }
373
+ //#endregion
374
+ //#region src/core-internal/types.ts
375
+ /** Erasure level at which a point counts as fully erased. */
376
+ var ERASE_THRESHOLD = .95;
377
+ function cloneStroke(stroke) {
378
+ return {
379
+ ...stroke,
380
+ points: stroke.points.map((p) => ({ ...p })),
381
+ ...stroke.matrix ? { matrix: [...stroke.matrix] } : {}
382
+ };
383
+ }
384
+ var INK_COLORS = {
385
+ black: "#1C1C1E",
386
+ red: "#E0333D",
387
+ blue: "#2563EB",
388
+ green: "#16A34A"
389
+ };
390
+ var HIGHLIGHT_COLORS = {
391
+ yellow: "#FDE047",
392
+ green: "#86EFAC",
393
+ pink: "#F9A8D4",
394
+ blue: "#93C5FD"
395
+ };
396
+ var NOTE_COLORS = {
397
+ yellow: "#FDE68A",
398
+ pink: "#FBCFE8",
399
+ blue: "#BFDBFE",
400
+ green: "#BBF7D0"
401
+ };
402
+ var TEXT_DEFAULT_SIZE = 1.8;
403
+ function cloneText(block) {
404
+ return { ...block };
405
+ }
406
+ /**
407
+ * Estimated bounds of a text block (average-glyph-width heuristic — no DOM,
408
+ * usable from clustering, export, and tests alike).
409
+ */
410
+ function measureTextBlock(text, fontSize) {
411
+ const lines = text.split("\n");
412
+ return {
413
+ width: Math.max(1, ...lines.map((l) => l.length)) * fontSize * .55,
414
+ height: lines.length * fontSize * 1.25
415
+ };
416
+ }
417
+ var NOTE_DEFAULT_SIZE = 10;
418
+ var NOTE_DEFAULT_Z = 1;
419
+ var NOTE_MIN_Z = .4;
420
+ var NOTE_MAX_Z = 6;
421
+ var NOTE_PEEL_STEP = .7;
422
+ function cloneNote(note) {
423
+ return {
424
+ ...note,
425
+ ...note.votes ? { votes: note.votes.map((v) => ({ ...v })) } : {}
426
+ };
427
+ }
428
+ var TABLE_DEFAULT_CELL_WIDTH = 8;
429
+ var TABLE_DEFAULT_CELL_HEIGHT = 3.6;
430
+ var TABLE_DEFAULT_FONT_SIZE = 1.25;
431
+ function cloneTable(table) {
432
+ return {
433
+ ...table,
434
+ colWidths: [...table.colWidths],
435
+ rowHeights: [...table.rowHeights],
436
+ cells: { ...table.cells }
437
+ };
438
+ }
439
+ function measureTable(table) {
440
+ return {
441
+ width: table.colWidths.reduce((sum, w) => sum + w, 0),
442
+ height: table.rowHeights.reduce((sum, h) => sum + h, 0)
443
+ };
444
+ }
445
+ var BOARD_COLOR = "#FFFFFF";
446
+ var FOG_COLOR = "#FFFFFF";
447
+ function cloneImage(img) {
448
+ return { ...img };
449
+ }
450
+ //#endregion
451
+ //#region src/core-internal/clusterStore.ts
452
+ /** A stroke joins a cluster last written to within this window at full reach. */
453
+ var RECENT_MS = 8e3;
454
+ /** Older clusters are still joinable, at half the gap threshold. */
455
+ var STALE_FACTOR = .5;
456
+ var GAP_FACTOR = .9;
457
+ var MIN_GAP = 1;
458
+ var MAX_GAP = 8;
459
+ var ClusterStore = class {
460
+ constructor(doc, createId, now) {
461
+ this.doc = doc;
462
+ this.createId = createId;
463
+ this.now = now;
464
+ this.clusters = /* @__PURE__ */ new Map();
465
+ this.byStroke = /* @__PURE__ */ new Map();
466
+ this.unsubscribe = doc.subscribe((change) => {
467
+ for (const id of change.removed) this.removeMember(id);
468
+ for (const id of change.textRemoved) this.removeMember(id);
469
+ for (const stroke of change.added) this.addMember(stroke.id, stroke.clusterId);
470
+ for (const block of change.textAdded) this.addMember(block.id, block.clusterId);
471
+ const moved = /* @__PURE__ */ new Set();
472
+ for (const stroke of change.transformed) {
473
+ const cid = this.byStroke.get(stroke.id);
474
+ if (cid) moved.add(cid);
475
+ }
476
+ for (const block of change.textUpdated) {
477
+ const cid = this.byStroke.get(block.id);
478
+ if (cid) moved.add(cid);
479
+ }
480
+ for (const cid of moved) this.recompute(cid);
481
+ });
482
+ for (const stroke of doc.all()) this.addMember(stroke.id, stroke.clusterId, 0);
483
+ for (const block of doc.allTexts()) this.addMember(block.id, block.clusterId, 0);
484
+ }
485
+ /**
486
+ * Pick (or create) the cluster for a freshly drawn stroke and stamp its
487
+ * clusterId. Gap threshold scales with stroke height, per the build prompt.
488
+ */
489
+ assign(stroke, now = this.now()) {
490
+ const box = bboxOfPoints(stroke.points);
491
+ stroke.clusterId = this.pick(box, now);
492
+ }
493
+ /** Same assignment for a typed text block, using its estimated bounds. */
494
+ assignText(block, now = this.now()) {
495
+ block.clusterId = this.pick(textBBox(block), now);
496
+ }
497
+ pick(box, now) {
498
+ const height = box.maxY - box.minY;
499
+ let best = null;
500
+ for (const cluster of this.clusters.values()) {
501
+ const avgHeight = cluster.heightSum / cluster.strokeIds.size;
502
+ let threshold = clamp$1(GAP_FACTOR * Math.max(height, avgHeight), MIN_GAP, MAX_GAP);
503
+ if (now - cluster.lastStrokeAt > RECENT_MS) threshold *= STALE_FACTOR;
504
+ const gap = bboxGap(box, cluster.bbox);
505
+ if (gap <= threshold && (!best || gap < best.gap)) best = {
506
+ id: cluster.id,
507
+ gap
508
+ };
509
+ }
510
+ return best?.id ?? this.createId();
511
+ }
512
+ /** All strokes in the same cluster; a clusterless stroke is its own group. */
513
+ membersOf(strokeId) {
514
+ const cid = this.byStroke.get(strokeId);
515
+ const cluster = cid ? this.clusters.get(cid) : void 0;
516
+ return cluster ? [...cluster.strokeIds] : [strokeId];
517
+ }
518
+ dispose() {
519
+ this.unsubscribe();
520
+ this.clusters.clear();
521
+ this.byStroke.clear();
522
+ }
523
+ /** Strokes and text blocks index identically; bbox source differs. */
524
+ memberBBox(id) {
525
+ const strokeBox = this.doc.bbox(id);
526
+ if (strokeBox) return strokeBox;
527
+ const block = this.doc.getText(id);
528
+ return block ? textBBox(block) : void 0;
529
+ }
530
+ addMember(id, clusterId, at = this.now()) {
531
+ if (!clusterId) return;
532
+ const box = this.memberBBox(id);
533
+ if (!box) return;
534
+ let cluster = this.clusters.get(clusterId);
535
+ if (!cluster) {
536
+ cluster = {
537
+ id: clusterId,
538
+ strokeIds: /* @__PURE__ */ new Set(),
539
+ bbox: { ...box },
540
+ lastStrokeAt: at,
541
+ heightSum: 0
542
+ };
543
+ this.clusters.set(cluster.id, cluster);
544
+ }
545
+ cluster.strokeIds.add(id);
546
+ cluster.bbox.minX = Math.min(cluster.bbox.minX, box.minX);
547
+ cluster.bbox.minY = Math.min(cluster.bbox.minY, box.minY);
548
+ cluster.bbox.maxX = Math.max(cluster.bbox.maxX, box.maxX);
549
+ cluster.bbox.maxY = Math.max(cluster.bbox.maxY, box.maxY);
550
+ cluster.lastStrokeAt = Math.max(cluster.lastStrokeAt, at);
551
+ cluster.heightSum += box.maxY - box.minY;
552
+ this.byStroke.set(id, cluster.id);
553
+ }
554
+ removeMember(memberId) {
555
+ const cid = this.byStroke.get(memberId);
556
+ if (!cid) return;
557
+ this.byStroke.delete(memberId);
558
+ const cluster = this.clusters.get(cid);
559
+ if (!cluster) return;
560
+ cluster.strokeIds.delete(memberId);
561
+ if (cluster.strokeIds.size === 0) this.clusters.delete(cid);
562
+ else this.recompute(cid);
563
+ }
564
+ recompute(cid) {
565
+ const cluster = this.clusters.get(cid);
566
+ if (!cluster) return;
567
+ const box = {
568
+ minX: Infinity,
569
+ minY: Infinity,
570
+ maxX: -Infinity,
571
+ maxY: -Infinity
572
+ };
573
+ let heightSum = 0;
574
+ for (const id of cluster.strokeIds) {
575
+ const b = this.memberBBox(id);
576
+ if (!b) continue;
577
+ box.minX = Math.min(box.minX, b.minX);
578
+ box.minY = Math.min(box.minY, b.minY);
579
+ box.maxX = Math.max(box.maxX, b.maxX);
580
+ box.maxY = Math.max(box.maxY, b.maxY);
581
+ heightSum += b.maxY - b.minY;
582
+ }
583
+ cluster.bbox = box;
584
+ cluster.heightSum = heightSum;
585
+ }
586
+ };
587
+ /** Estimated world bounds of a text block: top-left anchor, lines flow -y. */
588
+ function textBBox(block) {
589
+ const { width, height } = measureTextBlock(block.text, block.fontSize);
590
+ return {
591
+ minX: block.x,
592
+ maxX: block.x + width,
593
+ minY: block.y - height,
594
+ maxY: block.y
595
+ };
596
+ }
597
+ function bboxOfPoints(points) {
598
+ const box = {
599
+ minX: Infinity,
600
+ minY: Infinity,
601
+ maxX: -Infinity,
602
+ maxY: -Infinity
603
+ };
604
+ for (const p of points) {
605
+ if (p.x < box.minX) box.minX = p.x;
606
+ if (p.y < box.minY) box.minY = p.y;
607
+ if (p.x > box.maxX) box.maxX = p.x;
608
+ if (p.y > box.maxY) box.maxY = p.y;
609
+ }
610
+ return box;
611
+ }
612
+ /** Shortest distance between two bboxes; 0 when they touch or overlap. */
613
+ function bboxGap(a, b) {
614
+ const dx = Math.max(0, b.minX - a.maxX, a.minX - b.maxX);
615
+ const dy = Math.max(0, b.minY - a.maxY, a.minY - b.maxY);
616
+ return Math.hypot(dx, dy);
617
+ }
618
+ function clamp$1(v, lo, hi) {
619
+ return Math.min(hi, Math.max(lo, v));
620
+ }
621
+ //#endregion
622
+ //#region src/core-internal/extensionTypes.ts
623
+ function cloneCustomObject(object) {
624
+ return {
625
+ ...object,
626
+ transform: [...object.transform],
627
+ fallback: { ...object.fallback },
628
+ ...object.lock ? { lock: { ...object.lock } } : {}
629
+ };
630
+ }
631
+ //#endregion
632
+ //#region src/core-internal/kitchenTimer.ts
633
+ var TIMER_DEFAULT_SIZE = 12;
634
+ var TIMER_DEFAULT_DURATION_MS = 300 * 1e3;
635
+ var TIMER_PRESETS_MS = [
636
+ 6e4,
637
+ 5 * 6e4,
638
+ 10 * 6e4,
639
+ 15 * 6e4
640
+ ];
641
+ function cloneTimer(timer) {
642
+ return { ...timer };
643
+ }
644
+ function timerRemaining(timer, now) {
645
+ if (!timer.runningSince) return Math.max(0, timer.remainingMs);
646
+ return Math.max(0, timer.remainingMs - (now - timer.runningSince));
647
+ }
648
+ function timerExpired(timer, now) {
649
+ return timerRemaining(timer, now) <= 0;
650
+ }
651
+ function startTimer(timer, now) {
652
+ const remaining = timerRemaining(timer, now);
653
+ if (remaining <= 0) return {
654
+ ...timer,
655
+ remainingMs: timer.durationMs,
656
+ runningSince: now
657
+ };
658
+ if (timer.runningSince) return timer;
659
+ return {
660
+ ...timer,
661
+ remainingMs: remaining,
662
+ runningSince: now
663
+ };
664
+ }
665
+ function pauseTimer(timer, now) {
666
+ if (!timer.runningSince) return timer;
667
+ return {
668
+ ...timer,
669
+ remainingMs: timerRemaining(timer, now),
670
+ runningSince: void 0
671
+ };
672
+ }
673
+ function toggleTimer(timer, now) {
674
+ return timer.runningSince ? pauseTimer(timer, now) : startTimer(timer, now);
675
+ }
676
+ function setTimerDuration(timer, durationMs) {
677
+ return {
678
+ ...timer,
679
+ durationMs,
680
+ remainingMs: durationMs,
681
+ runningSince: void 0
682
+ };
683
+ }
684
+ function formatTimer(ms) {
685
+ const total = Math.max(0, Math.ceil(ms / 1e3));
686
+ return `${Math.floor(total / 60)}:${(total % 60).toString().padStart(2, "0")}`;
687
+ }
688
+ //#endregion
689
+ //#region src/core-internal/itemLock.ts
690
+ /**
691
+ * Apply or clear a lock in place. Unlock always drops the holder so a stale
692
+ * name cannot linger on an unlocked item.
693
+ */
694
+ function applyItemLock(item, locked, by) {
695
+ if (locked) {
696
+ item.locked = true;
697
+ if (by) {
698
+ item.lockedBy = by.userId;
699
+ item.lockedByName = by.name;
700
+ }
701
+ return;
702
+ }
703
+ delete item.locked;
704
+ delete item.lockedBy;
705
+ delete item.lockedByName;
706
+ }
707
+ /**
708
+ * Only the person who locked it can unlock it. Items locked before ownership
709
+ * existed (no `lockedBy`) stay unlockable by anyone, so old boards don't brick.
710
+ */
711
+ function canUnlockItem(item, userId) {
712
+ if (!item?.locked) return true;
713
+ if (!item.lockedBy) return true;
714
+ return !!userId && item.lockedBy === userId;
715
+ }
716
+ /** Wire fields for a locked item; omitted entirely when unlocked. */
717
+ function serializeLock(item) {
718
+ if (!item.locked) return {};
719
+ return {
720
+ locked: true,
721
+ ...item.lockedBy ? { lockedBy: item.lockedBy } : {},
722
+ ...item.lockedByName ? { lockedByName: item.lockedByName } : {}
723
+ };
724
+ }
725
+ //#endregion
726
+ //#region src/core-internal/document.ts
727
+ /**
728
+ * The one place a live Stroke becomes its wire representation — in
729
+ * particular, points collapse from {x,y,pressure,erase?} objects into
730
+ * [x,y,pressure,erase] tuples. Anything that persists a stroke (toJSON, and
731
+ * the incremental ops opSync.ts sends) must go through this, or the two
732
+ * paths drift: a stroke saved with points-as-objects looks fine until the
733
+ * next load, where deserializeStrokes' array-destructuring throws "object is
734
+ * not iterable" — exactly what shipping the live object straight into an op
735
+ * used to do.
736
+ */
737
+ function serializeStroke(s) {
738
+ return {
739
+ id: s.id,
740
+ color: s.color,
741
+ baseWidth: s.baseWidth,
742
+ ...s.tool && s.tool !== "marker" ? { tool: s.tool } : {},
743
+ points: s.points.map((p) => [
744
+ p.x,
745
+ p.y,
746
+ p.pressure,
747
+ p.erase ?? 0
748
+ ]),
749
+ ...s.matrix && !isIdentity(s.matrix) ? { matrix: [...s.matrix] } : {},
750
+ ...s.clusterId ? { clusterId: s.clusterId } : {},
751
+ ...serializeLock(s)
752
+ };
753
+ }
754
+ var EMPTY_CHANGE = {
755
+ added: [],
756
+ removed: [],
757
+ updated: [],
758
+ transformed: [],
759
+ notesAdded: [],
760
+ notesRemoved: [],
761
+ notesUpdated: [],
762
+ textAdded: [],
763
+ textRemoved: [],
764
+ textUpdated: [],
765
+ tablesAdded: [],
766
+ tablesRemoved: [],
767
+ tablesUpdated: [],
768
+ imagesAdded: [],
769
+ imagesRemoved: [],
770
+ imagesUpdated: [],
771
+ timersAdded: [],
772
+ timersRemoved: [],
773
+ timersUpdated: [],
774
+ customObjectsAdded: [],
775
+ customObjectsRemoved: [],
776
+ customObjectsUpdated: []
777
+ };
778
+ var BoardDocument = class {
779
+ constructor(id) {
780
+ this.id = id;
781
+ this.version = 0;
782
+ this.strokes = /* @__PURE__ */ new Map();
783
+ this.notes = /* @__PURE__ */ new Map();
784
+ this.texts = /* @__PURE__ */ new Map();
785
+ this.tables = /* @__PURE__ */ new Map();
786
+ this.images = /* @__PURE__ */ new Map();
787
+ this.timers = /* @__PURE__ */ new Map();
788
+ this.customObjects = /* @__PURE__ */ new Map();
789
+ this.bboxes = /* @__PURE__ */ new Map();
790
+ this.listeners = /* @__PURE__ */ new Set();
791
+ }
792
+ get(id) {
793
+ return this.strokes.get(id);
794
+ }
795
+ all() {
796
+ return this.strokes.values();
797
+ }
798
+ bbox(id) {
799
+ return this.bboxes.get(id);
800
+ }
801
+ subscribe(listener) {
802
+ this.listeners.add(listener);
803
+ return () => this.listeners.delete(listener);
804
+ }
805
+ addStrokes(strokes) {
806
+ for (const stroke of strokes) {
807
+ this.strokes.set(stroke.id, stroke);
808
+ this.bboxes.set(stroke.id, computeBBox(stroke));
809
+ }
810
+ this.emit({ added: strokes });
811
+ }
812
+ removeStrokes(ids) {
813
+ const removed = [];
814
+ for (const id of ids) if (this.strokes.delete(id)) {
815
+ this.bboxes.delete(id);
816
+ removed.push(id);
817
+ }
818
+ if (removed.length) this.emit({ removed });
819
+ }
820
+ /** Announce in-place point mutations (erasure decay; bbox is unchanged). */
821
+ touchStrokes(strokes) {
822
+ this.emit({ updated: strokes });
823
+ }
824
+ /** Announce matrix changes; recomputes world bboxes. */
825
+ transformStrokes(strokes) {
826
+ for (const stroke of strokes) this.bboxes.set(stroke.id, computeBBox(stroke));
827
+ this.emit({ transformed: strokes });
828
+ }
829
+ getNote(id) {
830
+ return this.notes.get(id);
831
+ }
832
+ allNotes() {
833
+ return this.notes.values();
834
+ }
835
+ addNotes(notes) {
836
+ for (const note of notes) this.notes.set(note.id, note);
837
+ this.emit({ notesAdded: notes });
838
+ }
839
+ removeNotes(ids) {
840
+ const notesRemoved = [];
841
+ for (const id of ids) if (this.notes.delete(id)) notesRemoved.push(id);
842
+ if (notesRemoved.length) this.emit({ notesRemoved });
843
+ }
844
+ /** Replace a note's contents (move, peel, retext) under the same id. */
845
+ setNote(note) {
846
+ this.notes.set(note.id, note);
847
+ this.emit({ notesUpdated: [note] });
848
+ }
849
+ getText(id) {
850
+ return this.texts.get(id);
851
+ }
852
+ allTexts() {
853
+ return this.texts.values();
854
+ }
855
+ addTexts(blocks) {
856
+ for (const block of blocks) this.texts.set(block.id, block);
857
+ this.emit({ textAdded: blocks });
858
+ }
859
+ removeTexts(ids) {
860
+ const textRemoved = [];
861
+ for (const id of ids) if (this.texts.delete(id)) textRemoved.push(id);
862
+ if (textRemoved.length) this.emit({ textRemoved });
863
+ }
864
+ /** Replace a text block's contents (move, retext) under the same id. */
865
+ setText(block) {
866
+ this.texts.set(block.id, block);
867
+ this.emit({ textUpdated: [block] });
868
+ }
869
+ getTable(id) {
870
+ return this.tables.get(id);
871
+ }
872
+ allTables() {
873
+ return this.tables.values();
874
+ }
875
+ addTables(tables) {
876
+ for (const table of tables) this.tables.set(table.id, table);
877
+ this.emit({ tablesAdded: tables });
878
+ }
879
+ removeTables(ids) {
880
+ const tablesRemoved = [];
881
+ for (const id of ids) if (this.tables.delete(id)) tablesRemoved.push(id);
882
+ if (tablesRemoved.length) this.emit({ tablesRemoved });
883
+ }
884
+ /** Replace a table's contents (move, resize, change cells) under the same id. */
885
+ setTable(table) {
886
+ this.tables.set(table.id, table);
887
+ this.emit({ tablesUpdated: [table] });
888
+ }
889
+ getImage(id) {
890
+ return this.images.get(id);
891
+ }
892
+ allImages() {
893
+ return this.images.values();
894
+ }
895
+ addImages(images) {
896
+ for (const img of images) this.images.set(img.id, img);
897
+ this.emit({ imagesAdded: images });
898
+ }
899
+ removeImages(ids) {
900
+ const imagesRemoved = [];
901
+ for (const id of ids) if (this.images.delete(id)) imagesRemoved.push(id);
902
+ if (imagesRemoved.length) this.emit({ imagesRemoved });
903
+ }
904
+ /** Replace an image block's contents (move, resize) under the same id. */
905
+ setImage(image) {
906
+ this.images.set(image.id, image);
907
+ this.emit({ imagesUpdated: [image] });
908
+ }
909
+ getTimer(id) {
910
+ return this.timers.get(id);
911
+ }
912
+ allTimers() {
913
+ return this.timers.values();
914
+ }
915
+ addTimers(timers) {
916
+ for (const timer of timers) this.timers.set(timer.id, timer);
917
+ this.emit({ timersAdded: timers });
918
+ }
919
+ removeTimers(ids) {
920
+ const timersRemoved = [];
921
+ for (const id of ids) if (this.timers.delete(id)) timersRemoved.push(id);
922
+ if (timersRemoved.length) this.emit({ timersRemoved });
923
+ }
924
+ setTimer(timer) {
925
+ this.timers.set(timer.id, timer);
926
+ this.emit({ timersUpdated: [timer] });
927
+ }
928
+ getCustomObject(id) {
929
+ return this.customObjects.get(id);
930
+ }
931
+ allCustomObjects() {
932
+ return this.customObjects.values();
933
+ }
934
+ addCustomObjects(objects) {
935
+ for (const object of objects) this.customObjects.set(object.id, object);
936
+ this.emit({ customObjectsAdded: objects });
937
+ }
938
+ removeCustomObjects(ids) {
939
+ const customObjectsRemoved = [];
940
+ for (const id of ids) if (this.customObjects.delete(id)) customObjectsRemoved.push(id);
941
+ if (customObjectsRemoved.length) this.emit({ customObjectsRemoved });
942
+ }
943
+ /** Replace a custom object's contents under the same id. */
944
+ setCustomObject(object) {
945
+ this.customObjects.set(object.id, object);
946
+ this.emit({ customObjectsUpdated: [object] });
947
+ }
948
+ setStrokeLocked(id, locked, by) {
949
+ const stroke = this.strokes.get(id);
950
+ if (!stroke) return;
951
+ applyItemLock(stroke, locked, by);
952
+ this.emit({ updated: [stroke] });
953
+ }
954
+ setStrokesLocked(ids, locked, by) {
955
+ const updated = [];
956
+ for (const id of ids) {
957
+ const stroke = this.strokes.get(id);
958
+ if (stroke) {
959
+ applyItemLock(stroke, locked, by);
960
+ updated.push(stroke);
961
+ }
962
+ }
963
+ if (updated.length) this.emit({ updated });
964
+ }
965
+ setNoteLocked(id, locked, by) {
966
+ const note = this.notes.get(id);
967
+ if (!note) return;
968
+ applyItemLock(note, locked, by);
969
+ this.emit({ notesUpdated: [note] });
970
+ }
971
+ setTextLocked(id, locked, by) {
972
+ const text = this.texts.get(id);
973
+ if (!text) return;
974
+ applyItemLock(text, locked, by);
975
+ this.emit({ textUpdated: [text] });
976
+ }
977
+ setTableLocked(id, locked, by) {
978
+ const table = this.tables.get(id);
979
+ if (!table) return;
980
+ applyItemLock(table, locked, by);
981
+ this.emit({ tablesUpdated: [table] });
982
+ }
983
+ setImageLocked(id, locked, by) {
984
+ const img = this.images.get(id);
985
+ if (!img) return;
986
+ applyItemLock(img, locked, by);
987
+ this.emit({ imagesUpdated: [img] });
988
+ }
989
+ setTimerLocked(id, locked, by) {
990
+ const timer = this.timers.get(id);
991
+ if (!timer) return;
992
+ applyItemLock(timer, locked, by);
993
+ this.emit({ timersUpdated: [timer] });
994
+ }
995
+ /** Replace all content (initial load). Does not touch `version`. */
996
+ replaceAll(strokes, notes, texts, tables = [], images = [], timers = [], customObjects = []) {
997
+ const removed = [...this.strokes.keys()];
998
+ const notesRemoved = [...this.notes.keys()];
999
+ const textRemoved = [...this.texts.keys()];
1000
+ const tablesRemoved = [...this.tables.keys()];
1001
+ const imagesRemoved = [...this.images.keys()];
1002
+ const timersRemoved = [...this.timers.keys()];
1003
+ const customObjectsRemoved = [...this.customObjects.keys()];
1004
+ this.strokes.clear();
1005
+ this.notes.clear();
1006
+ this.texts.clear();
1007
+ this.tables.clear();
1008
+ this.images.clear();
1009
+ this.timers.clear();
1010
+ this.customObjects.clear();
1011
+ this.bboxes.clear();
1012
+ for (const stroke of strokes) {
1013
+ this.strokes.set(stroke.id, stroke);
1014
+ this.bboxes.set(stroke.id, computeBBox(stroke));
1015
+ }
1016
+ for (const note of notes) this.notes.set(note.id, note);
1017
+ for (const text of texts) this.texts.set(text.id, text);
1018
+ for (const table of tables) this.tables.set(table.id, table);
1019
+ for (const img of images) this.images.set(img.id, img);
1020
+ for (const timer of timers) this.timers.set(timer.id, timer);
1021
+ for (const object of customObjects) this.customObjects.set(object.id, object);
1022
+ this.emit({
1023
+ added: strokes,
1024
+ removed,
1025
+ notesAdded: notes,
1026
+ notesRemoved,
1027
+ textAdded: texts,
1028
+ textRemoved,
1029
+ tablesAdded: tables,
1030
+ tablesRemoved,
1031
+ imagesAdded: images,
1032
+ imagesRemoved,
1033
+ timersAdded: timers,
1034
+ timersRemoved,
1035
+ customObjectsAdded: customObjects,
1036
+ customObjectsRemoved
1037
+ });
1038
+ }
1039
+ /** Apply incremental real-time change received from a remote collaborator over WebSocket. */
1040
+ applyRemoteChange(change) {
1041
+ const events = {};
1042
+ if (change.added && change.added.length > 0) {
1043
+ for (const stroke of change.added) {
1044
+ this.strokes.set(stroke.id, stroke);
1045
+ this.bboxes.set(stroke.id, computeBBox(stroke));
1046
+ }
1047
+ events.added = change.added;
1048
+ }
1049
+ if (change.removed && change.removed.length > 0) {
1050
+ const removed = [];
1051
+ for (const id of change.removed) if (this.strokes.delete(id)) {
1052
+ this.bboxes.delete(id);
1053
+ removed.push(id);
1054
+ }
1055
+ if (removed.length > 0) events.removed = removed;
1056
+ }
1057
+ if (change.updated && change.updated.length > 0) {
1058
+ for (const stroke of change.updated) {
1059
+ this.strokes.set(stroke.id, stroke);
1060
+ this.bboxes.set(stroke.id, computeBBox(stroke));
1061
+ }
1062
+ events.updated = change.updated;
1063
+ }
1064
+ if (change.transformed && change.transformed.length > 0) {
1065
+ for (const stroke of change.transformed) {
1066
+ this.strokes.set(stroke.id, stroke);
1067
+ this.bboxes.set(stroke.id, computeBBox(stroke));
1068
+ }
1069
+ events.transformed = change.transformed;
1070
+ }
1071
+ if (change.notesAdded && change.notesAdded.length > 0) {
1072
+ for (const note of change.notesAdded) this.notes.set(note.id, note);
1073
+ events.notesAdded = change.notesAdded;
1074
+ }
1075
+ if (change.notesRemoved && change.notesRemoved.length > 0) {
1076
+ const notesRemoved = [];
1077
+ for (const id of change.notesRemoved) if (this.notes.delete(id)) notesRemoved.push(id);
1078
+ if (notesRemoved.length > 0) events.notesRemoved = notesRemoved;
1079
+ }
1080
+ if (change.notesUpdated && change.notesUpdated.length > 0) {
1081
+ for (const note of change.notesUpdated) this.notes.set(note.id, note);
1082
+ events.notesUpdated = change.notesUpdated;
1083
+ }
1084
+ if (change.textAdded && change.textAdded.length > 0) {
1085
+ for (const t of change.textAdded) this.texts.set(t.id, t);
1086
+ events.textAdded = change.textAdded;
1087
+ }
1088
+ if (change.textRemoved && change.textRemoved.length > 0) {
1089
+ const textRemoved = [];
1090
+ for (const id of change.textRemoved) if (this.texts.delete(id)) textRemoved.push(id);
1091
+ if (textRemoved.length > 0) events.textRemoved = textRemoved;
1092
+ }
1093
+ if (change.textUpdated && change.textUpdated.length > 0) {
1094
+ for (const t of change.textUpdated) this.texts.set(t.id, t);
1095
+ events.textUpdated = change.textUpdated;
1096
+ }
1097
+ if (change.tablesAdded && change.tablesAdded.length > 0) {
1098
+ for (const tbl of change.tablesAdded) this.tables.set(tbl.id, tbl);
1099
+ events.tablesAdded = change.tablesAdded;
1100
+ }
1101
+ if (change.tablesRemoved && change.tablesRemoved.length > 0) {
1102
+ const tablesRemoved = [];
1103
+ for (const id of change.tablesRemoved) if (this.tables.delete(id)) tablesRemoved.push(id);
1104
+ if (tablesRemoved.length > 0) events.tablesRemoved = tablesRemoved;
1105
+ }
1106
+ if (change.tablesUpdated && change.tablesUpdated.length > 0) {
1107
+ for (const tbl of change.tablesUpdated) this.tables.set(tbl.id, tbl);
1108
+ events.tablesUpdated = change.tablesUpdated;
1109
+ }
1110
+ if (change.imagesAdded && change.imagesAdded.length > 0) {
1111
+ for (const img of change.imagesAdded) this.images.set(img.id, img);
1112
+ events.imagesAdded = change.imagesAdded;
1113
+ }
1114
+ if (change.imagesRemoved && change.imagesRemoved.length > 0) {
1115
+ const imagesRemoved = [];
1116
+ for (const id of change.imagesRemoved) if (this.images.delete(id)) imagesRemoved.push(id);
1117
+ if (imagesRemoved.length > 0) events.imagesRemoved = imagesRemoved;
1118
+ }
1119
+ if (change.imagesUpdated && change.imagesUpdated.length > 0) {
1120
+ for (const img of change.imagesUpdated) this.images.set(img.id, img);
1121
+ events.imagesUpdated = change.imagesUpdated;
1122
+ }
1123
+ if (change.timersAdded && change.timersAdded.length > 0) {
1124
+ for (const timer of change.timersAdded) this.timers.set(timer.id, timer);
1125
+ events.timersAdded = change.timersAdded;
1126
+ }
1127
+ if (change.timersRemoved && change.timersRemoved.length > 0) {
1128
+ const timersRemoved = [];
1129
+ for (const id of change.timersRemoved) if (this.timers.delete(id)) timersRemoved.push(id);
1130
+ if (timersRemoved.length > 0) events.timersRemoved = timersRemoved;
1131
+ }
1132
+ if (change.timersUpdated && change.timersUpdated.length > 0) {
1133
+ for (const timer of change.timersUpdated) this.timers.set(timer.id, timer);
1134
+ events.timersUpdated = change.timersUpdated;
1135
+ }
1136
+ if (change.customObjectsAdded && change.customObjectsAdded.length > 0) {
1137
+ for (const object of change.customObjectsAdded) this.customObjects.set(object.id, object);
1138
+ events.customObjectsAdded = change.customObjectsAdded;
1139
+ }
1140
+ if (change.customObjectsRemoved && change.customObjectsRemoved.length > 0) {
1141
+ const customObjectsRemoved = [];
1142
+ for (const id of change.customObjectsRemoved) if (this.customObjects.delete(id)) customObjectsRemoved.push(id);
1143
+ if (customObjectsRemoved.length > 0) events.customObjectsRemoved = customObjectsRemoved;
1144
+ }
1145
+ if (change.customObjectsUpdated && change.customObjectsUpdated.length > 0) {
1146
+ for (const object of change.customObjectsUpdated) this.customObjects.set(object.id, object);
1147
+ events.customObjectsUpdated = change.customObjectsUpdated;
1148
+ }
1149
+ this.emit(events);
1150
+ }
1151
+ toJSON() {
1152
+ return {
1153
+ schemaVersion: 1,
1154
+ strokes: [...this.strokes.values()].map(serializeStroke),
1155
+ notes: [...this.notes.values()].map(cloneNote),
1156
+ textBlocks: [...this.texts.values()].map(cloneText),
1157
+ tables: [...this.tables.values()].map(cloneTable),
1158
+ images: [...this.images.values()].map(cloneImage),
1159
+ timers: [...this.timers.values()].map(cloneTimer),
1160
+ customObjects: [...this.customObjects.values()].map(cloneCustomObject)
1161
+ };
1162
+ }
1163
+ static deserializeCustomObjects(data) {
1164
+ return (data.customObjects ?? []).map(cloneCustomObject);
1165
+ }
1166
+ static deserializeImages(data) {
1167
+ return (data.images ?? []).map(cloneImage);
1168
+ }
1169
+ static deserializeTimers(data) {
1170
+ return (data.timers ?? []).map(cloneTimer);
1171
+ }
1172
+ static deserializeNotes(data) {
1173
+ return (data.notes ?? []).map(cloneNote);
1174
+ }
1175
+ static deserializeTexts(data) {
1176
+ return (data.textBlocks ?? []).map(cloneText);
1177
+ }
1178
+ static deserializeTables(data) {
1179
+ return (data.tables ?? []).map(cloneTable);
1180
+ }
1181
+ static deserializeStrokes(data) {
1182
+ const strokes = [];
1183
+ for (const s of data.strokes) try {
1184
+ strokes.push({
1185
+ id: s.id,
1186
+ color: s.color,
1187
+ baseWidth: s.baseWidth,
1188
+ ...s.tool ? { tool: s.tool } : {},
1189
+ points: s.points.map(parseStrokePoint),
1190
+ ...s.matrix ? { matrix: [...s.matrix] } : {},
1191
+ ...s.clusterId ? { clusterId: s.clusterId } : {},
1192
+ ...serializeLock(s)
1193
+ });
1194
+ } catch (err) {
1195
+ console.error(`Skipping malformed stroke ${s?.id ?? "(no id)"}:`, err);
1196
+ }
1197
+ return strokes;
1198
+ }
1199
+ emit(change) {
1200
+ const full = {
1201
+ ...EMPTY_CHANGE,
1202
+ ...change
1203
+ };
1204
+ for (const listener of this.listeners) listener(full);
1205
+ }
1206
+ };
1207
+ /** World-space bbox: local point bounds pushed through the stroke matrix. */
1208
+ /**
1209
+ * A point should be the wire tuple [x,y,pressure,erase]. Also accepts the
1210
+ * live {x,y,pressure,erase?} object shape, which is what a stroke saved
1211
+ * through the buggy pre-fix ops path was persisted as — so boards written
1212
+ * during that window keep loading instead of throwing "object is not
1213
+ * iterable" forever. Write paths always produce tuples; this is read-side
1214
+ * tolerance for data already on disk, not a second accepted format.
1215
+ */
1216
+ function parseStrokePoint(raw) {
1217
+ if (Array.isArray(raw)) {
1218
+ const [x, y, pressure, erase] = raw;
1219
+ return {
1220
+ x,
1221
+ y,
1222
+ pressure,
1223
+ ...erase > 0 ? { erase } : {}
1224
+ };
1225
+ }
1226
+ if (raw && typeof raw === "object") {
1227
+ const p = raw;
1228
+ return {
1229
+ x: p.x,
1230
+ y: p.y,
1231
+ pressure: p.pressure,
1232
+ ...p.erase ? { erase: p.erase } : {}
1233
+ };
1234
+ }
1235
+ throw new Error("point is neither a tuple nor an object");
1236
+ }
1237
+ function computeBBox(stroke) {
1238
+ const box = {
1239
+ minX: Infinity,
1240
+ minY: Infinity,
1241
+ maxX: -Infinity,
1242
+ maxY: -Infinity
1243
+ };
1244
+ for (const p of stroke.points) {
1245
+ if (p.x < box.minX) box.minX = p.x;
1246
+ if (p.y < box.minY) box.minY = p.y;
1247
+ if (p.x > box.maxX) box.maxX = p.x;
1248
+ if (p.y > box.maxY) box.maxY = p.y;
1249
+ }
1250
+ const m = stroke.matrix;
1251
+ if (!m || isIdentity(m)) return box;
1252
+ const world = {
1253
+ minX: Infinity,
1254
+ minY: Infinity,
1255
+ maxX: -Infinity,
1256
+ maxY: -Infinity
1257
+ };
1258
+ for (const corner of [
1259
+ {
1260
+ x: box.minX,
1261
+ y: box.minY
1262
+ },
1263
+ {
1264
+ x: box.maxX,
1265
+ y: box.minY
1266
+ },
1267
+ {
1268
+ x: box.minX,
1269
+ y: box.maxY
1270
+ },
1271
+ {
1272
+ x: box.maxX,
1273
+ y: box.maxY
1274
+ }
1275
+ ]) {
1276
+ const p = apply(m, corner);
1277
+ if (p.x < world.minX) world.minX = p.x;
1278
+ if (p.y < world.minY) world.minY = p.y;
1279
+ if (p.x > world.maxX) world.maxX = p.x;
1280
+ if (p.y > world.maxY) world.maxY = p.y;
1281
+ }
1282
+ return world;
1283
+ }
1284
+ //#endregion
1285
+ //#region src/core-internal/commands.ts
1286
+ /** One drawing action — a marker stroke, or a shape's strokes as one unit. */
1287
+ var AddStrokesCommand = class {
1288
+ constructor(strokes) {
1289
+ this.label = "draw";
1290
+ this.strokes = strokes.map(cloneStroke);
1291
+ }
1292
+ apply(doc) {
1293
+ doc.addStrokes(this.strokes.map(cloneStroke));
1294
+ }
1295
+ revert(doc) {
1296
+ doc.removeStrokes(this.strokes.map((s) => s.id));
1297
+ }
1298
+ };
1299
+ /**
1300
+ * One eraser swipe (ADR 0003): `before` are the touched strokes as they were
1301
+ * at swipe start; `after` is what survived — smudged, split, or gone.
1302
+ */
1303
+ var EraseCommand = class {
1304
+ constructor(before, after) {
1305
+ this.label = "erase";
1306
+ this.before = before.map(cloneStroke);
1307
+ this.after = after.map(cloneStroke);
1308
+ }
1309
+ apply(doc) {
1310
+ doc.removeStrokes(this.before.map((s) => s.id));
1311
+ doc.addStrokes(this.after.map(cloneStroke));
1312
+ }
1313
+ revert(doc) {
1314
+ doc.removeStrokes(this.after.map((s) => s.id));
1315
+ doc.addStrokes(this.before.map(cloneStroke));
1316
+ }
1317
+ };
1318
+ /**
1319
+ * One transform gesture: `delta` composed onto each member's matrix
1320
+ * (`child = delta × child`, per §6.3 — never baked into geometry).
1321
+ */
1322
+ var TransformCommand = class {
1323
+ constructor(ids, delta) {
1324
+ this.ids = ids;
1325
+ this.delta = delta;
1326
+ this.label = "transform selection";
1327
+ this.inverse = invert(delta);
1328
+ }
1329
+ apply(doc) {
1330
+ this.compose(doc, this.delta);
1331
+ }
1332
+ revert(doc) {
1333
+ this.compose(doc, this.inverse);
1334
+ }
1335
+ compose(doc, m) {
1336
+ const changed = [];
1337
+ for (const id of this.ids) {
1338
+ const stroke = doc.get(id);
1339
+ if (!stroke) continue;
1340
+ stroke.matrix = mul(m, stroke.matrix ?? [
1341
+ 1,
1342
+ 0,
1343
+ 0,
1344
+ 1,
1345
+ 0,
1346
+ 0
1347
+ ]);
1348
+ changed.push(stroke);
1349
+ }
1350
+ if (changed.length) doc.transformStrokes(changed);
1351
+ }
1352
+ };
1353
+ var AddNoteCommand = class {
1354
+ constructor(note) {
1355
+ this.label = "add note";
1356
+ this.note = cloneNote(note);
1357
+ }
1358
+ apply(doc) {
1359
+ doc.addNotes([cloneNote(this.note)]);
1360
+ }
1361
+ revert(doc) {
1362
+ doc.removeNotes([this.note.id]);
1363
+ }
1364
+ };
1365
+ /** Any note mutation — move, peel, recolor, retext — as before/after. */
1366
+ var UpdateNoteCommand = class {
1367
+ constructor(before, after) {
1368
+ this.label = "update note";
1369
+ this.before = cloneNote(before);
1370
+ this.after = cloneNote(after);
1371
+ }
1372
+ apply(doc) {
1373
+ doc.setNote(cloneNote(this.after));
1374
+ }
1375
+ revert(doc) {
1376
+ doc.setNote(cloneNote(this.before));
1377
+ }
1378
+ };
1379
+ var DeleteNoteCommand = class {
1380
+ constructor(note) {
1381
+ this.label = "delete note";
1382
+ this.note = cloneNote(note);
1383
+ }
1384
+ apply(doc) {
1385
+ doc.removeNotes([this.note.id]);
1386
+ }
1387
+ revert(doc) {
1388
+ doc.addNotes([cloneNote(this.note)]);
1389
+ }
1390
+ };
1391
+ var AddTextCommand = class {
1392
+ constructor(block) {
1393
+ this.label = "add text";
1394
+ this.block = cloneText(block);
1395
+ }
1396
+ apply(doc) {
1397
+ doc.addTexts([cloneText(this.block)]);
1398
+ }
1399
+ revert(doc) {
1400
+ doc.removeTexts([this.block.id]);
1401
+ }
1402
+ };
1403
+ /** Any text-block mutation — move or retext — as before/after. */
1404
+ var UpdateTextCommand = class {
1405
+ constructor(before, after) {
1406
+ this.label = "update text";
1407
+ this.before = cloneText(before);
1408
+ this.after = cloneText(after);
1409
+ }
1410
+ apply(doc) {
1411
+ doc.setText(cloneText(this.after));
1412
+ }
1413
+ revert(doc) {
1414
+ doc.setText(cloneText(this.before));
1415
+ }
1416
+ };
1417
+ var DeleteTextCommand = class {
1418
+ constructor(block) {
1419
+ this.label = "delete text";
1420
+ this.block = cloneText(block);
1421
+ }
1422
+ apply(doc) {
1423
+ doc.removeTexts([this.block.id]);
1424
+ }
1425
+ revert(doc) {
1426
+ doc.addTexts([cloneText(this.block)]);
1427
+ }
1428
+ };
1429
+ var AddTableCommand = class {
1430
+ constructor(table) {
1431
+ this.label = "add table";
1432
+ this.table = cloneTable(table);
1433
+ }
1434
+ apply(doc) {
1435
+ doc.addTables([cloneTable(this.table)]);
1436
+ }
1437
+ revert(doc) {
1438
+ doc.removeTables([this.table.id]);
1439
+ }
1440
+ };
1441
+ /** Any table mutation — move, resize, cell text edit — as before/after. */
1442
+ var UpdateTableCommand = class {
1443
+ constructor(before, after) {
1444
+ this.label = "update table";
1445
+ this.before = cloneTable(before);
1446
+ this.after = cloneTable(after);
1447
+ }
1448
+ apply(doc) {
1449
+ doc.setTable(cloneTable(this.after));
1450
+ }
1451
+ revert(doc) {
1452
+ doc.setTable(cloneTable(this.before));
1453
+ }
1454
+ };
1455
+ var DeleteTableCommand = class {
1456
+ constructor(table) {
1457
+ this.label = "delete table";
1458
+ this.table = cloneTable(table);
1459
+ }
1460
+ apply(doc) {
1461
+ doc.removeTables([this.table.id]);
1462
+ }
1463
+ revert(doc) {
1464
+ doc.addTables([cloneTable(this.table)]);
1465
+ }
1466
+ };
1467
+ var DeleteStrokesCommand = class {
1468
+ constructor(strokes) {
1469
+ this.label = "delete selection";
1470
+ this.strokes = strokes.map(cloneStroke);
1471
+ }
1472
+ apply(doc) {
1473
+ doc.removeStrokes(this.strokes.map((s) => s.id));
1474
+ }
1475
+ revert(doc) {
1476
+ doc.addStrokes(this.strokes.map(cloneStroke));
1477
+ }
1478
+ };
1479
+ var AddImageCommand = class {
1480
+ constructor(image) {
1481
+ this.label = "add image";
1482
+ this.image = cloneImage(image);
1483
+ }
1484
+ apply(doc) {
1485
+ doc.addImages([cloneImage(this.image)]);
1486
+ }
1487
+ revert(doc) {
1488
+ doc.removeImages([this.image.id]);
1489
+ }
1490
+ };
1491
+ /** Any image mutation — move, resize — as before/after. */
1492
+ var UpdateImageCommand = class {
1493
+ constructor(before, after) {
1494
+ this.label = "update image";
1495
+ this.before = cloneImage(before);
1496
+ this.after = cloneImage(after);
1497
+ }
1498
+ apply(doc) {
1499
+ doc.setImage(cloneImage(this.after));
1500
+ }
1501
+ revert(doc) {
1502
+ doc.setImage(cloneImage(this.before));
1503
+ }
1504
+ };
1505
+ var DeleteImageCommand = class {
1506
+ constructor(image) {
1507
+ this.label = "delete image";
1508
+ this.image = cloneImage(image);
1509
+ }
1510
+ apply(doc) {
1511
+ doc.removeImages([this.image.id]);
1512
+ }
1513
+ revert(doc) {
1514
+ doc.addImages([cloneImage(this.image)]);
1515
+ }
1516
+ };
1517
+ var AddTimerCommand = class {
1518
+ constructor(timer) {
1519
+ this.label = "add timer";
1520
+ this.timer = cloneTimer(timer);
1521
+ }
1522
+ apply(doc) {
1523
+ doc.addTimers([cloneTimer(this.timer)]);
1524
+ }
1525
+ revert(doc) {
1526
+ doc.removeTimers([this.timer.id]);
1527
+ }
1528
+ };
1529
+ var UpdateTimerCommand = class {
1530
+ constructor(before, after) {
1531
+ this.label = "update timer";
1532
+ this.before = cloneTimer(before);
1533
+ this.after = cloneTimer(after);
1534
+ }
1535
+ apply(doc) {
1536
+ doc.setTimer(cloneTimer(this.after));
1537
+ }
1538
+ revert(doc) {
1539
+ doc.setTimer(cloneTimer(this.before));
1540
+ }
1541
+ };
1542
+ var DeleteTimerCommand = class {
1543
+ constructor(timer) {
1544
+ this.label = "delete timer";
1545
+ this.timer = cloneTimer(timer);
1546
+ }
1547
+ apply(doc) {
1548
+ doc.removeTimers([this.timer.id]);
1549
+ }
1550
+ revert(doc) {
1551
+ doc.addTimers([cloneTimer(this.timer)]);
1552
+ }
1553
+ };
1554
+ var LockItemsCommand = class {
1555
+ constructor(targets, targetState, actor) {
1556
+ this.label = "toggle lock";
1557
+ this.targets = targets.map((t) => ({ ...t }));
1558
+ this.targetState = targetState;
1559
+ this.actor = actor ?? null;
1560
+ }
1561
+ apply(doc) {
1562
+ const by = this.targetState ? this.actor : null;
1563
+ for (const t of this.targets) this.applyLock(doc, t.type, t.id, this.targetState, by);
1564
+ }
1565
+ revert(doc) {
1566
+ for (const t of this.targets) {
1567
+ const by = t.locked && t.lockedBy ? {
1568
+ userId: t.lockedBy,
1569
+ name: t.lockedByName ?? ""
1570
+ } : null;
1571
+ this.applyLock(doc, t.type, t.id, t.locked, by);
1572
+ }
1573
+ }
1574
+ applyLock(doc, type, id, locked, by) {
1575
+ if (type === "stroke") doc.setStrokeLocked(id, locked, by);
1576
+ else if (type === "note") doc.setNoteLocked(id, locked, by);
1577
+ else if (type === "text") doc.setTextLocked(id, locked, by);
1578
+ else if (type === "table") doc.setTableLocked(id, locked, by);
1579
+ else if (type === "image") doc.setImageLocked(id, locked, by);
1580
+ else if (type === "timer") doc.setTimerLocked(id, locked, by);
1581
+ }
1582
+ };
1583
+ //#endregion
1584
+ //#region src/core-internal/history.ts
1585
+ var LIMIT = 200;
1586
+ var History = class {
1587
+ constructor(doc) {
1588
+ this.doc = doc;
1589
+ this.undoStack = [];
1590
+ this.redoStack = [];
1591
+ this.onCommand = null;
1592
+ this.undoing = false;
1593
+ }
1594
+ /** Apply a command and make it undoable. */
1595
+ execute(command) {
1596
+ command.apply(this.doc);
1597
+ this.record(command);
1598
+ }
1599
+ /** Make an already-applied change undoable (e.g. a live erase swipe). */
1600
+ record(command) {
1601
+ this.undoStack.push(command);
1602
+ if (this.undoStack.length > LIMIT) this.undoStack.shift();
1603
+ this.redoStack.length = 0;
1604
+ this.onCommand?.(command, "do");
1605
+ }
1606
+ /**
1607
+ * True while a revert is in flight. Persistence reads this: re-adding
1608
+ * something the author deleted must be sent as a `restore`, the only op the
1609
+ * server lets past a tombstone (ADR 0006).
1610
+ */
1611
+ get isUndoing() {
1612
+ return this.undoing;
1613
+ }
1614
+ undo() {
1615
+ const command = this.undoStack.pop();
1616
+ if (!command) return;
1617
+ this.undoing = true;
1618
+ try {
1619
+ command.revert(this.doc);
1620
+ } finally {
1621
+ this.undoing = false;
1622
+ }
1623
+ this.redoStack.push(command);
1624
+ this.onCommand?.(command, "undo");
1625
+ }
1626
+ redo() {
1627
+ const command = this.redoStack.pop();
1628
+ if (!command) return;
1629
+ command.apply(this.doc);
1630
+ this.undoStack.push(command);
1631
+ this.onCommand?.(command, "redo");
1632
+ }
1633
+ get canUndo() {
1634
+ return this.undoStack.length > 0;
1635
+ }
1636
+ get canRedo() {
1637
+ return this.redoStack.length > 0;
1638
+ }
1639
+ clear() {
1640
+ this.undoStack.length = 0;
1641
+ this.redoStack.length = 0;
1642
+ }
1643
+ };
1644
+ //#endregion
1645
+ //#region src/core-internal/ops.ts
1646
+ var ADDED = [
1647
+ ["added", "strokes"],
1648
+ ["notesAdded", "notes"],
1649
+ ["textAdded", "textBlocks"],
1650
+ ["tablesAdded", "tables"],
1651
+ ["imagesAdded", "images"],
1652
+ ["timersAdded", "timers"],
1653
+ ["customObjectsAdded", "customObjects"]
1654
+ ];
1655
+ var UPDATED = [
1656
+ ["updated", "strokes"],
1657
+ ["transformed", "strokes"],
1658
+ ["notesUpdated", "notes"],
1659
+ ["textUpdated", "textBlocks"],
1660
+ ["tablesUpdated", "tables"],
1661
+ ["imagesUpdated", "images"],
1662
+ ["timersUpdated", "timers"],
1663
+ ["customObjectsUpdated", "customObjects"]
1664
+ ];
1665
+ var REMOVED = [
1666
+ ["removed", "strokes"],
1667
+ ["notesRemoved", "notes"],
1668
+ ["textRemoved", "textBlocks"],
1669
+ ["tablesRemoved", "tables"],
1670
+ ["imagesRemoved", "images"],
1671
+ ["timersRemoved", "timers"],
1672
+ ["customObjectsRemoved", "customObjects"]
1673
+ ];
1674
+ function toWireObject(collection, object) {
1675
+ return collection === "strokes" ? serializeStroke(object) : object;
1676
+ }
1677
+ /** Convert a canonical Document change into durable, renderer-neutral Ops. */
1678
+ function changeToOps(change, restoring = false) {
1679
+ const ops = [];
1680
+ const addKind = restoring ? "restore" : "upsert";
1681
+ for (const [field, collection] of ADDED) for (const object of change[field] ?? []) ops.push({
1682
+ kind: addKind,
1683
+ collection,
1684
+ object: toWireObject(collection, object)
1685
+ });
1686
+ for (const [field, collection] of UPDATED) for (const object of change[field] ?? []) ops.push({
1687
+ kind: "upsert",
1688
+ collection,
1689
+ object: toWireObject(collection, object)
1690
+ });
1691
+ for (const [field, collection] of REMOVED) for (const id of change[field] ?? []) ops.push({
1692
+ kind: "remove",
1693
+ collection,
1694
+ id
1695
+ });
1696
+ return ops;
1697
+ }
1698
+ //#endregion
1699
+ //#region src/core-internal/ribbonEdges.ts
1700
+ var MIN_WIDTH_FACTOR = .35;
1701
+ var END_TAPER = .55;
1702
+ function ribbonEdges(points, baseWidth) {
1703
+ const pts = points.length === 1 ? [points[0], {
1704
+ ...points[0],
1705
+ x: points[0].x + baseWidth * .05
1706
+ }] : points;
1707
+ const n = pts.length;
1708
+ const out = new Array(n);
1709
+ for (let i = 0; i < n; i++) {
1710
+ const prev = pts[Math.max(0, i - 1)];
1711
+ const next = pts[Math.min(n - 1, i + 1)];
1712
+ let dx = next.x - prev.x;
1713
+ let dy = next.y - prev.y;
1714
+ const len = Math.hypot(dx, dy) || 1;
1715
+ dx /= len;
1716
+ dy /= len;
1717
+ let width = baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * pts[i].pressure);
1718
+ if (i === 0 || i === n - 1) width *= END_TAPER;
1719
+ const hw = width / 2;
1720
+ out[i] = {
1721
+ lx: pts[i].x - dy * hw,
1722
+ ly: pts[i].y + dx * hw,
1723
+ rx: pts[i].x + dy * hw,
1724
+ ry: pts[i].y - dx * hw,
1725
+ alpha: 1 - Math.min(1, pts[i].erase ?? 0)
1726
+ };
1727
+ }
1728
+ return out;
1729
+ }
1730
+ //#endregion
1731
+ //#region src/core-internal/spatialIndex.ts
1732
+ var CELL = 8;
1733
+ var SpatialIndex = class {
1734
+ constructor(doc) {
1735
+ this.doc = doc;
1736
+ this.cells = /* @__PURE__ */ new Map();
1737
+ this.strokeCells = /* @__PURE__ */ new Map();
1738
+ this.unsubscribe = doc.subscribe((change) => {
1739
+ for (const id of change.removed) this.remove(id);
1740
+ for (const stroke of change.added) this.insert(stroke.id);
1741
+ for (const stroke of change.transformed) {
1742
+ this.remove(stroke.id);
1743
+ this.insert(stroke.id);
1744
+ }
1745
+ });
1746
+ for (const stroke of doc.all()) this.insert(stroke.id);
1747
+ }
1748
+ /** Ids of strokes whose bbox may overlap the query rect. */
1749
+ query(minX, minY, maxX, maxY) {
1750
+ const result = /* @__PURE__ */ new Set();
1751
+ for (const key of cellsOf({
1752
+ minX,
1753
+ minY,
1754
+ maxX,
1755
+ maxY
1756
+ })) {
1757
+ const bucket = this.cells.get(key);
1758
+ if (bucket) for (const id of bucket) result.add(id);
1759
+ }
1760
+ return result;
1761
+ }
1762
+ dispose() {
1763
+ this.unsubscribe();
1764
+ this.cells.clear();
1765
+ this.strokeCells.clear();
1766
+ }
1767
+ insert(id) {
1768
+ const box = this.doc.bbox(id);
1769
+ if (!box) return;
1770
+ const keys = cellsOf(box);
1771
+ this.strokeCells.set(id, keys);
1772
+ for (const key of keys) {
1773
+ let bucket = this.cells.get(key);
1774
+ if (!bucket) {
1775
+ bucket = /* @__PURE__ */ new Set();
1776
+ this.cells.set(key, bucket);
1777
+ }
1778
+ bucket.add(id);
1779
+ }
1780
+ }
1781
+ remove(id) {
1782
+ const keys = this.strokeCells.get(id);
1783
+ if (!keys) return;
1784
+ this.strokeCells.delete(id);
1785
+ for (const key of keys) {
1786
+ const bucket = this.cells.get(key);
1787
+ if (bucket) {
1788
+ bucket.delete(id);
1789
+ if (bucket.size === 0) this.cells.delete(key);
1790
+ }
1791
+ }
1792
+ }
1793
+ };
1794
+ function cellsOf(box) {
1795
+ const x0 = Math.floor(box.minX / CELL);
1796
+ const x1 = Math.floor(box.maxX / CELL);
1797
+ const y0 = Math.floor(box.minY / CELL);
1798
+ const y1 = Math.floor(box.maxY / CELL);
1799
+ const keys = [];
1800
+ for (let x = x0; x <= x1; x++) for (let y = y0; y <= y1; y++) keys.push(`${x}:${y}`);
1801
+ return keys;
1802
+ }
1803
+ //#endregion
1804
+ //#region src/core-internal/stamps.ts
1805
+ var STAMP_SIZE = 6;
1806
+ var STAMPS = [
1807
+ {
1808
+ kind: "star",
1809
+ label: "Star",
1810
+ glyph: "★"
1811
+ },
1812
+ {
1813
+ kind: "check",
1814
+ label: "Check",
1815
+ glyph: "✓"
1816
+ },
1817
+ {
1818
+ kind: "ship",
1819
+ label: "Ship it",
1820
+ glyph: "⚑"
1821
+ },
1822
+ {
1823
+ kind: "heart",
1824
+ label: "Heart",
1825
+ glyph: "♥"
1826
+ },
1827
+ {
1828
+ kind: "plus",
1829
+ label: "Plus",
1830
+ glyph: "+"
1831
+ },
1832
+ {
1833
+ kind: "fire",
1834
+ label: "Fire",
1835
+ glyph: "!"
1836
+ }
1837
+ ];
1838
+ var SVG = {
1839
+ star: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><polygon fill="#F59E0B" points="32,4 39.5,24.2 61,24.2 43.7,36.6 50.4,57 32,44.4 13.6,57 20.3,36.6 3,24.2 24.5,24.2"/></svg>`,
1840
+ check: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="28" fill="#16A34A"/><path fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" d="M18 33l10 10 18-20"/></svg>`,
1841
+ ship: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect x="8" y="8" width="48" height="48" rx="10" fill="#2563EB"/><text x="32" y="30" text-anchor="middle" font-size="11" font-family="ui-rounded,sans-serif" font-weight="700" fill="#fff">SHIP</text><text x="32" y="46" text-anchor="middle" font-size="11" font-family="ui-rounded,sans-serif" font-weight="700" fill="#fff">IT</text></svg>`,
1842
+ heart: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="#E11D48" d="M32 54S8 38 8 24c0-8 6-14 14-14 5 0 8 3 10 6 2-3 5-6 10-6 8 0 14 6 14 14 0 14-24 30-24 30z"/></svg>`,
1843
+ plus: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="28" fill="#7C3AED"/><path fill="none" stroke="#fff" stroke-width="7" stroke-linecap="round" d="M32 18v28M18 32h28"/></svg>`,
1844
+ fire: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="#EA580C" d="M32 6s4 10-2 18c6-2 14 4 14 16 0 12-8 20-14 20s-14-8-14-20c0-10 6-16 10-20 2 6 6 8 6-14z"/><path fill="#FDE047" d="M32 30s2 6-1 10c3-1 7 2 7 8 0 6-4 10-6 10s-6-4-6-10c0-5 3-8 5-10 1 3 3 4 3-8z"/></svg>`
1845
+ };
1846
+ function stampDataUrl(kind) {
1847
+ return `data:image/svg+xml;utf8,${encodeURIComponent(SVG[kind])}`;
1848
+ }
1849
+ function isStampKind(value) {
1850
+ return typeof value === "string" && STAMPS.some((s) => s.kind === value);
1851
+ }
1852
+ //#endregion
1853
+ //#region src/core-internal/presence.ts
1854
+ /** Keep beacons clear of typical chrome at the top and bottom of the Board. */
1855
+ var BEACON_INSET = {
1856
+ top: 72,
1857
+ right: 24,
1858
+ bottom: 108,
1859
+ left: 24
1860
+ };
1861
+ /**
1862
+ * If a collaborator is in the usable viewport, return their screen position.
1863
+ * If they are outside it, clamp to the nearest edge and return the angle a
1864
+ * chevron should point (screen space, radians, 0 = right, clockwise).
1865
+ */
1866
+ function placePresenceBeacon(screen, viewport, inset = BEACON_INSET) {
1867
+ const minX = inset.left;
1868
+ const maxX = Math.max(inset.left, viewport.width - inset.right);
1869
+ const minY = inset.top;
1870
+ const maxY = Math.max(inset.top, viewport.height - inset.bottom);
1871
+ if (screen.x >= minX && screen.x <= maxX && screen.y >= minY && screen.y <= maxY) return {
1872
+ kind: "on-screen",
1873
+ x: screen.x,
1874
+ y: screen.y
1875
+ };
1876
+ const x = clamp(screen.x, minX, maxX);
1877
+ const y = clamp(screen.y, minY, maxY);
1878
+ return {
1879
+ kind: "edge",
1880
+ x,
1881
+ y,
1882
+ angle: Math.atan2(screen.y - y, screen.x - x)
1883
+ };
1884
+ }
1885
+ function clamp(value, min, max) {
1886
+ return Math.min(max, Math.max(min, value));
1887
+ }
1888
+ //#endregion
1889
+ //#region src/core-internal/svg.ts
1890
+ /** Matches the key light used by the board shader and note shadows. */
1891
+ var LIGHT = {
1892
+ x: -.25,
1893
+ y: .4,
1894
+ z: .88
1895
+ };
1896
+ var MARGIN = 4;
1897
+ var NOTE_FONT_RATIO = 44 / 512;
1898
+ var NOTE_PAD_RATIO = 40 / 512;
1899
+ /**
1900
+ * `registry` is optional and only enables rendering Custom objects through
1901
+ * their own `describe()` — without it (or for an object whose extension
1902
+ * isn't in it), Custom objects still export via the standard fallback
1903
+ * placeholder (`fallback.bounds`/`label`), never silently dropped.
1904
+ *
1905
+ * `resolvedAssets` (ticket #23) maps an Asset reference to an already-
1906
+ * resolved `data:` URI — see `assetExport.ts`'s `exportDocumentSVGWithAssets`,
1907
+ * which is the only intended caller that ever passes one. Without it, every
1908
+ * `ref`-backed image (built-in or Custom `SceneImage`) renders its
1909
+ * placeholder instead of guessing at a URL; a legacy `src`-backed image is
1910
+ * unaffected either way.
1911
+ */
1912
+ function documentToSVG(doc, now = 0, registry, resolvedAssets) {
1913
+ const notes = doc.notes ?? [];
1914
+ const texts = doc.textBlocks ?? [];
1915
+ const tables = doc.tables ?? [];
1916
+ const images = doc.images ?? [];
1917
+ const timers = doc.timers ?? [];
1918
+ const customObjects = doc.customObjects ?? [];
1919
+ const bounds = contentBounds(doc.strokes, notes, texts, tables, images, timers, customObjects);
1920
+ const view = `${fmt(bounds.minX)} ${fmt(bounds.minY)} ${fmt(bounds.width)} ${fmt(bounds.height)}`;
1921
+ const defs = notes.map(noteShadowFilter).join("\n");
1922
+ const highlights = doc.strokes.filter((s) => s.tool === "highlighter");
1923
+ const ink = doc.strokes.filter((s) => s.tool !== "highlighter");
1924
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${view}">\n<defs>\n${defs}\n</defs>\n${[
1925
+ `<rect x="${fmt(bounds.minX)}" y="${fmt(bounds.minY)}" width="${fmt(bounds.width)}" height="${fmt(bounds.height)}" fill="${BOARD_COLOR}"/>`,
1926
+ ...images.map((image) => imageToElement(image, resolvedAssets)),
1927
+ ...tables.map(tableToGroup),
1928
+ ...highlights.flatMap(strokeToPaths),
1929
+ ...ink.flatMap(strokeToPaths),
1930
+ ...texts.map(textToElement),
1931
+ ...notes.map(noteToGroup),
1932
+ ...timers.map((timer) => timerToGroup(timer, now)),
1933
+ ...customObjects.map((object) => customObjectToGroup(object, registry, resolvedAssets))
1934
+ ].join("\n")}\n</svg>\n`;
1935
+ }
1936
+ /**
1937
+ * Renders through the object's own `describe()` when its extension is
1938
+ * registered; otherwise (or if `describe` throws) falls back to the
1939
+ * standard non-editable placeholder — see the spec's "unknown/invalid
1940
+ * Custom objects... display a standard non-editable fallback" rule.
1941
+ */
1942
+ function customObjectToGroup(object, registry, resolvedAssets) {
1943
+ const definition = registry?.objectType(object.type);
1944
+ if (definition) try {
1945
+ const scene = definition.describe(object, { selected: false });
1946
+ return `<g data-id="${object.id}">${sceneNodeToSVG(scene, object.transform, resolvedAssets)}</g>`;
1947
+ } catch {}
1948
+ return fallbackToGroup(object);
1949
+ }
1950
+ /** Board-space matrix `m`, expressed as the equivalent SVG-space `matrix()`. */
1951
+ function svgTransformAttr(m) {
1952
+ if (isIdentity(m)) return "";
1953
+ return ` transform="matrix(${fmt(m[0])} ${fmt(-m[1])} ${fmt(-m[2])} ${fmt(m[3])} ${fmt(m[4])} ${fmt(-m[5])})"`;
1954
+ }
1955
+ /**
1956
+ * Walks one `BoardScene` node. Coordinates are object-local (pre-`parent`
1957
+ * transform, y grows the same "downward" direction `sceneGeometry.ts`'s
1958
+ * bounds walk assumes); `ScenePath` has no renderer yet (deferred, matching
1959
+ * `sceneGeometry.ts`'s own bounds walk) so it renders nothing rather than
1960
+ * guessing at SVG path-data semantics.
1961
+ */
1962
+ function sceneNodeToSVG(node, parent, resolvedAssets) {
1963
+ const m = node.transform ? mul(parent, node.transform) : parent;
1964
+ const opacityAttr = node.opacity !== void 0 && node.opacity < 1 ? ` opacity="${fmt(node.opacity)}"` : "";
1965
+ const transformAttr = svgTransformAttr(m);
1966
+ switch (node.kind) {
1967
+ case "rect": {
1968
+ const svgY = -node.y - node.height;
1969
+ const rx = node.cornerRadius ? ` rx="${fmt(node.cornerRadius)}"` : "";
1970
+ const stroke = node.stroke ? ` stroke="${node.stroke}" stroke-width="${fmt(node.strokeWidth ?? .1)}"` : "";
1971
+ return `<rect x="${fmt(node.x)}" y="${fmt(svgY)}" width="${fmt(node.width)}" height="${fmt(node.height)}" fill="${node.fill ?? "none"}"${rx}${stroke}${opacityAttr}${transformAttr}/>`;
1972
+ }
1973
+ case "text": {
1974
+ const anchor = node.align === "center" ? ` text-anchor="middle"` : node.align === "end" ? ` text-anchor="end"` : "";
1975
+ const tspans = node.text.split("\n").map((line, i) => `<tspan x="${fmt(node.x)}" y="${fmt(-node.y + node.fontSize * (.8 + 1.25 * i))}">${escapeXML(line)}</tspan>`).join("");
1976
+ return `<text font-family="Inter, 'Segoe UI', sans-serif" font-size="${fmt(node.fontSize)}" font-weight="500" fill="${node.color}"${anchor}${opacityAttr}${transformAttr}>${tspans}</text>`;
1977
+ }
1978
+ case "ellipse": {
1979
+ const stroke = node.stroke ? ` stroke="${node.stroke}" stroke-width="${fmt(node.strokeWidth ?? .1)}"` : "";
1980
+ return `<ellipse cx="${fmt(node.cx)}" cy="${fmt(-node.cy)}" rx="${fmt(node.rx)}" ry="${fmt(node.ry)}" fill="${node.fill ?? "none"}"${stroke}${opacityAttr}${transformAttr}/>`;
1981
+ }
1982
+ case "image": {
1983
+ const svgY = -node.y - node.height;
1984
+ const dataUri = resolvedAssets?.get(node.ref);
1985
+ if (!dataUri) {
1986
+ const label = typeof node.alt === "string" ? node.alt : void 0;
1987
+ return `<g${opacityAttr}${transformAttr}>${imagePlaceholderElement(node.x, svgY, node.width, node.height, label)}</g>`;
1988
+ }
1989
+ const preserveAspectRatio = node.fit === "contain" ? "xMidYMid meet" : node.fit === "cover" ? "xMidYMid slice" : "none";
1990
+ return `<image href="${escapeXML(dataUri)}" x="${fmt(node.x)}" y="${fmt(svgY)}" width="${fmt(node.width)}" height="${fmt(node.height)}" preserveAspectRatio="${preserveAspectRatio}"${opacityAttr}${transformAttr}/>`;
1991
+ }
1992
+ case "group": return `<g${opacityAttr}${transformAttr}>${node.children.map((child) => sceneNodeToSVG(child, IDENTITY, resolvedAssets)).join("")}</g>`;
1993
+ case "path": return "";
1994
+ }
1995
+ }
1996
+ /**
1997
+ * A never-resolved or missing Asset's placeholder (ticket #23) — same
1998
+ * visual language as `fallbackToGroup`'s Custom-object fallback. Never
1999
+ * reveals the reference, a path, a URL, or a resolver error detail.
2000
+ */
2001
+ function imagePlaceholderElement(x, y, width, height, label) {
2002
+ const labelEl = label ? `<text x="${fmt(x + Math.min(.3, width * .1))}" y="${fmt(y + Math.min(1.2, height * .4))}" font-family="Inter, 'Segoe UI', sans-serif" font-size="${fmt(Math.min(1.1, height * .3))}" fill="#6B7280">${escapeXML(label)}</text>` : "";
2003
+ return [`<rect x="${fmt(x)}" y="${fmt(y)}" width="${fmt(width)}" height="${fmt(height)}" fill="#F3F4F6" stroke="#9CA3AF" stroke-width="0.15" stroke-dasharray="0.3 0.3"/>`, labelEl].join("");
2004
+ }
2005
+ function fallbackToGroup(object) {
2006
+ const b = object.fallback.bounds;
2007
+ const svgY = -b.y - b.height;
2008
+ const label = object.fallback.label;
2009
+ const labelEl = label ? `<text x="${fmt(b.x + Math.min(.3, b.width * .1))}" y="${fmt(svgY + Math.min(1.2, b.height * .4))}" font-family="Inter, 'Segoe UI', sans-serif" font-size="${fmt(Math.min(1.1, b.height * .3))}" fill="#6B7280">${escapeXML(label)}</text>` : "";
2010
+ return [
2011
+ `<g data-id="${object.id}" data-fallback="true"${svgTransformAttr(object.transform)}>`,
2012
+ `<rect x="${fmt(b.x)}" y="${fmt(svgY)}" width="${fmt(b.width)}" height="${fmt(b.height)}" fill="#F3F4F6" stroke="#9CA3AF" stroke-width="0.15" stroke-dasharray="0.3 0.3"/>`,
2013
+ labelEl,
2014
+ `</g>`
2015
+ ].join("");
2016
+ }
2017
+ /** One filled outline path per run of similar erasure; y flipped at emit. */
2018
+ function strokeToPaths(stroke) {
2019
+ const edges = ribbonEdges(stroke.points.map(([x, y, pressure, erase]) => ({
2020
+ x,
2021
+ y,
2022
+ pressure,
2023
+ ...erase > 0 ? { erase } : {}
2024
+ })), stroke.baseWidth);
2025
+ const transform = stroke.matrix ? ` transform="matrix(${fmt(stroke.matrix[0])} ${fmt(-stroke.matrix[1])} ${fmt(-stroke.matrix[2])} ${fmt(stroke.matrix[3])} ${fmt(stroke.matrix[4])} ${fmt(-stroke.matrix[5])})"` : "";
2026
+ const baseOpacity = stroke.tool === "highlighter" ? .4 : 1;
2027
+ const paths = [];
2028
+ for (const run of splitByOpacity(edges)) {
2029
+ if (run.edges.length < 2) continue;
2030
+ const left = run.edges.map((e) => `${fmt(e.lx)} ${fmt(-e.ly)}`);
2031
+ const right = [...run.edges].reverse().map((e) => `${fmt(e.rx)} ${fmt(-e.ry)}`);
2032
+ const d = `M ${left.join(" L ")} L ${right.join(" L ")} Z`;
2033
+ const opacity = run.opacity * baseOpacity;
2034
+ const opacityAttr = opacity < 1 ? ` fill-opacity="${fmt(opacity)}"` : "";
2035
+ paths.push(`<path data-id="${stroke.id}" d="${d}" fill="${stroke.color}"${opacityAttr}${transform}/>`);
2036
+ }
2037
+ return paths;
2038
+ }
2039
+ /** Consecutive edge points bucketed by quarter-step opacity; erased spans dropped. */
2040
+ function splitByOpacity(edges) {
2041
+ const runs = [];
2042
+ for (const edge of edges) {
2043
+ const opacity = Math.round(edge.alpha * 4) / 4;
2044
+ const current = runs[runs.length - 1];
2045
+ if (current && current.opacity === opacity) current.edges.push(edge);
2046
+ else runs.push({
2047
+ opacity,
2048
+ edges: [edge]
2049
+ });
2050
+ }
2051
+ return runs.filter((r) => r.opacity > 0);
2052
+ }
2053
+ /** Typed text: top-left anchored, lines flow downward (svg +y after flip). */
2054
+ function textToElement(block) {
2055
+ const top = -block.y;
2056
+ const tspans = block.text.split("\n").map((line, i) => `<tspan x="${fmt(block.x)}" y="${fmt(top + block.fontSize * (.8 + 1.25 * i))}">${escapeXML(line)}</tspan>`).join("");
2057
+ return `<text font-family="Inter, 'Segoe UI', sans-serif" font-size="${fmt(block.fontSize)}" font-weight="500" fill="${block.color}">${tspans}</text>`;
2058
+ }
2059
+ function noteShadowFilter(note) {
2060
+ const dx = -LIGHT.x / LIGHT.z * note.zOffset;
2061
+ const dy = LIGHT.y / LIGHT.z * note.zOffset;
2062
+ const blur = (.35 + note.zOffset * .55) / 2;
2063
+ const opacity = Math.max(.05, .26 - note.zOffset * .028);
2064
+ return `<filter id="sh-${note.id}" x="-50%" y="-50%" width="200%" height="200%"><feDropShadow dx="${fmt(dx)}" dy="${fmt(dy)}" stdDeviation="${fmt(blur)}" flood-opacity="${fmt(opacity)}"/></filter>`;
2065
+ }
2066
+ function noteToGroup(note) {
2067
+ const left = note.x - note.size / 2;
2068
+ const top = -note.y - note.size / 2;
2069
+ const fontSize = note.size * NOTE_FONT_RATIO;
2070
+ const pad = note.size * NOTE_PAD_RATIO;
2071
+ const tspans = wrapText(note.text, note.size - pad * 2, fontSize).map((line, i) => `<tspan x="${fmt(left + pad)}" y="${fmt(top + pad + fontSize * (.8 + 1.25 * i))}">${escapeXML(line)}</tspan>`).join("");
2072
+ return [
2073
+ `<g>`,
2074
+ `<rect x="${fmt(left)}" y="${fmt(top)}" width="${fmt(note.size)}" height="${fmt(note.size)}" fill="${note.color}" filter="url(#sh-${note.id})"/>`,
2075
+ `<text font-family="ui-rounded, 'Segoe UI', sans-serif" font-size="${fmt(fontSize)}" font-weight="500" fill="#37352F">${tspans}</text>`,
2076
+ `</g>`
2077
+ ].join("");
2078
+ }
2079
+ /**
2080
+ * Deterministic word wrap with an average-glyph-width estimate — no DOM
2081
+ * text measurement, so the walk stays pure and testable.
2082
+ */
2083
+ function wrapText(text, maxWidth, fontSize) {
2084
+ const charWidth = fontSize * .55;
2085
+ const maxChars = Math.max(1, Math.floor(maxWidth / charWidth));
2086
+ const lines = [];
2087
+ for (const paragraph of text.split("\n")) {
2088
+ let line = "";
2089
+ for (const word of paragraph.split(" ")) {
2090
+ const attempt = line ? `${line} ${word}` : word;
2091
+ if (attempt.length > maxChars && line) {
2092
+ lines.push(line);
2093
+ line = word;
2094
+ } else line = attempt;
2095
+ }
2096
+ lines.push(line);
2097
+ }
2098
+ return lines;
2099
+ }
2100
+ function tableToGroup(table) {
2101
+ const { width, height } = measureTable(table);
2102
+ const left = table.x;
2103
+ const top = -table.y;
2104
+ const headerH = table.rowHeights[0] ?? height / table.rows;
2105
+ const borderColor = table.color || "#D1D5DB";
2106
+ const bgColor = table.backgroundColor || "#FFFFFF";
2107
+ const elements = [];
2108
+ elements.push(`<rect x="${fmt(left)}" y="${fmt(top)}" width="${fmt(width)}" height="${fmt(height)}" fill="${bgColor}" rx="0.5"/>`);
2109
+ elements.push(`<rect x="${fmt(left)}" y="${fmt(top)}" width="${fmt(width)}" height="${fmt(headerH)}" fill="#F3F4F6" rx="0.5"/>`);
2110
+ elements.push(`<rect x="${fmt(left)}" y="${fmt(top)}" width="${fmt(width)}" height="${fmt(height)}" fill="none" stroke="${borderColor}" stroke-width="0.3" rx="0.5"/>`);
2111
+ let curY = 0;
2112
+ for (let r = 0; r < table.rows - 1; r++) {
2113
+ curY += table.rowHeights[r];
2114
+ elements.push(`<line x1="${fmt(left)}" y1="${fmt(top + curY)}" x2="${fmt(left + width)}" y2="${fmt(top + curY)}" stroke="${borderColor}" stroke-width="0.2"/>`);
2115
+ }
2116
+ let curX = 0;
2117
+ for (let c = 0; c < table.cols - 1; c++) {
2118
+ curX += table.colWidths[c];
2119
+ elements.push(`<line x1="${fmt(left + curX)}" y1="${fmt(top)}" x2="${fmt(left + curX)}" y2="${fmt(top + height)}" stroke="${borderColor}" stroke-width="0.2"/>`);
2120
+ }
2121
+ let cellTop = 0;
2122
+ for (let r = 0; r < table.rows; r++) {
2123
+ const rowH = table.rowHeights[r];
2124
+ let cellLeft = 0;
2125
+ for (let c = 0; c < table.cols; c++) {
2126
+ const colW = table.colWidths[c];
2127
+ const key = `${r},${c}`;
2128
+ const text = table.cells[key] ?? "";
2129
+ if (text) {
2130
+ const padX = Math.min(.4, colW * .08);
2131
+ const padY = Math.min(.3, rowH * .12);
2132
+ const fontSize = Math.min(1.25, rowH * .45);
2133
+ const weight = r === 0 ? "bold" : "500";
2134
+ const color = r === 0 ? "#111827" : table.color ?? "#374151";
2135
+ elements.push(`<text x="${fmt(left + cellLeft + padX)}" y="${fmt(top + cellTop + padY + fontSize * .8)}" font-family="Inter, 'Segoe UI', sans-serif" font-size="${fmt(fontSize)}" font-weight="${weight}" fill="${color}">${escapeXML(text)}</text>`);
2136
+ }
2137
+ cellLeft += colW;
2138
+ }
2139
+ cellTop += rowH;
2140
+ }
2141
+ return `<g data-id="${table.id}">\n${elements.join("\n")}\n</g>`;
2142
+ }
2143
+ function timerToGroup(timer, now) {
2144
+ const r = timer.size / 2;
2145
+ const cx = timer.x;
2146
+ const cy = -timer.y;
2147
+ const label = formatTimer(timerRemaining(timer, now));
2148
+ return [
2149
+ `<g data-id="${timer.id}">`,
2150
+ `<circle cx="${fmt(cx + .4)}" cy="${fmt(cy + .4)}" r="${fmt(r)}" fill="#000" fill-opacity="0.12"/>`,
2151
+ `<circle cx="${fmt(cx)}" cy="${fmt(cy)}" r="${fmt(r)}" fill="#E11D48"/>`,
2152
+ `<circle cx="${fmt(cx)}" cy="${fmt(cy)}" r="${fmt(r * .72)}" fill="#FAFAF9"/>`,
2153
+ `<text x="${fmt(cx)}" y="${fmt(cy + timer.size * .08)}" text-anchor="middle" font-family="ui-rounded, 'Segoe UI', sans-serif" font-size="${fmt(timer.size * .22)}" font-weight="700" fill="#1C1917">${label}</text>`,
2154
+ `</g>`
2155
+ ].join("");
2156
+ }
2157
+ function imageToElement(image, resolvedAssets) {
2158
+ const left = image.x - image.width / 2;
2159
+ const top = -image.y - image.height / 2;
2160
+ if (image.ref) {
2161
+ const dataUri = resolvedAssets?.get(image.ref);
2162
+ if (!dataUri) return `<g data-id="${image.id}" data-fallback="true">${imagePlaceholderElement(left, top, image.width, image.height, image.name)}</g>`;
2163
+ return `<image data-id="${image.id}" href="${escapeXML(dataUri)}" x="${fmt(left)}" y="${fmt(top)}" width="${fmt(image.width)}" height="${fmt(image.height)}" preserveAspectRatio="none"/>`;
2164
+ }
2165
+ return `<image href="${escapeXML(image.src)}" x="${fmt(left)}" y="${fmt(top)}" width="${fmt(image.width)}" height="${fmt(image.height)}" preserveAspectRatio="none"/>`;
2166
+ }
2167
+ function contentBounds(strokes, notes, texts, tables = [], images = [], timers = [], customObjects = []) {
2168
+ let minX = Infinity;
2169
+ let minY = Infinity;
2170
+ let maxX = -Infinity;
2171
+ let maxY = -Infinity;
2172
+ const grow = (x, y) => {
2173
+ if (x < minX) minX = x;
2174
+ if (y < minY) minY = y;
2175
+ if (x > maxX) maxX = x;
2176
+ if (y > maxY) maxY = y;
2177
+ };
2178
+ for (const s of strokes) {
2179
+ const m = s.matrix;
2180
+ for (const [x, y] of s.points) grow(m ? m[0] * x + m[2] * y + m[4] : x, -(m ? m[1] * x + m[3] * y + m[5] : y));
2181
+ }
2182
+ for (const n of notes) {
2183
+ grow(n.x - n.size / 2, -n.y - n.size / 2);
2184
+ grow(n.x + n.size / 2, -n.y + n.size / 2);
2185
+ }
2186
+ for (const t of texts) {
2187
+ const { width, height } = measureTextBlock(t.text, t.fontSize);
2188
+ grow(t.x, -t.y);
2189
+ grow(t.x + width, -t.y + height);
2190
+ }
2191
+ for (const table of tables) {
2192
+ const { width, height } = measureTable(table);
2193
+ grow(table.x, -table.y);
2194
+ grow(table.x + width, -table.y + height);
2195
+ }
2196
+ for (const img of images) {
2197
+ grow(img.x - img.width / 2, -img.y - img.height / 2);
2198
+ grow(img.x + img.width / 2, -img.y + img.height / 2);
2199
+ }
2200
+ for (const timer of timers) {
2201
+ const half = timer.size / 2;
2202
+ grow(timer.x - half, -timer.y - half);
2203
+ grow(timer.x + half, -timer.y + half);
2204
+ }
2205
+ for (const object of customObjects) {
2206
+ const b = object.fallback.bounds;
2207
+ for (const corner of [
2208
+ {
2209
+ x: b.x,
2210
+ y: b.y
2211
+ },
2212
+ {
2213
+ x: b.x + b.width,
2214
+ y: b.y
2215
+ },
2216
+ {
2217
+ x: b.x,
2218
+ y: b.y + b.height
2219
+ },
2220
+ {
2221
+ x: b.x + b.width,
2222
+ y: b.y + b.height
2223
+ }
2224
+ ]) {
2225
+ const p = apply(object.transform, corner);
2226
+ grow(p.x, -p.y);
2227
+ }
2228
+ }
2229
+ if (minX > maxX) return {
2230
+ minX: 0,
2231
+ minY: 0,
2232
+ width: 100,
2233
+ height: 100
2234
+ };
2235
+ return {
2236
+ minX: minX - MARGIN,
2237
+ minY: minY - MARGIN,
2238
+ width: maxX - minX + MARGIN * 2,
2239
+ height: maxY - minY + MARGIN * 2
2240
+ };
2241
+ }
2242
+ function escapeXML(s) {
2243
+ return s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
2244
+ }
2245
+ function fmt(n) {
2246
+ return String(Math.round(n * 100) / 100);
2247
+ }
2248
+ //#endregion
2249
+ //#region src/core.ts
2250
+ var SDK_PACKAGE_NAME = "@scrawl-board/board";
2251
+ var SDK_DEVELOPMENT_VERSION = "0.0.0-development";
2252
+ //#endregion
2253
+ export { AddImageCommand, AddNoteCommand, AddStrokesCommand, AddTableCommand, AddTextCommand, AddTimerCommand, BEACON_INSET, BOARD_COLOR, BoardDocument, CURRENT_DOCUMENT_SCHEMA_VERSION, ClusterStore, DeleteImageCommand, DeleteNoteCommand, DeleteStrokesCommand, DeleteTableCommand, DeleteTextCommand, DeleteTimerCommand, DocumentRecoveryError, END_TAPER, ERASE_THRESHOLD, EraseCommand, FOG_COLOR, HIGHLIGHT_COLORS, History, IDENTITY, INK_COLORS, LockItemsCommand, MIN_WIDTH_FACTOR, NOTE_COLORS, NOTE_DEFAULT_SIZE, NOTE_DEFAULT_Z, NOTE_MAX_Z, NOTE_MIN_Z, NOTE_PEEL_STEP, SDK_DEVELOPMENT_VERSION, SDK_PACKAGE_NAME, STAMPS, STAMP_SIZE, SpatialIndex, TABLE_DEFAULT_CELL_HEIGHT, TABLE_DEFAULT_CELL_WIDTH, TABLE_DEFAULT_FONT_SIZE, TEXT_DEFAULT_SIZE, TIMER_DEFAULT_DURATION_MS, TIMER_DEFAULT_SIZE, TIMER_PRESETS_MS, TransformCommand, UpdateImageCommand, UpdateNoteCommand, UpdateTableCommand, UpdateTextCommand, UpdateTimerCommand, apply, applyItemLock, avgScale, canUnlockItem, changeToOps, cloneImage, cloneNote, cloneStroke, cloneTable, cloneText, cloneTimer, documentId, documentToSVG, formatTimer, invert, isIdentity, isStampKind, loadDocumentBytes, measureTable, measureTextBlock, migrateDocument, mul, pauseTimer, placePresenceBeacon, ribbonEdges, rotationAbout, scalingAbout, searchBoard, serializeDocument, serializeLock, serializeStroke, setTimerDuration, stampDataUrl, startTimer, strokeId, timerExpired, timerRemaining, toggleTimer, translation };