Mypal68/browser/components/search/content/search-one-offs.js
2024-07-21 11:54:34 +03:00

1382 lines
44 KiB
JavaScript

/* 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/. */
"use strict";
/* eslint-env mozilla/browser-window */
/* globals XULCommandEvent */
/**
* Defines the search one-off button elements. These are displayed at the bottom
* of the address bar or the search bar.
*/
class SearchOneOffs {
constructor(container) {
this.container = container;
this.container.appendChild(
MozXULElement.parseXULToFragment(
`
<deck class="search-panel-one-offs-header search-panel-header search-panel-current-input">
<label class="searchbar-oneoffheader-search" value="&searchWithHeader.label;"/>
<hbox class="search-panel-searchforwith search-panel-current-input">
<label value="&searchFor.label;"/>
<label class="searchbar-oneoffheader-searchtext search-panel-input-value" flex="1" crop="end"/>
<label flex="10000" value="&searchWith.label;"/>
</hbox>
<hbox class="search-panel-searchonengine search-panel-current-input">
<label value="&search.label;"/>
<label class="searchbar-oneoffheader-engine search-panel-input-value" flex="1" crop="end"/>
<label flex="10000" value="&searchAfter.label;"/>
</hbox>
</deck>
<description role="group" class="search-panel-one-offs">
<button class="searchbar-engine-one-off-item search-setting-button-compact" tooltiptext="&changeSearchSettings.tooltip;"/>
</description>
<vbox class="search-add-engines"/>
<button class="search-setting-button search-panel-header" label="&changeSearchSettings.button;"/>
<menupopup class="search-one-offs-context-menu">
<menuitem class="search-one-offs-context-open-in-new-tab" label="&searchInNewTab.label;" accesskey="&searchInNewTab.accesskey;"/>
<menuitem class="search-one-offs-context-set-default" label="&searchSetAsDefault.label;" accesskey="&searchSetAsDefault.accesskey;"/>
</menupopup>
`,
["chrome://browser/locale/browser.dtd"]
)
);
this._view = null;
this._popup = null;
this._textbox = null;
this._textboxWidth = 0;
/**
* Set this to a string that identifies your one-offs consumer. It'll
* be appended to telemetry recorded with maybeRecordTelemetry().
*/
this.telemetryOrigin = "";
this._query = "";
this._selectedButton = null;
this.buttons = this.querySelector(".search-panel-one-offs");
this.header = this.querySelector(".search-panel-one-offs-header");
this.addEngines = this.querySelector(".search-add-engines");
this.settingsButton = this.querySelector(".search-setting-button");
this.settingsButtonCompact = this.querySelector(
".search-setting-button-compact"
);
this.contextMenuPopup = this.querySelector(".search-one-offs-context-menu");
this._bundle = null;
/**
* When a context menu is opened on a one-off button, this is set to the
* engine of that button for use with the context menu actions.
*/
this._contextEngine = null;
this._engines = null;
/**
* `_rebuild()` is async, because it queries the Search Service, which means
* there is a potential for a race when it's called multiple times in succession.
*/
this._rebuilding = false;
/**
* If a page offers more than this number of engines, the add-engines
* menu button is shown, instead of showing the engines directly in the
* popup.
*/
this._addEngineMenuThreshold = 5;
/**
* All this stuff is to make the add-engines menu button behave like an
* actual menu. The add-engines menu button is shown when there are
* many engines offered by the current site.
*/
this._addEngineMenuTimeoutMs = 200;
this._addEngineMenuTimeout = null;
this._addEngineMenuShouldBeOpen = false;
this.addEventListener("mousedown", this);
this.addEventListener("mousemove", this);
this.addEventListener("mouseout", this);
this.addEventListener("click", this);
this.addEventListener("command", this);
this.addEventListener("contextmenu", this);
// Prevent popup events from the context menu from reaching the autocomplete
// binding (or other listeners).
let listener = aEvent => aEvent.stopPropagation();
this.contextMenuPopup.addEventListener("popupshowing", listener);
this.contextMenuPopup.addEventListener("popuphiding", listener);
this.contextMenuPopup.addEventListener("popupshown", aEvent => {
this._ignoreMouseEvents = true;
aEvent.stopPropagation();
});
this.contextMenuPopup.addEventListener("popuphidden", aEvent => {
this._ignoreMouseEvents = false;
aEvent.stopPropagation();
});
// Add weak referenced observers to invalidate our cached list of engines.
this.QueryInterface = ChromeUtils.generateQI([
Ci.nsIObserver,
Ci.nsISupportsWeakReference,
]);
Services.prefs.addObserver("browser.search.hiddenOneOffs", this, true);
Services.obs.addObserver(this, "browser-search-engine-modified", true);
Services.obs.addObserver(this, "browser-search-service", true);
// Rebuild the buttons when the theme changes. See bug 1357800 for
// details. Summary: On Linux, switching between themes can cause a row
// of buttons to disappear.
Services.obs.addObserver(this, "lightweight-theme-changed", true);
}
addEventListener(...args) {
this.container.addEventListener(...args);
}
removeEventListener(...args) {
this.container.removeEventListener(...args);
}
dispatchEvent(...args) {
this.container.dispatchEvent(...args);
}
getAttribute(...args) {
return this.container.getAttribute(...args);
}
setAttribute(...args) {
this.container.setAttribute(...args);
}
querySelector(...args) {
return this.container.querySelector(...args);
}
handleEvent(event) {
let methodName = "_on_" + event.type;
if (methodName in this) {
this[methodName](event);
} else {
throw new Error("Unrecognized search-one-offs event: " + event.type);
}
}
/**
* Width in pixels of the one-off buttons. 49px is the min-width of
* each search engine button, adapt this const when changing the css.
* It's actually 48px + 1px of right border.
*/
get buttonWidth() {
return 49;
}
/**
* @param {UrlbarView} val
*/
set view(val) {
this._view = val;
this.popup = val && val.panel;
return val;
}
/**
* The popup that contains the one-offs. This is required, so it should
* never be null or undefined, except possibly before the one-offs are
* used.
*
* @param {DOMElement} val
* The new value to set.
*/
set popup(val) {
let events = ["popupshowing", "popuphidden"];
if (this._popup) {
for (let event of events) {
this._popup.removeEventListener(event, this);
}
}
if (val) {
for (let event of events) {
val.addEventListener(event, this);
}
}
this._popup = val;
// If the popup is already open, rebuild the one-offs now. The
// popup may be opening, so check that the state is not closed
// instead of checking popupOpen.
if (val && val.state != "closed") {
this._rebuild();
}
return val;
}
get popup() {
return this._popup;
}
/**
* The textbox associated with the one-offs. Set this to a textbox to
* automatically keep the related one-offs UI up to date. Otherwise you
* can leave it null/undefined, and in that case you should update the
* query property manually.
*
* @param {DOMElement} val
* The new value to set.
*/
set textbox(val) {
if (this._textbox) {
this._textbox.removeEventListener("input", this);
}
if (val) {
val.addEventListener("input", this);
}
return (this._textbox = val);
}
get style() {
return this.container.style;
}
get textbox() {
return this._textbox;
}
/**
* The query string currently shown in the one-offs. If the textbox
* property is non-null, then this is automatically updated on
* input.
*
* @param {string} val
* The new query string to set.
*/
set query(val) {
this._query = val;
if (
(this._view && this._view.isOpen) ||
(this.popup && this.popup.popupOpen)
) {
this._updateAfterQueryChanged();
}
return val;
}
get query() {
return this._query;
}
/**
* The selected one-off, a xul:button, including the add-engine button
* and the search-settings button.
*
* @param {DOMElement|null} val
* The selected one-off button. Null if no one-off is selected.
*/
set selectedButton(val) {
if (val && val.classList.contains("dummy")) {
// Never select dummy buttons.
val = null;
}
let previousButton = this._selectedButton;
if (previousButton) {
previousButton.removeAttribute("selected");
}
if (val) {
val.setAttribute("selected", "true");
}
this._selectedButton = val;
this._updateStateForButton(null);
if (val && !val.engine) {
// If the button doesn't have an engine, then clear the popup's
// selection to indicate that pressing Return while the button is
// selected will do the button's command, not search.
this.selectedAutocompleteIndex = -1;
}
let event = new CustomEvent("SelectedOneOffButtonChanged", {
previousSelectedButton: previousButton,
});
this.dispatchEvent(event);
return val;
}
get selectedButton() {
return this._selectedButton;
}
/**
* The index of the selected one-off, including the add-engine button
* and the search-settings button.
*
* @param {number} val
* The new index to set, -1 for nothing selected.
*/
set selectedButtonIndex(val) {
let buttons = this.getSelectableButtons(true);
this.selectedButton = buttons[val];
return val;
}
get selectedButtonIndex() {
let buttons = this.getSelectableButtons(true);
for (let i = 0; i < buttons.length; i++) {
if (buttons[i] == this._selectedButton) {
return i;
}
}
return -1;
}
get selectedAutocompleteIndex() {
return (this._view || this.popup).selectedIndex;
}
set selectedAutocompleteIndex(val) {
return ((this._view || this.popup).selectedIndex = val);
}
get compact() {
return this.getAttribute("compact") == "true";
}
get bundle() {
if (!this._bundle) {
const kBundleURI = "chrome://browser/locale/search.properties";
this._bundle = Services.strings.createBundle(kBundleURI);
}
return this._bundle;
}
async getEngines() {
if (this._engines) {
return this._engines;
}
let currentEngineNameToIgnore;
if (!this.getAttribute("includecurrentengine")) {
currentEngineNameToIgnore = (await Services.search.getDefault()).name;
}
let pref = Services.prefs.getStringPref("browser.search.hiddenOneOffs");
let hiddenList = pref ? pref.split(",") : [];
this._engines = (await Services.search.getVisibleEngines()).filter(e => {
let name = e.name;
return (
(!currentEngineNameToIgnore || name != currentEngineNameToIgnore) &&
!hiddenList.includes(name)
);
});
return this._engines;
}
observe(aEngine, aTopic, aData) {
// Make sure the engine list is refetched next time it's needed.
this._engines = null;
}
/**
* Updates the parts of the UI that show the query string.
*/
_updateAfterQueryChanged() {
let headerSearchText = this.querySelector(
".searchbar-oneoffheader-searchtext"
);
headerSearchText.setAttribute("value", this.query);
let groupText;
let isOneOffSelected =
this.selectedButton &&
this.selectedButton.classList.contains("searchbar-engine-one-off-item");
// Typing de-selects the settings or opensearch buttons at the bottom
// of the search panel, as typing shows the user intends to search.
if (this.selectedButton && !isOneOffSelected) {
this.selectedButton = null;
}
if (this.query) {
groupText =
headerSearchText.previousElementSibling.value +
'"' +
headerSearchText.value +
'"' +
headerSearchText.nextElementSibling.value;
if (!isOneOffSelected) {
this.header.selectedIndex = 1;
}
} else {
let noSearchHeader = this.querySelector(".searchbar-oneoffheader-search");
groupText = noSearchHeader.value;
if (!isOneOffSelected) {
this.header.selectedIndex = 0;
}
}
this.buttons.setAttribute("aria-label", groupText);
}
/**
* Infallible, non-re-entrant version of `__rebuild()`.
*/
async _rebuild() {
if (this._rebuilding) {
return;
}
this._rebuilding = true;
try {
await this.__rebuild();
} catch (ex) {
Cu.reportError("Search-one-offs::_rebuild() error: " + ex);
} finally {
this._rebuilding = false;
}
}
/**
* Builds all the UI.
*/
async __rebuild() {
// Update the 'Search for <keywords> with:" header.
this._updateAfterQueryChanged();
// Handle opensearch items. This needs to be done before building the
// list of one off providers, as that code will return early if all the
// alternative engines are hidden.
// Skip this in compact mode, ie. for the urlbar.
if (!this.compact) {
this._rebuildAddEngineList();
}
// Check if the one-off buttons really need to be rebuilt.
if (this._textbox) {
// We can't get a reliable value for the popup width without flushing,
// but the popup width won't change if the textbox width doesn't.
let DOMUtils = window.windowUtils;
let textboxWidth = DOMUtils.getBoundsWithoutFlushing(this._textbox).width;
// We can return early if neither the list of engines nor the panel
// width has changed.
if (this._engines && this._textboxWidth == textboxWidth) {
return;
}
this._textboxWidth = textboxWidth;
}
// Finally, build the list of one-off buttons.
while (this.buttons.firstElementChild != this.settingsButtonCompact) {
this.buttons.firstElementChild.remove();
}
// Remove the trailing empty text node introduced by the binding's
// content markup above.
if (this.settingsButtonCompact.nextElementSibling) {
this.settingsButtonCompact.nextElementSibling.remove();
}
let engines = await this.getEngines();
let defaultEngine = await Services.search.getDefault();
let oneOffCount = engines.length;
let collapsed =
!oneOffCount ||
(oneOffCount == 1 && engines[0].name == defaultEngine.name);
// header is a xul:deck so collapsed doesn't work on it, see bug 589569.
this.header.hidden = this.buttons.collapsed = collapsed;
if (collapsed) {
return;
}
let panelWidth = parseInt(this.popup.clientWidth);
// There's one weird thing to guard against: when layout pixels
// aren't an integral multiple of device pixels, the last button
// of each row sometimes gets pushed to the next row, depending on the
// panel and button widths.
// This is likely because the clientWidth getter rounds the value, but
// the panel's border width is not an integer.
// As a workaround, decrement the width if the scale is not an integer.
let scale = window.windowUtils.screenPixelsPerCSSPixel;
if (Math.floor(scale) != scale) {
--panelWidth;
}
// The + 1 is because the last button doesn't have a right border.
let enginesPerRow = Math.floor((panelWidth + 1) / this.buttonWidth);
let buttonWidth = Math.floor(panelWidth / enginesPerRow);
// There will be an emtpy area of:
// panelWidth - enginesPerRow * buttonWidth px
// at the end of each row.
// If the <description> tag with the list of search engines doesn't have
// a fixed height, the panel will be sized incorrectly, causing the bottom
// of the suggestion <tree> to be hidden.
if (this.compact) {
++oneOffCount;
}
let rowCount = Math.ceil(oneOffCount / enginesPerRow);
let height = rowCount * 33; // 32px per row, 1px border.
this.buttons.setAttribute("height", height + "px");
// Ensure we can refer to the settings buttons by ID:
let origin = this.telemetryOrigin;
this.settingsButton.id = origin + "-anon-search-settings";
this.settingsButtonCompact.id = origin + "-anon-search-settings-compact";
let dummyItems =
enginesPerRow - (oneOffCount % enginesPerRow || enginesPerRow);
for (let i = 0; i < engines.length; ++i) {
let engine = engines[i];
let button = document.createXULElement("button");
button.id = this._buttonIDForEngine(engine);
let uri = "chrome://browser/skin/search-engine-placeholder.png";
if (engine.iconURI) {
uri = engine.iconURI.spec;
}
button.setAttribute("image", uri);
button.setAttribute("class", "searchbar-engine-one-off-item");
button.setAttribute("tooltiptext", engine.name);
button.setAttribute("width", buttonWidth);
button.engine = engine;
if ((i + 1) % enginesPerRow == 0) {
button.classList.add("last-of-row");
}
if (i + 1 == engines.length) {
button.classList.add("last-engine");
}
if (i >= oneOffCount + dummyItems - enginesPerRow) {
button.classList.add("last-row");
}
this.buttons.insertBefore(button, this.settingsButtonCompact);
}
let hasDummyItems = !!dummyItems;
while (dummyItems) {
let button = document.createXULElement("button");
button.setAttribute(
"class",
"searchbar-engine-one-off-item dummy last-row"
);
button.setAttribute("width", buttonWidth);
if (!--dummyItems) {
button.classList.add("last-of-row");
}
this.buttons.insertBefore(button, this.settingsButtonCompact);
}
if (this.compact) {
this.settingsButtonCompact.setAttribute("width", buttonWidth);
if (rowCount == 1 && hasDummyItems) {
// When there's only one row, make the compact settings button
// hug the right edge of the panel. It may not due to the panel's
// width not being an integral multiple of the button width. (See
// the "There will be an emtpy area" comment above.) Increase the
// width of the last dummy item by the remainder.
let remainder = panelWidth - enginesPerRow * buttonWidth;
let width = remainder + buttonWidth;
let lastDummyItem = this.settingsButtonCompact.previousElementSibling;
lastDummyItem.setAttribute("width", width);
}
}
}
_rebuildAddEngineList() {
let list = this.addEngines;
while (list.firstChild) {
list.firstChild.remove();
}
// Add a button for each engine that the page in the selected browser
// offers, except when there are too many offered engines.
// The popup isn't designed to handle too many (by scrolling for
// example), so a page could break the popup by offering too many.
// Instead, add a single menu button with a submenu of all the engines.
if (!gBrowser.selectedBrowser.engines) {
return;
}
let engines = gBrowser.selectedBrowser.engines;
let tooManyEngines = engines.length > this._addEngineMenuThreshold;
if (tooManyEngines) {
// Make the top-level menu button.
let button = document.createXULElement("toolbarbutton");
list.appendChild(button);
button.classList.add(
"addengine-menu-button",
"addengine-item",
"badged-button"
);
button.setAttribute("type", "menu");
button.setAttribute(
"label",
this.bundle.GetStringFromName("cmd_addFoundEngineMenu")
);
button.setAttribute("crop", "end");
button.setAttribute("pack", "start");
// Set the menu button's image to the image of the first engine. The
// offered engines may have differing images, so there's no perfect
// choice here.
let engine = engines[0];
if (engine.icon) {
button.setAttribute("image", engine.icon);
}
// Now make the button's child menupopup.
list = document.createXULElement("menupopup");
button.appendChild(list);
list.setAttribute("class", "addengine-menu");
list.setAttribute("position", "topright topleft");
// Events from child menupopups bubble up to the autocomplete binding,
// which breaks it, so prevent these events from propagating.
let suppressEventTypes = [
"popupshowing",
"popuphiding",
"popupshown",
"popuphidden",
];
for (let type of suppressEventTypes) {
list.addEventListener(type, event => {
event.stopPropagation();
});
}
}
// Finally, add the engines to the list. If there aren't too many
// engines, the list is the search-add-engines vbox. Otherwise it's the
// menupopup created earlier. In the latter case, create menuitem
// elements instead of buttons, because buttons don't get keyboard
// handling for free inside menupopups.
let eltType = tooManyEngines ? "menuitem" : "toolbarbutton";
for (let engine of engines) {
let button = document.createXULElement(eltType);
button.classList.add("addengine-item");
if (!tooManyEngines) {
button.classList.add("badged-button");
}
button.id =
this.telemetryOrigin +
"-add-engine-" +
this._fixUpEngineNameForID(engine.title);
let label = this.bundle.formatStringFromName(
"cmd_addFoundEngine",
[engine.title]
);
button.setAttribute("label", label);
button.setAttribute("crop", "end");
button.setAttribute("tooltiptext", engine.title + "\n" + engine.uri);
button.setAttribute("uri", engine.uri);
button.setAttribute("title", engine.title);
if (engine.icon) {
button.setAttribute("image", engine.icon);
}
if (tooManyEngines) {
button.classList.add("menuitem-iconic");
} else {
button.setAttribute("pack", "start");
}
list.appendChild(button);
}
}
_buttonIDForEngine(engine) {
return (
this.telemetryOrigin +
"-engine-one-off-item-" +
this._fixUpEngineNameForID(engine.name)
);
}
_fixUpEngineNameForID(name) {
return name.replace(/ /g, "-");
}
_buttonForEngine(engine) {
let id = this._buttonIDForEngine(engine);
return this._popup && document.getElementById(id);
}
/**
* Updates the popup and textbox for the currently selected or moused-over
* button.
*
* @param {DOMElement} mousedOverButton
* The currently moused-over button, or null if there isn't one.
*/
_updateStateForButton(mousedOverButton) {
let button = mousedOverButton;
// Ignore dummy buttons.
if (button && button.classList.contains("dummy")) {
button = null;
}
// If there's no moused-over button, then the one-offs should reflect
// the selected button, if any.
button = button || this.selectedButton;
if (!button) {
this.header.selectedIndex = this.query ? 1 : 0;
if (this.textbox) {
this.textbox.removeAttribute("aria-activedescendant");
}
return;
}
if (
button.classList.contains("searchbar-engine-one-off-item") &&
button.engine
) {
let headerEngineText = this.querySelector(
".searchbar-oneoffheader-engine"
);
this.header.selectedIndex = 2;
headerEngineText.value = button.engine.name;
} else {
this.header.selectedIndex = this.query ? 1 : 0;
}
if (this.textbox) {
this.textbox.setAttribute("aria-activedescendant", button.id);
}
}
getSelectableButtons(aIncludeNonEngineButtons) {
let buttons = [];
for (
let oneOff = this.buttons.firstElementChild;
oneOff;
oneOff = oneOff.nextElementSibling
) {
// oneOff may be a text node since the list xul:description contains
// whitespace and the compact settings button. See the markup
// above. _rebuild removes text nodes, but it may not have been
// called yet (because e.g. the popup hasn't been opened yet).
if (oneOff.nodeType == Node.ELEMENT_NODE) {
if (
oneOff.classList.contains("dummy") ||
oneOff.classList.contains("search-setting-button-compact")
) {
break;
}
buttons.push(oneOff);
}
}
if (aIncludeNonEngineButtons) {
for (
let addEngine = this.addEngines.firstElementChild;
addEngine;
addEngine = addEngine.nextElementSibling
) {
buttons.push(addEngine);
}
buttons.push(
this.compact ? this.settingsButtonCompact : this.settingsButton
);
}
return buttons;
}
handleSearchCommand(aEvent, aEngine, aForceNewTab) {
let where = "current";
let params;
// Open ctrl/cmd clicks on one-off buttons in a new background tab.
if (aForceNewTab) {
where = "tab";
if (Services.prefs.getBoolPref("browser.tabs.loadInBackground")) {
params = {
inBackground: true,
};
}
} else {
let newTabPref = Services.prefs.getBoolPref("browser.search.openintab");
if (
(aEvent instanceof KeyboardEvent && aEvent.altKey) ^ newTabPref &&
!gBrowser.selectedTab.isEmpty
) {
where = "tab";
}
if (
aEvent instanceof MouseEvent &&
(aEvent.button == 1 || aEvent.getModifierState("Accel"))
) {
where = "tab";
params = {
inBackground: true,
};
}
}
(this._view || this.popup).handleOneOffSearch(
aEvent,
aEngine,
where,
params
);
}
/**
* Increments or decrements the index of the currently selected one-off.
*
* @param {boolean} aForward
* If true, the index is incremented, and if false, the index is
* decremented.
* @param {boolean} aIncludeNonEngineButtons
* If true, non-dummy buttons that do not have engines are included.
* These buttons include the OpenSearch and settings buttons. For
* example, if the currently selected button is an engine button,
* the next button is the settings button, and you pass true for
* aForward, then passing true for this value would cause the
* settings to be selected. Passing false for this value would
* cause the selection to clear or wrap around, depending on what
* value you passed for the aWrapAround parameter.
* @param {boolean} aWrapAround
* If true, the selection wraps around between the first and last
* buttons.
*/
advanceSelection(aForward, aIncludeNonEngineButtons, aWrapAround) {
let buttons = this.getSelectableButtons(aIncludeNonEngineButtons);
let index;
if (this.selectedButton) {
let inc = aForward ? 1 : -1;
let oldIndex = buttons.indexOf(this.selectedButton);
index = (oldIndex + inc + buttons.length) % buttons.length;
if (
!aWrapAround &&
((aForward && index <= oldIndex) || (!aForward && oldIndex <= index))
) {
// The index has wrapped around, but wrapping around isn't
// allowed.
index = -1;
}
} else {
index = aForward ? 0 : buttons.length - 1;
}
this.selectedButton = index < 0 ? null : buttons[index];
}
/**
* This handles key presses specific to the one-off buttons like Tab and
* Alt+Up/Down, and Up/Down keys within the buttons. Since one-off buttons
* are always used in conjunction with a list of some sort (in this.popup),
* it also handles Up/Down keys that cross the boundaries between list
* items and the one-off buttons.
*
* If this method handles the key press, then it will call
* event.preventDefault() and return true.
*
* @param {Event} event
* The key event.
* @param {number} numListItems
* The number of items in the list. The reason that this is a
* parameter at all is that the list may contain items at the end
* that should be ignored, depending on the consumer. That's true
* for the urlbar for example.
* @param {boolean} allowEmptySelection
* Pass true if it's OK that neither the list nor the one-off
* buttons contains a selection. Pass false if either the list or
* the one-off buttons (or both) should always contain a selection.
* @param {string} [textboxUserValue]
* When the last list item is selected and the user presses Down,
* the first one-off becomes selected and the textbox value is
* restored to the value that the user typed. Pass that value here.
* However, if you pass true for allowEmptySelection, you don't need
* to pass anything for this parameter. (Pass undefined or null.)
* @returns {boolean} True if the one-offs handled the key press.
*/
handleKeyPress(event, numListItems, allowEmptySelection, textboxUserValue) {
if (!this.popup) {
return false;
}
let handled = this._handleKeyPress(
event,
numListItems,
allowEmptySelection,
textboxUserValue
);
if (handled) {
event.preventDefault();
event.stopPropagation();
}
return handled;
}
_handleKeyPress(event, numListItems, allowEmptySelection, textboxUserValue) {
if (this.compact && this.buttons.collapsed) {
return false;
}
if (
event.keyCode == KeyEvent.DOM_VK_RIGHT &&
this.selectedButton &&
this.selectedButton.classList.contains("addengine-menu-button")
) {
// If the add-engine overflow menu item is selected and the user
// presses the right arrow key, open the submenu. Unfortunately
// handling the left arrow key -- to close the popup -- isn't
// straightforward. Once the popup is open, it consumes all key
// events. Setting ignorekeys=handled on it doesn't help, since the
// popup handles all arrow keys. Setting ignorekeys=true on it does
// mean that the popup no longer consumes the left arrow key, but
// then it no longer handles up/down keys to select items in the
// popup.
this.selectedButton.open = true;
return true;
}
// Handle the Tab key, but only if non-Shift modifiers aren't also
// pressed to avoid clobbering other shortcuts (like the Alt+Tab
// browser tab switcher). The reason this uses getModifierState() and
// checks for "AltGraph" is that when you press Shift-Alt-Tab,
// event.altKey is actually false for some reason, at least on macOS.
// getModifierState("Alt") is also false, but "AltGraph" is true.
if (
event.keyCode == KeyEvent.DOM_VK_TAB &&
!event.getModifierState("Alt") &&
!event.getModifierState("AltGraph") &&
!event.getModifierState("Control") &&
!event.getModifierState("Meta")
) {
if (
this.getAttribute("disabletab") == "true" ||
(event.shiftKey && this.selectedButtonIndex <= 0) ||
(!event.shiftKey &&
this.selectedButtonIndex ==
this.getSelectableButtons(true).length - 1)
) {
this.selectedButton = null;
return false;
}
this.selectedAutocompleteIndex = -1;
this.advanceSelection(!event.shiftKey, true, false);
return !!this.selectedButton;
}
if (event.keyCode == KeyboardEvent.DOM_VK_UP) {
if (event.altKey) {
// Keep the currently selected result in the list (if any) as a
// secondary "alt" selection and move the selection up within the
// buttons.
this.advanceSelection(false, false, false);
return true;
}
if (numListItems == 0) {
this.advanceSelection(false, true, false);
return true;
}
if (this.selectedAutocompleteIndex > 0) {
// Moving up within the list. The autocomplete controller should
// handle this case. A button may be selected, so null it.
this.selectedButton = null;
return false;
}
if (this.selectedAutocompleteIndex == 0) {
// Moving up from the top of the list.
if (allowEmptySelection) {
// Let the autocomplete controller remove selection in the list
// and revert the typed text in the textbox.
return false;
}
// Wrap selection around to the last button.
if (this.textbox && typeof textboxUserValue == "string") {
this.textbox.value = textboxUserValue;
}
this.advanceSelection(false, true, true);
return true;
}
if (!this.selectedButton) {
// Moving up from no selection in the list or the buttons, back
// down to the last button.
this.advanceSelection(false, true, true);
return true;
}
if (this.selectedButtonIndex == 0) {
// Moving up from the buttons to the bottom of the list.
this.selectedButton = null;
return false;
}
// Moving up/left within the buttons.
this.advanceSelection(false, true, false);
return true;
}
if (event.keyCode == KeyboardEvent.DOM_VK_DOWN) {
if (event.altKey) {
// Keep the currently selected result in the list (if any) as a
// secondary "alt" selection and move the selection down within
// the buttons.
this.advanceSelection(true, false, false);
return true;
}
if (numListItems == 0) {
this.advanceSelection(true, true, false);
return true;
}
if (
this.selectedAutocompleteIndex >= 0 &&
this.selectedAutocompleteIndex < numListItems - 1
) {
// Moving down within the list. The autocomplete controller
// should handle this case. A button may be selected, so null it.
this.selectedButton = null;
return false;
}
if (this.selectedAutocompleteIndex == numListItems - 1) {
// Moving down from the last item in the list to the buttons.
this.selectedButtonIndex = 0;
if (allowEmptySelection) {
// Let the autocomplete controller remove selection in the list
// and revert the typed text in the textbox.
return false;
}
if (this.textbox && typeof textboxUserValue == "string") {
this.textbox.value = textboxUserValue;
}
this.selectedAutocompleteIndex = -1;
return true;
}
if (this.selectedButton) {
let buttons = this.getSelectableButtons(true);
if (this.selectedButtonIndex == buttons.length - 1) {
// Moving down from the buttons back up to the top of the list.
this.selectedButton = null;
if (allowEmptySelection) {
// Prevent the selection from wrapping around to the top of
// the list by returning true, since the list currently has no
// selection. Nothing should be selected after handling this
// Down key.
return true;
}
return false;
}
// Moving down/right within the buttons.
this.advanceSelection(true, true, false);
return true;
}
return false;
}
if (event.keyCode == KeyboardEvent.DOM_VK_LEFT) {
if (this.selectedButton && (this.compact || this.selectedButton.engine)) {
// Moving left within the buttons.
this.advanceSelection(false, this.compact, true);
return true;
}
return false;
}
if (event.keyCode == KeyboardEvent.DOM_VK_RIGHT) {
if (this.selectedButton && (this.compact || this.selectedButton.engine)) {
// Moving right within the buttons.
this.advanceSelection(true, this.compact, true);
return true;
}
return false;
}
return false;
}
/**
* If the given event is related to the one-offs, this method records
* one-off telemetry for it. this.telemetryOrigin will be appended to the
* computed source, so make sure you set that first.
*
* @param {Event} aEvent
* An event, like a click on a one-off button.
* @returns {boolean} True if telemetry was recorded and false if not.
*/
maybeRecordTelemetry(aEvent) {
if (!aEvent) {
return false;
}
let source = null;
let type = "unknown";
let engine = null;
let target = aEvent.originalTarget;
if (aEvent instanceof KeyboardEvent) {
type = "key";
if (this.selectedButton) {
source = "oneoff";
engine = this.selectedButton.engine;
}
} else if (aEvent instanceof MouseEvent) {
type = "mouse";
if (target.classList.contains("searchbar-engine-one-off-item")) {
source = "oneoff";
engine = target.engine;
}
} else if (
aEvent instanceof XULCommandEvent &&
target.classList.contains("search-one-offs-context-open-in-new-tab")
) {
source = "oneoff-context";
engine = this._contextEngine;
}
if (!source) {
return false;
}
if (this.telemetryOrigin) {
source += "-" + this.telemetryOrigin;
}
BrowserSearch.recordOneoffSearchInTelemetry(engine, source, type);
return true;
}
_resetAddEngineMenuTimeout() {
if (this._addEngineMenuTimeout) {
clearTimeout(this._addEngineMenuTimeout);
}
this._addEngineMenuTimeout = setTimeout(() => {
delete this._addEngineMenuTimeout;
let button = this.querySelector(".addengine-menu-button");
button.open = this._addEngineMenuShouldBeOpen;
}, this._addEngineMenuTimeoutMs);
}
// Event handlers below.
_on_mousedown(event) {
let target = event.originalTarget;
if (target.classList.contains("addengine-menu-button")) {
return;
}
// Required to receive click events from the buttons on Linux.
event.preventDefault();
}
_on_mousemove(event) {
let target = event.originalTarget;
// Handle mouseover on the add-engine menu button and its popup items.
if (
(target.localName == "menuitem" &&
target.classList.contains("addengine-item")) ||
target.classList.contains("addengine-menu-button")
) {
let menuButton = this.querySelector(".addengine-menu-button");
this._updateStateForButton(menuButton);
this._addEngineMenuShouldBeOpen = true;
this._resetAddEngineMenuTimeout();
return;
}
if (target.localName != "button") {
return;
}
// Ignore mouse events when the context menu is open.
if (this._ignoreMouseEvents) {
return;
}
let isOneOff =
target.classList.contains("searchbar-engine-one-off-item") &&
!target.classList.contains("dummy");
if (
isOneOff ||
target.classList.contains("addengine-item") ||
target.classList.contains("search-setting-button")
) {
this._updateStateForButton(target);
}
}
_on_mouseout(event) {
let target = event.originalTarget;
// Handle mouseout on the add-engine menu button and its popup items.
if (
(target.localName == "menuitem" &&
target.classList.contains("addengine-item")) ||
target.classList.contains("addengine-menu-button")
) {
this._updateStateForButton(null);
this._addEngineMenuShouldBeOpen = false;
this._resetAddEngineMenuTimeout();
return;
}
if (target.localName != "button") {
return;
}
// Don't update the mouseover state if the context menu is open.
if (this._ignoreMouseEvents) {
return;
}
this._updateStateForButton(null);
}
_on_click(event) {
if (event.button == 2) {
return; // ignore right clicks.
}
let button = event.originalTarget;
let engine = button.engine;
if (!engine) {
return;
}
// Select the clicked button so that consumers can easily tell which
// button was acted on.
this.selectedButton = button;
this.handleSearchCommand(event, engine);
}
_on_command(event) {
let target = event.target;
if (target == this.settingsButton || target == this.settingsButtonCompact) {
openPreferences("paneSearch");
// If the preference tab was already selected, the panel doesn't
// close itself automatically.
this.popup.hidePopup();
return;
}
if (target.classList.contains("addengine-item")) {
// On success, hide the panel and tell event listeners to reshow it to
// show the new engine.
Services.search
.addEngine(
target.getAttribute("uri"),
target.getAttribute("image"),
false
)
.then(engine => {
this._rebuild();
})
.catch(errorCode => {
if (errorCode != Ci.nsISearchService.ERROR_DUPLICATE_ENGINE) {
// Download error is shown by the search service
return;
}
const kSearchBundleURI =
"chrome://global/locale/search/search.properties";
let searchBundle = Services.strings.createBundle(kSearchBundleURI);
let brandBundle = document.getElementById("bundle_brand");
let brandName = brandBundle.getString("brandShortName");
let title = searchBundle.GetStringFromName(
"error_invalid_engine_title"
);
let text = searchBundle.formatStringFromName(
"error_duplicate_engine_msg",
[brandName, target.getAttribute("uri")]
);
Services.prompt.QueryInterface(Ci.nsIPromptFactory);
let prompt = Services.prompt.getPrompt(
gBrowser.contentWindow,
Ci.nsIPrompt
);
prompt.QueryInterface(Ci.nsIWritablePropertyBag2);
prompt.setPropertyAsBool("allowTabModal", true);
prompt.alert(title, text);
});
}
if (target.classList.contains("search-one-offs-context-open-in-new-tab")) {
// Select the context-clicked button so that consumers can easily
// tell which button was acted on.
this.selectedButton = this._buttonForEngine(this._contextEngine);
this.handleSearchCommand(event, this._contextEngine, true);
}
if (target.classList.contains("search-one-offs-context-set-default")) {
let currentEngine = Services.search.defaultEngine;
if (!this.getAttribute("includecurrentengine")) {
// Make the target button of the context menu reflect the current
// search engine first. Doing this as opposed to rebuilding all the
// one-off buttons avoids flicker.
let button = this._buttonForEngine(this._contextEngine);
button.id = this._buttonIDForEngine(currentEngine);
let uri = "chrome://browser/skin/search-engine-placeholder.png";
if (currentEngine.iconURI) {
uri = currentEngine.iconURI.spec;
}
button.setAttribute("image", uri);
button.setAttribute("tooltiptext", currentEngine.name);
button.engine = currentEngine;
}
Services.search.defaultEngine = this._contextEngine;
}
}
_on_contextmenu(event) {
let target = event.originalTarget;
// Prevent the context menu from appearing except on the one off buttons.
if (
!target.classList.contains("searchbar-engine-one-off-item") ||
target.classList.contains("search-setting-button-compact") ||
target.classList.contains("dummy")
) {
event.preventDefault();
return;
}
this.querySelector(".search-one-offs-context-set-default").setAttribute(
"disabled",
target.engine == Services.search.defaultEngine
);
this.contextMenuPopup.openPopupAtScreen(event.screenX, event.screenY, true);
event.preventDefault();
this._contextEngine = target.engine;
}
_on_input(event) {
// Allow the consumer's input to override its value property with
// a oneOffSearchQuery property. That way if the value is not
// actually what the user typed (e.g., it's autofilled, or it's a
// mozaction URI), the consumer has some way of providing it.
this.query = event.target.oneOffSearchQuery || event.target.value;
}
_on_popupshowing() {
this._rebuild();
}
_on_popuphidden() {
Services.tm.dispatchToMainThread(() => {
this.selectedButton = null;
this._contextEngine = null;
});
}
}
window.SearchOneOffs = SearchOneOffs;