edytor 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +2 -2
  2. package/dist/block/block.svelte.js +6 -21
  3. package/dist/block/inlineBlock.svelte.js +4 -6
  4. package/dist/clipboard/moveText.js +6 -8
  5. package/dist/cloudflare/DocumentRoom.d.ts +10 -3
  6. package/dist/cloudflare/index.d.ts +1 -1
  7. package/dist/cloudflare/room/access.d.ts +8 -3
  8. package/dist/cloudflare/room/access.js +44 -20
  9. package/dist/cloudflare/room/admission.js +28 -5
  10. package/dist/cloudflare/room/comments.js +28 -1
  11. package/dist/cloudflare/room/context.d.ts +115 -106
  12. package/dist/cloudflare/room/context.js +10 -3
  13. package/dist/cloudflare/room/forged.d.ts +14 -0
  14. package/dist/cloudflare/room/forged.js +30 -2
  15. package/dist/cloudflare/room/frames.d.ts +4 -0
  16. package/dist/cloudflare/room/frames.js +5 -1
  17. package/dist/cloudflare/room/purge.js +2 -1
  18. package/dist/cloudflare/room/replicas.d.ts +4 -2
  19. package/dist/cloudflare/room/replicas.js +8 -3
  20. package/dist/cloudflare/room/suggest.d.ts +7 -0
  21. package/dist/cloudflare/room/suggest.js +143 -0
  22. package/dist/cloudflare/routeDocumentSocket.d.ts +10 -1
  23. package/dist/cloudflare/routeDocumentSocket.js +5 -3
  24. package/dist/components/Content.svelte +9 -1
  25. package/dist/crdt/anchors.js +3 -2
  26. package/dist/crdt/attribution/attribution.d.ts +3 -0
  27. package/dist/crdt/attribution/attribution.js +3 -3
  28. package/dist/crdt/doc/capability.js +9 -13
  29. package/dist/crdt/doc/funnel.d.ts +1 -1
  30. package/dist/crdt/doc/funnel.js +5 -2
  31. package/dist/crdt/doc/gate.d.ts +1 -1
  32. package/dist/crdt/doc/history.d.ts +6 -0
  33. package/dist/crdt/doc/history.js +77 -56
  34. package/dist/crdt/doc/layout.js +5 -5
  35. package/dist/crdt/doc/meta.js +6 -7
  36. package/dist/crdt/doc/reads.d.ts +2 -2
  37. package/dist/crdt/doc/reads.js +18 -28
  38. package/dist/crdt/doc/steps.js +4 -4
  39. package/dist/crdt/doc/suggest.d.ts +167 -0
  40. package/dist/crdt/doc/suggest.js +579 -0
  41. package/dist/crdt/document.d.ts +10 -1
  42. package/dist/crdt/document.js +19 -11
  43. package/dist/crdt/edytor-doc.d.ts +355 -333
  44. package/dist/crdt/edytor-doc.js +125 -82
  45. package/dist/crdt/engine-api.d.ts +8 -2
  46. package/dist/crdt/engine.d.ts +2 -1
  47. package/dist/crdt/engine.js +3 -1
  48. package/dist/crdt/flow.d.ts +1 -1
  49. package/dist/crdt/flow.js +4 -10
  50. package/dist/crdt/index.d.ts +111 -105
  51. package/dist/crdt/index.js +3 -0
  52. package/dist/crdt/operations.d.ts +12 -3
  53. package/dist/crdt/parts.d.ts +43 -0
  54. package/dist/crdt/parts.js +44 -0
  55. package/dist/crdt/placement/display.d.ts +1 -1
  56. package/dist/crdt/placement/display.js +3 -3
  57. package/dist/crdt/placement/keys.d.ts +5 -0
  58. package/dist/crdt/placement/keys.js +5 -0
  59. package/dist/crdt/placement/resolve.js +2 -1
  60. package/dist/crdt/protocol.d.ts +1 -1
  61. package/dist/crdt/protocol.js +1 -1
  62. package/dist/crdt/protocols/auth.d.ts +11 -1
  63. package/dist/crdt/protocols/auth.js +17 -1
  64. package/dist/crdt/protocols/comments.d.ts +16 -1
  65. package/dist/crdt/protocols/comments.js +9 -1
  66. package/dist/crdt/providers/index.d.ts +6 -4
  67. package/dist/crdt/providers/index.js +3 -1
  68. package/dist/crdt/providers/websocket.d.ts +6 -4
  69. package/dist/crdt/providers/websocket.js +9 -4
  70. package/dist/crdt/purge.d.ts +7 -1
  71. package/dist/crdt/purge.js +32 -2
  72. package/dist/crdt/restore.d.ts +2 -1
  73. package/dist/crdt/restore.js +9 -0
  74. package/dist/crdt/schema.d.ts +9 -1
  75. package/dist/crdt/schema.js +13 -3
  76. package/dist/crdt/structs.d.ts +2 -0
  77. package/dist/crdt/structs.js +2 -0
  78. package/dist/crdt/text/index/claims.js +1 -23
  79. package/dist/crdt/text/index/layout.d.ts +2 -4
  80. package/dist/crdt/text/index/layout.js +9 -22
  81. package/dist/crdt/text/index/placement.js +21 -24
  82. package/dist/crdt/text/index/shared.js +17 -14
  83. package/dist/crdt/text/model.d.ts +10 -0
  84. package/dist/crdt/text/model.js +8 -1
  85. package/dist/crdt/text/runs.d.ts +34 -30
  86. package/dist/crdt/vendor/yjs/UPSTREAM.md +83 -0
  87. package/dist/crdt/vendor/yjs/dts/index.d.ts +1 -1
  88. package/dist/crdt/vendor/yjs/dts/structs/Item.d.ts +6 -3
  89. package/dist/crdt/vendor/yjs/dts/ynode.d.ts +13 -2
  90. package/dist/crdt/vendor/yjs/src/index.js +1 -1
  91. package/dist/crdt/vendor/yjs/src/structs/Item.js +44 -5
  92. package/dist/crdt/vendor/yjs/src/ynode.js +29 -7
  93. package/dist/edytor.svelte.d.ts +1 -1
  94. package/dist/edytor.svelte.js +100 -75
  95. package/dist/events/beforeInputCommands.d.ts +2 -2
  96. package/dist/events/nativeInteractiveControl.d.ts +11 -1
  97. package/dist/events/nativeInteractiveControl.js +41 -1
  98. package/dist/events/onBeforeInput.js +3 -3
  99. package/dist/events/onFocus.js +9 -11
  100. package/dist/export/forms.d.ts +25 -25
  101. package/dist/index.d.ts +4 -2
  102. package/dist/index.js +1 -1
  103. package/dist/labels.d.ts +8 -0
  104. package/dist/labels.js +6 -1
  105. package/dist/plugins/comments/CommentsController.svelte.d.ts +5 -1
  106. package/dist/plugins/comments/CommentsController.svelte.js +14 -4
  107. package/dist/plugins/find/search.js +2 -2
  108. package/dist/plugins/index.d.ts +1 -0
  109. package/dist/plugins/index.js +1 -0
  110. package/dist/plugins/suggestions/SuggestionBars.svelte +40 -23
  111. package/dist/plugins/suggestions/SuggestionBars.svelte.d.ts +3 -1
  112. package/dist/plugins/suggestions/SuggestionRuns.svelte +77 -0
  113. package/dist/plugins/suggestions/SuggestionRuns.svelte.d.ts +9 -0
  114. package/dist/plugins/suggestions/shared.svelte.d.ts +58 -0
  115. package/dist/plugins/suggestions/shared.svelte.js +421 -0
  116. package/dist/plugins/suggestions/suggestionsPlugin.d.ts +15 -4
  117. package/dist/selection/domSelection.d.ts +2 -0
  118. package/dist/selection/domSelection.js +6 -2
  119. package/dist/selection/pointer.svelte.js +75 -48
  120. package/dist/selection/replaceSelection.js +2 -6
  121. package/dist/selection/selection.svelte.d.ts +3 -3
  122. package/dist/selection/selection.svelte.js +39 -32
  123. package/dist/selection/selection.utils.d.ts +14 -0
  124. package/dist/selection/selection.utils.js +28 -3
  125. package/dist/session/commands.js +21 -11
  126. package/dist/session/composition.svelte.d.ts +5 -3
  127. package/dist/session/composition.svelte.js +21 -17
  128. package/dist/session/handles.d.ts +4 -17
  129. package/dist/session/handles.js +11 -37
  130. package/dist/session/navigation.js +6 -9
  131. package/dist/session/selection.d.ts +11 -5
  132. package/dist/session/selection.js +14 -48
  133. package/dist/session/suggestions.svelte.d.ts +38 -9
  134. package/dist/session/suggestions.svelte.js +37 -18
  135. package/dist/session/virtual.d.ts +1 -1
  136. package/dist/surface/attributes.d.ts +5 -0
  137. package/dist/surface/attributes.js +2 -2
  138. package/dist/surface/cells.d.ts +2 -26
  139. package/dist/surface/cells.js +2 -33
  140. package/dist/surface/observer.svelte.d.ts +6 -1
  141. package/dist/surface/observer.svelte.js +128 -81
  142. package/dist/surface/pin.svelte.d.ts +9 -9
  143. package/dist/surface/pin.svelte.js +15 -22
  144. package/dist/surface/projector.svelte.js +8 -14
  145. package/dist/text/text.svelte.js +14 -19
  146. package/dist/themes/notion.css +29 -0
  147. package/package.json +3 -1
