diff --git a/JS/appMenuAboutConfigButton.uc.js b/JS/appMenuAboutConfigButton.uc.js index 4b12b6a..ecf32b8 100644 --- a/JS/appMenuAboutConfigButton.uc.js +++ b/JS/appMenuAboutConfigButton.uc.js @@ -21,93 +21,99 @@ // ==/UserScript== (function () { - // user configuration - const config = { - urlOverride: "", - /* the script tries to automatically find earthlng's aboutconfig URL, and if it can't be - found, uses the built-in about:config URL instead. if it's unable to find the URL for your - particular setup, or if you just want to use the vanilla about:config page, replace this - empty string with your preferred URL, in quotes. if you want to use my about:cfg script that - registers earthlng's aboutconfig page to about:cfg, and you want the about:config button to - take you to about:cfg, then leave this empty. it will automatically use about:cfg if the - script exists. if about:cfg doesn't work for you then change the urlOverride in *that* - script instead of this one. */ - }; + // user configuration + const config = { + urlOverride: "", + /* the script tries to automatically find earthlng's aboutconfig URL, + and if it can't be found, uses the built-in about:config URL instead. if + it's unable to find the URL for your particular setup, or if you just + want to use the vanilla about:config page, replace this empty string + with your preferred URL, in quotes. if you want to use my about:cfg + script that registers earthlng's aboutconfig page to about:cfg, and you + want the about:config button to take you to about:cfg, then leave this + empty. it will automatically use about:cfg if the script exists. if + about:cfg doesn't work for you then change the pathOverride in *that* + script instead of setting urlOverride in this one. if you changed the + address (the "cfg" string) in that script, you'll need to use + urlOverride here if you want the button to direct to earthlng's + aboutconfig page. so if for example you changed the address to "config2" + then change urlOverride above to "about:config2" */ + }; - let { interfaces: Ci, manager: Cm } = Components; + let { interfaces: Ci, manager: Cm } = Components; - function findAboutConfig() { - if (config.urlOverride) return config.urlOverride; + function findAboutConfig() { + if (config.urlOverride) return config.urlOverride; - if ( - Cm.QueryInterface(Ci.nsIComponentRegistrar).isContractIDRegistered( - "@mozilla.org/network/protocol/about;1?what=cfg" - ) - ) - return "about:cfg"; + if ( + Cm.QueryInterface(Ci.nsIComponentRegistrar).isContractIDRegistered( + "@mozilla.org/network/protocol/about;1?what=cfg" + ) + ) + return "about:cfg"; - let dir = Services.dirsvc.get("UChrm", Ci.nsIFile); - let appendFn = (nm) => dir.append(nm); + let dir = Services.dirsvc.get("UChrm", Ci.nsIFile); + let appendFn = nm => dir.append(nm); - // fx-autoconfig - ["resources", "aboutconfig", "config.xhtml"].forEach(appendFn); - if (dir.exists()) return "chrome://userchrome/content/aboutconfig/config.xhtml"; + // fx-autoconfig + ["resources", "aboutconfig", "config.xhtml"].forEach(appendFn); + if (dir.exists()) return "chrome://userchrome/content/aboutconfig/config.xhtml"; - // earthlng's loader - dir = Services.dirsvc.get("UChrm", Ci.nsIFile); - ["utils", "aboutconfig", "config.xhtml"].forEach(appendFn); - if (dir.exists()) return "chrome://userchromejs/content/aboutconfig/config.xhtml"; + // earthlng's loader + dir = Services.dirsvc.get("UChrm", Ci.nsIFile); + ["utils", "aboutconfig", "config.xhtml"].forEach(appendFn); + if (dir.exists()) return "chrome://userchromejs/content/aboutconfig/config.xhtml"; - // xiaoxiaoflood's loader - dir = Services.dirsvc.get("UChrm", Ci.nsIFile); - ["utils", "aboutconfig", "aboutconfig.xhtml"].forEach(appendFn); - if (dir.exists()) return "chrome://userchromejs/content/aboutconfig/aboutconfig.xhtml"; + // xiaoxiaoflood's loader + dir = Services.dirsvc.get("UChrm", Ci.nsIFile); + ["utils", "aboutconfig", "aboutconfig.xhtml"].forEach(appendFn); + if (dir.exists()) return "chrome://userchromejs/content/aboutconfig/aboutconfig.xhtml"; - // no about:config replacement found - return "about:config"; - } + // no about:config replacement found + return "about:config"; + } - async function createButton() { - // get fluent file for AboutConfig page - const configStrings = await new Localization(["toolkit/about/config.ftl"], true); - // localize the "Advanced Preferences" string - const advancedPrefsLabel = await configStrings.formatValue(["about-config-page-title"]); - const { mainView } = PanelUI; - const doc = mainView.ownerDocument; - const settingsButton = - doc.getElementById("appMenu-settings-button") ?? - doc.getElementById("appMenu-preferences-button"); - const prefsButton = doc.createXULElement("toolbarbutton"); + async function createButton() { + // get fluent file for AboutConfig page + const configStrings = await new Localization(["toolkit/about/config.ftl"], true); + // localize the "Advanced Preferences" string + const advancedPrefsLabel = await configStrings.formatValue(["about-config-page-title"]); + const { mainView } = PanelUI; + const doc = mainView.ownerDocument; + const settingsButton = + doc.getElementById("appMenu-settings-button") ?? + doc.getElementById("appMenu-preferences-button"); + const prefsButton = doc.createXULElement("toolbarbutton"); - prefsButton.preferredURL = findAboutConfig(); - for (const [key, val] of Object.entries({ - id: "appMenu-advanced-settings-button", - class: "subviewbutton", - label: advancedPrefsLabel, - oncommand: `openTrustedLinkIn(this.preferredURL, gBrowser.currentURI.spec === AboutNewTab.newTabURL || gBrowser.currentURI.spec === HomePage.get(window) ? "current" : "tab")`, - })) - prefsButton.setAttribute(key, val); - // place after the built-in "Settings" button - settingsButton.after(prefsButton); - } + prefsButton.preferredURL = findAboutConfig(); + for (const [key, val] of Object.entries({ + id: "appMenu-advanced-settings-button", + class: "subviewbutton", + label: advancedPrefsLabel, + oncommand: `openTrustedLinkIn(this.preferredURL, gBrowser.currentURI.spec === AboutNewTab.newTabURL || gBrowser.currentURI.spec === HomePage.get(window) ? "current" : "tab")`, + })) + prefsButton.setAttribute(key, val); + // place after the built-in "Settings" button + settingsButton.after(prefsButton); + } - function init() { - PanelMultiView.getViewNode(document, "appMenu-multiView").addEventListener( - "ViewShowing", - createButton, - { once: true } - ); - } + function init() { + PanelMultiView.getViewNode(document, "appMenu-multiView").addEventListener( + "ViewShowing", + createButton, + { once: true } + ); + } - if (gBrowserInit.delayedStartupFinished) { + if (gBrowserInit.delayedStartupFinished) { + init(); + } else { + let delayedListener = (subject, topic) => { + if (topic == "browser-delayed-startup-finished" && subject == window) { + Services.obs.removeObserver(delayedListener, topic); 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"); - } + } + }; + Services.obs.addObserver(delayedListener, "browser-delayed-startup-finished"); + } })(); diff --git a/JS/debugExtensionInToolbarContextMenu.uc.js b/JS/debugExtensionInToolbarContextMenu.uc.js index 2a1baa2..75478b4 100644 --- a/JS/debugExtensionInToolbarContextMenu.uc.js +++ b/JS/debugExtensionInToolbarContextMenu.uc.js @@ -8,323 +8,320 @@ // ==/UserScript== class DebugExtension { - // you can modify the menu items' labels and access keys here, e.g. if you prefer another language. - // an access key is the letter highlighted in a menuitem's label. - // if the letter highlighted is "D" for example, and you press D on your keyboard - // while the context menu is open, it will automatically select the menu item with that access key. - // if two menu items have the same access key and are both visible, - // then instead of selecting one menu item it will just cycle between the two. - // however, the access key does not need to be a character in the label. - // if the access key isn't in the label, then instead of underlining the letter in the label, - // it will add the access key to the end of the label in parentheses. - // e.g. "Debug Extension (Q)" instead of "_D_ebug Extension". - static config = { - menuLabel: "Debug Extension", // menu label - menuAccessKey: "D", - // individual menu items - Manifest: { - label: "Extension Manifest", - accesskey: "M", - }, - ViewDocs: { - label: "View Documents", - accesskey: "D", - }, - BrowserAction: { - label: "Browser Action", - accesskey: "B", - }, - PageAction: { - label: "Page Action", - accesskey: "P", - }, - SidebarAction: { - label: "Sidebar Action", - accesskey: "S", - }, - Options: { - label: "Extension Options", - accesskey: "O", - }, - Inspector: { - label: "Inspect Extension", - accesskey: "I", - }, - ViewSource: { - label: "View Source", - accesskey: "V", - }, - CopyID: { - label: "Copy ID", - accesskey: "C", - }, - CopyURL: { - label: "Copy URL", - accesskey: "U", - }, - }; - // end user configuration - static menuitems = [ - "Manifest", - { name: "ViewDocs", children: ["BrowserAction", "PageAction", "SidebarAction", "Options"] }, - "Inspector", - "ViewSource", - "CopyID", - "CopyURL", - ]; - constructor() { - this.setupUpdate(); - this.toolbarMenu = this.makeMainMenu(this.toolbarContext); - this.toolbarMenupopup = this.toolbarMenu.appendChild( - document.createXULElement("menupopup") - ); - this.toolbarMenupopup.addEventListener("popupshowing", this); - this.overflowMenu = this.makeMainMenu(this.overflowContext); - this.overflowMenupopup = this.overflowMenu.appendChild( - document.createXULElement("menupopup") - ); - this.overflowMenupopup.addEventListener("popupshowing", this); - this.pageActionMenu = this.makeMainMenu(this.pageActionContext); - this.pageActionMenupopup = this.pageActionMenu.appendChild( - document.createXULElement("menupopup") - ); - this.pageActionMenupopup.addEventListener("popupshowing", this); - // make a menu item for each type of page within each context - DebugExtension.menuitems.forEach((type) => - ["toolbar", "overflow", "pageAction"].forEach((context) => { - if (typeof type === "string") this.makeMenuitem(type, this[`${context}Menupopup`]); - else if (typeof type === "object") this.makeMenu(type, this[`${context}Menupopup`]); - }) - ); + // you can modify the menu items' labels and access keys here, e.g. if you prefer another language. + // an access key is the letter highlighted in a menuitem's label. + // if the letter highlighted is "D" for example, and you press D on your keyboard + // while the context menu is open, it will automatically select the menu item with that access key. + // if two menu items have the same access key and are both visible, + // then instead of selecting one menu item it will just cycle between the two. + // however, the access key does not need to be a character in the label. + // if the access key isn't in the label, then instead of underlining the letter in the label, + // it will add the access key to the end of the label in parentheses. + // e.g. "Debug Extension (Q)" instead of "_D_ebug Extension". + static config = { + menuLabel: "Debug Extension", // menu label + menuAccessKey: "D", + // individual menu items + Manifest: { + label: "Extension Manifest", + accesskey: "M", + }, + ViewDocs: { + label: "View Documents", + accesskey: "D", + }, + BrowserAction: { + label: "Browser Action", + accesskey: "B", + }, + PageAction: { + label: "Page Action", + accesskey: "P", + }, + SidebarAction: { + label: "Sidebar Action", + accesskey: "S", + }, + Options: { + label: "Extension Options", + accesskey: "O", + }, + Inspector: { + label: "Inspect Extension", + accesskey: "I", + }, + ViewSource: { + label: "View Source", + accesskey: "V", + }, + CopyID: { + label: "Copy ID", + accesskey: "C", + }, + CopyURL: { + label: "Copy URL", + accesskey: "U", + }, + }; + constructor() { + this.setupUpdate(); + this.toolbarMenu = this.makeMainMenu(this.toolbarContext); + this.toolbarMenupopup = this.toolbarMenu.appendChild(document.createXULElement("menupopup")); + this.toolbarMenupopup.addEventListener("popupshowing", this); + this.overflowMenu = this.makeMainMenu(this.overflowContext); + this.overflowMenupopup = this.overflowMenu.appendChild(document.createXULElement("menupopup")); + this.overflowMenupopup.addEventListener("popupshowing", this); + this.pageActionMenu = this.makeMainMenu(this.pageActionContext); + this.pageActionMenupopup = this.pageActionMenu.appendChild( + document.createXULElement("menupopup") + ); + this.pageActionMenupopup.addEventListener("popupshowing", this); + // make a menu item for each type of page within each context + [ + "Manifest", + { + name: "ViewDocs", + children: ["BrowserAction", "PageAction", "SidebarAction", "Options"], + }, + "Inspector", + "ViewSource", + "CopyID", + "CopyURL", + ].forEach(type => + ["toolbar", "overflow", "pageAction"].forEach(context => { + if (typeof type === "string") this.makeMenuitem(type, this[`${context}Menupopup`]); + else if (typeof type === "object") this.makeMenu(type, this[`${context}Menupopup`]); + }) + ); + } + /** + * set a bunch of attributes on a node + * @param {object} element (a DOM node) + * @param {object} attrs (an object containing properties — keys are turned into attributes on the DOM node) + */ + maybeSetAttributes(element, attrs) { + for (let [name, value] of Object.entries(attrs)) + if (value === void 0) element.removeAttribute(name); + else element.setAttribute(name, value); + } + get toolbarContext() { + return ( + this._toolbarContext || + (this._toolbarContext = document.getElementById("toolbar-context-menu")) + ); + } + get overflowContext() { + return ( + this._overflowContext || + (this._overflowContext = document.getElementById("customizationPanelItemContextMenu")) + ); + } + get pageActionContext() { + return ( + this._pageActionContext || + (this._pageActionContext = document.getElementById("pageActionContextMenu")) + ); + } + // enable/disable menu items depending on whether the clicked extension has pages available to open. + handleEvent(e) { + if (e.target !== e.currentTarget) return; + let popup = e.target; + let id = this.getExtensionId(popup); + if (!id) return; + let extension = WebExtensionPolicy.getByID(id).extension; + if (e.target.className.includes("Submenu-Popup")) { + popup.querySelector(".customize-context-BrowserAction").disabled = + !extension.manifest.browser_action?.default_popup; + popup.querySelector(".customize-context-PageAction").disabled = + !extension.manifest.page_action?.default_popup; + popup.querySelector(".customize-context-SidebarAction").disabled = + !extension.manifest.sidebar_action?.default_panel; + popup.querySelector(".customize-context-Options").disabled = + !extension.manifest.options_ui?.page; + } else { + popup.querySelector(".customize-context-ViewDocs-Submenu").disabled = + !extension.manifest.browser_action?.default_popup && + !extension.manifest.page_action?.default_popup && + !extension.manifest.options_ui?.page; + popup.querySelector(".customize-context-ViewSource").disabled = + extension.addonData.isSystem || + extension.addonData.builtIn || + extension.addonData.temporarilyInstalled; } - /** - * set a bunch of attributes on a node - * @param {object} element (a DOM node) - * @param {object} attrs (an object containing properties — keys are turned into attributes on the DOM node) - */ - maybeSetAttributes(element, attrs) { - for (let [name, value] of Object.entries(attrs)) - if (value === void 0) element.removeAttribute(name); - else element.setAttribute(name, value); - } - get toolbarContext() { - return ( - this._toolbarContext || - (this._toolbarContext = document.getElementById("toolbar-context-menu")) - ); - } - get overflowContext() { - return ( - this._overflowContext || - (this._overflowContext = document.getElementById("customizationPanelItemContextMenu")) - ); - } - get pageActionContext() { - return ( - this._pageActionContext || - (this._pageActionContext = document.getElementById("pageActionContextMenu")) - ); - } - // enable/disable menu items depending on whether the clicked extension has pages available to open. - handleEvent(e) { - if (e.target !== e.currentTarget) return; - let popup = e.target; - let id = this.getExtensionId(popup); - if (!id) return; - let extension = WebExtensionPolicy.getByID(id).extension; - if (e.target.className.includes("Submenu-Popup")) { - popup.querySelector(".customize-context-BrowserAction").disabled = - !extension.manifest.browser_action?.default_popup; - popup.querySelector(".customize-context-PageAction").disabled = - !extension.manifest.page_action?.default_popup; - popup.querySelector(".customize-context-SidebarAction").disabled = - !extension.manifest.sidebar_action?.default_panel; - popup.querySelector(".customize-context-Options").disabled = - !extension.manifest.options_ui?.page; - } else { - popup.querySelector(".customize-context-ViewDocs-Submenu").disabled = - !extension.manifest.browser_action?.default_popup && - !extension.manifest.page_action?.default_popup && - !extension.manifest.options_ui?.page; - popup.querySelector(".customize-context-ViewSource").disabled = - extension.addonData.isSystem || - extension.addonData.builtIn || - extension.addonData.temporarilyInstalled; - } - } - makeMainMenu(popup) { - let menu = document.createXULElement("menu"); - this.maybeSetAttributes(menu, { - class: "customize-context-debugExtension", - label: DebugExtension.config.menuLabel, - accesskey: DebugExtension.config.menuAccessKey, - contexttype: popup === this.pageActionContext ? void 0 : "toolbaritem", - }); - popup - .querySelector( - popup === this.pageActionContext - ? ".manageExtensionItem" - : ".customize-context-manageExtension" - ) - .after(menu); - return menu; - } - /** - * make a menu item that opens a given type of page, with label & accesskey corresponding to those defined in the "config" static property - * @param {string} type (which menuitem to make) - * @param {object} popup (where to put the menuitem) - * @returns a menuitem DOM node - */ - makeMenuitem(type, popup) { - let item = document.createXULElement("menuitem"); - this.maybeSetAttributes(item, { - class: `customize-context-${type}`, - label: DebugExtension.config[type].label, - accesskey: DebugExtension.config[type].accesskey, - oncommand: `debugExtensionMenu.onCommand(event, this.parentElement, "${type}")`, - contexttype: popup.closest("#pageActionContextMenu") ? void 0 : "toolbaritem", - }); - popup.appendChild(item); - return item; - } - /** - * make a submenu in a given popup - * @param {string} type (which menu to make) - * @param {object} popup (where to put the menuitem) - * @returns a menu DOM node - */ - makeMenu(type, popup) { - let { name, children } = type; - if (!name || !children) return; - let menu = document.createXULElement("menu"); - this.maybeSetAttributes(menu, { - class: `customize-context-${name}-Submenu`, - label: DebugExtension.config[name].label, - accesskey: DebugExtension.config[name].accesskey, - contexttype: popup.closest("#pageActionContextMenu") ? void 0 : "toolbaritem", - }); - let menupopup = menu.appendChild(document.createXULElement("menupopup")); - menupopup.className = `customize-context-${name}-Submenu-Popup`; - menupopup.addEventListener("popupshowing", this); - children.forEach((item) => this.makeMenuitem(item, menupopup)); - popup.appendChild(menu); - return menu; - } - // get the ID for the button the context menu was opened on - getExtensionId(popup) { - if (popup.closest("#pageActionContextMenu")) - return BrowserPageActions.actionForNode(popup.triggerNode).extensionID; - else return ToolbarContextMenu._getExtensionId(popup); - } - // click callback - onCommand(event, popup, type) { - let id = this.getExtensionId(popup); - let extension = WebExtensionPolicy.getByID(id).extension; // this contains information about an extension with a given ID. - // use extension's principal if it's available. - let triggeringPrincipal = extension.principal; - let url; - // which type of page to open. the "type" value passed is different for each menu item. - switch (type) { - case "Manifest": - url = extension.baseURL + `manifest.json`; - break; - case "BrowserAction": - url = extension.manifest.browser_action?.default_popup; - break; - case "PageAction": - url = extension.manifest.page_action?.default_popup; - break; - case "SidebarAction": - url = extension.manifest.sidebar_action?.default_panel; - break; - case "Options": - url = extension.manifest.options_ui?.page; - break; - case "Inspector": - url = `about:devtools-toolbox?id=${encodeURIComponent(id)}&type=extension`; - triggeringPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); // use the system principal for about:devtools-toolbox - break; - case "ViewSource": - this.openArchive(id); - return; - case "CopyID": - case "CopyURL": - Cc["@mozilla.org/widget/clipboardhelper;1"] - .getService(Ci.nsIClipboardHelper) - .copyString(type === "CopyID" ? id : extension.baseURL); - if (windowUtils.getBoundsWithoutFlushing(popup.triggerNode).width) - window.CustomHint?.show(popup.triggerNode, "Copied"); - return; - } - // if the extension's principal isn't available for some reason, make a content principal. - if (!triggeringPrincipal) - triggeringPrincipal = Services.scriptSecurityManager.createContentPrincipal( - Services.io.newURI(url), - {} - ); - // whether to open in the current tab or a new tab. - // only opens in the current tab if the current tab is on the new tab page or home page. - let where = new RegExp(`(${BROWSER_NEW_TAB_URL}|${HomePage.get(window)})`, "i").test( - gBrowser.currentURI.spec - ) - ? "current" - : "tab"; - openLinkIn(url, where, { - triggeringPrincipal, - // only open in the background if the shift key was pressed when the menu item was clicked - inBackground: event.shiftKey, - }); - } - /** - * open a given addon's source xpi file - * @param {string} id (an addon's ID) - */ - openArchive(id) { - let dir = Services.dirsvc.get("ProfD", Ci.nsIFile); - dir.append("extensions"); - dir.append(id + ".xpi"); - dir.launch(); - } - // modify the internal functions that updates the visibility - // of the built-in "remove extension," "manage extension" items, etc. - // that's based on whether the button that was clicked is an extension or not, - // so it also updates the visibility of our menu by the same parameter. - setupUpdate() { - eval( - `ToolbarContextMenu.updateExtension = async function ` + - ToolbarContextMenu.updateExtension - .toSource() - .replace(/async updateExtension/, "") - .replace( - /let separator/, - `let debugExtension = popup.querySelector(\".customize-context-debugExtension\");\n let separator` - ) - .replace( - /\[removeExtension, manageExtension,/, - `[removeExtension, manageExtension, debugExtension,` - ) - ); - eval( - `BrowserPageActions.onContextMenuShowing = async function ` + - BrowserPageActions.onContextMenuShowing - .toSource() - .replace(/async onContextMenuShowing/, "") - .replace( - /(let removeExtension.*);/, - `$1, debugExtension = popup.querySelector(".customize-context-debugExtension");` - ) - .replace(/(removeExtension.hidden =)/, `$1 debugExtension.hidden =`) - ); + } + makeMainMenu(popup) { + let menu = document.createXULElement("menu"); + this.maybeSetAttributes(menu, { + class: "customize-context-debugExtension", + label: DebugExtension.config.menuLabel, + accesskey: DebugExtension.config.menuAccessKey, + contexttype: popup === this.pageActionContext ? void 0 : "toolbaritem", + }); + popup + .querySelector( + popup === this.pageActionContext + ? ".manageExtensionItem" + : ".customize-context-manageExtension" + ) + .after(menu); + return menu; + } + /** + * make a menu item that opens a given type of page, with label & accesskey corresponding to those defined in the "config" static property + * @param {string} type (which menuitem to make) + * @param {object} popup (where to put the menuitem) + * @returns a menuitem DOM node + */ + makeMenuitem(type, popup) { + let item = document.createXULElement("menuitem"); + this.maybeSetAttributes(item, { + class: `customize-context-${type}`, + label: DebugExtension.config[type].label, + accesskey: DebugExtension.config[type].accesskey, + oncommand: `debugExtensionMenu.onCommand(event, this.parentElement, "${type}")`, + contexttype: popup.closest("#pageActionContextMenu") ? void 0 : "toolbaritem", + }); + popup.appendChild(item); + return item; + } + /** + * make a submenu in a given popup + * @param {string} type (which menu to make) + * @param {object} popup (where to put the menuitem) + * @returns a menu DOM node + */ + makeMenu(type, popup) { + let { name, children } = type; + if (!name || !children) return; + let menu = document.createXULElement("menu"); + this.maybeSetAttributes(menu, { + class: `customize-context-${name}-Submenu`, + label: DebugExtension.config[name].label, + accesskey: DebugExtension.config[name].accesskey, + contexttype: popup.closest("#pageActionContextMenu") ? void 0 : "toolbaritem", + }); + let menupopup = menu.appendChild(document.createXULElement("menupopup")); + menupopup.className = `customize-context-${name}-Submenu-Popup`; + menupopup.addEventListener("popupshowing", this); + children.forEach(item => this.makeMenuitem(item, menupopup)); + popup.appendChild(menu); + return menu; + } + // get the ID for the button the context menu was opened on + getExtensionId(popup) { + if (popup.closest("#pageActionContextMenu")) + return BrowserPageActions.actionForNode(popup.triggerNode).extensionID; + else return ToolbarContextMenu._getExtensionId(popup); + } + // click callback + onCommand(event, popup, type) { + let id = this.getExtensionId(popup); + let extension = WebExtensionPolicy.getByID(id).extension; // this contains information about an extension with a given ID. + // use extension's principal if it's available. + let triggeringPrincipal = extension.principal; + let url; + // which type of page to open. the "type" value passed is different for each menu item. + switch (type) { + case "Manifest": + url = extension.baseURL + `manifest.json`; + break; + case "BrowserAction": + url = extension.manifest.browser_action?.default_popup; + break; + case "PageAction": + url = extension.manifest.page_action?.default_popup; + break; + case "SidebarAction": + url = extension.manifest.sidebar_action?.default_panel; + break; + case "Options": + url = extension.manifest.options_ui?.page; + break; + case "Inspector": + url = `about:devtools-toolbox?id=${encodeURIComponent(id)}&type=extension`; + triggeringPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); // use the system principal for about:devtools-toolbox + break; + case "ViewSource": + this.openArchive(id); + return; + case "CopyID": + case "CopyURL": + Cc["@mozilla.org/widget/clipboardhelper;1"] + .getService(Ci.nsIClipboardHelper) + .copyString(type === "CopyID" ? id : extension.baseURL); + if (windowUtils.getBoundsWithoutFlushing(popup.triggerNode).width) + window.CustomHint?.show(popup.triggerNode, "Copied"); + return; } + // if the extension's principal isn't available for some reason, make a content principal. + if (!triggeringPrincipal) + triggeringPrincipal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(url), + {} + ); + // whether to open in the current tab or a new tab. + // only opens in the current tab if the current tab is on the new tab page or home page. + let where = new RegExp(`(${BROWSER_NEW_TAB_URL}|${HomePage.get(window)})`, "i").test( + gBrowser.currentURI.spec + ) + ? "current" + : "tab"; + openLinkIn(url, where, { + triggeringPrincipal, + // only open in the background if the shift key was pressed when the menu item was clicked + inBackground: event.shiftKey, + }); + } + /** + * open a given addon's source xpi file + * @param {string} id (an addon's ID) + */ + openArchive(id) { + let dir = Services.dirsvc.get("ProfD", Ci.nsIFile); + dir.append("extensions"); + dir.append(id + ".xpi"); + dir.launch(); + } + // modify the internal functions that updates the visibility + // of the built-in "remove extension," "manage extension" items, etc. + // that's based on whether the button that was clicked is an extension or not, + // so it also updates the visibility of our menu by the same parameter. + setupUpdate() { + eval( + `ToolbarContextMenu.updateExtension = async function ` + + ToolbarContextMenu.updateExtension + .toSource() + .replace(/async updateExtension/, "") + .replace( + /let separator/, + `let debugExtension = popup.querySelector(\".customize-context-debugExtension\");\n let separator` + ) + .replace( + /\[removeExtension, manageExtension,/, + `[removeExtension, manageExtension, debugExtension,` + ) + ); + eval( + `BrowserPageActions.onContextMenuShowing = async function ` + + BrowserPageActions.onContextMenuShowing + .toSource() + .replace(/async onContextMenuShowing/, "") + .replace( + /(let removeExtension.*);/, + `$1, debugExtension = popup.querySelector(".customize-context-debugExtension");` + ) + .replace(/(removeExtension.hidden =)/, `$1 debugExtension.hidden =`) + ); + } } if (gBrowserInit.delayedStartupFinished) window.debugExtensionMenu = new DebugExtension(); else { - let delayedListener = (subject, topic) => { - if (topic == "browser-delayed-startup-finished" && subject == window) { - Services.obs.removeObserver(delayedListener, topic); - window.debugExtensionMenu = new DebugExtension(); - } - }; - Services.obs.addObserver(delayedListener, "browser-delayed-startup-finished"); + let delayedListener = (subject, topic) => { + if (topic == "browser-delayed-startup-finished" && subject == window) { + Services.obs.removeObserver(delayedListener, topic); + window.debugExtensionMenu = new DebugExtension(); + } + }; + Services.obs.addObserver(delayedListener, "browser-delayed-startup-finished"); } diff --git a/JS/eyedropperButton.uc.js b/JS/eyedropperButton.uc.js index 9488ed4..331a0ca 100644 --- a/JS/eyedropperButton.uc.js +++ b/JS/eyedropperButton.uc.js @@ -1,6 +1,6 @@ // ==UserScript== // @name Eyedropper Button -// @version 1.0 +// @version 1.0.1 // @author aminomancer // @homepage https://github.com/aminomancer/uc.css.js // @description Adds a toolbar button that implements the color picker without launching the devtools. Similar to the menu item in the "More Tools" and "Tools > Browser Tools" menus, only this one can be placed directly on your toolbar. Also adds a customizable hotkey to do the same — by default, it's Ctrl+Shift+Y (or Cmd+Shift+Y on macOS) @@ -8,129 +8,131 @@ // ==/UserScript== class EyedropperButton { - static shortcut = { - key: "Y", // shortcut key, combined with modifiers. - modifiers: "accel shift", // ctrl + shift or cmd + shift (use accel instead of ctrl, it's cross-platform. it can be changed in about:config with ui.key.accelKey. if you leave the "" quotes empty, no modifier will be used. that means the hotkey will just be "Y" which may be a bad idea — only do that if your "key" value is something obscure like a function key, since this key will be active at all times and in almost all contexts. - id: "key_eyedropper", - }; - constructor() { - this.makeBundles(); - this.makeHotkey(); - Services.obs.addObserver(this, "uc-eyedropper-started"); - if (gBrowserInit.delayedStartupFinished) this.afterLazyStartup(); - else Services.obs.addObserver(this, "browser-delayed-startup-finished"); - } - makeBundles() { - this.menuBundle = Services.strings.createBundle( - "chrome://devtools/locale/menus.properties" - ); - this.inspectorBundle = Services.strings.createBundle( - "chrome://devtools/locale/inspector.properties" - ); - } - getString(name, where) { - return this[`${where}Bundle`].GetStringFromName(name); - } - // "Eyedropper" - get labelString() { - return ( - this._labelString || (this._labelString = this.getString("eyedropper.label", "menu")) - ); - } - // "Grab a color from the page" - get tooltipString() { - return ( - this._tooltipString || - (this._tooltipString = this.getString("inspector.eyedropper.label", "inspector")) - ); - } - // "Ctrl+Shift+Y" - get shortcutString() { - return ( - this._shortcutString || - (this._shortcutString = this.hotkey - ? ` (${ShortcutUtils.prettifyShortcut(this.keyEl)})` - : "") - ); - } - // "Grab a color from the page (%S)" - get tooltipWithShortcut() { - return ( - this._tooltipWithShortcut || - (this._tooltipWithShortcut = this.tooltipString + this.shortcutString) - ); - } - get devToolsMenu() { - return ( - this._devToolsMenu || - (this._devToolsMenu = document.getElementById("menuWebDeveloperPopup")) - ); - } - get mainMenuItem() { - return ( - this._mainMenuItem || - (this._mainMenuItem = document.getElementById("menu_eyedropper") || null) - ); - } - get keyEl() { - return this._keyEl || (this._keyEl = window[EyedropperButton.shortcut.id]); - } - makeHotkey() { - this.hotkey = _ucUtils.registerHotkey(EyedropperButton.shortcut, (win, key) => { - Services.obs.notifyObservers(win, "uc-eyedropper-started"); - }); - } - makeWidget() { - if (CustomizableUI.getPlacementOfWidget("eyedropper-button", true)) return; - CustomizableUI.createWidget({ - id: "eyedropper-button", - type: "button", - defaultArea: CustomizableUI.AREA_NAVBAR, - label: this.labelString, - tooltiptext: this.tooltipWithShortcut, - localized: false, - onCommand: (e) => { - Services.obs.notifyObservers(e.view, "uc-eyedropper-started"); - }, - onCreated: (aNode) => { - aNode.style.listStyleImage = `url(chrome://devtools/skin/images/command-eyedropper.svg)`; - }, - }); - } - setShortcutLabel() { - this.mainMenuItem.setAttribute("key", EyedropperButton.shortcut.id); - this.mainMenuItem.removeAttribute("type"); - } - afterLazyStartup() { - Services.obs.notifyObservers( - PanelMultiView.getViewNode(document, "appmenu-developer-tools-view"), - "web-developer-tools-view-showing" - ); - this.makeWidget(); - if (this.mainMenuItem) this.setShortcutLabel(); - else { - this.observer = new MutationObserver(() => { - if (this.devToolsMenu.querySelector("#menu_eyedropper")) { - this.setShortcutLabel(); - this.observer.disconnect(); - delete this.observer; - } - }); - this.observer.observe(this.devToolsMenu, { childList: true }); + shortcut = { + // shortcut key, combined with modifiers. + key: "Y", + + // ctrl + shift or cmd + shift (use accel instead of ctrl, it's + // cross-platform. it can be changed in about:config with ui.key.accelKey. + // if you leave the "" quotes empty, no modifier will be used. that means + // the hotkey will just be "Y" which may be a bad idea — only do that if + // your "key" value is something obscure like a function key, since this key + // will be active at all times and in almost all contexts. + modifiers: "accel shift", + + id: "key_eyedropper", + }; + constructor() { + this.makeBundles(); + this.makeHotkey(); + Services.obs.addObserver(this, "uc-eyedropper-started"); + if (gBrowserInit.delayedStartupFinished) this.afterLazyStartup(); + else Services.obs.addObserver(this, "browser-delayed-startup-finished"); + } + makeBundles() { + this.menuBundle = Services.strings.createBundle("chrome://devtools/locale/menus.properties"); + this.inspectorBundle = Services.strings.createBundle( + "chrome://devtools/locale/inspector.properties" + ); + } + getString(name, where) { + return this[`${where}Bundle`].GetStringFromName(name); + } + // "Eyedropper" + get labelString() { + return this._labelString || (this._labelString = this.getString("eyedropper.label", "menu")); + } + // "Grab a color from the page" + get tooltipString() { + return ( + this._tooltipString || + (this._tooltipString = this.getString("inspector.eyedropper.label", "inspector")) + ); + } + // "Ctrl+Shift+Y" + get shortcutString() { + return ( + this._shortcutString || + (this._shortcutString = this.hotkey ? ` (${ShortcutUtils.prettifyShortcut(this.keyEl)})` : "") + ); + } + // "Grab a color from the page (%S)" + get tooltipWithShortcut() { + return ( + this._tooltipWithShortcut || + (this._tooltipWithShortcut = this.tooltipString + this.shortcutString) + ); + } + get devToolsMenu() { + return ( + this._devToolsMenu || (this._devToolsMenu = document.getElementById("menuWebDeveloperPopup")) + ); + } + get mainMenuItem() { + return ( + this._mainMenuItem || + (this._mainMenuItem = document.getElementById("menu_eyedropper") || null) + ); + } + get keyEl() { + return this._keyEl || (this._keyEl = window[this.shortcut.id]); + } + makeHotkey() { + this.hotkey = _ucUtils.registerHotkey(this.shortcut, (win, key) => { + Services.obs.notifyObservers(win, "uc-eyedropper-started"); + }); + } + makeWidget() { + if (CustomizableUI.getPlacementOfWidget("eyedropper-button", true)) return; + CustomizableUI.createWidget({ + id: "eyedropper-button", + type: "button", + defaultArea: CustomizableUI.AREA_NAVBAR, + label: this.labelString, + tooltiptext: this.tooltipWithShortcut, + localized: false, + onCommand: e => { + Services.obs.notifyObservers(e.view, "uc-eyedropper-started"); + }, + onCreated: aNode => { + aNode.style.listStyleImage = `url(chrome://devtools/skin/images/command-eyedropper.svg)`; + }, + }); + } + setShortcutLabel() { + this.mainMenuItem.setAttribute("key", this.shortcut.id); + this.mainMenuItem.removeAttribute("type"); + } + afterLazyStartup() { + Services.obs.notifyObservers( + PanelMultiView.getViewNode(document, "appmenu-developer-tools-view"), + "web-developer-tools-view-showing" + ); + this.makeWidget(); + if (this.mainMenuItem) this.setShortcutLabel(); + else { + this.observer = new MutationObserver(() => { + if (this.devToolsMenu.querySelector("#menu_eyedropper")) { + this.setShortcutLabel(); + this.observer.disconnect(); + delete this.observer; } + }); + this.observer.observe(this.devToolsMenu, { childList: true }); } - observe(sub, top) { - if (sub === window) - switch (top) { - case "uc-eyedropper-started": - this.mainMenuItem.click(); - break; - case "browser-delayed-startup-finished": - Services.obs.removeObserver(this, top); - this.afterLazyStartup(); - break; - } - } + } + observe(sub, top) { + if (sub === window) + switch (top) { + case "uc-eyedropper-started": + this.mainMenuItem.click(); + break; + case "browser-delayed-startup-finished": + Services.obs.removeObserver(this, top); + this.afterLazyStartup(); + break; + } + } } if (/^chrome:\/\/browser\/content\/browser.(xul||xhtml)$/i.test(location)) new EyedropperButton(); diff --git a/JS/restorePreProtonArrowpanels.uc.js b/JS/restorePreProtonArrowpanels.uc.js index d1cca1b..25d2d1e 100644 --- a/JS/restorePreProtonArrowpanels.uc.js +++ b/JS/restorePreProtonArrowpanels.uc.js @@ -3,383 +3,385 @@ // @version 1.0.3 // @author aminomancer // @homepage https://github.com/aminomancer/uc.css.js -// @description The mother of all proton reversals. Probably my least favorite "feature" of the UI refresh has been the removal of arrows from arrowpanels. I'd call it misguided, except I can't comprehend what guided it in the first place. I mean — they're called arrowpanels, and that should really say it all. The point of arrows is to point at something. In this case, to point at the node to which the panel is anchored. Some might think it's enough that the popup simply be anchored to the node. That might be true for small elements like tooltips. But panels are big enough that they can be lined up with many nodes, especially when they're opened on a toolbar that's full of other buttons. It's not like there aren't other ways to see where the panel is anchored, but why give the user less information? Moreover, don't the arrows look good? This change, more than any of the other ones, feels like change for change's sake. Stripping things down merely for the sake of simplifying them. Anyway, this script will basically restore a bunch of stuff that was removed in Nightly 96.0a1. Arrowpanels' glory days are not behind them, not if I have anything to say about it... +// @description The mother of all proton reversals. This script will +// basically restore the arrows at the corner of panels that point at the +// element to which the panel is anchored. // @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== -// wait for customElements.js to load the scripts. waiting for autocomplete-richlistbox-popup because there's a stupid bug that doesn't let you call whenDefined on an element that doesn't have a dash in its name, unless the code is running in some specific namespace that I have no idea how to make my code run in. +// wait for customElements.js to load the scripts. waiting for +// autocomplete-richlistbox-popup because there's a stupid bug that doesn't let +// you call whenDefined on an element that doesn't have a dash in its name, +// unless the code is running in some specific namespace that I have no idea how +// to make my code run in. (function () { - customElements.whenDefined("autocomplete-richlistbox-popup").then(() => { - let spec = customElements.get("panel"); - - Object.defineProperty(spec, "inheritedAttributes", { - enumerable: true, - get() { - return { - ".panel-arrowcontent": "align,dir,orient,pack", - }; - }, - }); - spec.prototype._setSideAttribute = function (event) { - if (!this.isArrowPanel || !event.isAnchored) { - return; - } - - let container = this.shadowRoot.querySelector(".panel-arrowcontainer"); - if (!container) { - this.shadowRoot.querySelector("slot").replaceWith( - MozXULElement.parseXULToFragment( - ` - - - - - ` - ) - ); - container = this.shadowRoot.querySelector(".panel-arrowcontainer"); - delete this._scrollBox; - delete this.__indicatorBar; - this.initializeAttributeInheritance(); - } - let arrowbox = this.shadowRoot.querySelector(".panel-arrowbox"); - - let arrow = this.shadowRoot.querySelector(".panel-arrow"); - if (arrow) { - arrow.hidden = !event.isAnchored; - this.shadowRoot.querySelector(".panel-arrowbox").style.removeProperty("transform"); - } - - let position = event.alignmentPosition; - let offset = event.alignmentOffset; - - if (position.indexOf("start_") == 0 || position.indexOf("end_") == 0) { - container.setAttribute("orient", "horizontal"); - arrowbox.setAttribute("orient", "vertical"); - if (position.indexOf("_after") > 0) { - arrowbox.setAttribute("pack", "end"); - } else { - arrowbox.setAttribute("pack", "start"); - } - arrowbox.style.transform = "translate(0, " + -offset + "px)"; - - // The assigned side stays the same regardless of direction. - let isRTL = window.getComputedStyle(this).direction == "rtl"; - - if (position.indexOf("start_") == 0) { - container.style.MozBoxDirection = "reverse"; - this.setAttribute("side", isRTL ? "left" : "right"); - } else { - container.style.removeProperty("-moz-box-direction"); - this.setAttribute("side", isRTL ? "right" : "left"); - } - } else if (position.indexOf("before_") == 0 || position.indexOf("after_") == 0) { - container.removeAttribute("orient"); - arrowbox.removeAttribute("orient"); - if (position.indexOf("_end") > 0) { - arrowbox.setAttribute("pack", "end"); - } else { - arrowbox.setAttribute("pack", "start"); - } - arrowbox.style.transform = "translate(" + -offset + "px, 0)"; - - if (position.indexOf("before_") == 0) { - container.style.MozBoxDirection = "reverse"; - this.setAttribute("side", "bottom"); - } else { - container.style.removeProperty("-moz-box-direction"); - this.setAttribute("side", "top"); - } - } - }; - spec.prototype.on_popupshowing = function (event) { - if (event.target == this) { - this.panelContent.style.display = ""; - } - if (this.isArrowPanel && event.target == this) { - if (event.isAnchored && this.anchorNode) { - let anchorRoot = - this.anchorNode.closest("toolbarbutton, .anchor-root") || this.anchorNode; - anchorRoot.setAttribute("open", "true"); - } - - let arrow = this.shadowRoot.querySelector(".panel-arrow"); - if (arrow) { - arrow.hidden = !event.isAnchored; - this.shadowRoot - .querySelector(".panel-arrowbox") - .style.removeProperty("transform"); - } - - if (this.getAttribute("animate") != "false") { - this.setAttribute("animate", "open"); - // the animating attribute prevents user interaction during transition - // it is removed when popupshown fires - this.setAttribute("animating", "true"); - } - - // set fading - let fade = this.getAttribute("fade"); - let fadeDelay = 0; - if (fade == "fast") { - fadeDelay = 1; - } else if (fade == "slow") { - fadeDelay = 4000; - } - - if (fadeDelay != 0) { - this._fadeTimer = setTimeout(() => this.hidePopup(true), fadeDelay, this); - } - } - - // Capture the previous focus before has a chance to get set inside the panel - try { - this._prevFocus = Cu.getWeakReference(document.commandDispatcher.focusedElement); - if (!this._prevFocus.get()) { - this._prevFocus = Cu.getWeakReference(document.activeElement); - return; - } - } catch (ex) { - this._prevFocus = Cu.getWeakReference(document.activeElement); - } - }; - }); - - customElements.whenDefined("places-popup-arrow").then((spec) => { - spec.prototype._setSideAttribute = function (event) { - if (!this.anchorNode) return; - - let container = this.shadowRoot.querySelector(".panel-arrowcontainer"); - let arrow = this.shadowRoot.querySelector(".panel-arrow"); - if (arrow) { - this.shadowRoot.querySelector(".panel-arrowbox").style.removeProperty("transform"); - if (!this.anchorNode) { - arrow.hidden = true; - return; - } else arrow.hidden = false; - } - - if (!container) { - this.shadowRoot.querySelector(":host > hbox").replaceWith( - MozXULElement.parseXULToFragment( - ` - - - - - - - - - - ` - ) - ); - container = this.shadowRoot.querySelector(".panel-arrowcontainer"); - arrow = this.shadowRoot.querySelector(".panel-arrow"); - delete this._scrollBox; - delete this.__indicatorBar; - this.initializeAttributeInheritance(); - } - let arrowbox = this.shadowRoot.querySelector(".panel-arrowbox"); - - let position = event.alignmentPosition; - let offset = event.alignmentOffset; - - // if this panel has a "sliding" arrow, we may have previously set margins... - arrowbox.style.removeProperty("transform"); - if (position.indexOf("start_") == 0 || position.indexOf("end_") == 0) { - container.setAttribute("orient", "horizontal"); - arrowbox.setAttribute("orient", "vertical"); - if (position.indexOf("_after") > 0) { - arrowbox.setAttribute("pack", "end"); - } else { - arrowbox.setAttribute("pack", "start"); - } - arrowbox.style.transform = "translate(0, " + -offset + "px)"; - - // The assigned side stays the same regardless of direction. - let isRTL = this.matches(":-moz-locale-dir(rtl)"); - - if (position.indexOf("start_") == 0) { - container.style.MozBoxDirection = "reverse"; - this.setAttribute("side", isRTL ? "left" : "right"); - } else { - container.style.removeProperty("-moz-box-direction"); - this.setAttribute("side", isRTL ? "right" : "left"); - } - } else if (position.indexOf("before_") == 0 || position.indexOf("after_") == 0) { - container.removeAttribute("orient"); - arrowbox.removeAttribute("orient"); - if (position.indexOf("_end") > 0) { - arrowbox.setAttribute("pack", "end"); - } else { - arrowbox.setAttribute("pack", "start"); - } - arrowbox.style.transform = "translate(" + -offset + "px, 0)"; - - if (position.indexOf("before_") == 0) { - container.style.MozBoxDirection = "reverse"; - this.setAttribute("side", "bottom"); - } else { - container.style.removeProperty("-moz-box-direction"); - this.setAttribute("side", "top"); - } - } - - arrow.hidden = false; + customElements.whenDefined("autocomplete-richlistbox-popup").then(() => { + let spec = customElements.get("panel"); + + Object.defineProperty(spec, "inheritedAttributes", { + enumerable: true, + get() { + return { + ".panel-arrowcontent": "align,dir,orient,pack", }; + }, }); - - function init() { - ConfirmationHint = { - _timerID: null, - - /** - * Shows a transient, non-interactive confirmation hint anchored to an - * element, usually used in response to a user action to reaffirm that it was - * successful and potentially provide extra context. Examples for such hints: - * - "Saved to bookmarks" after bookmarking a page - * - "Sent!" after sending a tab to another device - * - "Queued (offline)" when attempting to send a tab to another device - * while offline - * - * @param anchor (DOM node, required) - * The anchor for the panel. - * @param messageId (string, required) - * For getting the message string from browser.properties: - * confirmationHint..label - * @param options (object, optional) - * An object with the following optional properties: - * - event (DOM event): The event that triggered the feedback. - * - hideArrow (boolean): Optionally hide the arrow. - * - showDescription (boolean): show description text (confirmationHint..description) - * - */ - show(anchor, messageId, options = {}) { - this._reset(); - - this._message.textContent = gBrowserBundle.GetStringFromName( - `confirmationHint.${messageId}.label` - ); - - if (options.showDescription) { - this._description.textContent = gBrowserBundle.GetStringFromName( - `confirmationHint.${messageId}.description` - ); - this._description.hidden = false; - this._panel.classList.add("with-description"); - } else { - this._description.hidden = true; - this._panel.classList.remove("with-description"); - } - - if (options.hideArrow) { - this._panel.setAttribute("hidearrow", "true"); - } - - this._panel.setAttribute("data-message-id", messageId); - - // The timeout value used here allows the panel to stay open for - // 1.5s second after the text transition (duration=120ms) has finished. - // If there is a description, we show for 4s after the text transition. - const DURATION = options.showDescription ? 4000 : 1500; - this._panel.addEventListener( - "popupshown", - () => { - this._animationBox.setAttribute("animate", "true"); - this._timerID = setTimeout(() => { - this._panel.hidePopup(true); - }, DURATION + 120); - }, - { once: true } - ); - - this._panel.addEventListener( - "popuphidden", - () => { - // reset the timerId in case our timeout wasn't the cause of the popup being hidden - this._reset(); - }, - { once: true } - ); - - this._panel.openPopup(anchor, { - position: "bottomcenter topleft", - triggerEvent: options.event, - }); - }, - - _reset() { - if (this._timerID) { - clearTimeout(this._timerID); - this._timerID = null; - } - if (this.__panel) { - this._panel.removeAttribute("hidearrow"); - this._animationBox.removeAttribute("animate"); - this._panel.removeAttribute("data-message-id"); - } - }, - - get _panel() { - this._ensurePanel(); - return this.__panel; - }, - - get _animationBox() { - this._ensurePanel(); - delete this._animationBox; - return (this._animationBox = document.getElementById( - "confirmation-hint-checkmark-animation-container" - )); - }, - - get _message() { - this._ensurePanel(); - delete this._message; - return (this._message = document.getElementById("confirmation-hint-message")); - }, - - get _description() { - this._ensurePanel(); - delete this._description; - return (this._description = document.getElementById( - "confirmation-hint-description" - )); - }, - - _ensurePanel() { - if (!this.__panel) { - let wrapper = document.getElementById("confirmation-hint-wrapper"); - wrapper.replaceWith(wrapper.content); - this.__panel = document.getElementById("confirmation-hint"); - } - }, - }; - } - - let sss = Cc["@mozilla.org/content/style-sheet-service;1"].getService(Ci.nsIStyleSheetService); - let uri = Services.io.newURI( - "data:text/css;charset=UTF=8," + - encodeURIComponent( - `panel[type="arrow"][side="top"],panel[type="arrow"][side="bottom"]{margin-inline:-20px;}panel[type="arrow"][side="left"],panel[type="arrow"][side="right"]{margin-block:-20px;}:is(panel,menupopup)::part(arrow){-moz-context-properties:fill,stroke;fill:var(--arrowpanel-background);stroke:var(--arrowpanel-border-color);}:is(panel,menupopup)[side="top"]::part(arrow),:is(panel,menupopup)[side="bottom"]::part(arrow){list-style-image:url('data:image/svg+xml;utf8,');position:relative;margin-inline:10px;}:is(panel,menupopup)[side="top"]::part(arrow){margin-bottom:-5px;}:is(panel,menupopup)[side="bottom"]::part(arrow){transform:scaleY(-1);margin-top:-5px;}:is(panel,menupopup)[side="left"]::part(arrow),:is(panel,menupopup)[side="right"]::part(arrow){list-style-image:url('data:image/svg+xml;utf8,');position:relative;margin-block:10px;}:is(panel,menupopup)[side="left"]::part(arrow){margin-right:-5px;}:is(panel,menupopup)[side="right"]::part(arrow){transform:scaleX(-1);margin-left:-5px;}#confirmation-hint[hidearrow]::part(arrowbox){visibility:hidden;}` - ) - ); - if (!sss.sheetRegistered(uri, sss.AUTHOR_SHEET)) - sss.loadAndRegisterSheet(uri, sss.AUTHOR_SHEET); - - 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"); - } + spec.prototype._setSideAttribute = function (event) { + if (!this.isArrowPanel || !this.anchorNode) { + return; + } + + let container = this.shadowRoot.querySelector(".panel-arrowcontainer"); + if (!container) { + this.shadowRoot.querySelector("slot").replaceWith( + MozXULElement.parseXULToFragment( + ` + + + + + ` + ) + ); + container = this.shadowRoot.querySelector(".panel-arrowcontainer"); + delete this._scrollBox; + delete this.__indicatorBar; + this.initializeAttributeInheritance(); + } + + let arrowbox = this.shadowRoot.querySelector(".panel-arrowbox"); + + let arrow = this.shadowRoot.querySelector(".panel-arrow"); + if (arrow) { + arrow.hidden = !this.anchorNode; + this.shadowRoot.querySelector(".panel-arrowbox").style.removeProperty("transform"); + } + + let position = event.alignmentPosition; + let offset = event.alignmentOffset; + + if (position.indexOf("start_") == 0 || position.indexOf("end_") == 0) { + container.setAttribute("orient", "horizontal"); + arrowbox.setAttribute("orient", "vertical"); + if (position.indexOf("_after") > 0) { + arrowbox.setAttribute("pack", "end"); + } else { + arrowbox.setAttribute("pack", "start"); + } + arrowbox.style.transform = "translate(0, " + -offset + "px)"; + + // The assigned side stays the same regardless of direction. + let isRTL = window.getComputedStyle(this).direction == "rtl"; + + if (position.indexOf("start_") == 0) { + container.style.MozBoxDirection = "reverse"; + this.setAttribute("side", isRTL ? "left" : "right"); + } else { + container.style.removeProperty("-moz-box-direction"); + this.setAttribute("side", isRTL ? "right" : "left"); + } + } else if (position.indexOf("before_") == 0 || position.indexOf("after_") == 0) { + container.removeAttribute("orient"); + arrowbox.removeAttribute("orient"); + if (position.indexOf("_end") > 0) { + arrowbox.setAttribute("pack", "end"); + } else { + arrowbox.setAttribute("pack", "start"); + } + arrowbox.style.transform = "translate(" + -offset + "px, 0)"; + + if (position.indexOf("before_") == 0) { + container.style.MozBoxDirection = "reverse"; + this.setAttribute("side", "bottom"); + } else { + container.style.removeProperty("-moz-box-direction"); + this.setAttribute("side", "top"); + } + } + }; + spec.prototype.on_popupshowing = function (event) { + if (event.target == this) { + this.panelContent.style.display = ""; + } + if (this.isArrowPanel && event.target == this) { + if (this.anchorNode) { + let anchorRoot = + this.anchorNode.closest("toolbarbutton, .anchor-root") || this.anchorNode; + anchorRoot.setAttribute("open", "true"); + } + + let arrow = this.shadowRoot.querySelector(".panel-arrow"); + if (arrow) { + arrow.hidden = !this.anchorNode; + this.shadowRoot.querySelector(".panel-arrowbox").style.removeProperty("transform"); + } + + if (this.getAttribute("animate") != "false") { + this.setAttribute("animate", "open"); + // the animating attribute prevents user interaction during transition + // it is removed when popupshown fires + this.setAttribute("animating", "true"); + } + + // set fading + let fade = this.getAttribute("fade"); + let fadeDelay = 0; + if (fade == "fast") { + fadeDelay = 1; + } else if (fade == "slow") { + fadeDelay = 4000; + } + + if (fadeDelay != 0) { + this._fadeTimer = setTimeout(() => this.hidePopup(true), fadeDelay, this); + } + } + + // Capture the previous focus before has a chance to get set inside the panel + try { + this._prevFocus = Cu.getWeakReference(document.commandDispatcher.focusedElement); + if (!this._prevFocus.get()) { + this._prevFocus = Cu.getWeakReference(document.activeElement); + return; + } + } catch (ex) { + this._prevFocus = Cu.getWeakReference(document.activeElement); + } + }; + }); + + customElements.whenDefined("places-popup-arrow").then(spec => { + spec.prototype._setSideAttribute = function (event) { + if (!this.anchorNode) return; + + let container = this.shadowRoot.querySelector(".panel-arrowcontainer"); + let arrow = this.shadowRoot.querySelector(".panel-arrow"); + if (arrow) { + this.shadowRoot.querySelector(".panel-arrowbox").style.removeProperty("transform"); + if (!this.anchorNode) { + arrow.hidden = true; + return; + } else arrow.hidden = false; + } + + if (!container) { + this.shadowRoot.querySelector(":host > hbox").replaceWith( + MozXULElement.parseXULToFragment( + ` + + + + + + + + + + ` + ) + ); + container = this.shadowRoot.querySelector(".panel-arrowcontainer"); + arrow = this.shadowRoot.querySelector(".panel-arrow"); + delete this._scrollBox; + delete this.__indicatorBar; + this.initializeAttributeInheritance(); + } + let arrowbox = this.shadowRoot.querySelector(".panel-arrowbox"); + + let position = event.alignmentPosition; + let offset = event.alignmentOffset; + + // if this panel has a "sliding" arrow, we may have previously set margins... + arrowbox.style.removeProperty("transform"); + if (position.indexOf("start_") == 0 || position.indexOf("end_") == 0) { + container.setAttribute("orient", "horizontal"); + arrowbox.setAttribute("orient", "vertical"); + if (position.indexOf("_after") > 0) { + arrowbox.setAttribute("pack", "end"); + } else { + arrowbox.setAttribute("pack", "start"); + } + arrowbox.style.transform = "translate(0, " + -offset + "px)"; + + // The assigned side stays the same regardless of direction. + let isRTL = this.matches(":-moz-locale-dir(rtl)"); + + if (position.indexOf("start_") == 0) { + container.style.MozBoxDirection = "reverse"; + this.setAttribute("side", isRTL ? "left" : "right"); + } else { + container.style.removeProperty("-moz-box-direction"); + this.setAttribute("side", isRTL ? "right" : "left"); + } + } else if (position.indexOf("before_") == 0 || position.indexOf("after_") == 0) { + container.removeAttribute("orient"); + arrowbox.removeAttribute("orient"); + if (position.indexOf("_end") > 0) { + arrowbox.setAttribute("pack", "end"); + } else { + arrowbox.setAttribute("pack", "start"); + } + arrowbox.style.transform = "translate(" + -offset + "px, 0)"; + + if (position.indexOf("before_") == 0) { + container.style.MozBoxDirection = "reverse"; + this.setAttribute("side", "bottom"); + } else { + container.style.removeProperty("-moz-box-direction"); + this.setAttribute("side", "top"); + } + } + + arrow.hidden = false; + }; + }); + + function init() { + ConfirmationHint = { + _timerID: null, + + /** + * Shows a transient, non-interactive confirmation hint anchored to an + * element, usually used in response to a user action to reaffirm that it was + * successful and potentially provide extra context. Examples for such hints: + * - "Saved to bookmarks" after bookmarking a page + * - "Sent!" after sending a tab to another device + * - "Queued (offline)" when attempting to send a tab to another device + * while offline + * + * @param anchor (DOM node, required) + * The anchor for the panel. + * @param messageId (string, required) + * For getting the message string from browser.properties: + * confirmationHint..label + * @param options (object, optional) + * An object with the following optional properties: + * - event (DOM event): The event that triggered the feedback. + * - hideArrow (boolean): Optionally hide the arrow. + * - showDescription (boolean): show description text (confirmationHint..description) + * + */ + show(anchor, messageId, options = {}) { + this._reset(); + + this._message.textContent = gBrowserBundle.GetStringFromName( + `confirmationHint.${messageId}.label` + ); + + if (options.showDescription) { + this._description.textContent = gBrowserBundle.GetStringFromName( + `confirmationHint.${messageId}.description` + ); + this._description.hidden = false; + this._panel.classList.add("with-description"); + } else { + this._description.hidden = true; + this._panel.classList.remove("with-description"); + } + + if (options.hideArrow) { + this._panel.setAttribute("hidearrow", "true"); + } + + this._panel.setAttribute("data-message-id", messageId); + + // The timeout value used here allows the panel to stay open for + // 1.5s second after the text transition (duration=120ms) has finished. + // If there is a description, we show for 4s after the text transition. + const DURATION = options.showDescription ? 4000 : 1500; + this._panel.addEventListener( + "popupshown", + () => { + this._animationBox.setAttribute("animate", "true"); + this._timerID = setTimeout(() => { + this._panel.hidePopup(true); + }, DURATION + 120); + }, + { once: true } + ); + + this._panel.addEventListener( + "popuphidden", + () => { + // reset the timerId in case our timeout wasn't the cause of the popup being hidden + this._reset(); + }, + { once: true } + ); + + this._panel.openPopup(anchor, { + position: "bottomcenter topleft", + triggerEvent: options.event, + }); + }, + + _reset() { + if (this._timerID) { + clearTimeout(this._timerID); + this._timerID = null; + } + if (this.__panel) { + this._panel.removeAttribute("hidearrow"); + this._animationBox.removeAttribute("animate"); + this._panel.removeAttribute("data-message-id"); + } + }, + + get _panel() { + this._ensurePanel(); + return this.__panel; + }, + + get _animationBox() { + this._ensurePanel(); + delete this._animationBox; + return (this._animationBox = document.getElementById( + "confirmation-hint-checkmark-animation-container" + )); + }, + + get _message() { + this._ensurePanel(); + delete this._message; + return (this._message = document.getElementById("confirmation-hint-message")); + }, + + get _description() { + this._ensurePanel(); + delete this._description; + return (this._description = document.getElementById("confirmation-hint-description")); + }, + + _ensurePanel() { + if (!this.__panel) { + let wrapper = document.getElementById("confirmation-hint-wrapper"); + wrapper.replaceWith(wrapper.content); + this.__panel = document.getElementById("confirmation-hint"); + } + }, + }; + } + + let sss = Cc["@mozilla.org/content/style-sheet-service;1"].getService(Ci.nsIStyleSheetService); + let uri = Services.io.newURI( + "data:text/css;charset=UTF=8," + + encodeURIComponent( + `panel[type="arrow"][side="top"],panel[type="arrow"][side="bottom"]{margin-inline:-20px;}panel[type="arrow"][side="left"],panel[type="arrow"][side="right"]{margin-block:-20px;}:is(panel,menupopup)::part(arrow){-moz-context-properties:fill,stroke;fill:var(--arrowpanel-background);stroke:var(--arrowpanel-border-color);}:is(panel,menupopup)[side="top"]::part(arrow),:is(panel,menupopup)[side="bottom"]::part(arrow){list-style-image:url('data:image/svg+xml;utf8,');position:relative;margin-inline:10px;}:is(panel,menupopup)[side="top"]::part(arrow){margin-bottom:-5px;}:is(panel,menupopup)[side="bottom"]::part(arrow){transform:scaleY(-1);margin-top:-5px;}:is(panel,menupopup)[side="left"]::part(arrow),:is(panel,menupopup)[side="right"]::part(arrow){list-style-image:url('data:image/svg+xml;utf8,');position:relative;margin-block:10px;}:is(panel,menupopup)[side="left"]::part(arrow){margin-right:-5px;}:is(panel,menupopup)[side="right"]::part(arrow){transform:scaleX(-1);margin-left:-5px;}#confirmation-hint[hidearrow]::part(arrowbox){visibility:hidden;}` + ) + ); + if (!sss.sheetRegistered(uri, sss.AUTHOR_SHEET)) sss.loadAndRegisterSheet(uri, sss.AUTHOR_SHEET); + + 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"); + } })(); diff --git a/JS/searchModeIndicatorIcons.uc.js b/JS/searchModeIndicatorIcons.uc.js index 1dfba7a..5e6a9b5 100644 --- a/JS/searchModeIndicatorIcons.uc.js +++ b/JS/searchModeIndicatorIcons.uc.js @@ -1,232 +1,291 @@ -// ==UserScript== -// @name Search Mode Indicator Icons -// @version 1.4.1 -// @author aminomancer -// @homepage https://github.com/aminomancer -// @description Automatically replace the urlbar's identity icon with the current search engine's icon. Optionally replace the searchglass icon in regular search mode by dynamically retrieving icons from your chrome/resources/engines/ folder. That means on the new tab page or when typing in the urlbar, instead of showing a searchglass icon it will show a Google icon if your default engine is Google; a Bing icon if your default engine is Bing; etc. Read the comments in the config section below for more details on adding your own engine icons. Also optionally show any engine name in the urlbar placeholder, even if the engine was installed by an addon. By default, Firefox only shows your default engine's name in the placeholder if the engine was built into Firefox. With this script, the placeholder will include the name of your engine. This can be disabled and configured/restricted in the config section below. The main feature (setting the identity icon to match the current engine in one-off search mode) also adds an [engine] attribute to the identity icon so you can customize the icons yourself if you don't like a search engine's icon, or want to adjust its dimensions. If you have google set to "goo" and type in goo then hit spacebar, the identity icon will change to a google icon. And it'll also gain an attribute reflecting that, so you can change its icon further with a CSS rule like: #identity-icon[engine="Tabs"] {list-style-image: url("chrome://browser/skin/tab.svg") !important;} This doesn't change anything about the layout so you may want to tweak some things in your stylesheet. For example I have mine set up so the tracking protection icon disappears while the user is typing in the urlbar, and so a little box appears behind the identity icon while in one-off search mode. This way the icon appears to the left of the label, like it does on about:preferences and other UI pages. -// @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== - -(() => { - // customize the script by adjusting these values: - const config = { - // SECTION: - // when you type into the urlbar or open a new tab, the identity icon (lock icon) - // is set to resemble a magnifying glass. historically my theme has replaced this - // with a google logo, but I realized this is a bit invasive since many firefox users - // avoid google like the plague. however, I dislike the search icon since it makes the urlbar - // harder to distinguish from the searchbar. so I set up this module to dynamically check - // the user's default search engine and look for a matching icon in the chrome/resources/engines/ folder. - // if you use my theme, you'll already have this folder with several engine icons included in it. - // if you don't use my theme, or if my theme doesn't include an icon for your search engine, - // you can add one yourself by placing it in chrome/resources/engines/ - // your icon file must have an SVG, PNG, JPG, or JPEG file extension. use SVG for best visual results. - // if other extensions work that I'm not aware of, make a request on https://github.com/aminomancer/uc.css.js/issues - // name the icon file according to this format: - // engine name: "Google Search" => googlesearch.svg - // engine name: "Google-Search" => google-search.svg - // delete all spaces but don't delete anything else. the engine name refers to whatever name appears - // in the search shortcuts list in about:preferences#search (the table with engine names and keywords). - // engine name is often the same as the name of the extension that created the engine, but not always. - // so always check about:preferences#search to get the actual name of the engine. - // keep in mind this feature is totally separate from the rest of the script, - // which sets the identity icon in *one-off* search mode, e.g., after clicking a one-off search engine button. - // this setting affects the normal search mode. if you set this setting to false, - // it will leave the searchglass as-is in normal mode, but will still set icons in one-off search mode. - // the script achieves this replacement by setting the value of a variable: --default-search-identity-icon - // on its own, this doesn't do anything. if you don't have my theme duskFox, you'll need to add some CSS - // to set the identity icon's URL to var(--default-search-identity-icon) as you normally would to replace an image. - // I do this with the following CSS rule: - // #urlbar[pageproxystate="invalid"] #identity-box #identity-icon, - // #tracking-protection-icon-container[hidden] ~ #identity-box[pageproxystate="valid"].notSecure:not(.chromeUI, .localResource) #identity-icon { - // list-style-image: var(--default-search-identity-icon, url("chrome://userchrome/content/search-glass.svg")) !important; - // } - "Try to replace searchglass icon with engine icon in normal mode": true, - // !SECTION - - // SECTION: - // by default, firefox ONLY shows your default search engine's name in the urlbar placeholder text IF - // the engine is built into firefox, for example, the default Google engine. - // if you switch your engine to an engine from an extension or one you built yourself - // with "Add custom search engine", the placeholder will just say "Search or enter address". - // this setting changes that. it will show the name in the placeholder even if the engine - // was installed by the user. the extra settings below can add some restrictions if you want. - "Show engine name in placeholder": true, - // !SECTION - - // SECTION: - // it's possible for an extension to add an engine with any arbitrary name. - // so if the developer is stupid, they could name an engine "Awesome Amazon Search Extension OMGWTFBBQ" - // and then you could end up with a placeholder that says "Search with Awesome Amazon Search Extension OMGWTFBBQ or enter address". - // I imagine you can see the problem with that. so the following setting will limit the number of characters: - // if the number of characters in the engine's name is above the character limit, it will use the generic placeholder instead. - // I think 25 is a good default limit. if you don't want this limit at all, - // i.e., you're okay with arbitrarily long engine names, set the value to -1 or 0 - "Engine name character limit": 25, - // !SECTION - - // SECTION: - // an engine name might also have an unreasonable number of words (too many spaces). - // again, if the engine name is "Awesome Amazon Search Extension OMGWTFBBQ" it will go over the limit. - // and the script will default to the generic placeholder "Search or enter address". - // increase the word limit by changing the value 3 below. a value of -1 or 0 disables the limit entirely. - "Engine name word limit": 3, - // !SECTION - }; - function init() { - const defaultIcon = `chrome://global/skin/icons/search-glass.svg`; - const searchModeIndicatorFocused = gURLBar._searchModeIndicatorTitle; - const urlbar = gURLBar.textbox; - const identityIcon = gURLBar._identityBox.firstElementChild; - const oneOffs = gURLBar.view.oneOffSearchButtons; - const buttons = oneOffs.buttons; - // use an author sheet to set the identity icon equal to the search engine icon when in search mode - function registerSheet() { - let css = `#urlbar[searchmode=""][pageproxystate="invalid"] #identity-box > #identity-icon-box > #identity-icon, #urlbar[searchmode=""][pageproxystate="valid"] #identity-box > #identity-icon-box > #identity-icon, #urlbar[searchmode=""] #identity-icon-box > #identity-icon, #urlbar[pageproxystate="invalid"] #identity-box > #identity-icon-box[engine] > #identity-icon, #urlbar[pageproxystate="valid"] #identity-box > #identity-icon-box[engine] > #identity-icon, #urlbar #identity-icon-box[engine] > #identity-icon {list-style-image: var(--search-engine-icon, url("${defaultIcon}"));}`; - 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; - sss.loadAndRegisterSheet(uri, sss.AUTHOR_SHEET); - } - window.findEngineIcon = function (name) { - let files = _ucUtils.getFSEntry("engines"); - if (!files) return false; - let nameParts = name - .toLowerCase() - .split(" ") - .filter((word) => word !== "search"); - let joinedName = nameParts.join(""); - if (!joinedName) return false; - let typeRegex = /(.*)(\.svg|\.png|\.jpg|\.jpeg)$/i; - let filename; - while (files.hasMoreElements()) { - let file = files.getNext().QueryInterface(Ci.nsIFile); - let { leafName } = file; - let fileParts = leafName.toLowerCase().match(typeRegex); - if (file.isFile() && fileParts && fileParts[1]) { - if (joinedName === fileParts[1]) { - filename = leafName; - break; - } - if (joinedName.includes(fileParts[1]) || fileParts[1].includes(joinedName)) { - filename = leafName; - } - if (!filename && fileParts[1].includes(nameParts[0])) filename = leafName; - } - } - if (filename) return `url(chrome://userchrome/content/engines/${filename})`; - return false; - }; - function handleDefaultEngine() { - if (config["Try to replace searchglass icon with engine icon in normal mode"]) { - eval( - "BrowserSearch._setURLBarPlaceholder = function " + - BrowserSearch._setURLBarPlaceholder - .toSource() - .replace(/^_setURLBarPlaceholder/, "") - .replace( - /\}$/, - ` let icon = findEngineIcon(name);\n if (icon) document.documentElement.style.setProperty("--default-search-identity-icon", icon);\n else document.documentElement.style.removeProperty("--default-search-identity-icon");\n}` - ) - ); - } - if (config["Show engine name in placeholder"]) { - let placeholderString = `engine`; - if (config["Engine name character limit"] > 0) - placeholderString += ` && engineName.length <= config["Engine name character limit"]`; - if (config["Engine name word limit"] > 0) - placeholderString += ` && engineName.split(" ").length <= config["Engine name word limit"]`; - eval( - "BrowserSearch._updateURLBarPlaceholder = function " + - BrowserSearch._updateURLBarPlaceholder - .toSource() - .replace(/^_updateURLBarPlaceholder/, "") - .replace(/engine\.isAppProvided/, placeholderString) - ); - } - BrowserSearch.initPlaceHolder(); - } - async function searchModeCallback(mus, _observer) { - for (let mu of mus) { - // since we're listening to the whole urlbar, check that the target is one of the things we actually care about. alternatively we could have set more specific nodes to observe and made multiple observers but i think that's clunkier. - if ( - mu.target === searchModeIndicatorFocused || - mu.target === urlbar || - buttons.contains(mu.target) - ) { - // a string representing the current engine - // if the indicator label has any text, use that (this is almost always the case when we're actually in search mode) - let engineStr = searchModeIndicatorFocused.textContent || null; - - // if not, then it's possible we're in switchtab mode, which you may never run into depending on your prefs. if certain prefs are enabled, then you'll occasionally get regular search results telling you to switch tabs. so we'll honor that, but the browser also overrides the action of these results when holding down shift or ctrl. (that's what "actionoverride" represents) so we're going to honor that and only use the Tabs string if we're explicitly in search mode, or if we're in switchtab mode and not holding down a modifier key. for any other case, we just remove the engine attribute, which can be styled by :not([engine]). - let switchTab; - if (!engineStr) - switchTab = - urlbar.getAttribute("actiontype") === "switchtab" && - urlbar.getAttribute("actionoverride") !== "true"; - if (switchTab) engineStr = "Tabs"; - - // now actually set the attribute equal to the temporary string - if (engineStr === null) identityIcon.removeAttribute("engine"); - else identityIcon.setAttribute("engine", engineStr); - - let url; - // in switchtab mode we'll use the tab icon - if (switchTab) url = `chrome://browser/skin/tab.svg`; - // built-in engines don't have icons or engine names, they just have integer sources. - // the icons are defined in browser.css so we'll use those icons. - else if (gURLBar.searchMode?.source) { - let { BOOKMARKS, HISTORY, TABS } = UrlbarUtils.RESULT_SOURCE; - switch (gURLBar.searchMode.source) { - case BOOKMARKS: - url = `chrome://browser/skin/bookmark.svg`; - break; - case HISTORY: - url = `chrome://browser/skin/history.svg`; - break; - case TABS: - url = `chrome://browser/skin/tab.svg`; - break; - } - } - if (!url) { - let engines = await Services.search.getVisibleEngines(); - // set a variable var(--search-engine-icon) equal to the engine's icon, as a fallback if the user doesn't have CSS for the engine. - // we prefer to set the icon with CSS because it allows the user to adjust it and use a better icon than might be included with the engine. - // so use the [engine="engine name"] attribute wherever possible, but the following will handle any situations where you don't have a rule for the engine. - let filterFn = gURLBar.searchMode?.engineName - ? (engine) => engine._name === gURLBar.searchMode?.engineName - : (engine) => engine._name === searchModeIndicatorFocused.textContent; - let engine = engines.find(filterFn); - // use the default icon if there is still no engine. - url = (engine && engine._iconURI?.spec) || defaultIcon; - } - // set a CSS property instead of setting icon directly so user can modify it with userChrome.css - urlbar.style.setProperty("--search-engine-icon", `url("${url}")`); - } - } - } - registerSheet(); - handleDefaultEngine(); - new MutationObserver(searchModeCallback).observe(urlbar, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ["actiontype", "searchmode", "actionoverride"], - }); - } - - 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"); - } -})(); +// ==UserScript== +// @name Search Mode Indicator Icons +// @version 1.4.2 +// @author aminomancer +// @homepage https://github.com/aminomancer +// @description Automatically replace the urlbar's identity icon with the +// current search engine's icon. Optionally replace the searchglass icon in +// regular search mode by dynamically retrieving icons from your +// chrome/resources/engines/ folder. That means on the new tab page or when +// typing in the urlbar, instead of showing a searchglass icon it will show a +// Google icon if your default engine is Google; a Bing icon if your default +// engine is Bing; etc. Read the comments in the config section below for more +// details on adding your own engine icons. Also optionally show any engine name +// in the urlbar placeholder, even if the engine was installed by an addon. By +// default, Firefox only shows your default engine's name in the placeholder if +// the engine was built into Firefox. With this script, the placeholder will +// include the name of your engine. This can be disabled and +// configured/restricted in the config section below. The main feature (setting +// the identity icon to match the current engine in one-off search mode) also +// adds an [engine] attribute to the identity icon so you can customize the +// icons yourself if you don't like a search engine's icon, or want to adjust +// its dimensions. If you have google set to "goo" and type in goo then hit +// spacebar, the identity icon will change to a google icon. And it'll also gain +// an attribute reflecting that, so you can change its icon further with a CSS +// rule like: +// #identity-icon[engine="Tabs"] { +// list-style-image: url("chrome://browser/skin/tab.svg") !important; +// } +// This doesn't change anything about the layout so you may want to tweak some +// things in your stylesheet. For example I have mine set up so the tracking +// protection icon disappears while the user is typing in the urlbar, and so a +// little box appears behind the identity icon while in one-off search mode. +// This way the icon appears to the left of the label, like it does on +// about:preferences and other UI pages. +// @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== + +(() => { + // customize the script by adjusting these values: + const config = { + // when you type into the urlbar or open a new tab, the identity icon + // (lock icon) is set to resemble a magnifying glass. historically my + // theme has replaced this with a google logo, but I realized this is a + // bit invasive since many firefox users avoid google like the plague. + // however, I dislike the search icon since it makes the urlbar harder + // to distinguish from the searchbar. so I set up this module to + // dynamically check the user's default search engine and look for a + // matching icon in the chrome/resources/engines/ folder. if you use my + // theme, you'll already have this folder with several engine icons + // included in it. if you don't use my theme, or if my theme doesn't + // include an icon for your search engine, you can add one yourself by + // placing it in chrome/resources/engines/ your icon file must have an + // SVG, PNG, JPG, or JPEG file extension. use SVG for best visual + // results. if other extensions work that I'm not aware of, make a + // request on https://github.com/aminomancer/uc.css.js/issues + // name the icon file according to this format: + // engine name: "Google Search" => googlesearch.svg + // engine name: "Google-Search" => google-search.svg + // delete all spaces but don't delete anything else. the engine name refers + // to whatever name appears in the search shortcuts list in + // about:preferences#search (the table with engine names and keywords). + // engine name is often the same as the name of the extension that created + // the engine, but not always. so always check about:preferences#search to + // get the actual name of the engine. keep in mind this feature is totally + // separate from the rest of the script, which sets the identity icon in + // *one-off* search mode, e.g., after clicking a one-off search engine + // button. this setting affects the normal search mode. if you set this + // setting to false, it will leave the searchglass as-is in normal mode, but + // will still set icons in one-off search mode. the script achieves this + // replacement by setting the value of a variable: --default-search-identity-icon + // on its own, this doesn't do anything. if you don't have my theme duskFox, + // you'll need to add some CSS to set the identity icon's URL to + // var(--default-search-identity-icon) as you normally would to replace an + // image. I do this with the following CSS rule: + // #urlbar[pageproxystate="invalid"] #identity-box #identity-icon, + // #tracking-protection-icon-container[hidden] + // ~ #identity-box[pageproxystate="valid"].notSecure:not(.chromeUI, .localResource) + // #identity-icon { + // list-style-image: var(--default-search-identity-icon, + // url("chrome://userchrome/content/search-glass.svg")) !important; + // } + "Try to replace searchglass icon with engine icon in normal mode": true, + + // by default, firefox ONLY shows your default search engine's name in the + // urlbar placeholder text IF the engine is built into firefox, for example, + // the default Google engine. if you switch your engine to an engine from an + // extension or one you built yourself with "Add custom search engine", the + // placeholder will just say "Search or enter address". this setting changes + // that. it will show the name in the placeholder even if the engine was + // installed by the user. the extra settings below can add some restrictions + // if you want. + "Show engine name in placeholder": true, + + // it's possible for an extension to add an engine with any arbitrary name. + // so if the developer is stupid, they could name an engine "Awesome Amazon + // Search Extension OMGWTFBBQ" and then you could end up with a placeholder + // that says "Search with Awesome Amazon Search Extension OMGWTFBBQ or enter + // address". I imagine you can see the problem with that. so the following + // setting will limit the number of characters: if the number of characters + // in the engine's name is above the character limit, it will use the + // generic placeholder instead. I think 25 is a good default limit. if you + // don't want this limit at all, i.e., you're okay with arbitrarily long + // engine names, set the value to -1 or 0 + "Engine name character limit": 25, + + // an engine name might also have an unreasonable number of words (too many + // spaces). again, if the engine name is "Awesome Amazon Search Extension + // OMGWTFBBQ" it will go over the limit. and the script will default to the + // generic placeholder "Search or enter address". increase the word limit by + // changing the value 3 below. a value of -1 or 0 disables the limit + // entirely. + "Engine name word limit": 3, + }; + function init() { + const defaultIcon = `chrome://global/skin/icons/search-glass.svg`; + const searchModeIndicatorFocused = gURLBar._searchModeIndicatorTitle; + const urlbar = gURLBar.textbox; + const identityIcon = gURLBar._identityBox.firstElementChild; + const oneOffs = gURLBar.view.oneOffSearchButtons; + const buttons = oneOffs.buttons; + // use an author sheet to set the identity icon equal to the search engine + // icon when in search mode + function registerSheet() { + let css = `#urlbar[searchmode=""][pageproxystate="invalid"] #identity-box > #identity-icon-box > #identity-icon, #urlbar[searchmode=""][pageproxystate="valid"] #identity-box > #identity-icon-box > #identity-icon, #urlbar[searchmode=""] #identity-icon-box > #identity-icon, #urlbar[pageproxystate="invalid"] #identity-box > #identity-icon-box[engine] > #identity-icon, #urlbar[pageproxystate="valid"] #identity-box > #identity-icon-box[engine] > #identity-icon, #urlbar #identity-icon-box[engine] > #identity-icon {list-style-image: var(--search-engine-icon, url("${defaultIcon}"));}`; + 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; + sss.loadAndRegisterSheet(uri, sss.AUTHOR_SHEET); + } + window.findEngineIcon = function (name) { + let files = _ucUtils.getFSEntry("engines"); + if (!files) return false; + let nameParts = name + .toLowerCase() + .split(" ") + .filter(word => word !== "search"); + let joinedName = nameParts.join(""); + if (!joinedName) return false; + let typeRegex = /(.*)(\.svg|\.png|\.jpg|\.jpeg)$/i; + let identical; + let included; + let partIncluded; + while (!identical && files.hasMoreElements()) { + let file = files.getNext().QueryInterface(Ci.nsIFile); + let { leafName } = file; + let fileParts = leafName.toLowerCase().match(typeRegex); + if (file.isFile() && fileParts && fileParts[1]) { + if (joinedName === fileParts[1]) { + identical = leafName; + } else if (joinedName.includes(fileParts[1]) || fileParts[1].includes(joinedName)) { + if (!included) included = leafName; + } else if (fileParts[1].includes(nameParts[0])) { + if (!partIncluded) partIncluded = leafName; + } + } + } + let filename = identical || included || partIncluded; + return filename ? `url(chrome://userchrome/content/engines/${filename})` : false; + }; + function handleDefaultEngine() { + if (config["Try to replace searchglass icon with engine icon in normal mode"]) { + eval( + "BrowserSearch._setURLBarPlaceholder = function " + + BrowserSearch._setURLBarPlaceholder + .toSource() + .replace(/^_setURLBarPlaceholder/, "") + .replace( + /\}$/, + ` let icon = findEngineIcon(name);\n if (icon) document.documentElement.style.setProperty("--default-search-identity-icon", icon);\n else document.documentElement.style.removeProperty("--default-search-identity-icon");\n}` + ) + ); + } + if (config["Show engine name in placeholder"]) { + let placeholderString = `engine`; + if (config["Engine name character limit"] > 0) + placeholderString += ` && engineName.length <= config["Engine name character limit"]`; + if (config["Engine name word limit"] > 0) + placeholderString += ` && engineName.split(" ").length <= config["Engine name word limit"]`; + eval( + "BrowserSearch._updateURLBarPlaceholder = function " + + BrowserSearch._updateURLBarPlaceholder + .toSource() + .replace(/^_updateURLBarPlaceholder/, "") + .replace(/engine\.isAppProvided/, placeholderString) + ); + } + BrowserSearch.initPlaceHolder(); + } + async function searchModeCallback(mus, _observer) { + for (let mu of mus) { + // since we're listening to the whole urlbar, check that the target is + // one of the things we actually care about. alternatively we could have + // set more specific nodes to observe and made multiple observers but i + // think that's clunkier. + if ( + mu.target === searchModeIndicatorFocused || + mu.target === urlbar || + buttons.contains(mu.target) + ) { + // a string representing the current engine if the indicator label has + // any text, use that (this is almost always the case when we're + // actually in search mode) + let engineStr = searchModeIndicatorFocused.textContent || null; + + // if not, then it's possible we're in switchtab mode, which you may + // never run into depending on your prefs. if certain prefs are + // enabled, then you'll occasionally get regular search results + // telling you to switch tabs. so we'll honor that, but the browser + // also overrides the action of these results when holding down shift + // or ctrl. (that's what "actionoverride" represents) so we're going + // to honor that and only use the Tabs string if we're explicitly in + // search mode, or if we're in switchtab mode and not holding down a + // modifier key. for any other case, we just remove the engine + // attribute, which can be styled by :not([engine]). + let switchTab; + if (!engineStr) + switchTab = + urlbar.getAttribute("actiontype") === "switchtab" && + urlbar.getAttribute("actionoverride") !== "true"; + if (switchTab) engineStr = "Tabs"; + + // now actually set the attribute equal to the temporary string + if (engineStr === null) identityIcon.removeAttribute("engine"); + else identityIcon.setAttribute("engine", engineStr); + + let url; + // in switchtab mode we'll use the tab icon + if (switchTab) url = `chrome://browser/skin/tab.svg`; + // built-in engines don't have icons or engine names, they just have + // integer sources. the icons are defined in browser.css so we'll use + // those icons. + else if (gURLBar.searchMode?.source) { + let { BOOKMARKS, HISTORY, TABS } = UrlbarUtils.RESULT_SOURCE; + switch (gURLBar.searchMode.source) { + case BOOKMARKS: + url = `chrome://browser/skin/bookmark.svg`; + break; + case HISTORY: + url = `chrome://browser/skin/history.svg`; + break; + case TABS: + url = `chrome://browser/skin/tab.svg`; + break; + } + } + if (!url) { + let engines = await Services.search.getVisibleEngines(); + // set a variable var(--search-engine-icon) equal to the engine's + // icon, as a fallback if the user doesn't have CSS for the engine. + // we prefer to set the icon with CSS because it allows the user to + // adjust it and use a better icon than might be included with the + // engine. so use the [engine="engine name"] attribute wherever + // possible, but the following will handle situations where you + // don't have a rule for the engine. + let filterFn = gURLBar.searchMode?.engineName + ? engine => engine._name === gURLBar.searchMode?.engineName + : engine => engine._name === searchModeIndicatorFocused.textContent; + let engine = engines.find(filterFn); + // use the default icon if there is still no engine. + url = (engine && engine._iconURI?.spec) || defaultIcon; + } + // set a CSS property instead of setting icon directly so user can + // modify it with userChrome.css + urlbar.style.setProperty("--search-engine-icon", `url("${url}")`); + } + } + } + registerSheet(); + handleDefaultEngine(); + new MutationObserver(searchModeCallback).observe(urlbar, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["actiontype", "searchmode", "actionoverride"], + }); + } + + 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"); + } +})(); diff --git a/JS/verticalTabsPane.uc.js b/JS/verticalTabsPane.uc.js index 9a05a4c..22390b1 100644 --- a/JS/verticalTabsPane.uc.js +++ b/JS/verticalTabsPane.uc.js @@ -32,1629 +32,1681 @@ // ==/UserScript== (function () { - let config = { - // localization strings. change these if your UI is not in english. - l10n: { - "Button label": `Vertical Tabs`, // label and tooltip for the toolbar button - "Button tooltip": `Toggle vertical tabs`, - "Collapse button tooltip": `Collapse pane`, - "Pin button tooltip": `Pin pane`, - // labels for the context menu - context: { - "Move Pane to Right": "Move Pane to Right", - "Move Pane to Left": "Move Pane to Left", - "Expand Pane": "Expand Pane on Hover/Focus", - "Reverse Tab Order": "Reverse Tab Order", - "Configure Hover Delay": "Configure Hover Delay", - "Configure Hover Out Delay": "Configure Hover Out Delay", - }, - // strings for the hover delay config prompt - prompt: { - "Hover delay title": "Hover delay (in milliseconds)", - "Hover delay description": - "How long should the collapsed pane wait before expanding?", - "Hover out delay title": "Hover out delay (in milliseconds)", - "Hover out delay description": - "How long should the expanded pane wait before collapsing?", - "Invalid": "Invalid input!", - "Invalid description": "This preference must be a positive integer.", - }, - }, - // settings for the hotkey - hotkey: { - enabled: true, // set to false if you don't want any hotkey - modifiers: "accel alt", // 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. - key: "V", // the actual key. valid keys are letters, the hyphen key - and F1-F12. digits and F13-F24 are not supported by firefox. - }, - }; - 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 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", + let config = { + // localization strings. change these if your UI is not in english. + l10n: { + "Button label": `Vertical Tabs`, + "Button tooltip": `Toggle vertical tabs`, + "Collapse button tooltip": `Collapse pane`, + "Pin button tooltip": `Pin pane`, + + // labels for the context menu + context: { + "Move Pane to Right": "Move Pane to Right", + "Move Pane to Left": "Move Pane to Left", + "Expand Pane": "Expand Pane on Hover/Focus", + "Reverse Tab Order": "Reverse Tab Order", + "Configure Hover Delay": "Configure Hover Delay", + "Configure Hover Out Delay": "Configure Hover Out Delay", + }, + + // strings for the hover delay config prompt + prompt: { + "Hover delay title": "Hover delay (in milliseconds)", + "Hover delay description": "How long should the collapsed pane wait before expanding?", + "Hover out delay title": "Hover out delay (in milliseconds)", + "Hover out delay description": "How long should the expanded pane wait before collapsing?", + "Invalid": "Invalid input!", + "Invalid description": "This preference must be a positive integer.", + }, + }, + // 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 alt", + + // the actual key. valid keys are letters, the hyphen key - and F1-F12. + // digits and F13-F24 are not supported by firefox.F + key: "V", + }, + }; + 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 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 }, ]; + constructor() { + this._registerSheet(); + // ensure E10SUtils are available. required for showing tab's process ID + // in its tooltip, if the pref for that is enabled. + if (window.E10SUtils) { + this.E10SUtils = window.E10SUtils; + } else { + 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.pane.removeAttribute("unpinned") : 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", + }) + ); + // 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 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); + if (!this._hoverDelay) this._hoverDelay = 100; + if (!this._hoverOutDelay) this._hoverOutDelay = 100; + // 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) + if (!sourceWindow.closed && sourceWindow.location.protocol == "chrome:") + if (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.indexOf(menu) === -1) 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, + }; + } /** - * 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 + * 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 element from the vanilla tab bar) + * @returns {boolean} false if the tab should be excluded from the pane */ - 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]); + _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.pane.removeAttribute("unpinned"); + 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; + } + /** + * 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; + } + 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(); + else + try { + prefSvc.setIntPref(pref, int); + } catch (e) { + return onFail(); } - 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 + * 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) */ - function setAttributes(el, attrs) { - for (let [name, value] of Object.entries(attrs)) - if (value) el.setAttribute(name, value); - else el.removeAttribute(name); - } - class VerticalTabsPaneBase { - static 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 }, - ]; - constructor() { - this._preferences = VerticalTabsPaneBase.preferences; - this._registerSheet(); - // ensure E10SUtils are available. required for showing tab's process ID in its tooltip, if the pref for that is enabled. - if (!window.E10SUtils) - XPCOMUtils.defineLazyModuleGetters(this, { - E10SUtils: "resource://gre/modules/E10SUtils.jsm", - }); - else this.E10SUtils = window.E10SUtils; - // 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.pane.removeAttribute("unpinned") - : 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", - }) - ); - // 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 element from the built-in tab bar) + 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._warmupRowTab(e, tab); + 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.pane.removeAttribute("unpinned"); + 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._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 vertical tabs pane. - XPCOMUtils.defineLazyPreferenceGetter( - SidebarUI, - "_positionStart", - SidebarUI.POSITION_START_PREF, - true, - SidebarUI.setPosition.bind(SidebarUI) + 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 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);` ); - // destroy the scrollbuttons. - ["#scrollbutton-up", "#scrollbutton-down"].forEach((id) => - this._arrowscrollbox.shadowRoot.querySelector(id).remove() + } else { + menuitem.label = config.l10n.context["Move Pane to Right"]; + menuitem.setAttribute( + "oncommand", + `Services.prefs.setBoolPref(SidebarUI.POSITION_START_PREF, true);` ); - 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); - if (!this._hoverDelay) this._hoverDelay = 100; - if (!this._hoverOutDelay) this._hoverOutDelay = 100; - // 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) - if (!sourceWindow.closed && sourceWindow.location.protocol == "chrome:") - if (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.indexOf(menu) === -1) 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. - // imo this is a good reason to bring document.popupNode back, but I don't have any power over that. - 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 element from the vanilla tab bar) - * @returns {boolean} false if the tab should be excluded from the vertical tabs 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 preferences. - */ - _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.pane.removeAttribute("unpinned"); - 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; - } - /** - * 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; - } - 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(); - else - 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._warmupRowTab(e, tab); - 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 object with nsIPrefBranch interface — 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. when a preference is changed, do something about it. - * @param {object} sub (an object with nsIPrefBranch interface — 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.pane.removeAttribute("unpinned"); - 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 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._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); - 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, false); - 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, false); - 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 in property "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.addEventListener("command", this); - row.addEventListener("mousedown", this); - row.addEventListener("mouseup", this); - row.addEventListener("click", this); - row.addEventListener("mouseover", this); - this.tabToElement.set(tab, row); + } + 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._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); + 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, false); + 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, false); + 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.addEventListener("command", this); + row.addEventListener("mousedown", this); + row.addEventListener("mouseup", this); + row.addEventListener("click", this); + row.addEventListener("mouseover", 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; + // 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; + // 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; + // 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; + // 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's display - * @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"); - } + 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"), - }); + // 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); + 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're navigating with left/right or up/down) - */ - 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 (this._noExpand) return this.pane.removeAttribute("expanded"); // if the pane is set to not expand, forget about all this. - // 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); - e.preventDefault(); - } else if (accelKey) { - if (tab.multiselected) gBrowser.removeFromMultiSelectedTabs(tab); - else if (tab != gBrowser.selectedTab) { - gBrowser.addToMultiSelectedTabs(tab); - gBrowser.lastMultiSelectedTab = tab; - } - e.preventDefault(); - } 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(); - } - } - } - // 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; - } - let accelKey = AppConstants.platform == "macosx" ? e.metaKey : e.ctrlKey; - if (e.shiftKey || accelKey || 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.25).toFixed(2) + "s" - ); - if (this.pane.matches(":hover, :focus-within") && !this._noExpand) - this.pane.setAttribute("expanded", true); - this.pane.setAttribute("unpinned", 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 || e.target.classList.contains("all-tabs-secondary-button")) 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); + // 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); + e.preventDefault(); + } else if (accelKey) { + if (tab.multiselected) gBrowser.removeFromMultiSelectedTabs(tab); + else if (tab != gBrowser.selectedTab) { + gBrowser.addToMultiSelectedTabs(tab); + gBrowser.lastMultiSelectedTab = tab; + } + e.preventDefault(); + } 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(); + } + } + } + // 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; + } + let accelKey = AppConstants.platform == "macosx" ? e.metaKey : e.ctrlKey; + if (e.shiftKey || accelKey || 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.25).toFixed(2) + "s" + ); + if (this.pane.matches(":hover, :focus-within") && !this._noExpand) + this.pane.setAttribute("expanded", true); + this.pane.setAttribute("unpinned", 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 || e.target.classList.contains("all-tabs-secondary-button")) 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; + 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; + if (!dt.types.includes("all-tabs-item") || !row) return; - let draggedTab = dt.mozGetDataAt("all-tabs-item", 0); - let movingTabs = draggedTab._dragData.movingTabs; + 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; - } + if (!movingTabs || dt.mozUserCancelled || dt.dropEffect === "none" || tabBar._isCustomizing) { + delete draggedTab._dragData; + return; + } - tabBar._finishGroupSelectedTabs(draggedTab); + 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 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 - _warmupRowTab(e, tab) { - let row = this._findRow(e.target); - SessionStore.speculativeConnectOnTabHover(tab); - if (row.closeButton.matches(":hover")) tab = gBrowser._findTabToBlurTo(tab); - gBrowser.warmupTab(tab); - } - // 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; - let align = true; // should we align to the tab or to the mouse? depends on which element was hovered. - 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) { - label += " (pid " + contentPid + ")"; - if (gFissionBrowser) { - label += " [F"; - if (framePids.length) label += " " + framePids.join(", "); - label += "]"; - } - } - 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) - label += ` (${this._fluentStrings[ - tab.hasAttribute("activemedia-blocked") - ? "blockedString" - : linkedBrowser.audioMuted - ? "mutedString" - : "playingString" - ].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"); - let url = e.target.querySelector(".places-tooltip-uri"); - let icon = e.target.querySelector("#places-tooltip-insecure-icon"); - title.textContent = label; - url.value = linkedBrowser?.currentURI?.spec.replace(/^https:\/\//, ""); - // show a lock icon to show tab security/encryption - 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; - } + 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 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 + _warmupRowTab(e, tab) { + let row = this._findRow(e.target); + SessionStore.speculativeConnectOnTabHover(tab); + if (row.closeButton.matches(":hover")) tab = gBrowser._findTabToBlurTo(tab); + gBrowser.warmupTab(tab); + } + // 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) { + label += " (pid " + contentPid + ")"; + if (gFissionBrowser) { + label += " [F"; + if (framePids.length) label += " " + framePids.join(", "); + label += "]"; + } } - 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); - let parent = this._newTabButton; - 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"); + 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) + label += ` (${this._fluentStrings[ + tab.hasAttribute("activemedia-blocked") + ? "blockedString" + : linkedBrowser.audioMuted + ? "mutedString" + : "playingString" + ].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"); + let url = e.target.querySelector(".places-tooltip-uri"); + let icon = e.target.querySelector("#places-tooltip-insecure-icon"); + title.textContent = label; + url.value = linkedBrowser?.currentURI?.spec.replace(/^https:\/\//, ""); + // show a lock icon to show tab security/encryption + 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; } - gDynamicTooltipCache.delete(parent.id); - if (containersEnabled && !newTabLeftClickOpensContainersMenu) { - gClickAndHoldListenersOnElement.add(parent); - } else { - gClickAndHoldListenersOnElement.remove(parent); + if (docURI.filePath == "blocked") { + icon.setAttribute("type", "blocked-page"); + icon.hidden = false; + return; } - } - // load our stylesheet as an author sheet. override it with userChrome.css and !important rules. - _registerSheet() { - let css = /* css */ ` + 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); + let parent = this._newTabButton; + 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); + } + } + // load our stylesheet as an author sheet. override it with userChrome.css + // and !important rules. + _registerSheet() { + let css = /* css */ ` #vertical-tabs-pane { --vertical-tabs-padding: 4px; --collapsed-pane-width: calc( @@ -2146,210 +2198,209 @@ #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); - } - } + 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); } - - // 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 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 - )})`; - } - }, + // 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"]);` + ) + ); + } - // 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") + // 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 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 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, - }) + // 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") ); - makeWidget(); + // 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, + }) + ); - // 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(); - }; + makeWidget(); - // 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"); + // 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"); + } })(); diff --git a/resources/in-content/site-mozilla.css b/resources/in-content/site-mozilla.css index bf7b37f..f148f68 100644 --- a/resources/in-content/site-mozilla.css +++ b/resources/in-content/site-mozilla.css @@ -4,7 +4,23 @@ * 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. */ -/* CSS styles fixes for various mozilla sites */ +/* CSS style adjustments for various mozilla sites */ + +@-moz-document domain("bugzilla.mozilla.org") { + :root { + --font-family-monospace: Fira Code UC, Fira Code, SF Mono, Menlo, Consolas, Monaco, + -moz-fixed, monospace !important; + --font-weight-monospace: 300 !important; + } + + xmp, + pre, + code, + plaintext, + kbd { + font-weight: var(--font-weight-monospace, inherit); + } +} @-moz-document domain("firefox-source-docs.mozilla.org") { /* make search highlight color match the selection color */ diff --git a/uc-navbar.css b/uc-navbar.css index c6d59f9..06bb2a0 100644 --- a/uc-navbar.css +++ b/uc-navbar.css @@ -13,11 +13,6 @@ if it's visible in the row beneath the tabs then it's probably in here. */ border: none !important; } -/* fix a silly bug */ -#mainPopupSet { - height: 0; -} - toolbarpaletteitem > toolbarspring { margin-block: calc((var(--urlbar-container-height) - var(--urlbar-height)) / 2) !important; outline: none !important; @@ -33,6 +28,11 @@ toolbarpaletteitem[place="palette"] > toolbarspring { margin-block: 5px !important; } +#PersonalToolbar > toolbarpaletteitem > toolbarspring { + margin-block: revert !important; + margin-inline: 2px !important; +} + #wrapper-urlbar-container + toolbarpaletteitem > #search-container, #nav-bar[customizing="true"] #urlbar-container + #search-container, #wrapper-search-container + toolbarpaletteitem > #urlbar-container, @@ -365,7 +365,6 @@ toolbarpaletteitem[place="toolbar"]:not([mousedown="true"]):focus-visible { visibility: visible !important; padding-inline: 2px !important; min-height: var(--urlbar-height) !important; - z-index: 3 !important; transition: min-height 200ms ease-in-out, max-height 200ms ease-in-out, opacity 200ms ease-in-out, transform 200ms ease-in-out !important; overflow-y: visible !important; @@ -373,6 +372,15 @@ toolbarpaletteitem[place="toolbar"]:not([mousedown="true"]):focus-visible { outline: none !important; } +#PersonalToolbar > * { + transition: opacity 200ms ease-in-out, transform 200ms ease-in-out !important; + overflow-y: visible !important; +} + +#PersonalToolbar > toolbarpaletteitem:not([notransition])[place="toolbar"] { + transition: opacity 200ms ease-in-out, transform 200ms ease-in-out, border-width var(--drag-drop-transition-duration) ease-in-out !important; +} + #PersonalToolbar[customizing][draggingover] { z-index: 4 !important; } @@ -387,6 +395,13 @@ toolbarpaletteitem[place="toolbar"]:not([mousedown="true"]):focus-visible { overflow-y: clip !important; min-height: 0 !important; max-height: 0 !important; +} + +:root[inFullscreen]:not([macOSNativeFullscreen], [customizing]) + #PersonalToolbar:not([fullscreentoolbar="true"]) + > *, +#PersonalToolbar[collapsed="true"] > * { + overflow-y: clip !important; opacity: 0 !important; transform: translateY(-16px); }