mirror of https://github.com/roytam1/UXP
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
4387 lines
156 KiB
4387 lines
156 KiB
/* 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"; |
|
|
|
this.EXPORTED_SYMBOLS = ["CustomizableUI"]; |
|
|
|
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; |
|
|
|
Cu.import("resource://gre/modules/Services.jsm"); |
|
Cu.import("resource://gre/modules/XPCOMUtils.jsm"); |
|
Cu.import("resource://gre/modules/AppConstants.jsm"); |
|
XPCOMUtils.defineLazyModuleGetter(this, "PanelWideWidgetTracker", |
|
"resource:///modules/PanelWideWidgetTracker.jsm"); |
|
XPCOMUtils.defineLazyModuleGetter(this, "CustomizableWidgets", |
|
"resource:///modules/CustomizableWidgets.jsm"); |
|
XPCOMUtils.defineLazyModuleGetter(this, "DeferredTask", |
|
"resource://gre/modules/DeferredTask.jsm"); |
|
XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", |
|
"resource://gre/modules/PrivateBrowsingUtils.jsm"); |
|
XPCOMUtils.defineLazyGetter(this, "gWidgetsBundle", function() { |
|
const kUrl = "chrome://browser/locale/customizableui/customizableWidgets.properties"; |
|
return Services.strings.createBundle(kUrl); |
|
}); |
|
XPCOMUtils.defineLazyModuleGetter(this, "ShortcutUtils", |
|
"resource://gre/modules/ShortcutUtils.jsm"); |
|
XPCOMUtils.defineLazyServiceGetter(this, "gELS", |
|
"@mozilla.org/eventlistenerservice;1", "nsIEventListenerService"); |
|
XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeManager", |
|
"resource://gre/modules/LightweightThemeManager.jsm"); |
|
|
|
const kNSXUL = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; |
|
|
|
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 kExpectedWindowURL = "chrome://browser/content/browser.xul"; |
|
|
|
/** |
|
* 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 = 6; |
|
|
|
/** |
|
* 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 = { |
|
"pocket-button": 6 |
|
}; |
|
|
|
/** |
|
* 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, |
|
currentTheme: null, |
|
}; |
|
|
|
XPCOMUtils.defineLazyGetter(this, "log", () => { |
|
let scope = {}; |
|
Cu.import("resource://gre/modules/Console.jsm", scope); |
|
let debug = Services.prefs.getBoolPref(kPrefCustomizationDebug, false); |
|
let consoleOptions = { |
|
maxLogLevel: debug ? "all" : "log", |
|
prefix: "CustomizableUI", |
|
}; |
|
return new scope.ConsoleAPI(consoleOptions); |
|
}); |
|
|
|
var CustomizableUIInternal = { |
|
initialize: function() { |
|
log.debug("Initializing"); |
|
|
|
this.addListener(this); |
|
this._defineBuiltInWidgets(); |
|
this.loadSavedState(); |
|
this._introduceNewBuiltinWidgets(); |
|
this._markObsoleteBuiltinButtonsSeen(); |
|
|
|
/** |
|
* Please be advised that adding items to the panel by default could |
|
* cause CART talos test regressions. This might happen when the |
|
* number of items in the panel causes the area to become "scrollable" |
|
* during the last phases of the transition. See bug 1230671 for an |
|
* example of this. Be sure that what you're adding really needs to go |
|
* into the panel by default, and if it does, consider swapping |
|
* something out for it. |
|
*/ |
|
let panelPlacements = [ |
|
"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", |
|
]; |
|
|
|
panelPlacements.splice(-1, 0, "developer-button"); |
|
|
|
let showCharacterEncoding = Services.prefs.getComplexValue( |
|
"browser.menu.showCharacterEncoding", |
|
Ci.nsIPrefLocalizedString |
|
).data; |
|
if (showCharacterEncoding == "true") { |
|
panelPlacements.push("characterencoding-button"); |
|
} |
|
|
|
this.registerArea(CustomizableUI.AREA_PANEL, { |
|
anchor: "PanelUI-menu-button", |
|
type: CustomizableUI.TYPE_MENU_PANEL, |
|
defaultPlacements: panelPlacements |
|
}, true); |
|
PanelWideWidgetTracker.init(); |
|
|
|
let navbarPlacements = [ |
|
"urlbar-container", |
|
"search-container", |
|
"bookmarks-menu-button", |
|
"downloads-button", |
|
"home-button", |
|
]; |
|
|
|
// Place this last, when createWidget is called for pocket, it will |
|
// append to the toolbar. |
|
if (Services.prefs.getPrefType("extensions.pocket.enabled") != Services.prefs.PREF_INVALID && |
|
Services.prefs.getBoolPref("extensions.pocket.enabled")) { |
|
navbarPlacements.push("pocket-button"); |
|
} |
|
|
|
this.registerArea(CustomizableUI.AREA_NAVBAR, { |
|
legacy: true, |
|
type: CustomizableUI.TYPE_TOOLBAR, |
|
overflowable: true, |
|
defaultPlacements: navbarPlacements, |
|
defaultCollapsed: false, |
|
}, true); |
|
|
|
if (AppConstants.platform != "macosx") { |
|
this.registerArea(CustomizableUI.AREA_MENUBAR, { |
|
legacy: true, |
|
type: CustomizableUI.TYPE_TOOLBAR, |
|
defaultPlacements: [ |
|
"menubar-items", |
|
], |
|
get defaultCollapsed() { |
|
if (AppConstants.MENUBAR_CAN_AUTOHIDE) { |
|
if (AppConstants.platform == "linux") { |
|
return true; |
|
} |
|
// This is duplicated logic from /browser/base/jar.mn |
|
// for win6BrowserOverlay.xul. |
|
return AppConstants.isPlatformAndVersionAtLeast("win", 6); |
|
} |
|
return false; |
|
} |
|
}, true); |
|
} |
|
|
|
this.registerArea(CustomizableUI.AREA_TABSTRIP, { |
|
legacy: true, |
|
type: CustomizableUI.TYPE_TOOLBAR, |
|
defaultPlacements: [ |
|
"tabbrowser-tabs", |
|
"new-tab-button", |
|
"alltabs-button", |
|
], |
|
defaultCollapsed: null, |
|
}, true); |
|
this.registerArea(CustomizableUI.AREA_BOOKMARKS, { |
|
legacy: true, |
|
type: CustomizableUI.TYPE_TOOLBAR, |
|
defaultPlacements: [ |
|
"personal-bookmarks", |
|
], |
|
defaultCollapsed: true, |
|
}, true); |
|
|
|
this.registerArea(CustomizableUI.AREA_ADDONBAR, { |
|
type: CustomizableUI.TYPE_TOOLBAR, |
|
legacy: true, |
|
defaultPlacements: ["addonbar-closebutton", "status-bar"], |
|
defaultCollapsed: false, |
|
}, true); |
|
}, |
|
|
|
get _builtinToolbars() { |
|
let toolbars = new Set([ |
|
CustomizableUI.AREA_NAVBAR, |
|
CustomizableUI.AREA_BOOKMARKS, |
|
CustomizableUI.AREA_TABSTRIP, |
|
CustomizableUI.AREA_ADDONBAR, |
|
]); |
|
if (AppConstants.platform != "macosx") { |
|
toolbars.add(CustomizableUI.AREA_MENUBAR); |
|
} |
|
return toolbars; |
|
}, |
|
|
|
_defineBuiltInWidgets: function() { |
|
for (let widgetDefinition of CustomizableWidgets) { |
|
this.createBuiltinWidget(widgetDefinition); |
|
} |
|
}, |
|
|
|
_introduceNewBuiltinWidgets: function() { |
|
// 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 < 2) { |
|
// Nuke the old 'loop-call-button' out of orbit. |
|
CustomizableUI.removeWidgetFromArea("loop-call-button"); |
|
} |
|
|
|
if (currentVersion < 4) { |
|
CustomizableUI.removeWidgetFromArea("loop-button-throttled"); |
|
} |
|
}, |
|
|
|
/** |
|
* _markObsoleteBuiltinButtonsSeen |
|
* when upgrading, ensure obsoleted buttons are in seen state. |
|
*/ |
|
_markObsoleteBuiltinButtonsSeen: function() { |
|
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: function(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.indexOf(widget.id) !== -1) { |
|
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(); |
|
}, |
|
|
|
wrapWidget: function(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. |
|
let wrapper = new XULWidgetGroupWrapper(aWidgetId); |
|
gGroupWrapperCache.set(aWidgetId, wrapper); |
|
return wrapper; |
|
}, |
|
|
|
registerArea: function(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", "legacy", "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 + "'"); |
|
} |
|
// XXXgijs for special items, we need to make sure they have an appropriate ID |
|
// so we aren't perpetually in a non-default state: |
|
if (key == "defaultPlacements" && Array.isArray(aProperties[key])) { |
|
props.set(key, aProperties[key].map(x => this.isSpecialWidget(x) ? this.ensureSpecialWidgetId(x) : x )); |
|
} else { |
|
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.indexOf(props.get("type")) == -1) { |
|
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("legacy") && !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, existingChildren] of pendingNodes) { |
|
this.registerToolbarNode(pendingNode, existingChildren); |
|
} |
|
gPendingBuildAreas.delete(aName); |
|
} |
|
} |
|
}, |
|
|
|
unregisterArea: function(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, areaNode.customizationTarget, |
|
CustomizableUI.REASON_AREA_UNREGISTERED); |
|
} |
|
} |
|
gBuildAreas.delete(aName); |
|
} finally { |
|
this.endBatchUpdate(true); |
|
} |
|
}, |
|
|
|
registerToolbarNode: function(aToolbar, aExistingChildren) { |
|
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 there's no defaultset attribute and this isn't a legacy extra toolbar, |
|
// we assume that we should wait for registerArea to be called: |
|
if (!aToolbar.hasAttribute("defaultset") && |
|
!aToolbar.hasAttribute("customindex")) { |
|
if (!gPendingBuildAreas.has(area)) { |
|
gPendingBuildAreas.set(area, new Map()); |
|
} |
|
let pendingNodes = gPendingBuildAreas.get(area); |
|
pendingNodes.set(aToolbar, aExistingChildren); |
|
return; |
|
} |
|
let props = {type: CustomizableUI.TYPE_TOOLBAR, legacy: true}; |
|
let defaultsetAttribute = aToolbar.getAttribute("defaultset") || ""; |
|
props.defaultPlacements = defaultsetAttribute.split(',').filter(s => s); |
|
this.registerArea(area, props); |
|
areaProperties = gAreas.get(area); |
|
} |
|
|
|
this.beginBatchUpdate(); |
|
try { |
|
let placements = gPlacements.get(area); |
|
if (!placements && areaProperties.has("legacy")) { |
|
let legacyState = aToolbar.getAttribute("currentset"); |
|
if (legacyState) { |
|
legacyState = legacyState.split(",").filter(s => s); |
|
} |
|
|
|
// Manually restore the state here, so the legacy state can be converted. |
|
this.restoreStateForArea(area, legacyState); |
|
placements = gPlacements.get(area); |
|
} |
|
|
|
// Check that the current children and the current placements match. If |
|
// not, mark it as dirty: |
|
if (aExistingChildren.length != placements.length || |
|
aExistingChildren.every((id, i) => id == placements[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); |
|
} |
|
this.notifyListeners("onAreaNodeRegistered", area, aToolbar.customizationTarget); |
|
aToolbar.setAttribute("currentset", placements.join(",")); |
|
} finally { |
|
this.endBatchUpdate(); |
|
} |
|
}, |
|
|
|
buildArea: function(aArea, aPlacements, aAreaNode) { |
|
let document = aAreaNode.ownerDocument; |
|
let window = document.defaultView; |
|
let inPrivateWindow = PrivateBrowsingUtils.isWindowPrivate(window); |
|
let container = aAreaNode.customizationTarget; |
|
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.firstChild; |
|
let placementsToRemove = new Set(); |
|
for (let id of aPlacements) { |
|
while (currentNode && currentNode.getAttribute("skipintoolbarset") == "true") { |
|
currentNode = currentNode.nextSibling; |
|
} |
|
|
|
if (currentNode && currentNode.id == id) { |
|
currentNode = currentNode.nextSibling; |
|
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); |
|
if (node.localName == "toolbarbutton") { |
|
if (areaIsPanel) { |
|
node.setAttribute("wrap", "true"); |
|
} else { |
|
node.removeAttribute("wrap"); |
|
} |
|
} |
|
|
|
// 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 = aAreaNode.toolbox ? aAreaNode.toolbox.palette : null; |
|
let limit = currentNode.previousSibling; |
|
let node = container.lastChild; |
|
while (node && node != limit) { |
|
let previousSibling = node.previousSibling; |
|
// 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, and because such elements are not intended to be widgets |
|
// (eg, titlebar-placeholder elements). |
|
if (node.id && node.getAttribute("skipintoolbarset") != "true") { |
|
if (this.isWidgetRemovable(node)) { |
|
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: function(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: function(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: function(aNode, aAreaNode) { |
|
const kPanelItemContextMenu = "customizationPanelItemContextMenu"; |
|
|
|
let currentContextMenu = aNode.getAttribute("context") || |
|
aNode.getAttribute("contextmenu"); |
|
let place = CustomizableUI.getPlaceForItem(aAreaNode); |
|
let contextMenuForPlace = place == "panel" ? |
|
kPanelItemContextMenu : |
|
null; |
|
if (contextMenuForPlace && !currentContextMenu) { |
|
aNode.setAttribute("context", contextMenuForPlace); |
|
} else if (currentContextMenu == kPanelItemContextMenu && |
|
contextMenuForPlace != kPanelItemContextMenu) { |
|
aNode.removeAttribute("context"); |
|
aNode.removeAttribute("contextmenu"); |
|
} |
|
}, |
|
|
|
getWidgetProvider: function(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: function(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: function(aPanelContents) { |
|
if (gBuildAreas.has(CustomizableUI.AREA_PANEL) && |
|
gBuildAreas.get(CustomizableUI.AREA_PANEL).has(aPanelContents)) { |
|
return; |
|
} |
|
|
|
let document = aPanelContents.ownerDocument; |
|
|
|
aPanelContents.toolbox = document.getElementById("navigator-toolbox"); |
|
aPanelContents.customizationTarget = aPanelContents; |
|
|
|
this.addPanelCloseListeners(this._getPanelForNode(aPanelContents)); |
|
|
|
let placements = gPlacements.get(CustomizableUI.AREA_PANEL); |
|
this.buildArea(CustomizableUI.AREA_PANEL, placements, aPanelContents); |
|
this.notifyListeners("onAreaNodeRegistered", CustomizableUI.AREA_PANEL, aPanelContents); |
|
|
|
for (let child of aPanelContents.children) { |
|
if (child.localName != "toolbarbutton") { |
|
if (child.localName == "toolbaritem") { |
|
this.ensureButtonContextMenu(child, aPanelContents); |
|
} |
|
continue; |
|
} |
|
this.ensureButtonContextMenu(child, aPanelContents); |
|
child.setAttribute("wrap", "true"); |
|
} |
|
|
|
this.registerBuildArea(CustomizableUI.AREA_PANEL, aPanelContents); |
|
}, |
|
|
|
onWidgetAdded: function(aWidgetId, aArea, aPosition) { |
|
this.insertNode(aWidgetId, aArea, aPosition, true); |
|
|
|
if (!gResetting) { |
|
this._clearPreviousUIState(); |
|
} |
|
}, |
|
|
|
onWidgetRemoved: function(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 = areaNode.customizationTarget; |
|
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); |
|
widgetNode.removeAttribute("wrap"); |
|
if (gPalette.has(aWidgetId) || this.isSpecialWidget(aWidgetId)) { |
|
container.removeChild(widgetNode); |
|
} else { |
|
areaNode.toolbox.palette.appendChild(widgetNode); |
|
} |
|
this.notifyListeners("onWidgetAfterDOMChange", widgetNode, null, container, true); |
|
|
|
if (isToolbar) { |
|
areaNode.setAttribute("currentset", gPlacements.get(aArea).join(',')); |
|
} |
|
|
|
let windowCache = gSingleWrapperCache.get(window); |
|
if (windowCache) { |
|
windowCache.delete(aWidgetId); |
|
} |
|
} |
|
if (!gResetting) { |
|
this._clearPreviousUIState(); |
|
} |
|
}, |
|
|
|
onWidgetMoved: function(aWidgetId, aArea, aOldPosition, aNewPosition) { |
|
this.insertNode(aWidgetId, aArea, aNewPosition); |
|
if (!gResetting) { |
|
this._clearPreviousUIState(); |
|
} |
|
}, |
|
|
|
onCustomizeEnd: function(aWindow) { |
|
this._clearPreviousUIState(); |
|
}, |
|
|
|
registerBuildArea: function(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 (aNode.toolbox) { |
|
gBuildWindows.get(window).add(aNode.toolbox); |
|
} |
|
|
|
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: function(aWindow) { |
|
if (!gBuildWindows.has(aWindow)) { |
|
gBuildWindows.set(aWindow, new Set()); |
|
|
|
aWindow.addEventListener("unload", this); |
|
aWindow.addEventListener("command", this, true); |
|
|
|
this.notifyListeners("onWindowOpened", aWindow); |
|
} |
|
}, |
|
|
|
unregisterBuildWindow: function(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, node.customizationTarget, |
|
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 [, areaMap] of gPendingBuildAreas) { |
|
let toDelete = []; |
|
for (let [areaNode, ] of areaMap) { |
|
if (areaNode.ownerDocument == document) { |
|
toDelete.push(areaNode); |
|
} |
|
} |
|
for (let areaNode of toDelete) { |
|
areaMap.delete(areaNode); |
|
} |
|
} |
|
|
|
this.notifyListeners("onWindowClosed", aWindow); |
|
}, |
|
|
|
setLocationAttributes: function(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: function(aNode) { |
|
aNode.removeAttribute("cui-areatype"); |
|
aNode.removeAttribute("cui-anchorid"); |
|
}, |
|
|
|
insertNode: function(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: function(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); |
|
if (widgetNode.localName == "toolbarbutton" && areaId == CustomizableUI.AREA_PANEL) { |
|
widgetNode.setAttribute("wrap", "true"); |
|
} |
|
} |
|
|
|
let [insertionContainer, nextNode] = this.findInsertionPoints(widgetNode, aAreaNode); |
|
this.insertWidgetBefore(widgetNode, nextNode, insertionContainer, areaId); |
|
|
|
if (gAreas.get(areaId).get("type") == CustomizableUI.TYPE_TOOLBAR) { |
|
aAreaNode.setAttribute("currentset", gPlacements.get(areaId).join(',')); |
|
} |
|
}, |
|
|
|
findInsertionPoints: function(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 = aAreaNode.customizationTarget; |
|
let placements = gPlacements.get(areaId); |
|
let nodeIndex = placements.indexOf(aNode.id); |
|
|
|
while (++nodeIndex < placements.length) { |
|
let nextNodeId = placements[nodeIndex]; |
|
let nextNode = container.getElementsByAttribute("id", nextNodeId).item(0); |
|
|
|
if (nextNode) { |
|
return [container, nextNode]; |
|
} |
|
} |
|
|
|
return [container, null]; |
|
}, |
|
|
|
insertWidgetBefore: function(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: function(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: function(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); |
|
}, |
|
|
|
isSpecialWidget: function(aId) { |
|
return (aId.startsWith(kSpecialWidgetPfx) || |
|
aId.startsWith("separator") || |
|
aId.startsWith("spring") || |
|
aId.startsWith("spacer")); |
|
}, |
|
|
|
ensureSpecialWidgetId: function(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: function(aId, aDocument) { |
|
let nodeName = "toolbar" + aId.match(/spring|spacer|separator/)[0]; |
|
let node = aDocument.createElementNS(kNSXUL, nodeName); |
|
node.id = this.ensureSpecialWidgetId(aId); |
|
if (nodeName == "toolbarspring") { |
|
node.flex = 1; |
|
} |
|
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: function(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 && !(parent.customizationTarget || |
|
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 ((parent.customizationTarget == nodeInArea.parentNode && |
|
gBuildWindows.get(aWindow).has(parent.toolbox)) || |
|
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", !parent.customizationTarget); |
|
} |
|
return node; |
|
} |
|
} |
|
} |
|
|
|
let toolboxes = gBuildWindows.get(aWindow); |
|
for (let toolbox of toolboxes) { |
|
if (toolbox.palette) { |
|
// Attempt to locate a node with a matching ID within |
|
// the palette. |
|
let node = toolbox.palette.getElementsByAttribute("id", aId)[0]; |
|
if (node) { |
|
// 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 (!node.hasAttribute("removable")) { |
|
node.setAttribute("removable", true); |
|
} |
|
return node; |
|
} |
|
} |
|
} |
|
return null; |
|
}, |
|
|
|
buildWidget: function(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."); |
|
} |
|
|
|
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.createElementNS(kNSXUL, "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); |
|
} |
|
node.setAttribute("class", "toolbarbutton-1 chromeclass-toolbar-additional"); |
|
|
|
let commandHandler = this.handleWidgetCommand.bind(this, aWidget, node); |
|
node.addEventListener("command", commandHandler, false); |
|
let clickHandler = this.handleWidgetClick.bind(this, aWidget, node); |
|
node.addEventListener("click", clickHandler, false); |
|
|
|
// 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."); |
|
let viewNode = aDocument.getElementById(aWidget.viewId); |
|
|
|
if (viewNode) { |
|
// PanelUI relies on the .PanelUI-subView class to be able to show only |
|
// one sub-view at a time. |
|
viewNode.classList.add("PanelUI-subView"); |
|
|
|
for (let eventName of kSubviewEvents) { |
|
let handler = "on" + eventName; |
|
if (typeof aWidget[handler] == "function") { |
|
viewNode.addEventListener(eventName, aWidget[handler], false); |
|
} |
|
} |
|
|
|
log.debug("Widget " + aWidget.id + " showing and hiding event handlers set."); |
|
} else { |
|
log.error("Could not find the view node with id: " + aWidget.viewId + |
|
", for widget: " + aWidget.id + "."); |
|
} |
|
} |
|
|
|
if (aWidget.onCreated) { |
|
aWidget.onCreated(node); |
|
} |
|
} |
|
|
|
aWidget.instances.set(aDocument, node); |
|
return node; |
|
}, |
|
|
|
getLocalizedProperty: function(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 || ""; |
|
} |
|
try { |
|
if (Array.isArray(aFormatArgs) && aFormatArgs.length) { |
|
return gWidgetsBundle.formatStringFromName(name, aFormatArgs, |
|
aFormatArgs.length) || 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: function(aShortcutNode, aTargetNode) { |
|
if (!aTargetNode) |
|
aTargetNode = aShortcutNode; |
|
let document = aShortcutNode.ownerDocument; |
|
|
|
// Detect if we've already been here before. |
|
if (!aTargetNode || aTargetNode.hasAttribute("shortcut")) |
|
return; |
|
|
|
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: function(aWidget, aNode, aEvent) { |
|
log.debug("handleWidgetCommand"); |
|
|
|
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 anchor = aNode; |
|
if (area != CustomizableUI.AREA_PANEL) { |
|
let wrapper = this.wrapWidget(aWidget.id).forWindow(ownerWindow); |
|
if (wrapper && wrapper.anchor) { |
|
this.hidePanelForNode(aNode); |
|
anchor = wrapper.anchor; |
|
} |
|
} |
|
ownerWindow.PanelUI.showSubView(aWidget.viewId, anchor, area); |
|
} |
|
}, |
|
|
|
handleWidgetClick: function(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); |
|
} |
|
}, |
|
|
|
_getPanelForNode: function(aNode) { |
|
let panel = aNode; |
|
while (panel && panel.localName != "panel") |
|
panel = panel.parentNode; |
|
return 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: function(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 |
|
let inItem = false; |
|
// whether the current menuitem has a valid closemenu attribute |
|
let menuitemCloseMenu = "auto"; |
|
// whether the toolbarbutton/item has a valid closemenu attribute. |
|
let closemenu = "auto"; |
|
|
|
// While keeping track of that, we go from the original target back up, |
|
// to the panel if we have to. We bail as soon as we find an input, |
|
// a toolbarbutton/item, or the panel: |
|
while (true && target) { |
|
// Skip out of iframes etc: |
|
if (target.nodeType == target.DOCUMENT_NODE) { |
|
if (!target.defaultView) { |
|
// Err, we're done. |
|
break; |
|
} |
|
// Cue some voodoo |
|
target = target.defaultView.QueryInterface(Ci.nsIInterfaceRequestor) |
|
.getInterface(Ci.nsIWebNavigation) |
|
.QueryInterface(Ci.nsIDocShell) |
|
.chromeEventHandler; |
|
if (!target) { |
|
break; |
|
} |
|
} |
|
let tagName = target.localName; |
|
inInput = tagName == "input" || tagName == "textbox"; |
|
inItem = tagName == "toolbaritem" || tagName == "toolbarbutton"; |
|
let isMenuItem = tagName == "menuitem"; |
|
inMenu = inMenu || isMenuItem; |
|
if (inItem && target.hasAttribute("closemenu")) { |
|
let closemenuVal = target.getAttribute("closemenu"); |
|
closemenu = (closemenuVal == "single" || closemenuVal == "none") ? |
|
closemenuVal : "auto"; |
|
} |
|
|
|
if (isMenuItem && target.hasAttribute("closemenu")) { |
|
let closemenuVal = target.getAttribute("closemenu"); |
|
menuitemCloseMenu = (closemenuVal == "single" || closemenuVal == "none") ? |
|
closemenuVal : "auto"; |
|
} |
|
// Break out of the loop immediately for disabled items, as we need to |
|
// keep the menu open in that case. |
|
if (target.getAttribute("disabled") == "true") { |
|
return true; |
|
} |
|
|
|
// This isn't in the loop condition because we want to break before |
|
// changing |target| if any of these conditions are true |
|
if (inInput || inItem || target == panel) { |
|
break; |
|
} |
|
// We need specific code for popups: the item on which they were invoked |
|
// isn't necessarily in their parentNode chain: |
|
if (isMenuItem) { |
|
let topmostMenuPopup = getMenuPopupForDescendant(target); |
|
target = (topmostMenuPopup && topmostMenuPopup.triggerNode) || |
|
target.parentNode; |
|
} else { |
|
target = target.parentNode; |
|
} |
|
} |
|
|
|
// If the user clicked a menu item... |
|
if (inMenu) { |
|
// We care if we're in an input also, |
|
// or if the user specified closemenu!="auto": |
|
if (inInput || menuitemCloseMenu != "auto") { |
|
return true; |
|
} |
|
// Otherwise, we're probably fine to close the panel |
|
return false; |
|
} |
|
// If we're not in a menu, and we *are* in a type="menu" toolbarbutton, |
|
// we'll now interact with the menu |
|
if (inItem && target.getAttribute("type") == "menu") { |
|
return true; |
|
} |
|
// If we're not in a menu, and we *are* in a type="menu-button" toolbarbutton, |
|
// it depends whether we're in the dropmarker or the 'real' button: |
|
if (inItem && target.getAttribute("type") == "menu-button") { |
|
// 'real' button (which has a single action): |
|
if (target.getAttribute("anonid") == "button") { |
|
return closemenu != "none"; |
|
} |
|
// otherwise, this is the outer button, and the user will now |
|
// interact with the menu: |
|
return true; |
|
} |
|
return inInput || !inItem; |
|
}, |
|
|
|
hidePanelForNode: function(aNode) { |
|
let panel = this._getPanelForNode(aNode); |
|
if (panel) { |
|
panel.hidePopup(); |
|
} |
|
}, |
|
|
|
maybeAutoHidePanel: function(aEvent) { |
|
if (aEvent.type == "keypress") { |
|
if (aEvent.keyCode != aEvent.DOM_VK_RETURN) { |
|
return; |
|
} |
|
// If the user hit enter/return, we don't check preventDefault - it makes sense |
|
// that this was prevented, but we probably still want to close the panel. |
|
// If consumers don't want this to happen, they should specify the closemenu |
|
// attribute. |
|
|
|
} else if (aEvent.type != "command") { // mouse events: |
|
if (aEvent.defaultPrevented || aEvent.button != 0) { |
|
return; |
|
} |
|
let isInteractive = this._isOnInteractiveElement(aEvent); |
|
log.debug("maybeAutoHidePanel: interactive ? " + isInteractive); |
|
if (isInteractive) { |
|
return; |
|
} |
|
} |
|
|
|
// We can't use event.target because we might have passed a panelview |
|
// anonymous content boundary as well, and so target points to the |
|
// panelmultiview in that case. Unfortunately, this means we get |
|
// anonymous child nodes instead of the real ones, so looking for the |
|
// 'stoooop, don't close me' attributes is more involved. |
|
let target = aEvent.originalTarget; |
|
let closemenu = "auto"; |
|
let widgetType = "button"; |
|
while (target.parentNode && target.localName != "panel") { |
|
closemenu = target.getAttribute("closemenu"); |
|
widgetType = target.getAttribute("widget-type"); |
|
if (closemenu == "none" || closemenu == "single" || |
|
widgetType == "view") { |
|
break; |
|
} |
|
target = target.parentNode; |
|
} |
|
if (closemenu == "none" || widgetType == "view") { |
|
return; |
|
} |
|
|
|
if (closemenu == "single") { |
|
let panel = this._getPanelForNode(target); |
|
let multiview = panel.querySelector("panelmultiview"); |
|
if (multiview.showingSubView) { |
|
multiview.showMainView(); |
|
return; |
|
} |
|
} |
|
|
|
// If we get here, we can actually hide the popup: |
|
this.hidePanelForNode(aEvent.target); |
|
}, |
|
|
|
getUnusedWidgets: function(aWindowPalette) { |
|
let window = aWindowPalette.ownerGlobal; |
|
let isWindowPrivate = PrivateBrowsingUtils.isWindowPrivate(window); |
|
// We use a Set because there can be overlap between the widgets in |
|
// gPalette and the items in the palette, especially after the first |
|
// customization, since programmatically generated widgets will remain |
|
// in the toolbox palette. |
|
let widgets = new Set(); |
|
|
|
// It's possible that some widgets have been defined programmatically and |
|
// have not been overlayed into the palette. We can find those inside |
|
// gPalette. |
|
for (let [id, widget] of gPalette) { |
|
if (!widget.currentArea) { |
|
if (widget.showInPrivateBrowsing || !isWindowPrivate) { |
|
widgets.add(id); |
|
} |
|
} |
|
} |
|
|
|
log.debug("Iterating the actual nodes of the window palette"); |
|
for (let node of aWindowPalette.children) { |
|
log.debug("In palette children: " + node.id); |
|
if (node.id && !this.getPlacementOfWidget(node.id)) { |
|
widgets.add(node.id); |
|
} |
|
} |
|
|
|
return [...widgets]; |
|
}, |
|
|
|
getPlacementOfWidget: function(aWidgetId, aOnlyRegistered, aDeadAreas) { |
|
if (aOnlyRegistered && !this.widgetExists(aWidgetId)) { |
|
return null; |
|
} |
|
|
|
for (let [area, placements] of gPlacements) { |
|
if (!gAreas.has(area) && !aDeadAreas) { |
|
continue; |
|
} |
|
let index = placements.indexOf(aWidgetId); |
|
if (index != -1) { |
|
return { area: area, position: index }; |
|
} |
|
} |
|
|
|
return null; |
|
}, |
|
|
|
widgetExists: function(aWidgetId) { |
|
if (gPalette.has(aWidgetId) || this.isSpecialWidget(aWidgetId)) { |
|
return true; |
|
} |
|
|
|
// Destroyed API widgets are in gSeenWidgets, but not in gPalette: |
|
if (gSeenWidgets.has(aWidgetId)) { |
|
return false; |
|
} |
|
|
|
// We're assuming XUL widgets always exist, as it's much harder to check, |
|
// and checking would be much more error prone. |
|
return true; |
|
}, |
|
|
|
addWidgetToArea: function(aWidgetId, aArea, aPosition, aInitialAdd) { |
|
if (!gAreas.has(aArea)) { |
|
throw new Error("Unknown customization area: " + aArea); |
|
} |
|
|
|
// Hack: don't want special widgets in the panel (need to check here as well |
|
// as in canWidgetMoveToArea because the menu panel is lazy): |
|
if (gAreas.get(aArea).get("type") == CustomizableUI.TYPE_MENU_PANEL && |
|
this.isSpecialWidget(aWidgetId)) { |
|
return; |
|
} |
|
|
|
// If this is a lazy area that hasn't been restored yet, we can't yet modify |
|
// it - would would at least like to add to it. So we keep track of it in |
|
// gFuturePlacements, and use that to add it when restoring the area. We |
|
// throw away aPosition though, as that can only be bogus if the area hasn't |
|
// yet been restorted (caller can't possibly know where its putting the |
|
// widget in relation to other widgets). |
|
if (this.isAreaLazy(aArea)) { |
|
gFuturePlacements.get(aArea).add(aWidgetId); |
|
return; |
|
} |
|
|
|
if (this.isSpecialWidget(aWidgetId)) { |
|
aWidgetId = this.ensureSpecialWidgetId(aWidgetId); |
|
} |
|
|
|
let oldPlacement = this.getPlacementOfWidget(aWidgetId, false, true); |
|
if (oldPlacement && oldPlacement.area == aArea) { |
|
this.moveWidgetWithinArea(aWidgetId, aPosition); |
|
return; |
|
} |
|
|
|
// Do nothing if the widget is not allowed to move to the target area. |
|
if (!this.canWidgetMoveToArea(aWidgetId, aArea)) { |
|
return; |
|
} |
|
|
|
if (oldPlacement) { |
|
this.removeWidgetFromArea(aWidgetId); |
|
} |
|
|
|
if (!gPlacements.has(aArea)) { |
|
gPlacements.set(aArea, [aWidgetId]); |
|
aPosition = 0; |
|
} else { |
|
let placements = gPlacements.get(aArea); |
|
if (typeof aPosition != "number") { |
|
aPosition = placements.length; |
|
} |
|
if (aPosition < 0) { |
|
aPosition = 0; |
|
} |
|
placements.splice(aPosition, 0, aWidgetId); |
|
} |
|
|
|
let widget = gPalette.get(aWidgetId); |
|
if (widget) { |
|
widget.currentArea = aArea; |
|
widget.currentPosition = aPosition; |
|
} |
|
|
|
// We initially set placements with addWidgetToArea, so in that case |
|
// we don't consider the area "dirtied". |
|
if (!aInitialAdd) { |
|
gDirtyAreaCache.add(aArea); |
|
} |
|
|
|
gDirty = true; |
|
this.saveState(); |
|
|
|
this.notifyListeners("onWidgetAdded", aWidgetId, aArea, aPosition); |
|
}, |
|
|
|
removeWidgetFromArea: function(aWidgetId) { |
|
let oldPlacement = this.getPlacementOfWidget(aWidgetId, false, true); |
|
if (!oldPlacement) { |
|
return; |
|
} |
|
|
|
if (!this.isWidgetRemovable(aWidgetId)) { |
|
return; |
|
} |
|
|
|
let placements = gPlacements.get(oldPlacement.area); |
|
let position = placements.indexOf(aWidgetId); |
|
if (position != -1) { |
|
placements.splice(position, 1); |
|
} |
|
|
|
let widget = gPalette.get(aWidgetId); |
|
if (widget) { |
|
widget.currentArea = null; |
|
widget.currentPosition = null; |
|
} |
|
|
|
gDirty = true; |
|
this.saveState(); |
|
gDirtyAreaCache.add(oldPlacement.area); |
|
|
|
this.notifyListeners("onWidgetRemoved", aWidgetId, oldPlacement.area); |
|
}, |
|
|
|
moveWidgetWithinArea: function(aWidgetId, aPosition) { |
|
let oldPlacement = this.getPlacementOfWidget(aWidgetId); |
|
if (!oldPlacement) { |
|
return; |
|
} |
|
|
|
let placements = gPlacements.get(oldPlacement.area); |
|
if (typeof aPosition != "number") { |
|
aPosition = placements.length; |
|
} else if (aPosition < 0) { |
|
aPosition = 0; |
|
} else if (aPosition > placements.length) { |
|
aPosition = placements.length; |
|
} |
|
|
|
let widget = gPalette.get(aWidgetId); |
|
if (widget) { |
|
widget.currentPosition = aPosition; |
|
widget.currentArea = oldPlacement.area; |
|
} |
|
|
|
if (aPosition == oldPlacement.position) { |
|
return; |
|
} |
|
|
|
placements.splice(oldPlacement.position, 1); |
|
// If we just removed the item from *before* where it is now added, |
|
// we need to compensate the position offset for that: |
|
if (oldPlacement.position < aPosition) { |
|
aPosition--; |
|
} |
|
placements.splice(aPosition, 0, aWidgetId); |
|
|
|
gDirty = true; |
|
gDirtyAreaCache.add(oldPlacement.area); |
|
|
|
this.saveState(); |
|
|
|
this.notifyListeners("onWidgetMoved", aWidgetId, oldPlacement.area, |
|
oldPlacement.position, aPosition); |
|
}, |
|
|
|
// Note that this does not populate gPlacements, which is done lazily so that |
|
// the legacy state can be migrated, which is only available once a browser |
|
// window is openned. |
|
// The panel area is an exception here, since it has no legacy state and is |
|
// built lazily - and therefore wouldn't otherwise result in restoring its |
|
// state immediately when a browser window opens, which is important for |
|
// other consumers of this API. |
|
loadSavedState: function() { |
|
let state = Services.prefs.getCharPref(kPrefCustomizationState, ""); |
|
if (!state) { |
|
log.debug("No saved state found"); |
|
// Nothing has been customized, so silently fall back to the defaults. |
|
return; |
|
} |
|
try { |
|
gSavedState = JSON.parse(state); |
|
if (typeof gSavedState != "object" || gSavedState === null) { |
|
throw "Invalid saved state"; |
|
} |
|
} catch (e) { |
|
Services.prefs.clearUserPref(kPrefCustomizationState); |
|
gSavedState = {}; |
|
log.debug("Error loading saved UI customization state, falling back to defaults."); |
|
} |
|
|
|
if (!("placements" in gSavedState)) { |
|
gSavedState.placements = {}; |
|
} |
|
|
|
if (!("currentVersion" in gSavedState)) { |
|
gSavedState.currentVersion = 0; |
|
} |
|
|
|
gSeenWidgets = new Set(gSavedState.seen || []); |
|
gDirtyAreaCache = new Set(gSavedState.dirtyAreaCache || []); |
|
gNewElementCount = gSavedState.newElementCount || 0; |
|
}, |
|
|
|
restoreStateForArea: function(aArea, aLegacyState) { |
|
let placementsPreexisted = gPlacements.has(aArea); |
|
|
|
this.beginBatchUpdate(); |
|
try { |
|
gRestoring = true; |
|
|
|
let restored = false; |
|
if (placementsPreexisted) { |
|
log.debug("Restoring " + aArea + " from pre-existing placements"); |
|
for (let [position, id] of gPlacements.get(aArea).entries()) { |
|
this.moveWidgetWithinArea(id, position); |
|
} |
|
gDirty = false; |
|
restored = true; |
|
} else { |
|
gPlacements.set(aArea, []); |
|
} |
|
|
|
if (!restored && gSavedState && aArea in gSavedState.placements) { |
|
log.debug("Restoring " + aArea + " from saved state"); |
|
let placements = gSavedState.placements[aArea]; |
|
for (let id of placements) |
|
this.addWidgetToArea(id, aArea); |
|
gDirty = false; |
|
restored = true; |
|
} |
|
|
|
if (!restored && aLegacyState) { |
|
log.debug("Restoring " + aArea + " from legacy state"); |
|
for (let id of aLegacyState) |
|
this.addWidgetToArea(id, aArea); |
|
// Don't override dirty state, to ensure legacy state is saved here and |
|
// therefore only used once. |
|
restored = true; |
|
} |
|
|
|
if (!restored) { |
|
log.debug("Restoring " + aArea + " from default state"); |
|
let defaults = gAreas.get(aArea).get("defaultPlacements"); |
|
if (defaults) { |
|
for (let id of defaults) |
|
this.addWidgetToArea(id, aArea, null, true); |
|
} |
|
gDirty = false; |
|
} |
|
|
|
// Finally, add widgets to the area that were added before the it was able |
|
// to be restored. This can occur when add-ons register widgets for a |
|
// lazily-restored area before it's been restored. |
|
if (gFuturePlacements.has(aArea)) { |
|
for (let id of gFuturePlacements.get(aArea)) |
|
this.addWidgetToArea(id, aArea); |
|
gFuturePlacements.delete(aArea); |
|
} |
|
|
|
log.debug("Placements for " + aArea + ":\n\t" + gPlacements.get(aArea).join("\n\t")); |
|
|
|
gRestoring = false; |
|
} finally { |
|
this.endBatchUpdate(); |
|
} |
|
}, |
|
|
|
saveState: function() { |
|
if (gInBatchStack || !gDirty) { |
|
return; |
|
} |
|
// Clone because we want to modify this map: |
|
let state = { placements: new Map(gPlacements), |
|
seen: gSeenWidgets, |
|
dirtyAreaCache: gDirtyAreaCache, |
|
currentVersion: kVersion, |
|
newElementCount: gNewElementCount }; |
|
|
|
// Merge in previously saved areas if not present in gPlacements. |
|
// This way, state is still persisted for e.g. temporarily disabled |
|
// add-ons - see bug 989338. |
|
if (gSavedState && gSavedState.placements) { |
|
for (let area of Object.keys(gSavedState.placements)) { |
|
if (!state.placements.has(area)) { |
|
let placements = gSavedState.placements[area]; |
|
state.placements.set(area, placements); |
|
} |
|
} |
|
} |
|
|
|
log.debug("Saving state."); |
|
let serialized = JSON.stringify(state, this.serializerHelper); |
|
log.debug("State saved as: " + serialized); |
|
Services.prefs.setCharPref(kPrefCustomizationState, serialized); |
|
gDirty = false; |
|
}, |
|
|
|
serializerHelper: function(aKey, aValue) { |
|
if (typeof aValue == "object" && aValue.constructor.name == "Map") { |
|
let result = {}; |
|
for (let [mapKey, mapValue] of aValue) |
|
result[mapKey] = mapValue; |
|
return result; |
|
} |
|
|
|
if (typeof aValue == "object" && aValue.constructor.name == "Set") { |
|
return [...aValue]; |
|
} |
|
|
|
return aValue; |
|
}, |
|
|
|
beginBatchUpdate: function() { |
|
gInBatchStack++; |
|
}, |
|
|
|
endBatchUpdate: function(aForceDirty) { |
|
gInBatchStack--; |
|
if (aForceDirty === true) { |
|
gDirty = true; |
|
} |
|
if (gInBatchStack == 0) { |
|
this.saveState(); |
|
} else if (gInBatchStack < 0) { |
|
throw new Error("The batch editing stack should never reach a negative number."); |
|
} |
|
}, |
|
|
|
addListener: function(aListener) { |
|
gListeners.add(aListener); |
|
}, |
|
|
|
removeListener: function(aListener) { |
|
if (aListener == this) { |
|
return; |
|
} |
|
|
|
gListeners.delete(aListener); |
|
}, |
|
|
|
notifyListeners: function(aEvent, ...aArgs) { |
|
if (gRestoring) { |
|
return; |
|
} |
|
|
|
for (let listener of gListeners) { |
|
try { |
|
if (typeof listener[aEvent] == "function") { |
|
listener[aEvent].apply(listener, aArgs); |
|
} |
|
} catch (e) { |
|
log.error(e + " -- " + e.fileName + ":" + e.lineNumber); |
|
} |
|
} |
|
}, |
|
|
|
_dispatchToolboxEventToWindow: function(aEventType, aDetails, aWindow) { |
|
let evt = new aWindow.CustomEvent(aEventType, { |
|
bubbles: true, |
|
cancelable: true, |
|
detail: aDetails |
|
}); |
|
aWindow.gNavToolbox.dispatchEvent(evt); |
|
}, |
|
|
|
dispatchToolboxEvent: function(aEventType, aDetails={}, aWindow=null) { |
|
if (aWindow) { |
|
this._dispatchToolboxEventToWindow(aEventType, aDetails, aWindow); |
|
return; |
|
} |
|
for (let [win, ] of gBuildWindows) { |
|
this._dispatchToolboxEventToWindow(aEventType, aDetails, win); |
|
} |
|
}, |
|
|
|
createWidget: function(aProperties) { |
|
let widget = this.normalizeWidget(aProperties, CustomizableUI.SOURCE_EXTERNAL); |
|
// XXXunf This should probably throw. |
|
if (!widget) { |
|
log.error("unable to normalize widget"); |
|
return undefined; |
|
} |
|
|
|
gPalette.set(widget.id, widget); |
|
|
|
// Clear our caches: |
|
gGroupWrapperCache.delete(widget.id); |
|
for (let [win, ] of gBuildWindows) { |
|
let cache = gSingleWrapperCache.get(win); |
|
if (cache) { |
|
cache.delete(widget.id); |
|
} |
|
} |
|
|
|
this.notifyListeners("onWidgetCreated", widget.id); |
|
|
|
if (widget.defaultArea) { |
|
let addToDefaultPlacements = false; |
|
let area = gAreas.get(widget.defaultArea); |
|
if (!CustomizableUI.isBuiltinToolbar(widget.defaultArea) && |
|
widget.defaultArea != CustomizableUI.AREA_PANEL) { |
|
addToDefaultPlacements = true; |
|
} |
|
|
|
if (addToDefaultPlacements) { |
|
if (area.has("defaultPlacements")) { |
|
area.get("defaultPlacements").push(widget.id); |
|
} else { |
|
area.set("defaultPlacements", [widget.id]); |
|
} |
|
} |
|
} |
|
|
|
// Look through previously saved state to see if we're restoring a widget. |
|
let seenAreas = new Set(); |
|
let widgetMightNeedAutoAdding = true; |
|
for (let [area, ] of gPlacements) { |
|
seenAreas.add(area); |
|
let areaIsRegistered = gAreas.has(area); |
|
let index = gPlacements.get(area).indexOf(widget.id); |
|
if (index != -1) { |
|
widgetMightNeedAutoAdding = false; |
|
if (areaIsRegistered) { |
|
widget.currentArea = area; |
|
widget.currentPosition = index; |
|
} |
|
break; |
|
} |
|
} |
|
|
|
// Also look at saved state data directly in areas that haven't yet been |
|
// restored. Can't rely on this for restored areas, as they may have |
|
// changed. |
|
if (widgetMightNeedAutoAdding && gSavedState) { |
|
for (let area of Object.keys(gSavedState.placements)) { |
|
if (seenAreas.has(area)) { |
|
continue; |
|
} |
|
|
|
let areaIsRegistered = gAreas.has(area); |
|
let index = gSavedState.placements[area].indexOf(widget.id); |
|
if (index != -1) { |
|
widgetMightNeedAutoAdding = false; |
|
if (areaIsRegistered) { |
|
widget.currentArea = area; |
|
widget.currentPosition = index; |
|
} |
|
break; |
|
} |
|
} |
|
} |
|
|
|
// If we're restoring the widget to it's old placement, fire off the |
|
// onWidgetAdded event - our own handler will take care of adding it to |
|
// any build areas. |
|
this.beginBatchUpdate(); |
|
try { |
|
if (widget.currentArea) { |
|
this.notifyListeners("onWidgetAdded", widget.id, widget.currentArea, |
|
widget.currentPosition); |
|
} else if (widgetMightNeedAutoAdding) { |
|
let autoAdd = Services.prefs.getBoolPref(kPrefCustomizationAutoAdd, true); |
|
|
|
// If the widget doesn't have an existing placement, and it hasn't been |
|
// seen before, then add it to its default area so it can be used. |
|
// If the widget is not removable, we *have* to add it to its default |
|
// area here. |
|
let canBeAutoAdded = autoAdd && !gSeenWidgets.has(widget.id); |
|
if (!widget.currentArea && (!widget.removable || canBeAutoAdded)) { |
|
if (widget.defaultArea) { |
|
if (this.isAreaLazy(widget.defaultArea)) { |
|
gFuturePlacements.get(widget.defaultArea).add(widget.id); |
|
} else { |
|
this.addWidgetToArea(widget.id, widget.defaultArea); |
|
} |
|
} |
|
} |
|
} |
|
} finally { |
|
// Ensure we always have this widget in gSeenWidgets, and save |
|
// state in case this needs to be done here. |
|
gSeenWidgets.add(widget.id); |
|
this.endBatchUpdate(true); |
|
} |
|
|
|
this.notifyListeners("onWidgetAfterCreation", widget.id, widget.currentArea); |
|
return widget.id; |
|
}, |
|
|
|
createBuiltinWidget: function(aData) { |
|
// This should only ever be called on startup, before any windows are |
|
// opened - so we know there's no build areas to handle. Also, builtin |
|
// widgets are expected to be (mostly) static, so shouldn't affect the |
|
// current placement settings. |
|
|
|
// This allows a widget to be both built-in by default but also able to be |
|
// destroyed and removed from the area based on criteria that may not be |
|
// available when the widget is created -- for example, because some other |
|
// feature in the browser supersedes the widget. |
|
let conditionalDestroyPromise = aData.conditionalDestroyPromise || null; |
|
delete aData.conditionalDestroyPromise; |
|
|
|
let widget = this.normalizeWidget(aData, CustomizableUI.SOURCE_BUILTIN); |
|
if (!widget) { |
|
log.error("Error creating builtin widget: " + aData.id); |
|
return; |
|
} |
|
|
|
log.debug("Creating built-in widget with id: " + widget.id); |
|
gPalette.set(widget.id, widget); |
|
|
|
if (conditionalDestroyPromise) { |
|
conditionalDestroyPromise.then(shouldDestroy => { |
|
if (shouldDestroy) { |
|
this.destroyWidget(widget.id); |
|
this.removeWidgetFromArea(widget.id); |
|
} |
|
}, err => { |
|
Cu.reportError(err); |
|
}); |
|
} |
|
}, |
|
|
|
// Returns true if the area will eventually lazily restore (but hasn't yet). |
|
isAreaLazy: function(aArea) { |
|
if (gPlacements.has(aArea)) { |
|
return false; |
|
} |
|
return gAreas.get(aArea).has("legacy"); |
|
}, |
|
|
|
// XXXunf Log some warnings here, when the data provided isn't up to scratch. |
|
normalizeWidget: function(aData, aSource) { |
|
let widget = { |
|
implementation: aData, |
|
source: aSource || CustomizableUI.SOURCE_EXTERNAL, |
|
instances: new Map(), |
|
currentArea: null, |
|
removable: true, |
|
overflows: true, |
|
defaultArea: null, |
|
shortcutId: null, |
|
tabSpecific: false, |
|
tooltiptext: null, |
|
showInPrivateBrowsing: true, |
|
_introducedInVersion: -1, |
|
}; |
|
|
|
if (typeof aData.id != "string" || !/^[a-z0-9-_]{1,}$/i.test(aData.id)) { |
|
log.error("Given an illegal id in normalizeWidget: " + aData.id); |
|
return null; |
|
} |
|
|
|
delete widget.implementation.currentArea; |
|
widget.implementation.__defineGetter__("currentArea", () => widget.currentArea); |
|
|
|
const kReqStringProps = ["id"]; |
|
for (let prop of kReqStringProps) { |
|
if (typeof aData[prop] != "string") { |
|
log.error("Missing required property '" + prop + "' in normalizeWidget: " |
|
+ aData.id); |
|
return null; |
|
} |
|
widget[prop] = aData[prop]; |
|
} |
|
|
|
const kOptStringProps = ["label", "tooltiptext", "shortcutId"]; |
|
for (let prop of kOptStringProps) { |
|
if (typeof aData[prop] == "string") { |
|
widget[prop] = aData[prop]; |
|
} |
|
} |
|
|
|
const kOptBoolProps = ["removable", "showInPrivateBrowsing", "overflows", "tabSpecific"]; |
|
for (let prop of kOptBoolProps) { |
|
if (typeof aData[prop] == "boolean") { |
|
widget[prop] = aData[prop]; |
|
} |
|
} |
|
|
|
// When we normalize builtin widgets, areas have not yet been registered: |
|
if (aData.defaultArea && |
|
(aSource == CustomizableUI.SOURCE_BUILTIN || gAreas.has(aData.defaultArea))) { |
|
widget.defaultArea = aData.defaultArea; |
|
} else if (!widget.removable) { |
|
log.error("Widget '" + widget.id + "' is not removable but does not specify " + |
|
"a valid defaultArea. That's not possible; it must specify a " + |
|
"valid defaultArea as well."); |
|
return null; |
|
} |
|
|
|
if ("type" in aData && gSupportedWidgetTypes.has(aData.type)) { |
|
widget.type = aData.type; |
|
} else { |
|
widget.type = "button"; |
|
} |
|
|
|
widget.disabled = aData.disabled === true; |
|
|
|
if (aSource == CustomizableUI.SOURCE_BUILTIN) { |
|
widget._introducedInVersion = aData.introducedInVersion || 0; |
|
} |
|
|
|
this.wrapWidgetEventHandler("onBeforeCreated", widget); |
|
this.wrapWidgetEventHandler("onClick", widget); |
|
this.wrapWidgetEventHandler("onCreated", widget); |
|
this.wrapWidgetEventHandler("onDestroyed", widget); |
|
|
|
if (widget.type == "button") { |
|
widget.onCommand = typeof aData.onCommand == "function" ? |
|
aData.onCommand : |
|
null; |
|
} else if (widget.type == "view") { |
|
if (typeof aData.viewId != "string") { |
|
log.error("Expected a string for widget " + widget.id + " viewId, but got " |
|
+ aData.viewId); |
|
return null; |
|
} |
|
widget.viewId = aData.viewId; |
|
|
|
this.wrapWidgetEventHandler("onViewShowing", widget); |
|
this.wrapWidgetEventHandler("onViewHiding", widget); |
|
} else if (widget.type == "custom") { |
|
this.wrapWidgetEventHandler("onBuild", widget); |
|
} |
|
|
|
if (gPalette.has(widget.id)) { |
|
return null; |
|
} |
|
|
|
return widget; |
|
}, |
|
|
|
wrapWidgetEventHandler: function(aEventName, aWidget) { |
|
if (typeof aWidget.implementation[aEventName] != "function") { |
|
aWidget[aEventName] = null; |
|
return; |
|
} |
|
aWidget[aEventName] = function(...aArgs) { |
|
// Wrap inside a try...catch to properly log errors, until bug 862627 is |
|
// fixed, which in turn might help bug 503244. |
|
try { |
|
// Don't copy the function to the normalized widget object, instead |
|
// keep it on the original object provided to the API so that |
|
// additional methods can be implemented and used by the event |
|
// handlers. |
|
return aWidget.implementation[aEventName].apply(aWidget.implementation, |
|
aArgs); |
|
} catch (e) { |
|
Cu.reportError(e); |
|
return undefined; |
|
} |
|
}; |
|
}, |
|
|
|
destroyWidget: function(aWidgetId) { |
|
let widget = gPalette.get(aWidgetId); |
|
if (!widget) { |
|
gGroupWrapperCache.delete(aWidgetId); |
|
for (let [window, ] of gBuildWindows) { |
|
let windowCache = gSingleWrapperCache.get(window); |
|
if (windowCache) { |
|
windowCache.delete(aWidgetId); |
|
} |
|
} |
|
return; |
|
} |
|
|
|
// Remove it from the default placements of an area if it was added there: |
|
if (widget.defaultArea) { |
|
let area = gAreas.get(widget.defaultArea); |
|
if (area) { |
|
let defaultPlacements = area.get("defaultPlacements"); |
|
// We can assume this is present because if a widget has a defaultArea, |
|
// we automatically create a defaultPlacements array for that area. |
|
let widgetIndex = defaultPlacements.indexOf(aWidgetId); |
|
if (widgetIndex != -1) { |
|
defaultPlacements.splice(widgetIndex, 1); |
|
} |
|
} |
|
} |
|
|
|
// This will not remove the widget from gPlacements - we want to keep the |
|
// setting so the widget gets put back in it's old position if/when it |
|
// returns. |
|
for (let [window, ] of gBuildWindows) { |
|
< |