@memberjunction/ng-whiteboard 0.0.1 → 5.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +302 -28
  2. package/dist/lib/whiteboard-agent-sees-popover.component.d.ts +31 -0
  3. package/dist/lib/whiteboard-agent-sees-popover.component.d.ts.map +1 -0
  4. package/dist/lib/whiteboard-agent-sees-popover.component.js +132 -0
  5. package/dist/lib/whiteboard-agent-sees-popover.component.js.map +1 -0
  6. package/dist/lib/whiteboard-board.component.d.ts +458 -0
  7. package/dist/lib/whiteboard-board.component.d.ts.map +1 -0
  8. package/dist/lib/whiteboard-board.component.js +2357 -0
  9. package/dist/lib/whiteboard-board.component.js.map +1 -0
  10. package/dist/lib/whiteboard-context-menu.d.ts +57 -0
  11. package/dist/lib/whiteboard-context-menu.d.ts.map +1 -0
  12. package/dist/lib/whiteboard-context-menu.js +69 -0
  13. package/dist/lib/whiteboard-context-menu.js.map +1 -0
  14. package/dist/lib/whiteboard-export.d.ts +94 -0
  15. package/dist/lib/whiteboard-export.d.ts.map +1 -0
  16. package/dist/lib/whiteboard-export.js +592 -0
  17. package/dist/lib/whiteboard-export.js.map +1 -0
  18. package/dist/lib/whiteboard-host.component.d.ts +174 -0
  19. package/dist/lib/whiteboard-host.component.d.ts.map +1 -0
  20. package/dist/lib/whiteboard-host.component.js +758 -0
  21. package/dist/lib/whiteboard-host.component.js.map +1 -0
  22. package/dist/lib/whiteboard-pages.component.d.ts +83 -0
  23. package/dist/lib/whiteboard-pages.component.d.ts.map +1 -0
  24. package/dist/lib/whiteboard-pages.component.js +211 -0
  25. package/dist/lib/whiteboard-pages.component.js.map +1 -0
  26. package/dist/lib/whiteboard-snapshot.component.d.ts +30 -0
  27. package/dist/lib/whiteboard-snapshot.component.d.ts.map +1 -0
  28. package/dist/lib/whiteboard-snapshot.component.js +91 -0
  29. package/dist/lib/whiteboard-snapshot.component.js.map +1 -0
  30. package/dist/lib/whiteboard-srcdoc.pipe.d.ts +60 -0
  31. package/dist/lib/whiteboard-srcdoc.pipe.d.ts.map +1 -0
  32. package/dist/lib/whiteboard-srcdoc.pipe.js +75 -0
  33. package/dist/lib/whiteboard-srcdoc.pipe.js.map +1 -0
  34. package/dist/lib/whiteboard-state.d.ts +1113 -0
  35. package/dist/lib/whiteboard-state.d.ts.map +1 -0
  36. package/dist/lib/whiteboard-state.js +1396 -0
  37. package/dist/lib/whiteboard-state.js.map +1 -0
  38. package/dist/lib/whiteboard-toolbar.component.d.ts +95 -0
  39. package/dist/lib/whiteboard-toolbar.component.d.ts.map +1 -0
  40. package/dist/lib/whiteboard-toolbar.component.js +344 -0
  41. package/dist/lib/whiteboard-toolbar.component.js.map +1 -0
  42. package/dist/lib/whiteboard-tools.d.ts +103 -0
  43. package/dist/lib/whiteboard-tools.d.ts.map +1 -0
  44. package/dist/lib/whiteboard-tools.js +748 -0
  45. package/dist/lib/whiteboard-tools.js.map +1 -0
  46. package/dist/lib/whiteboard-widget-bridge.d.ts +217 -0
  47. package/dist/lib/whiteboard-widget-bridge.d.ts.map +1 -0
  48. package/dist/lib/whiteboard-widget-bridge.js +254 -0
  49. package/dist/lib/whiteboard-widget-bridge.js.map +1 -0
  50. package/dist/lib/whiteboard-zoom.component.d.ts +55 -0
  51. package/dist/lib/whiteboard-zoom.component.d.ts.map +1 -0
  52. package/dist/lib/whiteboard-zoom.component.js +139 -0
  53. package/dist/lib/whiteboard-zoom.component.js.map +1 -0
  54. package/dist/lib/whiteboard.module.d.ts +29 -0
  55. package/dist/lib/whiteboard.module.d.ts.map +1 -0
  56. package/dist/lib/whiteboard.module.js +78 -0
  57. package/dist/lib/whiteboard.module.js.map +1 -0
  58. package/dist/public-api.d.ts +49 -0
  59. package/dist/public-api.d.ts.map +1 -0
  60. package/dist/public-api.js +59 -0
  61. package/dist/public-api.js.map +1 -0
  62. package/package.json +43 -6
