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"
);
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 kPrefExtraDragSpace = "browser.tabs.extraDragSpace";
const kPrefUIDensity = "browser.uidensity";
const kPrefAutoTouchMode = "browser.touchmode.auto";
const kPrefAutoHideDownloadsButton = "browser.download.autohideButton";
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 = 16;
/**
* 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();
// XXXunf Temporary. Need a nice way to abstract functions to build widgets
// of these types.
var gSupportedWidgetTypes = new Set(["button", "view", "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,
extraDragSpace: 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._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",
"home-button",
"spring",
"urlbar-container",
"spring",
"downloads-button",
"library-button",
"sidebar-button",
"fxa-toolbar-menu-button",
];
if (AppConstants.MOZ_DEV_EDITION) {
navbarPlacements.splice(7, 0, "developer-button");
}
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: true,
},
true
);
SearchWidgetTracker.init();
},
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");
}
}
},
/**
* _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];
}
}
return [container, null];
},
insertWidgetBefore(aNode, aNextNode, aContainer, aArea) {
this.notifyListeners(
"onWidgetBeforeDOMChange",
aNode,
aNextNode,
aContainer
);
this.setLocationAttributes(aNode, aArea);
aContainer.insertBefore(aNode, aNextNode);
this.notifyListeners(
"onWidgetAfterDOMChange",
aNode,
aNextNode,
aContainer
);
},
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) {
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 &&
PrivateBrowsingUtils.isWindowPrivate(aDocument.defaultView)
) {
return null;
}
log.debug("Building " + aWidget.id + " of type " + aWidget.type);
let node;
if (aWidget.type == "custom") {
if (aWidget.onBuild) {
node = aWidget.onBuild(aDocument);
}
if (!node || !(node instanceof aDocument.defaultView.XULElement)) {
log.error(
"Custom widget with id " +
aWidget.id +
" does not return a valid node"
);
}
} else {
if (aWidget.onBeforeCreated) {
aWidget.onBeforeCreated(aDocument);
}
node = aDocument.createXULElement("toolbarbutton");
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);
}
node.setAttribute("label", this.getLocalizedProperty(aWidget, "label"));
let additionalTooltipArguments = [];
if (aWidget.shortcutId) {
let keyEl = aDocument.getElementById(aWidget.shortcutId);
if (keyEl) {
additionalTooltipArguments.push(
ShortcutUtils.prettifyShortcut(keyEl)
);
} else {
log.error(
"Key element with id '" +
aWidget.shortcutId +
"' for widget '" +
aWidget.id +
"' not found!"
);
}
}
let tooltip = this.getLocalizedProperty(
aWidget,
"tooltiptext",
additionalTooltipArguments
);
if (tooltip) {
node.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);
let nodeClasses = ["toolbarbutton-1", "chromeclass-toolbar-additional"];
// If the widget has a view, and has view showing / hiding listeners,
// hook those up to this widget.
if (aWidget.type == "view") {
log.debug(
"Widget " +
aWidget.id +
" has a view. Auto-registering event handlers."
);
if (aWidget.source == CustomizableUI.SOURCE_BUILTIN) {
nodeClasses.push("subviewbutton-nav");
}
let keyPressHandler = this.handleWidgetKeyPress.bind(
this,
aWidget,
node
);
node.addEventListener("keypress", keyPressHandler);
}
node.setAttribute("class", nodeClasses.join(" "));
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;
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 gWidgetsBundle.formatStringFromName(name, aFormatArgs) || def;
}
return 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))) {
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 = ShortcutUtils.findShortcut(
document.getElementById(commandId)
);
}
}
if (!shortcut) {
return;
}
aTargetNode.setAttribute(
"shortcut",
ShortcutUtils.prettifyShortcut(shortcut)
);
},
handleWidgetCommand(aWidget, aNode, aEvent) {
// Note that aEvent can be a keypress event for widgets of type "view".
log.debug("handleWidgetCommand");
if (aWidget.onBeforeCommand) {
try {
aWidget.onBeforeCommand.call(null, aEvent);
} catch (e) {
log.error(e);
}
}
if (aWidget.type == "button") {
if (aWidget.onCommand) {
try {
aWidget.onCommand.call(null, aEvent);
} catch (e) {
log.error(e);
}
} else {
// XXXunf Need to think this through more, and formalize.
Services.obs.notifyObservers(
aNode,
"customizedui-widget-command",
aWidget.id
);
}
} else if (aWidget.type == "view") {
let ownerWindow = aNode.ownerGlobal;
let area = this.getPlacementOfWidget(aNode.id).area;
let areaType = CustomizableUI.getAreaType(area);
let anchor = aNode;
if (areaType != CustomizableUI.TYPE_MENU_PANEL) {
let wrapper = this.wrapWidget(aWidget.id).forWindow(ownerWindow);
let hasMultiView = !!aNode.closest("panelmultiview");
if (wrapper && !hasMultiView && wrapper.anchor) {
this.hidePanelForNode(aNode);
anchor = wrapper.anchor;
}
}
ownerWindow.PanelUI.showSubView(aWidget.viewId, anchor, aEvent);
}
},
handleWidgetClick(aWidget, aNode, aEvent) {
log.debug("handleWidgetClick");
if (aWidget.onClick) {
try {
aWidget.onClick.call(null, aEvent);
} catch (e) {
Cu.reportError(e);
}
} else {
// XXXunf Need to think this through more, and formalize.
Services.obs.notifyObservers(
aNode,
"customizedui-widget-click",
aWidget.id
);
}
},
handleWidgetKeyPress(aWidget, aNode, aEvent) {
if (aEvent.key != " " && aEvent.key != "Enter") {
return;
}
aEvent.stopPropagation();
aEvent.preventDefault();
this.handleWidgetCommand(aWidget, aNode, aEvent);
},
_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.
*/
_isOnInteractiveElement(aEvent) {
function getMenuPopupForDescendant(aNode) {
let lastPopup = null;
while (
aNode &&
aNode.parentNode &&
aNode.parentNode.localName.startsWith("menu")
) {
lastPopup = aNode.localName == "menupopup" ? aNode : lastPopup;
aNode = aNode.parentNode;
}
return lastPopup;
}
let target = aEvent.originalTarget;
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;
}
// We keep track of:
// whether we're in an input container (text field)
let inInput = false;
// whether we're in a popup/context menu
let inMenu = false;
// whether we're in a toolbarbutton/toolbaritem