Source code

Revision control

Copy as Markdown

Other Tools

/* 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, {
});
ChromeUtils.defineLazyGetter(lazy, "gWidgetsBundle", function () {
const kUrl =
return Services.strings.createBundle(kUrl);
});
const kDefaultThemeID = "default-theme@mozilla.org";
const kSpecialWidgetPfx = "customizableui-special-";
const kPrefCustomizationState = "browser.uiCustomization.state";
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 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 = 20;
/**
* 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.
*/
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,
};
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
);
ChromeUtils.defineLazyGetter(lazy, "log", () => {
let { ConsoleAPI } = ChromeUtils.importESModule(
);
let consoleOptions = {
maxLogLevel: lazy.gDebuggingEnabled ? "all" : "log",
prefix: "CustomizableUI",
};
return new ConsoleAPI(consoleOptions);
});
var CustomizableUIInternal = {
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 = [
"back-button",
"forward-button",
"stop-reload-button",
Services.policies.isAllowed("removeHomeButtonByDefault")
? null
: "home-button",
"spring",
"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,
defaultCollapsed: false,
},
true
);
if (AppConstants.MENUBAR_CAN_AUTOHIDE) {
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",
],
defaultCollapsed: null,
},
true
);
this.registerArea(
CustomizableUI.AREA_BOOKMARKS,
{
type: CustomizableUI.TYPE_TOOLBAR,
defaultPlacements: ["personal-bookmarks"],
defaultCollapsed: "newtab",
},
true
);
SearchWidgetTracker.init();
Services.obs.addObserver(this, "browser-set-toolbar-visibility");
},
onEnabled(addon) {
if (addon.type == "theme") {
gSelectedTheme = addon;
}
},
get _builtinAreas() {
return new Set([
...this._builtinToolbars,
CustomizableUI.AREA_FIXED_OVERFLOW_PANEL,
CustomizableUI.AREA_ADDONS,
]);
},
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;
},
_defineBuiltInWidgets() {
for (let widgetDefinition of lazy.CustomizableWidgets) {
this.createBuiltinWidget(widgetDefinition);
}
},
// 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;
}
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.push("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");
}
}
},
_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);
},
/**
* _markObsoleteBuiltinButtonsSeen
* when upgrading, ensure obsoleted buttons are in seen state.
*/
_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;
}
}
},
_placeNewDefaultWidgetsInArea(aArea) {
let futurePlacedWidgets = gFuturePlacements.get(aArea);
let savedPlacements =
gSavedState && gSavedState.placements && gSavedState.placements[aArea];
let 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 ||
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();
},
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;
},
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;
},
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)
) {
// 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);
}
}
},
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);
}
},
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 {
this.endBatchUpdate();
}
},
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;
}
}
},
buildArea(aArea, 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(aArea).get("type") == CustomizableUI.TYPE_PANEL;
if (!container) {
throw new Error(
"Expected area " + aArea + " 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 (aArea == 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 && aArea != 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;
}
this.ensureButtonContextMenu(node, aAreaNode);
// This needs updating in case we're resetting / undoing a reset.
if (widget) {
widget.currentArea = aArea;
}
this.insertWidgetBefore(node, currentNode, container, aArea);
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 " +
aArea +
": " +
node.id
);
gPlacements.get(aArea).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(aArea);
for (let id of placementsToRemove) {
let index = placementAry.indexOf(id);
placementAry.splice(index, 1);
}
}
if (gResetting) {
this.notifyListeners("onAreaReset", aArea, container);
}
} finally {
this.endBatchUpdate();
}
},
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));
},
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));
}
},
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");
}
},
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;
},
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.buildWidget(document, widget)];
}
lazy.log.debug("Searching for " + aWidgetId + " in toolbox.");
let node = this.findWidgetInWindow(aWidgetId, aWindow);
if (node) {
return [CustomizableUI.PROVIDER_XUL, node];
}
lazy.log.debug("No node for " + aWidgetId + " found.");
return [null, null];
},
registerPanelNode(aNode, aArea) {
if (gBuildAreas.has(aArea) && gBuildAreas.get(aArea).has(aNode)) {
return;
}
aNode._customizationTarget = aNode;
this.addPanelCloseListeners(this._getPanelForNode(aNode));
let placements = gPlacements.get(aArea);
this.buildArea(aArea, placements, aNode);
this.notifyListeners("onAreaNodeRegistered", aArea, 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(aArea, aNode);
},
onWidgetAdded(aWidgetId, aArea, aPosition) {
this.insertNode(aWidgetId, aArea, aPosition, true);
if (!gResetting) {
this._clearPreviousUIState();
}
},
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;
for (let areaNode of areaNodes) {
let window = areaNode.ownerGlobal;
if (
!showInPrivateBrowsing &&
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();
}
},
onWidgetMoved(aWidgetId, aArea, aOldPosition, aNewPosition) {
this.insertNode(aWidgetId, aArea, aNewPosition);
if (!gResetting) {
this._clearPreviousUIState();
}
},
onCustomizeEnd() {
this._clearPreviousUIState();
},
registerBuildArea(aArea, aNode) {
// We ensure that the window is registered to have its customization data
// cleaned up when unloading.
let window = aNode.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(aArea)) {
gBuildAreas.set(aArea, new Set());
}
gBuildAreas.get(aArea).add(aNode);
// Give a class to all customize targets to be used for styling in Customize Mode
let customizableNode = this.getCustomizeTargetForArea(aArea, window);
customizableNode.classList.add("customization-target");
},
registerBuildWindow(aWindow) {
if (!gBuildWindows.has(aWindow)) {
gBuildWindows.set(aWindow, new Set());
aWindow.addEventListener("unload", this);
aWindow.addEventListener("command", this, true);
this.notifyListeners("onWindowOpened", aWindow);
}
},
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);
},
setLocationAttributes(aNode, aArea) {
let props = gAreas.get(aArea);
if (!props) {
throw new Error(
"Expected area " +
aArea +
" 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");
}
},
removeLocationAttributes(aNode) {
aNode.removeAttribute("cui-areatype");
aNode.removeAttribute("cui-anchorid");
},
insertNode(aWidgetId, aArea, aPosition, isNew) {
let areaNodes = gBuildAreas.get(aArea);
if (!areaNodes) {
return;
}
let placements = gPlacements.get(aArea);
if (!placements) {
lazy.log.error(
"Could not find any placements for " + aArea + " 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);
}
},
insertNodeInWindow(aWidgetId, aAreaNode, isNew) {
let window = aAreaNode.ownerGlobal;
let showInPrivateBrowsing = gPalette.has(aWidgetId)
? gPalette.get(aWidgetId).showInPrivateBrowsing
: true;
if (
!showInPrivateBrowsing &&
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);
},
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];
},
insertWidgetBefore(aNode, aNextNode, aContainer, aArea) {
this.notifyDOMChange(aNode, aNextNode, aContainer, false, () => {
this.setLocationAttributes(aNode, aArea);
aContainer.insertBefore(aNode, aNextNode);
});
},
notifyDOMChange(aNode, aNextNode, aContainer, aIsRemove, aCallback) {
this.notifyListeners(
"onWidgetBeforeDOMChange",
aNode,
aNextNode,
aContainer,
aIsRemove
);
aCallback();
this.notifyListeners(
"onWidgetAfterDOMChange",
aNode,
aNextNode,
aContainer,
aIsRemove
);
},
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;
}
},
_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);
},
_getSpecialIdForNode(aNode) {
if (typeof aNode == "object" && aNode.localName) {
if (aNode.id) {
return aNode.id;
}
if (aNode.localName.startsWith("toolbar")) {
return aNode.localName.substring(7);
}
return "";
}
return aNode;
},
isSpecialWidget(aId) {
aId = this._getSpecialIdForNode(aId);
return (
aId.startsWith(kSpecialWidgetPfx) ||
aId.startsWith("separator") ||
aId.startsWith("spring") ||
aId.startsWith("spacer")
);
},
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]
);
},
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;
},
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 in a window. Don't try to use this
* for an API-provided widget or a special widget.
*/
findWidgetInWindow(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;
},
buildWidget(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;
}
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;
},
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."
);
},
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;
},
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)
);
},
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
);
}
},
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);
},
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);
}
}
},
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
);
}
},
_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).
*/
_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;
}
// 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;
},
hidePanelForNode(aNode) {
let panel = this._getPanelForNode(aNode);
if (panel) {
lazy.PanelMultiView.hidePopup(panel);
}
},
maybeAutoHidePanel(aEvent) {
let eventType = aEvent.type;
if (eventType == "keypress" && aEvent.keyCode != aEvent.DOM_VK_RETURN) {
return;
}
if (eventType == "click" && aEvent.button != 0) {
return;
}
// We don't check preventDefault - it makes sense that this was prevented,
// but we probably still want to close the panel. If consumers don't want
// this to happen, they should specify the closemenu attribute.
if (eventType != "command" && this._isOnInteractiveElement(aEvent)) {
return;
}
// We can't use event.target because we might have passed an anonymous
// content boundary as well, and so target points to the outer element in
// that case. Unfortunately, this means we get anonymous child nodes instead
// of the real ones, so looking for the 'stoooop, don't close me' attributes
// is more involved.
let target = aEvent.originalTarget;
while (target.parentNode && target.localName != "panel") {
if (
target.getAttribute("closemenu") == "none" ||
target.getAttribute("widget-type") == "view" ||
target.getAttribute("widget-type") == "button-and-view" ||
target.hasAttribute("view-button-id")
) {
return;
}
target = target.parentNode;
}
// If we get here, we can actually hide the popup:
this.hidePanelForNode(aEvent.target);
},
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 (widget.showInPrivateBrowsing || !isWindowPrivate) {
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];
},
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;
},
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;
},
addWidgetToArea(aWidgetId, aArea, aPosition, aInitialAdd) {
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);
},
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);
this.notifyListeners("onWidgetRemoved", aWidgetId, oldPlacement.area);
},
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
);
},
// Note that this does not populate gPlacements, which is done lazily.
// The panel area is an exception here.
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;
},
restoreStateForArea(aArea) {
let placementsPreexisted = gPlacements.has(aArea);
this.beginBatchUpdate();
try {
gRestoring = true;
let restored = false;
if (placementsPreexisted) {
lazy.log.debug("Restoring " + aArea + " from pre-existing placements");
for (let [position, id] of gPlacements.get(aArea).entries()) {
this.moveWidgetWithinArea(id, position);
}
gDirty = false;
restored = true;
} else {
gPlacements.set(aArea, []);
}
if (!restored && gSavedState && aArea in gSavedState.placements) {
lazy.log.debug("Restoring " + aArea + " from saved state");
let placements = gSavedState.placements[aArea];
for (let id of placements) {
this.addWidgetToArea(id, aArea);
}
gDirty = false;
restored = true;
}
if (!restored) {
lazy.log.debug("Restoring " + aArea + " from default state");
let defaults = gAreas.get(aArea).get("defaultPlacements");
if (defaults) {
for (let id of defaults) {
this.addWidgetToArea(id, aArea, 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(aArea)) {
let areaPlacements = gPlacements.get(aArea);
for (let id of gFuturePlacements.get(aArea)) {
if (areaPlacements.includes(id)) {
continue;
}
this.addWidgetToArea(id, aArea);
}
gFuturePlacements.delete(aArea);
}
lazy.log.debug(
"Placements for " +
aArea +
":\n\t" +
gPlacements.get(aArea).join("\n\t")
);
gRestoring = false;
} finally {
this.endBatchUpdate();
}
},
saveState() {
if (gInBatchStack || !gDirty) {
return;
}
// Clone because we want to modify this map:
let state = {
placements: new Map(gPlacements),
seen: gSeenWidgets,
dirtyAreaCache: gDirtyAreaCache,
currentVersion: kVersion,
newElementCount: gNewElementCount,
};
// Merge in previously saved areas if not present in gPlacements.
// This way, state is still persisted for e.g. temporarily disabled
// add-ons - see bug 989338.
if (gSavedState && gSavedState.placements) {
for (let area of Object.keys(gSavedState.placements)) {
if (!state.placements.has(area)) {
let placements = gSavedState.placements[area];
state.placements.set(area, placements);
}
}
}
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;
},
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;
},
beginBatchUpdate() {
gInBatchStack++;
},
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."
);
}
},
addListener(aListener) {
gListeners.add(aListener);
},
removeListener(aListener) {
if (aListener == this) {
return;
}
gListeners.delete(aListener);
},
notifyListeners(aEvent, ...aArgs) {
if (gRestoring) {
return;
}
for (let listener of gListeners) {
try {
if (typeof listener[aEvent] == "function") {
listener[aEvent].apply(listener, aArgs);
}
} catch (e) {
lazy.log.error(e + " -- " + e.fileName + ":" + e.lineNumber);
}
}
},
_dispatchToolboxEventToWindow(aEventType, aDetails, aWindow) {
let evt = new aWindow.CustomEvent(aEventType, {
bubbles: true,
cancelable: true,
detail: aDetails,
});
aWindow.gNavToolbox.dispatchEvent(evt);
},
dispatchToolboxEvent(aEventType, aDetails = {}, aWindow = null) {
if (aWindow) {
this._dispatchToolboxEventToWindow(aEventType, aDetails, aWindow);
return;
}
for (let [win] of gBuildWindows) {
this._dispatchToolboxEventToWindow(aEventType, aDetails, win);
}
},
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;
},
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 area will eventually lazily restore (but hasn't yet).
isAreaLazy(aArea) {
if (gPlacements.has(aArea)) {
return false;
}
return gAreas.get(aArea).get("type") == CustomizableUI.TYPE_TOOLBAR;
},
// XXXunf Log some warnings here, when the data provided isn't up to scratch.
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,
_introducedInVersion: -1,
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",
"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;
}
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;
},
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;
}
};
},
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);
},
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;
},
reset() {
gResetting = true;
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;
},
_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;
} catch (e) {}
Services.prefs.clearUserPref(kPrefCustomizationState);
Services.prefs.clearUserPref(kPrefDrawInTitlebar);
Services.prefs.clearUserPref(kPrefUIDensity);
Services.prefs.clearUserPref(kPrefAutoTouchMode);
Services.prefs.clearUserPref(kPrefAutoHideDownloadsButton);
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);
}
}
},
_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,
} = 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
);
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;
},
_clearPreviousUIState() {
Object.getOwnPropertyNames(gUIStateBeforeReset).forEach(prop => {
gUIStateBeforeReset[prop] = null;
});
},
/**
* @param {String|Node} aWidget - widget ID or a widget node (preferred for performance).
* @return {Boolean} whether 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;
},
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);
},
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;
},
_getCurrentWidgetsInContainer(container) {
// Get a list of all the widget IDs in this container, including any that
// are overflown.
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 => currentWidgets.has(w));
},
get inDefaultState() {
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;
}
return true;
},
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;
},
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;
}
}
},
observe(aSubject, aTopic, aData) {
if (aTopic == "browser-set-toolbar-visibility") {
let [toolbar, visibility] = JSON.parse(aData);
CustomizableUI.setToolbarVisibility(toolbar, visibility == "true");
}
},
};
Object.freeze(CustomizableUIInternal);
export var CustomizableUI = {
/**
* Constant reference to the ID of the navigation toolbar.
*/
AREA_NAVBAR: "nav-bar",
/**
* Constant reference to the ID of the menubar's toolbar.
*/
AREA_MENUBAR: "toolbar-menubar",
/**
* Constant reference to the ID of the tabstrip toolbar.
*/
AREA_TABSTRIP: "TabsToolbar",
/**
* Constant reference to the ID of the bookmarks toolbar.
*/
AREA_BOOKMARKS: "PersonalToolbar",
/**
* Constant reference to the ID of the non-dymanic (fixed) list in the overflow panel.
*/
AREA_FIXED_OVERFLOW_PANEL: "widget-overflow-fixed-list",
/**
* Constant 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;
}
},
},
/**
* Add a listener object that will get fired for various events regarding
* customization.
*
* @param 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. The following event handlers are supported:
* - onWidgetAdded(aWidgetId, aArea, aPosition)
* Fired when a widget is added to an area. aWidgetId is the widget that
* was added, aArea the area it was added to, and aPosition the position
* in which it was added.
* - onWidgetMoved(aWidgetId, aArea, aOldPosition, aNewPosition)
* Fired when a widget is moved within its area. aWidgetId is the widget
* that was moved, aArea the area it was moved in, aOldPosition its old
* position, and aNewPosition its new position.
* - onWidgetRemoved(aWidgetId, aArea)
* Fired when a widget is removed from its area. aWidgetId is the widget
* that was removed, aArea the area it was removed from.
*
* - onWidgetBeforeDOMChange(aNode, aNextNode, aContainer, aIsRemoval)
* Fired *before* a widget's DOM node is acted upon by CustomizableUI
* (to add, move or remove it). aNode is the DOM node changed, aNextNode
* the DOM node (if any) before which a widget will be inserted,
* aContainer the *actual* DOM container (could be an overflow panel in
* case of an overflowable toolbar), and aWasRemoval is true iff the
* action about to happen is the removal of the DOM node.
* - onWidgetAfterDOMChange(aNode, aNextNode, aContainer, aWasRemoval)
* Like onWidgetBeforeDOMChange, but fired after the change to the DOM
* node of the widget.
*
* - onWidgetReset(aNode, aContainer)
* Fired after a reset to default placements moves a widget's node to a
* different location. aNode is the widget's node, aContainer is the
* area it was moved into (NB: it might already have been there and been
* moved to a different position!)
* - onWidgetUndoMove(aNode, aContainer)
* Fired after undoing a reset to default placements moves a widget's
* node to a different location. aNode is the widget's node, aContainer
* is the area it was moved into (NB: it might already have been there
* and been moved to a different position!)
* - onAreaReset(aArea, aContainer)
* Fired after a reset to default placements is complete on an area's
* DOM node. Note that this is fired for each DOM node. aArea is the area
* that was reset, aContainer the DOM node that was reset.
*
* - onWidgetCreated(aWidgetId)
* 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.
* - onWidgetAfterCreation(aWidgetId, aArea)
* 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.
* - onWidgetDestroyed(aWidgetId)
* Fired when widgets are destroyed. aWidgetId is the widget that is
* being destroyed. Only fired for API-based widgets.
* - onWidgetInstanceRemoved(aWidgetId, aDocument)
* Fired when a window is unloaded and a widget's instance is destroyed
* because of this. Only fired for API-based widgets.
*
* - onWidgetDrag(aWidgetId, aArea)
* Fired both when and after customize mode drag handling system tries
* to determine the width and height of widget aWidgetId when dragged to a
* different area. aArea will be the area the item is dragged to, or
* undefined after the measurements have been done and the node has been
* moved back to its 'regular' area.
*
* - onCustomizeStart(aWindow)
* Fired when opening customize mode in aWindow.
* - onCustomizeEnd(aWindow)
* Fired when exiting customize mode in aWindow.
*
* - onWidgetOverflow(aNode, aContainer)
* Fired when a widget's DOM node is overflowing its container, a toolbar,
* and will be displayed in the overflow panel.
* - onWidgetUnderflow(aNode, aContainer)
* Fired when a widget's DOM node is *not* overflowing its container, a
* toolbar, anymore.
* - onWindowOpened(aWindow)
* Fired when a window has been opened that is managed by CustomizableUI,
* once all of the prerequisite setup has been done.
* - onWindowClosed(aWindow)
* Fired when a window that has been managed by CustomizableUI has been
* closed.
* - onAreaNodeRegistered(aArea, aContainer)
* 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.
* - onAreaNodeUnregistered(aArea, aContainer, aReason)
* 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.
*/
addListener(aListener) {
CustomizableUIInternal.addListener(aListener);
},
/**
* Remove a listener added with addListener
* @param aListener the listener object to remove
*/
removeListener(aListener) {
CustomizableUIInternal.removeListener(aListener);
},
/**
* Register a customizable area with CustomizableUI.
* @param aName the name of the area to register. Can only contain
* alphanumeric characters, dashes (-) and underscores (_).
* @param aProps the properties of the area. The following properties are
* recognized:
* - type: the type of area. Either TYPE_TOOLBAR (default) or
* TYPE_PANEL;
* - anchor: for a menu panel or overflowable toolbar, the
* anchoring node for the panel.
* - overflowable: set to true if your toolbar is overflowable.
* This requires an anchor, and only has an
* effect for toolbars.
* - defaultPlacements: an array of widget IDs making up the
* default contents of the area
* - defaultCollapsed: (INTERNAL ONLY) applies if the type is TYPE_TOOLBAR, specifies
* if toolbar is collapsed by default (default 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.
*/
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 aPanelContents the panel contents DOM node being registered.
* @param aArea 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 aName the name of the area to unregister
* @param aDestroyPlacements whether to destroy the placements information
* for the area, too.
*/
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 aWidgetId the ID of the widget to add
* @param aArea the ID of the area to add the widget to
* @param aPosition the position at which to add the widget. If you do not
* pass a position, the widget will be added to the end
* of the area.
*/
addWidgetToArea(aWidgetId, aArea, aPosition) {
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 aWidgetId the ID of the widget to remove
*/
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 aWidgetId the ID of the widget to move
* @param 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.
*
* @param aWidgetId the ID of the widget that was just created
* @param aWindow the window in which you want to ensure it was added.
*
* 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.
*/
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 aForceDirty force CustomizableUI to flush to the prefs file when
* all batch updates have finished.
*/
endBatchUpdate(aForceDirty) {
CustomizableUIInternal.endBatchUpdate(aForceDirty);
},
/**
* Create a widget.
*
* To create a widget, you should pass an object with its desired
* properties. The following properties are supported:
*
* - id: the ID of the widget (required).
* - type: a string indicating the type of widget. Possible 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.
* - viewId: Only useful for views and button-and-view widgets (and
* required there): the id of the <panelview> 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.
* - onBuild(aDoc): Only useful for custom widgets (and required there); a
* function that will be invoked with the document in which
* to build a widget. Should return the DOM node that has
* been constructed.
* - onBeforeCreated(aDoc): Attached to all non-custom widgets; a function
* that will be invoked before the widget gets a DOM node
* constructed, 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.
* - onCreated(aNode): Attached to all widgets; a function that will be invoked
* whenever the widget has a DOM node constructed, passing the
* constructed node as an argument.
* - onDestroyed(aDoc): Attached to all non-custom widgets; 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.
* - onBeforeCommand(aEvt, aNode): 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.
* - onCommand(aEvt): Useful for custom, button and button-and-view widgets; a
* function that will be invoked when the user activates
* the button. A custom widget with a view should
* return "view" or "command" to continue processing
* the command per the needs of the widget.
* - onClick(aEvt): Attached to all widgets; a function that will be invoked
* when the user clicks the widget.
* - onViewShowing(aEvt): Only useful for views and button-and-view widgets; 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.
* - onViewHiding(aEvt): Only useful for views; a function that will be
* invoked when a user hides your view.
* - l10nId: fluent string identifier to use for localizing attributes
* on the widget. If present, preferred over the
* label/tooltiptext.
* - tooltiptext: string to use for the tooltip of the widget
* - label: string to use for the label of the widget
* - localized: If true, or undefined, attempt to retrieve the
* widget's string properties from the customizable
* widgets string bundle.
* - removable: whether the widget is removable (optional, default: true)
* NB: if you specify false here, you must provide a
* defaultArea, too.
* - overflows: whether widget can overflow when in an overflowable
* toolbar (optional, default: true)
* - defaultArea: default area to add the widget to
* (optional, default: none; required if non-removable)
* - shortcutId: id of an element that has a shortcut for this widget
* (optional, default: null). 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.
* - showInPrivateBrowsing: whether to show the widget in private browsing
* mode (optional, default: true)
* - tabSpecific: If true, closes the panel if the tab changes.
* - locationSpecific: If true, closes the panel if the location changes.
* This is similar to tabSpecific, but also if the location
* changes in the same tab, we may want to close the panel.
* - webExtension: Set to true if this widget is being created on behalf of an
* extension.
*
* @param aProperties the specifications for the widget.
* @return a wrapper around the created widget (see getWidget)
*/
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 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;
*
* 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 aWidgetId the ID of the widget whose information you need
* @return a wrapper around the widget as described above, or null if the
* widget is known not to exist (anymore). NB: 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 aWindowPalette the palette (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).
*
* @return 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.
*
* @param aArea the ID of the area whose placements you want to obtain.
* @return an array containing the widget IDs that are in the area.
*
* NB: will throw if called too early (before placements have been fetched)
* or if the area is not currently known to CustomizableUI.
*/
getWidgetIdsInArea(aArea) {
if (!gAreas.has(aArea)) {
throw new Error("Unknown customization area: " + aArea);
}
if (!gPlacements.has(aArea)) {
throw new Error("Area 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 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 aArea the ID of the area whose widgets you want to obtain.
* @return 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 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 aArea the ID of the area whose type you want to know
* @return TYPE_TOOLBAR or 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 aArea the ID of the area whose default-collapsed state you want to know.
* @return `true` or `false` depending on the area, null if the area is unknown,
* or 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 aArea the ID of the area whose customize target you want to have
* @param aWindow the window where you want to fetch the DOM node.
* @return 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.
* @return 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 aToolbarId the ID of the toolbar to remove
*/
removeExtraToolbar(aToolbarId) {
CustomizableUIInternal.removeExtraToolbar(aToolbarId);
},
/**
* Can the last Restore Defaults operation be undone.
*
* @return A boolean stating whether an undo of the
* Restore Defaults can be performed.
*/
get canUndoReset() {
return (
gUIStateBeforeReset.uiCustomizationState != null ||
gUIStateBeforeReset.drawInTitlebar != null ||
gUIStateBeforeReset.currentTheme != null ||
gUIStateBeforeReset.autoTouchMode != null ||
gUIStateBeforeReset.uiDensity != null
);
},
/**
* Get the placement of a widget. This is by far the best way to obtain
* information about what the state of your widget is. The internals of
* this call are cheap (no DOM necessary) and you will know where the user
* has put your widget.
*
* @param aWidgetId the ID of the widget whose placement you want to know
* @return
* {
* area: "somearea", // The ID of the area where the widget is placed
* position: 42 // the index in the placements array corresponding to
* // your widget.
* }
*
* OR
*
* null // if the widget is not placed anywhere (ie in the palette)
*/
getPlacementOfWidget(aWidgetId, aOnlyRegistered = true, aDeadAreas = false) {
return 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 aWidgetId a widget ID or DOM node to check
* @return true if the widget can be removed from its area,
* false otherwise.
*/
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 aWidgetId the widget ID or DOM node you want to move somewhere
* @param aArea the area ID you want to move it to. This can also be
* AREA_NO_AREA to see if the widget can move to the
* customization palette, whether it's removable or not.
* @return true if this is possible, false if it is not. 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 aToolbarId the toolbar whose visibility should be adjusted
* @param aIsVisible whether the toolbar should be 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.
* @return {Set<string>}
*/
getCollapsedToolbarIds(window) {
return CustomizableUIInternal.getCollapsedToolbarIds(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 aWidget the object whose property we should use to fetch a
* localizable string;
* @param aProp the property on the object to use for the fetching;
* @param aFormatArgs (optional) any extra arguments to use for a formatted
* string;
* @param aDef (optional) the default to return if we don't find the
* string in the stringbundle;
*
* @return 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 aShortcutNode the XUL node where the shortcut will be derived from;
* @param aTargetNode (optional) 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 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 aWidgetId the widget ID to check.
* @return 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 aWidgetId the widget ID to check.
* @return true if the widget was provided by an extension, false otherwise.
*/
isWebExtensionWidget(aWidgetId) {
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
* consumers.
*
* @param aPanel the panel to which listeners should be attached.
*/
addPanelCloseListeners(aPanel) {
CustomizableUIInternal.addPanelCloseListeners(aPanel);
},
/**
* Remove close listeners that have been added to a panel with
* addPanelCloseListeners. For use from the menu panel and overflowable
* toolbar implementations, unlikely to be useful for consumers.
*
* @param aPanel the panel from which listeners should be removed.
*/
removePanelCloseListeners(aPanel) {
CustomizableUIInternal.removePanelCloseListeners(aPanel);
},
/**
* Notify listeners a widget is about to be dragged to an area. For use from
* Customize Mode only, do not use otherwise.
*
* @param aWidgetId the ID of the widget that is being dragged to an area.
* @param 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 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 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 aEvent the name of the event to send.
* @param aDetails optional, the details of the event.
* @param aWindow optional, the window in which to send the event.
*/
dispatchToolboxEvent(aEvent, aDetails = {}, aWindow = null) {
CustomizableUIInternal.dispatchToolboxEvent(aEvent, aDetails, aWindow);
},
/**
* Check whether an area is overflowable.
*
* @param aAreaId the ID of an area to check for overflowable-ness
* @return 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 aElement the DOM node whose place we need to check
* @return "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 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 aId the type of special widget (spring, spacer or separator)
* @param aDocument the document in which to create it.
*/
createSpecialWidget(aId, aDocument) {
return CustomizableUIInternal.createSpecialWidget(aId, aDocument);
},
/**
* Fills a submenu with menu items.
* @param aMenuItems the menu items to display.
* @param aSubview the subview to fill.
*/
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.defaultView.MouseEvent(event.type, 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 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);
},
getCustomizationTarget(aElement) {
return CustomizableUIInternal.getCustomizationTarget(aElement);
},
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;
},
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",
"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.buildWidget(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.findWidgetInWindow(
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 <xul:toolbar> 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 <xul:toolbar> that is overflowable.
*
* @type {Element}
*/
#toolbar = null;
/**
* A reference to the part of the <xul:toolbar> 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<string, number>}
*/
#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 <xul:panel> 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 <xul:toolbar> 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 <xul:panel> 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 <xul:toolbar> 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");
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 {undefined} 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();
}
/**
* Returns a Promise that resolves to a an object that describes the state
* that this OverflowableToolbar is currently in.
*
* @returns {Promise}
* @resolves {Object}
* An object with the following properties:
*
* isOverflowing: {boolean} True if at least one toolbar item has overflowed
* into an overflow panel.
* targetContentWidth: {number} The total width of the items within the
* customization target area of the toolbar.
* totalAvailWidth: {number} The maximum width items in the toolbar may
* occupy before causing an overflow.
*/
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 {undefined} 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 <xul:panel> 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 {undefined} 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, <select> popups, etc.
return;
}
this.#defaultListButton.open = false;
this.#defaultListPanel.removeEventListener("dragover", this);
this.#defaultListPanel.removeEventListener("dragend", this);
let doc = aEvent.target.ownerDocument;
doc.defaultView.updateEditUIVisibility();
let contextMenuId = this.#defaultListPanel.getAttribute("context");
if (contextMenuId) {
let contextMenu = doc.getElementById(contextMenuId);
contextMenu.removeEventListener("command", this, {
capture: true,
mozSystemGroup: true,
});
}
}
/**
* Handles a resize event fired on the window hosting this
* OverflowableToolbar.
*
* @param {UIEvent} aEvent the resize event.
*/
#onResize(aEvent) {
// Ignore bubbled-up resize events.
if (aEvent.target != aEvent.currentTarget) {
return;
}
this.#checkOverflow();
}
/**
* CustomizableUI listener methods start here.
*/
onWidgetBeforeDOMChange(aNode, aNextNode, aContainer) {
// This listener method is used to handle the case where a widget is
// moved or removed from an area via the CustomizableUI API while
// overflowed. It reorganizes the internal state of this OverflowableToolbar
// to handle that change.
if (!this.#enabled || !this.#isOverflowList(aContainer)) {
return;
}
// When we (re)move an item, update all the items that come after it in the list
// with the minsize *of the item before the to-be-removed node*. This way, we
// ensure that we try to move items back as soon as that's possible.
let updatedMinSize;
if (aNode.previousElementSibling) {
updatedMinSize = this.#overflowedInfo.get(
aNode.previousElementSibling.id
);
} else {
// Force (these) items to try to flow back into the bar:
updatedMinSize = 1;
}
let nextItem = aNode.nextElementSibling;
while (nextItem) {
this.#overflowedInfo.set(nextItem.id, updatedMinSize);
nextItem = nextItem.nextElementSibling;
}
}
onWidgetAfterDOMChange(aNode, aNextNode, aContainer) {
// This listener method is used to handle the case where a widget is
// moved or removed from an area via the CustomizableUI API while
// overflowed. It updates the DOM in the event that the movement or removal
// causes overflow or underflow of the toolbar.
if (
!this.#enabled ||
(aContainer != this.#target && !this.#isOverflowList(aContainer))
) {
return;
}
let nowOverflowed = this.#isOverflowList(aNode.parentNode);
let wasOverflowed = this.#overflowedInfo.has(aNode.id);
// If this wasn't overflowed before...
if (!wasOverflowed) {
// ... but it is now, then we added to one of the overflow panels.
if (nowOverflowed) {
// We could be the first item in the overflow panel if we're being inserted
// before the previous first item in it. We can't assume the minimum
// size is the same (because the other item might be much wider), so if
// there is no previous item, just allow this item to be put back in the
// toolbar immediately by specifying a very low minimum size.
let sourceOfMinSize = aNode.previousElementSibling;
let minSize = sourceOfMinSize
? this.#overflowedInfo.get(sourceOfMinSize.id)
: 1;
this.#overflowedInfo.set(aNode.id, minSize);
aNode.setAttribute("cui-anchorid", this.#defaultListButton.id);
aNode.setAttribute("overflowedItem", true);
CustomizableUIInternal.ensureButtonContextMenu(aNode, aContainer, true);
CustomizableUIInternal.notifyListeners(
"onWidgetOverflow",
aNode,
this.#target
);
}
} else if (!nowOverflowed) {
// If it used to be overflowed...
// ... and isn't anymore, let's remove our bookkeeping:
this.#overflowedInfo.delete(aNode.id);
aNode.removeAttribute("cui-anchorid");
aNode.removeAttribute("overflowedItem");
CustomizableUIInternal.ensureButtonContextMenu(aNode, aContainer);
CustomizableUIInternal.notifyListeners(
"onWidgetUnderflow",
aNode,
this.#target
);
let collapsedWidgetIds = Array.from(this.#overflowedInfo.keys());
if (collapsedWidgetIds.every(w => CustomizableUI.isSpecialWidget(w))) {
this.#toolbar.removeAttribute("overflowing");
}
} else if (aNode.previousElementSibling) {
// but if it still is, it must have changed places. Bookkeep:
let prevId = aNode.previousElementSibling.id;
let minSize = this.#overflowedInfo.get(prevId);
this.#overflowedInfo.set(aNode.id, minSize);
}
// We might overflow now if an item was added, or we may be able to move
// stuff back into the toolbar if an item was removed.
this.#checkOverflow();
}
/**
* @returns {Boolean} whether the given node is in the overflow list.
*/
isInOverflowList(node) {
return node.parentNode == this.#defaultList;
}
/**
* nsIObserver implementation starts here.
*/
observe(aSubject, aTopic) {
// This nsIObserver method allows us to defer initialization until after
// this window has finished painting and starting up.
if (
aTopic == "browser-delayed-startup-finished" &&
aSubject == this.#toolbar.ownerGlobal
) {
Services.obs.removeObserver(this, "browser-delayed-startup-finished");
this.init();
}
}
/**
* nsIDOMEventListener implementation starts here.
*/
handleEvent(aEvent) {
switch (aEvent.type) {
case "aftercustomization": {
this.#enable();
break;
}
case "mousedown": {
if (aEvent.button != 0) {
break;
}
if (aEvent.target == this.#defaultListButton) {
this.#onClickDefaultListButton(aEvent);
} else {
lazy.PanelMultiView.hidePopup(this.#defaultListPanel);
}
break;
}
case "keypress": {
if (
aEvent.target == this.#defaultListButton &&
(aEvent.key == " " || aEvent.key == "Enter")
) {
this.#onClickDefaultListButton(aEvent);
}
break;
}
case "customizationstarting": {
this.#disable();
break;
}
case "dragover": {
if (this.#enabled) {
this.#showWithTimeout();
}
break;
}
case "dragend": {
lazy.PanelMultiView.hidePopup(this.#defaultListPanel);
break;
}
case "popuphiding": {
this.#onPanelHiding(aEvent);
break;
}
case "resize": {
this.#onResize(aEvent);
break;
}
}
}
}
CustomizableUIInternal.initialize();