diff --git a/browser/components/extensions/ext-browser.json b/browser/components/extensions/ext-browser.json
index f62a842d3ea2..e3e0c43d343d 100644
--- a/browser/components/extensions/ext-browser.json
+++ b/browser/components/extensions/ext-browser.json
@@ -72,6 +72,14 @@
["devtools", "panels"]
]
},
+ "find": {
+ "url": "chrome://browser/content/ext-find.js",
+ "schema": "chrome://browser/content/schemas/find.json",
+ "scopes": ["addon_parent"],
+ "paths": [
+ ["find"]
+ ]
+ },
"history": {
"url": "chrome://browser/content/ext-history.js",
"schema": "chrome://browser/content/schemas/history.json",
diff --git a/browser/components/extensions/ext-find.js b/browser/components/extensions/ext-find.js
new file mode 100644
index 000000000000..e26cdb290226
--- /dev/null
+++ b/browser/components/extensions/ext-find.js
@@ -0,0 +1,106 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* global tabTracker */
+"use strict";
+
+/**
+ * runFindOperation
+ * Utility for `find` and `highlightResults`.
+ *
+ * @param {object} params - params to pass to message sender.
+ * @param {string} message - identifying component of message name.
+ *
+ * @returns {Promise} a promise that will be resolved or rejected based on the
+ * data received by the message listener.
+ */
+function runFindOperation(params, message) {
+ let {tabId} = params;
+ let tab = tabId ? tabTracker.getTab(tabId) : tabTracker.activeTab;
+ let browser = tab.linkedBrowser;
+ let mm = browser.messageManager;
+ tabId = tabId || tabTracker.getId(tab);
+
+ return new Promise((resolve, reject) => {
+ mm.addMessageListener(`ext-Finder:${message}Finished`, function messageListener(message) {
+ mm.removeMessageListener(`ext-Finder:${message}Finished`, messageListener);
+ switch (message.data) {
+ case "Success":
+ resolve();
+ break;
+ case "OutOfRange":
+ reject({message: "index supplied was out of range"});
+ break;
+ case "NoResults":
+ reject({message: "no search results to highlight"});
+ break;
+ }
+ resolve(message.data);
+ });
+ mm.sendAsyncMessage(`ext-Finder:${message}`, params);
+ });
+};
+
+this.find = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ find: {
+ /**
+ * browser.find.find
+ * Searches document and its frames for a given queryphrase and stores all found
+ * Range objects in an array accessible by other browser.find methods.
+ *
+ * @param {string} queryphrase - The string to search for.
+ * @param {object} params optional - may contain any of the following properties,
+ * all of which are optional:
+ * {number} tabId - Tab to query. Defaults to the active tab.
+ * {boolean} caseSensitive - Highlight only ranges with case sensitive match.
+ * {boolean} entireWord - Highlight only ranges that match entire word.
+ * {boolean} includeRangeData - Whether to return range data.
+ * {boolean} includeRectData - Whether to return rectangle data.
+ *
+ * @returns {object} data received by the message listener that includes:
+ * {number} count - number of results found.
+ * {array} rangeData (if opted) - serialized representation of ranges found.
+ * {array} rectData (if opted) - rect data of ranges found.
+ */
+ find(queryphrase, params) {
+ params = params || {};
+ params.queryphrase = queryphrase;
+ return runFindOperation(params, "CollectResults");
+ },
+
+ /**
+ * browser.find.highlightResults
+ * Highlights range(s) found in previous browser.find.find.
+ *
+ * @param {object} params optional - may contain any of the following properties,
+ * all of which are optional:
+ * {number} rangeIndex - Found range to be highlighted. Default highlights all ranges.
+ * {number} tabId - Tab to highlight. Defaults to the active tab.
+ * {boolean} noScroll - Don't scroll to highlighted item.
+ *
+ * @returns {string} - data received by the message listener that may be:
+ * "Success" - Highlighting succeeded.
+ * "OutOfRange" - The index supplied was out of range.
+ * "NoResults" - There were no search results to highlight.
+ */
+ highlightResults(params) {
+ params = params || {};
+ return runFindOperation(params, "HighlightResults");
+ },
+
+ /**
+ * browser.find.removeHighlighting
+ * Removes all hightlighting from previous search.
+ *
+ * @param {number} tabId optional
+ * Tab to clear highlighting in. Defaults to the active tab.
+ */
+ removeHighlighting(tabId) {
+ let tab = tabId ? tabTracker.getTab(tabId) : tabTracker.activeTab;
+ tab.linkedBrowser.messageManager.sendAsyncMessage("ext-Finder:clearHighlighting");
+ },
+ },
+ };
+ }
+};
diff --git a/browser/components/extensions/jar.mn b/browser/components/extensions/jar.mn
index f6b356c46efa..8532aa8d0292 100644
--- a/browser/components/extensions/jar.mn
+++ b/browser/components/extensions/jar.mn
@@ -23,6 +23,7 @@ browser.jar:
content/browser/ext-devtools-inspectedWindow.js
content/browser/ext-devtools-network.js
content/browser/ext-devtools-panels.js
+ content/browser/ext-find.js
content/browser/ext-geckoProfiler.js
content/browser/ext-history.js
content/browser/ext-menus.js
diff --git a/browser/components/extensions/schemas/find.json b/browser/components/extensions/schemas/find.json
new file mode 100644
index 000000000000..a365a7436224
--- /dev/null
+++ b/browser/components/extensions/schemas/find.json
@@ -0,0 +1,121 @@
+// 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": "OptionalPermission",
+ "choices": [{
+ "type": "string",
+ "enum": [
+ "find"
+ ]
+ }]
+ }
+ ]
+ },
+ {
+ "namespace": "find",
+ "description": "Use the browser.find
API to interact with the browser's Find
interface.",
+ "permissions": ["find"],
+ "functions": [
+ {
+ "name": "find",
+ "type": "function",
+ "async": true,
+ "description": "Search for text in document and store found ranges in array, in document order.",
+ "parameters": [
+ {
+ "name": "queryphrase",
+ "type": "string",
+ "description": "The string to search for."
+ },
+ {
+ "name": "params",
+ "type": "object",
+ "description": "Search parameters.",
+ "optional": true,
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "description": "Tab to query. Defaults to the active tab.",
+ "optional": true,
+ "minimum": 0
+ },
+ "caseSensitive": {
+ "type": "boolean",
+ "description": "Find only ranges with case sensitive match.",
+ "optional": true
+ },
+ "entireWord": {
+ "type": "boolean",
+ "description": "Find only ranges that match entire word.",
+ "optional": true
+ },
+ "includeRectData": {
+ "description": "Return rectangle data which describes visual position of search results.",
+ "type": "boolean",
+ "optional": true
+ },
+ "includeRangeData": {
+ "description": "Return range data which provides range data in a serializable form.",
+ "type": "boolean",
+ "optional": true
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "highlightResults",
+ "type": "function",
+ "async": true,
+ "description": "Highlight a range",
+ "parameters": [
+ {
+ "name": "params",
+ "type": "object",
+ "description": "highlightResults parameters",
+ "optional": true,
+ "properties": {
+ "rangeIndex": {
+ "type": "integer",
+ "description": "Found range to be highlighted. Default highlights all ranges.",
+ "minimum": 0,
+ "optional": true
+ },
+ "tabId": {
+ "type": "integer",
+ "description": "Tab to highlight. Defaults to the active tab.",
+ "minimum": 0,
+ "optional": true
+ },
+ "noScroll": {
+ "type": "boolean",
+ "description": "Don't scroll to highlighted item.",
+ "optional": true
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "removeHighlighting",
+ "type": "function",
+ "async": true,
+ "description": "Remove all highlighting from previous searches.",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "Tab to highlight. Defaults to the active tab.",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/browser/components/extensions/schemas/jar.mn b/browser/components/extensions/schemas/jar.mn
index 02bc61ec2e23..8ff07a716138 100644
--- a/browser/components/extensions/schemas/jar.mn
+++ b/browser/components/extensions/schemas/jar.mn
@@ -12,6 +12,7 @@ browser.jar:
content/browser/schemas/devtools_inspected_window.json
content/browser/schemas/devtools_network.json
content/browser/schemas/devtools_panels.json
+ content/browser/schemas/find.json
content/browser/schemas/geckoProfiler.json
content/browser/schemas/history.json
content/browser/schemas/menus.json
diff --git a/browser/components/extensions/test/browser/browser-common.ini b/browser/components/extensions/test/browser/browser-common.ini
index a0279e2d1b3f..c4d3446651bc 100644
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -10,6 +10,7 @@ support-files =
context_tabs_onUpdated_page.html
context_tabs_onUpdated_iframe.html
file_clearplugindata.html
+ file_find_frames.html
file_popup_api_injection_a.html
file_popup_api_injection_b.html
file_iframe_document.html
@@ -73,6 +74,7 @@ skip-if = (os == 'win' && !debug) # bug 1352668
[browser_ext_devtools_page.js]
[browser_ext_devtools_panel.js]
[browser_ext_devtools_panels_elements.js]
+[browser_ext_find.js]
[browser_ext_geckoProfiler_symbolicate.js]
[browser_ext_getViews.js]
[browser_ext_identity_indication.js]
diff --git a/browser/components/extensions/test/browser/browser_ext_find.js b/browser/components/extensions/test/browser/browser_ext_find.js
new file mode 100644
index 000000000000..f7bae8dc2102
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_find.js
@@ -0,0 +1,138 @@
+/* global browser */
+"use strict";
+
+function frameScript() {
+ function getSelectedText() {
+ let frame = this.content.frames[0].frames[1];
+ let Ci = Components.interfaces;
+ let docShell = frame.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebNavigation)
+ .QueryInterface(Ci.nsIDocShell);
+ let controller = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsISelectionDisplay)
+ .QueryInterface(Ci.nsISelectionController);
+ let selection = controller.getSelection(controller.SELECTION_FIND);
+ let range = selection.getRangeAt(0);
+ let r1 = frame.parent.frameElement.getBoundingClientRect();
+ let r2 = frame.frameElement.getBoundingClientRect();
+ let r3 = range.getBoundingClientRect();
+ let rect = {top: (r1.top + r2.top + r3.top), left: (r1.left + r2.left + r3.left)};
+ this.sendAsyncMessage("test:find:selectionTest", {text: selection.toString(), rect});
+ }
+ getSelectedText();
+}
+
+function waitForMessage(messageManager, topic) {
+ return new Promise(resolve => {
+ messageManager.addMessageListener(topic, function messageListener(message) {
+ messageManager.removeMessageListener(topic, messageListener);
+ resolve(message);
+ });
+ });
+}
+
+add_task(async function testDuplicatePinnedTab() {
+ async function background() {
+ function awaitLoad(tabId) {
+ return new Promise(resolve => {
+ browser.tabs.onUpdated.addListener(function listener(tabId_, changed, tab) {
+ if (tabId == tabId_ && changed.status == "complete") {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve();
+ }
+ });
+ });
+ }
+
+ let url = "http://example.com/browser/browser/components/extensions/test/browser/file_find_frames.html";
+ let tab = await browser.tabs.update({url});
+ await awaitLoad(tab.id);
+
+ let data = await browser.find.find("banana", {includeRangeData: true});
+ let rangeData = data.rangeData;
+
+ browser.test.log("Test that `data.count` is the expected value.");
+ browser.test.assertEq(6, data.count, "The value returned from `data.count`");
+
+ browser.test.log("Test that `rangeData` has the proper number of values.");
+ browser.test.assertEq(6, rangeData.length, "The number of values held in `rangeData`");
+
+ browser.test.log("Test that the text found in the top window and nested frames corresponds to the proper position.");
+ let terms = ["Banana", "bAnana", "baNana", "banAna", "banaNa", "bananA"];
+ for (let i = 0; i < terms.length; i++) {
+ browser.test.assertEq(terms[i], rangeData[i].text, `The text at range position ${i}:`);
+ }
+
+ browser.test.log("Test that case sensitive match works properly.");
+ data = await browser.find.find("baNana", {caseSensitive: true, includeRangeData: true});
+ browser.test.assertEq(1, data.count, "The number of matches found:");
+ browser.test.assertEq("baNana", data.rangeData[0].text, "The text found:");
+
+ browser.test.log("Test that case insensitive match works properly.");
+ data = await browser.find.find("banana", {caseSensitive: false});
+ browser.test.assertEq(6, data.count, "The number of matches found:");
+
+ browser.test.log("Test that entire word match works properly.");
+ data = await browser.find.find("banana", {entireWord: true});
+ browser.test.assertEq(4, data.count, "The number of matches found, should skip 2 matches, \"banaNaland\" and \"bananAland\":");
+
+ browser.test.log("Test that `rangeData` is not returned if `includeRangeData` is false.");
+ data = await browser.find.find("banana", {caseSensitive: false, includeRangeData: false});
+ browser.test.assertEq(false, !!data.rangeData, "The boolean cast value of `rangeData`:");
+
+ browser.test.log("Test that `rectData` is not returned if `includeRectData` is false.");
+ data = await browser.find.find("banana", {caseSensitive: false, includeRectData: false});
+ browser.test.assertEq(false, !!data.rectData, "The boolean cast value of `rectData`:");
+
+ browser.test.log("Test that text spanning multiple inline elements is found.");
+ data = await browser.find.find("fruitcake");
+ browser.test.assertEq(1, data.count, "The number of matches found:");
+
+ browser.test.log("Test that text spanning multiple block elements is not found.");
+ data = await browser.find.find("angelfood");
+ browser.test.assertEq(0, data.count, "The number of matches found:");
+
+ browser.test.log("Test that `highlightResults` returns proper status code.");
+ await browser.find.find("banana");
+
+ await browser.test.assertRejects(browser.find.highlightResults({rangeIndex: 6}),
+ /index supplied was out of range/,
+ "rejected Promise should pass the expected error");
+
+ data = await browser.find.find("xyz");
+ await browser.test.assertRejects(browser.find.highlightResults({rangeIndex: 0}),
+ /no search results to highlight/,
+ "rejected Promise should pass the expected error");
+
+ data = await browser.find.find("banana", {includeRectData: true});
+ await browser.find.highlightResults({rangeIndex: 5});
+
+ browser.test.sendMessage("test:find:WebExtensionFinished", data.rectData);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ "permissions": ["find", "tabs"],
+ },
+ background,
+ });
+
+ await extension.startup();
+ let rectData = await extension.awaitMessage("test:find:WebExtensionFinished");
+ let {top, left} = rectData[5].rectsAndTexts.rectList[0];
+ await extension.unload();
+
+ let {selectedBrowser} = gBrowser;
+
+ let frameScriptUrl = `data:,(${frameScript})()`;
+ selectedBrowser.messageManager.loadFrameScript(frameScriptUrl, false);
+ let message = await waitForMessage(selectedBrowser.messageManager, "test:find:selectionTest");
+
+ info("Test that text was highlighted properly.");
+ is(message.data.text, "bananA", `The text that was highlighted: - Expected: bananA, Actual: ${message.data.text}`);
+
+ info("Test that rectangle data returned from the search matches the highlighted result.");
+ is(message.data.rect.top, top, `rect.top: - Expected: ${message.data.rect.top}, Actual: ${top}`);
+ is(message.data.rect.left, left, `rect.left: - Expected: ${message.data.rect.left}, Actual: ${left}`);
+});
+
diff --git a/browser/components/extensions/test/browser/file_find_frames.html b/browser/components/extensions/test/browser/file_find_frames.html
new file mode 100644
index 000000000000..0d8a7a7694f1
--- /dev/null
+++ b/browser/components/extensions/test/browser/file_find_frames.html
@@ -0,0 +1,15 @@
+
+
+
Banana 0
+ +bAnana 1
+fruitcake
+ang