@scrawl-board/board 0.1.0-beta.6 → 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,7 +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)
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)
93
132
  };
94
133
  if (collections.notes instanceof DocumentRecoveryError) return {
95
134
  ok: false,
@@ -115,6 +154,43 @@ function migrateDocument(raw) {
115
154
  ok: false,
116
155
  error: collections.customObjects
117
156
  };
157
+ if (collections.rectangles instanceof DocumentRecoveryError) return {
158
+ ok: false,
159
+ error: collections.rectangles
160
+ };
161
+ if (collections.ellipses instanceof DocumentRecoveryError) return {
162
+ ok: false,
163
+ error: collections.ellipses
164
+ };
165
+ if (collections.groups instanceof DocumentRecoveryError) return {
166
+ ok: false,
167
+ error: collections.groups
168
+ };
169
+ if (collections.lines instanceof DocumentRecoveryError) return {
170
+ ok: false,
171
+ error: collections.lines
172
+ };
173
+ if (collections.arrows instanceof DocumentRecoveryError) return {
174
+ ok: false,
175
+ error: collections.arrows
176
+ };
177
+ if (collections.polygons instanceof DocumentRecoveryError) return {
178
+ ok: false,
179
+ error: collections.polygons
180
+ };
181
+ if (collections.stars instanceof DocumentRecoveryError) return {
182
+ ok: false,
183
+ error: collections.stars
184
+ };
185
+ if (collections.hearts instanceof DocumentRecoveryError) return {
186
+ ok: false,
187
+ error: collections.hearts
188
+ };
189
+ const objectOrder = validateObjectOrder(raw.objectOrder);
190
+ if (objectOrder instanceof DocumentRecoveryError) return {
191
+ ok: false,
192
+ error: objectOrder
193
+ };
118
194
  return {
119
195
  ok: true,
120
196
  migratedFrom: version,
@@ -126,7 +202,16 @@ function migrateDocument(raw) {
126
202
  tables: collections.tables,
127
203
  images: collections.images,
128
204
  timers: collections.timers,
129
- customObjects: collections.customObjects
205
+ customObjects: collections.customObjects,
206
+ rectangles: collections.rectangles,
207
+ ellipses: collections.ellipses,
208
+ groups: collections.groups,
209
+ lines: collections.lines,
210
+ arrows: collections.arrows,
211
+ polygons: collections.polygons,
212
+ stars: collections.stars,
213
+ hearts: collections.hearts,
214
+ objectOrder
130
215
  }
131
216
  };
132
217
  }
@@ -135,8 +220,19 @@ function serializeDocument(document) {
135
220
  if (!result.ok || result.migratedFrom !== 1) throw result.ok ? new DocumentRecoveryError("DOCUMENT_VALIDATION_FAILED", "Saving requires the current Document schema") : result.error;
136
221
  return JSON.stringify(result.document);
137
222
  }
138
- function validateCollection(raw, name, version, validator, requiredAtCurrentVersion = true) {
139
- 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);
140
236
  const value = raw[name] ?? [];
141
237
  if (!Array.isArray(value)) return validation(`${name} must be an array`, name);
142
238
  for (let index = 0; index < value.length; index += 1) if (!isRecord(value[index]) || !validator(value[index])) return validation(`invalid ${name} entry`, `${name}[${index}]`);
