edytor 6.0.0 → 7.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 (76) hide show
  1. package/README.md +3 -3
  2. package/dist/cloudflare/DocumentRoom.d.ts +77 -2
  3. package/dist/cloudflare/DocumentRoom.js +37 -1
  4. package/dist/cloudflare/index.d.ts +1 -1
  5. package/dist/cloudflare/index.js +1 -1
  6. package/dist/cloudflare/room/access.d.ts +6 -1
  7. package/dist/cloudflare/room/access.js +30 -4
  8. package/dist/cloudflare/room/admission.d.ts +11 -0
  9. package/dist/cloudflare/room/admission.js +43 -10
  10. package/dist/cloudflare/room/context.d.ts +11 -2
  11. package/dist/cloudflare/room/context.js +17 -6
  12. package/dist/cloudflare/room/forged.d.ts +1 -1
  13. package/dist/cloudflare/room/forged.js +11 -9
  14. package/dist/cloudflare/room/frames.d.ts +2 -0
  15. package/dist/cloudflare/room/frames.js +2 -0
  16. package/dist/cloudflare/room/presence.js +1 -4
  17. package/dist/cloudflare/room/reseed.d.ts +93 -0
  18. package/dist/cloudflare/room/reseed.js +500 -0
  19. package/dist/cloudflare/room/scheduler.d.ts +1 -1
  20. package/dist/cloudflare/room/scheduler.js +9 -1
  21. package/dist/cloudflare/room/shared.d.ts +10 -0
  22. package/dist/cloudflare/room/shared.js +10 -0
  23. package/dist/cloudflare/room/storage.d.ts +7 -0
  24. package/dist/cloudflare/room/storage.js +33 -1
  25. package/dist/components/Edytor.svelte +18 -3
  26. package/dist/components/Edytor.svelte.d.ts +10 -1
  27. package/dist/components/EdytorRoot.svelte +17 -0
  28. package/dist/components/EdytorRoot.svelte.d.ts +4 -0
  29. package/dist/components/Suggestion.svelte +1 -1
  30. package/dist/crdt/attribution/block.d.ts +3 -0
  31. package/dist/crdt/attribution/block.js +3 -2
  32. package/dist/crdt/doc/suggest.d.ts +6 -2
  33. package/dist/crdt/doc/suggest.js +6 -5
  34. package/dist/crdt/edytor-doc.d.ts +1 -1
  35. package/dist/crdt/edytor-doc.js +1 -1
  36. package/dist/crdt/engine-room.d.ts +4 -0
  37. package/dist/crdt/engine-room.js +6 -0
  38. package/dist/crdt/index.d.ts +2 -1
  39. package/dist/crdt/protocol.d.ts +2 -2
  40. package/dist/crdt/protocol.js +2 -2
  41. package/dist/crdt/protocols/auth.d.ts +8 -1
  42. package/dist/crdt/protocols/auth.js +14 -1
  43. package/dist/crdt/providers/index.d.ts +1 -0
  44. package/dist/crdt/providers/index.js +22 -3
  45. package/dist/crdt/providers/room.d.ts +15 -0
  46. package/dist/crdt/providers/room.js +15 -0
  47. package/dist/crdt/providers/websocket.d.ts +4 -2
  48. package/dist/crdt/providers/websocket.js +36 -9
  49. package/dist/crdt/reseed.d.ts +127 -0
  50. package/dist/crdt/reseed.js +533 -0
  51. package/dist/crdt/restore.d.ts +13 -1
  52. package/dist/crdt/restore.js +8 -3
  53. package/dist/crdt/schema.d.ts +6 -0
  54. package/dist/crdt/schema.js +6 -0
  55. package/dist/crdt/structs.d.ts +8 -11
  56. package/dist/crdt/structs.js +25 -44
  57. package/dist/crdt/text/index/build.d.ts +3 -9
  58. package/dist/crdt/text/index/build.js +87 -181
  59. package/dist/crdt/vendor/yjs/UPSTREAM.md +101 -5
  60. package/dist/crdt/vendor/yjs/dts/index.d.ts +1 -1
  61. package/dist/crdt/vendor/yjs/dts/utils/Transaction.d.ts +10 -10
  62. package/dist/crdt/vendor/yjs/dts/utils/encoding.d.ts +1 -0
  63. package/dist/crdt/vendor/yjs/src/index.js +1 -1
  64. package/dist/crdt/vendor/yjs/src/structs/Item.js +24 -13
  65. package/dist/crdt/vendor/yjs/src/utils/Transaction.js +12 -10
  66. package/dist/crdt/vendor/yjs/src/utils/encoding.js +42 -1
  67. package/dist/index.d.ts +1 -1
  68. package/dist/index.js +1 -1
  69. package/dist/selection/selection.svelte.d.ts +1 -1
  70. package/dist/selection/selection.svelte.js +87 -106
  71. package/dist/session/commands.js +49 -51
  72. package/dist/surface/attributes.d.ts +3 -3
  73. package/dist/surface/attributes.js +2 -2
  74. package/dist/surface/observer.svelte.d.ts +7 -7
  75. package/dist/surface/observer.svelte.js +213 -309
  76. package/package.json +1 -1
