edytor 4.0.0 → 6.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 (193) 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 +106 -109
  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/funnel.d.ts +1 -1
  29. package/dist/crdt/doc/funnel.js +5 -2
  30. package/dist/crdt/doc/gate.d.ts +1 -1
  31. package/dist/crdt/doc/history.d.ts +6 -0
  32. package/dist/crdt/doc/history.js +79 -59
  33. package/dist/crdt/doc/ops.d.ts +81 -0
  34. package/dist/crdt/doc/ops.js +1898 -0
  35. package/dist/crdt/doc/reads.d.ts +2 -2
  36. package/dist/crdt/doc/reads.js +18 -28
  37. package/dist/crdt/doc/suggest.d.ts +167 -0
  38. package/dist/crdt/doc/suggest.js +579 -0
  39. package/dist/crdt/document.d.ts +10 -1
  40. package/dist/crdt/document.js +19 -11
  41. package/dist/crdt/edytor-doc.d.ts +294 -272
  42. package/dist/crdt/edytor-doc.js +123 -108
  43. package/dist/crdt/engine-api.d.ts +8 -2
  44. package/dist/crdt/engine.d.ts +2 -1
  45. package/dist/crdt/engine.js +3 -1
  46. package/dist/crdt/flow.d.ts +5 -47
  47. package/dist/crdt/flow.js +3 -269
  48. package/dist/crdt/index.d.ts +102 -108
  49. package/dist/crdt/index.js +3 -0
  50. package/dist/crdt/operations.d.ts +13 -4
  51. package/dist/crdt/parts.d.ts +43 -0
  52. package/dist/crdt/parts.js +44 -0
  53. package/dist/crdt/placement/display.d.ts +1 -1
  54. package/dist/crdt/placement/display.js +3 -3
  55. package/dist/crdt/placement/keys.d.ts +5 -0
  56. package/dist/crdt/placement/keys.js +5 -0
  57. package/dist/crdt/placement/resolve.d.ts +1 -1
  58. package/dist/crdt/placement/resolve.js +2 -1
  59. package/dist/crdt/protocol.d.ts +1 -1
  60. package/dist/crdt/protocol.js +1 -1
  61. package/dist/crdt/protocols/auth.d.ts +11 -1
  62. package/dist/crdt/protocols/auth.js +17 -1
  63. package/dist/crdt/protocols/awareness.js +46 -81
  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 +12 -22
  67. package/dist/crdt/providers/index.js +3 -1
  68. package/dist/crdt/providers/indexeddb.d.ts +6 -10
  69. package/dist/crdt/providers/indexeddb.js +10 -16
  70. package/dist/crdt/providers/room.d.ts +11 -0
  71. package/dist/crdt/providers/room.js +26 -12
  72. package/dist/crdt/providers/websocket.d.ts +8 -12
  73. package/dist/crdt/providers/websocket.js +17 -19
  74. package/dist/crdt/purge.d.ts +7 -1
  75. package/dist/crdt/purge.js +32 -2
  76. package/dist/crdt/rangeDelete.d.ts +2 -47
  77. package/dist/crdt/rangeDelete.js +1 -169
  78. package/dist/crdt/restore.d.ts +2 -1
  79. package/dist/crdt/restore.js +9 -0
  80. package/dist/crdt/schema.d.ts +9 -1
  81. package/dist/crdt/schema.js +13 -3
  82. package/dist/crdt/structs.d.ts +2 -0
  83. package/dist/crdt/structs.js +2 -0
  84. package/dist/crdt/text/index/build.d.ts +125 -0
  85. package/dist/crdt/text/index/build.js +2895 -0
  86. package/dist/crdt/text/index/shared.d.ts +2 -6
  87. package/dist/crdt/text/index/shared.js +19 -19
  88. package/dist/crdt/text/model.d.ts +10 -0
  89. package/dist/crdt/text/model.js +10 -4
  90. package/dist/crdt/text/runs.d.ts +43 -42
  91. package/dist/crdt/text/runs.js +2 -165
  92. package/dist/crdt/vendor/yjs/UPSTREAM.md +83 -0
  93. package/dist/crdt/vendor/yjs/dts/index.d.ts +1 -1
  94. package/dist/crdt/vendor/yjs/dts/structs/Item.d.ts +6 -3
  95. package/dist/crdt/vendor/yjs/dts/ynode.d.ts +13 -2
  96. package/dist/crdt/vendor/yjs/src/index.js +1 -1
  97. package/dist/crdt/vendor/yjs/src/structs/Item.js +44 -5
  98. package/dist/crdt/vendor/yjs/src/ynode.js +29 -7
  99. package/dist/edytor.svelte.d.ts +1 -1
  100. package/dist/edytor.svelte.js +100 -75
  101. package/dist/events/beforeInputCommands.d.ts +2 -2
  102. package/dist/events/domTextOffset.d.ts +2 -0
  103. package/dist/events/domTextOffset.js +8 -0
  104. package/dist/events/nativeInteractiveControl.d.ts +11 -1
  105. package/dist/events/nativeInteractiveControl.js +41 -1
  106. package/dist/events/onBeforeInput.js +3 -3
  107. package/dist/events/onFocus.js +9 -11
  108. package/dist/export/forms.d.ts +75 -75
  109. package/dist/index.d.ts +4 -2
  110. package/dist/index.js +1 -1
  111. package/dist/labels.d.ts +8 -0
  112. package/dist/labels.js +6 -1
  113. package/dist/plugins/comments/CommentsController.svelte.d.ts +5 -1
  114. package/dist/plugins/comments/CommentsController.svelte.js +14 -4
  115. package/dist/plugins/find/search.js +2 -2
  116. package/dist/plugins/index.d.ts +1 -0
  117. package/dist/plugins/index.js +1 -0
  118. package/dist/plugins/richtext/RichTextPlugin.svelte +63 -61
  119. package/dist/plugins/richtext/richTextOperations.js +26 -32
  120. package/dist/plugins/suggestions/SuggestionBars.svelte +40 -23
  121. package/dist/plugins/suggestions/SuggestionBars.svelte.d.ts +3 -1
  122. package/dist/plugins/suggestions/SuggestionRuns.svelte +77 -0
  123. package/dist/plugins/suggestions/SuggestionRuns.svelte.d.ts +9 -0
  124. package/dist/plugins/suggestions/shared.svelte.d.ts +58 -0
  125. package/dist/plugins/suggestions/shared.svelte.js +421 -0
  126. package/dist/plugins/suggestions/suggestionsPlugin.d.ts +15 -4
  127. package/dist/selection/domSelection.d.ts +2 -0
  128. package/dist/selection/domSelection.js +6 -2
  129. package/dist/selection/pointer.svelte.js +75 -48
  130. package/dist/selection/replaceSelection.js +2 -6
  131. package/dist/selection/selection.svelte.d.ts +3 -3
  132. package/dist/selection/selection.svelte.js +39 -32
  133. package/dist/selection/selection.utils.d.ts +14 -0
  134. package/dist/selection/selection.utils.js +33 -11
  135. package/dist/session/commands.js +21 -11
  136. package/dist/session/composition.svelte.d.ts +5 -3
  137. package/dist/session/composition.svelte.js +21 -17
  138. package/dist/session/handles.d.ts +4 -17
  139. package/dist/session/handles.js +14 -44
  140. package/dist/session/navigation.js +6 -9
  141. package/dist/session/selection.d.ts +11 -5
  142. package/dist/session/selection.js +14 -48
  143. package/dist/session/suggestions.svelte.d.ts +38 -9
  144. package/dist/session/suggestions.svelte.js +37 -18
  145. package/dist/session/virtual.d.ts +1 -1
  146. package/dist/surface/attributes.d.ts +5 -0
  147. package/dist/surface/attributes.js +2 -2
  148. package/dist/surface/cells.d.ts +2 -26
  149. package/dist/surface/cells.js +2 -33
  150. package/dist/surface/observer.svelte.d.ts +6 -1
  151. package/dist/surface/observer.svelte.js +143 -111
  152. package/dist/surface/pin.svelte.d.ts +9 -9
  153. package/dist/surface/pin.svelte.js +15 -22
  154. package/dist/surface/projector.svelte.js +10 -16
  155. package/dist/text/text.svelte.js +14 -19
  156. package/dist/themes/notion.css +29 -0
  157. package/dist/utils.d.ts +5 -0
  158. package/dist/utils.js +7 -0
  159. package/package.json +3 -1
  160. package/dist/crdt/doc/capability.d.ts +0 -56
  161. package/dist/crdt/doc/capability.js +0 -211
  162. package/dist/crdt/doc/content.d.ts +0 -16
  163. package/dist/crdt/doc/content.js +0 -77
  164. package/dist/crdt/doc/delete.d.ts +0 -15
  165. package/dist/crdt/doc/delete.js +0 -84
  166. package/dist/crdt/doc/layout.d.ts +0 -9
  167. package/dist/crdt/doc/layout.js +0 -178
  168. package/dist/crdt/doc/meta.d.ts +0 -19
  169. package/dist/crdt/doc/meta.js +0 -199
  170. package/dist/crdt/doc/moves.d.ts +0 -21
  171. package/dist/crdt/doc/moves.js +0 -274
  172. package/dist/crdt/doc/split-merge.d.ts +0 -10
  173. package/dist/crdt/doc/split-merge.js +0 -179
  174. package/dist/crdt/doc/steps.d.ts +0 -65
  175. package/dist/crdt/doc/steps.js +0 -311
  176. package/dist/crdt/text/index/cache.d.ts +0 -16
  177. package/dist/crdt/text/index/cache.js +0 -175
  178. package/dist/crdt/text/index/claims.d.ts +0 -18
  179. package/dist/crdt/text/index/claims.js +0 -225
  180. package/dist/crdt/text/index/fold.d.ts +0 -21
  181. package/dist/crdt/text/index/fold.js +0 -656
  182. package/dist/crdt/text/index/layout.d.ts +0 -24
  183. package/dist/crdt/text/index/layout.js +0 -271
  184. package/dist/crdt/text/index/placement.d.ts +0 -47
  185. package/dist/crdt/text/index/placement.js +0 -741
  186. package/dist/crdt/text/index/records.d.ts +0 -13
  187. package/dist/crdt/text/index/records.js +0 -139
  188. package/dist/crdt/text/index/report.d.ts +0 -18
  189. package/dist/crdt/text/index/report.js +0 -337
  190. package/dist/crdt/text/index/state.d.ts +0 -197
  191. package/dist/crdt/text/index/state.js +0 -116
  192. package/dist/crdt/text/index/streams.d.ts +0 -25
  193. package/dist/crdt/text/index/streams.js +0 -248
