ckeditor/ckeditor5-engine

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

Summary

Maintainability
A
1 hr
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/selectionobserver
 */

/* global setInterval, clearInterval */

import Observer from './observer';
import MutationObserver from './mutationobserver';
import { debounce } from 'lodash-es';

/**
 * Selection observer class observes selection changes in the document. If selection changes on the document this
 * observer checks if there are any mutations and if DOM selection is different than the
 * {@link module:engine/view/document~Document#selection view selection}. Selection observer fires
 * {@link module:engine/view/document~Document#event:selectionChange} event only if selection change was the only change in the document
 * and DOM selection is different then the view selection.
 *
 * Note that this observer is attached by the {@link module:engine/view/view~View} and is available by default.
 *
 * @see module:engine/view/observer/mutationobserver~MutationObserver
 * @extends module:engine/view/observer/observer~Observer
 */
export default class SelectionObserver extends Observer {
    constructor( view ) {
        super( view );

        /**
         * Instance of the mutation observer. Selection observer calls
         * {@link module:engine/view/observer/mutationobserver~MutationObserver#flush} to ensure that the mutations will be handled
         * before the {@link module:engine/view/document~Document#event:selectionChange} event is fired.
         *
         * @readonly
         * @member {module:engine/view/observer/mutationobserver~MutationObserver}
         * module:engine/view/observer/selectionobserver~SelectionObserver#mutationObserver
         */
        this.mutationObserver = view.getObserver( MutationObserver );

        /**
         * Reference to the view {@link module:engine/view/documentselection~DocumentSelection} object used to compare
         * new selection with it.
         *
         * @readonly
         * @member {module:engine/view/documentselection~DocumentSelection}
         * module:engine/view/observer/selectionobserver~SelectionObserver#selection
         */
        this.selection = this.document.selection;

        /* eslint-disable max-len */
        /**
         * Reference to the {@link module:engine/view/view~View#domConverter}.
         *
         * @readonly
         * @member {module:engine/view/domconverter~DomConverter} module:engine/view/observer/selectionobserver~SelectionObserver#domConverter
         */
        /* eslint-enable max-len */
        this.domConverter = view.domConverter;

        /**
         * Set of documents which have added "selectionchange" listener to avoid adding listener twice to the same
         * document.
         *
         * @private
         * @member {WeakSet.<Document>} module:engine/view/observer/selectionobserver~SelectionObserver#_documents
         */
        this._documents = new WeakSet();

        /**
         * Fires debounced event `selectionChangeDone`. It uses `lodash#debounce` method to delay function call.
         *
         * @private
         * @param {Object} data Selection change data.
         * @method #_fireSelectionChangeDoneDebounced
         */
        this._fireSelectionChangeDoneDebounced = debounce( data => this.document.fire( 'selectionChangeDone', data ), 200 );

        this._clearInfiniteLoopInterval = setInterval( () => this._clearInfiniteLoop(), 1000 );

        /**
         * Private property to check if the code does not enter infinite loop.
         *
         * @private
         * @member {Number} module:engine/view/observer/selectionobserver~SelectionObserver#_loopbackCounter
         */
        this._loopbackCounter = 0;
    }

    /**
     * @inheritDoc
     */
    observe( domElement ) {
        const domDocument = domElement.ownerDocument;

        // Add listener once per each document.
        if ( this._documents.has( domDocument ) ) {
            return;
        }

        this.listenTo( domDocument, 'selectionchange', () => {
            this._handleSelectionChange( domDocument );
        } );

        this._documents.add( domDocument );
    }

    /**
     * @inheritDoc
     */
    destroy() {
        super.destroy();

        clearInterval( this._clearInfiniteLoopInterval );
        this._fireSelectionChangeDoneDebounced.cancel();
    }

