ckeditor/ckeditor5-utils

View on GitHub
src/dom/emittermixin.js

Summary

Maintainability
A
0 mins
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 utils/dom/emittermixin
 */

import { default as EmitterMixin, _getEmitterListenedTo, _setEmitterId } from '../emittermixin';
import uid from '../uid';
import isNode from './isnode';
import isWindow from './iswindow';
import { extend } from 'lodash-es';

/**
 * Mixin that injects the DOM events API into its host. It provides the API
 * compatible with {@link module:utils/emittermixin~EmitterMixin}.
 *
 * DOM emitter mixin is by default available in the {@link module:ui/view~View} class,
 * but it can also be mixed into any other class:
 *
 *        import mix from '../utils/mix.js';
 *        import DomEmitterMixin from '../utils/dom/emittermixin.js';
 *
 *        class SomeView {}
 *        mix( SomeView, DomEmitterMixin );
 *
 *        const view = new SomeView();
 *        view.listenTo( domElement, ( evt, domEvt ) => {
 *            console.log( evt, domEvt );
 *        } );
 *
 * @mixin EmitterMixin
 * @mixes module:utils/emittermixin~EmitterMixin
 * @implements module:utils/dom/emittermixin~Emitter
 */
const DomEmitterMixin = extend( {}, EmitterMixin, {
    /**
     * Registers a callback function to be executed when an event is fired in a specific Emitter or DOM Node.
     * It is backwards compatible with {@link module:utils/emittermixin~EmitterMixin#listenTo}.
     *
     * @param {module:utils/emittermixin~Emitter|Node} emitter The object that fires the event.
     * @param {String} event The name of the event.
     * @param {Function} callback The function to be called on event.
     * @param {Object} [options={}] Additional options.
     * @param {module:utils/priorities~PriorityString|Number} [options.priority='normal'] The priority of this event callback. The higher
     * the priority value the sooner the callback will be fired. Events having the same priority are called in the
     * order they were added.
     * @param {Boolean} [options.useCapture=false] Indicates that events of this type will be dispatched to the registered
     * listener before being dispatched to any EventTarget beneath it in the DOM tree.
     */
    listenTo( emitter, ...rest ) {
        // Check if emitter is an instance of DOM Node. If so, replace the argument with
        // corresponding ProxyEmitter (or create one if not existing).
        if ( isNode( emitter ) || isWindow( emitter ) ) {
            const proxy = this._getProxyEmitter( emitter ) || new ProxyEmitter( emitter );

            proxy.attach( ...rest );

            emitter = proxy;
        }

        // Execute parent class method with Emitter (or ProxyEmitter) instance.
        EmitterMixin.listenTo.call( this, emitter, ...rest );
    },

    /**
     * Stops listening for events. It can be used at different levels:
     * It is backwards compatible with {@link module:utils/emittermixin~EmitterMixin#listenTo}.
     *
     * * To stop listening to a specific callback.
     * * To stop listening to a specific event.
     * * To stop listening to all events fired by a specific object.
     * * To stop listening to all events fired by all object.
     *
     * @param {module:utils/emittermixin~Emitter|Node} [emitter] The object to stop listening to. If omitted, stops it for all objects.
     * @param {String} [event] (Requires the `emitter`) The name of the event to stop listening to. If omitted, stops it
     * for all events from `emitter`.
     * @param {Function} [callback] (Requires the `event`) The function to be removed from the call list for the given
     * `event`.
     */
    stopListening( emitter, event, callback ) {
        // Check if emitter is an instance of DOM Node. If so, replace the argument with corresponding ProxyEmitter.
        if ( isNode( emitter ) || isWindow( emitter ) ) {
            const proxy = this._getProxyEmitter( emitter );

            // Element has no listeners.
            if ( !proxy ) {
                return;
            }

            emitter = proxy;
        }

        // Execute parent class method with Emitter (or ProxyEmitter) instance.
        EmitterMixin.stopListening.call( this, emitter, event, callback );

        if ( emitter instanceof ProxyEmitter ) {
            emitter.detach( event );
        }
    },

    /**
     * Retrieves ProxyEmitter instance for given DOM Node residing in this Host.
     *
     * @private
     * @param {Node} node DOM Node of the ProxyEmitter.
     * @returns {module:utils/dom/emittermixin~ProxyEmitter} ProxyEmitter instance or null.
     */
    _getProxyEmitter( node ) {
        return _getEmitterListenedTo( this, getNodeUID( node ) );
    }
} );

export default DomEmitterMixin;

