michael@0: # This Source Code Form is subject to the terms of the Mozilla Public michael@0: # License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: # file, You can obtain one at http://mozilla.org/MPL/2.0/. michael@0: michael@0: Cu.import("resource://gre/modules/TelemetryStopwatch.jsm", this); michael@0: michael@0: // Simple gestures support michael@0: // michael@0: // As per bug #412486, web content must not be allowed to receive any michael@0: // simple gesture events. Multi-touch gesture APIs are in their michael@0: // infancy and we do NOT want to be forced into supporting an API that michael@0: // will probably have to change in the future. (The current Mac OS X michael@0: // API is undocumented and was reverse-engineered.) Until support is michael@0: // implemented in the event dispatcher to keep these events as michael@0: // chrome-only, we must listen for the simple gesture events during michael@0: // the capturing phase and call stopPropagation on every event. michael@0: michael@0: let gGestureSupport = { michael@0: _currentRotation: 0, michael@0: _lastRotateDelta: 0, michael@0: _rotateMomentumThreshold: .75, michael@0: michael@0: /** michael@0: * Add or remove mouse gesture event listeners michael@0: * michael@0: * @param aAddListener michael@0: * True to add/init listeners and false to remove/uninit michael@0: */ michael@0: init: function GS_init(aAddListener) { michael@0: const gestureEvents = ["SwipeGestureStart", michael@0: "SwipeGestureUpdate", "SwipeGestureEnd", "SwipeGesture", michael@0: "MagnifyGestureStart", "MagnifyGestureUpdate", "MagnifyGesture", michael@0: "RotateGestureStart", "RotateGestureUpdate", "RotateGesture", michael@0: "TapGesture", "PressTapGesture"]; michael@0: michael@0: let addRemove = aAddListener ? window.addEventListener : michael@0: window.removeEventListener; michael@0: michael@0: gestureEvents.forEach(function (event) addRemove("Moz" + event, this, true), michael@0: this); michael@0: }, michael@0: michael@0: /** michael@0: * Dispatch events based on the type of mouse gesture event. For now, make michael@0: * sure to stop propagation of every gesture event so that web content cannot michael@0: * receive gesture events. michael@0: * michael@0: * @param aEvent michael@0: * The gesture event to handle michael@0: */ michael@0: handleEvent: function GS_handleEvent(aEvent) { michael@0: if (!Services.prefs.getBoolPref( michael@0: "dom.debug.propagate_gesture_events_through_content")) { michael@0: aEvent.stopPropagation(); michael@0: } michael@0: michael@0: // Create a preference object with some defaults michael@0: let def = function(aThreshold, aLatched) michael@0: ({ threshold: aThreshold, latched: !!aLatched }); michael@0: michael@0: switch (aEvent.type) { michael@0: case "MozSwipeGestureStart": michael@0: if (this._setupSwipeGesture(aEvent)) { michael@0: aEvent.preventDefault(); michael@0: } michael@0: break; michael@0: case "MozSwipeGestureUpdate": michael@0: aEvent.preventDefault(); michael@0: this._doUpdate(aEvent); michael@0: break; michael@0: case "MozSwipeGestureEnd": michael@0: aEvent.preventDefault(); michael@0: this._doEnd(aEvent); michael@0: break; michael@0: case "MozSwipeGesture": michael@0: aEvent.preventDefault(); michael@0: this.onSwipe(aEvent); michael@0: break; michael@0: case "MozMagnifyGestureStart": michael@0: aEvent.preventDefault(); michael@0: #ifdef XP_WIN michael@0: this._setupGesture(aEvent, "pinch", def(25, 0), "out", "in"); michael@0: #else michael@0: this._setupGesture(aEvent, "pinch", def(150, 1), "out", "in"); michael@0: #endif michael@0: break; michael@0: case "MozRotateGestureStart": michael@0: aEvent.preventDefault(); michael@0: this._setupGesture(aEvent, "twist", def(25, 0), "right", "left"); michael@0: break; michael@0: case "MozMagnifyGestureUpdate": michael@0: case "MozRotateGestureUpdate": michael@0: aEvent.preventDefault(); michael@0: this._doUpdate(aEvent); michael@0: break; michael@0: case "MozTapGesture": michael@0: aEvent.preventDefault(); michael@0: this._doAction(aEvent, ["tap"]); michael@0: break; michael@0: case "MozRotateGesture": michael@0: aEvent.preventDefault(); michael@0: this._doAction(aEvent, ["twist", "end"]); michael@0: break; michael@0: /* case "MozPressTapGesture": michael@0: break; */ michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Called at the start of "pinch" and "twist" gestures to setup all of the michael@0: * information needed to process the gesture michael@0: * michael@0: * @param aEvent michael@0: * The continual motion start event to handle michael@0: * @param aGesture michael@0: * Name of the gesture to handle michael@0: * @param aPref michael@0: * Preference object with the names of preferences and defaults michael@0: * @param aInc michael@0: * Command to trigger for increasing motion (without gesture name) michael@0: * @param aDec michael@0: * Command to trigger for decreasing motion (without gesture name) michael@0: */ michael@0: _setupGesture: function GS__setupGesture(aEvent, aGesture, aPref, aInc, aDec) { michael@0: // Try to load user-set values from preferences michael@0: for (let [pref, def] in Iterator(aPref)) michael@0: aPref[pref] = this._getPref(aGesture + "." + pref, def); michael@0: michael@0: // Keep track of the total deltas and latching behavior michael@0: let offset = 0; michael@0: let latchDir = aEvent.delta > 0 ? 1 : -1; michael@0: let isLatched = false; michael@0: michael@0: // Create the update function here to capture closure state michael@0: this._doUpdate = function GS__doUpdate(aEvent) { michael@0: // Update the offset with new event data michael@0: offset += aEvent.delta; michael@0: michael@0: // Check if the cumulative deltas exceed the threshold michael@0: if (Math.abs(offset) > aPref["threshold"]) { michael@0: // Trigger the action if we don't care about latching; otherwise, make michael@0: // sure either we're not latched and going the same direction of the michael@0: // initial motion; or we're latched and going the opposite way michael@0: let sameDir = (latchDir ^ offset) >= 0; michael@0: if (!aPref["latched"] || (isLatched ^ sameDir)) { michael@0: this._doAction(aEvent, [aGesture, offset > 0 ? aInc : aDec]); michael@0: michael@0: // We must be getting latched or leaving it, so just toggle michael@0: isLatched = !isLatched; michael@0: } michael@0: michael@0: // Reset motion counter to prepare for more of the same gesture michael@0: offset = 0; michael@0: } michael@0: }; michael@0: michael@0: // The start event also contains deltas, so handle an update right away michael@0: this._doUpdate(aEvent); michael@0: }, michael@0: michael@0: /** michael@0: * Checks whether a swipe gesture event can navigate the browser history or michael@0: * not. michael@0: * michael@0: * @param aEvent michael@0: * The swipe gesture event. michael@0: * @return true if the swipe event may navigate the history, false othwerwise. michael@0: */ michael@0: _swipeNavigatesHistory: function GS__swipeNavigatesHistory(aEvent) { michael@0: return this._getCommand(aEvent, ["swipe", "left"]) michael@0: == "Browser:BackOrBackDuplicate" && michael@0: this._getCommand(aEvent, ["swipe", "right"]) michael@0: == "Browser:ForwardOrForwardDuplicate"; michael@0: }, michael@0: michael@0: /** michael@0: * Sets up swipe gestures. This includes setting up swipe animations for the michael@0: * gesture, if enabled. michael@0: * michael@0: * @param aEvent michael@0: * The swipe gesture start event. michael@0: * @return true if swipe gestures could successfully be set up, false michael@0: * othwerwise. michael@0: */ michael@0: _setupSwipeGesture: function GS__setupSwipeGesture(aEvent) { michael@0: if (!this._swipeNavigatesHistory(aEvent)) { michael@0: return false; michael@0: } michael@0: michael@0: let isVerticalSwipe = false; michael@0: if (aEvent.direction == aEvent.DIRECTION_UP) { michael@0: if (content.pageYOffset > 0) { michael@0: return false; michael@0: } michael@0: isVerticalSwipe = true; michael@0: } else if (aEvent.direction == aEvent.DIRECTION_DOWN) { michael@0: if (content.pageYOffset < content.scrollMaxY) { michael@0: return false; michael@0: } michael@0: isVerticalSwipe = true; michael@0: } michael@0: if (isVerticalSwipe) { michael@0: // Vertical overscroll has been temporarily disabled until bug 939480 is michael@0: // fixed. michael@0: return false; michael@0: } michael@0: michael@0: let canGoBack = gHistorySwipeAnimation.canGoBack(); michael@0: let canGoForward = gHistorySwipeAnimation.canGoForward(); michael@0: let isLTR = gHistorySwipeAnimation.isLTR; michael@0: michael@0: if (canGoBack) { michael@0: aEvent.allowedDirections |= isLTR ? aEvent.DIRECTION_LEFT : michael@0: aEvent.DIRECTION_RIGHT; michael@0: } michael@0: if (canGoForward) { michael@0: aEvent.allowedDirections |= isLTR ? aEvent.DIRECTION_RIGHT : michael@0: aEvent.DIRECTION_LEFT; michael@0: } michael@0: michael@0: gHistorySwipeAnimation.startAnimation(isVerticalSwipe); michael@0: michael@0: this._doUpdate = function GS__doUpdate(aEvent) { michael@0: gHistorySwipeAnimation.updateAnimation(aEvent.delta); michael@0: }; michael@0: michael@0: this._doEnd = function GS__doEnd(aEvent) { michael@0: gHistorySwipeAnimation.swipeEndEventReceived(); michael@0: michael@0: this._doUpdate = function (aEvent) {}; michael@0: this._doEnd = function (aEvent) {}; michael@0: } michael@0: michael@0: return true; michael@0: }, michael@0: michael@0: /** michael@0: * Generator producing the powerset of the input array where the first result michael@0: * is the complete set and the last result (before StopIteration) is empty. michael@0: * michael@0: * @param aArray michael@0: * Source array containing any number of elements michael@0: * @yield Array that is a subset of the input array from full set to empty michael@0: */ michael@0: _power: function GS__power(aArray) { michael@0: // Create a bitmask based on the length of the array michael@0: let num = 1 << aArray.length; michael@0: while (--num >= 0) { michael@0: // Only select array elements where the current bit is set michael@0: yield aArray.reduce(function (aPrev, aCurr, aIndex) { michael@0: if (num & 1 << aIndex) michael@0: aPrev.push(aCurr); michael@0: return aPrev; michael@0: }, []); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Determine what action to do for the gesture based on which keys are michael@0: * pressed and which commands are set, and execute the command. michael@0: * michael@0: * @param aEvent michael@0: * The original gesture event to convert into a fake click event michael@0: * @param aGesture michael@0: * Array of gesture name parts (to be joined by periods) michael@0: * @return Name of the executed command. Returns null if no command is michael@0: * found. michael@0: */ michael@0: _doAction: function GS__doAction(aEvent, aGesture) { michael@0: let command = this._getCommand(aEvent, aGesture); michael@0: return command && this._doCommand(aEvent, command); michael@0: }, michael@0: michael@0: /** michael@0: * Determine what action to do for the gesture based on which keys are michael@0: * pressed and which commands are set michael@0: * michael@0: * @param aEvent michael@0: * The original gesture event to convert into a fake click event michael@0: * @param aGesture michael@0: * Array of gesture name parts (to be joined by periods) michael@0: */ michael@0: _getCommand: function GS__getCommand(aEvent, aGesture) { michael@0: // Create an array of pressed keys in a fixed order so that a command for michael@0: // "meta" is preferred over "ctrl" when both buttons are pressed (and a michael@0: // command for both don't exist) michael@0: let keyCombos = []; michael@0: ["shift", "alt", "ctrl", "meta"].forEach(function (key) { michael@0: if (aEvent[key + "Key"]) michael@0: keyCombos.push(key); michael@0: }); michael@0: michael@0: // Try each combination of key presses in decreasing order for commands michael@0: for (let subCombo of this._power(keyCombos)) { michael@0: // Convert a gesture and pressed keys into the corresponding command michael@0: // action where the preference has the gesture before "shift" before michael@0: // "alt" before "ctrl" before "meta" all separated by periods michael@0: let command; michael@0: try { michael@0: command = this._getPref(aGesture.concat(subCombo).join(".")); michael@0: } catch (e) {} michael@0: michael@0: if (command) michael@0: return command; michael@0: } michael@0: return null; michael@0: }, michael@0: michael@0: /** michael@0: * Execute the specified command. michael@0: * michael@0: * @param aEvent michael@0: * The original gesture event to convert into a fake click event michael@0: * @param aCommand michael@0: * Name of the command found for the event's keys and gesture. michael@0: */ michael@0: _doCommand: function GS__doCommand(aEvent, aCommand) { michael@0: let node = document.getElementById(aCommand); michael@0: if (node) { michael@0: if (node.getAttribute("disabled") != "true") { michael@0: let cmdEvent = document.createEvent("xulcommandevent"); michael@0: cmdEvent.initCommandEvent("command", true, true, window, 0, michael@0: aEvent.ctrlKey, aEvent.altKey, michael@0: aEvent.shiftKey, aEvent.metaKey, aEvent); michael@0: node.dispatchEvent(cmdEvent); michael@0: } michael@0: michael@0: } michael@0: else { michael@0: goDoCommand(aCommand); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Handle continual motion events. This function will be set by michael@0: * _setupGesture or _setupSwipe. michael@0: * michael@0: * @param aEvent michael@0: * The continual motion update event to handle michael@0: */ michael@0: _doUpdate: function(aEvent) {}, michael@0: michael@0: /** michael@0: * Handle gesture end events. This function will be set by _setupSwipe. michael@0: * michael@0: * @param aEvent michael@0: * The gesture end event to handle michael@0: */ michael@0: _doEnd: function(aEvent) {}, michael@0: michael@0: /** michael@0: * Convert the swipe gesture into a browser action based on the direction. michael@0: * michael@0: * @param aEvent michael@0: * The swipe event to handle michael@0: */ michael@0: onSwipe: function GS_onSwipe(aEvent) { michael@0: // Figure out which one (and only one) direction was triggered michael@0: for (let dir of ["UP", "RIGHT", "DOWN", "LEFT"]) { michael@0: if (aEvent.direction == aEvent["DIRECTION_" + dir]) { michael@0: this._coordinateSwipeEventWithAnimation(aEvent, dir); michael@0: break; michael@0: } michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Process a swipe event based on the given direction. michael@0: * michael@0: * @param aEvent michael@0: * The swipe event to handle michael@0: * @param aDir michael@0: * The direction for the swipe event michael@0: */ michael@0: processSwipeEvent: function GS_processSwipeEvent(aEvent, aDir) { michael@0: this._doAction(aEvent, ["swipe", aDir.toLowerCase()]); michael@0: }, michael@0: michael@0: /** michael@0: * Coordinates the swipe event with the swipe animation, if any. michael@0: * If an animation is currently running, the swipe event will be michael@0: * processed once the animation stops. This will guarantee a fluid michael@0: * motion of the animation. michael@0: * michael@0: * @param aEvent michael@0: * The swipe event to handle michael@0: * @param aDir michael@0: * The direction for the swipe event michael@0: */ michael@0: _coordinateSwipeEventWithAnimation: michael@0: function GS__coordinateSwipeEventWithAnimation(aEvent, aDir) { michael@0: if ((gHistorySwipeAnimation.isAnimationRunning()) && michael@0: (aDir == "RIGHT" || aDir == "LEFT")) { michael@0: gHistorySwipeAnimation.processSwipeEvent(aEvent, aDir); michael@0: } michael@0: else { michael@0: this.processSwipeEvent(aEvent, aDir); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Get a gesture preference or use a default if it doesn't exist michael@0: * michael@0: * @param aPref michael@0: * Name of the preference to load under the gesture branch michael@0: * @param aDef michael@0: * Default value if the preference doesn't exist michael@0: */ michael@0: _getPref: function GS__getPref(aPref, aDef) { michael@0: // Preferences branch under which all gestures preferences are stored michael@0: const branch = "browser.gesture."; michael@0: michael@0: try { michael@0: // Determine what type of data to load based on default value's type michael@0: let type = typeof aDef; michael@0: let getFunc = "get" + (type == "boolean" ? "Bool" : michael@0: type == "number" ? "Int" : "Char") + "Pref"; michael@0: return gPrefService[getFunc](branch + aPref); michael@0: } michael@0: catch (e) { michael@0: return aDef; michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Perform rotation for ImageDocuments michael@0: * michael@0: * @param aEvent michael@0: * The MozRotateGestureUpdate event triggering this call michael@0: */ michael@0: rotate: function(aEvent) { michael@0: if (!(content.document instanceof ImageDocument)) michael@0: return; michael@0: michael@0: let contentElement = content.document.body.firstElementChild; michael@0: if (!contentElement) michael@0: return; michael@0: // If we're currently snapping, cancel that snap michael@0: if (contentElement.classList.contains("completeRotation")) michael@0: this._clearCompleteRotation(); michael@0: michael@0: this.rotation = Math.round(this.rotation + aEvent.delta); michael@0: contentElement.style.transform = "rotate(" + this.rotation + "deg)"; michael@0: this._lastRotateDelta = aEvent.delta; michael@0: }, michael@0: michael@0: /** michael@0: * Perform a rotation end for ImageDocuments michael@0: */ michael@0: rotateEnd: function() { michael@0: if (!(content.document instanceof ImageDocument)) michael@0: return; michael@0: michael@0: let contentElement = content.document.body.firstElementChild; michael@0: if (!contentElement) michael@0: return; michael@0: michael@0: let transitionRotation = 0; michael@0: michael@0: // The reason that 360 is allowed here is because when rotating between michael@0: // 315 and 360, setting rotate(0deg) will cause it to rotate the wrong michael@0: // direction around--spinning wildly. michael@0: if (this.rotation <= 45) michael@0: transitionRotation = 0; michael@0: else if (this.rotation > 45 && this.rotation <= 135) michael@0: transitionRotation = 90; michael@0: else if (this.rotation > 135 && this.rotation <= 225) michael@0: transitionRotation = 180; michael@0: else if (this.rotation > 225 && this.rotation <= 315) michael@0: transitionRotation = 270; michael@0: else michael@0: transitionRotation = 360; michael@0: michael@0: // If we're going fast enough, and we didn't already snap ahead of rotation, michael@0: // then snap ahead of rotation to simulate momentum michael@0: if (this._lastRotateDelta > this._rotateMomentumThreshold && michael@0: this.rotation > transitionRotation) michael@0: transitionRotation += 90; michael@0: else if (this._lastRotateDelta < -1 * this._rotateMomentumThreshold && michael@0: this.rotation < transitionRotation) michael@0: transitionRotation -= 90; michael@0: michael@0: // Only add the completeRotation class if it is is necessary michael@0: if (transitionRotation != this.rotation) { michael@0: contentElement.classList.add("completeRotation"); michael@0: contentElement.addEventListener("transitionend", this._clearCompleteRotation); michael@0: } michael@0: michael@0: contentElement.style.transform = "rotate(" + transitionRotation + "deg)"; michael@0: this.rotation = transitionRotation; michael@0: }, michael@0: michael@0: /** michael@0: * Gets the current rotation for the ImageDocument michael@0: */ michael@0: get rotation() { michael@0: return this._currentRotation; michael@0: }, michael@0: michael@0: /** michael@0: * Sets the current rotation for the ImageDocument michael@0: * michael@0: * @param aVal michael@0: * The new value to take. Can be any value, but it will be bounded to michael@0: * 0 inclusive to 360 exclusive. michael@0: */ michael@0: set rotation(aVal) { michael@0: this._currentRotation = aVal % 360; michael@0: if (this._currentRotation < 0) michael@0: this._currentRotation += 360; michael@0: return this._currentRotation; michael@0: }, michael@0: michael@0: /** michael@0: * When the location/tab changes, need to reload the current rotation for the michael@0: * image michael@0: */ michael@0: restoreRotationState: function() { michael@0: // Bug 863514 - Make gesture support work in electrolysis michael@0: if (gMultiProcessBrowser) michael@0: return; michael@0: michael@0: if (!(content.document instanceof ImageDocument)) michael@0: return; michael@0: michael@0: let contentElement = content.document.body.firstElementChild; michael@0: let transformValue = content.window.getComputedStyle(contentElement, null) michael@0: .transform; michael@0: michael@0: if (transformValue == "none") { michael@0: this.rotation = 0; michael@0: return; michael@0: } michael@0: michael@0: // transformValue is a rotation matrix--split it and do mathemagic to michael@0: // obtain the real rotation value michael@0: transformValue = transformValue.split("(")[1] michael@0: .split(")")[0] michael@0: .split(","); michael@0: this.rotation = Math.round(Math.atan2(transformValue[1], transformValue[0]) * michael@0: (180 / Math.PI)); michael@0: }, michael@0: michael@0: /** michael@0: * Removes the transition rule by removing the completeRotation class michael@0: */ michael@0: _clearCompleteRotation: function() { michael@0: let contentElement = content.document && michael@0: content.document instanceof ImageDocument && michael@0: content.document.body && michael@0: content.document.body.firstElementChild; michael@0: if (!contentElement) michael@0: return; michael@0: contentElement.classList.remove("completeRotation"); michael@0: contentElement.removeEventListener("transitionend", this._clearCompleteRotation); michael@0: }, michael@0: }; michael@0: michael@0: // History Swipe Animation Support (bug 678392) michael@0: let gHistorySwipeAnimation = { michael@0: michael@0: active: false, michael@0: isLTR: false, michael@0: michael@0: /** michael@0: * Initializes the support for history swipe animations, if it is supported michael@0: * by the platform/configuration. michael@0: */ michael@0: init: function HSA_init() { michael@0: if (!this._isSupported()) michael@0: return; michael@0: michael@0: this.active = false; michael@0: this.isLTR = document.documentElement.mozMatchesSelector( michael@0: ":-moz-locale-dir(ltr)"); michael@0: this._trackedSnapshots = []; michael@0: this._startingIndex = -1; michael@0: this._historyIndex = -1; michael@0: this._boxWidth = -1; michael@0: this._boxHeight = -1; michael@0: this._maxSnapshots = this._getMaxSnapshots(); michael@0: this._lastSwipeDir = ""; michael@0: this._direction = "horizontal"; michael@0: michael@0: // We only want to activate history swipe animations if we store snapshots. michael@0: // If we don't store any, we handle horizontal swipes without animations. michael@0: if (this._maxSnapshots > 0) { michael@0: this.active = true; michael@0: gBrowser.addEventListener("pagehide", this, false); michael@0: gBrowser.addEventListener("pageshow", this, false); michael@0: gBrowser.addEventListener("popstate", this, false); michael@0: gBrowser.addEventListener("DOMModalDialogClosed", this, false); michael@0: gBrowser.tabContainer.addEventListener("TabClose", this, false); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Uninitializes the support for history swipe animations. michael@0: */ michael@0: uninit: function HSA_uninit() { michael@0: gBrowser.removeEventListener("pagehide", this, false); michael@0: gBrowser.removeEventListener("pageshow", this, false); michael@0: gBrowser.removeEventListener("popstate", this, false); michael@0: gBrowser.removeEventListener("DOMModalDialogClosed", this, false); michael@0: gBrowser.tabContainer.removeEventListener("TabClose", this, false); michael@0: michael@0: this.active = false; michael@0: this.isLTR = false; michael@0: }, michael@0: michael@0: /** michael@0: * Starts the swipe animation and handles fast swiping (i.e. a swipe animation michael@0: * is already in progress when a new one is initiated). michael@0: * michael@0: * @param aIsVerticalSwipe michael@0: * Whether we're dealing with a vertical swipe or not. michael@0: */ michael@0: startAnimation: function HSA_startAnimation(aIsVerticalSwipe) { michael@0: this._direction = aIsVerticalSwipe ? "vertical" : "horizontal"; michael@0: michael@0: if (this.isAnimationRunning()) { michael@0: // If this is a horizontal scroll, or if this is a vertical scroll that michael@0: // was started while a horizontal scroll was still running, handle it as michael@0: // as a fast swipe. In the case of the latter scenario, this allows us to michael@0: // start the vertical animation without first loading the final page, or michael@0: // taking another snapshot. If vertical scrolls are initiated repeatedly michael@0: // without prior horizontal scroll we skip this and restart the animation michael@0: // from 0. michael@0: if (this._direction == "horizontal" || this._lastSwipeDir != "") { michael@0: gBrowser.stop(); michael@0: this._lastSwipeDir = "RELOAD"; // just ensure that != "" michael@0: this._canGoBack = this.canGoBack(); michael@0: this._canGoForward = this.canGoForward(); michael@0: this._handleFastSwiping(); michael@0: } michael@0: } michael@0: else { michael@0: this._startingIndex = gBrowser.webNavigation.sessionHistory.index; michael@0: this._historyIndex = this._startingIndex; michael@0: this._canGoBack = this.canGoBack(); michael@0: this._canGoForward = this.canGoForward(); michael@0: if (this.active) { michael@0: this._addBoxes(); michael@0: this._takeSnapshot(); michael@0: this._installPrevAndNextSnapshots(); michael@0: this._lastSwipeDir = ""; michael@0: } michael@0: } michael@0: this.updateAnimation(0); michael@0: }, michael@0: michael@0: /** michael@0: * Stops the swipe animation. michael@0: */ michael@0: stopAnimation: function HSA_stopAnimation() { michael@0: gHistorySwipeAnimation._removeBoxes(); michael@0: this._historyIndex = gBrowser.webNavigation.sessionHistory.index; michael@0: }, michael@0: michael@0: /** michael@0: * Updates the animation between two pages in history. michael@0: * michael@0: * @param aVal michael@0: * A floating point value that represents the progress of the michael@0: * swipe gesture. michael@0: */ michael@0: updateAnimation: function HSA_updateAnimation(aVal) { michael@0: if (!this.isAnimationRunning()) { michael@0: return; michael@0: } michael@0: michael@0: // We use the following value to decrease the bounce effect when scrolling michael@0: // to the top or bottom of the page, or when swiping back/forward past the michael@0: // browsing history. This value was determined experimentally. michael@0: let dampValue = 4; michael@0: if (this._direction == "vertical") { michael@0: this._prevBox.collapsed = true; michael@0: this._nextBox.collapsed = true; michael@0: this._positionBox(this._curBox, -1 * aVal / dampValue); michael@0: } else if ((aVal >= 0 && this.isLTR) || michael@0: (aVal <= 0 && !this.isLTR)) { michael@0: let tempDampValue = 1; michael@0: if (this._canGoBack) { michael@0: this._prevBox.collapsed = false; michael@0: } else { michael@0: tempDampValue = dampValue; michael@0: this._prevBox.collapsed = true; michael@0: } michael@0: michael@0: // The current page is pushed to the right (LTR) or left (RTL), michael@0: // the intention is to go back. michael@0: // If there is a page to go back to, it should show in the background. michael@0: this._positionBox(this._curBox, aVal / tempDampValue); michael@0: michael@0: // The forward page should be pushed offscreen all the way to the right. michael@0: this._positionBox(this._nextBox, 1); michael@0: } else { michael@0: // The intention is to go forward. If there is a page to go forward to, michael@0: // it should slide in from the right (LTR) or left (RTL). michael@0: // Otherwise, the current page should slide to the left (LTR) or michael@0: // right (RTL) and the backdrop should appear in the background. michael@0: // For the backdrop to be visible in that case, the previous page needs michael@0: // to be hidden (if it exists). michael@0: if (this._canGoForward) { michael@0: this._nextBox.collapsed = false; michael@0: let offset = this.isLTR ? 1 : -1; michael@0: this._positionBox(this._curBox, 0); michael@0: this._positionBox(this._nextBox, offset + aVal); michael@0: } else { michael@0: this._prevBox.collapsed = true; michael@0: this._positionBox(this._curBox, aVal / dampValue); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Event handler for events relevant to the history swipe animation. michael@0: * michael@0: * @param aEvent michael@0: * An event to process. michael@0: */ michael@0: handleEvent: function HSA_handleEvent(aEvent) { michael@0: let browser = gBrowser.selectedBrowser; michael@0: switch (aEvent.type) { michael@0: case "TabClose": michael@0: let browserForTab = gBrowser.getBrowserForTab(aEvent.target); michael@0: this._removeTrackedSnapshot(-1, browserForTab); michael@0: break; michael@0: case "DOMModalDialogClosed": michael@0: this.stopAnimation(); michael@0: break; michael@0: case "pageshow": michael@0: if (aEvent.target == browser.contentDocument) { michael@0: this.stopAnimation(); michael@0: } michael@0: break; michael@0: case "popstate": michael@0: if (aEvent.target == browser.contentDocument.defaultView) { michael@0: this.stopAnimation(); michael@0: } michael@0: break; michael@0: case "pagehide": michael@0: if (aEvent.target == browser.contentDocument) { michael@0: // Take and compress a snapshot of a page whenever it's about to be michael@0: // navigated away from. We already have a snapshot of the page if an michael@0: // animation is running, so we're left with compressing it. michael@0: if (!this.isAnimationRunning()) { michael@0: this._takeSnapshot(); michael@0: } michael@0: this._compressSnapshotAtCurrentIndex(); michael@0: } michael@0: break; michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Checks whether the history swipe animation is currently running or not. michael@0: * michael@0: * @return true if the animation is currently running, false otherwise. michael@0: */ michael@0: isAnimationRunning: function HSA_isAnimationRunning() { michael@0: return !!this._container; michael@0: }, michael@0: michael@0: /** michael@0: * Process a swipe event based on the given direction. michael@0: * michael@0: * @param aEvent michael@0: * The swipe event to handle michael@0: * @param aDir michael@0: * The direction for the swipe event michael@0: */ michael@0: processSwipeEvent: function HSA_processSwipeEvent(aEvent, aDir) { michael@0: if (aDir == "RIGHT") michael@0: this._historyIndex += this.isLTR ? 1 : -1; michael@0: else if (aDir == "LEFT") michael@0: this._historyIndex += this.isLTR ? -1 : 1; michael@0: else michael@0: return; michael@0: this._lastSwipeDir = aDir; michael@0: }, michael@0: michael@0: /** michael@0: * Checks if there is a page in the browser history to go back to. michael@0: * michael@0: * @return true if there is a previous page in history, false otherwise. michael@0: */ michael@0: canGoBack: function HSA_canGoBack() { michael@0: if (this.isAnimationRunning()) michael@0: return this._doesIndexExistInHistory(this._historyIndex - 1); michael@0: return gBrowser.webNavigation.canGoBack; michael@0: }, michael@0: michael@0: /** michael@0: * Checks if there is a page in the browser history to go forward to. michael@0: * michael@0: * @return true if there is a next page in history, false otherwise. michael@0: */ michael@0: canGoForward: function HSA_canGoForward() { michael@0: if (this.isAnimationRunning()) michael@0: return this._doesIndexExistInHistory(this._historyIndex + 1); michael@0: return gBrowser.webNavigation.canGoForward; michael@0: }, michael@0: michael@0: /** michael@0: * Used to notify the history swipe animation that the OS sent a swipe end michael@0: * event and that we should navigate to the page that the user swiped to, if michael@0: * any. This will also result in the animation overlay to be torn down. michael@0: */ michael@0: swipeEndEventReceived: function HSA_swipeEndEventReceived() { michael@0: if (this._lastSwipeDir != "" && this._historyIndex != this._startingIndex) michael@0: this._navigateToHistoryIndex(); michael@0: else michael@0: this.stopAnimation(); michael@0: }, michael@0: michael@0: /** michael@0: * Checks whether a particular index exists in the browser history or not. michael@0: * michael@0: * @param aIndex michael@0: * The index to check for availability for in the history. michael@0: * @return true if the index exists in the browser history, false otherwise. michael@0: */ michael@0: _doesIndexExistInHistory: function HSA__doesIndexExistInHistory(aIndex) { michael@0: try { michael@0: gBrowser.webNavigation.sessionHistory.getEntryAtIndex(aIndex, false); michael@0: } michael@0: catch(ex) { michael@0: return false; michael@0: } michael@0: return true; michael@0: }, michael@0: michael@0: /** michael@0: * Navigates to the index in history that is currently being tracked by michael@0: * |this|. michael@0: */ michael@0: _navigateToHistoryIndex: function HSA__navigateToHistoryIndex() { michael@0: if (this._doesIndexExistInHistory(this._historyIndex)) michael@0: gBrowser.webNavigation.gotoIndex(this._historyIndex); michael@0: else michael@0: this.stopAnimation(); michael@0: }, michael@0: michael@0: /** michael@0: * Checks to see if history swipe animations are supported by this michael@0: * platform/configuration. michael@0: * michael@0: * return true if supported, false otherwise. michael@0: */ michael@0: _isSupported: function HSA__isSupported() { michael@0: return window.matchMedia("(-moz-swipe-animation-enabled)").matches; michael@0: }, michael@0: michael@0: /** michael@0: * Handle fast swiping (i.e. a swipe animation is already in michael@0: * progress when a new one is initiated). This will swap out the snapshots michael@0: * used in the previous animation with the appropriate new ones. michael@0: */ michael@0: _handleFastSwiping: function HSA__handleFastSwiping() { michael@0: this._installCurrentPageSnapshot(null); michael@0: this._installPrevAndNextSnapshots(); michael@0: }, michael@0: michael@0: /** michael@0: * Adds the boxes that contain the snapshots used during the swipe animation. michael@0: */ michael@0: _addBoxes: function HSA__addBoxes() { michael@0: let browserStack = michael@0: document.getAnonymousElementByAttribute(gBrowser.getNotificationBox(), michael@0: "class", "browserStack"); michael@0: this._container = this._createElement("historySwipeAnimationContainer", michael@0: "stack"); michael@0: browserStack.appendChild(this._container); michael@0: michael@0: this._prevBox = this._createElement("historySwipeAnimationPreviousPage", michael@0: "box"); michael@0: this._container.appendChild(this._prevBox); michael@0: michael@0: this._curBox = this._createElement("historySwipeAnimationCurrentPage", michael@0: "box"); michael@0: this._container.appendChild(this._curBox); michael@0: michael@0: this._nextBox = this._createElement("historySwipeAnimationNextPage", michael@0: "box"); michael@0: this._container.appendChild(this._nextBox); michael@0: michael@0: // Cache width and height. michael@0: this._boxWidth = this._curBox.getBoundingClientRect().width; michael@0: this._boxHeight = this._curBox.getBoundingClientRect().height; michael@0: }, michael@0: michael@0: /** michael@0: * Removes the boxes. michael@0: */ michael@0: _removeBoxes: function HSA__removeBoxes() { michael@0: this._curBox = null; michael@0: this._prevBox = null; michael@0: this._nextBox = null; michael@0: if (this._container) michael@0: this._container.parentNode.removeChild(this._container); michael@0: this._container = null; michael@0: this._boxWidth = -1; michael@0: this._boxHeight = -1; michael@0: }, michael@0: michael@0: /** michael@0: * Creates an element with a given identifier and tag name. michael@0: * michael@0: * @param aID michael@0: * An identifier to create the element with. michael@0: * @param aTagName michael@0: * The name of the tag to create the element for. michael@0: * @return the newly created element. michael@0: */ michael@0: _createElement: function HSA__createElement(aID, aTagName) { michael@0: let XULNS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; michael@0: let element = document.createElementNS(XULNS, aTagName); michael@0: element.id = aID; michael@0: return element; michael@0: }, michael@0: michael@0: /** michael@0: * Moves a given box to a given X coordinate position. michael@0: * michael@0: * @param aBox michael@0: * The box element to position. michael@0: * @param aPosition michael@0: * The position (in X coordinates) to move the box element to. michael@0: */ michael@0: _positionBox: function HSA__positionBox(aBox, aPosition) { michael@0: let transform = ""; michael@0: michael@0: if (this._direction == "vertical") michael@0: transform = "translateY(" + this._boxHeight * aPosition + "px)"; michael@0: else michael@0: transform = "translateX(" + this._boxWidth * aPosition + "px)"; michael@0: michael@0: aBox.style.transform = transform; michael@0: }, michael@0: michael@0: /** michael@0: * Verifies that we're ready to take snapshots based on the global pref and michael@0: * the current index in history. michael@0: * michael@0: * @return true if we're ready to take snapshots, false otherwise. michael@0: */ michael@0: _readyToTakeSnapshots: function HSA__readyToTakeSnapshots() { michael@0: if ((this._maxSnapshots < 1) || michael@0: (gBrowser.webNavigation.sessionHistory.index < 0)) { michael@0: return false; michael@0: } michael@0: return true; michael@0: }, michael@0: michael@0: /** michael@0: * Takes a snapshot of the page the browser is currently on. michael@0: */ michael@0: _takeSnapshot: function HSA__takeSnapshot() { michael@0: if (!this._readyToTakeSnapshots()) { michael@0: return; michael@0: } michael@0: michael@0: let canvas = null; michael@0: michael@0: TelemetryStopwatch.start("FX_GESTURE_TAKE_SNAPSHOT_OF_PAGE"); michael@0: try { michael@0: let browser = gBrowser.selectedBrowser; michael@0: let r = browser.getBoundingClientRect(); michael@0: canvas = document.createElementNS("http://www.w3.org/1999/xhtml", michael@0: "canvas"); michael@0: canvas.mozOpaque = true; michael@0: let scale = window.devicePixelRatio; michael@0: canvas.width = r.width * scale; michael@0: canvas.height = r.height * scale; michael@0: let ctx = canvas.getContext("2d"); michael@0: let zoom = browser.markupDocumentViewer.fullZoom * scale; michael@0: ctx.scale(zoom, zoom); michael@0: ctx.drawWindow(browser.contentWindow, michael@0: 0, 0, canvas.width / zoom, canvas.height / zoom, "white", michael@0: ctx.DRAWWINDOW_DO_NOT_FLUSH | ctx.DRAWWINDOW_DRAW_VIEW | michael@0: ctx.DRAWWINDOW_ASYNC_DECODE_IMAGES | michael@0: ctx.DRAWWINDOW_USE_WIDGET_LAYERS); michael@0: } finally { michael@0: TelemetryStopwatch.finish("FX_GESTURE_TAKE_SNAPSHOT_OF_PAGE"); michael@0: } michael@0: michael@0: TelemetryStopwatch.start("FX_GESTURE_INSTALL_SNAPSHOT_OF_PAGE"); michael@0: try { michael@0: this._installCurrentPageSnapshot(canvas); michael@0: this._assignSnapshotToCurrentBrowser(canvas); michael@0: } finally { michael@0: TelemetryStopwatch.finish("FX_GESTURE_INSTALL_SNAPSHOT_OF_PAGE"); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Retrieves the maximum number of snapshots that should be kept in memory. michael@0: * This limit is a global limit and is valid across all open tabs. michael@0: */ michael@0: _getMaxSnapshots: function HSA__getMaxSnapshots() { michael@0: return gPrefService.getIntPref("browser.snapshots.limit"); michael@0: }, michael@0: michael@0: /** michael@0: * Adds a snapshot to the list and initiates the compression of said snapshot. michael@0: * Once the compression is completed, it will replace the uncompressed michael@0: * snapshot in the list. michael@0: * michael@0: * @param aCanvas michael@0: * The snapshot to add to the list and compress. michael@0: */ michael@0: _assignSnapshotToCurrentBrowser: michael@0: function HSA__assignSnapshotToCurrentBrowser(aCanvas) { michael@0: let browser = gBrowser.selectedBrowser; michael@0: let currIndex = browser.webNavigation.sessionHistory.index; michael@0: michael@0: this._removeTrackedSnapshot(currIndex, browser); michael@0: this._addSnapshotRefToArray(currIndex, browser); michael@0: michael@0: if (!("snapshots" in browser)) michael@0: browser.snapshots = []; michael@0: let snapshots = browser.snapshots; michael@0: // Temporarily store the canvas as the compressed snapshot. michael@0: // This avoids a blank page if the user swipes quickly michael@0: // between pages before the compression could complete. michael@0: snapshots[currIndex] = { michael@0: image: aCanvas, michael@0: scale: window.devicePixelRatio michael@0: }; michael@0: }, michael@0: michael@0: /** michael@0: * Compresses the HTMLCanvasElement that's stored at the current history michael@0: * index in the snapshot array and stores the compressed image in its place. michael@0: */ michael@0: _compressSnapshotAtCurrentIndex: michael@0: function HSA__compressSnapshotAtCurrentIndex() { michael@0: if (!this._readyToTakeSnapshots()) { michael@0: // We didn't take a snapshot earlier because we weren't ready to, so michael@0: // there's nothing to compress. michael@0: return; michael@0: } michael@0: michael@0: TelemetryStopwatch.start("FX_GESTURE_COMPRESS_SNAPSHOT_OF_PAGE"); michael@0: try { michael@0: let browser = gBrowser.selectedBrowser; michael@0: let snapshots = browser.snapshots; michael@0: let currIndex = browser.webNavigation.sessionHistory.index; michael@0: michael@0: // Kick off snapshot compression. michael@0: let canvas = snapshots[currIndex].image; michael@0: canvas.toBlob(function(aBlob) { michael@0: if (snapshots[currIndex]) { michael@0: snapshots[currIndex].image = aBlob; michael@0: } michael@0: }, "image/png" michael@0: ); michael@0: } finally { michael@0: TelemetryStopwatch.finish("FX_GESTURE_COMPRESS_SNAPSHOT_OF_PAGE"); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Removes a snapshot identified by the browser and index in the array of michael@0: * snapshots for that browser, if present. If no snapshot could be identified michael@0: * the method simply returns without taking any action. If aIndex is negative, michael@0: * all snapshots for a particular browser will be removed. michael@0: * michael@0: * @param aIndex michael@0: * The index in history of the new snapshot, or negative value if all michael@0: * snapshots for a browser should be removed. michael@0: * @param aBrowser michael@0: * The browser the new snapshot was taken in. michael@0: */ michael@0: _removeTrackedSnapshot: function HSA__removeTrackedSnapshot(aIndex, aBrowser) { michael@0: let arr = this._trackedSnapshots; michael@0: let requiresExactIndexMatch = aIndex >= 0; michael@0: for (let i = 0; i < arr.length; i++) { michael@0: if ((arr[i].browser == aBrowser) && michael@0: (aIndex < 0 || aIndex == arr[i].index)) { michael@0: delete aBrowser.snapshots[arr[i].index]; michael@0: arr.splice(i, 1); michael@0: if (requiresExactIndexMatch) michael@0: return; // Found and removed the only element. michael@0: i--; // Make sure to revisit the index that we just removed an michael@0: // element at. michael@0: } michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Adds a new snapshot reference for a given index and browser to the array michael@0: * of references to tracked snapshots. michael@0: * michael@0: * @param aIndex michael@0: * The index in history of the new snapshot. michael@0: * @param aBrowser michael@0: * The browser the new snapshot was taken in. michael@0: */ michael@0: _addSnapshotRefToArray: michael@0: function HSA__addSnapshotRefToArray(aIndex, aBrowser) { michael@0: let id = { index: aIndex, michael@0: browser: aBrowser }; michael@0: let arr = this._trackedSnapshots; michael@0: arr.unshift(id); michael@0: michael@0: while (arr.length > this._maxSnapshots) { michael@0: let lastElem = arr[arr.length - 1]; michael@0: delete lastElem.browser.snapshots[lastElem.index].image; michael@0: delete lastElem.browser.snapshots[lastElem.index]; michael@0: arr.splice(-1, 1); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Converts a compressed blob to an Image object. In some situations michael@0: * (especially during fast swiping) aBlob may still be a canvas, not a michael@0: * compressed blob. In this case, we simply return the canvas. michael@0: * michael@0: * @param aBlob michael@0: * The compressed blob to convert, or a canvas if a blob compression michael@0: * couldn't complete before this method was called. michael@0: * @return A new Image object representing the converted blob. michael@0: */ michael@0: _convertToImg: function HSA__convertToImg(aBlob) { michael@0: if (!aBlob) michael@0: return null; michael@0: michael@0: // Return aBlob if it's still a canvas and not a compressed blob yet. michael@0: if (aBlob instanceof HTMLCanvasElement) michael@0: return aBlob; michael@0: michael@0: let img = new Image(); michael@0: let url = ""; michael@0: try { michael@0: url = URL.createObjectURL(aBlob); michael@0: img.onload = function() { michael@0: URL.revokeObjectURL(url); michael@0: }; michael@0: } michael@0: finally { michael@0: img.src = url; michael@0: return img; michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Scales the background of a given box element (which uses a given snapshot michael@0: * as background) based on a given scale factor. michael@0: * @param aSnapshot michael@0: * The snapshot that is used as background of aBox. michael@0: * @param aScale michael@0: * The scale factor to use. michael@0: * @param aBox michael@0: * The box element that uses aSnapshot as background. michael@0: */ michael@0: _scaleSnapshot: function HSA__scaleSnapshot(aSnapshot, aScale, aBox) { michael@0: if (aSnapshot && aScale != 1 && aBox) { michael@0: if (aSnapshot instanceof HTMLCanvasElement) { michael@0: aBox.style.backgroundSize = michael@0: aSnapshot.width / aScale + "px " + aSnapshot.height / aScale + "px"; michael@0: } else { michael@0: // snapshot is instanceof HTMLImageElement michael@0: aSnapshot.addEventListener("load", function() { michael@0: aBox.style.backgroundSize = michael@0: aSnapshot.width / aScale + "px " + aSnapshot.height / aScale + "px"; michael@0: }); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Sets the snapshot of the current page to the snapshot passed as parameter, michael@0: * or to the one previously stored for the current index in history if the michael@0: * parameter is null. michael@0: * michael@0: * @param aCanvas michael@0: * The snapshot to set the current page to. If this parameter is null, michael@0: * the previously stored snapshot for this index (if any) will be used. michael@0: */ michael@0: _installCurrentPageSnapshot: michael@0: function HSA__installCurrentPageSnapshot(aCanvas) { michael@0: let currSnapshot = aCanvas; michael@0: let scale = window.devicePixelRatio; michael@0: if (!currSnapshot) { michael@0: let snapshots = gBrowser.selectedBrowser.snapshots || {}; michael@0: let currIndex = this._historyIndex; michael@0: if (currIndex in snapshots) { michael@0: currSnapshot = this._convertToImg(snapshots[currIndex].image); michael@0: scale = snapshots[currIndex].scale; michael@0: } michael@0: } michael@0: this._scaleSnapshot(currSnapshot, scale, this._curBox ? this._curBox : michael@0: null); michael@0: document.mozSetImageElement("historySwipeAnimationCurrentPageSnapshot", michael@0: currSnapshot); michael@0: }, michael@0: michael@0: /** michael@0: * Sets the snapshots of the previous and next pages to the snapshots michael@0: * previously stored for their respective indeces. michael@0: */ michael@0: _installPrevAndNextSnapshots: michael@0: function HSA__installPrevAndNextSnapshots() { michael@0: let snapshots = gBrowser.selectedBrowser.snapshots || []; michael@0: let currIndex = this._historyIndex; michael@0: let prevIndex = currIndex - 1; michael@0: let prevSnapshot = null; michael@0: if (prevIndex in snapshots) { michael@0: prevSnapshot = this._convertToImg(snapshots[prevIndex].image); michael@0: this._scaleSnapshot(prevSnapshot, snapshots[prevIndex].scale, michael@0: this._prevBox); michael@0: } michael@0: document.mozSetImageElement("historySwipeAnimationPreviousPageSnapshot", michael@0: prevSnapshot); michael@0: michael@0: let nextIndex = currIndex + 1; michael@0: let nextSnapshot = null; michael@0: if (nextIndex in snapshots) { michael@0: nextSnapshot = this._convertToImg(snapshots[nextIndex].image); michael@0: this._scaleSnapshot(nextSnapshot, snapshots[nextIndex].scale, michael@0: this._nextBox); michael@0: } michael@0: document.mozSetImageElement("historySwipeAnimationNextPageSnapshot", michael@0: nextSnapshot); michael@0: }, michael@0: };