1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
9 ChromeUtils.defineESModuleGetters(lazy, {
10 NLP: "resource://gre/modules/NLP.sys.mjs",
11 Rect: "resource://gre/modules/Geometry.sys.mjs",
15 const kIterationSizeMax = 100;
16 const kTimeoutPref = "findbar.iteratorTimeout";
19 * FinderIterator. See the documentation for the `start()` method to
22 export class FinderIterator {
24 this._listeners = new Map();
25 this._currentParams = null;
26 this._catchingUp = new Set();
27 this._previousParams = null;
28 this._previousRanges = [];
33 this.useSubFrames = false;
36 _timeout = Services.prefs.getIntPref(kTimeoutPref);
38 // Expose `kIterationSizeMax` to the outside world for unit tests to use.
39 get kIterationSizeMax() {
40 return kIterationSizeMax;
44 if (!this._currentParams && !this._previousParams) {
47 return Object.assign({}, this._currentParams || this._previousParams);
51 * Start iterating the active Finder docShell, using the options below. When
52 * it already started at the request of another consumer, we first yield the
53 * results we already collected before continuing onward to yield fresh results.
54 * We make sure to pause every `kIterationSizeMax` iterations to make sure we
55 * don't block the host process too long. In the case of a break like this, we
56 * yield `undefined`, instead of a range.
57 * Upon re-entrance after a break, we check if `stop()` was called during the
58 * break and if so, we stop iterating.
59 * Results are also passed to the `listener.onIteratorRangeFound` callback
60 * method, along with a flag that specifies if the result comes from the cache
61 * or is fresh. The callback also adheres to the `limit` flag.
62 * The returned promise is resolved when 1) the limit is reached, 2) when all
63 * the ranges have been found or 3) when `stop()` is called whilst iterating.
65 * @param {Number} [options.allowDistance] Allowed edit distance between the
66 * current word and `options.word`
67 * when the iterator is already running
68 * @param {Boolean} options.caseSensitive Whether to search in case sensitive
70 * @param {Boolean} options.entireWord Whether to search in entire-word mode
71 * @param {Finder} options.finder Currently active Finder instance
72 * @param {Number} [options.limit] Limit the amount of results to be
73 * passed back. Optional, defaults to no
75 * @param {Boolean} [options.linksOnly] Only yield ranges that are inside a
76 * hyperlink (used by QuickFind).
77 * Optional, defaults to `false`.
78 * @param {Object} options.listener Listener object that implements the
79 * following callback functions:
80 * - onIteratorRangeFound({Range} range);
81 * - onIteratorReset();
82 * - onIteratorRestart({Object} iterParams);
83 * - onIteratorStart({Object} iterParams);
84 * @param {Boolean} options.matchDiacritics Whether to search in
85 * diacritic-matching mode
86 * @param {Boolean} [options.useCache] Whether to allow results already
87 * present in the cache or demand fresh.
88 * Optional, defaults to `false`.
89 * @param {Boolean} [options.useSubFrames] Whether to iterate over subframes.
90 * Optional, defaults to `false`.
91 * @param {String} options.word Word to search for
107 // Take care of default values for non-required options.
108 if (typeof allowDistance != "number") {
111 if (typeof limit != "number") {
114 if (typeof linksOnly != "boolean") {
117 if (typeof useCache != "boolean") {
120 if (typeof useSubFrames != "boolean") {
121 useSubFrames = false;
124 // Validate the options.
125 if (typeof caseSensitive != "boolean") {
126 throw new Error("Missing required option 'caseSensitive'");
128 if (typeof entireWord != "boolean") {
129 throw new Error("Missing required option 'entireWord'");
131 if (typeof matchDiacritics != "boolean") {
132 throw new Error("Missing required option 'matchDiacritics'");
135 throw new Error("Missing required option 'finder'");
138 throw new Error("Missing required option 'word'");
140 if (typeof listener != "object" || !listener.onIteratorRangeFound) {
141 throw new TypeError("Missing valid, required option 'listener'");
144 // If the listener was added before, make sure the promise is resolved before
145 // we replace it with another.
146 if (this._listeners.has(listener)) {
147 let { onEnd } = this._listeners.get(listener);
153 let window = finder._getWindow();
155 let promise = new Promise(resolve => (resolver = resolve));
167 this._listeners.set(listener, { limit, onEnd: resolver });
169 // If we're not running anymore and we're requesting the previous result, use it.
170 if (!this.running && this._previousResultAvailable(iterParams)) {
171 this._yieldPreviousResult(listener, window);
176 // Double-check if we're not running the iterator with a different set of
177 // parameters, otherwise report an error with the most common reason.
179 !this._areParamsEqual(this._currentParams, iterParams, allowDistance)
183 `We're currently iterating over '${this._currentParams.word}', not '${word}'\n` +
187 this._listeners.delete(listener);
192 // if we're still running, yield the set we have built up this far.
193 this._yieldIntermediateResult(listener, window);
200 this._currentParams = iterParams;
201 this._findAllRanges(finder, ++this._spawnId);
207 * Stop the currently running iterator as soon as possible and optionally cache
208 * the result for later.
210 * @param {Boolean} [cachePrevious] Whether to save the result for later.
213 stop(cachePrevious = false) {
219 clearTimeout(this._timer);
222 if (this._runningFindResolver) {
223 this._runningFindResolver();
224 this._runningFindResolver = null;
228 this._previousRanges = [].concat(this.ranges);
229 this._previousParams = Object.assign({}, this._currentParams);
231 this._previousRanges = [];
232 this._previousParams = null;
235 this._catchingUp.clear();
236 this._currentParams = null;
238 this.running = false;
240 for (let [, { onEnd }] of this._listeners) {
246 * Stops the iteration that currently running, if it is, and start a new one
247 * with the exact same params as before.
249 * @param {Finder} finder Currently active Finder instance
252 // Capture current iterator params before we stop the show.
253 let iterParams = this.params;
261 this._currentParams = iterParams;
263 this._findAllRanges(finder, ++this._spawnId);
264 this._notifyListeners("restart", iterParams);
268 * Reset the internal state of the iterator. Typically this would be called
269 * when the docShell is not active anymore, which makes the current and cached
270 * previous result invalid.
271 * If the iterator is running, it will be stopped as soon as possible.
275 clearTimeout(this._timer);
278 if (this._runningFindResolver) {
279 this._runningFindResolver();
280 this._runningFindResolver = null;
283 this._catchingUp.clear();
284 this._currentParams = this._previousParams = null;
285 this._previousRanges = [];
287 this.running = false;
289 this._notifyListeners("reset");
290 for (let [, { onEnd }] of this._listeners) {
293 this._listeners.clear();
297 * Check if the currently running iterator parameters are the same as the ones
298 * passed through the arguments. When `true`, we can keep it running as-is and
299 * the consumer should stop the iterator when `false`.
301 * @param {Boolean} options.caseSensitive Whether to search in case sensitive
303 * @param {Boolean} options.entireWord Whether to search in entire-word mode
304 * @param {Boolean} options.linksOnly Whether to search for the word to be
305 * present in links only
306 * @param {Boolean} options.matchDiacritics Whether to search in
307 * diacritic-matching mode
308 * @param {String} options.word The word being searched for
309 * @param (Boolean) options.useSubFrames Whether to search subframes
322 this._currentParams.caseSensitive === caseSensitive &&
323 this._currentParams.entireWord === entireWord &&
324 this._currentParams.linksOnly === linksOnly &&
325 this._currentParams.matchDiacritics === matchDiacritics &&
326 this._currentParams.word == word &&
327 this._currentParams.useSubFrames == useSubFrames
332 * The default mode of operation of the iterator is to not accept duplicate
333 * listeners, resolve the promise of the older listeners and replace it with
335 * Consumers may opt-out of this behavior by using this check and not call
338 * @param {Object} paramSet Property bag with the same signature as you would
339 * pass into `start()`
342 isAlreadyRunning(paramSet) {
345 this._areParamsEqual(this._currentParams, paramSet) &&
346 this._listeners.has(paramSet.listener)
351 * Safely notify all registered listeners that an event has occurred.
353 * @param {String} callback Name of the callback to invoke
354 * @param {mixed} [params] Optional argument that will be passed to the
356 * @param {Iterable} [listeners] Set of listeners to notify. Optional, defaults
357 * to `this._listeners.keys()`.
359 _notifyListeners(callback, params, listeners = this._listeners.keys()) {
361 "onIterator" + callback.charAt(0).toUpperCase() + callback.substr(1);
362 for (let listener of listeners) {
364 listener[callback](params);
366 console.error("FinderIterator Error: ", ex);
372 * Internal; check if an iteration request is available in the previous result
375 * @param {Boolean} options.caseSensitive Whether to search in case sensitive
377 * @param {Boolean} options.entireWord Whether to search in entire-word mode
378 * @param {Boolean} options.linksOnly Whether to search for the word to be
379 * present in links only
380 * @param {Boolean} options.matchDiacritics Whether to search in
381 * diacritic-matching mode
382 * @param {Boolean} options.useCache Whether the consumer wants to use the
383 * cached previous result at all
384 * @param {String} options.word The word being searched for
387 _previousResultAvailable({
397 this._areParamsEqual(this._previousParams, {
404 this._previousRanges.length
409 * Internal; compare if two sets of iterator parameters are equivalent.
411 * @param {Object} paramSet1 First set of params (left hand side)
412 * @param {Object} paramSet2 Second set of params (right hand side)
413 * @param {Number} [allowDistance] Allowed edit distance between the two words.
414 * Optional, defaults to '0', which means 'no
418 _areParamsEqual(paramSet1, paramSet2, allowDistance = 0) {
422 paramSet1.caseSensitive === paramSet2.caseSensitive &&
423 paramSet1.entireWord === paramSet2.entireWord &&
424 paramSet1.linksOnly === paramSet2.linksOnly &&
425 paramSet1.matchDiacritics === paramSet2.matchDiacritics &&
426 paramSet1.window === paramSet2.window &&
427 paramSet1.useSubFrames === paramSet2.useSubFrames &&
428 lazy.NLP.levenshtein(paramSet1.word, paramSet2.word) <= allowDistance
433 * Internal; iterate over a predefined set of ranges that have been collected
435 * Also here, we make sure to pause every `kIterationSizeMax` iterations to
436 * make sure we don't block the host process too long. In the case of a break
437 * like this, we yield `undefined`, instead of a range.
439 * @param {Object} listener Listener object
440 * @param {Array} rangeSource Set of ranges to iterate over
441 * @param {nsIDOMWindow} window The window object is only really used
442 * for access to `setTimeout`
443 * @param {Boolean} [withPause] Whether to pause after each `kIterationSizeMax`
444 * number of ranges yielded. Optional, defaults
448 async _yieldResult(listener, rangeSource, window, withPause = true) {
449 // We keep track of the number of iterations to allow a short pause between
450 // every `kIterationSizeMax` number of iterations.
452 let { limit, onEnd } = this._listeners.get(listener);
453 let ranges = rangeSource.slice(0, limit > -1 ? limit : undefined);
454 for (let range of ranges) {
456 range.startContainer;
458 // Don't yield dead objects, so use the escape hatch.
459 if (ex.message.includes("dead object")) {
464 // Pass a flag that is `true` when we're returning the result from a
465 // cached previous iteration.
466 listener.onIteratorRangeFound(range, !this.running);
469 if (withPause && ++iterCount >= kIterationSizeMax) {
471 // Make sure to save the current limit for later.
472 this._listeners.set(listener, { limit, onEnd });
473 // Sleep for the rest of this cycle.
474 await new Promise(resolve => window.setTimeout(resolve, 0));
475 // After a sleep, the set of ranges may have updated.
476 ranges = rangeSource.slice(0, limit > -1 ? limit : undefined);
479 if (limit !== -1 && --limit === 0) {
480 // We've reached our limit; no need to do more work.
481 this._listeners.delete(listener);
487 // Save the updated limit globally.
488 this._listeners.set(listener, { limit, onEnd });
492 * Internal; iterate over the set of previously found ranges. Meanwhile it'll
493 * mark the listener as 'catching up', meaning it will not receive fresh
494 * results from a running iterator.
496 * @param {Object} listener Listener object
497 * @param {nsIDOMWindow} window The window object is only really used
498 * for access to `setTimeout`
501 async _yieldPreviousResult(listener, window) {
502 this._notifyListeners("start", this.params, [listener]);
503 this._catchingUp.add(listener);
504 await this._yieldResult(listener, this._previousRanges, window);
505 this._catchingUp.delete(listener);
506 let { onEnd } = this._listeners.get(listener);
513 * Internal; iterate over the set of already found ranges. Meanwhile it'll
514 * mark the listener as 'catching up', meaning it will not receive fresh
515 * results from the running iterator.
517 * @param {Object} listener Listener object
518 * @param {nsIDOMWindow} window The window object is only really used
519 * for access to `setTimeout`
522 async _yieldIntermediateResult(listener, window) {
523 this._notifyListeners("start", this.params, [listener]);
524 this._catchingUp.add(listener);
525 await this._yieldResult(listener, this.ranges, window, false);
526 this._catchingUp.delete(listener);
530 * Internal; see the documentation of the start() method above.
532 * @param {Finder} finder Currently active Finder instance
533 * @param {Number} spawnId Since `stop()` is synchronous and this method
534 * is not, this identifier is used to learn if
535 * it's supposed to still continue after a pause.
538 async _findAllRanges(finder, spawnId) {
541 clearTimeout(this._timer);
543 if (this._runningFindResolver) {
544 this._runningFindResolver();
547 let timeout = this._timeout;
548 let searchTerm = this._currentParams.word;
549 // Wait a little longer when the first or second character is typed into
551 if (searchTerm.length == 1) {
553 } else if (searchTerm.length == 2) {
556 await new Promise(resolve => {
557 this._runningFindResolver = resolve;
558 this._timer = setTimeout(resolve, timeout);
560 this._timer = this._runningFindResolver = null;
561 // During the timeout, we could have gotten the signal to stop iterating.
562 // Make sure we do here.
563 if (!this.running || spawnId !== this._spawnId) {
568 this._notifyListeners("start", this.params);
570 let { linksOnly, useSubFrames, window } = this._currentParams;
571 // First we collect all frames we need to search through, whilst making sure
572 // that the parent window gets dibs.
573 let frames = [window];
575 frames.push(...this._collectFrames(window, finder));
578 for (let frame of frames) {
579 for (let range of this._iterateDocument(this._currentParams, frame)) {
580 // Between iterations, for example after a sleep of one cycle, we could
581 // have gotten the signal to stop iterating. Make sure we do here.
582 if (!this.running || spawnId !== this._spawnId) {
586 // Deal with links-only mode here.
587 if (linksOnly && !this._rangeStartsInLink(range)) {
591 this.ranges.push(range);
593 // Call each listener with the range we just found.
594 for (let [listener, { limit, onEnd }] of this._listeners) {
595 if (this._catchingUp.has(listener)) {
599 listener.onIteratorRangeFound(range);
601 if (limit !== -1 && --limit === 0) {
602 // We've reached our limit; no need to do more work for this listener.
603 this._listeners.delete(listener);
608 // Save the updated limit globally.
609 this._listeners.set(listener, { limit, onEnd });
614 if (++iterCount >= kIterationSizeMax) {
616 // Sleep for the rest of this cycle.
617 await new Promise(resolve => window.setTimeout(resolve, 0));
622 // When the iterating has finished, make sure we reset and save the state
628 * Internal; basic wrapper around nsIFind that provides a generator yielding
629 * a range each time an occurence of `word` string is found.
631 * @param {Boolean} options.caseSensitive Whether to search in case
633 * @param {Boolean} options.entireWord Whether to search in entire-word
635 * @param {Boolean} options.matchDiacritics Whether to search in
636 * diacritic-matching mode
637 * @param {String} options.word The word to search for
638 * @param {nsIDOMWindow} window The window to search in
642 { caseSensitive, entireWord, matchDiacritics, word },
645 let doc = window.document;
646 let body = doc.body || doc.documentElement;
652 let searchRange = doc.createRange();
653 searchRange.selectNodeContents(body);
655 let startPt = searchRange.cloneRange();
656 startPt.collapse(true);
658 let endPt = searchRange.cloneRange();
659 endPt.collapse(false);
663 let nsIFind = Cc["@mozilla.org/embedcomp/rangefind;1"]
665 .QueryInterface(Ci.nsIFind);
666 nsIFind.caseSensitive = caseSensitive;
667 nsIFind.entireWord = entireWord;
668 nsIFind.matchDiacritics = matchDiacritics;
670 while ((retRange = nsIFind.Find(word, searchRange, startPt, endPt))) {
672 startPt = retRange.cloneRange();
673 startPt.collapse(false);
678 * Internal; helper method for the iterator that recursively collects all
679 * visible (i)frames inside a window.
681 * @param {nsIDOMWindow} window The window to extract the (i)frames from
682 * @param {Finder} finder The Finder instance
683 * @return {Array} Stack of frames to iterate over
685 _collectFrames(window, finder) {
687 if (!("frames" in window) || !window.frames.length) {
691 // Casting `window.frames` to an Iterator doesn't work, so we're stuck with
692 // a plain, old for-loop.
693 let dwu = window.windowUtils;
694 for (let i = 0, l = window.frames.length; i < l; ++i) {
695 let frame = window.frames[i];
696 // Don't count matches in hidden frames; get the frame element rect and
697 // check if it's empty. We shan't flush!
698 let frameEl = frame && frame.frameElement;
701 lazy.Rect.fromRect(dwu.getBoundsWithoutFlushing(frameEl)).isEmpty()
705 // All conditions pass, so push the current frame and its children on the
707 frames.push(frame, ...this._collectFrames(frame, finder));
714 * Internal; helper method to extract the docShell reference from a Window or
717 * @param {Range} windowOrRange Window object to query. May also be a
718 * Range, from which the owner window will
720 * @return {nsIDocShell}
722 _getDocShell(windowOrRange) {
723 let window = windowOrRange;
724 // Ranges may also be passed in, so fetch its window.
725 if (ChromeUtils.getClassName(windowOrRange) === "Range") {
726 window = windowOrRange.startContainer.ownerGlobal;
728 return window.docShell;
732 * Internal; determines whether a range is inside a link.
734 * @param {Range} range the range to check
735 * @return {Boolean} True if the range starts in a link
737 _rangeStartsInLink(range) {
738 let isInsideLink = false;
739 let node = range.startContainer;
741 if (node.nodeType == node.ELEMENT_NODE) {
742 if (node.hasChildNodes) {
743 let childNode = node.item(range.startOffset);
750 const XLink_NS = "http://www.w3.org/1999/xlink";
751 const HTMLAnchorElement = (node.ownerDocument || node).defaultView
754 if (HTMLAnchorElement.isInstance(node)) {
755 isInsideLink = node.hasAttribute("href");
758 typeof node.hasAttributeNS == "function" &&
759 node.hasAttributeNS(XLink_NS, "href")
761 isInsideLink = node.getAttributeNS(XLink_NS, "type") == "simple";
765 node = node.parentNode;