diff --git a/browser/components/extensions/ext-browser.js b/browser/components/extensions/ext-browser.js
--- a/browser/components/extensions/ext-browser.js
+++ b/browser/components/extensions/ext-browser.js
@@ -217,9 +217,17 @@ extensions.registerModules({
windows: {
url: "chrome://browser/content/ext-windows.js",
schema: "chrome://browser/content/schemas/windows.json",
scopes: ["addon_parent"],
paths: [
["windows"],
],
},
+ find: {
+ url: "chrome://browser/content/ext-find.js",
+ schema: "chrome://browser/content/schemas/find.json",
+ scopes: ["addon_parent"],
+ paths: [
+ ["find"],
+ ],
+ },
});
diff --git a/browser/components/extensions/ext-find.js b/browser/components/extensions/ext-find.js
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/ext-find.js
@@ -0,0 +1,105 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+this.find = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ find: {
+
+ /**
+ * browser.find.search
+ *
+ * @param queryphrase string required - the string to search for.
+ * @param tabId integer optional - tab to query. If tabId is not present uses the active tab.
+ * @param includeRectData boolean optional - whether to return rectangle data.
+ * @param includeRangeData boolean optional - whether to return range data.
+ *
+ * Collects all ranges of queryphrase found within the document and its frames.
+ * Stores collected ranges in array accessible by other browser.find methods.
+ *
+ * The returned value contains the tabId of the tabs searched, and an object
+ * `searchResults` which may contain the following, some if opted in:
+ * retval count integer always - the number of matches found.
+ * retval rectData array optional - contains serializable data describing the visible positions
+ * of all the matches on the page. Can be used by extensions for custom highlighting.
+ * retval rangeData array optional - contains serializable data describing the positions of the
+ * starting and ending nodes of the ranges found, in relation to document order.
+ * Can be used by extensions for getting surround context of matches.
+ */
+ search(query) {
+ let { queryphrase, tabId, includeRectData, includeRangeData } = query;
+ let tab = tabId ? tabTracker.getTab(tabId) : tabTracker.activeTab;
+ let browser = tab.linkedBrowser;
+ let finder = browser.finder;
+ tabId = tabTracker.getId(tab);
+
+ return new Promise(resolve => {
+ if (browser.isRemoteBrowser) {
+ let mm = browser.messageManager;
+ mm.addMessageListener("Finder:CollectSearchResultsFinished", function returnFindRanges(message) {
+ mm.removeMessageListener("Finder:CollectSearchResultsFinished", returnFindRanges);
+ message.data.tabId = tabId;
+ resolve(message.data);
+ });
+ finder.getSearchResults(queryphrase, includeRangeData, includeRectData);
+ } else {
+ let searchResults = finder.getSearchResults(queryphrase, includeRangeData, includeRectData);
+ resolve({ tabId, searchResults });
+ }
+ });
+ },
+
+ /**
+ * browser.find.highlightResults
+ *
+ * @param rangeIndexes array required - indexes of ranges held in API's ranges array for the tabId.
+ * Empty array will highlight all ranges.
+ * @param tabId number optional - identifies which tab to highlight. Defaults to selected tab.
+ * @param noScroll boolean optional - don't scroll to last highlighted item.
+ * @param noRemoveHighlighting boolean optional - don't remove previous highlighting.
+ *
+ * Returned value `status` contains an integer value describing the resulting
+ * status of the highlighting:
+ * 1 - there were search results found at all indices supplied.
+ * 2 - there were no search results at some of the indices supplied.
+ * 3 - there were no search results at any of the indices supplied.
+ * 4 - there were no search results to highlight.
+ */
+ highlightResults(params) {
+ let { rangeIndexes, tabId, noScroll, noRemoveHighlighting } = params;
+ let tab = tabId ? tabTracker.getTab(tabId) : tabTracker.activeTab;
+ let browser = tab.linkedBrowser;
+ let finder = browser.finder;
+
+ return new Promise(resolve => {
+ if (browser.isRemoteBrowser) {
+ let mm = browser.messageManager;
+ mm.addMessageListener("Finder:HighlightSearchResultsFinished", function highlightFindRanges(message) {
+ mm.removeMessageListener("Finder:HighlightSearchResultsFinished", highlightFindRanges);
+ resolve(message.params);
+ });
+ finder.highlightSearchResults(params.rangeIndexes, noScroll, noRemoveHighlighting);
+ } else {
+ let status = finder.highlightSearchResults(params.rangeIndexes, noScroll, noRemoveHighlighting);
+ resolve({ status });
+ }
+ });
+ },
+
+ /**
+ * browser.find.removeHighlighting
+ *
+ * Removes all hightlighting from previous search.
+ */
+ removeHighlighting(tabId) {
+ let tab = tabId ? tabTracker.getTab(tabId) : tabTracker.activeTab;
+ return new Promise(resolve => {
+ tab.linkedBrowser.finder.removeHighlighting();
+ resolve();
+ });
+ },
+ },
+ };
+ }
+}
diff --git a/browser/components/extensions/schemas/find.json b/browser/components/extensions/schemas/find.json
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/schemas/find.json
@@ -0,0 +1,105 @@
+// Copyright (c) 2012 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "Permission",
+ "choices": [{
+ "type": "string",
+ "enum": [
+ "find"
+ ]
+ }]
+ }
+ ]
+ },
+ {
+ "namespace": "find",
+ "description": "Use the chrome.find
API to interact with the browsers find
system.",
+ "permissions": ["find"],
+ "functions": [
+ {
+ "name": "search",
+ "type": "function",
+ "async": true,
+ "description": "Search for text and store in array",
+ "parameters": [
+ {
+ "name": "query",
+ "type": "object",
+ "description": "Search query parameters",
+ "properties": {
+ "queryphrase": {
+ "type": "string"
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0
+ },
+ "includeRangeData": {
+ "type": "boolean",
+ "optional": true
+ },
+ "includeRectData": {
+ "type": "boolean",
+ "optional": true
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "highlightResults",
+ "type": "function",
+ "async": true,
+ "description": "Highlight a range",
+ "parameters": [
+ {
+ "name": "params",
+ "type": "object",
+ "description": "highlightResults parameters",
+ "properties": {
+ "rangeIndexes": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "tabId": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "noScroll": {
+ "type": "boolean",
+ "optional": true
+ },
+ "noRemoveHighlighting": {
+ "type": "boolean",
+ "optional": true
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "removeHighlighting",
+ "type": "function",
+ "async": true,
+ "description": "Remove all highlighting from previous searches.",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/toolkit/modules/Finder.jsm b/toolkit/modules/Finder.jsm
--- a/toolkit/modules/Finder.jsm
+++ b/toolkit/modules/Finder.jsm
@@ -19,16 +19,19 @@ XPCOMUtils.defineLazyServiceGetter(this,
"@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");
+XPCOMUtils.defineLazyServiceGetter(this, "RangeFinder",
+ "@mozilla.org/embedcomp/rangefind;1",
+ "nsIFind");
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);
@@ -347,16 +350,220 @@ Finder.prototype = {
onHighlightAllChange(highlightAll) {
if (this._highlighter)
this._highlighter.onHighlightAllChange(highlightAll);
if (this._iterator)
this._iterator.reset();
},
+ searchResults: [],
+
+ /**
+ * getSearchResults
+ *
+ * Used by extensions to perform a search which will store found ranges
+ * in `searchResults`. This data can then be used by `highlightSearchResults`,
+ * `collectRectData` and `collectRangeData` for extensions to use custom
+ * UI presentation.
+ *
+ * @param searchString string - the text to search for.
+ * @param includeRangeData boolean - whether to collect and include range
+ * data in return value.
+ * @param searchString boolean - whether to collect and include rectangle
+ * data in return value.
+ *
+ * @retval count integer - the number of results found.
+ * @retval rectData - if opted, the rectangle data.
+ * @retval rangeData - if opted, the range data.
+ */
+ getSearchResults(searchString, includeRangeData, includeRectData) {
+ let topWin = this._getWindow();
+ this.searchResults = this._findInDocument(topWin, searchString);
+ let searchResults = this.searchResults;
+
+ let rectData;
+ let rangeData;
+ if (includeRectData) {
+ rectData = this.collectRectData();
+ }
+ if (includeRangeData) {
+ rangeData = this.collectRangeData();
+ }
+
+ return { count: searchResults.length, rectData, rangeData };
+ },
+
+ /**
+ * collectRectData
+ *
+ * Create an array of rectangle data corresponding to position of text on the
+ * page found by most recent search made by `getSearchResults`. Useful to
+ * extensions for custom highlighting of search results.
+ *
+ * @retval array - serializable rectangle data.
+ */
+ collectRectData() {
+ let searchResults = this.searchResults;
+
+ // Get from the cache if available.
+ if (searchResults.rectData) {
+ return searchResults.rectData;
+ }
+
+ let topWin = this._getWindow();
+ let rectData = [];
+
+ let len = searchResults.length;
+ for (let i = 0; i < len; i++) {
+ let datum = searchResults[i]
+ let win = datum.win;
+ let range = datum.range;
+ let doc = win.document;
+
+ let rectdata = this._computeRectFromWinAndRange(topWin, win, range);
+ datum.rectdata = rectdata;
+ let {scrollX, scrollY, rect} = rectdata;
+ rectData.push({scrollX, scrollY, rect: {x: rect.x, y: rect.y,
+ left: rect.left, top: rect.top,
+ right: rect.right, bottom: rect.bottom,
+ width: rect.width, height: rect.height}})
+ }
+ // Cache in searchResults.
+ searchResults.rectData = rectData;
+
+ return rectData;
+ },
+
+ /**
+ * collectRangeData
+ *
+ * Collect data from ranges found on the most recent search made by
+ * `getSearchResults` and make it in serializable. Returns an array whose
+ * members correspond to found ranges in document order. Useful to
+ * extensions for finding surrounding text of search results.
+ *
+ * @retval array - serializable range data.
+ */
+ collectRangeData() {
+ let searchResults = this.searchResults;
+
+ // Get from the cache if available.
+ if (searchResults.rangeData) {
+ return searchResults.rangeData;
+ }
+
+ let rangeData = [];
+ let nodeCountWin = 0;
+ let lastWin;
+ let walker;
+ let node;
+
+ let len = searchResults.length;
+ for (let i = 0; i < len; i++) {
+ let datum = searchResults[i]
+ let { win, range, framePos } = datum;
+ let doc = win.document;
+
+ if (lastWin !== win) {
+ walker = doc.createTreeWalker(doc, win.NodeFilter.SHOW_TEXT, null, false);
+ // Get first node.
+ node = walker.nextNode();
+ // Reset node count.
+ nodeCountWin = 0;
+ }
+ lastWin = win;
+
+ let data = { framePos };
+ rangeData.push(data);
+
+ if (node != range.startContainer) {
+ while(node = walker.nextNode()) {
+ nodeCountWin++;
+ if (node == range.startContainer) {
+ break;
+ }
+ }
+ }
+ data.startTextNodePos = nodeCountWin;
+ data.startOffset = range.startOffset
+
+ if (range.startContainer != range.endContainer) {
+ while(node = walker.nextNode()) {
+ nodeCountWin++;
+ if (node == range.endContainer) {
+ break;
+ }
+ }
+ }
+ data.endTextNodePos = nodeCountWin;
+ data.endOffset = range.endOffset
+ }
+ // Cache in searchResults.
+ searchResults.rangeData = rangeData;
+
+ return rangeData;
+ },
+
+ /* highlightSearchResults
+ * param rangeIndexes array - indexes in this.searchResults to highlight
+ * param noScroll boolean - whether or not to scroll page to the hightlighted text.
+ * param noRemoveHighlighting boolean - whether or not to remove highlighting
+ * from a previous highlighting operation.
+ *
+ * retval status - integer value describing the resulting status of the highlighting:
+ * 1 - there were search results found at all indices supplied.
+ * 2 - there were no search results at some of the indices supplied.
+ * 3 - there were no search results at any of the indices supplied.
+ * 4 - there were no search results to highlight.
+ */
+ highlightSearchResults(rangeIndexes, noScroll, noRemoveHighlighting) {
+ let searchResults = this.searchResults;
+
+ // Remove previous highlighting.
+ // TODO: decide exactly how we want to handle this. Opt in? Opt out?
+ // Remove only if there is something to highlight?
+ if (!noRemoveHighlighting) {
+ this._removeHighlightDoc(this._getWindow());
+ }
+
+ let status = 4;
+ let rangesNotFound = [];
+
+ if (searchResults.length) {
+ // We highlight the first range last so if noScroll is default,
+ // we scroll to the first range.
+ if (!rangeIndexes.length) {
+ for (let i = 1; i < searchResults.length; i++) {
+ rangeIndexes.push(i);
+ }
+ rangeIndexes.push(0);
+ }
+
+ let rangeFound = false;
+
+ // TODO: prevent scrolling to the last highlighted term option/default.
+ for (let rangeIndex of rangeIndexes) {
+ if (searchResults[rangeIndex]) {
+ let result = searchResults[rangeIndex];
+ this._highlightSearchResult(result.win, result.range, noScroll);
+ rangeFound = true;
+ } else {
+ rangesNotFound.push(rangeIndex);
+ }
+ }
+ status = !rangeFound ? 3 : rangesNotFound.length ? 2 : 1;
+ }
+ return status;
+ },
+
+ removeHighlighting() {
+ this._removeHighlightDoc(this._getWindow());
+ },
+
keyPress(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.ownerGlobal;
this._fastFind.foundLink.dispatchEvent(new view.MouseEvent("click", {
@@ -510,31 +717,36 @@ Finder.prototype = {
}
}
}
if (!selection.rangeCount || selection.isCollapsed) {
return null;
}
+ let {scrollX, scrollY, rect} = this._computeRectFromWinAndRange(topWin, win, selection.getRangeAt(0));
+ return rect.translate(scrollX, scrollY);
+ },
+
+ _computeRectFromWinAndRange(topWin, win, range) {
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);
+ let rect = Rect.fromRect(range.getBoundingClientRect());
+ return {scrollX: scrollX.value, scrollY: scrollY.value, rect};
},
_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)
@@ -563,16 +775,75 @@ Finder.prototype = {
// 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;
}
},
+ _findInDocument(topWin, searchString) {
+ let wins = this._getFrames(topWin);
+
+ let searchResults = [];
+ for (let i = 0; i < wins.length; i++) {
+ let win = wins[i]
+ let doc = win.document;
+
+ if (!doc) {
+ continue;
+ }
+
+ let searchRange = doc.createRange();
+ searchRange.selectNodeContents(doc.body);
+ let start = searchRange.cloneRange();
+ let end = searchRange.cloneRange();
+ start.collapse(true);
+ end.collapse(false);
+
+ let result;
+ while((result = RangeFinder.Find(searchString, searchRange, start, end))) {
+ let startNode = result.startContainer;
+ if (startNode.parentNode && startNode.parentNode.parentNode &&
+ startNode.parentNode.parentNode instanceof Components.interfaces.nsIDOMNSEditableElement) {
+ start = result;
+ start.collapse(false);
+ continue;
+ }
+ // We must collapse result no matter what, so use try/catch.
+ try {
+ searchResults.push({ range: result.cloneRange(), win, framePos: i });
+ start = result;
+ start.collapse(false);
+ } catch(e) {
+ start = result;
+ start.collapse(false);
+ }
+ }
+ }
+ return searchResults;
+ },
+
+ _getFrames(topWin) {
+ function getframes(win, frameList) {
+ for (var i = 0; win.frames && i < win.frames.length; i++) {
+ let frame = win.frames[i];
+ if (!frame || !frame.document || !frame.frameElement) {
+ continue;
+ }
+ frameList.push(frame);
+ getframes(frame, frameList);
+ }
+ }
+ let frameList = [topWin];
+ getframes(topWin, frameList);
+
+ return frameList;
+ },
+
_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.
@@ -585,16 +856,72 @@ Finder.prototype = {
.QueryInterface(Ci.nsIDocShell);
let controller = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsISelectionDisplay)
.QueryInterface(Ci.nsISelectionController);
return controller;
},
+ _highlightSearchResult(win, range, noScroll) {
+ let node = range.startContainer;
+
+ let editableNode = this._getEditableNode(node);
+ let controller = editableNode ? editableNode.editor.selectionController :
+ this._getSelectionController(win);
+
+ // this does the selection
+ let findSelection = controller.getSelection(128);
+ findSelection.addRange(range);
+
+ if (!noScroll) {
+ // this scrolls it into view
+ controller.scrollSelectionIntoView( controller.SELECTION_FIND,
+ controller.SELECTION_ON,
+ controller.SCROLL_CENTER_VERTICALLY);
+ }
+ },
+
+ _getEditableNode(node) {
+ while (node) {
+ if (node instanceof Components.interfaces.nsIDOMNSEditableElement) {
+ return node.editor ? node : null;
+ }
+ node = node.parentNode;
+ }
+ return null;
+ },
+
+ _removeHighlightDoc(win) {
+ let textFound = false;
+
+ for (let i = 0; win.frames && i < win.frames.length; i++) {
+ if (this._removeHighlightDoc(win.frames[i])) {
+ textFound = true;
+ }
+ }
+
+ let controller = this._getSelectionController(win);
+ if (!controller) {
+ return textFound;
+ }
+
+ let doc = win.document;
+ if (!doc) {
+ return textFound;
+ }
+
+ try {
+ let sel = controller.getSelection(controller.SELECTION_FIND);
+ sel.removeAllRanges();
+ } catch(e) {}
+
+ return true;
+ },
+
// 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;
diff --git a/toolkit/modules/RemoteFinder.jsm b/toolkit/modules/RemoteFinder.jsm
--- a/toolkit/modules/RemoteFinder.jsm
+++ b/toolkit/modules/RemoteFinder.jsm
@@ -198,16 +198,34 @@ RemoteFinder.prototype = {
altKey: aEvent.altKey,
shiftKey: aEvent.shiftKey });
},
requestMatchesCount(aSearchString, aLinksOnly) {
this._browser.messageManager.sendAsyncMessage("Finder:MatchesCount",
{ searchString: aSearchString,
linksOnly: aLinksOnly });
+ },
+
+ getSearchResults(searchString, includeRangeData, includeRectData) {
+ this._browser.messageManager.sendAsyncMessage("Finder:CollectSearchResults",
+ { searchString,
+ includeRangeData,
+ includeRectData });
+ },
+
+ highlightSearchResults(rangeIndexes, noScroll, noRemoveHighlighting) {
+ this._browser.messageManager.sendAsyncMessage("Finder:HighlightSearchResults",
+ { rangeIndexes,
+ noScroll,
+ noRemoveHighlighting });
+ },
+
+ removeHighlighting() {
+ this._browser.messageManager.sendAsyncMessage("Finder:RemoveHighlighting");
}
}
function RemoteFinderListener(global) {
let {Finder} = Cu.import("resource://gre/modules/Finder.jsm", {});
this._finder = new Finder(global.docShell);
this._finder.addResultListener(this);
this._global = global;
@@ -229,17 +247,20 @@ RemoteFinderListener.prototype = {
"Finder:HighlightAllChange",
"Finder:EnableSelection",
"Finder:RemoveSelection",
"Finder:FocusContent",
"Finder:FindbarClose",
"Finder:FindbarOpen",
"Finder:KeyPress",
"Finder:MatchesCount",
- "Finder:ModalHighlightChange"
+ "Finder:ModalHighlightChange",
+ "Finder:CollectSearchResults",
+ "Finder:HighlightSearchResults",
+ "Finder:RemoveHighlighting"
],
onFindResult(aData) {
this._global.sendAsyncMessage("Finder:Result", aData);
},
// When the child receives messages with results of requestMatchesCount,
// it passes them forward to the parent.
@@ -321,11 +342,27 @@ RemoteFinderListener.prototype = {
case "Finder:MatchesCount":
this._finder.requestMatchesCount(data.searchString, data.linksOnly);
break;
case "Finder:ModalHighlightChange":
this._finder.onModalHighlightChange(data.useModalHighlight);
break;
+
+ case "Finder:CollectSearchResults":
+ let { searchString, includeRangeData, includeRectData } = data;
+ let searchResults = this._finder.getSearchResults(searchString, includeRangeData, includeRectData);
+ this._global.sendAsyncMessage("Finder:CollectSearchResultsFinished", { searchResults });
+ break;
+
+ case "Finder:HighlightSearchResults":
+ let { rangeIndexes, noScroll, noRemoveHighlighting } = data;
+ let status = this._finder.highlightSearchResults(rangeIndexes, noScroll, noRemoveHighlighting);
+ this._global.sendAsyncMessage("Finder:HighlightSearchResultsFinished", { status });
+ break;
+
+ case "Finder:RemoveHighlighting":
+ this._finder.removeHighlighting();
+ break;
}
}
};