ToolbarContextMenu.sys.mjs (22228B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 6 7 const lazy = {}; 8 ChromeUtils.defineESModuleGetters(lazy, { 9 AddonManager: "resource://gre/modules/AddonManager.sys.mjs", 10 CustomizableUI: 11 "moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs", 12 ExtensionsUI: "resource:///modules/ExtensionsUI.sys.mjs", 13 SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", 14 }); 15 16 XPCOMUtils.defineLazyPreferenceGetter( 17 lazy, 18 "gAlwaysOpenPanel", 19 "browser.download.alwaysOpenPanel", 20 true 21 ); 22 23 XPCOMUtils.defineLazyPreferenceGetter( 24 lazy, 25 "gAddonAbuseReportEnabled", 26 "extensions.abuseReport.enabled", 27 false 28 ); 29 30 /** 31 * Various events handlers to set the state of the toolbar-context-menu popup, 32 * as well as to handle some commands from that popup. 33 */ 34 export var ToolbarContextMenu = { 35 /** 36 * Makes visible the "autohide the downloads button" checkbox in the popup 37 * in the event that the downloads button was context clicked. Otherwise, 38 * hides that checkbox. 39 * 40 * This method also sets the checkbox state depending on the current user 41 * configuration for hiding the downloads button. 42 * 43 * @param {Element} popup 44 * The toolbar-context-menu element for a window. 45 */ 46 updateDownloadsAutoHide(popup) { 47 let { document, DownloadsButton } = popup.ownerGlobal; 48 let checkbox = document.getElementById( 49 "toolbar-context-autohide-downloads-button" 50 ); 51 let isDownloads = 52 popup.triggerNode && 53 ["downloads-button", "wrapper-downloads-button"].includes( 54 popup.triggerNode.id 55 ); 56 checkbox.hidden = !isDownloads; 57 checkbox.toggleAttribute( 58 "checked", 59 DownloadsButton.autoHideDownloadsButton 60 ); 61 }, 62 63 /** 64 * Handler for the toolbar-context-autohide-downloads-button command event 65 * that is fired when the checkbox for autohiding the downloads button is 66 * changed. This method does the work of updating the internal preference 67 * state for auto-hiding the downloads button. 68 * 69 * @param {CommandEvent} event 70 */ 71 onDownloadsAutoHideChange(event) { 72 let autoHide = event.target.hasAttribute("checked"); 73 Services.prefs.setBoolPref("browser.download.autohideButton", autoHide); 74 }, 75 76 /** 77 * Makes visible the "always open downloads panel" checkbox in the popup 78 * in the event that the downloads button was context clicked. Otherwise, 79 * hides that checkbox. 80 * 81 * This method also sets the checkbox state depending on the current user 82 * configuration for always showing the panel. 83 * 84 * @param {Element} popup 85 * The toolbar-context-menu element for a window. 86 */ 87 updateDownloadsAlwaysOpenPanel(popup) { 88 let { document } = popup.ownerGlobal; 89 let separator = document.getElementById( 90 "toolbarDownloadsAnchorMenuSeparator" 91 ); 92 let checkbox = document.getElementById( 93 "toolbar-context-always-open-downloads-panel" 94 ); 95 let isDownloads = 96 popup.triggerNode && 97 ["downloads-button", "wrapper-downloads-button"].includes( 98 popup.triggerNode.id 99 ); 100 separator.hidden = checkbox.hidden = !isDownloads; 101 checkbox.toggleAttribute("checked", lazy.gAlwaysOpenPanel); 102 }, 103 104 /** 105 * Handler for the toolbar-context-always-open-downloads-panel command event 106 * that is fired when the checkbox for always showing the downloads panel is 107 * changed. This method does the work of updating the internal preference 108 * state for always showing the downloads panel. 109 * 110 * @param {CommandEvent} event 111 */ 112 onDownloadsAlwaysOpenPanelChange(event) { 113 let alwaysOpen = event.target.hasAttribute("checked"); 114 Services.prefs.setBoolPref("browser.download.alwaysOpenPanel", alwaysOpen); 115 }, 116 117 /** 118 * This is called when a menupopup for configuring toolbars fires its 119 * popupshowing event. There are multiple such menupopups, and this logic 120 * tries to work for all of them. This method will insert menuitems into 121 * the popup to allow for controlling the toolbars within the browser 122 * toolbox. 123 * 124 * @param {Event} aEvent 125 * The popupshowing event for the menupopup. 126 * @param {DOMNode} aInsertPoint 127 * The point within the menupopup to insert the controls for each toolbar. 128 */ 129 // eslint-disable-next-line complexity 130 onViewToolbarsPopupShowing(aEvent, aInsertPoint) { 131 var popup = aEvent.target; 132 let window = popup.ownerGlobal; 133 let { 134 document, 135 BookmarkingUI, 136 MozXULElement, 137 onViewToolbarCommand, 138 showFullScreenViewContextMenuItems, 139 gBrowser, 140 CustomizationHandler, 141 gNavToolbox, 142 } = window; 143 144 // triggerNode can be a nested child element of a toolbaritem. 145 let toolbarItem = popup.triggerNode; 146 while (toolbarItem) { 147 let localName = toolbarItem.localName; 148 if (localName == "toolbar") { 149 toolbarItem = null; 150 break; 151 } 152 if (localName == "toolbarpaletteitem") { 153 toolbarItem = toolbarItem.firstElementChild; 154 break; 155 } 156 if (localName == "menupopup") { 157 aEvent.preventDefault(); 158 aEvent.stopPropagation(); 159 return; 160 } 161 let parent = toolbarItem.parentElement; 162 if (parent) { 163 if ( 164 parent.classList.contains("customization-target") || 165 parent.getAttribute("overflowfortoolbar") || // Needs to work in the overflow list as well. 166 parent.localName == "toolbarpaletteitem" || 167 parent.localName == "toolbar" || 168 parent.id == "vertical-tabs" 169 ) { 170 break; 171 } 172 } 173 toolbarItem = parent; 174 } 175 176 // Empty the menu 177 for (var i = popup.children.length - 1; i >= 0; --i) { 178 var deadItem = popup.children[i]; 179 if (deadItem.hasAttribute("toolbarId")) { 180 popup.removeChild(deadItem); 181 } 182 } 183 184 let showTabStripItems = toolbarItem?.id == "tabbrowser-tabs"; 185 let isVerticalTabStripMenu = 186 showTabStripItems && toolbarItem.parentElement.id == "vertical-tabs"; 187 188 if (aInsertPoint) { 189 aInsertPoint.hidden = isVerticalTabStripMenu; 190 } 191 document.getElementById("toolbar-context-customize").hidden = 192 isVerticalTabStripMenu; 193 194 if (!isVerticalTabStripMenu) { 195 MozXULElement.insertFTLIfNeeded("browser/toolbarContextMenu.ftl"); 196 let firstMenuItem = aInsertPoint || popup.firstElementChild; 197 let toolbarNodes = gNavToolbox.querySelectorAll("toolbar"); 198 for (let toolbar of toolbarNodes) { 199 if (!toolbar.hasAttribute("toolbarname")) { 200 continue; 201 } 202 203 if (toolbar.id == "PersonalToolbar") { 204 let menu = BookmarkingUI.buildBookmarksToolbarSubmenu(toolbar); 205 popup.insertBefore(menu, firstMenuItem); 206 } else { 207 let menuItem = document.createXULElement("menuitem"); 208 menuItem.setAttribute("id", "toggle_" + toolbar.id); 209 menuItem.setAttribute("toolbarId", toolbar.id); 210 menuItem.setAttribute("type", "checkbox"); 211 menuItem.setAttribute("label", toolbar.getAttribute("toolbarname")); 212 let hidingAttribute = 213 toolbar.getAttribute("type") == "menubar" 214 ? "autohide" 215 : "collapsed"; 216 menuItem.toggleAttribute( 217 "checked", 218 !toolbar.hasAttribute(hidingAttribute) 219 ); 220 menuItem.setAttribute("accesskey", toolbar.getAttribute("accesskey")); 221 222 popup.insertBefore(menuItem, firstMenuItem); 223 menuItem.addEventListener("command", onViewToolbarCommand); 224 } 225 } 226 } 227 228 let moveToPanel = popup.querySelector(".customize-context-moveToPanel"); 229 let removeFromToolbar = popup.querySelector( 230 ".customize-context-removeFromToolbar" 231 ); 232 233 let isTitlebarSpacer = toolbarItem?.classList.contains("titlebar-spacer"); 234 235 let isMenuBarSpacer = 236 toolbarItem?.localName == "spacer" && 237 toolbarItem?.parentElement?.id == "toolbar-menubar"; 238 239 // Show/hide fullscreen context menu items and set the 240 // autohide item's checked state to mirror the autohide pref. 241 showFullScreenViewContextMenuItems(popup); 242 243 // Show/hide sidebar and vertical tabs menu items 244 let sidebarRevampEnabled = Services.prefs.getBoolPref("sidebar.revamp"); 245 let showSidebarActions = 246 ["tabbrowser-tabs", "sidebar-button"].includes(toolbarItem?.id) || 247 toolbarItem?.localName == "toolbarspring" || 248 isTitlebarSpacer || 249 isMenuBarSpacer; 250 251 let toggleVerticalTabsItem = document.getElementById( 252 "toolbar-context-toggle-vertical-tabs" 253 ); 254 toggleVerticalTabsItem.hidden = !showSidebarActions; 255 document.l10n.setAttributes( 256 toggleVerticalTabsItem, 257 gBrowser.tabContainer?.verticalMode 258 ? "toolbar-context-turn-off-vertical-tabs" 259 : "toolbar-context-turn-on-vertical-tabs" 260 ); 261 document.getElementById("toolbar-context-customize-sidebar").hidden = 262 !sidebarRevampEnabled || 263 (toolbarItem?.id != "sidebar-button" && 264 !gBrowser.tabContainer?.verticalMode) || 265 (!["tabbrowser-tabs", "sidebar-button"].includes(toolbarItem?.id) && 266 gBrowser.tabContainer?.verticalMode); 267 document.getElementById("sidebarRevampSeparator").hidden = 268 !showSidebarActions || isVerticalTabStripMenu; 269 document.getElementById("customizationMenuSeparator").hidden = 270 toolbarItem?.id == "tabbrowser-tabs" || 271 (toolbarItem?.localName == "toolbarspring" && 272 !CustomizationHandler.isCustomizing()) || 273 isMenuBarSpacer || 274 isTitlebarSpacer; 275 276 // View -> Toolbars menu doesn't have the moveToPanel or removeFromToolbar items. 277 if (!moveToPanel || !removeFromToolbar) { 278 return; 279 } 280 281 for (let node of popup.querySelectorAll( 282 'menuitem[contexttype="toolbaritem"]' 283 )) { 284 node.hidden = showTabStripItems; 285 } 286 287 for (let node of popup.querySelectorAll('menuitem[contexttype="tabbar"]')) { 288 node.hidden = !showTabStripItems; 289 } 290 291 document 292 .getElementById("toolbar-context-menu") 293 .querySelectorAll("[data-lazy-l10n-id]") 294 .forEach(el => { 295 el.setAttribute("data-l10n-id", el.getAttribute("data-lazy-l10n-id")); 296 el.removeAttribute("data-lazy-l10n-id"); 297 }); 298 299 // The "normal" toolbar items menu separator is hidden because it's unused 300 // when hiding the "moveToPanel" and "removeFromToolbar" items on flexible 301 // space items. But we need to ensure its hidden state is reset in the case 302 // the context menu is subsequently opened on a non-flexible space item. 303 let menuSeparator = document.getElementById("tabbarItemsMenuSeparator"); 304 menuSeparator.hidden = false; 305 306 document.getElementById("toolbarNavigatorItemsMenuSeparator").hidden = 307 !showTabStripItems; 308 309 let isSpacerItem = 310 toolbarItem?.localName.includes("separator") || 311 toolbarItem?.localName.includes("spring") || 312 toolbarItem?.localName.includes("spacer") || 313 toolbarItem?.id.startsWith("customizableui-special"); 314 315 // For spacer items, customization items should only appear 316 // when the user is actively customizing the toolbar. 317 let shouldHideCustomizationItems = 318 isSpacerItem && !CustomizationHandler.isCustomizing(); 319 320 if (shouldHideCustomizationItems) { 321 moveToPanel.hidden = true; 322 removeFromToolbar.hidden = true; 323 menuSeparator.hidden = !showTabStripItems; 324 } 325 326 if (toolbarItem?.id != "tabbrowser-tabs") { 327 menuSeparator.hidden = true; 328 } 329 330 if (showTabStripItems) { 331 let multipleTabsSelected = !!gBrowser.multiSelectedTabsCount; 332 document.getElementById("toolbar-context-bookmarkSelectedTabs").hidden = 333 !multipleTabsSelected; 334 document.getElementById("toolbar-context-bookmarkSelectedTab").hidden = 335 multipleTabsSelected; 336 document.getElementById("toolbar-context-reloadSelectedTabs").hidden = 337 !multipleTabsSelected; 338 document.getElementById("toolbar-context-reloadSelectedTab").hidden = 339 multipleTabsSelected; 340 document.getElementById("toolbar-context-selectAllTabs").disabled = 341 gBrowser.allTabsSelected(); 342 let closedCount = lazy.SessionStore.getLastClosedTabCount(window); 343 document 344 .getElementById("History:UndoCloseTab") 345 .setAttribute("disabled", closedCount == 0); 346 document.l10n.setArgs( 347 document.getElementById("toolbar-context-undoCloseTab"), 348 { tabCount: closedCount } 349 ); 350 return; 351 } 352 353 let movable = 354 toolbarItem?.id && lazy.CustomizableUI.isWidgetRemovable(toolbarItem); 355 if (movable) { 356 if (lazy.CustomizableUI.isSpecialWidget(toolbarItem.id)) { 357 moveToPanel.setAttribute("disabled", true); 358 } else { 359 moveToPanel.removeAttribute("disabled"); 360 } 361 if (shouldHideCustomizationItems) { 362 removeFromToolbar.setAttribute("disabled", true); 363 } else { 364 removeFromToolbar.removeAttribute("disabled"); 365 } 366 } else { 367 removeFromToolbar.setAttribute("disabled", true); 368 moveToPanel.setAttribute("disabled", true); 369 } 370 }, 371 372 /** 373 * Given an opened menupopup, returns the triggerNode that opened that 374 * menupopup. If customize mode is enabled, this will return the unwrapped 375 * underlying triggerNode, rather than the customize mode wrapper around it. 376 * 377 * @param {DOMNode} popup 378 * The menupopup to get the unwrapped trigger node for. 379 * @returns {DOMNode} 380 * The underlying trigger node that opened the menupopup. 381 */ 382 _getUnwrappedTriggerNode(popup) { 383 // Toolbar buttons are wrapped in customize mode. Unwrap if necessary. 384 let { triggerNode } = popup; 385 let { gCustomizeMode } = popup.ownerGlobal; 386 if (triggerNode && gCustomizeMode.isWrappedToolbarItem(triggerNode)) { 387 return triggerNode.firstElementChild; 388 } 389 return triggerNode; 390 }, 391 392 /** 393 * For an opened menupopup, if the triggerNode was provided by an extension, 394 * returns the extension ID. Otherwise, return the empty string. 395 * 396 * @param {DOMNode} popup 397 * The menupopup that was opened. 398 * @returns {string} 399 * The ID of the extension that provided the triggerNode, or the empty 400 * string if the triggerNode was not provided by an extension. 401 */ 402 _getExtensionId(popup) { 403 let node = this._getUnwrappedTriggerNode(popup); 404 return node && node.getAttribute("data-extensionid"); 405 }, 406 407 /** 408 * For an opened menupopup, if the triggerNode was provided by an extension, 409 * returns the widget ID of the triggerNode. Otherwise, return the empty 410 * string. 411 * 412 * @param {DOMNode} popup 413 * The menupopup that was opened. 414 * @returns {string} 415 * The ID of the extension-provided widget that was the triggerNode, or the 416 * empty string if the trigger node was not provided by an extension 417 * widget. 418 */ 419 _getWidgetId(popup) { 420 let node = this._getUnwrappedTriggerNode(popup); 421 return node?.closest(".unified-extensions-item")?.id; 422 }, 423 424 /** 425 * Updates the toolbar context menu items unique to gUnifiedExtensions.button. 426 * 427 * @param {Element} popup 428 * The toolbar-context-menu element for a window. 429 */ 430 updateExtensionsButtonContextMenu(popup) { 431 const isExtsButton = popup.triggerNode?.id === "unified-extensions-button"; 432 const isCustomizingExtsButton = 433 popup.triggerNode?.id === "wrapper-unified-extensions-button"; 434 const { gUnifiedExtensions } = popup.ownerGlobal; 435 436 const checkbox = popup.querySelector( 437 "#toolbar-context-always-show-extensions-button" 438 ); 439 if (isCustomizingExtsButton) { 440 checkbox.hidden = false; 441 checkbox.toggleAttribute( 442 "checked", 443 gUnifiedExtensions.buttonAlwaysVisible 444 ); 445 } else if (isExtsButton && !gUnifiedExtensions.buttonAlwaysVisible) { 446 // The button may be visible despite the user's preference, which could 447 // remind the user of the button's existence. Offer an option to unhide 448 // the button, in case the user is looking for a way to do so. 449 checkbox.hidden = false; 450 checkbox.removeAttribute("checked"); 451 } else { 452 checkbox.hidden = true; 453 } 454 455 // removeFromToolbar is shown but disabled by default, via an earlier call 456 // to ToolbarContextMenu.onViewToolbarsPopupShowing. Enable/hide if needed. 457 if (isExtsButton) { 458 const removeFromToolbar = popup.querySelector( 459 ".customize-context-removeFromToolbar" 460 ); 461 if (gUnifiedExtensions.buttonAlwaysVisible) { 462 removeFromToolbar.removeAttribute("disabled"); 463 } else { 464 // No need to show "Remove from Toolbar" even if disabled, because the 465 // "Always Show in Toolbar" checkbox is already shown above. 466 removeFromToolbar.hidden = true; 467 } 468 } 469 }, 470 471 /** 472 * Updates the toolbar context menu to show the right state if an 473 * extension-provided widget acted as the triggerNode. This will, for example, 474 * show or hide items for managing the underlying addon. 475 * 476 * @param {DOMNode} popup 477 * The menupopup for the toolbar context menu. 478 * @returns {Promise<undefined>} 479 * Resolves once the menupopup state has been set. 480 */ 481 async updateExtension(popup) { 482 let removeExtension = popup.querySelector( 483 ".customize-context-removeExtension" 484 ); 485 let manageExtension = popup.querySelector( 486 ".customize-context-manageExtension" 487 ); 488 let reportExtension = popup.querySelector( 489 ".customize-context-reportExtension" 490 ); 491 let pinToToolbar = popup.querySelector(".customize-context-pinToToolbar"); 492 let separator = reportExtension.nextElementSibling; 493 let id = this._getExtensionId(popup); 494 let addon = id && (await lazy.AddonManager.getAddonByID(id)); 495 496 for (let element of [removeExtension, manageExtension, separator]) { 497 element.hidden = !addon; 498 } 499 500 if (pinToToolbar) { 501 pinToToolbar.hidden = !addon; 502 } 503 504 reportExtension.hidden = !addon || !lazy.gAddonAbuseReportEnabled; 505 506 if (addon) { 507 popup.querySelector(".customize-context-moveToPanel").hidden = true; 508 popup.querySelector(".customize-context-removeFromToolbar").hidden = true; 509 510 if (pinToToolbar) { 511 let widgetId = this._getWidgetId(popup); 512 if (widgetId) { 513 let area = lazy.CustomizableUI.getPlacementOfWidget(widgetId).area; 514 let inToolbar = area != lazy.CustomizableUI.AREA_ADDONS; 515 pinToToolbar.toggleAttribute("checked", inToolbar); 516 } 517 } 518 519 removeExtension.disabled = !( 520 addon.permissions & lazy.AddonManager.PERM_CAN_UNINSTALL 521 ); 522 523 if (popup.id === "toolbar-context-menu") { 524 lazy.ExtensionsUI.originControlsMenu(popup, id); 525 } 526 } 527 }, 528 529 /** 530 * Handler for the context menu item for removing an extension. 531 * 532 * @param {DOMNode} popup 533 * The menupopup that triggered the extension removal. 534 * @returns {Promise<undefined>} 535 * Resolves when the extension has been removed. 536 */ 537 async removeExtensionForContextAction(popup) { 538 let { BrowserAddonUI } = popup.ownerGlobal; 539 540 let id = this._getExtensionId(popup); 541 await BrowserAddonUI.removeAddon(id, "browserAction"); 542 }, 543 544 /** 545 * Handler for the context menu item for issuing a report on an extension. 546 * 547 * @param {DOMNode} popup 548 * The menupopup that triggered the extension report. 549 * @param {string} reportEntryPoint 550 * A string describing the UI entrypoint for the report. 551 * @returns {Promise<undefined>} 552 * Resolves when the extension has been removed. 553 */ 554 async reportExtensionForContextAction(popup, reportEntryPoint) { 555 let { BrowserAddonUI } = popup.ownerGlobal; 556 let id = this._getExtensionId(popup); 557 await BrowserAddonUI.reportAddon(id, reportEntryPoint); 558 }, 559 560 /** 561 * Handler for the context menu item for managing an extension. 562 * 563 * @param {DOMNode} popup 564 * The menupopup that triggered extension management. 565 * @returns {Promise<undefined>} 566 * Resolves when the extension's about:addons management page has been 567 * opened. 568 */ 569 async openAboutAddonsForContextAction(popup) { 570 let { BrowserAddonUI } = popup.ownerGlobal; 571 let id = this._getExtensionId(popup); 572 await BrowserAddonUI.manageAddon(id, "browserAction"); 573 }, 574 575 /** 576 * Hides the first visible menu separator if it would appear at the top of the 577 * toolbar context menu (i.e., when all preceding menu items are hidden). This 578 * prevents a separator from appearing at the top of the menu with no items above it. 579 * 580 * Fix for Bug 1955241. 581 * 582 * @param {Element} popup 583 * The toolbar-context-menu element for a window. 584 */ 585 hideLeadingSeparatorIfNeeded(popup) { 586 // Find the first non-hidden element in the menu 587 let firstVisibleElement = popup.firstElementChild; 588 while (firstVisibleElement && firstVisibleElement.hidden) { 589 firstVisibleElement = firstVisibleElement.nextElementSibling; 590 } 591 592 // If the first visible element is a separator, hide it 593 if ( 594 firstVisibleElement && 595 firstVisibleElement.localName === "menuseparator" 596 ) { 597 firstVisibleElement.hidden = true; 598 } 599 }, 600 601 /** 602 * Hides the "Move to Panel" and "Remove from Toolbar" items if both are 603 * disabled. This prevents showing a menu with no useful items. If at least 604 * one of the items is enabled, both items are shown for consistency. 605 * 606 * This is its own method to allow it to be called after other methods 607 * that may change the disabled state of either menu item. 608 * 609 * @param {Element} popup 610 * The toolbar-context-menu element for a window. 611 */ 612 updateCustomizationItemsVisibility(popup) { 613 let moveToPanel = popup.querySelector(".customize-context-moveToPanel"); 614 let removeFromToolbar = popup.querySelector( 615 ".customize-context-removeFromToolbar" 616 ); 617 618 if ( 619 removeFromToolbar?.getAttribute("disabled") && 620 moveToPanel.getAttribute("disabled") 621 ) { 622 removeFromToolbar.hidden = true; 623 moveToPanel.hidden = true; 624 } 625 }, 626 };