@ckeditor/ckeditor5-engine
Version:
The editing engine of CKEditor 5 – the best browser-based rich text editor.
220 lines (219 loc) • 8.95 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module engine/view/observer/mutationobserver
*/
import { Observer } from './observer.js';
import { startsWithFiller } from '../filler.js';
import { isEqualWith } from 'es-toolkit/compat';
// @if CK_DEBUG_TYPING // const { _debouncedLine, _buildLogMessage } = require( '../../dev-utils/utils.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~ViewRenderer} 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~ViewRenderer#render}. Because all mutated nodes are marked as
* "to be rendered" and the {@link module:engine/view/renderer~ViewRenderer#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~EditingView} and is available by default.
*/
export class MutationObserver extends Observer {
/**
* Reference to the {@link module:engine/view/view~EditingView#domConverter}.
*/
domConverter;
/**
* Native mutation observer config.
*/
_config;
/**
* Observed DOM elements.
*/
_domElements;
/**
* Native mutation observer.
*/
_mutationObserver;
/**
* @inheritDoc
*/
constructor(view) {
super(view);
this._config = {
childList: true,
characterData: true,
subtree: true
};
this.domConverter = view.domConverter;
this._domElements = new Set();
this._mutationObserver = new window.MutationObserver(this._onMutations.bind(this));
}
/**
* Synchronously handles mutations and empties the queue.
*/
flush() {
this._onMutations(this._mutationObserver.takeRecords());
}
/**
* @inheritDoc
*/
observe(domElement) {
this._domElements.add(domElement);
if (this.isEnabled) {
this._mutationObserver.observe(domElement, this._config);
}
}
/**
* @inheritDoc
*/
stopObserving(domElement) {
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
*/
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. Mark view elements to sync and call render.
*
* @param 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 mutatedTextNodes = new Set();
const elementsWithMutatedChildren = 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) {
const element = domConverter.mapDomToView(mutation.target);
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);
}
}
// 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 and RawElements.
if (element && (element.is('uiElement') || element.is('rawElement'))) {
continue;
}
if (mutation.type === 'characterData') {
const text = domConverter.findCorrespondingViewText(mutation.target);
if (text && !elementsWithMutatedChildren.has(text.parent)) {
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));
}
}
}
// 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.
const mutations = [];
for (const textNode of mutatedTextNodes) {
mutations.push({ type: 'text', node: 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)) {
mutations.push({ type: 'children', node: viewElement });
}
}
// In case only non-relevant mutations were recorded it skips the event and force render (#5600).
if (mutations.length) {
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // _debouncedLine();
// @if CK_DEBUG_TYPING // console.group( ..._buildLogMessage( this, 'MutationObserver',
// @if CK_DEBUG_TYPING // '%cMutations detected',
// @if CK_DEBUG_TYPING // 'font-weight: bold'
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
this.document.fire('mutations', { mutations });
// @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.
*/
_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');
}
}
function sameNodes(child1, child2) {
// First level of comparison (array of children vs array of children) – use the es-toolkit'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;
}