// vim: set ts=2 sw=2 sts=2 tw=80: // 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/. import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; import { Rect } from "resource://gre/modules/Geometry.sys.mjs"; import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { FinderIterator: "resource://gre/modules/FinderIterator.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", }); XPCOMUtils.defineLazyServiceGetter( lazy, "ClipboardHelper", "@mozilla.org/widget/clipboardhelper;1", "nsIClipboardHelper" ); const kSelectionMaxLen = 150; const kMatchesCountLimitPref = "accessibility.typeaheadfind.matchesCountLimit"; const activeFinderRoots = new WeakSet(); export function Finder(docShell) { this._fastFind = Cc["@mozilla.org/typeaheadfind;1"].createInstance( Ci.nsITypeAheadFind ); this._fastFind.init(docShell); this._currentFoundRange = null; this._docShell = docShell; this._listeners = []; this._previousLink = null; this._searchString = null; this._highlighter = null; docShell .QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIWebProgress) .addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION); docShell.domWindow.addEventListener( "unload", this.onLocationChange.bind(this, { isTopLevel: true }) ); } Finder.isFindbarVisible = function(docShell) { return activeFinderRoots.has(docShell.browsingContext.top); }; Finder.prototype = { get iterator() { if (!this._iterator) { this._iterator = new lazy.FinderIterator(); } return this._iterator; }, destroy() { if (this._iterator) { this._iterator.reset(); } let window = this._getWindow(); if (this._highlighter && window) { // if we clear all the references before we hide the highlights (in both // highlighting modes), we simply can't use them to find the ranges we // need to clear from the selection. this._highlighter.hide(window); this._highlighter.clear(window); this.highlighter.removeScrollMarks(); } this.listeners = []; this._docShell .QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIWebProgress) .removeProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION); this._listeners = []; this._currentFoundRange = this._fastFind = this._docShell = this._previousLink = this._highlighter = null; }, addResultListener(aListener) { if (!this._listeners.includes(aListener)) { this._listeners.push(aListener); } }, removeResultListener(aListener) { this._listeners = this._listeners.filter(l => l != aListener); }, _setResults(options, mode) { if (typeof options.storeResult != "boolean") { options.storeResult = true; } if (options.storeResult) { this._searchString = options.searchString; this.clipboardSearchString = options.searchString; } let foundLink = this._fastFind.foundLink; let linkURL = null; if (foundLink) { linkURL = Services.textToSubURI.unEscapeURIForUI(foundLink.href); } options.linkURL = linkURL; options.rect = this._getResultRect(); options.searchString = this._searchString; this._outlineLink(options.drawOutline); for (let l of this._listeners) { try { l.onFindResult(options); } catch (ex) {} } }, get searchString() { if (!this._searchString && this._fastFind.searchString) { this._searchString = this._fastFind.searchString; } return this._searchString; }, get clipboardSearchString() { return GetClipboardSearchString( this._getWindow().docShell.QueryInterface(Ci.nsILoadContext) ); }, set clipboardSearchString(aSearchString) { if (!lazy.PrivateBrowsingUtils.isContentWindowPrivate(this._getWindow())) { SetClipboardSearchString(aSearchString); } }, set caseSensitive(aSensitive) { if (this._fastFind.caseSensitive === aSensitive) { return; } this._fastFind.caseSensitive = aSensitive; this.iterator.reset(); }, set matchDiacritics(aMatchDiacritics) { if (this._fastFind.matchDiacritics === aMatchDiacritics) { return; } this._fastFind.matchDiacritics = aMatchDiacritics; this.iterator.reset(); }, set entireWord(aEntireWord) { if (this._fastFind.entireWord === aEntireWord) { return; } this._fastFind.entireWord = aEntireWord; this.iterator.reset(); }, get highlighter() { if (this._highlighter) { return this._highlighter; } const { FinderHighlighter } = ChromeUtils.importESModule( "resource://gre/modules/FinderHighlighter.sys.mjs" ); return (this._highlighter = new FinderHighlighter(this)); }, get matchesCountLimit() { if (typeof this._matchesCountLimit == "number") { return this._matchesCountLimit; } this._matchesCountLimit = Services.prefs.getIntPref(kMatchesCountLimitPref) || 0; return this._matchesCountLimit; }, _lastFindResult: null, /** * Used for normal search operations, highlights the first match. * This method is used only for compatibility with non-remote browsers. * * @param aSearchString String to search for. * @param aLinksOnly Only consider nodes that are links for the search. * @param aDrawOutline Puts an outline around matched links. */ fastFind(aSearchString, aLinksOnly, aDrawOutline) { this._lastFindResult = this._fastFind.find( aSearchString, aLinksOnly, Ci.nsITypeAheadFind.FIND_INITIAL, false ); let searchString = this._fastFind.searchString; let results = { searchString, result: this._lastFindResult, findBackwards: false, findAgain: false, drawOutline: aDrawOutline, linksOnly: aLinksOnly, useSubFrames: true, }; this._setResults(results); this.updateHighlightAndMatchCount(results); return this._lastFindResult; }, /** * Repeat the previous search. Should only be called after a previous * call to Finder.fastFind. * This method is used only for compatibility with non-remote browsers. * * @param aSearchString String to search for. * @param aFindBackwards Controls the search direction: * true: before current match, false: after current match. * @param aLinksOnly Only consider nodes that are links for the search. * @param aDrawOutline Puts an outline around matched links. */ findAgain(aSearchString, aFindBackwards, aLinksOnly, aDrawOutline) { let mode = aFindBackwards ? Ci.nsITypeAheadFind.FIND_PREVIOUS : Ci.nsITypeAheadFind.FIND_NEXT; this._lastFindResult = this._fastFind.find( aFindBackwards, aLinksOnly, mode, false ); let searchString = this._fastFind.searchString; let results = { searchString, result: this._lastFindResult, findBackwards: aFindBackwards, findAgain: true, drawOutline: aDrawOutline, linksOnly: aLinksOnly, useSubFrames: true, }; this._setResults(results); this.updateHighlightAndMatchCount(results); return this._lastFindResult; }, /** * Used for normal search operations, highlights the first or * subsequent match depending on the mode. * * Options are: * searchString String to search for. * findAgain True if this a find again operation. * mode Search mode from nsITypeAheadFind. * linksOnly Only consider nodes that are links for the search. * drawOutline Puts an outline around matched links. * useSubFrames True to iterate over subframes. * caseSensitive True for case sensitive searching. * entireWord True to match entire words. * matchDiacritics True to match diacritics. */ find(options) { this.caseSensitive = options.caseSensitive; this.entireWord = options.entireWord; this.matchDiacritics = options.matchDiacritics; this._lastFindResult = this._fastFind.find( options.searchString, options.linksOnly, options.mode, !options.useSubFrames ); let searchString = this._fastFind.searchString; let results = { searchString, result: this._lastFindResult, findBackwards: options.mode == Ci.nsITypeAheadFind.FIND_PREVIOUS || options.mode == Ci.nsITypeAheadFind.FIND_LAST, findAgain: options.findAgain, drawOutline: options.drawOutline, linksOnly: options.linksOnly, entireWord: this._fastFind.entireWord, useSubFrames: options.useSubFrames, }; this._setResults(results, options.mode); return new Promise(resolve => resolve(results)); }, /** * Forcibly set the search string of the find clipboard to the currently * selected text in the window, on supported platforms (i.e. OSX). */ setSearchStringToSelection() { let searchInfo = this.getActiveSelectionText(); // If an empty string is returned or a subframe is focused, don't // assign the search string. if (searchInfo.selectedText) { this.clipboardSearchString = searchInfo.selectedText; } return searchInfo; }, async highlight(aHighlight, aWord, aLinksOnly, aUseSubFrames = true) { return this.highlighter.highlight( aHighlight, aWord, aLinksOnly, false, aUseSubFrames ); }, async updateHighlightAndMatchCount(aArgs) { this._lastFindResult = aArgs; if ( !this.iterator.continueRunning({ caseSensitive: this._fastFind.caseSensitive, entireWord: this._fastFind.entireWord, linksOnly: aArgs.linksOnly, matchDiacritics: this._fastFind.matchDiacritics, word: aArgs.searchString, useSubFrames: aArgs.useSubFrames, }) ) { this.iterator.stop(); } let highlightPromise = this.highlighter.update( aArgs, aArgs.useSubFrames ? false : aArgs.foundInThisFrame ); let matchCountPromise = this.requestMatchesCount( aArgs.searchString, aArgs.linksOnly, aArgs.useSubFrames ); let results = await Promise.all([highlightPromise, matchCountPromise]); this.highlighter.updateScrollMarks(); if (results[1]) { return Object.assign(results[1], results[0]); } else if (results[0]) { return results[0]; } return null; }, getInitialSelection() { let initialSelection = this.getActiveSelectionText().selectedText; this._getWindow().setTimeout(() => { for (let l of this._listeners) { try { l.onCurrentSelection(initialSelection, true); } catch (ex) {} } }, 0); }, getActiveSelectionText() { let focusedWindow = {}; let focusedElement = Services.focus.getFocusedElementForWindow( this._getWindow(), true, focusedWindow ); focusedWindow = focusedWindow.value; let selText; // If this is a remote subframe, return an empty string but // indiciate which browsing context was focused. if ( focusedElement && "frameLoader" in focusedElement && BrowsingContext.isInstance(focusedElement.browsingContext) ) { return { focusedChildBrowserContextId: focusedElement.browsingContext.id, selectedText: "", }; } if (focusedElement && focusedElement.editor) { // The user may have a selection in an input or textarea. selText = focusedElement.editor.selectionController .getSelection(Ci.nsISelectionController.SELECTION_NORMAL) .toString(); } else { // Look for any selected text on the actual page. selText = focusedWindow.getSelection().toString(); } if (!selText) { return { selectedText: "" }; } // Process our text to get rid of unwanted characters. selText = selText.trim().replace(/\s+/g, " "); let truncLength = kSelectionMaxLen; if (selText.length > truncLength) { let truncChar = selText.charAt(truncLength).charCodeAt(0); if (truncChar >= 0xdc00 && truncChar <= 0xdfff) { truncLength++; } selText = selText.substr(0, truncLength); } return { selectedText: selText }; }, enableSelection() { this._fastFind.setSelectionModeAndRepaint( Ci.nsISelectionController.SELECTION_ON ); this._restoreOriginalOutline(); }, removeSelection(keepHighlight) { this._fastFind.collapseSelection(); this.enableSelection(); let window = this._getWindow(); if (keepHighlight) { this.highlighter.clearCurrentOutline(window); } else { this.highlighter.clear(window); this.highlighter.removeScrollMarks(); } }, focusContent() { // Allow Finder listeners to cancel focusing the content. for (let l of this._listeners) { try { if ("shouldFocusContent" in l && !l.shouldFocusContent()) { return; } } catch (ex) { Cu.reportError(ex); } } let fastFind = this._fastFind; try { // Try to find the best possible match that should receive focus and // block scrolling on focus since find already scrolls. Further // scrolling is due to user action, so don't override this. if (fastFind.foundLink) { Services.focus.setFocus( fastFind.foundLink, Services.focus.FLAG_NOSCROLL ); } else if (fastFind.foundEditable) { Services.focus.setFocus( fastFind.foundEditable, Services.focus.FLAG_NOSCROLL ); fastFind.collapseSelection(); } else { this._getWindow().focus(); } } catch (e) {} }, onFindbarClose() { this.enableSelection(); this.highlighter.highlight(false); this.highlighter.removeScrollMarks(); this.iterator.reset(); activeFinderRoots.delete(this._docShell.browsingContext.top); }, onFindbarOpen() { activeFinderRoots.add(this._docShell.browsingContext.top); }, onModalHighlightChange(useModalHighlight) { if (this._highlighter) { this._highlighter.onModalHighlightChange(useModalHighlight); } }, onHighlightAllChange(highlightAll) { if (this._highlighter) { this._highlighter.onHighlightAllChange(highlightAll); } if (this._iterator) { this._iterator.reset(); } }, keyPress(aEvent) { let controller = this._getSelectionController(this._getWindow()); let accelKeyPressed = AppConstants.platform == "macosx" ? aEvent.metaKey : aEvent.ctrlKey; switch (aEvent.keyCode) { case aEvent.DOM_VK_RETURN: if (this._fastFind.foundLink) { let view = this._fastFind.foundLink.ownerGlobal; this._fastFind.foundLink.dispatchEvent( new view.MouseEvent("click", { view, cancelable: true, bubbles: true, ctrlKey: aEvent.ctrlKey, altKey: aEvent.altKey, shiftKey: aEvent.shiftKey, metaKey: aEvent.metaKey, }) ); } break; case aEvent.DOM_VK_TAB: let direction = Services.focus.MOVEFOCUS_FORWARD; if (aEvent.shiftKey) { direction = Services.focus.MOVEFOCUS_BACKWARD; } Services.focus.moveFocus(this._getWindow(), null, direction, 0); break; case aEvent.DOM_VK_PAGE_UP: controller.scrollPage(false); break; case aEvent.DOM_VK_PAGE_DOWN: controller.scrollPage(true); break; case aEvent.DOM_VK_UP: if (accelKeyPressed) { controller.completeScroll(false); } else { controller.scrollLine(false); } break; case aEvent.DOM_VK_DOWN: if (accelKeyPressed) { controller.completeScroll(true); } else { controller.scrollLine(true); } break; } }, _notifyMatchesCount(aWord, result = this._currentMatchesCountResult) { // The `_currentFound` property is only used for internal bookkeeping. delete result._currentFound; result.searchString = aWord; result.limit = this.matchesCountLimit; if (result.total == result.limit) { result.total = -1; } for (let l of this._listeners) { try { l.onMatchesCountResult(result); } catch (ex) {} } this._currentMatchesCountResult = null; return result; }, async requestMatchesCount(aWord, aLinksOnly, aUseSubFrames = true) { if ( this._lastFindResult == Ci.nsITypeAheadFind.FIND_NOTFOUND || this.searchString == "" || !aWord || !this.matchesCountLimit ) { return this._notifyMatchesCount(aWord, { total: 0, current: 0, }); } this._currentFoundRange = this._fastFind.getFoundRange(); let params = { caseSensitive: this._fastFind.caseSensitive, entireWord: this._fastFind.entireWord, linksOnly: aLinksOnly, matchDiacritics: this._fastFind.matchDiacritics, word: aWord, useSubFrames: aUseSubFrames, }; if (!this.iterator.continueRunning(params)) { this.iterator.stop(); } await this.iterator.start( Object.assign(params, { finder: this, limit: this.matchesCountLimit, listener: this, useCache: true, useSubFrames: aUseSubFrames, }) ); // Without a valid result, there's nothing to notify about. This happens // when the iterator was started before and won the race. if (!this._currentMatchesCountResult) { return null; } return this._notifyMatchesCount(aWord); }, // FinderIterator listener implementation onIteratorRangeFound(range) { let result = this._currentMatchesCountResult; if (!result) { return; } ++result.total; if (!result._currentFound) { ++result.current; result._currentFound = this._currentFoundRange && range.startContainer == this._currentFoundRange.startContainer && range.startOffset == this._currentFoundRange.startOffset && range.endContainer == this._currentFoundRange.endContainer && range.endOffset == this._currentFoundRange.endOffset; } }, onIteratorReset() {}, onIteratorRestart({ word, linksOnly, useSubFrames }) { this.requestMatchesCount(word, linksOnly, useSubFrames); }, onIteratorStart() { this._currentMatchesCountResult = { total: 0, current: 0, _currentFound: false, }; }, _getWindow() { if (!this._docShell) { return null; } return this._docShell.domWindow; }, /** * Get the bounding selection rect in CSS px relative to the origin of the * top-level content document. */ _getResultRect() { let topWin = this._getWindow(); let win = this._fastFind.currentWindow; if (!win) { return null; } let selection = win.getSelection(); if (!selection.rangeCount || selection.isCollapsed) { // The selection can be into an input or a textarea element. let nodes = win.document.querySelectorAll("input, textarea"); for (let node of nodes) { if (node.editor) { try { let sc = node.editor.selectionController; selection = sc.getSelection( Ci.nsISelectionController.SELECTION_NORMAL ); if (selection.rangeCount && !selection.isCollapsed) { break; } } catch (e) { // If this textarea is hidden, then its selection controller might // not be intialized. Ignore the failure. } } } } if (!selection.rangeCount || selection.isCollapsed) { return null; } let utils = topWin.windowUtils; let scrollX = {}, scrollY = {}; utils.getScrollXY(false, scrollX, scrollY); for (let frame = win; frame != topWin; frame = frame.parent) { let rect = frame.frameElement.getBoundingClientRect(); let left = frame.getComputedStyle(frame.frameElement).borderLeftWidth; let top = frame.getComputedStyle(frame.frameElement).borderTopWidth; scrollX.value += rect.left + parseInt(left, 10); scrollY.value += rect.top + parseInt(top, 10); } let rect = Rect.fromRect(selection.getRangeAt(0).getBoundingClientRect()); return rect.translate(scrollX.value, scrollY.value); }, _outlineLink(aDrawOutline) { let foundLink = this._fastFind.foundLink; // Optimization: We are drawing outlines and we matched // the same link before, so don't duplicate work. if (foundLink == this._previousLink && aDrawOutline) { return; } this._restoreOriginalOutline(); if (foundLink && aDrawOutline) { // Backup original outline this._tmpOutline = foundLink.style.outline; this._tmpOutlineOffset = foundLink.style.outlineOffset; // Draw pseudo focus rect // XXX Should we change the following style for FAYT pseudo focus? // XXX Shouldn't we change default design if outline is visible // already? // Don't set the outline-color, we should always use initial value. foundLink.style.outline = "1px dotted"; foundLink.style.outlineOffset = "0"; this._previousLink = foundLink; } }, _restoreOriginalOutline() { // Removes the outline around the last found link. if (this._previousLink) { this._previousLink.style.outline = this._tmpOutline; this._previousLink.style.outlineOffset = this._tmpOutlineOffset; this._previousLink = null; } }, _getSelectionController(aWindow) { // display: none iframes don't have a selection controller, see bug 493658 try { if (!aWindow.innerWidth || !aWindow.innerHeight) { return null; } } catch (e) { // If getting innerWidth or innerHeight throws, we can't get a selection // controller. return null; } // Yuck. See bug 138068. let docShell = aWindow.docShell; let controller = docShell .QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsISelectionDisplay) .QueryInterface(Ci.nsISelectionController); return controller; }, // Start of nsIWebProgressListener implementation. onLocationChange(aWebProgress, aRequest, aLocation, aFlags) { if (!aWebProgress.isTopLevel) { return; } // Ignore events that don't change the document. if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) { return; } // Avoid leaking if we change the page. this._lastFindResult = this._previousLink = this._currentFoundRange = null; this.highlighter.onLocationChange(); this.iterator.reset(); }, QueryInterface: ChromeUtils.generateQI([ "nsIWebProgressListener", "nsISupportsWeakReference", ]), }; export function GetClipboardSearchString(aLoadContext) { let searchString = ""; if (!Services.clipboard.supportsFindClipboard()) { return searchString; } try { let trans = Cc["@mozilla.org/widget/transferable;1"].createInstance( Ci.nsITransferable ); trans.init(aLoadContext); trans.addDataFlavor("text/unicode"); Services.clipboard.getData(trans, Ci.nsIClipboard.kFindClipboard); let data = {}; trans.getTransferData("text/unicode", data); if (data.value) { data = data.value.QueryInterface(Ci.nsISupportsString); searchString = data.toString(); } } catch (ex) {} return searchString; } export function SetClipboardSearchString(aSearchString) { if (!aSearchString || !Services.clipboard.supportsFindClipboard()) { return; } lazy.ClipboardHelper.copyStringToClipboard( aSearchString, Ci.nsIClipboard.kFindClipboard ); }