core/src/Region.js

'use strict';

const State = require('./State.js');
const {
  CANCEL_EVENTS,
  KEYBOARD_EVENTS,
  MOUSE_EVENTS,
  POINTER_EVENTS,
  TOUCH_EVENTS,

  STATE_KEY_STRINGS,

  PHASE,

  CANCEL,
  END,
  START,
} = require('./constants.js');
const {
  setDifference,
  setFilter,
} = require('./utils.js');

/**
 * Allows the user to specify the control region which will listen for user
 * input events.
 *
 * @memberof westures-core
 *
 * @param {Element} [element=null] - The element which should listen to input
 * events. If not provided, will be set to the window unless operating in
 * "headless" mode.
 * @param {object} [options]
 * @param {boolean} [options.capture=false] - Whether the region uses the
 * capture phase of input events. If false, uses the bubbling phase.
 * @param {boolean} [options.preferPointer=true] - If false, the region listens
 * to mouse/touch events instead of pointer events.
 * @param {boolean} [options.preventDefault=true] - Whether the default
 * browser functionality should be disabled. This option should most likely be
 * ignored. Here there by dragons if set to false.
 * @param {string} [options.touchAction='none'] - Value to set the CSS
 * 'touch-action' property to on elements added to the region.
 * @param {boolean} [options.headless=false] - Set to true to turn on "headless"
 * mode. This mode is intended for use outside of a browser environment. It does
 * not listen to window events, so instead you will have to send events directly
 * into the region. Pointer down/move/up events should be sent to
 * Region.arbitrate(event), cancel events should be sent to
 * Region.cancel(event), and keyboard events should be sent to
 * Region.handleKeyboardEvent(event). You do not need to supply an element to
 * the Region constructor in this mode, but you will still need to attach
 * elements to Gestures, and the events you pass in should specify event.target
 * appropriately, in order to select which gestures to run.
 */
class Region {
  constructor(element = null, options = {}) {
    options = { ...Region.DEFAULTS, ...options };
    if (element === null) {
      if (options.headless) {
        element = null;
      } else {
        element = window;
      }
    }

    /**
     * The list of relations between elements, their gestures, and the handlers.
     *
     * @type {Set.<westures-core.Gesture>}
     */
    this.gestures = new Set();

    /**
     * The list of active gestures for the current input session.
     *
     * @type {Set.<westures-core.Gesture>}
     */
    this.activeGestures = new Set();

    /**
     * The base list of potentially active gestures for the current input
     * session.
     *
     * @type {Set.<westures-core.Gesture>}
     */
    this.potentialGestures = new Set();

    /**
     * The element being bound to.
     *
     * @type {Element}
     */
    this.element = element;

    /**
     * The user-supplied options for the Region.
     *
     * @type {object}
     */
    this.options = options;

    /**
     * The internal state object for a Region.  Keeps track of inputs.
     *
     * @type {westures-core.State}
     */
    this.state = new State(this.element, options.headless);

    if (!options.headless) {
      // Begin operating immediately.
      this.activate();
    }
  }

  /**
   * Activates the region by adding event listeners for all appropriate input
   * events to the region's element.
   *
   * @private
   */
  activate() {
    /*
     * Listening to both mouse and touch comes with the difficulty that
     * preventDefault() must be called to prevent both events from iterating
     * through the system. However I have left it as an option to the end user,
     * which defaults to calling preventDefault(), in case there's a use-case I
     * haven't considered or am not aware of.
     *
     * It also may be a good idea to keep regions small in large pages.
     *
     * See:
     *  https://www.html5rocks.com/en/mobile/touchandmouse/
     *  https://developer.mozilla.org/en-US/docs/Web/API/Touch_events
     *  https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events
     */
    let eventNames = [];
    if (this.options.preferPointer && window.PointerEvent) {
      eventNames = POINTER_EVENTS;
    } else {
      eventNames = MOUSE_EVENTS.concat(TOUCH_EVENTS);
    }

    // Bind detected browser events to the region element.
    const arbitrate = this.arbitrate.bind(this);
    eventNames.forEach(eventName => {
      this.element.addEventListener(eventName, arbitrate, {
        capture: this.options.capture,
        once:    false,
        passive: false,
      });
    });

    const cancel = this.cancel.bind(this);
    CANCEL_EVENTS.forEach(eventName => {
      window.addEventListener(eventName, cancel);
    });

    const handleKeyboardEvent = this.handleKeyboardEvent.bind(this);
    KEYBOARD_EVENTS.forEach(eventName => {
      window.addEventListener(eventName, handleKeyboardEvent);
    });
  }

