edytor 0.1.0-next.21 → 0.1.0-next.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +1 -1
  2. package/dist/cloudflare/DocumentRoom.d.ts +302 -10
  3. package/dist/cloudflare/DocumentRoom.js +965 -88
  4. package/dist/cloudflare/index.d.ts +1 -1
  5. package/dist/cloudflare/index.js +1 -1
  6. package/dist/cloudflare/routeDocumentSocket.js +30 -6
  7. package/dist/collaboration/awarenessSelection.d.ts +7 -1
  8. package/dist/collaboration/awarenessSelection.js +7 -1
  9. package/dist/collaboration/index.d.ts +2 -2
  10. package/dist/collaboration/index.js +2 -2
  11. package/dist/collaboration/providers.d.ts +4 -0
  12. package/dist/collaboration/providers.js +4 -0
  13. package/dist/components/Edytor.svelte +14 -2
  14. package/dist/components/Edytor.svelte.d.ts +8 -1
  15. package/dist/crdt/admission.d.ts +6 -1
  16. package/dist/crdt/admission.js +9 -2
  17. package/dist/crdt/data.d.ts +3 -1
  18. package/dist/crdt/data.js +42 -32
  19. package/dist/crdt/document.d.ts +31 -8
  20. package/dist/crdt/document.js +53 -18
  21. package/dist/crdt/edytor-doc.d.ts +31 -3
  22. package/dist/crdt/edytor-doc.js +131 -14
  23. package/dist/crdt/engine.d.ts +6 -0
  24. package/dist/crdt/engine.js +7 -0
  25. package/dist/crdt/flow.d.ts +2 -0
  26. package/dist/crdt/flow.js +2 -2
  27. package/dist/crdt/index.d.ts +20 -9
  28. package/dist/crdt/index.js +3 -3
  29. package/dist/crdt/migration/migrate.js +9 -3
  30. package/dist/crdt/placement/model.d.ts +9 -1
  31. package/dist/crdt/placement/model.js +29 -10
  32. package/dist/crdt/placement/rank.d.ts +20 -0
  33. package/dist/crdt/placement/rank.js +79 -0
  34. package/dist/crdt/protocols/envelope.d.ts +20 -1
  35. package/dist/crdt/protocols/envelope.js +14 -2
  36. package/dist/crdt/providers/container.d.ts +15 -0
  37. package/dist/crdt/providers/container.js +17 -3
  38. package/dist/crdt/providers/index.d.ts +21 -1
  39. package/dist/crdt/providers/index.js +86 -3
  40. package/dist/crdt/providers/indexeddb.js +60 -40
  41. package/dist/crdt/providers/room.d.ts +18 -2
  42. package/dist/crdt/providers/room.js +29 -5
  43. package/dist/crdt/providers/websocket.d.ts +9 -1
  44. package/dist/crdt/providers/websocket.js +21 -6
  45. package/dist/crdt/storage.d.ts +21 -0
  46. package/dist/crdt/storage.js +37 -0
  47. package/dist/crdt/structs.d.ts +17 -0
  48. package/dist/crdt/structs.js +41 -0
  49. package/dist/crdt/text/model.d.ts +9 -1
  50. package/dist/crdt/text/model.js +24 -14
  51. package/dist/crdt/text/rows.d.ts +94 -0
  52. package/dist/crdt/text/rows.js +193 -0
  53. package/dist/crdt/text/runs.d.ts +30 -10
  54. package/dist/crdt/text/runs.js +1235 -264
  55. package/dist/edytor.svelte.d.ts +3 -1
  56. package/dist/edytor.svelte.js +9 -6
  57. package/dist/index.d.ts +1 -1
  58. package/dist/index.js +1 -1
  59. package/dist/plugins/image/ImageEmpty.svelte +19 -6
  60. package/dist/plugins/image/ImagePlugin.svelte +9 -3
  61. package/dist/plugins/image/ImagePlugin.svelte.d.ts +2 -2
  62. package/dist/plugins/image/image.d.ts +16 -0
  63. package/dist/plugins/image/image.js +19 -0
  64. package/dist/plugins/index.d.ts +1 -1
  65. package/dist/plugins/index.js +1 -1
  66. package/dist/plugins.d.ts +11 -0
  67. package/dist/session/commands.d.ts +1 -0
  68. package/dist/session/commands.js +4 -1
  69. package/package.json +1 -1
@@ -1,7 +1,8 @@
1
- import { candidatesOf, childrenIndex, displayIndex, documentOrder, REGISTRY_KEY, resolvePlacements } from '../placement/model.js';
2
- import { bindText, canonKey, claimGraph, DEAD, deepFreeze, delimiters, displayOf, placeText, protectItems, readClaims, scanText } from './model.js';
1
+ import { candidatesOf, bySlot, childrenIndex, displayIndex, displaySlotOf, documentOrder, REGISTRY_KEY, resolvePlacements } from '../placement/model.js';
2
+ import { bindText, byId, canonKey, claimGraph, cmpStamp, DEAD, deepFreeze, delimiters, displayOf, isBoundary, placeText, protectItems, readClaims, scanText } from './model.js';
3
+ import { gapOfItem, headOf, placeRow, rowLength, rowOf, sameRow, segmentAt, segmentOfCut, segmentOfGap, shiftGap, streamOfSegment } from './rows.js';
3
4
  import { AT, CLAIMS, CONTENT, DATA, DATA_LEAF_PREFIX, DOC_DATA_ROOT, hasDeleteMark, hasWithdrawMark, ID, isNodeLike, LAST_CHANGED_ATTR, NONCE, TYPE } from '../schema.js';
4
- import { walkIdSetStructs } from '../structs.js';
5
+ import { queuedTransactions, walkIdSetRanges, walkIdSetStructs } from '../structs.js';
5
6
  import { cloneJsonSafe, sameIds } from '../../utils/json.js';
6
7
  import { readData } from '../data.js';
7
8
  import { callEach } from '../protocols/observable.js';
@@ -70,6 +71,15 @@ const sameShape = (roles, a, b) => {
70
71
  };
71
72
  /** One index per engine doc, shared by every binding (the doc's lifetime). */
72
73
  const indexes = new WeakMap();
