edytor 3.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 (231) 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 +23 -12
  6. package/dist/cloudflare/DocumentRoom.js +10 -6
  7. package/dist/cloudflare/index.d.ts +1 -1
  8. package/dist/cloudflare/room/access.d.ts +8 -3
  9. package/dist/cloudflare/room/access.js +44 -20
  10. package/dist/cloudflare/room/admission.js +28 -5
  11. package/dist/cloudflare/room/comments.js +28 -1
  12. package/dist/cloudflare/room/context.d.ts +139 -129
  13. package/dist/cloudflare/room/context.js +11 -4
  14. package/dist/cloudflare/room/forged.d.ts +14 -0
  15. package/dist/cloudflare/room/forged.js +30 -2
  16. package/dist/cloudflare/room/frames.d.ts +4 -0
  17. package/dist/cloudflare/room/frames.js +5 -1
  18. package/dist/cloudflare/room/purge.d.ts +6 -3
  19. package/dist/cloudflare/room/purge.js +45 -19
  20. package/dist/cloudflare/room/replicas.d.ts +4 -2
  21. package/dist/cloudflare/room/replicas.js +8 -3
  22. package/dist/cloudflare/room/suggest.d.ts +7 -0
  23. package/dist/cloudflare/room/suggest.js +143 -0
  24. package/dist/cloudflare/room/updates.d.ts +3 -4
  25. package/dist/cloudflare/room/updates.js +6 -8
  26. package/dist/cloudflare/routeDocumentSocket.d.ts +10 -1
  27. package/dist/cloudflare/routeDocumentSocket.js +5 -3
  28. package/dist/components/Content.svelte +9 -1
  29. package/dist/crdt/anchors.js +3 -2
  30. package/dist/crdt/attribution/attribution.d.ts +34 -12
  31. package/dist/crdt/attribution/attribution.js +59 -21
  32. package/dist/crdt/attribution/block.d.ts +3 -3
  33. package/dist/crdt/attribution/block.js +7 -8
  34. package/dist/crdt/attribution/index.d.ts +1 -1
  35. package/dist/crdt/attribution/index.js +1 -1
  36. package/dist/crdt/data.d.ts +62 -31
  37. package/dist/crdt/data.js +211 -103
  38. package/dist/crdt/doc/capability.js +9 -13
  39. package/dist/crdt/doc/delete.d.ts +1 -1
  40. package/dist/crdt/doc/delete.js +13 -30
  41. package/dist/crdt/doc/funnel.d.ts +1 -1
  42. package/dist/crdt/doc/funnel.js +34 -26
  43. package/dist/crdt/doc/gate.d.ts +3 -3
  44. package/dist/crdt/doc/history.d.ts +31 -9
  45. package/dist/crdt/doc/history.js +204 -73
  46. package/dist/crdt/doc/layout.js +16 -27
  47. package/dist/crdt/doc/meta.js +17 -23
  48. package/dist/crdt/doc/moves.d.ts +1 -1
  49. package/dist/crdt/doc/moves.js +52 -32
  50. package/dist/crdt/doc/reads.d.ts +2 -2
  51. package/dist/crdt/doc/reads.js +20 -30
  52. package/dist/crdt/doc/seed.js +2 -2
  53. package/dist/crdt/doc/split-merge.js +12 -15
  54. package/dist/crdt/doc/steps.d.ts +25 -13
  55. package/dist/crdt/doc/steps.js +55 -125
  56. package/dist/crdt/doc/suggest.d.ts +167 -0
  57. package/dist/crdt/doc/suggest.js +579 -0
  58. package/dist/crdt/doc/types.d.ts +21 -9
  59. package/dist/crdt/document.d.ts +26 -6
  60. package/dist/crdt/document.js +40 -15
  61. package/dist/crdt/edytor-doc.d.ts +410 -390
  62. package/dist/crdt/edytor-doc.js +143 -95
  63. package/dist/crdt/engine-api.d.ts +39 -1
  64. package/dist/crdt/engine.d.ts +2 -2
  65. package/dist/crdt/engine.js +4 -5
  66. package/dist/crdt/flow.d.ts +2 -2
  67. package/dist/crdt/flow.js +29 -37
  68. package/dist/crdt/index.d.ts +134 -127
  69. package/dist/crdt/index.js +4 -1
  70. package/dist/crdt/operations.d.ts +12 -3
  71. package/dist/crdt/parts.d.ts +43 -0
  72. package/dist/crdt/parts.js +44 -0
  73. package/dist/crdt/placement/display.d.ts +6 -31
  74. package/dist/crdt/placement/display.js +41 -146
  75. package/dist/crdt/placement/keys.d.ts +63 -0
  76. package/dist/crdt/placement/keys.js +95 -0
  77. package/dist/crdt/placement/model.d.ts +119 -118
  78. package/dist/crdt/placement/model.js +242 -142
  79. package/dist/crdt/placement/resolve.d.ts +123 -35
  80. package/dist/crdt/placement/resolve.js +188 -153
  81. package/dist/crdt/placement/slots.d.ts +134 -0
  82. package/dist/crdt/placement/slots.js +152 -0
  83. package/dist/crdt/protocol.d.ts +1 -1
  84. package/dist/crdt/protocol.js +1 -1
  85. package/dist/crdt/protocols/auth.d.ts +11 -1
  86. package/dist/crdt/protocols/auth.js +17 -1
  87. package/dist/crdt/protocols/comments.d.ts +16 -1
  88. package/dist/crdt/protocols/comments.js +9 -1
  89. package/dist/crdt/providers/index.d.ts +6 -4
  90. package/dist/crdt/providers/index.js +3 -1
  91. package/dist/crdt/providers/room.d.ts +16 -2
  92. package/dist/crdt/providers/room.js +64 -17
  93. package/dist/crdt/providers/websocket.d.ts +6 -4
  94. package/dist/crdt/providers/websocket.js +14 -7
  95. package/dist/crdt/purge.d.ts +24 -9
  96. package/dist/crdt/purge.js +266 -126
  97. package/dist/crdt/restore.d.ts +4 -3
  98. package/dist/crdt/restore.js +27 -34
  99. package/dist/crdt/schema.d.ts +30 -21
  100. package/dist/crdt/schema.js +39 -23
  101. package/dist/crdt/storage.d.ts +1 -1
  102. package/dist/crdt/storage.js +1 -1
  103. package/dist/crdt/structs.d.ts +17 -15
  104. package/dist/crdt/structs.js +10 -22
  105. package/dist/crdt/text/index/cache.js +4 -0
  106. package/dist/crdt/text/index/claims.js +13 -23
  107. package/dist/crdt/text/index/fold.d.ts +2 -3
  108. package/dist/crdt/text/index/fold.js +194 -44
  109. package/dist/crdt/text/index/layout.d.ts +2 -4
  110. package/dist/crdt/text/index/layout.js +14 -30
  111. package/dist/crdt/text/index/placement.d.ts +26 -18
  112. package/dist/crdt/text/index/placement.js +420 -157
  113. package/dist/crdt/text/index/records.d.ts +3 -3
  114. package/dist/crdt/text/index/records.js +24 -31
  115. package/dist/crdt/text/index/shared.d.ts +11 -3
  116. package/dist/crdt/text/index/shared.js +29 -17
  117. package/dist/crdt/text/index/state.d.ts +21 -17
  118. package/dist/crdt/text/index/state.js +15 -17
  119. package/dist/crdt/text/index/streams.d.ts +10 -5
  120. package/dist/crdt/text/index/streams.js +78 -27
  121. package/dist/crdt/text/items.d.ts +10 -3
  122. package/dist/crdt/text/items.js +7 -0
  123. package/dist/crdt/text/model.d.ts +87 -62
  124. package/dist/crdt/text/model.js +91 -169
  125. package/dist/crdt/text/ranges.d.ts +1 -1
  126. package/dist/crdt/text/ranges.js +3 -3
  127. package/dist/crdt/text/rows.d.ts +13 -7
  128. package/dist/crdt/text/rows.js +24 -7
  129. package/dist/crdt/text/runs.d.ts +51 -42
  130. package/dist/crdt/text/runs.js +24 -7
  131. package/dist/crdt/text/streams.d.ts +26 -13
  132. package/dist/crdt/text/streams.js +41 -14
  133. package/dist/crdt/vendor/yjs/UPSTREAM.md +651 -21
  134. package/dist/crdt/vendor/yjs/dts/global.d.ts +2 -0
  135. package/dist/crdt/vendor/yjs/dts/index.d.ts +1 -1
  136. package/dist/crdt/vendor/yjs/dts/structs/AbstractStruct.d.ts +7 -0
  137. package/dist/crdt/vendor/yjs/dts/structs/Item.d.ts +197 -10
  138. package/dist/crdt/vendor/yjs/dts/utils/Doc.d.ts +6 -3
  139. package/dist/crdt/vendor/yjs/dts/utils/StructStore.d.ts +5 -18
  140. package/dist/crdt/vendor/yjs/dts/utils/Transaction.d.ts +15 -1
  141. package/dist/crdt/vendor/yjs/dts/utils/UpdateDecoder.d.ts +14 -6
  142. package/dist/crdt/vendor/yjs/dts/utils/UpdateEncoder.d.ts +33 -6
  143. package/dist/crdt/vendor/yjs/dts/utils/ids.d.ts +1 -65
  144. package/dist/crdt/vendor/yjs/dts/utils/marks.d.ts +6 -1
  145. package/dist/crdt/vendor/yjs/dts/ynode.d.ts +54 -7
  146. package/dist/crdt/vendor/yjs/global.d.ts +2 -0
  147. package/dist/crdt/vendor/yjs/src/index.js +1 -1
  148. package/dist/crdt/vendor/yjs/src/structs/AbstractStruct.js +10 -0
  149. package/dist/crdt/vendor/yjs/src/structs/Item.js +649 -51
  150. package/dist/crdt/vendor/yjs/src/utils/BlockSet.js +18 -9
  151. package/dist/crdt/vendor/yjs/src/utils/Doc.js +5 -3
  152. package/dist/crdt/vendor/yjs/src/utils/StructStore.js +5 -0
  153. package/dist/crdt/vendor/yjs/src/utils/Transaction.js +36 -13
  154. package/dist/crdt/vendor/yjs/src/utils/UpdateDecoder.js +88 -18
  155. package/dist/crdt/vendor/yjs/src/utils/UpdateEncoder.js +119 -32
  156. package/dist/crdt/vendor/yjs/src/utils/encoding.js +13 -1
  157. package/dist/crdt/vendor/yjs/src/utils/ids.js +7 -7
  158. package/dist/crdt/vendor/yjs/src/utils/marks.js +193 -8
  159. package/dist/crdt/vendor/yjs/src/utils/redo.js +1 -1
  160. package/dist/crdt/vendor/yjs/src/utils/transaction-helpers.js +1 -1
  161. package/dist/crdt/vendor/yjs/src/utils/updates.js +3 -3
  162. package/dist/crdt/vendor/yjs/src/ynode.js +199 -12
  163. package/dist/edytor.svelte.d.ts +1 -1
  164. package/dist/edytor.svelte.js +103 -75
  165. package/dist/events/beforeInputCommands.d.ts +2 -2
  166. package/dist/events/beforeInputCommands.js +2 -2
  167. package/dist/events/nativeInteractiveControl.d.ts +11 -1
  168. package/dist/events/nativeInteractiveControl.js +41 -1
  169. package/dist/events/onBeforeInput.js +3 -3
  170. package/dist/events/onFocus.js +9 -11
  171. package/dist/events/onPageHide.d.ts +3 -0
  172. package/dist/events/onPageHide.js +16 -0
  173. package/dist/export/forms.d.ts +50 -50
  174. package/dist/index.d.ts +4 -2
  175. package/dist/index.js +1 -1
  176. package/dist/labels.d.ts +8 -0
  177. package/dist/labels.js +6 -1
  178. package/dist/plugins/comments/CommentsController.svelte.d.ts +5 -1
  179. package/dist/plugins/comments/CommentsController.svelte.js +14 -4
  180. package/dist/plugins/find/search.js +2 -2
  181. package/dist/plugins/index.d.ts +1 -0
  182. package/dist/plugins/index.js +1 -0
  183. package/dist/plugins/suggestions/SuggestionBars.svelte +40 -23
  184. package/dist/plugins/suggestions/SuggestionBars.svelte.d.ts +3 -1
  185. package/dist/plugins/suggestions/SuggestionRuns.svelte +77 -0
  186. package/dist/plugins/suggestions/SuggestionRuns.svelte.d.ts +9 -0
  187. package/dist/plugins/suggestions/shared.svelte.d.ts +58 -0
  188. package/dist/plugins/suggestions/shared.svelte.js +421 -0
  189. package/dist/plugins/suggestions/suggestionsPlugin.d.ts +15 -4
  190. package/dist/selection/domSelection.d.ts +2 -0
  191. package/dist/selection/domSelection.js +6 -2
  192. package/dist/selection/pointer.svelte.js +75 -48
  193. package/dist/selection/replaceSelection.js +2 -6
  194. package/dist/selection/selection.svelte.d.ts +3 -3
  195. package/dist/selection/selection.svelte.js +39 -32
  196. package/dist/selection/selection.utils.d.ts +14 -0
  197. package/dist/selection/selection.utils.js +28 -3
  198. package/dist/session/commands.js +21 -11
  199. package/dist/session/composition.svelte.d.ts +5 -3
  200. package/dist/session/composition.svelte.js +21 -17
  201. package/dist/session/handles.d.ts +4 -17
  202. package/dist/session/handles.js +11 -37
  203. package/dist/session/navigation.js +6 -9
  204. package/dist/session/selection.d.ts +11 -5
  205. package/dist/session/selection.js +14 -48
  206. package/dist/session/suggestions.svelte.d.ts +38 -9
  207. package/dist/session/suggestions.svelte.js +37 -18
  208. package/dist/session/virtual.d.ts +1 -1
  209. package/dist/surface/attributes.d.ts +5 -0
  210. package/dist/surface/attributes.js +2 -2
  211. package/dist/surface/cells.d.ts +2 -26
  212. package/dist/surface/cells.js +2 -33
  213. package/dist/surface/observer.svelte.d.ts +6 -1
  214. package/dist/surface/observer.svelte.js +130 -83
  215. package/dist/surface/pin.svelte.d.ts +9 -9
  216. package/dist/surface/pin.svelte.js +15 -22
  217. package/dist/surface/projector.svelte.d.ts +15 -3
  218. package/dist/surface/projector.svelte.js +67 -22
  219. package/dist/text/text.svelte.js +14 -19
  220. package/dist/themes/notion.css +29 -0
  221. package/package.json +4 -2
  222. package/dist/crdt/placement/rank.d.ts +0 -87
  223. package/dist/crdt/placement/rank.js +0 -332
  224. package/dist/crdt/placement/source.d.ts +0 -43
  225. package/dist/crdt/placement/source.js +0 -66
  226. package/dist/crdt/text/delete-index.d.ts +0 -129
  227. package/dist/crdt/text/delete-index.js +0 -365
  228. package/dist/crdt/text/deletes.d.ts +0 -76
  229. package/dist/crdt/text/deletes.js +0 -362
  230. package/dist/crdt/text/index/anchored.d.ts +0 -17
  231. package/dist/crdt/text/index/anchored.js +0 -197
