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
@@ -38,7 +38,8 @@ import { attachDocument, SemanticConflictError } from './crdt/index.js';
38
38
  import { whenDocumentReady } from './collaboration/documentSync.js';
39
39
  import { mintPresenceKey, PresenceWriter } from './collaboration/awarenessSelection.js';
40
40
  import { canMove, move } from './session/moves.js';
41
- import { DOCUMENT_WRITES } from './crdt/operations.js';
41
+ import { SUGGEST_ROOT } from './crdt/schema.js';
42
+ import { asEngineDoc } from './crdt/structs.js';
42
43
  import { on } from 'svelte/events';
43
44
  import { Keymap } from './session/keymap.js';
44
45
  import { TRANSACTION } from './constants.js';
@@ -46,7 +47,7 @@ import { Dispatcher } from './session/commands.js';
46
47
  import { History } from './session/history.js';
47
48
  import { DataWrites } from './session/props.js';
48
49
  import { kindCatalogue, kindCommand, UNKNOWN_KIND } from './kinds.js';
49
- import { clearDomSelection, getActiveElement, getDomSelection, getDomSelectionSnapshot } from './selection/domSelection.js';
50
+ import { clearDomSelection, focusIn, getActiveElement, getDomSelection, getDomSelectionSnapshot } from './selection/domSelection.js';
50
51
  /** A consumer that is set (a prop or a plugin hook left out is `undefined`). */
51
52
  const isListener = (listener) => listener !== undefined;
52
53
  /**
@@ -87,10 +88,6 @@ export class Edytor {
87
88
  /** The view's handles: one id-only `Block` per live id, texts and atoms by position and id. */
88
89
  idToBlock = new Handles(this);
89
90
  /** @internal */
90
- nodeToInlineBlock = new Map();
91
- /** @internal */
92
- nodeToText = new Map();
93
- /** @internal */
94
91
  transaction = new TRANSACTION();
95
92
  /** @internal The view's keymap: consumer bindings, then the extensions', then the built-in rows. */
96
93
  keymap;
