diff options
Diffstat (limited to 'toolkit/modules/Finder.sys.mjs')
-rw-r--r-- | toolkit/modules/Finder.sys.mjs | 844 |
1 files changed, 844 insertions, 0 deletions
diff --git a/toolkit/modules/Finder.sys.mjs b/toolkit/modules/Finder.sys.mjs new file mode 100644 index 0000000000..4722f48f6d --- /dev/null +++ b/toolkit/modules/Finder.sys.mjs @@ -0,0 +1,844 @@ +// 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 + ); +} |