/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

// Simple gestures support
//
// As per bug #412486, web content must not be allowed to receive any
// simple gesture events.  Multi-touch gesture APIs are in their
// infancy and we do NOT want to be forced into supporting an API that
// will probably have to change in the future.  (The current Mac OS X
// API is undocumented and was reverse-engineered.)  Until support is
// implemented in the event dispatcher to keep these events as
// chrome-only, we must listen for the simple gesture events during
// the capturing phase and call stopPropagation on every event.

var gGestureSupport = {
  _currentRotation: 0,
  _lastRotateDelta: 0,
  _rotateMomentumThreshold: .75,
  _magnifierOverlay: null,
  _magnifierCanvas: null,
  _magnifierZoom: 1,
  _magnifierCenterX: 0,
  _magnifierCenterY: 0,
  _magnifierSourceBrowser: null,

  /**
   * Add or remove mouse gesture event listeners
   *
   * @param aAddListener
   *        True to add/init listeners and false to remove/uninit
   */
  init: function(aAddListener) {
    const gestureEvents = ["SwipeGestureMayStart", "SwipeGestureStart",
      "SwipeGestureUpdate", "SwipeGestureEnd", "SwipeGesture",
      "MagnifyGestureStart", "MagnifyGestureUpdate", "MagnifyGesture",
      "RotateGestureStart", "RotateGestureUpdate", "RotateGesture",
      "TapGesture", "PressTapGesture"];

    let addRemove = aAddListener ? window.addEventListener :
      window.removeEventListener;

    for (let event of gestureEvents) {
      addRemove("Moz" + event, this, true);
    }
  },

  /**
   * Dispatch events based on the type of mouse gesture event. For now, make
   * sure to stop propagation of every gesture event so that web content cannot
   * receive gesture events.
   *
   * @param aEvent
   *        The gesture event to handle
   */
  handleEvent: function(aEvent) {
    if (!Services.prefs.getBoolPref(
           "dom.debug.propagate_gesture_events_through_content")) {
      aEvent.stopPropagation();
    }

    // Create a preference object with some defaults
    let def = (aThreshold, aLatched) =>
      ({ threshold: aThreshold, latched: !!aLatched });

    switch (aEvent.type) {
      case "MozSwipeGestureMayStart":
        if (this._shouldDoSwipeGesture(aEvent)) {
          aEvent.preventDefault();
        }
        break;
      case "MozSwipeGestureStart":
        aEvent.preventDefault();
        this._setupSwipeGesture();
        break;
      case "MozSwipeGestureUpdate":
        aEvent.preventDefault();
        this._doUpdate(aEvent);
        break;
      case "MozSwipeGestureEnd":
        aEvent.preventDefault();
        this._doEnd(aEvent);
        break;
      case "MozSwipeGesture":
        aEvent.preventDefault();
        this.onSwipe(aEvent);
        break;
      case "MozMagnifyGestureStart":
        aEvent.preventDefault();
        let pinchPref = AppConstants.platform == "win"
                        ? def(25, 0)
                        : def(150, 1);
        this._setupPinchGesture(aEvent, pinchPref);
        break;
      case "MozRotateGestureStart":
        aEvent.preventDefault();
        this._setupGesture(aEvent, "twist", def(25, 0), "right", "left");
        break;
      case "MozMagnifyGestureUpdate":
      case "MozRotateGestureUpdate":
        aEvent.preventDefault();
        this._doUpdate(aEvent);
        break;
      case "MozTapGesture":
        aEvent.preventDefault();
        this._doAction(aEvent, ["tap"]);
        break;
      case "MozMagnifyGesture":
        aEvent.preventDefault();
        this._destroyMagnifierOverlay();
        break;
      case "MozRotateGesture":
        aEvent.preventDefault();
        this._doAction(aEvent, ["twist", "end"]);
        break;
      /* case "MozPressTapGesture":
        break; */
    }
  },

  /**
   * Called at the start of "pinch" and "twist" gestures to setup all of the
   * information needed to process the gesture
   *
   * @param aEvent
   *        The continual motion start event to handle
   * @param aGesture
   *        Name of the gesture to handle
   * @param aPref
   *        Preference object with the names of preferences and defaults
   * @param aInc
   *        Command to trigger for increasing motion (without gesture name)
   * @param aDec
   *        Command to trigger for decreasing motion (without gesture name)
   */
  _setupGesture: function(aEvent, aGesture, aPref, aInc, aDec) {
    // Try to load user-set values from preferences
    for (let [pref, def] of Object.entries(aPref))
      aPref[pref] = this._getPref(aGesture + "." + pref, def);

    // Keep track of the total deltas and latching behavior
    let offset = 0;
    let latchDir = aEvent.delta > 0 ? 1 : -1;
    let isLatched = false;

    // Create the update function here to capture closure state
    this._doUpdate = function GS__doUpdate(aEvent) {
      // Update the offset with new event data
      offset += aEvent.delta;

      // Check if the cumulative deltas exceed the threshold
      if (Math.abs(offset) > aPref["threshold"]) {
        // Trigger the action if we don't care about latching; otherwise, make
        // sure either we're not latched and going the same direction of the
        // initial motion; or we're latched and going the opposite way
        let sameDir = (latchDir ^ offset) >= 0;
        if (!aPref["latched"] || (isLatched ^ sameDir)) {
          this._doAction(aEvent, [aGesture, offset > 0 ? aInc : aDec]);

          // We must be getting latched or leaving it, so just toggle
          isLatched = !isLatched;
        }

        // Reset motion counter to prepare for more of the same gesture
        offset = 0;
      }
    };

    // The start event also contains deltas, so handle an update right away
    this._doUpdate(aEvent);
  },

  /**
   * Checks whether a swipe gesture event can navigate the browser history or
   * not.
   *
   * @param aEvent
   *        The swipe gesture event.
   * @return true if the swipe event may navigate the history, false othwerwise.
   */
  _swipeNavigatesHistory: function(aEvent) {
    return this._getCommand(aEvent, ["swipe", "left"])
              == "Browser:BackOrBackDuplicate" &&
           this._getCommand(aEvent, ["swipe", "right"])
              == "Browser:ForwardOrForwardDuplicate";
  },

  /**
   * Checks whether we want to start a swipe for aEvent and sets
   * aEvent.allowedDirections to the right values.
   *
   * @param aEvent
   *        The swipe gesture "MayStart" event.
   * @return true if we're willing to start a swipe for this event, false
   *         otherwise.
   */
  _shouldDoSwipeGesture: function(aEvent) {
    if (!this._swipeNavigatesHistory(aEvent)) {
      return false;
    }

    let isVerticalSwipe = false;
    if (aEvent.direction == aEvent.DIRECTION_UP) {
      if (gMultiProcessBrowser || content.pageYOffset > 0) {
        return false;
      }
      isVerticalSwipe = true;
    } else if (aEvent.direction == aEvent.DIRECTION_DOWN) {
      if (gMultiProcessBrowser || content.pageYOffset < content.scrollMaxY) {
        return false;
      }
      isVerticalSwipe = true;
    }
    if (isVerticalSwipe) {
      // Vertical overscroll has been temporarily disabled until bug 939480 is
      // fixed.
      return false;
    }

    let canGoBack = gHistorySwipeAnimation.canGoBack();
    let canGoForward = gHistorySwipeAnimation.canGoForward();
    let isLTR = gHistorySwipeAnimation.isLTR;

    if (canGoBack) {
      aEvent.allowedDirections |= isLTR ? aEvent.DIRECTION_LEFT :
                                          aEvent.DIRECTION_RIGHT;
    }
    if (canGoForward) {
      aEvent.allowedDirections |= isLTR ? aEvent.DIRECTION_RIGHT :
                                          aEvent.DIRECTION_LEFT;
    }

    return true;
  },

  /**
   * Sets up swipe gestures. This includes setting up swipe animations for the
   * gesture, if enabled.
   *
   * @param aEvent
   *        The swipe gesture start event.
   * @return true if swipe gestures could successfully be set up, false
   *         othwerwise.
   */
  _setupSwipeGesture: function() {
    gHistorySwipeAnimation.startAnimation(false);

    this._doUpdate = function(aEvent) {
      gHistorySwipeAnimation.updateAnimation(aEvent.delta);
    };

    this._doEnd = function(aEvent) {
      gHistorySwipeAnimation.swipeEndEventReceived();

      this._doUpdate = function (aEvent) {};
      this._doEnd = function (aEvent) {};
    }
  },

  /**
   * Generator producing the powerset of the input array where the first result
   * is the complete set and the last result (before StopIteration) is empty.
   *
   * @param aArray
   *        Source array containing any number of elements
   * @yield Array that is a subset of the input array from full set to empty
   */
  _power: function* (aArray) {
    // Create a bitmask based on the length of the array
    let num = 1 << aArray.length;
    while (--num >= 0) {
      // Only select array elements where the current bit is set
      yield aArray.reduce(function (aPrev, aCurr, aIndex) {
        if (num & 1 << aIndex)
          aPrev.push(aCurr);
        return aPrev;
      }, []);
    }
  },

  /**
   * Setup pinch gesture with full-screen magnifier overlay.
   *
   * @param aEvent
   *        The magnify gesture start event
   * @param aPref
   *        Preference object with threshold and latched settings
   */
  _setupPinchGesture: function(aEvent, aPref) {
    let browser = gBrowser.selectedBrowser;
    if (!browser || !browser.contentWindow) {
      return this._setupGesture(aEvent, "pinch", aPref, "out", "in");
    }

    this._magnifierSourceBrowser = browser;
    this._magnifierCenterX = aEvent.clientX || 0;
    this._magnifierCenterY = aEvent.clientY || 0;
    this._magnifierZoom = 1;
    this._createMagnifierOverlay();

    // Setup the gesture with magnifier handler
    for (let [pref, def] of Object.entries(aPref))
      aPref[pref] = this._getPref("pinch." + pref, def);

    let offset = 0;
    let latchDir = aEvent.delta > 0 ? 1 : -1;
    let isLatched = false;

    this._doUpdate = (aEvent) => {
      offset += aEvent.delta;
      if (Math.abs(offset) > aPref["threshold"]) {
        let sameDir = (latchDir ^ offset) >= 0;
        if (!aPref["latched"] || (isLatched ^ sameDir)) {
          this._updateMagnifier(offset > 0 ? 1.2 : 0.833);
          isLatched = !isLatched;
        }
        offset = 0;
      }
    };

    this._doUpdate(aEvent);
  },

  /**
   * Create full-screen magnifier overlay.
   */
  _createMagnifierOverlay: function() {
    if (this._magnifierOverlay)
      return;

    try {
      let overlay = document.createElement("div");
      overlay.id = "gGestureSupport-magnifier-overlay";
      overlay.style.cssText = `
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background: #000;
        z-index: 2147483647;
        overflow: hidden;
        margin: 0;
        padding: 0;
      `;

      let canvas = document.createElement("canvas");
      canvas.id = "gGestureSupport-magnifier-canvas";
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
      canvas.style.cssText = `
        position: absolute;
        top: 0;
        left: 0;
        display: block;
      `;

      overlay.appendChild(canvas);
      document.documentElement.appendChild(overlay);

      this._magnifierOverlay = overlay;
      this._magnifierCanvas = canvas;
      this._drawMagnifier();
    } catch (e) {
      Cu.reportError("Magnifier overlay creation failed: " + e);
    }
  },

  /**
   * Update magnifier zoom level and redraw.
   *
   * @param aZoomMultiplier
   *        Factor to multiply current zoom by (e.g., 1.2 for zoom in, 0.833 for zoom out)
   */
  _updateMagnifier: function(aZoomMultiplier) {
    let minZoom = 1;
    let maxZoom = 5;
    this._magnifierZoom = Math.max(minZoom, Math.min(maxZoom, this._magnifierZoom * aZoomMultiplier));
    this._drawMagnifier();
  },

  /**
   * Draw magnified content to canvas.
   */
  _drawMagnifier: function() {
    if (!this._magnifierOverlay || !this._magnifierCanvas || !this._magnifierSourceBrowser)
      return;

    try {
      let browser = this._magnifierSourceBrowser;
      let canvas = this._magnifierCanvas;

      let ctx = canvas.getContext("2d");
      if (!ctx) return;

      // Fill background black
      ctx.fillStyle = "#000000";
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      // Save context state
      ctx.save();

      // Calculate zoom magnification
      let zoomLevel = this._magnifierZoom;
      let centerX = this._magnifierCenterX;
      let centerY = this._magnifierCenterY;

      // Apply magnification transform centered on gesture point
      ctx.translate(canvas.width / 2, canvas.height / 2);
      ctx.scale(zoomLevel, zoomLevel);
      ctx.translate(-centerX, -centerY);

      // Try to draw the window content
      try {
        let viewer = browser.markupDocumentViewer;
        let pageZoom = viewer ? (viewer.fullZoom || 1) : 1;
        ctx.scale(pageZoom, pageZoom);

        ctx.drawWindow(
          browser.contentWindow,
          0, 0,
          browser.contentWindow.innerWidth,
          browser.contentWindow.innerHeight,
          "rgb(0,0,0)",
          ctx.DRAWWINDOW_DO_NOT_FLUSH | ctx.DRAWWINDOW_DRAW_VIEW | ctx.DRAWWINDOW_USE_WIDGET_LAYERS
        );
      } catch (e) {
        // drawWindow may fail, just show black background
        Cu.reportError("drawWindow failed: " + e);
      }

      ctx.restore();
    } catch (e) {
      Cu.reportError("_drawMagnifier error: " + e);
    }
  },

  /**
   * Hide and destroy magnifier overlay.
   */
  _destroyMagnifierOverlay: function() {
    if (this._magnifierOverlay && this._magnifierOverlay.parentNode)
      this._magnifierOverlay.parentNode.removeChild(this._magnifierOverlay);
    this._magnifierOverlay = null;
    this._magnifierCanvas = null;
    this._magnifierSourceBrowser = null;
  },

  /**
   * Determine what action to do for the gesture based on which keys are
   * pressed and which commands are set, and execute the command.
   *
   * @param aEvent
   *        The original gesture event to convert into a fake click event
   * @param aGesture
   *        Array of gesture name parts (to be joined by periods)
   * @return Name of the executed command. Returns null if no command is
   *         found.
   */
  _doAction: function(aEvent, aGesture) {
    let command = this._getCommand(aEvent, aGesture);
    return command && this._doCommand(aEvent, command);
  },

  /**
   * Determine what action to do for the gesture based on which keys are
   * pressed and which commands are set
   *
   * @param aEvent
   *        The original gesture event to convert into a fake click event
   * @param aGesture
   *        Array of gesture name parts (to be joined by periods)
   */
  _getCommand: function(aEvent, aGesture) {
    // Create an array of pressed keys in a fixed order so that a command for
    // "meta" is preferred over "ctrl" when both buttons are pressed (and a
    // command for both don't exist)
    let keyCombos = [];
    for (let key of ["shift", "alt", "ctrl", "meta"]) {
      if (aEvent[key + "Key"])
        keyCombos.push(key);
    }

    // Try each combination of key presses in decreasing order for commands
    for (let subCombo of this._power(keyCombos)) {
      // Convert a gesture and pressed keys into the corresponding command
      // action where the preference has the gesture before "shift" before
      // "alt" before "ctrl" before "meta" all separated by periods
      let command;
      try {
        command = this._getPref(aGesture.concat(subCombo).join("."));
      } catch (e) {}

      if (command)
        return command;
    }
    return null;
  },

  /**
   * Execute the specified command.
   *
   * @param aEvent
   *        The original gesture event to convert into a fake click event
   * @param aCommand
   *        Name of the command found for the event's keys and gesture.
   */
  _doCommand: function(aEvent, aCommand) {
    let node = document.getElementById(aCommand);
    if (node) {
      if (node.getAttribute("disabled") != "true") {
        let cmdEvent = document.createEvent("xulcommandevent");
        cmdEvent.initCommandEvent("command", true, true, window, 0,
                                  aEvent.ctrlKey, aEvent.altKey,
                                  aEvent.shiftKey, aEvent.metaKey, aEvent);
        node.dispatchEvent(cmdEvent);
      }

    }
    else {
      goDoCommand(aCommand);
    }
  },

  /**
   * Handle continual motion events.  This function will be set by
   * _setupGesture or _setupSwipe.
   *
   * @param aEvent
   *        The continual motion update event to handle
   */
  _doUpdate: function(aEvent) {},

  /**
   * Handle gesture end events.  This function will be set by _setupSwipe.
   *
   * @param aEvent
   *        The gesture end event to handle
   */
  _doEnd: function(aEvent) {},

  /**
   * Convert the swipe gesture into a browser action based on the direction.
   *
   * @param aEvent
   *        The swipe event to handle
   */
  onSwipe: function(aEvent) {
    // Figure out which one (and only one) direction was triggered
    for (let dir of ["UP", "RIGHT", "DOWN", "LEFT"]) {
      if (aEvent.direction == aEvent["DIRECTION_" + dir]) {
        this._coordinateSwipeEventWithAnimation(aEvent, dir);
        break;
      }
    }
  },

  /**
   * Process a swipe event based on the given direction.
   *
   * @param aEvent
   *        The swipe event to handle
   * @param aDir
   *        The direction for the swipe event
   */
  processSwipeEvent: function(aEvent, aDir) {
    this._doAction(aEvent, ["swipe", aDir.toLowerCase()]);
  },

  /**
   * Coordinates the swipe event with the swipe animation, if any.
   * If an animation is currently running, the swipe event will be
   * processed once the animation stops. This will guarantee a fluid
   * motion of the animation.
   *
   * @param aEvent
   *        The swipe event to handle
   * @param aDir
   *        The direction for the swipe event
   */
  _coordinateSwipeEventWithAnimation:
  function GS__coordinateSwipeEventWithAnimation(aEvent, aDir) {
    if ((gHistorySwipeAnimation.isAnimationRunning()) &&
        (aDir == "RIGHT" || aDir == "LEFT")) {
      gHistorySwipeAnimation.processSwipeEvent(aEvent, aDir);
    }
    else {
      this.processSwipeEvent(aEvent, aDir);
    }
  },

  /**
   * Get a gesture preference or use a default if it doesn't exist
   *
   * @param aPref
   *        Name of the preference to load under the gesture branch
   * @param aDef
   *        Default value if the preference doesn't exist
   */
  _getPref: function(aPref, aDef) {
    // Preferences branch under which all gestures preferences are stored
    const branch = "browser.gesture.";

    try {
      // Determine what type of data to load based on default value's type
      let type = typeof aDef;
      let getFunc = "Char";
      if (type == "boolean")
        getFunc = "Bool";
      else if (type == "number")
        getFunc = "Int";
      return gPrefService["get" + getFunc + "Pref"](branch + aPref);
    }
    catch (e) {
      return aDef;
    }
  },

  /**
   * Perform rotation for ImageDocuments
   *
   * @param aEvent
   *        The MozRotateGestureUpdate event triggering this call
   */
  rotate: function(aEvent) {
    if (!(content.document instanceof ImageDocument))
      return;

    let contentElement = content.document.body.firstElementChild;
    if (!contentElement)
      return;
    // If we're currently snapping, cancel that snap
    if (contentElement.classList.contains("completeRotation"))
      this._clearCompleteRotation();

    this.rotation = Math.round(this.rotation + aEvent.delta);
    contentElement.style.transform = "rotate(" + this.rotation + "deg)";
    this._lastRotateDelta = aEvent.delta;
  },

  /**
   * Perform a rotation end for ImageDocuments
   */
  rotateEnd: function() {
    if (!(content.document instanceof ImageDocument))
      return;

    let contentElement = content.document.body.firstElementChild;
    if (!contentElement)
      return;

    let transitionRotation = 0;

    // The reason that 360 is allowed here is because when rotating between
    // 315 and 360, setting rotate(0deg) will cause it to rotate the wrong
    // direction around--spinning wildly.
    if (this.rotation <= 45)
      transitionRotation = 0;
    else if (this.rotation > 45 && this.rotation <= 135)
      transitionRotation = 90;
    else if (this.rotation > 135 && this.rotation <= 225)
      transitionRotation = 180;
    else if (this.rotation > 225 && this.rotation <= 315)
      transitionRotation = 270;
    else
      transitionRotation = 360;

    // If we're going fast enough, and we didn't already snap ahead of rotation,
    // then snap ahead of rotation to simulate momentum
    if (this._lastRotateDelta > this._rotateMomentumThreshold &&
        this.rotation > transitionRotation)
      transitionRotation += 90;
    else if (this._lastRotateDelta < -1 * this._rotateMomentumThreshold &&
             this.rotation < transitionRotation)
      transitionRotation -= 90;

    // Only add the completeRotation class if it is is necessary
    if (transitionRotation != this.rotation) {
      contentElement.classList.add("completeRotation");
      contentElement.addEventListener("transitionend", this._clearCompleteRotation);
    }

    contentElement.style.transform = "rotate(" + transitionRotation + "deg)";
    this.rotation = transitionRotation;
  },

  /**
   * Gets the current rotation for the ImageDocument
   */
  get rotation() {
    return this._currentRotation;
  },

  /**
   * Sets the current rotation for the ImageDocument
   *
   * @param aVal
   *        The new value to take.  Can be any value, but it will be bounded to
   *        0 inclusive to 360 exclusive.
   */
  set rotation(aVal) {
    this._currentRotation = aVal % 360;
    if (this._currentRotation < 0)
      this._currentRotation += 360;
    return this._currentRotation;
  },

  /**
   * When the location/tab changes, need to reload the current rotation for the
   * image
   */
  restoreRotationState: function() {
    // Bug 863514 - Make gesture support work in electrolysis
    if (gMultiProcessBrowser)
      return;

    if (!(content.document instanceof ImageDocument))
      return;

    let contentElement = content.document.body.firstElementChild;
    let transformValue = content.window.getComputedStyle(contentElement, null)
                                       .transform;

    if (transformValue == "none") {
      this.rotation = 0;
      return;
    }

    // transformValue is a rotation matrix--split it and do mathemagic to
    // obtain the real rotation value
    transformValue = transformValue.split("(")[1]
                                   .split(")")[0]
                                   .split(",");
    this.rotation = Math.round(Math.atan2(transformValue[1], transformValue[0]) *
                               (180 / Math.PI));
  },

  /**
   * Removes the transition rule by removing the completeRotation class
   */
  _clearCompleteRotation: function() {
    let contentElement = content.document &&
                         content.document instanceof ImageDocument &&
                         content.document.body &&
                         content.document.body.firstElementChild;
    if (!contentElement)
      return;
    contentElement.classList.remove("completeRotation");
    contentElement.removeEventListener("transitionend", this._clearCompleteRotation);
  },
};