@@ -16,19 +16,18 @@ import { caretBeside, shownText } from './replaceSelection.js';
16
16
  * order), comparing boundary points — a focus on an ancestor element of the
17
17
  * anchor (`(textElement, childCount)`) is after it.
18
18
  */
19
- export const isBackward = (selection) => {
20
- const { anchorNode, focusNode } = selection;
19
+ export const isBackward = ({ anchorNode, anchorOffset, focusNode, focusOffset }) => {
21
20
  if (!anchorNode || !focusNode)
22
21
  return false;
23
22
  if (anchorNode === focusNode)
24
- return selection.focusOffset < selection.anchorOffset;
23
+ return focusOffset < anchorOffset;
25
24
  try {
26
25
  const range = createDomRange(anchorNode);
27
- range.setStart(anchorNode, selection.anchorOffset);
28
- return range.comparePoint(focusNode, selection.focusOffset) < 0;
26
+ range.setStart(anchorNode, anchorOffset);
27
+ return range.comparePoint(focusNode, focusOffset) < 0;
29
28
  }
30
29
  catch {
31
- return Boolean(anchorNode.compareDocumentPosition(focusNode) & Node.DOCUMENT_POSITION_PRECEDING);
30
+ return !!(anchorNode.compareDocumentPosition(focusNode) & Node.DOCUMENT_POSITION_PRECEDING);
32
31
  }
33
32
  };
34
33
  /** A text suggestion's preview at a DOM point: around it, or the child at either side of it. */
@@ -56,18 +55,6 @@ const atomBetween = (start, end, yStart, yEnd) => {
56
55
  const part = from !== -1 && to === from + 2 ? content[from + 1] : null;
57
56
  return part instanceof InlineBlock ? part : null;
58
57
  };
59
- /**
60
- * Where a range ending in `block` starts (`first`) or ends (`last`): its
61
- * first or last shown line (a list's items, a code block's lines); a block
62
- * that shows none (a divider) passes to its neighbours as a caret does, the
63
- * next line first.
64
- */
65
- const rangeEdge = (block, edge) => {
66
- const text = shownText(block, edge);
67
- if (text)
68
- return { text, offset: edge === 'first' ? 0 : text.length };
69
- return caretBeside(block, 'blockAfter') ?? caretBeside(block, 'blockBefore');
70
- };
71
58
  /** One block's membership of the selected and focused sets, as its own two signals. */
72
59
  class Membership {
73
60
  selected = $state(false);
@@ -183,14 +170,18 @@ export class EdytorSelection {
183
170
  texts
184
171
  };
185
172
  };
173
+ /** The DOM fields observed with the current value, if they were. */
174
+ get #observed() {
175
+ return this.#surface?.value === this.value ? this.#surface : undefined;
176
+ }
186
177
  /** @internal The mark-edge side the DOM showed the current caret with (`marksForInsertion`). */
187
178
  get edge() {
188
- return this.#surface?.value === this.value ? this.#surface.edge : undefined;
179
+ return this.#observed?.edge;
189
180
  }
190
181
  /** @internal Focus is in a void block's own `input` or `textarea`. */
191
182
  get voidField() {
192
- const tag = getActiveElement(this.edytor.node)?.tagName.toUpperCase();
193
- return this.projection.voidRoot !== null && (tag === 'INPUT' || tag === 'TEXTAREA');
183
+ return (this.projection.voidRoot !== null &&
184
+ /^(input|textarea)$/i.test(getActiveElement(this.edytor.node)?.tagName ?? ''));
194
185
  }
195
186
  /**
196
187
  * Replace the selection with `next` (none, a text range of two anchors,
@@ -206,6 +197,7 @@ export class EdytorSelection {
206
197
  * @internal
207
198
  */
