/* 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"; // This is loaded into all XUL windows. Wrap in a block to prevent // leaking to window scope. { const { AppConstants } = ChromeUtils.importESModule( "resource://gre/modules/AppConstants.sys.mjs" ); const { XPCOMUtils } = ChromeUtils.importESModule( "resource://gre/modules/XPCOMUtils.sys.mjs" ); class AutocompleteInput extends HTMLInputElement { constructor() { super(); this.popupSelectedIndex = -1; ChromeUtils.defineESModuleGetters(this, { PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", }); XPCOMUtils.defineLazyPreferenceGetter( this, "disablePopupAutohide", "ui.popup.disable_autohide", false ); this.addEventListener("input", event => { this.onInput(event); }); this.addEventListener("keydown", event => this.handleKeyDown(event)); this.addEventListener( "compositionstart", () => { if ( this.mController.input.wrappedJSObject == this.nsIAutocompleteInput ) { this.mController.handleStartComposition(); } }, true ); this.addEventListener( "compositionend", () => { if ( this.mController.input.wrappedJSObject == this.nsIAutocompleteInput ) { this.mController.handleEndComposition(); } }, true ); this.addEventListener( "focus", () => { this.attachController(); if ( window.gBrowser && window.gBrowser.selectedBrowser.hasAttribute("usercontextid") ) { this.userContextId = parseInt( window.gBrowser.selectedBrowser.getAttribute("usercontextid") ); } else { this.userContextId = 0; } }, true ); this.addEventListener( "blur", () => { if (!this._dontBlur) { if (this.forceComplete && this.mController.matchCount >= 1) { // If forceComplete is requested, we need to call the enter processing // on blur so the input will be forced to the closest match. // Thunderbird is the only consumer of forceComplete and this is used // to force an recipient's email to the exact address book entry. this.mController.handleEnter(true); } if (!this.ignoreBlurWhileSearching) { this._dontClosePopup = this.disablePopupAutohide; this.detachController(); } } }, true ); } connectedCallback() { this.setAttribute("is", "autocomplete-input"); this.setAttribute("autocomplete", "off"); this.mController = Cc[ "@mozilla.org/autocomplete/controller;1" ].getService(Ci.nsIAutoCompleteController); this.mSearchNames = null; this.mIgnoreInput = false; this.noRollupOnEmptySearch = false; this._popup = null; this.nsIAutocompleteInput = this.getCustomInterfaceCallback( Ci.nsIAutoCompleteInput ); this.valueIsTyped = false; } get popup() { // Memoize the result in a field rather than replacing this property, // so that it can be reset along with the binding. if (this._popup) { return this._popup; } let popup = null; let popupId = this.getAttribute("autocompletepopup"); if (popupId) { popup = document.getElementById(popupId); } /* This path is only used in tests, we have the and in document for other usages */ if (!popup) { popup = document.createXULElement("panel", { is: "autocomplete-richlistbox-popup", }); popup.setAttribute("type", "autocomplete-richlistbox"); popup.setAttribute("noautofocus", "true"); if (!this._popupset) { this._popupset = document.createXULElement("popupset"); document.documentElement.appendChild(this._popupset); } this._popupset.appendChild(popup); } popup.mInput = this; return (this._popup = popup); } get popupElement() { return this.popup; } get controller() { return this.mController; } set popupOpen(val) { if (val) { this.openPopup(); } else { this.closePopup(); } } get popupOpen() { return this.popup.popupOpen; } set disableAutoComplete(val) { this.setAttribute("disableautocomplete", val); } get disableAutoComplete() { return this.getAttribute("disableautocomplete") == "true"; } set completeDefaultIndex(val) { this.setAttribute("completedefaultindex", val); } get completeDefaultIndex() { return this.getAttribute("completedefaultindex") == "true"; } set completeSelectedIndex(val) { this.setAttribute("completeselectedindex", val); } get completeSelectedIndex() { return this.getAttribute("completeselectedindex") == "true"; } set forceComplete(val) { this.setAttribute("forcecomplete", val); } get forceComplete() { return this.getAttribute("forcecomplete") == "true"; } set minResultsForPopup(val) { this.setAttribute("minresultsforpopup", val); } get minResultsForPopup() { var m = parseInt(this.getAttribute("minresultsforpopup")); return isNaN(m) ? 1 : m; } set timeout(val) { this.setAttribute("timeout", val); } get timeout() { var t = parseInt(this.getAttribute("timeout")); return isNaN(t) ? 50 : t; } set searchParam(val) { this.setAttribute("autocompletesearchparam", val); } get searchParam() { return this.getAttribute("autocompletesearchparam") || ""; } get searchCount() { this.initSearchNames(); return this.mSearchNames.length; } get inPrivateContext() { return this.PrivateBrowsingUtils.isWindowPrivate(window); } get noRollupOnCaretMove() { return this.popup.getAttribute("norolluponanchor") == "true"; } set textValue(val) { // "input" event is automatically dispatched by the editor if // necessary. this._setValueInternal(val, true); } get textValue() { return this.value; } /** * =================== nsIDOMXULMenuListElement =================== */ get editable() { return true; } set open(val) { if (val) { this.showHistoryPopup(); } else { this.closePopup(); } } get open() { return this.getAttribute("open") == "true"; } set value(val) { this._setValueInternal(val, false); } get value() { return super.value; } get focused() { return this === document.activeElement; } /** * maximum number of rows to display at a time when opening the popup normally * (e.g., focus element and press the down arrow) */ set maxRows(val) { this.setAttribute("maxrows", val); } get maxRows() { return parseInt(this.getAttribute("maxrows")) || 0; } /** * maximum number of rows to display at a time when opening the popup by * clicking the dropmarker (for inputs that have one) */ set maxdropmarkerrows(val) { this.setAttribute("maxdropmarkerrows", val); } get maxdropmarkerrows() { return parseInt(this.getAttribute("maxdropmarkerrows"), 10) || 14; } /** * option to allow scrolling through the list via the tab key, rather than * tab moving focus out of the textbox */ set tabScrolling(val) { this.setAttribute("tabscrolling", val); } get tabScrolling() { return this.getAttribute("tabscrolling") == "true"; } /** * option to completely ignore any blur events while searches are * still going on. */ set ignoreBlurWhileSearching(val) { this.setAttribute("ignoreblurwhilesearching", val); } get ignoreBlurWhileSearching() { return this.getAttribute("ignoreblurwhilesearching") == "true"; } /** * option to highlight entries that don't have any matches */ set highlightNonMatches(val) { this.setAttribute("highlightnonmatches", val); } get highlightNonMatches() { return this.getAttribute("highlightnonmatches") == "true"; } getSearchAt(aIndex) { this.initSearchNames(); return this.mSearchNames[aIndex]; } selectTextRange(aStartIndex, aEndIndex) { super.setSelectionRange(aStartIndex, aEndIndex); } onSearchBegin() { if (this.popup && typeof this.popup.onSearchBegin == "function") { this.popup.onSearchBegin(); } } onSearchComplete() { if (this.mController.matchCount == 0) { this.setAttribute("nomatch", "true"); } else { this.removeAttribute("nomatch"); } if (this.ignoreBlurWhileSearching && !this.focused) { this.handleEnter(); this.detachController(); } } onTextEntered(event) { if (this.getAttribute("notifylegacyevents") === "true") { let e = new CustomEvent("textEntered", { bubbles: false, cancelable: true, detail: { rootEvent: event }, }); return !this.dispatchEvent(e); } return false; } onTextReverted(event) { if (this.getAttribute("notifylegacyevents") === "true") { let e = new CustomEvent("textReverted", { bubbles: false, cancelable: true, detail: { rootEvent: event }, }); return !this.dispatchEvent(e); } return false; } /** * =================== PRIVATE MEMBERS =================== */ /* * ::::::::::::: autocomplete controller ::::::::::::: */ attachController() { this.mController.input = this.nsIAutocompleteInput; } detachController() { if ( this.mController.input && this.mController.input.wrappedJSObject == this.nsIAutocompleteInput ) { this.mController.input = null; } } /** * ::::::::::::: popup opening ::::::::::::: */ openPopup() { if (this.focused) { this.popup.openAutocompletePopup(this.nsIAutocompleteInput, this); } } closePopup() { if (this._dontClosePopup) { delete this._dontClosePopup; return; } this.popup.closePopup(); } showHistoryPopup() { // Store our "normal" maxRows on the popup, so that it can reset the // value when the popup is hidden. this.popup._normalMaxRows = this.maxRows; // Temporarily change our maxRows, since we want the dropdown to be a // different size in this case. The popup's popupshowing/popuphiding // handlers will take care of resetting this. this.maxRows = this.maxdropmarkerrows; // Ensure that we have focus. if (!this.focused) { this.focus(); } this.attachController(); this.mController.startSearch(""); } toggleHistoryPopup() { if (!this.popup.popupOpen) { this.showHistoryPopup(); } else { this.closePopup(); } } handleKeyDown(aEvent) { // Re: urlbarDeferred, see the comment in urlbarBindings.xml. if (aEvent.defaultPrevented && !aEvent.urlbarDeferred) { return false; } if ( typeof this.onBeforeHandleKeyDown == "function" && this.onBeforeHandleKeyDown(aEvent) ) { return true; } const isMac = AppConstants.platform == "macosx"; var cancel = false; // Catch any keys that could potentially move the caret. Ctrl can be // used in combination with these keys on Windows and Linux; and Alt // can be used on OS X, so make sure the unused one isn't used. let metaKey = isMac ? aEvent.ctrlKey : aEvent.altKey; if (!metaKey) { switch (aEvent.keyCode) { case KeyEvent.DOM_VK_LEFT: case KeyEvent.DOM_VK_RIGHT: case KeyEvent.DOM_VK_HOME: cancel = this.mController.handleKeyNavigation(aEvent.keyCode); break; } } // Handle keys that are not part of a keyboard shortcut (no Ctrl or Alt) if (!aEvent.ctrlKey && !aEvent.altKey) { switch (aEvent.keyCode) { case KeyEvent.DOM_VK_TAB: if (this.tabScrolling && this.popup.popupOpen) { cancel = this.mController.handleKeyNavigation( aEvent.shiftKey ? KeyEvent.DOM_VK_UP : KeyEvent.DOM_VK_DOWN ); } else if (this.forceComplete && this.mController.matchCount >= 1) { this.mController.handleTab(); } break; case KeyEvent.DOM_VK_UP: case KeyEvent.DOM_VK_DOWN: case KeyEvent.DOM_VK_PAGE_UP: case KeyEvent.DOM_VK_PAGE_DOWN: cancel = this.mController.handleKeyNavigation(aEvent.keyCode); break; } } // Handle readline/emacs-style navigation bindings on Mac. if ( isMac && this.popup.popupOpen && aEvent.ctrlKey && (aEvent.key === "n" || aEvent.key === "p") ) { const effectiveKey = aEvent.key === "p" ? KeyEvent.DOM_VK_UP : KeyEvent.DOM_VK_DOWN; cancel = this.mController.handleKeyNavigation(effectiveKey); } // Handle keys we know aren't part of a shortcut, even with Alt or // Ctrl. switch (aEvent.keyCode) { case KeyEvent.DOM_VK_ESCAPE: cancel = this.mController.handleEscape(); break; case KeyEvent.DOM_VK_RETURN: if (isMac) { // Prevent the default action, since it will beep on Mac if (aEvent.metaKey) { aEvent.preventDefault(); } } if (this.popup.selectedIndex >= 0) { this.popupSelectedIndex = this.popup.selectedIndex; } cancel = this.handleEnter(aEvent); break; case KeyEvent.DOM_VK_DELETE: if (isMac && !aEvent.shiftKey) { break; } cancel = this.handleDelete(); break; case KeyEvent.DOM_VK_BACK_SPACE: if (isMac && aEvent.shiftKey) { cancel = this.handleDelete(); } break; case KeyEvent.DOM_VK_DOWN: case KeyEvent.DOM_VK_UP: if (aEvent.altKey) { this.toggleHistoryPopup(); } break; case KeyEvent.DOM_VK_F4: if (!isMac) { this.toggleHistoryPopup(); } break; } if (cancel) { aEvent.stopPropagation(); aEvent.preventDefault(); } return true; } handleEnter(event) { return this.mController.handleEnter(false, event || null); } handleDelete() { return this.mController.handleDelete(); } /** * ::::::::::::: miscellaneous ::::::::::::: */ initSearchNames() { if (!this.mSearchNames) { var names = this.getAttribute("autocompletesearch"); if (!names) { this.mSearchNames = []; } else { this.mSearchNames = names.split(" "); } } } _focus() { this._dontBlur = true; this.focus(); this._dontBlur = false; } resetActionType() { if (this.mIgnoreInput) { return; } this.removeAttribute("actiontype"); } _setValueInternal(value, isUserInput) { this.mIgnoreInput = true; if (typeof this.onBeforeValueSet == "function") { value = this.onBeforeValueSet(value); } this.valueIsTyped = false; if (isUserInput) { super.setUserInput(value); } else { super.value = value; } this.mIgnoreInput = false; var event = document.createEvent("Events"); event.initEvent("ValueChange", true, true); super.dispatchEvent(event); return value; } onInput() { if ( !this.mIgnoreInput && this.mController.input.wrappedJSObject == this.nsIAutocompleteInput ) { this.valueIsTyped = true; this.mController.handleText(); } this.resetActionType(); } } MozHTMLElement.implementCustomInterface(AutocompleteInput, [ Ci.nsIAutoCompleteInput, Ci.nsIDOMXULMenuListElement, ]); customElements.define("autocomplete-input", AutocompleteInput, { extends: "input", }); }