Source: js/edge_swipe_detector.js

'use strict';
/* global SettingsListener */
/* global Service */
/* global SheetsTransition */
/* global StackManager */
/* global TouchForwarder */

(function(exports) {

  const kEdgeIntertia = 250;
  const kEdgeThreshold = 0.3;
  const kEdgeAngleThreshold = Math.PI / 6;
  const kSignificant = 5 * window.devicePixelRatio;

  /**
   * Detects user gestures for moving between apps using edge gestures.
   * Gestures are listened for on the previous/next elements, which is
   * above the app container. Forwards events when they are not relevant to
   * the app iframe.
   * @class EdgeSwipeDetector
   */
  function EdgeSwipeDetector() {}

  EdgeSwipeDetector.prototype = {
    previous: document.getElementById('left-panel'),
    next: document.getElementById('right-panel'),
    screen: document.getElementById('screen'),

    _touchForwarder: null,

    /**
     * Starts EdgeSwipeDetector and begins listening for events.
     * @memberof EdgeSwipeDetector.prototype
     */
    start: function esd_init() {
      window.addEventListener('homescreenopened', this);
      window.addEventListener('appopened', this);
      window.addEventListener('cardviewclosed', this);
      window.addEventListener('mozChromeEvent', this);
      window.addEventListener('updatepromptshown', this);
      window.addEventListener('updateprompthidden', this);
      window.addEventListener('installpromptshown', this);
      window.addEventListener('installprompthidden', this);
      window.addEventListener('shrinking-start', this);
      window.addEventListener('shrinking-stop', this);
      window.addEventListener('hierarchychanged', this);
      window.addEventListener('hierarchytopmostwindowchanged', this);

      ['touchstart', 'touchmove', 'touchend',
       'mousedown', 'mousemove', 'mouseup'].forEach(function(e) {
        this.previous.addEventListener(e, this);
        this.next.addEventListener(e, this);
      }, this);
      this._touchForwarder = new TouchForwarder();

      SettingsListener.observe('edgesgesture.enabled', false,
                               this.settingUpdate.bind(this));
      SettingsListener.observe('edgesgesture.debug', false,
                               this.debugUpdate.bind(this));
    },

    /**
     * Whether or not the setting is enabled.
     * @memberof EdgeSwipeDetector.prototype
     */
    _settingEnabled: false,

    /**
     * Called when the edgesgesture.enabled setting is changed.
     * @memberof EdgeSwipeDetector.prototype
     */
    settingUpdate: function esd_settingUpdate(enabled) {
      this._settingEnabled = enabled;
      this._updateEnabled();
    },

    /**
     * Called when the edgesgesture.debug setting is changed.
     * @memberof EdgeSwipeDetector.prototype
     */
    debugUpdate: function esd_debugUpdate(enabled) {
      this.screen.classList.toggle('edges-debug', enabled);
    },

    _lifecycleEnabled: false,

    get lifecycleEnabled() {
      return this._lifecycleEnabled;
    },
    set lifecycleEnabled(enable) {
      this._lifecycleEnabled = enable;
      this._updateEnabled();
    },

    /**
     * General event handler.
     * @param {Object} e
     * @memberof EdgeSwipeDetector.prototype
     */
    handleEvent: function esd_handleEvent(e) {
      switch (e.type) {
        case 'hierarchychanged':
        case 'hierarchytopmostwindowchanged':
          // XXX: Use this.appWindowManager instead if
          // we become part of appWindowManager submodules.
          // i.e., Service.query('getTopMostUI') === this.parent
          if (Service.query('getTopMostUI') &&
              Service.query('getTopMostUI').name === 'AppWindowManager') {
            if (Service.query('getTopMostWindow') &&
                !Service.query('getTopMostWindow').isHomescreen &&
                !Service.query('isFtuRunning')) {
              this.lifecycleEnabled = true;
            } else {
              this.lifecycleEnabled = false;
            }
          } else {
            this.lifecycleEnabled = false;
          }
          break;
        case 'mousedown':
        case 'mousemove':
        case 'mouseup':
          // Preventing gecko reflows until
          // https://bugzilla.mozilla.org/show_bug.cgi?id=1005815 lands
          e.preventDefault();
          break;
        case 'touchstart':
          e.preventDefault();
          this._touchStart(e);
          break;
        case 'touchmove':
          e.preventDefault();
          this._touchMove(e);
          break;
        case 'touchend':
          e.preventDefault();
          this._touchEnd(e);
          break;
        case 'appopened':
          var app = e.detail;
          if (!app.stayBackground) {
            this.lifecycleEnabled =
              (app.origin !== Service.query('getFtuOrigin'));
          }
          break;
        case 'shrinking-start':
          this.lifecycleEnabled = false;
          break;
        case 'cardviewclosed':
          if (e.detail && e.detail.newStackPosition) {
            this.lifecycleEnabled = true;
          }
          break;
        case 'mozChromeEvent':
            if (e.detail.type !== 'accessibility-control') {
              break;
            }
            var details = JSON.parse(e.detail.details);
            switch (details.eventType) {
              case 'edge-swipe-right':
                this.autoSwipe('ltr');
                break;
              case 'edge-swipe-left':
                this.autoSwipe('rtl');
                break;
            }
            break;
        case 'updatepromptshown':
        case 'installpromptshown':
          this.lifecycleEnabled = false;
          break;
        // XXX: Move install/update dialog into system dialog
        // and then we could remove this.
        case 'updateprompthidden':
        case 'installprompthidden':
        case 'shrinking-stop':
          if (Service.query('getTopMostWindow') &&
              !Service.query('getTopMostWindow').isHomescreen) {
            this.lifecycleEnabled = true;
          }
          break;
      }
    },

    /**
     * Enables or disables the previous/next triggers.
     * @memberof EdgeSwipeDetector.prototype
     */
    _updateEnabled: function esd_updateEnabled() {
      var enabled = this._lifecycleEnabled && this._settingEnabled;
      this.previous.classList.toggle('disabled', !enabled);
      this.next.classList.toggle('disabled', !enabled);

      if (!enabled && this._touchStartEvt) {
        this._touchStartEvt = null; // we ignore the rest of the gesture
        SheetsTransition.snapInPlace();
      }
    },

    _touchStartEvt: null,
    _startDate: null,
    _startX: null,
    _deltaX: null,
    _startY: null,
    _deltaY: null,
    _forwardTimeout: null,

    _progress: null,
    _winWidth: null,
    _beganTransition: null,
    _moved: null,
    _direction: null,
    _forwarding: null,
    _redispatching: null,

    _touchStart: function esd_touchStart(e) {
      this._winWidth = window.innerWidth;
      this._direction = (e.target == this.next) ? 'rtl' : 'ltr';
      this._touchStartEvt = e;
      this._startDate = Date.now();

      var iframe = StackManager.getCurrent().getTopMostWindow().iframe;
      this._touchForwarder.destination = iframe;

      var touch = e.changedTouches[0];
      this._startX = touch.clientX;
      this._startY = touch.clientY;
      this._deltaX = 0;
      this._deltaY = 0;
      this._beganTransition = false;
      this._moved = false;
      this._forwarding = false;
      this._redispatching = false;

      this._clearForwardTimeout();
      this._forwardTimeout = setTimeout((function longTouch() {
        // Didn't move for a while after the touchstart,
        // this isn't a swipe
        this._forwardTimeout = null;
        this._forwarding = true;
        this._forward(this._touchStartEvt);
      }).bind(this), 300);
    },

    _touchMove: function esd_touchMove(e) {
      if (!this._touchStartEvt) {
        return;
      }
      var touch = e.touches[0];
      this._updateProgress(touch);
      var delta = Math.max(Math.abs(this._deltaX), Math.abs(this._deltaY));

      // If we already started forwarding we just continue
      if (this._forwarding) {
        this._forward(e);
        return;
      }

      // If it's a pinch gesture we start forwarding
      if (e.touches.length > 1) {
        this._startForwarding(e);
        return;
      }

      // If the gesture isn't horizontal we start forwarding
      if (delta > kSignificant && !this._horizontalGesture()) {
        this._startForwarding(e);
        return;
      }

      // Preparing to move the sheets...
      if (!this._beganTransition) {
        SheetsTransition.begin(this._direction);
        this._clearForwardTimeout();
        this._beganTransition = true;
      }

      // after a small threshold
      if ((this._deltaX < kSignificant || this._outsideApp(e)) &&
          !this._moved) {
        return;
      }

      SheetsTransition.moveInDirection(this._direction, this._progress);
      this._moved = true;
    },

    _touchEnd: function esd_touchEnd(e) {
      if (!this._touchStartEvt) {
        return;
      }

      // Edge gestures are never multi-touch
      var touches = e.touches.length + e.changedTouches.length;
      if (touches > 1 && !this._forwarding) {
        this._touchStartEvt = null;
        SheetsTransition.snapInPlace();
        return;
      }

      var touch = e.changedTouches[0];
      this._updateProgress(touch);

      if (this._forwarding) {
        this._forward(e);
      } else if ((this._deltaX < kSignificant) &&
                 (this._deltaY < kSignificant)) {
        setTimeout(function(self, touchstart, touchend) {
          self._forward(touchstart);
          setTimeout(function() {
            self._forward(touchend);
          }, 100);
        }, 0, this, this._touchStartEvt, e);
        this._forwarding = true;
      }

      this._clearForwardTimeout();

      var deltaT = Date.now() - this._startDate;
      var speed = this._progress / deltaT; // progress / ms
      var inertia = speed * kEdgeIntertia; // ms of intertia
      var adjustedProgress = (this._progress + inertia);

      if (adjustedProgress < kEdgeThreshold || this._forwarding) {
        SheetsTransition.snapInPlace();
        return;
      }

      var direction = this._direction;
      if (direction == 'ltr') {
        SheetsTransition.snapBack(speed);
        StackManager.goPrev();
      } else {
        SheetsTransition.snapForward(speed);
        StackManager.goNext();
      }
    },

    _updateProgress: function esd_updateProgress(touch) {
      this._deltaX = touch.clientX - this._startX;
      this._deltaY = touch.clientY - this._startY;

      if (this._direction == 'rtl') {
        this._deltaX *= -1;
        this._deltaY *= -1;
      }
      this._progress = this._deltaX / this._winWidth;
    },

    _horizontalGesture: function esd_horizontalGesture() {
      var angle = Math.atan2(this._deltaX, this._deltaY);
      var horizontalAngle = Math.PI / 2;

      return Math.abs(angle - horizontalAngle) < kEdgeAngleThreshold;
    },

    _clearForwardTimeout: function esd_clearForwardTimeout() {
      if (this._forwardTimeout) {
        clearTimeout(this._forwardTimeout);
        this._forwardTimeout = null;
      }
    },

    _startForwarding: function esd_startForwarding(e) {
      this._clearForwardTimeout();
      this._forwarding = true;
      this._forward(this._touchStartEvt);

      this._forward(e);

      if (this._beganTransition) {
        SheetsTransition.snapInPlace();
      }
    },

    /**
     * Once we identify the gesture as something other than an horizontal
     * swipe we replay and start forwarding the touch events:
     *
     * * either sending them to the current app through the sendTouchEvent
     *   mozbrowser API
     * * or redispatching the events to the system app if they were outside
     *   the app frame
     *
     * When redispatching, since we don't know on which element they should be
     * dispatched (the target is always the gesture-panel) we send a custom
     * event instead.
     * @memberof EdgeSwipeDetector.prototype
     */
    _forward: function esd_forward(e) {
      if (this._moved) {
        return;
      }

      // We continue dispatching/forwarding where we started.
      if (this._outsideApp(e) || this._redispatching) {
        window.dispatchEvent(new CustomEvent('edge-touch-redispatch', {
          bubbles: true,
          detail: e
        }));
        this._redispatching = true;
      } else {
        this._touchForwarder.forward(e);
      }
    },

    /**
     * Detects whether or not a touch event is outside of an app container.
     * @param {Object} e The relevant touch event.
     * @memberof EdgeSwipeDetector.prototype
     */
    _outsideApp: function esd_outsideApp(e) {
      var touch = (e.type === 'touchend') ? e.changedTouches[0] : e.touches[0];

      var x = touch.pageX;
      var y = touch.pageY;

      // In fullscreen_layout mode the app frame will always be fullscreen
      // but we still want to redispatch touch events to the "overlayed"
      // software home button
      var softwareButtonOverlayed =
        Service.query('getTopMostWindow') &&
        Service.query('getTopMostWindow').isFullScreenLayout();
      var width = Service.query('LayoutManager.width');
      var height = Service.query('LayoutManager.height');
      if (softwareButtonOverlayed) {
        var sbWidth = Service.query('SoftwareButtonManager.width');
        var sbHeight = Service.query('SoftwareButtonManager.height');
        return x > (width - sbWidth) || y > (height - sbHeight);
      }
      return (x > width || y > height);
    },

    /**
     * Swipes a page when we receive an event from gecko.
     * @param {String} direction
     * @memberof EdgeSwipeDetector.prototype
     */
    autoSwipe: function esd_autoSwipe(direction) {
      if (!this._lifecycleEnabled) {
        return;
      }
      SheetsTransition.begin(direction);
      if (direction === 'ltr') {
        SheetsTransition.snapBack(1);
        StackManager.goPrev();
      } else {
        SheetsTransition.snapForward(1);
        StackManager.goNext();
      }
    }
  };

  exports.EdgeSwipeDetector = EdgeSwipeDetector;

}(window));