@ckeditor/ckeditor5-engine 35.0.1 → 35.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/package.json +30 -24
  3. package/src/controller/datacontroller.js +467 -561
  4. package/src/controller/editingcontroller.js +168 -204
  5. package/src/conversion/conversion.js +541 -565
  6. package/src/conversion/conversionhelpers.js +24 -28
  7. package/src/conversion/downcastdispatcher.js +457 -686
  8. package/src/conversion/downcasthelpers.js +1583 -1965
  9. package/src/conversion/mapper.js +518 -707
  10. package/src/conversion/modelconsumable.js +240 -283
  11. package/src/conversion/upcastdispatcher.js +372 -718
  12. package/src/conversion/upcasthelpers.js +707 -818
  13. package/src/conversion/viewconsumable.js +524 -581
  14. package/src/dataprocessor/basichtmlwriter.js +12 -16
  15. package/src/dataprocessor/dataprocessor.js +5 -0
  16. package/src/dataprocessor/htmldataprocessor.js +100 -116
  17. package/src/dataprocessor/htmlwriter.js +1 -18
  18. package/src/dataprocessor/xmldataprocessor.js +116 -137
  19. package/src/dev-utils/model.js +260 -352
  20. package/src/dev-utils/operationreplayer.js +106 -126
  21. package/src/dev-utils/utils.js +34 -51
  22. package/src/dev-utils/view.js +632 -753
  23. package/src/index.js +0 -11
  24. package/src/model/batch.js +111 -127
  25. package/src/model/differ.js +988 -1233
  26. package/src/model/document.js +340 -449
  27. package/src/model/documentfragment.js +327 -364
  28. package/src/model/documentselection.js +996 -1189
  29. package/src/model/element.js +306 -410
  30. package/src/model/history.js +224 -262
  31. package/src/model/item.js +5 -0
  32. package/src/model/liveposition.js +84 -145
  33. package/src/model/liverange.js +108 -185
  34. package/src/model/markercollection.js +379 -480
  35. package/src/model/model.js +883 -1034
  36. package/src/model/node.js +419 -463
  37. package/src/model/nodelist.js +176 -201
  38. package/src/model/operation/attributeoperation.js +153 -182
  39. package/src/model/operation/detachoperation.js +64 -83
  40. package/src/model/operation/insertoperation.js +135 -166
  41. package/src/model/operation/markeroperation.js +114 -140
  42. package/src/model/operation/mergeoperation.js +163 -191
  43. package/src/model/operation/moveoperation.js +157 -187
  44. package/src/model/operation/nooperation.js +28 -38
  45. package/src/model/operation/operation.js +106 -125
  46. package/src/model/operation/operationfactory.js +30 -34
  47. package/src/model/operation/renameoperation.js +109 -135
  48. package/src/model/operation/rootattributeoperation.js +155 -188
  49. package/src/model/operation/splitoperation.js +196 -232
  50. package/src/model/operation/transform.js +1833 -2204
  51. package/src/model/operation/utils.js +140 -204
  52. package/src/model/position.js +980 -1053
  53. package/src/model/range.js +910 -1028
  54. package/src/model/rootelement.js +77 -97
  55. package/src/model/schema.js +1189 -1835
  56. package/src/model/selection.js +745 -862
  57. package/src/model/text.js +90 -114
  58. package/src/model/textproxy.js +204 -240
  59. package/src/model/treewalker.js +316 -397
  60. package/src/model/typecheckable.js +16 -0
  61. package/src/model/utils/autoparagraphing.js +32 -44
  62. package/src/model/utils/deletecontent.js +334 -418
  63. package/src/model/utils/findoptimalinsertionrange.js +25 -36
  64. package/src/model/utils/getselectedcontent.js +96 -118
  65. package/src/model/utils/insertcontent.js +757 -773
  66. package/src/model/utils/insertobject.js +96 -119
  67. package/src/model/utils/modifyselection.js +120 -158
  68. package/src/model/utils/selection-post-fixer.js +153 -201
  69. package/src/model/writer.js +1305 -1474
  70. package/src/view/attributeelement.js +189 -225
  71. package/src/view/containerelement.js +75 -85
  72. package/src/view/document.js +172 -215
  73. package/src/view/documentfragment.js +200 -249
  74. package/src/view/documentselection.js +338 -367
  75. package/src/view/domconverter.js +1370 -1617
  76. package/src/view/downcastwriter.js +1747 -2076
  77. package/src/view/editableelement.js +81 -97
  78. package/src/view/element.js +739 -890
  79. package/src/view/elementdefinition.js +5 -0
  80. package/src/view/emptyelement.js +82 -92
  81. package/src/view/filler.js +35 -50
  82. package/src/view/item.js +5 -0
  83. package/src/view/matcher.js +260 -559
  84. package/src/view/node.js +274 -360
  85. package/src/view/observer/arrowkeysobserver.js +19 -28
  86. package/src/view/observer/bubblingemittermixin.js +120 -263
  87. package/src/view/observer/bubblingeventinfo.js +47 -55
  88. package/src/view/observer/clickobserver.js +7 -13
  89. package/src/view/observer/compositionobserver.js +14 -24
  90. package/src/view/observer/domeventdata.js +57 -67
  91. package/src/view/observer/domeventobserver.js +40 -64
  92. package/src/view/observer/fakeselectionobserver.js +81 -96
  93. package/src/view/observer/focusobserver.js +45 -61
  94. package/src/view/observer/inputobserver.js +7 -13
  95. package/src/view/observer/keyobserver.js +17 -27
  96. package/src/view/observer/mouseobserver.js +7 -14
  97. package/src/view/observer/mutationobserver.js +220 -315
  98. package/src/view/observer/observer.js +81 -102
  99. package/src/view/observer/selectionobserver.js +199 -246
  100. package/src/view/observer/tabobserver.js +23 -36
  101. package/src/view/placeholder.js +128 -173
  102. package/src/view/position.js +350 -401
  103. package/src/view/range.js +453 -513
  104. package/src/view/rawelement.js +85 -112
  105. package/src/view/renderer.js +874 -1018
  106. package/src/view/rooteditableelement.js +80 -90
  107. package/src/view/selection.js +608 -689
  108. package/src/view/styles/background.js +43 -44
  109. package/src/view/styles/border.js +220 -276
  110. package/src/view/styles/margin.js +8 -17
  111. package/src/view/styles/padding.js +8 -16
  112. package/src/view/styles/utils.js +127 -160
  113. package/src/view/stylesmap.js +728 -905
  114. package/src/view/text.js +102 -126
  115. package/src/view/textproxy.js +144 -170
  116. package/src/view/treewalker.js +383 -479
  117. package/src/view/typecheckable.js +19 -0
  118. package/src/view/uielement.js +166 -187
  119. package/src/view/upcastwriter.js +395 -449
  120. package/src/view/view.js +569 -664
  121. package/src/dataprocessor/dataprocessor.jsdoc +0 -64
  122. package/src/model/item.jsdoc +0 -14
  123. package/src/view/elementdefinition.jsdoc +0 -59
  124. package/src/view/item.jsdoc +0 -14
@@ -2,31 +2,23 @@
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
- /* globals Node */
7
-
8
5
  /**
9
6
  * @module engine/view/renderer
10
7
  */
11
-
12
8
  import ViewText from './text';
13
9
  import ViewPosition from './position';
14
10
  import { INLINE_FILLER, INLINE_FILLER_LENGTH, startsWithFiller, isInlineFiller } from './filler';
15
-
16
- import mix from '@ckeditor/ckeditor5-utils/src/mix';
17
- import diff from '@ckeditor/ckeditor5-utils/src/diff';
11
+ import { default as diff } from '@ckeditor/ckeditor5-utils/src/diff';
18
12
  import insertAt from '@ckeditor/ckeditor5-utils/src/dom/insertat';
19
13
  import remove from '@ckeditor/ckeditor5-utils/src/dom/remove';
20
- import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
14
+ import { Observable } from '@ckeditor/ckeditor5-utils/src/observablemixin';
21
15
  import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
22
16
  import isText from '@ckeditor/ckeditor5-utils/src/dom/istext';
23
17
  import isComment from '@ckeditor/ckeditor5-utils/src/dom/iscomment';
24
18
  import isNode from '@ckeditor/ckeditor5-utils/src/dom/isnode';
25
19
  import fastDiff from '@ckeditor/ckeditor5-utils/src/fastdiff';
26
20
  import env from '@ckeditor/ckeditor5-utils/src/env';
27
-
28
21
  import '../../theme/renderer.css';
29
-
30
22
  /**
31
23
  * Renderer is responsible for updating the DOM structure and the DOM selection based on
32
24
  * the {@link module:engine/view/renderer~Renderer#markToSync information about updated view nodes}.
@@ -40,923 +32,806 @@ import '../../theme/renderer.css';
40
32
  * Renderer uses {@link module:engine/view/domconverter~DomConverter} to transform view nodes and positions
41
33
  * to and from the DOM.
42
34
  */
