@mosetta/content 0.0.0-stage → 0.0.10

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/src/editing.ts ADDED
@@ -0,0 +1,1304 @@
1
+ // The client-side EDITING SESSION (stage 5): shared state of "which page draft is
2
+ // open, what's selected, what changed" for the workbench's smart widgets. Lives HERE because it
3
+ // operates purely on content-model documents — zero react, plain listeners; widgets subscribe via
4
+ // useSyncExternalStore. The session never touches the content API: it is the single source of the
5
+ // draft being edited, and WRITING that draft out belongs to a service alongside it (the editor's
6
+ // EditingAutosave) — not to this class, and not to whichever panel happens to be open.
7
+ import { DiContainer, SingleDiToken } from '@mosetta/di';
8
+ import {
9
+ ContentKind,
10
+ DEFAULT_SLOT,
11
+ IBlueprintNodeDoc,
12
+ IPageAction,
13
+ IPageDoc,
14
+ PAGE_HISTORY_LIMIT,
15
+ IPageMeta,
16
+ IPageMetaWords,
17
+ IPrefabField,
18
+ } from './model';
19
+ import { unitOwnerOf } from './units';
20
+
21
+ /**
22
+ * Where the next block goes, as picked in the preview: a place inside a parent's slot, at a
23
+ * position among that slot's children. Only slots the preview actually RENDERS can be picked —
24
+ * the editor learns them from the running page, not from a manifest.
25
+ */
26
+ export interface IInsertionPoint {
27
+ readonly parentId: string;
28
+ readonly slot: string;
29
+ /** position among the children of THIS slot (children of other slots are not counted) */
30
+ readonly index: number;
31
+ /** what the place calls itself, in the preview's own relative words ("add before heading") —
32
+ * whoever shows the pending insert repeats these words instead of naming raw node ids */
33
+ readonly label?: string;
34
+ }
35
+
36
+ /** a SLOT as the selection: not a node — copy/cut act on its children, a paste lands inside it */
37
+ export interface ISelectedSlot {
38
+ readonly parentId: string;
39
+ readonly slot: string;
40
+ }
41
+
42
+ export interface IEditingState {
43
+ /** what is open: a page, or a prefab — edited in a page's shape (prefabAsPage) */
44
+ readonly kind: ContentKind;
45
+ /** the open entity's name — a page's or a prefab's */
46
+ readonly pageName: string | null;
47
+ /** the DRAFT being edited (saves target it); null while a published revision is inspected */
48
+ readonly draftId: string | null;
49
+ /**
50
+ * The PUBLISHED (or archived) revision being INSPECTED — read-only by the content model
51
+ * (versions are immutable). Non-null puts the whole session in the read-only mode: the one
52
+ * mutation doorway refuses, so nothing dirties, nothing autosaves, and every compose affordance
53
+ * downstream can simply ask this field. Exclusive with `draftId` by construction.
54
+ */
55
+ readonly revision: number | null;
56
+ /**
57
+ * The draft's last PERSISTED write sequence — the optimistic-concurrency token. The working doc
58
+ * is not the authority on it (its own `seq` goes stale the moment editing starts): the autosave
59
+ * stamps `seq + 1` on what it sends, and only a landed save advances it (markSaved). A save
60
+ * refused with CONFLICT means someone else advanced the chain — fork or roll back.
61
+ */
62
+ readonly seq: number;
63
+ readonly doc: IPageDoc | null;
64
+ readonly selectedNodeId: string | null;
65
+ /**
66
+ * The node under the pointer in THIS session's canvas — ephemeral, never a mode: the tree wears
67
+ * a pale highlight for it (paler than the selection), and nothing else reacts. Hover is also
68
+ * what ACTIVATES a session (focus follows the pointer across canvases) — which is exactly why
69
+ * consumers that care about the last deliberate CHOICE (the settings panel) follow the hub's
70
+ * `selection` facade instead of the active one.
71
+ */
72
+ readonly hoveredNodeId: string | null;
73
+ /**
74
+ * Bumped by every deliberate selection ACT (selectNode, selectSlot, openPage) — the hub's
75
+ * selection anchor follows acts, not diffs: re-picking the very node already selected here
76
+ * must still pull the anchor back from wherever it wandered, and the ids alone cannot say so.
77
+ */
78
+ readonly selectionStamp: number;
79
+ /** the blue selection's OTHER shape: a slot instead of a node — exclusive with everything else */
80
+ readonly selectedSlot: ISelectedSlot | null;
81
+ /**
82
+ * Slots the RUNNING page declares, reported by the preview (the canvas feeds them in): the one
83
+ * way the tree can show EMPTY slots — the document only knows slots that hold children.
84
+ */
85
+ readonly declaredSlots: readonly ISelectedSlot[];
86
+ /**
87
+ * The node being MOVED (the tree's move mode) — its own exclusive mode, isolated from the rest
88
+ * by construction: entering it dissolves every selection, and any selection dissolves it. The
89
+ * isolation is the point — mixed modes breed emergent bugs.
90
+ */
91
+ readonly movingNodeId: string | null;
92
+ /**
93
+ * The nodes picked with Ctrl (multi-selection, the substrate of copy/paste) — a FLAT set of
94
+ * ids: pickedness belongs to each element, not to its place in the hierarchy. A Ctrl-click
95
+ * puts an element in (or takes it out) TOGETHER with its subtree, and touches nothing else —
96
+ * so a parent stays picked when one of its children is excluded, and a copy of "the card
97
+ * without this text" is expressible. Non-empty means the yellow mode, which excludes the
98
+ * ordinary selection and the insertion point — and they exclude it back.
99
+ */
100
+ readonly multiSelectedIds: readonly string[];
101
+ /** picked in the preview; absent → an insert lands at the end of the selected node */
102
+ readonly insertionPoint: IInsertionPoint | null;
103
+ /**
104
+ * Whether the preview is being INSPECTED: outlines drawn, clicks taken as selection. Turned off,
105
+ * the preview behaves like the live page — which is the only way to try a menu or a form inside
106
+ * it — and nothing can be composed until it is back on.
107
+ */
108
+ readonly inspecting: boolean;
109
+ readonly dirty: boolean;
110
+ /**
111
+ * Why the draft is not on disk, when a write was tried and refused — a code for the widget that
112
+ * shows it. The session carries it because "is my work saved" is a question about the session,
113
+ * not about whichever panel happens to be open.
114
+ */
115
+ readonly saveFailure: string | null;
116
+ /** bumped on every successful save — the canvas keys its preview iframe on it (refresh signal) */
117
+ readonly savedCount: number;
118
+ /**
119
+ * The VIEWS the author chose for reference providers, by provider name — i18n: `{ lang }`, the
120
+ * language being edited. Editor state, never document: forms read and write references through
121
+ * it, the canvas previews it. Workbench-wide by the hub (one switch for every canvas).
122
+ */
123
+ readonly views: Readonly<Record<string, unknown>>;
124
+ }
125
+
126
+ const IDLE: IEditingState = {
127
+ kind: 'pages', pageName: null, draftId: null, revision: null, seq: 1, doc: null, selectedNodeId: null, hoveredNodeId: null,
128
+ selectionStamp: 0, selectedSlot: null, declaredSlots: [], multiSelectedIds: [], insertionPoint: null,
129
+ movingNodeId: null, inspecting: true, dirty: false, saveFailure: null, savedCount: 0, views: {},
130
+ };
131
+
132
+ // ---- pure immutable tree ops (unit-testable without a session) ----
133
+
134
+ export function findNodeDoc(root: IBlueprintNodeDoc, id: string): IBlueprintNodeDoc | null {
135
+ if (root.id === id) return root;
136
+ for (const child of root.children ?? []) {
137
+ const hit = findNodeDoc(child, id);
138
+ if (hit) return hit;
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /** every id in the branch under `node`, the node itself included */
144
+ function subtreeIds(node: IBlueprintNodeDoc): string[] {
145
+ return [node.id, ...(node.children ?? []).flatMap(subtreeIds)];
146
+ }
147
+
148
+ /** the picked nodes that START a region (their parent is not picked), in document order */
149
+ function pickedRegionStarts(doc: IBlueprintNodeDoc, picked: readonly string[]): string[] {
150
+ const starts: string[] = [];
151
+ const walk = (node: IBlueprintNodeDoc, parentPicked: boolean): void => {
152
+ const isPicked = picked.includes(node.id);
153
+ if (isPicked && !parentPicked) starts.push(node.id);
154
+ for (const child of node.children ?? []) walk(child, isPicked);
155
+ };
156
+ for (const child of doc.children ?? []) walk(child, false);
157
+ return starts;
158
+ }
159
+
160
+ /** the node holding `id` among its children — null for the root itself and for unknown ids */
161
+ function findParentNode(root: IBlueprintNodeDoc, id: string): IBlueprintNodeDoc | null {
162
+ for (const child of root.children ?? []) {
163
+ if (child.id === id) return root;
164
+ const deeper = findParentNode(child, id);
165
+ if (deeper) return deeper;
166
+ }
167
+ return null;
168
+ }
169
+
170
+ /**
171
+ * What a copy takes from a flat multi-selection: the PICKED FOREST. A picked node keeps only its
172
+ * picked children (the holes stay holes — "the card without this text"), and a picked node whose
173
+ * parent is NOT picked starts a top-level block of its own, however deep it sits. The document
174
+ * root is a page, not a block — it never enters a copy; picking it copies its picked children.
175
+ */
176
+ export function pickedForest(doc: IBlueprintNodeDoc, picked: readonly string[]): IBlueprintNodeDoc[] {
177
+ const prune = (node: IBlueprintNodeDoc): IBlueprintNodeDoc => ({
178
+ ...node,
179
+ children: (node.children ?? []).filter((child) => picked.includes(child.id)).map(prune),
180
+ });
181
+ const roots: IBlueprintNodeDoc[] = [];
182
+ const walk = (node: IBlueprintNodeDoc, parentPicked: boolean): void => {
183
+ const isPicked = picked.includes(node.id);
184
+ if (isPicked && !parentPicked) roots.push(prune(node));
185
+ for (const child of node.children ?? []) walk(child, isPicked);
186
+ };
187
+ for (const child of doc.children ?? []) walk(child, false);
188
+ return roots;
189
+ }
190
+
191
+ /**
192
+ * Fresh identities for arriving blocks: every node gets the next `node-<n>` the document does not
193
+ * hold — a copy may come from this very page (ids would collide) or from another one (ids mean
194
+ * nothing here). Top-level blocks also shed their `slot`: the slot they were copied out of belongs
195
+ * to the OLD parent, and the insert stamps the new one's. Children keep theirs — their parents
196
+ * came along.
197
+ */
198
+ export function renumberBlocks(doc: IBlueprintNodeDoc, blocks: readonly IBlueprintNodeDoc[]): IBlueprintNodeDoc[] {
199
+ let max = 0;
200
+ (function scan(node: IBlueprintNodeDoc) {
201
+ const match = /^node-(\d+)$/.exec(node.id);
202
+ if (match) max = Math.max(max, Number(match[1]));
203
+ for (const child of node.children ?? []) scan(child);
204
+ })(doc);
205
+ const fresh = (node: IBlueprintNodeDoc, topLevel: boolean): IBlueprintNodeDoc => {
206
+ max += 1;
207
+ const { slot, ...rest } = node;
208
+ return {
209
+ ...rest,
210
+ id: `node-${max}`,
211
+ ...(topLevel || slot === undefined ? {} : { slot }),
212
+ children: (node.children ?? []).map((child) => fresh(child, false)),
213
+ };
214
+ };
215
+ return blocks.map((block) => fresh(block, true));
216
+ }
217
+
218
+ /** replace/remove (fn → null) the node with `id`; untouched subtrees keep identity */
219
+ function transformNode(
220
+ node: IBlueprintNodeDoc,
221
+ id: string,
222
+ fn: (n: IBlueprintNodeDoc) => IBlueprintNodeDoc | null,
223
+ ): IBlueprintNodeDoc | null {
224
+ if (node.id === id) return fn(node);
225
+ let changed = false;
226
+ const children: IBlueprintNodeDoc[] = [];
227
+ for (const child of node.children ?? []) {
228
+ const next = transformNode(child, id, fn);
229
+ if (next !== child) changed = true;
230
+ if (next) children.push(next);
231
+ }
232
+ return changed ? { ...node, children } : node;
233
+ }
234
+
235
+ /** the meta with the patch merged; an emptied field (or a flag turned off) leaves the doc */
236
+ function mergeMeta(meta: IPageMeta, patch: Partial<IPageMeta>): IPageMeta {
237
+ const merged: Record<string, unknown> = {};
238
+ for (const [key, value] of Object.entries({ ...meta, ...patch })) {
239
+ if (key === 'i18n') {
240
+ if (value && typeof value === 'object' && Object.keys(value).length) merged[key] = value;
241
+ } else if ((typeof value === 'string' && value !== '') || value === true) merged[key] = value;
242
+ }
243
+ return merged;
244
+ }
245
+
246
+ /** the meta with `lang`'s words merged in; an emptied word, then an empty language, leaves the doc */
247
+ function mergeMetaWords(meta: IPageMeta, byLang: Readonly<Record<string, IPageMetaWords>>): IPageMeta {
248
+ const i18n: Record<string, IPageMetaWords> = { ...meta.i18n };
249
+ for (const [lang, patch] of Object.entries(byLang)) {
250
+ const words: Record<string, string> = {};
251
+ for (const [key, value] of Object.entries({ ...i18n[lang], ...patch })) {
252
+ if ((key === 'title' || key === 'description') && typeof value === 'string' && value !== '') words[key] = value;
253
+ }
254
+ if (Object.keys(words).length) i18n[lang] = words;
255
+ else delete i18n[lang];
256
+ }
257
+ return mergeMeta(meta, { i18n });
258
+ }
259
+
260
+ /** the doc with this meta; an empty meta leaves the doc */
261
+ function withMeta(doc: IPageDoc, meta: IPageMeta): IPageDoc {
262
+ const { meta: old, ...rest } = doc;
263
+ void old;
264
+ return Object.keys(meta).length ? { ...rest, meta } : rest;
265
+ }
266
+
267
+ export function updateNodeSettingsInDoc(doc: IPageDoc, id: string, patch: Record<string, unknown>): IPageDoc {
268
+ return transformNode(doc, id, (n) => ({ ...n, settings: { ...n.settings, ...patch } })) as IPageDoc;
269
+ }
270
+
271
+ const slotOf = (node: IBlueprintNodeDoc): string => node.slot ?? DEFAULT_SLOT;
272
+
273
+ /**
274
+ * Insert under `parentId`. Without `at` the child lands at the very end (the old behaviour, used
275
+ * when nothing was picked in the preview). With `at` it lands at that position WITHIN ITS SLOT:
276
+ * children of one slot are scattered through the array, so the index is resolved against the
277
+ * slot's own sequence and the array position is derived from it.
278
+ */
279
+ export function insertChildInDoc(
280
+ doc: IPageDoc,
281
+ parentId: string,
282
+ child: IBlueprintNodeDoc,
283
+ at?: { slot: string; index: number },
284
+ ): IPageDoc {
285
+ return transformNode(doc, parentId, (n) => {
286
+ const children = [...(n.children ?? [])];
287
+ if (!at) return { ...n, children: [...children, child] };
288
+ const placed = at.slot === DEFAULT_SLOT ? child : { ...child, slot: at.slot };
289
+ const sameSlot = children.map((c, i) => ({ c, i })).filter(({ c }) => slotOf(c) === at.slot);
290
+ // past the last child of the slot → right after it; the slot being empty → the very end
291
+ const position = sameSlot[at.index]?.i
292
+ ?? (sameSlot.length ? sameSlot[sameSlot.length - 1].i + 1 : children.length);
293
+ children.splice(position, 0, placed);
294
+ return { ...n, children };
295
+ }) as IPageDoc;
296
+ }
297
+
298
+ // node-level `ssr` flag (SSR epic stage 4): true on the ROOT enables server rendering of the
299
+ // page; false on any node opts its whole branch out (cannot be re-enabled deeper — no islands).
300
+ // undefined removes the field (keeps stored docs free of noise).
301
+ export function setNodeSsrInDoc(doc: IPageDoc, id: string, ssr: boolean | undefined): IPageDoc {
302
+ return transformNode(doc, id, (n) => {
303
+ const next = { ...n } as IBlueprintNodeDoc & { ssr?: boolean };
304
+ if (ssr === undefined) delete next.ssr;
305
+ else next.ssr = ssr;
306
+ return next;
307
+ }) as IPageDoc;
308
+ }
309
+
310
+ /** node-level `autoBind` flag: a provider linking new blocks' fields at once; false removes the field */
311
+ export function setNodeAutoBindInDoc(doc: IPageDoc, id: string, on: boolean): IPageDoc {
312
+ return transformNode(doc, id, (n) => {
313
+ const next = { ...n };
314
+ if (on) (next as { autoBind?: boolean }).autoBind = true;
315
+ else delete (next as { autoBind?: boolean }).autoBind;
316
+ return next;
317
+ }) as IPageDoc;
318
+ }
319
+
320
+ export function removeNodeFromDoc(doc: IPageDoc, id: string): IPageDoc {
321
+ if (doc.id === id) return doc; // the root is the page itself — not removable
322
+ return transformNode(doc, id, () => null) as IPageDoc;
323
+ }
324
+
325
+ /**
326
+ * Move a node (with its whole subtree) to `index` in `parentId`'s `slot`, or refuse with null.
327
+ * Two traps live here. The INDEX SHIFT: the index describes the document with the moving node
328
+ * still in it, so it is converted to an ANCHOR (the child currently at that position) before the
329
+ * removal, and the anchor is re-found afterwards. The CYCLE: a target inside the moving branch
330
+ * disappears together with it — the parent lookup after removal is the guard, for free.
331
+ */
332
+ export function moveNodeInDoc(
333
+ doc: IPageDoc,
334
+ id: string,
335
+ parentId: string,
336
+ slot: string,
337
+ index: number,
338
+ ): IPageDoc | null {
339
+ if (id === doc.id) return null; // the root is the page — it does not move
340
+ const moving = findNodeDoc(doc, id);
341
+ const parentBefore = findNodeDoc(doc, parentId);
342
+ if (!moving || !parentBefore) return null;
343
+ const anchor = (parentBefore.children ?? []).filter((child) => (child.slot ?? DEFAULT_SLOT) === slot)[index] ?? null;
344
+ if (anchor?.id === id) return null; // right before itself — the same place
345
+ const without = removeNodeFromDoc(doc, id);
346
+ const parentAfter = findNodeDoc(without, parentId);
347
+ if (!parentAfter) return null; // the target lived inside the moving branch — a cycle
348
+ const siblings = (parentAfter.children ?? []).filter((child) => (child.slot ?? DEFAULT_SLOT) === slot);
349
+ const at = anchor ? siblings.findIndex((child) => child.id === anchor.id) : siblings.length;
350
+ // the moving node adopts the TARGET's slot; its children keep theirs — they came along
351
+ const { slot: _old, ...bare } = moving;
352
+ return insertChildInDoc(without, parentId, bare, { slot, index: at === -1 ? siblings.length : at });
353
+ }
354
+
355
+
356
+ /**
357
+ * Record a mutation in the draft itself: `next` gets a history entry holding the action and the
358
+ * page as it was before, capped at PAGE_HISTORY_LIMIT (the oldest step falls off). The snapshot
359
+ * goes in with both stacks stripped — entries never nest. A fresh edit is the fork where the
360
+ * undone future becomes unreachable, so the redo list is cleared.
361
+ */
362
+ export function recordAction(prev: IPageDoc, action: IPageAction, next: IPageDoc): IPageDoc {
363
+ const { history: _prior, redo: _undone, ...before } = prev;
364
+ const history = [...(prev.history ?? []), { action, before }].slice(-PAGE_HISTORY_LIMIT);
365
+ const { redo: _stale, ...cleanNext } = next;
366
+ return { ...cleanNext, history };
367
+ }
368
+
369
+ /** next `node-<n>` id not colliding with ANY existing id in the doc */
370
+ export function nextNodeId(doc: IBlueprintNodeDoc): string {
371
+ let max = 0;
372
+ (function walk(n: IBlueprintNodeDoc) {
373
+ const m = /^node-(\d+)$/.exec(n.id);
374
+ if (m) max = Math.max(max, Number(m[1]));
375
+ for (const c of n.children ?? []) walk(c);
376
+ })(doc);
377
+ return `node-${max + 1}`;
378
+ }
379
+
380
+ // ---- the session ----
381
+
382
+ export class EditingSession {
383
+ private state: IEditingState = IDLE;
384
+
385
+ private readonly listeners = new Set<() => void>();
386
+
387
+ readonly subscribe = (listener: () => void): (() => void) => {
388
+ this.listeners.add(listener);
389
+ return () => this.listeners.delete(listener);
390
+ };
391
+
392
+ readonly snapshot = (): IEditingState => this.state;
393
+
394
+ private set(next: IEditingState): void {
395
+ this.state = next;
396
+ for (const listener of this.listeners) listener();
397
+ }
398
+
399
+ /** open a DRAFT for editing; the root starts selected (= default insertion target) */
400
+ openPage(pageName: string, draftId: string, doc: IPageDoc, kind: ContentKind = 'pages'): void {
401
+ this.set({
402
+ ...this.state,
403
+ kind,
404
+ pageName,
405
+ draftId,
406
+ revision: null,
407
+ seq: doc.seq ?? 1,
408
+ doc,
409
+ selectedNodeId: doc.id,
410
+ hoveredNodeId: null,
411
+ selectionStamp: this.state.selectionStamp + 1,
412
+ selectedSlot: null,
413
+ declaredSlots: [],
414
+ multiSelectedIds: [],
415
+ insertionPoint: null,
416
+ movingNodeId: null,
417
+ dirty: false,
418
+ saveFailure: null,
419
+ savedCount: 0,
420
+ });
421
+ }
422
+
423
+ /**
424
+ * Open a PUBLISHED (or archived) revision for read-only inspection. Same session, same panels —
425
+ * selection, hover and the preview all work — but the mutation doorway is shut (`revision`
426
+ * non-null), so the tree cannot change and nothing ever dirties or autosaves.
427
+ */
428
+ openRevision(pageName: string, revision: number, doc: IPageDoc, kind: ContentKind = 'pages'): void {
429
+ this.set({
430
+ ...this.state,
431
+ kind,
432
+ pageName,
433
+ draftId: null,
434
+ revision,
435
+ seq: 1,
436
+ doc,
437
+ selectedNodeId: doc.id,
438
+ hoveredNodeId: null,
439
+ selectionStamp: this.state.selectionStamp + 1,
440
+ selectedSlot: null,
441
+ declaredSlots: [],
442
+ multiSelectedIds: [],
443
+ insertionPoint: null,
444
+ movingNodeId: null,
445
+ dirty: false,
446
+ saveFailure: null,
447
+ savedCount: 0,
448
+ });
449
+ }
450
+
451
+ closePage(): void {
452
+ this.set(IDLE);
453
+ }
454
+
455
+ /** the ORDINARY selection — one blue node; taking it dissolves every other mode (exclusive) */
456
+ selectNode(id: string | null): void {
457
+ if (!this.state.doc) return;
458
+ this.set({
459
+ ...this.state,
460
+ selectedNodeId: id,
461
+ selectedSlot: null,
462
+ multiSelectedIds: [],
463
+ movingNodeId: null,
464
+ selectionStamp: this.state.selectionStamp + 1,
465
+ });
466
+ }
467
+
468
+ /** the pointer moved over a node (or off every node) — ephemeral, disturbs no mode */
469
+ hoverNode(id: string | null): void {
470
+ const { doc, hoveredNodeId } = this.state;
471
+ // ids the draft no longer knows (an edit landed after the frame rendered) read as "nothing";
472
+ // a prefab's block (`<prefabNode>/<block>`) is known while its prefab node is
473
+ const next = id && doc && findNodeDoc(doc, unitOwnerOf(id) ?? id) ? id : null;
474
+ if (next === hoveredNodeId) return;
475
+ this.set({ ...this.state, hoveredNodeId: next });
476
+ }
477
+
478
+ /**
479
+ * The blue selection leaves QUIETLY — no act, no stamp: a canvas surrenders its own selection
480
+ * when it sees the workspace's selection anchor living elsewhere (ONE blue block per
481
+ * workspace), and that surrender must never read as a new pick — a stamp bump here would have
482
+ * the anchor stolen right back by the very canvas that was told to let go.
483
+ */
484
+ retireSelection(): void {
485
+ const { selectedNodeId, selectedSlot } = this.state;
486
+ if (selectedNodeId === null && selectedSlot === null) return;
487
+ this.set({ ...this.state, selectedNodeId: null, selectedSlot: null });
488
+ }
489
+
490
+ /**
491
+ * The blue selection's OTHER shape: a SLOT. Not a node — what it means is its children: a copy
492
+ * takes them, a cut removes them (the slot itself is structure and stays), a paste lands INSIDE
493
+ * it, at the end. Exclusive with every other mode, like any selection.
494
+ */
495
+ selectSlot(parentId: string, slot: string): void {
496
+ const { doc } = this.state;
497
+ if (!doc || !findNodeDoc(doc, parentId)) return;
498
+ this.set({
499
+ ...this.state,
500
+ selectedSlot: { parentId, slot },
501
+ selectedNodeId: null,
502
+ multiSelectedIds: [],
503
+ insertionPoint: null,
504
+ movingNodeId: null,
505
+ selectionStamp: this.state.selectionStamp + 1,
506
+ });
507
+ }
508
+
509
+ /**
510
+ * A Ctrl-click: an UNPICKED element joins the set with its whole subtree; a PICKED one leaves
511
+ * with its subtree — and NOTHING ELSE moves. Deselecting a child does not touch its parent:
512
+ * pickedness is per element, and the hole left behind is exactly what a copy wants to express.
513
+ * The three modes are exclusive BY THE SESSION, not by the widgets: the yellow pick clears the
514
+ * blue selection and the pending insertion point, whoever sent it.
515
+ */
516
+ toggleMultiSelect(id: string): void {
517
+ const { doc, multiSelectedIds } = this.state;
518
+ if (!doc) return;
519
+ const branch = findNodeDoc(doc, id);
520
+ if (!branch) return;
521
+ const family = subtreeIds(branch);
522
+ const picked = multiSelectedIds.includes(id)
523
+ ? multiSelectedIds.filter((node) => !family.includes(node))
524
+ : [...multiSelectedIds, ...family.filter((node) => !multiSelectedIds.includes(node))];
525
+ this.set({ ...this.state, multiSelectedIds: picked, selectedNodeId: null, selectedSlot: null, insertionPoint: null, movingNodeId: null });
526
+ }
527
+
528
+ /**
529
+ * A Ctrl-click on a SLOT ROW: the slot is not a node, it is a DOOR to its children — the
530
+ * gesture passes through. Any child missing → all of them join (subtrees included); all of
531
+ * them already in → all of them leave.
532
+ */
533
+ toggleMultiSelectSlot(parentId: string, slot: string): void {
534
+ const { doc, multiSelectedIds } = this.state;
535
+ if (!doc) return;
536
+ const parent = findNodeDoc(doc, parentId);
537
+ if (!parent) return;
538
+ const kids = (parent.children ?? []).filter((child) => (child.slot ?? DEFAULT_SLOT) === slot);
539
+ if (!kids.length) return;
540
+ const families = kids.flatMap(subtreeIds);
541
+ const picked = kids.every((kid) => multiSelectedIds.includes(kid.id))
542
+ ? multiSelectedIds.filter((id) => !families.includes(id))
543
+ : [...multiSelectedIds, ...families.filter((id) => !multiSelectedIds.includes(id))];
544
+ this.set({ ...this.state, multiSelectedIds: picked, selectedNodeId: null, selectedSlot: null, insertionPoint: null, movingNodeId: null });
545
+ }
546
+
547
+ /**
548
+ * The preview reported what slots the running page declares. Geometry arrives on every
549
+ * measurement, so an unchanged list is dropped here — without waking a single subscriber.
550
+ */
551
+ setDeclaredSlots(slots: readonly ISelectedSlot[]): void {
552
+ const current = this.state.declaredSlots;
553
+ const same = slots.length === current.length
554
+ && slots.every((slot, i) => current[i].parentId === slot.parentId && current[i].slot === slot.slot);
555
+ if (same) return;
556
+ this.set({ ...this.state, declaredSlots: slots });
557
+ }
558
+
559
+ /**
560
+ * Enter the MOVE mode for one node (the tree's per-row button): its own exclusive mode —
561
+ * the isolation from every selection is deliberate, mixed modes breed emergent bugs. The
562
+ * root does not move; picking a place is commitMove, leaving is cancelMove (or Escape).
563
+ */
564
+ startMove(id: string): void {
565
+ const { doc } = this.state;
566
+ if (!doc || id === doc.id || !findNodeDoc(doc, id)) return;
567
+ if (this.state.revision !== null) return; // a read-only view offers no mode that edits
568
+ this.set({
569
+ ...this.state,
570
+ movingNodeId: id,
571
+ selectedNodeId: null,
572
+ selectedSlot: null,
573
+ multiSelectedIds: [],
574
+ insertionPoint: null,
575
+ });
576
+ }
577
+
578
+ cancelMove(): void {
579
+ if (this.state.movingNodeId === null) return;
580
+ this.set({ ...this.state, movingNodeId: null });
581
+ }
582
+
583
+ /**
584
+ * The purple tip was clicked: the moving node lands at `index` of `parentId`'s `slot` — one
585
+ * action through the one channel. A refused move (a cycle, the same place) just leaves the
586
+ * mode; the moved node comes out blue-selected.
587
+ */
588
+ commitMove(parentId: string, slot: string, index: number): void {
589
+ const { doc, movingNodeId } = this.state;
590
+ if (!doc || !movingNodeId) return;
591
+ const next = moveNodeInDoc(doc, movingNodeId, parentId, slot, index);
592
+ if (!next) {
593
+ this.set({ ...this.state, movingNodeId: null });
594
+ return;
595
+ }
596
+ this.mutate({ type: 'move', nodeId: movingNodeId }, next, {
597
+ movingNodeId: null,
598
+ selectedNodeId: movingNodeId,
599
+ });
600
+ }
601
+
602
+ /** the preview is handed back to the visitor, or taken back for editing */
603
+ setInspecting(inspecting: boolean): void {
604
+ this.set({ ...this.state, inspecting, ...(inspecting ? {} : { insertionPoint: null }) });
605
+ }
606
+
607
+ /** a "+" in the preview was pressed (or dismissed) — the next insert targets exactly this place */
608
+ setInsertionPoint(point: IInsertionPoint | null): void {
609
+ if (!this.state.doc) return;
610
+ if (point && this.state.revision !== null) return; // nothing can be inserted into a revision
611
+ // naming a place is another mode — it dissolves the others (dismissing names nothing)
612
+ this.set({
613
+ ...this.state,
614
+ insertionPoint: point,
615
+ ...(point ? { multiSelectedIds: [], selectedSlot: null, movingNodeId: null } : {}),
616
+ });
617
+ }
618
+
619
+ /**
620
+ * THE mutation channel: every change to the page tree passes through here, and here alone the
621
+ * draft learns its history — the action plus the page as it was before (recordAction). One
622
+ * doorway is what lets copy/paste and undo/redo manipulate edits instead of growing their own.
623
+ */
624
+ private mutate(action: IPageAction, next: IPageDoc, rest: Partial<IEditingState> = {}): void {
625
+ const { doc } = this.state;
626
+ if (!doc) return;
627
+ if (this.state.revision !== null) return; // an inspected revision is immutable — refuse quietly
628
+ const recorded = recordAction(doc, action, next);
629
+ this.set({
630
+ ...this.state,
631
+ ...rest,
632
+ doc: recorded,
633
+ // a node the edit removed cannot stay picked; rest may hand the set over (a paste clears it)
634
+ multiSelectedIds: (rest.multiSelectedIds ?? this.state.multiSelectedIds)
635
+ .filter((node) => findNodeDoc(recorded, node) !== null),
636
+ hoveredNodeId: this.state.hoveredNodeId && findNodeDoc(recorded, this.state.hoveredNodeId)
637
+ ? this.state.hoveredNodeId
638
+ : null,
639
+ selectedSlot: (() => {
640
+ const kept = rest.selectedSlot !== undefined ? rest.selectedSlot : this.state.selectedSlot;
641
+ return kept && findNodeDoc(recorded, kept.parentId) ? kept : null;
642
+ })(),
643
+ movingNodeId: (() => {
644
+ const kept = rest.movingNodeId !== undefined ? rest.movingNodeId : this.state.movingNodeId;
645
+ return kept && findNodeDoc(recorded, kept) ? kept : null;
646
+ })(),
647
+ dirty: true,
648
+ });
649
+ }
650
+
651
+ updateNodeSettings(id: string, patch: Record<string, unknown>): void {
652
+ if (!this.state.doc) return;
653
+ this.mutate({ type: 'settings', nodeId: id }, updateNodeSettingsInDoc(this.state.doc, id, patch));
654
+ }
655
+
656
+ /**
657
+ * Several nodes' settings in ONE undoable step — a write through a reference touches the
658
+ * provider, binding a field touches the provider and the consumer, and the author made one edit.
659
+ */
660
+ updateNodesSettings(patches: readonly { readonly nodeId: string; readonly patch: Record<string, unknown> }[]): void {
661
+ const { doc } = this.state;
662
+ if (!doc || !patches.length) return;
663
+ const next = patches.reduce((acc, { nodeId, patch }) => updateNodeSettingsInDoc(acc, nodeId, patch), doc);
664
+ this.mutate({ type: 'settings', nodeId: patches[0].nodeId }, next);
665
+ }
666
+
667
+ /** the page's own words about itself (title, description, cover, date) — one undoable step per patch */
668
+ setPageMeta(patch: Partial<IPageMeta>): void {
669
+ const { doc } = this.state;
670
+ if (!doc) return;
671
+ this.mutate({ type: 'settings', nodeId: doc.id }, withMeta(doc, mergeMeta(doc.meta ?? {}, patch)));
672
+ }
673
+
674
+ /**
675
+ * The page's title and description in other languages, by language code — one undoable step for
676
+ * all of them (a translation fills several at once). An emptied word reads the original again.
677
+ */
678
+ setPageMetaWords(byLang: Readonly<Record<string, IPageMetaWords>>): void {
679
+ const { doc } = this.state;
680
+ if (!doc || !Object.keys(byLang).length) return;
681
+ this.mutate({ type: 'settings', nodeId: doc.id }, withMeta(doc, mergeMetaWords(doc.meta ?? {}, byLang)));
682
+ }
683
+
684
+ /**
685
+ * A PREFAB's open fields — the settings each page placing it sets for itself. One undoable step;
686
+ * an empty list leaves the document. Meaningful only while a prefab is open.
687
+ */
688
+ setPrefabFields(fields: readonly IPrefabField[]): void {
689
+ const { doc } = this.state;
690
+ if (!doc) return;
691
+ const { fields: _old, ...rest } = doc;
692
+ this.mutate({ type: 'settings', nodeId: doc.id }, fields.length ? { ...rest, fields } : rest);
693
+ }
694
+
695
+ /** the author's view of a reference provider (i18n: the language being edited); null clears it */
696
+ setView(name: string, view: unknown): void {
697
+ if (Object.is(this.state.views[name] ?? null, view ?? null)) return;
698
+ const { [name]: old, ...rest } = this.state.views;
699
+ void old;
700
+ this.set({ ...this.state, views: view == null ? rest : { ...rest, [name]: view } });
701
+ }
702
+
703
+ /**
704
+ * The root's block — the page's root component (a prefab's preview frame): swapped for another,
705
+ * the children stay where they are. Chosen in the editor, not at creation: what a root
706
+ * component is needs the page in front of one to make sense.
707
+ */
708
+ setRootReference(reference: string): void {
709
+ const { doc } = this.state;
710
+ if (!doc || doc.reference === reference) return;
711
+ this.mutate({ type: 'settings', nodeId: doc.id }, { ...doc, reference });
712
+ }
713
+
714
+ /**
715
+ * Blocks moved to another version of themselves (ADR-0061) in ONE undoable step: each node takes
716
+ * its new reference and its settings as the new version reads them (migrated by the caller — the
717
+ * block's own `upgrade`, its new defaults). Children, slot and every other field stay.
718
+ */
719
+ upgradeNodes(changes: readonly { readonly nodeId: string; readonly reference: string; readonly settings: Record<string, unknown> }[]): void {
720
+ const { doc } = this.state;
721
+ if (!doc || !changes.length || this.state.revision !== null) return;
722
+ const next = changes.reduce(
723
+ (acc, { nodeId, reference, settings }) => transformNode(acc, nodeId, (n) => ({ ...n, reference, settings })) as IPageDoc,
724
+ doc,
725
+ );
726
+ this.mutate({ type: 'settings', nodeId: changes[0].nodeId }, next);
727
+ }
728
+
729
+ /** a provider takes new blocks under its wing — or stops (node-level `autoBind`) */
730
+ setNodeAutoBind(id: string, on: boolean): void {
731
+ if (!this.state.doc) return;
732
+ this.mutate({ type: 'settings', nodeId: id }, setNodeAutoBindInDoc(this.state.doc, id, on));
733
+ }
734
+
735
+ /**
736
+ * Settings changes that FOLLOW the step just made, folded into it — an insert and the links its
737
+ * block gets from a provider are one edit, one Ctrl+Z. Folded only while that step is still the
738
+ * last one (`action`, as recorded); otherwise — the author did something meanwhile — they become
739
+ * a step of their own. Answers whether they were folded in.
740
+ */
741
+ amendStep(action: IPageAction, patches: readonly { readonly nodeId: string; readonly patch: Record<string, unknown> }[]): boolean {
742
+ const { doc } = this.state;
743
+ if (!doc || !patches.length || this.state.revision !== null) return false;
744
+ const last = doc.history?.[doc.history.length - 1]?.action;
745
+ if (!last || last.type !== action.type || last.nodeId !== action.nodeId) {
746
+ this.updateNodesSettings(patches);
747
+ return false;
748
+ }
749
+ const next = patches.reduce((acc, { nodeId, patch }) => updateNodeSettingsInDoc(acc, nodeId, patch), doc);
750
+ this.set({ ...this.state, doc: next, dirty: true });
751
+ return true;
752
+ }
753
+
754
+ /** toggle the node-level ssr flag (root: enable page SSR; deeper: opt the branch out) */
755
+ setNodeSsr(id: string, ssr: boolean | undefined): void {
756
+ if (!this.state.doc) return;
757
+ this.mutate({ type: 'ssr', nodeId: id }, setNodeSsrInDoc(this.state.doc, id, ssr));
758
+ }
759
+
760
+ /**
761
+ * Insert into the place picked in the preview; with nothing picked — at the end of the selected
762
+ * node (the root when nothing is selected). The new node gets selected, and the picked place is
763
+ * consumed: its index describes a tree that no longer exists.
764
+ */
765
+ insertNode(reference: string, settings: Record<string, unknown>): void {
766
+ const { doc, selectedNodeId, insertionPoint } = this.state;
767
+ if (!doc) return;
768
+ const node: IBlueprintNodeDoc = { id: nextNodeId(doc), reference, settings, children: [] };
769
+ const parentId = insertionPoint?.parentId ?? selectedNodeId ?? doc.id;
770
+ // a prefab's block selected (seen, not edited) holds no children of the page's
771
+ if (!findNodeDoc(doc, parentId)) return;
772
+ const at = insertionPoint ? { slot: insertionPoint.slot, index: insertionPoint.index } : undefined;
773
+ this.mutate({ type: 'insert', nodeId: node.id, reference }, insertChildInDoc(doc, parentId, node, at), {
774
+ selectedNodeId: node.id,
775
+ insertionPoint: null,
776
+ });
777
+ }
778
+
779
+ /**
780
+ * The blocks a copy takes, in document order: the picked forest (the yellow mode), or the blue
781
+ * branch whole. The page root is not a block — a blue selection on it copies nothing (picking
782
+ * it YELLOW copies its picked children, see pickedForest).
783
+ */
784
+ copySelection(): IBlueprintNodeDoc[] {
785
+ const { doc, multiSelectedIds, selectedNodeId, selectedSlot } = this.state;
786
+ if (!doc) return [];
787
+ if (selectedSlot) {
788
+ // a selected slot MEANS its children: each one a top-level block, subtrees whole
789
+ const parent = findNodeDoc(doc, selectedSlot.parentId);
790
+ return (parent?.children ?? []).filter((child) => (child.slot ?? DEFAULT_SLOT) === selectedSlot.slot);
791
+ }
792
+ if (multiSelectedIds.length) return pickedForest(doc, multiSelectedIds);
793
+ if (selectedNodeId && selectedNodeId !== doc.id) {
794
+ const branch = findNodeDoc(doc, selectedNodeId);
795
+ return branch ? [branch] : [];
796
+ }
797
+ return [];
798
+ }
799
+
800
+ /**
801
+ * Insert arriving blocks — freshly renumbered — into the place picked in the preview; with no
802
+ * place, NEXT TO the selection, never inside it: whether a block can host children is the
803
+ * PREVIEW's knowledge (slots are learned from the running page, not from a manifest), so the
804
+ * paste takes the one spot the document itself vouches for — the selected node's own slot,
805
+ * right after it. The root is the exception: it demonstrably hosts, blocks land at its end.
806
+ * The WHOLE paste is one action through the one channel, so one Ctrl+Z takes all of it back.
807
+ * The first pasted block gets selected, the picked place is consumed.
808
+ */
809
+ pasteNodes(blocks: readonly IBlueprintNodeDoc[]): void {
810
+ const { doc, selectedNodeId, multiSelectedIds, insertionPoint } = this.state;
811
+ if (!doc || !blocks.length) return;
812
+ const arriving = renumberBlocks(doc, blocks);
813
+ // right AFTER the anchor node, in its own slot — the one spot the document vouches for
814
+ const allAfter = (anchorId: string): IPageDoc | null => {
815
+ const anchor = findNodeDoc(doc, anchorId);
816
+ const parent = anchor ? findParentNode(doc, anchorId) : null;
817
+ if (!anchor || !parent) return null;
818
+ const slotName = anchor.slot ?? DEFAULT_SLOT;
819
+ const siblings = (parent.children ?? []).filter((child) => (child.slot ?? DEFAULT_SLOT) === slotName);
820
+ const position = siblings.findIndex((child) => child.id === anchorId) + 1;
821
+ let acc: IPageDoc = doc;
822
+ arriving.forEach((block, index) => {
823
+ acc = insertChildInDoc(acc, parent.id, block, { slot: slotName, index: position + index });
824
+ });
825
+ return acc;
826
+ };
827
+ const { selectedSlot } = this.state;
828
+ let next: IPageDoc | null = null;
829
+ if (selectedSlot && findNodeDoc(doc, selectedSlot.parentId)) {
830
+ // INTO the selected slot, at the end of its children
831
+ const host = findNodeDoc(doc, selectedSlot.parentId)!;
832
+ const already = (host.children ?? []).filter((child) => (child.slot ?? DEFAULT_SLOT) === selectedSlot.slot).length;
833
+ next = doc;
834
+ arriving.forEach((block, index) => {
835
+ next = insertChildInDoc(next!, selectedSlot.parentId, block, {
836
+ slot: selectedSlot.slot,
837
+ index: already + index,
838
+ });
839
+ });
840
+ } else if (insertionPoint && findNodeDoc(doc, insertionPoint.parentId)) {
841
+ next = doc;
842
+ arriving.forEach((block, index) => {
843
+ next = insertChildInDoc(next!, insertionPoint.parentId, block, {
844
+ slot: insertionPoint.slot,
845
+ index: insertionPoint.index + index,
846
+ });
847
+ });
848
+ } else if (multiSelectedIds.length) {
849
+ // the yellow mode has no blue anchor: the paste follows the LAST picked region — so
850
+ // picking a slot's children and pasting lands into that very slot, after them
851
+ const starts = pickedRegionStarts(doc, multiSelectedIds);
852
+ next = starts.length ? allAfter(starts[starts.length - 1]) : null;
853
+ } else if (selectedNodeId && selectedNodeId !== doc.id) {
854
+ // a prefab's block selected: the paste lands after its prefab, the page's own block
855
+ next = allAfter(unitOwnerOf(selectedNodeId) ?? selectedNodeId);
856
+ }
857
+ if (next === null) {
858
+ next = doc;
859
+ for (const block of arriving) next = insertChildInDoc(next, doc.id, block);
860
+ }
861
+ this.mutate({ type: 'paste', nodeId: arriving[0].id }, next, {
862
+ selectedNodeId: arriving[0].id,
863
+ selectedSlot: null,
864
+ multiSelectedIds: [],
865
+ insertionPoint: null,
866
+ });
867
+ }
868
+
869
+ /**
870
+ * The removing half of a CUT (the copy is copySelection — the two are one gesture, split so
871
+ * the clipboard can be written before the tree changes). What leaves the page are the picked
872
+ * REGION STARTS whole: an unpicked child of a picked parent has nowhere to stay and goes with
873
+ * it — deliberately, while the clipboard copy still carries only what was picked. One action,
874
+ * one undo. The blue selection cuts its branch; the root cannot be cut.
875
+ */
876
+ removeSelection(): void {
877
+ const { doc, multiSelectedIds, selectedNodeId, insertionPoint } = this.state;
878
+ if (!doc) return;
879
+ const { selectedSlot } = this.state;
880
+ // a selected slot cuts its CHILDREN; the slot is structure, it stays selected for the paste back
881
+ const slotParent = selectedSlot ? findNodeDoc(doc, selectedSlot.parentId) : null;
882
+ const starts: string[] = selectedSlot
883
+ ? (slotParent?.children ?? [])
884
+ .filter((child) => (child.slot ?? DEFAULT_SLOT) === selectedSlot.slot)
885
+ .map((child) => child.id)
886
+ : multiSelectedIds.length
887
+ ? pickedRegionStarts(doc, multiSelectedIds)
888
+ : (selectedNodeId && selectedNodeId !== doc.id && findNodeDoc(doc, selectedNodeId) ? [selectedNodeId] : []);
889
+ if (!starts.length) return;
890
+ let next: IPageDoc = doc;
891
+ for (const id of starts) next = removeNodeFromDoc(next, id);
892
+ this.mutate({ type: 'cut', nodeId: starts[0] }, next, {
893
+ selectedNodeId: selectedNodeId && !findNodeDoc(next, selectedNodeId) ? doc.id : selectedNodeId,
894
+ insertionPoint: insertionPoint && findNodeDoc(next, insertionPoint.parentId) ? insertionPoint : null,
895
+ });
896
+ }
897
+
898
+ removeNode(id: string): void {
899
+ const { doc, selectedNodeId, insertionPoint } = this.state;
900
+ // the root stays; a prefab's block is the prefab's — not the page's to remove
901
+ if (!doc || doc.id === id || !findNodeDoc(doc, id)) return;
902
+ const next = removeNodeFromDoc(doc, id);
903
+ this.mutate({ type: 'remove', nodeId: id }, next, {
904
+ selectedNodeId: selectedNodeId === id ? doc.id : selectedNodeId,
905
+ // the picked place lived inside what just left
906
+ insertionPoint: insertionPoint && findNodeDoc(next, insertionPoint.parentId) ? insertionPoint : null,
907
+ });
908
+ }
909
+
910
+ /**
911
+ * Step back: the top history entry leaves the stack, its snapshot becomes the page, and the
912
+ * step waits on the redo list — inside the document, like the history itself, so both
913
+ * directions survive a reload together. No entry is ever RECORDED by this: undo and redo move
914
+ * steps between the stacks, only real edits (mutate) create them.
915
+ */
916
+ undo(): void {
917
+ const { doc } = this.state;
918
+ if (!doc?.history?.length) return;
919
+ const entry = doc.history[doc.history.length - 1];
920
+ const { history: _h, redo: _r, ...after } = doc;
921
+ const remaining = doc.history.slice(0, -1);
922
+ this.restore({
923
+ ...entry.before,
924
+ ...(remaining.length ? { history: remaining } : {}),
925
+ redo: [...(doc.redo ?? []), { action: entry.action, after }].slice(-PAGE_HISTORY_LIMIT),
926
+ });
927
+ }
928
+
929
+ /** step forward again: the mirror of undo — the top redo entry goes back onto the history */
930
+ redo(): void {
931
+ const { doc } = this.state;
932
+ if (!doc?.redo?.length) return;
933
+ const entry = doc.redo[doc.redo.length - 1];
934
+ const { history: _h, redo: _r, ...before } = doc;
935
+ const remaining = doc.redo.slice(0, -1);
936
+ this.restore({
937
+ ...entry.after,
938
+ history: [...(doc.history ?? []), { action: entry.action, before }].slice(-PAGE_HISTORY_LIMIT),
939
+ ...(remaining.length ? { redo: remaining } : {}),
940
+ });
941
+ }
942
+
943
+ /**
944
+ * Swap the document wholesale and keep the pointers honest: a selection whose node is gone
945
+ * falls back to the root, a picked place dies with its parent. Dirty like any edit — the
946
+ * autosave writes the restored page out by the same rule.
947
+ */
948
+ private restore(next: IPageDoc): void {
949
+ if (this.state.revision !== null) return; // undo/redo have nothing to do in a read-only view
950
+ const { selectedNodeId, multiSelectedIds, insertionPoint } = this.state;
951
+ this.set({
952
+ ...this.state,
953
+ doc: next,
954
+ // a selection whose node is gone falls back to the root; NO selection stays none — grabbing
955
+ // the root here would drag the blue mode over an active yellow one. A prefab's block stays
956
+ // while its prefab node does
957
+ selectedNodeId: selectedNodeId ? (findNodeDoc(next, unitOwnerOf(selectedNodeId) ?? selectedNodeId) ? selectedNodeId : next.id) : null,
958
+ hoveredNodeId: this.state.hoveredNodeId && findNodeDoc(next, unitOwnerOf(this.state.hoveredNodeId) ?? this.state.hoveredNodeId)
959
+ ? this.state.hoveredNodeId
960
+ : null,
961
+ multiSelectedIds: multiSelectedIds.filter((node) => findNodeDoc(next, node) !== null),
962
+ selectedSlot: this.state.selectedSlot && findNodeDoc(next, this.state.selectedSlot.parentId)
963
+ ? this.state.selectedSlot
964
+ : null,
965
+ movingNodeId: this.state.movingNodeId && findNodeDoc(next, this.state.movingNodeId)
966
+ ? this.state.movingNodeId
967
+ : null,
968
+ insertionPoint: insertionPoint && findNodeDoc(next, insertionPoint.parentId) ? insertionPoint : null,
969
+ dirty: true,
970
+ });
971
+ }
972
+
973
+ /**
974
+ * A savePageDraft mutation landed ok. `saved` is the document that was SENT, and the flag is
975
+ * cleared only if the session still holds it: an edit made while the request was in flight is
976
+ * NOT on disk, and clearing the flag for it would leave the last change unsaved with nothing
977
+ * saying so — the dot goes out, no further save is scheduled, and the next thing to touch the
978
+ * draft decides whether it survives.
979
+ */
980
+ markSaved(saved: IPageDoc, seq: number): void {
981
+ if (!this.state.doc) return;
982
+ this.set({
983
+ ...this.state,
984
+ dirty: saved !== this.state.doc,
985
+ seq,
986
+ saveFailure: null,
987
+ savedCount: this.state.savedCount + 1,
988
+ });
989
+ }
990
+
991
+ /** a write was refused — the draft stays dirty, and now says why */
992
+ markSaveFailed(code: string): void {
993
+ this.set({ ...this.state, saveFailure: code });
994
+ }
995
+ }
996
+
997
+ export const EDITING_SESSION_TOKEN = new SingleDiToken<EditingSession>('EDITING_SESSION_TOKEN');
998
+
999
+ // ---- many sessions, one ACTIVE: the hub behind a multi-canvas workbench ------------------------
1000
+
1001
+ /** the key of the session work starts in before any canvas has bound itself */
1002
+ export const DEFAULT_EDITING_SESSION_KEY = 'default';
1003
+
1004
+ /**
1005
+ * The HUB of editing sessions: every canvas owns a session under its own key, and the hub's
1006
+ * `active` facade is what the rest of the workbench holds — a session-shaped view that routes
1007
+ * every call to the CURRENT session and re-emits its changes, so the tree and the hotkeys follow
1008
+ * whichever canvas the user last touched (a hover included) without knowing sessions switched.
1009
+ *
1010
+ * The SECOND facade, `selection`, follows the last deliberate CHOICE instead: it routes to the
1011
+ * session where a selection last CHANGED, whichever canvas is active now. Hover activates
1012
+ * sessions (focus follows the pointer), and a panel that shows "the element you picked" (the
1013
+ * settings) must not lose its subject to a pointer passing over another canvas — it keeps
1014
+ * showing the picked element until a pick happens elsewhere.
1015
+ *
1016
+ * Ownership starts loose: work begun before any canvas exists (a fresh draft opened from the
1017
+ * pages panel) lives in the DEFAULT session, and the first canvas to bind ADOPTS that very
1018
+ * instance — the open draft included — instead of starting empty.
1019
+ */
1020
+ export class EditingSessionHub {
1021
+ private readonly sessions = new Map<string, EditingSession>();
1022
+
1023
+ private readonly activeListeners = new Set<() => void>();
1024
+
1025
+ private readonly selectionListeners = new Set<() => void>();
1026
+
1027
+ /** each session's selectionStamp when last seen — the act detector */
1028
+ private readonly seenStamps = new Map<EditingSession, number>();
1029
+
1030
+ /** the permanent per-session subscriptions, so a retired session can be let go of */
1031
+ private readonly watchers = new Map<EditingSession, () => void>();
1032
+
1033
+ private currentKey = DEFAULT_EDITING_SESSION_KEY;
1034
+
1035
+ private selectionKey = DEFAULT_EDITING_SESSION_KEY;
1036
+
1037
+ /** the session-shaped view of whichever session is ACTIVE — what EDITING_SESSION_TOKEN holds */
1038
+ readonly active: EditingSession;
1039
+
1040
+ /** the session-shaped view of wherever the last SELECTION happened — the settings' anchor */
1041
+ readonly selection: EditingSession;
1042
+
1043
+ constructor() {
1044
+ const first = new EditingSession();
1045
+ this.sessions.set(this.currentKey, first);
1046
+ this.watch(first);
1047
+ // A Proxy rather than a hand-written delegator: the facade must follow the session's WHOLE
1048
+ // surface forever, and a forgotten method here would be a silent no-op there. TWO members are
1049
+ // intercepted with STABLE, late-binding functions: `subscribe` (subscribers belong to the
1050
+ // facade and survive switches) and `snapshot` — react's useSyncExternalStore CACHES the
1051
+ // getSnapshot it was rendered with, so a snapshot bound at property-ACCESS time would keep
1052
+ // reading the pre-switch session forever, and every post-switch notify would look like
1053
+ // "nothing changed" (a silent bailout instead of following the active canvas).
1054
+ this.active = this.facadeOver(() => this.currentSession(), this.subscribeActive, this.snapshotActive);
1055
+ this.selection = this.facadeOver(() => this.selectionSession(), this.subscribeSelection, this.snapshotSelection);
1056
+ }
1057
+
1058
+ /** the session bound to `key`, if one exists — a LOOK, never a creation (nor an adoption) */
1059
+ peek(key: string): EditingSession | null {
1060
+ return this.sessions.get(key) ?? null;
1061
+ }
1062
+
1063
+ /** the workbench's provider views — every session shows them, a new session inherits them */
1064
+ private views: Readonly<Record<string, unknown>> = {};
1065
+
1066
+ private readonly viewListeners = new Set<() => void>();
1067
+
1068
+ /** one provider's view for the whole workbench (null clears it) */
1069
+ setView(name: string, view: unknown): void {
1070
+ const { [name]: old, ...rest } = this.views;
1071
+ void old;
1072
+ this.views = view == null ? rest : { ...this.views, [name]: view };
1073
+ for (const session of this.sessions.values()) session.setView(name, view);
1074
+ for (const listener of [...this.viewListeners]) listener();
1075
+ }
1076
+
1077
+ readonly viewsSnapshot = (): Readonly<Record<string, unknown>> => this.views;
1078
+
1079
+ readonly subscribeViews = (listener: () => void): (() => void) => {
1080
+ this.viewListeners.add(listener);
1081
+ return () => {
1082
+ this.viewListeners.delete(listener);
1083
+ };
1084
+ };
1085
+
1086
+ /** the session bound to `key` — created on first ask, or ADOPTED from the default pool */
1087
+ sessionFor(key: string): EditingSession {
1088
+ const existing = this.sessions.get(key);
1089
+ if (existing) return existing;
1090
+ const unowned = this.sessions.get(DEFAULT_EDITING_SESSION_KEY);
1091
+ if (unowned && key !== DEFAULT_EDITING_SESSION_KEY) {
1092
+ this.sessions.delete(DEFAULT_EDITING_SESSION_KEY);
1093
+ this.sessions.set(key, unowned);
1094
+ if (this.currentKey === DEFAULT_EDITING_SESSION_KEY) this.currentKey = key;
1095
+ if (this.selectionKey === DEFAULT_EDITING_SESSION_KEY) this.selectionKey = key;
1096
+ return unowned;
1097
+ }
1098
+ const fresh = new EditingSession();
1099
+ for (const [name, view] of Object.entries(this.views)) fresh.setView(name, view);
1100
+ this.sessions.set(key, fresh);
1101
+ this.watch(fresh);
1102
+ return fresh;
1103
+ }
1104
+
1105
+ /** the key hover-borrowed the focus for; null whenever the activation was deliberate */
1106
+ private borrowedKey: string | null = null;
1107
+
1108
+ /** make `key`'s session the ACTIVE one — the facade switches and tells every subscriber */
1109
+ activate(key: string): void {
1110
+ this.borrowedKey = null;
1111
+ const before = this.currentSession();
1112
+ const target = this.sessionFor(key);
1113
+ this.currentKey = key;
1114
+ if (target !== before) this.notifyActive();
1115
+ }
1116
+
1117
+ /**
1118
+ * Make `key` the active session AND the selection anchor — the canvas in front after a reload:
1119
+ * the documents restored behind it were opened too, and the last of them to arrive must not
1120
+ * keep the anchor (the settings would show a canvas nobody sees).
1121
+ */
1122
+ focus(key: string): void {
1123
+ this.activate(key);
1124
+ if (this.selectionKey === key) return;
1125
+ this.selectionKey = key;
1126
+ this.notifySelection();
1127
+ }
1128
+
1129
+ /**
1130
+ * Hover BORROWS the focus rather than taking it: the session activates like any other, but
1131
+ * the activation is marked a LOAN — when the pointer leaves the canvas without a deliberate
1132
+ * act having happened (release), the focus goes home to the selection anchor. The invariant
1133
+ * this buys: at rest, the tree shows the canvas of the selected node.
1134
+ */
1135
+ borrow(key: string): void {
1136
+ // the pointer over the canvas that already HAS the focus (its tab picked, a click inside)
1137
+ // borrows nothing: its leave must keep it — the anchor may live in a canvas hidden behind it
1138
+ if (key === this.currentKey && this.borrowedKey === null) return;
1139
+ this.activate(key);
1140
+ this.borrowedKey = key;
1141
+ }
1142
+
1143
+ /** the pointer left `key`'s canvas: a focus still on loan for it returns to the anchor */
1144
+ release(key: string): void {
1145
+ if (this.borrowedKey !== key) return;
1146
+ this.activate(this.selectionKey);
1147
+ }
1148
+
1149
+ activeKey(): string {
1150
+ return this.currentKey;
1151
+ }
1152
+
1153
+ /** where the selection anchor lives — a canvas checks it to keep ONE blue block per workspace */
1154
+ anchorKey(): string {
1155
+ return this.selectionKey;
1156
+ }
1157
+
1158
+ /** every key a session lives under right now */
1159
+ keys(): string[] {
1160
+ return [...this.sessions.keys()];
1161
+ }
1162
+
1163
+ /**
1164
+ * A canvas tab CLOSED: its session leaves the hub for good. Whoever pointed at it falls back —
1165
+ * the active facade to a remaining session (dockview activates a neighbour tab anyway, and a
1166
+ * neighbouring CANVAS re-activates its own session right after; this fallback covers the
1167
+ * neighbour being no canvas at all), the selection anchor to wherever the active one landed.
1168
+ * The last session leaving re-seeds an empty default — the tree and the settings show their
1169
+ * placeholders instead of a dead canvas's world.
1170
+ */
1171
+ retire(key: string): void {
1172
+ const session = this.sessions.get(key);
1173
+ if (!session) return;
1174
+ this.sessions.delete(key);
1175
+ this.seenStamps.delete(session);
1176
+ this.watchers.get(session)?.();
1177
+ this.watchers.delete(session);
1178
+ if (this.borrowedKey === key) this.borrowedKey = null;
1179
+ if (this.sessions.size === 0) {
1180
+ const fresh = new EditingSession();
1181
+ this.sessions.set(DEFAULT_EDITING_SESSION_KEY, fresh);
1182
+ this.watch(fresh);
1183
+ }
1184
+ if (this.currentKey === key) {
1185
+ // a remaining canvas with an open draft beats an empty one — «the tree switches to the
1186
+ // canvas that is left», and only with nothing left does it show its placeholder
1187
+ const remaining = [...this.sessions.entries()];
1188
+ const withDoc = remaining.find(([, held]) => held.snapshot().doc !== null);
1189
+ this.currentKey = (withDoc ?? remaining[0])[0];
1190
+ this.notifyActive();
1191
+ }
1192
+ if (this.selectionKey === key) {
1193
+ this.selectionKey = this.currentKey;
1194
+ this.notifySelection();
1195
+ }
1196
+ }
1197
+
1198
+ private currentSession(): EditingSession {
1199
+ return this.sessions.get(this.currentKey)!;
1200
+ }
1201
+
1202
+ private selectionSession(): EditingSession {
1203
+ return this.sessions.get(this.selectionKey) ?? this.currentSession();
1204
+ }
1205
+
1206
+ /**
1207
+ * The hub listens to EVERY session it holds, permanently — that is what lets it see a
1208
+ * selection change in a canvas that is not active, and what feeds both facades from one wire.
1209
+ */
1210
+ private watch(session: EditingSession): void {
1211
+ this.seenStamps.set(session, session.snapshot().selectionStamp);
1212
+ this.watchers.set(session, session.subscribe(() => this.onSessionChanged(session)));
1213
+ }
1214
+
1215
+ private onSessionChanged(session: EditingSession): void {
1216
+ const now = session.snapshot();
1217
+ // ONLY an act moves the anchor (the stamp): a diff without one is somebody's programmatic
1218
+ // housekeeping — a canvas surrendering its blue selection (retireSelection), an edit pruning
1219
+ // a dead id — and mistaking it for a pick would drag the anchor around behind the janitor
1220
+ const selectionChanged = this.seenStamps.get(session) !== now.selectionStamp;
1221
+ this.seenStamps.set(session, now.selectionStamp);
1222
+ // the key is looked up at NOTIFY time: an adoption re-keys the session, the instance stays
1223
+ const key = this.keyOf(session);
1224
+ if (selectionChanged && key !== null && key !== this.selectionKey) {
1225
+ this.selectionKey = key;
1226
+ this.notifySelection();
1227
+ } else if (key === this.selectionKey) {
1228
+ this.notifySelection();
1229
+ }
1230
+ if (key === this.currentKey) this.notifyActive();
1231
+ }
1232
+
1233
+ private keyOf(session: EditingSession): string | null {
1234
+ for (const [key, held] of this.sessions) {
1235
+ if (held === session) return key;
1236
+ }
1237
+ return null;
1238
+ }
1239
+
1240
+ private facadeOver(
1241
+ current: () => EditingSession,
1242
+ subscribe: EditingSession['subscribe'],
1243
+ snapshot: EditingSession['snapshot'],
1244
+ ): EditingSession {
1245
+ return new Proxy(current(), {
1246
+ get: (_target, prop) => {
1247
+ if (prop === 'subscribe') return subscribe;
1248
+ if (prop === 'snapshot') return snapshot;
1249
+ const target = current();
1250
+ const value = Reflect.get(target, prop, target) as unknown;
1251
+ return typeof value === 'function'
1252
+ ? (value as (...args: unknown[]) => unknown).bind(target)
1253
+ : value;
1254
+ },
1255
+ });
1256
+ }
1257
+
1258
+ private notifyActive(): void {
1259
+ for (const listener of this.activeListeners) listener();
1260
+ }
1261
+
1262
+ private notifySelection(): void {
1263
+ for (const listener of this.selectionListeners) listener();
1264
+ }
1265
+
1266
+ private readonly snapshotActive = (): IEditingState => this.currentSession().snapshot();
1267
+
1268
+ private readonly subscribeActive = (listener: () => void): (() => void) => {
1269
+ this.activeListeners.add(listener);
1270
+ return () => this.activeListeners.delete(listener);
1271
+ };
1272
+
1273
+ private readonly snapshotSelection = (): IEditingState => this.selectionSession().snapshot();
1274
+
1275
+ private readonly subscribeSelection = (listener: () => void): (() => void) => {
1276
+ this.selectionListeners.add(listener);
1277
+ return () => this.selectionListeners.delete(listener);
1278
+ };
1279
+ }
1280
+
1281
+ export const EDITING_SESSION_HUB_TOKEN = new SingleDiToken<EditingSessionHub>('EDITING_SESSION_HUB_TOKEN');
1282
+
1283
+ /**
1284
+ * Lazily register the session on first use: the workbench's ROOT app belongs to the TARGET app
1285
+ * (the editor is injected into its devOnly page) and must not know editor services —
1286
+ * whichever widget renders first creates the shared instance. What the token holds is the HUB's
1287
+ * `active` facade; a container that already resolves the token (a preview environment seeding
1288
+ * its own specimen session) keeps what it has — no hub appears there.
1289
+ */
1290
+ export function ensureEditingSession(di: DiContainer): EditingSession {
1291
+ try {
1292
+ return di.getByToken(EDITING_SESSION_TOKEN);
1293
+ } catch {
1294
+ const hub = new EditingSessionHub();
1295
+ di.registerValueOfToken(EDITING_SESSION_HUB_TOKEN, hub);
1296
+ di.registerValueOfToken(EDITING_SESSION_TOKEN, hub.active);
1297
+ return hub.active;
1298
+ }
1299
+ }
1300
+
1301
+ /** the hub, where one exists — null in preview environments and anywhere pre-hub */
1302
+ export function editingSessionHubOf(di: DiContainer): EditingSessionHub | null {
1303
+ return di.hasByToken(EDITING_SESSION_HUB_TOKEN) ? di.getByToken(EDITING_SESSION_HUB_TOKEN) : null;
1304
+ }