/* 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/. */ XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI", "resource:///modules/CustomizableUI.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "ScrollbarSampler", "resource:///modules/ScrollbarSampler.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "ShortcutUtils", "resource://gre/modules/ShortcutUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", "resource://gre/modules/AppConstants.jsm"); /** * Maintains the state and dispatches events for the main menu panel. */ const PanelUI = { /** Panel events that we listen for. **/ get kEvents() { return ["popupshowing", "popupshown", "popuphiding", "popuphidden"]; }, /** * Used for lazily getting and memoizing elements from the document. Lazy * getters are set in init, and memoizing happens after the first retrieval. */ get kElements() { return { contents: "PanelUI-contents", mainView: "PanelUI-mainView", multiView: "PanelUI-multiView", helpView: "PanelUI-helpView", menuButton: "PanelUI-menu-button", panel: "PanelUI-popup", scroller: "PanelUI-contents-scroller" }; }, _initialized: false, init: function() { for (let [k, v] of Object.entries(this.kElements)) { // Need to do fresh let-bindings per iteration let getKey = k; let id = v; this.__defineGetter__(getKey, function() { delete this[getKey]; return this[getKey] = document.getElementById(id); }); } this.menuButton.addEventListener("mousedown", this); this.menuButton.addEventListener("keypress", this); this._overlayScrollListenerBoundFn = this._overlayScrollListener.bind(this); window.matchMedia("(-moz-overlay-scrollbars)").addListener(this._overlayScrollListenerBoundFn); CustomizableUI.addListener(this); this._initialized = true; }, _eventListenersAdded: false, _ensureEventListenersAdded: function() { if (this._eventListenersAdded) return; this._addEventListeners(); }, _addEventListeners: function() { for (let event of this.kEvents) { this.panel.addEventListener(event, this); } this.helpView.addEventListener("ViewShowing", this._onHelpViewShow, false); this._eventListenersAdded = true; }, uninit: function() { for (let event of this.kEvents) { this.panel.removeEventListener(event, this); } this.helpView.removeEventListener("ViewShowing", this._onHelpViewShow); this.menuButton.removeEventListener("mousedown", this); this.menuButton.removeEventListener("keypress", this); window.matchMedia("(-moz-overlay-scrollbars)").removeListener(this._overlayScrollListenerBoundFn); CustomizableUI.removeListener(this); this._overlayScrollListenerBoundFn = null; }, /** * Customize mode extracts the mainView and puts it somewhere else while the * user customizes. Upon completion, this function can be called to put the * panel back to where it belongs in normal browsing mode. * * @param aMainView * The mainView node to put back into place. */ setMainView: function(aMainView) { this._ensureEventListenersAdded(); this.multiView.setMainView(aMainView); }, /** * Opens the menu panel if it's closed, or closes it if it's * open. * * @param aEvent the event that triggers the toggle. */ toggle: function(aEvent) { // Don't show the panel if the window is in customization mode, // since this button doubles as an exit path for the user in this case. if (document.documentElement.hasAttribute("customizing")) { return; } this._ensureEventListenersAdded(); if (this.panel.state == "open") { this.hide(); } else if (this.panel.state == "closed") { this.show(aEvent); } }, /** * Opens the menu panel. If the event target has a child with the * toolbarbutton-icon attribute, the panel will be anchored on that child. * Otherwise, the panel is anchored on the event target itself. * * @param aEvent the event (if any) that triggers showing the menu. */ show: function(aEvent) { return new Promise(resolve => { this.ensureReady().then(() => { if (this.panel.state == "open" || document.documentElement.hasAttribute("customizing")) { resolve(); return; } let editControlPlacement = CustomizableUI.getPlacementOfWidget("edit-controls"); if (editControlPlacement && editControlPlacement.area == CustomizableUI.AREA_PANEL) { updateEditUIVisibility(); } let personalBookmarksPlacement = CustomizableUI.getPlacementOfWidget("personal-bookmarks"); if (personalBookmarksPlacement && personalBookmarksPlacement.area == CustomizableUI.AREA_PANEL) { PlacesToolbarHelper.customizeChange(); } let anchor; if (!aEvent || aEvent.type == "command") { anchor = this.menuButton; } else { anchor = aEvent.target; } this.panel.addEventListener("popupshown", function onPopupShown() { this.removeEventListener("popupshown", onPopupShown); resolve(); }); let iconAnchor = document.getAnonymousElementByAttribute(anchor, "class", "toolbarbutton-icon"); this.panel.openPopup(iconAnchor || anchor); }, (reason) => { console.error("Error showing the PanelUI menu", reason); }); }); }, /** * If the menu panel is being shown, hide it. */ hide: function() { if (document.documentElement.hasAttribute("customizing")) { return; } this.panel.hidePopup(); }, handleEvent: function(aEvent) { // Ignore context menus and menu button menus showing and hiding: if (aEvent.type.startsWith("popup") && aEvent.target != this.panel) { return; } switch (aEvent.type) { case "popupshowing": this._adjustLabelsForAutoHyphens(); // Fall through case "popupshown": // Fall through case "popuphiding": // Fall through case "popuphidden": this._updatePanelButton(aEvent.target); break; case "mousedown": if (aEvent.button == 0) this.toggle(aEvent); break; case "keypress": this.toggle(aEvent); break; } }, get isReady() { return !!this._isReady; }, /** * Registering the menu panel is done lazily for performance reasons. This * method is exposed so that CustomizationMode can force panel-readyness in the * event that customization mode is started before the panel has been opened * by the user. * * @param aCustomizing (optional) set to true if this was called while entering * customization mode. If that's the case, we trust that customization * mode will handle calling beginBatchUpdate and endBatchUpdate. * * @return a Promise that resolves once the panel is ready to roll. */ ensureReady: function(aCustomizing=false) { if (this._readyPromise) { return this._readyPromise; } this._readyPromise = Task.spawn(function*() { if (!this._initialized) { yield new Promise(resolve => { let delayedStartupObserver = (aSubject, aTopic, aData) => { if (aSubject == window) { Services.obs.removeObserver(delayedStartupObserver, "browser-delayed-startup-finished"); resolve(); } }; Services.obs.addObserver(delayedStartupObserver, "browser-delayed-startup-finished", false); }); } this.contents.setAttributeNS("http://www.w3.org/XML/1998/namespace", "lang", getLocale()); if (!this._scrollWidth) { // In order to properly center the contents of the panel, while ensuring // that we have enough space on either side to show a scrollbar, we have to // do a bit of hackery. In particular, we calculate a new width for the // scroller, based on the system scrollbar width. this._scrollWidth = (yield ScrollbarSampler.getSystemScrollbarWidth()) + "px"; let cstyle = window.getComputedStyle(this.scroller); let widthStr = cstyle.width; // Get the calculated padding on the left and right sides of // the scroller too. We'll use that in our final calculation so // that if a scrollbar appears, we don't have the contents right // up against the edge of the scroller. let paddingLeft = cstyle.paddingLeft; let paddingRight = cstyle.paddingRight; let calcStr = [widthStr, this._scrollWidth, paddingLeft, paddingRight].join(" + "); this.scroller.style.width = "calc(" + calcStr + ")"; } if (aCustomizing) { CustomizableUI.registerMenuPanel(this.contents); } else { this.beginBatchUpdate(); try { CustomizableUI.registerMenuPanel(this.contents); } finally { this.endBatchUpdate(); } } this._updateQuitTooltip(); this.panel.hidden = false; this._isReady = true; }.bind(this)).then(null, Cu.reportError); return this._readyPromise; }, /** * Switch the panel to the main view if it's not already * in that view. */ showMainView: function() { this._ensureEventListenersAdded(); this.multiView.showMainView(); }, /** * Switch the panel to the help view if it's not already * in that view. */ showHelpView: function(aAnchor) { this._ensureEventListenersAdded(); this.multiView.showSubView("PanelUI-helpView", aAnchor); }, /** * Shows a subview in the panel with a given ID. * * @param aViewId the ID of the subview to show. * @param aAnchor the element that spawned the subview. * @param aPlacementArea the CustomizableUI area that aAnchor is in. */ showSubView: Task.async(function*(aViewId, aAnchor, aPlacementArea) { this._ensureEventListenersAdded(); let viewNode = document.getElementById(aViewId); if (!viewNode) { Cu.reportError("Could not show panel subview with id: " + aViewId); return; } if (!aAnchor) { Cu.reportError("Expected an anchor when opening subview with id: " + aViewId); return; } if (aPlacementArea == CustomizableUI.AREA_PANEL) { this.multiView.showSubView(aViewId, aAnchor); } else if (!aAnchor.open) { aAnchor.open = true; let tempPanel = document.createElement("panel"); tempPanel.setAttribute("type", "arrow"); tempPanel.setAttribute("id", "customizationui-widget-panel"); tempPanel.setAttribute("class", "cui-widget-panel"); tempPanel.setAttribute("viewId", aViewId); if (aAnchor.getAttribute("tabspecific")) { tempPanel.setAttribute("tabspecific", true); } if (this._disableAnimations) { tempPanel.setAttribute("animate", "false"); } tempPanel.setAttribute("context", ""); document.getElementById(CustomizableUI.AREA_NAVBAR).appendChild(tempPanel); // If the view has a footer, set a convenience class on the panel. tempPanel.classList.toggle("cui-widget-panelWithFooter", viewNode.querySelector(".panel-subview-footer")); let multiView = document.createElement("panelmultiview"); multiView.setAttribute("id", "customizationui-widget-multiview"); multiView.setAttribute("nosubviews", "true"); tempPanel.appendChild(multiView); multiView.setAttribute("mainViewIsSubView", "true"); multiView.setMainView(viewNode); viewNode.classList.add("cui-widget-panelview"); let viewShown = false; let panelRemover = () => { viewNode.classList.remove("cui-widget-panelview"); if (viewShown) { CustomizableUI.removePanelCloseListeners(tempPanel); tempPanel.removeEventListener("popuphidden", panelRemover); let evt = new CustomEvent("ViewHiding", {detail: viewNode}); viewNode.dispatchEvent(evt); } aAnchor.open = false; this.multiView.appendChild(viewNode); tempPanel.remove(); }; // Emit the ViewShowing event so that the widget definition has a chance // to lazily populate the subview with things. let detail = { blockers: new Set(), addBlocker(aPromise) { this.blockers.add(aPromise); }, }; let evt = new CustomEvent("ViewShowing", { bubbles: true, cancelable: true, detail }); viewNode.dispatchEvent(evt); let cancel = evt.defaultPrevented; if (detail.blockers.size) { try { let results = yield Promise.all(detail.blockers); cancel = cancel || results.some(val => val === false); } catch (e) { Components.utils.reportError(e); cancel = true; } } if (cancel) { panelRemover(); return; } viewShown = true; CustomizableUI.addPanelCloseListeners(tempPanel); tempPanel.addEventListener("popuphidden", panelRemover); let iconAnchor = document.getAnonymousElementByAttribute(aAnchor, "class", "toolbarbutton-icon"); if (iconAnchor && aAnchor.id) { iconAnchor.setAttribute("consumeanchor", aAnchor.id); } tempPanel.openPopup(iconAnchor || aAnchor, "bottomcenter topright"); } }), /** * NB: The enable- and disableSingleSubviewPanelAnimations methods only * affect the hiding/showing animations of single-subview panels (tempPanel * in the showSubView method). */ disableSingleSubviewPanelAnimations: function() { this._disableAnimations = true; }, enableSingleSubviewPanelAnimations: function() { this._disableAnimations = false; }, onWidgetAfterDOMChange: function(aNode, aNextNode, aContainer, aWasRemoval) { if (aContainer != this.contents) { return; } if (aWasRemoval) { aNode.removeAttribute("auto-hyphens"); } }, onWidgetBeforeDOMChange: function(aNode, aNextNode, aContainer, aIsRemoval) { if (aContainer != this.contents) { return; } if (!aIsRemoval && (this.panel.state == "open" || document.documentElement.hasAttribute("customizing"))) { this._adjustLabelsForAutoHyphens(aNode); } }, /** * Signal that we're about to make a lot of changes to the contents of the * panels all at once. For performance, we ignore the mutations. */ beginBatchUpdate: function() { this._ensureEventListenersAdded(); this.multiView.ignoreMutations = true; }, /** * Signal that we're done making bulk changes to the panel. We now pay * attention to mutations. This automatically synchronizes the multiview * container with whichever view is displayed if the panel is open. */ endBatchUpdate: function(aReason) { this._ensureEventListenersAdded(); this.multiView.ignoreMutations = false; }, _adjustLabelsForAutoHyphens: function(aNode) { let toolbarButtons = aNode ? [aNode] : this.contents.querySelectorAll(".toolbarbutton-1"); for (let node of toolbarButtons) { let label = node.getAttribute("label"); if (!label) { continue; } if (label.includes("\u00ad")) { node.setAttribute("auto-hyphens", "off"); } else { node.removeAttribute("auto-hyphens"); } } }, /** * Sets the anchor node into the open or closed state, depending * on the state of the panel. */ _updatePanelButton: function() { this.menuButton.open = this.panel.state == "open" || this.panel.state == "showing"; }, _onHelpViewShow: function(aEvent) { // Call global menu setup function buildHelpMenu(); let helpMenu = document.getElementById("menu_HelpPopup"); let items = this.getElementsByTagName("vbox")[0]; let attrs = ["oncommand", "onclick", "label", "key", "disabled"]; let NSXUL = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; // Remove all buttons from the view while (items.firstChild) { items.removeChild(items.firstChild); } // Add the current set of menuitems of the Help menu to this view let menuItems = Array.prototype.slice.call(helpMenu.getElementsByTagName("menuitem")); let fragment = document.createDocumentFragment(); for (let node of menuItems) { if (node.hidden) continue; let button = document.createElementNS(NSXUL, "toolbarbutton"); // Copy specific attributes from a menuitem of the Help menu for (let attrName of attrs) { if (!node.hasAttribute(attrName)) continue; button.setAttribute(attrName, node.getAttribute(attrName)); } button.setAttribute("class", "subviewbutton"); fragment.appendChild(button); } items.appendChild(fragment); }, _updateQuitTooltip: function() { if (AppConstants.platform == "win") { return; } let tooltipId = AppConstants.platform == "macosx" ? "quit-button.tooltiptext.mac" : "quit-button.tooltiptext.linux2"; let brands = Services.strings.createBundle("chrome://branding/locale/brand.properties"); let stringArgs = [brands.GetStringFromName("brandShortName")]; let key = document.getElementById("key_quitApplication"); stringArgs.push(ShortcutUtils.prettifyShortcut(key)); let tooltipString = CustomizableUI.getLocalizedProperty({x: tooltipId}, "x", stringArgs); let quitButton = document.getElementById("PanelUI-quit"); quitButton.setAttribute("tooltiptext", tooltipString); }, _overlayScrollListenerBoundFn: null, _overlayScrollListener: function(aMQL) { ScrollbarSampler.resetSystemScrollbarWidth(); this._scrollWidth = null; }, }; XPCOMUtils.defineConstant(this, "PanelUI", PanelUI); /** * Gets the currently selected locale for display. * @return the selected locale or "en-US" if none is selected */ function getLocale() { try { let chromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"] .getService(Ci.nsIXULChromeRegistry); return chromeRegistry.getSelectedLocale("browser"); } catch (ex) { return "en-US"; } }