diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/mail/components/customizableui | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/mail/components/customizableui')
8 files changed, 3702 insertions, 0 deletions
diff --git a/comm/mail/components/customizableui/CustomizableUI.sys.mjs b/comm/mail/components/customizableui/CustomizableUI.sys.mjs new file mode 100644 index 0000000000..2628bd6109 --- /dev/null +++ b/comm/mail/components/customizableui/CustomizableUI.sys.mjs @@ -0,0 +1,360 @@ +/* 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/. */ + +// This file is a copy of a file with the same name in Firefox. Only the +// pieces we're using, and a few pieces the devtools rely on such as the +// constants, remain. + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs", +}); + +/** + * gPanelsForWindow is a list of known panels in a window which we may need to close + * should command events fire which target them. + */ +var gPanelsForWindow = new WeakMap(); + +var CustomizableUIInternal = { + addPanelCloseListeners(aPanel) { + Services.els.addSystemEventListener(aPanel, "click", this, false); + Services.els.addSystemEventListener(aPanel, "keypress", this, false); + let win = aPanel.ownerGlobal; + if (!gPanelsForWindow.has(win)) { + gPanelsForWindow.set(win, new Set()); + } + gPanelsForWindow.get(win).add(this._getPanelForNode(aPanel)); + }, + + removePanelCloseListeners(aPanel) { + Services.els.removeSystemEventListener(aPanel, "click", this, false); + Services.els.removeSystemEventListener(aPanel, "keypress", this, false); + let win = aPanel.ownerGlobal; + let panels = gPanelsForWindow.get(win); + if (panels) { + panels.delete(this._getPanelForNode(aPanel)); + } + }, + + handleEvent(aEvent) { + switch (aEvent.type) { + case "click": + case "keypress": + this.maybeAutoHidePanel(aEvent); + break; + } + }, + + _getPanelForNode(aNode) { + return aNode.closest("panel"); + }, + + /* + * If people put things in the panel which need more than single-click interaction, + * we don't want to close it. Right now we check for text inputs and menu buttons. + * We also check for being outside of any toolbaritem/toolbarbutton, ie on a blank + * part of the menu. + */ + _isOnInteractiveElement(aEvent) { + function getMenuPopupForDescendant(aNode) { + let lastPopup = null; + while ( + aNode && + aNode.parentNode && + aNode.parentNode.localName.startsWith("menu") + ) { + lastPopup = aNode.localName == "menupopup" ? aNode : lastPopup; + aNode = aNode.parentNode; + } + return lastPopup; + } + + let target = aEvent.target; + let panel = this._getPanelForNode(aEvent.currentTarget); + // This can happen in e.g. customize mode. If there's no panel, + // there's clearly nothing for us to close; pretend we're interactive. + if (!panel) { + return true; + } + // We keep track of: + // whether we're in an input container (text field) + let inInput = false; + // whether we're in a popup/context menu + let inMenu = false; + // whether we're in a toolbarbutton/toolbaritem + let inItem = false; + // whether the current menuitem has a valid closemenu attribute + let menuitemCloseMenu = "auto"; + + // While keeping track of that, we go from the original target back up, + // to the panel if we have to. We bail as soon as we find an input, + // a toolbarbutton/item, or the panel: + while (target) { + // Skip out of iframes etc: + if (target.nodeType == target.DOCUMENT_NODE) { + if (!target.defaultView) { + // Err, we're done. + break; + } + // Find containing browser or iframe element in the parent doc. + target = target.defaultView.docShell.chromeEventHandler; + if (!target) { + break; + } + } + let tagName = target.localName; + inInput = tagName == "input"; + inItem = tagName == "toolbaritem" || tagName == "toolbarbutton"; + let isMenuItem = tagName == "menuitem"; + inMenu = inMenu || isMenuItem; + + if (isMenuItem && target.hasAttribute("closemenu")) { + let closemenuVal = target.getAttribute("closemenu"); + menuitemCloseMenu = + closemenuVal == "single" || closemenuVal == "none" + ? closemenuVal + : "auto"; + } + + // Keep the menu open and break out of the loop if the click happened on + // the ShadowRoot or a disabled menu item. + if ( + target.nodeType == target.DOCUMENT_FRAGMENT_NODE || + target.getAttribute("disabled") == "true" + ) { + return true; + } + + // This isn't in the loop condition because we want to break before + // changing |target| if any of these conditions are true + if (inInput || inItem || target == panel) { + break; + } + // We need specific code for popups: the item on which they were invoked + // isn't necessarily in their parentNode chain: + if (isMenuItem) { + let topmostMenuPopup = getMenuPopupForDescendant(target); + target = + (topmostMenuPopup && topmostMenuPopup.triggerNode) || + target.parentNode; + } else { + target = target.parentNode; + } + } + + // If the user clicked a menu item... + if (inMenu) { + // We care if we're in an input also, + // or if the user specified closemenu!="auto": + if (inInput || menuitemCloseMenu != "auto") { + return true; + } + // Otherwise, we're probably fine to close the panel + return false; + } + // If we're not in a menu, and we *are* in a type="menu" toolbarbutton, + // we'll now interact with the menu + if (inItem && target.getAttribute("type") == "menu") { + return true; + } + return inInput || !inItem; + }, + + hidePanelForNode(aNode) { + let panel = this._getPanelForNode(aNode); + if (panel) { + lazy.PanelMultiView.hidePopup(panel); + } + }, + + maybeAutoHidePanel(aEvent) { + let eventType = aEvent.type; + if (eventType == "keypress" && aEvent.keyCode != aEvent.DOM_VK_RETURN) { + return; + } + + if (eventType == "click" && aEvent.button != 0) { + return; + } + + // We don't check preventDefault - it makes sense that this was prevented, + // but we probably still want to close the panel. If consumers don't want + // this to happen, they should specify the closemenu attribute. + if (eventType != "command" && this._isOnInteractiveElement(aEvent)) { + return; + } + + // We can't use event.target because we might have passed an anonymous + // content boundary as well, and so target points to the outer element in + // that case. Unfortunately, this means we get anonymous child nodes instead + // of the real ones, so looking for the 'stoooop, don't close me' attributes + // is more involved. + let target = aEvent.originalTarget; + while (target.parentNode && target.localName != "panel") { + if ( + target.getAttribute("closemenu") == "none" || + target.getAttribute("widget-type") == "view" || + target.getAttribute("widget-type") == "button-and-view" + ) { + return; + } + target = target.parentNode; + } + + // If we get here, we can actually hide the popup: + this.hidePanelForNode(aEvent.target); + }, +}; +Object.freeze(CustomizableUIInternal); + +export var CustomizableUI = { + /** + * Constant reference to the ID of the navigation toolbar. + */ + AREA_NAVBAR: "nav-bar", + /** + * Constant reference to the ID of the menubar's toolbar. + */ + AREA_MENUBAR: "toolbar-menubar", + /** + * Constant reference to the ID of the tabstrip toolbar. + */ + AREA_TABSTRIP: "TabsToolbar", + /** + * Constant reference to the ID of the bookmarks toolbar. + */ + AREA_BOOKMARKS: "PersonalToolbar", + /** + * Constant reference to the ID of the non-dymanic (fixed) list in the overflow panel. + */ + AREA_FIXED_OVERFLOW_PANEL: "widget-overflow-fixed-list", + + /** + * Constant indicating the area is a menu panel. + */ + TYPE_MENU_PANEL: "menu-panel", + /** + * Constant indicating the area is a toolbar. + */ + TYPE_TOOLBAR: "toolbar", + + /** + * Constant indicating a XUL-type provider. + */ + PROVIDER_XUL: "xul", + /** + * Constant indicating an API-type provider. + */ + PROVIDER_API: "api", + /** + * Constant indicating dynamic (special) widgets: spring, spacer, and separator. + */ + PROVIDER_SPECIAL: "special", + + /** + * Constant indicating the widget is built-in + */ + SOURCE_BUILTIN: "builtin", + /** + * Constant indicating the widget is externally provided + * (e.g. by add-ons or other items not part of the builtin widget set). + */ + SOURCE_EXTERNAL: "external", + + /** + * Constant indicating the reason the event was fired was a window closing + */ + REASON_WINDOW_CLOSED: "window-closed", + /** + * Constant indicating the reason the event was fired was an area being + * unregistered separately from window closing mechanics. + */ + REASON_AREA_UNREGISTERED: "area-unregistered", + + /** + * Add a widget to an area. + * If the area to which you try to add is not known to CustomizableUI, + * this will throw. + * If the area to which you try to add is the same as the area in which + * the widget is currently placed, this will do the same as + * moveWidgetWithinArea. + * If the widget cannot be removed from its original location, this will + * no-op. + * + * This will fire an onWidgetAdded notification, + * and an onWidgetBeforeDOMChange and onWidgetAfterDOMChange notification + * for each window CustomizableUI knows about. + * + * @param aWidgetId the ID of the widget to add + * @param aArea the ID of the area to add the widget to + * @param aPosition the position at which to add the widget. If you do not + * pass a position, the widget will be added to the end + * of the area. + */ + addWidgetToArea(aWidgetId, aArea, aPosition) {}, + /** + * Remove a widget from its area. If the widget cannot be removed from its + * area, or is not in any area, this will no-op. Otherwise, this will fire an + * onWidgetRemoved notification, and an onWidgetBeforeDOMChange and + * onWidgetAfterDOMChange notification for each window CustomizableUI knows + * about. + * + * @param aWidgetId the ID of the widget to remove + */ + removeWidgetFromArea(aWidgetId) {}, + /** + * Get the placement of a widget. This is by far the best way to obtain + * information about what the state of your widget is. The internals of + * this call are cheap (no DOM necessary) and you will know where the user + * has put your widget. + * + * @param aWidgetId the ID of the widget whose placement you want to know + * @returns + * { + * area: "somearea", // The ID of the area where the widget is placed + * position: 42 // the index in the placements array corresponding to + * // your widget. + * } + * + * OR + * + * null // if the widget is not placed anywhere (ie in the palette) + */ + getPlacementOfWidget(aWidgetId, aOnlyRegistered = true, aDeadAreas = false) { + return null; + }, + /** + * Add listeners to a panel that will close it. For use from the menu panel + * and overflowable toolbar implementations, unlikely to be useful for + * consumers. + * + * @param aPanel the panel to which listeners should be attached. + */ + addPanelCloseListeners(aPanel) { + CustomizableUIInternal.addPanelCloseListeners(aPanel); + }, + /** + * Remove close listeners that have been added to a panel with + * addPanelCloseListeners. For use from the menu panel and overflowable + * toolbar implementations, unlikely to be useful for consumers. + * + * @param aPanel the panel from which listeners should be removed. + */ + removePanelCloseListeners(aPanel) { + CustomizableUIInternal.removePanelCloseListeners(aPanel); + }, + /** + * Notify toolbox(es) of a particular event. If you don't pass aWindow, + * all toolboxes will be notified. For use from Customize Mode only, + * do not use otherwise. + * + * @param aEvent the name of the event to send. + * @param aDetails optional, the details of the event. + * @param aWindow optional, the window in which to send the event. + */ + dispatchToolboxEvent(aEvent, aDetails = {}, aWindow = null) {}, +}; +Object.freeze(CustomizableUI); diff --git a/comm/mail/components/customizableui/PanelMultiView.sys.mjs b/comm/mail/components/customizableui/PanelMultiView.sys.mjs new file mode 100644 index 0000000000..c68f88c586 --- /dev/null +++ b/comm/mail/components/customizableui/PanelMultiView.sys.mjs @@ -0,0 +1,1699 @@ +/* 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 <panel> element should contain a <panelmultiview> element. Views are + * declared using <panelview> elements that are usually children of the main + * <panelmultiview> 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 <panelmultiview> 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. + * + * The process of displaying the main view or a new subview requires multiple + * steps to be completed, hence at any given time the <panelview> element may + * be in different states: + * + * -- Open or closed + * + * All the <panelview> elements start "closed", meaning that they are not + * associated to a <panelmultiview> element and can be located anywhere in + * the document. When the openPopup or showSubView methods are called, the + * relevant view becomes "open" and the <panelview> element may be moved to + * ensure it is a descendant of the <panelmultiview> element. + * + * The "ViewShowing" event is fired at this point, when the view is not + * visible yet. The event is allowed to cancel the operation, in which case + * the view is closed immediately. + * + * Closing the view does not move the node back to its original position. + * + * -- Visible or invisible + * + * This indicates whether the view is visible in the document from a layout + * perspective, regardless of whether it is currently scrolled into view. In + * fact, all subviews are already visible before they start sliding in. + * + * Before scrolling into view, a view may become visible but be placed in a + * special off-screen area of the document where layout and measurements can + * take place asynchronously. + * + * When navigating forward, an open view may become invisible but stay open + * after sliding out of view. The last known size of these views is still + * taken into account for determining the overall panel size. + * + * When navigating backwards, an open subview will first become invisible and + * then will be closed. + * + * -- Active or inactive + * + * This indicates whether the view is fully scrolled into the visible area + * and ready to receive mouse and keyboard events. An active view is always + * visible, but a visible view may be inactive. For example, during a scroll + * transition, both views will be inactive. + * + * When a view becomes active, the ViewShown event is fired synchronously, + * and the showSubView and goBack methods can be called for navigation. + * + * For the main view of the panel, the ViewShown event is dispatched during + * the "popupshown" event, which means that other "popupshown" handlers may + * be called before the view is active. Thus, code that needs to perform + * further navigation automatically should either use the ViewShown event or + * wait for an event loop tick, like BrowserTestUtils.waitForEvent does. + * + * -- Navigating with the keyboard + * + * An open view may keep state related to keyboard navigation, even if it is + * invisible. When a view is closed, keyboard navigation state is cleared. + * + * This diagram shows how <panelview> nodes move during navigation: + * + * In this <panelmultiview> 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 + * │ │ │ + * └───┴───┴── Open views + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "gBundle", function () { + return Services.strings.createBundle( + "chrome://messenger/locale/messenger.properties" + ); +}); + +/** + * Safety timeout after which asynchronous events will be canceled if any of the + * registered blockers does not return. + */ +const BLOCKERS_TIMEOUT_MS = 10000; + +const TRANSITION_PHASES = Object.freeze({ + START: 1, + PREPARE: 2, + TRANSITION: 3, +}); + +let gNodeToObjectMap = new WeakMap(); +let gWindowsWithUnloadHandler = new WeakSet(); + +/** + * 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. + */ +var AssociatedToNode = class { + constructor(node) { + /** + * Node associated to this object. + */ + this.node = node; + + /** + * This promise is resolved when the current set of blockers set by event + * handlers have all been processed. + */ + this._blockersPromise = Promise.resolve(); + } + + /** + * 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; + } + + _getBoundsWithoutFlushing(element) { + return this.window.windowUtils.getBoundsWithoutFlushing(element); + } + + /** + * 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. + * @returns {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; + } + + /** + * Dispatches a custom event on this element and waits for any blocking + * promises registered using the "addBlocker" function on the details object. + * If this function is called again, the event is only dispatched after all + * the previously registered blockers have returned. + * + * The event can be canceled either by resolving any blocking promise to the + * boolean value "false" or by calling preventDefault on the event. Rejections + * and exceptions will be reported and will cancel the event. + * + * Blocking should be used sporadically because it slows down the interface. + * Also, non-reentrancy is not strictly guaranteed because a safety timeout of + * BLOCKERS_TIMEOUT_MS is implemented, after which the event will be canceled. + * This helps to prevent deadlocks if any of the event handlers does not + * resolve a blocker promise. + * + * @note Since there is no use case for dispatching different asynchronous + * events in parallel for the same element, this function will also wait + * for previous blockers when the event name is different. + * + * @param eventName + * Name of the custom event to dispatch. + * + * @resolves True if the event was canceled by a handler, false otherwise. + */ + async dispatchAsyncEvent(eventName) { + // Wait for all the previous blockers before dispatching the event. + let blockersPromise = this._blockersPromise.catch(() => {}); + return (this._blockersPromise = blockersPromise.then(async () => { + let blockers = new Set(); + let cancel = this.dispatchCustomEvent( + eventName, + { + addBlocker(promise) { + // Any exception in the blocker will cancel the operation. + blockers.add( + promise.catch(ex => { + console.error(ex); + return true; + }) + ); + }, + }, + true + ); + if (blockers.size) { + let timeoutPromise = new Promise((resolve, reject) => { + this.window.setTimeout(reject, BLOCKERS_TIMEOUT_MS); + }); + try { + let results = await Promise.race([ + Promise.all(blockers), + timeoutPromise, + ]); + cancel = cancel || results.some(result => result === false); + } catch (ex) { + console.error( + new Error(`One of the blockers for ${eventName} timed out.`) + ); + return true; + } + } + return cancel; + })); + } +}; + +/** + * This is associated to <panelmultiview> elements. + */ +export class PanelMultiView extends AssociatedToNode { + /** + * Tries to open the specified <panel> and displays the main view specified + * with the "mainViewId" attribute on the <panelmultiview> node it contains. + * + * If the panel does not contain a <panelmultiview>, 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 <panel> which contains a <panelmultiview> node. + * + * If the panel does not contain a <panelmultiview>, 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(); + } + } + + /** + * Removes the specified <panel> from the document, ensuring that any + * <panelmultiview> node it contains is destroyed properly. + * + * If the viewCacheId attribute is present on the <panelmultiview> element, + * imported subviews will be moved out again to the element it specifies, so + * that the panel element can be removed safely. + * + * If the panel does not contain a <panelmultiview>, it is removed directly. + * This allows consumers like page actions to accept different panel types. + */ + static removePopup(panelNode) { + try { + let panelMultiViewNode = panelNode.querySelector("panelmultiview"); + if (panelMultiViewNode) { + let panelMultiView = this.forNode(panelMultiViewNode); + panelMultiView._moveOutKids(); + panelMultiView.disconnect(); + } + } finally { + // Make sure to remove the panel element even if disconnecting fails. + panelNode.remove(); + } + } + + /** + * Ensures that when the specified window is closed all the <panelmultiview> + * node it contains are destroyed properly. + */ + static ensureUnloadHandlerRegistered(window) { + if (gWindowsWithUnloadHandler.has(window)) { + return; + } + + window.addEventListener( + "unload", + () => { + for (let panelMultiViewNode of window.document.querySelectorAll( + "panelmultiview" + )) { + this.forNode(panelMultiViewNode).disconnect(); + } + }, + { once: true } + ); + + gWindowsWithUnloadHandler.add(window); + } + + get _panel() { + return this.node.parentNode; + } + + set _transitioning(val) { + if (val) { + this.node.setAttribute("transitioning", "true"); + } else { + this.node.removeAttribute("transitioning"); + } + } + + get _screenManager() { + if (this.__screenManager) { + return this.__screenManager; + } + return (this.__screenManager = Cc[ + "@mozilla.org/gfx/screenmanager;1" + ].getService(Ci.nsIScreenManager)); + } + + constructor(node) { + super(node); + this._openPopupPromise = Promise.resolve(false); + this._openPopupCancelCallback = () => {}; + } + + connect() { + this.connected = true; + + PanelMultiView.ensureUnloadHandlerRegistered(this.window); + + let viewContainer = (this._viewContainer = + this.document.createXULElement("box")); + viewContainer.classList.add("panel-viewcontainer"); + + let viewStack = (this._viewStack = this.document.createXULElement("box")); + viewStack.classList.add("panel-viewstack"); + viewContainer.append(viewStack); + + let offscreenViewContainer = this.document.createXULElement("box"); + offscreenViewContainer.classList.add("panel-viewcontainer", "offscreen"); + + let offscreenViewStack = (this._offscreenViewStack = + this.document.createXULElement("box")); + offscreenViewStack.classList.add("panel-viewstack"); + offscreenViewContainer.append(offscreenViewStack); + + this.node.prepend(offscreenViewContainer); + this.node.prepend(viewContainer); + + this.openViews = []; + + this._panel.addEventListener("popupshowing", this); + this._panel.addEventListener("popuppositioned", this); + this._panel.addEventListener("popuphidden", this); + this._panel.addEventListener("popupshown", this); + + // Proxy these public properties and methods, as used elsewhere by various + // parts of the browser, to this instance. + ["goBack", "showSubView"].forEach(method => { + Object.defineProperty(this.node, method, { + enumerable: true, + value: (...args) => this[method](...args), + }); + }); + } + + disconnect() { + // Guard against re-entrancy. + if (!this.node || !this.connected) { + return; + } + + 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, true); + this.node = + this._openPopupPromise = + this._openPopupCancelCallback = + this._viewContainer = + this._viewStack = + 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 <panelmultiview> 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 anchor + * The node to anchor the popup to. + * @param options + * Either options to use or a string position. This is forwarded to + * the openPopup method of the panel. + * @param args + * Additional 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(anchor, options, ...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 { + if (!this.connected) { + this.connect(); + } + // 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(anchor, options, ...args); + + // On Windows, if another popup is hiding while we call openPopup, the + // call won't fail but the popup won't open. In this case, we have to + // dispatch an artificial "popuphidden" event to reset our state. + if (this._panel.state == "closed" && this.openViews.length) { + this.dispatchCustomEvent("popuphidden"); + return false; + } + + if ( + options && + typeof options == "object" && + options.triggerEvent && + options.triggerEvent.type == "keypress" && + this.openViews.length + ) { + // This was opened via the keyboard, so focus the first item. + this.openViews[0].focusWhenActive = true; + } + + 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 || !this.connected) { + 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(); + } + + // We close all the views synchronously, so that they are ready to be opened + // in other PanelMultiView instances. The "popuphidden" handler may also + // call this function, but the second time openViews will be empty. + this.closeAllViews(); + } + + /** + * Move any child subviews into the element defined by "viewCacheId" to make + * sure they will not be removed together with the <panelmultiview> element. + */ + _moveOutKids() { + let viewCacheId = this.node.getAttribute("viewCacheId"); + if (!viewCacheId) { + return; + } + + // Node.children and Node.children is live to DOM changes like the + // ones we're about to do, so iterate over a static copy: + let subviews = Array.from(this._viewStack.children); + let viewCache = this.document.getElementById(viewCacheId); + for (let subview of subviews) { + viewCache.appendChild(subview); + } + } + + /** + * Slides in the specified view as a subview. + * + * @param viewIdOrNode + * DOM element or string ID of the <panelview> to display. + * @param anchor + * DOM element that triggered the subview, which will be highlighted + * and whose "label" attribute will be used for the title of the + * subview when a "title" attribute is not specified. + */ + showSubView(viewIdOrNode, anchor) { + this._showSubView(viewIdOrNode, anchor).catch(console.error); + } + async _showSubView(viewIdOrNode, anchor) { + let viewNode = + typeof viewIdOrNode == "string" + ? this.document.getElementById(viewIdOrNode) + : viewIdOrNode; + if (!viewNode) { + console.error(new Error(`Subview ${viewIdOrNode} doesn't exist.`)); + return; + } + + if (!this.openViews.length) { + console.error(new Error(`Cannot show a subview in a closed panel.`)); + return; + } + + let prevPanelView = this.openViews[this.openViews.length - 1]; + let nextPanelView = PanelView.forNode(viewNode); + if (this.openViews.includes(nextPanelView)) { + console.error(new Error(`Subview ${viewNode.id} is already open.`)); + return; + } + + // Do not re-enter the process if navigation is already in progress. Since + // there is only one active view at any given time, we can do this check + // safely, even considering that during the navigation process the actual + // view to which prevPanelView refers will change. + if (!prevPanelView.active) { + return; + } + // If prevPanelView._doingKeyboardActivation is true, it will be reset to + // false synchronously. Therefore, we must capture it before we use any + // "await" statements. + let doingKeyboardActivation = prevPanelView._doingKeyboardActivation; + // Marking the view that is about to scrolled out of the visible area as + // inactive will prevent re-entrancy and also disable keyboard navigation. + // From this point onwards, "await" statements can be used safely. + prevPanelView.active = false; + + // Provide visual feedback while navigation is in progress, starting before + // the transition starts and ending when the previous view is invisible. + if (anchor) { + anchor.setAttribute("open", "true"); + } + try { + // If the ViewShowing event cancels the operation we have to re-enable + // keyboard navigation, but this must be avoided if the panel was closed. + if (!(await this._openView(nextPanelView))) { + if (prevPanelView.isOpenIn(this)) { + // We don't raise a ViewShown event because nothing actually changed. + // Technically we should use a different state flag just because there + // is code that could check the "active" property to determine whether + // to wait for a ViewShown event later, but this only happens in + // regression tests and is less likely to be a technique used in + // production code, where use of ViewShown is less common. + prevPanelView.active = true; + } + return; + } + + prevPanelView.captureKnownSize(); + + // The main view of a panel can be a subview in another one. Make sure to + // reset all the properties that may be set on a subview. + nextPanelView.mainview = false; + // The header may change based on how the subview was opened. + nextPanelView.headerText = + viewNode.getAttribute("title") || + (anchor && anchor.getAttribute("label")); + // The constrained width of subviews may also vary between panels. + nextPanelView.minMaxWidth = prevPanelView.knownWidth; + + if (anchor) { + viewNode.classList.add("PanelUI-subView"); + } + + await this._transitionViews(prevPanelView.node, viewNode, false, anchor); + } finally { + if (anchor) { + anchor.removeAttribute("open"); + } + } + + nextPanelView.focusWhenActive = doingKeyboardActivation; + this._activateView(nextPanelView); + } + + /** + * Navigates backwards by sliding out the most recent subview. + */ + goBack() { + this._goBack().catch(console.error); + } + async _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 prevPanelView = this.openViews[this.openViews.length - 1]; + let nextPanelView = this.openViews[this.openViews.length - 2]; + + // Like in the showSubView method, do not re-enter navigation while it is + // in progress, and make the view inactive immediately. From this point + // onwards, "await" statements can be used safely. + if (!prevPanelView.active) { + return; + } + + prevPanelView.active = false; + + prevPanelView.captureKnownSize(); + + await this._transitionViews(prevPanelView.node, nextPanelView.node, true); + + this._closeLatestView(); + + this._activateView(nextPanelView); + } + + /** + * Prepares the main view before showing the panel. + */ + async _showMainView() { + let nextPanelView = PanelView.forNode( + this.document.getElementById(this.node.getAttribute("mainViewId")) + ); + + // If the view is already open in another panel, close the panel first. + let oldPanelMultiViewNode = nextPanelView.node.panelMultiView; + if (oldPanelMultiViewNode) { + PanelMultiView.forNode(oldPanelMultiViewNode).hidePopup(); + // Wait for a layout flush after hiding the popup, otherwise the view may + // not be displayed correctly for some time after the new panel is opened. + // This is filed as bug 1441015. + await this.window.promiseDocumentFlushed(() => {}); + } + + if (!(await this._openView(nextPanelView))) { + return false; + } + + // The main view of a panel can be a subview in another one. Make sure to + // reset all the properties that may be set on a subview. + nextPanelView.mainview = true; + nextPanelView.headerText = ""; + nextPanelView.minMaxWidth = 0; + + // Ensure the view will be visible once the panel is opened. + nextPanelView.visible = true; + + return true; + } + + /** + * Opens the specified PanelView and dispatches the ViewShowing event, which + * can be used to populate the subview or cancel the operation. + * + * This also clears all the attributes and styles that may be left by a + * transition that was interrupted. + * + * @resolves With true if the view was opened, false otherwise. + */ + async _openView(panelView) { + if (panelView.node.parentNode != this._viewStack) { + this._viewStack.appendChild(panelView.node); + } + + panelView.node.panelMultiView = this.node; + this.openViews.push(panelView); + + let canceled = await panelView.dispatchAsyncEvent("ViewShowing"); + + // The panel can be hidden while we are processing the ViewShowing event. + // This results in all the views being closed synchronously, and at this + // point the ViewHiding event has already been dispatched for all of them. + if (!this.openViews.length) { + return false; + } + + // Check if the event requested cancellation but the panel is still open. + if (canceled) { + // Handlers for ViewShowing can't know if a different handler requested + // cancellation, so this will dispatch a ViewHiding event to give a chance + // to clean up. + this._closeLatestView(); + return false; + } + + // Clean up all the attributes and styles related to transitions. We do this + // here rather than when the view is closed because we are likely to make + // other DOM modifications soon, which isn't the case when closing. + let { style } = panelView.node; + style.removeProperty("outline"); + style.removeProperty("width"); + + return true; + } + + /** + * Activates the specified view and raises the ViewShown event, unless the + * view was closed in the meantime. + */ + _activateView(panelView) { + if (panelView.isOpenIn(this)) { + panelView.active = true; + if (panelView.focusWhenActive) { + panelView.focusFirstNavigableElement(false, true); + panelView.focusWhenActive = false; + } + panelView.dispatchCustomEvent("ViewShown"); + } + } + + /** + * Closes the most recent PanelView and raises the ViewHiding event. + * + * @note The ViewHiding event is not cancelable and should probably be renamed + * to ViewHidden or ViewClosed instead, see bug 1438507. + */ + _closeLatestView() { + let panelView = this.openViews.pop(); + panelView.clearNavigation(); + panelView.dispatchCustomEvent("ViewHiding"); + panelView.node.panelMultiView = null; + // Views become invisible synchronously when they are closed, and they won't + // become visible again until they are opened. When this is called at the + // end of backwards navigation, the view is already invisible. + panelView.visible = false; + } + + /** + * Closes all the views that are currently open. + */ + closeAllViews() { + // Raise ViewHiding events for open views in reverse order. + while (this.openViews.length) { + this._closeLatestView(); + } + } + + /** + * 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 displayed, but + * is about to be transitioned away. This + * must be already inactive at this point. + * @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. + */ + async _transitionViews(previousViewNode, viewNode, reverse) { + const { window } = this; + + let nextPanelView = PanelView.forNode(viewNode); + let prevPanelView = PanelView.forNode(previousViewNode); + + let details = (this._transitionDetails = { + phase: TRANSITION_PHASES.START, + }); + + // 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._getBoundsWithoutFlushing(this._panel); + this._panel.style.width = rect.width + "px"; + this._panel.style.height = rect.height + "px"; + + 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, + }; + nextPanelView.visible = true; + } else if (viewNode.customRectGetter) { + // We use a customRectGetter for WebExtensions panels, because they need + // to query the size from an embedded browser. The presence of this + // getter also provides an indication that the view node shouldn't be + // moved around, otherwise the state of the browser would get disrupted. + let width = prevPanelView.knownWidth; + let height = prevPanelView.knownHeight; + viewRect = Object.assign({ height, width }, viewNode.customRectGetter()); + nextPanelView.visible = true; + // Until the header is visible, it has 0 height. + // Wait for layout before measuring it + let header = viewNode.firstElementChild; + if (header && header.classList.contains("panel-header")) { + viewRect.height += await window.promiseDocumentFlushed(() => { + return this._getBoundsWithoutFlushing(header).height; + }); + } + } else { + this._offscreenViewStack.style.minHeight = olderView.knownHeight + "px"; + this._offscreenViewStack.appendChild(viewNode); + nextPanelView.visible = true; + + viewRect = await window.promiseDocumentFlushed(() => { + return this._getBoundsWithoutFlushing(viewNode); + }); + // Bail out if the panel was closed in the meantime. + if (!nextPanelView.isOpenIn(this)) { + return; + } + + // Place back the view after all the other views that are already open in + // order for the transition to work as expected. + 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.window.RTL_UI && !reverse) || (!this.window.RTL_UI && 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 that all the elements are in place for the start of the transition, + // give the layout code a chance to set the initial values. + await window.promiseDocumentFlushed(() => {}); + // Bail out if the panel was closed in the meantime. + if (!nextPanelView.isOpenIn(this)) { + return; + } + + // 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.style.removeProperty("width"); + this._panel.style.removeProperty("height"); + + // We're setting the width property to prevent flickering during the + // sliding animation with smaller views. + viewNode.style.width = viewRect.width + "px"; + + // Kick off the transition! + details.phase = TRANSITION_PHASES.TRANSITION; + + // If we're going to show the main view, we can remove the + // min-height property on the view container. + if (viewNode.getAttribute("mainview")) { + this._viewContainer.style.removeProperty("min-height"); + } + + 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(); + }) + ); + }); + + // Bail out if the panel was closed during the transition. + if (!nextPanelView.isOpenIn(this)) { + return; + } + prevPanelView.visible = false; + + // This will complete the operation by removing any transition properties. + nextPanelView.node.style.removeProperty("width"); + deepestNode.style.removeProperty("outline"); + this._cleanupTransitionPhase(); + + nextPanelView.focusSelectedElement(); + } + + /** + * 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. + */ + _cleanupTransitionPhase() { + if (!this._transitionDetails) { + return; + } + + let { phase, resolve, listener, cancelListener } = this._transitionDetails; + this._transitionDetails = null; + + if (phase >= TRANSITION_PHASES.START) { + this._panel.style.removeProperty("width"); + this._panel.style.removeProperty("height"); + this._viewContainer.style.removeProperty("height"); + this._viewContainer.style.removeProperty("width"); + } + if (phase >= TRANSITION_PHASES.PREPARE) { + this._transitioning = false; + this._viewStack.style.removeProperty("margin-inline-start"); + this._viewStack.style.removeProperty("transition"); + } + if (phase >= TRANSITION_PHASES.TRANSITION) { + this._viewStack.style.removeProperty("transform"); + if (listener) { + this._viewContainer.removeEventListener("transitionend", listener); + } + if (cancelListener) { + this._viewContainer.removeEventListener( + "transitioncancel", + cancelListener + ); + } + if (resolve) { + resolve(); + } + } + } + + _calculateMaxHeight(aEvent) { + // 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 anchor = this._panel.anchorNode; + let anchorRect = anchor.getBoundingClientRect(); + + let screen = this._screenManager.screenForRect( + anchor.screenX, + anchor.screenY, + anchorRect.width, + anchorRect.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 (aEvent.alignmentPosition.startsWith("before_")) { + maxHeight = anchor.screenY - cssAvailTop; + } else { + let anchorScreenBottom = anchor.screenY + anchorRect.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) { + // Only process actual popup events from the panel or events we generate + // ourselves, but not from menus being shown from within the panel. + if ( + aEvent.type.startsWith("popup") && + aEvent.target != this._panel && + aEvent.target != this.node + ) { + return; + } + switch (aEvent.type) { + case "keydown": + // Since we start listening for the "keydown" event when the popup is + // already showing and stop listening when the panel is hidden, we + // always have at least one view open. + let currentView = this.openViews[this.openViews.length - 1]; + currentView.keyNavigation(aEvent); + break; + case "mousemove": + this.openViews.forEach(panelView => panelView.clearNavigation()); + break; + case "popupshowing": { + this._viewContainer.setAttribute("panelopen", "true"); + if (!this.node.hasAttribute("disablekeynav")) { + // We add the keydown handler on the window so that it handles key + // presses when a panel appears but doesn't get focus, as happens + // when a button to open a panel is clicked with the mouse. + // However, this means the listener is on an ancestor of the panel, + // which means that handlers such as ToolbarKeyboardNavigator are + // deeper in the tree. Therefore, this must be a capturing listener + // so we get the event first. + this.window.addEventListener("keydown", this, true); + this._panel.addEventListener("mousemove", this); + } + break; + } + case "popuppositioned": { + if (this._panel.state == "showing") { + let maxHeight = this._calculateMaxHeight(aEvent); + this._viewStack.style.maxHeight = maxHeight + "px"; + this._offscreenViewStack.style.maxHeight = maxHeight + "px"; + } + break; + } + case "popupshown": + // The main view is always open and visible when the panel is first + // shown, so we can check the height of the description elements it + // contains and notify consumers using the ViewShown event. In order to + // minimize flicker we need to allow synchronous reflows, and we still + // make sure the ViewShown event is dispatched synchronously. + let mainPanelView = this.openViews[0]; + this._activateView(mainPanelView); + break; + case "popuphidden": { + // WebExtensions consumers can hide the popup from viewshowing, or + // mid-transition, which disrupts our state: + this._transitioning = false; + this._viewContainer.removeAttribute("panelopen"); + this._cleanupTransitionPhase(); + this.window.removeEventListener("keydown", this, true); + this._panel.removeEventListener("mousemove", this); + this.closeAllViews(); + + // 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 <panelview> elements. + */ +export class PanelView extends AssociatedToNode { + constructor(node) { + super(node); + + /** + * Indicates whether the view is active. When this is false, consumers can + * wait for the ViewShown event to know when the view becomes active. + */ + this.active = false; + + /** + * Specifies whether the view should be focused when active. When this + * is true, the first navigable element in the view will be focused + * when the view becomes active. This should be set to true when the view + * is activated from the keyboard. It will be set to false once the view + * is active. + */ + this.focusWhenActive = false; + } + + /** + * Indicates whether the view is open in the specified PanelMultiView object. + */ + isOpenIn(panelMultiView) { + return this.node.panelMultiView == panelMultiView.node; + } + + /** + * The "mainview" attribute is set before the panel is opened when this view + * is displayed as the main view, and is removed before the <panelview> 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"); + } + } + + /** + * Determines whether the view is visible. Setting this to false also resets + * the "active" property. + */ + set visible(value) { + if (value) { + this.node.setAttribute("visible", true); + } else { + this.node.removeAttribute("visible"); + this.active = false; + this.focusWhenActive = false; + } + } + + /** + * 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.firstElementChild; + if (header && header.classList.contains("panel-header")) { + if (value) { + header.querySelector(".panel-header > h1 > span").textContent = value; + } else { + header.remove(); + } + return; + } + + // The header doesn't exist, only create it if needed. + if (!value) { + return; + } + + header = this.document.createXULElement("box"); + header.classList.add("panel-header"); + + let backButton = this.document.createXULElement("toolbarbutton"); + backButton.className = + "subviewbutton subviewbutton-iconic subviewbutton-back"; + backButton.setAttribute("closemenu", "none"); + backButton.setAttribute("tabindex", "0"); + + backButton.setAttribute( + "aria-label", + lazy.gBundle.GetStringFromName("panel.back") + ); + + backButton.addEventListener("command", () => { + // The panelmultiview element may change if the view is reused. + this.node.panelMultiView.goBack(); + backButton.blur(); + }); + + let h1 = this.document.createElement("h1"); + let span = this.document.createElement("span"); + span.textContent = value; + h1.appendChild(span); + + header.append(backButton, h1); + this.node.prepend(header); + } + + /** + * 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._getBoundsWithoutFlushing(this.node); + this.knownWidth = rect.width; + this.knownHeight = rect.height; + } + + /** + * Determine whether an element can only be navigated to with tab/shift+tab, + * not the arrow keys. + */ + _isNavigableWithTabOnly(element) { + let tag = element.localName; + return ( + tag == "menulist" || + tag == "input" || + tag == "textarea" || + // Allow tab to reach embedded documents in extension panels. + tag == "browser" + ); + } + + /** + * Make a TreeWalker for keyboard navigation. + * + * @param {boolean} arrowKey If `true`, elements only navigable with tab are + * excluded. + */ + _makeNavigableTreeWalker(arrowKey) { + let filter = node => { + if (node.disabled) { + return NodeFilter.FILTER_REJECT; + } + let bounds = this._getBoundsWithoutFlushing(node); + if (bounds.width == 0 || bounds.height == 0) { + return NodeFilter.FILTER_REJECT; + } + if ( + node.tagName == "button" || + node.tagName == "toolbarbutton" || + node.classList.contains("text-link") || + (!arrowKey && this._isNavigableWithTabOnly(node)) + ) { + // Set the tabindex attribute to make sure the node is focusable. + if (!node.hasAttribute("tabindex")) { + node.setAttribute("tabindex", "-1"); + } + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_SKIP; + }; + return this.document.createTreeWalker( + this.node, + NodeFilter.SHOW_ELEMENT, + filter + ); + } + + /** + * Get a TreeWalker which finds elements navigable with tab/shift+tab. + */ + get _tabNavigableWalker() { + if (!this.__tabNavigableWalker) { + this.__tabNavigableWalker = this._makeNavigableTreeWalker(false); + } + return this.__tabNavigableWalker; + } + + /** + * Get a TreeWalker which finds elements navigable with up/down arrow keys. + */ + get _arrowNavigableWalker() { + if (!this.__arrowNavigableWalker) { + this.__arrowNavigableWalker = this._makeNavigableTreeWalker(true); + } + return this.__arrowNavigableWalker; + } + + /** + * Element that is currently selected with the keyboard, or null if no element + * is selected. Since the reference is held weakly, it can become null or + * undefined at any time. + */ + get selectedElement() { + return this._selectedElement && this._selectedElement.get(); + } + set selectedElement(value) { + if (!value) { + delete this._selectedElement; + } else { + this._selectedElement = Cu.getWeakReference(value); + } + } + + /** + * Focuses and moves keyboard selection to the first navigable element. + * This is a no-op if there are no navigable elements. + * + * @param {boolean} homeKey - `true` if this is for the home key. + * @param {boolean} skipBack - `true` if the Back button should be skipped. + */ + focusFirstNavigableElement(homeKey = false, skipBack = false) { + // The home key is conceptually similar to the up/down arrow keys. + let walker = homeKey + ? this._arrowNavigableWalker + : this._tabNavigableWalker; + walker.currentNode = walker.root; + this.selectedElement = walker.firstChild(); + if ( + skipBack && + walker.currentNode && + walker.currentNode.classList.contains("subviewbutton-back") && + walker.nextNode() + ) { + this.selectedElement = walker.currentNode; + } + this.focusSelectedElement(/* byKey */ true); + } + + /** + * Focuses and moves keyboard selection to the last navigable element. + * This is a no-op if there are no navigable elements. + * + * @param {boolean} endKey - `true` if this is for the end key. + */ + focusLastNavigableElement(endKey = false) { + // The end key is conceptually similar to the up/down arrow keys. + let walker = endKey ? this._arrowNavigableWalker : this._tabNavigableWalker; + walker.currentNode = walker.root; + this.selectedElement = walker.lastChild(); + this.focusSelectedElement(/* byKey */ true); + } + + /** + * Based on going up or down, select the previous or next focusable element. + * + * @param {boolean} isDown - whether we're going down (true) or up (false). + * @param {boolean} arrowKey - `true` if this is for the up/down arrow keys. + * + * @returns {DOMNode} the element we selected. + */ + moveSelection(isDown, arrowKey = false) { + let walker = arrowKey + ? this._arrowNavigableWalker + : this._tabNavigableWalker; + let oldSel = this.selectedElement; + let newSel; + if (oldSel) { + walker.currentNode = oldSel; + newSel = isDown ? walker.nextNode() : walker.previousNode(); + } + // If we couldn't find something, select the first or last item: + if (!newSel) { + walker.currentNode = walker.root; + newSel = isDown ? walker.firstChild() : walker.lastChild(); + } + this.selectedElement = newSel; + return newSel; + } + + /** + * 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. + * + * Key navigation is only enabled while the view is active, meaning that this + * method will return early if it is invoked during a sliding transition. + * + * @param {KeyEvent} event + */ + /* eslint-disable-next-line complexity */ + keyNavigation(event) { + if (!this.active) { + return; + } + + let focus = this.document.activeElement; + // Make sure the focus is actually inside the panel. (It might not be if + // the panel was opened with the mouse.) If it isn't, we don't care + // about it for our purposes. + // We use Node.compareDocumentPosition because Node.contains doesn't + // behave as expected for anonymous content; e.g. the input inside a + // textbox. + if ( + focus && + !( + this.node.compareDocumentPosition(focus) & + Node.DOCUMENT_POSITION_CONTAINED_BY + ) + ) { + focus = null; + } + + // Extension panels contain embedded documents. We can't manage + // keyboard navigation within those. + if (focus && focus.tagName == "browser") { + return; + } + + let stop = () => { + event.stopPropagation(); + event.preventDefault(); + }; + + // If the focused element is only navigable with tab, it wants the arrow + // keys, etc. We shouldn't handle any keys except tab and shift+tab. + // We make a function for this for performance reasons: we only want to + // check this for keys we potentially care about, not *all* keys. + let tabOnly = () => { + // We use the real focus rather than this.selectedElement because focus + // might have been moved without keyboard navigation (e.g. mouse click) + // and this.selectedElement is only updated for keyboard navigation. + return focus && this._isNavigableWithTabOnly(focus); + }; + + // If a context menu is open, we must let it handle all keys. + // Normally, this just happens, but because we have a capturing window + // keydown listener, our listener takes precedence. + // Again, we only want to do this check on demand for performance. + let isContextMenuOpen = () => { + if (!focus) { + return false; + } + let contextNode = focus.closest("[context]"); + if (!contextNode) { + return false; + } + let context = contextNode.getAttribute("context"); + let popup = this.document.getElementById(context); + return popup && popup.state == "open"; + }; + + let keyCode = event.code; + switch (keyCode) { + case "ArrowDown": + case "ArrowUp": + if (tabOnly()) { + break; + } + // Fall-through... + case "Tab": { + if (isContextMenuOpen()) { + break; + } + stop(); + let isDown = + keyCode == "ArrowDown" || (keyCode == "Tab" && !event.shiftKey); + let button = this.moveSelection(isDown, keyCode != "Tab"); + Services.focus.setFocus(button, Services.focus.FLAG_BYKEY); + break; + } + case "Home": + if (tabOnly() || isContextMenuOpen()) { + break; + } + stop(); + this.focusFirstNavigableElement(true); + break; + case "End": + if (tabOnly() || isContextMenuOpen()) { + break; + } + stop(); + this.focusLastNavigableElement(true); + break; + case "ArrowLeft": + case "ArrowRight": { + if (tabOnly() || isContextMenuOpen()) { + break; + } + stop(); + if ( + (!this.window.RTL_UI && keyCode == "ArrowLeft") || + (this.window.RTL_UI && keyCode == "ArrowRight") + ) { + this.node.panelMultiView.goBack(); + break; + } + // If the current button is _not_ one that points to a subview, pressing + // the arrow key shouldn't do anything. + let button = this.selectedElement; + if (!button || !button.classList.contains("subviewbutton-nav")) { + break; + } + } + // Fall-through... + case "Space": + case "NumpadEnter": + case "Enter": { + if (tabOnly() || isContextMenuOpen()) { + break; + } + let button = this.selectedElement; + if (!button) { + break; + } + stop(); + + this._doingKeyboardActivation = true; + // Unfortunately, 'tabindex' doesn't execute the default action, so + // we explicitly do this here. + // We are sending a command event, a mousedown event and then a click + // event. This is done in order to mimic a "real" mouse click event. + // Normally, the command event executes the action, then the click event + // closes the menu. However, in some cases (e.g. the Library button), + // there is no command event handler and the mousedown event executes the + // action instead. + button.doCommand(); + let dispEvent = new event.target.ownerGlobal.MouseEvent("mousedown", { + bubbles: true, + }); + button.dispatchEvent(dispEvent); + dispEvent = new event.target.ownerGlobal.MouseEvent("click", { + bubbles: true, + }); + button.dispatchEvent(dispEvent); + this._doingKeyboardActivation = false; + break; + } + } + } + + /** + * Focus the last selected element in the view, if any. + * + * @param byKey {Boolean} whether focus was moved by the user pressing a key. + * Needed to ensure we show focus styles in the right cases. + */ + focusSelectedElement(byKey = false) { + let selected = this.selectedElement; + if (selected) { + let flag = byKey ? "FLAG_BYKEY" : "FLAG_BYELEMENTFOCUS"; + Services.focus.setFocus(selected, Services.focus[flag]); + } + } + + /** + * Clear all traces of keyboard navigation happening right now. + */ + clearNavigation() { + let selected = this.selectedElement; + if (selected) { + selected.blur(); + this.selectedElement = null; + } + } +} diff --git a/comm/mail/components/customizableui/content/customizeMode.inc.xhtml b/comm/mail/components/customizableui/content/customizeMode.inc.xhtml new file mode 100644 index 0000000000..fc7eb0595b --- /dev/null +++ b/comm/mail/components/customizableui/content/customizeMode.inc.xhtml @@ -0,0 +1,128 @@ +<!-- 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/. --> + +<box id="customization-content-container"> +<box flex="1" id="customization-palette-container"> + <label id="customization-header" data-l10n-id="customize-mode-menu-and-toolbars-header"></label> + <vbox id="customization-palette" class="customization-palette" hidden="true"/> + <vbox id="customization-pong-arena" hidden="true"/> + <spacer id="customization-spacer"/> +</box> +<vbox id="customization-panel-container"> + <vbox id="customization-panelWrapper"> + <box class="panel-arrowbox"> + <image class="panel-arrow" side="top"/> + </box> + <box class="panel-arrowcontent" side="top" flex="1"> + <vbox id="customization-panelHolder"> + <description id="customization-panelHeader" data-l10n-id="customize-mode-overflow-list-title"></description> + <description id="customization-panelDescription" data-l10n-id="customize-mode-overflow-list-description"></description> + </vbox> + <box class="panel-inner-arrowcontentfooter" hidden="true"/> + </box> + </vbox> +</vbox> +</box> +<hbox id="customization-footer"> +<checkbox id="customization-titlebar-visibility-checkbox" class="customizationmode-checkbox" +# NB: because oncommand fires after click, by the time we've fired, the checkbox binding +# will already have switched the button's state, so this is correct: + oncommand="gCustomizeMode.toggleTitlebar(this.checked)" data-l10n-id="customize-mode-titlebar"/> +<checkbox id="customization-extra-drag-space-checkbox" class="customizationmode-checkbox" + data-l10n-id="customize-mode-extra-drag-space" + oncommand="gCustomizeMode.toggleDragSpace(this.checked)"/> +<button id="customization-toolbar-visibility-button" class="customizationmode-button" type="menu" data-l10n-id="customize-mode-toolbars"> + <menupopup id="customization-toolbar-menu" onpopupshowing="onViewToolbarsPopupShowing(event)"/> +</button> +<button id="customization-lwtheme-button" data-l10n-id="customize-mode-lwthemes" class="customizationmode-button" type="menu"> + <panel type="arrow" id="customization-lwtheme-menu" + orient="vertical" + onpopupshowing="gCustomizeMode.onThemesMenuShowing(event);" + position="topcenter bottomleft" + flip="none" + role="menu"> + <label id="customization-lwtheme-menu-header" data-l10n-id="customize-mode-lwthemes-my-themes"/> + <hbox id="customization-lwtheme-menu-footer"> + <toolbarbutton class="customization-lwtheme-menu-footeritem" + data-l10n-id="customize-mode-lwthemes-menu-manage" + tabindex="0" + oncommand="gCustomizeMode.openAddonsManagerThemes(event);"/> + <toolbarbutton class="customization-lwtheme-menu-footeritem" + data-l10n-id="customize-mode-lwthemes-menu-get-more" + tabindex="0" + oncommand="gCustomizeMode.getMoreThemes(event);"/> + </hbox> + </panel> +</button> +<button id="customization-uidensity-button" + data-l10n-id="customize-mode-uidensity" + class="customizationmode-button" + type="menu"> + <panel type="arrow" id="customization-uidensity-menu" + onpopupshowing="gCustomizeMode.onUIDensityMenuShowing();" + position="topcenter bottomleft" + flip="none" + role="menu"> + <menuitem id="customization-uidensity-menuitem-compact" + class="menuitem-iconic customization-uidensity-menuitem" + role="menuitemradio" + data-l10n-id="customize-mode-uidensity-menu-compact" + tabindex="0" + onfocus="gCustomizeMode.updateUIDensity(this.mode);" + onmouseover="gCustomizeMode.updateUIDensity(this.mode);" + onblur="gCustomizeMode.resetUIDensity();" + onmouseout="gCustomizeMode.resetUIDensity();" + oncommand="gCustomizeMode.setUIDensity(this.mode);"/> + <menuitem id="customization-uidensity-menuitem-normal" + class="menuitem-iconic customization-uidensity-menuitem" + role="menuitemradio" + data-l10n-id="customize-mode-uidensity-menu-normal" + tabindex="0" + onfocus="gCustomizeMode.updateUIDensity(this.mode);" + onmouseover="gCustomizeMode.updateUIDensity(this.mode);" + onblur="gCustomizeMode.resetUIDensity();" + onmouseout="gCustomizeMode.resetUIDensity();" + oncommand="gCustomizeMode.setUIDensity(this.mode);"/> +#ifndef XP_MACOSX + <menuitem id="customization-uidensity-menuitem-touch" + class="menuitem-iconic customization-uidensity-menuitem" + role="menuitemradio" + data-l10n-id="customize-mode-uidensity-menu-touch" + tabindex="0" + onfocus="gCustomizeMode.updateUIDensity(this.mode);" + onmouseover="gCustomizeMode.updateUIDensity(this.mode);" + onblur="gCustomizeMode.resetUIDensity();" + onmouseout="gCustomizeMode.resetUIDensity();" + oncommand="gCustomizeMode.setUIDensity(this.mode);"> + </menuitem> + <spacer hidden="true" id="customization-uidensity-touch-spacer"/> + <checkbox id="customization-uidensity-autotouchmode-checkbox" + hidden="true" + data-l10n-id="customize-mode-uidensity-auto-touch-mode-checkbox" + oncommand="gCustomizeMode.updateAutoTouchMode(this.checked)"/> +#endif + </panel> +</button> + +<button id="whimsy-button" + type="checkbox" + class="customizationmode-button" + oncommand="gCustomizeMode.togglePong(this.checked);" + hidden="true"/> + +<spacer id="customization-footer-spacer"/> +<button id="customization-undo-reset-button" + class="customizationmode-button" + hidden="true" + oncommand="gCustomizeMode.undoReset();" + data-l10n-id="customize-mode-undo-cmd"/> +<button id="customization-reset-button" + oncommand="gCustomizeMode.reset();" + data-l10n-id="customize-mode-restore-defaults" + class="customizationmode-button"/> +<button id="customization-done-button" + oncommand="gCustomizeMode.exit();" + data-l10n-id="customize-mode-done" + class="customizationmode-button"/> +</hbox> diff --git a/comm/mail/components/customizableui/content/jar.mn b/comm/mail/components/customizableui/content/jar.mn new file mode 100644 index 0000000000..db1978fdb0 --- /dev/null +++ b/comm/mail/components/customizableui/content/jar.mn @@ -0,0 +1,6 @@ +# 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/. + +messenger.jar: + content/messenger/panelUI.js diff --git a/comm/mail/components/customizableui/content/moz.build b/comm/mail/components/customizableui/content/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/comm/mail/components/customizableui/content/moz.build @@ -0,0 +1,7 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/comm/mail/components/customizableui/content/panelUI.inc.xhtml b/comm/mail/components/customizableui/content/panelUI.inc.xhtml new file mode 100644 index 0000000000..3b965da756 --- /dev/null +++ b/comm/mail/components/customizableui/content/panelUI.inc.xhtml @@ -0,0 +1,606 @@ +<!-- 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/. --> + +<panel id="appMenu-popup" + class="cui-widget-panel panel-no-padding" + role="group" + type="arrow" + hidden="true" + flip="slide" + position="bottomright topright" + noautofocus="true"> + <panelmultiview id="appMenu-multiView" + mainViewId="appMenu-mainView" + viewCacheId="appMenu-viewCache"> + + <!-- Main Appmenu View --> + <panelview id="appMenu-mainView" class="PanelUI-subView"> + <vbox id="appMenu-mainViewItems" + class="panel-subview-body"> + <vbox id="appMenu-addon-banners"/> + <toolbarbutton class="panel-banner-item" + oncommand="PanelUI._onBannerItemSelected(event)" + hidden="true"/> +#ifdef NIGHTLY_BUILD + <toolbarbutton id="appmenu_signin" + data-l10n-id="appmenu-signin-panel" + class="subviewbutton subviewbutton-iconic" + hidden="true" + oncommand="gSync.initFxA();"/> + <toolbarbutton id="appmenu_sync" + class="subviewbutton subviewbutton-iconic subviewbutton-nav" + hidden="true" + align="center" + closemenu="none" + oncommand="PanelUI.showSubView('appMenu-syncView', this)"> + <hbox flex="1"> + <html:img id="appmenu-sync-icon" + class="toolbarbutton-icon" + alt=""/> + <vbox flex="1"> + <label id="appmenu-sync-sync" + crop="end" + data-l10n-id="appmenu-sync-sync"/> + <label id="appmenu-sync-account" + class="appmenu-sync-account-email" + crop="end" + data-l10n-id="appmenu-sync-account"/> + </vbox> + </hbox> + </toolbarbutton> + <toolbarseparator id="syncSeparator" hidden="true"/> +#endif + <toolbarbutton id="appmenu_new" + data-l10n-id="appmenu-new-account-panel" + class="subviewbutton subviewbutton-iconic subviewbutton-nav" + closemenu="none" + oncommand="PanelUI.showSubView('appMenu-newView', this)"/> + <toolbarbutton id="appmenu_create" + data-l10n-id="appmenu-create-panel" + class="subviewbutton subviewbutton-iconic subviewbutton-nav" + closemenu="none" + oncommand="PanelUI.showSubView('appMenu-createView', this)"/> + <toolbarseparator id="appmenu_createPopupMenuSeparator"/> + <toolbarbutton id="appmenu_open" + class="subviewbutton subviewbutton-iconic subviewbutton-nav" + data-l10n-id="appmenu-open-file-panel" + closemenu="none" + oncommand="PanelUI.showSubView('appMenu-openView', this)"/> + <toolbarseparator/> + <toolbarbutton id="appmenu_View" + class="subviewbutton subviewbutton-iconic subviewbutton-nav" + data-l10n-id="appmenu-view-panel" + closemenu="none" + oncommand="PanelUI.showSubView('appMenu-viewView', this)"/> + <toolbaritem id="appMenu-uiDensity-controls" + class="subviewbutton subviewbutton-iconic toolbaritem-combined-buttons" + closemenu="none"> + <html:img class="toolbarbutton-icon" src="" alt=""/> + <label class="toolbarbutton-text" data-l10n-id="appmenu-mail-uidensity-value"/> + <toolbarbutton id="appmenu_uiDensityCompact" + class="subviewbutton subviewbutton-iconic subviewbutton" + data-l10n-id="appmenu-uidensity-compact" + type="radio" + oncommand="PanelUI.setUIDensity(event);"/> + <toolbarbutton id="appmenu_uiDensityNormal" + class="subviewbutton subviewbutton-iconic subviewbutton" + data-l10n-id="appmenu-uidensity-default" + type="radio" + oncommand="PanelUI.setUIDensity(event);"/> + <toolbarbutton id="appmenu_uiDensityTouch" + class="subviewbutton subviewbutton-iconic subviewbutton" + data-l10n-id="appmenu-uidensity-relaxed" + type="radio" + oncommand="PanelUI.setUIDensity(event);"/> + </toolbaritem> + <toolbaritem id="appMenu-fontSize-controls" + class="subviewbutton subviewbutton-iconic toolbaritem-combined-buttons" + closemenu="none"> + <html:img class="toolbarbutton-icon" src="" alt=""/> + <label class="toolbarbutton-text" data-l10n-id="appmenu-font-size-value"/> + <toolbarbutton id="appMenu-fontSizeReduce-button" + class="subviewbutton subviewbutton-iconic" + oncommand="UIFontSize.reduceSize();" + data-l10n-id="appmenuitem-font-size-reduce"/> + <toolbarbutton id="appMenu-fontSizeReset-button" + class="subviewbutton" + oncommand="UIFontSize.resetSize();" + tooltip="fontSizeReset"/> + <toolbarbutton id="appMenu-fontSizeEnlarge-button" + class="subviewbutton subviewbutton-iconic" + oncommand="UIFontSize.increaseSize();" + data-l10n-id="appmenuitem-font-size-enlarge"/> + </toolbaritem> + <toolbarseparator/> + <toolbarbutton id="appmenu_preferences" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-settings" + oncommand="openOptionsDialog();"/> + <toolbarbutton id="appmenu_accountmgr" + class="subviewbutton subviewbutton-iconic" + label="&accountManagerCmd2.label;" + oncommand="MsgAccountManager(null);"/> + <toolbarbutton id="appmenu_addons" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-addons-and-themes" + oncommand="openAddonsMgr();"/> + <toolbarseparator/> + <toolbarbutton id="appmenu_toolsMenu" + class="subviewbutton subviewbutton-iconic subviewbutton-nav" + data-l10n-id="appmenu-tools-panel" + closemenu="none" + oncommand="PanelUI.showSubView('appMenu-toolsView', this)"/> + <toolbarbutton id="appmenu_help" + class="subviewbutton subviewbutton-iconic subviewbutton-nav" + data-l10n-id="menu-help-help-title" + closemenu="none" + oncommand="buildHelpMenu(); PanelUI.showSubView('appMenu-helpView', this)"/> + <toolbarseparator/> + <toolbarbutton id="appmenu-quit" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="menu-quit" + key="key_quitApplication" + command="cmd_quitApplication"/> + </vbox> + </panelview> +#ifdef NIGHTLY_BUILD + <!-- Sync --> + <panelview id="appMenu-syncView" + data-l10n-id="appmenu-sync-panel-title" + class="PanelUI-subView"> + <vbox id="appMenu-syncViewItems" + class="panel-subview-body"> + <toolbarbutton id="appmenu_manageSyncAccountMenuItem" + class="subviewbutton subviewbutton-iconic" + align="center" + oncommand="gSync.openFxAManagePage();"> + <hbox flex="1"> + <html:img id="appmenu-manage-sync-icon" + class="toolbarbutton-icon" + alt=""/> + <vbox flex="1"> + <label id="appmenu-sync-menu-manage" + crop="end" + data-l10n-id="appmenu-sync-manage"/> + <label id="appmenu-sync-menu-account" + class="appmenu-sync-account-email" + crop="end" + data-l10n-id="appmenu-sync-account"/> + </vbox> + </hbox> + </toolbarbutton> + + <toolbarbutton id="appmenu-submenu-sync-now" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-sync-now" + closemenu="none" + oncommand="Weave.Service.sync({});"/> + <toolbarbutton id="appmenu-submenu-sync-settings" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-sync-settings" + oncommand="openPreferencesTab('sync');"/> + <toolbarseparator/> + <toolbarbutton id="appmenu-submenu-sync-sign-out" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-sync-sign-out" + oncommand="gSync.disconnect({ confirm: true });"/> + </vbox> + </panelview> +#endif + <!-- New --> + <panelview id="appMenu-newView" + data-l10n-id="appmenu-new-account-panel-title" + class="PanelUI-subView"> + <vbox id="appMenu-newViewItems" + class="panel-subview-body"> + <toolbarbutton id="appmenu_newCreateEmailAccountMenuItem" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-create-new-mail-account" + oncommand="openAccountProvisionerTab();"/> + <toolbarbutton id="appmenu_newMailAccountMenuItem" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-new-mail-account" + oncommand="openAccountSetupTab();"/> +#ifdef MAIN_WINDOW + <toolbarbutton id="appmenu_calendar-new-calendar-menu-item" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-new-calendar" + command="calendar_new_calendar_command"/> +#endif + <toolbarbutton id="appmenu_newAB" + class="subviewbutton subviewbutton-iconic subviewbutton-nav" + data-l10n-id="appmenu-newab-panel" + closemenu="none" + oncommand="PanelUI.showSubView('appMenu-newabView', this)"/> + <toolbarbutton id="appmenu_newIMAccountMenuItem" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-new-chat-account" + oncommand="openIMAccountWizard();"/> + <toolbarbutton id="appmenu_newFeedAccountMenuItem" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-new-feed" + oncommand="AddFeedAccount();"/> + <toolbarbutton id="appmenu_newNewsgroupAccountMenuItem" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-new-newsgroup" + oncommand="openNewsgroupAccountWizard();"/> + </vbox> + </panelview> + + <!-- New AB --> + <panelview id="appMenu-newabView" + data-l10n-id="appmenu-newab-panel-title" + class="PanelUI-subView"> + <vbox id="appMenu-newABItems" + class="panel-subview-body"> + <toolbarbutton id="appmenu_newABMenuItem" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-new-addressbook" + oncommand="openNewABDialog();"/> + <toolbarbutton id="appmenu_newCardDAVMenuItem" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-new-carddav" + oncommand="openNewABDialog('CARDDAV');"/> + <toolbarbutton id="appmenu_newLdapMenuItem" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-new-ldap" + oncommand="openNewABDialog('LDAP');"/> + </vbox> + </panelview> + + <!-- Create --> + <panelview id="appMenu-createView" + data-l10n-id="appmenu-create-panel-title" + class="PanelUI-subView"> + <vbox id="appMenu-createViewItems" + class="panel-subview-body"> + <toolbarbutton id="appmenu_newNewMsgCmd" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-create-message" + key = "key_newMessage2" + command="cmd_newMessage"/> +#ifdef MAIN_WINDOW + <toolbarbutton id="appmenu_calendar-new-event-menu-item" + class="subviewbutton subviewbutton-iconic hide-when-calendar-deactivated" + data-l10n-id="appmenu-create-event" + command="calendar_new_event_command"/> + <toolbarbutton id="appmenu_calendar-new-task-menu-item" + class="subviewbutton subviewbutton-iconic hide-when-calendar-deactivated" + data-l10n-id="appmenu-create-task" + command="calendar_new_todo_command"/> +#endif + <toolbarbutton id="appmenu_newCard" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-create-contact" + command="cmd_newCard"/> + </vbox> + </panelview> + + <!-- Open --> + <panelview id="appMenu-openView" + data-l10n-id="appmenu-open-file-panel-title" + class="PanelUI-subView"> + <vbox id="appMenu-openViewItems" + class="panel-subview-body"> + <toolbarbutton id="appmenu_OpenMessageFileMenuitem" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-open-message" + oncommand="MsgOpenFromFile();"/> + <toolbarbutton id="appmenu_OpenCalendarFileMenuitem" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-open-calendar" + oncommand="openLocalCalendar();"/> + </vbox> + </panelview> + + <!-- View / Toolbars --> + <panelview id="appMenu-toolbarsView" + title="&viewToolbarsMenu.label;" + class="PanelUI-subView"> + <vbox class="panel-subview-body"> +#ifdef MAIN_WINDOW + <toolbarbutton id="appmenu_quickFilterBar" + class="subviewbutton subviewbutton-iconic" + type="checkbox" + closemenu="none" + data-l10n-id="quick-filter-bar-toggle" + command="cmd_toggleQuickFilterBar"/> + <toolbarbutton id="appmenu_spacesToolbar" + class="subviewbutton subviewbutton-iconic" + type="checkbox" + data-l10n-id="menu-spaces-toolbar-button" + closemenu="none" + oncommand="gSpacesToolbar.toggleToolbarFromMenu();"/> +#endif + <toolbarbutton id="appmenu_showStatusbar" + class="subviewbutton subviewbutton-iconic" + type="checkbox" + label="&showTaskbarCmd.label;" + oncommand="goToggleToolbar('status-bar', 'menu_showTaskbar')" + closemenu="none" + checked="true" + observes="menu_showTaskbar"/> + <toolbarseparator id="appmenu_toggleToolbarsSeparator"/> + <toolbarbutton id="appmenu_toolbarLayout" + class="subviewbutton subviewbutton-iconic" + label="&appmenuToolbarLayout.label;" + command="cmd_CustomizeMailToolbar"/> + </vbox> + </panelview> + + <!-- View / Layout --> + <panelview id="appMenu-preferencesLayoutView" + title="&messagePaneLayoutStyle.label;" + class="PanelUI-subView"> + <vbox class="panel-subview-body"> + <toolbarbutton id="appmenu_messagePaneClassic" + class="subviewbutton subviewbutton-iconic" + type="radio" + label="&messagePaneClassic.label;" + name="viewlayoutgroup" + command="cmd_viewClassicMailLayout"/> + <toolbarbutton id="appmenu_messagePaneWide" + class="subviewbutton subviewbutton-iconic" + type="radio" + label="&messagePaneWide.label;" + name="viewlayoutgroup" + command="cmd_viewWideMailLayout"/> + <toolbarbutton id="appmenu_messagePaneVertical" + class="subviewbutton subviewbutton-iconic" + type="radio" + label="&messagePaneVertical.label;" + name="viewlayoutgroup" + command="cmd_viewVerticalMailLayout"/> + <toolbarseparator id="appmenu_viewMenuAfterPaneVerticalSeparator"/> + <toolbarbutton id="appmenu_showFolderPane" + class="subviewbutton subviewbutton-iconic" + type="checkbox" + closemenu="none" + label="&showFolderPaneCmd.label;" + command="cmd_toggleFolderPane"/> + <toolbarbutton id="appmenu_toggleThreadPaneHeader" + class="subviewbutton subviewbutton-iconic" + type="checkbox" + name="threadheader" + closemenu="none" + data-l10n-id="appmenuitem-toggle-thread-pane-header" + command="cmd_toggleThreadPaneHeader"/> + <toolbarbutton id="appmenu_showMessage" + class="subviewbutton subviewbutton-iconic" + type="checkbox" + closemenu="none" + label="&showMessageCmd.label;" + key="key_toggleMessagePane" + command="cmd_toggleMessagePane"/> + <toolbarseparator/> + <toolbarbutton id="appmenu_calShowTodayPane-2" + class="subviewbutton subviewbutton-iconic" + label="&todaypane.showTodayPane.label;" + type="checkbox" + command="calendar_toggle_todaypane_command"/> + </vbox> + </panelview> + + <!-- View --> + <panelview id="appMenu-viewView" + class="PanelUI-subView" + data-l10n-id="appmenu-view-panel-title"> + <vbox id="appMenu-viewViewItems" + class="panel-subview-body"> + <toolbarbutton id="appmenu_Toolbars" + class="subviewbutton subviewbutton-iconic subviewbutton-nav" + label="&viewToolbarsMenu.label;" + accesskey="&viewToolbarsMenu.accesskey;" + closemenu="none" + oncommand="PanelUI.showSubView('appMenu-toolbarsView', this)"/> + <toolbarbutton id="appmenu_MessagePaneLayout" + class="subviewbutton subviewbutton-iconic subviewbutton-nav" + label="&messagePaneLayoutStyle.label;" + closemenu="none" + oncommand="PanelUI.showSubView('appMenu-preferencesLayoutView', this)"/> + <toolbarbutton id="appmenu_FolderViews" + class="subviewbutton subviewbutton-iconic subviewbutton-nav" + label="&folderView.label;" + closemenu="none" + oncommand="PanelUI.showSubView('appMenu-foldersView', this)"/> + </vbox> + </panelview> + + <!-- View / Folders --> + <panelview id="appMenu-foldersView" + title="&folderView.label;" + class="PanelUI-subView"> + <vbox class="panel-subview-body"> + <toolbarbutton id="appmenu_toggleFolderHeader" + class="subviewbutton subviewbutton-iconic" + name="paneheader" + value="toggle-header" + data-l10n-id="menu-view-folders-toggle-header" + type="checkbox" + closemenu="none" + oncommand="PanelUI.folderViewMenuOnCommand(event);"/> + <toolbarseparator id="appmenu_folderModesSeparator"/> + <toolbarbutton id="appmenu_allFolders" + class="subviewbutton subviewbutton-iconic" + value="all" + data-l10n-id="show-all-folders-label" + type="checkbox" + name="viewmessages" + closemenu="none" + oncommand="PanelUI.folderViewMenuOnCommand(event);"/> + <toolbarbutton id="appmenu_smartFolders" + class="subviewbutton subviewbutton-iconic" + value="smart" + data-l10n-id="show-smart-folders-label" + type="checkbox" + name="viewmessages" + closemenu="none" + oncommand="PanelUI.folderViewMenuOnCommand(event);"/> + <toolbarbutton id="appmenu_unreadFolders" + class="subviewbutton subviewbutton-iconic" + value="unread" + data-l10n-id="show-unread-folders-label" + type="checkbox" + name="viewmessages" + closemenu="none" + oncommand="PanelUI.folderViewMenuOnCommand(event);"/> + <toolbarbutton id="appmenu_favoriteFolders" + class="subviewbutton subviewbutton-iconic" + value="favorite" + data-l10n-id="show-favorite-folders-label" + type="checkbox" + name="viewmessages" + closemenu="none" + oncommand="PanelUI.folderViewMenuOnCommand(event);"/> + <toolbarbutton id="appmenu_recentFolders" + class="subviewbutton subviewbutton-iconic" + value="recent" + data-l10n-id="show-recent-folders-label" + type="checkbox" + name="viewmessages" + closemenu="none" + oncommand="PanelUI.folderViewMenuOnCommand(event);"/> + <toolbarseparator/> + <toolbarbutton id="appmenu_tagsFolders" + class="subviewbutton subviewbutton-iconic" + value="tags" + data-l10n-id="show-tags-folders-label" + type="checkbox" + name="viewmessages" + closemenu="none" + oncommand="PanelUI.folderViewMenuOnCommand(event);"/> + <toolbarseparator id="appmenu_compactPropertiesSeparator"/> + <toolbarbutton id="appmenu_compactMode" + class="subviewbutton subviewbutton-iconic" + value="compact" + data-l10n-id="folder-toolbar-toggle-folder-compact-view" + type="checkbox" + name="viewmessages" + closemenu="none" + oncommand="PanelUI.folderCompactMenuOnCommand(event)"/> + <toolbarseparator id="appmenu_favoritePropertiesSeparator"/> + <toolbarbutton id="appmenu_favoriteFolder" + class="subviewbutton subviewbutton-iconic" + type="checkbox" + label="&menuFavoriteFolder.label;" + checked="false" + command="cmd_toggleFavoriteFolder"/> + <toolbarbutton id="appmenu_properties" + class="subviewbutton subviewbutton-iconic" + command="cmd_properties"/> + </vbox> + </panelview> + + <!-- View / Messages / Tags --> + <!-- Dynamically populated when shown. --> + <panelview id="appMenu-viewMessagesTagsView" + title="&viewTags.label;" + class="PanelUI-subView" + oncommand="ViewChangeByMenuitem(event.target);"> + <vbox class="panel-subview-body"/> + </panelview> + + <!-- View / Messages / Custom Views --> + <!-- Dynamically populated when shown. --> + <panelview id="appMenu-viewMessagesCustomViewsView" + title="&viewCustomViews.label;" + class="PanelUI-subView" + oncommand="ViewChangeByMenuitem(event.target);"> + <vbox class="panel-subview-body"/> + </panelview> + + <!-- Tools --> + <panelview id="appMenu-toolsView" + data-l10n-id="appmenu-tools-panel-title" + class="PanelUI-subView"> + <vbox class="panel-subview-body"> + <toolbarbutton id="appmenu_import" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-tools-import" + oncommand="toImport();"/> + <toolbarbutton id="appmenu_export" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-tools-export" + oncommand="toExport();"/> + <toolbarseparator id="importExportSeparator"/> + <toolbarbutton id="appmenu_searchCmd" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-tools-message-search" + key="key_searchMail" + command="cmd_searchMessages"/> + <toolbarbutton id="appmenu_filtersCmd" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-tools-message-filters" + oncommand="MsgFilters();"/> + <toolbarbutton id="appmenu_manageKeysOpenPGP" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="openpgp-manage-keys-openpgp-cmd" + oncommand="openKeyManager()"/> + <toolbarbutton id="appmenu_openSavedFilesWnd" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-tools-download-manager" + key="key_savedFiles" + oncommand="openSavedFilesWnd();"/> + <toolbarbutton id="appmenu_activityManager" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-tools-activity-manager" + oncommand="openActivityMgr();"/> + <toolbarseparator id="devToolsSeparator"/> + <toolbarbutton id="appmenu_devtoolsToolbox" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-tools-dev-tools" + key="key_devtoolsToolbox" + oncommand="BrowserToolboxLauncher.init();"/> + </vbox> + </panelview> + + <!-- Help --> + <panelview id="appMenu-helpView" + data-l10n-id="appmenu-help-panel-title" + class="PanelUI-subView"> + <vbox class="panel-subview-body"> + <toolbarbutton id="appmenu_openHelp" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-help-get-help" + key="key_openHelp" + oncommand="openSupportURL();"/> + <toolbarbutton id="appmenu_openTour" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-help-explore-features" + oncommand="openLinkText(event, 'tourURL');"/> + <toolbarbutton id="appmenu_keyboardShortcuts" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-help-shortcuts" + oncommand="openLinkText(event, 'keyboardShortcutsURL');"/> + <toolbarseparator/> + <toolbarbutton id="appmenu_getInvolved" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-help-get-involved" + oncommand="openLinkText(event, 'getInvolvedURL');"/> + <toolbarbutton id="appmenu_makeDonation" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-help-donation" + oncommand="openLinkText(event, 'donateURL');"/> + <toolbarbutton id="appmenu_submitFeedback" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-help-share-feedback" + oncommand="openLinkText(event, 'feedbackURL');"/> + <toolbarseparator/> + <toolbarbutton id="appmenu_troubleshootMode" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-help-enter-troubleshoot-mode2" + oncommand="safeModeRestart();"/> + <toolbarbutton id="appmenu_troubleshootingInfo" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-help-troubleshooting-info" + oncommand="openAboutSupport();"/> + <toolbarseparator/> + <toolbarbutton id="appmenu_about" + class="subviewbutton subviewbutton-iconic" + data-l10n-id="appmenu-help-about-product" + oncommand="openAboutDialog();"/> + </vbox> + </panelview> + </panelmultiview> +</panel> diff --git a/comm/mail/components/customizableui/content/panelUI.js b/comm/mail/components/customizableui/content/panelUI.js new file mode 100644 index 0000000000..bad418abb4 --- /dev/null +++ b/comm/mail/components/customizableui/content/panelUI.js @@ -0,0 +1,882 @@ +/* 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/. */ + +/* import-globals-from ../../../base/content/globalOverlay.js */ +/* import-globals-from ../../../base/content/mailCore.js */ +/* import-globals-from ../../../base/content/mailWindowOverlay.js */ +/* import-globals-from ../../../base/content/messenger.js */ +/* import-globals-from ../../../extensions/mailviews/content/msgViewPickerOverlay.js */ + +var { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); +var { ExtensionSupport } = ChromeUtils.import( + "resource:///modules/ExtensionSupport.jsm" +); +var { ShortcutUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ShortcutUtils.sys.mjs" +); +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { UIDensity } = ChromeUtils.import("resource:///modules/UIDensity.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs", + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", + PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs", +}); +ChromeUtils.defineModuleGetter( + this, + "ExtensionsUI", + "resource:///modules/ExtensionsUI.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", + "ViewShowing", + ]; + }, + /** + * 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 { + mainView: "appMenu-mainView", + multiView: "appMenu-multiView", + menuButton: "button-appmenu", + panel: "appMenu-popup", + addonNotificationContainer: "appMenu-addon-banners", + navbar: "mail-bar3", + }; + }, + + kAppMenuButtons: new Set(), + + _initialized: false, + _notifications: null, + + init() { + this._initElements(); + this.initAppMenuButton("button-appmenu", "mail-toolbox"); + + this.menuButton = this.menuButtonMail; + + Services.obs.addObserver(this, "fullscreen-nav-toolbox"); + Services.obs.addObserver(this, "appMenu-notifications"); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "autoHideToolbarInFullScreen", + "browser.fullscreen.autohide", + false, + (pref, previousValue, newValue) => { + // On OSX, or with autohide preffed off, MozDOMFullscreen is the only + // event we care about, since fullscreen should behave just like non + // fullscreen. Otherwise, we don't want to listen to these because + // we'd just be spamming ourselves with both of them whenever a user + // opened a video. + if (newValue) { + window.removeEventListener("MozDOMFullscreen:Entered", this); + window.removeEventListener("MozDOMFullscreen:Exited", this); + window.addEventListener("fullscreen", this); + } else { + window.addEventListener("MozDOMFullscreen:Entered", this); + window.addEventListener("MozDOMFullscreen:Exited", this); + window.removeEventListener("fullscreen", this); + } + + this._updateNotifications(false); + }, + autoHidePref => autoHidePref && Services.appinfo.OS !== "Darwin" + ); + + if (this.autoHideToolbarInFullScreen) { + window.addEventListener("fullscreen", this); + } else { + window.addEventListener("MozDOMFullscreen:Entered", this); + window.addEventListener("MozDOMFullscreen:Exited", this); + } + + window.addEventListener("activate", this); + + Services.obs.notifyObservers( + null, + "appMenu-notifications-request", + "refresh" + ); + + this._initialized = true; + }, + + _initElements() { + 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]; + // eslint-disable-next-line consistent-return + return (this[getKey] = document.getElementById(id)); + }); + } + }, + + initAppMenuButton(id, toolboxId) { + let button = document.getElementById(id); + if (!button) { + // If not in the document, the button should be in the toolbox palette, + // which isn't part of the document. + let toolbox = document.getElementById(toolboxId); + if (toolbox) { + button = toolbox.palette.querySelector(`#${id}`); + } + } + + if (button) { + button.addEventListener("mousedown", PanelUI); + button.addEventListener("keypress", PanelUI); + + this.kAppMenuButtons.add(button); + } + }, + + _eventListenersAdded: false, + _ensureEventListenersAdded() { + if (this._eventListenersAdded) { + return; + } + this._addEventListeners(); + }, + + _addEventListeners() { + for (let event of this.kEvents) { + this.panel.addEventListener(event, this); + } + this._eventListenersAdded = true; + }, + + _removeEventListeners() { + for (let event of this.kEvents) { + this.panel.removeEventListener(event, this); + } + this._eventListenersAdded = false; + }, + + uninit() { + this._removeEventListeners(); + + Services.obs.removeObserver(this, "fullscreen-nav-toolbox"); + Services.obs.removeObserver(this, "appMenu-notifications"); + + window.removeEventListener("MozDOMFullscreen:Entered", this); + window.removeEventListener("MozDOMFullscreen:Exited", this); + window.removeEventListener("fullscreen", this); + window.removeEventListener("activate", this); + + [this.menuButtonMail, this.menuButtonChat].forEach(button => { + // There's no chat button in the messageWindow.xhtml context. + if (button) { + button.removeEventListener("mousedown", this); + button.removeEventListener("keypress", this); + } + }); + }, + + /** + * Opens the menu panel if it's closed, or closes it if it's open. + * + * @param event the event that triggers the toggle. + */ + toggle(event) { + // 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; + } + + // Since we have several menu buttons, make sure the current one is used. + // This works for now, but in the long run, if we're showing badges etc. + // then the current menuButton needs to be set when the app's view/tab + // changes, not just when the menu is toggled. + this.menuButton = event.target; + + this._ensureEventListenersAdded(); + if (this.panel.state == "open") { + this.hide(); + } else if (this.panel.state == "closed") { + this.show(event); + } + }, + + /** + * 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(aEvent) { + this._ensureShortcutsShown(); + (async () => { + await this.ensureReady(); + + if ( + this.panel.state == "open" || + document.documentElement.hasAttribute("customizing") + ) { + return; + } + + let domEvent = null; + if (aEvent && aEvent.type != "command") { + domEvent = aEvent; + } + + // We try to use the event.target to account for clicks triggered + // from the #button-chat-appmenu. In case the opening of the menu isn't + // triggered by a click event, fallback to the main menu button as anchor. + let anchor = this._getPanelAnchor( + aEvent ? aEvent.target : this.menuButton + ); + await PanelMultiView.openPopup(this.panel, anchor, { + triggerEvent: domEvent, + }); + })().catch(console.error); + }, + + /** + * If the menu panel is being shown, hide it. + */ + hide() { + if (document.documentElement.hasAttribute("customizing")) { + return; + } + + PanelMultiView.hidePopup(this.panel); + }, + + observe(subject, topic, status) { + switch (topic) { + case "fullscreen-nav-toolbox": + if (this._notifications) { + this._updateNotifications(false); + } + break; + case "appMenu-notifications": + // Don't initialize twice. + if (status == "init" && this._notifications) { + break; + } + this._notifications = AppMenuNotifications.notifications; + this._updateNotifications(true); + break; + } + }, + + handleEvent(event) { + // Ignore context menus and menu button menus showing and hiding: + if (event.type.startsWith("popup") && event.target != this.panel) { + return; + } + switch (event.type) { + case "popupshowing": + initAppMenuPopup(); + // Fall through + case "popupshown": + if (event.type == "popupshown") { + CustomizableUI.addPanelCloseListeners(this.panel); + } + // Fall through + case "popuphiding": + // Fall through + case "popuphidden": + this._updateNotifications(); + this._updatePanelButton(event.target); + if (event.type == "popuphidden") { + CustomizableUI.removePanelCloseListeners(this.panel); + } + break; + case "mousedown": + if (event.button == 0) { + this.toggle(event); + } + break; + case "keypress": + if (event.key == " " || event.key == "Enter") { + this.toggle(event); + event.stopPropagation(); + } + break; + case "MozDOMFullscreen:Entered": + case "MozDOMFullscreen:Exited": + case "fullscreen": + case "activate": + this._updateNotifications(); + break; + case "ViewShowing": + PanelUI._handleViewShowingEvent(event); + break; + } + }, + + /** + * When a ViewShowing event happens when a <panelview> element is shown, + * do any required set up for that particular view. + * + * @param {ViewShowingEvent} event - ViewShowing event. + */ + _handleViewShowingEvent(event) { + // Typically event.target for "ViewShowing" is a <panelview> element. + PanelUI._ensureShortcutsShown(event.target); + + switch (event.target.id) { + case "appMenu-foldersView": + this._onFoldersViewShow(event); + break; + case "appMenu-addonsView": + initAddonPrefsMenu( + event.target.querySelector(".panel-subview-body"), + "toolbarbutton", + "subviewbutton subviewbutton-iconic", + "subviewbutton subviewbutton-iconic" + ); + break; + case "appMenu-toolbarsView": + onViewToolbarsPopupShowing( + event, + "mail-toolbox", + document.getElementById("appmenu_quickFilterBar"), + "toolbarbutton", + "subviewbutton subviewbutton-iconic", + true + ); + break; + case "appMenu-preferencesLayoutView": + PanelUI._onPreferencesLayoutViewShow(event); + break; + // View + case "appMenu-viewMessagesTagsView": + PanelUI._refreshDynamicView(event, RefreshTagsPopup); + break; + case "appMenu-viewMessagesCustomViewsView": + PanelUI._refreshDynamicView(event, RefreshCustomViewsPopup); + break; + } + }, + + /** + * Refreshes some views that are dynamically populated. Typically called by + * event listeners responding to a ViewShowing event. It calls a given refresh + * function (that populates the view), passing appmenu-specific arguments. + * + * @param {ViewShowingEvent} event - ViewShowing event. + * @param {Function} refreshFunction - Function that refreshes a particular view. + */ + _refreshDynamicView(event, refreshFunction) { + refreshFunction( + event.target.querySelector(".panel-subview-body"), + "toolbarbutton", + "subviewbutton subviewbutton-iconic", + "toolbarseparator" + ); + }, + + 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. + * + * @returns a Promise that resolves once the panel is ready to roll. + */ + async ensureReady() { + if (this._isReady) { + return; + } + + await window.delayedStartupPromise; + this._ensureEventListenersAdded(); + this.panel.hidden = false; + this._isReady = true; + }, + + /** + * 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. + */ + async showSubView(aViewId, aAnchor) { + this._ensureEventListenersAdded(); + let viewNode = document.getElementById(aViewId); + if (!viewNode) { + console.error("Could not show panel subview with id: " + aViewId); + return; + } + + if (!aAnchor) { + console.error( + "Expected an anchor when opening subview with id: " + aViewId + ); + return; + } + + let container = aAnchor.closest("panelmultiview"); + if (container) { + container.showSubView(aViewId, aAnchor); + } + }, + + /** + * NB: The enable- and disableSingleSubviewPanelAnimations methods only + * affect the hiding/showing animations of single-subview panels (tempPanel + * in the showSubView method). + */ + disableSingleSubviewPanelAnimations() { + this._disableAnimations = true; + }, + + enableSingleSubviewPanelAnimations() { + this._disableAnimations = false; + }, + + /** + * Sets the anchor node into the open or closed state, depending + * on the state of the panel. + */ + _updatePanelButton() { + this.menuButton.open = + this.panel.state == "open" || this.panel.state == "showing"; + }, + + /** + * Event handler for showing the Preferences/Layout view. Removes "checked" + * from all layout menu items and then checks the current layout menu item. + * + * @param {ViewShowingEvent} event - ViewShowing event. + */ + _onPreferencesLayoutViewShow(event) { + event.target + .querySelectorAll("[name='viewlayoutgroup']") + .forEach(item => item.removeAttribute("checked")); + + InitViewLayoutStyleMenu(event, true); + }, + + /** + * Event listener for showing the Folders view. + * + * @param {ViewShowingEvent} event - ViewShowing event. + */ + _onFoldersViewShow(event) { + let about3Pane = document.getElementById("tabmail").currentAbout3Pane; + let folder = about3Pane.gFolder; + + const paneHeaderMenuitem = event.target.querySelector( + '[name="paneheader"]' + ); + if (about3Pane.folderPane.isFolderPaneHeaderHidden()) { + paneHeaderMenuitem.removeAttribute("checked"); + } else { + paneHeaderMenuitem.setAttribute("checked", "true"); + } + + let { activeModes, canBeCompact, isCompact } = about3Pane.folderPane; + if (isCompact) { + activeModes.push("compact"); + } + + for (let item of event.target.querySelectorAll('[name="viewmessages"]')) { + let mode = item.getAttribute("value"); + if (activeModes.includes(mode)) { + item.setAttribute("checked", "true"); + if (mode == "all") { + item.disabled = activeModes.length == 1; + } + } else { + item.removeAttribute("checked"); + } + if (mode == "compact") { + item.disabled = !canBeCompact; + } + } + + goUpdateCommand("cmd_properties"); + let propertiesMenuItem = document.getElementById("appmenu_properties"); + if (folder?.server.type == "nntp") { + document.l10n.setAttributes( + propertiesMenuItem, + "menu-edit-newsgroup-properties" + ); + } else { + document.l10n.setAttributes( + propertiesMenuItem, + "menu-edit-folder-properties" + ); + } + + let favoriteFolderMenu = document.getElementById("appmenu_favoriteFolder"); + if (folder?.getFlag(Ci.nsMsgFolderFlags.Favorite)) { + favoriteFolderMenu.setAttribute("checked", "true"); + } else { + favoriteFolderMenu.removeAttribute("checked"); + } + }, + + _onToolsMenuShown(event) { + let noAccounts = MailServices.accounts.accounts.length == 0; + event.target.querySelector("#appmenu_searchCmd").disabled = noAccounts; + event.target.querySelector("#appmenu_filtersCmd").disabled = noAccounts; + }, + + _updateNotifications(notificationsChanged) { + let notifications = this._notifications; + if (!notifications || !notifications.length) { + if (notificationsChanged) { + this._clearAllNotifications(); + } + return; + } + + let doorhangers = notifications.filter( + n => !n.dismissed && !n.options.badgeOnly + ); + + if (this.panel.state == "showing" || this.panel.state == "open") { + // If the menu is already showing, then we need to dismiss all notifications + // since we don't want their doorhangers competing for attention + doorhangers.forEach(n => { + n.dismissed = true; + if (n.options.onDismissed) { + n.options.onDismissed(window); + } + }); + this._clearBadge(); + if (!notifications[0].options.badgeOnly) { + this._showBannerItem(notifications[0]); + } + } else if (doorhangers.length > 0) { + // Only show the doorhanger if the window is focused and not fullscreen + if ( + (window.fullScreen && this.autoHideToolbarInFullScreen) || + Services.focus.activeWindow !== window + ) { + this._showBadge(doorhangers[0]); + this._showBannerItem(doorhangers[0]); + } else { + this._clearBadge(); + } + } else { + this._showBadge(notifications[0]); + this._showBannerItem(notifications[0]); + } + }, + + _clearAllNotifications() { + this._clearBadge(); + this._clearBannerItem(); + }, + + _formatDescriptionMessage(n) { + let text = {}; + let array = n.options.message.split("<>"); + text.start = array[0] || ""; + text.name = n.options.name || ""; + text.end = array[1] || ""; + return text; + }, + + _showBadge(notification) { + let badgeStatus = this._getBadgeStatus(notification); + for (let menuButton of this.kAppMenuButtons) { + menuButton.setAttribute("badge-status", badgeStatus); + } + }, + + // "Banner item" here refers to an item in the hamburger panel menu. They will + // typically show up as a colored row in the panel. + _showBannerItem(notification) { + const supportedIds = [ + "update-downloading", + "update-available", + "update-manual", + "update-unsupported", + "update-restart", + ]; + if (!supportedIds.includes(notification.id)) { + return; + } + + if (!this._panelBannerItem) { + this._panelBannerItem = this.mainView.querySelector(".panel-banner-item"); + } + + let l10nId = "appmenuitem-banner-" + notification.id; + document.l10n.setAttributes(this._panelBannerItem, l10nId); + + this._panelBannerItem.setAttribute("notificationid", notification.id); + this._panelBannerItem.hidden = false; + this._panelBannerItem.notification = notification; + }, + + _clearBadge() { + for (let menuButton of this.kAppMenuButtons) { + menuButton.removeAttribute("badge-status"); + } + }, + + _clearBannerItem() { + if (this._panelBannerItem) { + this._panelBannerItem.notification = null; + this._panelBannerItem.hidden = true; + } + }, + + _onNotificationButtonEvent(event, type) { + let notificationEl = getNotificationFromElement(event.target); + + if (!notificationEl) { + throw new Error( + "PanelUI._onNotificationButtonEvent: couldn't find notification element" + ); + } + + if (!notificationEl.notification) { + throw new Error( + "PanelUI._onNotificationButtonEvent: couldn't find notification" + ); + } + + let notification = notificationEl.notification; + + if (type == "secondarybuttoncommand") { + AppMenuNotifications.callSecondaryAction(window, notification); + } else { + AppMenuNotifications.callMainAction(window, notification, true); + } + }, + + _onBannerItemSelected(event) { + let target = event.target; + if (!target.notification) { + throw new Error( + "menucommand target has no associated action/notification" + ); + } + + event.stopPropagation(); + AppMenuNotifications.callMainAction(window, target.notification, false); + }, + + _getPopupId(notification) { + return "appMenu-" + notification.id + "-notification"; + }, + + _getBadgeStatus(notification) { + return notification.id; + }, + + _getPanelAnchor(candidate) { + let iconAnchor = candidate.badgeStack || candidate.icon; + return iconAnchor || candidate; + }, + + _ensureShortcutsShown(view = this.mainView) { + if (view.hasAttribute("added-shortcuts")) { + return; + } + view.setAttribute("added-shortcuts", "true"); + for (let button of view.querySelectorAll("toolbarbutton[key]")) { + let keyId = button.getAttribute("key"); + let key = document.getElementById(keyId); + if (!key) { + continue; + } + button.setAttribute("shortcut", ShortcutUtils.prettifyShortcut(key)); + } + }, + + folderViewMenuOnCommand(event) { + let about3Pane = document.getElementById("tabmail").currentAbout3Pane; + if (!about3Pane) { + return; + } + + let mode = event.target.getAttribute("value"); + if (mode == "toggle-header") { + about3Pane.folderPane.toggleHeader(event.target.hasAttribute("checked")); + return; + } + + let activeModes = about3Pane.folderPane.activeModes; + let index = activeModes.indexOf(mode); + if (event.target.hasAttribute("checked")) { + if (index == -1) { + activeModes.push(mode); + } + } else if (index >= 0) { + activeModes.splice(index, 1); + } + about3Pane.folderPane.activeModes = activeModes; + + this._onFoldersViewShow({ target: event.target.parentNode }); + }, + + folderCompactMenuOnCommand(event) { + let about3Pane = document.getElementById("tabmail").currentAbout3Pane; + if (!about3Pane) { + return; + } + + about3Pane.folderPane.isCompact = event.target.hasAttribute("checked"); + }, + + setUIDensity(event) { + // Loops through all available options and uncheck them. This is necessary + // since the toolbarbuttons don't uncheck themselves even if they're radio. + for (let item of event.originalTarget + .closest(".panel-subview-body") + .querySelectorAll("toolbarbutton")) { + // Skip this item if it's the one clicked. + if (item == event.originalTarget) { + continue; + } + + item.removeAttribute("checked"); + } + // Update the UI density. + UIDensity.setMode(event.originalTarget.mode); + }, +}; + +XPCOMUtils.defineConstant(this, "PanelUI", PanelUI); + +/** + * Gets the currently selected locale for display. + * + * @returns the selected locale + */ +function getLocale() { + return Services.locale.appLocaleAsBCP47; +} + +/** + * Given a DOM node inside a <popupnotification>, return the parent <popupnotification>. + */ +function getNotificationFromElement(aElement) { + return aElement.closest("popupnotification"); +} + +/** + * This object is Thunderbird's version of the same object in + * browser/base/content/browser-addons.js. + */ +var gExtensionsNotifications = { + initialized: false, + init() { + this.updateAlerts(); + this.boundUpdate = this.updateAlerts.bind(this); + ExtensionsUI.on("change", this.boundUpdate); + this.initialized = true; + }, + + uninit() { + // uninit() can race ahead of init() in some cases, if that happens, + // we have no handler to remove. + if (!this.initialized) { + return; + } + ExtensionsUI.off("change", this.boundUpdate); + }, + + get l10n() { + if (this._l10n) { + return this._l10n; + } + return (this._l10n = new Localization( + ["messenger/addonNotifications.ftl", "branding/brand.ftl"], + true + )); + }, + + _createAddonButton(l10nId, addon, callback) { + let text = this.l10n.formatValueSync(l10nId, { addonName: addon.name }); + let button = document.createXULElement("toolbarbutton"); + button.setAttribute("wrap", "true"); + button.setAttribute("label", text); + button.setAttribute("tooltiptext", text); + const DEFAULT_EXTENSION_ICON = + "chrome://messenger/skin/icons/new/compact/extension.svg"; + button.setAttribute("image", addon.iconURL || DEFAULT_EXTENSION_ICON); + button.className = "addon-banner-item subviewbutton"; + + button.addEventListener("command", callback); + PanelUI.addonNotificationContainer.appendChild(button); + }, + + updateAlerts() { + let gBrowser = document.getElementById("tabmail"); + let sideloaded = ExtensionsUI.sideloaded; + let updates = ExtensionsUI.updates; + + let container = PanelUI.addonNotificationContainer; + + while (container.firstChild) { + container.firstChild.remove(); + } + + let items = 0; + for (let update of updates) { + if (++items > 4) { + break; + } + this._createAddonButton( + "webext-perms-update-menu-item", + update.addon, + evt => { + ExtensionsUI.showUpdate(gBrowser, update); + } + ); + } + + for (let addon of sideloaded) { + if (++items > 4) { + break; + } + this._createAddonButton("webext-perms-sideload-menu-item", addon, evt => { + // We need to hide the main menu manually because the toolbarbutton is + // removed immediately while processing this event, and PanelUI is + // unable to identify which panel should be closed automatically. + PanelUI.hide(); + ExtensionsUI.showSideloaded(gBrowser, addon); + }); + } + }, +}; + +addEventListener("unload", () => gExtensionsNotifications.uninit(), { + once: true, +}); diff --git a/comm/mail/components/customizableui/moz.build b/comm/mail/components/customizableui/moz.build new file mode 100644 index 0000000000..4bc53e73ea --- /dev/null +++ b/comm/mail/components/customizableui/moz.build @@ -0,0 +1,14 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DIRS += [ + "content", +] + +EXTRA_JS_MODULES += [ + "CustomizableUI.sys.mjs", + "PanelMultiView.sys.mjs", +] |