1
0
Fork 0
firefox/browser/components/customizableui/CustomizeMode.sys.mjs
Daniel Baumann 5e9a113729
Adding upstream version 140.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
2025-06-25 09:37:52 +02:00

4007 lines
133 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/. */
const kPrefCustomizationDebug = "browser.uiCustomization.debug";
const kPaletteId = "customization-palette";
const kDragDataTypePrefix = "text/toolbarwrapper-id/";
const kSkipSourceNodePref = "browser.uiCustomization.skipSourceNodeCheck";
const kDrawInTitlebarPref = "browser.tabs.inTitlebar";
const kCompactModeShowPref = "browser.compactmode.show";
const kBookmarksToolbarPref = "browser.toolbars.bookmarks.visibility";
const kKeepBroadcastAttributes = "keepbroadcastattributeswhencustomizing";
const kPanelItemContextMenu = "customizationPanelItemContextMenu";
const kPaletteItemContextMenu = "customizationPaletteItemContextMenu";
const kDownloadAutohideCheckboxId = "downloads-button-autohide-checkbox";
const kDownloadAutohidePanelId = "downloads-button-autohide-panel";
const kDownloadAutoHidePref = "browser.download.autohideButton";
import { CustomizableUI } from "resource:///modules/CustomizableUI.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.sys.mjs",
DragPositionManager: "resource:///modules/DragPositionManager.sys.mjs",
URILoadingHelper: "resource:///modules/URILoadingHelper.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "gWidgetsBundle", function () {
const kUrl =
"chrome://browser/locale/customizableui/customizableWidgets.properties";
return Services.strings.createBundle(kUrl);
});
XPCOMUtils.defineLazyServiceGetter(
lazy,
"gTouchBarUpdater",
"@mozilla.org/widget/touchbarupdater;1",
"nsITouchBarUpdater"
);
let gDebug;
ChromeUtils.defineLazyGetter(lazy, "log", () => {
let { ConsoleAPI } = ChromeUtils.importESModule(
"resource://gre/modules/Console.sys.mjs"
);
gDebug = Services.prefs.getBoolPref(kPrefCustomizationDebug, false);
let consoleOptions = {
maxLogLevel: gDebug ? "all" : "log",
prefix: "CustomizeMode",
};
return new ConsoleAPI(consoleOptions);
});
var gDraggingInToolbars;
var gTab;
function closeGlobalTab() {
let win = gTab.ownerGlobal;
if (win.gBrowser.browsers.length == 1) {
win.BrowserCommands.openTab();
}
win.gBrowser.removeTab(gTab, { animate: true });
gTab = null;
}
var gTabsProgressListener = {
onLocationChange(aBrowser, aWebProgress, aRequest, aLocation) {
// Tear down customize mode when the customize mode tab loads some other page.
// Customize mode will be re-entered if "about:blank" is loaded again, so
// don't tear down in this case.
if (
!gTab ||
gTab.linkedBrowser != aBrowser ||
aLocation.spec == "about:blank"
) {
return;
}
unregisterGlobalTab();
},
};
function unregisterGlobalTab() {
gTab.removeEventListener("TabClose", unregisterGlobalTab);
let win = gTab.ownerGlobal;
win.removeEventListener("unload", unregisterGlobalTab);
win.gBrowser.removeTabsProgressListener(gTabsProgressListener);
gTab.removeAttribute("customizemode");
gTab = null;
}
/**
* This class manages the lifetime of the CustomizeMode UI in a single browser
* window. There is one instance of CustomizeMode per browser window.
*/
export class CustomizeMode {
constructor(aWindow) {
this.#window = aWindow;
this.#document = aWindow.document;
this.#browser = aWindow.gBrowser;
this.areas = new Set();
this.#translationObserver = new aWindow.MutationObserver(mutations =>
this.#onTranslations(mutations)
);
this.#ensureCustomizationPanels();
let content = this.$("customization-content-container");
if (!content) {
this.#window.MozXULElement.insertFTLIfNeeded("browser/customizeMode.ftl");
let container = this.$("customization-container");
container.replaceChild(
this.#window.MozXULElement.parseXULToFragment(
container.firstChild.data
),
container.lastChild
);
}
this.#attachEventListeners();
// There are two palettes - there's the palette that can be overlayed with
// toolbar items in browser.xhtml. This is invisible, and never seen by the
// user. Then there's the visible palette, which gets populated and displayed
// to the user when in customizing mode.
this.visiblePalette = this.$(kPaletteId);
this.pongArena = this.$("customization-pong-arena");
if (this.#canDrawInTitlebar()) {
this.#updateTitlebarCheckbox();
Services.prefs.addObserver(kDrawInTitlebarPref, this);
} else {
this.$("customization-titlebar-visibility-checkbox").hidden = true;
}
// Observe pref changes to the bookmarks toolbar visibility,
// since we won't get a toolbarvisibilitychange event if the
// toolbar is changing from 'newtab' to 'always' in Customize mode
// since the toolbar is shown with the 'newtab' setting.
Services.prefs.addObserver(kBookmarksToolbarPref, this);
this.#window.addEventListener("unload", this);
}
/**
* True if CustomizeMode is in the process of being transitioned into or out
* of.
*
* @type {boolean}
*/
#transitioning = false;
/**
* A reference to the top-level browser window that this instance of
* CustomizeMode is configured to use.
*
* @type {DOMWindow}
*/
#window = null;
/**
* A reference to the top-level browser window document that this instance of
* CustomizeMode is configured to use.
*
* @type {Document}
*/
#document = null;
/**
* A reference to the Tabbrowser instance that belongs to the top-level
* browser window that CustomizeMode is configured to use.
*
* @type {Tabbrowser}
*/
#browser = null;
/**
* An array of customize target DOM nodes that this instance of CustomizeMode
* can be used to manipulate. It is assumed that when targets are in this
* Set, that they have drag / drop listeners attached and that their
* customizable children have been wrapped as toolbarpaletteitems.
*
* @type {null|Set<DOMNode>}
*/
areas = null;
/**
* When in customizing mode, we swap out the reference to the invisible
* palette in gNavToolbox.palette for our visiblePalette. This way, for the
* customizing browser window, when widgets are removed from customizable
* areas and added to the palette, they're added to the visible palette.
* #stowedPalette is a reference to the old invisible palette so we can
* restore gNavToolbox.palette to its original state after exiting
* customization mode.
*
* @type {null|DOMNode}
*/
#stowedPalette = null;
/**
* If a drag and drop operation is underway for a customizable toolbar item,
* this member is set to the current item being dragged over.
*
* @type {null|DOMNode}
*/
#dragOverItem = null;
/**
* True if we're in the state of customizing this browser window.
*
* @type {boolean}
*/
#customizing = false;
/**
* True if we're synthesizing drag and drop in customize mode for automated
* tests and want to skip the checks that ensure that the source of the
* drag events was this top-level browser window. This is controllable via
* `browser.uiCustomization.skipSourceNodeCheck`.
*
* @type {boolean}
*/
#skipSourceNodeCheck = false;
/**
* These are the commands we continue to leave enabled while in customize
* mode. All other commands are disabled, and we remove the disabled attribute
* when leaving customize mode.
*
* @type {Set<string>}
*/
#enabledCommands = new Set([
"cmd_newNavigator",
"cmd_newNavigatorTab",
"cmd_newNavigatorTabNoEvent",
"cmd_close",
"cmd_closeWindow",
"cmd_maximizeWindow",
"cmd_minimizeWindow",
"cmd_restoreWindow",
"cmd_quitApplication",
"View:FullScreen",
"Browser:NextTab",
"Browser:PrevTab",
"Browser:NewUserContextTab",
"Tools:PrivateBrowsing",
"zoomWindow",
]);
/**
* A MutationObserver used to hear about Fluent localization occurring for
* customizable items.
*
* @type {MutationObserver|null}
*/
#translationObserver = null;
/**
* A description of the size of a customizable item were it to be placed in a
* particular customizable area.
*
* @typedef {object} ItemSizeForArea
* @property {number} width
* The width of the customizable item when placed in a certain area.
* @property {number} height
* The height of the customizable item when placed in a certain area.
*/
/**
* A WeakMap mapping dragged customizable items to a WeakMap of areas that
* the item could be dragged into. That mapping maps to ItemSizeForArea
* objects that describe the width and height of the item were it to be
* dropped and placed within that area (since certain areas will encourage
* items to expand or contract).
*
* @type {WeakMap<DOMNode, WeakMap<DOMNode, DragSizeForArea>>|null}
*/
#dragSizeMap = null;
/**
* If this is set to true, this means that the user enabled the downloads
* button auto-hide feature while the button was in the palette. If so, then
* on exiting mode, the button is moved to its default position in the
* navigation toolbar.
*/
#moveDownloadsButtonToNavBar = false;
/**
* Returns the CustomizationHandler browser window global object. See
* browser-customization.js.
*
* @type {object}
*/
get #handler() {
return this.#window.CustomizationHandler;
}
/**
* Does cleanup of any listeners or observers when the browser window
* that this CustomizeMode instance is configured to use unloads.
*/
#uninit() {
if (this.#canDrawInTitlebar()) {
Services.prefs.removeObserver(kDrawInTitlebarPref, this);
}
Services.prefs.removeObserver(kBookmarksToolbarPref, this);
}
/**
* This is a shortcut for this.#document.getElementById.
*
* @param {string} id
* The DOM ID to return a result for.
* @returns {DOMNode|null}
*/
$(id) {
return this.#document.getElementById(id);
}
/**
* Sets the fake tab element that will be associated with being in
* customize mode. Customize mode looks similar to a "special kind of tab",
* and when that tab is closed, we exit customize mode. When that tab is
* switched to, we enter customize mode. If that tab is restored in the
* foreground, we enter customize mode.
*
* This method assigns the special <xul:tab> that will represent customize
* mode for this window, and sets up the relevant listeners to it. The
* tab will have a "customizemode" attribute set to "true" on it, as well as
* a special favicon.
*
* @param {MozTabbrowserTab} aTab
* The tab to act as the tab representation of customize mode.
*/
setTab(aTab) {
if (gTab == aTab) {
return;
}
if (gTab) {
closeGlobalTab();
}
gTab = aTab;
gTab.setAttribute("customizemode", "true");
if (gTab.linkedPanel) {
gTab.linkedBrowser.stop();
}
let win = gTab.ownerGlobal;
win.gBrowser.setTabTitle(gTab);
win.gBrowser.setIcon(gTab, "chrome://browser/skin/customize.svg");
gTab.addEventListener("TabClose", unregisterGlobalTab);
win.gBrowser.addTabsProgressListener(gTabsProgressListener);
win.addEventListener("unload", unregisterGlobalTab);
if (gTab.selected) {
win.gCustomizeMode.enter();
}
}
/**
* Kicks off the process of entering customize mode for the window that this
* CustomizeMode instance was constructed with. If this window happens to be
* a popup window or web app window, the opener window will enter customize mode.
*
* Entering customize mode is a multistep asynchronous operation, but this
* method returns immediately while this operation is underway. A
* `customizationready` custom event is dispatched on the gNavToolbox when
* this asynchronous process has completed.
*
* This method will return early if customize mode is already active in this
* window.
*/
enter() {
if (
!this.#window.toolbar.visible ||
this.#window.document.documentElement.hasAttribute("taskbartab")
) {
let w = lazy.URILoadingHelper.getTargetWindow(this.#window, {
skipPopups: true,
skipTaskbarTabs: true,
});
if (w) {
w.gCustomizeMode.enter();
return;
}
let obs = () => {
Services.obs.removeObserver(obs, "browser-delayed-startup-finished");
w = lazy.URILoadingHelper.getTargetWindow(this.#window, {
skipPopups: true,
skipTaskbarTabs: true,
});
w.gCustomizeMode.enter();
};
Services.obs.addObserver(obs, "browser-delayed-startup-finished");
this.#window.openTrustedLinkIn("about:newtab", "window");
return;
}
this._wantToBeInCustomizeMode = true;
if (this.#customizing || this.#handler.isEnteringCustomizeMode) {
return;
}
// Exiting; want to re-enter once we've done that.
if (this.#handler.isExitingCustomizeMode) {
lazy.log.debug(
"Attempted to enter while we're in the middle of exiting. " +
"We'll exit after we've entered"
);
return;
}
if (!gTab) {
this.setTab(
this.#browser.addTab("about:blank", {
inBackground: false,
forceNotRemote: true,
skipAnimation: true,
triggeringPrincipal:
Services.scriptSecurityManager.getSystemPrincipal(),
})
);
return;
}
if (!gTab.selected) {
// This will force another .enter() to be called via the
// onlocationchange handler of the tabbrowser, so we return early.
gTab.ownerGlobal.gBrowser.selectedTab = gTab;
return;
}
gTab.ownerGlobal.focus();
if (gTab.ownerDocument != this.#document) {
return;
}
let window = this.#window;
let document = this.#document;
this.#handler.isEnteringCustomizeMode = true;
// Always disable the reset button at the start of customize mode, it'll be re-enabled
// if necessary when we finish entering:
let resetButton = this.$("customization-reset-button");
resetButton.setAttribute("disabled", "true");
(async () => {
// We shouldn't start customize mode until after browser-delayed-startup has finished:
if (!this.#window.gBrowserInit.delayedStartupFinished) {
await new Promise(resolve => {
let delayedStartupObserver = aSubject => {
if (aSubject == this.#window) {
Services.obs.removeObserver(
delayedStartupObserver,
"browser-delayed-startup-finished"
);
resolve();
}
};
Services.obs.addObserver(
delayedStartupObserver,
"browser-delayed-startup-finished"
);
});
}
CustomizableUI.dispatchToolboxEvent("beforecustomization", {}, window);
CustomizableUI.notifyStartCustomizing(this.#window);
// Add a keypress listener to the document so that we can quickly exit
// customization mode when pressing ESC.
document.addEventListener("keypress", this);
// Same goes for the menu button - if we're customizing, a click on the
// menu button means a quick exit from customization mode.
window.PanelUI.hide();
let panelHolder = document.getElementById("customization-panelHolder");
let panelContextMenu = document.getElementById(kPanelItemContextMenu);
this._previousPanelContextMenuParent = panelContextMenu.parentNode;
document.getElementById("mainPopupSet").appendChild(panelContextMenu);
panelHolder.appendChild(window.PanelUI.overflowFixedList);
window.PanelUI.overflowFixedList.toggleAttribute("customizing", true);
window.PanelUI.menuButton.disabled = true;
document.getElementById("nav-bar-overflow-button").disabled = true;
this.#transitioning = true;
let customizer = document.getElementById("customization-container");
let browser = document.getElementById("browser");
browser.hidden = true;
customizer.hidden = false;
this.#wrapAreaItemsSync(CustomizableUI.AREA_TABSTRIP);
this.#document.documentElement.toggleAttribute("customizing", true);
let customizableToolbars = document.querySelectorAll(
"toolbar[customizable=true]:not([autohide=true], [collapsed=true])"
);
for (let toolbar of customizableToolbars) {
toolbar.toggleAttribute("customizing", true);
}
this.#updateOverflowPanelArrowOffset();
// Let everybody in this window know that we're about to customize.
CustomizableUI.dispatchToolboxEvent("customizationstarting", {}, window);
await this.#wrapAllAreaItems();
this.#populatePalette();
this.#setupPaletteDragging();
window.gNavToolbox.addEventListener("toolbarvisibilitychange", this);
this.#updateResetButton();
this.#updateUndoResetButton();
this.#updateTouchBarButton();
this.#updateDensityMenu();
this.#skipSourceNodeCheck =
Services.prefs.getPrefType(kSkipSourceNodePref) ==
Ci.nsIPrefBranch.PREF_BOOL &&
Services.prefs.getBoolPref(kSkipSourceNodePref);
CustomizableUI.addListener(this);
this.#customizing = true;
this.#transitioning = false;
// Show the palette now that the transition has finished.
this.visiblePalette.hidden = false;
window.setTimeout(() => {
// Force layout reflow to ensure the animation runs,
// and make it async so it doesn't affect the timing.
this.visiblePalette.clientTop;
this.visiblePalette.setAttribute("showing", "true");
}, 0);
this.#updateEmptyPaletteNotice();
this.#setupDownloadAutoHideToggle();
this.#handler.isEnteringCustomizeMode = false;
CustomizableUI.dispatchToolboxEvent("customizationready", {}, window);
if (!this._wantToBeInCustomizeMode) {
this.exit();
}
})().catch(e => {
lazy.log.error("Error entering customize mode", e);
this.#handler.isEnteringCustomizeMode = false;
// Exit customize mode to ensure proper clean-up when entering failed.
this.exit();
});
}
/**
* Exits customize mode, if we happen to be in it. This is a no-op if we
* are not in customize mode.
*
* This is a multi-step asynchronous operation, but this method returns
* synchronously after the operation begins. An `aftercustomization`
* custom event is dispatched on the gNavToolbox once the operation has
* completed.
*/
exit() {
this._wantToBeInCustomizeMode = false;
if (!this.#customizing || this.#handler.isExitingCustomizeMode) {
return;
}
// Entering; want to exit once we've done that.
if (this.#handler.isEnteringCustomizeMode) {
lazy.log.debug(
"Attempted to exit while we're in the middle of entering. " +
"We'll exit after we've entered"
);
return;
}
if (this.resetting) {
lazy.log.debug(
"Attempted to exit while we're resetting. " +
"We'll exit after resetting has finished."
);
return;
}
this.#handler.isExitingCustomizeMode = true;
this.#translationObserver.disconnect();
this.#teardownDownloadAutoHideToggle();
CustomizableUI.removeListener(this);
let window = this.#window;
let document = this.#document;
document.removeEventListener("keypress", this);
this.#togglePong(false);
// Disable the reset and undo reset buttons while transitioning:
let resetButton = this.$("customization-reset-button");
let undoResetButton = this.$("customization-undo-reset-button");
undoResetButton.hidden = resetButton.disabled = true;
this.#transitioning = true;
this.#depopulatePalette();
// We need to set this.#customizing to false and remove the `customizing`
// attribute before removing the tab or else
// XULBrowserWindow.onLocationChange might think that we're still in
// customization mode and need to exit it for a second time.
this.#customizing = false;
document.documentElement.removeAttribute("customizing");
if (this.#browser.selectedTab == gTab) {
closeGlobalTab();
}
let customizer = document.getElementById("customization-container");
let browser = document.getElementById("browser");
customizer.hidden = true;
browser.hidden = false;
window.gNavToolbox.removeEventListener("toolbarvisibilitychange", this);
this.#teardownPaletteDragging();
(async () => {
await this.#unwrapAllAreaItems();
// And drop all area references.
this.areas.clear();
// Let everybody in this window know that we're starting to
// exit customization mode.
CustomizableUI.dispatchToolboxEvent("customizationending", {}, window);
window.PanelUI.menuButton.disabled = false;
let overflowContainer = document.getElementById(
"widget-overflow-mainView"
).firstElementChild;
overflowContainer.appendChild(window.PanelUI.overflowFixedList);
document.getElementById("nav-bar-overflow-button").disabled = false;
let panelContextMenu = document.getElementById(kPanelItemContextMenu);
this._previousPanelContextMenuParent.appendChild(panelContextMenu);
let customizableToolbars = document.querySelectorAll(
"toolbar[customizable=true]:not([autohide=true])"
);
for (let toolbar of customizableToolbars) {
toolbar.removeAttribute("customizing");
}
this.#maybeMoveDownloadsButtonToNavBar();
delete this._lastLightweightTheme;
this.#transitioning = false;
this.#handler.isExitingCustomizeMode = false;
CustomizableUI.dispatchToolboxEvent("aftercustomization", {}, window);
CustomizableUI.notifyEndCustomizing(window);
if (this._wantToBeInCustomizeMode) {
this.enter();
}
})().catch(e => {
lazy.log.error("Error exiting customize mode", e);
this.#handler.isExitingCustomizeMode = false;
});
}
/**
* The overflow panel in customize mode should have its arrow pointing
* at the overflow button. In order to do this correctly, we pass the
* distance between the inside of window and the middle of the button
* to the customize mode markup in which the arrow and panel are placed.
*
* The returned Promise resolves once the offset has been set on the panel
* wrapper.
*
* @returns {Promise<undefined>}
*/
async #updateOverflowPanelArrowOffset() {
let currentDensity =
this.#document.documentElement.getAttribute("uidensity");
let offset = await this.#window.promiseDocumentFlushed(() => {
let overflowButton = this.$("nav-bar-overflow-button");
let buttonRect = overflowButton.getBoundingClientRect();
let endDistance;
if (this.#window.RTL_UI) {
endDistance = buttonRect.left;
} else {
endDistance = this.#window.innerWidth - buttonRect.right;
}
return endDistance + buttonRect.width / 2;
});
if (
!this.#document ||
currentDensity != this.#document.documentElement.getAttribute("uidensity")
) {
return;
}
this.$("customization-panelWrapper").style.setProperty(
"--panel-arrow-offset",
offset + "px"
);
}
/**
* Given some DOM node, attempts to resolve to the relevant child or overflow
* target node that can have customizable items placed within it. It may
* resolve to aNode itself, if aNode can have customizable items placed
* directly within it. If the node does not appear to have such a child, this
* method will return `null`.
*
* @param {DOMNode} aNode
* @returns {null|DOMNode}
*/
#getCustomizableChildForNode(aNode) {
// NB: adjusted from #getCustomizableParent to keep that method fast
// (it's used during drags), and avoid multiple DOM loops
let areas = CustomizableUI.areas;
// Caching this length is important because otherwise we'll also iterate
// over items we add to the end from within the loop.
let numberOfAreas = areas.length;
for (let i = 0; i < numberOfAreas; i++) {
let area = areas[i];
let areaNode = aNode.ownerDocument.getElementById(area);
let customizationTarget = CustomizableUI.getCustomizationTarget(areaNode);
if (customizationTarget && customizationTarget != areaNode) {
areas.push(customizationTarget.id);
}
let overflowTarget =
areaNode && areaNode.getAttribute("default-overflowtarget");
if (overflowTarget) {
areas.push(overflowTarget);
}
}
areas.push(kPaletteId);
while (aNode && aNode.parentNode) {
let parent = aNode.parentNode;
if (areas.includes(parent.id)) {
return aNode;
}
aNode = parent;
}
return null;
}
/**
* Kicks off an animation for aNode that causes it to scale down and become
* transparent before being removed from a toolbar. This can be seen when
* using the context menu to remove an item from a toolbar.
*
* For nodes that are within the overflow panel, aren't
* toolbaritem / toolbarbuttons, or is the hidden downloads button, this
* returns `null` immediately and no animation is performed. If reduced
* motion is enabled, this returns `null` immediately and no animation is
* performed.
*
* @param {DOMNode} aNode
* The node to be removed from the toolbar.
* @returns {null|Promise<DOMNode>}
* Returns `null` if no animation is going to occur, or the DOMNode that
* the animation was performed on after the animation has completed.
*/
#promiseWidgetAnimationOut(aNode) {
if (
this.#window.gReduceMotion ||
aNode.getAttribute("cui-anchorid") == "nav-bar-overflow-button" ||
(aNode.tagName != "toolbaritem" && aNode.tagName != "toolbarbutton") ||
(aNode.id == "downloads-button" && aNode.hidden)
) {
return null;
}
let animationNode;
if (aNode.parentNode && aNode.parentNode.id.startsWith("wrapper-")) {
animationNode = aNode.parentNode;
} else {
animationNode = aNode;
}
return new Promise(resolve => {
function cleanupCustomizationExit() {
resolveAnimationPromise();
}
function cleanupWidgetAnimationEnd(e) {
if (
e.animationName == "widget-animate-out" &&
e.target.id == animationNode.id
) {
resolveAnimationPromise();
}
}
function resolveAnimationPromise() {
animationNode.removeEventListener(
"animationend",
cleanupWidgetAnimationEnd
);
animationNode.removeEventListener(
"customizationending",
cleanupCustomizationExit
);
resolve(animationNode);
}
// Wait until the next frame before setting the class to ensure
// we do start the animation.
this.#window.requestAnimationFrame(() => {
this.#window.requestAnimationFrame(() => {
animationNode.classList.add("animate-out");
animationNode.ownerGlobal.gNavToolbox.addEventListener(
"customizationending",
cleanupCustomizationExit
);
animationNode.addEventListener(
"animationend",
cleanupWidgetAnimationEnd
);
});
});
});
}
/**
* Moves a customizable item to the end of the navbar. This is used primarily
* by the toolbar item context menus regardless of whether or not we're in
* customize mode. If this item is within a toolbar, this may kick off an
* animation that shrinks the item icon and causes it to become transparent
* before the move occurs. The returned Promise resolves once the animation
* and the move has completed.
*
* @param {DOMNode} aNode
* The node to be moved to the end of the navbar area.
* @returns {Promise<undefined>}
* Resolves once the move has completed.
*/
async addToToolbar(aNode) {
aNode = this.#getCustomizableChildForNode(aNode);
if (aNode.localName == "toolbarpaletteitem" && aNode.firstElementChild) {
aNode = aNode.firstElementChild;
}
let widgetAnimationPromise = this.#promiseWidgetAnimationOut(aNode);
let animationNode;
if (widgetAnimationPromise) {
animationNode = await widgetAnimationPromise;
}
let widgetToAdd = aNode.id;
if (
CustomizableUI.isSpecialWidget(widgetToAdd) &&
aNode.closest("#customization-palette")
) {
widgetToAdd = widgetToAdd.match(
/^customizableui-special-(spring|spacer|separator)/
)[1];
}
CustomizableUI.addWidgetToArea(widgetToAdd, CustomizableUI.AREA_NAVBAR);
lazy.BrowserUsageTelemetry.recordWidgetChange(
widgetToAdd,
CustomizableUI.AREA_NAVBAR
);
if (!this.#customizing) {
CustomizableUI.dispatchToolboxEvent("customizationchange");
}
// If the user explicitly moves this item, turn off autohide.
if (aNode.id == "downloads-button") {
Services.prefs.setBoolPref(kDownloadAutoHidePref, false);
if (this.#customizing) {
this.#showDownloadsAutoHidePanel();
}
}
if (animationNode) {
animationNode.classList.remove("animate-out");
}
}
/**
* Pins a customizable widget to the overflow panel. This is used by the
* toolbar item context menus regardless of whether or not we're in customize
* mode. It is also on the widget context menu within the overflow panel when
* the widget is placed there temporarily during a toolbar overflow. If this
* item is within a toolbar, this may kick off an* animation that shrinks the
* item icon and causes it to become transparent before the move occurs. The
* returned Promise resolves once the animation and the move has completed.
*
* @param {DOMNode} aNode
* The node to be moved to the overflow panel.
* @param {string} aReason
* A string to describe why the item is being moved to the overflow panel.
* This is passed along to BrowserUsageTelemetry.recordWidgetChange, and
* is dash-delimited.
*
* Examples:
*
* "toolbar-context-menu": the reason is that the user chose to do this via
* the toolbar context menu.
*
* "panelitem-context": the reason is that the user chose to do this via
* the overflow panel item context menu when the item was moved there
* temporarily during a toolbar overflow.
* @returns {Promise<undefined>}
* Resolves once the move has completed.
*/
async addToPanel(aNode, aReason) {
aNode = this.#getCustomizableChildForNode(aNode);
if (aNode.localName == "toolbarpaletteitem" && aNode.firstElementChild) {
aNode = aNode.firstElementChild;
}
let widgetAnimationPromise = this.#promiseWidgetAnimationOut(aNode);
let animationNode;
if (widgetAnimationPromise) {
animationNode = await widgetAnimationPromise;
}
let panel = CustomizableUI.AREA_FIXED_OVERFLOW_PANEL;
CustomizableUI.addWidgetToArea(aNode.id, panel);
lazy.BrowserUsageTelemetry.recordWidgetChange(aNode.id, panel, aReason);
if (!this.#customizing) {
CustomizableUI.dispatchToolboxEvent("customizationchange");
}
// If the user explicitly moves this item, turn off autohide.
if (aNode.id == "downloads-button") {
Services.prefs.setBoolPref(kDownloadAutoHidePref, false);
if (this.#customizing) {
this.#showDownloadsAutoHidePanel();
}
}
if (animationNode) {
animationNode.classList.remove("animate-out");
}
if (!this.#window.gReduceMotion) {
let overflowButton = this.$("nav-bar-overflow-button");
overflowButton.setAttribute("animate", "true");
overflowButton.addEventListener(
"animationend",
function onAnimationEnd(event) {
if (event.animationName.startsWith("overflow-animation")) {
this.removeEventListener("animationend", onAnimationEnd);
this.removeAttribute("animate");
}
}
);
}
}
/**
* Removes a customizable item from its area and puts it in the palette. This
* is used by the toolbar item context menus regardless of whether or not
* we're in customize mode. It is also on the widget context menu within the
* overflow panel when the widget is placed there temporarily during a toolbar
* overflow. If this item is within a toolbar, this may kick off an animation
* that shrinks the item icon and causes it to become transparent before the
* removal occurs. The returned Promise resolves once the animation and the
* removal has completed.
*
* @param {DOMNode} aNode
* The node to be removed and placed into the palette.
* @param {string} aReason
* A string to describe why the item is being removed.
* This is passed along to BrowserUsageTelemetry.recordWidgetChange, and
* is dash-delimited.
*
* Examples:
*
* "toolbar-context-menu": the reason is that the user chose to do this via
* the toolbar context menu.
*
* "panelitem-context": the reason is that the user chose to do this via
* the overflow panel item context menu when the item was moved there
* temporarily during a toolbar overflow.
* @returns {Promise<undefined>}
* Resolves once the removal has completed.
*/
async removeFromArea(aNode, aReason) {
aNode = this.#getCustomizableChildForNode(aNode);
if (aNode.localName == "toolbarpaletteitem" && aNode.firstElementChild) {
aNode = aNode.firstElementChild;
}
let widgetAnimationPromise = this.#promiseWidgetAnimationOut(aNode);
let animationNode;
if (widgetAnimationPromise) {
animationNode = await widgetAnimationPromise;
}
CustomizableUI.removeWidgetFromArea(aNode.id);
lazy.BrowserUsageTelemetry.recordWidgetChange(aNode.id, null, aReason);
if (!this.#customizing) {
CustomizableUI.dispatchToolboxEvent("customizationchange");
}
// If the user explicitly removes this item, turn off autohide.
if (aNode.id == "downloads-button") {
Services.prefs.setBoolPref(kDownloadAutoHidePref, false);
if (this.#customizing) {
this.#showDownloadsAutoHidePanel();
}
}
if (animationNode) {
animationNode.classList.remove("animate-out");
}
}
/**
* Populates the visible palette seen in the content area when entering
* customize mode. This moves items from the normal "hidden" palette that
* belongs to the toolbox, and then temporarily overrides the toolbox
* with the visible palette until we exit customize mode.
*/
#populatePalette() {
let fragment = this.#document.createDocumentFragment();
let toolboxPalette = this.#window.gNavToolbox.palette;
try {
let unusedWidgets = CustomizableUI.getUnusedWidgets(toolboxPalette);
for (let widget of unusedWidgets) {
let paletteItem = this.#makePaletteItem(widget);
if (!paletteItem) {
continue;
}
fragment.appendChild(paletteItem);
}
let flexSpace = CustomizableUI.createSpecialWidget(
"spring",
this.#document
);
fragment.appendChild(this.wrapToolbarItem(flexSpace, "palette"));
this.visiblePalette.appendChild(fragment);
this.#stowedPalette = this.#window.gNavToolbox.palette;
this.#window.gNavToolbox.palette = this.visiblePalette;
// Now that the palette items are all here, disable all commands.
// We do this here rather than directly in `enter` because we
// need to do/undo this when we're called from reset(), too.
this.#updateCommandsDisabledState(true);
} catch (ex) {
lazy.log.error(ex);
}
}
/**
* For a given widget, finds the associated node within this window and then
* creates / updates a toolbarpaletteitem that will wrap that node to make
* drag and drop operations on that node work while in customize mode.
*
* @param {WidgetGroupWrapper|XULWidgetGroupWrapper} aWidget
* @returns {DOMNode}
*/
#makePaletteItem(aWidget) {
let widgetNode = aWidget.forWindow(this.#window).node;
if (!widgetNode) {
lazy.log.error(
"Widget with id " + aWidget.id + " does not return a valid node"
);
return null;
}
// Do not build a palette item for hidden widgets; there's not much to show.
if (widgetNode.hidden) {
return null;
}
let wrapper = this.createOrUpdateWrapper(widgetNode, "palette");
wrapper.appendChild(widgetNode);
return wrapper;
}
/**
* Unwraps and moves items from the visible palette back to the hidden palette
* when exiting customize mode. This also reassigns the toolbox's palette
* property to point back at the default hidden palette, as this was
* overridden to be the visible palette in #populatePalette.
*/
#depopulatePalette() {
// Quick, undo the command disabling before we depopulate completely:
this.#updateCommandsDisabledState(false);
this.visiblePalette.hidden = true;
let paletteChild = this.visiblePalette.firstElementChild;
let nextChild;
while (paletteChild) {
nextChild = paletteChild.nextElementSibling;
let itemId = paletteChild.firstElementChild.id;
if (CustomizableUI.isSpecialWidget(itemId)) {
this.visiblePalette.removeChild(paletteChild);
} else {
// XXXunf Currently this doesn't destroy the (now unused) node in the
// API provider case. It would be good to do so, but we need to
// keep strong refs to it in CustomizableUI (can't iterate of
// WeakMaps), and there's the question of what behavior
// wrappers should have if consumers keep hold of them.
let unwrappedPaletteItem = this.unwrapToolbarItem(paletteChild);
this.#stowedPalette.appendChild(unwrappedPaletteItem);
}
paletteChild = nextChild;
}
this.visiblePalette.hidden = false;
this.#window.gNavToolbox.palette = this.#stowedPalette;
}
/**
* For all <command> elements in the window document that have no ID, or
* are not in the #enabledCommands set, puts them in the "disabled" state if
* `shouldBeDisabled` is true. For any command that was already disabled, adds
* a "wasdisabled" attribute to the command.
*
* If `shouldBeDisabled` is false, removes the "wasdisabled" attribute from
* any command nodes that have them, and for those that don't, removes the
* "disabled" attribute.
*
* @param {boolean} shouldBeDisabled
* True if all <command> elements not in #enabledCommands should be
* disabled. False otherwise.
*/
#updateCommandsDisabledState(shouldBeDisabled) {
for (let command of this.#document.querySelectorAll("command")) {
if (!command.id || !this.#enabledCommands.has(command.id)) {
if (shouldBeDisabled) {
if (command.getAttribute("disabled") != "true") {
command.setAttribute("disabled", true);
} else {
command.setAttribute("wasdisabled", true);
}
} else if (command.getAttribute("wasdisabled") != "true") {
command.removeAttribute("disabled");
} else {
command.removeAttribute("wasdisabled");
}
}
}
}
/**
* Checks if the passed in DOM node is one that can represent a
* customizable widget.
*
* @param {DOMNode} aNode
* The node to check to see if it's a customizable widget node.
* @returns {boolean}
* `true` if the passed in DOM node is a type that can be used for
* customizable widgets.
*/
#isCustomizableItem(aNode) {
return (
aNode.localName == "toolbarbutton" ||
aNode.localName == "toolbaritem" ||
aNode.localName == "toolbarseparator" ||
aNode.localName == "toolbarspring" ||
aNode.localName == "toolbarspacer"
);
}
/**
* Checks if the passed in DOM node is a toolbarpaletteitem wrapper.
*
* @param {DOMNode} aNode
* The node to check for being wrapped.
* @returns {boolean}
* `true` if the passed in DOM node is a toolbarpaletteitem, meaning
* that it was wrapped via createOrUpdateWrapper.
*/
isWrappedToolbarItem(aNode) {
return aNode.localName == "toolbarpaletteitem";
}
/**
* Queues a function for the main thread to execute soon that will wrap
* aNode in a toolbarpaletteitem (or update the wrapper if it already exists).
*
* @param {DOMNode} aNode
* The node to wrap in a toolbarpaletteitem.
* @param {string} aPlace
* The string to set as the "place" attribute on the node when it is
* wrapped. This is expected to be one of the strings returned by
* CustomizableUI.getPlaceForItem.
* @returns {Promise<DOMNode>}
* Resolves with the wrapper node, or the node itself if the node is not
* a customizable item.
*/
#deferredWrapToolbarItem(aNode, aPlace) {
return new Promise(resolve => {
Services.tm.dispatchToMainThread(() => {
let wrapper = this.wrapToolbarItem(aNode, aPlace);
resolve(wrapper);
});
});
}
/**
* Creates or updates a wrapping toolbarpaletteitem around aNode, presuming
* the node is a customizable item.
*
* @param {DOMNode} aNode
* The node to wrap, or update the wrapper for.
* @param {string} aPlace
* The string to set as the "place" attribute on the node when it is
* wrapped. This is expected to be one of the strings returned by
* CustomizableUI.getPlaceForItem.
* @returns {DOMNode}
* The toolbarbpaletteitem wrapper, in the event that aNode is a
* customizable item. Otherwise, returns aNode.
*/
wrapToolbarItem(aNode, aPlace) {
if (!this.#isCustomizableItem(aNode)) {
return aNode;
}
let wrapper = this.createOrUpdateWrapper(aNode, aPlace);
// It's possible that this toolbar node is "mid-flight" and doesn't have
// a parent, in which case we skip replacing it. This can happen if a
// toolbar item has been dragged into the palette. In that case, we tell
// CustomizableUI to remove the widget from its area before putting the
// widget in the palette - so the node will have no parent.
if (aNode.parentNode) {
aNode = aNode.parentNode.replaceChild(wrapper, aNode);
}
wrapper.appendChild(aNode);
return wrapper;
}
/**
* Helper to set the title and tooltiptext on a toolbarpaletteitem wrapper
* based on the wrapped node - either by reading the label/title attributes
* of aNode, or (in the event of delayed Fluent localization) setting up a
* mutation observer on aNode such that when the label and/or title do
* get set, we re-enter #updateWrapperLabel to update the toolbarpaletteitem.
*
* @param {DOMNode} aNode
* The wrapped customizable item to update the wrapper for.
* @param {boolean} aIsUpdate
* True if the node already has a pre-existing wrapper that is being
* updated rather than created.
* @param {DOMNode} [aWrapper=aNode.parentElement]
* The toolbarpaletteitem wrapper, in the event that it's not the
* immediate ancestor of aNode for some reason.
*/
#updateWrapperLabel(aNode, aIsUpdate, aWrapper = aNode.parentElement) {
if (aNode.hasAttribute("label")) {
aWrapper.setAttribute("title", aNode.getAttribute("label"));
aWrapper.setAttribute("tooltiptext", aNode.getAttribute("label"));
} else if (aNode.hasAttribute("title")) {
aWrapper.setAttribute("title", aNode.getAttribute("title"));
aWrapper.setAttribute("tooltiptext", aNode.getAttribute("title"));
} else if (aNode.hasAttribute("data-l10n-id") && !aIsUpdate) {
this.#translationObserver.observe(aNode, {
attributes: true,
attributeFilter: ["label", "title"],
});
}
}
/**
* Called when a node without a label or title has those attributes updated.
*
* @param {MutationRecord[]} aMutations
* The list of mutations for the label/title attributes of the nodes that
* had neither of those attributes set when wrapping them.
*/
#onTranslations(aMutations) {
for (let mut of aMutations) {
let { target } = mut;
if (
target.parentElement?.localName == "toolbarpaletteitem" &&
(target.hasAttribute("label") || mut.target.hasAttribute("title"))
) {
this.#updateWrapperLabel(target, true);
}
}
}
/**
* Creates or updates a toolbarpaletteitem to wrap a customizable item. This
* wrapper makes it possible to click and drag these customizable items around
* in the DOM without the underlying item having its event handlers invoked.
*
* @param {DOMNode} aNode
* The node to create or update the toolbarpaletteitem wrapper for.
* @param {string} aPlace
* The string to set as the "place" attribute on the node when it is
* wrapped. This is expected to be one of the strings returned by
* CustomizableUI.getPlaceForItem.
* @param {boolean} aIsUpdate
* True if it is expected that aNode is already wrapped and that we're
* updating the wrapper rather than creating it.
* @returns {DOMNode}
* Returns the created or updated toolbarpaletteitem wrapper.
*/
createOrUpdateWrapper(aNode, aPlace, aIsUpdate) {
let wrapper;
if (
aIsUpdate &&
aNode.parentNode &&
aNode.parentNode.localName == "toolbarpaletteitem"
) {
wrapper = aNode.parentNode;
aPlace = wrapper.getAttribute("place");
} else {
wrapper = this.#document.createXULElement("toolbarpaletteitem");
// "place" is used to show the label when it's sitting in the palette.
wrapper.setAttribute("place", aPlace);
}
// Ensure the wrapped item doesn't look like it's in any special state, and
// can't be interactved with when in the customization palette.
// Note that some buttons opt out of this with the
// keepbroadcastattributeswhencustomizing attribute.
if (
aNode.hasAttribute("command") &&
aNode.getAttribute(kKeepBroadcastAttributes) != "true"
) {
wrapper.setAttribute("itemcommand", aNode.getAttribute("command"));
aNode.removeAttribute("command");
}
if (
aNode.hasAttribute("observes") &&
aNode.getAttribute(kKeepBroadcastAttributes) != "true"
) {
wrapper.setAttribute("itemobserves", aNode.getAttribute("observes"));
aNode.removeAttribute("observes");
}
if (aNode.getAttribute("checked") == "true") {
wrapper.setAttribute("itemchecked", "true");
aNode.removeAttribute("checked");
}
if (aNode.hasAttribute("id")) {
wrapper.setAttribute("id", "wrapper-" + aNode.getAttribute("id"));
}
this.#updateWrapperLabel(aNode, aIsUpdate, wrapper);
if (aNode.hasAttribute("flex")) {
wrapper.setAttribute("flex", aNode.getAttribute("flex"));
}
let removable =
aPlace == "palette" || CustomizableUI.isWidgetRemovable(aNode);
wrapper.setAttribute("removable", removable);
// Allow touch events to initiate dragging in customize mode.
// This is only supported on Windows for now.
wrapper.setAttribute("touchdownstartsdrag", "true");
let contextMenuAttrName = "";
if (aNode.getAttribute("context")) {
contextMenuAttrName = "context";
} else if (aNode.getAttribute("contextmenu")) {
contextMenuAttrName = "contextmenu";
}
let currentContextMenu = aNode.getAttribute(contextMenuAttrName);
let contextMenuForPlace =
aPlace == "panel" ? kPanelItemContextMenu : kPaletteItemContextMenu;
if (aPlace != "toolbar") {
wrapper.setAttribute("context", contextMenuForPlace);
}
// Only keep track of the menu if it is non-default.
if (currentContextMenu && currentContextMenu != contextMenuForPlace) {
aNode.setAttribute("wrapped-context", currentContextMenu);
aNode.setAttribute("wrapped-contextAttrName", contextMenuAttrName);
aNode.removeAttribute(contextMenuAttrName);
} else if (currentContextMenu == contextMenuForPlace) {
aNode.removeAttribute(contextMenuAttrName);
}
// Only add listeners for newly created wrappers:
if (!aIsUpdate) {
wrapper.addEventListener("mousedown", this);
wrapper.addEventListener("mouseup", this);
}
if (CustomizableUI.isSpecialWidget(aNode.id)) {
wrapper.setAttribute(
"title",
lazy.gWidgetsBundle.GetStringFromName(aNode.nodeName + ".label")
);
}
return wrapper;
}
/**
* Queues a function for the main thread to execute soon that will unwrap
* the node wrapped with aWrapper, which should be a toolbarpaletteitem.
*
* @param {DOMNode} aWrapper
* The toolbarpaletteitem wrapper around a node to unwrap.
* @returns {Promise<DOMNode|null>}
* Resolves with the unwrapped node, or null in the event that some
* problem occurred while unwrapping (which will be logged).
*/
#deferredUnwrapToolbarItem(aWrapper) {
return new Promise(resolve => {
Services.tm.dispatchToMainThread(() => {
let item = null;
try {
item = this.unwrapToolbarItem(aWrapper);
} catch (ex) {
console.error(ex);
}
resolve(item);
});
});
}
/**
* Unwraps a customizable item wrapped with a toolbarpaletteitem. If the
* passed in aWrapper is not a toolbarpaletteitem, this just returns
* aWrapper.
*
* @param {DOMNode} aWrapper
* The toolbarpaletteitem wrapper around a node to be unwrapped.
* @returns {DOMNode|null}
* Returns the unwrapped customizable item, or null if something went wrong
* while unwrapping.
*/
unwrapToolbarItem(aWrapper) {
if (aWrapper.nodeName != "toolbarpaletteitem") {
return aWrapper;
}
aWrapper.removeEventListener("mousedown", this);
aWrapper.removeEventListener("mouseup", this);
let place = aWrapper.getAttribute("place");
let toolbarItem = aWrapper.firstElementChild;
if (!toolbarItem) {
lazy.log.error(
"no toolbarItem child for " + aWrapper.tagName + "#" + aWrapper.id
);
aWrapper.remove();
return null;
}
if (aWrapper.hasAttribute("itemobserves")) {
toolbarItem.setAttribute(
"observes",
aWrapper.getAttribute("itemobserves")
);
}
if (aWrapper.hasAttribute("itemchecked")) {
toolbarItem.checked = true;
}
if (aWrapper.hasAttribute("itemcommand")) {
let commandID = aWrapper.getAttribute("itemcommand");
toolbarItem.setAttribute("command", commandID);
// XXX Bug 309953 - toolbarbuttons aren't in sync with their commands after customizing
let command = this.$(commandID);
if (command && command.hasAttribute("disabled")) {
toolbarItem.setAttribute("disabled", command.getAttribute("disabled"));
}
}
let wrappedContext = toolbarItem.getAttribute("wrapped-context");
if (wrappedContext) {
let contextAttrName = toolbarItem.getAttribute("wrapped-contextAttrName");
toolbarItem.setAttribute(contextAttrName, wrappedContext);
toolbarItem.removeAttribute("wrapped-contextAttrName");
toolbarItem.removeAttribute("wrapped-context");
} else if (place == "panel") {
toolbarItem.setAttribute("context", kPanelItemContextMenu);
}
if (aWrapper.parentNode) {
aWrapper.parentNode.replaceChild(toolbarItem, aWrapper);
}
return toolbarItem;
}
/**
* For a given area, iterates the children within its customize target,
* and asynchronously wraps each customizable child in a
* toolbarpaletteitem. This also adds drag and drop handlers to the
* customize target for the area.
*
* @param {string} aArea
* The ID of the area to wrap the children for in the window that this
* CustomizeMode was instantiated for.
* @returns {Promise<DOMNode|null>}
* Resolves with the customize target DOMNode for aArea for the window that
* this CustomizeMode was instantiated for - or null if the customize target
* cannot be found or is unknown to CustomizeMode.
*/
async #wrapAreaItems(aArea) {
let target = CustomizableUI.getCustomizeTargetForArea(aArea, this.#window);
if (!target || this.areas.has(target)) {
return null;
}
this.#addCustomizeTargetDragAndDropHandlers(target);
for (let child of target.children) {
if (
this.#isCustomizableItem(child) &&
!this.isWrappedToolbarItem(child)
) {
await this.#deferredWrapToolbarItem(
child,
CustomizableUI.getPlaceForItem(child)
).catch(lazy.log.error);
}
}
this.areas.add(target);
return target;
}
/**
* A synchronous version of #wrapAreaItems that will wrap all of the
* customizable children of aArea's customize target in toolbarpaletteitems.
*
* @param {string} aArea
* The ID of the area to wrap the children for in the window that this
* CustomizeMode was instantiated for.
* @returns {DOMNode|null}
* Returns the customize target DOMNode for aArea for the window that
* this CustomizeMode was instantiated for - or null if the customize target
* cannot be found or is unknown to CustomizeMode.
*/
#wrapAreaItemsSync(aArea) {
let target = CustomizableUI.getCustomizeTargetForArea(aArea, this.#window);
if (!target || this.areas.has(target)) {
return null;
}
this.#addCustomizeTargetDragAndDropHandlers(target);
try {
for (let child of target.children) {
if (
this.#isCustomizableItem(child) &&
!this.isWrappedToolbarItem(child)
) {
this.wrapToolbarItem(child, CustomizableUI.getPlaceForItem(child));
}
}
} catch (ex) {
lazy.log.error(ex, ex.stack);
}
this.areas.add(target);
return target;
}
/**
* Iterates all areas and asynchronously wraps their customize target children
* with toolbarpaletteitems in the window that this CustomizeMode was
* constructed for.
*
* @returns {Promise<undefined>}
* Resolves when wrapping has completed.
*/
async #wrapAllAreaItems() {
for (let area of CustomizableUI.areas) {
await this.#wrapAreaItems(area);
}
}
/**
* Adds capturing drag and drop handlers for some customize target for some
* customizable area. These handlers delegate event handling to the
* handleEvent method.
*
* @param {DOMNode} aTarget
* The customize target node to add drag and drop handlers for.
*/
#addCustomizeTargetDragAndDropHandlers(aTarget) {
// Allow dropping on the padding of the arrow panel.
if (aTarget.id == CustomizableUI.AREA_FIXED_OVERFLOW_PANEL) {
aTarget = this.$("customization-panelHolder");
}
aTarget.addEventListener("dragstart", this, true);
aTarget.addEventListener("dragover", this, true);
aTarget.addEventListener("dragleave", this, true);
aTarget.addEventListener("drop", this, true);
aTarget.addEventListener("dragend", this, true);
}
/**
* Iterates all of the customizable item children of a customize target within
* a particular area, and attempts to wrap them in toolbarpaletteitems.
*
* @param {DOMNode} target
* The customize target for some customizable area.
*/
#wrapItemsInArea(target) {
for (let child of target.children) {
if (this.#isCustomizableItem(child)) {
this.wrapToolbarItem(child, CustomizableUI.getPlaceForItem(child));
}
}
}
/**
* Removes capturing drag and drop handlers for some customize target for some
* customizable area added via #addCustomizeTargetDragAndDropHandlers.
*
* @param {DOMNode} aTarget
* The customize target node to remove drag and drop handlers for.
*/
#removeCustomizeTargetDragAndDropHandlers(aTarget) {
// Remove handler from different target if it was added to
// allow dropping on the padding of the arrow panel.
if (aTarget.id == CustomizableUI.AREA_FIXED_OVERFLOW_PANEL) {
aTarget = this.$("customization-panelHolder");
}
aTarget.removeEventListener("dragstart", this, true);
aTarget.removeEventListener("dragover", this, true);
aTarget.removeEventListener("dragleave", this, true);
aTarget.removeEventListener("drop", this, true);
aTarget.removeEventListener("dragend", this, true);
}
/**
* Iterates all of the customizable item children of a customize target within
* a particular area that have been wrapped in toolbarpaletteitems, and
* attemps to unwrap them.
*
* @param {DOMNode} target
* The customize target for some customizable area.
*/
#unwrapItemsInArea(target) {
for (let toolbarItem of target.children) {
if (this.isWrappedToolbarItem(toolbarItem)) {
this.unwrapToolbarItem(toolbarItem);
}
}
}
/**
* Iterates all areas and asynchronously unwraps their customize target
* children that had been previously wrapped in toolbarpaletteitems in the
* window that this CustomizeMode was constructed for. This also removes
* the drag and drop handlers for each area.
*
* @returns {Promise<undefined>}
* Resolves when unwrapping has completed.
*/
#unwrapAllAreaItems() {
return (async () => {
for (let target of this.areas) {
for (let toolbarItem of target.children) {
if (this.isWrappedToolbarItem(toolbarItem)) {
await this.#deferredUnwrapToolbarItem(toolbarItem);
}
}
this.#removeCustomizeTargetDragAndDropHandlers(target);
}
this.areas.clear();
})().catch(lazy.log.error);
}
/**
* Resets the customization state of the browser across all windows to the
* default settings.
*
* @returns {Promise<undefined>}
* Resolves once resetting the customization state has completed.
*/
reset() {
this.resetting = true;
// Disable the reset button temporarily while resetting:
let btn = this.$("customization-reset-button");
btn.disabled = true;
return (async () => {
this.#depopulatePalette();
await this.#unwrapAllAreaItems();
CustomizableUI.reset();
await this.#wrapAllAreaItems();
this.#populatePalette();
this.#updateResetButton();
this.#updateUndoResetButton();
this.#updateEmptyPaletteNotice();
this.#moveDownloadsButtonToNavBar = false;
this.resetting = false;
if (!this._wantToBeInCustomizeMode) {
this.exit();
}
})().catch(lazy.log.error);
}
/**
* Reverts a reset operation back to the prior customization state.
*
* @see CustomizeMode.reset()
* @returns {Promise<undefined>}
*/
undoReset() {
this.resetting = true;
return (async () => {
this.#depopulatePalette();
await this.#unwrapAllAreaItems();
CustomizableUI.undoReset();
await this.#wrapAllAreaItems();
this.#populatePalette();
this.#updateResetButton();
this.#updateUndoResetButton();
this.#updateEmptyPaletteNotice();
this.#moveDownloadsButtonToNavBar = false;
this.resetting = false;
})().catch(lazy.log.error);
}
/**
* Handler for toolbarvisibilitychange events that fire within the window
* that this CustomizeMode was constructed for.
*
* @param {CustomEvent} aEvent
* The toolbarvisibilitychange event that was fired.
*/
#onToolbarVisibilityChange(aEvent) {
let toolbar = aEvent.target;
toolbar.toggleAttribute(
"customizing",
aEvent.detail.visible && toolbar.getAttribute("customizable") == "true"
);
this.#onUIChange();
}
/**
* The callback called by CustomizableUI when a widget moves.
*/
onWidgetMoved() {
this.#onUIChange();
}
/**
* The callback called by CustomizableUI when a widget is added to an area.
*/
onWidgetAdded() {
this.#onUIChange();
}
/**
* The callback called by CustomizableUI when a widget is removed from an
* area.
*/
onWidgetRemoved() {
this.#onUIChange();
}
/**
* The callback called by CustomizableUI *before* a widget's DOM node is acted
* upon by CustomizableUI (to add, move or remove it).
*
* @param {Element} aNodeToChange
* The DOM node being acted upon.
* @param {Element|null} aSecondaryNode
* The DOM node (if any) before which a widget will be inserted.
* @param {Element} aContainer
* The *actual* DOM container for the widget (could be an overflow panel in
* case of an overflowable toolbar).
*/
onWidgetBeforeDOMChange(aNodeToChange, aSecondaryNode, aContainer) {
if (aContainer.ownerGlobal != this.#window || this.resetting) {
return;
}
// If we get called for widgets that aren't in the window yet, they might not have
// a parentNode at all.
if (aNodeToChange.parentNode) {
this.unwrapToolbarItem(aNodeToChange.parentNode);
}
if (aSecondaryNode) {
this.unwrapToolbarItem(aSecondaryNode.parentNode);
}
}
/**
* The callback called by CustomizableUI *after* a widget's DOM node is acted
* upon by CustomizableUI (to add, move or remove it).
*
* @param {Element} aNodeToChange
* The DOM node that was acted upon.
* @param {Element|null} aSecondaryNode
* The DOM node (if any) that the widget was inserted before.
* @param {Element} aContainer
* The *actual* DOM container for the widget (could be an overflow panel in
* case of an overflowable toolbar).
*/
onWidgetAfterDOMChange(aNodeToChange, aSecondaryNode, aContainer) {
if (aContainer.ownerGlobal != this.#window || this.resetting) {
return;
}
// If the node is still attached to the container, wrap it again:
if (aNodeToChange.parentNode) {
let place = CustomizableUI.getPlaceForItem(aNodeToChange);
this.wrapToolbarItem(aNodeToChange, place);
if (aSecondaryNode) {
this.wrapToolbarItem(aSecondaryNode, place);
}
} else {
// If not, it got removed.
// If an API-based widget is removed while customizing, append it to the palette.
// The #applyDrop code itself will take care of positioning it correctly, if
// applicable. We need the code to be here so removing widgets using CustomizableUI's
// API also does the right thing (and adds it to the palette)
let widgetId = aNodeToChange.id;
let widget = CustomizableUI.getWidget(widgetId);
if (widget.provider == CustomizableUI.PROVIDER_API) {
let paletteItem = this.#makePaletteItem(widget);
this.visiblePalette.appendChild(paletteItem);
}
}
}
/**
* The callback called by CustomizableUI when a widget is destroyed. Only
* fired for API-based widgets.
*
* @param {string} aWidgetId
* The ID of the widget that was destroyed.
*/
onWidgetDestroyed(aWidgetId) {
let wrapper = this.$("wrapper-" + aWidgetId);
if (wrapper) {
wrapper.remove();
}
}
/**
* The callback called by CustomizableUI after a widget with id aWidgetId has
* been created, and has been added to either its default area or the area in
* which it was placed previously. If the widget has no default area and/or it
* has never been placed anywhere, aArea may be null. Only fired for API-based
* widgets.
*
* @param {string} aWidgetId
* The ID of the widget that was just created.
* @param {string|null} aArea
* The ID of the area that the widget was placed in, or null if it is
* now in the customization palette.
*/
onWidgetAfterCreation(aWidgetId, aArea) {
// If the node was added to an area, we would have gotten an onWidgetAdded notification,
// plus associated DOM change notifications, so only do stuff for the palette:
if (!aArea) {
let widgetNode = this.$(aWidgetId);
if (widgetNode) {
this.wrapToolbarItem(widgetNode, "palette");
} else {
let widget = CustomizableUI.getWidget(aWidgetId);
this.visiblePalette.appendChild(this.#makePaletteItem(widget));
}
}
}
/**
* Called by CustomizableUI after an area node is first built when it is
* registered.
*
* @param {string} aArea
* The ID for the area that was just registered.
* @param {DOMNode} aContainer
* The DOM node for the customizable area.
*/
onAreaNodeRegistered(aArea, aContainer) {
if (aContainer.ownerDocument == this.#document) {
this.#wrapItemsInArea(aContainer);
this.#addCustomizeTargetDragAndDropHandlers(aContainer);
this.areas.add(aContainer);
}
}
/**
* Called by CustomizableUI after an area node is unregistered and no longer
* available in this window.
*
* @param {string} aArea
* The ID for the area that was just registered.
* @param {DOMNode} aContainer
* The DOM node for the customizable area.
* @param {string} aReason
* One of the CustomizableUI.REASON_* constants to describe the reason
* that the area was unregistered for.
*/
onAreaNodeUnregistered(aArea, aContainer, aReason) {
if (
aContainer.ownerDocument == this.#document &&
aReason == CustomizableUI.REASON_AREA_UNREGISTERED
) {
this.#unwrapItemsInArea(aContainer);
this.#removeCustomizeTargetDragAndDropHandlers(aContainer);
this.areas.delete(aContainer);
}
}
/**
* Opens about:addons in a new tab, showing the themes list.
*/
#openAddonsManagerThemes() {
this.#window.BrowserAddonUI.openAddonsMgr("addons://list/theme");
}
/**
* Temporarily updates the density of the browser UI to suit the passed in
* mode. This is used to preview the density of the browser while the user
* hovers the various density options, and is reset when the user stops
* hovering the options.
*
* @param {number|null} mode
* One of the density mode constants from gUIDensity - for example,
* gUIDensity.MODE_TOUCH.
*/
#previewUIDensity(mode) {
this.#window.gUIDensity.update(mode);
this.#updateOverflowPanelArrowOffset();
}
/**
* Resets the current UI density to the currently configured density. This
* is used after temporarily previewing a density.
*/
#resetUIDensity() {
this.#window.gUIDensity.update();
this.#updateOverflowPanelArrowOffset();
}
/**
* Sets a UI density mode as the configured density.
*
* @param {number|null} mode
* One of the density mode constants from gUIDensity - for example,
* gUIDensity.MODE_TOUCH.
*/
setUIDensity(mode) {
let win = this.#window;
let gUIDensity = win.gUIDensity;
let currentDensity = gUIDensity.getCurrentDensity();
let panel = win.document.getElementById("customization-uidensity-menu");
Services.prefs.setIntPref(gUIDensity.uiDensityPref, mode);
// If the user is choosing a different UI density mode while
// the mode is overriden to Touch, remove the override.
if (currentDensity.overridden) {
Services.prefs.setBoolPref(gUIDensity.autoTouchModePref, false);
}
this.#onUIChange();
panel.hidePopup();
this.#updateOverflowPanelArrowOffset();
}
/**
* Updates the state of the UI density menupopup to correctly reflect the
* current configured density and to list the available alternative densities.
*/
#onUIDensityMenuShowing() {
let win = this.#window;
let doc = win.document;
let gUIDensity = win.gUIDensity;
let currentDensity = gUIDensity.getCurrentDensity();
let normalItem = doc.getElementById(
"customization-uidensity-menuitem-normal"
);
normalItem.mode = gUIDensity.MODE_NORMAL;
let items = [normalItem];
let compactItem = doc.getElementById(
"customization-uidensity-menuitem-compact"
);
compactItem.mode = gUIDensity.MODE_COMPACT;
if (Services.prefs.getBoolPref(kCompactModeShowPref)) {
compactItem.hidden = false;
items.push(compactItem);
} else {
compactItem.hidden = true;
}
let touchItem = doc.getElementById(
"customization-uidensity-menuitem-touch"
);
// Touch mode can not be enabled in OSX right now.
if (touchItem) {
touchItem.mode = gUIDensity.MODE_TOUCH;
items.push(touchItem);
}
// Mark the active mode menuitem.
for (let item of items) {
if (item.mode == currentDensity.mode) {
item.setAttribute("aria-checked", "true");
item.setAttribute("active", "true");
} else {
item.removeAttribute("aria-checked");
item.removeAttribute("active");
}
}
// Add menu items for automatically switching to Touch mode in Windows Tablet Mode.
if (AppConstants.platform == "win") {
let spacer = doc.getElementById("customization-uidensity-touch-spacer");
let checkbox = doc.getElementById(
"customization-uidensity-autotouchmode-checkbox"
);
spacer.removeAttribute("hidden");
checkbox.removeAttribute("hidden");
// Show a hint that the UI density was overridden automatically.
if (currentDensity.overridden) {
let sb = Services.strings.createBundle(
"chrome://browser/locale/uiDensity.properties"
);
touchItem.setAttribute(
"acceltext",
sb.GetStringFromName("uiDensity.menuitem-touch.acceltext")
);
} else {
touchItem.removeAttribute("acceltext");
}
let autoTouchMode = Services.prefs.getBoolPref(
win.gUIDensity.autoTouchModePref
);
if (autoTouchMode) {
checkbox.setAttribute("checked", "true");
} else {
checkbox.removeAttribute("checked");
}
}
}
/**
* Sets "automatic" touch mode to enabled or disabled. Automatic touch mode
* means that touch density is used automatically if the device has switched
* into a tablet mode.
*
* @param {boolean} checked
* True if automatic touch mode should be enabled.
*/
#updateAutoTouchMode(checked) {
Services.prefs.setBoolPref("browser.touchmode.auto", checked);
// Re-render the menu items since the active mode might have
// change because of this.
this.#onUIDensityMenuShowing();
this.#onUIChange();
}
/**
* Called anytime the UI configuration has changed in such a way that we need
* to update the state and appearance of customize mode.
*/
#onUIChange() {
if (!this.resetting) {
this.#updateResetButton();
this.#updateUndoResetButton();
this.#updateEmptyPaletteNotice();
}
CustomizableUI.dispatchToolboxEvent("customizationchange");
}
/**
* Handles updating the state of customize mode if the palette has been
* emptied such that only the special "spring" element remains (as this
* cannot be removed from the palette).
*/
#updateEmptyPaletteNotice() {
let paletteItems =
this.visiblePalette.getElementsByTagName("toolbarpaletteitem");
let whimsyButton = this.$("whimsy-button");
if (
paletteItems.length == 1 &&
paletteItems[0].id.includes("wrapper-customizableui-special-spring")
) {
whimsyButton.hidden = false;
} else {
this.#togglePong(false);
whimsyButton.hidden = true;
}
}
/**
* Updates the enabled / disabled state of the Restore Defaults button based
* on whether or not we're already in the default state.
*/
#updateResetButton() {
let btn = this.$("customization-reset-button");
btn.disabled = CustomizableUI.inDefaultState;
}
/**
* Updates the hidden / visible state of the "undo reset" button based on
* whether or not we've just performed a reset that can be undone.
*/
#updateUndoResetButton() {
let undoResetButton = this.$("customization-undo-reset-button");
undoResetButton.hidden = !CustomizableUI.canUndoReset;
}
/**
* On macOS, if a touch bar is available on the device, updates the
* hidden / visible state of the Customize Touch Bar button and spacer.
*/
#updateTouchBarButton() {
if (AppConstants.platform != "macosx") {
return;
}
let touchBarButton = this.$("customization-touchbar-button");
let touchBarSpacer = this.$("customization-touchbar-spacer");
let isTouchBarInitialized = lazy.gTouchBarUpdater.isTouchBarInitialized();
touchBarButton.hidden = !isTouchBarInitialized;
touchBarSpacer.hidden = !isTouchBarInitialized;
}
/**
* Updates the hidden / visible state of the UI density button.
*/
#updateDensityMenu() {
// If we're entering Customize Mode, and we're using compact mode,
// then show the button after that.
let gUIDensity = this.#window.gUIDensity;
if (gUIDensity.getCurrentDensity().mode == gUIDensity.MODE_COMPACT) {
Services.prefs.setBoolPref(kCompactModeShowPref, true);
}
let button = this.#document.getElementById(
"customization-uidensity-button"
);
button.hidden =
!Services.prefs.getBoolPref(kCompactModeShowPref) &&
!button.querySelector("#customization-uidensity-menuitem-touch");
}
/**
* Generic event handler used throughout most of the Customize Mode UI. This
* is mainly used to dispatch events to more specific handlers based on the
* event type.
*
* @param {Event} aEvent
* The event being handled.
*/
handleEvent(aEvent) {
switch (aEvent.type) {
case "toolbarvisibilitychange":
this.#onToolbarVisibilityChange(aEvent);
break;
case "dragstart":
this.#onDragStart(aEvent);
break;
case "dragover":
this.#onDragOver(aEvent);
break;
case "drop":
this.#onDragDrop(aEvent);
break;
case "dragleave":
this.#onDragLeave(aEvent);
break;
case "dragend":
this.#onDragEnd(aEvent);
break;
case "mousedown":
this.#onMouseDown(aEvent);
break;
case "mouseup":
this.#onMouseUp(aEvent);
break;
case "keypress":
if (aEvent.keyCode == aEvent.DOM_VK_ESCAPE) {
this.exit();
}
break;
case "unload":
this.#uninit();
break;
}
}
/**
* Sets up the dragover/drop handlers on the visible palette. We handle
* dragover/drop on the outer palette separately to avoid overlap with other
* drag/drop handlers.
*/
#setupPaletteDragging() {
this.#addCustomizeTargetDragAndDropHandlers(this.visiblePalette);
this.paletteDragHandler = aEvent => {
let originalTarget = aEvent.originalTarget;
if (
this.#isUnwantedDragDrop(aEvent) ||
this.visiblePalette.contains(originalTarget) ||
this.$("customization-panelHolder").contains(originalTarget)
) {
return;
}
// We have a dragover/drop on the palette.
if (aEvent.type == "dragover") {
this.#onDragOver(aEvent, this.visiblePalette);
} else {
this.#onDragDrop(aEvent, this.visiblePalette);
}
};
let contentContainer = this.$("customization-content-container");
contentContainer.addEventListener(
"dragover",
this.paletteDragHandler,
true
);
contentContainer.addEventListener("drop", this.paletteDragHandler, true);
}
/**
* Tears down the dragover/drop handlers on the visible palette added by
* #setupPaletteDragging.
*/
#teardownPaletteDragging() {
lazy.DragPositionManager.stop();
this.#removeCustomizeTargetDragAndDropHandlers(this.visiblePalette);
let contentContainer = this.$("customization-content-container");
contentContainer.removeEventListener(
"dragover",
this.paletteDragHandler,
true
);
contentContainer.removeEventListener("drop", this.paletteDragHandler, true);
delete this.paletteDragHandler;
}
/**
* Implements nsIObserver. This is mainly to observe for preference changes.
*
* @param {nsISupports} aSubject
* The nsISupports subject for the notification topic that is being
* observed.
* @param {string} aTopic
* The notification topic that is being observed.
*/
observe(aSubject, aTopic) {
switch (aTopic) {
case "nsPref:changed":
this.#updateResetButton();
this.#updateUndoResetButton();
if (this.#canDrawInTitlebar()) {
this.#updateTitlebarCheckbox();
}
break;
}
}
/**
* Returns true if the current platform and configuration allows us to draw in
* the window titlebar.
*
* @returns {boolean}
*/
#canDrawInTitlebar() {
return this.#window.CustomTitlebar.systemSupported;
}
/**
* De-lazifies the customization panel and the menupopup / panel template
* holding various DOM nodes for customize mode. These things are lazily
* added to the DOM to avoid polluting the browser window DOM with things
* that only Customize Mode cares about.
*/
#ensureCustomizationPanels() {
let template = this.$("customizationPanel");
template.replaceWith(template.content);
let wrapper = this.$("customModeWrapper");
wrapper.replaceWith(wrapper.content);
}
/**
* Adds event listeners for all of the interactive elements in the window that
* this Customize Mode instance was constructed with.
*/
#attachEventListeners() {
let container = this.$("customization-container");
container.addEventListener("command", event => {
switch (event.target.id) {
case "customization-titlebar-visibility-checkbox":
// NB: because command fires after click, by the time we've fired, the checkbox binding
// will already have switched the button's state, so this is correct:
this.#toggleTitlebar(event.target.checked);
break;
case "customization-uidensity-menuitem-compact":
case "customization-uidensity-menuitem-normal":
case "customization-uidensity-menuitem-touch":
this.setUIDensity(event.target.mode);
break;
case "customization-uidensity-autotouchmode-checkbox":
this.#updateAutoTouchMode(event.target.checked);
break;
case "whimsy-button":
this.#togglePong(event.target.checked);
break;
case "customization-touchbar-button":
this.#customizeTouchBar();
break;
case "customization-undo-reset-button":
this.undoReset();
break;
case "customization-reset-button":
this.reset();
break;
case "customization-done-button":
this.exit();
break;
}
});
container.addEventListener("popupshowing", event => {
switch (event.target.id) {
case "customization-toolbar-menu":
this.#window.ToolbarContextMenu.onViewToolbarsPopupShowing(event);
break;
case "customization-uidensity-menu":
this.#onUIDensityMenuShowing();
break;
}
});
let updateDensity = event => {
switch (event.target.id) {
case "customization-uidensity-menuitem-compact":
case "customization-uidensity-menuitem-normal":
case "customization-uidensity-menuitem-touch":
this.#previewUIDensity(event.target.mode);
}
};
let densityMenu = this.#document.getElementById(
"customization-uidensity-menu"
);
densityMenu.addEventListener("focus", updateDensity);
densityMenu.addEventListener("mouseover", updateDensity);
let resetDensity = event => {
switch (event.target.id) {
case "customization-uidensity-menuitem-compact":
case "customization-uidensity-menuitem-normal":
case "customization-uidensity-menuitem-touch":
this.#resetUIDensity();
}
};
densityMenu.addEventListener("blur", resetDensity);
densityMenu.addEventListener("mouseout", resetDensity);
this.$("customization-lwtheme-link").addEventListener("click", () => {
this.#openAddonsManagerThemes();
});
this.$(kPaletteItemContextMenu).addEventListener("popupshowing", event => {
this.#onPaletteContextMenuShowing(event);
});
this.$(kPaletteItemContextMenu).addEventListener("command", event => {
switch (event.target.id) {
case "customizationPaletteItemContextMenuAddToToolbar":
this.addToToolbar(
event.target.parentNode.triggerNode,
"palette-context"
);
break;
case "customizationPaletteItemContextMenuAddToPanel":
this.addToPanel(
event.target.parentNode.triggerNode,
"palette-context"
);
break;
}
});
let autohidePanel = this.$(kDownloadAutohidePanelId);
autohidePanel.addEventListener("popupshown", event => {
this._downloadPanelAutoHideTimeout = this.#window.setTimeout(
() => event.target.hidePopup(),
4000
);
});
autohidePanel.addEventListener("mouseover", () => {
this.#window.clearTimeout(this._downloadPanelAutoHideTimeout);
});
autohidePanel.addEventListener("mouseout", event => {
this._downloadPanelAutoHideTimeout = this.#window.setTimeout(
() => event.target.hidePopup(),
2000
);
});
autohidePanel.addEventListener("popuphidden", () => {
this.#window.clearTimeout(this._downloadPanelAutoHideTimeout);
});
this.$(kDownloadAutohideCheckboxId).addEventListener("command", event => {
this.#onDownloadsAutoHideChange(event);
});
}
/**
* Updates the checked / unchecked state of the Titlebar checkbox, to
* reflect whether or not we're currently configured to show the native
* titlebar or not.
*/
#updateTitlebarCheckbox() {
let drawInTitlebar = Services.appinfo.drawInTitlebar;
let checkbox = this.$("customization-titlebar-visibility-checkbox");
// Drawing in the titlebar means 'hiding' the titlebar.
// We use the attribute rather than a property because if we're not in
// customize mode the button is hidden and properties don't work.
if (drawInTitlebar) {
checkbox.removeAttribute("checked");
} else {
checkbox.setAttribute("checked", "true");
}
}
/**
* Configures whether or not we should show the native titlebar.
*
* @param {boolean} aShouldShowTitlebar
* True if we should show the native titlebar. False to draw the browser
* UI into the titlebar instead.
*/
#toggleTitlebar(aShouldShowTitlebar) {
// Drawing in the titlebar means not showing the titlebar, hence the negation:
Services.prefs.setIntPref(kDrawInTitlebarPref, !aShouldShowTitlebar);
}
/**
* A convenient shortcut to calling getBoundsWithoutFlushing on this windows'
* nsIDOMWindowUtils.
*
* @param {DOMNode} element
* An element for which to try to get the bounding client rect, but without
* flushing styles or layout.
* @returns {DOMRect}
*/
#getBoundsWithoutFlushing(element) {
return this.#window.windowUtils.getBoundsWithoutFlushing(element);
}
/**
* Handles the dragstart event on any customizable item in one of the
* customizable areas.
*
* @param {DragEvent} aEvent
* The dragstart event being handled.
*/
#onDragStart(aEvent) {
__dumpDragData(aEvent);
let item = aEvent.target;
while (item && item.localName != "toolbarpaletteitem") {
if (
item.localName == "toolbar" ||
item.id == kPaletteId ||
item.id == "customization-panelHolder"
) {
return;
}
item = item.parentNode;
}
let draggedItem = item.firstElementChild;
let placeForItem = CustomizableUI.getPlaceForItem(item);
let dt = aEvent.dataTransfer;
let documentId = aEvent.target.ownerDocument.documentElement.id;
dt.mozSetDataAt(kDragDataTypePrefix + documentId, draggedItem.id, 0);
dt.effectAllowed = "move";
let itemRect = this.#getBoundsWithoutFlushing(draggedItem);
let itemCenter = {
x: itemRect.left + itemRect.width / 2,
y: itemRect.top + itemRect.height / 2,
};
this._dragOffset = {
x: aEvent.clientX - itemCenter.x,
y: aEvent.clientY - itemCenter.y,
};
let toolbarParent = draggedItem.closest("toolbar");
if (toolbarParent) {
let toolbarRect = this.#getBoundsWithoutFlushing(toolbarParent);
toolbarParent.style.minHeight = toolbarRect.height + "px";
}
gDraggingInToolbars = new Set();
// Hack needed so that the dragimage will still show the
// item as it appeared before it was hidden.
this._initializeDragAfterMove = () => {
// For automated tests, we sometimes start exiting customization mode
// before this fires, which leaves us with placeholders inserted after
// we've exited. So we need to check that we are indeed customizing.
if (this.#customizing && !this.#transitioning) {
item.hidden = true;
lazy.DragPositionManager.start(this.#window);
let canUsePrevSibling =
placeForItem == "toolbar" || placeForItem == "panel";
if (item.nextElementSibling) {
this.#setDragActive(
item.nextElementSibling,
"before",
draggedItem.id,
placeForItem
);
this.#dragOverItem = item.nextElementSibling;
} else if (canUsePrevSibling && item.previousElementSibling) {
this.#setDragActive(
item.previousElementSibling,
"after",
draggedItem.id,
placeForItem
);
this.#dragOverItem = item.previousElementSibling;
}
let currentArea = this.#getCustomizableParent(item);
currentArea.setAttribute("draggingover", "true");
}
this._initializeDragAfterMove = null;
this.#window.clearTimeout(this._dragInitializeTimeout);
};
this._dragInitializeTimeout = this.#window.setTimeout(
this._initializeDragAfterMove,
0
);
}
/**
* Handles the dragover event for any customizable area.
*
* @param {DragEvent} aEvent
* The dragover event being handled.
* @param {DOMNode} [aOverrideTarget=undefined]
* Optional argument that allows callers to override the dragover target to
* be something other than the dragover event current target.
*/
#onDragOver(aEvent, aOverrideTarget) {
if (this.#isUnwantedDragDrop(aEvent)) {
return;
}
if (this._initializeDragAfterMove) {
this._initializeDragAfterMove();
}
__dumpDragData(aEvent);
let document = aEvent.target.ownerDocument;
let documentId = document.documentElement.id;
if (!aEvent.dataTransfer.mozTypesAt(0).length) {
return;
}
let draggedItemId = aEvent.dataTransfer.mozGetDataAt(
kDragDataTypePrefix + documentId,
0
);
let draggedWrapper = document.getElementById("wrapper-" + draggedItemId);
let targetArea = this.#getCustomizableParent(
aOverrideTarget || aEvent.currentTarget
);
let originArea = this.#getCustomizableParent(draggedWrapper);
// Do nothing if the target or origin are not customizable.
if (!targetArea || !originArea) {
return;
}
// Do nothing if the widget is not allowed to be removed.
if (
targetArea.id == kPaletteId &&
!CustomizableUI.isWidgetRemovable(draggedItemId)
) {
return;
}
// Do nothing if the widget is not allowed to move to the target area.
if (!CustomizableUI.canWidgetMoveToArea(draggedItemId, targetArea.id)) {
return;
}
let targetAreaType = CustomizableUI.getPlaceForItem(targetArea);
let targetNode = this.#getDragOverNode(
aEvent,
targetArea,
targetAreaType,
draggedItemId
);
// We need to determine the place that the widget is being dropped in
// the target.
let dragOverItem, dragValue;
if (targetNode == CustomizableUI.getCustomizationTarget(targetArea)) {
// We'll assume if the user is dragging directly over the target, that
// they're attempting to append a child to that target.
dragOverItem =
(targetAreaType == "toolbar"
? this.#findVisiblePreviousSiblingNode(targetNode.lastElementChild)
: targetNode.lastElementChild) || targetNode;
dragValue = "after";
} else {
let targetParent = targetNode.parentNode;
let position = Array.prototype.indexOf.call(
targetParent.children,
targetNode
);
if (position == -1) {
dragOverItem =
targetAreaType == "toolbar"
? this.#findVisiblePreviousSiblingNode(targetNode.lastElementChild)
: targetNode.lastElementChild;
dragValue = "after";
} else {
dragOverItem = targetParent.children[position];
if (targetAreaType == "toolbar") {
// Check if the aDraggedItem is hovered past the first half of dragOverItem
let itemRect = this.#getBoundsWithoutFlushing(dragOverItem);
let dropTargetCenter = itemRect.left + itemRect.width / 2;
let existingDir = dragOverItem.getAttribute("dragover");
let dirFactor = this.#window.RTL_UI ? -1 : 1;
if (existingDir == "before") {
dropTargetCenter +=
((parseInt(dragOverItem.style.borderInlineStartWidth) || 0) / 2) *
dirFactor;
} else {
dropTargetCenter -=
((parseInt(dragOverItem.style.borderInlineEndWidth) || 0) / 2) *
dirFactor;
}
let before = this.#window.RTL_UI
? aEvent.clientX > dropTargetCenter
: aEvent.clientX < dropTargetCenter;
dragValue = before ? "before" : "after";
} else if (targetAreaType == "panel") {
let itemRect = this.#getBoundsWithoutFlushing(dragOverItem);
let dropTargetCenter = itemRect.top + itemRect.height / 2;
let existingDir = dragOverItem.getAttribute("dragover");
if (existingDir == "before") {
dropTargetCenter +=
(parseInt(dragOverItem.style.borderBlockStartWidth) || 0) / 2;
} else {
dropTargetCenter -=
(parseInt(dragOverItem.style.borderBlockEndWidth) || 0) / 2;
}
dragValue = aEvent.clientY < dropTargetCenter ? "before" : "after";
} else {
dragValue = "before";
}
}
}
if (this.#dragOverItem && dragOverItem != this.#dragOverItem) {
this.#cancelDragActive(this.#dragOverItem, dragOverItem);
}
if (
dragOverItem != this.#dragOverItem ||
dragValue != dragOverItem.getAttribute("dragover")
) {
if (dragOverItem != CustomizableUI.getCustomizationTarget(targetArea)) {
this.#setDragActive(
dragOverItem,
dragValue,
draggedItemId,
targetAreaType
);
}
this.#dragOverItem = dragOverItem;
targetArea.setAttribute("draggingover", "true");
}
aEvent.preventDefault();
aEvent.stopPropagation();
}
/**
* Handles the drop event on any customizable area.
*
* @param {DragEvent} aEvent
* The drop event being handled.
* @param {DOMNode} [aOverrideTarget=undefined]
* Optional argument that allows callers to override the drop target to
* be something other than the drop event current target.
*/
#onDragDrop(aEvent, aOverrideTarget) {
if (this.#isUnwantedDragDrop(aEvent)) {
return;
}
__dumpDragData(aEvent);
this._initializeDragAfterMove = null;
this.#window.clearTimeout(this._dragInitializeTimeout);
let targetArea = this.#getCustomizableParent(
aOverrideTarget || aEvent.currentTarget
);
let document = aEvent.target.ownerDocument;
let documentId = document.documentElement.id;
let draggedItemId = aEvent.dataTransfer.mozGetDataAt(
kDragDataTypePrefix + documentId,
0
);
let draggedWrapper = document.getElementById("wrapper-" + draggedItemId);
let originArea = this.#getCustomizableParent(draggedWrapper);
if (this.#dragSizeMap) {
this.#dragSizeMap = new WeakMap();
}
// Do nothing if the target area or origin area are not customizable.
if (!targetArea || !originArea) {
return;
}
let targetNode = this.#dragOverItem;
let dropDir = targetNode.getAttribute("dragover");
// Need to insert *after* this node if we promised the user that:
if (targetNode != targetArea && dropDir == "after") {
if (targetNode.nextElementSibling) {
targetNode = targetNode.nextElementSibling;
} else {
targetNode = targetArea;
}
}
if (targetNode.tagName == "toolbarpaletteitem") {
targetNode = targetNode.firstElementChild;
}
this.#cancelDragActive(this.#dragOverItem, null, true);
try {
this.#applyDrop(
aEvent,
targetArea,
originArea,
draggedItemId,
targetNode
);
} catch (ex) {
lazy.log.error(ex, ex.stack);
}
// If the user explicitly moves this item, turn off autohide.
if (draggedItemId == "downloads-button") {
Services.prefs.setBoolPref(kDownloadAutoHidePref, false);
this.#showDownloadsAutoHidePanel();
}
}
/**
* A helper method for #onDragDrop that applies the changes to the browser
* UI from the drop event on either a customizable item, or an area.
*
* @param {DragEvent} aEvent
* The drop event being handled.
* @param {DOMNode} aTargetArea
* The target area node being dropped on.
* @param {DOMNode} aOriginArea
* The origin area node that the dropped item originally came from.
* @param {string} aDroppedItemId
* The ID value of the customizable item being dropped.
* @param {DOMNode} aTargetNode
* The customizable item (or area) node being dropped on.
*/
#applyDrop(aEvent, aTargetArea, aOriginArea, aDroppedItemId, aTargetNode) {
let document = aEvent.target.ownerDocument;
let draggedItem = document.getElementById(aDroppedItemId);
draggedItem.hidden = false;
draggedItem.removeAttribute("mousedown");
let toolbarParent = draggedItem.closest("toolbar");
if (toolbarParent) {
toolbarParent.style.removeProperty("min-height");
}
// Do nothing if the target was dropped onto itself (ie, no change in area
// or position).
if (draggedItem == aTargetNode) {
return;
}
if (!CustomizableUI.canWidgetMoveToArea(aDroppedItemId, aTargetArea.id)) {
return;
}
// Is the target area the customization palette?
if (aTargetArea.id == kPaletteId) {
// Did we drag from outside the palette?
if (aOriginArea.id !== kPaletteId) {
if (!CustomizableUI.isWidgetRemovable(aDroppedItemId)) {
return;
}
CustomizableUI.removeWidgetFromArea(aDroppedItemId, "drag");
lazy.BrowserUsageTelemetry.recordWidgetChange(
aDroppedItemId,
null,
"drag"
);
// Special widgets are removed outright, we can return here:
if (CustomizableUI.isSpecialWidget(aDroppedItemId)) {
return;
}
}
draggedItem = draggedItem.parentNode;
// If the target node is the palette itself, just append
if (aTargetNode == this.visiblePalette) {
this.visiblePalette.appendChild(draggedItem);
} else {
// The items in the palette are wrapped, so we need the target node's parent here:
this.visiblePalette.insertBefore(draggedItem, aTargetNode.parentNode);
}
this.#onDragEnd(aEvent);
return;
}
// Skipintoolbarset items won't really be moved:
let areaCustomizationTarget =
CustomizableUI.getCustomizationTarget(aTargetArea);
if (draggedItem.getAttribute("skipintoolbarset") == "true") {
// These items should never leave their area:
if (aTargetArea != aOriginArea) {
return;
}
let place = draggedItem.parentNode.getAttribute("place");
this.unwrapToolbarItem(draggedItem.parentNode);
if (aTargetNode == areaCustomizationTarget) {
areaCustomizationTarget.appendChild(draggedItem);
} else {
this.unwrapToolbarItem(aTargetNode.parentNode);
areaCustomizationTarget.insertBefore(draggedItem, aTargetNode);
this.wrapToolbarItem(aTargetNode, place);
}
this.wrapToolbarItem(draggedItem, place);
return;
}
// Force creating a new spacer/spring/separator if dragging from the palette
if (
CustomizableUI.isSpecialWidget(aDroppedItemId) &&
aOriginArea.id == kPaletteId
) {
aDroppedItemId = aDroppedItemId.match(
/^customizableui-special-(spring|spacer|separator)/
)[1];
}
// Is the target the customization area itself? If so, we just add the
// widget to the end of the area.
if (aTargetNode == areaCustomizationTarget) {
CustomizableUI.addWidgetToArea(aDroppedItemId, aTargetArea.id);
lazy.BrowserUsageTelemetry.recordWidgetChange(
aDroppedItemId,
aTargetArea.id,
"drag"
);
this.#onDragEnd(aEvent);
return;
}
// We need to determine the place that the widget is being dropped in
// the target.
let placement;
let itemForPlacement = aTargetNode;
// Skip the skipintoolbarset items when determining the place of the item:
while (
itemForPlacement &&
itemForPlacement.getAttribute("skipintoolbarset") == "true" &&
itemForPlacement.parentNode &&
itemForPlacement.parentNode.nodeName == "toolbarpaletteitem"
) {
itemForPlacement = itemForPlacement.parentNode.nextElementSibling;
if (
itemForPlacement &&
itemForPlacement.nodeName == "toolbarpaletteitem"
) {
itemForPlacement = itemForPlacement.firstElementChild;
}
}
if (itemForPlacement) {
let targetNodeId =
itemForPlacement.nodeName == "toolbarpaletteitem"
? itemForPlacement.firstElementChild &&
itemForPlacement.firstElementChild.id
: itemForPlacement.id;
placement = CustomizableUI.getPlacementOfWidget(targetNodeId);
}
if (!placement) {
lazy.log.debug(
"Could not get a position for " +
aTargetNode.nodeName +
"#" +
aTargetNode.id +
"." +
aTargetNode.className
);
}
let position = placement ? placement.position : null;
// Is the target area the same as the origin? Since we've already handled
// the possibility that the target is the customization palette, we know
// that the widget is moving within a customizable area.
if (aTargetArea == aOriginArea) {
CustomizableUI.moveWidgetWithinArea(aDroppedItemId, position);
lazy.BrowserUsageTelemetry.recordWidgetChange(
aDroppedItemId,
aTargetArea.id,
"drag"
);
} else {
CustomizableUI.addWidgetToArea(aDroppedItemId, aTargetArea.id, position);
lazy.BrowserUsageTelemetry.recordWidgetChange(
aDroppedItemId,
aTargetArea.id,
"drag"
);
}
this.#onDragEnd(aEvent);
// If we dropped onto a skipintoolbarset item, manually correct the drop location:
if (aTargetNode != itemForPlacement) {
let draggedWrapper = draggedItem.parentNode;
let container = draggedWrapper.parentNode;
container.insertBefore(draggedWrapper, aTargetNode.parentNode);
}
}
/**
* Handles the dragleave event on any customizable item or area.
*
* @param {DragEvent} aEvent
* The dragleave event being handled.
*/
#onDragLeave(aEvent) {
if (this.#isUnwantedDragDrop(aEvent)) {
return;
}
__dumpDragData(aEvent);
// When leaving customization areas, cancel the drag on the last dragover item
// We've attached the listener to areas, so aEvent.currentTarget will be the area.
// We don't care about dragleave events fired on descendants of the area,
// so we check that the event's target is the same as the area to which the listener
// was attached.
if (this.#dragOverItem && aEvent.target == aEvent.currentTarget) {
this.#cancelDragActive(this.#dragOverItem);
this.#dragOverItem = null;
}
}
/**
* Handles the dragleave event on any customizable item being dragged.
*
* @param {DragEvent} aEvent
* The dragleave event being handled.
*/
#onDragEnd(aEvent) {
// To workaround bug 460801 we manually forward the drop event here when
// dragend wouldn't be fired.
//
// Note that that means that this function may be called multiple times by a
// single drag operation.
if (this.#isUnwantedDragDrop(aEvent)) {
return;
}
this._initializeDragAfterMove = null;
this.#window.clearTimeout(this._dragInitializeTimeout);
__dumpDragData(aEvent, "#onDragEnd");
let document = aEvent.target.ownerDocument;
document.documentElement.removeAttribute("customizing-movingItem");
let documentId = document.documentElement.id;
if (!aEvent.dataTransfer.mozTypesAt(0)) {
return;
}
let draggedItemId = aEvent.dataTransfer.mozGetDataAt(
kDragDataTypePrefix + documentId,
0
);
let draggedWrapper = document.getElementById("wrapper-" + draggedItemId);
// DraggedWrapper might no longer available if a widget node is
// destroyed after starting (but before stopping) a drag.
if (draggedWrapper) {
draggedWrapper.hidden = false;
draggedWrapper.removeAttribute("mousedown");
let toolbarParent = draggedWrapper.closest("toolbar");
if (toolbarParent) {
toolbarParent.style.removeProperty("min-height");
}
}
if (this.#dragOverItem) {
this.#cancelDragActive(this.#dragOverItem);
this.#dragOverItem = null;
}
lazy.DragPositionManager.stop();
}
/**
* True if the drag/drop event comes from a source other than one of our
* browser windows. This check can be overridden for testing by setting
* `browser.uiCustomization.skipSourceNodeCheck` to `true`.
*
* @param {DragEvent} aEvent
* A drag/drop event.
* @returns {boolean}
* True if the event should be ignored.
*/
#isUnwantedDragDrop(aEvent) {
// The synthesized events for tests generated by synthesizePlainDragAndDrop
// and synthesizeDrop in mochitests are used only for testing whether the
// right data is being put into the dataTransfer. Neither cause a real drop
// to occur, so they don't set the source node. There isn't a means of
// testing real drag and drops, so this pref skips the check but it should
// only be set by test code.
if (this.#skipSourceNodeCheck) {
return false;
}
/* Discard drag events that originated from a separate window to
prevent content->chrome privilege escalations. */
let mozSourceNode = aEvent.dataTransfer.mozSourceNode;
// mozSourceNode is null in the dragStart event handler or if
// the drag event originated in an external application.
return !mozSourceNode || mozSourceNode.ownerGlobal != this.#window;
}
/**
* Handles applying drag preview effects to a customizable item or area while
* a drag and drop operation is underway.
*
* @param {DOMNode} aDraggedOverItem
* A customizable item being dragged over within a customizable area, or
* a customizable area node.
* @param {string} aValue
* A string to set as the "dragover" attribute on the dragged item to
* indicate which direction (before or after) the placeholder for the
* drag operation should go relative to aItem. This is either the
* string "before" or the string "after".
* @param {string} aDraggedItemId
* The ID of the customizable item being dragged.
* @param {string} aPlace
* The place string associated with the customizable area being dragged
* over. This is expected to be one of the strings returned by
* CustomizableUI.getPlaceForItem.
*/
#setDragActive(aDraggedOverItem, aValue, aDraggedItemId, aPlace) {
if (!aDraggedOverItem) {
return;
}
if (aDraggedOverItem.getAttribute("dragover") != aValue) {
aDraggedOverItem.setAttribute("dragover", aValue);
let window = aDraggedOverItem.ownerGlobal;
let draggedItem = window.document.getElementById(aDraggedItemId);
if (aPlace == "palette") {
// We mostly delegate the complexity of grid placeholder effects to
// DragPositionManager by way of #setGridDragActive.
this.#setGridDragActive(aDraggedOverItem, draggedItem, aValue);
} else {
let targetArea = this.#getCustomizableParent(aDraggedOverItem);
let makeSpaceImmediately = false;
if (!gDraggingInToolbars.has(targetArea.id)) {
gDraggingInToolbars.add(targetArea.id);
let draggedWrapper = this.$("wrapper-" + aDraggedItemId);
let originArea = this.#getCustomizableParent(draggedWrapper);
makeSpaceImmediately = originArea == targetArea;
}
let propertyToMeasure = aPlace == "toolbar" ? "width" : "height";
// Calculate width/height of the item when it'd be dropped in this position.
let borderWidth = this.#getDragItemSize(aDraggedOverItem, draggedItem)[
propertyToMeasure
];
let layoutSide = aPlace == "toolbar" ? "Inline" : "Block";
let prop, otherProp;
if (aValue == "before") {
prop = "border" + layoutSide + "StartWidth";
otherProp = "border-" + layoutSide.toLowerCase() + "-end-width";
} else {
prop = "border" + layoutSide + "EndWidth";
otherProp = "border-" + layoutSide.toLowerCase() + "-start-width";
}
if (makeSpaceImmediately) {
aDraggedOverItem.setAttribute("notransition", "true");
}
aDraggedOverItem.style[prop] = borderWidth + "px";
aDraggedOverItem.style.removeProperty(otherProp);
if (makeSpaceImmediately) {
// Force a layout flush:
aDraggedOverItem.getBoundingClientRect();
aDraggedOverItem.removeAttribute("notransition");
}
}
}
}
/**
* Reverts drag preview effects applied via #setDragActive from a customizable
* item or area when a drag and drop operation ends.
*
* @param {DOMNode} aDraggedOverItem
* The customizable item or area that was being dragged over.
* @param {DOMNode} aNextDraggedOverItem
* If non-null, this is the customizable item or area that is being
* dragged over now instead of aDraggedOverItem.
* @param {boolean} aNoTransition
* True if the reversion of the drag preview effect should occur without
* a transition (for example, on a drop).
*/
#cancelDragActive(aDraggedOverItem, aNextDraggedOverItem, aNoTransition) {
let currentArea = this.#getCustomizableParent(aDraggedOverItem);
if (!currentArea) {
return;
}
let nextArea = aNextDraggedOverItem
? this.#getCustomizableParent(aNextDraggedOverItem)
: null;
if (currentArea != nextArea) {
currentArea.removeAttribute("draggingover");
}
let areaType = CustomizableUI.getAreaType(currentArea.id);
if (areaType) {
if (aNoTransition) {
aDraggedOverItem.setAttribute("notransition", "true");
}
aDraggedOverItem.removeAttribute("dragover");
// Remove all property values in the case that the end padding
// had been set.
aDraggedOverItem.style.removeProperty("border-inline-start-width");
aDraggedOverItem.style.removeProperty("border-inline-end-width");
aDraggedOverItem.style.removeProperty("border-block-start-width");
aDraggedOverItem.style.removeProperty("border-block-end-width");
if (aNoTransition) {
// Force a layout flush:
aDraggedOverItem.getBoundingClientRect();
aDraggedOverItem.removeAttribute("notransition");
}
} else {
aDraggedOverItem.removeAttribute("dragover");
if (aNextDraggedOverItem) {
if (nextArea == currentArea) {
// No need to do anything if we're still dragging in this area:
return;
}
}
// Otherwise, clear everything out:
let positionManager =
lazy.DragPositionManager.getManagerForArea(currentArea);
positionManager.clearPlaceholders(currentArea, aNoTransition);
}
}
/**
* Handles applying drag preview effects to the customization palette grid.
*
* @param {DOMNode} aDragOverNode
* A customizable item being dragged over within the palette, or
* the palette node itself.
* @param {DOMNode} aDraggedItem
* The customizable item being dragged.
*/
#setGridDragActive(aDragOverNode, aDraggedItem) {
let targetArea = this.#getCustomizableParent(aDragOverNode);
let draggedWrapper = this.$("wrapper-" + aDraggedItem.id);
let originArea = this.#getCustomizableParent(draggedWrapper);
let positionManager =
lazy.DragPositionManager.getManagerForArea(targetArea);
let draggedSize = this.#getDragItemSize(aDragOverNode, aDraggedItem);
positionManager.insertPlaceholder(
targetArea,
aDragOverNode,
draggedSize,
originArea == targetArea
);
}
/**
* Given a customizable item being dragged, and a DOMNode being dragged over,
* returns the size of the dragged item were it to be placed within the area
* associated with aDragOverNode.
*
* @param {DOMNode} aDragOverNode
* The node currently being dragged over.
* @param {DOMNode} aDraggedItem
* The customizable item node currently being dragged.
* @returns {ItemSizeForArea}
*/
#getDragItemSize(aDragOverNode, aDraggedItem) {
// Cache it good, cache it real good.
if (!this.#dragSizeMap) {
this.#dragSizeMap = new WeakMap();
}
if (!this.#dragSizeMap.has(aDraggedItem)) {
this.#dragSizeMap.set(aDraggedItem, new WeakMap());
}
let itemMap = this.#dragSizeMap.get(aDraggedItem);
let targetArea = this.#getCustomizableParent(aDragOverNode);
let currentArea = this.#getCustomizableParent(aDraggedItem);
// Return the size for this target from cache, if it exists.
let size = itemMap.get(targetArea);
if (size) {
return size;
}
// Calculate size of the item when it'd be dropped in this position.
let currentParent = aDraggedItem.parentNode;
let currentSibling = aDraggedItem.nextElementSibling;
const kAreaType = "cui-areatype";
let areaType, currentType;
if (targetArea != currentArea) {
// Move the widget temporarily next to the placeholder.
aDragOverNode.parentNode.insertBefore(aDraggedItem, aDragOverNode);
// Update the node's areaType.
areaType = CustomizableUI.getAreaType(targetArea.id);
currentType =
aDraggedItem.hasAttribute(kAreaType) &&
aDraggedItem.getAttribute(kAreaType);
if (areaType) {
aDraggedItem.setAttribute(kAreaType, areaType);
}
this.wrapToolbarItem(aDraggedItem, areaType || "palette");
CustomizableUI.onWidgetDrag(aDraggedItem.id, targetArea.id);
} else {
aDraggedItem.parentNode.hidden = false;
}
// Fetch the new size.
let rect = aDraggedItem.parentNode.getBoundingClientRect();
size = { width: rect.width, height: rect.height };
// Cache the found value of size for this target.
itemMap.set(targetArea, size);
if (targetArea != currentArea) {
this.unwrapToolbarItem(aDraggedItem.parentNode);
// Put the item back into its previous position.
currentParent.insertBefore(aDraggedItem, currentSibling);
// restore the areaType
if (areaType) {
if (currentType === false) {
aDraggedItem.removeAttribute(kAreaType);
} else {
aDraggedItem.setAttribute(kAreaType, currentType);
}
}
this.createOrUpdateWrapper(aDraggedItem, null, true);
CustomizableUI.onWidgetDrag(aDraggedItem.id);
} else {
aDraggedItem.parentNode.hidden = true;
}
return size;
}
/**
* Walks the ancestry of a DOMNode element and finds the first customizable
* area node in that ancestry, or null if no such customizable area node
* can be found.
*
* @param {DOMNode} aElement
* The DOMNode for which to find the customizable area parent.
* @returns {DOMNode}
* The customizable area parent of aElement.
*/
#getCustomizableParent(aElement) {
if (aElement) {
// Deal with drag/drop on the padding of the panel.
let containingPanelHolder = aElement.closest(
"#customization-panelHolder"
);
if (containingPanelHolder) {
return containingPanelHolder.querySelector(
"#widget-overflow-fixed-list"
);
}
}
let areas = CustomizableUI.areas;
areas.push(kPaletteId);
return aElement.closest(areas.map(a => "#" + CSS.escape(a)).join(","));
}
/**
* During a drag operation of a customizable item over a customizable area,
* returns the node within that customizable area that the item is being
* dragged over.
*
* @param {DragEvent} aEvent
* The dragover event being handled.
* @param {DOMNode} aAreaElement
* The customizable area element that we should consider the dragover
* operation to be occurring on. This might actually be different from the
* target of the dragover event if we've retargeted the drag (see bug
* 1396423 for an example of where we retarget a dragover area to the
* palette rather than the overflow panel).
* @param {string} aPlace
* The place string associated with the customizable area being dragged
* over. This is expected to be one of the strings returned by
* CustomizableUI.getPlaceForItem.
* @returns {DOMNode}
* The node within aAreaElement that we should assume the dragged item is
* being dragged over. If we cannot resolve this to a target, this falls
* back to just being the aEvent.target.
*/
#getDragOverNode(aEvent, aAreaElement, aPlace) {
let expectedParent =
CustomizableUI.getCustomizationTarget(aAreaElement) || aAreaElement;
if (!expectedParent.contains(aEvent.target)) {
return expectedParent;
}
// Offset the drag event's position with the offset to the center of
// the thing we're dragging
let dragX = aEvent.clientX - this._dragOffset.x;
let dragY = aEvent.clientY - this._dragOffset.y;
// Ensure this is within the container
let boundsContainer = expectedParent;
let bounds = this.#getBoundsWithoutFlushing(boundsContainer);
dragX = Math.min(bounds.right, Math.max(dragX, bounds.left));
dragY = Math.min(bounds.bottom, Math.max(dragY, bounds.top));
let targetNode;
if (aPlace == "toolbar" || aPlace == "panel") {
targetNode = aAreaElement.ownerDocument.elementFromPoint(dragX, dragY);
while (targetNode && targetNode.parentNode != expectedParent) {
targetNode = targetNode.parentNode;
}
} else {
let positionManager =
lazy.DragPositionManager.getManagerForArea(aAreaElement);
// Make it relative to the container:
dragX -= bounds.left;
dragY -= bounds.top;
// Find the closest node:
targetNode = positionManager.find(aAreaElement, dragX, dragY);
}
return targetNode || aEvent.target;
}
/**
* Handler for mousedown events in the customize mode UI for the primary
* button. If the mousedown event is being fired on a customizable item, it
* will have a "mousedown" attribute set to "true" on it. A
* "customizing-movingItem" attribute is also set to "true" on the
* document element.
*
* @param {MouseEvent} aEvent
* The mousedown event being handled.
*/
#onMouseDown(aEvent) {
lazy.log.debug("#onMouseDown");
if (aEvent.button != 0) {
return;
}
let doc = aEvent.target.ownerDocument;
doc.documentElement.setAttribute("customizing-movingItem", true);
let item = this.#getWrapper(aEvent.target);
if (item) {
item.toggleAttribute("mousedown", true);
}
}
/**
* Handler for mouseup events in the customize mode UI for the primary
* button. If the mouseup event is being fired on a customizable item, it
* will have the "mousedown" attribute added in #onMouseDown removed. This
* will also remove the "customizing-movingItem" attribute on the document
* element.
*
* @param {MouseEvent} aEvent
* The mouseup event being handled.
*/
#onMouseUp(aEvent) {
lazy.log.debug("#onMouseUp");
if (aEvent.button != 0) {
return;
}
let doc = aEvent.target.ownerDocument;
doc.documentElement.removeAttribute("customizing-movingItem");
let item = this.#getWrapper(aEvent.target);
if (item) {
item.removeAttribute("mousedown");
}
}
/**
* Given a customizable item (or one of its descendants), returns the
* toolbarpaletteitem wrapper node ancestor. If no such wrapper can be found,
* this returns null.
*
* @param {DOMNode} aElement
* The customizable item node (or one of its descendants) to get the
* toolbarpaletteitem wrapper node ancestor for.
* @returns {DOMNode|null}
* The toolbarpaletteitem wrapper node, or null if one cannot be found.
*/
#getWrapper(aElement) {
while (aElement && aElement.localName != "toolbarpaletteitem") {
if (aElement.localName == "toolbar") {
return null;
}
aElement = aElement.parentNode;
}
return aElement;
}
/**
* Given some toolbarpaletteitem wrapper, walks the prior sibling elements
* until it finds one that either isn't a toolbarpaletteitem, or doesn't have
* it's first element hidden. Returns null if no such prior sibling element
* can be found.
*
* @param {DOMNode} aReferenceNode
* The toolbarpaletteitem node to check the prior siblings for visibilty.
* If aReferenceNode is not a toolbarpaletteitem, this just returns the
* aReferenceNode immediately.
* @returns {DOMNode|null}
* The first prior sibling with a visible first element child, or the
* first non-toolbarpaletteitem prior sibling, or null if no such item can
* be found.
*/
#findVisiblePreviousSiblingNode(aReferenceNode) {
while (
aReferenceNode &&
aReferenceNode.localName == "toolbarpaletteitem" &&
aReferenceNode.firstElementChild.hidden
) {
aReferenceNode = aReferenceNode.previousElementSibling;
}
return aReferenceNode;
}
/**
* The popupshowing event handler for the context menu on the customization
* palette.
*
* @param {WidgetMouseEvent} event
* The popupshowing event being fired for the context menu.
*/
#onPaletteContextMenuShowing(event) {
let isFlexibleSpace = event.target.triggerNode.id.includes(
"wrapper-customizableui-special-spring"
);
event.target.querySelector(".customize-context-addToPanel").disabled =
isFlexibleSpace;
}
/**
* The popupshowing event handler for the context menu for items in the
* overflow panel while in customize mode. This is currently public due to
* bug 1378427 (also see bug 1747945).
*
* @param {WidgetMouseEvent} event
* The popupshowing event being fired for the context menu.
*/
onPanelContextMenuShowing(event) {
let inPermanentArea = !!event.target.triggerNode.closest(
"#widget-overflow-fixed-list"
);
let doc = event.target.ownerDocument;
doc.getElementById("customizationPanelItemContextMenuUnpin").hidden =
!inPermanentArea;
doc.getElementById("customizationPanelItemContextMenuPin").hidden =
inPermanentArea;
doc.ownerGlobal.MozXULElement.insertFTLIfNeeded(
"browser/toolbarContextMenu.ftl"
);
event.target.querySelectorAll("[data-lazy-l10n-id]").forEach(el => {
el.setAttribute("data-l10n-id", el.getAttribute("data-lazy-l10n-id"));
el.removeAttribute("data-lazy-l10n-id");
});
}
/**
* A window click event handler that checks to see if the item being clicked
* in the window is the downloads button wrapper, with the primary button.
* This is used to show the downloads button autohide panel.
*
* @param {MouseEvent} event
* The click event on the window.
*/
#checkForDownloadsClick(event) {
if (
event.target.closest("#wrapper-downloads-button") &&
event.button == 0
) {
event.view.gCustomizeMode.#showDownloadsAutoHidePanel();
}
}
/**
* Adds a click event listener to the top-level window to check to see if the
* downloads button is ever clicked while in customize mode. Callers should
* ensure that #teardownDownloadAutoHideToggle is called when exiting
* customize mode.
*/
#setupDownloadAutoHideToggle() {
this.#window.addEventListener("click", this.#checkForDownloadsClick, true);
}
/**
* Removes the click event listener on the top-level window that was added by
* #setupDownloadAutoHideToggle.
*/
#teardownDownloadAutoHideToggle() {
this.#window.removeEventListener(
"click",
this.#checkForDownloadsClick,
true
);
this.$(kDownloadAutohidePanelId).hidePopup();
}
/**
* Attempts to move the downloads button to the navigation toolbar in the
* event that they've turned on the autohide feature for the button while
* the button is in the palette.
*/
#maybeMoveDownloadsButtonToNavBar() {
// If the user toggled the autohide checkbox while the item was in the
// palette, and hasn't moved it since, move the item to the default
// location in the navbar for them.
if (
!CustomizableUI.getPlacementOfWidget("downloads-button") &&
this.#moveDownloadsButtonToNavBar &&
this.#window.DownloadsButton.autoHideDownloadsButton
) {
let navbarPlacements = CustomizableUI.getWidgetIdsInArea("nav-bar");
let insertionPoint = navbarPlacements.indexOf("urlbar-container");
while (++insertionPoint < navbarPlacements.length) {
let widget = navbarPlacements[insertionPoint];
// If we find a non-searchbar, non-spacer node, break out of the loop:
if (
widget != "search-container" &&
!(CustomizableUI.isSpecialWidget(widget) && widget.includes("spring"))
) {
break;
}
}
CustomizableUI.addWidgetToArea(
"downloads-button",
"nav-bar",
insertionPoint
);
lazy.BrowserUsageTelemetry.recordWidgetChange(
"downloads-button",
"nav-bar",
"move-downloads"
);
}
}
/**
* Opens the panel that shows the toggle for auto-hiding the downloads button
* when there are no downloads underway. If that panel is already open, it
* is first closed. This panel is not shown if the downloads button is in
* the overflow panel (since when the button is there, it does not autohide).
*
* @returns {Promise<undefined>}
*/
async #showDownloadsAutoHidePanel() {
let doc = this.#document;
let panel = doc.getElementById(kDownloadAutohidePanelId);
panel.hidePopup();
let button = doc.getElementById("downloads-button");
// We don't show the tooltip if the button is in the panel.
if (button.closest("#widget-overflow-fixed-list")) {
return;
}
let offsetX = 0,
offsetY = 0;
let panelOnTheLeft = false;
let toolbarContainer = button.closest("toolbar");
if (toolbarContainer && toolbarContainer.id == "nav-bar") {
let navbarWidgets = CustomizableUI.getWidgetIdsInArea("nav-bar");
if (
navbarWidgets.indexOf("urlbar-container") <=
navbarWidgets.indexOf("downloads-button")
) {
panelOnTheLeft = true;
}
} else {
await this.#window.promiseDocumentFlushed(() => {});
if (!this.#customizing || !this._wantToBeInCustomizeMode) {
return;
}
let buttonBounds = this.#getBoundsWithoutFlushing(button);
let windowBounds = this.#getBoundsWithoutFlushing(doc.documentElement);
panelOnTheLeft =
buttonBounds.left + buttonBounds.width / 2 > windowBounds.width / 2;
}
let position;
if (panelOnTheLeft) {
// Tested in RTL, these get inverted automatically, so this does the
// right thing without taking RTL into account explicitly.
position = "topleft topright";
if (toolbarContainer) {
offsetX = 8;
}
} else {
position = "topright topleft";
if (toolbarContainer) {
offsetX = -8;
}
}
let checkbox = doc.getElementById(kDownloadAutohideCheckboxId);
if (this.#window.DownloadsButton.autoHideDownloadsButton) {
checkbox.setAttribute("checked", "true");
} else {
checkbox.removeAttribute("checked");
}
// We don't use the icon to anchor because it might be resizing because of
// the animations for drag/drop. Hence the use of offsets.
panel.openPopup(button, position, offsetX, offsetY);
}
/**
* Called when the downloads button auto-hide toggle changes value.
*
* @param {CommandEvent} event
* The event that caused the toggle change.
*/
#onDownloadsAutoHideChange(event) {
let checkbox = event.target.ownerDocument.getElementById(
kDownloadAutohideCheckboxId
);
Services.prefs.setBoolPref(kDownloadAutoHidePref, checkbox.checked);
// Ensure we move the button (back) after the user leaves customize mode.
event.view.gCustomizeMode.#moveDownloadsButtonToNavBar = checkbox.checked;
}
/**
* Called when the button to customize the macOS touchbar is clicked.
*/
#customizeTouchBar() {
let updater = Cc["@mozilla.org/widget/touchbarupdater;1"].getService(
Ci.nsITouchBarUpdater
);
updater.enterCustomizeMode();
}
/**
* This is a method to toggle pong on or off in customize mode. You heard me.
*
* @param {boolean} enabled
* True if pong should be launched, or false if it should be torn down.
*/
#togglePong(enabled) {
// It's possible we're toggling for a reason other than hitting
// the button (we might be exiting, for example), so make sure that
// the state and checkbox are in sync.
let whimsyButton = this.$("whimsy-button");
whimsyButton.checked = enabled;
if (enabled) {
this.visiblePalette.setAttribute("whimsypong", "true");
this.pongArena.hidden = false;
if (!this.uninitWhimsy) {
this.uninitWhimsy = this.#whimsypong();
}
} else {
this.visiblePalette.removeAttribute("whimsypong");
if (this.uninitWhimsy) {
this.uninitWhimsy();
this.uninitWhimsy = null;
}
this.pongArena.hidden = true;
}
}
/**
* This method contains a very simple implementation of a pong-like game.
* Calling this method presumes that the pongArea element is visible.
*
* @returns {Function}
* Returns a clean-up function which tears down the launched pong game.
*/
#whimsypong() {
function update() {
updateBall();
updatePlayers();
}
function updateBall() {
if (ball[1] <= 0 || ball[1] >= gameSide) {
if (
(ball[1] <= 0 && (ball[0] < p1 || ball[0] > p1 + paddleWidth)) ||
(ball[1] >= gameSide && (ball[0] < p2 || ball[0] > p2 + paddleWidth))
) {
updateScore(ball[1] <= 0 ? 0 : 1);
} else {
if (
(ball[1] <= 0 &&
(ball[0] - p1 < paddleEdge ||
p1 + paddleWidth - ball[0] < paddleEdge)) ||
(ball[1] >= gameSide &&
(ball[0] - p2 < paddleEdge ||
p2 + paddleWidth - ball[0] < paddleEdge))
) {
ballDxDy[0] *= Math.random() + 1.3;
ballDxDy[0] = Math.max(Math.min(ballDxDy[0], 6), -6);
if (Math.abs(ballDxDy[0]) == 6) {
ballDxDy[0] += Math.sign(ballDxDy[0]) * Math.random();
}
} else {
ballDxDy[0] /= 1.1;
}
ballDxDy[1] *= -1;
ball[1] = ball[1] <= 0 ? 0 : gameSide;
}
}
ball = [
Math.max(Math.min(ball[0] + ballDxDy[0], gameSide), 0),
Math.max(Math.min(ball[1] + ballDxDy[1], gameSide), 0),
];
if (ball[0] <= 0 || ball[0] >= gameSide) {
ballDxDy[0] *= -1;
}
}
function updatePlayers() {
if (keydown) {
let p1Adj = 1;
if (
(keydown == 37 && !window.RTL_UI) ||
(keydown == 39 && window.RTL_UI)
) {
p1Adj = -1;
}
p1 += p1Adj * 10 * keydownAdj;
}
let sign = Math.sign(ballDxDy[0]);
if (
(sign > 0 && ball[0] > p2 + paddleWidth / 2) ||
(sign < 0 && ball[0] < p2 + paddleWidth / 2)
) {
p2 += sign * 3;
} else if (
(sign > 0 && ball[0] > p2 + paddleWidth / 1.1) ||
(sign < 0 && ball[0] < p2 + paddleWidth / 1.1)
) {
p2 += sign * 9;
}
if (score >= winScore) {
p1 = ball[0];
p2 = ball[0];
}
p1 = Math.max(Math.min(p1, gameSide - paddleWidth), 0);
p2 = Math.max(Math.min(p2, gameSide - paddleWidth), 0);
}
function updateScore(adj) {
if (adj) {
score += adj;
} else if (--lives == 0) {
quit = true;
}
ball = ballDef.slice();
ballDxDy = ballDxDyDef.slice();
ballDxDy[1] *= score / winScore + 1;
}
function draw() {
let xAdj = window.RTL_UI ? -1 : 1;
elements["wp-player1"].style.transform =
"translate(" + xAdj * p1 + "px, -37px)";
elements["wp-player2"].style.transform =
"translate(" + xAdj * p2 + "px, " + gameSide + "px)";
elements["wp-ball"].style.transform =
"translate(" + xAdj * ball[0] + "px, " + ball[1] + "px)";
elements["wp-score"].textContent = score;
elements["wp-lives"].setAttribute("lives", lives);
if (score >= winScore) {
let arena = elements.arena;
let image = "url(chrome://browser/skin/customizableui/whimsy.png)";
let position = `${
(window.RTL_UI ? gameSide : 0) + xAdj * ball[0] - 10
}px ${ball[1] - 10}px`;
let repeat = "no-repeat";
let size = "20px";
if (arena.style.backgroundImage) {
if (arena.style.backgroundImage.split(",").length >= 160) {
quit = true;
}
image += ", " + arena.style.backgroundImage;
position += ", " + arena.style.backgroundPosition;
repeat += ", " + arena.style.backgroundRepeat;
size += ", " + arena.style.backgroundSize;
}
arena.style.backgroundImage = image;
arena.style.backgroundPosition = position;
arena.style.backgroundRepeat = repeat;
arena.style.backgroundSize = size;
}
}
function onkeydown(event) {
keys.push(event.which);
if (keys.length > 10) {
keys.shift();
let codeEntered = true;
for (let i = 0; i < keys.length; i++) {
if (keys[i] != keysCode[i]) {
codeEntered = false;
break;
}
}
if (codeEntered) {
elements.arena.setAttribute("kcode", "true");
let spacer = document.querySelector(
"#customization-palette > toolbarpaletteitem"
);
spacer.setAttribute("kcode", "true");
}
}
if (event.which == 37 /* left */ || event.which == 39 /* right */) {
keydown = event.which;
keydownAdj *= 1.05;
}
}
function onkeyup(event) {
if (event.which == 37 || event.which == 39) {
keydownAdj = 1;
keydown = 0;
}
}
function uninit() {
document.removeEventListener("keydown", onkeydown);
document.removeEventListener("keyup", onkeyup);
if (rAFHandle) {
window.cancelAnimationFrame(rAFHandle);
}
let arena = elements.arena;
while (arena.firstChild) {
arena.firstChild.remove();
}
arena.removeAttribute("score");
arena.removeAttribute("lives");
arena.removeAttribute("kcode");
arena.style.removeProperty("background-image");
arena.style.removeProperty("background-position");
arena.style.removeProperty("background-repeat");
arena.style.removeProperty("background-size");
let spacer = document.querySelector(
"#customization-palette > toolbarpaletteitem"
);
spacer.removeAttribute("kcode");
elements = null;
document = null;
quit = true;
}
if (this.uninitWhimsy) {
return this.uninitWhimsy;
}
let ballDef = [10, 10];
let ball = [10, 10];
let ballDxDyDef = [2, 2];
let ballDxDy = [2, 2];
let score = 0;
let p1 = 0;
let p2 = 10;
let gameSide = 300;
let paddleEdge = 30;
let paddleWidth = 84;
let keydownAdj = 1;
let keydown = 0;
let keys = [];
let keysCode = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65];
let lives = 5;
let winScore = 11;
let quit = false;
let document = this.#document;
let rAFHandle = 0;
let elements = {
arena: document.getElementById("customization-pong-arena"),
};
document.addEventListener("keydown", onkeydown);
document.addEventListener("keyup", onkeyup);
for (let id of ["player1", "player2", "ball", "score", "lives"]) {
let el = document.createXULElement("box");
el.id = "wp-" + id;
elements[el.id] = elements.arena.appendChild(el);
}
let spacer = this.visiblePalette.querySelector("toolbarpaletteitem");
for (let player of ["#wp-player1", "#wp-player2"]) {
let val = "-moz-element(#" + spacer.id + ") no-repeat";
elements.arena.querySelector(player).style.background = val;
}
let window = this.#window;
rAFHandle = window.requestAnimationFrame(function animate() {
update();
draw();
if (quit) {
elements["wp-score"].textContent = score;
elements["wp-lives"] &&
elements["wp-lives"].setAttribute("lives", lives);
elements.arena.setAttribute("score", score);
elements.arena.setAttribute("lives", lives);
} else {
rAFHandle = window.requestAnimationFrame(animate);
}
});
return uninit;
}
}
/**
* A utility function that, when in debug mode, can emit drag data through the
* debug logging mechanism for various drag and drop events.
*
* @param {DragEvent} aEvent
* The DragEvent to dump debug information to the log for.
* @param {string|null} caller
* An optional string to indicate the caller of the log message.
*/
function __dumpDragData(aEvent, caller) {
if (!gDebug) {
return;
}
let str =
"Dumping drag data (" +
(caller ? caller + " in " : "") +
"CustomizeMode.sys.mjs) {\n";
str += " type: " + aEvent.type + "\n";
for (let el of ["target", "currentTarget", "relatedTarget"]) {
if (aEvent[el]) {
str +=
" " +
el +
": " +
aEvent[el] +
"(localName=" +
aEvent[el].localName +
"; id=" +
aEvent[el].id +
")\n";
}
}
for (let prop in aEvent.dataTransfer) {
if (typeof aEvent.dataTransfer[prop] != "function") {
str +=
" dataTransfer[" + prop + "]: " + aEvent.dataTransfer[prop] + "\n";
}
}
str += "}";
lazy.log.debug(str);
}