@@ -1,15 +1,26 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { Plugin } from '../../plugins.js';
3
- import type { Suggestion } from '../../session/suggestions.svelte.js';
4
- /** What a `bar` snippet receives: one block suggestion and its actions. */
3
+ import type { AnySuggestion } from '../../session/suggestions.svelte.js';
4
+ /** What a `bar` snippet receives: one suggestion (a block suggestion, or a shared one) and its actions. */
5
5
  export type SuggestionBarPayload = {
6
- suggestion: Suggestion;
7
- /** Place it in the document and give the editor its focus back. */
6
+ suggestion: AnySuggestion;
7
+ /** Accept it (a view-local one: place it in the document) and give the editor its focus back. */
8
8
  accept: () => void;
9
+ /** Drop it (a shared one: its author withdraws it, a decider rejects it). */
9
10
  discard: () => void;
11
+ /** Reject a shared one (a view-local one: drop it). */
12
+ reject: () => void;
10
13
  /** Ask for another answer (`onRetry`); `undefined` without one. */
11
14
  retry: (() => void) | undefined;
12
15
  readonly: boolean;
16
+ /** This view may accept or reject it (a shared one; else: not readonly). */
17
+ canDecide: boolean;
18
+ /** A shared one's author, as their profile names them ("Ada", with `suggestion.via`: "Ada (AI)"). */
19
+ author: {
20
+ id: string;
21
+ name?: string;
22
+ color?: string;
23
+ } | undefined;
13
24
  /** The view's keys are a Mac's: Mod is ⌘ (name the Accept key with it). */
14
25
  mac: boolean;
15
26
  };
