michael@0: // This Source Code Form is subject to the terms of the Mozilla Public
michael@0: // License, v. 2.0. If a copy of the MPL was not distributed with this
michael@0: // file, You can obtain one at http://mozilla.org/MPL/2.0/.
michael@0:
michael@0: this.EXPORTED_SYMBOLS = ["Finder"];
michael@0:
michael@0: const Ci = Components.interfaces;
michael@0: const Cc = Components.classes;
michael@0: const Cu = Components.utils;
michael@0:
michael@0: Cu.import("resource://gre/modules/XPCOMUtils.jsm");
michael@0: Cu.import("resource://gre/modules/Geometry.jsm");
michael@0: Cu.import("resource://gre/modules/Services.jsm");
michael@0:
michael@0: XPCOMUtils.defineLazyServiceGetter(this, "TextToSubURIService",
michael@0: "@mozilla.org/intl/texttosuburi;1",
michael@0: "nsITextToSubURI");
michael@0: XPCOMUtils.defineLazyServiceGetter(this, "Clipboard",
michael@0: "@mozilla.org/widget/clipboard;1",
michael@0: "nsIClipboard");
michael@0: XPCOMUtils.defineLazyServiceGetter(this, "ClipboardHelper",
michael@0: "@mozilla.org/widget/clipboardhelper;1",
michael@0: "nsIClipboardHelper");
michael@0:
michael@0: function Finder(docShell) {
michael@0: this._fastFind = Cc["@mozilla.org/typeaheadfind;1"].createInstance(Ci.nsITypeAheadFind);
michael@0: this._fastFind.init(docShell);
michael@0:
michael@0: this._docShell = docShell;
michael@0: this._listeners = [];
michael@0: this._previousLink = null;
michael@0: this._searchString = null;
michael@0:
michael@0: docShell.QueryInterface(Ci.nsIInterfaceRequestor)
michael@0: .getInterface(Ci.nsIWebProgress)
michael@0: .addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION);
michael@0: }
michael@0:
michael@0: Finder.prototype = {
michael@0: addResultListener: function (aListener) {
michael@0: if (this._listeners.indexOf(aListener) === -1)
michael@0: this._listeners.push(aListener);
michael@0: },
michael@0:
michael@0: removeResultListener: function (aListener) {
michael@0: this._listeners = this._listeners.filter(l => l != aListener);
michael@0: },
michael@0:
michael@0: _notify: function (aSearchString, aResult, aFindBackwards, aDrawOutline, aStoreResult = true) {
michael@0: if (aStoreResult) {
michael@0: this._searchString = aSearchString;
michael@0: this.clipboardSearchString = aSearchString
michael@0: }
michael@0: this._outlineLink(aDrawOutline);
michael@0:
michael@0: let foundLink = this._fastFind.foundLink;
michael@0: let linkURL = null;
michael@0: if (foundLink) {
michael@0: let docCharset = null;
michael@0: let ownerDoc = foundLink.ownerDocument;
michael@0: if (ownerDoc)
michael@0: docCharset = ownerDoc.characterSet;
michael@0:
michael@0: linkURL = TextToSubURIService.unEscapeURIForUI(docCharset, foundLink.href);
michael@0: }
michael@0:
michael@0: let data = {
michael@0: result: aResult,
michael@0: findBackwards: aFindBackwards,
michael@0: linkURL: linkURL,
michael@0: rect: this._getResultRect(),
michael@0: searchString: this._searchString,
michael@0: storeResult: aStoreResult
michael@0: };
michael@0:
michael@0: for (let l of this._listeners) {
michael@0: l.onFindResult(data);
michael@0: }
michael@0: },
michael@0:
michael@0: get searchString() {
michael@0: if (!this._searchString && this._fastFind.searchString)
michael@0: this._searchString = this._fastFind.searchString;
michael@0: return this._searchString;
michael@0: },
michael@0:
michael@0: get clipboardSearchString() {
michael@0: let searchString = "";
michael@0: if (!Clipboard.supportsFindClipboard())
michael@0: return searchString;
michael@0:
michael@0: try {
michael@0: let trans = Cc["@mozilla.org/widget/transferable;1"]
michael@0: .createInstance(Ci.nsITransferable);
michael@0: trans.init(this._getWindow()
michael@0: .QueryInterface(Ci.nsIInterfaceRequestor)
michael@0: .getInterface(Ci.nsIWebNavigation)
michael@0: .QueryInterface(Ci.nsILoadContext));
michael@0: trans.addDataFlavor("text/unicode");
michael@0:
michael@0: Clipboard.getData(trans, Ci.nsIClipboard.kFindClipboard);
michael@0:
michael@0: let data = {};
michael@0: let dataLen = {};
michael@0: trans.getTransferData("text/unicode", data, dataLen);
michael@0: if (data.value) {
michael@0: data = data.value.QueryInterface(Ci.nsISupportsString);
michael@0: searchString = data.toString();
michael@0: }
michael@0: } catch (ex) {}
michael@0:
michael@0: return searchString;
michael@0: },
michael@0:
michael@0: set clipboardSearchString(aSearchString) {
michael@0: if (!aSearchString || !Clipboard.supportsFindClipboard())
michael@0: return;
michael@0:
michael@0: ClipboardHelper.copyStringToClipboard(aSearchString,
michael@0: Ci.nsIClipboard.kFindClipboard,
michael@0: this._getWindow().document);
michael@0: },
michael@0:
michael@0: set caseSensitive(aSensitive) {
michael@0: this._fastFind.caseSensitive = aSensitive;
michael@0: },
michael@0:
michael@0: /**
michael@0: * Used for normal search operations, highlights the first match.
michael@0: *
michael@0: * @param aSearchString String to search for.
michael@0: * @param aLinksOnly Only consider nodes that are links for the search.
michael@0: * @param aDrawOutline Puts an outline around matched links.
michael@0: */
michael@0: fastFind: function (aSearchString, aLinksOnly, aDrawOutline) {
michael@0: let result = this._fastFind.find(aSearchString, aLinksOnly);
michael@0: let searchString = this._fastFind.searchString;
michael@0: this._notify(searchString, result, false, aDrawOutline);
michael@0: },
michael@0:
michael@0: /**
michael@0: * Repeat the previous search. Should only be called after a previous
michael@0: * call to Finder.fastFind.
michael@0: *
michael@0: * @param aFindBackwards Controls the search direction:
michael@0: * true: before current match, false: after current match.
michael@0: * @param aLinksOnly Only consider nodes that are links for the search.
michael@0: * @param aDrawOutline Puts an outline around matched links.
michael@0: */
michael@0: findAgain: function (aFindBackwards, aLinksOnly, aDrawOutline) {
michael@0: let result = this._fastFind.findAgain(aFindBackwards, aLinksOnly);
michael@0: let searchString = this._fastFind.searchString;
michael@0: this._notify(searchString, result, aFindBackwards, aDrawOutline);
michael@0: },
michael@0:
michael@0: /**
michael@0: * Forcibly set the search string of the find clipboard to the currently
michael@0: * selected text in the window, on supported platforms (i.e. OSX).
michael@0: */
michael@0: setSearchStringToSelection: function() {
michael@0: // Find the selected text.
michael@0: let selection = this._getWindow().getSelection();
michael@0: // Don't go for empty selections.
michael@0: if (!selection.rangeCount)
michael@0: return null;
michael@0: let searchString = (selection.toString() || "").trim();
michael@0: // Empty strings are rather useless to search for.
michael@0: if (!searchString.length)
michael@0: return null;
michael@0:
michael@0: this.clipboardSearchString = searchString;
michael@0: return searchString;
michael@0: },
michael@0:
michael@0: highlight: function (aHighlight, aWord) {
michael@0: let found = this._highlight(aHighlight, aWord, null);
michael@0: if (aHighlight) {
michael@0: let result = found ? Ci.nsITypeAheadFind.FIND_FOUND
michael@0: : Ci.nsITypeAheadFind.FIND_NOTFOUND;
michael@0: this._notify(aWord, result, false, false, false);
michael@0: }
michael@0: },
michael@0:
michael@0: enableSelection: function() {
michael@0: this._fastFind.setSelectionModeAndRepaint(Ci.nsISelectionController.SELECTION_ON);
michael@0: this._restoreOriginalOutline();
michael@0: },
michael@0:
michael@0: removeSelection: function() {
michael@0: this._fastFind.collapseSelection();
michael@0: this.enableSelection();
michael@0: },
michael@0:
michael@0: focusContent: function() {
michael@0: // Allow Finder listeners to cancel focusing the content.
michael@0: for (let l of this._listeners) {
michael@0: if (!l.shouldFocusContent())
michael@0: return;
michael@0: }
michael@0:
michael@0: let fastFind = this._fastFind;
michael@0: const fm = Cc["@mozilla.org/focus-manager;1"].getService(Ci.nsIFocusManager);
michael@0: try {
michael@0: // Try to find the best possible match that should receive focus and
michael@0: // block scrolling on focus since find already scrolls. Further
michael@0: // scrolling is due to user action, so don't override this.
michael@0: if (fastFind.foundLink) {
michael@0: fm.setFocus(fastFind.foundLink, fm.FLAG_NOSCROLL);
michael@0: } else if (fastFind.foundEditable) {
michael@0: fm.setFocus(fastFind.foundEditable, fm.FLAG_NOSCROLL);
michael@0: fastFind.collapseSelection();
michael@0: } else {
michael@0: this._getWindow().focus()
michael@0: }
michael@0: } catch (e) {}
michael@0: },
michael@0:
michael@0: keyPress: function (aEvent) {
michael@0: let controller = this._getSelectionController(this._getWindow());
michael@0:
michael@0: switch (aEvent.keyCode) {
michael@0: case Ci.nsIDOMKeyEvent.DOM_VK_RETURN:
michael@0: if (this._fastFind.foundLink) {
michael@0: let view = this._fastFind.foundLink.ownerDocument.defaultView;
michael@0: this._fastFind.foundLink.dispatchEvent(new view.MouseEvent("click", {
michael@0: view: view,
michael@0: cancelable: true,
michael@0: bubbles: true,
michael@0: ctrlKey: aEvent.ctrlKey,
michael@0: altKey: aEvent.altKey,
michael@0: shiftKey: aEvent.shiftKey,
michael@0: metaKey: aEvent.metaKey
michael@0: }));
michael@0: }
michael@0: break;
michael@0: case Ci.nsIDOMKeyEvent.DOM_VK_TAB:
michael@0: let direction = Services.focus.MOVEFOCUS_FORWARD;
michael@0: if (aEvent.shiftKey) {
michael@0: direction = Services.focus.MOVEFOCUS_BACKWARD;
michael@0: }
michael@0: Services.focus.moveFocus(this._getWindow(), null, direction, 0);
michael@0: break;
michael@0: case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP:
michael@0: controller.scrollPage(false);
michael@0: break;
michael@0: case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN:
michael@0: controller.scrollPage(true);
michael@0: break;
michael@0: case Ci.nsIDOMKeyEvent.DOM_VK_UP:
michael@0: controller.scrollLine(false);
michael@0: break;
michael@0: case Ci.nsIDOMKeyEvent.DOM_VK_DOWN:
michael@0: controller.scrollLine(true);
michael@0: break;
michael@0: }
michael@0: },
michael@0:
michael@0: _getWindow: function () {
michael@0: return this._docShell.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindow);
michael@0: },
michael@0:
michael@0: /**
michael@0: * Get the bounding selection rect in CSS px relative to the origin of the
michael@0: * top-level content document.
michael@0: */
michael@0: _getResultRect: function () {
michael@0: let topWin = this._getWindow();
michael@0: let win = this._fastFind.currentWindow;
michael@0: if (!win)
michael@0: return null;
michael@0:
michael@0: let selection = win.getSelection();
michael@0: if (!selection.rangeCount || selection.isCollapsed) {
michael@0: // The selection can be into an input or a textarea element.
michael@0: let nodes = win.document.querySelectorAll("input, textarea");
michael@0: for (let node of nodes) {
michael@0: if (node instanceof Ci.nsIDOMNSEditableElement && node.editor) {
michael@0: let sc = node.editor.selectionController;
michael@0: selection = sc.getSelection(Ci.nsISelectionController.SELECTION_NORMAL);
michael@0: if (selection.rangeCount && !selection.isCollapsed) {
michael@0: break;
michael@0: }
michael@0: }
michael@0: }
michael@0: }
michael@0:
michael@0: let utils = topWin.QueryInterface(Ci.nsIInterfaceRequestor)
michael@0: .getInterface(Ci.nsIDOMWindowUtils);
michael@0:
michael@0: let scrollX = {}, scrollY = {};
michael@0: utils.getScrollXY(false, scrollX, scrollY);
michael@0:
michael@0: for (let frame = win; frame != topWin; frame = frame.parent) {
michael@0: let rect = frame.frameElement.getBoundingClientRect();
michael@0: let left = frame.getComputedStyle(frame.frameElement, "").borderLeftWidth;
michael@0: let top = frame.getComputedStyle(frame.frameElement, "").borderTopWidth;
michael@0: scrollX.value += rect.left + parseInt(left, 10);
michael@0: scrollY.value += rect.top + parseInt(top, 10);
michael@0: }
michael@0: let rect = Rect.fromRect(selection.getRangeAt(0).getBoundingClientRect());
michael@0: return rect.translate(scrollX.value, scrollY.value);
michael@0: },
michael@0:
michael@0: _outlineLink: function (aDrawOutline) {
michael@0: let foundLink = this._fastFind.foundLink;
michael@0:
michael@0: // Optimization: We are drawing outlines and we matched
michael@0: // the same link before, so don't duplicate work.
michael@0: if (foundLink == this._previousLink && aDrawOutline)
michael@0: return;
michael@0:
michael@0: this._restoreOriginalOutline();
michael@0:
michael@0: if (foundLink && aDrawOutline) {
michael@0: // Backup original outline
michael@0: this._tmpOutline = foundLink.style.outline;
michael@0: this._tmpOutlineOffset = foundLink.style.outlineOffset;
michael@0:
michael@0: // Draw pseudo focus rect
michael@0: // XXX Should we change the following style for FAYT pseudo focus?
michael@0: // XXX Shouldn't we change default design if outline is visible
michael@0: // already?
michael@0: // Don't set the outline-color, we should always use initial value.
michael@0: foundLink.style.outline = "1px dotted";
michael@0: foundLink.style.outlineOffset = "0";
michael@0:
michael@0: this._previousLink = foundLink;
michael@0: }
michael@0: },
michael@0:
michael@0: _restoreOriginalOutline: function () {
michael@0: // Removes the outline around the last found link.
michael@0: if (this._previousLink) {
michael@0: this._previousLink.style.outline = this._tmpOutline;
michael@0: this._previousLink.style.outlineOffset = this._tmpOutlineOffset;
michael@0: this._previousLink = null;
michael@0: }
michael@0: },
michael@0:
michael@0: _highlight: function (aHighlight, aWord, aWindow) {
michael@0: let win = aWindow || this._getWindow();
michael@0:
michael@0: let found = false;
michael@0: for (let i = 0; win.frames && i < win.frames.length; i++) {
michael@0: if (this._highlight(aHighlight, aWord, win.frames[i]))
michael@0: found = true;
michael@0: }
michael@0:
michael@0: let controller = this._getSelectionController(win);
michael@0: let doc = win.document;
michael@0: if (!controller || !doc || !doc.documentElement) {
michael@0: // Without the selection controller,
michael@0: // we are unable to (un)highlight any matches
michael@0: return found;
michael@0: }
michael@0:
michael@0: let body = (doc instanceof Ci.nsIDOMHTMLDocument && doc.body) ?
michael@0: doc.body : doc.documentElement;
michael@0:
michael@0: if (aHighlight) {
michael@0: let searchRange = doc.createRange();
michael@0: searchRange.selectNodeContents(body);
michael@0:
michael@0: let startPt = searchRange.cloneRange();
michael@0: startPt.collapse(true);
michael@0:
michael@0: let endPt = searchRange.cloneRange();
michael@0: endPt.collapse(false);
michael@0:
michael@0: let retRange = null;
michael@0: let finder = Cc["@mozilla.org/embedcomp/rangefind;1"]
michael@0: .createInstance()
michael@0: .QueryInterface(Ci.nsIFind);
michael@0:
michael@0: finder.caseSensitive = this._fastFind.caseSensitive;
michael@0:
michael@0: while ((retRange = finder.Find(aWord, searchRange,
michael@0: startPt, endPt))) {
michael@0: this._highlightRange(retRange, controller);
michael@0: startPt = retRange.cloneRange();
michael@0: startPt.collapse(false);
michael@0:
michael@0: found = true;
michael@0: }
michael@0: } else {
michael@0: // First, attempt to remove highlighting from main document
michael@0: let sel = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
michael@0: sel.removeAllRanges();
michael@0:
michael@0: // Next, check our editor cache, for editors belonging to this
michael@0: // document
michael@0: if (this._editors) {
michael@0: for (let x = this._editors.length - 1; x >= 0; --x) {
michael@0: if (this._editors[x].document == doc) {
michael@0: sel = this._editors[x].selectionController
michael@0: .getSelection(Ci.nsISelectionController.SELECTION_FIND);
michael@0: sel.removeAllRanges();
michael@0: // We don't need to listen to this editor any more
michael@0: this._unhookListenersAtIndex(x);
michael@0: }
michael@0: }
michael@0: }
michael@0:
michael@0: // Removing the highlighting always succeeds, so return true.
michael@0: found = true;
michael@0: }
michael@0:
michael@0: return found;
michael@0: },
michael@0:
michael@0: _highlightRange: function(aRange, aController) {
michael@0: let node = aRange.startContainer;
michael@0: let controller = aController;
michael@0:
michael@0: let editableNode = this._getEditableNode(node);
michael@0: if (editableNode)
michael@0: controller = editableNode.editor.selectionController;
michael@0:
michael@0: let findSelection = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
michael@0: findSelection.addRange(aRange);
michael@0:
michael@0: if (editableNode) {
michael@0: // Highlighting added, so cache this editor, and hook up listeners
michael@0: // to ensure we deal properly with edits within the highlighting
michael@0: if (!this._editors) {
michael@0: this._editors = [];
michael@0: this._stateListeners = [];
michael@0: }
michael@0:
michael@0: let existingIndex = this._editors.indexOf(editableNode.editor);
michael@0: if (existingIndex == -1) {
michael@0: let x = this._editors.length;
michael@0: this._editors[x] = editableNode.editor;
michael@0: this._stateListeners[x] = this._createStateListener();
michael@0: this._editors[x].addEditActionListener(this);
michael@0: this._editors[x].addDocumentStateListener(this._stateListeners[x]);
michael@0: }
michael@0: }
michael@0: },
michael@0:
michael@0: _getSelectionController: function(aWindow) {
michael@0: // display: none iframes don't have a selection controller, see bug 493658
michael@0: if (!aWindow.innerWidth || !aWindow.innerHeight)
michael@0: return null;
michael@0:
michael@0: // Yuck. See bug 138068.
michael@0: let docShell = aWindow.QueryInterface(Ci.nsIInterfaceRequestor)
michael@0: .getInterface(Ci.nsIWebNavigation)
michael@0: .QueryInterface(Ci.nsIDocShell);
michael@0:
michael@0: let controller = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
michael@0: .getInterface(Ci.nsISelectionDisplay)
michael@0: .QueryInterface(Ci.nsISelectionController);
michael@0: return controller;
michael@0: },
michael@0:
michael@0: /*
michael@0: * For a given node, walk up it's parent chain, to try and find an
michael@0: * editable node.
michael@0: *
michael@0: * @param aNode the node we want to check
michael@0: * @returns the first node in the parent chain that is editable,
michael@0: * null if there is no such node
michael@0: */
michael@0: _getEditableNode: function (aNode) {
michael@0: while (aNode) {
michael@0: if (aNode instanceof Ci.nsIDOMNSEditableElement)
michael@0: return aNode.editor ? aNode : null;
michael@0:
michael@0: aNode = aNode.parentNode;
michael@0: }
michael@0: return null;
michael@0: },
michael@0:
michael@0: /*
michael@0: * Helper method to unhook listeners, remove cached editors
michael@0: * and keep the relevant arrays in sync
michael@0: *
michael@0: * @param aIndex the index into the array of editors/state listeners
michael@0: * we wish to remove
michael@0: */
michael@0: _unhookListenersAtIndex: function (aIndex) {
michael@0: this._editors[aIndex].removeEditActionListener(this);
michael@0: this._editors[aIndex]
michael@0: .removeDocumentStateListener(this._stateListeners[aIndex]);
michael@0: this._editors.splice(aIndex, 1);
michael@0: this._stateListeners.splice(aIndex, 1);
michael@0: if (!this._editors.length) {
michael@0: delete this._editors;
michael@0: delete this._stateListeners;
michael@0: }
michael@0: },
michael@0:
michael@0: /*
michael@0: * Remove ourselves as an nsIEditActionListener and
michael@0: * nsIDocumentStateListener from a given cached editor
michael@0: *
michael@0: * @param aEditor the editor we no longer wish to listen to
michael@0: */
michael@0: _removeEditorListeners: function (aEditor) {
michael@0: // aEditor is an editor that we listen to, so therefore must be
michael@0: // cached. Find the index of this editor
michael@0: let idx = this._editors.indexOf(aEditor);
michael@0: if (idx == -1)
michael@0: return;
michael@0: // Now unhook ourselves, and remove our cached copy
michael@0: this._unhookListenersAtIndex(idx);
michael@0: },
michael@0:
michael@0: /*
michael@0: * nsIEditActionListener logic follows
michael@0: *
michael@0: * We implement this interface to allow us to catch the case where
michael@0: * the findbar found a match in a HTML or