toolshed-8.x-1.x-dev/assets/EventListener.es6.js

assets/EventListener.es6.js
(({ Toolshed: ts, debounce }) => {
  /**
   * Creates a queue of callable functions that can get managed and triggered
   * together. The main purpose of this for queuing event listeners or
   * registering a series of callbacks.
   *
   * CallList are orderable, and can be used to insert listeners around a
   * specified reference point (before or after another callable).
   */
  class CallList {
    /**
     * Ensure that a listener is a valid handler for the event used by this
     * EventListener. This test is for checking the listener before adding it
     * to the list of active listeners for this event.
     *
     * @param {Array|function} callable
     *   The object to test if it is valid for handling this event.
     *
     * @return {bool}
     *   Boolean to indicate if this listener is valid for handling this event.
     *   _true_ IFF this listener can be added and used with this event object.
     */
    static isCallable(callable) {
      let obj = null;
      let func = callable;

      if (callable instanceof Array) {
        [obj, func] = callable;
      }

      return (typeof func === 'function') && (!obj || obj instanceof Object);
    }

    /**
     * Execute a callable item, with the parameters passed to the underlying
     * function or method. A callable is either a function or an array
     * (containing an object to use as the "this" and a method to execute).
     *
     * @param {function|Array} callable
     *   A callable is either a function that can be called directly or is an
     *   array that contains an object (used for "this") and a method to call.
     * @param {*[]} ...args
     *   Additional arguments passed with the called item.
     *
     * @return {*|null}
     *   The result of the callabable
     */
    static callItem(callable, ...args) {
      if (callable instanceof Array) {
        const [obj, func] = callable;
        return func.apply(obj, args);
      }

      if (typeof callable === 'function') {
        return callable(...args);
      }

      throw new Error('Unable to execute callable method.');
    }

    /**
     * Create a new instance of a callable list of items.
     */
    constructor() {
      this.list = [];
    }

    /**
     * Get the current size of the callable list.
     *
     * @return {int}
     *   The current size of the callables list array.
     */
    size() {
      return this.list.length;
    }

    /**
     * Make a call to a list of callables.
     *
     * @param {Event} param
     *   argument object to pass to each of the callables as they get
     *   called respectively.
     */
    call(param, ...args) {
      this.list.forEach((cb) => CallList.callItem(cb, param, ...args));
    }

    /**
     * If there is a valid atPos, place the callable at this position,
     * otherwise, just add it to the end of the list. This allows some
     * flexibility to place callabbles at the start of the list, or
     * before other callables.
     *
     * @param {Array|function} callable
     *   A callable object to add to the list.
     * @param {int} atPos
     *   Index to add the callable at. This allows callables to be run in
     *   a different order than they maybe registered in.
     */
    add(callable, atPos) {
      if (!CallList.isCallable(callable)) {
        throw new Error('Trying to add new callback, but it is not a valid callable.');
      }

      // Ensure that all existing references to this event are removed.
      // Prevents the event from being called more than once unintentionally.
      this.remove(callable);

      if (atPos !== null && atPos >= 0) this.list.splice(atPos - 1, 0, callable);
      else this.list.push(callable);
    }

    /**
     * Remove the specified callable from the list of callables.
     *
     * @param {Array|function} callable
     *  A listener object that requests to get removed.
     */
    remove(callable) {
      let pos = this.indexOf(callable);

      while (pos >= 0) {
        this.list.splice(pos, 1);
        pos = this.indexOf(callable, pos);
      }
    }

    /**
     * Look for an a matching listener in the objects listener registry.
     * Listeners can be stored as either function references, or arrays. If
     * an array, the first item in the array is the object calling context,
     * and the second parameter is function to call.
     *
     * Matches can be found by function, array (object and function) or just
     * by matching the object contexts.
     *
     * @param {Array|Object|function} needle
     *   The matching listener to locate in the listeners array. If the param
     *   is an array, look for the matching object and function. If an object
     *   is passed in, find the first occurance of the object as the object
     *   context in the arrays. If a function, just search for the function.
     * @param {int} start
     *   The starting position in the listener array to search for the callback.
     *
     * @return {int}
     *   The index where the matching listener was found. If a matching listener
     *   was not found, return -1 to indicate no match is available.
     */
    indexOf(needle, start = 0) {
      if (typeof needle === 'function') {
        // For functions, matching is direct and straightforward.
        return this.list.indexOf(needle, start);
      }

      const [obj, func] = (needle instanceof Array) ? needle : [null, needle];
      for (; start < this.list.length; ++start) {
        const item = this.list[start];
        const [context, callback] = (item instanceof Array) ? item : [null, item];

        if (obj === context && (!func || func === callback)) {
          return start;
        }
      }
      return -1;
    }
  }

  /**
   * Attach DOM and element events, which allow global registration and utilize
   * a CallList for event calling and management. These event listeners
   * have options for handling debounce, callables (@see CallList) and
   * auto registration (only adding the event listener to the DOM when listener
   * is added).
   *
   * Toolshed.EventListener.{eventName} namespace. Some of the events in that
   * namespace may have customized event callback. An example of this is defined
   * in ./screen-events.es6.js which are used by the Toolshed.Dock.
   */
  ts.EventListener = class {
    /**
     * Constructor for creating of event listeners.
     *
     * @param {DOMElement} elem
     *   DOM element that will be the target of the event.
     * @param {string} eventName
     *   The event.
     * @param {null|object} options
     *   method:
     *     Name of the method to call on all listeners (special cases). Will call
     *     the default "on[this.eventName]" method if left blank.
     *   useCapture:
     *     Use capture instead of bubbling for event propagation.
     *   passive:
     *     Event handlers will not call preventDefault() which can enable browser
     *     optimatization that no longer need to wait for all handlers to complete
     *     before triggering other events like scrolling.
     *   debounce:
     *     Determine if the event only triggers using debounce handling. This means
     *     that events will only fire off after a short delay.
     *
     *     If null or FALSE, no debounce will be used, and the event registered
     *     fires off as soon as the event is raised.
     *
     *     If TRUE then use the default debounce delay. If an integer, than use the
     *     value as the delay in milliseconds.
     */
    constructor(elem, eventName, options) {
      options = options || {}; // options can be left blank.

      this.elem = elem;
      this.event = eventName;
      this.autoListen = options.autoListen || false;
      this.listeners = new CallList();

      // Check and properly organize the event options to be used later.
      if (options.debounce) {
        this.debounce = (typeof options.debounce === 'boolean') ? 100 : options.debounce;
      }

      // Allow for addEventListener options as described here
      // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
      // I am also employing the https://github.com/WICG/EventListenerOptions
      // as a polyfill, but support will not be available for IE8 and earlier.
      this.eventOpts = {
        capture: options.capture || false,
        passive: options.passive || false,
      };
    }

    /**
     * Trigger the event for all the registered listeners. Custom
     * EventListeners are most likely to override this function in order
     * to create implement special functionality, triggered by events.
     *
     * @param {Object} event
     *   The event object that was generated and passed to the event handler.
     */
    _run(event, ...args) {
      this.listners.call(event, ...args);
    }

    /**
     * Trigger the event manaully.
     *
     * @param {Event|null} event
     *   Event data to use with this event.
     *
     * @return {Drupal.Toolshed.EventListener}
     *   Return this instance of this EventListener for the purpose of chaining.
     */
    trigger(event, ...args) {
      this._run(event || new Event(this.event), args);
      return this;
    }

    /**
     * Register the event, and keep track of the callback so it can be removed
     * later if we need to disable / remove the listener at a later time.
     *
     * @return {Drupal.Toolshed.EventListener}
     *   Return this instance of this EventListener for the purpose of chaining.
     */
    listen() {
      if (!this.callback && (!this.autoListen || this.listeners.size())) {
        this.callback = (this.debounce && this.debounce > 0 && debounce)
          ? debounce(this._run.bind(this), this.debounce) : this._run.bind(this);

        this.elem.addEventListener(this.event, this.callback, this.eventOpts);
      }
      return this;
    }

    /**
     * Stop listening for this event, and unregister from any event listeners.
     *
     * @return {Drupal.Toolshed.EventListener}
     *   Return this instance of this EventListener for the purpose of chaining.
     */
    ignore() {
      if (this.callback) {
        this.elem.removeEventListener(this.event, this.callback);
        delete this.callback;
      }
      return this;
    }

    /**
     * If there is a valid atPos, place the listener at this position,
     * otherwise, just add it to the end of the list. This allows some
     * flexibility to place listeners at the start of the list, or
     * before other listeners.
     *
     * @param {Object} listener
     *   A listener object that contains the a method 'on' + [this.eventName].
     * @param {int} atPos
     *   Index to add the listener at. This allows listeners to be run in
     *   a different order than they maybe registered in.
     *
     * @return {Drupal.Toolshed.EventListener}
     *   Return this instance of this EventListener for the purpose of chaining.
     */
    add(listener, atPos) {
      this.listeners.add(listener, atPos);

      if (this.autoListen) this.listen();
      return this;
    }

    /**
     * Add a new listener before an existing listener already in the list.
     * If [before] is null, then insert at the start of the list.
     *
     * @param {Array|function} listener
     *   A listener object that contains the a method 'on' + [this.eventName].
     * @param {Array|function} before
     *   Listener object that is used to position the new listener.
     *
     * @return {Drupal.Toolshed.EventListener}
     *   Return this instance of this EventListener for the purpose of chaining.
     */
    addBefore(listener, before) {
      const pos = (before) ? this.listeners.indexOf(before) : 0;
      return this.add(listener, pos < 0 ? 0 : pos);
    }

    /**
     * Add a new listener after an existing listener already in the list.
     * If [after] is null, then insert at the end of the list.
     *
     * @param {Array|function} listener
     *  A listener object that represents a callable.
     * @param {Array|function} after
     *  Listener object that is used to position the new listener.
     *
     * @return {Drupal.Toolshed.EventListener}
     *   Return this instance of this EventListener for the purpose of chaining.
     */
    addAfter(listener, after) {
      let pos = null;

      if (after) {
        pos = this.listeners.indexOf(after);
        pos = pos >= 0 ? pos + 1 : -1;
      }
      return this.add(listener, pos);
    }

    /**
     * Remove the specified listener from the list of event listeners.
     * This assume there should only be one entry pert callback.
     *
     * @param {Array|function} listener
     *  A listener object that requests to get removed.
     *
     * @return {Drupal.Toolshed.EventListener}
     *   Return this instance of this EventListener for the purpose of chaining.
     */
    remove(listener) {
      this.listeners.remove(listener);

      // If there are no listeners and the autoListen option is on, turn off
      // listening. This prevents the event from being called for no reason.
      if (this.autoListen && !this.listeners.size()) this.ignore();
      return this;
    }

    /**
     * Clean-up events and data.
     */
    destroy() {
      this.ignore();
    }
  };

  /**
   * Event listener for media query listeners.
   */
  ts.MediaQueryListener = class {
    /**
     * Constructs a new Media Query listener instance.
     *
     * @param {Object[]} breakpoints
     *   An array of breakpoints in the order they should be checked. Each
     *   breakpoint object is expected to have an `mq`, `inverted` and `event`
     *   property which help determine what event to call when a Media Query
     *   listener triggers.
     */
    constructor(breakpoints) {
      this.mode = null;
      this.curBp = null;
      this.bps = new Map();
      this.aliases = new Map();

      breakpoints.forEach((bp) => {
        const mql = window.matchMedia(bp.mediaQuery);

        if (bp.event && !this.aliases.has(bp.event)) {
          this.aliases.set(bp.event, {
            on: new CallList(),
            off: new CallList(),
          });
        }

        this.bps.set(bp.id, {
          query: mql,
          mode: bp.event || null,
          inverted: bp.inverted || false,
        });
      });
    }

    /**
     * Alter the current breakpoint, and trigger the related events.
     *
     * @param {string} newBp
     *   The ID of the breakpoint to trigger.
     */
    _changeMode(newBp) {
      let newMode = null;

      // If the mode changed, trigger the appropriate action.
      if (newBp === this.curBp) return;

      if (this.curBp) {
        const offList = this.bps.get(this.curBp);
        if (offList && offList.off) offList.off.call(this.curBp, 'off');
      }

      this.curBp = newBp;
      if (newBp) {
        const onList = this.bps.get(newBp);

        if (onList) {
          newMode = onList.mode;
          if (onList.on) onList.on.call(this.curBp, 'on');
        }
      }

      if (newMode !== this.mode) {
        if (this.mode) {
          const offMode = this.aliases.get(this.mode);
          if (offMode) offMode.off.call(this.mode, 'off');
        }

        this.mode = newMode;
        if (newMode) {
          const onMode = this.aliases.get(newMode);
          if (onMode) onMode.on.call(this.mode, 'on');
        }
      }
    }

    /**
     * Check the registered breakpoints in order to see which one is active.
     *
     * @return {string|null}
     *   The query mapped event if a matching breakpoint is found, otherwise
     *   return null to mean no event.
     */
    checkBreakpoints() {
      const bps = Array.from(this.bps.entries());

      for (let i = 0; i < bps.length; ++i) {
        const [id, bp] = bps[i];

        if (!bp.query.matches !== !bp.inverted) {
          return id;
        }
      }

      return null;
    }

    /**
     * Add a new listener to a breakpoint or mode.
     *
     * @param {string} bpId
     *   The ID of the breakpoint to watch for, or the media query event to
     *   attach the listener callable to.
     * @param {[type]} listener
     *   The callable callback to get called when the breakpoint matches the
     *   action specified (either on or off).
     * @param {string} action
     *   Either 'on' or 'off' to indicated of the callback should be called
     *   when the media query is active or deactivated respectively.
     *
     * @return {Object}
     *   The current instance of the MediaQueryListener, and the "this" object.
     */
    add(bpId, listener, action = 'on') {
      if (action === 'on' || action === 'off') {
        let target;
        let curState;

        if (this.aliases.has(bpId)) {
          target = this.aliases.get(bpId);
          curState = this.mode;
        }
        else if (this.bps.has(bpId)) {
          target = this.bps.get(bpId);
          curState = this.curBp;

          if (!target[action]) target[action] = new CallList();
        }
        else {
          throw new Error(`Error adding ${bpId} with action ${action}`);
        }

        target[action].add(listener);
        // Trigger 'on' event when listener is added if current state matches
        // screen sizes targeted by added listener.
        if (curState === bpId && action === 'on') {
          CallList.callItem(listener, bpId, 'on');
        }
        // Trigger 'off' event when listener is added if current state does not
        // match screen sizes targeted by added listener.
        else if (curState !== bpId && action === 'off') {
          CallList.callItem(listener, bpId, 'off');
        }
      }

      return this;
    }

    /**
     * Listen for changes of the registered breakpoints.
     */
    listen() {
      let current;

      this.bps.forEach((bp, id) => {
        if (!bp.callback) {
          bp.callback = ((mql) => {
            const mode = (!mql.matches !== !bp.inverted) ? id : this.checkBreakpoints();
            this._changeMode(mode);
          });

          bp.query.addListener(bp.callback);
        }

        // Stop updating this after the first breakpoint to trigger.
        if (!current && (!bp.query.matches !== !bp.inverted)) {
          current = id;
        }
      });

      this._changeMode(current);
    }

    /**
     * Unregister all media query listeners, and ignore all breakpoint events.
     */
    ignore() {
      this.bps.forEach((id, bp) => {
        if (bp.callback) {
          bp.query.removeListener(bp.callback);
          delete bp.callback;
        }
      });
    }
  };
})(Drupal);

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc