ckeditor/ckeditor5-engine

View on GitHub
src/view/observer/mutationobserver.js

Summary

Maintainability
C
1 day
Test Coverage
/**
 * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. 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';
import ViewSelection from '../selection';
import { startsWithFiller, getDataWithoutFiller } from '../filler';
import { isEqualWith } from 'lodash-es';

/**
 * 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, { 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 ) ) {
                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 );
            }
        }

        // In case only non-relevant mutations were recorded it skips the event and force render (#5600).
        if ( viewMutations.length ) {
            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.forceRender();
        }

        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.
 */