@ckeditor/ckeditor5-engine 35.2.0 → 35.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ckeditor/ckeditor5-engine",
3
- "version": "35.2.0",
3
+ "version": "35.3.0",
4
4
  "description": "The editing engine of CKEditor 5 – the best browser-based rich text editor.",
5
5
  "keywords": [
6
6
  "wysiwyg",
@@ -23,31 +23,31 @@
23
23
  ],
24
24
  "main": "src/index.js",
25
25
  "dependencies": {
26
- "@ckeditor/ckeditor5-utils": "^35.2.0",
26
+ "@ckeditor/ckeditor5-utils": "^35.3.0",
27
27
  "lodash-es": "^4.17.15"
28
28
  },
29
29
  "devDependencies": {
30
- "@ckeditor/ckeditor5-basic-styles": "^35.2.0",
31
- "@ckeditor/ckeditor5-block-quote": "^35.2.0",
32
- "@ckeditor/ckeditor5-clipboard": "^35.2.0",
33
- "@ckeditor/ckeditor5-cloud-services": "^35.2.0",
34
- "@ckeditor/ckeditor5-core": "^35.2.0",
35
- "@ckeditor/ckeditor5-editor-classic": "^35.2.0",
36
- "@ckeditor/ckeditor5-enter": "^35.2.0",
37
- "@ckeditor/ckeditor5-essentials": "^35.2.0",
38
- "@ckeditor/ckeditor5-heading": "^35.2.0",
39
- "@ckeditor/ckeditor5-image": "^35.2.0",
40
- "@ckeditor/ckeditor5-link": "^35.2.0",
41
- "@ckeditor/ckeditor5-list": "^35.2.0",
42
- "@ckeditor/ckeditor5-mention": "^35.2.0",
43
- "@ckeditor/ckeditor5-paragraph": "^35.2.0",
44
- "@ckeditor/ckeditor5-table": "^35.2.0",
45
- "@ckeditor/ckeditor5-theme-lark": "^35.2.0",
46
- "@ckeditor/ckeditor5-typing": "^35.2.0",
47
- "@ckeditor/ckeditor5-ui": "^35.2.0",
48
- "@ckeditor/ckeditor5-undo": "^35.2.0",
49
- "@ckeditor/ckeditor5-widget": "^35.2.0",
50
- "typescript": "^4.6.4",
30
+ "@ckeditor/ckeditor5-basic-styles": "^35.3.0",
31
+ "@ckeditor/ckeditor5-block-quote": "^35.3.0",
32
+ "@ckeditor/ckeditor5-clipboard": "^35.3.0",
33
+ "@ckeditor/ckeditor5-cloud-services": "^35.3.0",
34
+ "@ckeditor/ckeditor5-core": "^35.3.0",
35
+ "@ckeditor/ckeditor5-editor-classic": "^35.3.0",
36
+ "@ckeditor/ckeditor5-enter": "^35.3.0",
37
+ "@ckeditor/ckeditor5-essentials": "^35.3.0",
38
+ "@ckeditor/ckeditor5-heading": "^35.3.0",
39
+ "@ckeditor/ckeditor5-image": "^35.3.0",
40
+ "@ckeditor/ckeditor5-link": "^35.3.0",
41
+ "@ckeditor/ckeditor5-list": "^35.3.0",
42
+ "@ckeditor/ckeditor5-mention": "^35.3.0",
43
+ "@ckeditor/ckeditor5-paragraph": "^35.3.0",
44
+ "@ckeditor/ckeditor5-table": "^35.3.0",
45
+ "@ckeditor/ckeditor5-theme-lark": "^35.3.0",
46
+ "@ckeditor/ckeditor5-typing": "^35.3.0",
47
+ "@ckeditor/ckeditor5-ui": "^35.3.0",
48
+ "@ckeditor/ckeditor5-undo": "^35.3.0",
49
+ "@ckeditor/ckeditor5-widget": "^35.3.0",
50
+ "typescript": "^4.8.4",
51
51
  "webpack": "^5.58.1",
52
52
  "webpack-cli": "^4.9.0"
53
53
  },
package/src/index.js CHANGED
@@ -14,6 +14,7 @@ export { default as InsertOperation } from './model/operation/insertoperation';
14
14
  export { default as MarkerOperation } from './model/operation/markeroperation';
15
15
  export { default as OperationFactory } from './model/operation/operationfactory';
16
16
  export { transformSets } from './model/operation/transform';
17
+ export { default as Selection } from './model/selection';
17
18
  export { default as DocumentSelection } from './model/documentselection';
18
19
  export { default as Range } from './model/range';
19
20
  export { default as LiveRange } from './model/liverange';
@@ -25,8 +26,10 @@ export { default as Position } from './model/position';
25
26
  export { default as DocumentFragment } from './model/documentfragment';
26
27
  export { default as History } from './model/history';
27
28
  export { default as Text } from './model/text';
29
+ export { default as Schema } from './model/schema';
28
30
  export { default as DomConverter } from './view/domconverter';
29
31
  export { default as Renderer } from './view/renderer';
32
+ export { default as View } from './view/view';
30
33
  export { default as ViewDocument } from './view/document';
31
34
  export { default as ViewText } from './view/text';
32
35
  export { default as ViewElement } from './view/element';
@@ -729,7 +729,7 @@ function isUnvisitedBlock(element, visited) {
729
729
  return false;
730
730
  }
731
731
  visited.add(element);
732
- return element.root.document.model.schema.isBlock(element) && element.parent;
732
+ return element.root.document.model.schema.isBlock(element) && !!element.parent;
733
733
  }
734
734
  // Checks if the given element is a $block was not previously visited and is a top block in a range.
