@rtif-sdk/web 1.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 (215) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/block-drag-handler.d.ts +189 -0
  4. package/dist/block-drag-handler.d.ts.map +1 -0
  5. package/dist/block-drag-handler.js +745 -0
  6. package/dist/block-drag-handler.js.map +1 -0
  7. package/dist/block-renderer.d.ts +402 -0
  8. package/dist/block-renderer.d.ts.map +1 -0
  9. package/dist/block-renderer.js +424 -0
  10. package/dist/block-renderer.js.map +1 -0
  11. package/dist/clipboard.d.ts +178 -0
  12. package/dist/clipboard.d.ts.map +1 -0
  13. package/dist/clipboard.js +432 -0
  14. package/dist/clipboard.js.map +1 -0
  15. package/dist/command-bus.d.ts +113 -0
  16. package/dist/command-bus.d.ts.map +1 -0
  17. package/dist/command-bus.js +70 -0
  18. package/dist/command-bus.js.map +1 -0
  19. package/dist/composition.d.ts +220 -0
  20. package/dist/composition.d.ts.map +1 -0
  21. package/dist/composition.js +271 -0
  22. package/dist/composition.js.map +1 -0
  23. package/dist/content-extraction.d.ts +69 -0
  24. package/dist/content-extraction.d.ts.map +1 -0
  25. package/dist/content-extraction.js +228 -0
  26. package/dist/content-extraction.js.map +1 -0
  27. package/dist/content-handler-file.d.ts +40 -0
  28. package/dist/content-handler-file.d.ts.map +1 -0
  29. package/dist/content-handler-file.js +91 -0
  30. package/dist/content-handler-file.js.map +1 -0
  31. package/dist/content-handler-image.d.ts +82 -0
  32. package/dist/content-handler-image.d.ts.map +1 -0
  33. package/dist/content-handler-image.js +120 -0
  34. package/dist/content-handler-image.js.map +1 -0
  35. package/dist/content-handler-url.d.ts +129 -0
  36. package/dist/content-handler-url.d.ts.map +1 -0
  37. package/dist/content-handler-url.js +244 -0
  38. package/dist/content-handler-url.js.map +1 -0
  39. package/dist/content-handlers.d.ts +67 -0
  40. package/dist/content-handlers.d.ts.map +1 -0
  41. package/dist/content-handlers.js +263 -0
  42. package/dist/content-handlers.js.map +1 -0
  43. package/dist/content-pipeline.d.ts +383 -0
  44. package/dist/content-pipeline.d.ts.map +1 -0
  45. package/dist/content-pipeline.js +232 -0
  46. package/dist/content-pipeline.js.map +1 -0
  47. package/dist/cursor-nav.d.ts +149 -0
  48. package/dist/cursor-nav.d.ts.map +1 -0
  49. package/dist/cursor-nav.js +230 -0
  50. package/dist/cursor-nav.js.map +1 -0
  51. package/dist/cursor-rect.d.ts +65 -0
  52. package/dist/cursor-rect.d.ts.map +1 -0
  53. package/dist/cursor-rect.js +98 -0
  54. package/dist/cursor-rect.js.map +1 -0
  55. package/dist/drop-indicator.d.ts +108 -0
  56. package/dist/drop-indicator.d.ts.map +1 -0
  57. package/dist/drop-indicator.js +236 -0
  58. package/dist/drop-indicator.js.map +1 -0
  59. package/dist/editor.d.ts +41 -0
  60. package/dist/editor.d.ts.map +1 -0
  61. package/dist/editor.js +710 -0
  62. package/dist/editor.js.map +1 -0
  63. package/dist/floating-toolbar.d.ts +93 -0
  64. package/dist/floating-toolbar.d.ts.map +1 -0
  65. package/dist/floating-toolbar.js +159 -0
  66. package/dist/floating-toolbar.js.map +1 -0
  67. package/dist/index.d.ts +62 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +119 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/input-bridge.d.ts +273 -0
  72. package/dist/input-bridge.d.ts.map +1 -0
  73. package/dist/input-bridge.js +884 -0
  74. package/dist/input-bridge.js.map +1 -0
  75. package/dist/link-popover.d.ts +38 -0
  76. package/dist/link-popover.d.ts.map +1 -0
  77. package/dist/link-popover.js +278 -0
  78. package/dist/link-popover.js.map +1 -0
  79. package/dist/mark-renderer.d.ts +275 -0
  80. package/dist/mark-renderer.d.ts.map +1 -0
  81. package/dist/mark-renderer.js +210 -0
  82. package/dist/mark-renderer.js.map +1 -0
  83. package/dist/perf.d.ts +145 -0
  84. package/dist/perf.d.ts.map +1 -0
  85. package/dist/perf.js +260 -0
  86. package/dist/perf.js.map +1 -0
  87. package/dist/plugin-kit.d.ts +265 -0
  88. package/dist/plugin-kit.d.ts.map +1 -0
  89. package/dist/plugin-kit.js +234 -0
  90. package/dist/plugin-kit.js.map +1 -0
  91. package/dist/plugins/alignment-plugin.d.ts +68 -0
  92. package/dist/plugins/alignment-plugin.d.ts.map +1 -0
  93. package/dist/plugins/alignment-plugin.js +98 -0
  94. package/dist/plugins/alignment-plugin.js.map +1 -0
  95. package/dist/plugins/block-utils.d.ts +113 -0
  96. package/dist/plugins/block-utils.d.ts.map +1 -0
  97. package/dist/plugins/block-utils.js +191 -0
  98. package/dist/plugins/block-utils.js.map +1 -0
  99. package/dist/plugins/blockquote-plugin.d.ts +39 -0
  100. package/dist/plugins/blockquote-plugin.d.ts.map +1 -0
  101. package/dist/plugins/blockquote-plugin.js +88 -0
  102. package/dist/plugins/blockquote-plugin.js.map +1 -0
  103. package/dist/plugins/bold-plugin.d.ts +37 -0
  104. package/dist/plugins/bold-plugin.d.ts.map +1 -0
  105. package/dist/plugins/bold-plugin.js +48 -0
  106. package/dist/plugins/bold-plugin.js.map +1 -0
  107. package/dist/plugins/callout-plugin.d.ts +100 -0
  108. package/dist/plugins/callout-plugin.d.ts.map +1 -0
  109. package/dist/plugins/callout-plugin.js +200 -0
  110. package/dist/plugins/callout-plugin.js.map +1 -0
  111. package/dist/plugins/code-block-plugin.d.ts +62 -0
  112. package/dist/plugins/code-block-plugin.d.ts.map +1 -0
  113. package/dist/plugins/code-block-plugin.js +176 -0
  114. package/dist/plugins/code-block-plugin.js.map +1 -0
  115. package/dist/plugins/code-plugin.d.ts +37 -0
  116. package/dist/plugins/code-plugin.d.ts.map +1 -0
  117. package/dist/plugins/code-plugin.js +48 -0
  118. package/dist/plugins/code-plugin.js.map +1 -0
  119. package/dist/plugins/embed-plugin.d.ts +90 -0
  120. package/dist/plugins/embed-plugin.d.ts.map +1 -0
  121. package/dist/plugins/embed-plugin.js +147 -0
  122. package/dist/plugins/embed-plugin.js.map +1 -0
  123. package/dist/plugins/font-family-plugin.d.ts +58 -0
  124. package/dist/plugins/font-family-plugin.d.ts.map +1 -0
  125. package/dist/plugins/font-family-plugin.js +57 -0
  126. package/dist/plugins/font-family-plugin.js.map +1 -0
  127. package/dist/plugins/font-size-plugin.d.ts +57 -0
  128. package/dist/plugins/font-size-plugin.d.ts.map +1 -0
  129. package/dist/plugins/font-size-plugin.js +56 -0
  130. package/dist/plugins/font-size-plugin.js.map +1 -0
  131. package/dist/plugins/heading-plugin.d.ts +52 -0
  132. package/dist/plugins/heading-plugin.d.ts.map +1 -0
  133. package/dist/plugins/heading-plugin.js +114 -0
  134. package/dist/plugins/heading-plugin.js.map +1 -0
  135. package/dist/plugins/hr-plugin.d.ts +33 -0
  136. package/dist/plugins/hr-plugin.d.ts.map +1 -0
  137. package/dist/plugins/hr-plugin.js +75 -0
  138. package/dist/plugins/hr-plugin.js.map +1 -0
  139. package/dist/plugins/image-plugin.d.ts +115 -0
  140. package/dist/plugins/image-plugin.d.ts.map +1 -0
  141. package/dist/plugins/image-plugin.js +199 -0
  142. package/dist/plugins/image-plugin.js.map +1 -0
  143. package/dist/plugins/indent-plugin.d.ts +62 -0
  144. package/dist/plugins/indent-plugin.d.ts.map +1 -0
  145. package/dist/plugins/indent-plugin.js +128 -0
  146. package/dist/plugins/indent-plugin.js.map +1 -0
  147. package/dist/plugins/index.d.ts +45 -0
  148. package/dist/plugins/index.d.ts.map +1 -0
  149. package/dist/plugins/index.js +42 -0
  150. package/dist/plugins/index.js.map +1 -0
  151. package/dist/plugins/italic-plugin.d.ts +37 -0
  152. package/dist/plugins/italic-plugin.d.ts.map +1 -0
  153. package/dist/plugins/italic-plugin.js +48 -0
  154. package/dist/plugins/italic-plugin.js.map +1 -0
  155. package/dist/plugins/link-plugin.d.ts +129 -0
  156. package/dist/plugins/link-plugin.d.ts.map +1 -0
  157. package/dist/plugins/link-plugin.js +212 -0
  158. package/dist/plugins/link-plugin.js.map +1 -0
  159. package/dist/plugins/list-plugin.d.ts +53 -0
  160. package/dist/plugins/list-plugin.d.ts.map +1 -0
  161. package/dist/plugins/list-plugin.js +309 -0
  162. package/dist/plugins/list-plugin.js.map +1 -0
  163. package/dist/plugins/mark-utils.d.ts +173 -0
  164. package/dist/plugins/mark-utils.d.ts.map +1 -0
  165. package/dist/plugins/mark-utils.js +425 -0
  166. package/dist/plugins/mark-utils.js.map +1 -0
  167. package/dist/plugins/mention-plugin.d.ts +191 -0
  168. package/dist/plugins/mention-plugin.d.ts.map +1 -0
  169. package/dist/plugins/mention-plugin.js +295 -0
  170. package/dist/plugins/mention-plugin.js.map +1 -0
  171. package/dist/plugins/strikethrough-plugin.d.ts +37 -0
  172. package/dist/plugins/strikethrough-plugin.d.ts.map +1 -0
  173. package/dist/plugins/strikethrough-plugin.js +48 -0
  174. package/dist/plugins/strikethrough-plugin.js.map +1 -0
  175. package/dist/plugins/text-color-plugin.d.ts +57 -0
  176. package/dist/plugins/text-color-plugin.d.ts.map +1 -0
  177. package/dist/plugins/text-color-plugin.js +56 -0
  178. package/dist/plugins/text-color-plugin.js.map +1 -0
  179. package/dist/plugins/underline-plugin.d.ts +37 -0
  180. package/dist/plugins/underline-plugin.d.ts.map +1 -0
  181. package/dist/plugins/underline-plugin.js +48 -0
  182. package/dist/plugins/underline-plugin.js.map +1 -0
  183. package/dist/presets.d.ts +95 -0
  184. package/dist/presets.d.ts.map +1 -0
  185. package/dist/presets.js +159 -0
  186. package/dist/presets.js.map +1 -0
  187. package/dist/renderer.d.ts +125 -0
  188. package/dist/renderer.d.ts.map +1 -0
  189. package/dist/renderer.js +415 -0
  190. package/dist/renderer.js.map +1 -0
  191. package/dist/scroll-to-cursor.d.ts +25 -0
  192. package/dist/scroll-to-cursor.d.ts.map +1 -0
  193. package/dist/scroll-to-cursor.js +59 -0
  194. package/dist/scroll-to-cursor.js.map +1 -0
  195. package/dist/selection-sync.d.ts +159 -0
  196. package/dist/selection-sync.d.ts.map +1 -0
  197. package/dist/selection-sync.js +527 -0
  198. package/dist/selection-sync.js.map +1 -0
  199. package/dist/shortcut-handler.d.ts +98 -0
  200. package/dist/shortcut-handler.d.ts.map +1 -0
  201. package/dist/shortcut-handler.js +155 -0
  202. package/dist/shortcut-handler.js.map +1 -0
  203. package/dist/toolbar.d.ts +103 -0
  204. package/dist/toolbar.d.ts.map +1 -0
  205. package/dist/toolbar.js +134 -0
  206. package/dist/toolbar.js.map +1 -0
  207. package/dist/trigger-manager.d.ts +205 -0
  208. package/dist/trigger-manager.d.ts.map +1 -0
  209. package/dist/trigger-manager.js +466 -0
  210. package/dist/trigger-manager.js.map +1 -0
  211. package/dist/types.d.ts +216 -0
  212. package/dist/types.d.ts.map +1 -0
  213. package/dist/types.js +2 -0
  214. package/dist/types.js.map +1 -0
  215. package/package.json +30 -0
