diff options
Diffstat (limited to 'toolkit/modules/Finder.jsm')
-rw-r--r-- | toolkit/modules/Finder.jsm | 639 |
1 files changed, 639 insertions, 0 deletions
diff --git a/toolkit/modules/Finder.jsm b/toolkit/modules/Finder.jsm new file mode 100644 index 000000000..c2a9af5b1 --- /dev/null +++ b/toolkit/modules/Finder.jsm @@ -0,0 +1,639 @@ +// 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/. + +this.EXPORTED_SYMBOLS = ["Finder", "GetClipboardSearchString"]; + +const { interfaces: Ci, classes: Cc, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Geometry.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils", + "resource://gre/modules/BrowserUtils.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "TextToSubURIService", + "@mozilla.org/intl/texttosuburi;1", + "nsITextToSubURI"); +XPCOMUtils.defineLazyServiceGetter(this, "Clipboard", + "@mozilla.org/widget/clipboard;1", + "nsIClipboard"); +XPCOMUtils.defineLazyServiceGetter(this, "ClipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper"); + +const kSelectionMaxLen = 150; +const kMatchesCountLimitPref = "accessibility.typeaheadfind.matchesCountLimit"; + +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); + BrowserUtils.getRootWindow(this._docShell).addEventListener("unload", + this.onLocationChange.bind(this, { isTopLevel: true })); +} + +Finder.prototype = { + get iterator() { + if (this._iterator) + return this._iterator; + this._iterator = Cu.import("resource://gre/modules/FinderIterator.jsm", null).FinderIterator; + return this._iterator; + }, + + destroy: function() { + 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.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: function (aListener) { + if (this._listeners.indexOf(aListener) === -1) + this._listeners.push(aListener); + }, + + removeResultListener: function (aListener) { + this._listeners = this._listeners.filter(l => l != aListener); + }, + + _notify: function (options) { + 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) { + let docCharset = null; + let ownerDoc = foundLink.ownerDocument; + if (ownerDoc) + docCharset = ownerDoc.characterSet; + + linkURL = TextToSubURIService.unEscapeURIForUI(docCharset, foundLink.href); + } + + options.linkURL = linkURL; + options.rect = this._getResultRect(); + options.searchString = this._searchString; + + if (!this.iterator.continueRunning({ + caseSensitive: this._fastFind.caseSensitive, + entireWord: this._fastFind.entireWord, + linksOnly: options.linksOnly, + word: options.searchString + })) { + this.iterator.stop(); + } + + this.highlighter.update(options); + this.requestMatchesCount(options.searchString, options.linksOnly); + + 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() + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsILoadContext)); + }, + + set clipboardSearchString(aSearchString) { + if (!aSearchString || !Clipboard.supportsFindClipboard()) + return; + + ClipboardHelper.copyStringToClipboard(aSearchString, + Ci.nsIClipboard.kFindClipboard); + }, + + set caseSensitive(aSensitive) { + if (this._fastFind.caseSensitive === aSensitive) + return; + this._fastFind.caseSensitive = aSensitive; + 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} = Cu.import("resource://gre/modules/FinderHighlighter.jsm", {}); + 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. + * + * @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: function (aSearchString, aLinksOnly, aDrawOutline) { + this._lastFindResult = this._fastFind.find(aSearchString, aLinksOnly); + let searchString = this._fastFind.searchString; + this._notify({ + searchString, + result: this._lastFindResult, + findBackwards: false, + findAgain: false, + drawOutline: aDrawOutline, + linksOnly: aLinksOnly + }); + }, + + /** + * Repeat the previous search. Should only be called after a previous + * call to Finder.fastFind. + * + * @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: function (aFindBackwards, aLinksOnly, aDrawOutline) { + this._lastFindResult = this._fastFind.findAgain(aFindBackwards, aLinksOnly); + let searchString = this._fastFind.searchString; + this._notify({ + searchString, + result: this._lastFindResult, + findBackwards: aFindBackwards, + findAgain: true, + drawOutline: aDrawOutline, + linksOnly: aLinksOnly + }); + }, + + /** + * Forcibly set the search string of the find clipboard to the currently + * selected text in the window, on supported platforms (i.e. OSX). + */ + setSearchStringToSelection: function() { + let searchString = this.getActiveSelectionText(); + + // Empty strings are rather useless to search for. + if (!searchString.length) + return null; + + this.clipboardSearchString = searchString; + return searchString; + }, + + highlight: Task.async(function* (aHighlight, aWord, aLinksOnly) { + yield this.highlighter.highlight(aHighlight, aWord, aLinksOnly); + }), + + getInitialSelection: function() { + this._getWindow().setTimeout(() => { + let initialSelection = this.getActiveSelectionText(); + for (let l of this._listeners) { + try { + l.onCurrentSelection(initialSelection, true); + } catch (ex) {} + } + }, 0); + }, + + getActiveSelectionText: function() { + let focusedWindow = {}; + let focusedElement = + Services.focus.getFocusedElementForWindow(this._getWindow(), true, + focusedWindow); + focusedWindow = focusedWindow.value; + + let selText; + + if (focusedElement instanceof Ci.nsIDOMNSEditableElement && + 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 ""; + + // 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 selText; + }, + + enableSelection: function() { + this._fastFind.setSelectionModeAndRepaint(Ci.nsISelectionController.SELECTION_ON); + this._restoreOriginalOutline(); + }, + + removeSelection: function() { + this._fastFind.collapseSelection(); + this.enableSelection(); + this.highlighter.clear(this._getWindow()); + }, + + focusContent: function() { + // 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; + const fm = Cc["@mozilla.org/focus-manager;1"].getService(Ci.nsIFocusManager); + 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) { + fm.setFocus(fastFind.foundLink, fm.FLAG_NOSCROLL); + } else if (fastFind.foundEditable) { + fm.setFocus(fastFind.foundEditable, fm.FLAG_NOSCROLL); + fastFind.collapseSelection(); + } else { + this._getWindow().focus() + } + } catch (e) {} + }, + + onFindbarClose: function() { + this.enableSelection(); + this.highlighter.highlight(false); + this.iterator.reset(); + BrowserUtils.trackToolbarVisibility(this._docShell, "findbar", false); + }, + + onFindbarOpen: function() { + BrowserUtils.trackToolbarVisibility(this._docShell, "findbar", true); + }, + + 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: function (aEvent) { + let controller = this._getSelectionController(this._getWindow()); + + switch (aEvent.keyCode) { + case Ci.nsIDOMKeyEvent.DOM_VK_RETURN: + if (this._fastFind.foundLink) { + let view = this._fastFind.foundLink.ownerDocument.defaultView; + this._fastFind.foundLink.dispatchEvent(new view.MouseEvent("click", { + view: view, + cancelable: true, + bubbles: true, + ctrlKey: aEvent.ctrlKey, + altKey: aEvent.altKey, + shiftKey: aEvent.shiftKey, + metaKey: aEvent.metaKey + })); + } + break; + case Ci.nsIDOMKeyEvent.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 Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP: + controller.scrollPage(false); + break; + case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN: + controller.scrollPage(true); + break; + case Ci.nsIDOMKeyEvent.DOM_VK_UP: + controller.scrollLine(false); + break; + case Ci.nsIDOMKeyEvent.DOM_VK_DOWN: + controller.scrollLine(true); + break; + } + }, + + _notifyMatchesCount: function(result = this._currentMatchesCountResult) { + // The `_currentFound` property is only used for internal bookkeeping. + delete result._currentFound; + 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; + }, + + requestMatchesCount: function(aWord, aLinksOnly) { + if (this._lastFindResult == Ci.nsITypeAheadFind.FIND_NOTFOUND || + this.searchString == "" || !aWord || !this.matchesCountLimit) { + this._notifyMatchesCount({ + total: 0, + current: 0 + }); + return; + } + + let window = this._getWindow(); + this._currentFoundRange = this._fastFind.getFoundRange(); + + let params = { + caseSensitive: this._fastFind.caseSensitive, + entireWord: this._fastFind.entireWord, + linksOnly: aLinksOnly, + word: aWord + }; + if (!this.iterator.continueRunning(params)) + this.iterator.stop(); + + this.iterator.start(Object.assign(params, { + finder: this, + limit: this.matchesCountLimit, + listener: this, + useCache: true, + })).then(() => { + // 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 || !this._currentMatchesCountResult.total) + return; + this._notifyMatchesCount(); + }); + }, + + // 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 }) { + this.requestMatchesCount(word, linksOnly); + }, + + onIteratorStart() { + this._currentMatchesCountResult = { + total: 0, + current: 0, + _currentFound: false + }; + }, + + _getWindow: function () { + if (!this._docShell) + return null; + return this._docShell.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindow); + }, + + /** + * Get the bounding selection rect in CSS px relative to the origin of the + * top-level content document. + */ + _getResultRect: function () { + 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 instanceof Ci.nsIDOMNSEditableElement && 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.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + + 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: function (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: function () { + // 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: function(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.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + + let controller = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsISelectionDisplay) + .QueryInterface(Ci.nsISelectionController); + return controller; + }, + + // Start of nsIWebProgressListener implementation. + + onLocationChange: function(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: XPCOMUtils.generateQI([Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference]) +}; + +function GetClipboardSearchString(aLoadContext) { + let searchString = ""; + if (!Clipboard.supportsFindClipboard()) + return searchString; + + try { + let trans = Cc["@mozilla.org/widget/transferable;1"] + .createInstance(Ci.nsITransferable); + trans.init(aLoadContext); + trans.addDataFlavor("text/unicode"); + + Clipboard.getData(trans, Ci.nsIClipboard.kFindClipboard); + + let data = {}; + let dataLen = {}; + trans.getTransferData("text/unicode", data, dataLen); + if (data.value) { + data = data.value.QueryInterface(Ci.nsISupportsString); + searchString = data.toString(); + } + } catch (ex) {} + + return searchString; +} + +this.Finder = Finder; +this.GetClipboardSearchString = GetClipboardSearchString; |