Home Manual ReferenceGraphs Source Repository

typhonjs-core-backbone-events/src/Events.js

'use strict';

import _ from 'underscore';

// Private / internal methods ---------------------------------------------------------------------------------------

/**
 * Regular expression used to split event strings.
 * @type {RegExp}
 */
const s_EVENT_SPLITTER = /\s+/;

/**
 * Iterates over the standard `event, callback` (as well as the fancy multiple space-separated events `"change blur",
 * callback` and jQuery-style event maps `{event: callback}`).
 *
 * @param {function} iteratee    - Event operation to invoke.
 * @param {Object.<{callback: function, context: object, ctx: object, listening:{}}>} events - Events object
 * @param {string|object} name   - A single event name, compound event names, or a hash of event names.
 * @param {function} callback    - Event callback function
 * @param {object}   opts        - Optional parameters
 * @returns {*}
 */
const s_EVENTS_API = (iteratee, events, name, callback, opts) =>
{
   let i = 0, names;
   if (name && typeof name === 'object')
   {
      // Handle event maps.
      if (callback !== void 0 && 'context' in opts && opts.context === void 0) { opts.context = callback; }
      for (names = _.keys(name); i < names.length; i++)
      {
         events = s_EVENTS_API(iteratee, events, names[i], name[names[i]], opts);
      }
   }
   else if (name && s_EVENT_SPLITTER.test(name))
   {
      // Handle space separated event names by delegating them individually.
      for (names = name.split(s_EVENT_SPLITTER); i < names.length; i++)
      {
         events = iteratee(events, names[i], callback, opts);
      }
   }
   else
   {
      // Finally, standard events.
      events = iteratee(events, name, callback, opts);
   }
   return events;
};

/**
 * Guard the `listening` argument from the public API.
 *
 * @param {Events}   obj      - The Events instance
 * @param {string}   name     - Event name
 * @param {function} callback - Event callback
 * @param {object}   context  - Event context
 * @param {Object.<{obj: object, objId: string, id: string, listeningTo: object, count: number}>} listening -
 *                              Listening object
 * @returns {*}
 */
const s_INTERNAL_ON = (obj, name, callback, context, listening) =>
{
   obj._events = s_EVENTS_API(s_ON_API, obj._events || {}, name, callback, { context, ctx: obj, listening });

   if (listening)
   {
      const listeners = obj._listeners || (obj._listeners = {});
      listeners[listening.id] = listening;
   }

   return obj;
};

/**
 * The reducing API that removes a callback from the `events` object.
 *
 * @param {Object.<{callback: function, context: object, ctx: object, listening:{}}>} events - Events object
 * @param {string}   name     - Event name
 * @param {function} callback - Event callback
 * @param {object}   options  - Optional parameters
 * @returns {*}
 */
const s_OFF_API = (events, name, callback, options) =>
{
   if (!events) { return; }

   let i = 0, listening;
   const context = options.context, listeners = options.listeners;

   // Delete all events listeners and "drop" events.
   if (!name && !callback && !context)
   {
      const ids = _.keys(listeners);
      for (; i < ids.length; i++)
      {
         listening = listeners[ids[i]];
         delete listeners[listening.id];
         delete listening.listeningTo[listening.objId];
      }
      return;
   }

   const names = name ? [name] : _.keys(events);
   for (; i < names.length; i++)
   {
      name = names[i];
      const handlers = events[name];

      // Bail out if there are no events stored.
      /* istanbul ignore if */
      if (!handlers) { break; }

      // Replace events if there are any remaining.  Otherwise, clean up.
      const remaining = [];
      for (let j = 0; j < handlers.length; j++)
      {
         const handler = handlers[j];
         if (
          callback && callback !== handler.callback &&
          callback !== handler.callback._callback ||
          context && context !== handler.context
         )
         {
            remaining.push(handler);
         }
         else
         {
            listening = handler.listening;
            if (listening && --listening.count === 0)
            {
               delete listeners[listening.id];
               delete listening.listeningTo[listening.objId];
            }
         }
      }

      // Update tail event if the list has any events.  Otherwise, clean up.
      if (remaining.length)
      {
         events[name] = remaining;
      }
      else
      {
         delete events[name];
      }
   }
   if (_.size(events)) { return events; }
};

/**
 * The reducing API that adds a callback to the `events` object.
 *
 * @param {Object.<{callback: function, context: object, ctx: object, listening:{}}>} events - Events object
 * @param {string}   name     - Event name
 * @param {function} callback - Event callback
 * @param {object}   options  - Optional parameters
 * @returns {*}
 */
