@scrawl-board/board 0.1.0-beta.7 → 0.1.0-beta.8

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 CHANGED
@@ -48,6 +48,37 @@ function strokeId(value) {
48
48
  return value;
49
49
  }
50
50
  //#endregion
51
+ //#region src/core/document/migrations/registry.ts
52
+ /** Schema versions this build can load. An unrecognized version fails safely (`DOCUMENT_VERSION_UNSUPPORTED`) rather than being silently treated as current. */
53
+ var SUPPORTED_SCHEMA_VERSIONS = /* @__PURE__ */ new Set([0, 1]);
54
+ function isSupportedSchemaVersion(version) {
55
+ return typeof version === "number" && SUPPORTED_SCHEMA_VERSIONS.has(version);
56
+ }
57
+ /**
58
+ * Document collection fields introduced after schemaVersion 1 first
59
+ * shipped real documents. A document stored before a field's introduction
60
+ * can never retroactively gain it, so requiring it at load would fail
61
+ * every pre-existing document — these stay optional even at the current
62
+ * version. New entries only ever get added here, never removed (an old
63
+ * stored document's absence of a since-introduced field must keep loading
64
+ * for as long as that document might still exist).
65
+ */
66
+ var FIELDS_OPTIONAL_AT_CURRENT_VERSION = /* @__PURE__ */ new Set([
67
+ "customObjects",
68
+ "rectangles",
69
+ "ellipses",
70
+ "groups",
71
+ "lines",
72
+ "arrows",
73
+ "polygons",
74
+ "stars",
75
+ "hearts"
76
+ ]);
77
+ /** Whether `field` must be present on a document already at the current schema version (never required on an older, migrating-forward document). */
78
+ function isFieldRequiredAtCurrentVersion(field) {
79
+ return !FIELDS_OPTIONAL_AT_CURRENT_VERSION.has(field);
80
+ }
81
+ //#endregion
51
82
  //#region src/core/document/document-schema.ts
52
83
  var CURRENT_DOCUMENT_SCHEMA_VERSION = 1;
