/* 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/. */ /** * Allows a popup panel to host multiple subviews. The main view shown when the * panel is opened may slide out to display a subview, which in turn may lead to * other subviews in a cascade menu pattern. * * The element should contain a element. Views are * declared using elements that are usually children of the main * element, although they don't need to be, as views can also * be imported into the panel from other panels or popup sets. * * The panel should be opened asynchronously using the openPopup static method * on the PanelMultiView object. This will display the view specified using the * mainViewId attribute on the contained element. * * Specific subviews can slide in using the showSubView method, and backwards * navigation can be done using the goBack method or through a button in the * subview headers. * * This diagram shows how nodes move during navigation: * * In this In other panels Action * ┌───┬───┬───┐ ┌───┬───┐ * │(A)│ B │ C │ │ D │ E │ Open panel * └───┴───┴───┘ └───┴───┘ * ┌───┬───┬───┐ ┌───┬───┐ * │ A │(C)│ B │ │ D │ E │ Show subview C * └───┴───┴───┘ └───┴───┘ * ┌───┬───┬───┬───┐ ┌───┐ * │ A │ C │(D)│ B │ │ E │ Show subview D * └───┴───┴───┴───┘ └───┘ * ┌───┬───┬───┬───┐ ┌───┐ * │ A │(C)│ D │ B │ │ E │ Go back * └───┴───┴───┴───┘ └───┘ * │ * └── Currently visible view * * If the element is "ephemeral", imported subviews will be * moved out again to the element specified by the viewCacheId attribute, so * that the panel element can be removed safely. */ "use strict"; this.EXPORTED_SYMBOLS = [ "PanelMultiView", "PanelView", ]; const {classes: Cc, interfaces: Ci, utils: Cu} = Components; ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); ChromeUtils.import("resource://gre/modules/Services.jsm"); ChromeUtils.defineModuleGetter(this, "AppConstants", "resource://gre/modules/AppConstants.jsm"); ChromeUtils.defineModuleGetter(this, "BrowserUtils", "resource://gre/modules/BrowserUtils.jsm"); ChromeUtils.defineModuleGetter(this, "CustomizableUI", "resource:///modules/CustomizableUI.jsm"); const TRANSITION_PHASES = Object.freeze({ START: 1, PREPARE: 2, TRANSITION: 3, END: 4 }); let gNodeToObjectMap = new WeakMap(); let gMultiLineElementsMap = new WeakMap(); /** * Allows associating an object to a node lazily using a weak map. * * Classes deriving from this one may be easily converted to Custom Elements, * although they would lose the ability of being associated lazily. */ this.AssociatedToNode = class { constructor(node) { /** * Node associated to this object. */ this.node = node; } /** * Retrieves the instance associated with the given node, constructing a new * one if necessary. When the last reference to the node is released, the * object instance will be garbage collected as well. */ static forNode(node) { let associatedToNode = gNodeToObjectMap.get(node); if (!associatedToNode) { associatedToNode = new this(node); gNodeToObjectMap.set(node, associatedToNode); } return associatedToNode; } get document() { return this.node.ownerDocument; } get window() { return this.node.ownerGlobal; } /** * nsIDOMWindowUtils for the window of this node. */ get _dwu() { if (this.__dwu) return this.__dwu; return this.__dwu = this.window.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIDOMWindowUtils); } /** * Dispatches a custom event on this element. * * @param {String} eventName Name of the event to dispatch. * @param {Object} [detail] Event detail object. Optional. * @param {Boolean} cancelable If the event can be canceled. * @return {Boolean} `true` if the event was canceled by an event handler, `false` * otherwise. */ dispatchCustomEvent(eventName, detail, cancelable = false) { let event = new this.window.CustomEvent(eventName, { detail, bubbles: true, cancelable, }); this.node.dispatchEvent(event); return event.defaultPrevented; } }; /** * This is associated to elements by the panelUI.xml binding. */ this.PanelMultiView = class extends this.AssociatedToNode { /** * Tries to open the specified and displays the main view specified * with the "mainViewId" attribute on the node it contains. * * If the panel does not contain a , it is opened directly. * This allows consumers like page actions to accept different panel types. * * @see The non-static openPopup method for details. */ static async openPopup(panelNode, ...args) { let panelMultiViewNode = panelNode.querySelector("panelmultiview"); if (panelMultiViewNode) { return this.forNode(panelMultiViewNode).openPopup(...args); } panelNode.openPopup(...args); return true; } /** * Closes the specified which contains a node. * * If the panel does not contain a , it is closed directly. * This allows consumers like page actions to accept different panel types. * * @see The non-static hidePopup method for details. */ static hidePopup(panelNode) { let panelMultiViewNode = panelNode.querySelector("panelmultiview"); if (panelMultiViewNode) { this.forNode(panelMultiViewNode).hidePopup(); } else { panelNode.hidePopup(); } } get _panel() { return this.node.parentNode; } get _mainViewId() { return this.node.getAttribute("mainViewId"); } get _mainView() { return this.document.getElementById(this._mainViewId); } 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 _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.node && (this._viewShowing || this._currentSubView); } get _currentSubView() { // Peek the top of the stack, but fall back to the main view if the list of // opened views is currently empty. let panelView = this.openViews[this.openViews.length - 1]; return (panelView && panelView.node) || this._mainView; } constructor(node) { super(node); this._openPopupPromise = Promise.resolve(false); this._openPopupCancelCallback = () => {}; } connect() { this.connected = true; this.knownViews = new Set(Array.from( this.node.getElementsByTagName("panelview"), node => PanelView.forNode(node))); this.openViews = []; this.__transitioning = false; this.showingSubView = false; const {document, window} = this; this._viewContainer = document.getAnonymousElementByAttribute(this.node, "anonid", "viewContainer"); 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); 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; // Proxy these public properties and methods, as used elsewhere by various // parts of the browser, to this instance. ["goBack", "showMainView", "showSubView"].forEach(method => { Object.defineProperty(this.node, method, { enumerable: true, value: (...args) => this[method](...args) }); }); ["current", "showingSubView"].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"); } this._moveOutKids(this._viewStack); 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._openPopupPromise = this._openPopupCancelCallback = this._viewContainer = this._viewStack = this.__dwu = this._panelViewCache = this._transitionDetails = null; } /** * Tries to open the panel associated with this PanelMultiView, and displays * the main view specified with the "mainViewId" attribute. * * The hidePopup method can be called while the operation is in progress to * prevent the panel from being displayed. View events may also cancel the * operation, so there is no guarantee that the panel will become visible. * * The "popuphidden" event will be fired either when the operation is canceled * or when the popup is closed later. This event can be used for example to * reset the "open" state of the anchor or tear down temporary panels. * * If this method is called again before the panel is shown, the result * depends on the operation currently in progress. If the operation was not * canceled, the panel is opened using the arguments from the previous call, * and this call is ignored. If the operation was canceled, it will be * retried again using the arguments from this call. * * It's not necessary for the binding to be connected when * this method is called, but the containing panel must have its display * turned on, for example it shouldn't have the "hidden" attribute. * * @param args * Arguments to be forwarded to the openPopup method of the panel. * * @resolves With true as soon as the request to display the panel has been * sent, or with false if the operation was canceled. The state of * the panel at this point is not guaranteed. It may be still * showing, completely shown, or completely hidden. * @rejects If an exception is thrown at any point in the process before the * request to display the panel is sent. */ async openPopup(...args) { // Set up the function that allows hidePopup or a second call to showPopup // to cancel the specific panel opening operation that we're starting below. // This function must be synchronous, meaning we can't use Promise.race, // because hidePopup wants to dispatch the "popuphidden" event synchronously // even if the panel has not been opened yet. let canCancel = true; let cancelCallback = this._openPopupCancelCallback = () => { // If the cancel callback is called and the panel hasn't been prepared // yet, cancel showing it. Setting canCancel to false will prevent the // popup from opening. If the panel has opened by the time the cancel // callback is called, canCancel will be false already, and we will not // fire the "popuphidden" event. if (canCancel && this.node) { canCancel = false; this.dispatchCustomEvent("popuphidden"); } }; // Create a promise that is resolved with the result of the last call to // this method, where errors indicate that the panel was not opened. let openPopupPromise = this._openPopupPromise.catch(() => { return false; }); // Make the preparation done before showing the panel non-reentrant. The // promise created here will be resolved only after the panel preparation is // completed, even if a cancellation request is received in the meantime. return this._openPopupPromise = openPopupPromise.then(async wasShown => { // The panel may have been destroyed in the meantime. if (!this.node) { return false; } // If the panel has been already opened there is nothing more to do. We // check the actual state of the panel rather than setting some state in // our handler of the "popuphidden" event because this has a lower chance // of locking indefinitely if events aren't raised in the expected order. if (wasShown && ["open", "showing"].includes(this._panel.state)) { return true; } try { // Most of the panel elements in the browser window have their display // turned off for performance reasons, typically by setting the "hidden" // attribute. If the caller has just turned on the display, the XBL // binding for the element may still be disconnected. // In this case, give the layout code a chance to run. if (!this.connected) { await BrowserUtils.promiseLayoutFlushed(this.document, "layout", () => {}); // The XBL binding must be connected at this point. If this is not the // case, the calling code should be updated to unhide the panel. if (!this.connected) { throw new Error("The binding for the panelmultiview element isn't" + " connected. The containing panel may still have" + " its display turned off by the hidden attribute."); } } // Allow any of the ViewShowing handlers to prevent showing the main view. if (!(await this.showMainView())) { cancelCallback(); } } catch (ex) { cancelCallback(); throw ex; } // If a cancellation request was received there is nothing more to do. if (!canCancel || !this.node) { return false; } // We have to set canCancel to false before opening the popup because the // hidePopup method of PanelMultiView can be re-entered by event handlers. // If the openPopup call fails, however, we still have to dispatch the // "popuphidden" event even if canCancel was set to false. try { canCancel = false; this._panel.openPopup(...args); return true; } catch (ex) { this.dispatchCustomEvent("popuphidden"); throw ex; } }); } /** * Closes the panel associated with this PanelMultiView. * * If the openPopup method was called but the panel has not been displayed * yet, the operation is canceled and the panel will not be displayed, but the * "popuphidden" event is fired synchronously anyways. * * This means that by the time this method returns all the operations handled * by the "popuphidden" event are completed, for example resetting the "open" * state of the anchor, and the panel is already invisible. */ hidePopup() { if (!this.node) { return; } // If we have already reached the _panel.openPopup call in the openPopup // method, we can call hidePopup. Otherwise, we have to cancel the latest // request to open the panel, which will have no effect if the request has // been canceled already. if (["open", "showing"].includes(this._panel.state)) { this._panel.hidePopup(); } else { this._openPopupCancelCallback(); } } /** * 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); } } goBack() { if (this.openViews.length < 2) { // This may be called by keyboard navigation or external code when only // the main view is open. return; } let previous = this.openViews.pop().node; let current = this._currentSubView; this.showSubView(current, null, previous); } showMainView() { if (!this.node || !this._mainViewId) return Promise.resolve(false); return this.showSubView(this._mainView); } /** * Ensures that all the panelviews, that are currently part of this instance, * are hidden, except one specifically. * * @param {panelview} [nextPanelView] * The PanelView object to ensure is visible. Optional. */ hideAllViewsExcept(nextPanelView = null) { for (let panelView of this.knownViews) { // When the panelview was already reparented, don't interfere any more. if (panelView == nextPanelView || !this.node || panelView.node.panelMultiView != this.node) continue; panelView.current = false; } this._viewShowing = null; if (!this.node || !nextPanelView) return; if (!this.openViews.includes(nextPanelView)) this.openViews.push(nextPanelView); nextPanelView.current = true; this.showingSubView = nextPanelView.node.id != this._mainViewId; } async showSubView(aViewId, aAnchor, aPreviousView) { try { // 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._viewStack.appendChild(viewNode); } else { throw new Error(`Subview ${aViewId} doesn't exist!`); } } else if (viewNode.parentNode == this._panelViewCache) { this._viewStack.appendChild(viewNode); } let nextPanelView = PanelView.forNode(viewNode); this.knownViews.add(nextPanelView); viewNode.panelMultiView = this.node; 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 prevPanelView = PanelView.forNode(previousViewNode); prevPanelView.captureKnownSize(); this._viewShowing = viewNode; let reverse = !!aPreviousView; if (!reverse) { // We are opening a new view, either because we are navigating forward // or because we are showing the main view. Some properties of the view // may vary between panels, so we make sure to update them every time. // Firstly, make sure that the header matches how the view was opened. nextPanelView.headerText = viewNode.getAttribute("title") || (aAnchor && aAnchor.getAttribute("label")); // The main view of a panel can be a subview in another one. let isMainView = viewNode.id == this._mainViewId; nextPanelView.mainview = isMainView; // The constrained width of subviews may also vary between panels. nextPanelView.minMaxWidth = isMainView ? 0 : prevPanelView.knownWidth; } if (aAnchor) { viewNode.classList.add("PanelUI-subView"); } 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 = nextPanelView.dispatchCustomEvent("ViewShowing", detail, true); 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 false; } } // Now we have to transition the panel. If we've got an older transition // still running, make sure to clean it up. await this._cleanupTransitionPhase(); if (!showingSameView && this._panel.state == "open") { await this._transitionViews(previousViewNode, viewNode, reverse, aAnchor); nextPanelView.focusSelectedElement(); } else { this.hideAllViewsExcept(nextPanelView); } return true; } catch (ex) { Cu.reportError(ex); return false; } } /** * 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 {Element} anchor the anchor for which we're opening * a new panelview, if any */ async _transitionViews(previousViewNode, viewNode, reverse, 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; let nextPanelView = PanelView.forNode(viewNode); let prevPanelView = PanelView.forNode(previousViewNode); 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. let olderView = reverse ? nextPanelView : prevPanelView; this._viewContainer.style.minHeight = olderView.knownHeight + "px"; this._viewContainer.style.height = prevPanelView.knownHeight + "px"; this._viewContainer.style.width = prevPanelView.knownWidth + "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 (reverse) { // Use the cached size when going back to a previous view, but not when // reopening a subview, because its contents may have changed. viewRect = { width: nextPanelView.knownWidth, height: nextPanelView.knownHeight }; 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 width = prevPanelView.knownWidth; let height = prevPanelView.knownHeight; viewRect = Object.assign({height, width}, viewNode.customRectGetter()); let header = viewNode.firstChild; if (header && header.classList.contains("panel-header")) { viewRect.height += this._dwu.getBoundsWithoutFlushing(header).height; } viewNode.setAttribute("in-transition", true); } else { let oldSibling = viewNode.nextSibling || null; this._offscreenViewStack.style.minHeight = olderView.knownHeight + "px"; 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. nextPanelView.descriptionHeightWorkaround(); 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._offscreenViewStack.style.removeProperty("min-height"); } 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 = prevPanelView.knownWidth; 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"; // Use an outline instead of a border so that the size is not affected. deepestNode.style.outline = "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 = viewRect.height + "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; let nextPanelView = PanelView.forNode(viewNode); let prevPanelView = PanelView.forNode(previousViewNode); // Do the things we _always_ need to do whenever the transition ends or is // interrupted. this.hideAllViewsExcept(nextPanelView); previousViewNode.removeAttribute("in-transition"); viewNode.removeAttribute("in-transition"); if (reverse) prevPanelView.clearNavigation(); 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("outline"); 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"); } } _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; } 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 "keydown": if (!this._transitioning) { PanelView.forNode(this._currentSubView) .keyNavigation(aEvent, this._dir); } break; case "mousemove": this.openViews.forEach(panelView => panelView.clearNavigation()); break; case "popupshowing": { this.node.setAttribute("panelopen", "true"); if (!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"; this._offscreenViewStack.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. PanelView.forNode(this._mainView).descriptionHeightWorkaround(); break; case "popuphidden": { // WebExtensions consumers can hide the popup from viewshowing, or // mid-transition, which disrupts our state: if (this._viewShowing) { PanelView.forNode(this._viewShowing).dispatchCustomEvent("ViewHiding"); } this._viewShowing = null; this._transitioning = false; this.node.removeAttribute("panelopen"); // Raise the ViewHiding event for the current view. this.hideAllViewsExcept(null); this.window.removeEventListener("keydown", this); this._panel.removeEventListener("mousemove", this); this.openViews.forEach(panelView => panelView.clearNavigation()); this.openViews = []; // Clear the main view size caches. The dimensions could be different // when the popup is opened again, e.g. through touch mode sizing. this._viewContainer.style.removeProperty("min-height"); this._viewStack.style.removeProperty("max-height"); this._viewContainer.style.removeProperty("width"); this._viewContainer.style.removeProperty("height"); this.dispatchCustomEvent("PanelMultiViewHidden"); break; } } } }; /** * This is associated to elements. */ this.PanelView = class extends this.AssociatedToNode { /** * The "mainview" attribute is set before the panel is opened when this view * is displayed as the main view, and is removed before the is * displayed as a subview. The same view element can be displayed as a main * view and as a subview at different times. */ set mainview(value) { if (value) { this.node.setAttribute("mainview", true); } else { this.node.removeAttribute("mainview"); } } set current(value) { if (value) { if (!this.node.hasAttribute("current")) { this.node.setAttribute("current", true); this.descriptionHeightWorkaround(); this.dispatchCustomEvent("ViewShown"); } } else if (this.node.hasAttribute("current")) { this.dispatchCustomEvent("ViewHiding"); this.node.removeAttribute("current"); } } /** * Constrains the width of this view using the "min-width" and "max-width" * styles. Setting this to zero removes the constraints. */ set minMaxWidth(value) { let style = this.node.style; if (value) { style.minWidth = style.maxWidth = value + "px"; } else { style.removeProperty("min-width"); style.removeProperty("max-width"); } } /** * Adds a header with the given title, or removes it if the title is empty. */ set headerText(value) { // If the header already exists, update or remove it as requested. let header = this.node.firstChild; if (header && header.classList.contains("panel-header")) { if (value) { header.querySelector("label").setAttribute("value", value); } else { header.remove(); } return; } // The header doesn't exist, only create it if needed. if (!value) { return; } header = this.document.createElement("box"); header.classList.add("panel-header"); let backButton = this.document.createElement("toolbarbutton"); backButton.className = "subviewbutton subviewbutton-iconic subviewbutton-back"; backButton.setAttribute("closemenu", "none"); backButton.setAttribute("tabindex", "0"); backButton.setAttribute("tooltip", this.node.getAttribute("data-subviewbutton-tooltip")); backButton.addEventListener("command", () => { // The panelmultiview element may change if the view is reused. this.node.panelMultiView.goBack(); backButton.blur(); }); let label = this.document.createElement("label"); label.setAttribute("value", value); header.append(backButton, label); this.node.prepend(header); } /** * Also make sure that the correct method is called on CustomizableWidget. */ dispatchCustomEvent(...args) { CustomizableUI.ensureSubviewListeners(this.node); return super.dispatchCustomEvent(...args); } /** * Populates the "knownWidth" and "knownHeight" properties with the current * dimensions of the view. These may be zero if the view is invisible. * * These values are relevant during transitions and are retained for backwards * navigation if the view is still open but is invisible. */ captureKnownSize() { let rect = this._dwu.getBoundsWithoutFlushing(this.node); this.knownWidth = rect.width; this.knownHeight = rect.height; } /** * 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. */ descriptionHeightWorkaround() { if (!this.node.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