    /**
     * Selection change listener. {@link module:engine/view/observer/mutationobserver~MutationObserver#flush Flush} mutations, check if
     * selection changes and fires {@link module:engine/view/document~Document#event:selectionChange} event on every change
     * and {@link module:engine/view/document~Document#event:selectionChangeDone} when selection stop changing.
     *
     * @private
     * @param {Document} domDocument DOM document.
     */
    _handleSelectionChange( domDocument ) {
        if ( !this.isEnabled ) {
            return;
        }

        // Ensure the mutation event will be before selection event on all browsers.
        this.mutationObserver.flush();

        // If there were mutations then the view will be re-rendered by the mutation observer and selection
        // will be updated, so selections will equal and event will not be fired, as expected.
        const domSelection = domDocument.defaultView.getSelection();
        const newViewSelection = this.domConverter.domSelectionToView( domSelection );

        // Do not convert selection change if the new view selection has no ranges in it.
        //
        // It means that the DOM selection is in some way incorrect. Ranges that were in the DOM selection could not be
        // converted to the view. This happens when the DOM selection was moved outside of the editable element.
        if ( newViewSelection.rangeCount == 0 ) {
            this.view.hasDomSelection = false;

            return;
        }

        this.view.hasDomSelection = true;

        if ( this.selection.isEqual( newViewSelection ) && this.domConverter.isDomSelectionCorrect( domSelection ) ) {
            return;
        }

        // Ensure we are not in the infinite loop (#400).
        // This counter is reset each second. 60 selection changes in 1 second is enough high number
        // to be very difficult (impossible) to achieve using just keyboard keys (during normal editor use).
        if ( ++this._loopbackCounter > 60 ) {
            // Selection change observer detected an infinite rendering loop.
            // Most probably you try to put the selection in the position which is not allowed
            // by the browser and browser fixes it automatically what causes `selectionchange` event on
            // which a loopback through a model tries to re-render the wrong selection and again.
            //
            // @if CK_DEBUG // console.warn( 'Selection change observer detected an infinite rendering loop.' );

            return;
        }

        if ( this.selection.isSimilar( newViewSelection ) ) {
            // If selection was equal and we are at this point of algorithm, it means that it was incorrect.
            // Just re-render it, no need to fire any events, etc.
            this.view.forceRender();
        } else {
            const data = {
                oldSelection: this.selection,
                newSelection: newViewSelection,
                domSelection
            };

            // Prepare data for new selection and fire appropriate events.
            this.document.fire( 'selectionChange', data );

            // Call` #_fireSelectionChangeDoneDebounced` every time when `selectionChange` event is fired.
            // This function is debounced what means that `selectionChangeDone` event will be fired only when
            // defined int the function time will elapse since the last time the function was called.
            // So `selectionChangeDone` will be fired when selection will stop changing.
            this._fireSelectionChangeDoneDebounced( data );
        }
    }

    /**
     * Clears `SelectionObserver` internal properties connected with preventing infinite loop.
     *
     * @protected
     */
    _clearInfiniteLoop() {
        this._loopbackCounter = 0;
    }
}

/**
 * Fired when selection has changed. This event is fired only when the selection change was the only change that happened
 * in the document, and old selection is different then the new selection.
 *
 * Introduced by {@link module:engine/view/observer/selectionobserver~SelectionObserver}.
 *
 * Note that because {@link module:engine/view/observer/selectionobserver~SelectionObserver} is attached by the
 * {@link module:engine/view/view~View} this event is available by default.
 *
 * @see module:engine/view/observer/selectionobserver~SelectionObserver
 * @event module:engine/view/document~Document#event:selectionChange
 * @param {Object} data
 * @param {module:engine/view/documentselection~DocumentSelection} data.oldSelection Old View selection which is
 * {@link module:engine/view/document~Document#selection}.
 * @param {module:engine/view/selection~Selection} data.newSelection New View selection which is converted DOM selection.
 * @param {Selection} data.domSelection Native DOM selection.
 */

/**
 * Fired when selection stops changing.
 *
 * Introduced by {@link module:engine/view/observer/selectionobserver~SelectionObserver}.
 *
 * Note that because {@link module:engine/view/observer/selectionobserver~SelectionObserver} is attached by the
 * {@link module:engine/view/view~View} this event is available by default.
 *
 * @see module:engine/view/observer/selectionobserver~SelectionObserver
 * @event module:engine/view/document~Document#event:selectionChangeDone
 * @param {Object} data
 * @param {module:engine/view/documentselection~DocumentSelection} data.oldSelection Old View selection which is
 * {@link module:engine/view/document~Document#selection}.
 * @param {module:engine/view/selection~Selection} data.newSelection New View selection which is converted DOM selection.
 * @param {Selection} data.domSelection Native DOM selection.
 */