/* 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";
/* globals XULCommandEvent */
// This is loaded into chrome windows with the subscript loader. Wrap in
// a block to prevent accidentally leaking globals onto `window`.
{
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
FormHistory: "resource://gre/modules/FormHistory.sys.mjs",
SearchSuggestionController:
"resource://gre/modules/SearchSuggestionController.sys.mjs",
});
/**
* Defines the search bar element.
*/
class MozSearchbar extends MozXULElement {
static get inheritedAttributes() {
return {
".searchbar-textbox":
"disabled,disableautocomplete,searchengine,src,newlines",
".searchbar-search-button": "addengines",
};
}
static get markup() {
return `
`;
}
constructor() {
super();
MozXULElement.insertFTLIfNeeded("browser/search.ftl");
this.destroy = this.destroy.bind(this);
this._setupEventListeners();
let searchbar = this;
this.observer = {
observe(aEngine, aTopic) {
if (aTopic == "browser-search-engine-modified") {
// Make sure the engine list is refetched next time it's needed
searchbar._engines = null;
// Update the popup header and update the display after any modification.
searchbar._textbox.popup.updateHeader();
searchbar.updateDisplay();
}
},
QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
};
this._ignoreFocus = false;
this._engines = null;
this.telemetrySelectedIndex = -1;
}
connectedCallback() {
// Don't initialize if this isn't going to be visible
if (this.closest("#BrowserToolbarPalette")) {
return;
}
this.appendChild(this.constructor.fragment);
this.initializeAttributeInheritance();
// Don't go further if in Customize mode.
if (this.parentNode.parentNode.localName == "toolbarpaletteitem") {
return;
}
// Ensure we get persisted widths back, if we've been in the palette:
let storedWidth = Services.xulStore.getValue(
document.documentURI,
this.parentNode.id,
"width"
);
if (storedWidth) {
this.parentNode.setAttribute("width", storedWidth);
this.parentNode.style.width = storedWidth + "px";
}
this._stringBundle = this.querySelector("stringbundle");
this._textbox = this.querySelector(".searchbar-textbox");
this._menupopup = null;
this._pasteAndSearchMenuItem = null;
this._setupTextboxEventListeners();
this._initTextbox();
window.addEventListener("unload", this.destroy);
Services.obs.addObserver(this.observer, "browser-search-engine-modified");
this._initialized = true;
(window.delayedStartupPromise || Promise.resolve()).then(() => {
window.requestIdleCallback(() => {
Services.search
.init()
.then(() => {
// Bail out if the binding's been destroyed
if (!this._initialized) {
return;
}
// Ensure the popup header is updated if the user has somehow
// managed to open the popup before the search service has finished
// initializing.
this._textbox.popup.updateHeader();
// Refresh the display (updating icon, etc)
this.updateDisplay();
BrowserSearch.updateOpenSearchBadge();
})
.catch(status =>
console.error(
"Cannot initialize search service, bailing out:",
status
)
);
});
});
// Wait until the popupshowing event to avoid forcing immediate
// attachment of the search-one-offs binding.
this.textbox.popup.addEventListener(
"popupshowing",
() => {
let oneOffButtons = this.textbox.popup.oneOffButtons;
// Some accessibility tests create their own that doesn't
// use the popup binding below, so null-check oneOffButtons.
if (oneOffButtons) {
oneOffButtons.telemetryOrigin = "searchbar";
// Set .textbox first, since the popup setter will cause
// a _rebuild call that uses it.
oneOffButtons.textbox = this.textbox;
oneOffButtons.popup = this.textbox.popup;
}
},
{ capture: true, once: true }
);
}
async getEngines() {
if (!this._engines) {
this._engines = await Services.search.getVisibleEngines();
}
return this._engines;
}
set currentEngine(val) {
if (PrivateBrowsingUtils.isWindowPrivate(window)) {
Services.search.setDefaultPrivate(
val,
Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR
);
} else {
Services.search.setDefault(
val,
Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR
);
}
}
get currentEngine() {
let currentEngine;
if (PrivateBrowsingUtils.isWindowPrivate(window)) {
currentEngine = Services.search.defaultPrivateEngine;
} else {
currentEngine = Services.search.defaultEngine;
}
// Return a dummy engine if there is no currentEngine
return currentEngine || { name: "", uri: null };
}
/**
* textbox is used by sanitize.js to clear the undo history when
* clearing form information.
*
* @returns {HTMLInputElement}
*/
get textbox() {
return this._textbox;
}
set value(val) {
this._textbox.value = val;
}
get value() {
return this._textbox.value;
}
destroy() {
if (this._initialized) {
this._initialized = false;
window.removeEventListener("unload", this.destroy);
Services.obs.removeObserver(
this.observer,
"browser-search-engine-modified"
);
}
// Make sure to break the cycle from _textbox to us. Otherwise we leak
// the world. But make sure it's actually pointing to us.
// Also make sure the textbox has ever been constructed, otherwise the
// _textbox getter will cause the textbox constructor to run, add an
// observer, and leak the world too.
if (
this._textbox &&
this._textbox.mController &&
this._textbox.mController.input &&
this._textbox.mController.input.wrappedJSObject ==
this.nsIAutocompleteInput
) {
this._textbox.mController.input = null;
}
}
focus() {
this._textbox.focus();
}
select() {
this._textbox.select();
}
setIcon(element, uri) {
element.setAttribute("src", uri);
}
updateDisplay() {
this._textbox.title = this._stringBundle.getFormattedString("searchtip", [
this.currentEngine.name,
]);
}
updateGoButtonVisibility() {
this.querySelector(".search-go-button").hidden = !this._textbox.value;
}
openSuggestionsPanel(aShowOnlySettingsIfEmpty) {
if (this._textbox.open) {
return;
}
this._textbox.showHistoryPopup();
let searchIcon = document.querySelector(".searchbar-search-button");
searchIcon.setAttribute("aria-expanded", "true");
if (this._textbox.value) {
// showHistoryPopup does a startSearch("") call, ensure the
// controller handles the text from the input box instead:
this._textbox.mController.handleText();
} else if (aShowOnlySettingsIfEmpty) {
this.setAttribute("showonlysettings", "true");
}
}
async selectEngine(aEvent, isNextEngine) {
// Stop event bubbling now, because the rest of this method is async.
aEvent.preventDefault();
aEvent.stopPropagation();
// Find the new index.
let engines = await this.getEngines();
let currentName = this.currentEngine.name;
let newIndex = -1;
let lastIndex = engines.length - 1;
for (let i = lastIndex; i >= 0; --i) {
if (engines[i].name == currentName) {
// Check bounds to cycle through the list of engines continuously.
if (!isNextEngine && i == 0) {
newIndex = lastIndex;
} else if (isNextEngine && i == lastIndex) {
newIndex = 0;
} else {
newIndex = i + (isNextEngine ? 1 : -1);
}
break;
}
}
this.currentEngine = engines[newIndex];
this.openSuggestionsPanel();
}
handleSearchCommand(aEvent, aEngine, aForceNewTab) {
let where = "current";
let params;
const newTabPref = Services.prefs.getBoolPref("browser.search.openintab");
// Open ctrl/cmd clicks on one-off buttons in a new background tab.
if (
aEvent &&
aEvent.originalTarget.classList.contains("search-go-button")
) {
if (aEvent.button == 2) {
return;
}
where = lazy.BrowserUtils.whereToOpenLink(aEvent, false, true);
if (
newTabPref &&
!aEvent.altKey &&
!aEvent.getModifierState("AltGraph") &&
where == "current" &&
!gBrowser.selectedTab.isEmpty
) {
where = "tab";
}
} else if (aForceNewTab) {
where = "tab";
if (Services.prefs.getBoolPref("browser.tabs.loadInBackground")) {
where += "-background";
}
} else {
if (
(KeyboardEvent.isInstance(aEvent) &&
(aEvent.altKey || aEvent.getModifierState("AltGraph"))) ^
newTabPref &&
!gBrowser.selectedTab.isEmpty
) {
where = "tab";
}
if (
MouseEvent.isInstance(aEvent) &&
(aEvent.button == 1 || aEvent.getModifierState("Accel"))
) {
where = "tab";
params = {
inBackground: true,
};
}
}
this.handleSearchCommandWhere(aEvent, aEngine, where, params);
}
handleSearchCommandWhere(aEvent, aEngine, aWhere, aParams = {}) {
let textBox = this._textbox;
let textValue = textBox.value;
let selectedIndex = this.telemetrySelectedIndex;
let isOneOff = false;
BrowserSearchTelemetry.recordSearchSuggestionSelectionMethod(
aEvent,
"searchbar",
selectedIndex
);
if (selectedIndex == -1) {
isOneOff =
this.textbox.popup.oneOffButtons.eventTargetIsAOneOff(aEvent);
}
if (aWhere === "tab" && !!aParams.inBackground) {
// Keep the focus in the search bar.
aParams.avoidBrowserFocus = true;
} else if (
aWhere !== "window" &&
aEvent.keyCode === KeyEvent.DOM_VK_RETURN
) {
// Move the focus to the selected browser when keyup the Enter.
aParams.avoidBrowserFocus = true;
this._needBrowserFocusAtEnterKeyUp = true;
}
// This is a one-off search only if oneOffRecorded is true.
this.doSearch(textValue, aWhere, aEngine, aParams, isOneOff);
}
doSearch(aData, aWhere, aEngine, aParams, isOneOff = false) {
let textBox = this._textbox;
let engine = aEngine || this.currentEngine;
// Save the current value in the form history
if (
aData &&
!PrivateBrowsingUtils.isWindowPrivate(window) &&
lazy.FormHistory.enabled &&
aData.length <=
lazy.SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH
) {
lazy.FormHistory.update({
op: "bump",
fieldname: textBox.getAttribute("autocompletesearchparam"),
value: aData,
source: engine.name,
}).catch(error =>
console.error("Saving search to form history failed:", error)
);
}
let submission = engine.getSubmission(aData, null, "searchbar");
// If we hit here, we come either from a one-off, a plain search or a suggestion.
const details = {
isOneOff,
isSuggestion: !isOneOff && this.telemetrySelectedIndex != -1,
};
this.telemetrySelectedIndex = -1;
BrowserSearchTelemetry.recordSearch(
gBrowser.selectedBrowser,
engine,
"searchbar",
details
);
// Record when the user uses the search bar
Services.prefs.setStringPref(
"browser.search.widget.lastUsed",
new Date().toISOString()
);
// null parameter below specifies HTML response for search
let params = {
postData: submission.postData,
globalHistoryOptions: {
triggeringSearchEngine: engine.name,
},
};
if (aParams) {
for (let key in aParams) {
params[key] = aParams[key];
}
}
openTrustedLinkIn(submission.uri.spec, aWhere, params);
}
disconnectedCallback() {
this.destroy();
while (this.firstChild) {
this.firstChild.remove();
}
}
/**
* Determines if we should select all the text in the searchbar based on the
* searchbar state, and whether the selection is empty.
*/
_maybeSelectAll() {
if (
!this._preventClickSelectsAll &&
document.activeElement == this._textbox &&
this._textbox.selectionStart == this._textbox.selectionEnd
) {
this.select();
}
}
_setupEventListeners() {
this.addEventListener("click", () => {
this._maybeSelectAll();
});
this.addEventListener(
"DOMMouseScroll",
event => {
if (event.getModifierState("Accel")) {
this.selectEngine(event, event.detail > 0);
}
},
true
);
this.addEventListener("input", () => {
this.updateGoButtonVisibility();
});
this.addEventListener("drop", () => {
this.updateGoButtonVisibility();
});
this.addEventListener(
"blur",
() => {
// Reset the flag since we can't capture enter keyup event if the event happens
// after moving the focus.
this._needBrowserFocusAtEnterKeyUp = false;
// If the input field is still focused then a different window has
// received focus, ignore the next focus event.
this._ignoreFocus = document.activeElement == this._textbox;
},
true
);
this.addEventListener(
"focus",
() => {
// Speculatively connect to the current engine's search URI (and
// suggest URI, if different) to reduce request latency
this.currentEngine.speculativeConnect({
window,
originAttributes: gBrowser.contentPrincipal.originAttributes,
});
if (this._ignoreFocus) {
// This window has been re-focused, don't show the suggestions
this._ignoreFocus = false;
return;
}
// Don't open the suggestions if there is no text in the textbox.
if (!this._textbox.value) {
return;
}
// Don't open the suggestions if the mouse was used to focus the
// textbox, that will be taken care of in the click handler.
if (
Services.focus.getLastFocusMethod(window) &
Services.focus.FLAG_BYMOUSE
) {
return;
}
this.openSuggestionsPanel();
},
true
);
this.addEventListener("mousedown", event => {
this._preventClickSelectsAll = this._textbox.focused;
// Ignore right clicks
if (event.button != 0) {
return;
}
// Ignore clicks on the search go button.
if (event.originalTarget.classList.contains("search-go-button")) {
return;
}
// Ignore clicks on menu items in the input's context menu.
if (event.originalTarget.localName == "menuitem") {
return;
}
let isIconClick = event.originalTarget.classList.contains(
"searchbar-search-button"
);
// Hide popup when icon is clicked while popup is open
if (isIconClick && this.textbox.popup.popupOpen) {
this.textbox.popup.closePopup();
let searchIcon = document.querySelector(".searchbar-search-button");
searchIcon.setAttribute("aria-expanded", "false");
} else if (isIconClick || this._textbox.value) {
// Open the suggestions whenever clicking on the search icon or if there
// is text in the textbox.
this.openSuggestionsPanel(true);
}
});
}
_setupTextboxEventListeners() {
this.textbox.addEventListener("input", () => {
this.textbox.popup.removeAttribute("showonlysettings");
});
this.textbox.addEventListener("dragover", event => {
let types = event.dataTransfer.types;
if (
types.includes("text/plain") ||
types.includes("text/x-moz-text-internal")
) {
event.preventDefault();
}
});
this.textbox.addEventListener("drop", event => {
let dataTransfer = event.dataTransfer;
let data = dataTransfer.getData("text/plain");
if (!data) {
data = dataTransfer.getData("text/x-moz-text-internal");
}
if (data) {
event.preventDefault();
this.textbox.value = data;
this.openSuggestionsPanel();
}
});
this.textbox.addEventListener("contextmenu", event => {
if (!this._menupopup) {
this._buildContextMenu();
}
this._textbox.closePopup();
// Make sure the context menu isn't opened via keyboard shortcut. Check for text selection
// before updating the state of any menu items.
if (event.button) {
this._maybeSelectAll();
}
// Update disabled state of menu items
for (let item of this._menupopup.querySelectorAll("menuitem[cmd]")) {
let command = item.getAttribute("cmd");
let controller =
document.commandDispatcher.getControllerForCommand(command);
item.disabled = !controller.isCommandEnabled(command);
}
let pasteEnabled = document.commandDispatcher
.getControllerForCommand("cmd_paste")
.isCommandEnabled("cmd_paste");
this._pasteAndSearchMenuItem.disabled = !pasteEnabled;
this._menupopup.openPopupAtScreen(event.screenX, event.screenY, true);
event.preventDefault();
});
}
_initTextbox() {
if (this.parentNode.parentNode.localName == "toolbarpaletteitem") {
return;
}
this.setAttribute("role", "combobox");
this.setAttribute("aria-owns", this.textbox.popup.id);
// This overrides the searchParam property in autocomplete.xml. We're
// hijacking this property as a vehicle for delivering the privacy
// information about the window into the guts of nsSearchSuggestions.
// Note that the setter is the same as the parent. We were not sure whether
// we can override just the getter. If that proves to be the case, the setter
// can be removed.
Object.defineProperty(this.textbox, "searchParam", {
get() {
return (
this.getAttribute("autocompletesearchparam") +
(PrivateBrowsingUtils.isWindowPrivate(window) ? "|private" : "")
);
},
set(val) {
this.setAttribute("autocompletesearchparam", val);
},
});
Object.defineProperty(this.textbox, "selectedButton", {
get() {
return this.popup.oneOffButtons.selectedButton;
},
set(val) {
this.popup.oneOffButtons.selectedButton = val;
},
});
// This is implemented so that when textbox.value is set directly (e.g.,
// by tests), the one-off query is updated.
this.textbox.onBeforeValueSet = aValue => {
if (this.textbox.popup._oneOffButtons) {
this.textbox.popup.oneOffButtons.query = aValue;
}
return aValue;
};
// Returns true if the event is handled by us, false otherwise.
this.textbox.onBeforeHandleKeyDown = aEvent => {
if (aEvent.getModifierState("Accel")) {
if (
aEvent.keyCode == KeyEvent.DOM_VK_DOWN ||
aEvent.keyCode == KeyEvent.DOM_VK_UP
) {
this.selectEngine(aEvent, aEvent.keyCode == KeyEvent.DOM_VK_DOWN);
return true;
}
return false;
}
if (
(AppConstants.platform == "macosx" &&
aEvent.keyCode == KeyEvent.DOM_VK_F4) ||
(aEvent.getModifierState("Alt") &&
(aEvent.keyCode == KeyEvent.DOM_VK_DOWN ||
aEvent.keyCode == KeyEvent.DOM_VK_UP))
) {
if (!this.textbox.openSearch()) {
aEvent.preventDefault();
aEvent.stopPropagation();
return true;
}
}
let popup = this.textbox.popup;
let searchIcon = document.querySelector(".searchbar-search-button");
searchIcon.setAttribute("aria-expanded", popup.popupOpen);
if (popup.popupOpen) {
let suggestionsHidden =
popup.richlistbox.getAttribute("collapsed") == "true";
let numItems = suggestionsHidden ? 0 : popup.matchCount;
return popup.oneOffButtons.handleKeyDown(aEvent, numItems, true);
} else if (aEvent.keyCode == KeyEvent.DOM_VK_ESCAPE) {
if (this.textbox.editor.canUndo) {
this.textbox.editor.undoAll();
} else {
this.textbox.select();
}
return true;
}
return false;
};
// This method overrides the autocomplete binding's openPopup (essentially
// duplicating the logic from the autocomplete popup binding's
// openAutocompletePopup method), modifying it so that the popup is aligned with
// the inner textbox, but sized to not extend beyond the search bar border.
this.textbox.openPopup = () => {
// Entering customization mode after the search bar had focus causes
// the popup to appear again, due to focus returning after the
// hamburger panel closes. Don't open in that spurious event.
if (document.documentElement.getAttribute("customizing") == "true") {
return;
}
let popup = this.textbox.popup;
let searchIcon = document.querySelector(".searchbar-search-button");
if (!popup.mPopupOpen) {
// Initially the panel used for the searchbar (PopupSearchAutoComplete
// in browser.xhtml) is hidden to avoid impacting startup / new
// window performance. The base binding's openPopup would normally
// call the overriden openAutocompletePopup in
// browser-search-autocomplete-result-popup binding to unhide the popup,
// but since we're overriding openPopup we need to unhide the panel
// ourselves.
popup.hidden = false;
// Don't roll up on mouse click in the anchor for the search UI.
if (popup.id == "PopupSearchAutoComplete") {
popup.setAttribute("norolluponanchor", "true");
}
popup.mInput = this.textbox;
// clear any previous selection, see bugs 400671 and 488357
popup.selectedIndex = -1;
// Ensure the panel has a meaningful initial size and doesn't grow
// unconditionally.
let { width } = window.windowUtils.getBoundsWithoutFlushing(this);
if (popup.oneOffButtons) {
// We have a min-width rule on search-panel-one-offs to show at
// least 4 buttons, so take that into account here.
width = Math.max(width, popup.oneOffButtons.buttonWidth * 4);
}
popup.style.setProperty("--panel-width", width + "px");
popup._invalidate();
popup.openPopup(this, "after_start");
searchIcon.setAttribute("aria-expanded", "true");
}
};
this.textbox.openSearch = () => {
if (!this.textbox.popupOpen) {
this.openSuggestionsPanel();
return false;
}
return true;
};
this.textbox.handleEnter = event => {
// Toggle the open state of the add-engine menu button if it's
// selected. We're using handleEnter for this instead of listening
// for the command event because a command event isn't fired.
if (
this.textbox.selectedButton &&
this.textbox.selectedButton.getAttribute("anonid") ==
"addengine-menu-button"
) {
this.textbox.selectedButton.open = !this.textbox.selectedButton.open;
return true;
}
// Otherwise, "call super": do what the autocomplete binding's
// handleEnter implementation does.
return this.textbox.mController.handleEnter(false, event || null);
};
// override |onTextEntered| in autocomplete.xml
this.textbox.onTextEntered = event => {
this.textbox.editor.clearUndoRedo();
let engine;
let oneOff = this.textbox.selectedButton;
if (oneOff) {
if (!oneOff.engine) {
oneOff.doCommand();
return;
}
engine = oneOff.engine;
}
if (this.textbox.popupSelectedIndex != -1) {
this.telemetrySelectedIndex = this.textbox.popupSelectedIndex;
this.textbox.popupSelectedIndex = -1;
}
this.handleSearchCommand(event, engine);
};
this.textbox.onbeforeinput = event => {
if (event.data && this._needBrowserFocusAtEnterKeyUp) {
// Ignore char key input while processing enter key.
event.preventDefault();
}
};
this.textbox.onkeyup = () => {
// Pressing Enter key while pressing Meta key, and next, even when
// releasing Enter key before releasing Meta key, the keyup event is not
// fired. Therefore, if Enter keydown is detecting, continue the post
// processing for Enter key when any keyup event is detected.
if (this._needBrowserFocusAtEnterKeyUp) {
this._needBrowserFocusAtEnterKeyUp = false;
gBrowser.selectedBrowser.focus();
}
};
}
_buildContextMenu() {
const raw = `
`;
this._menupopup = this.querySelector(".textbox-contextmenu");
let frag = MozXULElement.parseXULToFragment(raw);
// Insert attributes that come from localized properties
this._pasteAndSearchMenuItem = frag.querySelector(
".searchbar-paste-and-search"
);
this._pasteAndSearchMenuItem.setAttribute(
"label",
this._stringBundle.getString("cmd_pasteAndSearch")
);
let clearHistoryItem = frag.querySelector(".searchbar-clear-history");
clearHistoryItem.setAttribute(
"label",
this._stringBundle.getString("cmd_clearHistory")
);
clearHistoryItem.setAttribute(
"accesskey",
this._stringBundle.getString("cmd_clearHistory_accesskey")
);
this._menupopup.appendChild(frag);
this._menupopup.addEventListener("command", event => {
switch (event.originalTarget) {
case this._pasteAndSearchMenuItem:
this.select();
goDoCommand("cmd_paste");
this.handleSearchCommand(event);
break;
case clearHistoryItem: {
let param = this.textbox.getAttribute("autocompletesearchparam");
lazy.FormHistory.update({ op: "remove", fieldname: param });
this.textbox.value = "";
break;
}
default: {
let cmd = event.originalTarget.getAttribute("cmd");
if (cmd) {
let controller =
document.commandDispatcher.getControllerForCommand(cmd);
controller.doCommand(cmd);
}
break;
}
}
});
}
}
customElements.define("searchbar", MozSearchbar);
}