@@ -12,6 +12,7 @@ import {
12
12
  ContentType,
13
13
  ContentHold, // YP20
14
14
  ContentMove, // YP22
15
+ hideIds, // YP32
15
16
  readFormatValue, // YP25
16
17
  YXmlFragmentRefID,
17
18
  YXmlElementRefID,
@@ -327,19 +328,24 @@ const holdHelper = (transaction, list, ids) => {
327
328
 
328
329
  /**
329
330
  * With `held`, the live units are not deleted: their ids go to `held`
330
- * (`client, clock, len`), except those `fresh` names (YP20).
331
+ * (`client, clock, len`), except those `fresh` names (YP20). YP32: with
332
+ * `sort` (a suggestion's delete), `sort`'s `1` goes to `held`, `2` to
333
+ * `proposed` and `hidden`, `3` to `hidden`.
331
334
  *
332
335
  * @param {Transaction} transaction
333
336
  * @param {ItemTextListPosition} currPos
334
337
  * @param {number} length
335
338
  * @param {Array<number>|null} [held]
336
339
  * @param {import('./utils/ids.js').IdSet|null} [fresh]
340
+ * @param {((client: number, clock: number) => number)|null} [sort] YP32: a suggestion's delete sorts each unit
341
+ * @param {Array<number>|null} [proposed] YP32: the units it proposes
342
+ * @param {Array<number>|null} [hidden] YP32: the units it hides until `unhide`
337
343
  * @return {ItemTextListPosition}
338
344
  *
339
345
  * @private
340
346
  * @function
341
347
  */
342
- export const deleteText = (transaction, currPos, length, held = null, fresh = null) => {
348
+ export const deleteText = (transaction, currPos, length, held = null, fresh = null, sort = null, proposed = null, hidden = null) => {
343
349
  const startLength = length
344
350
  let kept = 0 // YP20
345
351
  while (length > 0 && currPos.right !== null) {
@@ -347,15 +353,19 @@ export const deleteText = (transaction, currPos, length, held = null, fresh = nu
347
353
  if (!item.deleted && item.countable) {
348
354
  const { client, clock } = item.id
349
355
  // YP20: a held run ends where the open step's inserts start or end
350
- const n = fresh === null ? math.min(length, item.length) : runEnd(fresh, client, clock, clock + math.min(length, item.length)) - clock
356
+ let n = fresh === null ? math.min(length, item.length) : runEnd(fresh, client, clock, clock + math.min(length, item.length)) - clock
357
+ // YP32: a suggestion's delete sorts each unit — held (`1`), proposed (`2`) or stepped over (`3`)
358
+ const kind = held === null || (fresh !== null && fresh.has(client, clock)) ? 0 : sort === null ? 1 : sort(client, clock)
359
+ if (kind !== 0 && sort !== null) for (let k = 1; k < n; k++) if (sort(client, clock + k) !== kind) n = k
351
360
  if (n < item.length) {
352
361
  getItemCleanStart(transaction, createID(client, clock + n))
353
362
  }
354
363
  length -= item.length
355
- if (held === null || (fresh !== null && fresh.has(client, clock))) {
364
+ if (kind === 0) {
356
365
  item.delete(transaction)
357
366
  } else {
358
- held.push(client, clock, item.length)
367
+ if (kind === 2) /** @type {Array<number>} */ (proposed).push(client, clock, item.length)
368
+ ;/** @type {Array<number>} */ (kind === 1 ? held : hidden).push(client, clock, item.length)
359
369
  kept += item.length
360
370
  }
361
371
  }
@@ -993,12 +1003,20 @@ export class YNode {
993
1003
  * which an undo of the record takes back), except those `fresh` names (the
994
1004
  * open undo step's inserts: removed, so that step's undo changes nothing).
995
1005
  *
1006
+ * YP32: with `propose` (a suggestion's delete) each unit is sorted: held
1007
+ * by the record in `into` (`1`: its author's own), proposed (`2`: an id
1008
+ * list appended to `propose.into`, a suggestion's record, holding nothing)
1009
+ * or stepped over (`3`: already proposed); proposed and stepped-over units
1010
+ * read gone until `unhide`, so the rest of the plan reads the offsets it
1011
+ * was prepared with.
1012
+ *
996
1013
  * @param {number} index Index at which to start deleting elements
997
1014
  * @param {number} length The number of elements to remove. Defaults to 1.
998
1015
  * @param {YNode|null} [into]
999
1016
  * @param {import('./utils/ids.js').IdSet|null} [fresh]
1017
+ * @param {{ into: YNode, sort: (client: number, clock: number) => number }|null} [propose]
1000
1018
  */
1001
- delete (index, length = 1, into = null, fresh = null) {
1019
+ delete (index, length = 1, into = null, fresh = null, propose = null) {
1002
1020
  if (!(length > 0)) return
1003
1021
  if (this.doc == null) {
1004
1022
  const runs = prelimOf(this).runs
@@ -1015,13 +1033,17 @@ export class YNode {
1015
1033
  transact(this.doc, transaction => {
1016
1034
  const currPos = cursorAt(transaction, this, index)
1017
1035
  const held = into === null ? null : [] // YP20
1018
- deleteText(transaction, currPos, length, held, fresh)
1036
+ const [proposed, hidden] = [[], []] // YP32
1037
+ deleteText(transaction, currPos, length, held, fresh, propose && propose.sort, proposed, hidden)
1019
1038
  if (held !== null && held.length > 0) {
1020
1039
  // YP20: `deleteText` moved the markers as for a delete; the hold keeps them
1021
1040
  const ms = this._searchMarker === null ? null : this._searchMarker.slice()
1022
1041
  holdHelper(transaction, /** @type {YNode} */ (into), held)
1023
1042
  if (ms !== null && this._searchMarker !== null) this._searchMarker.splice(0, this._searchMarker.length, ...ms)
1024
1043
  }
1044
+ // YP32: the proposal, then what it hides until `unhide`
1045
+ if (proposed.length > 0) holdHelper(transaction, /** @type {any} */ (propose).into, proposed)
1046
+ if (hidden.length > 0) hideIds(transaction, hidden)
1025
1047
  plantMarker(this, currPos)
1026
1048
  })
1027
1049
  }
@@ -11,7 +11,7 @@ import { Handles } from './session/handles.js';
11
11
  import { type Awareness, type DocChange, type DocumentActor, type EdytorDocument, type OrderPolicy, type YDoc } from './crdt/index.js';
12
12
  import { PresenceWriter, type PresenceOptions } from './collaboration/awarenessSelection.js';
13
13
  import { type BlockMoveRequest } from './session/moves.js';
14
- import { type DocumentWrites } from './crdt/operations.js';
14
+ import type { DocumentWrites } from './crdt/operations.js';
15
15
  import type { Plugin, BlockDefinition, MarkDefinition, InlineBlockDefinition, EditorCommand, Placeholder } from './plugins.js';
16
16
  import { type HotKey, type HotKeyCombination } from './session/keymap.js';
17
17
  import { Dispatcher } from './session/commands.js';
@@ -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;
@@ -10,3 +10,5 @@
10
10
  * through its child index); a disconnected one keeps its offset.
11
11
  */
12
12
  export declare const getTextContentOffsetAtPoint: (textNode: Node, node: Node, offset: number) => number;
13
+ /** `element`'s text nodes in document order: the leaves the offsets above count. */
14
+ export declare const textLeaves: (element: Node) => CharacterData[];
@@ -22,3 +22,11 @@ export const getTextContentOffsetAtPoint = (textNode, node, offset) => {
22
22
  range.setEnd(node, at);
23
23
  return range.toString().length;
24
24
  };
25
+ /** `element`'s text nodes in document order: the leaves the offsets above count. */
26
+ export const textLeaves = (element) => {
27
+ const walker = (element.ownerDocument ?? document).createTreeWalker(element, NodeFilter.SHOW_TEXT);
28
+ const leaves = [];
29
+ while (walker.nextNode())
30
+ leaves.push(walker.currentNode);
31
+ return leaves;
32
+ };
@@ -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