Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
var { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
);
const ZERO_DELAY_ACTIVATION_TIME = 300;
/**
* Detailed preview card that displays when hovering a tab
*/
export default class TabHoverPreviewPanel {
constructor(panel) {
this._panel = panel;
this._win = panel.ownerGlobal;
this._tab = null;
this._thumbnailElement = null;
// Observe changes to this tab's DOM, and
// update the preview if the tab title changes
this._tabObserver = new this._win.MutationObserver(
(mutationList, _observer) => {
for (const mutation of mutationList) {
if (mutation.attributeName === "label") {
this._updatePreview();
}
}
}
);
this._setExternalPopupListeners();
XPCOMUtils.defineLazyPreferenceGetter(
this,
"_prefDisableAutohide",
"ui.popup.disable_autohide",
false
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"_prefPreviewDelay",
"ui.tooltip.delay_ms"
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"_prefDisplayThumbnail",
"browser.tabs.hoverPreview.showThumbnails",
false
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"_prefShowPidAndActiveness",
"browser.tabs.tooltipsShowPidAndActiveness",
false
);
this._panelOpener = new TabPreviewPanelTimedFunction(
() => {
this._panel.openPopup(this._tab, this.#popupOptions);
},
this._prefPreviewDelay,
ZERO_DELAY_ACTIVATION_TIME,
this._win
);
}
get #verticalMode() {
return this._win.gBrowser.tabContainer.verticalMode;
}
get #popupOptions() {
if (!this.#verticalMode) {
return {
position: "bottomleft topleft",
x: 0,
y: -2,
};
}
if (!this._win.SidebarController._positionStart) {
return {
position: "topleft topright",
x: 0,
y: 4,
};
}
return {
position: "topright topleft",
x: 0,
y: 4,
};
}
getPrettyURI(uri) {
try {
let url = new URL(uri);
if (url.protocol == "about:" && url.pathname == "reader") {
url = new URL(url.searchParams.get("url"));
}
if (url.protocol === "about:") {
return url.href;
}
return `${url.hostname}`.replace(/^w{3}\./, "");
} catch {
return uri;
}
}
_hasValidThumbnailState(tab) {
return (
this._prefDisplayThumbnail &&
tab &&
tab.linkedBrowser &&
!tab.getAttribute("pending") &&
!tab.selected
);
}
_maybeRequestThumbnail() {
let tab = this._tab;
if (!this._hasValidThumbnailState(tab)) {
return;
}
let thumbnailCanvas = this._win.document.createElement("canvas");
thumbnailCanvas.width = 280 * this._win.devicePixelRatio;
thumbnailCanvas.height = 140 * this._win.devicePixelRatio;
this._win.PageThumbs.captureTabPreviewThumbnail(
tab.linkedBrowser,
thumbnailCanvas
)
.then(() => {
// in case we've changed tabs after capture started, ensure we still want to show the thumbnail
if (this._tab == tab && this._hasValidThumbnailState(tab)) {
this._thumbnailElement = thumbnailCanvas;
this._updatePreview();
}
})
.catch(e => {
// Most likely the window was killed before capture completed, so just log the error
console.error(e);
});
}
activate(tab) {
if (this._isDisabled()) {
return;
}
this._tab = tab;
this._tabObserver.observe(this._tab, {
attributes: true,
});
// Calling `moveToAnchor` in advance of the call to `openPopup` ensures
// that race conditions can be avoided in cases where the user hovers
// over a different tab while the preview panel is still opening.
// This will ensure the move operation is carried out even if the popup is
// in an intermediary state (opening but not fully open).
//
// If the popup is closed this call will be ignored.
this._movePanel();
this._thumbnailElement = null;
this._maybeRequestThumbnail();
if (this._panel.state == "open" || this._panel.state == "showing") {
this._updatePreview();
}
this._panelOpener.execute();
this._win.addEventListener("TabSelect", this);
this._panel.addEventListener("popupshowing", this);
}
deactivate(leavingTab = null) {
if (leavingTab) {
if (this._tab != leavingTab) {
return;
}
this._win.requestAnimationFrame(() => {
if (this._tab == leavingTab) {
this.deactivate();
}
});
return;
}
this._tab = null;
this._tabObserver.disconnect();
this._thumbnailElement = null;
this._panel.removeEventListener("popupshowing", this);
this._win.removeEventListener("TabSelect", this);
if (!this._prefDisableAutohide) {
this._panel.hidePopup();
}
this._panelOpener.setZeroDelay();
}
handleEvent(e) {
switch (e.type) {
case "popupshowing":
this._updatePreview();
break;
case "TabSelect":
this.deactivate();
break;
}
}
_updatePreview() {
this._panel.querySelector(".tab-preview-title").textContent =
this._displayTitle;
this._panel.querySelector(".tab-preview-uri").textContent =
this._displayURI;
if (this._prefShowPidAndActiveness) {
this._panel.querySelector(".tab-preview-pid").textContent =
this._displayPids;
this._panel.querySelector(".tab-preview-activeness").textContent =
this._displayActiveness;
} else {
this._panel.querySelector(".tab-preview-pid").textContent = "";
this._panel.querySelector(".tab-preview-activeness").textContent = "";
}
let thumbnailContainer = this._panel.querySelector(
".tab-preview-thumbnail-container"
);
thumbnailContainer.classList.toggle(
"hide-thumbnail",
!this._hasValidThumbnailState(this._tab)
);
if (thumbnailContainer.firstChild != this._thumbnailElement) {
thumbnailContainer.replaceChildren();
if (this._thumbnailElement) {
thumbnailContainer.appendChild(this._thumbnailElement);
}
this._panel.dispatchEvent(
new CustomEvent("previewThumbnailUpdated", {
detail: {
thumbnail: this._thumbnailElement,
},
})
);
}
this._movePanel();
}
_movePanel() {
if (this._tab) {
this._panel.moveToAnchor(
this._tab,
this.#popupOptions.position,
this.#popupOptions.x,
this.#popupOptions.y
);
}
}
/**
* Listen for any panels or menupopups that open or close anywhere else in the DOM tree
* and maintain a list of the ones that are currently open.
* This is used to disable tab previews until such time as the other panels are closed.
*/
_setExternalPopupListeners() {
// Since the tab preview panel is lazy loaded, there is a possibility that panels could
// already be open on init. Therefore we need to initialize _openPopups with existing panels
// the first time.
const initialPopups = this._win.document.querySelectorAll(
"panel[panelopen=true]:not(#tab-preview-panel), panel[animating=true]:not(#tab-preview-panel), menupopup[open=true]"
);
this._openPopups = new Set(initialPopups);
const handleExternalPopupEvent = (eventName, setMethod) => {
this._win.addEventListener(eventName, ev => {
if (
ev.target !== this._panel &&
(ev.target.nodeName == "panel" || ev.target.nodeName == "menupopup")
) {
this._openPopups[setMethod](ev.target);
}
});
};
handleExternalPopupEvent("popupshowing", "add");
handleExternalPopupEvent("popuphiding", "delete");
}
_isDisabled() {
return (
// Other popups are open.
this._openPopups.size ||
// TODO (bug 1899556): for now disable in background windows, as there are
// issues with windows ordering on Linux (bug 1897475), plus intermittent
// persistence of previews after session restore (bug 1888148).
this._win != Services.focus.activeWindow
);
}
get _displayTitle() {
if (!this._tab) {
return "";
}
return this._tab.textLabel.textContent;
}
get _displayURI() {
if (!this._tab || !this._tab.linkedBrowser) {
return "";
}
return this.getPrettyURI(this._tab.linkedBrowser.currentURI.spec);
}
get _displayPids() {
const pids = this._win.gBrowser.getTabPids(this._tab);
if (!pids.length) {
return "";
}
let pidLabel = pids.length > 1 ? "pids" : "pid";
return `${pidLabel}: ${pids.join(", ")}`;
}
get _displayActiveness() {
return this._tab?.linkedBrowser?.docShellIsActive ? "[A]" : "";
}
}
/**
* A wrapper that allows for delayed function execution, but with the
* ability to "zero" (i.e. cancel) the delay for a predetermined period
*/
class TabPreviewPanelTimedFunction {
constructor(target, delay, zeroDelayTime, win) {
this._target = target;
this._delay = delay;
this._zeroDelayTime = zeroDelayTime;
this._win = win;
this._timer = null;
this._useZeroDelay = false;
}
execute() {
if (this.delayActive) {
return;
}
// Always setting a timer, even in the situation where the
// delay is zero, seems to prevent a class of race conditions
// where multiple tabs are hovered in quick succession
this._timer = this._win.setTimeout(
() => {
this._timer = null;
this._target();
},
this._useZeroDelay ? 0 : this._delay
);
}
clear() {
if (this._timer) {
this._win.clearTimeout(this._timer);
this._timer = null;
}
}
setZeroDelay() {
this.clear();
if (this._useZeroDelay) {
return;
}
this._win.setTimeout(() => {
this._useZeroDelay = false;
}, this._zeroDelayTime);
this._useZeroDelay = true;
}
get delayActive() {
return this._timer !== null;
}
}