Source: gfx/interaction/InteractionManager.js

const core = require('engine/core');
const Node = require('../core/Node');
const InteractionData = require('./InteractionData');
const Vector = require('engine/Vector');
const WebGLRenderer = require('../core/renderers/webgl/WebGLRenderer');
const CanvasRenderer = require('../core/renderers/canvas/CanvasRenderer');

// Mix interactiveTarget into Node.prototype
Object.assign(
  Node.prototype,
  require('./interactiveTarget')
);

/**
 * The interaction manager deals with mouse and touch events. Any Node can be interactive
 * if its interactive parameter is set to true
 * This manager also supports multitouch.
 *
 * @class
 * @memberof interaction
 * @param renderer {CanvasRenderer|WebGLRenderer} A reference to the current renderer
 * @param [options] {object}
 * @param [options.autoPreventDefault=true] {boolean} Should the manager automatically prevent default browser actions.
 * @param [options.interactionFrequency=10] {number} Frequency increases the interaction events will be checked.
 */
function InteractionManager(renderer, options = {}) {
    /**
     * The renderer this interaction manager works for.
     *
     * @member {SystemRenderer}
     */
  this.renderer = renderer;

    /**
     * Should default browser actions automatically be prevented.
     *
     * @member {boolean}
     * @default true
     */
  this.autoPreventDefault = options.autoPreventDefault !== undefined ? options.autoPreventDefault : true;

    /**
     * As this frequency increases the interaction events will be checked more often.
     *
     * @member {number}
     * @default 10
     */
  this.interactionFrequency = options.interactionFrequency || 10;

    /**
     * The mouse data
     *
     * @member {interaction.InteractionData}
     */
  this.mouse = new InteractionData();

    /**
     * An event data object to handle all the event tracking/dispatching
     *
     * @member {object}
     */
  this.eventData = {
    stopped: false,
    target: null,
    type: null,
    data: this.mouse,
    stopPropagation:function() {
      this.stopped = true;
    },
  };

    /**
     * Tiny little interactiveData pool!
     *
     * @member {interaction.InteractionData[]}
     */
  this.interactiveDataPool = [];

    /**
     * The DOM element to bind to.
     *
     * @member {HTMLElement}
     * @private
     */
  this.interactionDOMElement = null;

    /**
     * This property determins if mousemove and touchmove events are fired only when the cursror is over the object
     * Setting to true will make things work more in line with how the DOM verison works.
     * Setting to false can make things easier for things like dragging.
     * @member {boolean}
     * @private
     */
  this.moveWhenInside = false;

    /**
     * Have events been attached to the dom element?
     *
     * @member {boolean}
     * @private
     */
  this.eventsAdded = false;

    // this will make it so that you don't have to call bind all the time

    /**
     * @member {Function}
     */
  this.onMouseUp = this.onMouseUp.bind(this);
  this.processMouseUp = this.processMouseUp.bind(this);


    /**
     * @member {Function}
     */
  this.onMouseDown = this.onMouseDown.bind(this);
  this.processMouseDown = this.processMouseDown.bind(this);

    /**
     * @member {Function}
     */
  this.onMouseMove = this.onMouseMove.bind(this);
  this.processMouseMove = this.processMouseMove.bind(this);

    /**
     * @member {Function}
     */
  this.onMouseOut = this.onMouseOut.bind(this);
  this.processMouseOverOut = this.processMouseOverOut.bind(this);


    /**
     * @member {Function}
     */
  this.onTouchStart = this.onTouchStart.bind(this);
  this.processTouchStart = this.processTouchStart.bind(this);

    /**
     * @member {Function}
     */
  this.onTouchEnd = this.onTouchEnd.bind(this);
  this.processTouchEnd = this.processTouchEnd.bind(this);

    /**
     * @member {Function}
     */
  this.onTouchMove = this.onTouchMove.bind(this);
  this.processTouchMove = this.processTouchMove.bind(this);

    /**
     * @member {number}
     */
  this.last = 0;

    /**
     * The css style of the cursor that is being used
     * @member {string}
     */
  this.currentCursorStyle = 'inherit';

    /**
     * Internal cached var
     * @member {Vector}
     * @private
     */
  this._tempPoint = new Vector();


    /**
     * The current resolution
     * @member {number}
     */
  this.resolution = 1;

  this.setTargetElement(this.renderer.view, this.renderer.resolution);
}

