Last active
March 10, 2023 17:23
-
-
Save bigshans/68a16a439a575901a78b20b8ad9a15fa to your computer and use it in GitHub Desktop.
Vertical tab pane for firefox, look like edge
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Vertical Tabs Pane | |
// @version 1.6.9 | |
// @author aminomancer | |
// @homepage https://github.com/aminomancer/uc.css.js | |
// @description Create a vertical pane across from the sidebar that functions | |
// like the vertical tab pane in Microsoft Edge. It doesn't hide the tab bar | |
// since people have different preferences on how to do that, but it sets an | |
// attribute on the root element that you can use to hide the regular tab bar | |
// while the vertical pane is open, for example :root[vertical-tabs] | |
// #TabsToolbar... By default, the pane is resizable just like the sidebar is. | |
// And like the pane in Edge, you can press a button to collapse it, and it will | |
// hide the tab labels and become a thin strip that just shows the tabs' | |
// favicons. Hovering the collapsed pane will expand it without moving the | |
// browser content. As with the [vertical-tabs] attribute, this "unpinned" state | |
// is reflected on the root element, so you can select it like | |
// :root[vertical-tabs-unpinned]... Like the sidebar, the state of the pane is | |
// stored between windows and recorded in preferences. There's no need to edit | |
// these preferences directly. There are a few other preferences that can be | |
// edited in about:config, but they can all be changed on the fly by opening the | |
// context menu within the pane. The new tab button and the individual tabs all | |
// have their own context menus, but right-clicking anything else will open the | |
// pane's context menu, which has options for changing these preferences. "Move | |
// Pane to Right/Left" will change which side the pane (and by extension, the | |
// sidebar) is displayed on, relative to the browser content. Since the pane | |
// always mirrors the position of the sidebar, moving the pane to the right will | |
// move the sidebar to the left, and vice versa. "Reverse Tab Order" changes the | |
// direction of the pane so that newer tabs are displayed on top rather than on | |
// bottom. "Expand Pane on Hover/Focus" causes the pane to expand on hover when | |
// it's collapsed. When you collapse the pane with the unpin button, it | |
// collapses to a small width and then temporarily expands if you hover it, | |
// after a delay of 100ms. Then when your mouse leaves the pane, it collapses | |
// again, after a delay of 100ms. Both of these delays can be changed with the | |
// "Configure Hover Delay" and "Configure Hover Out Delay" options in the | |
// context menu, or in about:config. For languages other than English, the | |
// labels and tooltips can be modified directly in the l10n object below. | |
// @license This Source Code Form is subject to the terms of the Creative Commons Attribution-NonCommercial-ShareAlike International License, v. 4.0. If a copy of the CC BY-NC-SA 4.0 was not distributed with this file, You can obtain one at http://creativecommons.org/licenses/by-nc-sa/4.0/ or send a letter to Creative Commons, PO Box 1866, Mountain View, CA 94042, USA. | |
// ==/UserScript== | |
(function () { | |
let _windows = { | |
get: function (onlyBrowsers = true) { | |
let windows = Services.wm.getEnumerator(onlyBrowsers ? 'navigator:browser' : null); | |
let wins = []; | |
while (windows.hasMoreElements()) { | |
wins.push(windows.getNext()); | |
} | |
return wins | |
}, | |
forEach: function (fun, onlyBrowsers = true) { | |
let wins = this.get(onlyBrowsers); | |
wins.forEach((w) => (fun(w.document, w))) | |
} | |
}; | |
var _ucUtils = { | |
createElement: function (doc, tag, props, isHTML = false) { | |
let el = isHTML ? doc.createElement(tag) : doc.createXULElement(tag); | |
for (let prop in props) { | |
el.setAttribute(prop, props[prop]) | |
} | |
return el | |
}, | |
registerHotkey: function (desc, func) { | |
const validMods = ["accel", "alt", "ctrl", "meta", "shift"]; | |
const validKey = (k) => ((/^[\w-]$/).test(k) ? 1 : (/^F(?:1[0,2]|[1-9])$/).test(k) ? 2 : 0); | |
const NOK = (a) => (typeof a != "string"); | |
const eToO = (e) => ({ | |
"metaKey": e.metaKey, | |
"ctrlKey": e.ctrlKey, | |
"altKey": e.altKey, | |
"shiftKey": e.shiftKey, | |
"key": e.srcElement.getAttribute("key"), | |
"id": e.srcElement.getAttribute("id") | |
}); | |
if (NOK(desc.id) || NOK(desc.key) || NOK(desc.modifiers)) { | |
return false | |
} | |
try { | |
let mods = desc.modifiers.toLowerCase().split(" ").filter((a) => (validMods.includes(a))); | |
let key = validKey(desc.key); | |
if (!key || (mods.length === 0 && key === 1)) { | |
return false | |
} | |
_windows.forEach((doc, win) => { | |
if (doc.getElementById(desc.id)) { | |
return | |
} | |
let details = { | |
"id": desc.id, | |
"modifiers": mods.join(",").replace("ctrl", "accel"), | |
"oncommand": "//" | |
}; | |
if (key === 1) { | |
details.key = desc.key.toUpperCase(); | |
} else { | |
details.keycode = `VK_${desc.key}`; | |
} | |
let el = _ucUtils.createElement(doc, "key", details); | |
el.addEventListener("command", (ev) => { | |
func(ev.target.ownerGlobal, eToO(ev)) | |
}); | |
let keyset = doc.getElementById("mainKeyset") || doc.body.appendChild(_ucUtils.createElement(doc, "keyset", { | |
id: "ucKeys" | |
})); | |
keyset.insertBefore(el, keyset.firstChild); | |
}); | |
} catch (e) { | |
console.error(e); | |
return false | |
} | |
return true | |
}, | |
} | |
let config = { | |
// localization strings. change these if your UI is not in english. | |
l10n: { | |
"Button label": "垂直标签栏", | |
"Button tooltip": "显示/隐藏垂直标签栏", | |
"Collapse button tooltip": "自动缩小标签栏", | |
"Pin button tooltip": "固定标签栏尺寸", | |
// labels for the context menu | |
context: { | |
"Move Pane to Right": "移动到右边", | |
"Move Pane to Left": "移动到左边", | |
"Hidden Tabbar": "隐藏标签栏", | |
"Expand Pane": "标签栏自动变窄", | |
"Reverse Tab Order": "翻转标签顺序", | |
"Configure Hover Delay": "设置显示完整标签栏延迟", | |
"Configure Hover Out Delay": "设置标签栏自动变窄延迟", | |
}, | |
// strings for the hover delay config prompt | |
prompt: { | |
"Hover delay title": "显示完整标签栏延迟 (单位毫秒)", | |
"Hover delay description": "鼠标放在迷你标签栏上多久后显示完整标签栏?", | |
"Hover out delay title": "标签栏自动缩小延迟 (单位毫秒)", | |
"Hover out delay description": "鼠标离开标签栏多久后自动缩小?", | |
"Invalid": "输入的数值有误!", | |
"Invalid description": "只能输入正数", | |
}, | |
}, | |
// settings for the hotkey | |
hotkey: { | |
// set to false if you don't want any hotkey | |
enabled: true, | |
// valid modifiers are "alt", "shift", "ctrl", "meta" and "accel". accel | |
// is equal to ctrl on windows and linux, but meta (cmd ⌘) on macOS. meta | |
// is the windows key on windows. it's variable on linux. | |
modifiers: "accel", | |
// the actual key. valid keys are letters, the hyphen key - and F1-F12. | |
// digits and F13-F24 are not supported by firefox.F | |
key: "F1", | |
}, | |
}; | |
if (location.href !== "chrome://browser/content/browser.xhtml") return; | |
const prefSvc = Services.prefs; | |
const closedPref = "userChrome.tabs.verticalTabsPane.closed"; | |
const unpinnedPref = "userChrome.tabs.verticalTabsPane.unpinned"; | |
const noExpandPref = "userChrome.tabs.verticalTabsPane.no-expand-on-hover"; | |
const widthPref = "userChrome.tabs.verticalTabsPane.width"; | |
const reversePref = "userChrome.tabs.verticalTabsPane.reverse-order"; | |
const hoverDelayPref = "userChrome.tabs.verticalTabsPane.hover-delay"; | |
const hoverOutDelayPref = "userChrome.tabs.verticalTabsPane.hover-out-delay"; | |
const hiddenTabBarPref = "userChrome.tabs.verticalTabsPane.hidden-tabbar"; | |
const userContextPref = "privacy.userContext.enabled"; | |
const containerOnClickPref = "privacy.userContext.newTabContainerOnLeftClick.enabled"; | |
// all of these events will be listened for on the pane itself | |
const paneEvents = ["mouseenter", "mouseleave", "focus"]; | |
// these events target the arrowscrollbox (the container for tab items) | |
const dragEvents = ["dragstart", "dragleave", "dragover", "drop", "dragend"]; | |
// these events target the vanilla tab bar, gBrowser.tabContainer | |
const tabEvents = [ | |
"TabAttrModified", | |
"TabClose", | |
"TabMove", | |
"TabHide", | |
"TabShow", | |
"TabPinned", | |
"TabUnpinned", | |
"TabSelect", | |
"TabBrowserDiscarded", | |
]; | |
/** | |
* create a DOM node with given parameters | |
* @param {object} aDoc (which document to create the element in) | |
* @param {string} tag (an HTML tag name, like "button" or "p") | |
* @param {object} props (an object containing attribute name/value pairs, | |
* e.g. class: ".bookmark-item") | |
* @param {boolean} isHTML (if true, create an HTML element. if omitted or | |
* false, create a XUL element. generally avoid HTML | |
* when modding the UI, most UI elements are actually | |
* XUL elements.) | |
* @returns the created DOM node | |
*/ | |
function create(aDoc, tag, props, isHTML = false) { | |
let el = isHTML ? aDoc.createElement(tag) : aDoc.createXULElement(tag); | |
for (let prop in props) { | |
el.setAttribute(prop, props[prop]); | |
} | |
return el; | |
} | |
/** | |
* set or remove multiple attributes for a given node | |
* @param {object} el (a DOM node) | |
* @param {object} attrs (an object of attribute name/value pairs) | |
* @returns the DOM node | |
*/ | |
function setAttributes(el, attrs) { | |
for (let [name, value] of Object.entries(attrs)) { | |
if (value) el.setAttribute(name, value); | |
else el.removeAttribute(name); | |
} | |
} | |
class VerticalTabsPaneBase { | |
preferences = [ | |
{ name: closedPref, value: false }, | |
{ name: unpinnedPref, value: false }, | |
{ name: noExpandPref, value: false }, | |
{ name: widthPref, value: 350 }, | |
{ name: reversePref, value: false }, | |
{ name: hoverDelayPref, value: 100 }, | |
{ name: hoverOutDelayPref, value: 100 }, | |
{ name: hiddenTabBarPref, value: true }, | |
]; | |
constructor() { | |
this._registerSheet(); | |
// ensure E10SUtils are available. required for showing tab's process ID | |
// in its tooltip, if the pref for that is enabled. | |
XPCOMUtils.defineLazyModuleGetters(this, { | |
E10SUtils: "resource://gre/modules/E10SUtils.jsm", | |
}); | |
// get some localized strings for the tooltip | |
XPCOMUtils.defineLazyGetter(this, "_l10n", function () { | |
return new Localization(["browser/browser.ftl"], true); | |
}); | |
this._formatFluentStrings(); | |
Services.obs.addObserver(this, "vertical-tabs-pane-toggle"); | |
// build the DOM | |
this.pane = document.getElementById("vertical-tabs-pane"); | |
this._splitter = document.getElementById("vertical-tabs-splitter"); | |
this._contextMenu = document.getElementById("mainPopupSet").appendChild( | |
create(document, "menupopup", { | |
id: "vertical-tabs-context-menu", | |
}) | |
); | |
this._innerBox = this.pane.appendChild( | |
create(document, "vbox", { id: "vertical-tabs-inner-box" }) | |
); | |
this._buttonsRow = this._innerBox.appendChild( | |
create(document, "hbox", { | |
id: "vertical-tabs-buttons-row", | |
}) | |
); | |
this._contextMenu.menuitemPosition = this._contextMenu.appendChild( | |
create(document, "menuitem", { | |
id: "vertical-tabs-context-position", | |
label: config.l10n.context["Move Pane to Right"], | |
oncommand: `Services.prefs.setBoolPref(SidebarUI.POSITION_START_PREF, true);`, | |
}) | |
); | |
this._contextMenu.menuitemExpand = this._contextMenu.appendChild( | |
create(document, "menuitem", { | |
id: "vertical-tabs-context-expand", | |
label: config.l10n.context["Expand Pane"], | |
type: "checkbox", | |
oncommand: `Services.prefs.setBoolPref("userChrome.tabs.verticalTabsPane.no-expand-on-hover", !this.getAttribute("checked"));`, | |
}) | |
); | |
this._contextMenu.menuitemReverse = this._contextMenu.appendChild( | |
create(document, "menuitem", { | |
id: "vertical-tabs-context-reverse", | |
label: config.l10n.context["Reverse Tab Order"], | |
type: "checkbox", | |
oncommand: `Services.prefs.setBoolPref("userChrome.tabs.verticalTabsPane.reverse-order", this.getAttribute("checked"));`, | |
}) | |
); | |
this._contextMenu.menuitemHoverDelay = this._contextMenu.appendChild( | |
create(document, "menuitem", { | |
id: "vertical-tabs-context-hover-delay", | |
label: config.l10n.context["Configure Hover Delay"], | |
oncommand: `verticalTabsPane.promptForIntPref("userChrome.tabs.verticalTabsPane.hover-delay")`, | |
}) | |
); | |
this._contextMenu.menuitemHoverOutDelay = this._contextMenu.appendChild( | |
create(document, "menuitem", { | |
id: "vertical-tabs-context-hover-out-delay", | |
label: config.l10n.context["Configure Hover Out Delay"], | |
oncommand: `verticalTabsPane.promptForIntPref("userChrome.tabs.verticalTabsPane.hover-out-delay")`, | |
}) | |
); | |
// tab stops let us focus elements in the tabs pane by hitting tab to | |
// cycle through toolbars, just as in vanilla firefox. | |
this._buttonsRow.appendChild(create(document, "toolbartabstop", { "aria-hidden": true })); | |
this._newTabButton = this._buttonsRow.appendChild( | |
CustomizableUI.getWidget("new-tab-button") | |
.forWindow(window) | |
.node.cloneNode(true) | |
); | |
this._newTabButton.id = "vertical-tabs-new-tab-button"; | |
this._newTabButton.setAttribute("flex", "1"); | |
this._newTabButton.setAttribute("class", "subviewbutton subviewbutton-iconic"); | |
nodeToShortcutMap[this._newTabButton.id] = nodeToShortcutMap["new-tab-button"]; | |
this._pinButton = this._buttonsRow.appendChild( | |
create(document, "toolbarbutton", { | |
id: "vertical-tabs-pin-button", | |
class: "subviewbutton subviewbutton-iconic no-label", | |
tooltiptext: config.l10n["Collapse button tooltip"], | |
}) | |
); | |
this._pinButton.addEventListener("command", e => { | |
this.pane.getAttribute("unpinned") ? this._removeUnpinned() : this.unpin(); | |
this._resetPinnedTooltip(); | |
}); | |
this._closeButton = this._buttonsRow.appendChild( | |
create(document, "toolbarbutton", { | |
id: "vertical-tabs-close-button", | |
class: "subviewbutton subviewbutton-iconic no-label", | |
tooltiptext: config.l10n["Button tooltip"], | |
}) | |
); | |
if ("key_toggleVerticalTabs" in window) { | |
this._closeButton.tooltipText += ` (${ShortcutUtils.prettifyShortcut( | |
window.key_toggleVerticalTabs | |
)})`; | |
} | |
this._closeButton.addEventListener("command", e => this.toggle()); | |
this._innerBox.appendChild(create(document, "toolbarseparator")); | |
this._innerBox.appendChild(create(document, "toolbartabstop", { "aria-hidden": true })); | |
this._arrowscrollbox = this._innerBox.appendChild( | |
create(document, "arrowscrollbox", { | |
id: "vertical-tabs-list", | |
tooltip: "vertical-tabs-tooltip", | |
context: "tabContextMenu", | |
orient: "vertical", | |
flex: "1", | |
}) | |
); | |
this._innerBox.appendChild(create(document, "toolbarseparator")); | |
const newTab = this._innerBox.appendChild( | |
CustomizableUI.getWidget("new-tab-button") | |
.forWindow(window) | |
.node.cloneNode(true) | |
); | |
newTab.id = "vertical-tabs-new-tab-button-plus"; | |
newTab.setAttribute("flex", "1"); | |
newTab.setAttribute("class", "subviewbutton subviewbutton-iconic"); | |
nodeToShortcutMap[newTab.id] = nodeToShortcutMap["new-tab-button"]; | |
this._bottomNewTab = newTab; | |
// build a modified clone of the built-in tabs tooltip for use in the pane. | |
let vanillaTooltip = document.getElementById("tabbrowser-tab-tooltip"); | |
this._tabTooltip = vanillaTooltip.cloneNode(true); | |
vanillaTooltip.after(this._tabTooltip); | |
this._tabTooltip.id = "vertical-tabs-tooltip"; | |
this._tabTooltip.setAttribute("onpopupshowing", `verticalTabsPane.createTabTooltip(event)`); | |
// this is a map of all the rows, and you can get a specific row from it | |
// by passing a tab (like a real <tab> element from the built-in tab bar) | |
this.tabToElement = new Map(); | |
this._listenersRegistered = false; | |
// set up preferences if they don't already exist | |
this.preferences.forEach(pref => { | |
if (!prefSvc.prefHasUserValue(pref.name)) { | |
prefSvc[`set${typeof pref.value === "number" ? "Int" : "Bool"}Pref`]( | |
pref.name, | |
pref.value | |
); | |
} | |
}); | |
prefSvc.addObserver("userChrome.tabs.verticalTabsPane", this); | |
prefSvc.addObserver("privacy.userContext", this); | |
prefSvc.addObserver(SidebarUI.POSITION_START_PREF, this); | |
// re-initialize the sidebar's positionstart pref callback since we | |
// changed it earlier at the bottom to make it also move the pane. | |
XPCOMUtils.defineLazyPreferenceGetter( | |
SidebarUI, | |
"_positionStart", | |
SidebarUI.POSITION_START_PREF, | |
true, | |
SidebarUI.setPosition.bind(SidebarUI) | |
); | |
// destroy the scrollbuttons. | |
["#scrollbutton-up", "#scrollbutton-down"].forEach(id => | |
this._arrowscrollbox.shadowRoot.querySelector(id).remove() | |
); | |
this._l10nIfNeeded(); | |
// the pref observer changes stuff in the script when the pref is changed. | |
// but when the script initially starts, the prefs haven't been changed so | |
// that logic isn't immediately invoked. we have to invoke it manually, as | |
// if the prefs had been changed. | |
let readPref = pref => this.observe(prefSvc, "nsPref:read", pref); | |
readPref(noExpandPref); | |
readPref(hoverDelayPref); | |
readPref(hoverOutDelayPref); | |
readPref(hiddenTabBarPref); | |
if (!this._hoverDelay) this._hoverDelay = 100; | |
if (!this._hoverOutDelay) this._hoverOutDelay = 100; | |
if (!this._hiddenTabBarPref) this._hiddenTabBarPref = true; | |
// we don't want to read some of these prefs until we know whether the | |
// window was opened by another window with a pane, because instead of | |
// reading from prefs we can adopt the pane state from the previous | |
// window. normally in my scripts I update prefs like this every time | |
// they're changed, which would mean, for example, changing the pane's | |
// width in one window would instantly update the pane's width in every | |
// other window. that's not how firefox's built-in sidebar works, though. | |
// when you open a window, the sidebar state is taken from the previous | |
// window. but changing the sidebar in that window won't affect the | |
// sidebar in the previous window. sidebar state isn't permanently stored | |
// anywhere until the last window is closed. (basically, when the app has | |
// been closed) so to keep this consistent with the sidebar we're gonna | |
// use the previous window as the main source of state, and use prefs as a | |
// fallback. the prefs will be set when the last window is closed (see the | |
// uninit function at the bottom) | |
SessionStore.promiseInitialized.then(() => { | |
if (window.closed) return; | |
readPref(reversePref); | |
readPref(userContextPref); | |
readPref(SidebarUI.POSITION_START_PREF); | |
// try to adopt from previous window, otherwise restore from prefs. | |
let sourceWindow = window.opener; | |
if ( | |
sourceWindow && | |
!sourceWindow.closed && | |
sourceWindow.location.protocol == "chrome:" && | |
this._adoptFromWindow(sourceWindow) | |
) { | |
return; | |
} | |
readPref(widthPref); | |
readPref(unpinnedPref); | |
readPref(closedPref); | |
}); | |
} | |
// get the root element, e.g. what you'd select in CSS with :root | |
get _root() { | |
if (!this.__root) this.__root = document.documentElement; | |
return this.__root; | |
} | |
// return all the DOM nodes for tab rows in the pane. | |
get _rows() { | |
return this.tabToElement.values(); | |
} | |
// return the row for the active/selected tab. | |
get _selectedRow() { | |
return this._arrowscrollbox.querySelector(".all-tabs-item[selected]"); | |
} | |
// this creates (and caches) a tree walker. tree walkers are basically | |
// interfaces for finding nodes in order. we get to specify which direction | |
// we're looking in, forward or backward, and we get to specify a filter | |
// function that rules out types of elements. this one accepts tabstops, | |
// buttons, toolbarbuttons, and checkboxes, but rules out disabled or hidden | |
// nodes, and rules out everything else. this is what tells us which element | |
// to focus when pressing the right/left arrow keys. | |
get _horizontalWalker() { | |
if (!this.__horizontalWalker) { | |
this.__horizontalWalker = document.createTreeWalker( | |
this.pane, | |
NodeFilter.SHOW_ELEMENT, | |
node => { | |
if (node.tagName == "toolbartabstop") return NodeFilter.FILTER_ACCEPT; | |
if (node.disabled || node.hidden) return NodeFilter.FILTER_REJECT; | |
if ( | |
node.tagName == "button" || | |
node.tagName == "toolbarbutton" || | |
node.tagName == "checkbox" | |
) { | |
if (!node.hasAttribute("tabindex")) node.setAttribute("tabindex", "-1"); | |
return NodeFilter.FILTER_ACCEPT; | |
} | |
return NodeFilter.FILTER_SKIP; | |
} | |
); | |
} | |
return this.__horizontalWalker; | |
} | |
// this one tells us which element to focus when pressing the up/down arrow | |
// keys. it's just like the other but it skips secondary buttons. (mute and | |
// close buttons) this way we can arrow up/down to navigate through tabs | |
// very quickly, and arrow left/right to focus the mute and close buttons. | |
get _verticalWalker() { | |
if (!this.__verticalWalker) { | |
this.__verticalWalker = document.createTreeWalker( | |
this.pane, | |
NodeFilter.SHOW_ELEMENT, | |
node => { | |
if (node.tagName == "toolbartabstop") return NodeFilter.FILTER_ACCEPT; | |
if (node.disabled || node.hidden) return NodeFilter.FILTER_REJECT; | |
if ( | |
node.tagName == "button" || | |
node.tagName == "toolbarbutton" || | |
node.tagName == "checkbox" | |
) { | |
if (node.classList.contains("all-tabs-secondary-button")) { | |
return NodeFilter.FILTER_SKIP; | |
} | |
if (!node.hasAttribute("tabindex")) node.setAttribute("tabindex", "-1"); | |
return NodeFilter.FILTER_ACCEPT; | |
} | |
return NodeFilter.FILTER_SKIP; | |
} | |
); | |
} | |
return this.__verticalWalker; | |
} | |
// make an array containing all the context menus that can be opened by | |
// right-clicking something inside the pane. | |
get _availContextMenus() { | |
let menus = []; | |
let contextDefs = [...this.pane.querySelectorAll("[context]")]; | |
contextDefs.push(this.pane); | |
contextDefs.forEach(node => { | |
let menu = document.getElementById(node.getAttribute("context")); | |
if (!menus.includes(menu)) menus.push(menu); | |
}); | |
return menus; | |
} | |
// we want to prevent the pane from collapsing when a context menu is opened | |
// from inside it. since document.popupNode was recently removed, we have to | |
// manually locate every context menu, and check if it's open by checking | |
// the triggerNode property. if the triggerNode is inside the pane, we | |
// prevent the pane from collapsing and instead add a popuphidden event | |
// listener, so it instead collapses once the pane has been closed. | |
get _openMenu() { | |
let menus = this._availContextMenus; | |
if (!menus.length) return false; | |
let openMenu = false; | |
menus.forEach(menu => { | |
if (menu.triggerNode && this.pane.contains(menu.triggerNode)) openMenu = menu; | |
}); | |
return openMenu; | |
} | |
// grab the localized strings for the built-in tab sound pseudo-tooltip, | |
// e.g. "PLAYING" or "AUTOPLAY BLOCKED". we lowercase these and append them | |
// to the end of the tooltip title if the sound overlay is hovered. | |
async _formatFluentStrings() { | |
let [playingString, mutedString, blockedString, pipString] = await this._l10n.formatValues([ | |
"browser-tab-audio-playing2", | |
"browser-tab-audio-muted2", | |
"browser-tab-audio-blocked", | |
"browser-tab-audio-pip", | |
]); | |
this._fluentStrings = { | |
playingString, | |
mutedString, | |
blockedString, | |
pipString, | |
}; | |
} | |
/** | |
* this tells us which tabs to not make rows for. in this case we only | |
* exclude hidden tabs. tabs are normally only hidden by certain extensions, | |
* e.g. an addon that makes tab groups. | |
* @param {object} tab (a <tab> element from the vanilla tab bar) | |
* @returns {boolean} false if the tab should be excluded from the pane | |
*/ | |
_filterFn(tab) { | |
return !tab.hidden; | |
} | |
/** | |
* get the initial state for the pane from a previous window. this is what | |
* happens when you open a new window (not the first window of a session) | |
* @param {object} sourceWindow (a window object, the window from which the | |
* new window was opened) | |
* @returns {boolean} true if state was successfully restored from source | |
* window, false if state must be restored from prefs. | |
*/ | |
_adoptFromWindow(sourceWindow) { | |
let sourceUI = sourceWindow.verticalTabsPane; | |
if (!sourceUI || !sourceUI.pane) return false; | |
this.pane.setAttribute( | |
"width", | |
sourceUI.pane.width || sourceUI.pane.getBoundingClientRect().width | |
); | |
let sourcePinned = !!sourceUI.pane.getAttribute("unpinned"); | |
sourcePinned ? this.unpin() : this._removeUnpinned(); | |
sourcePinned | |
? this._root.setAttribute("vertical-tabs-unpinned", true) | |
: this._root.removeAttribute("vertical-tabs-unpinned"); | |
this._resetPinnedTooltip(); | |
sourceUI.pane.hidden ? this.close() : this.open(); | |
return true; | |
} | |
_removeUnpinned() { | |
this.pane.removeAttribute("unpinned"); | |
document.getElementById("PersonalToolbar").removeAttribute("vertical-space"); | |
} | |
/** | |
* for a given descendant of a tab row, return the actual tab row element. | |
* @param {object} el (a DOM node contained within a tab row) | |
* @returns the ancestor tab row | |
*/ | |
_findRow(el) { | |
return el.classList.contains("all-tabs-item") ? el : el.closest(".all-tabs-item"); | |
} | |
// change the pin/unpin button's tooltip so it reflects the current state. | |
// if the pane is pinned, the button should say "Collapse pane" and if it's | |
// unpinned it should say "Pin pane" | |
_resetPinnedTooltip() { | |
let newVal = this.pane.getAttribute("unpinned"); | |
this._pinButton.tooltipText = | |
config.l10n[newVal ? "Pin button tooltip" : "Collapse button tooltip"]; | |
} | |
/** | |
* launch a modal prompt (attached to the window) asking the user to set the | |
* hover/hover out delay. the prompt has an input box containing the current | |
* value. it will accept any positive integer. this is invoked by the | |
* "configure hover delay" context menu items. | |
* @param {string} pref (which pref the prompt should change) | |
* @returns an error prompt if the input is invalid, which returns back to | |
* this input prompt | |
*/ | |
promptForIntPref(pref) { | |
let val, title, text; | |
switch (pref) { | |
case hoverDelayPref: | |
val = this._hoverDelay ?? 100; | |
title = config.l10n.prompt["Hover delay title"]; | |
text = config.l10n.prompt["Hover delay description"]; | |
break; | |
case hoverOutDelayPref: | |
val = this._hoverOutDelay ?? 100; | |
title = config.l10n.prompt["Hover out delay title"]; | |
text = config.l10n.prompt["Hover out delay description"]; | |
break; | |
default: | |
return; | |
} | |
let input = { value: val }; | |
let win = Services.wm.getMostRecentWindow(null); | |
let ok = Services.prompt.prompt(win, title, text, input, null, { value: 0 }); | |
if (!ok) return; | |
let int = parseInt(input.value, 10); | |
let onFail = () => { | |
Services.prompt.alert( | |
win, | |
config.l10n.prompt.Invalid, | |
config.l10n.prompt["Invalid description"] | |
); | |
this.promptForIntPref(pref); | |
}; | |
if (!(int >= 0)) { | |
return onFail(); | |
} | |
try { | |
prefSvc.setIntPref(pref, int); | |
} catch (e) { | |
return onFail(); | |
} | |
} | |
/** | |
* universal event handler — we generally pass the whole class to | |
* addEventListener and let this function decide which callback to invoke. | |
* @param {object} e (an event object) | |
*/ | |
handleEvent(e) { | |
let { tab } = e.target; | |
switch (e.type) { | |
case "mousedown": | |
this._onMouseDown(e, tab); | |
break; | |
case "mouseup": | |
this._onMouseUp(e, tab); | |
break; | |
case "click": | |
this._onClick(e); | |
break; | |
case "command": | |
this._onCommand(e, tab); | |
break; | |
case "mouseover": | |
this._onMouseOver(e, tab); | |
break; | |
case "mouseout": | |
this._onMouseOut(e); | |
break; | |
case "mouseenter": | |
this._onMouseEnter(e); | |
break; | |
case "mouseleave": | |
this._onMouseLeave(e); | |
break; | |
case "deactivate": | |
this._onDeactivate(e); | |
break; | |
case "TabHide": | |
case "TabShow": | |
case "TabPinned": | |
case "TabUnpinned": | |
case "TabAttrModified": | |
case "TabBrowserDiscarded": | |
this._tabAttrModified(e.target); | |
break; | |
case "TabClose": | |
this._tabClose(e.target); | |
break; | |
case "TabMove": | |
this._moveTab(e.target); | |
break; | |
case "dragstart": | |
this._onDragStart(e, tab); | |
break; | |
case "dragleave": | |
this._onDragLeave(e); | |
break; | |
case "dragover": | |
this._onDragOver(e); | |
break; | |
case "dragend": | |
this._onDragEnd(e); | |
break; | |
case "drop": | |
this._onDrop(e); | |
break; | |
case "keydown": | |
this._onKeyDown(e); | |
break; | |
case "focus": | |
this._onFocus(e); | |
break; | |
case "blur": | |
e.currentTarget === this.pane ? this._onPaneBlur(e) : this._onButtonBlur(e); | |
break; | |
case "TabMultiSelect": | |
this._onTabMultiSelect(); | |
break; | |
case "TabSelect": | |
if (this.isOpen) this.tabToElement.get(e.target).scrollIntoView({ block: "nearest" }); | |
break; | |
} | |
} | |
/** | |
* notification observer. used to receive notifications about prefs | |
* changing, or notifications telling us to toggle the pane | |
* @param {object} subject (the subject of the notification) | |
* @param {string} topic (the topic "nsPref:changed" is passed to our | |
* observer when a pref is changed. we use | |
* "vertical-tabs-pane-toggle" to toggle the pane) | |
* @param {string} data (additional data is often passed, e.g. the name of | |
* the preference that changed) | |
*/ | |
observe(subject, topic, data) { | |
switch (topic) { | |
case "vertical-tabs-pane-toggle": | |
if (subject === window) this.toggle(); | |
break; | |
case "nsPref:changed": | |
case "nsPref:read": | |
this._onPrefChanged(subject, data); | |
break; | |
} | |
} | |
/** | |
* for a given preference, get its value, regardless of the preference type. | |
* @param {object} root (an nsIPrefBranch object. reflects the preference | |
* branch we're watching, or just the root) | |
* @param {string} pref (a preference string) | |
* @returns the preference's value | |
*/ | |
_getPref(root, pref) { | |
switch (root.getPrefType(pref)) { | |
case root.PREF_BOOL: | |
return root.getBoolPref(pref); | |
case root.PREF_INT: | |
return root.getIntPref(pref); | |
case root.PREF_STRING: | |
return root.getStringPref(pref); | |
default: | |
return null; | |
} | |
} | |
/** | |
* universal preference observer. called when a preference is changed. | |
* @param {object} sub (an nsIPrefBranch object. reflects the preference | |
* branch we're watching, or just the root) | |
* @param {string} pref (the preference that changed) | |
*/ | |
_onPrefChanged(sub, pref) { | |
let value = this._getPref(sub, pref); | |
switch (pref) { | |
case widthPref: | |
if (value === null) value = 350; | |
this.pane.width = value; | |
break; | |
case closedPref: | |
value ? this.close() : this.open(); | |
break; | |
case unpinnedPref: | |
value ? this.unpin() : this._removeUnpinned(); | |
value | |
? this._root.setAttribute("vertical-tabs-unpinned", true) | |
: this._root.removeAttribute("vertical-tabs-unpinned"); | |
this._resetPinnedTooltip(); | |
break; | |
case noExpandPref: | |
this._noExpand = value; | |
if (value) { | |
this.pane.setAttribute("no-expand", true); | |
this.pane.removeAttribute("expanded"); | |
this._contextMenu.menuitemExpand.removeAttribute("checked"); | |
} else { | |
this.pane.removeAttribute("no-expand"); | |
this._contextMenu.menuitemExpand.setAttribute("checked", true); | |
} | |
break; | |
case reversePref: | |
this._reversed = value; | |
if (this.isOpen) { | |
for (let item of this._rows) item.remove(); | |
this.tabToElement = new Map(); | |
this._populate(); | |
} | |
if (value) this._contextMenu.menuitemReverse.setAttribute("checked", true); | |
else this._contextMenu.menuitemReverse.removeAttribute("checked"); | |
break; | |
case hoverDelayPref: | |
this._hoverDelay = value ?? 100; | |
break; | |
case hoverOutDelayPref: | |
this._hoverOutDelay = value ?? 100; | |
break; | |
case hiddenTabBarPref: | |
this.hiddenTabBar = value !== undefined ? value : true; | |
break; | |
case userContextPref: | |
case containerOnClickPref: | |
this._handlePrivacyChange(); | |
break; | |
case SidebarUI.POSITION_START_PREF: | |
let menuitem = this._contextMenu.menuitemPosition; | |
if (value) { | |
menuitem.label = config.l10n.context["Move Pane to Left"]; | |
menuitem.setAttribute( | |
"oncommand", | |
`Services.prefs.setBoolPref(SidebarUI.POSITION_START_PREF, false);` | |
); | |
} else { | |
menuitem.label = config.l10n.context["Move Pane to Right"]; | |
menuitem.setAttribute( | |
"oncommand", | |
`Services.prefs.setBoolPref(SidebarUI.POSITION_START_PREF, true);` | |
); | |
} | |
break; | |
} | |
} | |
toggle() { | |
this.isOpen ? this.close() : this.open(); | |
} | |
open() { | |
this.pane.hidden = this._splitter.hidden = false; | |
this.pane.setAttribute("checked", true); | |
this.isOpen = true; | |
this._root.setAttribute("vertical-tabs", true); | |
if (this._hiddenTabBarPref) { | |
document.getElementById("titlebar").setAttribute("pane", true); | |
} | |
document.getElementById("PersonalToolbar").setAttribute("vertical-space", true); | |
if (!this._listenersRegistered) this._populate(); | |
} | |
close() { | |
if (this.pane.contains(document.activeElement)) document.activeElement.blur(); | |
this.pane.hidden = this._splitter.hidden = true; | |
this.pane.removeAttribute("checked"); | |
this.isOpen = false; | |
this._root.setAttribute("vertical-tabs", false); | |
document.getElementById("titlebar").removeAttribute("pane"); | |
document.getElementById("PersonalToolbar").removeAttribute("vertical-space"); | |
this._cleanup(); | |
} | |
// set the active tab | |
_selectTab(tab) { | |
if (gBrowser.selectedTab != tab) gBrowser.selectedTab = tab; | |
else gBrowser.tabContainer._handleTabSelect(); | |
} | |
// fill the pane with tab rows | |
_populate() { | |
let fragment = document.createDocumentFragment(); | |
for (let tab of gBrowser.tabs) { | |
if (this._filterFn(tab)) { | |
fragment[this._reversed ? `prepend` : `appendChild`](this._createRow(tab)); | |
} | |
} | |
this._addElement(fragment); | |
this._setupListeners(); | |
for (let row of this._rows) this._setImageAttributes(row, row.tab); | |
this._selectedRow.scrollIntoView({ block: "nearest", behavior: "instant" }); | |
} | |
/** | |
* add an element to the tab container/arrowscrollbox | |
* @param {object} elementOrFragment (a DOM element or document fragment to | |
* add to the container) | |
*/ | |
_addElement(elementOrFragment) { | |
this._arrowscrollbox.insertBefore(elementOrFragment, this.insertBefore); | |
} | |
// invoked when closing the pane. destroy all the rows and clear any | |
// timeouts and flags. | |
_cleanup() { | |
for (let item of this._rows) item.remove(); | |
this.tabToElement = new Map(); | |
this._cleanupListeners(); | |
clearTimeout(this.hoverOutTimer); | |
clearTimeout(this.hoverTimer); | |
this.hoverOutQueued = false; | |
this.hoverQueued = false; | |
this.pane.removeAttribute("expanded"); | |
} | |
// invoked when opening the pane. add all the event listeners. | |
// this way the script is less wasteful when the pane is closed. | |
_setupListeners() { | |
this._listenersRegistered = true; | |
window.addEventListener("deactivate", this); | |
tabEvents.forEach(ev => gBrowser.tabContainer.addEventListener(ev, this)); | |
dragEvents.forEach(ev => this._arrowscrollbox.addEventListener(ev, this)); | |
paneEvents.forEach(ev => this.pane.addEventListener(ev, this)); | |
if (gToolbarKeyNavEnabled) this.pane.addEventListener("keydown", this); | |
this.pane.addEventListener("blur", this, true); | |
gBrowser.addEventListener("TabMultiSelect", this); | |
for (let stop of this.pane.getElementsByTagName("toolbartabstop")) { | |
stop.addEventListener("focus", this); | |
} | |
} | |
// invoked when closing the pane. clear all the aforementioned event listeners. | |
_cleanupListeners() { | |
window.removeEventListener("deactivate", this); | |
tabEvents.forEach(ev => gBrowser.tabContainer.removeEventListener(ev, this)); | |
dragEvents.forEach(ev => this._arrowscrollbox.removeEventListener(ev, this)); | |
paneEvents.forEach(ev => this.pane.removeEventListener(ev, this)); | |
this.pane.removeEventListener("keydown", this); | |
this.pane.removeEventListener("blur", this, true); | |
gBrowser.removeEventListener("TabMultiSelect", this); | |
for (let stop of this.pane.getElementsByTagName("toolbartabstop")) { | |
stop.removeEventListener("focus", this); | |
} | |
this._listenersRegistered = false; | |
} | |
/** | |
* callback when a tab attribute is modified. a response to the | |
* TabAttrModified custom event dispatched by gBrowser. this is what we use | |
* to update most of the tab attributes, like busy, soundplaying, etc. | |
* @param {object} tab (a tab element from the real tab bar) | |
*/ | |
_tabAttrModified(tab) { | |
let item = this.tabToElement.get(tab); | |
if (item) { | |
if (!this._filterFn(tab)) this._removeItem(item, tab); | |
else this._setRowAttributes(item, tab); | |
} else if (this._filterFn(tab)) { | |
this._addTab(tab); | |
} | |
} | |
/** | |
* the key implies that we're moving a tab, but this doesn't tell us where | |
* to move the tab to. in reality, this just removes a tab and adds it back. | |
* it simply gets called when a tab gets moved by other means, so we delete | |
* the row and _addTab places it in the same position as its corresponding | |
* tab. meaning we can't actually move a tab this way, this just helps the | |
* tabs pane mirror the real tab bar. | |
* @param {object} tab (a tab element) | |
*/ | |
_moveTab(tab) { | |
let item = this.tabToElement.get(tab); | |
if (item) { | |
this._removeItem(item, tab); | |
this._addTab(tab); | |
this._selectedRow.scrollIntoView({ block: "nearest", behavior: "instant" }); | |
} | |
} | |
/** | |
* invoked by the above functions. if a tab's attributes change and it's | |
* somehow not in the pane already, add it. this adds a dom node for a given | |
* tab and places it in a position reflecting the tab's real position. | |
* @param {object} newTab (a tab element that's not already in the pane) | |
*/ | |
_addTab(newTab) { | |
if (!this._filterFn(newTab)) return; | |
let newRow = this._createRow(newTab); | |
let nextTab = newTab.nextElementSibling; | |
while (nextTab && !this._filterFn(nextTab)) nextTab = nextTab.nextElementSibling; | |
let nextRow = this.tabToElement.get(nextTab); | |
if (this._reversed) { | |
if (nextRow) nextRow.after(newRow); | |
else this._arrowscrollbox.prepend(newRow); | |
} else if (nextRow) { | |
nextRow.parentNode.insertBefore(newRow, nextRow); | |
} else { | |
this._addElement(newRow); | |
} | |
} | |
/** | |
* invoked when a tab is closed from outside the pane. since the tab no | |
* longer exists, remove it from the pane. | |
* @param {object} tab (a tab element) | |
*/ | |
_tabClose(tab) { | |
let item = this.tabToElement.get(tab); | |
if (item) this._removeItem(item, tab); | |
} | |
/** | |
* remove a tab/item pair from the map, and remove the item from the DOM. | |
* @param {object} item (a row element, e.g. with class all-tabs-item) | |
* @param {object} tab (a corresponding tab element — every all-tabs-item | |
* has a reference to its corresponding tab at item.tab) | |
*/ | |
_removeItem(item, tab) { | |
this.tabToElement.delete(tab); | |
item.remove(); | |
} | |
/** | |
* for a given tab, create a row in the pane's container. | |
* @param {object} tab (a tab element) | |
* @returns a row element | |
*/ | |
_createRow(tab) { | |
let row = create(document, "toolbaritem", { | |
class: "all-tabs-item", | |
draggable: true, | |
}); | |
if (this.className) row.classList.add(this.className); | |
row.tab = tab; | |
row.mOverSecondaryButton = false; | |
row.addEventListener("command", this); | |
row.addEventListener("mousedown", this); | |
row.addEventListener("mouseup", this); | |
row.addEventListener("click", this); | |
row.addEventListener("mouseover", this); | |
row.addEventListener("mouseout", this); | |
this.tabToElement.set(tab, row); | |
// main button | |
row.mainButton = row.appendChild( | |
create(document, "toolbarbutton", { | |
class: "all-tabs-button subviewbutton subviewbutton-iconic", | |
flex: "1", | |
crop: "right", | |
}) | |
); | |
row.mainButton.tab = tab; | |
// audio button | |
row.audioButton = row.appendChild( | |
create(document, "toolbarbutton", { | |
class: "all-tabs-secondary-button subviewbutton subviewbutton-iconic", | |
closemenu: "none", | |
"toggle-mute": "true", | |
}) | |
); | |
row.audioButton.tab = tab; | |
// close button | |
row.closeButton = row.appendChild( | |
create(document, "toolbarbutton", { | |
class: "all-tabs-secondary-button subviewbutton subviewbutton-iconic", | |
"close-button": "true", | |
}) | |
); | |
row.closeButton.tab = tab; | |
// sound overlay — it only shows when the pane is collapsed | |
row.soundOverlay = row.appendChild( | |
create(document, "image", { class: "sound-overlay" }, true) | |
); | |
row.soundOverlay.tab = tab; | |
this._setRowAttributes(row, tab); | |
return row; | |
} | |
/** | |
* for a given row/tab pair, set the row's attributes equal to the tab's. | |
* this gets invoked on various events whereupon we need to update a row. | |
* @param {object} row (a row element) | |
* @param {object} tab (a tab element) | |
*/ | |
_setRowAttributes(row, tab) { | |
// attributes to set on the row | |
setAttributes(row, { | |
selected: tab.selected, | |
pinned: tab.pinned, | |
pending: tab.getAttribute("pending"), | |
multiselected: tab.getAttribute("multiselected"), | |
muted: tab.muted, | |
soundplaying: tab.soundPlaying, | |
"activemedia-blocked": tab.activeMediaBlocked, | |
pictureinpicture: tab.pictureinpicture, | |
notselectedsinceload: tab.getAttribute("notselectedsinceload"), | |
}); | |
// we need to use classes for the usercontext/container, since the | |
// built-in CSS that sets the identity color & icon uses classes, not | |
// attributes. | |
if (tab.userContextId) { | |
let idColor = ContextualIdentityService.getPublicIdentityFromId(tab.userContextId)?.color; | |
row.className = idColor ? `all-tabs-item identity-color-${idColor}` : "all-tabs-item"; | |
row.setAttribute("usercontextid", tab.userContextId); | |
} else { | |
row.className = "all-tabs-item"; | |
row.removeAttribute("usercontextid"); | |
} | |
// set attributes on the main button, in particular the tab title and favicon. | |
let busy = tab.getAttribute("busy"); | |
setAttributes(row.mainButton, { | |
busy, | |
label: tab.label, | |
image: !busy && tab.getAttribute("image"), | |
iconloadingprincipal: tab.getAttribute("iconloadingprincipal"), | |
}); | |
this._setImageAttributes(row, tab); | |
// decide which icon to display for the audio button, or whether it should | |
// be displayed at all. | |
setAttributes(row.audioButton, { | |
muted: tab.muted, | |
soundplaying: tab.soundPlaying, | |
"activemedia-blocked": tab.activeMediaBlocked, | |
pictureinpicture: tab.pictureinpicture, | |
hidden: !(tab.muted || tab.soundPlaying || tab.activeMediaBlocked || tab.pictureinpicture), | |
}); | |
} | |
/** | |
* show a throbber in place of the favicon while a tab is loading. | |
* @param {object} row (a row element) | |
* @param {object} tab (a row element) | |
*/ | |
_setImageAttributes(row, tab) { | |
let image = row.mainButton.icon; | |
if (image) { | |
let busy = tab.getAttribute("busy"); | |
setAttributes(image, { busy, progress: tab.getAttribute("progress") }); | |
if (busy) image.classList.add("tab-throbber-tabslist"); | |
else image.classList.remove("tab-throbber-tabslist"); | |
} | |
} | |
get _mouseTargetRect() { | |
return window.windowUtils?.getBoundsWithoutFlushing(this.pane); | |
} | |
/** | |
* get the previous or next node for a given TreeWalker | |
* @param {object} walker (a TreeWalker object) | |
* @param {boolean} prev (whether to walk backwards or forwards) | |
* @returns the next eligible DOM node to focus | |
*/ | |
getNewFocus(walker, prev) { | |
return prev ? walker.previousNode() : walker.nextNode(); | |
} | |
/** | |
* cycle focus between buttons in the pane | |
* @param {boolean} prev (whether to go backwards or forwards) | |
* @param {boolean} horizontal (whether we navigated with left/right arrow | |
* keys, or up/down arrow keys. determines | |
* whether we skip over mute/close buttons.) | |
*/ | |
navigateButtons(prev, horizontal) { | |
let walker = horizontal ? this._horizontalWalker : this._verticalWalker; | |
let oldFocus = document.activeElement; | |
walker.currentNode = oldFocus; | |
let newFocus = this.getNewFocus(walker, prev); | |
while (newFocus && newFocus.tagName == "toolbartabstop") { | |
newFocus = this.getNewFocus(walker, prev); | |
} | |
if (newFocus) this._focusButton(newFocus); | |
} | |
/** | |
* make a DOM node focusable, focus it, and add a blur listener to it | |
* that'll revert its focusability when we're done focusing it. we have to | |
* do it this way since we don't want ALL the buttons to be focusable with | |
* tabs. it looks like you can focus them with tabs, but really you're just | |
* focusing the tab stops, which are set up to instantly focus the | |
* next/previous element. this way you only need to tab twice to get past | |
* the pane. if every button was tabbable then you'd have to press the tab | |
* key at least twice for every tab you have just to get to the browser | |
* content, perhaps hundreds of times. instead, tab only focuses the top | |
* buttons row and the lower tabs scrollbox. once one of those is focused, | |
* arrow keys cycle between buttons. | |
* @param {object} button (DOM node) | |
*/ | |
_focusButton(button) { | |
button.setAttribute("tabindex", "-1"); | |
button.focus(); | |
button.addEventListener("blur", this); | |
} | |
// event callback when something is focused. prevent the pane from being | |
// collapsed while it's focused. also execute the tab stop behavior if a tab | |
// stop was focused. | |
_onFocus(e) { | |
clearTimeout(this.hoverOutTimer); | |
clearTimeout(this.hoverTimer); | |
this.hoverOutQueued = false; | |
this.hoverQueued = false; | |
if (this.pane.getAttribute("unpinned") && !this._noExpand) { | |
this.pane.setAttribute("expanded", true); | |
} | |
if (e.target.tagName === "toolbartabstop") this._onTabStopFocus(e); | |
} | |
// invoked on a blur event. if the pane is no longer focused or hovered, and | |
// it's unpinned, prepare to collapse it. | |
_onPaneBlur(e) { | |
if (this.pane.matches(":hover, :focus-within")) return; | |
clearTimeout(this.hoverOutTimer); | |
clearTimeout(this.hoverTimer); | |
this.hoverOutQueued = false; | |
this.hoverQueued = false; | |
// if the pane is set to not expand, forget about all this. | |
if (this._noExpand) return this.pane.removeAttribute("expanded"); | |
// if the pane was blurred because a context menu was opened, defer this | |
// behavior until the context menu is hidden. | |
let { _openMenu } = this; | |
if (_openMenu) { | |
_openMenu.addEventListener("popuphidden", e => this._onPaneBlur(e), { | |
once: true, | |
}); | |
return; | |
} | |
this.pane.removeAttribute("expanded"); | |
} | |
// if a button was blurred, make it un-tabbable again. | |
_onButtonBlur(e) { | |
if (document.activeElement == e.target) return; | |
e.target.removeEventListener("blur", this); | |
e.target.removeAttribute("tabindex"); | |
} | |
// this one is pretty complicated. if a tab stop was focused, we need to | |
// pass focus to the next eligible element. the only truly focusable | |
// elements in the pane are tab stops. but the first button after a tab stop | |
// receives focus from the tab stop. then the buttons that come after it can | |
// be focused with arrow keys. but we also need to check if user is tabbing | |
// *out* of the pane, and pass focus to the next eligible button outside of | |
// the pane (probably a button) see browser-toolbarKeyNav.js for more | |
// details on this concept. | |
_onTabStopFocus(e) { | |
let walker = this._horizontalWalker; | |
let oldFocus = e.relatedTarget; | |
let isButton = node => node.tagName == "button" || node.tagName == "toolbarbutton"; | |
if (oldFocus) { | |
this._isFocusMovingBackward = | |
oldFocus.compareDocumentPosition(e.target) & Node.DOCUMENT_POSITION_PRECEDING; | |
if (this._isFocusMovingBackward && oldFocus && isButton(oldFocus)) { | |
document.commandDispatcher.rewindFocus(); | |
return; | |
} | |
} | |
walker.currentNode = e.target; | |
let button = walker.nextNode(); | |
if (!button || !isButton(button)) { | |
if ( | |
oldFocus && | |
this._isFocusMovingBackward && | |
!gNavToolbox.contains(oldFocus) && | |
!this.pane.contains(oldFocus) | |
) { | |
let allStops = [...document.querySelectorAll("toolbartabstop")]; | |
let earlierVisibleStopIndex = allStops.indexOf(e.target) - 1; | |
while (earlierVisibleStopIndex >= 0) { | |
let stop = allStops[earlierVisibleStopIndex]; | |
let stopContainer = this.pane.contains(stop) ? this.pane : stop.closest("toolbar"); | |
if (window.windowUtils?.getBoundsWithoutFlushing(stopContainer).height > 0) break; | |
earlierVisibleStopIndex--; | |
} | |
if (earlierVisibleStopIndex == -1) this._isFocusMovingBackward = false; | |
} | |
if (this._isFocusMovingBackward) document.commandDispatcher.rewindFocus(); | |
else document.commandDispatcher.advanceFocus(); | |
return; | |
} | |
this._focusButton(button); | |
} | |
// when a key is pressed, navigate the focus (or remove it for esc key) | |
_onKeyDown(e) { | |
let accelKey = AppConstants.platform == "macosx" ? e.metaKey : e.ctrlKey; | |
if (e.altKey || e.shiftKey || accelKey) return; | |
switch (e.key) { | |
case "ArrowLeft": | |
this.navigateButtons( | |
!window.RTL_UI, | |
!(this._noExpand && this.pane.getAttribute("unpinned")) | |
); | |
break; | |
case "ArrowRight": | |
// Previous if UI is RTL, next if UI is LTR. | |
this.navigateButtons( | |
window.RTL_UI, | |
!(this._noExpand && this.pane.getAttribute("unpinned")) | |
); | |
break; | |
case "ArrowUp": | |
this.navigateButtons(true); | |
break; | |
case "ArrowDown": | |
this.navigateButtons(false); | |
break; | |
case "Escape": | |
if (this.pane.contains(document.activeElement)) { | |
document.activeElement.blur(); | |
break; | |
} | |
// fall through | |
default: | |
return; | |
} | |
e.preventDefault(); | |
} | |
// when you left-click a tab, the first thing that happens is selection. | |
// this happens on mouse down, not on mouse up. if holding shift key or ctrl | |
// key, perform multiselection operations. otherwise, just select the | |
// clicked tab. | |
_onMouseDown(e, tab) { | |
if (e.button !== 0) return; | |
let accelKey = AppConstants.platform == "macosx" ? e.metaKey : e.ctrlKey; | |
if (e.shiftKey) { | |
const lastSelectedTab = gBrowser.lastMultiSelectedTab; | |
if (!accelKey) { | |
gBrowser.selectedTab = lastSelectedTab; | |
gBrowser.clearMultiSelectedTabs(); | |
} | |
gBrowser.addRangeToMultiSelectedTabs(lastSelectedTab, tab); | |
} else if (accelKey) { | |
if (tab.multiselected) { | |
gBrowser.removeFromMultiSelectedTabs(tab); | |
} else if (tab != gBrowser.selectedTab) { | |
gBrowser.addToMultiSelectedTabs(tab); | |
gBrowser.lastMultiSelectedTab = tab; | |
} | |
} else { | |
if (!tab.selected && tab.multiselected) gBrowser.lockClearMultiSelectionOnce(); | |
if ( | |
!e.shiftKey && | |
!accelKey && | |
!e.target.classList.contains("all-tabs-secondary-button") && | |
tab !== gBrowser.selectedTab | |
) { | |
if (tab.getAttribute("pending") || tab.getAttribute("busy")) tab.noCanvas = true; | |
else delete tab.noCanvas; | |
if (gBrowser.selectedTab != tab) gBrowser.selectedTab = tab; | |
else gBrowser.tabContainer._handleTabSelect(); | |
} | |
} | |
if (e.target.closest(".all-tabs-item")?.mOverSecondaryButton) { | |
e.stopPropagation(); | |
e.preventDefault(); | |
} | |
} | |
// when the mouse is released, clear the multiselection and perform some | |
// drag/drop cleanup. if middle mouse button was clicked, then close the | |
// tab, but first warm up the next tab that will be selected. | |
_onMouseUp(e, tab) { | |
if (e.button === 2) return; | |
if (e.button === 1) { | |
gBrowser.warmupTab(gBrowser._findTabToBlurTo(tab)); | |
gBrowser.removeTab(tab, { | |
animate: true, | |
byMouse: false, | |
}); | |
return; | |
} | |
if ( | |
e.shiftKey || | |
(AppConstants.platform == "macosx" ? e.metaKey : e.ctrlKey) || | |
e.target.classList.contains("all-tabs-secondary-button") | |
) { | |
return; | |
} | |
delete tab.noCanvas; | |
gBrowser.unlockClearMultiSelection(); | |
gBrowser.clearMultiSelectedTabs(); | |
} | |
// when mouse enters the pane, prepare to expand the pane after the | |
// specified delay. | |
_onMouseEnter(e) { | |
clearTimeout(this.hoverOutTimer); | |
this.hoverOutQueued = false; | |
if (!this.pane.getAttribute("unpinned") || this._noExpand) { | |
return this.pane.removeAttribute("expanded"); | |
} | |
if (this.hoverQueued) return; | |
this.hoverQueued = true; | |
this.hoverTimer = setTimeout(() => { | |
this.hoverQueued = false; | |
this.pane.setAttribute("expanded", true); | |
}, this._hoverDelay); | |
} | |
// when mouse leaves the pane, prepare to collapse the pane... | |
_onMouseLeave(e, delay) { | |
clearTimeout(this.hoverTimer); | |
this.hoverQueued = false; | |
if (this.hoverOutQueued) return; | |
this.hoverOutQueued = true; | |
this.hoverOutTimer = setTimeout(() => { | |
this.hoverOutQueued = false; | |
if (this.pane.matches(":hover, :focus-within")) return; | |
if (e.type === "popuphidden" && Services.focus.activeWindow === window) { | |
let rect = this._mouseTargetRect; | |
let { _x, _y } = MousePosTracker; | |
if (_x >= rect.left && _x <= rect.right && _y >= rect.top && _y <= rect.bottom) return; | |
} | |
if (this._noExpand) return this.pane.removeAttribute("expanded"); | |
// again, don't collapse the pane yet if the mouse left because a | |
// context menu was opened on the pane. wait until the context menu is | |
// closed before collapsing the pane. | |
let { _openMenu } = this; | |
if (_openMenu) { | |
_openMenu.addEventListener("popuphidden", e => this._onMouseLeave(e, 0), { | |
once: true, | |
}); | |
return; | |
} | |
this.pane.removeAttribute("expanded"); | |
}, delay ?? this._hoverOutDelay); | |
} | |
_onDeactivate(e) { | |
clearTimeout(this.hoverTimer); | |
clearTimeout(this.hoverOutTimer); | |
this.hoverQueued = false; | |
this.hoverOutQueued = false; | |
this.pane.removeAttribute("expanded"); | |
} | |
unpin() { | |
this.pane.style.setProperty("--pane-width", this.pane.width + "px"); | |
this.pane.style.setProperty( | |
"--pane-transition-duration", | |
(Math.sqrt(this.pane.width / 350) * 0).toFixed(2) + "s" | |
); | |
if (this.pane.matches(":hover, :focus-within") && !this._noExpand) { | |
this.pane.setAttribute("expanded", true); | |
} | |
this.pane.setAttribute("unpinned", true); | |
document.getElementById("PersonalToolbar").setAttribute("vertical-space", true); | |
} | |
// "click" events work kind of like "mouseup" events, but in this case we're | |
// only using this to prevent the click event yielding a command event. | |
_onClick(e) { | |
if (e.button === 0) { | |
if ( | |
e.target.classList.contains("all-tabs-secondary-button") && | |
!e.shiftKey && | |
!(AppConstants.platform == "macosx" ? e.metaKey : e.ctrlKey) | |
) { | |
return; | |
} | |
e.preventDefault(); | |
} | |
} | |
// "command" events happen on click or on spacebar/enter. we want the | |
// buttons to be keyboard accessible too. so this is how the mute button and | |
// close button work, and ultimately how you select a tab with the keyboard. | |
_onCommand(e, tab) { | |
if (e.target.hasAttribute("toggle-mute")) { | |
tab.multiselected | |
? gBrowser.toggleMuteAudioOnMultiSelectedTabs(tab) | |
: tab.toggleMuteAudio(); | |
return; | |
} | |
if (e.target.hasAttribute("close-button")) { | |
if (tab.multiselected) gBrowser.removeMultiSelectedTabs(); | |
else gBrowser.removeTab(tab, { animate: true }); | |
return; | |
} | |
if (!gSharedTabWarning.willShowSharedTabWarning(tab)) { | |
if (tab !== gBrowser.selectedTab) this._selectTab(tab); | |
} | |
delete tab.noCanvas; | |
} | |
// invoked on "dragstart" event. first figure out what we're dragging and | |
// set a drag image. | |
_onDragStart(e, tab) { | |
let row = e.target; | |
if (!tab || gBrowser.tabContainer._isCustomizing) return; | |
let selectedTabs = gBrowser.selectedTabs; | |
let otherSelectedTabs = selectedTabs.filter(selectedTab => selectedTab != tab); | |
let dataTransferOrderedTabs = [tab].concat(otherSelectedTabs); | |
let dt = e.dataTransfer; | |
for (let i = 0; i < dataTransferOrderedTabs.length; i++) { | |
let dtTab = dataTransferOrderedTabs[i]; | |
dt.mozSetDataAt("all-tabs-item", dtTab, i); | |
} | |
dt.mozCursor = "default"; | |
dt.addElement(row); | |
// if multiselected tabs aren't adjacent, make them adjacent | |
if (tab.multiselected) { | |
let newIndex = (aTab, index) => { | |
if (aTab.pinned) return Math.min(index, gBrowser._numPinnedTabs - 1); | |
return Math.max(index, gBrowser._numPinnedTabs); | |
}; | |
let tabIndex = selectedTabs.indexOf(tab); | |
let draggedTabPos = tab._tPos; | |
// tabs to the left of the dragged tab | |
let insertAtPos = draggedTabPos - 1; | |
for (let i = tabIndex - 1; i > -1; i--) { | |
insertAtPos = newIndex(selectedTabs[i], insertAtPos); | |
if (insertAtPos && !selectedTabs[i].nextElementSibling.multiselected) { | |
gBrowser.moveTabTo(selectedTabs[i], insertAtPos); | |
} | |
} | |
// tabs to the right | |
insertAtPos = draggedTabPos + 1; | |
for (let i = tabIndex + 1; i < selectedTabs.length; i++) { | |
insertAtPos = newIndex(selectedTabs[i], insertAtPos); | |
if (insertAtPos && !selectedTabs[i].previousElementSibling.multiselected) { | |
gBrowser.moveTabTo(selectedTabs[i], insertAtPos); | |
} | |
} | |
} | |
// tab preview | |
if (!tab.noCanvas && (AppConstants.platform == "win" || AppConstants.platform == "macosx")) { | |
delete tab.noCanvas; | |
let scale = window.devicePixelRatio; | |
let canvas = this._dndCanvas; | |
if (!canvas) { | |
this._dndCanvas = canvas = document.createElementNS( | |
"http://www.w3.org/1999/xhtml", | |
"canvas" | |
); | |
canvas.style.width = "100%"; | |
canvas.style.height = "100%"; | |
canvas.mozOpaque = true; | |
} | |
canvas.width = 160 * scale; | |
canvas.height = 90 * scale; | |
let toDrag = canvas; | |
let dragImageOffset = -16; | |
let browser = tab.linkedBrowser; | |
if (gMultiProcessBrowser) { | |
let context = canvas.getContext("2d"); | |
context.fillStyle = getComputedStyle(this.pane).getPropertyValue("background-color"); | |
context.fillRect(0, 0, canvas.width, canvas.height); | |
let captureListener = () => dt.updateDragImage(canvas, dragImageOffset, dragImageOffset); | |
PageThumbs.captureToCanvas(browser, canvas).then(captureListener); | |
} else { | |
PageThumbs.captureToCanvas(browser, canvas); | |
dragImageOffset = dragImageOffset * scale; | |
} | |
dt.setDragImage(toDrag, dragImageOffset, dragImageOffset); | |
} | |
tab._dragData = { | |
movingTabs: (tab.multiselected ? gBrowser.selectedTabs : [tab]).filter(this._filterFn), | |
}; | |
e.stopPropagation(); | |
} | |
// invoked when we drag over an element inside the pane. decide whether to | |
// show the drag-over styling on a row, and whether to show the drag | |
// indicator above or below the row. | |
_onDragOver(e) { | |
let row = this._findRow(e.target); | |
let dt = e.dataTransfer; | |
// scroll when dragging near the ends of the scrollbox | |
let pixelsToScroll = 0; | |
let rect = this._arrowscrollbox.getBoundingClientRect(); | |
if (row) { | |
let targetRect = row.getBoundingClientRect(); | |
let increment = (targetRect.height || this._arrowscrollbox.scrollIncrement) * 3; | |
if (e.clientY - rect.top < targetRect.height) pixelsToScroll = increment * -1; | |
else if (rect.bottom - e.clientY < targetRect.height) pixelsToScroll = increment; | |
if (pixelsToScroll) this._arrowscrollbox.scrollByPixels(pixelsToScroll, false); | |
} | |
this._arrowscrollbox | |
.querySelectorAll("[dragpos]") | |
.forEach(item => item.removeAttribute("dragpos")); | |
if (!dt.types.includes("all-tabs-item") || !row || row.tab.multiselected) { | |
dt.mozCursor = "auto"; | |
return; | |
} | |
dt.mozCursor = "default"; | |
let draggedTab = dt.mozGetDataAt("all-tabs-item", 0); | |
if (row.tab === draggedTab) return; | |
if (row.tab.pinned !== draggedTab.pinned) return; | |
// whether a tab will be placed before or after the drop target depends on | |
// 1) whether the drop target is above or below the dragged tab, and 2) | |
// whether the order of the tab list is reversed. | |
let getPosition = () => { | |
return this._reversed ? row.tab._tPos < draggedTab._tPos : row.tab._tPos > draggedTab._tPos; | |
}; | |
let position = getPosition() ? "after" : "before"; | |
row.setAttribute("dragpos", position); | |
e.preventDefault(); | |
} | |
// invoked when we drag over an element then leave it. clean up the dragpos | |
// attribute. we actually do this for every row (wasteful, I know) since | |
// these events are dispatched too slowly. I guess it's a firefox bug, idk. | |
_onDragLeave(e) { | |
let row = this._findRow(e.target); | |
let dt = e.dataTransfer; | |
dt.mozCursor = "auto"; | |
if (!dt.types.includes("all-tabs-item") || !row) return; | |
this._arrowscrollbox | |
.querySelectorAll("[dragpos]") | |
.forEach(item => item.removeAttribute("dragpos")); | |
} | |
// invoked when we finally release the dragged tab(s). figure out where to | |
// move the tab to, move it, do some cleanup. | |
_onDrop(e) { | |
let row = this._findRow(e.target); | |
let dt = e.dataTransfer; | |
let tabBar = gBrowser.tabContainer; | |
if (!dt.types.includes("all-tabs-item") || !row) return; | |
let draggedTab = dt.mozGetDataAt("all-tabs-item", 0); | |
let movingTabs = draggedTab._dragData.movingTabs; | |
if (!movingTabs || dt.mozUserCancelled || dt.dropEffect === "none" || tabBar._isCustomizing) { | |
delete draggedTab._dragData; | |
return; | |
} | |
tabBar._finishGroupSelectedTabs(draggedTab); | |
if (draggedTab) { | |
let newIndex = row.tab._tPos; | |
const dir = newIndex < movingTabs[0]._tPos; | |
movingTabs.forEach(tab => { | |
if (tab.pinned !== row.tab.pinned) return; | |
gBrowser.moveTabTo( | |
dt.dropEffect == "copy" ? gBrowser.duplicateTab(tab) : tab, | |
dir ? newIndex++ : newIndex | |
); | |
}); | |
} | |
row.removeAttribute("dragpos"); | |
e.stopPropagation(); | |
} | |
// invoked when dragging ends, whether by dropping or by exiting. just | |
// cleans up after the other drag event handlers. | |
_onDragEnd(e) { | |
let draggedTab = e.dataTransfer.mozGetDataAt("all-tabs-item", 0); | |
delete draggedTab._dragData; | |
delete draggedTab.noCanvas; | |
for (let row of this._rows) row.removeAttribute("dragpos"); | |
} | |
// callback function for the TabMultiSelect custom event. this event doesn't | |
// get dispatched to a specific tab, because multiple tabs can be | |
// multiselected by the same operation. so we can't use its target to | |
// specify which row's attributes to change. we therefore have to update the | |
// "multiselected" attribute for every row. | |
_onTabMultiSelect() { | |
for (let item of this._rows) { | |
item.tab.multiselected | |
? item.setAttribute("multiselected", true) | |
: item.removeAttribute("multiselected"); | |
} | |
} | |
// invoked when mousing over a row. we use this to set a flag | |
// mOverSecondaryButton on the row, which our drag handlers reference. we | |
// want to speculatively warm up a tab when the user hovers it since it's | |
// possible they will click it. there's a cache for this with a maximum | |
// limit, so if the user mouses over 3 tabs without clicking them, then a | |
// 4th, it will clear the 1st to make room. this is the same thing the | |
// built-in tab bar does so we're just mimicking vanilla behavior here. this | |
// can be disabled with browser.tabs.remote.warmup.enabled | |
_onMouseOver(e, tab) { | |
let row = this._findRow(e.target); | |
SessionStore.speculativeConnectOnTabHover(tab); | |
if (e.target.classList.contains("all-tabs-secondary-button")) { | |
row.mOverSecondaryButton = true; | |
} | |
if (e.target.hasAttribute("close-button")) { | |
tab = gBrowser._findTabToBlurTo(tab); | |
} | |
gBrowser.warmupTab(tab); | |
} | |
// invoked when mousing out of an element. | |
_onMouseOut(e) { | |
let row = e.target.closest(".all-tabs-item"); | |
if (e.target.classList.contains("all-tabs-secondary-button")) { | |
row.mOverSecondaryButton = false; | |
} | |
} | |
// generate tooltip labels and decide where to anchor the tooltip. invoked | |
// when the vertical-tabs-tooltip is about to be shown. | |
createTabTooltip(e) { | |
e.stopPropagation(); | |
let row = e.target.triggerNode ? this._findRow(e.target.triggerNode) : null; | |
if (!row) return e.preventDefault(); | |
let { tab } = row; | |
if (!tab) return e.preventDefault(); | |
// get a localized string, replace any plural variables with the passed | |
// number, and add a shortcut string (e.g. Ctrl+M) matching the passed key | |
// element ID. | |
let stringWithShortcut = (stringId, keyElemId, pluralCount) => { | |
let keyElem = document.getElementById(keyElemId); | |
let shortcut = ShortcutUtils.prettifyShortcut(keyElem); | |
return PluralForm.get(pluralCount, gTabBrowserBundle.GetStringFromName(stringId)) | |
.replace("%S", shortcut) | |
.replace("#1", pluralCount); | |
}; | |
let label; | |
// should we align to the tab or to the mouse? depends on which element | |
// was hovered. | |
let align = true; | |
let { linkedBrowser } = tab; | |
const selectedTabs = gBrowser.selectedTabs; | |
const contextTabInSelection = selectedTabs.includes(tab); | |
const affectedTabsLength = contextTabInSelection ? selectedTabs.length : 1; | |
// a bunch of localization | |
if (row.closeButton.matches(":hover")) { | |
let shortcut = ShortcutUtils.prettifyShortcut(window.key_close); | |
label = PluralForm.get( | |
affectedTabsLength, | |
gTabBrowserBundle.GetStringFromName("tabs.closeTabs.tooltip") | |
).replace("#1", affectedTabsLength); | |
if (contextTabInSelection && shortcut) { | |
if (label.includes("%S")) label = label.replace("%S", shortcut); | |
else label = label + " (" + shortcut + ")"; | |
} | |
align = false; | |
} else if (row.audioButton.matches(":hover")) { | |
let stringID; | |
if (contextTabInSelection) { | |
stringID = linkedBrowser.audioMuted | |
? "tabs.unmuteAudio2.tooltip" | |
: "tabs.muteAudio2.tooltip"; | |
label = stringWithShortcut(stringID, "key_toggleMute", affectedTabsLength); | |
} else { | |
if (tab.hasAttribute("activemedia-blocked")) { | |
stringID = "tabs.unblockAudio2.tooltip"; | |
} else { | |
stringID = linkedBrowser.audioMuted | |
? "tabs.unmuteAudio2.background.tooltip" | |
: "tabs.muteAudio2.background.tooltip"; | |
} | |
label = PluralForm.get( | |
affectedTabsLength, | |
gTabBrowserBundle.GetStringFromName(stringID) | |
).replace("#1", affectedTabsLength); | |
} | |
align = false; | |
} else { | |
label = tab._fullLabel || tab.getAttribute("label"); | |
// show the tab's process ID in the tooltip? | |
if (prefSvc.getBoolPref("browser.tabs.tooltipsShowPidAndActiveness", false)) { | |
if (linkedBrowser) { | |
let [contentPid, ...framePids] = this.E10SUtils.getBrowserPids( | |
linkedBrowser, | |
gFissionBrowser | |
); | |
if (contentPid) { | |
if (framePids && framePids.length) { | |
label += ` (pids ${contentPid}, ${framePids.sort().join(", ")})`; | |
} else { | |
label += ` (pid ${contentPid})`; | |
} | |
} | |
if (linkedBrowser.docShellIsActive) label += " [A]"; | |
} | |
} | |
// add the container name to the tooltip? | |
if (tab.userContextId) { | |
label = gTabBrowserBundle.formatStringFromName("tabs.containers.tooltip", [ | |
label, | |
ContextualIdentityService.getUserContextLabel(tab.userContextId), | |
]); | |
} | |
// if hovering the sound overlay, show the current media state of the | |
// tab, after the tab title. "playing" or "muted" or "media blocked" | |
if (row.soundOverlay.matches(":hover") && this._fluentStrings) { | |
let stateString; | |
if (tab.hasAttribute("activemedia-blocked")) stateString = "blockedString"; | |
else if (linkedBrowser.audioMuted) stateString = "mutedString"; | |
else stateString = "playingString"; | |
label += ` (${this._fluentStrings[stateString].toLowerCase()})`; | |
} | |
} | |
// align to the row | |
if (align) { | |
e.target.setAttribute("position", "after_start"); | |
e.target.moveToAnchor(row, "after_start"); | |
} | |
let title = e.target.querySelector(".places-tooltip-title"); | |
title.textContent = label; | |
if (tab.getAttribute("customizemode") === "true") { | |
e.target.querySelector(".places-tooltip-box").setAttribute("desc-hidden", "true"); | |
return; | |
} | |
let url = e.target.querySelector(".places-tooltip-uri"); | |
url.value = linkedBrowser?.currentURI?.spec.replace(/^https:\/\//, ""); | |
e.target.querySelector(".places-tooltip-box").removeAttribute("desc-hidden"); | |
// show a lock icon to show tab security/encryption | |
let icon = e.target.querySelector("#places-tooltip-insecure-icon"); | |
let pending = tab.hasAttribute("pending") || !linkedBrowser.browsingContext; | |
let docURI = pending | |
? linkedBrowser?.currentURI | |
: linkedBrowser?.documentURI || linkedBrowser?.currentURI; | |
if (docURI) { | |
let homePage = new RegExp(`(${BROWSER_NEW_TAB_URL}|${HomePage.get(window)})`, "i").test( | |
docURI.spec | |
); | |
if (homePage) { | |
icon.setAttribute("type", "home-page"); | |
icon.hidden = false; | |
return; | |
} | |
switch (docURI.scheme) { | |
case "file": | |
case "resource": | |
case "chrome": | |
icon.setAttribute("type", "local-page"); | |
icon.hidden = false; | |
return; | |
case "about": | |
let pathQueryRef = docURI?.pathQueryRef; | |
if (pathQueryRef && /^(neterror|certerror|httpsonlyerror)/.test(pathQueryRef)) { | |
icon.setAttribute("type", "error-page"); | |
icon.hidden = false; | |
return; | |
} | |
if (docURI.filePath == "blocked") { | |
icon.setAttribute("type", "blocked-page"); | |
icon.hidden = false; | |
return; | |
} | |
icon.setAttribute("type", "about-page"); | |
icon.hidden = false; | |
return; | |
case "moz-extension": | |
icon.setAttribute("type", "extension-page"); | |
icon.hidden = false; | |
return; | |
} | |
} | |
if (linkedBrowser.browsingContext) { | |
let prog = Ci.nsIWebProgressListener; | |
let state = linkedBrowser?.securityUI?.state; | |
if (typeof state != "number" || state & prog.STATE_IS_SECURE) { | |
icon.hidden = true; | |
icon.setAttribute("type", "secure"); | |
return; | |
} | |
if (state & prog.STATE_IS_INSECURE) { | |
icon.setAttribute("type", "insecure"); | |
icon.hidden = false; | |
return; | |
} | |
if (state & prog.STATE_IS_BROKEN) { | |
if (state & prog.STATE_LOADED_MIXED_ACTIVE_CONTENT) { | |
icon.hidden = false; | |
icon.setAttribute("type", "insecure"); | |
} else { | |
icon.setAttribute("type", "mixed-passive"); | |
icon.hidden = false; | |
} | |
return; | |
} | |
} | |
icon.hidden = true; | |
icon.setAttribute("type", pending ? "pending" : "secure"); | |
} | |
// container tab settings affect what we need to show in the "New Tab" | |
// button's tooltip and context menu. so we need to observe this preference | |
// and respond accordingly. | |
_handlePrivacyChange() { | |
let containersEnabled = | |
prefSvc.getBoolPref(userContextPref) && !PrivateBrowsingUtils.isWindowPrivate(window); | |
const newTabLeftClickOpensContainersMenu = prefSvc.getBoolPref(containerOnClickPref); | |
const switchPrivacyForNewTab = (parent) => { | |
parent.removeAttribute("type"); | |
if (parent.menupopup) parent.menupopup.remove(); | |
if (containersEnabled) { | |
parent.setAttribute("context", "new-tab-button-popup"); | |
let popup = document.getElementById("new-tab-button-popup").cloneNode(true); | |
popup.removeAttribute("id"); | |
popup.className = "new-tab-popup"; | |
popup.setAttribute("position", "after_end"); | |
parent.prepend(popup); | |
parent.setAttribute("type", "menu"); | |
nodeToTooltipMap[parent.id] = newTabLeftClickOpensContainersMenu | |
? "newTabAlwaysContainer.tooltip" | |
: "newTabContainer.tooltip"; | |
} else { | |
nodeToTooltipMap[parent.id] = "newTabButton.tooltip"; | |
parent.removeAttribute("context", "new-tab-button-popup"); | |
} | |
gDynamicTooltipCache.delete(parent.id); | |
if (containersEnabled && !newTabLeftClickOpensContainersMenu) { | |
gClickAndHoldListenersOnElement.add(parent); | |
} else { | |
gClickAndHoldListenersOnElement.remove(parent); | |
} | |
} | |
switchPrivacyForNewTab(this._newTabButton); | |
switchPrivacyForNewTab(this._bottomNewTab); | |
} | |
// load our stylesheet as an author sheet. override it with userChrome.css | |
// and !important rules. | |
_registerSheet() { | |
let css = /* css */ ` | |
#PersonalToolbar[vertical-space] { | |
/* | |
* margin-left: calc( 20px + 4px * 2 + var(--arrowpanel-menuitem-padding-inline) * 2 ); | |
*/ | |
} | |
#titlebar[pane] { | |
display: none; | |
} | |
#vertical-tabs-pane { | |
--vertical-tabs-padding: 4px; | |
--collapsed-pane-width: calc( | |
24px + var(--vertical-tabs-padding) * 2 + var(--arrowpanel-menuitem-padding-inline) * 2 | |
); | |
background-color: var(--vertical-tabs-pane-background, var(--lwt-accent-color)); | |
padding: var(--vertical-tabs-padding); | |
border-color: var(--sidebar-border-color); | |
border-block-style: none; | |
border-inline-style: solid; | |
border-inline-width: 1px 0; | |
z-index: 2; | |
height: 100% !important; | |
} | |
#vertical-tabs-pane[fullscreen] { | |
display: none !important; | |
} | |
#vertical-tabs-pane[unpinned] { | |
/* | |
margin-top: -32px; | |
height: calc(100% + 32px) !important; | |
*/ | |
} | |
#vertical-tabs-pane[positionstart] { | |
border-inline-width: 0 1px; | |
} | |
#vertical-tabs-pane:not([unpinned]) { | |
min-width: 160px; | |
max-width: 50vw; | |
} | |
#vertical-tabs-pane:not([hidden]) { | |
min-height: 0; | |
display: flex; | |
} | |
#vertical-tabs-pane[unpinned]:not([hidden]) { | |
position: relative; | |
z-index: 1; | |
margin-inline: 0; | |
max-width: var(--collapsed-pane-width); | |
min-width: var(--collapsed-pane-width); | |
width: var(--collapsed-pane-width); | |
height: 0; | |
transition-property: min-width, max-width, margin; | |
transition-timing-function: ease-in-out, ease-in-out, ease-in-out; | |
transition-duration: var(--pane-transition-duration), var(--pane-transition-duration), var(--pane-transition-duration); | |
} | |
#vertical-tabs-pane[unpinned]:not([positionstart="true"]) { | |
left: auto; | |
right: 0; | |
margin-inline: 0; | |
order: 6; | |
} | |
#vertical-tabs-pane[unpinned][expanded] { | |
min-width: var(--pane-width, 350px); | |
width: var(--pane-width, 350px); | |
max-width: var(--pane-width, 350px); | |
margin-inline: 0 calc(var(--collapsed-pane-width) - var(--pane-width, 350px)); | |
} | |
#vertical-tabs-pane[unpinned][expanded]:not([positionstart="true"]) { | |
margin-inline: calc(var(--collapsed-pane-width) - var(--pane-width, 350px)) 0; | |
} | |
#vertical-tabs-pane[no-expand] { | |
transition: none !important; | |
} | |
#vertical-tabs-splitter { | |
border: none; | |
} | |
#vertical-tabs-pane[unpinned] ~ #vertical-tabs-splitter { | |
display: none; | |
} | |
#vertical-tabs-inner-box { | |
overflow: hidden; | |
width: -moz-available; | |
min-width: calc(16px + var(--arrowpanel-menuitem-padding-inline) * 2); | |
height: min-content; | |
max-height: 100%; | |
} | |
#vertical-tabs-buttons-row { | |
min-width: 0 !important; | |
} | |
#vertical-tabs-pane[no-expand][unpinned] #vertical-tabs-buttons-row { | |
-moz-box-orient: vertical; | |
flex-direction: column; | |
} | |
#vertical-tabs-buttons-row > toolbarbutton { | |
margin: 0 !important; | |
} | |
#vertical-tabs-pane[unpinned]:not([expanded]) #vertical-tabs-buttons-row > toolbarbutton { | |
min-width: calc(16px + var(--arrowpanel-menuitem-padding-inline) * 2) !important; | |
} | |
/* tabs */ | |
#vertical-tabs-list .all-tabs-item { | |
border-radius: var(--arrowpanel-menuitem-border-radius); | |
box-shadow: none; | |
-moz-box-align: center; | |
padding-inline-end: 2px; | |
margin: 0; | |
overflow: clip; | |
position: relative; | |
} | |
#vertical-tabs-pane[unpinned]:not([expanded]) #vertical-tabs-list .all-tabs-item { | |
padding-inline-end: 0; | |
} | |
#vertical-tabs-list .all-tabs-item .all-tabs-button:not([disabled], [open]):focus { | |
background: none; | |
} | |
#vertical-tabs-list | |
.all-tabs-item:is([selected], [multiselected], [usercontextid]:is(:hover, [_moz-menuactive])) | |
.all-tabs-button:not([disabled]) { | |
background-image: linear-gradient( | |
to right, | |
var(--main-stripe-color) 0, | |
var(--main-stripe-color) 4px, | |
transparent 4px | |
) !important; | |
} | |
#vertical-tabs-list .all-tabs-item[selected] { | |
font-weight: normal; | |
background-color: #F9FBFC !important; | |
box-shadow: rgba(0, 0, 0, 0.35) 0px 5px 15px; | |
} | |
#vertical-tabs-list .all-tabs-item .all-tabs-button { | |
min-height: revert; | |
} | |
#vertical-tabs-list .all-tabs-item[usercontextid]:not([multiselected]) { | |
--main-stripe-color: var(--identity-tab-color); | |
} | |
#vertical-tabs-list .all-tabs-item[multiselected] { | |
--main-stripe-color: var(--multiselected-color, var(--toolbarbutton-icon-fill-attention)); | |
} | |
#vertical-tabs-list | |
.all-tabs-item:not([selected]):is(:hover, :focus-within, [_moz-menuactive], [multiselected]) { | |
background-color: var(--arrowpanel-dimmed) !important; | |
} | |
#vertical-tabs-list .all-tabs-item[multiselected]:not([selected]):is(:hover, [_moz-menuactive]) { | |
background-color: var(--arrowpanel-dimmed-further) !important; | |
} | |
#vertical-tabs-list | |
.all-tabs-item[pending]:not([selected]):is(:hover, :focus-within, [_moz-menuactive], [multiselected]) { | |
background-color: var( | |
--arrowpanel-faint, | |
color-mix(in srgb, var(--arrowpanel-dimmed) 60%, transparent) | |
) !important; | |
} | |
#vertical-tabs-list .all-tabs-item[pending][multiselected]:not([selected]):is(:hover, [_moz-menuactive]) { | |
background-color: var(--arrowpanel-dimmed) !important; | |
} | |
#vertical-tabs-list .all-tabs-item[pending] > .all-tabs-button { | |
opacity: 0.6; | |
} | |
:root[italic-unread-tabs] .all-tabs-item[notselectedsinceload]:not([pending]) > .all-tabs-button, | |
:root[italic-unread-tabs] .all-tabs-item[notselectedsinceload][pending] > .all-tabs-button[busy] { | |
font-style: italic; | |
} | |
/* secondary buttons inside a tab row */ | |
#vertical-tabs-list .all-tabs-item .all-tabs-secondary-button { | |
width: 18px; | |
height: 18px; | |
border-radius: var(--tab-button-border-radius, 2px); | |
color: inherit; | |
background-color: transparent !important; | |
opacity: 0.7; | |
min-height: revert; | |
min-width: revert; | |
padding: 0; | |
} | |
#vertical-tabs-list .all-tabs-item .all-tabs-secondary-button > .toolbarbutton-icon { | |
min-width: 18px; | |
min-height: 18px; | |
fill: inherit; | |
fill-opacity: inherit; | |
-moz-context-properties: inherit; | |
} | |
#vertical-tabs-list .all-tabs-item .all-tabs-secondary-button > label:empty { | |
display: none; | |
} | |
#vertical-tabs-list .all-tabs-item .all-tabs-secondary-button:is(:hover, :focus):not([disabled]), | |
#vertical-tabs-list | |
.all-tabs-item:is(:hover, :focus-within) | |
.all-tabs-secondary-button[close-button]:is(:hover, :focus):not([disabled]) { | |
background-color: var(--arrowpanel-dimmed) !important; | |
opacity: 1; | |
color: inherit; | |
} | |
#vertical-tabs-list .all-tabs-item .all-tabs-secondary-button:hover:active:not([disabled]), | |
#vertical-tabs-list | |
.all-tabs-item:is(:hover, :focus-within) | |
.all-tabs-secondary-button[close-button]:hover:active:not([disabled]) { | |
background-color: var(--arrowpanel-dimmed-further) !important; | |
} | |
/* audio button */ | |
#vertical-tabs-list .all-tabs-item .all-tabs-secondary-button[toggle-mute] { | |
list-style-image: none !important; | |
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 18 18"><path fill-opacity="context-fill-opacity" fill="context-fill" d="M3.52,5.367c-1.332,0-2.422,1.09-2.422,2.422v2.422c0,1.332,1.09,2.422,2.422,2.422h1.516l4.102,3.633 V1.735L5.035,5.367H3.52z M12.059,9c0-0.727-0.484-1.211-1.211-1.211v2.422C11.574,10.211,12.059,9.727,12.059,9z M14.48,9 c0-1.695-1.211-3.148-2.785-3.512l-0.363,1.09C12.422,6.82,13.27,7.789,13.27,9c0,1.211-0.848,2.18-1.938,2.422l0.484,1.09 C13.27,12.148,14.48,10.695,14.48,9z M12.543,3.188l-0.484,1.09C14.238,4.883,15.691,6.82,15.691,9c0,2.18-1.453,4.117-3.512,4.601 l0.484,1.09c2.422-0.605,4.238-2.906,4.238-5.691C16.902,6.215,15.086,3.914,12.543,3.188z"/></svg>') !important; | |
background-size: 14px !important; | |
background-repeat: no-repeat !important; | |
background-position: center !important; | |
padding: 0 !important; | |
margin-inline-end: 8.5px; | |
margin-inline-start: -27px; | |
transition: 0.25s cubic-bezier(0.07, 0.78, 0.21, 0.95) transform, | |
0.2s cubic-bezier(0.07, 0.74, 0.24, 0.95) margin, 0.075s linear opacity; | |
display: block !important; | |
} | |
#vertical-tabs-list .all-tabs-item .all-tabs-secondary-button[toggle-mute][hidden] { | |
transform: translateX(14px); | |
opacity: 0; | |
} | |
#vertical-tabs-list | |
.all-tabs-item:is(:hover, :focus-within) | |
.all-tabs-secondary-button[toggle-mute] { | |
transform: translateX(48px); | |
} | |
#vertical-tabs-list .all-tabs-item .all-tabs-secondary-button[soundplaying] { | |
transform: none !important; | |
opacity: 0.7; | |
margin-inline-start: -2px; | |
} | |
#vertical-tabs-list .all-tabs-item .all-tabs-secondary-button[muted] { | |
list-style-image: none !important; | |
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 18 18"><path fill-opacity="context-fill-opacity" fill="context-fill" d="M3.52,5.367c-1.332,0-2.422,1.09-2.422,2.422v2.422c0,1.332,1.09,2.422,2.422,2.422h1.516l4.102,3.633V1.735L5.035,5.367H3.52z"/><path fill="context-fill" fill-rule="evenodd" d="M12.155,12.066l-1.138-1.138l4.872-4.872l1.138,1.138 L12.155,12.066z"/><path fill="context-fill" fill-rule="evenodd" d="M10.998,7.204l1.138-1.138l4.872,4.872l-1.138,1.138L10.998,7.204z"/></svg>') !important; | |
transform: none !important; | |
opacity: 0.7; | |
margin-inline-start: -2px; | |
} | |
#vertical-tabs-list .all-tabs-item .all-tabs-secondary-button[activemedia-blocked] { | |
list-style-image: none !important; | |
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><path fill-opacity="context-fill-opacity" fill="context-fill" d="M2.128.13A.968.968 0 0 0 .676.964v10.068a.968.968 0 0 0 1.452.838l8.712-5.034a.968.968 0 0 0 0-1.676L2.128.13z"/></svg>') !important; | |
background-size: 10px !important; | |
background-position: 4.5px center !important; | |
transform: none !important; | |
opacity: 0.7; | |
margin-inline-start: -2px; | |
} | |
#vertical-tabs-list | |
> .all-tabs-item:not(:hover, :focus-within) | |
.all-tabs-secondary-button[pictureinpicture] { | |
list-style-image: none !important; | |
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 625.8 512"><path fill="context-fill" fill-opacity="context-fill-opacity" d="M568.9 0h-512C25.6 0 0 25 0 56.3v398.8C0 486.4 25.6 512 56.9 512h512c31.3 0 56.9-25.6 56.9-56.9V56.3C625.8 25 600.2 0 568.9 0zm-512 425.7V86c0-16.5 13.5-30 30-30h452c16.5 0 30 13.5 30 30v339.6c0 16.5-13.5 30-30 30h-452c-16.5.1-30-13.4-30-29.9zM482 227.6H314.4c-16.5 0-30 13.5-30 30v110.7c0 16.5 13.5 30 30 30H482c16.5 0 30-13.5 30-30V257.6c0-16.5-13.5-30-30-30z"/></svg>') !important; | |
border-radius: 0 !important; | |
} | |
#vertical-tabs-list .all-tabs-item .all-tabs-secondary-button[pictureinpicture] { | |
transform: none !important; | |
opacity: 0.7; | |
margin-inline-start: -2px; | |
} | |
/* sound overlay on the favicon */ | |
#vertical-tabs-pane .sound-overlay { | |
display: none; | |
} | |
#vertical-tabs-pane | |
.all-tabs-item:is([muted], [soundplaying], [activemedia-blocked]) | |
.sound-overlay { | |
display: block; | |
position: absolute; | |
left: calc(var(--arrowpanel-menuitem-padding-inline) + 8px); | |
top: calc(var(--arrowpanel-menuitem-padding-block) + 8px); | |
width: 14px; | |
height: 14px; | |
-moz-context-properties: fill, fill-opacity; | |
fill: currentColor; | |
fill-opacity: 0.7; | |
opacity: 0; | |
pointer-events: none; | |
transition-property: opacity; | |
transition-timing-function: ease-in-out; | |
transition-duration: var(--pane-transition-duration); | |
} | |
#vertical-tabs-pane[unpinned]:not([expanded]) | |
.all-tabs-item:is([muted], [soundplaying], [activemedia-blocked]) | |
.sound-overlay { | |
opacity: 1; | |
pointer-events: auto; | |
} | |
#vertical-tabs-pane[unpinned] .all-tabs-item[selected] .sound-overlay { | |
fill-opacity: inherit; | |
} | |
#vertical-tabs-pane[unpinned] .all-tabs-item[soundplaying] .sound-overlay { | |
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 18 18"><path fill-opacity="context-fill-opacity" fill="context-fill" d="M3.52,5.367c-1.332,0-2.422,1.09-2.422,2.422v2.422c0,1.332,1.09,2.422,2.422,2.422h1.516l4.102,3.633 V1.735L5.035,5.367H3.52z M12.059,9c0-0.727-0.484-1.211-1.211-1.211v2.422C11.574,10.211,12.059,9.727,12.059,9z M14.48,9 c0-1.695-1.211-3.148-2.785-3.512l-0.363,1.09C12.422,6.82,13.27,7.789,13.27,9c0,1.211-0.848,2.18-1.938,2.422l0.484,1.09 C13.27,12.148,14.48,10.695,14.48,9z M12.543,3.188l-0.484,1.09C14.238,4.883,15.691,6.82,15.691,9c0,2.18-1.453,4.117-3.512,4.601 l0.484,1.09c2.422-0.605,4.238-2.906,4.238-5.691C16.902,6.215,15.086,3.914,12.543,3.188z"/></svg>') | |
center/12px no-repeat; | |
} | |
#vertical-tabs-pane[unpinned] .all-tabs-item[muted] .sound-overlay { | |
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 18 18"><path fill-opacity="context-fill-opacity" fill="context-fill" d="M3.52,5.367c-1.332,0-2.422,1.09-2.422,2.422v2.422c0,1.332,1.09,2.422,2.422,2.422h1.516l4.102,3.633V1.735L5.035,5.367H3.52z"/><path fill="context-fill" fill-rule="evenodd" d="M12.155,12.066l-1.138-1.138l4.872-4.872l1.138,1.138 L12.155,12.066z"/><path fill="context-fill" fill-rule="evenodd" d="M10.998,7.204l1.138-1.138l4.872,4.872l-1.138,1.138L10.998,7.204z"/></svg>') | |
center/12px no-repeat; | |
} | |
#vertical-tabs-pane[unpinned] .all-tabs-item[activemedia-blocked] .sound-overlay { | |
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 12 12" fill-opacity="context-fill-opacity" fill="context-fill"><path d="M2.128.13A.968.968 0 0 0 .676.964v10.068a.968.968 0 0 0 1.452.838l8.712-5.034a.968.968 0 0 0 0-1.676L2.128.13z"/></svg>') | |
3px 3px/9px no-repeat; | |
} | |
/* take a chunk out of the favicon so the overlay is more visible */ | |
#vertical-tabs-pane | |
.all-tabs-item:is([muted], [soundplaying], [activemedia-blocked]) | |
.all-tabs-button | |
.toolbarbutton-icon { | |
mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><circle cx="100%" cy="100%" r="9"/></svg>') | |
exclude 0/100% 100% no-repeat, | |
linear-gradient(#fff, #fff); | |
mask-position: 8px 8px; | |
transition-property: mask; | |
transition-timing-function: ease-in-out; | |
transition-duration: calc(var(--pane-transition-duration) / 2); | |
} | |
#vertical-tabs-pane[unpinned]:not([expanded]) | |
.all-tabs-item:is([muted], [soundplaying], [activemedia-blocked]) | |
.all-tabs-button | |
.toolbarbutton-icon { | |
mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><circle cx="100%" cy="100%" r="9"/></svg>') | |
exclude 0/100% 100% no-repeat, | |
linear-gradient(#fff, #fff); | |
} | |
/* close button */ | |
#vertical-tabs-list .all-tabs-item .all-tabs-secondary-button[close-button] { | |
fill-opacity: 0; | |
transform: translateX(14px); | |
opacity: 0; | |
margin-inline-start: -27px; | |
transition: 0.25s cubic-bezier(0.07, 0.78, 0.21, 0.95) transform, | |
0.2s cubic-bezier(0.07, 0.74, 0.24, 0.95) margin, 0.075s linear opacity; | |
display: block; | |
-moz-context-properties: fill, fill-opacity, stroke; | |
fill: currentColor; | |
fill-opacity: 0; | |
border-radius: var(--tab-button-border-radius, 2px); | |
list-style-image: url("chrome://global/skin/icons/close.svg"); | |
} | |
#vertical-tabs-list | |
.all-tabs-item:is(:hover, :focus-within) | |
.all-tabs-secondary-button[close-button] { | |
transform: none; | |
opacity: 0.7; | |
margin-inline-start: -2px; | |
} | |
/* drag/drop indicator */ | |
#vertical-tabs-list .all-tabs-item[dragpos] { | |
background-color: color-mix( | |
in srgb, | |
transparent 30%, | |
var(--arrowpanel-faint, color-mix(in srgb, var(--arrowpanel-dimmed) 60%, transparent)) | |
); | |
} | |
#vertical-tabs-list .all-tabs-item[dragpos]::before { | |
content: ""; | |
position: absolute; | |
pointer-events: none; | |
height: 0; | |
z-index: 1000; | |
width: 100%; | |
} | |
#vertical-tabs-pane:not([no-expand][unpinned]) #vertical-tabs-list .all-tabs-item[dragpos]::before { | |
border-image: linear-gradient( | |
to right, | |
transparent, | |
var(--panel-item-active-bgcolor) 1%, | |
var(--panel-item-active-bgcolor) 25%, | |
transparent 90% | |
); | |
border-image-slice: 1; | |
} | |
#vertical-tabs-list .all-tabs-item[dragpos="before"]::before { | |
inset-block-start: 0; | |
border-top: 1px solid var(--panel-item-active-bgcolor); | |
} | |
#vertical-tabs-list .all-tabs-item[dragpos="after"]::before { | |
inset-block-end: 0; | |
border-bottom: 1px solid var(--panel-item-active-bgcolor); | |
} | |
#vertical-tabs-pane[unpinned]:not([expanded]) | |
#vertical-tabs-list | |
.all-tabs-item | |
.all-tabs-secondary-button[toggle-mute] { | |
transform: none !important; | |
margin-inline: revert !important; | |
} | |
#vertical-tabs-pane[unpinned]:not([expanded]) .all-tabs-item { | |
min-width: 0 !important; | |
} | |
#vertical-tabs-pane[unpinned]:not([expanded]) :is(.all-tabs-item, .subviewbutton) { | |
margin: 0 !important; | |
-moz-box-pack: start !important; | |
} | |
#vertical-tabs-pane[unpinned]:not([no-expand]) | |
#vertical-tabs-buttons-row | |
> toolbarbutton:not(#vertical-tabs-new-tab-button), | |
#vertical-tabs-pane[unpinned] :is(.all-tabs-item, .subviewbutton) .toolbarbutton-text { | |
transition-property: opacity; | |
transition-timing-function: ease-in-out; | |
transition-duration: var(--pane-transition-duration); | |
} | |
#vertical-tabs-pane[unpinned]:not([expanded]) .all-tabs-secondary-button { | |
visibility: collapse; | |
} | |
#vertical-tabs-pane[unpinned]:not([expanded], [no-expand]) | |
#vertical-tabs-buttons-row | |
> toolbarbutton:not(#vertical-tabs-new-tab-button), | |
#vertical-tabs-pane[unpinned]:not([expanded]) | |
:is(.all-tabs-item, .subviewbutton) | |
.toolbarbutton-text { | |
opacity: 0 !important; | |
} | |
#vertical-tabs-pane .subviewbutton-iconic > .toolbarbutton-icon { | |
-moz-context-properties: fill, fill-opacity; | |
fill: var(--toolbarbutton-icon-fill); | |
} | |
#vertical-tabs-pane .subviewbutton.no-label .toolbarbutton-text { | |
display: none; | |
} | |
/* pinned indicator */ | |
#vertical-tabs-pane .all-tabs-item[pinned] > .all-tabs-button.subviewbutton > .toolbarbutton-text { | |
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="context-fill" fill-opacity="context-fill-opacity" d="M14.707 13.293L11.414 10l2.293-2.293a1 1 0 0 0 0-1.414A4.384 4.384 0 0 0 10.586 5h-.172A2.415 2.415 0 0 1 8 2.586V2a1 1 0 0 0-1.707-.707l-5 5A1 1 0 0 0 2 8h.586A2.415 2.415 0 0 1 5 10.414v.169a4.036 4.036 0 0 0 1.337 3.166 1 1 0 0 0 1.37-.042L10 11.414l3.293 3.293a1 1 0 0 0 1.414-1.414zm-7.578-1.837A2.684 2.684 0 0 1 7 10.583v-.169a4.386 4.386 0 0 0-1.292-3.121 4.414 4.414 0 0 0-1.572-1.015l2.143-2.142a4.4 4.4 0 0 0 1.013 1.571A4.384 4.384 0 0 0 10.414 7h.172a2.4 2.4 0 0 1 .848.152z"/></svg>') | |
no-repeat 6px/11px; | |
padding-inline-start: 20px; | |
-moz-context-properties: fill, fill-opacity; | |
fill: currentColor; | |
} | |
#vertical-tabs-pane toolbarseparator { | |
appearance: none; | |
min-height: 0; | |
border-top: 1px solid var(--panel-separator-color); | |
border-bottom: none; | |
margin: var(--panel-separator-margin); | |
margin-inline: 0; | |
padding: 0; | |
} | |
#vertical-tabs-pane[checked] toolbartabstop { | |
-moz-user-focus: normal; | |
} | |
/* the main toolbar button */ | |
#vertical-tabs-button { | |
list-style-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="context-fill %230c0c0d"><path fill-opacity="context-fill-opacity" d="M2,7h3v6H2V7z"/><path d="M6,7v6H5V7H2V6h12v1H6z M13,1c1.657,0,3,1.343,3,3v8c0,1.657-1.343,3-3,3H3c-1.657,0-3-1.343-3-3V4c0-1.657,1.343-3,3-3H13z M3,3C2.448,3,2,3.448,2,4v8c0,0.6,0.4,1,1,1h10c0.6,0,1-0.4,1-1V4c0-0.6-0.4-1-1-1H3z"/></svg>'); | |
fill-opacity: 0.4; | |
} | |
/* buttons at the top of the pane */ | |
#vertical-tabs-button:not([positionstart="true"]) .toolbarbutton-icon { | |
transform: scaleX(-1); | |
} | |
#vertical-tabs-button[checked="true"] { | |
display: none; | |
} | |
#vertical-tabs-button[checked], | |
#vertical-tabs-close-button { | |
list-style-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="context-fill %230c0c0d"><path fill-opacity="context-fill-opacity" d="M2,3h12v3H2V3z"/><path d="M6,7v6H5V7H2V6h12v1H6z M13,1c1.657,0,3,1.343,3,3v8c0,1.657-1.343,3-3,3H3c-1.657,0-3-1.343-3-3V4c0-1.657,1.343-3,3-3H13z M3,3C2.448,3,2,3.448,2,4v8c0,0.6,0.4,1,1,1h10c0.6,0,1-0.4,1-1V4c0-0.6-0.4-1-1-1H3z"/></svg>'); | |
fill-opacity: 0.4; | |
} | |
#vertical-tabs-new-tab-button { | |
list-style-image: url("chrome://browser/skin/new-tab.svg"); | |
} | |
#vertical-tabs-new-tab-button-plus { | |
list-style-image: url("chrome://global/skin/icons/plus.svg"); | |
} | |
#vertical-tabs-pin-button { | |
list-style-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="context-fill" fill-opacity="context-fill-opacity" d="M11.414 10l2.293-2.293a1 1 0 0 0 0-1.414 4.418 4.418 0 0 0-.8-.622L11.425 7.15h.008l-4.3 4.3v-.017l-1.48 1.476a3.865 3.865 0 0 0 .692.834 1 1 0 0 0 1.37-.042L10 11.414l3.293 3.293a1 1 0 0 0 1.414-1.414zm3.293-8.707a1 1 0 0 0-1.414 0L9.7 4.882A2.382 2.382 0 0 1 8 2.586V2a1 1 0 0 0-1.707-.707l-5 5A1 1 0 0 0 2 8h.586a2.382 2.382 0 0 1 2.3 1.7l-3.593 3.593a1 1 0 1 0 1.414 1.414l12-12a1 1 0 0 0 0-1.414zm-9 6a4.414 4.414 0 0 0-1.571-1.015l2.143-2.142a4.4 4.4 0 0 0 1.013 1.571 4.191 4.191 0 0 0 .9.684l-1.8 1.8a4.2 4.2 0 0 0-.684-.898z"/></svg>'); | |
} | |
#vertical-tabs-pane[unpinned] #vertical-tabs-pin-button { | |
list-style-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="context-fill" fill-opacity="context-fill-opacity" d="M14.707 13.293L11.414 10l2.293-2.293a1 1 0 0 0 0-1.414A4.384 4.384 0 0 0 10.586 5h-.172A2.415 2.415 0 0 1 8 2.586V2a1 1 0 0 0-1.707-.707l-5 5A1 1 0 0 0 2 8h.586A2.415 2.415 0 0 1 5 10.414v.169a4.036 4.036 0 0 0 1.337 3.166 1 1 0 0 0 1.37-.042L10 11.414l3.293 3.293a1 1 0 0 0 1.414-1.414zm-7.578-1.837A2.684 2.684 0 0 1 7 10.583v-.169a4.386 4.386 0 0 0-1.292-3.121 4.414 4.414 0 0 0-1.572-1.015l2.143-2.142a4.4 4.4 0 0 0 1.013 1.571A4.384 4.384 0 0 0 10.414 7h.172a2.4 2.4 0 0 1 .848.152z"/></svg>'); | |
} | |
#vertical-tabs-tooltip > .places-tooltip-box > hbox { | |
-moz-box-align: center; | |
} | |
#vertical-tabs-tooltip #places-tooltip-insecure-icon { | |
min-width: 1em; | |
min-height: 1em; | |
} | |
#vertical-tabs-tooltip #places-tooltip-insecure-icon[hidden] { | |
display: none; | |
} | |
@supports -moz-bool-pref("userChrome.tabs.tooltip.always-show-lock-icon") { | |
#vertical-tabs-tooltip #places-tooltip-insecure-icon { | |
display: inline-block !important; | |
} | |
} | |
#vertical-tabs-tooltip #places-tooltip-insecure-icon[pending] { | |
display: none !important; | |
} | |
#vertical-tabs-tooltip #places-tooltip-insecure-icon[type="secure"] { | |
list-style-image: url("chrome://global/skin/icons/security.svg"); | |
} | |
#vertical-tabs-tooltip #places-tooltip-insecure-icon[type="insecure"] { | |
list-style-image: url("chrome://global/skin/icons/security-broken.svg"); | |
} | |
#vertical-tabs-tooltip #places-tooltip-insecure-icon[type="mixed-passive"] { | |
list-style-image: url("chrome://global/skin/icons/security-warning.svg"); | |
} | |
#vertical-tabs-tooltip #places-tooltip-insecure-icon[type="about-page"] { | |
list-style-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path fill="context-fill" fill-opacity="context-fill-opacity" d="M15.424 5.366A4.384 4.384 0 0 0 13.817 3.4a7.893 7.893 0 0 1 .811 2.353v.017c-.9-2.185-2.441-3.066-3.7-4.984l-.189-.3c-.035-.059-.063-.112-.088-.161a1.341 1.341 0 0 1-.119-.306.022.022 0 0 0-.013-.019.026.026 0 0 0-.019 0h-.006a5.629 5.629 0 0 0-2.755 4.308c.094-.006.187-.014.282-.014a4.069 4.069 0 0 1 3.51 1.983A2.838 2.838 0 0 0 9.6 5.824a3.2 3.2 0 0 1-1.885 6.013 3.651 3.651 0 0 1-1.042-.2c-.078-.028-.157-.059-.235-.093-.046-.02-.091-.04-.135-.062A3.282 3.282 0 0 1 4.415 8.95s.369-1.334 2.647-1.334a1.91 1.91 0 0 0 .964-.857 12.756 12.756 0 0 1-1.941-1.118c-.29-.277-.428-.411-.551-.511-.066-.054-.128-.1-.207-.152a3.481 3.481 0 0 1-.022-1.894 5.915 5.915 0 0 0-1.929 1.442A4.108 4.108 0 0 1 3.1 2.584a1.561 1.561 0 0 0-.267.138 5.767 5.767 0 0 0-.783.649 6.9 6.9 0 0 0-.748.868 6.446 6.446 0 0 0-1.08 2.348c0 .009-.076.325-.131.715l-.025.182c-.019.117-.033.245-.048.444v.023c-.005.076-.011.16-.016.258v.04A7.884 7.884 0 0 0 8.011 16a7.941 7.941 0 0 0 7.9-6.44l.036-.3a7.724 7.724 0 0 0-.523-3.894z" /></svg>'); | |
} | |
#vertical-tabs-tooltip #places-tooltip-insecure-icon[type="local-page"] { | |
list-style-image: url("chrome://browser/skin/notification-icons/persistent-storage.svg"); | |
} | |
#vertical-tabs-tooltip #places-tooltip-insecure-icon[type="extension-page"] { | |
list-style-image: url("chrome://mozapps/skin/extensions/extension.svg"); | |
} | |
#vertical-tabs-tooltip #places-tooltip-insecure-icon[type="home-page"] { | |
display: none; | |
} | |
#vertical-tabs-tooltip #places-tooltip-insecure-icon[type="error-page"] { | |
list-style-image: url("chrome://global/skin/icons/warning.svg"); | |
} | |
#places-tooltip-insecure-icon { | |
-moz-context-properties: fill; | |
fill: currentColor; | |
width: 1em; | |
height: 1em; | |
margin-inline-start: 0; | |
margin-inline-end: .2em; | |
min-width: 1em !important; | |
} | |
#places-tooltip-insecure-icon[hidden] { | |
display: none; | |
}`; | |
let sss = Cc["@mozilla.org/content/style-sheet-service;1"].getService( | |
Ci.nsIStyleSheetService | |
); | |
let uri = makeURI("data:text/css;charset=UTF=8," + encodeURIComponent(css)); | |
if (sss.sheetRegistered(uri, sss.AUTHOR_SHEET)) return; // avoid loading duplicate sheets on subsequent window launches. | |
sss.loadAndRegisterSheet(uri, sss.AUTHOR_SHEET); | |
} | |
// there's a firefox bug where menuitems in the tab context menu don't have | |
// their localized labels initialized until the menu is opened on the | |
// *actual* tab bar. this bug actually affects the all-tabs menu but would | |
// affect anything trying to use the tab context menu that isn't the real | |
// tab bar. so we de-lazify the l10n IDs ourselves. lazy IDs are used for | |
// things that don't need to be managed at startup, but since we're | |
// increasing the number of elements that use this context menu, it's now | |
// pertinent to do this at startup. | |
_l10nIfNeeded() { | |
let lazies = document | |
.getElementById("tabContextMenu") | |
.querySelectorAll("[data-lazy-l10n-id]"); | |
if (lazies) { | |
MozXULElement.insertFTLIfNeeded("browser/tabContextMenu.ftl"); | |
lazies.forEach(el => { | |
el.setAttribute("data-l10n-id", el.getAttribute("data-lazy-l10n-id")); | |
el.removeAttribute("data-lazy-l10n-id"); | |
}); | |
} | |
} | |
// what to do when a window is closed. if it's the last window, record data | |
// about the pane's state to the xulStore and prefs. | |
uninit() { | |
let enumerator = Services.wm.getEnumerator("navigator:browser"); | |
if (!enumerator.hasMoreElements()) { | |
let xulStore = Services.xulStore; | |
if (this.pane.hasAttribute("checked")) xulStore.persist(this.pane, "checked"); | |
else xulStore.removeValue(document.documentURI, "vertical-tabs-pane", "checked"); | |
xulStore.persist(this.pane, "width"); | |
prefSvc.setBoolPref(closedPref, this.pane.hidden || false); | |
prefSvc.setBoolPref(unpinnedPref, this.pane.getAttribute("unpinned") || false); | |
prefSvc.setIntPref(widthPref, this.pane.width || 350); | |
} | |
} | |
} | |
// invoked when delayed window startup has finished, in other words after | |
// important components have been fully inited. | |
function init() { | |
// instantiate our tabs pane | |
window.verticalTabsPane = new VerticalTabsPaneBase(); | |
// set the sidebar position since we modified this function. change the | |
// onUnload function (invoked when window is closed) so that it calls our | |
// uninit function too. | |
SidebarUI.setPosition(); | |
eval( | |
`gBrowserInit.onUnload = function ` + | |
gBrowserInit.onUnload | |
.toSource() | |
.replace(/(SidebarUI\.uninit\(\))/, `$1; verticalTabsPane.uninit()`) | |
); | |
// reset the event handler since it used the bind method, which creates an | |
// anonymous version of the function that we can't change. just re-bind our | |
// new version. | |
window.onunload = gBrowserInit.onUnload.bind(gBrowserInit); | |
// looks unread but this is required for the following functions | |
let gNextWindowID = 0; | |
// make the PictureInPicture methods dispatch an event to the tab container | |
// informing us that a tab's "pictureinpicture" attribute has changed. this | |
// is how we capture all changes to the sound icon in real-time. obviously | |
// this behavior isn't built-in. | |
let handleRequestSrc = PictureInPicture.handlePictureInPictureRequest.toSource(); | |
if (!handleRequestSrc.includes("_tabAttrModified")) { | |
eval( | |
`PictureInPicture.handlePictureInPictureRequest = async function ` + | |
handleRequestSrc | |
.replace(/async handlePictureInPictureRequest/, "") | |
.replace(/\sServices\.telemetry.*\s*.*\s*.*\s*.*/, "") | |
.replace(/gCurrentPlayerCount.*/g, "") | |
.replace( | |
/(tab\.setAttribute\(\"pictureinpicture\".*)/, | |
`$1 parentWin.gBrowser._tabAttrModified(tab, ["pictureinpicture"]);` | |
) | |
); | |
} | |
let clearIconSrc = PictureInPicture.clearPipTabIcon.toSource(); | |
if (!clearIconSrc.includes("_tabAttrModified")) { | |
eval( | |
`PictureInPicture.clearPipTabIcon = function ` + | |
clearIconSrc | |
.replace(/WINDOW\_TYPE/, `"Toolkit:PictureInPicture"`) | |
.replace( | |
/(tab\.removeAttribute\(\"pictureinpicture\".*)/, | |
`$1 gBrowser._tabAttrModified(tab, ["pictureinpicture"]);` | |
) | |
); | |
} | |
} | |
// create the main button that goes in the tabs toolbar and opens the pane. | |
function makeWidget() { | |
// if you create a widget in the first window, it will automatically be | |
// created in subsequent videos. so we stop the script from re-registering | |
// it on every subsequent window load. | |
if (CustomizableUI.getPlacementOfWidget("vertical-tabs-button", true)) return; | |
CustomizableUI.createWidget({ | |
id: "vertical-tabs-button", | |
type: "button", | |
// it should go in the tabs toolbar by default but can be moved to any | |
// customizable toolbar. | |
defaultArea: CustomizableUI.AREA_TABSTRIP, | |
label: config.l10n["Button label"], | |
tooltiptext: config.l10n["Button tooltip"], | |
localized: false, | |
onCommand(e) { | |
Services.obs.notifyObservers(e.target.ownerGlobal, "vertical-tabs-pane-toggle"); | |
}, | |
onCreated(node) { | |
// an <observes> element is how we get the button to appear "checked" | |
// when the tabs pane is checked. it automatically sets its parent's | |
// specified attribute ("checked" and "positionstart") to match that of | |
// whatever it's observing. | |
let doc = node.ownerDocument; | |
node.appendChild( | |
create(doc, "observes", { | |
"element": "vertical-tabs-pane", | |
"attribute": "checked", | |
}) | |
); | |
node.appendChild( | |
create(doc, "observes", { | |
"element": "vertical-tabs-pane", | |
"attribute": "positionstart", | |
}) | |
); | |
if ("key_toggleVerticalTabs" in window) { | |
node.tooltipText += ` (${ShortcutUtils.prettifyShortcut(window.key_toggleVerticalTabs)})`; | |
} | |
}, | |
}); | |
} | |
// make the hotkey (Ctrl+Alt+V by default) | |
if (config.hotkey.enabled && _ucUtils?.registerHotkey) { | |
_ucUtils.registerHotkey( | |
{ | |
id: "key_toggleVerticalTabs", | |
modifiers: config.hotkey.modifiers, | |
key: config.hotkey.key, | |
}, | |
(win, key) => Services.obs.notifyObservers(win, "vertical-tabs-pane-toggle") | |
); | |
} | |
// make the main elements | |
document.getElementById("sidebar-splitter").after( | |
create(document, "splitter", { | |
class: "chromeclass-extrachrome sidebar-splitter", | |
id: "vertical-tabs-splitter", | |
hidden: true, | |
}) | |
); | |
document.getElementById("sidebar-splitter").after( | |
create(document, "vbox", { | |
class: "chromeclass-extrachrome", | |
id: "vertical-tabs-pane", | |
context: "vertical-tabs-context-menu", | |
hidden: true, | |
}) | |
); | |
makeWidget(); | |
// tab pane's horizontal alignment should mirror that of the sidebar, which | |
// can be moved from left to right. | |
SidebarUI.setPosition = function () { | |
let appcontent = document.getElementById("appcontent"); | |
let verticalSplitter = document.getElementById("vertical-tabs-splitter"); | |
let verticalPane = document.getElementById("vertical-tabs-pane"); | |
this._box.style.MozBoxOrdinalGroup = 1; | |
this._splitter.style.MozBoxOrdinalGroup = 2; | |
appcontent.style.MozBoxOrdinalGroup = 3; | |
verticalSplitter.style.MozBoxOrdinalGroup = 4; | |
verticalPane.style.MozBoxOrdinalGroup = 5; | |
if (!this._positionStart) { | |
this._box.style.MozBoxOrdinalGroup = 5; | |
this._splitter.style.MozBoxOrdinalGroup = 4; | |
verticalSplitter.style.MozBoxOrdinalGroup = 2; | |
verticalPane.style.MozBoxOrdinalGroup = 1; | |
this._box.setAttribute("positionend", true); | |
verticalPane.setAttribute("positionstart", true); | |
} else { | |
this._box.removeAttribute("positionend"); | |
verticalPane.removeAttribute("positionstart"); | |
} | |
this.hideSwitcherPanel(); | |
let content = SidebarUI.browser.contentWindow; | |
if (content && content.updatePosition) content.updatePosition(); | |
}; | |
// wait for delayed startup for some parts of the script to execute. | |
if (gBrowserInit.delayedStartupFinished) { | |
init(); | |
} else { | |
let delayedListener = (subject, topic) => { | |
if (topic == "browser-delayed-startup-finished" && subject == window) { | |
Services.obs.removeObserver(delayedListener, topic); | |
init(); | |
} | |
}; | |
Services.obs.addObserver(delayedListener, "browser-delayed-startup-finished"); | |
} | |
document.addEventListener("fullscreenchange", () => { | |
if (document.fullscreenElement) { | |
document.getElementById("vertical-tabs-pane").setAttribute("fullscreen", true); | |
} else { | |
document.getElementById("vertical-tabs-pane").removeAttribute("fullscreen"); | |
} | |
}); | |
let t; | |
document.getElementById("vertical-tabs-pane").addEventListener("click", (e) => { | |
const target = e.target; | |
console.log("clicked", t); | |
if (t && target.id === "vertical-tabs-pane") { | |
clearTimeout(t); | |
BrowserOpenTab(); | |
t = null; | |
} else { | |
t = setTimeout(() => { | |
t = null; | |
}, 500); | |
} | |
}); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment