/* 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/. */ // This file is loaded into the browser window scope. /* eslint-env mozilla/browser-window */ /** * Tab previews utility, produces thumbnails */ var tabPreviews = { get aspectRatio() { let { PageThumbUtils } = ChromeUtils.importESModule( "resource://gre/modules/PageThumbUtils.sys.mjs" ); let [width, height] = PageThumbUtils.getThumbnailSize(window); delete this.aspectRatio; return (this.aspectRatio = height / width); }, /** * Get the stored thumbnail URL for a given page URL and wait up to 1s for it * to load. If the browser is discarded and there is no stored thumbnail, the * image URL will fail to load and this method will return null after 1s. * Callers should handle this case by doing nothing or using a fallback image. * @param {String} uri The page URL. * @returns {Promise} */ loadImage: async function tabPreviews_loadImage(uri) { let img = new Image(); img.src = PageThumbs.getThumbnailURL(uri); if (img.complete && img.naturalWidth) { return img; } return new Promise(resolve => { const controller = new AbortController(); img.addEventListener( "load", () => { clearTimeout(timeout); controller.abort(); resolve(img); }, { signal: controller.signal } ); const timeout = setTimeout(() => { controller.abort(); resolve(null); }, 1000); }); }, /** * For a given tab, retrieve a preview thumbnail (a canvas or an image) from * storage or capture a new one. If the tab's URL has changed since the * previous call, the thumbnail will be regenerated. * @param {MozTabbrowserTab} aTab The tab to get a preview for. * @returns {Promise} Resolves to... * @resolves {HTMLCanvasElement} If a thumbnail can NOT be captured and stored * for the tab, or if the tab is still loading, a snapshot is taken and * returned as a canvas. It may be cached as a canvas (separately from * thumbnail storage) in aTab.__thumbnail if the tab is finished loading. If * the snapshot CAN be stored as a thumbnail, the snapshot is converted to a * blob image and drawn in the returned canvas, but the image is added to * thumbnail storage and cached in aTab.__thumbnail. * @resolves {Image} A cached blob image from a previous thumbnail capture. * e.g. * @resolves {null} If a thumbnail cannot be captured for any reason (e.g. * because the tab is discarded) and there is no cached/stored thumbnail. */ get: async function tabPreviews_get(aTab) { let browser = aTab.linkedBrowser; let uri = browser.currentURI.spec; // Invalidate the cached thumbnail since the tab has changed. if (aTab.__thumbnail_lastURI && aTab.__thumbnail_lastURI != uri) { aTab.__thumbnail = null; aTab.__thumbnail_lastURI = null; } // A cached thumbnail (not from thumbnail storage) is available. if (aTab.__thumbnail) { return aTab.__thumbnail; } // This means the browser is discarded. Try to load a stored thumbnail, and // use a fallback style otherwise. if (!browser.browsingContext) { return this.loadImage(uri); } // Don't cache or store the thumbnail if the tab is still loading. return this.capture(aTab, !aTab.hasAttribute("busy")); }, /** * For a given tab, capture a preview thumbnail (a canvas), optionally cache * it in aTab.__thumbnail, and possibly store it in thumbnail storage. * @param {MozTabbrowserTab} aTab The tab to capture a preview for. * @param {Boolean} aShouldCache Cache/store the captured thumbnail? * @returns {Promise} Resolves to... * @resolves {HTMLCanvasElement} A snapshot of the tab's content. If the * snapshot is safe for storage and aShouldCache is true, the snapshot is * converted to a blob image, stored and cached, and drawn in the returned * canvas. The thumbnail can then be recovered even if the browser is * discarded. Otherwise, the canvas itself is cached in aTab.__thumbnail. * @resolves {null} If a fatal exception occurred during thumbnail capture. */ capture: async function tabPreviews_capture(aTab, aShouldCache) { let browser = aTab.linkedBrowser; let uri = browser.currentURI.spec; let canvas = PageThumbs.createCanvas(window); const doStore = await PageThumbs.shouldStoreThumbnail(browser); if (doStore && aShouldCache) { await PageThumbs.captureAndStore(browser); let img = await this.loadImage(uri); if (img) { // Cache the stored blob image for future use. aTab.__thumbnail = img; aTab.__thumbnail_lastURI = uri; // Draw the stored blob image in the canvas. canvas.getContext("2d").drawImage(img, 0, 0); } else { canvas = null; } } else { try { await PageThumbs.captureToCanvas(browser, canvas); if (aShouldCache) { // Cache the canvas itself for future use. aTab.__thumbnail = canvas; aTab.__thumbnail_lastURI = uri; } } catch (error) { console.error(error); canvas = null; } } return canvas; }, }; var tabPreviewPanelHelper = { opening(host) { host.panel.hidden = false; var handler = this._generateHandler(host); host.panel.addEventListener("popupshown", handler); host.panel.addEventListener("popuphiding", handler); host._prevFocus = document.commandDispatcher.focusedElement; }, _generateHandler(host) { var self = this; return function listener(event) { if (event.target == host.panel) { host.panel.removeEventListener(event.type, listener); self["_" + event.type](host); } }; }, _popupshown(host) { if ("setupGUI" in host) { host.setupGUI(); } }, _popuphiding(host) { if ("suspendGUI" in host) { host.suspendGUI(); } if (host._prevFocus) { Services.focus.setFocus( host._prevFocus, Ci.nsIFocusManager.FLAG_NOSCROLL ); host._prevFocus = null; } else { gBrowser.selectedBrowser.focus(); } if (host.tabToSelect) { gBrowser.selectedTab = host.tabToSelect; host.tabToSelect = null; } }, }; /** * Ctrl-Tab panel */ var ctrlTab = { maxTabPreviews: 7, get panel() { delete this.panel; return (this.panel = document.getElementById("ctrlTab-panel")); }, get showAllButton() { delete this.showAllButton; this.showAllButton = document.createXULElement("button"); this.showAllButton.id = "ctrlTab-showAll"; this.showAllButton.addEventListener("mouseover", this); this.showAllButton.addEventListener("command", this); this.showAllButton.addEventListener("click", this); document .getElementById("ctrlTab-showAll-container") .appendChild(this.showAllButton); return this.showAllButton; }, get previews() { delete this.previews; this.previews = []; let previewsContainer = document.getElementById("ctrlTab-previews"); for (let i = 0; i < this.maxTabPreviews; i++) { let preview = this._makePreview(); previewsContainer.appendChild(preview); this.previews.push(preview); } this.previews.push(this.showAllButton); return this.previews; }, get keys() { var keys = {}; ["close", "find", "selectAll"].forEach(function (key) { keys[key] = document .getElementById("key_" + key) .getAttribute("key") .toLocaleLowerCase() .charCodeAt(0); }); delete this.keys; return (this.keys = keys); }, _selectedIndex: 0, get selected() { return this._selectedIndex < 0 ? document.activeElement : this.previews[this._selectedIndex]; }, get isOpen() { return ( this.panel.state == "open" || this.panel.state == "showing" || this._timer ); }, get tabCount() { return this.tabList.length; }, get tabPreviewCount() { return Math.min(this.maxTabPreviews, this.tabCount); }, get tabList() { return this._recentlyUsedTabs; }, init: function ctrlTab_init() { if (!this._recentlyUsedTabs) { this._initRecentlyUsedTabs(); this._init(true); } }, uninit: function ctrlTab_uninit() { if (this._recentlyUsedTabs) { this._recentlyUsedTabs = null; this._init(false); } }, prefName: "browser.ctrlTab.sortByRecentlyUsed", readPref: function ctrlTab_readPref() { var enable = Services.prefs.getBoolPref(this.prefName) && !Services.prefs.getBoolPref( "browser.ctrlTab.disallowForScreenReaders", false ); if (enable) { this.init(); } else { this.uninit(); } }, observe(aSubject, aTopic, aPrefName) { this.readPref(); }, _makePreview() { let preview = document.createXULElement("button"); preview.className = "ctrlTab-preview"; preview.setAttribute("pack", "center"); preview.setAttribute("flex", "1"); preview.addEventListener("mouseover", this); preview.addEventListener("command", this); preview.addEventListener("click", this); let previewInner = document.createXULElement("vbox"); previewInner.className = "ctrlTab-preview-inner"; preview.appendChild(previewInner); let canvas = (preview._canvas = document.createXULElement("hbox")); canvas.className = "ctrlTab-canvas"; previewInner.appendChild(canvas); let faviconContainer = document.createXULElement("hbox"); faviconContainer.className = "ctrlTab-favicon-container"; previewInner.appendChild(faviconContainer); let favicon = (preview._favicon = document.createXULElement("image")); favicon.className = "ctrlTab-favicon"; faviconContainer.appendChild(favicon); let label = (preview._label = document.createXULElement("label")); label.className = "ctrlTab-label plain"; label.setAttribute("crop", "end"); previewInner.appendChild(label); return preview; }, updatePreviews: function ctrlTab_updatePreviews() { for (let i = 0; i < this.previews.length; i++) { this.updatePreview(this.previews[i], this.tabList[i]); } document.l10n.setAttributes( this.showAllButton, "tabbrowser-ctrl-tab-list-all-tabs", { tabCount: this.tabCount } ); this.showAllButton.hidden = !gTabsPanel.canOpen; }, updatePreview: function ctrlTab_updatePreview(aPreview, aTab) { if (aPreview == this.showAllButton) { return; } aPreview._tab = aTab; if (aTab) { let canvas = aPreview._canvas; let canvasWidth = this.canvasWidth; let canvasHeight = this.canvasHeight; canvas.setAttribute("width", canvasWidth); canvas.style.minWidth = canvasWidth + "px"; canvas.style.maxWidth = canvasWidth + "px"; canvas.style.minHeight = canvasHeight + "px"; canvas.style.maxHeight = canvasHeight + "px"; tabPreviews .get(aTab) .then(img => { switch (aPreview._tab) { case aTab: this._clearCanvas(canvas); if (img) { canvas.appendChild(img); } break; case null: // The preview panel is not open, so don't render anything. this._clearCanvas(canvas); break; // If the tab exists but it has changed since updatePreview was // called, the preview will likely be handled by a later // updatePreview call, e.g. on TabAttrModified. } }) .catch(error => console.error(error)); aPreview._label.setAttribute("value", aTab.label); aPreview.setAttribute("tooltiptext", aTab.label); if (aTab.image) { aPreview._favicon.setAttribute("src", aTab.image); } else { aPreview._favicon.removeAttribute("src"); } aPreview.hidden = false; } else { this._clearCanvas(aPreview._canvas); aPreview.hidden = true; aPreview._label.removeAttribute("value"); aPreview.removeAttribute("tooltiptext"); aPreview._favicon.removeAttribute("src"); } }, // Remove previous preview images from the canvas box. _clearCanvas(canvas) { while (canvas.firstElementChild) { canvas.firstElementChild.remove(); } }, advanceFocus: function ctrlTab_advanceFocus(aForward) { let selectedIndex = this.previews.indexOf(this.selected); do { selectedIndex += aForward ? 1 : -1; if (selectedIndex < 0) { selectedIndex = this.previews.length - 1; } else if (selectedIndex >= this.previews.length) { selectedIndex = 0; } } while (this.previews[selectedIndex].hidden); if (this._selectedIndex == -1) { // Focus is already in the panel. this.previews[selectedIndex].focus(); } else { this._selectedIndex = selectedIndex; } if (this.previews[selectedIndex]._tab) { gBrowser.warmupTab(this.previews[selectedIndex]._tab); } if (this._timer) { clearTimeout(this._timer); this._timer = null; this._openPanel(); } }, _mouseOverFocus: function ctrlTab_mouseOverFocus(aPreview) { if (this._trackMouseOver) { aPreview.focus(); } }, pick: function ctrlTab_pick(aPreview) { if (!this.tabCount) { return; } var select = aPreview || this.selected; if (select == this.showAllButton) { this.showAllTabs("ctrltab-all-tabs-button"); } else { this.close(select._tab); } }, showAllTabs: function ctrlTab_showAllTabs(aEntrypoint = "unknown") { this.close(); gTabsPanel.showAllTabsPanel(null, aEntrypoint); }, remove: function ctrlTab_remove(aPreview) { if (aPreview._tab) { gBrowser.removeTab(aPreview._tab); } }, attachTab: function ctrlTab_attachTab(aTab, aPos) { // If the tab is hidden, don't add it to the list unless it's selected // (Normally hidden tabs would be unhidden when selected, but that doesn't // happen for Firefox View). if (aTab.closing || (aTab.hidden && !aTab.selected)) { return; } // If the tab is already in the list, remove it before re-inserting it. this.detachTab(aTab); if (aPos == 0) { this._recentlyUsedTabs.unshift(aTab); } else if (aPos) { this._recentlyUsedTabs.splice(aPos, 0, aTab); } else { this._recentlyUsedTabs.push(aTab); } }, detachTab: function ctrlTab_detachTab(aTab) { var i = this._recentlyUsedTabs.indexOf(aTab); if (i >= 0) { this._recentlyUsedTabs.splice(i, 1); } }, open: function ctrlTab_open() { if (this.isOpen) { return; } this.canvasWidth = Math.ceil( (screen.availWidth * 0.85) / this.maxTabPreviews ); this.canvasHeight = Math.round(this.canvasWidth * tabPreviews.aspectRatio); this.updatePreviews(); this._selectedIndex = 1; gBrowser.warmupTab(this.selected._tab); // Add a slight delay before showing the UI, so that a quick // "ctrl-tab" keypress just flips back to the MRU tab. this._timer = setTimeout(() => { this._timer = null; this._openPanel(); }, 200); }, _openPanel: function ctrlTab_openPanel() { tabPreviewPanelHelper.opening(this); let width = Math.min( screen.availWidth * 0.99, this.canvasWidth * 1.25 * this.tabPreviewCount ); this.panel.style.width = width + "px"; var estimateHeight = this.canvasHeight * 1.25 + 75; this.panel.openPopupAtScreen( screen.availLeft + (screen.availWidth - width) / 2, screen.availTop + (screen.availHeight - estimateHeight) / 2, false ); }, close: function ctrlTab_close(aTabToSelect) { if (!this.isOpen) { return; } if (this._timer) { clearTimeout(this._timer); this._timer = null; this.suspendGUI(); if (aTabToSelect) { gBrowser.selectedTab = aTabToSelect; } return; } this.tabToSelect = aTabToSelect; this.panel.hidePopup(); }, setupGUI: function ctrlTab_setupGUI() { this.selected.focus(); this._selectedIndex = -1; // Track mouse movement after a brief delay so that the item that happens // to be under the mouse pointer initially won't be selected unintentionally. this._trackMouseOver = false; setTimeout( function (self) { if (self.isOpen) { self._trackMouseOver = true; } }, 0, this ); }, suspendGUI: function ctrlTab_suspendGUI() { for (let preview of this.previews) { this.updatePreview(preview, null); } }, onKeyDown(event) { let action = ShortcutUtils.getSystemActionForEvent(event); if (action != ShortcutUtils.CYCLE_TABS) { return; } event.preventDefault(); event.stopPropagation(); if (this.isOpen) { this.advanceFocus(!event.shiftKey); return; } if (event.shiftKey) { this.showAllTabs("shift-tab"); return; } Services.els.addSystemEventListener(document, "keyup", this, false); let tabs = gBrowser.visibleTabs; if (tabs.length > 2) { this.open(); } else if (tabs.length == 2) { let index = tabs[0].selected ? 1 : 0; gBrowser.selectedTab = tabs[index]; } }, onKeyPress(event) { if (!this.isOpen || !event.ctrlKey) { return; } event.preventDefault(); event.stopPropagation(); if (event.keyCode == event.DOM_VK_DELETE) { this.remove(this.selected); return; } switch (event.charCode) { case this.keys.close: this.remove(this.selected); break; case this.keys.find: this.showAllTabs("ctrltab-key-find"); break; case this.keys.selectAll: this.showAllTabs("ctrltab-key-selectAll"); break; } }, removeClosingTabFromUI: function ctrlTab_removeClosingTabFromUI(aTab) { if (this.tabCount == 2) { this.close(); return; } this.updatePreviews(); if (this.selected.hidden) { this.advanceFocus(false); } if (this.selected == this.showAllButton) { this.advanceFocus(false); } // If the current tab is removed, another tab can steal our focus. if (aTab.selected && this.panel.state == "open") { setTimeout( function (selected) { selected.focus(); }, 0, this.selected ); } }, handleEvent: function ctrlTab_handleEvent(event) { switch (event.type) { case "SSWindowRestored": this._initRecentlyUsedTabs(); break; case "TabAttrModified": // tab attribute modified (i.e. label, busy, image) // update preview only if tab attribute modified in the list if ( event.detail.changed.some((elem, ind, arr) => ["label", "busy", "image"].includes(elem) ) ) { for (let i = this.previews.length - 1; i >= 0; i--) { if ( this.previews[i]._tab && this.previews[i]._tab == event.target ) { this.updatePreview(this.previews[i], event.target); break; } } } break; case "TabSelect": this.attachTab(event.target, 0); // If the previous tab was hidden (e.g. Firefox View), remove it from // the list when it's deselected. let previousTab = event.detail.previousTab; if (previousTab.hidden) { this.detachTab(previousTab); } break; case "TabOpen": this.attachTab(event.target, 1); break; case "TabClose": this.detachTab(event.target); if (this.isOpen) { this.removeClosingTabFromUI(event.target); } break; case "TabHide": this.detachTab(event.target); break; case "TabShow": this.attachTab(event.target); this._sortRecentlyUsedTabs(); break; case "keydown": this.onKeyDown(event); break; case "keypress": this.onKeyPress(event); break; case "keyup": // During cycling tabs, we avoid sending keyup event to content document. event.preventDefault(); event.stopPropagation(); if (event.keyCode === event.DOM_VK_CONTROL) { Services.els.removeSystemEventListener( document, "keyup", this, false ); if (this.isOpen) { this.pick(); } } break; case "popupshowing": if (event.target.id == "menu_viewPopup") { document.getElementById("menu_showAllTabs").hidden = !gTabsPanel.canOpen; } break; case "mouseover": this._mouseOverFocus(event.currentTarget); break; case "command": this.pick(event.currentTarget); break; case "click": if (event.button == 1) { this.remove(event.currentTarget); } else if (AppConstants.platform == "macosx" && event.button == 2) { // Control+click is a right click on macOS, but in this case we want // to handle it like a left click. this.pick(event.currentTarget); } break; } }, filterForThumbnailExpiration(aCallback) { // Save a few more thumbnails than we actually display, so that when tabs // are closed, the previews we add instead still get thumbnails. const extraThumbnails = 3; const thumbnailCount = Math.min( this.tabPreviewCount + extraThumbnails, this.tabCount ); let urls = []; for (let i = 0; i < thumbnailCount; i++) { urls.push(this.tabList[i].linkedBrowser.currentURI.spec); } aCallback(urls); }, _sortRecentlyUsedTabs() { this._recentlyUsedTabs.sort( (tab1, tab2) => tab2.lastAccessed - tab1.lastAccessed ); }, _initRecentlyUsedTabs() { this._recentlyUsedTabs = Array.prototype.filter.call( gBrowser.tabs, tab => !tab.closing && !tab.hidden ); this._sortRecentlyUsedTabs(); }, _init: function ctrlTab__init(enable) { var toggleEventListener = enable ? "addEventListener" : "removeEventListener"; window[toggleEventListener]("SSWindowRestored", this); var tabContainer = gBrowser.tabContainer; tabContainer[toggleEventListener]("TabOpen", this); tabContainer[toggleEventListener]("TabAttrModified", this); tabContainer[toggleEventListener]("TabSelect", this); tabContainer[toggleEventListener]("TabClose", this); tabContainer[toggleEventListener]("TabHide", this); tabContainer[toggleEventListener]("TabShow", this); if (enable) { Services.els.addSystemEventListener(document, "keydown", this, false); } else { Services.els.removeSystemEventListener(document, "keydown", this, false); } document[toggleEventListener]("keypress", this); gBrowser.tabbox.handleCtrlTab = !enable; if (enable) { PageThumbs.addExpirationFilter(this); } else { PageThumbs.removeExpirationFilter(this); } // If we're not running, hide the "Show All Tabs" menu item, // as Shift+Ctrl+Tab will be handled by the tab bar. document.getElementById("menu_showAllTabs").hidden = !enable; document .getElementById("menu_viewPopup") [toggleEventListener]("popupshowing", this); }, };