InteractionManager.prototype.constructor = InteractionManager;
module.exports = InteractionManager;

/**
 * Sets the DOM element which will receive mouse/touch events. This is useful for when you have
 * other DOM elements on top of the renderers Canvas element. With this you'll be bale to deletegate
 * another DOM element to receive those events.
 *
 * @param element {HTMLElement} the DOM element which will receive mouse and touch events.
 * @param [resolution=1] {number} THe resolution of the new element (relative to the canvas).
 * @private
 */
InteractionManager.prototype.setTargetElement = function(element, resolution) {
  this.removeEvents();

  this.interactionDOMElement = element;

  this.resolution = resolution || 1;

  this.addEvents();
};

/**
 * Registers all the DOM events
 *
 * @private
 */
InteractionManager.prototype.addEvents = function() {
  if (!this.interactionDOMElement) {
    return;
  }

  core.on('tick', this.update, this);

  if (window.navigator.msPointerEnabled) {
    this.interactionDOMElement.style['-ms-content-zooming'] = 'none';
    this.interactionDOMElement.style['-ms-touch-action'] = 'none';
  }

  window.document.addEventListener('mousemove', this.onMouseMove, true);
  this.interactionDOMElement.addEventListener('mousedown', this.onMouseDown, true);
  this.interactionDOMElement.addEventListener('mouseout', this.onMouseOut, true);

  this.interactionDOMElement.addEventListener('touchstart', this.onTouchStart, true);
  this.interactionDOMElement.addEventListener('touchend', this.onTouchEnd, true);
  this.interactionDOMElement.addEventListener('touchmove', this.onTouchMove, true);

  window.addEventListener('mouseup', this.onMouseUp, true);

  this.eventsAdded = true;
};

/**
 * Removes all the DOM events that were previously registered
 *
 * @private
 */
InteractionManager.prototype.removeEvents = function() {
  if (!this.interactionDOMElement) {
    return;
  }

  core.off('tick', this.update, this);

  if (window.navigator.msPointerEnabled) {
    this.interactionDOMElement.style['-ms-content-zooming'] = '';
    this.interactionDOMElement.style['-ms-touch-action'] = '';
  }

  window.document.removeEventListener('mousemove', this.onMouseMove, true);
  this.interactionDOMElement.removeEventListener('mousedown', this.onMouseDown, true);
  this.interactionDOMElement.removeEventListener('mouseout', this.onMouseOut, true);

  this.interactionDOMElement.removeEventListener('touchstart', this.onTouchStart, true);
  this.interactionDOMElement.removeEventListener('touchend', this.onTouchEnd, true);
  this.interactionDOMElement.removeEventListener('touchmove', this.onTouchMove, true);

  this.interactionDOMElement = null;

  window.removeEventListener('mouseup', this.onMouseUp, true);

  this.eventsAdded = false;
};

/**
 * Updates the state of interactive objects.
 * Invoked by a throttled ticker update from
 * {@link ticker.shared}.
 *
 * @param deltaTime {number}
 */
InteractionManager.prototype.update = function(deltaTime) {
  this._deltaTime += deltaTime;

  if (this._deltaTime < this.interactionFrequency) {
    return;
  }

  this._deltaTime = 0;

  if (!this.interactionDOMElement) {
    return;
  }

    // if the user move the mouse this check has already been dfone using the mouse move!
  if (this.didMove) {
    this.didMove = false;
    return;
  }

  this.cursor = 'inherit';

  this.processInteractive(this.mouse.global, this.renderer._lastObjectRendered, this.processMouseOverOut, true);

  if (this.currentCursorStyle !== this.cursor) {
    this.currentCursorStyle = this.cursor;
    this.interactionDOMElement.style.cursor = this.cursor;
  }

    // TODO
};