const s_ON_API = (events, name, callback, options) =>
{
   if (callback)
   {
      const handlers = events[name] || (events[name] = []);
      const context = options.context, ctx = options.ctx, listening = options.listening;

      if (listening) { listening.count++; }

      handlers.push({ callback, context, ctx: context || ctx, listening });
   }
   return events;
};

/**
 * Reduces the event callbacks into a map of `{event: onceWrapper}`. `offer` unbinds the `onceWrapper` after
 * it has been called.
 *
 * @param {Object.<{callback: function, context: object, ctx: object, listening:{}}>} map - Events object
 * @param {string}   name     - Event name
 * @param {function} callback - Event callback
 * @param {function} offer    - Function to invoke after event has been triggered once; `off()`
 * @returns {*}
 */
const s_ONCE_MAP = function(map, name, callback, offer)
{
   if (callback)
   {
      const once = map[name] = _.once(function()
      {
         offer(name, once);
         callback.apply(this, arguments);
      });
      once._callback = callback;
   }
   return map;
};

/**
 * Handles triggering the appropriate event callbacks.
 *
 * @param {Object.<{callback: function, context: object, ctx: object, listening:{}}>} objEvents - Events object
 * @param {string}   name  - Event name
 * @param {function} cb    - Event callback
 * @param {Array<*>} args  - Event arguments
 * @returns {*}
 */
const s_TRIGGER_API = (objEvents, name, cb, args) =>
{
   if (objEvents)
   {
      const events = objEvents[name];
      let allEvents = objEvents.all;
      if (events && allEvents) { allEvents = allEvents.slice(); }
      if (events) { s_TRIGGER_EVENTS(events, args); }
      if (allEvents) { s_TRIGGER_EVENTS(allEvents, [name].concat(args)); }
   }
   return objEvents;
};

/**
 * A difficult-to-believe, but optimized internal dispatch function for triggering events. Tries to keep the usual
 * cases speedy (most internal Backbone events have 3 arguments).
 *
 * @param {Object.<{callback: function, context: object, ctx: object, listening:{}}>}  events - events array
 * @param {Array<*>} args - event argument array
 */
const s_TRIGGER_EVENTS = (events, args) =>
{
   let ev, i = -1;
   const a1 = args[0], a2 = args[1], a3 = args[2], l = events.length;

   switch (args.length)
   {
      case 0:
         while (++i < l) { (ev = events[i]).callback.call(ev.ctx); }
         return;
      case 1:
         while (++i < l) { (ev = events[i]).callback.call(ev.ctx, a1); }
         return;
      case 2:
         while (++i < l) { (ev = events[i]).callback.call(ev.ctx, a1, a2); }
         return;
      case 3:
         while (++i < l) { (ev = events[i]).callback.call(ev.ctx, a1, a2, a3); }
         return;
      default:
         while (++i < l) { (ev = events[i]).callback.apply(ev.ctx, args); }
         return;
   }
};

