-
Notifications
You must be signed in to change notification settings - Fork 3.6k
/
mutationobserver.ts
284 lines (234 loc) · 8.63 KB
/
mutationobserver.ts
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
/**
* @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module engine/view/observer/mutationobserver
*/
/* globals window */
import Observer from './observer.js';
import { startsWithFiller } from '../filler.js';
import { isEqualWith } from 'lodash-es';
import type DomConverter from '../domconverter.js';
import type Renderer from '../renderer.js';
import type View from '../view.js';
import type ViewElement from '../element.js';
import type ViewNode from '../node.js';
import type ViewText from '../text.js';
/**
* Mutation observer's role is to watch for any DOM changes inside the editor that weren't
* done by the editor's {@link module:engine/view/renderer~Renderer} itself and reverting these changes.
*
* It does this by observing all mutations in the DOM, marking related view elements as changed and calling
* {@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 `render()`} method is called,
* all changes are reverted in the DOM (the DOM is synced with the editor's view structure).
*
* Note that this observer is attached by the {@link module:engine/view/view~View} and is available by default.
*/
export default class MutationObserver extends Observer {
/**
* Reference to the {@link module:engine/view/view~View#domConverter}.
*/
public readonly domConverter: DomConverter;
/**
* Reference to the {@link module:engine/view/view~View#_renderer}.
*/
public readonly renderer: Renderer;
/**
* Native mutation observer config.
*/
private readonly _config: MutationObserverInit;
/**
* Observed DOM elements.
*/
private readonly _domElements: Set<HTMLElement>;
/**
* Native mutation observer.
*/
private _mutationObserver: InstanceType<typeof window.MutationObserver>;
/**
* @inheritDoc
*/
constructor( view: View ) {
super( view );
this._config = {
childList: true,
characterData: true,
subtree: true
};
this.domConverter = view.domConverter;
this.renderer = view._renderer;
this._domElements = new Set();
this._mutationObserver = new window.MutationObserver( this._onMutations.bind( this ) );
}
/**
* Synchronously handles mutations and empties the queue.
*/
public flush(): void {
this._onMutations( this._mutationObserver.takeRecords() );
}
/**
* @inheritDoc
*/
public observe( domElement: HTMLElement ): void {
this._domElements.add( domElement );
if ( this.isEnabled ) {
this._mutationObserver.observe( domElement, this._config );
}
}
/**
* @inheritDoc
*/
public override stopObserving( domElement: HTMLElement ): void {
this._domElements.delete( domElement );
if ( this.isEnabled ) {
// Unfortunately, it is not possible to stop observing particular DOM element.
// In order to stop observing one of multiple DOM elements, we need to re-connect the mutation observer.
this._mutationObserver.disconnect();
for ( const domElement of this._domElements ) {
this._mutationObserver.observe( domElement, this._config );
}
}
}
/**
* @inheritDoc
*/
public override enable(): void {
super.enable();
for ( const domElement of this._domElements ) {
this._mutationObserver.observe( domElement, this._config );
}
}
/**
* @inheritDoc
*/
public override disable(): void {
super.disable();
this._mutationObserver.disconnect();
}
/**
* @inheritDoc
*/
public override destroy(): void {
super.destroy();
this._mutationObserver.disconnect();
}
/**
* Handles mutations. Mark view elements to sync and call render.
*
* @param domMutations Array of native mutations.
*/
private _onMutations( domMutations: Array<MutationRecord> ) {
// 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 mutatedTextNodes = new Set<ViewText>();
const elementsWithMutatedChildren = new Set<ViewElement>();
// 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 ) {
const element = domConverter.mapDomToView( mutation.target as HTMLElement );
if ( !element ) {
continue;
}
// Do not collect mutations from UIElements and RawElements.
if ( element.is( 'uiElement' ) || element.is( 'rawElement' ) ) {
continue;
}
if ( mutation.type === 'childList' && !this._isBogusBrMutation( mutation ) ) {
elementsWithMutatedChildren.add( element as ViewElement );
}
}
// 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 as HTMLElement );
// Do not collect mutations from UIElements and RawElements.
if ( element && ( element.is( 'uiElement' ) || element.is( 'rawElement' ) ) ) {
continue;
}
if ( mutation.type === 'characterData' ) {
const text = domConverter.findCorrespondingViewText( mutation.target as Text ) as ViewText;
if ( text && !elementsWithMutatedChildren.has( text.parent as ViewElement ) ) {
mutatedTextNodes.add( 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 exist, new text node was created, so we
// need to handle it as a 'children' mutation instead of 'text'.
else if ( !text && startsWithFiller( mutation.target ) ) {
elementsWithMutatedChildren.add(
domConverter.mapDomToView( mutation.target.parentNode as HTMLElement ) as ViewElement
);
}
}
}
// Now we build the list of mutations to mark elements. We did not do it earlier to avoid marking the
// same node multiple times in case of duplication.
let hasMutations = false;
for ( const textNode of mutatedTextNodes ) {
hasMutations = true;
this.renderer.markToSync( 'text', textNode );
}
for ( const viewElement of elementsWithMutatedChildren ) {
const domElement = domConverter.mapViewToDom( viewElement )!;
const viewChildren = Array.from( viewElement.getChildren() );
const newViewChildren = Array.from( domConverter.domChildrenToView( domElement, { withChildren: false } ) );
// 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 ) ) {
hasMutations = true;
this.renderer.markToSync( 'children', viewElement );
}
}
// In case only non-relevant mutations were recorded it skips the event and force render (#5600).
if ( hasMutations ) {
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.group( '%c[MutationObserver]%c Mutations detected',
// @if CK_DEBUG_TYPING // 'font-weight:bold;color:green', ''
// @if CK_DEBUG_TYPING // );
// @if CK_DEBUG_TYPING // }
// 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.forceRender();
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.groupEnd();
// @if CK_DEBUG_TYPING // }
}
}
/**
* 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.
*
* @param mutation Native mutation object.
*/
private _isBogusBrMutation( mutation: MutationRecord ) {
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' );
}
}
function sameNodes( child1: ViewNode, child2: ViewNode ) {
// 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;
}