Bug 1332144 - Add browser.find extension API. r=mikedeboer, r=mixedpuppy

Provides access to the browser's internal Find APIs.  Can search,
get range data and rect data on found results, and highlight results.

--HG--
extra : amend_source : dfa2b36794543378db58e411ca4e317a64921831
This commit is contained in:
Kevin Jones 2017-08-24 18:24:00 -04:00
Родитель 034b4fdfa6
Коммит 841456a51c
12 изменённых файлов: 680 добавлений и 0 удалений

Просмотреть файл

@ -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",

Просмотреть файл

@ -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");
},
},
};
}
};

Просмотреть файл

@ -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

Просмотреть файл

@ -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 <code>browser.find</code> API to interact with the browser's <code>Find</code> 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
}
]
}
]
}
]

Просмотреть файл

@ -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

Просмотреть файл

@ -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]

Просмотреть файл

@ -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}`);
});

Просмотреть файл

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<body>
<p>Banana 0</p>
<iframe src="data:text/html,<p>baNana 2</p>
<iframe src='data:text/html,banaNaland 4' height='50' width='100%'></iframe>
<iframe src='data:text/html,bananAland 5' height='50' width='100%'></iframe>
<p>banAna 3</p>" height="250" width="100%"></iframe>
<p>bAnana 1</p>
<p><b>fru</b>it<i><b>ca</b>ke</i></p>
<p>ang<div>elf</div>ood</p>
</body>
</html>

Просмотреть файл

@ -100,6 +100,7 @@ webextPerms.description.browserSettings=Read and modify browser settings
webextPerms.description.clipboardRead=Get data from the clipboard
webextPerms.description.clipboardWrite=Input data to the clipboard
webextPerms.description.downloads=Download files and read and modify the browsers download history
webextPerms.description.find=Read the Web page text of all open tabs
webextPerms.description.geolocation=Access your location
webextPerms.description.history=Access browsing history
webextPerms.description.management=Monitor extension usage and manage themes

Просмотреть файл

@ -0,0 +1,248 @@
/* 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/. */
"use strict";
this.EXPORTED_SYMBOLS = ["FindContent"];
/* exported FindContent */
const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components;
class FindContent {
constructor(docShell) {
const {Finder} = Cu.import("resource://gre/modules/Finder.jsm", {});
this.finder = new Finder(docShell);
}
get iterator() {
if (!this._iterator) {
const {FinderIterator} = Cu.import("resource://gre/modules/FinderIterator.jsm", {});
this._iterator = Object.assign({}, FinderIterator);
// Native FinderIterator._collectFrames skips frames if they are scrolled out
// of viewport. Override with method that doesn't do that.
this._iterator._collectFrames = (window) => {
let frames = [];
if (!("frames" in window) || !window.frames.length) {
return frames;
}
for (let i = 0, l = window.frames.length; i < l; ++i) {
let frame = window.frames[i];
if (!frame || !frame.frameElement) {
continue;
}
frames.push(frame, ...this._iterator._collectFrames(frame));
}
return frames;
};
}
return this._iterator;
}
get highlighter() {
if (!this._highlighter) {
const {FinderHighlighter} = Cu.import("resource://gre/modules/FinderHighlighter.jsm", {});
this._highlighter = new FinderHighlighter(this.finder);
}
return this._highlighter;
}
/**
* findRanges
*
* Performs a search which will cache found ranges in `iterator._previousRanges`. Cached
* data can then be used by `highlightResults`, `_collectRectData` and `_serializeRangeData`.
*
* @param {object} params - the params.
* @param {string} queryphrase - the text to search for.
* @param {boolean} caseSensitive - whether to use case sensitive matches.
* @param {boolean} includeRangeData - whether to collect and return range data.
* @param {boolean} searchString - whether to collect and return rect data.
*
* @returns {object} 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.
*/
findRanges(params) {
return new Promise(resolve => {
let {queryphrase, caseSensitive, entireWord, includeRangeData, includeRectData} = params;
this.iterator.reset();
// Cast `caseSensitive` and `entireWord` to boolean, otherwise _iterator.start will throw.
let iteratorPromise = this.iterator.start({
word: queryphrase,
caseSensitive: !!caseSensitive,
entireWord: !!entireWord,
finder: this.finder,
listener: this.finder,
});
iteratorPromise.then(() => {
let rangeData;
let rectData;
if (includeRangeData) {
rangeData = this._serializeRangeData();
}
if (includeRectData) {
rectData = this._collectRectData();
}
resolve({count: this.iterator._previousRanges.length, rangeData, rectData});
});
});
}
/**
* _serializeRangeData
*
* Optionally returned by `findRanges`.
* Collects DOM data from ranges found on the most recent search made by `findRanges`
* and encodes it into a serializable form. Useful to extensions for custom UI presentation
* of search results, eg, getting surrounding context of results.
*
* @returns {array} - serializable range data.
*/
_serializeRangeData() {
let ranges = this.iterator._previousRanges;
let rangeData = [];
let nodeCountWin = 0;
let lastDoc;
let framePos = -1;
let walker;
let node;
for (let range of ranges) {
let startContainer = range.startContainer;
let doc = startContainer.ownerDocument;
if (lastDoc !== doc) {
walker = doc.createTreeWalker(doc, doc.defaultView.NodeFilter.SHOW_TEXT, null, false);
// Get first node.
node = walker.nextNode();
// Reset node count.
nodeCountWin = 0;
framePos++;
}
lastDoc = doc;
let data = {framePos, text: range.toString()};
rangeData.push(data);
if (node != range.startContainer) {
let node = walker.nextNode();
while (node) {
nodeCountWin++;
if (node == range.startContainer) {
break;
}
node = walker.nextNode();
}
}
data.startTextNodePos = nodeCountWin;
data.startOffset = range.startOffset;
if (range.startContainer != range.endContainer) {
let node = walker.nextNode();
while (node) {
nodeCountWin++;
if (node == range.endContainer) {
break;
}
node = walker.nextNode();
}
}
data.endTextNodePos = nodeCountWin;
data.endOffset = range.endOffset;
}
return rangeData;
}
/**
* _collectRectData
*
* Optionally returned by `findRanges`.
* Collects rect data of ranges found by most recent search made by `findRanges`.
* Useful to extensions for custom highlighting of search results.
*
* @returns {array} rectData - serializable rect data.
*/
_collectRectData() {
let rectData = [];
let ranges = this.iterator._previousRanges;
for (let range of ranges) {
let rectsAndTexts = this.highlighter._getRangeRectsAndTexts(range);
rectData.push({text: range.toString(), rectsAndTexts});
}
return rectData;
}
/**
* highlightResults
*
* Highlights range(s) found in previous browser.find.find.
*
* @param {object} params - may contain any of the following properties:
* all of which are optional:
* {number} rangeIndex -
* Found range to be highlighted held in API's ranges array for the tabId.
* Default highlights all ranges.
* {number} tabId - Tab to highlight. Defaults to the active tab.
* {boolean} noScroll - Don't scroll to highlighted item.
*
* @returns {string} - a string describing the resulting status of the highlighting,
* which will be used as criteria for resolving or rejecting the promise.
* This can be:
* "Success" - Highlighting succeeded.
* "OutOfRange" - The index supplied was out of range.
* "NoResults" - There were no search results to highlight.
*/
highlightResults(params) {
let {rangeIndex, noScroll} = params;
this.highlighter.highlight(false);
let ranges = this.iterator._previousRanges;
let status = "Success";
if (ranges.length) {
if (typeof rangeIndex == "number") {
if (rangeIndex < ranges.length) {
let foundRange = ranges[rangeIndex];
this.highlighter.highlightRange(foundRange);
if (!noScroll) {
let node = foundRange.startContainer;
let editableNode = this.highlighter._getEditableNode(node);
let controller = editableNode ? editableNode.editor.selectionController :
this.finder._getSelectionController(node.ownerGlobal);
controller.scrollSelectionIntoView(controller.SELECTION_FIND,
controller.SELECTION_ON,
controller.SCROLL_CENTER_VERTICALLY);
}
} else {
status = "OutOfRange";
}
} else {
for (let range of ranges) {
this.highlighter.highlightRange(range);
}
}
} else {
status = "NoResults";
}
return status;
}
}

Просмотреть файл

@ -21,6 +21,7 @@ EXTRA_JS_MODULES += [
'ExtensionStorage.jsm',
'ExtensionStorageSync.jsm',
'ExtensionUtils.jsm',
'FindContent.jsm',
'LegacyExtensionsUtils.jsm',
'MessageChannel.jsm',
'NativeMessaging.jsm',

Просмотреть файл

@ -4,6 +4,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* eslint-env mozilla/frame-script */
/* global sendAsyncMessage */
var Cc = Components.classes;
var Ci = Components.interfaces;
@ -19,6 +20,8 @@ XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils",
"resource://gre/modules/BrowserUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "SelectContentHelper",
"resource://gre/modules/SelectContentHelper.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FindContent",
"resource://gre/modules/FindContent.jsm");
var global = this;
@ -1847,3 +1850,38 @@ addEventListener("mozshowdropdown-sourcetouch", event => {
new SelectContentHelper(event.target, {isOpenedViaTouch: true}, this);
}
});
let ExtFind = {
init() {
addMessageListener("ext-Finder:CollectResults", this);
addMessageListener("ext-Finder:HighlightResults", this);
addMessageListener("ext-Finder:clearHighlighting", this);
},
_findContent: null,
async receiveMessage(message) {
if (!this._findContent) {
this._findContent = new FindContent(docShell);
}
let data;
switch (message.name) {
case "ext-Finder:CollectResults":
this.finderInited = true;
data = await this._findContent.findRanges(message.data);
sendAsyncMessage("ext-Finder:CollectResultsFinished", data);
break;
case "ext-Finder:HighlightResults":
data = this._findContent.highlightResults(message.data);
sendAsyncMessage("ext-Finder:HighlightResultsFinished", data);
break;
case "ext-Finder:clearHighlighting":
this._findContent.highlighter.highlight(false);
break;
}
},
}
ExtFind.init();