@barocss/editor-view-react 0.1.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 +23 -0
- package/README.md +89 -0
- package/dist/editor-view-react/src/EditorView.d.ts +14 -0
- package/dist/editor-view-react/src/EditorView.d.ts.map +1 -0
- package/dist/editor-view-react/src/EditorViewContentLayer.d.ts +9 -0
- package/dist/editor-view-react/src/EditorViewContentLayer.d.ts.map +1 -0
- package/dist/editor-view-react/src/EditorViewContext.d.ts +43 -0
- package/dist/editor-view-react/src/EditorViewContext.d.ts.map +1 -0
- package/dist/editor-view-react/src/EditorViewLayer.d.ts +8 -0
- package/dist/editor-view-react/src/EditorViewLayer.d.ts.map +1 -0
- package/dist/editor-view-react/src/EditorViewOverlayLayerContent.d.ts +14 -0
- package/dist/editor-view-react/src/EditorViewOverlayLayerContent.d.ts.map +1 -0
- package/dist/editor-view-react/src/dom-sync/classify-c1.d.ts +45 -0
- package/dist/editor-view-react/src/dom-sync/classify-c1.d.ts.map +1 -0
- package/dist/editor-view-react/src/dom-sync/edit-position.d.ts +6 -0
- package/dist/editor-view-react/src/dom-sync/edit-position.d.ts.map +1 -0
- package/dist/editor-view-react/src/index.d.ts +12 -0
- package/dist/editor-view-react/src/index.d.ts.map +1 -0
- package/dist/editor-view-react/src/input-handler.d.ts +51 -0
- package/dist/editor-view-react/src/input-handler.d.ts.map +1 -0
- package/dist/editor-view-react/src/mutation-observer-manager.d.ts +13 -0
- package/dist/editor-view-react/src/mutation-observer-manager.d.ts.map +1 -0
- package/dist/editor-view-react/src/selection-handler.d.ts +56 -0
- package/dist/editor-view-react/src/selection-handler.d.ts.map +1 -0
- package/dist/editor-view-react/src/types.d.ts +103 -0
- package/dist/editor-view-react/src/types.d.ts.map +1 -0
- package/dist/index.cjs +4 -0
- package/dist/index.js +11882 -0
- package/docs/SPEC_VERIFICATION.md +109 -0
- package/docs/editor-view-react-spec.md +359 -0
- package/docs/improvement-opportunities.md +66 -0
- package/docs/layers-spec.md +97 -0
- package/package.json +53 -0
- package/src/EditorView.tsx +312 -0
- package/src/EditorViewContentLayer.tsx +90 -0
- package/src/EditorViewContext.tsx +228 -0
- package/src/EditorViewLayer.tsx +35 -0
- package/src/EditorViewOverlayLayerContent.tsx +42 -0
- package/src/dom-sync/classify-c1.ts +91 -0
- package/src/dom-sync/edit-position.ts +27 -0
- package/src/index.ts +33 -0
- package/src/input-handler.ts +716 -0
- package/src/mutation-observer-manager.ts +65 -0
- package/src/selection-handler.ts +450 -0
- package/src/types.ts +123 -0
- package/test/EditorView-decorator.test.tsx +198 -0
- package/test/EditorView-layers.test.tsx +352 -0
- package/test/EditorView.test.tsx +218 -0
- package/test/dom-sync.test.ts +49 -0
- package/test/mutation-observer-manager.test.ts +48 -0
- package/test/selection-handler.test.ts +86 -0
- package/tsconfig.json +12 -0
- package/vite.config.ts +26 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
import type { MutableRefObject } from 'react';
|
|
2
|
+
import type { Editor } from '@barocss/editor-core';
|
|
3
|
+
import { getKeyString } from '@barocss/shared';
|
|
4
|
+
import { analyzeTextChanges } from '@barocss/text-analyzer';
|
|
5
|
+
import type { ReactSelectionHandler } from './selection-handler';
|
|
6
|
+
import { classifyDomChangeC1, type ClassifiedChangeC1, type InputHint } from './dom-sync/classify-c1';
|
|
7
|
+
import { findClosestInlineTextNode, reconstructModelTextFromDOM } from './dom-sync/edit-position';
|
|
8
|
+
import type { EditorViewViewState } from './EditorViewContext';
|
|
9
|
+
|
|
10
|
+
type ModelSelectionRange = {
|
|
11
|
+
type: 'range';
|
|
12
|
+
startNodeId: string;
|
|
13
|
+
startOffset: number;
|
|
14
|
+
endNodeId: string;
|
|
15
|
+
endOffset: number;
|
|
16
|
+
collapsed?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type ContentRange = ModelSelectionRange & { _deleteNode?: boolean; nodeId?: string };
|
|
20
|
+
|
|
21
|
+
function shouldPreventDefaultStructural(inputType: string): boolean {
|
|
22
|
+
const structural = ['insertParagraph', 'insertLineBreak'];
|
|
23
|
+
const history = ['historyUndo', 'historyRedo'];
|
|
24
|
+
return structural.includes(inputType) || history.includes(inputType);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function shouldHandleFormat(inputType: string): boolean {
|
|
28
|
+
return ['formatBold', 'formatItalic', 'formatUnderline', 'formatStrikeThrough'].includes(inputType);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function shouldHandleDelete(inputType: string): boolean {
|
|
32
|
+
return [
|
|
33
|
+
'deleteContentBackward',
|
|
34
|
+
'deleteContentForward',
|
|
35
|
+
'deleteWordBackward',
|
|
36
|
+
'deleteWordForward',
|
|
37
|
+
'deleteByCut',
|
|
38
|
+
'deleteByDrag',
|
|
39
|
+
].includes(inputType);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Input handler for React editor view: beforeinput/keydown handling and model updates.
|
|
44
|
+
* Uses ReactSelectionHandler for DOM↔model selection. Does not depend on editor-view-dom.
|
|
45
|
+
*/
|
|
46
|
+
export class ReactInputHandler {
|
|
47
|
+
private editor: Editor;
|
|
48
|
+
private selectionHandler: ReactSelectionHandler;
|
|
49
|
+
private viewStateRef: MutableRefObject<EditorViewViewState>;
|
|
50
|
+
private _isComposing = false;
|
|
51
|
+
private _pendingInsertHint: InputHint | null = null;
|
|
52
|
+
|
|
53
|
+
constructor(
|
|
54
|
+
editor: Editor,
|
|
55
|
+
selectionHandler: ReactSelectionHandler,
|
|
56
|
+
viewStateRef: MutableRefObject<EditorViewViewState>
|
|
57
|
+
) {
|
|
58
|
+
this.editor = editor;
|
|
59
|
+
this.selectionHandler = selectionHandler;
|
|
60
|
+
this.viewStateRef = viewStateRef;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Set IME composition state. Called from compositionstart/compositionend so keydown/beforeinput see it early. */
|
|
64
|
+
setComposing(isComposing: boolean): void {
|
|
65
|
+
this._isComposing = isComposing;
|
|
66
|
+
this.viewStateRef.current.isComposing = isComposing;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Sync model to DOM for the focused inline-text node. Call once after compositionend so the final composed text is applied (no intermediate C1).
|
|
71
|
+
*/
|
|
72
|
+
async syncFocusedTextNodeAfterComposition(): Promise<void> {
|
|
73
|
+
const view = this.viewStateRef.current;
|
|
74
|
+
if (view.isModelDrivenChange || view.isRendering) return;
|
|
75
|
+
|
|
76
|
+
const selection = window.getSelection();
|
|
77
|
+
if (!selection?.rangeCount || !selection.anchorNode) return;
|
|
78
|
+
|
|
79
|
+
const inlineEl = findClosestInlineTextNode(selection.anchorNode);
|
|
80
|
+
if (!inlineEl) return;
|
|
81
|
+
|
|
82
|
+
const nodeId = inlineEl.getAttribute('data-bc-sid');
|
|
83
|
+
if (!nodeId) return;
|
|
84
|
+
|
|
85
|
+
const dataStore = this.editor.dataStore;
|
|
86
|
+
const modelNode = dataStore?.getNode?.(nodeId) as { stype?: string; text?: string } | undefined;
|
|
87
|
+
if (!modelNode || modelNode.stype !== 'inline-text') return;
|
|
88
|
+
|
|
89
|
+
const prevText = modelNode.text ?? '';
|
|
90
|
+
const newText = reconstructModelTextFromDOM(inlineEl);
|
|
91
|
+
if (prevText === newText) return;
|
|
92
|
+
|
|
93
|
+
this.viewStateRef.current.skipNextRenderFromMO = true;
|
|
94
|
+
let success = false;
|
|
95
|
+
try {
|
|
96
|
+
success = await this.editor.executeCommand('replaceText', {
|
|
97
|
+
range: {
|
|
98
|
+
type: 'range',
|
|
99
|
+
startNodeId: nodeId,
|
|
100
|
+
startOffset: 0,
|
|
101
|
+
endNodeId: nodeId,
|
|
102
|
+
endOffset: prevText.length,
|
|
103
|
+
},
|
|
104
|
+
text: newText,
|
|
105
|
+
});
|
|
106
|
+
} finally {
|
|
107
|
+
this.viewStateRef.current.skipNextRenderFromMO = false;
|
|
108
|
+
}
|
|
109
|
+
if (!success) return;
|
|
110
|
+
|
|
111
|
+
const selAfter = window.getSelection();
|
|
112
|
+
if (selAfter?.rangeCount) {
|
|
113
|
+
this.viewStateRef.current.skipApplyModelSelectionToDOM = true;
|
|
114
|
+
try {
|
|
115
|
+
const modelSel = this.selectionHandler.convertDOMSelectionToModel(selAfter);
|
|
116
|
+
if (modelSel.type === 'range') {
|
|
117
|
+
const newSel: ModelSelectionRange = {
|
|
118
|
+
type: 'range',
|
|
119
|
+
startNodeId: modelSel.startNodeId,
|
|
120
|
+
startOffset: modelSel.startOffset,
|
|
121
|
+
endNodeId: modelSel.endNodeId,
|
|
122
|
+
endOffset: modelSel.endOffset,
|
|
123
|
+
collapsed: modelSel.startNodeId === modelSel.endNodeId && modelSel.startOffset === modelSel.endOffset,
|
|
124
|
+
};
|
|
125
|
+
this.editor.updateSelection?.(newSel);
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// keep browser selection as-is
|
|
129
|
+
} finally {
|
|
130
|
+
setTimeout(() => {
|
|
131
|
+
this.viewStateRef.current.skipApplyModelSelectionToDOM = false;
|
|
132
|
+
}, 0);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.editor.emit?.('editor:content.change', {
|
|
137
|
+
skipRender: true,
|
|
138
|
+
from: 'compositionend-sync',
|
|
139
|
+
content: (this.editor as { document?: unknown }).document,
|
|
140
|
+
transaction: { type: 'text_replace', nodeId },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Called from MutationObserver (same role as editor-view-dom InputHandler.handleDomMutations).
|
|
146
|
+
* Classifies DOM changes (C1 for single-node text) and updates model; emit editor:content.change with skipRender.
|
|
147
|
+
* During IME composition we skip C1 and sync once on compositionend via syncFocusedTextNodeAfterComposition.
|
|
148
|
+
* Set skipNextRenderFromMO so the model's content.change (no skipRender) does not trigger React refresh (data-only update).
|
|
149
|
+
*/
|
|
150
|
+
async handleDomMutations(mutations: MutationRecord[]): Promise<void> {
|
|
151
|
+
const view = this.viewStateRef.current;
|
|
152
|
+
if (view.isModelDrivenChange || view.isRendering) return;
|
|
153
|
+
if (view.isComposing) return;
|
|
154
|
+
|
|
155
|
+
this.viewStateRef.current.skipNextRenderFromMO = true;
|
|
156
|
+
try {
|
|
157
|
+
await this.handleDomMutationsInner(mutations);
|
|
158
|
+
} finally {
|
|
159
|
+
this.viewStateRef.current.skipNextRenderFromMO = false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async handleDomMutationsInner(mutations: MutationRecord[]): Promise<void> {
|
|
164
|
+
const view = this.viewStateRef.current;
|
|
165
|
+
const selection = window.getSelection();
|
|
166
|
+
let modelSelection: ModelSelectionRange | undefined;
|
|
167
|
+
if (selection?.rangeCount) {
|
|
168
|
+
try {
|
|
169
|
+
const sel = this.selectionHandler.convertDOMSelectionToModel(selection);
|
|
170
|
+
if (sel.type === 'range')
|
|
171
|
+
modelSelection = {
|
|
172
|
+
type: 'range',
|
|
173
|
+
startNodeId: sel.startNodeId,
|
|
174
|
+
startOffset: sel.startOffset,
|
|
175
|
+
endNodeId: sel.endNodeId,
|
|
176
|
+
endOffset: sel.endOffset,
|
|
177
|
+
};
|
|
178
|
+
} catch {
|
|
179
|
+
// ignore
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const inputHint = this.getValidInsertHint(view.isComposing);
|
|
184
|
+
const classified = classifyDomChangeC1(mutations, {
|
|
185
|
+
editor: this.editor,
|
|
186
|
+
selection: selection ?? undefined,
|
|
187
|
+
modelSelection,
|
|
188
|
+
inputHint: inputHint ?? undefined,
|
|
189
|
+
isComposing: view.isComposing,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (classified?.case === 'C1') {
|
|
193
|
+
await this.handleC1(classified);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private getValidInsertHint(isComposing: boolean): InputHint | null {
|
|
198
|
+
const hint = this._pendingInsertHint;
|
|
199
|
+
if (!hint || isComposing) return null;
|
|
200
|
+
const MAX_AGE_MS = 500;
|
|
201
|
+
if (Date.now() - hint.timestamp > MAX_AGE_MS) return null;
|
|
202
|
+
return hint;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async handleC1(classified: ClassifiedChangeC1): Promise<void> {
|
|
206
|
+
if (!classified.nodeId || !classified.prevText || classified.newText === undefined) return;
|
|
207
|
+
|
|
208
|
+
const selection = window.getSelection();
|
|
209
|
+
const selectionOffset = selection?.rangeCount ? selection.getRangeAt(0).startOffset : 0;
|
|
210
|
+
const textChanges = analyzeTextChanges({
|
|
211
|
+
oldText: classified.prevText,
|
|
212
|
+
newText: classified.newText,
|
|
213
|
+
selectionOffset,
|
|
214
|
+
selectionLength: 0,
|
|
215
|
+
});
|
|
216
|
+
if (textChanges.length === 0) return;
|
|
217
|
+
|
|
218
|
+
const change = textChanges[0];
|
|
219
|
+
const contentRange = classified.contentRange && classified.metadata?.usedInputHint
|
|
220
|
+
? classified.contentRange
|
|
221
|
+
: {
|
|
222
|
+
startNodeId: classified.nodeId,
|
|
223
|
+
startOffset: change.start,
|
|
224
|
+
endNodeId: classified.nodeId,
|
|
225
|
+
endOffset: change.end,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
this.viewStateRef.current.skipApplyModelSelectionToDOM = true;
|
|
229
|
+
try {
|
|
230
|
+
if (change.type === 'delete') {
|
|
231
|
+
const success =
|
|
232
|
+
contentRange.startNodeId === contentRange.endNodeId
|
|
233
|
+
? await this.editor.executeCommand('deleteText', { range: contentRange })
|
|
234
|
+
: await this.editor.executeCommand('deleteCrossNode', { range: contentRange });
|
|
235
|
+
if (!success) return;
|
|
236
|
+
const newSel: ModelSelectionRange = {
|
|
237
|
+
type: 'range',
|
|
238
|
+
startNodeId: contentRange.startNodeId,
|
|
239
|
+
startOffset: contentRange.startOffset,
|
|
240
|
+
endNodeId: contentRange.startNodeId,
|
|
241
|
+
endOffset: contentRange.startOffset,
|
|
242
|
+
collapsed: true,
|
|
243
|
+
};
|
|
244
|
+
this.editor.updateSelection?.(newSel);
|
|
245
|
+
} else {
|
|
246
|
+
const success = await this.editor.executeCommand('replaceText', {
|
|
247
|
+
range: contentRange,
|
|
248
|
+
text: change.text ?? '',
|
|
249
|
+
});
|
|
250
|
+
if (!success) return;
|
|
251
|
+
const insertedLen = (change.text ?? '').length;
|
|
252
|
+
const newSel: ModelSelectionRange = {
|
|
253
|
+
type: 'range',
|
|
254
|
+
startNodeId: contentRange.startNodeId,
|
|
255
|
+
startOffset: contentRange.startOffset + insertedLen,
|
|
256
|
+
endNodeId: contentRange.startNodeId,
|
|
257
|
+
endOffset: contentRange.startOffset + insertedLen,
|
|
258
|
+
collapsed: true,
|
|
259
|
+
};
|
|
260
|
+
this.editor.updateSelection?.(newSel);
|
|
261
|
+
}
|
|
262
|
+
} finally {
|
|
263
|
+
setTimeout(() => {
|
|
264
|
+
this.viewStateRef.current.skipApplyModelSelectionToDOM = false;
|
|
265
|
+
}, 0);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
this.editor.emit?.('editor:content.change', {
|
|
269
|
+
skipRender: true,
|
|
270
|
+
from: 'MutationObserver-C1',
|
|
271
|
+
content: (this.editor as { document?: unknown }).document,
|
|
272
|
+
transaction: { type: 'text_replace', nodeId: classified.nodeId },
|
|
273
|
+
});
|
|
274
|
+
this._pendingInsertHint = null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
handleBeforeInput(event: InputEvent): void {
|
|
278
|
+
if (event.isComposing !== undefined) {
|
|
279
|
+
this._isComposing = event.isComposing;
|
|
280
|
+
this.viewStateRef.current.isComposing = event.isComposing;
|
|
281
|
+
}
|
|
282
|
+
const inputType = event.inputType;
|
|
283
|
+
|
|
284
|
+
if (shouldPreventDefaultStructural(inputType)) {
|
|
285
|
+
event.preventDefault();
|
|
286
|
+
this.executeStructuralCommand(inputType);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (shouldHandleFormat(inputType)) {
|
|
291
|
+
event.preventDefault();
|
|
292
|
+
this.executeFormatCommand(inputType);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (shouldHandleDelete(inputType) && !event.isComposing) {
|
|
297
|
+
event.preventDefault();
|
|
298
|
+
this.handleDelete(event);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// insertText: never preventDefault. Browser updates DOM, MutationObserver syncs model and emits skipRender: true → no React re-render during typing (cursor stays).
|
|
303
|
+
if (inputType === 'insertText') {
|
|
304
|
+
this.updateInsertHintFromBeforeInput(event);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// insertFromPaste / insertReplacementText: may preventDefault and update model so view can re-render once.
|
|
308
|
+
if (['insertFromPaste', 'insertReplacementText'].includes(inputType)) {
|
|
309
|
+
if (this.tryHandleInsertViaGetTargetRanges(event)) return;
|
|
310
|
+
this.updateInsertHintFromBeforeInput(event);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private updateInsertHintFromBeforeInput(event: InputEvent): void {
|
|
315
|
+
const selection = window.getSelection();
|
|
316
|
+
if (!selection?.rangeCount) {
|
|
317
|
+
this._pendingInsertHint = null;
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
const modelSelection = this.selectionHandler.convertDOMSelectionToModel(selection);
|
|
322
|
+
if (modelSelection.type !== 'range') {
|
|
323
|
+
this._pendingInsertHint = null;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
this._pendingInsertHint = {
|
|
327
|
+
contentRange: {
|
|
328
|
+
startNodeId: modelSelection.startNodeId,
|
|
329
|
+
startOffset: modelSelection.startOffset,
|
|
330
|
+
endNodeId: modelSelection.endNodeId,
|
|
331
|
+
endOffset: modelSelection.endOffset,
|
|
332
|
+
},
|
|
333
|
+
timestamp: Date.now(),
|
|
334
|
+
};
|
|
335
|
+
} catch {
|
|
336
|
+
this._pendingInsertHint = null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Keydown: keybindings only (Enter, Backspace, Delete, etc.). Do not insert characters here so IME works.
|
|
342
|
+
* Character input is handled in beforeinput via tryHandleInsertViaGetTargetRanges (non-IME) or MutationObserver (IME).
|
|
343
|
+
*/
|
|
344
|
+
handleKeydown(event: KeyboardEvent): void {
|
|
345
|
+
if (this._isComposing) return;
|
|
346
|
+
|
|
347
|
+
const isCharacterKey =
|
|
348
|
+
event.key.length === 1 &&
|
|
349
|
+
!['Enter', 'Tab', 'Escape'].includes(event.key) &&
|
|
350
|
+
!event.ctrlKey &&
|
|
351
|
+
!event.metaKey;
|
|
352
|
+
if (isCharacterKey && event.keyCode !== 229 && !this.selectionHandler.isSelectionInsideEditableText()) {
|
|
353
|
+
event.preventDefault();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const key = getKeyString(event);
|
|
358
|
+
// Plain Space must not be prevented so the browser can insert it; beforeinput insertText then MO syncs model.
|
|
359
|
+
if (key === 'Space' && !event.ctrlKey && !event.metaKey && !event.altKey) return;
|
|
360
|
+
|
|
361
|
+
const resolved = this.editor.keybindings?.resolve?.(key);
|
|
362
|
+
if (resolved?.length) {
|
|
363
|
+
const { command, args } = resolved[0];
|
|
364
|
+
event.preventDefault();
|
|
365
|
+
const sel = window.getSelection();
|
|
366
|
+
if (sel && sel.rangeCount > 0) {
|
|
367
|
+
try {
|
|
368
|
+
const modelSel = this.selectionHandler.convertDOMSelectionToModel(sel);
|
|
369
|
+
if (modelSel && modelSel.type === 'range') this.editor.updateSelection?.(modelSel);
|
|
370
|
+
} catch {
|
|
371
|
+
// ignore conversion errors
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
void this.editor.executeCommand(command, args ?? {});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private insertTextAtSelection(text: string): void {
|
|
379
|
+
const selection = window.getSelection();
|
|
380
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
381
|
+
|
|
382
|
+
const modelSelection = this.selectionHandler.convertDOMSelectionToModel(selection);
|
|
383
|
+
if (!modelSelection || modelSelection.type !== 'range') return;
|
|
384
|
+
|
|
385
|
+
const rangeForReplace: ModelSelectionRange = {
|
|
386
|
+
type: 'range',
|
|
387
|
+
startNodeId: modelSelection.startNodeId,
|
|
388
|
+
startOffset: modelSelection.startOffset,
|
|
389
|
+
endNodeId: modelSelection.endNodeId,
|
|
390
|
+
endOffset: modelSelection.endOffset,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
this.editor.executeCommand('replaceText', { range: rangeForReplace, text }).then((success) => {
|
|
394
|
+
if (!success) return;
|
|
395
|
+
const newCaret: ModelSelectionRange = {
|
|
396
|
+
type: 'range',
|
|
397
|
+
startNodeId: modelSelection.startNodeId,
|
|
398
|
+
startOffset: modelSelection.startOffset + text.length,
|
|
399
|
+
endNodeId: modelSelection.startNodeId,
|
|
400
|
+
endOffset: modelSelection.startOffset + text.length,
|
|
401
|
+
};
|
|
402
|
+
this.editor.updateSelection?.(newCaret);
|
|
403
|
+
this.applyModelSelectionAfterRender(newCaret);
|
|
404
|
+
}).catch(() => {});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private tryHandleInsertViaGetTargetRanges(event: InputEvent): boolean {
|
|
408
|
+
const inputType = event.inputType;
|
|
409
|
+
if (!['insertText', 'insertFromPaste', 'insertReplacementText'].includes(inputType)) return false;
|
|
410
|
+
if (event.isComposing) return false;
|
|
411
|
+
|
|
412
|
+
const getTargetRanges = (event as InputEvent & { getTargetRanges?: () => StaticRange[] }).getTargetRanges;
|
|
413
|
+
if (typeof getTargetRanges !== 'function') return false;
|
|
414
|
+
|
|
415
|
+
const ranges = getTargetRanges.call(event);
|
|
416
|
+
if (!ranges?.length) return false;
|
|
417
|
+
|
|
418
|
+
const staticRange = ranges[0];
|
|
419
|
+
const modelRange = this.selectionHandler.convertStaticRangeToModel(staticRange);
|
|
420
|
+
if (!modelRange || modelRange.type !== 'range') return false;
|
|
421
|
+
|
|
422
|
+
const dataStore = this.editor.dataStore;
|
|
423
|
+
if (!dataStore) return false;
|
|
424
|
+
|
|
425
|
+
const startNode = dataStore.getNode(modelRange.startNodeId);
|
|
426
|
+
const endNode = dataStore.getNode(modelRange.endNodeId);
|
|
427
|
+
const isEditable =
|
|
428
|
+
(startNode as { stype?: string })?.stype === 'inline-text' &&
|
|
429
|
+
(endNode as { stype?: string })?.stype === 'inline-text';
|
|
430
|
+
|
|
431
|
+
if (!isEditable) {
|
|
432
|
+
event.preventDefault();
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const text = event.data ?? '';
|
|
437
|
+
const rangeForReplace: ModelSelectionRange = {
|
|
438
|
+
type: 'range',
|
|
439
|
+
startNodeId: modelRange.startNodeId,
|
|
440
|
+
startOffset: modelRange.startOffset,
|
|
441
|
+
endNodeId: modelRange.endNodeId,
|
|
442
|
+
endOffset: modelRange.endOffset,
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
event.preventDefault();
|
|
446
|
+
|
|
447
|
+
this.editor.executeCommand('replaceText', { range: rangeForReplace, text }).then((success) => {
|
|
448
|
+
if (!success) return;
|
|
449
|
+
const textLen = text.length;
|
|
450
|
+
const newCaret: ModelSelectionRange = {
|
|
451
|
+
type: 'range',
|
|
452
|
+
startNodeId: modelRange.startNodeId,
|
|
453
|
+
startOffset: modelRange.startOffset + textLen,
|
|
454
|
+
endNodeId: modelRange.startNodeId,
|
|
455
|
+
endOffset: modelRange.startOffset + textLen,
|
|
456
|
+
};
|
|
457
|
+
this.editor.updateSelection?.(newCaret);
|
|
458
|
+
this.editor.emit?.('editor:content.change', {
|
|
459
|
+
skipRender: false,
|
|
460
|
+
from: 'getTargetRanges',
|
|
461
|
+
content: (this.editor as { document?: unknown }).document,
|
|
462
|
+
transaction: { type: 'text_replace', range: rangeForReplace },
|
|
463
|
+
});
|
|
464
|
+
this.applyModelSelectionAfterRender(newCaret);
|
|
465
|
+
}).catch(() => {});
|
|
466
|
+
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private applyModelSelectionAfterRender(modelSelection: ModelSelectionRange): void {
|
|
471
|
+
requestAnimationFrame(() => {
|
|
472
|
+
requestAnimationFrame(() => {
|
|
473
|
+
this.selectionHandler.convertModelSelectionToDOM(modelSelection);
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private async handleDelete(event: InputEvent): Promise<void> {
|
|
479
|
+
const selection = window.getSelection();
|
|
480
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
481
|
+
|
|
482
|
+
let modelSelection: unknown = null;
|
|
483
|
+
try {
|
|
484
|
+
modelSelection = this.selectionHandler.convertDOMSelectionToModel(selection);
|
|
485
|
+
} catch {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (!modelSelection || (modelSelection as { type?: string }).type !== 'range') return;
|
|
490
|
+
|
|
491
|
+
const sel = modelSelection as ModelSelectionRange;
|
|
492
|
+
this.editor.updateSelection?.(sel);
|
|
493
|
+
|
|
494
|
+
const contentRange = this.calculateDeleteRange(sel, event.inputType, sel.startNodeId);
|
|
495
|
+
if (!contentRange) return;
|
|
496
|
+
|
|
497
|
+
let success = false;
|
|
498
|
+
|
|
499
|
+
if ((contentRange as ContentRange)._deleteNode && (contentRange as ContentRange).nodeId) {
|
|
500
|
+
success = await this.editor.executeCommand('deleteNode', {
|
|
501
|
+
nodeId: (contentRange as ContentRange).nodeId,
|
|
502
|
+
});
|
|
503
|
+
} else if (contentRange.startNodeId !== contentRange.endNodeId) {
|
|
504
|
+
success = await this.editor.executeCommand('deleteCrossNode', { range: contentRange });
|
|
505
|
+
} else {
|
|
506
|
+
success = await this.editor.executeCommand('deleteText', { range: contentRange });
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (!success) return;
|
|
510
|
+
|
|
511
|
+
const newModelSelection: ModelSelectionRange = {
|
|
512
|
+
type: 'range',
|
|
513
|
+
startNodeId: contentRange.startNodeId,
|
|
514
|
+
startOffset: contentRange.startOffset,
|
|
515
|
+
endNodeId: contentRange.startNodeId,
|
|
516
|
+
endOffset: contentRange.startOffset,
|
|
517
|
+
collapsed: true,
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
this.editor.emit?.('editor:selection.change', {
|
|
521
|
+
selection: newModelSelection,
|
|
522
|
+
oldSelection: modelSelection,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
this.editor.emit?.('editor:content.change', {
|
|
526
|
+
skipRender: false,
|
|
527
|
+
from: 'beforeinput-delete',
|
|
528
|
+
content: (this.editor as { document?: unknown }).document,
|
|
529
|
+
transaction: { type: 'delete', contentRange },
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
this.applyModelSelectionAfterRender(newModelSelection);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private calculateDeleteRange(
|
|
536
|
+
modelSelection: ModelSelectionRange,
|
|
537
|
+
inputType: string,
|
|
538
|
+
currentNodeId: string
|
|
539
|
+
): ContentRange | null {
|
|
540
|
+
const { startNodeId, startOffset, endNodeId, endOffset } = modelSelection;
|
|
541
|
+
const collapsed = startNodeId === endNodeId && startOffset === endOffset;
|
|
542
|
+
|
|
543
|
+
if (!collapsed) {
|
|
544
|
+
return { type: 'range', startNodeId, startOffset, endNodeId, endOffset };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
switch (inputType) {
|
|
548
|
+
case 'deleteContentBackward':
|
|
549
|
+
if (startOffset > 0) {
|
|
550
|
+
return {
|
|
551
|
+
type: 'range',
|
|
552
|
+
startNodeId,
|
|
553
|
+
startOffset: startOffset - 1,
|
|
554
|
+
endNodeId,
|
|
555
|
+
endOffset: startOffset,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
return this.calculateCrossNodeDeleteRange(startNodeId, 'backward');
|
|
559
|
+
|
|
560
|
+
case 'deleteContentForward': {
|
|
561
|
+
const node = this.editor.dataStore?.getNode?.(startNodeId) as { text?: string } | undefined;
|
|
562
|
+
const textLength = node?.text?.length ?? 0;
|
|
563
|
+
if (startOffset < textLength) {
|
|
564
|
+
return {
|
|
565
|
+
type: 'range',
|
|
566
|
+
startNodeId,
|
|
567
|
+
startOffset,
|
|
568
|
+
endNodeId,
|
|
569
|
+
endOffset: startOffset + 1,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
return this.calculateCrossNodeDeleteRange(startNodeId, 'forward');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
case 'deleteWordBackward':
|
|
576
|
+
case 'deleteWordForward':
|
|
577
|
+
return this.calculateDeleteRange(
|
|
578
|
+
modelSelection,
|
|
579
|
+
inputType === 'deleteWordBackward' ? 'deleteContentBackward' : 'deleteContentForward',
|
|
580
|
+
currentNodeId
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
case 'deleteByCut':
|
|
584
|
+
case 'deleteByDrag':
|
|
585
|
+
return { type: 'range', startNodeId, startOffset, endNodeId, endOffset };
|
|
586
|
+
|
|
587
|
+
default:
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
private calculateCrossNodeDeleteRange(
|
|
593
|
+
currentNodeId: string,
|
|
594
|
+
direction: 'backward' | 'forward'
|
|
595
|
+
): ContentRange | null {
|
|
596
|
+
const dataStore = this.editor.dataStore;
|
|
597
|
+
if (!dataStore?.getNode || !dataStore?.getParent) return null;
|
|
598
|
+
|
|
599
|
+
const currentNode = dataStore.getNode(currentNodeId) as { text?: string; stype?: string } | undefined;
|
|
600
|
+
if (!currentNode) return null;
|
|
601
|
+
if (currentNode.text === undefined || typeof currentNode.text !== 'string') return null;
|
|
602
|
+
|
|
603
|
+
const currentParent = dataStore.getParent(currentNodeId) as { content?: string[]; sid?: string } | undefined;
|
|
604
|
+
if (!currentParent?.content) return null;
|
|
605
|
+
|
|
606
|
+
const currentIndex = currentParent.content.indexOf(currentNodeId);
|
|
607
|
+
if (currentIndex === -1) return null;
|
|
608
|
+
|
|
609
|
+
const targetIndex = direction === 'backward' ? currentIndex - 1 : currentIndex + 1;
|
|
610
|
+
if (targetIndex < 0 || targetIndex >= currentParent.content.length) return null;
|
|
611
|
+
|
|
612
|
+
const targetNodeId = currentParent.content[targetIndex] as string;
|
|
613
|
+
const targetNode = dataStore.getNode(targetNodeId) as { text?: string; stype?: string; type?: string } | undefined;
|
|
614
|
+
if (!targetNode) return null;
|
|
615
|
+
|
|
616
|
+
const targetParent = dataStore.getParent(targetNodeId) as { sid?: string } | undefined;
|
|
617
|
+
if (!targetParent || targetParent.sid !== currentParent.sid) return null;
|
|
618
|
+
|
|
619
|
+
const schema = (dataStore as { schema?: { getNodeType?: (t: string) => { group?: string } } }).schema;
|
|
620
|
+
if (schema?.getNodeType) {
|
|
621
|
+
const type = targetNode.stype ?? targetNode.type;
|
|
622
|
+
const spec = type ? schema.getNodeType(type) : undefined;
|
|
623
|
+
if (spec?.group === 'block') return null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (targetNode.text === undefined || typeof targetNode.text !== 'string') {
|
|
627
|
+
return { type: 'range', startNodeId: '', startOffset: 0, endNodeId: '', endOffset: 0, _deleteNode: true, nodeId: targetNodeId };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const targetTextLength = (targetNode.text ?? '').length;
|
|
631
|
+
if (direction === 'backward') {
|
|
632
|
+
if (targetTextLength === 0) return null;
|
|
633
|
+
return {
|
|
634
|
+
type: 'range',
|
|
635
|
+
startNodeId: targetNodeId,
|
|
636
|
+
startOffset: targetTextLength - 1,
|
|
637
|
+
endNodeId: targetNodeId,
|
|
638
|
+
endOffset: targetTextLength,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
if (targetTextLength === 0) return null;
|
|
642
|
+
return {
|
|
643
|
+
type: 'range',
|
|
644
|
+
startNodeId: targetNodeId,
|
|
645
|
+
startOffset: 0,
|
|
646
|
+
endNodeId: targetNodeId,
|
|
647
|
+
endOffset: 1,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private executeStructuralCommand(inputType: string): void {
|
|
652
|
+
switch (inputType) {
|
|
653
|
+
case 'insertParagraph':
|
|
654
|
+
this.insertParagraph();
|
|
655
|
+
break;
|
|
656
|
+
case 'insertLineBreak':
|
|
657
|
+
this.insertText('\n');
|
|
658
|
+
break;
|
|
659
|
+
case 'historyUndo':
|
|
660
|
+
void this.editor.executeCommand('historyUndo', {});
|
|
661
|
+
break;
|
|
662
|
+
case 'historyRedo':
|
|
663
|
+
void this.editor.executeCommand('historyRedo', {});
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private insertParagraph(): void {
|
|
669
|
+
const selection = window.getSelection();
|
|
670
|
+
if (!selection || selection.rangeCount === 0) {
|
|
671
|
+
void this.editor.executeCommand('insertParagraph', {}).catch(() => {});
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
let modelSelection;
|
|
675
|
+
try {
|
|
676
|
+
modelSelection = this.selectionHandler.convertDOMSelectionToModel(selection);
|
|
677
|
+
} catch {
|
|
678
|
+
void this.editor.executeCommand('insertParagraph', {}).catch(() => {});
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (!modelSelection || modelSelection.type === 'none') {
|
|
682
|
+
void this.editor.executeCommand('insertParagraph', {}).catch(() => {});
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
this.editor.updateSelection?.(modelSelection);
|
|
686
|
+
void this.editor.executeCommand('insertParagraph', { selection: modelSelection }).catch(() => {});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
private insertText(text: string): void {
|
|
690
|
+
const selection = window.getSelection();
|
|
691
|
+
if (!selection || selection.rangeCount === 0) {
|
|
692
|
+
void this.editor.executeCommand('insertText', { text });
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const modelSelection = this.selectionHandler.convertDOMSelectionToModel(selection);
|
|
696
|
+
if (!modelSelection || modelSelection.type === 'none') {
|
|
697
|
+
void this.editor.executeCommand('insertText', { text });
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
void this.editor.executeCommand('insertText', { text, selection: modelSelection });
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private executeFormatCommand(inputType: string): void {
|
|
704
|
+
const commandMap: Record<string, string> = {
|
|
705
|
+
formatBold: 'toggleBold',
|
|
706
|
+
formatItalic: 'toggleItalic',
|
|
707
|
+
formatUnderline: 'toggleUnderline',
|
|
708
|
+
formatStrikeThrough: 'toggleStrikeThrough',
|
|
709
|
+
};
|
|
710
|
+
const command = commandMap[inputType];
|
|
711
|
+
if (command) {
|
|
712
|
+
this.editor.emit?.('editor:command.execute', { command, data: undefined });
|
|
713
|
+
void this.editor.executeCommand(command, {});
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|