/**
 * Dispatches an event on the display object that was interacted with
 *
 * @param displayObject {Node} the display object in question
 * @param eventString {string} the name of the event (e.g, mousedown)
 * @param eventData {object} the event data object
 * @private
 */
InteractionManager.prototype.dispatchEvent = function(displayObject, eventString, eventData) {
  if (!eventData.stopped) {
    eventData.target = displayObject;
    eventData.type = eventString;

    displayObject.emit(eventString, eventData);

    if (displayObject[eventString]) {
      displayObject[eventString](eventData);
    }
  }
};

/**
 * Maps x and y coords from a DOM object and maps them correctly to the pixi view. The resulting value is stored in the point.
 * This takes into account the fact that the DOM element could be scaled and positioned anywhere on the screen.
 *
 * @param  {Vector} point the point that the result will be stored in
 * @param  {number} x     the x coord of the position to map
 * @param  {number} y     the y coord of the position to map
 */
InteractionManager.prototype.mapPositionToPoint = function(point, x, y) {
  var rect = this.interactionDOMElement.getBoundingClientRect();
  point.x = ((x - rect.left) * (this.interactionDOMElement.width / rect.width)) / this.resolution;
  point.y = ((y - rect.top) * (this.interactionDOMElement.height / rect.height)) / this.resolution;
};

/**
 * This function is provides a neat way of crawling through the scene graph and running a specified function on all interactive objects it finds.
 * It will also take care of hit testing the interactive objects and passes the hit across in the function.
 *
 * @param  {Vector} point the point that is tested for collision
 * @param  {Node} displayObject the displayObject that will be hit test (recurcsivly crawls its children)
 * @param  {Function} func the function that will be called on each interactive object. The displayObject and hit will be passed to the function
 * @param  {boolean} hitTest this indicates if the objects inside should be hit test against the point
 * @return {boolean} returns true if the displayObject hit the point
 */
InteractionManager.prototype.processInteractive = function(point, displayObject, func, hitTest, interactive) {
  if (!displayObject || !displayObject.visible) {
    return false;
  }

    // Took a little while to rework this function correctly! But now it is done and nice and optimised. ^_^
    //
    // This function will now loop through all objects and then only hit test the objects it HAS to, not all of them. MUCH faster..
    // An object will be hit test if the following is true:
    //
    // 1: It is interactive.
    // 2: It belongs to a parent that is interactive AND one of the parents children have not already been hit.
    //
    // As another little optimisation once an interactive object has been hit we can carry on through the scenegraph, but we know that there will be no more hits! So we can avoid extra hit tests
    // A final optimisation is that an object is not hit test directly if a child has already been hit.

  var hit = false,
    interactiveParent = interactive = displayObject.interactive || interactive;

    // if the displayobject has a hitArea, then it does not need to hitTest children.
  if (displayObject.hitArea) {
    interactiveParent = false;
  }

    // ** FREE TIP **! If an object is not interacttive or has no buttons in it (such as a game scene!) set interactiveChildren to false for that displayObject.
    // This will allow pixi to completly ignore and bypass checking the displayObjects children.
  if (displayObject.interactiveChildren) {
    var children = displayObject.children;

    for (var i = children.length - 1; i >= 0; i--) {
      var child = children[i];

            // time to get recursive.. if this function will return if somthing is hit..
      if (this.processInteractive(point, child, func, hitTest, interactiveParent)) {
                // its a good idea to check if a child has lost its parent.
                // this means it has been removed whilst looping so its best
        if (!child.parent) {
          continue;
        }

        hit = true;

                // we no longer need to hit test any more objects in this container as we we now know the parent has been hit
        interactiveParent = false;

                // If the child is interactive , that means that the object hit was actually interactive and not just the child of an interactive object.
                // This means we no longer need to hit test anything else. We still need to run through all objects, but we don't need to perform any hit tests.
                // if(child.interactive)
                // {
        hitTest = false;
                // }

                // we can break now as we have hit an object.
                // break;
      }
    }
  }

    // no point running this if the item is not interactive or does not have an interactive parent.
  if (interactive) {
        // if we are hit testing (as in we have no hit any objects yet)
        // We also don't need to worry about hit testing if once of the displayObjects children has already been hit!
    if (hitTest && !hit) {
      if (displayObject.hitArea) {
        displayObject.worldTransform.applyInverse(point, this._tempPoint);
        hit = displayObject.hitArea.contains(this._tempPoint.x, this._tempPoint.y);
      }
      else if (displayObject.containsPoint) {
        hit = displayObject.containsPoint(point);
      }
    }

    if (displayObject.interactive) {
      func(displayObject, hit);
    }
  }

  return hit;

};


