diff options
Diffstat (limited to 'toolkit/content/widgets/findbar.js')
-rw-r--r-- | toolkit/content/widgets/findbar.js | 1382 |
1 files changed, 1382 insertions, 0 deletions
diff --git a/toolkit/content/widgets/findbar.js b/toolkit/content/widgets/findbar.js new file mode 100644 index 0000000000..4fd3030d97 --- /dev/null +++ b/toolkit/content/widgets/findbar.js @@ -0,0 +1,1382 @@ +/* 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 chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + const PREFS_TO_OBSERVE_BOOL = new Map([ + ["findAsYouType", "accessibility.typeaheadfind"], + ["manualFAYT", "accessibility.typeaheadfind.manual"], + ["typeAheadLinksOnly", "accessibility.typeaheadfind.linksonly"], + ["entireWord", "findbar.entireword"], + ["highlightAll", "findbar.highlightAll"], + ["useModalHighlight", "findbar.modalHighlight"], + ]); + const PREFS_TO_OBSERVE_INT = new Map([ + ["typeAheadCaseSensitive", "accessibility.typeaheadfind.casesensitive"], + ["matchDiacritics", "findbar.matchdiacritics"], + ]); + const PREFS_TO_OBSERVE_ALL = new Map([ + ...PREFS_TO_OBSERVE_BOOL, + ...PREFS_TO_OBSERVE_INT, + ]); + const TOPIC_MAC_APP_ACTIVATE = "mac_app_activate"; + + class MozFindbar extends MozXULElement { + static get markup() { + return ` + <hbox anonid="findbar-container" class="findbar-container" flex="1" align="center"> + <hbox anonid="findbar-textbox-wrapper" align="stretch"> + <html:input anonid="findbar-textbox" class="findbar-textbox" /> + <toolbarbutton anonid="find-previous" class="findbar-find-previous tabbable" + data-l10n-attrs="tooltiptext" data-l10n-id="findbar-previous" + oncommand="onFindAgainCommand(true);" disabled="true" /> + <toolbarbutton anonid="find-next" class="findbar-find-next tabbable" + data-l10n-id="findbar-next" oncommand="onFindAgainCommand(false);" disabled="true" /> + </hbox> + <checkbox anonid="highlight" class="findbar-highlight tabbable" + data-l10n-id="findbar-highlight-all2" oncommand="toggleHighlight(this.checked);"/> + <checkbox anonid="find-case-sensitive" class="findbar-case-sensitive tabbable" + data-l10n-id="findbar-case-sensitive" oncommand="_setCaseSensitivity(this.checked ? 1 : 0);"/> + <checkbox anonid="find-match-diacritics" class="findbar-match-diacritics tabbable" + data-l10n-id="findbar-match-diacritics" oncommand="_setDiacriticMatching(this.checked ? 1 : 0);"/> + <checkbox anonid="find-entire-word" class="findbar-entire-word tabbable" + data-l10n-id="findbar-entire-word" oncommand="toggleEntireWord(this.checked);"/> + <label anonid="match-case-status" class="findbar-label" + data-l10n-id="findbar-case-sensitive-status" hidden="true" /> + <label anonid="match-diacritics-status" class="findbar-label" + data-l10n-id="findbar-match-diacritics-status" hidden="true" /> + <label anonid="entire-word-status" class="findbar-label" + data-l10n-id="findbar-entire-word-status" hidden="true" /> + <label anonid="found-matches" class="findbar-label found-matches" hidden="true" /> + <image anonid="find-status-icon" class="find-status-icon" /> + <description anonid="find-status" control="findbar-textbox" class="findbar-label findbar-find-status" /> + </hbox> + <toolbarbutton anonid="find-closebutton" class="findbar-closebutton tabbable close-icon" + data-l10n-id="findbar-find-button-close" oncommand="close();"/> + `; + } + + constructor() { + super(); + MozXULElement.insertFTLIfNeeded("toolkit/main-window/findbar.ftl"); + this.destroy = this.destroy.bind(this); + + // We have to guard against `this.close` being |null| due to an unknown + // issue, which is tracked in bug 957999. + this.addEventListener( + "keypress", + event => { + if (event.keyCode == event.DOM_VK_ESCAPE) { + if (this.close) { + this.close(); + } + event.preventDefault(); + } + }, + true + ); + } + + connectedCallback() { + // Hide the findbar immediately without animation. This prevents a flicker in the case where + // we'll never be shown (i.e. adopting a tab that has a previously-opened-but-now-closed + // findbar into a new window). + this.setAttribute("noanim", "true"); + this.hidden = true; + this.appendChild(this.constructor.fragment); + if (AppConstants.platform == "macosx") { + this.insertBefore( + this.getElement("find-closebutton"), + this.getElement("findbar-container") + ); + } + + /** + * Please keep in sync with toolkit/modules/FindBarContent.sys.mjs + */ + this.FIND_NORMAL = 0; + + this.FIND_TYPEAHEAD = 1; + + this.FIND_LINKS = 2; + + this._findMode = 0; + + this._flashFindBar = 0; + + this._initialFlashFindBarCount = 6; + + /** + * For tests that need to know when the find bar is finished + * initializing, we store a promise to notify on. + */ + this._startFindDeferred = null; + + this._browser = null; + + this._destroyed = false; + + this._xulBrowserWindow = null; + + // These elements are accessed frequently and are therefore cached. + this._findField = this.getElement("findbar-textbox"); + this._foundMatches = this.getElement("found-matches"); + this._findStatusIcon = this.getElement("find-status-icon"); + this._findStatusDesc = this.getElement("find-status"); + + this._foundURL = null; + + let prefsvc = Services.prefs; + + this.quickFindTimeoutLength = prefsvc.getIntPref( + "accessibility.typeaheadfind.timeout" + ); + this._flashFindBar = prefsvc.getIntPref( + "accessibility.typeaheadfind.flashBar" + ); + + let observe = (this._observe = this.observe.bind(this)); + for (let [propName, prefName] of PREFS_TO_OBSERVE_ALL) { + prefsvc.addObserver(prefName, observe); + let prefGetter = PREFS_TO_OBSERVE_BOOL.has(propName) ? "Bool" : "Int"; + this["_" + propName] = prefsvc[`get${prefGetter}Pref`](prefName); + } + Services.obs.addObserver(observe, TOPIC_MAC_APP_ACTIVATE); + + this._findResetTimeout = -1; + + // Make sure the FAYT keypress listener is attached by initializing the + // browser property. + if (this.getAttribute("browserid")) { + setTimeout(() => { + // eslint-disable-next-line no-self-assign + this.browser = this.browser; + }, 0); + } + + window.addEventListener("unload", this.destroy); + + this._findField.addEventListener("input", event => { + // We should do nothing during composition. E.g., composing string + // before converting may matches a forward word of expected word. + // After that, even if user converts the composition string to the + // expected word, it may find second or later searching word in the + // document. + if (this._isIMEComposing) { + return; + } + + const value = this._findField.value; + if (this._hadValue && !value) { + this._willfullyDeleted = true; + this._hadValue = false; + } else if (value.trim()) { + this._hadValue = true; + this._willfullyDeleted = false; + } + this._find(value); + }); + + this._findField.addEventListener("keypress", event => { + switch (event.keyCode) { + case KeyEvent.DOM_VK_RETURN: + if (this.findMode == this.FIND_NORMAL) { + let findString = this._findField; + if (!findString.value) { + return; + } + if (event.getModifierState("Accel")) { + this.getElement("highlight").click(); + return; + } + + this.onFindAgainCommand(event.shiftKey); + } else { + this._finishFAYT(event); + } + break; + case KeyEvent.DOM_VK_TAB: + let shouldHandle = + !event.altKey && !event.ctrlKey && !event.metaKey; + if (shouldHandle && this.findMode != this.FIND_NORMAL) { + this._finishFAYT(event); + } + break; + case KeyEvent.DOM_VK_PAGE_UP: + case KeyEvent.DOM_VK_PAGE_DOWN: + if ( + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey + ) { + this.browser.finder.keyPress(event); + event.preventDefault(); + } + break; + case KeyEvent.DOM_VK_UP: + case KeyEvent.DOM_VK_DOWN: + this.browser.finder.keyPress(event); + event.preventDefault(); + break; + } + }); + + this._findField.addEventListener("blur", event => { + // Note: This code used to remove the selection + // if it matched an editable. + this.browser.finder.enableSelection(); + }); + + this._findField.addEventListener("focus", event => { + this._updateBrowserWithState(); + }); + + this._findField.addEventListener("compositionstart", event => { + // Don't close the find toolbar while IME is composing. + let findbar = this; + findbar._isIMEComposing = true; + if (findbar._quickFindTimeout) { + clearTimeout(findbar._quickFindTimeout); + findbar._quickFindTimeout = null; + findbar._updateBrowserWithState(); + } + }); + + this._findField.addEventListener("compositionend", event => { + this._isIMEComposing = false; + if (this.findMode != this.FIND_NORMAL) { + this._setFindCloseTimeout(); + } + }); + + this._findField.addEventListener("dragover", event => { + if (event.dataTransfer.types.includes("text/plain")) { + event.preventDefault(); + } + }); + + this._findField.addEventListener("drop", event => { + let value = event.dataTransfer.getData("text/plain"); + this._findField.value = value; + this._find(value); + event.stopPropagation(); + event.preventDefault(); + }); + } + + set findMode(val) { + this._findMode = val; + this._updateBrowserWithState(); + } + + get findMode() { + return this._findMode; + } + + set prefillWithSelection(val) { + this.setAttribute("prefillwithselection", val); + } + + get prefillWithSelection() { + return this.getAttribute("prefillwithselection") != "false"; + } + + get hasTransactions() { + if (this._findField.value) { + return true; + } + + // Watch out for lazy editor init + if (this._findField.editor) { + return this._findField.editor.canUndo || this._findField.editor.canRedo; + } + return false; + } + + set browser(val) { + function setFindbarInActor(browser, findbar) { + if (!browser.frameLoader) { + return; + } + + let windowGlobal = browser.browsingContext.currentWindowGlobal; + if (windowGlobal) { + let findbarParent = windowGlobal.getActor("FindBar"); + if (findbarParent) { + findbarParent.setFindbar(browser, findbar); + } + } + } + + if (this._browser) { + setFindbarInActor(this._browser, null); + + let finder = this._browser.finder; + if (finder) { + finder.removeResultListener(this); + } + } + + this._browser = val; + if (this._browser) { + // Need to do this to ensure the correct initial state. + this._updateBrowserWithState(); + + setFindbarInActor(this._browser, this); + + this._browser.finder.addResultListener(this); + } + } + + get browser() { + if (!this._browser) { + const id = this.getAttribute("browserid"); + if (id) { + this._browser = document.getElementById(id); + } + } + return this._browser; + } + + observe(subject, topic, prefName) { + if (topic == TOPIC_MAC_APP_ACTIVATE) { + this._onAppActivateMac(); + return; + } + + if (topic != "nsPref:changed") { + return; + } + + let prefsvc = Services.prefs; + + switch (prefName) { + case "accessibility.typeaheadfind": + this._findAsYouType = prefsvc.getBoolPref(prefName); + break; + case "accessibility.typeaheadfind.manual": + this._manualFAYT = prefsvc.getBoolPref(prefName); + break; + case "accessibility.typeaheadfind.timeout": + this.quickFindTimeoutLength = prefsvc.getIntPref(prefName); + break; + case "accessibility.typeaheadfind.linksonly": + this._typeAheadLinksOnly = prefsvc.getBoolPref(prefName); + break; + case "accessibility.typeaheadfind.casesensitive": + this._setCaseSensitivity(prefsvc.getIntPref(prefName)); + break; + case "findbar.entireword": + this._entireWord = prefsvc.getBoolPref(prefName); + this.toggleEntireWord(this._entireWord, true); + break; + case "findbar.highlightAll": + this.toggleHighlight(prefsvc.getBoolPref(prefName), true); + break; + case "findbar.matchdiacritics": + this._setDiacriticMatching(prefsvc.getIntPref(prefName)); + break; + case "findbar.modalHighlight": + this._useModalHighlight = prefsvc.getBoolPref(prefName); + if (this.browser.finder) { + this.browser.finder.onModalHighlightChange(this._useModalHighlight); + } + break; + } + } + + getElement(aAnonymousID) { + return this.querySelector(`[anonid=${aAnonymousID}]`); + } + + /** + * This is necessary because custom elements don't have a "real" destructor. + * This method is called explicitly from disconnectedCallback, and from + * an unload event handler that we add. + */ + destroy() { + if (this._destroyed) { + return; + } + window.removeEventListener("unload", this.destroy); + this._destroyed = true; + + this.browser?._finder?.destroy(); + + // Invoking this setter also removes the message listeners. + this.browser = null; + + let prefsvc = Services.prefs; + let observe = this._observe; + for (let [, prefName] of PREFS_TO_OBSERVE_ALL) { + prefsvc.removeObserver(prefName, observe); + } + + Services.obs.removeObserver(observe, TOPIC_MAC_APP_ACTIVATE); + + // Clear all timers that might still be running. + this._cancelTimers(); + } + + _cancelTimers() { + if (this._flashFindBarTimeout) { + clearInterval(this._flashFindBarTimeout); + this._flashFindBarTimeout = null; + } + if (this._quickFindTimeout) { + clearTimeout(this._quickFindTimeout); + this._quickFindTimeout = null; + } + if (this._findResetTimeout) { + clearTimeout(this._findResetTimeout); + this._findResetTimeout = null; + } + } + + _setFindCloseTimeout() { + if (this._quickFindTimeout) { + clearTimeout(this._quickFindTimeout); + } + + // Don't close the find toolbar while IME is composing OR when the + // findbar is already hidden. + if (this._isIMEComposing || this.hidden) { + this._quickFindTimeout = null; + this._updateBrowserWithState(); + return; + } + + if (this.quickFindTimeoutLength < 1) { + this._quickFindTimeout = null; + } else { + this._quickFindTimeout = setTimeout(() => { + if (this.findMode != this.FIND_NORMAL) { + this.close(); + } + this._quickFindTimeout = null; + }, this.quickFindTimeoutLength); + } + this._updateBrowserWithState(); + } + + /** + * Updates the search match count after each find operation on a new string. + */ + _updateMatchesCount() { + if (!this._dispatchFindEvent("matchescount")) { + return; + } + + this.browser.finder.requestMatchesCount( + this._findField.value, + this.findMode == this.FIND_LINKS + ); + } + + /** + * Turns highlighting of all occurrences on or off. + * + * @param {Boolean} highlight Whether to turn the highlight on or off. + * @param {Boolean} fromPrefObserver Whether the callee is the pref + * observer, which means we should not set + * the same pref again. + */ + toggleHighlight(highlight, fromPrefObserver) { + if (highlight === this._highlightAll) { + return; + } + + this.browser.finder.onHighlightAllChange(highlight); + + this._setHighlightAll(highlight, fromPrefObserver); + + if (!this._dispatchFindEvent("highlightallchange")) { + return; + } + + let word = this._findField.value; + // Bug 429723. Don't attempt to highlight "" + if (highlight && !word) { + return; + } + + this.browser.finder.highlight( + highlight, + word, + this.findMode == this.FIND_LINKS + ); + + // Update the matches count + this._updateMatchesCount(Ci.nsITypeAheadFind.FIND_FOUND); + } + + /** + * Updates the highlight-all mode of the findbar and its UI. + * + * @param {Boolean} highlight Whether to turn the highlight on or off. + * @param {Boolean} fromPrefObserver Whether the callee is the pref + * observer, which means we should not set + * the same pref again. + */ + _setHighlightAll(highlight, fromPrefObserver) { + if (typeof highlight != "boolean") { + highlight = this._highlightAll; + } + if (highlight !== this._highlightAll) { + this._highlightAll = highlight; + if (!fromPrefObserver) { + Services.telemetry.scalarAdd("findbar.highlight_all", 1); + Services.prefs.setBoolPref("findbar.highlightAll", highlight); + } + } + let checkbox = this.getElement("highlight"); + checkbox.checked = this._highlightAll; + } + + /** + * Updates the case-sensitivity mode of the findbar and its UI. + * + * @param {String} [str] The string for which case sensitivity might be + * turned on. This only used when case-sensitivity is + * in auto mode, see `_shouldBeCaseSensitive`. The + * default value for this parameter is the find-field + * value. + * @see _shouldBeCaseSensitive + */ + _updateCaseSensitivity(str) { + let val = str || this._findField.value; + + let caseSensitive = this._shouldBeCaseSensitive(val); + let checkbox = this.getElement("find-case-sensitive"); + let statusLabel = this.getElement("match-case-status"); + checkbox.checked = caseSensitive; + + // Show the checkbox on the full Find bar in non-auto mode. + // Show the label in all other cases. + if ( + this.findMode == this.FIND_NORMAL && + (this._typeAheadCaseSensitive == 0 || this._typeAheadCaseSensitive == 1) + ) { + checkbox.hidden = false; + statusLabel.hidden = true; + } else { + checkbox.hidden = true; + statusLabel.hidden = !caseSensitive; + } + + this.browser.finder.caseSensitive = caseSensitive; + } + + /** + * Sets the findbar case-sensitivity mode. + * + * @param {Number} caseSensitivity 0 - case insensitive, + * 1 - case sensitive, + * 2 - auto = case sensitive if the matching + * string contains upper case letters. + * @see _shouldBeCaseSensitive + */ + _setCaseSensitivity(caseSensitivity) { + this._typeAheadCaseSensitive = caseSensitivity; + this._updateCaseSensitivity(); + this._findFailedString = null; + this._find(); + + this._dispatchFindEvent("casesensitivitychange"); + Services.telemetry.scalarAdd("findbar.match_case", 1); + } + + /** + * Updates the diacritic-matching mode of the findbar and its UI. + * + * @param {String} [str] The string for which diacritic matching might be + * turned on. This is only used when diacritic + * matching is in auto mode, see + * `_shouldMatchDiacritics`. The default value for + * this parameter is the find-field value. + * @see _shouldMatchDiacritics. + */ + _updateDiacriticMatching(str) { + let val = str || this._findField.value; + + let matchDiacritics = this._shouldMatchDiacritics(val); + let checkbox = this.getElement("find-match-diacritics"); + let statusLabel = this.getElement("match-diacritics-status"); + checkbox.checked = matchDiacritics; + + // Show the checkbox on the full Find bar in non-auto mode. + // Show the label in all other cases. + if ( + this.findMode == this.FIND_NORMAL && + (this._matchDiacritics == 0 || this._matchDiacritics == 1) + ) { + checkbox.hidden = false; + statusLabel.hidden = true; + } else { + checkbox.hidden = true; + statusLabel.hidden = !matchDiacritics; + } + + this.browser.finder.matchDiacritics = matchDiacritics; + } + + /** + * Sets the findbar diacritic-matching mode + * @param {Number} diacriticMatching 0 - ignore diacritics, + * 1 - match diacritics, + * 2 - auto = match diacritics if the + * matching string contains + * diacritics. + * @see _shouldMatchDiacritics + */ + _setDiacriticMatching(diacriticMatching) { + this._matchDiacritics = diacriticMatching; + this._updateDiacriticMatching(); + this._findFailedString = null; + this._find(); + + this._dispatchFindEvent("diacriticmatchingchange"); + + Services.telemetry.scalarAdd("findbar.match_diacritics", 1); + } + + /** + * Updates the entire-word mode of the findbar and its UI. + */ + _setEntireWord() { + let entireWord = this._entireWord; + let checkbox = this.getElement("find-entire-word"); + let statusLabel = this.getElement("entire-word-status"); + checkbox.checked = entireWord; + + // Show the checkbox on the full Find bar. + // Show the label in all other cases. + if (this.findMode == this.FIND_NORMAL) { + checkbox.hidden = false; + statusLabel.hidden = true; + } else { + checkbox.hidden = true; + statusLabel.hidden = !entireWord; + } + + this.browser.finder.entireWord = entireWord; + } + + /** + * Sets the findbar entire-word mode. + * + * @param {Boolean} entireWord Whether or not entire-word mode should be + * turned on. + */ + toggleEntireWord(entireWord, fromPrefObserver) { + if (!fromPrefObserver) { + // Just set the pref; our observer will change the find bar behavior. + Services.prefs.setBoolPref("findbar.entireword", entireWord); + + Services.telemetry.scalarAdd("findbar.whole_words", 1); + return; + } + + this._findFailedString = null; + this._find(); + } + + /** + * Opens and displays the find bar. + * + * @param {Number} mode The find mode to be used, which is either + * FIND_NORMAL, FIND_TYPEAHEAD or FIND_LINKS. If not + * passed, we revert to the last find mode if any or + * FIND_NORMAL. + * @return {Boolean} `true` if the find bar wasn't previously open, `false` + * otherwise. + */ + open(mode) { + if (mode != undefined) { + this.findMode = mode; + } + + this._findFailedString = null; + + this._updateFindUI(); + if (this.hidden) { + Services.telemetry.scalarAdd("findbar.shown", 1); + this.removeAttribute("noanim"); + this.hidden = false; + + this._updateStatusUI(Ci.nsITypeAheadFind.FIND_FOUND); + + let event = document.createEvent("Events"); + event.initEvent("findbaropen", true, false); + this.dispatchEvent(event); + + this.browser.finder.onFindbarOpen(); + + return true; + } + return false; + } + + /** + * Closes the findbar. + * + * @param {Boolean} [noAnim] Whether to disable to closing animation. Used + * to close instantly and synchronously, when + * other operations depend on this state. + */ + close(noAnim) { + if (this.hidden) { + return; + } + + if (noAnim) { + this.setAttribute("noanim", true); + } + this.hidden = true; + + let event = document.createEvent("Events"); + event.initEvent("findbarclose", true, false); + this.dispatchEvent(event); + + // 'focusContent()' iterates over all listeners in the chrome + // process, so we need to call it from here. + this.browser.finder.focusContent(); + this.browser.finder.onFindbarClose(); + + this._cancelTimers(); + this._updateBrowserWithState(); + + this._findFailedString = null; + } + + clear() { + this.browser.finder.removeSelection(); + // Clear value and undo/redo transactions + this._findField.value = ""; + this._findField.editor?.clearUndoRedo(); + this.toggleHighlight(false); + this._updateStatusUI(); + this._enableFindButtons(false); + } + + _dispatchKeypressEvent(target, fakeEvent) { + if (!target) { + return; + } + + // The event information comes from the child process. + let event = new target.ownerGlobal.KeyboardEvent( + fakeEvent.type, + fakeEvent + ); + target.dispatchEvent(event); + } + + _updateStatusUIBar(foundURL) { + if (!this._xulBrowserWindow) { + try { + this._xulBrowserWindow = window.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow).XULBrowserWindow; + } catch (ex) {} + if (!this._xulBrowserWindow) { + return false; + } + } + + // Call this has the same effect like hovering over link, + // the browser shows the URL as a tooltip. + this._xulBrowserWindow.setOverLink(foundURL || ""); + return true; + } + + _finishFAYT(keypressEvent) { + this.browser.finder.focusContent(); + + if (keypressEvent) { + keypressEvent.preventDefault(); + } + + this.browser.finder.keyPress(keypressEvent); + + this.close(); + return true; + } + + _shouldBeCaseSensitive(str) { + if (this._typeAheadCaseSensitive == 0) { + return false; + } + if (this._typeAheadCaseSensitive == 1) { + return true; + } + + return str != str.toLowerCase(); + } + + _shouldMatchDiacritics(str) { + if (this._matchDiacritics == 0) { + return false; + } + if (this._matchDiacritics == 1) { + return true; + } + + return str != str.normalize("NFD"); + } + + onMouseUp() { + if (!this.hidden && this.findMode != this.FIND_NORMAL) { + this.close(); + } + } + + /** + * We get a fake event object through an IPC message when FAYT is being used + * from within the browser. We then stuff that input in the find bar here. + * + * @param {Object} fakeEvent Event object that looks and quacks like a + * native DOM KeyPress event. + */ + _onBrowserKeypress(fakeEvent) { + const FAYT_LINKS_KEY = "'"; + const FAYT_TEXT_KEY = "/"; + + if (!this.hidden && this._findField == document.activeElement) { + this._dispatchKeypressEvent(this._findField, fakeEvent); + return; + } + + if (this.findMode != this.FIND_NORMAL && this._quickFindTimeout) { + this._findField.select(); + this._findField.focus(); + this._dispatchKeypressEvent(this._findField, fakeEvent); + return; + } + + let key = fakeEvent.charCode + ? String.fromCharCode(fakeEvent.charCode) + : null; + let manualstartFAYT = + (key == FAYT_LINKS_KEY || key == FAYT_TEXT_KEY) && this._manualFAYT; + let autostartFAYT = + !manualstartFAYT && this._findAsYouType && key && key != " "; + if (manualstartFAYT || autostartFAYT) { + let mode = + key == FAYT_LINKS_KEY || (autostartFAYT && this._typeAheadLinksOnly) + ? this.FIND_LINKS + : this.FIND_TYPEAHEAD; + + // Clear bar first, so that when openFindBar() calls setCaseSensitivity() + // it doesn't get confused by a lingering value + this._findField.value = ""; + + this.open(mode); + this._setFindCloseTimeout(); + this._findField.select(); + this._findField.focus(); + + if (autostartFAYT) { + this._dispatchKeypressEvent(this._findField, fakeEvent); + } else { + this._updateStatusUI(Ci.nsITypeAheadFind.FIND_FOUND); + } + } + } + + _updateBrowserWithState() { + if (this._browser) { + this._browser.sendMessageToActor( + "Findbar:UpdateState", + { + findMode: this.findMode, + isOpenAndFocused: + !this.hidden && document.activeElement == this._findField, + hasQuickFindTimeout: !!this._quickFindTimeout, + }, + "FindBar", + "all" + ); + } + } + + _enableFindButtons(aEnable) { + this.getElement("find-next").disabled = this.getElement( + "find-previous" + ).disabled = !aEnable; + } + + /** + * Determines whether minimalist or general-purpose search UI is to be + * displayed when the find bar is activated. + */ + _updateFindUI() { + let showMinimalUI = this.findMode != this.FIND_NORMAL; + + let nodes = this.getElement("findbar-container").children; + let wrapper = this.getElement("findbar-textbox-wrapper"); + let foundMatches = this._foundMatches; + for (let node of nodes) { + if (node == wrapper || node == foundMatches) { + continue; + } + node.hidden = showMinimalUI; + } + this.getElement("find-next").hidden = this.getElement( + "find-previous" + ).hidden = showMinimalUI; + foundMatches.hidden = showMinimalUI || !foundMatches.value; + this._updateCaseSensitivity(); + this._updateDiacriticMatching(); + this._setEntireWord(); + this._setHighlightAll(); + + if (showMinimalUI) { + this._findField.classList.add("minimal"); + } else { + this._findField.classList.remove("minimal"); + } + + let l10nId; + if (this.findMode == this.FIND_TYPEAHEAD) { + l10nId = "findbar-fast-find"; + } else if (this.findMode == this.FIND_LINKS) { + l10nId = "findbar-fast-find-links"; + } else { + l10nId = "findbar-normal-find"; + } + document.l10n.setAttributes(this._findField, l10nId); + } + + _find(value) { + if (!this._dispatchFindEvent("")) { + return; + } + + let val = value || this._findField.value; + + // We have to carry around an explicit version of this, because + // finder.searchString doesn't update on failed searches. + this.browser._lastSearchString = val; + + // Only search on input if we don't have a last-failed string, + // or if the current search string doesn't start with it. + // In entire-word mode we always attemp a find; since sequential matching + // is not guaranteed, the first character typed may not be a word (no + // match), but the with the second character it may well be a word, + // thus a match. + if ( + !this._findFailedString || + !val.startsWith(this._findFailedString) || + this._entireWord + ) { + // Getting here means the user commanded a find op. Make sure any + // initial prefilling is ignored if it hasn't happened yet. + if (this._startFindDeferred) { + this._startFindDeferred.resolve(); + this._startFindDeferred = null; + } + + this._enableFindButtons(val); + this._updateCaseSensitivity(val); + this._updateDiacriticMatching(val); + this._setEntireWord(); + + this.browser.finder.fastFind( + val, + this.findMode == this.FIND_LINKS, + this.findMode != this.FIND_NORMAL + ); + } + + if (this.findMode != this.FIND_NORMAL) { + this._setFindCloseTimeout(); + } + + if (this._findResetTimeout != -1) { + clearTimeout(this._findResetTimeout); + } + + // allow a search to happen on input again after a second has expired + // since the previous input, to allow for dynamic content and/ or page + // loading. + this._findResetTimeout = setTimeout(() => { + this._findFailedString = null; + this._findResetTimeout = -1; + }, 1000); + } + + _flash() { + if (this._flashFindBarCount === undefined) { + this._flashFindBarCount = this._initialFlashFindBarCount; + } + + if (this._flashFindBarCount-- == 0) { + clearInterval(this._flashFindBarTimeout); + this._findField.removeAttribute("flash"); + this._flashFindBarCount = 6; + return; + } + + this._findField.setAttribute( + "flash", + this._flashFindBarCount % 2 == 0 ? "false" : "true" + ); + } + + _findAgain(findPrevious) { + this.browser.finder.findAgain( + this._findField.value, + findPrevious, + this.findMode == this.FIND_LINKS, + this.findMode != this.FIND_NORMAL + ); + } + + _updateStatusUI(res, findPrevious) { + let statusL10nId; + switch (res) { + case Ci.nsITypeAheadFind.FIND_WRAPPED: + this._findStatusIcon.setAttribute("status", "wrapped"); + this._findField.removeAttribute("status"); + statusL10nId = findPrevious + ? "findbar-wrapped-to-bottom" + : "findbar-wrapped-to-top"; + break; + case Ci.nsITypeAheadFind.FIND_NOTFOUND: + this._findStatusDesc.setAttribute("status", "notfound"); + this._findStatusIcon.setAttribute("status", "notfound"); + this._findField.setAttribute("status", "notfound"); + this._foundMatches.hidden = true; + statusL10nId = "findbar-not-found"; + break; + case Ci.nsITypeAheadFind.FIND_PENDING: + this._findStatusIcon.setAttribute("status", "pending"); + this._findField.removeAttribute("status"); + this._findStatusDesc.removeAttribute("status"); + statusL10nId = ""; + break; + case Ci.nsITypeAheadFind.FIND_FOUND: + default: + this._findStatusIcon.removeAttribute("status"); + this._findField.removeAttribute("status"); + this._findStatusDesc.removeAttribute("status"); + statusL10nId = ""; + break; + } + if (statusL10nId) { + document.l10n.setAttributes(this._findStatusDesc, statusL10nId); + } else { + delete this._findStatusDesc.dataset.l10nId; + this._findStatusDesc.textContent = ""; + } + } + + updateControlState(result, findPrevious) { + this._updateStatusUI(result, findPrevious); + this._enableFindButtons( + result !== Ci.nsITypeAheadFind.FIND_NOTFOUND && !!this._findField.value + ); + } + + _dispatchFindEvent(type, findPrevious) { + let event = document.createEvent("CustomEvent"); + event.initCustomEvent("find" + type, true, true, { + query: this._findField.value, + caseSensitive: !!this._typeAheadCaseSensitive, + matchDiacritics: !!this._matchDiacritics, + entireWord: this._entireWord, + highlightAll: this._highlightAll, + findPrevious, + }); + return this.dispatchEvent(event); + } + + /** + * Opens the findbar, focuses the findfield and selects its contents. + * Also flashes the findbar the first time it's used. + * + * @param {Number} mode The find mode to be used, which is either + * FIND_NORMAL, FIND_TYPEAHEAD or FIND_LINKS. If not + * passed, we revert to the last find mode if any or + * FIND_NORMAL. + * @return {Promise} A promise that will be resolved when the findbar is + * fully opened. + */ + startFind(mode) { + let prefsvc = Services.prefs; + let userWantsPrefill = true; + this.open(mode); + + if (this._flashFindBar) { + this._flashFindBarTimeout = setInterval(() => this._flash(), 500); + prefsvc.setIntPref( + "accessibility.typeaheadfind.flashBar", + --this._flashFindBar + ); + } + + let { PromiseUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PromiseUtils.sys.mjs" + ); + this._startFindDeferred = PromiseUtils.defer(); + let startFindPromise = this._startFindDeferred.promise; + + if (this.prefillWithSelection) { + userWantsPrefill = prefsvc.getBoolPref( + "accessibility.typeaheadfind.prefillwithselection" + ); + } + + if (this.prefillWithSelection && userWantsPrefill) { + this.browser.finder.getInitialSelection(); + + // NB: We have to focus this._findField here so tests that send + // key events can open and close the find bar synchronously. + this._findField.focus(); + + // (e10s) since we focus lets also select it, otherwise that would + // only happen in this.onCurrentSelection and, because it is async, + // there's a chance keypresses could come inbetween, leading to + // jumbled up queries. + this._findField.select(); + + return startFindPromise; + } + + // If userWantsPrefill is false but prefillWithSelection is true, + // then we might need to check the selection clipboard. Call + // onCurrentSelection to do so. + // Note: this.onCurrentSelection clears this._startFindDeferred. + this.onCurrentSelection("", true); + return startFindPromise; + } + + /** + * Convenient alias to startFind(gFindBar.FIND_NORMAL); + * + * You should generally map the window's find command to this method. + * e.g. <command name="cmd_find" oncommand="gFindBar.onFindCommand();"/> + */ + onFindCommand() { + return this.startFind(this.FIND_NORMAL); + } + + /** + * Stub for find-next and find-previous commands. + * + * @param {Boolean} findPrevious `true` for find-previous, `false` + * otherwise. + */ + onFindAgainCommand(findPrevious) { + if (findPrevious) { + Services.telemetry.scalarAdd("findbar.find_prev", 1); + } else { + Services.telemetry.scalarAdd("findbar.find_next", 1); + } + + let findString = + this._browser.finder.searchString || this._findField.value; + if (!findString) { + return this.startFind(); + } + + // We dispatch the findAgain event here instead of in _findAgain since + // if there is a find event handler that prevents the default then + // finder.searchString will never get updated which in turn means + // there would never be findAgain events because of the logic below. + if (!this._dispatchFindEvent("again", findPrevious)) { + return undefined; + } + + // user explicitly requested another search, so do it even if we think it'll fail + this._findFailedString = null; + + // Ensure the stored SearchString is in sync with what we want to find + if (this._findField.value != this._browser.finder.searchString) { + this._find(this._findField.value); + } else { + this._findAgain(findPrevious); + if (this._useModalHighlight) { + this.open(); + this._findField.focus(); + } + } + + return undefined; + } + + /** + * Fetches the currently selected text and sets that as the text to search + * next. This is a MacOS specific feature. + */ + onFindSelectionCommand() { + this.browser.finder.setSearchStringToSelection().then(searchInfo => { + if (searchInfo.selectedText) { + this._findField.value = searchInfo.selectedText; + } + }); + } + + _onAppActivateMac() { + const kPref = "accessibility.typeaheadfind.prefillwithselection"; + if (this.prefillWithSelection && Services.prefs.getBoolPref(kPref)) { + return; + } + + let clipboardSearchString = this._browser.finder.clipboardSearchString; + if ( + clipboardSearchString && + this._findField.value != clipboardSearchString && + !this._findField._willfullyDeleted + ) { + this._findField.value = clipboardSearchString; + this._findField._hadValue = true; + // Changing the search string makes the previous status invalid, so + // we better clear it here. + this._updateStatusUI(); + } + } + + /** + * This handles all the result changes for both type-ahead-find and + * highlighting. + * + * @param {Object} data A dictionary that holds the following properties: + * - {Number} result One of the FIND_* constants + * indicating the result of a search + * operation. + * - {Boolean} findBackwards If the search was done + * from the bottom to the + * top. This is used for + * status messages when + * reaching "the end of the + * page". + * - {String} linkURL When a link matched, then its + * URL. Always null when not in + * FIND_LINKS mode. + */ + onFindResult(data) { + if (data.result == Ci.nsITypeAheadFind.FIND_NOTFOUND) { + // If an explicit Find Again command fails, re-open the toolbar. + if (data.storeResult && this.open()) { + this._findField.select(); + this._findField.focus(); + } + this._findFailedString = data.searchString; + } else { + this._findFailedString = null; + } + + this._updateStatusUI(data.result, data.findBackwards); + this._updateStatusUIBar(data.linkURL); + + if (this.findMode != this.FIND_NORMAL) { + this._setFindCloseTimeout(); + } + } + + /** + * This handles all the result changes for matches counts. + * + * @param {Object} result Result Object, containing the total amount of + * matches and a vector of the current result. + * - {Number} total Total count number of matches found. + * - {Number} limit Current setting of the number of matches + * to hit to hit the limit. + * - {Number} current Vector of the current result. + */ + onMatchesCountResult(result) { + if (!result.total) { + delete this._foundMatches.dataset.l10nId; + this._foundMatches.hidden = true; + this._foundMatches.setAttribute("value", ""); + } else { + const l10nId = + result.total === -1 + ? "findbar-found-matches-count-limit" + : "findbar-found-matches"; + this._foundMatches.hidden = false; + document.l10n.setAttributes(this._foundMatches, l10nId, result); + } + } + + onHighlightFinished(result) { + // Noop. + } + + onCurrentSelection(selectionString, isInitialSelection) { + // Ignore the prefill if the user has already typed in the findbar, + // it would have been overwritten anyway. See bug 1198465. + if (isInitialSelection && !this._startFindDeferred) { + return; + } + + if ( + AppConstants.platform == "macosx" && + isInitialSelection && + !selectionString + ) { + let clipboardSearchString = this.browser.finder.clipboardSearchString; + if (clipboardSearchString) { + selectionString = clipboardSearchString; + } + } + + if (selectionString) { + this._findField.value = selectionString; + } + + if (isInitialSelection) { + this._enableFindButtons(!!this._findField.value); + this._findField.select(); + this._findField.focus(); + + this._startFindDeferred.resolve(); + this._startFindDeferred = null; + } + } + + /** + * This handler may cancel a request to focus content by returning |false| + * explicitly. + */ + shouldFocusContent() { + const fm = Services.focus; + if (fm.focusedWindow != window) { + return false; + } + + let focusedElement = fm.focusedElement; + if (!focusedElement) { + return false; + } + + let focusedParent = focusedElement.closest("findbar"); + if (focusedParent != this && focusedParent != this._findField) { + return false; + } + + return true; + } + + disconnectedCallback() { + // Empty the DOM. We will rebuild if reconnected. + while (this.lastChild) { + this.removeChild(this.lastChild); + } + this.destroy(); + } + } + + customElements.define("findbar", MozFindbar); +} |