@@ -0,0 +1,1396 @@
1
+ import { Subject } from 'rxjs';
2
+ import { UUIDsEqual } from '@memberjunction/global';
3
+ /**
4
+ * Curated font-size steps for text labels and sticky notes. The toolbar's text-style flyout
5
+ * and the agent's `fontSize` tool params both restrict to these values.
6
+ */
7
+ export const WHITEBOARD_FONT_SIZES = [12, 14, 18, 24, 32];
8
+ /** Render-time default dimensions for items whose size is content-driven. */
9
+ export const WHITEBOARD_DEFAULTS = {
10
+ StickyW: 172,
11
+ StickyH: 96,
12
+ ImageW: 198,
13
+ ImageH: 134,
14
+ TextW: 132,
15
+ TextH: 18,
16
+ ShapeMinH: 56,
17
+ MarkdownW: 280,
18
+ MarkdownMinH: 96,
19
+ HtmlW: 360,
20
+ HtmlH: 240
21
+ };
22
+ /**
23
+ * Whether an item kind is BOX-RESIZABLE by the user — i.e. the selection chrome shows the
24
+ * 8 resize handles for it. Everything with a box model resizes: stickies, shapes, text
25
+ * labels, images, markdown panels, HTML widgets and highlight regions. Ink strokes and
26
+ * connectors are path-based and are not box-resizable.
27
+ */
28
+ export function IsResizableKind(kind) {
29
+ return kind === 'sticky' || kind === 'shape' || kind === 'text' || kind === 'image'
30
+ || kind === 'highlight' || kind === 'markdown' || kind === 'html';
31
+ }
32
+ /**
33
+ * The patch a COMMITTED resize gesture applies for an item kind, given the gesture's
34
+ * final bounds:
35
+ * - full-box kinds (`shape`, `highlight`, `html`) commit X / Y / W / H;
36
+ * - content-driven-height kinds (`sticky`, `text`, `image`, `markdown`) commit
37
+ * X / Y / W only — their rendered height stays content-driven (markdown's optional
38
+ * `H` max-height cap is set by tools/agents, never by the drag gesture);
39
+ * - non-resizable kinds (`ink`, `connector`) return `null` (nothing to commit).
40
+ */
41
+ export function BuildResizeCommitPatch(kind, bounds) {
42
+ if (kind === 'shape' || kind === 'highlight' || kind === 'html') {
43
+ return { X: bounds.X, Y: bounds.Y, W: bounds.W, H: bounds.H };
44
+ }
45
+ if (kind === 'sticky' || kind === 'text' || kind === 'image' || kind === 'markdown') {
46
+ return { X: bounds.X, Y: bounds.Y, W: bounds.W };
47
+ }
48
+ return null;
49
+ }
50
+ /** Truncate long item text for summary fragments. */
51
+ function clip(text, max = 42) {
52
+ const t = (text || '').replace(/\s+/g, ' ').trim();
53
+ return t.length > max ? `${t.slice(0, max).trimEnd()}…` : t;
54
+ }
55
+ /**
56
+ * The whiteboard engine: items + ordered render list, single selection, snapshot-based
57
+ * undo/redo (one entry per user gesture or per agent tool call via {@link RunBatch}),
58
+ * change journal + coalesced scene deltas, JSON persistence, and the cancelable
59
+ * BEFORE / AFTER mutation event surface.
60
+ *
61
+ * ## Before / after events
62
+ *
63
+ * Every targeted mutation raises a cancelable BEFORE event and, when it applies, a
64
+ * matching AFTER event:
65
+ *
66
+ * | Mutation | Before (cancelable) | After |
67
+ * |---|---|---|
68
+ * | {@link AddItem} (incl. {@link Highlight}, {@link DuplicateItem}) | {@link ItemAdding$} | {@link ItemAdded$} |
69
+ * | {@link UpdateItem} / {@link MoveItem} / {@link BringToFront} / {@link SendToBack} | {@link ItemUpdating$} | {@link ItemUpdated$} |
70
+ * | {@link UpdateItem} touching content fields (Text / Label / Sub / Markdown / Html / Title) | {@link ContentChanging$} (after ItemUpdating$) | {@link ContentChanged$} |
71
+ * | {@link RemoveItem} | {@link ItemRemoving$} | {@link ItemRemoved$} |
72
+ * | {@link AddPage} | {@link PageAdding$} | {@link PageAdded$} |
73
+ * | {@link SwitchPage} | {@link PageSwitching$} | {@link PageSwitched$} |
74
+ * | {@link RenamePage} | {@link PageRenaming$} | {@link PageRenamed$} |
75
+ * | {@link RemovePage} | {@link PageRemoving$} | {@link PageRemoved$} |
76
+ * | {@link Select} (and implicit clears) | — | {@link SelectionChanged$} |
77
+ * | {@link Clear} | — | {@link BoardCleared$} |
78
+ * | {@link LoadFromJSON} | — | {@link BoardLoaded$} |
79
+ *
80
+ * Handlers run synchronously during the emit; setting `Cancel = true` on the event args
81
+ * aborts the mutation (the caller sees `null` / `false`) with no undo snapshot, journal
82
+ * entry or {@link Changed$} emission. These events layer ALONGSIDE the existing
83
+ * {@link Changed$} / journal / perception machinery — they never replace it. Undo / redo
84
+ * whole-scene replacements are NOT item mutations and only surface through
85
+ * {@link Changed$} as `'replace'` ops.
86
+ */
87
+ export class WhiteboardState {
88
+ constructor() {
89
+ /**
90
+ * The ordered page list. A fresh board has one page named "Page 1". Every item
91
+ * operation reads/writes the ACTIVE page's map through the `items` accessor below, so
92
+ * the entire pre-pages mutation surface is page-scoped without per-call changes.
93
+ */
94
+ this.pages = [{ ID: 'page-1', Name: 'Page 1', Author: 'user', Items: new Map() }];
95
+ /** ID of the active page (always present in {@link pages}). */
96
+ this.activePageId = 'page-1';
97
+ /** Monotonic page counter — mints page IDs and the "Page N" auto-names. */
98
+ this.pageCounter = 1;
99
+ this.idCounter = 0;
100
+ this.zCounter = 0;
101
+ this.seq = 0;
102
+ this.undoStack = [];
103
+ this.redoStack = [];
104
+ this.batchDepth = 0;
105
+ this.journal = [];
106
+ /** Per-item snapshot of "did this item exist at journal-trim time" is not needed because
107
+ * trimming forces reset semantics for tokens older than the journal window. */
108
+ this.journalTrimmedBeforeSeq = 0;
109
+ this.changed = new Subject();
110
+ /** Fires after every mutation (including undo/redo `'replace'` events). */
111
+ this.Changed$ = this.changed.asObservable();
112
+ this.itemAdding = new Subject();
113
+ /**
114
+ * Cancelable BEFORE event of {@link AddItem} (and {@link Highlight} /
115
+ * {@link DuplicateItem}, which add through it). Set `Cancel = true` synchronously to
116
+ * veto the add — {@link AddItem} then returns `null` and nothing changes.
117
+ */
118
+ this.ItemAdding$ = this.itemAdding.asObservable();
119
+ this.itemAdded = new Subject();
120
+ /** AFTER event: an item was added (fires once per applied {@link AddItem}). */
121
+ this.ItemAdded$ = this.itemAdded.asObservable();
122
+ this.itemUpdating = new Subject();
123
+ /**
124
+ * Cancelable BEFORE event of {@link UpdateItem} (`Operation: 'update'`),
125
+ * {@link MoveItem} (`'move'`) and {@link BringToFront} / {@link SendToBack}
126
+ * (`'reorder'`). Set `Cancel = true` synchronously to veto — the mutator returns `false`.
127
+ */
128
+ this.ItemUpdating$ = this.itemUpdating.asObservable();
129
+ this.itemUpdated = new Subject();
130
+ /** AFTER event: an item changed (patch applied / moved / z-reordered). */
131
+ this.ItemUpdated$ = this.itemUpdated.asObservable();
132
+ this.itemRemoving = new Subject();
133
+ /**
134
+ * Cancelable BEFORE event of {@link RemoveItem}. Set `Cancel = true` synchronously to
135
+ * keep the item — {@link RemoveItem} then returns `false` and nothing changes.
136
+ */
137
+ this.ItemRemoving$ = this.itemRemoving.asObservable();
138
+ this.itemRemoved = new Subject();
139
+ /** AFTER event: an item was removed from the board. */
140
+ this.ItemRemoved$ = this.itemRemoved.asObservable();
141
+ this.contentChanging = new Subject();
142
+ /**
143
+ * Cancelable BEFORE event raised — in addition to {@link ItemUpdating$} — when an
144
+ * {@link UpdateItem} patch touches CONTENT fields (Text / Label / Sub / Markdown /
145
+ * Html / Title). The dedicated hook for content governance: set `Cancel = true`
146
+ * synchronously to veto the whole update.
147
+ */
148
+ this.ContentChanging$ = this.contentChanging.asObservable();
149
+ this.contentChanged = new Subject();
150
+ /** AFTER event: an item's content fields changed (markdown / html / text edits). */
151
+ this.ContentChanged$ = this.contentChanged.asObservable();
152
+ this.selectionChanged = new Subject();
153
+ /**
154
+ * AFTER event: the single selection changed — via {@link Select} or implicitly (the
155
+ * selected item was removed / dropped by a restore). Selection is transient UI state,
156
+ * so this is a notification only (never cancelable, never journaled).
157
+ */
158
+ this.SelectionChanged$ = this.selectionChanged.asObservable();
159
+ this.boardCleared = new Subject();
160
+ /** AFTER event: {@link Clear} removed everything from the board (one undo step). */
161
+ this.BoardCleared$ = this.boardCleared.asObservable();
162
+ this.boardLoaded = new Subject();
163
+ /** AFTER event: {@link LoadFromJSON} rehydrated a persisted board into this instance. */
164
+ this.BoardLoaded$ = this.boardLoaded.asObservable();
165
+ this.pageAdding = new Subject();
166
+ /**
167
+ * Cancelable BEFORE event of {@link AddPage}. Set `Cancel = true` synchronously to
168
+ * veto — {@link AddPage} then returns `null` and nothing changes.
169
+ */
170
+ this.PageAdding$ = this.pageAdding.asObservable();
171
+ this.pageAdded = new Subject();
172
+ /** AFTER event: a page was added (and became the active page). */
173
+ this.PageAdded$ = this.pageAdded.asObservable();
174
+ this.pageSwitching = new Subject();
175
+ /**
176
+ * Cancelable BEFORE event of {@link SwitchPage}. Set `Cancel = true` synchronously to
177
+ * stay on the current page — {@link SwitchPage} then returns `false`.
178
+ */
179
+ this.PageSwitching$ = this.pageSwitching.asObservable();
180
+ this.pageSwitched = new Subject();
181
+ /** AFTER event: the active page changed via {@link SwitchPage}. */
182
+ this.PageSwitched$ = this.pageSwitched.asObservable();
183
+ this.pageRenaming = new Subject();
184
+ /**
185
+ * Cancelable BEFORE event of {@link RenamePage}. Handlers may rewrite `NewName`; set
186
+ * `Cancel = true` synchronously to veto — {@link RenamePage} then returns `false`.
187
+ */
188
+ this.PageRenaming$ = this.pageRenaming.asObservable();
189
+ this.pageRenamed = new Subject();
190
+ /** AFTER event: a page was renamed. */
191
+ this.PageRenamed$ = this.pageRenamed.asObservable();
192
+ this.pageRemoving = new Subject();
193
+ /**
194
+ * Cancelable BEFORE event of {@link RemovePage} (never raised for the guarded
195
+ * last-page case). Set `Cancel = true` synchronously to keep the page.
196
+ */
197
+ this.PageRemoving$ = this.pageRemoving.asObservable();
198
+ this.pageRemoved = new Subject();
199
+ /** AFTER event: a page (and all of its items) was removed from the board. */
200
+ this.PageRemoved$ = this.pageRemoved.asObservable();
201
+ /**
202
+ * The MULTI-selection, in selection order (last entry is the primary selection).
203
+ * Selection is volatile UI state — never journaled, never undoable, never persisted,
204
+ * and cleared on page switches and whole-scene restores.
205
+ */
206
+ this.selectedIds = [];
207
+ }
208
+ static { this.UndoMax = 100; }
209
+ static { this.JournalMax = 1000; }
210
+ /** The ACTIVE page's item map (accessor keeps all item mutations page-scoped). */
211
+ get items() {
212
+ return this.activePage.Items;
213
+ }
214
+ set items(value) {
215
+ this.activePage.Items = value;
216
+ }
217
+ /** The active page record (defensive fallback to the first page — never undefined). */
218
+ get activePage() {
219
+ return this.pages.find((p) => UUIDsEqual(p.ID, this.activePageId)) ?? this.pages[0];
220
+ }
221
+ /**
222
+ * The PRIMARY selected item's ID (the most recently selected member of the
223
+ * multi-selection), or null when nothing is selected. Selection is UI state — not
224
+ * persisted. For the full multi-selection see {@link SelectedIDs}.
225
+ */
226
+ get SelectedID() {
227
+ return this.selectedIds.length > 0 ? this.selectedIds[this.selectedIds.length - 1] : null;
228
+ }
229
+ /** All selected item IDs in selection order (last = primary). Returns a copy. */
230
+ get SelectedIDs() {
231
+ return [...this.selectedIds];
232
+ }
233
+ // ────────────────────────────────────────────── reads
234
+ /** All items on the ACTIVE page in render order (ascending Z). */
235
+ get Items() {
236
+ return Array.from(this.items.values()).sort((a, b) => a.Z - b.Z);
237
+ }
238
+ /** The ordered page list (read-only snapshots — see {@link WhiteboardPageInfo}). */
239
+ get Pages() {
240
+ return this.pages.map((p) => this.pageInfo(p));
241
+ }
242
+ /** ID of the active page (every item operation targets this page). */
243
+ get ActivePageID() {
244
+ return this.activePage.ID;
245
+ }
246
+ /** Display name of the active page. */
247
+ get ActivePageName() {
248
+ return this.activePage.Name;
249
+ }
250
+ /** Total item count summed across ALL pages (the active-page count is {@link ElementCount}). */
251
+ get TotalItemCount() {
252
+ return this.pages.reduce((n, p) => n + p.Items.size, 0);
253
+ }
254
+ /**
255
+ * Tolerant page lookup: by exact ID first, then by case-insensitive, trimmed name
256
+ * (first match wins on duplicate names). Returns `undefined` when nothing matches.
257
+ */
258
+ FindPage(idOrName) {
259
+ const page = this.resolvePage(idOrName);
260
+ return page ? this.pageInfo(page) : undefined;
261
+ }
262
+ /** Current sequence number — use as the `sinceToken` for the next {@link BuildSceneDelta}. */
263
+ get CurrentSeq() {
264
+ return this.seq;
265
+ }
266
+ /** Look up one ACTIVE-page item by ID (items on other pages are not visible here). */
267
+ GetItem(id) {
268
+ return this.items.get(id);
269
+ }
270
+ /** ACTIVE-page "elements" as the status footer reports them (transient highlights excluded). */
271
+ get ElementCount() {
272
+ return this.Items.filter((i) => i.Kind !== 'highlight').length;
273
+ }
274
+ /** Active-page elements by author (highlights excluded, same basis as {@link ElementCount}). */
275
+ CountByAuthor(author) {
276
+ return this.Items.filter((i) => i.Kind !== 'highlight' && i.Author === author).length;
277
+ }
278
+ get CanUndo() {
279
+ return this.undoStack.length > 0;
280
+ }
281
+ get CanRedo() {
282
+ return this.redoStack.length > 0;
283
+ }
284
+ // ────────────────────────────────────────────── geometry
285
+ /** Axis-aligned bounds for any item (estimates for content-sized kinds). */
286
+ ItemBounds(item) {
287
+ switch (item.Kind) {
288
+ case 'sticky':
289
+ return { X: item.X, Y: item.Y, W: item.W ?? WHITEBOARD_DEFAULTS.StickyW, H: WHITEBOARD_DEFAULTS.StickyH };
290
+ case 'shape':
291
+ return { X: item.X, Y: item.Y, W: item.W, H: item.H };
292
+ case 'text': {
293
+ // estimate scales with the chosen font size (the CSS default renders ~12px);
294
+ // an explicit wrap width (item.W) wins over the single-line estimate
295
+ const scale = item.FontSize ? item.FontSize / 12 : 1;
296
+ return {
297
+ X: item.X,
298
+ Y: item.Y,
299
+ W: item.W ?? Math.round(WHITEBOARD_DEFAULTS.TextW * scale),
300
+ H: Math.round(WHITEBOARD_DEFAULTS.TextH * scale)
301
+ };
302
+ }
303
+ case 'image':
304
+ return { X: item.X, Y: item.Y, W: item.W ?? WHITEBOARD_DEFAULTS.ImageW, H: WHITEBOARD_DEFAULTS.ImageH };
305
+ case 'highlight':
306
+ return { X: item.X, Y: item.Y, W: item.W, H: item.H };
307
+ case 'markdown':
308
+ // content-driven height: an explicit max (H) wins, otherwise estimate from line count
309
+ return { X: item.X, Y: item.Y, W: item.W, H: item.H ?? WhiteboardState.markdownHeightEstimate(item.Markdown) };
310
+ case 'html':
311
+ return { X: item.X, Y: item.Y, W: item.W, H: item.H };
312
+ case 'ink': {
313
+ return WhiteboardState.pointsBounds(item.Points, item.StrokeWidth);
314
+ }
315
+ case 'connector': {
316
+ const from = this.ResolveEndpoint(item, 'from');
317
+ const to = this.ResolveEndpoint(item, 'to');
318
+ return WhiteboardState.pointsBounds([from, to], 2);
319
+ }
320
+ }
321
+ }
322
+ /** Rough rendered height of a markdown panel (line count · line height + card padding). */
323
+ static markdownHeightEstimate(markdown) {
324
+ const lines = (markdown || '').split('\n').length;
325
+ return Math.max(WHITEBOARD_DEFAULTS.MarkdownMinH, Math.min(520, 44 + lines * 19));
326
+ }
327
+ static pointsBounds(points, pad) {
328
+ if (points.length === 0) {
329
+ return { X: 0, Y: 0, W: 0, H: 0 };
330
+ }
331
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
332
+ for (const p of points) {
333
+ minX = Math.min(minX, p.X);
334
+ minY = Math.min(minY, p.Y);
335
+ maxX = Math.max(maxX, p.X);
336
+ maxY = Math.max(maxY, p.Y);
337
+ }
338
+ return { X: minX - pad, Y: minY - pad, W: maxX - minX + pad * 2, H: maxY - minY + pad * 2 };
339
+ }
340
+ /** Bounding box of all items (null when the board is empty). Powers fit-to-content + minimap. */
341
+ ContentBounds() {
342
+ const all = this.Items;
343
+ if (all.length === 0) {
344
+ return null;
345
+ }
346
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
347
+ for (const item of all) {
348
+ const b = this.ItemBounds(item);
349
+ minX = Math.min(minX, b.X);
350
+ minY = Math.min(minY, b.Y);
351
+ maxX = Math.max(maxX, b.X + b.W);
352
+ maxY = Math.max(maxY, b.Y + b.H);
353
+ }
354
+ return { X: minX, Y: minY, W: maxX - minX, H: maxY - minY };
355
+ }
356
+ /**
357
+ * Resolve one connector endpoint: anchored to the referenced item's bounds-center when the
358
+ * item still exists, otherwise the absolute fallback point (floating endpoint).
359
+ */
360
+ ResolveEndpoint(conn, end) {
361
+ const refId = end === 'from' ? conn.FromItemID : conn.ToItemID;
362
+ const fallback = end === 'from' ? conn.FromPoint : conn.ToPoint;
363
+ if (refId) {
364
+ const target = this.items.get(refId);
365
+ if (target) {
366
+ const b = this.ItemBounds(target);
367
+ return { X: b.X + b.W / 2, Y: b.Y + b.H / 2 };
368
+ }
369
+ }
370
+ return fallback ?? { X: 0, Y: 0 };
371
+ }
372
+ // ────────────────────────────────────────────── selection
373
+ /**
374
+ * Set (or clear) the selection to a SINGLE item. Unknown IDs clear the selection.
375
+ * Fires {@link SelectionChanged$} when the effective selection actually changes.
376
+ */
377
+ Select(id) {
378
+ this.applySelection(id != null && this.items.has(id) ? [id] : []);
379
+ }
380
+ /**
381
+ * Toggle one item's membership in the multi-selection WITHOUT clearing the rest —
382
+ * the shift-click semantics. A newly added item becomes the primary selection
383
+ * ({@link SelectedID}); unknown IDs are a no-op.
384
+ */
385
+ ToggleSelect(id) {
386
+ if (!this.items.has(id)) {
387
+ return;
388
+ }
389
+ this.applySelection(this.selectedIds.includes(id)
390
+ ? this.selectedIds.filter((s) => s !== id)
391
+ : [...this.selectedIds, id]);
392
+ }
393
+ /**
394
+ * Replace the selection with a set of items (the marquee result). Unknown IDs are
395
+ * dropped and duplicates collapse to their first occurrence; order is preserved
396
+ * (the last surviving entry becomes the primary selection). An empty / fully-unknown
397
+ * list clears the selection.
398
+ */
399
+ SelectMany(ids) {
400
+ const seen = new Set();
401
+ const next = [];
402
+ for (const id of ids) {
403
+ if (id != null && this.items.has(id) && !seen.has(id)) {
404
+ seen.add(id);
405
+ next.push(id);
406
+ }
407
+ }
408
+ this.applySelection(next);
409
+ }
410
+ /** Whether an item is part of the current (single or multi) selection. */
411
+ IsItemSelected(id) {
412
+ return this.selectedIds.includes(id);
413
+ }
414
+ /**
415
+ * All ACTIVE-page items whose axis-aligned bounds intersect the given rectangle —
416
+ * the marquee (rubber-band) hit test, in render order. Transient highlight regions
417
+ * are excluded: they are "pointing" chrome dismissed by click, never selected.
418
+ * Edge-touching items (zero overlap area) do NOT count as intersecting.
419
+ */
420
+ ItemsIntersecting(rect) {
421
+ return this.Items.filter((item) => {
422
+ if (item.Kind === 'highlight') {
423
+ return false;
424
+ }
425
+ const b = this.ItemBounds(item);
426
+ return b.X < rect.X + rect.W && b.X + b.W > rect.X
427
+ && b.Y < rect.Y + rect.H && b.Y + b.H > rect.Y;
428
+ });
429
+ }
430
+ /** Apply a SINGLE-or-clear selection change (legacy internal path). */
431
+ changeSelection(next) {
432
+ this.applySelection(next != null ? [next] : []);
433
+ }
434
+ /** Swap the multi-selection and fire {@link SelectionChanged$} when it differs. */
435
+ applySelection(next) {
436
+ const current = this.selectedIds;
437
+ if (next.length === current.length && next.every((id, i) => id === current[i])) {
438
+ return;
439
+ }
440
+ const previous = this.SelectedID;
441
+ this.selectedIds = next;
442
+ this.selectionChanged.next({ SelectedID: this.SelectedID, PreviousID: previous, SelectedIDs: [...next] });
443
+ }
444
+ // ────────────────────────────────────────────── mutations
445
+ /**
446
+ * Add an item; the engine stamps ID, Z and Author and emits one change.
447
+ *
448
+ * Raises the cancelable {@link ItemAdding$} BEFORE event first — when a handler
449
+ * cancels, nothing changes and `null` is returned. On success the stamped item is
450
+ * returned and {@link ItemAdded$} fires after the journal/{@link Changed$} emission.
451
+ */
452
+ AddItem(input, author) {
453
+ const adding = { Input: input, Author: author, Cancel: false };
454
+ this.itemAdding.next(adding);
455
+ if (adding.Cancel) {
456
+ return null;
457
+ }
458
+ this.beforeMutate();
459
+ const item = { ...adding.Input, ID: this.nextId(adding.Input.Kind), Z: ++this.zCounter, Author: author };
460
+ this.items.set(item.ID, item);
461
+ this.record('add', item.ID, author, `added ${WhiteboardState.describe(item)}`);
462
+ this.itemAdded.next({ Item: item, Author: author });
463
+ return item;
464
+ }
465
+ /**
466
+ * Patch an item's mutable fields. Returns false when the ID is unknown — or when a
467
+ * handler of the cancelable {@link ItemUpdating$} BEFORE event (or, for patches that
468
+ * touch content fields, the cancelable {@link ContentChanging$} event) vetoed the
469
+ * change. On success {@link ItemUpdated$} — and {@link ContentChanged$} for content
470
+ * patches — fires after the journal/{@link Changed$} emission.
471
+ */
472
+ UpdateItem(id, patch, author) {
473
+ const item = this.items.get(id);
474
+ if (!item) {
475
+ return false;
476
+ }
477
+ const updating = { Item: item, Operation: 'update', Patch: patch, Author: author, Cancel: false };
478
+ this.itemUpdating.next(updating);
479
+ if (updating.Cancel) {
480
+ return false;
481
+ }
482
+ const effective = updating.Patch ?? patch;
483
+ const isContent = WhiteboardState.isContentPatch(effective);
484
+ if (isContent) {
485
+ const changing = { Item: item, Patch: effective, Author: author, Cancel: false };
486
+ this.contentChanging.next(changing);
487
+ if (changing.Cancel) {
488
+ return false;
489
+ }
490
+ }
491
+ this.beforeMutate();
492
+ const target = item;
493
+ for (const [key, value] of Object.entries(effective)) {
494
+ if (value !== undefined) {
495
+ target[key] = value;
496
+ }
497
+ }
498
+ this.record('update', id, author, `updated ${WhiteboardState.describe(item)}`);
499
+ this.itemUpdated.next({ Item: item, Operation: 'update', Author: author });
500
+ if (isContent) {
501
+ this.contentChanged.next({ Item: item, Patch: effective, Author: author });
502
+ }
503
+ return true;
504
+ }
505
+ /** Whether a patch touches CONTENT fields (drives the ContentChanging/Changed pair). */
506
+ static isContentPatch(patch) {
507
+ return patch.Text !== undefined || patch.Label !== undefined || patch.Sub !== undefined
508
+ || patch.Markdown !== undefined || patch.Html !== undefined || patch.Title !== undefined;
509
+ }
510
+ /**
511
+ * Move an item to an absolute board position. Positioned kinds move their origin; ink
512
+ * strokes translate every point; connectors translate their floating endpoints.
513
+ *
514
+ * Raises the cancelable {@link ItemUpdating$} BEFORE event (`Operation: 'move'`,
515
+ * `Position` = the requested top-left) — returns false when vetoed or the ID is
516
+ * unknown; fires {@link ItemUpdated$} after an applied move.
517
+ */
518
+ MoveItem(id, x, y, author) {
519
+ const item = this.items.get(id);
520
+ if (!item) {
521
+ return false;
522
+ }
523
+ const moving = { Item: item, Operation: 'move', Position: { X: x, Y: y }, Author: author, Cancel: false };
524
+ this.itemUpdating.next(moving);
525
+ if (moving.Cancel) {
526
+ return false;
527
+ }
528
+ this.beforeMutate();
529
+ const bounds = this.ItemBounds(item);
530
+ const dx = x - bounds.X;
531
+ const dy = y - bounds.Y;
532
+ if (item.Kind === 'ink') {
533
+ item.Points = item.Points.map((p) => ({ X: p.X + dx, Y: p.Y + dy }));
534
+ }
535
+ else if (item.Kind === 'connector') {
536
+ if (item.FromPoint) {
537
+ item.FromPoint = { X: item.FromPoint.X + dx, Y: item.FromPoint.Y + dy };
538
+ }
539
+ if (item.ToPoint) {
540
+ item.ToPoint = { X: item.ToPoint.X + dx, Y: item.ToPoint.Y + dy };
541
+ }
542
+ }
543
+ else {
544
+ item.X = x;
545
+ item.Y = y;
546
+ }
547
+ this.record('move', id, author, `moved ${WhiteboardState.describe(item)}`);
548
+ this.itemUpdated.next({ Item: item, Operation: 'move', Author: author });
549
+ return true;
550
+ }
551
+ /**
552
+ * Remove an item. Connectors that referenced it survive: their endpoint freezes to the
553
+ * removed item's last center (the floating-endpoint fallback).
554
+ *
555
+ * Raises the cancelable {@link ItemRemoving$} BEFORE event — returns false when vetoed
556
+ * or the ID is unknown; fires {@link ItemRemoved$} after an applied removal.
557
+ */
558
+ RemoveItem(id, author) {
559
+ const item = this.items.get(id);
560
+ if (!item) {
561
+ return false;
562
+ }
563
+ const removing = { Item: item, Author: author, Cancel: false };
564
+ this.itemRemoving.next(removing);
565
+ if (removing.Cancel) {
566
+ return false;
567
+ }
568
+ this.beforeMutate();
569
+ // Freeze any connector endpoints anchored to the item being removed.
570
+ const center = (() => {
571
+ const b = this.ItemBounds(item);
572
+ return { X: b.X + b.W / 2, Y: b.Y + b.H / 2 };
573
+ })();
574
+ for (const other of this.items.values()) {
575
+ if (other.Kind === 'connector') {
576
+ if (other.FromItemID === id) {
577
+ other.FromItemID = null;
578
+ other.FromPoint = { ...center };
579
+ }
580
+ if (other.ToItemID === id) {
581
+ other.ToItemID = null;
582
+ other.ToPoint = { ...center };
583
+ }
584
+ }
585
+ }
586
+ this.items.delete(id);
587
+ if (this.selectedIds.includes(id)) {
588
+ this.applySelection(this.selectedIds.filter((s) => s !== id));
589
+ }
590
+ this.record('remove', id, author, `removed ${WhiteboardState.describe(item)}`);
591
+ this.itemRemoved.next({ Item: item, Author: author });
592
+ return true;
593
+ }
594
+ /**
595
+ * Duplicate an item: a DEEP clone (ink points included) with a fresh engine-stamped
596
+ * identity, offset +16/+16 from the source so the copy is visibly distinct. Connectors
597
+ * (which reference other items) and transient highlights cannot be duplicated — returns
598
+ * `null` without mutating. The clone lands through {@link AddItem}, so it journals as a
599
+ * normal `'add'`, is one undo step, and raises the {@link ItemAdding$} /
600
+ * {@link ItemAdded$} pair (a canceled add also returns `null`).
601
+ */
602
+ DuplicateItem(id, author) {
603
+ const source = this.items.get(id);
604
+ if (!source || source.Kind === 'connector' || source.Kind === 'highlight') {
605
+ return null;
606
+ }
607
+ const { ID: _id, Z: _z, Author: _author, ...rest } = JSON.parse(JSON.stringify(source));
608
+ const input = rest;
609
+ if (input.Kind === 'ink') {
610
+ input.Points = input.Points.map((p) => ({ X: p.X + 16, Y: p.Y + 16 }));
611
+ }
612
+ else if (input.Kind !== 'connector') {
613
+ input.X += 16;
614
+ input.Y += 16;
615
+ }
616
+ return this.AddItem(input, author);
617
+ }
618
+ /**
619
+ * Raise an item above everything else. Follows the engine's existing Z handling:
620
+ * `++zCounter` is by construction greater than every assigned Z (max + 1), exactly how
621
+ * {@link AddItem} stamps new items. Journals as an `'update'`.
622
+ *
623
+ * Raises the cancelable {@link ItemUpdating$} BEFORE event (`Operation: 'reorder'`) —
624
+ * returns false when vetoed or the ID is unknown; fires {@link ItemUpdated$} after.
625
+ */
626
+ BringToFront(id, author) {
627
+ const item = this.items.get(id);
628
+ if (!item) {
629
+ return false;
630
+ }
631
+ if (!this.raiseReorder(item, author)) {
632
+ return false;
633
+ }
634
+ this.beforeMutate();
635
+ item.Z = ++this.zCounter;
636
+ this.record('update', id, author, `brought ${WhiteboardState.describe(item)} to the front`);
637
+ this.itemUpdated.next({ Item: item, Operation: 'reorder', Author: author });
638
+ return true;
639
+ }
640
+ /**
641
+ * Drop an item below everything else (current min Z − 1). Journals as an `'update'`.
642
+ *
643
+ * Raises the cancelable {@link ItemUpdating$} BEFORE event (`Operation: 'reorder'`) —
644
+ * returns false when vetoed or the ID is unknown; fires {@link ItemUpdated$} after.
645
+ */
646
+ SendToBack(id, author) {
647
+ const item = this.items.get(id);
648
+ if (!item) {
649
+ return false;
650
+ }
651
+ if (!this.raiseReorder(item, author)) {
652
+ return false;
653
+ }
654
+ this.beforeMutate();
655
+ let minZ = item.Z;
656
+ for (const other of this.items.values()) {
657
+ minZ = Math.min(minZ, other.Z);
658
+ }
659
+ item.Z = minZ - 1;
660
+ this.record('update', id, author, `sent ${WhiteboardState.describe(item)} to the back`);
661
+ this.itemUpdated.next({ Item: item, Operation: 'reorder', Author: author });
662
+ return true;
663
+ }
664
+ /** Raise the cancelable `'reorder'` BEFORE event; returns false when a handler vetoed. */
665
+ raiseReorder(item, author) {
666
+ const reordering = { Item: item, Operation: 'reorder', Author: author, Cancel: false };
667
+ this.itemUpdating.next(reordering);
668
+ return !reordering.Cancel;
669
+ }
670
+ /**
671
+ * Convenience: add a pulsing highlight region (agent "pointing without touching").
672
+ * Adds through {@link AddItem}, so the {@link ItemAdding$} / {@link ItemAdded$} pair
673
+ * fires — returns `null` when a handler canceled the add.
674
+ */
675
+ Highlight(x, y, w, h, label, author) {
676
+ return this.AddItem({ Kind: 'highlight', X: x, Y: y, W: w, H: h, Label: label }, author);
677
+ }
678
+ /**
679
+ * Remove EVERYTHING from the ACTIVE page as ONE undoable operation (other pages are
680
+ * untouched). Journals a single `'replace'` op (perception consumers re-read the
681
+ * now-empty scene) and fires {@link BoardCleared$}. Clears the selection (firing
682
+ * {@link SelectionChanged$} when one existed). Returns false when the active page was
683
+ * already empty.
684
+ */
685
+ Clear(author = 'user') {
686
+ if (this.items.size === 0) {
687
+ return false;
688
+ }
689
+ const count = this.items.size;
690
+ this.beforeMutate();
691
+ this.items = new Map();
692
+ this.changeSelection(null);
693
+ this.record('replace', '', author, 'cleared the board');
694
+ this.boardCleared.next({ Author: author, ItemCount: count });
695
+ return true;
696
+ }
697
+ /**
698
+ * Move EVERY selected item by the same delta as ONE undo step (the multi-select group
699
+ * drag). Internally one {@link RunBatch} of per-item {@link MoveItem} calls, so each
700
+ * member still raises its own cancelable `'move'` BEFORE event (a veto skips just that
701
+ * member) and journals normally — but a single Undo reverts the whole group move.
702
+ * Returns how many items actually moved (0 when nothing is selected or the delta is 0).
703
+ */
704
+ MoveSelectedBy(dx, dy, author) {
705
+ const ids = this.selectedIds.filter((id) => this.items.has(id));
706
+ if (ids.length === 0 || (dx === 0 && dy === 0)) {
707
+ return 0;
708
+ }
709
+ return this.RunBatch(() => {
710
+ // capture every member's bounds BEFORE any member moves, so anchored-connector
711
+ // bounds (which follow their endpoints) don't skew later members' targets
712
+ const targets = ids
713
+ .map((id) => {
714
+ const item = this.items.get(id);
715
+ return item ? { id, bounds: this.ItemBounds(item) } : null;
716
+ })
717
+ .filter((t) => t !== null);
718
+ let moved = 0;
719
+ for (const t of targets) {
720
+ if (this.MoveItem(t.id, t.bounds.X + dx, t.bounds.Y + dy, author)) {
721
+ moved++;
722
+ }
723
+ }
724
+ return moved;
725
+ });
726
+ }
727
+ /**
728
+ * Remove EVERY selected item as ONE undo step (the multi-select Delete key). One
729
+ * {@link RunBatch} of per-item {@link RemoveItem} calls — each member still raises its
730
+ * cancelable {@link ItemRemoving$} BEFORE event (a veto keeps just that member), and a
731
+ * single Undo restores the whole group. The selection empties as items are removed.
732
+ * Returns how many items were actually removed.
733
+ */
734
+ RemoveSelected(author) {
735
+ const ids = this.selectedIds.filter((id) => this.items.has(id));
736
+ if (ids.length === 0) {
737
+ return 0;
738
+ }
739
+ return this.RunBatch(() => {
740
+ let removed = 0;
741
+ for (const id of ids) {
742
+ if (this.RemoveItem(id, author)) {
743
+ removed++;
744
+ }
745
+ }
746
+ return removed;
747
+ });
748
+ }
749
+ // ────────────────────────────────────────────── pages
750
+ /** Project a live page record to its public read-only descriptor. */
751
+ pageInfo(page) {
752
+ return { ID: page.ID, Name: page.Name, ItemCount: page.Items.size, Active: UUIDsEqual(page.ID, this.activePageId), Author: page.Author };
753
+ }
754
+ /** Resolve a page by exact ID first, then case-insensitive trimmed name. */
755
+ resolvePage(idOrName) {
756
+ const key = (idOrName ?? '').trim();
757
+ if (key.length === 0) {
758
+ return undefined;
759
+ }
760
+ return this.pages.find((p) => UUIDsEqual(p.ID, key))
761
+ ?? this.pages.find((p) => p.Name.trim().toLowerCase() === key.toLowerCase());
762
+ }
763
+ /**
764
+ * Add a new page and SWITCH to it. `name` is trimmed; when omitted (or blank) the page
765
+ * auto-names itself "Page N" from the monotonic page counter, so auto-names never
766
+ * repeat even after removals.
767
+ *
768
+ * Raises the cancelable {@link PageAdding$} BEFORE event (handlers may rewrite the
769
+ * name) — returns `null` when vetoed; fires {@link PageAdded$} after. One undoable
770
+ * step; journals a `'replace'` op (the agent's visible scene swaps to the new, empty
771
+ * page), so perception consumers re-read the scene.
772
+ */
773
+ AddPage(name, author = 'user') {
774
+ const autoName = `Page ${this.pageCounter + 1}`;
775
+ const requested = (name ?? '').trim();
776
+ const adding = { Name: requested.length > 0 ? requested : autoName, Author: author, Cancel: false };
777
+ this.pageAdding.next(adding);
778
+ if (adding.Cancel) {
779
+ return null;
780
+ }
781
+ this.beforeMutate();
782
+ this.pageCounter++;
783
+ const page = {
784
+ ID: `page-${this.pageCounter}`,
785
+ Name: (adding.Name ?? '').trim() || autoName,
786
+ Author: author,
787
+ Items: new Map()
788
+ };
789
+ this.pages.push(page);
790
+ this.activePageId = page.ID;
791
+ this.changeSelection(null);
792
+ this.record('replace', '', author, `added page "${page.Name}"`);
793
+ const info = this.pageInfo(page);
794
+ this.pageAdded.next({ Page: info, Author: author });
795
+ return info;
796
+ }
797
+ /**
798
+ * Make another page the active page. Tolerant lookup: exact ID first, then
799
+ * case-insensitive name (see {@link FindPage}). Switching to the already-active page
800
+ * is a successful no-op (no events, no journal entry).
801
+ *
802
+ * Raises the cancelable {@link PageSwitching$} BEFORE event — returns `false` when
803
+ * vetoed or the page is unknown; fires {@link PageSwitched$} after. Journals a
804
+ * `'replace'` op (the visible scene swaps wholesale) but deliberately pushes NO undo
805
+ * snapshot — switching is navigation, not a content mutation.
806
+ */
807
+ SwitchPage(idOrName, author = 'user') {
808
+ const target = this.resolvePage(idOrName);
809
+ if (!target) {
810
+ return false;
811
+ }
812
+ if (UUIDsEqual(target.ID, this.activePageId)) {
813
+ return true; // already there — successful no-op
814
+ }
815
+ const from = this.pageInfo(this.activePage);
816
+ const switching = { FromPage: from, ToPage: this.pageInfo(target), Author: author, Cancel: false };
817
+ this.pageSwitching.next(switching);
818
+ if (switching.Cancel) {
819
+ return false;
820
+ }
821
+ this.activePageId = target.ID;
822
+ this.changeSelection(null);
823
+ this.record('replace', '', author, `switched to page "${target.Name}"`);
824
+ this.pageSwitched.next({ FromPage: from, ToPage: this.pageInfo(target), Author: author });
825
+ return true;
826
+ }
827
+ /**
828
+ * Rename a page (tolerant lookup, same as {@link SwitchPage}). The new name is
829
+ * trimmed; an empty result returns `false`. Renaming to the current name is a
830
+ * successful no-op (no events, no journal entry).
831
+ *
832
+ * Raises the cancelable {@link PageRenaming$} BEFORE event (handlers may rewrite
833
+ * `NewName`) — returns `false` when vetoed; fires {@link PageRenamed$} after. One
834
+ * undoable step; journals a `'replace'` op so the agent's page list stays current.
835
+ */
836
+ RenamePage(idOrName, newName, author = 'user') {
837
+ const target = this.resolvePage(idOrName);
838
+ const requested = (newName ?? '').trim();
839
+ if (!target || requested.length === 0) {
840
+ return false;
841
+ }
842
+ if (target.Name === requested) {
843
+ return true; // nothing to do
844
+ }
845
+ const renaming = { Page: this.pageInfo(target), NewName: requested, Author: author, Cancel: false };
846
+ this.pageRenaming.next(renaming);
847
+ if (renaming.Cancel) {
848
+ return false;
849
+ }
850
+ const effective = (renaming.NewName ?? '').trim();
851
+ if (effective.length === 0) {
852
+ return false; // a handler emptied the name — treat as an abort
853
+ }
854
+ this.beforeMutate();
855
+ const oldName = target.Name;
856
+ target.Name = effective;
857
+ this.record('replace', '', author, `renamed page "${oldName}" to "${target.Name}"`);
858
+ this.pageRenamed.next({ Page: this.pageInfo(target), OldName: oldName, Author: author });
859
+ return true;
860
+ }
861
+ /**
862
+ * Remove a page AND all of its items (tolerant lookup, same as {@link SwitchPage}).
863
+ * The LAST remaining page can never be removed (`false`, no events). Removing the
864
+ * ACTIVE page activates a neighbor — the next page when one exists, otherwise the
865
+ * previous one.
866
+ *
867
+ * Raises the cancelable {@link PageRemoving$} BEFORE event — returns `false` when
868
+ * vetoed or the page is unknown; fires {@link PageRemoved$} after (with the activated
869
+ * neighbor when the active page was removed). One undoable step; journals a
870
+ * `'replace'` op.
871
+ */
872
+ RemovePage(idOrName, author = 'user') {
873
+ const target = this.resolvePage(idOrName);
874
+ if (!target || this.pages.length <= 1) {
875
+ return false; // unknown, or the guarded last page
876
+ }
877
+ const removingInfo = this.pageInfo(target);
878
+ const removing = { Page: removingInfo, Author: author, Cancel: false };
879
+ this.pageRemoving.next(removing);
880
+ if (removing.Cancel) {
881
+ return false;
882
+ }
883
+ this.beforeMutate();
884
+ const index = this.pages.indexOf(target);
885
+ this.pages.splice(index, 1);
886
+ let activated = null;
887
+ if (UUIDsEqual(target.ID, this.activePageId)) {
888
+ // activate the neighbor: the page that slid into the removed slot, else the new last
889
+ const neighbor = this.pages[Math.min(index, this.pages.length - 1)];
890
+ this.activePageId = neighbor.ID;
891
+ activated = this.pageInfo(neighbor);
892
+ this.changeSelection(null);
893
+ }
894
+ this.record('replace', '', author, `removed page "${target.Name}"`);
895
+ this.pageRemoved.next({ Page: removingInfo, ActivatedPage: activated, Author: author });
896
+ return true;
897
+ }
898
+ /**
899
+ * Run several mutations as ONE undo step (one snapshot). Used per agent tool call and for
900
+ * compound user gestures, so the toast's "Undo" reverts the whole tool effect at once.
901
+ */
902
+ RunBatch(fn) {
903
+ if (this.batchDepth === 0) {
904
+ this.pushUndo();
905
+ }
906
+ this.batchDepth++;
907
+ try {
908
+ return fn();
909
+ }
910
+ finally {
911
+ this.batchDepth--;
912
+ }
913
+ }
914
+ // ────────────────────────────────────────────── undo / redo
915
+ /** Restore the previous snapshot. Emits a `'replace'` change. */
916
+ Undo() {
917
+ const snap = this.undoStack.pop();
918
+ if (!snap) {
919
+ return false;
920
+ }
921
+ this.redoStack.push(this.snapshot());
922
+ this.restore(snap);
923
+ this.record('replace', '', 'user', 'undid the last change');
924
+ return true;
925
+ }
926
+ /** Re-apply the most recently undone snapshot. Emits a `'replace'` change. */
927
+ Redo() {
928
+ const snap = this.redoStack.pop();
929
+ if (!snap) {
930
+ return false;
931
+ }
932
+ this.undoStack.push(this.snapshot());
933
+ this.restore(snap);
934
+ this.record('replace', '', 'user', 'redid the last change');
935
+ return true;
936
+ }
937
+ // ────────────────────────────────────────────── perception (scene deltas)
938
+ /**
939
+ * Build the coalesced scene delta of everything that changed AFTER `sinceToken`
940
+ * (a previously observed {@link CurrentSeq}; defaults to 0 = everything).
941
+ *
942
+ * Coalescing: per item, the NET effect wins — N moves → one `moved` entry at the current
943
+ * position; add+move → one `added` entry (current state); add+remove → nothing;
944
+ * update+move → one `updated` entry. When the window contains a `'replace'` (undo/redo/load)
945
+ * or the journal no longer reaches the token, the delta carries `reset: true` plus the full
946
+ * compact `items` array — replace-current-state semantics, never an append-only log.
947
+ */
948
+ BuildSceneDelta(sinceToken = 0) {
949
+ const delta = {
950
+ op: 'scene-delta',
951
+ seq: this.seq,
952
+ added: [],
953
+ moved: [],
954
+ updated: [],
955
+ removed: [],
956
+ pages: this.scenePages(),
957
+ summary: ''
958
+ };
959
+ const tokenTooOld = sinceToken < this.journalTrimmedBeforeSeq;
960
+ const inRange = this.journal.filter((e) => e.Seq > sinceToken);
961
+ const hasReplace = inRange.some((e) => e.Op === 'replace');
962
+ if (tokenTooOld || hasReplace) {
963
+ delta.reset = true;
964
+ delta.items = this.Items.map((i) => this.compact(i));
965
+ delta.summary = this.composeSummaryText({ reset: true });
966
+ return delta;
967
+ }
968
+ // Net-effect per item, preserving first-op semantics.
969
+ const firstOp = new Map();
970
+ const lastOp = new Map();
971
+ const sawUpdate = new Set();
972
+ for (const entry of inRange) {
973
+ if (!firstOp.has(entry.ItemID)) {
974
+ firstOp.set(entry.ItemID, entry.Op);
975
+ }
976
+ lastOp.set(entry.ItemID, entry.Op);
977
+ if (entry.Op === 'update') {
978
+ sawUpdate.add(entry.ItemID);
979
+ }
980
+ }
981
+ for (const [id, first] of firstOp) {
982
+ const last = lastOp.get(id);
983
+ const item = this.items.get(id);
984
+ if (first === 'add') {
985
+ if (item) {
986
+ delta.added.push(this.compact(item)); // add → (move/update)* coalesces into the added entry
987
+ }
988
+ // add → … → remove: net nothing
989
+ continue;
990
+ }
991
+ if (last === 'remove' || !item) {
992
+ delta.removed.push(id);
993
+ continue;
994
+ }
995
+ if (sawUpdate.has(id)) {
996
+ delta.updated.push(this.compact(item)); // update (+ moves) → one updated entry w/ current state
997
+ continue;
998
+ }
999
+ // moves only → one moved entry at the current position
1000
+ const b = this.ItemBounds(item);
1001
+ delta.moved.push({ id, x: Math.round(b.X), y: Math.round(b.Y) });
1002
+ }
1003
+ delta.summary = this.composeSummaryText({
1004
+ added: delta.added.length,
1005
+ moved: delta.moved.length,
1006
+ updated: delta.updated.length,
1007
+ removed: delta.removed.length
1008
+ });
1009
+ return delta;
1010
+ }
1011
+ /** Full compact scene + counts — the popover's stats and the delta-reset payload. */
1012
+ BuildSceneSummary() {
1013
+ const items = this.Items;
1014
+ const byKind = {};
1015
+ for (const item of items) {
1016
+ byKind[item.Kind] = (byKind[item.Kind] ?? 0) + 1;
1017
+ }
1018
+ return {
1019
+ op: 'scene-summary',
1020
+ seq: this.seq,
1021
+ counts: {
1022
+ total: this.ElementCount,
1023
+ user: this.CountByAuthor('user'),
1024
+ agent: this.CountByAuthor('agent'),
1025
+ byKind
1026
+ },
1027
+ items: items.map((i) => this.compact(i)),
1028
+ pages: this.scenePages(),
1029
+ summary: this.composeSummaryText({})
1030
+ };
1031
+ }
1032
+ /** Compact page list for deltas / summaries (model-facing). */
1033
+ scenePages() {
1034
+ return this.pages.map((p) => ({
1035
+ id: p.ID,
1036
+ name: p.Name,
1037
+ active: UUIDsEqual(p.ID, this.activePageId),
1038
+ items: p.Items.size
1039
+ }));
1040
+ }
1041
+ // ────────────────────────────────────────────── persistence
1042
+ /**
1043
+ * Serialize the board (state of record — persisted as the session-channel artifact).
1044
+ * Emits the VERSION 2 paged shape (see {@link WhiteboardStateJSON}); the legacy flat
1045
+ * shape is still accepted on load and rehydrates as a single page "Page 1".
1046
+ */
1047
+ ToJSON() {
1048
+ return JSON.stringify(this.snapshot());
1049
+ }
1050
+ /**
1051
+ * Rehydrate a board from {@link ToJSON} output — BOTH shapes accepted: the current
1052
+ * paged shape (version 2) and the legacy flat shape (version 1, `items` at the root),
1053
+ * which migrates to one page named "Page 1". Throws on malformed input (use
1054
+ * {@link LoadFromJSON} or `ParseBoardStateJson` for the tolerant variants).
1055
+ */
1056
+ static FromJSON(json) {
1057
+ const normalized = WhiteboardState.normalizePersisted(JSON.parse(json));
1058
+ if (!normalized) {
1059
+ throw new Error('WhiteboardState.FromJSON: malformed payload');
1060
+ }
1061
+ const state = new WhiteboardState();
1062
+ state.restore(normalized);
1063
+ state.seq = normalized.seq;
1064
+ state.journalTrimmedBeforeSeq = state.seq; // older tokens force reset deltas
1065
+ return state;
1066
+ }
1067
+ /**
1068
+ * Normalize a parsed persisted payload — EITHER shape — into the current
1069
+ * {@link WhiteboardStateJSON}. Returns `null` for anything unrecognizable (the
1070
+ * callers decide whether that throws or fails soft). Defensive throughout: page
1071
+ * entries missing ids/names/item arrays are repaired, an empty page list gains one
1072
+ * "Page 1", and an unknown `activePageId` falls back to the first page.
1073
+ */
1074
+ static normalizePersisted(parsed) {
1075
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1076
+ return null;
1077
+ }
1078
+ const raw = parsed;
1079
+ const num = (value, fallback) => typeof value === 'number' && Number.isFinite(value) ? value : fallback;
1080
+ if (Array.isArray(raw['pages'])) {
1081
+ // CURRENT shape (version 2, paged)
1082
+ const pages = [];
1083
+ for (const entry of raw['pages']) {
1084
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
1085
+ continue;
1086
+ }
1087
+ const p = entry;
1088
+ const name = typeof p['name'] === 'string' && p['name'].trim().length > 0
1089
+ ? p['name'] : `Page ${pages.length + 1}`;
1090
+ pages.push({
1091
+ id: typeof p['id'] === 'string' && p['id'].length > 0 ? p['id'] : `page-${pages.length + 1}`,
1092
+ name,
1093
+ // additive authorship field — tolerated absent (pre-garnish payloads → 'user')
1094
+ author: p['author'] === 'agent' ? 'agent' : 'user',
1095
+ items: Array.isArray(p['items']) ? p['items'] : []
1096
+ });
1097
+ }
1098
+ if (pages.length === 0) {
1099
+ pages.push({ id: 'page-1', name: 'Page 1', items: [] });
1100
+ }
1101
+ const active = raw['activePageId'];
1102
+ return {
1103
+ version: 2,
1104
+ seq: num(raw['seq'], 0),
1105
+ idCounter: num(raw['idCounter'], 0),
1106
+ zCounter: num(raw['zCounter'], 0),
1107
+ pageCounter: Math.max(num(raw['pageCounter'], pages.length), pages.length),
1108
+ activePageId: typeof active === 'string' && pages.some((p) => p.id === active) ? active : pages[0].id,
1109
+ pages
1110
+ };
1111
+ }
1112
+ if (Array.isArray(raw['items'])) {
1113
+ // LEGACY shape (version 1, flat) — migrate to a single page "Page 1"
1114
+ return {
1115
+ version: 2,
1116
+ seq: num(raw['seq'], 0),
1117
+ idCounter: num(raw['idCounter'], 0),
1118
+ zCounter: num(raw['zCounter'], 0),
1119
+ pageCounter: 1,
1120
+ activePageId: 'page-1',
1121
+ pages: [{ id: 'page-1', name: 'Page 1', items: raw['items'] }]
1122
+ };
1123
+ }
1124
+ return null;
1125
+ }
1126
+ /**
1127
+ * Rehydrate THIS instance in place from {@link ToJSON} output — used by the channel's
1128
+ * `RestoreState` hook so existing subscriptions (perception feed, save pipeline) and any
1129
+ * bound surface keep pointing at the same engine. TOLERANT: malformed input returns
1130
+ * `false` and leaves the current state untouched (never throws).
1131
+ *
1132
+ * On success the undo/redo stacks and journal are cleared (restored state is the new
1133
+ * baseline), stale delta tokens force reset semantics, one `'replace'` change is
1134
+ * emitted so consumers re-read the full scene, and {@link BoardLoaded$} fires.
1135
+ */
1136
+ LoadFromJSON(json) {
1137
+ let parsed;
1138
+ try {
1139
+ parsed = JSON.parse(json);
1140
+ }
1141
+ catch {
1142
+ return false;
1143
+ }
1144
+ const normalized = WhiteboardState.normalizePersisted(parsed);
1145
+ if (!normalized) {
1146
+ return false;
1147
+ }
1148
+ this.restore(normalized);
1149
+ this.seq = normalized.seq;
1150
+ this.journal = [];
1151
+ this.journalTrimmedBeforeSeq = this.seq; // older tokens force reset deltas
1152
+ this.undoStack = [];
1153
+ this.redoStack = [];
1154
+ this.changeSelection(null);
1155
+ this.record('replace', '', 'user', 'restored a saved board');
1156
+ this.boardLoaded.next({ ItemCount: this.TotalItemCount });
1157
+ return true;
1158
+ }
1159
+ // ────────────────────────────────────────────── internals
1160
+ /** Mint the next stable item ID for a kind (`sticky-3`, `shape-7`, …). */
1161
+ nextId(kind) {
1162
+ return `${kind}-${++this.idCounter}`;
1163
+ }
1164
+ /**
1165
+ * Pre-mutation bookkeeping shared by every committed mutation: pushes the undo
1166
+ * snapshot (unless inside a {@link RunBatch}, which snapshotted at batch start) and
1167
+ * invalidates the redo branch. Subclasses extending the mutation paths should call
1168
+ * this exactly once per logical change, AFTER any cancelable before-event survived.
1169
+ */
1170
+ beforeMutate() {
1171
+ if (this.batchDepth === 0) {
1172
+ this.pushUndo();
1173
+ }
1174
+ // Any forward mutation invalidates the redo branch (batched mutations included).
1175
+ this.redoStack = [];
1176
+ }
1177
+ /** Push the current scene onto the undo stack (bounded) and drop the redo branch. */
1178
+ pushUndo() {
1179
+ this.undoStack.push(this.snapshot());
1180
+ if (this.undoStack.length > WhiteboardState.UndoMax) {
1181
+ this.undoStack.shift();
1182
+ }
1183
+ this.redoStack = [];
1184
+ }
1185
+ /**
1186
+ * Deep-copied serializable snapshot of the WHOLE board — every page's items plus the
1187
+ * page structure and counters (undo entries / ToJSON). Page items serialize in render
1188
+ * order (ascending Z).
1189
+ */
1190
+ snapshot() {
1191
+ return {
1192
+ version: 2,
1193
+ seq: this.seq,
1194
+ idCounter: this.idCounter,
1195
+ zCounter: this.zCounter,
1196
+ pageCounter: this.pageCounter,
1197
+ activePageId: this.activePageId,
1198
+ pages: this.pages.map((p) => ({
1199
+ id: p.ID,
1200
+ name: p.Name,
1201
+ author: p.Author,
1202
+ items: JSON.parse(JSON.stringify(Array.from(p.Items.values()).sort((a, b) => a.Z - b.Z)))
1203
+ }))
1204
+ };
1205
+ }
1206
+ /**
1207
+ * Swap the whole board to a snapshot's pages/counters (drops a now-missing selection;
1208
+ * an unknown active-page id falls back to the first page; an empty page list gains a
1209
+ * fresh "Page 1" so the engine never runs page-less).
1210
+ */
1211
+ restore(snap) {
1212
+ this.pages = (snap.pages ?? []).map((p) => ({
1213
+ ID: p.id,
1214
+ Name: p.name,
1215
+ Author: p.author === 'agent' ? 'agent' : 'user',
1216
+ Items: new Map(JSON.parse(JSON.stringify(p.items ?? []))
1217
+ .filter((i) => !!i && typeof i === 'object' && typeof i.ID === 'string')
1218
+ .map((i) => [i.ID, i]))
1219
+ }));
1220
+ if (this.pages.length === 0) {
1221
+ this.pages = [{ ID: 'page-1', Name: 'Page 1', Author: 'user', Items: new Map() }];
1222
+ }
1223
+ this.activePageId = this.pages.some((p) => UUIDsEqual(p.ID, snap.activePageId)) ? snap.activePageId : this.pages[0].ID;
1224
+ this.idCounter = snap.idCounter ?? 0;
1225
+ this.zCounter = snap.zCounter ?? 0;
1226
+ this.pageCounter = Math.max(snap.pageCounter ?? this.pages.length, this.pages.length);
1227
+ // drop selection members the restored active page no longer contains
1228
+ const surviving = this.selectedIds.filter((id) => this.items.has(id));
1229
+ if (surviving.length !== this.selectedIds.length) {
1230
+ this.applySelection(surviving);
1231
+ }
1232
+ }
1233
+ /**
1234
+ * Post-mutation bookkeeping shared by every committed mutation: bumps the sequence,
1235
+ * appends the journal entry (bounded — trimming forces reset deltas for stale tokens)
1236
+ * and emits the {@link Changed$} notification.
1237
+ */
1238
+ record(op, itemId, author, summaryFragment) {
1239
+ this.seq++;
1240
+ this.journal.push({ Seq: this.seq, Op: op, ItemID: itemId });
1241
+ if (this.journal.length > WhiteboardState.JournalMax) {
1242
+ const dropped = this.journal.splice(0, this.journal.length - WhiteboardState.JournalMax);
1243
+ this.journalTrimmedBeforeSeq = dropped[dropped.length - 1].Seq;
1244
+ }
1245
+ this.changed.next({ Op: op, ItemID: itemId, Author: author, SummaryFragment: summaryFragment, Seq: this.seq });
1246
+ }
1247
+ /** Project one item to its compact, model-facing representation (deltas / summaries). */
1248
+ compact(item) {
1249
+ const c = { id: item.ID, type: item.Kind, author: item.Author };
1250
+ switch (item.Kind) {
1251
+ case 'sticky':
1252
+ c.x = Math.round(item.X);
1253
+ c.y = Math.round(item.Y);
1254
+ c.text = clip(item.Text, 120);
1255
+ WhiteboardState.compactTextStyle(item, c);
1256
+ break;
1257
+ case 'shape':
1258
+ c.x = Math.round(item.X);
1259
+ c.y = Math.round(item.Y);
1260
+ c.w = Math.round(item.W);
1261
+ c.h = Math.round(item.H);
1262
+ c.shape = item.Shape;
1263
+ c.text = clip(item.Sub ? `${item.Label} (${item.Sub})` : item.Label, 120);
1264
+ break;
1265
+ case 'text':
1266
+ c.x = Math.round(item.X);
1267
+ c.y = Math.round(item.Y);
1268
+ if (item.W) {
1269
+ c.w = Math.round(item.W);
1270
+ }
1271
+ c.text = clip(item.Text, 120);
1272
+ WhiteboardState.compactTextStyle(item, c);
1273
+ if (item.Color) {
1274
+ c.color = item.Color;
1275
+ }
1276
+ break;
1277
+ case 'image':
1278
+ c.x = Math.round(item.X);
1279
+ c.y = Math.round(item.Y);
1280
+ c.name = item.Name;
1281
+ break;
1282
+ case 'ink': {
1283
+ const b = this.ItemBounds(item);
1284
+ c.x = Math.round(b.X);
1285
+ c.y = Math.round(b.Y);
1286
+ c.w = Math.round(b.W);
1287
+ c.h = Math.round(b.H);
1288
+ c.points = item.Points.length;
1289
+ break;
1290
+ }
1291
+ case 'connector':
1292
+ c.from = item.FromItemID ?? item.FromPoint ?? null;
1293
+ c.to = item.ToItemID ?? item.ToPoint ?? null;
1294
+ break;
1295
+ case 'highlight':
1296
+ c.x = Math.round(item.X);
1297
+ c.y = Math.round(item.Y);
1298
+ c.w = Math.round(item.W);
1299
+ c.h = Math.round(item.H);
1300
+ if (item.Label) {
1301
+ c.text = clip(item.Label, 120);
1302
+ }
1303
+ break;
1304
+ case 'markdown':
1305
+ // the agent must SEE what's in the panel — project the clipped SOURCE, not a stub
1306
+ c.x = Math.round(item.X);
1307
+ c.y = Math.round(item.Y);
1308
+ c.w = Math.round(item.W);
1309
+ if (item.H != null) {
1310
+ c.h = Math.round(item.H);
1311
+ }
1312
+ c.text = clip(item.Markdown, 200);
1313
+ break;
1314
+ case 'html':
1315
+ c.x = Math.round(item.X);
1316
+ c.y = Math.round(item.Y);
1317
+ c.w = Math.round(item.W);
1318
+ c.h = Math.round(item.H);
1319
+ if (item.Title) {
1320
+ c.name = clip(item.Title, 80);
1321
+ }
1322
+ c.text = clip(item.Html, 200);
1323
+ break;
1324
+ }
1325
+ return c;
1326
+ }
1327
+ /** Project the optional text-style fields onto a compact item (sticky / text kinds). */
1328
+ static compactTextStyle(item, c) {
1329
+ if (item.FontSize != null) {
1330
+ c.fontSize = item.FontSize;
1331
+ }
1332
+ if (item.FontFamily) {
1333
+ c.fontFamily = item.FontFamily;
1334
+ }
1335
+ if (item.FontWeight === 700) {
1336
+ c.bold = true;
1337
+ }
1338
+ }
1339
+ /**
1340
+ * Compose the human-readable tail line of deltas / summaries ("2 added, 1 moved. …"),
1341
+ * including which page is active and the full page list — so the model always knows
1342
+ * pages exist and which one its item entries describe.
1343
+ */
1344
+ composeSummaryText(parts) {
1345
+ const bits = [];
1346
+ if (parts.reset) {
1347
+ bits.push('full scene snapshot (state replaced)');
1348
+ }
1349
+ else {
1350
+ if (parts.added) {
1351
+ bits.push(`${parts.added} added`);
1352
+ }
1353
+ if (parts.moved) {
1354
+ bits.push(`${parts.moved} moved`);
1355
+ }
1356
+ if (parts.updated) {
1357
+ bits.push(`${parts.updated} updated`);
1358
+ }
1359
+ if (parts.removed) {
1360
+ bits.push(`${parts.removed} removed`);
1361
+ }
1362
+ if (bits.length === 0) {
1363
+ bits.push('no changes');
1364
+ }
1365
+ }
1366
+ return `${bits.join(', ')}. ${this.composePagesText()}`;
1367
+ }
1368
+ /** The page-aware scene sentence shared by every delta / summary tail line. */
1369
+ composePagesText() {
1370
+ const active = this.activePage;
1371
+ const index = this.pages.indexOf(active) + 1;
1372
+ const counts = `${this.ElementCount} elements (user ${this.CountByAuthor('user')} · agent ${this.CountByAuthor('agent')})`;
1373
+ if (this.pages.length === 1) {
1374
+ return `Active page "${active.Name}" (the only page) has ${counts}.`;
1375
+ }
1376
+ const others = this.pages
1377
+ .filter((p) => !UUIDsEqual(p.ID, active.ID))
1378
+ .map((p) => `"${p.Name}" (${p.Items.size})`)
1379
+ .join(', ');
1380
+ return `Active page "${active.Name}" (${index} of ${this.pages.length}) has ${counts}. Other pages: ${others}.`;
1381
+ }
1382
+ static describe(item) {
1383
+ switch (item.Kind) {
1384
+ case 'sticky': return `a sticky note ("${clip(item.Text)}")`;
1385
+ case 'shape': return `a ${item.Shape} ("${clip(item.Label)}")`;
1386
+ case 'text': return `a text label ("${clip(item.Text)}")`;
1387
+ case 'image': return `an image (${item.Name})`;
1388
+ case 'ink': return 'an ink stroke';
1389
+ case 'connector': return 'a connector';
1390
+ case 'highlight': return 'a highlight region';
1391
+ case 'markdown': return `a markdown panel ("${clip(item.Markdown)}")`;
1392
+ case 'html': return `an HTML widget${item.Title ? ` ("${clip(item.Title)}")` : ''}`;
1393
+ }
1394
+ }
1395
+ }
1396
+ //# sourceMappingURL=whiteboard-state.js.map