Source code
Revision control
Copy as Markdown
Other Tools
/* 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
import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
NLP: "resource://gre/modules/NLP.sys.mjs",
Rect: "resource://gre/modules/Geometry.sys.mjs",
});
const kDebug = false;
const kIterationSizeMax = 100;
const kTimeoutPref = "findbar.iteratorTimeout";
/**
* FinderIterator. See the documentation for the `start()` method to
* learn more.
*/
export class FinderIterator {
constructor() {
this._listeners = new Map();
this._currentParams = null;
this._catchingUp = new Set();
this._previousParams = null;
this._previousRanges = [];
this._spawnId = 0;
this._timer = null;
this.ranges = [];
this.running = false;
this.useSubFrames = false;
}
_timeout = Services.prefs.getIntPref(kTimeoutPref);
// Expose `kIterationSizeMax` to the outside world for unit tests to use.
get kIterationSizeMax() {
return kIterationSizeMax;
}
get params() {
if (!this._currentParams && !this._previousParams) {
return null;
}
return Object.assign({}, this._currentParams || this._previousParams);
}
/**
* Start iterating the active Finder docShell, using the options below. When
* it already started at the request of another consumer, we first yield the
* results we already collected before continuing onward to yield fresh results.
* We make sure to pause every `kIterationSizeMax` iterations to make sure we
* don't block the host process too long. In the case of a break like this, we
* yield `undefined`, instead of a range.
* Upon re-entrance after a break, we check if `stop()` was called during the
* break and if so, we stop iterating.
* Results are also passed to the `listener.onIteratorRangeFound` callback
* method, along with a flag that specifies if the result comes from the cache
* or is fresh. The callback also adheres to the `limit` flag.
* The returned promise is resolved when 1) the limit is reached, 2) when all
* the ranges have been found or 3) when `stop()` is called whilst iterating.
*
* @param {Number} [options.allowDistance] Allowed edit distance between the
* current word and `options.word`
* when the iterator is already running
* @param {Boolean} options.caseSensitive Whether to search in case sensitive
* mode
* @param {Boolean} options.entireWord Whether to search in entire-word mode
* @param {Finder} options.finder Currently active Finder instance
* @param {Number} [options.limit] Limit the amount of results to be
* passed back. Optional, defaults to no
* limit.
* @param {Boolean} [options.linksOnly] Only yield ranges that are inside a
* hyperlink (used by QuickFind).
* Optional, defaults to `false`.
* @param {Object} options.listener Listener object that implements the
* following callback functions:
* - onIteratorRangeFound({Range} range);
* - onIteratorReset();
* - onIteratorRestart({Object} iterParams);
* - onIteratorStart({Object} iterParams);
* @param {Boolean} options.matchDiacritics Whether to search in
* diacritic-matching mode
* @param {Boolean} [options.useCache] Whether to allow results already
* present in the cache or demand fresh.
* Optional, defaults to `false`.
* @param {Boolean} [options.useSubFrames] Whether to iterate over subframes.
* Optional, defaults to `false`.
* @param {String} options.word Word to search for
* @return {Promise}
*/
start({
allowDistance,
caseSensitive,
entireWord,
finder,
limit,
linksOnly,
listener,
matchDiacritics,
useCache,
word,
useSubFrames,
}) {
// Take care of default values for non-required options.
if (typeof allowDistance != "number") {
allowDistance = 0;
}
if (typeof limit != "number") {
limit = -1;
}
if (typeof linksOnly != "boolean") {
linksOnly = false;
}
if (typeof useCache != "boolean") {
useCache = false;
}
if (typeof useSubFrames != "boolean") {
useSubFrames = false;
}
// Validate the options.
if (typeof caseSensitive != "boolean") {
throw new Error("Missing required option 'caseSensitive'");
}
if (typeof entireWord != "boolean") {
throw new Error("Missing required option 'entireWord'");
}
if (typeof matchDiacritics != "boolean") {
throw new Error("Missing required option 'matchDiacritics'");
}
if (!finder) {
throw new Error("Missing required option 'finder'");
}
if (!word) {
throw new Error("Missing required option 'word'");
}
if (typeof listener != "object" || !listener.onIteratorRangeFound) {
throw new TypeError("Missing valid, required option 'listener'");
}
// If the listener was added before, make sure the promise is resolved before
// we replace it with another.
if (this._listeners.has(listener)) {
let { onEnd } = this._listeners.get(listener);
if (onEnd) {
onEnd();
}
}
let window = finder._getWindow();
let resolver;
let promise = new Promise(resolve => (resolver = resolve));
let iterParams = {
caseSensitive,
entireWord,
linksOnly,
matchDiacritics,
useCache,
window,
word,
useSubFrames,
};
this._listeners.set(listener, { limit, onEnd: resolver });
// If we're not running anymore and we're requesting the previous result, use it.
if (!this.running && this._previousResultAvailable(iterParams)) {
this._yieldPreviousResult(listener, window);
return promise;
}
if (this.running) {
// Double-check if we're not running the iterator with a different set of
// parameters, otherwise report an error with the most common reason.
if (
!this._areParamsEqual(this._currentParams, iterParams, allowDistance)
) {
if (kDebug) {
console.error(
`We're currently iterating over '${this._currentParams.word}', not '${word}'\n` +
new Error().stack
);
}
this._listeners.delete(listener);
resolver();
return promise;
}
// if we're still running, yield the set we have built up this far.
this._yieldIntermediateResult(listener, window);
return promise;
}
// Start!
this.running = true;
this._currentParams = iterParams;
this._findAllRanges(finder, ++this._spawnId);
return promise;
}
/**
* Stop the currently running iterator as soon as possible and optionally cache
* the result for later.
*
* @param {Boolean} [cachePrevious] Whether to save the result for later.
* Optional.
*/
stop(cachePrevious = false) {
if (!this.running) {
return;
}
if (this._timer) {
clearTimeout(this._timer);
this._timer = null;
}
if (this._runningFindResolver) {
this._runningFindResolver();
this._runningFindResolver = null;
}
if (cachePrevious) {
this._previousRanges = [].concat(this.ranges);
this._previousParams = Object.assign({}, this._currentParams);
} else {
this._previousRanges = [];
this._previousParams = null;
}
this._catchingUp.clear();
this._currentParams = null;
this.ranges = [];
this.running = false;
for (let [, { onEnd }] of this._listeners) {
onEnd();
}
}
/**
* Stops the iteration that currently running, if it is, and start a new one
* with the exact same params as before.
*
* @param {Finder} finder Currently active Finder instance
*/
restart(finder) {
// Capture current iterator params before we stop the show.
let iterParams = this.params;
if (!iterParams) {
return;
}
this.stop();
// Restart manually.
this.running = true;
this._currentParams = iterParams;
this._findAllRanges(finder, ++this._spawnId);
this._notifyListeners("restart", iterParams);
}
/**
* Reset the internal state of the iterator. Typically this would be called
* when the docShell is not active anymore, which makes the current and cached
* previous result invalid.
* If the iterator is running, it will be stopped as soon as possible.
*/
reset() {
if (this._timer) {
clearTimeout(this._timer);
this._timer = null;
}
if (this._runningFindResolver) {
this._runningFindResolver();
this._runningFindResolver = null;
}
this._catchingUp.clear();
this._currentParams = this._previousParams = null;
this._previousRanges = [];
this.ranges = [];
this.running = false;
this._notifyListeners("reset");
for (let [, { onEnd }] of this._listeners) {
onEnd();
}
this._listeners.clear();
}
/**
* Check if the currently running iterator parameters are the same as the ones
* passed through the arguments. When `true`, we can keep it running as-is and
* the consumer should stop the iterator when `false`.
*
* @param {Boolean} options.caseSensitive Whether to search in case sensitive
* mode
* @param {Boolean} options.entireWord Whether to search in entire-word mode
* @param {Boolean} options.linksOnly Whether to search for the word to be
* present in links only
* @param {Boolean} options.matchDiacritics Whether to search in
* diacritic-matching mode
* @param {String} options.word The word being searched for
* @param (Boolean) options.useSubFrames Whether to search subframes
* @return {Boolean}
*/
continueRunning({
caseSensitive,
entireWord,
linksOnly,
matchDiacritics,
word,
useSubFrames,
}) {
return (
this.running &&
this._currentParams.caseSensitive === caseSensitive &&
this._currentParams.entireWord === entireWord &&
this._currentParams.linksOnly === linksOnly &&
this._currentParams.matchDiacritics === matchDiacritics &&
this._currentParams.word == word &&
this._currentParams.useSubFrames == useSubFrames
);
}
/**
* The default mode of operation of the iterator is to not accept duplicate
* listeners, resolve the promise of the older listeners and replace it with
* the new listener.
* Consumers may opt-out of this behavior by using this check and not call
* start().
*
* @param {Object} paramSet Property bag with the same signature as you would
* pass into `start()`
* @return {Boolean}
*/
isAlreadyRunning(paramSet) {
return (
this.running &&
this._areParamsEqual(this._currentParams, paramSet) &&
this._listeners.has(paramSet.listener)
);
}
/**
* Safely notify all registered listeners that an event has occurred.
*
* @param {String} callback Name of the callback to invoke
* @param {mixed} [params] Optional argument that will be passed to the
* callback
* @param {Iterable} [listeners] Set of listeners to notify. Optional, defaults
* to `this._listeners.keys()`.
*/
_notifyListeners(callback, params, listeners = this._listeners.keys()) {
callback =
"onIterator" + callback.charAt(0).toUpperCase() + callback.substr(1);
for (let listener of listeners) {
try {
listener[callback](params);
} catch (ex) {
console.error("FinderIterator Error: ", ex);
}
}
}
/**
* Internal; check if an iteration request is available in the previous result
* that we cached.
*
* @param {Boolean} options.caseSensitive Whether to search in case sensitive
* mode
* @param {Boolean} options.entireWord Whether to search in entire-word mode
* @param {Boolean} options.linksOnly Whether to search for the word to be
* present in links only
* @param {Boolean} options.matchDiacritics Whether to search in
* diacritic-matching mode
* @param {Boolean} options.useCache Whether the consumer wants to use the
* cached previous result at all
* @param {String} options.word The word being searched for
* @return {Boolean}
*/
_previousResultAvailable({
caseSensitive,
entireWord,
linksOnly,
matchDiacritics,
useCache,
word,
}) {
return !!(
useCache &&
this._areParamsEqual(this._previousParams, {
caseSensitive,
entireWord,
linksOnly,
matchDiacritics,
word,
}) &&
this._previousRanges.length
);
}
/**
* Internal; compare if two sets of iterator parameters are equivalent.
*
* @param {Object} paramSet1 First set of params (left hand side)
* @param {Object} paramSet2 Second set of params (right hand side)
* @param {Number} [allowDistance] Allowed edit distance between the two words.
* Optional, defaults to '0', which means 'no
* distance'.
* @return {Boolean}
*/
_areParamsEqual(paramSet1, paramSet2, allowDistance = 0) {
return (
!!paramSet1 &&
!!paramSet2 &&
paramSet1.caseSensitive === paramSet2.caseSensitive &&
paramSet1.entireWord === paramSet2.entireWord &&
paramSet1.linksOnly === paramSet2.linksOnly &&
paramSet1.matchDiacritics === paramSet2.matchDiacritics &&
paramSet1.window === paramSet2.window &&
paramSet1.useSubFrames === paramSet2.useSubFrames &&
lazy.NLP.levenshtein(paramSet1.word, paramSet2.word) <= allowDistance
);
}
/**
* Internal; iterate over a predefined set of ranges that have been collected
* before.
* Also here, we make sure to pause every `kIterationSizeMax` iterations to
* make sure we don't block the host process too long. In the case of a break
* like this, we yield `undefined`, instead of a range.
*
* @param {Object} listener Listener object
* @param {Array} rangeSource Set of ranges to iterate over
* @param {nsIDOMWindow} window The window object is only really used
* for access to `setTimeout`
* @param {Boolean} [withPause] Whether to pause after each `kIterationSizeMax`
* number of ranges yielded. Optional, defaults
* to `true`.
* @yield {Range}
*/
async _yieldResult(listener, rangeSource, window, withPause = true) {
// We keep track of the number of iterations to allow a short pause between
// every `kIterationSizeMax` number of iterations.
let iterCount = 0;
let { limit, onEnd } = this._listeners.get(listener);
let ranges = rangeSource.slice(0, limit > -1 ? limit : undefined);
for (let range of ranges) {
try {
range.startContainer;
} catch (ex) {
// Don't yield dead objects, so use the escape hatch.
if (ex.message.includes("dead object")) {
return;
}
}
// Pass a flag that is `true` when we're returning the result from a
// cached previous iteration.
listener.onIteratorRangeFound(range, !this.running);
await range;
if (withPause && ++iterCount >= kIterationSizeMax) {
iterCount = 0;
// Make sure to save the current limit for later.
this._listeners.set(listener, { limit, onEnd });
// Sleep for the rest of this cycle.
await new Promise(resolve => window.setTimeout(resolve, 0));
// After a sleep, the set of ranges may have updated.
ranges = rangeSource.slice(0, limit > -1 ? limit : undefined);
}
if (limit !== -1 && --limit === 0) {
// We've reached our limit; no need to do more work.
this._listeners.delete(listener);
onEnd();
return;
}
}
// Save the updated limit globally.
this._listeners.set(listener, { limit, onEnd });
}
/**
* Internal; iterate over the set of previously found ranges. Meanwhile it'll
* mark the listener as 'catching up', meaning it will not receive fresh
* results from a running iterator.
*
* @param {Object} listener Listener object
* @param {nsIDOMWindow} window The window object is only really used
* for access to `setTimeout`
* @yield {Range}
*/
async _yieldPreviousResult(listener, window) {
this._notifyListeners("start", this.params, [listener]);
this._catchingUp.add(listener);
await this._yieldResult(listener, this._previousRanges, window);
this._catchingUp.delete(listener);
let { onEnd } = this._listeners.get(listener);
if (onEnd) {
onEnd();
}
}
/**
* Internal; iterate over the set of already found ranges. Meanwhile it'll
* mark the listener as 'catching up', meaning it will not receive fresh
* results from the running iterator.
*
* @param {Object} listener Listener object
* @param {nsIDOMWindow} window The window object is only really used
* for access to `setTimeout`
* @yield {Range}
*/
async _yieldIntermediateResult(listener, window) {
this._notifyListeners("start", this.params, [listener]);
this._catchingUp.add(listener);
await this._yieldResult(listener, this.ranges, window, false);
this._catchingUp.delete(listener);
}
/**
* Internal; see the documentation of the start() method above.
*
* @param {Finder} finder Currently active Finder instance
* @param {Number} spawnId Since `stop()` is synchronous and this method
* is not, this identifier is used to learn if
* it's supposed to still continue after a pause.
* @yield {Range}
*/
async _findAllRanges(finder, spawnId) {
if (this._timeout) {
if (this._timer) {
clearTimeout(this._timer);
}
if (this._runningFindResolver) {
this._runningFindResolver();
}
let timeout = this._timeout;
let searchTerm = this._currentParams.word;
// Wait a little longer when the first or second character is typed into
// the findbar.
if (searchTerm.length == 1) {
timeout *= 4;
} else if (searchTerm.length == 2) {
timeout *= 2;
}
await new Promise(resolve => {
this._runningFindResolver = resolve;
this._timer = setTimeout(resolve, timeout);
});
this._timer = this._runningFindResolver = null;
// During the timeout, we could have gotten the signal to stop iterating.
// Make sure we do here.
if (!this.running || spawnId !== this._spawnId) {
return;
}
}
this._notifyListeners("start", this.params);
let { linksOnly, useSubFrames, window } = this._currentParams;
// First we collect all frames we need to search through, whilst making sure
// that the parent window gets dibs.
let frames = [window];
if (useSubFrames) {
frames.push(...this._collectFrames(window, finder));
}
let iterCount = 0;
for (let frame of frames) {
for (let range of this._iterateDocument(this._currentParams, frame)) {
// Between iterations, for example after a sleep of one cycle, we could
// have gotten the signal to stop iterating. Make sure we do here.
if (!this.running || spawnId !== this._spawnId) {
return;
}
// Deal with links-only mode here.
if (linksOnly && !this._rangeStartsInLink(range)) {
continue;
}
this.ranges.push(range);
// Call each listener with the range we just found.
for (let [listener, { limit, onEnd }] of this._listeners) {
if (this._catchingUp.has(listener)) {
continue;
}
listener.onIteratorRangeFound(range);
if (limit !== -1 && --limit === 0) {
// We've reached our limit; no need to do more work for this listener.
this._listeners.delete(listener);
onEnd();
continue;
}
// Save the updated limit globally.
this._listeners.set(listener, { limit, onEnd });
}
await range;
if (++iterCount >= kIterationSizeMax) {
iterCount = 0;
// Sleep for the rest of this cycle.
await new Promise(resolve => window.setTimeout(resolve, 0));
}
}
}
// When the iterating has finished, make sure we reset and save the state
// properly.
this.stop(true);
}
/**
* Internal; basic wrapper around nsIFind that provides a generator yielding
* a range each time an occurence of `word` string is found.
*
* @param {Boolean} options.caseSensitive Whether to search in case
* sensitive mode
* @param {Boolean} options.entireWord Whether to search in entire-word
* mode
* @param {Boolean} options.matchDiacritics Whether to search in
* diacritic-matching mode
* @param {String} options.word The word to search for
* @param {nsIDOMWindow} window The window to search in
* @yield {Range}
*/
*_iterateDocument(
{ caseSensitive, entireWord, matchDiacritics, word },
window
) {
let doc = window.document;
let body = doc.body || doc.documentElement;
if (!body) {
return;
}
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 nsIFind = Cc["@mozilla.org/embedcomp/rangefind;1"]
.createInstance()
.QueryInterface(Ci.nsIFind);
nsIFind.caseSensitive = caseSensitive;
nsIFind.entireWord = entireWord;
nsIFind.matchDiacritics = matchDiacritics;
while ((retRange = nsIFind.Find(word, searchRange, startPt, endPt))) {
yield retRange;
startPt = retRange.cloneRange();
startPt.collapse(false);
}
}
/**
* Internal; helper method for the iterator that recursively collects all
* visible (i)frames inside a window.
*
* @param {nsIDOMWindow} window The window to extract the (i)frames from
* @param {Finder} finder The Finder instance
* @return {Array} Stack of frames to iterate over
*/
_collectFrames(window, finder) {
let frames = [];
if (!("frames" in window) || !window.frames.length) {
return frames;
}
// Casting `window.frames` to an Iterator doesn't work, so we're stuck with
// a plain, old for-loop.
let dwu = window.windowUtils;
for (let i = 0, l = window.frames.length; i < l; ++i) {
let frame = window.frames[i];
// Don't count matches in hidden frames; get the frame element rect and
// check if it's empty. We shan't flush!
let frameEl = frame && frame.frameElement;
if (
!frameEl ||
lazy.Rect.fromRect(dwu.getBoundsWithoutFlushing(frameEl)).isEmpty()
) {
continue;
}
// All conditions pass, so push the current frame and its children on the
// stack.
frames.push(frame, ...this._collectFrames(frame, finder));
}
return frames;
}
/**
* Internal; helper method to extract the docShell reference from a Window or
* Range object.
*
* @param {Range} windowOrRange Window object to query. May also be a
* Range, from which the owner window will
* be queried.
* @return {nsIDocShell}
*/
_getDocShell(windowOrRange) {
let window = windowOrRange;
// Ranges may also be passed in, so fetch its window.
if (ChromeUtils.getClassName(windowOrRange) === "Range") {
window = windowOrRange.startContainer.ownerGlobal;
}
return window.docShell;
}
/**
* Internal; determines whether a range is inside a link.
*
* @param {Range} range the range to check
* @return {Boolean} True if the range starts in a link
*/
_rangeStartsInLink(range) {
let isInsideLink = false;
let node = range.startContainer;
if (node.nodeType == node.ELEMENT_NODE) {
if (node.hasChildNodes) {
let childNode = node.item(range.startOffset);
if (childNode) {
node = childNode;
}
}
}
const HTMLAnchorElement = (node.ownerDocument || node).defaultView
.HTMLAnchorElement;
do {
if (HTMLAnchorElement.isInstance(node)) {
isInsideLink = node.hasAttribute("href");
break;
} else if (
typeof node.hasAttributeNS == "function" &&
node.hasAttributeNS(XLink_NS, "href")
) {
isInsideLink = node.getAttributeNS(XLink_NS, "type") == "simple";
break;
}
node = node.parentNode;
} while (node);
return isInsideLink;
}
}