2346 lines
72 KiB
JavaScript
2346 lines
72 KiB
JavaScript
/* 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/NetUtil.jsm");
|
|
Cu.import("resource://gre/modules/XPCOMUtils.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.defineLazyServiceGetter(this, "gELS",
|
|
"@mozilla.org/eventlistenerservice;1", "nsIEventListenerService");
|
|
|
|
const kNSXUL = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
|
|
|
|
const kSpecialWidgetPfx = "customizableui-special-";
|
|
|
|
const kCustomizationContextMenu = "customizationContextMenu";
|
|
|
|
|
|
const kPrefCustomizationState = "browser.uiCustomization.state";
|
|
const kPrefCustomizationAutoAdd = "browser.uiCustomization.autoAdd";
|
|
const kPrefCustomizationDebug = "browser.uiCustomization.debug";
|
|
|
|
/**
|
|
* 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"
|
|
];
|
|
|
|
/**
|
|
* gPalette is a map of every widget that CustomizableUI.jsm knows about, keyed
|
|
* on their IDs.
|
|
*/
|
|
let 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.
|
|
*/
|
|
let 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).
|
|
*/
|
|
let 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.
|
|
*/
|
|
let gFuturePlacements = new Map();
|
|
|
|
//XXXunf Temporary. Need a nice way to abstract functions to build widgets
|
|
// of these types.
|
|
let 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.
|
|
*/
|
|
let 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.
|
|
*/
|
|
let gSeenWidgets = new Set();
|
|
|
|
let gSavedState = null;
|
|
let gRestoring = false;
|
|
let gDirty = false;
|
|
let gInBatch = false;
|
|
let gResetting = false;
|
|
|
|
/**
|
|
* gBuildAreas maps area IDs to actual area nodes within browser windows.
|
|
*/
|
|
let gBuildAreas = new Map();
|
|
|
|
/**
|
|
* gBuildWindows is a map of windows that have registered build areas, mapped
|
|
* to a Set of known toolboxes in that window.
|
|
*/
|
|
let gBuildWindows = new Map();
|
|
|
|
let gNewElementCount = 0;
|
|
let gWrapperCache = new WeakMap();
|
|
let gListeners = new Set();
|
|
|
|
let gModuleName = "[CustomizableUI]";
|
|
#include logging.js
|
|
|
|
let CustomizableUIInternal = {
|
|
initialize: function() {
|
|
LOG("Initializing");
|
|
|
|
this.addListener(this);
|
|
this._defineBuiltInWidgets();
|
|
this.loadSavedState();
|
|
|
|
this.registerArea(CustomizableUI.AREA_PANEL, {
|
|
anchor: "PanelUI-menu-button",
|
|
type: CustomizableUI.TYPE_MENU_PANEL,
|
|
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",
|
|
]
|
|
});
|
|
this.registerArea(CustomizableUI.AREA_NAVBAR, {
|
|
legacy: true,
|
|
anchor: "nav-bar-overflow-button",
|
|
type: CustomizableUI.TYPE_TOOLBAR,
|
|
overflowable: true,
|
|
defaultPlacements: [
|
|
"urlbar-container",
|
|
"search-container",
|
|
"webrtc-status-button",
|
|
"bookmarks-menu-button",
|
|
"downloads-button",
|
|
"home-button",
|
|
"social-share-button",
|
|
"social-toolbar-item",
|
|
]
|
|
});
|
|
#ifndef XP_MACOSX
|
|
this.registerArea(CustomizableUI.AREA_MENUBAR, {
|
|
legacy: true,
|
|
type: CustomizableUI.TYPE_TOOLBAR,
|
|
defaultPlacements: [
|
|
"menubar-items",
|
|
]
|
|
});
|
|
#endif
|
|
this.registerArea(CustomizableUI.AREA_TABSTRIP, {
|
|
legacy: true,
|
|
type: CustomizableUI.TYPE_TOOLBAR,
|
|
defaultPlacements: [
|
|
"tabbrowser-tabs",
|
|
"new-tab-button",
|
|
"alltabs-button",
|
|
"tabs-closebutton",
|
|
]
|
|
});
|
|
this.registerArea(CustomizableUI.AREA_BOOKMARKS, {
|
|
legacy: true,
|
|
type: CustomizableUI.TYPE_TOOLBAR,
|
|
defaultPlacements: [
|
|
"personal-bookmarks",
|
|
]
|
|
});
|
|
|
|
this.registerArea(CustomizableUI.AREA_ADDONBAR, {
|
|
type: CustomizableUI.TYPE_TOOLBAR,
|
|
legacy: true,
|
|
defaultPlacements: ["addonbar-closebutton", "status-bar"]
|
|
});
|
|
},
|
|
|
|
_defineBuiltInWidgets: function() {
|
|
//XXXunf Need to figure out how to auto-add new builtin widgets in new
|
|
// app versions to already customized areas.
|
|
for (let widgetDefinition of CustomizableWidgets) {
|
|
this.createBuiltinWidget(widgetDefinition);
|
|
}
|
|
},
|
|
|
|
wrapWidget: function(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);
|
|
}
|
|
return widget.wrapper;
|
|
}
|
|
|
|
// PROVIDER_SPECIAL gets treated the same as PROVIDER_XUL.
|
|
return new XULWidgetGroupWrapper(aWidgetId);
|
|
},
|
|
|
|
registerArea: function(aName, aProperties) {
|
|
if (typeof aName != "string" || !/^[a-z0-9-_]{1,}$/i.test(aName)) {
|
|
throw new Error("Invalid area name");
|
|
}
|
|
if (gAreas.has(aName)) {
|
|
throw new Error("Area already registered");
|
|
}
|
|
|
|
let props = new Map();
|
|
for (let key in aProperties) {
|
|
//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]);
|
|
}
|
|
}
|
|
gAreas.set(aName, props);
|
|
|
|
if (props.get("legacy")) {
|
|
// Guarantee this area exists in gFuturePlacements, to avoid checking it in
|
|
// various places elsewhere.
|
|
gFuturePlacements.set(aName, new Set());
|
|
} else {
|
|
this.restoreStateForArea(aName);
|
|
}
|
|
},
|
|
|
|
unregisterArea: function(aName) {
|
|
if (typeof aName != "string" || !/^[a-z0-9-_]{1,}$/i.test(aName)) {
|
|
throw new Error("Invalid area name");
|
|
}
|
|
if (!gAreas.has(aName)) {
|
|
throw new Error("Area not registered");
|
|
}
|
|
|
|
// Move all the widgets out
|
|
this.beginBatchUpdate();
|
|
let placements = gPlacements.get(aName);
|
|
placements.forEach(this.removeWidgetFromArea, this);
|
|
|
|
// Delete all remaining traces.
|
|
gAreas.delete(aName);
|
|
gPlacements.delete(aName);
|
|
gFuturePlacements.delete(aName);
|
|
this.endBatchUpdate(true);
|
|
},
|
|
|
|
registerToolbar: function(aToolbar) {
|
|
let document = aToolbar.ownerDocument;
|
|
let area = aToolbar.id;
|
|
let areaProperties = gAreas.get(area);
|
|
|
|
if (!areaProperties) {
|
|
throw new Error("Unknown customization area: " + area);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
if (areaProperties.has("overflowable")) {
|
|
aToolbar.overflowable = new OverflowableToolbar(aToolbar);
|
|
}
|
|
|
|
this.registerBuildArea(area, aToolbar);
|
|
this.buildArea(area, placements, aToolbar);
|
|
aToolbar.setAttribute("currentset", placements.join(","));
|
|
},
|
|
|
|
buildArea: function(aArea, aPlacements, aAreaNode) {
|
|
let document = aAreaNode.ownerDocument;
|
|
let window = document.defaultView;
|
|
let inPrivateWindow = PrivateBrowsingUtils.isWindowPrivate(window);
|
|
let container = aAreaNode.customizationTarget;
|
|
|
|
if (!container) {
|
|
throw new Error("Expected area " + aArea
|
|
+ " to have a customizationTarget attribute.");
|
|
}
|
|
|
|
this.beginBatchUpdate();
|
|
|
|
let currentNode = container.firstChild;
|
|
for (let id of aPlacements) {
|
|
if (currentNode && currentNode.id == id) {
|
|
this._addParentFlex(currentNode);
|
|
this.setLocationAttributes(currentNode, aArea);
|
|
|
|
// Normalize removable attribute. It defaults to false if the widget is
|
|
// originally defined as a child of a build area.
|
|
if (!currentNode.hasAttribute("removable")) {
|
|
currentNode.setAttribute("removable", this.isWidgetRemovable(currentNode));
|
|
}
|
|
|
|
currentNode = currentNode.nextSibling;
|
|
continue;
|
|
}
|
|
|
|
let [provider, node] = this.getWidgetNode(id, window);
|
|
if (!node) {
|
|
LOG("Unknown widget: " + id);
|
|
continue;
|
|
}
|
|
|
|
if (inPrivateWindow && provider == CustomizableUI.PROVIDER_API) {
|
|
let widget = gPalette.get(id);
|
|
if (!widget.showInPrivateBrowsing && inPrivateWindow) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
this.ensureButtonContextMenu(node, aArea == CustomizableUI.AREA_PANEL);
|
|
if (node.localName == "toolbarbutton" && aArea == CustomizableUI.AREA_PANEL) {
|
|
node.setAttribute("tabindex", "0");
|
|
if (!node.hasAttribute("type")) {
|
|
node.setAttribute("type", "wrap");
|
|
}
|
|
}
|
|
|
|
this.insertWidgetBefore(node, currentNode, container, aArea);
|
|
this._addParentFlex(node);
|
|
if (gResetting)
|
|
this.notifyListeners("onWidgetReset", id);
|
|
}
|
|
|
|
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) {
|
|
if (this.isWidgetRemovable(node)) {
|
|
if (palette) {
|
|
palette.appendChild(node);
|
|
} else {
|
|
container.removeChild(node);
|
|
}
|
|
} else if (node.getAttribute("skipintoolbarset") != "true") {
|
|
this.setLocationAttributes(currentNode, aArea);
|
|
node.setAttribute("removable", false);
|
|
LOG("Adding non-removable widget to placements of " + aArea + ": " +
|
|
node.id);
|
|
gPlacements.get(aArea).push(node.id);
|
|
gDirty = true;
|
|
}
|
|
}
|
|
node = previousSibling;
|
|
}
|
|
}
|
|
|
|
this.endBatchUpdate();
|
|
},
|
|
|
|
addPanelCloseListeners: function(aPanel) {
|
|
gELS.addSystemEventListener(aPanel, "click", this, false);
|
|
gELS.addSystemEventListener(aPanel, "keypress", this, false);
|
|
let win = aPanel.ownerDocument.defaultView;
|
|
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.ownerDocument.defaultView;
|
|
let panels = gPanelsForWindow.get(win);
|
|
if (panels) {
|
|
panels.delete(this._getPanelForNode(aPanel));
|
|
}
|
|
},
|
|
|
|
ensureButtonContextMenu: function(aNode, aShouldHaveCustomizationMenu) {
|
|
let currentContextMenu = aNode.getAttribute("context") ||
|
|
aNode.getAttribute("contextmenu");
|
|
if (aShouldHaveCustomizationMenu) {
|
|
if (!currentContextMenu)
|
|
aNode.setAttribute("context", kCustomizationContextMenu);
|
|
} else {
|
|
if (currentContextMenu == kCustomizationContextMenu)
|
|
aNode.removeAttribute("context");
|
|
}
|
|
},
|
|
|
|
getWidgetProvider: function(aWidgetId) {
|
|
if (this.isSpecialWidget(aWidgetId)) {
|
|
return CustomizableUI.PROVIDER_SPECIAL;
|
|
}
|
|
if (gPalette.has(aWidgetId)) {
|
|
return CustomizableUI.PROVIDER_API;
|
|
}
|
|
|
|
// 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("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("Searching for " + aWidgetId + " in toolbox.");
|
|
let node = this.findWidgetInWindow(aWidgetId, aWindow);
|
|
if (node) {
|
|
return [ CustomizableUI.PROVIDER_XUL, node ];
|
|
}
|
|
|
|
LOG("No node for " + aWidgetId + " found.");
|
|
return [];
|
|
},
|
|
|
|
registerMenuPanel: function(aPanel) {
|
|
if (gBuildAreas.has(CustomizableUI.AREA_PANEL) &&
|
|
gBuildAreas.get(CustomizableUI.AREA_PANEL).has(aPanel)) {
|
|
return;
|
|
}
|
|
|
|
let document = aPanel.ownerDocument;
|
|
|
|
for (let btn of aPanel.querySelectorAll("toolbarbutton")) {
|
|
btn.setAttribute("tabindex", "0");
|
|
this.ensureButtonContextMenu(btn, true);
|
|
if (!btn.hasAttribute("type")) {
|
|
btn.setAttribute("type", "wrap");
|
|
}
|
|
}
|
|
|
|
aPanel.toolbox = document.getElementById("navigator-toolbox");
|
|
aPanel.customizationTarget = aPanel;
|
|
|
|
this.addPanelCloseListeners(aPanel);
|
|
|
|
let placements = gPlacements.get(CustomizableUI.AREA_PANEL);
|
|
this.buildArea(CustomizableUI.AREA_PANEL, placements, aPanel);
|
|
this.registerBuildArea(CustomizableUI.AREA_PANEL, aPanel);
|
|
},
|
|
|
|
onWidgetAdded: function(aWidgetId, aArea, aPosition) {
|
|
let areaNodes = gBuildAreas.get(aArea);
|
|
if (!areaNodes) {
|
|
return;
|
|
}
|
|
|
|
let placements = gPlacements.get(aArea);
|
|
if (!placements) {
|
|
ERROR("Could not find any placements for " + aArea +
|
|
" when adding a widget.");
|
|
return;
|
|
}
|
|
|
|
let area = gAreas.get(aArea);
|
|
let showInPrivateBrowsing = gPalette.has(aWidgetId)
|
|
? gPalette.get(aWidgetId).showInPrivateBrowsing
|
|
: true;
|
|
let nextNodeId = placements[aPosition + 1];
|
|
|
|
// Go through each of the nodes associated with this area and move the
|
|
// widget to the requested location.
|
|
for (let areaNode of areaNodes) {
|
|
let window = areaNode.ownerDocument.defaultView;
|
|
if (!showInPrivateBrowsing && PrivateBrowsingUtils.isWindowPrivate(window)) {
|
|
continue;
|
|
}
|
|
|
|
let container = areaNode.customizationTarget;
|
|
let [provider, widgetNode] = this.getWidgetNode(aWidgetId, window);
|
|
|
|
this.ensureButtonContextMenu(widgetNode, aArea == CustomizableUI.AREA_PANEL);
|
|
if (widgetNode.localName == "toolbarbutton" && aArea == CustomizableUI.AREA_PANEL) {
|
|
widgetNode.setAttribute("tabindex", "0");
|
|
if (!widgetNode.hasAttribute("type")) {
|
|
widgetNode.setAttribute("type", "wrap");
|
|
}
|
|
}
|
|
|
|
let nextNode = nextNodeId ? container.querySelector(idToSelector(nextNodeId))
|
|
: null;
|
|
this.insertWidgetBefore(widgetNode, nextNode, container, aArea);
|
|
this._addParentFlex(widgetNode);
|
|
if (area.type == this.TYPE_TOOLBAR) {
|
|
areaNode.setAttribute("currentset", areaNode.currentSet);
|
|
}
|
|
}
|
|
},
|
|
|
|
onWidgetRemoved: function(aWidgetId, aArea) {
|
|
let areaNodes = gBuildAreas.get(aArea);
|
|
if (!areaNodes) {
|
|
return;
|
|
}
|
|
|
|
let area = gAreas.get(aArea);
|
|
let showInPrivateBrowsing = gPalette.has(aWidgetId)
|
|
? gPalette.get(aWidgetId).showInPrivateBrowsing
|
|
: true;
|
|
|
|
for (let areaNode of areaNodes) {
|
|
let window = areaNode.ownerDocument.defaultView;
|
|
if (!showInPrivateBrowsing &&
|
|
PrivateBrowsingUtils.isWindowPrivate(window)) {
|
|
continue;
|
|
}
|
|
|
|
let container = areaNode.customizationTarget;
|
|
let widgetNode = container.ownerDocument.getElementById(aWidgetId);
|
|
if (!widgetNode) {
|
|
ERROR("Widget not found, unable to remove");
|
|
continue;
|
|
}
|
|
|
|
this._removeParentFlex(widgetNode);
|
|
|
|
if (gPalette.has(aWidgetId) || this.isSpecialWidget(aWidgetId)) {
|
|
container.removeChild(widgetNode);
|
|
} else {
|
|
this.removeLocationAttributes(widgetNode);
|
|
widgetNode.removeAttribute("tabindex");
|
|
if (widgetNode.getAttribute("type") == "wrap") {
|
|
widgetNode.removeAttribute("type");
|
|
}
|
|
areaNode.toolbox.palette.appendChild(widgetNode);
|
|
}
|
|
if (area.type == this.TYPE_TOOLBAR) {
|
|
areaNode.setAttribute("currentset", areaNode.currentSet);
|
|
}
|
|
}
|
|
},
|
|
|
|
onWidgetMoved: function(aWidgetId, aArea, aOldPosition, aNewPosition) {
|
|
let areaNodes = gBuildAreas.get(aArea);
|
|
if (!areaNodes) {
|
|
return;
|
|
}
|
|
|
|
let placements = gPlacements.get(aArea);
|
|
if (!placements) {
|
|
ERROR("Could not find any placements for " + aArea +
|
|
" when moving a widget.");
|
|
return;
|
|
}
|
|
|
|
let area = gAreas.get(aArea);
|
|
let showInPrivateBrowsing = gPalette.has(aWidgetId)
|
|
? gPalette.get(aWidgetId).showInPrivateBrowsing
|
|
: true;
|
|
|
|
let nextNodeId = placements[aNewPosition + 1];
|
|
|
|
for (let areaNode of areaNodes) {
|
|
let window = areaNode.ownerDocument.defaultView;
|
|
if (!showInPrivateBrowsing &&
|
|
PrivateBrowsingUtils.isWindowPrivate(window)) {
|
|
continue;
|
|
}
|
|
|
|
let container = areaNode.customizationTarget;
|
|
let [provider, widgetNode] = this.getWidgetNode(aWidgetId, window);
|
|
if (!widgetNode) {
|
|
ERROR("Widget not found, unable to move");
|
|
continue;
|
|
}
|
|
|
|
let nextNode = nextNodeId ? container.querySelector(idToSelector(nextNodeId))
|
|
: null;
|
|
this.insertWidgetBefore(widgetNode, nextNode, container, aArea);
|
|
if (area.type == this.TYPE_TOOLBAR) {
|
|
areaNode.setAttribute("currentset", areaNode.currentSet);
|
|
}
|
|
}
|
|
},
|
|
|
|
registerBuildArea: function(aArea, aNode) {
|
|
// We ensure that the window is registered to have its customization data
|
|
// cleaned up when unloading.
|
|
let window = aNode.ownerDocument.defaultView;
|
|
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);
|
|
},
|
|
|
|
registerBuildWindow: function(aWindow) {
|
|
if (!gBuildWindows.has(aWindow)) {
|
|
gBuildWindows.set(aWindow, new Set());
|
|
}
|
|
|
|
aWindow.addEventListener("unload", this);
|
|
aWindow.addEventListener("command", this, true);
|
|
},
|
|
|
|
unregisterBuildWindow: function(aWindow) {
|
|
aWindow.removeEventListener("unload", this);
|
|
aWindow.removeEventListener("command", this, true);
|
|
gPanelsForWindow.delete(aWindow);
|
|
gBuildWindows.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) {
|
|
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);
|
|
}
|
|
},
|
|
|
|
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("customizableui-areatype", props.get("type") || "");
|
|
aNode.setAttribute("customizableui-anchorid", props.get("anchor") || "");
|
|
},
|
|
|
|
removeLocationAttributes: function(aNode) {
|
|
aNode.removeAttribute("customizableui-areatype");
|
|
aNode.removeAttribute("customizableui-anchorid");
|
|
},
|
|
|
|
insertWidgetBefore: function(aNode, aNextNode, aContainer, aArea) {
|
|
this.setLocationAttributes(aNode, aArea);
|
|
aContainer.insertBefore(aNode, aNextNode);
|
|
},
|
|
|
|
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) {
|
|
// Due to timers resolution Date.now() can be the same for
|
|
// elements created in small timeframes. So ids are
|
|
// differentiated through a unique count suffix.
|
|
return kSpecialWidgetPfx + aId + Date.now() + (++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) {
|
|
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.localName == "toolbarpaletteitem")) {
|
|
parent = parent.parentNode;
|
|
}
|
|
|
|
if ((parent && parent.customizationTarget == node.parentNode &&
|
|
gBuildWindows.get(aWindow).has(parent.toolbox)) ||
|
|
(parent && parent.localName == "toolbarpaletteitem")) {
|
|
// Normalize the removable attribute. For backwards compat, if
|
|
// the widget is not defined 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.querySelector(idToSelector(aId));
|
|
if (node) {
|
|
// Normalize the removable attribute. For backwards compat, this
|
|
// is optional if the widget is defined in the toolbox palette,
|
|
// and defaults to *true*, unlike if it was defined elsewhere.
|
|
if (!node.hasAttribute("removable")) {
|
|
node.setAttribute("removable", true);
|
|
}
|
|
return node;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
|
|
buildWidget: function(aDocument, aWidget) {
|
|
if (typeof aWidget == "string") {
|
|
aWidget = gPalette.get(aWidget);
|
|
}
|
|
if (!aWidget) {
|
|
throw new Error("buildWidget was passed a non-widget to build.");
|
|
}
|
|
|
|
LOG("Building " + aWidget.id + " of type " + aWidget.type);
|
|
|
|
let node;
|
|
if (aWidget.type == "custom") {
|
|
if (aWidget.onBuild) {
|
|
try {
|
|
node = aWidget.onBuild(aDocument);
|
|
} catch (ex) {
|
|
ERROR("Custom widget with id " + aWidget.id + " threw an error: " + ex.message);
|
|
}
|
|
}
|
|
if (!node || !(node instanceof aDocument.defaultView.XULElement))
|
|
ERROR("Custom widget with id " + aWidget.id + " does not return a valid node");
|
|
}
|
|
else {
|
|
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("nooverflow", aWidget.nooverflow);
|
|
node.setAttribute("label", this.getLocalizedProperty(aWidget, "label"));
|
|
node.setAttribute("tooltiptext", this.getLocalizedProperty(aWidget, "tooltiptext"));
|
|
//XXXunf Need to hook this up to a <key> element or something.
|
|
let shortcut = this.getLocalizedProperty(aWidget, "shortcut", null, "none");
|
|
if (shortcut != "none") {
|
|
node.setAttribute("acceltext", shortcut);
|
|
}
|
|
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" &&
|
|
(aWidget.onViewShowing || aWidget.onViewHiding)) {
|
|
LOG("Widget " + aWidget.id + " has a view with showing and hiding events. 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("Widget " + aWidget.id + " showing and hiding event handlers set.");
|
|
} else {
|
|
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) {
|
|
if (typeof aWidget == "string") {
|
|
aWidget = gPalette.get(aWidget);
|
|
}
|
|
if (!aWidget) {
|
|
throw new Error("getLocalizedProperty was passed a non-widget to work with.");
|
|
}
|
|
if (typeof aWidget[aProp] == "string") {
|
|
return aWidget[aProp];
|
|
}
|
|
let def = aDef || "";
|
|
let name = aWidget.id + "." + aProp;
|
|
try {
|
|
if (Array.isArray(aFormatArgs) && aFormatArgs.length) {
|
|
return gWidgetsBundle.formatStringFromName(name, aFormatArgs,
|
|
aFormatArgs.length) || def;
|
|
}
|
|
return gWidgetsBundle.GetStringFromName(name) || def;
|
|
} catch(ex) {
|
|
if (!def) {
|
|
ERROR("Could not localize property '" + name + "'.");
|
|
}
|
|
}
|
|
return def;
|
|
},
|
|
|
|
handleWidgetCommand: function(aWidget, aNode, aEvent) {
|
|
LOG("handleWidgetCommand");
|
|
|
|
if (aWidget.type == "button") {
|
|
this.maybeAutoHidePanel(aEvent);
|
|
|
|
if (aWidget.onCommand) {
|
|
try {
|
|
aWidget.onCommand.call(null, aEvent);
|
|
} catch (e) {
|
|
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.ownerDocument.defaultView;
|
|
ownerWindow.PanelUI.showSubView(aWidget.viewId, aNode,
|
|
this.getPlacementOfWidget(aNode.id).area);
|
|
}
|
|
},
|
|
|
|
handleWidgetClick: function(aWidget, aNode, aEvent) {
|
|
LOG("handleWidgetClick");
|
|
if (aWidget.type == "button") {
|
|
this.maybeAutoHidePanel(aEvent);
|
|
}
|
|
|
|
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.
|
|
* Anything else we should take care of?
|
|
*/
|
|
_isOnInteractiveElement: function(aEvent) {
|
|
let target = aEvent.originalTarget;
|
|
let panel = aEvent.currentTarget;
|
|
let inInput = false;
|
|
let inMenu = false;
|
|
while (!inInput && !inMenu && target != aEvent.currentTarget) {
|
|
inInput = target.localName == "input";
|
|
inMenu = target.type == "menu";
|
|
target = target.parentNode;
|
|
}
|
|
return inMenu || inInput;
|
|
},
|
|
|
|
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_ENTER &&
|
|
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 noautoclose.
|
|
|
|
} else if (aEvent.type != "command") { // mouse events:
|
|
if (aEvent.defaultPrevented || aEvent.button != 0) {
|
|
return;
|
|
}
|
|
let isInteractive = this._isOnInteractiveElement(aEvent);
|
|
LOG("maybeAutoHidePanel: interactive ? " + isInteractive);
|
|
if (isInteractive) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (aEvent.target.getAttribute("noautoclose") == "true" ||
|
|
aEvent.target.getAttribute("widget-type") == "view") {
|
|
return;
|
|
}
|
|
|
|
// If we get here, we can actually hide the popup:
|
|
this.hidePanelForNode(aEvent.target);
|
|
},
|
|
|
|
getUnusedWidgets: function(aWindowPalette) {
|
|
// 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) {
|
|
widgets.add(id);
|
|
}
|
|
}
|
|
|
|
LOG("Iterating the actual nodes of the window palette");
|
|
for (let node of aWindowPalette.children) {
|
|
LOG("In palette children: " + node.id);
|
|
if (node.id && !this.getPlacementOfWidget(node.id)) {
|
|
widgets.add(node.id);
|
|
}
|
|
}
|
|
|
|
return [...widgets];
|
|
},
|
|
|
|
getPlacementOfWidget: function(aWidgetId) {
|
|
for (let [area, placements] of gPlacements) {
|
|
let index = placements.indexOf(aWidgetId);
|
|
if (index != -1) {
|
|
return { area: area, position: index };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
addWidgetToArea: function(aWidgetId, aArea, aPosition) {
|
|
if (!gAreas.has(aArea)) {
|
|
throw new Error("Unknown customization area: " + aArea);
|
|
}
|
|
|
|
// 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);
|
|
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;
|
|
}
|
|
|
|
gDirty = true;
|
|
this.saveState();
|
|
|
|
this.notifyListeners("onWidgetAdded", aWidgetId, aArea, aPosition);
|
|
},
|
|
|
|
removeWidgetFromArea: function(aWidgetId) {
|
|
let oldPlacement = this.getPlacementOfWidget(aWidgetId);
|
|
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();
|
|
|
|
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;
|
|
}
|
|
|
|
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);
|
|
|
|
let widget = gPalette.get(aWidgetId);
|
|
if (widget) {
|
|
widget.currentPosition = aPosition;
|
|
}
|
|
|
|
gDirty = true;
|
|
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 = null;
|
|
try {
|
|
state = Services.prefs.getCharPref(kPrefCustomizationState);
|
|
} catch (e) {
|
|
LOG("No saved state found");
|
|
// This will fail if nothing has been customized, so silently fall back to
|
|
// the defaults.
|
|
}
|
|
|
|
if (!state) {
|
|
return;
|
|
}
|
|
try {
|
|
gSavedState = JSON.parse(state);
|
|
} catch(e) {
|
|
LOG("Error loading saved UI customization state, falling back to defaults.");
|
|
}
|
|
|
|
if (!("placements" in gSavedState)) {
|
|
gSavedState.placements = {};
|
|
}
|
|
|
|
gSeenWidgets = new Set(gSavedState.seen || []);
|
|
},
|
|
|
|
restoreStateForArea: function(aArea, aLegacyState) {
|
|
if (gPlacements.has(aArea)) {
|
|
// Already restored.
|
|
return;
|
|
}
|
|
|
|
this.beginBatchUpdate();
|
|
gRestoring = true;
|
|
|
|
let restored = false;
|
|
gPlacements.set(aArea, []);
|
|
|
|
if (gSavedState && aArea in gSavedState.placements) {
|
|
LOG("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("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("Restoring " + aArea + " from default state");
|
|
let defaults = gAreas.get(aArea).get("defaultPlacements");
|
|
if (defaults) {
|
|
for (let id of defaults)
|
|
this.addWidgetToArea(id, aArea);
|
|
}
|
|
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);
|
|
}
|
|
|
|
LOG("Placements for " + aArea + ":\n\t" + gPlacements.get(aArea).join("\n\t"));
|
|
|
|
gRestoring = false;
|
|
this.endBatchUpdate();
|
|
},
|
|
|
|
saveState: function() {
|
|
if (gInBatch || !gDirty) {
|
|
return;
|
|
}
|
|
let state = { placements: gPlacements,
|
|
seen: gSeenWidgets };
|
|
|
|
LOG("Saving state.");
|
|
let serialized = JSON.stringify(state, this.serializerHelper);
|
|
LOG("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() {
|
|
gInBatch = true;
|
|
},
|
|
|
|
endBatchUpdate: function(aForceSave) {
|
|
gInBatch = false;
|
|
if (aForceSave === true) {
|
|
gDirty = true;
|
|
}
|
|
this.saveState();
|
|
},
|
|
|
|
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 (aEvent in listener) {
|
|
listener[aEvent].apply(listener, aArgs);
|
|
}
|
|
} catch (e) {
|
|
ERROR(e + " -- " + e.fileName + ":" + e.lineNumber);
|
|
}
|
|
}
|
|
},
|
|
|
|
createWidget: function(aProperties) {
|
|
let widget = this.normalizeWidget(aProperties, CustomizableUI.SOURCE_EXTERNAL);
|
|
//XXXunf This should probably throw.
|
|
if (!widget) {
|
|
return;
|
|
}
|
|
|
|
gPalette.set(widget.id, widget);
|
|
this.notifyListeners("onWidgetCreated", widget.id);
|
|
|
|
if (widget.defaultArea) {
|
|
let area = gAreas.get(widget.defaultArea);
|
|
//XXXgijs this won't have any effect for legacy items. Sort of OK because
|
|
// consumers can modify currentset? Maybe?
|
|
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();
|
|
for (let [area, placements] of gPlacements) {
|
|
seenAreas.add(area);
|
|
let index = gPlacements.get(area).indexOf(widget.id);
|
|
if (index != -1) {
|
|
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 (!widget.currentArea && gSavedState) {
|
|
for (let area of Object.keys(gSavedState.placements)) {
|
|
if (seenAreas.has(area)) {
|
|
continue;
|
|
}
|
|
|
|
let index = gSavedState.placements[area].indexOf(widget.id);
|
|
if (index != -1) {
|
|
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.
|
|
if (widget.currentArea) {
|
|
this.notifyListeners("onWidgetAdded", widget.id, widget.currentArea,
|
|
widget.currentPosition);
|
|
} else {
|
|
let autoAdd = true;
|
|
try {
|
|
autoAdd = Services.prefs.getBoolPref(kPrefCustomizationAutoAdd);
|
|
} catch (e) {}
|
|
|
|
// 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 (autoAdd && !widget.currentArea && !gSeenWidgets.has(widget.id)) {
|
|
this.beginBatchUpdate();
|
|
gSeenWidgets.add(widget.id);
|
|
|
|
if (widget.defaultArea) {
|
|
if (this.isAreaLazy(widget.defaultArea)) {
|
|
gFuturePlacements.get(widget.defaultArea).add(widget.id);
|
|
} else {
|
|
this.addWidgetToArea(widget.id, widget.defaultArea);
|
|
}
|
|
}
|
|
|
|
this.endBatchUpdate(true);
|
|
}
|
|
}
|
|
|
|
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.
|
|
let widget = this.normalizeWidget(aData, CustomizableUI.SOURCE_BUILTIN);
|
|
if (!widget) {
|
|
ERROR("Error creating builtin widget: " + aData.id);
|
|
return;
|
|
}
|
|
|
|
LOG("Creating built-in widget with id: " + widget.id);
|
|
gPalette.set(widget.id, widget);
|
|
},
|
|
|
|
// 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 = {
|
|
source: aSource || "addon",
|
|
instances: new Map(),
|
|
currentArea: null,
|
|
removable: false,
|
|
nooverflow: false,
|
|
defaultArea: null,
|
|
allowedAreas: [],
|
|
shortcut: null,
|
|
tooltiptext: null,
|
|
showInPrivateBrowsing: true,
|
|
};
|
|
|
|
if (typeof aData.id != "string" || !/^[a-z0-9-_]{1,}$/i.test(aData.id)) {
|
|
ERROR("Given an illegal id in normalizeWidget: " + aData.id);
|
|
return null;
|
|
}
|
|
|
|
const kReqStringProps = ["id"];
|
|
for (let prop of kReqStringProps) {
|
|
if (typeof aData[prop] != "string") {
|
|
ERROR("Missing required property '" + prop + "' in normalizeWidget: "
|
|
+ aData.id);
|
|
return null;
|
|
}
|
|
widget[prop] = aData[prop];
|
|
}
|
|
|
|
const kOptStringProps = ["label", "tooltiptext", "shortcut"];
|
|
for (let prop of kOptStringProps) {
|
|
if (typeof aData[prop] == "string") {
|
|
widget[prop] = aData[prop];
|
|
}
|
|
}
|
|
|
|
const kOptBoolProps = ["removable", "showInPrivateBrowsing", "nooverflow"]
|
|
for (let prop of kOptBoolProps) {
|
|
if (typeof aData[prop] == "boolean") {
|
|
widget[prop] = aData[prop];
|
|
}
|
|
}
|
|
|
|
if (aData.defaultArea && gAreas.has(aData.defaultArea)) {
|
|
widget.defaultArea = aData.defaultArea;
|
|
}
|
|
|
|
if (Array.isArray(aData.allowedAreas)) {
|
|
widget.allowedAreas =
|
|
[area for (area of aData.allowedAreas) if (gAreas.has(area))];
|
|
}
|
|
|
|
if ("type" in aData && gSupportedWidgetTypes.has(aData.type)) {
|
|
widget.type = aData.type;
|
|
} else {
|
|
widget.type = "button";
|
|
}
|
|
|
|
widget.disabled = aData.disabled === true;
|
|
|
|
widget.onClick = typeof aData.onClick == "function" ? aData.onClick : null;
|
|
|
|
widget.onCreated = typeof aData.onCreated == "function" ? aData.onCreated : null;
|
|
|
|
if (widget.type == "button") {
|
|
widget.onCommand = typeof aData.onCommand == "function" ?
|
|
aData.onCommand :
|
|
null;
|
|
} else if (widget.type == "view") {
|
|
if (typeof aData.viewId != "string") {
|
|
ERROR("Expected a string for widget " + widget.id + " viewId, but got "
|
|
+ aData.viewId);
|
|
return null;
|
|
}
|
|
widget.viewId = aData.viewId;
|
|
|
|
widget.onViewShowing = typeof aData.onViewShowing == "function" ?
|
|
aData.onViewShowing :
|
|
null;
|
|
widget.onViewHiding = typeof aData.onViewHiding == "function" ?
|
|
aData.onViewHiding :
|
|
null;
|
|
} else if (widget.type == "custom") {
|
|
widget.onBuild = typeof aData.onBuild == "function" ?
|
|
aData.onBuild :
|
|
null;
|
|
}
|
|
|
|
if (gPalette.has(widget.id)) {
|
|
return null;
|
|
}
|
|
|
|
return widget;
|
|
},
|
|
|
|
destroyWidget: function(aWidgetId) {
|
|
let widget = gPalette.get(aWidgetId);
|
|
if (!widget) {
|
|
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.
|
|
|
|
let area = widget.currentArea;
|
|
let buildAreaNodes = area && gBuildAreas.get(area);
|
|
if (buildAreaNodes) {
|
|
for (let buildNode of buildAreaNodes) {
|
|
let widgetNode = buildNode.ownerDocument.getElementById(aWidgetId);
|
|
if (widgetNode) {
|
|
widgetNode.parentNode.removeChild(widgetNode);
|
|
}
|
|
if (widget.type == "view") {
|
|
let viewNode = buildNode.ownerDocument.getElementById(widget.viewId);
|
|
if (viewNode) {
|
|
for (let eventName of kSubviewEvents) {
|
|
let handler = "on" + eventName;
|
|
if (typeof widget[handler] == "function") {
|
|
viewNode.removeEventListener(eventName, widget[handler], false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
gPalette.delete(aWidgetId);
|
|
|
|
this.notifyListeners("onWidgetDestroyed", aWidgetId);
|
|
},
|
|
|
|
registerManifest: function(aBaseLocation, aData) {
|
|
let tokens = aData.split(/\s+/);
|
|
let directive = tokens.shift();
|
|
if (directive != "widget") {
|
|
return;
|
|
}
|
|
|
|
for (let [id, widget] of gPalette) {
|
|
if (widget.source == aBaseLocation.spec) {
|
|
return; // Already registered.
|
|
}
|
|
}
|
|
|
|
let uri = NetUtil.newURI(tokens.shift(), null, aBaseLocation);
|
|
|
|
dump("\tNew widget! " + uri.spec + "\n");
|
|
|
|
let data = "";
|
|
try {
|
|
if (uri.schemeIs("jar")) {
|
|
data = this.readManifestFromJar(uri);
|
|
} else {
|
|
data = this.readManifestFromFile(uri);
|
|
}
|
|
}
|
|
catch (e) {
|
|
ERROR(e);
|
|
return;
|
|
}
|
|
data = JSON.parse(data);
|
|
data.source = aBaseLocation.spec;
|
|
|
|
this.createWidget(data);
|
|
},
|
|
|
|
// readManifestFromJar and readManifestFromFile from ChromeManifestParser.jsm.
|
|
readManifestFromJar: function(aURI) {
|
|
let data = "";
|
|
let entries = [];
|
|
let readers = [];
|
|
|
|
try {
|
|
// Deconstrict URI, which can be nested jar: URIs.
|
|
let uri = aURI.clone();
|
|
while (uri instanceof Ci.nsIJARURI) {
|
|
entries.push(uri.JAREntry);
|
|
uri = uri.JARFile;
|
|
}
|
|
|
|
// Open the base jar.
|
|
let reader = Cc["@mozilla.org/libjar/zip-reader;1"]
|
|
.createInstance(Ci.nsIZipReader);
|
|
reader.open(uri.QueryInterface(Ci.nsIFileURL).file);
|
|
readers.push(reader);
|
|
|
|
// Open the nested jars.
|
|
for (let i = entries.length - 1; i > 0; i--) {
|
|
let innerReader = Cc["@mozilla.org/libjar/zip-reader;1"].
|
|
createInstance(Ci.nsIZipReader);
|
|
innerReader.openInner(reader, entries[i]);
|
|
readers.push(innerReader);
|
|
reader = innerReader;
|
|
}
|
|
|
|
// First entry is the actual file we want to read.
|
|
let zis = reader.getInputStream(entries[0]);
|
|
data = NetUtil.readInputStreamToString(zis, zis.available());
|
|
}
|
|
finally {
|
|
// Close readers in reverse order.
|
|
for (let i = readers.length - 1; i >= 0; i--) {
|
|
readers[i].close();
|
|
//XXXunf Don't think this is needed, but need to double check.
|
|
//flushJarCache(readers[i].file);
|
|
}
|
|
}
|
|
|
|
return data;
|
|
},
|
|
|
|
readManifestFromFile: function(aURI) {
|
|
let file = aURI.QueryInterface(Ci.nsIFileURL).file;
|
|
if (!file.exists() || !file.isFile()) {
|
|
return "";
|
|
}
|
|
|
|
let data = "";
|
|
let fis = Cc["@mozilla.org/network/file-input-stream;1"]
|
|
.createInstance(Ci.nsIFileInputStream);
|
|
try {
|
|
fis.init(file, -1, -1, false);
|
|
data = NetUtil.readInputStreamToString(fis, fis.available());
|
|
} finally {
|
|
fis.close();
|
|
}
|
|
return data;
|
|
},
|
|
|
|
getCustomizeTargetForArea: function(aArea, aWindow) {
|
|
let buildAreaNodes = gBuildAreas.get(aArea);
|
|
if (!buildAreaNodes) {
|
|
throw new Error("No build area nodes registered for " + aArea);
|
|
}
|
|
|
|
for (let node of buildAreaNodes) {
|
|
if (node.ownerDocument.defaultView === aWindow) {
|
|
return node.customizationTarget ? node.customizationTarget : node;
|
|
}
|
|
}
|
|
|
|
throw new Error("Could not find any window nodes for area " + aArea);
|
|
},
|
|
|
|
reset: function() {
|
|
gResetting = true;
|
|
Services.prefs.clearUserPref(kPrefCustomizationState);
|
|
LOG("State reset");
|
|
|
|
// Reset placements to make restoring default placements possible.
|
|
gPlacements = new Map();
|
|
// Clear the saved state to ensure that defaults will be used.
|
|
gSavedState = null;
|
|
// Restore the state for each area to its defaults
|
|
for (let [areaId,] of gAreas) {
|
|
this.restoreStateForArea(areaId);
|
|
}
|
|
|
|
// Rebuild each registered area (across windows) to reflect the state that
|
|
// was reset above.
|
|
for (let [areaId, areaNodes] of gBuildAreas) {
|
|
let placements = gPlacements.get(areaId);
|
|
for (let areaNode of areaNodes) {
|
|
this.buildArea(areaId, placements, areaNode);
|
|
}
|
|
}
|
|
gResetting = false;
|
|
},
|
|
|
|
_addParentFlex: function(aElement) {
|
|
// If necessary, add flex to accomodate new child.
|
|
let elementFlex = aElement.getAttribute("flex");
|
|
if (elementFlex) {
|
|
let parent = aElement.parentNode;
|
|
let parentFlex = +parent.getAttribute("flex") || 0;
|
|
elementFlex = +elementFlex || 0;
|
|
parent.setAttribute("flex", parentFlex + elementFlex);
|
|
}
|
|
},
|
|
|
|
_removeParentFlex: function(aElement) {
|
|
if (aElement.parentNode.hasAttribute("flex") && aElement.hasAttribute("flex")) {
|
|
let parent = aElement.parentNode;
|
|
let parentFlex = parseInt(parent.getAttribute("flex"), 10);
|
|
let elementFlex = parseInt(aElement.getAttribute("flex"), 10);
|
|
parent.setAttribute("flex", Math.max(0, parentFlex - elementFlex));
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @param {String|Node} aWidget - widget ID or a widget node (preferred for performance).
|
|
* @return {Boolean} whether the widget is removable
|
|
*/
|
|
isWidgetRemovable: function(aWidget) {
|
|
let widgetId;
|
|
let widgetNode;
|
|
if (typeof aWidget == "string") {
|
|
widgetId = aWidget;
|
|
} else {
|
|
widgetId = aWidget.id;
|
|
widgetNode = aWidget;
|
|
}
|
|
let provider = this.getWidgetProvider(widgetId);
|
|
|
|
if (provider == CustomizableUI.PROVIDER_API) {
|
|
return gPalette.get(widgetId).removable;
|
|
}
|
|
|
|
if (provider == CustomizableUI.PROVIDER_XUL) {
|
|
if (gBuildWindows.size == 0) {
|
|
// We don't have any build windows to look at, so just assume for now
|
|
// that its removable.
|
|
return true;
|
|
}
|
|
|
|
if (!widgetNode) {
|
|
// Pick any of the build windows to look at.
|
|
let [window,] = [...gBuildWindows][0];
|
|
[, widgetNode] = this.getWidgetNode(widgetId, window);
|
|
}
|
|
// If we don't have a node, we assume it's removable. This can happen because
|
|
// getWidgetProvider returns PROVIDER_XUL by default, but this will also happen
|
|
// for API-provided widgets which have been destroyed.
|
|
if (!widgetNode) {
|
|
return true;
|
|
}
|
|
return widgetNode.getAttribute("removable") == "true";
|
|
}
|
|
|
|
// Otherwise this is a special widget, which are always removable.
|
|
return true;
|
|
},
|
|
|
|
canWidgetMoveToArea: function(aWidgetId, aArea) {
|
|
let placement = this.getPlacementOfWidget(aWidgetId);
|
|
if (placement && placement.area != aArea &&
|
|
!this.isWidgetRemovable(aWidgetId)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
get inDefaultState() {
|
|
for (let [areaId, props] of gAreas) {
|
|
let defaultPlacements = props.get("defaultPlacements");
|
|
// Areas without default placements (like legacy ones?) get skipped
|
|
if (!defaultPlacements) {
|
|
continue;
|
|
}
|
|
|
|
let currentPlacements = gPlacements.get(areaId);
|
|
// We're excluding all of the placement IDs for items that do not exist,
|
|
// because we don't want to consider them when determining if we're
|
|
// in the default state. This way, if an add-on introduces a widget
|
|
// and is then uninstalled, the leftover placement doesn't cause us to
|
|
// automatically assume that the buttons are not in the default state.
|
|
let buildAreaNodes = gBuildAreas.get(areaId);
|
|
if (buildAreaNodes && buildAreaNodes.size) {
|
|
let container = [...buildAreaNodes][0];
|
|
// Clone the array so we don't modify the actual placements...
|
|
currentPlacements = [...currentPlacements];
|
|
// Loop backwards through the placements so we can easily remove items:
|
|
let itemIndex = currentPlacements.length;
|
|
while (itemIndex--) {
|
|
if (!container.querySelector(idToSelector(currentPlacements[itemIndex]))) {
|
|
currentPlacements.splice(itemIndex, 1);
|
|
}
|
|
}
|
|
}
|
|
LOG("Checking default state for " + areaId + ":\n" + currentPlacements.join("\n") +
|
|
" vs. " + defaultPlacements.join("\n"));
|
|
|
|
if (currentPlacements.length != defaultPlacements.length) {
|
|
return false;
|
|
}
|
|
|
|
for (let i = 0; i < currentPlacements.length; ++i) {
|
|
if (currentPlacements[i] != defaultPlacements[i]) {
|
|
LOG("Found " + currentPlacements[i] + " in " + areaId + " where " +
|
|
defaultPlacements[i] + " was expected!");
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
};
|
|
Object.freeze(CustomizableUIInternal);
|
|
|
|
this.CustomizableUI = {
|
|
get AREA_PANEL() "PanelUI-contents",
|
|
get AREA_NAVBAR() "nav-bar",
|
|
get AREA_MENUBAR() "toolbar-menubar",
|
|
get AREA_TABSTRIP() "TabsToolbar",
|
|
get AREA_BOOKMARKS() "PersonalToolbar",
|
|
get AREA_ADDONBAR() "addon-bar",
|
|
|
|
get PROVIDER_XUL() "xul",
|
|
get PROVIDER_API() "api",
|
|
get PROVIDER_SPECIAL() "special",
|
|
|
|
get SOURCE_BUILTIN() "builtin",
|
|
get SOURCE_EXTERNAL() "external",
|
|
|
|
get TYPE_BUTTON() "button",
|
|
get TYPE_MENU_PANEL() "menu-panel",
|
|
get TYPE_TOOLBAR() "toolbar",
|
|
|
|
addListener: function(aListener) {
|
|
CustomizableUIInternal.addListener(aListener);
|
|
},
|
|
removeListener: function(aListener) {
|
|
CustomizableUIInternal.removeListener(aListener);
|
|
},
|
|
registerArea: function(aName, aProperties) {
|
|
CustomizableUIInternal.registerArea(aName, aProperties);
|
|
},
|
|
//XXXunf registerToolbarNode / registerToolbarInstance ?
|
|
registerToolbar: function(aToolbar) {
|
|
CustomizableUIInternal.registerToolbar(aToolbar);
|
|
},
|
|
registerMenuPanel: function(aPanel) {
|
|
CustomizableUIInternal.registerMenuPanel(aPanel);
|
|
},
|
|
unregisterArea: function(aName) {
|
|
CustomizableUIInternal.unregisterArea(aName);
|
|
},
|
|
addWidgetToArea: function(aWidgetId, aArea, aPosition) {
|
|
CustomizableUIInternal.addWidgetToArea(aWidgetId, aArea, aPosition);
|
|
},
|
|
removeWidgetFromArea: function(aWidgetId) {
|
|
CustomizableUIInternal.removeWidgetFromArea(aWidgetId);
|
|
},
|
|
moveWidgetWithinArea: function(aWidgetId, aPosition) {
|
|
CustomizableUIInternal.moveWidgetWithinArea(aWidgetId, aPosition);
|
|
},
|
|
beginBatchUpdate: function() {
|
|
CustomizableUIInternal.beginBatchUpdate();
|
|
},
|
|
endBatchUpdate: function(aForceSave) {
|
|
CustomizableUIInternal.endBatchUpdate(aForceSave);
|
|
},
|
|
createWidget: function(aProperties) {
|
|
return CustomizableUIInternal.wrapWidget(
|
|
CustomizableUIInternal.createWidget(aProperties)
|
|
);
|
|
},
|
|
destroyWidget: function(aWidgetId) {
|
|
CustomizableUIInternal.destroyWidget(aWidgetId);
|
|
},
|
|
getWidget: function(aWidgetId) {
|
|
return CustomizableUIInternal.wrapWidget(aWidgetId);
|
|
},
|
|
getUnusedWidgets: function(aWindowPalette) {
|
|
return CustomizableUIInternal.getUnusedWidgets(aWindowPalette).map(
|
|
CustomizableUIInternal.wrapWidget,
|
|
CustomizableUIInternal
|
|
);
|
|
},
|
|
getWidgetIdsInArea: function(aArea) {
|
|
if (!gAreas.has(aArea)) {
|
|
throw new Error("Unknown customization area: " + aArea);
|
|
}
|
|
if (!gPlacements.has(aArea)) {
|
|
throw new Error("Area not yet restored");
|
|
}
|
|
|
|
return gPlacements.get(aArea);
|
|
},
|
|
getWidgetsInArea: function(aArea) {
|
|
return this.getWidgetIdsInArea(aArea).map(
|
|
CustomizableUIInternal.wrapWidget,
|
|
CustomizableUIInternal
|
|
);
|
|
},
|
|
get areas() {
|
|
return [area for ([area, props] of gAreas)];
|
|
},
|
|
getCustomizeTargetForArea: function(aArea, aWindow) {
|
|
return CustomizableUIInternal.getCustomizeTargetForArea(aArea, aWindow);
|
|
},
|
|
reset: function() {
|
|
CustomizableUIInternal.reset();
|
|
},
|
|
getPlacementOfWidget: function(aWidgetId) {
|
|
return CustomizableUIInternal.getPlacementOfWidget(aWidgetId);
|
|
},
|
|
isWidgetRemovable: function(aWidgetId) {
|
|
return CustomizableUIInternal.isWidgetRemovable(aWidgetId);
|
|
},
|
|
canWidgetMoveToArea: function(aWidgetId, aArea) {
|
|
return CustomizableUIInternal.canWidgetMoveToArea(aWidgetId, aArea);
|
|
},
|
|
get inDefaultState() {
|
|
return CustomizableUIInternal.inDefaultState;
|
|
},
|
|
getLocalizedProperty: function(aWidget, aProp, aFormatArgs, aDef) {
|
|
return CustomizableUIInternal.getLocalizedProperty(aWidget, aProp,
|
|
aFormatArgs, aDef);
|
|
},
|
|
hidePanelForNode: function(aNode) {
|
|
CustomizableUIInternal.hidePanelForNode(aNode);
|
|
},
|
|
isSpecialWidget: function(aWidgetId) {
|
|
return CustomizableUIInternal.isSpecialWidget(aWidgetId);
|
|
},
|
|
addPanelCloseListeners: function(aPanel) {
|
|
CustomizableUIInternal.addPanelCloseListeners(aPanel);
|
|
},
|
|
removePanelCloseListeners: function(aPanel) {
|
|
CustomizableUIInternal.removePanelCloseListeners(aPanel);
|
|
},
|
|
};
|
|
Object.freeze(this.CustomizableUI);
|
|
|
|
|
|
/**
|
|
* All external consumers of widgets are really interacting with these wrappers
|
|
* which provide a common interface.
|
|
*/
|
|
|
|
/**
|
|
* WidgetGroupWrapper is the common interface for interacting with an entire
|
|
* widget group - AKA, all instances of a widget across a series of windows.
|
|
* This particular wrapper is only used for widgets created via the provider
|
|
* API.
|
|
*/
|
|
function WidgetGroupWrapper(aWidget) {
|
|
this.isGroup = true;
|
|
|
|
const kBareProps = ["id", "source", "type", "disabled", "label", "tooltiptext",
|
|
"showInPrivateBrowsing"];
|
|
for (let prop of kBareProps) {
|
|
let propertyName = prop;
|
|
this.__defineGetter__(propertyName, function() aWidget[propertyName]);
|
|
}
|
|
|
|
this.__defineGetter__("provider", function() CustomizableUI.PROVIDER_API);
|
|
|
|
this.__defineSetter__("disabled", function(aValue) {
|
|
aValue = !!aValue;
|
|
aWidget.disabled = aValue;
|
|
for (let [,instance] of aWidget.instances) {
|
|
instance.disabled = aValue;
|
|
}
|
|
});
|
|
|
|
this.forWindow = function WidgetGroupWrapper_forWindow(aWindow) {
|
|
let instance = aWidget.instances.get(aWindow.document);
|
|
if (!instance) {
|
|
instance = CustomizableUIInternal.buildWidget(aWindow.document,
|
|
aWidget);
|
|
}
|
|
|
|
let wrapper = gWrapperCache.get(instance);
|
|
if (!wrapper) {
|
|
wrapper = new WidgetSingleWrapper(aWidget, instance);
|
|
gWrapperCache.set(instance, wrapper);
|
|
}
|
|
return wrapper;
|
|
};
|
|
|
|
Object.freeze(this);
|
|
}
|
|
|
|
/**
|
|
* A WidgetSingleWrapper is a wrapper around a single instance of a widget in
|
|
* a particular window.
|
|
*/
|
|
function WidgetSingleWrapper(aWidget, aNode) {
|
|
this.isGroup = false;
|
|
|
|
this.node = aNode;
|
|
this.provider = CustomizableUI.PROVIDER_API;
|
|
|
|
const kGlobalProps = ["id", "type"];
|
|
for (let prop of kGlobalProps) {
|
|
this[prop] = aWidget[prop];
|
|
}
|
|
|
|
const nodeProps = ["label", "tooltiptext"];
|
|
for (let prop of nodeProps) {
|
|
let propertyName = prop;
|
|
// Look at the node for these, instead of the widget data, to ensure the
|
|
// wrapper always reflects this live instance.
|
|
this.__defineGetter__(propertyName,
|
|
function() aNode.getAttribute(propertyName));
|
|
}
|
|
|
|
this.__defineGetter__("disabled", function() aNode.disabled);
|
|
this.__defineSetter__("disabled", function(aValue) {
|
|
aNode.disabled = !!aValue;
|
|
});
|
|
|
|
this.__defineGetter__("anchor", function() {
|
|
let anchorId = aNode.getAttribute("customizableui-anchorid");
|
|
return anchorId ? aNode.ownerDocument.getElementById(anchorId)
|
|
: aNode;
|
|
});
|
|
|
|
this.__defineGetter__("areaType", function() {
|
|
return aNode.getAttribute("customizableui-areatype") || "";
|
|
});
|
|
|
|
this.__defineGetter__("overflowed", function() {
|
|
return aNode.classList.contains("overflowedItem");
|
|
});
|
|
|
|
Object.freeze(this);
|
|
}
|
|
|
|
/**
|
|
* XULWidgetGroupWrapper is the common interface for interacting with an entire
|
|
* widget group - AKA, all instances of a widget across a series of windows.
|
|
* This particular wrapper is only used for widgets created via the old-school
|
|
* XUL method (overlays, or programmatically injecting toolbaritems, or other
|
|
* such things).
|
|
*/
|
|
//XXXunf Going to need to hook this up to some events to keep it all live.
|
|
function XULWidgetGroupWrapper(aWidgetId) {
|
|
this.isGroup = true;
|
|
|
|
let nodes = [];
|
|
|
|
let placement = CustomizableUIInternal.getPlacementOfWidget(aWidgetId);
|
|
if (placement) {
|
|
let buildAreas = gBuildAreas.get(placement.area) || [];
|
|
for (let areaNode of buildAreas)
|
|
nodes.push(areaNode.ownerDocument.getElementById(aWidgetId));
|
|
}
|
|
|
|
this.id = aWidgetId;
|
|
this.type = "custom";
|
|
this.provider = CustomizableUI.PROVIDER_XUL;
|
|
|
|
this.forWindow = function XULWidgetGroupWrapper_forWindow(aWindow) {
|
|
let instance = aWindow.document.getElementById(aWidgetId);
|
|
if (!instance) {
|
|
// Toolbar palettes aren't part of the document, so elements in there
|
|
// won't be found via document.getElementById().
|
|
instance = aWindow.gNavToolbox.palette.querySelector(idToSelector(aWidgetId));
|
|
}
|
|
|
|
let wrapper = gWrapperCache.get(instance);
|
|
if (!wrapper) {
|
|
wrapper = new XULWidgetSingleWrapper(aWidgetId, instance);
|
|
gWrapperCache.set(instance, wrapper);
|
|
}
|
|
return wrapper;
|
|
};
|
|
|
|
Object.freeze(this);
|
|
}
|
|
|
|
/**
|
|
* A XULWidgetSingleWrapper is a wrapper around a single instance of a XUL
|
|
* widget in a particular window.
|
|
*/
|
|
function XULWidgetSingleWrapper(aWidgetId, aNode) {
|
|
this.isGroup = false;
|
|
|
|
this.id = aWidgetId;
|
|
this.type = "custom";
|
|
this.provider = CustomizableUI.PROVIDER_XUL;
|
|
|
|
this.node = aNode;
|
|
|
|
this.__defineGetter__("anchor", function() {
|
|
let anchorId = aNode.getAttribute("customizableui-anchorid");
|
|
return anchorId ? aNode.ownerDocument.getElementById(anchorId)
|
|
: aNode;
|
|
});
|
|
|
|
this.__defineGetter__("areaType", function() {
|
|
return aNode.getAttribute("customizableui-areatype") || "";
|
|
});
|
|
|
|
this.__defineGetter__("overflowed", function() {
|
|
return aNode.classList.contains("overflowedItem");
|
|
});
|
|
|
|
Object.freeze(this);
|
|
}
|
|
|
|
const LAZY_RESIZE_INTERVAL_MS = 200;
|
|
|
|
function OverflowableToolbar(aToolbarNode) {
|
|
this._toolbar = aToolbarNode;
|
|
this._collapsed = [];
|
|
this._enabled = true;
|
|
|
|
this._toolbar.setAttribute("overflowable", "true");
|
|
Services.obs.addObserver(this, "browser-delayed-startup-finished", false);
|
|
}
|
|
|
|
OverflowableToolbar.prototype = {
|
|
observe: function(aSubject, aTopic, aData) {
|
|
if (aTopic == "browser-delayed-startup-finished" &&
|
|
aSubject == this._toolbar.ownerDocument.defaultView) {
|
|
Services.obs.removeObserver(this, "browser-delayed-startup-finished");
|
|
this.init();
|
|
}
|
|
},
|
|
|
|
init: function() {
|
|
this._target = this._toolbar.customizationTarget;
|
|
let doc = this._toolbar.ownerDocument;
|
|
this._list = doc.getElementById(this._toolbar.getAttribute("overflowtarget"));
|
|
this._toolbar.customizationTarget.addEventListener("overflow", this);
|
|
|
|
let window = doc.defaultView;
|
|
window.addEventListener("resize", this);
|
|
window.gNavToolbox.addEventListener("customizationstarting", this);
|
|
window.gNavToolbox.addEventListener("aftercustomization", this);
|
|
|
|
let chevronId = this._toolbar.getAttribute("overflowbutton");
|
|
this._chevron = doc.getElementById(chevronId);
|
|
this._chevron.addEventListener("command", this);
|
|
|
|
this._panel = doc.getElementById("widget-overflow");
|
|
this._panel.addEventListener("popuphiding", this);
|
|
CustomizableUIInternal.addPanelCloseListeners(this._panel);
|
|
|
|
this.initialized = true;
|
|
|
|
// The toolbar could initialize in an overflowed state, in which case
|
|
// the 'overflow' event may have been fired before the handler was registered.
|
|
this._onOverflow();
|
|
},
|
|
|
|
uninit: function() {
|
|
if (!this.initialized) {
|
|
return;
|
|
}
|
|
this._disable();
|
|
|
|
this._toolbar.removeAttribute("overflowable");
|
|
this._toolbar.customizationTarget.removeEventListener("overflow", this);
|
|
let window = this._toolbar.ownerDocument.defaultView;
|
|
window.removeEventListener("resize", this);
|
|
window.gNavToolbox.removeEventListener("customizationstarting", this);
|
|
window.gNavToolbox.removeEventListener("aftercustomization", this);
|
|
this._chevron.removeEventListener("command", this);
|
|
this._panel.removeEventListener("popuphiding", this);
|
|
CustomizableUIInternal.removePanelCloseListeners(this._panel);
|
|
},
|
|
|
|
handleEvent: function(aEvent) {
|
|
switch(aEvent.type) {
|
|
case "overflow":
|
|
this._onOverflow();
|
|
break;
|
|
case "resize":
|
|
this._onResize(aEvent);
|
|
break;
|
|
case "command":
|
|
this._onClickChevron(aEvent);
|
|
break;
|
|
case "popuphiding":
|
|
this._onPanelHiding(aEvent);
|
|
break;
|
|
case "customizationstarting":
|
|
this._disable();
|
|
break;
|
|
case "aftercustomization":
|
|
this._enable();
|
|
break;
|
|
}
|
|
},
|
|
|
|
_onClickChevron: function(aEvent) {
|
|
if (this._chevron.open)
|
|
this._panel.hidePopup();
|
|
else {
|
|
let doc = aEvent.target.ownerDocument;
|
|
this._panel.hidden = false;
|
|
let anchor = doc.getAnonymousElementByAttribute(this._chevron, "class", "toolbarbutton-icon");
|
|
this._panel.openPopup(anchor || this._chevron, "bottomcenter topright");
|
|
}
|
|
this._chevron.open = !this._chevron.open;
|
|
},
|
|
|
|
_onPanelHiding: function(aEvent) {
|
|
this._chevron.open = false;
|
|
},
|
|
|
|
_onOverflow: function() {
|
|
if (!this._enabled)
|
|
return;
|
|
|
|
let child = this._target.lastChild;
|
|
|
|
while(child && this._target.clientWidth < this._target.scrollWidth) {
|
|
let prevChild = child.previousSibling;
|
|
|
|
if (!child.hasAttribute("nooverflow")) {
|
|
this._collapsed.push({child: child, minSize: this._target.clientWidth});
|
|
child.classList.add("overflowedItem");
|
|
child.setAttribute("customizableui-anchorid", this._chevron.id);
|
|
|
|
this._list.insertBefore(child, this._list.firstChild);
|
|
this._toolbar.setAttribute("overflowing", "true");
|
|
}
|
|
child = prevChild;
|
|
};
|
|
},
|
|
|
|
_onResize: function(aEvent) {
|
|
if (!this._lazyResizeHandler) {
|
|
this._lazyResizeHandler = new DeferredTask(this._onLazyResize.bind(this),
|
|
LAZY_RESIZE_INTERVAL_MS);
|
|
}
|
|
this._lazyResizeHandler.start();
|
|
},
|
|
|
|
_moveItemsBackToTheirOrigin: function(shouldMoveAllItems) {
|
|
for (let i = this._collapsed.length - 1; i >= 0; i--) {
|
|
let {child, minSize} = this._collapsed[i];
|
|
|
|
if (!shouldMoveAllItems &&
|
|
this._target.clientWidth <= minSize) {
|
|
return;
|
|
}
|
|
|
|
this._collapsed.pop();
|
|
this._target.appendChild(child);
|
|
child.removeAttribute("customizableui-anchorid");
|
|
child.classList.remove("overflowedItem");
|
|
}
|
|
|
|
if (!this._collapsed.length) {
|
|
this._toolbar.removeAttribute("overflowing");
|
|
}
|
|
},
|
|
|
|
_onLazyResize: function() {
|
|
if (!this._enabled)
|
|
return;
|
|
|
|
this._moveItemsBackToTheirOrigin();
|
|
},
|
|
|
|
_disable: function() {
|
|
this._enabled = false;
|
|
this._moveItemsBackToTheirOrigin(true);
|
|
if (this._lazyResizeHandler) {
|
|
this._lazyResizeHandler.cancel();
|
|
}
|
|
},
|
|
|
|
_enable: function() {
|
|
this._enabled = true;
|
|
this._onOverflow();
|
|
}
|
|
};
|
|
|
|
// When IDs contain special characters, we need to escape them for use with querySelector:
|
|
function idToSelector(aId) {
|
|
return "#" + aId.replace(/[ !"'#$%&\(\)*+\-,.\/:;<=>?@\[\\\]^`{|}~]/g, '\\$&');
|
|
}
|
|
|
|
CustomizableUIInternal.initialize();
|