@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.
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/block-drag-handler.d.ts +189 -0
- package/dist/block-drag-handler.d.ts.map +1 -0
- package/dist/block-drag-handler.js +745 -0
- package/dist/block-drag-handler.js.map +1 -0
- package/dist/block-renderer.d.ts +402 -0
- package/dist/block-renderer.d.ts.map +1 -0
- package/dist/block-renderer.js +424 -0
- package/dist/block-renderer.js.map +1 -0
- package/dist/clipboard.d.ts +178 -0
- package/dist/clipboard.d.ts.map +1 -0
- package/dist/clipboard.js +432 -0
- package/dist/clipboard.js.map +1 -0
- package/dist/command-bus.d.ts +113 -0
- package/dist/command-bus.d.ts.map +1 -0
- package/dist/command-bus.js +70 -0
- package/dist/command-bus.js.map +1 -0
- package/dist/composition.d.ts +220 -0
- package/dist/composition.d.ts.map +1 -0
- package/dist/composition.js +271 -0
- package/dist/composition.js.map +1 -0
- package/dist/content-extraction.d.ts +69 -0
- package/dist/content-extraction.d.ts.map +1 -0
- package/dist/content-extraction.js +228 -0
- package/dist/content-extraction.js.map +1 -0
- package/dist/content-handler-file.d.ts +40 -0
- package/dist/content-handler-file.d.ts.map +1 -0
- package/dist/content-handler-file.js +91 -0
- package/dist/content-handler-file.js.map +1 -0
- package/dist/content-handler-image.d.ts +82 -0
- package/dist/content-handler-image.d.ts.map +1 -0
- package/dist/content-handler-image.js +120 -0
- package/dist/content-handler-image.js.map +1 -0
- package/dist/content-handler-url.d.ts +129 -0
- package/dist/content-handler-url.d.ts.map +1 -0
- package/dist/content-handler-url.js +244 -0
- package/dist/content-handler-url.js.map +1 -0
- package/dist/content-handlers.d.ts +67 -0
- package/dist/content-handlers.d.ts.map +1 -0
- package/dist/content-handlers.js +263 -0
- package/dist/content-handlers.js.map +1 -0
- package/dist/content-pipeline.d.ts +383 -0
- package/dist/content-pipeline.d.ts.map +1 -0
- package/dist/content-pipeline.js +232 -0
- package/dist/content-pipeline.js.map +1 -0
- package/dist/cursor-nav.d.ts +149 -0
- package/dist/cursor-nav.d.ts.map +1 -0
- package/dist/cursor-nav.js +230 -0
- package/dist/cursor-nav.js.map +1 -0
- package/dist/cursor-rect.d.ts +65 -0
- package/dist/cursor-rect.d.ts.map +1 -0
- package/dist/cursor-rect.js +98 -0
- package/dist/cursor-rect.js.map +1 -0
- package/dist/drop-indicator.d.ts +108 -0
- package/dist/drop-indicator.d.ts.map +1 -0
- package/dist/drop-indicator.js +236 -0
- package/dist/drop-indicator.js.map +1 -0
- package/dist/editor.d.ts +41 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +710 -0
- package/dist/editor.js.map +1 -0
- package/dist/floating-toolbar.d.ts +93 -0
- package/dist/floating-toolbar.d.ts.map +1 -0
- package/dist/floating-toolbar.js +159 -0
- package/dist/floating-toolbar.js.map +1 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +119 -0
- package/dist/index.js.map +1 -0
- package/dist/input-bridge.d.ts +273 -0
- package/dist/input-bridge.d.ts.map +1 -0
- package/dist/input-bridge.js +884 -0
- package/dist/input-bridge.js.map +1 -0
- package/dist/link-popover.d.ts +38 -0
- package/dist/link-popover.d.ts.map +1 -0
- package/dist/link-popover.js +278 -0
- package/dist/link-popover.js.map +1 -0
- package/dist/mark-renderer.d.ts +275 -0
- package/dist/mark-renderer.d.ts.map +1 -0
- package/dist/mark-renderer.js +210 -0
- package/dist/mark-renderer.js.map +1 -0
- package/dist/perf.d.ts +145 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/perf.js +260 -0
- package/dist/perf.js.map +1 -0
- package/dist/plugin-kit.d.ts +265 -0
- package/dist/plugin-kit.d.ts.map +1 -0
- package/dist/plugin-kit.js +234 -0
- package/dist/plugin-kit.js.map +1 -0
- package/dist/plugins/alignment-plugin.d.ts +68 -0
- package/dist/plugins/alignment-plugin.d.ts.map +1 -0
- package/dist/plugins/alignment-plugin.js +98 -0
- package/dist/plugins/alignment-plugin.js.map +1 -0
- package/dist/plugins/block-utils.d.ts +113 -0
- package/dist/plugins/block-utils.d.ts.map +1 -0
- package/dist/plugins/block-utils.js +191 -0
- package/dist/plugins/block-utils.js.map +1 -0
- package/dist/plugins/blockquote-plugin.d.ts +39 -0
- package/dist/plugins/blockquote-plugin.d.ts.map +1 -0
- package/dist/plugins/blockquote-plugin.js +88 -0
- package/dist/plugins/blockquote-plugin.js.map +1 -0
- package/dist/plugins/bold-plugin.d.ts +37 -0
- package/dist/plugins/bold-plugin.d.ts.map +1 -0
- package/dist/plugins/bold-plugin.js +48 -0
- package/dist/plugins/bold-plugin.js.map +1 -0
- package/dist/plugins/callout-plugin.d.ts +100 -0
- package/dist/plugins/callout-plugin.d.ts.map +1 -0
- package/dist/plugins/callout-plugin.js +200 -0
- package/dist/plugins/callout-plugin.js.map +1 -0
- package/dist/plugins/code-block-plugin.d.ts +62 -0
- package/dist/plugins/code-block-plugin.d.ts.map +1 -0
- package/dist/plugins/code-block-plugin.js +176 -0
- package/dist/plugins/code-block-plugin.js.map +1 -0
- package/dist/plugins/code-plugin.d.ts +37 -0
- package/dist/plugins/code-plugin.d.ts.map +1 -0
- package/dist/plugins/code-plugin.js +48 -0
- package/dist/plugins/code-plugin.js.map +1 -0
- package/dist/plugins/embed-plugin.d.ts +90 -0
- package/dist/plugins/embed-plugin.d.ts.map +1 -0
- package/dist/plugins/embed-plugin.js +147 -0
- package/dist/plugins/embed-plugin.js.map +1 -0
- package/dist/plugins/font-family-plugin.d.ts +58 -0
- package/dist/plugins/font-family-plugin.d.ts.map +1 -0
- package/dist/plugins/font-family-plugin.js +57 -0
- package/dist/plugins/font-family-plugin.js.map +1 -0
- package/dist/plugins/font-size-plugin.d.ts +57 -0
- package/dist/plugins/font-size-plugin.d.ts.map +1 -0
- package/dist/plugins/font-size-plugin.js +56 -0
- package/dist/plugins/font-size-plugin.js.map +1 -0
- package/dist/plugins/heading-plugin.d.ts +52 -0
- package/dist/plugins/heading-plugin.d.ts.map +1 -0
- package/dist/plugins/heading-plugin.js +114 -0
- package/dist/plugins/heading-plugin.js.map +1 -0
- package/dist/plugins/hr-plugin.d.ts +33 -0
- package/dist/plugins/hr-plugin.d.ts.map +1 -0
- package/dist/plugins/hr-plugin.js +75 -0
- package/dist/plugins/hr-plugin.js.map +1 -0
- package/dist/plugins/image-plugin.d.ts +115 -0
- package/dist/plugins/image-plugin.d.ts.map +1 -0
- package/dist/plugins/image-plugin.js +199 -0
- package/dist/plugins/image-plugin.js.map +1 -0
- package/dist/plugins/indent-plugin.d.ts +62 -0
- package/dist/plugins/indent-plugin.d.ts.map +1 -0
- package/dist/plugins/indent-plugin.js +128 -0
- package/dist/plugins/indent-plugin.js.map +1 -0
- package/dist/plugins/index.d.ts +45 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +42 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/italic-plugin.d.ts +37 -0
- package/dist/plugins/italic-plugin.d.ts.map +1 -0
- package/dist/plugins/italic-plugin.js +48 -0
- package/dist/plugins/italic-plugin.js.map +1 -0
- package/dist/plugins/link-plugin.d.ts +129 -0
- package/dist/plugins/link-plugin.d.ts.map +1 -0
- package/dist/plugins/link-plugin.js +212 -0
- package/dist/plugins/link-plugin.js.map +1 -0
- package/dist/plugins/list-plugin.d.ts +53 -0
- package/dist/plugins/list-plugin.d.ts.map +1 -0
- package/dist/plugins/list-plugin.js +309 -0
- package/dist/plugins/list-plugin.js.map +1 -0
- package/dist/plugins/mark-utils.d.ts +173 -0
- package/dist/plugins/mark-utils.d.ts.map +1 -0
- package/dist/plugins/mark-utils.js +425 -0
- package/dist/plugins/mark-utils.js.map +1 -0
- package/dist/plugins/mention-plugin.d.ts +191 -0
- package/dist/plugins/mention-plugin.d.ts.map +1 -0
- package/dist/plugins/mention-plugin.js +295 -0
- package/dist/plugins/mention-plugin.js.map +1 -0
- package/dist/plugins/strikethrough-plugin.d.ts +37 -0
- package/dist/plugins/strikethrough-plugin.d.ts.map +1 -0
- package/dist/plugins/strikethrough-plugin.js +48 -0
- package/dist/plugins/strikethrough-plugin.js.map +1 -0
- package/dist/plugins/text-color-plugin.d.ts +57 -0
- package/dist/plugins/text-color-plugin.d.ts.map +1 -0
- package/dist/plugins/text-color-plugin.js +56 -0
- package/dist/plugins/text-color-plugin.js.map +1 -0
- package/dist/plugins/underline-plugin.d.ts +37 -0
- package/dist/plugins/underline-plugin.d.ts.map +1 -0
- package/dist/plugins/underline-plugin.js +48 -0
- package/dist/plugins/underline-plugin.js.map +1 -0
- package/dist/presets.d.ts +95 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +159 -0
- package/dist/presets.js.map +1 -0
- package/dist/renderer.d.ts +125 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +415 -0
- package/dist/renderer.js.map +1 -0
- package/dist/scroll-to-cursor.d.ts +25 -0
- package/dist/scroll-to-cursor.d.ts.map +1 -0
- package/dist/scroll-to-cursor.js +59 -0
- package/dist/scroll-to-cursor.js.map +1 -0
- package/dist/selection-sync.d.ts +159 -0
- package/dist/selection-sync.d.ts.map +1 -0
- package/dist/selection-sync.js +527 -0
- package/dist/selection-sync.js.map +1 -0
- package/dist/shortcut-handler.d.ts +98 -0
- package/dist/shortcut-handler.d.ts.map +1 -0
- package/dist/shortcut-handler.js +155 -0
- package/dist/shortcut-handler.js.map +1 -0
- package/dist/toolbar.d.ts +103 -0
- package/dist/toolbar.d.ts.map +1 -0
- package/dist/toolbar.js +134 -0
- package/dist/toolbar.js.map +1 -0
- package/dist/trigger-manager.d.ts +205 -0
- package/dist/trigger-manager.d.ts.map +1 -0
- package/dist/trigger-manager.js +466 -0
- package/dist/trigger-manager.js.map +1 -0
- package/dist/types.d.ts +216 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- 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
|