/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ var EXPORTED_SYMBOLS = ["TouchBarHelper", "TouchBarInput"]; const { XPCOMUtils } = ChromeUtils.importESModule( "resource://gre/modules/XPCOMUtils.sys.mjs" ); const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", }); XPCOMUtils.defineLazyModuleGetters(lazy, { BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", }); XPCOMUtils.defineLazyServiceGetter( lazy, "touchBarUpdater", "@mozilla.org/widget/touchbarupdater;1", "nsITouchBarUpdater" ); // For accessing TouchBarHelper methods from static contexts in this file. XPCOMUtils.defineLazyServiceGetter( lazy, "touchBarHelper", "@mozilla.org/widget/touchbarhelper;1", "nsITouchBarHelper" ); /** * Executes a XUL command on the top window. Called by the callbacks in each * TouchBarInput. * @param {string} commandName * A XUL command. */ function execCommand(commandName) { if (!TouchBarHelper.window) { return; } let command = TouchBarHelper.window.document.getElementById(commandName); if (command) { command.doCommand(); } } /** * Static helper function to convert a hexadecimal string to its integer * value. Used to convert colours to a format accepted by Apple's NSColor code. * @param {string} hexString * A hexadecimal string, optionally beginning with '#'. */ function hexToInt(hexString) { if (!hexString) { return null; } if (hexString.charAt(0) == "#") { hexString = hexString.slice(1); } let val = parseInt(hexString, 16); return isNaN(val) ? null : val; } const kInputTypes = { BUTTON: "button", LABEL: "label", MAIN_BUTTON: "mainButton", POPOVER: "popover", SCROLLVIEW: "scrollView", SCRUBBER: "scrubber", }; /** * An object containing all implemented TouchBarInput objects. */ var gBuiltInInputs = { Back: { title: "back", image: "chrome://browser/skin/back.svg", type: kInputTypes.BUTTON, callback: () => execCommand("Browser:Back"), }, Forward: { title: "forward", image: "chrome://browser/skin/forward.svg", type: kInputTypes.BUTTON, callback: () => execCommand("Browser:Forward"), }, Reload: { title: "reload", image: "chrome://global/skin/icons/reload.svg", type: kInputTypes.BUTTON, callback: () => execCommand("Browser:Reload"), }, Home: { title: "home", image: "chrome://browser/skin/home.svg", type: kInputTypes.BUTTON, callback: () => { let win = lazy.BrowserWindowTracker.getTopWindow(); win.BrowserHome(); }, }, Fullscreen: { title: "fullscreen", image: "chrome://browser/skin/fullscreen.svg", type: kInputTypes.BUTTON, callback: () => execCommand("View:FullScreen"), }, Find: { title: "find", image: "chrome://global/skin/icons/search-glass.svg", type: kInputTypes.BUTTON, callback: () => execCommand("cmd_find"), }, NewTab: { title: "new-tab", image: "chrome://global/skin/icons/plus.svg", type: kInputTypes.BUTTON, callback: () => execCommand("cmd_newNavigatorTabNoEvent"), }, Sidebar: { title: "open-sidebar", image: "chrome://browser/skin/sidebars.svg", type: kInputTypes.BUTTON, callback: () => { let win = lazy.BrowserWindowTracker.getTopWindow(); win.SidebarUI.toggle(); }, }, AddBookmark: { title: "add-bookmark", image: "chrome://browser/skin/bookmark-hollow.svg", type: kInputTypes.BUTTON, callback: () => execCommand("Browser:AddBookmarkAs"), }, ReaderView: { title: "reader-view", image: "chrome://browser/skin/reader-mode.svg", type: kInputTypes.BUTTON, callback: () => execCommand("View:ReaderView"), disabled: true, // Updated when the page is found to be Reader View-able. }, OpenLocation: { key: "open-location", title: "open-location", image: "chrome://global/skin/icons/search-glass.svg", type: kInputTypes.MAIN_BUTTON, callback: () => execCommand("Browser:OpenLocation"), }, // This is a special-case `type: kInputTypes.SCRUBBER` element. // Scrubbers are not yet generally implemented. // See follow-up bug 1502539. Share: { title: "share", image: "chrome://browser/skin/share.svg", type: kInputTypes.SCRUBBER, callback: () => execCommand("cmd_share"), }, SearchPopover: { title: "search-popover", image: "chrome://global/skin/icons/search-glass.svg", type: kInputTypes.POPOVER, children: { SearchScrollViewLabel: { title: "search-search-in", type: kInputTypes.LABEL, }, SearchScrollView: { key: "search-scrollview", type: kInputTypes.SCROLLVIEW, children: { Bookmarks: { title: "search-bookmarks", type: kInputTypes.BUTTON, callback: () => lazy.touchBarHelper.insertRestrictionInUrlbar( lazy.UrlbarTokenizer.RESTRICT.BOOKMARK ), }, OpenTabs: { title: "search-opentabs", type: kInputTypes.BUTTON, callback: () => lazy.touchBarHelper.insertRestrictionInUrlbar( lazy.UrlbarTokenizer.RESTRICT.OPENPAGE ), }, History: { title: "search-history", type: kInputTypes.BUTTON, callback: () => lazy.touchBarHelper.insertRestrictionInUrlbar( lazy.UrlbarTokenizer.RESTRICT.HISTORY ), }, Tags: { title: "search-tags", type: kInputTypes.BUTTON, callback: () => lazy.touchBarHelper.insertRestrictionInUrlbar( lazy.UrlbarTokenizer.RESTRICT.TAG ), }, }, }, }, }, }; // We create a new flat object to cache strings. Since gBuiltInInputs is a // tree, caching/retrieval of localized strings would otherwise require tree // traversal. var localizedStrings = {}; const kHelperObservers = new Set([ "bookmark-icon-updated", "fullscreen-painted", "reader-mode-available", "touchbar-location-change", "quit-application", "intl:app-locales-changed", "urlbar-focus", "urlbar-blur", ]); /** * JS-implemented TouchBarHelper class. * Provides services to the Mac Touch Bar. */ class TouchBarHelper { constructor() { for (let topic of kHelperObservers) { Services.obs.addObserver(this, topic); } // We cache our search popover since otherwise it is frequently // created/destroyed for the urlbar-focus/blur events. this._searchPopover = this.getTouchBarInput("SearchPopover"); this._inputsNotUpdated = new Set(); } destructor() { this._searchPopover = null; for (let topic of kHelperObservers) { Services.obs.removeObserver(this, topic); } } get activeTitle() { if (!TouchBarHelper.window) { return ""; } let tabbrowser = TouchBarHelper.window.ownerGlobal.gBrowser; let activeTitle; if (tabbrowser) { activeTitle = tabbrowser.selectedBrowser.contentTitle; } return activeTitle; } get allItems() { let layoutItems = Cc["@mozilla.org/array;1"].createInstance( Ci.nsIMutableArray ); let window = TouchBarHelper.window; if ( !window || !window.isChromeWindow || window.document.documentElement.getAttribute("windowtype") != "navigator:browser" ) { return layoutItems; } // Every input must be updated at least once so that all assets (titles, // icons) are loaded. We keep track of which inputs haven't updated and // run an update on them ASAP. this._inputsNotUpdated.clear(); for (let inputName of Object.keys(gBuiltInInputs)) { let input = this.getTouchBarInput(inputName); if (!input) { continue; } this._inputsNotUpdated.add(inputName); layoutItems.appendElement(input); } return layoutItems; } static get window() { return lazy.BrowserWindowTracker.getTopWindow(); } get document() { if (!TouchBarHelper.window) { return null; } return TouchBarHelper.window.document; } get isUrlbarFocused() { if (!TouchBarHelper.window || !TouchBarHelper.window.gURLBar) { return false; } return TouchBarHelper.window.gURLBar.focused; } static get baseWindow() { return TouchBarHelper.window ? TouchBarHelper.window.docShell.treeOwner.QueryInterface( Ci.nsIBaseWindow ) : null; } getTouchBarInput(inputName) { if (inputName == "SearchPopover" && this._searchPopover) { return this._searchPopover; } if (!inputName || !gBuiltInInputs.hasOwnProperty(inputName)) { return null; } let inputData = gBuiltInInputs[inputName]; let item = new TouchBarInput(inputData); // Skip localization if there is already a cached localized title or if // no title is needed. if ( !inputData.hasOwnProperty("title") || localizedStrings[inputData.title] ) { return item; } // Async l10n fills in the localized input labels after the initial load. this._l10n.formatValue(inputData.title).then(result => { item.title = result; localizedStrings[inputData.title] = result; // Cache result. // Checking TouchBarHelper.window since this callback can fire after all windows are closed. if (TouchBarHelper.window) { if (this._inputsNotUpdated) { this._inputsNotUpdated.delete(inputName); } lazy.touchBarUpdater.updateTouchBarInputs(TouchBarHelper.baseWindow, [ item, ]); } }); return item; } /** * Fetches a specific Touch Bar Input by name and updates it on the Touch Bar. * @param {...*} inputNames * A key/keys to a value/values in the gBuiltInInputs object in this file. */ _updateTouchBarInputs(...inputNames) { if (!TouchBarHelper.window || !inputNames.length) { return; } let inputs = []; for (let inputName of new Set([...inputNames, ...this._inputsNotUpdated])) { let input = this.getTouchBarInput(inputName); if (!input) { continue; } this._inputsNotUpdated.delete(inputName); inputs.push(input); } lazy.touchBarUpdater.updateTouchBarInputs( TouchBarHelper.baseWindow, inputs ); } /** * Inserts a restriction token into the Urlbar ahead of the current typed * search term. * @param {string} restrictionToken * The restriction token to be inserted into the Urlbar. Preferably * sourced from UrlbarTokenizer.RESTRICT. */ insertRestrictionInUrlbar(restrictionToken) { if (!TouchBarHelper.window) { return; } let searchString = ""; if ( TouchBarHelper.window.gURLBar.getAttribute("pageproxystate") != "valid" ) { searchString = TouchBarHelper.window.gURLBar.lastSearchString.trimStart(); if ( Object.values(lazy.UrlbarTokenizer.RESTRICT).includes(searchString[0]) ) { searchString = searchString.substring(1).trimStart(); } } TouchBarHelper.window.gURLBar.search( `${restrictionToken} ${searchString}`, { searchModeEntry: "touchbar" } ); } observe(subject, topic, data) { switch (topic) { case "touchbar-location-change": let updatedInputs = ["Back", "Forward"]; gBuiltInInputs.Back.disabled = !TouchBarHelper.window.gBrowser .canGoBack; gBuiltInInputs.Forward.disabled = !TouchBarHelper.window.gBrowser .canGoForward; if (subject.QueryInterface(Ci.nsIWebProgress)?.isTopLevel) { this.activeUrl = data; // ReaderView button is disabled on every toplevel location change // since Reader View must determine if the new page can be Reader // Viewed. updatedInputs.push("ReaderView"); gBuiltInInputs.ReaderView.disabled = !data.startsWith("about:reader"); } this._updateTouchBarInputs(...updatedInputs); break; case "fullscreen-painted": if (TouchBarHelper.window.document.fullscreenElement) { gBuiltInInputs.OpenLocation.title = "touchbar-fullscreen-exit"; gBuiltInInputs.OpenLocation.image = "chrome://browser/skin/fullscreen-exit.svg"; gBuiltInInputs.OpenLocation.callback = () => { TouchBarHelper.window.windowUtils.exitFullscreen(); }; } else { gBuiltInInputs.OpenLocation.title = "open-location"; gBuiltInInputs.OpenLocation.image = "chrome://global/skin/icons/search-glass.svg"; gBuiltInInputs.OpenLocation.callback = () => execCommand("Browser:OpenLocation", "OpenLocation"); } this._updateTouchBarInputs("OpenLocation"); break; case "bookmark-icon-updated": gBuiltInInputs.AddBookmark.image = data == "starred" ? "chrome://browser/skin/bookmark.svg" : "chrome://browser/skin/bookmark-hollow.svg"; this._updateTouchBarInputs("AddBookmark"); break; case "reader-mode-available": gBuiltInInputs.ReaderView.disabled = false; this._updateTouchBarInputs("ReaderView"); break; case "urlbar-focus": if (!this._searchPopover) { this._searchPopover = this.getTouchBarInput("SearchPopover"); } lazy.touchBarUpdater.showPopover( TouchBarHelper.baseWindow, this._searchPopover, true ); break; case "urlbar-blur": if (!this._searchPopover) { this._searchPopover = this.getTouchBarInput("SearchPopover"); } lazy.touchBarUpdater.showPopover( TouchBarHelper.baseWindow, this._searchPopover, false ); break; case "intl:app-locales-changed": this._searchPopover = null; localizedStrings = {}; // This event can fire before this._l10n updates to switch languages, // so all the new translations are in the old language. To avoid this, // we need to reinitialize this._l10n. this._l10n = new Localization(["browser/touchbar/touchbar.ftl"]); helperProto._l10n = this._l10n; this._updateTouchBarInputs(...Object.keys(gBuiltInInputs)); break; case "quit-application": this.destructor(); break; } } } const helperProto = TouchBarHelper.prototype; helperProto.QueryInterface = ChromeUtils.generateQI(["nsITouchBarHelper"]); helperProto._l10n = new Localization(["browser/touchbar/touchbar.ftl"]); /** * A representation of a Touch Bar input. * @param {object} input * An object representing a Touch Bar Input. * Contains listed properties. * @param {string} input.title * The lookup key for the button's localized text title. * @param {string} input.image * A URL pointing to an SVG internal to Firefox. * @param {string} input.type * The type of Touch Bar input represented by the object. * Must be a value from kInputTypes. * @param {Function} input.callback * A callback invoked when a touchbar item is touched. * @param {string} [input.color] * A string in hex format specifying the button's background color. * If omitted, the default background color is used. * @param {bool} [input.disabled] * If `true`, the Touch Bar input is greyed out and inoperable. * @param {Array} [input.children] * An array of input objects that will be displayed as children of * this input. Available only for types KInputTypes.POPOVER and * kInputTypes.SCROLLVIEW. */ class TouchBarInput { constructor(input) { this._key = input.key || input.title; this._title = localizedStrings[input.title] || ""; this._image = input.image; this._type = input.type; this._callback = input.callback; this._color = hexToInt(input.color); this._disabled = input.hasOwnProperty("disabled") ? input.disabled : false; if (input.children) { this._children = []; let toLocalize = []; for (let childData of Object.values(input.children)) { let initializedChild = new TouchBarInput(childData); if (!initializedChild) { continue; } // Children's types are prepended by the parent's type. This is so we // can uniquely identify a child input from a standalone input with // the same name. (e.g. a button called "back" in a popover would be a // "popover-button.back" vs. a "button.back"). initializedChild.type = input.type + "-" + initializedChild.type; this._children.push(initializedChild); // Skip l10n for inputs without a title or those already localized. if (childData.title && !localizedStrings[childData.title]) { toLocalize.push(initializedChild); } } this._localizeChildren(toLocalize); } } get key() { return this._key; } get title() { return this._title; } set title(title) { this._title = title; } get image() { return this._image ? Services.io.newURI(this._image) : null; } set image(image) { this._image = image; } get type() { return this._type == "" ? "button" : this._type; } set type(type) { this._type = type; } get callback() { return this._callback; } set callback(callback) { this._callback = callback; } get color() { return this._color; } set color(color) { this._color = this.hexToInt(color); } get disabled() { return this._disabled || false; } set disabled(disabled) { this._disabled = disabled; } get children() { if (!this._children) { return null; } let children = Cc["@mozilla.org/array;1"].createInstance( Ci.nsIMutableArray ); for (let child of this._children) { children.appendElement(child); } return children; } /** * Apply Fluent l10n to child inputs. * @param {Array} children * An array of initialized TouchBarInputs. */ async _localizeChildren(children) { if (!children || !children.length) { return; } let titles = await helperProto._l10n.formatValues( children.map(child => ({ id: child.key })) ); // In the TouchBarInput constuctor, we filtered so children contains only // those inputs with titles to be localized. We can be confident that the // results in titles match up with the inputs to be localized. children.forEach(function(child, index) { child.title = titles[index]; localizedStrings[child.key] = child.title; }); lazy.touchBarUpdater.updateTouchBarInputs( TouchBarHelper.baseWindow, children ); } } TouchBarInput.prototype.QueryInterface = ChromeUtils.generateQI([ "nsITouchBarInput", ]);