/* 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/. */ 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 XLink_NS = "http://www.w3.org/1999/xlink"; 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; } }