tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 }