@@ -1,5 +1,6 @@
1
1
  import type { Edytor } from '../edytor.svelte.js';
2
2
  import type { Text } from '../text/text.svelte.js';
3
+ import type { InlineBlock } from '../block/inlineBlock.svelte.js';
3
4
  import { type Kind, type Owned } from './attributes.js';
4
5
  import { type Patched, type Replaced } from './cells.js';
5
6
  /**
@@ -29,7 +30,7 @@ export declare class SurfaceObserver {
29
30
  * release. A text element takes the table's attributes now (its id, its
30
31
  * editing host inside a void).
31
32
  */
32
- register: (node: Element, kind: Kind, block: string, name?: string, owned?: () => Owned) => () => void;
33
+ register: (node: Element, kind: Kind, block: string, name?: string, owned?: () => Owned, handle?: Text | InlineBlock) => () => void;
33
34
  /** The render anchor (`Edytor.svelte`): a registered child of the strict root. */
34
35
  anchor: (node: HTMLElement) => {
35
36
  destroy: () => void;
@@ -53,6 +54,10 @@ export declare class SurfaceObserver {
53
54
  flush: () => Promise<void>;
54
55
  pre: () => void;
55
56
  post: () => void;
57
+ /** The handle a registered element of `kind` renders (`null`: another element). */
58
+ handleOf: <K extends "text" | "atom">(element: Node | null | undefined, kind: K) => (K extends "text" ? Text : InlineBlock) | null;
59
+ /** The block `node` is in (its closest registered element's), none in a preview or outside the host. */
60
+ blockOf: (node: Node | null) => import("../index.js").Block | null;
56
61
  }
57
62
  /**
58
63
  * Adopt what the browser made of `text` — the only adopter: one
@@ -18,8 +18,11 @@
18
18
  * - The pre pass (root `$effect.pre`, before the flush's writes) inverts a
19
19
  * record that removed registered elements (its added nodes go, its removed
20
20
  * nodes return at their recorded siblings, before Svelte reconciles around
21
- * them) and snapshots a content the flush re-renders whose DOM holds an edit
22
- * the flush would overwrite.
21
+ * them), and one that removed only the render's anchors from a block list
22
+ * when the editor did not write it (`dom.anchors.foreign`: the records taken
23
+ * from the pre pass to the end of a compare pass are the editor's own), and
24
+ * snapshots a content the flush re-renders whose DOM holds an edit the
25
+ * flush would overwrite.
23
26
  * - The compare pass (root `$effect`, after every DOM write of the flush)
24
27
  * removes identity clones, nodes inside a content's run and the root's
25
28
  * foreign children, heals owned attributes (bounded), and compares each
@@ -189,6 +192,10 @@ export class SurfaceObserver {
189
192
  #wrote = false;
190
193
  /** Records arrived since the last pass (the projector reads what they did: `rendered`). */
191
194
  #fresh = false;
195
+ /** The flush writes the host: from the pre pass to the end of its compare pass. */
196
+ #rendering = false;
197
+ /** Passes under way (a pass's own repairs; an adoption's synchronous flush nests one). */
198
+ #passes = 0;
192
199
  #window = null;
193
200
  constructor(edytor) {
194
201
  this.edytor = edytor;
@@ -196,11 +203,13 @@ export class SurfaceObserver {
196
203
  /** Observe `root` (the host): records name what to compare. */
197
204
  attach = (root) => {
198
205
  const mo = (this.#mo = new MutationObserver((records) => {
199
- records.forEach(this.#intake);
206
+ for (const record of records)
207
+ this.#intake(record, this.#own());
200
208
  this.signal();
201
209
  }));
202
210
  for (const child of root.childNodes)
203
211
  this.#rooted.add(child);
212
+ this.#rendering = false;
204
213
  mo.observe(root, {
205
214
  attributes: true,
206
215
  attributeOldValue: true,
@@ -220,8 +229,8 @@ export class SurfaceObserver {
220
229
  * release. A text element takes the table's attributes now (its id, its
221
230
  * editing host inside a void).
222
231
  */
223
- register = (node, kind, block, name, owned) => {
224
- const entry = { kind, block, name, owned };
232
+ register = (node, kind, block, name, owned, handle) => {
233
+ const entry = { kind, block, name, owned, handle };
225
234
  this.#registry.set(node, entry);
226
235
  if (kind === 'text')
227
236
  heal(node, ownedOf(this.edytor, node, entry));
@@ -289,50 +298,71 @@ export class SurfaceObserver {
289
298
  if (!this.edytor.readonly)
290
299
  this.#restore();
291
300
  this.#snapshot();
301
+ // From here to the end of the compare pass, what the host records is the flush's.
302
+ this.#rendering = true;
292
303
  });
293
304
  };
294
305
  post = () => {
295
306
  this.#deps();
296
- untrack(() => void this.#pass(true));
307
+ untrack(() => {
308
+ void this.#pass(true);
309
+ this.#rendering = false;
310
+ });
297
311
  };
298
312
  #deps = () => {
299
313
  const { edytor } = this;
300
314
  void [this.epoch, this.records, edytor.readonly, edytor.composition.phase];
301
315
  void [edytor.selection.value, edytor.suggestions.revision];
302
316
  };
317
+ /**
318
+ * The editor writes the host now: the flush (from the pre pass to the end
319
+ * of its compare pass) or a pass's own repairs. A record taken then is the
320
+ * render's (`dom.anchors.foreign`); every other one is the browser's, a
321
+ * page script's or an extension's.
322
+ */
323
+ #own = () => this.#rendering || this.#passes > 0;
303
324
  #take = () => {
304
325
  const records = this.#mo?.takeRecords() ?? [];
305
- records.forEach(this.#intake);
326
+ const own = this.#own();
327
+ for (const record of records)
328
+ this.#intake(record, own);
306
329
  return records.length > 0;
307
330
  };
308
- /** The block an element belongs to: its closest registered element's (`null`: the root). */
309
- #owner = (node) => {
331
+ /** The closest registered element holding `node` in the host: its entry (`null`: the host). */
332
+ #near = (node) => {
310
333
  for (let at = node; at; at = at.parentNode) {
311
334
  if (at === this.edytor.node)
312
335
  return null;
313
- const entry = at instanceof Element ? this.#registry.get(at) : undefined;
314
- // What happens in a preview is the view's own render: nothing to compare.
336
+ const entry = this.#registry.get(at);
315
337
  if (entry)
316
- return entry.kind === 'preview' ? undefined : entry.kind === 'anchor' ? null : entry.block;
338
+ return entry;
317
339
  }
318
- return undefined;
319
340
  };
320
- /** The text whose element holds `node`. */
321
- #textOf = (node) => {
322
- for (let at = node; at && at !== this.edytor.node; at = at.parentNode) {
323
- const entry = at instanceof Element ? this.#registry.get(at) : undefined;
324
- if (entry)
325
- return entry.kind === 'text' ? this.edytor.nodeToText.get(at) : undefined;
326
- }
327
- return undefined;
341
+ /** The block an element belongs to: its closest registered element's (`null`: the root). */
342
+ #owner = (node) => {
343
+ const entry = this.#near(node);
344
+ // What happens in a preview is the view's own render: nothing to compare.
345
+ return (entry && (entry.kind === 'preview' ? undefined : entry.kind === 'anchor' ? null : entry.block));
346
+ };
347
+ /** The handle a registered element of `kind` renders (`null`: another element). */
348
+ handleOf = (element, kind) => {
349
+ const entry = element && this.#registry.get(element);
350
+ return (entry?.kind === kind ? entry.handle : null);
351
+ };
352
+ /** The block `node` is in (its closest registered element's), none in a preview or outside the host. */
353
+ blockOf = (node) => {
354
+ const entry = this.#near(node);
355
+ return entry && entry.kind !== 'preview' && entry.kind !== 'anchor'
356
+ ? (this.edytor.idToBlock.get(entry.block) ?? null)
357
+ : null;
328
358
  };
329
359
  #registered = (node) => node instanceof Element && this.#registry.has(node);
330
360
  /** `node` is, or holds, an element the core still renders. */
331
361
  #holds = (node) => this.#registered(node) ||
332
362
  (node instanceof Element &&
333
363
  [...node.querySelectorAll('*')].some((child) => this.#registry.has(child)));
334
- /** A record names elements to compare (never a verdict). */
335
- #intake = (record) => {
364
+ /** A record names elements to compare (never a verdict); `own`: the editor wrote it. */
365
+ #intake = (record, own) => {
336
366
  this.#fresh = true;
337
367
  const id = this.#owner(record.target);
338
368
  if (id === undefined)
@@ -355,7 +385,7 @@ export class SurfaceObserver {
355
385
  return;
356
386
  const added = [...record.addedNodes];
357
387
  const { target: parent, nextSibling: next, previousSibling: prev } = record;
358
- this.#removed.push({ parent, nodes: removed, added, next, prev });
388
+ this.#removed.push({ parent, nodes: removed, added, next, prev, foreign: !own });
359
389
  // Wrapper replacement: the browser replaced a text element with its own nodes.
360
390
  const [text] = removed;
361
391
  if (removed.length === 1 &&
@@ -394,21 +424,33 @@ export class SurfaceObserver {
394
424
  * browser removed them one record each (Firefox): an anchor-only record is
395
425
  * inverted in a parent that gets a registered element back, else the
396
426
  * element's recorded siblings are gone and it lands outside Svelte's block.
427
+ * A foreign range across blocks (`dom.anchors.foreign`: a script's
428
+ * `Range.deleteContents()`) takes the render's anchors between the blocks
429
+ * from their list (the root, a block's children) and those after its start
430
+ * and before its end inside the blocks it enters, and leaves the elements:
431
+ * Svelte's next removal of either block would start at a detached anchor
432
+ * and leave its element, a child added to one would find no anchor to
433
+ * render before. When a foreign record took anchors from a block list,
434
+ * every foreign anchor-only record of the batch is inverted. The render's
435
+ * own removals (an item leaving the list) are never undone.
397
436
  */
398
437
  #restore = () => {
399
438
  const root = this.edytor.node;
400
439
  const removals = this.#removed.splice(0).reverse();
401
440
  const held = ({ parent, nodes }) => nodes.some((node) => node.parentNode !== parent && this.#holds(node));
402
441
  const parents = new Set(removals.filter(held).map(({ parent }) => parent));
442
+ const crossed = removals.some((removal) => this.#lost(removal) && this.#list(removal.parent));
403
443
  for (const removal of removals) {
404
444
  const { parent, nodes, added, next, prev } = removal;
405
445
  if (!root?.contains(parent))
406
446
  continue;
407
- if (!held(removal) && !(parents.has(parent) && nodes.every(isAnchor)))
447
+ const anchors = parents.has(parent) && nodes.every(isAnchor);
448
+ if (!held(removal) && !anchors && !(crossed && this.#lost(removal)))
408
449
  continue;
409
450
  // A browser-owned attempt's host: its change is adopted whatever the
410
451
  // browser did to its structure, which the re-render then restores.
411
- const inside = this.#textOf(parent);
452
+ const near = this.#near(parent);
453
+ const inside = near?.kind === 'text' && near.handle;
412
454
  if (inside && this.edytor.attempts.on(inside))
413
455
  continue;
414
456
  for (const node of added)
@@ -428,6 +470,17 @@ export class SurfaceObserver {
428
470
  }
429
471
  this.#replaced.clear();
430
472
  };
473
+ /**
474
+ * A foreign record took only the render's anchors, and they are gone from
475
+ * the page (not moved): they can return beside a recorded sibling still
476
+ * there (never at the parent's end).
477
+ */
478
+ #lost = ({ parent, nodes, next, prev, foreign }) => foreign &&
479
+ nodes.every((node) => isAnchor(node) && !node.isConnected) &&
480
+ (next?.parentNode === parent || prev?.parentNode === parent);
481
+ /** A block list: the root, or a parent of block elements (a block's children). */
482
+ #list = (parent) => parent === this.edytor.node ||
483
+ [...parent.childNodes].some((child) => child instanceof Element && this.#registry.get(child)?.kind === 'block');
431
484
  /**
432
485
  * Where an edit made at `at` of the base lands now: through the anchor
433
486
  * minted at it (it follows its text through a re-key, split, move or
@@ -479,6 +532,19 @@ export class SurfaceObserver {
479
532
  * `$effect`); otherwise contents waiting for a flush are left to it.
480
533
  */
481
534
  #pass = (flushed) => {
535
+ this.#passes++;
536
+ try {
537
+ return this.#compare(flushed);
538
+ }
539
+ finally {
540
+ // What the flush and this pass wrote: the render's records, compared
541
+ // at the next pass (as the observer's callback would have them).
542
+ if (this.#take())
543
+ queueMicrotask(this.signal);
544
+ this.#passes--;
545
+ }
546
+ };
547
+ #compare = (flushed) => {
482
548
  const { edytor } = this;
483
549
  const cells = edytor.cells;
484
550
  if (!cells || !edytor.node?.isConnected || edytor.destroyed)
@@ -620,31 +686,11 @@ export class SurfaceObserver {
620
686
  #invert = (id) => {
621
687
  if (id === this.#live())
622
688
  return;
623
- if (this.#shape(id) || !this.#rewrite(id))
689
+ if (this.#shape(id, true))
624
690
  this.edytor.cells?.remount(id);
625
691
  else
626
692
  this.update();
627
693
  };
628
- /** Write each render delta's text back into its node; false when a node is not there. */
629
- #rewrite = (id) => {
630
- const writes = [];
631
- for (const text of this.#texts(id)) {
632
- const deltas = this.edytor.deltasOf(text);
633
- const nodes = this.#nodes(text.node);
634
- const shown = deltas.length ? deltas : [{ text: '\u200B' }];
635
- for (const [i, delta] of shown.entries()) {
636
- const node = nodes[i];
637
- const leaf = node?.nodeType === Node.TEXT_NODE ? node : node && textLeaf(node);
638
- if (!leaf)
639
- return false;
640
- if (leaf.data !== delta.text)
641
- writes.push([leaf, delta.text]);
642
- }
643
- }
644
- for (const [leaf, value] of writes)
645
- leaf.data = value;
646
- return true;
647
- };
648
694
  /** A text element's children that stand for render deltas (Svelte's anchors and the newline marker aside). */
649
695
  #nodes = (element) => [...element.childNodes].filter((node) => !isAnchor(node) &&
650
696
  !(node instanceof Element && node.hasAttribute('data-edytor-trailing-newline')));
@@ -655,7 +701,7 @@ export class SurfaceObserver {
655
701
  */
656
702
  #adoptContent = (id) => {
657
703
  const { edytor } = this;
658
- let broken = Boolean(this.#shape(id));
704
+ let broken = this.#shape(id);
659
705
  const caret = getCollapsedDomTextSelection(edytor);
660
706
  const work = [];
661
707
  for (const text of this.#texts(id)) {
@@ -688,7 +734,7 @@ export class SurfaceObserver {
688
734
  const value = now.slice(0, local) + insert + now.slice(local + remove);
689
735
  return adopt(this.edytor, text, value, undefined, local + insert.length);
690
736
  };
691
- #texts = (id) => [...(this.#contents.get(id) ?? [])].flatMap((element) => this.edytor.nodeToText.get(element) ?? []);
737
+ #texts = (id) => [...(this.#contents.get(id) ?? [])].flatMap((element) => this.handleOf(element, 'text') ?? []);
692
738
  /** A content's DOM: its registered text and atom elements in order; null when not shown. */
693
739
  #read = (id) => {
694
740
  const elements = [...(this.#contents.get(id) ?? [])];
@@ -697,20 +743,17 @@ export class SurfaceObserver {
697
743
  elements.sort((a, b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1);
698
744
  const host = this.#blocks.get(id);
699
745
  let text = '';
700
- let broken = host && !host.isConnected ? 'missing' : null;
746
+ let broken = !!host && !host.isConnected;
701
747
  for (const element of elements) {
702
748
  const kind = this.#registry.get(element)?.kind;
703
749
  // A mark is inside its text element: its presence is the shape check's.
704
750
  if (kind === 'mark')
705
751
  continue;
706
- if (!element.isConnected)
707
- broken ??= 'missing';
708
- else if (host && !host.contains(element))
709
- broken ??= 'escaped';
752
+ broken ||= !element.isConnected || (!!host && !host.contains(element));
710
753
  if (kind === 'atom')
711
754
  text += ATOM;
712
755
  else if (kind === 'text') {
713
- const found = this.edytor.nodeToText.get(element);
756
+ const found = this.handleOf(element, 'text');
714
757
  text += (found ? readDomText(found) : (element.textContent ?? '')).replace(ZWSP, '');
715
758
  }
716
759
  }
@@ -718,46 +761,50 @@ export class SurfaceObserver {
718
761
  };
719
762
  /**
720
763
  * Equal text, compared structurally: each text element (a strict
721
- * container) holds one node per render delta — a text node, or a
722
- * registered mark element whose registered marks follow the delta's chain.
764
+ * container) holds one node per render delta (the empty filler's one) — a
765
+ * text node, or a registered mark element whose registered marks follow
766
+ * the delta's chain and that renders one text node. Answers whether it
767
+ * differs; `write`: a content that does not gets each delta's text back
768
+ * in its node.
723
769
  */
724
- #shape = (id) => {
770
+ #shape = (id, write) => {
771
+ const writes = [];
725
772
  for (const text of this.#texts(id)) {
726
773
  const element = text.node;
727
774
  if (!element)
728
775
  continue;
729
776
  const deltas = this.edytor.deltasOf(text);
777
+ const shown = deltas.length ? deltas : [{ text: '\u200B', marks: [] }];
730
778
  const nodes = this.#nodes(element);
731
- if (!deltas.length) {
732
- if (nodes.length !== 1 || nodes[0].nodeType !== Node.TEXT_NODE)
733
- return 'filler';
734
- continue;
735
- }
736
- if (nodes.length !== deltas.length)
737
- return 'partition';
738
- for (const [i, delta] of deltas.entries()) {
779
+ if (nodes.length !== shown.length)
780
+ return true;
781
+ for (const [i, delta] of shown.entries()) {
739
782
  const node = nodes[i];
783
+ let leaf = node;
740
784
  const chain = delta.marks
741
785
  .map(([name]) => name)
742
786
  .filter((name) => rendersMark(this.edytor.marks, name));
743
- if (!chain.length) {
744
- if (node.nodeType !== Node.TEXT_NODE)
745
- return 'foreign-child';
746
- continue;
787
+ if (chain.length) {
788
+ if (!this.#registry.has(node))
789
+ return true;
790
+ const marks = [node, ...node.querySelectorAll('[data-edytor-mark]')]
791
+ .map((el) => this.#registry.get(el)?.name)
792
+ .filter(Boolean);
793
+ leaf = textLeaf(node);
794
+ // The snippet's markup is its own, but the delta renders one text node.
795
+ if (marks.join() !== chain.join() || !leaf || textLeaf(node, 1))
796
+ return true;
747
797
  }
748
- if (!(node instanceof Element) || !this.#registry.has(node))
749
- return 'foreign-child';
750
- const marks = [node, ...node.querySelectorAll('[data-edytor-mark]')]
751
- .map((el) => this.#registry.get(el)?.name)
752
- .filter(Boolean);
753
- if (marks.join() !== chain.join())
754
- return 'mark-chain';
755
- // The snippet's markup is its own, but the delta renders one text node.
756
- if (!textLeaf(node) || textLeaf(node, 1))
757
- return 'mark-text';
798
+ else if (node.nodeType !== Node.TEXT_NODE)
799
+ return true;
800
+ if (leaf.data !== delta.text)
801
+ writes.push([leaf, delta.text]);
758
802
  }
759
803
  }
760
- return null;
804
+ if (write)
805
+ for (const [leaf, value] of writes)
806
+ leaf.data = value;
807
+ return false;
761
808
  };
762
809
  /** The root's children differ from its registered block elements in cell order, the anchor, Svelte's anchors. */
763
810
  #rootDiverges = () => {
@@ -23,15 +23,15 @@ export declare class Pin {
23
23
  /** The session's preview (the browser draws it itself when `native`). */
24
24
  show: (text: string, marks: Record<string, unknown> | undefined, native: boolean) => void;
25
25
  /**
26
- * The first write replaced a range across texts: the host's
27
- * cell lost the segments and atoms it covered, and the rest of its end text
28
- * now follows the preview in the host, as its own nodes, so the IME's node
29
- * is never rewritten. Across blocks they follow the frozen render inside the
30
- * host element; inside one block (`beside`: across an inline atom) they go
31
- * in their own element right after it (`rest`), as there the IME's write
32
- * moves the host element's render anchors into the next segment's (Chromium).
26
+ * The first write replaced a range across an inline atom inside one block:
27
+ * the host's cell lost the segments and atoms it covered, and the rest of
28
+ * its end text now follows the preview in its own element right after the
29
+ * host (`rest`), so the IME's node is never rewritten (the IME's write
30
+ * moves the host element's render anchors into the next segment's,
31
+ * Chromium). A range across blocks never merges: the session replaces it
32
+ * at its start (`Composition.#rangeless`).
33
33
  */
34
- merge: (cell: Cell, items: Item[], beside: boolean) => void;
34
+ merge: (cell: Cell, items: Item[]) => void;
35
35
  /** The session ended: the host renders from its cell again (the catch-up patch). */
36
36
  release: () => null;
37
37
  /** `node` lies inside the pinned element. */
@@ -48,7 +48,7 @@ export declare class Pin {
48
48
  deltas: RenderDelta[];
49
49
  empty: boolean;
50
50
  };
51
- /** What follows the pinned segment `key` of `block` in its own element (`merge`, `beside`). */
51
+ /** What follows the pinned segment `key` of `block` in its own element (`merge`). */
52
52
  rest: (block: BlockId, key: string) => RenderDelta[];
53
53
  /**
54
54
  * What the IME shows in the pinned element: its text between the frozen
@@ -29,7 +29,6 @@ export class Pin {
29
29
  head: cut(frozen, from)[0],
30
30
  tail: cut(frozen, Math.max(from, to))[1],
31
31
  merged: [],
32
- beside: false,
33
32
  element,
34
33
  // Until the first preview the render stays as the IME found it: a
35
34
  // write now would collapse its DOM range before it replaces it.
@@ -44,18 +43,18 @@ export class Pin {
44
43
  this.#held = { ...held, preview: { text, marks: set && Object.fromEntries(set), native } };
45
44
  };
46
45
  /**
47
- * The first write replaced a range across texts: the host's
48
- * cell lost the segments and atoms it covered, and the rest of its end text
49
- * now follows the preview in the host, as its own nodes, so the IME's node
50
- * is never rewritten. Across blocks they follow the frozen render inside the
51
- * host element; inside one block (`beside`: across an inline atom) they go
52
- * in their own element right after it (`rest`), as there the IME's write
53
- * moves the host element's render anchors into the next segment's (Chromium).
46
+ * The first write replaced a range across an inline atom inside one block:
47
+ * the host's cell lost the segments and atoms it covered, and the rest of
48
+ * its end text now follows the preview in its own element right after the
49
+ * host (`rest`), so the IME's node is never rewritten (the IME's write
50
+ * moves the host element's render anchors into the next segment's,
51
+ * Chromium). A range across blocks never merges: the session replaces it
52
+ * at its start (`Composition.#rangeless`).
54
53
  */
55
- merge = (cell, items, beside) => {
54
+ merge = (cell, items) => {
56
55
  const held = this.#held;
57
56
  if (held)
58
- this.#held = { ...held, parts: partsOf(cell.runs), merged: items, beside };
57
+ this.#held = { ...held, parts: partsOf(cell.runs), merged: items };
59
58
  };
60
59
  /** The session ended: the host renders from its cell again (the catch-up patch). */
61
60
  release = () => (this.#held = null);
@@ -68,14 +67,10 @@ export class Pin {
68
67
  const held = this.#held;
69
68
  if (!held || held.block !== block || held.key !== key)
70
69
  return null;
71
- const { preview, head, tail, beside } = held;
72
- const merged = beside ? [] : held.merged;
70
+ const { preview, head, tail } = held;
73
71
  if (!preview || preview.native)
74
- return {
75
- deltas: [...held.frozen, ...renderDeltas(merged)],
76
- empty: held.empty && !textOf(merged)
77
- };
78
- const items = [...head, preview, ...tail, ...merged];
72
+ return { deltas: [...held.frozen], empty: held.empty };
73
+ const items = [...head, preview, ...tail];
79
74
  return { deltas: renderDeltas(items), empty: !textOf(items) };
80
75
  };
81
76
  /** The render of `segment` of `cell`: pinned while it hosts the IME, else its kind's (`segmentDeltas`). */
@@ -83,11 +78,10 @@ export class Pin {
83
78
  deltas: segmentDeltas(cell, segment, transform),
84
79
  empty: segment.text === ''
85
80
  };
86
- /** What follows the pinned segment `key` of `block` in its own element (`merge`, `beside`). */
81
+ /** What follows the pinned segment `key` of `block` in its own element (`merge`). */
87
82
  rest = (block, key) => {
88
83
  const held = this.#held;
89
- const shown = held?.beside && held.block === block && held.key === key;
90
- return shown ? renderDeltas(held.merged) : [];
84
+ return held?.block === block && held.key === key ? renderDeltas(held.merged) : [];
91
85
  };
92
86
  /**
93
87
  * What the IME shows in the pinned element: its text between the frozen
@@ -102,8 +96,7 @@ export class Pin {
102
96
  const dom = (held.element.textContent ?? '').replace(/\u200B/g, '');
103
97
  if (!held.preview && dom === textOf(held.frozen))
104
98
  return null;
105
- const inside = held.beside ? held.tail : [...held.tail, ...held.merged];
106
- const [head, tail] = [textOf(held.head), textOf(inside)];
99
+ const [head, tail] = [textOf(held.head), textOf(held.tail)];
107
100
  if (dom.length < head.length + tail.length)
108
101
  return null;
109
102
  return dom.startsWith(head) && dom.endsWith(tail)
@@ -39,7 +39,7 @@
39
39
  * from the observer's records (`rendered`, `sel.drift.unreported`).
40
40
  */
41
41
  import { untrack } from 'svelte';
42
- import { clearDomSelection, createDomRange, getActiveElement, getDomSelection, getDomSelectionSnapshot, scrollCaretIntoView } from '../selection/domSelection.js';
42
+ import { clearDomSelection, createDomRange, focusIn, getActiveElement, getDomSelection, getDomSelectionSnapshot, scrollCaretIntoView } from '../selection/domSelection.js';
43
43
  import { getMarkEdgeSide, getYIndex, isTextBoundSelectionPoint } from '../selection/selection.utils.js';
44
44
  import { project, sameEnds } from '../session/selection.js';
45
45
  import { isAndroidChromeBrowser } from '../events/events.utils.js';
@@ -116,9 +116,7 @@ export class Projector {
116
116
  pre = () => {
117
117
  void this.#deps();
118
118
  untrack(() => {
119
- const node = this.edytor.node;
120
- const active = node && getActiveElement(node);
121
- this.#noted = active && node.contains(active) ? active : null;
119
+ this.#noted = focusIn(this.edytor.node);
122
120
  });
123
121
  };
124
122
  /** Root post-flush `$effect`: every DOM write of the flush has landed. */
@@ -430,8 +428,7 @@ export class Projector {
430
428
  // whose `selectionchange` can land after that key counted as a
431
429
  // gesture) leaves any caret the browser parks later its own
432
430
  // (`#parking`, `placed`), whatever key comes before it.
433
- const active = getActiveElement(node);
434
- if (requested && value.kind !== 'blocks' && active && node.contains(active))
431
+ if (requested && value.kind !== 'blocks' && focusIn(node))
435
432
  this.#parking = true;
436
433
  const inside = !!dom?.anchorNode && node.contains(dom.anchorNode);
437
434
  const clears = value.kind === 'none' ? requested && inside : inside || dom?.rangeCount === 0;
@@ -459,15 +456,11 @@ export class Projector {
459
456
  if (!this.#shows(dom, range, isReversed)) {
460
457
  if (!this.#ours(requested))
461
458
  return true;
462
- const active = getActiveElement(node);
463
- if (!(active && node.contains(active))) {
459
+ if (!focusIn(node)) {
464
460
  // Focus first (focusing a host can move the selection): the editing
465
461
  // host, never a focusable element inside the text such as a link.
466
- edytor.expectInternalFocus();
467
462
  const element = start[0] instanceof HTMLElement ? start[0] : start[0].parentElement;
468
- (element?.closest('[contenteditable="true"]') ?? node).focus({
469
- preventScroll: true
470
- });
463
+ edytor.expectInternalFocus(element?.closest('[contenteditable="true"]') ?? node);
471
464
  }
472
465
  this.#write(getDomSelection(node) ?? dom, points, isCollapsed);
473
466
  if (selection.scrollOnDisplay)
@@ -551,10 +544,11 @@ export class Projector {
551
544
  #ours = (requested) => {
552
545
  const node = this.edytor.node;
553
546
  const active = getActiveElement(node);
547
+ const inside = focusIn(node);
554
548
  // A form control or a foreign editable in a kind's chrome (a field bound to
555
549
  // `block.data`) owns its focus and its selection: never written over.
556
- if (active && node.contains(active))
557
- return !isKindControl(node, active);
550
+ if (inside)
551
+ return !isKindControl(node, inside);
558
552
  const document = node.ownerDocument;
559
553
  if (active && active !== document.body && active !== document.documentElement)
560
554
  return false;
@@ -27,28 +27,26 @@ export class Text {
27
27
  get parent() {
28
28
  return this.edytor.idToBlock.block(this.blockId);
29
29
  }
30
- /** This segment in its block's parts, and its position among them. */
31
- get #at() {
30
+ /** This segment in its block's parts. */
31
+ get #parts() {
32
32
  if ($effect.tracking())
33
33
  this.edytor.cells?.get(this.blockId);
34
- const parts = this.edytor.idToBlock.parts(this.blockId);
35
- for (let index = 0, k = 0; index < parts.length; index++) {
36
- const part = parts[index];
37
- if (part.kind === 'text' && k++ === this.ordinal)
38
- return { part, index };
39
- }
40
- return { index: -1 };
34
+ return this.edytor.idToBlock.parts(this.blockId);
35
+ }
36
+ get #part() {
37
+ return this.#parts.find((part) => part.kind === 'text' && part.segOrd === this.ordinal);
41
38
  }
42
39
  /** Position among the block's content parts. */
43
40
  get index() {
44
- return this.#at.index;
41
+ const parts = this.#parts;
42
+ return parts.indexOf(this.#part);
45
43
  }
46
44
  get value() {
47
- return segmentJSON(this.#at.part?.items ?? []);
45
+ return segmentJSON(this.#part?.runs ?? []);
48
46
  }
49
47
  /** The segment's text. */
50
48
  get stringContent() {
51
- return this.#at.part?.items.map((item) => item.text).join('') ?? '';
49
+ return this.#part?.text ?? '';
52
50
  }
53
51
  get isEmpty() {
54
52
  return this.length === 0;
@@ -57,15 +55,15 @@ export class Text {
57
55
  return this.stringContent.endsWith('\n');
58
56
  }
59
57
  get length() {
60
- return this.#at.part?.length ?? 0;
58
+ return this.stringContent.length;
61
59
  }
62
60
  /** Display offset of this segment inside its block's content (atoms count 1). */
63
61
  get segStart() {
64
- return this.#at.part?.start ?? 0;
62
+ return this.#part?.start ?? 0;
65
63
  }
66
64
  /** The segment is shown by a live block. */
67
65
  get isInDocument() {
68
- return this.parent.isInTree && this.#at.part !== undefined;
66
+ return this.parent.isInTree && this.#part !== undefined;
69
67
  }
70
68
  /**
71
69
  * Re-create this block's text elements from its cell — the repair of a
@@ -101,9 +99,8 @@ export class Text {
101
99
  /** @internal */
102
100
  attach = (node) => {
103
101
  this.node = node;
104
- this.edytor.nodeToText.set(node, this);
105
102
  // Registered, it takes the attribute table's attributes (`surface/attributes.ts`).
106
- const release = this.edytor.surface.register(node, 'text', this.parent.id);
103
+ const release = this.edytor.surface.register(node, 'text', this.parent.id, undefined, undefined, this);
107
104
  const pluginDestroy = cleanups(this.edytor.plugins, (plugin) => plugin.onTextAttached?.({ node, text: this }));
108
105
  // A display that waited for a mounted destination runs in the next pass.
109
106
  this.edytor.projector?.mounted(this);
@@ -111,8 +108,6 @@ export class Text {
111
108
  destroy: () => {
112
109
  if (this.node === node)
113
110
  this.node = undefined;
114
- if (this.edytor.nodeToText.get(node) === this)
115
- this.edytor.nodeToText.delete(node);
116
111
  release();
117
112
  pluginDestroy.forEach((destroy) => destroy());
118
113
  }