Source code

Revision control

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/. */
"use strict";
var EXPORTED_SYMBOLS = ["CustomizableUI"];
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { XPCOMUtils } = ChromeUtils.import(
);
const { AppConstants } = ChromeUtils.import(
);
XPCOMUtils.defineLazyModuleGetters(this, {
AddonManagerPrivate: "resource://gre/modules/AddonManager.jsm",
});
XPCOMUtils.defineLazyGetter(this, "gWidgetsBundle", function() {
const kUrl =
return Services.strings.createBundle(kUrl);
});
XPCOMUtils.defineLazyServiceGetter(
this,
"gELS",
"@mozilla.org/eventlistenerservice;1",
"nsIEventListenerService"
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"gBookmarksToolbar2h2020",
"browser.toolbars.bookmarks.2h2020",
false
);
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.drawInTitlebar";
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 = 17;
/**
* 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.jsm 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(
this,
"gDebuggingEnabled",
kPrefCustomizationDebug,
false,
(pref, oldVal, newVal) => {
if (typeof log != "undefined") {
log.maxLogLevel = newVal ? "all" : "log";
}
}
);
XPCOMUtils.defineLazyGetter(this, "log", () => {
let scope = {};
ChromeUtils.import("resource://gre/modules/Console.jsm", scope);
let consoleOptions = {
maxLogLevel: gDebuggingEnabled ? "all" : "log",
prefix: "CustomizableUI",
};
return new scope.ConsoleAPI(consoleOptions);
});
var CustomizableUIInternal = {
initialize() {
log.debug("Initializing");
AddonManagerPrivate.databaseReady.then(async () => {
AddonManager.addAddonListener(this);
let addons = await 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_MENU_PANEL,
defaultPlacements: [],
anchor: "nav-bar-overflow-button",
},
true
);
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",
].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: [
"tabbrowser-tabs",
"new-tab-button",
"alltabs-button",
],
defaultCollapsed: null,
},
true
);
this.registerArea(
CustomizableUI.AREA_BOOKMARKS,
{
type: CustomizableUI.TYPE_TOOLBAR,
defaultPlacements: ["personal-bookmarks"],
defaultCollapsed: gBookmarksToolbar2h2020 ? "newtab" : true,
},
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,
]);
},
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 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);
}
}
}
}
if (
currentVersion < 7 &&
gSavedState.placements &&
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 &&
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",
"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 &&
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 && gSavedState.placements) {
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 && gSavedState.placements) {
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 && gSavedState.placements) {
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 && gSavedState.placements) {
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 && gSavedState.placements) {
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 && gSavedState.placements) {
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 && gSavedState.placements) {
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");
}
}
},
_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 = 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_MENU_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.has("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.
if (gDirtyAreaCache.has(area)) {
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 = PrivateBrowsingUtils.isWindowPrivate(window);
let container = this.getCustomizationTarget(aAreaNode);
let areaIsPanel =
gAreas.get(aArea).get("type") == CustomizableUI.TYPE_MENU_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) {
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;
}
}
if (palette && !this.isSpecialWidget(node.id)) {
palette.appendChild(node);
this.removeLocationAttributes(node);
} else {
container.removeChild(node);
}
} else {
node.setAttribute("removable", false);
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) {
gELS.addSystemEventListener(aPanel, "click", this, false);
gELS.addSystemEventListener(aPanel, "keypress", this, false);
let win = aPanel.ownerGlobal;
if (!gPanelsForWindow.has(win)) {
gPanelsForWindow.set(win, new Set());
}
gPanelsForWindow.get(win).add(this._getPanelForNode(aPanel));
},
removePanelCloseListeners(aPanel) {
gELS.removeSystemEventListener(aPanel, "click", this, false);
gELS.removeSystemEventListener(aPanel, "keypress", this, false);
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 =
forcePanel || "menu-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)) {
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)];
}
log.debug("Searching for " + aWidgetId + " in toolbox.");
let node = this.findWidgetInWindow(aWidgetId, aWindow);
if (node) {
return [CustomizableUI.PROVIDER_XUL, node];
}
log.debug("No node for " + aWidgetId + " found.");
return [null, null];
},
registerMenuPanel(aPanelContents, aArea) {
if (gBuildAreas.has(aArea) && gBuildAreas.get(aArea).has(aPanelContents)) {
return;
}
aPanelContents._customizationTarget = aPanelContents;
this.addPanelCloseListeners(this._getPanelForNode(aPanelContents));
let placements = gPlacements.get(aArea);
this.buildArea(aArea, placements, aPanelContents);
this.notifyListeners("onAreaNodeRegistered", aArea, aPanelContents);
for (let child of aPanelContents.children) {
if (child.localName != "toolbarbutton") {
if (child.localName == "toolbaritem") {
this.ensureButtonContextMenu(child, aPanelContents, true);
}
continue;
}
this.ensureButtonContextMenu(child, aPanelContents, true);
}
this.registerBuildArea(aArea, aPanelContents);
},
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 &&
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)) {
log.info(
"Widget " + aWidgetId + " not found, unable to remove from " + aArea
);
continue;
}
this.notifyListeners(
"onWidgetBeforeDOMChange",
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);
}
this.notifyListeners(
"onWidgetAfterDOMChange",
widgetNode,
null,
container,
true
);
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(aWindow) {
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.has("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) {
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 &&
PrivateBrowsingUtils.isWindowPrivate(window)
) {
return;
}
let [, widgetNode] = this.getWidgetNode(aWidgetId, window);
if (!widgetNode) {
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];
}
}