/**
 * Creates a ProxyEmitter instance. Such an instance is a bridge between a DOM Node firing events
 * and any Host listening to them. It is backwards compatible with {@link module:utils/emittermixin~EmitterMixin#on}.
 *
 *                                  listenTo( click, ... )
 *                    +-----------------------------------------+
 *                    |              stopListening( ... )       |
 *     +----------------------------+                           |             addEventListener( click, ... )
 *     | Host                       |                           |   +---------------------------------------------+
 *     +----------------------------+                           |   |       removeEventListener( click, ... )     |
 *     | _listeningTo: {            |                +----------v-------------+                                   |
 *     |   UID: {                   |                | ProxyEmitter           |                                   |
 *     |     emitter: ProxyEmitter, |                +------------------------+                      +------------v----------+
 *     |     callbacks: {           |                | events: {              |                      | Node (HTMLElement)    |
 *     |       click: [ callbacks ] |                |   click: [ callbacks ] |                      +-----------------------+
 *     |     }                      |                | },                     |                      | data-ck-expando: UID  |
 *     |   }                        |                | _domNode: Node,        |                      +-----------------------+
 *     | }                          |                | _domListeners: {},     |                                   |
 *     | +------------------------+ |                | _emitterId: UID        |                                   |
 *     | | DomEmitterMixin        | |                +--------------^---------+                                   |
 *     | +------------------------+ |                           |   |                                             |
 *     +--------------^-------------+                           |   +---------------------------------------------+
 *                    |                                         |                  click (DOM Event)
 *                    +-----------------------------------------+
 *                                fire( click, DOM Event )
 *
 * @mixes module:utils/emittermixin~EmitterMixin
 * @implements module:utils/dom/emittermixin~Emitter
 * @private
 */
class ProxyEmitter {
    /**
     * @param {Node} node DOM Node that fires events.
     * @returns {Object} ProxyEmitter instance bound to the DOM Node.
     */
    constructor( node ) {
        // Set emitter ID to match DOM Node "expando" property.
        _setEmitterId( this, getNodeUID( node ) );

        // Remember the DOM Node this ProxyEmitter is bound to.
        this._domNode = node;
    }
}

extend( ProxyEmitter.prototype, EmitterMixin, {
    /**
     * Collection of native DOM listeners.
     *
     * @private
     * @member {Object} module:utils/dom/emittermixin~ProxyEmitter#_domListeners
     */

    /**
     * Registers a callback function to be executed when an event is fired.
     *
     * It attaches a native DOM listener to the DOM Node. When fired,
     * a corresponding Emitter event will also fire with DOM Event object as an argument.
     *
     * @method module:utils/dom/emittermixin~ProxyEmitter#attach
     * @param {String} event The name of the event.
     * @param {Function} callback The function to be called on event.
     * @param {Object} [options={}] Additional options.
     * @param {Boolean} [options.useCapture=false] Indicates that events of this type will be dispatched to the registered
     * listener before being dispatched to any EventTarget beneath it in the DOM tree.
     */
    attach( event, callback, options = {} ) {
        // If the DOM Listener for given event already exist it is pointless
        // to attach another one.
        if ( this._domListeners && this._domListeners[ event ] ) {
            return;
        }

        const domListener = this._createDomListener( event, !!options.useCapture );

        // Attach the native DOM listener to DOM Node.
        this._domNode.addEventListener( event, domListener, !!options.useCapture );

        if ( !this._domListeners ) {
            this._domListeners = {};
        }

        // Store the native DOM listener in this ProxyEmitter. It will be helpful
        // when stopping listening to the event.
        this._domListeners[ event ] = domListener;
    },

    /**
     * Stops executing the callback on the given event.
     *
     * @method module:utils/dom/emittermixin~ProxyEmitter#detach
     * @param {String} event The name of the event.
     */
    detach( event ) {
        let events;

        // Remove native DOM listeners which are orphans. If no callbacks
        // are awaiting given event, detach native DOM listener from DOM Node.
        // See: {@link attach}.

        if ( this._domListeners[ event ] && ( !( events = this._events[ event ] ) || !events.callbacks.length ) ) {
            this._domListeners[ event ].removeListener();
        }
    },

    /**
     * Creates a native DOM listener callback. When the native DOM event
     * is fired it will fire corresponding event on this ProxyEmitter.
     * Note: A native DOM Event is passed as an argument.
     *
     * @private
     * @method module:utils/dom/emittermixin~ProxyEmitter#_createDomListener
     * @param {String} event The name of the event.
     * @param {Boolean} useCapture Indicates whether the listener was created for capturing event.
     * @returns {Function} The DOM listener callback.
     */
    _createDomListener( event, useCapture ) {
        const domListener = domEvt => {
            this.fire( event, domEvt );
        };

        // Supply the DOM listener callback with a function that will help
        // detach it from the DOM Node, when it is no longer necessary.
        // See: {@link detach}.
        domListener.removeListener = () => {
            this._domNode.removeEventListener( event, domListener, useCapture );
            delete this._domListeners[ event ];
        };

        return domListener;
    }
} );

// Gets an unique DOM Node identifier. The identifier will be set if not defined.
//
// @private
// @param {Node} node
// @returns {String} UID for given DOM Node.
function getNodeUID( node ) {
    return node[ 'data-ck-expando' ] || ( node[ 'data-ck-expando' ] = uid() );
}

/**
 * Interface representing classes which mix in {@link module:utils/dom/emittermixin~EmitterMixin}.
 *
 * @interface Emitter
 */