/**
 * Backbone.Events - Provides the ability to bind and trigger custom named events. (http://backbonejs.org/#Events)
 * ---------------
 *
 * An important consideration of Backbone-ES6 is that Events are no longer an object literal, but a full blown ES6
 * class. This is the biggest potential breaking change for Backbone-ES6 when compared to the original Backbone.
 *
 * Previously Events could be mixed in to any object. This is no longer possible with Backbone-ES6 when working from
 * source or the bundled versions. It should be noted that Events is also no longer mixed into Backbone itself, so
 * Backbone is not a Global events instance.
 *
 * Catalog of Events:
 * Here's the complete list of built-in Backbone events, with arguments. You're also free to trigger your own events on
 * Models, Collections and Views as you see fit.
 *
 * "add" (model, collection, options) — when a model is added to a collection.
 * "remove" (model, collection, options) — when a model is removed from a collection.
 * "update" (collection, options) — single event triggered after any number of models have been added or removed from a
 * collection.
 * "reset" (collection, options) — when the collection's entire contents have been replaced.
 * "sort" (collection, options) — when the collection has been re-sorted.
 * "change" (model, options) — when a model's attributes have changed.
 * "change:[attribute]" (model, value, options) — when a specific attribute has been updated.
 * "destroy" (model, collection, options) — when a model is destroyed.
 * "request" (model_or_collection, xhr, options) — when a model or collection has started a request to the server.
 * "sync" (model_or_collection, resp, options) — when a model or collection has been successfully synced with the
 * server.
 * "error" (model_or_collection, resp, options) — when a model's or collection's request to the server has failed.
 * "invalid" (model, error, options) — when a model's validation fails on the client.
 * "route:[name]" (params) — Fired by the router when a specific route is matched.
 * "route" (route, params) — Fired by the router when any route has been matched.
 * "route" (router, route, params) — Fired by history when any route has been matched.
 * "all" — this special event fires for any triggered event, passing the event name as the first argument.
 *
 * Generally speaking, when calling a function that emits an event (model.set, collection.add, and so on...), if you'd
 * like to prevent the event from being triggered, you may pass {silent: true} as an option. Note that this is rarely,
 * perhaps even never, a good idea. Passing through a specific flag in the options for your event callback to look at,
 * and choose to ignore, will usually work out better.
 *
 * @example
 * This no longer works:
 *
 * let object = {};
 * _.extend(object, Backbone.Events);
 * object.on('expand', function(){ alert('expanded'); });
 * object.trigger('expand');
 *
 * One must now use ES6 extends syntax for Backbone.Events when inheriting events functionality:
 * import Backbone from 'backbone';
 *
 * class MyClass extends Backbone.Events {}
 *
 * @example
 * A nice ES6 pattern for creating a named events instance is the following:
 *
 * import Backbone from 'backbone';
 *
 * export default new Backbone.Events();
 *
 * This module / Events instance can then be imported by full path or if consuming in a modular runtime by creating
 * a mapped path to it.
 */
export default class Events
{
   /** */
   constructor() {}

   /**
    * Delegates to `on`.
    *
    * @returns {*}
    */
   bind()
   {
      return this.on(...arguments);
   }

   /**
    * Tell an object to listen to a particular event on an other object. The advantage of using this form, instead of
    * other.on(event, callback, object), is that listenTo allows the object to keep track of the events, and they can
    * be removed all at once later on. The callback will always be called with object as context.
    *
    * @example
    * view.listenTo(model, 'change', view.render);
    *
    * @see http://backbonejs.org/#Events-listenTo
    *
    * @param {object}   obj      - Event context
    * @param {string}   name     - Event name(s)
    * @param {function} callback - Event callback function
    * @returns {Events}
    */
   listenTo(obj, name, callback)
   {
      if (!obj) { return this; }
      const id = obj._listenId || (obj._listenId = _.uniqueId('l'));
      const listeningTo = this._listeningTo || (this._listeningTo = {});
      let listening = listeningTo[id];

      // This object is not listening to any other events on `obj` yet.
      // Setup the necessary references to track the listening callbacks.
      if (!listening)
      {
         const thisId = this._listenId || (this._listenId = _.uniqueId('l'));
         listening = listeningTo[id] = { obj, objId: id, id: thisId, listeningTo, count: 0 };
      }

      // Bind callbacks on obj, and keep track of them on listening.
      s_INTERNAL_ON(obj, name, callback, this, listening);
      return this;
   }

   /**
    * Just like `listenTo`, but causes the bound callback to fire only once before being removed.
    *
    * @see http://backbonejs.org/#Events-listenToOnce
    *
    * @param {object}   obj      - Event context
    * @param {string}   name     - Event name(s)
    * @param {function} callback - Event callback function
    * @returns {Events}
    */
   listenToOnce(obj, name, callback)
   {
      // Map the event into a `{event: once}` object.
      const events = s_EVENTS_API(s_ONCE_MAP, {}, name, callback, _.bind(this.stopListening, this, obj));
      return this.listenTo(obj, events, void 0);
   }

   /**
    * Remove a previously-bound callback function from an object. If no context is specified, all of the versions of
    * the callback with different contexts will be removed. If no callback is specified, all callbacks for the event
    * will be removed. If no event is specified, callbacks for all events will be removed.
    *
    * Note that calling model.off(), for example, will indeed remove all events on the model — including events that
    * Backbone uses for internal bookkeeping.
    *
    * @example
    * // Removes just the `onChange` callback.
    * object.off("change", onChange);
    *
    * // Removes all "change" callbacks.
    * object.off("change");
    *
    * // Removes the `onChange` callback for all events.
    * object.off(null, onChange);
    *
    * // Removes all callbacks for `context` for all events.
    * object.off(null, null, context);
    *
    * // Removes all callbacks on `object`.
    * object.off();
    *
    * @see http://backbonejs.org/#Events-off
    *
    * @param {string}   name     - Event name(s)
    * @param {function} callback - Event callback function
    * @param {object}   context  - Event context
    * @returns {Events}
    */
   off(name, callback, context)
   {
      /* istanbul ignore if */
      if (!this._events) { return this; }
      this._events = s_EVENTS_API(s_OFF_API, this._events, name, callback, { context, listeners: this._listeners });
      return this;
   }