53
84
  var DocumentRecoveryError = class extends Error {
@@ -76,7 +107,7 @@ function loadDocumentBytes(originalBytes) {
76
107
  function migrateDocument(raw) {
77
108
  if (!isRecord(raw)) return failure("DOCUMENT_VALIDATION_FAILED", "Document must be an object");
78
109
  const version = raw.schemaVersion ?? 0;
79
- if (version !== 0 && version !== 1) return failure("DOCUMENT_VERSION_UNSUPPORTED", `Document schema version ${String(version)} is unsupported`, void 0, "schemaVersion");
110
+ if (!isSupportedSchemaVersion(version)) return failure("DOCUMENT_VERSION_UNSUPPORTED", `Document schema version ${String(version)} is unsupported`, void 0, "schemaVersion");
80
111
  if (!isJsonValue(raw)) return failure("DOCUMENT_VALIDATION_FAILED", "Document must contain JSON values only");
81
112
  const strokes = validateStrokes(raw.strokes);
82
113
  if (strokes instanceof DocumentRecoveryError) return {
@@ -89,15 +120,15 @@ function migrateDocument(raw) {
89
120
  tables: validateCollection(raw, "tables", version, validateTable),
90
121
  images: validateCollection(raw, "images", version, validateImage),
91
122
  timers: validateCollection(raw, "timers", version, validateTimer),
92
- customObjects: validateCollection(raw, "customObjects", version, validateCustomObject, false),
93
- rectangles: validateCollection(raw, "rectangles", version, validateRectangle, false),
94
- ellipses: validateCollection(raw, "ellipses", version, validateEllipse, false),
95
- groups: validateCollection(raw, "groups", version, validateGroup, false),
96
- lines: validateCollection(raw, "lines", version, validateLine, false),
97
- arrows: validateCollection(raw, "arrows", version, validateArrow, false),
98
- polygons: validateCollection(raw, "polygons", version, validatePolygon, false),
99
- stars: validateCollection(raw, "stars", version, validateStar, false),
100
- hearts: validateCollection(raw, "hearts", version, validateHeart, false)
123
+ customObjects: validateCollection(raw, "customObjects", version, validateCustomObject),
124
+ rectangles: validateCollection(raw, "rectangles", version, validateRectangle),
125
+ ellipses: validateCollection(raw, "ellipses", version, validateEllipse),
126
+ groups: validateCollection(raw, "groups", version, validateGroup),
127
+ lines: validateCollection(raw, "lines", version, validateLine),
128
+ arrows: validateCollection(raw, "arrows", version, validateArrow),
129
+ polygons: validateCollection(raw, "polygons", version, validatePolygon),
130
+ stars: validateCollection(raw, "stars", version, validateStar),
131
+ hearts: validateCollection(raw, "hearts", version, validateHeart)
101
132
  };
102
133
  if (collections.notes instanceof DocumentRecoveryError) return {
103
134
  ok: false,
@@ -189,8 +220,19 @@ function serializeDocument(document) {
189
220
  if (!result.ok || result.migratedFrom !== 1) throw result.ok ? new DocumentRecoveryError("DOCUMENT_VALIDATION_FAILED", "Saving requires the current Document schema") : result.error;
190
221
  return JSON.stringify(result.document);
191
222
  }
192
- function validateCollection(raw, name, version, validator, requiredAtCurrentVersion = true) {
193
- if (requiredAtCurrentVersion && version === 1 && !(name in raw)) return validation(`${name} is required`, name);
223
+ /**
224
+ * A Document's serialized size in bytes (Phase 5) — UTF-8, not UTF-16
225
+ * `string.length`, since a Document with non-ASCII note/text content (most
226
+ * of them, eventually) would otherwise under-report. Useful for a Host
227
+ * deciding when to warn about an unusually large board, or for logging/
228
+ * telemetry around save size — not consulted by anything inside this
229
+ * package itself, which has no size limit of its own.
230
+ */
231
+ function documentSize(document) {
232
+ return new TextEncoder().encode(JSON.stringify(document)).length;
233
+ }
234
+ function validateCollection(raw, name, version, validator) {
235
+ if (isFieldRequiredAtCurrentVersion(name) && version === 1 && !(name in raw)) return validation(`${name} is required`, name);
194
236
  const value = raw[name] ?? [];
195
237
  if (!Array.isArray(value)) return validation(`${name} must be an array`, name);
196
238
  for (let index = 0; index < value.length; index += 1) if (!isRecord(value[index]) || !validator(value[index])) return validation(`invalid ${name} entry`, `${name}[${index}]`);
@@ -202,11 +244,20 @@ function validateStrokes(raw) {
202
244
  for (let index = 0; index < raw.length; index += 1) {
203
245
  const value = raw[index];
204
246
  const path = `strokes[${index}]`;
205
- if (!isRecord(value) || !nonempty(value.id) || !nonempty(value.color) || !positive(value.baseWidth) || !optionalEnum(value.tool, [
247
+ if (!isRecord(value)) return validation("stroke must be an object", path);
248
+ if (!nonempty(value.id)) return validation("stroke.id must be a non-empty string", `${path}.id`);
249
+ if (!nonempty(value.color)) return validation("stroke.color must be a non-empty string", `${path}.color`);
250
+ if (!positive(value.baseWidth)) return validation("stroke.baseWidth must be a positive number", `${path}.baseWidth`);
251
+ if (!optionalEnum(value.tool, [
206
252
  "marker",
207
253
  "highlighter",
208
254
  "shape"
209
- ]) || !validLock(value) || !optionalString(value.clusterId) || !validMatrix(value.matrix) || !Array.isArray(value.points)) return validation("invalid stroke", path);
255
+ ])) return validation("stroke.tool must be \"marker\", \"highlighter\", or \"shape\"", `${path}.tool`);
256
+ if (!validLock(value)) return validation("invalid stroke.lock", `${path}.lock`);
257
+ if (!validHidden(value)) return validation("invalid stroke.hidden", `${path}.hidden`);
258
+ if (!optionalString(value.clusterId)) return validation("stroke.clusterId must be a string", `${path}.clusterId`);
259
+ if (!validMatrix(value.matrix)) return validation("invalid stroke.matrix", `${path}.matrix`);
260
+ if (!Array.isArray(value.points)) return validation("stroke.points must be an array", `${path}.points`);
210
261
  const points = [];
211
262
  for (let pointIndex = 0; pointIndex < value.points.length; pointIndex += 1) {
212
263
  const point = normalizePoint(value.points[pointIndex]);
@@ -232,47 +283,47 @@ function normalizePoint(raw) {
232
283
  return values;
233
284
  }
234
285
  function validateNote(v) {
235
- 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)));
286
+ return basePosition(v) && positive(v.size) && finite(v.zOffset) && nonempty(v.color) && typeof v.text === "string" && validLock(v) && validHidden(v) && (v.votes === void 0 || Array.isArray(v.votes) && v.votes.every((vote) => isRecord(vote) && nonempty(vote.userId) && nonempty(vote.name) && nonempty(vote.color)));
236
287
  }
237
288
  function validateText(v) {
238
- return basePosition(v) && typeof v.text === "string" && positive(v.fontSize) && nonempty(v.color) && validLock(v) && optionalString(v.clusterId);
289
+ return basePosition(v) && typeof v.text === "string" && positive(v.fontSize) && nonempty(v.color) && validLock(v) && validHidden(v) && optionalString(v.clusterId);
239
290
  }
240
291
  function validateTable(v) {
241
- 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);
292
+ return basePosition(v) && integer(v.rows) && integer(v.cols) && numberArray(v.colWidths, v.cols) && numberArray(v.rowHeights, v.rows) && isStringRecord(v.cells) && validLock(v) && validHidden(v) && optionalString(v.color) && optionalString(v.backgroundColor) && optionalString(v.clusterId);
242
293
  }
243
294
  function validateImage(v) {
244
295
  const identifiesAnImage = v.ref !== void 0 ? isAssetRef(v.ref) : nonempty(v.src);
245
- return basePosition(v) && identifiesAnImage && positive(v.width) && positive(v.height) && positive(v.aspectRatio) && validLock(v) && optionalString(v.name) && optionalString(v.createdAt) && optionalString(v.stamp);
296
+ return basePosition(v) && identifiesAnImage && positive(v.width) && positive(v.height) && positive(v.aspectRatio) && validLock(v) && validHidden(v) && optionalString(v.name) && optionalString(v.createdAt) && optionalString(v.stamp);
246
297
  }
247
298
  function validateShapeStyle(v) {
248
299
  return optionalString(v.fill) && optionalString(v.stroke) && (v.strokeWidth === void 0 || positive(v.strokeWidth)) && (v.opacity === void 0 || finite(v.opacity) && v.opacity >= 0 && v.opacity <= 1);
249
300
  }
250
301
  function validateRectangle(v) {
251
- return basePosition(v) && positive(v.width) && positive(v.height) && validLock(v) && validateShapeStyle(v) && (v.rotation === void 0 || finite(v.rotation)) && (v.cornerRadius === void 0 || finite(v.cornerRadius) && v.cornerRadius >= 0);
302
+ return basePosition(v) && positive(v.width) && positive(v.height) && validLock(v) && validHidden(v) && validateShapeStyle(v) && (v.rotation === void 0 || finite(v.rotation)) && (v.cornerRadius === void 0 || finite(v.cornerRadius) && v.cornerRadius >= 0);
252
303
  }
253
304
  function validateEllipse(v) {
254
- return basePosition(v) && positive(v.width) && positive(v.height) && validLock(v) && validateShapeStyle(v) && (v.rotation === void 0 || finite(v.rotation));
305
+ return basePosition(v) && positive(v.width) && positive(v.height) && validLock(v) && validHidden(v) && validateShapeStyle(v) && (v.rotation === void 0 || finite(v.rotation));
255
306
  }
256
307
  function validatePolygon(v) {
257
- return basePosition(v) && positive(v.width) && positive(v.height) && validLock(v) && validateShapeStyle(v) && (v.rotation === void 0 || finite(v.rotation)) && (v.sides === 3 || v.sides === 4 || v.sides === 5 || v.sides === 6 || v.sides === 8);
308
+ return basePosition(v) && positive(v.width) && positive(v.height) && validLock(v) && validHidden(v) && validateShapeStyle(v) && (v.rotation === void 0 || finite(v.rotation)) && (v.sides === 3 || v.sides === 4 || v.sides === 5 || v.sides === 6 || v.sides === 8);
258
309
  }
259
310
  function validateStar(v) {
260
- return basePosition(v) && positive(v.width) && positive(v.height) && validLock(v) && validateShapeStyle(v) && (v.rotation === void 0 || finite(v.rotation)) && finite(v.points) && v.points >= 3 && finite(v.innerRadiusRatio) && v.innerRadiusRatio > 0 && v.innerRadiusRatio < 1;
311
+ return basePosition(v) && positive(v.width) && positive(v.height) && validLock(v) && validHidden(v) && validateShapeStyle(v) && (v.rotation === void 0 || finite(v.rotation)) && finite(v.points) && v.points >= 3 && finite(v.innerRadiusRatio) && v.innerRadiusRatio > 0 && v.innerRadiusRatio < 1;
261
312
  }
262
313
  function validateHeart(v) {
263
- return basePosition(v) && positive(v.width) && positive(v.height) && validLock(v) && validateShapeStyle(v) && (v.rotation === void 0 || finite(v.rotation));
314
+ return basePosition(v) && positive(v.width) && positive(v.height) && validLock(v) && validHidden(v) && validateShapeStyle(v) && (v.rotation === void 0 || finite(v.rotation));
264
315
  }
265
316
  function validPoint(v) {
266
317
  return isRecord(v) && finite(v.x) && finite(v.y);
267
318
  }
268
319
  function validateLine(v) {
269
- return nonempty(v.id) && validPoint(v.start) && validPoint(v.end) && validLock(v) && validateShapeStyle(v);
320
+ return nonempty(v.id) && validPoint(v.start) && validPoint(v.end) && validLock(v) && validHidden(v) && validateShapeStyle(v);
270
321
  }
271
322
  function validateArrow(v) {
272
- return nonempty(v.id) && validPoint(v.start) && validPoint(v.end) && validLock(v) && validateShapeStyle(v) && (v.head === void 0 || v.head === "triangle" || v.head === "none");
323
+ return nonempty(v.id) && validPoint(v.start) && validPoint(v.end) && validLock(v) && validHidden(v) && validateShapeStyle(v) && (v.head === void 0 || v.head === "triangle" || v.head === "none");
273
324
  }
274
325
  function validateGroup(v) {
275
- return nonempty(v.id) && Array.isArray(v.children) && v.children.every((c) => nonempty(c)) && validLock(v);
326
+ return nonempty(v.id) && Array.isArray(v.children) && v.children.every((c) => nonempty(c)) && validLock(v) && validHidden(v);
276
327
  }
277
328
  function validateObjectOrder(v) {
278
329
  if (v === void 0) return [];
@@ -280,7 +331,7 @@ function validateObjectOrder(v) {
280
331
  return structuredClone(v);
281
332
  }
282
333
  function validateTimer(v) {
283
- return basePosition(v) && positive(v.size) && positive(v.durationMs) && finite(v.remainingMs) && v.remainingMs >= 0 && (v.runningSince === void 0 || finite(v.runningSince)) && validLock(v);
334
+ return basePosition(v) && positive(v.size) && positive(v.durationMs) && finite(v.remainingMs) && v.remainingMs >= 0 && (v.runningSince === void 0 || finite(v.runningSince)) && validLock(v) && validHidden(v);
284
335
  }
285
336
  function validateCustomObject(v) {
286
337
  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);
@@ -299,6 +350,9 @@ function basePosition(v) {
299
350
  function validLock(v) {
300
351
  return (v.locked === void 0 || typeof v.locked === "boolean") && optionalString(v.lockedBy) && optionalString(v.lockedByName);
301
352
  }
353
+ function validHidden(v) {
354
+ return v.hidden === void 0 || typeof v.hidden === "boolean";
355
+ }
302
356
  function validMatrix(v) {
303
357
  return v === void 0 || Array.isArray(v) && v.length === 6 && v.every(finite);
304
358
  }
@@ -863,8 +917,51 @@ function serializeLock(item) {
863
917
  //#endregion
864
918
  //#region src/core/shapes/shapeObjects.ts
865
919
  var SHAPE_MIN_SIZE = .5;
866
- var SHAPE_DEFAULT_STROKE = "#1C1C1E";
867
- var SHAPE_DEFAULT_STROKE_WIDTH = .12;
920
+ /**
921
+ * Centralized shape style defaults (Phase 8) — one object a Host can read
922
+ * to know (or, by not relying on the standalone constants below, override
923
+ * via its own UI state) what a newly drawn Rectangle/Ellipse/Line/Arrow/
924
+ * Polygon/Star/Heart starts with when the user hasn't picked a stroke/width
925
+ * yet. `shapeTool.ts` (commit-time), `renderer/shapes/lines.ts` (render-time
926
+ * fallback for an object missing these fields), and
927
+ * `persistence/serialization/svg.ts` (export-time fallback) all read from
928
+ * here — the two standalone constants below are kept for source
929
+ * compatibility and simply mirror this object's values, not a second
930
+ * source of truth.
931
+ */
932
+ var SHAPE_STYLE_DEFAULTS = {
933
+ stroke: "#1C1C1E",
934
+ strokeWidth: .12
935
+ };
936
+ var SHAPE_DEFAULT_STROKE = SHAPE_STYLE_DEFAULTS.stroke;
937
+ var SHAPE_DEFAULT_STROKE_WIDTH = SHAPE_STYLE_DEFAULTS.strokeWidth;
938
+ /**
939
+ * Normalizes a box-shaped object's `x`/`y`/`width`/`height` to non-negative
940
+ * dimensions in place of whatever an arbitrary caller supplied (Phase 8) —
941
+ * the interactive drag tools (`shapeTool.ts`) already produce
942
+ * always-non-negative geometry by scanning min/max points before ever
943
+ * constructing a shape, but a raw `content.add`/`content.update` call (Host
944
+ * code, a Custom tool, or a remote collaboration Op) has no such guarantee.
945
+ * `BoardDocument` calls this at every entry point so "dragging from
946
+ * bottom-right toward top-left" and "a negative `width`/`height` patch"
947
+ * produce the same valid geometry regardless of source. A no-op (returns
948
+ * the same reference) when both dimensions are already non-negative, so
949
+ * this never allocates on the common path.
950
+ */
951
+ function normalizeBoxGeometry(box) {
952
+ if (box.width >= 0 && box.height >= 0) return box;
953
+ const left = Math.min(box.x, box.x + box.width);
954
+ const right = Math.max(box.x, box.x + box.width);
955
+ const top = Math.max(box.y, box.y - box.height);
956
+ const bottom = Math.min(box.y, box.y - box.height);
957
+ return {
958
+ ...box,
959
+ x: left,
960
+ y: top,
961
+ width: right - left,
962
+ height: top - bottom
963
+ };
964
+ }
868
965
  function cloneRectangle(rect) {
869
966
  return { ...rect };
870
967
  }
@@ -1233,31 +1330,33 @@ var BoardDocument = class {
1233
1330
  const i = this.orderIndex.get(id);
1234
1331
  if (i === void 0 || i >= this.objectOrder.length - 1) return;
1235
1332
  [this.objectOrder[i], this.objectOrder[i + 1]] = [this.objectOrder[i + 1], this.objectOrder[i]];
1236
- this.reindexOrder();
1237
- this.emit({ orderChanged: [...this.objectOrder] });
1333
+ this.orderIndex.set(this.objectOrder[i], i);
1334
+ this.orderIndex.set(this.objectOrder[i + 1], i + 1);
1335
+ this.emit({ orderChanged: [this.objectOrder[i], this.objectOrder[i + 1]] });
1238
1336
  }
1239
1337
  sendBackward(id) {
1240
1338
  const i = this.orderIndex.get(id);
1241
1339
  if (i === void 0 || i <= 0) return;
1242
1340
  [this.objectOrder[i], this.objectOrder[i - 1]] = [this.objectOrder[i - 1], this.objectOrder[i]];
1243
- this.reindexOrder();
1244
- this.emit({ orderChanged: [...this.objectOrder] });
1341
+ this.orderIndex.set(this.objectOrder[i], i);
1342
+ this.orderIndex.set(this.objectOrder[i - 1], i - 1);
1343
+ this.emit({ orderChanged: [this.objectOrder[i - 1], this.objectOrder[i]] });
1245
1344
  }
1246
1345
  bringToFront(id) {
1247
1346
  const i = this.orderIndex.get(id);
1248
1347
  if (i === void 0 || i >= this.objectOrder.length - 1) return;
1249
1348
  this.objectOrder.splice(i, 1);
1250
1349
  this.objectOrder.push(id);
1251
- this.reindexOrder();
1252
- this.emit({ orderChanged: [...this.objectOrder] });
1350
+ for (let k = i; k < this.objectOrder.length; k++) this.orderIndex.set(this.objectOrder[k], k);
1351
+ this.emit({ orderChanged: this.objectOrder.slice(i) });
1253
1352
  }
1254
1353
  sendToBack(id) {
1255
1354
  const i = this.orderIndex.get(id);
1256
1355
  if (i === void 0 || i <= 0) return;
1257
1356
  this.objectOrder.splice(i, 1);
1258
1357
  this.objectOrder.unshift(id);
1259
- this.reindexOrder();
1260
- this.emit({ orderChanged: [...this.objectOrder] });
1358
+ for (let k = 0; k <= i; k++) this.orderIndex.set(this.objectOrder[k], k);
1359
+ this.emit({ orderChanged: this.objectOrder.slice(0, i + 1) });
1261
1360
  }
1262
1361
  /** Reorders `id` relative to its current neighbors. A no-op for an id that doesn't participate in paint order (see `orderRank`). */
1263
1362
  reorder(id, direction) {
@@ -1347,6 +1446,18 @@ var BoardDocument = class {
1347
1446
  isLocked(id) {
1348
1447
  return !!this.strokes.get(id)?.locked || !!this.notes.get(id)?.locked || !!this.texts.get(id)?.locked || !!this.tables.get(id)?.locked || !!this.images.get(id)?.locked || !!this.timers.get(id)?.locked || !!this.rectangles.get(id)?.locked || !!this.ellipses.get(id)?.locked || !!this.groups.get(id)?.locked || !!this.lines.get(id)?.locked || !!this.arrows.get(id)?.locked || !!this.polygons.get(id)?.locked || !!this.stars.get(id)?.locked || !!this.hearts.get(id)?.locked;
1349
1448
  }
1449
+ /**
1450
+ * True if `id` exists and is hidden, for any type — the same per-type
1451
+ * probe pattern as {@link isLocked} (Phase 8). Custom objects are
1452
+ * excluded for the same reason `isLocked` excludes them: they have no
1453
+ * `Hideable` field at all, so "hidden" isn't a concept that applies to
1454
+ * them yet. Used by marquee selection (`selectTool.ts`) to keep a hidden
1455
+ * object out of a rubber-band selection even for object types whose own
1456
+ * renderer doesn't yet suppress click-based hit-testing.
1457
+ */
1458
+ isHidden(id) {
1459
+ return !!this.strokes.get(id)?.hidden || !!this.notes.get(id)?.hidden || !!this.texts.get(id)?.hidden || !!this.tables.get(id)?.hidden || !!this.images.get(id)?.hidden || !!this.timers.get(id)?.hidden || !!this.rectangles.get(id)?.hidden || !!this.ellipses.get(id)?.hidden || !!this.groups.get(id)?.hidden || !!this.lines.get(id)?.hidden || !!this.arrows.get(id)?.hidden || !!this.polygons.get(id)?.hidden || !!this.stars.get(id)?.hidden || !!this.hearts.get(id)?.hidden;
1460
+ }
1350
1461
  subscribe(listener) {
1351
1462
  this.listeners.add(listener);
1352
1463
  return () => this.listeners.delete(listener);
@@ -1501,8 +1612,9 @@ var BoardDocument = class {
1501
1612
  return this.rectangles.values();
1502
1613
  }
1503
1614
  addRectangles(rectangles) {
1504
- for (const rect of rectangles) this.rectangles.set(rect.id, rect);
1505
- this.emit({ rectanglesAdded: rectangles });
1615
+ const normalized = rectangles.map(normalizeBoxGeometry);
1616
+ for (const rect of normalized) this.rectangles.set(rect.id, rect);
1617
+ this.emit({ rectanglesAdded: normalized });
1506
1618
  }
1507
1619
  removeRectangles(ids) {
1508
1620
  const rectanglesRemoved = [];
@@ -1511,8 +1623,9 @@ var BoardDocument = class {
1511
1623
  }
1512
1624
  /** Replace a rectangle's contents (move, resize, restyle) under the same id. */
1513
1625
  setRectangle(rect) {
1514
- this.rectangles.set(rect.id, rect);
1515
- this.emit({ rectanglesUpdated: [rect] });
1626
+ const normalized = normalizeBoxGeometry(rect);
1627
+ this.rectangles.set(normalized.id, normalized);
1628
+ this.emit({ rectanglesUpdated: [normalized] });
1516
1629
  }
1517
1630
  getEllipse(id) {
1518
1631
  return this.ellipses.get(id);
@@ -1521,8 +1634,9 @@ var BoardDocument = class {
1521
1634
  return this.ellipses.values();
1522
1635
  }
1523
1636
  addEllipses(ellipses) {
1524
- for (const ellipse of ellipses) this.ellipses.set(ellipse.id, ellipse);
1525
- this.emit({ ellipsesAdded: ellipses });
1637
+ const normalized = ellipses.map(normalizeBoxGeometry);
1638
+ for (const ellipse of normalized) this.ellipses.set(ellipse.id, ellipse);
1639
+ this.emit({ ellipsesAdded: normalized });
1526
1640
  }
1527
1641
  removeEllipses(ids) {
1528
1642
  const ellipsesRemoved = [];
@@ -1531,8 +1645,9 @@ var BoardDocument = class {
1531
1645
  }
1532
1646
  /** Replace an ellipse's contents (move, resize, restyle) under the same id. */
1533
1647
  setEllipse(ellipse) {
1534
- this.ellipses.set(ellipse.id, ellipse);
1535
- this.emit({ ellipsesUpdated: [ellipse] });
1648
+ const normalized = normalizeBoxGeometry(ellipse);
1649
+ this.ellipses.set(normalized.id, normalized);
1650
+ this.emit({ ellipsesUpdated: [normalized] });
1536
1651
  }
1537
1652
  getGroup(id) {
1538
1653
  return this.groups.get(id);
@@ -1601,8 +1716,9 @@ var BoardDocument = class {
1601
1716
  return this.polygons.values();
1602
1717
  }
1603
1718
  addPolygons(polygons) {
1604
- for (const polygon of polygons) this.polygons.set(polygon.id, polygon);
1605
- this.emit({ polygonsAdded: polygons });
1719
+ const normalized = polygons.map(normalizeBoxGeometry);
1720
+ for (const polygon of normalized) this.polygons.set(polygon.id, polygon);
1721
+ this.emit({ polygonsAdded: normalized });
1606
1722
  }
1607
1723
  removePolygons(ids) {
1608
1724
  const polygonsRemoved = [];
@@ -1611,8 +1727,9 @@ var BoardDocument = class {
1611
1727
  }
1612
1728
  /** Replace a polygon's contents (move, resize, rotate, restyle) under the same id. */
1613
1729
  setPolygon(polygon) {
1614
- this.polygons.set(polygon.id, polygon);
1615
- this.emit({ polygonsUpdated: [polygon] });
1730
+ const normalized = normalizeBoxGeometry(polygon);
1731
+ this.polygons.set(normalized.id, normalized);
1732
+ this.emit({ polygonsUpdated: [normalized] });
1616
1733
  }
1617
1734
  getStar(id) {
1618
1735
  return this.stars.get(id);
@@ -1621,8 +1738,9 @@ var BoardDocument = class {
1621
1738
  return this.stars.values();
1622
1739
  }
1623
1740
  addStars(stars) {
1624
- for (const star of stars) this.stars.set(star.id, star);
1625
- this.emit({ starsAdded: stars });
1741
+ const normalized = stars.map(normalizeBoxGeometry);
1742
+ for (const star of normalized) this.stars.set(star.id, star);
1743
+ this.emit({ starsAdded: normalized });
1626
1744
  }
1627
1745
  removeStars(ids) {
1628
1746
  const starsRemoved = [];
@@ -1631,8 +1749,9 @@ var BoardDocument = class {
1631
1749
  }
1632
1750
  /** Replace a star's contents (move, resize, rotate, restyle) under the same id. */
1633
1751
  setStar(star) {
1634
- this.stars.set(star.id, star);
1635
- this.emit({ starsUpdated: [star] });
1752
+ const normalized = normalizeBoxGeometry(star);
1753
+ this.stars.set(normalized.id, normalized);
1754
+ this.emit({ starsUpdated: [normalized] });
1636
1755
  }
1637
1756
  getHeart(id) {
1638
1757
  return this.hearts.get(id);
@@ -1641,8 +1760,9 @@ var BoardDocument = class {
1641
1760
  return this.hearts.values();
1642
1761
  }
1643
1762
  addHearts(hearts) {
1644
- for (const heart of hearts) this.hearts.set(heart.id, heart);
1645
- this.emit({ heartsAdded: hearts });
1763
+ const normalized = hearts.map(normalizeBoxGeometry);
1764
+ for (const heart of normalized) this.hearts.set(heart.id, heart);
1765
+ this.emit({ heartsAdded: normalized });
1646
1766
  }
1647
1767
  removeHearts(ids) {
1648
1768
  const heartsRemoved = [];
@@ -1651,8 +1771,9 @@ var BoardDocument = class {
1651
1771
  }
1652
1772
  /** Replace a heart's contents (move, resize, rotate, restyle) under the same id. */
1653
1773
  setHeart(heart) {
1654
- this.hearts.set(heart.id, heart);
1655
- this.emit({ heartsUpdated: [heart] });
1774
+ const normalized = normalizeBoxGeometry(heart);
1775
+ this.hearts.set(normalized.id, normalized);
1776
+ this.emit({ heartsUpdated: [normalized] });
1656
1777
  }
1657
1778
  setStrokeLocked(id, locked, by) {
1658
1779
  const stroke = this.strokes.get(id);
@@ -2117,7 +2238,17 @@ var BoardDocument = class {
2117
2238
  static deserializeTables(data) {
2118
2239
  return (data.tables ?? []).map(cloneTable);
2119
2240
  }
2120
- static deserializeStrokes(data) {
2241
+ /**
2242
+ * `onSkip` (Phase 9) replaces an unconditional `console.error` — `core`
2243
+ * must never do raw console I/O (no dev-gate, no way for a Host to
2244
+ * suppress or redirect it), so a skipped stroke is now reported only if
2245
+ * the caller asks for it, via whatever diagnostic channel it already
2246
+ * has (e.g. `controller-internal.ts` routes this into the same typed
2247
+ * `"error"` event every other diagnostic already uses). Silent by
2248
+ * default, matching how every other `deserialize*` method here already
2249
+ * behaves (no diagnostics at all).
2250
+ */
2251
+ static deserializeStrokes(data, onSkip) {
2121
2252
  const strokes = [];
2122
2253
  for (const s of data.strokes) try {
2123
2254
  strokes.push({
@@ -2131,7 +2262,7 @@ var BoardDocument = class {
2131
2262
  ...serializeLock(s)
2132
2263
  });
2133
2264
  } catch (err) {
2134
- console.error(`Skipping malformed stroke ${s?.id ?? "(no id)"}:`, err);
2265
+ onSkip?.(s?.id ?? "(no id)", err);
2135
2266
  }
2136
2267
  return strokes;
2137
2268
  }
@@ -2145,46 +2276,51 @@ var BoardDocument = class {
2145
2276
  * ids already tracked (idempotent by construction: `orderIndex.has` gates
2146
2277
  * every append).
2147
2278
  *
2148
- * `orderChanged` is populated here whenever `objectOrder` actually
2149
- * changed — not just for an explicit reorder, but for any add/remove too.
2150
- * A pure append never shifts an existing id's rank (new ids land at the
2151
- * tail), but a removal splices a middle id out, which *does* shift every
2152
- * id after it down by one — a renderer that only resynced Z on an
2153
- * explicit reorder would silently render those with a stale rank until
2154
- * something else happened to touch them, eventually colliding with a
2155
- * freshly-added object's freshly-computed Z. Firing this on every
2156
- * order-touching change (not just removes) is simpler than special-
2157
- * casing which kind of change actually needs it, at the cost of a
2158
- * redundant same-value resync on a pure append.
2279
+ * `orderChanged` (Phase 9) reports exactly the ids whose rank actually
2280
+ * changed, using `orderIndex` throughout instead of `indexOf` — a pure
2281
+ * append never shifts any existing id's rank (new ids land at the tail,
2282
+ * already covered by this same change's own `Added` field, so
2283
+ * `orderChanged` stays unset), while a removal shifts every id at-or-
2284
+ * after the lowest removed rank down by one, computed in a single O(n)
2285
+ * filter pass (not one `indexOf`+`splice` per removed id) regardless of
2286
+ * how many ids this one change removes. Every renderer's `onChange` now
2287
+ * looks up only the ids actually in `orderChanged` instead of walking
2288
+ * its entire mesh map on any order-touching change — a broad, unfiltered
2289
+ * `orderChanged` here would silently defeat that fix, not just waste
2290
+ * cycles here.
2159
2291
  */
2160
2292
  emit(change) {
2161
- let orderTouched = false;
2293
+ const newIds = [];
2162
2294
  for (const field of ORDERED_ADDED_FIELDS) {
2163
2295
  const objects = change[field];
2164
2296
  if (!objects) continue;
2165
- for (const object of objects) if (!this.orderIndex.has(object.id)) {
2166
- this.objectOrder.push(object.id);
2167
- orderTouched = true;
2168
- }
2297
+ for (const object of objects) if (!this.orderIndex.has(object.id)) newIds.push(object.id);
2169
2298
  }
2299
+ const removedIds = /* @__PURE__ */ new Set();
2170
2300
  for (const field of ORDERED_REMOVED_FIELDS) {
2171
2301
  const ids = change[field];
2172
2302
  if (!ids) continue;
2173
- for (const id of ids) {
2174
- const index = this.objectOrder.indexOf(id);
2175
- if (index !== -1) {
2176
- this.objectOrder.splice(index, 1);
2177
- orderTouched = true;
2178
- }
2179
- }
2303
+ for (const id of ids) if (this.orderIndex.has(id)) removedIds.add(id);
2180
2304
  }
2181
- if (orderTouched) {
2182
- this.reindexOrder();
2183
- change = {
2184
- ...change,
2185
- orderChanged: [...this.objectOrder]
2186
- };
2305
+ let orderChanged;
2306
+ if (removedIds.size > 0) {
2307
+ let minIndex = this.objectOrder.length;
2308
+ for (const id of removedIds) minIndex = Math.min(minIndex, this.orderIndex.get(id));
2309
+ const next = [];
2310
+ for (const id of this.objectOrder) if (!removedIds.has(id)) next.push(id);
2311
+ this.objectOrder = next;
2312
+ for (let k = minIndex; k < this.objectOrder.length; k++) this.orderIndex.set(this.objectOrder[k], k);
2313
+ for (const id of removedIds) this.orderIndex.delete(id);
2314
+ orderChanged = this.objectOrder.slice(minIndex);
2187
2315
  }
2316
+ if (newIds.length > 0) for (const id of newIds) {
2317
+ this.orderIndex.set(id, this.objectOrder.length);
2318
+ this.objectOrder.push(id);
2319
+ }
2320
+ if (orderChanged) change = {
2321
+ ...change,
2322
+ orderChanged
2323
+ };
2188
2324
  const full = {
2189
2325
  ...EMPTY_CHANGE,
2190
2326
  ...change
@@ -3695,7 +3831,7 @@ function documentToSVG(doc, now = 0, registry, backgroundColor = DEFAULT_BACKGRO
3695
3831
  const highlights = doc.strokes.filter((s) => s.tool === "highlighter");
3696
3832
  const ink = doc.strokes.filter((s) => s.tool !== "highlighter");
3697
3833
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${view}">\n<defs>\n${defs}\n</defs>\n${[
3698
- `<rect x="${fmt(bounds.minX)}" y="${fmt(bounds.minY)}" width="${fmt(bounds.width)}" height="${fmt(bounds.height)}" fill="${backgroundColor}"/>`,
3834
+ `<rect x="${fmt(bounds.minX)}" y="${fmt(bounds.minY)}" width="${fmt(bounds.width)}" height="${fmt(bounds.height)}" fill="${escapeXML(backgroundColor)}"/>`,
3699
3835
  ...images.map((image) => imageToElement(image, resolvedAssets)),
3700
3836
  ...tables.map(tableToGroup),
3701
3837
  ...rectangles.map(rectangleToElement),
@@ -3723,7 +3859,7 @@ function customObjectToGroup(object, registry, resolvedAssets) {
3723
3859
  const definition = registry?.objectType(object.type);
3724
3860
  if (definition) try {
3725
3861
  const scene = definition.describe(object, { selected: false });
3726
- return `<g data-id="${object.id}">${sceneNodeToSVG(scene, object.transform, resolvedAssets)}</g>`;
3862
+ return `<g data-id="${escapeXML(object.id)}">${sceneNodeToSVG(scene, object.transform, resolvedAssets)}</g>`;
3727
3863
  } catch {}
3728
3864
  return fallbackToGroup(object);
3729
3865
  }
@@ -3747,17 +3883,17 @@ function sceneNodeToSVG(node, parent, resolvedAssets) {
3747
3883
  case "rect": {
3748
3884
  const svgY = -node.y - node.height;
3749
3885
  const rx = node.cornerRadius ? ` rx="${fmt(node.cornerRadius)}"` : "";
3750
- const stroke = node.stroke ? ` stroke="${node.stroke}" stroke-width="${fmt(node.strokeWidth ?? .1)}"` : "";
3751
- 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}/>`;
3886
+ const stroke = node.stroke ? ` stroke="${escapeXML(node.stroke)}" stroke-width="${fmt(node.strokeWidth ?? .1)}"` : "";
3887
+ return `<rect x="${fmt(node.x)}" y="${fmt(svgY)}" width="${fmt(node.width)}" height="${fmt(node.height)}" fill="${escapeXML(node.fill ?? "none")}"${rx}${stroke}${opacityAttr}${transformAttr}/>`;
3752
3888
  }
3753
3889
  case "text": {
3754
3890
  const anchor = node.align === "center" ? ` text-anchor="middle"` : node.align === "end" ? ` text-anchor="end"` : "";
3755
3891
  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("");
3756
- 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>`;
3892
+ return `<text font-family="Inter, 'Segoe UI', sans-serif" font-size="${fmt(node.fontSize)}" font-weight="500" fill="${escapeXML(node.color)}"${anchor}${opacityAttr}${transformAttr}>${tspans}</text>`;
3757
3893
  }
3758
3894
  case "ellipse": {
3759
- const stroke = node.stroke ? ` stroke="${node.stroke}" stroke-width="${fmt(node.strokeWidth ?? .1)}"` : "";
3760
- 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}/>`;
3895
+ const stroke = node.stroke ? ` stroke="${escapeXML(node.stroke)}" stroke-width="${fmt(node.strokeWidth ?? .1)}"` : "";
3896
+ return `<ellipse cx="${fmt(node.cx)}" cy="${fmt(-node.cy)}" rx="${fmt(node.rx)}" ry="${fmt(node.ry)}" fill="${escapeXML(node.fill ?? "none")}"${stroke}${opacityAttr}${transformAttr}/>`;
3761
3897
  }
3762
3898
  case "image": {
3763
3899
  const svgY = -node.y - node.height;
@@ -3788,7 +3924,7 @@ function fallbackToGroup(object) {
3788
3924
  const label = object.fallback.label;
3789
3925
  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>` : "";
3790
3926
  return [
3791
- `<g data-id="${object.id}" data-fallback="true"${svgTransformAttr(object.transform)}>`,
3927
+ `<g data-id="${escapeXML(object.id)}" data-fallback="true"${svgTransformAttr(object.transform)}>`,
3792
3928
  `<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"/>`,
3793
3929
  labelEl,
3794
3930
  `</g>`
@@ -3812,7 +3948,7 @@ function strokeToPaths(stroke) {
3812
3948
  const d = `M ${left.join(" L ")} L ${right.join(" L ")} Z`;
3813
3949
  const opacity = run.opacity * baseOpacity;
3814
3950
  const opacityAttr = opacity < 1 ? ` fill-opacity="${fmt(opacity)}"` : "";
3815
- paths.push(`<path data-id="${stroke.id}" d="${d}" fill="${stroke.color}"${opacityAttr}${transform}/>`);
3951
+ paths.push(`<path data-id="${escapeXML(stroke.id)}" d="${d}" fill="${escapeXML(stroke.color)}"${opacityAttr}${transform}/>`);
3816
3952
  }
3817
3953
  return paths;
3818
3954
  }
@@ -3834,14 +3970,14 @@ function splitByOpacity(edges) {
3834
3970
  function textToElement(block) {
3835
3971
  const top = -block.y;
3836
3972
  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("");
3837
- return `<text font-family="Inter, 'Segoe UI', sans-serif" font-size="${fmt(block.fontSize)}" font-weight="500" fill="${block.color}">${tspans}</text>`;
3973
+ return `<text font-family="Inter, 'Segoe UI', sans-serif" font-size="${fmt(block.fontSize)}" font-weight="500" fill="${escapeXML(block.color)}">${tspans}</text>`;
3838
3974
  }
3839
3975
  function noteShadowFilter(note) {
3840
3976
  const dx = -LIGHT.x / LIGHT.z * note.zOffset;
3841
3977
  const dy = LIGHT.y / LIGHT.z * note.zOffset;
3842
3978
  const blur = (.35 + note.zOffset * .55) / 2;
3843
3979
  const opacity = Math.max(.05, .26 - note.zOffset * .028);
3844
- 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>`;
3980
+ return `<filter id="sh-${escapeXML(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>`;
3845
3981
  }
3846
3982
  function noteToGroup(note) {
3847
3983
  const left = note.x - note.size / 2;
@@ -3851,7 +3987,7 @@ function noteToGroup(note) {
3851
3987
  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("");
3852
3988
  return [
3853
3989
  `<g>`,
3854
- `<rect x="${fmt(left)}" y="${fmt(top)}" width="${fmt(note.size)}" height="${fmt(note.size)}" fill="${note.color}" filter="url(#sh-${note.id})"/>`,
3990
+ `<rect x="${fmt(left)}" y="${fmt(top)}" width="${fmt(note.size)}" height="${fmt(note.size)}" fill="${escapeXML(note.color)}" filter="url(#sh-${escapeXML(note.id)})"/>`,
3855
3991
  `<text font-family="ui-rounded, 'Segoe UI', sans-serif" font-size="${fmt(fontSize)}" font-weight="500" fill="#37352F">${tspans}</text>`,
3856
3992
  `</g>`
3857
3993
  ].join("");
@@ -3882,8 +4018,8 @@ function tableToGroup(table) {
3882
4018
  const left = table.x;
3883
4019
  const top = -table.y;
3884
4020
  const headerH = table.rowHeights[0] ?? height / table.rows;
3885
- const borderColor = table.color || "#D1D5DB";
3886
- const bgColor = table.backgroundColor || "#FFFFFF";
4021
+ const borderColor = escapeXML(table.color || "#D1D5DB");
4022
+ const bgColor = escapeXML(table.backgroundColor || "#FFFFFF");
3887
4023
  const elements = [];
3888
4024
  elements.push(`<rect x="${fmt(left)}" y="${fmt(top)}" width="${fmt(width)}" height="${fmt(height)}" fill="${bgColor}" rx="0.5"/>`);
3889
4025
  elements.push(`<rect x="${fmt(left)}" y="${fmt(top)}" width="${fmt(width)}" height="${fmt(headerH)}" fill="#F3F4F6" rx="0.5"/>`);
@@ -3911,14 +4047,14 @@ function tableToGroup(table) {
3911
4047
  const padY = Math.min(.3, rowH * .12);
3912
4048
  const fontSize = Math.min(1.25, rowH * .45);
3913
4049
  const weight = r === 0 ? "bold" : "500";
3914
- const color = r === 0 ? "#111827" : table.color ?? "#374151";
4050
+ const color = escapeXML(r === 0 ? "#111827" : table.color ?? "#374151");
3915
4051
  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>`);
3916
4052
  }
3917
4053
  cellLeft += colW;
3918
4054
  }
3919
4055
  cellTop += rowH;
3920
4056
  }
3921
- return `<g data-id="${table.id}">\n${elements.join("\n")}\n</g>`;
4057
+ return `<g data-id="${escapeXML(table.id)}">\n${elements.join("\n")}\n</g>`;
3922
4058
  }
3923
4059
  function timerToGroup(timer, now) {
3924
4060
  const r = timer.size / 2;
@@ -3926,7 +4062,7 @@ function timerToGroup(timer, now) {
3926
4062
  const cy = -timer.y;
3927
4063
  const label = formatTimer(timerRemaining(timer, now));
3928
4064
  return [
3929
- `<g data-id="${timer.id}">`,
4065
+ `<g data-id="${escapeXML(timer.id)}">`,
3930
4066
  `<circle cx="${fmt(cx + .4)}" cy="${fmt(cy + .4)}" r="${fmt(r)}" fill="#000" fill-opacity="0.12"/>`,
3931
4067
  `<circle cx="${fmt(cx)}" cy="${fmt(cy)}" r="${fmt(r)}" fill="#E11D48"/>`,
3932
4068
  `<circle cx="${fmt(cx)}" cy="${fmt(cy)}" r="${fmt(r * .72)}" fill="#FAFAF9"/>`,
@@ -3939,8 +4075,8 @@ function imageToElement(image, resolvedAssets) {
3939
4075
  const top = -image.y - image.height / 2;
3940
4076
  if (image.ref) {
3941
4077
  const dataUri = resolvedAssets?.get(image.ref);
3942
- if (!dataUri) return `<g data-id="${image.id}" data-fallback="true">${imagePlaceholderElement(left, top, image.width, image.height, image.name)}</g>`;
3943
- 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"/>`;
4078
+ if (!dataUri) return `<g data-id="${escapeXML(image.id)}" data-fallback="true">${imagePlaceholderElement(left, top, image.width, image.height, image.name)}</g>`;
4079
+ return `<image data-id="${escapeXML(image.id)}" href="${escapeXML(dataUri)}" x="${fmt(left)}" y="${fmt(top)}" width="${fmt(image.width)}" height="${fmt(image.height)}" preserveAspectRatio="none"/>`;
3944
4080
  }
3945
4081
  return `<image href="${escapeXML(image.src)}" x="${fmt(left)}" y="${fmt(top)}" width="${fmt(image.width)}" height="${fmt(image.height)}" preserveAspectRatio="none"/>`;
3946
4082
  }
@@ -3962,19 +4098,19 @@ function opacityAttrOf(opacity) {
3962
4098
  }
3963
4099
  function rectangleToElement(rect) {
3964
4100
  const svgY = -rect.y;
3965
- const stroke = rect.stroke ? ` stroke="${rect.stroke}" stroke-width="${fmt(rect.strokeWidth ?? .12)}"` : "";
4101
+ const stroke = rect.stroke ? ` stroke="${escapeXML(rect.stroke)}" stroke-width="${fmt(rect.strokeWidth ?? .12)}"` : "";
3966
4102
  const rx = rect.cornerRadius ? ` rx="${fmt(rect.cornerRadius)}"` : "";
3967
4103
  const transform = shapeRotationAttr(rect.x, rect.y, rect.width, rect.height, rect.rotation);
3968
4104
  const opacity = opacityAttrOf(rect.opacity);
3969
- return `<rect data-id="${rect.id}" x="${fmt(rect.x)}" y="${fmt(svgY)}" width="${fmt(rect.width)}" height="${fmt(rect.height)}" fill="${rect.fill ?? "none"}"${rx}${stroke}${opacity}${transform}/>`;
4105
+ return `<rect data-id="${escapeXML(rect.id)}" x="${fmt(rect.x)}" y="${fmt(svgY)}" width="${fmt(rect.width)}" height="${fmt(rect.height)}" fill="${escapeXML(rect.fill ?? "none")}"${rx}${stroke}${opacity}${transform}/>`;
3970
4106
  }
3971
4107
  function ellipseToElement(ellipse) {
3972
4108
  const cx = ellipse.x + ellipse.width / 2;
3973
4109
  const cy = -ellipse.y + ellipse.height / 2;
3974
- const stroke = ellipse.stroke ? ` stroke="${ellipse.stroke}" stroke-width="${fmt(ellipse.strokeWidth ?? .12)}"` : "";
4110
+ const stroke = ellipse.stroke ? ` stroke="${escapeXML(ellipse.stroke)}" stroke-width="${fmt(ellipse.strokeWidth ?? .12)}"` : "";
3975
4111
  const transform = shapeRotationAttr(ellipse.x, ellipse.y, ellipse.width, ellipse.height, ellipse.rotation);
3976
4112
  const opacity = opacityAttrOf(ellipse.opacity);
3977
- return `<ellipse data-id="${ellipse.id}" cx="${fmt(cx)}" cy="${fmt(cy)}" rx="${fmt(ellipse.width / 2)}" ry="${fmt(ellipse.height / 2)}" fill="${ellipse.fill ?? "none"}"${stroke}${opacity}${transform}/>`;
4113
+ return `<ellipse data-id="${escapeXML(ellipse.id)}" cx="${fmt(cx)}" cy="${fmt(cy)}" rx="${fmt(ellipse.width / 2)}" ry="${fmt(ellipse.height / 2)}" fill="${escapeXML(ellipse.fill ?? "none")}"${stroke}${opacity}${transform}/>`;
3978
4114
  }
3979
4115
  /**
3980
4116
  * A regular N-gon inscribed in the same top-edge/Y-up bounding box
@@ -3987,46 +4123,46 @@ function polygonToElement(polygon) {
3987
4123
  const cx = polygon.x + polygon.width / 2;
3988
4124
  const cy = polygon.y - polygon.height / 2;
3989
4125
  const svgPoints = regularPolygonPoints(polygon.sides, polygon.width, polygon.height, polygonStartAngle(polygon.sides)).map((p) => `${fmt(cx + p.x)},${fmt(-(cy + p.y))}`).join(" ");
3990
- const stroke = polygon.stroke ? ` stroke="${polygon.stroke}" stroke-width="${fmt(polygon.strokeWidth ?? .12)}"` : "";
4126
+ const stroke = polygon.stroke ? ` stroke="${escapeXML(polygon.stroke)}" stroke-width="${fmt(polygon.strokeWidth ?? SHAPE_DEFAULT_STROKE_WIDTH)}"` : "";
3991
4127
  const transform = shapeRotationAttr(polygon.x, polygon.y, polygon.width, polygon.height, polygon.rotation);
3992
4128
  const opacity = opacityAttrOf(polygon.opacity);
3993
- return `<polygon data-id="${polygon.id}" points="${svgPoints}" fill="${polygon.fill ?? "none"}"${stroke}${opacity}${transform}/>`;
4129
+ return `<polygon data-id="${escapeXML(polygon.id)}" points="${svgPoints}" fill="${escapeXML(polygon.fill ?? "none")}"${stroke}${opacity}${transform}/>`;
3994
4130
  }
3995
4131
  /** Same convention/pattern as `polygonToElement` — a `points`-vertex star with `innerRadiusRatio` (`polygonGeometry.ts`'s `starPoints`). */
3996
4132
  function starToElement(star) {
3997
4133
  const cx = star.x + star.width / 2;
3998
4134
  const cy = star.y - star.height / 2;
3999
4135
  const svgPoints = starPoints(star.points, star.innerRadiusRatio, star.width, star.height).map((p) => `${fmt(cx + p.x)},${fmt(-(cy + p.y))}`).join(" ");
4000
- const stroke = star.stroke ? ` stroke="${star.stroke}" stroke-width="${fmt(star.strokeWidth ?? .12)}"` : "";
4136
+ const stroke = star.stroke ? ` stroke="${escapeXML(star.stroke)}" stroke-width="${fmt(star.strokeWidth ?? SHAPE_DEFAULT_STROKE_WIDTH)}"` : "";
4001
4137
  const transform = shapeRotationAttr(star.x, star.y, star.width, star.height, star.rotation);
4002
4138
  const opacity = opacityAttrOf(star.opacity);
4003
- return `<polygon data-id="${star.id}" points="${svgPoints}" fill="${star.fill ?? "none"}"${stroke}${opacity}${transform}/>`;
4139
+ return `<polygon data-id="${escapeXML(star.id)}" points="${svgPoints}" fill="${escapeXML(star.fill ?? "none")}"${stroke}${opacity}${transform}/>`;
4004
4140
  }
4005
4141
  /** Same convention/pattern as `polygonToElement` — the standard parametric heart curve (`polygonGeometry.ts`'s `heartPoints`), emitted as a closed `<polygon>` (dense enough at `HEART_SEGMENTS` to read as a smooth curve). */
4006
4142
  function heartToElement(heart) {
4007
4143
  const cx = heart.x + heart.width / 2;
4008
4144
  const cy = heart.y - heart.height / 2;
4009
4145
  const svgPoints = heartPoints(heart.width, heart.height).map((p) => `${fmt(cx + p.x)},${fmt(-(cy + p.y))}`).join(" ");
4010
- const stroke = heart.stroke ? ` stroke="${heart.stroke}" stroke-width="${fmt(heart.strokeWidth ?? .12)}"` : "";
4146
+ const stroke = heart.stroke ? ` stroke="${escapeXML(heart.stroke)}" stroke-width="${fmt(heart.strokeWidth ?? SHAPE_DEFAULT_STROKE_WIDTH)}"` : "";
4011
4147
  const transform = shapeRotationAttr(heart.x, heart.y, heart.width, heart.height, heart.rotation);
4012
4148
  const opacity = opacityAttrOf(heart.opacity);
4013
- return `<polygon data-id="${heart.id}" points="${svgPoints}" fill="${heart.fill ?? "none"}"${stroke}${opacity}${transform}/>`;
4149
+ return `<polygon data-id="${escapeXML(heart.id)}" points="${svgPoints}" fill="${escapeXML(heart.fill ?? "none")}"${stroke}${opacity}${transform}/>`;
4014
4150
  }
4015
4151
  /** No rotation/box concept (see shapeObjects.ts's header) — the two endpoints are emitted directly, y-flipped like every other coordinate here. */
4016
4152
  function lineToElement(line) {
4017
- const stroke = line.stroke ?? "#1C1C1E";
4018
- const width = line.strokeWidth ?? .12;
4153
+ const stroke = escapeXML(line.stroke ?? SHAPE_DEFAULT_STROKE);
4154
+ const width = line.strokeWidth ?? SHAPE_DEFAULT_STROKE_WIDTH;
4019
4155
  const opacity = opacityAttrOf(line.opacity);
4020
- return `<line data-id="${line.id}" x1="${fmt(line.start.x)}" y1="${fmt(-line.start.y)}" x2="${fmt(line.end.x)}" y2="${fmt(-line.end.y)}" stroke="${stroke}" stroke-width="${fmt(width)}"${opacity}/>`;
4156
+ return `<line data-id="${escapeXML(line.id)}" x1="${fmt(line.start.x)}" y1="${fmt(-line.start.y)}" x2="${fmt(line.end.x)}" y2="${fmt(-line.end.y)}" stroke="${stroke}" stroke-width="${fmt(width)}"${opacity}/>`;
4021
4157
  }
4022
4158
  /** Shaft plus head barbs — same `arrowHeadBarbs` the canvas renderer uses, so the two never drift. */
4023
4159
  function arrowToGroup(arrow) {
4024
- const stroke = arrow.stroke ?? "#1C1C1E";
4025
- const width = arrow.strokeWidth ?? .12;
4160
+ const stroke = escapeXML(arrow.stroke ?? SHAPE_DEFAULT_STROKE);
4161
+ const width = arrow.strokeWidth ?? SHAPE_DEFAULT_STROKE_WIDTH;
4026
4162
  const opacity = opacityAttrOf(arrow.opacity);
4027
4163
  const segment = (a, b) => `<line x1="${fmt(a.x)}" y1="${fmt(-a.y)}" x2="${fmt(b.x)}" y2="${fmt(-b.y)}" stroke="${stroke}" stroke-width="${fmt(width)}"/>`;
4028
4164
  const segments = [segment(arrow.start, arrow.end), ...arrowHeadBarbs(arrow.start, arrow.end, arrow.head).map(([a, b]) => segment(a, b))];
4029
- return `<g data-id="${arrow.id}"${opacity}>${segments.join("")}</g>`;
4165
+ return `<g data-id="${escapeXML(arrow.id)}"${opacity}>${segments.join("")}</g>`;
4030
4166
  }
4031
4167
  function contentBounds(strokes, notes, texts, tables = [], images = [], timers = [], customObjects = [], rectangles = [], ellipses = [], lines = [], arrows = [], polygons = [], stars = [], hearts = []) {
4032
4168
  let minX = Infinity;
@@ -4142,4 +4278,4 @@ function fmt(n) {
4142
4278
  var SDK_PACKAGE_NAME = "@scrawl-board/board";
4143
4279
  var SDK_DEVELOPMENT_VERSION = "0.0.0-development";
4144
4280
  //#endregion
4145
- export { ASSET_CACHE_BYTES_DEFAULT, ASSET_CACHE_BYTES_MAX, ASSET_CACHE_BYTES_MIN, ASSET_EXPORT_MAX_DECODED_MEGAPIXELS, ASSET_EXPORT_MAX_ENCODED_BYTES, ASSET_MAX_CONCURRENT_RESOLUTIONS, ASSET_MAX_DECODED_MEGAPIXELS, ASSET_MAX_DIMENSION_PX, ASSET_MAX_ENCODED_BYTES, ASSET_REF_MAX_BYTES, ASSET_REF_PATTERN, AddArrowCommand, AddEllipseCommand, AddGroupCommand, AddHeartCommand, AddImageCommand, AddLineCommand, AddNoteCommand, AddPolygonCommand, AddRectangleCommand, AddStarCommand, AddStrokesCommand, AddTableCommand, AddTextCommand, AddTimerCommand, AssetResolutionError, BEACON_INSET, BoardDocument, CURRENT_DOCUMENT_SCHEMA_VERSION, ClusterStore, CommandBatch, DeleteArrowCommand, DeleteEllipseCommand, DeleteGroupCommand, DeleteHeartCommand, DeleteImageCommand, DeleteLineCommand, DeleteNoteCommand, DeletePolygonCommand, DeleteRectangleCommand, DeleteStarCommand, 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, ReorderObjectCommand, SDK_DEVELOPMENT_VERSION, SDK_PACKAGE_NAME, SHAPE_DEFAULT_STROKE, SHAPE_DEFAULT_STROKE_WIDTH, SHAPE_MIN_SIZE, STAMPS, STAMP_SIZE, SUPPORTED_ASSET_MEDIA_TYPES, 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, TransformObjectsCommand, UpdateArrowCommand, UpdateEllipseCommand, UpdateGroupCommand, UpdateHeartCommand, UpdateImageCommand, UpdateLineCommand, UpdateNoteCommand, UpdatePolygonCommand, UpdateRectangleCommand, UpdateStarCommand, UpdateTableCommand, UpdateTextCommand, UpdateTimerCommand, apply, applyItemLock, assetRef, avgScale, canUnlockItem, changeToOps, clampAssetCacheBytes, cloneArrow, cloneCustomObject, cloneEllipse, cloneGroup, cloneHeart, cloneImage, cloneLine, cloneNote, clonePolygon, cloneRectangle, cloneStar, cloneStroke, cloneTable, cloneText, cloneTimer, documentId, documentToSVG, formatTimer, invert, isAssetRef, isIdentity, isStampKind, loadDocumentBytes, measureTable, measureTextBlock, migrateDocument, mul, pauseTimer, placePresenceBeacon, ribbonEdges, rotationAbout, scalingAbout, searchBoard, serializeDocument, serializeLock, serializeStroke, setTimerDuration, stampDataUrl, startTimer, strokeId, timerExpired, timerRemaining, toggleTimer, translation };
4281
+ export { ASSET_CACHE_BYTES_DEFAULT, ASSET_CACHE_BYTES_MAX, ASSET_CACHE_BYTES_MIN, ASSET_EXPORT_MAX_DECODED_MEGAPIXELS, ASSET_EXPORT_MAX_ENCODED_BYTES, ASSET_MAX_CONCURRENT_RESOLUTIONS, ASSET_MAX_DECODED_MEGAPIXELS, ASSET_MAX_DIMENSION_PX, ASSET_MAX_ENCODED_BYTES, ASSET_REF_MAX_BYTES, ASSET_REF_PATTERN, AddArrowCommand, AddEllipseCommand, AddGroupCommand, AddHeartCommand, AddImageCommand, AddLineCommand, AddNoteCommand, AddPolygonCommand, AddRectangleCommand, AddStarCommand, AddStrokesCommand, AddTableCommand, AddTextCommand, AddTimerCommand, AssetResolutionError, BEACON_INSET, BoardDocument, CURRENT_DOCUMENT_SCHEMA_VERSION, ClusterStore, CommandBatch, DeleteArrowCommand, DeleteEllipseCommand, DeleteGroupCommand, DeleteHeartCommand, DeleteImageCommand, DeleteLineCommand, DeleteNoteCommand, DeletePolygonCommand, DeleteRectangleCommand, DeleteStarCommand, 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, ReorderObjectCommand, SDK_DEVELOPMENT_VERSION, SDK_PACKAGE_NAME, SHAPE_DEFAULT_STROKE, SHAPE_DEFAULT_STROKE_WIDTH, SHAPE_MIN_SIZE, SHAPE_STYLE_DEFAULTS, STAMPS, STAMP_SIZE, SUPPORTED_ASSET_MEDIA_TYPES, 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, TransformObjectsCommand, UpdateArrowCommand, UpdateEllipseCommand, UpdateGroupCommand, UpdateHeartCommand, UpdateImageCommand, UpdateLineCommand, UpdateNoteCommand, UpdatePolygonCommand, UpdateRectangleCommand, UpdateStarCommand, UpdateTableCommand, UpdateTextCommand, UpdateTimerCommand, apply, applyItemLock, assetRef, avgScale, canUnlockItem, changeToOps, clampAssetCacheBytes, cloneArrow, cloneCustomObject, cloneEllipse, cloneGroup, cloneHeart, cloneImage, cloneLine, cloneNote, clonePolygon, cloneRectangle, cloneStar, cloneStroke, cloneTable, cloneText, cloneTimer, documentId, documentSize, documentToSVG, formatTimer, invert, isAssetRef, isIdentity, isStampKind, loadDocumentBytes, measureTable, measureTextBlock, migrateDocument, mul, pauseTimer, placePresenceBeacon, ribbonEdges, rotationAbout, scalingAbout, searchBoard, serializeDocument, serializeLock, serializeStroke, setTimerDuration, stampDataUrl, startTimer, strokeId, timerExpired, timerRemaining, toggleTimer, translation };