735
735
  function isUnvisitedTopBlock(element, visited, range) {
@@ -743,7 +743,7 @@ function getParentBlock(position, visited) {
743
743
  const schema = element.root.document.model.schema;
744
744
  const ancestors = position.parent.getAncestors({ parentFirst: true, includeSelf: true });
745
745
  let hasParentLimit = false;
746
- const block = ancestors.find(element => {
746
+ const block = ancestors.find((element) => {
747
747
  // Stop searching after first parent node that is limit element.
748
748
  if (hasParentLimit) {
749
749
  return false;
@@ -144,26 +144,24 @@ function getCorrectPosition(walker, unit, treatEmojiAsSingleUnit) {
144
144
  // @param {Boolean} isForward Is the direction in which the selection should be modified is forward.
145
145
  function getCorrectWordBreakPosition(walker, isForward) {
146
146
  let textNode = walker.position.textNode;
147
- if (textNode) {
148
- let offset = walker.position.offset - textNode.startOffset;
149
- while (!isAtWordBoundary(textNode.data, offset, isForward) && !isAtNodeBoundary(textNode, offset, isForward)) {
147
+ if (!textNode) {
148
+ textNode = isForward ? walker.position.nodeAfter : walker.position.nodeBefore;
149
+ }
150
+ while (textNode && textNode.is('$text')) {
151
+ const offset = walker.position.offset - textNode.startOffset;
152
+ // Check of adjacent text nodes with different attributes (like BOLD).
153
+ // Example : 'foofoo []bar<$text bold="true">bar</$text> bazbaz'
154
+ // should expand to : 'foofoo [bar<$text bold="true">bar</$text>] bazbaz'.
155
+ if (isAtNodeBoundary(textNode, offset, isForward)) {
156
+ textNode = isForward ? walker.position.nodeAfter : walker.position.nodeBefore;
157
+ }
158
+ // Check if this is a word boundary.
159
+ else if (isAtWordBoundary(textNode.data, offset, isForward)) {
160
+ break;
161
+ }
162
+ // Maybe one more character.
163
+ else {
150
164
  walker.next();
151
- // Check of adjacent text nodes with different attributes (like BOLD).
152
- // Example : 'foofoo []bar<$text bold="true">bar</$text> bazbaz'
153
- // should expand to : 'foofoo [bar<$text bold="true">bar</$text>] bazbaz'.
154
- const nextNode = isForward ? walker.position.nodeAfter : walker.position.nodeBefore;
155
- // Scan only text nodes. Ignore inline elements (like `<softBreak>`).
156
- if (nextNode && nextNode.is('$text')) {
157
- // Check boundary char of an adjacent text node.
158
- const boundaryChar = nextNode.data.charAt(isForward ? 0 : nextNode.data.length - 1);
159
- // Go to the next node if the character at the boundary of that node belongs to the same word.
160
- if (!wordBoundaryCharacters.includes(boundaryChar)) {
161
- // If adjacent text node belongs to the same word go to it & reset values.
162
- walker.next();
163
- textNode = walker.position.textNode;
164
- }
165
- }
166
- offset = walker.position.offset - textNode.startOffset;
167
165
  }
168
166
  }
169
167
  return walker.position;
@@ -194,5 +192,5 @@ function isAtWordBoundary(data, offset, isForward) {
194
192
  // @param {Number} offset Position offset.
195
193
  // @param {Boolean} isForward Is the direction in which the selection should be modified is forward.
196
194
  function isAtNodeBoundary(textNode, offset, isForward) {
197
- return offset === (isForward ? textNode.endOffset : 0);
195
+ return offset === (isForward ? textNode.offsetSize : 0);
198
196
  }
@@ -121,7 +121,7 @@ function tryFixingCollapsedRange(range, schema) {
121
121
  // In the first case, there is no need to fix the selection range.
122
122
  // In the second, let's go up to the outer selectable element
123
123
  if (!nearestSelectionRange) {
124
- const ancestorObject = originalPosition.getAncestors().reverse().find(item => schema.isObject(item));
124
+ const ancestorObject = originalPosition.getAncestors().reverse().find((item) => schema.isObject(item));
125
125
  if (ancestorObject) {
126
126
  return Range._createOn(ancestorObject);
127
127
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved.
3
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4
+ */
5
+ /**
6
+ * A facade over the native [`DataTransfer`](https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer) object.
7
+ */
8
+ export default class DataTransfer {
9
+ constructor(nativeDataTransfer) {
10
+ /**
11
+ * The array of files created from the native `DataTransfer#files` or `DataTransfer#items`.
12
+ *
13
+ * @readonly
14
+ * @member {Array.<File>} #files
15
+ */
16
+ this.files = getFiles(nativeDataTransfer);
17
+ /**
18
+ * The native DataTransfer object.
19
+ *
20
+ * @private
21
+ * @member {DataTransfer} #_native
22
+ */
23
+ this._native = nativeDataTransfer;
24
+ }
25
+ /**
26
+ * Returns an array of available native content types.
27
+ *
28
+ * @returns {Array.<String>}
29
+ */
30
+ get types() {
31
+ return this._native.types;
32
+ }
33
+ /**
34
+ * Gets the data from the data transfer by its MIME type.
35
+ *
36
+ * dataTransfer.getData( 'text/plain' );
37
+ *
38
+ * @param {String} type The MIME type. E.g. `text/html` or `text/plain`.
39
+ * @returns {String}
40
+ */
41
+ getData(type) {
42
+ return this._native.getData(type);
43
+ }
44
+ /**
45
+ * Sets the data in the data transfer.
46
+ *
47
+ * @param {String} type The MIME type. E.g. `text/html` or `text/plain`.
48
+ * @param {String} data
49
+ */
50
+ setData(type, data) {
51
+ this._native.setData(type, data);
52
+ }
53
+ /**
54
+ * The effect that is allowed for a drag operation.
55
+ *
56
+ * @param {String} value
57
+ */
58
+ set effectAllowed(value) {
59
+ this._native.effectAllowed = value;
60
+ }
61
+ get effectAllowed() {
62
+ return this._native.effectAllowed;
63
+ }
64
+ /**
65
+ * The actual drop effect.
66
+ *
67
+ * @param {String} value
68
+ */
69
+ set dropEffect(value) {
70
+ this._native.dropEffect = value;
71
+ }
72
+ get dropEffect() {
73
+ return this._native.dropEffect;
74
+ }
75
+ /**
76
+ * Whether the dragging operation was canceled.
77
+ *
78
+ * @returns {Boolean}
79
+ */
80
+ get isCanceled() {
81
+ return this._native.dropEffect == 'none' || !!this._native.mozUserCancelled;
82
+ }
83
+ }
84
+ function getFiles(nativeDataTransfer) {
85
+ // DataTransfer.files and items are array-like and might not have an iterable interface.
86
+ const files = Array.from(nativeDataTransfer.files || []);
87
+ const items = Array.from(nativeDataTransfer.items || []);
88
+ if (files.length) {
89
+ return files;
90
+ }
91
+ // Chrome has empty DataTransfer.files, but allows getting files through the items interface.
92
+ return items
93
+ .filter(item => item.kind === 'file')
94
+ .map(item => item.getAsFile());
95
+ }
@@ -726,6 +726,9 @@ export default class DomConverter {
726
726
  }
727
727
  else {
728
728
  const domBefore = domParent.childNodes[domOffset - 1];
729
+ if (isText(domBefore) && isInlineFiller(domBefore)) {
730
+ return this.domPositionToView(domBefore.parentNode, indexOf(domBefore));
731
+ }
729
732
  const viewBefore = isText(domBefore) ?
730
733
  this.findCorrespondingViewText(domBefore) :
731
734
  this.mapDomToView(domBefore);
@@ -950,8 +953,15 @@ export default class DomConverter {
950
953
  // Since it takes multiple lines of code to check whether a "DOM Position" is before/after another "DOM Position",
951
954
  // we will use the fact that range will collapse if it's end is before it's start.
952
955
  const range = this._domDocument.createRange();
953
- range.setStart(selection.anchorNode, selection.anchorOffset);
954
- range.setEnd(selection.focusNode, selection.focusOffset);
956
+ try {
957
+ range.setStart(selection.anchorNode, selection.anchorOffset);
958
+ range.setEnd(selection.focusNode, selection.focusOffset);
959
+ }
960
+ catch (e) {
961
+ // Safari sometimes gives us a selection that makes Range.set{Start,End} throw.
962
+ // See https://github.com/ckeditor/ckeditor5/issues/12375.
963
+ return false;
964
+ }
955
965
  const backward = range.collapsed;
956
966
  range.detach();
957
967
  return backward;
@@ -7,7 +7,7 @@
7
7
  * @module engine/view/editableelement
8
8
  */
9
9
  import ContainerElement from './containerelement';
10
- import { default as ObservableMixin } from '@ckeditor/ckeditor5-utils/src/observablemixin';
10
+ import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
11
11
  /**
12
12
  * Editable element which can be a {@link module:engine/view/rooteditableelement~RootEditableElement root}
13
13
  * or nested editable area in the editor.
@@ -21,14 +21,34 @@ export default class CompositionObserver extends DomEventObserver {
21
21
  this.domEventType = ['compositionstart', 'compositionupdate', 'compositionend'];
22
22
  const document = this.document;
23
23
  document.on('compositionstart', () => {
24
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
25
+ // @if CK_DEBUG_TYPING // console.log( '%c[CompositionObserver] ' +
26
+ // @if CK_DEBUG_TYPING // '┌───────────────────────────── isComposing = true ─────────────────────────────┐',
27
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green'
28
+ // @if CK_DEBUG_TYPING // );
29
+ // @if CK_DEBUG_TYPING // }
24
30
  document.isComposing = true;
25
- });
31
+ }, { priority: 'low' });
26
32
  document.on('compositionend', () => {
33
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
34
+ // @if CK_DEBUG_TYPING // console.log( '%c[CompositionObserver] ' +
35
+ // @if CK_DEBUG_TYPING // '└───────────────────────────── isComposing = false ─────────────────────────────┘',
36
+ // @if CK_DEBUG_TYPING // 'font-weight: bold; color: green'
37
+ // @if CK_DEBUG_TYPING // );
38
+ // @if CK_DEBUG_TYPING // }
27
39
  document.isComposing = false;
28
- });
40
+ }, { priority: 'low' });
29
41
  }
30
42
  onDomEvent(domEvent) {
31
- this.fire(domEvent.type, domEvent);
43
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
44
+ // @if CK_DEBUG_TYPING // console.group( `%c[CompositionObserver]%c ${ domEvent.type }`, 'color: green', '' );
45
+ // @if CK_DEBUG_TYPING // }
46
+ this.fire(domEvent.type, domEvent, {
47
+ data: domEvent.data
48
+ });
49
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
50
+ // @if CK_DEBUG_TYPING // console.groupEnd();
51
+ // @if CK_DEBUG_TYPING // }
32
52
  }
33
53
  }
34
54
  /**
@@ -6,10 +6,13 @@
6
6
  * @module engine/view/observer/inputobserver
7
7
  */
8
8
  import DomEventObserver from './domeventobserver';
9
+ import DataTransfer from '../datatransfer';
10
+ import env from '@ckeditor/ckeditor5-utils/src/env';
9
11
  /**
10
12
  * Observer for events connected with data input.
11
13
  *
12
- * Note that this observer is attached by the {@link module:engine/view/view~View} and is available by default.
14
+ * **Note**: This observer is attached by {@link module:engine/view/view~View} and available by default in all
15
+ * editor instances.
13
16
  *
14
17
  * @extends module:engine/view/observer/domeventobserver~DomEventObserver
15
18
  */
@@ -19,20 +22,144 @@ export default class InputObserver extends DomEventObserver {
19
22
  this.domEventType = ['beforeinput'];
20
23
  }
21
24
  onDomEvent(domEvent) {
22
- this.fire(domEvent.type, domEvent);
25
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
26
+ // @if CK_DEBUG_TYPING // console.group( `%c[InputObserver]%c ${ domEvent.type }: ${ domEvent.inputType }`,
27
+ // @if CK_DEBUG_TYPING // 'color: green', 'color: default'
28
+ // @if CK_DEBUG_TYPING // );
29
+ // @if CK_DEBUG_TYPING // }
30
+ const domTargetRanges = domEvent.getTargetRanges();
31
+ const view = this.view;
32
+ const viewDocument = view.document;
33
+ let dataTransfer = null;
34
+ let data = null;
35
+ let targetRanges = [];
36
+ if (domEvent.dataTransfer) {
37
+ dataTransfer = new DataTransfer(domEvent.dataTransfer);
38
+ }
39
+ if (domEvent.data !== null) {
40
+ data = domEvent.data;
41
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
42
+ // @if CK_DEBUG_TYPING // console.info( `%c[InputObserver]%c event data: %c${ JSON.stringify( data ) }`,
43
+ // @if CK_DEBUG_TYPING // 'color: green;font-weight: bold', 'font-weight:bold', 'color: blue;'
44
+ // @if CK_DEBUG_TYPING // );
45
+ // @if CK_DEBUG_TYPING // }
46
+ }
47
+ else if (dataTransfer) {
48
+ data = dataTransfer.getData('text/plain');
49
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
50
+ // @if CK_DEBUG_TYPING // console.info( `%c[InputObserver]%c event data transfer: %c${ JSON.stringify( data ) }`,
51
+ // @if CK_DEBUG_TYPING // 'color: green;font-weight: bold', 'font-weight:bold', 'color: blue;'
52
+ // @if CK_DEBUG_TYPING // );
53
+ // @if CK_DEBUG_TYPING // }
54
+ }
55
+ // If the editor selection is fake (an object is selected), the DOM range does not make sense because it is anchored
56
+ // in the fake selection container.
57
+ if (viewDocument.selection.isFake) {
58
+ // Future-proof: in case of multi-range fake selections being possible.
59
+ targetRanges = Array.from(viewDocument.selection.getRanges());
60
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
61
+ // @if CK_DEBUG_TYPING // console.info( '%c[InputObserver]%c using fake selection:',
62
+ // @if CK_DEBUG_TYPING // 'color: green;font-weight: bold', 'font-weight:bold', targetRanges,
63
+ // @if CK_DEBUG_TYPING // viewDocument.selection.isFake ? 'fake view selection' : 'fake DOM parent'
64
+ // @if CK_DEBUG_TYPING // );
65
+ // @if CK_DEBUG_TYPING // }
66
+ }
67
+ else if (domTargetRanges.length) {
68
+ targetRanges = domTargetRanges.map(domRange => {
69
+ return view.domConverter.domRangeToView(domRange);
70
+ });
71
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
72
+ // @if CK_DEBUG_TYPING // console.info( '%c[InputObserver]%c using target ranges:',
73
+ // @if CK_DEBUG_TYPING // 'color: green;font-weight: bold', 'font-weight:bold', targetRanges
74
+ // @if CK_DEBUG_TYPING // );
75
+ // @if CK_DEBUG_TYPING // }
76
+ }
77
+ // For Android devices we use a fallback to the current DOM selection, Android modifies it according
78
+ // to the expected target ranges of input event.
79
+ else if (env.isAndroid) {
80
+ const domSelection = domEvent.target.ownerDocument.defaultView.getSelection();
81
+ targetRanges = Array.from(view.domConverter.domSelectionToView(domSelection).getRanges());
82
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
83
+ // @if CK_DEBUG_TYPING // console.info( '%c[InputObserver]%c using selection ranges:',
84
+ // @if CK_DEBUG_TYPING // 'color: green;font-weight: bold', 'font-weight:bold', targetRanges
85
+ // @if CK_DEBUG_TYPING // );
86
+ // @if CK_DEBUG_TYPING // }
87
+ }
88
+ // Android sometimes fires insertCompositionText with a new-line character at the end of the data
89
+ // instead of firing insertParagraph beforeInput event.
90
+ // Fire the correct type of beforeInput event and ignore the replaced fragment of text because
91
+ // it wants to replace "test" with "test\n".
92
+ // https://github.com/ckeditor/ckeditor5/issues/12368.
93
+ if (env.isAndroid && domEvent.inputType == 'insertCompositionText' && data && data.endsWith('\n')) {
94
+ this.fire(domEvent.type, domEvent, {
95
+ inputType: 'insertParagraph',
96
+ targetRanges: [view.createRange(targetRanges[0].end)]
97
+ });
98
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
99
+ // @if CK_DEBUG_TYPING // console.groupEnd();
100
+ // @if CK_DEBUG_TYPING // }
101
+ return;
102
+ }
103
+ // Fire the normalized beforeInput event.
104
+ this.fire(domEvent.type, domEvent, {
105
+ data,
106
+ dataTransfer,
107
+ targetRanges,
108
+ inputType: domEvent.inputType,
109
+ isComposing: domEvent.isComposing
110
+ });
111
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
112
+ // @if CK_DEBUG_TYPING // console.groupEnd();
113
+ // @if CK_DEBUG_TYPING // }
23
114
  }
24
115
  }
25
116
  /**
26
- * Fired before browser inputs (or deletes) some data.
117
+ * The data transfer instance of the input event. Corresponds to native `InputEvent#dataTransfer`.
27
118
  *
28
- * This event is available only on browsers which support DOM `beforeinput` event.
119
+ * The value is `null` when no `dataTransfer` was passed along with the input event.
29
120
  *
30
- * Introduced by {@link module:engine/view/observer/inputobserver~InputObserver}.
121
+ * @readonly
122
+ * @member {module:engine/view/datatransfer~DataTransfer|null} module:engine/view/observer/inputobserver~InputEventData#dataTransfer
123
+ */
124
+ /**
125
+ * A flag indicating that the `beforeinput` event was fired during composition.
126
+ *
127
+ * Corresponds to the
128
+ * {@link module:engine/view/document~Document#event:compositionstart},
129
+ * {@link module:engine/view/document~Document#event:compositionupdate},
130
+ * and {@link module:engine/view/document~Document#event:compositionend } trio.
131
+ *
132
+ * @readonly
133
+ * @member {Boolean} module:engine/view/observer/inputobserver~InputEventData#isComposing
134
+ */
135
+ /**
136
+ * The type of the input event (e.g. "insertText" or "deleteWordBackward"). Corresponds to native `InputEvent#inputType`.
137
+ *
138
+ * @readonly
139
+ * @member {String} module:engine/view/observer/inputobserver~InputEventData#inputType
140
+ */
141
+ /**
142
+ * Editing {@link module:engine/view/range~Range view ranges} corresponding to DOM ranges provided by the web browser
143
+ * (as returned by `InputEvent#getTargetRanges()`).
144
+ *
145
+ * @readonly
146
+ * @member {Array.<module:engine/view/range~Range>} module:engine/view/observer/inputobserver~InputEventData#targetRanges
147
+ */
148
+ /**
149
+ * A unified text data passed along with the input event. Depending on:
150
+ *
151
+ * * the web browser and input events implementation (for instance [Level 1](https://www.w3.org/TR/input-events-1/) or
152
+ * [Level 2](https://www.w3.org/TR/input-events-2/)),
153
+ * * {@link module:engine/view/observer/inputobserver~InputEventData#inputType input type}
154
+ *
155
+ * text data is sometimes passed in the `data` and sometimes in the `dataTransfer` property.
31
156
  *
32
- * Note that because {@link module:engine/view/observer/inputobserver~InputObserver} is attached by the
33
- * {@link module:engine/view/view~View} this event is available by default.
157
+ * * If `InputEvent#data` was set, this property reflects its value.
158
+ * * If `InputEvent#data` is unavailable, this property contains the `'text/plain'` data from
159
+ * {@link module:engine/view/observer/inputobserver~InputEventData#dataTransfer}.
160
+ * * If the event ({@link module:engine/view/observer/inputobserver~InputEventData#inputType input type})
161
+ * provides no data whatsoever, this property is `null`.
34
162
  *
35
- * @see module:engine/view/observer/inputobserver~InputObserver
36
- * @event module:engine/view/document~Document#event:beforeinput
37
- * @param {module:engine/view/observer/domeventdata~DomEventData} data Event data.
163
+ * @readonly
164
+ * @member {String|null} module:engine/view/observer/inputobserver~InputEventData#data
38
165
  */
@@ -7,20 +7,16 @@
7
7
  */
8
8
  /* globals window */
9
9
  import Observer from './observer';
10
- import ViewSelection from '../selection';
11
- import { startsWithFiller, getDataWithoutFiller } from '../filler';
10
+ import { startsWithFiller } from '../filler';
12
11
  import { isEqualWith } from 'lodash-es';
13
12
  /**
14
- * Mutation observer class observes changes in the DOM, fires {@link module:engine/view/document~Document#event:mutations} event, mark view
15
- * elements as changed and call {@link module:engine/view/renderer~Renderer#render}.
16
- * Because all mutated nodes are marked as "to be rendered" and the
17
- * {@link module:engine/view/renderer~Renderer#render} is called, all changes will be reverted, unless the mutation will be handled by the
18
- * {@link module:engine/view/document~Document#event:mutations} event listener. It means user will see only handled changes, and the editor
19
- * will block all changes which are not handled.
13
+ * Mutation observer's role is to watch for any DOM changes inside the editor that weren't
14
+ * done by the editor's {@link module:engine/view/renderer~Renderer} itself and reverting these changes.
20
15
  *
21
- * Mutation Observer also take care of reducing number of mutations which are fired. It removes duplicates and
22
- * mutations on elements which do not have corresponding view elements. Also
23
- * {@link module:engine/view/observer/mutationobserver~MutatedText text mutation} is fired only if parent element do not change child list.
16
+ * It does this by observing all mutations in the DOM, marking related view elements as changed and calling
17
+ * {@link module:engine/view/renderer~Renderer#render}. Because all mutated nodes are marked as
18
+ * "to be rendered" and the {@link module:engine/view/renderer~Renderer#render `render()`} method is called,
19
+ * all changes are reverted in the DOM (the DOM is synced with the editor's view structure).
24
20
  *
25
21
  * Note that this observer is attached by the {@link module:engine/view/view~View} and is available by default.
26
22
  *
@@ -38,7 +34,6 @@ export default class MutationObserver extends Observer {
38
34
  this._config = {
39
35
  childList: true,
40
36
  characterData: true,
41
- characterDataOldValue: true,
42
37
  subtree: true
43
38
  };
44
39
  /**
@@ -69,8 +64,7 @@ export default class MutationObserver extends Observer {
69
64
  this._mutationObserver = new window.MutationObserver(this._onMutations.bind(this));
70
65
  }
71
66
  /**
72
- * Synchronously fires {@link module:engine/view/document~Document#event:mutations} event with all mutations in record queue.
73
- * At the same time empties the queue so mutations will not be fired twice.
67
+ * Synchronously handles mutations and empties the queue.
74
68
  */
75
69
  flush() {
76
70
  this._onMutations(this._mutationObserver.takeRecords());
@@ -108,7 +102,7 @@ export default class MutationObserver extends Observer {
108
102
  this._mutationObserver.disconnect();
109
103
  }
110
104
  /**
111
- * Handles mutations. Deduplicates, mark view elements to sync, fire event and call render.
105
+ * Handles mutations. Mark view elements to sync and call render.
112
106
  *
113
107
  * @private
114
108
  * @param {Array.<Object>} domMutations Array of native mutations.
@@ -120,20 +114,21 @@ export default class MutationObserver extends Observer {
120
114
  }
121
115
  const domConverter = this.domConverter;
122
116
  // Use map and set for deduplication.
123
- const mutatedTexts = new Map();
124
- const mutatedElements = new Set();
117
+ const mutatedTextNodes = new Set();
118
+ const elementsWithMutatedChildren = new Set();
125
119
  // Handle `childList` mutations first, so we will be able to check if the `characterData` mutation is in the
126
120
  // element with changed structure anyway.
127
121
  for (const mutation of domMutations) {
128
- if (mutation.type === 'childList') {
129
- const element = domConverter.mapDomToView(mutation.target);
130
- // Do not collect mutations from UIElements and RawElements.
131
- if (element && (element.is('uiElement') || element.is('rawElement'))) {
132
- continue;
133
- }
134
- if (element && !this._isBogusBrMutation(mutation)) {
135
- mutatedElements.add(element);
136
- }
122
+ const element = domConverter.mapDomToView(mutation.target);
123
+ if (!element) {
124
+ continue;
125
+ }
126
+ // Do not collect mutations from UIElements and RawElements.
127
+ if (element.is('uiElement') || element.is('rawElement')) {
128
+ continue;
129
+ }
130
+ if (mutation.type === 'childList' && !this._isBogusBrMutation(mutation)) {
131
+ elementsWithMutatedChildren.add(element);
137
132
  }
138
133
  }
139
134
  // Handle `characterData` mutations later, when we have the full list of nodes which changed structure.
@@ -145,87 +140,48 @@ export default class MutationObserver extends Observer {
145
140
  }
146
141
  if (mutation.type === 'characterData') {
147
142
  const text = domConverter.findCorrespondingViewText(mutation.target);
148
- if (text && !mutatedElements.has(text.parent)) {
149
- // Use text as a key, for deduplication. If there will be another mutation on the same text element
150
- // we will have only one in the map.
151
- mutatedTexts.set(text, {
152
- type: 'text',
153
- oldText: text.data,
154
- newText: getDataWithoutFiller(mutation.target),
155
- node: text
156
- });
143
+ if (text && !elementsWithMutatedChildren.has(text.parent)) {
144
+ mutatedTextNodes.add(text);
157
145
  }
158
146
  // When we added first letter to the text node which had only inline filler, for the DOM it is mutation
159
- // on text, but for the view, where filler text node did not existed, new text node was created, so we
160
- // need to fire 'children' mutation instead of 'text'.
147
+ // on text, but for the view, where filler text node did not exist, new text node was created, so we
148
+ // need to handle it as a 'children' mutation instead of 'text'.
161
149
  else if (!text && startsWithFiller(mutation.target)) {
162
- mutatedElements.add(domConverter.mapDomToView(mutation.target.parentNode));
150
+ elementsWithMutatedChildren.add(domConverter.mapDomToView(mutation.target.parentNode));
163
151
  }
164
152
  }
165
153
  }
166
- // Now we build the list of mutations to fire and mark elements. We did not do it earlier to avoid marking the
154
+ // Now we build the list of mutations to mark elements. We did not do it earlier to avoid marking the
167
155
  // same node multiple times in case of duplication.
168
- // List of mutations we will fire.
169
- const viewMutations = [];
170
- for (const mutatedText of mutatedTexts.values()) {
171
- this.renderer.markToSync('text', mutatedText.node);
172
- viewMutations.push(mutatedText);
156
+ let hasMutations = false;
157
+ for (const textNode of mutatedTextNodes) {
158
+ hasMutations = true;
159
+ this.renderer.markToSync('text', textNode);
173
160
  }
174
- for (const viewElement of mutatedElements) {
161
+ for (const viewElement of elementsWithMutatedChildren) {
175
162
  const domElement = domConverter.mapViewToDom(viewElement);
176
163
  const viewChildren = Array.from(viewElement.getChildren());
177
164
  const newViewChildren = Array.from(domConverter.domChildrenToView(domElement, { withChildren: false }));
178
165
  // It may happen that as a result of many changes (sth was inserted and then removed),
179
166
  // both elements haven't really changed. #1031
180
167
  if (!isEqualWith(viewChildren, newViewChildren, sameNodes)) {
168
+ hasMutations = true;
181
169
  this.renderer.markToSync('children', viewElement);
182
- viewMutations.push({
183
- type: 'children',
184
- oldChildren: viewChildren,
185
- newChildren: newViewChildren,
186
- node: viewElement
187
- });
188
- }
189
- }
190
- // Retrieve `domSelection` using `ownerDocument` of one of mutated nodes.
191
- // There should not be simultaneous mutation in multiple documents, so it's fine.
192
- const domSelection = domMutations[0].target.ownerDocument.getSelection();
193
- let viewSelection = null;
194
- if (domSelection && domSelection.anchorNode) {
195
- // If `domSelection` is inside a dom node that is already bound to a view node from view tree, get
196
- // corresponding selection in the view and pass it together with `viewMutations`. The `viewSelection` may
197
- // be used by features handling mutations.
198
- // Only one range is supported.
199
- const viewSelectionAnchor = domConverter.domPositionToView(domSelection.anchorNode, domSelection.anchorOffset);
200
- const viewSelectionFocus = domConverter.domPositionToView(domSelection.focusNode, domSelection.focusOffset);
201
- // Anchor and focus has to be properly mapped to view.
202
- if (viewSelectionAnchor && viewSelectionFocus) {
203
- viewSelection = new ViewSelection(viewSelectionAnchor);
204
- viewSelection.setFocus(viewSelectionFocus);
205
170
  }
206
171
  }
207
172
  // In case only non-relevant mutations were recorded it skips the event and force render (#5600).
208
- if (viewMutations.length) {
209
- this.document.fire('mutations', viewMutations, viewSelection);
210
- // If nothing changes on `mutations` event, at this point we have "dirty DOM" (changed) and de-synched
211
- // view (which has not been changed). In order to "reset DOM" we render the view again.
173
+ if (hasMutations) {
174
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
175
+ // @if CK_DEBUG_TYPING // console.group( '%c[MutationObserver]%c Mutations detected',
176
+ // @if CK_DEBUG_TYPING // 'font-weight:bold;color:green', ''
177
+ // @if CK_DEBUG_TYPING // );
178
+ // @if CK_DEBUG_TYPING // }
179
+ // At this point we have "dirty DOM" (changed) and de-synched view (which has not been changed).
180
+ // In order to "reset DOM" we render the view again.
212
181
  this.view.forceRender();
213
- }
214
- function sameNodes(child1, child2) {
215
- // First level of comparison (array of children vs array of children) – use the Lodash's default behavior.
216
- if (Array.isArray(child1)) {
217
- return;
218
- }
219
- // Elements.
220
- if (child1 === child2) {
221
- return true;
222
- }
223
- // Texts.
224
- else if (child1.is('$text') && child2.is('$text')) {
225
- return child1.data === child2.data;
226
- }
227
- // Not matching types.
228
- return false;
182
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
183
+ // @if CK_DEBUG_TYPING // console.groupEnd();
184
+ // @if CK_DEBUG_TYPING // }
229
185
  }
230
186
  }
231
187
  /**
@@ -248,3 +204,19 @@ export default class MutationObserver extends Observer {
248
204
  return addedNode && addedNode.is('element', 'br');
249
205
  }
250
206
  }
207
+ function sameNodes(child1, child2) {
208
+ // First level of comparison (array of children vs array of children) – use the Lodash's default behavior.
209
+ if (Array.isArray(child1)) {
210
+ return;
211
+ }
212
+ // Elements.
213
+ if (child1 === child2) {
214
+ return true;
215
+ }
216
+ // Texts.
217
+ else if (child1.is('$text') && child2.is('$text')) {
218
+ return child1.data === child2.data;
219
+ }
220
+ // Not matching types.
221
+ return false;
222
+ }
@@ -8,19 +8,18 @@
8
8
  /* global setInterval, clearInterval */
9
9
  import Observer from './observer';
10
10
  import MutationObserver from './mutationobserver';
11
+ import env from '@ckeditor/ckeditor5-utils/src/env';
11
12
  import { debounce } from 'lodash-es';
12
13
  /**
13
14
  * Selection observer class observes selection changes in the document. If a selection changes on the document this
14
- * observer checks if there are any mutations and if the DOM selection is different from the
15
- * {@link module:engine/view/document~Document#selection view selection}. The selection observer fires
16
- * {@link module:engine/view/document~Document#event:selectionChange} event only if a selection change was the only change in the document
17
- * and the DOM selection is different then the view selection.
15
+ * observer checks if the DOM selection is different from the {@link module:engine/view/document~Document#selection view selection}.
16
+ * The selection observer fires {@link module:engine/view/document~Document#event:selectionChange} event only if
17
+ * a selection change was the only change in the document and the DOM selection is different from the view selection.
18
18
  *
19
19
  * This observer also manages the {@link module:engine/view/document~Document#isSelecting} property of the view document.
20
20
  *
21
21
  * Note that this observer is attached by the {@link module:engine/view/view~View} and is available by default.
22
22
  *
23
- * @see module:engine/view/observer/mutationobserver~MutationObserver
24
23
  * @extends module:engine/view/observer/observer~Observer
25
24
  */
26
25
  export default class SelectionObserver extends Observer {
@@ -133,7 +132,30 @@ export default class SelectionObserver extends Observer {
133
132
  // handler would like to check it and update (for example table multi cell selection).
134
133
  this.listenTo(domDocument, 'mouseup', endDocumentIsSelecting, { priority: 'highest', useCapture: true });
135
134
  this.listenTo(domDocument, 'selectionchange', (evt, domEvent) => {
135
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
136
+ // @if CK_DEBUG_TYPING // const domSelection = domDocument.defaultView.getSelection();
137
+ // @if CK_DEBUG_TYPING // console.group( '%c[SelectionObserver]%c selectionchange', 'color:green', ''
138
+ // @if CK_DEBUG_TYPING // );
139
+ // @if CK_DEBUG_TYPING // console.info( '%c[SelectionObserver]%c DOM Selection:', 'font-weight:bold;color:green', '',
140
+ // @if CK_DEBUG_TYPING // { node: domSelection.anchorNode, offset: domSelection.anchorOffset },
141
+ // @if CK_DEBUG_TYPING // { node: domSelection.focusNode, offset: domSelection.focusOffset }
142
+ // @if CK_DEBUG_TYPING // );
143
+ // @if CK_DEBUG_TYPING // }
144
+ // The Renderer is disabled while composing on non-android browsers, so we can't update the view selection
145
+ // because the DOM and view tree drifted apart. Position mapping could fail because of it.
146
+ if (this.document.isComposing && !env.isAndroid) {
147
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
148
+ // @if CK_DEBUG_TYPING // console.info( '%c[SelectionObserver]%c Selection change ignored (isComposing)',
149
+ // @if CK_DEBUG_TYPING // 'font-weight:bold;color:green', ''
150
+ // @if CK_DEBUG_TYPING // );
151
+ // @if CK_DEBUG_TYPING // console.groupEnd();
152
+ // @if CK_DEBUG_TYPING // }
153
+ return;
154
+ }
136
155
  this._handleSelectionChange(domEvent, domDocument);
156
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
157
+ // @if CK_DEBUG_TYPING // console.groupEnd();
158
+ // @if CK_DEBUG_TYPING // }
137
159
  // Defer the safety timeout when the selection changes (e.g. the user keeps extending the selection
138
160
  // using their mouse).
139
161
  this._documentIsSelectingInactivityTimeoutDebounced();
@@ -168,8 +190,6 @@ export default class SelectionObserver extends Observer {
168
190
  }
169
191
  // Ensure the mutation event will be before selection event on all browsers.
170
192
  this.mutationObserver.flush();
171
- // If there were mutations then the view will be re-rendered by the mutation observer and the selection
172
- // will be updated, so the selections will equal and the event will not be fired, as expected.
173
193
  const newViewSelection = this.domConverter.domSelectionToView(domSelection);
174
194
  // Do not convert selection change if the new view selection has no ranges in it.
175
195
  //
@@ -204,8 +224,14 @@ export default class SelectionObserver extends Observer {
204
224
  const data = {
205
225
  oldSelection: this.selection,
206
226
  newSelection: newViewSelection,
207
- domSelection: domSelection
227
+ domSelection
208
228
  };
229
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
230
+ // @if CK_DEBUG_TYPING // console.info( '%c[SelectionObserver]%c Fire selection change:',
231
+ // @if CK_DEBUG_TYPING // 'font-weight:bold;color:green', '',
232
+ // @if CK_DEBUG_TYPING // newViewSelection.getFirstRange()
233
+ // @if CK_DEBUG_TYPING // );
234
+ // @if CK_DEBUG_TYPING // }
209
235
  // Prepare data for new selection and fire appropriate events.
210
236
  this.document.fire('selectionChange', data);
211
237
  // Call `#_fireSelectionChangeDoneDebounced` every time when `selectionChange` event is fired.
@@ -2,9 +2,6 @@
2
2
  * @license Copyright (c) 2003-2022, 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
- /**
6
- * @module engine/view/observer/tabobserver
7
- */
8
5
  import Observer from './observer';
9
6
  import BubblingEventInfo from './bubblingeventinfo';
10
7
  import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
@@ -36,6 +36,10 @@ export function enablePlaceholder(options) {
36
36
  // If a post-fixer callback makes a change, it should return `true` so other post–fixers
37
37
  // can re–evaluate the document again.
38
38
  doc.registerPostFixer(writer => updateDocumentPlaceholders(doc, writer));
39
+ // Update placeholders on isComposing state change since rendering is disabled while in composition mode.
40
+ doc.on('change:isComposing', () => {
41
+ view.change(writer => updateDocumentPlaceholders(doc, writer));
42
+ }, { priority: 'high' });
39
43
  }
40
44
  // Store information about the element placeholder under its document.
41
45
  documentPlaceholders.get(doc).set(element, {
@@ -137,17 +141,20 @@ export function needsPlaceholder(element, keepOnFocus) {
137
141
  if (hasContent) {
138
142
  return false;
139
143
  }
144
+ const doc = element.document;
145
+ const viewSelection = doc.selection;
146
+ const selectionAnchor = viewSelection.anchor;
147
+ if (doc.isComposing && selectionAnchor && selectionAnchor.parent === element) {
148
+ return false;
149
+ }
140
150
  // Skip the focus check and make the placeholder visible already regardless of document focus state.
141
151
  if (keepOnFocus) {
142
152
  return true;
143
153
  }
144
- const doc = element.document;
145
154
  // If the document is blurred.
146
155
  if (!doc.isFocused) {
147
156
  return true;
148
157
  }
149
- const viewSelection = doc.selection;
150
- const selectionAnchor = viewSelection.anchor;
151
158
  // If document is focused and the element is empty but the selection is not anchored inside it.
152
159
  return !!selectionAnchor && selectionAnchor.parent !== element;
153
160
  }
@@ -114,6 +114,20 @@ export default class Renderer extends Observable {
114
114
  }
115
115
  });
116
116
  }
117
+ /**
118
+ * True if composition is in progress inside the document.
119
+ *
120
+ * This property is bound to the {@link module:engine/view/document~Document#isComposing `Document#isComposing`} property.
121
+ *
122
+ * @member {Boolean}
123
+ * @observable
124
+ */
125
+ this.set('isComposing', false);
126
+ this.on('change:isComposing', () => {
127
+ if (!this.isComposing) {
128
+ this.render();
129
+ }
130
+ });
117
131
  /**
118
132
  * The text node in which the inline filler was rendered.
119
133
  *
@@ -181,6 +195,23 @@ export default class Renderer extends Observable {
181
195
  * removed as long as the selection is in the text node which needed it at first.
182
196
  */
183
197
  render() {
198
+ // Ignore rendering while in the composition mode. Composition events are not cancellable and browser will modify the DOM tree.
199
+ // All marked elements, attributes, etc. will wait until next render after the composition ends.
200
+ // On Android composition events are immediately applied to the model, so we don't need to skip rendering,
201
+ // and we should not do it because the difference between view and DOM could lead to position mapping problems.
202
+ if (this.isComposing && !env.isAndroid) {
203
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
204
+ // @if CK_DEBUG_TYPING // console.info( '%c[Renderer]%c Rendering aborted while isComposing',
205
+ // @if CK_DEBUG_TYPING // 'color: green;font-weight: bold', ''
206
+ // @if CK_DEBUG_TYPING // );
207
+ // @if CK_DEBUG_TYPING // }
208
+ return;
209
+ }
210
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
211
+ // @if CK_DEBUG_TYPING // console.group( '%c[Renderer]%c Rendering',
212
+ // @if CK_DEBUG_TYPING // 'color: green;font-weight: bold', ''
213
+ // @if CK_DEBUG_TYPING // );
214
+ // @if CK_DEBUG_TYPING // }
184
215
  let inlineFillerPosition = null;
185
216
  const isInlineFillerRenderingPossible = env.isBlink && !env.isAndroid ? !this.isSelecting : true;
186
217
  // Refresh mappings.
@@ -265,6 +296,9 @@ export default class Renderer extends Observable {
265
296
  this.markedTexts.clear();
266
297
  this.markedAttributes.clear();
267
298
  this.markedChildren.clear();
299
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
300
+ // @if CK_DEBUG_TYPING // console.groupEnd();
301
+ // @if CK_DEBUG_TYPING // }
268
302
  }
269
303
  /**
270
304
  * Updates mappings of view element's children.
@@ -446,6 +480,11 @@ export default class Renderer extends Observable {
446
480
  if (nodeBefore instanceof ViewText || nodeAfter instanceof ViewText) {
447
481
  return false;
448
482
  }
483
+ // Do not use inline filler while typing outside inline elements on Android.
484
+ // The deleteContentBackward would remove part of the inline filler instead of removing last letter in a link.
485
+ if (env.isAndroid && (nodeBefore || nodeAfter)) {
486
+ return false;
487
+ }
449
488
  return true;
450
489
  }
451
490
  /**
@@ -460,23 +499,20 @@ export default class Renderer extends Observable {
460
499
  _updateText(viewText, options) {
461
500
  const domText = this.domConverter.findCorrespondingDomText(viewText);
462
501
  const newDomText = this.domConverter.viewToDom(viewText);
463
- const actualText = domText.data;
464
502
  let expectedText = newDomText.data;
465
503
  const filler = options.inlineFillerPosition;
466
504
  if (filler && filler.parent == viewText.parent && filler.offset == viewText.index) {
467
505
  expectedText = INLINE_FILLER + expectedText;
468
506
  }
469
- if (actualText != expectedText) {
470
- const actions = fastDiff(actualText, expectedText);
471
- for (const action of actions) {
472
- if (action.type === 'insert') {
473
- domText.insertData(action.index, action.values.join(''));
474
- }
475
- else { // 'delete'
476
- domText.deleteData(action.index, action.howMany);
477
- }
478
- }
479
- }
507
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
508
+ // @if CK_DEBUG_TYPING // console.group( '%c[Renderer]%c Update text',
509
+ // @if CK_DEBUG_TYPING // 'color: green;font-weight: bold', ''
510
+ // @if CK_DEBUG_TYPING // );
511
+ // @if CK_DEBUG_TYPING // }
512
+ updateTextNode(domText, expectedText);
513
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
514
+ // @if CK_DEBUG_TYPING // console.groupEnd();
515
+ // @if CK_DEBUG_TYPING // }
480
516
  }
481
517
  /**
482
518
  * Checks if attribute list needs to be updated and possibly updates it.
@@ -510,6 +546,9 @@ export default class Renderer extends Observable {
510
546
  /**
511
547
  * Checks if elements child list needs to be updated and possibly updates it.
512
548
  *
549
+ * Note that on Android, to reduce the risk of composition breaks, it tries to update data of an existing
550
+ * child text nodes instead of replacing them completely.
551
+ *
513
552
  * @private
514
553
  * @param {module:engine/view/element~Element} viewElement View element to update.
515
554
  * @param {Object} options
@@ -523,8 +562,27 @@ export default class Renderer extends Observable {
523
562
  // There is no need to process it. It will be processed when re-inserted.
524
563
  return;
525
564
  }
565
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
566
+ // @if CK_DEBUG_TYPING // console.group( '%c[Renderer]%c Update children',
567
+ // @if CK_DEBUG_TYPING // 'color: green;font-weight: bold', ''
568
+ // @if CK_DEBUG_TYPING // );
569
+ // @if CK_DEBUG_TYPING // }
570
+ // IME on Android inserts a new text node while typing after a link
571
+ // instead of updating an existing text node that follows the link.
572
+ // We must normalize those text nodes so the diff won't get confused.
573
+ // https://github.com/ckeditor/ckeditor5/issues/12574.
574
+ if (env.isAndroid) {
575
+ let previousDomNode = null;
576
+ for (const domNode of Array.from(domElement.childNodes)) {
577
+ if (previousDomNode && isText(previousDomNode) && isText(domNode)) {
578
+ domElement.normalize();
579
+ break;
580
+ }
581
+ previousDomNode = domNode;
582
+ }
583
+ }
526
584
  const inlineFillerPosition = options.inlineFillerPosition;
527
- const actualDomChildren = this.domConverter.mapViewToDom(viewElement).childNodes;
585
+ const actualDomChildren = domElement.childNodes;
528
586
  const expectedDomChildren = Array.from(this.domConverter.viewChildrenToDom(viewElement, { bind: true }));
529
587
  // Inline filler element has to be created as it is present in the DOM, but not in the view. It is required
530
588
  // during diffing so text nodes could be compared correctly and also during rendering to maintain
@@ -533,6 +591,17 @@ export default class Renderer extends Observable {
533
591
  addInlineFiller(domElement.ownerDocument, expectedDomChildren, inlineFillerPosition.offset);
534
592
  }
535
593
  const diff = this._diffNodeLists(actualDomChildren, expectedDomChildren);
594
+ // The rendering is not disabled on Android in the composition mode.
595
+ // Composition events are not cancellable and browser will modify the DOM tree.
596
+ // On Android composition events are immediately applied to the model, so we don't need to skip rendering,
597
+ // and we should not do it because the difference between view and DOM could lead to position mapping problems.
598
+ // Since the composition is fragile and often breaks if the composed text node is replaced while composing
599
+ // we need to make sure that we update the existing text node and not replace it with another one.
600
+ // We don't want to change the behavior on other browsers for safety, but maybe one day cause it seems to make sense.
601
+ // https://github.com/ckeditor/ckeditor5/issues/12455.
602
+ const actions = env.isAndroid ?
603
+ this._findReplaceActions(diff, actualDomChildren, expectedDomChildren, { replaceText: true }) :
604
+ diff;
536
605
  let i = 0;
537
606
  const nodesToUnbind = new Set();
538
607
  // Handle deletions first.
@@ -541,21 +610,44 @@ export default class Renderer extends Observable {
541
610
  // and it disrupts the whole algorithm. See https://github.com/ckeditor/ckeditor5/issues/6367.
542
611
  //
543
612
  // It doesn't matter in what order we remove or add nodes, as long as we remove and add correct nodes at correct indexes.
544
- for (const action of diff) {
613
+ for (const action of actions) {
545
614
  if (action === 'delete') {
615
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
616
+ // @if CK_DEBUG_TYPING // console.info( '%c[Renderer]%c Remove node',
617
+ // @if CK_DEBUG_TYPING // 'color: green;font-weight: bold', '', actualDomChildren[ i ]
618
+ // @if CK_DEBUG_TYPING // );
619
+ // @if CK_DEBUG_TYPING // }
546
620
  nodesToUnbind.add(actualDomChildren[i]);
547
621
  remove(actualDomChildren[i]);
548
622
  }
549
- else if (action === 'equal') {
623
+ else if (action === 'equal' || action === 'replace') {
550
624
  i++;
551
625
  }
552
626
  }
553
627
  i = 0;
554
- for (const action of diff) {
628
+ for (const action of actions) {
555
629
  if (action === 'insert') {
630
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
631
+ // @if CK_DEBUG_TYPING // console.info( '%c[Renderer]%c Insert node',
632
+ // @if CK_DEBUG_TYPING // 'color: green;font-weight: bold', '', expectedDomChildren[ i ]
633
+ // @if CK_DEBUG_TYPING // );
634
+ // @if CK_DEBUG_TYPING // }
556
635
  insertAt(domElement, i, expectedDomChildren[i]);
557
636
  i++;
558
637
  }
638
+ // Update the existing text node data. Note that replace action is generated only for Android for now.
639
+ else if (action === 'replace') {
640
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
641
+ // @if CK_DEBUG_TYPING // console.group( '%c[Renderer]%c Update text node',
642
+ // @if CK_DEBUG_TYPING // 'color: green;font-weight: bold', ''
643
+ // @if CK_DEBUG_TYPING // );
644
+ // @if CK_DEBUG_TYPING // }
645
+ updateTextNode(actualDomChildren[i], expectedDomChildren[i].data);
646
+ i++;
647
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
648
+ // @if CK_DEBUG_TYPING // console.groupEnd();
649
+ // @if CK_DEBUG_TYPING // }
650
+ }
559
651
  else if (action === 'equal') {
560
652
  // Force updating text nodes inside elements which did not change and do not need to be re-rendered (#1125).
561
653
  // Do it here (not in the loop above) because only after insertions the `i` index is correct.
@@ -571,6 +663,9 @@ export default class Renderer extends Observable {
571
663
  this.domConverter.unbindDomElement(node);
572
664
  }
573
665
  }
666
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
667
+ // @if CK_DEBUG_TYPING // console.groupEnd();
668
+ // @if CK_DEBUG_TYPING // }
574
669
  }
575
670
  /**
576
671
  * Shorthand for diffing two arrays or node lists of DOM nodes.
@@ -597,9 +692,11 @@ export default class Renderer extends Observable {
597
692
  * @param {Array.<String>} actions Actions array which is a result of the {@link module:utils/diff~diff} function.
598
693
  * @param {Array.<ViewNode>|NodeList} actualDom Actual DOM children
599
694
  * @param {Array.<ViewNode>} expectedDom Expected DOM children.
695
+ * @param {Object} [options] Options
696
+ * @param {Boolean} [options.replaceText] Mark text nodes replacement.
600
697
  * @returns {Array.<String>} Actions array modified with the `replace` actions.
601
698
  */
602
- _findReplaceActions(actions, actualDom, expectedDom) {
699
+ _findReplaceActions(actions, actualDom, expectedDom, options = {}) {
603
700
  // If there is no both 'insert' and 'delete' actions, no need to check for replaced elements.
604
701
  if (actions.indexOf('insert') === -1 || actions.indexOf('delete') === -1) {
605
702
  return actions;
@@ -616,7 +713,8 @@ export default class Renderer extends Observable {
616
713
  actualSlice.push(actualDom[counter.equal + counter.delete]);
617
714
  }
618
715
  else { // equal
619
- newActions = newActions.concat(diff(actualSlice, expectedSlice, areSimilar).map(x => x === 'equal' ? 'replace' : x));
716
+ newActions = newActions.concat(diff(actualSlice, expectedSlice, options.replaceText ? areTextNodes : areSimilar)
717
+ .map(x => x === 'equal' ? 'replace' : x));
620
718
  newActions.push('equal');
621
719
  // Reset stored elements on 'equal'.
622
720
  actualSlice = [];
@@ -624,7 +722,8 @@ export default class Renderer extends Observable {
624
722
  }
625
723
  counter[action]++;
626
724
  }
627
- return newActions.concat(diff(actualSlice, expectedSlice, areSimilar).map(x => x === 'equal' ? 'replace' : x));
725
+ return newActions.concat(diff(actualSlice, expectedSlice, options.replaceText ? areTextNodes : areSimilar)
726
+ .map(x => x === 'equal' ? 'replace' : x));
628
727
  }
629
728
  /**
630
729
  * Marks text nodes to be synchronized.
@@ -671,14 +770,23 @@ export default class Renderer extends Observable {
671
770
  if (!this.isFocused || !domRoot) {
672
771
  return;
673
772
  }
674
- // Render selection.
773
+ // Render fake selection - create the fake selection container (if needed) and move DOM selection to it.
675
774
  if (this.selection.isFake) {
676
775
  this._updateFakeSelection(domRoot);
677
776
  }
678
- else {
777
+ // There was a fake selection so remove it and update the DOM selection.
778
+ // This is especially important on Android because otherwise IME will try to compose over the fake selection container.
779
+ else if (this._fakeSelectionContainer && this._fakeSelectionContainer.isConnected) {
679
780
  this._removeFakeSelection();
680
781
  this._updateDomSelection(domRoot);
681
782
  }
783
+ // Update the DOM selection in case of a plain selection change (no fake selection is involved).
784
+ // On non-Android the whole rendering is disabled in composition mode (including DOM selection update),
785
+ // but updating DOM selection should be also disabled on Android if in the middle of the composition
786
+ // (to not interrupt it).
787
+ else if (!(this.isComposing && env.isAndroid)) {
788
+ this._updateDomSelection(domRoot);
789
+ }
682
790
  }
683
791
  /**
684
792
  * Updates the fake selection.
@@ -726,6 +834,11 @@ export default class Renderer extends Observable {
726
834
  // selected. If there is any editable selected, it is okay (editable is taken from selection anchor).
727
835
  const anchor = this.domConverter.viewPositionToDom(this.selection.anchor);
728
836
  const focus = this.domConverter.viewPositionToDom(this.selection.focus);
837
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
838
+ // @if CK_DEBUG_TYPING // console.info( '%c[Renderer]%c Update DOM selection:',
839
+ // @if CK_DEBUG_TYPING // 'color: green;font-weight: bold', '', anchor, focus
840
+ // @if CK_DEBUG_TYPING // );
841
+ // @if CK_DEBUG_TYPING // }
729
842
  domSelection.collapse(anchor.parent, anchor.offset);
730
843
  domSelection.extend(focus.parent, focus.offset);
731
844
  // Firefox–specific hack (https://github.com/ckeditor/ckeditor5-engine/issues/1439).
@@ -873,6 +986,11 @@ function areSimilar(node1, node2) {
873
986
  !isComment(node1) && !isComment(node2) &&
874
987
  node1.tagName.toLowerCase() === node2.tagName.toLowerCase();
875
988
  }
989
+ // Whether two DOM nodes are text nodes.
990
+ function areTextNodes(node1, node2) {
991
+ return isNode(node1) && isNode(node2) &&
992
+ isText(node1) && isText(node2);
993
+ }
876
994
  // Whether two dom nodes should be considered as the same.
877
995
  // Two nodes which are considered the same are:
878
996
  //
@@ -953,3 +1071,35 @@ function createFakeSelectionContainer(domDocument) {
953
1071
  container.textContent = '\u00A0';
954
1072
  return container;
955
1073
  }
1074
+ // Checks if text needs to be updated and possibly updates it by removing and inserting only parts
1075
+ // of the data from the existing text node to reduce impact on the IME composition.
1076
+ //
1077
+ // @param {Text} domText DOM text node to update.
1078
+ // @param {String} expectedText The expected data of a text node.
1079
+ function updateTextNode(domText, expectedText) {
1080
+ const actualText = domText.data;
1081
+ if (actualText == expectedText) {
1082
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
1083
+ // @if CK_DEBUG_TYPING // console.info( '%c[Renderer]%c Text node does not need update:',
1084
+ // @if CK_DEBUG_TYPING // 'color: green;font-weight: bold', '',
1085
+ // @if CK_DEBUG_TYPING // `"${ domText.data }" (${ domText.data.length })`
1086
+ // @if CK_DEBUG_TYPING // );
1087
+ // @if CK_DEBUG_TYPING // }
1088
+ return;
1089
+ }
1090
+ // @if CK_DEBUG_TYPING // if ( window.logCKETyping ) {
1091
+ // @if CK_DEBUG_TYPING // console.info( '%c[Renderer]%c Update text node:',
1092
+ // @if CK_DEBUG_TYPING // 'color: green;font-weight: bold', '',
1093
+ // @if CK_DEBUG_TYPING // `"${ domText.data }" (${ domText.data.length }) -> "${ expectedText }" (${ expectedText.length })`
1094
+ // @if CK_DEBUG_TYPING // );
1095
+ // @if CK_DEBUG_TYPING // }
1096
+ const actions = fastDiff(actualText, expectedText);
1097
+ for (const action of actions) {
1098
+ if (action.type === 'insert') {
1099
+ domText.insertData(action.index, action.values.join(''));
1100
+ }
1101
+ else { // 'delete'
1102
+ domText.deleteData(action.index, action.howMany);
1103
+ }
1104
+ }
1105
+ }
package/src/view/view.js CHANGED
@@ -12,9 +12,9 @@ import DomConverter from './domconverter';
12
12
  import Position from './position';
13
13
  import Range from './range';
14
14
  import Selection from './selection';
15
- import MutationObserver from './observer/mutationobserver';
16
15
  import KeyObserver from './observer/keyobserver';
17
16
  import FakeSelectionObserver from './observer/fakeselectionobserver';
17
+ import MutationObserver from './observer/mutationobserver';
18
18
  import SelectionObserver from './observer/selectionobserver';
19
19
  import FocusObserver from './observer/focusobserver';
20
20
  import CompositionObserver from './observer/compositionobserver';
@@ -26,7 +26,6 @@ import { scrollViewportToShowTarget } from '@ckeditor/ckeditor5-utils/src/dom/sc
26
26
  import { injectUiElementHandling } from './uielement';
27
27
  import { injectQuirksHandling } from './filler';
28
28
  import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
29
- import env from '@ckeditor/ckeditor5-utils/src/env';
30
29
  /**
31
30
  * Editor's view controller class. Its main responsibility is DOM - View management for editing purposes, to provide
32
31
  * abstraction over the DOM structure and events and hide all browsers quirks.
@@ -43,12 +42,12 @@ import env from '@ckeditor/ckeditor5-utils/src/env';
43
42
  * on DOM and fire events on the {@link module:engine/view/document~Document Document}.
44
43
  * Note that the following observers are added by the class constructor and are always available:
45
44
  *
46
- * * {@link module:engine/view/observer/mutationobserver~MutationObserver},
47
45
  * * {@link module:engine/view/observer/selectionobserver~SelectionObserver},
48
46
  * * {@link module:engine/view/observer/focusobserver~FocusObserver},
49
47
  * * {@link module:engine/view/observer/keyobserver~KeyObserver},
50
48
  * * {@link module:engine/view/observer/fakeselectionobserver~FakeSelectionObserver}.
51
49
  * * {@link module:engine/view/observer/compositionobserver~CompositionObserver}.
50
+ * * {@link module:engine/view/observer/inputobserver~InputObserver}.
52
51
  * * {@link module:engine/view/observer/arrowkeysobserver~ArrowKeysObserver}.
53
52
  * * {@link module:engine/view/observer/tabobserver~TabObserver}.
54
53
  *
@@ -109,7 +108,7 @@ export default class View extends Observable {
109
108
  * @type {module:engine/view/renderer~Renderer}
110
109
  */
111
110
  this._renderer = new Renderer(this.domConverter, this.document.selection);
112
- this._renderer.bind('isFocused', 'isSelecting').to(this.document, 'isFocused', 'isSelecting');
111
+ this._renderer.bind('isFocused', 'isSelecting', 'isComposing').to(this.document, 'isFocused', 'isSelecting', 'isComposing');
113
112
  /**
114
113
  * A DOM root attributes cache. It saves the initial values of DOM root attributes before the DOM element
115
114
  * is {@link module:engine/view/view~View#attachDomRoot attached} to the view so later on, when
@@ -171,10 +170,8 @@ export default class View extends Observable {
171
170
  this.addObserver(FakeSelectionObserver);
172
171
  this.addObserver(CompositionObserver);
173
172
  this.addObserver(ArrowKeysObserver);
173
+ this.addObserver(InputObserver);
174
174
  this.addObserver(TabObserver);
175
- if (env.isAndroid) {
176
- this.addObserver(InputObserver);
177
- }
178
175
  // Inject quirks handlers.
179
176
  injectQuirksHandling(this);
180
177
  injectUiElementHandling(this);
@@ -615,7 +612,7 @@ export default class View extends Observable {
615
612
  }
616
613
  }
617
614
  /**
618
- * Renders all changes. In order to avoid triggering the observers (e.g. mutations) all observers are disabled
615
+ * Renders all changes. In order to avoid triggering the observers (e.g. selection) all observers are disabled
619
616
  * before rendering and re-enabled after that.
620
617
  *
621
618
  * @private