@@ -148,11 +244,20 @@ function validateStrokes(raw) {
148
244
  for (let index = 0; index < raw.length; index += 1) {
149
245
  const value = raw[index];
150
246
  const path = `strokes[${index}]`;
151
- 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, [
152
252
  "marker",
153
253
  "highlighter",
154
254
  "shape"
155
- ]) || !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`);
156
261
  const points = [];
157
262
  for (let pointIndex = 0; pointIndex < value.points.length; pointIndex += 1) {
158
263
  const point = normalizePoint(value.points[pointIndex]);
@@ -178,20 +283,55 @@ function normalizePoint(raw) {
178
283
  return values;
179
284
  }
180
285
  function validateNote(v) {
181
- 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)));
182
287
  }
183
288
  function validateText(v) {
184
- 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);
185
290
  }
186
291
  function validateTable(v) {
187
- 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);
188
293
  }
189
294
  function validateImage(v) {
190
295
  const identifiesAnImage = v.ref !== void 0 ? isAssetRef(v.ref) : nonempty(v.src);
191
- 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);
297
+ }
298
+ function validateShapeStyle(v) {
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);
300
+ }
301
+ function validateRectangle(v) {
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);
303
+ }
304
+ function validateEllipse(v) {
305
+ return basePosition(v) && positive(v.width) && positive(v.height) && validLock(v) && validHidden(v) && validateShapeStyle(v) && (v.rotation === void 0 || finite(v.rotation));
306
+ }
307
+ function validatePolygon(v) {
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);
309
+ }
310
+ function validateStar(v) {
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;
312
+ }
313
+ function validateHeart(v) {
314
+ return basePosition(v) && positive(v.width) && positive(v.height) && validLock(v) && validHidden(v) && validateShapeStyle(v) && (v.rotation === void 0 || finite(v.rotation));
315
+ }
316
+ function validPoint(v) {
317
+ return isRecord(v) && finite(v.x) && finite(v.y);
318
+ }
319
+ function validateLine(v) {
320
+ return nonempty(v.id) && validPoint(v.start) && validPoint(v.end) && validLock(v) && validHidden(v) && validateShapeStyle(v);
321
+ }
322
+ function validateArrow(v) {
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");
324
+ }
325
+ function validateGroup(v) {
326
+ return nonempty(v.id) && Array.isArray(v.children) && v.children.every((c) => nonempty(c)) && validLock(v) && validHidden(v);
327
+ }
328
+ function validateObjectOrder(v) {
329
+ if (v === void 0) return [];
330
+ if (!Array.isArray(v) || !v.every((id) => nonempty(id))) return validation("objectOrder must be an array of ids", "objectOrder");
331
+ return structuredClone(v);
192
332
  }
193
333
  function validateTimer(v) {
194
- 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);
195
335
  }
196
336
  function validateCustomObject(v) {
197
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);
@@ -210,6 +350,9 @@ function basePosition(v) {
210
350
  function validLock(v) {
211
351
  return (v.locked === void 0 || typeof v.locked === "boolean") && optionalString(v.lockedBy) && optionalString(v.lockedByName);
212
352
  }
353
+ function validHidden(v) {
354
+ return v.hidden === void 0 || typeof v.hidden === "boolean";
355
+ }
213
356
  function validMatrix(v) {
214
357
  return v === void 0 || Array.isArray(v) && v.length === 6 && v.every(finite);
215
358
  }
@@ -772,6 +915,265 @@ function serializeLock(item) {
772
915
  };
773
916
  }
774
917
  //#endregion
918
+ //#region src/core/shapes/shapeObjects.ts
919
+ var SHAPE_MIN_SIZE = .5;
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
+ }
965
+ function cloneRectangle(rect) {
966
+ return { ...rect };
967
+ }
968
+ function cloneEllipse(ellipse) {
969
+ return { ...ellipse };
970
+ }
971
+ function clonePolygon(polygon) {
972
+ return { ...polygon };
973
+ }
974
+ function cloneStar(star) {
975
+ return { ...star };
976
+ }
977
+ function cloneHeart(heart) {
978
+ return { ...heart };
979
+ }
980
+ function cloneLine(line) {
981
+ return {
982
+ ...line,
983
+ start: { ...line.start },
984
+ end: { ...line.end }
985
+ };
986
+ }
987
+ function cloneArrow(arrow) {
988
+ return {
989
+ ...arrow,
990
+ start: { ...arrow.start },
991
+ end: { ...arrow.end }
992
+ };
993
+ }
994
+ function cloneGroup(group) {
995
+ return {
996
+ ...group,
997
+ children: [...group.children]
998
+ };
999
+ }
1000
+ //#endregion
1001
+ //#region src/core/document/objectBounds.ts
1002
+ function growAll(points) {
1003
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1004
+ for (const p of points) {
1005
+ if (p.x < minX) minX = p.x;
1006
+ if (p.y < minY) minY = p.y;
1007
+ if (p.x > maxX) maxX = p.x;
1008
+ if (p.y > maxY) maxY = p.y;
1009
+ }
1010
+ return {
1011
+ minX,
1012
+ minY,
1013
+ maxX,
1014
+ maxY
1015
+ };
1016
+ }
1017
+ function noteBounds(note) {
1018
+ const half = note.size / 2;
1019
+ return {
1020
+ minX: note.x - half,
1021
+ minY: note.y - half,
1022
+ maxX: note.x + half,
1023
+ maxY: note.y + half
1024
+ };
1025
+ }
1026
+ /** (x, y) is the top-left corner; text flows downward via -y. */
1027
+ function textBounds(block) {
1028
+ const { width, height } = measureTextBlock(block.text, block.fontSize);
1029
+ return {
1030
+ minX: block.x,
1031
+ minY: block.y - height,
1032
+ maxX: block.x + width,
1033
+ maxY: block.y
1034
+ };
1035
+ }
1036
+ /** (x, y) is the top-left corner, matching TextBlock's convention. */
1037
+ function tableBounds(table) {
1038
+ const { width, height } = measureTable(table);
1039
+ return {
1040
+ minX: table.x,
1041
+ minY: table.y - height,
1042
+ maxX: table.x + width,
1043
+ maxY: table.y
1044
+ };
1045
+ }
1046
+ /** (x, y) is the center — ImageBlock's own, different convention. */
1047
+ function imageBounds(image) {
1048
+ const halfW = image.width / 2;
1049
+ const halfH = image.height / 2;
1050
+ return {
1051
+ minX: image.x - halfW,
1052
+ minY: image.y - halfH,
1053
+ maxX: image.x + halfW,
1054
+ maxY: image.y + halfH
1055
+ };
1056
+ }
1057
+ function timerBounds(timer) {
1058
+ const half = timer.size / 2;
1059
+ return {
1060
+ minX: timer.x - half,
1061
+ minY: timer.y - half,
1062
+ maxX: timer.x + half,
1063
+ maxY: timer.y + half
1064
+ };
1065
+ }
1066
+ /** World-space corners of the object's local `fallback.bounds`, taken through its `transform` — the same math `hitTestCustom`'s inverse already relies on being correct. */
1067
+ function customObjectBounds(object) {
1068
+ const b = object.fallback.bounds;
1069
+ return growAll([
1070
+ {
1071
+ x: b.x,
1072
+ y: b.y
1073
+ },
1074
+ {
1075
+ x: b.x + b.width,
1076
+ y: b.y
1077
+ },
1078
+ {
1079
+ x: b.x,
1080
+ y: b.y + b.height
1081
+ },
1082
+ {
1083
+ x: b.x + b.width,
1084
+ y: b.y + b.height
1085
+ }
1086
+ ].map((corner) => apply(object.transform, corner)));
1087
+ }
1088
+ /**
1089
+ * Shared by Rectangle/Ellipse: both use the top-edge, Y-up, extends-downward
1090
+ * convention (see `shapeObjects.ts`'s own header comment), and both rotate
1091
+ * (Phase 3) about the same center `(x + width/2, y - height/2)`. An ellipse's
1092
+ * axis-aligned bounds equal its bounding rectangle's — an ellipse never
1093
+ * exceeds the box it's inscribed in — so one corner-rotation helper covers
1094
+ * both shapes exactly.
1095
+ */
1096
+ function topEdgeRotatedBounds(x, y, width, height, rotation) {
1097
+ if (!rotation) return {
1098
+ minX: x,
1099
+ minY: y - height,
1100
+ maxX: x + width,
1101
+ maxY: y
1102
+ };
1103
+ const m = rotationAbout(rotation, x + width / 2, y - height / 2);
1104
+ return growAll([
1105
+ {
1106
+ x,
1107
+ y
1108
+ },
1109
+ {
1110
+ x: x + width,
1111
+ y
1112
+ },
1113
+ {
1114
+ x,
1115
+ y: y - height
1116
+ },
1117
+ {
1118
+ x: x + width,
1119
+ y: y - height
1120
+ }
1121
+ ].map((corner) => apply(m, corner)));
1122
+ }
1123
+ function rectangleBounds(rect) {
1124
+ return topEdgeRotatedBounds(rect.x, rect.y, rect.width, rect.height, rect.rotation);
1125
+ }
1126
+ function ellipseBounds(ellipse) {
1127
+ return topEdgeRotatedBounds(ellipse.x, ellipse.y, ellipse.width, ellipse.height, ellipse.rotation);
1128
+ }
1129
+ /**
1130
+ * Same top-edge/Y-up/rotate-about-center convention as Rectangle — a
1131
+ * regular polygon's own outline is normalized (`polygonGeometry.ts`'s
1132
+ * `fitToBox`) to exactly fill its declared box, so this is exact at
1133
+ * rotation 0 and a safe (never-too-small) superset once rotated, matching
1134
+ * how `ellipseBounds` already treats its own inscribed shape.
1135
+ */
1136
+ function polygonBounds(polygon) {
1137
+ return topEdgeRotatedBounds(polygon.x, polygon.y, polygon.width, polygon.height, polygon.rotation);
1138
+ }
1139
+ /** Same convention and same safe-superset reasoning as `polygonBounds` — a star's points are normalized to touch its declared box (`polygonGeometry.ts`'s `starPoints`). */
1140
+ function starBounds(star) {
1141
+ return topEdgeRotatedBounds(star.x, star.y, star.width, star.height, star.rotation);
1142
+ }
1143
+ /** Same convention and same safe-superset reasoning as `polygonBounds` — a heart's curve is normalized to touch its declared box (`polygonGeometry.ts`'s `heartPoints`). */
1144
+ function heartBounds(heart) {
1145
+ return topEdgeRotatedBounds(heart.x, heart.y, heart.width, heart.height, heart.rotation);
1146
+ }
1147
+ /** No rotation concept (see `shapeObjects.ts`'s header) — the two endpoints already are the bounds. */
1148
+ function lineBounds(line) {
1149
+ return growAll([line.start, line.end]);
1150
+ }
1151
+ /** Same as `lineBounds` — an arrow's head barbs stay within the shaft's own bounding box (they curve back toward it, never past the tip). */
1152
+ function arrowBounds(arrow) {
1153
+ return growAll([arrow.start, arrow.end]);
1154
+ }
1155
+ /**
1156
+ * Union of one or more child bounds, or `null` when every child resolved to
1157
+ * nothing (an empty or entirely-dangling group). Used for `GroupObject`
1158
+ * bounds — the caller (`BoardDocument.bbox`) supplies `resolve` as its own
1159
+ * `bbox(id)` so this stays a pure fold with no document dependency here.
1160
+ */
1161
+ function unionBounds(boxes) {
1162
+ let result;
1163
+ for (const box of boxes) {
1164
+ if (!box) continue;
1165
+ if (!result) {
1166
+ result = { ...box };
1167
+ continue;
1168
+ }
1169
+ result.minX = Math.min(result.minX, box.minX);
1170
+ result.minY = Math.min(result.minY, box.minY);
1171
+ result.maxX = Math.max(result.maxX, box.maxX);
1172
+ result.maxY = Math.max(result.maxY, box.maxY);
1173
+ }
1174
+ return result;
1175
+ }
1176
+ //#endregion
775
1177
  //#region src/core/document/document.ts
776
1178
  /**
777
1179
  * The one place a live Stroke becomes its wire representation — in
@@ -822,8 +1224,73 @@ var EMPTY_CHANGE = {
822
1224
  timersUpdated: [],
823
1225
  customObjectsAdded: [],
824
1226
  customObjectsRemoved: [],
825
- customObjectsUpdated: []
1227
+ customObjectsUpdated: [],
1228
+ rectanglesAdded: [],
1229
+ rectanglesRemoved: [],
1230
+ rectanglesUpdated: [],
1231
+ ellipsesAdded: [],
1232
+ ellipsesRemoved: [],
1233
+ ellipsesUpdated: [],
1234
+ groupsAdded: [],
1235
+ groupsRemoved: [],
1236
+ groupsUpdated: [],
1237
+ linesAdded: [],
1238
+ linesRemoved: [],
1239
+ linesUpdated: [],
1240
+ arrowsAdded: [],
1241
+ arrowsRemoved: [],
1242
+ arrowsUpdated: [],
1243
+ polygonsAdded: [],
1244
+ polygonsRemoved: [],
1245
+ polygonsUpdated: [],
1246
+ starsAdded: [],
1247
+ starsRemoved: [],
1248
+ starsUpdated: [],
1249
+ heartsAdded: [],
1250
+ heartsRemoved: [],
1251
+ heartsUpdated: [],
1252
+ orderChanged: []
826
1253
  };
1254
+ /**
1255
+ * Flat content types that participate in the unified paint order — see
1256
+ * `DocumentChange.orderChanged`'s own doc comment for why notes/timers are
1257
+ * excluded. Listed back-to-front in the exact sequence the old fixed
1258
+ * per-renderer Z constants already used (rectangles/ellipses lowest, then
1259
+ * tables, then images, then ink/text, then custom objects) — this is what
1260
+ * `replaceAll`/`emit`'s automatic append-on-add tracking uses to synthesize
1261
+ * a default order for a legacy document with no persisted `objectOrder`, so
1262
+ * an existing document's visual stacking never changes just from loading it.
1263
+ */
1264
+ var ORDERED_ADDED_FIELDS = [
1265
+ "rectanglesAdded",
1266
+ "ellipsesAdded",
1267
+ "tablesAdded",
1268
+ "imagesAdded",
1269
+ "added",
1270
+ "textAdded",
1271
+ "customObjectsAdded",
1272
+ "groupsAdded",
1273
+ "linesAdded",
1274
+ "arrowsAdded",
1275
+ "polygonsAdded",
1276
+ "starsAdded",
1277
+ "heartsAdded"
1278
+ ];
1279
+ var ORDERED_REMOVED_FIELDS = [
1280
+ "rectanglesRemoved",
1281
+ "ellipsesRemoved",
1282
+ "tablesRemoved",
1283
+ "imagesRemoved",
1284
+ "removed",
1285
+ "textRemoved",
1286
+ "customObjectsRemoved",
1287
+ "groupsRemoved",
1288
+ "linesRemoved",
1289
+ "arrowsRemoved",
1290
+ "polygonsRemoved",
1291
+ "starsRemoved",
1292
+ "heartsRemoved"
1293
+ ];
827
1294
  var BoardDocument = class {
828
1295
  constructor(id) {
829
1296
  this.id = id;
@@ -835,8 +1302,85 @@ var BoardDocument = class {
835
1302
  this.images = /* @__PURE__ */ new Map();
836
1303
  this.timers = /* @__PURE__ */ new Map();
837
1304
  this.customObjects = /* @__PURE__ */ new Map();
1305
+ this.rectangles = /* @__PURE__ */ new Map();
1306
+ this.ellipses = /* @__PURE__ */ new Map();
1307
+ this.groups = /* @__PURE__ */ new Map();
1308
+ this.lines = /* @__PURE__ */ new Map();
1309
+ this.arrows = /* @__PURE__ */ new Map();
1310
+ this.polygons = /* @__PURE__ */ new Map();
1311
+ this.stars = /* @__PURE__ */ new Map();
1312
+ this.hearts = /* @__PURE__ */ new Map();
838
1313
  this.bboxes = /* @__PURE__ */ new Map();
839
1314
  this.listeners = /* @__PURE__ */ new Set();
1315
+ this.objectOrder = [];
1316
+ this.orderIndex = /* @__PURE__ */ new Map();
1317
+ }
1318
+ reindexOrder() {
1319
+ this.orderIndex = new Map(this.objectOrder.map((id, index) => [id, index]));
1320
+ }
1321
+ /** The full current paint order, back to front. */
1322
+ order() {
1323
+ return this.objectOrder;
1324
+ }
1325
+ /** This object's rank in the paint order, or -1 if it doesn't participate (unknown id, a note, or a timer). */
1326
+ orderRank(id) {
1327
+ return this.orderIndex.get(id) ?? -1;
1328
+ }
1329
+ bringForward(id) {
1330
+ const i = this.orderIndex.get(id);
1331
+ if (i === void 0 || i >= this.objectOrder.length - 1) return;
1332
+ [this.objectOrder[i], this.objectOrder[i + 1]] = [this.objectOrder[i + 1], this.objectOrder[i]];
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]] });
1336
+ }
1337
+ sendBackward(id) {
1338
+ const i = this.orderIndex.get(id);
1339
+ if (i === void 0 || i <= 0) return;
1340
+ [this.objectOrder[i], this.objectOrder[i - 1]] = [this.objectOrder[i - 1], this.objectOrder[i]];
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]] });
1344
+ }
1345
+ bringToFront(id) {
1346
+ const i = this.orderIndex.get(id);
1347
+ if (i === void 0 || i >= this.objectOrder.length - 1) return;
1348
+ this.objectOrder.splice(i, 1);
1349
+ this.objectOrder.push(id);
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) });
1352
+ }
1353
+ sendToBack(id) {
1354
+ const i = this.orderIndex.get(id);
1355
+ if (i === void 0 || i <= 0) return;
1356
+ this.objectOrder.splice(i, 1);
1357
+ this.objectOrder.unshift(id);
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) });
1360
+ }
1361
+ /** Reorders `id` relative to its current neighbors. A no-op for an id that doesn't participate in paint order (see `orderRank`). */
1362
+ reorder(id, direction) {
1363
+ if (this.orderIndex.get(id) === void 0) return;
1364
+ if (direction === "forward") this.bringForward(id);
1365
+ else if (direction === "backward") this.sendBackward(id);
1366
+ else if (direction === "front") this.bringToFront(id);
1367
+ else this.sendToBack(id);
1368
+ }
1369
+ /**
1370
+ * Overwrites the paint order directly — used only when loading a document
1371
+ * that already carries a persisted `objectOrder`; every other order
1372
+ * mutation goes through `reorder`/the automatic append-on-add tracking in
1373
+ * `emit`. Ids not present in the document are dropped; ids present in the
1374
+ * document but missing from `order` are appended at the back, so a
1375
+ * partially-stale order (e.g. from a schema migration) never silently
1376
+ * drops an object from paint order entirely.
1377
+ */
1378
+ setOrder(order) {
1379
+ const known = new Set(this.objectOrder);
1380
+ const next = order.filter((id) => known.has(id));
1381
+ for (const id of this.objectOrder) if (!next.includes(id)) next.push(id);
1382
+ this.objectOrder = next;
1383
+ this.reindexOrder();
840
1384
  }
841
1385
  get(id) {
842
1386
  return this.strokes.get(id);
@@ -844,8 +1388,75 @@ var BoardDocument = class {
844
1388
  all() {
845
1389
  return this.strokes.values();
846
1390
  }
847
- bbox(id) {
848
- return this.bboxes.get(id);
1391
+ /**
1392
+ * World-space bounds for any content object, of any type. Strokes hit
1393
+ * their cached-on-mutation fast path (`bboxes`, populated by
1394
+ * `addStrokes`/`transformStrokes` — many points, worth caching); every
1395
+ * other type computes on demand via `objectBounds.ts` (cheap arithmetic,
1396
+ * no caching needed). A group's bounds are the union of its (recursively
1397
+ * resolved) children — `seen` guards against a cycle in nested groups.
1398
+ */
1399
+ bbox(id, seen) {
1400
+ const cached = this.bboxes.get(id);
1401
+ if (cached) return cached;
1402
+ const note = this.notes.get(id);
1403
+ if (note) return noteBounds(note);
1404
+ const text = this.texts.get(id);
1405
+ if (text) return textBounds(text);
1406
+ const table = this.tables.get(id);
1407
+ if (table) return tableBounds(table);
1408
+ const image = this.images.get(id);
1409
+ if (image) return imageBounds(image);
1410
+ const timer = this.timers.get(id);
1411
+ if (timer) return timerBounds(timer);
1412
+ const custom = this.customObjects.get(id);
1413
+ if (custom) return customObjectBounds(custom);
1414
+ const rect = this.rectangles.get(id);
1415
+ if (rect) return rectangleBounds(rect);
1416
+ const ellipse = this.ellipses.get(id);
1417
+ if (ellipse) return ellipseBounds(ellipse);
1418
+ const line = this.lines.get(id);
1419
+ if (line) return lineBounds(line);
1420
+ const arrow = this.arrows.get(id);
1421
+ if (arrow) return arrowBounds(arrow);
1422
+ const polygon = this.polygons.get(id);
1423
+ if (polygon) return polygonBounds(polygon);
1424
+ const star = this.stars.get(id);
1425
+ if (star) return starBounds(star);
1426
+ const heart = this.hearts.get(id);
1427
+ if (heart) return heartBounds(heart);
1428
+ const group = this.groups.get(id);
1429
+ if (group) {
1430
+ const visited = seen ?? /* @__PURE__ */ new Set();
1431
+ if (visited.has(id)) return void 0;
1432
+ visited.add(id);
1433
+ return unionBounds(group.children.map((childId) => this.bbox(childId, visited)));
1434
+ }
1435
+ }
1436
+ /**
1437
+ * True if `id` exists and is locked, for any type — the same per-type
1438
+ * probe pattern as `bbox`, for interactive gestures (drag/transform) that
1439
+ * need to gate on lock state regardless of what's selected. Custom
1440
+ * objects are deliberately excluded: their `lock` field is a different
1441
+ * shape (`{holderId, acquiredAt}`, no display name) with no interactive
1442
+ * lock UI yet, matching the existing, deliberate "always unlockable,
1443
+ * never gates a drag" treatment already established elsewhere (e.g.
1444
+ * `getSelectedItemInfo`'s custom branch hardcodes `isLocked: false`).
1445
+ */
1446
+ isLocked(id) {
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;
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;
849
1460
  }
850
1461
  subscribe(listener) {
851
1462
  this.listeners.add(listener);
@@ -994,6 +1605,176 @@ var BoardDocument = class {
994
1605
  this.customObjects.set(object.id, object);
995
1606
  this.emit({ customObjectsUpdated: [object] });
996
1607
  }
1608
+ getRectangle(id) {
1609
+ return this.rectangles.get(id);
1610
+ }
1611
+ allRectangles() {
1612
+ return this.rectangles.values();
1613
+ }
1614
+ addRectangles(rectangles) {
1615
+ const normalized = rectangles.map(normalizeBoxGeometry);
1616
+ for (const rect of normalized) this.rectangles.set(rect.id, rect);
1617
+ this.emit({ rectanglesAdded: normalized });
1618
+ }
1619
+ removeRectangles(ids) {
1620
+ const rectanglesRemoved = [];
1621
+ for (const id of ids) if (this.rectangles.delete(id)) rectanglesRemoved.push(id);
1622
+ if (rectanglesRemoved.length) this.emit({ rectanglesRemoved });
1623
+ }
1624
+ /** Replace a rectangle's contents (move, resize, restyle) under the same id. */
1625
+ setRectangle(rect) {
1626
+ const normalized = normalizeBoxGeometry(rect);
1627
+ this.rectangles.set(normalized.id, normalized);
1628
+ this.emit({ rectanglesUpdated: [normalized] });
1629
+ }
1630
+ getEllipse(id) {
1631
+ return this.ellipses.get(id);
1632
+ }
1633
+ allEllipses() {
1634
+ return this.ellipses.values();
1635
+ }
1636
+ addEllipses(ellipses) {
1637
+ const normalized = ellipses.map(normalizeBoxGeometry);
1638
+ for (const ellipse of normalized) this.ellipses.set(ellipse.id, ellipse);
1639
+ this.emit({ ellipsesAdded: normalized });
1640
+ }
1641
+ removeEllipses(ids) {
1642
+ const ellipsesRemoved = [];
1643
+ for (const id of ids) if (this.ellipses.delete(id)) ellipsesRemoved.push(id);
1644
+ if (ellipsesRemoved.length) this.emit({ ellipsesRemoved });
1645
+ }
1646
+ /** Replace an ellipse's contents (move, resize, restyle) under the same id. */
1647
+ setEllipse(ellipse) {
1648
+ const normalized = normalizeBoxGeometry(ellipse);
1649
+ this.ellipses.set(normalized.id, normalized);
1650
+ this.emit({ ellipsesUpdated: [normalized] });
1651
+ }
1652
+ getGroup(id) {
1653
+ return this.groups.get(id);
1654
+ }
1655
+ allGroups() {
1656
+ return this.groups.values();
1657
+ }
1658
+ addGroups(groups) {
1659
+ for (const group of groups) this.groups.set(group.id, group);
1660
+ this.emit({ groupsAdded: groups });
1661
+ }
1662
+ removeGroups(ids) {
1663
+ const groupsRemoved = [];
1664
+ for (const id of ids) if (this.groups.delete(id)) groupsRemoved.push(id);
1665
+ if (groupsRemoved.length) this.emit({ groupsRemoved });
1666
+ }
1667
+ /** Replace a group's contents (its children list) under the same id. */
1668
+ setGroup(group) {
1669
+ this.groups.set(group.id, group);
1670
+ this.emit({ groupsUpdated: [group] });
1671
+ }
1672
+ getLine(id) {
1673
+ return this.lines.get(id);
1674
+ }
1675
+ allLines() {
1676
+ return this.lines.values();
1677
+ }
1678
+ addLines(lines) {
1679
+ for (const line of lines) this.lines.set(line.id, line);
1680
+ this.emit({ linesAdded: lines });
1681
+ }
1682
+ removeLines(ids) {
1683
+ const linesRemoved = [];
1684
+ for (const id of ids) if (this.lines.delete(id)) linesRemoved.push(id);
1685
+ if (linesRemoved.length) this.emit({ linesRemoved });
1686
+ }
1687
+ /** Replace a line's contents (move, restyle) under the same id. */
1688
+ setLine(line) {
1689
+ this.lines.set(line.id, line);
1690
+ this.emit({ linesUpdated: [line] });
1691
+ }
1692
+ getArrow(id) {
1693
+ return this.arrows.get(id);
1694
+ }
1695
+ allArrows() {
1696
+ return this.arrows.values();
1697
+ }
1698
+ addArrows(arrows) {
1699
+ for (const arrow of arrows) this.arrows.set(arrow.id, arrow);
1700
+ this.emit({ arrowsAdded: arrows });
1701
+ }
1702
+ removeArrows(ids) {
1703
+ const arrowsRemoved = [];
1704
+ for (const id of ids) if (this.arrows.delete(id)) arrowsRemoved.push(id);
1705
+ if (arrowsRemoved.length) this.emit({ arrowsRemoved });
1706
+ }
1707
+ /** Replace an arrow's contents (move, restyle, change head) under the same id. */
1708
+ setArrow(arrow) {
1709
+ this.arrows.set(arrow.id, arrow);
1710
+ this.emit({ arrowsUpdated: [arrow] });
1711
+ }
1712
+ getPolygon(id) {
1713
+ return this.polygons.get(id);
1714
+ }
1715
+ allPolygons() {
1716
+ return this.polygons.values();
1717
+ }
1718
+ addPolygons(polygons) {
1719
+ const normalized = polygons.map(normalizeBoxGeometry);
1720
+ for (const polygon of normalized) this.polygons.set(polygon.id, polygon);
1721
+ this.emit({ polygonsAdded: normalized });
1722
+ }
1723
+ removePolygons(ids) {
1724
+ const polygonsRemoved = [];
1725
+ for (const id of ids) if (this.polygons.delete(id)) polygonsRemoved.push(id);
1726
+ if (polygonsRemoved.length) this.emit({ polygonsRemoved });
1727
+ }
1728
+ /** Replace a polygon's contents (move, resize, rotate, restyle) under the same id. */
1729
+ setPolygon(polygon) {
1730
+ const normalized = normalizeBoxGeometry(polygon);
1731
+ this.polygons.set(normalized.id, normalized);
1732
+ this.emit({ polygonsUpdated: [normalized] });
1733
+ }
1734
+ getStar(id) {
1735
+ return this.stars.get(id);
1736
+ }
1737
+ allStars() {
1738
+ return this.stars.values();
1739
+ }
1740
+ addStars(stars) {
1741
+ const normalized = stars.map(normalizeBoxGeometry);
1742
+ for (const star of normalized) this.stars.set(star.id, star);
1743
+ this.emit({ starsAdded: normalized });
1744
+ }
1745
+ removeStars(ids) {
1746
+ const starsRemoved = [];
1747
+ for (const id of ids) if (this.stars.delete(id)) starsRemoved.push(id);
1748
+ if (starsRemoved.length) this.emit({ starsRemoved });
1749
+ }
1750
+ /** Replace a star's contents (move, resize, rotate, restyle) under the same id. */
1751
+ setStar(star) {
1752
+ const normalized = normalizeBoxGeometry(star);
1753
+ this.stars.set(normalized.id, normalized);
1754
+ this.emit({ starsUpdated: [normalized] });
1755
+ }
1756
+ getHeart(id) {
1757
+ return this.hearts.get(id);
1758
+ }
1759
+ allHearts() {
1760
+ return this.hearts.values();
1761
+ }
1762
+ addHearts(hearts) {
1763
+ const normalized = hearts.map(normalizeBoxGeometry);
1764
+ for (const heart of normalized) this.hearts.set(heart.id, heart);
1765
+ this.emit({ heartsAdded: normalized });
1766
+ }
1767
+ removeHearts(ids) {
1768
+ const heartsRemoved = [];
1769
+ for (const id of ids) if (this.hearts.delete(id)) heartsRemoved.push(id);
1770
+ if (heartsRemoved.length) this.emit({ heartsRemoved });
1771
+ }
1772
+ /** Replace a heart's contents (move, resize, rotate, restyle) under the same id. */
1773
+ setHeart(heart) {
1774
+ const normalized = normalizeBoxGeometry(heart);
1775
+ this.hearts.set(normalized.id, normalized);
1776
+ this.emit({ heartsUpdated: [normalized] });
1777
+ }
997
1778
  setStrokeLocked(id, locked, by) {
998
1779
  const stroke = this.strokes.get(id);
999
1780
  if (!stroke) return;
@@ -1041,8 +1822,56 @@ var BoardDocument = class {
1041
1822
  applyItemLock(timer, locked, by);
1042
1823
  this.emit({ timersUpdated: [timer] });
1043
1824
  }
1825
+ setRectangleLocked(id, locked, by) {
1826
+ const rect = this.rectangles.get(id);
1827
+ if (!rect) return;
1828
+ applyItemLock(rect, locked, by);
1829
+ this.emit({ rectanglesUpdated: [rect] });
1830
+ }
1831
+ setEllipseLocked(id, locked, by) {
1832
+ const ellipse = this.ellipses.get(id);
1833
+ if (!ellipse) return;
1834
+ applyItemLock(ellipse, locked, by);
1835
+ this.emit({ ellipsesUpdated: [ellipse] });
1836
+ }
1837
+ setGroupLocked(id, locked, by) {
1838
+ const group = this.groups.get(id);
1839
+ if (!group) return;
1840
+ applyItemLock(group, locked, by);
1841
+ this.emit({ groupsUpdated: [group] });
1842
+ }
1843
+ setLineLocked(id, locked, by) {
1844
+ const line = this.lines.get(id);
1845
+ if (!line) return;
1846
+ applyItemLock(line, locked, by);
1847
+ this.emit({ linesUpdated: [line] });
1848
+ }
1849
+ setArrowLocked(id, locked, by) {
1850
+ const arrow = this.arrows.get(id);
1851
+ if (!arrow) return;
1852
+ applyItemLock(arrow, locked, by);
1853
+ this.emit({ arrowsUpdated: [arrow] });
1854
+ }
1855
+ setPolygonLocked(id, locked, by) {
1856
+ const polygon = this.polygons.get(id);
1857
+ if (!polygon) return;
1858
+ applyItemLock(polygon, locked, by);
1859
+ this.emit({ polygonsUpdated: [polygon] });
1860
+ }
1861
+ setStarLocked(id, locked, by) {
1862
+ const star = this.stars.get(id);
1863
+ if (!star) return;
1864
+ applyItemLock(star, locked, by);
1865
+ this.emit({ starsUpdated: [star] });
1866
+ }
1867
+ setHeartLocked(id, locked, by) {
1868
+ const heart = this.hearts.get(id);
1869
+ if (!heart) return;
1870
+ applyItemLock(heart, locked, by);
1871
+ this.emit({ heartsUpdated: [heart] });
1872
+ }
1044
1873
  /** Replace all content (initial load). Does not touch `version`. */
1045
- replaceAll(strokes, notes, texts, tables = [], images = [], timers = [], customObjects = []) {
1874
+ replaceAll(strokes, notes, texts, tables = [], images = [], timers = [], customObjects = [], rectangles = [], ellipses = [], groups = [], lines = [], arrows = [], polygons = [], stars = [], hearts = [], objectOrder) {
1046
1875
  const removed = [...this.strokes.keys()];
1047
1876
  const notesRemoved = [...this.notes.keys()];
1048
1877
  const textRemoved = [...this.texts.keys()];
@@ -1050,6 +1879,14 @@ var BoardDocument = class {
1050
1879
  const imagesRemoved = [...this.images.keys()];
1051
1880
  const timersRemoved = [...this.timers.keys()];
1052
1881
  const customObjectsRemoved = [...this.customObjects.keys()];
1882
+ const rectanglesRemoved = [...this.rectangles.keys()];
1883
+ const ellipsesRemoved = [...this.ellipses.keys()];
1884
+ const groupsRemoved = [...this.groups.keys()];
1885
+ const linesRemoved = [...this.lines.keys()];
1886
+ const arrowsRemoved = [...this.arrows.keys()];
1887
+ const polygonsRemoved = [...this.polygons.keys()];
1888
+ const starsRemoved = [...this.stars.keys()];
1889
+ const heartsRemoved = [...this.hearts.keys()];
1053
1890
  this.strokes.clear();
1054
1891
  this.notes.clear();
1055
1892
  this.texts.clear();
@@ -1057,6 +1894,14 @@ var BoardDocument = class {
1057
1894
  this.images.clear();
1058
1895
  this.timers.clear();
1059
1896
  this.customObjects.clear();
1897
+ this.rectangles.clear();
1898
+ this.ellipses.clear();
1899
+ this.groups.clear();
1900
+ this.lines.clear();
1901
+ this.arrows.clear();
1902
+ this.polygons.clear();
1903
+ this.stars.clear();
1904
+ this.hearts.clear();
1060
1905
  this.bboxes.clear();
1061
1906
  for (const stroke of strokes) {
1062
1907
  this.strokes.set(stroke.id, stroke);
@@ -1068,6 +1913,14 @@ var BoardDocument = class {
1068
1913
  for (const img of images) this.images.set(img.id, img);
1069
1914
  for (const timer of timers) this.timers.set(timer.id, timer);
1070
1915
  for (const object of customObjects) this.customObjects.set(object.id, object);
1916
+ for (const rect of rectangles) this.rectangles.set(rect.id, rect);
1917
+ for (const ellipse of ellipses) this.ellipses.set(ellipse.id, ellipse);
1918
+ for (const group of groups) this.groups.set(group.id, group);
1919
+ for (const line of lines) this.lines.set(line.id, line);
1920
+ for (const arrow of arrows) this.arrows.set(arrow.id, arrow);
1921
+ for (const polygon of polygons) this.polygons.set(polygon.id, polygon);
1922
+ for (const star of stars) this.stars.set(star.id, star);
1923
+ for (const heart of hearts) this.hearts.set(heart.id, heart);
1071
1924
  this.emit({
1072
1925
  added: strokes,
1073
1926
  removed,
@@ -1082,8 +1935,25 @@ var BoardDocument = class {
1082
1935
  timersAdded: timers,
1083
1936
  timersRemoved,
1084
1937
  customObjectsAdded: customObjects,
1085
- customObjectsRemoved
1938
+ customObjectsRemoved,
1939
+ rectanglesAdded: rectangles,
1940
+ rectanglesRemoved,
1941
+ ellipsesAdded: ellipses,
1942
+ ellipsesRemoved,
1943
+ groupsAdded: groups,
1944
+ groupsRemoved,
1945
+ linesAdded: lines,
1946
+ linesRemoved,
1947
+ arrowsAdded: arrows,
1948
+ arrowsRemoved,
1949
+ polygonsAdded: polygons,
1950
+ polygonsRemoved,
1951
+ starsAdded: stars,
1952
+ starsRemoved,
1953
+ heartsAdded: hearts,
1954
+ heartsRemoved
1086
1955
  });
1956
+ if (objectOrder) this.setOrder(objectOrder);
1087
1957
  }
1088
1958
  /** Apply incremental real-time change received from a remote collaborator over WebSocket. */
1089
1959
  applyRemoteChange(change) {
@@ -1195,6 +2065,114 @@ var BoardDocument = class {
1195
2065
  for (const object of change.customObjectsUpdated) this.customObjects.set(object.id, object);
1196
2066
  events.customObjectsUpdated = change.customObjectsUpdated;
1197
2067
  }
2068
+ if (change.rectanglesAdded && change.rectanglesAdded.length > 0) {
2069
+ for (const rect of change.rectanglesAdded) this.rectangles.set(rect.id, rect);
2070
+ events.rectanglesAdded = change.rectanglesAdded;
2071
+ }
2072
+ if (change.rectanglesRemoved && change.rectanglesRemoved.length > 0) {
2073
+ const rectanglesRemoved = [];
2074
+ for (const id of change.rectanglesRemoved) if (this.rectangles.delete(id)) rectanglesRemoved.push(id);
2075
+ if (rectanglesRemoved.length > 0) events.rectanglesRemoved = rectanglesRemoved;
2076
+ }
2077
+ if (change.rectanglesUpdated && change.rectanglesUpdated.length > 0) {
2078
+ for (const rect of change.rectanglesUpdated) this.rectangles.set(rect.id, rect);
2079
+ events.rectanglesUpdated = change.rectanglesUpdated;
2080
+ }
2081
+ if (change.ellipsesAdded && change.ellipsesAdded.length > 0) {
2082
+ for (const ellipse of change.ellipsesAdded) this.ellipses.set(ellipse.id, ellipse);
2083
+ events.ellipsesAdded = change.ellipsesAdded;
2084
+ }
2085
+ if (change.ellipsesRemoved && change.ellipsesRemoved.length > 0) {
2086
+ const ellipsesRemoved = [];
2087
+ for (const id of change.ellipsesRemoved) if (this.ellipses.delete(id)) ellipsesRemoved.push(id);
2088
+ if (ellipsesRemoved.length > 0) events.ellipsesRemoved = ellipsesRemoved;
2089
+ }
2090
+ if (change.ellipsesUpdated && change.ellipsesUpdated.length > 0) {
2091
+ for (const ellipse of change.ellipsesUpdated) this.ellipses.set(ellipse.id, ellipse);
2092
+ events.ellipsesUpdated = change.ellipsesUpdated;
2093
+ }
2094
+ if (change.groupsAdded && change.groupsAdded.length > 0) {
2095
+ for (const group of change.groupsAdded) this.groups.set(group.id, group);
2096
+ events.groupsAdded = change.groupsAdded;
2097
+ }
2098
+ if (change.groupsRemoved && change.groupsRemoved.length > 0) {
2099
+ const groupsRemoved = [];
2100
+ for (const id of change.groupsRemoved) if (this.groups.delete(id)) groupsRemoved.push(id);
2101
+ if (groupsRemoved.length > 0) events.groupsRemoved = groupsRemoved;
2102
+ }
2103
+ if (change.groupsUpdated && change.groupsUpdated.length > 0) {
2104
+ for (const group of change.groupsUpdated) this.groups.set(group.id, group);
2105
+ events.groupsUpdated = change.groupsUpdated;
2106
+ }
2107
+ if (change.linesAdded && change.linesAdded.length > 0) {
2108
+ for (const line of change.linesAdded) this.lines.set(line.id, line);
2109
+ events.linesAdded = change.linesAdded;
2110
+ }
2111
+ if (change.linesRemoved && change.linesRemoved.length > 0) {
2112
+ const linesRemoved = [];
2113
+ for (const id of change.linesRemoved) if (this.lines.delete(id)) linesRemoved.push(id);
2114
+ if (linesRemoved.length > 0) events.linesRemoved = linesRemoved;
2115
+ }
2116
+ if (change.linesUpdated && change.linesUpdated.length > 0) {
2117
+ for (const line of change.linesUpdated) this.lines.set(line.id, line);
2118
+ events.linesUpdated = change.linesUpdated;
2119
+ }
2120
+ if (change.arrowsAdded && change.arrowsAdded.length > 0) {
2121
+ for (const arrow of change.arrowsAdded) this.arrows.set(arrow.id, arrow);
2122
+ events.arrowsAdded = change.arrowsAdded;
2123
+ }
2124
+ if (change.arrowsRemoved && change.arrowsRemoved.length > 0) {
2125
+ const arrowsRemoved = [];
2126
+ for (const id of change.arrowsRemoved) if (this.arrows.delete(id)) arrowsRemoved.push(id);
2127
+ if (arrowsRemoved.length > 0) events.arrowsRemoved = arrowsRemoved;
2128
+ }
2129
+ if (change.arrowsUpdated && change.arrowsUpdated.length > 0) {
2130
+ for (const arrow of change.arrowsUpdated) this.arrows.set(arrow.id, arrow);
2131
+ events.arrowsUpdated = change.arrowsUpdated;
2132
+ }
2133
+ if (change.polygonsAdded && change.polygonsAdded.length > 0) {
2134
+ for (const polygon of change.polygonsAdded) this.polygons.set(polygon.id, polygon);
2135
+ events.polygonsAdded = change.polygonsAdded;
2136
+ }
2137
+ if (change.polygonsRemoved && change.polygonsRemoved.length > 0) {
2138
+ const polygonsRemoved = [];
2139
+ for (const id of change.polygonsRemoved) if (this.polygons.delete(id)) polygonsRemoved.push(id);
2140
+ if (polygonsRemoved.length > 0) events.polygonsRemoved = polygonsRemoved;
2141
+ }
2142
+ if (change.polygonsUpdated && change.polygonsUpdated.length > 0) {
2143
+ for (const polygon of change.polygonsUpdated) this.polygons.set(polygon.id, polygon);
2144
+ events.polygonsUpdated = change.polygonsUpdated;
2145
+ }
2146
+ if (change.starsAdded && change.starsAdded.length > 0) {
2147
+ for (const star of change.starsAdded) this.stars.set(star.id, star);
2148
+ events.starsAdded = change.starsAdded;
2149
+ }
2150
+ if (change.starsRemoved && change.starsRemoved.length > 0) {
2151
+ const starsRemoved = [];
2152
+ for (const id of change.starsRemoved) if (this.stars.delete(id)) starsRemoved.push(id);
2153
+ if (starsRemoved.length > 0) events.starsRemoved = starsRemoved;
2154
+ }
2155
+ if (change.starsUpdated && change.starsUpdated.length > 0) {
2156
+ for (const star of change.starsUpdated) this.stars.set(star.id, star);
2157
+ events.starsUpdated = change.starsUpdated;
2158
+ }
2159
+ if (change.heartsAdded && change.heartsAdded.length > 0) {
2160
+ for (const heart of change.heartsAdded) this.hearts.set(heart.id, heart);
2161
+ events.heartsAdded = change.heartsAdded;
2162
+ }
2163
+ if (change.heartsRemoved && change.heartsRemoved.length > 0) {
2164
+ const heartsRemoved = [];
2165
+ for (const id of change.heartsRemoved) if (this.hearts.delete(id)) heartsRemoved.push(id);
2166
+ if (heartsRemoved.length > 0) events.heartsRemoved = heartsRemoved;
2167
+ }
2168
+ if (change.heartsUpdated && change.heartsUpdated.length > 0) {
2169
+ for (const heart of change.heartsUpdated) this.hearts.set(heart.id, heart);
2170
+ events.heartsUpdated = change.heartsUpdated;
2171
+ }
2172
+ if (change.orderChanged && change.orderChanged.length > 0) {
2173
+ this.setOrder(change.orderChanged);
2174
+ events.orderChanged = change.orderChanged;
2175
+ }
1198
2176
  this.emit(events);
1199
2177
  }
1200
2178
  toJSON() {
@@ -1206,12 +2184,45 @@ var BoardDocument = class {
1206
2184
  tables: [...this.tables.values()].map(cloneTable),
1207
2185
  images: [...this.images.values()].map(cloneImage),
1208
2186
  timers: [...this.timers.values()].map(cloneTimer),
1209
- customObjects: [...this.customObjects.values()].map(cloneCustomObject)
2187
+ customObjects: [...this.customObjects.values()].map(cloneCustomObject),
2188
+ rectangles: [...this.rectangles.values()].map(cloneRectangle),
2189
+ ellipses: [...this.ellipses.values()].map(cloneEllipse),
2190
+ groups: [...this.groups.values()].map(cloneGroup),
2191
+ lines: [...this.lines.values()].map(cloneLine),
2192
+ arrows: [...this.arrows.values()].map(cloneArrow),
2193
+ polygons: [...this.polygons.values()].map(clonePolygon),
2194
+ stars: [...this.stars.values()].map(cloneStar),
2195
+ hearts: [...this.hearts.values()].map(cloneHeart),
2196
+ objectOrder: [...this.objectOrder]
1210
2197
  };
1211
2198
  }
2199
+ static deserializeGroups(data) {
2200
+ return (data.groups ?? []).map(cloneGroup);
2201
+ }
2202
+ static deserializeLines(data) {
2203
+ return (data.lines ?? []).map(cloneLine);
2204
+ }
2205
+ static deserializeArrows(data) {
2206
+ return (data.arrows ?? []).map(cloneArrow);
2207
+ }
2208
+ static deserializePolygons(data) {
2209
+ return (data.polygons ?? []).map(clonePolygon);
2210
+ }
2211
+ static deserializeStars(data) {
2212
+ return (data.stars ?? []).map(cloneStar);
2213
+ }
2214
+ static deserializeHearts(data) {
2215
+ return (data.hearts ?? []).map(cloneHeart);
2216
+ }
1212
2217
  static deserializeCustomObjects(data) {
1213
2218
  return (data.customObjects ?? []).map(cloneCustomObject);
1214
2219
  }
2220
+ static deserializeRectangles(data) {
2221
+ return (data.rectangles ?? []).map(cloneRectangle);
2222
+ }
2223
+ static deserializeEllipses(data) {
2224
+ return (data.ellipses ?? []).map(cloneEllipse);
2225
+ }
1215
2226
  static deserializeImages(data) {
1216
2227
  return (data.images ?? []).map(cloneImage);
1217
2228
  }
@@ -1227,7 +2238,17 @@ var BoardDocument = class {
1227
2238
  static deserializeTables(data) {
1228
2239
  return (data.tables ?? []).map(cloneTable);
1229
2240
  }
1230
- 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) {
1231
2252
  const strokes = [];
1232
2253
  for (const s of data.strokes) try {
1233
2254
  strokes.push({
@@ -1241,11 +2262,65 @@ var BoardDocument = class {
1241
2262
  ...serializeLock(s)
1242
2263
  });
1243
2264
  } catch (err) {
1244
- console.error(`Skipping malformed stroke ${s?.id ?? "(no id)"}:`, err);
2265
+ onSkip?.(s?.id ?? "(no id)", err);
1245
2266
  }
1246
2267
  return strokes;
1247
2268
  }
2269
+ /**
2270
+ * Every mutation funnels through here, so paint-order tracking lives in
2271
+ * exactly one place rather than at every individual add/remove call site
2272
+ * (18 of them, times `replaceAll`/`applyRemoteChange`) — new ids are
2273
+ * appended to the back (front-most) of `objectOrder`, removed ids are
2274
+ * spliced out. An explicit reorder (`reorder`/`setOrder`) updates
2275
+ * `objectOrder` itself before calling this, so this step is a no-op for
2276
+ * ids already tracked (idempotent by construction: `orderIndex.has` gates
2277
+ * every append).
2278
+ *
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.
2291
+ */
1248
2292
  emit(change) {
2293
+ const newIds = [];
2294
+ for (const field of ORDERED_ADDED_FIELDS) {
2295
+ const objects = change[field];
2296
+ if (!objects) continue;
2297
+ for (const object of objects) if (!this.orderIndex.has(object.id)) newIds.push(object.id);
2298
+ }
2299
+ const removedIds = /* @__PURE__ */ new Set();
2300
+ for (const field of ORDERED_REMOVED_FIELDS) {
2301
+ const ids = change[field];
2302
+ if (!ids) continue;
2303
+ for (const id of ids) if (this.orderIndex.has(id)) removedIds.add(id);
2304
+ }
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);
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
+ };
1249
2324
  const full = {
1250
2325
  ...EMPTY_CHANGE,
1251
2326
  ...change
@@ -1399,6 +2474,200 @@ var TransformCommand = class {
1399
2474
  if (changed.length) doc.transformStrokes(changed);
1400
2475
  }
1401
2476
  };
2477
+ /**
2478
+ * One move gesture over a mixed-type selection (Phase 3) — the general
2479
+ * successor to `TransformCommand` for translation. `TransformCommand`
2480
+ * itself stays as-is (still used for scale/rotate, which remain stroke-only
2481
+ * this phase — see `interaction/tools/selectTool.ts`'s `TransformState`):
2482
+ * its own per-id `doc.get(id)` check already no-ops safely for any id that
2483
+ * isn't a stroke, so it doesn't need touching for that narrower case.
2484
+ *
2485
+ * `delta` here is always a pure translation (never scale/rotate), so
2486
+ * `apply(delta, point)` is a safe, uniform way to move every position-only
2487
+ * type's `x`/`y` — translating commutes trivially regardless of a shape's
2488
+ * own rotation. Matrix-carrying types (stroke, Custom) instead compose
2489
+ * `delta` onto their existing matrix/transform, matching `TransformCommand`.
2490
+ *
2491
+ * A group id in `ids` is expanded into its (recursively resolved, cycle-
2492
+ * safe) children every time `compose` runs — deterministic, since group
2493
+ * membership never changes mid-command — so moving a selected group means
2494
+ * moving every one of its members by the same delta. This expansion is
2495
+ * `TransformObjectsCommand`'s own responsibility precisely so a caller that
2496
+ * doesn't itself expand groups (e.g. `ScrawlEngine.nudgeSelection`) still
2497
+ * gets correct behavior; `interaction/tools/selectTool.ts`'s `TransformState`
2498
+ * separately expands for its own reason (live per-child drag preview), which
2499
+ * makes this a no-op re-expansion for that caller, not a conflict.
2500
+ */
2501
+ var TransformObjectsCommand = class {
2502
+ constructor(ids, delta) {
2503
+ this.ids = ids;
2504
+ this.delta = delta;
2505
+ this.label = "move selection";
2506
+ this.inverse = invert(delta);
2507
+ }
2508
+ apply(doc) {
2509
+ this.compose(doc, this.delta);
2510
+ }
2511
+ revert(doc) {
2512
+ this.compose(doc, this.inverse);
2513
+ }
2514
+ compose(doc, m) {
2515
+ const resolved = /* @__PURE__ */ new Set();
2516
+ const expand = (id, seen) => {
2517
+ if (seen.has(id)) return;
2518
+ seen.add(id);
2519
+ const group = doc.getGroup(id);
2520
+ if (group) {
2521
+ for (const childId of group.children) expand(childId, seen);
2522
+ return;
2523
+ }
2524
+ resolved.add(id);
2525
+ };
2526
+ for (const id of this.ids) expand(id, /* @__PURE__ */ new Set());
2527
+ const changedStrokes = [];
2528
+ for (const id of resolved) {
2529
+ const stroke = doc.get(id);
2530
+ if (stroke) {
2531
+ stroke.matrix = mul(m, stroke.matrix ?? [
2532
+ 1,
2533
+ 0,
2534
+ 0,
2535
+ 1,
2536
+ 0,
2537
+ 0
2538
+ ]);
2539
+ changedStrokes.push(stroke);
2540
+ continue;
2541
+ }
2542
+ const custom = doc.getCustomObject(id);
2543
+ if (custom) {
2544
+ doc.setCustomObject({
2545
+ ...custom,
2546
+ transform: mul(m, custom.transform)
2547
+ });
2548
+ continue;
2549
+ }
2550
+ const note = doc.getNote(id);
2551
+ if (note) {
2552
+ const p = apply(m, note);
2553
+ doc.setNote({
2554
+ ...note,
2555
+ x: p.x,
2556
+ y: p.y
2557
+ });
2558
+ continue;
2559
+ }
2560
+ const text = doc.getText(id);
2561
+ if (text) {
2562
+ const p = apply(m, text);
2563
+ doc.setText({
2564
+ ...text,
2565
+ x: p.x,
2566
+ y: p.y
2567
+ });
2568
+ continue;
2569
+ }
2570
+ const table = doc.getTable(id);
2571
+ if (table) {
2572
+ const p = apply(m, table);
2573
+ doc.setTable({
2574
+ ...table,
2575
+ x: p.x,
2576
+ y: p.y
2577
+ });
2578
+ continue;
2579
+ }
2580
+ const image = doc.getImage(id);
2581
+ if (image) {
2582
+ const p = apply(m, image);
2583
+ doc.setImage({
2584
+ ...image,
2585
+ x: p.x,
2586
+ y: p.y
2587
+ });
2588
+ continue;
2589
+ }
2590
+ const timer = doc.getTimer(id);
2591
+ if (timer) {
2592
+ const p = apply(m, timer);
2593
+ doc.setTimer({
2594
+ ...timer,
2595
+ x: p.x,
2596
+ y: p.y
2597
+ });
2598
+ continue;
2599
+ }
2600
+ const rect = doc.getRectangle(id);
2601
+ if (rect) {
2602
+ const p = apply(m, rect);
2603
+ doc.setRectangle({
2604
+ ...rect,
2605
+ x: p.x,
2606
+ y: p.y
2607
+ });
2608
+ continue;
2609
+ }
2610
+ const ellipse = doc.getEllipse(id);
2611
+ if (ellipse) {
2612
+ const p = apply(m, ellipse);
2613
+ doc.setEllipse({
2614
+ ...ellipse,
2615
+ x: p.x,
2616
+ y: p.y
2617
+ });
2618
+ continue;
2619
+ }
2620
+ const line = doc.getLine(id);
2621
+ if (line) {
2622
+ doc.setLine({
2623
+ ...line,
2624
+ start: apply(m, line.start),
2625
+ end: apply(m, line.end)
2626
+ });
2627
+ continue;
2628
+ }
2629
+ const arrow = doc.getArrow(id);
2630
+ if (arrow) {
2631
+ doc.setArrow({
2632
+ ...arrow,
2633
+ start: apply(m, arrow.start),
2634
+ end: apply(m, arrow.end)
2635
+ });
2636
+ continue;
2637
+ }
2638
+ const polygon = doc.getPolygon(id);
2639
+ if (polygon) {
2640
+ const p = apply(m, polygon);
2641
+ doc.setPolygon({
2642
+ ...polygon,
2643
+ x: p.x,
2644
+ y: p.y
2645
+ });
2646
+ continue;
2647
+ }
2648
+ const star = doc.getStar(id);
2649
+ if (star) {
2650
+ const p = apply(m, star);
2651
+ doc.setStar({
2652
+ ...star,
2653
+ x: p.x,
2654
+ y: p.y
2655
+ });
2656
+ continue;
2657
+ }
2658
+ const heart = doc.getHeart(id);
2659
+ if (heart) {
2660
+ const p = apply(m, heart);
2661
+ doc.setHeart({
2662
+ ...heart,
2663
+ x: p.x,
2664
+ y: p.y
2665
+ });
2666
+ }
2667
+ }
2668
+ if (changedStrokes.length) doc.transformStrokes(changedStrokes);
2669
+ }
2670
+ };
1402
2671
  var AddNoteCommand = class {
1403
2672
  constructor(note) {
1404
2673
  this.label = "add note";
@@ -1600,6 +2869,342 @@ var DeleteTimerCommand = class {
1600
2869
  doc.addTimers([cloneTimer(this.timer)]);
1601
2870
  }
1602
2871
  };
2872
+ /** Add/update/delete for semantic Rectangle objects (Phase 2). */
2873
+ var AddRectangleCommand = class {
2874
+ constructor(rect) {
2875
+ this.label = "add rectangle";
2876
+ this.rect = cloneRectangle(rect);
2877
+ }
2878
+ apply(doc) {
2879
+ doc.addRectangles([cloneRectangle(this.rect)]);
2880
+ }
2881
+ revert(doc) {
2882
+ doc.removeRectangles([this.rect.id]);
2883
+ }
2884
+ };
2885
+ var UpdateRectangleCommand = class {
2886
+ constructor(before, after) {
2887
+ this.label = "update rectangle";
2888
+ this.before = cloneRectangle(before);
2889
+ this.after = cloneRectangle(after);
2890
+ }
2891
+ apply(doc) {
2892
+ doc.setRectangle(cloneRectangle(this.after));
2893
+ }
2894
+ revert(doc) {
2895
+ doc.setRectangle(cloneRectangle(this.before));
2896
+ }
2897
+ };
2898
+ var DeleteRectangleCommand = class {
2899
+ constructor(rect) {
2900
+ this.label = "delete rectangle";
2901
+ this.rect = cloneRectangle(rect);
2902
+ }
2903
+ apply(doc) {
2904
+ doc.removeRectangles([this.rect.id]);
2905
+ }
2906
+ revert(doc) {
2907
+ doc.addRectangles([cloneRectangle(this.rect)]);
2908
+ }
2909
+ };
2910
+ /** Add/update/delete for semantic Ellipse objects (Phase 2). */
2911
+ var AddEllipseCommand = class {
2912
+ constructor(ellipse) {
2913
+ this.label = "add ellipse";
2914
+ this.ellipse = cloneEllipse(ellipse);
2915
+ }
2916
+ apply(doc) {
2917
+ doc.addEllipses([cloneEllipse(this.ellipse)]);
2918
+ }
2919
+ revert(doc) {
2920
+ doc.removeEllipses([this.ellipse.id]);
2921
+ }
2922
+ };
2923
+ var UpdateEllipseCommand = class {
2924
+ constructor(before, after) {
2925
+ this.label = "update ellipse";
2926
+ this.before = cloneEllipse(before);
2927
+ this.after = cloneEllipse(after);
2928
+ }
2929
+ apply(doc) {
2930
+ doc.setEllipse(cloneEllipse(this.after));
2931
+ }
2932
+ revert(doc) {
2933
+ doc.setEllipse(cloneEllipse(this.before));
2934
+ }
2935
+ };
2936
+ var DeleteEllipseCommand = class {
2937
+ constructor(ellipse) {
2938
+ this.label = "delete ellipse";
2939
+ this.ellipse = cloneEllipse(ellipse);
2940
+ }
2941
+ apply(doc) {
2942
+ doc.removeEllipses([this.ellipse.id]);
2943
+ }
2944
+ revert(doc) {
2945
+ doc.addEllipses([cloneEllipse(this.ellipse)]);
2946
+ }
2947
+ };
2948
+ /** Add/update/delete for semantic Line objects (Phase 4). */
2949
+ var AddLineCommand = class {
2950
+ constructor(line) {
2951
+ this.label = "add line";
2952
+ this.line = cloneLine(line);
2953
+ }
2954
+ apply(doc) {
2955
+ doc.addLines([cloneLine(this.line)]);
2956
+ }
2957
+ revert(doc) {
2958
+ doc.removeLines([this.line.id]);
2959
+ }
2960
+ };
2961
+ var UpdateLineCommand = class {
2962
+ constructor(before, after) {
2963
+ this.label = "update line";
2964
+ this.before = cloneLine(before);
2965
+ this.after = cloneLine(after);
2966
+ }
2967
+ apply(doc) {
2968
+ doc.setLine(cloneLine(this.after));
2969
+ }
2970
+ revert(doc) {
2971
+ doc.setLine(cloneLine(this.before));
2972
+ }
2973
+ };
2974
+ var DeleteLineCommand = class {
2975
+ constructor(line) {
2976
+ this.label = "delete line";
2977
+ this.line = cloneLine(line);
2978
+ }
2979
+ apply(doc) {
2980
+ doc.removeLines([this.line.id]);
2981
+ }
2982
+ revert(doc) {
2983
+ doc.addLines([cloneLine(this.line)]);
2984
+ }
2985
+ };
2986
+ /** Add/update/delete for semantic Arrow objects (Phase 4). */
2987
+ var AddArrowCommand = class {
2988
+ constructor(arrow) {
2989
+ this.label = "add arrow";
2990
+ this.arrow = cloneArrow(arrow);
2991
+ }
2992
+ apply(doc) {
2993
+ doc.addArrows([cloneArrow(this.arrow)]);
2994
+ }
2995
+ revert(doc) {
2996
+ doc.removeArrows([this.arrow.id]);
2997
+ }
2998
+ };
2999
+ var UpdateArrowCommand = class {
3000
+ constructor(before, after) {
3001
+ this.label = "update arrow";
3002
+ this.before = cloneArrow(before);
3003
+ this.after = cloneArrow(after);
3004
+ }
3005
+ apply(doc) {
3006
+ doc.setArrow(cloneArrow(this.after));
3007
+ }
3008
+ revert(doc) {
3009
+ doc.setArrow(cloneArrow(this.before));
3010
+ }
3011
+ };
3012
+ var DeleteArrowCommand = class {
3013
+ constructor(arrow) {
3014
+ this.label = "delete arrow";
3015
+ this.arrow = cloneArrow(arrow);
3016
+ }
3017
+ apply(doc) {
3018
+ doc.removeArrows([this.arrow.id]);
3019
+ }
3020
+ revert(doc) {
3021
+ doc.addArrows([cloneArrow(this.arrow)]);
3022
+ }
3023
+ };
3024
+ /** Add/update/delete for semantic Polygon objects (Phase 4) — Triangle/Diamond/Pentagon/Hexagon/Octagon. */
3025
+ var AddPolygonCommand = class {
3026
+ constructor(polygon) {
3027
+ this.label = "add polygon";
3028
+ this.polygon = clonePolygon(polygon);
3029
+ }
3030
+ apply(doc) {
3031
+ doc.addPolygons([clonePolygon(this.polygon)]);
3032
+ }
3033
+ revert(doc) {
3034
+ doc.removePolygons([this.polygon.id]);
3035
+ }
3036
+ };
3037
+ var UpdatePolygonCommand = class {
3038
+ constructor(before, after) {
3039
+ this.label = "update polygon";
3040
+ this.before = clonePolygon(before);
3041
+ this.after = clonePolygon(after);
3042
+ }
3043
+ apply(doc) {
3044
+ doc.setPolygon(clonePolygon(this.after));
3045
+ }
3046
+ revert(doc) {
3047
+ doc.setPolygon(clonePolygon(this.before));
3048
+ }
3049
+ };
3050
+ var DeletePolygonCommand = class {
3051
+ constructor(polygon) {
3052
+ this.label = "delete polygon";
3053
+ this.polygon = clonePolygon(polygon);
3054
+ }
3055
+ apply(doc) {
3056
+ doc.removePolygons([this.polygon.id]);
3057
+ }
3058
+ revert(doc) {
3059
+ doc.addPolygons([clonePolygon(this.polygon)]);
3060
+ }
3061
+ };
3062
+ /** Add/update/delete for semantic Star objects (Phase 4). */
3063
+ var AddStarCommand = class {
3064
+ constructor(star) {
3065
+ this.label = "add star";
3066
+ this.star = cloneStar(star);
3067
+ }
3068
+ apply(doc) {
3069
+ doc.addStars([cloneStar(this.star)]);
3070
+ }
3071
+ revert(doc) {
3072
+ doc.removeStars([this.star.id]);
3073
+ }
3074
+ };
3075
+ var UpdateStarCommand = class {
3076
+ constructor(before, after) {
3077
+ this.label = "update star";
3078
+ this.before = cloneStar(before);
3079
+ this.after = cloneStar(after);
3080
+ }
3081
+ apply(doc) {
3082
+ doc.setStar(cloneStar(this.after));
3083
+ }
3084
+ revert(doc) {
3085
+ doc.setStar(cloneStar(this.before));
3086
+ }
3087
+ };
3088
+ var DeleteStarCommand = class {
3089
+ constructor(star) {
3090
+ this.label = "delete star";
3091
+ this.star = cloneStar(star);
3092
+ }
3093
+ apply(doc) {
3094
+ doc.removeStars([this.star.id]);
3095
+ }
3096
+ revert(doc) {
3097
+ doc.addStars([cloneStar(this.star)]);
3098
+ }
3099
+ };
3100
+ /** Add/update/delete for semantic Heart objects (Phase 4). */
3101
+ var AddHeartCommand = class {
3102
+ constructor(heart) {
3103
+ this.label = "add heart";
3104
+ this.heart = cloneHeart(heart);
3105
+ }
3106
+ apply(doc) {
3107
+ doc.addHearts([cloneHeart(this.heart)]);
3108
+ }
3109
+ revert(doc) {
3110
+ doc.removeHearts([this.heart.id]);
3111
+ }
3112
+ };
3113
+ var UpdateHeartCommand = class {
3114
+ constructor(before, after) {
3115
+ this.label = "update heart";
3116
+ this.before = cloneHeart(before);
3117
+ this.after = cloneHeart(after);
3118
+ }
3119
+ apply(doc) {
3120
+ doc.setHeart(cloneHeart(this.after));
3121
+ }
3122
+ revert(doc) {
3123
+ doc.setHeart(cloneHeart(this.before));
3124
+ }
3125
+ };
3126
+ var DeleteHeartCommand = class {
3127
+ constructor(heart) {
3128
+ this.label = "delete heart";
3129
+ this.heart = cloneHeart(heart);
3130
+ }
3131
+ apply(doc) {
3132
+ doc.removeHearts([this.heart.id]);
3133
+ }
3134
+ revert(doc) {
3135
+ doc.addHearts([cloneHeart(this.heart)]);
3136
+ }
3137
+ };
3138
+ /** Add/update/delete for Groups (Phase 3). */
3139
+ var AddGroupCommand = class {
3140
+ constructor(group) {
3141
+ this.label = "group";
3142
+ this.group = cloneGroup(group);
3143
+ }
3144
+ apply(doc) {
3145
+ doc.addGroups([cloneGroup(this.group)]);
3146
+ }
3147
+ revert(doc) {
3148
+ doc.removeGroups([this.group.id]);
3149
+ }
3150
+ };
3151
+ var UpdateGroupCommand = class {
3152
+ constructor(before, after) {
3153
+ this.label = "update group";
3154
+ this.before = cloneGroup(before);
3155
+ this.after = cloneGroup(after);
3156
+ }
3157
+ apply(doc) {
3158
+ doc.setGroup(cloneGroup(this.after));
3159
+ }
3160
+ revert(doc) {
3161
+ doc.setGroup(cloneGroup(this.before));
3162
+ }
3163
+ };
3164
+ var DeleteGroupCommand = class {
3165
+ constructor(group) {
3166
+ this.label = "ungroup";
3167
+ this.group = cloneGroup(group);
3168
+ }
3169
+ apply(doc) {
3170
+ doc.removeGroups([this.group.id]);
3171
+ }
3172
+ revert(doc) {
3173
+ doc.addGroups([cloneGroup(this.group)]);
3174
+ }
3175
+ };
3176
+ var REORDER_LABELS = {
3177
+ forward: "bring forward",
3178
+ backward: "send backward",
3179
+ front: "bring to front",
3180
+ back: "send to back"
3181
+ };
3182
+ /**
3183
+ * Bring-forward / send-backward / bring-to-front / send-to-back (Phase 3) —
3184
+ * one command family covering all four directions rather than four
3185
+ * near-identical classes, since the only difference between them is which
3186
+ * `BoardDocument.reorder` direction to replay. Captures the full paint order
3187
+ * on first `apply` rather than in the constructor — `BoardDocument.order()`
3188
+ * needs the doc, which a `Command` only ever receives via `apply`/`revert` —
3189
+ * so `revert` can restore it exactly; redo re-runs the same `reorder` call,
3190
+ * which is deterministic because `revert` always restores the identical
3191
+ * starting order first.
3192
+ */
3193
+ var ReorderObjectCommand = class {
3194
+ constructor(id, direction) {
3195
+ this.id = id;
3196
+ this.direction = direction;
3197
+ this.before = null;
3198
+ this.label = REORDER_LABELS[direction];
3199
+ }
3200
+ apply(doc) {
3201
+ this.before ??= [...doc.order()];
3202
+ doc.reorder(this.id, this.direction);
3203
+ }
3204
+ revert(doc) {
3205
+ if (this.before) doc.applyRemoteChange({ orderChanged: this.before });
3206
+ }
3207
+ };
1603
3208
  var LockItemsCommand = class {
1604
3209
  constructor(targets, targetState, actor) {
1605
3210
  this.label = "toggle lock";
@@ -1627,10 +3232,38 @@ var LockItemsCommand = class {
1627
3232
  else if (type === "table") doc.setTableLocked(id, locked, by);
1628
3233
  else if (type === "image") doc.setImageLocked(id, locked, by);
1629
3234
  else if (type === "timer") doc.setTimerLocked(id, locked, by);
3235
+ else if (type === "rectangle") doc.setRectangleLocked(id, locked, by);
3236
+ else if (type === "ellipse") doc.setEllipseLocked(id, locked, by);
3237
+ else if (type === "group") doc.setGroupLocked(id, locked, by);
3238
+ else if (type === "line") doc.setLineLocked(id, locked, by);
3239
+ else if (type === "arrow") doc.setArrowLocked(id, locked, by);
3240
+ else if (type === "polygon") doc.setPolygonLocked(id, locked, by);
3241
+ else if (type === "star") doc.setStarLocked(id, locked, by);
3242
+ else if (type === "heart") doc.setHeartLocked(id, locked, by);
1630
3243
  }
1631
3244
  };
1632
3245
  //#endregion
1633
3246
  //#region src/core/history/history.ts
3247
+ /**
3248
+ * Several commands applied/reverted together as one undo step (Phase 3
3249
+ * consolidation — this exact class used to be hand-duplicated as a private
3250
+ * `CommandBatch` in `controller-internal.ts` and an exported
3251
+ * `ExtensionCommandBatch` in `interaction/tools/customTool.ts`; both now
3252
+ * import this one instead). Revert runs in reverse order, so a batch that
3253
+ * depends on ordering (e.g. add-then-reference) undoes cleanly.
3254
+ */
3255
+ var CommandBatch = class {
3256
+ constructor(label, commands) {
3257
+ this.label = label;
3258
+ this.commands = commands;
3259
+ }
3260
+ apply(doc) {
3261
+ for (const command of this.commands) command.apply(doc);
3262
+ }
3263
+ revert(doc) {
3264
+ for (const command of [...this.commands].reverse()) command.revert(doc);
3265
+ }
3266
+ };
1634
3267
  var LIMIT = 200;
1635
3268
  var History = class {
1636
3269
  constructor(doc) {
@@ -1699,6 +3332,14 @@ var ADDED = [
1699
3332
  ["tablesAdded", "tables"],
1700
3333
  ["imagesAdded", "images"],
1701
3334
  ["timersAdded", "timers"],
3335
+ ["rectanglesAdded", "rectangles"],
3336
+ ["ellipsesAdded", "ellipses"],
3337
+ ["groupsAdded", "groups"],
3338
+ ["linesAdded", "lines"],
3339
+ ["arrowsAdded", "arrows"],
3340
+ ["polygonsAdded", "polygons"],
3341
+ ["starsAdded", "stars"],
3342
+ ["heartsAdded", "hearts"],
1702
3343
  ["customObjectsAdded", "customObjects"]
1703
3344
  ];
1704
3345
  var UPDATED = [
@@ -1709,6 +3350,14 @@ var UPDATED = [
1709
3350
  ["tablesUpdated", "tables"],
1710
3351
  ["imagesUpdated", "images"],
1711
3352
  ["timersUpdated", "timers"],
3353
+ ["rectanglesUpdated", "rectangles"],
3354
+ ["ellipsesUpdated", "ellipses"],
3355
+ ["groupsUpdated", "groups"],
3356
+ ["linesUpdated", "lines"],
3357
+ ["arrowsUpdated", "arrows"],
3358
+ ["polygonsUpdated", "polygons"],
3359
+ ["starsUpdated", "stars"],
3360
+ ["heartsUpdated", "hearts"],
1712
3361
  ["customObjectsUpdated", "customObjects"]
1713
3362
  ];
1714
3363
  var REMOVED = [
@@ -1718,6 +3367,14 @@ var REMOVED = [
1718
3367
  ["tablesRemoved", "tables"],
1719
3368
  ["imagesRemoved", "images"],
1720
3369
  ["timersRemoved", "timers"],
3370
+ ["rectanglesRemoved", "rectangles"],
3371
+ ["ellipsesRemoved", "ellipses"],
3372
+ ["groupsRemoved", "groups"],
3373
+ ["linesRemoved", "lines"],
3374
+ ["arrowsRemoved", "arrows"],
3375
+ ["polygonsRemoved", "polygons"],
3376
+ ["starsRemoved", "stars"],
3377
+ ["heartsRemoved", "hearts"],
1721
3378
  ["customObjectsRemoved", "customObjects"]
1722
3379
  ];
1723
3380
  function toWireObject(collection, object) {
@@ -1787,22 +3444,74 @@ function ribbonEdges(points, baseWidth, handDrawn = true) {
1787
3444
  //#endregion
1788
3445
  //#region src/core/document/spatialIndex.ts
1789
3446
  var CELL = 8;
3447
+ /**
3448
+ * Non-stroke types have no separate "transformed" event the way strokes
3449
+ * do — any content replace (`setNote`/`setTable`/...) goes through the
3450
+ * type's single `*Updated` field regardless of whether it moved, so a
3451
+ * reindex on every update is unconditional (cheap arithmetic per object,
3452
+ * matching `objectBounds.ts`'s own "no caching needed" precedent for these
3453
+ * types — not worth distinguishing "moved" from "just retexted").
3454
+ */
3455
+ var ADDED_FIELDS = [
3456
+ "added",
3457
+ "notesAdded",
3458
+ "textAdded",
3459
+ "tablesAdded",
3460
+ "imagesAdded",
3461
+ "timersAdded",
3462
+ "customObjectsAdded",
3463
+ "rectanglesAdded",
3464
+ "ellipsesAdded"
3465
+ ];
3466
+ var REMOVED_FIELDS = [
3467
+ "removed",
3468
+ "notesRemoved",
3469
+ "textRemoved",
3470
+ "tablesRemoved",
3471
+ "imagesRemoved",
3472
+ "timersRemoved",
3473
+ "customObjectsRemoved",
3474
+ "rectanglesRemoved",
3475
+ "ellipsesRemoved"
3476
+ ];
3477
+ var UPDATED_FIELDS = [
3478
+ "notesUpdated",
3479
+ "textUpdated",
3480
+ "tablesUpdated",
3481
+ "imagesUpdated",
3482
+ "timersUpdated",
3483
+ "customObjectsUpdated",
3484
+ "rectanglesUpdated",
3485
+ "ellipsesUpdated"
3486
+ ];
1790
3487
  var SpatialIndex = class {
1791
3488
  constructor(doc) {
1792
3489
  this.doc = doc;
1793
3490
  this.cells = /* @__PURE__ */ new Map();
1794
- this.strokeCells = /* @__PURE__ */ new Map();
3491
+ this.objectCells = /* @__PURE__ */ new Map();
1795
3492
  this.unsubscribe = doc.subscribe((change) => {
1796
- for (const id of change.removed) this.remove(id);
1797
- for (const stroke of change.added) this.insert(stroke.id);
3493
+ for (const field of REMOVED_FIELDS) for (const id of change[field]) this.remove(id);
3494
+ for (const field of ADDED_FIELDS) for (const object of change[field]) this.insert(object.id);
1798
3495
  for (const stroke of change.transformed) {
1799
3496
  this.remove(stroke.id);
1800
3497
  this.insert(stroke.id);
1801
3498
  }
3499
+ for (const field of UPDATED_FIELDS) for (const object of change[field]) {
3500
+ this.remove(object.id);
3501
+ this.insert(object.id);
3502
+ }
1802
3503
  });
1803
3504
  for (const stroke of doc.all()) this.insert(stroke.id);
1804
- }
1805
- /** Ids of strokes whose bbox may overlap the query rect. */
3505
+ for (const note of doc.allNotes()) this.insert(note.id);
3506
+ for (const text of doc.allTexts()) this.insert(text.id);
3507
+ for (const table of doc.allTables()) this.insert(table.id);
3508
+ for (const image of doc.allImages()) this.insert(image.id);
3509
+ for (const timer of doc.allTimers()) this.insert(timer.id);
3510
+ for (const object of doc.allCustomObjects()) this.insert(object.id);
3511
+ for (const rect of doc.allRectangles()) this.insert(rect.id);
3512
+ for (const ellipse of doc.allEllipses()) this.insert(ellipse.id);
3513
+ }
3514
+ /** Ids of content objects (any type except groups) whose bbox may overlap the query rect. */
1806
3515
  query(minX, minY, maxX, maxY) {
1807
3516
  const result = /* @__PURE__ */ new Set();
1808
3517
  for (const key of cellsOf({
@@ -1819,13 +3528,13 @@ var SpatialIndex = class {
1819
3528
  dispose() {
1820
3529
  this.unsubscribe();
1821
3530
  this.cells.clear();
1822
- this.strokeCells.clear();
3531
+ this.objectCells.clear();
1823
3532
  }
1824
3533
  insert(id) {
1825
3534
  const box = this.doc.bbox(id);
1826
3535
  if (!box) return;
1827
3536
  const keys = cellsOf(box);
1828
- this.strokeCells.set(id, keys);
3537
+ this.objectCells.set(id, keys);
1829
3538
  for (const key of keys) {
1830
3539
  let bucket = this.cells.get(key);
1831
3540
  if (!bucket) {
@@ -1836,9 +3545,9 @@ var SpatialIndex = class {
1836
3545
  }
1837
3546
  }
1838
3547
  remove(id) {
1839
- const keys = this.strokeCells.get(id);
3548
+ const keys = this.objectCells.get(id);
1840
3549
  if (!keys) return;
1841
- this.strokeCells.delete(id);
3550
+ this.objectCells.delete(id);
1842
3551
  for (const key of keys) {
1843
3552
  const bucket = this.cells.get(key);
1844
3553
  if (bucket) {
@@ -1943,6 +3652,135 @@ function clamp(value, min, max) {
1943
3652
  return Math.min(max, Math.max(min, value));
1944
3653
  }
1945
3654
  //#endregion
3655
+ //#region src/core/shapes/lineGeometry.ts
3656
+ var BARB_ANGLE = Math.PI * (25 / 180);
3657
+ /** Two barb segments (each `[fromPoint, tip]`), or `[]` for `head: "none"`. */
3658
+ function arrowHeadBarbs(start, end, head) {
3659
+ if (head === "none") return [];
3660
+ const len = Math.hypot(end.x - start.x, end.y - start.y);
3661
+ if (len < 1e-6) return [];
3662
+ const dirX = (end.x - start.x) / len;
3663
+ const dirY = (end.y - start.y) / len;
3664
+ const headLen = Math.min(4, Math.max(1.2, len * .25));
3665
+ const barb = (sign) => {
3666
+ const cos = Math.cos(BARB_ANGLE * sign);
3667
+ const sin = Math.sin(BARB_ANGLE * sign);
3668
+ return {
3669
+ x: end.x - headLen * (dirX * cos - dirY * sin),
3670
+ y: end.y - headLen * (dirX * sin + dirY * cos)
3671
+ };
3672
+ };
3673
+ return [[barb(1), end], [barb(-1), end]];
3674
+ }
3675
+ //#endregion
3676
+ //#region src/core/shapes/polygonGeometry.ts
3677
+ /**
3678
+ * The tuned per-`sides` starting angle that makes each regular-polygon kind
3679
+ * look right inscribed in its bounding box (a point-up triangle, a
3680
+ * flat-sided diamond, a point-up pentagon/hexagon, a flat-topped octagon) —
3681
+ * extracted unchanged from the legacy shape-stroke tool's own per-call
3682
+ * arguments, which were already tuned and shipped. Any `sides` count not
3683
+ * listed falls back to the common "point at top" case.
3684
+ */
3685
+ function polygonStartAngle(sides) {
3686
+ switch (sides) {
3687
+ case 3: return Math.PI / 2;
3688
+ case 4: return 0;
3689
+ case 8: return Math.PI / 8;
3690
+ default: return Math.PI / 2;
3691
+ }
3692
+ }
3693
+ /**
3694
+ * Rescales/recenters an arbitrary point set so its own bounding box exactly
3695
+ * fills `[-width/2, width/2] × [-height/2, height/2]`, centered at the
3696
+ * origin. Needed because "inscribed on a radius-rx,ry ellipse" (the natural
3697
+ * way to place N evenly-spaced vertices) does *not* by itself guarantee a
3698
+ * full, centered fit: a shape only touches every edge of its box when it
3699
+ * has a vertex at each of the four cardinal angles (true for a square/
3700
+ * diamond/octagon-with-the-right-start-angle) — a triangle or pentagon's
3701
+ * *actual* bounding box is both smaller than and off-center from the
3702
+ * nominal rx,ry ellipse (verified: an upward triangle's own bounding box is
3703
+ * ~13% narrower than its circumradius, and vertically off-center by a
3704
+ * quarter of its own height). Every semantic shape's declared `width`/
3705
+ * `height` has to be the shape's *real* extent — `objectBounds.ts` and
3706
+ * rotate-about-center math both assume it — so every generator below routes
3707
+ * through this instead of leaving each one to reinvent (or skip) the fix.
3708
+ */
3709
+ function fitToBox(points, width, height) {
3710
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
3711
+ for (const p of points) {
3712
+ if (p.x < minX) minX = p.x;
3713
+ if (p.x > maxX) maxX = p.x;
3714
+ if (p.y < minY) minY = p.y;
3715
+ if (p.y > maxY) maxY = p.y;
3716
+ }
3717
+ const rawW = maxX - minX || 1;
3718
+ const rawH = maxY - minY || 1;
3719
+ const cx = (minX + maxX) / 2;
3720
+ const cy = (minY + maxY) / 2;
3721
+ return points.map((p) => ({
3722
+ x: (p.x - cx) / rawW * width,
3723
+ y: (p.y - cy) / rawH * height
3724
+ }));
3725
+ }
3726
+ /**
3727
+ * A regular `sides`-gon inscribed in a `width`×`height` box, centered at
3728
+ * the origin and fit exactly to it via `fitToBox` — independently scaled
3729
+ * per axis (like an ellipse inscribed in a non-square box), not a true
3730
+ * regular polygon forced square. `sides: 4` with the diamond start angle
3731
+ * (0) produces the four cardinal points of the box — a diamond is exactly
3732
+ * this case, not a separate shape.
3733
+ */
3734
+ function regularPolygonPoints(sides, width, height, startAngle) {
3735
+ const raw = [];
3736
+ for (let i = 0; i < sides; i++) {
3737
+ const theta = startAngle + i / sides * Math.PI * 2;
3738
+ raw.push({
3739
+ x: Math.cos(theta),
3740
+ y: Math.sin(theta)
3741
+ });
3742
+ }
3743
+ return fitToBox(raw, width, height);
3744
+ }
3745
+ /**
3746
+ * A `points`-pointed star inscribed in a `width`×`height` box, centered at
3747
+ * the origin and fit exactly to it via `fitToBox` — alternating outer/inner
3748
+ * vertices, `innerRatio` of the outer radius. Matches the legacy tool's own
3749
+ * tuned defaults (5 points, 0.42).
3750
+ */
3751
+ function starPoints(points, innerRatio, width, height) {
3752
+ const total = points * 2;
3753
+ const raw = [];
3754
+ for (let i = 0; i < total; i++) {
3755
+ const isOuter = i % 2 === 0;
3756
+ const theta = -Math.PI / 2 + i / total * Math.PI * 2;
3757
+ const r = isOuter ? 1 : innerRatio;
3758
+ raw.push({
3759
+ x: Math.cos(theta) * r,
3760
+ y: Math.sin(theta) * r
3761
+ });
3762
+ }
3763
+ return fitToBox(raw, width, height);
3764
+ }
3765
+ var HEART_SEGMENTS = 64;
3766
+ /**
3767
+ * A heart inscribed in a `width`×`height` box, centered at the origin and
3768
+ * fit exactly to it via `fitToBox` — the standard parametric heart curve
3769
+ * (`x = 16 sin³t`, `y = 13 cos t − 5 cos 2t − 2 cos 3t − cos 4t`, matching
3770
+ * the legacy shape-stroke tool's own formula).
3771
+ */
3772
+ function heartPoints(width, height) {
3773
+ const raw = [];
3774
+ for (let i = 0; i < HEART_SEGMENTS; i++) {
3775
+ const t = i / HEART_SEGMENTS * Math.PI * 2;
3776
+ raw.push({
3777
+ x: 16 * Math.sin(t) ** 3,
3778
+ y: -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t))
3779
+ });
3780
+ }
3781
+ return fitToBox(raw, width, height);
3782
+ }
3783
+ //#endregion
1946
3784
  //#region src/persistence/serialization/svg.ts
1947
3785
  /** Matches the key light used by the board shader and note shadows. */
1948
3786
  var LIGHT = {
@@ -1980,15 +3818,29 @@ function documentToSVG(doc, now = 0, registry, backgroundColor = DEFAULT_BACKGRO
1980
3818
  const images = doc.images ?? [];
1981
3819
  const timers = doc.timers ?? [];
1982
3820
  const customObjects = doc.customObjects ?? [];
1983
- const bounds = contentBounds(doc.strokes, notes, texts, tables, images, timers, customObjects);
3821
+ const rectangles = doc.rectangles ?? [];
3822
+ const ellipses = doc.ellipses ?? [];
3823
+ const lines = doc.lines ?? [];
3824
+ const arrows = doc.arrows ?? [];
3825
+ const polygons = doc.polygons ?? [];
3826
+ const stars = doc.stars ?? [];
3827
+ const hearts = doc.hearts ?? [];
3828
+ const bounds = contentBounds(doc.strokes, notes, texts, tables, images, timers, customObjects, rectangles, ellipses, lines, arrows, polygons, stars, hearts);
1984
3829
  const view = `${fmt(bounds.minX)} ${fmt(bounds.minY)} ${fmt(bounds.width)} ${fmt(bounds.height)}`;
1985
3830
  const defs = notes.map(noteShadowFilter).join("\n");
1986
3831
  const highlights = doc.strokes.filter((s) => s.tool === "highlighter");
1987
3832
  const ink = doc.strokes.filter((s) => s.tool !== "highlighter");
1988
3833
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${view}">\n<defs>\n${defs}\n</defs>\n${[
1989
- `<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)}"/>`,
1990
3835
  ...images.map((image) => imageToElement(image, resolvedAssets)),