/**
 * Is called when the mouse button is pressed down on the renderer element
 *
 * @param event {Event} The DOM event of a mouse button being pressed down
 * @private
 */
InteractionManager.prototype.onMouseDown = function(event) {
  this.mouse.originalEvent = event;
  this.eventData.data = this.mouse;
  this.eventData.stopped = false;

    // Update internal mouse reference
  this.mapPositionToPoint(this.mouse.global, event.clientX, event.clientY);

  if (this.autoPreventDefault) {
    this.mouse.originalEvent.preventDefault();
  }

  this.processInteractive(this.mouse.global, this.renderer._lastObjectRendered, this.processMouseDown, true);
};

/**
 * Processes the result of the mouse down check and dispatches the event if need be
 *
 * @param displayObject {Node} The display object that was tested
 * @param hit {boolean} the result of the hit test on the dispay object
 * @private
 */
InteractionManager.prototype.processMouseDown = function(displayObject, hit) {
  var e = this.mouse.originalEvent;

  var isRightButton = e.button === 2 || e.which === 3;

  if (hit) {
    displayObject[ isRightButton ? '_isRightDown' : '_isLeftDown' ] = true;
    this.dispatchEvent(displayObject, isRightButton ? 'rightdown' : 'mousedown', this.eventData);
  }
};



/**
 * Is called when the mouse button is released on the renderer element
 *
 * @param event {Event} The DOM event of a mouse button being released
 * @private
 */
InteractionManager.prototype.onMouseUp = function(event) {
  this.mouse.originalEvent = event;
  this.eventData.data = this.mouse;
  this.eventData.stopped = false;

    // Update internal mouse reference
  this.mapPositionToPoint(this.mouse.global, event.clientX, event.clientY);

  this.processInteractive(this.mouse.global, this.renderer._lastObjectRendered, this.processMouseUp, true);
};

/**
 * Processes the result of the mouse up check and dispatches the event if need be
 *
 * @param displayObject {Node} The display object that was tested
 * @param hit {boolean} the result of the hit test on the display object
 * @private
 */
InteractionManager.prototype.processMouseUp = function(displayObject, hit) {
  var e = this.mouse.originalEvent;

  var isRightButton = e.button === 2 || e.which === 3;
  var isDown = isRightButton ? '_isRightDown' : '_isLeftDown';

  if (hit) {
    this.dispatchEvent(displayObject, isRightButton ? 'rightup' : 'mouseup', this.eventData);

    if (displayObject[ isDown ]) {
      displayObject[ isDown ] = false;
      this.dispatchEvent(displayObject, isRightButton ? 'rightclick' : 'click', this.eventData);
    }
  }
  else {
    if (displayObject[ isDown ]) {
      displayObject[ isDown ] = false;
      this.dispatchEvent(displayObject, isRightButton ? 'rightupoutside' : 'mouseupoutside', this.eventData);
    }
  }
};


/**
 * Is called when the mouse moves across the renderer element
 *
 * @param event {Event} The DOM event of the mouse moving
 * @private
 */
InteractionManager.prototype.onMouseMove = function(event) {
  this.mouse.originalEvent = event;
  this.eventData.data = this.mouse;
  this.eventData.stopped = false;

  this.mapPositionToPoint(this.mouse.global, event.clientX, event.clientY);

  this.didMove = true;

  this.cursor = 'inherit';

  this.processInteractive(this.mouse.global, this.renderer._lastObjectRendered, this.processMouseMove, true);

  if (this.currentCursorStyle !== this.cursor) {
    this.currentCursorStyle = this.cursor;
    this.interactionDOMElement.style.cursor = this.cursor;
  }

    // TODO BUG for parents ineractive object (border order issue)
};