   /**
    * Bind a callback function to an object. The callback will be invoked whenever the event is fired. If you have a
    * large number of different events on a page, the convention is to use colons to namespace them: "poll:start", or
    * "change:selection".
    *
    * To supply a context value for this when the callback is invoked, pass the optional last argument:
    * model.on('change', this.render, this) or model.on({change: this.render}, this).
    *
    * @example
    * The event string may also be a space-delimited list of several events...
    * book.on("change:title change:author", ...);
    *
    * @example
    * Callbacks bound to the special "all" event will be triggered when any event occurs, and are passed the name of
    * the event as the first argument. For example, to proxy all events from one object to another:
    * proxy.on("all", function(eventName) {
    *    object.trigger(eventName);
    * });
    *
    * @example
    * All Backbone event methods also support an event map syntax, as an alternative to positional arguments:
    * book.on({
    *    "change:author": authorPane.update,
    *    "change:title change:subtitle": titleView.update,
    *    "destroy": bookView.remove
    * });
    *
    * @see http://backbonejs.org/#Events-on
    *
    * @param {string}   name     - Event name(s)
    * @param {function} callback - Event callback function
    * @param {object}   context  - Event context
    * @returns {*}
    */
   on(name, callback, context)
   {
      return s_INTERNAL_ON(this, name, callback, context, void 0);
   }

   /**
    * Just like `on`, but causes the bound callback to fire only once before being removed. Handy for saying "the next
    * time that X happens, do this". When multiple events are passed in using the space separated syntax, the event
    * will fire once for every event you passed in, not once for a combination of all events
    *
    * @see http://backbonejs.org/#Events-once
    *
    * @param {string}   name     - Event name(s)
    * @param {function} callback - Event callback function
    * @param {object}   context  - Event context
    * @returns {*}
    */
   once(name, callback, context)
   {
      // Map the event into a `{event: once}` object.
      const events = s_EVENTS_API(s_ONCE_MAP, {}, name, callback, _.bind(this.off, this));
      return this.on(events, void 0, context);
   }

   /**
    * Tell an object to stop listening to events. Either call stopListening with no arguments to have the object remove
    * all of its registered callbacks ... or be more precise by telling it to remove just the events it's listening to
    * on a specific object, or a specific event, or just a specific callback.
    *
    * @example
    * view.stopListening();
    *
    * view.stopListening(model);
    *
    * @see http://backbonejs.org/#Events-stopListening
    *
    * @param {object}   obj      - Event context
    * @param {string}   name     - Event name(s)
    * @param {function} callback - Event callback function
    * @returns {Events}
    */
   stopListening(obj, name, callback)
   {
      const listeningTo = this._listeningTo;
      if (!listeningTo) { return this; }

      const ids = obj ? [obj._listenId] : _.keys(listeningTo);

      for (let i = 0; i < ids.length; i++)
      {
         const listening = listeningTo[ids[i]];

         // If listening doesn't exist, this object is not currently listening to obj. Break out early.
         if (!listening) { break; }

         listening.obj.off(name, callback, this);
      }
      if (_.isEmpty(listeningTo)) { this._listeningTo = void 0; }

      return this;
   }

   /**
    * Trigger callbacks for the given event, or space-delimited list of events. Subsequent arguments to trigger will be
    * passed along to the event callbacks.
    *
    * @see http://backbonejs.org/#Events-trigger
    *
    * @param {string}   name  - Event name(s)
    * @returns {Events}
    */
   trigger(name)
   {
      /* istanbul ignore if */
      if (!this._events) { return this; }

      const length = Math.max(0, arguments.length - 1);
      const args = new Array(length);

      for (let i = 0; i < length; i++) { args[i] = arguments[i + 1]; }

      s_EVENTS_API(s_TRIGGER_API, this._events, name, void 0, args);

      return this;
   }

   /**
    * Delegates to `off`.
    *
    * @returns {*}
    */
   unbind()
   {
      return this.off(...arguments);
   }
}