@@ -3,6 +3,8 @@ export type DomSelectionSnapshot = Pick<Selection, 'anchorNode' | 'anchorOffset'
3
3
  };
4
4
  export declare const getDomSelection: (node?: Node | null) => Selection | null;
5
5
  export declare const getActiveElement: (node?: Node | null) => Element | null;
6
+ /** The focused element when it is `node` or inside it (the editor's own focus), else `null`. */
7
+ export declare const focusIn: (node?: Node | null) => Element | null;
6
8
  export declare const clearDomSelection: (node?: Node | null) => void;
7
9
  /**
8
10
  * The caret point under (`x`, `y`): `caretPositionFromPoint` (Firefox,
@@ -7,6 +7,11 @@ const shadowOf = (node) => {
7
7
  const documentSelection = (node) => ownerOf(node)?.getSelection() ?? null;
8
8
  export const getDomSelection = (node) => shadowOf(node)?.getSelection?.() ?? documentSelection(node);
9
9
  export const getActiveElement = (node) => shadowOf(node)?.activeElement ?? ownerOf(node)?.activeElement ?? null;
10
+ /** The focused element when it is `node` or inside it (the editor's own focus), else `null`. */
11
+ export const focusIn = (node) => {
12
+ const active = getActiveElement(node);
13
+ return active && node?.contains(active) ? active : null;
14
+ };
10
15
  export const clearDomSelection = (node) => {
11
16
  getDomSelection(node)?.removeAllRanges();
12
17
  };
