mirror of
https://github.com/mozilla/gecko-dev.git
synced 2025-11-08 20:28:42 +02:00
Having this event means that we don't have to wait for content to find out whether it's scrollable in the case that no swipe should be happening anyway. IGNORE IDL because I'm only changing comments in the IDL files. --HG-- extra : commitid : 22814fSLcIz extra : rebase_source : e51b4db26333275c9bcb3fd7fece78b7d3653275
1241 lines
41 KiB
JavaScript
1241 lines
41 KiB
JavaScript
# 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.
|
|
|
|
let gGestureSupport = {
|
|
_currentRotation: 0,
|
|
_lastRotateDelta: 0,
|
|
_rotateMomentumThreshold: .75,
|
|
|
|
/**
|
|
* Add or remove mouse gesture event listeners
|
|
*
|
|
* @param aAddListener
|
|
* True to add/init listeners and false to remove/uninit
|
|
*/
|
|
init: function GS_init(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 GS_handleEvent(aEvent) {
|
|
if (!Services.prefs.getBoolPref(
|
|
"dom.debug.propagate_gesture_events_through_content")) {
|
|
aEvent.stopPropagation();
|
|
}
|
|
|
|
// Create a preference object with some defaults
|
|
let def = function(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();
|
|
#ifdef XP_WIN
|
|
this._setupGesture(aEvent, "pinch", def(25, 0), "out", "in");
|
|
#else
|
|
this._setupGesture(aEvent, "pinch", def(150, 1), "out", "in");
|
|
#endif
|
|
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 "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 GS__setupGesture(aEvent, aGesture, aPref, aInc, aDec) {
|
|
// Try to load user-set values from preferences
|
|
for (let [pref, def] in Iterator(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 GS__swipeNavigatesHistory(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 GS__shouldDoSwipeGesture(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 GS__setupSwipeGesture() {
|
|
gHistorySwipeAnimation.startAnimation(false);
|
|
|
|
this._doUpdate = function GS__doUpdate(aEvent) {
|
|
gHistorySwipeAnimation.updateAnimation(aEvent.delta);
|
|
};
|
|
|
|
this._doEnd = function GS__doEnd(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 GS__power(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;
|
|
}, []);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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 GS__doAction(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 GS__getCommand(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 GS__doCommand(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 GS_onSwipe(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 GS_processSwipeEvent(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 GS__getPref(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 = "get" + (type == "boolean" ? "Bool" :
|
|
type == "number" ? "Int" : "Char") + "Pref";
|
|
return gPrefService[getFunc](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)
|
|
let gHistorySwipeAnimation = {
|
|
|
|
active: false,
|
|
isLTR: false,
|
|
|
|
/**
|
|
* Initializes the support for history swipe animations, if it is supported
|
|
* by the platform/configuration.
|
|
*/
|
|
init: function HSA_init() {
|
|
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 HSA_uninit() {
|
|
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 HSA_startAnimation(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();
|
|
}
|
|
}
|
|
else {
|
|
this._startingIndex = gBrowser.webNavigation.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);
|
|
},
|
|
|
|
/**
|
|
* Stops the swipe animation.
|
|
*/
|
|
stopAnimation: function HSA_stopAnimation() {
|
|
gHistorySwipeAnimation._removeBoxes();
|
|
this._historyIndex = gBrowser.webNavigation.sessionHistory.index;
|
|
},
|
|
|
|
/**
|
|
* Updates the animation between two pages in history.
|
|
*
|
|
* @param aVal
|
|
* A floating point value that represents the progress of the
|
|
* swipe gesture.
|
|
*/
|
|
updateAnimation: function HSA_updateAnimation(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 {
|
|
// 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).
|
|
if (this._canGoForward) {
|
|
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);
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Event handler for events relevant to the history swipe animation.
|
|
*
|
|
* @param aEvent
|
|
* An event to process.
|
|
*/
|
|
handleEvent: function HSA_handleEvent(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 HSA_isAnimationRunning() {
|
|
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 HSA_processSwipeEvent(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 HSA_canGoBack() {
|
|
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 HSA_canGoForward() {
|
|
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 HSA_swipeEndEventReceived() {
|
|
if (this._lastSwipeDir != "" && this._historyIndex != this._startingIndex)
|
|
this._navigateToHistoryIndex();
|
|
else
|
|
this.stopAnimation();
|
|
},
|
|
|
|
/**
|
|
* 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 HSA__doesIndexExistInHistory(aIndex) {
|
|
try {
|
|
gBrowser.webNavigation.sessionHistory.getEntryAtIndex(aIndex, false);
|
|
}
|
|
catch(ex) {
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Navigates to the index in history that is currently being tracked by
|
|
* |this|.
|
|
*/
|
|
_navigateToHistoryIndex: function HSA__navigateToHistoryIndex() {
|
|
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 HSA__isSupported() {
|
|
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 HSA__handleFastSwiping() {
|
|
this._installCurrentPageSnapshot(null);
|
|
this._installPrevAndNextSnapshots();
|
|
},
|
|
|
|
/**
|
|
* Adds the boxes that contain the snapshots used during the swipe animation.
|
|
*/
|
|
_addBoxes: function HSA__addBoxes() {
|
|
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 HSA__removeBoxes() {
|
|
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 HSA__createElement(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 HSA__positionBox(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 HSA__readyToTakeSnapshots() {
|
|
if ((this._maxSnapshots < 1) ||
|
|
(gBrowser.webNavigation.sessionHistory.index < 0)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Takes a snapshot of the page the browser is currently on.
|
|
*/
|
|
_takeSnapshot: function HSA__takeSnapshot() {
|
|
if (!this._readyToTakeSnapshots()) {
|
|
return;
|
|
}
|
|
|
|
let canvas = null;
|
|
|
|
TelemetryStopwatch.start("FX_GESTURE_TAKE_SNAPSHOT_OF_PAGE");
|
|
try {
|
|
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);
|
|
} finally {
|
|
TelemetryStopwatch.finish("FX_GESTURE_TAKE_SNAPSHOT_OF_PAGE");
|
|
}
|
|
|
|
TelemetryStopwatch.start("FX_GESTURE_INSTALL_SNAPSHOT_OF_PAGE");
|
|
try {
|
|
this._installCurrentPageSnapshot(canvas);
|
|
this._assignSnapshotToCurrentBrowser(canvas);
|
|
} finally {
|
|
TelemetryStopwatch.finish("FX_GESTURE_INSTALL_SNAPSHOT_OF_PAGE");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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 HSA__getMaxSnapshots() {
|
|
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 = browser.webNavigation.sessionHistory.index;
|
|
|
|
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;
|
|
}
|
|
|
|
TelemetryStopwatch.start("FX_GESTURE_COMPRESS_SNAPSHOT_OF_PAGE");
|
|
try {
|
|
let browser = gBrowser.selectedBrowser;
|
|
let snapshots = browser.snapshots;
|
|
let currIndex = browser.webNavigation.sessionHistory.index;
|
|
|
|
// Kick off snapshot compression.
|
|
let canvas = snapshots[currIndex].image;
|
|
canvas.toBlob(function(aBlob) {
|
|
if (snapshots[currIndex]) {
|
|
snapshots[currIndex].image = aBlob;
|
|
}
|
|
}, "image/png"
|
|
);
|
|
} finally {
|
|
TelemetryStopwatch.finish("FX_GESTURE_COMPRESS_SNAPSHOT_OF_PAGE");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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 HSA__removeTrackedSnapshot(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 HSA__convertToImg(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 HSA__scaleSnapshot(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);
|
|
},
|
|
};
|