diff options
Diffstat (limited to 'comm/mail/base/content/spacesToolbar.js')
-rw-r--r-- | comm/mail/base/content/spacesToolbar.js | 1325 |
1 files changed, 1325 insertions, 0 deletions
diff --git a/comm/mail/base/content/spacesToolbar.js b/comm/mail/base/content/spacesToolbar.js new file mode 100644 index 0000000000..4155d7d0dc --- /dev/null +++ b/comm/mail/base/content/spacesToolbar.js @@ -0,0 +1,1325 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* import-globals-from mailCore.js */ +/* import-globals-from utilityOverlay.js */ + +/** + * Special vertical toolbar to organize all the buttons opening a tab. + */ +var gSpacesToolbar = { + SUPPORTED_BADGE_STYLES: ["--spaces-button-badge-bg-color"], + SUPPORTED_ICON_STYLES: [ + "--webextension-toolbar-image", + "--webextension-toolbar-image-dark", + "--webextension-toolbar-image-light", + "--webextension-toolbar-image-2x", + "--webextension-toolbar-image-2x-dark", + "--webextension-toolbar-image-2x-light", + ], + docURL: "chrome://messenger/content/messenger.xhtml", + /** + * The spaces toolbar DOM element. + * + * @type {?HTMLElement} + */ + element: null, + /** + * If the spaces toolbar has already been loaded. + * + * @type {boolean} + */ + isLoaded: false, + /** + * If the spaces toolbar is hidden or visible. + * + * @type {boolean} + */ + isHidden: false, + /** + * If the spaces toolbar is currently being customized. + * + * @type {boolean} + */ + isCustomizing: false, + /** + * The DOM element panel collecting all customization options. + */ + customizePanel: null, + /** + * The object storing all saved customization options: + * - background: The toolbar background color. + * - color: The default icon color of the buttons. + * - accentColor: The background color of an active/current button. + * - accentBackground: The icon color of an active/current button. + */ + customizeData: {}, + + /** + * @callback TabInSpace + * @param {object} tabInfo - The tabInfo object (a member of tabmail.tabInfo) + * for the tab. + * @returns {0|1|2} - The relation between the tab and the space. 0 means it + * does not belong to the space. 1 means it is a primary tab of the space. + * 2 means it is a secondary tab of the space. + */ + /** + * @callback OpenSpace + * @param {"tab"|"window"} where - Where to open the space: in a new tab or in + * a new window. + * @returns {?object | Window} - The tabInfo for the newly opened tab, or the + * newly opened messenger window, or null if neither was created. + */ + /** + * Data and methods for a space. + * + * @typedef {object} SpaceInfo + * @property {string} name - The name for this space. + * @property {boolean} allowMultipleTabs - Whether to allow the user to open + * multiple tabs in this space. + * @property {HTMLButtonElement} button - The toolbar button for this space. + * @property {?XULMenuItem} menuitem - The menuitem for this space, if + * available. + * @property {TabInSpace} tabInSpace - A callback that determines whether an + * existing tab is considered outside this space, a primary tab of this + * space (a tab that is similar to the tab created by the open method) or + * a secondary tab of this space (a related tab that still belongs to this + * space). + * @property {OpenSpace} open - A callback to open this space. + */ + /** + * The main spaces in this toolbar. This will be constructed on load. + * + * @type {?SpaceInfo[]} + */ + spaces: null, + /** + * The current space the window is in, or undefined if it is not in any of the + * known spaces. + * + * @type {SpaceInfo|undefined} + */ + currentSpace: undefined, + /** + * The number of buttons created by add-ons. + * + * @returns {integer} + */ + get addonButtonCount() { + return document.querySelectorAll(".spaces-addon-button").length; + }, + /** + * The number of pixel spacing to add to the add-ons button height calculation + * based on the current UI density. + * + * @type {integer} + */ + densitySpacing: 0, + /** + * The button that can receive focus. Used for managing focus with a roving + * tabindex. + * + * @type {?HTMLElement} + */ + focusButton: null, + + tabMonitor: { + monitorName: "spacesToolbarMonitor", + + onTabTitleChanged() {}, + onTabOpened() {}, + onTabPersist() {}, + onTabRestored() {}, + onTabClosing() {}, + + onTabSwitched(newTabInfo, oldTabInfo) { + // Bail out if for whatever reason something went wrong. + if (!newTabInfo) { + console.error( + "Spaces Toolbar: Missing new tab on monitored tab switching" + ); + return; + } + + let tabSpace = gSpacesToolbar.spaces.find(space => + space.tabInSpace(newTabInfo) + ); + if (gSpacesToolbar.currentSpace != tabSpace) { + gSpacesToolbar.currentSpace?.button.classList.remove("current"); + gSpacesToolbar.currentSpace?.menuitem?.classList.remove("current"); + gSpacesToolbar.currentSpace = tabSpace; + if (gSpacesToolbar.currentSpace) { + gSpacesToolbar.currentSpace.button.classList.add("current"); + gSpacesToolbar.currentSpace.menuitem?.classList.add("current"); + gSpacesToolbar.setFocusButton(gSpacesToolbar.currentSpace.button); + } + + const spaceChangeEvent = new CustomEvent("spacechange", { + detail: tabSpace, + }); + gSpacesToolbar.element.dispatchEvent(spaceChangeEvent); + } + }, + }, + + /** + * Convert an rgb() string to an hexadecimal color string. + * + * @param {string} color - The RGBA color string that needs conversion. + * @returns {string} - The converted hexadecimal color. + */ + _rgbToHex(color) { + let rgb = color.split("(")[1].split(")")[0].split(","); + + // For each array element convert ot a base16 string and add zero if we get + // only one character. + let hash = rgb.map(x => parseInt(x).toString(16).padStart(2, "0")); + + return `#${hash.join("")}`; + }, + + onLoad() { + if (this.isLoaded) { + return; + } + + this.element = document.getElementById("spacesToolbar"); + this.focusButton = document.getElementById("mailButton"); + let tabmail = document.getElementById("tabmail"); + + this.spaces = [ + { + name: "mail", + button: document.getElementById("mailButton"), + menuitem: document.getElementById("spacesPopupButtonMail"), + tabInSpace(tabInfo) { + switch (tabInfo.mode.name) { + case "folder": + case "mail3PaneTab": + case "mailMessageTab": + return 1; + default: + return 0; + } + }, + open(where) { + // Prefer the current tab, else the earliest tab. + let existingTab = [tabmail.currentTabInfo, ...tabmail.tabInfo].find( + tabInfo => this.tabInSpace(tabInfo) == 1 + ); + let folderURI = null; + switch (existingTab?.mode.name) { + case "folder": + folderURI = + existingTab.folderDisplay.displayedFolder?.URI || null; + break; + case "mail3PaneTab": + folderURI = existingTab.folder.URI || null; + break; + } + if (where == "window") { + return window.openDialog( + "chrome://messenger/content/messenger.xhtml", + "_blank", + "chrome,dialog=no,all", + folderURI, + -1 + ); + } + return openTab("mail3PaneTab", { folderURI }, "tab"); + }, + allowMultipleTabs: true, + }, + { + name: "addressbook", + button: document.getElementById("addressBookButton"), + menuitem: document.getElementById("spacesPopupButtonAddressBook"), + tabInSpace(tabInfo) { + if (tabInfo.mode.name == "addressBookTab") { + return 1; + } + return 0; + }, + open(where) { + return openTab("addressBookTab", {}, where); + }, + }, + { + name: "calendar", + button: document.getElementById("calendarButton"), + menuitem: document.getElementById("spacesPopupButtonCalendar"), + tabInSpace(tabInfo) { + return tabInfo.mode.name == "calendar" ? 1 : 0; + }, + open(where) { + return openTab("calendar", {}, where); + }, + }, + { + name: "tasks", + button: document.getElementById("tasksButton"), + menuitem: document.getElementById("spacesPopupButtonTasks"), + tabInSpace(tabInfo) { + return tabInfo.mode.name == "tasks" ? 1 : 0; + }, + open(where) { + return openTab("tasks", {}, where); + }, + }, + { + name: "chat", + button: document.getElementById("chatButton"), + menuitem: document.getElementById("spacesPopupButtonChat"), + tabInSpace(tabInfo) { + return tabInfo.mode.name == "chat" ? 1 : 0; + }, + open(where) { + return openTab("chat", {}, where); + }, + }, + { + name: "settings", + button: document.getElementById("settingsButton"), + menuitem: document.getElementById("spacesPopupButtonSettings"), + tabInSpace(tabInfo) { + switch (tabInfo.mode.name) { + case "preferencesTab": + // A primary tab that the open method creates. + return 1; + case "contentTab": + let url = tabInfo.urlbar?.value; + if (url == "about:accountsettings" || url == "about:addons") { + // A secondary tab, that is related to this space. + return 2; + } + } + return 0; + }, + open(where) { + return openTab("preferencesTab", {}, where); + }, + }, + ]; + + this.setupEventListeners(); + this.toggleToolbar( + Services.xulStore.getValue(this.docURL, "spacesToolbar", "hidden") == + "true" + ); + + // The tab monitor will inform us when a different tab is selected. + tabmail.registerTabMonitor(this.tabMonitor); + + this.customizePanel = document.getElementById( + "spacesToolbarCustomizationPanel" + ); + this.loadCustomization(); + + this.isLoaded = true; + window.dispatchEvent(new CustomEvent("spaces-toolbar-ready")); + // Update the window UI after the spaces toolbar has been loaded. + this.updateUI(); + }, + + setupEventListeners() { + this.element.addEventListener("contextmenu", event => + this._showContextMenu(event) + ); + this.element.addEventListener("keydown", event => { + this._onSpacesToolbarKeyDown(event); + }); + + // Prevent buttons from stealing the focus on click since the focus is + // handled when a specific tab is opened or switched to. + for (let button of document.querySelectorAll(".spaces-toolbar-button")) { + button.onmousedown = event => event.preventDefault(); + } + + let tabmail = document.getElementById("tabmail"); + let contextMenu = document.getElementById("spacesContextMenu"); + let newTabItem = document.getElementById("spacesContextNewTabItem"); + let newWindowItem = document.getElementById("spacesContextNewWindowItem"); + let separator = document.getElementById("spacesContextMenuSeparator"); + + // The space that we (last) opened the context menu for, which we share + // between methods. + let contextSpace; + newTabItem.addEventListener("command", () => contextSpace.open("tab")); + newWindowItem.addEventListener("command", () => + contextSpace.open("window") + ); + + let settingsContextMenu = document.getElementById("settingsContextMenu"); + document + .getElementById("settingsContextOpenSettingsItem") + .addEventListener("command", () => openTab("preferencesTab", {})); + document + .getElementById("settingsContextOpenAccountSettingsItem") + .addEventListener("command", () => + openTab("contentTab", { url: "about:accountsettings" }) + ); + document + .getElementById("settingsContextOpenAddonsItem") + .addEventListener("command", () => + openTab("contentTab", { url: "about:addons" }) + ); + document + .getElementById("settingsContextOpenCustomizeItem") + .addEventListener("command", () => this.showCustomize()); + + for (let space of this.spaces) { + this._addButtonClickListener(space.button, () => { + this.openSpace(tabmail, space); + }); + space.menuitem?.addEventListener("command", () => { + this.openSpace(tabmail, space); + }); + if (space.name == "settings") { + space.button.addEventListener("contextmenu", event => { + event.stopPropagation(); + settingsContextMenu.openPopupAtScreen( + event.screenX, + event.screenY, + true, + event + ); + }); + continue; + } + space.button.addEventListener("contextmenu", event => { + event.stopPropagation(); + contextSpace = space; + // Clean up old items. + for (let menuitem of contextMenu.querySelectorAll(".switch-to-tab")) { + menuitem.remove(); + } + + let existingTabs = tabmail.tabInfo.filter(space.tabInSpace); + // Show opening in new tab if no existing tabs or can open multiple. + // NOTE: We always show at least one item: either the switch to tab + // items, or the new tab item. + newTabItem.hidden = !!existingTabs.length && !space.allowMultipleTabs; + newWindowItem.hidden = !space.allowMultipleTabs; + + for (let tabInfo of existingTabs) { + let menuitem = document.createXULElement("menuitem"); + document.l10n.setAttributes( + menuitem, + "spaces-context-switch-tab-item", + { tabName: tabInfo.title } + ); + menuitem.classList.add("switch-to-tab", "menuitem-iconic"); + menuitem.addEventListener("command", () => + tabmail.switchToTab(tabInfo) + ); + contextMenu.appendChild(menuitem); + } + // The separator splits the "Open in new tab" and "Open in new window" + // items from the switch-to-tab items. Only show separator if there + // are non-hidden items on both sides. + separator.hidden = !existingTabs.length || !space.allowMultipleTabs; + + contextMenu.openPopupAtScreen( + event.screenX, + event.screenY, + true, + event + ); + }); + } + + this._addButtonClickListener( + document.getElementById("collapseButton"), + () => this.toggleToolbar(true) + ); + + document + .getElementById("spacesPopupButtonReveal") + .addEventListener("command", () => { + this.toggleToolbar(false); + }); + this._addButtonClickListener( + document.getElementById("spacesToolbarAddonsOverflowButton"), + event => this.openSpacesToolbarAddonsPopup(event) + ); + + // Allow opening the pinned menu with Space or Enter keypress. + document + .getElementById("spacesPinnedButton") + .addEventListener("keypress", event => { + // Don't show the panel if the window is in customization mode. + if ( + document.getElementById("toolbar-menubar").hasAttribute("customizing") + ) { + return; + } + + if (event.key == " " || event.key == "Enter") { + let panel = document.getElementById("spacesButtonMenuPopup"); + if (panel.state == "open") { + panel.hidePopup(); + } else if (panel.state == "closed") { + panel.openPopup(event.target, "after_start"); + } + } + }); + }, + + /** + * Handle the keypress event on the spaces toolbar. + * + * @param {Event} event - The keypress DOMEvent. + */ + _onSpacesToolbarKeyDown(event) { + if ( + !["ArrowUp", "ArrowDown", "Home", "End", " ", "Enter"].includes(event.key) + ) { + return; + } + + // NOTE: Normally a button click handler would cover Enter and Space key + // events, however we need to prevent the default behavior and explicitly + // trigger the button click because in some tabs XUL keys or Window event + // listeners are attached to this keys triggering specific actions. + // TODO: Remove once we have a properly mapped global shortcut object not + // relying on XUL keys. + if (event.key == " " || event.key == "Enter") { + event.preventDefault(); + event.target.click(); + return; + } + + // Collect all currently visible buttons of the spaces toolbar. + let buttons = [ + ...document.querySelectorAll(".spaces-toolbar-button:not([hidden])"), + ]; + let elementIndex = buttons.indexOf(this.focusButton); + + // Find the adjacent focusable element based on the pressed key. + switch (event.key) { + case "ArrowUp": + elementIndex--; + if (elementIndex == -1) { + elementIndex = buttons.length - 1; + } + break; + + case "ArrowDown": + elementIndex++; + if (elementIndex > buttons.length - 1) { + elementIndex = 0; + } + break; + + case "Home": + elementIndex = 0; + break; + + case "End": + elementIndex = buttons.length - 1; + break; + } + + this.setFocusButton(buttons[elementIndex], true); + }, + + /** + * Move the focus to a new toolbar button and update the tabindex attribute. + * + * @param {HTMLElement} buttonToFocus - The new button to receive focus. + * @param {boolean} [forceFocus=false] - Whether to force the focus to move + * onto the new button, otherwise focus will only move if the previous + * focusButton had focus. + */ + setFocusButton(buttonToFocus, forceFocus = false) { + let prevHadFocus = false; + if (buttonToFocus != this.focusButton) { + prevHadFocus = document.activeElement == this.focusButton; + this.focusButton.tabIndex = -1; + this.focusButton = buttonToFocus; + buttonToFocus.tabIndex = 0; + } + // Only move the focus if the currently focused button was the active + // element. + if (forceFocus || prevHadFocus) { + buttonToFocus.focus(); + } + }, + + /** + * Add a click event listener to a spaces toolbar button. + * + * This method will insert focus controls for when the button is clicked. + * + * @param {HTMLButtonElement} button - A button that belongs to the spaces + * toolbar. + * @param {Function} listener - An event listener to call when the button's + * click event is fired. + */ + _addButtonClickListener(button, listener) { + button.addEventListener("click", event => { + // Since the button may have tabIndex = -1, we must manually move the + // focus into the button and set it as the focusButton. + // NOTE: We do *not* force the focus to move onto the button if it is not + // currently already within the spaces toolbar. This is mainly to avoid + // changing the document.activeElement before the tab's lastActiveElement + // is set in tabmail.js. + // NOTE: We do this before activating the button, which may move the focus + // elsewhere, such as into a space. + this.setFocusButton(button); + listener(event); + }); + }, + + /** + * Open a space by creating a new tab or switching to an existing tab. + * + * @param {XULElement} tabmail - The tabmail element. + * @param {SpaceInfo} space - The space to open. + */ + openSpace(tabmail, space) { + // Find the earliest primary tab that belongs to this space. + let existing = tabmail.tabInfo.find( + tabInfo => space.tabInSpace(tabInfo) == 1 + ); + if (!existing) { + return space.open("tab"); + } else if (this.currentSpace != space) { + // Only switch to the tab if it is in a different space to the + // current one. In particular, if we are in a later tab we won't + // switch to the earliest tab. + tabmail.switchToTab(existing); + return existing; + } + return tabmail.currentTabInfo; + }, + + /** + * Open a popup context menu at the location of the right on the toolbar. + * + * @param {DOMEvent} event - The click event. + */ + _showContextMenu(event) { + document + .getElementById("spacesToolbarContextMenu") + .openPopupAtScreen(event.screenX, event.screenY, true); + }, + + /** + * Load the saved customization from the xulStore, if we have any. + */ + async loadCustomization() { + let xulStore = Services.xulStore; + if (xulStore.hasValue(this.docURL, "spacesToolbar", "colors")) { + this.customizeData = JSON.parse( + xulStore.getValue(this.docURL, "spacesToolbar", "colors") + ); + this.updateCustomization(); + } + }, + + /** + * Reset the colors shown on the button colors to the default state to remove + * any previously applied custom color. + */ + _resetColorInputs() { + // Update colors with the current values. If we don't have any customization + // data, we fetch the current colors from the DOM elements. + // IMPORTANT! Always clear the onchange method before setting a new value + // since this method might be called after the popup is already opened. + let bgButton = document.getElementById("spacesBackgroundColor"); + bgButton.onchange = null; + bgButton.value = + this.customizeData.background || + this._rgbToHex(getComputedStyle(this.element).backgroundColor); + bgButton.onchange = event => { + this.customizeData.background = event.target.value; + this.updateCustomization(); + }; + + let iconButton = document.getElementById("spacesIconsColor"); + iconButton.onchange = null; + iconButton.value = + this.customizeData.color || + this._rgbToHex( + getComputedStyle( + document.querySelector(".spaces-toolbar-button:not(.current)") + ).color + ); + iconButton.onchange = event => { + this.customizeData.color = event.target.value; + this.updateCustomization(); + }; + + let accentStyle = getComputedStyle( + document.getElementById("spacesAccentPlaceholder") + ); + let accentBgButton = document.getElementById("spacesAccentBgColor"); + accentBgButton.onchange = null; + accentBgButton.value = + this.customizeData.accentBackground || + this._rgbToHex(accentStyle.backgroundColor); + accentBgButton.onchange = event => { + this.customizeData.accentBackground = event.target.value; + this.updateCustomization(); + }; + + let accentFgButton = document.getElementById("spacesAccentTextColor"); + accentFgButton.onchange = null; + accentFgButton.value = + this.customizeData.accentColor || this._rgbToHex(accentStyle.color); + accentFgButton.onchange = event => { + this.customizeData.accentColor = event.target.value; + this.updateCustomization(); + }; + }, + + /** + * Update the color buttons to reflect the current state of the toolbar UI, + * then open the customization panel. + */ + showCustomize() { + this.isCustomizing = true; + // Reset the color inputs to be sure we're showing the correct colors. + this._resetColorInputs(); + + // Since we're forcing the panel to stay open with noautohide, we need to + // listen for the Escape keypress to maintain that usability exit point. + window.addEventListener("keypress", this.onWindowKeypress); + this.customizePanel.openPopup( + document.getElementById("collapseButton"), + "end_after", + 6, + 0, + false + ); + }, + + /** + * Listen for the keypress event on the window after the customize panel was + * opened to enable the closing on Escape. + * + * @param {Event} event - The DOM Event. + */ + onWindowKeypress(event) { + if (event.key == "Escape") { + gSpacesToolbar.customizePanel.hidePopup(); + } + }, + + /** + * Close the customization panel. + */ + closeCustomize() { + this.customizePanel.hidePopup(); + }, + + /** + * Reset all event listeners and store the custom colors. + */ + onCustomizePopupHidden() { + this.isCustomizing = false; + // Always remove the keypress event listener set on opening. + window.removeEventListener("keypress", this.onWindowKeypress); + + // Save the custom colors, or delete it if we don't have any. + if (!Object.keys(this.customizeData).length) { + Services.xulStore.removeValue(this.docURL, "spacesToolbar", "colors"); + return; + } + + Services.xulStore.setValue( + this.docURL, + "spacesToolbar", + "colors", + JSON.stringify(this.customizeData) + ); + }, + + /** + * Apply the customization to the CSS file. + */ + updateCustomization() { + let data = this.customizeData; + let style = document.documentElement.style; + + // Toolbar background color. + style.setProperty("--spaces-bg-color", data.background ?? null); + // Icons color. + style.setProperty("--spaces-button-text-color", data.color ?? null); + // Icons color for current/active buttons. + style.setProperty( + "--spaces-button-active-text-color", + data.accentColor ?? null + ); + // Background color for current/active buttons. + style.setProperty( + "--spaces-button-active-bg-color", + data.accentBackground ?? null + ); + }, + + /** + * Reset all color customizations to show the user the default UI. + */ + resetColorCustomization() { + if (!matchMedia("(prefers-reduced-motion)").matches) { + // We set an event listener for the transition of any element inside the + // toolbar so we can reset the color for the buttons only after the + // toolbar and its elements reverted to their original colors. + this.element.addEventListener( + "transitionend", + () => { + this._resetColorInputs(); + }, + { + once: true, + } + ); + } + + this.customizeData = {}; + this.updateCustomization(); + + // If the user required reduced motion, the transitionend listener will not + // work. + if (matchMedia("(prefers-reduced-motion)").matches) { + this._resetColorInputs(); + } + }, + + /** + * Toggle the spaces toolbar and toolbar buttons visibility. + * + * @param {boolean} state - The visibility state to update the elements. + */ + toggleToolbar(state) { + // Prevent the visibility change state of the spaces toolbar if we're + // currently customizing it, in order to avoid weird positioning outcomes + // with the customize popup panel. + if (this.isCustomizing) { + return; + } + + this.isHidden = state; + + // The focused element, prior to toggling. + let activeElement = document.activeElement; + + let pinnedButton = document.getElementById("spacesPinnedButton"); + pinnedButton.hidden = !state; + let revealButton = document.getElementById("spacesToolbarReveal"); + revealButton.hidden = !state; + this.element.hidden = state; + + if (state && this.element.contains(activeElement)) { + // If the toolbar is being hidden and one of its child element was + // focused, move the focus to the pinned button without changing the + // focusButton attribute of this object. + pinnedButton.focus(); + } else if ( + !state && + (activeElement == pinnedButton || activeElement == revealButton) + ) { + // If the the toolbar is being shown and the focus is on the pinned or + // reveal button, move the focus to the previously focused button. + this.focusButton?.focus(); + } + + // Update the window UI after the visibility state of the spaces toolbar + // has changed. + this.updateUI(); + }, + + /** + * Toggle the spaces toolbar from a menuitem. + */ + toggleToolbarFromMenu() { + this.toggleToolbar(!this.isHidden); + }, + + /** + * Update the addons buttons and propagate toolbar visibility to a global + * attribute. + */ + updateUI() { + // Interrupt if the spaces toolbar isn't loaded yet. + if (!this.isLoaded) { + return; + } + + let density = Services.prefs.getIntPref("mail.uidensity", 1); + switch (density) { + case 0: + this.densitySpacing = 10; + break; + case 1: + this.densitySpacing = 15; + break; + case 2: + this.densitySpacing = 20; + break; + } + + // Toggle the window attribute for those CSS selectors that need it. + if (this.isHidden) { + document.documentElement.removeAttribute("spacestoolbar"); + } else { + document.documentElement.setAttribute("spacestoolbar", "true"); + this.updateAddonButtonsUI(); + } + }, + + /** + * Reset the inline style of the various titlebars and toolbars that interact + * with the spaces toolbar. + */ + resetInlineStyle() { + document.getElementById("tabmail-tabs").removeAttribute("style"); + }, + + /** + * Update the UI based on the window sizing. + */ + onWindowResize() { + if (!this.isLoaded) { + return; + } + + this.updateUImacOS(); + this.updateAddonButtonsUI(); + }, + + /** + * Update the location of buttons added by addons based on the space available + * in the toolbar. If the number of buttons is greater than the height of the + * visible container, move those buttons inside an overflow popup. + */ + updateAddonButtonsUI() { + if (this.isHidden) { + return; + } + + let overflowButton = document.getElementById( + "spacesToolbarAddonsOverflowButton" + ); + let separator = document.getElementById("spacesPopupAddonsSeparator"); + let popup = document.getElementById("spacesToolbarAddonsPopup"); + // Bail out if we don't have any add-ons button. + if (!this.addonButtonCount) { + if (this.focusButton == overflowButton) { + this.setFocusButton( + this.element.querySelector(".spaces-toolbar-button:not([hidden])") + ); + } + overflowButton.hidden = true; + separator.collapsed = true; + popup.hidePopup(); + return; + } + + separator.collapsed = false; + // Use the first available button's height as reference, and include the gap + // defined by the UIDensity pref. + let buttonHeight = + document.querySelector(".spaces-toolbar-button").getBoundingClientRect() + .height + this.densitySpacing; + + let containerHeight = document + .getElementById("spacesToolbarAddonsContainer") + .getBoundingClientRect().height; + + // Calculate the visible threshold of add-on buttons by: + // - Multiplying the space occupied by one button for the number of the + // add-on buttons currently present. + // - Subtracting the height of the add-ons container from the height + // occupied by all add-on buttons. + // - Dividing the returned value by the height of a single button. + // Doing so we will get an integer representing how many buttons might or + // might not fit in the available area. + let threshold = Math.ceil( + (buttonHeight * this.addonButtonCount - containerHeight) / buttonHeight + ); + + // Always reset the visibility of all buttons to avoid unnecessary + // calculations when needing to reveal hidden buttons. + for (let btn of document.querySelectorAll(".spaces-addon-button[hidden]")) { + btn.hidden = false; + } + + // If we get a negative threshold, it means we have plenty of empty space + // so we don't need to do anything. + if (threshold <= 0) { + // If the overflow button was the currently focused button, move the focus + // to an arbitrary first available button. + if (this.focusButton == overflowButton) { + this.setFocusButton( + this.element.querySelector(".spaces-toolbar-button:not([hidden])") + ); + } + overflowButton.hidden = true; + popup.hidePopup(); + return; + } + + overflowButton.hidden = false; + // Hide as many buttons as needed based on the threshold value. + for (let i = 0; i <= threshold; i++) { + let btn = document.querySelector( + `.spaces-addon-button:nth-last-child(${i})` + ); + if (btn) { + // If one of the hidden add-on buttons was the focused one, move the + // focus to the overflow button. + if (btn == this.focusButton) { + this.setFocusButton(overflowButton); + } + btn.hidden = true; + } + } + }, + + /** + * Update the spacesToolbar UI and adjacent tabs exclusively for macOS. This + * is necessary mostly to tackle the changes when switching fullscreen mode. + */ + updateUImacOS() { + // No need to to anything if we're not on macOS. + if (AppConstants.platform != "macosx") { + return; + } + + // Add inline styling to the tabmail tabs only if we're on macOS and the + // app is in full screen mode. + if (window.fullScreen) { + let size = this.element.getBoundingClientRect().width; + let style = `margin-inline-start: ${size}px;`; + document.getElementById("tabmail-tabs").setAttribute("style", style); + return; + } + + // Reset the style if we exited full screen mode. + this.resetInlineStyle(); + }, + + /** + * @typedef NativeButtonProperties + * @property {string} title - The text of the button tooltip and menuitem value. + * @property {string} url - The URL of the content tab to open. + * @property {Map} iconStyles - The icon styles Map. + * @property {?string} badgeText - The optional badge text. + * @property {?Map} badgeStyles - The optional badge styles Map. + */ + + /** + * Helper function for extensions in order to add buttons to the spaces + * toolbar. + * + * @param {string} id - The ID of the newly created button. + * @param {NativeButtonProperties} properties - The properties of the new button. + * + * @returns {Promise} - A Promise that resolves when the button is created. + */ + async createToolbarButton(id, properties = {}) { + return new Promise((resolve, reject) => { + if (!this.isLoaded) { + return reject("Unable to add spaces toolbar button! Toolbar not ready"); + } + if ( + !id || + !properties.title || + !properties.url || + !properties.iconStyles + ) { + return reject( + "Unable to add spaces toolbar button! Missing ID, Title, IconStyles, or space URL" + ); + } + + // Create the button. + let button = document.createElement("button"); + button.classList.add("spaces-toolbar-button", "spaces-addon-button"); + button.id = id; + button.title = properties.title; + button.tabIndex = -1; + + let badge = document.createElement("span"); + badge.classList.add("spaces-badge-container"); + button.appendChild(badge); + + let img = document.createElement("img"); + img.setAttribute("alt", ""); + button.appendChild(img); + document + .getElementById("spacesToolbarAddonsContainer") + .appendChild(button); + + // Create the menuitem. + let menuitem = document.createXULElement("menuitem"); + menuitem.classList.add( + "spaces-addon-menuitem", + "menuitem-iconic", + "spaces-popup-menuitem" + ); + menuitem.id = `${id}-menuitem`; + menuitem.label = properties.title; + document + .getElementById("spacesButtonMenuPopup") + .insertBefore( + menuitem, + document.getElementById("spacesPopupRevealSeparator") + ); + + // Set icons. The unified toolbar customization also relies on the CSS + // variables of the img. + for (let style of this.SUPPORTED_ICON_STYLES) { + if (properties.iconStyles.has(style)) { + img.style.setProperty(style, properties.iconStyles.get(style)); + menuitem.style.setProperty(style, properties.iconStyles.get(style)); + } + } + + // Add space. + gSpacesToolbar.spaces.push({ + name: id, + button, + menuitem, + url: properties.url, + isExtensionSpace: true, + tabInSpace(tabInfo) { + // TODO: Store the spaceButtonId in the XULStore (or somewhere), so the + // space is recognized after a restart. Or force closing of all spaces + // on shutdown. + return tabInfo.spaceButtonId == this.name ? 1 : 0; + }, + open(where) { + // The check if we should switch to an existing tab in this space was + // done in openSpace() and this function here should always open a new + // tab and not switch to a tab which might have loaded the same url, + // but belongs to a different space. + let tab = openTab( + "contentTab", + { url: this.url, duplicate: true }, + where + ); + tab.spaceButtonId = this.name; + // TODO: Make sure the spaceButtonId is set during load, and not here, + // where it might be too late. + gSpacesToolbar.currentSpace = this; + button.classList.add("current"); + return tab; + }, + }); + + // Set click actions. + let tabmail = document.getElementById("tabmail"); + this._addButtonClickListener(button, () => { + let space = gSpacesToolbar.spaces.find(space => space.name == id); + this.openSpace(tabmail, space); + }); + menuitem.addEventListener("command", () => { + let space = gSpacesToolbar.spaces.find(space => space.name == id); + this.openSpace(tabmail, space); + }); + + // Set badge. + if (properties.badgeText) { + button.classList.add("has-badge"); + badge.textContent = properties.badgeText; + } + + if (properties.badgeStyles) { + for (let style of this.SUPPORTED_BADGE_STYLES) { + if (properties.badgeStyles.has(style)) { + badge.style.setProperty(style, properties.badgeStyles.get(style)); + } + } + } + + this.updateAddonButtonsUI(); + return resolve(); + }); + }, + + /** + * Helper function for extensions in order to update buttons previously added + * to the spaces toolbar. + * + * @param {string} id - The ID of the button that needs to be updated. + * @param {NativeButtonProperties} properties - The new properties of the button. + * Not specifying the optional badgeText or badgeStyles will remove them. + * + * @returns {Promise} - A promise that resolves when the button is updated. + */ + async updateToolbarButton(id, properties = {}) { + return new Promise((resolve, reject) => { + if ( + !id || + !properties.title || + !properties.url || + !properties.iconStyles + ) { + return reject( + "Unable to update spaces toolbar button! Missing ID, Title, IconsStyles, or space URL" + ); + } + + let button = document.getElementById(`${id}`); + let menuitem = document.getElementById(`${id}-menuitem`); + if (!button || !menuitem) { + return reject( + "Unable to update spaces toolbar button! Button or menuitem don't exist" + ); + } + + button.title = properties.title; + menuitem.label = properties.title; + + // Update icons. + let img = button.querySelector("img"); + for (let style of this.SUPPORTED_ICON_STYLES) { + let value = properties.iconStyles.get(style); + img.style.setProperty(style, value ?? null); + menuitem.style.setProperty(style, value ?? null); + } + + // Update url. + let space = gSpacesToolbar.spaces.find(space => space.name == id); + if (space.url != properties.url) { + // TODO: Reload the space, when the url is changed (or close and re-open + // the tab). + space.url = properties.url; + } + + // Update badge. + let badge = button.querySelector(".spaces-badge-container"); + if (properties.badgeText) { + button.classList.add("has-badge"); + badge.textContent = properties.badgeText; + } else { + button.classList.remove("has-badge"); + badge.textContent = ""; + } + + for (let style of this.SUPPORTED_BADGE_STYLES) { + badge.style.setProperty( + style, + properties.badgeStyles?.get(style) ?? null + ); + } + + return resolve(); + }); + }, + + /** + * Helper function for extensions allowing the removal of previously created + * buttons. + * + * @param {string} id - The ID of the button that needs to be removed. + * @returns {Promise} - A promise that resolves when the button is removed. + */ + async removeToolbarButton(id) { + return new Promise((resolve, reject) => { + if (!this.isLoaded) { + return reject( + "Unable to remove spaces toolbar button! Toolbar not ready" + ); + } + if (!id) { + return reject("Unable to remove spaces toolbar button! Missing ID"); + } + + let button = document.getElementById(`${id}`); + // If the button being removed is the currently focused one, move the + // focus on an arbitrary first available spaces button. + if (this.focusButton == button) { + this.setFocusButton( + this.element.querySelector(".spaces-toolbar-button:not([hidden])") + ); + } + + button?.remove(); + document.getElementById(`${id}-menuitem`)?.remove(); + + let space = gSpacesToolbar.spaces.find(space => space.name == id); + let tabmail = document.getElementById("tabmail"); + let existing = tabmail.tabInfo.find( + tabInfo => space.tabInSpace(tabInfo) == 1 + ); + if (existing) { + tabmail.closeTab(existing); + } + + gSpacesToolbar.spaces = gSpacesToolbar.spaces.filter(e => e.name != id); + this.updateAddonButtonsUI(); + + return resolve(); + }); + }, + + /** + * Populate the overflow container with a copy of all the currently hidden + * buttons generated by add-ons. + * + * @param {DOMEvent} event - The DOM click event. + */ + openSpacesToolbarAddonsPopup(event) { + let popup = document.getElementById("spacesToolbarAddonsPopup"); + + for (let button of document.querySelectorAll( + ".spaces-addon-button[hidden]" + )) { + let menuitem = document.createXULElement("menuitem"); + menuitem.classList.add("menuitem-iconic", "spaces-popup-menuitem"); + menuitem.label = button.title; + + let img = button.querySelector("img"); + for (let style of this.SUPPORTED_ICON_STYLES) { + menuitem.style.setProperty( + style, + img.style.getPropertyValue(style) ?? null + ); + } + + menuitem.addEventListener("command", () => button.click()); + popup.appendChild(menuitem); + } + + popup.openPopup(event.target, "after_start", 0, 0); + }, + + /** + * Empty the overflow container. + */ + spacesToolbarAddonsPopupClosed() { + document.getElementById("spacesToolbarAddonsPopup").replaceChildren(); + }, + + /** + * Copy the badges from the contained menu items to the pinned button. + * Should be called whenever one of the menu item's badge state changes. + */ + updatePinnedBadgeState() { + let hasBadge = Boolean( + document.querySelector("#spacesButtonMenuPopup .has-badge") + ); + let spacesPinnedButton = document.getElementById("spacesPinnedButton"); + spacesPinnedButton.classList.toggle("has-badge", hasBadge); + }, + + /** + * Save the preferred state when the app is closed. + */ + onUnload() { + Services.xulStore.setValue( + this.docURL, + "spacesToolbar", + "hidden", + this.isHidden + ); + }, +}; |