@ckeditor/ckeditor5-typing 42.0.2 → 43.0.0-alpha.1

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/dist/input.d.ts CHANGED
@@ -14,6 +14,10 @@ import { Plugin } from '@ckeditor/ckeditor5-core';
14
14
  * Handles text input coming from the keyboard or other input methods.
15
15
  */
16
16
  export default class Input extends Plugin {
17
+ /**
18
+ * The queue of `insertText` command executions that are waiting for the DOM to get updated after beforeinput event.
19
+ */
20
+ private _compositionQueue;
17
21
  /**
18
22
  * @inheritDoc
19
23
  */
@@ -22,4 +26,8 @@ export default class Input extends Plugin {
22
26
  * @inheritDoc
23
27
  */
24
28
  init(): void;
29
+ /**
30
+ * @inheritDoc
31
+ */
32
+ destroy(): void;
25
33
  }
@@ -6,7 +6,7 @@
6
6
  * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
7
7
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
8
8
  */
9
- import { DomEventData, Observer, FocusObserver, type EditingView, type ViewDocumentSelection, type ViewRange, type ViewSelection } from '@ckeditor/ckeditor5-engine';
9
+ import { DomEventData, Observer, FocusObserver, type EditingView, type ViewDocumentSelection, type ViewSelection } from '@ckeditor/ckeditor5-engine';
10
10
  /**
11
11
  * Text insertion observer introduces the {@link module:engine/view/document~Document#event:insertText} event.
12
12
  */
@@ -55,9 +55,5 @@ export interface InsertTextEventData extends DomEventData {
55
55
  * The selection into which the text should be inserted.
56
56
  * If not specified, the insertion should occur at the current view selection.
57
57
  */
58
- selection: ViewSelection | ViewDocumentSelection;
59
- /**
60
- * The range that view selection should be set to after insertion.
61
- */
62
- resultRange?: ViewRange;
58
+ selection?: ViewSelection | ViewDocumentSelection;
63
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ckeditor/ckeditor5-typing",
3
- "version": "42.0.2",
3
+ "version": "43.0.0-alpha.1",
4
4
  "description": "Typing feature for CKEditor 5.",
5
5
  "keywords": [
6
6
  "ckeditor",
@@ -13,9 +13,9 @@
13
13
  "type": "module",
14
14
  "main": "src/index.js",
15
15
  "dependencies": {
16
- "@ckeditor/ckeditor5-core": "42.0.2",
17
- "@ckeditor/ckeditor5-engine": "42.0.2",
18
- "@ckeditor/ckeditor5-utils": "42.0.2",
16
+ "@ckeditor/ckeditor5-core": "43.0.0-alpha.1",
17
+ "@ckeditor/ckeditor5-engine": "43.0.0-alpha.1",
18
+ "@ckeditor/ckeditor5-utils": "43.0.0-alpha.1",
19
19
  "lodash-es": "4.17.21"
20
20
  },
21
21
  "author": "CKSource (http://cksource.com/)",
package/src/input.d.ts CHANGED
@@ -10,6 +10,10 @@ import { Plugin } from '@ckeditor/ckeditor5-core';
10
10
  * Handles text input coming from the keyboard or other input methods.
11
11
  */
12
12
  export default class Input extends Plugin {
13
+ /**
14
+ * The queue of `insertText` command executions that are waiting for the DOM to get updated after beforeinput event.
15
+ */
16
+ private _compositionQueue;
13
17
  /**
14
18
  * @inheritDoc
15
19
  */
@@ -18,4 +22,8 @@ export default class Input extends Plugin {
18
22
  * @inheritDoc
19
23
  */
20
24
  init(): void;
25
+ /**
26
+ * @inheritDoc
27
+ */
28
+ destroy(): void;
21
29
  }
package/src/input.js CHANGED
@@ -9,6 +9,8 @@ import { Plugin } from '@ckeditor/ckeditor5-core';
9
9
  import { env } from '@ckeditor/ckeditor5-utils';
10
10
  import InsertTextCommand from './inserttextcommand.js';
11
11
  import InsertTextObserver from './inserttextobserver.js';
12
+ import { LiveRange } from '@ckeditor/ckeditor5-engine';
13
+ import { debounce } from 'lodash-es';
12
14
  /**
13
15
  * Handles text input coming from the keyboard or other input methods.
14
16
  */
@@ -26,7 +28,9 @@ export default class Input extends Plugin {
26
28
  const editor = this.editor;
27
29
  const model = editor.model;
28
30
  const view = editor.editing.view;
31
+ const mapper = editor.editing.mapper;
29
32
  const modelSelection = model.document.selection;
33
+ this._compositionQueue = new CompositionQueue(editor);
30
34
  view.addObserver(InsertTextObserver);
31
35
  // TODO The above default configuration value should be defined using editor.config.define() once it's fixed.
32
36
  const insertTextCommand = new InsertTextCommand(editor, editor.config.get('typing.undoStep') || 20);
@@ -39,11 +43,20 @@ export default class Input extends Plugin {
39
43
  if (!view.document.isComposing) {
40
44
  data.preventDefault();
41
45
  }
42
- const { text, selection: viewSelection, resultRange: viewResultRange } = data;
46
+ // Flush queue on the next beforeinput event because it could happen
47
+ // that the mutation observer does not notice the DOM change in time.
48
+ if (env.isAndroid && view.document.isComposing) {
49
+ this._compositionQueue.flush('next beforeinput');
50
+ }
51
+ const { text, selection: viewSelection } = data;
52
+ let modelRanges;
43
53
  // If view selection was specified, translate it to model selection.
44
- const modelRanges = Array.from(viewSelection.getRanges()).map(viewRange => {
45
- return editor.editing.mapper.toModelRange(viewRange);
46
- });
54
+ if (viewSelection) {
55
+ modelRanges = Array.from(viewSelection.getRanges()).map(viewRange => mapper.toModelRange(viewRange));
56
+ }
57
+ else {
58
+ modelRanges = Array.from(modelSelection.getRanges());
59
+ }
47
60
  let insertText = text;
48
61
  // Typing in English on Android is firing composition events for the whole typed word.
49
62
  // We need to check the target range text to only apply the difference.
@@ -66,24 +79,47 @@ export default class Input extends Plugin {
66
79
  }
67
80
  }
68
81
  }
82
+ if (insertText.length == 0 && modelRanges[0].isCollapsed) {
83
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
84
+ // @if CK_DEBUG_TYPING // console.log( '%c[Input]%c Ignore insertion of an empty data to the collapsed range.',
85
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green;', 'font-style: italic'
86
+ // @if CK_DEBUG_TYPING // );
87
+ // @if CK_DEBUG_TYPING // }
88
+ return;
89
+ }
69
90
  }
70
- const insertTextCommandData = {
91
+ const commandData = {
71
92
  text: insertText,
72
93
  selection: model.createSelection(modelRanges)
73
94
  };
74
- // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
75
- // @if CK_DEBUG_TYPING // console.log( '%c[Input]%c Execute insertText:',
76
- // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green;', '',
77
- // @if CK_DEBUG_TYPING // insertText,
78
- // @if CK_DEBUG_TYPING // `[${ modelRanges[ 0 ].start.path }]-[${ modelRanges[ 0 ].end.path }]`
79
- // @if CK_DEBUG_TYPING // );
80
- // @if CK_DEBUG_TYPING // }
81
- if (viewResultRange) {
82
- insertTextCommandData.resultRange = editor.editing.mapper.toModelRange(viewResultRange);
95
+ // This is a composition event and those are not cancellable, so we need to wait until browser updates the DOM
96
+ // and we could apply changes to the model and verify if the DOM is valid.
97
+ // The browser applies changes to the DOM not immediately on beforeinput event.
98
+ // We just wait for mutation observer to notice changes or as a fallback a timeout.
99
+ if (env.isAndroid && view.document.isComposing) {
100
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
101
+ // @if CK_DEBUG_TYPING // console.log( `%c[Input]%c Queue insertText:%c "${ commandData.text }"%c ` +
102
+ // @if CK_DEBUG_TYPING // `[${ commandData.selection.getFirstPosition().path }]-` +
103
+ // @if CK_DEBUG_TYPING // `[${ commandData.selection.getLastPosition().path }]` +
104
+ // @if CK_DEBUG_TYPING // ` queue size: ${ this._compositionQueue.length + 1 }`,
105
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green;', 'font-weight: bold', 'color: blue', ''
106
+ // @if CK_DEBUG_TYPING // );
107
+ // @if CK_DEBUG_TYPING // }
108
+ this._compositionQueue.push(commandData);
109
+ }
110
+ else {
111
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
112
+ // @if CK_DEBUG_TYPING // console.log( `%c[Input]%c Execute insertText:%c "${ commandData.text }"%c ` +
113
+ // @if CK_DEBUG_TYPING // `[${ commandData.selection.getFirstPosition().path }]-` +
114
+ // @if CK_DEBUG_TYPING // `[${ commandData.selection.getLastPosition().path }]`,
115
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green;', 'font-weight: bold', 'color: blue', ''
116
+ // @if CK_DEBUG_TYPING // );
117
+ // @if CK_DEBUG_TYPING // }
118
+ editor.execute('insertText', commandData);
119
+ view.scrollToTheSelection();
83
120
  }
84
- editor.execute('insertText', insertTextCommandData);
85
- view.scrollToTheSelection();
86
121
  });
122
+ // Delete selected content on composition start.
87
123
  if (env.isAndroid) {
88
124
  // On Android with English keyboard, the composition starts just by putting caret
89
125
  // at the word end or by selecting a table column. This is not a real composition started.
@@ -95,9 +131,9 @@ export default class Input extends Plugin {
95
131
  // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
96
132
  // @if CK_DEBUG_TYPING // const firstPositionPath = modelSelection.getFirstPosition()!.path;
97
133
  // @if CK_DEBUG_TYPING // const lastPositionPath = modelSelection.getLastPosition()!.path;
98
- // @if CK_DEBUG_TYPING // console.log( '%c[Input]%c KeyDown 229 -> model.deleteContent()',
99
- // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green;', '',
100
- // @if CK_DEBUG_TYPING // `[${ firstPositionPath }]-[${ lastPositionPath }]`
134
+ // @if CK_DEBUG_TYPING // console.log( '%c[Input]%c KeyDown 229%c -> model.deleteContent() ' +
135
+ // @if CK_DEBUG_TYPING // `[${ firstPositionPath }]-[${ lastPositionPath }]`,
136
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green;', 'font-weight: bold', '',
101
137
  // @if CK_DEBUG_TYPING // );
102
138
  // @if CK_DEBUG_TYPING // }
103
139
  deleteSelectionContent(model, insertTextCommand);
@@ -113,16 +149,234 @@ export default class Input extends Plugin {
113
149
  // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
114
150
  // @if CK_DEBUG_TYPING // const firstPositionPath = modelSelection.getFirstPosition()!.path;
115
151
  // @if CK_DEBUG_TYPING // const lastPositionPath = modelSelection.getLastPosition()!.path;
116
- // @if CK_DEBUG_TYPING // console.log( '%c[Input]%c Composition start -> model.deleteContent()',
117
- // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green;', '',
118
- // @if CK_DEBUG_TYPING // `[${ firstPositionPath }]-[${ lastPositionPath }]`
152
+ // @if CK_DEBUG_TYPING // console.log( '%c[Input]%c Composition start%c -> model.deleteContent() ' +
153
+ // @if CK_DEBUG_TYPING // `[${ firstPositionPath }]-[${ lastPositionPath }]`,
154
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green;', 'font-weight: bold', '',
119
155
  // @if CK_DEBUG_TYPING // );
120
156
  // @if CK_DEBUG_TYPING // }
121
157
  deleteSelectionContent(model, insertTextCommand);
122
158
  });
123
159
  }
160
+ // Apply composed changes to the model.
161
+ if (env.isAndroid) {
162
+ // Apply changes to the model as they are applied to the DOM by the browser.
163
+ // On beforeinput event, the DOM is not yet modified. We wait for detected mutations to apply model changes.
164
+ this.listenTo(view.document, 'mutations', (evt, { mutations }) => {
165
+ if (!view.document.isComposing) {
166
+ return;
167
+ }
168
+ // Check if mutations are relevant for queued changes.
169
+ for (const { node } of mutations) {
170
+ const viewElement = findMappedViewAncestor(node, mapper);
171
+ const modelElement = mapper.toModelElement(viewElement);
172
+ if (this._compositionQueue.isComposedElement(modelElement)) {
173
+ this._compositionQueue.flush('mutations');
174
+ return;
175
+ }
176
+ }
177
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
178
+ // @if CK_DEBUG_TYPING // console.log( '%c[Input]%c Mutations not related to the composition.',
179
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green;', 'font-style: italic'
180
+ // @if CK_DEBUG_TYPING // );
181
+ // @if CK_DEBUG_TYPING // }
182
+ });
183
+ // Make sure that all changes are applied to the model before the end of composition.
184
+ this.listenTo(view.document, 'compositionend', () => {
185
+ this._compositionQueue.flush('composition end');
186
+ });
187
+ // Trigger mutations check after the composition completes to fix all DOM changes that got ignored during composition.
188
+ // On Android the Renderer is not disabled while composing. While updating DOM nodes we ignore some changes
189
+ // that are not that important (like NBSP vs plain space character) and could break the composition flow.
190
+ // After composition is completed we trigger additional `mutations` event for elements affected by the composition
191
+ // so the Renderer can adjust the DOM to the expected structure without breaking the composition.
192
+ this.listenTo(view.document, 'compositionend', () => {
193
+ const mutations = [];
194
+ for (const element of this._compositionQueue.flushComposedElements()) {
195
+ const viewElement = mapper.toViewElement(element);
196
+ if (!viewElement) {
197
+ continue;
198
+ }
199
+ mutations.push({ type: 'children', node: viewElement });
200
+ }
201
+ if (mutations.length) {
202
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
203
+ // @if CK_DEBUG_TYPING // console.group( '%c[Input]%c Fire post-composition mutation fixes.',
204
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green', 'font-weight: bold', ''
205
+ // @if CK_DEBUG_TYPING // );
206
+ // @if CK_DEBUG_TYPING // }
207
+ view.document.fire('mutations', { mutations });
208
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
209
+ // @if CK_DEBUG_TYPING // console.groupEnd();
210
+ // @if CK_DEBUG_TYPING // }
211
+ }
212
+ }, { priority: 'lowest' });
213
+ }
214
+ else {
215
+ // After composition end we need to verify if there are no left-overs.
216
+ // Listening at the lowest priority so after the `InsertTextObserver` added above (all composed text
217
+ // should already be applied to the model, view, and DOM).
218
+ // On non-Android the `Renderer` is blocked while user is composing but the `MutationObserver` still collects
219
+ // mutated nodes and fires `mutations` events.
220
+ // Those events are recorded by the `Renderer` but not applied to the DOM while composing.
221
+ // We need to trigger those checks (and fixes) once again but this time without specifying the exact mutations
222
+ // since they are already recorded by the `Renderer`.
223
+ // It in the most cases just clears the internal record of mutated text nodes
224
+ // since all changes should already be applied to the DOM.
225
+ // This is especially needed when user cancels composition, so we can clear nodes marked to sync.
226
+ this.listenTo(view.document, 'compositionend', () => {
227
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
228
+ // @if CK_DEBUG_TYPING // console.group( '%c[Input]%c Force render after composition end.',
229
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green', 'font-weight: bold', ''
230
+ // @if CK_DEBUG_TYPING // );
231
+ // @if CK_DEBUG_TYPING // }
232
+ view.document.fire('mutations', { mutations: [] });
233
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
234
+ // @if CK_DEBUG_TYPING // console.groupEnd();
235
+ // @if CK_DEBUG_TYPING // }
236
+ }, { priority: 'lowest' });
237
+ }
238
+ }
239
+ /**
240
+ * @inheritDoc
241
+ */
242
+ destroy() {
243
+ super.destroy();
244
+ this._compositionQueue.destroy();
245
+ }
246
+ }
247
+ /**
248
+ * The queue of `insertText` command executions that are waiting for the DOM to get updated after beforeinput event.
249
+ */
250
+ class CompositionQueue {
251
+ /**
252
+ * @inheritDoc
253
+ */
254
+ constructor(editor) {
255
+ /**
256
+ * Debounced queue flush as a safety mechanism for cases of mutation observer not triggering.
257
+ */
258
+ this.flushDebounced = debounce(() => this.flush('timeout'), 50);
259
+ /**
260
+ * The queue of `insertText` command executions that are waiting for the DOM to get updated after beforeinput event.
261
+ */
262
+ this._queue = [];
263
+ /**
264
+ * A set of model elements. The composition happened in those elements. It's used for mutations check.
265
+ */
266
+ this._compositionElements = new Set();
267
+ this.editor = editor;
268
+ }
269
+ /**
270
+ * Destroys the helper object.
271
+ */
272
+ destroy() {
273
+ this.flushDebounced.cancel();
274
+ this._compositionElements.clear();
275
+ while (this._queue.length) {
276
+ this.shift();
277
+ }
278
+ }
279
+ /**
280
+ * Returns the size of the queue.
281
+ */
282
+ get length() {
283
+ return this._queue.length;
284
+ }
285
+ /**
286
+ * Push next insertText command data to the queue.
287
+ */
288
+ push(commandData) {
289
+ const commandLiveData = {
290
+ text: commandData.text
291
+ };
292
+ if (commandData.selection) {
293
+ commandLiveData.selectionRanges = [];
294
+ for (const range of commandData.selection.getRanges()) {
295
+ commandLiveData.selectionRanges.push(LiveRange.fromRange(range));
296
+ // Keep reference to the model element for later mutation checks.
297
+ this._compositionElements.add(range.start.parent);
298
+ }
299
+ }
300
+ this._queue.push(commandLiveData);
301
+ this.flushDebounced();
302
+ }
303
+ /**
304
+ * Shift the first item from the insertText command data queue.
305
+ */
306
+ shift() {
307
+ const commandLiveData = this._queue.shift();
308
+ const commandData = {
309
+ text: commandLiveData.text
310
+ };
311
+ if (commandLiveData.selectionRanges) {
312
+ const ranges = commandLiveData.selectionRanges
313
+ .map(liveRange => detachLiveRange(liveRange))
314
+ .filter((range) => !!range);
315
+ if (ranges.length) {
316
+ commandData.selection = this.editor.model.createSelection(ranges);
317
+ }
318
+ }
319
+ return commandData;
320
+ }
321
+ /**
322
+ * Applies all queued insertText command executions.
323
+ *
324
+ * @param reason Used only for debugging.
325
+ */
326
+ flush(reason) {
327
+ const editor = this.editor;
328
+ const model = editor.model;
329
+ const view = editor.editing.view;
330
+ this.flushDebounced.cancel();
331
+ if (!this._queue.length) {
332
+ return;
333
+ }
334
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
335
+ // @if CK_DEBUG_TYPING // console.group( `%c[Input]%c Flush insertText queue on ${ reason }.`,
336
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green;', 'font-weight: bold'
337
+ // @if CK_DEBUG_TYPING // );
338
+ // @if CK_DEBUG_TYPING // }
339
+ const insertTextCommand = editor.commands.get('insertText');
340
+ const buffer = insertTextCommand.buffer;
341
+ model.enqueueChange(buffer.batch, () => {
342
+ buffer.lock();
343
+ while (this._queue.length) {
344
+ const commandData = this.shift();
345
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
346
+ // @if CK_DEBUG_TYPING // console.log( '%c[Input]%c Execute queued insertText:%c ' +
347
+ // @if CK_DEBUG_TYPING // `"${ commandData.text }"%c ` +
348
+ // @if CK_DEBUG_TYPING // `[${ commandData.selection.getFirstPosition().path }]-` +
349
+ // @if CK_DEBUG_TYPING // `[${ commandData.selection.getLastPosition().path }]`,
350
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green;', 'font-weight: bold', 'color: blue', ''
351
+ // @if CK_DEBUG_TYPING // );
352
+ // @if CK_DEBUG_TYPING // }
353
+ editor.execute('insertText', commandData);
354
+ }
355
+ buffer.unlock();
356
+ });
357
+ view.scrollToTheSelection();
358
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
359
+ // @if CK_DEBUG_TYPING // console.groupEnd();
360
+ // @if CK_DEBUG_TYPING // }
361
+ }
362
+ /**
363
+ * Returns `true` if the given model element is related to recent composition.
364
+ */
365
+ isComposedElement(element) {
366
+ return this._compositionElements.has(element);
367
+ }
368
+ /**
369
+ * Returns an array of composition-related elements and clears the internal list.
370
+ */
371
+ flushComposedElements() {
372
+ const result = Array.from(this._compositionElements);
373
+ this._compositionElements.clear();
374
+ return result;
124
375
  }
125
376
  }
377
+ /**
378
+ * Deletes the content selected by the document selection at the start of composition.
379
+ */
126
380
  function deleteSelectionContent(model, insertTextCommand) {
127
381
  // By relying on the state of the input command we allow disabling the entire input easily
128
382
  // by just disabling the input command. We could’ve used here the delete command but that
@@ -139,3 +393,24 @@ function deleteSelectionContent(model, insertTextCommand) {
139
393
  });
140
394
  buffer.unlock();
141
395
  }
