Source: ui/gesturehandler.js

var _ = require("lodash");
var Hammer = require("hammerjs");
var binder = require("../events").binder;
var hasEmitter = require("../events").hasEmitter;
var dom = require("../ui/dom");
var rect = require("../utils").rect;
var DecksEvent = require("../events").DecksEvent;
var PanEmitter = require("./panemitter");
var SwipeEmitter = require("./swipeemitter");
var MouseWheelEmitter = require("./mousewheelemitter");
var MouseOverOutEmitter = require("./mouseoveroutemitter");
var MouseEnterLeaveEmitter = require("./mouseenterleaveemitter");
var TapEmitter = require("./tapemitter");
var PressEmitter = require("./pressemitter");
var ScrollEmitter = require("./scrollemitter");
var validate = require("../utils/validate");
//var raf = require("raf");

/**
 * Object to bind and handle gesture events for a single DOM element.
 *
 * @class
 * @mixes binder
 * @mixes hasEmitter
 * @param {!Object} options - additional options
 * @param {!Element} options.element - element for which to handle gestures
 * @param {?Emitter} [options.emitter={}] - {@link Emitter} instance or options
 * @param {!Object} options.animator - animator object
 * @param {!Object} options.config - config object
 * @param {?Object} options.gestures - gesture emitter options
 * @param {?Element} options.containerElement - container element for this element
 * @param {?Object} options.bounds - rectangle-like boundary for gestures/animations
 */
function GestureHandler(options) {
  if (!(this instanceof GestureHandler)) {
    return new GestureHandler(options);
  }

  options = _.merge({}, this.defaultOptions, options);

  this.gestureEmitters = {};
  this.animationCount = 0;
  this.debouncedOnGestureScroll = _.debounce(this.onGestureScroll, options.movement.debouncedScrollWait);

  this.setAnimator(options.animator);
  this.setConfig(options.config);
  this.setEmitter(options.emitter || {});
  this.setElement(options.element);
  this.setOptions(options);

  this.bind();
}