1991
3836
  ...tables.map(tableToGroup),
3837
+ ...rectangles.map(rectangleToElement),
3838
+ ...ellipses.map(ellipseToElement),
3839
+ ...polygons.map(polygonToElement),
3840
+ ...stars.map(starToElement),
3841
+ ...hearts.map(heartToElement),
3842
+ ...lines.map(lineToElement),
3843
+ ...arrows.map(arrowToGroup),
1992
3844
  ...highlights.flatMap(strokeToPaths),
1993
3845
  ...ink.flatMap(strokeToPaths),
1994
3846
  ...texts.map(textToElement),
@@ -2007,7 +3859,7 @@ function customObjectToGroup(object, registry, resolvedAssets) {
2007
3859
  const definition = registry?.objectType(object.type);
2008
3860
  if (definition) try {
2009
3861
  const scene = definition.describe(object, { selected: false });
2010
- 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>`;
2011
3863
  } catch {}
2012
3864
  return fallbackToGroup(object);
2013
3865
  }
@@ -2031,17 +3883,17 @@ function sceneNodeToSVG(node, parent, resolvedAssets) {
2031
3883
  case "rect": {
2032
3884
  const svgY = -node.y - node.height;
2033
3885
  const rx = node.cornerRadius ? ` rx="${fmt(node.cornerRadius)}"` : "";
2034
- const stroke = node.stroke ? ` stroke="${node.stroke}" stroke-width="${fmt(node.strokeWidth ?? .1)}"` : "";
2035
- 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}/>`;
2036
3888
  }
2037
3889
  case "text": {
2038
3890
  const anchor = node.align === "center" ? ` text-anchor="middle"` : node.align === "end" ? ` text-anchor="end"` : "";
2039
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("");
2040
- 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>`;
2041
3893
  }
2042
3894
  case "ellipse": {
2043
- const stroke = node.stroke ? ` stroke="${node.stroke}" stroke-width="${fmt(node.strokeWidth ?? .1)}"` : "";
2044
- 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}/>`;
2045
3897
  }
2046
3898
  case "image": {
2047
3899
  const svgY = -node.y - node.height;
@@ -2072,7 +3924,7 @@ function fallbackToGroup(object) {
2072
3924
  const label = object.fallback.label;
2073
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>` : "";
2074
3926
  return [
2075
- `<g data-id="${object.id}" data-fallback="true"${svgTransformAttr(object.transform)}>`,
3927
+ `<g data-id="${escapeXML(object.id)}" data-fallback="true"${svgTransformAttr(object.transform)}>`,
2076
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"/>`,
2077
3929
  labelEl,
2078
3930
  `</g>`
@@ -2096,7 +3948,7 @@ function strokeToPaths(stroke) {
2096
3948
  const d = `M ${left.join(" L ")} L ${right.join(" L ")} Z`;
2097
3949
  const opacity = run.opacity * baseOpacity;
2098
3950
  const opacityAttr = opacity < 1 ? ` fill-opacity="${fmt(opacity)}"` : "";
2099
- 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}/>`);
2100
3952
  }
2101
3953
  return paths;
2102
3954
  }
@@ -2118,14 +3970,14 @@ function splitByOpacity(edges) {
2118
3970
  function textToElement(block) {
2119
3971
  const top = -block.y;
2120
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("");
2121
- 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>`;
2122
3974
  }
2123
3975
  function noteShadowFilter(note) {
2124
3976
  const dx = -LIGHT.x / LIGHT.z * note.zOffset;
2125
3977
  const dy = LIGHT.y / LIGHT.z * note.zOffset;
2126
3978
  const blur = (.35 + note.zOffset * .55) / 2;
2127
3979
  const opacity = Math.max(.05, .26 - note.zOffset * .028);
2128
- 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>`;
2129
3981
  }
2130
3982
  function noteToGroup(note) {
2131
3983
  const left = note.x - note.size / 2;
@@ -2135,7 +3987,7 @@ function noteToGroup(note) {
2135
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("");
2136
3988
  return [
2137
3989
  `<g>`,
2138
- `<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)})"/>`,
2139
3991
  `<text font-family="ui-rounded, 'Segoe UI', sans-serif" font-size="${fmt(fontSize)}" font-weight="500" fill="#37352F">${tspans}</text>`,