396
+ /**
397
+ * Detaches a LiveRange and returns the static range from it.
398
+ */
399
+ function detachLiveRange(liveRange) {
400
+ const range = liveRange.toRange();
401
+ liveRange.detach();
402
+ if (range.root.rootName == '$graveyard') {
403
+ return null;
404
+ }
405
+ return range;
406
+ }
407
+ /**
408
+ * For the given `viewNode`, finds and returns the closest ancestor of this node that has a mapping to the model.
409
+ */
410
+ function findMappedViewAncestor(viewNode, mapper) {
411
+ let node = (viewNode.is('$text') ? viewNode.parent : viewNode);
412
+ while (!mapper.toModelElement(node)) {
413
+ node = node.parent;
414
+ }
415
+ return node;
416
+ }
@@ -2,7 +2,7 @@
2
2
  * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
3
3
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4
4
  */
5
- import { DomEventData, Observer, FocusObserver, type EditingView, type ViewDocumentSelection, type ViewRange, type ViewSelection } from '@ckeditor/ckeditor5-engine';
5
+ import { DomEventData, Observer, FocusObserver, type EditingView, type ViewDocumentSelection, type ViewSelection } from '@ckeditor/ckeditor5-engine';
6
6
  /**
7
7
  * Text insertion observer introduces the {@link module:engine/view/document~Document#event:insertText} event.
8
8
  */
