summaryrefslogtreecommitdiffstats
path: root/toolkit/modules/FinderIterator.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/modules/FinderIterator.sys.mjs')
-rw-r--r--toolkit/modules/FinderIterator.sys.mjs770
1 files changed, 770 insertions, 0 deletions
diff --git a/toolkit/modules/FinderIterator.sys.mjs b/toolkit/modules/FinderIterator.sys.mjs
new file mode 100644
index 0000000000..f09873bed8
--- /dev/null
+++ b/toolkit/modules/FinderIterator.sys.mjs
@@ -0,0 +1,770 @@
+/* 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;
+ }
+}