/**
 * Processes the result of the mouse move check and dispatches the event if need be
 *
 * @param displayObject {Node} The display object that was tested
 * @param hit {boolean} the result of the hit test on the display object
 * @private
 */
InteractionManager.prototype.processMouseMove = function(displayObject, hit) {
  this.processMouseOverOut(displayObject, hit);

    // only display on mouse over
  if (!this.moveWhenInside || hit) {
    this.dispatchEvent(displayObject, 'mousemove', this.eventData);
  }
};


/**
 * Is called when the mouse is moved out of the renderer element
 *
 * @param event {Event} The DOM event of a mouse being moved out
 * @private
 */
InteractionManager.prototype.onMouseOut = function(event) {
  this.mouse.originalEvent = event;
  this.eventData.stopped = false;

    // Update internal mouse reference
  this.mapPositionToPoint(this.mouse.global, event.clientX, event.clientY);

  this.interactionDOMElement.style.cursor = 'inherit';

    // TODO optimize by not check EVERY TIME! maybe half as often? //
  this.mapPositionToPoint(this.mouse.global, event.clientX, event.clientY);

  this.processInteractive(this.mouse.global, this.renderer._lastObjectRendered, this.processMouseOverOut, false);
};

/**
 * Processes the result of the mouse over/out check and dispatches the event if need be
 *
 * @param displayObject {Node} The display object that was tested
 * @param hit {boolean} the result of the hit test on the display object
 * @private
 */
InteractionManager.prototype.processMouseOverOut = function(displayObject, hit) {
  if (hit) {
    if (!displayObject._over) {
      displayObject._over = true;
      this.dispatchEvent(displayObject, 'mouseover', this.eventData);
    }

    if (displayObject.buttonMode) {
      this.cursor = displayObject.defaultCursor;
    }
  }
  else {
    if (displayObject._over) {
      displayObject._over = false;
      this.dispatchEvent(displayObject, 'mouseout', this.eventData);
    }
  }
};


/**
 * Is called when a touch is started on the renderer element
 *
 * @param event {Event} The DOM event of a touch starting on the renderer view
 * @private
 */
InteractionManager.prototype.onTouchStart = function(event) {
  if (this.autoPreventDefault) {
    event.preventDefault();
  }

  var changedTouches = event.changedTouches;
  var cLength = changedTouches.length;

  for (var i = 0; i < cLength; i++) {
    var touchEvent = changedTouches[i];
        // TODO POOL
    var touchData = this.getTouchData(touchEvent);

    touchData.originalEvent = event;

    this.eventData.data = touchData;
    this.eventData.stopped = false;

    this.processInteractive(touchData.global, this.renderer._lastObjectRendered, this.processTouchStart, true);

    this.returnTouchData(touchData);
  }
};

/**
 * Processes the result of a touch check and dispatches the event if need be
 *
 * @param displayObject {Node} The display object that was tested
 * @param hit {boolean} the result of the hit test on the display object
 * @private
 */
InteractionManager.prototype.processTouchStart = function(displayObject, hit) {
  if (hit) {
    displayObject._touchDown = true;
    this.dispatchEvent(displayObject, 'touchstart', this.eventData);
  }
};


/**
 * Is called when a touch ends on the renderer element
 *
 * @param event {Event} The DOM event of a touch ending on the renderer view
 */
InteractionManager.prototype.onTouchEnd = function(event) {
  if (this.autoPreventDefault) {
    event.preventDefault();
  }

  var changedTouches = event.changedTouches;
  var cLength = changedTouches.length;

  for (var i = 0; i < cLength; i++) {
    var touchEvent = changedTouches[i];

    var touchData = this.getTouchData(touchEvent);

    touchData.originalEvent = event;

        // TODO this should be passed along.. no set
    this.eventData.data = touchData;
    this.eventData.stopped = false;


    this.processInteractive(touchData.global, this.renderer._lastObjectRendered, this.processTouchEnd, true);

    this.returnTouchData(touchData);
  }
};

