diff options
Diffstat (limited to '')
-rw-r--r-- | browser/base/content/browser-ctrlTab.js | 810 |
1 files changed, 810 insertions, 0 deletions
diff --git a/browser/base/content/browser-ctrlTab.js b/browser/base/content/browser-ctrlTab.js new file mode 100644 index 0000000000..31617f13a3 --- /dev/null +++ b/browser/base/content/browser-ctrlTab.js @@ -0,0 +1,810 @@ +/* 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<Image|null>} + */ + 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<HTMLCanvasElement|Image|null>} 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. <img src="moz-page-thumb://thumbnails/?url=foo.com&revision=bar"> + * @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<HTMLCanvasElement|null>} 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); + }, +}; |