mixins_evented.js

/**
 * @file mixins/evented.js
 * @module evented
 */
import window from 'global/window';
import * as Dom from '../utils/dom';
import * as Events from '../utils/events';
import * as Fn from '../utils/fn';
import EventTarget from '../event-target';
import DomData from '../utils/dom-data';

const objName = (obj) => {
  if (typeof obj.name === 'function') {
    return obj.name();
  }

  if (typeof obj.name === 'string') {
    return obj.name;
  }

  if (obj.name_) {
    return obj.name_;
  }

  if (obj.constructor && obj.constructor.name) {
    return obj.constructor.name;
  }

  return typeof obj;
};

/**
 * Returns whether or not an object has had the evented mixin applied.
 *
 * @param  {Object} object
 *         An object to test.
 *
 * @return {boolean}
 *         Whether or not the object appears to be evented.
 */
const isEvented = (object) =>
  object instanceof EventTarget ||
  !!object.eventBusEl_ &&
  ['on', 'one', 'off', 'trigger'].every(k => typeof object[k] === 'function');

/**
 * Adds a callback to run after the evented mixin applied.
 *
 * @param  {Object} target
 *         An object to Add
 * @param  {Function} callback
 *         The callback to run.
 */
const addEventedCallback = (target, callback) => {
  if (isEvented(target)) {
    callback();
  } else {
    if (!target.eventedCallbacks) {
      target.eventedCallbacks = [];
    }
    target.eventedCallbacks.push(callback);
  }
};

/**
 * Whether a value is a valid event type - non-empty string or array.
 *
 * @private
 * @param  {string|Array} type
 *         The type value to test.
 *
 * @return {boolean}
 *         Whether or not the type is a valid event type.
 */
const isValidEventType = (type) =>
  // The regex here verifies that the `type` contains at least one non-
  // whitespace character.
  (typeof type === 'string' && (/\S/).test(type)) ||
  (Array.isArray(type) && !!type.length);

/**
 * Validates a value to determine if it is a valid event target. Throws if not.
 *
 * @private
 * @throws {Error}
 *         If the target does not appear to be a valid event target.
 *
 * @param  {Object} target
 *         The object to test.
 *
 * @param  {Object} obj
 *         The evented object we are validating for
 *
 * @param  {string} fnName
 *         The name of the evented mixin function that called this.
 */
const validateTarget = (target, obj, fnName) => {
  if (!target || (!target.nodeName && !isEvented(target))) {
    throw new Error(`Invalid target for ${objName(obj)}#${fnName}; must be a DOM node or evented object.`);
  }
};

/**
 * Validates a value to determine if it is a valid event target. Throws if not.
 *
 * @private
 * @throws {Error}
 *         If the type does not appear to be a valid event type.
 *
 * @param  {string|Array} type
 *         The type to test.
 *
 * @param  {Object} obj
*         The evented object we are validating for
 *
 * @param  {string} fnName
 *         The name of the evented mixin function that called this.
 */
const validateEventType = (type, obj, fnName) => {
  if (!isValidEventType(type)) {
    throw new Error(`Invalid event type for ${objName(obj)}#${fnName}; must be a non-empty string or array.`);
  }
};

/**
 * Validates a value to determine if it is a valid listener. Throws if not.
 *
 * @private
 * @throws {Error}
 *         If the listener is not a function.
 *
 * @param  {Function} listener
 *         The listener to test.
 *
 * @param  {Object} obj
 *         The evented object we are validating for
 *
 * @param  {string} fnName
 *         The name of the evented mixin function that called this.
 */
const validateListener = (listener, obj, fnName) => {
  if (typeof listener !== 'function') {
    throw new Error(`Invalid listener for ${objName(obj)}#${fnName}; must be a function.`);
  }
};

/**
 * Takes an array of arguments given to `on()` or `one()`, validates them, and
 * normalizes them into an object.
 *
 * @private
 * @param  {Object} self
 *         The evented object on which `on()` or `one()` was called. This
 *         object will be bound as the `this` value for the listener.
 *
 * @param  {Array} args
 *         An array of arguments passed to `on()` or `one()`.
 *
 * @param  {string} fnName
 *         The name of the evented mixin function that called this.
 *
 * @return {Object}
 *         An object containing useful values for `on()` or `one()` calls.
 */
const normalizeListenArgs = (self, args, fnName) => {

  // If the number of arguments is less than 3, the target is always the
  // evented object itself.
  const isTargetingSelf = args.length < 3 || args[0] === self || args[0] === self.eventBusEl_;
  let target;
  let type;
  let listener;

  if (isTargetingSelf) {
    target = self.eventBusEl_;

    // Deal with cases where we got 3 arguments, but we are still listening to
    // the evented object itself.
    if (args.length >= 3) {
      args.shift();
    }

    [type, listener] = args;
  } else {
    [target, type, listener] = args;
  }

  validateTarget(target, self, fnName);
  validateEventType(type, self, fnName);
  validateListener(listener, self, fnName);

  listener = Fn.bind_(self, listener);

  return {isTargetingSelf, target, type, listener};
};

