// 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"]; 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"); 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"); 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) { l.onFindResult(data); } }, get searchString() { if (!this._searchString && this._fastFind.searchString) this._searchString = this._fastFind.searchString; return this._searchString; }, get clipboardSearchString() { let searchString = ""; if (!Clipboard.supportsFindClipboard()) return searchString; try { let trans = Cc["@mozilla.org/widget/transferable;1"] .createInstance(Ci.nsITransferable); trans.init(this._getWindow() .QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIWebNavigation) .QueryInterface(Ci.nsILoadContext)); 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; }, set clipboardSearchString(aSearchString) { if (!aSearchString || !Clipboard.supportsFindClipboard()) return; ClipboardHelper.copyStringToClipboard(aSearchString, Ci.nsIClipboard.kFindClipboard, this._getWindow().document); }, set caseSensitive(aSensitive) { this._fastFind.caseSensitive = aSensitive; }, /** * 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) { let result = this._fastFind.find(aSearchString, aLinksOnly); let searchString = this._fastFind.searchString; this._notify(searchString, result, 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) { let result = this._fastFind.findAgain(aFindBackwards, aLinksOnly); let searchString = this._fastFind.searchString; this._notify(searchString, result, 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() { // Find the selected text. let selection = this._getWindow().getSelection(); // Don't go for empty selections. if (!selection.rangeCount) return null; let searchString = (selection.toString() || "").trim(); // Empty strings are rather useless to search for. if (!searchString.length) return null; this.clipboardSearchString = searchString; return searchString; }, highlight: function (aHighlight, aWord) { let found = this._highlight(aHighlight, aWord, null); if (aHighlight) { let result = found ? Ci.nsITypeAheadFind.FIND_FOUND : Ci.nsITypeAheadFind.FIND_NOTFOUND; this._notify(aWord, result, false, false, false); } }, 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) { if (!l.shouldFocusContent()) return; } 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; } }, _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: function (aHighlight, aWord, aWindow) { let win = aWindow || this._getWindow(); let found = false; for (let i = 0; win.frames && i < win.frames.length; i++) { if (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; } let body = (doc instanceof Ci.nsIDOMHTMLDocument && doc.body) ? doc.body : doc.documentElement; if (aHighlight) { 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))) { this._highlightRange(retRange, controller); startPt = retRange.cloneRange(); startPt.collapse(false); 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 if (!aWindow.innerWidth || !aWindow.innerHeight) 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, walk up it's parent chain, to try and find an * editable node. * * @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) { while (aNode) { if (aNode instanceof Ci.nsIDOMNSEditableElement) return aNode.editor ? aNode : null; aNode = aNode.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