forked from mirrors/gecko-dev
		
	 c5c9a1666e
			
		
	
	
		c5c9a1666e
		
	
	
	
	
		
			
			* Let popup code initially measure and place the panel without maxHeight, this ensures alignmentPosition is a reasonable value * Assign maxHeight from a popuppositioned handler and update the comment explaining the role of the autoPosition property * Refactor to move the maxHeight calculation into a method on PMV * panel autoPosition now gets reset to false in popuppositioned (was popupshowing) as the ShowWithPositionedEvent on popupFrame sets it back to true every time * Update reflow tests with new signatures, and elimination of 1 reflow * In appMenu reflow test, we must now wait for popupshown before opening subviews MozReview-Commit-ID: KfHxngnajM3 --HG-- extra : rebase_source : 2918a30f6ecdfded57fb7b93aba3f0479fd4635c
		
			
				
	
	
		
			1367 lines
		
	
	
	
		
			51 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			1367 lines
		
	
	
	
		
			51 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/. */
 | |
| 
 | |
| "use strict";
 | |
| 
 | |
| this.EXPORTED_SYMBOLS = ["PanelMultiView"];
 | |
| 
 | |
| const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 | |
| 
 | |
| Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 | |
| XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
 | |
|   "resource://gre/modules/AppConstants.jsm");
 | |
| XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils",
 | |
|   "resource://gre/modules/BrowserUtils.jsm");
 | |
| XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
 | |
|   "resource:///modules/CustomizableUI.jsm");
 | |
| 
 | |
| const TRANSITION_PHASES = Object.freeze({
 | |
|   START: 1,
 | |
|   PREPARE: 2,
 | |
|   TRANSITION: 3,
 | |
|   END: 4
 | |
| });
 | |
| 
 | |
| /**
 | |
|  * Simple implementation of the sliding window pattern; panels are added to a
 | |
|  * linked list, in-order, and the currently shown panel is remembered using a
 | |
|  * marker. The marker shifts as navigation between panels is continued, where
 | |
|  * the panel at index 0 is always the starting point:
 | |
|  *           ┌────┬────┬────┬────┐
 | |
|  *           │▓▓▓▓│    │    │    │ Start
 | |
|  *           └────┴────┴────┴────┘
 | |
|  *      ┌────┬────┬────┬────┐
 | |
|  *      │    │▓▓▓▓│    │    │      Forward
 | |
|  *      └────┴────┴────┴────┘
 | |
|  * ┌────┬────┬────┬────┐
 | |
|  * │    │    │▓▓▓▓│    │           Forward
 | |
|  * └────┴────┴────┴────┘
 | |
|  *      ┌────┬────┬────┬────┐
 | |
|  *      │    │▓▓▓▓│    │    │      Back
 | |
|  *      └────┴────┴────┴────┘
 | |
|  */
 | |