/**
 * Adds the listener to the event type(s) on the target, normalizing for
 * the type of target.
 *
 * @private
 * @param  {Element|Object} target
 *         A DOM node or evented object.
 *
 * @param  {string} method
 *         The event binding method to use ("on" or "one").
 *
 * @param  {string|Array} type
 *         One or more event type(s).
 *
 * @param  {Function} listener
 *         A listener function.
 */
const listen = (target, method, type, listener) => {
  validateTarget(target, target, method);

  if (target.nodeName) {
    Events[method](target, type, listener);
  } else {
    target[method](type, listener);
  }
};

/**
 * Contains methods that provide event capabilities to an object which is passed
 * to {@link module:evented|evented}.
 *
 * @mixin EventedMixin
 */
const EventedMixin = {

  /**
   * Add a listener to an event (or events) on this object or another evented
   * object.
   *
   * @param  {string|Array|Element|Object} targetOrType
   *         If this is a string or array, it represents the event type(s)
   *         that will trigger the listener.
   *
   *         Another evented object can be passed here instead, which will
   *         cause the listener to listen for events on _that_ object.
   *
   *         In either case, the listener's `this` value will be bound to
   *         this object.
   *
   * @param  {string|Array|Function} typeOrListener
   *         If the first argument was a string or array, this should be the
   *         listener function. Otherwise, this is a string or array of event
   *         type(s).
   *
   * @param  {Function} [listener]
   *         If the first argument was another evented object, this will be
   *         the listener function.
   */
  on(...args) {
    const {isTargetingSelf, target, type, listener} = normalizeListenArgs(this, args, 'on');

    listen(target, 'on', type, listener);

    // If this object is listening to another evented object.
    if (!isTargetingSelf) {

      // If this object is disposed, remove the listener.
      const removeListenerOnDispose = () => this.off(target, type, listener);

      // Use the same function ID as the listener so we can remove it later it
      // using the ID of the original listener.
      removeListenerOnDispose.guid = listener.guid;

      // Add a listener to the target's dispose event as well. This ensures
      // that if the target is disposed BEFORE this object, we remove the
      // removal listener that was just added. Otherwise, we create a memory leak.
      const removeRemoverOnTargetDispose = () => this.off('dispose', removeListenerOnDispose);

      // Use the same function ID as the listener so we can remove it later
      // it using the ID of the original listener.
      removeRemoverOnTargetDispose.guid = listener.guid;

      listen(this, 'on', 'dispose', removeListenerOnDispose);
      listen(target, 'on', 'dispose', removeRemoverOnTargetDispose);
    }
  },

  /**
   * Add a listener to an event (or events) on this object or another evented
   * object. The listener will be called once per event and then removed.
   *
   * @param  {string|Array|Element|Object} targetOrType
   *         If this is a string or array, it represents the event type(s)
   *         that will trigger the listener.
   *
   *         Another evented object can be passed here instead, which will
   *         cause the listener to listen for events on _that_ object.
   *
   *         In either case, the listener's `this` value will be bound to
   *         this object.
   *
   * @param  {string|Array|Function} typeOrListener
   *         If the first argument was a string or array, this should be the
   *         listener function. Otherwise, this is a string or array of event
   *         type(s).
   *
   * @param  {Function} [listener]
   *         If the first argument was another evented object, this will be
   *         the listener function.
   */
  one(...args) {
    const {isTargetingSelf, target, type, listener} = normalizeListenArgs(this, args, 'one');

    // Targeting this evented object.
    if (isTargetingSelf) {
      listen(target, 'one', type, listener);

    // Targeting another evented object.
    } else {
      // TODO: This wrapper is incorrect! It should only
      //       remove the wrapper for the event type that called it.
      //       Instead all listeners are removed on the first trigger!
      //       see https://github.com/videojs/video.js/issues/5962
      const wrapper = (...largs) => {
        this.off(target, type, wrapper);
        listener.apply(null, largs);
      };

      // Use the same function ID as the listener so we can remove it later
      // it using the ID of the original listener.
      wrapper.guid = listener.guid;
      listen(target, 'one', type, wrapper);
    }
  },

  /**
   * Add a listener to an event (or events) on this object or another evented
   * object. The listener will only be called once for the first event that is triggered
   * then removed.
   *
   * @param  {string|Array|Element|Object} targetOrType
   *         If this is a string or array, it represents the event type(s)
   *         that will trigger the listener.
   *
   *         Another evented object can be passed here instead, which will
   *         cause the listener to listen for events on _that_ object.
   *
   *         In either case, the listener's `this` value will be bound to
   *         this object.
   *
   * @param  {string|Array|Function} typeOrListener
   *         If the first argument was a string or array, this should be the
   *         listener function. Otherwise, this is a string or array of event
   *         type(s).
   *
   * @param  {Function} [listener]
   *         If the first argument was another evented object, this will be
   *         the listener function.
   */
  any(...args) {
    const {isTargetingSelf, target, type, listener} = normalizeListenArgs(this, args, 'any');

    // Targeting this evented object.
    if (isTargetingSelf) {
      listen(target, 'any', type, listener);

    // Targeting another evented object.
    } else {
      const wrapper = (...largs) => {
        this.off(target, type, wrapper);
        listener.apply(null, largs);
      };

      // Use the same function ID as the listener so we can remove it later
      // it using the ID of the original listener.
      wrapper.guid = listener.guid;
      listen(target, 'any', type, wrapper);
    }
  },

  /**
   * Removes listener(s) from event(s) on an evented object.
   *
   * @param  {string|Array|Element|Object} [targetOrType]
   *         If this is a string or array, it represents the event type(s).
   *
   *         Another evented object can be passed here instead, in which case
   *         ALL 3 arguments are _required_.
   *
   * @param  {string|Array|Function} [typeOrListener]
   *         If the first argument was a string or array, this may be the
   *         listener function. Otherwise, this is a string or array of event
   *         type(s).
   *
   * @param  {Function} [listener]
   *         If the first argument was another evented object, this will be
   *         the listener function; otherwise, _all_ listeners bound to the
   *         event type(s) will be removed.
   */
  off(targetOrType, typeOrListener, listener) {

    // Targeting this evented object.
    if (!targetOrType || isValidEventType(targetOrType)) {
      Events.off(this.eventBusEl_, targetOrType, typeOrListener);

    // Targeting another evented object.
    } else {
      const target = targetOrType;
      const type = typeOrListener;

      // Fail fast and in a meaningful way!
      validateTarget(target, this, 'off');
      validateEventType(type, this, 'off');
      validateListener(listener, this, 'off');

      // Ensure there's at least a guid, even if the function hasn't been used
      listener = Fn.bind_(this, listener);

      // Remove the dispose listener on this evented object, which was given
      // the same guid as the event listener in on().
      this.off('dispose', listener);

      if (target.nodeName) {
        Events.off(target, type, listener);
        Events.off(target, 'dispose', listener);
      } else if (isEvented(target)) {
        target.off(type, listener);
        target.off('dispose', listener);
      }
    }
  },

  /**
   * Fire an event on this evented object, causing its listeners to be called.
   *
   * @param   {string|Object} event
   *          An event type or an object with a type property.
   *
   * @param   {Object} [hash]
   *          An additional object to pass along to listeners.
   *
   * @return {boolean}
   *          Whether or not the default behavior was prevented.
   */
  trigger(event, hash) {
    validateTarget(this.eventBusEl_, this, 'trigger');

    const type = event && typeof event !== 'string' ? event.type : event;

    if (!isValidEventType(type)) {
      throw new Error(`Invalid event type for ${objName(this)}#trigger; ` +
        'must be a non-empty string or object with a type key that has a non-empty value.');
    }
    return Events.trigger(this.eventBusEl_, event, hash);
  }
};

