 |
1 # This Source Code Form is subject to the terms of the Mozilla Public
2 # License, v. 2.0. If a copy of the MPL was not distributed with this
3 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5 // Simple gestures support
6 //
7 // As per bug #412486, web content must not be allowed to receive any
8 // simple gesture events. Multi-touch gesture APIs are in their
9 // infancy and we do NOT want to be forced into supporting an API that
10 // will probably have to change in the future. (The current Mac OS X
11 // API is undocumented and was reverse-engineered.) Until support is
12 // implemented in the event dispatcher to keep these events as
13 // chrome-only, we must listen for the simple gesture events during
14 // the capturing phase and call stopPropagation on every event.
15
16 let gGestureSupport = {
17 _currentRotation: 0,
18 _lastRotateDelta: 0,
19 _rotateMomentumThreshold: .75,
20
21 /**
22 * Add or remove mouse gesture event listeners
23 *
24 * @param aAddListener
25 * True to add/init listeners and false to remove/uninit
26 */
27 init: function GS_init(aAddListener) {
28 const gestureEvents = ["SwipeGestureStart",
29 "SwipeGestureUpdate", "SwipeGestureEnd", "SwipeGesture",
30 "MagnifyGestureStart", "MagnifyGestureUpdate", "MagnifyGesture",
31 "RotateGestureStart", "RotateGestureUpdate", "RotateGesture",
32 "TapGesture", "PressTapGesture"];
33
34 let addRemove = aAddListener ? window.addEventListener :
35 window.removeEventListener;
36
37 for (let event of gestureEvents) {
38 addRemove("Moz" + event, this, true);
39 }
40 },
41
42 /**
43 * Dispatch events based on the type of mouse gesture event. For now, make
44 * sure to stop propagation of every gesture event so that web content cannot
45 * receive gesture events.
46 *
47 * @param aEvent
48 * The gesture event to handle
49 */
50 handleEvent: function GS_handleEvent(aEvent) {
51 if (!Services.prefs.getBoolPref(
52 "dom.debug.propagate_gesture_events_through_content")) {
53 aEvent.stopPropagation();
54 }
55
56 // Create a preference object with some defaults
57 let def = function(aThreshold, aLatched)
58 ({ threshold: aThreshold, latched: !!aLatched });
59
60 switch (aEvent.type) {
61 case "MozSwipeGestureStart":
62 if (this._setupSwipeGesture(aEvent)) {
63 aEvent.preventDefault();
64 }
65 break;
66 case "MozSwipeGestureUpdate":
67 aEvent.preventDefault();
68 this._doUpdate(aEvent);
69 break;
70 case "MozSwipeGestureEnd":
71 aEvent.preventDefault();
72 this._doEnd(aEvent);
73 break;
74 case "MozSwipeGesture":
75 aEvent.preventDefault();
76 this.onSwipe(aEvent);
77 break;
78 case "MozMagnifyGestureStart":
79 aEvent.preventDefault();
80 #ifdef XP_WIN
81 this._setupGesture(aEvent, "pinch", def(25, 0), "out", "in");
82 #else
83 this._setupGesture(aEvent, "pinch", def(150, 1), "out", "in");
84 #endif
85 break;
86 case "MozRotateGestureStart":
87 aEvent.preventDefault();
88 this._setupGesture(aEvent, "twist", def(25, 0), "right", "left");
89 break;
90 case "MozMagnifyGestureUpdate":
91 case "MozRotateGestureUpdate":
92 aEvent.preventDefault();
93 this._doUpdate(aEvent);
94 break;
95 case "MozTapGesture":
96 aEvent.preventDefault();
97 this._doAction(aEvent, ["tap"]);
98 break;
99 case "MozRotateGesture":
100 aEvent.preventDefault();
101 this._doAction(aEvent, ["twist", "end"]);
102 break;
103 /* case "MozPressTapGesture":
104 break; */
105 }
106 },
107
108 /**
109 * Called at the start of "pinch" and "twist" gestures to setup all of the
110 * information needed to process the gesture
111 *
112 * @param aEvent
113 * The continual motion start event to handle
114 * @param aGesture
115 * Name of the gesture to handle
116 * @param aPref
117 * Preference object with the names of preferences and defaults
118 * @param aInc
119 * Command to trigger for increasing motion (without gesture name)
120 * @param aDec
121 * Command to trigger for decreasing motion (without gesture name)
122 */
123 _setupGesture: function GS__setupGesture(aEvent, aGesture, aPref, aInc, aDec) {
124 // Try to load user-set values from preferences
125 for (let [pref, def] in Iterator(aPref))
126 aPref[pref] = this._getPref(aGesture + "." + pref, def);
127
128 // Keep track of the total deltas and latching behavior
129 let offset = 0;
130 let latchDir = aEvent.delta > 0 ? 1 : -1;
131 let isLatched = false;
132
133 // Create the update function here to capture closure state
134 this._doUpdate = function GS__doUpdate(aEvent) {
135 // Update the offset with new event data
136 offset += aEvent.delta;
137
138 // Check if the cumulative deltas exceed the threshold
139 if (Math.abs(offset) > aPref["threshold"]) {
140 // Trigger the action if we don't care about latching; otherwise, make
141 // sure either we're not latched and going the same direction of the
142 // initial motion; or we're latched and going the opposite way
143 let sameDir = (latchDir ^ offset) >= 0;
144 if (!aPref["latched"] || (isLatched ^ sameDir)) {
145 this._doAction(aEvent, [aGesture, offset > 0 ? aInc : aDec]);
146
147 // We must be getting latched or leaving it, so just toggle
148 isLatched = !isLatched;
149 }
150
151 // Reset motion counter to prepare for more of the same gesture
152 offset = 0;
153 }
154 };
155
156 // The start event also contains deltas, so handle an update right away
157 this._doUpdate(aEvent);
158 },
159
160 /**
161 * Checks whether a swipe gesture event can navigate the browser history or
162 * not.
163 *
164 * @param aEvent
165 * The swipe gesture event.
166 * @return true if the swipe event may navigate the history, false othwerwise.
167 */
168 _swipeNavigatesHistory: function GS__swipeNavigatesHistory(aEvent) {
169 return this._getCommand(aEvent, ["swipe", "left"])
170 == "Browser:BackOrBackDuplicate" &&
171 this._getCommand(aEvent, ["swipe", "right"])
172 == "Browser:ForwardOrForwardDuplicate";
173 },
174
175 /**
176 * Sets up swipe gestures. This includes setting up swipe animations for the
177 * gesture, if enabled.
178 *
179 * @param aEvent
180 * The swipe gesture start event.
181 * @return true if swipe gestures could successfully be set up, false
182 * othwerwise.
183 */
184 _setupSwipeGesture: function GS__setupSwipeGesture(aEvent) {
185 if (!this._swipeNavigatesHistory(aEvent)) {
186 return false;
187 }
188
189 let isVerticalSwipe = false;
190 if (aEvent.direction == aEvent.DIRECTION_UP) {
191 if (gMultiProcessBrowser || content.pageYOffset > 0) {
192 return false;
193 }
194 isVerticalSwipe = true;
195 } else if (aEvent.direction == aEvent.DIRECTION_DOWN) {
196 if (gMultiProcessBrowser || content.pageYOffset < content.scrollMaxY) {
197 return false;
198 }
199 isVerticalSwipe = true;
200 }
201 if (isVerticalSwipe) {
202 // Vertical overscroll has been temporarily disabled until bug 939480 is
203 // fixed.
204 return false;
205 }
206
207 let canGoBack = gHistorySwipeAnimation.canGoBack();
208 let canGoForward = gHistorySwipeAnimation.canGoForward();
209 let isLTR = gHistorySwipeAnimation.isLTR;
210
211 if (canGoBack) {
212 aEvent.allowedDirections |= isLTR ? aEvent.DIRECTION_LEFT :
213 aEvent.DIRECTION_RIGHT;
214 }
215 if (canGoForward) {
216 aEvent.allowedDirections |= isLTR ? aEvent.DIRECTION_RIGHT :
217 aEvent.DIRECTION_LEFT;
218 }
219
220 gHistorySwipeAnimation.startAnimation(isVerticalSwipe);
221
222 this._doUpdate = function GS__doUpdate(aEvent) {
223 gHistorySwipeAnimation.updateAnimation(aEvent.delta);
224 };
225
226 this._doEnd = function GS__doEnd(aEvent) {
227 gHistorySwipeAnimation.swipeEndEventReceived();
228
229 this._doUpdate = function (aEvent) {};
230 this._doEnd = function (aEvent) {};
231 }
232
233 return true;
234 },
235
236 /**
237 * Generator producing the powerset of the input array where the first result
238 * is the complete set and the last result (before StopIteration) is empty.
239 *
240 * @param aArray
241 * Source array containing any number of elements
242 * @yield Array that is a subset of the input array from full set to empty
243 */
244 _power: function GS__power(aArray) {
245 // Create a bitmask based on the length of the array
246 let num = 1 << aArray.length;
247 while (--num >= 0) {
248 // Only select array elements where the current bit is set
249 yield aArray.reduce(function (aPrev, aCurr, aIndex) {
250 if (num & 1 << aIndex)
251 aPrev.push(aCurr);
252 return aPrev;
253 }, []);
254 }
255 },
256
257 /**
258 * Determine what action to do for the gesture based on which keys are
259 * pressed and which commands are set, and execute the command.
260 *
261 * @param aEvent
262 * The original gesture event to convert into a fake click event
263 * @param aGesture
264 * Array of gesture name parts (to be joined by periods)
265 * @return Name of the executed command. Returns null if no command is
266 * found.
267 */
268 _doAction: function GS__doAction(aEvent, aGesture) {
269 let command = this._getCommand(aEvent, aGesture);
270 return command && this._doCommand(aEvent, command);
271 },
272
273 /**
274 * Determine what action to do for the gesture based on which keys are
275 * pressed and which commands are set
276 *
277 * @param aEvent
278 * The original gesture event to convert into a fake click event
279 * @param aGesture
280 * Array of gesture name parts (to be joined by periods)
281 */
282 _getCommand: function GS__getCommand(aEvent, aGesture) {
283 // Create an array of pressed keys in a fixed order so that a command for
284 // "meta" is preferred over "ctrl" when both buttons are pressed (and a
285 // command for both don't exist)
286 let keyCombos = [];
287 for (let key of ["shift", "alt", "ctrl", "meta"]) {
288 if (aEvent[key + "Key"])
289 keyCombos.push(key);
290 }
291
292 // Try each combination of key presses in decreasing order for commands
293 for (let subCombo of this._power(keyCombos)) {
294 // Convert a gesture and pressed keys into the corresponding command
295 // action where the preference has the gesture before "shift" before
296 // "alt" before "ctrl" before "meta" all separated by periods
297 let command;
298 try {
299 command = this._getPref(aGesture.concat(subCombo).join("."));
300 } catch (e) {}
301
302 if (command)
303 return command;
304 }
305 return null;
306 },
307
308 /**
309 * Execute the specified command.
310 *
311 * @param aEvent
312 * The original gesture event to convert into a fake click event
313 * @param aCommand
314 * Name of the command found for the event's keys and gesture.
315 */
316 _doCommand: function GS__doCommand(aEvent, aCommand) {
317 let node = document.getElementById(aCommand);
318 if (node) {
319 if (node.getAttribute("disabled") != "true") {
320 let cmdEvent = document.createEvent("xulcommandevent");
321 cmdEvent.initCommandEvent("command", true, true, window, 0,
322 aEvent.ctrlKey, aEvent.altKey,
323 aEvent.shiftKey, aEvent.metaKey, aEvent);
324 node.dispatchEvent(cmdEvent);
325 }
326
327 }
328 else {
329 goDoCommand(aCommand);
330 }
331 },
332
333 /**
334 * Handle continual motion events. This function will be set by
335 * _setupGesture or _setupSwipe.
336 *
337 * @param aEvent
338 * The continual motion update event to handle
339 */
340 _doUpdate: function(aEvent) {},
341
342 /**
343 * Handle gesture end events. This function will be set by _setupSwipe.
344 *
345 * @param aEvent
346 * The gesture end event to handle
347 */
348 _doEnd: function(aEvent) {},
349
350 /**
351 * Convert the swipe gesture into a browser action based on the direction.
352 *
353 * @param aEvent
354 * The swipe event to handle
355 */
356 onSwipe: function GS_onSwipe(aEvent) {
357 // Figure out which one (and only one) direction was triggered
358 for (let dir of ["UP", "RIGHT", "DOWN", "LEFT"]) {
359 if (aEvent.direction == aEvent["DIRECTION_" + dir]) {
360 this._coordinateSwipeEventWithAnimation(aEvent, dir);
361 break;
362 }
363 }
364 },
365
366 /**
367 * Process a swipe event based on the given direction.
368 *
369 * @param aEvent
370 * The swipe event to handle
371 * @param aDir
372 * The direction for the swipe event
373 */
374 processSwipeEvent: function GS_processSwipeEvent(aEvent, aDir) {
375 this._doAction(aEvent, ["swipe", aDir.toLowerCase()]);
376 },
377
378 /**
379 * Coordinates the swipe event with the swipe animation, if any.
380 * If an animation is currently running, the swipe event will be
381 * processed once the animation stops. This will guarantee a fluid
382 * motion of the animation.
383 *
384 * @param aEvent
385 * The swipe event to handle
386 * @param aDir
387 * The direction for the swipe event
388 */
389 _coordinateSwipeEventWithAnimation:
390 function GS__coordinateSwipeEventWithAnimation(aEvent, aDir) {
391 if ((gHistorySwipeAnimation.isAnimationRunning()) &&
392 (aDir == "RIGHT" || aDir == "LEFT")) {
393 gHistorySwipeAnimation.processSwipeEvent(aEvent, aDir);
394 }
395 else {
396 this.processSwipeEvent(aEvent, aDir);
397 }
398 },
399
400 /**
401 * Get a gesture preference or use a default if it doesn't exist
402 *
403 * @param aPref
404 * Name of the preference to load under the gesture branch
405 * @param aDef
406 * Default value if the preference doesn't exist
407 */
408 _getPref: function GS__getPref(aPref, aDef) {
409 // Preferences branch under which all gestures preferences are stored
410 const branch = "browser.gesture.";
411
412 try {
413 // Determine what type of data to load based on default value's type
414 let type = typeof aDef;
415 let getFunc = "get" + (type == "boolean" ? "Bool" :
416 type == "number" ? "Int" : "Char") + "Pref";
417 return gPrefService[getFunc](branch + aPref);
418 }
419 catch (e) {
420 return aDef;
421 }
422 },
423
424 /**
425 * Perform rotation for ImageDocuments
426 *
427 * @param aEvent
428 * The MozRotateGestureUpdate event triggering this call
429 */
430 rotate: function(aEvent) {
431 if (!(content.document instanceof ImageDocument))
432 return;
433
434 let contentElement = content.document.body.firstElementChild;
435 if (!contentElement)
436 return;
437 // If we're currently snapping, cancel that snap
438 if (contentElement.classList.contains("completeRotation"))
439 this._clearCompleteRotation();
440
441 this.rotation = Math.round(this.rotation + aEvent.delta);
442 contentElement.style.transform = "rotate(" + this.rotation + "deg)";
443 this._lastRotateDelta = aEvent.delta;
444 },
445
446 /**
447 * Perform a rotation end for ImageDocuments
448 */
449 rotateEnd: function() {
450 if (!(content.document instanceof ImageDocument))
451 return;
452
453 let contentElement = content.document.body.firstElementChild;
454 if (!contentElement)
455 return;
456
457 let transitionRotation = 0;
458
459 // The reason that 360 is allowed here is because when rotating between
460 // 315 and 360, setting rotate(0deg) will cause it to rotate the wrong
461 // direction around--spinning wildly.
462 if (this.rotation <= 45)
463 transitionRotation = 0;
464 else if (this.rotation > 45 && this.rotation <= 135)
465 transitionRotation = 90;
466 else if (this.rotation > 135 && this.rotation <= 225)
467 transitionRotation = 180;
468 else if (this.rotation > 225 && this.rotation <= 315)
469 transitionRotation = 270;
470 else
471 transitionRotation = 360;
472
473 // If we're going fast enough, and we didn't already snap ahead of rotation,
474 // then snap ahead of rotation to simulate momentum
475 if (this._lastRotateDelta > this._rotateMomentumThreshold &&
476 this.rotation > transitionRotation)
477 transitionRotation += 90;
478 else if (this._lastRotateDelta < -1 * this._rotateMomentumThreshold &&
479 this.rotation < transitionRotation)
480 transitionRotation -= 90;
481
482 // Only add the completeRotation class if it is is necessary
483 if (transitionRotation != this.rotation) {
484 contentElement.classList.add("completeRotation");
485 contentElement.addEventListener("transitionend", this._clearCompleteRotation);
486 }
487
488 contentElement.style.transform = "rotate(" + transitionRotation + "deg)";
489 this.rotation = transitionRotation;
490 },
491
492 /**
493 * Gets the current rotation for the ImageDocument
494 */
495 get rotation() {
496 return this._currentRotation;
497 },
498
499 /**
500 * Sets the current rotation for the ImageDocument
501 *
502 * @param aVal
503 * The new value to take. Can be any value, but it will be bounded to
504 * 0 inclusive to 360 exclusive.
505 */
506 set rotation(aVal) {
507 this._currentRotation = aVal % 360;
508 if (this._currentRotation < 0)
509 this._currentRotation += 360;
510 return this._currentRotation;
511 },
512
513 /**
514 * When the location/tab changes, need to reload the current rotation for the
515 * image
516 */
517 restoreRotationState: function() {
518 // Bug 863514 - Make gesture support work in electrolysis
519 if (gMultiProcessBrowser)
520 return;
521
522 if (!(content.document instanceof ImageDocument))
523 return;
524
525 let contentElement = content.document.body.firstElementChild;
526 let transformValue = content.window.getComputedStyle(contentElement, null)
527 .transform;
528
529 if (transformValue == "none") {
530 this.rotation = 0;
531 return;
532 }
533
534 // transformValue is a rotation matrix--split it and do mathemagic to
535 // obtain the real rotation value
536 transformValue = transformValue.split("(")[1]
537 .split(")")[0]
538 .split(",");
539 this.rotation = Math.round(Math.atan2(transformValue[1], transformValue[0]) *
540 (180 / Math.PI));
541 },
542
543 /**
544 * Removes the transition rule by removing the completeRotation class
545 */
546 _clearCompleteRotation: function() {
547 let contentElement = content.document &&
548 content.document instanceof ImageDocument &&
549 content.document.body &&
550 content.document.body.firstElementChild;
551 if (!contentElement)
552 return;
553 contentElement.classList.remove("completeRotation");
554 contentElement.removeEventListener("transitionend", this._clearCompleteRotation);
555 },
556 };
557
558 // History Swipe Animation Support (bug 678392)
559 let gHistorySwipeAnimation = {
560
561 active: false,
562 isLTR: false,
563
564 /**
565 * Initializes the support for history swipe animations, if it is supported
566 * by the platform/configuration.
567 */
568 init: function HSA_init() {
569 if (!this._isSupported())
570 return;
571
572 this.active = false;
573 this.isLTR = document.documentElement.matches(":-moz-locale-dir(ltr)");
574 this._trackedSnapshots = [];
575 this._startingIndex = -1;
576 this._historyIndex = -1;
577 this._boxWidth = -1;
578 this._boxHeight = -1;
579 this._maxSnapshots = this._getMaxSnapshots();
580 this._lastSwipeDir = "";
581 this._direction = "horizontal";
582
583 // We only want to activate history swipe animations if we store snapshots.
584 // If we don't store any, we handle horizontal swipes without animations.
585 if (this._maxSnapshots > 0) {
586 this.active = true;
587 gBrowser.addEventListener("pagehide", this, false);
588 gBrowser.addEventListener("pageshow", this, false);
589 gBrowser.addEventListener("popstate", this, false);
590 gBrowser.addEventListener("DOMModalDialogClosed", this, false);
591 gBrowser.tabContainer.addEventListener("TabClose", this, false);
592 }
593 },
594
595 /**
596 * Uninitializes the support for history swipe animations.
597 */
598 uninit: function HSA_uninit() {
599 gBrowser.removeEventListener("pagehide", this, false);
600 gBrowser.removeEventListener("pageshow", this, false);
601 gBrowser.removeEventListener("popstate", this, false);
602 gBrowser.removeEventListener("DOMModalDialogClosed", this, false);
603 gBrowser.tabContainer.removeEventListener("TabClose", this, false);
604
605 this.active = false;
606 this.isLTR = false;
607 },
608
609 /**
610 * Starts the swipe animation and handles fast swiping (i.e. a swipe animation
611 * is already in progress when a new one is initiated).
612 *
613 * @param aIsVerticalSwipe
614 * Whether we're dealing with a vertical swipe or not.
615 */
616 startAnimation: function HSA_startAnimation(aIsVerticalSwipe) {
617 this._direction = aIsVerticalSwipe ? "vertical" : "horizontal";
618
619 if (this.isAnimationRunning()) {
620 // If this is a horizontal scroll, or if this is a vertical scroll that
621 // was started while a horizontal scroll was still running, handle it as
622 // as a fast swipe. In the case of the latter scenario, this allows us to
623 // start the vertical animation without first loading the final page, or
624 // taking another snapshot. If vertical scrolls are initiated repeatedly
625 // without prior horizontal scroll we skip this and restart the animation
626 // from 0.
627 if (this._direction == "horizontal" || this._lastSwipeDir != "") {
628 gBrowser.stop();
629 this._lastSwipeDir = "RELOAD"; // just ensure that != ""
630 this._canGoBack = this.canGoBack();
631 this._canGoForward = this.canGoForward();
632 this._handleFastSwiping();
633 }
634 }
635 else {
636 this._startingIndex = gBrowser.webNavigation.sessionHistory.index;
637 this._historyIndex = this._startingIndex;
638 this._canGoBack = this.canGoBack();
639 this._canGoForward = this.canGoForward();
640 if (this.active) {
641 this._addBoxes();
642 this._takeSnapshot();
643 this._installPrevAndNextSnapshots();
644 this._lastSwipeDir = "";
645 }
646 }
647 this.updateAnimation(0);
648 },
649
650 /**
651 * Stops the swipe animation.
652 */
653 stopAnimation: function HSA_stopAnimation() {
654 gHistorySwipeAnimation._removeBoxes();
655 this._historyIndex = gBrowser.webNavigation.sessionHistory.index;
656 },
657
658 /**
659 * Updates the animation between two pages in history.
660 *
661 * @param aVal
662 * A floating point value that represents the progress of the
663 * swipe gesture.
664 */
665 updateAnimation: function HSA_updateAnimation(aVal) {
666 if (!this.isAnimationRunning()) {
667 return;
668 }
669
670 // We use the following value to decrease the bounce effect when scrolling
671 // to the top or bottom of the page, or when swiping back/forward past the
672 // browsing history. This value was determined experimentally.
673 let dampValue = 4;
674 if (this._direction == "vertical") {
675 this._prevBox.collapsed = true;
676 this._nextBox.collapsed = true;
677 this._positionBox(this._curBox, -1 * aVal / dampValue);
678 } else if ((aVal >= 0 && this.isLTR) ||
679 (aVal <= 0 && !this.isLTR)) {
680 let tempDampValue = 1;
681 if (this._canGoBack) {
682 this._prevBox.collapsed = false;
683 } else {
684 tempDampValue = dampValue;
685 this._prevBox.collapsed = true;
686 }
687
688 // The current page is pushed to the right (LTR) or left (RTL),
689 // the intention is to go back.
690 // If there is a page to go back to, it should show in the background.
691 this._positionBox(this._curBox, aVal / tempDampValue);
692
693 // The forward page should be pushed offscreen all the way to the right.
694 this._positionBox(this._nextBox, 1);
695 } else {
696 // The intention is to go forward. If there is a page to go forward to,
697 // it should slide in from the right (LTR) or left (RTL).
698 // Otherwise, the current page should slide to the left (LTR) or
699 // right (RTL) and the backdrop should appear in the background.
700 // For the backdrop to be visible in that case, the previous page needs
701 // to be hidden (if it exists).
702 if (this._canGoForward) {
703 this._nextBox.collapsed = false;
704 let offset = this.isLTR ? 1 : -1;
705 this._positionBox(this._curBox, 0);
706 this._positionBox(this._nextBox, offset + aVal);
707 } else {
708 this._prevBox.collapsed = true;
709 this._positionBox(this._curBox, aVal / dampValue);
710 }
711 }
712 },
713
714 /**
715 * Event handler for events relevant to the history swipe animation.
716 *
717 * @param aEvent
718 * An event to process.
719 */
720 handleEvent: function HSA_handleEvent(aEvent) {
721 let browser = gBrowser.selectedBrowser;
722 switch (aEvent.type) {
723 case "TabClose":
724 let browserForTab = gBrowser.getBrowserForTab(aEvent.target);
725 this._removeTrackedSnapshot(-1, browserForTab);
726 break;
727 case "DOMModalDialogClosed":
728 this.stopAnimation();
729 break;
730 case "pageshow":
731 if (aEvent.target == browser.contentDocument) {
732 this.stopAnimation();
733 }
734 break;
735 case "popstate":
736 if (aEvent.target == browser.contentDocument.defaultView) {
737 this.stopAnimation();
738 }
739 break;
740 case "pagehide":
741 if (aEvent.target == browser.contentDocument) {
742 // Take and compress a snapshot of a page whenever it's about to be
743 // navigated away from. We already have a snapshot of the page if an
744 // animation is running, so we're left with compressing it.
745 if (!this.isAnimationRunning()) {
746 this._takeSnapshot();
747 }
748 this._compressSnapshotAtCurrentIndex();
749 }
750 break;
751 }
752 },
753
754 /**
755 * Checks whether the history swipe animation is currently running or not.
756 *
757 * @return true if the animation is currently running, false otherwise.
758 */
759 isAnimationRunning: function HSA_isAnimationRunning() {
760 return !!this._container;
761 },
762
763 /**
764 * Process a swipe event based on the given direction.
765 *
766 * @param aEvent
767 * The swipe event to handle
768 * @param aDir
769 * The direction for the swipe event
770 */
771 processSwipeEvent: function HSA_processSwipeEvent(aEvent, aDir) {
772 if (aDir == "RIGHT")
773 this._historyIndex += this.isLTR ? 1 : -1;
774 else if (aDir == "LEFT")
775 this._historyIndex += this.isLTR ? -1 : 1;
776 else
777 return;
778 this._lastSwipeDir = aDir;
779 },
780
781 /**
782 * Checks if there is a page in the browser history to go back to.
783 *
784 * @return true if there is a previous page in history, false otherwise.
785 */
786 canGoBack: function HSA_canGoBack() {
787 if (this.isAnimationRunning())
788 return this._doesIndexExistInHistory(this._historyIndex - 1);
789 return gBrowser.webNavigation.canGoBack;
790 },
791
792 /**
793 * Checks if there is a page in the browser history to go forward to.
794 *
795 * @return true if there is a next page in history, false otherwise.
796 */
797 canGoForward: function HSA_canGoForward() {
798 if (this.isAnimationRunning())
799 return this._doesIndexExistInHistory(this._historyIndex + 1);
800 return gBrowser.webNavigation.canGoForward;
801 },
802
803 /**
804 * Used to notify the history swipe animation that the OS sent a swipe end
805 * event and that we should navigate to the page that the user swiped to, if
806 * any. This will also result in the animation overlay to be torn down.
807 */
808 swipeEndEventReceived: function HSA_swipeEndEventReceived() {
809 if (this._lastSwipeDir != "" && this._historyIndex != this._startingIndex)
810 this._navigateToHistoryIndex();
811 else
812 this.stopAnimation();
813 },
814
815 /**
816 * Checks whether a particular index exists in the browser history or not.
817 *
818 * @param aIndex
819 * The index to check for availability for in the history.
820 * @return true if the index exists in the browser history, false otherwise.
821 */
822 _doesIndexExistInHistory: function HSA__doesIndexExistInHistory(aIndex) {
823 try {
824 gBrowser.webNavigation.sessionHistory.getEntryAtIndex(aIndex, false);
825 }
826 catch(ex) {
827 return false;
828 }
829 return true;
830 },
831
832 /**
833 * Navigates to the index in history that is currently being tracked by
834 * |this|.
835 */
836 _navigateToHistoryIndex: function HSA__navigateToHistoryIndex() {
837 if (this._doesIndexExistInHistory(this._historyIndex))
838 gBrowser.webNavigation.gotoIndex(this._historyIndex);
839 else
840 this.stopAnimation();
841 },
842
843 /**
844 * Checks to see if history swipe animations are supported by this
845 * platform/configuration.
846 *
847 * return true if supported, false otherwise.
848 */
849 _isSupported: function HSA__isSupported() {
850 return window.matchMedia("(-moz-swipe-animation-enabled)").matches;
851 },
852
853 /**
854 * Handle fast swiping (i.e. a swipe animation is already in
855 * progress when a new one is initiated). This will swap out the snapshots
856 * used in the previous animation with the appropriate new ones.
857 */
858 _handleFastSwiping: function HSA__handleFastSwiping() {
859 this._installCurrentPageSnapshot(null);
860 this._installPrevAndNextSnapshots();
861 },
862
863 /**
864 * Adds the boxes that contain the snapshots used during the swipe animation.
865 */
866 _addBoxes: function HSA__addBoxes() {
867 let browserStack =
868 document.getAnonymousElementByAttribute(gBrowser.getNotificationBox(),
869 "class", "browserStack");
870 this._container = this._createElement("historySwipeAnimationContainer",
871 "stack");
872 browserStack.appendChild(this._container);
873
874 this._prevBox = this._createElement("historySwipeAnimationPreviousPage",
875 "box");
876 this._container.appendChild(this._prevBox);
877
878 this._curBox = this._createElement("historySwipeAnimationCurrentPage",
879 "box");
880 this._container.appendChild(this._curBox);
881
882 this._nextBox = this._createElement("historySwipeAnimationNextPage",
883 "box");
884 this._container.appendChild(this._nextBox);
885
886 // Cache width and height.
887 this._boxWidth = this._curBox.getBoundingClientRect().width;
888 this._boxHeight = this._curBox.getBoundingClientRect().height;
889 },
890
891 /**
892 * Removes the boxes.
893 */
894 _removeBoxes: function HSA__removeBoxes() {
895 this._curBox = null;
896 this._prevBox = null;
897 this._nextBox = null;
898 if (this._container)
899 this._container.parentNode.removeChild(this._container);
900 this._container = null;
901 this._boxWidth = -1;
902 this._boxHeight = -1;
903 },
904
905 /**
906 * Creates an element with a given identifier and tag name.
907 *
908 * @param aID
909 * An identifier to create the element with.
910 * @param aTagName
911 * The name of the tag to create the element for.
912 * @return the newly created element.
913 */
914 _createElement: function HSA__createElement(aID, aTagName) {
915 let XULNS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
916 let element = document.createElementNS(XULNS, aTagName);
917 element.id = aID;
918 return element;
919 },
920
921 /**
922 * Moves a given box to a given X coordinate position.
923 *
924 * @param aBox
925 * The box element to position.
926 * @param aPosition
927 * The position (in X coordinates) to move the box element to.
928 */
929 _positionBox: function HSA__positionBox(aBox, aPosition) {
930 let transform = "";
931
932 if (this._direction == "vertical")
933 transform = "translateY(" + this._boxHeight * aPosition + "px)";
934 else
935 transform = "translateX(" + this._boxWidth * aPosition + "px)";
936
937 aBox.style.transform = transform;
938 },
939
940 /**
941 * Verifies that we're ready to take snapshots based on the global pref and
942 * the current index in history.
943 *
944 * @return true if we're ready to take snapshots, false otherwise.
945 */
946 _readyToTakeSnapshots: function HSA__readyToTakeSnapshots() {
947 if ((this._maxSnapshots < 1) ||
948 (gBrowser.webNavigation.sessionHistory.index < 0)) {
949 return false;
950 }
951 return true;
952 },
953
954 /**
955 * Takes a snapshot of the page the browser is currently on.
956 */
957 _takeSnapshot: function HSA__takeSnapshot() {
958 if (!this._readyToTakeSnapshots()) {
959 return;
960 }
961
962 let canvas = null;
963
964 TelemetryStopwatch.start("FX_GESTURE_TAKE_SNAPSHOT_OF_PAGE");
965 try {
966 let browser = gBrowser.selectedBrowser;
967 let r = browser.getBoundingClientRect();
968 canvas = document.createElementNS("http://www.w3.org/1999/xhtml",
969 "canvas");
970 canvas.mozOpaque = true;
971 let scale = window.devicePixelRatio;
972 canvas.width = r.width * scale;
973 canvas.height = r.height * scale;
974 let ctx = canvas.getContext("2d");
975 let zoom = browser.markupDocumentViewer.fullZoom * scale;
976 ctx.scale(zoom, zoom);
977 ctx.drawWindow(browser.contentWindow,
978 0, 0, canvas.width / zoom, canvas.height / zoom, "white",
979 ctx.DRAWWINDOW_DO_NOT_FLUSH | ctx.DRAWWINDOW_DRAW_VIEW |
980 ctx.DRAWWINDOW_ASYNC_DECODE_IMAGES |
981 ctx.DRAWWINDOW_USE_WIDGET_LAYERS);
982 } finally {
983 TelemetryStopwatch.finish("FX_GESTURE_TAKE_SNAPSHOT_OF_PAGE");
984 }
985
986 TelemetryStopwatch.start("FX_GESTURE_INSTALL_SNAPSHOT_OF_PAGE");
987 try {
988 this._installCurrentPageSnapshot(canvas);
989 this._assignSnapshotToCurrentBrowser(canvas);
990 } finally {
991 TelemetryStopwatch.finish("FX_GESTURE_INSTALL_SNAPSHOT_OF_PAGE");
992 }
993 },
994
995 /**
996 * Retrieves the maximum number of snapshots that should be kept in memory.
997 * This limit is a global limit and is valid across all open tabs.
998 */
999 _getMaxSnapshots: function HSA__getMaxSnapshots() {
1000 return gPrefService.getIntPref("browser.snapshots.limit");
1001 },
1002
1003 /**
1004 * Adds a snapshot to the list and initiates the compression of said snapshot.
1005 * Once the compression is completed, it will replace the uncompressed
1006 * snapshot in the list.
1007 *
1008 * @param aCanvas
1009 * The snapshot to add to the list and compress.
1010 */
1011 _assignSnapshotToCurrentBrowser:
1012 function HSA__assignSnapshotToCurrentBrowser(aCanvas) {
1013 let browser = gBrowser.selectedBrowser;
1014 let currIndex = browser.webNavigation.sessionHistory.index;
1015
1016 this._removeTrackedSnapshot(currIndex, browser);
1017 this._addSnapshotRefToArray(currIndex, browser);
1018
1019 if (!("snapshots" in browser))
1020 browser.snapshots = [];
1021 let snapshots = browser.snapshots;
1022 // Temporarily store the canvas as the compressed snapshot.
1023 // This avoids a blank page if the user swipes quickly
1024 // between pages before the compression could complete.
1025 snapshots[currIndex] = {
1026 image: aCanvas,
1027 scale: window.devicePixelRatio
1028 };
1029 },
1030
1031 /**
1032 * Compresses the HTMLCanvasElement that's stored at the current history
1033 * index in the snapshot array and stores the compressed image in its place.
1034 */
1035 _compressSnapshotAtCurrentIndex:
1036 function HSA__compressSnapshotAtCurrentIndex() {
1037 if (!this._readyToTakeSnapshots()) {
1038 // We didn't take a snapshot earlier because we weren't ready to, so
1039 // there's nothing to compress.
1040 return;
1041 }
1042
1043 TelemetryStopwatch.start("FX_GESTURE_COMPRESS_SNAPSHOT_OF_PAGE");
1044 try {
1045 let browser = gBrowser.selectedBrowser;
1046 let snapshots = browser.snapshots;
1047 let currIndex = browser.webNavigation.sessionHistory.index;
1048
1049 // Kick off snapshot compression.
1050 let canvas = snapshots[currIndex].image;
1051 canvas.toBlob(function(aBlob) {
1052 if (snapshots[currIndex]) {
1053 snapshots[currIndex].image = aBlob;
1054 }
1055 }, "image/png"
1056 );
1057 } finally {
1058 TelemetryStopwatch.finish("FX_GESTURE_COMPRESS_SNAPSHOT_OF_PAGE");
1059 }
1060 },
1061
1062 /**
1063 * Removes a snapshot identified by the browser and index in the array of
1064 * snapshots for that browser, if present. If no snapshot could be identified
1065 * the method simply returns without taking any action. If aIndex is negative,
1066 * all snapshots for a particular browser will be removed.
1067 *
1068 * @param aIndex
1069 * The index in history of the new snapshot, or negative value if all
1070 * snapshots for a browser should be removed.
1071 * @param aBrowser
1072 * The browser the new snapshot was taken in.
1073 */
1074 _removeTrackedSnapshot: function HSA__removeTrackedSnapshot(aIndex, aBrowser) {
1075 let arr = this._trackedSnapshots;
1076 let requiresExactIndexMatch = aIndex >= 0;
1077 for (let i = 0; i < arr.length; i++) {
1078 if ((arr[i].browser == aBrowser) &&
1079 (aIndex < 0 || aIndex == arr[i].index)) {
1080 delete aBrowser.snapshots[arr[i].index];
1081 arr.splice(i, 1);
1082 if (requiresExactIndexMatch)
1083 return; // Found and removed the only element.
1084 i--; // Make sure to revisit the index that we just removed an
1085 // element at.
1086 }
1087 }
1088 },
1089
1090 /**
1091 * Adds a new snapshot reference for a given index and browser to the array
1092 * of references to tracked snapshots.
1093 *
1094 * @param aIndex
1095 * The index in history of the new snapshot.
1096 * @param aBrowser
1097 * The browser the new snapshot was taken in.
1098 */
1099 _addSnapshotRefToArray:
1100 function HSA__addSnapshotRefToArray(aIndex, aBrowser) {
1101 let id = { index: aIndex,
1102 browser: aBrowser };
1103 let arr = this._trackedSnapshots;
1104 arr.unshift(id);
1105
1106 while (arr.length > this._maxSnapshots) {
1107 let lastElem = arr[arr.length - 1];
1108 delete lastElem.browser.snapshots[lastElem.index].image;
1109 delete lastElem.browser.snapshots[lastElem.index];
1110 arr.splice(-1, 1);
1111 }
1112 },
1113
1114 /**
1115 * Converts a compressed blob to an Image object. In some situations
1116 * (especially during fast swiping) aBlob may still be a canvas, not a
1117 * compressed blob. In this case, we simply return the canvas.
1118 *
1119 * @param aBlob
1120 * The compressed blob to convert, or a canvas if a blob compression
1121 * couldn't complete before this method was called.
1122 * @return A new Image object representing the converted blob.
1123 */
1124 _convertToImg: function HSA__convertToImg(aBlob) {
1125 if (!aBlob)
1126 return null;
1127
1128 // Return aBlob if it's still a canvas and not a compressed blob yet.
1129 if (aBlob instanceof HTMLCanvasElement)
1130 return aBlob;
1131
1132 let img = new Image();
1133 let url = "";
1134 try {
1135 url = URL.createObjectURL(aBlob);
1136 img.onload = function() {
1137 URL.revokeObjectURL(url);
1138 };
1139 }
1140 finally {
1141 img.src = url;
1142 return img;
1143 }
1144 },
1145
1146 /**
1147 * Scales the background of a given box element (which uses a given snapshot
1148 * as background) based on a given scale factor.
1149 * @param aSnapshot
1150 * The snapshot that is used as background of aBox.
1151 * @param aScale
1152 * The scale factor to use.
1153 * @param aBox
1154 * The box element that uses aSnapshot as background.
1155 */
1156 _scaleSnapshot: function HSA__scaleSnapshot(aSnapshot, aScale, aBox) {
1157 if (aSnapshot && aScale != 1 && aBox) {
1158 if (aSnapshot instanceof HTMLCanvasElement) {
1159 aBox.style.backgroundSize =
1160 aSnapshot.width / aScale + "px " + aSnapshot.height / aScale + "px";
1161 } else {
1162 // snapshot is instanceof HTMLImageElement
1163 aSnapshot.addEventListener("load", function() {
1164 aBox.style.backgroundSize =
1165 aSnapshot.width / aScale + "px " + aSnapshot.height / aScale + "px";
1166 });
1167 }
1168 }
1169 },
1170
1171 /**
1172 * Sets the snapshot of the current page to the snapshot passed as parameter,
1173 * or to the one previously stored for the current index in history if the
1174 * parameter is null.
1175 *
1176 * @param aCanvas
1177 * The snapshot to set the current page to. If this parameter is null,
1178 * the previously stored snapshot for this index (if any) will be used.
1179 */
1180 _installCurrentPageSnapshot:
1181 function HSA__installCurrentPageSnapshot(aCanvas) {
1182 let currSnapshot = aCanvas;
1183 let scale = window.devicePixelRatio;
1184 if (!currSnapshot) {
1185 let snapshots = gBrowser.selectedBrowser.snapshots || {};
1186 let currIndex = this._historyIndex;
1187 if (currIndex in snapshots) {
1188 currSnapshot = this._convertToImg(snapshots[currIndex].image);
1189 scale = snapshots[currIndex].scale;
1190 }
1191 }
1192 this._scaleSnapshot(currSnapshot, scale, this._curBox ? this._curBox :
1193 null);
1194 document.mozSetImageElement("historySwipeAnimationCurrentPageSnapshot",
1195 currSnapshot);
1196 },
1197
1198 /**
1199 * Sets the snapshots of the previous and next pages to the snapshots
1200 * previously stored for their respective indeces.
1201 */
1202 _installPrevAndNextSnapshots:
1203 function HSA__installPrevAndNextSnapshots() {
1204 let snapshots = gBrowser.selectedBrowser.snapshots || [];
1205 let currIndex = this._historyIndex;
1206 let prevIndex = currIndex - 1;
1207 let prevSnapshot = null;
1208 if (prevIndex in snapshots) {
1209 prevSnapshot = this._convertToImg(snapshots[prevIndex].image);
1210 this._scaleSnapshot(prevSnapshot, snapshots[prevIndex].scale,
1211 this._prevBox);
1212 }
1213 document.mozSetImageElement("historySwipeAnimationPreviousPageSnapshot",
1214 prevSnapshot);
1215
1216 let nextIndex = currIndex + 1;
1217 let nextSnapshot = null;
1218 if (nextIndex in snapshots) {
1219 nextSnapshot = this._convertToImg(snapshots[nextIndex].image);
1220 this._scaleSnapshot(nextSnapshot, snapshots[nextIndex].scale,
1221 this._nextBox);
1222 }
1223 document.mozSetImageElement("historySwipeAnimationNextPageSnapshot",
1224 nextSnapshot);
1225 },
1226 };
1227