/* 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); }