208
199
  commit = (next, cause = 'model', surface) => {
200
+ const { edytor } = this;
209
201
  next = this.#keepPending(this.#shown(next));
210
202
  const changed = !sameValue(this.value, next);
211
203
  if (changed)
@@ -215,44 +207,39 @@ export class EdytorSelection {
215
207
  // A repair is a background display (it never takes focus); a command,
216
208
  // history or host code asks for one.
217
209
  if (cause === 'repair')
218
- this.edytor.surface.update();
210
+ edytor.surface.update();
219
211
  else if (cause !== 'dom')
220
212
  this.display();
221
213
  if (surface)
222
214
  this.#surface = { value, ...surface };
223
- const projection = project(value, this.edytor.facade);
224
- const blocksOf = (ids) => ids.flatMap((id) => this.edytor.idToBlock.get(id) ?? []);
215
+ const projection = project(value, edytor.facade);
216
+ const handles = edytor.idToBlock;
217
+ const blocksOf = (ids) => ids.flatMap((id) => handles.get(id) ?? []);
225
218
  const atom = value.kind === 'atom' && projection.start
226
- ? this.edytor.idToBlock.atom(value.blockId, value.atomId)
219
+ ? handles.atom(value.blockId, value.atomId)
227
220
  : undefined;
228
- this.#sync(this.#selected, value.kind === 'blocks' ? blocksOf(value.ids) : [], 'selected');
229
- this.#sync(this.#focused, value.kind === 'text'
230
- ? blocksOf(projection.blocks)
231
- : value.kind === 'atom'
232
- ? blocksOf([value.blockId])
233
- : [], 'focused');
221
+ this.#sync(this.#selected, blocksOf(value.kind === 'blocks' ? value.ids : []), 'selected');
222
+ this.#sync(this.#focused, blocksOf(value.kind === 'text' ? projection.blocks : value.kind === 'atom' ? [value.blockId] : []), 'focused');
234
223
  if (atom !== this.inlineBlockDeletionTarget || (!atom && this.selectedInlineBlock.size)) {
235
224
  this.selectedInlineBlock.clear();
236
225
  if (atom)
237
226
  this.selectedInlineBlock.add(atom);
238
227
  this.inlineBlockDeletionTarget = atom ?? null;
239
228
  }
240
- this.edytor.history?.selected(value);
229
+ edytor.history?.selected(value);
241
230
  const start = this.textAt(projection.start);
242
- if (start) {
231
+ if (start)
243
232
  this.#lastText = start.text;
244
- }
245
233
  if (!changed)
246
234
  return;
247
235
  // A dead view never publishes: its entry went with its teardown.
248
- if (!this.edytor.destroyed) {
236
+ if (!edytor.destroyed) {
249
237
  const focus = projection.isReversed ? projection.start : projection.end;
250
- this.edytor.presence.write(serialize(value, projection), focus?.block ?? null);
238
+ edytor.presence.write(serialize(value, projection), focus?.block ?? null);
251
239
  }
252
240
  this.edytorOnSelectionChange?.(this);
253
- this.edytor.plugins.forEach((plugin) => {
241
+ for (const plugin of edytor.plugins)
254
242
  plugin.onSelectionChange?.(this);
255
- });
256
243
  };
257
244
  /**
258
245
  * The collapsed text caret in block offsets (`{ block, offset }`, an
@@ -307,13 +294,17 @@ export class EdytorSelection {
307
294
  return next;
308
295
  const { start, end, isReversed } = project(next, this.edytor.facade);
309
296
  const caret = next.focus === next.anchor;
297
+ // A range's end goes to its block's first or last shown line (a list's
298
+ // items, a code block's lines); a caret, or a block that shows none (a
299
+ // divider), passes to its neighbours, the next line first.
310
300
  const moved = (at, anchor, edge) => {
311
301
  const block = at && this.edytor.idToBlock.get(at.block);
312
302
  if (!block || block.rendersContent)
313
303
  return anchor;
314
- const to = caret
315
- ? (caretBeside(block, 'blockAfter') ?? caretBeside(block, 'blockBefore'))
316
- : rangeEdge(block, edge);
304
+ const text = !caret && shownText(block, edge);
305
+ const to = text
306
+ ? { text, offset: edge === 'first' ? 0 : text.length }
307
+ : (caretBeside(block, 'blockAfter') ?? caretBeside(block, 'blockBefore'));
317
308
  return to && this.createTextAnchor(to.text, to.offset);
318
309
  };
319
310
  const [first, last] = isReversed ? ['last', 'first'] : ['first', 'last'];
@@ -477,8 +468,7 @@ export class EdytorSelection {
477
468
  * `inlineBlockDeletionTarget` follow the value (`commit`).
478
469
  */
479
470
  clearInlineBlockSelection = () => {
480
- const value = this.value;
481
- if (value.kind !== 'atom')
471
+ if (this.value.kind !== 'atom')
482
472
  return;
483
473
  const { start } = this.projection;
484
474
  this.select(start ? this.valueAt(start) : noSelection);
@@ -533,10 +523,11 @@ export class EdytorSelection {
533
523
  };
534
524
  /** @internal */
535
525
  applySelectionSnapshot = (selection, options = {}) => {
536
- if (this.dragging && this.pointer.dragAcross(selection))
526
+ const { edytor, pointer } = this;
527
+ if (this.dragging && pointer.dragAcross(selection))
537
528
  return;
538
529
  // A block selection ignores the DOM, but the one a drag across columns made while it lasts.
539
- if (this.selectedBlocks.size > 0 && !(this.dragging && this.pointer.across))
530
+ if (this.selectedBlocks.size > 0 && !(this.dragging && pointer.across))
540
531
  return;
541
532
  // A selection in a kind's own control (a form control, a nested
542
533
  // editable island) or a focused one belongs to it, not the model —
@@ -544,46 +535,49 @@ export class EdytorSelection {
544
535
  // corrupt the next model-routed write. Both endpoints are checked: a
545
536
  // range anchored in host text but focused inside an island must not
546
537
  // resolve either.
547
- const container = this.edytor.node;
538
+ const container = edytor.node;
548
539
  const read = this.#read(selection);
549
- if (!selection || !read || isKindControl(container, getActiveElement(container)))
540
+ if (!read || isKindControl(container, getActiveElement(container)))
550
541
  return;
551
- const { anchorNode, focusNode, isCollapsed } = selection;
542
+ const { anchorNode, focusNode, isCollapsed, rangeCount } = selection;
552
543
  // A native vertical move that landed on no caret stop takes the key's line stop.
553
544
  if (isCollapsed &&
554
545
  !isTextBoundSelectionPoint(anchorNode) &&
555
- !getInlineBlockOfNode(this.edytor, anchorNode) &&
556
- landed(this.edytor))
546
+ !getInlineBlockOfNode(edytor, anchorNode) &&
547
+ landed(edytor))
557
548
  return;
558
549
  const { isReversed, startNode, start, endNode, end } = read;
559
- const atom = getInlineBlockInSelectedRange(this.edytor, selection.rangeCount ? selection.getRangeAt(0) : undefined);
560
- const startText = atom ? null : this.getTextOfNode(startNode, start);
561
- let endText = startText && this.getTextOfNode(endNode, end);
550
+ const atom = getInlineBlockInSelectedRange(edytor, rangeCount ? selection.getRangeAt(0) : undefined);
551
+ const startText = atom ? null : read.startText;
552
+ let endText = startText && read.endText;
553
+ /** The atom selection: the DOM shows no range for it. */
554
+ const toAtom = (atom, from) => {
555
+ clearDomSelection(container);
556
+ this.selectInlineBlock(atom, from);
557
+ };
562
558
  if (!startText) {
563
- const inlineBlock = atom ?? getInlineBlockOfNode(this.edytor, startNode);
559
+ const inlineBlock = atom ?? getInlineBlockOfNode(edytor, startNode);
564
560
  // A press in a non-native editable island (contenteditable=false, no control).
565
561
  const island = getElementFromNode(startNode)?.closest('[contenteditable="false"]');
566
- const islandBlock = island && !isNativeInteractiveControl(island) && this.getBlockOfNode(island);
567
- if (islandBlock && islandBlock.firstText)
568
- return this.selectText(islandBlock.firstText, 0);
562
+ const first = island && !isNativeInteractiveControl(island) && this.getBlockOfNode(island)?.firstText;
563
+ if (first)
564
+ return this.selectText(first, 0);
569
565
  // A point on a white space node: the model caret is displayed again.
570
566
  if (!inlineBlock && this.textAt(this.projection.start))
571
567
  return this.display();
572
- clearDomSelection(this.edytor.node);
573
568
  if (inlineBlock)
574
- this.selectInlineBlock(inlineBlock);
575
- else
576
- this.clearInlineBlockSelection();
577
- return;
569
+ return toAtom(inlineBlock);
570
+ clearDomSelection(container);
571
+ return this.clearInlineBlockSelection();
578
572
  }
579
- const overlay = isCollapsed ? overlayAt(startNode, start) : null;
573
+ const overlay = isCollapsed && overlayAt(startNode, start);
580
574
  if (overlay) {
581
575
  const text = this.getBlockOfNode(overlay)?.lastText ?? startText;
582
576
  return this.selectText(text, text.length);
583
577
  }
584
578
  const rawYStart = getYIndex(startText, startNode, start);
585
579
  const rawYEnd = isCollapsed ? rawYStart : getYIndex(endText, endNode, end);
586
- let yStart = normalizeUtf16Boundary(startText.stringContent, rawYStart, isCollapsed ? 'forward' : 'backward');
580
+ const yStart = normalizeUtf16Boundary(startText.stringContent, rawYStart, isCollapsed ? 'forward' : 'backward');
587
581
  let yEnd = isCollapsed
588
582
  ? yStart
589
583
  : endText
@@ -599,7 +593,7 @@ export class EdytorSelection {
599
593
  endText === endText.parent.firstText &&
600
594
  yEnd === 0) {
601
595
  // The shown block before: a closed toggle's header, not its hidden body.
602
- const previous = visibility.shown(endText.parent, 'blockBefore')?.lastText ?? null;
596
+ const previous = visibility.shown(endText.parent, 'blockBefore')?.lastText;
603
597
  if (previous?.node?.isConnected) {
604
598
  endText = previous;
605
599
  yEnd = previous.length;
@@ -611,11 +605,8 @@ export class EdytorSelection {
611
605
  return this.selectText(startText, startText.length);
612
606
  }
613
607
  const between = atomBetween(startText, endText, yStart, yEnd);
614
- if (between) {
615
- clearDomSelection(this.edytor.node);
616
- this.selectInlineBlock(between, isReversed ? 'after' : 'before');
617
- return;
618
- }
608
+ if (between)
609
+ return toAtom(between, isReversed ? 'after' : 'before');
619
610
  const value = this.textValue(startText, yStart, endText ?? startText, yEnd, isReversed);
620
611
  // A node-bound native range (endpoints outside text elements: a block
621
612
  // node selection, a stray boundary node) is the shape engines extend
@@ -629,11 +620,11 @@ export class EdytorSelection {
629
620
  edge: isCollapsed ? getMarkEdgeSide(startText, startNode, yStart) : undefined,
630
621
  nodeBound
631
622
  });
632
- this.edytor.projector.observe();
623
+ edytor.projector.observe();
633
624
  if (restore && options.restoreNormalizedDomRange !== false && endText) {
634
625
  // Writing the DOM under a pointer drag would reset its anchor: after it.
635
626
  if (this.dragging)
636
- this.pointer.hold();
627
+ pointer.hold();
637
628
  else
638
629
  this.selectText(startText, yStart, endText, yEnd, isReversed);
639
630
  }
@@ -644,7 +635,7 @@ export class EdytorSelection {
644
635
  * engine-defined and therefore intercepted by the hotkey layer.
645
636
  * @internal
646
637
  */
647
- hasNativeNodeSelection = () => this.#surface?.value === this.value && this.#surface.nodeBound === true;
638
+ hasNativeNodeSelection = () => this.#observed?.nodeBound === true;
648
639
  /**
649
640
  * Anchor a text position into the BACKING text that owns the atom at
650
641
  * that display position — the v14 replacement for
@@ -717,16 +708,13 @@ export class EdytorSelection {
717
708
  * @internal
718
709
  */
719
710
  restoreDeadSelectionEndpoints = () => {
720
- if (this.expectHistoryRestore) {
711
+ // A history step selects its own value; mid pointer-drag the user's
712
+ // in-progress range owns the selection; a command that declared its
713
+ // result selection owns this view's endpoints.
714
+ if (this.expectHistoryRestore || this.dragging || this.edytor.dispatcher.authoring)
721
715
  return;
722
- }
723
- // Mid pointer-drag the user's in-progress range owns the selection; a
724
- // command that declared its result selection owns this view's endpoints.
725
- if (this.dragging || this.edytor.dispatcher.authoring) {
726
- return;
727
- }
728
716
  const { value } = this;
729
- const facade = this.edytor.facade;
717
+ const { facade } = this.edytor;
730
718
  const { start, isCollapsed } = this.projection;
731
719
  const at = this.textAt(start);
732
720
  let dead;
@@ -734,10 +722,8 @@ export class EdytorSelection {
734
722
  const live = value.ids.filter((id) => facade.isVisibleBlock(id));
735
723
  if (live.length === value.ids.length)
736
724
  return;
737
- if (live.length) {
738
- this.commit(blockSelection(live), 'repair');
739
- return;
740
- }
725
+ if (live.length)
726
+ return this.#land(blockSelection(live));
741
727
  dead = value.ids[0] ?? null;
742
728
  }
743
729
  else if (value.kind === 'atom') {
@@ -746,32 +732,26 @@ export class EdytorSelection {
746
732
  const anchor = facade.isVisibleBlock(value.blockId)
747
733
  ? facade.anchorAt(value.blockId, 0, 'left')
748
734
  : null;
749
- if (anchor) {
750
- this.#land(textSelection(anchor));
751
- return;
752
- }
735
+ if (anchor)
736
+ return this.#land(textSelection(anchor));
753
737
  dead = value.blockId;
754
738
  }
755
739
  else if (value.kind === 'text') {
756
740
  // A peer or host code turned the block it rests in into one that shows
757
741
  // no content (an empty line into a divider): the nearest shown line.
758
742
  const shown = this.#shown(value);
759
- if (shown !== value) {
760
- this.#land(shown);
761
- return;
762
- }
763
- if (at && start && isCollapsed && value.anchor !== value.focus) {
743
+ if (shown !== value)
744
+ return this.#land(shown);
745
+ if (at && isCollapsed && value.anchor !== value.focus) {
764
746
  // A range whose content died is a caret at the seam (`sel.seam.covered-atom`):
765
747
  // its two anchors would re-open around text re-inserted there.
766
748
  const caret = facade.anchorAt(start.block, start.offset, 'left');
767
- if (caret) {
768
- this.#land(textSelection(caret));
769
- return;
770
- }
749
+ if (caret)
750
+ return this.#land(textSelection(caret));
771
751
  }
772
752
  if (at) {
773
753
  // The text it was displayed in died while its anchor moved on: display again.
774
- if (this.#lastText !== null && !this.#lastText.isInDocument)
754
+ if (this.#lastText && !this.#lastText.isInDocument)
775
755
  this.display();
776
756
  this.#lastText = at.text;
777
757
  return;
@@ -804,25 +784,26 @@ export class EdytorSelection {
804
784
  */
805
785
  mint = (snapshot) => {
806
786
  const read = this.#read(snapshot);
807
- if (!read)
808
- return null;
809
- const { isReversed, startNode, start, endNode, end } = read;
810
- const startText = this.getTextOfNode(startNode, start);
811
- const endText = startText && this.getTextOfNode(endNode, end);
787
+ const { startText, endText } = read ?? {};
812
788
  if (!startText || !endText)
813
789
  return null;
790
+ const { isReversed, startNode, start, endNode, end } = read;
814
791
  const from = offsetIn(startText, startNode, start);
815
792
  return this.textValue(startText, from, endText, offsetIn(endText, endNode, end), isReversed);
816
793
  };
817
794
  /**
818
795
  * A DOM selection whose two endpoints are this editor's own points (not
819
- * in a kind's control): its endpoints in document order and its direction.
796
+ * in a kind's control): its endpoints in document order, its direction,
797
+ * and the texts they are in (the end's only when the start is in one).
820
798
  */
821
799
  #read = (dom) => {
822
800
  const container = this.edytor.node;
823
801
  if (!dom || !isOwnPoint(container, dom.anchorNode) || !isOwnPoint(container, dom.focusNode))
824
802
  return null;
825
803
  const isReversed = isBackward(dom);
826
- return { isReversed, ...ordered(dom, isReversed) };
804
+ const ends = ordered(dom, isReversed);
805
+ const startText = this.getTextOfNode(ends.startNode, ends.start);
806
+ const endText = startText && this.getTextOfNode(ends.endNode, ends.end);
807
+ return { isReversed, ...ends, startText, endText };
827
808
  };
828
809
  }
@@ -6,6 +6,7 @@ import { DEV } from 'esm-env';
6
6
  import { PreventionError, vetoable } from '../utils.js';
7
7
  import { kindOf } from './attempt.js';
8
8
  import { insertionMarksAt } from './editing/text.js';
9
+ import { diffText } from '../utils/diffText.js';
9
10
  /** A command the view refused (readonly, a read-only document) or a hook prevented. */
10
11
  const PREVENTED = refused('view');
11
12
  /**
@@ -73,26 +74,17 @@ const textPatch = (args) => {
73
74
  };
74
75
  };
75
76
  /**
76
- * `next` is `previous` with one contiguous run typed in or deleted (their
77
- * common prefix and suffix cover the shorter one): what typing and Backspace
78
- * produce. Another value ('todo' → 'done', 'doing' → 'done') is not.
77
+ * `patch` continues `previous`: the same paths, each string edited by typing
78
+ * (one contiguous run typed in or deleted, what typing and Backspace produce;
79
+ * another value, 'todo' → 'done', is not).
79
80
  */
80
- const typedFrom = (previous, next) => {
81
- const shorter = Math.min(previous.length, next.length);
82
- let prefix = 0;
83
- while (prefix < shorter && previous[prefix] === next[prefix])
84
- prefix++;
85
- let suffix = 0;
86
- while (suffix < shorter - prefix &&
87
- previous[previous.length - 1 - suffix] === next[next.length - 1 - suffix])
88
- suffix++;
89
- return prefix + suffix === shorter;
90
- };
91
- /** `patch` continues `previous`: the same paths, each string edited by typing. */
92
81
  const continuesTyping = (previous, patch) => patch !== null &&
93
82
  previous !== null &&
94
83
  patch.key === previous.key &&
95
- patch.values.every((value, i) => typedFrom(previous.values[i], value));
84
+ patch.values.every((value, i) => {
85
+ const edit = diffText(previous.values[i], value);
86
+ return !edit || !edit.remove || !edit.insert;
87
+ });
96
88
  /** Passes one normalizer may take on one block per command: the first and 50 re-requests. */
97
89
  const MAX_PASSES = 51;
98
90
  /**
@@ -221,9 +213,10 @@ export class Dispatcher {
221
213
  * writable, and decided when it requires hydration. Every command asks
222
214
  * it first; a plugin's own write outside a command asks it too.
223
215
  */
224
- permits = () => !this.edytor.readonly &&
225
- this.edytor.document.writable &&
226
- (this.edytor.document.ready || !this.edytor.document.requireHydration);
216
+ permits = () => {
217
+ const { readonly, document } = this.edytor;
218
+ return !readonly && document.writable && (document.ready || !document.requireHydration);
219
+ };
227
220
  /**
228
221
  * Apply the undo policy's cut before a `kind` command writes.
229
222
  * @internal
@@ -292,7 +285,7 @@ export class Dispatcher {
292
285
  this.kind = null;
293
286
  }
294
287
  });
295
- if (this.applied && this.last?.status !== 'applied' && this.last?.status !== 'failed')
288
+ if (this.applied && this.#last?.status !== 'applied' && this.#last?.status !== 'failed')
296
289
  this.#last = this.applied;
297
290
  this.applied = null;
298
291
  const after = () => this.cut(kind, 'after');
@@ -317,15 +310,15 @@ export class Dispatcher {
317
310
  dispatch = (operation, payload, context, body, prepare) => {
318
311
  if (this.active)
319
312
  return body(payload);
313
+ const { edytor } = this;
314
+ const { facade, plugins } = edytor;
320
315
  const lead = this.leading;
321
316
  this.leading = null;
322
317
  // A led operation, or one `alone` runs: its own step, never an insertion's.
323
318
  const led = !!lead || this.#alone;
324
319
  this.#alone = false;
325
- if (!this.permits()) {
326
- this.#refused(operation);
327
- return undefined;
328
- }
320
+ if (!this.permits())
321
+ return void this.#refused(operation);
329
322
  const original = payload;
330
323
  const replaced = new Set();
331
324
  // A lead composes with the command's plan (both prepared at this version);
@@ -334,21 +327,22 @@ export class Dispatcher {
334
327
  const own = prepare?.(p);
335
328
  if (!lead || !own || !('writes' in own))
336
329
  return own;
337
- const { displayLength, compose } = this.edytor.facade;
330
+ const { displayLength, compose } = facade;
338
331
  const whole = own.writes.flatMap((w) => w.op === 'deleteText' && w.offset === 0 && w.length === displayLength(w.id) ? [w.id] : []);
339
332
  const writes = lead.writes.filter((w) => !('id' in w && w.id && whole.includes(w.id)));
340
333
  return compose({ ...lead, writes }, own);
341
334
  };
342
335
  let plan = prepared(payload);
336
+ let planned;
343
337
  hooks: for (;;) {
344
338
  // A command refused at preparation is still shown (without steps): an
345
339
  // extension may replace or retarget it.
346
- const planned = plan && 'writes' in plan ? plan : undefined;
340
+ planned = plan && 'writes' in plan ? plan : undefined;
347
341
  const command = { operation, payload, ...context, effect: planned?.effect };
348
342
  const steps = [...(lead && !plan ? lead.writes : []), ...(planned?.writes ?? [])];
349
343
  const changes = [command, ...this.steps(steps, planned?.effect.creates ?? [], command)];
350
344
  for (const change of changes) {
351
- for (const plugin of this.edytor.plugins) {
345
+ for (const plugin of plugins) {
352
346
  let out;
353
347
  try {
354
348
  out = vetoable((prevent) => plugin.onBeforeOperation?.({ ...change, prevent }));
@@ -384,33 +378,33 @@ export class Dispatcher {
384
378
  break;
385
379
  }
386
380
  // Refused at preparation, and no extension replaced it.
387
- if (plan && !('writes' in plan)) {
381
+ if (plan && !planned) {
388
382
  this.#refused(operation);
389
383
  return body(payload, plan);
390
384
  }
391
- const version = this.edytor.facade.version;
385
+ const version = facade.version;
392
386
  // A text patch typing into the strings the previous command set on the
393
387
  // same paths (a bound field) continues its step, within the capture
394
388
  // window, as typing does; another value of them is its own step.
395
389
  const patch = operation === 'patchData' ? textPatch(payload) : null;
396
- const again = continuesTyping(this.patched, patch) && this.last?.operation === operation;
390
+ const again = continuesTyping(this.patched, patch) && this.#last?.operation === operation;
397
391
  // A write outside the history continues nothing and leaves the typing's record.
398
392
  if (!this.untracked)
399
393
  this.patched = patch;
400
394
  const cut = again ? undefined : this.policy(operation, led);
401
395
  if (cut)
402
- this.edytor.undoManager?.stopCapturing();
396
+ edytor.undoManager?.stopCapturing();
403
397
  let result;
404
398
  try {
405
- result = this.edytor.transact(() => {
399
+ result = edytor.transact(() => {
406
400
  // What the step holds, recorded as the transaction ends: only an
407
401
  // insertion's step may be continued (`history.continues`).
408
- this.edytor.history.wrote(inserts(this.kind ?? operation, led));
402
+ edytor.history.wrote(inserts(this.kind ?? operation, led));
409
403
  this.active = true;
410
404
  try {
411
405
  // An unplanned command runs after its lead, reading the state it leaves.
412
406
  if (lead && !plan)
413
- void (this.writer?.(lead) ?? this.edytor.facade.apply(lead));
407
+ void (this.writer?.(lead) ?? facade.apply(lead));
414
408
  return body(payload, plan);
415
409
  }
416
410
  finally {
@@ -423,17 +417,21 @@ export class Dispatcher {
423
417
  throw error;
424
418
  }
425
419
  if (cut === 'both')
426
- this.edytor.undoManager?.stopCapturing();
420
+ edytor.undoManager?.stopCapturing();
427
421
  const own = {
428
422
  operation,
429
- status: this.edytor.facade.version === version ? 'noop' : 'applied'
423
+ status: facade.version === version ? 'noop' : 'applied'
430
424
  };
431
425
  this.#last = own;
432
426
  if (this.running && own.status === 'applied')
433
427
  this.applied = own;
434
- const effect = plan && 'writes' in plan ? plan.effect : undefined;
435
- const change = { operation, payload: original, ...context, effect };
436
- for (const plugin of this.edytor.plugins)
428
+ const change = {
429
+ operation,
430
+ payload: original,
431
+ ...context,
432
+ effect: planned?.effect
433
+ };
434
+ for (const plugin of plugins)
437
435
  plugin.onAfterOperation?.(change);
438
436
  return result;
439
437
  };
@@ -452,10 +450,11 @@ export class Dispatcher {
452
450
  const out = this.dispatch(operation, payload, { block: block ?? this.edytor.root, ...(text && { text }) }, (p, plan = prepare(p)) => {
453
451
  const out = this.write(plan);
454
452
  // Read once written: a plan a plugin wrote says where its caret stands now.
455
- at = 'writes' in plan ? plan.at : undefined;
453
+ at = plan.at;
456
454
  return out;
457
455
  }, prepare);
458
- if (at && !this.active && out && out.status !== 'refused')
456
+ // A caret is set only by a body that wrote (`out` is its result).
457
+ if (at && !this.active && out.status !== 'refused')
459
458
  this.caret(at);
460
459
  return out ?? PREVENTED;
461
460
  };
@@ -524,22 +523,22 @@ export class Dispatcher {
524
523
  #touched(w) {
525
524
  const { facade } = this.edytor;
526
525
  const parent = (id) => facade.parentOf(id);
526
+ const content = (...ids) => ids.map((id) => [id, 'normalizeContent']);
527
527
  const children = (...ids) => ids.map((id) => [id ?? 'root', 'normalizeChildren']);
528
528
  if (w.op === 'insertBlocks')
529
- return [...children(w.parent), ...w.specs.map(({ id }) => [id, 'normalizeContent'])];
529
+ return [...children(w.parent), ...content(...w.specs.map(({ id }) => id))];
530
530
  if (w.op === 'moveBlocks')
531
531
  return children(w.parent, ...w.ids.map(parent));
532
532
  if (w.op === 'deleteBlock')
533
533
  return children(parent(w.id));
534
534
  if (w.op === 'splitBlock')
535
- return [[w.id, 'normalizeContent'], ...children(w.parent, parent(w.id))];
535
+ return [...content(w.id), ...children(w.parent, parent(w.id))];
536
536
  if (w.op === 'mergeBlocks')
537
- return [[w.into, 'normalizeContent'], ...children(parent(w.from))];
537
+ return [...content(w.into), ...children(parent(w.from))];
538
538
  if (w.op === 'setBlockType')
539
- return [[w.id, 'normalizeContent'], ...children(w.id)];
540
- if (w.op === 'patchData')
541
- return w.id === undefined ? [] : [[w.id, 'normalizeContent']];
542
- return [[w.id, 'normalizeContent']];
539
+ return [...content(w.id), ...children(w.id)];
540
+ // A text step's block; a data patch's, none for the document's data.
541
+ return w.id === undefined ? [] : content(w.id);
543
542
  }
544
543
  /**
545
544
  * Run `body` with `lead` (a plan prepared now) composed into the first
@@ -654,9 +653,8 @@ export class Dispatcher {
654
653
  const block = edytor.idToBlock.get(id);
655
654
  if (!block)
656
655
  continue;
657
- const counts = passes.get(normalize) ?? passes.set(normalize, new Map()).get(normalize);
658
- const count = (counts.get(id) ?? 0) + 1;
659
- counts.set(id, count);
656
+ const count = (passes.get(normalize + id) ?? 0) + 1;
657
+ passes.set(normalize + id, count);
660
658
  if (count > MAX_PASSES) {
661
659
  if (DEV && count === MAX_PASSES + 1)
662
660
  console.warn(`edytor: ${normalize} on block "${id}" exceeded ${MAX_PASSES - 1} passes — a plugin normalizer is not converging; skipping further passes`);
@@ -32,12 +32,12 @@ export type Kind = 'block' | 'text' | 'mark' | 'atom' | 'anchor' | 'preview';
32
32
  * moves the caret by its paragraph's direction, never an inline run's.
33
33
  */
34
34
  export declare const blockDir: (text: string) => "rtl" | "ltr" | null;
35
- /** The text a cell's runs show (its atoms left out): what `blockDir` reads. */
36
- export declare const ownText: (runs: readonly ContentRun[]) => string;
35
+ /** The text a cell's runs show, each atom as `atom` (left out: what `blockDir` reads). */
36
+ export declare const ownText: (runs: readonly ContentRun[], atom?: string) => string;
37
37
  /** A text inside a void block is its own editing host (the core sets it at attach). */
38
38
  export declare const insideVoid: (edytor: Edytor, block: string) => boolean;
39
39
  /** What the table owns on `element` (the root, or a registered element). */
40
- export declare const ownedOf: (edytor: Edytor, element: Element, entry: {
40
+ export declare const ownedOf: (edytor: Edytor, element: Element, entry?: {
41
41
  kind: Kind;
42
42
  block: string;
43
43
  name?: string;