This repository has been archived by the owner on Jun 26, 2020. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 40
/
Copy pathmutationobserver.js
342 lines (292 loc) · 11.5 KB
/
mutationobserver.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
/**
* @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md.
*/
/**
* @module engine/view/observer/mutationobserver
*/
/* globals window */
import Observer from './observer';
import ViewSelection from '../selection';
import { startsWithFiller, getDataWithoutFiller } from '../filler';
import isEqualWith from '@ckeditor/ckeditor5-utils/src/lib/lodash/isEqualWith';
/**
* Mutation observer class observes changes in the DOM, fires {@link module:engine/view/document~Document#event:mutations} event, mark view
* elements as changed and call {@link module:engine/view/renderer~Renderer#render}.
* Because all mutated nodes are marked as "to be rendered" and the
* {@link module:engine/view/renderer~Renderer#render} is called, all changes will be reverted, unless the mutation will be handled by the
* {@link module:engine/view/document~Document#event:mutations} event listener. It means user will see only handled changes, and the editor
* will block all changes which are not handled.
*
* Mutation Observer also take care of reducing number of mutations which are fired. It removes duplicates and
* mutations on elements which do not have corresponding view elements. Also
* {@link module:engine/view/observer/mutationobserver~MutatedText text mutation} is fired only if parent element do not change child list.
*
* Note that this observer is attached by the {@link module:engine/view/view~View} and is available by default.
*
* @extends module:engine/view/observer/observer~Observer
*/
export default class MutationObserver extends Observer {
constructor( view ) {
super( view );
/**
* Native mutation observer config.
*
* @private
* @member {Object}
*/
this._config = {
childList: true,
characterData: true,
characterDataOldValue: true,
subtree: true
};
/**
* Reference to the {@link module:engine/view/view~View#domConverter}.
*
* @member {module:engine/view/domconverter~DomConverter}
*/
this.domConverter = view.domConverter;
/**
* Reference to the {@link module:engine/view/view~View#renderer}.
*
* @member {module:engine/view/renderer~Renderer}
*/
this.renderer = view._renderer;
/**
* Observed DOM elements.
*
* @private
* @member {Array.<HTMLElement>}
*/
this._domElements = [];
/**
* Native mutation observer.
*
* @private
* @member {MutationObserver}
*/
this._mutationObserver = new window.MutationObserver( this._onMutations.bind( this ) );
}
/**
* Synchronously fires {@link module:engine/view/document~Document#event:mutations} event with all mutations in record queue.
* At the same time empties the queue so mutations will not be fired twice.
*/
flush() {
this._onMutations( this._mutationObserver.takeRecords() );
}
/**
* @inheritDoc
*/
observe( domElement ) {
this._domElements.push( domElement );
if ( this.isEnabled ) {
this._mutationObserver.observe( domElement, this._config );
}
}
/**
* @inheritDoc
*/
enable() {
super.enable();
for ( const domElement of this._domElements ) {
this._mutationObserver.observe( domElement, this._config );
}
}
/**
* @inheritDoc
*/
disable() {
super.disable();
this._mutationObserver.disconnect();
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
this._mutationObserver.disconnect();
}
/**
* Handles mutations. Deduplicates, mark view elements to sync, fire event and call render.
*
* @private
* @param {Array.<Object>} domMutations Array of native mutations.
*/
_onMutations( domMutations ) {
// As a result of this.flush() we can have an empty collection.
if ( domMutations.length === 0 ) {
return;
}
const domConverter = this.domConverter;
// Use map and set for deduplication.
const mutatedTexts = new Map();
const mutatedElements = new Set();
// Handle `childList` mutations first, so we will be able to check if the `characterData` mutation is in the
// element with changed structure anyway.
for ( const mutation of domMutations ) {
if ( mutation.type === 'childList' ) {
const element = domConverter.mapDomToView( mutation.target );
// Do not collect mutations from UIElements.
if ( element && element.is( 'uiElement' ) ) {
continue;
}
if ( element && !this._isBogusBrMutation( mutation ) ) {
mutatedElements.add( element );
}
}
}
// Handle `characterData` mutations later, when we have the full list of nodes which changed structure.
for ( const mutation of domMutations ) {
const element = domConverter.mapDomToView( mutation.target );
// Do not collect mutations from UIElements.
if ( element && element.is( 'uiElement' ) ) {
continue;
}
if ( mutation.type === 'characterData' ) {
const text = domConverter.findCorrespondingViewText( mutation.target );
if ( text && !mutatedElements.has( text.parent ) ) {
// Use text as a key, for deduplication. If there will be another mutation on the same text element
// we will have only one in the map.
mutatedTexts.set( text, {
type: 'text',
oldText: text.data,
newText: getDataWithoutFiller( mutation.target ),
node: text
} );
}
// When we added first letter to the text node which had only inline filler, for the DOM it is mutation
// on text, but for the view, where filler text node did not existed, new text node was created, so we
// need to fire 'children' mutation instead of 'text'.
else if ( !text && startsWithFiller( mutation.target ) ) {
mutatedElements.add( domConverter.mapDomToView( mutation.target.parentNode ) );
}
}
}
// Now we build the list of mutations to fire and mark elements. We did not do it earlier to avoid marking the
// same node multiple times in case of duplication.
// List of mutations we will fire.
const viewMutations = [];
for ( const mutatedText of mutatedTexts.values() ) {
this.renderer.markToSync( 'text', mutatedText.node );
viewMutations.push( mutatedText );
}
for ( const viewElement of mutatedElements ) {
const domElement = domConverter.mapViewToDom( viewElement );
const viewChildren = Array.from( viewElement.getChildren() );
const newViewChildren = Array.from( domConverter.domChildrenToView( domElement ) );
// It may happen that as a result of many changes (sth was inserted and then removed),
// both elements haven't really changed. #1031
if ( !isEqualWith( viewChildren, newViewChildren, sameNodes ) ) {
this.renderer.markToSync( 'children', viewElement );
viewMutations.push( {
type: 'children',
oldChildren: viewChildren,
newChildren: newViewChildren,
node: viewElement
} );
}
}
// Retrieve `domSelection` using `ownerDocument` of one of mutated nodes.
// There should not be simultaneous mutation in multiple documents, so it's fine.
const domSelection = domMutations[ 0 ].target.ownerDocument.getSelection();
let viewSelection = null;
if ( domSelection && domSelection.anchorNode ) {
// If `domSelection` is inside a dom node that is already bound to a view node from view tree, get
// corresponding selection in the view and pass it together with `viewMutations`. The `viewSelection` may
// be used by features handling mutations.
// Only one range is supported.
const viewSelectionAnchor = domConverter.domPositionToView( domSelection.anchorNode, domSelection.anchorOffset );
const viewSelectionFocus = domConverter.domPositionToView( domSelection.focusNode, domSelection.focusOffset );
// Anchor and focus has to be properly mapped to view.
if ( viewSelectionAnchor && viewSelectionFocus ) {
viewSelection = new ViewSelection( viewSelectionAnchor );
viewSelection._setFocus( viewSelectionFocus );
}
}
this.document.fire( 'mutations', viewMutations, viewSelection );
// If nothing changes on `mutations` event, at this point we have "dirty DOM" (changed) and de-synched
// view (which has not been changed). In order to "reset DOM" we render the view again.
this.view.render();
function sameNodes( child1, child2 ) {
// First level of comparison (array of children vs array of children) – use the Lodash's default behavior.
if ( Array.isArray( child1 ) ) {
return;
}
// Elements.
if ( child1 === child2 ) {
return true;
}
// Texts.
else if ( child1.is( 'text' ) && child2.is( 'text' ) ) {
return child1.data === child2.data;
}
// Not matching types.
return false;
}
}
/**
* Checks if mutation was generated by the browser inserting bogus br on the end of the block element.
* Such mutations are generated while pressing space or performing native spellchecker correction
* on the end of the block element in Firefox browser.
*
* @private
* @param {Object} mutation Native mutation object.
* @returns {Boolean}
*/
_isBogusBrMutation( mutation ) {
let addedNode = null;
// Check if mutation added only one node on the end of its parent.
if ( mutation.nextSibling === null && mutation.removedNodes.length === 0 && mutation.addedNodes.length == 1 ) {
addedNode = this.domConverter.domToView( mutation.addedNodes[ 0 ], {
withChildren: false
} );
}
return addedNode && addedNode.is( 'element', 'br' );
}
}
/**
* Fired when mutation occurred. If tree view is not changed on this event, DOM will be reverted to the state before
* mutation, so all changes which should be applied, should be handled on this event.
*
* Introduced by {@link module:engine/view/observer/mutationobserver~MutationObserver}.
*
* Note that because {@link module:engine/view/observer/mutationobserver~MutationObserver} is attached by the
* {@link module:engine/view/view~View} this event is available by default.
*
* @see module:engine/view/observer/mutationobserver~MutationObserver
* @event module:engine/view/document~Document#event:mutations
* @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|module:engine/view/observer/mutationobserver~MutatedChildren>}
* viewMutations Array of mutations.
* For mutated texts it will be {@link module:engine/view/observer/mutationobserver~MutatedText} and for mutated elements it will be
* {@link module:engine/view/observer/mutationobserver~MutatedChildren}. You can recognize the type based on the `type` property.
* @param {module:engine/view/selection~Selection|null} viewSelection View selection that is a result of converting DOM selection to view.
* Keep in
* mind that the DOM selection is already "updated", meaning that it already acknowledges changes done in mutation.
*/
/**
* Mutation item for text.
*
* @see module:engine/view/document~Document#event:mutations
* @see module:engine/view/observer/mutationobserver~MutatedChildren
*
* @typedef {Object} module:engine/view/observer/mutationobserver~MutatedText
*
* @property {String} type For text mutations it is always 'text'.
* @property {module:engine/view/text~Text} node Mutated text node.
* @property {String} oldText Old text.
* @property {String} newText New text.
*/
/**
* Mutation item for child nodes.
*
* @see module:engine/view/document~Document#event:mutations
* @see module:engine/view/observer/mutationobserver~MutatedText
*
* @typedef {Object} module:engine/view/observer/mutationobserver~MutatedChildren
*
* @property {String} type For child nodes mutations it is always 'children'.
* @property {module:engine/view/element~Element} node Parent of the mutated children.
* @property {Array.<module:engine/view/node~Node>} oldChildren Old child nodes.
* @property {Array.<module:engine/view/node~Node>} newChildren New child nodes.
*/