/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; import { SearchWidgetTracker } from "resource:///modules/SearchWidgetTracker.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { AddonManager: "resource://gre/modules/AddonManager.sys.mjs", AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.sys.mjs", CustomizableWidgets: "resource:///modules/CustomizableWidgets.sys.mjs", HomePage: "resource:///modules/HomePage.sys.mjs", PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "gWidgetsBundle", function () { const kUrl = "chrome://browser/locale/customizableui/customizableWidgets.properties"; return Services.strings.createBundle(kUrl); }); const kDefaultThemeID = "default-theme@mozilla.org"; const kSpecialWidgetPfx = "customizableui-special-"; const kPrefCustomizationState = "browser.uiCustomization.state"; const kPrefCustomizationHorizontalTabstrip = "browser.uiCustomization.horizontalTabstrip"; const kPrefCustomizationHorizontalTabsBackup = "browser.uiCustomization.horizontalTabsBackup"; const kPrefCustomizationNavBarWhenVerticalTabs = "browser.uiCustomization.navBarWhenVerticalTabs"; const kPrefCustomizationAutoAdd = "browser.uiCustomization.autoAdd"; const kPrefCustomizationDebug = "browser.uiCustomization.debug"; const kPrefDrawInTitlebar = "browser.tabs.inTitlebar"; const kPrefUIDensity = "browser.uidensity"; const kPrefAutoTouchMode = "browser.touchmode.auto"; const kPrefAutoHideDownloadsButton = "browser.download.autohideButton"; const kPrefProtonToolbarVersion = "browser.proton.toolbar.version"; const kPrefHomeButtonUsed = "browser.engagement.home-button.has-used"; const kPrefLibraryButtonUsed = "browser.engagement.library-button.has-used"; const kPrefSidebarButtonUsed = "browser.engagement.sidebar-button.has-used"; const kPrefSidebarRevampEnabled = "sidebar.revamp"; const kPrefSidebarVerticalTabsEnabled = "sidebar.verticalTabs"; const kPrefSidebarPositionStartEnabled = "sidebar.position_start"; const kExpectedWindowURL = AppConstants.BROWSER_CHROME_URL; var gDefaultTheme; var gSelectedTheme; /** * The keys are the handlers that are fired when the event type (the value) * is fired on the subview. A widget that provides a subview has the option * of providing onViewShowing and onViewHiding event handlers. */ const kSubviewEvents = ["ViewShowing", "ViewHiding"]; /** * The current version. We can use this to auto-add new default widgets as necessary. * (would be const but isn't because of testing purposes) */ var kVersion = 22; /** * Buttons removed from built-ins by version they were removed. kVersion must be * bumped any time a new id is added to this. Use the button id as key, and * version the button is removed in as the value. e.g. "pocket-button": 5 */ var ObsoleteBuiltinButtons = { "feed-button": 15, }; /** * gPalette is a map of every widget that CustomizableUI.sys.mjs knows about, keyed * on their IDs. */ var gPalette = new Map(); /** * gAreas maps area IDs to Sets of properties about those areas. An area is a * place where a widget can be put. */ var gAreas = new Map(); /** * gPlacements maps area IDs to Arrays of widget IDs, indicating that the widgets * are placed within that area (either directly in the area node, or in the * customizationTarget of the node). */ var gPlacements = new Map(); /** * gFuturePlacements represent placements that will happen for areas that have * not yet loaded (due to lazy-loading). This can occur when add-ons register * widgets. */ var gFuturePlacements = new Map(); var gSupportedWidgetTypes = new Set([ // A button that does a command. "button", // A button that opens a view in a panel (or in a subview of the panel). "view", // A combination of the above, which looks different depending on whether it's // located in the toolbar or in the panel: When located in the toolbar, shown // as a combined item of a button and a dropmarker button. The button triggers // the command and the dropmarker button opens the view. When located in the // panel, shown as one item which opens the view, and the button command // cannot be triggered separately. "button-and-view", // A custom widget that defines its own markup. "custom", ]); /** * gPanelsForWindow is a list of known panels in a window which we may need to close * should command events fire which target them. * * @type {WeakMap>} */ var gPanelsForWindow = new WeakMap(); /** * gSeenWidgets remembers which widgets the user has seen for the first time * before. This way, if a new widget is created, and the user has not seen it * before, it can be put in its default location. Otherwise, it remains in the * palette. */ var gSeenWidgets = new Set(); /** * gDirtyAreaCache is a set of area IDs for areas where items have been added, * moved or removed at least once. This set is persisted, and is used to * optimize building of toolbars in the default case where no toolbars should * be "dirty". */ var gDirtyAreaCache = new Set(); /** * gPendingBuildAreas is a map from area IDs to map from build nodes to their * existing children at the time of node registration, that are waiting * for the area to be registered */ var gPendingBuildAreas = new Map(); var gSavedState = null; var gRestoring = false; var gDirty = false; var gInBatchStack = 0; var gResetting = false; var gUndoResetting = false; /** * gBuildAreas maps area IDs to actual area nodes within browser windows. */ var gBuildAreas = new Map(); /** * gBuildWindows is a map of windows that have registered build areas, mapped * to a Set of known toolboxes in that window. */ var gBuildWindows = new Map(); var gNewElementCount = 0; var gGroupWrapperCache = new Map(); var gSingleWrapperCache = new WeakMap(); var gListeners = new Set(); var gUIStateBeforeReset = { uiCustomizationState: null, drawInTitlebar: null, currentTheme: null, uiDensity: null, autoTouchMode: null, sidebarPositionStart: null, }; /* * The current tab orientation: initially null until initialization, * true for vertical, false for horizontal */ var gCurrentVerticalTabs = null; XPCOMUtils.defineLazyPreferenceGetter( lazy, "gDebuggingEnabled", kPrefCustomizationDebug, false, (pref, oldVal, newVal) => { if (typeof lazy.log != "undefined") { lazy.log.maxLogLevel = newVal ? "all" : "log"; } } ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "resetPBMToolbarButtonEnabled", "browser.privatebrowsing.resetPBM.enabled", false ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "sidebarRevampEnabled", "sidebar.revamp", false, (pref, oldVal, newVal) => { if (!newVal) { return; } let navbarPlacements = CustomizableUI.getWidgetIdsInArea( CustomizableUI.AREA_NAVBAR ); if (!navbarPlacements.includes("sidebar-button")) { CustomizableUI.addWidgetToArea( "sidebar-button", CustomizableUI.AREA_NAVBAR, Services.prefs.getBoolPref(kPrefSidebarPositionStartEnabled, true) ? 0 : undefined // Adds to the end of navbar if position_start is false. ); } } ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "verticalTabsPref", "sidebar.verticalTabs", false, (pref, oldVal, newVal) => { lazy.log.debug( `sidebar.verticalTabs change handler, calling updateTabStripOrientation with value: ${newVal}, gCurrentVerticalTabs: ${gCurrentVerticalTabs}` ); CustomizableUIInternal.updateTabStripOrientation(); } ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "horizontalPlacementsPref", kPrefCustomizationHorizontalTabstrip, "" ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "verticalPlacementsPref", kPrefCustomizationNavBarWhenVerticalTabs, "" ); ChromeUtils.defineLazyGetter(lazy, "log", () => { let { ConsoleAPI } = ChromeUtils.importESModule( "resource://gre/modules/Console.sys.mjs" ); let consoleOptions = { maxLogLevel: lazy.gDebuggingEnabled ? "all" : "log", prefix: "CustomizableUI", }; return new ConsoleAPI(consoleOptions); }); /** * This is the internal, private implementation of most of the CustomizableUI * API. This is intentionally not exported, but is instead called directly * from within this module via the exported CustomizableUI object, which * allows us to get a type of encapsulation. */ var CustomizableUIInternal = { /** * Main entrypoint to initializing the CustomizableUI singleton. This is * called once the very first time this module is evaluated anywhere, via a * a call at the very end of this module file. * * This sets up observers, registers built-in widgets, and loads the saved * customization state from preferences, and performs any migration on that * loaded state. */ initialize() { lazy.log.debug("Initializing"); lazy.AddonManagerPrivate.databaseReady.then(async () => { lazy.AddonManager.addAddonListener(this); let addons = await lazy.AddonManager.getAddonsByTypes(["theme"]); gDefaultTheme = addons.find(addon => addon.id == kDefaultThemeID); gSelectedTheme = addons.find(addon => addon.isActive) || gDefaultTheme; }); this.addListener(this); this.defineBuiltInWidgets(); this.loadSavedState(); this.updateForNewVersion(); this.updateForNewProtonVersion(); this.markObsoleteBuiltinButtonsSeen(); this.registerArea( CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, { type: CustomizableUI.TYPE_PANEL, defaultPlacements: [], anchor: "nav-bar-overflow-button", }, true ); this.registerArea( CustomizableUI.AREA_ADDONS, { type: CustomizableUI.TYPE_PANEL, defaultPlacements: [], anchor: "unified-extensions-button", }, false ); let navbarPlacements = [ lazy.sidebarRevampEnabled ? "sidebar-button" : null, "back-button", "forward-button", "stop-reload-button", Services.policies.isAllowed("removeHomeButtonByDefault") ? null : "home-button", "spring", "vertical-spacer", "urlbar-container", "spring", "save-to-pocket-button", "downloads-button", AppConstants.MOZ_DEV_EDITION ? "developer-button" : null, "fxa-toolbar-menu-button", lazy.resetPBMToolbarButtonEnabled ? "reset-pbm-toolbar-button" : null, ].filter(name => name); this.registerArea( CustomizableUI.AREA_NAVBAR, { type: CustomizableUI.TYPE_TOOLBAR, overflowable: true, defaultPlacements: navbarPlacements, verticalTabsDefaultPlacements: [ "firefox-view-button", "alltabs-button", ], defaultCollapsed: false, }, true ); if (!Services.appinfo.nativeMenubar) { this.registerArea( CustomizableUI.AREA_MENUBAR, { type: CustomizableUI.TYPE_TOOLBAR, defaultPlacements: ["menubar-items"], defaultCollapsed: true, }, true ); } this.registerArea( CustomizableUI.AREA_TABSTRIP, { type: CustomizableUI.TYPE_TOOLBAR, defaultPlacements: [ "firefox-view-button", "tabbrowser-tabs", "new-tab-button", "alltabs-button", ], verticalTabsDefaultPlacements: [], defaultCollapsed: null, }, true ); this.registerArea( CustomizableUI.AREA_VERTICAL_TABSTRIP, { type: "toolbar", defaultPlacements: [], verticalTabsDefaultPlacements: ["tabbrowser-tabs"], defaultCollapsed: null, }, true ); this.registerArea( CustomizableUI.AREA_BOOKMARKS, { type: CustomizableUI.TYPE_TOOLBAR, defaultPlacements: ["personal-bookmarks"], defaultCollapsed: "newtab", }, true ); lazy.log.debug(`All the areas registered: ${[...gAreas.keys()]}`); // At initialization, if we find vertical tabs enabled but not sidebar.revamp // we'll enable revamp rather than disable vertical tabs. this.reconcileSidebarPrefs(kPrefSidebarVerticalTabsEnabled); this.initializeForTabsOrientation(CustomizableUI.verticalTabsEnabled); SearchWidgetTracker.init(); Services.obs.addObserver(this, "browser-set-toolbar-visibility"); Services.prefs.addObserver(kPrefSidebarVerticalTabsEnabled, this); Services.prefs.addObserver(kPrefSidebarRevampEnabled, this); Services.prefs.addObserver(kPrefSidebarPositionStartEnabled, this); }, /** * Implements the onEnabled method for the AddonListener interface. Called * when an add-on is marked as enabled. * * @param {AddonInternal} addon * The add-on that was enabled. */ onEnabled(addon) { if (addon.type == "theme") { gSelectedTheme = addon; } }, /** * Returns a new Set that contains the IDs of all built-in customizable areas. * * @type {Set} */ get builtinAreas() { return new Set([ ...this.builtinToolbars, CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, CustomizableUI.AREA_ADDONS, ]); }, /** * Returns a new Set that contains the IDs of all built-in customizable * toolbar areas. * * @type {Set} */ get builtinToolbars() { let toolbars = new Set([ CustomizableUI.AREA_NAVBAR, CustomizableUI.AREA_BOOKMARKS, CustomizableUI.AREA_TABSTRIP, ]); if (AppConstants.platform != "macosx") { toolbars.add(CustomizableUI.AREA_MENUBAR); } return toolbars; }, /** * Goes through the list of widgets defined in CustomizableWidgets and * registers them with CustomizableUI. */ defineBuiltInWidgets() { for (let widgetDefinition of lazy.CustomizableWidgets) { this.createBuiltinWidget(widgetDefinition); } }, /** * Runs any necessary migrations on the current saved customization state * to get us to a kVersion compatible state. */ // eslint-disable-next-line complexity updateForNewVersion() { // We should still enter even if gSavedState.currentVersion >= kVersion // because the per-widget pref facility is independent of versioning. if (!gSavedState) { // Flip all the prefs so we don't try to re-introduce later: for (let [, widget] of gPalette) { if (widget.defaultArea && widget._introducedInVersion === "pref") { let prefId = "browser.toolbarbuttons.introduced." + widget.id; Services.prefs.setBoolPref(prefId, true); } } return; } let currentVersion = gSavedState.currentVersion; for (let [id, widget] of gPalette) { if (widget.defaultArea) { let shouldAdd = false; let shouldSetPref = false; let prefId = "browser.toolbarbuttons.introduced." + widget.id; if (widget._introducedInVersion === "pref") { try { shouldAdd = !Services.prefs.getBoolPref(prefId); } catch (ex) { // Pref doesn't exist: shouldAdd = true; } shouldSetPref = shouldAdd; } else if (widget._introducedInVersion > currentVersion) { shouldAdd = true; } else if ( widget._introducedByPref && Services.prefs.getBoolPref(widget._introducedByPref) ) { shouldSetPref = shouldAdd = !Services.prefs.getBoolPref( prefId, false ); } if (shouldAdd) { let futurePlacements = gFuturePlacements.get(widget.defaultArea); if (futurePlacements) { futurePlacements.add(id); } else { gFuturePlacements.set(widget.defaultArea, new Set([id])); } if (shouldSetPref) { Services.prefs.setBoolPref(prefId, true); } } } } // Nothing to migrate now if we don't have placements. if (!gSavedState.placements) { return; } if ( currentVersion < 7 && gSavedState.placements[CustomizableUI.AREA_NAVBAR] ) { let placements = gSavedState.placements[CustomizableUI.AREA_NAVBAR]; let newPlacements = [ "back-button", "forward-button", "stop-reload-button", "home-button", ]; for (let button of placements) { if (!newPlacements.includes(button)) { newPlacements.push(button); } } if (!newPlacements.includes("sidebar-button")) { newPlacements.unshift("sidebar-button"); } gSavedState.placements[CustomizableUI.AREA_NAVBAR] = newPlacements; } if (currentVersion < 8 && gSavedState.placements["PanelUI-contents"]) { let savedPanelPlacements = gSavedState.placements["PanelUI-contents"]; delete gSavedState.placements["PanelUI-contents"]; let defaultPlacements = [ "edit-controls", "zoom-controls", "new-window-button", "privatebrowsing-button", "save-page-button", "print-button", "history-panelmenu", "fullscreen-button", "find-button", "preferences-button", // This widget no longer exists as of 2023, see Bug 1799009. "add-ons-button", "sync-button", ]; if (!AppConstants.MOZ_DEV_EDITION) { defaultPlacements.splice(-1, 0, "developer-button"); } let showCharacterEncoding = Services.prefs.getComplexValue( "browser.menu.showCharacterEncoding", Ci.nsIPrefLocalizedString ).data; if (showCharacterEncoding == "true") { defaultPlacements.push("characterencoding-button"); } savedPanelPlacements = savedPanelPlacements.filter( id => !defaultPlacements.includes(id) ); if (savedPanelPlacements.length) { gSavedState.placements[CustomizableUI.AREA_FIXED_OVERFLOW_PANEL] = savedPanelPlacements; } } if (currentVersion < 9 && gSavedState.placements["nav-bar"]) { let placements = gSavedState.placements["nav-bar"]; if (placements.includes("urlbar-container")) { let urlbarIndex = placements.indexOf("urlbar-container"); let secondSpringIndex = urlbarIndex + 1; // Insert if there isn't already a spring before the urlbar if ( urlbarIndex == 0 || !placements[urlbarIndex - 1].startsWith(kSpecialWidgetPfx + "spring") ) { placements.splice(urlbarIndex, 0, "spring"); // The url bar is now 1 index later, so increment the insertion point for // the second spring. secondSpringIndex++; } // If the search container is present, insert after the search container // instead of after the url bar let searchContainerIndex = placements.indexOf("search-container"); if (searchContainerIndex != -1) { secondSpringIndex = searchContainerIndex + 1; } if ( secondSpringIndex == placements.length || !placements[secondSpringIndex].startsWith( kSpecialWidgetPfx + "spring" ) ) { placements.splice(secondSpringIndex, 0, "spring"); } } // Finally, replace the bookmarks menu button with the library one if present if (placements.includes("bookmarks-menu-button")) { let bmbIndex = placements.indexOf("bookmarks-menu-button"); placements.splice(bmbIndex, 1); let downloadButtonIndex = placements.indexOf("downloads-button"); let libraryIndex = downloadButtonIndex == -1 ? bmbIndex : downloadButtonIndex + 1; placements.splice(libraryIndex, 0, "library-button"); } } if (currentVersion < 10) { for (let placements of Object.values(gSavedState.placements)) { if (placements.includes("webcompat-reporter-button")) { placements.splice(placements.indexOf("webcompat-reporter-button"), 1); break; } } } // Move the downloads button to the default position in the navbar if it's // not there already. if (currentVersion < 11) { let navbarPlacements = gSavedState.placements[CustomizableUI.AREA_NAVBAR]; // First remove from wherever it currently lives, if anywhere: for (let placements of Object.values(gSavedState.placements)) { let existingIndex = placements.indexOf("downloads-button"); if (existingIndex != -1) { placements.splice(existingIndex, 1); break; // It can only be in 1 place, so no point looking elsewhere. } } // Now put the button in the navbar in the correct spot: if (navbarPlacements) { let insertionPoint = navbarPlacements.indexOf("urlbar-container"); // Deliberately iterate to 1 past the end of the array to insert at the // end if need be. while (++insertionPoint < navbarPlacements.length) { let widget = navbarPlacements[insertionPoint]; // If we find a non-searchbar, non-spacer node, break out of the loop: if ( widget != "search-container" && !this.matchingSpecials(widget, "spring") ) { break; } } // We either found the right spot, or reached the end of the // placements, so insert here: navbarPlacements.splice(insertionPoint, 0, "downloads-button"); } } if (currentVersion < 12) { const removedButtons = [ "loop-call-button", "loop-button-throttled", "pocket-button", ]; for (let placements of Object.values(gSavedState.placements)) { for (let button of removedButtons) { let buttonIndex = placements.indexOf(button); if (buttonIndex != -1) { placements.splice(buttonIndex, 1); } } } } // Remove the old placements from the now-gone Nightly-only // "New non-e10s window" button. if (currentVersion < 13) { for (let placements of Object.values(gSavedState.placements)) { let buttonIndex = placements.indexOf("e10s-button"); if (buttonIndex != -1) { placements.splice(buttonIndex, 1); } } } // Remove unsupported custom toolbar saved placements if (currentVersion < 14) { for (let area in gSavedState.placements) { if (!this.builtinAreas.has(area)) { delete gSavedState.placements[area]; } } } // Add the FxA toolbar menu as the right most button item if (currentVersion < 16) { let navbarPlacements = gSavedState.placements[CustomizableUI.AREA_NAVBAR]; // Place the menu item as the first item to the left of the hamburger menu if (navbarPlacements) { navbarPlacements.push("fxa-toolbar-menu-button"); } } // Add the save to Pocket button left of downloads button. if (currentVersion < 17) { let navbarPlacements = gSavedState.placements[CustomizableUI.AREA_NAVBAR]; let persistedPageActionsPref = Services.prefs.getCharPref( "browser.pageActions.persistedActions", "" ); let pocketPreviouslyInUrl = true; try { let persistedPageActionsData = JSON.parse(persistedPageActionsPref); // If Pocket was previously not in the url bar, let's not put it in the toolbar. // It'll still be an option to add from the customization page. pocketPreviouslyInUrl = persistedPageActionsData.idsInUrlbar.includes("pocket"); } catch (e) {} if (navbarPlacements && pocketPreviouslyInUrl) { // Pocket's new home is next to the downloads button, or the next best spot. let newPosition = navbarPlacements.indexOf("downloads-button") ?? navbarPlacements.indexOf("fxa-toolbar-menu-button") ?? navbarPlacements.length; navbarPlacements.splice(newPosition, 0, "save-to-pocket-button"); } } // Add firefox-view if not present if (currentVersion < 18) { let tabstripPlacements = gSavedState.placements[CustomizableUI.AREA_TABSTRIP]; if ( tabstripPlacements && !tabstripPlacements.includes("firefox-view-button") ) { tabstripPlacements.unshift("firefox-view-button"); } } // Unified Extensions addon button migration, which puts any browser action // buttons in the overflow menu into the addons panel instead. if (currentVersion < 19) { let overflowPlacements = gSavedState.placements[CustomizableUI.AREA_FIXED_OVERFLOW_PANEL] || []; // The most likely case is that there are no AREA_ADDONS placements, in which case the // array won't exist. let addonsPlacements = gSavedState.placements[CustomizableUI.AREA_ADDONS] || []; // Migration algorithm for transitioning to Unified Extensions: // // 1. Create two arrays, one for extension widgets, one for built-in widgets. // 2. Iterate all items in the overflow panel, and push them into the // appropriate array based on whether or not its an extension widget. // 3. Overwrite the overflow panel placements with the built-in widgets array. // 4. Prepend the extension widgets to the addonsPlacements array. Note that this // does not overwrite this array as a precaution because it's possible // (though pretty unlikely) that some widgets are already there. // // For extension widgets that were in the palette, they will be appended to the // addons area when they're created within createWidget. let extWidgets = []; let builtInWidgets = []; for (let widgetId of overflowPlacements) { if (CustomizableUI.isWebExtensionWidget(widgetId)) { extWidgets.push(widgetId); } else { builtInWidgets.push(widgetId); } } gSavedState.placements[CustomizableUI.AREA_FIXED_OVERFLOW_PANEL] = builtInWidgets; gSavedState.placements[CustomizableUI.AREA_ADDONS] = [ ...extWidgets, ...addonsPlacements, ]; } // Add the PBM reset button as the right most button item if (currentVersion < 20) { let navbarPlacements = gSavedState.placements[CustomizableUI.AREA_NAVBAR]; // Place the button as the first item to the left of the hamburger menu if ( navbarPlacements && !navbarPlacements.includes("reset-pbm-toolbar-button") ) { navbarPlacements.push("reset-pbm-toolbar-button"); } } if (currentVersion < 21) { // If the vertical-spacer has not yet been added, ensure its to the left of the urlbar initially let navbarPlacements = gSavedState.placements[CustomizableUI.AREA_NAVBAR]; if (!navbarPlacements.includes("vertical-spacer")) { let urlbarContainerPosition = navbarPlacements.indexOf("urlbar-container"); gSavedState.placements[CustomizableUI.AREA_NAVBAR].splice( urlbarContainerPosition - 1, 0, "vertical-spacer" ); } } if (currentVersion < 22) { if (!Services.prefs.getBoolPref(kPrefSidebarPositionStartEnabled, true)) { // If the sidebar is on the right, the toolbar button is also on the right. const navbarPlacements = gSavedState.placements[CustomizableUI.AREA_NAVBAR]; if (navbarPlacements[0] === "sidebar-button") { navbarPlacements.shift(); navbarPlacements.push("sidebar-button"); } } } }, /** * A separate state migration method for when we introduced the Proton * retheme in 2021. Because the Proton retheme was toggle-able via a pref, * it was important to have the migration separated out. Like most old * migrations, this is probably a historical artifact at this point. */ updateForNewProtonVersion() { const VERSION = 3; let currentVersion = Services.prefs.getIntPref( kPrefProtonToolbarVersion, 0 ); if (currentVersion >= VERSION) { return; } let placements = gSavedState?.placements?.[CustomizableUI.AREA_NAVBAR]; if (!placements) { // The profile was created with this version, so no need to migrate. Services.prefs.setIntPref(kPrefProtonToolbarVersion, VERSION); return; } // Remove the home button if it hasn't been used and is set to about:home if (currentVersion < 1) { let homePage = lazy.HomePage.get(); if ( placements.includes("home-button") && !Services.prefs.getBoolPref(kPrefHomeButtonUsed) && (homePage == "about:home" || homePage == "about:blank") && Services.policies.isAllowed("removeHomeButtonByDefault") ) { placements.splice(placements.indexOf("home-button"), 1); } } // Remove the library button if it hasn't been used if (currentVersion < 2) { if ( placements.includes("library-button") && !Services.prefs.getBoolPref(kPrefLibraryButtonUsed) ) { placements.splice(placements.indexOf("library-button"), 1); } } // Remove the library button if it hasn't been used if (currentVersion < 3) { if ( placements.includes("sidebar-button") && !Services.prefs.getBoolPref(kPrefSidebarButtonUsed) ) { placements.splice(placements.indexOf("sidebar-button"), 1); } } Services.prefs.setIntPref(kPrefProtonToolbarVersion, VERSION); }, /** * When upgrading, checks to see if any built-in button definitions were * just removed / marked obsolete (see ObsoleteBuiltinButtons) for the * current kVersion. If so, marks those buttons as seen. */ markObsoleteBuiltinButtonsSeen() { if (!gSavedState) { return; } let currentVersion = gSavedState.currentVersion; if (currentVersion >= kVersion) { return; } // we're upgrading, update state if necessary for (let id in ObsoleteBuiltinButtons) { let version = ObsoleteBuiltinButtons[id]; if (version == kVersion) { gSeenWidgets.add(id); gDirty = true; } } }, /** * If a new area was defined, or new default widgets for an area are defined, * this reconciles the placements of those new default widgets with the * existing customization and toolbar state of the browser. * * @param {string} aArea * The ID of the area to reconcile the default widget state for. */ placeNewDefaultWidgetsInArea(aArea) { let futurePlacedWidgets = gFuturePlacements.get(aArea); let savedPlacements = gSavedState && gSavedState.placements && gSavedState.placements[aArea]; let defaultPlacements; if ( CustomizableUI.verticalTabsEnabled && gAreas.get(aArea).has("verticalTabsDefaultPlacements") ) { defaultPlacements = gAreas .get(aArea) .get("verticalTabsDefaultPlacements"); } else { defaultPlacements = gAreas.get(aArea).get("defaultPlacements"); } if ( !savedPlacements || !savedPlacements.length || !futurePlacedWidgets || !defaultPlacements || !defaultPlacements.length ) { return; } let defaultWidgetIndex = -1; for (let widgetId of futurePlacedWidgets) { let widget = gPalette.get(widgetId); if ( !widget || widget.source !== CustomizableUI.SOURCE_BUILTIN || !widget.defaultArea || !(widget._introducedInVersion || widget._introducedByPref) || savedPlacements.includes(widget.id) ) { continue; } defaultWidgetIndex = defaultPlacements.indexOf(widget.id); if (defaultWidgetIndex === -1) { continue; } // Now we know that this widget should be here by default, was newly introduced, // and we have a saved state to insert into, and a default state to work off of. // Try introducing after widgets that come before it in the default placements: for (let i = defaultWidgetIndex; i >= 0; i--) { // Special case: if the defaults list this widget as coming first, insert at the beginning: if (i === 0 && i === defaultWidgetIndex) { savedPlacements.splice(0, 0, widget.id); // Before you ask, yes, deleting things inside a let x of y loop where y is a Set is // safe, and we won't skip any items. futurePlacedWidgets.delete(widget.id); gDirty = true; break; } // Otherwise, if we're somewhere other than the beginning, check if the previous // widget is in the saved placements. if (i) { let previousWidget = defaultPlacements[i - 1]; let previousWidgetIndex = savedPlacements.indexOf(previousWidget); if (previousWidgetIndex != -1) { savedPlacements.splice(previousWidgetIndex + 1, 0, widget.id); futurePlacedWidgets.delete(widget.id); gDirty = true; break; } } } // The loop above either inserts the item or doesn't - either way, we can get away // with doing nothing else now; if the item remains in gFuturePlacements, we'll // add it at the end in restoreStateForArea. } this.saveState(); }, /** * @see CustomizableUI.getCustomizationTarget * @param {Element|null} aElement * @returns {Element|null} */ getCustomizationTarget(aElement) { if (!aElement) { return null; } if ( !aElement._customizationTarget && aElement.hasAttribute("customizable") ) { let id = aElement.getAttribute("customizationtarget"); if (id) { aElement._customizationTarget = aElement.ownerDocument.getElementById(id); } if (!aElement._customizationTarget) { aElement._customizationTarget = aElement; } } return aElement._customizationTarget; }, /** * Given a customizable widget ID, creates and returns a WidgetGroupWrapper * or a XULGroupWrapper for that widget (depending on how the widget is * provided). This wrapper is then cached and returned for future calls * for that same widget ID. * * If the customizable widget ID cannot be resolved to a particular provider, * null is returned. * * @param {string} aWidgetId * The ID of the customizable widget to get the WidgetGroupWrapper / * XULGroupWrapper for. * @returns {WidgetGroupWrapper|XULGroupWrapper|null} * The appropriate GroupWrapper for the widget, or null if no such wrapper * can be found. */ wrapWidget(aWidgetId) { if (gGroupWrapperCache.has(aWidgetId)) { return gGroupWrapperCache.get(aWidgetId); } let provider = this.getWidgetProvider(aWidgetId); if (!provider) { return null; } if (provider == CustomizableUI.PROVIDER_API) { let widget = gPalette.get(aWidgetId); if (!widget.wrapper) { widget.wrapper = new WidgetGroupWrapper(widget); gGroupWrapperCache.set(aWidgetId, widget.wrapper); } return widget.wrapper; } // PROVIDER_SPECIAL gets treated the same as PROVIDER_XUL. // XXXgijs: this causes bugs in code that depends on widgetWrapper.provider // giving an accurate answer... filed as bug 1379821 let wrapper = new XULWidgetGroupWrapper(aWidgetId); gGroupWrapperCache.set(aWidgetId, wrapper); return wrapper; }, /** * @see CustomizableUI.registerArea * @param {string} aName * @param {object} aProperties * @param {boolean} aInternalCaller */ registerArea(aName, aProperties, aInternalCaller) { if (typeof aName != "string" || !/^[a-z0-9-_]{1,}$/i.test(aName)) { throw new Error("Invalid area name"); } let areaIsKnown = gAreas.has(aName); let props = areaIsKnown ? gAreas.get(aName) : new Map(); const kImmutableProperties = new Set(["type", "overflowable"]); for (let key in aProperties) { if ( areaIsKnown && kImmutableProperties.has(key) && props.get(key) != aProperties[key] ) { throw new Error("An area cannot change the property for '" + key + "'"); } props.set(key, aProperties[key]); } // Default to a toolbar: if (!props.has("type")) { props.set("type", CustomizableUI.TYPE_TOOLBAR); } if (props.get("type") == CustomizableUI.TYPE_TOOLBAR) { // Check aProperties instead of props because this check is only interested // in the passed arguments, not the state of a potentially pre-existing area. if (!aInternalCaller && aProperties.defaultCollapsed) { throw new Error( "defaultCollapsed is only allowed for default toolbars." ); } if (!props.has("defaultCollapsed")) { props.set("defaultCollapsed", true); } } else if (props.has("defaultCollapsed")) { throw new Error("defaultCollapsed only applies for TYPE_TOOLBAR areas."); } // Sanity check type: let allTypes = [CustomizableUI.TYPE_TOOLBAR, CustomizableUI.TYPE_PANEL]; if (!allTypes.includes(props.get("type"))) { throw new Error("Invalid area type " + props.get("type")); } // And to no placements: if (!props.has("defaultPlacements")) { props.set("defaultPlacements", []); } // Sanity check default placements array: if (!Array.isArray(props.get("defaultPlacements"))) { throw new Error("Should provide an array of default placements"); } if (!areaIsKnown) { gAreas.set(aName, props); // Reconcile new default widgets. Have to do this before we start restoring things. this.placeNewDefaultWidgetsInArea(aName); if ( props.get("type") == CustomizableUI.TYPE_TOOLBAR && !gPlacements.has(aName) ) { lazy.log.debug( `registerArea ${aName}, no gPlacements yet, nothing to restore` ); // Guarantee this area exists in gFuturePlacements, to avoid checking it in // various places elsewhere. if (!gFuturePlacements.has(aName)) { gFuturePlacements.set(aName, new Set()); } } else { this.restoreStateForArea(aName); } // If we have pending build area nodes, register all of them if (gPendingBuildAreas.has(aName)) { let pendingNodes = gPendingBuildAreas.get(aName); for (let pendingNode of pendingNodes) { this.registerToolbarNode(pendingNode); } gPendingBuildAreas.delete(aName); } } }, /** * @see CustomizableUI.unregisterArea * @param {string} aName * @param {boolean} [aDestroyPlacements] */ unregisterArea(aName, aDestroyPlacements) { if (typeof aName != "string" || !/^[a-z0-9-_]{1,}$/i.test(aName)) { throw new Error("Invalid area name"); } if (!gAreas.has(aName) && !gPlacements.has(aName)) { throw new Error("Area not registered"); } // Move all the widgets out this.beginBatchUpdate(); try { let placements = gPlacements.get(aName); if (placements) { // Need to clone this array so removeWidgetFromArea doesn't modify it placements = [...placements]; placements.forEach(this.removeWidgetFromArea, this); } // Delete all remaining traces. gAreas.delete(aName); // Only destroy placements when necessary: if (aDestroyPlacements) { gPlacements.delete(aName); } else { // Otherwise we need to re-set them, as removeFromArea will have emptied // them out: gPlacements.set(aName, placements); } gFuturePlacements.delete(aName); let existingAreaNodes = gBuildAreas.get(aName); if (existingAreaNodes) { for (let areaNode of existingAreaNodes) { this.notifyListeners( "onAreaNodeUnregistered", aName, this.getCustomizationTarget(areaNode), CustomizableUI.REASON_AREA_UNREGISTERED ); } } gBuildAreas.delete(aName); } finally { this.endBatchUpdate(true); } }, /** * @see CustomizableUI.registerToolbarNode * @param {Element} aToolbar */ registerToolbarNode(aToolbar) { let area = aToolbar.id; if (gBuildAreas.has(area) && gBuildAreas.get(area).has(aToolbar)) { return; } let areaProperties = gAreas.get(area); // If this area is not registered, try to do it automatically: if (!areaProperties) { if (!gPendingBuildAreas.has(area)) { gPendingBuildAreas.set(area, []); } gPendingBuildAreas.get(area).push(aToolbar); return; } this.beginBatchUpdate(); try { let placements = gPlacements.get(area); if ( !placements && areaProperties.get("type") == CustomizableUI.TYPE_TOOLBAR ) { this.restoreStateForArea(area); placements = gPlacements.get(area); } // For toolbars that need it, mark as dirty. let defaultPlacements = areaProperties.get("defaultPlacements"); if ( !this.builtinToolbars.has(area) || placements.length != defaultPlacements.length || !placements.every((id, i) => id == defaultPlacements[i]) ) { gDirtyAreaCache.add(area); } if (areaProperties.get("overflowable")) { aToolbar.overflowable = new OverflowableToolbar(aToolbar); } this.registerBuildArea(area, aToolbar); // We only build the toolbar if it's been marked as "dirty". Dirty means // one of the following things: // 1) Items have been added, moved or removed from this toolbar before. // 2) The number of children of the toolbar does not match the length of // the placements array for that area. // // This notion of being "dirty" is stored in a cache which is persisted // in the saved state. // // Secondly, if the list of placements contains an API-provided widget, // we need to call `buildArea` or it won't be built and put in the toolbar. if ( gDirtyAreaCache.has(area) || placements.some(id => gPalette.has(id)) ) { this.buildArea(area, placements, aToolbar); } else { // We must have a builtin toolbar that's in the default state. We need // to only make sure that all the special nodes are correct. let specials = placements.filter(p => this.isSpecialWidget(p)); if (specials.length) { this.updateSpecialsForBuiltinToolbar(aToolbar, specials); } } this.notifyListeners( "onAreaNodeRegistered", area, this.getCustomizationTarget(aToolbar) ); } finally { lazy.log.debug( `registerToolbarNode for ${area}, tabstripAreasReady? ${this.tabstripAreasReady}` ); this.endBatchUpdate(); } }, /** * For each "special" node in a toolbar (any spring, spacer, separator, or * element with an ID prefixed with kSpecialWidgetPfx), assigns the unique * ID from the saved state to the DOM nodes. These unique IDs are things like * "customizableui-special-spring30". * * @param {Element} aToolbar * The node to update the special widget children IDs for. * @param {string[]} aSpecialIDs * An array of special node IDs from the saved placements state. */ updateSpecialsForBuiltinToolbar(aToolbar, aSpecialIDs) { // Nodes are going to be in the correct order, so we can do this straightforwardly: let { children } = this.getCustomizationTarget(aToolbar); for (let kid of children) { if ( this.matchingSpecials(aSpecialIDs[0], kid) && kid.getAttribute("skipintoolbarset") != "true" ) { kid.id = aSpecialIDs.shift(); } if (!aSpecialIDs.length) { return; } } }, /** * This does the work of causing a customizable area to reflect the placements * that have been computed for that area. This means taking the initial * default DOM state of the area, and then modifying it to match the computed * state (either the state that had been saved in preferences, or the state * computed after doing runtime checks during initialization). * * @param {string} aAreaId * The ID of the customizable area to "build". * @param {string[]} aPlacements * The IDs of the customizable widgets that are expected to be in the * customizable area. * @param {Element} aAreaNode * The node associated with the area (as opposed to the customization * target within that area). */ buildArea(aAreaId, aPlacements, aAreaNode) { let document = aAreaNode.ownerDocument; let window = document.defaultView; let inPrivateWindow = lazy.PrivateBrowsingUtils.isWindowPrivate(window); let container = this.getCustomizationTarget(aAreaNode); let areaIsPanel = gAreas.get(aAreaId).get("type") == CustomizableUI.TYPE_PANEL; if (!container) { throw new Error( "Expected area " + aAreaId + " to have a customizationTarget attribute." ); } // Restore nav-bar visibility since it may have been hidden // through a migration path (bug 938980) or an add-on. if (aAreaId == CustomizableUI.AREA_NAVBAR) { aAreaNode.collapsed = false; } this.beginBatchUpdate(); try { let currentNode = container.firstElementChild; let placementsToRemove = new Set(); for (let id of aPlacements) { while ( currentNode && currentNode.getAttribute("skipintoolbarset") == "true" ) { currentNode = currentNode.nextElementSibling; } // Fix ids for specials and continue, for correctly placed specials. if ( currentNode && (!currentNode.id || CustomizableUI.isSpecialWidget(currentNode)) && this.matchingSpecials(id, currentNode) ) { currentNode.id = id; } if (currentNode && currentNode.id == id) { currentNode = currentNode.nextElementSibling; continue; } if (this.isSpecialWidget(id) && areaIsPanel) { placementsToRemove.add(id); continue; } let [provider, node] = this.getWidgetNode(id, window); if (!node) { lazy.log.debug("Unknown widget: " + id); continue; } let widget = null; // If the placements have items in them which are (now) no longer removable, // we shouldn't be moving them: if (provider == CustomizableUI.PROVIDER_API) { widget = gPalette.get(id); if (!widget.removable && aAreaId != widget.defaultArea) { placementsToRemove.add(id); continue; } } else if ( provider == CustomizableUI.PROVIDER_XUL && node.parentNode != container && !this.isWidgetRemovable(node) ) { placementsToRemove.add(id); continue; } // Special widgets are always removable, so no need to check them if (inPrivateWindow && widget && !widget.showInPrivateBrowsing) { continue; } if (!inPrivateWindow && widget?.hideInNonPrivateBrowsing) { continue; } this.ensureButtonContextMenu(node, aAreaNode); // This needs updating in case we're resetting / undoing a reset. if (widget) { widget.currentArea = aAreaId; } this.insertWidgetBefore(node, currentNode, container, aAreaId); if (gResetting) { this.notifyListeners("onWidgetReset", node, container); } else if (gUndoResetting) { this.notifyListeners("onWidgetUndoMove", node, container); } } if (currentNode) { let palette = window.gNavToolbox ? window.gNavToolbox.palette : null; let limit = currentNode.previousElementSibling; let node = container.lastElementChild; while (node && node != limit) { let previousSibling = node.previousElementSibling; // Nodes opt-in to removability. If they're removable, and we haven't // seen them in the placements array, then we toss them into the palette // if one exists. If no palette exists, we just remove the node. If the // node is not removable, we leave it where it is. However, we can only // safely touch elements that have an ID - both because we depend on // IDs (or are specials), and because such elements are not intended to // be widgets (eg, titlebar-spacer elements). if ( (node.id || this.isSpecialWidget(node)) && node.getAttribute("skipintoolbarset") != "true" ) { if (this.isWidgetRemovable(node)) { if (node.id && (gResetting || gUndoResetting)) { let widget = gPalette.get(node.id); if (widget) { widget.currentArea = null; } } this.notifyDOMChange(node, null, container, true, () => { if (palette && !this.isSpecialWidget(node.id)) { palette.appendChild(node); this.removeLocationAttributes(node); } else { container.removeChild(node); } }); } else { node.setAttribute("removable", false); lazy.log.debug( "Adding non-removable widget to placements of " + aAreaId + ": " + node.id ); gPlacements.get(aAreaId).push(node.id); gDirty = true; } } node = previousSibling; } } // If there are placements in here which aren't removable from their original area, // we remove them from this area's placement array. They will (have) be(en) added // to their original area's placements array in the block above this one. if (placementsToRemove.size) { let placementAry = gPlacements.get(aAreaId); for (let id of placementsToRemove) { let index = placementAry.indexOf(id); placementAry.splice(index, 1); } } if (gResetting) { this.notifyListeners("onAreaReset", aAreaId, container); } } finally { this.endBatchUpdate(); } }, /** * @see CustomizableUI.addPanelCloseListeners * @param {Element} aPanel */ addPanelCloseListeners(aPanel) { aPanel.addEventListener("click", this, { mozSystemGroup: true }); aPanel.addEventListener("keypress", this, { mozSystemGroup: true }); let win = aPanel.ownerGlobal; if (!gPanelsForWindow.has(win)) { gPanelsForWindow.set(win, new Set()); } gPanelsForWindow.get(win).add(this._getPanelForNode(aPanel)); }, /** * @see CustomizableUI.removePanelCloseListeners * @param {Element} aPanel */ removePanelCloseListeners(aPanel) { aPanel.removeEventListener("click", this, { mozSystemGroup: true }); aPanel.removeEventListener("keypress", this, { mozSystemGroup: true }); let win = aPanel.ownerGlobal; let panels = gPanelsForWindow.get(win); if (panels) { panels.delete(this._getPanelForNode(aPanel)); } }, /** * Given a customizable widget node, attempts to assign it the correct * context / contextmenu attributes for the current placement of that * widget. * * @param {Element} aNode * The node to set the context / contextmenu attributes for. * @param {Element} aAreaNode * The node representing the area that aNode is currently placed within. * @param {boolean} forcePanel * True if we should force the panel context menu, regardless of the * current placement. This is mainly useful for the overflow panel, where * the placement may be the overflowable toolbar, but visually the widget * is within the overflowable toolbar panel. */ ensureButtonContextMenu(aNode, aAreaNode, forcePanel) { const kPanelItemContextMenu = "customizationPanelItemContextMenu"; let currentContextMenu = aNode.getAttribute("context") || aNode.getAttribute("contextmenu"); let contextMenuForPlace; if ( CustomizableUI.isWebExtensionWidget(aNode.id) && (aAreaNode?.id == CustomizableUI.AREA_ADDONS || aNode.getAttribute("overflowedItem") == "true") ) { contextMenuForPlace = null; } else { contextMenuForPlace = forcePanel || "panel" == CustomizableUI.getPlaceForItem(aAreaNode) ? kPanelItemContextMenu : null; } if (contextMenuForPlace && !currentContextMenu) { aNode.setAttribute("context", contextMenuForPlace); } else if ( currentContextMenu == kPanelItemContextMenu && contextMenuForPlace != kPanelItemContextMenu ) { aNode.removeAttribute("context"); aNode.removeAttribute("contextmenu"); } }, /** * Returns the ID of the provider for a given widget. This is one of the * CustomizableUI.PROVIDER_* constants. * * @param {string} aWidgetId * The ID of the widget to get the provider for. * @returns {string} * One of the CustomizableUI.PROVIDER_* constants. */ getWidgetProvider(aWidgetId) { if (this.isSpecialWidget(aWidgetId)) { return CustomizableUI.PROVIDER_SPECIAL; } if (gPalette.has(aWidgetId)) { return CustomizableUI.PROVIDER_API; } // If this was an API widget that was destroyed, return null: if (gSeenWidgets.has(aWidgetId)) { return null; } // We fall back to the XUL provider, but we don't know for sure (at this // point) whether it exists there either. So the API is technically lying. // Ideally, it would be able to return an error value (or throw an // exception) if it really didn't exist. Our code calling this function // handles that fine, but this is a public API. return CustomizableUI.PROVIDER_XUL; }, /** * @typedef {string|null} GetWidgetNodeIndex0 * The ID of the provider for the widget node. This is one of the constants * in CustomizableUI.PROVIDER_*. This is null if no node is found for the * widget ID. * @typedef {Element|null} GetWidgetNodeIndex1 * The found node associated with a widget ID, or null if no such node can * be found. * @typedef {[GetWidgetNodeIndex0, GetWidgetNodeIndex1]} GetWidgetNodeResult */ /** * For a given window, returns the node associated with a widget ID. * * @param {string} aWidgetId * The ID of the widget to get the associated node for in the window. * @param {DOMWindow} aWindow * The window to find the node for, associated with aWidgetId. * @returns {GetWidgetNodeResult} * The found node information. */ getWidgetNode(aWidgetId, aWindow) { let document = aWindow.document; if (this.isSpecialWidget(aWidgetId)) { let widgetNode = document.getElementById(aWidgetId) || this.createSpecialWidget(aWidgetId, document); return [CustomizableUI.PROVIDER_SPECIAL, widgetNode]; } let widget = gPalette.get(aWidgetId); if (widget) { // If we have an instance of this widget already, just use that. if (widget.instances.has(document)) { lazy.log.debug( "An instance of widget " + aWidgetId + " already exists in this " + "document. Reusing." ); return [CustomizableUI.PROVIDER_API, widget.instances.get(document)]; } return [ CustomizableUI.PROVIDER_API, this.buildWidgetNode(document, widget), ]; } lazy.log.debug("Searching for " + aWidgetId + " in toolbox."); let node = this.findXULWidgetInWindow(aWidgetId, aWindow); if (node) { return [CustomizableUI.PROVIDER_XUL, node]; } lazy.log.debug("No node for " + aWidgetId + " found."); return [null, null]; }, /** * @see CustomizableUI.registerPanelNode * @param {Element} aNode * @param {Element} aAreaId */ registerPanelNode(aNode, aAreaId) { if (gBuildAreas.has(aAreaId) && gBuildAreas.get(aAreaId).has(aNode)) { return; } aNode._customizationTarget = aNode; this.addPanelCloseListeners(this._getPanelForNode(aNode)); let placements = gPlacements.get(aAreaId); this.buildArea(aAreaId, placements, aNode); this.notifyListeners("onAreaNodeRegistered", aAreaId, aNode); for (let child of aNode.children) { if (child.localName != "toolbarbutton") { if (child.localName == "toolbaritem") { this.ensureButtonContextMenu(child, aNode, true); } continue; } this.ensureButtonContextMenu(child, aNode, true); } this.registerBuildArea(aAreaId, aNode); }, /** * @type {CustomizableUIOnWidgetAddedCallback} */ onWidgetAdded(aWidgetId, aArea, _aPosition) { this.insertNode(aWidgetId, aArea, true); if (!gResetting) { this._clearPreviousUIState(); } }, /** * @type {CustomizableUIOnWidgetRemovedCallback} */ onWidgetRemoved(aWidgetId, aArea) { let areaNodes = gBuildAreas.get(aArea); if (!areaNodes) { return; } let area = gAreas.get(aArea); let isToolbar = area.get("type") == CustomizableUI.TYPE_TOOLBAR; let isOverflowable = isToolbar && area.get("overflowable"); let showInPrivateBrowsing = gPalette.has(aWidgetId) ? gPalette.get(aWidgetId).showInPrivateBrowsing : true; let hideInNonPrivateBrowsing = gPalette.get(aWidgetId)?.hideInNonPrivateBrowsing ?? false; for (let areaNode of areaNodes) { let window = areaNode.ownerGlobal; if ( !showInPrivateBrowsing && lazy.PrivateBrowsingUtils.isWindowPrivate(window) ) { continue; } if ( hideInNonPrivateBrowsing && !lazy.PrivateBrowsingUtils.isWindowPrivate(window) ) { continue; } let container = this.getCustomizationTarget(areaNode); let widgetNode = window.document.getElementById(aWidgetId); if (widgetNode && isOverflowable) { container = areaNode.overflowable.getContainerFor(widgetNode); } if (!widgetNode || !container.contains(widgetNode)) { lazy.log.info( "Widget " + aWidgetId + " not found, unable to remove from " + aArea ); continue; } this.notifyDOMChange(widgetNode, null, container, true, () => { // We remove location attributes here to make sure they're gone too when a // widget is removed from a toolbar to the palette. See bug 930950. this.removeLocationAttributes(widgetNode); // We also need to remove the panel context menu if it's there: this.ensureButtonContextMenu(widgetNode); if (gPalette.has(aWidgetId) || this.isSpecialWidget(aWidgetId)) { container.removeChild(widgetNode); } else { window.gNavToolbox.palette.appendChild(widgetNode); } }); let windowCache = gSingleWrapperCache.get(window); if (windowCache) { windowCache.delete(aWidgetId); } } if (!gResetting) { this._clearPreviousUIState(); } }, /** * @type {CustomizableUIOnWidgetMovedCallback} */ onWidgetMoved(aWidgetId, aArea, _aOldPosition, _aNewPosition) { this.insertNode(aWidgetId, aArea); if (!gResetting) { this._clearPreviousUIState(); } }, /** * @type {CustomizableUIOnCustomizeEnd} */ onCustomizeEnd() { this._clearPreviousUIState(); }, /** * Registers a customizable area of a window with CustomizableUI such that it * can then be "built" to reflect the current stored state. * * @param {string} aAreaId * The ID of the area to be registered. * @param {Element} aAreaNode * The element for the area in the window being registered. */ registerBuildArea(aAreaId, aAreaNode) { // We ensure that the window is registered to have its customization data // cleaned up when unloading. let window = aAreaNode.ownerGlobal; if (window.closed) { return; } this.registerBuildWindow(window); // Also register this build area's toolbox. if (window.gNavToolbox) { gBuildWindows.get(window).add(window.gNavToolbox); } if (!gBuildAreas.has(aAreaId)) { gBuildAreas.set(aAreaId, new Set()); } gBuildAreas.get(aAreaId).add(aAreaNode); // Give a class to all customize targets to be used for styling in Customize Mode let customizableNode = this.getCustomizeTargetForArea(aAreaId, window); customizableNode.classList.add("customization-target"); }, /** * Registers a browser window with customizable elements with CustomizableUI. * This is mainly used to set up event handlers to perform cleanups if and * when the window closes. If the window is already registered, this is a * no-op. * * @param {DOMWindow} aWindow * The window to register. */ registerBuildWindow(aWindow) { if (!gBuildWindows.has(aWindow)) { gBuildWindows.set(aWindow, new Set()); aWindow.addEventListener("unload", this); aWindow.addEventListener("command", this, true); this.notifyListeners("onWindowOpened", aWindow); } }, /** * Unregisters a browser window that was registered with registerBuildWindow. * The onAreaNodeUnregistered callback will be called for each customizable * area within the window being unregistered. The onWidgetInstanceRemoved * will be called for each widget in the window being unregistered. Finally, * the onWindowClosed listener will be called with the window. * * @param {DOMWindow} aWindow * The window to unregister. */ unregisterBuildWindow(aWindow) { aWindow.removeEventListener("unload", this); aWindow.removeEventListener("command", this, true); gPanelsForWindow.delete(aWindow); gBuildWindows.delete(aWindow); gSingleWrapperCache.delete(aWindow); let document = aWindow.document; for (let [areaId, areaNodes] of gBuildAreas) { let areaProperties = gAreas.get(areaId); for (let node of areaNodes) { if (node.ownerDocument == document) { this.notifyListeners( "onAreaNodeUnregistered", areaId, this.getCustomizationTarget(node), CustomizableUI.REASON_WINDOW_CLOSED ); if (areaProperties.get("overflowable")) { node.overflowable.uninit(); node.overflowable = null; } areaNodes.delete(node); } } } for (let [, widget] of gPalette) { widget.instances.delete(document); this.notifyListeners("onWidgetInstanceRemoved", widget.id, document); } for (let [, pendingNodes] of gPendingBuildAreas) { for (let i = pendingNodes.length - 1; i >= 0; i--) { if (pendingNodes[i].ownerDocument == document) { pendingNodes.splice(i, 1); } } } this.notifyListeners("onWindowClosed", aWindow); }, /** * @see CustomizableUI.handleNewBrowserWindow * @param {DOMWindow} aWindow */ handleNewBrowserWindow(aWindow) { let { gNavToolbox, document, gBrowser } = aWindow; gNavToolbox.palette = document.getElementById( "BrowserToolbarPalette" ).content; let isVerticalTabs = Services.prefs.getBoolPref( kPrefSidebarVerticalTabsEnabled, false ); let nonRemovables; // We don't want these normally non-removable elements to get put back into the // tabstrip if we're initializing with vertical tabs. // We do this for all windows, including popups, as otherwise it's possible for // us to get confused about window state and save a blank state to prefs. if (isVerticalTabs) { nonRemovables = [gBrowser.tabContainer]; for (let elem of nonRemovables) { elem.setAttribute("removable", "true"); // tell CUI to ignore this element when it builds the toolbar areas elem.setAttribute("skipintoolbarset", "true"); } } // Now register all the toolbars for (let area of CustomizableUI.areas) { let type = CustomizableUI.getAreaType(area); if (type == CustomizableUI.TYPE_TOOLBAR) { let node = document.getElementById(area); this.registerToolbarNode(node); } } // Handle initial state of vertical tabs. if (isVerticalTabs) { // Show the vertical tabs toolbar aWindow.setToolbarVisibility( document.getElementById(CustomizableUI.AREA_VERTICAL_TABSTRIP), true, false, false ); let tabstripToolbar = document.getElementById( CustomizableUI.AREA_TABSTRIP ); let wasCollapsed = tabstripToolbar.collapsed; aWindow.TabBarVisibility.update(); if (tabstripToolbar.collapsed !== wasCollapsed) { let eventParams = { detail: { visible: !tabstripToolbar.collapsed, }, bubbles: true, }; let event = new CustomEvent("toolbarvisibilitychange", eventParams); tabstripToolbar.dispatchEvent(event); } for (let elem of nonRemovables) { elem.setAttribute("removable", "false"); elem.removeAttribute("skipintoolbarset"); } } }, /** * Sets some attributes on a customizable widget when it is introduced into * the DOM or moved around within it. Those attributes are "cui-anchorid" * and "cui-areatype". * * @param {Element} aNode * The customizable widget node being inserted or moved within the DOM. * @param {string} aAreaId * The area that the customizable widget is being moved into or within. */ setLocationAttributes(aNode, aAreaId) { let props = gAreas.get(aAreaId); if (!props) { throw new Error( "Expected area " + aAreaId + " to have a properties Map " + "associated with it." ); } aNode.setAttribute("cui-areatype", props.get("type") || ""); let anchor = props.get("anchor"); if (anchor) { aNode.setAttribute("cui-anchorid", anchor); } else { aNode.removeAttribute("cui-anchorid"); } }, /** * Removes any location attributes from a customizable widget node when the * node is removed from any of the registered customizable areas. * * @param {Element} aNode * The node being removed from the customizable area. */ removeLocationAttributes(aNode) { aNode.removeAttribute("cui-areatype"); aNode.removeAttribute("cui-anchorid"); }, /** * Inserts a node associated with the customizable widget with ID aWidgetId * into all the areas with aAreaId across all windows. * * @param {string} aWidgetId * The ID of the customizable widget to insert into aAreaId across all * windows. * @param {string} aAreaId * The ID of the area to insert the widget into across all windows. * This method is a no-op if aAreaId is not associated with a registered * area. * @param {boolean} isNew * True if the widget is being newly inserted as opposed to moved. */ insertNode(aWidgetId, aAreaId, isNew) { let areaNodes = gBuildAreas.get(aAreaId); if (!areaNodes) { return; } let placements = gPlacements.get(aAreaId); if (!placements) { lazy.log.error( "Could not find any placements for " + aAreaId + " when moving a widget." ); return; } // Go through each of the nodes associated with this area and move the // widget to the requested location. for (let areaNode of areaNodes) { this.insertNodeInWindow(aWidgetId, areaNode, isNew); } }, /** * Inserts a widget with ID aWidgetId into the passed area node in the * position dictated by CustomizableUI's internal positioning state for * widgets. * * @param {string} aWidgetId * The ID of the widget to insert into aAreaNode. * @param {Element} aAreaNode * The customizable area node to insert aWidgetId into. * @param {boolean} isNew * True if the widget is being inserted for the first time, instead of * moved. */ insertNodeInWindow(aWidgetId, aAreaNode, isNew) { let window = aAreaNode.ownerGlobal; let showInPrivateBrowsing = gPalette.has(aWidgetId) ? gPalette.get(aWidgetId).showInPrivateBrowsing : true; let hideInNonPrivateBrowsing = gPalette.get(aWidgetId)?.hideInNonPrivateBrowsing ?? false; if ( !showInPrivateBrowsing && lazy.PrivateBrowsingUtils.isWindowPrivate(window) ) { return; } if ( hideInNonPrivateBrowsing && !lazy.PrivateBrowsingUtils.isWindowPrivate(window) ) { return; } let [, widgetNode] = this.getWidgetNode(aWidgetId, window); if (!widgetNode) { lazy.log.error("Widget '" + aWidgetId + "' not found, unable to move"); return; } let areaId = aAreaNode.id; if (isNew) { this.ensureButtonContextMenu(widgetNode, aAreaNode); } let [insertionContainer, nextNode] = this.findInsertionPoints( widgetNode, aAreaNode ); this.insertWidgetBefore(widgetNode, nextNode, insertionContainer, areaId); }, /** * @typedef {Element|null} FindInsertionPointsIndex0 * The container that the node should be inserted into, or null if no such * container can be found. * @typedef {Element|null} FindInsertionPointsIndex1 * The node that the insertion should occur before, or null if no such * sibling can be found. * @typedef {[FindInsertionPointsIndex0, FindInsertionPointsIndex1]} FindInsertionPointsResult */ /** * Given a node for a customizable widget that may have a placement within an * area, find the location in the DOM where it makes the most sense to insert * that node. In the event of there being a placement for aNode in aAreaNode, * this insertion point will reflect the index of the node in that area's * placements array. In the event of there not being a pre-existing placement * for aNode in aAreaNode, the node will be prepended to the area. * * @param {Element} aNode * @param {Element} aAreaNode * @returns {FindInsertionPointsResult} */ findInsertionPoints(aNode, aAreaNode) { let areaId = aAreaNode.id; let props = gAreas.get(areaId); // For overflowable toolbars, rely on them (because the work is more complicated): if ( props.get("type") == CustomizableUI.TYPE_TOOLBAR && props.get("overflowable") ) { return aAreaNode.overflowable.findOverflowedInsertionPoints(aNode); } let container = this.getCustomizationTarget(aAreaNode); let placements = gPlacements.get(areaId); let nodeIndex = placements.indexOf(aNode.id); while (++nodeIndex < placements.length) { let nextNodeId = placements[nodeIndex]; // We use aAreaNode here, because if aNode is in a template, its // `ownerDocument` is *not* going to be the browser.xhtml document, // so we cannot rely on it. let nextNode = aAreaNode.ownerDocument.getElementById(nextNodeId); // If the next placed widget exists, and is a direct child of the // container, or wrapped in a customize mode wrapper (toolbarpaletteitem) // inside the container, insert beside it. // We have to check the parent to avoid errors when the placement ids // are for nodes that are no longer customizable. if ( nextNode && (nextNode.parentNode == container || (nextNode.parentNode.localName == "toolbarpaletteitem" && nextNode.parentNode.parentNode == container)) ) { return [container, nextNode]; } } return [container, null]; }, /** * Inserts a node associated with a widget before some other node within a * container within a customizable area. * * @param {Element} aNode * The customizable widget node to insert. * @param {Element|null} aNextNode * The node that the inserted node should be inserted before, or null if * it should be inserted at the end of aContainer. * @param {Element} aContainer * The parent node of both aNode and aNextNode. * @param {string} aAreaId * The identifier string of the area that aNode is being inserted into. */ insertWidgetBefore(aNode, aNextNode, aContainer, aAreaId) { this.notifyDOMChange(aNode, aNextNode, aContainer, false, () => { this.setLocationAttributes(aNode, aAreaId); aContainer.insertBefore(aNode, aNextNode); }); }, /** * Fires the onWidgetBeforeDOMChange event, then calls aCallback, before * firing the onWidgetAfterDOMChange event. * * @param {Element} aNode * The node that is being changed. * @param {Element|null} aNextNode * The node immediately after the one changing, or null if there is no next * node in the container. * @param {Element} aContainer * The container of the node that is being changed. * @param {boolean} aIsRemove * True iff the action about to happen is the removal of the DOM node. * @param {Function} aCallback * A synchronous function that will be called after the * onWidgetBeforeDOMChange event is fired, but before onWidgetAfterDOMChange * is fired. */ notifyDOMChange(aNode, aNextNode, aContainer, aIsRemove, aCallback) { this.notifyListeners( "onWidgetBeforeDOMChange", aNode, aNextNode, aContainer, aIsRemove ); aCallback(); this.notifyListeners( "onWidgetAfterDOMChange", aNode, aNextNode, aContainer, aIsRemove ); }, /** * General event handler for CustomizableUIInternal that mostly just * dispatches to more specialized event handlers based on the event type. * * @param {CommandEvent|MouseEvent|KeyEvent|Event} aEvent */ handleEvent(aEvent) { switch (aEvent.type) { case "command": if (!this._originalEventInPanel(aEvent)) { break; } aEvent = aEvent.sourceEvent; // Fall through case "click": case "keypress": this.maybeAutoHidePanel(aEvent); break; case "unload": this.unregisterBuildWindow(aEvent.currentTarget); break; } }, /** * Returns true if the CommandEvent is being fired on a target that exists * in one of the panels that CustomizableUI tracks in gPanelsForWindow. * * @param {CommandEvent} aEvent * @returns {boolean} */ _originalEventInPanel(aEvent) { let e = aEvent.sourceEvent; if (!e) { return false; } let node = this._getPanelForNode(e.target); if (!node) { return false; } let win = e.view; let panels = gPanelsForWindow.get(win); return !!panels && panels.has(node); }, /** * If passed a DOM node, this will return the ID attribute for the node if it * exists. If it doesn't, it will check to see if the element localName starts * with "toolbar", and if so, return the rest of the localName after that * string. This means that for a without an ID, this will * return "separator". If the element does not have a localName that starts * with "toolbar", this will return the empty string. * * If aNode happens to be a string instead of a DOM node, this simply returns * the string back. * * @param {Element|string} aStringOrNode * A node to try to get the special identifier for, or a string that will * be echoed back to the caller. * @returns {string} * The special identifier for the node, the empty string, or aStringOrNode * in the event that aStringOrNode happened to already be a string. */ _getSpecialIdForNode(aStringOrNode) { if (typeof aStringOrNode == "object" && aStringOrNode.localName) { if (aStringOrNode.id) { return aStringOrNode.id; } if (aStringOrNode.localName.startsWith("toolbar")) { return aStringOrNode.localName.substring(7); } return ""; } return aStringOrNode; }, /** * Returns true if the passed in ID or node happens to be one of the "special" * widget types (a separator, a spring, or a spacer). * * @param {string|Element} aStringOrNode * An ID for a node, or an actual node itself to check for special-ness. * @returns {boolean} * True if the ID or node resolves to a "special" widget type. */ isSpecialWidget(aStringOrNode) { if (aStringOrNode === null) { lazy.log.debug("isSpecialWidget was passed null"); return false; } aStringOrNode = this._getSpecialIdForNode(aStringOrNode); return ( aStringOrNode.startsWith(kSpecialWidgetPfx) || aStringOrNode.startsWith("separator") || aStringOrNode.startsWith("spring") || aStringOrNode.startsWith("spacer") ); }, /** * Returns true if the passed in strings (or nodes) happen to be the same * special widget. * * @param {string|Element} aId1 * The first ID or element to compare to the second. * @param {string|Element} aId2 * The second ID or element to compare to the first. * @returns {boolean} * True if the two strings or elements being compared refer to the same * special widget. */ matchingSpecials(aId1, aId2) { aId1 = this._getSpecialIdForNode(aId1); aId2 = this._getSpecialIdForNode(aId2); return ( this.isSpecialWidget(aId1) && this.isSpecialWidget(aId2) && aId1.match(/spring|spacer|separator/)[0] == aId2.match(/spring|spacer|separator/)[0] ); }, /** * If the aId string starts with any of "spring", "spacer" or "separator" (any * of the special widget types), this will add a the special widget prefix * to the id, as well as a unique numeric suffix at the end and return it. * * Otherwise, this simply echoes back the aId string. * * @param {string} aId * @returns {string} * The aId string with the special widget prefix and unique numeric suffix * if the aId string is for a special widget, otherwise this just echoes * back aId. */ ensureSpecialWidgetId(aId) { let nodeType = aId.match(/spring|spacer|separator/)[0]; // If the ID we were passed isn't a generated one, generate one now: if (nodeType == aId) { // Ids are differentiated through a unique count suffix. return kSpecialWidgetPfx + aId + ++gNewElementCount; } return aId; }, /** * @see CustomizableUI.createSpecialWidget * @param {string} aId * @param {Document} aDocument * @returns {Element} */ createSpecialWidget(aId, aDocument) { let nodeName = "toolbar" + aId.match(/spring|spacer|separator/)[0]; let node = aDocument.createXULElement(nodeName); node.className = "chromeclass-toolbar-additional"; node.id = this.ensureSpecialWidgetId(aId); return node; }, /** * Find a XUL-provided widget node in a window. Don't try to use this * for an API-provided widget or a special widget. * * @param {string} aId * The ID of the XUL-provided widget to find the node for in aWindow. * @param {DOMWindow} aWindow * The window to find the XUL-provided widget node for. * @returns {Element|null} * The found XUL widget node, or null if it cannot be found. * @throws {Error} * Throws if aWindow is not a registered build window. */ findXULWidgetInWindow(aId, aWindow) { if (!gBuildWindows.has(aWindow)) { throw new Error("Build window not registered"); } if (!aId) { lazy.log.error("findWidgetInWindow was passed an empty string."); return null; } let document = aWindow.document; // look for a node with the same id, as the node may be // in a different toolbar. let node = document.getElementById(aId); if (node) { let parent = node.parentNode; while ( parent && !( this.getCustomizationTarget(parent) || parent == aWindow.gNavToolbox.palette ) ) { parent = parent.parentNode; } if (parent) { let nodeInArea = node.parentNode.localName == "toolbarpaletteitem" ? node.parentNode : node; // Check if we're in a customization target, or in the palette: if ( (this.getCustomizationTarget(parent) == nodeInArea.parentNode && gBuildWindows.get(aWindow).has(aWindow.gNavToolbox)) || aWindow.gNavToolbox.palette == nodeInArea.parentNode ) { // Normalize the removable attribute. For backwards compat, if // the widget is not located in a toolbox palette then absence // of the "removable" attribute means it is not removable. if (!node.hasAttribute("removable")) { // If we first see this in customization mode, it may be in the // customization palette instead of the toolbox palette. node.setAttribute( "removable", !this.getCustomizationTarget(parent) ); } return node; } } } let toolboxes = gBuildWindows.get(aWindow); for (let toolbox of toolboxes) { if (toolbox.palette) { // Attempt to locate an element with a matching ID within // the palette. let element = toolbox.palette.getElementsByAttribute("id", aId)[0]; if (element) { // Normalize the removable attribute. For backwards compat, this // is optional if the widget is located in the toolbox palette, // and defaults to *true*, unlike if it was located elsewhere. if (!element.hasAttribute("removable")) { element.setAttribute("removable", true); } return element; } } } return null; }, /** * Constructs a node for a customizable UI widget that can be placed within * aDocument. * * @param {Document} aDocument * The document that the node will be inserted into. * @param {string|WidgetGroupWrapper|XULWidgetGroupWrapper} aWidget * The customizable UI widget wrapper or widget ID of the widget to * construct. * @returns {Element|null} * Returns the constructed node for the widget to be placed in aDocument. * Will return null if the widget node is not allowed to be placed in * aDocument (for example, if aDocument is a private browsing window * document, and the widget is not allowed to be placed in such a window). * @throws {Error} * Can throw if the document is not a browser window document, or if * aWidget is null. */ buildWidgetNode(aDocument, aWidget) { if (aDocument.documentURI != kExpectedWindowURL) { throw new Error("buildWidget was called for a non-browser window!"); } if (typeof aWidget == "string") { aWidget = gPalette.get(aWidget); } if (!aWidget) { throw new Error("buildWidget was passed a non-widget to build."); } if ( !aWidget.showInPrivateBrowsing && lazy.PrivateBrowsingUtils.isWindowPrivate(aDocument.defaultView) ) { return null; } if ( aWidget.hideInNonPrivateBrowsing && !lazy.PrivateBrowsingUtils.isWindowPrivate(aDocument.defaultView) ) { return null; } lazy.log.debug("Building " + aWidget.id + " of type " + aWidget.type); let node; let button; if (aWidget.type == "custom") { if (aWidget.onBuild) { node = aWidget.onBuild(aDocument); } if ( !node || !aDocument.defaultView.XULElement.isInstance(node) || (aWidget.viewId && !node.viewButton) ) { lazy.log.error( "Custom widget with id " + aWidget.id + " does not return a valid node" ); } // A custom widget can define a viewId for the panel and a viewButton // property for the panel anchor. With that, it will be treated as a view // type where necessary to hook up the view panel. if (aWidget.viewId) { button = node.viewButton; } } // Button and view widget types, plus custom widgets that have a viewId and thus a button. if (button || aWidget.type != "custom") { if ( aWidget.onBeforeCreated && aWidget.onBeforeCreated(aDocument) === false ) { return null; } if (!button) { button = aDocument.createXULElement("toolbarbutton"); node = button; } button.classList.add("toolbarbutton-1"); button.setAttribute("delegatesanchor", "true"); let viewbutton = null; if (aWidget.type == "button-and-view") { button.setAttribute("id", aWidget.id + "-button"); let dropmarker = aDocument.createXULElement("toolbarbutton"); dropmarker.setAttribute("id", aWidget.id + "-dropmarker"); dropmarker.setAttribute("delegatesanchor", "true"); dropmarker.classList.add( "toolbarbutton-1", "toolbarbutton-combined-buttons-dropmarker" ); node = aDocument.createXULElement("toolbaritem"); node.classList.add("toolbaritem-combined-buttons"); node.append(button, dropmarker); viewbutton = dropmarker; } else if (aWidget.viewId) { // Also set viewbutton for anything with a view viewbutton = button; } node.setAttribute("id", aWidget.id); node.setAttribute("widget-id", aWidget.id); node.setAttribute("widget-type", aWidget.type); if (aWidget.disabled) { node.setAttribute("disabled", true); } node.setAttribute("removable", aWidget.removable); node.setAttribute("overflows", aWidget.overflows); if (aWidget.tabSpecific) { node.setAttribute("tabspecific", aWidget.tabSpecific); } if (aWidget.locationSpecific) { node.setAttribute("locationspecific", aWidget.locationSpecific); } if (aWidget.keepBroadcastAttributesWhenCustomizing) { node.setAttribute( "keepbroadcastattributeswhencustomizing", aWidget.keepBroadcastAttributesWhenCustomizing ); } let shortcut; if (aWidget.shortcutId) { let keyEl = aDocument.getElementById(aWidget.shortcutId); if (keyEl) { shortcut = lazy.ShortcutUtils.prettifyShortcut(keyEl); } else { lazy.log.error( "Key element with id '" + aWidget.shortcutId + "' for widget '" + aWidget.id + "' not found!" ); } } if (aWidget.l10nId) { aDocument.l10n.setAttributes(node, aWidget.l10nId); if (button != node) { // This is probably a "button-and-view" widget, such as the Profiler // button. In that case, "node" is the "toolbaritem" container, and // "button" the main button (see above). // In this case, the values on the "node" is used in the Customize // view, as well as the tooltips over both buttons; the values on the // "button" are used in the overflow menu. aDocument.l10n.setAttributes(button, aWidget.l10nId); } if (shortcut) { node.setAttribute("data-l10n-args", JSON.stringify({ shortcut })); if (button != node) { // This is probably a "button-and-view" widget. button.setAttribute("data-l10n-args", JSON.stringify({ shortcut })); } } } else { node.setAttribute("label", this.getLocalizedProperty(aWidget, "label")); if (button != node) { // This is probably a "button-and-view" widget. button.setAttribute("label", node.getAttribute("label")); } let tooltip = this.getLocalizedProperty( aWidget, "tooltiptext", shortcut ? [shortcut] : [] ); if (tooltip) { node.setAttribute("tooltiptext", tooltip); if (button != node) { // This is probably a "button-and-view" widget. button.setAttribute("tooltiptext", tooltip); } } } let commandHandler = this.handleWidgetCommand.bind(this, aWidget, node); node.addEventListener("command", commandHandler); let clickHandler = this.handleWidgetClick.bind(this, aWidget, node); node.addEventListener("click", clickHandler); node.classList.add("chromeclass-toolbar-additional"); // If the widget has a view, register a keypress handler because opening // a view with the keyboard has slightly different focus handling than // opening a view with the mouse. (When opened with the keyboard, the // first item in the view should be focused after opening.) if (viewbutton) { lazy.log.debug( "Widget " + aWidget.id + " has a view. Auto-registering event handlers." ); if (aWidget.source == CustomizableUI.SOURCE_BUILTIN) { node.classList.add("subviewbutton-nav"); } } if (aWidget.onCreated) { aWidget.onCreated(node); } } aWidget.instances.set(aDocument, node); return node; }, /** * @see CustomizableUI.ensureSubviewListeners * @param {Element} viewNode */ ensureSubviewListeners(viewNode) { if (viewNode._addedEventListeners) { return; } let viewId = viewNode.id; let widget = [...gPalette.values()].find(w => w.viewId == viewId); if (!widget) { return; } for (let eventName of kSubviewEvents) { let handler = "on" + eventName; if (typeof widget[handler] == "function") { viewNode.addEventListener(eventName, widget[handler]); } } viewNode._addedEventListeners = true; lazy.log.debug( "Widget " + widget.id + " showing and hiding event handlers set." ); }, /** * @see CustomizableUI.getLocalizedProperty * @param {string|object} aWidget * @param {string} aProp * @param {string[]} [aFormatArgs] * @param {string} [aDef] * @returns {string} */ getLocalizedProperty(aWidget, aProp, aFormatArgs, aDef) { const kReqStringProps = ["label"]; if (typeof aWidget == "string") { aWidget = gPalette.get(aWidget); } if (!aWidget) { throw new Error( "getLocalizedProperty was passed a non-widget to work with." ); } let def, name; // Let widgets pass their own string identifiers or strings, so that // we can use strings which aren't the default (in case string ids change) // and so that non-builtin-widgets can also provide labels, tooltips, etc. if (aWidget[aProp] != null) { name = aWidget[aProp]; // By using this as the default, if a widget provides a full string rather // than a string ID for localization, we will fall back to that string // and return that. def = aDef || name; } else { name = aWidget.id + "." + aProp; def = aDef || ""; } if (aWidget.localized === false) { return def; } try { if (Array.isArray(aFormatArgs) && aFormatArgs.length) { return ( lazy.gWidgetsBundle.formatStringFromName(name, aFormatArgs) || def ); } return lazy.gWidgetsBundle.GetStringFromName(name) || def; } catch (ex) { // If an empty string was explicitly passed, treat it as an actual // value rather than a missing property. if (!def && (name != "" || kReqStringProps.includes(aProp))) { lazy.log.error("Could not localize property '" + name + "'."); } } return def; }, /** * @see CustomizableUI.addShortcut * @param {Element} aShortcutNode * @param {Element|null} aTargetNode */ addShortcut(aShortcutNode, aTargetNode = aShortcutNode) { // Detect if we've already been here before. if (aTargetNode.hasAttribute("shortcut")) { return; } // Use ownerGlobal.document to ensure we get the right doc even for // elements in template tags. let { document } = aShortcutNode.ownerGlobal; let shortcutId = aShortcutNode.getAttribute("key"); let shortcut; if (shortcutId) { shortcut = document.getElementById(shortcutId); } else { let commandId = aShortcutNode.getAttribute("command"); if (commandId) { shortcut = lazy.ShortcutUtils.findShortcut( document.getElementById(commandId) ); } } if (!shortcut) { return; } aTargetNode.setAttribute( "shortcut", lazy.ShortcutUtils.prettifyShortcut(shortcut) ); }, /** * Executes a customizable UI widget's command handler to handle aEvent. * * @param {WidgetGroupWrapper|XULWidgetGroupWrapper} aWidget * The customizable UI widget to call the command handler for. * @param {Element} aNode * The node that the aEvent command event fired on. * @param {CommandEvent} aEvent * The command event to handle. */ doWidgetCommand(aWidget, aNode, aEvent) { if (aWidget.onCommand) { try { aWidget.onCommand.call(null, aEvent); } catch (e) { lazy.log.error(e); } } else { // XXXunf Need to think this through more, and formalize. Services.obs.notifyObservers( aNode, "customizedui-widget-command", aWidget.id ); } }, /** * Handles an event on a customizable UI widget node that has a panelview * associated with it. This routine will cause the panelview to be displayed. * * @param {WidgetGroupWrapper|XULWidgetGroupWrapper} aWidget * The customizable UI widget to show the panelview for. * @param {Element} aNode * The node that is handling the event that is causing the panelview to * show. * @param {Event} aEvent * The event that the node is handlign that is causing the panelview to * show. */ showWidgetView(aWidget, aNode, aEvent) { let ownerWindow = aNode.ownerGlobal; let area = this.getPlacementOfWidget(aNode.id).area; let areaType = CustomizableUI.getAreaType(area); let anchor = aNode; if ( aWidget.disallowSubView && (areaType == CustomizableUI.TYPE_PANEL || aNode.hasAttribute("overflowedItem")) ) { // Close the containing panel (e.g. overflow), PanelUI will reopen. let wrapper = this.wrapWidget(aWidget.id).forWindow(ownerWindow); if (wrapper?.anchor) { this.hidePanelForNode(aNode); anchor = wrapper.anchor; } } else if (areaType != CustomizableUI.TYPE_PANEL) { let wrapper = this.wrapWidget(aWidget.id).forWindow(ownerWindow); let hasMultiView = !!aNode.closest("panelmultiview"); if (!hasMultiView && wrapper?.anchor) { this.hidePanelForNode(aNode); anchor = wrapper.anchor; } } ownerWindow.PanelUI.showSubView(aWidget.viewId, anchor, aEvent); }, /** * Handles a command event on a customizable ui widget node, and does the * action that best suits the type of widget. * * @param {WidgetGroupWrapper|XULWidgetGroupWrapper} aWidget * The widget to handle the command event for. * @param {Element} aNode * The node that the command event is being fired on. * @param {CommandEvent} aEvent * The command event to be handled. */ handleWidgetCommand(aWidget, aNode, aEvent) { // Note that aEvent can be a keypress event for widgets of type "view". lazy.log.debug("handleWidgetCommand"); let action; if (aWidget.onBeforeCommand) { try { action = aWidget.onBeforeCommand.call(null, aEvent, aNode); } catch (e) { lazy.log.error(e); } } if (aWidget.type == "button" || action == "command") { this.doWidgetCommand(aWidget, aNode, aEvent); } else if (aWidget.type == "view" || action == "view") { this.showWidgetView(aWidget, aNode, aEvent); } else if (aWidget.type == "button-and-view") { // Do the command if we're in the toolbar and the button was clicked. // Otherwise, including when we have currently overflowed out of the // toolbar, open the view. There is no way to trigger the command while // the widget is in the panel, by design. let button = aNode.firstElementChild; let area = this.getPlacementOfWidget(aNode.id).area; let areaType = CustomizableUI.getAreaType(area); if ( areaType == CustomizableUI.TYPE_TOOLBAR && button.contains(aEvent.target) && !aNode.hasAttribute("overflowedItem") ) { this.doWidgetCommand(aWidget, aNode, aEvent); } else { this.showWidgetView(aWidget, aNode, aEvent); } } }, /** * Handles a click event on a customizable ui widget node, and redirects to * the widgets onClick event handler, if such a handler exists. * * @param {WidgetGroupWrapper|XULWidgetGroupWrapper} aWidget * The widget to call the onClick event handler for if such a handler * exists. If the handler does not exist, nothing is called. * @param {Element} aNode * The node that fired the click event. * @param {MouseEvent} aEvent * The click event to be handled. */ handleWidgetClick(aWidget, aNode, aEvent) { lazy.log.debug("handleWidgetClick"); if (aWidget.onClick) { try { aWidget.onClick.call(null, aEvent); } catch (e) { console.error(e); } } else { // XXXunf Need to think this through more, and formalize. Services.obs.notifyObservers( aNode, "customizedui-widget-click", aWidget.id ); } }, /** * Returns the closest element to aNode in its ancestry, or null * if no such node can be found. * * @param {Element} aNode * The node to check the ancestry for. * @returns {Element|null} */ _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, or on another * menu (like a context menu inside the panel). * * So this returns true if the event being handled is on one of these * interactive things that should NOT result in the associated panel closing. * * @param {Event} aEvent * The event that is occurring that we're evaluating. * @returns {boolean} * True if the event occurred on an item we consider "interactive" such * that the enclosing panel should remain open. False if the panel should * close. */ _isOnInteractiveElement(aEvent) { 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; } function getNextTarget(target) { if (target.nodeType == target.DOCUMENT_NODE) { if (!target.defaultView) { // Err, we're done. return null; } // Find containing browser or iframe element in the parent doc. return target.defaultView.docShell.chromeEventHandler; } // Skip any parent shadow roots return target.parentNode?.host?.parentNode || target.parentNode; } // 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 a menuItem. for ( let target = aEvent.originalTarget; target && target != panel; target = getNextTarget(target) ) { if (target.nodeType == target.DOCUMENT_NODE) { // Skip out of iframes etc: continue; } // Skip out of shadow roots if ( target.nodeType == target.DOCUMENT_FRAGMENT_NODE && target.containingShadowRoot == target ) { continue; } // Break out of the loop immediately for disabled items, as we need to // keep the menu open in that case. if (target.getAttribute("disabled") == "true") { return true; } let tagName = target.localName; if (tagName == "input" || tagName == "searchbar") { return true; } if (tagName == "toolbaritem" || tagName == "toolbarbutton") { // If we are in a type="menu" toolbarbutton, we'll now interact with // the menu. return target.getAttribute("type") == "menu"; } if (tagName == "menuitem") { // If we're in a nested menu we don't need to close this panel. return true; } } // We don't know what we interacted with, assume interactive. return true; }, /** * Finds the associated panel (if any) enclosing a given element, and * closes it. * * @param {Element} aNode * The node to close the panel ancestor for. */ hidePanelForNode(aNode) { let panel = this._getPanelForNode(aNode); if (panel) { lazy.PanelMultiView.hidePopup(panel); } }, /** * For an event that occurs for a node within a panel, this routine will * check to see if that event should cause the panel to close. * * @param {Event} aEvent * The event that is being fired on the node. This could be a keyboard, * mouse or command event, for example. */ 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" || target.hasAttribute("view-button-id") ) { return; } target = target.parentNode; // If we reached the shadow boundry, let's cross it while we head up // the tree. if (ShadowRoot.isInstance(target)) { target = target.host; } } // If we get here, we can actually hide the popup: this.hidePanelForNode(aEvent.target); }, /** * @see CustomizableUI.getUnusedWidgets * @param {DOMElement} aWindowPalette * @returns {Array} */ getUnusedWidgets(aWindowPalette) { let window = aWindowPalette.ownerGlobal; let isWindowPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate(window); // We use a Set because there can be overlap between the widgets in // gPalette and the items in the palette, especially after the first // customization, since programmatically generated widgets will remain // in the toolbox palette. let widgets = new Set(); // It's possible that some widgets have been defined programmatically and // have not been overlayed into the palette. We can find those inside // gPalette. for (let [id, widget] of gPalette) { if (!widget.currentArea) { if ( (isWindowPrivate && widget.showInPrivateBrowsing) || (!isWindowPrivate && !widget.hideInNonPrivateBrowsing) ) { widgets.add(id); } } } lazy.log.debug("Iterating the actual nodes of the window palette"); for (let node of aWindowPalette.children) { lazy.log.debug("In palette children: " + node.id); if (node.id && !this.getPlacementOfWidget(node.id)) { widgets.add(node.id); } } return [...widgets]; }, /** * @see CustomizableUI.getPlacementOfWidget * @param {string} aWidgetId * @param {boolean} [aOnlyRegistered=true] * @param {boolean} [aDeadAreas=false] * @returns {CustomizableUIPlacementInfo|null} */ getPlacementOfWidget(aWidgetId, aOnlyRegistered, aDeadAreas) { if (aOnlyRegistered && !this.widgetExists(aWidgetId)) { return null; } for (let [area, placements] of gPlacements) { if (!gAreas.has(area) && !aDeadAreas) { continue; } let index = placements.indexOf(aWidgetId); if (index != -1) { return { area, position: index }; } } return null; }, /** * Check for the current existance of a widget by ID. * * @see CustomizableUIInternal.isSpecialWidget * @param {string} aWidgetId * @returns {boolean} * Returns true if the widget ID belongs to a widget that is registered or * is a "special" widget (see isSpecialWidget). This will return false for * widget IDs belonging to widgets we have seen in the past, but are no * longer registered. There is also a special case for treating * the "save-to-pocket-button" as a non-existant widget. All other IDs are * presumed to belong to XUL widgets, and presumed to exist. */ widgetExists(aWidgetId) { if (gPalette.has(aWidgetId) || this.isSpecialWidget(aWidgetId)) { return true; } // Destroyed API widgets are in gSeenWidgets, but not in gPalette: // The Pocket button is a default API widget that acts like a custom widget. // If it's not in gPalette, it doesn't exist. if (gSeenWidgets.has(aWidgetId) || aWidgetId === "save-to-pocket-button") { return false; } // We're assuming XUL widgets always exist, as it's much harder to check, // and checking would be much more error prone. return true; }, /** * @see CustomizableUI.addWidgetToArea * @param {string} aWidgetId * @param {string} aArea * @param {number} aPosition * @param {boolean} [aInitialAdd=false] * True if this is the first time the widget is being added to the area * for the first time during initialization, or after a state reset. */ addWidgetToArea(aWidgetId, aArea, aPosition, aInitialAdd = false) { if (aArea == CustomizableUI.AREA_NO_AREA) { throw new Error( "AREA_NO_AREA is only used as an argument for " + "canWidgetMoveToArea. Use removeWidgetFromArea instead." ); } if (!gAreas.has(aArea)) { throw new Error("Unknown customization area: " + aArea); } // Hack: don't want special widgets in the panel (need to check here as well // as in canWidgetMoveToArea because the menu panel is lazy): if ( gAreas.get(aArea).get("type") == CustomizableUI.TYPE_PANEL && this.isSpecialWidget(aWidgetId) ) { return; } // If this is a lazy area that hasn't been restored yet, we can't yet modify // it - would would at least like to add to it. So we keep track of it in // gFuturePlacements, and use that to add it when restoring the area. We // throw away aPosition though, as that can only be bogus if the area hasn't // yet been restorted (caller can't possibly know where its putting the // widget in relation to other widgets). if (this.isAreaLazy(aArea)) { gFuturePlacements.get(aArea).add(aWidgetId); return; } if (this.isSpecialWidget(aWidgetId)) { aWidgetId = this.ensureSpecialWidgetId(aWidgetId); } let oldPlacement = this.getPlacementOfWidget(aWidgetId, false, true); if (oldPlacement && oldPlacement.area == aArea) { this.moveWidgetWithinArea(aWidgetId, aPosition); return; } // Do nothing if the widget is not allowed to move to the target area. if (!this.canWidgetMoveToArea(aWidgetId, aArea)) { return; } if (oldPlacement) { this.removeWidgetFromArea(aWidgetId); } if (!gPlacements.has(aArea)) { gPlacements.set(aArea, [aWidgetId]); aPosition = 0; } else { let placements = gPlacements.get(aArea); if (typeof aPosition != "number") { aPosition = placements.length; } if (aPosition < 0) { aPosition = 0; } placements.splice(aPosition, 0, aWidgetId); } let widget = gPalette.get(aWidgetId); if (widget) { widget.currentArea = aArea; widget.currentPosition = aPosition; } // We initially set placements with addWidgetToArea, so in that case // we don't consider the area "dirtied". if (!aInitialAdd) { gDirtyAreaCache.add(aArea); } gDirty = true; this.saveState(); this.notifyListeners("onWidgetAdded", aWidgetId, aArea, aPosition); }, /** * @see CustomizableUI.removeWidgetFromArea * @param {string} aWidgetId */ removeWidgetFromArea(aWidgetId) { let oldPlacement = this.getPlacementOfWidget(aWidgetId, false, true); if (!oldPlacement) { return; } if (!this.isWidgetRemovable(aWidgetId)) { return; } let placements = gPlacements.get(oldPlacement.area); let position = placements.indexOf(aWidgetId); if (position != -1) { placements.splice(position, 1); } let widget = gPalette.get(aWidgetId); if (widget) { widget.currentArea = null; widget.currentPosition = null; } gDirty = true; this.saveState(); gDirtyAreaCache.add(oldPlacement.area); // If we're in vertical tabs, ensure we don't restore the widget when we toggle back // to horizontal tabs. if (!gInBatchStack && CustomizableUI.verticalTabsEnabled) { if (oldPlacement.area == CustomizableUI.AREA_TABSTRIP) { this.deleteWidgetInSavedHorizontalTabStripState(aWidgetId); } else if ( oldPlacement.area == CustomizableUI.AREA_NAVBAR && this.getSavedHorizontalSnapshotState().includes(aWidgetId) ) { this.deleteWidgetInSavedHorizontalTabStripState(aWidgetId); this.deleteWidgetInSavedNavBarWhenVerticalTabsState(aWidgetId); } } this.notifyListeners("onWidgetRemoved", aWidgetId, oldPlacement.area); }, /** * @see CustomizableUI.moveWidgetWithinArea * @param {string} aWidgetId * @param {number} aPosition */ moveWidgetWithinArea(aWidgetId, aPosition) { let oldPlacement = this.getPlacementOfWidget(aWidgetId); if (!oldPlacement) { return; } let placements = gPlacements.get(oldPlacement.area); if (typeof aPosition != "number") { aPosition = placements.length; } else if (aPosition < 0) { aPosition = 0; } else if (aPosition > placements.length) { aPosition = placements.length; } let widget = gPalette.get(aWidgetId); if (widget) { widget.currentPosition = aPosition; widget.currentArea = oldPlacement.area; } if (aPosition == oldPlacement.position) { return; } placements.splice(oldPlacement.position, 1); // If we just removed the item from *before* where it is now added, // we need to compensate the position offset for that: if (oldPlacement.position < aPosition) { aPosition--; } placements.splice(aPosition, 0, aWidgetId); gDirty = true; gDirtyAreaCache.add(oldPlacement.area); this.saveState(); this.notifyListeners( "onWidgetMoved", aWidgetId, oldPlacement.area, oldPlacement.position, aPosition ); }, /** * Returns the horizontal tab strip's placements state that was saved the * last time we switched to vertical tabs mode. This state is an array of * widget IDs that had been in the tab strip prior to switching to vertical * tabs. * * @returns {string[]} */ getSavedHorizontalSnapshotState() { let state = []; let prefValue = lazy.horizontalPlacementsPref; if (prefValue) { try { state = JSON.parse(prefValue); } catch (e) { lazy.log.warn( `Failed to parse value of ${kPrefCustomizationHorizontalTabstrip}`, e ); } } return state; }, /** * Returns the vertical tab strip's placements state that was saved the * last time we switched to horizontal tabs mode. This state is an array of * widget IDs that had been in the vertical tab strip prior to switching to * horizontal tabs. * * @returns {string[]} */ getSavedVerticalSnapshotState() { let state = []; let prefValue = lazy.verticalPlacementsPref; if (prefValue) { try { state = JSON.parse(prefValue); } catch (e) { lazy.log.warn( `Failed to parse value of ${kPrefCustomizationNavBarWhenVerticalTabs}`, e ); } } return state; }, /** * Loads the saved customization state objects from preferences or sets them * to their defaults if no such state can be found. * * Note that this does not populate gPlacements, which is done lazily. */ loadSavedState() { let state = Services.prefs.getCharPref(kPrefCustomizationState, ""); if (!state) { lazy.log.debug("No saved state found"); // Nothing has been customized, so silently fall back to the defaults. return; } try { gSavedState = JSON.parse(state); if (typeof gSavedState != "object" || gSavedState === null) { throw new Error("Invalid saved state"); } } catch (e) { Services.prefs.clearUserPref(kPrefCustomizationState); gSavedState = {}; lazy.log.debug( "Error loading saved UI customization state, falling back to defaults." ); } if (!("placements" in gSavedState)) { gSavedState.placements = {}; } if (!("currentVersion" in gSavedState)) { gSavedState.currentVersion = 0; } gSeenWidgets = new Set(gSavedState.seen || []); gDirtyAreaCache = new Set(gSavedState.dirtyAreaCache || []); gNewElementCount = gSavedState.newElementCount || 0; }, /** * Restores the placements of widgets within an area with ID aAreaId from * saved state, or sets the default placements if no such saved state exists. * This should be called during area registration, or after a state reset. * * @param {string} aAreaId * The ID of the area to restore the state for. */ restoreStateForArea(aAreaId) { let placementsPreexisted = gPlacements.has(aAreaId); this.beginBatchUpdate(); try { gRestoring = true; let restored = false; if (placementsPreexisted) { lazy.log.debug( "Restoring " + aAreaId + " from pre-existing placements" ); for (let [position, id] of gPlacements.get(aAreaId).entries()) { this.moveWidgetWithinArea(id, position); } gDirty = false; restored = true; } else { gPlacements.set(aAreaId, []); } if (!restored && gSavedState && aAreaId in gSavedState.placements) { lazy.log.debug("Restoring " + aAreaId + " from saved state"); let placements = gSavedState.placements[aAreaId]; for (let id of placements) { this.addWidgetToArea(id, aAreaId); } gDirty = false; restored = true; } if (!restored) { lazy.log.debug("Restoring " + aAreaId + " from default state"); let defaults = gAreas.get(aAreaId).get("defaultPlacements"); if ( CustomizableUI.verticalTabsEnabled && gAreas.get(aAreaId).has("verticalTabsDefaultPlacements") ) { lazy.log.debug( "Using verticalTabsDefaultPlacements to restore " + aAreaId ); defaults = gAreas.get(aAreaId).get("verticalTabsDefaultPlacements"); } if (defaults) { for (let id of defaults) { this.addWidgetToArea(id, aAreaId, null, true); } } gDirty = false; } // Finally, add widgets to the area that were added before the it was able // to be restored. This can occur when add-ons register widgets for a // lazily-restored area before it's been restored. if (gFuturePlacements.has(aAreaId)) { let areaPlacements = gPlacements.get(aAreaId); for (let id of gFuturePlacements.get(aAreaId)) { if (areaPlacements.includes(id)) { continue; } this.addWidgetToArea(id, aAreaId); } gFuturePlacements.delete(aAreaId); } lazy.log.debug( "Placements for " + aAreaId + ":\n\t" + gPlacements.get(aAreaId).join("\n\t") ); gRestoring = false; } finally { this.endBatchUpdate(); } }, /** * Adds widgets to the AREA_TABSTRIP area that were saved when switching away * from horizontal tabs. This will effectively rebuild AREA_TABSTRIP to * match the state in savedPlacements, and then clear the saved horizontal * tab state. * * @param {string[]} [savedPlacements=this.getSavedHorizontalSnapshotState()] * The array of widget IDs to add to the AREA_TABSTRIP, in order. If this * set of placements doesn't include "tabbrowser-tabs" for some reason, the * whole set of placements is ignored and the tab strip is reset to the * defaults. * @param {boolean} [isInitializing = false] * True if the horizontal tab strip is being rebuilt during CustomizableUI * initialization as opposed to via a pref-flip at runtime. */ restoreSavedHorizontalTabStripState( savedPlacements = this.getSavedHorizontalSnapshotState(), isInitializing = false ) { const tabstripAreaId = CustomizableUI.AREA_TABSTRIP; lazy.log.debug( `restoreSavedHorizontalTabStripState, ${kPrefCustomizationHorizontalTabstrip} contained:`, savedPlacements ); // If there's no saved state, or it doesn't pass the sniff test, use // default placements instead if (!savedPlacements.includes("tabbrowser-tabs")) { savedPlacements = gAreas.get(tabstripAreaId).get("defaultPlacements"); lazy.log.debug(`Using defaultPlacements for ${tabstripAreaId}`); } lazy.log.debug( `Replacing existing placements: ${gPlacements.get( tabstripAreaId )}, with ${savedPlacements}.` ); // Restore the tabstrip to either saved or default placements this.beginBatchUpdate(); for (let [index, widgetId] of savedPlacements.entries()) { this.addWidgetToArea(widgetId, tabstripAreaId, index, isInitializing); } // Wipe the pref now that state is restored Services.prefs.clearUserPref(kPrefCustomizationHorizontalTabstrip); // The vertical tabstrip area is supposed to be empty when we switch back to horizontal if (gPlacements.get(CustomizableUI.AREA_VERTICAL_TABSTRIP)?.length) { lazy.log.warn( `Widgets remain in ${CustomizableUI.AREA_VERTICAL_TABSTRIP}:`, gPlacements.get(CustomizableUI.AREA_VERTICAL_TABSTRIP) ); } this.endBatchUpdate(); }, /** * Looks for a widget with a matching ID to aWidgetId within the saved * horizontal tab strip state, and then deletes it from that state before * saving that state to preferences. * * @see CustomizableUIInternal.saveHorizontalTabStripState * @param {string} aWidgetId * The ID of the widget to remove from the saved horizontal tabstrip state. */ deleteWidgetInSavedHorizontalTabStripState(aWidgetId) { const savedPlacements = this.getSavedHorizontalSnapshotState(); let position = savedPlacements.indexOf(aWidgetId); if (position != -1) { savedPlacements.splice(position, 1); this.saveHorizontalTabStripState(savedPlacements); } }, /** * Looks for a widget with a matching ID to aWidgetId within the navbar state * that was saved when switching away from vertical tabs mode, and then * deletes it from that state before saving that state to preferences. * * @see CustomizableUIInternal.saveNavBarWhenVerticalTabsState * @param {string} aWidgetId * The ID of the widget to remove from the saved navbar state. */ deleteWidgetInSavedNavBarWhenVerticalTabsState(aWidgetId) { const savedPlacements = this.getSavedVerticalSnapshotState(); let position = savedPlacements.indexOf(aWidgetId); if (position != -1) { savedPlacements.splice(position, 1); this.saveNavBarWhenVerticalTabsState(savedPlacements); } }, /** * Takes the current set of widgets placed within the horizontal tab strip * and saves their IDs to a preference. This is used just before switching * to vertical tabs mode (which moves some widgets around), and the saved * state is used to restore the horizontal tab mode state of the tab * strip. * * @param {string[]} [placements=[]] * The placements within the horizontal tab strip to save to preferences. * If this is the empty array, this method will use the current placements * of the tab strip automatically. */ saveHorizontalTabStripState(placements = []) { if (!placements.length) { placements = this.getAreaPlacementsForSaving( CustomizableUI.AREA_TABSTRIP ); } let serialized = JSON.stringify(placements, this.serializerHelper); lazy.log.debug("Saving horizontal tabstrip state.", serialized); Services.prefs.setCharPref( kPrefCustomizationHorizontalTabstrip, serialized ); }, /** * Takes the current set of widgets placed within the navbar while in * vertical tabs mode, and and saves their IDs to a preference. This is used * just before switching to horizontal tabs mode (which moves some widgets * around), and the saved state is used to restore the navbar's widget * placements if the user switches back to vertical tabs. * * @param {string[]} [placements=[]] * The placements within the navbar to save to preferences. If this is the * empty array, this method will use the current placements of the navbar * automatically. */ saveNavBarWhenVerticalTabsState(placements = []) { if (!placements.length) { placements = this.getAreaPlacementsForSaving(CustomizableUI.AREA_NAVBAR); } let serialized = JSON.stringify(placements, this.serializerHelper); lazy.log.debug("Saving vertical navbar state.", serialized); Services.prefs.setCharPref( kPrefCustomizationNavBarWhenVerticalTabs, serialized ); }, /** * Returns the placements of widgets within a known area, regardless of * whether or not the area has already been built, or is still registered. * This means that we can get the placements for an area that was registered * in the past, is no longer registered, but still exists within the * saved state. * * @param {string} aAreaId * The ID of the area to get the placements for. * @returns {string[]|undefined} * Returns the placements for the area, or undefined if the area is not * recognized. */ getAreaPlacementsForSaving(aAreaId) { // An early call to saveState can occur before all the lazy-area building is complete let placements; if (this.isAreaLazy(aAreaId) && gFuturePlacements.get(aAreaId)?.size) { placements = [...gFuturePlacements.get(aAreaId)]; } else if (gPlacements.has(aAreaId)) { placements = gPlacements.get(aAreaId); } // Merge in previously saved areas if not present in gPlacements/gFuturePlacements. // This way, state is still persisted for e.g. temporarily disabled // add-ons - see bug 989338. if (!placements && gSavedState && gSavedState.placements?.[aAreaId]) { placements = gSavedState.placements[aAreaId]; } lazy.log.debug( `getAreaPlacementsForSaving for area: ${aAreaId}, gPlacements for area: ${gPlacements.get( aAreaId )}, returning: ${placements}` ); return placements; }, /** * Saves the current state of all customizable areas to preferences. */ saveState() { if (gInBatchStack || !gDirty) { return; } // Clone because we want to modify this map: let placements = new Map(); // Because of Bug 989338 and the risk of having area ids that aren't yet registered, // we collect the areas from both gPlacements and gSavedState rather than gAreas. let allAreaIds = new Set([...gPlacements.keys()]); if (gSavedState?.placements) { for (let area of Object.keys(gSavedState.placements)) { allAreaIds.add(area); } } for (let area of allAreaIds) { placements.set(area, this.getAreaPlacementsForSaving(area)); } let state = { placements, seen: gSeenWidgets, dirtyAreaCache: gDirtyAreaCache, currentVersion: kVersion, newElementCount: gNewElementCount, }; lazy.log.debug("Saving state."); let serialized = JSON.stringify(state, this.serializerHelper); lazy.log.debug("State saved as: " + serialized); Services.prefs.setCharPref(kPrefCustomizationState, serialized); gDirty = false; }, /** * This helper is passed to JSON.stringify when serializing the current * customizable areas to preferences in saveState(). This does the work * of serializing Map and Sets to objects and arrays, respectively. * * @see CustomizableUIInternal.saveState() * @param {string|symbol} _aKey * @param {any} aValue * @returns {any} */ serializerHelper(_aKey, aValue) { if (typeof aValue == "object" && aValue.constructor.name == "Map") { let result = {}; for (let [mapKey, mapValue] of aValue) { result[mapKey] = mapValue; } return result; } if (typeof aValue == "object" && aValue.constructor.name == "Set") { return [...aValue]; } return aValue; }, /** * @see CustomizableUI.beginBatchUpdate() */ beginBatchUpdate() { gInBatchStack++; }, /** * @see CustomizableUI.endBatchUpdate() * @param {boolean} aForceDirty */ endBatchUpdate(aForceDirty) { gInBatchStack--; if (aForceDirty === true) { gDirty = true; } if (gInBatchStack == 0) { this.saveState(); } else if (gInBatchStack < 0) { throw new Error( "The batch editing stack should never reach a negative number." ); } }, /** * @see CustomizableUI.addListener * @param {CustomizableUIListener} aListener */ addListener(aListener) { gListeners.add(aListener); }, /** * @see CustomizableUI.removeListener * @param {CustomizableUIListener} aListener */ removeListener(aListener) { if (aListener == this) { return; } gListeners.delete(aListener); }, /** * For any listeners registered via `addListener` or `removeListener`, this * calls the appropriate listener function for a particular CustomizableUI * event if it is defined on the listener, passing along the arguments. * * @param {string} aListenerName * The name of the listener that should be called. This is a string * identifier of something that can be listened for via a * CustomizableUIListener - for example, "onWidgetCreated". * @param {...any} aArgs * The arguments to pass to the CustomizableUIListener function. */ notifyListeners(aListenerName, ...aArgs) { if (gRestoring) { return; } for (let listener of gListeners) { try { if (typeof listener[aListenerName] == "function") { listener[aListenerName].apply(listener, aArgs); } } catch (e) { lazy.log.error(e + " -- " + e.fileName + ":" + e.lineNumber); } } }, /** * Constructs a CustomEvent with the aEventType type that is bubbling and * cancelable, and includes the details passed on aDetails. This event is * then dispatched on the gNavToolbox in aWindow. * * @see CustomizableUIInternal.dispatchToolboxEvent * @param {string} aEventType * The type of the CustomEvent to fire. * @param {any} aDetails * The details to assign to the event being fired. * @param {DOMWindow} aWindow * The browser window containing the gNavToolbox which will have the event * dispatched on it. */ _dispatchToolboxEventToWindow(aEventType, aDetails, aWindow) { let evt = new aWindow.CustomEvent(aEventType, { bubbles: true, cancelable: true, detail: aDetails, }); aWindow.gNavToolbox.dispatchEvent(evt); }, /** * Constructs a CustomEvent with the aEventType type that is bubbling and * cancelable, and includes the details passed on aDetails. This event is * then dispatched on the gNavToolbox in aWindow if one is provided. If no * window is provided, this is dispatched on the gNavToolbox for all * registered windows. * * @param {string} aEventType * The type of the CustomEvent to fire. * @param {any} [aDetails={}] * The details to assign to the event being fired. * @param {DOMWindow} [aWindow=null] * The browser window containing the gNavToolbox which will have the event * dispatched on it, or null to dispatch to all gNavToolbox elements in * all registered windows. */ dispatchToolboxEvent(aEventType, aDetails = {}, aWindow = null) { if (aWindow) { this._dispatchToolboxEventToWindow(aEventType, aDetails, aWindow); return; } for (let [win] of gBuildWindows) { this._dispatchToolboxEventToWindow(aEventType, aDetails, win); } }, /** * @see CustomizableUI.createWidget * @param {CustomizableUICreateWidgetProperties} aProperties * @returns {string} * The ID of the created widget. */ createWidget(aProperties) { let widget = this.normalizeWidget( aProperties, CustomizableUI.SOURCE_EXTERNAL ); // XXXunf This should probably throw. if (!widget) { lazy.log.error("unable to normalize widget"); return undefined; } gPalette.set(widget.id, widget); // Clear our caches: gGroupWrapperCache.delete(widget.id); for (let [win] of gBuildWindows) { let cache = gSingleWrapperCache.get(win); if (cache) { cache.delete(widget.id); } } this.notifyListeners("onWidgetCreated", widget.id); if (widget.defaultArea) { let addToDefaultPlacements = false; let area = gAreas.get(widget.defaultArea); if ( !CustomizableUI.isBuiltinToolbar(widget.defaultArea) && widget.defaultArea != CustomizableUI.AREA_FIXED_OVERFLOW_PANEL ) { addToDefaultPlacements = true; } if (addToDefaultPlacements) { if (area.has("defaultPlacements")) { area.get("defaultPlacements").push(widget.id); } else { area.set("defaultPlacements", [widget.id]); } } } // Look through previously saved state to see if we're restoring a widget. let seenAreas = new Set(); let widgetMightNeedAutoAdding = true; for (let [area] of gPlacements) { seenAreas.add(area); let areaIsRegistered = gAreas.has(area); let index = gPlacements.get(area).indexOf(widget.id); if (index != -1) { widgetMightNeedAutoAdding = false; if (areaIsRegistered) { widget.currentArea = area; widget.currentPosition = index; } break; } } // Also look at saved state data directly in areas that haven't yet been // restored. Can't rely on this for restored areas, as they may have // changed. if (widgetMightNeedAutoAdding && gSavedState) { for (let area of Object.keys(gSavedState.placements)) { if (seenAreas.has(area)) { continue; } let areaIsRegistered = gAreas.has(area); let index = gSavedState.placements[area].indexOf(widget.id); if (index != -1) { widgetMightNeedAutoAdding = false; if (areaIsRegistered) { widget.currentArea = area; widget.currentPosition = index; } break; } } } // If we're restoring the widget to it's old placement, fire off the // onWidgetAdded event - our own handler will take care of adding it to // any build areas. this.beginBatchUpdate(); try { if (widget.currentArea) { this.notifyListeners( "onWidgetAdded", widget.id, widget.currentArea, widget.currentPosition ); } else if (widgetMightNeedAutoAdding) { let autoAdd = Services.prefs.getBoolPref( kPrefCustomizationAutoAdd, true ); // If the widget doesn't have an existing placement, and it hasn't been // seen before, then add it to its default area so it can be used. // If the widget is not removable, we *have* to add it to its default // area here. let canBeAutoAdded = autoAdd && !gSeenWidgets.has(widget.id); if (!widget.currentArea && (!widget.removable || canBeAutoAdded)) { if (widget.defaultArea) { if (this.isAreaLazy(widget.defaultArea)) { gFuturePlacements.get(widget.defaultArea).add(widget.id); } else { this.addWidgetToArea(widget.id, widget.defaultArea); } } } // Extension widgets cannot enter the customization palette, so if // at this point, we haven't found an area for them, move them into // AREA_ADDONS. if ( !widget.currentArea && CustomizableUI.isWebExtensionWidget(widget.id) ) { this.addWidgetToArea(widget.id, CustomizableUI.AREA_ADDONS); } } } finally { // Ensure we always have this widget in gSeenWidgets, and save // state in case this needs to be done here. gSeenWidgets.add(widget.id); this.endBatchUpdate(true); } this.notifyListeners( "onWidgetAfterCreation", widget.id, widget.currentArea ); return widget.id; }, /** * Creates the widgets that are defined statically within the browser in * CustomizableWidgets. * * @param {CustomizableUICreateWidgetProperties} aData */ createBuiltinWidget(aData) { // This should only ever be called on startup, before any windows are // opened - so we know there's no build areas to handle. Also, builtin // widgets are expected to be (mostly) static, so shouldn't affect the // current placement settings. // This allows a widget to be both built-in by default but also able to be // destroyed and removed from the area based on criteria that may not be // available when the widget is created -- for example, because some other // feature in the browser supersedes the widget. let conditionalDestroyPromise = aData.conditionalDestroyPromise || null; delete aData.conditionalDestroyPromise; let widget = this.normalizeWidget(aData, CustomizableUI.SOURCE_BUILTIN); if (!widget) { lazy.log.error("Error creating builtin widget: " + aData.id); return; } lazy.log.debug("Creating built-in widget with id: " + widget.id); gPalette.set(widget.id, widget); if (conditionalDestroyPromise) { conditionalDestroyPromise.then( shouldDestroy => { if (shouldDestroy) { this.destroyWidget(widget.id); this.removeWidgetFromArea(widget.id); } }, err => { console.error(err); } ); } }, /** * Returns true if the associated area will eventually lazily restore (but * hasn't yet). * * @param {string} aAreaId * @returns {boolean} */ isAreaLazy(aAreaId) { if (gPlacements.has(aAreaId) || !gAreas.has(aAreaId)) { return false; } return gAreas.get(aAreaId).get("type") == CustomizableUI.TYPE_TOOLBAR; }, /** * Given a set of CustomizableUICreateWidgetProperties, attempts to * create a "normalized" version of that object with default values where * aData failed to define values, as well as properly wrapped event handlers. * * @param {CustomizableUICreateWidgetProperties} aData * @param {string} aSource * One of the CustomizableUI.SOURCE_* constants, for example * CustomizableUI.SOURCE_EXTERNAL. * @returns {object} * The normalized widget representation. Notably, the `implementation` * property of this widget will point to the original aData structure. */ normalizeWidget(aData, aSource) { let widget = { implementation: aData, source: aSource || CustomizableUI.SOURCE_EXTERNAL, instances: new Map(), currentArea: null, localized: true, removable: true, overflows: true, defaultArea: null, shortcutId: null, tabSpecific: false, locationSpecific: false, tooltiptext: null, l10nId: null, showInPrivateBrowsing: true, hideInNonPrivateBrowsing: false, _introducedInVersion: -1, _introducedByPref: null, keepBroadcastAttributesWhenCustomizing: false, disallowSubView: false, webExtension: false, }; if (typeof aData.id != "string" || !/^[a-z0-9-_]{1,}$/i.test(aData.id)) { lazy.log.error("Given an illegal id in normalizeWidget: " + aData.id); return null; } delete widget.implementation.currentArea; widget.implementation.__defineGetter__( "currentArea", () => widget.currentArea ); const kReqStringProps = ["id"]; for (let prop of kReqStringProps) { if (typeof aData[prop] != "string") { lazy.log.error( "Missing required property '" + prop + "' in normalizeWidget: " + aData.id ); return null; } widget[prop] = aData[prop]; } const kOptStringProps = ["l10nId", "label", "tooltiptext", "shortcutId"]; for (let prop of kOptStringProps) { if (typeof aData[prop] == "string") { widget[prop] = aData[prop]; } } const kOptBoolProps = [ "removable", "showInPrivateBrowsing", "hideInNonPrivateBrowsing", "overflows", "tabSpecific", "locationSpecific", "localized", "keepBroadcastAttributesWhenCustomizing", "disallowSubView", "webExtension", ]; for (let prop of kOptBoolProps) { if (typeof aData[prop] == "boolean") { widget[prop] = aData[prop]; } } // When we normalize builtin widgets, areas have not yet been registered: if ( aData.defaultArea && (aSource == CustomizableUI.SOURCE_BUILTIN || gAreas.has(aData.defaultArea)) ) { widget.defaultArea = aData.defaultArea; } else if (!widget.removable) { lazy.log.error( "Widget '" + widget.id + "' is not removable but does not specify " + "a valid defaultArea. That's not possible; it must specify a " + "valid defaultArea as well." ); return null; } if ("type" in aData && gSupportedWidgetTypes.has(aData.type)) { widget.type = aData.type; } else { widget.type = "button"; } widget.disabled = aData.disabled === true; if (aSource == CustomizableUI.SOURCE_BUILTIN) { widget._introducedInVersion = aData.introducedInVersion || 0; if (aData._introducedByPref) { widget._introducedByPref = aData._introducedByPref; } } this.wrapWidgetEventHandler("onBeforeCreated", widget); this.wrapWidgetEventHandler("onClick", widget); this.wrapWidgetEventHandler("onCreated", widget); this.wrapWidgetEventHandler("onDestroyed", widget); if (typeof aData.onBeforeCommand == "function") { widget.onBeforeCommand = aData.onBeforeCommand; } if (typeof aData.onCommand == "function") { widget.onCommand = aData.onCommand; } if ( widget.type == "view" || widget.type == "button-and-view" || aData.viewId ) { if (typeof aData.viewId != "string") { lazy.log.error( "Expected a string for widget " + widget.id + " viewId, but got " + aData.viewId ); return null; } widget.viewId = aData.viewId; this.wrapWidgetEventHandler("onViewShowing", widget); this.wrapWidgetEventHandler("onViewHiding", widget); } if (widget.type == "custom") { this.wrapWidgetEventHandler("onBuild", widget); } if (gPalette.has(widget.id)) { return null; } return widget; }, /** * Given some widget definition object, forwards calls to functions with the * name aEventName to the underlying implementation objects copy of that * function. * * @param {string} aEventName * The name of the function to redirect to the underlying implementations * version of that same named function. * @param {object} aWidget * A "normalized" widget definition as computed by `normalizeWidget()`. */ wrapWidgetEventHandler(aEventName, aWidget) { if (typeof aWidget.implementation[aEventName] != "function") { aWidget[aEventName] = null; return; } aWidget[aEventName] = function (...aArgs) { try { // Don't copy the function to the normalized widget object, instead // keep it on the original object provided to the API so that // additional methods can be implemented and used by the event // handlers. return aWidget.implementation[aEventName].apply( aWidget.implementation, aArgs ); } catch (e) { console.error(e); return undefined; } }; }, /** * @see CustomizableUI.destroyWidget * @param {string} aWidgetId */ destroyWidget(aWidgetId) { let widget = gPalette.get(aWidgetId); if (!widget) { gGroupWrapperCache.delete(aWidgetId); for (let [window] of gBuildWindows) { let windowCache = gSingleWrapperCache.get(window); if (windowCache) { windowCache.delete(aWidgetId); } } return; } // Remove it from the default placements of an area if it was added there: if (widget.defaultArea) { let area = gAreas.get(widget.defaultArea); if (area) { let defaultPlacements = area.get("defaultPlacements"); // We can assume this is present because if a widget has a defaultArea, // we automatically create a defaultPlacements array for that area. let widgetIndex = defaultPlacements.indexOf(aWidgetId); if (widgetIndex != -1) { defaultPlacements.splice(widgetIndex, 1); } } } // This will not remove the widget from gPlacements - we want to keep the // setting so the widget gets put back in it's old position if/when it // returns. for (let [window] of gBuildWindows) { let windowCache = gSingleWrapperCache.get(window); if (windowCache) { windowCache.delete(aWidgetId); } let widgetNode = window.document.getElementById(aWidgetId) || window.gNavToolbox.palette.getElementsByAttribute("id", aWidgetId)[0]; if (widgetNode) { let container = widgetNode.parentNode; this.notifyListeners( "onWidgetBeforeDOMChange", widgetNode, null, container, true ); widgetNode.remove(); this.notifyListeners( "onWidgetAfterDOMChange", widgetNode, null, container, true ); } if ( widget.type == "view" || widget.type == "button-and-view" || widget.viewId ) { let viewNode = window.document.getElementById(widget.viewId); if (viewNode) { for (let eventName of kSubviewEvents) { let handler = "on" + eventName; if (typeof widget[handler] == "function") { viewNode.removeEventListener(eventName, widget[handler]); } } viewNode._addedEventListeners = false; } } if (widgetNode && widget.onDestroyed) { widget.onDestroyed(window.document); } } gPalette.delete(aWidgetId); gGroupWrapperCache.delete(aWidgetId); this.notifyListeners("onWidgetDestroyed", aWidgetId); }, /** * @see CustomizableUI.getCustomizeTargetForArea * @param {string} aArea * @param {DOMWindow} aWindow * @returns {Element} */ getCustomizeTargetForArea(aArea, aWindow) { let buildAreaNodes = gBuildAreas.get(aArea); if (!buildAreaNodes) { return null; } for (let node of buildAreaNodes) { if (node.ownerGlobal == aWindow) { return this.getCustomizationTarget(node) || node; } } return null; }, /** * @see CustomizableUI.reset() */ reset() { gResetting = true; // CUI reset also implies resetting verticalTabs back to false. // We do this before the rest of the reset so widgets are reset to their non-vertical // positions. Services.prefs.setBoolPref("sidebar.verticalTabs", false); this._resetUIState(); // Rebuild each registered area (across windows) to reflect the state that // was reset above. this._rebuildRegisteredAreas(); for (let [widgetId, widget] of gPalette) { if (widget.source == CustomizableUI.SOURCE_EXTERNAL) { gSeenWidgets.add(widgetId); } } if (gSeenWidgets.size || gNewElementCount) { gDirty = true; this.saveState(); } gResetting = false; }, /** * Persists the current state to gUIStateBeforeReset (in order to temporarily * allow for undoing CustomizableUI resets) and then blows away the current * CustomizableUI state (including saved prefs) and sets them to their * defaults. This also rebuilds all of the registered areas to reflect the * defaults. */ _resetUIState() { try { gUIStateBeforeReset.drawInTitlebar = Services.prefs.getIntPref(kPrefDrawInTitlebar); gUIStateBeforeReset.uiCustomizationState = Services.prefs.getCharPref( kPrefCustomizationState ); gUIStateBeforeReset.uiDensity = Services.prefs.getIntPref(kPrefUIDensity); gUIStateBeforeReset.autoTouchMode = Services.prefs.getBoolPref(kPrefAutoTouchMode); gUIStateBeforeReset.currentTheme = gSelectedTheme; gUIStateBeforeReset.autoHideDownloadsButton = Services.prefs.getBoolPref( kPrefAutoHideDownloadsButton ); gUIStateBeforeReset.newElementCount = gNewElementCount; gUIStateBeforeReset.sidebarPositionStart = Services.prefs.getBoolPref( kPrefSidebarPositionStartEnabled ); } catch (e) {} Services.prefs.clearUserPref(kPrefCustomizationState); Services.prefs.clearUserPref(kPrefDrawInTitlebar); Services.prefs.clearUserPref(kPrefUIDensity); Services.prefs.clearUserPref(kPrefAutoTouchMode); Services.prefs.clearUserPref(kPrefAutoHideDownloadsButton); Services.prefs.clearUserPref(kPrefSidebarPositionStartEnabled); gDefaultTheme.enable(); gNewElementCount = 0; lazy.log.debug("State reset"); // Later in the function, we're going to add any area-less extension // buttons to the AREA_ADDONS area. We'll remember the old placements // for that area so that we don't need to re-add widgets that are already // in there in the DOM. let oldAddonPlacements = gPlacements[CustomizableUI.AREA_ADDONS] || []; // Reset placements to make restoring default placements possible. gPlacements = new Map(); gDirtyAreaCache = new Set(); gSeenWidgets = new Set(); // Clear the saved state to ensure that defaults will be used. gSavedState = null; // Restore the state for each area to its defaults for (let [areaId] of gAreas) { // If the Unified Extensions UI is enabled, we'll be adding any // extension buttons that aren't already in AREA_ADDONS there, // so we can skip restoring the state for it. if (areaId != CustomizableUI.AREA_ADDONS) { this.restoreStateForArea(areaId); } } // restoreStateForArea will have normally set an array for the placements // for each area, but since we skip AREA_ADDONS intentionally, that array // doesn't get set, so we do that manually here. gPlacements.set(CustomizableUI.AREA_ADDONS, []); for (let [widgetId] of gPalette) { if ( CustomizableUI.isWebExtensionWidget(widgetId) && !oldAddonPlacements.includes(widgetId) ) { this.addWidgetToArea(widgetId, CustomizableUI.AREA_ADDONS); } } }, /** * For all registered areas, builds those areas to reflect the current * placement state of all widgets. */ _rebuildRegisteredAreas() { for (let [areaId, areaNodes] of gBuildAreas) { let placements = gPlacements.get(areaId); let isFirstChangedToolbar = true; for (let areaNode of areaNodes) { this.buildArea(areaId, placements, areaNode); let area = gAreas.get(areaId); if (area.get("type") == CustomizableUI.TYPE_TOOLBAR) { let defaultCollapsed = area.get("defaultCollapsed"); let win = areaNode.ownerGlobal; if (defaultCollapsed !== null) { win.setToolbarVisibility( areaNode, typeof defaultCollapsed == "string" ? defaultCollapsed : !defaultCollapsed, isFirstChangedToolbar ); } } isFirstChangedToolbar = false; } } }, /** * Undoes a previous reset, restoring the state of the UI to the state prior * to the reset. */ undoReset() { if ( gUIStateBeforeReset.uiCustomizationState == null || gUIStateBeforeReset.drawInTitlebar == null ) { return; } gUndoResetting = true; const { uiCustomizationState, drawInTitlebar, currentTheme, uiDensity, autoTouchMode, autoHideDownloadsButton, sidebarPositionStart, } = gUIStateBeforeReset; gNewElementCount = gUIStateBeforeReset.newElementCount; // Need to clear the previous state before setting the prefs // because pref observers may check if there is a previous UI state. this._clearPreviousUIState(); Services.prefs.setCharPref(kPrefCustomizationState, uiCustomizationState); Services.prefs.setIntPref(kPrefDrawInTitlebar, drawInTitlebar); Services.prefs.setIntPref(kPrefUIDensity, uiDensity); Services.prefs.setBoolPref(kPrefAutoTouchMode, autoTouchMode); Services.prefs.setBoolPref( kPrefAutoHideDownloadsButton, autoHideDownloadsButton ); Services.prefs.setBoolPref( kPrefSidebarPositionStartEnabled, sidebarPositionStart ); currentTheme.enable(); this.loadSavedState(); // If the user just customizes toolbar/titlebar visibility, gSavedState will be null // and we don't need to do anything else here: if (gSavedState) { for (let areaId of Object.keys(gSavedState.placements)) { let placements = gSavedState.placements[areaId]; gPlacements.set(areaId, placements); } this._rebuildRegisteredAreas(); } gUndoResetting = false; }, /** * Clears the persisted state that was snapshotted just before the most * recent reset of the state. */ _clearPreviousUIState() { Object.getOwnPropertyNames(gUIStateBeforeReset).forEach(prop => { gUIStateBeforeReset[prop] = null; }); }, /** * @param {string|Node} aWidget * Widget ID or a widget node (preferred for performance). * @returns {boolean} * True if the widget is removable. */ isWidgetRemovable(aWidget) { let widgetId; let widgetNode; if (typeof aWidget == "string") { widgetId = aWidget; } else { // Skipped items could just not have ids. if (!aWidget.id && aWidget.getAttribute("skipintoolbarset") == "true") { return false; } if ( !aWidget.id && !["toolbarspring", "toolbarspacer", "toolbarseparator"].includes( aWidget.nodeName ) ) { throw new Error( "No nodes without ids that aren't special widgets should ever come into contact with CUI" ); } // Use "spring" / "spacer" / "separator" for special widgets without ids widgetId = aWidget.id || aWidget.nodeName.substring(7 /* "toolbar".length */); widgetNode = aWidget; } let provider = this.getWidgetProvider(widgetId); if (provider == CustomizableUI.PROVIDER_API) { return gPalette.get(widgetId).removable; } if (provider == CustomizableUI.PROVIDER_XUL) { if (gBuildWindows.size == 0) { // We don't have any build windows to look at, so just assume for now // that its removable. return true; } if (!widgetNode) { // Pick any of the build windows to look at. let [window] = [...gBuildWindows][0]; [, widgetNode] = this.getWidgetNode(widgetId, window); } // If we don't have a node, we assume it's removable. This can happen because // getWidgetProvider returns PROVIDER_XUL by default, but this will also happen // for API-provided widgets which have been destroyed. if (!widgetNode) { return true; } return widgetNode.getAttribute("removable") == "true"; } // Otherwise this is either a special widget, which is always removable, or // an API widget which has already been removed from gPalette. Returning true // here allows us to then remove its ID from any placements where it might // still occur. return true; }, /** * @see CustomizableUI.canWidgetMoveToArea * @param {string} aWidgetId * @param {string} aArea * @returns {boolean} */ canWidgetMoveToArea(aWidgetId, aArea) { // Special widgets can't move to the menu panel. if ( this.isSpecialWidget(aWidgetId) && gAreas.has(aArea) && gAreas.get(aArea).get("type") == CustomizableUI.TYPE_PANEL ) { return false; } if ( aArea == CustomizableUI.AREA_ADDONS && !CustomizableUI.isWebExtensionWidget(aWidgetId) ) { return false; } if (CustomizableUI.isWebExtensionWidget(aWidgetId)) { // Extension widgets cannot move to the customization palette. if (aArea == CustomizableUI.AREA_NO_AREA) { return false; } // Extension widgets cannot move to panels, with the exception of the // AREA_ADDONS area. if ( gAreas.get(aArea).get("type") == CustomizableUI.TYPE_PANEL && aArea != CustomizableUI.AREA_ADDONS ) { return false; } } let placement = this.getPlacementOfWidget(aWidgetId); // Items in the palette can move, and items can move within their area: if (!placement || placement.area == aArea) { return true; } // For everything else, just return whether the widget is removable. return this.isWidgetRemovable(aWidgetId); }, /** * @see CustomizableUI.ensureWidgetPlacedInWindow * @param {string} aWidgetId * @param {DOMWindow} aWindow * @returns {boolean} */ ensureWidgetPlacedInWindow(aWidgetId, aWindow) { let placement = this.getPlacementOfWidget(aWidgetId); if (!placement) { return false; } let areaNodes = gBuildAreas.get(placement.area); if (!areaNodes) { return false; } let container = [...areaNodes].filter(n => n.ownerGlobal == aWindow); if (!container.length) { return false; } let existingNode = container[0].getElementsByAttribute("id", aWidgetId)[0]; if (existingNode) { return true; } this.insertNodeInWindow(aWidgetId, container[0], true); return true; }, /** * Returns a list of all the widget IDs actively in this container, including * any that are overflown for overflowable containers. Notably, this does NOT * include IDs of widgets that have been previously placed within this * container but are not currently registered (for example, for uninstalled * extensions). * * @param {Element} container * @returns {string[]} * The list of widget IDs that currently exist within container. */ _getCurrentWidgetsInContainer(container) { let currentWidgets = new Set(); function addUnskippedChildren(parent) { for (let node of parent.children) { let realNode = node.localName == "toolbarpaletteitem" ? node.firstElementChild : node; if (realNode.getAttribute("skipintoolbarset") != "true") { currentWidgets.add(realNode.id); } } } addUnskippedChildren(this.getCustomizationTarget(container)); if (container.getAttribute("overflowing") == "true") { let overflowTarget = container.getAttribute("default-overflowtarget"); addUnskippedChildren( container.ownerDocument.getElementById(overflowTarget) ); let webExtOverflowTarget = container.getAttribute( "addon-webext-overflowtarget" ); addUnskippedChildren( container.ownerDocument.getElementById(webExtOverflowTarget) ); } // Then get the sorted list of placements, and filter based on the nodes // that are present. This avoids including items that don't exist (e.g. ids // of add-on items that the user has uninstalled). let orderedPlacements = CustomizableUI.getWidgetIdsInArea(container.id); return orderedPlacements.filter(w => { return ( currentWidgets.has(w) || this.getWidgetProvider(w) == CustomizableUI.PROVIDER_API ); }); }, /** * @type {boolean} * True if the CustomizableUI state of the browser is in the stock state * that is shipped by default. */ get inDefaultState() { if (CustomizableUI.verticalTabsEnabled) { return false; } for (let [areaId, props] of gAreas) { let defaultPlacements = props .get("defaultPlacements") .filter(item => this.widgetExists(item)); let currentPlacements = gPlacements.get(areaId); // We're excluding all of the placement IDs for items that do not exist, // and items that have removable="false", // because we don't want to consider them when determining if we're // in the default state. This way, if an add-on introduces a widget // and is then uninstalled, the leftover placement doesn't cause us to // automatically assume that the buttons are not in the default state. let buildAreaNodes = gBuildAreas.get(areaId); if (buildAreaNodes && buildAreaNodes.size) { let container = [...buildAreaNodes][0]; let removableOrDefault = itemNodeOrItem => { let item = (itemNodeOrItem && itemNodeOrItem.id) || itemNodeOrItem; let isRemovable = this.isWidgetRemovable(itemNodeOrItem); let isInDefault = defaultPlacements.includes(item); return isRemovable || isInDefault; }; // Toolbars need to deal with overflown widgets (if any) - so // specialcase them: if (props.get("type") == CustomizableUI.TYPE_TOOLBAR) { currentPlacements = this._getCurrentWidgetsInContainer(container).filter( removableOrDefault ); } else { currentPlacements = currentPlacements.filter(item => { let itemNode = container.getElementsByAttribute("id", item)[0]; return itemNode && removableOrDefault(itemNode || item); }); } if (props.get("type") == CustomizableUI.TYPE_TOOLBAR) { let collapsed = null; let defaultCollapsed = props.get("defaultCollapsed"); let nondefaultState = false; if (areaId == CustomizableUI.AREA_BOOKMARKS) { collapsed = Services.prefs.getCharPref( "browser.toolbars.bookmarks.visibility" ); nondefaultState = Services.prefs.prefHasUserValue( "browser.toolbars.bookmarks.visibility" ); } else { let attribute = container.getAttribute("type") == "menubar" ? "autohide" : "collapsed"; collapsed = container.getAttribute(attribute) == "true"; nondefaultState = collapsed != defaultCollapsed; } if (defaultCollapsed !== null && nondefaultState) { lazy.log.debug( "Found " + areaId + " had non-default toolbar visibility" + "(expected " + defaultCollapsed + ", was " + collapsed + ")" ); return false; } } } lazy.log.debug( "Checking default state for " + areaId + ":\n" + currentPlacements.join(",") + "\nvs.\n" + defaultPlacements.join(",") ); if (currentPlacements.length != defaultPlacements.length) { return false; } for (let i = 0; i < currentPlacements.length; ++i) { if ( currentPlacements[i] != defaultPlacements[i] && !this.matchingSpecials(currentPlacements[i], defaultPlacements[i]) ) { lazy.log.debug( "Found " + currentPlacements[i] + " in " + areaId + " where " + defaultPlacements[i] + " was expected!" ); return false; } } } if (Services.prefs.prefHasUserValue(kPrefUIDensity)) { lazy.log.debug(kPrefUIDensity + " pref is non-default"); return false; } if (Services.prefs.prefHasUserValue(kPrefAutoTouchMode)) { lazy.log.debug(kPrefAutoTouchMode + " pref is non-default"); return false; } if (Services.prefs.prefHasUserValue(kPrefDrawInTitlebar)) { lazy.log.debug(kPrefDrawInTitlebar + " pref is non-default"); return false; } // This should just be `gDefaultTheme.isActive`, but bugs... if (gDefaultTheme && gDefaultTheme.id != gSelectedTheme.id) { lazy.log.debug(gSelectedTheme.id + " theme is non-default"); return false; } if (Services.prefs.prefHasUserValue(kPrefSidebarPositionStartEnabled)) { lazy.log.debug(kPrefSidebarPositionStartEnabled + " pref is non-default"); return false; } return true; }, /** * @see CustomizableUI.getCollapsedToolbarIds * @param {Window} window * @returns {Set} */ getCollapsedToolbarIds(window) { let collapsedToolbars = new Set(); for (let toolbarId of CustomizableUIInternal.builtinToolbars) { let toolbar = window.document.getElementById(toolbarId); // Menubar toolbars are special in that they're hidden with the autohide // attribute. let hidingAttribute = toolbar.getAttribute("type") == "menubar" ? "autohide" : "collapsed"; if (toolbar.getAttribute(hidingAttribute) == "true") { collapsedToolbars.add(toolbarId); } } return collapsedToolbars; }, /** * @see CustomizableUI.setToolbarVisibility * @param {string} aToolbarId * @param {boolean} aIsVisible */ setToolbarVisibility(aToolbarId, aIsVisible) { // We only persist the attribute the first time. let isFirstChangedToolbar = true; for (let window of CustomizableUI.windows) { let toolbar = window.document.getElementById(aToolbarId); if (toolbar) { window.setToolbarVisibility(toolbar, aIsVisible, isFirstChangedToolbar); isFirstChangedToolbar = false; } } }, /** * @see CustomizableUI.widgetIsLikelyVisible * @param {string} aWidgetId * @param {Window} window * @returns {boolean} */ widgetIsLikelyVisible(aWidgetId, window) { let placement = this.getPlacementOfWidget(aWidgetId); if (!placement) { return false; } switch (placement.area) { case CustomizableUI.AREA_NAVBAR: return true; case CustomizableUI.AREA_MENUBAR: return !this.getCollapsedToolbarIds(window).has( CustomizableUI.AREA_MENUBAR ); case CustomizableUI.AREA_TABSTRIP: return !CustomizableUI.verticalTabsEnabled; case CustomizableUI.AREA_BOOKMARKS: return ( Services.prefs.getCharPref( "browser.toolbars.bookmarks.visibility" ) === "always" ); default: return false; } }, /** * nsIObserver implementation that observes for toolbar visibility changes * or preference changes. * * @param {nsISupports} aSubject * @param {string} aTopic * @param {string} aData */ observe(aSubject, aTopic, aData) { if (aTopic == "browser-set-toolbar-visibility") { let [toolbar, visibility] = JSON.parse(aData); CustomizableUI.setToolbarVisibility(toolbar, visibility == "true"); } if (aTopic === "nsPref:changed") { this.reconcileSidebarPrefs(aData); } }, /** * Initializes CustomizableUI for the current tab orientation. * * @param {boolean} toVertical * True if the tab orientation is vertical, false if horizontal. */ initializeForTabsOrientation(toVertical) { lazy.log.debug( `initializeForTabsOrientation, toVertical: ${toVertical}, gCurrentVerticalTabs: ${gCurrentVerticalTabs}` ); if (!toVertical) { const savedPlacements = this.getSavedHorizontalSnapshotState(); lazy.log.debug( "initializeForTabsOrientation, savedPlacements", savedPlacements ); if (savedPlacements.length) { // We're startup up with horizontal tabs, but there are saved placements for the // horizontal tab strip, so its possible the verticalTabs pref was updated outside // of normal use. Make sure to restore those tabstrip widget placements this.restoreSavedHorizontalTabStripState(savedPlacements, true); } else { // This is the default state and normal initialization will do everything necessary } gCurrentVerticalTabs = false; return; } // If the UI was already customized and saved, the earlier call to loadSavedState will // have populated gSavedState from the pref. If not, we need to move the tabs into the // vertical tabs area in the gSavedState. Then, the normal build-areas lifecycle // can populate the needed toolbar placements and elements. lazy.log.debug( "initializeForTabsOrientation, toVertical=true, gSavedState", gSavedState ); // If there are saved placement customizations, we need to manually move widgets // around before we restore this state let savedPlacements = gSavedState?.placements || {}; if (!savedPlacements[CustomizableUI.AREA_VERTICAL_TABSTRIP]?.length) { savedPlacements[CustomizableUI.AREA_VERTICAL_TABSTRIP] = gAreas .get(CustomizableUI.AREA_VERTICAL_TABSTRIP) .get("verticalTabsDefaultPlacements") || []; lazy.log.debug( "initializeForTabsOrientation, using defaults for AREA_VERTICAL_TABSTRIP", savedPlacements[CustomizableUI.AREA_VERTICAL_TABSTRIP] ); } let tabstripPlacements = savedPlacements[CustomizableUI.AREA_TABSTRIP] || []; // also pick up any widgets already in gFuturePlacements so we can wipe that if (gFuturePlacements.has(CustomizableUI.AREA_TABSTRIP)) { for (let id of gFuturePlacements.get(CustomizableUI.AREA_TABSTRIP)) { if (!tabstripPlacements.includes(id)) { tabstripPlacements.push(id); } } gFuturePlacements.delete(CustomizableUI.AREA_TABSTRIP); } // Take a copy we can save and restore to, ensuring there's a sane default let savedTabstripPlacements = tabstripPlacements.length ? [...tabstripPlacements] : gAreas.get(CustomizableUI.AREA_TABSTRIP).get("defaultPlacements"); // now we can remove the saved placements so they don't get picked back up again later in startup delete savedPlacements[CustomizableUI.AREA_TABSTRIP]; let widgetsMoved = []; for (let widgetId of tabstripPlacements) { if (widgetId == "tabbrowser-tabs") { lazy.log.debug( `Moving saved tabbrowser-tabs to AREA_VERTICAL_TABSTRIP` ); this.addWidgetToArea( widgetId, CustomizableUI.AREA_VERTICAL_TABSTRIP, null, true ); continue; } // if this is a extension, those are handled in a toolbarvisibilitychange handler in browser-addons.js if (CustomizableUI.isWebExtensionWidget(widgetId)) { lazy.log.debug(`Skipping a webextension saved placement ${widgetId}`); continue; } // Everything else gets moved to the nav-bar area while tabs are vertical lazy.log.debug(`Moving saved placement ${widgetId} to nav-bar`); this.addWidgetToArea(widgetId, CustomizableUI.AREA_NAVBAR, null, true); widgetsMoved.push(widgetId); } lazy.log.debug( "initializeForTabsOrientation, widgets moved:", widgetsMoved ); if (widgetsMoved.length) { // We've updated the areas, so we don't need to do this again post-initialization gCurrentVerticalTabs = true; } // Remove new tab from AREA_NAVBAR when vertical tabs enabled. this.removeWidgetFromArea("new-tab-button"); // If we've ended up with a non-default CUI state and vertical tabs enabled, ensure // there's a sane snapshot to revert to if (!lazy.horizontalPlacementsPref) { lazy.log.debug( `verticalTabsEnabled but ${kPrefCustomizationHorizontalTabstrip} is empty` ); CustomizableUIInternal.saveHorizontalTabStripState( savedTabstripPlacements ); } }, /** * Currently, the new sidebar and vertical tabs have a tight relationship * with one another (specifically, the new sidebar is a dependency for * vertical tabs). They are, however, controlled by two separate preferences. * This function does the work of changing the vertical tabs state if the * sidebar pref changes, and vice-versa. * * @param {string} prefChanged * The key for the preference that changed. */ reconcileSidebarPrefs(prefChanged) { let sidebarRevampEnabled = Services.prefs.getBoolPref( kPrefSidebarRevampEnabled, false ); let verticalTabsEnabled = Services.prefs.getBoolPref( kPrefSidebarVerticalTabsEnabled, false ); let positionStartEnabled = Services.prefs.getBoolPref( kPrefSidebarPositionStartEnabled, true ); lazy.log.debug( `reconcileSidebarPrefs, kPrefSidebarRevampEnabled: {sidebarRevampEnabled}, kPrefSidebarVerticalTabsEnabled: ${verticalTabsEnabled}` ); switch (prefChanged) { case kPrefSidebarVerticalTabsEnabled: { // We need to also enable sidebar.revamp if vertical tabs gets enabled if (verticalTabsEnabled && !sidebarRevampEnabled) { Services.prefs.setBoolPref(kPrefSidebarRevampEnabled, true); } break; } case kPrefSidebarRevampEnabled: { // If we are changing the pref after startup, update the nav bar defaultPlacements to include/exclude sidebar-button let props = gAreas.get(CustomizableUI.AREA_NAVBAR); let defaults = props.get("defaultPlacements"); let sidebarButtonIndex = defaults.indexOf("sidebar-button"); if (sidebarRevampEnabled && sidebarButtonIndex < 0) { defaults.unshift("sidebar-button"); } else if (!sidebarRevampEnabled && sidebarButtonIndex > -1) { defaults.splice(sidebarButtonIndex, 1); } props.set("defaultPlacements", defaults); gAreas.set(CustomizableUI.AREA_NAVBAR, props); // We need to also disable vertical tabs if sidebar.revamp is no longer enabled if (!sidebarRevampEnabled && verticalTabsEnabled) { lazy.log.debug( `{kPrefSidebarRevampEnabled} disabled, so also disabling ${kPrefSidebarVerticalTabsEnabled}` ); Services.prefs.setBoolPref(kPrefSidebarVerticalTabsEnabled, false); } break; } case kPrefSidebarPositionStartEnabled: { // If the sidebar moves to the left or right, move the toolbar button along with it. const navbarPlacements = gPlacements.get(CustomizableUI.AREA_NAVBAR); const index = navbarPlacements.indexOf("sidebar-button"); if (!positionStartEnabled && index === 0) { this.moveWidgetWithinArea("sidebar-button", navbarPlacements.length); } if (positionStartEnabled && index === navbarPlacements.length - 1) { this.moveWidgetWithinArea("sidebar-button", 0); } } } }, /** * @type {boolean} * True if the horizontal and vertical tabstrips have been registered. */ get tabstripAreasReady() { return ( gBuildAreas.get(CustomizableUI.AREA_TABSTRIP)?.size && gBuildAreas.get(CustomizableUI.AREA_VERTICAL_TABSTRIP)?.size ); }, /** * Updates the vertical or horizontal state of the tabstrip to best match * the current preference value. */ updateTabStripOrientation() { if (!this.tabstripAreasReady) { lazy.log.debug("tabstrip build areas not yet ready"); return; } let toVertical = CustomizableUI.verticalTabsEnabled; if (toVertical === gCurrentVerticalTabs) { lazy.log.debug("early return as the value hasn't changed"); return; } lazy.log.debug( `verticalTabs changed, from ${gCurrentVerticalTabs}, to ${toVertical}` ); if (toVertical && gCurrentVerticalTabs !== null) { // Stash current placements as a state we can restore to when going back to horizontal tabs lazy.log.debug( "Switching to vertical tabs post-initialization, so capturing tabstrip placements snapshot" ); CustomizableUIInternal.saveHorizontalTabStripState(); } gCurrentVerticalTabs = toVertical; function changeWidgetRemovability(widgetId, removable) { let widget = CustomizableUI.getWidget(widgetId); for (let { node } of widget.instances) { if (node) { node.setAttribute("removable", removable.toString()); } } } // Normally these aren't removable, but for this operation only we need to move them changeWidgetRemovability("tabbrowser-tabs", true); if (toVertical) { lazy.log.debug( `Switching to verticalTabs=true in updateTabStripOrientation` ); gDirty = true; if ( !Services.prefs.getCharPref(kPrefCustomizationHorizontalTabsBackup, "") ) { // Before we switch for the first time, take a back up just in case we need an escape hatch Services.prefs.setCharPref( kPrefCustomizationHorizontalTabsBackup, Services.prefs.getCharPref(kPrefCustomizationState, "") ); } CustomizableUI.beginBatchUpdate(); let customVerticalNavbarPlacements = this.getSavedVerticalSnapshotState(); let tabstripPlacements = this.getSavedHorizontalSnapshotState(); const isSidebarLast = gPlacements.get(CustomizableUI.AREA_NAVBAR).at(-1) === "sidebar-button"; // Remove non-default widgets to the nav-bar for (let id of CustomizableUI.getWidgetIdsInArea("TabsToolbar")) { if (id == "tabbrowser-tabs") { CustomizableUI.addWidgetToArea( id, CustomizableUI.AREA_VERTICAL_TABSTRIP ); continue; } // We add the tab strip placements later in the case they have a custom position if ( tabstripPlacements.includes(id) && customVerticalNavbarPlacements.includes(id) ) { continue; } if (!CustomizableUI.isWidgetRemovable(id)) { continue; } // if this is a extension, those are handled in a toolbarvisibilitychange handler in browser-addons.js if (CustomizableUI.isWebExtensionWidget(id)) { continue; } // Everything else gets moved to the nav-bar area while tabs are vertical CustomizableUI.addWidgetToArea(id, CustomizableUI.AREA_NAVBAR); } // Remove new tab from nav-bar when vertical tabs enabled this.removeWidgetFromArea("new-tab-button"); customVerticalNavbarPlacements.forEach((id, index) => { if (tabstripPlacements.includes(id)) { CustomizableUI.addWidgetToArea(id, CustomizableUI.AREA_NAVBAR, index); } }); // If sidebar was previously the last widget in navbar, carry it over to // the end of the newly constructed navbar. if (isSidebarLast) { this.addWidgetToArea("sidebar-button", CustomizableUI.AREA_NAVBAR); } CustomizableUI.endBatchUpdate(); } else { this.saveNavBarWhenVerticalTabsState(); // We're switching to vertical in this session; pull saved state from pref and update placements this.restoreSavedHorizontalTabStripState(); } // Give the sidebar a chance to adjust before we show/hide the toolbars lazy.log.debug("CustomizableUI notifying tabstrip-orientation-change"); Services.obs.notifyObservers(null, "tabstrip-orientation-change", { isVertical: toVertical, }); this.setToolbarVisibility( CustomizableUI.AREA_VERTICAL_TABSTRIP, toVertical ); this.setToolbarVisibility(CustomizableUI.AREA_TABSTRIP, !toVertical); changeWidgetRemovability("tabbrowser-tabs", false); for (let [win] of gBuildWindows) { win.TabBarVisibility.update(true); } }, }; Object.freeze(CustomizableUIInternal); /** * This is the publicly exposed interface CustomizableUI. It uses old-school * encapsulation by forwarding most method calls to CustomizableUIInternal, * which is not exported. */ 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 vertical tabstrip toolbar. */ AREA_VERTICAL_TABSTRIP: "vertical-tabs", /** * 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 reference to the ID of the addons area. */ AREA_ADDONS: "unified-extensions-area", /** * Constant reference to the ID of the customization palette, which is * where widgets go when they're not assigned to an area. Note that this * area is "virtual" in that it's never set as a value for a widgets * currentArea or defaultArea. It's only used for the `canWidgetMoveToArea` * function to check if widgets can be moved to the palette. Callers who * wish to move items to the palette should use `removeWidgetFromArea`. */ AREA_NO_AREA: "customization-palette", /** * Constant indicating the area is a panel. */ TYPE_PANEL: "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", /** * An iteratable property of windows managed by CustomizableUI. * Note that this can *only* be used as an iterator. ie: * for (let window of CustomizableUI.windows) { ... } */ windows: { *[Symbol.iterator]() { for (let [window] of gBuildWindows) { yield window; } }, }, get verticalTabsEnabled() { return lazy.verticalTabsPref; }, /** * Fired when a widget is added to an area. * * @callback CustomizableUIOnWidgetAddedCallback * @param {string} aWidgetId * The ID of the widget that was added to an area. * @param {string} aArea * The ID of the area that the widget was added to. * @param {number} aPosition * The position of the widget in the area that it was added to. */ /** * Fired when a widget is moved within its area. * * @callback CustomizableUIOnWidgetMovedCallback * @param {string} aWidgetId * The ID of the widget that was moved. * @param {string} aArea * The ID of the area that the widget was moved within. * @param {number} aOldPosition * The original position of the widget before being moved. * @param {number} aNewPosition * The new position of the widget after being moved. */ /** * Fired when a widget is removed from an area. * * @callback CustomizableUIOnWidgetRemovedCallback * @param {string} aWidgetId * The ID of the widget that was removed. * @param {string} aArea * The ID of the area that the widget was removed from. */ /** * Fired *before* a widget's DOM node is acted upon by CustomizableUI * (to add, move or remove it). * * @callback CustomizableUIOnWidgetBeforeDOMChange * @param {Element} aNode * The DOM node being acted upon. * @param {Element|null} aNextNode * The DOM node (if any) before which a widget will be inserted. * @param {Element} aContainer * The *actual* DOM container for the widget (could be an overflow panel in * case of an overflowable toolbar). * @param {boolean} aWasRemoval * True iff the action about to happen is the removal of the DOM node. */ /** * Fired *after* a widget's DOM node is acted upon by CustomizableUI * (to add, move or remove it). * * @callback CustomizableUIOnWidgetAfterDOMChange * @param {Element} aNode * The DOM node that was acted upon. * @param {Element|null} aNextNode * The DOM node (if any) that the widget was inserted before. * @param {Element} aContainer * The *actual* DOM container for the widget (could be an overflow panel in * case of an overflowable toolbar). * @param {boolean} aWasRemoval * True iff the action that happened was the removal of the DOM node. */ /** * Fired after a reset to default placements moves a widget's node to a * different location. * * @callback CustomizableUIOnWidgetReset * @param {Element} aNode * The DOM node for the widget that was moved. * @param {Element} aContainer * The *actual* DOM container for the widget (could be an overflow panel in * case of an overflowable toolbar) after the reset. (NB: it might already * have been there and been moved to a different position!) */ /** * Fired after undoing a reset to default placements moves a widget's * node to a different location. * * @callback CustomizableUIOnWidgetUndoMove * @param {Element} aNode * The DOM node for the widget that was moved after the undo. * @param {Element} aContainer * The *actual* DOM container for the widget (could be an overflow panel in * case of an overflowable toolbar) after the undo-move. (NB: it might * already have been there and been moved to a different position!) */ /** * Fired when a widget with id aWidgetId has been created, but before it * is added to any placements or any DOM nodes have been constructed. * Only fired for API-based widgets. * * @callback CustomizableUIOnWidgetCreated * @param {string} aWidgetId * The ID of the widget that was created. */ /** * Fired after a reset to default placements is complete on an area's * DOM node. Note that this is fired for each DOM node across all windows. * * @callback CustomizableUIOnAreaReset * @param {string} aArea * The ID for the area that was reset. * @param {Element} aContainer * The DOM node for the area that was reset. */ /** * Fired after a widget with id aWidgetId has been created, and has been * added to either its default area or the area in which it was placed * previously. If the widget has no default area and/or it has never * been placed anywhere, aArea may be null. Only fired for API-based * widgets. * * @callback CustomizableUIOnWidgetAfterCreation * @param {string} aWidgetId * The ID of the widget that was just created. * @param {string|null} aArea * The ID of the area that the widget was placed in, or null if it is * now in the customization palette. */ /** * Fired when a widget is destroyed. Only fired for API-based widgets. * * @callback CustomizableUIOnWidgetDestroyed * @param {string} aWidgetId * The ID of the widget that was destroyed. */ /** * Fired when a window is unloaded and a widget's instance is destroyed * because of this. Only fired for API-based widgets. * * @callback CustomizableUIOnWidgetInstanceRemoved * @param {string} aWidgetId * The ID of the widget that was just removed. * @param {Document} aDocument * The Document that the widget belonged to that was just unloaded. */ /** * Fired when entering customize mode in aWindow. * * @callback CustomizableUIOnCustomizeStart * @param {DOMWindow} aWindow * The window in which customize mode was entered. */ /** * Fired when exiting customize mode in aWindow. * * @callback CustomizableUIOnCustomizeEnd * @param {DOMWindow} aWindow * The window in which customize mode was exited. */ /** * Fired when a widget's DOM node is overflowing its toolbar and will be * displayed in an overflow panel. * * @callback CustomizableUIOnWidgetOverflow * @param {Element} aNode * The DOM node for the widget that overflowed. * @param {Element} aContainer * The DOM container that the widget just overflowed out of. */ /** * Fired when a widget that was overflowed out of its toolbar container * "underflows" back. * * @callback CustomizableUIOnWidgetUnderflow * @param {Element} aNode * The DOM node for the widget that had overflowed out. * @param {Element} aContainer * The DOM container that the widget is underflowing back into. */ /** * Fired when a window has been opened that is managed by CustomizableUI, * once all of the prerequisite setup has been done. * * @callback CustomizableUIOnWindowOpened * @param {DOMWindow} aWindow * The window that opened. */ /** * Fired when a window that has been managed by CustomizableUI has been * closed. * * @callback CustomizableUIOnWindowClosed * @param {DOMWindow} aWindow * The window that closed. */ /** * Fired after an area node is first built when it is registered. This is * often when the window has opened, but in the case of add-ons, could fire * when the node has just been registered with CustomizableUI after an add-on * update or disable/enable sequence. * * @callback CustomizableUIOnAreaNodeRegistered * @param {string} aArea * The ID for the area that was just registered. * @param {Element} aContainer * The DOM node for the customizable area. */ /** * Fired when an area node is explicitly unregistered by an API caller, or by * a window closing. The aReason parameter indicates which of these is the * case. * * @callback CustomizableUIOnAreaNodeUnregistered * @param {string} aArea * The ID for the area that was just registered. * @param {Element} aContainer * The DOM node for the customizable area. * @param {string} aReason * One of either CustomizableUI.REASON_WINDOW_CLOSED or * CustomizableUI.REASON_AREA_UNREGISTERED. */ /** * @typedef {object} CustomizableUIListener * @property {CustomizableUIOnWidgetAddedCallback} [onWidgetAdded] * @property {CustomizableUIOnWidgetMovedCallback} [onWidgetMoved] * @property {CustomizableUIOnWidgetRemovedCallback} [onWidgetRemoved] * @property {CustomizableUIOnWidgetBeforeDOMChange} [onWidgetBeforeDOMChange] * @property {CustomizableUIOnWidgetAfterDOMChange} [onWidgetAfterDOMChange] * @property {CustomizableUIOnWidgetReset} [onWidgetReset] * @property {CustomizableUIOnWidgetUndoMove} [onWidgetUndoMove] * @property {CustomizableUIOnWidgetCreated} [onWidgetCreated] * @property {CustomizableUIOnAreaReset} [onAreaReset] * @property {CustomizableUIOnWidgetAfterCreation} [onWidgetAfterCreation] * @property {CustomizableUIOnWidgetDestroyed} [onWidgetDestroyed] * @property {CustomizableUIOnWidgetInstanceRemoved} [onWidgetInstanceRemoved] * @property {CustomizableUIOnWidgetDrag} [onWidgetDrag] * @property {CustomizableUIOnCustomizeStart} [onCustomizeStart] * @property {CustomizableUIOnCustomizeEnd} [onCustomizeEnd] * @property {CustomizableUIOnWidgetOverflow} [onWidgetOverflow] * @property {CustomizableUIOnWindowOpened} [onWindowOpened] * @property {CustomizableUIOnWindowClosed} [onWindowClosed] * @property {CustomizableUIOnAreaNodeRegistered} [onAreaNodeRegistered] * @property {CustomizableUIOnAreaNodeUnregistered} [onAreaNodeUnregistered] */ /** * Add a listener object that will get fired for various events regarding * window, area, and window lifetimes / events, as well as customization * events. * * @param {CustomizableUIListener} aListener * The listener object to add. Not all event handler methods need to be * defined. CustomizableUI will catch exceptions. Events are dispatched * synchronously on the UI thread, so if you can delay any/some of your * processing, that is advisable. */ addListener(aListener) { CustomizableUIInternal.addListener(aListener); }, /** * Remove a listener that was previously added with addListener. * * @param {CustomizableUIListener} aListener * The listener object to remove. */ removeListener(aListener) { CustomizableUIInternal.removeListener(aListener); }, /** * Register a customizable area with CustomizableUI. * * @param {string} aName * The name of the area to register. Can only contain alphanumeric * characters, dashes (-) and underscores (_). * @param {object} aProperties * The properties of the area to register. * @param {string} [aProperties.type=CustomizableUI.TYPE_TOOLBAR] * The type of area being registered. Either CustomizableUI.TYPE_TOOLBAR * (default) or CustomizableUI.TYPE_PANEL. * @param {Element|undefined} [aProperties.anchor] * For a menu panel or overflowable toolbar area, the anchoring node for the * panel. * @param {boolean} [aProperties.overflowable] * Set to true if your toolbar is overflowable. This requires an anchor, and * only has an effect for toolbars. * @param {string[]} [aProperties.defaultPlacements] * An array of widget IDs making up the default contents of the area. * @param {boolean|null} [aProperties.defaultCollapsed=true] * (INTERNAL ONLY) applies if the type is CustomizableUI.TYPE_TOOLBAR, * specifies if the toolbar is collapsed by default (defaults to true). * Specify `null` to ensure that reset/inDefaultArea don't care * about a toolbar's collapsed state */ registerArea(aName, aProperties) { CustomizableUIInternal.registerArea(aName, aProperties); }, /** * Register a concrete node for a registered area. This method needs to be called * with any toolbar in the main browser window that has its "customizable" attribute * set to true. * * Note that ideally, you should register your toolbar using registerArea * before calling this. If you don't, the node will be saved for processing when * you call registerArea. Note that CustomizableUI won't restore state in the area, * allow the user to customize it in customize mode, or otherwise deal * with it, until the area has been registered. * * @param {Element} aToolbar * The node to register. */ registerToolbarNode(aToolbar) { CustomizableUIInternal.registerToolbarNode(aToolbar); }, /** * Register a panel node. A panel treated slightly differently from a toolbar in * terms of what items can be moved into it. For example, a panel cannot have a * spacer or a spring put into it. * * @param {Element} aNode * The panel contents DOM node being registered. * @param {string} aArea * The name of the area for which to register this node. */ registerPanelNode(aNode, aArea) { CustomizableUIInternal.registerPanelNode(aNode, aArea); }, /** * Unregister a customizable area. The inverse of registerArea. * * Unregistering an area will remove all the (removable) widgets in the * area, which will return to the panel, and destroy all other traces * of the area within CustomizableUI. Note that this means the *contents* * of the area's DOM nodes will be moved to the panel or removed, but * the area's DOM nodes *themselves* will stay. * * Furthermore, by default the placements of the area will be kept in the * saved state (!) and restored if you re-register the area at a later * point. This is useful for e.g. add-ons that get disabled and then * re-enabled (e.g. when they update). * * You can override this last behaviour (and destroy the placements * information in the saved state) by passing true for aDestroyPlacements. * * @param {string} aName * The name of the area to unregister. * @param {boolean} [aDestroyPlacements] * True if the placements information for the area should be destroyed * too. Defaults to not destroying the placements information. */ unregisterArea(aName, aDestroyPlacements) { CustomizableUIInternal.unregisterArea(aName, aDestroyPlacements); }, /** * 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 {string} aWidgetId * The ID of the widget to add to the area. * @param {string} aArea * The name of the area to add the widget to. * @param {number} [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) { CustomizableUIInternal.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 {string} aWidgetId * The ID of the widget to remove from its area. */ removeWidgetFromArea(aWidgetId) { CustomizableUIInternal.removeWidgetFromArea(aWidgetId); }, /** * Move a widget within an area. * If the widget is not in any area, this will no-op. * If the widget is already at the indicated position, this will no-op. * * Otherwise, this will move the widget and fire an onWidgetMoved notification, * and an onWidgetBeforeDOMChange and onWidgetAfterDOMChange notification for * each window CustomizableUI knows about. * * @param {string} aWidgetId * The ID of the widget to move. * @param {number} aPosition * The position to move the widget to. Negative values or values greater * than the number of widgets will be interpreted to mean moving the widget * to respectively the first or last position. */ moveWidgetWithinArea(aWidgetId, aPosition) { CustomizableUIInternal.moveWidgetWithinArea(aWidgetId, aPosition); }, /** * Ensure a XUL-based widget created in a window after areas were * initialized moves to its correct position. * Always prefer this over moving items in the DOM yourself. * * NB: why is this API per-window, you wonder? Because if you need this, * presumably you yourself need to create the widget in all the windows * and need to loop through them anyway. * * @param {string} aWidgetId * The ID of the widget that was just created. * @param {DOMWindow} aWindow * The window in which you want to ensure it was added. * @returns {boolean} * True if the widget was successfully placed in the window (or was already * placed in the window). False if something goes wrong with checking for * the presence of the widget in the window. */ ensureWidgetPlacedInWindow(aWidgetId, aWindow) { return CustomizableUIInternal.ensureWidgetPlacedInWindow( aWidgetId, aWindow ); }, /** * Start a batch update of items. * During a batch update, the customization state is not saved to the user's * preferences file, in order to reduce (possibly sync) IO. * Calls to begin/endBatchUpdate may be nested. * * Callers should ensure that NO MATTER WHAT they call endBatchUpdate once * for each call to beginBatchUpdate, even if there are exceptions in the * code in the batch update. Otherwise, for the duration of the * Firefox session, customization state is never saved. Typically, you * would do this using a try...finally block. */ beginBatchUpdate() { CustomizableUIInternal.beginBatchUpdate(); }, /** * End a batch update. See the documentation for beginBatchUpdate above. * * State is not saved if we believe it is identical to the last known * saved state. State is only ever saved when all batch updates have * finished (ie there has been 1 endBatchUpdate call for each * beginBatchUpdate call). If any of the endBatchUpdate calls pass * aForceDirty=true, we will flush to the prefs file. * * @param {boolean} [aForceDirty=false] * Force CustomizableUI to flush to the prefs file when all batch updates * have finished. Defaults to false. */ endBatchUpdate(aForceDirty = false) { CustomizableUIInternal.endBatchUpdate(aForceDirty); }, /** * A function that will be invoked with the document in which to build a * widget. Should return the DOM node that has been constructed. * * @callback CustomizableUICreateWidgetOnBuild * @param {Document} aDoc * The document to create the widget in. * @returns {Element} * The DOM node that was constructed for the widget. */ /** * Invoked before the widget gets a DOM node constructed for it, passing the * document in which that will happen. This is useful especially for 'view' * type widgets that need to construct their views on the fly (e.g. from * bootstrapped add-ons). If the function returns `false`, the widget will * not be created. * * @callback CustomizableUICreateWidgetOnBeforeCreated * @param {Document} aDoc * The document that the widget might be created in. * @returns {boolean} * True if the widget should be created. */ /** * A function that will be invoked whenever the widget has a DOM node * constructed, passing the constructed node as an argument. * * @callback CustomizableUICreateWidgetOnCreated * @param {Element} aNode * The DOM node that was constructed for the widget in a document. */ /** * A function that will be invoked after the widget has a DOM node destroyed, * passing the document from which it was removed. This is useful especially * for 'view' type widgets that need to cleanup after views that were * constructed on the fly. * * @callback CustomizableUICreateWidgetOnDestroyed * @param {Document} aDoc * The document that the widget was destroyed in. */ /** * A function that will be invoked when the user activates the button but * before the command is evaluated. Useful if code needs to run to change the * button's icon in preparation to the pending command action. Called for any * type that supports the handler. The command type, either "view" or * "command", may be returned to force the action that will occur. View will * open the panel and command will result in calling onCommand. * * @callback CustomizableUICreateWidgetOnBeforeCommand * @param {Event} aEvent * The command event that occurred on the button. * @param {Element} aNode * The element upon which the command event occurred. * @returns {string} * One of "action" or "view". */ /** * Useful for custom, button and button-and-view widgets; a function that will * be invoked when the user activates the button. * * @callback CustomizableUICreateWidgetOnCommand * @param {Event} aEvent * The command event that was fired for the node. */ /** * A function that will be invoked when the user clicks a widget node. * * @callback CustomizableUICreateWidgetOnClick * @param {Event} aEvent * The click event that was fired for the widget node. */ /** * A function that will be invoked when a user shows your view. If any event * handler calls aEvt.preventDefault(), the view will not be shown. * * The event's `detail` property is an object with an `addBlocker` method. * Handlers which need to perform asynchronous operations before the view is * shown may pass this method a Promise, which will prevent the view from * showing until it resolves. Additionally, if the promise resolves to the * exact value `false`, the view will not be shown. * * @callback CustomizableUICreateWidgetOnViewShowing * @param {Event} aEvent * The ViewShowing event. See PanelMultiView.sys.mjs. */ /** * A function that will be invoked when a user hides your view. * * @callback CustomizableUICreateWidgetOnViewHiding * @param {Event} aEvent * The ViewHiding event. See PanelMultiView.sys.mjs. */ /** * @typedef {object} CustomizableUICreateWidgetProperties * @property {string} id * The ID of the widget to be created. * @property {string} [type="button"] * The type of widget to create. The valid types are: * 'button' - for simple button widgets (the default) * 'view' - for buttons that open a panel or subview, * depending on where they are placed. * 'button-and-view' - A combination of 'button' and 'view', * which looks different depending on whether it's * located in the toolbar or in the panel: When * located in the toolbar, the widget is shown as * a combined item of a button and a dropmarker * button. The button triggers the command and the * dropmarker button opens the view. When located * in the panel, shown as one item which opens the * view, and the button command cannot be * triggered separately. * 'custom' - for fine-grained control over the creation * of the widget. * @property {string} [viewId] * Only useful for views and button-and-view widgets (and required in those * cases). Should be set to the id of the that should be shown * when clicking the widget. If used with a custom widget, the widget must * also provide a toolbaritem where the first child is the view button. * @property {CustomizableUICreateWidgetOnBuild} [onBuild] * Only useful for custom widgets (and required there). * @property {CustomizableUICreateWidgetOnBeforeCreated} [onBeforeCreated] * Called for all button and non-custom widgets. * @property {CustomizableUICreateWidgetOnCreated} [onCreated] * @property {CustomizableUICreateWidgetOnDestroyed} [onDestroyed] * @property {CustomizableUICreateWidgetOnBeforeCommand} [onBeforeCommand] * @property {CustomizableUICreateWidgetOnCommand} [onCommand] * @property {CustomizableUICreateWidgetOnClick} [onClick] * @property {CustomizableUICreateWidgetOnViewShowing} [onViewShowing] * Only useful for view and button-and-view widgets. * @property {CustomizableUICreateWidgetOnViewHiding} [onViewHiding] * Only useful for view and button-and-view widgets. * @property {string} [l10nId] * A Fluent string identifier to use for localizing attributes on the * widget. If present, preferred over the label/tooltiptext parameters. * @property {string} [tooltiptext] * **Deprecated** - use l10nId and Fluent instead. A string to use for the * tooltip of the widget. * @property {string} [label] * **Deprecated** - use l10nId and Fluent instead. A string to use for the * label of the widget. * @property {string} [localized] * **Deprecated** - use l10nId and Fluent instead. If true, or undefined, * attempt to retrieve the widget's string properties from the customizable * widgets string bundle. * @property {boolean} [removable=true] * Whether the widget can be removed from a customizable area. * Note: if you specify false here, you must provide a defaultArea, too. * @property {boolean} [overflows=true] * Whether widget can overflow when placed within an overflowable toolbar. * @property {string} [defaultArea] * The default area to add the widget to. If not supplied, this widget will * be placed in the palette by default. A valid default area is required if * the widget is not removable. * @property {string} [shortcutId] * The id of an element that has a shortcut for this widget. This is only * used to display the shortcut as part of the tooltip for builtin widgets * (which have strings inside customizableWidgets.properties). If you're in * an add-on, you should not set this property. If l10nId is provided, the * resulting shortcut is passed as the "$shortcut" variable to the Fluent * message. * @property {boolean} [showInPrivateBrowsing=true] * True to show the widget in private browsing mode windows. * @property {boolean} [hideInNonPrivateBrowsing=false] * True to hide the widget in non-private browsing mode windows. * @property {boolean} [tabSpecific] * True to close any widget view panels if the selected tab changes. * @property {boolean} [locationSpecific] * True to close any widget view panels if the location changes. * @property {boolean} [webExtension] * True if this widget is being created on behalf of a WebExtension. */ /** * Create a widget. * * To create a widget, you should pass an object with its desired * properties. * * @param {CustomizableUICreateWidgetProperties} aProperties * The properties for the widget to be created. * @returns {WidgetGroupWrapper|XULWidgetGroupWrapper} */ createWidget(aProperties) { return CustomizableUIInternal.wrapWidget( CustomizableUIInternal.createWidget(aProperties) ); }, /** * Destroy a widget * * If the widget is part of the default placements in an area, this will * remove it from there. It will also remove any DOM instances. However, * it will keep the widget in the placements for whatever area it was * in at the time. You can remove it from there yourself by calling * CustomizableUI.removeWidgetFromArea(aWidgetId). * * @param {string} aWidgetId * The ID of the widget to destroy. */ destroyWidget(aWidgetId) { CustomizableUIInternal.destroyWidget(aWidgetId); }, /** * Get a wrapper object with information about the widget. * The object provides the following properties * (all read-only unless otherwise indicated): * * - id: the widget's ID; * - type: the type of widget (button, view, custom). For * XUL-provided widgets, this is always 'custom'; * - provider: the provider type of the widget, id est one of * PROVIDER_API or PROVIDER_XUL; * - forWindow(w): a method to obtain a single window wrapper for a widget, * in the window w passed as the only argument; * - instances: an array of all instances (single window wrappers) * of the widget. This array is NOT live; * - areaType: the type of the widget's current area * - isGroup: true; will be false for wrappers around single widget nodes; * - source: for API-provided widgets, whether they are built-in to * Firefox or add-on-provided; * - disabled: for API-provided widgets, whether the widget is currently * disabled. NB: this property is writable, and will toggle * all the widgets' nodes' disabled states; * - label: for API-provied widgets, the label of the widget; * - tooltiptext: for API-provided widgets, the tooltip of the widget; * - showInPrivateBrowsing: for API-provided widgets, whether the widget is * visible in private browsing; * - hideInNonPrivateBrowsing: for API-provided widgets, whether the widget is * hidden in non-private browsing; * * Single window wrappers obtained through forWindow(someWindow) or from the * instances array have the following properties * (all read-only unless otherwise indicated): * * - id: the widget's ID; * - type: the type of widget (button, view, custom). For * XUL-provided widgets, this is always 'custom'; * - provider: the provider type of the widget, id est one of * PROVIDER_API or PROVIDER_XUL; * - node: reference to the corresponding DOM node; * - anchor: the anchor on which to anchor panels opened from this * node. This will point to the overflow chevron on * overflowable toolbars if and only if your widget node * is overflowed, to the anchor for the panel menu * if your widget is inside the panel menu, and to the * node itself in all other cases; * - overflowed: boolean indicating whether the node is currently in the * overflow panel of the toolbar; * - isGroup: false; will be true for the group widget; * - label: for API-provided widgets, convenience getter for the * label attribute of the DOM node; * - tooltiptext: for API-provided widgets, convenience getter for the * tooltiptext attribute of the DOM node; * - disabled: for API-provided widgets, convenience getter *and setter* * for the disabled state of this single widget. Note that * you may prefer to use the group wrapper's getter/setter * instead. * * @param {string} aWidgetId * The ID of the widget whose information you need. * @returns {WidgetGroupWrapper|XULWidgetGroupWrapper|null} * A wrapper around the widget as described above, or null if the widget is * known not to exist (anymore). NB: A non-null return is no guarantee the * widget exists because we cannot know in advance if a XUL widget exists or * not. */ getWidget(aWidgetId) { return CustomizableUIInternal.wrapWidget(aWidgetId); }, /** * Get an array of widget wrappers (see getWidget) for all the widgets * which are currently not in any area (so which are in the palette). * * @param {DOMElement} aWindowPalette * The palette element (and by extension, the window) in which * CustomizableUI should look. This matters because of course XUL-provided * widgets could be available in some windows but not others, and likewise * API-provided widgets might not exist in a private window (because of the * showInPrivateBrowsing property). * @returns {Array} * An array of widget wrappers (see getWidget) */ getUnusedWidgets(aWindowPalette) { return CustomizableUIInternal.getUnusedWidgets(aWindowPalette).map( CustomizableUIInternal.wrapWidget, CustomizableUIInternal ); }, /** * Get an array of all the widget IDs placed in an area. * Modifying the array will not affect CustomizableUI. * * NB: will throw if called too early (before placements have been fetched) * or if the area is not currently known to CustomizableUI. * * @param {string} aArea * The name of the area whose placements you want to obtain. * @returns {string[]} * An array containing the widget IDs that are in the area. */ getWidgetIdsInArea(aArea) { if (!gAreas.has(aArea)) { throw new Error("Unknown customization area: " + aArea); } if (!gPlacements.has(aArea)) { throw new Error(`Area ${aArea} not yet restored`); } // We need to clone this, as we don't want to let consumers muck with placements return [...gPlacements.get(aArea)]; }, /** * Get an array of all the widget IDs in the default placements for an area. * Modifying the array will not affect CustomizableUI. * * @param {string} aArea * The ID of the area whose default placements you want to obtain. * @returns {string[]} * An array containing the widget IDs that are in the default placements for * that area. */ getDefaultPlacementsForArea(aArea) { return [...gAreas.get(aArea).get("defaultPlacements")]; }, /** * Get an array of widget wrappers for all the widgets in an area. This is * the same as calling getWidgetIdsInArea and .map() ing the result through * CustomizableUI.getWidget. Careful: this means that if there are IDs in there * which don't have corresponding DOM nodes, there might be nulls in this array, * or items for which wrapper.forWindow(win) will return null. * * @param {string} aArea * The ID of the area whose widgets you want to obtain. * @returns {Array} * An array of widget wrappers and/or null values for the widget IDs * placed in an area. * * NB: will throw if called too early (before placements have been fetched) * or if the area is not currently known to CustomizableUI. */ getWidgetsInArea(aArea) { return this.getWidgetIdsInArea(aArea).map( CustomizableUIInternal.wrapWidget, CustomizableUIInternal ); }, /** * Ensure the customizable widget that matches up with this view node * will get the right subview showing/shown/hiding/hidden events when * they fire. * * @param {Element} aViewNode * The view node to add listeners to if they haven't been added already. */ ensureSubviewListeners(aViewNode) { return CustomizableUIInternal.ensureSubviewListeners(aViewNode); }, /** * Obtain an array of all the area IDs known to CustomizableUI. * This array is created for you, so is modifiable without CustomizableUI * being affected. */ get areas() { return [...gAreas.keys()]; }, /** * Check what kind of area (toolbar or menu panel) an area is. This is * useful if you have a widget that needs to behave differently depending * on its location. Note that widget wrappers have a convenience getter * property (areaType) for this purpose. * * @param {string} aArea * The ID of the area whose type you want to know * @returns {string} * Returns CustomizableUI.TYPE_TOOLBAR or CustomizableUI.TYPE_PANEL * depending on the area, null if the area is unknown. */ getAreaType(aArea) { let area = gAreas.get(aArea); return area ? area.get("type") : null; }, /** * Check if a toolbar is collapsed by default. * * @param {string} aArea * The ID of the area whose default-collapsed state you want to know. * @returns {boolean} * Returns true if the toolbar area is collapsed by default, false if * not collapsed by default, and null if the area is unknown its collapsed * state cannot normally be controlled by the user. */ isToolbarDefaultCollapsed(aArea) { let area = gAreas.get(aArea); return area ? area.get("defaultCollapsed") : null; }, /** * Obtain the DOM node that is the customize target for an area in a * specific window. * * Areas can have a customization target that does not correspond to the * node itself. In particular, toolbars that have a customizationtarget * attribute set will have their customization target set to that node. * This means widgets will end up in the customization target, not in the * DOM node with the ID that corresponds to the area ID. This is useful * because it lets you have fixed content in a toolbar (e.g. the panel * menu item in the navbar) and have all the customizable widgets use * the customization target. * * Using this API yourself is discouraged; you should generally not need * to be asking for the DOM container node used for a particular area. * In particular, if you're wanting to check it in relation to a widget's * node, your DOM node might not be a direct child of the customize target * in a window if, for instance, the window is in customization mode, or if * this is an overflowable toolbar and the widget has been overflowed. * * @param {string} aArea * The ID of the area whose customize target you want to have * @param {DOMWindow} aWindow * The window where you want to fetch the DOM node. * @returns {Element} * The customize target DOM node for aArea in aWindow */ getCustomizeTargetForArea(aArea, aWindow) { return CustomizableUIInternal.getCustomizeTargetForArea(aArea, aWindow); }, /** * Reset the customization state back to its default. * * This is the nuclear option. You should never call this except if the user * explicitly requests it. Firefox does this when the user clicks the * "Restore Defaults" button in customize mode. */ reset() { CustomizableUIInternal.reset(); }, /** * Undo the previous reset, can only be called immediately after a reset. * * @returns {Promise} * A promise that will be resolved when the operation is complete. */ undoReset() { CustomizableUIInternal.undoReset(); }, /** * Remove a custom toolbar added in a previous version of Firefox or using * an add-on. NB: only works on the customizable toolbars generated by * the toolbox itself. Intended for use from CustomizeMode, not by * other consumers. * * @param {string} aToolbarId * The ID of the toolbar to remove. */ removeExtraToolbar(aToolbarId) { CustomizableUIInternal.removeExtraToolbar(aToolbarId); }, /** * Can the last Restore Defaults operation be undone. * * @returns {boolean} * True if the last Restore Defaults operation can be undone. */ get canUndoReset() { return ( gUIStateBeforeReset.uiCustomizationState != null || gUIStateBeforeReset.drawInTitlebar != null || gUIStateBeforeReset.currentTheme != null || gUIStateBeforeReset.autoTouchMode != null || gUIStateBeforeReset.uiDensity != null || gUIStateBeforeReset.sidebarPositionStart != null ); }, /** * @typedef {object} CustomizableUIPlacementInfo * @param {string} area * The ID of the area where the widget is placed. * @param {number} position * The 0-indexed position of the widget according to the placements area * of the area that it's in. */ /** * 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 {string} aWidgetId * The ID of the widget whose placement you want to know. * @param {boolean} [aOnlyRegistered=true] * Set to false to return placements for widgets that aren't registered, * but still exist within the placements state, having been registered and * placed in the past. * @param {boolean} [aDeadAreas=false] * Set to true to include placements within "dead" areas that are no longer * registered, but still exist in the placement state. * @returns {CustomizableUIPlacementInfo|null} * Returns null if the widget is not placed anywhere (ie in the palette). */ getPlacementOfWidget(aWidgetId, aOnlyRegistered = true, aDeadAreas = false) { return CustomizableUIInternal.getPlacementOfWidget( aWidgetId, aOnlyRegistered, aDeadAreas ); }, /** * Check if a widget can be removed from the area it's in. * * Note that if you're wanting to move the widget somewhere, you should * generally be checking canWidgetMoveToArea, because that will return * true if the widget is already in the area where you want to move it (!). * * NB: oh, also, this method might lie if the widget in question is a * XUL-provided widget and there are no windows open, because it * can obviously not check anything in this case. It will return * true. You will be able to move the widget elsewhere. However, * once the user reopens a window, the widget will move back to its * 'proper' area automagically. * * @param {string} aWidgetId * A widget ID or DOM node to check. * @returns {boolean} * True if the widget can be removed from its area. */ isWidgetRemovable(aWidgetId) { return CustomizableUIInternal.isWidgetRemovable(aWidgetId); }, /** * Check if a widget can be moved to a particular area. Like * isWidgetRemovable but better, because it'll return true if the widget * is already in the right area. * * @param {string} aWidgetId * The ID of the widget that you want to move somewhere. * @param {string} aArea * The area ID you want to move the widget to. This can also be * CustomizableUI.AREA_NO_AREA to see if the widget can move to the * customization palette, whether it's removable or not. * @returns {boolean} * True if this is possible. The same caveats as for isWidgetRemovable * apply, however, if no windows are open. */ canWidgetMoveToArea(aWidgetId, aArea) { return CustomizableUIInternal.canWidgetMoveToArea(aWidgetId, aArea); }, /** * Whether we're in a default state. Note that non-removable non-default * widgets and non-existing widgets are not taken into account in determining * whether we're in the default state. * * NB: this is a property with a getter. The getter is NOT cheap, because * it does smart things with non-removable non-default items, non-existent * items, and so forth. Please don't call unless necessary. */ get inDefaultState() { return CustomizableUIInternal.inDefaultState; }, /** * Set a toolbar's visibility state in all windows. * * @param {string} aToolbarId * The toolbar whose visibility should be adjusted. * @param {boolean} aIsVisible * Whether the toolbar should be made visible. */ setToolbarVisibility(aToolbarId, aIsVisible) { CustomizableUIInternal.setToolbarVisibility(aToolbarId, aIsVisible); }, /** * Returns a Set with the IDs of any registered toolbar areas that are * currently collapsed in a particular window. Menubars that are set to * autohide and are in the temporary "open" state are still considered * collapsed by default. * * @param {Window} window The browser window to check for collapsed toolbars. * @returns {Set} */ getCollapsedToolbarIds(window) { return CustomizableUIInternal.getCollapsedToolbarIds(window); }, /** * Checks if a widget is likely visible in a given window. * * This method returns true when a widget is: * - Not pinned to the overflow menu * - Not in a collapsed toolbar (e.g. bookmarks toolbar, menu bar) * - Not in the customization palette * * Note: A widget that is moved into the overflow menu due to * the window being small might be considered visible by * this method, because a widget's placement does not * change when it overflows into the overflow menu. * * @param {string} aWidgetId the widget ID to check. * @param {Window} window The browser window to check for widget visibility. * @returns {boolean} whether the given widget is likely visible or not. */ widgetIsLikelyVisible(aWidgetId, window) { return CustomizableUIInternal.widgetIsLikelyVisible(aWidgetId, window); }, /** * DEPRECATED! Use fluent instead. * * Get a localized property off a (widget?) object. * * NB: this is unlikely to be useful unless you're in Firefox code, because * this code uses the builtin widget stringbundle, and can't be told * to use add-on-provided strings. It's mainly here as convenience for * custom builtin widgets that build their own DOM but use the same * stringbundle as the other builtin widgets. * * @param {string|object} aWidget * The ID of a widget, or a widget object whose properties we should use to * fetch a localizable string. * @param {string} aProp * The property on the object to use for the fetching from * customizableWidgets.properties. * @param {string[]} [aFormatArgs] * Any extra arguments to use for a formatted string. * @param {string} [aDef] * The default to return if we don't find the string in the stringbundle. * @returns {string} * The localized string, or aDef if the string isn't in the bundle. If no * default is provided, if aProp exists on aWidget, we'll return that, * otherwise we'll return the empty string. */ getLocalizedProperty(aWidget, aProp, aFormatArgs, aDef) { return CustomizableUIInternal.getLocalizedProperty( aWidget, aProp, aFormatArgs, aDef ); }, /** * Utility function to detect, find and set a keyboard shortcut for a menuitem * or (toolbar)button. * * @param {Element} aShortcutNode * The XUL node where the shortcut will be derived from; * @param {Element|null} aTargetNode * The XUL node on which the `shortcut` attribute will be set. If NULL, the * shortcut will be set on aShortcutNode. */ addShortcut(aShortcutNode, aTargetNode) { return CustomizableUIInternal.addShortcut(aShortcutNode, aTargetNode); }, /** * Given a node, walk up to the first panel in its ancestor chain, and * close it. * * @param {Element} aNode a node whose panel should be closed. */ hidePanelForNode(aNode) { CustomizableUIInternal.hidePanelForNode(aNode); }, /** * Check if a widget is a "special" widget: a spring, spacer or separator. * * @param {string} aWidgetId the widget ID to check. * @returns {boolean} true if the widget is 'special', false otherwise. */ isSpecialWidget(aWidgetId) { return CustomizableUIInternal.isSpecialWidget(aWidgetId); }, /** * Check if a widget is provided by an extension. This effectively checks * whether `webExtension: true` passed when the widget was being created. * * If the widget being referred to hasn't yet been created, or has been * destroyed, we fallback to checking the ID for the "-browser-action" * suffix. * * @param {string} aWidgetId the widget ID to check. * @returns {boolean} * True if the widget was provided by an extension, false otherwise. */ isWebExtensionWidget(aWidgetId) { if (typeof aWidgetId !== "string") { return false; } let widget = CustomizableUI.getWidget(aWidgetId); return widget?.webExtension || aWidgetId.endsWith("-browser-action"); }, /** * Add listeners to a panel that will close it. For use from the menu panel * and overflowable toolbar implementations, unlikely to be useful for other * consumers. * * @param {Element} 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 {Element} aPanel * The panel from which listeners should be removed. */ removePanelCloseListeners(aPanel) { CustomizableUIInternal.removePanelCloseListeners(aPanel); }, /** * Notify listeners a widget is about to be dragged to an area. For use from * Customize Mode only, do not use otherwise. * * @param {string} aWidgetId * The ID of the widget that is being dragged to an area. * @param {string} aArea * The ID of the area to which the widget is being dragged. */ onWidgetDrag(aWidgetId, aArea) { CustomizableUIInternal.notifyListeners("onWidgetDrag", aWidgetId, aArea); }, /** * Notify listeners that a window is entering customize mode. For use from * Customize Mode only, do not use otherwise. * * @param {DOMWindow} aWindow * The window entering customize mode. */ notifyStartCustomizing(aWindow) { CustomizableUIInternal.notifyListeners("onCustomizeStart", aWindow); }, /** * Notify listeners that a window is exiting customize mode. For use from * Customize Mode only, do not use otherwise. * * @param {DOMWindow} aWindow * The window exiting customize mode. */ notifyEndCustomizing(aWindow) { CustomizableUIInternal.notifyListeners("onCustomizeEnd", aWindow); }, /** * 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 {string} aEvent * The name of the event to send. * @param {object} [aDetails={}] * The details of the event. * @param {DOMWindow|null} [aWindow=null] * The window in which to send the event. */ dispatchToolboxEvent(aEvent, aDetails = {}, aWindow = null) { CustomizableUIInternal.dispatchToolboxEvent(aEvent, aDetails, aWindow); }, /** * Check whether an area is overflowable. * * @param {string} aAreaId * The ID of an area to check for overflowable-ness. * @returns {boolean} * True if the area is overflowable, false otherwise. */ isAreaOverflowable(aAreaId) { let area = gAreas.get(aAreaId); return area ? area.get("type") == this.TYPE_TOOLBAR && area.get("overflowable") : false; }, /** * Obtain a string indicating the place of an element. This is intended * for use from customize mode; You should generally use getPlacementOfWidget * instead, which is cheaper because it does not use the DOM. * * @param {DOMElement} aElement * The DOM node whose place we need to check. * @returns {string|undefined} * "toolbar" if the node is in a toolbar, "panel" if it is in the menu * panel, "palette" if it is in the (visible!) customization palette, * undefined otherwise. */ getPlaceForItem(aElement) { let place; let node = aElement; while (node && !place) { if (node.localName == "toolbar") { place = "toolbar"; } else if (node.id == CustomizableUI.AREA_FIXED_OVERFLOW_PANEL) { place = "panel"; } else if (node.id == "customization-palette") { place = "palette"; } node = node.parentNode; } return place; }, /** * Check if a toolbar is builtin or not. * * @param {string} aToolbarId * The ID of the toolbar you want to check. */ isBuiltinToolbar(aToolbarId) { return CustomizableUIInternal.builtinToolbars.has(aToolbarId); }, /** * Create an instance of a spring, spacer or separator. * * @param {string} aId * The type of special widget (spring, spacer or separator). * @param {Document} aDocument * The document in which to create it. * @returns {Element} * The created spring, spacer or separator node. */ createSpecialWidget(aId, aDocument) { return CustomizableUIInternal.createSpecialWidget(aId, aDocument); }, /** * Fills a submenu with menu items. * * @param {Element[]} aMenuItems * The array of menu items to display. * @param {Element} aSubview * The subview to fill with the menu items. */ fillSubviewFromMenuItems(aMenuItems, aSubview) { let attrs = [ "oncommand", "onclick", "label", "key", "disabled", "command", "observes", "hidden", "class", "origin", "image", "checked", "style", ]; // Use ownerGlobal.document to ensure we get the right doc even for // elements in template tags. let doc = aSubview.ownerGlobal.document; let fragment = doc.createDocumentFragment(); for (let menuChild of aMenuItems) { if (menuChild.hidden) { continue; } let subviewItem; if (menuChild.localName == "menuseparator") { // Don't insert duplicate or leading separators. This can happen if there are // menus (which we don't copy) above the separator. if ( !fragment.lastElementChild || fragment.lastElementChild.localName == "toolbarseparator" ) { continue; } subviewItem = doc.createXULElement("toolbarseparator"); } else if (menuChild.localName == "menuitem") { subviewItem = doc.createXULElement("toolbarbutton"); CustomizableUI.addShortcut(menuChild, subviewItem); let item = menuChild; if (!item.hasAttribute("onclick")) { subviewItem.addEventListener("click", event => { let newEvent = new doc.ownerGlobal.PointerEvent("click", event); // Telemetry should only pay attention to the original event. lazy.BrowserUsageTelemetry.ignoreEvent(newEvent); item.dispatchEvent(newEvent); }); } if (!item.hasAttribute("oncommand")) { subviewItem.addEventListener("command", event => { let newEvent = doc.createEvent("XULCommandEvent"); newEvent.initCommandEvent( event.type, event.bubbles, event.cancelable, event.view, event.detail, event.ctrlKey, event.altKey, event.shiftKey, event.metaKey, 0, event.sourceEvent, 0 ); // Telemetry should only pay attention to the original event. lazy.BrowserUsageTelemetry.ignoreEvent(newEvent); item.dispatchEvent(newEvent); }); } } else { continue; } for (let attr of attrs) { let attrVal = menuChild.getAttribute(attr); if (attrVal) { subviewItem.setAttribute(attr, attrVal); } } // We do this after so the .subviewbutton class doesn't get overriden. if (menuChild.localName == "menuitem") { subviewItem.classList.add("subviewbutton"); } // We make it possible to supply an alternative Fluent key when cloning // this menuitem into the AppMenu or panel contexts. This is because // we often use Title Case in menuitems in native menus, but want to use // Sentence case in the AppMenu / panels. let l10nId = menuChild.getAttribute("appmenu-data-l10n-id"); if (l10nId) { doc.l10n.setAttributes(subviewItem, l10nId); } fragment.appendChild(subviewItem); } aSubview.appendChild(fragment); }, /** * A helper function for clearing subviews. * * @param {Element} aSubview * The subview to clear. */ clearSubview(aSubview) { let parent = aSubview.parentNode; // We'll take the container out of the document before cleaning it out // to avoid reflowing each time we remove something. parent.removeChild(aSubview); while (aSubview.firstChild) { aSubview.firstChild.remove(); } parent.appendChild(aSubview); }, /** * Called when DOMContentLoaded fires for a new browser window. * * @param {DOMWindow} aWindow * The DOM Window that has just opened. */ handleNewBrowserWindow(aWindow) { return CustomizableUIInternal.handleNewBrowserWindow(aWindow); }, /** * Given a DOM node with the `customizable` attribute, will attempt to resolve * it to the associated "customization target" for that DOM node via its * `customizationtarget` attribute. If no such attribute exists, the DOM node * itself is returned. * * If the DOM node is null, is not customizable, or cannot be resolved to * a customization target, then null is returned. * * @param {Element|null} aElement * The DOM node to resolve to a customization target DOM node. * @returns {Element|null} * The customization target DOM node, or null if one cannot be found. */ getCustomizationTarget(aElement) { return CustomizableUIInternal.getCustomizationTarget(aElement); }, /** * This is a test-only method that allows tests to violate encapsulation and * gain access to some state internal to this module. If not running in test * automation, this will always return null. * * @param {string} aProp * The string representation of the internal property to retrieve. Only some * properties are supported - see the method code. * @returns {any|null} */ getTestOnlyInternalProp(aProp) { if (!Cu.isInAutomation) { return null; } switch (aProp) { case "CustomizableUIInternal": return CustomizableUIInternal; case "gAreas": return gAreas; case "gFuturePlacements": return gFuturePlacements; case "gPalette": return gPalette; case "gPlacements": return gPlacements; case "gSavedState": return gSavedState; case "gSeenWidgets": return gSeenWidgets; case "kVersion": return kVersion; } return null; }, /** * This is a test-only method that allows tests to violate encapsulation and * write to some state internal to this module. If not running in test * automation, this will always just immediately return without making any * changes. * * @param {string} aProp * The string representation of the internal property to change. Only some * properties are supported - see the method code. * @param {any} aValue * The value to set the property to. */ setTestOnlyInternalProp(aProp, aValue) { if (!Cu.isInAutomation) { return; } switch (aProp) { case "gSavedState": gSavedState = aValue; break; case "kVersion": kVersion = aValue; break; case "gDirty": gDirty = aValue; break; } }, }; Object.freeze(CustomizableUI); Object.freeze(CustomizableUI.windows); /** * All external consumers of widgets are really interacting with these wrappers * which provide a common interface. */ /** * WidgetGroupWrapper is the common interface for interacting with an entire * widget group - AKA, all instances of a widget across a series of windows. * This particular wrapper is only used for widgets created via the provider * API. */ function WidgetGroupWrapper(aWidget) { this.isGroup = true; const kBareProps = [ "id", "source", "type", "disabled", "label", "tooltiptext", "showInPrivateBrowsing", "hideInNonPrivateBrowsing", "viewId", "disallowSubView", "webExtension", ]; for (let prop of kBareProps) { let propertyName = prop; this.__defineGetter__(propertyName, () => aWidget[propertyName]); } this.__defineGetter__("provider", () => CustomizableUI.PROVIDER_API); this.__defineSetter__("disabled", function (aValue) { aValue = !!aValue; aWidget.disabled = aValue; for (let [, instance] of aWidget.instances) { instance.disabled = aValue; } }); this.forWindow = function WidgetGroupWrapper_forWindow(aWindow) { let wrapperMap; if (!gSingleWrapperCache.has(aWindow)) { wrapperMap = new Map(); gSingleWrapperCache.set(aWindow, wrapperMap); } else { wrapperMap = gSingleWrapperCache.get(aWindow); } if (wrapperMap.has(aWidget.id)) { return wrapperMap.get(aWidget.id); } let instance = aWidget.instances.get(aWindow.document); if (!instance) { instance = CustomizableUIInternal.buildWidgetNode( aWindow.document, aWidget ); } let wrapper = new WidgetSingleWrapper(aWidget, instance); wrapperMap.set(aWidget.id, wrapper); return wrapper; }; this.__defineGetter__("instances", function () { // Can't use gBuildWindows here because some areas load lazily: let placement = CustomizableUIInternal.getPlacementOfWidget(aWidget.id); if (!placement) { return []; } let area = placement.area; let buildAreas = gBuildAreas.get(area); if (!buildAreas) { return []; } return Array.from(buildAreas, node => this.forWindow(node.ownerGlobal)); }); this.__defineGetter__("areaType", function () { let areaProps = gAreas.get(aWidget.currentArea); return areaProps && areaProps.get("type"); }); Object.freeze(this); } /** * A WidgetSingleWrapper is a wrapper around a single instance of a widget in * a particular window. */ function WidgetSingleWrapper(aWidget, aNode) { this.isGroup = false; this.node = aNode; this.provider = CustomizableUI.PROVIDER_API; const kGlobalProps = ["id", "type"]; for (let prop of kGlobalProps) { this[prop] = aWidget[prop]; } const kNodeProps = ["label", "tooltiptext"]; for (let prop of kNodeProps) { let propertyName = prop; // Look at the node for these, instead of the widget data, to ensure the // wrapper always reflects this live instance. this.__defineGetter__(propertyName, () => aNode.getAttribute(propertyName)); } this.__defineGetter__("disabled", () => aNode.disabled); this.__defineSetter__("disabled", function (aValue) { aNode.disabled = !!aValue; }); this.__defineGetter__("anchor", function () { let anchorId; // First check for an anchor for the area: let placement = CustomizableUIInternal.getPlacementOfWidget(aWidget.id); if (placement) { anchorId = gAreas.get(placement.area).get("anchor"); } if (!anchorId) { anchorId = aNode.getAttribute("cui-anchorid"); } if (!anchorId) { anchorId = aNode.getAttribute("view-button-id"); } if (anchorId) { return aNode.ownerDocument.getElementById(anchorId); } if (aWidget.type == "button-and-view") { return aNode.lastElementChild; } return aNode; }); this.__defineGetter__("overflowed", function () { return aNode.getAttribute("overflowedItem") == "true"; }); Object.freeze(this); } /** * XULWidgetGroupWrapper is the common interface for interacting with an entire * widget group - AKA, all instances of a widget across a series of windows. * This particular wrapper is only used for widgets created via the old-school * XUL method (overlays, or programmatically injecting toolbaritems, or other * such things). */ // XXXunf Going to need to hook this up to some events to keep it all live. function XULWidgetGroupWrapper(aWidgetId) { this.isGroup = true; this.id = aWidgetId; this.type = "custom"; // XUL Widgets can never be provided by extensions. this.webExtension = false; this.provider = CustomizableUI.PROVIDER_XUL; this.forWindow = function XULWidgetGroupWrapper_forWindow(aWindow) { let wrapperMap; if (!gSingleWrapperCache.has(aWindow)) { wrapperMap = new Map(); gSingleWrapperCache.set(aWindow, wrapperMap); } else { wrapperMap = gSingleWrapperCache.get(aWindow); } if (wrapperMap.has(aWidgetId)) { return wrapperMap.get(aWidgetId); } let instance = aWindow.document.getElementById(aWidgetId); if (!instance) { // Toolbar palettes aren't part of the document, so elements in there // won't be found via document.getElementById(). instance = aWindow.gNavToolbox.palette.getElementsByAttribute( "id", aWidgetId )[0]; } let wrapper = new XULWidgetSingleWrapper( aWidgetId, instance, aWindow.document ); wrapperMap.set(aWidgetId, wrapper); return wrapper; }; this.__defineGetter__("areaType", function () { let placement = CustomizableUIInternal.getPlacementOfWidget(aWidgetId); if (!placement) { return null; } let areaProps = gAreas.get(placement.area); return areaProps && areaProps.get("type"); }); this.__defineGetter__("instances", function () { return Array.from(gBuildWindows, wins => this.forWindow(wins[0])); }); Object.freeze(this); } /** * A XULWidgetSingleWrapper is a wrapper around a single instance of a XUL * widget in a particular window. */ function XULWidgetSingleWrapper(aWidgetId, aNode, aDocument) { this.isGroup = false; this.id = aWidgetId; this.type = "custom"; this.provider = CustomizableUI.PROVIDER_XUL; let weakDoc = Cu.getWeakReference(aDocument); // If we keep a strong ref, the weak ref will never die, so null it out: aDocument = null; this.__defineGetter__("node", function () { // If we've set this to null (further down), we're sure there's nothing to // be gotten here, so bail out early: if (!weakDoc) { return null; } if (aNode) { // Return the last known node if it's still in the DOM... if (aNode.isConnected) { return aNode; } // ... or the toolbox let toolbox = aNode.ownerGlobal.gNavToolbox; if (toolbox && toolbox.palette && aNode.parentNode == toolbox.palette) { return aNode; } // If it isn't, clear the cached value and fall through to the "slow" case: aNode = null; } let doc = weakDoc.get(); if (doc) { // Store locally so we can cache the result: aNode = CustomizableUIInternal.findXULWidgetInWindow( aWidgetId, doc.defaultView ); return aNode; } // The weakref to the document is dead, we're done here forever more: weakDoc = null; return null; }); this.__defineGetter__("anchor", function () { let anchorId; // First check for an anchor for the area: let placement = CustomizableUIInternal.getPlacementOfWidget(aWidgetId); if (placement) { anchorId = gAreas.get(placement.area).get("anchor"); } let node = this.node; if (!anchorId && node) { anchorId = node.getAttribute("cui-anchorid"); } return anchorId && node ? node.ownerDocument.getElementById(anchorId) : node; }); this.__defineGetter__("overflowed", function () { let node = this.node; if (!node) { return false; } return node.getAttribute("overflowedItem") == "true"; }); Object.freeze(this); } /** * OverflowableToolbar is a class that gives a the ability to send * toolbar items that are "overflowable" to lists in separate panels if and * when the toolbar shrinks enough so that those items overflow out of bounds. * Secondly, this class manages moving things out from those panels and back * into the toolbar once it underflows and has the space to accommodate the * items that had originally overflowed out. * * There are two panels that toolbar items can be overflowed to: * * 1. The default items overflow panel * This is where built-in default toolbar items will go to. * 2. The Unified Extensions panel * This is where browser_action toolbar buttons created by extensions will * go to if the Unified Extensions UI is enabled - otherwise, those items will * go to the default items overflow panel. * * Finally, OverflowableToolbar manages the showing of the default items * overflow panel when the associated anchor is clicked or dragged over. The * Unified Extensions panel is managed separately by the extension code. * * In theory, we could have multiple overflowable toolbars, but in practice, * only the nav-bar (CustomizableUI.AREA_NAVBAR) makes use of this class. */ class OverflowableToolbar { /** * The OverflowableToolbar class is constructed during browser window * creation, but to optimize for window painting, we defer most work until * after the window has painted. This property is set to true once * initialization has completed. * * @type {boolean} */ #initialized = false; /** * A reference to the that is overflowable. * * @type {Element} */ #toolbar = null; /** * A reference to the part of the that accepts CustomizableUI * widgets. * * @type {Element} */ #target = null; /** * A mapping from the ID of a toolbar item that has overflowed to the width * that the toolbar item occupied in the toolbar at the time of overflow. Any * item that is currently overflowed will have an entry in this map. * * @type {Map} */ #overflowedInfo = new Map(); /** * The set of overflowed DOM nodes that were hidden at the time of overflowing. */ #hiddenOverflowedNodes = new WeakSet(); /** * True if the overflowable toolbar is actively handling overflows and * underflows. This value is set internally by the private #enable() and * #disable() methods. * * @type {boolean} */ #enabled = true; /** * A reference to the element that overflowed toolbar items will be * appended to as children upon overflow. * * @type {Element} */ #defaultList = null; /** * A reference to the button that opens the overflow panel. This is also * the element that the panel will anchor to. * * @type {Element} */ #defaultListButton = null; /** * A reference to the overflow panel that contains the #defaultList * element. * * @type {Element} */ #defaultListPanel = null; /** * A reference to the the element that overflowed extension browser action * toolbar items will be appended to as children upon overflow if the * Unified Extension UI is enabled. This is created lazily and might be null, * so you should use the #webExtList memoizing getter instead to get this. * * @type {Element|null} */ #webExtListRef = null; /** * An empty object that is created in #checkOverflow to identify individual * calls to #checkOverflow and avoid re-entrancy (since #checkOverflow is * asynchronous, and in theory, could be called multiple times before any of * those times have a chance to fully exit). * * @type {object} */ #checkOverflowHandle = null; /** * A timeout ID returned by setTimeout that identifies a timeout function that * runs to hide the #defaultListPanel if the user happened to open the panel by dragging * over the #defaultListButton and then didn't hover any part of the #defaultListPanel. * * @type {number} */ #hideTimeoutId = null; /** * Public methods start here. */ /** * OverflowableToolbar constructor. This is run very early on in the lifecycle * of a browser window, so it tries to defer most work to the init() method * instead after first paint. * * Upon construction, a "overflowable" attribute will be set on the * toolbar, set to the value of "true". * * Part of the API for OverflowableToolbar is declarative, in that it expects * certain attributes to be set on the that is overflowable. * Those attributes are: * * default-overflowbutton: * The ID of the button that is used to open and anchor the overflow panel. * default-overflowtarget: * The ID of the element that overflowed items will be appended to as * children. Note that the overflowed toolbar items are moved into and out * of this overflow target, so it is definitely advisable to let * OverflowableToolbar own managing the children of default-overflowtarget, * and to not modify it outside of this class. * default-overflowpanel: * The ID of the that contains the default-overflowtarget. * addon-webext-overflowbutton: * The ID of the button that is used to open and anchor the Unified * Extensions panel. * addon-webext-overflowtarget: * The ID of the element that overflowed extension toolbar buttons will * be appended to as children if the Unified Extensions UI is enabled. * Note that the overflowed toolbar items are moved into and out of this * overflow target, so it is definitely advisable to let OverflowableToolbar * own managing the children of addon-webext-overflowtarget, and to not * modify it outside of this class. * * @param {Element} aToolbarNode The that will be overflowable. * @throws {Error} Throws if the customization target of the toolbar somehow * isn't a direct descendent of the toolbar. */ constructor(aToolbarNode) { this.#toolbar = aToolbarNode; this.#target = CustomizableUI.getCustomizationTarget(this.#toolbar); if (this.#target.parentNode != this.#toolbar) { throw new Error( "Customization target must be a direct child of an overflowable toolbar." ); } this.#toolbar.setAttribute("overflowable", "true"); let doc = this.#toolbar.ownerDocument; this.#defaultList = doc.getElementById( this.#toolbar.getAttribute("default-overflowtarget") ); this.#defaultList._customizationTarget = this.#defaultList; let window = this.#toolbar.ownerGlobal; if (window.gBrowserInit.delayedStartupFinished) { this.init(); } else { Services.obs.addObserver(this, "browser-delayed-startup-finished"); } } /** * Does final initialization of the OverflowableToolbar after the window has * first painted. This will also kick off the first check to see if overflow * has already occurred at the time of initialization. */ init() { let doc = this.#toolbar.ownerDocument; let window = doc.defaultView; window.addEventListener("resize", this); window.gNavToolbox.addEventListener("customizationstarting", this); window.gNavToolbox.addEventListener("aftercustomization", this); let defaultListButton = this.#toolbar.getAttribute( "default-overflowbutton" ); this.#defaultListButton = doc.getElementById(defaultListButton); this.#defaultListButton.addEventListener("mousedown", this); this.#defaultListButton.addEventListener("keypress", this); this.#defaultListButton.addEventListener("dragover", this); this.#defaultListButton.addEventListener("dragend", this); let panelId = this.#toolbar.getAttribute("default-overflowpanel"); this.#defaultListPanel = doc.getElementById(panelId); this.#defaultListPanel.addEventListener("popuphiding", this); CustomizableUIInternal.addPanelCloseListeners(this.#defaultListPanel); CustomizableUI.addListener(this); this.#checkOverflow(); this.#initialized = true; } /** * Almost the exact reverse of init(). This is called when the browser window * is unloading. */ uninit() { this.#toolbar.removeAttribute("overflowable"); if (!this.#initialized) { Services.obs.removeObserver(this, "browser-delayed-startup-finished"); Services.prefs.removeObserver(kPrefSidebarVerticalTabsEnabled, this); Services.prefs.removeObserver(kPrefSidebarRevampEnabled, this); return; } this.#disable(); let window = this.#toolbar.ownerGlobal; window.removeEventListener("resize", this); window.gNavToolbox.removeEventListener("customizationstarting", this); window.gNavToolbox.removeEventListener("aftercustomization", this); this.#defaultListButton.removeEventListener("mousedown", this); this.#defaultListButton.removeEventListener("keypress", this); this.#defaultListButton.removeEventListener("dragover", this); this.#defaultListButton.removeEventListener("dragend", this); this.#defaultListPanel.removeEventListener("popuphiding", this); CustomizableUI.removeListener(this); CustomizableUIInternal.removePanelCloseListeners(this.#defaultListPanel); } /** * Opens the overflow #defaultListPanel if it's not already open. If the panel is in * the midst of hiding when this is called, the panel will be re-opened. * * @returns {Promise} * Resolves once the panel is open. */ show(aEvent) { if (this.#defaultListPanel.state == "open") { return Promise.resolve(); } return new Promise(resolve => { let doc = this.#defaultListPanel.ownerDocument; this.#defaultListPanel.hidden = false; let multiview = this.#defaultListPanel.querySelector("panelmultiview"); let mainViewId = multiview.getAttribute("mainViewId"); let mainView = doc.getElementById(mainViewId); let contextMenu = doc.getElementById(mainView.getAttribute("context")); contextMenu.addEventListener("command", this, { capture: true, mozSystemGroup: true, }); let anchor = this.#defaultListButton.icon; let popupshown = false; this.#defaultListPanel.addEventListener( "popupshown", () => { popupshown = true; this.#defaultListPanel.addEventListener("dragover", this); this.#defaultListPanel.addEventListener("dragend", this); // Wait until the next tick to resolve so all popupshown // handlers have a chance to run before our promise resolution // handlers do. Services.tm.dispatchToMainThread(resolve); }, { once: true } ); let openPanel = () => { // Ensure we update the gEditUIVisible flag when opening the popup, in // case the edit controls are in it. this.#defaultListPanel.addEventListener( "popupshowing", () => { doc.defaultView.updateEditUIVisibility(); }, { once: true } ); this.#defaultListPanel.addEventListener( "popuphidden", () => { if (!popupshown) { // The panel was hidden again before it was shown. This can break // consumers waiting for the panel to show. So we try again. openPanel(); } }, { once: true } ); lazy.PanelMultiView.openPopup( this.#defaultListPanel, anchor || this.#defaultListButton, { triggerEvent: aEvent, } ); this.#defaultListButton.open = true; }; openPanel(); }); } /** * Exposes whether #checkOverflow is currently running. * * @returns {boolean} True if #checkOverflow is currently running. */ isHandlingOverflow() { return !!this.#checkOverflowHandle; } /** * Finds the most appropriate place to insert toolbar item aNode if we've been * asked to put it into the overflowable toolbar without being told exactly * where. * * @param {Element} aNode The toolbar item being inserted. * @returns {Array} [parent, nextNode] * parent: {Element} The parent element that should contain aNode. * nextNode: {Element|null} The node that should follow aNode after * insertion, if any. If this is null, aNode should be placed at the end * of parent. */ findOverflowedInsertionPoints(aNode) { let newNodeCanOverflow = aNode.getAttribute("overflows") != "false"; let areaId = this.#toolbar.id; let placements = gPlacements.get(areaId); let nodeIndex = placements.indexOf(aNode.id); let nodeBeforeNewNodeIsOverflown = false; let loopIndex = -1; // Loop through placements to find where to insert this item. // As soon as we find an overflown widget, we will only // insert in the overflow panel (this is why we check placements // before the desired location for the new node). Once we pass // the desired location of the widget, we look for placement ids // that actually have DOM equivalents to insert before. If all // else fails, we insert at the end of either the overflow list // or the toolbar target. while (++loopIndex < placements.length) { let nextNodeId = placements[loopIndex]; if (loopIndex > nodeIndex) { // Note that if aNode is in a template, its `ownerDocument` is *not* // going to be the browser.xhtml document, so we cannot rely on it. let nextNode = this.#toolbar.ownerDocument.getElementById(nextNodeId); // If the node we're inserting can overflow, and the next node // in the toolbar is overflown, we should insert this node // in the overflow panel before it. if ( newNodeCanOverflow && this.#overflowedInfo.has(nextNodeId) && nextNode && nextNode.parentNode == this.#defaultList ) { return [this.#defaultList, nextNode]; } // Otherwise (if either we can't overflow, or the previous node // wasn't overflown), and the next node is in the toolbar itself, // insert the node in the toolbar. if ( (!nodeBeforeNewNodeIsOverflown || !newNodeCanOverflow) && nextNode && (nextNode.parentNode == this.#target || // Also check if the next node is in a customization wrapper // (toolbarpaletteitem). We don't need to do this for the // overflow case because overflow is disabled in customize mode. (nextNode.parentNode.localName == "toolbarpaletteitem" && nextNode.parentNode.parentNode == this.#target)) ) { return [this.#target, nextNode]; } } else if ( loopIndex < nodeIndex && this.#overflowedInfo.has(nextNodeId) ) { nodeBeforeNewNodeIsOverflown = true; } } let overflowList = CustomizableUI.isWebExtensionWidget(aNode.id) ? this.#webExtList : this.#defaultList; let containerForAppending = this.#overflowedInfo.size && newNodeCanOverflow ? overflowList : this.#target; return [containerForAppending, null]; } /** * Allows callers to query for the current parent of a toolbar item that may * or may not be overflowed. That parent will either be #defaultList, * #webExtList (if it's an extension button) or #target. * * Note: It is assumed that the caller has verified that aNode is placed * within the toolbar customizable area according to CustomizableUI. * * @param {Element} aNode the node that can be overflowed by this * OverflowableToolbar. * @returns {Element} The current containing node for aNode. */ getContainerFor(aNode) { if (aNode.getAttribute("overflowedItem") == "true") { return CustomizableUI.isWebExtensionWidget(aNode.id) ? this.#webExtList : this.#defaultList; } return this.#target; } /** * Private methods start here. */ /** * Handle overflow in the toolbar by moving items to the overflow menu. */ async #onOverflow() { if (!this.#enabled) { return; } let win = this.#target.ownerGlobal; let checkOverflowHandle = this.#checkOverflowHandle; let webExtButtonID = this.#toolbar.getAttribute( "addon-webext-overflowbutton" ); let { isOverflowing, targetContentWidth } = await this.#getOverflowInfo(); // Stop if the window has closed or if we re-enter while waiting for // layout. if (win.closed || this.#checkOverflowHandle != checkOverflowHandle) { lazy.log.debug("Window closed or another overflow handler started."); return; } let webExtList = this.#webExtList; let child = this.#target.lastElementChild; while (child && isOverflowing) { let prevChild = child.previousElementSibling; if (child.getAttribute("overflows") != "false") { this.#overflowedInfo.set(child.id, targetContentWidth); let { width: childWidth } = win.windowUtils.getBoundsWithoutFlushing(child); if (!childWidth) { this.#hiddenOverflowedNodes.add(child); } child.setAttribute("overflowedItem", true); CustomizableUIInternal.ensureButtonContextMenu( child, this.#toolbar, true ); CustomizableUIInternal.notifyListeners( "onWidgetOverflow", child, this.#target ); if (webExtList && CustomizableUI.isWebExtensionWidget(child.id)) { child.setAttribute("cui-anchorid", webExtButtonID); webExtList.insertBefore(child, webExtList.firstElementChild); } else { child.setAttribute("cui-anchorid", this.#defaultListButton.id); this.#defaultList.insertBefore( child, this.#defaultList.firstElementChild ); if (!CustomizableUI.isSpecialWidget(child.id) && childWidth) { this.#toolbar.setAttribute("overflowing", "true"); } } } child = prevChild; ({ isOverflowing, targetContentWidth } = await this.#getOverflowInfo()); // Stop if the window has closed or if we re-enter while waiting for // layout. if (win.closed || this.#checkOverflowHandle != checkOverflowHandle) { lazy.log.debug("Window closed or another overflow handler started."); return; } } win.UpdateUrlbarSearchSplitterState(); } /** * @typedef {object} CustomizableUIOverflowInfo * @property {boolean} isOverflowing * True if at least one toolbar item has overflowed into an overflow panel. * @property {number} targetContentWidth * The total width of the items within the customization target area of the * overflowable toolbar toolbar. * @property {number} totalAvailWidth * The maximum width items in the overflowable toolbar may occupy before * causing an overflow. */ /** * Returns a Promise that resolves to a an object that describes the state * that this OverflowableToolbar is currently in. * * @returns {Promise} */ async #getOverflowInfo() { function getInlineSize(aElement) { return aElement.getBoundingClientRect().width; } function sumChildrenInlineSize(aParent, aExceptChild = null) { let sum = 0; for (let child of aParent.children) { let style = win.getComputedStyle(child); if ( style.display == "none" || win.XULPopupElement.isInstance(child) || (style.position != "static" && style.position != "relative") ) { continue; } sum += parseFloat(style.marginLeft) + parseFloat(style.marginRight); if (child != aExceptChild) { sum += getInlineSize(child); } } return sum; } let win = this.#target.ownerGlobal; let totalAvailWidth; let targetWidth; let targetChildrenWidth; await win.promiseDocumentFlushed(() => { let style = win.getComputedStyle(this.#toolbar); let toolbarChildrenWidth = sumChildrenInlineSize( this.#toolbar, this.#target ); totalAvailWidth = getInlineSize(this.#toolbar) - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight) - toolbarChildrenWidth; targetWidth = getInlineSize(this.#target); targetChildrenWidth = this.#target == this.#toolbar ? toolbarChildrenWidth : sumChildrenInlineSize(this.#target); }); lazy.log.debug( `Getting overflow info: target width: ${targetWidth} (${targetChildrenWidth}); avail: ${totalAvailWidth}` ); // If the target has min-width: 0, their children might actually overflow // it, so check for both cases explicitly. let targetContentWidth = Math.max(targetWidth, targetChildrenWidth); let isOverflowing = Math.floor(targetContentWidth) > totalAvailWidth; return { isOverflowing, targetContentWidth, totalAvailWidth }; } /** * Tries to move toolbar items back to the toolbar from the overflow panel. * * @param {boolean} shouldMoveAllItems * Whether we should move everything (e.g. because we're being * disabled) * @param {number} [totalAvailWidth=undefined] * Optional; the width of the toolbar area in which we can put things. * Some consumers pass this to avoid reflows. * * While there are items in the list, this width won't change, and so * we can avoid flushing layout by providing it and/or caching it. * Note that if `shouldMoveAllItems` is true, we never need the width * anyway, and this value is ignored. * @returns {Promise} * Resolves once moving of items has completed. */ async #moveItemsBackToTheirOrigin(shouldMoveAllItems, totalAvailWidth) { lazy.log.debug( `Attempting to move ${shouldMoveAllItems ? "all" : "some"} items back` ); let placements = gPlacements.get(this.#toolbar.id); let win = this.#target.ownerGlobal; let doc = this.#target.ownerDocument; let checkOverflowHandle = this.#checkOverflowHandle; let overflowedItemStack = Array.from(this.#overflowedInfo.entries()); for (let i = overflowedItemStack.length - 1; i >= 0; --i) { let [childID, minSize] = overflowedItemStack[i]; // The item may have been placed inside of a that is lazily // loaded and still in the view cache. PanelMultiView.getViewNode will // do the work of checking the DOM for the child, and then falling back to // the cache if that is the case. let child = lazy.PanelMultiView.getViewNode(doc, childID); if (!child) { this.#overflowedInfo.delete(childID); continue; } lazy.log.debug( `Considering moving ${child.id} back, minSize: ${minSize}` ); if (!shouldMoveAllItems && minSize) { if (!totalAvailWidth) { ({ totalAvailWidth } = await this.#getOverflowInfo()); // If the window has closed or if we re-enter because we were waiting // for layout, stop. if (win.closed || this.#checkOverflowHandle != checkOverflowHandle) { lazy.log.debug("Window closed or #checkOverflow called again."); return; } } if (totalAvailWidth <= minSize) { lazy.log.debug( `Need ${minSize} but width is ${totalAvailWidth} so bailing` ); break; } } lazy.log.debug(`Moving ${child.id} back`); this.#overflowedInfo.delete(child.id); let beforeNodeIndex = placements.indexOf(child.id) + 1; // If this is a skipintoolbarset item, meaning it doesn't occur in the placements list, // we're inserting it at the end. This will mean first-in, first-out (more or less) // leading to as little change in order as possible. if (beforeNodeIndex == 0) { beforeNodeIndex = placements.length; } let inserted = false; for (; beforeNodeIndex < placements.length; beforeNodeIndex++) { let beforeNode = this.#target.getElementsByAttribute( "id", placements[beforeNodeIndex] )[0]; // Unfortunately, XUL add-ons can mess with nodes after they are inserted, // and this breaks the following code if the button isn't where we expect // it to be (ie not a child of the target). In this case, ignore the node. if (beforeNode && this.#target == beforeNode.parentElement) { this.#target.insertBefore(child, beforeNode); inserted = true; break; } } if (!inserted) { this.#target.appendChild(child); } child.removeAttribute("cui-anchorid"); child.removeAttribute("overflowedItem"); CustomizableUIInternal.ensureButtonContextMenu(child, this.#target); CustomizableUIInternal.notifyListeners( "onWidgetUnderflow", child, this.#target ); } win.UpdateUrlbarSearchSplitterState(); let defaultListItems = Array.from(this.#defaultList.children); if ( defaultListItems.every( item => CustomizableUI.isSpecialWidget(item.id) || this.#hiddenOverflowedNodes.has(item) ) ) { this.#toolbar.removeAttribute("overflowing"); } } /** * Checks to see if there are overflowable items within the customization * target of the toolbar that should be moved into the overflow panel, and * if there are, moves them. * * Note that since this is an async function that can be called in bursts * by resize events on the window, this function is often re-called even * when a prior call hasn't yet resolved. In that situation, the older calls * resolve early without doing any work and leave any DOM manipulation to the * most recent call. * * This function is a no-op if the OverflowableToolbar is disabled or the * DOM fullscreen UI is currently being used. * * @returns {Promise} * Resolves once any movement of toolbar items has completed. */ async #checkOverflow() { if (!this.#enabled) { return; } let win = this.#target.ownerGlobal; if (win.document.documentElement.hasAttribute("inDOMFullscreen")) { // Toolbars are hidden and cannot be made visible in DOM fullscreen mode // so there's nothing to do here. return; } let checkOverflowHandle = (this.#checkOverflowHandle = {}); lazy.log.debug("Checking overflow"); let { isOverflowing, totalAvailWidth } = await this.#getOverflowInfo(); if (win.closed || this.#checkOverflowHandle != checkOverflowHandle) { return; } if (isOverflowing) { await this.#onOverflow(); } else { await this.#moveItemsBackToTheirOrigin(false, totalAvailWidth); } if (checkOverflowHandle == this.#checkOverflowHandle) { this.#checkOverflowHandle = null; } } /** * Makes the OverflowableToolbar inert and moves all overflowable items back * into the customization target of the toolbar. */ #disable() { // Abort any ongoing overflow check. #enable() will #checkOverflow() // anyways, so this is enough. this.#checkOverflowHandle = {}; this.#moveItemsBackToTheirOrigin(true); this.#enabled = false; } /** * Puts the OverflowableToolbar into the enabled state and then checks to see * if any of the items in the customization target should be overflowed into * the overflow panel list. */ #enable() { this.#enabled = true; this.#checkOverflow(); } /** * Shows the overflow panel and sets a timeout to automatically re-hide the * panel if it is not being hovered. */ #showWithTimeout() { const OVERFLOW_PANEL_HIDE_DELAY_MS = 500; this.show().then(() => { let window = this.#toolbar.ownerGlobal; if (this.#hideTimeoutId) { window.clearTimeout(this.#hideTimeoutId); } this.#hideTimeoutId = window.setTimeout(() => { if (!this.#defaultListPanel.firstElementChild.matches(":hover")) { lazy.PanelMultiView.hidePopup(this.#defaultListPanel); } }, OVERFLOW_PANEL_HIDE_DELAY_MS); }); } /** * Gets and caches a reference to the DOM node with the ID set as the value * of addon-webext-overflowtarget. If a cache already exists, that's returned * instead. If addon-webext-overflowtarget has no value, null is returned. * * @returns {Element|null} the list that overflowed extension toolbar * buttons should go to if the Unified Extensions UI is enabled, or null * if no such list exists. */ get #webExtList() { if (!this.#webExtListRef) { let targetID = this.#toolbar.getAttribute("addon-webext-overflowtarget"); if (!targetID) { throw new Error( "addon-webext-overflowtarget was not defined on the " + `overflowable toolbar with id: ${this.#toolbar.id}` ); } let win = this.#toolbar.ownerGlobal; let { panel } = win.gUnifiedExtensions; this.#webExtListRef = panel.querySelector(`#${targetID}`); } return this.#webExtListRef; } /** * Returns true if aNode is not null and is one of either this.#webExtList or * this.#defaultList. * * @param {DOMElement} aNode The node to test. * @returns {boolean} */ #isOverflowList(aNode) { return aNode == this.#defaultList || aNode == this.#webExtList; } /** * Private event handlers start here. */ /** * Handles clicks on the #defaultListButton element. * * @param {MouseEvent} aEvent the click event. */ #onClickDefaultListButton(aEvent) { if (this.#defaultListButton.open) { this.#defaultListButton.open = false; lazy.PanelMultiView.hidePopup(this.#defaultListPanel); } else if ( this.#defaultListPanel.state != "hiding" && !this.#defaultListButton.disabled ) { this.show(aEvent); } } /** * Handles the popuphiding event firing on the #defaultListPanel. * * @param {WidgetMouseEvent} aEvent the popuphiding event that fired on the * #defaultListPanel. */ #onPanelHiding(aEvent) { if (aEvent.target != this.#defaultListPanel) { // Ignore context menus,