@@ -96,8 +101,7 @@ export const scrollCaretIntoView = (root) => {
96
101
  const focusNode = selection?.focusNode;
97
102
  if (!selection || !focusNode || selection.rangeCount === 0 || !root.contains(focusNode))
98
103
  return;
99
- const active = getActiveElement(root);
100
- if (!active || !root.contains(active))
104
+ if (!focusIn(root))
101
105
  return;
102
106
  const element = focusNode instanceof Element ? focusNode : focusNode.parentElement;
103
107
  if (!element)
@@ -1,10 +1,10 @@
1
1
  import { blockSelection } from '../session/selection.js';
2
2
  import { toTrailingParagraph } from '../session/navigation.js';
3
- import { isNativeInteractiveControl, isNativeInteractiveEvent } from '../events/nativeInteractiveControl.js';
3
+ import { isNativeInteractiveControl, isNativeInteractiveEvent, ownsPress } from '../events/nativeInteractiveControl.js';
4
4
  import { acrossColumns } from './replaceSelection.js';
5
5
  import { caretAtPoint, getDomSelectionSnapshot } from './domSelection.js';
6
6
  import { domPointOf } from '../surface/projector.svelte.js';
7
- import { getElementFromNode, isTextBoundSelectionPoint, offsetIn, SUGGESTION } from './selection.utils.js';
7
+ import { editableBoundary, isTextBoundSelectionPoint, offsetIn, SUGGESTION } from './selection.utils.js';
8
8
  export class SelectionPointer {
9
9
  selection;
10
10
  /** The press's text point while a primary press (a drag) lasts; `null` after its release. */
@@ -24,13 +24,17 @@ export class SelectionPointer {
24
24
  hold = () => {
25
25
  this.#held = true;
26
26
  };
27
+ /**
28
+ * The block whose non-editable chrome `node` is in: its nearest
29
+ * `contenteditable` boundary is `false`. A nested editable between them
30
+ * (a void block's caption) is no chrome: the browser places the caret
31
+ * at the press, in one step (`sel.press.nested-editable`).
32
+ */
27
33
  #chromeBlock = (node) => {
28
- const element = getElementFromNode(node);
29
- const nonEditableElement = element?.closest('[contenteditable="false"]');
30
- if (!(nonEditableElement instanceof HTMLElement) ||
31
- !this.selection.edytor.node?.contains(nonEditableElement)) {
34
+ const boundary = editableBoundary(node, this.selection.edytor.node);
35
+ if (!boundary || boundary.editable)
32
36
  return null;
33
- }
37
+ const nonEditableElement = boundary.element;
34
38
  // A suggestion's preview is no block's chrome: a press there is its own (cancelled).
35
39
  if (isNativeInteractiveControl(nonEditableElement) || nonEditableElement.closest(SUGGESTION)) {
36
40
  return null;
@@ -75,17 +79,17 @@ export class SelectionPointer {
75
79
  };
76
80
  #trailing = () => {
77
81
  const { edytor } = this.selection;
78
- edytor.expectInternalFocus();
79
- edytor.node?.focus({ preventScroll: true });
82
+ edytor.expectInternalFocus(edytor.node);
80
83
  toTrailingParagraph(edytor);
81
84
  };
82
85
  /** The gesture a press in the host's own area starts (`claimMargins`): the marquee's. */
83
86
  #margin = null;
84
87
  /**
85
- * The host's last press went to the margin gesture (`marginPress`), until
86
- * its release: its compatibility `mousedown` is cancelled (no native
87
- * caret, focus or selection), and it places no caret (`pressed`) and
88
- * starts no text drag (`capture`).
88
+ * The host's last press went to a gesture of the model's (the margin
89
+ * gesture's, `marginPress`; a void block's selection, `chromePress`),
90
+ * until its release: its compatibility `mousedown` is cancelled (no
91
+ * native caret, focus or selection), and it places no caret (`pressed`)
92
+ * and starts no text drag (`capture`).
89
93
  * @internal
90
94
  */
91
95
  claimed = false;
@@ -159,14 +163,26 @@ export class SelectionPointer {
159
163
  const point = this.#pointAt(x, at.clientY);
160
164
  if (!point)
161
165
  return;
162
- edytor.expectInternalFocus();
163
- host.focus({ preventScroll: true });
166
+ edytor.expectInternalFocus(host);
164
167
  this.selection.selectText(point.text, point.offset);
165
168
  };
166
- /** @internal */
169
+ /**
170
+ * A press on a block's non-editable chrome. A void block's
171
+ * (`sel.press.void`, Notion): the block is selected, the host focused,
172
+ * no caret placed — unless the press is a control's of its own
173
+ * (`ownsPress`: a native control, an interactive role, an element the
174
+ * app marks `data-edytor-control`), which keeps it whole; Shift or Mod
175
+ * adds the block to a block selection. On the line of the block's text
176
+ * (a caption's, beside its text), the caret at the nearest point of it.
177
+ * Any other block's: the caret at the start of its text. A press a
178
+ * control owns (`ownPress`) never reaches it.
179
+ * @internal
180
+ */
167
181
  chromePress = (event) => {
168
182
  const targetNode = event.target instanceof Node ? event.target : null;
169
183
  const targetBlock = this.#chromeBlock(targetNode);
184
+ if (targetBlock && this.selection.edytor.facade.isVoid(targetBlock.id))
185
+ return this.#voidPress(event, targetBlock);
170
186
  const targetText = targetBlock?.firstText;
171
187
  // A native control inside the chrome (a void block's input) owns its press.
172
188
  if (!targetText || isNativeInteractiveEvent(event)) {
@@ -175,10 +191,40 @@ export class SelectionPointer {
175
191
  // The model answers this press: the browser's own caret placement on
176
192
  // the chrome is cancelled (a canceled pointerdown moves no selection).
177
193
  event.preventDefault();
178
- this.selection.edytor.expectInternalFocus();
179
- this.selection.edytor.node?.focus({ preventScroll: true });
194
+ this.selection.edytor.expectInternalFocus(this.selection.edytor.node);
180
195
  this.selection.selectText(targetText, 0);
181
196
  };
197
+ /**
198
+ * A press (and its whole gesture: its clicks, multi-clicks, `selectstart`
199
+ * and drag) a control of a block's chrome owns (`ownsPress`,
200
+ * `sel.press.void`): the editor does nothing with it. Every press handler
201
+ * asks this one rule first.
202
+ * @internal
203
+ */
204
+ ownPress = (event) => ownsPress(this.selection.edytor.node, event);
205
+ /** A press on void block `block`'s chrome selects it (`sel.press.void`). */
206
+ #voidPress = (event, block) => {
207
+ const { edytor, value } = this.selection;
208
+ if (event.button !== 0)
209
+ return;
210
+ // The model answers it: no native caret, focus or selection; no text drag (`claimed`).
211
+ event.preventDefault();
212
+ this.claimed = true;
213
+ edytor.expectInternalFocus(edytor.node);
214
+ // On the line of its text (a caption's, beside the text): the caret at the nearest point there.
215
+ const target = event.target instanceof Element && event.target !== block.node ? event.target : null;
216
+ const line = target?.querySelector('[data-edytor-text]');
217
+ const text = line && this.selection.getTextOfNode(line);
218
+ const box = line?.getBoundingClientRect();
219
+ if (text &&
220
+ text.parent === block &&
221
+ box &&
222
+ event.clientY >= box.top &&
223
+ event.clientY <= box.bottom)
224
+ return this.placeAt(text, event.clientX, event.clientY);
225
+ const adding = (event.shiftKey || event.metaKey || event.ctrlKey) && value.kind === 'blocks';
226
+ this.selection.select(blockSelection(adding ? [...new Set([...value.ids, block.id])] : [block.id]));
227
+ };
182
228
  /**
183
229
  * A triple click selects the block's content in the model; the browser's
184
230
  * own multi-click selection is cancelled at its `mousedown`
@@ -186,7 +232,7 @@ export class SelectionPointer {
186
232
  * @internal
187
233
  */
188
234
  tripleClick = (e) => {
189
- if (e.detail < 3)
235
+ if (e.detail < 3 || this.ownPress(e))
190
236
  return;
191
237
  const targetNode = e.target instanceof Node ? e.target : null;
192
238
  const clickedBlock = this.selection.getBlockOfNode(targetNode);
@@ -202,7 +248,7 @@ export class SelectionPointer {
202
248
  };
203
249
  /** @internal */
204
250
  preventTripleClick = (e) => {
205
- if (e.detail >= 3)
251
+ if (e.detail >= 3 && !this.ownPress(e))
206
252
  e.preventDefault();
207
253
  };
208
254
  /** The text point under the pointer, if any. */
@@ -361,39 +407,20 @@ export class SelectionPointer {
361
407
  * anchor) would anchor the DOM selection on nodes the model cannot
362
408
  * represent — block-level selection owns that surface instead.
363
409
  *
364
- * The NEAREST `[contenteditable]` boundary decides: `false` means
365
- * non-editable chrome, while a nested `true` (void-block captions
366
- * re-enable editing on their text spans) keeps native selection.
367
- * The root itself is excluded so readonly mode stays selectable.
410
+ * The nearest `[contenteditable]` boundary decides (`editableBoundary`,
411
+ * the chrome press's rule too): `false` means non-editable chrome, while
412
+ * a nested editable (void-block captions re-enable editing on their text
413
+ * spans) keeps native selection. The root itself is excluded so readonly
414
+ * mode stays selectable.
368
415
  * @internal
369
416
  */
370
417
  onSelectStart = (event) => {
371
418
  // Native controls (todo checkboxes, plugin inputs…) keep their own
372
419
  // selection behavior.
373
- if (isNativeInteractiveEvent(event)) {
374
- return;
375
- }
376
- const element = getElementFromNode(event.target instanceof Node ? event.target : null);
377
- if (!element || !this.selection.edytor.node?.contains(element)) {
420
+ if (isNativeInteractiveEvent(event) || this.ownPress(event))
378
421
  return;
379
- }
380
- // Walk ancestors to the nearest contenteditable boundary. Both the
381
- // attribute and the `contentEditable` property are checked — the
382
- // attach hooks of inline/void blocks set the property, which jsdom
383
- // does not reflect onto the attribute.
384
- let boundary = element instanceof HTMLElement ? element : null;
385
- while (boundary && boundary !== this.selection.edytor.node) {
386
- const state = boundary.getAttribute('contenteditable') ?? boundary.contentEditable;
387
- if (state === 'false') {
388
- event.preventDefault();
389
- return;
390
- }
391
- if (state === 'true' || state === 'plaintext-only' || state === '') {
392
- // Nested editable region (e.g. void-block captions re-enabling
393
- // editing on their text spans) keeps native selection.
394
- return;
395
- }
396
- boundary = boundary.parentElement;
397
- }
422
+ const target = event.target instanceof Node ? event.target : null;
423
+ if (editableBoundary(target, this.selection.edytor.node)?.editable === false)
424
+ event.preventDefault();
398
425
  };
399
426
  }
@@ -1,6 +1,6 @@
1
1
  import { Text } from '../text/text.svelte.js';
2
2
  import { id, vetoable } from '../utils.js';
3
- import { blockSelection, noSelection } from '../session/selection.js';
3
+ import { blockSelection, noSelection, pointOf } from '../session/selection.js';
4
4
  import { hidden, selectedMembers, shown } from './visibility.js';
5
5
  /**
6
6
  * The caret a write that decides one left (a range deletion, a placed flow:
@@ -211,11 +211,7 @@ const deleteRange = (edytor, replace, selection) => {
211
211
  const { startText, endText, yStart, yEnd } = selection ?? edytor.selection.textRange();
212
212
  if (!startText || !endText)
213
213
  return null;
214
- const at = (text, offset) => ({
215
- block: text.parent.id,
216
- offset: text.segStart + offset
217
- });
218
- const [from, to] = [at(startText, yStart), at(endText, yEnd)];
214
+ const [from, to] = [pointOf(startText, yStart), pointOf(endText, yEnd)];
219
215
  const result = replace ? edytor.replaceRange(from, to) : edytor.deleteRange(from, to);
220
216
  if (result.status === 'refused')
221
217
  return result.reason === 'view' ? null : { text: startText, offset: yStart };
@@ -90,9 +90,9 @@ export declare class EdytorSelection {
90
90
  /** Stage (or, with `undefined`, clear) the caret's pending marks: a new value, same anchors. */
91
91
  stage: (pending: Marks | undefined) => void;
92
92
  /**
93
- * A text value from wrapper endpoints (offsets inside text segments):
94
- * the start binds right (boundary inserts stay outside), the end and a
95
- * caret bind left; `none` when the start text is not bound to the document.
93
+ * A text value from wrapper endpoints (offsets inside text segments): the
94
+ * start binds right (boundary inserts stay outside), the end and a caret
95
+ * bind left; `none` when the start text is not bound to the document.
96
96
  */
97
97
  textValue: (startText: Text, yStart: number, endText?: Text, yEnd?: number, isReversed?: boolean, startAffinity?: "left" | "right") => SelectionValue;
98
98
  /**
@@ -5,7 +5,7 @@ import { getDomSelectionSnapshot } from './domSelection.js';
5
5
  import { SvelteSet } from 'svelte/reactivity';
6
6
  import { InlineBlock } from '../block/inlineBlock.svelte.js';
7
7
  import { isKindControl, isNativeInteractiveControl, isOwnPoint } from '../events/nativeInteractiveControl.js';
8
- import { atomSelection, blockSelection, noSelection, project, sameEnds, sameValue, serialize, textSelection } from '../session/selection.js';
8
+ import { atomSelection, blockSelection, noSelection, pointOf, project, sameEnds, sameValue, serialize, textSelection } from '../session/selection.js';
9
9
  import { seam } from '../crdt/anchors.js';
10
10
  import { landed } from '../session/navigation.js';
11
11
  import * as visibility from './visibility.js';
@@ -272,9 +272,8 @@ export class EdytorSelection {
272
272
  * shows no text (a divider) keeps the current value. Displayed after the flush.
273
273
  */
274
274
  setCaret = (caret) => {
275
- const at = caret && caret.block.textAtOffset(Math.max(0, caret.offset));
276
- if (at)
277
- this.selectText(at.text, at.offset);
275
+ if (caret)
276
+ this.selectAt({ block: caret.block.id, offset: Math.max(0, caret.offset) });
278
277
  };
279
278
  /** The marks the next insertion at the caret takes (values kept). */
280
279
  get pending() {
@@ -385,20 +384,39 @@ export class EdytorSelection {
385
384
  }
386
385
  };
387
386
  /**
388
- * A text value from wrapper endpoints (offsets inside text segments):
389
- * the start binds right (boundary inserts stay outside), the end and a
390
- * caret bind left; `none` when the start text is not bound to the document.
387
+ * A text value from block-offset points (an inline atom counting 1): the
388
+ * start binds right (boundary inserts stay outside), the end and a caret
389
+ * bind left; `none` when the start does not anchor (the root, an unknown block).
390
+ * @internal
391
391
  */
392
- textValue = (startText, yStart, endText = startText, yEnd = yStart, isReversed = false, startAffinity) => {
393
- const mint = this.createTextAnchor;
394
- const collapsed = startText === endText && yStart === yEnd;
395
- const start = mint(startText, yStart, startAffinity ?? (collapsed ? 'left' : 'right'));
396
- if (!start)
392
+ valueAt = (start, end = start, isReversed = false, startAffinity) => {
393
+ const { facade } = this.edytor;
394
+ const collapsed = start.block === end.block && start.offset === end.offset;
395
+ const a = facade.anchorAt(start.block, start.offset, startAffinity ?? (collapsed ? 'left' : 'right'));
396
+ if (!a)
397
397
  return noSelection;
398
398
  if (collapsed)
399
- return textSelection(start);
400
- const end = mint(endText, yEnd, 'left') ?? start;
401
- return isReversed ? textSelection(end, start) : textSelection(start, end);
399
+ return textSelection(a);
400
+ const b = facade.anchorAt(end.block, end.offset, 'left') ?? a;
401
+ return isReversed ? textSelection(b, a) : textSelection(a, b);
402
+ };
403
+ /**
404
+ * A text value from wrapper endpoints (offsets inside text segments): the
405
+ * start binds right (boundary inserts stay outside), the end and a caret
406
+ * bind left; `none` when the start text is not bound to the document.
407
+ */
408
+ textValue = (startText, yStart, endText = startText, yEnd = yStart, isReversed = false, startAffinity) => this.valueAt(pointOf(startText, yStart), pointOf(endText, yEnd), isReversed, startAffinity);
409
+ /**
410
+ * Select from `start` to `end` (block offsets, a caret by default;
411
+ * `isReversed` makes the end the anchor), else the seam of the start's
412
+ * block. The projector displays it after the flush.
413
+ * @internal
414
+ */
415
+ selectAt = (start, end = start, isReversed = false) => {
416
+ const value = this.valueAt(start, end, isReversed);
417
+ const target = project(value, this.edytor.facade).start ? value : this.#seamValue(start.block);
418
+ if (target)
419
+ this.select(target);
402
420
  };
403
421
  /**
404
422
  * Select from `yStart` of `startText` to `yEnd` of `endText` (a caret by
@@ -413,13 +431,8 @@ export class EdytorSelection {
413
431
  selectText = (startText, yStart, endText = startText ?? null, yEnd = yStart, isReversed = false) => {
414
432
  if (!startText || !endText)
415
433
  return;
416
- const at = (text, offset) => Math.min(Math.max(offset, 0), text.length);
417
- const value = this.textValue(startText, at(startText, yStart), endText, at(endText, yEnd), isReversed);
418
- const target = project(value, this.edytor.facade).start
419
- ? value
420
- : this.#seamValue(startText.parent?.id ?? null);
421
- if (target)
422
- this.select(target);
434
+ const at = (text, offset) => pointOf(text, Math.min(Math.max(offset, 0), text.length));
435
+ this.selectAt(at(startText, yStart), at(endText, yEnd), isReversed);
423
436
  };
424
437
  /**
425
438
  * Select `block`'s lines (Mod+A, a triple click): from its first shown
@@ -468,8 +481,7 @@ export class EdytorSelection {
468
481
  if (value.kind !== 'atom')
469
482
  return;
470
483
  const { start } = this.projection;
471
- const before = start && this.textAt(start);
472
- this.select(before ? this.textValue(before.text, before.offset) : noSelection);
484
+ this.select(start ? this.valueAt(start) : noSelection);
473
485
  };
474
486
  selectInlineBlock = (inlineBlock, from = 'before') => {
475
487
  this.select(atomSelection(inlineBlock.parent.id, inlineBlock.id, from));
@@ -477,11 +489,7 @@ export class EdytorSelection {
477
489
  /** @internal */
478
490
  getTextOfNode = getTextOfNode.bind(this);
479
491
  /** @internal The block whose element holds `node`, in this editor. */
480
- getBlockOfNode = (node) => {
481
- const element = getElementFromNode(node)?.closest('[data-edytor-block]');
482
- const id = element && this.edytor.node?.contains(element) && element.dataset.edytorId;
483
- return (id && this.edytor.idToBlock.get(id)) || null;
484
- };
492
+ getBlockOfNode = (node) => this.edytor.surface.blockOf(node);
485
493
  /** @internal */
486
494
  init = () => {
487
495
  if (typeof document === 'undefined')
@@ -656,9 +664,8 @@ export class EdytorSelection {
656
664
  * for the root's own text.
657
665
  */
658
666
  createTextAnchor = (text, offset, affinity = 'left') => {
659
- if (text.parent.isRoot)
660
- return null;
661
- return this.edytor.facade.anchorAt(text.blockId, text.segStart + offset, affinity);
667
+ const { block, offset: at } = pointOf(text, offset);
668
+ return this.edytor.facade.anchorAt(block, at, affinity);
662
669
  };
663
670
  /**
664
671
  * Resolve a `TextAnchor` → `{text, offset}` (offset within the text
@@ -7,6 +7,20 @@ export declare const SYNTHETIC_TEXT_OVERLAY_SELECTOR = "[data-edytor-text-sugges
7
7
  /** A block suggestion's preview group: view-only, never a selection endpoint (`session/suggestions`). */
8
8
  export declare const SUGGESTION = "[data-edytor-suggestion]";
9
9
  export declare const getElementFromNode: (node: Node | null) => Element | null;
10
+ /**
11
+ * The nearest `contenteditable` boundary of `node` inside `host` (the host
12
+ * itself excluded), and whether it is editable: `false` is non-editable
13
+ * chrome (a void block's wrapper, an atom, a control's icon); a nested
14
+ * `true`, `plaintext-only` or `''` (a void block's caption, which turns
15
+ * editing on again for its text) is an editable region whose presses and
16
+ * drags are the browser's (`sel.press.nested-editable`). The attribute or,
17
+ * without one, the property (an attach hook sets it; jsdom reflects
18
+ * neither way). `null`: no boundary below the host.
19
+ */
20
+ export declare const editableBoundary: (node: Node | null, host: Element | null | undefined) => {
21
+ element: Element;
22
+ editable: boolean;
23
+ } | null;
10
24
  export declare const isInsideTrailingNewlineMarker: (node: Node) => boolean;
11
25
  /**
12
26
  * A selection endpoint counts as text-bound when it lives inside a
@@ -13,6 +13,31 @@ export const getElementFromNode = (node) => !node || typeof Element === 'undefin
13
13
  : node.nodeType === Node.TEXT_NODE
14
14
  ? node.parentElement
15
15
  : null;
16
+ /**
17
+ * The nearest `contenteditable` boundary of `node` inside `host` (the host
18
+ * itself excluded), and whether it is editable: `false` is non-editable
19
+ * chrome (a void block's wrapper, an atom, a control's icon); a nested
20
+ * `true`, `plaintext-only` or `''` (a void block's caption, which turns
21
+ * editing on again for its text) is an editable region whose presses and
22
+ * drags are the browser's (`sel.press.nested-editable`). The attribute or,
23
+ * without one, the property (an attach hook sets it; jsdom reflects
24
+ * neither way). `null`: no boundary below the host.
25
+ */
26
+ export const editableBoundary = (node, host) => {
27
+ if (!host)
28
+ return null;
29
+ for (let at = getElementFromNode(node); at && at !== host; at = at.parentElement) {
30
+ if (!host.contains(at))
31
+ return null;
32
+ const attribute = at.getAttribute('contenteditable');
33
+ const state = attribute?.toLowerCase() ?? at.contentEditable;
34
+ if (state === 'false')
35
+ return { element: at, editable: false };
36
+ if (state === 'true' || state === 'plaintext-only' || state === '')
37
+ return { element: at, editable: true };
38
+ }
39
+ return null;
40
+ };
16
41
  /** The element around `node` (itself included) matching `selector`. */
17
42
  const closestTo = (node, selector) => getElementFromNode(node)?.closest(selector) ?? null;
18
43
  /** `node` is an element matching `selector` or holding one. */
@@ -120,7 +145,7 @@ export function getTextOfNode(node, offset) {
120
145
  if (!node)
121
146
  return null;
122
147
  const { edytor } = this;
123
- const textOf = (element) => (element && edytor.nodeToText.get(element)) || null;
148
+ const textOf = (element) => edytor.surface.handleOf(element, 'text');
124
149
  // A point in a suggestion's preview is the stray boundary the preview sits at.
125
150
  const preview = closestTo(node, SUGGESTION);
126
151
  if (preview)
@@ -138,7 +163,7 @@ export const getInlineBlockOfNode = (edytor, node) => {
138
163
  const element = getElementFromNode(node);
139
164
  const around = element?.closest(INLINE);
140
165
  const inline = around && edytor.node?.contains(around) ? around : element?.querySelector(INLINE);
141
- return (inline && edytor.nodeToInlineBlock.get(inline)) || null;
166
+ return edytor.surface.handleOf(inline, 'atom');
142
167
  };
143
168
  /** The one inline atom a non-collapsed range covers, touching no text. */
144
169
  export const getInlineBlockInSelectedRange = (edytor, range) => {
@@ -146,7 +171,7 @@ export const getInlineBlockInSelectedRange = (edytor, range) => {
146
171
  if (!root)
147
172
  return null;
148
173
  const inside = (selector) => [...(root.matches(selector) ? [root] : []), ...root.querySelectorAll(selector)].filter((element) => range.intersectsNode(element));
149
- const atoms = new Set(inside(INLINE).flatMap((element) => edytor.nodeToInlineBlock.get(element) ?? []));
174
+ const atoms = new Set(inside(INLINE).flatMap((element) => edytor.surface.handleOf(element, 'atom') ?? []));
150
175
  return atoms.size === 1 && !inside(TEXT).length ? [...atoms][0] : null;
151
176
  };
152
177
  /**
@@ -1,4 +1,5 @@
1
1
  import { refused } from '../crdt/doc/plan.js';
2
+ import { pointOf } from './selection.js';
2
3
  import { viewOf } from '../selection/visibility.js';
3
4
  import { reveal } from '../selection/replaceSelection.js';
4
5
  import { DEV } from 'esm-env';
@@ -171,6 +172,13 @@ export class Dispatcher {
171
172
  patched = null;
172
173
  /** `outside` is running: its commands cut no step and enter none. */
173
174
  untracked = false;
175
+ /**
176
+ * A plugin that writes plans itself (the shared suggestions' suggesting
177
+ * mode): a plan's result (its `at` set to where its caret stands now),
178
+ * or `null` to write it as prepared. Set by the plugin.
179
+ * @internal
180
+ */
181
+ writer;
174
182
  /** @internal */
175
183
  constructor(edytor) {
176
184
  this.edytor = edytor;
@@ -402,7 +410,7 @@ export class Dispatcher {
402
410
  try {
403
411
  // An unplanned command runs after its lead, reading the state it leaves.
404
412
  if (lead && !plan)
405
- this.edytor.facade.apply(lead);
413
+ void (this.writer?.(lead) ?? this.edytor.facade.apply(lead));
406
414
  return body(payload, plan);
407
415
  }
408
416
  finally {
@@ -442,8 +450,10 @@ export class Dispatcher {
442
450
  plan = (operation, payload, prepare, block, text) => {
443
451
  let at;
444
452
  const out = this.dispatch(operation, payload, { block: block ?? this.edytor.root, ...(text && { text }) }, (p, plan = prepare(p)) => {
453
+ const out = this.write(plan);
454
+ // Read once written: a plan a plugin wrote says where its caret stands now.
445
455
  at = 'writes' in plan ? plan.at : undefined;
446
- return this.write(plan);
456
+ return out;
447
457
  }, prepare);
448
458
  if (at && !this.active && out && out.status !== 'refused')
449
459
  this.caret(at);
@@ -493,13 +503,14 @@ export class Dispatcher {
493
503
  !!caret &&
494
504
  plan.writes.some((w) => w.op === op && w.id === caret.block.id && w.offset === caret.offset);
495
505
  const [typed, atom] = [at('insertText'), at('insertInline')];
496
- const result = facade.apply(plan);
506
+ const result = this.writer?.(plan) ?? facade.apply(plan);
497
507
  for (const [id, hook] of touched)
498
508
  this.request(id, hook);
499
509
  if (typed)
500
510
  selection.stage(undefined);
501
- const after = atom ? caret.block.textAtOffset(caret.offset + 1) : null;
502
- const value = after ? selection.textValue(after.text, after.offset) : null;
511
+ const value = atom
512
+ ? selection.valueAt({ block: caret.block.id, offset: caret.offset + 1 })
513
+ : null;
503
514
  if (value?.kind === 'text')
504
515
  selection.select(Object.freeze({ ...value, pending }));
505
516
  for (const id of plan.effect.moves) {
@@ -575,14 +586,13 @@ export class Dispatcher {
575
586
  */
576
587
  caret = (caret, ops) => {
577
588
  const block = typeof caret?.block === 'string' ? this.edytor.idToBlock.get(caret.block) : caret?.block;
578
- const at = block?.textAtOffset(Math.max(0, caret.offset));
579
- return this.caretIn(at?.text, at?.offset ?? 0, ops);
589
+ return this.#caretAt(block && { block: block.id, offset: Math.max(0, caret.offset) }, ops);
580
590
  };
581
591
  /** @internal `caret` at an offset inside a text segment. */
582
- caretIn = (text, offset, ops) => {
592
+ caretIn = (text, offset, ops) => this.#caretAt(text && pointOf(text, Math.max(0, Math.min(offset, text.length))), ops);
593
+ #caretAt = (point, ops) => {
583
594
  const selection = this.edytor.selection;
584
- const at = text ? Math.max(0, Math.min(offset, text.length)) : 0;
585
- const value = text ? selection.textValue(text, at) : null;
595
+ const value = point ? selection.valueAt(point) : null;
586
596
  let out;
587
597
  if (ops) {
588
598
  const outer = this.authoring;
@@ -596,7 +606,7 @@ export class Dispatcher {
596
606
  if (out.status === 'refused')
597
607
  return out;
598
608
  }
599
- if (!text || !value || value.kind === 'none')
609
+ if (!value || value.kind === 'none')
600
610
  return out;
601
611
  selection.select(value);
602
612
  // Declared before its operations: gone with them after all, the seam.
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * One IME composition is one session. Its start target (the selection at
5
5
  * `compositionstart`) is replaced at its first write through an ordinary
6
- * command (hooks, veto, tracked) — over a block or atom selection, which
7
- * shows no DOM range, at the start, so the IME writes in its place — and
8
- * the marks of the composed text are captured once, at the start. Previews are mechanical tracked writes (no
6
+ * command (hooks, veto, tracked) — at the start over a block or atom
7
+ * selection, which shows no DOM range, and over a text range across blocks,
8
+ * whose native replacement would join the blocks' elements (`ime.range.blocks`),
9
+ * so the IME writes in its place — and the marks of the composed text are
10
+ * captured once, at the start. Previews are mechanical tracked writes (no
9
11
  * hooks) inside one capture group: the group opens with the session's first
10
12
  * write and is held open before each later one (`UndoManager.lastChange`),
11
13
  * so the session and its ending are one undo step. Like a typed