2140
3992
  `</g>`
2141
3993
  ].join("");
@@ -2166,8 +4018,8 @@ function tableToGroup(table) {
2166
4018
  const left = table.x;
2167
4019
  const top = -table.y;
2168
4020
  const headerH = table.rowHeights[0] ?? height / table.rows;
2169
- const borderColor = table.color || "#D1D5DB";
2170
- const bgColor = table.backgroundColor || "#FFFFFF";
4021
+ const borderColor = escapeXML(table.color || "#D1D5DB");
4022
+ const bgColor = escapeXML(table.backgroundColor || "#FFFFFF");
2171
4023
  const elements = [];
2172
4024
  elements.push(`<rect x="${fmt(left)}" y="${fmt(top)}" width="${fmt(width)}" height="${fmt(height)}" fill="${bgColor}" rx="0.5"/>`);
2173
4025
  elements.push(`<rect x="${fmt(left)}" y="${fmt(top)}" width="${fmt(width)}" height="${fmt(headerH)}" fill="#F3F4F6" rx="0.5"/>`);
@@ -2195,14 +4047,14 @@ function tableToGroup(table) {
2195
4047
  const padY = Math.min(.3, rowH * .12);
2196
4048
  const fontSize = Math.min(1.25, rowH * .45);
2197
4049
  const weight = r === 0 ? "bold" : "500";
2198
- const color = r === 0 ? "#111827" : table.color ?? "#374151";
4050
+ const color = escapeXML(r === 0 ? "#111827" : table.color ?? "#374151");
2199
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>`);
2200
4052
  }
2201
4053
  cellLeft += colW;
2202
4054
  }
2203
4055
  cellTop += rowH;
2204
4056
  }
2205
- 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>`;
2206
4058
  }
2207
4059
  function timerToGroup(timer, now) {
2208
4060
  const r = timer.size / 2;
@@ -2210,7 +4062,7 @@ function timerToGroup(timer, now) {
2210
4062
  const cy = -timer.y;
2211
4063
  const label = formatTimer(timerRemaining(timer, now));
2212
4064
  return [
2213
- `<g data-id="${timer.id}">`,
4065
+ `<g data-id="${escapeXML(timer.id)}">`,
2214
4066
  `<circle cx="${fmt(cx + .4)}" cy="${fmt(cy + .4)}" r="${fmt(r)}" fill="#000" fill-opacity="0.12"/>`,
2215
4067
  `<circle cx="${fmt(cx)}" cy="${fmt(cy)}" r="${fmt(r)}" fill="#E11D48"/>`,
2216
4068
  `<circle cx="${fmt(cx)}" cy="${fmt(cy)}" r="${fmt(r * .72)}" fill="#FAFAF9"/>`,
@@ -2223,12 +4075,96 @@ function imageToElement(image, resolvedAssets) {
2223
4075
  const top = -image.y - image.height / 2;
2224
4076
  if (image.ref) {
2225
4077
  const dataUri = resolvedAssets?.get(image.ref);
2226
- if (!dataUri) return `<g data-id="${image.id}" data-fallback="true">${imagePlaceholderElement(left, top, image.width, image.height, image.name)}</g>`;
2227
- 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"/>`;
2228
4080
  }
2229
4081
  return `<image href="${escapeXML(image.src)}" x="${fmt(left)}" y="${fmt(top)}" width="${fmt(image.width)}" height="${fmt(image.height)}" preserveAspectRatio="none"/>`;
2230
4082
  }
2231
- function contentBounds(strokes, notes, texts, tables = [], images = [], timers = [], customObjects = []) {
4083
+ /** (x, y) is the top edge (core/shapes/shapeObjects.ts) — same convention as TableBlock, so `svgY = -y` directly, no height offset. */
4084
+ /**
4085
+ * SVG `transform` attribute for a top-edge, Y-up shape's `rotation` (about
4086
+ * its own center, per `shapeObjects.ts`'s convention) — board-space
4087
+ * `rotationAbout` fed through the existing `svgTransformAttr` F·M·F
4088
+ * conversion, exactly how `sceneNodeToSVG`'s identical-convention `rect`
4089
+ * case already does it. Shared by every top-edge shape kind (Rectangle,
4090
+ * Ellipse, and Phase 4's Polygon/Star/Heart), not just these two.
4091
+ */
4092
+ function shapeRotationAttr(x, y, width, height, rotation) {
4093
+ if (!rotation) return "";
4094
+ return svgTransformAttr(rotationAbout(rotation, x + width / 2, y - height / 2));
4095
+ }
4096
+ function opacityAttrOf(opacity) {
4097
+ return opacity !== void 0 && opacity < 1 ? ` opacity="${fmt(opacity)}"` : "";
4098
+ }
4099
+ function rectangleToElement(rect) {
4100
+ const svgY = -rect.y;
4101
+ const stroke = rect.stroke ? ` stroke="${escapeXML(rect.stroke)}" stroke-width="${fmt(rect.strokeWidth ?? .12)}"` : "";
4102
+ const rx = rect.cornerRadius ? ` rx="${fmt(rect.cornerRadius)}"` : "";
4103
+ const transform = shapeRotationAttr(rect.x, rect.y, rect.width, rect.height, rect.rotation);
4104
+ const opacity = opacityAttrOf(rect.opacity);
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}/>`;
4106
+ }
4107
+ function ellipseToElement(ellipse) {
4108
+ const cx = ellipse.x + ellipse.width / 2;
4109
+ const cy = -ellipse.y + ellipse.height / 2;
4110
+ const stroke = ellipse.stroke ? ` stroke="${escapeXML(ellipse.stroke)}" stroke-width="${fmt(ellipse.strokeWidth ?? .12)}"` : "";
4111
+ const transform = shapeRotationAttr(ellipse.x, ellipse.y, ellipse.width, ellipse.height, ellipse.rotation);
4112
+ const opacity = opacityAttrOf(ellipse.opacity);
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}/>`;
4114
+ }
4115
+ /**
4116
+ * A regular N-gon inscribed in the same top-edge/Y-up bounding box
4117
+ * Rectangle uses — points are authored unrotated (absolute, y-flipped),
4118
+ * with `transform` handling rotation about the shape's own center exactly
4119
+ * like `rectangleToElement`/`ellipseToElement` already do, so this reuses
4120
+ * that same proven pattern rather than rotating points by hand.
4121
+ */
4122
+ function polygonToElement(polygon) {
4123
+ const cx = polygon.x + polygon.width / 2;
4124
+ const cy = polygon.y - polygon.height / 2;
4125
+ const svgPoints = regularPolygonPoints(polygon.sides, polygon.width, polygon.height, polygonStartAngle(polygon.sides)).map((p) => `${fmt(cx + p.x)},${fmt(-(cy + p.y))}`).join(" ");
4126
+ const stroke = polygon.stroke ? ` stroke="${escapeXML(polygon.stroke)}" stroke-width="${fmt(polygon.strokeWidth ?? SHAPE_DEFAULT_STROKE_WIDTH)}"` : "";
4127
+ const transform = shapeRotationAttr(polygon.x, polygon.y, polygon.width, polygon.height, polygon.rotation);
4128
+ const opacity = opacityAttrOf(polygon.opacity);
4129
+ return `<polygon data-id="${escapeXML(polygon.id)}" points="${svgPoints}" fill="${escapeXML(polygon.fill ?? "none")}"${stroke}${opacity}${transform}/>`;
4130
+ }
4131
+ /** Same convention/pattern as `polygonToElement` — a `points`-vertex star with `innerRadiusRatio` (`polygonGeometry.ts`'s `starPoints`). */
4132
+ function starToElement(star) {
4133
+ const cx = star.x + star.width / 2;
4134
+ const cy = star.y - star.height / 2;
4135
+ const svgPoints = starPoints(star.points, star.innerRadiusRatio, star.width, star.height).map((p) => `${fmt(cx + p.x)},${fmt(-(cy + p.y))}`).join(" ");
4136
+ const stroke = star.stroke ? ` stroke="${escapeXML(star.stroke)}" stroke-width="${fmt(star.strokeWidth ?? SHAPE_DEFAULT_STROKE_WIDTH)}"` : "";
4137
+ const transform = shapeRotationAttr(star.x, star.y, star.width, star.height, star.rotation);
4138
+ const opacity = opacityAttrOf(star.opacity);
4139
+ return `<polygon data-id="${escapeXML(star.id)}" points="${svgPoints}" fill="${escapeXML(star.fill ?? "none")}"${stroke}${opacity}${transform}/>`;
4140
+ }
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). */
4142
+ function heartToElement(heart) {
4143
+ const cx = heart.x + heart.width / 2;
4144
+ const cy = heart.y - heart.height / 2;
4145
+ const svgPoints = heartPoints(heart.width, heart.height).map((p) => `${fmt(cx + p.x)},${fmt(-(cy + p.y))}`).join(" ");
4146
+ const stroke = heart.stroke ? ` stroke="${escapeXML(heart.stroke)}" stroke-width="${fmt(heart.strokeWidth ?? SHAPE_DEFAULT_STROKE_WIDTH)}"` : "";
4147
+ const transform = shapeRotationAttr(heart.x, heart.y, heart.width, heart.height, heart.rotation);
4148
+ const opacity = opacityAttrOf(heart.opacity);
4149
+ return `<polygon data-id="${escapeXML(heart.id)}" points="${svgPoints}" fill="${escapeXML(heart.fill ?? "none")}"${stroke}${opacity}${transform}/>`;
4150
+ }
4151
+ /** No rotation/box concept (see shapeObjects.ts's header) — the two endpoints are emitted directly, y-flipped like every other coordinate here. */
4152
+ function lineToElement(line) {
4153
+ const stroke = escapeXML(line.stroke ?? SHAPE_DEFAULT_STROKE);
4154
+ const width = line.strokeWidth ?? SHAPE_DEFAULT_STROKE_WIDTH;
4155
+ const opacity = opacityAttrOf(line.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}/>`;
4157
+ }
4158
+ /** Shaft plus head barbs — same `arrowHeadBarbs` the canvas renderer uses, so the two never drift. */
4159
+ function arrowToGroup(arrow) {
4160
+ const stroke = escapeXML(arrow.stroke ?? SHAPE_DEFAULT_STROKE);
4161
+ const width = arrow.strokeWidth ?? SHAPE_DEFAULT_STROKE_WIDTH;
4162
+ const opacity = opacityAttrOf(arrow.opacity);
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)}"/>`;
4164
+ const segments = [segment(arrow.start, arrow.end), ...arrowHeadBarbs(arrow.start, arrow.end, arrow.head).map(([a, b]) => segment(a, b))];
4165
+ return `<g data-id="${escapeXML(arrow.id)}"${opacity}>${segments.join("")}</g>`;
4166
+ }
4167
+ function contentBounds(strokes, notes, texts, tables = [], images = [], timers = [], customObjects = [], rectangles = [], ellipses = [], lines = [], arrows = [], polygons = [], stars = [], hearts = []) {
2232
4168
  let minX = Infinity;
2233
4169
  let minY = Infinity;
2234
4170
  let maxX = -Infinity;
@@ -2290,6 +4226,34 @@ function contentBounds(strokes, notes, texts, tables = [], images = [], timers =
2290
4226
  grow(p.x, -p.y);
2291
4227
  }
2292
4228
  }
4229
+ for (const rect of rectangles) {
4230
+ grow(rect.x, -rect.y);
4231
+ grow(rect.x + rect.width, -rect.y + rect.height);
4232
+ }
4233
+ for (const ellipse of ellipses) {
4234
+ grow(ellipse.x, -ellipse.y);
4235
+ grow(ellipse.x + ellipse.width, -ellipse.y + ellipse.height);
4236
+ }
4237
+ for (const line of lines) {
4238
+ grow(line.start.x, -line.start.y);
4239
+ grow(line.end.x, -line.end.y);
4240
+ }
4241
+ for (const arrow of arrows) {
4242
+ grow(arrow.start.x, -arrow.start.y);
4243
+ grow(arrow.end.x, -arrow.end.y);
4244
+ }
4245
+ for (const polygon of polygons) {
4246
+ grow(polygon.x, -polygon.y);
4247
+ grow(polygon.x + polygon.width, -polygon.y + polygon.height);
4248
+ }
4249
+ for (const star of stars) {
4250
+ grow(star.x, -star.y);
4251
+ grow(star.x + star.width, -star.y + star.height);
4252
+ }
4253
+ for (const heart of hearts) {
4254
+ grow(heart.x, -heart.y);
4255
+ grow(heart.x + heart.width, -heart.y + heart.height);
4256
+ }
2293
4257
  if (minX > maxX) return {
2294
4258
  minX: 0,
2295
4259
  minY: 0,
@@ -2314,4 +4278,4 @@ function fmt(n) {
2314
4278
  var SDK_PACKAGE_NAME = "@scrawl-board/board";
2315
4279
  var SDK_DEVELOPMENT_VERSION = "0.0.0-development";
2316
4280
  //#endregion
2317
- 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, AddImageCommand, AddNoteCommand, AddStrokesCommand, AddTableCommand, AddTextCommand, AddTimerCommand, AssetResolutionError, BEACON_INSET, 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, 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, UpdateImageCommand, UpdateNoteCommand, UpdateTableCommand, UpdateTextCommand, UpdateTimerCommand, apply, applyItemLock, assetRef, avgScale, canUnlockItem, changeToOps, clampAssetCacheBytes, cloneCustomObject, cloneImage, cloneNote, 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 };