| class SlidingPanelViews extends Array {
 | |
|   constructor() {
 | |
|     super();
 | |
|     this._marker = 0;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Get the index that points to the currently selected view.
 | |
|    *
 | |
|    * @return {Number}
 | |
|    */
 | |
|   get current() {
 | |
|     return this._marker;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Setter for the current index, which changes the order of elements and
 | |
|    * updates the internal marker for the currently selected view.
 | |
|    * We're manipulating the array directly to have it reflect the order of
 | |
|    * navigation, instead of continuously growing the array with the next selected
 | |
|    * view to keep memory usage within reasonable proportions. With this method,
 | |
|    * the data structure grows no larger than the number of panels inside the
 | |
|    * panelMultiView.
 | |
|    *
 | |
|    * @param  {Number} index Index of the item to move to the current position.
 | |
|    * @return {Number} The new marker index.
 | |
|    */
 | |
|   set current(index) {
 | |
|     if (index == this._marker) {
 | |
|       // Never change a winning team.
 | |
|       return index;
 | |
|     }
 | |
|     if (index == -1 || index > (this.length - 1)) {
 | |
|       throw new Error(`SlidingPanelViews :: index ${index} out of bounds`);
 | |
|     }
 | |
| 
 | |
|     let view = this.splice(index, 1)[0];
 | |
|     if (this._marker > index) {
 | |
|       // Correct the current marker if the view-to-select was removed somewhere
 | |
|       // before it.
 | |
|       --this._marker;
 | |
|     }
 | |
|     // Then add the view-to-select right after the currently selected view.
 | |
|     this.splice(++this._marker, 0, view);
 | |
|     return this._marker;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Getter for the currently selected view node.
 | |
|    *
 | |
|    * @return {panelview}
 | |
|    */
 | |
|   get currentView() {
 | |
|     return this[this._marker];
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Setter for the currently selected view node.
 | |
|    *
 | |
|    * @param  {panelview} view
 | |
|    * @return {Number} Index of the currently selected view.
 | |
|    */
 | |
|   set currentView(view) {
 | |
|     if (!view)
 | |
|       return this.current;
 | |
|     // This will throw an error if the view could not be found.
 | |
|     return this.current = this.indexOf(view);
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Getter for the previous view, which is always positioned one position after
 | |
|    * the current view.
 | |
|    *
 | |
|    * @return {panelview}
 | |
|    */
 | |
|   get previousView() {
 | |
|     return this[this._marker + 1];
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Going back is an explicit action on the data structure, moving the marker
 | |
|    * one step back.
 | |
|    *
 | |
|    * @return {Array} A list of two items: the newly selected view and the previous one.
 | |
|    */
 | |
|   back() {
 | |
|     if (this._marker > 0)
 | |
|       --this._marker;
 | |
|     return [this.currentView, this.previousView];
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Reset the data structure to its original construct, removing all references
 | |
|    * to view nodes.
 | |
|    */
 | |
|   clear() {
 | |
|     this._marker = 0;
 | |
|     this.splice(0, this.length);
 | |
|   }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * This is the implementation of the panelUI.xml XBL binding, moved to this
 | |
|  * module, to make it easier to fork the logic for the newer photon structure.
 | |
|  * Goals are:
 | |
|  * 1. to make it easier to programmatically extend the list of panels,
 | |
|  * 2. allow for navigation between panels multiple levels deep and
 | |
|  * 3. maintain the pre-photon structure with as little effort possible.
 | |
|  *
 | |
|  * @type {PanelMultiView}
 | |
|  */
 | |
| this.PanelMultiView = class {
 | |
|   get document() {
 | |
|     return this.node.ownerDocument;
 | |
|   }
 | |
| 
 | |
|   get window() {
 | |
|     return this.node.ownerGlobal;
 | |
|   }
 | |
| 
 | |
|   get _panel() {
 | |
|     return this.node.parentNode;
 | |
|   }
 | |
| 
 | |
|   get showingSubView() {
 | |
|     return this.node.getAttribute("viewtype") == "subview";
 | |
|   }
 | |
|   get _mainViewId() {
 | |
|     return this.node.getAttribute("mainViewId");
 | |
|   }
 | |
|   set _mainViewId(val) {
 | |
|     this.node.setAttribute("mainViewId", val);
 | |
|     return val;
 | |
|   }
 | |
|   get _mainView() {
 | |
|     return this._mainViewId ? this.document.getElementById(this._mainViewId) : null;
 | |
|   }
 | |
| 
 | |
|   get _transitioning() {
 | |
|     return this.__transitioning;
 | |
|   }
 | |
|   set _transitioning(val) {
 | |
|     this.__transitioning = val;
 | |
|     if (val) {
 | |
|       this.node.setAttribute("transitioning", "true");
 | |
|     } else {
 | |
|       this.node.removeAttribute("transitioning");
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * @return {Boolean} |true| when the 'ephemeral' attribute is set, which means
 | |
|    *                   that this instance should be ready to be thrown away at
 | |
|    *                   any time.
 | |
|    */
 | |
|   get _ephemeral() {
 | |
|     return this.node.hasAttribute("ephemeral");
 | |
|   }
 | |
| 
 | |
|   get panelViews() {
 | |
|     // If there's a dedicated subViews container, we're not in the right binding
 | |
|     // to use SlidingPanelViews.
 | |
|     if (this._subViews)
 | |
|       return null;
 | |
| 
 | |
|     if (this._panelViews)
 | |
|       return this._panelViews;
 | |
| 
 | |
|     this._panelViews = new SlidingPanelViews();
 | |
|     this._panelViews.push(...this.node.getElementsByTagName("panelview"));
 | |
|     return this._panelViews;
 | |
|   }
 | |
|   get _dwu() {
 | |
|     if (this.__dwu)
 | |
|       return this.__dwu;
 | |
|     return this.__dwu = this.window.QueryInterface(Ci.nsIInterfaceRequestor)
 | |
|                                    .getInterface(Ci.nsIDOMWindowUtils);
 | |
|   }
 | |
|   get _screenManager() {
 | |
|     if (this.__screenManager)
 | |
|       return this.__screenManager;
 | |
|     return this.__screenManager = Cc["@mozilla.org/gfx/screenmanager;1"]
 | |
|                                     .getService(Ci.nsIScreenManager);
 | |
|   }
 | |
|   /**
 | |
|    * @return {panelview} the currently visible subview OR the subview that is
 | |
|    *                     about to be shown whilst a 'ViewShowing' event is being
 | |
|    *                     dispatched.
 | |
|    */
 | |
|   get current() {
 | |
|     return this._viewShowing || this._currentSubView;
 | |
|   }
 | |
|   get _currentSubView() {
 | |
|     return this.panelViews ? this.panelViews.currentView : this.__currentSubView;
 | |
|   }
 | |
|   set _currentSubView(panel) {
 | |
|     if (this.panelViews)
 | |
|       this.panelViews.currentView = panel;
 | |
|     else
 | |
|       this.__currentSubView = panel;
 | |
|     return panel;
 | |
|   }
 | |
|   /**
 | |
|    * @return {Promise} showSubView() returns a promise, which is kept here for
 | |
|    *                   random access.
 | |
|    */
 | |
|   get currentShowPromise() {
 | |
|     return this._currentShowPromise || Promise.resolve();
 | |
|   }
 | |
|   get _keyNavigationMap() {
 | |
|     if (!this.__keyNavigationMap)
 | |
|       this.__keyNavigationMap = new Map();
 | |
|     return this.__keyNavigationMap;
 | |
|   }
 | |
|   get _multiLineElementsMap() {
 | |
|     if (!this.__multiLineElementsMap)
 | |
|       this.__multiLineElementsMap = new WeakMap();
 | |
|     return this.__multiLineElementsMap;
 | |
|   }
 | |
| 
 | |
|   constructor(xulNode, testMode = false) {
 | |
|     this.node = xulNode;
 | |
|     // If `testMode` is `true`, the consumer is only interested in accessing the
 | |
|     // methods of this instance. (E.g. in unit tests.)
 | |
|     if (testMode)
 | |
|       return;
 | |
| 
 | |
|     this._currentSubView = this._anchorElement = this._subViewObserver = null;
 | |
|     this._mainViewHeight = 0;
 | |
|     this.__transitioning = this._ignoreMutations = false;
 | |
| 
 | |
|     const {document, window} = this;
 | |
| 
 | |
|     this._clickCapturer =
 | |
|       document.getAnonymousElementByAttribute(this.node, "anonid", "clickCapturer");
 | |
|     this._viewContainer =
 | |
|       document.getAnonymousElementByAttribute(this.node, "anonid", "viewContainer");
 | |
|     this._mainViewContainer =
 | |
|       document.getAnonymousElementByAttribute(this.node, "anonid", "mainViewContainer");
 | |
|     this._subViews =
 | |
|       document.getAnonymousElementByAttribute(this.node, "anonid", "subViews");
 | |
|     this._viewStack =
 | |
|       document.getAnonymousElementByAttribute(this.node, "anonid", "viewStack");
 | |
|     this._offscreenViewStack =
 | |
|       document.getAnonymousElementByAttribute(this.node, "anonid", "offscreenViewStack");
 | |
| 
 | |
|     XPCOMUtils.defineLazyGetter(this, "_panelViewCache", () => {
 | |
|       let viewCacheId = this.node.getAttribute("viewCacheId");
 | |
|       return viewCacheId ? document.getElementById(viewCacheId) : null;
 | |
|     });
 | |
| 
 | |
|     this._panel.addEventListener("popupshowing", this);
 | |
|     this._panel.addEventListener("popuppositioned", this);
 | |
|     this._panel.addEventListener("popuphidden", this);
 | |
|     this._panel.addEventListener("popupshown", this);
 | |
|     if (this.panelViews) {
 | |
|       let cs = window.getComputedStyle(document.documentElement);
 | |
|       // Set CSS-determined attributes now to prevent a layout flush when we do
 | |
|       // it when transitioning between panels.
 | |
|       this._dir = cs.direction;
 | |
|       this.setMainView(this.panelViews.currentView);
 | |
|       this.showMainView();
 | |
|     } else {
 | |
|       this._clickCapturer.addEventListener("click", this);
 | |
| 
 | |
|       this._mainViewContainer.setAttribute("panelid", this._panel.id);
 | |
| 
 | |
|       if (this._mainView) {
 | |
|         this.setMainView(this._mainView);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     this.node.setAttribute("viewtype", "main");
 | |
| 
 | |
|     // Proxy these public properties and methods, as used elsewhere by various
 | |
|     // parts of the browser, to this instance.
 | |
|     ["_mainView", "ignoreMutations", "showingSubView",
 | |
|      "_panelViews"].forEach(property => {
 | |
|       Object.defineProperty(this.node, property, {
 | |
|         enumerable: true,
 | |
|         get: () => this[property],
 | |
|         set: (val) => this[property] = val
 | |
|       });
 | |
|     });
 | |
|     ["goBack", "descriptionHeightWorkaround", "setMainView", "showMainView",
 | |
|      "showSubView"].forEach(method => {
 | |
|       Object.defineProperty(this.node, method, {
 | |
|         enumerable: true,
 | |
|         value: (...args) => this[method](...args)
 | |
|       });
 | |
|     });
 | |
|     ["current", "currentShowPromise"].forEach(property => {
 | |
|       Object.defineProperty(this.node, property, {
 | |
|         enumerable: true,
 | |
|         get: () => this[property]
 | |
|       });
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   destructor() {
 | |
|     // Guard against re-entrancy.
 | |
|     if (!this.node)
 | |
|       return;
 | |
| 
 | |
|     this._cleanupTransitionPhase();
 | |
|     if (this._ephemeral)
 | |
|       this.hideAllViewsExcept(null);
 | |
|     let mainView = this._mainView;
 | |
|     if (mainView) {
 | |
|       if (this._panelViewCache)
 | |
|         this._panelViewCache.appendChild(mainView);
 | |
|       mainView.removeAttribute("mainview");
 | |
|     }
 | |
|     if (this._subViews)
 | |
|       this._moveOutKids(this._subViews);
 | |
| 
 | |
|     if (this.panelViews) {
 | |
|       this._moveOutKids(this._viewStack);
 | |
|       this.panelViews.clear();
 | |
|     } else {
 | |
|       this._clickCapturer.removeEventListener("click", this);
 | |
|     }
 | |
|     this._panel.removeEventListener("mousemove", this);
 | |
|     this._panel.removeEventListener("popupshowing", this);
 | |
|     this._panel.removeEventListener("popuppositioned", this);
 | |
|     this._panel.removeEventListener("popupshown", this);
 | |
|     this._panel.removeEventListener("popuphidden", this);
 | |
|     this.window.removeEventListener("keydown", this);
 | |
|     this.node = this._clickCapturer = this._viewContainer = this._mainViewContainer =
 | |
|       this._subViews = this._viewStack = this.__dwu = this._panelViewCache =
 | |
|         this._transitionDetails = null;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Remove any child subviews into the panelViewCache, to ensure
 | |
|    * they remain usable even if this panelmultiview instance is removed
 | |
|    * from the DOM.
 | |
|    * @param viewNodeContainer the container from which to remove subviews
 | |
|    */
 | |
|   _moveOutKids(viewNodeContainer) {
 | |
|     if (!this._panelViewCache)
 | |
|       return;
 | |
| 
 | |
|     // Node.children and Node.childNodes is live to DOM changes like the
 | |
|     // ones we're about to do, so iterate over a static copy:
 | |
|     let subviews = Array.from(viewNodeContainer.childNodes);
 | |
|     for (let subview of subviews) {
 | |
|       // XBL lists the 'children' XBL element explicitly. :-(
 | |
|       if (subview.nodeName != "children")
 | |
|         this._panelViewCache.appendChild(subview);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   _placeSubView(viewNode) {
 | |
|     if (this.panelViews) {
 | |
|       this._viewStack.appendChild(viewNode);
 | |
|       if (!this.panelViews.includes(viewNode))
 | |
|         this.panelViews.push(viewNode);
 | |
|     } else {
 | |
|       this._subViews.appendChild(viewNode);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   goBack(target) {
 | |
|     let [current, previous] = this.panelViews.back();
 | |
|     return this.showSubView(current, target, previous);
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Checks whether it is possible to navigate backwards currently. Returns
 | |
|    * false if this is the panelmultiview's mainview, true otherwise.
 | |
|    *
 | |
|    * @param  {panelview} view View to check, defaults to the currently active view.
 | |
|    * @return {Boolean}
 | |
|    */
 | |
|   _canGoBack(view = this._currentSubView) {
 | |
|     return view.id != this._mainViewId;
 | |
|   }
 | |
| 
 | |
|   setMainView(aNewMainView) {
 | |
|     if (!aNewMainView)
 | |
|       return;
 | |
| 
 | |
|     if (this._mainView) {
 | |
|       if (!this.panelViews)
 | |
|         this._subViews.appendChild(this._mainView);
 | |
|       this._mainView.removeAttribute("mainview");
 | |
|     }
 | |
|     this._mainViewId = aNewMainView.id;
 | |
|     aNewMainView.setAttribute("mainview", "true");
 | |
|     if (this.panelViews) {
 | |
|       // If the new main view is not yet in the zeroth position, make sure it's
 | |
|       // inserted there.
 | |
|       if (aNewMainView.parentNode != this._viewStack && this._viewStack.firstChild != aNewMainView) {
 | |
|         this._viewStack.insertBefore(aNewMainView, this._viewStack.firstChild);
 | |
|       }
 | |
|     } else {
 | |
|       this._mainViewContainer.appendChild(aNewMainView);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   showMainView() {
 | |
|     if (!this._mainViewId)
 | |
|       return Promise.resolve();
 | |
| 
 | |
|     if (this.panelViews)
 | |
|       return this.showSubView(this._mainView);
 | |
| 
 | |
|     if (this.showingSubView) {
 | |
|       let viewNode = this._currentSubView;
 | |
|       this._dispatchViewEvent(viewNode, "ViewHiding");
 | |
|       this._transitionHeight(() => {
 | |
|         viewNode.removeAttribute("current");
 | |
|         this._currentSubView = null;
 | |
|         this.node.setAttribute("viewtype", "main");
 | |
|       });
 | |
|     }
 | |
| 
 | |
|     this._shiftMainView();
 | |
|     return Promise.resolve();
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Ensures that all the panelviews, that are currently part of this instance,
 | |
|    * are hidden, except one specifically.
 | |
|    *
 | |
|    * @param {panelview} [theOne] The panelview DOM node to ensure is visible.
 | |
|    *                             Optional.
 | |
|    */
 | |
|   hideAllViewsExcept(theOne = null) {
 | |
|     for (let panelview of this._panelViews) {
 | |
|       // When the panelview was already reparented, don't interfere any more.
 | |
|       if (panelview == theOne || !this.node || panelview.panelMultiView != this.node)
 | |
|         continue;
 | |
|       if (panelview.hasAttribute("current"))
 | |
|         this._dispatchViewEvent(panelview, "ViewHiding");
 | |
|       panelview.removeAttribute("current");
 | |
|     }
 | |
| 
 | |
|     this._viewShowing = null;
 | |
| 
 | |
|     if (!this.node || !theOne)
 | |
|       return;
 | |
| 
 | |
|     this._currentSubView = theOne;
 | |
|     if (!theOne.hasAttribute("current")) {
 | |
|       theOne.setAttribute("current", true);
 | |
|       this.descriptionHeightWorkaround(theOne);
 | |
|       this._dispatchViewEvent(theOne, "ViewShown");
 | |
|     }
 | |
|     this.node.setAttribute("viewtype", (theOne.id == this._mainViewId) ? "main" : "subview");
 | |
|   }
 | |
| 
 | |
|   showSubView(aViewId, aAnchor, aPreviousView) {
 | |
|     this._currentShowPromise = (async () => {
 | |
|       // Support passing in the node directly.
 | |
|       let viewNode = typeof aViewId == "string" ? this.node.querySelector("#" + aViewId) : aViewId;
 | |
|       if (!viewNode) {
 | |
|         viewNode = this.document.getElementById(aViewId);
 | |
|         if (viewNode) {
 | |
|           this._placeSubView(viewNode);
 | |
|         } else {
 | |
|           throw new Error(`Subview ${aViewId} doesn't exist!`);
 | |
|         }
 | |
|       } else if (viewNode.parentNode == this._panelViewCache) {
 | |
|         this._placeSubView(viewNode);
 | |
|       }
 | |
| 
 | |
|       let reverse = !!aPreviousView;
 | |
|       let previousViewNode = aPreviousView || this._currentSubView;
 | |
|       // If the panelview to show is the same as the previous one, the 'ViewShowing'
 | |
|       // event has already been dispatched. Don't do it twice.
 | |
|       let showingSameView = viewNode == previousViewNode;
 | |
|       let playTransition = (!!previousViewNode && !showingSameView && this._panel.state == "open");
 | |
|       let isMainView = viewNode.id == this._mainViewId;
 | |
| 
 | |
|       let dwu, previousRect;
 | |
|       if (playTransition || this.panelViews) {
 | |
|         dwu = this._dwu;
 | |
|         previousRect = previousViewNode.__lastKnownBoundingRect =
 | |
|           dwu.getBoundsWithoutFlushing(previousViewNode);
 | |
|         if (this.panelViews) {
 | |
|           // Cache the measures that have the same caching lifetime as the width
 | |
|           // or height of the main view, i.e. whilst the panel is shown and/ or
 | |
|           // visible.
 | |
|           if (!this._mainViewWidth) {
 | |
|             this._mainViewWidth = previousRect.width;
 | |
|             let top = dwu.getBoundsWithoutFlushing(previousViewNode.firstChild || previousViewNode).top;
 | |
|             let bottom = dwu.getBoundsWithoutFlushing(previousViewNode.lastChild || previousViewNode).bottom;
 | |
|             this._viewVerticalPadding = previousRect.height - (bottom - top);
 | |
|           }
 | |
|           if (!this._mainViewHeight) {
 | |
|             this._mainViewHeight = previousRect.height;
 | |
|             this._viewContainer.style.minHeight = this._mainViewHeight + "px";
 | |
|           }
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       this._viewShowing = viewNode;
 | |
|       // Because the 'mainview' attribute may be out-of-sync, due to view node
 | |
|       // reparenting in combination with ephemeral PanelMultiView instances,
 | |
|       // this is the best place to correct it (just before showing).
 | |
|       if (isMainView)
 | |
|         viewNode.setAttribute("mainview", true);
 | |
|       else
 | |
|         viewNode.removeAttribute("mainview");
 | |
| 
 | |
|       // Make sure that new panels always have a title set.
 | |
|       if (this.panelViews && aAnchor) {
 | |
|         if (!viewNode.hasAttribute("title"))
 | |
|           viewNode.setAttribute("title", aAnchor.getAttribute("label"));
 | |
|         viewNode.classList.add("PanelUI-subView");
 | |
|       }
 | |
|       if (this.panelViews && !isMainView && this._mainViewWidth)
 | |
|         viewNode.style.maxWidth = viewNode.style.minWidth = this._mainViewWidth + "px";
 | |
| 
 | |
|       if (!showingSameView || !viewNode.hasAttribute("current")) {
 | |
|         // Emit the ViewShowing event so that the widget definition has a chance
 | |
|         // to lazily populate the subview with things or perhaps even cancel this
 | |
|         // whole operation.
 | |
|         let detail = {
 | |
|           blockers: new Set(),
 | |
|           addBlocker(promise) {
 | |
|             this.blockers.add(promise);
 | |
|           }
 | |
|         };
 | |
|         let cancel = this._dispatchViewEvent(viewNode, "ViewShowing", aAnchor, detail);
 | |
|         if (detail.blockers.size) {
 | |
|           try {
 | |
|             let results = await Promise.all(detail.blockers);
 | |
|             cancel = cancel || results.some(val => val === false);
 | |
|           } catch (e) {
 | |
|             Cu.reportError(e);
 | |
|             cancel = true;
 | |
|           }
 | |
|         }
 | |
| 
 | |
|         if (cancel) {
 | |
|           this._viewShowing = null;
 | |
|           return;
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       // Now we have to transition the panel.
 | |
|       if (this.panelViews) {
 | |
|         // If we've got an older transition still running, make sure to clean it up.
 | |
|         await this._cleanupTransitionPhase();
 | |
|         if (playTransition) {
 | |
|           await this._transitionViews(previousViewNode, viewNode, reverse, previousRect, aAnchor);
 | |
|           this._updateKeyboardFocus(viewNode);
 | |
|         } else {
 | |
|           this.hideAllViewsExcept(viewNode);
 | |
|         }
 | |
|       } else {
 | |
|         this._currentSubView = viewNode;
 | |
|         this._transitionHeight(() => {
 | |
|           viewNode.setAttribute("current", true);
 | |
|           if (viewNode.id == this._mainViewId) {
 | |
|             this.node.setAttribute("viewtype", "main");
 | |
|           } else {
 | |
|             this.node.setAttribute("viewtype", "subview");
 | |
|           }
 | |
|           // Now that the subview is visible, we can check the height of the
 | |
|           // description elements it contains.
 | |
|           this.descriptionHeightWorkaround(viewNode);
 | |
|           this._dispatchViewEvent(viewNode, "ViewShown");
 | |
|         });
 | |
|         this._shiftMainView(aAnchor);
 | |
|       }
 | |
|     })().catch(e => Cu.reportError(e));
 | |
|     return this._currentShowPromise;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Apply a transition to 'slide' from the currently active view to the next
 | |
|    * one.
 | |
|    * Sliding the next subview in means that the previous panelview stays where it
 | |
|    * is and the active panelview slides in from the left in LTR mode, right in
 | |
|    * RTL mode.
 | |
|    *
 | |
|    * @param {panelview} previousViewNode Node that is currently shown as active,
 | |
|    *                                     but is about to be transitioned away.
 | |
|    * @param {panelview} viewNode         Node that will becode the active view,
 | |
|    *                                     after the transition has finished.
 | |
|    * @param {Boolean}   reverse          Whether we're navigation back to a
 | |
|    *                                     previous view or forward to a next view.
 | |
|    * @param {Object}    previousRect     Rect object, with the same structure as
 | |
|    *                                     a DOMRect, of the `previousViewNode`.
 | |
|    * @param {Element}   anchor           the anchor for which we're opening
 | |
|    *                                     a new panelview, if any
 | |
|    */
 | |
|   async _transitionViews(previousViewNode, viewNode, reverse, previousRect, anchor) {
 | |
|     // There's absolutely no need to show off our epic animation skillz when
 | |
|     // the panel's not even open.
 | |
|     if (this._panel.state != "open") {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const {window, document} = this;
 | |
| 
 | |
|     if (this._autoResizeWorkaroundTimer)
 | |
|       window.clearTimeout(this._autoResizeWorkaroundTimer);
 | |
| 
 | |
|     let details = this._transitionDetails = {
 | |
|       phase: TRANSITION_PHASES.START,
 | |
|       previousViewNode, viewNode, reverse, anchor
 | |
|     };
 | |
| 
 | |
|     if (anchor)
 | |
|       anchor.setAttribute("open", "true");
 | |
| 
 | |
|     // Since we're going to show two subview at the same time, don't abuse the
 | |
|     // 'current' attribute, since it's needed for other state-keeping, but use
 | |
|     // a separate 'in-transition' attribute instead.
 | |
|     previousViewNode.setAttribute("in-transition", true);
 | |
|     // Set the viewContainer dimensions to make sure only the current view is
 | |
|     // visible.
 | |
|     this._viewContainer.style.height = Math.max(previousRect.height, this._mainViewHeight) + "px";
 | |
|     this._viewContainer.style.width = previousRect.width + "px";
 | |
|     // Lock the dimensions of the window that hosts the popup panel.
 | |
|     let rect = this._panel.popupBoxObject.getOuterScreenRect();
 | |
|     this._panel.setAttribute("width", rect.width);
 | |
|     this._panel.setAttribute("height", rect.height);
 | |
| 
 | |
|     let viewRect;
 | |
|     if (viewNode.__lastKnownBoundingRect) {
 | |
|       viewRect = viewNode.__lastKnownBoundingRect;
 | |
|       viewNode.setAttribute("in-transition", true);
 | |
|     } else if (viewNode.customRectGetter) {
 | |
|       // Can't use Object.assign directly with a DOM Rect object because its properties
 | |
|       // aren't enumerable.
 | |
|       let {height, width} = previousRect;
 | |
|       viewRect = Object.assign({height, width}, viewNode.customRectGetter());
 | |
|       let {header} = viewNode;
 | |
|       if (header) {
 | |
|         viewRect.height += this._dwu.getBoundsWithoutFlushing(header).height;
 | |
|       }
 | |
|       viewNode.setAttribute("in-transition", true);
 | |
|     } else {
 | |
|       let oldSibling = viewNode.nextSibling || null;
 | |
|       this._offscreenViewStack.appendChild(viewNode);
 | |
|       viewNode.setAttribute("in-transition", true);
 | |
| 
 | |
|       // Now that the subview is visible, we can check the height of the
 | |
|       // description elements it contains.
 | |
|       this.descriptionHeightWorkaround(viewNode);
 | |
| 
 | |
|       viewRect = await BrowserUtils.promiseLayoutFlushed(this.document, "layout", () => {
 | |
|         return this._dwu.getBoundsWithoutFlushing(viewNode);
 | |
|       });
 | |
| 
 | |
|       try {
 | |
|         this._viewStack.insertBefore(viewNode, oldSibling);
 | |
|       } catch (ex) {
 | |
|         this._viewStack.appendChild(viewNode);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     this._transitioning = true;
 | |
|     details.phase = TRANSITION_PHASES.PREPARE;
 | |
| 
 | |
|     // The 'magic' part: build up the amount of pixels to move right or left.
 | |
|     let moveToLeft = (this._dir == "rtl" && !reverse) || (this._dir == "ltr" && reverse);
 | |
|     let deltaX = previousRect.width;
 | |
|     let deepestNode = reverse ? previousViewNode : viewNode;
 | |
| 
 | |
|     // With a transition when navigating backwards - user hits the 'back'
 | |
|     // button - we need to make sure that the views are positioned in a way
 | |
|     // that a translateX() unveils the previous view from the right direction.
 | |
|     if (reverse)
 | |
|       this._viewStack.style.marginInlineStart = "-" + deltaX + "px";
 | |
| 
 | |
|     // Set the transition style and listen for its end to clean up and make sure
 | |
|     // the box sizing becomes dynamic again.
 | |
|     // Somehow, putting these properties in PanelUI.css doesn't work for newly
 | |
|     // shown nodes in a XUL parent node.
 | |
|     this._viewStack.style.transition = "transform var(--animation-easing-function)" +
 | |
|       " var(--panelui-subview-transition-duration)";
 | |
|     this._viewStack.style.willChange = "transform";
 | |
|     deepestNode.style.borderInlineStart = "1px solid var(--panel-separator-color)";
 | |
| 
 | |
|     // Now set the viewContainer dimensions to that of the new view, which
 | |
|     // kicks of the height animation.
 | |
|     this._viewContainer.style.height = Math.max(viewRect.height, this._mainViewHeight) + "px";
 | |
|     this._viewContainer.style.width = viewRect.width + "px";
 | |
|     this._panel.removeAttribute("width");
 | |
|     this._panel.removeAttribute("height");
 | |
|     // We're setting the width property to prevent flickering during the
 | |
|     // sliding animation with smaller views.
 | |
|     viewNode.style.width = viewRect.width + "px";
 | |
| 
 | |
|     await BrowserUtils.promiseLayoutFlushed(document, "layout", () => {});
 | |
| 
 | |
|     // Kick off the transition!
 | |
|     details.phase = TRANSITION_PHASES.TRANSITION;
 | |
|     this._viewStack.style.transform = "translateX(" + (moveToLeft ? "" : "-") + deltaX + "px)";
 | |
| 
 | |
|     await new Promise(resolve => {
 | |
|       details.resolve = resolve;
 | |
|       this._viewContainer.addEventListener("transitionend", details.listener = ev => {
 | |
|         // It's quite common that `height` on the view container doesn't need
 | |
|         // to transition, so we make sure to do all the work on the transform
 | |
|         // transition-end, because that is guaranteed to happen.
 | |
|         if (ev.target != this._viewStack || ev.propertyName != "transform")
 | |
|           return;
 | |
|         this._viewContainer.removeEventListener("transitionend", details.listener);
 | |
|         delete details.listener;
 | |
|         resolve();
 | |
|       });
 | |
|       this._viewContainer.addEventListener("transitioncancel", details.cancelListener = ev => {
 | |
|         if (ev.target != this._viewStack)
 | |
|           return;
 | |
|         this._viewContainer.removeEventListener("transitioncancel", details.cancelListener);
 | |
|         delete details.cancelListener;
 | |
|         resolve();
 | |
|       });
 | |
|     });
 | |
| 
 | |
|     details.phase = TRANSITION_PHASES.END;
 | |
| 
 | |
|     await this._cleanupTransitionPhase(details);
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Attempt to clean up the attributes and properties set by `_transitionViews`
 | |
|    * above. Which attributes and properties depends on the phase the transition
 | |
|    * was left from - normally that'd be `TRANSITION_PHASES.END`.
 | |
|    *
 | |
|    * @param {Object} details Dictionary object containing details of the transition
 | |
|    *                         that should be cleaned up after. Defaults to the most
 | |
|    *                         recent details.
 | |
|    */
 | |
|   async _cleanupTransitionPhase(details = this._transitionDetails) {
 | |
|     if (!details || !this.node)
 | |
|       return;
 | |
| 
 | |
|     let {phase, previousViewNode, viewNode, reverse, resolve, listener, cancelListener, anchor} = details;
 | |
|     if (details == this._transitionDetails)
 | |
|       this._transitionDetails = null;
 | |
| 
 | |
|     // Do the things we _always_ need to do whenever the transition ends or is
 | |
|     // interrupted.
 | |
|     this.hideAllViewsExcept(viewNode);
 | |
|     previousViewNode.removeAttribute("in-transition");
 | |
|     viewNode.removeAttribute("in-transition");
 | |
|     if (reverse)
 | |
|       this._resetKeyNavigation(previousViewNode);
 | |
| 
 | |
|     if (anchor)
 | |
|       anchor.removeAttribute("open");
 | |
| 
 | |
|     if (phase >= TRANSITION_PHASES.START) {
 | |
|       this._panel.removeAttribute("width");
 | |
|       this._panel.removeAttribute("height");
 | |
|       // Myeah, panel layout auto-resizing is a funky thing. We'll wait
 | |
|       // another few milliseconds to remove the width and height 'fixtures',
 | |
|       // to be sure we don't flicker annoyingly.
 | |
|       // NB: HACK! Bug 1363756 is there to fix this.
 | |
|       this._autoResizeWorkaroundTimer = this.window.setTimeout(() => {
 | |
|         if (!this._viewContainer)
 | |
|           return;
 | |
|         this._viewContainer.style.removeProperty("height");
 | |
|         this._viewContainer.style.removeProperty("width");
 | |
|       }, 500);
 | |
|     }
 | |
|     if (phase >= TRANSITION_PHASES.PREPARE) {
 | |
|       this._transitioning = false;
 | |
|       if (reverse)
 | |
|         this._viewStack.style.removeProperty("margin-inline-start");
 | |
|       let deepestNode = reverse ? previousViewNode : viewNode;
 | |
|       deepestNode.style.removeProperty("border-inline-start");
 | |
|       this._viewStack.style.removeProperty("transition");
 | |
|     }
 | |
|     if (phase >= TRANSITION_PHASES.TRANSITION) {
 | |
|       this._viewStack.style.removeProperty("transform");
 | |
|       viewNode.style.removeProperty("width");
 | |
|       if (listener)
 | |
|         this._viewContainer.removeEventListener("transitionend", listener);
 | |
|       if (cancelListener)
 | |
|         this._viewContainer.removeEventListener("transitioncancel", cancelListener);
 | |
|       if (resolve)
 | |
|         resolve();
 | |
|     }
 | |
|     if (phase >= TRANSITION_PHASES.END) {
 | |
|       // We force 'display: none' on the previous view node to make sure that it
 | |
|       // doesn't cause an annoying flicker whilst resetting the styles above.
 | |
|       previousViewNode.style.display = "none";
 | |
|       await BrowserUtils.promiseLayoutFlushed(this.document, "layout", () => {});
 | |
|       previousViewNode.style.removeProperty("display");
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Helper method to emit an event on a panelview, whilst also making sure that
 | |
|    * the correct method is called on CustomizableWidget instances.
 | |
|    *
 | |
|    * @param  {panelview} viewNode  Target of the event to dispatch.
 | |
|    * @param  {String}    eventName Name of the event to dispatch.
 | |
|    * @param  {DOMNode}   [anchor]  Node where the panel is anchored to. Optional.
 | |
|    * @param  {Object}    [detail]  Event detail object. Optional.
 | |
|    * @return {Boolean} `true` if the event was canceled by an event handler, `false`
 | |
|    *                   otherwise.
 | |
|    */
 | |
|   _dispatchViewEvent(viewNode, eventName, anchor, detail) {
 | |
|     let cancel = false;
 | |
|     if (eventName != "PanelMultiViewHidden") {
 | |
|       // Don't need to do this for PanelMultiViewHidden event
 | |
|       CustomizableUI.ensureSubviewListeners(viewNode);
 | |
|     }
 | |
| 
 | |
|     let evt = new this.window.CustomEvent(eventName, {
 | |
|       detail,
 | |
|       bubbles: true,
 | |
|       cancelable: eventName == "ViewShowing"
 | |
|     });
 | |
|     viewNode.dispatchEvent(evt);
 | |
|     if (!cancel)
 | |
|       cancel = evt.defaultPrevented;
 | |
|     return cancel;
 | |
|   }
 | |
| 
 | |
|   _calculateMaxHeight() {
 | |
|     // While opening the panel, we have to limit the maximum height of any
 | |
|     // view based on the space that will be available. We cannot just use
 | |
|     // window.screen.availTop and availHeight because these may return an
 | |
|     // incorrect value when the window spans multiple screens.
 | |
|     let anchorBox = this._panel.anchorNode.boxObject;
 | |
|     let screen = this._screenManager.screenForRect(anchorBox.screenX,
 | |
|                                                    anchorBox.screenY,
 | |
|                                                    anchorBox.width,
 | |
|                                                    anchorBox.height);
 | |
|     let availTop = {}, availHeight = {};
 | |
|     screen.GetAvailRect({}, availTop, {}, availHeight);
 | |
|     let cssAvailTop = availTop.value / screen.defaultCSSScaleFactor;
 | |
| 
 | |
|     // The distance from the anchor to the available margin of the screen is
 | |
|     // based on whether the panel will open towards the top or the bottom.
 | |
|     let maxHeight;
 | |
|     if (this._panel.alignmentPosition.startsWith("before_")) {
 | |
|       maxHeight = anchorBox.screenY - cssAvailTop;
 | |
|     } else {
 | |
|       let anchorScreenBottom = anchorBox.screenY + anchorBox.height;
 | |
|       let cssAvailHeight = availHeight.value / screen.defaultCSSScaleFactor;
 | |
|       maxHeight = cssAvailTop + cssAvailHeight - anchorScreenBottom;
 | |
|     }
 | |
| 
 | |
|     // To go from the maximum height of the panel to the maximum height of
 | |
|     // the view stack, we need to subtract the height of the arrow and the
 | |
|     // height of the opposite margin, but we cannot get their actual values
 | |
|     // because the panel is not visible yet. However, we know that this is
 | |
|     // currently 11px on Mac, 13px on Windows, and 13px on Linux. We also
 | |
|     // want an extra margin, both for visual reasons and to prevent glitches
 | |
|     // due to small rounding errors. So, we just use a value that makes
 | |
|     // sense for all platforms. If the arrow visuals change significantly,
 | |
|     // this value will be easy to adjust.
 | |
|     const EXTRA_MARGIN_PX = 20;
 | |
|     maxHeight -= EXTRA_MARGIN_PX;
 | |
|     return maxHeight;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Applies the height transition for which <panelmultiview> is designed.
 | |
|    *
 | |
|    * The height transition involves two elements, the viewContainer and its only
 | |
|    * immediate child the viewStack. In order for this to work correctly, the
 | |
|    * viewContainer must have "overflow: hidden;" and the two elements must have
 | |
|    * no margins or padding. This means that the height of the viewStack is never
 | |
|    * limited by the viewContainer, but when the height of the container is not
 | |
|    * constrained it matches the height of the viewStack.
 | |
|    *
 | |
|    * @param changeFn
 | |
|    *        This synchronous function is called to make the DOM changes
 | |
|    *        that will result in a new height of the viewStack.
 | |
|    */
 | |
|   _transitionHeight(changeFn) {
 | |
|     if (this._panel.state != "open") {
 | |
|       changeFn();
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     // Lock the dimensions of the window that hosts the popup panel. This
 | |
|     // in turn constrains the height of the viewContainer.
 | |
|     let rect = this._panel.popupBoxObject.getOuterScreenRect();
 | |
|     this._panel.setAttribute("width", rect.width);
 | |
|     this._panel.setAttribute("height", rect.height);
 | |
| 
 | |
|     // Read the current height of the viewStack. If we are in the middle
 | |
|     // of a transition, this is the actual height of the element at this
 | |
|     // point in time.
 | |
|     let oldHeight = this._dwu.getBoundsWithoutFlushing(this._viewStack).height;
 | |
| 
 | |
|     // Make the necessary DOM changes, and remove the "height" property of the
 | |
|     // viewStack to ensure that we read its final value even if we are in the
 | |
|     // middle of a transition. To avoid flickering, we have to prevent the panel
 | |
|     // from being painted in this temporary state, which requires a synchronous
 | |
|     // layout when reading the new height.
 | |
|     this._viewStack.style.removeProperty("height");
 | |
|     changeFn();
 | |
|     let newHeight = this._viewStack.getBoundingClientRect().height;
 | |
| 
 | |
|     // Now we can allow the popup panel to resize again. This must occur
 | |
|     // in the same tick as the code below, but we can do this before
 | |
|     // setting the starting height in case the transition is not needed.
 | |
|     this._panel.removeAttribute("width");
 | |
|     this._panel.removeAttribute("height");
 | |
| 
 | |
|     if (oldHeight != newHeight) {
 | |
|       // Height transitions can only occur between two numeric values, and
 | |
|       // cannot start if the height is not set. In case a transition is
 | |
|       // needed, we have to set the height to the old value, then force a
 | |
|       // synchronous layout so the panel won't resize unexpectedly.
 | |
|       this._viewStack.style.height = oldHeight + "px";
 | |
|       this._viewStack.getBoundingClientRect().height;
 | |
| 
 | |
|       // We can now set the new height to start the transition, but
 | |
|       // before doing that we set up a listener to reset the height to
 | |
|       // "auto" at the end, so that DOM changes made after the
 | |
|       // transition ends are still reflected by the height of the panel.
 | |
|       let onTransitionEnd = event => {
 | |
|         if (event.target != this._viewStack) {
 | |
|           return;
 | |
|         }
 | |
|         this._viewStack.removeEventListener("transitionend", onTransitionEnd);
 | |
|         this._viewStack.style.removeProperty("height");
 | |
|       };
 | |
|       this._viewStack.addEventListener("transitionend", onTransitionEnd);
 | |
|       this._viewStack.style.height = newHeight + "px";
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   _shiftMainView(aAnchor) {
 | |
|     if (aAnchor) {
 | |
|       // We need to find the edge of the anchor, relative to the main panel.
 | |
|       // Then we need to add half the width of the anchor. This is the target
 | |
|       // that we need to transition to.
 | |
|       let anchorRect = aAnchor.getBoundingClientRect();
 | |
|       let mainViewRect = this._mainViewContainer.getBoundingClientRect();
 | |
|       let center = aAnchor.clientWidth / 2;
 | |
|       let direction = aAnchor.ownerGlobal.getComputedStyle(aAnchor).direction;
 | |
|       let edge;
 | |
|       if (direction == "ltr") {
 | |
|         edge = anchorRect.left - mainViewRect.left;
 | |
|       } else {
 | |
|         edge = mainViewRect.right - anchorRect.right;
 | |
|       }
 | |
| 
 | |
|       // If the anchor is an element on the far end of the mainView we
 | |
|       // don't want to shift the mainView too far, we would reveal empty
 | |
|       // space otherwise.
 | |
|       let cstyle = this.window.getComputedStyle(this.document.documentElement);
 | |
|       let exitSubViewGutterWidth =
 | |
|         cstyle.getPropertyValue("--panel-ui-exit-subview-gutter-width");
 | |
|       let maxShift = mainViewRect.width - parseInt(exitSubViewGutterWidth);
 | |
|       let target = Math.min(maxShift, edge + center);
 | |
| 
 | |
|       let neg = direction == "ltr" ? "-" : "";
 | |
|       this._mainViewContainer.style.transform = `translateX(${neg}${target}px)`;
 | |
|       aAnchor.setAttribute("panel-multiview-anchor", true);
 | |
|     } else {
 | |
|       this._mainViewContainer.style.transform = "";
 | |
|       if (this.anchorElement)
 | |
|         this.anchorElement.removeAttribute("panel-multiview-anchor");
 | |
|     }
 | |
|     this.anchorElement = aAnchor;
 | |
|   }
 | |
| 
 | |
|   handleEvent(aEvent) {
 | |
|     if (aEvent.type.startsWith("popup") && aEvent.target != this._panel) {
 | |
|       // Shouldn't act on e.g. context menus being shown from within the panel.
 | |
|       return;
 | |
|     }
 | |
|     switch (aEvent.type) {
 | |
|       case "click":
 | |
|         if (aEvent.originalTarget == this._clickCapturer) {
 | |
|           this.showMainView();
 | |
|         }
 | |
|         break;
 | |
|       case "keydown":
 | |
|         this._keyNavigation(aEvent);
 | |
|         break;
 | |
|       case "mousemove":
 | |
|         this._resetKeyNavigation();
 | |
|         break;
 | |
|       case "popupshowing": {
 | |
|         this.node.setAttribute("panelopen", "true");
 | |
|         if (this.panelViews && !this.node.hasAttribute("disablekeynav")) {
 | |
|           this.window.addEventListener("keydown", this);
 | |
|           this._panel.addEventListener("mousemove", this);
 | |
|         }
 | |
|         break;
 | |
|       }
 | |
|       case "popuppositioned": {
 | |
|         // When autoPosition is true, the popup window manager would attempt to re-position
 | |
|         // the panel as subviews are opened and it changes size. The resulting popoppositioned
 | |
|         // events triggers the binding's arrow position adjustment - and its reflow.
 | |
|         // This is not needed here, as we calculated and set maxHeight so it is known
 | |
|         // to fit the screen while open.
 | |
|         // autoPosition gets reset after each popuppositioned event, and when the
 | |
|         // popup closes, so we must set it back to false each time.
 | |
|         this._panel.autoPosition = false;
 | |
| 
 | |
|         if (this._panel.state == "showing") {
 | |
|           let maxHeight = this._calculateMaxHeight();
 | |
|           this._viewStack.style.maxHeight = maxHeight + "px";
 | |
|         }
 | |
|         break;
 | |
|       }
 | |
|       case "popupshown":
 | |
|         // Now that the main view is visible, we can check the height of the
 | |
|         // description elements it contains.
 | |
|         this.descriptionHeightWorkaround();
 | |
|         break;
 | |
|       case "popuphidden": {
 | |
|         // WebExtensions consumers can hide the popup from viewshowing, or
 | |
|         // mid-transition, which disrupts our state:
 | |
|         this._viewShowing = null;
 | |
|         this._transitioning = false;
 | |
|         this.node.removeAttribute("panelopen");
 | |
|         this.showMainView();
 | |
|         if (this.panelViews) {
 | |
|           for (let panelView of this._viewStack.children) {
 | |
|             if (panelView.nodeName != "children") {
 | |
|               panelView.__lastKnownBoundingRect = null;
 | |
|               panelView.style.removeProperty("min-width");
 | |
|               panelView.style.removeProperty("max-width");
 | |
|             }
 | |
|           }
 | |
|           this.window.removeEventListener("keydown", this);
 | |
|           this._panel.removeEventListener("mousemove", this);
 | |
|           this._resetKeyNavigation();
 | |
| 
 | |
|           // Clear the main view size caches. The dimensions could be different
 | |
|           // when the popup is opened again, e.g. through touch mode sizing.
 | |
|           this._mainViewHeight = 0;
 | |
|           this._mainViewWidth = 0;
 | |
|           this._viewContainer.style.removeProperty("min-height");
 | |
|           this._viewStack.style.removeProperty("max-height");
 | |
|           this._viewContainer.style.removeProperty("min-width");
 | |
|           this._viewContainer.style.removeProperty("max-width");
 | |
|         }
 | |
| 
 | |
|         this._dispatchViewEvent(this.node, "PanelMultiViewHidden");
 | |
|         break;
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Based on going up or down, select the previous or next focusable button
 | |
|    * in the current view.
 | |
|    *
 | |
|    * @param {Object}  navMap   the navigation keyboard map object for the view
 | |
|    * @param {Array}   buttons  an array of focusable buttons to select an item from.
 | |
|    * @param {Boolean} isDown   whether we're going down (true) or up (false) in this view.
 | |
|    *
 | |
|    * @return {DOMNode} the button we selected.
 | |
|    */
 | |
|   _updateSelectedKeyNav(navMap, buttons, isDown) {
 | |
|     let lastSelected = navMap.selected && navMap.selected.get();
 | |
|     let newButton = null;
 | |
|     let maxIdx = buttons.length - 1;
 | |
|     if (lastSelected) {
 | |
|       let buttonIndex = buttons.indexOf(lastSelected);
 | |
|       if (buttonIndex != -1) {
 | |
|         // Buttons may get selected whilst the panel is shown, so add an extra
 | |
|         // check here.
 | |
|         do {
 | |
|           buttonIndex = buttonIndex + (isDown ? 1 : -1);
 | |
|         } while (buttons[buttonIndex] && buttons[buttonIndex].disabled);
 | |
|         if (isDown && buttonIndex > maxIdx)
 | |
|           buttonIndex = 0;
 | |
|         else if (!isDown && buttonIndex < 0)
 | |
|           buttonIndex = maxIdx;
 | |
|         newButton = buttons[buttonIndex];
 | |
|       } else {
 | |
|         // The previously selected item is no longer selectable. Find the next item:
 | |
|         let allButtons = lastSelected.closest("panelview").getElementsByTagName("toolbarbutton");
 | |
|         let maxAllButtonIdx = allButtons.length - 1;
 | |
|         let allButtonIndex = allButtons.indexOf(lastSelected);
 | |
|         while (allButtonIndex >= 0 && allButtonIndex <= maxAllButtonIdx) {
 | |
|           allButtonIndex++;
 | |
|           // Check if the next button is in the list of focusable buttons.
 | |
|           buttonIndex = buttons.indexOf(allButtons[allButtonIndex]);
 | |
|           if (buttonIndex != -1) {
 | |
|             // If it is, just use that button if we were going down, or the previous one
 | |
|             // otherwise. If this was the first button, newButton will end up undefined,
 | |
|             // which is fine because we'll fall back to using the last button at the
 | |
|             // bottom of this method.
 | |
|             newButton = buttons[isDown ? buttonIndex : buttonIndex - 1];
 | |
|             break;
 | |
|           }
 | |
|         }
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // If we couldn't find something, select the first or last item:
 | |
|     if (!newButton) {
 | |
|       newButton = buttons[isDown ? 0 : maxIdx];
 | |
|     }
 | |
|     navMap.selected = Cu.getWeakReference(newButton);
 | |
|     return newButton;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Allow for navigating subview buttons using the arrow keys and the Enter key.
 | |
|    * The Up and Down keys can be used to navigate the list up and down and the
 | |
|    * Enter, Right or Left - depending on the text direction - key can be used to
 | |
|    * simulate a click on the currently selected button.
 | |
|    * The Right or Left key - depending on the text direction - can be used to
 | |
|    * navigate to the previous view, functioning as a shortcut for the view's
 | |
|    * back button.
 | |
|    * Thus, in LTR mode:
 | |
|    *  - The Right key functions the same as the Enter key, simulating a click
 | |
|    *  - The Left key triggers a navigation back to the previous view.
 | |
|    *
 | |
|    * @param {KeyEvent} event
 | |
|    */
 | |
|   _keyNavigation(event) {
 | |
|     if (this._transitioning)
 | |
|       return;
 | |
| 
 | |
|     let view = this._currentSubView;
 | |
|     let navMap = this._keyNavigationMap.get(view);
 | |
|     if (!navMap) {
 | |
|       navMap = {};
 | |
|       this._keyNavigationMap.set(view, navMap);
 | |
|     }
 | |
| 
 | |
|     let buttons = navMap.buttons;
 | |
|     if (!buttons || !buttons.length) {
 | |
|       buttons = navMap.buttons = this._getNavigableElements(view);
 | |
|       // Set the 'tabindex' attribute on the buttons to make sure they're focussable.
 | |
|       for (let button of buttons) {
 | |
|         if (!button.classList.contains("subviewbutton-back") &&
 | |
|             !button.hasAttribute("tabindex")) {
 | |
|           button.setAttribute("tabindex", 0);
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|     if (!buttons.length)
 | |
|       return;
 | |
| 
 | |
|     let stop = () => {
 | |
|       event.stopPropagation();
 | |
|       event.preventDefault();
 | |
|     };
 | |
| 
 | |
|     let keyCode = event.code;
 | |
|     switch (keyCode) {
 | |
|       case "ArrowDown":
 | |
|       case "ArrowUp": {
 | |
|         stop();
 | |
|         let isDown = (keyCode == "ArrowDown");
 | |
|         let button = this._updateSelectedKeyNav(navMap, buttons, isDown);
 | |
|         button.focus();
 | |
|         break;
 | |
|       }
 | |
|       case "ArrowLeft":
 | |
|       case "ArrowRight": {
 | |
|         stop();
 | |
|         let dir = this._dir;
 | |
|         if ((dir == "ltr" && keyCode == "ArrowLeft") ||
 | |
|             (dir == "rtl" && keyCode == "ArrowRight")) {
 | |
|           if (this._canGoBack(view))
 | |
|             this.goBack(view.backButton);
 | |
|           break;
 | |
|         }
 | |
|         // If the current button is _not_ one that points to a subview, pressing
 | |
|         // the arrow key shouldn't do anything.
 | |
|         if (!navMap.selected || !navMap.selected.get() ||
 | |
|             !navMap.selected.get().classList.contains("subviewbutton-nav")) {
 | |
|           break;
 | |
|         }
 | |
|         // Fall-through...
 | |
|       }
 | |
|       case "Space":
 | |
|       case "Enter": {
 | |
|         let button = navMap.selected && navMap.selected.get();
 | |
|         if (!button)
 | |
|           break;
 | |
|         stop();
 | |
| 
 | |
|         // Unfortunately, 'tabindex' doesn't execute the default action, so
 | |
|         // we explicitly do this here.
 | |
|         // We are sending a command event and then a click event.
 | |
|         // This is done in order to mimic a "real" mouse click event.
 | |
|         // The command event executes the action, then the click event closes the menu.
 | |
|         button.doCommand();
 | |
|         let clickEvent = new event.target.ownerGlobal.MouseEvent("click", {"bubbles": true});
 | |
|         button.dispatchEvent(clickEvent);
 | |
|         break;
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Clear all traces of keyboard navigation happening right now.
 | |
|    *
 | |
|    * @param {panelview} view View to reset the key navigation attributes of.
 | |
|    *                         If no view is passed, all navigation attributes for
 | |
|    *                         this panelmultiview are cleared.
 | |
|    */
 | |
|   _resetKeyNavigation(view) {
 | |
|     let viewToBlur = view || this._currentSubView;
 | |
|     let navMap = this._keyNavigationMap.get(viewToBlur);
 | |
|     if (navMap && navMap.selected && navMap.selected.get()) {
 | |
|       navMap.selected.get().blur();
 | |
|     }
 | |
| 
 | |
|     // We clear the entire key navigation map ONLY if *no* view was passed in.
 | |
|     // This happens e.g. when the popup is hidden completely, or the user moves
 | |
|     // their mouse.
 | |
|     // If a view is passed in, we just delete the map for that view. This happens
 | |
|     // when going back from a view (which resets the map for that view only)
 | |
|     if (view) {
 | |
|       this._keyNavigationMap.delete(view);
 | |
|     } else {
 | |
|       this._keyNavigationMap.clear();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Retrieve the button elements from a view node that can be used for navigation
 | |
|    * using the keyboard; enabled buttons and the back button, if visible.
 | |
|    *
 | |
|    * @param  {nsIDOMNode} view
 | |
|    * @return {Array}
 | |
|    */
 | |
|   _getNavigableElements(view) {
 | |
|     let buttons = Array.from(view.querySelectorAll(".subviewbutton:not([disabled])"));
 | |
|     if (this._canGoBack(view))
 | |
|       buttons.unshift(view.backButton);
 | |
|     let dwu = this._dwu;
 | |
|     return buttons.filter(button => {
 | |
|       let bounds = dwu.getBoundsWithoutFlushing(button);
 | |
|       return bounds.width > 0 && bounds.height > 0;
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Focus the last selected element in the view, if any.
 | |
|    *
 | |
|    * @param {panelview} view the view in which to update keyboard focus.
 | |
|    */
 | |
|   _updateKeyboardFocus(view) {
 | |
|     let navMap = this._keyNavigationMap.get(view);
 | |
|     if (navMap && navMap.selected && navMap.selected.get()) {
 | |
|       navMap.selected.get().focus();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * If the main view or a subview contains wrapping elements, the attribute
 | |
|    * "descriptionheightworkaround" should be set on the view to force all the
 | |
|    * wrapping "description", "label" or "toolbarbutton" elements to a fixed
 | |
|    * height. If the attribute is set and the visibility, contents, or width
 | |
|    * of any of these elements changes, this function should be called to
 | |
|    * refresh the calculated heights.
 | |
|    *
 | |
|    * This may trigger a synchronous layout.
 | |
|    *
 | |
|    * @param viewNode
 | |
|    *        Indicates the node to scan for descendant elements. This is the main
 | |
|    *        view if omitted.
 | |
|    */
 | |
|   descriptionHeightWorkaround(viewNode = this._mainView) {
 | |
|     if (!viewNode || !viewNode.hasAttribute("descriptionheightworkaround")) {
 | |
|       // This view does not require the workaround.
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     // We batch DOM changes together in order to reduce synchronous layouts.
 | |
|     // First we reset any change we may have made previously. The first time
 | |
|     // this is called, and in the best case scenario, this has no effect.
 | |
|     let items = [];
 | |
|     // Non-hidden <label> or <description> elements that also aren't empty
 | |
|     // and also don't have a value attribute can be multiline (if their
 | |
|     // text content is long enough).
 | |
|     let isMultiline = ":not(:-moz-any([hidden],[value],:empty))";
 | |
|     let selector = [
 | |
|       "description" + isMultiline,
 | |
|       "label" + isMultiline,
 | |
|       "toolbarbutton[wrap]:not([hidden])",
 | |
|     ].join(",");
 | |
|     for (let element of viewNode.querySelectorAll(selector)) {
 | |
|       // Ignore items in hidden containers.
 | |
|       if (element.closest("[hidden]")) {
 | |
|         continue;
 | |
|       }
 | |
|       // Take the label for toolbarbuttons; it only exists on those elements.
 | |
|       element = element.labelElement || element;
 | |
| 
 | |
|       let bounds = element.getBoundingClientRect();
 | |
|       let previous = this._multiLineElementsMap.get(element);
 | |
|       // We don't need to (re-)apply the workaround for invisible elements or
 | |
|       // on elements we've seen before and haven't changed in the meantime.
 | |
|       if (!bounds.width || !bounds.height ||
 | |
|           (previous && element.textContent == previous.textContent &&
 | |
|                        bounds.width == previous.bounds.width)) {
 | |
|         continue;
 | |
|       }
 | |
| 
 | |
|       items.push({ element });
 | |
|     }
 | |
| 
 | |
|     // Removing the 'height' property will only cause a layout flush in the next
 | |
|     // loop below if it was set.
 | |
|     for (let item of items) {
 | |
|       item.element.style.removeProperty("height");
 | |
|     }
 | |
| 
 | |
|     // We now read the computed style to store the height of any element that
 | |
|     // may contain wrapping text.
 | |
|     for (let item of items) {
 | |
|       item.bounds = item.element.getBoundingClientRect();
 | |
|     }
 | |
| 
 | |
|     // Now we can make all the necessary DOM changes at once.
 | |
|     for (let { element, bounds } of items) {
 | |
|       this._multiLineElementsMap.set(element, { bounds, textContent: element.textContent });
 | |
|       element.style.height = bounds.height + "px";
 | |
|     }
 | |
|   }
 | |
| };
 |