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/. */
5 // This file is loaded into the browser window scope.
6 /* eslint-env mozilla/browser-window */
9 * Tab previews utility, produces thumbnails
13 let { PageThumbUtils } = ChromeUtils.importESModule(
14 "resource://gre/modules/PageThumbUtils.sys.mjs"
16 let [width, height] = PageThumbUtils.getThumbnailSize(window);
17 delete this.aspectRatio;
18 return (this.aspectRatio = height / width);
22 * Get the stored thumbnail URL for a given page URL and wait up to 1s for it
23 * to load. If the browser is discarded and there is no stored thumbnail, the
24 * image URL will fail to load and this method will return null after 1s.
25 * Callers should handle this case by doing nothing or using a fallback image.
26 * @param {String} uri The page URL.
27 * @returns {Promise<Image|null>}
29 loadImage: async function tabPreviews_loadImage(uri) {
30 let img = new Image();
31 img.src = PageThumbs.getThumbnailURL(uri);
32 if (img.complete && img.naturalWidth) {
35 return new Promise(resolve => {
36 const controller = new AbortController();
40 clearTimeout(timeout);
44 { signal: controller.signal }
46 const timeout = setTimeout(() => {
54 * For a given tab, retrieve a preview thumbnail (a canvas or an image) from
55 * storage or capture a new one. If the tab's URL has changed since the
56 * previous call, the thumbnail will be regenerated.
57 * @param {MozTabbrowserTab} aTab The tab to get a preview for.
58 * @returns {Promise<HTMLCanvasElement|Image|null>} Resolves to...
59 * @resolves {HTMLCanvasElement} If a thumbnail can NOT be captured and stored
60 * for the tab, or if the tab is still loading, a snapshot is taken and
61 * returned as a canvas. It may be cached as a canvas (separately from
62 * thumbnail storage) in aTab.__thumbnail if the tab is finished loading. If
63 * the snapshot CAN be stored as a thumbnail, the snapshot is converted to a
64 * blob image and drawn in the returned canvas, but the image is added to
65 * thumbnail storage and cached in aTab.__thumbnail.
66 * @resolves {Image} A cached blob image from a previous thumbnail capture.
67 * e.g. <img src="moz-page-thumb://thumbnails/?url=foo.com&revision=bar">
68 * @resolves {null} If a thumbnail cannot be captured for any reason (e.g.
69 * because the tab is discarded) and there is no cached/stored thumbnail.
71 get: async function tabPreviews_get(aTab) {
72 let browser = aTab.linkedBrowser;
73 let uri = browser.currentURI.spec;
75 // Invalidate the cached thumbnail since the tab has changed.
76 if (aTab.__thumbnail_lastURI && aTab.__thumbnail_lastURI != uri) {
77 aTab.__thumbnail = null;
78 aTab.__thumbnail_lastURI = null;
81 // A cached thumbnail (not from thumbnail storage) is available.
82 if (aTab.__thumbnail) {
83 return aTab.__thumbnail;
86 // This means the browser is discarded. Try to load a stored thumbnail, and
87 // use a fallback style otherwise.
88 if (!browser.browsingContext) {
89 return this.loadImage(uri);
92 // Don't cache or store the thumbnail if the tab is still loading.
93 return this.capture(aTab, !aTab.hasAttribute("busy"));
97 * For a given tab, capture a preview thumbnail (a canvas), optionally cache
98 * it in aTab.__thumbnail, and possibly store it in thumbnail storage.
99 * @param {MozTabbrowserTab} aTab The tab to capture a preview for.
100 * @param {Boolean} aShouldCache Cache/store the captured thumbnail?
101 * @returns {Promise<HTMLCanvasElement|null>} Resolves to...
102 * @resolves {HTMLCanvasElement} A snapshot of the tab's content. If the
103 * snapshot is safe for storage and aShouldCache is true, the snapshot is
104 * converted to a blob image, stored and cached, and drawn in the returned
105 * canvas. The thumbnail can then be recovered even if the browser is
106 * discarded. Otherwise, the canvas itself is cached in aTab.__thumbnail.
107 * @resolves {null} If a fatal exception occurred during thumbnail capture.
109 capture: async function tabPreviews_capture(aTab, aShouldCache) {
110 let browser = aTab.linkedBrowser;
111 let uri = browser.currentURI.spec;
112 let canvas = PageThumbs.createCanvas(window);
113 const doStore = await PageThumbs.shouldStoreThumbnail(browser);
115 if (doStore && aShouldCache) {
116 await PageThumbs.captureAndStore(browser);
117 let img = await this.loadImage(uri);
119 // Cache the stored blob image for future use.
120 aTab.__thumbnail = img;
121 aTab.__thumbnail_lastURI = uri;
122 // Draw the stored blob image in the canvas.
123 canvas.getContext("2d").drawImage(img, 0, 0);
129 await PageThumbs.captureToCanvas(browser, canvas);
131 // Cache the canvas itself for future use.
132 aTab.__thumbnail = canvas;
133 aTab.__thumbnail_lastURI = uri;
136 console.error(error);
145 var tabPreviewPanelHelper = {
147 host.panel.hidden = false;
149 var handler = this._generateHandler(host);
150 host.panel.addEventListener("popupshown", handler);
151 host.panel.addEventListener("popuphiding", handler);
153 host._prevFocus = document.commandDispatcher.focusedElement;
155 _generateHandler(host) {
157 return function listener(event) {
158 if (event.target == host.panel) {
159 host.panel.removeEventListener(event.type, listener);
160 self["_" + event.type](host);
165 if ("setupGUI" in host) {
170 if ("suspendGUI" in host) {
174 if (host._prevFocus) {
175 Services.focus.setFocus(
177 Ci.nsIFocusManager.FLAG_NOSCROLL
179 host._prevFocus = null;
181 gBrowser.selectedBrowser.focus();
184 if (host.tabToSelect) {
185 gBrowser.selectedTab = host.tabToSelect;
186 host.tabToSelect = null;
198 return (this.panel = document.getElementById("ctrlTab-panel"));
200 get showAllButton() {
201 delete this.showAllButton;
202 this.showAllButton = document.createXULElement("button");
203 this.showAllButton.id = "ctrlTab-showAll";
204 this.showAllButton.addEventListener("mouseover", this);
205 this.showAllButton.addEventListener("command", this);
206 this.showAllButton.addEventListener("click", this);
208 .getElementById("ctrlTab-showAll-container")
209 .appendChild(this.showAllButton);
210 return this.showAllButton;
213 delete this.previews;
215 let previewsContainer = document.getElementById("ctrlTab-previews");
216 for (let i = 0; i < this.maxTabPreviews; i++) {
217 let preview = this._makePreview();
218 previewsContainer.appendChild(preview);
219 this.previews.push(preview);
221 this.previews.push(this.showAllButton);
222 return this.previews;
226 ["close", "find", "selectAll"].forEach(function (key) {
228 .getElementById("key_" + key)
234 return (this.keys = keys);
238 return this._selectedIndex < 0
239 ? document.activeElement
240 : this.previews[this._selectedIndex];
244 this.panel.state == "open" || this.panel.state == "showing" || this._timer
248 return this.tabList.length;
250 get tabPreviewCount() {
251 return Math.min(this.maxTabPreviews, this.tabCount);
255 return this._recentlyUsedTabs;
258 init: function ctrlTab_init() {
259 if (!this._recentlyUsedTabs) {
260 this._initRecentlyUsedTabs();
265 uninit: function ctrlTab_uninit() {
266 if (this._recentlyUsedTabs) {
267 this._recentlyUsedTabs = null;
272 prefName: "browser.ctrlTab.sortByRecentlyUsed",
273 readPref: function ctrlTab_readPref() {
275 Services.prefs.getBoolPref(this.prefName) &&
276 !Services.prefs.getBoolPref(
277 "browser.ctrlTab.disallowForScreenReaders",
292 let preview = document.createXULElement("button");
293 preview.className = "ctrlTab-preview";
294 preview.setAttribute("pack", "center");
295 preview.setAttribute("flex", "1");
296 preview.addEventListener("mouseover", this);
297 preview.addEventListener("command", this);
298 preview.addEventListener("click", this);
300 let previewInner = document.createXULElement("vbox");
301 previewInner.className = "ctrlTab-preview-inner";
302 preview.appendChild(previewInner);
304 let canvas = (preview._canvas = document.createXULElement("hbox"));
305 canvas.className = "ctrlTab-canvas";
306 previewInner.appendChild(canvas);
308 let faviconContainer = document.createXULElement("hbox");
309 faviconContainer.className = "ctrlTab-favicon-container";
310 previewInner.appendChild(faviconContainer);
312 let favicon = (preview._favicon = document.createXULElement("image"));
313 favicon.className = "ctrlTab-favicon";
314 faviconContainer.appendChild(favicon);
316 let label = (preview._label = document.createXULElement("label"));
317 label.className = "ctrlTab-label plain";
318 label.setAttribute("crop", "end");
319 previewInner.appendChild(label);
324 updatePreviews: function ctrlTab_updatePreviews() {
325 for (let i = 0; i < this.previews.length; i++) {
326 this.updatePreview(this.previews[i], this.tabList[i]);
329 document.l10n.setAttributes(
331 "tabbrowser-ctrl-tab-list-all-tabs",
332 { tabCount: this.tabCount }
334 this.showAllButton.hidden = !gTabsPanel.canOpen;
337 updatePreview: function ctrlTab_updatePreview(aPreview, aTab) {
338 if (aPreview == this.showAllButton) {
342 aPreview._tab = aTab;
345 let canvas = aPreview._canvas;
346 let canvasWidth = this.canvasWidth;
347 let canvasHeight = this.canvasHeight;
348 canvas.setAttribute("width", canvasWidth);
349 canvas.style.minWidth = canvasWidth + "px";
350 canvas.style.maxWidth = canvasWidth + "px";
351 canvas.style.minHeight = canvasHeight + "px";
352 canvas.style.maxHeight = canvasHeight + "px";
356 switch (aPreview._tab) {
358 this._clearCanvas(canvas);
360 canvas.appendChild(img);
364 // The preview panel is not open, so don't render anything.
365 this._clearCanvas(canvas);
367 // If the tab exists but it has changed since updatePreview was
368 // called, the preview will likely be handled by a later
369 // updatePreview call, e.g. on TabAttrModified.
372 .catch(error => console.error(error));
374 aPreview._label.setAttribute("value", aTab.label);
375 aPreview.setAttribute("tooltiptext", aTab.label);
377 aPreview._favicon.setAttribute("src", aTab.image);
379 aPreview._favicon.removeAttribute("src");
381 aPreview.hidden = false;
383 this._clearCanvas(aPreview._canvas);
384 aPreview.hidden = true;
385 aPreview._label.removeAttribute("value");
386 aPreview.removeAttribute("tooltiptext");
387 aPreview._favicon.removeAttribute("src");
391 // Remove previous preview images from the canvas box.
392 _clearCanvas(canvas) {
393 while (canvas.firstElementChild) {
394 canvas.firstElementChild.remove();
398 advanceFocus: function ctrlTab_advanceFocus(aForward) {
399 let selectedIndex = this.previews.indexOf(this.selected);
401 selectedIndex += aForward ? 1 : -1;
402 if (selectedIndex < 0) {
403 selectedIndex = this.previews.length - 1;
404 } else if (selectedIndex >= this.previews.length) {
407 } while (this.previews[selectedIndex].hidden);
409 if (this._selectedIndex == -1) {
410 // Focus is already in the panel.
411 this.previews[selectedIndex].focus();
413 this._selectedIndex = selectedIndex;
416 if (this.previews[selectedIndex]._tab) {
417 gBrowser.warmupTab(this.previews[selectedIndex]._tab);
421 clearTimeout(this._timer);
427 _mouseOverFocus: function ctrlTab_mouseOverFocus(aPreview) {
428 if (this._trackMouseOver) {
433 pick: function ctrlTab_pick(aPreview) {
434 if (!this.tabCount) {
438 var select = aPreview || this.selected;
440 if (select == this.showAllButton) {
441 this.showAllTabs("ctrltab-all-tabs-button");
443 this.close(select._tab);
447 showAllTabs: function ctrlTab_showAllTabs(aEntrypoint = "unknown") {
449 gTabsPanel.showAllTabsPanel(null, aEntrypoint);
452 remove: function ctrlTab_remove(aPreview) {
454 gBrowser.removeTab(aPreview._tab);
458 attachTab: function ctrlTab_attachTab(aTab, aPos) {
459 // If the tab is hidden, don't add it to the list unless it's selected
460 // (Normally hidden tabs would be unhidden when selected, but that doesn't
461 // happen for Firefox View).
462 if (aTab.closing || (aTab.hidden && !aTab.selected)) {
466 // If the tab is already in the list, remove it before re-inserting it.
467 this.detachTab(aTab);
470 this._recentlyUsedTabs.unshift(aTab);
472 this._recentlyUsedTabs.splice(aPos, 0, aTab);
474 this._recentlyUsedTabs.push(aTab);
478 detachTab: function ctrlTab_detachTab(aTab) {
479 var i = this._recentlyUsedTabs.indexOf(aTab);
481 this._recentlyUsedTabs.splice(i, 1);
485 open: function ctrlTab_open() {
490 this.canvasWidth = Math.ceil(
491 (screen.availWidth * 0.85) / this.maxTabPreviews
493 this.canvasHeight = Math.round(this.canvasWidth * tabPreviews.aspectRatio);
494 this.updatePreviews();
495 this._selectedIndex = 1;
496 gBrowser.warmupTab(this.selected._tab);
498 // Add a slight delay before showing the UI, so that a quick
499 // "ctrl-tab" keypress just flips back to the MRU tab.
500 this._timer = setTimeout(() => {
506 _openPanel: function ctrlTab_openPanel() {
507 tabPreviewPanelHelper.opening(this);
509 let width = Math.min(
510 screen.availWidth * 0.99,
511 this.canvasWidth * 1.25 * this.tabPreviewCount
513 this.panel.style.width = width + "px";
514 var estimateHeight = this.canvasHeight * 1.25 + 75;
515 this.panel.openPopupAtScreen(
516 screen.availLeft + (screen.availWidth - width) / 2,
517 screen.availTop + (screen.availHeight - estimateHeight) / 2,
522 close: function ctrlTab_close(aTabToSelect) {
528 clearTimeout(this._timer);
532 gBrowser.selectedTab = aTabToSelect;
537 this.tabToSelect = aTabToSelect;
538 this.panel.hidePopup();
541 setupGUI: function ctrlTab_setupGUI() {
542 this.selected.focus();
543 this._selectedIndex = -1;
545 // Track mouse movement after a brief delay so that the item that happens
546 // to be under the mouse pointer initially won't be selected unintentionally.
547 this._trackMouseOver = false;
551 self._trackMouseOver = true;
559 suspendGUI: function ctrlTab_suspendGUI() {
560 for (let preview of this.previews) {
561 this.updatePreview(preview, null);
566 let action = ShortcutUtils.getSystemActionForEvent(event);
567 if (action != ShortcutUtils.CYCLE_TABS) {
571 event.preventDefault();
572 event.stopPropagation();
575 this.advanceFocus(!event.shiftKey);
579 if (event.shiftKey) {
580 this.showAllTabs("shift-tab");
584 document.addEventListener("keyup", this, { mozSystemGroup: true });
586 let tabs = gBrowser.visibleTabs;
587 if (tabs.length > 2) {
589 } else if (tabs.length == 2) {
590 let index = tabs[0].selected ? 1 : 0;
591 gBrowser.selectedTab = tabs[index];
596 if (!this.isOpen || !event.ctrlKey) {
600 event.preventDefault();
601 event.stopPropagation();
603 if (event.keyCode == event.DOM_VK_DELETE) {
604 this.remove(this.selected);
608 switch (event.charCode) {
609 case this.keys.close:
610 this.remove(this.selected);
613 this.showAllTabs("ctrltab-key-find");
615 case this.keys.selectAll:
616 this.showAllTabs("ctrltab-key-selectAll");
621 removeClosingTabFromUI: function ctrlTab_removeClosingTabFromUI(aTab) {
622 if (this.tabCount == 2) {
627 this.updatePreviews();
629 if (this.selected.hidden) {
630 this.advanceFocus(false);
632 if (this.selected == this.showAllButton) {
633 this.advanceFocus(false);
636 // If the current tab is removed, another tab can steal our focus.
637 if (aTab.selected && this.panel.state == "open") {
639 function (selected) {
648 handleEvent: function ctrlTab_handleEvent(event) {
649 switch (event.type) {
650 case "SSWindowRestored":
651 this._initRecentlyUsedTabs();
653 case "TabAttrModified":
654 // tab attribute modified (i.e. label, busy, image)
655 // update preview only if tab attribute modified in the list
657 event.detail.changed.some(elem =>
658 ["label", "busy", "image"].includes(elem)
661 for (let i = this.previews.length - 1; i >= 0; i--) {
663 this.previews[i]._tab &&
664 this.previews[i]._tab == event.target
666 this.updatePreview(this.previews[i], event.target);
673 this.attachTab(event.target, 0);
674 // If the previous tab was hidden (e.g. Firefox View), remove it from
675 // the list when it's deselected.
676 let previousTab = event.detail.previousTab;
677 if (previousTab.hidden) {
678 this.detachTab(previousTab);
682 this.attachTab(event.target, 1);
685 this.detachTab(event.target);
687 this.removeClosingTabFromUI(event.target);
691 this.detachTab(event.target);
694 this.attachTab(event.target);
695 this._sortRecentlyUsedTabs();
698 this.onKeyDown(event);
701 this.onKeyPress(event);
704 // During cycling tabs, we avoid sending keyup event to content document.
705 event.preventDefault();
706 event.stopPropagation();
708 if (event.keyCode === event.DOM_VK_CONTROL) {
709 document.removeEventListener("keyup", this, { mozSystemGroup: true });
717 if (event.target.id == "menu_viewPopup") {
718 document.getElementById("menu_showAllTabs").hidden =
723 this._mouseOverFocus(event.currentTarget);
726 this.pick(event.currentTarget);
729 if (event.button == 1) {
730 this.remove(event.currentTarget);
731 } else if (AppConstants.platform == "macosx" && event.button == 2) {
732 // Control+click is a right click on macOS, but in this case we want
733 // to handle it like a left click.
734 this.pick(event.currentTarget);
740 filterForThumbnailExpiration(aCallback) {
741 // Save a few more thumbnails than we actually display, so that when tabs
742 // are closed, the previews we add instead still get thumbnails.
743 const extraThumbnails = 3;
744 const thumbnailCount = Math.min(
745 this.tabPreviewCount + extraThumbnails,
750 for (let i = 0; i < thumbnailCount; i++) {
751 urls.push(this.tabList[i].linkedBrowser.currentURI.spec);
756 _sortRecentlyUsedTabs() {
757 this._recentlyUsedTabs.sort(
758 (tab1, tab2) => tab2.lastAccessed - tab1.lastAccessed
761 _initRecentlyUsedTabs() {
762 this._recentlyUsedTabs = Array.prototype.filter.call(
764 tab => !tab.closing && !tab.hidden
766 this._sortRecentlyUsedTabs();
769 _init: function ctrlTab__init(enable) {
770 var toggleEventListener = enable
772 : "removeEventListener";
774 window[toggleEventListener]("SSWindowRestored", this);
776 var tabContainer = gBrowser.tabContainer;
777 tabContainer[toggleEventListener]("TabOpen", this);
778 tabContainer[toggleEventListener]("TabAttrModified", this);
779 tabContainer[toggleEventListener]("TabSelect", this);
780 tabContainer[toggleEventListener]("TabClose", this);
781 tabContainer[toggleEventListener]("TabHide", this);
782 tabContainer[toggleEventListener]("TabShow", this);
785 document.addEventListener("keydown", this, { mozSystemGroup: true });
787 document.removeEventListener("keydown", this, { mozSystemGroup: true });
789 document[toggleEventListener]("keypress", this);
790 gBrowser.tabbox.handleCtrlTab = !enable;
793 PageThumbs.addExpirationFilter(this);
795 PageThumbs.removeExpirationFilter(this);
798 // If we're not running, hide the "Show All Tabs" menu item,
799 // as Shift+Ctrl+Tab will be handled by the tab bar.
800 document.getElementById("menu_showAllTabs").hidden = !enable;
802 .getElementById("menu_viewPopup")
803 [toggleEventListener]("popupshowing", this);