/**
 * Processes the result of the end of a touch and dispatches the event if need be
 *
 * @param displayObject {Node} The display object that was tested
 * @param hit {boolean} the result of the hit test on the display object
 * @private
 */
InteractionManager.prototype.processTouchEnd = function(displayObject, hit) {
  if (hit) {
    this.dispatchEvent(displayObject, 'touchend', this.eventData);

    if (displayObject._touchDown) {
      displayObject._touchDown = false;
      this.dispatchEvent(displayObject, 'tap', this.eventData);
    }
  }
  else {
    if (displayObject._touchDown) {
      displayObject._touchDown = false;
      this.dispatchEvent(displayObject, 'touchendoutside', this.eventData);
    }
  }
};

/**
 * Is called when a touch is moved across the renderer element
 *
 * @param event {Event} The DOM event of a touch moving across the renderer view
 * @private
 */
InteractionManager.prototype.onTouchMove = function(event) {
  if (this.autoPreventDefault) {
    event.preventDefault();
  }

  var changedTouches = event.changedTouches;
  var cLength = changedTouches.length;

  for (var i = 0; i < cLength; i++) {
    var touchEvent = changedTouches[i];

    var touchData = this.getTouchData(touchEvent);

    touchData.originalEvent = event;

    this.eventData.data = touchData;
    this.eventData.stopped = false;

    this.processInteractive(touchData.global, this.renderer._lastObjectRendered, this.processTouchMove, this.moveWhenInside);

    this.returnTouchData(touchData);
  }
};

/**
 * Processes the result of a touch move check and dispatches the event if need be
 *
 * @param displayObject {Node} The display object that was tested
 * @param hit {boolean} the result of the hit test on the display object
 * @private
 */
InteractionManager.prototype.processTouchMove = function(displayObject, hit) {
  if (!this.moveWhenInside || hit) {
    this.dispatchEvent(displayObject, 'touchmove', this.eventData);
  }
};

/**
 * Grabs an interaction data object from the internal pool
 *
 * @param touchEvent {EventData} The touch event we need to pair with an interactionData object
 *
 * @private
 */
InteractionManager.prototype.getTouchData = function(touchEvent) {
  var touchData = this.interactiveDataPool.pop();

  if (!touchData) {
    touchData = new InteractionData();
  }

  touchData.identifier = touchEvent.identifier;
  this.mapPositionToPoint(touchData.global, touchEvent.clientX, touchEvent.clientY);

  if (navigator.isCocoonJS) {
    touchData.global.x = touchData.global.x / this.resolution;
    touchData.global.y = touchData.global.y / this.resolution;
  }

  touchEvent.globalX = touchData.global.x;
  touchEvent.globalY = touchData.global.y;

  return touchData;
};

/**
 * Returns an interaction data object to the internal pool
 *
 * @param touchData {interaction.InteractionData} The touch data object we want to return to the pool
 *
 * @private
 */
InteractionManager.prototype.returnTouchData = function(touchData) {
  this.interactiveDataPool.push(touchData);
};

/**
 * Destroys the interaction manager
 *
 */
InteractionManager.prototype.destroy = function() {
  this.removeEvents();

  this.renderer = null;

  this.mouse = null;

  this.eventData = null;

  this.interactiveDataPool = null;

  this.interactionDOMElement = null;

  this.onMouseUp = null;
  this.processMouseUp = null;


  this.onMouseDown = null;
  this.processMouseDown = null;

  this.onMouseMove = null;
  this.processMouseMove = null;

  this.onMouseOut = null;
  this.processMouseOverOut = null;


  this.onTouchStart = null;
  this.processTouchStart = null;

  this.onTouchEnd = null;
  this.processTouchEnd = null;

  this.onTouchMove = null;
  this.processTouchMove = null;

  this._tempPoint = null;
};

WebGLRenderer.registerPlugin('interaction', InteractionManager);
CanvasRenderer.registerPlugin('interaction', InteractionManager);