@@ -113,8 +110,8 @@ export class Edytor {
113
110
  surface: {
114
111
  park: (text, offset) => this.projector.park(text, offset),
115
112
  flush: () => void this.surface.flush(),
116
- placed: () => this.projector.placed(),
117
- parked: () => this.projector.parked(),
113
+ placed: this.projector.placed,
114
+ parked: this.projector.parked,
118
115
  clear: () => clearDomSelection(this.node),
119
116
  pointAt: (node, offset) => textPointAt.call(this.selection, node, offset),
120
117
  sameLine: (a, b) => onOneLine(this.selection.textAt(a), this.selection.textAt(b))
@@ -211,7 +208,7 @@ export class Edytor {
211
208
  */
212
209
  get facade() {
213
210
  return (this.lens ??= virtualLens(this.document.facade, () => this.document.ready, () => this.document.defaultChild(null), (kind) => ({
214
- void: this.definitionOf(kind).void === true,
211
+ childless: this.definitionOf(kind).void === true,
215
212
  rendersContent: this.document.rendersContent(kind)
216
213
  })));
217
214
  }
@@ -298,10 +295,6 @@ export class Edytor {
298
295
  };
299
296
  constructor(options) {
300
297
  const { readonly, hotkeys, plugins, document, actor, requireHydration, presence, sync, value, onSelectionChange, labels, onChange, onDocChange, allowNesting } = options;
301
- for (const name of DOCUMENT_WRITES)
302
- Object.defineProperty(this, name, {
303
- value: (...args) => this.dispatcher.operate(name, args)
304
- });
305
298
  if (labels)
306
299
  this.labels = labels;
307
300
  this.allowNesting = allowNesting === true;
@@ -315,6 +308,11 @@ export class Edytor {
315
308
  this.document = attachDocument(new Y.Doc(), { actor, requireHydration });
316
309
  this.#owned = true;
317
310
  }
311
+ // The writes (`DocumentWrites`): every op the document prepares, dispatched.
312
+ for (const name in this.document.facade.prepare)
313
+ Object.defineProperty(this, name, {
314
+ value: (...args) => this.dispatcher.operate(name, args)
315
+ });
318
316
  this.readonly = readonly || false;
319
317
  this.onChange = onChange;
320
318
  this.onDocChange = onDocChange;
@@ -489,6 +487,18 @@ export class Edytor {
489
487
  this.cells = createCells(this.facade, this.surface.patched);
490
488
  this.root = this.idToBlock.root;
491
489
  this.#offCommit = this.facade.onChange(this.#onCommit);
490
+ // Without its plugin, a view reads a document's shared suggestions as plain content.
491
+ if (DEV && !this.suggestions.shared) {
492
+ const warn = () => {
493
+ if (asEngineDoc(this.document.doc).get(SUGGEST_ROOT).attrKeys().next().done)
494
+ return;
495
+ console.warn('[edytor] this document holds shared suggestions: without createSharedSuggestionsPlugin() the view shows their text as plain content and exports it');
496
+ };
497
+ if (this.document.ready)
498
+ warn();
499
+ else
500
+ this.document.onReady(warn);
501
+ }
492
502
  // A document that is already empty shows its virtual paragraph with the caret in it.
493
503
  this.selection?.restoreDeadSelectionEndpoints();
494
504
  };
@@ -533,11 +543,11 @@ export class Edytor {
533
543
  if (values.length > 0)
534
544
  callEach('[edytor] onChange', values, this.value);
535
545
  };
536
- /** The text each range `replace` suggestion removes when accepted (`Ranges`). */
546
+ /** The text each range `replace` suggestion removes when accepted (`Ranges`); a shared one's are its runs. */
537
547
  #replaced = () => {
538
548
  void this.suggestions.revision;
539
- return this.suggestions.list.flatMap(({ at }) => {
540
- if (!('replace' in at) || Array.isArray(at.replace))
549
+ return this.suggestions.list.flatMap(({ at, shared }) => {
550
+ if (shared || !('replace' in at) || Array.isArray(at.replace))
541
551
  return [];
542
552
  const { anchor, focus } = at.replace;
543
553
  const [start, end] = [anchor, focus].map(this.selection.resolveTextAnchor);
@@ -580,15 +590,14 @@ export class Edytor {
580
590
  * affordances long after the gesture ended.
581
591
  */
582
592
  #userInputHandlingDepth = 0;
583
- #withUserInput = (handler, opts = {}) => {
593
+ #withUserInput = (handler, bumpSerial = true) => {
584
594
  return (...args) => {
585
595
  // Depth-counted: a synchronous wrapped listener landing inside an
586
596
  // awaited handler's hold window must not end the window the outer
587
597
  // handler still needs.
588
598
  this.#userInputHandlingDepth++;
589
- if (opts.bumpSerial !== false) {
599
+ if (bumpSerial)
590
600
  this.markUserGesture();
591
- }
592
601
  const clear = () => {
593
602
  // A display the window held back gets its pass now.
594
603
  if (--this.#userInputHandlingDepth === 0)
@@ -637,12 +646,13 @@ export class Edytor {
637
646
  * fires (element already focused).
638
647
  */
639
648
  #expectInternalFocusArmed = false;
640
- /** @internal */
641
- expectInternalFocus = () => {
649
+ /** @internal Armed, and `element` (the host, an editing host in it) focused without scrolling. */
650
+ expectInternalFocus = (element) => {
642
651
  this.#expectInternalFocusArmed = true;
643
652
  queueMicrotask(() => {
644
653
  this.#expectInternalFocusArmed = false;
645
654
  });
655
+ element?.focus({ preventScroll: true });
646
656
  };
647
657
  /** @internal A `focusin` consumes the armed flag: `true` when the editor focused itself. */
648
658
  consumeInternalFocus = () => {
@@ -670,7 +680,7 @@ export class Edytor {
670
680
  */
671
681
  lastUserGestureOutsideEditor = false;
672
682
  /** @internal Run `body` as part of the user's input (deferred work of an occurrence). */
673
- userInput = (body) => this.#withUserInput(body, { bumpSerial: false })();
683
+ userInput = (body) => this.#withUserInput(body, false)();
674
684
  /**
675
685
  * A commit's caret: select it (the projector displays it once the pin's
676
686
  * render lands) and arm the IME post-commit jump rule — a move right after
@@ -681,13 +691,12 @@ export class Edytor {
681
691
  if (this.readonly || this.isComposing || !text.isInDocument)
682
692
  return;
683
693
  const active = getActiveElement(this.node);
684
- const selection = getDomSelection(this.node);
685
694
  const ours = (node) => Boolean(node && this.node?.contains(node));
686
695
  // Focus left for a foreign element with no selection of ours, or moved
687
696
  // into a nested editable island (its own caret): the user owns it.
688
697
  if ((active &&
689
- !ours(active) &&
690
- !ours(selection?.anchorNode) &&
698
+ !focusIn(this.node) &&
699
+ !ours(getDomSelection(this.node)?.anchorNode) &&
691
700
  !ours(this.selection.textRange().startText?.node)) ||
692
701
  isKindControl(this.node, active))
693
702
  return;
@@ -770,21 +779,50 @@ export class Edytor {
770
779
  this.lastUserGestureOutsideEditor = false;
771
780
  this.node = node;
772
781
  this.selection.init();
773
- this.doc.on('beforeAllTransactions', this.projector.opening);
774
- this.doc.on('beforeTransaction', this.surface.pending);
775
- this.doc.on('beforeTransaction', this.projector.before);
776
- this.doc.on('afterTransaction', this.projector.after);
782
+ // The engine's transaction events the projector and the observer read.
783
+ const engine = [
784
+ ['beforeAllTransactions', this.projector.opening],
785
+ ['beforeTransaction', this.surface.pending],
786
+ ['beforeTransaction', this.projector.before],
787
+ ['afterTransaction', this.projector.after]
788
+ ];
789
+ for (const [name, listener] of engine)
790
+ this.doc.on(name, listener);
777
791
  this.off.push(() => {
778
- this.doc.off('beforeAllTransactions', this.projector.opening);
779
- this.doc.off('beforeTransaction', this.surface.pending);
780
- this.doc.off('beforeTransaction', this.projector.before);
781
- this.doc.off('afterTransaction', this.projector.after);
792
+ for (const [name, listener] of engine)
793
+ this.doc.off(name, listener);
782
794
  });
783
795
  // Keydown serial bumps happen INSIDE onKeyDown, after the
784
796
  // composition-phantom swallow — a swallowed trailing Enter/Backspace
785
797
  // is a browser artifact, not a gesture, and must not disarm pending
786
798
  // composition caret restores.
787
- const keydown = this.#withUserInput(onKeyDown.bind(this), { bumpSerial: false });
799
+ const user = this.#withUserInput;
800
+ const keydown = user(onKeyDown.bind(this), false);
801
+ const { pointer } = this.selection;
802
+ const drop = (event) => preventUnsupportedDrop(event, this);
803
+ /** The host's listeners; the user-input ones run inside its window (`isHandlingUserInput`). */
804
+ const host = [
805
+ ['mousedown', pointer.preventTripleClick],
806
+ ['click', pointer.tripleClick],
807
+ // Settle queued mutation repairs before the native menu opens —
808
+ // spellcheck suggestions are computed against the DOM at this
809
+ // moment (PM flushes its DOM observer on contextmenu for the
810
+ // same reason).
811
+ ['contextmenu', () => void this.surface.flush()],
812
+ ['beforeinput', user(this.onBeforeInput)],
813
+ // An `input` records what the browser did; it says nothing new about
814
+ // where the user wants the selection (a display still to land wins).
815
+ ['input', user(this.onInput, false)],
816
+ ['copy', this.onCopy],
817
+ ['cut', user(this.onCut)],
818
+ ['paste', user(this.onPaste)],
819
+ ['dragstart', (event) => onTextDragStart(this, event)],
820
+ ['dragend', () => onTextDragEnd(this)],
821
+ ['dragover', drop],
822
+ ['drop', user(drop)],
823
+ ['compositionstart', this.onCompositionStart],
824
+ ['compositionend', this.onCompositionEnd]
825
+ ];
788
826
  const detachSurface = this.surface.attach(node);
789
827
  this.off.push(
790
828
  // One handler per keyboard occurrence: keys inside the editor at
@@ -797,48 +835,43 @@ export class Edytor {
797
835
  // Pointer and focus ownership, and the focus-time caret restore.
798
836
  ...attachFocus(this, node),
799
837
  // The page leaving for good ends the session's history.
800
- attachPageHide(this), on(node, 'mousedown', this.selection.pointer.preventTripleClick), on(node, 'click', this.selection.pointer.tripleClick),
801
- // Settle queued mutation repairs before the native menu opens —
802
- // spellcheck suggestions are computed against the DOM at this
803
- // moment (PM flushes its DOM observer on contextmenu for the
804
- // same reason).
805
- on(node, 'contextmenu', () => void this.surface.flush()), on(node, 'beforeinput', this.#withUserInput(this.onBeforeInput)),
806
- // An `input` records what the browser did; it says nothing new about
807
- // where the user wants the selection (a display still to land wins).
808
- on(node, 'input', this.#withUserInput(this.onInput, { bumpSerial: false })), on(node, 'copy', this.onCopy), on(node, 'cut', this.#withUserInput(this.onCut)), on(node, 'paste', this.#withUserInput(this.onPaste)), on(node, 'dragstart', (event) => onTextDragStart(this, event)), on(node, 'dragend', () => onTextDragEnd(this)), on(node, 'dragover', (event) => preventUnsupportedDrop(event, this)), on(node, 'drop', this.#withUserInput((event) => preventUnsupportedDrop(event, this))), on(node, 'compositionstart', this.onCompositionStart), on(node, 'compositionend', this.onCompositionEnd), detachSurface);
809
- // Chrome lives in the overlay, outside the host: remote carets, then the plugins'.
838
+ attachPageHide(this), ...host.map(([type, listener]) => on(node, type, listener)), detachSurface);
839
+ // Chrome lives in the overlay, outside the host: remote carets, the
840
+ // text a `replace` suggestion removes, marked beside the host, the
841
+ // polite live region (what this view's block moves and deletes did);
842
+ // the plugins' after them.
810
843
  const detachOverlay = this.overlay.attach(node);
811
- const presence = mount(RemoteSelections, {
812
- target: this.overlay.layer,
813
- props: { edytor: this }
814
- });
815
- // The text a `replace` suggestion removes, marked beside the host.
816
- const ranges = mount(Ranges, {
817
- target: this.overlay.layer,
818
- props: { edytor: this, name: 'suggestion-range', z: 19, ranges: this.#replaced }
819
- });
820
- // The polite live region: what this view's block moves and deletes did.
821
- const live = mount(Announcer, {
822
- target: this.overlay.layer,
823
- props: { announcer: this.announcer }
824
- });
825
- this.off.push(() => unmount(presence), () => unmount(ranges), () => unmount(live), detachOverlay);
844
+ const target = this.overlay.layer;
845
+ const mounted = [
846
+ [RemoteSelections, { edytor: this }],
847
+ [Ranges, { edytor: this, name: 'suggestion-range', z: 19, ranges: this.#replaced }],
848
+ [Announcer, { announcer: this.announcer }]
849
+ ].map(([component, props]) => mount(component, { target, props: props }));
850
+ this.off.push(() => mounted.forEach((instance) => void unmount(instance)), detachOverlay);
826
851
  this.off.push(...cleanups(this.plugins, (plugin) => plugin.onEdytorAttached?.({ node })));
827
852
  return {
828
853
  destroy: () => {
829
854
  if (selectionIsInside(node))
830
855
  clearDomSelection(node);
831
- const active = getActiveElement(node);
832
- if (active instanceof HTMLElement && node.contains(active))
856
+ const active = focusIn(node);
857
+ if (active instanceof HTMLElement)
833
858
  active.blur();
834
- this.selection.destroy();
835
- this.attempts.clear();
836
- this.composition.reset();
837
- // Drain AND clear: `destroy()` runs the same batch again.
838
- this.off.splice(0).forEach((off) => off());
859
+ this.#detach();
839
860
  }
840
861
  };
841
862
  };
863
+ /**
864
+ * Release what an attach holds: the selection's listeners, the attempts,
865
+ * the composition, then the attach-lifetime batch (DOM listeners, the
866
+ * mutation observer, plugin actions), drained AND cleared (`destroy()`
867
+ * runs it again).
868
+ */
869
+ #detach = () => {
870
+ this.selection.destroy();
871
+ this.attempts.clear();
872
+ this.composition.reset();
873
+ this.off.splice(0).forEach((off) => off());
874
+ };
842
875
  /** @internal */
843
876
  destroyed = false;
844
877
  /** @internal The key of this view's presence entry — minted here, written only by this view. */
@@ -869,21 +902,13 @@ export class Edytor {
869
902
  // A pending readiness binding must not resurrect a dead view.
870
903
  this.#readinessRelease?.();
871
904
  this.#readinessRelease = undefined;
872
- // Runs the whole attach-lifetime batch — DOM listeners, the mutation
873
- // observer, plugin actions — and empties the array.
874
- this.off.splice(0).forEach((off) => off());
905
+ this.#detach();
875
906
  this.#offCommit?.();
876
907
  this.history.unbind();
877
- // `selectionchange` listener + the published remote caret.
878
- this.selection.destroy();
879
- this.attempts.clear();
880
- this.composition.reset();
881
908
  this.cells?.dispose();
882
909
  // Wrapper maps — drop every id/node→wrapper edge a shared doc or a
883
910
  // stray mutation could still reach.
884
911
  this.idToBlock.clear();
885
- this.nodeToInlineBlock.clear();
886
- this.nodeToText.clear();
887
912
  this.root = undefined;
888
913
  this.node = undefined;
889
914
  // Release this view's local-edit origin from the document's
@@ -9,6 +9,6 @@ import { type Attempt } from '../session/attempt.js';
9
9
  */
10
10
  export declare const insertLineBreak: (edytor: Edytor, snapshot: Attempt, caret?: "after" | "before") => void;
11
11
  /** The model command for a `beforeinput`, run as one user command (undo policy, prevention scope). */
12
- export declare const runBeforeInputCommand: (edytor: Edytor, snapshot: Attempt) => boolean | void | Text | Promise<undefined> | null | undefined;
12
+ export declare const runBeforeInputCommand: (edytor: Edytor, snapshot: Attempt) => boolean | void | Promise<undefined> | Text | null | undefined;
13
13
  /** An editing intent at the current selection (a key binding's command): no event to fabricate. */
14
- export declare const runIntent: (edytor: Edytor, inputType: string) => boolean | void | Text | Promise<undefined> | null | undefined;
14
+ export declare const runIntent: (edytor: Edytor, inputType: string) => boolean | void | Promise<undefined> | Text | null | undefined;
@@ -2,9 +2,19 @@ export declare const isNativeInteractiveControl: (target: EventTarget | null) =>
2
2
  export declare const isNativeInteractiveEvent: (event: Event) => boolean;
3
3
  export declare const isNativeTextControl: (target: EventTarget | null) => boolean;
4
4
  export declare const isNativeTextControlEvent: (event: Event) => boolean;
5
+ /**
6
+ * Whether a press inside the host, and its whole gesture (its clicks and
7
+ * multi-clicks, its `selectstart`, its drag), is its target's own
8
+ * (`sel.press.void`): a control of a block's chrome, a link there (a
9
+ * bookmark's card, a file's row: its click opens it), or an element that
10
+ * keeps the selection (`data-edytor-keep-selection`). The one rule every
11
+ * press handler asks.
12
+ */
13
+ export declare const ownsPress: (editorRoot: Element | null | undefined, event: Event) => boolean;
5
14
  /**
6
15
  * A kind's own control: a form control (`input` of any type, `textarea`,
7
- * `select`, `button`) or a nested editable island inside the host (a plugin
16
+ * `select`, `button`), a control of a block's chrome (an interactive role,
17
+ * a focusable element, `data-edytor-control`) or a nested editable island inside the host (a plugin
8
18
  * chrome editor, an embedded code field: it owns its input and composition
9
19
  * lifecycle), never the root nor the editor's own text elements (they carry
10
20
  * their own `contentEditable="true"`). It owns its focus, selection and
@@ -32,13 +32,52 @@ export const isNativeInteractiveControl = (target) => Boolean(findClosestElement
32
32
  export const isNativeInteractiveEvent = (event) => Boolean(findNativeControlInEvent(event, NATIVE_INTERACTIVE_SELECTOR));
33
33
  export const isNativeTextControl = (target) => Boolean(findClosestElement(target, NATIVE_TEXT_SELECTOR));
34
34
  export const isNativeTextControlEvent = (event) => Boolean(findNativeControlInEvent(event, NATIVE_TEXT_SELECTOR));
35
+ /**
36
+ * The controls of a block's chrome (`sel.press.void`): a native form
37
+ * control, an element whose role says it is interactive or that takes the
38
+ * focus, and one the app marks as its own (`data-edytor-control`, for any
39
+ * other element). Their press and its whole gesture, their keys and their
40
+ * input are theirs: the editor does nothing with them.
41
+ */
42
+ const CHROME_CONTROL_SELECTOR = [
43
+ 'input, textarea, select, button',
44
+ '[role="button"], [role="link"], [role="checkbox"], [role="switch"], [role="menuitem"]',
45
+ '[role="tab"], [role="option"], [role="slider"], [role="textbox"], [role="combobox"]',
46
+ '[tabindex]:not([tabindex^="-"])',
47
+ '[data-edytor-control]'
48
+ ].join(', ');
49
+ /**
50
+ * The control of a block's chrome holding `target` inside the host, by
51
+ * `selector`: never the host, nor an element inside the editor's text or an
52
+ * inline atom (a link mark, a mention: the text's own presses).
53
+ */
54
+ const chromeControl = (editorRoot, target, selector = CHROME_CONTROL_SELECTOR) => {
55
+ const control = getElementFromTarget(target)?.closest(selector);
56
+ return control &&
57
+ editorRoot &&
58
+ control !== editorRoot &&
59
+ editorRoot.contains(control) &&
60
+ !control.closest('[data-edytor-text], [data-edytor-inline-block]')
61
+ ? control
62
+ : null;
63
+ };
64
+ /**
65
+ * Whether a press inside the host, and its whole gesture (its clicks and
66
+ * multi-clicks, its `selectstart`, its drag), is its target's own
67
+ * (`sel.press.void`): a control of a block's chrome, a link there (a
68
+ * bookmark's card, a file's row: its click opens it), or an element that
69
+ * keeps the selection (`data-edytor-keep-selection`). The one rule every
70
+ * press handler asks.
71
+ */
72
+ export const ownsPress = (editorRoot, event) => Boolean(chromeControl(editorRoot, event.target, `${CHROME_CONTROL_SELECTOR}, [data-edytor-keep-selection], a[href]`));
35
73
  // Any non-"false" contenteditable value — attribute selectors are
36
74
  // case-sensitive, and "TRUE"/"Plaintext-Only"/"inherit"/garbage values
37
75
  // all still establish a nested editable context the guard must catch.
38
76
  const EDITABLE_TARGET_SELECTOR = '[contenteditable]:not([contenteditable="false" i])';
39
77
  /**
40
78
  * A kind's own control: a form control (`input` of any type, `textarea`,
41
- * `select`, `button`) or a nested editable island inside the host (a plugin
79
+ * `select`, `button`), a control of a block's chrome (an interactive role,
80
+ * a focusable element, `data-edytor-control`) or a nested editable island inside the host (a plugin
42
81
  * chrome editor, an embedded code field: it owns its input and composition
43
82
  * lifecycle), never the root nor the editor's own text elements (they carry
44
83
  * their own `contentEditable="true"`). It owns its focus, selection and
@@ -48,6 +87,7 @@ const EDITABLE_TARGET_SELECTOR = '[contenteditable]:not([contenteditable="false"
48
87
  export const isKindControl = (editorRoot, target) => {
49
88
  const editable = findClosestElement(target, EDITABLE_TARGET_SELECTOR);
50
89
  const control = findClosestElement(target, NATIVE_ACTIVE_ELEMENT_SELECTOR) ??
90
+ chromeControl(editorRoot, target) ??
51
91
  (editable?.hasAttribute('data-edytor-text') ? null : editable);
52
92
  return Boolean(control && editorRoot && control !== editorRoot && editorRoot.contains(control));
53
93
  };
@@ -1,5 +1,5 @@
1
1
  import { vetoable } from '../utils.js';
2
- import { createDomRange, createDomSelectionSnapshotFromRange, getActiveElement, getDomSelection } from '../selection/domSelection.js';
2
+ import { createDomRange, createDomSelectionSnapshotFromRange, focusIn, getActiveElement, getDomSelection } from '../selection/domSelection.js';
3
3
  import { getYIndex } from '../selection/selection.utils.js';
4
4
  import { sameValue } from '../session/selection.js';
5
5
  import { INTENTS, attemptOf, intentOf, kindOf, reproject, targetless } from '../session/attempt.js';
@@ -87,9 +87,9 @@ const isForeignHistory = (edytor) => {
87
87
  const node = edytor.node;
88
88
  if (!node)
89
89
  return true;
90
- const active = getActiveElement(node);
91
- if (active && node.contains(active))
90
+ if (focusIn(node))
92
91
  return false;
92
+ const active = getActiveElement(node);
93
93
  const { body, documentElement } = node.ownerDocument;
94
94
  const anchor = getDomSelection(node)?.anchorNode;
95
95
  return ((!!active &&
@@ -6,7 +6,7 @@
6
6
  * programmatic focus returns to. Answers the listeners' teardown.
7
7
  */
8
8
  import { on } from 'svelte/events';
9
- import { clearDomSelection, getActiveElement, getDomSelection, getDomSelectionSnapshot } from '../selection/domSelection.js';
9
+ import { clearDomSelection, focusIn, getActiveElement, getDomSelection, getDomSelectionSnapshot } from '../selection/domSelection.js';
10
10
  import { getYIndex } from '../selection/selection.utils.js';
11
11
  import { noSelection } from '../session/selection.js';
12
12
  import { observeInternalDragSources } from './onDrop.js';
@@ -38,8 +38,7 @@ export const takeKeys = (edytor) => {
38
38
  return;
39
39
  if (getActiveElement(node) === node)
40
40
  return;
41
- edytor.expectInternalFocus();
42
- node.focus({ preventScroll: true });
41
+ edytor.expectInternalFocus(node);
43
42
  edytor.projector.parked();
44
43
  edytor.selection.display();
45
44
  };
@@ -125,6 +124,9 @@ export const attachFocus = (edytor, node) => {
125
124
  */
126
125
  const placingPress = (event) => {
127
126
  const { pointer } = edytor.selection;
127
+ // A control of a block's chrome owns its whole press (`sel.press.void`).
128
+ if (pointer.ownPress(event))
129
+ return void (pointer.claimed = false);
128
130
  if (pointer.marginPress(event))
129
131
  return;
130
132
  if (!pointer.belowPress(event))
@@ -136,8 +138,9 @@ export const attachFocus = (edytor, node) => {
136
138
  edytor.composition.abandon();
137
139
  lastPointerDownInsideEditorAt = event.timeStamp;
138
140
  const { pointer } = edytor.selection;
139
- // The margin gesture's press places no caret and starts no text drag.
140
- if (pointer.claimed)
141
+ // The margin gesture's press places no caret and starts no text drag; a
142
+ // control's press is the control's (`sel.press.void`).
143
+ if (pointer.claimed || pointer.ownPress(event))
141
144
  return;
142
145
  edytor.projector.pressed();
143
146
  pointer.capture(event);
@@ -145,13 +148,8 @@ export const attachFocus = (edytor, node) => {
145
148
  pointer.collapseBlocksAt(event);
146
149
  };
147
150
  const clearNativeSelectionAfterExternalFocus = () => {
148
- const activeElement = getActiveElement(node);
149
- if (activeElement instanceof Node && node.contains(activeElement)) {
150
- return;
151
- }
152
- if (selectionIsInside(node)) {
151
+ if (!focusIn(node) && selectionIsInside(node))
153
152
  clearDomSelection(node);
154
- }
155
153
  };
156
154
  /**
157
155
  * A keyboard or programmatic focus returns to the model's selection: the