43
- export default class Renderer {
44
- /**
45
- * Creates a renderer instance.
46
- *
47
- * @param {module:engine/view/domconverter~DomConverter} domConverter Converter instance.
48
- * @param {module:engine/view/documentselection~DocumentSelection} selection View selection.
49
- */
50
- constructor( domConverter, selection ) {
51
- /**
52
- * Set of DOM Documents instances.
53
- *
54
- * @readonly
55
- * @member {Set.<Document>}
56
- */
57
- this.domDocuments = new Set();
58
-
59
- /**
60
- * Converter instance.
61
- *
62
- * @readonly
63
- * @member {module:engine/view/domconverter~DomConverter}
64
- */
65
- this.domConverter = domConverter;
66
-
67
- /**
68
- * Set of nodes which attributes changed and may need to be rendered.
69
- *
70
- * @readonly
71
- * @member {Set.<module:engine/view/node~Node>}
72
- */
73
- this.markedAttributes = new Set();
74
-
75
- /**
76
- * Set of elements which child lists changed and may need to be rendered.
77
- *
78
- * @readonly
79
- * @member {Set.<module:engine/view/node~Node>}
80
- */
81
- this.markedChildren = new Set();
82
-
83
- /**
84
- * Set of text nodes which text data changed and may need to be rendered.
85
- *
86
- * @readonly
87
- * @member {Set.<module:engine/view/node~Node>}
88
- */
89
- this.markedTexts = new Set();
90
-
91
- /**
92
- * View selection. Renderer updates DOM selection based on the view selection.
93
- *
94
- * @readonly
95
- * @member {module:engine/view/documentselection~DocumentSelection}
96
- */
97
- this.selection = selection;
98
-
99
- /**
100
- * Indicates if the view document is focused and selection can be rendered. Selection will not be rendered if
101
- * this is set to `false`.
102
- *
103
- * @member {Boolean}
104
- * @observable
105
- */
106
- this.set( 'isFocused', false );
107
-
108
- /**
109
- * Indicates whether the user is making a selection in the document (e.g. holding the mouse button and moving the cursor).
110
- * When they stop selecting, the property goes back to `false`.
111
- *
112
- * Note: In some browsers, the renderer will stop rendering the selection and inline fillers while the user is making
113
- * a selection to avoid glitches in DOM selection
114
- * (https://github.com/ckeditor/ckeditor5/issues/10562, https://github.com/ckeditor/ckeditor5/issues/10723).
115
- *
116
- * @member {Boolean}
117
- * @observable
118
- */
119
- this.set( 'isSelecting', false );
120
-
121
- // Rendering the selection and inline filler manipulation should be postponed in (non-Android) Blink until the user finishes
122
- // creating the selection in DOM to avoid accidental selection collapsing
123
- // (https://github.com/ckeditor/ckeditor5/issues/10562, https://github.com/ckeditor/ckeditor5/issues/10723).
124
- // When the user stops selecting, all pending changes should be rendered ASAP, though.
125
- if ( env.isBlink && !env.isAndroid ) {
126
- this.on( 'change:isSelecting', () => {
127
- if ( !this.isSelecting ) {
128
- this.render();
129
- }
130
- } );
131
- }
132
-
133
- /**
134
- * The text node in which the inline filler was rendered.
135
- *
136
- * @private
137
- * @member {Text}
138
- */
139
- this._inlineFiller = null;
140
-
141
- /**
142
- * DOM element containing fake selection.
143
- *
144
- * @private
145
- * @type {null|HTMLElement}
146
- */
147
- this._fakeSelectionContainer = null;
148
- }
149
-
150
- /**
151
- * Marks a view node to be updated in the DOM by {@link #render `render()`}.
152
- *
153
- * Note that only view nodes whose parents have corresponding DOM elements need to be marked to be synchronized.
154
- *
155
- * @see #markedAttributes
156
- * @see #markedChildren
157
- * @see #markedTexts
158
- *
159
- * @param {module:engine/view/document~ChangeType} type Type of the change.
160
- * @param {module:engine/view/node~Node} node Node to be marked.
161
- */
162
- markToSync( type, node ) {
163
- if ( type === 'text' ) {
164
- if ( this.domConverter.mapViewToDom( node.parent ) ) {
165
- this.markedTexts.add( node );
166
- }
167
- } else {
168
- // If the node has no DOM element it is not rendered yet,
169
- // its children/attributes do not need to be marked to be sync.
170
- if ( !this.domConverter.mapViewToDom( node ) ) {
171
- return;
172
- }
173
-
174
- if ( type === 'attributes' ) {
175
- this.markedAttributes.add( node );
176
- } else if ( type === 'children' ) {
177
- this.markedChildren.add( node );
178
- } else {
179
- /**
180
- * Unknown type passed to Renderer.markToSync.
181
- *
182
- * @error view-renderer-unknown-type
183
- */
184
- throw new CKEditorError( 'view-renderer-unknown-type', this );
185
- }
186
- }
187
- }
188
-
189
- /**
190
- * Renders all buffered changes ({@link #markedAttributes}, {@link #markedChildren} and {@link #markedTexts}) and
191
- * the current view selection (if needed) to the DOM by applying a minimal set of changes to it.
192
- *
193
- * Renderer tries not to break the text composition (e.g. IME) and x-index of the selection,
194
- * so it does as little as it is needed to update the DOM.
195
- *
196
- * Renderer also handles {@link module:engine/view/filler fillers}. Especially, it checks if the inline filler is needed
197
- * at the selection position and adds or removes it. To prevent breaking text composition inline filler will not be
198
- * removed as long as the selection is in the text node which needed it at first.
199
- */
200
- render() {
201
- let inlineFillerPosition;
202
- const isInlineFillerRenderingPossible = env.isBlink && !env.isAndroid ? !this.isSelecting : true;
203
-
204
- // Refresh mappings.
205
- for ( const element of this.markedChildren ) {
206
- this._updateChildrenMappings( element );
207
- }
208
-
209
- // Don't manipulate inline fillers while the selection is being made in (non-Android) Blink to prevent accidental
210
- // DOM selection collapsing
211
- // (https://github.com/ckeditor/ckeditor5/issues/10562, https://github.com/ckeditor/ckeditor5/issues/10723).
212
- if ( isInlineFillerRenderingPossible ) {
213
- // There was inline filler rendered in the DOM but it's not
214
- // at the selection position any more, so we can remove it
215
- // (cause even if it's needed, it must be placed in another location).
216
- if ( this._inlineFiller && !this._isSelectionInInlineFiller() ) {
217
- this._removeInlineFiller();
218
- }
219
-
220
- // If we've got the filler, let's try to guess its position in the view.
221
- if ( this._inlineFiller ) {
222
- inlineFillerPosition = this._getInlineFillerPosition();
223
- }
224
- // Otherwise, if it's needed, create it at the selection position.
225
- else if ( this._needsInlineFillerAtSelection() ) {
226
- inlineFillerPosition = this.selection.getFirstPosition();
227
-
228
- // Do not use `markToSync` so it will be added even if the parent is already added.
229
- this.markedChildren.add( inlineFillerPosition.parent );
230
- }
231
- }
232
- // Make sure the inline filler has any parent, so it can be mapped to view position by DomConverter.
233
- else if ( this._inlineFiller && this._inlineFiller.parentNode ) {
234
- // While the user is making selection, preserve the inline filler at its original position.
235
- inlineFillerPosition = this.domConverter.domPositionToView( this._inlineFiller );
236
-
237
- // While down-casting the document selection attributes, all existing empty
238
- // attribute elements (for selection position) are removed from the view and DOM,
239
- // so make sure that we were able to map filler position.
240
- // https://github.com/ckeditor/ckeditor5/issues/12026
241
- if ( inlineFillerPosition && inlineFillerPosition.parent.is( '$text' ) ) {
242
- // The inline filler position is expected to be before the text node.
243
- inlineFillerPosition = ViewPosition._createBefore( inlineFillerPosition.parent );
244
- }
245
- }
246
-
247
- for ( const element of this.markedAttributes ) {
248
- this._updateAttrs( element );
249
- }
250
-
251
- for ( const element of this.markedChildren ) {
252
- this._updateChildren( element, { inlineFillerPosition } );
253
- }
254
-
255
- for ( const node of this.markedTexts ) {
256
- if ( !this.markedChildren.has( node.parent ) && this.domConverter.mapViewToDom( node.parent ) ) {
257
- this._updateText( node, { inlineFillerPosition } );
258
- }
259
- }
260
-
261
- // * Check whether the inline filler is required and where it really is in the DOM.
262
- // At this point in most cases it will be in the DOM, but there are exceptions.
263
- // For example, if the inline filler was deep in the created DOM structure, it will not be created.
264
- // Similarly, if it was removed at the beginning of this function and then neither text nor children were updated,
265
- // it will not be present. Fix those and similar scenarios.
266
- // * Don't manipulate inline fillers while the selection is being made in (non-Android) Blink to prevent accidental
267
- // DOM selection collapsing
268
- // (https://github.com/ckeditor/ckeditor5/issues/10562, https://github.com/ckeditor/ckeditor5/issues/10723).
269
- if ( isInlineFillerRenderingPossible ) {
270
- if ( inlineFillerPosition ) {
271
- const fillerDomPosition = this.domConverter.viewPositionToDom( inlineFillerPosition );
272
- const domDocument = fillerDomPosition.parent.ownerDocument;
273
-
274
- if ( !startsWithFiller( fillerDomPosition.parent ) ) {
275
- // Filler has not been created at filler position. Create it now.
276
- this._inlineFiller = addInlineFiller( domDocument, fillerDomPosition.parent, fillerDomPosition.offset );
277
- } else {
278
- // Filler has been found, save it.
279
- this._inlineFiller = fillerDomPosition.parent;
280
- }
281
- } else {
282
- // There is no filler needed.
283
- this._inlineFiller = null;
284
- }
285
- }
286
-
287
- // First focus the new editing host, then update the selection.
288
- // Otherwise, FF may throw an error (https://github.com/ckeditor/ckeditor5/issues/721).
289
- this._updateFocus();
290
- this._updateSelection();
291
-
292
- this.markedTexts.clear();
293
- this.markedAttributes.clear();
294
- this.markedChildren.clear();
295
- }
296
-
297
- /**
298
- * Updates mappings of view element's children.
299
- *
300
- * Children that were replaced in the view structure by similar elements (same tag name) are treated as 'replaced'.
301
- * This means that their mappings can be updated so the new view elements are mapped to the existing DOM elements.
302
- * Thanks to that these elements do not need to be re-rendered completely.
303
- *
304
- * @private
305
- * @param {module:engine/view/node~Node} viewElement The view element whose children mappings will be updated.
306
- */
307
- _updateChildrenMappings( viewElement ) {
308
- const domElement = this.domConverter.mapViewToDom( viewElement );
309
-
310
- if ( !domElement ) {
311
- // If there is no `domElement` it means that it was already removed from DOM and there is no need to process it.
312
- return;
313
- }
314
-
315
- // Removing nodes from the DOM as we iterate can cause `actualDomChildren`
316
- // (which is a live-updating `NodeList`) to get out of sync with the
317
- // indices that we compute as we iterate over `actions`.
318
- // This would produce incorrect element mappings.
319
- //
320
- // Converting live list to an array to make the list static.
321
- const actualDomChildren = Array.from(
322
- this.domConverter.mapViewToDom( viewElement ).childNodes
323
- );
324
- const expectedDomChildren = Array.from(
325
- this.domConverter.viewChildrenToDom( viewElement, { withChildren: false } )
326
- );
327
- const diff = this._diffNodeLists( actualDomChildren, expectedDomChildren );
328
- const actions = this._findReplaceActions( diff, actualDomChildren, expectedDomChildren );
329
-
330
- if ( actions.indexOf( 'replace' ) !== -1 ) {
331
- const counter = { equal: 0, insert: 0, delete: 0 };
332
-
333
- for ( const action of actions ) {
334
- if ( action === 'replace' ) {
335
- const insertIndex = counter.equal + counter.insert;
336
- const deleteIndex = counter.equal + counter.delete;
337
- const viewChild = viewElement.getChild( insertIndex );
338
-
339
- // UIElement and RawElement are special cases. Their children are not stored in a view (#799)
340
- // so we cannot use them with replacing flow (since they use view children during rendering
341
- // which will always result in rendering empty elements).
342
- if ( viewChild && !( viewChild.is( 'uiElement' ) || viewChild.is( 'rawElement' ) ) ) {
343
- this._updateElementMappings( viewChild, actualDomChildren[ deleteIndex ] );
344
- }
345
-
346
- remove( expectedDomChildren[ insertIndex ] );
347
- counter.equal++;
348
- } else {
349
- counter[ action ]++;
350
- }
351
- }
352
- }
353
- }
354
-
355
- /**
356
- * Updates mappings of a given view element.
357
- *
358
- * @private
359
- * @param {module:engine/view/node~Node} viewElement The view element whose mappings will be updated.
360
- * @param {Node} domElement The DOM element representing the given view element.
361
- */
362
- _updateElementMappings( viewElement, domElement ) {
363
- // Remap 'DomConverter' bindings.
364
- this.domConverter.unbindDomElement( domElement );
365
- this.domConverter.bindElements( domElement, viewElement );
366
-
367
- // View element may have children which needs to be updated, but are not marked, mark them to update.
368
- this.markedChildren.add( viewElement );
369
-
370
- // Because we replace new view element mapping with the existing one, the corresponding DOM element
371
- // will not be rerendered. The new view element may have different attributes than the previous one.
372
- // Since its corresponding DOM element will not be rerendered, new attributes will not be added
373
- // to the DOM, so we need to mark it here to make sure its attributes gets updated. See #1427 for more
374
- // detailed case study.
375
- // Also there are cases where replaced element is removed from the view structure and then has
376
- // its attributes changed or removed. In such cases the element will not be present in `markedAttributes`
377
- // and also may be the same (`element.isSimilar()`) as the reused element not having its attributes updated.
378
- // To prevent such situations we always mark reused element to have its attributes rerenderd (#1560).
379
- this.markedAttributes.add( viewElement );
380
- }
381
-
382
- /**
383
- * Gets the position of the inline filler based on the current selection.
384
- * Here, we assume that we know that the filler is needed and
385
- * {@link #_isSelectionInInlineFiller is at the selection position}, and, since it is needed,
386
- * it is somewhere at the selection position.
387
- *
388
- * Note: The filler position cannot be restored based on the filler's DOM text node, because
389
- * when this method is called (before rendering), the bindings will often be broken. View-to-DOM
390
- * bindings are only dependable after rendering.
391
- *
392
- * @private
393
- * @returns {module:engine/view/position~Position}
394
- */
395
- _getInlineFillerPosition() {
396
- const firstPos = this.selection.getFirstPosition();
397
-
398
- if ( firstPos.parent.is( '$text' ) ) {
399
- return ViewPosition._createBefore( this.selection.getFirstPosition().parent );
400
- } else {
401
- return firstPos;
402
- }
403
- }
404
-
405
- /**
406
- * Returns `true` if the selection has not left the inline filler's text node.
407
- * If it is `true`, it means that the filler had been added for a reason and the selection did not
408
- * leave the filler's text node. For example, the user can be in the middle of a composition so it should not be touched.
409
- *
410
- * @private
411
- * @returns {Boolean} `true` if the inline filler and selection are in the same place.
412
- */
413
- _isSelectionInInlineFiller() {
414
- if ( this.selection.rangeCount != 1 || !this.selection.isCollapsed ) {
415
- return false;
416
- }
417
-
418
- // Note, we can't check if selection's position equals position of the
419
- // this._inlineFiller node, because of #663. We may not be able to calculate
420
- // the filler's position in the view at this stage.
421
- // Instead, we check it the other way – whether selection is anchored in
422
- // that text node or next to it.
423
-
424
- // Possible options are:
425
- // "FILLER{}"
426
- // "FILLERadded-text{}"
427
- const selectionPosition = this.selection.getFirstPosition();
428
- const position = this.domConverter.viewPositionToDom( selectionPosition );
429
-
430
- if ( position && isText( position.parent ) && startsWithFiller( position.parent ) ) {
431
- return true;
432
- }
433
-
434
- return false;
435
- }
436
-
437
- /**
438
- * Removes the inline filler.
439
- *
440
- * @private
441
- */
442
- _removeInlineFiller() {
443
- const domFillerNode = this._inlineFiller;
444
-
445
- // Something weird happened and the stored node doesn't contain the filler's text.
446
- if ( !startsWithFiller( domFillerNode ) ) {
447
- /**
448
- * The inline filler node was lost. Most likely, something overwrote the filler text node
449
- * in the DOM.
450
- *
451
- * @error view-renderer-filler-was-lost
452
- */
453
- throw new CKEditorError( 'view-renderer-filler-was-lost', this );
454
- }
455
-
456
- if ( isInlineFiller( domFillerNode ) ) {
457
- domFillerNode.remove();
458
- } else {
459
- domFillerNode.data = domFillerNode.data.substr( INLINE_FILLER_LENGTH );
460
- }
461
-
462
- this._inlineFiller = null;
463
- }
464
-
465
- /**
466
- * Checks if the inline {@link module:engine/view/filler filler} should be added.
467
- *
468
- * @private
469
- * @returns {Boolean} `true` if the inline filler should be added.
470
- */
471
- _needsInlineFillerAtSelection() {
472
- if ( this.selection.rangeCount != 1 || !this.selection.isCollapsed ) {
473
- return false;
474
- }
475
-
476
- const selectionPosition = this.selection.getFirstPosition();
477
- const selectionParent = selectionPosition.parent;
478
- const selectionOffset = selectionPosition.offset;
479
-
480
- // If there is no DOM root we do not care about fillers.
481
- if ( !this.domConverter.mapViewToDom( selectionParent.root ) ) {
482
- return false;
483
- }
484
-
485
- if ( !( selectionParent.is( 'element' ) ) ) {
486
- return false;
487
- }
488
-
489
- // Prevent adding inline filler inside elements with contenteditable=false.
490
- // https://github.com/ckeditor/ckeditor5-engine/issues/1170
491
- if ( !isEditable( selectionParent ) ) {
492
- return false;
493
- }
494
-
495
- // We have block filler, we do not need inline one.
496
- if ( selectionOffset === selectionParent.getFillerOffset() ) {
497
- return false;
498
- }
499
-
500
- const nodeBefore = selectionPosition.nodeBefore;
501
- const nodeAfter = selectionPosition.nodeAfter;
502
-
503
- if ( nodeBefore instanceof ViewText || nodeAfter instanceof ViewText ) {
504
- return false;
505
- }
506
-
507
- return true;
508
- }
509
-
510
- /**
511
- * Checks if text needs to be updated and possibly updates it.
512
- *
513
- * @private
514
- * @param {module:engine/view/text~Text} viewText View text to update.
515
- * @param {Object} options
516
- * @param {module:engine/view/position~Position} options.inlineFillerPosition The position where the inline
517
- * filler should be rendered.
518
- */
519
- _updateText( viewText, options ) {
520
- const domText = this.domConverter.findCorrespondingDomText( viewText );
521
- const newDomText = this.domConverter.viewToDom( viewText );
522
-
523
- const actualText = domText.data;
524
- let expectedText = newDomText.data;
525
-
526
- const filler = options.inlineFillerPosition;
527
-
528
- if ( filler && filler.parent == viewText.parent && filler.offset == viewText.index ) {
529
- expectedText = INLINE_FILLER + expectedText;
530
- }
531
-
532
- if ( actualText != expectedText ) {
533
- const actions = fastDiff( actualText, expectedText );
534
-
535
- for ( const action of actions ) {
536
- if ( action.type === 'insert' ) {
537
- domText.insertData( action.index, action.values.join( '' ) );
538
- } else { // 'delete'
539
- domText.deleteData( action.index, action.howMany );
540
- }
541
- }
542
- }
543
- }
544
-
545
- /**
546
- * Checks if attribute list needs to be updated and possibly updates it.
547
- *
548
- * @private
549
- * @param {module:engine/view/element~Element} viewElement The view element to update.
550
- */
551
- _updateAttrs( viewElement ) {
552
- const domElement = this.domConverter.mapViewToDom( viewElement );
553
-
554
- if ( !domElement ) {
555
- // If there is no `domElement` it means that 'viewElement' is outdated as its mapping was updated
556
- // in 'this._updateChildrenMappings()'. There is no need to process it as new view element which
557
- // replaced old 'viewElement' mapping was also added to 'this.markedAttributes'
558
- // in 'this._updateChildrenMappings()' so it will be processed separately.
559
- return;
560
- }
561
-
562
- const domAttrKeys = Array.from( domElement.attributes ).map( attr => attr.name );
563
- const viewAttrKeys = viewElement.getAttributeKeys();
564
-
565
- // Add or overwrite attributes.
566
- for ( const key of viewAttrKeys ) {
567
- this.domConverter.setDomElementAttribute( domElement, key, viewElement.getAttribute( key ), viewElement );
568
- }
569
-
570
- // Remove from DOM attributes which do not exists in the view.
571
- for ( const key of domAttrKeys ) {
572
- // All other attributes not present in the DOM should be removed.
573
- if ( !viewElement.hasAttribute( key ) ) {
574
- this.domConverter.removeDomElementAttribute( domElement, key );
575
- }
576
- }
577
- }
578
-
579
- /**
580
- * Checks if elements child list needs to be updated and possibly updates it.
581
- *
582
- * @private
583
- * @param {module:engine/view/element~Element} viewElement View element to update.
584
- * @param {Object} options
585
- * @param {module:engine/view/position~Position} options.inlineFillerPosition The position where the inline
586
- * filler should be rendered.
587
- */
588
- _updateChildren( viewElement, options ) {
589
- const domElement = this.domConverter.mapViewToDom( viewElement );
590
-
591
- if ( !domElement ) {
592
- // If there is no `domElement` it means that it was already removed from DOM.
593
- // There is no need to process it. It will be processed when re-inserted.
594
- return;
595
- }
596
-
597
- const inlineFillerPosition = options.inlineFillerPosition;
598
- const actualDomChildren = this.domConverter.mapViewToDom( viewElement ).childNodes;
599
- const expectedDomChildren = Array.from(
600
- this.domConverter.viewChildrenToDom( viewElement, { bind: true } )
601
- );
602
-
603
- // Inline filler element has to be created as it is present in the DOM, but not in the view. It is required
604
- // during diffing so text nodes could be compared correctly and also during rendering to maintain
605
- // proper order and indexes while updating the DOM.
606
- if ( inlineFillerPosition && inlineFillerPosition.parent === viewElement ) {
607
- addInlineFiller( domElement.ownerDocument, expectedDomChildren, inlineFillerPosition.offset );
608
- }
609
-
610
- const diff = this._diffNodeLists( actualDomChildren, expectedDomChildren );
611
-
612
- let i = 0;
613
- const nodesToUnbind = new Set();
614
-
615
- // Handle deletions first.
616
- // This is to prevent a situation where an element that already exists in `actualDomChildren` is inserted at a different
617
- // index in `actualDomChildren`. Since `actualDomChildren` is a `NodeList`, this works like move, not like an insert,
618
- // and it disrupts the whole algorithm. See https://github.com/ckeditor/ckeditor5/issues/6367.
619
- //
620
- // It doesn't matter in what order we remove or add nodes, as long as we remove and add correct nodes at correct indexes.
621
- for ( const action of diff ) {
622
- if ( action === 'delete' ) {
623
- nodesToUnbind.add( actualDomChildren[ i ] );
624
- remove( actualDomChildren[ i ] );
625
- } else if ( action === 'equal' ) {
626
- i++;
627
- }
628
- }
629
-
630
- i = 0;
631
-
632
- for ( const action of diff ) {
633
- if ( action === 'insert' ) {
634
- insertAt( domElement, i, expectedDomChildren[ i ] );
635
- i++;
636
- } else if ( action === 'equal' ) {
637
- // Force updating text nodes inside elements which did not change and do not need to be re-rendered (#1125).
638
- // Do it here (not in the loop above) because only after insertions the `i` index is correct.
639
- this._markDescendantTextToSync( this.domConverter.domToView( expectedDomChildren[ i ] ) );
640
- i++;
641
- }
642
- }
643
-
644
- // Unbind removed nodes. When node does not have a parent it means that it was removed from DOM tree during
645
- // comparison with the expected DOM. We don't need to check child nodes, because if child node was reinserted,
646
- // it was moved to DOM tree out of the removed node.
647
- for ( const node of nodesToUnbind ) {
648
- if ( !node.parentNode ) {
649
- this.domConverter.unbindDomElement( node );
650
- }
651
- }
652
- }
653
-
654
- /**
655
- * Shorthand for diffing two arrays or node lists of DOM nodes.
656
- *
657
- * @private
658
- * @param {Array.<Node>|NodeList} actualDomChildren Actual DOM children
659
- * @param {Array.<Node>|NodeList} expectedDomChildren Expected DOM children.
660
- * @returns {Array.<String>} The list of actions based on the {@link module:utils/diff~diff} function.
661
- */
662
- _diffNodeLists( actualDomChildren, expectedDomChildren ) {
663
- actualDomChildren = filterOutFakeSelectionContainer( actualDomChildren, this._fakeSelectionContainer );
664
-
665
- return diff( actualDomChildren, expectedDomChildren, sameNodes.bind( null, this.domConverter ) );
666
- }
667
-
668
- /**
669
- * Finds DOM nodes that were replaced with the similar nodes (same tag name) in the view. All nodes are compared
670
- * within one `insert`/`delete` action group, for example:
671
- *
672
- * Actual DOM: <p><b>Foo</b>Bar<i>Baz</i><b>Bax</b></p>
673
- * Expected DOM: <p>Bar<b>123</b><i>Baz</i><b>456</b></p>
674
- * Input actions: [ insert, insert, delete, delete, equal, insert, delete ]
675
- * Output actions: [ insert, replace, delete, equal, replace ]
676
- *
677
- * @private
678
- * @param {Array.<String>} actions Actions array which is a result of the {@link module:utils/diff~diff} function.
679
- * @param {Array.<Node>|NodeList} actualDom Actual DOM children
680
- * @param {Array.<Node>} expectedDom Expected DOM children.
681
- * @returns {Array.<String>} Actions array modified with the `replace` actions.
682
- */
683
- _findReplaceActions( actions, actualDom, expectedDom ) {
684
- // If there is no both 'insert' and 'delete' actions, no need to check for replaced elements.
685
- if ( actions.indexOf( 'insert' ) === -1 || actions.indexOf( 'delete' ) === -1 ) {
686
- return actions;
687
- }
688
-
689
- let newActions = [];
690
- let actualSlice = [];
691
- let expectedSlice = [];
692
-
693
- const counter = { equal: 0, insert: 0, delete: 0 };
694
-
695
- for ( const action of actions ) {
696
- if ( action === 'insert' ) {
697
- expectedSlice.push( expectedDom[ counter.equal + counter.insert ] );
698
- } else if ( action === 'delete' ) {
699
- actualSlice.push( actualDom[ counter.equal + counter.delete ] );
700
- } else { // equal
701
- newActions = newActions.concat( diff( actualSlice, expectedSlice, areSimilar ).map( x => x === 'equal' ? 'replace' : x ) );
702
- newActions.push( 'equal' );
703
- // Reset stored elements on 'equal'.
704
- actualSlice = [];
705
- expectedSlice = [];
706
- }
707
- counter[ action ]++;
708
- }
709
-
710
- return newActions.concat( diff( actualSlice, expectedSlice, areSimilar ).map( x => x === 'equal' ? 'replace' : x ) );
711
- }
712
-
713
- /**
714
- * Marks text nodes to be synchronized.
715
- *
716
- * If a text node is passed, it will be marked. If an element is passed, all descendant text nodes inside it will be marked.
717
- *
718
- * @private
719
- * @param {module:engine/view/node~Node} viewNode View node to sync.
720
- */
721
- _markDescendantTextToSync( viewNode ) {
722
- if ( !viewNode ) {
723
- return;
724
- }
725
-
726
- if ( viewNode.is( '$text' ) ) {
727
- this.markedTexts.add( viewNode );
728
- } else if ( viewNode.is( 'element' ) ) {
729
- for ( const child of viewNode.getChildren() ) {
730
- this._markDescendantTextToSync( child );
731
- }
732
- }
733
- }
734
-
735
- /**
736
- * Checks if the selection needs to be updated and possibly updates it.
737
- *
738
- * @private
739
- */
740
- _updateSelection() {
741
- // Block updating DOM selection in (non-Android) Blink while the user is selecting to prevent accidental selection collapsing.
742
- // Note: Structural changes in DOM must trigger selection rendering, though. Nodes the selection was anchored
743
- // to, may disappear in DOM which would break the selection (e.g. in real-time collaboration scenarios).
744
- // https://github.com/ckeditor/ckeditor5/issues/10562, https://github.com/ckeditor/ckeditor5/issues/10723
745
- if ( env.isBlink && !env.isAndroid && this.isSelecting && !this.markedChildren.size ) {
746
- return;
747
- }
748
-
749
- // If there is no selection - remove DOM and fake selections.
750
- if ( this.selection.rangeCount === 0 ) {
751
- this._removeDomSelection();
752
- this._removeFakeSelection();
753
-
754
- return;
755
- }
756
-
757
- const domRoot = this.domConverter.mapViewToDom( this.selection.editableElement );
758
-
759
- // Do nothing if there is no focus, or there is no DOM element corresponding to selection's editable element.
760
- if ( !this.isFocused || !domRoot ) {
761
- return;
762
- }
763
-
764
- // Render selection.
765
- if ( this.selection.isFake ) {
766
- this._updateFakeSelection( domRoot );
767
- } else {
768
- this._removeFakeSelection();
769
- this._updateDomSelection( domRoot );
770
- }
771
- }
772
-
773
- /**
774
- * Updates the fake selection.
775
- *
776
- * @private
777
- * @param {HTMLElement} domRoot A valid DOM root where the fake selection container should be added.
778
- */
779
- _updateFakeSelection( domRoot ) {
780
- const domDocument = domRoot.ownerDocument;
781
-
782
- if ( !this._fakeSelectionContainer ) {
783
- this._fakeSelectionContainer = createFakeSelectionContainer( domDocument );
784
- }
785
-
786
- const container = this._fakeSelectionContainer;
787
-
788
- // Bind fake selection container with the current selection *position*.
789
- this.domConverter.bindFakeSelection( container, this.selection );
790
-
791
- if ( !this._fakeSelectionNeedsUpdate( domRoot ) ) {
792
- return;
793
- }
794
-
795
- if ( !container.parentElement || container.parentElement != domRoot ) {
796
- domRoot.appendChild( container );
797
- }
798
-
799
- container.textContent = this.selection.fakeSelectionLabel || '\u00A0';
800
-
801
- const domSelection = domDocument.getSelection();
802
- const domRange = domDocument.createRange();
803
-
804
- domSelection.removeAllRanges();
805
- domRange.selectNodeContents( container );
806
- domSelection.addRange( domRange );
807
- }
808
-
809
- /**
810
- * Updates the DOM selection.
811
- *
812
- * @private
813
- * @param {HTMLElement} domRoot A valid DOM root where the DOM selection should be rendered.
814
- */
815
- _updateDomSelection( domRoot ) {
816
- const domSelection = domRoot.ownerDocument.defaultView.getSelection();
817
-
818
- // Let's check whether DOM selection needs updating at all.
819
- if ( !this._domSelectionNeedsUpdate( domSelection ) ) {
820
- return;
821
- }
822
-
823
- // Multi-range selection is not available in most browsers, and, at least in Chrome, trying to
824
- // set such selection, that is not continuous, throws an error. Because of that, we will just use anchor
825
- // and focus of view selection.
826
- // Since we are not supporting multi-range selection, we also do not need to check if proper editable is
827
- // selected. If there is any editable selected, it is okay (editable is taken from selection anchor).
828
- const anchor = this.domConverter.viewPositionToDom( this.selection.anchor );
829
- const focus = this.domConverter.viewPositionToDom( this.selection.focus );
830
-
831
- domSelection.collapse( anchor.parent, anchor.offset );
832
- domSelection.extend( focus.parent, focus.offset );
833
-
834
- // Firefox–specific hack (https://github.com/ckeditor/ckeditor5-engine/issues/1439).
835
- if ( env.isGecko ) {
836
- fixGeckoSelectionAfterBr( focus, domSelection );
837
- }
838
- }
839
-
840
- /**
841
- * Checks whether a given DOM selection needs to be updated.
842
- *
843
- * @private
844
- * @param {Selection} domSelection The DOM selection to check.
845
- * @returns {Boolean}
846
- */
847
- _domSelectionNeedsUpdate( domSelection ) {
848
- if ( !this.domConverter.isDomSelectionCorrect( domSelection ) ) {
849
- // Current DOM selection is in incorrect position. We need to update it.
850
- return true;
851
- }
852
-
853
- const oldViewSelection = domSelection && this.domConverter.domSelectionToView( domSelection );
854
-
855
- if ( oldViewSelection && this.selection.isEqual( oldViewSelection ) ) {
856
- return false;
857
- }
858
-
859
- // If selection is not collapsed, it does not need to be updated if it is similar.
860
- if ( !this.selection.isCollapsed && this.selection.isSimilar( oldViewSelection ) ) {
861
- // Selection did not changed and is correct, do not update.
862
- return false;
863
- }
864
-
865
- // Selections are not similar.
866
- return true;
867
- }
868
-
869
- /**
870
- * Checks whether the fake selection needs to be updated.
871
- *
872
- * @private
873
- * @param {HTMLElement} domRoot A valid DOM root where a new fake selection container should be added.
874
- * @returns {Boolean}
875
- */
876
- _fakeSelectionNeedsUpdate( domRoot ) {
877
- const container = this._fakeSelectionContainer;
878
- const domSelection = domRoot.ownerDocument.getSelection();
879
-
880
- // Fake selection needs to be updated if there's no fake selection container, or the container currently sits
881
- // in a different root.
882
- if ( !container || container.parentElement !== domRoot ) {
883
- return true;
884
- }
885
-
886
- // Make sure that the selection actually is within the fake selection.
887
- if ( domSelection.anchorNode !== container && !container.contains( domSelection.anchorNode ) ) {
888
- return true;
889
- }
890
-
891
- return container.textContent !== this.selection.fakeSelectionLabel;
892
- }
893
-
894
- /**
895
- * Removes the DOM selection.
896
- *
897
- * @private
898
- */
899
- _removeDomSelection() {
900
- for ( const doc of this.domDocuments ) {
901
- const domSelection = doc.getSelection();
902
-
903
- if ( domSelection.rangeCount ) {
904
- const activeDomElement = doc.activeElement;
905
- const viewElement = this.domConverter.mapDomToView( activeDomElement );
906
-
907
- if ( activeDomElement && viewElement ) {
908
- doc.getSelection().removeAllRanges();
909
- }
910
- }
911
- }
912
- }
913
-
914
- /**
915
- * Removes the fake selection.
916
- *
917
- * @private
918
- */
919
- _removeFakeSelection() {
920
- const container = this._fakeSelectionContainer;
921
-
922
- if ( container ) {
923
- container.remove();
924
- }
925
- }
926
-
927
- /**
928
- * Checks if focus needs to be updated and possibly updates it.
929
- *
930
- * @private
931
- */
932
- _updateFocus() {
933
- if ( this.isFocused ) {
934
- const editable = this.selection.editableElement;
935
-
936
- if ( editable ) {
937
- this.domConverter.focus( editable );
938
- }
939
- }
940
- }
35
+ export default class Renderer extends Observable {
36
+ /**
37
+ * Creates a renderer instance.
38
+ *
39
+ * @param {module:engine/view/domconverter~DomConverter} domConverter Converter instance.
40
+ * @param {module:engine/view/documentselection~DocumentSelection} selection View selection.
41
+ */
42
+ constructor(domConverter, selection) {
43
+ super();
44
+ /**
45
+ * Set of DOM Documents instances.
46
+ *
47
+ * @readonly
48
+ * @member {Set.<Document>}
49
+ */
50
+ this.domDocuments = new Set();
51
+ /**
52
+ * Converter instance.
53
+ *
54
+ * @readonly
55
+ * @member {module:engine/view/domconverter~DomConverter}
56
+ */
57
+ this.domConverter = domConverter;
58
+ /**
59
+ * Set of nodes which attributes changed and may need to be rendered.
60
+ *
61
+ * @readonly
62
+ * @member {Set.<module:engine/view/node~ViewNode>}
63
+ */
64
+ this.markedAttributes = new Set();
65
+ /**
66
+ * Set of elements which child lists changed and may need to be rendered.
67
+ *
68
+ * @readonly
69
+ * @member {Set.<module:engine/view/node~ViewNode>}
70
+ */
71
+ this.markedChildren = new Set();
72
+ /**
73
+ * Set of text nodes which text data changed and may need to be rendered.
74
+ *
75
+ * @readonly
76
+ * @member {Set.<module:engine/view/node~ViewNode>}
77
+ */
78
+ this.markedTexts = new Set();
79
+ /**
80
+ * View selection. Renderer updates DOM selection based on the view selection.
81
+ *
82
+ * @readonly
83
+ * @member {module:engine/view/documentselection~DocumentSelection}
84
+ */
85
+ this.selection = selection;
86
+ /**
87
+ * Indicates if the view document is focused and selection can be rendered. Selection will not be rendered if
88
+ * this is set to `false`.
89
+ *
90
+ * @member {Boolean}
91
+ * @observable
92
+ */
93
+ this.set('isFocused', false);
94
+ /**
95
+ * Indicates whether the user is making a selection in the document (e.g. holding the mouse button and moving the cursor).
96
+ * When they stop selecting, the property goes back to `false`.
97
+ *
98
+ * Note: In some browsers, the renderer will stop rendering the selection and inline fillers while the user is making
99
+ * a selection to avoid glitches in DOM selection
100
+ * (https://github.com/ckeditor/ckeditor5/issues/10562, https://github.com/ckeditor/ckeditor5/issues/10723).
101
+ *
102
+ * @member {Boolean}
103
+ * @observable
104
+ */
105
+ this.set('isSelecting', false);
106
+ // Rendering the selection and inline filler manipulation should be postponed in (non-Android) Blink until the user finishes
107
+ // creating the selection in DOM to avoid accidental selection collapsing
108
+ // (https://github.com/ckeditor/ckeditor5/issues/10562, https://github.com/ckeditor/ckeditor5/issues/10723).
109
+ // When the user stops selecting, all pending changes should be rendered ASAP, though.
110
+ if (env.isBlink && !env.isAndroid) {
111
+ this.on('change:isSelecting', () => {
112
+ if (!this.isSelecting) {
113
+ this.render();
114
+ }
115
+ });
116
+ }
117
+ /**
118
+ * The text node in which the inline filler was rendered.
119
+ *
120
+ * @private
121
+ * @member {Text}
122
+ */
123
+ this._inlineFiller = null;
124
+ /**
125
+ * DOM element containing fake selection.
126
+ *
127
+ * @private
128
+ * @type {null|HTMLElement}
129
+ */
130
+ this._fakeSelectionContainer = null;
131
+ }
132
+ /**
133
+ * Marks a view node to be updated in the DOM by {@link #render `render()`}.
134
+ *
135
+ * Note that only view nodes whose parents have corresponding DOM elements need to be marked to be synchronized.
136
+ *
137
+ * @see #markedAttributes
138
+ * @see #markedChildren
139
+ * @see #markedTexts
140
+ *
141
+ * @param {module:engine/view/document~ChangeType} type Type of the change.
142
+ * @param {module:engine/view/node~ViewNode} node ViewNode to be marked.
143
+ */
144
+ markToSync(type, node) {
145
+ if (type === 'text') {
146
+ if (this.domConverter.mapViewToDom(node.parent)) {
147
+ this.markedTexts.add(node);
148
+ }
149
+ }
150
+ else {
151
+ // If the node has no DOM element it is not rendered yet,
152
+ // its children/attributes do not need to be marked to be sync.
153
+ if (!this.domConverter.mapViewToDom(node)) {
154
+ return;
155
+ }
156
+ if (type === 'attributes') {
157
+ this.markedAttributes.add(node);
158
+ }
159
+ else if (type === 'children') {
160
+ this.markedChildren.add(node);
161
+ }
162
+ else {
163
+ /**
164
+ * Unknown type passed to Renderer.markToSync.
165
+ *
166
+ * @error view-renderer-unknown-type
167
+ */
168
+ throw new CKEditorError('view-renderer-unknown-type', this);
169
+ }
170
+ }
171
+ }
172
+ /**
173
+ * Renders all buffered changes ({@link #markedAttributes}, {@link #markedChildren} and {@link #markedTexts}) and
174
+ * the current view selection (if needed) to the DOM by applying a minimal set of changes to it.
175
+ *
176
+ * Renderer tries not to break the text composition (e.g. IME) and x-index of the selection,
177
+ * so it does as little as it is needed to update the DOM.
178
+ *
179
+ * Renderer also handles {@link module:engine/view/filler fillers}. Especially, it checks if the inline filler is needed
180
+ * at the selection position and adds or removes it. To prevent breaking text composition inline filler will not be
181
+ * removed as long as the selection is in the text node which needed it at first.
182
+ */
183
+ render() {
184
+ let inlineFillerPosition = null;
185
+ const isInlineFillerRenderingPossible = env.isBlink && !env.isAndroid ? !this.isSelecting : true;
186
+ // Refresh mappings.
187
+ for (const element of this.markedChildren) {
188
+ this._updateChildrenMappings(element);
189
+ }
190
+ // Don't manipulate inline fillers while the selection is being made in (non-Android) Blink to prevent accidental
191
+ // DOM selection collapsing
192
+ // (https://github.com/ckeditor/ckeditor5/issues/10562, https://github.com/ckeditor/ckeditor5/issues/10723).
193
+ if (isInlineFillerRenderingPossible) {
194
+ // There was inline filler rendered in the DOM but it's not
195
+ // at the selection position any more, so we can remove it
196
+ // (cause even if it's needed, it must be placed in another location).
197
+ if (this._inlineFiller && !this._isSelectionInInlineFiller()) {
198
+ this._removeInlineFiller();
199
+ }
200
+ // If we've got the filler, let's try to guess its position in the view.
201
+ if (this._inlineFiller) {
202
+ inlineFillerPosition = this._getInlineFillerPosition();
203
+ }
204
+ // Otherwise, if it's needed, create it at the selection position.
205
+ else if (this._needsInlineFillerAtSelection()) {
206
+ inlineFillerPosition = this.selection.getFirstPosition();
207
+ // Do not use `markToSync` so it will be added even if the parent is already added.
208
+ this.markedChildren.add(inlineFillerPosition.parent);
209
+ }
210
+ }
211
+ // Make sure the inline filler has any parent, so it can be mapped to view position by DomConverter.
212
+ else if (this._inlineFiller && this._inlineFiller.parentNode) {
213
+ // While the user is making selection, preserve the inline filler at its original position.
214
+ inlineFillerPosition = this.domConverter.domPositionToView(this._inlineFiller);
215
+ // While down-casting the document selection attributes, all existing empty
216
+ // attribute elements (for selection position) are removed from the view and DOM,
217
+ // so make sure that we were able to map filler position.
218
+ // https://github.com/ckeditor/ckeditor5/issues/12026
219
+ if (inlineFillerPosition && inlineFillerPosition.parent.is('$text')) {
220
+ // The inline filler position is expected to be before the text node.
221
+ inlineFillerPosition = ViewPosition._createBefore(inlineFillerPosition.parent);
222
+ }
223
+ }
224
+ for (const element of this.markedAttributes) {
225
+ this._updateAttrs(element);
226
+ }
227
+ for (const element of this.markedChildren) {
228
+ this._updateChildren(element, { inlineFillerPosition });
229
+ }
230
+ for (const node of this.markedTexts) {
231
+ if (!this.markedChildren.has(node.parent) && this.domConverter.mapViewToDom(node.parent)) {
232
+ this._updateText(node, { inlineFillerPosition });
233
+ }
234
+ }
235
+ // * Check whether the inline filler is required and where it really is in the DOM.
236
+ // At this point in most cases it will be in the DOM, but there are exceptions.
237
+ // For example, if the inline filler was deep in the created DOM structure, it will not be created.
238
+ // Similarly, if it was removed at the beginning of this function and then neither text nor children were updated,
239
+ // it will not be present. Fix those and similar scenarios.
240
+ // * Don't manipulate inline fillers while the selection is being made in (non-Android) Blink to prevent accidental
241
+ // DOM selection collapsing
242
+ // (https://github.com/ckeditor/ckeditor5/issues/10562, https://github.com/ckeditor/ckeditor5/issues/10723).
243
+ if (isInlineFillerRenderingPossible) {
244
+ if (inlineFillerPosition) {
245
+ const fillerDomPosition = this.domConverter.viewPositionToDom(inlineFillerPosition);
246
+ const domDocument = fillerDomPosition.parent.ownerDocument;
247
+ if (!startsWithFiller(fillerDomPosition.parent)) {
248
+ // Filler has not been created at filler position. Create it now.
249
+ this._inlineFiller = addInlineFiller(domDocument, fillerDomPosition.parent, fillerDomPosition.offset);
250
+ }
251
+ else {
252
+ // Filler has been found, save it.
253
+ this._inlineFiller = fillerDomPosition.parent;
254
+ }
255
+ }
256
+ else {
257
+ // There is no filler needed.
258
+ this._inlineFiller = null;
259
+ }
260
+ }
261
+ // First focus the new editing host, then update the selection.
262
+ // Otherwise, FF may throw an error (https://github.com/ckeditor/ckeditor5/issues/721).
263
+ this._updateFocus();
264
+ this._updateSelection();
265
+ this.markedTexts.clear();
266
+ this.markedAttributes.clear();
267
+ this.markedChildren.clear();
268
+ }
269
+ /**
270
+ * Updates mappings of view element's children.
271
+ *
272
+ * Children that were replaced in the view structure by similar elements (same tag name) are treated as 'replaced'.
273
+ * This means that their mappings can be updated so the new view elements are mapped to the existing DOM elements.
274
+ * Thanks to that these elements do not need to be re-rendered completely.
275
+ *
276
+ * @private
277
+ * @param {module:engine/view/node~ViewNode} viewElement The view element whose children mappings will be updated.
278
+ */
279
+ _updateChildrenMappings(viewElement) {
280
+ const domElement = this.domConverter.mapViewToDom(viewElement);
281
+ if (!domElement) {
282
+ // If there is no `domElement` it means that it was already removed from DOM and there is no need to process it.
283
+ return;
284
+ }
285
+ // Removing nodes from the DOM as we iterate can cause `actualDomChildren`
286
+ // (which is a live-updating `NodeList`) to get out of sync with the
287
+ // indices that we compute as we iterate over `actions`.
288
+ // This would produce incorrect element mappings.
289
+ //
290
+ // Converting live list to an array to make the list static.
291
+ const actualDomChildren = Array.from(this.domConverter.mapViewToDom(viewElement).childNodes);
292
+ const expectedDomChildren = Array.from(this.domConverter.viewChildrenToDom(viewElement, { withChildren: false }));
293
+ const diff = this._diffNodeLists(actualDomChildren, expectedDomChildren);
294
+ const actions = this._findReplaceActions(diff, actualDomChildren, expectedDomChildren);
295
+ if (actions.indexOf('replace') !== -1) {
296
+ const counter = { equal: 0, insert: 0, delete: 0 };
297
+ for (const action of actions) {
298
+ if (action === 'replace') {
299
+ const insertIndex = counter.equal + counter.insert;
300
+ const deleteIndex = counter.equal + counter.delete;
301
+ const viewChild = viewElement.getChild(insertIndex);
302
+ // UIElement and RawElement are special cases. Their children are not stored in a view (#799)
303
+ // so we cannot use them with replacing flow (since they use view children during rendering
304
+ // which will always result in rendering empty elements).
305
+ if (viewChild && !(viewChild.is('uiElement') || viewChild.is('rawElement'))) {
306
+ this._updateElementMappings(viewChild, actualDomChildren[deleteIndex]);
307
+ }
308
+ remove(expectedDomChildren[insertIndex]);
309
+ counter.equal++;
310
+ }
311
+ else {
312
+ counter[action]++;
313
+ }
314
+ }
315
+ }
316
+ }
317
+ /**
318
+ * Updates mappings of a given view element.
319
+ *
320
+ * @private
321
+ * @param {module:engine/view/node~ViewNode} viewElement The view element whose mappings will be updated.
322
+ * @param {ViewNode} domElement The DOM element representing the given view element.
323
+ */
324
+ _updateElementMappings(viewElement, domElement) {
325
+ // Remap 'DomConverter' bindings.
326
+ this.domConverter.unbindDomElement(domElement);
327
+ this.domConverter.bindElements(domElement, viewElement);
328
+ // View element may have children which needs to be updated, but are not marked, mark them to update.
329
+ this.markedChildren.add(viewElement);
330
+ // Because we replace new view element mapping with the existing one, the corresponding DOM element
331
+ // will not be rerendered. The new view element may have different attributes than the previous one.
332
+ // Since its corresponding DOM element will not be rerendered, new attributes will not be added
333
+ // to the DOM, so we need to mark it here to make sure its attributes gets updated. See #1427 for more
334
+ // detailed case study.
335
+ // Also there are cases where replaced element is removed from the view structure and then has
336
+ // its attributes changed or removed. In such cases the element will not be present in `markedAttributes`
337
+ // and also may be the same (`element.isSimilar()`) as the reused element not having its attributes updated.
338
+ // To prevent such situations we always mark reused element to have its attributes rerenderd (#1560).
339
+ this.markedAttributes.add(viewElement);
340
+ }
341
+ /**
342
+ * Gets the position of the inline filler based on the current selection.
343
+ * Here, we assume that we know that the filler is needed and
344
+ * {@link #_isSelectionInInlineFiller is at the selection position}, and, since it is needed,
345
+ * it is somewhere at the selection position.
346
+ *
347
+ * Note: The filler position cannot be restored based on the filler's DOM text node, because
348
+ * when this method is called (before rendering), the bindings will often be broken. View-to-DOM
349
+ * bindings are only dependable after rendering.
350
+ *
351
+ * @private
352
+ * @returns {module:engine/view/position~Position}
353
+ */
354
+ _getInlineFillerPosition() {
355
+ const firstPos = this.selection.getFirstPosition();
356
+ if (firstPos.parent.is('$text')) {
357
+ return ViewPosition._createBefore(firstPos.parent);
358
+ }
359
+ else {
360
+ return firstPos;
361
+ }
362
+ }
363
+ /**
364
+ * Returns `true` if the selection has not left the inline filler's text node.
365
+ * If it is `true`, it means that the filler had been added for a reason and the selection did not
366
+ * leave the filler's text node. For example, the user can be in the middle of a composition so it should not be touched.
367
+ *
368
+ * @private
369
+ * @returns {Boolean} `true` if the inline filler and selection are in the same place.
370
+ */
371
+ _isSelectionInInlineFiller() {
372
+ if (this.selection.rangeCount != 1 || !this.selection.isCollapsed) {
373
+ return false;
374
+ }
375
+ // Note, we can't check if selection's position equals position of the
376
+ // this._inlineFiller node, because of #663. We may not be able to calculate
377
+ // the filler's position in the view at this stage.
378
+ // Instead, we check it the other way – whether selection is anchored in
379
+ // that text node or next to it.
380
+ // Possible options are:
381
+ // "FILLER{}"
382
+ // "FILLERadded-text{}"
383
+ const selectionPosition = this.selection.getFirstPosition();
384
+ const position = this.domConverter.viewPositionToDom(selectionPosition);
385
+ if (position && isText(position.parent) && startsWithFiller(position.parent)) {
386
+ return true;
387
+ }
388
+ return false;
389
+ }
390
+ /**
391
+ * Removes the inline filler.
392
+ *
393
+ * @private
394
+ */
395
+ _removeInlineFiller() {
396
+ const domFillerNode = this._inlineFiller;
397
+ // Something weird happened and the stored node doesn't contain the filler's text.
398
+ if (!startsWithFiller(domFillerNode)) {
399
+ /**
400
+ * The inline filler node was lost. Most likely, something overwrote the filler text node
401
+ * in the DOM.
402
+ *
403
+ * @error view-renderer-filler-was-lost
404
+ */
405
+ throw new CKEditorError('view-renderer-filler-was-lost', this);
406
+ }
407
+ if (isInlineFiller(domFillerNode)) {
408
+ domFillerNode.remove();
409
+ }
410
+ else {
411
+ domFillerNode.data = domFillerNode.data.substr(INLINE_FILLER_LENGTH);
412
+ }
413
+ this._inlineFiller = null;
414
+ }
415
+ /**
416
+ * Checks if the inline {@link module:engine/view/filler filler} should be added.
417
+ *
418
+ * @private
419
+ * @returns {Boolean} `true` if the inline filler should be added.
420
+ */
421
+ _needsInlineFillerAtSelection() {
422
+ if (this.selection.rangeCount != 1 || !this.selection.isCollapsed) {
423
+ return false;
424
+ }
425
+ const selectionPosition = this.selection.getFirstPosition();
426
+ const selectionParent = selectionPosition.parent;
427
+ const selectionOffset = selectionPosition.offset;
428
+ // If there is no DOM root we do not care about fillers.
429
+ if (!this.domConverter.mapViewToDom(selectionParent.root)) {
430
+ return false;
431
+ }
432
+ if (!(selectionParent.is('element'))) {
433
+ return false;
434
+ }
435
+ // Prevent adding inline filler inside elements with contenteditable=false.
436
+ // https://github.com/ckeditor/ckeditor5-engine/issues/1170
437
+ if (!isEditable(selectionParent)) {
438
+ return false;
439
+ }
440
+ // We have block filler, we do not need inline one.
441
+ if (selectionOffset === selectionParent.getFillerOffset()) {
442
+ return false;
443
+ }
444
+ const nodeBefore = selectionPosition.nodeBefore;
445
+ const nodeAfter = selectionPosition.nodeAfter;
446
+ if (nodeBefore instanceof ViewText || nodeAfter instanceof ViewText) {
447
+ return false;
448
+ }
449
+ return true;
450
+ }
451
+ /**
452
+ * Checks if text needs to be updated and possibly updates it.
453
+ *
454
+ * @private
455
+ * @param {module:engine/view/text~Text} viewText View text to update.
456
+ * @param {Object} options
457
+ * @param {module:engine/view/position~Position} options.inlineFillerPosition The position where the inline
458
+ * filler should be rendered.
459
+ */
460
+ _updateText(viewText, options) {
461
+ const domText = this.domConverter.findCorrespondingDomText(viewText);
462
+ const newDomText = this.domConverter.viewToDom(viewText);
463
+ const actualText = domText.data;
464
+ let expectedText = newDomText.data;
465
+ const filler = options.inlineFillerPosition;
466
+ if (filler && filler.parent == viewText.parent && filler.offset == viewText.index) {
467
+ expectedText = INLINE_FILLER + expectedText;
468
+ }
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
+ }
480
+ }
481
+ /**
482
+ * Checks if attribute list needs to be updated and possibly updates it.
483
+ *
484
+ * @private
485
+ * @param {module:engine/view/element~Element} viewElement The view element to update.
486
+ */
487
+ _updateAttrs(viewElement) {
488
+ const domElement = this.domConverter.mapViewToDom(viewElement);
489
+ if (!domElement) {
490
+ // If there is no `domElement` it means that 'viewElement' is outdated as its mapping was updated
491
+ // in 'this._updateChildrenMappings()'. There is no need to process it as new view element which
492
+ // replaced old 'viewElement' mapping was also added to 'this.markedAttributes'
493
+ // in 'this._updateChildrenMappings()' so it will be processed separately.
494
+ return;
495
+ }
496
+ const domAttrKeys = Array.from(domElement.attributes).map(attr => attr.name);
497
+ const viewAttrKeys = viewElement.getAttributeKeys();
498
+ // Add or overwrite attributes.
499
+ for (const key of viewAttrKeys) {
500
+ this.domConverter.setDomElementAttribute(domElement, key, viewElement.getAttribute(key), viewElement);
501
+ }
502
+ // Remove from DOM attributes which do not exists in the view.
503
+ for (const key of domAttrKeys) {
504
+ // All other attributes not present in the DOM should be removed.
505
+ if (!viewElement.hasAttribute(key)) {
506
+ this.domConverter.removeDomElementAttribute(domElement, key);
507
+ }
508
+ }
509
+ }
510
+ /**
511
+ * Checks if elements child list needs to be updated and possibly updates it.
512
+ *
513
+ * @private
514
+ * @param {module:engine/view/element~Element} viewElement View element to update.
515
+ * @param {Object} options
516
+ * @param {module:engine/view/position~Position} options.inlineFillerPosition The position where the inline
517
+ * filler should be rendered.
518
+ */
519
+ _updateChildren(viewElement, options) {
520
+ const domElement = this.domConverter.mapViewToDom(viewElement);
521
+ if (!domElement) {
522
+ // If there is no `domElement` it means that it was already removed from DOM.
523
+ // There is no need to process it. It will be processed when re-inserted.
524
+ return;
525
+ }
526
+ const inlineFillerPosition = options.inlineFillerPosition;
527
+ const actualDomChildren = this.domConverter.mapViewToDom(viewElement).childNodes;
528
+ const expectedDomChildren = Array.from(this.domConverter.viewChildrenToDom(viewElement, { bind: true }));
529
+ // Inline filler element has to be created as it is present in the DOM, but not in the view. It is required
530
+ // during diffing so text nodes could be compared correctly and also during rendering to maintain
531
+ // proper order and indexes while updating the DOM.
532
+ if (inlineFillerPosition && inlineFillerPosition.parent === viewElement) {
533
+ addInlineFiller(domElement.ownerDocument, expectedDomChildren, inlineFillerPosition.offset);
534
+ }
535
+ const diff = this._diffNodeLists(actualDomChildren, expectedDomChildren);
536
+ let i = 0;
537
+ const nodesToUnbind = new Set();
538
+ // Handle deletions first.
539
+ // This is to prevent a situation where an element that already exists in `actualDomChildren` is inserted at a different
540
+ // index in `actualDomChildren`. Since `actualDomChildren` is a `NodeList`, this works like move, not like an insert,
541
+ // and it disrupts the whole algorithm. See https://github.com/ckeditor/ckeditor5/issues/6367.
542
+ //
543
+ // 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) {
545
+ if (action === 'delete') {
546
+ nodesToUnbind.add(actualDomChildren[i]);
547
+ remove(actualDomChildren[i]);
548
+ }
549
+ else if (action === 'equal') {
550
+ i++;
551
+ }
552
+ }
553
+ i = 0;
554
+ for (const action of diff) {
555
+ if (action === 'insert') {
556
+ insertAt(domElement, i, expectedDomChildren[i]);
557
+ i++;
558
+ }
559
+ else if (action === 'equal') {
560
+ // Force updating text nodes inside elements which did not change and do not need to be re-rendered (#1125).
561
+ // Do it here (not in the loop above) because only after insertions the `i` index is correct.
562
+ this._markDescendantTextToSync(this.domConverter.domToView(expectedDomChildren[i]));
563
+ i++;
564
+ }
565
+ }
566
+ // Unbind removed nodes. When node does not have a parent it means that it was removed from DOM tree during
567
+ // comparison with the expected DOM. We don't need to check child nodes, because if child node was reinserted,
568
+ // it was moved to DOM tree out of the removed node.
569
+ for (const node of nodesToUnbind) {
570
+ if (!node.parentNode) {
571
+ this.domConverter.unbindDomElement(node);
572
+ }
573
+ }
574
+ }
575
+ /**
576
+ * Shorthand for diffing two arrays or node lists of DOM nodes.
577
+ *
578
+ * @private
579
+ * @param {Array.<ViewNode>|NodeList} actualDomChildren Actual DOM children
580
+ * @param {Array.<ViewNode>|NodeList} expectedDomChildren Expected DOM children.
581
+ * @returns {Array.<String>} The list of actions based on the {@link module:utils/diff~diff} function.
582
+ */
583
+ _diffNodeLists(actualDomChildren, expectedDomChildren) {
584
+ actualDomChildren = filterOutFakeSelectionContainer(actualDomChildren, this._fakeSelectionContainer);
585
+ return diff(actualDomChildren, expectedDomChildren, sameNodes.bind(null, this.domConverter));
586
+ }
587
+ /**
588
+ * Finds DOM nodes that were replaced with the similar nodes (same tag name) in the view. All nodes are compared
589
+ * within one `insert`/`delete` action group, for example:
590
+ *
591
+ * Actual DOM: <p><b>Foo</b>Bar<i>Baz</i><b>Bax</b></p>
592
+ * Expected DOM: <p>Bar<b>123</b><i>Baz</i><b>456</b></p>
593
+ * Input actions: [ insert, insert, delete, delete, equal, insert, delete ]
594
+ * Output actions: [ insert, replace, delete, equal, replace ]
595
+ *
596
+ * @private
597
+ * @param {Array.<String>} actions Actions array which is a result of the {@link module:utils/diff~diff} function.
598
+ * @param {Array.<ViewNode>|NodeList} actualDom Actual DOM children
599
+ * @param {Array.<ViewNode>} expectedDom Expected DOM children.
600
+ * @returns {Array.<String>} Actions array modified with the `replace` actions.
601
+ */
602
+ _findReplaceActions(actions, actualDom, expectedDom) {
603
+ // If there is no both 'insert' and 'delete' actions, no need to check for replaced elements.
604
+ if (actions.indexOf('insert') === -1 || actions.indexOf('delete') === -1) {
605
+ return actions;
606
+ }
607
+ let newActions = [];
608
+ let actualSlice = [];
609
+ let expectedSlice = [];
610
+ const counter = { equal: 0, insert: 0, delete: 0 };
611
+ for (const action of actions) {
612
+ if (action === 'insert') {
613
+ expectedSlice.push(expectedDom[counter.equal + counter.insert]);
614
+ }
615
+ else if (action === 'delete') {
616
+ actualSlice.push(actualDom[counter.equal + counter.delete]);
617
+ }
618
+ else { // equal
619
+ newActions = newActions.concat(diff(actualSlice, expectedSlice, areSimilar).map(x => x === 'equal' ? 'replace' : x));
620
+ newActions.push('equal');
621
+ // Reset stored elements on 'equal'.
622
+ actualSlice = [];
623
+ expectedSlice = [];
624
+ }
625
+ counter[action]++;
626
+ }
627
+ return newActions.concat(diff(actualSlice, expectedSlice, areSimilar).map(x => x === 'equal' ? 'replace' : x));
628
+ }
629
+ /**
630
+ * Marks text nodes to be synchronized.
631
+ *
632
+ * If a text node is passed, it will be marked. If an element is passed, all descendant text nodes inside it will be marked.
633
+ *
634
+ * @private
635
+ * @param {module:engine/view/node~ViewNode} viewNode View node to sync.
636
+ */
637
+ _markDescendantTextToSync(viewNode) {
638
+ if (!viewNode) {
639
+ return;
640
+ }
641
+ if (viewNode.is('$text')) {
642
+ this.markedTexts.add(viewNode);
643
+ }
644
+ else if (viewNode.is('element')) {
645
+ for (const child of viewNode.getChildren()) {
646
+ this._markDescendantTextToSync(child);
647
+ }
648
+ }
649
+ }
650
+ /**
651
+ * Checks if the selection needs to be updated and possibly updates it.
652
+ *
653
+ * @private
654
+ */
655
+ _updateSelection() {
656
+ // Block updating DOM selection in (non-Android) Blink while the user is selecting to prevent accidental selection collapsing.
657
+ // Note: Structural changes in DOM must trigger selection rendering, though. Nodes the selection was anchored
658
+ // to, may disappear in DOM which would break the selection (e.g. in real-time collaboration scenarios).
659
+ // https://github.com/ckeditor/ckeditor5/issues/10562, https://github.com/ckeditor/ckeditor5/issues/10723
660
+ if (env.isBlink && !env.isAndroid && this.isSelecting && !this.markedChildren.size) {
661
+ return;
662
+ }
663
+ // If there is no selection - remove DOM and fake selections.
664
+ if (this.selection.rangeCount === 0) {
665
+ this._removeDomSelection();
666
+ this._removeFakeSelection();
667
+ return;
668
+ }
669
+ const domRoot = this.domConverter.mapViewToDom(this.selection.editableElement);
670
+ // Do nothing if there is no focus, or there is no DOM element corresponding to selection's editable element.
671
+ if (!this.isFocused || !domRoot) {
672
+ return;
673
+ }
674
+ // Render selection.
675
+ if (this.selection.isFake) {
676
+ this._updateFakeSelection(domRoot);
677
+ }
678
+ else {
679
+ this._removeFakeSelection();
680
+ this._updateDomSelection(domRoot);
681
+ }
682
+ }
683
+ /**
684
+ * Updates the fake selection.
685
+ *
686
+ * @private
687
+ * @param {HTMLElement} domRoot A valid DOM root where the fake selection container should be added.
688
+ */
689
+ _updateFakeSelection(domRoot) {
690
+ const domDocument = domRoot.ownerDocument;
691
+ if (!this._fakeSelectionContainer) {
692
+ this._fakeSelectionContainer = createFakeSelectionContainer(domDocument);
693
+ }
694
+ const container = this._fakeSelectionContainer;
695
+ // Bind fake selection container with the current selection *position*.
696
+ this.domConverter.bindFakeSelection(container, this.selection);
697
+ if (!this._fakeSelectionNeedsUpdate(domRoot)) {
698
+ return;
699
+ }
700
+ if (!container.parentElement || container.parentElement != domRoot) {
701
+ domRoot.appendChild(container);
702
+ }
703
+ container.textContent = this.selection.fakeSelectionLabel || '\u00A0';
704
+ const domSelection = domDocument.getSelection();
705
+ const domRange = domDocument.createRange();
706
+ domSelection.removeAllRanges();
707
+ domRange.selectNodeContents(container);
708
+ domSelection.addRange(domRange);
709
+ }
710
+ /**
711
+ * Updates the DOM selection.
712
+ *
713
+ * @private
714
+ * @param {HTMLElement} domRoot A valid DOM root where the DOM selection should be rendered.
715
+ */
716
+ _updateDomSelection(domRoot) {
717
+ const domSelection = domRoot.ownerDocument.defaultView.getSelection();
718
+ // Let's check whether DOM selection needs updating at all.
719
+ if (!this._domSelectionNeedsUpdate(domSelection)) {
720
+ return;
721
+ }
722
+ // Multi-range selection is not available in most browsers, and, at least in Chrome, trying to
723
+ // set such selection, that is not continuous, throws an error. Because of that, we will just use anchor
724
+ // and focus of view selection.
725
+ // Since we are not supporting multi-range selection, we also do not need to check if proper editable is
726
+ // selected. If there is any editable selected, it is okay (editable is taken from selection anchor).
727
+ const anchor = this.domConverter.viewPositionToDom(this.selection.anchor);
728
+ const focus = this.domConverter.viewPositionToDom(this.selection.focus);
729
+ domSelection.collapse(anchor.parent, anchor.offset);
730
+ domSelection.extend(focus.parent, focus.offset);
731
+ // Firefox–specific hack (https://github.com/ckeditor/ckeditor5-engine/issues/1439).
732
+ if (env.isGecko) {
733
+ fixGeckoSelectionAfterBr(focus, domSelection);
734
+ }
735
+ }
736
+ /**
737
+ * Checks whether a given DOM selection needs to be updated.
738
+ *
739
+ * @private
740
+ * @param {Selection} domSelection The DOM selection to check.
741
+ * @returns {Boolean}
742
+ */
743
+ _domSelectionNeedsUpdate(domSelection) {
744
+ if (!this.domConverter.isDomSelectionCorrect(domSelection)) {
745
+ // Current DOM selection is in incorrect position. We need to update it.
746
+ return true;
747
+ }
748
+ const oldViewSelection = domSelection && this.domConverter.domSelectionToView(domSelection);
749
+ if (oldViewSelection && this.selection.isEqual(oldViewSelection)) {
750
+ return false;
751
+ }
752
+ // If selection is not collapsed, it does not need to be updated if it is similar.
753
+ if (!this.selection.isCollapsed && this.selection.isSimilar(oldViewSelection)) {
754
+ // Selection did not changed and is correct, do not update.
755
+ return false;
756
+ }
757
+ // Selections are not similar.
758
+ return true;
759
+ }
760
+ /**
761
+ * Checks whether the fake selection needs to be updated.
762
+ *
763
+ * @private
764
+ * @param {HTMLElement} domRoot A valid DOM root where a new fake selection container should be added.
765
+ * @returns {Boolean}
766
+ */
767
+ _fakeSelectionNeedsUpdate(domRoot) {
768
+ const container = this._fakeSelectionContainer;
769
+ const domSelection = domRoot.ownerDocument.getSelection();
770
+ // Fake selection needs to be updated if there's no fake selection container, or the container currently sits
771
+ // in a different root.
772
+ if (!container || container.parentElement !== domRoot) {
773
+ return true;
774
+ }
775
+ // Make sure that the selection actually is within the fake selection.
776
+ if (domSelection.anchorNode !== container && !container.contains(domSelection.anchorNode)) {
777
+ return true;
778
+ }
779
+ return container.textContent !== this.selection.fakeSelectionLabel;
780
+ }
781
+ /**
782
+ * Removes the DOM selection.
783
+ *
784
+ * @private
785
+ */
786
+ _removeDomSelection() {
787
+ for (const doc of this.domDocuments) {
788
+ const domSelection = doc.getSelection();
789
+ if (domSelection.rangeCount) {
790
+ const activeDomElement = doc.activeElement;
791
+ const viewElement = this.domConverter.mapDomToView(activeDomElement);
792
+ if (activeDomElement && viewElement) {
793
+ domSelection.removeAllRanges();
794
+ }
795
+ }
796
+ }
797
+ }
798
+ /**
799
+ * Removes the fake selection.
800
+ *
801
+ * @private
802
+ */
803
+ _removeFakeSelection() {
804
+ const container = this._fakeSelectionContainer;
805
+ if (container) {
806
+ container.remove();
807
+ }
808
+ }
809
+ /**
810
+ * Checks if focus needs to be updated and possibly updates it.
811
+ *
812
+ * @private
813
+ */
814
+ _updateFocus() {
815
+ if (this.isFocused) {
816
+ const editable = this.selection.editableElement;
817
+ if (editable) {
818
+ this.domConverter.focus(editable);
819
+ }
820
+ }
821
+ }
941
822
  }