@@ -51,9 +51,5 @@ export interface InsertTextEventData extends DomEventData {
51
51
  * The selection into which the text should be inserted.
52
52
  * If not specified, the insertion should occur at the current view selection.
53
53
  */
54
- selection: ViewSelection | ViewDocumentSelection;
55
- /**
56
- * The range that view selection should be set to after insertion.
57
- */
58
- resultRange?: ViewRange;
54
+ selection?: ViewSelection | ViewDocumentSelection;
59
55
  }
@@ -19,6 +19,10 @@ const TYPING_INPUT_TYPES = [
19
19
  // This one is used by Safari when accepting spell check suggestions from the autocorrection pop-up (Mac).
20
20
  'insertReplacementText'
21
21
  ];
22
+ const TYPING_INPUT_TYPES_ANDROID = [
23
+ ...TYPING_INPUT_TYPES,
24
+ 'insertCompositionText'
25
+ ];
22
26
  /**
23
27
  * Text insertion observer introduces the {@link module:engine/view/document~Document#event:insertText} event.
24
28
  */
@@ -32,16 +36,14 @@ export default class InsertTextObserver extends Observer {
32
36
  // On Android composition events should immediately be applied to the model. Rendering is not disabled.
33
37
  // On non-Android the model is updated only on composition end.
34
38
  // On Android we can't rely on composition start/end to update model.
35
- if (env.isAndroid) {
36
- TYPING_INPUT_TYPES.push('insertCompositionText');
37
- }
39
+ const typingInputTypes = env.isAndroid ? TYPING_INPUT_TYPES_ANDROID : TYPING_INPUT_TYPES;
38
40
  const viewDocument = view.document;
39
41
  viewDocument.on('beforeinput', (evt, data) => {
40
42
  if (!this.isEnabled) {
41
43
  return;
42
44
  }
43
45
  const { data: text, targetRanges, inputType, domEvent } = data;
44
- if (!TYPING_INPUT_TYPES.includes(inputType)) {
46
+ if (!typingInputTypes.includes(inputType)) {
45
47
  return;
46
48
  }
47
49
  // Mark the latest focus change as complete (we are typing in editable after the focus
@@ -58,44 +60,36 @@ export default class InsertTextObserver extends Observer {
58
60
  evt.stop();
59
61
  }
60
62
  });
61
- // Note: The priority must be lower than the CompositionObserver handler to call it after the renderer is unblocked.
62
- viewDocument.on('compositionend', (evt, { data, domEvent }) => {
63
- // On Android composition events are immediately applied to the model.
64
- // On non-Android the model is updated only on composition end.
65
- // On Android we can't rely on composition start/end to update model.
66
- if (!this.isEnabled || env.isAndroid) {
67
- return;
68
- }
69
- // In case of aborted composition.
70
- if (!data) {
71
- return;
72
- }
73
- // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
74
- // @if CK_DEBUG_TYPING // console.log( `%c[InsertTextObserver]%c Fire insertText event, text: ${ JSON.stringify( data ) }`,
75
- // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green;', ''
76
- // @if CK_DEBUG_TYPING // );
77
- // @if CK_DEBUG_TYPING // }
78
- // How do we know where to insert the composed text?
79
- // The selection observer is blocked and the view is not updated with the composition changes.
80
- // There were three options:
81
- // - Store the selection on `compositionstart` and use it now. This wouldn't work in RTC
82
- // where the view would change and the stored selection might get incorrect.
83
- // We'd need to fallback to the current view selection anyway.
84
- // - Use the current view selection. This is a bit weird and non-intuitive because
85
- // this isn't necessarily the selection on which the user started composing.
86
- // We cannot even know whether it's still collapsed (there might be some weird
87
- // editor feature that changed it in unpredictable ways for us). But it's by far
88
- // the simplest solution and should be stable (the selection is definitely correct)
89
- // and probably mostly predictable (features usually don't modify the selection
90
- // unless called explicitly by the user).
91
- // - Try to follow it from the `beforeinput` events. This would be really complex as each
92
- // `beforeinput` would come with just the range it's changing and we'd need to calculate that.
93
- // We decided to go with the 2nd option for its simplicity and stability.
94
- viewDocument.fire('insertText', new DomEventData(view, domEvent, {
95
- text: data,
96
- selection: viewDocument.selection
97
- }));
98
- }, { priority: 'lowest' });
63
+ // On Android composition events are immediately applied to the model.
64
+ // On non-Android the model is updated only on composition end.
65
+ // On Android we can't rely on composition start/end to update model.
66
+ if (!env.isAndroid) {
67
+ // Note: The priority must be lower than the CompositionObserver handler to call it after the renderer is unblocked.
68
+ // This is important for view to DOM position mapping.
69
+ // This causes the effect of first remove composed DOM and then reapply it after model modification.
70
+ viewDocument.on('compositionend', (evt, { data, domEvent }) => {
71
+ if (!this.isEnabled) {
72
+ return;
73
+ }
74
+ // In case of aborted composition.
75
+ if (!data) {
76
+ return;
77
+ }
78
+ // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
79
+ // @if CK_DEBUG_TYPING // console.log( `%c[InsertTextObserver]%c Fire insertText event, %c${ JSON.stringify( data ) }`,
80
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green;', 'font-weight: bold', 'color: blue'
81
+ // @if CK_DEBUG_TYPING // );
82
+ // @if CK_DEBUG_TYPING // }
83
+ // How do we know where to insert the composed text?
84
+ // 1. The SelectionObserver is blocked and the view is not updated with the composition changes.
85
+ // 2. The last moment before it's locked is the `compositionstart` event.
86
+ // 3. The `SelectionObserver` is listening for `compositionstart` event and immediately converts
87
+ // the selection. Handles this at the lowest priority so after the rendering is blocked.
88
+ viewDocument.fire('insertText', new DomEventData(view, domEvent, {
89
+ text: data
90
+ }));
91
+ }, { priority: 'lowest' });
92
+ }
99
93
  }
100
94
  /**
101
95
  * @inheritDoc