tab-hover-preview.mjs (27546B)
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 var { XPCOMUtils } = ChromeUtils.importESModule( 6 "resource://gre/modules/XPCOMUtils.sys.mjs" 7 ); 8 const lazy = {}; 9 ChromeUtils.defineESModuleGetters(lazy, { 10 PageWireframes: "resource:///modules/sessionstore/PageWireframes.sys.mjs", 11 SponsorProtection: 12 "moz-src:///browser/components/newtab/SponsorProtection.sys.mjs", 13 TabNotes: "moz-src:///browser/components/tabnotes/TabNotes.sys.mjs", 14 }); 15 16 // Denotes the amount of time (in ms) that the panel will *not* respect 17 // ui.tooltip.delay_ms after a tab preview panel is hidden. This is to reduce 18 // jitter in the event that a user accidentally moves their mouse off the tab 19 // strip. 20 const ZERO_DELAY_ACTIVATION_TIME = 300; 21 22 // Denotes the amount of time (in ms) that a hover preview panel will remain 23 // open after the user's mouse leaves its anchor element. This is necessary to 24 // allow the user to move their mouse between the anchor (tab or group label) 25 // and the open panel without having it disappear before they get there. 26 const HOVER_PANEL_STICKY_TIME = 100; 27 28 /** 29 * Shared module that contains logic for the tab hover preview (THP) and tab 30 * group hover preview (TGHP) panels. 31 */ 32 export default class TabHoverPanelSet { 33 /** @type {Window} */ 34 #win; 35 36 /** @type {Set<HTMLElement>} */ 37 #openPopups; 38 39 /** @type {WeakMap<HoverPanel, number>} */ 40 #deactivateTimers; 41 42 /** @type {HoverPanel|null} */ 43 #activePanel; 44 45 /** 46 * @param {Window} win 47 */ 48 constructor(win) { 49 XPCOMUtils.defineLazyPreferenceGetter( 50 this, 51 "_prefDisableAutohide", 52 "ui.popup.disable_autohide", 53 false 54 ); 55 56 this.#win = win; 57 this.#deactivateTimers = new WeakMap(); 58 this.#activePanel = null; 59 60 this.panelOpener = new TabPreviewPanelTimedFunction( 61 ZERO_DELAY_ACTIVATION_TIME, 62 this.#win 63 ); 64 65 /** @type {HTMLTemplateElement} */ 66 const tabPreviewTemplate = win.document.getElementById( 67 "tabPreviewPanelTemplate" 68 ); 69 const importedFragment = win.document.importNode( 70 tabPreviewTemplate.content, 71 true 72 ); 73 // #tabPreviewPanelTemplate is currently just the .tab-preview-add-note 74 // button element, so append it to the tab preview panel body. 75 const addNoteButton = importedFragment.firstElementChild; 76 const tabPreviewPanel = 77 this.#win.document.getElementById("tab-preview-panel"); 78 tabPreviewPanel.append(addNoteButton); 79 this.tabPanel = new TabPanel(tabPreviewPanel, this); 80 this.tabGroupPanel = new TabGroupPanel( 81 this.#win.document.getElementById("tabgroup-preview-panel"), 82 this 83 ); 84 85 this.#setExternalPopupListeners(); 86 this.#win.gBrowser.tabContainer.addEventListener("dragstart", event => { 87 const target = event.target.closest?.("tab, .tab-group-label"); 88 if ( 89 target && 90 (this.#win.gBrowser.isTab(target) || 91 this.#win.gBrowser.isTabGroupLabel(target)) 92 ) { 93 this.deactivate(null, { force: true }); 94 } 95 }); 96 } 97 98 /** 99 * Activate the tab preview or tab group preview, depending on context. 100 * 101 * If `tabOrGroup` is a tab, the tab preview will be activated. If 102 * `tabOrGroup` is a tab group, the group preview will be activated. 103 * Activating a panel of one type will automatically deactivate the other 104 * type. 105 * 106 * @param {MozTabbrowserTab|MozTabbrowserTabGroup} tabOrGroup - The tab or group to activate the panel on. 107 */ 108 activate(tabOrGroup) { 109 if (!this.shouldActivate()) { 110 return; 111 } 112 113 if (this.#win.gBrowser.isTab(tabOrGroup)) { 114 this.#setActivePanel(this.tabPanel); 115 this.tabPanel.activate(tabOrGroup); 116 } else if (this.#win.gBrowser.isTabGroup(tabOrGroup)) { 117 if (!tabOrGroup.collapsed) { 118 return; 119 } 120 121 this.#setActivePanel(this.tabGroupPanel); 122 this.tabGroupPanel.activate(tabOrGroup); 123 } else { 124 throw new Error("Received activate call from unknown element"); 125 } 126 } 127 128 /** 129 * Deactivate the tab panel and/or the tab group panel. 130 * 131 * If `tabOrGroup` is a tab, the tab preview will be deactivated. If 132 * `tabOrGroup` is a tab group, the group preview will be deactivated. 133 * If neither, both are deactivated. 134 * 135 * Panels linger briefly to allow the mouse to travel between the anchor and 136 * panel; passing `force` skips that delay. 137 * 138 * @param {MozTabbrowserTab|MozTabbrowserTabGroup|null} tabOrGroup - The tab or group to activate the panel on. 139 * @param {bool} [options.force] - If true, force immediate deactivation of the tab group panel. 140 */ 141 deactivate(tabOrGroup, { force = false } = {}) { 142 if (this._prefDisableAutohide) { 143 return; 144 } 145 146 if (this.#win.gBrowser.isTab(tabOrGroup) || !tabOrGroup) { 147 this.tabPanel.deactivate(tabOrGroup, { force }); 148 } 149 150 if (this.#win.gBrowser.isTabGroup(tabOrGroup) || !tabOrGroup) { 151 this.tabGroupPanel.deactivate({ force }); 152 } 153 } 154 155 #setActivePanel(panel) { 156 if (this.#activePanel && this.#activePanel != panel) { 157 this.requestDeactivate(this.#activePanel, { force: true }); 158 } 159 160 this.#activePanel = panel; 161 this.#clearDeactivateTimer(panel); 162 } 163 164 requestDeactivate(panel, { force = false } = {}) { 165 this.#clearDeactivateTimer(panel); 166 if (force) { 167 this.#doDeactivate(panel); 168 return; 169 } 170 171 const timer = this.#win.setTimeout(() => { 172 this.#deactivateTimers.delete(panel); 173 if (panel.hoverTargets?.some(t => t.matches(":hover"))) { 174 return; 175 } 176 this.#doDeactivate(panel); 177 }, HOVER_PANEL_STICKY_TIME); 178 this.#deactivateTimers.set(panel, timer); 179 } 180 181 #clearDeactivateTimer(panel) { 182 const timer = this.#deactivateTimers.get(panel); 183 if (timer) { 184 this.#win.clearTimeout(timer); 185 this.#deactivateTimers.delete(panel); 186 } 187 } 188 189 #doDeactivate(panel) { 190 panel.onBeforeHide(); 191 panel.panelElement.hidePopup(); 192 this.panelOpener.clear(panel); 193 this.panelOpener.setZeroDelay(); 194 195 if (this.#activePanel == panel) { 196 this.#activePanel = null; 197 } 198 } 199 200 shouldActivate() { 201 return ( 202 // All other popups are closed. 203 !this.#openPopups.size && 204 !this.#win.gBrowser.tabContainer.hasAttribute("movingtab") && 205 // TODO (bug 1899556): for now disable in background windows, as there are 206 // issues with windows ordering on Linux (bug 1897475), plus intermittent 207 // persistence of previews after session restore (bug 1888148). 208 this.#win == Services.focus.activeWindow 209 ); 210 } 211 212 /** 213 * Listen for any panels or menupopups that open or close anywhere else in the DOM tree 214 * and maintain a list of the ones that are currently open. 215 * This is used to disable tab previews until such time as the other panels are closed. 216 */ 217 #setExternalPopupListeners() { 218 // Since the tab preview panel is lazy loaded, there is a possibility that panels could 219 // already be open on init. Therefore we need to initialize `#openPopups` with existing panels 220 // the first time. 221 222 const initialPopups = this.#win.document.querySelectorAll( 223 `panel[panelopen=true]:not(#tab-preview-panel):not(#tabgroup-preview-panel), 224 panel[animating=true]:not(#tab-preview-panel):not(#tabgroup-preview-panel), 225 menupopup[open=true]`.trim() 226 ); 227 this.#openPopups = new Set(initialPopups); 228 229 const handleExternalPopupEvent = (eventName, setMethod) => { 230 this.#win.addEventListener(eventName, ev => { 231 const { target } = ev; 232 if ( 233 target !== this.tabPanel.panelElement && 234 target !== this.tabGroupPanel.panelElement && 235 (target.nodeName == "panel" || target.nodeName == "menupopup") 236 ) { 237 this.#openPopups[setMethod](target); 238 } 239 }); 240 }; 241 handleExternalPopupEvent("popupshowing", "add"); 242 handleExternalPopupEvent("popuphiding", "delete"); 243 } 244 } 245 246 class HoverPanel { 247 /** 248 * @param {XULPopupElement} panelElement 249 * @param {TabHoverPanelSet} panelSet 250 */ 251 constructor(panelElement, panelSet) { 252 this.panelElement = panelElement; 253 this.panelSet = panelSet; 254 this.win = this.panelElement.ownerGlobal; 255 } 256 257 get isActive() { 258 return this.panelElement.state == "open"; 259 } 260 261 deactivate({ force = false } = {}) { 262 this.panelSet.requestDeactivate(this, { force }); 263 } 264 265 get hoverTargets() { 266 return [this.panelElement]; 267 } 268 269 onBeforeHide() {} 270 } 271 272 class TabPanel extends HoverPanel { 273 /** @type {MozTabbrowserTab|null} */ 274 #tab; 275 276 /** @type {DOMElement|null} */ 277 #thumbnailElement; 278 279 constructor(panel, panelSet) { 280 super(panel, panelSet); 281 282 XPCOMUtils.defineLazyPreferenceGetter( 283 this, 284 "_prefDisplayThumbnail", 285 "browser.tabs.hoverPreview.showThumbnails", 286 false 287 ); 288 XPCOMUtils.defineLazyPreferenceGetter( 289 this, 290 "_prefCollectWireframes", 291 "browser.history.collectWireframes" 292 ); 293 XPCOMUtils.defineLazyPreferenceGetter( 294 this, 295 "_prefUseTabNotes", 296 "browser.tabs.notes.enabled", 297 false 298 ); 299 300 this.#tab = null; 301 this.#thumbnailElement = null; 302 303 this.panelElement 304 .querySelector(".tab-preview-add-note") 305 .addEventListener("click", () => this.#openTabNotePanel()); 306 } 307 308 /** 309 * @param {Event} e 310 */ 311 handleEvent(e) { 312 switch (e.type) { 313 case "popupshowing": 314 this.panelElement.addEventListener("mouseout", this); 315 this.#updatePreview(); 316 break; 317 case "TabAttrModified": 318 this.#updatePreview(e.target); 319 break; 320 case "TabSelect": 321 this.deactivate(null, { force: true }); 322 break; 323 case "mouseout": 324 if (!this.panelElement.contains(e.relatedTarget)) { 325 this.deactivate(); 326 } 327 break; 328 } 329 } 330 331 activate(tab) { 332 if (this.#tab === tab && this.panelElement.state == "open") { 333 return; 334 } 335 let originalTab = this.#tab; 336 this.#tab = tab; 337 338 // Calling `moveToAnchor` in advance of the call to `openPopup` ensures 339 // that race conditions can be avoided in cases where the user hovers 340 // over a different tab while the preview panel is still opening. 341 // This will ensure the move operation is carried out even if the popup is 342 // in an intermediary state (opening but not fully open). 343 // 344 // If the popup is closed this call will be ignored. 345 this.#movePanel(); 346 347 originalTab?.removeEventListener("TabAttrModified", this); 348 this.#tab.addEventListener("TabAttrModified", this); 349 350 this.#thumbnailElement = null; 351 this.#maybeRequestThumbnail(); 352 if ( 353 this.panelElement.state == "open" || 354 this.panelElement.state == "showing" 355 ) { 356 this.#updatePreview(); 357 } else { 358 this.panelSet.panelOpener.execute(() => { 359 if (!this.panelSet.shouldActivate()) { 360 return; 361 } 362 this.panelElement.openPopup(this.#tab, this.popupOptions); 363 }, this); 364 this.win.addEventListener("TabSelect", this); 365 this.panelElement.addEventListener("popupshowing", this); 366 } 367 } 368 369 /** 370 * @param {MozTabbrowserTab} [leavingTab] 371 * @param {object} [options] 372 * @param {boolean} [options.force=false] 373 */ 374 deactivate(leavingTab = null, { force = false } = {}) { 375 if (!this._prefUseTabNotes) { 376 force = true; 377 } 378 if (leavingTab) { 379 if (this.#tab != leavingTab) { 380 return; 381 } 382 this.win.requestAnimationFrame(() => { 383 if (this.#tab == leavingTab) { 384 this.deactivate(null, { force }); 385 } 386 }); 387 return; 388 } 389 super.deactivate({ force }); 390 } 391 392 onBeforeHide() { 393 this.panelElement.removeEventListener("popupshowing", this); 394 this.panelElement.removeEventListener("mouseout", this); 395 this.win.removeEventListener("TabSelect", this); 396 this.#tab?.removeEventListener("TabAttrModified", this); 397 this.#tab = null; 398 this.#thumbnailElement = null; 399 } 400 401 get hoverTargets() { 402 let targets = []; 403 if (this._prefUseTabNotes) { 404 targets.push(this.panelElement); 405 } 406 if (this.#tab) { 407 targets.push(this.#tab); 408 } 409 return targets; 410 } 411 412 getPrettyURI(uri) { 413 let url = URL.parse(uri); 414 if (!url) { 415 return uri; 416 } 417 418 if (url.protocol == "about:" && url.pathname == "reader") { 419 url = URL.parse(url.searchParams.get("url")); 420 } 421 422 if (url?.protocol === "about:") { 423 return url.href; 424 } 425 return url ? url.hostname.replace(/^w{3}\./, "") : uri; 426 } 427 428 #hasValidWireframeState(tab) { 429 return ( 430 this._prefCollectWireframes && 431 this._prefDisplayThumbnail && 432 tab && 433 !tab.selected && 434 !!lazy.PageWireframes.getWireframeState(tab) 435 ); 436 } 437 438 #hasValidThumbnailState(tab) { 439 return ( 440 this._prefDisplayThumbnail && 441 tab && 442 tab.linkedBrowser && 443 !tab.getAttribute("pending") && 444 !tab.selected 445 ); 446 } 447 448 #maybeRequestThumbnail() { 449 let tab = this.#tab; 450 451 if (!this.#hasValidThumbnailState(tab)) { 452 let wireframeElement = lazy.PageWireframes.getWireframeElementForTab(tab); 453 if (wireframeElement) { 454 this.#thumbnailElement = wireframeElement; 455 this.#updatePreview(); 456 } 457 return; 458 } 459 let thumbnailCanvas = this.win.document.createElement("canvas"); 460 thumbnailCanvas.width = 280 * this.win.devicePixelRatio; 461 thumbnailCanvas.height = 140 * this.win.devicePixelRatio; 462 463 this.win.PageThumbs.captureTabPreviewThumbnail( 464 tab.linkedBrowser, 465 thumbnailCanvas 466 ) 467 .then(() => { 468 // in case we've changed tabs after capture started, ensure we still want to show the thumbnail 469 if (this.#tab == tab && this.#hasValidThumbnailState(tab)) { 470 this.#thumbnailElement = thumbnailCanvas; 471 this.#updatePreview(); 472 } 473 }) 474 .catch(e => { 475 // Most likely the window was killed before capture completed, so just log the error 476 console.error(e); 477 }); 478 } 479 480 get #displayTitle() { 481 if (!this.#tab) { 482 return ""; 483 } 484 return this.#tab.textLabel.textContent; 485 } 486 487 get #displayURI() { 488 if (!this.#tab || !this.#tab.linkedBrowser) { 489 return ""; 490 } 491 return this.getPrettyURI(this.#tab.linkedBrowser.currentURI.spec); 492 } 493 494 get #displayPids() { 495 const pids = this.win.gBrowser.getTabPids(this.#tab); 496 if (!pids.length) { 497 return ""; 498 } 499 500 let pidLabel = pids.length > 1 ? "pids" : "pid"; 501 return `${pidLabel}: ${pids.join(", ")}`; 502 } 503 504 get #displayActiveness() { 505 return this.#tab?.linkedBrowser?.docShellIsActive ? "[A]" : ""; 506 } 507 508 get #displaySponsorProtection() { 509 return lazy.SponsorProtection.debugEnabled && 510 lazy.SponsorProtection.isProtectedBrowser(this.#tab?.linkedBrowser) 511 ? "[S]" 512 : ""; 513 } 514 515 /** 516 * Opens the tab note menu in the context of the current tab. Since only 517 * one panel should be open at a time, this also closes the tab hover preview 518 * panel. 519 */ 520 #openTabNotePanel() { 521 this.win.gBrowser.tabNoteMenu.openPanel(this.#tab, { 522 telemetrySource: lazy.TabNotes.TELEMETRY_SOURCE.TAB_HOVER_PREVIEW_PANEL, 523 }); 524 this.deactivate(this.#tab, { force: true }); 525 } 526 527 #updatePreview(tab = null) { 528 if (tab) { 529 this.#tab = tab; 530 } 531 532 this.panelElement.querySelector(".tab-preview-title").textContent = 533 this.#displayTitle; 534 this.panelElement.querySelector(".tab-preview-uri").textContent = 535 this.#displayURI; 536 537 if (this.win.gBrowser.showPidAndActiveness) { 538 this.panelElement.querySelector(".tab-preview-pid").textContent = 539 this.#displayPids; 540 this.panelElement.querySelector(".tab-preview-activeness").textContent = 541 this.#displayActiveness + this.#displaySponsorProtection; 542 } else { 543 this.panelElement.querySelector(".tab-preview-pid").textContent = ""; 544 this.panelElement.querySelector(".tab-preview-activeness").textContent = 545 ""; 546 } 547 548 const noteTextContainer = this.panelElement.querySelector( 549 ".tab-note-text-container" 550 ); 551 const addNoteButton = this.panelElement.querySelector( 552 ".tab-preview-add-note" 553 ); 554 if (this._prefUseTabNotes && lazy.TabNotes.isEligible(this.#tab)) { 555 lazy.TabNotes.get(this.#tab).then(note => { 556 noteTextContainer.textContent = note?.text || ""; 557 addNoteButton.toggleAttribute("hidden", !!note); 558 }); 559 } else { 560 noteTextContainer.textContent = ""; 561 addNoteButton.setAttribute("hidden", ""); 562 } 563 564 let thumbnailContainer = this.panelElement.querySelector( 565 ".tab-preview-thumbnail-container" 566 ); 567 thumbnailContainer.classList.toggle( 568 "hide-thumbnail", 569 !this.#hasValidThumbnailState(this.#tab) && 570 !this.#hasValidWireframeState(this.#tab) 571 ); 572 if (thumbnailContainer.firstChild != this.#thumbnailElement) { 573 thumbnailContainer.replaceChildren(); 574 if (this.#thumbnailElement) { 575 thumbnailContainer.appendChild(this.#thumbnailElement); 576 } 577 this.panelElement.dispatchEvent( 578 new CustomEvent("previewThumbnailUpdated", { 579 detail: { 580 thumbnail: this.#thumbnailElement, 581 }, 582 }) 583 ); 584 } 585 this.#movePanel(); 586 } 587 588 #movePanel() { 589 if (this.#tab) { 590 this.panelElement.moveToAnchor( 591 this.#tab, 592 this.popupOptions.position, 593 this.popupOptions.x, 594 this.popupOptions.y 595 ); 596 } 597 } 598 599 get popupOptions() { 600 let tabContainer = this.win.gBrowser.tabContainer; 601 // Popup anchors to the bottom edge of the tab in horizontal tabs mode 602 if (!tabContainer.verticalMode) { 603 return { 604 position: "bottomleft topleft", 605 x: 0, 606 y: -2, 607 }; 608 } 609 610 let sidebarAtStart = this.win.SidebarController._positionStart; 611 612 // Popup anchors to the end edge of the tab in vertical mode 613 let positionFromAnchor = sidebarAtStart ? "topright" : "topleft"; 614 let positionFromPanel = sidebarAtStart ? "topleft" : "topright"; 615 let positionX = 0; 616 let positionY = 3; 617 618 // Popup anchors to the corner of tabs in the vertical pinned grid 619 if (tabContainer.isContainerVerticalPinnedGrid(this.#tab)) { 620 positionFromAnchor = sidebarAtStart ? "bottomright" : "bottomleft"; 621 positionX = sidebarAtStart ? -6 : 6; 622 positionY = -10; 623 } 624 625 return { 626 position: `${positionFromAnchor} ${positionFromPanel}`, 627 x: positionX, 628 y: positionY, 629 }; 630 } 631 } 632 633 class TabGroupPanel extends HoverPanel { 634 /** @type {MozTabbrowserTabGroup|null} */ 635 #group; 636 637 static PANEL_UPDATE_EVENTS = [ 638 "TabAttrModified", 639 "TabClose", 640 "TabGrouped", 641 "TabMove", 642 "TabOpen", 643 "TabSelect", 644 "TabUngrouped", 645 ]; 646 647 constructor(panel, panelSet) { 648 super(panel, panelSet); 649 650 this.panelContent = panel.querySelector("#tabgroup-panel-content"); 651 this.#group = null; 652 } 653 654 activate(group) { 655 if (this.#group && this.#group != group) { 656 this.#removeGroupListeners(); 657 } 658 659 this.#group = group; 660 this.#movePanel(); 661 this.#updatePanelContent(); 662 Glean.tabgroup.groupInteractions.hover_preview.add(); 663 664 if (this.panelElement.state == "closed") { 665 this.panelSet.panelOpener.execute(() => { 666 if (!this.panelSet.shouldActivate() || !this.#group.collapsed) { 667 return; 668 } 669 this.#doOpenPanel(); 670 }, this); 671 } else { 672 this.#addGroupListeners(); 673 } 674 } 675 676 /** 677 * Move keyboard focus into the group preview panel. 678 * 679 * @param {-1|1} [dir] Whether to focus the beginning or end of the list. 680 */ 681 focusPanel(dir = 1) { 682 let childIndex = dir > 0 ? 0 : this.panelContent.children.length - 1; 683 this.panelContent.children[childIndex].focus(); 684 } 685 686 #doOpenPanel() { 687 this.panelElement.addEventListener("mouseout", this); 688 this.panelElement.addEventListener("command", this); 689 690 this.#addGroupListeners(); 691 692 this.panelElement.openPopup(this.#popupTarget, this.popupOptions); 693 } 694 695 #updatePanelContent() { 696 const fragment = this.win.document.createDocumentFragment(); 697 for (let tab of this.#group.tabs) { 698 let tabbutton = this.win.document.createXULElement("toolbarbutton"); 699 tabbutton.setAttribute("role", "button"); 700 tabbutton.setAttribute("keyNav", false); 701 tabbutton.setAttribute("tabindex", 0); 702 tabbutton.setAttribute("label", tab.label); 703 if (tab.linkedBrowser) { 704 tabbutton.setAttribute( 705 "image", 706 "page-icon:" + tab.linkedBrowser.currentURI.spec 707 ); 708 } 709 tabbutton.setAttribute("tooltiptext", tab.label); 710 tabbutton.classList.add( 711 "subviewbutton", 712 "subviewbutton-iconic", 713 "group-preview-button" 714 ); 715 if (tab == this.win.gBrowser.selectedTab) { 716 tabbutton.classList.add("active-tab"); 717 } 718 tabbutton.tab = tab; 719 fragment.appendChild(tabbutton); 720 } 721 this.panelContent.replaceChildren(fragment); 722 } 723 724 handleEvent(event) { 725 if (event.type == "command") { 726 if (this.win.gBrowser.selectedTab == event.target.tab) { 727 this.deactivate({ force: true }); 728 return; 729 } 730 731 // bug1984732: temporarily disable CSS transitions while tabs are 732 // switching to prevent an unsightly "slide" animation when switching 733 // tabs within a collapsed group 734 let switchingTabs = [this.win.gBrowser.selectedTab, event.target.tab]; 735 if (switchingTabs.every(tab => tab.group == this.#group)) { 736 for (let tab of switchingTabs) { 737 tab.animationsEnabled = false; 738 } 739 740 this.win.addEventListener( 741 "TabSwitchDone", 742 () => { 743 this.win.requestAnimationFrame(() => { 744 for (let tab of switchingTabs) { 745 tab.animationsEnabled = true; 746 } 747 }); 748 }, 749 { once: true } 750 ); 751 } 752 753 this.win.gBrowser.selectedTab = event.target.tab; 754 this.deactivate({ force: true }); 755 } else if ( 756 event.type == "mouseout" && 757 this.hoverTargets.every(target => !target.contains(event.relatedTarget)) 758 ) { 759 this.deactivate(); 760 } else if (TabGroupPanel.PANEL_UPDATE_EVENTS.includes(event.type)) { 761 this.#updatePanelContent(); 762 } 763 } 764 765 onBeforeHide() { 766 this.panelElement.removeEventListener("mouseout", this); 767 this.panelElement.removeEventListener("command", this); 768 769 this.#removeGroupListeners(); 770 } 771 772 get hoverTargets() { 773 let targets = [this.panelElement]; 774 if (this.#popupTarget) { 775 targets.push(this.#popupTarget); 776 } 777 return targets; 778 } 779 780 get popupOptions() { 781 if (!this.win.gBrowser.tabContainer.verticalMode) { 782 return { 783 position: "bottomleft topleft", 784 x: 0, 785 y: -2, 786 }; 787 } 788 if (!this.win.SidebarController._positionStart) { 789 return { 790 position: "topleft topright", 791 x: 0, 792 y: -5, 793 }; 794 } 795 return { 796 position: "topright topleft", 797 x: 0, 798 y: -5, 799 }; 800 } 801 802 get #popupTarget() { 803 return this.#group?.labelContainerElement; 804 } 805 806 #addGroupListeners() { 807 if (!this.#group) { 808 return; 809 } 810 this.#group.hoverPreviewPanelActive = true; 811 for (let event of TabGroupPanel.PANEL_UPDATE_EVENTS) { 812 this.#group.addEventListener(event, this); 813 } 814 } 815 816 #removeGroupListeners() { 817 if (!this.#group) { 818 return; 819 } 820 this.#group.hoverPreviewPanelActive = false; 821 for (let event of TabGroupPanel.PANEL_UPDATE_EVENTS) { 822 this.#group.removeEventListener(event, this); 823 } 824 } 825 826 #movePanel() { 827 if (!this.#popupTarget) { 828 return; 829 } 830 this.panelElement.moveToAnchor( 831 this.#popupTarget, 832 this.popupOptions.position, 833 this.popupOptions.x, 834 this.popupOptions.y 835 ); 836 } 837 } 838 839 /** 840 * A wrapper that allows for delayed function execution, but with the 841 * ability to "zero" (i.e. cancel) the delay for a predetermined period 842 */ 843 class TabPreviewPanelTimedFunction { 844 /** @type {number} */ 845 #zeroDelayTime; 846 847 /** @type {Window} */ 848 #win; 849 850 /** @type {number | null} */ 851 #timer; 852 853 /** @type {number | null} */ 854 #useZeroDelay; 855 856 /** @type {function(): void | null} */ 857 #target; 858 859 /** @type {TabPanel} */ 860 #from; 861 862 constructor(zeroDelayTime, win) { 863 XPCOMUtils.defineLazyPreferenceGetter( 864 this, 865 "_prefPreviewDelay", 866 "ui.tooltip.delay_ms" 867 ); 868 869 this.#zeroDelayTime = zeroDelayTime; 870 this.#win = win; 871 872 this.#timer = null; 873 this.#useZeroDelay = false; 874 875 this.#target = null; 876 this.#from = null; 877 } 878 879 /** 880 * Execute a function after a delay, according to the following rules: 881 * - By default, execute the function after the time specified by `ui.tooltip.delay_ms`. 882 * - If a timer is already active, the timer will not be restarted, but the 883 * function to be executed will be set to the one from the most recent 884 * call (see notes below) 885 * - If the zero delay has been set with `setZeroDelay`, the function will 886 * invoke immediately 887 * 888 * Multiple calls to `execute` within the delay will not invoke the function 889 * each time. The original delay will be preserved (i.e. the function will 890 * execute after `ui.tooltip.delay_ms` from the first call) but the function 891 * that is executed may be updated by subsequent calls to execute. This 892 * ensures that if the panel we want to open changes (e.g. if a user hovers 893 * over a tab, then quickly switches to a tab group before the delay 894 * expires), the delay is not restarted, which would cause a longer than 895 * usual time to open. 896 * 897 * @param {function(): void | null} target 898 * The function to execute 899 * @param {TabPanel} from 900 * The calling panel 901 */ 902 execute(target, from) { 903 this.#target = target; 904 this.#from = from; 905 906 if (this.delayActive) { 907 return; 908 } 909 910 // Always setting a timer, even in the situation where the 911 // delay is zero, seems to prevent a class of race conditions 912 // where multiple tabs are hovered in quick succession 913 this.#timer = this.#win.setTimeout( 914 () => { 915 this.#timer = null; 916 this.#target(); 917 }, 918 this.#useZeroDelay ? 0 : this._prefPreviewDelay 919 ); 920 } 921 922 /** 923 * Clear the timer, if it is active, for example when a user moves off a panel. 924 * This has the effect of suppressing the delayed function execution. 925 * 926 * @param {TabPanel} from 927 * The calling panel. This must be the same as the panel that most recently 928 * called `execute`. If it is not, the call will be ignored. This is 929 * necessary to prevent, e.g., the tab hover panel from inadvertently 930 * cancelling the opening of the tab group hover panel in cases where the 931 * user quickly hovers between tabs and tab groups before the panel fully 932 * opens. 933 */ 934 clear(from) { 935 if (from == this.#from && this.#timer) { 936 this.#win.clearTimeout(this.#timer); 937 this.#timer = null; 938 this.#from = null; 939 } 940 } 941 942 /** 943 * Temporarily suppress the delay mechanism. 944 * 945 * The delay will automatically reactivate after a set interval, which is 946 * configured by the constructor. 947 */ 948 setZeroDelay() { 949 if (this.#useZeroDelay) { 950 this.#win.clearTimeout(this.#useZeroDelay); 951 } 952 953 this.#useZeroDelay = this.#win.setTimeout(() => { 954 this.#useZeroDelay = null; 955 }, this.#zeroDelayTime); 956 } 957 958 get delayActive() { 959 return this.#timer !== null; 960 } 961 }