74
+ /**
75
+ * Test lanes turn this on (`globalThis.__EDYTOR_INDEX_CHECKS__`, set before
76
+ * the index loads): after every fold the index checks each fact it keeps
77
+ * incrementally against a rebuild from the replicated state and throws on
78
+ * the first difference. Off in production.
79
+ */
80
+ export const indexChecks = {
81
+ on: globalThis.__EDYTOR_INDEX_CHECKS__ === true
82
+ };
73
83
  export const bindRuns = (Y) => {
74
84
  const T = bindText(Y);
75
85
  /** The doc's index — built on first use, torn down when the doc is destroyed. */
@@ -85,38 +95,54 @@ export const bindRuns = (Y) => {
85
95
  const dataRoot = doc.get(DOC_DATA_ROOT);
86
96
  const docData = () => cloneJsonSafe(readData(dataRoot) ?? {});
87
97
  let dataChanged = false;
88
- const makeExtentIndex = (tr) => {
98
+ const NO_EDITS = Object.freeze({
99
+ edits: [],
100
+ scan: false,
101
+ opaque: false
102
+ });
103
+ const holdsBoundary = (s, from, to) => {
104
+ const arr = s.content?.arr;
105
+ if (arr === undefined)
106
+ return false;
107
+ for (let k = from - s.id.clock; k < to - s.id.clock; k++)
108
+ if (isBoundary(arr[k]))
109
+ return true;
110
+ return false;
111
+ };
112
+ /** The edits the id sets `ins`/`del` (what the fold has not folded yet) hold, per text. */
113
+ const makeEditIndex = (ins, del) => {
89
114
  let built;
90
115
  const build = () => {
91
116
  if (built !== undefined)
92
117
  return built;
93
- const tx = tr;
94
- if (tx?.insertSet === undefined || tx.deleteSet === undefined)
118
+ if (ins === undefined || del === undefined)
95
119
  return (built = null);
96
120
  const out = new Map();
97
- const visit = (idSet, deleted) => walkIdSetStructs(Y, doc, idSet, (s) => {
121
+ const visit = (idSet, deleted) => walkIdSetRanges(Y, doc, idSet, (s, from, to) => {
98
122
  const parent = s.parent;
99
123
  if (s.parentSub !== null || !isNodeLike(parent))
100
124
  return;
101
- const typeId = parent._item?.id;
102
- const abs = deleted || s.countable !== true || typeId === undefined
103
- ? null
104
- : Y.createAbsolutePositionFromRelativePosition(Y.createRelativePositionFromJSON({
105
- type: { client: typeId.client, clock: typeId.clock },
106
- item: { client: s.id.client, clock: s.id.clock },
107
- assoc: 0
108
- }), doc, false);
109
- const spans = out.get(parent);
110
- if (abs === null)
111
- out.set(parent, null);
112
- else if (spans !== null) {
113
- out.set(parent, [...(spans ?? []), [abs.index, abs.index + s.length]]);
114
- }
125
+ let e = out.get(parent);
126
+ if (e === undefined)
127
+ out.set(parent, (e = { edits: [], scan: false, opaque: false }));
128
+ // A format marker moves no unit; its effect reaches past the edit.
129
+ if (s.countable !== true)
130
+ return void (e.opaque = true);
131
+ // Written and removed within the fold: nothing moved.
132
+ if (deleted ? ins.has(s.id.client, from) : s.deleted)
133
+ return;
134
+ if (holdsBoundary(s, from, to))
135
+ return void (e.scan = true);
136
+ e.edits.push({ item: s, len: to - from, del: deleted });
115
137
  });
116
- built = visit(tx.insertSet, false) && visit(tx.deleteSet, true) ? out : null;
138
+ built = visit(ins, false) && visit(del, true) ? out : null;
117
139
  return built;
118
140
  };
119
- return (node) => build()?.get(node) ?? undefined;
141
+ /** `node`'s edits (`null`: unknown — rescan, every consumer recomputes). */
142
+ return (node) => {
143
+ const map = build();
144
+ return map === null ? null : (map.get(node) ?? NO_EDITS);
145
+ };
120
146
  };
121
147
  // ── the replicated-state index ──────────────────────────────────
122
148
  const blocks = new Map();
@@ -127,78 +153,349 @@ export const bindRuns = (Y) => {
127
153
  /** Forward dependencies of cached runs: backing text (by home) / walked block → consumers. */
128
154
  const textConsumers = new Map();
129
155
  const listConsumers = new Map();
130
- // ── the claim graph (lazy, per structure version) ─────────────────
131
- let owners = new Map();
132
- let tops = new Map();
133
- /** `-1` forces the first build (structureVersion starts at 0). */
134
- let ownersVersion = -1;
135
- let structureVersion = 0;
156
+ // ── the claim graph (P3: maintained) ─────────────────────────────
157
+ // `top(m)` is the max-stamp claim on `m` held by a live block; each
158
+ // block's owner follows `top` up (`claimGraph`). A structural change
159
+ // of a block (its claims, its delete mark, its record) re-decides the
160
+ // `top` of the blocks it claims (before and after) and the owners of
161
+ // those blocks and of every block whose `top` chain reaches them —
162
+ // never the whole graph.
163
+ const owners = new Map();
164
+ const topOf = new Map();
165
+ /** Claimer → the blocks it tops. */
166
+ const topInv = new Map();
167
+ /** Claimed block → the blocks whose claims list names it (live or not). */
168
+ const claimersOf = new Map();
169
+ /** Owner → the blocks it displays (itself included while it owns itself). */
170
+ const displaysMap = new Map();
171
+ /** Blocks whose claims, delete mark or record changed since the last owner pass. */
172
+ const ownerSeeds = new Set();
173
+ /** Blocks whose owner changed since the last placement pass. */
174
+ const ownerChanged = new Set();
175
+ let ownersBuilt = false;
176
+ const addTo = (index, key, value) => {
177
+ let set = index.get(key);
178
+ if (set === undefined)
179
+ index.set(key, (set = new Set()));
180
+ set.add(value);
181
+ };
182
+ const dropFrom = (index, key, value) => {
183
+ const set = index.get(key);
184
+ set?.delete(value);
185
+ if (set?.size === 0)
186
+ index.delete(key);
187
+ };
188
+ /** The max-stamp claim on `m` held by a live block. */
189
+ const topClaim = (m) => {
190
+ let best;
191
+ for (const holder of claimersOf.get(m) ?? []) {
192
+ const rec = blocks.get(holder);
193
+ if (rec === undefined || rec.deleted)
194
+ continue;
195
+ for (const c of rec.claims)
196
+ if (c.m === m && (best === undefined || cmpStamp(c.stamp, best.stamp) > 0))
197
+ best = { claimer: holder, stamp: c.stamp };
198
+ }
199
+ return best;
200
+ };
201
+ const setTop = (m, t) => {
202
+ const old = topOf.get(m);
203
+ if (old?.claimer === t?.claimer && (old === undefined || cmpStamp(old.stamp, t.stamp) === 0))
204
+ return false;
205
+ if (old !== undefined)
206
+ dropFrom(topInv, old.claimer, m);
207
+ if (t === undefined)
208
+ topOf.delete(m);
209
+ else {
210
+ topOf.set(m, t);
211
+ addTo(topInv, t.claimer, m);
212
+ }
213
+ return true;
214
+ };
215
+ /** `owner(b)` along `top` (`claimGraph`'s walk), memoized into `owners`. */
216
+ const walkOwner = (b) => {
217
+ const path = [];
218
+ let cur = b;
219
+ let result;
220
+ for (;;) {
221
+ const known = owners.get(cur);
222
+ if (known !== undefined) {
223
+ result = known;
224
+ break;
225
+ }
226
+ const rec = blocks.get(cur);
227
+ if (!rec || rec.deleted) {
228
+ result = DEAD;
229
+ break;
230
+ }
231
+ const at = path.indexOf(cur);
232
+ if (at >= 0) {
233
+ let best = topOf.get(path[at]);
234
+ for (const member of path.slice(at)) {
235
+ const t = topOf.get(member);
236
+ if (cmpStamp(t.stamp, best.stamp) > 0)
237
+ best = t;
238
+ }
239
+ result = best.claimer;
240
+ break;
241
+ }
242
+ const t = topOf.get(cur);
243
+ if (t === undefined) {
244
+ result = cur;
245
+ break;
246
+ }
247
+ path.push(cur);
248
+ cur = t.claimer;
249
+ }
250
+ if (blocks.has(cur))
251
+ owners.set(cur, result);
252
+ for (const x of path)
253
+ owners.set(x, result);
254
+ };
255
+ const setDisplay = (b, from, to) => {
256
+ if (typeof from === 'string')
257
+ dropFrom(displaysMap, from, b);
258
+ if (typeof to === 'string')
259
+ addTo(displaysMap, to, b);
260
+ };
136
261
  const ensureOwners = () => {
137
- if (ownersVersion >= structureVersion)
262
+ if (!ownersBuilt) {
263
+ ownersBuilt = true;
264
+ ownerSeeds.clear();
265
+ const graph = claimGraph(blocks);
266
+ owners.clear();
267
+ for (const [b, o] of graph.owners)
268
+ if (blocks.has(b))
269
+ owners.set(b, o);
270
+ topOf.clear();
271
+ topInv.clear();
272
+ claimersOf.clear();
273
+ for (const [holder, rec] of blocks)
274
+ for (const c of rec.claims)
275
+ addTo(claimersOf, c.m, holder);
276
+ for (const m of claimersOf.keys())
277
+ setTop(m, topClaim(m));
278
+ displaysMap.clear();
279
+ for (const b of blocks.keys())
280
+ setDisplay(b, undefined, owners.get(b));
281
+ return;
282
+ }
283
+ if (ownerSeeds.size === 0)
138
284
  return;
139
- ({ owners, top: tops } = claimGraph(blocks));
140
- ownersVersion = structureVersion;
285
+ // The tops a seed's claims (now, and those it dropped: noted in `claimersOf` changes) decide.
286
+ const affected = new Set();
287
+ const stack = [];
288
+ for (const x of ownerSeeds) {
289
+ stack.push(x);
290
+ for (const m of claimTargets.get(x) ?? [])
291
+ if (setTop(m, topClaim(m)))
292
+ stack.push(m);
293
+ for (const c of blocks.get(x)?.claims ?? [])
294
+ if (setTop(c.m, topClaim(c.m)))
295
+ stack.push(c.m);
296
+ }
297
+ ownerSeeds.clear();
298
+ claimTargets.clear();
299
+ for (let x = stack.pop(); x !== undefined; x = stack.pop()) {
300
+ if (affected.has(x))
301
+ continue;
302
+ affected.add(x);
303
+ for (const m of topInv.get(x) ?? [])
304
+ stack.push(m);
305
+ }
306
+ const before = new Map();
307
+ for (const x of affected) {
308
+ before.set(x, owners.get(x));
309
+ owners.delete(x);
310
+ }
311
+ for (const x of affected)
312
+ if (blocks.has(x))
313
+ walkOwner(x);
314
+ for (const [x, was] of before) {
315
+ const now = owners.get(x);
316
+ if (now === was)
317
+ continue;
318
+ setDisplay(x, was, now);
319
+ ownerChanged.add(x);
320
+ }
141
321
  };
322
+ /** The claims each seed held before its record changed (their tops are re-decided too). */
323
+ const claimTargets = new Map();
142
324
  const ownerOf = (b) => {
143
325
  ensureOwners();
144
326
  return owners.get(b) ?? DEAD;
145
327
  };
146
- // ── the stream table (R2) ────────────────────────────────────────
147
- // One scanned row per backing text (keyed by its home block), the
148
- // delimiting boundary of each block, and the streams per text. A
149
- // text's rows are rescanned when the fold sees it edited; the table
150
- // is rebuilt only when a boundary set, a nonce or a registry entry
151
- // changed — otherwise only the edited text's stream offsets move.
328
+ // ── the stream table (R2, P1) ────────────────────────────────────
329
+ // One maintained row per backing text (keyed by its home block): its
330
+ // live boundaries and the units between them (`text/rows.ts`), the
331
+ // delimiting boundary of each block, and each block's segment. An edit
332
+ // that writes or removes no boundary moves its row by its units
333
+ // (`shiftGap`); a row is rescanned only when its boundary set (or its
334
+ // text) changed, and only the delimiters of the blocks its boundaries
335
+ // name are re-decided, so only the rows those delimiters cut re-place.
152
336
  const rows = new Map();
153
337
  let delim = new Map();
154
- const streams = new Map();
155
- const inText = new Map();
156
- /** A stream's identity (text and delimiting boundary) — what a rebuild compares. */
157
- const streamKey = (b) => {
158
- const s = streams.get(b);
159
- return s && `${s.home}|${delim.get(b) ?? ''}`;
160
- };
161
- const place = (home) => {
162
- for (const s of inText.get(home) ?? [])
163
- if (streams.get(s.block) === s)
164
- streams.delete(s.block);
338
+ /** Every live boundary naming a block, by block: key → its row and nonce. */
339
+ const boundsBy = new Map();
340
+ /** Each block's segment: its row and the cut that opens it (`null`: the row's segment 0). */
341
+ const streamIx = new Map();
342
+ const placed = new Map();
343
+ const streamOf = (b) => {
344
+ const at = streamIx.get(b);
345
+ const row = at && rows.get(at.home);
346
+ if (row === undefined)
347
+ return undefined;
348
+ const k = segmentOfCut(row, at.cut);
349
+ return k < 0 ? undefined : streamOfSegment(row, k, b);
350
+ };
351
+ const streamsIn = (home) => {
165
352
  const row = rows.get(home);
166
- const list = row === undefined ? [] : placeText(row, delim);
167
- if (list.length === 0)
168
- inText.delete(home);
169
- else
170
- inText.set(home, list);
171
- for (const s of list)
172
- streams.set(s.block, s);
353
+ const out = [];
354
+ for (let k = 0; row !== undefined && k <= row.cuts.length; k++) {
355
+ const b = headOf(row, k);
356
+ if (b !== null)
357
+ out.push(streamOfSegment(row, k, b));
358
+ }
359
+ return out;
360
+ };
361
+ const streamAt = (home, i) => {
362
+ const row = rows.get(home);
363
+ if (row === undefined)
364
+ return undefined;
365
+ const k = segmentAt(row, i);
366
+ const b = headOf(row, k);
367
+ if (b === null)
368
+ return undefined;
369
+ const st = streamOfSegment(row, k, b);
370
+ return st.start <= i && i <= st.end ? st : undefined;
173
371
  };
174
- /** Rescan `home`'s text; `true` when its boundary set (or the text itself) changed. */
175
- const rescan = (home) => {
372
+ /** Record `home`'s new row (or none); the blocks its boundary set change names join `named`. */
373
+ const setRow = (home, row, named) => {
176
374
  const old = rows.get(home);
177
- const text = blocks.get(home)?.content;
178
- if (text === undefined) {
375
+ if (row === undefined)
179
376
  rows.delete(home);
180
- return old !== undefined;
377
+ else
378
+ rows.set(home, row);
379
+ if (old?.key === row?.key)
380
+ return;
381
+ for (const b of old?.bounds ?? []) {
382
+ if (row?.keyIndex.has(b.key))
383
+ continue;
384
+ const by = boundsBy.get(b.s);
385
+ by?.delete(b.key);
386
+ if (by?.size === 0)
387
+ boundsBy.delete(b.s);
388
+ named.add(b.s);
389
+ }
390
+ for (const b of row?.bounds ?? []) {
391
+ if (old?.keyIndex.has(b.key))
392
+ continue;
393
+ let by = boundsBy.get(b.s);
394
+ if (by === undefined)
395
+ boundsBy.set(b.s, (by = new Map()));
396
+ by.set(b.key, { home, n: b.n });
397
+ named.add(b.s);
181
398
  }
182
- const row = scanText(home, text);
183
- rows.set(home, row);
184
- return old === undefined || old.text !== text || old.key !== row.key;
185
399
  };
186
- /** Rebuild delimiters and every stream; invalidate the blocks whose stream changed. */
187
- const rebuildTable = (invalidated) => {
188
- const before = new Map([...streams.keys()].map((b) => [b, streamKey(b)]));
189
- delim = delimiters(blocks, rows.values());
190
- streams.clear();
191
- inText.clear();
192
- for (const home of rows.keys())
193
- place(home);
194
- for (const b of new Set([...before.keys(), ...streams.keys()])) {
195
- if (before.get(b) === streamKey(b))
400
+ /** A fresh row of `home`'s text (none without one). */
401
+ const scanRow = (home) => {
402
+ const text = blocks.get(home)?.content;
403
+ return text === undefined ? undefined : rowOf(scanText(home, text));
404
+ };
405
+ /** Re-decide the delimiting boundary of each block in `named`; the rows a change cuts join `homes`. */
406
+ const refreshDelims = (named, homes) => {
407
+ for (const s of named) {
408
+ const n = blocks.get(s)?.n;
409
+ let best;
410
+ if (blocks.has(s))
411
+ for (const [key, b] of boundsBy.get(s) ?? [])
412
+ if (b.n === n && (best === undefined || byId(key, best) < 0))
413
+ best = key;
414
+ const old = delim.get(s);
415
+ if (old === best)
196
416
  continue;
197
- invalidated.add(b);
198
- for (const c of listConsumers.get(b) ?? [])
417
+ if (best === undefined)
418
+ delim.delete(s);
419
+ else
420
+ delim.set(s, best);
421
+ const was = old === undefined ? undefined : boundsBy.get(s)?.get(old)?.home;
422
+ if (was !== undefined)
423
+ homes.add(was);
424
+ if (best !== undefined)
425
+ homes.add(boundsBy.get(s).get(best).home);
426
+ if (rows.has(s) || placed.has(s))
427
+ homes.add(s);
428
+ }
429
+ };
430
+ /**
431
+ * Place `home`'s row; invalidate the blocks whose segment changed (or
432
+ * appeared, or went). A segment is its two delimiting cuts, so only the
433
+ * segments between the longest common prefix and suffix of the old and
434
+ * new cut lists can differ: the others keep their entries untouched.
435
+ */
436
+ const placeHome = (home, invalidated) => {
437
+ const row = rows.get(home);
438
+ const before = placed.get(home) ?? { head: null, keys: [], blocks: [] };
439
+ const now = { head: null, keys: [], blocks: [] };
440
+ if (row !== undefined) {
441
+ placeRow(row, delim);
442
+ now.head = row.head;
443
+ for (const j of row.cuts) {
444
+ now.keys.push(row.bounds[j].key);
445
+ now.blocks.push(row.bounds[j].s);
446
+ }
447
+ }
448
+ if (row === undefined)
449
+ placed.delete(home);
450
+ else
451
+ placed.set(home, now);
452
+ const [a, b] = [before.keys, now.keys];
453
+ let p = 0;
454
+ while (p < a.length && p < b.length && a[p] === b[p])
455
+ p++;
456
+ let q = 0;
457
+ while (q < a.length - p && q < b.length - p && a[a.length - 1 - q] === b[b.length - 1 - q])
458
+ q++;
459
+ // Segment k ends at cut k: it changed iff k lies in [p, n - q] (segment 0 also with its block).
460
+ const from = before.head === now.head ? p : 0;
461
+ const changed = (blk) => {
462
+ if (blk === null)
463
+ return;
464
+ invalidated.add(blk);
465
+ for (const c of listConsumers.get(blk) ?? [])
199
466
  invalidated.add(c);
467
+ };
468
+ const blockOf = (x, k) => (k === 0 ? x.head : x.blocks[k - 1]);
469
+ for (let k = from; k <= a.length - q; k++) {
470
+ const blk = blockOf(before, k);
471
+ if (blk !== null && streamIx.get(blk)?.home === home)
472
+ streamIx.delete(blk);
473
+ changed(blk);
474
+ }
475
+ for (let k = from; k <= b.length - q; k++) {
476
+ const blk = blockOf(now, k);
477
+ if (blk === null)
478
+ continue;
479
+ streamIx.set(blk, { home, cut: k === 0 ? null : b[k - 1] });
480
+ changed(blk);
200
481
  }
201
482
  };
483
+ /** Rebuild the delimiters and every row's segments (the first build). */
484
+ const rebuildTable = (invalidated) => {
485
+ boundsBy.clear();
486
+ for (const [home, row] of rows)
487
+ for (const b of row.bounds) {
488
+ let by = boundsBy.get(b.s);
489
+ if (by === undefined)
490
+ boundsBy.set(b.s, (by = new Map()));
491
+ by.set(b.key, { home, n: b.n });
492
+ }
493
+ delim = new Map();
494
+ const homes = new Set([...rows.keys(), ...placed.keys()]);
495
+ refreshDelims(boundsBy.keys(), homes);
496
+ for (const home of homes)
497
+ placeHome(home, invalidated);
498
+ };
202
499
  /** The role table the display reads; `null` → none. */
203
500
  let roles = null;
204
501
  /** A stored kind changed since the last report: derived kinds may follow it (XW-08). */
@@ -228,31 +525,43 @@ export const bindRuns = (Y) => {
228
525
  },
229
526
  top: (m) => {
230
527
  ensureOwners();
231
- return tops.get(m);
528
+ return topOf.get(m)?.claimer;
232
529
  },
233
- streamOf: (b) => streams.get(b),
234
- streamsIn: (home) => inText.get(home) ?? [],
530
+ streamOf,
531
+ streamsIn,
532
+ streamAt,
235
533
  display: (b) => displayOf(b, blocks, ownShim)
236
534
  };
237
- /** Owner → displayed blocks, rebuilt lazily with `owners` (the delete step's marks). */
238
- let displaysMap = new Map();
239
- let displaysAt = -1;
535
+ /** The blocks `owner` displays (the delete step's marks). */
240
536
  const displays = (owner) => {
241
537
  ensureOwners();
242
- if (displaysAt !== ownersVersion) {
243
- displaysMap = displayIndex(blocks, ownerOf);
244
- displaysAt = ownersVersion;
245
- }
246
- return displaysMap.get(owner) ?? [];
247
- };
248
- // ── lazily-maintained placement / children-index facets ───────────
249
- // They rebuild only on `at` writes, delete marks, claim churn (the
250
- // display edge is `owner(parent)`) and registry entry churn; a
251
- // keystroke keeps the resolved placements and children index verbatim.
252
- let placementsMap = null;
253
- let kidsMap = null;
254
- /** Each visible block's display parent, and the island it displays out of (`reset`). */
255
- let slots = new Map();
538
+ return [...(displaysMap.get(owner) ?? [])];
539
+ };
540
+ // ── placements and the children index (P3: maintained) ────────────
541
+ // A structural change re-decides only what it can change: the winning
542
+ // placement of the blocks whose candidates (or whose parent's
543
+ // registry entry) changed, and the display slot of every block whose
544
+ // slot walk (`displaySlotOf`) reads one that changed — its stored
545
+ // subtree, through the blocks it displays — patched into the parents'
546
+ // child lists. While every block shows its winning candidate (no
547
+ // cycle rejected, none rehomed), a block's placement is its argmax
548
+ // (`resolvePlacements` accepts it), so only a cycle through a changed
549
+ // display edge needs the global resolution, which then runs whole.
550
+ // The layout rules re-run only on the layouts a change reaches.
551
+ /** Resolved placements. */
552
+ const placementsMap = new Map();
553
+ /** Resolved parent → the blocks placed under it. */
554
+ const kidsOf = new Map();
555
+ /** A block's argmax candidate parent → the blocks naming it (its entry decides theirs). */
556
+ const byArgParent = new Map();
557
+ /** The children index without the layout rules (what they read), and with them. */
558
+ let kids0 = new Map();
559
+ let slots0 = new Map();
560
+ let kidsMap = kids0;
561
+ /** Each visible block's slot (display parent, rank, the island it displays out of). */
562
+ let slots = slots0;
563
+ /** The document holds a layout or an item kind: `kids0` and `kidsMap` differ. */
564
+ let layoutMode = false;
256
565
  /** The line kinds the roles declare, and those of the lines islands the document holds. */
257
566
  let lineKinds = new Set();
258
567
  /**
@@ -260,106 +569,427 @@ export const bindRuns = (Y) => {
260
569
  * an island, then in a lined island or of a line kind — which the
261
570
  * report re-reads after a retype.
262
571
  */
263
- let following = [];
572
+ const following = new Set();
264
573
  let orderCache = null;
265
- let placementsBuiltAt = -1;
266
- let placementVersion = 0;
267
- /**
268
- * The layout rules over a children index (`layout.*`): the live blocks
269
- * they do not display, found in one post-order pass. A layout shows
270
- * only its items (`layout.only-items`, already in the index: the
271
- * layout sheds the others, `own.sheds`); an item that shows no child,
272
- * or shows outside a layout, does not display and hands its children
273
- * its slot (`layout.empty-item`, `layout.bare-item`); a layout showing
274
- * one item or none does not display, nor does that item
275
- * (`layout.single`). Each decision reads the children a node shows
276
- * once its own children's were made, so one pass is the fixpoint:
277
- * what a dissolve hands up is never an item (an item shows only in a
278
- * layout, and a dissolving layout hands up its item's children).
279
- */
280
- const dissolve = (kids, items) => {
281
- const out = new Set();
282
- const isItem = (b) => items.has(blocks.get(b)?.type ?? '');
283
- const visit = (parent, layout) => {
284
- const shown = [];
285
- for (const { id } of kids.get(parent) ?? []) {
286
- const own = itemKind(id) !== undefined;
287
- const sub = visit(id, own);
288
- if (own) {
289
- if (sub.length > 1)
290
- shown.push({ id, kids: [] });
291
- else {
292
- out.add(id);
293
- for (const k of sub) {
294
- out.add(k.id);
295
- shown.push(...k.kids.map((kid) => ({ id: kid, kids: [] })));
296
- }
297
- }
298
- }
299
- else if (isItem(id) && (!layout || sub.length === 0)) {
574
+ /** Bumps whenever a child list changes (the report compares it). */
575
+ let kidsVersion = 0;
576
+ /** The child lists patched since the last report (`all`: every list was rebuilt). */
577
+ const dirtyLists = new Set();
578
+ /** The blocks that left every list since the last report. */
579
+ const leftLists = new Set();
580
+ let allListsDirty = true;
581
+ /** The next pass rebuilds everything (first build, roles, an irregular state). */
582
+ let placementFull = true;
583
+ /** Some block shows no argmax candidate (a cycle rejected one, or none): passes run whole. */
584
+ let irregular = false;
585
+ /** Blocks whose candidates or record changed, whose display state changed. */
586
+ const placementSeeds = new Set();
587
+ const stateSeeds = new Set();
588
+ /** Per block: whether its kind is a layout, an item, a lines island (with its line kind). */
589
+ const kindsOf = new Map();
590
+ let layoutBlocks = 0;
591
+ let itemBlocks = 0;
592
+ const lineCounts = new Map();
593
+ /** The item kinds the roles declare. */
594
+ const declaredItems = () => {
595
+ const items = new Set();
596
+ for (const type of roles?.layoutKinds() ?? [])
597
+ items.add(roles.layout(type));
598
+ return items;
599
+ };
600
+ let itemKinds = new Set();
601
+ /** Re-count `id`'s kind facts; a fact the roles never declared forces a full pass. */
602
+ const noteKind = (id) => {
603
+ const old = kindsOf.get(id);
604
+ const type = blocks.get(id)?.type;
605
+ const now = type === undefined
606
+ ? undefined
607
+ : { layout: itemKind(id) !== undefined, item: itemKinds.has(type), line: lineKind(id) };
608
+ if (old?.layout)
609
+ layoutBlocks--;
610
+ if (old?.item)
611
+ itemBlocks--;
612
+ if (old?.line !== undefined)
613
+ lineCounts.set(old.line, lineCounts.get(old.line) - 1);
614
+ if (now === undefined)
615
+ kindsOf.delete(id);
616
+ else
617
+ kindsOf.set(id, now);
618
+ if (now?.layout)
619
+ layoutBlocks++;
620
+ if (now?.item)
621
+ itemBlocks++;
622
+ if (now?.line !== undefined)
623
+ lineCounts.set(now.line, (lineCounts.get(now.line) ?? 0) + 1);
624
+ const item = itemKind(id);
625
+ if (item !== undefined && !itemKinds.has(item))
626
+ placementFull = true;
627
+ if (now?.line !== undefined && !lineKinds.has(now.line))
628
+ placementFull = true;
629
+ if (layoutMode !== layoutBlocks + itemBlocks > 0)
630
+ placementFull = true;
631
+ };
632
+ /** The argmax candidate's parent (a registry entry or the root), and its index entry. */
633
+ const argParent = (rec) => rec?.cands[0]?.p;
634
+ const noteCands = (id, before) => {
635
+ const after = argParent(blocks.get(id));
636
+ if (before === after)
637
+ return;
638
+ if (typeof before === 'string')
639
+ dropFrom(byArgParent, before, id);
640
+ if (typeof after === 'string')
641
+ addTo(byArgParent, after, id);
642
+ };
643
+ const dissolveVisit = (kids, out, ids, layout) => {
644
+ const isItem = (b) => itemKinds.has(blocks.get(b)?.type ?? '');
645
+ const shown = [];
646
+ for (const id of ids) {
647
+ const own = itemKind(id) !== undefined;
648
+ const sub = dissolveVisit(kids, out, (kids.get(id) ?? []).map((k) => k.id), own);
649
+ if (own) {
650
+ if (sub.length > 1)
651
+ shown.push({ id, kids: [] });
652
+ else {
300
653
  out.add(id);
301
- shown.push(...sub);
654
+ for (const k of sub) {
655
+ out.add(k.id);
656
+ shown.push(...k.kids.map((kid) => ({ id: kid, kids: [] })));
657
+ }
302
658
  }
303
- else
304
- shown.push({ id, kids: sub.map((k) => k.id) });
305
659
  }
306
- return shown;
660
+ else if (isItem(id) && (!layout || sub.length === 0)) {
661
+ out.add(id);
662
+ shown.push(...sub);
663
+ }
664
+ else
665
+ shown.push({ id, kids: sub.map((k) => k.id) });
666
+ }
667
+ return shown;
668
+ };
669
+ const dissolve = (kids) => {
670
+ const out = new Set();
671
+ dissolveVisit(kids, out, (kids.get(null) ?? []).map((k) => k.id), false);
672
+ return out;
673
+ };
674
+ /** The ownership the layout rules read: none of their decisions applied. */
675
+ const own0 = {
676
+ ...ownShim,
677
+ hidden: (b) => ownerOf(b) !== b,
678
+ passes: () => false
679
+ };
680
+ /** `id`'s slot in the index `own` reads (none: hidden, or under no live parent). */
681
+ const slotIn = (own, id) => {
682
+ const pl = placementsMap.get(id);
683
+ if (pl === undefined || !blocks.has(id) || own.hidden(id))
684
+ return undefined;
685
+ const { parent, rank, reset } = displaySlotOf(own, placementsMap, pl, id);
686
+ if (parent === DEAD)
687
+ return undefined;
688
+ return reset === null ? { parent, rank } : { parent, rank, reset };
689
+ };
690
+ /** The index of the first slot of `list` not before `x`. */
691
+ const seek = (list, x) => {
692
+ let lo = 0;
693
+ let hi = list.length;
694
+ while (lo < hi) {
695
+ const mid = (lo + hi) >> 1;
696
+ if (bySlot(list[mid], x) < 0)
697
+ lo = mid + 1;
698
+ else
699
+ hi = mid;
700
+ }
701
+ return lo;
702
+ };
703
+ /**
704
+ * Re-decide the slots of `affected` in `kids`/`slotMap` (read through
705
+ * `own`), patching the child lists they leave or join (each list
706
+ * copied once: a list a reader holds never changes under it). Returns
707
+ * the blocks whose slot changed, with their parents before and after.
708
+ */
709
+ const patch = (kids, slotMap, own, affected) => {
710
+ const changed = new Map();
711
+ const copies = new Map();
712
+ const list = (p) => {
713
+ let l = copies.get(p);
714
+ if (l === undefined)
715
+ copies.set(p, (l = [...(kids.get(p) ?? [])]));
716
+ return l;
307
717
  };
308
- visit(null, false);
718
+ for (const id of affected) {
719
+ const old = slotMap.get(id);
720
+ const next = slotIn(own, id);
721
+ if (old?.parent === next?.parent &&
722
+ old?.rank === next?.rank &&
723
+ old?.reset === next?.reset &&
724
+ (old === undefined) === (next === undefined))
725
+ continue;
726
+ if (old !== undefined) {
727
+ const l = list(old.parent);
728
+ const at = seek(l, { id, rank: old.rank });
729
+ if (l[at]?.id === id)
730
+ l.splice(at, 1);
731
+ }
732
+ if (next !== undefined) {
733
+ const l = list(next.parent);
734
+ const slot = next.reset === undefined
735
+ ? { id, rank: next.rank }
736
+ : { id, rank: next.rank, reset: next.reset };
737
+ l.splice(seek(l, slot), 0, slot);
738
+ slotMap.set(id, next);
739
+ }
740
+ else {
741
+ slotMap.delete(id);
742
+ if (kids === kidsMap)
743
+ leftLists.add(id);
744
+ }
745
+ changed.set(id, [old?.parent, next?.parent]);
746
+ }
747
+ for (const [p, l] of copies) {
748
+ if (l.length === 0)
749
+ kids.delete(p);
750
+ else
751
+ kids.set(p, l);
752
+ if (kids === kidsMap)
753
+ dirtyLists.add(p);
754
+ }
755
+ return changed;
756
+ };
757
+ /** `seeds` and every block whose slot walk reads one of them: stored subtrees, through what each displays. */
758
+ const reach = (seeds) => {
759
+ const out = new Set();
760
+ const stack = [...seeds];
761
+ for (let x = stack.pop(); x !== undefined; x = stack.pop()) {
762
+ if (out.has(x))
763
+ continue;
764
+ out.add(x);
765
+ for (const c of kidsOf.get(x) ?? [])
766
+ stack.push(c);
767
+ for (const d of displaysMap.get(x) ?? [])
768
+ if (d !== x)
769
+ for (const c of kidsOf.get(d) ?? [])
770
+ stack.push(c);
771
+ }
309
772
  return out;
310
773
  };
311
- const ensurePlacements = () => {
312
- if (placementsBuiltAt >= placementVersion)
313
- return;
314
- ensureOwners();
315
- placementsMap = resolvePlacements(blocks, ownerOf);
316
- dissolved = new Set();
317
- kidsMap = childrenIndex(placementsMap, ownShim);
318
- // The layout rules, when the document holds a layout or an item kind.
319
- const items = new Set();
320
- for (const type of roles?.layoutKinds() ?? [])
321
- items.add(roles.layout(type));
322
- let layouts = false;
774
+ /** Whether `id` shows as its slot's kind (`following`): read from its slot and kinds. */
775
+ const noteFollowing = (id) => {
776
+ const slot = slots.get(id);
777
+ const follows = slot !== undefined &&
778
+ (slot.reset !== undefined ||
779
+ (slot.parent !== null && lineKind(slot.parent) !== undefined) ||
780
+ lineKinds.has(blocks.get(id)?.type ?? ''));
781
+ if (follows)
782
+ following.add(id);
783
+ else
784
+ following.delete(id);
785
+ };
786
+ /** The placement `resolvePlacements` gives `id` while every block shows its argmax. */
787
+ const argmaxOf = (id) => {
788
+ const rec = blocks.get(id);
789
+ if (rec === undefined)
790
+ return undefined;
791
+ const c = rec.cands[0];
792
+ if (c === undefined)
793
+ return null;
794
+ const p = c.p !== null && !blocks.has(c.p) ? null : c.p;
795
+ return { parent: p, rank: c.r };
796
+ };
797
+ /** A display edge walk from `id`'s parent reaches `id` (or loops): the argmax graph has a cycle. */
798
+ const cyclic = (id) => {
799
+ let steps = 0;
800
+ for (let cur = placementsMap.get(id)?.parent ?? null; cur !== null;) {
801
+ const o = ownerOf(cur);
802
+ const at = o === DEAD ? cur : o;
803
+ if (at === id || ++steps > blocks.size)
804
+ return true;
805
+ const cp = placementsMap.get(at);
806
+ if (cp === undefined)
807
+ return false;
808
+ cur = cp.parent;
809
+ }
810
+ return false;
811
+ };
812
+ const setPlacement = (id, pl) => {
813
+ const old = placementsMap.get(id);
814
+ if (old !== undefined)
815
+ dropFrom(kidsOf, old.parent, id);
816
+ if (pl === undefined)
817
+ placementsMap.delete(id);
818
+ else {
819
+ placementsMap.set(id, pl);
820
+ addTo(kidsOf, pl.parent, id);
821
+ }
822
+ };
823
+ /** Everything rebuilt from the records (the first build, roles, an irregular state). */
824
+ const rebuildPlacements = () => {
825
+ placementFull = false;
826
+ placementSeeds.clear();
827
+ stateSeeds.clear();
828
+ ownerChanged.clear();
829
+ itemKinds = declaredItems();
323
830
  for (const b of blocks.keys()) {
324
831
  const item = itemKind(b);
325
- if (item === undefined)
326
- continue;
327
- items.add(item);
328
- layouts = true;
329
- }
330
- if (!layouts)
331
- for (const rec of blocks.values())
332
- if (items.has(rec.type))
333
- layouts = true;
334
- if (layouts) {
335
- const out = dissolve(kidsMap, items);
336
- if (out.size > 0) {
337
- dissolved = out;
338
- kidsMap = childrenIndex(placementsMap, ownShim);
339
- }
832
+ if (item !== undefined)
833
+ itemKinds.add(item);
340
834
  }
341
- slots = new Map();
342
835
  lineKinds = new Set(roles?.lineKinds());
343
- for (const b of blocks.keys()) {
344
- const line = lineKind(b);
345
- if (line !== undefined)
346
- lineKinds.add(line);
836
+ kindsOf.clear();
837
+ [layoutBlocks, itemBlocks] = [0, 0];
838
+ lineCounts.clear();
839
+ for (const b of blocks.keys())
840
+ noteKind(b);
841
+ for (const line of lineCounts.keys())
842
+ lineKinds.add(line);
843
+ placementFull = false;
844
+ const resolved = resolvePlacements(blocks, ownerOf);
845
+ placementsMap.clear();
846
+ kidsOf.clear();
847
+ irregular = false;
848
+ for (const [id, pl] of resolved) {
849
+ setPlacement(id, pl);
850
+ const arg = argmaxOf(id);
851
+ if (!arg || arg.parent !== pl.parent || arg.rank !== pl.rank)
852
+ irregular = true;
347
853
  }
348
- const out = [];
349
- const lines = [];
350
- for (const [under, kids] of kidsMap) {
351
- const lined = under !== null && lineKind(under) !== undefined;
352
- for (const { id, reset } of kids) {
353
- slots.set(id, { under, reset });
354
- if (reset !== undefined)
355
- out.push(id);
356
- if (lined || lineKinds.has(blocks.get(id)?.type ?? ''))
357
- lines.push(id);
358
- }
854
+ layoutMode = layoutBlocks + itemBlocks > 0;
855
+ dissolved = new Set();
856
+ const index = (own, map) => {
857
+ const at = new Map();
858
+ for (const [parent, list] of map)
859
+ for (const { id, rank, reset } of list)
860
+ at.set(id, reset === undefined ? { parent, rank } : { parent, rank, reset });
861
+ void own;
862
+ return at;
863
+ };
864
+ kids0 = childrenIndex(placementsMap, own0);
865
+ slots0 = index(own0, kids0);
866
+ if (layoutMode) {
867
+ dissolved = dissolve(kids0);
868
+ kidsMap = childrenIndex(placementsMap, ownShim);
869
+ slots = index(ownShim, kidsMap);
359
870
  }
360
- following = [...out, ...lines];
871
+ else
872
+ [kidsMap, slots] = [kids0, slots0];
873
+ following.clear();
874
+ for (const id of slots.keys())
875
+ noteFollowing(id);
361
876
  orderCache = null;
362
- placementsBuiltAt = placementVersion;
877
+ kidsVersion++;
878
+ allListsDirty = true;
879
+ };
880
+ /**
881
+ * Re-run the layout rules on the layouts `changed` (blocks whose slot
882
+ * in `kids0` changed, with their parents) and `kinds` reach: from each
883
+ * one up through layouts and items, the topmost such ancestor's
884
+ * subtree. Returns the blocks whose dissolved state flipped.
885
+ */
886
+ const redissolve = (changed, kinds) => {
887
+ const flips = new Set();
888
+ const isLayoutish = (b) => {
889
+ const k = kindsOf.get(b);
890
+ return k !== undefined && (k.layout || k.item);
891
+ };
892
+ const roots = new Set();
893
+ const climb = (b) => {
894
+ let top;
895
+ for (let x = b; typeof x === 'string' && isLayoutish(x); x = slots0.get(x)?.parent)
896
+ top = x;
897
+ if (top !== undefined)
898
+ roots.add(top);
899
+ };
900
+ for (const [id, [from, to]] of changed) {
901
+ climb(id);
902
+ climb(from);
903
+ climb(to);
904
+ }
905
+ for (const id of kinds) {
906
+ // Its kind decides its own rule, its parent's count and its children's (`layout`).
907
+ climb(id);
908
+ climb(slots0.get(id)?.parent);
909
+ for (const k of kids0.get(id) ?? [])
910
+ climb(k.id);
911
+ // Only a layout or an item dissolves: a block that left those kinds shows again.
912
+ if (!isLayoutish(id) && dissolved.delete(id))
913
+ flips.add(id);
914
+ }
915
+ for (const root of roots) {
916
+ if (!slots0.has(root))
917
+ continue;
918
+ const parent = slots0.get(root).parent;
919
+ const nodes = [];
920
+ const stack = [root];
921
+ for (let x = stack.pop(); x !== undefined; x = stack.pop()) {
922
+ nodes.push(x);
923
+ for (const k of kids0.get(x) ?? [])
924
+ stack.push(k.id);
925
+ }
926
+ const out = new Set();
927
+ dissolveVisit(kids0, out, [root], parent !== null && itemKind(parent) !== undefined);
928
+ for (const x of nodes) {
929
+ if (out.has(x) === dissolved.has(x))
930
+ continue;
931
+ if (out.has(x))
932
+ dissolved.add(x);
933
+ else
934
+ dissolved.delete(x);
935
+ flips.add(x);
936
+ }
937
+ }
938
+ // A block that left the index (hidden, removed) dissolves no more.
939
+ for (const id of changed.keys())
940
+ if (!slots0.has(id) && dissolved.delete(id))
941
+ flips.add(id);
942
+ return flips;
943
+ };
944
+ const ensurePlacements = () => {
945
+ ensureOwners();
946
+ if (placementFull || irregular) {
947
+ if (placementFull || placementSeeds.size + stateSeeds.size + ownerChanged.size > 0)
948
+ rebuildPlacements();
949
+ return;
950
+ }
951
+ if (placementSeeds.size + stateSeeds.size + ownerChanged.size === 0)
952
+ return;
953
+ const moved = new Set();
954
+ for (const id of placementSeeds) {
955
+ const next = argmaxOf(id);
956
+ if (next === null)
957
+ return rebuildPlacements();
958
+ const old = placementsMap.get(id);
959
+ if (old?.parent === next?.parent && old?.rank === next?.rank)
960
+ continue;
961
+ setPlacement(id, next);
962
+ moved.add(id);
963
+ }
964
+ // A changed display edge closing a cycle needs the global resolution.
965
+ for (const id of moved)
966
+ if (cyclic(id))
967
+ return rebuildPlacements();
968
+ for (const o of ownerChanged)
969
+ for (const c of kidsOf.get(o) ?? [])
970
+ if (cyclic(c))
971
+ return rebuildPlacements();
972
+ const seeds = new Set([...moved, ...stateSeeds, ...ownerChanged]);
973
+ const kinds = [...stateSeeds];
974
+ placementSeeds.clear();
975
+ stateSeeds.clear();
976
+ ownerChanged.clear();
977
+ const affected = reach(seeds);
978
+ let touched = affected;
979
+ const changed = patch(kids0, slots0, own0, affected);
980
+ let any = changed.size > 0;
981
+ if (layoutMode) {
982
+ const flips = redissolve(changed, kinds);
983
+ const again = flips.size > 0 ? reach([...affected, ...flips]) : affected;
984
+ any = patch(kidsMap, slots, ownShim, again).size > 0 || any;
985
+ touched = again;
986
+ }
987
+ for (const id of touched)
988
+ noteFollowing(id);
989
+ if (any) {
990
+ orderCache = null;
991
+ kidsVersion++;
992
+ }
363
993
  };
364
994
  /**
365
995
  * The kind `id` displays as — THE shown-kind rule, read from its slot
@@ -397,7 +1027,7 @@ export const bindRuns = (Y) => {
397
1027
  const slot = slots.get(id);
398
1028
  if (roles === null || slot === undefined)
399
1029
  return stored;
400
- const { under, reset } = slot;
1030
+ const { parent: under, reset } = slot;
401
1031
  const line = under === null || lineKinds.size === 0 ? undefined : lineKind(under);
402
1032
  if (line !== undefined)
403
1033
  return line;
@@ -411,7 +1041,7 @@ export const bindRuns = (Y) => {
411
1041
  // container whose item is the document's default kind (a column) holds no
412
1042
  // items of its own: a paragraph under one is no list's item.
413
1043
  if (!lined && typeof from === 'string' && roles.container(from))
414
- for (let u = under; u !== null; u = slots.get(u)?.under ?? null) {
1044
+ for (let u = under; u !== null; u = slots.get(u)?.parent ?? null) {
415
1045
  const t = typeOf(u);
416
1046
  if (roles.container(t) &&
417
1047
  roles.defaultChild(t) === stored &&
@@ -487,15 +1117,48 @@ export const bindRuns = (Y) => {
487
1117
  for (const c of rec?.claims ?? [])
488
1118
  fx.add(c.m);
489
1119
  };
490
- /** Rebuild (or create, or drop) block `id`'s record. */
1120
+ /**
1121
+ * Rebuild (or create, or drop) block `id`'s record, and note what the
1122
+ * maintained facts must re-decide: its owner and the tops of the
1123
+ * blocks it claims (P3), its placement — and, when its entry came or
1124
+ * went, the placements of the blocks whose candidate names it.
1125
+ */
491
1126
  const updateBlockRec = (id) => {
1127
+ const old = blocks.get(id);
492
1128
  const node = registry.getAttr(id);
493
1129
  if (!isNodeLike(node))
494
1130
  blocks.delete(id);
495
1131
  else
496
1132
  blocks.set(id, buildRec(id, node));
497
- noteEffects(id, blocks.get(id));
1133
+ const rec = blocks.get(id);
1134
+ noteEffects(id, rec);
498
1135
  noteShell(id);
1136
+ const was = new Set(old?.claims.map((c) => c.m));
1137
+ const now = new Set(rec?.claims.map((c) => c.m));
1138
+ for (const m of was)
1139
+ if (!now.has(m)) {
1140
+ dropFrom(claimersOf, m, id);
1141
+ addTo(claimTargets, id, m);
1142
+ }
1143
+ for (const m of now)
1144
+ if (!was.has(m))
1145
+ addTo(claimersOf, m, id);
1146
+ ownerSeeds.add(id);
1147
+ noteCands(id, argParent(old));
1148
+ placementSeeds.add(id);
1149
+ if ((old === undefined) !== (rec === undefined)) {
1150
+ stateSeeds.add(id);
1151
+ for (const c of byArgParent.get(id) ?? [])
1152
+ placementSeeds.add(c);
1153
+ }
1154
+ if (old?.type !== rec?.type) {
1155
+ noteKind(id);
1156
+ if (old === undefined ||
1157
+ rec === undefined ||
1158
+ roles === null ||
1159
+ !sameShape(roles, old.type, rec.type))
1160
+ stateSeeds.add(id);
1161
+ }
499
1162
  };
500
1163
  /** A withdrawn block without a delete mark: its `deleted` is settled after each fold. */
501
1164
  const noteShell = (id) => {
@@ -638,7 +1301,7 @@ export const bindRuns = (Y) => {
638
1301
  };
639
1302
  const runs = (b) => {
640
1303
  // Read-your-writes: fold the open transaction's pending writes first.
641
- syncPending(openTx());
1304
+ syncAll();
642
1305
  if (dirty.has(b) || !cache.has(b))
643
1306
  computeRuns(b);
644
1307
  return cache.get(b)?.runs ?? EMPTY_RUNS;
@@ -646,7 +1309,6 @@ export const bindRuns = (Y) => {
646
1309
  const parentOf = (id) => blocks.get(id)?.cands[0]?.p;
647
1310
  /** A structural change of `id`: its readers, and the blocks it claims, re-read. */
648
1311
  const invalidateBlock = (id, ctx, claimsBefore = []) => {
649
- ctx.structure = ctx.placement = true;
650
1312
  ctx.invalidated.add(id);
651
1313
  for (const c of listConsumers.get(id) ?? [])
652
1314
  ctx.invalidated.add(c);
@@ -656,8 +1318,25 @@ export const bindRuns = (Y) => {
656
1318
  ctx.invalidated.add(c);
657
1319
  }
658
1320
  };
659
- /** Fold one block's changed facets (`spans`: its text's edited spans, `null` = opaque). */
660
- const foldBlock = (id, facets, spans, ctx) => {
1321
+ /** Note edits of `id`'s own text (`null`: unknown — rescan, every reader re-reads). */
1322
+ const noteText = (ctx, id, e) => {
1323
+ let t = ctx.texts.get(id);
1324
+ if (t === undefined)
1325
+ ctx.texts.set(id, (t = { edits: [], scan: false, opaque: false, seen: new Set() }));
1326
+ if (e === null) {
1327
+ t.scan = t.opaque = true;
1328
+ return;
1329
+ }
1330
+ if (t.seen.has(e))
1331
+ return;
1332
+ t.seen.add(e);
1333
+ t.scan ||= e.scan;
1334
+ t.opaque ||= e.opaque;
1335
+ for (const x of e.edits)
1336
+ t.edits.push(x);
1337
+ };
1338
+ /** Fold one block's changed facets (`edits`: its own text's, one per changed content node). */
1339
+ const foldBlock = (id, facets, edits, ctx) => {
661
1340
  const kinds = new Set();
662
1341
  for (const f of facets)
663
1342
  kinds.add(f === ENTRY_FACET ? 'entry' : facetOf(f));
@@ -673,73 +1352,119 @@ export const bindRuns = (Y) => {
673
1352
  // A new entry, a nonce or an own text can move streams (R2).
674
1353
  if (kinds.has('entry') || facets.has(NONCE) || facets.has(CONTENT_ATTR)) {
675
1354
  ctx.table = true;
676
- ctx.texts.set(id, null);
1355
+ ctx.named.add(id);
1356
+ noteText(ctx, id, null);
677
1357
  }
678
1358
  invalidateBlock(id, ctx, claimsBefore);
679
1359
  }
680
1360
  else {
681
1361
  ensureRec(id);
682
1362
  const rec = blocks.get(id);
683
- if (kinds.has('at')) {
684
- if (rec)
685
- rec.cands = candidatesOf(rec.node);
686
- ctx.placement = true;
1363
+ if (kinds.has('at') && rec) {
1364
+ const before = argParent(rec);
1365
+ rec.cands = candidatesOf(rec.node);
1366
+ noteCands(id, before);
1367
+ placementSeeds.add(id);
687
1368
  }
688
1369
  if (kinds.has('meta') && rec) {
689
1370
  const was = rec.type;
690
1371
  rec.type = typeAttr(rec.node);
691
1372
  rec.data = readData(rec.node);
692
- if (rec.type !== was)
1373
+ if (rec.type !== was) {
693
1374
  retyped = true;
1375
+ noteKind(id);
1376
+ }
694
1377
  // A retype that changes the kind's display shape re-parents
695
1378
  // (or re-kinds) its children.
696
1379
  if (roles !== null && !sameShape(roles, was, rec.type))
697
- ctx.placement = true;
1380
+ stateSeeds.add(id);
698
1381
  }
699
1382
  }
700
1383
  const parentAfter = parentOf(id);
701
1384
  if (typeof parentAfter === 'string')
702
1385
  ctx.parents.add(parentAfter);
703
- if (kinds.has('content') && ctx.texts.get(id) !== null) {
704
- ctx.texts.set(id, spans === null ? null : [...(ctx.texts.get(id) ?? []), ...spans]);
705
- }
1386
+ if (kinds.has('content'))
1387
+ for (const e of edits)
1388
+ noteText(ctx, id, e);
706
1389
  };
707
- /** Rescan the edited texts, move or rebuild the stream table, and invalidate their readers. */
1390
+ /**
1391
+ * Move each edited text's row by its edits (or rescan it), re-decide the
1392
+ * delimiters a boundary change names, re-place the rows they cut, and
1393
+ * invalidate the readers: the display owner of each edited stream (L7),
1394
+ * the blocks whose stream changed, every reader of an opaque text.
1395
+ */
708
1396
  const foldTexts = (ctx) => {
709
- const changed = new Set();
710
- for (const home of ctx.texts.keys())
711
- if (rescan(home))
712
- changed.add(home);
713
- if (changed.size > 0)
1397
+ const homes = new Set();
1398
+ const gapsOf = new Map();
1399
+ const gapsIn = (row, edits) => {
1400
+ const gaps = [];
1401
+ for (const e of edits) {
1402
+ const gap = gapOfItem(row, e.item);
1403
+ if (gap < 0)
1404
+ return null;
1405
+ gaps.push(gap);
1406
+ }
1407
+ return gaps;
1408
+ };
1409
+ for (const [home, t] of ctx.texts) {
1410
+ const text = blocks.get(home)?.content;
1411
+ const row = rows.get(home);
1412
+ let gaps = [];
1413
+ let fresh = t.scan || row === undefined || text === undefined || row.text !== text;
1414
+ if (!fresh) {
1415
+ for (const e of t.edits) {
1416
+ const gap = gapOfItem(row, e.item);
1417
+ if (gap < 0 || !shiftGap(row, gap, e.del ? -e.len : e.len)) {
1418
+ fresh = true;
1419
+ break;
1420
+ }
1421
+ gaps.push(gap);
1422
+ }
1423
+ // The engine's own count of live units: a row that disagrees is stale.
1424
+ if (!fresh && rowLength(row) !== text._length)
1425
+ fresh = true;
1426
+ }
1427
+ if (fresh) {
1428
+ const next = scanRow(home);
1429
+ const same = next !== undefined && row !== undefined && next.key === row.key;
1430
+ if (same)
1431
+ [next.cuts, next.head] = [row.cuts, row.head];
1432
+ setRow(home, next, ctx.named);
1433
+ if (!same)
1434
+ homes.add(home);
1435
+ gaps = next === undefined ? [] : gapsIn(next, t.edits);
1436
+ }
1437
+ gapsOf.set(home, t.opaque ? null : gaps);
1438
+ }
1439
+ refreshDelims(ctx.named, homes);
1440
+ if (homes.size > 0)
714
1441
  ctx.table = true;
715
- if (ctx.table)
716
- rebuildTable(ctx.invalidated);
717
- else
718
- for (const home of ctx.texts.keys())
719
- place(home);
720
- for (const [home, spans] of ctx.texts) {
721
- const readers = textConsumers.get(home) ?? [];
722
- if (spans === null || changed.has(home) || ctx.structure) {
723
- for (const c of readers)
1442
+ for (const home of homes)
1443
+ placeHome(home, ctx.invalidated);
1444
+ for (const [home, gaps] of gapsOf) {
1445
+ if (gaps === null) {
1446
+ for (const c of textConsumers.get(home) ?? [])
724
1447
  ctx.invalidated.add(c);
725
1448
  continue;
726
1449
  }
727
- // Only the streams the edit touched change their display (L7).
728
- for (const s of inText.get(home) ?? []) {
729
- if (!spans.some(([lo, hi]) => s.start <= hi && lo <= s.end))
730
- continue;
731
- const owner = ownerOf(s.block);
1450
+ // Only the stream each edit lies in changes its display (L7).
1451
+ const row = rows.get(home);
1452
+ if (row === undefined)
1453
+ continue;
1454
+ for (const gap of gaps) {
1455
+ const b = headOf(row, segmentOfGap(row, gap));
1456
+ const owner = b === null ? DEAD : ownerOf(b);
732
1457
  if (typeof owner === 'string')
733
1458
  ctx.invalidated.add(owner);
734
1459
  }
735
1460
  }
736
1461
  };
737
1462
  /** The blocks whose stream lies in a text the fold edited. */
738
- const editedStreams = (ctx) => [...ctx.texts.keys()].flatMap((home) => (inText.get(home) ?? []).map((s) => s.block));
1463
+ const editedStreams = (ctx) => [...ctx.texts.keys()].flatMap((home) => streamsIn(home).map((st) => st.block));
739
1464
  /** A live unit in `id`'s stream (boundaries excepted). */
740
1465
  const streamHolds = (id) => {
741
- const s = streams.get(id);
742
- return s !== undefined && s.end - s.start > s.inert.length;
1466
+ const st = streamOf(id);
1467
+ return st !== undefined && st.end - st.start > st.inert.length;
743
1468
  };
744
1469
  /**
745
1470
  * Settle the withdrawn blocks (`hist.undo.withdraw`): a shell is deleted
@@ -788,9 +1513,9 @@ export const bindRuns = (Y) => {
788
1513
  if (rec.deleted === !alive.has(id))
789
1514
  continue;
790
1515
  rec.deleted = !alive.has(id);
1516
+ ownerSeeds.add(id);
791
1517
  if (ctx === null)
792
1518
  continue;
793
- structureVersion++;
794
1519
  invalidateBlock(id, ctx);
795
1520
  }
796
1521
  };
@@ -828,9 +1553,9 @@ export const bindRuns = (Y) => {
828
1553
  const candidates = new Set();
829
1554
  let reporting = false;
830
1555
  /** THE fold: one pass over changed `(type, parentSub)` pairs, each block folded once. */
831
- const fold = (changed, extentOf) => {
1556
+ const fold = (changed, editsOf) => {
832
1557
  const touched = new Map();
833
- const spans = new Map();
1558
+ const edits = new Map();
834
1559
  let derived = changed.has(dataRoot);
835
1560
  for (const [type, subs] of changed) {
836
1561
  for (const sub of subs) {
@@ -842,32 +1567,33 @@ export const bindRuns = (Y) => {
842
1567
  if (facets === undefined)
843
1568
  touched.set(id, (facets = new Set()));
844
1569
  facets.add(facet);
845
- if (facet !== CONTENT || spans.get(id) === null)
1570
+ if (facet !== CONTENT)
846
1571
  continue;
847
- const edited = seq ? extentOf(type) : undefined;
848
- spans.set(id, edited === undefined ? null : [...(spans.get(id) ?? []), ...edited]);
1572
+ // A sequence edit of the block's own text, or a change inside one of its atoms.
1573
+ let list = edits.get(id);
1574
+ if (list === undefined)
1575
+ edits.set(id, (list = []));
1576
+ list.push(seq ? editsOf(type) : null);
849
1577
  }
850
1578
  }
851
1579
  const ctx = {
852
1580
  invalidated: new Set(),
853
1581
  texts: new Map(),
1582
+ named: new Set(),
854
1583
  table: false,
855
- structure: false,
856
- placement: false,
857
1584
  parents: new Set()
858
1585
  };
859
1586
  for (const [id, facets] of touched) {
860
- foldBlock(id, facets, spans.get(id) ?? null, ctx);
1587
+ foldBlock(id, facets, edits.get(id) ?? [], ctx);
861
1588
  derived ||= [...facets].some((f) => f === ENTRY_FACET || facetOf(f) !== 'ignore');
862
1589
  }
863
- if (ctx.structure)
864
- structureVersion++;
865
1590
  foldTexts(ctx);
866
- settle(ctx.table ? shells : [...touched.keys(), ...ctx.parents, ...editedStreams(ctx)], ctx);
867
- if (ctx.placement)
868
- placementVersion++;
1591
+ if (shells.size > 0)
1592
+ settle(ctx.table ? shells : [...touched.keys(), ...ctx.parents, ...editedStreams(ctx)], ctx);
869
1593
  for (const b of ctx.invalidated)
870
1594
  dirty.add(b);
1595
+ if (indexChecks.on)
1596
+ check();
871
1597
  if (derived)
872
1598
  version++;
873
1599
  if (reporting) {
@@ -888,8 +1614,94 @@ export const bindRuns = (Y) => {
888
1614
  }
889
1615
  return touched;
890
1616
  };
891
- /** The open transaction's folded part: copies of its id sets, and their lengths (the watermark). */
892
- let cursor = null;
1617
+ /** {@link indexChecks}: every incrementally maintained fact equals its rebuild. */
1618
+ const check = () => {
1619
+ const fail = (what) => {
1620
+ throw new Error(`[edytor index] ${what} differs from its rebuild`);
1621
+ };
1622
+ const scans = new Map();
1623
+ for (const rec of blocks.values())
1624
+ if (rec.content)
1625
+ scans.set(rec.id, scanText(rec.id, rec.content));
1626
+ for (const home of rows.keys())
1627
+ if (!scans.has(home))
1628
+ fail(`row ${home}`);
1629
+ for (const [home, scan] of scans) {
1630
+ const row = rows.get(home);
1631
+ if (row === undefined || row.text !== scan.text || !sameRow(row, scan))
1632
+ fail(`row ${home}`);
1633
+ }
1634
+ const want = delimiters(blocks, scans.values());
1635
+ if (want.size !== delim.size || [...want].some(([b, k]) => delim.get(b) !== k))
1636
+ fail('delimiters');
1637
+ const streamSig = (st) => st && `${st.home}:${st.start}-${st.end}/${st.inert.join(',')}`;
1638
+ const placed = new Set();
1639
+ for (const [home, scan] of scans)
1640
+ for (const st of placeText(scan, want)) {
1641
+ placed.add(st.block);
1642
+ if (streamSig(streamOf(st.block)) !== streamSig(st))
1643
+ fail(`stream of ${st.block} in ${home}`);
1644
+ }
1645
+ for (const b of streamIx.keys())
1646
+ if (!placed.has(b))
1647
+ fail(`stream of ${b}`);
1648
+ for (const [b, c] of cache) {
1649
+ if (dirty.has(b))
1650
+ continue;
1651
+ const fresh = computeFresh(b).fresh;
1652
+ if (keyOf(fresh) !== keyOf(c.runs))
1653
+ fail(`runs of ${b}`);
1654
+ }
1655
+ // P3: the claim graph, placements, children index and layout rules.
1656
+ ensurePlacements();
1657
+ const graph = claimGraph(blocks);
1658
+ for (const b of blocks.keys()) {
1659
+ if (ownerOf(b) !== (graph.owners.get(b) ?? DEAD))
1660
+ fail(`owner of ${b}`);
1661
+ if (ownShim.top(b) !== graph.top.get(b))
1662
+ fail(`top of ${b}`);
1663
+ }
1664
+ const shown = displayIndex(blocks, ownerOf);
1665
+ for (const [o, list] of shown)
1666
+ if (list.length !== (displaysMap.get(o)?.size ?? 0) ||
1667
+ list.some((b) => !displaysMap.get(o).has(b)))
1668
+ fail(`displays of ${o}`);
1669
+ for (const o of displaysMap.keys())
1670
+ if (!shown.has(o))
1671
+ fail(`displays of ${o}`);
1672
+ const resolved = resolvePlacements(blocks, ownerOf);
1673
+ if (resolved.size !== placementsMap.size)
1674
+ fail('placements');
1675
+ for (const [b, pl] of resolved) {
1676
+ const mine = placementsMap.get(b);
1677
+ if (mine?.parent !== pl.parent || mine.rank !== pl.rank)
1678
+ fail(`placement of ${b}`);
1679
+ }
1680
+ const same = (x, y) => x.size === y.size && [...x].every(([p, l]) => keyOf(l) === keyOf(y.get(p) ?? null));
1681
+ const k0 = childrenIndex(placementsMap, own0);
1682
+ if (!same(k0, kids0))
1683
+ fail('children index (before the layout rules)');
1684
+ const out = dissolve(k0);
1685
+ if (out.size !== dissolved.size || [...out].some((b) => !dissolved.has(b)))
1686
+ fail('dissolved');
1687
+ if (!same(childrenIndex(placementsMap, ownShim), kidsMap))
1688
+ fail('children index');
1689
+ for (const [p, l] of kidsMap)
1690
+ for (const { id, rank, reset } of l) {
1691
+ const slot = slots.get(id);
1692
+ if (slot?.parent !== p || slot.rank !== rank || slot.reset !== reset)
1693
+ fail(`slot of ${id}`);
1694
+ }
1695
+ const follow = new Set(following);
1696
+ for (const id of slots.keys())
1697
+ noteFollowing(id);
1698
+ if (follow.size !== following.size || [...follow].some((b) => !following.has(b)))
1699
+ fail('following');
1700
+ };
1701
+ /** Each transaction's folded part: copies of its id sets, and their lengths (the watermark). */
1702
+ const cursors = new WeakMap();
1703
+ /** The transactions whose commit fold ran. */
1704
+ const committed = new WeakSet();
893
1705
  const lengthOf = (set) => {
894
1706
  let n = 0;
895
1707
  set.clients.forEach((ranges) => {
@@ -907,9 +1719,9 @@ export const bindRuns = (Y) => {
907
1719
  const syncPending = (tr) => {
908
1720
  if (tr?.insertSet === undefined || tr.deleteSet === undefined)
909
1721
  return false;
910
- if (cursor?.tr !== tr) {
911
- cursor = { tr, ins: Y.createIdSet(), del: Y.createIdSet(), mark: 0 };
912
- }
1722
+ let cursor = cursors.get(tr);
1723
+ if (cursor === undefined)
1724
+ cursors.set(tr, (cursor = { ins: Y.createIdSet(), del: Y.createIdSet(), mark: 0 }));
913
1725
  const mark = lengthOf(tr.insertSet) + lengthOf(tr.deleteSet);
914
1726
  if (mark === cursor.mark)
915
1727
  return false;
@@ -931,16 +1743,37 @@ export const bindRuns = (Y) => {
931
1743
  };
932
1744
  walkIdSetStructs(Y, doc, ins, note);
933
1745
  walkIdSetStructs(Y, doc, del, note);
934
- fold(changed, makeExtentIndex({ insertSet: ins, deleteSet: del }));
1746
+ fold(changed, makeEditIndex(ins, del));
935
1747
  return true;
936
1748
  };
937
1749
  const openTx = () => doc._transaction;
1750
+ /**
1751
+ * Fold every transaction whose writes are in the document and whose
1752
+ * commit fold has not run, in their order: the open one, and those a
1753
+ * transaction's cleanup started (an observer's or an `update`
1754
+ * listener's write runs its body at once, its observers later) — a
1755
+ * read in between must not mix their items with an index that never
1756
+ * saw them.
1757
+ */
1758
+ const syncAll = () => {
1759
+ for (const tr of queuedTransactions(doc))
1760
+ if (!committed.has(tr))
1761
+ syncPending(tr);
1762
+ };
938
1763
  /** The commit: fold `transaction.changed` once (the report publishes from `update`). */
939
1764
  const onCommit = (e) => {
940
1765
  const tr = e.transaction;
941
- if (cursor?.tr === tr)
942
- cursor = null;
943
- fold(tr.changed ?? new Map(), makeExtentIndex(tr));
1766
+ let [ins, del] = [tr.insertSet, tr.deleteSet];
1767
+ // The writes a read folded already moved the rows: only the rest
1768
+ // are edits now (the facets fold again, idempotent).
1769
+ const cursor = cursors.get(tr);
1770
+ if (cursor !== undefined && ins !== undefined && del !== undefined) {
1771
+ ins = Y.diffIdSet(ins, cursor.ins);
1772
+ del = Y.diffIdSet(del, cursor.del);
1773
+ }
1774
+ cursors.delete(tr);
1775
+ committed.add(tr);
1776
+ fold(tr.changed ?? new Map(), makeEditIndex(ins, del));
944
1777
  };
945
1778
  // ── projection ───────────────────────────────────────────────────
946
1779
  /** `id`'s visible content items, canonical and frozen (R4). */
@@ -981,7 +1814,7 @@ export const bindRuns = (Y) => {
981
1814
  : { ...old, parent, index });
982
1815
  });
983
1816
  }
984
- return { nodes, order, kids: kidsMap };
1817
+ return { nodes, order, kids: kidsVersion };
985
1818
  };
986
1819
  const keyOf = (v) => {
987
1820
  try {
@@ -991,6 +1824,126 @@ export const bindRuns = (Y) => {
991
1824
  return JSON.stringify(cloneJsonSafe(v ?? null));
992
1825
  }
993
1826
  };
1827
+ /**
1828
+ * The report's structural part from the child lists patched since the
1829
+ * last report (P3), advancing the published tree in place — what
1830
+ * `reachable` would give, without walking the lists nothing changed:
1831
+ * a published block no longer shown leaves (a root when its parent
1832
+ * stays), its still-shown descendants staying; each patched list's
1833
+ * new order is news, a block in it at a new parent or index moved, one
1834
+ * not published before is added with its subtree.
1835
+ */
1836
+ const advance = (pub, r, covered, reparented) => {
1837
+ const { nodes, order } = pub;
1838
+ const shown = (x) => {
1839
+ for (let c = x; c !== null;) {
1840
+ const slot = slots.get(c);
1841
+ if (slot === undefined)
1842
+ return false;
1843
+ c = slot.parent;
1844
+ }
1845
+ return true;
1846
+ };
1847
+ const lists = [...dirtyLists];
1848
+ // Leaving: a published block of a patched list that no longer shows.
1849
+ const drop = (id) => {
1850
+ const ks = order.get(id);
1851
+ if (ks !== undefined) {
1852
+ order.delete(id);
1853
+ r.order.set(id, EMPTY_IDS);
1854
+ for (const k of ks)
1855
+ if (!shown(k))
1856
+ drop(k);
1857
+ }
1858
+ nodes.delete(id);
1859
+ };
1860
+ for (const id of leftLists) {
1861
+ const o = nodes.get(id);
1862
+ if (o === undefined || shown(id))
1863
+ continue;
1864
+ if (o.parent === null || shown(o.parent))
1865
+ r.removed.add(id);
1866
+ drop(id);
1867
+ }
1868
+ // Each patched list that shows: its order, its moved and new blocks.
1869
+ const fresh = new Map();
1870
+ for (const p of lists) {
1871
+ if (p !== null && !shown(p))
1872
+ continue;
1873
+ const ks = kidsMap.get(p) ?? [];
1874
+ const prev = order.get(p);
1875
+ if (ks.length === 0) {
1876
+ if (prev !== undefined) {
1877
+ order.delete(p);
1878
+ r.order.set(p, EMPTY_IDS);
1879
+ }
1880
+ continue;
1881
+ }
1882
+ const ids = ks.map((k) => k.id);
1883
+ if (prev === undefined || !sameIds(prev, ids)) {
1884
+ const frozen = Object.freeze(ids);
1885
+ order.set(p, frozen);
1886
+ r.order.set(p, frozen);
1887
+ }
1888
+ ks.forEach(({ id }, index) => {
1889
+ const n = nodes.get(id);
1890
+ if (n === undefined)
1891
+ fresh.set(id, { parent: p, index });
1892
+ else if (n.parent !== p || n.index !== index) {
1893
+ r.moved.add(id);
1894
+ if (n.parent !== p)
1895
+ reparented.add(id);
1896
+ n.parent = p;
1897
+ n.index = index;
1898
+ }
1899
+ });
1900
+ }
1901
+ // New roots (no new ancestor) bring their subtree.
1902
+ const add = (b, parent, index) => {
1903
+ if (!nodes.has(b.id)) {
1904
+ covered.add(b.id);
1905
+ const rec = blocks.get(b.id);
1906
+ nodes.set(b.id, { parent, index, type: typeOf(b.id), data: rec.data, runs: runs(b.id) });
1907
+ }
1908
+ if (b.children.length > 0 && !order.has(b.id)) {
1909
+ const ids = Object.freeze(b.children.map((c) => c.id));
1910
+ order.set(b.id, ids);
1911
+ r.order.set(b.id, ids);
1912
+ }
1913
+ b.children.forEach((c, i) => add(c, b.id, i));
1914
+ };
1915
+ for (const [id, at] of fresh) {
1916
+ let under = false;
1917
+ for (let c = slots.get(id)?.parent ?? null; c !== null && !under; c = slots.get(c)?.parent ?? null)
1918
+ under = fresh.has(c);
1919
+ if (under || covered.has(id))
1920
+ continue;
1921
+ const b = projectBlock(id);
1922
+ r.added.set(id, b);
1923
+ add(b, at.parent, at.index);
1924
+ }
1925
+ };
1926
+ /** {@link indexChecks}: the published tree the report advanced equals a fresh walk. */
1927
+ const checkPublished = () => {
1928
+ const pub = published;
1929
+ // Payloads from the published nodes: the check reads no runs.
1930
+ const want = reachable(pub.nodes);
1931
+ const fail = (what) => {
1932
+ throw new Error(`[edytor index] published ${what} differs from its rebuild`);
1933
+ };
1934
+ if (want.order.size !== pub.order.size)
1935
+ fail('lists');
1936
+ for (const [p, ids] of want.order)
1937
+ if (!sameIds(ids, pub.order.get(p) ?? []))
1938
+ fail(`list of ${p}`);
1939
+ if (want.nodes.size !== pub.nodes.size)
1940
+ fail('blocks');
1941
+ for (const [id, n] of want.nodes) {
1942
+ const m = pub.nodes.get(id);
1943
+ if (m?.parent !== n.parent || m.index !== n.index)
1944
+ fail(`slot of ${id}`);
1945
+ }
1946
+ };
994
1947
  /** The blocks whose shown kind followed their slot at the last report. */
995
1948
  let followingBefore = [];
996
1949
  /** The document data's key at the last report. */
@@ -1015,14 +1968,19 @@ export const bindRuns = (Y) => {
1015
1968
  if (key !== dataKey)
1016
1969
  [r.data, dataKey] = [data, key];
1017
1970
  }
1018
- if (kidsMap === before.kids && candidates.size === 0)
1971
+ if (kidsVersion === before.kids && candidates.size === 0)
1019
1972
  return r.data ? r : null;
1020
- const after = kidsMap === before.kids ? before : reachable(before.nodes);
1021
1973
  // Added subtrees carry their new descendants. A descendant that was
1022
1974
  // visible before is reported like any visible block (moved, retyped,
1023
1975
  // edited against its published baseline), so consumers keep it (K7).
1024
1976
  const covered = new Set();
1025
- if (after !== before) {
1977
+ /** Moved to another display parent: only those can show another kind (an index never does). */
1978
+ const reparented = new Set();
1979
+ const lists = kidsVersion !== before.kids && !allListsDirty;
1980
+ const after = kidsVersion === before.kids || lists ? before : reachable(before.nodes);
1981
+ if (lists)
1982
+ advance(before, r, covered, reparented);
1983
+ else if (after !== before) {
1026
1984
  for (const [parent, ids] of after.order) {
1027
1985
  const prev = before.order.get(parent);
1028
1986
  if (prev === undefined || !sameIds(prev, ids))
@@ -1049,6 +2007,8 @@ export const bindRuns = (Y) => {
1049
2007
  const o = before.nodes.get(id);
1050
2008
  if (o.parent !== n.parent || o.index !== n.index)
1051
2009
  r.moved.add(id);
2010
+ if (o.parent !== n.parent)
2011
+ reparented.add(id);
1052
2012
  }
1053
2013
  }
1054
2014
  // Removed subtree ROOTS: a removed id whose before-parent stays
@@ -1090,13 +2050,14 @@ export const bindRuns = (Y) => {
1090
2050
  // changes a shown kind — the retyped block's (a candidate) and the
1091
2051
  // kinds derived from it: a promoted or stray line shows its display
1092
2052
  // parent's default child (XW-08).
1093
- if (after !== before || retyped) {
1094
- for (const id of [...r.moved, ...following, ...followingBefore]) {
2053
+ if (after !== before || lists || retyped) {
2054
+ const now = [...following];
2055
+ for (const id of [...reparented, ...now, ...followingBefore]) {
1095
2056
  const n = after.nodes.get(id);
1096
2057
  if (n !== undefined && !candidates.has(id) && !covered.has(id) && typeOf(id) !== n.type)
1097
2058
  meta(id, n);
1098
2059
  }
1099
- followingBefore = following;
2060
+ followingBefore = now;
1100
2061
  }
1101
2062
  // A plain block directly in a list shows as its item (`itemOf`, AW-04):
1102
2063
  // a block whose shown kind changed re-reads its children's (a worklist:
@@ -1110,6 +2071,12 @@ export const bindRuns = (Y) => {
1110
2071
  retyped = false;
1111
2072
  candidates.clear();
1112
2073
  published = after;
2074
+ after.kids = kidsVersion;
2075
+ dirtyLists.clear();
2076
+ leftLists.clear();
2077
+ allListsDirty = false;
2078
+ if (indexChecks.on)
2079
+ checkPublished();
1113
2080
  const empty = r.added.size + r.removed.size + r.moved.size + r.meta.size + r.content.size + r.order.size;
1114
2081
  return empty === 0 && !r.data ? null : r;
1115
2082
  };
@@ -1129,7 +2096,9 @@ export const bindRuns = (Y) => {
1129
2096
  if (!isNodeLike(v))
1130
2097
  return;
1131
2098
  updateBlockRec(id);
1132
- rescan(id);
2099
+ const row = scanRow(id);
2100
+ if (row !== undefined)
2101
+ rows.set(id, row);
1133
2102
  });
1134
2103
  rebuildTable(new Set());
1135
2104
  settle(shells, null);
@@ -1146,12 +2115,12 @@ export const bindRuns = (Y) => {
1146
2115
  // ── public surface ───────────────────────────────────────────────
1147
2116
  const view = {
1148
2117
  version: () => {
1149
- syncPending(openTx());
2118
+ syncAll();
1150
2119
  return version;
1151
2120
  },
1152
2121
  runs,
1153
2122
  contentItems: (b) => {
1154
- syncPending(openTx());
2123
+ syncAll();
1155
2124
  return blocks.get(b)?.deleted === false ? itemsOf(b) : [];
1156
2125
  },
1157
2126
  // Inline atoms always carry `data` (`{}` when absent) — the public
@@ -1160,24 +2129,26 @@ export const bindRuns = (Y) => {
1160
2129
  ? { text: r.text, ...(r.marks !== undefined && { marks: cloneJsonSafe(r.marks) }) }
1161
2130
  : { id: r.id, type: r.type, data: r.data === undefined ? {} : cloneJsonSafe(r.data) }),
1162
2131
  view: (tr) => {
1163
- syncPending(tr ?? openTx());
2132
+ syncAll();
2133
+ if (tr !== undefined && !committed.has(tr))
2134
+ syncPending(tr);
1164
2135
  return ctx;
1165
2136
  },
1166
2137
  displayType: (id) => {
1167
2138
  if (roles === null)
1168
2139
  return undefined;
1169
- syncPending(openTx());
2140
+ syncAll();
1170
2141
  ensurePlacements();
1171
2142
  const shown = blocks.has(id) ? typeOf(id) : undefined;
1172
2143
  return shown === blocks.get(id)?.type ? undefined : shown;
1173
2144
  },
1174
2145
  dissolved: (id) => {
1175
- syncPending(openTx());
2146
+ syncAll();
1176
2147
  ensurePlacements();
1177
2148
  return dissolved.has(id);
1178
2149
  },
1179
2150
  project: (root) => {
1180
- syncPending(openTx());
2151
+ syncAll();
1181
2152
  ensurePlacements();
1182
2153
  return root === undefined
1183
2154
  ? (kidsMap.get(null) ?? []).map((k) => projectBlock(k.id))
@@ -1185,7 +2156,7 @@ export const bindRuns = (Y) => {
1185
2156
  },
1186
2157
  roles: (next) => {
1187
2158
  roles = next;
1188
- placementVersion++;
2159
+ placementFull = true;
1189
2160
  version++;
1190
2161
  // No commit carries a role change: report it now (not inside a
1191
2162
  // transaction, whose commit reports it), so every view follows.
@@ -1193,12 +2164,12 @@ export const bindRuns = (Y) => {
1193
2164
  publish(null, false);
1194
2165
  },
1195
2166
  track: () => {
1196
- syncPending(openTx());
2167
+ syncAll();
1197
2168
  const f = { touched: new Map(), wrote: false };
1198
2169
  frames.add(f);
1199
2170
  return {
1200
2171
  end: () => {
1201
- syncPending(openTx());
2172
+ syncAll();
1202
2173
  frames.delete(f);
1203
2174
  return f;
1204
2175
  }
@@ -1206,7 +2177,7 @@ export const bindRuns = (Y) => {
1206
2177
  },
1207
2178
  onReport: (cb) => {
1208
2179
  if (reportSubs.size === 0) {
1209
- syncPending(openTx());
2180
+ syncAll();
1210
2181
  published = reachable();
1211
2182
  dataKey = keyOf(docData());
1212
2183
  candidates.clear();