942
-
943
- mix( Renderer, ObservableMixin );
944
-
945
823
  // Checks if provided element is editable.
946
824
  //
947
825
  // @private
948
826
  // @param {module:engine/view/element~Element} element
949
827
  // @returns {Boolean}
950
- function isEditable( element ) {
951
- if ( element.getAttribute( 'contenteditable' ) == 'false' ) {
952
- return false;
953
- }
954
-
955
- const parent = element.findAncestor( element => element.hasAttribute( 'contenteditable' ) );
956
-
957
- return !parent || parent.getAttribute( 'contenteditable' ) == 'true';
828
+ function isEditable(element) {
829
+ if (element.getAttribute('contenteditable') == 'false') {
830
+ return false;
831
+ }
832
+ const parent = element.findAncestor(element => element.hasAttribute('contenteditable'));
833
+ return !parent || parent.getAttribute('contenteditable') == 'true';
958
834
  }
959
-
960
835
  // Adds inline filler at a given position.
961
836
  //
962
837
  // The position can be given as an array of DOM nodes and an offset in that array,
@@ -964,44 +839,40 @@ function isEditable( element ) {
964
839
  //
965
840
  // @private
966
841
  // @param {Document} domDocument
967
- // @param {Element|Array.<Node>} domParentOrArray
842
+ // @param {Element|Array.<ViewNode>} domParentOrArray
968
843
  // @param {Number} offset
969
844
  // @returns {Text} The DOM text node that contains an inline filler.
970
- function addInlineFiller( domDocument, domParentOrArray, offset ) {
971
- const childNodes = domParentOrArray instanceof Array ? domParentOrArray : domParentOrArray.childNodes;
972
- const nodeAfterFiller = childNodes[ offset ];
973
-
974
- if ( isText( nodeAfterFiller ) ) {
975
- nodeAfterFiller.data = INLINE_FILLER + nodeAfterFiller.data;
976
-
977
- return nodeAfterFiller;
978
- } else {
979
- const fillerNode = domDocument.createTextNode( INLINE_FILLER );
980
-
981
- if ( Array.isArray( domParentOrArray ) ) {
982
- childNodes.splice( offset, 0, fillerNode );
983
- } else {
984
- insertAt( domParentOrArray, offset, fillerNode );
985
- }
986
-
987
- return fillerNode;
988
- }
845
+ function addInlineFiller(domDocument, domParentOrArray, offset) {
846
+ const childNodes = domParentOrArray instanceof Array ? domParentOrArray : domParentOrArray.childNodes;
847
+ const nodeAfterFiller = childNodes[offset];
848
+ if (isText(nodeAfterFiller)) {
849
+ nodeAfterFiller.data = INLINE_FILLER + nodeAfterFiller.data;
850
+ return nodeAfterFiller;
851
+ }
852
+ else {
853
+ const fillerNode = domDocument.createTextNode(INLINE_FILLER);
854
+ if (Array.isArray(domParentOrArray)) {
855
+ childNodes.splice(offset, 0, fillerNode);
856
+ }
857
+ else {
858
+ insertAt(domParentOrArray, offset, fillerNode);
859
+ }
860
+ return fillerNode;
861
+ }
989
862
  }
990
-
991
863
  // Whether two DOM nodes should be considered as similar.
992
864
  // Nodes are considered similar if they have the same tag name.
993
865
  //
994
866
  // @private
995
- // @param {Node} node1
996
- // @param {Node} node2
867
+ // @param {ViewNode} node1
868
+ // @param {ViewNode} node2
997
869
  // @returns {Boolean}
998
- function areSimilar( node1, node2 ) {
999
- return isNode( node1 ) && isNode( node2 ) &&
1000
- !isText( node1 ) && !isText( node2 ) &&
1001
- !isComment( node1 ) && !isComment( node2 ) &&
1002
- node1.tagName.toLowerCase() === node2.tagName.toLowerCase();
870
+ function areSimilar(node1, node2) {
871
+ return isNode(node1) && isNode(node2) &&
872
+ !isText(node1) && !isText(node2) &&
873
+ !isComment(node1) && !isComment(node2) &&
874
+ node1.tagName.toLowerCase() === node2.tagName.toLowerCase();
1003
875
  }
1004
-
1005
876
  // Whether two dom nodes should be considered as the same.
1006
877
  // Two nodes which are considered the same are:
1007
878
  //
@@ -1011,28 +882,26 @@ function areSimilar( node1, node2 ) {
1011
882
  //
1012
883
  // @private
1013
884
  // @param {String} blockFillerMode Block filler mode, see {@link module:engine/view/domconverter~DomConverter#blockFillerMode}.
1014
- // @param {Node} node1
1015
- // @param {Node} node2
885
+ // @param {ViewNode} node1
886
+ // @param {ViewNode} node2
1016
887
  // @returns {Boolean}
1017
- function sameNodes( domConverter, actualDomChild, expectedDomChild ) {
1018
- // Elements.
1019
- if ( actualDomChild === expectedDomChild ) {
1020
- return true;
1021
- }
1022
- // Texts.
1023
- else if ( isText( actualDomChild ) && isText( expectedDomChild ) ) {
1024
- return actualDomChild.data === expectedDomChild.data;
1025
- }
1026
- // Block fillers.
1027
- else if ( domConverter.isBlockFiller( actualDomChild ) &&
1028
- domConverter.isBlockFiller( expectedDomChild ) ) {
1029
- return true;
1030
- }
1031
-
1032
- // Not matching types.
1033
- return false;
888
+ function sameNodes(domConverter, actualDomChild, expectedDomChild) {
889
+ // Elements.
890
+ if (actualDomChild === expectedDomChild) {
891
+ return true;
892
+ }
893
+ // Texts.
894
+ else if (isText(actualDomChild) && isText(expectedDomChild)) {
895
+ return actualDomChild.data === expectedDomChild.data;
896
+ }
897
+ // Block fillers.
898
+ else if (domConverter.isBlockFiller(actualDomChild) &&
899
+ domConverter.isBlockFiller(expectedDomChild)) {
900
+ return true;
901
+ }
902
+ // Not matching types.
903
+ return false;
1034
904
  }
1035
-
1036
905
  // The following is a Firefox–specific hack (https://github.com/ckeditor/ckeditor5-engine/issues/1439).
1037
906
  // When the native DOM selection is at the end of the block and preceded by <br /> e.g.
1038
907
  //
@@ -1040,60 +909,47 @@ function sameNodes( domConverter, actualDomChild, expectedDomChild ) {
1040
909
  //
1041
910
  // which happens a lot when using the soft line break, the browser fails to (visually) move the
1042
911
  // caret to the new line. A quick fix is as simple as force–refreshing the selection with the same range.
1043
- function fixGeckoSelectionAfterBr( focus, domSelection ) {
1044
- const parent = focus.parent;
1045
-
1046
- // This fix works only when the focus point is at the very end of an element.
1047
- // There is no point in running it in cases unrelated to the browser bug.
1048
- if ( parent.nodeType != Node.ELEMENT_NODE || focus.offset != parent.childNodes.length - 1 ) {
1049
- return;
1050
- }
1051
-
1052
- const childAtOffset = parent.childNodes[ focus.offset ];
1053
-
1054
- // To stay on the safe side, the fix being as specific as possible, it targets only the
1055
- // selection which is at the very end of the element and preceded by <br />.
1056
- if ( childAtOffset && childAtOffset.tagName == 'BR' ) {
1057
- domSelection.addRange( domSelection.getRangeAt( 0 ) );
1058
- }
912
+ function fixGeckoSelectionAfterBr(focus, domSelection) {
913
+ const parent = focus.parent;
914
+ // This fix works only when the focus point is at the very end of an element.
915
+ // There is no point in running it in cases unrelated to the browser bug.
916
+ if (parent.nodeType != Node.ELEMENT_NODE || focus.offset != parent.childNodes.length - 1) {
917
+ return;
918
+ }
919
+ const childAtOffset = parent.childNodes[focus.offset];
920
+ // To stay on the safe side, the fix being as specific as possible, it targets only the
921
+ // selection which is at the very end of the element and preceded by <br />.
922
+ if (childAtOffset && childAtOffset.tagName == 'BR') {
923
+ domSelection.addRange(domSelection.getRangeAt(0));
924
+ }
1059
925
  }
1060
-
1061
- function filterOutFakeSelectionContainer( domChildList, fakeSelectionContainer ) {
1062
- const childList = Array.from( domChildList );
1063
-
1064
- if ( childList.length == 0 || !fakeSelectionContainer ) {
1065
- return childList;
1066
- }
1067
-
1068
- const last = childList[ childList.length - 1 ];
1069
-
1070
- if ( last == fakeSelectionContainer ) {
1071
- childList.pop();
1072
- }
1073
-
1074
- return childList;
926
+ function filterOutFakeSelectionContainer(domChildList, fakeSelectionContainer) {
927
+ const childList = Array.from(domChildList);
928
+ if (childList.length == 0 || !fakeSelectionContainer) {
929
+ return childList;
930
+ }
931
+ const last = childList[childList.length - 1];
932
+ if (last == fakeSelectionContainer) {
933
+ childList.pop();
934
+ }
935
+ return childList;
1075
936
  }
1076
-
1077
937
  // Creates a fake selection container for a given document.
1078
938
  //
1079
939
  // @private
1080
940
  // @param {Document} domDocument
1081
941
  // @returns {HTMLElement}
1082
- function createFakeSelectionContainer( domDocument ) {
1083
- const container = domDocument.createElement( 'div' );
1084
-
1085
- container.className = 'ck-fake-selection-container';
1086
-
1087
- Object.assign( container.style, {
1088
- position: 'fixed',
1089
- top: 0,
1090
- left: '-9999px',
1091
- // See https://github.com/ckeditor/ckeditor5/issues/752.
1092
- width: '42px'
1093
- } );
1094
-
1095
- // Fill it with a text node so we can update it later.
1096
- container.textContent = '\u00A0';
1097
-
1098
- return container;
942
+ function createFakeSelectionContainer(domDocument) {
943
+ const container = domDocument.createElement('div');
944
+ container.className = 'ck-fake-selection-container';
945
+ Object.assign(container.style, {
946
+ position: 'fixed',
947
+ top: 0,
948
+ left: '-9999px',
949
+ // See https://github.com/ckeditor/ckeditor5/issues/752.
950
+ width: '42px'
951
+ });
952
+ // Fill it with a text node so we can update it later.
953
+ container.textContent = '\u00A0';
954
+ return container;
1099
955
  }