browser-sidebar.js (82132B)
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 /** 6 * SidebarController handles logic such as toggling sidebar panels, 7 * dynamically adding menubar menu items for the View -> Sidebar menu, 8 * and provides APIs for sidebar extensions, etc. 9 */ 10 11 const { DeferredTask } = ChromeUtils.importESModule( 12 "resource://gre/modules/DeferredTask.sys.mjs" 13 ); 14 15 const toolsNameMap = { 16 viewGenaiChatSidebar: "aichat", 17 viewGenaiPageAssistSidebar: "aipageassist", 18 viewGenaiSmartAssistSidebar: "aismartassist", 19 viewTabsSidebar: "syncedtabs", 20 viewHistorySidebar: "history", 21 viewBookmarksSidebar: "bookmarks", 22 viewCPMSidebar: "passwords", 23 }; 24 const EXPAND_ON_HOVER_DEBOUNCE_TIMEOUT_MS = 1000; 25 const LAUNCHER_SPLITTER_WIDTH = 4; 26 27 var SidebarController = { 28 makeSidebar({ elementId, ...rest }, commandID) { 29 const sidebar = { 30 get sourceL10nEl() { 31 return document.getElementById(elementId); 32 }, 33 get title() { 34 let element = document.getElementById(elementId); 35 return element?.getAttribute("label"); 36 }, 37 ...rest, 38 }; 39 40 const toolID = toolsNameMap[commandID]; 41 if (toolID) { 42 XPCOMUtils.defineLazyPreferenceGetter( 43 sidebar, 44 "attention", 45 `sidebar.notification.badge.${toolID}`, 46 false, 47 (_pref, _prev) => this.handleToolBadges(toolID) 48 ); 49 sidebar.attention; 50 } 51 52 return sidebar; 53 }, 54 55 registerPrefSidebar(pref, commandID, config) { 56 const sidebar = this.makeSidebar(config, commandID); 57 this._sidebars.set(commandID, sidebar); 58 59 let switcherMenuitem; 60 const updateMenus = visible => { 61 // Hide the sidebar if it is open and should not be visible, 62 // and unset the current command and lastOpenedId so they do not 63 // re-open the next time the sidebar does. 64 if (!visible && this._state.command == commandID) { 65 this._state.command = ""; 66 this.lastOpenedId = null; 67 this.hide(); 68 } 69 70 // Update visibility of View -> Sidebar menu item. 71 const viewItem = document.getElementById(sidebar.menuId); 72 if (viewItem) { 73 viewItem.hidden = !visible; 74 } 75 76 let menuItem = document.getElementById(config.elementId); 77 // Add/remove switcher menu item. 78 if (visible && !menuItem) { 79 switcherMenuitem = this.createMenuItem(commandID, sidebar); 80 switcherMenuitem.setAttribute("id", config.elementId); 81 switcherMenuitem.removeAttribute("type"); 82 const separator = this._switcherPanel.querySelector("menuseparator"); 83 separator.parentNode.insertBefore(switcherMenuitem, separator); 84 } else { 85 switcherMenuitem?.remove(); 86 } 87 88 window.dispatchEvent(new CustomEvent("SidebarItemChanged")); 89 }; 90 91 // Detect pref changes and handle initial state. 92 XPCOMUtils.defineLazyPreferenceGetter( 93 sidebar, 94 "visible", 95 pref, 96 false, 97 (_pref, _prev, val) => updateMenus(val) 98 ); 99 this.promiseInitialized.then(() => updateMenus(sidebar.visible)); 100 }, 101 102 isAIWindow() { 103 return this.AIWindow.isAIWindowActive(window); 104 }, 105 106 get sidebars() { 107 if (this._sidebars) { 108 return this._sidebars; 109 } 110 111 return this.generateSidebarsMap(); 112 }, 113 114 generateSidebarsMap() { 115 this._sidebars = new Map([ 116 [ 117 "viewHistorySidebar", 118 this.makeSidebar({ 119 name: "history", 120 elementId: "sidebar-switcher-history", 121 // sidebar-history.html requires the "firefoxview" component and 122 // requires more work. Stick to historySidebar.xhtml for ESR 140. 123 // See tor-browser#44108. 124 url: "chrome://browser/content/places/historySidebar.xhtml", 125 menuId: "menu_historySidebar", 126 triggerButtonId: "appMenuViewHistorySidebar", 127 keyId: "key_gotoHistory", 128 menuL10nId: "menu-view-history-button", 129 revampL10nId: "sidebar-menu-history-label", 130 iconUrl: "chrome://browser/skin/history.svg", 131 contextMenuId: this.sidebarRevampEnabled 132 ? "sidebar-history-context-menu" 133 : undefined, 134 gleanEvent: Glean.history.sidebarToggle, 135 gleanClickEvent: Glean.sidebar.historyIconClick, 136 recordSidebarVersion: true, 137 // In permanent private browsing, the history panel can be opened, but 138 // we hide the sidebar button to control this. tor-browser#43902. 139 visible: !PrivateBrowsingUtils.permanentPrivateBrowsing, 140 }), 141 ], 142 [ 143 "viewTabsSidebar", 144 this.makeSidebar({ 145 name: "syncedtabs", 146 elementId: "sidebar-switcher-tabs", 147 url: this.sidebarRevampEnabled 148 ? "chrome://browser/content/sidebar/sidebar-syncedtabs.html" 149 : "chrome://browser/content/syncedtabs/sidebar.xhtml", 150 menuId: "menu_tabsSidebar", 151 classAttribute: "sync-ui-item", 152 menuL10nId: "menu-view-synced-tabs-sidebar", 153 revampL10nId: "sidebar-menu-synced-tabs-label", 154 iconUrl: "chrome://browser/skin/synced-tabs.svg", 155 contextMenuId: this.sidebarRevampEnabled 156 ? "sidebar-synced-tabs-context-menu" 157 : undefined, 158 gleanClickEvent: Glean.sidebar.syncedTabsIconClick, 159 // firefoxview is disabled. tor-browser#42037 and tor-browser#43902. 160 // See bugzilla bug 1983505. 161 // NOTE: The menuId and elementId menu items (sidebar switchers) 162 // should be hidden via the `sync-ui-item` class, which will *one* 163 // time hide the menu items via gSync.init. 164 // #sidebar-switcher-tabs is already in the initial browser DOM, 165 // and #menu_tabsSidebar is created during SidebarController.init, 166 // which seems to run prior to gSync.init. 167 visible: false, 168 }), 169 ], 170 [ 171 "viewBookmarksSidebar", 172 this.makeSidebar({ 173 name: "bookmarks", 174 elementId: "sidebar-switcher-bookmarks", 175 url: "chrome://browser/content/places/bookmarksSidebar.xhtml", 176 menuId: "menu_bookmarksSidebar", 177 keyId: "viewBookmarksSidebarKb", 178 menuL10nId: "menu-view-bookmarks", 179 revampL10nId: "sidebar-menu-bookmarks-label", 180 iconUrl: "chrome://browser/skin/bookmark-hollow.svg", 181 disabled: true, 182 gleanEvent: Glean.bookmarks.sidebarToggle, 183 gleanClickEvent: Glean.sidebar.bookmarksIconClick, 184 recordSidebarVersion: true, 185 }), 186 ], 187 ]); 188 189 if (!this.isAIWindow()) { 190 this.registerPrefSidebar( 191 "browser.ml.chat.enabled", 192 "viewGenaiChatSidebar", 193 { 194 name: "aichat", 195 elementId: "sidebar-switcher-genai-chat", 196 url: "chrome://browser/content/genai/chat.html", 197 keyId: "viewGenaiChatSidebarKb", 198 menuId: "menu_genaiChatSidebar", 199 menuL10nId: "menu-view-genai-chat", 200 // Bug 1900915 to expose as conditional tool 201 revampL10nId: "sidebar-menu-genai-chat-label", 202 iconUrl: "chrome://global/skin/icons/highlights.svg", 203 gleanClickEvent: Glean.sidebar.chatbotIconClick, 204 toolContextMenuId: "aichat", 205 } 206 ); 207 } 208 209 this.registerPrefSidebar( 210 "browser.ml.pageAssist.enabled", 211 "viewGenaiPageAssistSidebar", 212 { 213 name: "aipageassist", 214 elementId: "sidebar-switcher-genai-page-assist", 215 url: "chrome://browser/content/genai/pageAssist.html", 216 menuId: "menu_genaiPageAssistSidebar", 217 menuL10nId: "menu-view-genai-page-assist", 218 revampL10nId: "sidebar-menu-genai-page-assist-label", 219 iconUrl: "chrome://browser/skin/reader-mode.svg", 220 } 221 ); 222 223 this.registerPrefSidebar( 224 "browser.ml.smartAssist.enabled", 225 "viewGenaiSmartAssistSidebar", 226 { 227 name: "aismartassist", 228 elementId: "sidebar-switcher-genai-smart-assist", 229 url: "chrome://browser/content/genai/smartAssist.html", 230 menuId: "menu_genaiSmartAssistSidebar", 231 menuL10nId: "menu-view-genai-smart-assist", 232 revampL10nId: "sidebar-menu-genai-smart-assist-label", 233 iconUrl: "chrome://browser/skin/trending.svg", 234 } 235 ); 236 237 this.registerPrefSidebar( 238 "browser.contextual-password-manager.enabled", 239 "viewCPMSidebar", 240 { 241 name: "passwords", 242 elementId: "sidebar-switcher-megalist", 243 url: "chrome://global/content/megalist/megalist.html", 244 menuId: "menu_megalistSidebar", 245 menuL10nId: "menu-view-contextual-password-manager", 246 revampL10nId: "sidebar-menu-contextual-password-manager-label", 247 iconUrl: "chrome://browser/skin/login.svg", 248 gleanEvent: Glean.contextualManager.sidebarToggle, 249 } 250 ); 251 252 if (this.sidebarRevampEnabled) { 253 this._sidebars.set("viewCustomizeSidebar", { 254 url: "chrome://browser/content/sidebar/sidebar-customize.html", 255 revampL10nId: "sidebar-menu-customize-label", 256 iconUrl: "chrome://global/skin/icons/settings.svg", 257 gleanEvent: Glean.sidebarCustomize.panelToggle, 258 visible: false, 259 }); 260 } 261 262 return this._sidebars; 263 }, 264 265 /** 266 * Returns a map of tools and extensions for use in the sidebar 267 */ 268 get toolsAndExtensions() { 269 if (this._toolsAndExtensions) { 270 return this._toolsAndExtensions; 271 } 272 273 this._toolsAndExtensions = new Map(); 274 this.getTools().forEach(tool => { 275 this._toolsAndExtensions.set(tool.commandID, tool); 276 }); 277 this.getExtensions().forEach(extension => { 278 this._toolsAndExtensions.set(extension.commandID, extension); 279 }); 280 return this._toolsAndExtensions; 281 }, 282 283 // Avoid getting the browser element from init() to avoid triggering the 284 // <browser> constructor during startup if the sidebar is hidden. 285 get browser() { 286 if (this._browser) { 287 return this._browser; 288 } 289 return (this._browser = document.getElementById("sidebar")); 290 }, 291 POSITION_START_PREF: "sidebar.position_start", 292 DEFAULT_SIDEBAR_ID: "viewBookmarksSidebar", 293 VISIBILITY_PREF: "sidebar.visibility", 294 TOOLS_PREF: "sidebar.main.tools", 295 INSTALLED_EXTENSIONS: "sidebar.installed.extensions", 296 297 // lastOpenedId is set in show() but unlike currentID it's not cleared out on hide 298 // and isn't persisted across windows 299 lastOpenedId: null, 300 301 _box: null, 302 _pinnedTabsContainer: null, 303 _pinnedTabsItemsWrapper: null, 304 // The constructor of this label accesses the browser element due to the 305 // control="sidebar" attribute, so avoid getting this label during startup. 306 get _title() { 307 if (this.__title) { 308 return this.__title; 309 } 310 return (this.__title = document.getElementById("sidebar-title")); 311 }, 312 _splitter: null, 313 _reversePositionButton: null, 314 _switcherPanel: null, 315 _switcherTarget: null, 316 _switcherArrow: null, 317 _inited: false, 318 _uninitializing: false, 319 _switcherListenersAdded: false, 320 _verticalNewTabListenerAdded: false, 321 _localesObserverAdded: false, 322 _mainResizeObserverAdded: false, 323 _mainResizeObserver: null, 324 _ongoingAnimations: [], 325 326 /** 327 * @type {MutationObserver | null} 328 */ 329 _observer: null, 330 331 _initDeferred: Promise.withResolvers(), 332 333 get promiseInitialized() { 334 return this._initDeferred.promise; 335 }, 336 337 get initialized() { 338 return this._inited; 339 }, 340 341 get uninitializing() { 342 return this._uninitializing; 343 }, 344 345 get inSingleTabWindow() { 346 return ( 347 !window.toolbar.visible || 348 window.document.documentElement.hasAttribute("taskbartab") 349 ); 350 }, 351 352 get sidebarContainer() { 353 if (!this._sidebarContainer) { 354 // This is the *parent* of the `sidebar-main` component. 355 // TODO: Rename this element in the markup in order to avoid confusion. (Bug 1904860) 356 this._sidebarContainer = document.getElementById("sidebar-main"); 357 } 358 return this._sidebarContainer; 359 }, 360 361 get sidebarMain() { 362 if (!this._sidebarMain) { 363 this._sidebarMain = document.querySelector("sidebar-main"); 364 } 365 return this._sidebarMain; 366 }, 367 368 get contentArea() { 369 if (!this._contentArea) { 370 this._contentArea = document.getElementById("tabbrowser-tabbox"); 371 } 372 return this._contentArea; 373 }, 374 375 get toolbarButton() { 376 if (!this._toolbarButton) { 377 this._toolbarButton = document.getElementById("sidebar-button"); 378 } 379 return this._toolbarButton; 380 }, 381 382 get isLauncherDragging() { 383 return this._launcherSplitter.getAttribute("state") === "dragging"; 384 }, 385 386 get isPinnedTabsDragging() { 387 return this._pinnedTabsSplitter.getAttribute("state") === "dragging"; 388 }, 389 390 get sidebarTools() { 391 return this.sidebarRevampTools ? this.sidebarRevampTools.split(",") : []; 392 }, 393 394 get sidebarExtensions() { 395 return this.installedExtensions ? this.installedExtensions.split(",") : []; 396 }, 397 398 init() { 399 // Initialize global state manager. 400 this.SidebarManager; 401 402 // Initialize per-window state manager. 403 if (!this._state) { 404 this._state = new this.SidebarState(this); 405 } 406 407 this._pinnedTabsContainer = document.getElementById( 408 "pinned-tabs-container" 409 ); 410 this._pinnedTabsItemsWrapper = 411 this._pinnedTabsContainer.shadowRoot.querySelector( 412 "[part=items-wrapper]" 413 ); 414 this._box = document.getElementById("sidebar-box"); 415 this._splitter = document.getElementById("sidebar-splitter"); 416 this._launcherSplitter = document.getElementById( 417 "sidebar-launcher-splitter" 418 ); 419 this._pinnedTabsSplitter = document.getElementById( 420 "vertical-pinned-tabs-splitter" 421 ); 422 this._reversePositionButton = document.getElementById( 423 "sidebar-reverse-position" 424 ); 425 this._switcherPanel = document.getElementById("sidebarMenu-popup"); 426 this._switcherTarget = document.getElementById("sidebar-switcher-target"); 427 this._switcherArrow = document.getElementById("sidebar-switcher-arrow"); 428 this._hoverBlockerCount = 0; 429 if ( 430 Services.prefs.getBoolPref( 431 "browser.tabs.allow_transparent_browser", 432 false 433 ) 434 ) { 435 this.browser.setAttribute("transparent", "true"); 436 } 437 438 const menubar = document.getElementById("viewSidebarMenu"); 439 const currentMenuItems = new Set( 440 Array.from(menubar.childNodes, item => item.id) 441 ); 442 for (const [commandID, sidebar] of this.sidebars.entries()) { 443 if ( 444 !Object.hasOwn(sidebar, "extensionId") && 445 commandID !== "viewCustomizeSidebar" && 446 !currentMenuItems.has(sidebar.menuId) 447 ) { 448 // registerExtension() already creates menu items for extensions. 449 const menuitem = this.createMenuItem(commandID, sidebar); 450 menubar.appendChild(menuitem); 451 } 452 } 453 if (this._mainResizeObserver) { 454 this._mainResizeObserver.disconnect(); 455 this._mainResizeObserverAdded = false; 456 } 457 this._mainResizeObserver = new ResizeObserver(([entry]) => 458 this._handleLauncherResize(entry) 459 ); 460 461 if (this.sidebarRevampEnabled && !BrowserHandler.kiosk) { 462 if (!customElements.get("sidebar-main")) { 463 ChromeUtils.importESModule( 464 "chrome://browser/content/sidebar/sidebar-main.mjs", 465 { global: "current" } 466 ); 467 } 468 this.revampComponentsLoaded = true; 469 this._state.initializeState(this._showLauncherAfterInit); 470 // clear the flag after we've used it 471 delete this._showLauncherAfterInit; 472 473 document.getElementById("sidebar-header").hidden = true; 474 if (!this._mainResizeObserverAdded) { 475 this._mainResizeObserver.observe(this.sidebarMain); 476 this._mainResizeObserverAdded = true; 477 } 478 if (!this._browserResizeObserver) { 479 this._browserResizeObserver = () => { 480 // Report resize events to Glean. 481 const current = this.browser.getBoundingClientRect().width; 482 const previous = this._browserWidth; 483 const percentage = (current / window.innerWidth) * 100; 484 Glean.sidebar.resize.record({ 485 current: Math.round(current), 486 previous: Math.round(previous), 487 percentage: Math.round(percentage), 488 }); 489 this._recordBrowserSize(); 490 }; 491 this._splitter.addEventListener("command", this._browserResizeObserver); 492 } 493 this._enableLauncherDragging(); 494 this._enablePinnedTabsSplitterDragging(); 495 496 // Record Glean metrics. 497 this.recordVisibilitySetting(); 498 this.recordPositionSetting(); 499 this.recordTabsLayoutSetting(); 500 } else { 501 this._switcherCloseButton = document.getElementById("sidebar-close"); 502 if (!this._switcherListenersAdded) { 503 this._switcherCloseButton.addEventListener("command", () => { 504 this.hide(); 505 }); 506 this._switcherTarget.addEventListener("command", () => { 507 this.toggleSwitcherPanel(); 508 }); 509 this._switcherTarget.addEventListener("keydown", event => { 510 this.handleKeydown(event); 511 }); 512 this._switcherListenersAdded = true; 513 } 514 this._disableLauncherDragging(); 515 this._disablePinnedTabsDragging(); 516 } 517 // We need to update the tab strip for vertical tabs during init 518 // as there will be no tabstrip-orientation-change event 519 if (CustomizableUI.verticalTabsEnabled) { 520 this.toggleTabstrip(); 521 } 522 523 // sets the sidebar to the left or right, based on a pref 524 this.setPosition(); 525 526 this._inited = true; 527 528 if (!this._localesObserverAdded) { 529 Services.obs.addObserver(this, "intl:app-locales-changed"); 530 this._localesObserverAdded = true; 531 } 532 if (!this._tabstripOrientationObserverAdded) { 533 Services.obs.addObserver(this, "tabstrip-orientation-change"); 534 this._tabstripOrientationObserverAdded = true; 535 } 536 537 requestIdleCallback(() => { 538 const windowPrivacyMatches = 539 !window.opener || this.windowPrivacyMatches(window.opener, window); 540 // If other sources (like session store or source window) haven't set the 541 // UI state at this point, load the backup state. (Do not load the backup 542 // state if this is a popup, or we are coming from a window of a different 543 // privacy level.) 544 if ( 545 !this.uiStateInitialized && 546 !this.inSingleTabWindow && 547 (this.sidebarRevampEnabled || windowPrivacyMatches) 548 ) { 549 const backupState = this.SidebarManager.getBackupState(); 550 this.initializeUIState(backupState); 551 } 552 }); 553 this._initDeferred.resolve(); 554 }, 555 556 uninit() { 557 // Set a flag to allow us to ignore pref changes while the host document is being unloaded. 558 this._uninitializing = true; 559 560 // If this is the last browser window, persist various values that should be 561 // remembered for after a restart / reopening a browser window. 562 let enumerator = Services.wm.getEnumerator("navigator:browser"); 563 if (!enumerator.hasMoreElements()) { 564 let xulStore = Services.xulStore; 565 xulStore.persist(this._title, "value"); 566 567 const currentState = this.getUIState(); 568 this.SidebarManager.setBackupState(currentState); 569 } 570 571 Services.obs.removeObserver(this, "intl:app-locales-changed"); 572 Services.obs.removeObserver(this, "tabstrip-orientation-change"); 573 delete this._tabstripOrientationObserverAdded; 574 575 CustomizableUI.removeListener(this); 576 577 if (this._observer) { 578 this._observer.disconnect(); 579 this._observer = null; 580 } 581 582 if (this._mainResizeObserver) { 583 this._mainResizeObserver.disconnect(); 584 this._mainResizeObserver = null; 585 } 586 587 if (this.revampComponentsLoaded) { 588 // Explicitly disconnect the `sidebar-main` element so that listeners 589 // setup by reactive controllers will also be removed. 590 this.sidebarMain.remove(); 591 } 592 this._splitter.removeEventListener("command", this._browserResizeObserver); 593 this._disableLauncherDragging(); 594 this._disablePinnedTabsDragging(); 595 }, 596 597 /** 598 * Keep track when sidebar.revamp is enabled by the user via about:preferences UI 599 * 600 * @param {boolean} isEnabled 601 */ 602 enabledViaSettings(isEnabled = false) { 603 this._showLauncherAfterInit = isEnabled; 604 }, 605 606 /** 607 * Handle the launcher being resized (either manually or programmatically). 608 * 609 * @param {ResizeObserverEntry} entry 610 */ 611 _handleLauncherResize(entry) { 612 this._state.launcherWidth = entry.contentBoxSize[0].inlineSize; 613 if (this.isLauncherDragging) { 614 this._state.launcherDragActive = true; 615 } 616 if (this._state.visibilitySetting === "expand-on-hover") { 617 this.setLauncherCollapsedWidth(); 618 } 619 }, 620 621 getUIState() { 622 if (this.inSingleTabWindow) { 623 return null; 624 } 625 return this._state.getProperties(); 626 }, 627 628 /** 629 * Load the UI state information given by session store, backup state, or 630 * adopted window. 631 * 632 * @param {SidebarStateProps} state 633 */ 634 async initializeUIState(state) { 635 if (!state) { 636 return; 637 } 638 const isValidSidebar = !state.command || this.sidebars.has(state.command); 639 if (!isValidSidebar) { 640 state.command = ""; 641 } 642 643 const hasOpenPanel = 644 state.panelOpen && 645 state.command && 646 this.sidebars.has(state.command) && 647 this.currentID !== state.command; 648 if (hasOpenPanel) { 649 // There's a panel to show, so ignore the contradictory hidden property. 650 delete state.hidden; 651 } 652 await this.promiseInitialized; 653 await this.waitUntilStable(); // Finish currently scheduled tasks. 654 await this._state.loadInitialState(state); 655 await this.waitUntilStable(); // Finish newly scheduled tasks. 656 this.updateToolbarButton(); 657 if (this.sidebarRevampVisibility === "expand-on-hover") { 658 await this.toggleExpandOnHover(true); 659 } 660 this.uiStateInitialized = true; 661 }, 662 663 /** 664 * Toggle the vertical tabs preference. 665 */ 666 toggleVerticalTabs() { 667 Services.prefs.setBoolPref( 668 "sidebar.verticalTabs", 669 !this.sidebarVerticalTabsEnabled 670 ); 671 }, 672 673 /** 674 * The handler for Services.obs.addObserver. 675 */ 676 observe(_subject, topic, _data) { 677 switch (topic) { 678 case "intl:app-locales-changed": { 679 if (this.isOpen) { 680 // The <tree> component used in history and bookmarks, but it does not 681 // support live switching the app locale. Reload the entire sidebar to 682 // invalidate any old text. 683 this.hide({ dismissPanel: false }); 684 this.showInitially(this.lastOpenedId); 685 break; 686 } 687 if (this.revampComponentsLoaded) { 688 this.sidebarMain.requestUpdate(); 689 } 690 break; 691 } 692 case "tabstrip-orientation-change": { 693 this.promiseInitialized.then(() => this.toggleTabstrip()); 694 break; 695 } 696 } 697 }, 698 699 /** 700 * Ensure the title stays in sync with the source element, which updates for 701 * l10n changes. 702 * 703 * @param {HTMLElement} [element] 704 */ 705 observeTitleChanges(element) { 706 if (!element) { 707 return; 708 } 709 let observer = this._observer; 710 if (!observer) { 711 observer = new MutationObserver(() => { 712 // it's possible for lastOpenedId to be null here 713 this.title = this.sidebars.get(this.lastOpenedId)?.title; 714 }); 715 // Re-use the observer. 716 this._observer = observer; 717 } 718 observer.disconnect(); 719 observer.observe(element, { 720 attributes: true, 721 attributeFilter: ["label"], 722 }); 723 }, 724 725 /** 726 * Opens the switcher panel if it's closed, or closes it if it's open. 727 */ 728 toggleSwitcherPanel() { 729 if ( 730 this._switcherPanel.state == "open" || 731 this._switcherPanel.state == "showing" 732 ) { 733 this.hideSwitcherPanel(); 734 } else if (this._switcherPanel.state == "closed") { 735 this.showSwitcherPanel(); 736 } 737 }, 738 739 /** 740 * Handles keydown on the the switcherTarget button 741 * 742 * @param {Event} event 743 */ 744 handleKeydown(event) { 745 switch (event.key) { 746 case "Enter": 747 case " ": { 748 this.toggleSwitcherPanel(); 749 event.stopPropagation(); 750 event.preventDefault(); 751 break; 752 } 753 case "Escape": { 754 this.hideSwitcherPanel(); 755 event.stopPropagation(); 756 event.preventDefault(); 757 break; 758 } 759 } 760 }, 761 762 hideSwitcherPanel() { 763 this._switcherPanel.hidePopup(); 764 }, 765 766 showSwitcherPanel() { 767 this._switcherPanel.addEventListener( 768 "popuphiding", 769 () => { 770 this._switcherTarget.classList.remove("active"); 771 this._switcherTarget.setAttribute("aria-expanded", false); 772 }, 773 { once: true } 774 ); 775 776 // Combine start/end position with ltr/rtl to set the label in the popup appropriately. 777 let label = 778 this._positionStart == RTL_UI 779 ? gNavigatorBundle.getString("sidebar.moveToLeft") 780 : gNavigatorBundle.getString("sidebar.moveToRight"); 781 this._reversePositionButton.setAttribute("label", label); 782 783 // Open the sidebar switcher popup, anchored off the switcher toggle 784 this._switcherPanel.hidden = false; 785 this._switcherPanel.openPopup(this._switcherTarget); 786 787 this._switcherTarget.classList.add("active"); 788 this._switcherTarget.setAttribute("aria-expanded", true); 789 }, 790 791 updateShortcut({ keyId }) { 792 let menuitem = this._switcherPanel?.querySelector(`[key="${keyId}"]`); 793 if (!menuitem) { 794 // If the menu item doesn't exist yet then the accel text will be set correctly 795 // upon creation so there's nothing to do now. 796 return; 797 } 798 menuitem.removeAttribute("acceltext"); 799 }, 800 801 /** 802 * Change the pref that will trigger a call to setPosition 803 */ 804 reversePosition() { 805 Services.prefs.setBoolPref(this.POSITION_START_PREF, !this._positionStart); 806 }, 807 808 /** 809 * Read the positioning pref and position the sidebar and the splitter 810 * appropriately within the browser container. 811 */ 812 setPosition() { 813 // First reset all ordinals to match DOM ordering. 814 let contentArea = document.getElementById("tabbrowser-tabbox"); 815 let browser = document.getElementById("browser"); 816 [...browser.children].forEach((node, i, children) => { 817 node.style.order = this._positionStart ? i + 1 : children.length - i; 818 }); 819 let sidebarContainer = document.getElementById("sidebar-main"); 820 let sidebarMain = document.querySelector("sidebar-main"); 821 822 // Indicate we've switched ordering to the box 823 this._box.toggleAttribute("sidebar-positionend", !this._positionStart); 824 sidebarMain.toggleAttribute("sidebar-positionend", !this._positionStart); 825 contentArea.toggleAttribute("sidebar-positionend", !this._positionStart); 826 sidebarContainer.toggleAttribute( 827 "sidebar-positionend", 828 !this._positionStart 829 ); 830 this.toolbarButton && 831 this.toolbarButton.toggleAttribute( 832 "sidebar-positionend", 833 !this._positionStart 834 ); 835 836 this.hideSwitcherPanel(); 837 838 let content = SidebarController.browser.contentWindow; 839 if (content && content.updatePosition) { 840 content.updatePosition(); 841 } 842 }, 843 844 /** 845 * Show/hide new sidebar based on sidebar.revamp pref 846 */ 847 async toggleRevampSidebar() { 848 await this.promiseInitialized; 849 let wasOpen = this.isOpen; 850 if (wasOpen) { 851 this.hide({ dismissPanel: false }); 852 } 853 // Reset sidebars map but preserve any existing extensions 854 let extensionsArr = []; 855 for (const [commandID, sidebar] of this.sidebars.entries()) { 856 if (sidebar.hasOwnProperty("extensionId")) { 857 extensionsArr.push({ commandID, sidebar }); 858 } 859 } 860 this.sidebars = this.generateSidebarsMap(); 861 for (const extension of extensionsArr) { 862 this.sidebars.set(extension.commandID, extension.sidebar); 863 } 864 if (!this.sidebarRevampEnabled) { 865 this._state.launcherVisible = false; 866 document.getElementById("sidebar-header").hidden = false; 867 868 // Ensure CPM isn't shown. 869 const cpmMenuItem = document.querySelector("#sidebar-switcher-megalist"); 870 this.lastOpenedId = this.DEFAULT_SIDEBAR_ID; 871 cpmMenuItem.hidden = true; 872 } 873 if (!this._sidebars.get(this.lastOpenedId)) { 874 this.lastOpenedId = this.DEFAULT_SIDEBAR_ID; 875 wasOpen = false; 876 } 877 this.updateToolbarButton(); 878 this._inited = false; 879 this.init(); 880 881 // Reopen the panel in the new or old sidebar now that we've inited 882 if (wasOpen) { 883 this.toggle(); 884 } 885 }, 886 887 /** 888 * Try and adopt the status of the sidebar from another window. 889 * 890 * @param {Window} sourceWindow - Window to use as a source for sidebar status. 891 * @returns {boolean} true if we adopted the state, or false if the caller should 892 * initialize the state itself. 893 */ 894 async adoptFromWindow(sourceWindow) { 895 // If the opener had a sidebar, open the same sidebar in our window. 896 // The opener can be the hidden window too, if we're coming from the state 897 // where no windows are open, and the hidden window has no sidebar box. 898 let sourceController = sourceWindow.SidebarController; 899 if (!sourceController || !sourceController._box) { 900 // no source UI or no _box means we also can't adopt the state. 901 return false; 902 } 903 904 // If window is a popup, hide the sidebar 905 if (this.inSingleTabWindow && this.sidebarRevampEnabled) { 906 document.getElementById("sidebar-main").hidden = true; 907 return false; 908 } 909 // Adopt the other window's UI state (it too could be a popup) 910 // We get the properties directly forom the SidebarState instance as in this case 911 // we need the command property even if no panel is currently open. 912 const sourceState = sourceController.inPopup 913 ? null 914 : sourceController._state?.getProperties(); 915 await this.initializeUIState(sourceState); 916 917 return true; 918 }, 919 920 windowPrivacyMatches(w1, w2) { 921 return ( 922 PrivateBrowsingUtils.isWindowPrivate(w1) === 923 PrivateBrowsingUtils.isWindowPrivate(w2) 924 ); 925 }, 926 927 /** 928 * If loading a sidebar was delayed on startup, start the load now. 929 */ 930 async startDelayedLoad() { 931 if (this.inSingleTabWindow) { 932 this._state.launcherVisible = false; 933 return; 934 } 935 936 let sourceWindow = window.opener; 937 // No source window means this is the initial window. If we're being 938 // opened from another window, check that it is one we might open a sidebar 939 // for. 940 if (sourceWindow) { 941 if ( 942 sourceWindow.closed || 943 sourceWindow.location.protocol != "chrome:" || 944 (!this.sidebarRevampEnabled && 945 !this.windowPrivacyMatches(sourceWindow, window)) 946 ) { 947 return; 948 } 949 // Try to adopt the sidebar state from the source window 950 if (await this.adoptFromWindow(sourceWindow)) { 951 this.uiStateInitialized = true; 952 return; 953 } 954 } 955 956 // If we're not adopting settings from a parent window, set them now. 957 let wasOpen = this._box.getAttribute("checked"); 958 if (!wasOpen) { 959 return; 960 } 961 962 let commandID = this._state.command; 963 if (commandID && this.sidebars.has(commandID)) { 964 this.showInitially(commandID); 965 } else { 966 this._box.removeAttribute("checked"); 967 // Update the state, because the element it 968 // refers to no longer exists, so we should assume this sidebar 969 // panel has been uninstalled. (249883) 970 this._state.command = ""; 971 // On a startup in which the startup cache was invalidated (e.g. app update) 972 // extensions will not be started prior to delayedLoad, thus the 973 // sidebarcommand element will not exist yet. Store the commandID so 974 // extensions may reopen if necessary. A startup cache invalidation 975 // can be forced (for testing) by deleting compatibility.ini from the 976 // profile. 977 this.lastOpenedId = commandID; 978 } 979 this.uiStateInitialized = true; 980 }, 981 982 /** 983 * Fire a "SidebarShown" event on the sidebar to give any interested parties 984 * a chance to update the button or whatever. 985 */ 986 _fireShowEvent() { 987 let event = new CustomEvent("SidebarShown", { bubbles: true }); 988 this._switcherTarget.dispatchEvent(event); 989 }, 990 991 /** 992 * Report the current browser width to Glean, and store it internally. 993 */ 994 _recordBrowserSize() { 995 this._browserWidth = this.browser.getBoundingClientRect().width; 996 Glean.sidebar.width.set(this._browserWidth); 997 }, 998 999 /** 1000 * Fire a "SidebarFocused" event on the sidebar's |window| to give the sidebar 1001 * a chance to adjust focus as needed. An additional event is needed, because 1002 * we don't want to focus the sidebar when it's opened on startup or in a new 1003 * window, only when the user opens the sidebar. 1004 */ 1005 _fireFocusedEvent() { 1006 let event = new CustomEvent("SidebarFocused", { bubbles: true }); 1007 this.browser.contentWindow.dispatchEvent(event); 1008 }, 1009 1010 /** 1011 * True if the sidebar is currently open. 1012 */ 1013 get isOpen() { 1014 return this._box ? !this._box.hidden : false; 1015 }, 1016 1017 /** 1018 * The ID of the current sidebar. 1019 */ 1020 get currentID() { 1021 return this.isOpen ? this._state.command : ""; 1022 }, 1023 1024 /** 1025 * The context menu of the current sidebar. 1026 */ 1027 get currentContextMenu() { 1028 const sidebar = this.sidebars.get(this.currentID); 1029 if (!sidebar) { 1030 return null; 1031 } 1032 return document.getElementById(sidebar.contextMenuId); 1033 }, 1034 1035 get launcherVisible() { 1036 return this._state?.launcherVisible; 1037 }, 1038 1039 get launcherEverVisible() { 1040 return this._state?.launcherEverVisible; 1041 }, 1042 1043 get title() { 1044 return this._title.value; 1045 }, 1046 1047 set title(value) { 1048 this._title.value = value; 1049 }, 1050 1051 /** 1052 * Toggle the visibility of the sidebar. If the sidebar is hidden or is open 1053 * with a different commandID, then the sidebar will be opened using the 1054 * specified commandID. Otherwise the sidebar will be hidden. 1055 * 1056 * @param {string} commandID ID of the sidebar. 1057 * @param {DOMNode} [triggerNode] Node, usually a button, that triggered the 1058 * visibility toggling of the sidebar. 1059 * @returns {Promise} 1060 */ 1061 toggle(commandID = this.lastOpenedId, triggerNode) { 1062 if ( 1063 CustomizationHandler.isCustomizing() || 1064 CustomizationHandler.isExitingCustomizeMode 1065 ) { 1066 return Promise.resolve(); 1067 } 1068 // First priority for a default value is this.lastOpenedId which is set during show() 1069 // and not reset in hide(), unlike currentID. If show() hasn't been called and we don't 1070 // have a persisted command either, or the command doesn't exist anymore, then 1071 // fallback to a default sidebar. 1072 if (!commandID) { 1073 commandID = this._state.command; 1074 } 1075 if (!commandID || !this.sidebars.has(commandID)) { 1076 if (this.sidebarRevampEnabled && this.sidebars.size) { 1077 commandID = this.sidebars.keys().next().value; 1078 } else { 1079 commandID = this.DEFAULT_SIDEBAR_ID; 1080 } 1081 } 1082 1083 if (this.isOpen && commandID == this.currentID) { 1084 // Revamp sidebar: this case is a dismissal of the current sidebar panel. The launcher should stay open 1085 // For legacy sidebar, this is a "sidebar" toggle and the current panel should be remembered 1086 this.hide({ triggerNode, dismissPanel: this.sidebarRevampEnabled }); 1087 this.updateToolbarButton(); 1088 return Promise.resolve(); 1089 } 1090 1091 if (!this.sidebarRevampEnabled) { 1092 const cpmMenuItem = document.querySelector("#sidebar-switcher-megalist"); 1093 this.lastOpenedId = this.DEFAULT_SIDEBAR_ID; 1094 cpmMenuItem.hidden = true; 1095 } 1096 1097 return this.show(commandID, triggerNode); 1098 }, 1099 1100 _getRects(animatingElements) { 1101 return animatingElements.map(e => [ 1102 e.hidden, 1103 e.getBoundingClientRect().toJSON(), 1104 ]); 1105 }, 1106 1107 /** 1108 * Wait for Lit updates and ongoing animations to complete. 1109 * 1110 * @returns {Promise} 1111 */ 1112 async waitUntilStable() { 1113 if (!this.sidebarRevampEnabled) { 1114 // Legacy sidebar doesn't have animations, nothing to await. 1115 return null; 1116 } 1117 const tasks = [this.sidebarMain.updateComplete]; 1118 if (this._ongoingAnimations?.length) { 1119 tasks.push( 1120 ...this._ongoingAnimations.map(animation => animation.finished) 1121 ); 1122 } 1123 return Promise.allSettled(tasks); 1124 }, 1125 1126 async _animateSidebarMain() { 1127 let tabbox = document.getElementById("tabbrowser-tabbox"); 1128 let animatingElements; 1129 let expandOnHoverEnabled = document.documentElement.hasAttribute( 1130 "sidebar-expand-on-hover" 1131 ); 1132 if (expandOnHoverEnabled) { 1133 animatingElements = [this.sidebarContainer]; 1134 1135 this._addHoverStateBlocker(); 1136 } else { 1137 animatingElements = [ 1138 this.sidebarContainer, 1139 this._box, 1140 this._splitter, 1141 tabbox, 1142 ]; 1143 } 1144 let resetElements = () => { 1145 for (let el of animatingElements) { 1146 el.style.minWidth = 1147 el.style.maxWidth = 1148 el.style.marginLeft = 1149 el.style.marginRight = 1150 el.style.display = 1151 ""; 1152 } 1153 this.sidebarContainer.toggleAttribute( 1154 "sidebar-ongoing-animations", 1155 false 1156 ); 1157 this._box.toggleAttribute("sidebar-ongoing-animations", false); 1158 tabbox.toggleAttribute("sidebar-ongoing-animations", false); 1159 }; 1160 if (this._ongoingAnimations.length) { 1161 this._ongoingAnimations.forEach(a => a.cancel()); 1162 this._ongoingAnimations = []; 1163 resetElements(); 1164 } 1165 1166 let fromRects = this._getRects(animatingElements); 1167 1168 // We need to wait for lit to re-render, and us to get the final width. 1169 // This is a bit unfortunate but alas... 1170 await new Promise(resolve => { 1171 queueMicrotask(() => resolve(this.sidebarMain.updateComplete)); 1172 }); 1173 let toRects = this._getRects(animatingElements); 1174 1175 const options = { 1176 duration: document.documentElement.hasAttribute("sidebar-expand-on-hover") 1177 ? this._animationExpandOnHoverDurationMs 1178 : this._animationDurationMs, 1179 easing: "ease-in-out", 1180 }; 1181 let animations = []; 1182 let sidebarOnLeft = this._positionStart != RTL_UI; 1183 let sidebarShift = 0; 1184 for (let i = 0; i < animatingElements.length; ++i) { 1185 const el = animatingElements[i]; 1186 const [wasHidden, from] = fromRects[i]; 1187 const [isHidden, to] = toRects[i]; 1188 1189 // For the sidebar, we need some special cases to make the animation 1190 // nicer (keeping the icon positions). 1191 const isSidebar = el === this.sidebarContainer; 1192 1193 if (wasHidden != isHidden) { 1194 if (wasHidden) { 1195 from.left = from.right = sidebarOnLeft ? to.left : to.right; 1196 } else { 1197 to.left = to.right = sidebarOnLeft ? from.left : from.right; 1198 } 1199 } 1200 const widthGrowth = to.width - from.width; 1201 if (isSidebar) { 1202 sidebarShift = widthGrowth; 1203 } 1204 1205 let fromTranslate = sidebarOnLeft 1206 ? from.left - to.left 1207 : from.right - to.right; 1208 let toTranslate = 0; 1209 1210 // We fix the element to the larger width during the animation if needed, 1211 // but keeping the right flex width, and thus our original position, with 1212 // a negative margin. 1213 el.style.minWidth = 1214 el.style.maxWidth = 1215 el.style.marginLeft = 1216 el.style.marginRight = 1217 el.style.display = 1218 ""; 1219 if (isHidden && !wasHidden) { 1220 el.style.display = "flex"; 1221 } 1222 1223 if (widthGrowth < 0) { 1224 el.style.minWidth = el.style.maxWidth = from.width + "px"; 1225 el.style["margin-" + (sidebarOnLeft ? "right" : "left")] = 1226 widthGrowth + "px"; 1227 if (isSidebar) { 1228 toTranslate = sidebarOnLeft ? widthGrowth : -widthGrowth; 1229 } else if (el === this._box) { 1230 // This is very hacky, but this code doesn't deal well with 1231 // more than two elements moving, and this is the less invasive change. 1232 // It would be better to treat "sidebar + sidebar-box" as a unit. 1233 // We only hit this when completely hiding the box. 1234 fromTranslate = sidebarOnLeft ? -sidebarShift : sidebarShift; 1235 toTranslate = sidebarOnLeft 1236 ? fromTranslate + widthGrowth 1237 : fromTranslate - widthGrowth; 1238 } 1239 } else if (isSidebar) { 1240 fromTranslate += sidebarOnLeft ? -widthGrowth : widthGrowth; 1241 } 1242 1243 animations.push( 1244 el.animate( 1245 [ 1246 { translate: `${fromTranslate}px 0 0` }, 1247 { translate: `${toTranslate}px 0 0` }, 1248 ], 1249 options 1250 ) 1251 ); 1252 if (!isSidebar || !this._positionStart) { 1253 continue; 1254 } 1255 // We want to keep the buttons in place during the animation, for which 1256 // we might need to compensate. 1257 if (!this._state.launcherExpanded) { 1258 animations.push( 1259 this.sidebarMain.animate( 1260 [{ translate: "0" }, { translate: `${-toTranslate}px 0 0` }], 1261 options 1262 ) 1263 ); 1264 } else { 1265 animations.push( 1266 this.sidebarMain.animate( 1267 [{ translate: `${-fromTranslate}px 0 0` }, { translate: "0" }], 1268 options 1269 ) 1270 ); 1271 } 1272 } 1273 this._ongoingAnimations = animations; 1274 this.sidebarContainer.toggleAttribute("sidebar-ongoing-animations", true); 1275 this.sidebarMain.toggleAttribute("sidebar-ongoing-animations", true); 1276 this._box.toggleAttribute("sidebar-ongoing-animations", true); 1277 tabbox.toggleAttribute("sidebar-ongoing-animations", true); 1278 await Promise.allSettled(animations.map(a => a.finished)); 1279 if (this._ongoingAnimations === animations) { 1280 this._ongoingAnimations = []; 1281 resetElements(); 1282 } 1283 1284 if (expandOnHoverEnabled) { 1285 await this._removeHoverStateBlocker(); 1286 } 1287 }, 1288 1289 /** 1290 * For sidebar.revamp=true only, handle the keyboard or sidebar-button command to toggle the sidebar state 1291 */ 1292 async handleToolbarButtonClick() { 1293 if (this.inSingleTabWindow || this.uninitializing) { 1294 return; 1295 } 1296 1297 const initialExpandedValue = this._state.launcherExpanded; 1298 1299 // What toggle means depends on the sidebar.visibility pref. 1300 const expandOnToggle = ["always-show", "expand-on-hover"].includes( 1301 this.sidebarRevampVisibility 1302 ); 1303 1304 // when the launcher is toggled open by the user, we disable expand-on-hover interactions. 1305 if (this.sidebarRevampVisibility === "expand-on-hover") { 1306 await this.toggleExpandOnHover(initialExpandedValue); 1307 } 1308 1309 if (this._animationEnabled && !window.gReduceMotion) { 1310 this._animateSidebarMain(); 1311 } 1312 1313 if (expandOnToggle) { 1314 // just expand/collapse the launcher 1315 this._state.updateVisibility(true, !initialExpandedValue); 1316 this.updateToolbarButton(); 1317 return; 1318 } 1319 1320 const shouldShowLauncher = !this._state.launcherVisible; 1321 // show/hide the launcher 1322 this._state.updateVisibility(shouldShowLauncher); 1323 // if we're showing and there was panel open, open it again 1324 if (shouldShowLauncher && this._state.command) { 1325 await this.show(this._state.command); 1326 } else if (!shouldShowLauncher) { 1327 // hide the open panel. It will re-open next time as we don't change the command value 1328 this.hide({ dismissPanel: false }); 1329 } 1330 this.updateToolbarButton(); 1331 }, 1332 1333 /** 1334 * Update `checked` state and tooltip text of the toolbar button. 1335 */ 1336 updateToolbarButton(toolbarButton = this.toolbarButton) { 1337 if (!toolbarButton || this.inSingleTabWindow) { 1338 return; 1339 } 1340 if (!this.sidebarRevampEnabled) { 1341 toolbarButton.dataset.l10nId = "show-sidebars"; 1342 toolbarButton.checked = this.isOpen; 1343 } else { 1344 let sidebarToggleKey = document.getElementById("toggleSidebarKb"); 1345 const shortcut = ShortcutUtils.prettifyShortcut(sidebarToggleKey); 1346 toolbarButton.dataset.l10nArgs = JSON.stringify({ shortcut }); 1347 // we need to use the pref rather than SidebarController's getter here 1348 // as the getter might not have the new value yet 1349 const isVerticalTabs = Services.prefs.getBoolPref("sidebar.verticalTabs"); 1350 if (isVerticalTabs) { 1351 toolbarButton.toggleAttribute("expanded", this.sidebarMain.expanded); 1352 } else { 1353 toolbarButton.toggleAttribute("expanded", false); 1354 } 1355 this.handleToolBadges(); 1356 switch (this.sidebarRevampVisibility) { 1357 case "always-show": 1358 case "expand-on-hover": 1359 // Toolbar button controls expanded state. 1360 toolbarButton.checked = this.sidebarMain.expanded; 1361 toolbarButton.dataset.l10nId = toolbarButton.checked 1362 ? "sidebar-widget-collapse-sidebar2" 1363 : "sidebar-widget-expand-sidebar2"; 1364 break; 1365 case "hide-sidebar": 1366 // Toolbar button controls hidden state. 1367 toolbarButton.checked = !this.sidebarContainer.hidden; 1368 toolbarButton.dataset.l10nId = toolbarButton.checked 1369 ? "sidebar-widget-hide-sidebar2" 1370 : "sidebar-widget-show-sidebar2"; 1371 break; 1372 } 1373 } 1374 }, 1375 1376 /** 1377 * Handles badges display for the toolbar and sidebar. 1378 * Check if a tool(toolID) has requested a badge from pref (i.e) sidebar.notification.badge.{toolID}) 1379 * Ensure that badges are shown or cleared based on the sidebar visibility and user interaction. 1380 * 1381 * @param {string|null} toolID 1382 */ 1383 handleToolBadges(toolID = null) { 1384 const toolPrefList = this.SidebarManager.getBadgeTools(); 1385 1386 for (const pref of toolPrefList) { 1387 if (toolID && toolID !== pref) { 1388 continue; 1389 } 1390 1391 const badgePref = Services.prefs.getBoolPref( 1392 `sidebar.notification.badge.${pref}`, 1393 false 1394 ); 1395 const commandID = [...this.toolsAndExtensions.keys()].find( 1396 id => toolsNameMap[id] === pref 1397 ); 1398 1399 if (!commandID) { 1400 continue; 1401 } 1402 1403 const isSidebarClosed = !this._state?.launcherVisible; 1404 const isCurrentView = this._state?.command === commandID; 1405 1406 // Don't show sidebar badge if sidebar is open and user is already viewing the tool panel 1407 if (badgePref && isCurrentView && this.isOpen) { 1408 this.dismissSidebarBadge(commandID); 1409 } 1410 1411 // Show badge on toolbar if we would have shown it on a visible tool but sidebar is closed 1412 const tool = this.toolsAndExtensions.get(commandID); 1413 if ( 1414 this.sidebarRevampEnabled && 1415 badgePref && 1416 !tool.disabled && 1417 !tool.hidden && 1418 isSidebarClosed 1419 ) { 1420 this._showToolbarButtonBadge(); 1421 } else { 1422 this._clearToolbarButtonBadge(); 1423 } 1424 1425 window.dispatchEvent(new CustomEvent("SidebarItemChanged")); 1426 } 1427 }, 1428 1429 _addHoverStateBlocker() { 1430 this._hoverBlockerCount++; 1431 MousePosTracker.removeListener(this); 1432 }, 1433 1434 async _removeHoverStateBlocker() { 1435 if (this._hoverBlockerCount == 1) { 1436 let isHovered = this._checkIsHoveredOverLauncher(); 1437 1438 // Collapse sidebar if needed 1439 if (this._state.launcherExpanded && !isHovered) { 1440 if (this._animationEnabled && !window.gReduceMotion) { 1441 this._animateSidebarMain(); 1442 } 1443 this._state.launcherExpanded = false; 1444 await this.waitUntilStable(); 1445 } 1446 1447 // Re-add MousePosTracker listener 1448 MousePosTracker.addListener(this); 1449 } 1450 if (this._hoverBlockerCount > 0) { 1451 this._hoverBlockerCount--; 1452 } 1453 }, 1454 1455 _showToolbarButtonBadge() { 1456 const badgeEl = this.toolbarButton?.querySelector(".toolbarbutton-badge"); 1457 return badgeEl?.classList.add("feature-callout"); 1458 }, 1459 1460 _clearToolbarButtonBadge() { 1461 const badgeEl = this.toolbarButton?.querySelector(".toolbarbutton-badge"); 1462 return badgeEl?.classList.remove("feature-callout"); 1463 }, 1464 1465 /** 1466 * Set badge toolID pref false on clicking the tool icon 1467 * 1468 * @param {string} view 1469 */ 1470 dismissSidebarBadge(view) { 1471 const prefName = `sidebar.notification.badge.${toolsNameMap[view]}`; 1472 if (Services.prefs.getBoolPref(prefName, false)) { 1473 Services.prefs.setBoolPref(prefName, false); 1474 } 1475 }, 1476 1477 /** 1478 * Enable the splitter which can be used to resize the launcher. 1479 */ 1480 _enableLauncherDragging() { 1481 if (!this._launcherSplitter.hidden) { 1482 // Already showing the launcher splitter with observers connected. 1483 // Nothing to do. 1484 return; 1485 } 1486 this._panelResizeObserver = new ResizeObserver( 1487 ([entry]) => (this._state.panelWidth = entry.contentBoxSize[0].inlineSize) 1488 ); 1489 this._panelResizeObserver.observe(this._box); 1490 1491 this._launcherDropHandler = () => (this._state.launcherDragActive = false); 1492 this._launcherSplitter.addEventListener( 1493 "command", 1494 this._launcherDropHandler 1495 ); 1496 1497 this._launcherSplitter.hidden = false; 1498 }, 1499 1500 /** 1501 * Enable the splitter which can be used to resize the pinned tabs container. 1502 */ 1503 _enablePinnedTabsSplitterDragging() { 1504 if (!this._pinnedTabsSplitter.hidden) { 1505 // Already showing the launcher splitter with observers connected. 1506 // Nothing to do. 1507 return; 1508 } 1509 this._pinnedTabsResizeObserver = new ResizeObserver(() => { 1510 if (this.isPinnedTabsDragging) { 1511 this._state.pinnedTabsDragActive = true; 1512 } 1513 }); 1514 1515 this._itemsWrapperResizeObserver = new ResizeObserver(async () => { 1516 await window.promiseDocumentFlushed(() => { 1517 // Adjust pinned tabs container height if needed 1518 requestAnimationFrame(() => { 1519 // If we are currently moving tabs, don't resize 1520 if (this._pinnedTabsContainer.hasAttribute("dragActive")) { 1521 return; 1522 } 1523 1524 this.updatePinnedTabsHeightOnResize(); 1525 }); 1526 }); 1527 }); 1528 this._pinnedTabsResizeObserver.observe(this._pinnedTabsContainer); 1529 this._itemsWrapperResizeObserver.observe(this._pinnedTabsItemsWrapper); 1530 1531 this._pinnedTabsDropHandler = () => 1532 (this._state.pinnedTabsDragActive = false); 1533 this._pinnedTabsSplitter.addEventListener( 1534 "command", 1535 this._pinnedTabsDropHandler 1536 ); 1537 1538 this._pinnedTabsSplitter.hidden = false; 1539 }, 1540 1541 /** 1542 * Disable the launcher splitter and remove any active observers. 1543 */ 1544 _disableLauncherDragging() { 1545 if (this._panelResizeObserver) { 1546 this._panelResizeObserver.disconnect(); 1547 } 1548 this._launcherSplitter.removeEventListener( 1549 "command", 1550 this._launcherDropHandler 1551 ); 1552 1553 this._launcherSplitter.hidden = true; 1554 }, 1555 1556 /** 1557 * Disable the pinned tabs splitter and remove any active observers. 1558 */ 1559 _disablePinnedTabsDragging() { 1560 if (this._pinnedTabsResizeObserver) { 1561 this._pinnedTabsResizeObserver.disconnect(); 1562 } 1563 if (this._itemsWrapperResizeObserver) { 1564 this._itemsWrapperResizeObserver.disconnect(); 1565 } 1566 1567 this._pinnedTabsSplitter.hidden = true; 1568 }, 1569 1570 _loadSidebarExtension(commandID) { 1571 let sidebar = this.sidebars.get(commandID); 1572 if (typeof sidebar?.onload === "function") { 1573 sidebar.onload(); 1574 } 1575 }, 1576 1577 updatePinnedTabsHeightOnResize() { 1578 let itemsWrapperHeight = window.windowUtils.getBoundsWithoutFlushing( 1579 this._pinnedTabsItemsWrapper 1580 ).height; 1581 if (this._state.pinnedTabsHeight > itemsWrapperHeight) { 1582 this._state.pinnedTabsHeight = itemsWrapperHeight; 1583 if (this._state.launcherExpanded) { 1584 this._state.expandedPinnedTabsHeight = this._state.pinnedTabsHeight; 1585 } else { 1586 this._state.collapsedPinnedTabsHeight = this._state.pinnedTabsHeight; 1587 } 1588 } 1589 }, 1590 1591 /** 1592 * Ensure tools reflect the current pref state 1593 */ 1594 refreshTools() { 1595 let changed = false; 1596 const tools = new Set(this.sidebarRevampTools.split(",")); 1597 this.toolsAndExtensions.forEach(tool => { 1598 const expected = !tools.has(tool.name); 1599 if (tool.disabled != expected) { 1600 tool.disabled = expected; 1601 changed = true; 1602 } 1603 }); 1604 if (changed) { 1605 window.dispatchEvent(new CustomEvent("SidebarItemChanged")); 1606 } 1607 }, 1608 1609 /** 1610 * Sets the disabled property for a tool when customizing sidebar options 1611 * 1612 * @param {string} commandID 1613 */ 1614 toggleTool(commandID) { 1615 const toggledTool = this.toolsAndExtensions.get(commandID); 1616 const toolName = toggledTool.name; 1617 toggledTool.disabled = !toggledTool.disabled; 1618 1619 if (!toggledTool.disabled) { 1620 // If re-enabling tool, remove from the map and add it to the end 1621 this.toolsAndExtensions.delete(commandID); 1622 this.toolsAndExtensions.set(commandID, toggledTool); 1623 } 1624 1625 this.SidebarManager.updateToolsPref(toolName, toggledTool.disabled); 1626 1627 if (toggledTool.disabled) { 1628 this.dismissSidebarBadge(commandID); 1629 } 1630 window.dispatchEvent(new CustomEvent("SidebarItemChanged")); 1631 }, 1632 1633 addOrUpdateExtension(commandID, extension) { 1634 if (this.inSingleTabWindow) { 1635 return; 1636 } 1637 if (this.toolsAndExtensions.has(commandID)) { 1638 // Update existing extension 1639 let extensionToUpdate = this.toolsAndExtensions.get(commandID); 1640 extensionToUpdate.icon = extension.icon; 1641 extensionToUpdate.iconUrl = extension.iconUrl; 1642 extensionToUpdate.tooltiptext = extension.label; 1643 window.dispatchEvent(new CustomEvent("SidebarItemChanged")); 1644 } else { 1645 // Add new extension 1646 const name = extension.extensionId; 1647 this.toolsAndExtensions.set(commandID, { 1648 view: commandID, 1649 extensionId: extension.extensionId, 1650 icon: extension.icon, 1651 iconUrl: extension.iconUrl, 1652 tooltiptext: extension.label, 1653 disabled: !this.sidebarTools.includes(name), // name is the extensionID 1654 name, 1655 }); 1656 window.dispatchEvent(new CustomEvent("SidebarItemAdded")); 1657 } 1658 }, 1659 1660 /** 1661 * Add menu items for a browser extension. Add the extension to the 1662 * `sidebars` map. 1663 * 1664 * @param {string} commandID 1665 * @param {object} props 1666 */ 1667 registerExtension(commandID, props) { 1668 const sidebarTools = this.sidebarTools; 1669 const installedExtensions = this.sidebarExtensions; 1670 const name = props.extensionId; 1671 1672 // An extension that is newly installed will be added to the sidebar.main.tools 1673 // pref by default until a user deselects it; separately we update our list of 1674 // sidebar extensions to ensure it keeps track of what's been installed. 1675 if (!installedExtensions.includes(name) && !sidebarTools.includes(name)) { 1676 sidebarTools.push(name); 1677 installedExtensions.push(name); 1678 Services.prefs.setStringPref(this.TOOLS_PREF, sidebarTools.join()); 1679 Services.prefs.setStringPref( 1680 this.INSTALLED_EXTENSIONS, 1681 installedExtensions.join() 1682 ); 1683 } 1684 1685 const sidebar = { 1686 title: props.title, 1687 url: "chrome://browser/content/webext-panels.xhtml", 1688 menuId: props.menuId, 1689 switcherMenuId: `sidebarswitcher_menu_${commandID}`, 1690 keyId: `ext-key-id-${commandID}`, 1691 label: props.title, 1692 icon: props.icon, 1693 iconUrl: props.iconUrl, 1694 classAttribute: "menuitem-iconic webextension-menuitem", 1695 // The following properties are specific to extensions 1696 extensionId: props.extensionId, 1697 onload: props.onload, 1698 name, 1699 }; 1700 this.sidebars.set(commandID, sidebar); 1701 1702 // Insert a menuitem for View->Show Sidebars. 1703 const menuitem = this.createMenuItem(commandID, sidebar); 1704 document.getElementById("viewSidebarMenu").appendChild(menuitem); 1705 this.addOrUpdateExtension(commandID, sidebar); 1706 1707 if (!this.sidebarRevampEnabled) { 1708 // Insert a toolbarbutton for the sidebar dropdown selector. 1709 let switcherMenuitem = this.createMenuItem(commandID, sidebar); 1710 switcherMenuitem.setAttribute("id", sidebar.switcherMenuId); 1711 switcherMenuitem.removeAttribute("type"); 1712 1713 let separator = document.getElementById("sidebar-extensions-separator"); 1714 separator.parentNode.insertBefore(switcherMenuitem, separator); 1715 } 1716 this._setExtensionAttributes( 1717 commandID, 1718 { icon: props.icon, iconUrl: props.iconUrl, label: props.title }, 1719 sidebar 1720 ); 1721 }, 1722 1723 /** 1724 * Create a menu item for the View>Sidebars submenu in the menubar. 1725 * 1726 * @param {string} commandID 1727 * @param {object} sidebar 1728 * @returns {Element} 1729 */ 1730 createMenuItem(commandID, sidebar) { 1731 const menuitem = document.createXULElement("menuitem"); 1732 menuitem.setAttribute("id", sidebar.menuId); 1733 menuitem.setAttribute("type", "checkbox"); 1734 // Some menu items get checkbox type removed, so should show the sidebar 1735 menuitem.addEventListener("command", () => 1736 this[menuitem.hasAttribute("type") ? "toggle" : "show"](commandID) 1737 ); 1738 if (sidebar.classAttribute) { 1739 menuitem.setAttribute("class", sidebar.classAttribute); 1740 } 1741 if (sidebar.keyId) { 1742 menuitem.setAttribute("key", sidebar.keyId); 1743 } 1744 if (sidebar.menuL10nId) { 1745 menuitem.dataset.l10nId = sidebar.menuL10nId; 1746 } 1747 if (this.inSingleTabWindow) { 1748 menuitem.setAttribute("disabled", "true"); 1749 } 1750 return menuitem; 1751 }, 1752 1753 /** 1754 * Update attributes on all existing menu items for a browser extension. 1755 * 1756 * @param {string} commandID 1757 * @param {object} attributes 1758 * @param {string} attributes.icon 1759 * @param {string} attributes.iconUrl 1760 * @param {string} attributes.label 1761 * @param {boolean} needsRefresh 1762 */ 1763 setExtensionAttributes(commandID, attributes, needsRefresh) { 1764 const sidebar = this.sidebars.get(commandID); 1765 this._setExtensionAttributes(commandID, attributes, sidebar, needsRefresh); 1766 this.addOrUpdateExtension(commandID, sidebar); 1767 }, 1768 1769 _setExtensionAttributes( 1770 commandID, 1771 { icon, iconUrl, label }, 1772 sidebar, 1773 needsRefresh = false 1774 ) { 1775 sidebar.icon = icon; 1776 sidebar.iconUrl = iconUrl; 1777 sidebar.label = label; 1778 1779 const updateAttributes = el => { 1780 // TODO Bug 1996762 - Add support for dark-theme sidebar icons 1781 // --webextension-menuitem-image-dark is used in dark themes 1782 el.style.setProperty("--webextension-menuitem-image", sidebar.icon); 1783 el.setAttribute("label", sidebar.label); 1784 }; 1785 1786 updateAttributes(document.getElementById(sidebar.menuId), sidebar); 1787 const switcherMenu = document.getElementById(sidebar.switcherMenuId); 1788 if (switcherMenu) { 1789 updateAttributes(switcherMenu, sidebar); 1790 } 1791 if (this.initialized && this.currentID === commandID) { 1792 // Update the sidebar title if this extension is the current sidebar. 1793 this.title = label; 1794 if (this.isOpen && needsRefresh) { 1795 this.show(commandID); 1796 } 1797 } 1798 }, 1799 1800 /** 1801 * Retrieve the list of registered browser extensions. 1802 * 1803 * @returns {Array} 1804 */ 1805 getExtensions() { 1806 const extensions = []; 1807 for (const [commandID, sidebar] of this.sidebars.entries()) { 1808 if (Object.hasOwn(sidebar, "extensionId")) { 1809 const disabled = !this.sidebarTools.includes(sidebar.name); 1810 1811 extensions.push({ 1812 commandID, 1813 view: commandID, 1814 extensionId: sidebar.extensionId, 1815 iconUrl: sidebar.iconUrl, 1816 tooltiptext: sidebar.label, 1817 disabled, 1818 name: sidebar.name, 1819 }); 1820 } 1821 } 1822 1823 return extensions; 1824 }, 1825 1826 /** 1827 * Retrieve the list of tools in the sidebar 1828 * 1829 * @returns {Array} 1830 */ 1831 getTools() { 1832 return Object.keys(toolsNameMap) 1833 .filter(commandID => this.sidebars.get(commandID)) 1834 .map(commandID => { 1835 const sidebar = this.sidebars.get(commandID); 1836 const disabled = !this.sidebarTools.includes(toolsNameMap[commandID]); 1837 return { 1838 commandID, 1839 view: commandID, 1840 name: sidebar.name, 1841 iconUrl: sidebar.iconUrl, 1842 l10nId: sidebar.revampL10nId, 1843 disabled, 1844 // Reflect the current tool state defaulting to visible 1845 get hidden() { 1846 return !(sidebar.visible ?? true); 1847 }, 1848 get attention() { 1849 return sidebar.attention ?? false; 1850 }, 1851 contextMenu: sidebar.toolContextMenuId, 1852 }; 1853 }); 1854 }, 1855 1856 /** 1857 * Remove a browser extension. 1858 * 1859 * @param {string} commandID 1860 */ 1861 removeExtension(commandID) { 1862 if (this.inSingleTabWindow) { 1863 return; 1864 } 1865 const sidebar = this.sidebars.get(commandID); 1866 if (!sidebar) { 1867 return; 1868 } 1869 if (this.currentID === commandID) { 1870 // If the extension removal is a update, we don't want to forget this panel. 1871 // So, let the sidebarAction extension API code remove the lastOpenedId as needed 1872 this.hide({ dismissPanel: false }); 1873 } 1874 document.getElementById(sidebar.menuId)?.remove(); 1875 document.getElementById(sidebar.switcherMenuId)?.remove(); 1876 1877 this.sidebars.delete(commandID); 1878 this.toolsAndExtensions.delete(commandID); 1879 window.dispatchEvent(new CustomEvent("SidebarItemRemoved")); 1880 }, 1881 1882 /** 1883 * Show the sidebar. 1884 * 1885 * This wraps the internal method, including a ping to telemetry. 1886 * 1887 * @param {string} commandID ID of the sidebar to use. 1888 * @param {DOMNode} [triggerNode] Node, usually a button, that triggered the 1889 * showing of the sidebar. 1890 * @returns {Promise<boolean>} 1891 */ 1892 async show(commandID, triggerNode) { 1893 if (this.inSingleTabWindow) { 1894 return false; 1895 } 1896 if (this.currentID && commandID !== this.currentID) { 1897 // If there is currently a panel open, we are about to hide it in order 1898 // to show another one, so record a "hide" event on the current panel. 1899 this._recordPanelToggle(this.currentID, false); 1900 } 1901 this._recordPanelToggle(commandID, true); 1902 1903 // Extensions without private window access wont be in the 1904 // sidebars map. 1905 if (!this.sidebars.has(commandID)) { 1906 return false; 1907 } 1908 return this._show(commandID).then(() => { 1909 this._loadSidebarExtension(commandID); 1910 1911 if (triggerNode) { 1912 updateToggleControlLabel(triggerNode); 1913 } 1914 this.updateToolbarButton(); 1915 this.dismissSidebarBadge(commandID); 1916 1917 this._fireFocusedEvent(); 1918 return true; 1919 }); 1920 }, 1921 1922 /** 1923 * Show the sidebar, without firing the focused event or logging telemetry. 1924 * This is intended to be used when the sidebar is opened automatically 1925 * when a window opens (not triggered by user interaction). 1926 * 1927 * @param {string} commandID ID of the sidebar. 1928 * @returns {Promise<boolean>} 1929 */ 1930 async showInitially(commandID) { 1931 if (this.inSingleTabWindow) { 1932 return false; 1933 } 1934 this._recordPanelToggle(commandID, true); 1935 1936 // Extensions without private window access wont be in the 1937 // sidebars map. 1938 if (!this.sidebars.has(commandID)) { 1939 return false; 1940 } 1941 return this._show(commandID).then(() => { 1942 this._loadSidebarExtension(commandID); 1943 return true; 1944 }); 1945 }, 1946 1947 /** 1948 * Implementation for show. Also used internally for sidebars that are shown 1949 * when a window is opened and we don't want to ping telemetry. 1950 * 1951 * @param {string} commandID ID of the sidebar. 1952 * @returns {Promise<void>} 1953 */ 1954 _show(commandID) { 1955 return new Promise(resolve => { 1956 const willShowEvent = new CustomEvent("SidebarWillShow"); 1957 this.browser.contentWindow?.dispatchEvent(willShowEvent); 1958 1959 this._state.panelOpen = true; 1960 if (this.sidebarRevampEnabled) { 1961 this._box.dispatchEvent( 1962 new CustomEvent("sidebar-show", { detail: { viewId: commandID } }) 1963 ); 1964 } else { 1965 this.hideSwitcherPanel(); 1966 } 1967 1968 this.selectMenuItem(commandID); 1969 this._box.hidden = this._splitter.hidden = false; 1970 1971 this._box.setAttribute("checked", "true"); 1972 this._state.command = commandID; 1973 1974 let { icon, url, title, sourceL10nEl, contextMenuId } = 1975 this.sidebars.get(commandID); 1976 if (icon) { 1977 this._switcherTarget.style.setProperty( 1978 "--webextension-menuitem-image", 1979 icon 1980 ); 1981 } else { 1982 this._switcherTarget.style.removeProperty( 1983 "--webextension-menuitem-image" 1984 ); 1985 } 1986 1987 if (contextMenuId) { 1988 this._box.setAttribute("context", contextMenuId); 1989 } else { 1990 this._box.removeAttribute("context"); 1991 } 1992 1993 // use to live update <tree> elements if the locale changes 1994 this.lastOpenedId = commandID; 1995 // These title changes only apply to the old sidebar menu 1996 if (!this.sidebarRevampEnabled) { 1997 this.title = title; 1998 // Keep the title element in the switcher in sync with any l10n changes. 1999 this.observeTitleChanges(sourceL10nEl); 2000 } 2001 2002 this.browser.setAttribute("src", url); // kick off async load 2003 2004 if (this.browser.contentDocument.location.href != url) { 2005 // make sure to clear the timeout if the load is aborted 2006 this.browser.addEventListener("unload", () => { 2007 if (this.browser.loadingTimerID) { 2008 clearTimeout(this.browser.loadingTimerID); 2009 delete this.browser.loadingTimerID; 2010 resolve(); 2011 } 2012 }); 2013 this.browser.addEventListener( 2014 "load", 2015 () => { 2016 // We're handling the 'load' event before it bubbles up to the usual 2017 // (non-capturing) event handlers. Let it bubble up before resolving. 2018 this.browser.loadingTimerID = setTimeout(() => { 2019 delete this.browser.loadingTimerID; 2020 resolve(); 2021 2022 // Now that the currentId is updated, fire a show event. 2023 this._fireShowEvent(); 2024 this._recordBrowserSize(); 2025 }, 0); 2026 }, 2027 { capture: true, once: true } 2028 ); 2029 } else { 2030 resolve(); 2031 2032 // Now that the currentId is updated, fire a show event. 2033 this._fireShowEvent(); 2034 this._recordBrowserSize(); 2035 } 2036 }); 2037 }, 2038 2039 /** 2040 * Hide the sidebar. 2041 * 2042 * @param {object} options - Parameter object. 2043 * @param {DOMNode} options.triggerNode - Node, usually a button, that triggered the 2044 * hiding of the sidebar. 2045 * @param {boolean} options.dismissPanel -Only close the panel or close the whole sidebar (the default.) 2046 */ 2047 hide({ triggerNode, dismissPanel = this.sidebarRevampEnabled } = {}) { 2048 if (!this.isOpen) { 2049 return; 2050 } 2051 2052 const willHideEvent = new CustomEvent("SidebarWillHide", { 2053 cancelable: true, 2054 }); 2055 this.browser.contentWindow?.dispatchEvent(willHideEvent); 2056 if (willHideEvent.defaultPrevented) { 2057 return; 2058 } 2059 2060 this.hideSwitcherPanel(); 2061 this._recordPanelToggle(this.currentID, false); 2062 this._state.panelOpen = false; 2063 if (dismissPanel) { 2064 // The user is explicitly closing this panel so we don't want it to 2065 // automatically re-open next time the sidebar is shown 2066 this._state.command = ""; 2067 this.lastOpenedId = null; 2068 } 2069 2070 if (this.sidebarRevampEnabled) { 2071 this._box.dispatchEvent(new CustomEvent("sidebar-hide")); 2072 } 2073 this.selectMenuItem(""); 2074 2075 // Replace the document currently displayed in the sidebar with about:blank 2076 // so that we can free memory by unloading the page. We need to explicitly 2077 // create a new content viewer because the old one doesn't get destroyed 2078 // until about:blank has loaded (which does not happen as long as the 2079 // element is hidden). 2080 this.browser.setAttribute("src", "about:blank"); 2081 this.browser.docShell?.createAboutBlankDocumentViewer(null, null); 2082 2083 this._box.removeAttribute("checked"); 2084 this._box.removeAttribute("context"); 2085 this._box.hidden = this._splitter.hidden = true; 2086 2087 let selBrowser = gBrowser.selectedBrowser; 2088 selBrowser.focus(); 2089 if (triggerNode) { 2090 updateToggleControlLabel(triggerNode); 2091 } 2092 this.updateToolbarButton(); 2093 }, 2094 2095 /** 2096 * Record to Glean when any of the sidebar panels is loaded or unloaded. 2097 * 2098 * @param {string} commandID 2099 * @param {boolean} opened 2100 */ 2101 _recordPanelToggle(commandID, opened) { 2102 const sidebar = this.sidebars.get(commandID); 2103 if (!sidebar) { 2104 return; 2105 } 2106 const isExtension = sidebar && Object.hasOwn(sidebar, "extensionId"); 2107 const version = this.sidebarRevampEnabled ? "new" : "old"; 2108 if (isExtension) { 2109 const addonId = sidebar.extensionId; 2110 const addonName = WebExtensionPolicy.getByID(addonId)?.name; 2111 Glean.extension.sidebarToggle.record({ 2112 opened, 2113 version, 2114 addon_id: AMTelemetry.getTrimmedString(addonId), 2115 addon_name: addonName && AMTelemetry.getTrimmedString(addonName), 2116 }); 2117 } else if (sidebar.gleanEvent && sidebar.recordSidebarVersion) { 2118 sidebar.gleanEvent.record({ opened, version }); 2119 } else if (sidebar.gleanEvent) { 2120 sidebar.gleanEvent.record({ opened }); 2121 } 2122 }, 2123 2124 /** 2125 * Use MousePosTracker to manually check for hover state over launcher 2126 */ 2127 _checkIsHoveredOverLauncher() { 2128 // Manually check mouse position 2129 let isHovered; 2130 MousePosTracker._callListener({ 2131 onMouseEnter: () => (isHovered = true), 2132 onMouseLeave: () => (isHovered = false), 2133 getMouseTargetRect: () => this.getMouseTargetRect(), 2134 }); 2135 return isHovered; 2136 }, 2137 2138 /** 2139 * Record to Glean when any of the sidebar icons are clicked. 2140 * 2141 * @param {string} commandID - Command ID of the icon. 2142 * @param {boolean} expanded - Whether the sidebar was expanded when clicked. 2143 */ 2144 recordIconClick(commandID, expanded) { 2145 const sidebar = this.sidebars.get(commandID); 2146 const isExtension = sidebar && Object.hasOwn(sidebar, "extensionId"); 2147 if (isExtension) { 2148 const addonId = sidebar.extensionId; 2149 Glean.sidebar.addonIconClick.record({ 2150 sidebar_open: expanded, 2151 addon_id: AMTelemetry.getTrimmedString(addonId), 2152 }); 2153 } else if (sidebar.gleanClickEvent) { 2154 sidebar.gleanClickEvent.record({ 2155 sidebar_open: expanded, 2156 }); 2157 } 2158 }, 2159 2160 /** 2161 * Sets the checked state only on the menu items of the specified sidebar, or 2162 * none if the argument is an empty string. 2163 */ 2164 selectMenuItem(commandID) { 2165 for (let [id, { menuId, triggerButtonId }] of this.sidebars) { 2166 let menu = document.getElementById(menuId); 2167 if (!menu) { 2168 continue; 2169 } 2170 let triggerbutton = 2171 triggerButtonId && document.getElementById(triggerButtonId); 2172 if (id == commandID) { 2173 menu.setAttribute("checked", "true"); 2174 if (triggerbutton) { 2175 triggerbutton.setAttribute("checked", "true"); 2176 updateToggleControlLabel(triggerbutton); 2177 } 2178 } else { 2179 menu.removeAttribute("checked"); 2180 if (triggerbutton) { 2181 triggerbutton.removeAttribute("checked"); 2182 updateToggleControlLabel(triggerbutton); 2183 } 2184 } 2185 } 2186 }, 2187 2188 toggleTabstrip() { 2189 let toVerticalTabs = CustomizableUI.verticalTabsEnabled; 2190 let tabStrip = gBrowser.tabContainer; 2191 let arrowScrollbox = tabStrip.arrowScrollbox; 2192 let currentScrollOrientation = arrowScrollbox.getAttribute("orient"); 2193 2194 if ( 2195 (!toVerticalTabs && currentScrollOrientation !== "vertical") || 2196 (toVerticalTabs && currentScrollOrientation === "vertical") 2197 ) { 2198 // Nothing to update 2199 return; 2200 } 2201 2202 if (toVerticalTabs) { 2203 arrowScrollbox.setAttribute("orient", "vertical"); 2204 tabStrip.setAttribute("orient", "vertical"); 2205 this._clearToolbarButtonBadge(); 2206 } else { 2207 arrowScrollbox.setAttribute("orient", "horizontal"); 2208 tabStrip.removeAttribute("expanded"); 2209 tabStrip.setAttribute("orient", "horizontal"); 2210 } 2211 2212 let verticalToolbar = document.getElementById( 2213 CustomizableUI.AREA_VERTICAL_TABSTRIP 2214 ); 2215 verticalToolbar.toggleAttribute("visible", toVerticalTabs); 2216 // Re-render sidebar-main so that templating is updated 2217 // for proper keyboard navigation for Tools 2218 this.sidebarMain.requestUpdate(); 2219 if ( 2220 !this.verticalTabsEnabled && 2221 this.sidebarRevampVisibility == "hide-sidebar" 2222 ) { 2223 // the sidebar.visibility pref didn't change so launcherExpanded hasn't 2224 // been updated; we need to set it here to un-expand the launcher 2225 this._state.launcherExpanded = false; 2226 } 2227 }, 2228 2229 debouncedMouseEnter() { 2230 const contentArea = document.getElementById("tabbrowser-tabbox"); 2231 this._box.toggleAttribute("sidebar-launcher-hovered", true); 2232 contentArea.toggleAttribute("sidebar-launcher-hovered", true); 2233 this._state.launcherHoverActive = true; 2234 if (this._animationEnabled && !window.gReduceMotion) { 2235 this._animateSidebarMain(); 2236 } 2237 this._state.launcherExpanded = true; 2238 this._mouseEnterDeferred.resolve(); 2239 }, 2240 2241 onMouseLeave() { 2242 if (!this._state.launcherExpanded) { 2243 return; 2244 } 2245 this.mouseEnterTask.disarm(); 2246 this._mouseEnterDeferred.resolve(); 2247 const contentArea = document.getElementById("tabbrowser-tabbox"); 2248 this._box.toggleAttribute("sidebar-launcher-hovered", false); 2249 contentArea.toggleAttribute("sidebar-launcher-hovered", false); 2250 this._state.launcherHoverActive = false; 2251 if (this._animationEnabled && !window.gReduceMotion) { 2252 this._animateSidebarMain(); 2253 } 2254 this._state.launcherExpanded = false; 2255 }, 2256 2257 onMouseEnter() { 2258 if (this._state.launcherExpanded) { 2259 return; 2260 } 2261 this._mouseEnterDeferred = Promise.withResolvers(); 2262 this.mouseEnterTask = new DeferredTask( 2263 () => { 2264 let isHovered = this._checkIsHoveredOverLauncher(); 2265 2266 // Only expand sidebar if mouse is still hovering over sidebar launcher 2267 if (isHovered) { 2268 this.debouncedMouseEnter(); 2269 } 2270 }, 2271 this._animationExpandOnHoverDelayDurationMs, 2272 EXPAND_ON_HOVER_DEBOUNCE_TIMEOUT_MS 2273 ); 2274 this.mouseEnterTask?.arm(); 2275 }, 2276 2277 get expandOnHoverComplete() { 2278 return this._mouseEnterDeferred?.promise || Promise.resolve(); 2279 }, 2280 2281 async setLauncherCollapsedWidth() { 2282 let browserEl = document.getElementById("browser"); 2283 if (this.getUIState().launcherExpanded) { 2284 this._state.launcherExpanded = false; 2285 } 2286 await this.waitUntilStable(); 2287 let collapsedWidth = await new Promise(resolve => { 2288 requestAnimationFrame(() => { 2289 resolve(this._getRects([this.sidebarMain])[0][1].width); 2290 }); 2291 }); 2292 2293 browserEl.style.setProperty( 2294 "--sidebar-launcher-collapsed-width", 2295 `${collapsedWidth}px` 2296 ); 2297 }, 2298 2299 getMouseTargetRect() { 2300 let launcherRect = window.windowUtils.getBoundsWithoutFlushing( 2301 SidebarController.sidebarMain 2302 ); 2303 return { 2304 top: launcherRect.top, 2305 bottom: launcherRect.bottom, 2306 left: this._positionStart 2307 ? launcherRect.left 2308 : launcherRect.left + LAUNCHER_SPLITTER_WIDTH, 2309 right: this._positionStart 2310 ? launcherRect.right - LAUNCHER_SPLITTER_WIDTH 2311 : launcherRect.right, 2312 }; 2313 }, 2314 2315 async handleEvent(e) { 2316 switch (e.type) { 2317 case "popupshown": 2318 /* Temporarily remove MousePosTracker listener when a context menu is open */ 2319 if (e.composedTarget.tagName !== "tooltip") { 2320 this._addHoverStateBlocker(); 2321 } 2322 break; 2323 case "popuphidden": 2324 if (e.composedTarget.tagName !== "tooltip") { 2325 await this._removeHoverStateBlocker(); 2326 } 2327 break; 2328 default: 2329 break; 2330 } 2331 }, 2332 2333 async toggleExpandOnHover(isEnabled, isDragEnded) { 2334 document.documentElement.toggleAttribute( 2335 "sidebar-expand-on-hover", 2336 isEnabled 2337 ); 2338 if (isEnabled) { 2339 if (!this._state) { 2340 this._state = new this.SidebarState(this); 2341 } 2342 await this.waitUntilStable(); 2343 MousePosTracker.addListener(this); 2344 if (!isDragEnded) { 2345 await this.setLauncherCollapsedWidth(); 2346 } 2347 document.addEventListener("popupshown", this); 2348 document.addEventListener("popuphidden", this); 2349 // Reset user-preferred height 2350 this.sidebarMain.buttonsWrapper.style.height = this._state 2351 .launcherExpanded 2352 ? "" 2353 : "0"; 2354 } else { 2355 this._removeHoverStateBlocker(); 2356 MousePosTracker.removeListener(this); 2357 if (!this.mouseOverTask?.isFinalized) { 2358 this.mouseOverTask?.finalize(); 2359 } 2360 document.removeEventListener("popupshown", this); 2361 document.removeEventListener("popuphidden", this); 2362 // Add back user-preferred height if defined 2363 if ( 2364 this._state.launcherExpanded && 2365 this._state.expandedToolsHeight !== undefined && 2366 this.sidebarMain.buttonGroup 2367 ) { 2368 this.sidebarMain.buttonGroup.style.height = 2369 this._state.expandedToolsHeight; 2370 } else if ( 2371 !this._state.launcherExpanded && 2372 this._state.collapsedToolsHeight !== undefined && 2373 this.sidebarMain.buttonGroup 2374 ) { 2375 this.sidebarMain.buttonGroup.style.height = 2376 this._state.collapsedToolsHeight; 2377 } 2378 } 2379 2380 document.documentElement.toggleAttribute( 2381 "sidebar-expand-on-hover", 2382 isEnabled 2383 ); 2384 }, 2385 2386 /** 2387 * Report visibility preference to Glean. 2388 * 2389 * @param {string} [value] - The preference value. 2390 */ 2391 recordVisibilitySetting(value = this.sidebarRevampVisibility) { 2392 let visibilitySetting = "hide"; 2393 if (value === "always-show") { 2394 visibilitySetting = "always"; 2395 } else if (value === "expand-on-hover") { 2396 visibilitySetting = "expand-on-hover"; 2397 } 2398 Glean.sidebar.displaySettings.set(visibilitySetting); 2399 }, 2400 2401 /** 2402 * Report position preference to Glean. 2403 * 2404 * @param {boolean} [value] - The preference value. 2405 */ 2406 recordPositionSetting(value = this._positionStart) { 2407 Glean.sidebar.positionSettings.set(value !== RTL_UI ? "left" : "right"); 2408 }, 2409 2410 /** 2411 * Report tabs layout preference to Glean. 2412 * 2413 * @param {boolean} [value] - The preference value. 2414 */ 2415 recordTabsLayoutSetting(value = this.sidebarVerticalTabsEnabled) { 2416 Glean.sidebar.tabsLayout.set(value ? "vertical" : "horizontal"); 2417 }, 2418 }; 2419 2420 ChromeUtils.defineESModuleGetters(SidebarController, { 2421 AIWindow: 2422 "moz-src:///browser/components/aiwindow/ui/modules/AIWindow.sys.mjs", 2423 SidebarManager: 2424 "moz-src:///browser/components/sidebar/SidebarManager.sys.mjs", 2425 SidebarState: "moz-src:///browser/components/sidebar/SidebarState.sys.mjs", 2426 }); 2427 2428 // Add getters related to the position here, since we will want them 2429 // available for both startDelayedLoad and init. 2430 XPCOMUtils.defineLazyPreferenceGetter( 2431 SidebarController, 2432 "_positionStart", 2433 SidebarController.POSITION_START_PREF, 2434 true, 2435 (_aPreference, _previousValue, newValue) => { 2436 if ( 2437 !SidebarController.uninitializing && 2438 !SidebarController.inSingleTabWindow 2439 ) { 2440 SidebarController.setPosition(); 2441 SidebarController.recordPositionSetting(newValue); 2442 } 2443 } 2444 ); 2445 XPCOMUtils.defineLazyPreferenceGetter( 2446 SidebarController, 2447 "_animationEnabled", 2448 "sidebar.animation.enabled", 2449 true 2450 ); 2451 XPCOMUtils.defineLazyPreferenceGetter( 2452 SidebarController, 2453 "_animationDurationMs", 2454 "sidebar.animation.duration-ms", 2455 200 2456 ); 2457 XPCOMUtils.defineLazyPreferenceGetter( 2458 SidebarController, 2459 "_animationExpandOnHoverDurationMs", 2460 "sidebar.animation.expand-on-hover.duration-ms", 2461 400 2462 ); 2463 XPCOMUtils.defineLazyPreferenceGetter( 2464 SidebarController, 2465 "_animationExpandOnHoverDelayDurationMs", 2466 "sidebar.animation.expand-on-hover.delay-duration-ms", 2467 200 2468 ); 2469 XPCOMUtils.defineLazyPreferenceGetter( 2470 SidebarController, 2471 "sidebarRevampEnabled", 2472 "sidebar.revamp", 2473 false, 2474 (_aPreference, _previousValue, newValue) => { 2475 if (!SidebarController.uninitializing) { 2476 SidebarController.toggleRevampSidebar(); 2477 SidebarController._state.revampEnabled = newValue; 2478 } 2479 } 2480 ); 2481 XPCOMUtils.defineLazyPreferenceGetter( 2482 SidebarController, 2483 "sidebarRevampTools", 2484 "sidebar.main.tools", 2485 "", 2486 () => { 2487 if ( 2488 !SidebarController.inSingleTabWindow && 2489 !SidebarController.uninitializing 2490 ) { 2491 SidebarController.refreshTools(); 2492 } 2493 } 2494 ); 2495 XPCOMUtils.defineLazyPreferenceGetter( 2496 SidebarController, 2497 "installedExtensions", 2498 "sidebar.installed.extensions", 2499 "" 2500 ); 2501 2502 XPCOMUtils.defineLazyPreferenceGetter( 2503 SidebarController, 2504 "sidebarRevampVisibility", 2505 "sidebar.visibility", 2506 "always-show", 2507 (_aPreference, _previousValue, newValue) => { 2508 if ( 2509 !SidebarController.inSingleTabWindow && 2510 !SidebarController.uninitializing 2511 ) { 2512 SidebarController.toggleExpandOnHover(newValue === "expand-on-hover"); 2513 SidebarController.recordVisibilitySetting(newValue); 2514 if (SidebarController._state) { 2515 // we need to use the pref rather than SidebarController's getter here 2516 // as the getter might not have the new value yet 2517 const isVerticalTabs = Services.prefs.getBoolPref( 2518 "sidebar.verticalTabs" 2519 ); 2520 SidebarController._state.revampVisibility = newValue; 2521 if ( 2522 SidebarController._animationEnabled && 2523 !window.gReduceMotion && 2524 newValue !== "expand-on-hover" 2525 ) { 2526 SidebarController._animateSidebarMain(); 2527 } 2528 2529 // launcher is always initially expanded with vertical tabs unless we're doing expand-on-hover 2530 let forceExpand = false; 2531 if ( 2532 isVerticalTabs && 2533 ["always-show", "hide-sidebar"].includes(newValue) 2534 ) { 2535 forceExpand = true; 2536 } 2537 2538 // horizontal tabs and hide-sidebar = visible initially. 2539 // vertical tab and hide-sidebar = not visible initially 2540 let showLauncher = true; 2541 if (newValue == "hide-sidebar" && isVerticalTabs) { 2542 showLauncher = false; 2543 } 2544 SidebarController._state.updateVisibility(showLauncher, forceExpand); 2545 } 2546 SidebarController.updateToolbarButton(); 2547 } 2548 } 2549 ); 2550 2551 XPCOMUtils.defineLazyPreferenceGetter( 2552 SidebarController, 2553 "sidebarVerticalTabsEnabled", 2554 "sidebar.verticalTabs", 2555 false, 2556 (_aPreference, _previousValue, newValue) => { 2557 if ( 2558 !SidebarController.uninitializing && 2559 !SidebarController.inSingleTabWindow 2560 ) { 2561 SidebarController.recordTabsLayoutSetting(newValue); 2562 if (newValue) { 2563 SidebarController._enablePinnedTabsSplitterDragging(); 2564 } else { 2565 SidebarController._disablePinnedTabsDragging(); 2566 } 2567 SidebarController._state.updatePinnedTabsHeight(); 2568 SidebarController._state.updateToolsHeight(); 2569 } 2570 } 2571 );