@@ -0,0 +1,884 @@
1
+ /**
2
+ * InputBridge — translates DOM `beforeinput` events into RTIF engine operations.
3
+ *
4
+ * This module is the core of the "controlled contenteditable" approach.
5
+ * It intercepts `beforeinput` events, prevents default browser behavior for
6
+ * most input types, and dispatches the corresponding RTIF operations through
7
+ * the injected dependencies.
8
+ *
9
+ * During IME composition, composition-related input types are passed through
10
+ * to the browser so that native composition rendering works correctly.
11
+ * The composition handler (separate module) emits RTIF operations on commit.
12
+ *
13
+ * @module
14
+ */
15
+ import { resolve, blockTextLength } from '@rtif-sdk/core';
16
+ // ---------------------------------------------------------------------------
17
+ // Word boundary helpers
18
+ // ---------------------------------------------------------------------------
19
+ /**
20
+ * Find the offset of the word boundary before the given local offset in block text.
21
+ *
22
+ * A "word character" is any character matching `\w` (alphanumeric + underscore).
23
+ * The algorithm: first skip any non-word characters (punctuation, whitespace),
24
+ * then skip word characters. The result is the start of the word.
25
+ *
26
+ * @param blockText - The full text of the block
27
+ * @param localOffset - The current cursor position within the block (0-indexed)
28
+ * @returns The local offset of the word boundary (always <= localOffset)
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * findWordBoundaryBackward('hello world', 11); // => 6 (start of "world")
33
+ * findWordBoundaryBackward('hello world', 5); // => 0 (start of "hello")
34
+ * ```
35
+ */
36
+ export function findWordBoundaryBackward(blockText, localOffset) {
37
+ let pos = localOffset;
38
+ // Phase 1: Skip non-word characters (whitespace, punctuation)
39
+ while (pos > 0 && !/\w/.test(blockText[pos - 1])) {
40
+ pos--;
41
+ }
42
+ // Phase 2: Skip word characters
43
+ while (pos > 0 && /\w/.test(blockText[pos - 1])) {
44
+ pos--;
45
+ }
46
+ return pos;
47
+ }
48
+ /**
49
+ * Find the offset of the word boundary after the given local offset in block text.
50
+ *
51
+ * The algorithm: first skip word characters, then skip non-word characters
52
+ * (whitespace, punctuation). The result is the position after the word
53
+ * and its trailing whitespace.
54
+ *
55
+ * @param blockText - The full text of the block
56
+ * @param localOffset - The current cursor position within the block (0-indexed)
57
+ * @returns The local offset of the word boundary (always >= localOffset)
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * findWordBoundaryForward('hello world', 0); // => 6 (after "hello ")
62
+ * findWordBoundaryForward('hello world', 6); // => 11 (after "world")
63
+ * ```
64
+ */
65
+ export function findWordBoundaryForward(blockText, localOffset) {
66
+ const len = blockText.length;
67
+ let pos = localOffset;
68
+ // Phase 1: Skip word characters
69
+ while (pos < len && /\w/.test(blockText[pos])) {
70
+ pos++;
71
+ }
72
+ // Phase 2: Skip non-word characters (whitespace, punctuation)
73
+ while (pos < len && !/\w/.test(blockText[pos])) {
74
+ pos++;
75
+ }
76
+ return pos;
77
+ }
78
+ // ---------------------------------------------------------------------------
79
+ // Selection deletion helpers
80
+ // ---------------------------------------------------------------------------
81
+ /**
82
+ * Compute the absolute offset where a block's text begins in the document.
83
+ *
84
+ * @param doc - The document
85
+ * @param blockIndex - Index of the target block
86
+ * @returns The absolute offset of the first character in the block
87
+ */
88
+ function blockStartOffset(doc, blockIndex) {
89
+ let offset = 0;
90
+ for (let i = 0; i < blockIndex; i++) {
91
+ const block = doc.blocks[i];
92
+ if (!block)
93
+ break;
94
+ offset += blockTextLength(block);
95
+ offset += 1; // virtual \n separator
96
+ }
97
+ return offset;
98
+ }
99
+ /**
100
+ * Compute the operations needed to delete a selection range.
101
+ *
102
+ * For single-block selections, returns a single `delete_text`. For cross-block
103
+ * selections, composes `delete_text` and `merge_block` operations that the
104
+ * engine can apply sequentially.
105
+ *
106
+ * The algorithm works forward through the blocks:
107
+ * 1. Delete the tail of the start block (from startLocalOffset to end).
108
+ * 2. For each subsequent block up to and including the end block:
109
+ * a. Merge it into the previous block.
110
+ * b. Delete the appropriate text (all text for intermediate blocks,
111
+ * or endLocalOffset characters for the end block).
112
+ *
113
+ * After each merge, the text from the merged block appears at the position
114
+ * where the start block's tail was deleted, so all delete offsets are the
115
+ * same absolute offset (the start of the selection).
116
+ *
117
+ * @param doc - The current document
118
+ * @param selection - The selection to delete (may be forward or backward)
119
+ * @returns Array of operations to dispatch. Empty if selection is collapsed.
120
+ *
121
+ * @example
122
+ * ```ts
123
+ * // Single-block selection
124
+ * const ops = deleteSelectionOps(doc, { anchor: { offset: 2 }, focus: { offset: 7 } });
125
+ * // => [{ type: 'delete_text', offset: 2, count: 5 }]
126
+ *
127
+ * // Cross-block selection
128
+ * const ops = deleteSelectionOps(doc, { anchor: { offset: 3 }, focus: { offset: 8 } });
129
+ * // => [delete_text, merge_block, delete_text, ...]
130
+ * ```
131
+ */
132
+ export function deleteSelectionOps(doc, selection) {
133
+ const startAbs = Math.min(selection.anchor.offset, selection.focus.offset);
134
+ const endAbs = Math.max(selection.anchor.offset, selection.focus.offset);
135
+ if (startAbs === endAbs) {
136
+ return [];
137
+ }
138
+ const startResolved = resolve(doc, startAbs);
139
+ const endResolved = resolve(doc, endAbs);
140
+ // Same block: simple delete_text
141
+ if (startResolved.blockIndex === endResolved.blockIndex) {
142
+ return [
143
+ { type: 'delete_text', offset: startAbs, count: endAbs - startAbs },
144
+ ];
145
+ }
146
+ // Cross-block deletion
147
+ const ops = [];
148
+ const startBI = startResolved.blockIndex;
149
+ const endBI = endResolved.blockIndex;
150
+ const startLocal = startResolved.localOffset;
151
+ const endLocal = endResolved.localOffset;
152
+ const startBlock = doc.blocks[startBI];
153
+ if (!startBlock)
154
+ return ops;
155
+ // Step 1: Delete the tail of the start block
156
+ const startBlockLen = blockTextLength(startBlock);
157
+ const tailLen = startBlockLen - startLocal;
158
+ if (tailLen > 0) {
159
+ ops.push({ type: 'delete_text', offset: startAbs, count: tailLen });
160
+ }
161
+ // Step 2: For each block from startBI+1 through endBI
162
+ for (let i = startBI + 1; i <= endBI; i++) {
163
+ const block = doc.blocks[i];
164
+ if (!block)
165
+ continue;
166
+ // Merge this block into the previous (which is now the start block)
167
+ ops.push({ type: 'merge_block', blockId: block.id });
168
+ // Determine how much text to delete from the merged content
169
+ const deleteCount = i === endBI ? endLocal : blockTextLength(block);
170
+ if (deleteCount > 0) {
171
+ ops.push({ type: 'delete_text', offset: startAbs, count: deleteCount });
172
+ }
173
+ }
174
+ return ops;
175
+ }
176
+ // ---------------------------------------------------------------------------
177
+ // Block text extraction helper
178
+ // ---------------------------------------------------------------------------
179
+ /**
180
+ * Get the full text of a block by concatenating all span texts.
181
+ *
182
+ * @param block - The block to extract text from
183
+ * @returns The concatenated text of all spans
184
+ */
185
+ function getBlockText(doc, blockIndex) {
186
+ const block = doc.blocks[blockIndex];
187
+ if (!block)
188
+ return '';
189
+ let text = '';
190
+ for (const span of block.spans) {
191
+ text += span.text;
192
+ }
193
+ return text;
194
+ }
195
+ // ---------------------------------------------------------------------------
196
+ // InputBridge
197
+ // ---------------------------------------------------------------------------
198
+ /**
199
+ * The InputBridge intercepts DOM `beforeinput` events on a contenteditable
200
+ * element and translates them into RTIF operations.
201
+ *
202
+ * This is the "controlled contenteditable" approach: most input types are
203
+ * prevented and handled through the RTIF engine, while IME composition
204
+ * events are passed through to the browser for native rendering.
205
+ *
206
+ * @example
207
+ * ```ts
208
+ * const bridge = new InputBridge(root, {
209
+ * getSelection: () => engine.state.selection,
210
+ * getDoc: () => engine.state.doc,
211
+ * dispatch: (ops) => engine.dispatch(ops),
212
+ * undo: () => engine.undo(),
213
+ * redo: () => engine.redo(),
214
+ * isComposing: () => compositionHandler.isComposing(),
215
+ * isReadOnly: () => false,
216
+ * generateBlockId: () => crypto.randomUUID(),
217
+ * });
218
+ * bridge.attach();
219
+ * ```
220
+ */
221
+ export class InputBridge {
222
+ _root;
223
+ _deps;
224
+ _attached = false;
225
+ /**
226
+ * Bound reference to the beforeinput handler so we can add/remove
227
+ * the same function reference.
228
+ */
229
+ _boundHandler;
230
+ constructor(root, deps) {
231
+ this._root = root;
232
+ this._deps = deps;
233
+ this._boundHandler = (e) => {
234
+ // Only handle InputEvent instances
235
+ if (e instanceof InputEvent) {
236
+ this.handleBeforeInput(e);
237
+ }
238
+ };
239
+ }
240
+ /**
241
+ * Attach the `beforeinput` event listener to the root element.
242
+ *
243
+ * Safe to call multiple times — only one listener is registered.
244
+ *
245
+ * @example
246
+ * ```ts
247
+ * bridge.attach();
248
+ * ```
249
+ */
250
+ attach() {
251
+ if (this._attached)
252
+ return;
253
+ this._root.addEventListener('beforeinput', this._boundHandler);
254
+ this._attached = true;
255
+ }
256
+ /**
257
+ * Remove the `beforeinput` event listener from the root element.
258
+ *
259
+ * Safe to call without a prior `attach()`.
260
+ *
261
+ * @example
262
+ * ```ts
263
+ * bridge.detach();
264
+ * ```
265
+ */
266
+ detach() {
267
+ if (!this._attached)
268
+ return;
269
+ this._root.removeEventListener('beforeinput', this._boundHandler);
270
+ this._attached = false;
271
+ }
272
+ /**
273
+ * Handle a `beforeinput` event by translating its `inputType` into
274
+ * RTIF operations.
275
+ *
276
+ * This method is public for testing. In production it is called by the
277
+ * bound event listener installed via `attach()`.
278
+ *
279
+ * @param e - The InputEvent from the `beforeinput` event
280
+ *
281
+ * @example
282
+ * ```ts
283
+ * // Typically called via the event listener, but can be called directly:
284
+ * bridge.handleBeforeInput(inputEvent);
285
+ * ```
286
+ */
287
+ handleBeforeInput(e) {
288
+ const inputType = e.inputType;
289
+ // ---------------------------------------------------------------
290
+ // Composition passthrough: let the browser handle IME mutations
291
+ // ---------------------------------------------------------------
292
+ if (this._deps.isComposing() && isCompositionInputType(inputType)) {
293
+ return;
294
+ }
295
+ // ---------------------------------------------------------------
296
+ // Read-only mode: block all mutations
297
+ // ---------------------------------------------------------------
298
+ if (this._deps.isReadOnly()) {
299
+ e.preventDefault();
300
+ return;
301
+ }
302
+ // ---------------------------------------------------------------
303
+ // Dispatch based on inputType
304
+ // ---------------------------------------------------------------
305
+ switch (inputType) {
306
+ case 'insertText':
307
+ e.preventDefault();
308
+ this._handleInsertText(e);
309
+ break;
310
+ case 'insertParagraph':
311
+ e.preventDefault();
312
+ this._handleSplitBlock();
313
+ break;
314
+ case 'insertLineBreak':
315
+ e.preventDefault();
316
+ this._handleInsertLineBreak();
317
+ break;
318
+ case 'deleteContentBackward':
319
+ e.preventDefault();
320
+ this._handleDeleteBackward();
321
+ break;
322
+ case 'deleteContentForward':
323
+ e.preventDefault();
324
+ this._handleDeleteForward();
325
+ break;
326
+ case 'deleteWordBackward':
327
+ e.preventDefault();
328
+ this._handleDeleteWordBackward();
329
+ break;
330
+ case 'deleteWordForward':
331
+ e.preventDefault();
332
+ this._handleDeleteWordForward();
333
+ break;
334
+ case 'deleteSoftLineBackward':
335
+ case 'deleteHardLineBackward':
336
+ e.preventDefault();
337
+ this._handleDeleteLineBackward();
338
+ break;
339
+ case 'deleteSoftLineForward':
340
+ case 'deleteHardLineForward':
341
+ e.preventDefault();
342
+ this._handleDeleteLineForward();
343
+ break;
344
+ case 'insertCompositionText':
345
+ // Not composing but got composition text — treat as regular text
346
+ e.preventDefault();
347
+ this._handleInsertText(e);
348
+ break;
349
+ case 'deleteByComposition':
350
+ case 'deleteCompositionText':
351
+ // Not composing — treat as backspace
352
+ e.preventDefault();
353
+ this._handleDeleteBackward();
354
+ break;
355
+ case 'insertReplacementText':
356
+ e.preventDefault();
357
+ this._handleInsertReplacement(e);
358
+ break;
359
+ case 'historyUndo':
360
+ e.preventDefault();
361
+ this._deps.undo();
362
+ break;
363
+ case 'historyRedo':
364
+ e.preventDefault();
365
+ this._deps.redo();
366
+ break;
367
+ // Clipboard events — handled by clipboard module, just prevent here
368
+ case 'deleteByCut':
369
+ case 'insertFromPaste':
370
+ case 'insertFromDrop':
371
+ e.preventDefault();
372
+ break;
373
+ // Format events — not handled in Phase 1
374
+ case 'formatBold':
375
+ case 'formatItalic':
376
+ case 'formatUnderline':
377
+ case 'formatStrikeThrough':
378
+ case 'formatSuperscript':
379
+ case 'formatSubscript':
380
+ case 'formatJustifyFull':
381
+ case 'formatJustifyCenter':
382
+ case 'formatJustifyRight':
383
+ case 'formatJustifyLeft':
384
+ case 'formatIndent':
385
+ case 'formatOutdent':
386
+ case 'formatRemove':
387
+ case 'formatSetBlockTextDirection':
388
+ case 'formatSetInlineTextDirection':
389
+ case 'formatBackColor':
390
+ case 'formatFontColor':
391
+ case 'formatFontName':
392
+ e.preventDefault();
393
+ break;
394
+ default:
395
+ // Unknown input type — prevent to avoid uncontrolled DOM mutations
396
+ e.preventDefault();
397
+ break;
398
+ }
399
+ }
400
+ // -----------------------------------------------------------------------
401
+ // Private handlers
402
+ // -----------------------------------------------------------------------
403
+ /**
404
+ * Handle `insertText` and `insertCompositionText` (when not composing).
405
+ *
406
+ * When pending marks are present (e.g., user toggled bold with collapsed cursor),
407
+ * appends a `set_span_marks` operation after the `insert_text` to apply those
408
+ * marks to the newly inserted text, then clears the pending marks.
409
+ */
410
+ _handleInsertText(e) {
411
+ const text = e.data;
412
+ if (!text)
413
+ return;
414
+ const sel = this._deps.getSelection();
415
+ const startAbs = Math.min(sel.anchor.offset, sel.focus.offset);
416
+ // Check for pending marks
417
+ const pendingMarks = this._deps.getPendingMarks?.() ?? {};
418
+ const hasPendingMarks = Object.keys(pendingMarks).length > 0;
419
+ const ops = [];
420
+ if (sel.anchor.offset !== sel.focus.offset) {
421
+ // Non-collapsed: delete selection, then insert
422
+ const doc = this._deps.getDoc();
423
+ const deleteOps = deleteSelectionOps(doc, sel);
424
+ ops.push(...deleteOps);
425
+ }
426
+ const insertOffset = sel.anchor.offset !== sel.focus.offset ? startAbs : sel.focus.offset;
427
+ ops.push({ type: 'insert_text', offset: insertOffset, text });
428
+ // Apply pending marks to the newly inserted text
429
+ if (hasPendingMarks) {
430
+ ops.push({
431
+ type: 'set_span_marks',
432
+ offset: insertOffset,
433
+ count: text.length,
434
+ marks: pendingMarks,
435
+ });
436
+ }
437
+ if (ops.length === 1) {
438
+ this._deps.dispatch(ops[0]);
439
+ }
440
+ else {
441
+ this._deps.dispatch(ops);
442
+ }
443
+ // Clear pending marks after use
444
+ if (hasPendingMarks) {
445
+ this._deps.clearPendingMarks?.();
446
+ }
447
+ }
448
+ /**
449
+ * Handle `insertLineBreak` (Shift+Enter).
450
+ * Inserts a newline character within the current block (soft line break).
451
+ */
452
+ _handleInsertLineBreak() {
453
+ const sel = this._deps.getSelection();
454
+ const startAbs = Math.min(sel.anchor.offset, sel.focus.offset);
455
+ const pendingMarks = this._deps.getPendingMarks?.() ?? {};
456
+ const hasPendingMarks = Object.keys(pendingMarks).length > 0;
457
+ const ops = [];
458
+ if (sel.anchor.offset !== sel.focus.offset) {
459
+ const doc = this._deps.getDoc();
460
+ const deleteOps = deleteSelectionOps(doc, sel);
461
+ ops.push(...deleteOps);
462
+ }
463
+ const insertOffset = sel.anchor.offset !== sel.focus.offset ? startAbs : sel.focus.offset;
464
+ ops.push({ type: 'insert_text', offset: insertOffset, text: '\n' });
465
+ if (hasPendingMarks) {
466
+ ops.push({
467
+ type: 'set_span_marks',
468
+ offset: insertOffset,
469
+ count: 1,
470
+ marks: pendingMarks,
471
+ });
472
+ }
473
+ if (ops.length === 1) {
474
+ this._deps.dispatch(ops[0]);
475
+ }
476
+ else {
477
+ this._deps.dispatch(ops);
478
+ }
479
+ if (hasPendingMarks) {
480
+ this._deps.clearPendingMarks?.();
481
+ }
482
+ }
483
+ /**
484
+ * Handle `insertParagraph` (Enter key).
485
+ */
486
+ _handleSplitBlock() {
487
+ const sel = this._deps.getSelection();
488
+ const startAbs = Math.min(sel.anchor.offset, sel.focus.offset);
489
+ const newBlockId = this._deps.generateBlockId();
490
+ if (sel.anchor.offset !== sel.focus.offset) {
491
+ const doc = this._deps.getDoc();
492
+ const deleteOps = deleteSelectionOps(doc, sel);
493
+ this._deps.dispatch([
494
+ ...deleteOps,
495
+ { type: 'split_block', offset: startAbs, newBlockId },
496
+ ]);
497
+ }
498
+ else {
499
+ this._deps.dispatch({
500
+ type: 'split_block',
501
+ offset: sel.focus.offset,
502
+ newBlockId,
503
+ });
504
+ }
505
+ }
506
+ /**
507
+ * Handle `deleteContentBackward` (Backspace, 1 character).
508
+ */
509
+ _handleDeleteBackward() {
510
+ const sel = this._deps.getSelection();
511
+ const doc = this._deps.getDoc();
512
+ // Non-collapsed selection: delete the selection
513
+ if (sel.anchor.offset !== sel.focus.offset) {
514
+ const ops = deleteSelectionOps(doc, sel);
515
+ if (ops.length > 0) {
516
+ this._deps.dispatch(ops.length === 1 ? ops[0] : ops);
517
+ }
518
+ return;
519
+ }
520
+ // Collapsed cursor
521
+ const offset = sel.focus.offset;
522
+ if (offset === 0) {
523
+ // At start of first block — revert empty non-text blocks to text
524
+ const block = doc.blocks[0];
525
+ if (block && block.type !== 'text' && blockTextLength(block) === 0) {
526
+ this._deps.dispatch({
527
+ type: 'set_block_type',
528
+ blockId: block.id,
529
+ blockType: 'text',
530
+ });
531
+ }
532
+ return;
533
+ }
534
+ const resolved = resolve(doc, offset);
535
+ if (resolved.localOffset > 0) {
536
+ // Check if the character before cursor is inside an atomic mark
537
+ const atomicRange = this._deps.findAtomicMarkRange?.(offset - 1);
538
+ if (atomicRange) {
539
+ this._deps.dispatch({
540
+ type: 'delete_text',
541
+ offset: atomicRange.offset,
542
+ count: atomicRange.count,
543
+ });
544
+ return;
545
+ }
546
+ // Delete one character before cursor
547
+ this._deps.dispatch({
548
+ type: 'delete_text',
549
+ offset: offset - 1,
550
+ count: 1,
551
+ });
552
+ }
553
+ else if (resolved.blockIndex > 0) {
554
+ // At start of a non-first block — merge with previous
555
+ const block = doc.blocks[resolved.blockIndex];
556
+ const prevBlock = doc.blocks[resolved.blockIndex - 1];
557
+ if (block && prevBlock) {
558
+ // If the previous block is atomic with empty text, merge but restore
559
+ // the current block's type/attrs to prevent corrupt state.
560
+ if (this._deps.isAtomicBlock?.(prevBlock.type) &&
561
+ blockTextLength(prevBlock) === 0) {
562
+ const ops = [
563
+ { type: 'merge_block', blockId: block.id },
564
+ { type: 'set_block_type', blockId: prevBlock.id, blockType: block.type },
565
+ ];
566
+ if (block.attrs && Object.keys(block.attrs).length > 0) {
567
+ ops.push({ type: 'set_block_attrs', blockId: prevBlock.id, attrs: block.attrs });
568
+ }
569
+ this._deps.dispatch(ops);
570
+ return;
571
+ }
572
+ this._deps.dispatch({
573
+ type: 'merge_block',
574
+ blockId: block.id,
575
+ });
576
+ }
577
+ }
578
+ }
579
+ /**
580
+ * Handle `deleteContentForward` (Delete key, 1 character).
581
+ */
582
+ _handleDeleteForward() {
583
+ const sel = this._deps.getSelection();
584
+ const doc = this._deps.getDoc();
585
+ // Non-collapsed selection: delete the selection
586
+ if (sel.anchor.offset !== sel.focus.offset) {
587
+ const ops = deleteSelectionOps(doc, sel);
588
+ if (ops.length > 0) {
589
+ this._deps.dispatch(ops.length === 1 ? ops[0] : ops);
590
+ }
591
+ return;
592
+ }
593
+ const offset = sel.focus.offset;
594
+ const resolved = resolve(doc, offset);
595
+ const block = doc.blocks[resolved.blockIndex];
596
+ if (!block)
597
+ return;
598
+ const bLen = blockTextLength(block);
599
+ if (resolved.localOffset < bLen) {
600
+ // Check if the character after cursor is inside an atomic mark
601
+ const atomicRange = this._deps.findAtomicMarkRange?.(offset);
602
+ if (atomicRange) {
603
+ this._deps.dispatch({
604
+ type: 'delete_text',
605
+ offset: atomicRange.offset,
606
+ count: atomicRange.count,
607
+ });
608
+ return;
609
+ }
610
+ // Delete one character after cursor
611
+ this._deps.dispatch({
612
+ type: 'delete_text',
613
+ offset,
614
+ count: 1,
615
+ });
616
+ }
617
+ else if (resolved.blockIndex < doc.blocks.length - 1) {
618
+ // At end of non-last block — merge next block into current
619
+ const nextBlock = doc.blocks[resolved.blockIndex + 1];
620
+ if (nextBlock) {
621
+ // If the next block is atomic with empty text, simply remove it
622
+ // by merging and restoring the current block's type/attrs.
623
+ if (this._deps.isAtomicBlock?.(nextBlock.type) &&
624
+ blockTextLength(nextBlock) === 0) {
625
+ const currentBlock = doc.blocks[resolved.blockIndex];
626
+ const ops = [
627
+ { type: 'merge_block', blockId: nextBlock.id },
628
+ { type: 'set_block_type', blockId: currentBlock.id, blockType: currentBlock.type },
629
+ ];
630
+ if (currentBlock.attrs && Object.keys(currentBlock.attrs).length > 0) {
631
+ ops.push({ type: 'set_block_attrs', blockId: currentBlock.id, attrs: currentBlock.attrs });
632
+ }
633
+ this._deps.dispatch(ops);
634
+ return;
635
+ }
636
+ this._deps.dispatch({
637
+ type: 'merge_block',
638
+ blockId: nextBlock.id,
639
+ });
640
+ }
641
+ }
642
+ // At end of last block — no-op
643
+ }
644
+ /**
645
+ * Handle `deleteWordBackward` (Ctrl/Option + Backspace).
646
+ */
647
+ _handleDeleteWordBackward() {
648
+ const sel = this._deps.getSelection();
649
+ const doc = this._deps.getDoc();
650
+ // Non-collapsed selection: delete the selection
651
+ if (sel.anchor.offset !== sel.focus.offset) {
652
+ const ops = deleteSelectionOps(doc, sel);
653
+ if (ops.length > 0) {
654
+ this._deps.dispatch(ops.length === 1 ? ops[0] : ops);
655
+ }
656
+ return;
657
+ }
658
+ const offset = sel.focus.offset;
659
+ const resolved = resolve(doc, offset);
660
+ if (resolved.localOffset === 0) {
661
+ // At start of block — merge with previous (same as single backspace)
662
+ if (resolved.blockIndex > 0) {
663
+ const block = doc.blocks[resolved.blockIndex];
664
+ if (block) {
665
+ this._deps.dispatch({
666
+ type: 'merge_block',
667
+ blockId: block.id,
668
+ });
669
+ }
670
+ }
671
+ return;
672
+ }
673
+ // Check if the character before cursor is inside an atomic mark
674
+ const atomicRange = this._deps.findAtomicMarkRange?.(offset - 1);
675
+ if (atomicRange) {
676
+ this._deps.dispatch({
677
+ type: 'delete_text',
678
+ offset: atomicRange.offset,
679
+ count: atomicRange.count,
680
+ });
681
+ return;
682
+ }
683
+ // Find word boundary backward
684
+ const blockText = getBlockText(doc, resolved.blockIndex);
685
+ const wordBoundary = findWordBoundaryBackward(blockText, resolved.localOffset);
686
+ const bStart = blockStartOffset(doc, resolved.blockIndex);
687
+ const deleteFrom = bStart + wordBoundary;
688
+ const deleteCount = offset - deleteFrom;
689
+ if (deleteCount > 0) {
690
+ this._deps.dispatch({
691
+ type: 'delete_text',
692
+ offset: deleteFrom,
693
+ count: deleteCount,
694
+ });
695
+ }
696
+ }
697
+ /**
698
+ * Handle `deleteWordForward` (Ctrl/Option + Delete).
699
+ */
700
+ _handleDeleteWordForward() {
701
+ const sel = this._deps.getSelection();
702
+ const doc = this._deps.getDoc();
703
+ // Non-collapsed selection: delete the selection
704
+ if (sel.anchor.offset !== sel.focus.offset) {
705
+ const ops = deleteSelectionOps(doc, sel);
706
+ if (ops.length > 0) {
707
+ this._deps.dispatch(ops.length === 1 ? ops[0] : ops);
708
+ }
709
+ return;
710
+ }
711
+ const offset = sel.focus.offset;
712
+ const resolved = resolve(doc, offset);
713
+ const block = doc.blocks[resolved.blockIndex];
714
+ if (!block)
715
+ return;
716
+ const bLen = blockTextLength(block);
717
+ if (resolved.localOffset >= bLen) {
718
+ // At end of block — merge next block (if exists)
719
+ if (resolved.blockIndex < doc.blocks.length - 1) {
720
+ const nextBlock = doc.blocks[resolved.blockIndex + 1];
721
+ if (nextBlock) {
722
+ this._deps.dispatch({
723
+ type: 'merge_block',
724
+ blockId: nextBlock.id,
725
+ });
726
+ }
727
+ }
728
+ return;
729
+ }
730
+ // Check if the character after cursor is inside an atomic mark
731
+ const atomicRange = this._deps.findAtomicMarkRange?.(offset);
732
+ if (atomicRange) {
733
+ this._deps.dispatch({
734
+ type: 'delete_text',
735
+ offset: atomicRange.offset,
736
+ count: atomicRange.count,
737
+ });
738
+ return;
739
+ }
740
+ // Find word boundary forward
741
+ const blockText = getBlockText(doc, resolved.blockIndex);
742
+ const wordBoundary = findWordBoundaryForward(blockText, resolved.localOffset);
743
+ const deleteCount = wordBoundary - resolved.localOffset;
744
+ if (deleteCount > 0) {
745
+ this._deps.dispatch({
746
+ type: 'delete_text',
747
+ offset,
748
+ count: deleteCount,
749
+ });
750
+ }
751
+ }
752
+ /**
753
+ * Handle `deleteSoftLineBackward` and `deleteHardLineBackward`
754
+ * (Cmd+Backspace on Mac).
755
+ */
756
+ _handleDeleteLineBackward() {
757
+ const sel = this._deps.getSelection();
758
+ const doc = this._deps.getDoc();
759
+ // Non-collapsed selection: delete the selection
760
+ if (sel.anchor.offset !== sel.focus.offset) {
761
+ const ops = deleteSelectionOps(doc, sel);
762
+ if (ops.length > 0) {
763
+ this._deps.dispatch(ops.length === 1 ? ops[0] : ops);
764
+ }
765
+ return;
766
+ }
767
+ const offset = sel.focus.offset;
768
+ const resolved = resolve(doc, offset);
769
+ if (resolved.localOffset === 0) {
770
+ // At start of block — merge with previous
771
+ if (resolved.blockIndex > 0) {
772
+ const block = doc.blocks[resolved.blockIndex];
773
+ if (block) {
774
+ this._deps.dispatch({
775
+ type: 'merge_block',
776
+ blockId: block.id,
777
+ });
778
+ }
779
+ }
780
+ return;
781
+ }
782
+ // Delete from start of block to cursor
783
+ const bStart = blockStartOffset(doc, resolved.blockIndex);
784
+ this._deps.dispatch({
785
+ type: 'delete_text',
786
+ offset: bStart,
787
+ count: resolved.localOffset,
788
+ });
789
+ }
790
+ /**
791
+ * Handle `deleteSoftLineForward` and `deleteHardLineForward`
792
+ * (Cmd+Delete on Mac).
793
+ */
794
+ _handleDeleteLineForward() {
795
+ const sel = this._deps.getSelection();
796
+ const doc = this._deps.getDoc();
797
+ // Non-collapsed selection: delete the selection
798
+ if (sel.anchor.offset !== sel.focus.offset) {
799
+ const ops = deleteSelectionOps(doc, sel);
800
+ if (ops.length > 0) {
801
+ this._deps.dispatch(ops.length === 1 ? ops[0] : ops);
802
+ }
803
+ return;
804
+ }
805
+ const offset = sel.focus.offset;
806
+ const resolved = resolve(doc, offset);
807
+ const block = doc.blocks[resolved.blockIndex];
808
+ if (!block)
809
+ return;
810
+ const bLen = blockTextLength(block);
811
+ if (resolved.localOffset >= bLen) {
812
+ // At end of block — merge next block if exists
813
+ if (resolved.blockIndex < doc.blocks.length - 1) {
814
+ const nextBlock = doc.blocks[resolved.blockIndex + 1];
815
+ if (nextBlock) {
816
+ this._deps.dispatch({
817
+ type: 'merge_block',
818
+ blockId: nextBlock.id,
819
+ });
820
+ }
821
+ }
822
+ return;
823
+ }
824
+ // Delete from cursor to end of block
825
+ const deleteCount = bLen - resolved.localOffset;
826
+ if (deleteCount > 0) {
827
+ this._deps.dispatch({
828
+ type: 'delete_text',
829
+ offset,
830
+ count: deleteCount,
831
+ });
832
+ }
833
+ }
834
+ /**
835
+ * Handle `insertReplacementText` (spellcheck/autocorrect acceptance).
836
+ *
837
+ * Reads the replacement text from `e.dataTransfer` or `e.data`, and
838
+ * the target range from `e.getTargetRanges()`. Falls back to treating
839
+ * the current selection as the target range if target ranges are
840
+ * unavailable.
841
+ */
842
+ _handleInsertReplacement(e) {
843
+ const replacementText = e.dataTransfer?.getData('text/plain') ?? e.data ?? null;
844
+ if (!replacementText)
845
+ return;
846
+ const sel = this._deps.getSelection();
847
+ const startAbs = Math.min(sel.anchor.offset, sel.focus.offset);
848
+ const endAbs = Math.max(sel.anchor.offset, sel.focus.offset);
849
+ const count = endAbs - startAbs;
850
+ if (count > 0) {
851
+ this._deps.dispatch([
852
+ { type: 'delete_text', offset: startAbs, count },
853
+ { type: 'insert_text', offset: startAbs, text: replacementText },
854
+ ]);
855
+ }
856
+ else {
857
+ // Collapsed cursor — just insert
858
+ this._deps.dispatch({
859
+ type: 'insert_text',
860
+ offset: startAbs,
861
+ text: replacementText,
862
+ });
863
+ }
864
+ }
865
+ }
866
+ // ---------------------------------------------------------------------------
867
+ // Helpers
868
+ // ---------------------------------------------------------------------------
869
+ /**
870
+ * Input types that should be passed through during IME composition.
871
+ */
872
+ const COMPOSITION_INPUT_TYPES = new Set([
873
+ 'insertCompositionText',
874
+ 'deleteCompositionText',
875
+ 'deleteByComposition',
876
+ ]);
877
+ /**
878
+ * Check whether an inputType is a composition-related type that should
879
+ * be passed through to the browser during an active composition session.
880
+ */
881
+ function isCompositionInputType(inputType) {
882
+ return COMPOSITION_INPUT_TYPES.has(inputType);
883
+ }
884
+ //# sourceMappingURL=input-bridge.js.map