/* 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/spacesToolbar.js */ /* import-globals-from ../../../base/content/utilityOverlay.js */ import { storeState, getState, } from "resource:///modules/CustomizationState.mjs"; import "./unified-toolbar-tab.mjs"; // eslint-disable-line import/no-unassigned-import import "./unified-toolbar-customization-pane.mjs"; // eslint-disable-line import/no-unassigned-import import { BUTTON_STYLE_MAP, BUTTON_STYLE_PREF, } from "resource:///modules/ButtonStyle.mjs"; /** * Set of names of the built in spaces. * * @type {Set} */ const BUILTIN_SPACES = new Set([ "mail", "addressbook", "calendar", "tasks", "chat", "settings", ]); /** * Customization palette container for the unified toolbar. Contained in a * custom element for state management. When visible, the document should have * the customizingUnifiedToolbar class. * Template: #unifiedToolbarCustomizationTemplate. */ class UnifiedToolbarCustomization extends HTMLElement { /** * Reference to the container where the space tabs go in. The tab panels will * be placed after this element. * * @type {?HTMLDivElement} */ #tabList = null; #buttonStyle = null; connectedCallback() { if (this.hasConnected) { return; } this.hasConnected = true; const template = document .getElementById("unifiedToolbarCustomizationTemplate") .content.cloneNode(true); const form = template.querySelector("form"); form.addEventListener( "submit", event => { event.preventDefault(); this.#save(); }, { passive: false, } ); form.addEventListener("reset", event => { this.#reset(); }); template .querySelector("#unifiedToolbarCustomizationCancel") .addEventListener("click", () => { this.toggle(false); }); this.#buttonStyle = template.querySelector("#buttonStyle"); this.#buttonStyle.addEventListener("change", this.#handleButtonStyleChange); this.addEventListener("itemchange", this.#handleItemChange, { capture: true, }); this.addEventListener("additem", this.#handleAddItem, { capture: true, }); this.addEventListener("removeitem", this.#handleRemoveItem, { capture: true, }); this.#tabList = template.querySelector("#customizationTabs"); this.#tabList.addEventListener("tabswitch", this.#handleTabSwitch, { capture: true, }); template .querySelector("#customizationToSettingsButton") .addEventListener("click", this.#handleSettingsButton); this.initialize(); this.append(template); this.#updateResetToDefault(); this.addEventListener("keyup", this.#handleKeyboard); this.addEventListener("keyup", this.#closeByKeyboard); this.addEventListener("keypress", this.#handleKeyboard); this.addEventListener("keydown", this.#handleKeyboard); } #handleItemChange = event => { event.stopPropagation(); this.#updateResetToDefault(); this.#updateUnsavedChangesState(); }; #handleTabSwitch = event => { event.stopPropagation(); this.#updateUnsavedChangesState(); }; #handleButtonStyleChange = event => { for (const pane of this.querySelectorAll( "unified-toolbar-customization-pane" )) { pane.updateButtonStyle(event.target.value); } this.#updateUnsavedChangesState(); }; #handleSettingsButton = event => { event.preventDefault(); openPreferencesTab("paneGeneral", "layoutGroup"); this.toggle(false); }; #handleAddItem = event => { event.stopPropagation(); const tabPanes = Array.from( this.querySelectorAll("unified-toolbar-customization-pane") ); for (const pane of tabPanes) { pane.addItem(event.detail.itemId); } }; #handleRemoveItem = event => { event.stopPropagation(); const tabPanes = Array.from( this.querySelectorAll("unified-toolbar-customization-pane") ); for (const pane of tabPanes) { pane.removeItem(event.detail.itemId); } }; /** * Close the customisation pane when Escape is released * * @param {KeyboardEvent} event - The keyboard event */ #closeByKeyboard = event => { if (event.key == "Escape") { event.preventDefault(); this.toggle(false); } }; /** * Ensure keyboard events are not propagated outside the customization dialog. * * @param {KeyboardEvent} event - The keyboard event. */ #handleKeyboard = event => { event.stopPropagation(); }; /** * Update state of reset to default button. */ #updateResetToDefault() { const tabPanes = Array.from( this.querySelectorAll("unified-toolbar-customization-pane") ); const isDefault = tabPanes.every(pane => pane.matchesDefaultState); this.querySelector('button[type="reset"]').disabled = isDefault; } #updateUnsavedChangesState() { const tabPanes = Array.from( this.querySelectorAll("unified-toolbar-customization-pane") ); const unsavedChanges = tabPanes.some(tabPane => tabPane.hasChanges) || this.#buttonStyle.value != BUTTON_STYLE_MAP[Services.prefs.getIntPref(BUTTON_STYLE_PREF, 0)]; const otherSpacesHaveUnsavedChanges = unsavedChanges && tabPanes.some(tabPane => tabPane.hidden && tabPane.hasChanges); this.querySelector('button[type="submit"]').disabled = !unsavedChanges; document.getElementById( "unifiedToolbarCustomizationUnsavedChanges" ).hidden = !otherSpacesHaveUnsavedChanges; } /** * Generate a tab and tab pane that are linked together for the given space. * If the space is the current space, the tab is marked as active. * * @param {SpaceInfo} space * @returns {{tab: UnifiedToolbarTab, tabPane: UnifiedToolbarCustomizationPane}} */ #makeSpaceTab(space) { const activeSpace = space === gSpacesToolbar.currentSpace; const tabId = `unified-toolbar-customization-tab-${space.name}`; const paneId = `unified-toolbar-customization-pane-${space.name}`; const tab = document.createElement("unified-toolbar-tab"); tab.id = tabId; tab.setAttribute("aria-controls", paneId); if (activeSpace) { tab.setAttribute("selected", true); } const isBuiltinSpace = BUILTIN_SPACES.has(space.name); if (isBuiltinSpace) { document.l10n.setAttributes(tab, `customize-space-tab-${space.name}`); } else { const title = space.button.title; tab.textContent = title; tab.title = title; tab.style = space.button.querySelector("img").style.cssText; } const tabPane = document.createElement( "unified-toolbar-customization-pane" ); tabPane.id = paneId; tabPane.setAttribute("space", space.name); tabPane.setAttribute("aria-labelledby", tabId); tabPane.toggleAttribute("builtin-space", isBuiltinSpace); tabPane.hidden = !activeSpace; return { tab, tabPane }; } /** * Reset all the spaces to their default customization state. */ #reset() { const tabPanes = Array.from( this.querySelectorAll("unified-toolbar-customization-pane") ); for (const pane of tabPanes) { pane.reset(); } } /** * Save the current state of the toolbar and hide the customization. */ #save() { const tabPanes = Array.from( this.querySelectorAll("unified-toolbar-customization-pane") ); const state = Object.fromEntries( tabPanes .filter(pane => !pane.matchesDefaultState) .map(pane => [pane.getAttribute("space"), pane.itemIds]) ); Services.prefs.setIntPref( BUTTON_STYLE_PREF, BUTTON_STYLE_MAP.indexOf(this.#buttonStyle.value) ); // Toggle happens before saving, so the newly restored buttons don't have to // be updated when the globalOverlay flag on tabmail goes away. this.toggle(false); storeState(state); } /** * Initialize the contents of this from the current state. Specifically makes * sure all the spaces have a tab, and all tabs still have a space. * * @param {boolean} [deep = false] - If true calls initialize on all tab * panes. */ initialize(deep = false) { const state = getState(); const existingTabs = Array.from(this.#tabList.children); const tabSpaces = existingTabs.map(tab => tab.id.split("-").pop()); const spaceNames = new Set(gSpacesToolbar.spaces.map(space => space.name)); const removedTabs = existingTabs.filter( (tab, index) => !spaceNames.has(tabSpaces[index]) ); for (const tab of removedTabs) { tab.pane.remove(); tab.remove(); } const newTabs = gSpacesToolbar.spaces.map(space => { if (tabSpaces.includes(space.name)) { const tab = existingTabs[tabSpaces.indexOf(space.name)]; if (!BUILTIN_SPACES.has(space.name)) { const title = space.button.title; tab.textContent = title; tab.title = title; tab.style = space.button.querySelector("img").style.cssText; } return [tab, tab.pane]; } const { tab, tabPane } = this.#makeSpaceTab(space); return [tab, tabPane]; }); this.#tabList.replaceChildren(...newTabs.map(([tab]) => tab)); let previousNode = this.#tabList; for (const [, tabPane] of newTabs) { previousNode.after(tabPane); const space = tabPane.getAttribute("space"); if (state.hasOwnProperty(space)) { tabPane.setAttribute("current-items", state[space].join(",")); } else { tabPane.removeAttribute("current-items"); } previousNode = tabPane; if (deep) { tabPane.initialize(deep); } } this.#buttonStyle.value = BUTTON_STYLE_MAP[Services.prefs.getIntPref(BUTTON_STYLE_PREF, 0)]; // Update state of reset to default button only when updating tab panes too. if (deep) { this.#updateResetToDefault(); this.#updateUnsavedChangesState(); } } /** * Toggle unified toolbar customization. * * @param {boolean} [visible] - If passed, defines if customization should * be active. */ toggle(visible) { if (visible) { this.initialize(true); let tabToSelect; if (gSpacesToolbar.currentSpace) { tabToSelect = document.getElementById( `unified-toolbar-customization-tab-${gSpacesToolbar.currentSpace.name}` ); } if ( !tabToSelect && !this.querySelector(`unified-toolbar-tab[selected="true"]`) ) { tabToSelect = this.querySelector("unified-toolbar-tab"); } if (tabToSelect) { tabToSelect.select(); } } document.getElementById("tabmail").globalOverlay = visible; document.documentElement.classList.toggle( "customizingUnifiedToolbar", visible ); // Make sure focus is where it belongs. if (visible) { if ( document.activeElement !== this && !this.contains(document.activeElement) ) { Services.focus.moveFocus( window, this, Services.focus.MOVEFOCUS_FIRST, 0 ); } } else { Services.focus.moveFocus( window, document.body, Services.focus.MOVEFOCUS_ROOT, 0 ); } } /** * Check if an item is active in all spaces. * * @param {string} itemId - Item ID of the item to check for. * @returns {boolean} If the given item is found active in all spaces. */ activeInAllSpaces(itemId) { return Array.from( this.querySelectorAll("unified-toolbar-customization-pane"), pane => pane.hasItem(itemId) ).every(hasItem => hasItem); } /** * Check if an item is active in two or more spaces. * * @param {string} itemId - Item ID of the item to check for. * @returns {boolean} If the given item is active in at least two spaces. */ activeInMultipleSpaces(itemId) { return ( Array.from( this.querySelectorAll("unified-toolbar-customization-pane"), pane => pane.hasItem(itemId) ).filter(Boolean).length > 1 ); } } customElements.define( "unified-toolbar-customization", UnifiedToolbarCustomization );