/**
 * Applies {@link module:evented~EventedMixin|EventedMixin} to a target object.
 *
 * @param  {Object} target
 *         The object to which to add event methods.
 *
 * @param  {Object} [options={}]
 *         Options for customizing the mixin behavior.
 *
 * @param  {string} [options.eventBusKey]
 *         By default, adds a `eventBusEl_` DOM element to the target object,
 *         which is used as an event bus. If the target object already has a
 *         DOM element that should be used, pass its key here.
 *
 * @return {Object}
 *         The target object.
 */
function evented(target, options = {}) {
  const {eventBusKey} = options;

  // Set or create the eventBusEl_.
  if (eventBusKey) {
    if (!target[eventBusKey].nodeName) {
      throw new Error(`The eventBusKey "${eventBusKey}" does not refer to an element.`);
    }
    target.eventBusEl_ = target[eventBusKey];
  } else {
    target.eventBusEl_ = Dom.createEl('span', {className: 'vjs-event-bus'});
  }

  Object.assign(target, EventedMixin);

  if (target.eventedCallbacks) {
    target.eventedCallbacks.forEach((callback) => {
      callback();
    });
  }

  // When any evented object is disposed, it removes all its listeners.
  target.on('dispose', () => {
    target.off();
    [target, target.el_, target.eventBusEl_].forEach(function(val) {
      if (val && DomData.has(val)) {
        DomData.delete(val);
      }
    });
    window.setTimeout(() => {
      target.eventBusEl_ = null;
    }, 0);
  });

  return target;
}

export default evented;
export {isEvented};
export {addEventedCallback};