@memberjunction/ng-whiteboard 0.0.1 → 5.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +302 -28
- package/dist/lib/whiteboard-agent-sees-popover.component.d.ts +31 -0
- package/dist/lib/whiteboard-agent-sees-popover.component.d.ts.map +1 -0
- package/dist/lib/whiteboard-agent-sees-popover.component.js +132 -0
- package/dist/lib/whiteboard-agent-sees-popover.component.js.map +1 -0
- package/dist/lib/whiteboard-board.component.d.ts +458 -0
- package/dist/lib/whiteboard-board.component.d.ts.map +1 -0
- package/dist/lib/whiteboard-board.component.js +2357 -0
- package/dist/lib/whiteboard-board.component.js.map +1 -0
- package/dist/lib/whiteboard-context-menu.d.ts +57 -0
- package/dist/lib/whiteboard-context-menu.d.ts.map +1 -0
- package/dist/lib/whiteboard-context-menu.js +69 -0
- package/dist/lib/whiteboard-context-menu.js.map +1 -0
- package/dist/lib/whiteboard-export.d.ts +94 -0
- package/dist/lib/whiteboard-export.d.ts.map +1 -0
- package/dist/lib/whiteboard-export.js +592 -0
- package/dist/lib/whiteboard-export.js.map +1 -0
- package/dist/lib/whiteboard-host.component.d.ts +174 -0
- package/dist/lib/whiteboard-host.component.d.ts.map +1 -0
- package/dist/lib/whiteboard-host.component.js +758 -0
- package/dist/lib/whiteboard-host.component.js.map +1 -0
- package/dist/lib/whiteboard-pages.component.d.ts +83 -0
- package/dist/lib/whiteboard-pages.component.d.ts.map +1 -0
- package/dist/lib/whiteboard-pages.component.js +211 -0
- package/dist/lib/whiteboard-pages.component.js.map +1 -0
- package/dist/lib/whiteboard-snapshot.component.d.ts +30 -0
- package/dist/lib/whiteboard-snapshot.component.d.ts.map +1 -0
- package/dist/lib/whiteboard-snapshot.component.js +91 -0
- package/dist/lib/whiteboard-snapshot.component.js.map +1 -0
- package/dist/lib/whiteboard-srcdoc.pipe.d.ts +60 -0
- package/dist/lib/whiteboard-srcdoc.pipe.d.ts.map +1 -0
- package/dist/lib/whiteboard-srcdoc.pipe.js +75 -0
- package/dist/lib/whiteboard-srcdoc.pipe.js.map +1 -0
- package/dist/lib/whiteboard-state.d.ts +1113 -0
- package/dist/lib/whiteboard-state.d.ts.map +1 -0
- package/dist/lib/whiteboard-state.js +1396 -0
- package/dist/lib/whiteboard-state.js.map +1 -0
- package/dist/lib/whiteboard-toolbar.component.d.ts +95 -0
- package/dist/lib/whiteboard-toolbar.component.d.ts.map +1 -0
- package/dist/lib/whiteboard-toolbar.component.js +344 -0
- package/dist/lib/whiteboard-toolbar.component.js.map +1 -0
- package/dist/lib/whiteboard-tools.d.ts +103 -0
- package/dist/lib/whiteboard-tools.d.ts.map +1 -0
- package/dist/lib/whiteboard-tools.js +748 -0
- package/dist/lib/whiteboard-tools.js.map +1 -0
- package/dist/lib/whiteboard-widget-bridge.d.ts +217 -0
- package/dist/lib/whiteboard-widget-bridge.d.ts.map +1 -0
- package/dist/lib/whiteboard-widget-bridge.js +254 -0
- package/dist/lib/whiteboard-widget-bridge.js.map +1 -0
- package/dist/lib/whiteboard-zoom.component.d.ts +55 -0
- package/dist/lib/whiteboard-zoom.component.d.ts.map +1 -0
- package/dist/lib/whiteboard-zoom.component.js +139 -0
- package/dist/lib/whiteboard-zoom.component.js.map +1 -0
- package/dist/lib/whiteboard.module.d.ts +29 -0
- package/dist/lib/whiteboard.module.d.ts.map +1 -0
- package/dist/lib/whiteboard.module.js +78 -0
- package/dist/lib/whiteboard.module.js.map +1 -0
- package/dist/public-api.d.ts +49 -0
- package/dist/public-api.d.ts.map +1 -0
- package/dist/public-api.js +59 -0
- package/dist/public-api.js.map +1 -0
- 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
|