SearchOneOffs.sys.mjs (37640B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const lazy = {}; 6 7 ChromeUtils.defineESModuleGetters(lazy, { 8 OpenSearchManager: 9 "moz-src:///browser/components/search/OpenSearchManager.sys.mjs", 10 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 11 SearchUIUtils: "moz-src:///browser/components/search/SearchUIUtils.sys.mjs", 12 }); 13 14 /** 15 * @import {UrlbarUtils} from "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs" 16 */ 17 18 /** 19 * @typedef {object} LegacySearchButton 20 * @property {boolean} open 21 * Whether the button is in an open state. 22 * @property {Values<typeof UrlbarUtils.RESULT_SOURCE>} [source] 23 * The result source of the button. Only appropriate for one-off buttons 24 * on the urlbar. 25 * @property {nsISearchEngine} engine 26 * The search engine associated with the button. 27 */ 28 29 /** 30 * A XULElement augmented at runtime with additional properties. 31 * 32 * @typedef {XULElement & LegacySearchButton} LegacySearchOneOffButton 33 */ 34 35 /** 36 * Defines the search one-off button elements. These are displayed at the bottom 37 * of the address bar and search bar. The address bar buttons are a subclass in 38 * browser/components/urlbar/UrlbarSearchOneOffs.sys.mjs. If you are adding a new 39 * subclass, see "Methods for subclasses to override" below. 40 */ 41 export class SearchOneOffs { 42 constructor(container) { 43 this.container = container; 44 this.window = container.ownerGlobal; 45 this.document = container.ownerDocument; 46 47 this.container.appendChild( 48 this.window.MozXULElement.parseXULToFragment( 49 ` 50 <hbox class="search-panel-one-offs-header search-panel-header"> 51 <label class="search-panel-one-offs-header-label" data-l10n-id="search-one-offs-with-title"/> 52 </hbox> 53 <box class="search-panel-one-offs-container"> 54 <hbox class="search-panel-one-offs" role="group"/> 55 <button class="searchbar-engine-one-off-item search-setting-button" tabindex="-1" data-l10n-id="search-one-offs-change-settings-compact-button"/> 56 </box> 57 <box> 58 <menupopup class="search-one-offs-context-menu"> 59 <menuitem class="search-one-offs-context-open-in-new-tab" data-l10n-id="search-one-offs-context-open-new-tab"/> 60 <menuitem class="search-one-offs-context-set-default" data-l10n-id="search-one-offs-context-set-as-default"/> 61 <menuitem class="search-one-offs-context-set-default-private" data-l10n-id="search-one-offs-context-set-as-default-private"/> 62 </menupopup> 63 </box> 64 ` 65 ) 66 ); 67 68 this._popup = null; 69 this._textbox = null; 70 71 this._textboxWidth = 0; 72 73 /** 74 * Set this to a string that identifies your one-offs consumer. It'll 75 * be appended to telemetry recorded with maybeRecordTelemetry(). 76 */ 77 this.telemetryOrigin = ""; 78 79 this._query = ""; 80 81 this._selectedButton = null; 82 83 this.buttons = this.querySelector(".search-panel-one-offs"); 84 85 this.header = this.querySelector(".search-panel-one-offs-header"); 86 87 this.settingsButton = this.querySelector(".search-setting-button"); 88 89 this.contextMenuPopup = this.querySelector(".search-one-offs-context-menu"); 90 91 this._engineInfo = null; 92 93 /** 94 * `_rebuild()` is async, because it queries the Search Service, which means 95 * there is a potential for a race when it's called multiple times in succession. 96 */ 97 this._rebuilding = false; 98 99 this.addEventListener("mousedown", this); 100 this.addEventListener("click", this); 101 this.addEventListener("command", this); 102 this.addEventListener("contextmenu", this); 103 104 // Prevent popup events from the context menu from reaching the autocomplete 105 // binding (or other listeners). 106 let listener = aEvent => aEvent.stopPropagation(); 107 this.contextMenuPopup.addEventListener("popupshowing", listener); 108 this.contextMenuPopup.addEventListener("popuphiding", listener); 109 this.contextMenuPopup.addEventListener("popupshown", aEvent => { 110 aEvent.stopPropagation(); 111 }); 112 this.contextMenuPopup.addEventListener("popuphidden", aEvent => { 113 aEvent.stopPropagation(); 114 }); 115 116 // Add weak referenced observers to invalidate our cached list of engines. 117 this.QueryInterface = ChromeUtils.generateQI([ 118 "nsIObserver", 119 "nsISupportsWeakReference", 120 ]); 121 Services.obs.addObserver(this, "browser-search-engine-modified", true); 122 Services.obs.addObserver(this, "browser-search-service", true); 123 124 // Rebuild the buttons when the theme changes. See bug 1357800 for 125 // details. Summary: On Linux, switching between themes can cause a row 126 // of buttons to disappear. 127 Services.obs.addObserver(this, "lightweight-theme-changed", true); 128 129 // This defaults to false in the Search Bar, subclasses can change their 130 // default in the constructor. 131 this.disableOneOffsHorizontalKeyNavigation = false; 132 } 133 134 addEventListener(...args) { 135 this.container.addEventListener(...args); 136 } 137 138 removeEventListener(...args) { 139 this.container.removeEventListener(...args); 140 } 141 142 dispatchEvent(...args) { 143 this.container.dispatchEvent(...args); 144 } 145 146 getAttribute(...args) { 147 return this.container.getAttribute(...args); 148 } 149 150 hasAttribute(...args) { 151 return this.container.hasAttribute(...args); 152 } 153 154 setAttribute(...args) { 155 this.container.setAttribute(...args); 156 } 157 158 querySelector(...args) { 159 return this.container.querySelector(...args); 160 } 161 162 handleEvent(event) { 163 let methodName = "_on_" + event.type; 164 if (methodName in this) { 165 this[methodName](event); 166 } else { 167 throw new Error("Unrecognized search-one-offs event: " + event.type); 168 } 169 } 170 171 /** 172 * @returns {Promise<boolean>} 173 * True if we will hide the one-offs when they are requested. 174 */ 175 async willHide() { 176 if (this._engineInfo?.willHide !== undefined) { 177 return this._engineInfo.willHide; 178 } 179 let engineInfo = await this.getEngineInfo(); 180 let oneOffCount = engineInfo.engines.length; 181 this._engineInfo.willHide = 182 !oneOffCount || 183 (oneOffCount == 1 && 184 engineInfo.engines[0].name == engineInfo.default.name); 185 return this._engineInfo.willHide; 186 } 187 188 /** 189 * Invalidates the engine cache. After invalidating the cache, the one-offs 190 * will be rebuilt the next time they are shown. 191 */ 192 invalidateCache() { 193 if (!this._rebuilding) { 194 this._engineInfo = null; 195 } 196 } 197 198 /** 199 * Width in pixels of the one-off buttons. 200 * NOTE: Used in browser/components/search/content/searchbar.js only. 201 * 202 * @returns {number} 203 */ 204 get buttonWidth() { 205 return 48; 206 } 207 208 /** 209 * The popup that contains the one-offs. 210 * 211 * @param {XULPopupElement} val 212 * The new value to set. 213 */ 214 set popup(val) { 215 if (this._popup) { 216 this._popup.removeEventListener("popupshowing", this); 217 this._popup.removeEventListener("popuphidden", this); 218 } 219 if (val) { 220 val.addEventListener("popupshowing", this); 221 val.addEventListener("popuphidden", this); 222 } 223 this._popup = val; 224 225 // If the popup is already open, rebuild the one-offs now. The 226 // popup may be opening, so check that the state is not closed 227 // instead of checking popupOpen. 228 if (val && val.state != "closed") { 229 this._rebuild(); 230 } 231 } 232 233 get popup() { 234 return this._popup; 235 } 236 237 /** 238 * The textbox associated with the one-offs. Set this to a textbox to 239 * automatically keep the related one-offs UI up to date. Otherwise you 240 * can leave it null/undefined, and in that case you should update the 241 * query property manually. 242 * 243 * @param {HTMLInputElement} val 244 * The new value to set. 245 */ 246 set textbox(val) { 247 if (this._textbox) { 248 this._textbox.removeEventListener("input", this); 249 } 250 if (val) { 251 val.addEventListener("input", this); 252 } 253 this._textbox = val; 254 } 255 256 get style() { 257 return this.container.style; 258 } 259 260 get textbox() { 261 return this._textbox; 262 } 263 264 /** 265 * The query string currently shown in the one-offs. If the textbox 266 * property is non-null, then this is automatically updated on 267 * input. 268 * 269 * @param {string} val 270 * The new query string to set. 271 */ 272 set query(val) { 273 this._query = val; 274 if (this.isViewOpen) { 275 let isOneOffSelected = 276 this.selectedButton && 277 this.selectedButton.classList.contains( 278 "searchbar-engine-one-off-item" 279 ) && 280 !( 281 this.selectedButton == this.settingsButton && 282 this.hasAttribute("is_searchbar") 283 ); 284 // Typing de-selects the settings or opensearch buttons at the bottom 285 // of the search panel, as typing shows the user intends to search. 286 if (this.selectedButton && !isOneOffSelected) { 287 this.selectedButton = null; 288 } 289 } 290 } 291 292 get query() { 293 return this._query; 294 } 295 296 /** 297 * The selected one-off including the add-engine button 298 * and the search-settings button. 299 * 300 * @param {LegacySearchOneOffButton|null} val 301 * The selected one-off button. Null if no one-off is selected. 302 */ 303 set selectedButton(val) { 304 let previousButton = this._selectedButton; 305 if (previousButton) { 306 previousButton.removeAttribute("selected"); 307 } 308 if (val) { 309 val.toggleAttribute("selected", true); 310 } 311 this._selectedButton = val; 312 313 if (this.textbox) { 314 if (val) { 315 this.textbox.setAttribute("aria-activedescendant", val.id); 316 } else { 317 let active = this.textbox.getAttribute("aria-activedescendant"); 318 if (active && active.includes("-engine-one-off-item-")) { 319 this.textbox.removeAttribute("aria-activedescendant"); 320 } 321 } 322 } 323 324 this.dispatchEvent(new CustomEvent("SelectedOneOffButtonChanged")); 325 } 326 327 get selectedButton() { 328 return this._selectedButton; 329 } 330 331 /** 332 * The index of the selected one-off, including the add-engine button 333 * and the search-settings button. 334 * 335 * @param {number} val 336 * The new index to set, -1 for nothing selected. 337 */ 338 set selectedButtonIndex(val) { 339 let buttons = this.getSelectableButtons(true); 340 this.selectedButton = buttons[val]; 341 } 342 343 get selectedButtonIndex() { 344 let buttons = this.getSelectableButtons(true); 345 for (let i = 0; i < buttons.length; i++) { 346 if (buttons[i] == this._selectedButton) { 347 return i; 348 } 349 } 350 return -1; 351 } 352 353 async getEngineInfo() { 354 if (this._engineInfo) { 355 return this._engineInfo; 356 } 357 358 this._engineInfo = {}; 359 if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.window)) { 360 this._engineInfo.default = await Services.search.getDefaultPrivate(); 361 } else { 362 this._engineInfo.default = await Services.search.getDefault(); 363 } 364 365 let currentEngineNameToIgnore; 366 if (!this.getAttribute("includecurrentengine")) { 367 currentEngineNameToIgnore = this._engineInfo.default.name; 368 } 369 370 this._engineInfo.engines = ( 371 await Services.search.getVisibleEngines() 372 ).filter(e => { 373 let name = e.name; 374 return ( 375 (!currentEngineNameToIgnore || name != currentEngineNameToIgnore) && 376 !e.hideOneOffButton 377 ); 378 }); 379 380 return this._engineInfo; 381 } 382 383 observe(aEngine, aTopic, aData) { 384 // For the "browser-search-service" topic, we only need to invalidate 385 // the cache on initialization complete or when the engines are reloaded. 386 if (aTopic != "browser-search-service" || aData == "engines-reloaded") { 387 // Make sure the engine list was updated. 388 this.invalidateCache(); 389 } 390 391 if (aData === "engine-icon-changed") { 392 aEngine.getIconURL().then(icon => { 393 this.getSelectableButtons(false) 394 .find(b => b.engine?.id == aEngine.id) 395 ?.setAttribute( 396 "image", 397 icon || "chrome://browser/skin/search-engine-placeholder.png" 398 ); 399 }); 400 } 401 } 402 403 get _maxInlineAddEngines() { 404 return 3; 405 } 406 407 /** 408 * Infallible, non-re-entrant version of `__rebuild()`. 409 */ 410 async _rebuild() { 411 if (this._rebuilding) { 412 return; 413 } 414 415 this._rebuilding = true; 416 try { 417 await this.__rebuild(); 418 } catch (ex) { 419 console.error("Search-one-offs::_rebuild() error:", ex); 420 } finally { 421 this._rebuilding = false; 422 this.dispatchEvent(new Event("rebuild")); 423 } 424 } 425 426 /** 427 * Builds all the UI. 428 */ 429 async __rebuild() { 430 // Return early if the list of engines has not changed. 431 if (!this.popup && this._engineInfo?.domWasUpdated) { 432 return; 433 } 434 435 const addEngines = lazy.OpenSearchManager.getEngines( 436 this.window.gBrowser.selectedBrowser 437 ); 438 439 // Return early if the engines and panel width have not changed. 440 if (this.popup && this._textbox) { 441 let textboxWidth = await this.window.promiseDocumentFlushed(() => { 442 return this._textbox.clientWidth; 443 }); 444 445 if ( 446 this._engineInfo?.domWasUpdated && 447 this._textboxWidth == textboxWidth && 448 this._addEngines == addEngines 449 ) { 450 return; 451 } 452 this._textboxWidth = textboxWidth; 453 this._addEngines = addEngines; 454 } 455 456 const isSearchBar = this.hasAttribute("is_searchbar"); 457 if (isSearchBar) { 458 // Hide the container during updating to avoid flickering. 459 this.container.hidden = true; 460 } 461 462 // Finally, build the list of one-off buttons. 463 while (this.buttons.firstElementChild) { 464 this.buttons.firstElementChild.remove(); 465 } 466 467 let headerText = this.header.querySelector( 468 ".search-panel-one-offs-header-label" 469 ); 470 headerText.id = this.telemetryOrigin + "-one-offs-header-label"; 471 this.buttons.setAttribute("aria-labelledby", headerText.id); 472 473 // For the search-bar, always show the one-off buttons where there is an 474 // option to add an engine. 475 let addEngineNeeded = isSearchBar && addEngines.length; 476 let hideOneOffs = (await this.willHide()) && !addEngineNeeded; 477 478 // The _engineInfo cache is used by more consumers, thus it is not a good 479 // representation of whether this method already updated the one-off buttons 480 // DOM. For this reason we introduce a separate flag tracking the DOM 481 // updating, and use it to know when it's okay to not rebuild the one-offs. 482 // We set this early, since we might either rebuild the DOM or hide it. 483 this._engineInfo.domWasUpdated = true; 484 485 this.container.hidden = hideOneOffs; 486 487 if (hideOneOffs) { 488 return; 489 } 490 491 // Ensure we can refer to the settings buttons by ID: 492 let origin = this.telemetryOrigin; 493 this.settingsButton.id = origin + "-anon-search-settings"; 494 495 let engines = (await this.getEngineInfo()).engines; 496 await this._rebuildEngineList(engines, addEngines); 497 } 498 499 /** 500 * Adds one-offs for the given engines to the DOM. 501 * 502 * @param {Array} engines 503 * The engines to add. 504 * @param {Array} addEngines 505 * The engines that can be added. 506 */ 507 async _rebuildEngineList(engines, addEngines) { 508 for (let i = 0; i < engines.length; ++i) { 509 let engine = engines[i]; 510 let button = this.document.createXULElement("button"); 511 button.engine = engine; 512 button.id = this._buttonIDForEngine(engine); 513 let iconURL = 514 (await engine.getIconURL()) || 515 "chrome://browser/skin/search-engine-placeholder.png"; 516 button.setAttribute("image", iconURL); 517 button.setAttribute("class", "searchbar-engine-one-off-item"); 518 button.setAttribute("tabindex", "-1"); 519 this.setTooltipForEngineButton(button); 520 this.buttons.appendChild(button); 521 } 522 523 for ( 524 let i = 0, len = Math.min(addEngines.length, this._maxInlineAddEngines); 525 i < len; 526 i++ 527 ) { 528 const engine = addEngines[i]; 529 const button = this.document.createXULElement("button"); 530 button.id = this._buttonIDForEngine(engine); 531 button.classList.add("searchbar-engine-one-off-item"); 532 button.classList.add("searchbar-engine-one-off-add-engine"); 533 button.setAttribute("tabindex", "-1"); 534 if (engine.icon) { 535 button.setAttribute("image", engine.icon); 536 } 537 this.document.l10n.setAttributes(button, "search-one-offs-add-engine", { 538 engineName: engine.title, 539 }); 540 button.setAttribute("engine-name", engine.title); 541 button.setAttribute("uri", engine.uri); 542 this.buttons.appendChild(button); 543 } 544 } 545 546 _buttonIDForEngine(engine) { 547 return ( 548 this.telemetryOrigin + 549 "-engine-one-off-item-engine-" + 550 this._engineInfo.engines.indexOf(engine) 551 ); 552 } 553 554 getSelectableButtons(aIncludeNonEngineButtons) { 555 const buttons = [ 556 ...this.buttons.querySelectorAll(".searchbar-engine-one-off-item"), 557 ]; 558 559 if (aIncludeNonEngineButtons) { 560 buttons.push(this.settingsButton); 561 } 562 563 return buttons; 564 } 565 566 /** 567 * Returns information on where a search results page should be loaded: in the 568 * current tab or a new tab. 569 * 570 * @param {event} aEvent 571 * The event that triggered the page load. 572 * @param {boolean} [aForceNewTab] 573 * True to force the load in a new tab. 574 * @returns {object} An object { where, params }. `where` is a string: 575 * "current" or "tab". `params` is an object further describing how 576 * the page should be loaded. 577 */ 578 _whereToOpen(aEvent, aForceNewTab = false) { 579 let where = "current"; 580 let params; 581 // Open ctrl/cmd clicks on one-off buttons in a new background tab. 582 if (aForceNewTab) { 583 where = "tab"; 584 if (Services.prefs.getBoolPref("browser.tabs.loadInBackground")) { 585 params = { 586 inBackground: true, 587 }; 588 } 589 } else { 590 let newTabPref = Services.prefs.getBoolPref("browser.search.openintab"); 591 if ( 592 (KeyboardEvent.isInstance(aEvent) && aEvent.altKey) != newTabPref && 593 !this.window.gBrowser.selectedTab.isEmpty 594 ) { 595 where = "tab"; 596 } 597 if ( 598 MouseEvent.isInstance(aEvent) && 599 (aEvent.button == 1 || aEvent.getModifierState("Accel")) 600 ) { 601 where = "tab"; 602 params = { 603 inBackground: true, 604 }; 605 } 606 } 607 608 return { where, params }; 609 } 610 611 /** 612 * Increments or decrements the index of the currently selected one-off. 613 * 614 * @param {boolean} aForward 615 * If true, the index is incremented, and if false, the index is 616 * decremented. 617 * @param {boolean} aIncludeNonEngineButtons 618 * If true, buttons that do not have engines are included. 619 * These buttons include the OpenSearch and settings buttons. For 620 * example, if the currently selected button is an engine button, 621 * the next button is the settings button, and you pass true for 622 * aForward, then passing true for this value would cause the 623 * settings to be selected. Passing false for this value would 624 * cause the selection to clear or wrap around, depending on what 625 * value you passed for the aWrapAround parameter. 626 * @param {boolean} aWrapAround 627 * If true, the selection wraps around between the first and last 628 * buttons. 629 */ 630 advanceSelection(aForward, aIncludeNonEngineButtons, aWrapAround) { 631 let buttons = this.getSelectableButtons(aIncludeNonEngineButtons); 632 let index; 633 if (this.selectedButton) { 634 let inc = aForward ? 1 : -1; 635 let oldIndex = buttons.indexOf(this.selectedButton); 636 index = (oldIndex + inc + buttons.length) % buttons.length; 637 if ( 638 !aWrapAround && 639 ((aForward && index <= oldIndex) || (!aForward && oldIndex <= index)) 640 ) { 641 // The index has wrapped around, but wrapping around isn't 642 // allowed. 643 index = -1; 644 } 645 } else { 646 index = aForward ? 0 : buttons.length - 1; 647 } 648 this.selectedButton = index < 0 ? null : buttons[index]; 649 } 650 651 /** 652 * This handles key presses specific to the one-off buttons like Tab and 653 * Alt+Up/Down, and Up/Down keys within the buttons. Since one-off buttons 654 * are always used in conjunction with a list of some sort (in this.popup), 655 * it also handles Up/Down keys that cross the boundaries between list 656 * items and the one-off buttons. 657 * 658 * If this method handles the key press, then it will call 659 * event.preventDefault() and return true. 660 * 661 * @param {Event} event 662 * The key event. 663 * @param {number} numListItems 664 * The number of items in the list. The reason that this is a 665 * parameter at all is that the list may contain items at the end 666 * that should be ignored, depending on the consumer. That's true 667 * for the urlbar for example. 668 * @param {boolean} allowEmptySelection 669 * Pass true if it's OK that neither the list nor the one-off 670 * buttons contains a selection. Pass false if either the list or 671 * the one-off buttons (or both) should always contain a selection. 672 * @param {string} [textboxUserValue] 673 * When the last list item is selected and the user presses Down, 674 * the first one-off becomes selected and the textbox value is 675 * restored to the value that the user typed. Pass that value here. 676 * However, if you pass true for allowEmptySelection, you don't need 677 * to pass anything for this parameter. (Pass undefined or null.) 678 * @returns {boolean} True if the one-offs handled the key press. 679 */ 680 handleKeyDown(event, numListItems, allowEmptySelection, textboxUserValue) { 681 if (!this.hasView) { 682 return false; 683 } 684 let handled = this._handleKeyDown( 685 event, 686 numListItems, 687 allowEmptySelection, 688 textboxUserValue 689 ); 690 if (handled) { 691 event.preventDefault(); 692 event.stopPropagation(); 693 } 694 return handled; 695 } 696 697 _handleKeyDown(event, numListItems, allowEmptySelection, textboxUserValue) { 698 if (this.container.hidden) { 699 return false; 700 } 701 if ( 702 event.keyCode == KeyEvent.DOM_VK_RIGHT && 703 this.selectedButton && 704 this.selectedButton.classList.contains("addengine-menu-button") 705 ) { 706 // If the add-engine overflow menu item is selected and the user 707 // presses the right arrow key, open the submenu. Unfortunately 708 // handling the left arrow key -- to close the popup -- isn't 709 // straightforward. Once the popup is open, it consumes all key 710 // events. Setting ignorekeys=handled on it doesn't help, since the 711 // popup handles all arrow keys. Setting ignorekeys=true on it does 712 // mean that the popup no longer consumes the left arrow key, but 713 // then it no longer handles up/down keys to select items in the 714 // popup. 715 this.selectedButton.open = true; 716 return true; 717 } 718 719 // Handle the Tab key, but only if non-Shift modifiers aren't also 720 // pressed to avoid clobbering other shortcuts (like the Alt+Tab 721 // browser tab switcher). The reason this uses getModifierState() and 722 // checks for "AltGraph" is that when you press Shift-Alt-Tab, 723 // event.altKey is actually false for some reason, at least on macOS. 724 // getModifierState("Alt") is also false, but "AltGraph" is true. 725 if ( 726 event.keyCode == KeyEvent.DOM_VK_TAB && 727 !event.getModifierState("Alt") && 728 !event.getModifierState("AltGraph") && 729 !event.getModifierState("Control") && 730 !event.getModifierState("Meta") 731 ) { 732 if ( 733 this.getAttribute("disabletab") == "true" || 734 (event.shiftKey && this.selectedButtonIndex <= 0) || 735 (!event.shiftKey && 736 this.selectedButtonIndex == 737 this.getSelectableButtons(true).length - 1) 738 ) { 739 this.selectedButton = null; 740 return false; 741 } 742 this.selectedViewIndex = -1; 743 this.advanceSelection(!event.shiftKey, true, false); 744 return !!this.selectedButton; 745 } 746 747 if (event.keyCode == KeyboardEvent.DOM_VK_UP) { 748 if (event.altKey) { 749 // Keep the currently selected result in the list (if any) as a 750 // secondary "alt" selection and move the selection up within the 751 // buttons. 752 this.advanceSelection(false, false, false); 753 return true; 754 } 755 if (numListItems == 0) { 756 this.advanceSelection(false, true, false); 757 return true; 758 } 759 if (this.selectedViewIndex > 0) { 760 // Moving up within the list. The autocomplete controller should 761 // handle this case. A button may be selected, so null it. 762 this.selectedButton = null; 763 return false; 764 } 765 if (this.selectedViewIndex == 0) { 766 // Moving up from the top of the list. 767 if (allowEmptySelection) { 768 // Let the autocomplete controller remove selection in the list 769 // and revert the typed text in the textbox. 770 return false; 771 } 772 // Wrap selection around to the last button. 773 if (this.textbox && typeof textboxUserValue == "string") { 774 this.textbox.value = textboxUserValue; 775 } 776 this.selectedViewIndex = -1; 777 this.advanceSelection(false, true, true); 778 return true; 779 } 780 if (!this.selectedButton) { 781 // Moving up from no selection in the list or the buttons, back 782 // down to the last button. 783 this.advanceSelection(false, true, true); 784 return true; 785 } 786 if (this.selectedButtonIndex == 0) { 787 // Moving up from the buttons to the bottom of the list. 788 this.selectedButton = null; 789 return false; 790 } 791 // Moving up/left within the buttons. 792 this.advanceSelection(false, true, false); 793 return true; 794 } 795 796 if (event.keyCode == KeyboardEvent.DOM_VK_DOWN) { 797 if (event.altKey) { 798 // Keep the currently selected result in the list (if any) as a 799 // secondary "alt" selection and move the selection down within 800 // the buttons. 801 this.advanceSelection(true, false, false); 802 return true; 803 } 804 if (numListItems == 0) { 805 this.advanceSelection(true, true, false); 806 return true; 807 } 808 if ( 809 this.selectedViewIndex >= 0 && 810 this.selectedViewIndex < numListItems - 1 811 ) { 812 // Moving down within the list. The autocomplete controller 813 // should handle this case. A button may be selected, so null it. 814 this.selectedButton = null; 815 return false; 816 } 817 if (this.selectedViewIndex == numListItems - 1) { 818 // Moving down from the last item in the list to the buttons. 819 if (!allowEmptySelection) { 820 this.selectedViewIndex = -1; 821 if (this.textbox && typeof textboxUserValue == "string") { 822 this.textbox.value = textboxUserValue; 823 } 824 } 825 this.selectedButtonIndex = 0; 826 if (allowEmptySelection) { 827 // Let the autocomplete controller remove selection in the list 828 // and revert the typed text in the textbox. 829 return false; 830 } 831 return true; 832 } 833 if (this.selectedButton) { 834 let buttons = this.getSelectableButtons(true); 835 if (this.selectedButtonIndex == buttons.length - 1) { 836 // Moving down from the buttons back up to the top of the list. 837 this.selectedButton = null; 838 if (allowEmptySelection) { 839 // Prevent the selection from wrapping around to the top of 840 // the list by returning true, since the list currently has no 841 // selection. Nothing should be selected after handling this 842 // Down key. 843 return true; 844 } 845 return false; 846 } 847 // Moving down/right within the buttons. 848 this.advanceSelection(true, true, false); 849 return true; 850 } 851 return false; 852 } 853 854 if (event.keyCode == KeyboardEvent.DOM_VK_LEFT) { 855 if ( 856 this.selectedButton && 857 this.selectedButton.engine && 858 !this.disableOneOffsHorizontalKeyNavigation 859 ) { 860 // Moving left within the buttons. 861 this.advanceSelection(false, true, true); 862 return true; 863 } 864 return false; 865 } 866 867 if (event.keyCode == KeyboardEvent.DOM_VK_RIGHT) { 868 if ( 869 this.selectedButton && 870 this.selectedButton.engine && 871 !this.disableOneOffsHorizontalKeyNavigation 872 ) { 873 // Moving right within the buttons. 874 this.advanceSelection(true, true, true); 875 return true; 876 } 877 return false; 878 } 879 880 return false; 881 } 882 883 /** 884 * Determines if the target of the event is a one-off button or 885 * context menu on a one-off button. 886 * 887 * @param {Event} event 888 * An event, like a click on a one-off button. 889 * @returns {boolean} True if telemetry was recorded and false if not. 890 */ 891 eventTargetIsAOneOff(event) { 892 if (!event) { 893 return false; 894 } 895 896 let target = event.originalTarget; 897 898 if (KeyboardEvent.isInstance(event) && this.selectedButton) { 899 return true; 900 } 901 if ( 902 MouseEvent.isInstance(event) && 903 Element.isInstance(target) && 904 target.classList.contains("searchbar-engine-one-off-item") 905 ) { 906 return true; 907 } 908 if ( 909 this.window.XULCommandEvent.isInstance(event) && 910 Element.isInstance(target) && 911 target.classList.contains("search-one-offs-context-open-in-new-tab") 912 ) { 913 return true; 914 } 915 916 return false; 917 } 918 919 // Methods for subclasses to override 920 921 /** 922 * @returns {boolean} True if the one-offs are connected to a view. 923 */ 924 get hasView() { 925 return !!this.popup; 926 } 927 928 /** 929 * @returns {boolean} True if the view is open. 930 */ 931 get isViewOpen() { 932 // @ts-expect-error - MozSearchAutocompleteRichlistboxPopup is defined in JS and lacks type declarations. 933 return this.popup && this.popup.popupOpen; 934 } 935 936 /** 937 * @returns {number} The selected index in the view or -1 if no selection. 938 */ 939 get selectedViewIndex() { 940 // @ts-expect-error - MozSearchAutocompleteRichlistboxPopup is defined in JS and lacks type declarations. 941 return this.popup.selectedIndex; 942 } 943 944 /** 945 * Sets the selected index in the view. 946 * 947 * @param {number} val 948 * The selected index or -1 if no selection. 949 */ 950 set selectedViewIndex(val) { 951 // @ts-expect-error - MozSearchAutocompleteRichlistboxPopup is defined in JS and lacks type declarations. 952 this.popup.selectedIndex = val; 953 } 954 955 /** 956 * Closes the view. 957 */ 958 closeView() { 959 this.popup.hidePopup(); 960 } 961 962 /** 963 * Called when a one-off is clicked or the "Search in New Tab" context menu 964 * item is picked. This is not called for the settings button. 965 * 966 * @param {event} event 967 * The event that triggered the pick. 968 * @param {nsISearchEngine} engine 969 * The engine that was picked. 970 * @param {boolean} forceNewTab 971 * True if the search results page should be loaded in a new tab. 972 */ 973 handleSearchCommand(event, engine, forceNewTab = false) { 974 let { where, params } = this._whereToOpen(event, forceNewTab); 975 // @ts-expect-error - MozSearchAutocompleteRichlistboxPopup is defined in JS and lacks type declarations. 976 this.popup.handleOneOffSearch(event, engine, where, params); 977 } 978 979 /** 980 * Sets the tooltip for a one-off button with an engine. This should set 981 * either the `tooltiptext` attribute or the relevant l10n ID. 982 * 983 * @param {LegacySearchOneOffButton} button 984 * The one-off button. 985 */ 986 setTooltipForEngineButton(button) { 987 button.setAttribute("tooltiptext", button.engine.name); 988 } 989 990 // Event handlers below. 991 992 _on_mousedown(event) { 993 // This is necessary to prevent the input from losing focus and closing the 994 // popup. Unfortunately it also has the side effect of preventing the 995 // buttons from receiving the `:active` pseudo-class. 996 event.preventDefault(); 997 } 998 999 _on_click(event) { 1000 if (event.button == 2) { 1001 return; // ignore right clicks. 1002 } 1003 1004 let button = event.originalTarget; 1005 let engine = button.engine; 1006 1007 if (!engine) { 1008 return; 1009 } 1010 1011 if (!this.textbox.value) { 1012 if (event.shiftKey) { 1013 // @ts-expect-error - MozSearchAutocompleteRichlistboxPopup is defined in JS and lacks type declarations. 1014 this.popup.openSearchForm(event, engine); 1015 } 1016 return; 1017 } 1018 // Select the clicked button so that consumers can easily tell which 1019 // button was acted on. 1020 this.selectedButton = button; 1021 this.handleSearchCommand(event, engine); 1022 } 1023 1024 async _on_command(event) { 1025 let target = event.target; 1026 1027 if (target == this.settingsButton) { 1028 this.window.openPreferences("paneSearch"); 1029 1030 // If the preference tab was already selected, the panel doesn't 1031 // close itself automatically. 1032 this.closeView(); 1033 return; 1034 } 1035 1036 if (target.classList.contains("searchbar-engine-one-off-add-engine")) { 1037 // On success, hide the panel and tell event listeners to reshow it to 1038 // show the new engine. 1039 lazy.SearchUIUtils.addOpenSearchEngine( 1040 target.getAttribute("uri"), 1041 target.getAttribute("image"), 1042 this.window.gBrowser.selectedBrowser.browsingContext 1043 ) 1044 .then(result => { 1045 if (result) { 1046 this._rebuild(); 1047 } 1048 }) 1049 .catch(console.error); 1050 return; 1051 } 1052 1053 if (target.classList.contains("search-one-offs-context-open-in-new-tab")) { 1054 // Select the context-clicked button so that consumers can easily 1055 // tell which button was acted on. 1056 this.selectedButton = target.closest("menupopup")._triggerButton; 1057 if (this.textbox.value) { 1058 this.handleSearchCommand(event, this.selectedButton.engine, true); 1059 } else { 1060 // @ts-expect-error - MozSearchAutocompleteRichlistboxPopup is defined in JS and lacks type declarations. 1061 this.popup.openSearchForm(event, this.selectedButton.engine, true); 1062 } 1063 } 1064 1065 const isPrivateButton = target.classList.contains( 1066 "search-one-offs-context-set-default-private" 1067 ); 1068 if ( 1069 target.classList.contains("search-one-offs-context-set-default") || 1070 isPrivateButton 1071 ) { 1072 const engineType = isPrivateButton 1073 ? "defaultPrivateEngine" 1074 : "defaultEngine"; 1075 let currentEngine = Services.search[engineType]; 1076 1077 const isPrivateWin = lazy.PrivateBrowsingUtils.isWindowPrivate( 1078 this.window 1079 ); 1080 let button = target.closest("menupopup")._triggerButton; 1081 // We're about to replace this, so it must be stored now. 1082 let newDefaultEngine = button.engine; 1083 if ( 1084 !this.getAttribute("includecurrentengine") && 1085 isPrivateButton == isPrivateWin 1086 ) { 1087 // Make the target button of the context menu reflect the current 1088 // search engine first. Doing this as opposed to rebuilding all the 1089 // one-off buttons avoids flicker. 1090 let iconURL = 1091 (await currentEngine.getIconURL()) || 1092 "chrome://browser/skin/search-engine-placeholder.png"; 1093 button.setAttribute("image", iconURL); 1094 button.setAttribute("tooltiptext", currentEngine.name); 1095 button.engine = currentEngine; 1096 } 1097 1098 if (isPrivateButton) { 1099 Services.search.setDefaultPrivate( 1100 newDefaultEngine, 1101 Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR_CONTEXT 1102 ); 1103 } else { 1104 Services.search.setDefault( 1105 newDefaultEngine, 1106 Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR_CONTEXT 1107 ); 1108 } 1109 } 1110 } 1111 1112 _on_contextmenu(event) { 1113 let target = event.originalTarget; 1114 // Prevent the context menu from appearing except on the one off buttons. 1115 if ( 1116 !target.classList.contains("searchbar-engine-one-off-item") || 1117 target.classList.contains("search-setting-button") 1118 ) { 1119 event.preventDefault(); 1120 return; 1121 } 1122 this.contextMenuPopup 1123 .querySelector(".search-one-offs-context-set-default") 1124 .setAttribute( 1125 "disabled", 1126 target.engine == Services.search.defaultEngine.wrappedJSObject 1127 ); 1128 1129 const privateDefaultItem = this.contextMenuPopup.querySelector( 1130 ".search-one-offs-context-set-default-private" 1131 ); 1132 1133 if ( 1134 Services.prefs.getBoolPref( 1135 "browser.search.separatePrivateDefault.ui.enabled", 1136 false 1137 ) && 1138 Services.prefs.getBoolPref("browser.search.separatePrivateDefault", false) 1139 ) { 1140 privateDefaultItem.hidden = false; 1141 privateDefaultItem.setAttribute( 1142 "disabled", 1143 target.engine == Services.search.defaultPrivateEngine.wrappedJSObject 1144 ); 1145 } else { 1146 privateDefaultItem.hidden = true; 1147 } 1148 1149 // When a context menu is opened on a one-off button, this is set to the 1150 // button to be used for the command. 1151 this.contextMenuPopup._triggerButton = target; 1152 this.contextMenuPopup.openPopupAtScreen(event.screenX, event.screenY, true); 1153 event.preventDefault(); 1154 } 1155 1156 _on_input(event) { 1157 // Allow the consumer's input to override its value property with 1158 // a oneOffSearchQuery property. That way if the value is not 1159 // actually what the user typed (e.g., it's autofilled, or it's a 1160 // mozaction URI), the consumer has some way of providing it. 1161 this.query = event.target.oneOffSearchQuery || event.target.value; 1162 } 1163 1164 _on_popupshowing() { 1165 this._rebuild(); 1166 } 1167 1168 _on_popuphidden() { 1169 this.selectedButton = null; 1170 } 1171 }