_.extend(GestureHandler.prototype, binder, hasEmitter, /** @lends GestureHandler.prototype */ {
  /**
   * Default options to use with a GestureHandler instance.
   */
  defaultOptions: {
    /**
     * Gesture types
     */
    gestures: {
      /**
       * Mousewheel events
       */
      mouseWheel: {
        enabled: false,
        horizontal: true,
        vertical: true
      },

      /**
       * Mouse over/out events
       */
      mouseOverOut: {
        enabled: false,
        over: true,
        out: true
      },

      /**
       * Mouse enter/leave events
       */
      mouseEnterLeave: {
        enabled: false,
        enter: true,
        leave: true
      },

      /**
       * Pan events
       */
      pan: {
        enabled: false,
        horizontal: false,
        vertical: true
      },

      /**
       * Swipe events
       */
      swipe: {
        enabled: false,
        horizontal: false,
        vertical: true
      },

      /**
       * Tap events
       */
      tap: {
        enabled: false
      },

      /**
       * Press events
       */
      press: {
        enabled: false
      },

      /**
       * Scroll events (on the container)
       */
      scroll: {
        enabled: false
      }
    },

    /**
     * The container element in which this element resides
     */
    containerElement: null,

    /**
     * Boundary for animations/gestures (can be a real element bounds, or just virtual bounds)
     */
    bounds: null,

    /**
     * Function which provides additional x/y offsets to apply when animating to a child element
     * e.g. for snap to nearest child element, or move to element
     */
    getMoveToElementOffsets: function() {
      return {
        x: 0,
        y: 0
      };
    },

    /**
     * Movement options
     */
    movement: {
      scroll: false, // false: move by changing top/left, true: move by changing scrollTop/scrollLeft on container
      debouncedScrollWait: 600,
      swipingTimeout: 30,
      animateOptions: {
        duration: 500,
        easing: "easeInOutCubic"
      }
    },

    /**
     * Snapping options
     */
    snapping: {
      toBounds: false,
      toNearestChildElement: false,
      childElementSelector: ".decks-item",
      reduceMovementAtBounds: false,
      hardStopAtBounds: false,
      distanceThreshold: 40, // The pixel distance when pulling away from an edge, where movement resistance begins to be applied
      distanceScale: 0.5, // The scale factor for reducing movement when pulling away from an edge
      animateOptions: {
        duration: 500,
        easing: [500, 20] // tension (default 500), friction (default 20)
      }
    },

    /**
     * Inertial movement options
     */
    inertia: {
      distanceScale: 500, // 400 used to calculate the movement distance for an inertia-based movement (swipe gesture)
      durationScale: 500, // 60 used to calculate the movement duration for an inertia-based movement (swipe gesture)
      animateOptions: {
        easing: "easeOutCubic"
      }
    }
  },

  /**
   * Mapping of gesture names to gesture emitter component constructor functions
   */
  gestureEmitterTypes: {
    pan: PanEmitter,
    swipe: SwipeEmitter,
    mouseWheel: MouseWheelEmitter,
    mouseOverOut: MouseOverOutEmitter,
    mouseEnterLeave: MouseEnterLeaveEmitter,
    tap: TapEmitter,
    press: PressEmitter,
    scroll: ScrollEmitter
  },

  getEmitterEvents: function() {
    return {
      // Pan gestures - linear tracking movement
      "gesture:pan:start": "onGesturePanStart",
      "gesture:pan:any": "onGesturePanAny",
      "gesture:pan:x": "onGesturePanX",
      "gesture:pan:y": "onGesturePanY",
      "gesture:pan:end": "onGesturePanEnd",
      "gesture:pan:cancel": "onGesturePanCancel",

      // Swipe gestures - inertial movement in swipe direction
      "gesture:swipe:any": "onGestureSwipeAny",
      "gesture:swipe:x": "onGestureSwipeX",
      "gesture:swipe:y": "onGestureSwipeY",

      // Tap/press gestures
      "gesture:tap": "onGestureTap",
      "gesture:press": "onGesturePress",

      // Scroll
      "gesture:scroll": "debouncedOnGestureScroll"
    };
  },

  /**
   * Binds all {@link GestureHandler} events handlers.
   *
   * @return {undefined}
   */
  bind: function bind() {
    this.bindEvents(this.emitter, this.getEmitterEvents());
  },

  /**
   * Unbinds all {@link GestureHandler} event handlers.
   *
   * @return {undefined}
   */
  unbind: function unbind() {
    this.unbindEvents(this.emitter, this.getEmitterEvents());
  },

  /**
   * Destroys the GestureHandler and all GestureEmitter instances
   *
   * @return {undefined}
   */
  destroy: function destroy() {
    _.each(this.gestureEmitters, function(gestureEmitter, key) {
      if (this.config.debugGestures) {
        console.log("GestureHandler#destroy: destroying gesture emitter: " + key);
      }

      gestureEmitter.destroy();

      delete this.gestureEmitters[key];
    }, this);

    if (this.config.debugGestures) {
      console.log("GestureHandler#destroy: destroying hammer: ", this.hammer);
    }

    // Destory the Hammer instance
    this.hammer.destroy();
    delete this.hammer;

    this.unbind();
  },

  /**
   * Sets the animator instance
   *
   * @param animator
   * @return {undefined}
   */
  setAnimator: function setAnimator(animator) {
    validate(animator, "GestureHandler#setAnimator: animator", { isPlainObject: true, isNotSet: this.animator });
    this.animator = animator;
  },

  /**
   * Sets the config instance
   *
   * @param config
   * @return {undefined}
   */
  setConfig: function setConfig(config) {
    validate(config, "GestureHandler#setConfig: config", { isPlainObject: true, isNotSet: this.config });
    this.config = config;
  },

  /**
   * Sets the element instance
   *
   * @param element
   * @return {undefined}
   */
  setElement: function setElement(element) {
    validate(element, "GestureHandler#setElement: element", { isElement: true, isNotSet: this.element });
    this.element = element;
    this.hammer = new Hammer(this.element);
  },

  /**
   * Sets GestureHandler options
   *
   * @param options
   * @return {undefined}
   */
  setOptions: function setOptions(options) {
    validate(options, "GestureHandler#setOptions: options", { isRequired: true });

    // Container element (optional)
    this.containerElement = options.containerElement;

    // Bounds (optional)
    this.setBounds(options.bounds);

    // Movement options
    this.movement = options.movement;

    if (this.movement.scroll && !_.isElement(this.containerElement)) {
      throw new Error("GestureHandler#setOptions: for options.movement.scroll === true, options.containerElement must be an element");
    }

    // Snapping options
    this.snapping = options.snapping;

    if (this.snapping.toBounds && !this.bounds) {
      throw new Error("GestureHandler#setOptions: for options.snapping.toBounds === true, options.bounds is required");
    }

    if (this.snapping.toNearestChildElement && !_.isString(this.snapping.childElementSelector)) {
      throw new Error("GestureHandler#setOptions: for options.snapping.toNearestChildElement === true, options.snapping.childElementSelector is required");
    }

    // Inertia options
    this.inertia = options.inertia;

    // Other callbacks/etc.
    if (!_.isFunction(options.getMoveToElementOffsets)) {
      throw new Error("GestureHandler#setOptions: getMoveToElementOffsets must be a function");
    }

    this.getMoveToElementOffsets = options.getMoveToElementOffsets;

    // Gesture types
    _.each(options.gestures, function(gestureEmitterOptions, key) {
      // Get the constructor function for this type of gesture emitter
      var GestureEmitter = this.gestureEmitterTypes[key];

      if (!GestureEmitter) {
        throw new Error("GestureHandler#setOptions: no gesture emitter component configured to handle gesture type: " + key);
      }

      var element = this.element;

      if (key === "scroll") {
        // Scroll emitter must be on the container element, not the element itself
        element = this.containerElement || this.element;
        gestureEmitterOptions.enabled = gestureEmitterOptions.enabled && this.movement.scroll;

        if (gestureEmitterOptions.enabled && !this.containerElement) {
          if (!this.containerElement) {
            throw new Error("GestureHandler#setOptions: for scroll gestures, options.containerElement is required");
          }
        }
      }

      _.extend(gestureEmitterOptions, {
        element: element,
        hammer: this.hammer,
        emitter: this.emitter
      });

      this.gestureEmitters[key] = new GestureEmitter(gestureEmitterOptions);
    }, this);
  },

  /**
   * Sets the bounds for the gestures/animations
   *
   * @param bounds
   * @return {undefined}
   */
  setBounds: function setBounds(bounds) {
    if (!bounds && _.isElement(this.containerElement)) {
      bounds = rect.normalize(this.containerElement);
    }

    if (rect.isEqual(this.bounds, bounds)) {
      return;
    }

    this.bounds = bounds;
  },

  /**
   * Updates the current position data (and sets the start position if not set)
   *
   * @param e
   * @return {undefined}
   */
  updatePositionData: function updatePositionData(e) {
    this.currentPosition = {
      event: e
    };

    // If moving by scroll, record the starting scroll top and left, otherwise, record the style top and left
    if (this.movement.scroll) {
      _.extend(this.currentPosition, {
        scrollTop: this.containerElement.scrollTop,
        scrollLeft: this.containerElement.scrollLeft
      });
    } else {
      _.extend(this.currentPosition, rect.normalize(this.element));
    }

    /*
    if (this.config.debugGestures) {
      console.log("set current position " + this.element.id, this.currentPosition);
    }
    */

    if (!this.startPosition) {
      this.startPosition = this.currentPosition;

      if (this.element.parentNode) {
        this.parentPosition = rect.normalize(this.element.parentNode);
      }

      /*
      if (this.config.debugGestures) {
        console.log("set start position " + this.element.id, this.startPosition);
      }
      */
    }
  },

  /**
   * Clears the current and start position data
   *
   * @return {undefined}
   */
  clearPositionData: function clearPositionData() {
    if (this.config.debugGestures) {
      console.log("clear position", this.element.id);
    }
    this.startPosition = null;
    this.currentPosition = null;
    this.parentPosition = null;
  },

  /**
   * Indicates if the element has any animation running currently.
   *
   * @return {undefined}
   */
  isAnimating: function isAnimating() {
    return this.animationCount > 0;
  },

  /**
   * Stops the current animation (if possible) and clears the animation queue for the element
   *
   * Note: only queued animations can be stopped.  Animations with "queue: false" don't seem
   * to be stoppable.
   *
   * @return {undefined}
   */
  stopAnimation: function stopAnimation() {
    if (this.config.debugGestures) {
      console.log("stop", this.element.id);
    }
    this.animator.animate(this.element, "stop", true);
    this.animationCount = 0;
  },

  /**
   * Moves the element using the information in the given Hammer event object.
   *
   * @param e - hammer pan event object (from a panmove|panleft|panright|etc.)
   * @param elementRect - the bounding client rect of the element
   * @return {undefined}
   */
  animateMoveForPan: function animateMoveForPan(e, animateOptions, beginOptions, completeOptions) {
    completeOptions.waitForXAndY = true;
    this.animateMoveForPanX(e, animateOptions, beginOptions, completeOptions);
    this.animateMoveForPanY(e, animateOptions, beginOptions, completeOptions);
  },

  /**
   * Moves the element horizontally, using the information in the given hammer event object.
   *
   * @param e
   * @param elementRect
   * @return {undefined}
   */
  animateMoveForPanX: function animateMoveForPanX(e, animateOptions, beginOptions, completeOptions) {
    var x;

    if (this.movement.scroll) {
      x = this.startPosition.scrollLeft - e.deltaX;
    } else {
      x = this.startPosition.left - this.parentPosition.left + e.deltaX;

      // Limit movement if the user is dragging the element towards the inside of the container bounds
      if (this.snapping.reduceMovementAtBounds && this.bounds && this.snapping.distanceThreshold) {
        if ((this.currentPosition.left - this.bounds.left) > this.snapping.distanceThreshold) {
          x = (this.startPosition.left + this.snapping.distanceThreshold) +
            ((e.deltaX - this.snapping.distanceThreshold) * this.snapping.distanceScale);
        } else if ((this.bounds.right - this.currentPosition.right) > this.snapping.distanceThreshold) {
          x = (this.startPosition.left - this.snapping.distanceThreshold) +
            ((e.deltaX + this.snapping.distanceThreshold) * this.snapping.distanceScale);
        }
      }

      // Don't allow pan movement to go beyond bounds
      if (this.snapping.hardStopAtBounds) {
        if (x + this.currentPosition.width + this.parentPosition.left > this.bounds.right) {
          x = this.bounds.right - this.currentPosition.width - this.parentPosition.left;
        }

        if (x + this.parentPosition.left < this.bounds.left) {
          x = this.bounds.left - this.parentPosition.left;
        }
      }
    }

    _.extend(animateOptions, {
      duration: 0 // Immediate animation for pan movements
    });

    _.extend(completeOptions, {
      snapToBounds: false, // Don't snap to bounds for single pan events (wait for pan end)
      snapToNearestChildElement: false, // Don't snap for single pan events (wait for pan end)
      clearPositionData: false // Don't clear position data, because pan events need the past data
    });

    this.animateMoveX(x, animateOptions, beginOptions, completeOptions);
  },

  /**
   * Moves the element vertically, using the information in the given hammer event object.
   *
   * @param e
   * @param elementRect
   * @return {undefined}
   */
  animateMoveForPanY: function animateMoveForPanY(e, animateOptions, beginOptions, completeOptions) {
    var y;

    if (this.movement.scroll) {
      y = this.startPosition.scrollTop - e.deltaY;
    } else {
      y = this.startPosition.top - this.parentPosition.top + e.deltaY;

      // Limit movement if the user is dragging the element towards the inside of the container bounds
      if (this.snapping.reduceMovementAtBounds && this.bounds && this.snapping.distanceThreshold) {
        if ((this.currentPosition.top - this.bounds.top) > this.snapping.distanceThreshold) {
          y = (this.startPosition.top + this.snapping.distanceThreshold) +
            ((e.deltaY - this.snapping.distanceThreshold) * this.snapping.distanceScale);
        } else if ((this.bounds.bottom - this.currentPosition.bottom) > this.snapping.distanceThreshold) {
          y = (this.startPosition.top - this.snapping.distanceThreshold) +
            ((e.deltaY + this.snapping.distanceThreshold) * this.snapping.distanceScale);
        }
      }

      // Don't allow the pan position to go beyond bounds
      if (this.snapping.hardStopAtBounds) {
        if (y + this.currentPosition.height + this.parentPosition.top > this.bounds.bottom) {
          y = this.bounds.bottom - this.currentPosition.height - this.parentPosition.top;
        }

        if (y + this.parentPosition.top < this.bounds.top) {
          y = this.bounds.top - this.parentPosition.top;
        }
      }
    }

    _.extend(animateOptions, {
      duration: 0 // Immediate animation for pan movements
    });

    _.extend(completeOptions, {
      snapToBounds: false, // Don't snap to bounds for pans (do it on pan end)
      snapToNearestChildElement: false, // Don't snap to bounds for pans (do it on pan end)
      clearPositionData: false // Don't clear position data - pan movements need past data
    });

    this.animateMoveY(y, animateOptions, beginOptions, completeOptions);
  },

  /**
   * Starts a horizontal and/or vertical movement animation using the
   * information in the given Hammer event object.
   *
   * @param e
   * @return {undefined}
   */
  animateMoveForSwipe: function animateMoveForSwipe(e, animateOptions, beginOptions, completeOptions) {
    completeOptions.waitForXAndY = true;
    this.animateMoveForSwipeX(e, animateOptions, beginOptions, completeOptions);
    this.animateMoveForSwipeY(e, animateOptions, beginOptions, completeOptions);
  },

  /**
   * Starts an animation for a swipe gesture in the horizontal direction.
   *
   * @param e
   * @param animateOptions
   * @param beginOptions
   * @param completeOptions
   * @return {undefined}
   */
  animateMoveForSwipeX: function animateMoveForSwipeX(e, animateOptions, beginOptions, completeOptions) {
    var distance = this.getInertiaDistance(e.velocityX);
    var duration = this.getInertiaDuration(e.velocityX);
    var x;

    if (this.movement.scroll) {
      x = this.currentPosition.scrollLeft + distance;
    } else {
      x = "-=" + distance;

      if (this.snapping.hardStopAtBounds) {
        /*
        if (this.element.id === "decks-item-1-0") {
          console.log("----------");
          console.log("this.element.id", this.element.id);
          console.log("this.startPosition", this.startPosition);
          console.log("this.currentPosition", this.currentPosition);
          console.log("this.parentPosition", this.parentPosition);
          console.log("this.bounds", this.bounds);
          console.log("distance", distance);
        }
        */
        if (distance > 0) {
          //console.log("left");
          // If moving to the left, stop the element at the left bounds
          if (this.currentPosition.left - distance < this.bounds.left) {
            x = this.bounds.left - this.parentPosition.left;
            duration = duration * (this.currentPosition.left - this.bounds.left) / distance;
          }
        } else {
          //console.log("right");
          // If moving to the right, stop the element at the right bounds
          if (this.currentPosition.right - distance > this.bounds.right) {
            x = this.bounds.right - this.currentPosition.width - this.parentPosition.left;
            duration = duration * (this.bounds.right - this.currentPosition.right) / -distance;
          }
        }
      }
    }

    //console.log("duration", duration);

    _.extend(animateOptions, this.inertia.animateOptions, {
      duration: duration
    });

    _.extend(completeOptions, {
      snapToBounds: false,
      snapToNearestChildElement: false,
      clearPositionData: true
    });


    this.animateMoveX(x, animateOptions, beginOptions, completeOptions);
  },

  /**
   * Starts an animation for a swipe gestures in the vertical direction.
   *
   * @param e
   * @param animateOptions
   * @param beginOptions
   * @param completeOptions
   * @return {undefined}
   */
  animateMoveForSwipeY: function animateMoveForSwipeY(e, animateOptions, beginOptions, completeOptions) {
    var distance = this.getInertiaDistance(e.velocityY);
    var duration = this.getInertiaDuration(e.velocityY);
    var y;

    if (this.movement.scroll) {
      y = this.currentPosition.scrollTop + distance;
    } else {
      y = "-=" + distance;

      if (this.snapping.hardStopAtBounds) {
        /*
        if (this.element.id === "decks-item-1-0") {
          console.log("----------");
          console.log("this.element.id", this.element.id);
          console.log("this.startPosition", this.startPosition);
          console.log("this.currentPosition", this.currentPosition);
          console.log("this.parentPosition", this.parentPosition);
          console.log("this.bounds", this.bounds);
          console.log("distance", distance);
        }
        */
        if (distance > 0) {
          // If moving top, stop the element at the top bounds
          if (this.currentPosition.top - distance < this.bounds.top) {
            y = this.bounds.top - this.parentPosition.top;
            duration = duration * (this.currentPosition.top - this.bounds.top) / distance;
          }
        } else {
          // If moving , stop the element at the bottom bounds
          if (this.currentPosition.bottom - distance > this.bounds.bottom) {
            y = this.bounds.bottom - this.currentPosition.width - this.parentPosition.top;
            duration = duration * (this.bounds.bottom - this.currentPosition.bottom) / -distance;
          }
        }
      }
    }

    _.extend(animateOptions, this.inertia.animateOptions, {
      duration: duration
    });

    _.extend(completeOptions, {
      snapToBounds: false,
      snapToNearestChildElement: false,
      clearPositionData: true
    });

    this.animateMoveY(y, animateOptions, beginOptions, completeOptions);
  },

  /**
   * Animates a movement in the horizontal direction.
   *
   * @param x
   * @param animateOptions
   * @param beginOptions
   * @param completeOptions
   * @return {undefined}
   */
  animateMoveX: function animateMoveX(x, animateOptions, beginOptions, completeOptions) {
    this.animateMoveXOrY(x, "x", animateOptions, beginOptions, completeOptions);
  },

  /**
   * Animates a movement in the vertical direction.
   *
   * @param y
   * @param animateOptions
   * @param beginOptions
   * @param completeOptions
   * @return {undefined}
   */
  animateMoveY: function animateMoveY(y, animateOptions, beginOptions, completeOptions) {
    this.animateMoveXOrY(y, "y", animateOptions, beginOptions, completeOptions);
  },

  /**
   * Animates a movement in the horizontal and vertical directions.
   *
   * @param x
   * @param y
   * @param animateOptions
   * @param beginOptions
   * @param completeOptions
   * @return {undefined}
   */
  animateMoveXAndY: function animateMoveXAndY(x, y, animateOptions, beginOptions, completeOptions) {
    completeOptions.waitForXAndY = true;
    this.animateMoveX(x, animateOptions, beginOptions, completeOptions);
    this.animateMoveY(y, animateOptions, beginOptions, completeOptions);
  },

  /**
   * Animates a move in the horizontal or vertical direction (based on axis parameter)
   *
   * @param value
   * @param axis
   * @param animateOptions
   * @param beginOptions
   * @param completeOptions
   * @return {undefined}
   */
  animateMoveXOrY: function animateMoveXOrY(value, axis, animateOptions, beginOptions, completeOptions) {
    var self = this;
    var transform;

    if (self.movement.scroll) {
      transform = "scroll";
      animateOptions.offset = value;
      animateOptions.axis = axis;
      animateOptions.container = self.containerElement;
    } else {
      transform = {};
      if (axis === "x") {
        transform.left = value;
      } else {
        transform.top = value;
      }
    }

    // If waiting for x and y, wait for 2 invocations of the complete function
    // before actually calling it
    completeOptions.callCount = 0;

    animateOptions = _.extend({
      queue: false, // Don't queue any movement animations, they need to be immediate (or in parallel), and not queued to run in series
      complete: function() {
        if (completeOptions.waitForXAndY) {
          completeOptions.callCount++;
          if (completeOptions.callCount < 2) {
            return;
          }
        }
        self.onAnimationComplete(completeOptions);
      }
    }, this.movement.animateOptions, animateOptions);

    // TODO: killing this event for now - it's probably overkill
    /*
    if (!beginOptions.silent) {
      this.emit(DecksEvent("gesture:element:moving", this, this.element));
    }
    */

    self.animationCount++;
    self.animator.animate(self.element, transform, animateOptions);
  },

  /**
   * Gets the distance to travel for an inertial movement.
   *
   * @param velocity
   * @return {undefined}
   */
  getInertiaDistance: function getInertiaDistance(velocity) {
    return this.inertia.distanceScale * velocity;
  },

  /**
   * Gets the animation duration for an inertial movement.
   *
   * @param velocity
   * @return {undefined}
   */
  getInertiaDuration: function getInertiaDuration(velocity) {
    return Math.abs(this.inertia.durationScale * velocity);
  },

  /**
   * Animates a movement to reset the element to its origin position (0, 0).
   *
   * @return {undefined}
   */
  resetPosition: function resetPosition() {
    var animateOptions = {};
    var beginOptions = {};
    var completeOptions = {
      description: "reset position",
      waitForXAndY: true,
      snapToBounds: false,
      snapToNearestChildElement: false,
      clearPositionData: true
    };

    this.animateMoveXAndY(0, 0, animateOptions, beginOptions, completeOptions);
  },

  /**
   * Animates a movement to move the element to a position near the given child element.
   *
   * @param element
   * @param animateOptions
   * @param beginOptions
   * @param completeOptions
   * @return {undefined}
   */
  animateMoveToElement: function animateMoveToElement(element, animateOptions, beginOptions, completeOptions) {
    validate(element, "GestureHandler#animateMoveToElement: element", { isElement: true });

    var left = dom.getStyle(element, "left", { parseFloat: true });
    var top = dom.getStyle(element, "top", { parseFloat: true });

    var offsets = this.getMoveToElementOffsets(element);
    var x = left + offsets.x;
    var y = top + offsets.y;

    animateOptions = _.extend({}, animateOptions);
    beginOptions = _.extend({}, beginOptions);
    completeOptions = _.extend({
      description: "animateMoveToElement",
      waitForXAndY: true,
      snapToBounds: true,
      snapToNearestChildElement: false,
      clearPositionData: true,
      event: DecksEvent("gesture:moved:to:element", this, element)
    }, completeOptions);

    this.animateMoveXAndY(x, y, animateOptions, beginOptions, completeOptions);
  },

  /**
   * Snaps the element's position back to within its movement boundary.
   *
   * @return {undefined}
   */
  snapToBounds: function snapToBounds() {
    var self = this;

    // If we don't have container bounds, we can't snap to anything.
    // If we are moving by scrolling, we can't snap, because the browser doesn't let you pull the element inside the bounds.
    if (!self.bounds || self.movement.scroll) {
      return;
    }

    if (this.config.debugGestures) {
      console.log("snap bounds");
    }

    var x;
    if (this.currentPosition.left > self.bounds.left) {
      //x = 0;
      x = self.bounds.left - self.parentPosition.left;
    } else if (this.currentPosition.right < self.bounds.right) {
      x = "+=" + (self.bounds.right - this.currentPosition.right);
    }

    var y;
    if (this.currentPosition.top > self.bounds.top) {
      //y = 0;
      y = self.bounds.top - self.parentPosition.top;
    } else if (this.currentPosition.bottom < self.bounds.bottom) {
      y = "+=" + (self.bounds.bottom - this.currentPosition.bottom);
    }

    var animateOptions = _.extend({}, this.snapping.animateOptions);
    var beginOptions = {};
    var completeOptions = {
      description: "snap to bounds",
      snapToBounds: false,
      snapToNearestChildElement: false,
      clearPositionData: true,
      event: DecksEvent("gesture:snapped:to:container:bounds", this, this.element)
    };

    if (!_.isUndefined(x) && !_.isUndefined(y)) {
      this.animateMoveXAndY(x, y, animateOptions, beginOptions, completeOptions);
    } else if (!_.isUndefined(x)) {
      this.animateMoveX(x, animateOptions, beginOptions, completeOptions);
    } else if (!_.isUndefined(y)) {
      this.animateMoveY(y, animateOptions, beginOptions, completeOptions);
    }
  },

  /**
   * Snaps the element's position to a nearby child element.
   *
   * @return {undefined}
   */
  snapToNearestChildElement: function snapToNearestChildElement() {
    if (!this.snapping.toNearestChildElement || !_.isString(this.snapping.childElementSelector) || !this.bounds) {
      return;
    }

    if (this.config.debugGestures) {
      console.log("snap to nearest child element");
    }

    var childElements = dom.query(this.snapping.childElementSelector, this.containerElement);
    var nearestChildElement = dom.nearest(this.bounds, childElements, { ignoreInvisibleElements: true });

    var animateOptions = _.extend({}, this.snapping.animateOptions);
    var beginOptions = {};
    var completeOptions = {
      description: "snap to nearest child element",
      snapToBounds: true,
      snapToNearestChildElement: false,
      clearPositionData: true
    };

    this.animateMoveToElement(nearestChildElement, animateOptions, beginOptions, completeOptions);
  },

  /**
   * Sets a flag that indicates that the element is swiping.
   *
   * Flag is automatically cleared after a configurable timeout.
   *
   * @return {undefined}
   */
  setSwiping: function setSwiping() {
    var self = this;
    self.isSwiping = true;
    _.delay(function() {
      self.isSwiping = false;
    }, self.movement.swipingTimeout);
  },

  /**
   * Called when a movement animation is complete.
   *
   * @param options
   * @return {undefined}
   */
  onAnimationComplete: function onAnimationComplete(options) {
    var self = this;

    if (this.config.debugGestures) {
      console.log("complete: " + options.description + " " + this.element.id);
    }

    this.isPanningAny = false;
    this.isPanningX = false;
    this.isPanningY = false;

    // If waitForXAndY, two animations must complete before this method is called,
    // so decrement by 2.  Otherwise, decrement by 1.
    this.animationCount -= (options.waitForXAndY ? 2 : 1);

    _.defer(function() {
      // Snapping to nearest child gets precedence over snapping to bounds.
      // Snapping to bounds might be called after snapping to nearest child completes.
      if (options.snapToNearestChildElement) {
        self.snapToNearestChildElement();
      } else if (options.snapToBounds) {
        self.snapToBounds();
      }

      if (options.clearPositionData) {
        self.clearPositionData();
      }

      if (!options.silent) {
        if (options.event) {
          self.emit(options.event);
        }
        self.emit(DecksEvent("gesture:element:moved", self, self.element));
      }
    });
  },

  /**
   * Called when a pan gestures is started.
   *
   * @param e
   * @return {undefined}
   */
  onGesturePanStart: function onGesturePanStart(e, options) {
    options = options || {};
    var element = options.elementOverride || e.sender.element;
    if (element !== this.element) {
      return;
    }

    if (this.config.debugGestures) {
      console.log("pan start", this.element.id);
    }

    this.updatePositionData(e.data);
  },

  /**
   * Called when a pan gesture is detected in any direction.
   *
   * @param e
   * @return {undefined}
   */
  onGesturePanAny: function onGesturePanAny(e, options) {
    options = options || {};
    var element = options.elementOverride || e.sender.element;
    if (element !== this.element || this.isPanningAny || this.isSwiping) {
      return;
    }

    this.isPanningAny = true;

    if (this.config.debugGestures) {
      console.log("pan any", this.element.id);
    }

    if (this.isAnimating()) {
      this.stopAnimation();
      this.clearPositionData();
    }

    this.updatePositionData(e.data);

    var animateOptions = {};
    var beginOptions = {};
    var completeOptions = {
      description: "pan any",
      waitForXAndY: true
    };

    this.animateMoveForPan(e.data, animateOptions, beginOptions, completeOptions);
  },

  /**
   * Called when a pan gesture is detected in the horizontal direction.
   *
   * @param e
   * @return {undefined}
   */
  onGesturePanX: function onGesturePanX(e, options) {
    options = options || {};
    var element = options.elementOverride || e.sender.element;
    if (element !== this.element || this.isPanningX || this.isSwiping) {
      return;
    }

    this.isPanningX = true;

    if (this.config.debugGestures) {
      console.log("pan x", this.element.id);
    }

    if (this.isAnimating()) {
      this.stopAnimation();
      this.clearPositionData();
    }

    this.updatePositionData(e.data);

    var animateOptions = {};
    var beginOptions = {};
    var completeOptions = {
      description: "pan x",
      waitForXAndY: false
    };

    this.animateMoveForPanX(e.data, animateOptions, beginOptions, completeOptions);
  },

  /**
   * Called when a pan gesture is detected in the vertical direction.
   *
   * @param e
   * @return {undefined}
   */
  onGesturePanY: function onGesturePanY(e, options) {
    options = options || {};
    var element = options.elementOverride || e.sender.element;
    if (element !== this.element || this.isPanningY || this.isSwiping) {
      return;
    }

    this.isPanningY = true;

    if (this.config.debugGestures) {
      console.log("pan y", this.element.id);
    }

    if (this.isAnimating()) {
      this.stopAnimation();
      this.clearPositionData();
    }

    this.updatePositionData(e.data);

    var animateOptions = {};
    var beginOptions = {};
    var completeOptions = {
      description: "pan y",
      waitForXAndY: false
    };

    this.animateMoveForPanY(e.data, animateOptions, beginOptions, completeOptions);
  },

  onGesturePanEnd: function onGesturePanEnd(e, options) {
    var self = this;
    options = options || {};
    var element = options.elementOverride || e.sender.element;
    if (element !== this.element || this.isSwiping) {
      return;
    }

    // Defer the completion of the pan for one tick, because sometimes the latest animation needs to finish
    _.defer(function() {
      if (self.config.debugGestures) {
        console.log("pan end: %s (is animating: %s)", self.element.id, self.isAnimating());
      }

      if (!self.isAnimating()) {
        self.clearPositionData();
      }
    });
  },

  onGesturePanCancel: function onGesturePanCancel(e, options) {
    options = options || {};
    var element = options.elementOverride || e.sender.element;
    if (element !== this.element) {
      return;
    }

    if (this.config.debugGestures) {
      console.log("pan cancel", this.element.id);
    }

    if (this.isAnimating()) {
      this.stopAnimation();
      this.clearPositionData();
    }
  },

  onGestureSwipeAny: function onGestureSwipeAny(e, options) {
    options = options || {};
    var element = options.elementOverride || e.sender.element;
    if (element !== this.element) {
      return;
    }
    this.setSwiping();
    this.stopAnimation();

    if (this.config.debugGestures) {
      console.log("swipe any", this.element.id);
    }

    var animateOptions = {};
    var beginOptions = {};
    var completeOptions = {
      description: "swipe any",
      waitForXAndY: true
    };

    this.animateMoveForSwipe(e.data, animateOptions, beginOptions, completeOptions);
  },

  onGestureSwipeX: function onGestureSwipeX(e, options) {
    options = options || {};
    var element = options.elementOverride || e.sender.element;
    if (element !== this.element) {
      return;
    }
    this.setSwiping();
    this.stopAnimation();

    if (this.config.debugGestures) {
      console.log("swipe x", this.element.id);
    }

    var animateOptions = {};
    var beginOptions = {};
    var completeOptions = {
      description: "swipe x",
      waitForXAndY: false
    };

    this.animateMoveForSwipeX(e.data, animateOptions, beginOptions, completeOptions);
  },

  onGestureSwipeY: function onGestureSwipeY(e, options) {
    options = options || {};
    var element = options.elementOverride || e.sender.element;
    if (element !== this.element) {
      return;
    }
    this.setSwiping();
    this.stopAnimation();

    if (this.config.debugGestures) {
      console.log("swipe y", this.element.id);
    }

    var animateOptions = {};
    var beginOptions = {};
    var completeOptions = {
      description: "swipe y",
      waitForXAndY: false
    };
    this.animateMoveForSwipeY(e.data, animateOptions, beginOptions, completeOptions);
  },

  onGestureTap: function onGestureTap(e, options) {
    options = options || {};
    var element = options.elementOverride || e.sender.element;
    if (element !== this.element) {
      return;
    }

    if (this.config.debugGestures) {
      console.log("tap", this.element.id);
    }

    this.stopAnimation();
    this.clearPositionData();
  },

  onGesturePress: function onGesturePress(e, options) {
    options = options || {};
    var element = options.elementOverride || e.sender.element;
    if (element !== this.element) {
      return;
    }

    if (this.config.debugGestures) {
      console.log("press", this.element.id);
    }

    this.stopAnimation();
    this.clearPositionData();
  },

  onGestureScroll: function onGestureScroll(e, options) {
    options = options || {};
    var element = options.elementOverride || e.sender.element;
    if (element !== this.containerElement) {
      return;
    }

    if (this.config.debugGestures) {
      console.log("scroll", this.containerElement);
    }

    this.emit(DecksEvent("gesture:element:moved", this, this.element));

    if (this.snapping.toNearestChildElement) {
      this.snapToNearestChildElement();
    } else if (this.snapping.toBounds) {
      this.snapToBounds();
    }
  }
});

module.exports = GestureHandler;