Bug 1908842: update password generator prompt text r=android-reviewers,gl
[gecko.git] / toolkit / modules / FinderIterator.sys.mjs
blobf09873bed8fbc1bc9f45aeb3e13f262acf51ef40
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";
7 const lazy = {};
9 ChromeUtils.defineESModuleGetters(lazy, {
10   NLP: "resource://gre/modules/NLP.sys.mjs",
11   Rect: "resource://gre/modules/Geometry.sys.mjs",
12 });
14 const kDebug = false;
15 const kIterationSizeMax = 100;
16 const kTimeoutPref = "findbar.iteratorTimeout";
18 /**
19  * FinderIterator. See the documentation for the `start()` method to
20  * learn more.
21  */
22 export class FinderIterator {
23   constructor() {
24     this._listeners = new Map();
25     this._currentParams = null;
26     this._catchingUp = new Set();
27     this._previousParams = null;
28     this._previousRanges = [];
29     this._spawnId = 0;
30     this._timer = null;
31     this.ranges = [];
32     this.running = false;
33     this.useSubFrames = false;
34   }
36   _timeout = Services.prefs.getIntPref(kTimeoutPref);
38   // Expose `kIterationSizeMax` to the outside world for unit tests to use.
39   get kIterationSizeMax() {
40     return kIterationSizeMax;
41   }
43   get params() {
44     if (!this._currentParams && !this._previousParams) {
45       return null;
46     }
47     return Object.assign({}, this._currentParams || this._previousParams);
48   }
50   /**
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.
64    *
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
69    *                                          mode
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
74    *                                          limit.
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
92    * @return {Promise}
93    */
94   start({
95     allowDistance,
96     caseSensitive,
97     entireWord,
98     finder,
99     limit,
100     linksOnly,
101     listener,
102     matchDiacritics,
103     useCache,
104     word,
105     useSubFrames,
106   }) {
107     // Take care of default values for non-required options.
108     if (typeof allowDistance != "number") {
109       allowDistance = 0;
110     }
111     if (typeof limit != "number") {
112       limit = -1;
113     }
114     if (typeof linksOnly != "boolean") {
115       linksOnly = false;
116     }
117     if (typeof useCache != "boolean") {
118       useCache = false;
119     }
120     if (typeof useSubFrames != "boolean") {
121       useSubFrames = false;
122     }
124     // Validate the options.
125     if (typeof caseSensitive != "boolean") {
126       throw new Error("Missing required option 'caseSensitive'");
127     }
128     if (typeof entireWord != "boolean") {
129       throw new Error("Missing required option 'entireWord'");
130     }
131     if (typeof matchDiacritics != "boolean") {
132       throw new Error("Missing required option 'matchDiacritics'");
133     }
134     if (!finder) {
135       throw new Error("Missing required option 'finder'");
136     }
137     if (!word) {
138       throw new Error("Missing required option 'word'");
139     }
140     if (typeof listener != "object" || !listener.onIteratorRangeFound) {
141       throw new TypeError("Missing valid, required option 'listener'");
142     }
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);
148       if (onEnd) {
149         onEnd();
150       }
151     }
153     let window = finder._getWindow();
154     let resolver;
155     let promise = new Promise(resolve => (resolver = resolve));
156     let iterParams = {
157       caseSensitive,
158       entireWord,
159       linksOnly,
160       matchDiacritics,
161       useCache,
162       window,
163       word,
164       useSubFrames,
165     };
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);
172       return promise;
173     }
175     if (this.running) {
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.
178       if (
179         !this._areParamsEqual(this._currentParams, iterParams, allowDistance)
180       ) {
181         if (kDebug) {
182           console.error(
183             `We're currently iterating over '${this._currentParams.word}', not '${word}'\n` +
184               new Error().stack
185           );
186         }
187         this._listeners.delete(listener);
188         resolver();
189         return promise;
190       }
192       // if we're still running, yield the set we have built up this far.
193       this._yieldIntermediateResult(listener, window);
195       return promise;
196     }
198     // Start!
199     this.running = true;
200     this._currentParams = iterParams;
201     this._findAllRanges(finder, ++this._spawnId);
203     return promise;
204   }
206   /**
207    * Stop the currently running iterator as soon as possible and optionally cache
208    * the result for later.
209    *
210    * @param {Boolean} [cachePrevious] Whether to save the result for later.
211    *                                  Optional.
212    */
213   stop(cachePrevious = false) {
214     if (!this.running) {
215       return;
216     }
218     if (this._timer) {
219       clearTimeout(this._timer);
220       this._timer = null;
221     }
222     if (this._runningFindResolver) {
223       this._runningFindResolver();
224       this._runningFindResolver = null;
225     }
227     if (cachePrevious) {
228       this._previousRanges = [].concat(this.ranges);
229       this._previousParams = Object.assign({}, this._currentParams);
230     } else {
231       this._previousRanges = [];
232       this._previousParams = null;
233     }
235     this._catchingUp.clear();
236     this._currentParams = null;
237     this.ranges = [];
238     this.running = false;
240     for (let [, { onEnd }] of this._listeners) {
241       onEnd();
242     }
243   }
245   /**
246    * Stops the iteration that currently running, if it is, and start a new one
247    * with the exact same params as before.
248    *
249    * @param {Finder} finder Currently active Finder instance
250    */
251   restart(finder) {
252     // Capture current iterator params before we stop the show.
253     let iterParams = this.params;
254     if (!iterParams) {
255       return;
256     }
257     this.stop();
259     // Restart manually.
260     this.running = true;
261     this._currentParams = iterParams;
263     this._findAllRanges(finder, ++this._spawnId);
264     this._notifyListeners("restart", iterParams);
265   }
267   /**
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.
272    */
273   reset() {
274     if (this._timer) {
275       clearTimeout(this._timer);
276       this._timer = null;
277     }
278     if (this._runningFindResolver) {
279       this._runningFindResolver();
280       this._runningFindResolver = null;
281     }
283     this._catchingUp.clear();
284     this._currentParams = this._previousParams = null;
285     this._previousRanges = [];
286     this.ranges = [];
287     this.running = false;
289     this._notifyListeners("reset");
290     for (let [, { onEnd }] of this._listeners) {
291       onEnd();
292     }
293     this._listeners.clear();
294   }
296   /**
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`.
300    *
301    * @param {Boolean}  options.caseSensitive Whether to search in case sensitive
302    *                                         mode
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
310    * @return {Boolean}
311    */
312   continueRunning({
313     caseSensitive,
314     entireWord,
315     linksOnly,
316     matchDiacritics,
317     word,
318     useSubFrames,
319   }) {
320     return (
321       this.running &&
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
328     );
329   }
331   /**
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
334    * the new listener.
335    * Consumers may opt-out of this behavior by using this check and not call
336    * start().
337    *
338    * @param  {Object} paramSet Property bag with the same signature as you would
339    *                           pass into `start()`
340    * @return {Boolean}
341    */
342   isAlreadyRunning(paramSet) {
343     return (
344       this.running &&
345       this._areParamsEqual(this._currentParams, paramSet) &&
346       this._listeners.has(paramSet.listener)
347     );
348   }
350   /**
351    * Safely notify all registered listeners that an event has occurred.
352    *
353    * @param {String}   callback    Name of the callback to invoke
354    * @param {mixed}    [params]    Optional argument that will be passed to the
355    *                               callback
356    * @param {Iterable} [listeners] Set of listeners to notify. Optional, defaults
357    *                               to `this._listeners.keys()`.
358    */
359   _notifyListeners(callback, params, listeners = this._listeners.keys()) {
360     callback =
361       "onIterator" + callback.charAt(0).toUpperCase() + callback.substr(1);
362     for (let listener of listeners) {
363       try {
364         listener[callback](params);
365       } catch (ex) {
366         console.error("FinderIterator Error: ", ex);
367       }
368     }
369   }
371   /**
372    * Internal; check if an iteration request is available in the previous result
373    * that we cached.
374    *
375    * @param  {Boolean} options.caseSensitive Whether to search in case sensitive
376    *                                         mode
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
385    * @return {Boolean}
386    */
387   _previousResultAvailable({
388     caseSensitive,
389     entireWord,
390     linksOnly,
391     matchDiacritics,
392     useCache,
393     word,
394   }) {
395     return !!(
396       useCache &&
397       this._areParamsEqual(this._previousParams, {
398         caseSensitive,
399         entireWord,
400         linksOnly,
401         matchDiacritics,
402         word,
403       }) &&
404       this._previousRanges.length
405     );
406   }
408   /**
409    * Internal; compare if two sets of iterator parameters are equivalent.
410    *
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
415    *                                  distance'.
416    * @return {Boolean}
417    */
418   _areParamsEqual(paramSet1, paramSet2, allowDistance = 0) {
419     return (
420       !!paramSet1 &&
421       !!paramSet2 &&
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
429     );
430   }
432   /**
433    * Internal; iterate over a predefined set of ranges that have been collected
434    * before.
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.
438    *
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
445    *                                   to `true`.
446    * @yield {Range}
447    */
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.
451     let iterCount = 0;
452     let { limit, onEnd } = this._listeners.get(listener);
453     let ranges = rangeSource.slice(0, limit > -1 ? limit : undefined);
454     for (let range of ranges) {
455       try {
456         range.startContainer;
457       } catch (ex) {
458         // Don't yield dead objects, so use the escape hatch.
459         if (ex.message.includes("dead object")) {
460           return;
461         }
462       }
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);
467       await range;
469       if (withPause && ++iterCount >= kIterationSizeMax) {
470         iterCount = 0;
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);
477       }
479       if (limit !== -1 && --limit === 0) {
480         // We've reached our limit; no need to do more work.
481         this._listeners.delete(listener);
482         onEnd();
483         return;
484       }
485     }
487     // Save the updated limit globally.
488     this._listeners.set(listener, { limit, onEnd });
489   }
491   /**
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.
495    *
496    * @param {Object}       listener Listener object
497    * @param {nsIDOMWindow} window   The window object is only really used
498    *                                for access to `setTimeout`
499    * @yield {Range}
500    */
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);
507     if (onEnd) {
508       onEnd();
509     }
510   }
512   /**
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.
516    *
517    * @param {Object}       listener Listener object
518    * @param {nsIDOMWindow} window   The window object is only really used
519    *                                for access to `setTimeout`
520    * @yield {Range}
521    */
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);
527   }
529   /**
530    * Internal; see the documentation of the start() method above.
531    *
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.
536    * @yield {Range}
537    */
538   async _findAllRanges(finder, spawnId) {
539     if (this._timeout) {
540       if (this._timer) {
541         clearTimeout(this._timer);
542       }
543       if (this._runningFindResolver) {
544         this._runningFindResolver();
545       }
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
550       // the findbar.
551       if (searchTerm.length == 1) {
552         timeout *= 4;
553       } else if (searchTerm.length == 2) {
554         timeout *= 2;
555       }
556       await new Promise(resolve => {
557         this._runningFindResolver = resolve;
558         this._timer = setTimeout(resolve, timeout);
559       });
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) {
564         return;
565       }
566     }
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];
574     if (useSubFrames) {
575       frames.push(...this._collectFrames(window, finder));
576     }
577     let iterCount = 0;
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) {
583           return;
584         }
586         // Deal with links-only mode here.
587         if (linksOnly && !this._rangeStartsInLink(range)) {
588           continue;
589         }
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)) {
596             continue;
597           }
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);
604             onEnd();
605             continue;
606           }
608           // Save the updated limit globally.
609           this._listeners.set(listener, { limit, onEnd });
610         }
612         await range;
614         if (++iterCount >= kIterationSizeMax) {
615           iterCount = 0;
616           // Sleep for the rest of this cycle.
617           await new Promise(resolve => window.setTimeout(resolve, 0));
618         }
619       }
620     }
622     // When the iterating has finished, make sure we reset and save the state
623     // properly.
624     this.stop(true);
625   }
627   /**
628    * Internal; basic wrapper around nsIFind that provides a generator yielding
629    * a range each time an occurence of `word` string is found.
630    *
631    * @param {Boolean}      options.caseSensitive Whether to search in case
632    *                                             sensitive mode
633    * @param {Boolean}      options.entireWord    Whether to search in entire-word
634    *                                             mode
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
639    * @yield {Range}
640    */
641   *_iterateDocument(
642     { caseSensitive, entireWord, matchDiacritics, word },
643     window
644   ) {
645     let doc = window.document;
646     let body = doc.body || doc.documentElement;
648     if (!body) {
649       return;
650     }
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);
661     let retRange = null;
663     let nsIFind = Cc["@mozilla.org/embedcomp/rangefind;1"]
664       .createInstance()
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))) {
671       yield retRange;
672       startPt = retRange.cloneRange();
673       startPt.collapse(false);
674     }
675   }
677   /**
678    * Internal; helper method for the iterator that recursively collects all
679    * visible (i)frames inside a window.
680    *
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
684    */
685   _collectFrames(window, finder) {
686     let frames = [];
687     if (!("frames" in window) || !window.frames.length) {
688       return frames;
689     }
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;
699       if (
700         !frameEl ||
701         lazy.Rect.fromRect(dwu.getBoundsWithoutFlushing(frameEl)).isEmpty()
702       ) {
703         continue;
704       }
705       // All conditions pass, so push the current frame and its children on the
706       // stack.
707       frames.push(frame, ...this._collectFrames(frame, finder));
708     }
710     return frames;
711   }
713   /**
714    * Internal; helper method to extract the docShell reference from a Window or
715    * Range object.
716    *
717    * @param  {Range} windowOrRange Window object to query. May also be a
718    *                               Range, from which the owner window will
719    *                               be queried.
720    * @return {nsIDocShell}
721    */
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;
727     }
728     return window.docShell;
729   }
731   /**
732    * Internal; determines whether a range is inside a link.
733    *
734    * @param  {Range} range the range to check
735    * @return {Boolean}     True if the range starts in a link
736    */
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);
744         if (childNode) {
745           node = childNode;
746         }
747       }
748     }
750     const XLink_NS = "http://www.w3.org/1999/xlink";
751     const HTMLAnchorElement = (node.ownerDocument || node).defaultView
752       .HTMLAnchorElement;
753     do {
754       if (HTMLAnchorElement.isInstance(node)) {
755         isInsideLink = node.hasAttribute("href");
756         break;
757       } else if (
758         typeof node.hasAttributeNS == "function" &&
759         node.hasAttributeNS(XLink_NS, "href")
760       ) {
761         isInsideLink = node.getAttributeNS(XLink_NS, "type") == "simple";
762         break;
763       }
765       node = node.parentNode;
766     } while (node);
768     return isInsideLink;
769   }