  /**
   * Handles a cancel event. Resets the state and the active / potential gesture
   * lists.
   *
   * @private
   * @param {Event} event - The event emitted from the window object.
   */
  cancel(event) {
    if (
      this.options.preventDefault && typeof event.preventDefault === 'function'
    ) {
      event.preventDefault();
    }
    this.state.inputs.forEach(input => {
      input.update(event);
    });
    this.activeGestures.forEach(gesture => {
      gesture.evaluateHook(CANCEL, this.state);
    });
    this.state = new State(this.element, this.options.headless);
    this.resetActiveGestures();
  }

  /**
   * Handles a keyboard event, triggering a restart of any gestures that need
   * it.
   *
   * @private
   * @param {KeyboardEvent} event - The keyboard event.
   */
  handleKeyboardEvent(event) {
    if (STATE_KEY_STRINGS.indexOf(event.key) >= 0) {
      this.state.event = event;
      const oldActiveGestures = this.activeGestures;
      this.setActiveGestures();

      setDifference(oldActiveGestures, this.activeGestures).forEach(gesture => {
        gesture.evaluateHook(END, this.state);
      });
      setDifference(this.activeGestures, oldActiveGestures).forEach(gesture => {
        gesture.evaluateHook(START, this.state);
      });
    }
  }

  /**
   * Resets the active gestures.
   *
   * @private
   */
  resetActiveGestures() {
    this.potentialGestures = new Set();
    this.activeGestures = new Set();
  }

  /**
   * Selects active gestures from the list of potentially active gestures.
   *
   * @private
   */
  setActiveGestures() {
    this.activeGestures = setFilter(this.potentialGestures, gesture => {
      return gesture.isEnabled(this.state);
    });
  }

  /**
   * Selects the potentially active gestures.
   *
   * @private
   */
  setPotentialGestures() {
    const input = this.state.inputs[0];
    this.potentialGestures = setFilter(this.gestures, gesture => {
      return input.initialElements.has(gesture.element);
    });
  }

  /**
   * Selects the gestures that are active for the current input sequence.
   *
   * @private
   * @param {Event} event - The event emitted from the window object.
   * @param {boolean} isInitial - Whether this is an initial contact.
   */
  updateActiveGestures(event, isInitial) {
    if (PHASE[event.type] === START) {
      if (isInitial) {
        this.setPotentialGestures();
      }
      this.setActiveGestures();
    }
  }

  /**
   * Evaluates whether the current input session has completed.
   *
   * @private
   * @param {Event} event - The event emitted from the window object.
   */
  pruneActiveGestures(event) {
    if (PHASE[event.type] === END) {
      if (this.state.hasNoInputs()) {
        this.resetActiveGestures();
      } else {
        this.setActiveGestures();
      }
    }
  }

  /**
   * All input events flow through this function. It makes sure that the input
   * state is maintained, determines which gestures to analyze based on the
   * initial position of the inputs, calls the relevant gesture hooks, and
   * dispatches gesture data.
   *
   * @private
   * @param {Event} event - The event emitted from the window object.
   */
  arbitrate(event) {
    const isInitial = this.state.hasNoInputs();
    this.state.updateAllInputs(event);
    this.updateActiveGestures(event, isInitial);

    if (this.activeGestures.size > 0) {
      if (
        this.options.preventDefault &&
        typeof event.preventDefault === 'function'
      ) event.preventDefault();

      this.activeGestures.forEach(gesture => {
        gesture.evaluateHook(PHASE[event.type], this.state);
      });
    }

    this.state.clearEndedInputs();
    this.pruneActiveGestures(event);
  }

  /**
   * Adds the given gesture to the region.
   *
   * @param {westures-core.Gesture} gesture - Instantiated gesture to add.
   */
  addGesture(gesture) {
    if (!this.options.headless) {
      gesture.element.style.touchAction = this.options.touchAction;
    }
    this.gestures.add(gesture);
  }

  /**
   * Removes the given gesture from the region.
   *
   * @param {westures-core.Gesture} gesture - Instantiated gesture to add.
   */
  removeGesture(gesture) {
    this.gestures.delete(gesture);
    this.potentialGestures.delete(gesture);
    this.activeGestures.delete(gesture);
  }

  /**
   * Retrieves Gestures by their associated element.
   *
   * @param {Element} element - The element for which to find gestures.
   *
   * @return {westures-core.Gesture[]} Gestures to which the element is bound.
   */
  getGesturesByElement(element) {
    return setFilter(this.gestures, gesture => gesture.element === element);
  }

  /**
   * Remove all gestures bound to the given element.
   *
   * @param {Element} element - The element to unbind.
   */
  removeGesturesByElement(element) {
    this.getGesturesByElement(element).forEach(g => this.removeGesture(g));
  }
}

Region.DEFAULTS = {
  capture:        false,
  preferPointer:  true,
  preventDefault: true,
  touchAction:    'none',
  headless:       false,
};

module.exports = Region;