// History Swipe Animation Support (bug 678392)
var gHistorySwipeAnimation = {

  active: false,
  isLTR: false,

  /**
   * Initializes the support for history swipe animations, if it is supported
   * by the platform/configuration.
   */
  init: function() {
    if (!this._isSupported())
      return;

    this.active = false;
    this.isLTR = document.documentElement.matches(":-moz-locale-dir(ltr)");
    this._trackedSnapshots = [];
    this._startingIndex = -1;
    this._historyIndex = -1;
    this._boxWidth = -1;
    this._boxHeight = -1;
    this._maxSnapshots = this._getMaxSnapshots();
    this._lastSwipeDir = "";
    this._direction = "horizontal";

    // We only want to activate history swipe animations if we store snapshots.
    // If we don't store any, we handle horizontal swipes without animations.
    if (this._maxSnapshots > 0) {
      this.active = true;
      gBrowser.addEventListener("pagehide", this, false);
      gBrowser.addEventListener("pageshow", this, false);
      gBrowser.addEventListener("popstate", this, false);
      gBrowser.addEventListener("DOMModalDialogClosed", this, false);
      gBrowser.tabContainer.addEventListener("TabClose", this, false);
    }
  },

  /**
   * Uninitializes the support for history swipe animations.
   */
  uninit: function() {
    gBrowser.removeEventListener("pagehide", this, false);
    gBrowser.removeEventListener("pageshow", this, false);
    gBrowser.removeEventListener("popstate", this, false);
    gBrowser.removeEventListener("DOMModalDialogClosed", this, false);
    gBrowser.tabContainer.removeEventListener("TabClose", this, false);

    this.active = false;
    this.isLTR = false;
  },

  /**
   * Starts the swipe animation and handles fast swiping (i.e. a swipe animation
   * is already in progress when a new one is initiated).
   *
   * @param aIsVerticalSwipe
   *        Whether we're dealing with a vertical swipe or not.
   */
  startAnimation: function(aIsVerticalSwipe) {
    this._direction = aIsVerticalSwipe ? "vertical" : "horizontal";

    if (this.isAnimationRunning()) {
      // If this is a horizontal scroll, or if this is a vertical scroll that
      // was started while a horizontal scroll was still running, handle it as
      // as a fast swipe. In the case of the latter scenario, this allows us to
      // start the vertical animation without first loading the final page, or
      // taking another snapshot. If vertical scrolls are initiated repeatedly
      // without prior horizontal scroll we skip this and restart the animation
      // from 0.
      if (this._direction == "horizontal" || this._lastSwipeDir != "") {
        gBrowser.stop();
        this._lastSwipeDir = "RELOAD"; // just ensure that != ""
        this._canGoBack = this.canGoBack();
        this._canGoForward = this.canGoForward();
        this._handleFastSwiping();
      }
      this.updateAnimation(0);
    }
    else {
      // Get the session history from SessionStore.
      let updateSessionHistory = sessionHistory => {
        this._startingIndex = sessionHistory.index;
        this._historyIndex = this._startingIndex;
        this._canGoBack = this.canGoBack();
        this._canGoForward = this.canGoForward();
        if (this.active) {
          this._addBoxes();
          this._takeSnapshot();
          this._installPrevAndNextSnapshots();
          this._lastSwipeDir = "";
        }
        this.updateAnimation(0);
      }
      SessionStore.getSessionHistory(gBrowser.selectedTab, updateSessionHistory);
    }
  },

  /**
   * Stops the swipe animation.
   */
  stopAnimation: function() {
    gHistorySwipeAnimation._removeBoxes();
    this._historyIndex = this._getCurrentHistoryIndex();
  },

  /**
   * Updates the animation between two pages in history.
   *
   * @param aVal
   *        A floating point value that represents the progress of the
   *        swipe gesture.
   */
  updateAnimation: function(aVal) {
    if (!this.isAnimationRunning()) {
      return;
    }

    // We use the following value to decrease the bounce effect when scrolling
    // to the top or bottom of the page, or when swiping back/forward past the
    // browsing history. This value was determined experimentally.
    let dampValue = 4;
    if (this._direction == "vertical") {
      this._prevBox.collapsed = true;
      this._nextBox.collapsed = true;
      this._positionBox(this._curBox, -1 * aVal / dampValue);
    } else if ((aVal >= 0 && this.isLTR) ||
               (aVal <= 0 && !this.isLTR)) {
      let tempDampValue = 1;
      if (this._canGoBack) {
        this._prevBox.collapsed = false;
      } else {
        tempDampValue = dampValue;
        this._prevBox.collapsed = true;
      }

      // The current page is pushed to the right (LTR) or left (RTL),
      // the intention is to go back.
      // If there is a page to go back to, it should show in the background.
      this._positionBox(this._curBox, aVal / tempDampValue);

      // The forward page should be pushed offscreen all the way to the right.
      this._positionBox(this._nextBox, 1);
    } else if (this._canGoForward) {
      // The intention is to go forward. If there is a page to go forward to,
      // it should slide in from the right (LTR) or left (RTL).
      // Otherwise, the current page should slide to the left (LTR) or
      // right (RTL) and the backdrop should appear in the background.
      // For the backdrop to be visible in that case, the previous page needs
      // to be hidden (if it exists).
      this._nextBox.collapsed = false;
      let offset = this.isLTR ? 1 : -1;
      this._positionBox(this._curBox, 0);
      this._positionBox(this._nextBox, offset + aVal);
    } else {
      this._prevBox.collapsed = true;
      this._positionBox(this._curBox, aVal / dampValue);
    }
  },

  _getCurrentHistoryIndex: function() {
    return SessionStore.getSessionHistory(gBrowser.selectedTab).index;
  },

  /**
   * Event handler for events relevant to the history swipe animation.
   *
   * @param aEvent
   *        An event to process.
   */
  handleEvent: function(aEvent) {
    let browser = gBrowser.selectedBrowser;
    switch (aEvent.type) {
      case "TabClose":
        let browserForTab = gBrowser.getBrowserForTab(aEvent.target);
        this._removeTrackedSnapshot(-1, browserForTab);
        break;
      case "DOMModalDialogClosed":
        this.stopAnimation();
        break;
      case "pageshow":
        if (aEvent.target == browser.contentDocument) {
          this.stopAnimation();
        }
        break;
      case "popstate":
        if (aEvent.target == browser.contentDocument.defaultView) {
          this.stopAnimation();
        }
        break;
      case "pagehide":
        if (aEvent.target == browser.contentDocument) {
          // Take and compress a snapshot of a page whenever it's about to be
          // navigated away from. We already have a snapshot of the page if an
          // animation is running, so we're left with compressing it.
          if (!this.isAnimationRunning()) {
            this._takeSnapshot();
          }
          this._compressSnapshotAtCurrentIndex();
        }
        break;
    }
  },

  /**
   * Checks whether the history swipe animation is currently running or not.
   *
   * @return true if the animation is currently running, false otherwise.
   */
  isAnimationRunning: function() {
    return !!this._container;
  },

  /**
   * Process a swipe event based on the given direction.
   *
   * @param aEvent
   *        The swipe event to handle
   * @param aDir
   *        The direction for the swipe event
   */
  processSwipeEvent: function(aEvent, aDir) {
    if (aDir == "RIGHT")
      this._historyIndex += this.isLTR ? 1 : -1;
    else if (aDir == "LEFT")
      this._historyIndex += this.isLTR ? -1 : 1;
    else
      return;
    this._lastSwipeDir = aDir;
  },

  /**
   * Checks if there is a page in the browser history to go back to.
   *
   * @return true if there is a previous page in history, false otherwise.
   */
  canGoBack: function() {
    if (this.isAnimationRunning())
      return this._doesIndexExistInHistory(this._historyIndex - 1);
    return gBrowser.webNavigation.canGoBack;
  },

  /**
   * Checks if there is a page in the browser history to go forward to.
   *
   * @return true if there is a next page in history, false otherwise.
   */
  canGoForward: function() {
    if (this.isAnimationRunning())
      return this._doesIndexExistInHistory(this._historyIndex + 1);
    return gBrowser.webNavigation.canGoForward;
  },

  /**
   * Used to notify the history swipe animation that the OS sent a swipe end
   * event and that we should navigate to the page that the user swiped to, if
   * any. This will also result in the animation overlay to be torn down.
   */
  swipeEndEventReceived: function() {
    // Update the session history before continuing.
    let updateSessionHistory = sessionHistory => {
      if (this._lastSwipeDir != "" && this._historyIndex != this._startingIndex)
        this._navigateToHistoryIndex();
      else
        this.stopAnimation();
    }
    SessionStore.getSessionHistory(gBrowser.selectedTab, updateSessionHistory);
  },

  /**
   * Checks whether a particular index exists in the browser history or not.
   *
   * @param aIndex
   *        The index to check for availability for in the history.
   * @return true if the index exists in the browser history, false otherwise.
   */
  _doesIndexExistInHistory: function(aIndex) {
    try {
      return SessionStore.getSessionHistory(gBrowser.selectedTab).entries[aIndex] != null;
    }
    catch (ex) {
      return false;
    }
  },

  /**
   * Navigates to the index in history that is currently being tracked by
   * |this|.
   */
  _navigateToHistoryIndex: function() {
    if (this._doesIndexExistInHistory(this._historyIndex))
      gBrowser.webNavigation.gotoIndex(this._historyIndex);
    else
      this.stopAnimation();
  },

  /**
   * Checks to see if history swipe animations are supported by this
   * platform/configuration.
   *
   * return true if supported, false otherwise.
   */
  _isSupported: function() {
    return window.matchMedia("(-moz-swipe-animation-enabled)").matches;
  },

  /**
   * Handle fast swiping (i.e. a swipe animation is already in
   * progress when a new one is initiated). This will swap out the snapshots
   * used in the previous animation with the appropriate new ones.
   */
  _handleFastSwiping: function() {
    this._installCurrentPageSnapshot(null);
    this._installPrevAndNextSnapshots();
  },

  /**
   * Adds the boxes that contain the snapshots used during the swipe animation.
   */
  _addBoxes: function() {
    let browserStack =
      document.getAnonymousElementByAttribute(gBrowser.getNotificationBox(),
                                              "class", "browserStack");
    this._container = this._createElement("historySwipeAnimationContainer",
                                          "stack");
    browserStack.appendChild(this._container);

    this._prevBox = this._createElement("historySwipeAnimationPreviousPage",
                                        "box");
    this._container.appendChild(this._prevBox);

    this._curBox = this._createElement("historySwipeAnimationCurrentPage",
                                       "box");
    this._container.appendChild(this._curBox);

    this._nextBox = this._createElement("historySwipeAnimationNextPage",
                                        "box");
    this._container.appendChild(this._nextBox);

    // Cache width and height.
    this._boxWidth = this._curBox.getBoundingClientRect().width;
    this._boxHeight = this._curBox.getBoundingClientRect().height;
  },

  /**
   * Removes the boxes.
   */
  _removeBoxes: function() {
    this._curBox = null;
    this._prevBox = null;
    this._nextBox = null;
    if (this._container)
      this._container.parentNode.removeChild(this._container);
    this._container = null;
    this._boxWidth = -1;
    this._boxHeight = -1;
  },

  /**
   * Creates an element with a given identifier and tag name.
   *
   * @param aID
   *        An identifier to create the element with.
   * @param aTagName
   *        The name of the tag to create the element for.
   * @return the newly created element.
   */
  _createElement: function(aID, aTagName) {
    let XULNS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
    let element = document.createElementNS(XULNS, aTagName);
    element.id = aID;
    return element;
  },

  /**
   * Moves a given box to a given X coordinate position.
   *
   * @param aBox
   *        The box element to position.
   * @param aPosition
   *        The position (in X coordinates) to move the box element to.
   */
  _positionBox: function(aBox, aPosition) {
    let transform = "";

    if (this._direction == "vertical")
      transform = "translateY(" + this._boxHeight * aPosition + "px)";
    else
      transform = "translateX(" + this._boxWidth * aPosition + "px)";

    aBox.style.transform = transform;
  },

  /**
   * Verifies that we're ready to take snapshots based on the global pref and
   * the current index in history.
   *
   * @return true if we're ready to take snapshots, false otherwise.
   */
  _readyToTakeSnapshots: function() {
    return (this._maxSnapshots >= 1 && this._getCurrentHistoryIndex() >= 0);
  },

  /**
   * Takes a snapshot of the page the browser is currently on.
   */
  _takeSnapshot: function() {
    if (!this._readyToTakeSnapshots()) {
      return;
    }

    let canvas = null;

    let browser = gBrowser.selectedBrowser;
    let r = browser.getBoundingClientRect();
    canvas = document.createElementNS("http://www.w3.org/1999/xhtml",
                                      "canvas");
    canvas.mozOpaque = true;
    let scale = window.devicePixelRatio;
    canvas.width = r.width * scale;
    canvas.height = r.height * scale;
    let ctx = canvas.getContext("2d");
    let zoom = browser.markupDocumentViewer.fullZoom * scale;
    ctx.scale(zoom, zoom);
    ctx.drawWindow(browser.contentWindow,
                   0, 0, canvas.width / zoom, canvas.height / zoom, "white",
                   ctx.DRAWWINDOW_DO_NOT_FLUSH | ctx.DRAWWINDOW_DRAW_VIEW |
                   ctx.DRAWWINDOW_ASYNC_DECODE_IMAGES |
                   ctx.DRAWWINDOW_USE_WIDGET_LAYERS);

    try {
      this._installCurrentPageSnapshot(canvas);
      this._assignSnapshotToCurrentBrowser(canvas);
    }  catch (e) {}
  },

  /**
   * Retrieves the maximum number of snapshots that should be kept in memory.
   * This limit is a global limit and is valid across all open tabs.
   */
  _getMaxSnapshots: function() {
    return gPrefService.getIntPref("browser.snapshots.limit");
  },

  /**
   * Adds a snapshot to the list and initiates the compression of said snapshot.
   * Once the compression is completed, it will replace the uncompressed
   * snapshot in the list.
   *
   * @param aCanvas
   *        The snapshot to add to the list and compress.
   */
  _assignSnapshotToCurrentBrowser:
  function HSA__assignSnapshotToCurrentBrowser(aCanvas) {
    let browser = gBrowser.selectedBrowser;
    let currIndex = this._getCurrentHistoryIndex();

    this._removeTrackedSnapshot(currIndex, browser);
    this._addSnapshotRefToArray(currIndex, browser);

    if (!("snapshots" in browser))
      browser.snapshots = [];
    let snapshots = browser.snapshots;
    // Temporarily store the canvas as the compressed snapshot.
    // This avoids a blank page if the user swipes quickly
    // between pages before the compression could complete.
    snapshots[currIndex] = {
      image: aCanvas,
      scale: window.devicePixelRatio
    };
  },

  /**
   * Compresses the HTMLCanvasElement that's stored at the current history
   * index in the snapshot array and stores the compressed image in its place.
   */
  _compressSnapshotAtCurrentIndex:
  function HSA__compressSnapshotAtCurrentIndex() {
    if (!this._readyToTakeSnapshots()) {
      // We didn't take a snapshot earlier because we weren't ready to, so
      // there's nothing to compress.
      return;
    }

    try {
      let browser = gBrowser.selectedBrowser;
      let snapshots = browser.snapshots;
      let currIndex = _getCurrentHistoryIndex();

      // Kick off snapshot compression.
      let canvas = snapshots[currIndex].image;
      canvas.toBlob(function(aBlob) {
          if (snapshots[currIndex]) {
            snapshots[currIndex].image = aBlob;
          }
        }, "image/png"
      );
    }  catch (e) {}
  },

  /**
   * Removes a snapshot identified by the browser and index in the array of
   * snapshots for that browser, if present. If no snapshot could be identified
   * the method simply returns without taking any action. If aIndex is negative,
   * all snapshots for a particular browser will be removed.
   *
   * @param aIndex
   *        The index in history of the new snapshot, or negative value if all
   *        snapshots for a browser should be removed.
   * @param aBrowser
   *        The browser the new snapshot was taken in.
   */
  _removeTrackedSnapshot: function(aIndex, aBrowser) {
    let arr = this._trackedSnapshots;
    let requiresExactIndexMatch = aIndex >= 0;
    for (let i = 0; i < arr.length; i++) {
      if ((arr[i].browser == aBrowser) &&
          (aIndex < 0 || aIndex == arr[i].index)) {
        delete aBrowser.snapshots[arr[i].index];
        arr.splice(i, 1);
        if (requiresExactIndexMatch)
          return; // Found and removed the only element.
        i--; // Make sure to revisit the index that we just removed an
             // element at.
      }
    }
  },

  /**
   * Adds a new snapshot reference for a given index and browser to the array
   * of references to tracked snapshots.
   *
   * @param aIndex
   *        The index in history of the new snapshot.
   * @param aBrowser
   *        The browser the new snapshot was taken in.
   */
  _addSnapshotRefToArray:
  function HSA__addSnapshotRefToArray(aIndex, aBrowser) {
    let id = { index: aIndex,
               browser: aBrowser };
    let arr = this._trackedSnapshots;
    arr.unshift(id);

    while (arr.length > this._maxSnapshots) {
      let lastElem = arr[arr.length - 1];
      delete lastElem.browser.snapshots[lastElem.index].image;
      delete lastElem.browser.snapshots[lastElem.index];
      arr.splice(-1, 1);
    }
  },

  /**
   * Converts a compressed blob to an Image object. In some situations
   * (especially during fast swiping) aBlob may still be a canvas, not a
   * compressed blob. In this case, we simply return the canvas.
   *
   * @param aBlob
   *        The compressed blob to convert, or a canvas if a blob compression
   *        couldn't complete before this method was called.
   * @return A new Image object representing the converted blob.
   */
  _convertToImg: function(aBlob) {
    if (!aBlob)
      return null;

    // Return aBlob if it's still a canvas and not a compressed blob yet.
    if (aBlob instanceof HTMLCanvasElement)
      return aBlob;

    let img = new Image();
    let url = "";
    try {
      url = URL.createObjectURL(aBlob);
      img.onload = function() {
        URL.revokeObjectURL(url);
      };
    }
    finally {
      img.src = url;
      return img;
    }
  },

  /**
   * Scales the background of a given box element (which uses a given snapshot
   * as background) based on a given scale factor.
   * @param aSnapshot
   *        The snapshot that is used as background of aBox.
   * @param aScale
   *        The scale factor to use.
   * @param aBox
   *        The box element that uses aSnapshot as background.
   */
  _scaleSnapshot: function(aSnapshot, aScale, aBox) {
    if (aSnapshot && aScale != 1 && aBox) {
      if (aSnapshot instanceof HTMLCanvasElement) {
        aBox.style.backgroundSize =
          aSnapshot.width / aScale + "px " + aSnapshot.height / aScale + "px";
      } else {
        // snapshot is instanceof HTMLImageElement
        aSnapshot.addEventListener("load", function() {
          aBox.style.backgroundSize =
            aSnapshot.width / aScale + "px " + aSnapshot.height / aScale + "px";
        });
      }
    }
  },

  /**
   * Sets the snapshot of the current page to the snapshot passed as parameter,
   * or to the one previously stored for the current index in history if the
   * parameter is null.
   *
   * @param aCanvas
   *        The snapshot to set the current page to. If this parameter is null,
   *        the previously stored snapshot for this index (if any) will be used.
   */
  _installCurrentPageSnapshot:
  function HSA__installCurrentPageSnapshot(aCanvas) {
    let currSnapshot = aCanvas;
    let scale = window.devicePixelRatio;
    if (!currSnapshot) {
      let snapshots = gBrowser.selectedBrowser.snapshots || {};
      let currIndex = this._historyIndex;
      if (currIndex in snapshots) {
        currSnapshot = this._convertToImg(snapshots[currIndex].image);
        scale = snapshots[currIndex].scale;
      }
    }
    this._scaleSnapshot(currSnapshot, scale, this._curBox ? this._curBox :
                                                            null);
    document.mozSetImageElement("historySwipeAnimationCurrentPageSnapshot",
                                currSnapshot);
  },

  /**
   * Sets the snapshots of the previous and next pages to the snapshots
   * previously stored for their respective indeces.
   */
  _installPrevAndNextSnapshots:
  function HSA__installPrevAndNextSnapshots() {
    let snapshots = gBrowser.selectedBrowser.snapshots || [];
    let currIndex = this._historyIndex;
    let prevIndex = currIndex - 1;
    let prevSnapshot = null;
    if (prevIndex in snapshots) {
      prevSnapshot = this._convertToImg(snapshots[prevIndex].image);
      this._scaleSnapshot(prevSnapshot, snapshots[prevIndex].scale,
                          this._prevBox);
    }
    document.mozSetImageElement("historySwipeAnimationPreviousPageSnapshot",
                                prevSnapshot);

    let nextIndex = currIndex + 1;
    let nextSnapshot = null;
    if (nextIndex in snapshots) {
      nextSnapshot = this._convertToImg(snapshots[nextIndex].image);
      this._scaleSnapshot(nextSnapshot, snapshots[nextIndex].scale,
                          this._nextBox);
    }
    document.mozSetImageElement("historySwipeAnimationNextPageSnapshot",
                                nextSnapshot);
  },
};
