// 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 Ci = Components.interfaces; const Cc = Components.classes; const Cu = Components.utils; 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.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 kHighlightIterationSizeMax = 100; const kSelectionMaxLen = 150; function Finder(docShell) { this._fastFind = Cc["@mozilla.org/typeaheadfind;1"].createInstance(Ci.nsITypeAheadFind); this._fastFind.init(docShell); this._docShell = docShell; this._listeners = []; this._previousLink = null; this._searchString = null; docShell.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIWebProgress) .addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION); } Finder.prototype = { 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 (aSearchString, aResult, aFindBackwards, aDrawOutline, aStoreResult = true) { if (aStoreResult) { this._searchString = aSearchString; this.clipboardSearchString = aSearchString } this._outlineLink(aDrawOutline); 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); } let data = { result: aResult, findBackwards: aFindBackwards, linkURL: linkURL, rect: this._getResultRect(), searchString: this._searchString, storeResult: aStoreResult }; for (let l of this._listeners) { try { l.onFindResult(data); } 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) { this._fastFind.caseSensitive = aSensitive; }, _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, this._lastFindResult, false, aDrawOutline); }, /** * 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, this._lastFindResult, aFindBackwards, aDrawOutline); }, /** * 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; }, _notifyHighlightFinished: function(aHighlight) { for (let l of this._listeners) { try { l.onHighlightFinished(aHighlight); } catch (ex) {} } }, highlight: Task.async(function* (aHighlight, aWord) { if (this._abortHighlight) { this._abortHighlight(); } let found = yield this._highlight(aHighlight, aWord, null); this._notifyHighlightFinished(aHighlight); if (aHighlight) { let result = found ? Ci.nsITypeAheadFind.FIND_FOUND : Ci.nsITypeAheadFind.FIND_NOTFOUND; this._notify(aWord, result, false, false, false); } }), 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. return selText.trim().replace(/\s+/g, " ").substr(0, kSelectionMaxLen); }, enableSelection: function() { this._fastFind.setSelectionModeAndRepaint(Ci.nsISelectionController.SELECTION_ON); this._restoreOriginalOutline(); }, removeSelection: function() { this._fastFind.collapseSelection(); this.enableSelection(); }, 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) {} }, 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) { for (let l of this._listeners) { try { l.onMatchesCountResult(result); } catch (ex) {} } }, requestMatchesCount: function(aWord, aMatchLimit, aLinksOnly) { if (this._lastFindResult == Ci.nsITypeAheadFind.FIND_NOTFOUND || this.searchString == "") { return this._notifyMatchesCount({ total: 0, current: 0 }); } let window = this._getWindow(); let result = this._countMatchesInWindow(aWord, aMatchLimit, aLinksOnly, window); // Count matches in (i)frames AFTER searching through the main window. for (let frame of result._framesToCount) { // We've reached our limit; no need to do more work. if (result.total == -1 || result.total == aMatchLimit) break; this._countMatchesInWindow(aWord, aMatchLimit, aLinksOnly, frame, result); } // The `_currentFound` and `_framesToCount` properties are only used for // internal bookkeeping between recursive calls. delete result._currentFound; delete result._framesToCount; this._notifyMatchesCount(result); }, /** * Counts the number of matches for the searched word in the passed window's * content. * @param aWord * the word to search for. * @param aMatchLimit * the maximum number of matches shown (for speed reasons). * @param aLinksOnly * whether we should only search through links. * @param aWindow * the window to search in. Passing undefined will search the * current content window. Optional. * @param aStats * the Object that is returned by this function. It may be passed as an * argument here in the case of a recursive call. * @returns an object stating the number of matches and a vector for the current match. */ _countMatchesInWindow: function(aWord, aMatchLimit, aLinksOnly, aWindow = null, aStats = null) { aWindow = aWindow || this._getWindow(); aStats = aStats || { total: 0, current: 0, _framesToCount: new Set(), _currentFound: false }; // If we already reached our max, there's no need to do more work! if (aStats.total == -1 || aStats.total == aMatchLimit) { aStats.total = -1; return aStats; } this._collectFrames(aWindow, aStats); let foundRange = this._fastFind.getFoundRange(); for(let range of this._findIterator(aWord, aWindow)) { if (!aLinksOnly || this._rangeStartsInLink(range)) { ++aStats.total; if (!aStats._currentFound) { ++aStats.current; aStats._currentFound = (foundRange && range.startContainer == foundRange.startContainer && range.startOffset == foundRange.startOffset && range.endContainer == foundRange.endContainer && range.endOffset == foundRange.endOffset); } } if (aStats.total == aMatchLimit) { aStats.total = -1; break; } }; return aStats; }, /** * Basic wrapper around nsIFind that provides a generator yielding * a range each time an occurence of `aWord` string is found. * * @param aWord * the word to search for. * @param aWindow * the window to search in. */ _findIterator: function* (aWord, aWindow) { let doc = aWindow.document; let body = (doc instanceof Ci.nsIDOMHTMLDocument && doc.body) ? doc.body : doc.documentElement; if (!body) return; let searchRange = doc.createRange(); searchRange.selectNodeContents(body); let startPt = searchRange.cloneRange(); startPt.collapse(true); let endPt = searchRange.cloneRange(); endPt.collapse(false); let retRange = null; let finder = Cc["@mozilla.org/embedcomp/rangefind;1"] .createInstance() .QueryInterface(Ci.nsIFind); finder.caseSensitive = this._fastFind.caseSensitive; while ((retRange = finder.Find(aWord, searchRange, startPt, endPt))) { yield retRange; startPt = retRange.cloneRange(); startPt.collapse(false); } }, _highlightIterator: Task.async(function* (aWord, aWindow, aOnFind) { let count = 0; for (let range of this._findIterator(aWord, aWindow)) { aOnFind(range); if (++count >= kHighlightIterationSizeMax) { count = 0; yield this._highlightSleep(0); } } }), _abortHighlight: null, _highlightSleep: function(delay) { return new Promise((resolve, reject) => { this._abortHighlight = () => { this._abortHighlight = null; reject(); }; this._getWindow().setTimeout(resolve, delay); }); }, /** * Helper method for `_countMatchesInWindow` that recursively collects all * visible (i)frames inside a window. * * @param aWindow * the window to extract the (i)frames from. * @param aStats * Object that contains a Set called '_framesToCount' */ _collectFrames: function(aWindow, aStats) { if (!aWindow.frames || !aWindow.frames.length) return; // Casting `aWindow.frames` to an Iterator doesn't work, so we're stuck with // a plain, old for-loop. for (let i = 0, l = aWindow.frames.length; i < l; ++i) { let frame = aWindow.frames[i]; // Don't count matches in hidden frames. let frameEl = frame && frame.frameElement; if (!frameEl) continue; // Construct a range around the frame element to check its visiblity. let range = aWindow.document.createRange(); range.setStart(frameEl, 0); range.setEnd(frameEl, 0); if (!this._fastFind.isRangeVisible(range, this._getDocShell(range), true)) continue; // All good, so add it to the set to count later. if (!aStats._framesToCount.has(frame)) aStats._framesToCount.add(frame); this._collectFrames(frame, aStats); } }, /** * Helper method to extract the docShell reference from a Window or Range object. * * @param aWindowOrRange * Window object to query. May also be a Range, from which the owner * window will be queried. * @returns nsIDocShell */ _getDocShell: function(aWindowOrRange) { let window = aWindowOrRange; // Ranges may also be passed in, so fetch its window. if (aWindowOrRange instanceof Ci.nsIDOMRange) window = aWindowOrRange.startContainer.ownerDocument.defaultView; return window.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIWebNavigation) .QueryInterface(Ci.nsIDocShell); }, _getWindow: function () { 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) { let sc = node.editor.selectionController; selection = sc.getSelection(Ci.nsISelectionController.SELECTION_NORMAL); if (selection.rangeCount && !selection.isCollapsed) { break; } } } } 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; } }, _highlight: Task.async(function* (aHighlight, aWord, aWindow) { let win = aWindow || this._getWindow(); let found = false; for (let i = 0; win.frames && i < win.frames.length; i++) { if (yield this._highlight(aHighlight, aWord, win.frames[i])) found = true; } let controller = this._getSelectionController(win); let doc = win.document; if (!controller || !doc || !doc.documentElement) { // Without the selection controller, // we are unable to (un)highlight any matches return found; } if (aHighlight) { yield this._highlightIterator(aWord, win, aRange => { this._highlightRange(aRange, controller); found = true; }); } else { // First, attempt to remove highlighting from main document let sel = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND); sel.removeAllRanges(); // Next, check our editor cache, for editors belonging to this // document if (this._editors) { for (let x = this._editors.length - 1; x >= 0; --x) { if (this._editors[x].document == doc) { sel = this._editors[x].selectionController .getSelection(Ci.nsISelectionController.SELECTION_FIND); sel.removeAllRanges(); // We don't need to listen to this editor any more this._unhookListenersAtIndex(x); } } } // Removing the highlighting always succeeds, so return true. found = true; } return found; }), _highlightRange: function(aRange, aController) { let node = aRange.startContainer; let controller = aController; let editableNode = this._getEditableNode(node); if (editableNode) controller = editableNode.editor.selectionController; let findSelection = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND); findSelection.addRange(aRange); if (editableNode) { // Highlighting added, so cache this editor, and hook up listeners // to ensure we deal properly with edits within the highlighting if (!this._editors) { this._editors = []; this._stateListeners = []; } let existingIndex = this._editors.indexOf(editableNode.editor); if (existingIndex == -1) { let x = this._editors.length; this._editors[x] = editableNode.editor; this._stateListeners[x] = this._createStateListener(); this._editors[x].addEditActionListener(this); this._editors[x].addDocumentStateListener(this._stateListeners[x]); } } }, _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; }, /* * For a given node returns its editable parent or null if there is none. * It's enough to check if aNode is a text node and its parent's parent is * instance of nsIDOMNSEditableElement. * * @param aNode the node we want to check * @returns the first node in the parent chain that is editable, * null if there is no such node */ _getEditableNode: function (aNode) { if (aNode.nodeType === aNode.TEXT_NODE && aNode.parentNode && aNode.parentNode.parentNode && aNode.parentNode.parentNode instanceof Ci.nsIDOMNSEditableElement) { return aNode.parentNode.parentNode; } return null; }, /* * Helper method to unhook listeners, remove cached editors * and keep the relevant arrays in sync * * @param aIndex the index into the array of editors/state listeners * we wish to remove */ _unhookListenersAtIndex: function (aIndex) { this._editors[aIndex].removeEditActionListener(this); this._editors[aIndex] .removeDocumentStateListener(this._stateListeners[aIndex]); this._editors.splice(aIndex, 1); this._stateListeners.splice(aIndex, 1); if (!this._editors.length) { delete this._editors; delete this._stateListeners; } }, /* * Remove ourselves as an nsIEditActionListener and * nsIDocumentStateListener from a given cached editor * * @param aEditor the editor we no longer wish to listen to */ _removeEditorListeners: function (aEditor) { // aEditor is an editor that we listen to, so therefore must be // cached. Find the index of this editor let idx = this._editors.indexOf(aEditor); if (idx == -1) return; // Now unhook ourselves, and remove our cached copy this._unhookListenersAtIndex(idx); }, /* * nsIEditActionListener logic follows * * We implement this interface to allow us to catch the case where * the findbar found a match in a HTML or