Bug 1608150 [wpt PR 21112] - Add missing space in `./wpt lint` command line docs...
[gecko.git] / toolkit / modules / FinderHighlighter.jsm
blob3dec6de936c5ca493c80b39bb970eed7c4d44e70
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 "use strict";
7 var EXPORTED_SYMBOLS = ["FinderHighlighter"];
9 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
10 const { XPCOMUtils } = ChromeUtils.import(
11   "resource://gre/modules/XPCOMUtils.jsm"
14 ChromeUtils.defineModuleGetter(
15   this,
16   "Color",
17   "resource://gre/modules/Color.jsm"
19 ChromeUtils.defineModuleGetter(
20   this,
21   "Rect",
22   "resource://gre/modules/Geometry.jsm"
24 XPCOMUtils.defineLazyGetter(this, "kDebug", () => {
25   const kDebugPref = "findbar.modalHighlight.debug";
26   return (
27     Services.prefs.getPrefType(kDebugPref) &&
28     Services.prefs.getBoolPref(kDebugPref)
29   );
30 });
32 const kContentChangeThresholdPx = 5;
33 const kBrightTextSampleSize = 5;
34 // This limit is arbitrary and doesn't scale for low-powered machines or
35 // high-powered machines. Netbooks will probably need a much lower limit, for
36 // example. Though getting something out there is better than nothing.
37 const kPageIsTooBigPx = 500000;
38 const kModalHighlightRepaintLoFreqMs = 100;
39 const kModalHighlightRepaintHiFreqMs = 16;
40 const kHighlightAllPref = "findbar.highlightAll";
41 const kModalHighlightPref = "findbar.modalHighlight";
42 const kFontPropsCSS = [
43   "color",
44   "font-family",
45   "font-kerning",
46   "font-size",
47   "font-size-adjust",
48   "font-stretch",
49   "font-variant",
50   "font-weight",
51   "line-height",
52   "letter-spacing",
53   "text-emphasis",
54   "text-orientation",
55   "text-transform",
56   "word-spacing",
58 const kFontPropsCamelCase = kFontPropsCSS.map(prop => {
59   let parts = prop.split("-");
60   return (
61     parts.shift() +
62     parts.map(part => part.charAt(0).toUpperCase() + part.slice(1)).join("")
63   );
64 });
65 const kRGBRE = /^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*/i;
66 // This uuid is used to prefix HTML element IDs in order to make them unique and
67 // hard to clash with IDs content authors come up with.
68 const kModalIdPrefix = "cedee4d0-74c5-4f2d-ab43-4d37c0f9d463";
69 const kModalOutlineId = kModalIdPrefix + "-findbar-modalHighlight-outline";
70 const kOutlineBoxColor = "255,197,53";
71 const kOutlineBoxBorderSize = 1;
72 const kOutlineBoxBorderRadius = 2;
73 const kModalStyles = {
74   outlineNode: [
75     ["background-color", `rgb(${kOutlineBoxColor})`],
76     ["background-clip", "padding-box"],
77     ["border", `${kOutlineBoxBorderSize}px solid rgba(${kOutlineBoxColor},.7)`],
78     ["border-radius", `${kOutlineBoxBorderRadius}px`],
79     ["box-shadow", `0 2px 0 0 rgba(0,0,0,.1)`],
80     ["color", "#000"],
81     ["display", "-moz-box"],
82     [
83       "margin",
84       `-${kOutlineBoxBorderSize}px 0 0 -${kOutlineBoxBorderSize}px !important`,
85     ],
86     ["overflow", "hidden"],
87     ["pointer-events", "none"],
88     ["position", "absolute"],
89     ["white-space", "nowrap"],
90     ["will-change", "transform"],
91     ["z-index", 2],
92   ],
93   outlineNodeDebug: [["z-index", 2147483647]],
94   outlineText: [
95     ["margin", "0 !important"],
96     ["padding", "0 !important"],
97     ["vertical-align", "top !important"],
98   ],
99   maskNode: [
100     ["background", "rgba(0,0,0,.25)"],
101     ["pointer-events", "none"],
102     ["position", "absolute"],
103     ["z-index", 1],
104   ],
105   maskNodeTransition: [["transition", "background .2s ease-in"]],
106   maskNodeDebug: [["z-index", 2147483646], ["top", 0], ["left", 0]],
107   maskNodeBrightText: [["background", "rgba(255,255,255,.25)"]],
109 const kModalOutlineAnim = {
110   keyframes: [
111     { transform: "scaleX(1) scaleY(1)" },
112     { transform: "scaleX(1.5) scaleY(1.5)", offset: 0.5, easing: "ease-in" },
113     { transform: "scaleX(1) scaleY(1)" },
114   ],
115   duration: 50,
117 const kNSHTML = "http://www.w3.org/1999/xhtml";
118 const kRepaintSchedulerStopped = 1;
119 const kRepaintSchedulerPaused = 2;
120 const kRepaintSchedulerRunning = 3;
122 function mockAnonymousContentNode(domNode) {
123   return {
124     setTextContentForElement(id, text) {
125       (domNode.querySelector("#" + id) || domNode).textContent = text;
126     },
127     getAttributeForElement(id, attrName) {
128       let node = domNode.querySelector("#" + id) || domNode;
129       if (!node.hasAttribute(attrName)) {
130         return undefined;
131       }
132       return node.getAttribute(attrName);
133     },
134     setAttributeForElement(id, attrName, attrValue) {
135       (domNode.querySelector("#" + id) || domNode).setAttribute(
136         attrName,
137         attrValue
138       );
139     },
140     removeAttributeForElement(id, attrName) {
141       let node = domNode.querySelector("#" + id) || domNode;
142       if (!node.hasAttribute(attrName)) {
143         return;
144       }
145       node.removeAttribute(attrName);
146     },
147     remove() {
148       try {
149         domNode.remove();
150       } catch (ex) {}
151     },
152     setAnimationForElement(id, keyframes, duration) {
153       return (domNode.querySelector("#" + id) || domNode).animate(
154         keyframes,
155         duration
156       );
157     },
158     setCutoutRectsForElement(id, rects) {
159       // no-op for now.
160     },
161   };
164 let gWindows = new WeakMap();
167  * FinderHighlighter class that is used by Finder.jsm to take care of the
168  * 'Highlight All' feature, which can highlight all find occurrences in a page.
170  * @param {Finder} finder Finder.jsm instance
171  * @param {boolean} useTop check and use top-level windows for rectangle
172  *                         computation, if possible.
173  */
174 function FinderHighlighter(finder, useTop = false) {
175   this._highlightAll = Services.prefs.getBoolPref(kHighlightAllPref);
176   this._modal = Services.prefs.getBoolPref(kModalHighlightPref);
177   this._useSubFrames = false;
178   this._useTop = useTop;
179   this.finder = finder;
182 FinderHighlighter.prototype = {
183   get iterator() {
184     return this.finder.iterator;
185   },
187   // Get the top-most window when allowed. When out-of-process frames are used,
188   // this will usually be the same as the passed-in window. The checkUseTop
189   // argument can be used to instead check the _useTop flag which can be used
190   // to enable rectangle coordinate checks.
191   getTopWindow(window, checkUseTop) {
192     if (this._useSubFrames || (checkUseTop && this._useTop)) {
193       try {
194         return window.top;
195       } catch (ex) {}
196     }
198     return window;
199   },
201   useModal() {
202     // Modal highlighting is currently only enabled when there are no
203     // out-of-process subframes.
204     return this._modal && this._useSubFrames;
205   },
207   /**
208    * Each window is unique, globally, and the relation between an active
209    * highlighting session and a window is 1:1.
210    * For each window we track a number of properties which _at least_ consist of
211    *  - {Boolean} detectedGeometryChange Whether the geometry of the found ranges'
212    *                                     rectangles has changed substantially
213    *  - {Set}     dynamicRangesSet       Set of ranges that may move around, depending
214    *                                     on page layout changes and user input
215    *  - {Map}     frames                 Collection of frames that were encountered
216    *                                     when inspecting the found ranges
217    *  - {Map}     modalHighlightRectsMap Collection of ranges and their corresponding
218    *                                     Rects and texts
219    *
220    * @param  {nsIDOMWindow} window
221    * @return {Object}
222    */
223   getForWindow(window, propName = null) {
224     if (!gWindows.has(window)) {
225       gWindows.set(window, {
226         detectedGeometryChange: false,
227         dynamicRangesSet: new Set(),
228         frames: new Map(),
229         lastWindowDimensions: { width: 0, height: 0 },
230         modalHighlightRectsMap: new Map(),
231         previousRangeRectsAndTexts: { rectList: [], textList: [] },
232         repaintSchedulerState: kRepaintSchedulerStopped,
233       });
234     }
235     return gWindows.get(window);
236   },
238   /**
239    * Notify all registered listeners that the 'Highlight All' operation finished.
240    *
241    * @param {Boolean} highlight Whether highlighting was turned on
242    */
243   notifyFinished(highlight) {
244     for (let l of this.finder._listeners) {
245       try {
246         l.onHighlightFinished(highlight);
247       } catch (ex) {}
248     }
249   },
251   /**
252    * Toggle highlighting all occurrences of a word in a page. This method will
253    * be called recursively for each (i)frame inside a page.
254    *
255    * @param {Booolean} highlight    Whether highlighting should be turned on
256    * @param {String}   word         Needle to search for and highlight when found
257    * @param {Boolean}  linksOnly    Only consider nodes that are links for the search
258    * @param {Boolean}  drawOutline  Whether found links should be outlined.
259    * @param {Boolean}  useSubFrames Whether to iterate over subframes.
260    * @yield {Promise}  that resolves once the operation has finished
261    */
262   async highlight(highlight, word, linksOnly, drawOutline, useSubFrames) {
263     let window = this.finder._getWindow();
264     let dict = this.getForWindow(window);
265     let controller = this.finder._getSelectionController(window);
266     let doc = window.document;
267     this._found = false;
268     this._useSubFrames = useSubFrames;
270     let result = { searchString: word, highlight, found: false };
272     if (!controller || !doc || !doc.documentElement) {
273       // Without the selection controller,
274       // we are unable to (un)highlight any matches
275       return result;
276     }
278     if (highlight) {
279       let params = {
280         allowDistance: 1,
281         caseSensitive: this.finder._fastFind.caseSensitive,
282         entireWord: this.finder._fastFind.entireWord,
283         linksOnly,
284         word,
285         finder: this.finder,
286         listener: this,
287         matchDiacritics: this.finder._fastFind.matchDiacritics,
288         useCache: true,
289         useSubFrames,
290         window,
291       };
292       if (
293         this.iterator.isAlreadyRunning(params) ||
294         (this.useModal() &&
295           this.iterator._areParamsEqual(params, dict.lastIteratorParams))
296       ) {
297         return result;
298       }
300       if (!this.useModal()) {
301         dict.visible = true;
302       }
303       await this.iterator.start(params);
304       if (this._found) {
305         this.finder._outlineLink(drawOutline);
306       }
307     } else {
308       this.hide(window);
310       // Removing the highlighting always succeeds, so return true.
311       this._found = true;
312     }
314     result.found = this._found;
315     this.notifyFinished(result);
316     return result;
317   },
319   // FinderIterator listener implementation
321   onIteratorRangeFound(range) {
322     this.highlightRange(range);
323     this._found = true;
324   },
326   onIteratorReset() {},
328   onIteratorRestart() {
329     this.clear(this.finder._getWindow());
330   },
332   onIteratorStart(params) {
333     let window = this.finder._getWindow();
334     let dict = this.getForWindow(window);
335     // Save a clean params set for use later in the `update()` method.
336     dict.lastIteratorParams = params;
337     if (!this.useModal()) {
338       this.hide(window, this.finder._fastFind.getFoundRange());
339     }
340     this.clear(window);
341   },
343   /**
344    * Add a range to the find selection, i.e. highlight it, and if it's inside an
345    * editable node, track it.
346    *
347    * @param {Range} range Range object to be highlighted
348    */
349   highlightRange(range) {
350     let node = range.startContainer;
351     let editableNode = this._getEditableNode(node);
352     let window = node.ownerGlobal;
353     let controller = this.finder._getSelectionController(window);
354     if (editableNode) {
355       controller = editableNode.editor.selectionController;
356     }
358     if (this.useModal()) {
359       this._modalHighlight(range, controller, window);
360     } else {
361       let findSelection = controller.getSelection(
362         Ci.nsISelectionController.SELECTION_FIND
363       );
364       findSelection.addRange(range);
365       // Check if the range is inside an (i)frame.
366       if (window != this.getTopWindow(window)) {
367         let dict = this.getForWindow(this.getTopWindow(window));
368         // Add this frame to the list, so that we'll be able to find it later
369         // when we need to clear its selection(s).
370         dict.frames.set(window, {});
371       }
372     }
374     if (editableNode) {
375       // Highlighting added, so cache this editor, and hook up listeners
376       // to ensure we deal properly with edits within the highlighting
377       this._addEditorListeners(editableNode.editor);
378     }
379   },
381   /**
382    * If modal highlighting is enabled, show the dimmed background that will overlay
383    * the page.
384    *
385    * @param {nsIDOMWindow} window The dimmed background will overlay this window.
386    *                              Optional, defaults to the finder window.
387    */
388   show(window = null) {
389     window = this.getTopWindow(window || this.finder._getWindow());
390     let dict = this.getForWindow(window);
391     if (!this.useModal() || dict.visible) {
392       return;
393     }
395     dict.visible = true;
397     this._maybeCreateModalHighlightNodes(window);
398     this._addModalHighlightListeners(window);
399   },
401   /**
402    * Clear all highlighted matches. If modal highlighting is enabled and
403    * the outline + dimmed background is currently visible, both will be hidden.
404    *
405    * @param {nsIDOMWindow} window    The dimmed background will overlay this window.
406    *                                 Optional, defaults to the finder window.
407    * @param {Range}        skipRange A range that should not be removed from the
408    *                                 find selection.
409    * @param {Event}        event     When called from an event handler, this will
410    *                                 be the triggering event.
411    */
412   hide(window, skipRange = null, event = null) {
413     try {
414       window = this.getTopWindow(window);
415     } catch (ex) {
416       Cu.reportError(ex);
417       return;
418     }
419     let dict = this.getForWindow(window);
421     let isBusySelecting = dict.busySelecting;
422     dict.busySelecting = false;
423     // Do not hide on anything but a left-click.
424     if (
425       event &&
426       event.type == "click" &&
427       (event.button !== 0 ||
428         event.altKey ||
429         event.ctrlKey ||
430         event.metaKey ||
431         event.shiftKey ||
432         event.relatedTarget ||
433         isBusySelecting ||
434         (event.target.localName == "a" && event.target.href))
435     ) {
436       return;
437     }
439     this._clearSelection(
440       this.finder._getSelectionController(window),
441       skipRange
442     );
443     for (let frame of dict.frames.keys()) {
444       this._clearSelection(
445         this.finder._getSelectionController(frame),
446         skipRange
447       );
448     }
450     // Next, check our editor cache, for editors belonging to this
451     // document
452     if (this._editors) {
453       let doc = window.document;
454       for (let x = this._editors.length - 1; x >= 0; --x) {
455         if (this._editors[x].document == doc) {
456           this._clearSelection(this._editors[x].selectionController, skipRange);
457           // We don't need to listen to this editor any more
458           this._unhookListenersAtIndex(x);
459         }
460       }
461     }
463     if (dict.modalRepaintScheduler) {
464       window.clearTimeout(dict.modalRepaintScheduler);
465       dict.modalRepaintScheduler = null;
466       dict.repaintSchedulerState = kRepaintSchedulerStopped;
467     }
468     dict.lastWindowDimensions = { width: 0, height: 0 };
470     this._removeRangeOutline(window);
471     this._removeHighlightAllMask(window);
472     this._removeModalHighlightListeners(window);
474     dict.visible = false;
475   },
477   /**
478    * Called by the Finder after a find result comes in; update the position and
479    * content of the outline to the newly found occurrence.
480    * To make sure that the outline covers the found range completely, all the
481    * CSS styles that influence the text are copied and applied to the outline.
482    *
483    * @param {Object} data Dictionary coming from Finder that contains the
484    *                      following properties:
485    *   {Number}  result        One of the nsITypeAheadFind.FIND_* constants
486    *                           indicating the result of a search operation.
487    *   {Boolean} findBackwards If TRUE, the search was performed backwards,
488    *                           FALSE if forwards.
489    *   {Boolean} findAgain     If TRUE, the search was performed using the same
490    *                           search string as before.
491    *   {String}  linkURL       If a link was hit, this will contain a URL string.
492    *   {Rect}    rect          An object with top, left, width and height
493    *                           coordinates of the current selection.
494    *   {String}  searchString  The string the search was performed with.
495    *   {Boolean} storeResult   Indicator if the search string should be stored
496    *                           by the consumer of the Finder.
497    */
498   async update(data, foundInThisFrame) {
499     let window = this.finder._getWindow();
500     let dict = this.getForWindow(window);
501     let foundRange = this.finder._fastFind.getFoundRange();
503     if (
504       data.result == Ci.nsITypeAheadFind.FIND_NOTFOUND ||
505       !data.searchString ||
506       (foundInThisFrame && !foundRange)
507     ) {
508       this.hide(window);
509       return;
510     }
512     this._useSubFrames = data.useSubFrames;
513     if (!this.useModal()) {
514       if (this._highlightAll) {
515         dict.previousFoundRange = dict.currentFoundRange;
516         dict.currentFoundRange = foundRange;
517         let params = this.iterator.params;
518         if (
519           dict.visible &&
520           this.iterator._areParamsEqual(params, dict.lastIteratorParams)
521         ) {
522           return;
523         }
524         if (!dict.visible && !params) {
525           params = { word: data.searchString, linksOnly: data.linksOnly };
526         }
527         if (params) {
528           await this.highlight(
529             true,
530             params.word,
531             params.linksOnly,
532             params.drawOutline,
533             data.useSubFrames
534           );
535         }
536       }
537       return;
538     }
540     dict.animateOutline = true;
541     // Immediately finish running animations, if any.
542     this._finishOutlineAnimations(dict);
544     if (foundRange !== dict.currentFoundRange || data.findAgain) {
545       dict.previousFoundRange = dict.currentFoundRange;
546       dict.currentFoundRange = foundRange;
548       if (!dict.visible) {
549         this.show(window);
550       } else {
551         this._maybeCreateModalHighlightNodes(window);
552       }
553     }
555     if (this._highlightAll) {
556       await this.highlight(
557         true,
558         data.searchString,
559         data.linksOnly,
560         data.drawOutline,
561         data.useSubFrames
562       );
563     }
564   },
566   /**
567    * Invalidates the list by clearing the map of highlighted ranges that we
568    * keep to build the mask for.
569    */
570   clear(window = null) {
571     if (!window || !this.getTopWindow(window)) {
572       return;
573     }
575     let dict = this.getForWindow(this.getTopWindow(window));
576     this._finishOutlineAnimations(dict);
577     dict.dynamicRangesSet.clear();
578     dict.frames.clear();
579     dict.modalHighlightRectsMap.clear();
580     dict.brightText = null;
581   },
583   /**
584    * Removes the outline from a single window. This is done when
585    * switching the current search to a new frame.
586    */
587   clearCurrentOutline(window = null) {
588     let dict = this.getForWindow(this.getTopWindow(window));
589     this._finishOutlineAnimations(dict);
590     this._removeRangeOutline(window);
591   },
593   /**
594    * When the current page is refreshed or navigated away from, the CanvasFrame
595    * contents is not valid anymore, i.e. all anonymous content is destroyed.
596    * We need to clear the references we keep, which'll make sure we redraw
597    * everything when the user starts to find in page again.
598    */
599   onLocationChange() {
600     let window = this.finder._getWindow();
601     if (!window || !this.getTopWindow(window)) {
602       return;
603     }
604     this.hide(window);
605     this.clear(window);
606     this._removeRangeOutline(window);
608     gWindows.delete(this.getTopWindow(window));
609   },
611   /**
612    * When `kModalHighlightPref` pref changed during a session, this callback is
613    * invoked. When modal highlighting is turned off, we hide the CanvasFrame
614    * contents.
615    *
616    * @param {Boolean} useModalHighlight
617    */
618   onModalHighlightChange(useModalHighlight) {
619     let window = this.finder._getWindow();
620     if (window && this.useModal() && !useModalHighlight) {
621       this.hide(window);
622       this.clear(window);
623     }
624     this._modal = useModalHighlight;
625   },
627   /**
628    * When 'Highlight All' is toggled during a session, this callback is invoked
629    * and when it's turned off, the found occurrences will be removed from the mask.
630    *
631    * @param {Boolean} highlightAll
632    */
633   onHighlightAllChange(highlightAll) {
634     this._highlightAll = highlightAll;
635     if (!highlightAll) {
636       let window = this.finder._getWindow();
637       if (!this.useModal()) {
638         this.hide(window);
639       }
640       this.clear(window);
641       this._scheduleRepaintOfMask(window);
642     }
643   },
645   /**
646    * Utility; removes all ranges from the find selection that belongs to a
647    * controller. Optionally skips a specific range.
648    *
649    * @param  {nsISelectionController} controller
650    * @param  {Range}                  restoreRange
651    */
652   _clearSelection(controller, restoreRange = null) {
653     if (!controller) {
654       return;
655     }
656     let sel = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
657     sel.removeAllRanges();
658     if (restoreRange) {
659       sel = controller.getSelection(Ci.nsISelectionController.SELECTION_NORMAL);
660       sel.addRange(restoreRange);
661       controller.setDisplaySelection(
662         Ci.nsISelectionController.SELECTION_ATTENTION
663       );
664       controller.repaintSelection(Ci.nsISelectionController.SELECTION_NORMAL);
665     }
666   },
668   /**
669    * Utility; get the nsIDOMWindowUtils for a window.
670    *
671    * @param  {nsIDOMWindow} window Optional, defaults to the finder window.
672    * @return {nsIDOMWindowUtils}
673    */
674   _getDWU(window = null) {
675     return (window || this.finder._getWindow()).windowUtils;
676   },
678   /**
679    * Utility; returns the bounds of the page relative to the viewport.
680    * If the pages is part of a frameset or inside an iframe of any kind, its
681    * offset is accounted for.
682    * Geometry.jsm takes care of the DOMRect calculations.
683    *
684    * @param  {nsIDOMWindow} window          Window to read the boundary rect from
685    * @param  {Boolean}      [includeScroll] Whether to ignore the scroll offset,
686    *                                        which is useful for comparing DOMRects.
687    *                                        Optional, defaults to `true`
688    * @return {Rect}
689    */
690   _getRootBounds(window, includeScroll = true) {
691     let dwu = this._getDWU(this.getTopWindow(window, true));
692     let cssPageRect = Rect.fromRect(dwu.getRootBounds());
693     let scrollX = {};
694     let scrollY = {};
695     if (includeScroll && window == this.getTopWindow(window, true)) {
696       dwu.getScrollXY(false, scrollX, scrollY);
697       cssPageRect.translate(scrollX.value, scrollY.value);
698     }
700     // If we're in a frame, update the position of the rect (top/ left).
701     let currWin = window;
702     while (currWin != this.getTopWindow(window, true)) {
703       let frameOffsets = this._getFrameElementOffsets(currWin);
704       cssPageRect.translate(frameOffsets.x, frameOffsets.y);
706       // Since the frame is an element inside a parent window, we'd like to
707       // learn its position relative to it.
708       let el = this._getDWU(currWin).containerElement;
709       currWin = currWin.parent;
710       dwu = this._getDWU(currWin);
711       let parentRect = Rect.fromRect(dwu.getBoundsWithoutFlushing(el));
713       if (includeScroll) {
714         dwu.getScrollXY(false, scrollX, scrollY);
715         parentRect.translate(scrollX.value, scrollY.value);
716         // If the current window is an iframe with scrolling="no" and its parent
717         // is also an iframe the scroll offsets from the parents' documentElement
718         // (inverse scroll position) needs to be subtracted from the parent
719         // window rect.
720         if (
721           el.getAttribute("scrolling") == "no" &&
722           currWin != this.getTopWindow(window, true)
723         ) {
724           let docEl = currWin.document.documentElement;
725           parentRect.translate(-docEl.scrollLeft, -docEl.scrollTop);
726         }
727       }
729       cssPageRect.translate(parentRect.left, parentRect.top);
730     }
731     let frameOffsets = this._getFrameElementOffsets(currWin);
732     cssPageRect.translate(frameOffsets.x, frameOffsets.y);
734     return cssPageRect;
735   },
737   /**
738    * (I)Frame elements may have a border and/ or padding set, which is not
739    * included in the bounds returned by nsDOMWindowUtils#getRootBounds() for the
740    * window it hosts.
741    * This method fetches this offset of the frame element to the respective window.
742    *
743    * @param  {nsIDOMWindow} window          Window to read the boundary rect from
744    * @return {Object}       Simple object that contains the following two properties:
745    *                        - {Number} x Offset along the horizontal axis.
746    *                        - {Number} y Offset along the vertical axis.
747    */
748   _getFrameElementOffsets(window) {
749     let frame = window.frameElement;
750     if (!frame) {
751       return { x: 0, y: 0 };
752     }
754     // Getting style info is super expensive, causing reflows, so let's cache
755     // frame border widths and padding values aggressively.
756     let dict = this.getForWindow(this.getTopWindow(window, true));
757     let frameData = dict.frames.get(window);
758     if (!frameData) {
759       dict.frames.set(window, (frameData = {}));
760     }
761     if (frameData.offset) {
762       return frameData.offset;
763     }
765     let style = frame.ownerGlobal.getComputedStyle(frame);
766     // We only need to left sides, because ranges are offset from point 0,0 in
767     // the top-left corner.
768     let borderOffset = [
769       parseInt(style.borderLeftWidth, 10) || 0,
770       parseInt(style.borderTopWidth, 10) || 0,
771     ];
772     let paddingOffset = [
773       parseInt(style.paddingLeft, 10) || 0,
774       parseInt(style.paddingTop, 10) || 0,
775     ];
776     return (frameData.offset = {
777       x: borderOffset[0] + paddingOffset[0],
778       y: borderOffset[1] + paddingOffset[1],
779     });
780   },
782   /**
783    * Utility; fetch the full width and height of the current window, excluding
784    * scrollbars.
785    *
786    * @param  {nsiDOMWindow} window The current finder window.
787    * @return {Object} The current full page dimensions with `width` and `height`
788    *                  properties
789    */
790   _getWindowDimensions(window) {
791     // First we'll try without flushing layout, because it's way faster.
792     let dwu = this._getDWU(window);
793     let { width, height } = dwu.getRootBounds();
795     if (!width || !height) {
796       // We need a flush after all :'(
797       width = window.innerWidth + window.scrollMaxX - window.scrollMinX;
798       height = window.innerHeight + window.scrollMaxY - window.scrollMinY;
800       let scrollbarHeight = {};
801       let scrollbarWidth = {};
802       dwu.getScrollbarSize(false, scrollbarWidth, scrollbarHeight);
803       width -= scrollbarWidth.value;
804       height -= scrollbarHeight.value;
805     }
807     return { width, height };
808   },
810   /**
811    * Utility; get all available font styles as applied to the content of a given
812    * range. The CSS properties we look for can be found in `kFontPropsCSS`.
813    *
814    * @param  {Range} range Range to fetch style info from.
815    * @return {Object} Dictionary consisting of the styles that were found.
816    */
817   _getRangeFontStyle(range) {
818     let node = range.startContainer;
819     while (node.nodeType != 1) {
820       node = node.parentNode;
821     }
822     let style = node.ownerGlobal.getComputedStyle(node);
823     let props = {};
824     for (let prop of kFontPropsCamelCase) {
825       if (prop in style && style[prop]) {
826         props[prop] = style[prop];
827       }
828     }
829     return props;
830   },
832   /**
833    * Utility; transform a dictionary object as returned by `_getRangeFontStyle`
834    * above into a HTML style attribute value.
835    *
836    * @param  {Object} fontStyle
837    * @return {String}
838    */
839   _getHTMLFontStyle(fontStyle) {
840     let style = [];
841     for (let prop of Object.getOwnPropertyNames(fontStyle)) {
842       let idx = kFontPropsCamelCase.indexOf(prop);
843       if (idx == -1) {
844         continue;
845       }
846       style.push(`${kFontPropsCSS[idx]}: ${fontStyle[prop]}`);
847     }
848     return style.join("; ");
849   },
851   /**
852    * Transform a style definition array as defined in `kModalStyles` into a CSS
853    * string that can be used to set the 'style' property of a DOM node.
854    *
855    * @param  {Array}    stylePairs         Two-dimensional array of style pairs
856    * @param  {...Array} [additionalStyles] Optional set of style pairs that will
857    *                                       augment or override the styles defined
858    *                                       by `stylePairs`
859    * @return {String}
860    */
861   _getStyleString(stylePairs, ...additionalStyles) {
862     let baseStyle = new Map(stylePairs);
863     for (let additionalStyle of additionalStyles) {
864       for (let [prop, value] of additionalStyle) {
865         baseStyle.set(prop, value);
866       }
867     }
868     return [...baseStyle]
869       .map(([cssProp, cssVal]) => `${cssProp}: ${cssVal}`)
870       .join("; ");
871   },
873   /**
874    * Checks whether a CSS RGB color value can be classified as being 'bright'.
875    *
876    * @param  {String} cssColor RGB color value in the default format rgb[a](r,g,b)
877    * @return {Boolean}
878    */
879   _isColorBright(cssColor) {
880     cssColor = cssColor.match(kRGBRE);
881     if (!cssColor || !cssColor.length) {
882       return false;
883     }
884     cssColor.shift();
885     return !new Color(...cssColor).useBrightText;
886   },
888   /**
889    * Detects if the overall text color in the page can be described as bright.
890    * This is done according to the following algorithm:
891    *  1. With the entire set of ranges that we have found thusfar;
892    *  2. Get an odd-numbered `sampleSize`, with a maximum of `kBrightTextSampleSize`
893    *     ranges,
894    *  3. Slice the set of ranges into `sampleSize` number of equal parts,
895    *  4. Grab the first range for each slice and inspect the brightness of the
896    *     color of its text content.
897    *  5. When the majority of ranges are counted as contain bright colored text,
898    *     the page is considered to contain bright text overall.
899    *
900    * @param {Object} dict Dictionary of properties belonging to the
901    *                      currently active window. The page text color property
902    *                      will be recorded in `dict.brightText` as `true` or `false`.
903    */
904   _detectBrightText(dict) {
905     let sampleSize = Math.min(
906       dict.modalHighlightRectsMap.size,
907       kBrightTextSampleSize
908     );
909     let ranges = [...dict.modalHighlightRectsMap.keys()];
910     let rangesCount = ranges.length;
911     // Make sure the sample size is an odd number.
912     if (sampleSize % 2 == 0) {
913       // Make the previously or currently found range weigh heavier.
914       if (dict.previousFoundRange || dict.currentFoundRange) {
915         ranges.push(dict.previousFoundRange || dict.currentFoundRange);
916         ++sampleSize;
917         ++rangesCount;
918       } else {
919         --sampleSize;
920       }
921     }
922     let brightCount = 0;
923     for (let i = 0; i < sampleSize; ++i) {
924       let range = ranges[Math.floor((rangesCount / sampleSize) * i)];
925       let fontStyle = this._getRangeFontStyle(range);
926       if (this._isColorBright(fontStyle.color)) {
927         ++brightCount;
928       }
929     }
931     dict.brightText = brightCount >= Math.ceil(sampleSize / 2);
932   },
934   /**
935    * Checks if a range is inside a DOM node that's positioned in a way that it
936    * doesn't scroll along when the document is scrolled and/ or zoomed. This
937    * is the case for 'fixed' and 'sticky' positioned elements, elements inside
938    * (i)frames and elements that have their overflow styles set to 'auto' or
939    * 'scroll'.
940    *
941    * @param  {Range} range Range that be enclosed in a dynamic container
942    * @return {Boolean}
943    */
944   _isInDynamicContainer(range) {
945     const kFixed = new Set(["fixed", "sticky", "scroll", "auto"]);
946     let node = range.startContainer;
947     while (node.nodeType != 1) {
948       node = node.parentNode;
949     }
950     let document = node.ownerDocument;
951     let window = document.defaultView;
952     let dict = this.getForWindow(this.getTopWindow(window));
954     // Check if we're in a frameset (including iframes).
955     if (window != this.getTopWindow(window)) {
956       if (!dict.frames.has(window)) {
957         dict.frames.set(window, {});
958       }
959       return true;
960     }
962     do {
963       let style = window.getComputedStyle(node);
964       if (
965         kFixed.has(style.position) ||
966         kFixed.has(style.overflow) ||
967         kFixed.has(style.overflowX) ||
968         kFixed.has(style.overflowY)
969       ) {
970         return true;
971       }
972       node = node.parentNode;
973     } while (node && node != document.documentElement);
975     return false;
976   },
978   /**
979    * Read and store the rectangles that encompass the entire region of a range
980    * for use by the drawing function of the highlighter.
981    *
982    * @param  {Range}  range  Range to fetch the rectangles from
983    * @param  {Object} [dict] Dictionary of properties belonging to
984    *                         the currently active window
985    * @return {Set}    Set of rects that were found for the range
986    */
987   _getRangeRectsAndTexts(range, dict = null) {
988     let window = range.startContainer.ownerGlobal;
989     let bounds;
990     // If the window is part of a frameset, try to cache the bounds query.
991     if (dict && dict.frames.has(window)) {
992       let frameData = dict.frames.get(window);
993       bounds = frameData.bounds;
994       if (!bounds) {
995         bounds = frameData.bounds = this._getRootBounds(window);
996       }
997     } else {
998       bounds = this._getRootBounds(window);
999     }
1001     let topBounds = this._getRootBounds(this.getTopWindow(window, true), false);
1002     let rects = [];
1003     // A range may consist of multiple rectangles, we can also do these kind of
1004     // precise cut-outs. range.getBoundingClientRect() returns the fully
1005     // encompassing rectangle, which is too much for our purpose here.
1006     let { rectList, textList } = range.getClientRectsAndTexts();
1007     for (let rect of rectList) {
1008       rect = Rect.fromRect(rect);
1009       rect.x += bounds.x;
1010       rect.y += bounds.y;
1011       // If the rect is not even visible from the top document, we can ignore it.
1012       if (rect.intersects(topBounds)) {
1013         rects.push(rect);
1014       }
1015     }
1016     return { rectList: rects, textList };
1017   },
1019   /**
1020    * Read and store the rectangles that encompass the entire region of a range
1021    * for use by the drawing function of the highlighter and store them in the
1022    * cache.
1023    *
1024    * @param  {Range}   range            Range to fetch the rectangles from
1025    * @param  {Boolean} [checkIfDynamic] Whether we should check if the range
1026    *                                    is dynamic as per the rules in
1027    *                                    `_isInDynamicContainer()`. Optional,
1028    *                                    defaults to `true`
1029    * @param  {Object}  [dict]           Dictionary of properties belonging to
1030    *                                    the currently active window
1031    * @return {Set}     Set of rects that were found for the range
1032    */
1033   _updateRangeRects(range, checkIfDynamic = true, dict = null) {
1034     let window = range.startContainer.ownerGlobal;
1035     let rectsAndTexts = this._getRangeRectsAndTexts(range, dict);
1037     // Only fetch the rect at this point, if not passed in as argument.
1038     dict = dict || this.getForWindow(this.getTopWindow(window));
1039     let oldRectsAndTexts = dict.modalHighlightRectsMap.get(range);
1040     dict.modalHighlightRectsMap.set(range, rectsAndTexts);
1041     // Check here if we suddenly went down to zero rects from more than zero before,
1042     // which indicates that we should re-iterate the document.
1043     if (
1044       oldRectsAndTexts &&
1045       oldRectsAndTexts.rectList.length &&
1046       !rectsAndTexts.rectList.length
1047     ) {
1048       dict.detectedGeometryChange = true;
1049     }
1050     if (checkIfDynamic && this._isInDynamicContainer(range)) {
1051       dict.dynamicRangesSet.add(range);
1052     }
1053     return rectsAndTexts;
1054   },
1056   /**
1057    * Re-read the rectangles of the ranges that we keep track of separately,
1058    * because they're enclosed by a position: fixed container DOM node or (i)frame.
1059    *
1060    * @param {Object} dict Dictionary of properties belonging to the currently
1061    *                      active window
1062    */
1063   _updateDynamicRangesRects(dict) {
1064     // Reset the frame bounds cache.
1065     for (let frameData of dict.frames.values()) {
1066       frameData.bounds = null;
1067     }
1068     for (let range of dict.dynamicRangesSet) {
1069       this._updateRangeRects(range, false, dict);
1070     }
1071   },
1073   /**
1074    * Update the content, position and style of the yellow current found range
1075    * outline that floats atop the mask with the dimmed background.
1076    * Rebuild it, if necessary, This will deactivate the animation between
1077    * occurrences.
1078    *
1079    * @param {Object} dict Dictionary of properties belonging to the currently
1080    *                      active window
1081    */
1082   _updateRangeOutline(dict) {
1083     let range = dict.currentFoundRange;
1084     if (!range) {
1085       return;
1086     }
1088     let fontStyle = this._getRangeFontStyle(range);
1089     // Text color in the outline is determined by kModalStyles.
1090     delete fontStyle.color;
1092     let rectsAndTexts = this._updateRangeRects(range, true, dict);
1093     let outlineAnonNode = dict.modalHighlightOutline;
1094     let rectCount = rectsAndTexts.rectList.length;
1095     let previousRectCount = dict.previousRangeRectsAndTexts.rectList.length;
1096     // (re-)Building the outline is conditional and happens when one of the
1097     // following conditions is met:
1098     // 1. No outline nodes were built before, or
1099     // 2. When the amount of rectangles to draw is different from before, or
1100     // 3. When there's more than one rectangle to draw, because it's impossible
1101     //    to animate that consistently with AnonymousContent nodes.
1102     let rebuildOutline =
1103       !outlineAnonNode || rectCount !== previousRectCount || rectCount != 1;
1104     dict.previousRangeRectsAndTexts = rectsAndTexts;
1106     let window = this.getTopWindow(range.startContainer.ownerGlobal);
1107     let document = window.document;
1108     // First see if we need to and can remove the previous outline nodes.
1109     if (rebuildOutline) {
1110       this._removeRangeOutline(window);
1111     }
1113     // Abort when there's no text to highlight OR when it's the exact same range
1114     // as the previous call and isn't inside a dynamic container.
1115     if (
1116       !rectsAndTexts.textList.length ||
1117       (!rebuildOutline &&
1118         dict.previousUpdatedRange == range &&
1119         !dict.dynamicRangesSet.has(range))
1120     ) {
1121       return;
1122     }
1124     let outlineBox;
1125     if (rebuildOutline) {
1126       // Create the main (yellow) highlight outline box.
1127       outlineBox = document.createElementNS(kNSHTML, "div");
1128       outlineBox.setAttribute("id", kModalOutlineId);
1129     }
1131     const kModalOutlineTextId = kModalOutlineId + "-text";
1132     let i = 0;
1133     for (let rect of rectsAndTexts.rectList) {
1134       let text = rectsAndTexts.textList[i];
1136       // Next up is to check of the outline box' borders will not overlap with
1137       // rects that we drew before or will draw after this one.
1138       // We're taking the width of the border into account, which is
1139       // `kOutlineBoxBorderSize` pixels.
1140       // When left and/ or right sides will overlap with the current, previous
1141       // or next rect, make sure to make the necessary adjustments to the style.
1142       // These adjustments will override the styles as defined in `kModalStyles.outlineNode`.
1143       let intersectingSides = new Set();
1144       let previous = rectsAndTexts.rectList[i - 1];
1145       if (previous && rect.left - previous.right <= 2 * kOutlineBoxBorderSize) {
1146         intersectingSides.add("left");
1147       }
1148       let next = rectsAndTexts.rectList[i + 1];
1149       if (next && next.left - rect.right <= 2 * kOutlineBoxBorderSize) {
1150         intersectingSides.add("right");
1151       }
1152       let borderStyles = [...intersectingSides].map(side => [
1153         "border-" + side,
1154         0,
1155       ]);
1156       if (intersectingSides.size) {
1157         borderStyles.push([
1158           "margin",
1159           `-${kOutlineBoxBorderSize}px 0 0 ${
1160             intersectingSides.has("left") ? 0 : -kOutlineBoxBorderSize
1161           }px !important`,
1162         ]);
1163         borderStyles.push([
1164           "border-radius",
1165           (intersectingSides.has("left") ? 0 : kOutlineBoxBorderRadius) +
1166             "px " +
1167             (intersectingSides.has("right") ? 0 : kOutlineBoxBorderRadius) +
1168             "px " +
1169             (intersectingSides.has("right") ? 0 : kOutlineBoxBorderRadius) +
1170             "px " +
1171             (intersectingSides.has("left") ? 0 : kOutlineBoxBorderRadius) +
1172             "px",
1173         ]);
1174       }
1176       let outlineStyle = this._getStyleString(
1177         kModalStyles.outlineNode,
1178         [
1179           ["top", rect.top + "px"],
1180           ["left", rect.left + "px"],
1181           ["height", rect.height + "px"],
1182           ["width", rect.width + "px"],
1183         ],
1184         borderStyles,
1185         kDebug ? kModalStyles.outlineNodeDebug : []
1186       );
1187       fontStyle.lineHeight = rect.height + "px";
1188       let textStyle =
1189         this._getStyleString(kModalStyles.outlineText) +
1190         "; " +
1191         this._getHTMLFontStyle(fontStyle);
1193       if (rebuildOutline) {
1194         let textBoxParent = outlineBox.appendChild(
1195           document.createElementNS(kNSHTML, "div")
1196         );
1197         textBoxParent.setAttribute("id", kModalOutlineId + i);
1198         textBoxParent.setAttribute("style", outlineStyle);
1200         let textBox = document.createElementNS(kNSHTML, "span");
1201         textBox.setAttribute("id", kModalOutlineTextId + i);
1202         textBox.setAttribute("style", textStyle);
1203         textBox.textContent = text;
1204         textBoxParent.appendChild(textBox);
1205       } else {
1206         // Set the appropriate properties on the existing nodes, which will also
1207         // activate the transitions.
1208         outlineAnonNode.setAttributeForElement(
1209           kModalOutlineId + i,
1210           "style",
1211           outlineStyle
1212         );
1213         outlineAnonNode.setAttributeForElement(
1214           kModalOutlineTextId + i,
1215           "style",
1216           textStyle
1217         );
1218         outlineAnonNode.setTextContentForElement(kModalOutlineTextId + i, text);
1219       }
1221       ++i;
1222     }
1224     if (rebuildOutline) {
1225       dict.modalHighlightOutline = kDebug
1226         ? mockAnonymousContentNode(
1227             (document.body || document.documentElement).appendChild(outlineBox)
1228           )
1229         : document.insertAnonymousContent(outlineBox);
1230     }
1232     if (dict.animateOutline && !this._isPageTooBig(dict)) {
1233       let animation;
1234       dict.animations = new Set();
1235       for (let i = rectsAndTexts.rectList.length - 1; i >= 0; --i) {
1236         animation = dict.modalHighlightOutline.setAnimationForElement(
1237           kModalOutlineId + i,
1238           Cu.cloneInto(kModalOutlineAnim.keyframes, window),
1239           kModalOutlineAnim.duration
1240         );
1241         animation.onfinish = function() {
1242           dict.animations.delete(this);
1243         };
1244         dict.animations.add(animation);
1245       }
1246     }
1247     dict.animateOutline = false;
1248     dict.ignoreNextContentChange = true;
1250     dict.previousUpdatedRange = range;
1251   },
1253   /**
1254    * Finish any currently playing animations on the found range outline node.
1255    *
1256    * @param {Object} dict Dictionary of properties belonging to the currently
1257    *                      active window
1258    */
1259   _finishOutlineAnimations(dict) {
1260     if (!dict.animations) {
1261       return;
1262     }
1263     for (let animation of dict.animations) {
1264       animation.finish();
1265     }
1266   },
1268   /**
1269    * Safely remove the outline AnoymousContent node from the CanvasFrame.
1270    *
1271    * @param {nsIDOMWindow} window
1272    */
1273   _removeRangeOutline(window) {
1274     let dict = this.getForWindow(window);
1275     if (!dict.modalHighlightOutline) {
1276       return;
1277     }
1279     if (kDebug) {
1280       dict.modalHighlightOutline.remove();
1281     } else {
1282       try {
1283         window.document.removeAnonymousContent(dict.modalHighlightOutline);
1284       } catch (ex) {}
1285     }
1287     dict.modalHighlightOutline = null;
1288   },
1290   /**
1291    * Add a range to the list of ranges to highlight on, or cut out of, the dimmed
1292    * background.
1293    *
1294    * @param {Range}        range  Range object that should be inspected
1295    * @param {nsIDOMWindow} window Window object, whose DOM tree is being traversed
1296    */
1297   _modalHighlight(range, controller, window) {
1298     this._updateRangeRects(range);
1300     this.show(window);
1301     // We don't repaint the mask right away, but pass it off to a render loop of
1302     // sorts.
1303     this._scheduleRepaintOfMask(window);
1304   },
1306   /**
1307    * Lazily insert the nodes we need as anonymous content into the CanvasFrame
1308    * of a window.
1309    *
1310    * @param {nsIDOMWindow} window Window to draw in.
1311    */
1312   _maybeCreateModalHighlightNodes(window) {
1313     window = this.getTopWindow(window);
1314     let dict = this.getForWindow(window);
1315     if (dict.modalHighlightOutline) {
1316       if (!dict.modalHighlightAllMask) {
1317         // Make sure to at least show the dimmed background.
1318         this._repaintHighlightAllMask(window, false);
1319         this._scheduleRepaintOfMask(window);
1320       } else {
1321         this._scheduleRepaintOfMask(window, { contentChanged: true });
1322       }
1323       return;
1324     }
1326     let document = window.document;
1327     // A hidden document doesn't accept insertAnonymousContent calls yet.
1328     if (document.hidden) {
1329       let onVisibilityChange = () => {
1330         document.removeEventListener("visibilitychange", onVisibilityChange);
1331         this._maybeCreateModalHighlightNodes(window);
1332       };
1333       document.addEventListener("visibilitychange", onVisibilityChange);
1334       return;
1335     }
1337     // Make sure to at least show the dimmed background.
1338     this._repaintHighlightAllMask(window, false);
1339   },
1341   /**
1342    * Build and draw the mask that takes care of the dimmed background that
1343    * overlays the current page and the mask that cuts out all the rectangles of
1344    * the ranges that were found.
1345    *
1346    * @param {nsIDOMWindow} window Window to draw in.
1347    * @param {Boolean} [paintContent]
1348    */
1349   _repaintHighlightAllMask(window, paintContent = true) {
1350     window = this.getTopWindow(window);
1351     let dict = this.getForWindow(window);
1353     const kMaskId = kModalIdPrefix + "-findbar-modalHighlight-outlineMask";
1354     if (!dict.modalHighlightAllMask) {
1355       let document = window.document;
1356       let maskNode = document.createElementNS(kNSHTML, "div");
1357       maskNode.setAttribute("id", kMaskId);
1358       dict.modalHighlightAllMask = kDebug
1359         ? mockAnonymousContentNode(
1360             (document.body || document.documentElement).appendChild(maskNode)
1361           )
1362         : document.insertAnonymousContent(maskNode);
1363     }
1365     // Make sure the dimmed mask node takes the full width and height that's available.
1366     let {
1367       width,
1368       height,
1369     } = (dict.lastWindowDimensions = this._getWindowDimensions(window));
1370     if (typeof dict.brightText != "boolean" || dict.updateAllRanges) {
1371       this._detectBrightText(dict);
1372     }
1373     let maskStyle = this._getStyleString(
1374       kModalStyles.maskNode,
1375       [["width", width + "px"], ["height", height + "px"]],
1376       dict.brightText ? kModalStyles.maskNodeBrightText : [],
1377       paintContent ? kModalStyles.maskNodeTransition : [],
1378       kDebug ? kModalStyles.maskNodeDebug : []
1379     );
1380     dict.modalHighlightAllMask.setAttributeForElement(
1381       kMaskId,
1382       "style",
1383       maskStyle
1384     );
1386     this._updateRangeOutline(dict);
1388     let allRects = [];
1389     // When the user's busy scrolling the document, don't bother cutting out rectangles,
1390     // because they're not going to keep up with scrolling speed anyway.
1391     if (!dict.busyScrolling && (paintContent || dict.modalHighlightAllMask)) {
1392       // No need to update dynamic ranges separately when we already about to
1393       // update all of them anyway.
1394       if (!dict.updateAllRanges) {
1395         this._updateDynamicRangesRects(dict);
1396       }
1398       let DOMRect = window.DOMRect;
1399       for (let [range, rectsAndTexts] of dict.modalHighlightRectsMap) {
1400         if (!this.finder._fastFind.isRangeVisible(range, false)) {
1401           continue;
1402         }
1404         if (dict.updateAllRanges) {
1405           rectsAndTexts = this._updateRangeRects(range);
1406         }
1408         // If a geometry change was detected, we bail out right away here, because
1409         // the current set of ranges has been invalidated.
1410         if (dict.detectedGeometryChange) {
1411           return;
1412         }
1414         for (let rect of rectsAndTexts.rectList) {
1415           allRects.push(new DOMRect(rect.x, rect.y, rect.width, rect.height));
1416         }
1417       }
1418       dict.updateAllRanges = false;
1419     }
1421     // We may also want to cut out zero rects, which effectively clears out the mask.
1422     dict.modalHighlightAllMask.setCutoutRectsForElement(kMaskId, allRects);
1424     // The reflow observer may ignore the reflow we cause ourselves here.
1425     dict.ignoreNextContentChange = true;
1426   },
1428   /**
1429    * Safely remove the mask AnoymousContent node from the CanvasFrame.
1430    *
1431    * @param {nsIDOMWindow} window
1432    */
1433   _removeHighlightAllMask(window) {
1434     window = this.getTopWindow(window);
1435     let dict = this.getForWindow(window);
1436     if (!dict.modalHighlightAllMask) {
1437       return;
1438     }
1440     // If the current window isn't the one the content was inserted into, this
1441     // will fail, but that's fine.
1442     if (kDebug) {
1443       dict.modalHighlightAllMask.remove();
1444     } else {
1445       try {
1446         window.document.removeAnonymousContent(dict.modalHighlightAllMask);
1447       } catch (ex) {}
1448     }
1449     dict.modalHighlightAllMask = null;
1450   },
1452   /**
1453    * Check if the width or height of the current document is too big to handle
1454    * for certain operations. This allows us to degrade gracefully when we expect
1455    * the performance to be negatively impacted due to drawing-intensive operations.
1456    *
1457    * @param  {Object} dict Dictionary of properties belonging to the currently
1458    *                       active window
1459    * @return {Boolean}
1460    */
1461   _isPageTooBig(dict) {
1462     let { height, width } = dict.lastWindowDimensions;
1463     return height >= kPageIsTooBigPx || width >= kPageIsTooBigPx;
1464   },
1466   /**
1467    * Doing a full repaint each time a range is delivered by the highlight iterator
1468    * is way too costly, thus we pipe the frequency down to every
1469    * `kModalHighlightRepaintLoFreqMs` milliseconds. If there are dynamic ranges
1470    * found (see `_isInDynamicContainer()` for the definition), the frequency
1471    * will be upscaled to `kModalHighlightRepaintHiFreqMs`.
1472    *
1473    * @param {nsIDOMWindow} window
1474    * @param {Object}       options Dictionary of painter hints that contains the
1475    *                               following properties:
1476    *   {Boolean} contentChanged  Whether the documents' content changed in the
1477    *                             meantime. This happens when the DOM is updated
1478    *                             whilst the page is loaded.
1479    *   {Boolean} scrollOnly      TRUE when the page has scrolled in the meantime,
1480    *                             which means that the dynamically positioned
1481    *                             elements need to be repainted.
1482    *   {Boolean} updateAllRanges Whether to recalculate the rects of all ranges
1483    *                             that were found up until now.
1484    */
1485   _scheduleRepaintOfMask(
1486     window,
1487     { contentChanged = false, scrollOnly = false, updateAllRanges = false } = {}
1488   ) {
1489     if (!this.useModal()) {
1490       return;
1491     }
1493     window = this.getTopWindow(window);
1494     let dict = this.getForWindow(window);
1495     // Bail out early if the repaint scheduler is paused or when we're supposed
1496     // to ignore the next paint (i.e. content change).
1497     if (
1498       dict.repaintSchedulerState == kRepaintSchedulerPaused ||
1499       (contentChanged && dict.ignoreNextContentChange)
1500     ) {
1501       dict.ignoreNextContentChange = false;
1502       return;
1503     }
1505     let hasDynamicRanges = !!dict.dynamicRangesSet.size;
1506     let pageIsTooBig = this._isPageTooBig(dict);
1507     let repaintDynamicRanges =
1508       (scrollOnly || contentChanged) && hasDynamicRanges && !pageIsTooBig;
1510     // Determine scroll behavior and keep that state around.
1511     let startedScrolling = !dict.busyScrolling && scrollOnly;
1512     // When the user started scrolling the document, hide the other highlights.
1513     if (startedScrolling) {
1514       dict.busyScrolling = startedScrolling;
1515       this._repaintHighlightAllMask(window);
1516     }
1517     // Whilst scrolling, suspend the repaint scheduler, but only when the page is
1518     // too big or the find results contains ranges that are inside dynamic
1519     // containers.
1520     if (dict.busyScrolling && (pageIsTooBig || hasDynamicRanges)) {
1521       dict.ignoreNextContentChange = true;
1522       this._updateRangeOutline(dict);
1523       // NB: we're not using `kRepaintSchedulerPaused` on purpose here, otherwise
1524       // we'd break the `busyScrolling` detection (re-)using the timer.
1525       if (dict.modalRepaintScheduler) {
1526         window.clearTimeout(dict.modalRepaintScheduler);
1527         dict.modalRepaintScheduler = null;
1528       }
1529     }
1531     // When we request to repaint unconditionally, we mean to call
1532     // `_repaintHighlightAllMask()` right after the timeout.
1533     if (!dict.unconditionalRepaintRequested) {
1534       dict.unconditionalRepaintRequested =
1535         !contentChanged || repaintDynamicRanges;
1536     }
1537     // Some events, like a resize, call for recalculation of all the rects of all ranges.
1538     if (!dict.updateAllRanges) {
1539       dict.updateAllRanges = updateAllRanges;
1540     }
1542     if (dict.modalRepaintScheduler) {
1543       return;
1544     }
1546     let timeoutMs =
1547       hasDynamicRanges && !dict.busyScrolling
1548         ? kModalHighlightRepaintHiFreqMs
1549         : kModalHighlightRepaintLoFreqMs;
1550     dict.modalRepaintScheduler = window.setTimeout(() => {
1551       dict.modalRepaintScheduler = null;
1552       dict.repaintSchedulerState = kRepaintSchedulerStopped;
1553       dict.busyScrolling = false;
1555       let pageContentChanged = dict.detectedGeometryChange;
1556       if (!pageContentChanged && !pageIsTooBig) {
1557         let {
1558           width: previousWidth,
1559           height: previousHeight,
1560         } = dict.lastWindowDimensions;
1561         let {
1562           width,
1563           height,
1564         } = (dict.lastWindowDimensions = this._getWindowDimensions(window));
1565         pageContentChanged =
1566           dict.detectedGeometryChange ||
1567           (Math.abs(previousWidth - width) > kContentChangeThresholdPx ||
1568             Math.abs(previousHeight - height) > kContentChangeThresholdPx);
1569       }
1570       dict.detectedGeometryChange = false;
1571       // When the page has changed significantly enough in size, we'll restart
1572       // the iterator with the same parameters as before to find us new ranges.
1573       if (pageContentChanged && !pageIsTooBig) {
1574         this.iterator.restart(this.finder);
1575       }
1577       if (
1578         dict.unconditionalRepaintRequested ||
1579         (dict.modalHighlightRectsMap.size && pageContentChanged)
1580       ) {
1581         dict.unconditionalRepaintRequested = false;
1582         this._repaintHighlightAllMask(window);
1583       }
1584     }, timeoutMs);
1585     dict.repaintSchedulerState = kRepaintSchedulerRunning;
1586   },
1588   /**
1589    * Add event listeners to the content which will cause the modal highlight
1590    * AnonymousContent to be re-painted or hidden.
1591    *
1592    * @param {nsIDOMWindow} window
1593    */
1594   _addModalHighlightListeners(window) {
1595     window = this.getTopWindow(window);
1596     let dict = this.getForWindow(window);
1597     if (dict.highlightListeners) {
1598       return;
1599     }
1601     dict.highlightListeners = [
1602       this._scheduleRepaintOfMask.bind(this, window, { contentChanged: true }),
1603       this._scheduleRepaintOfMask.bind(this, window, { updateAllRanges: true }),
1604       this._scheduleRepaintOfMask.bind(this, window, { scrollOnly: true }),
1605       this.hide.bind(this, window, null),
1606       () => (dict.busySelecting = true),
1607       () => {
1608         if (window.document.hidden) {
1609           dict.repaintSchedulerState = kRepaintSchedulerPaused;
1610         } else if (dict.repaintSchedulerState == kRepaintSchedulerPaused) {
1611           dict.repaintSchedulerState = kRepaintSchedulerRunning;
1612           this._scheduleRepaintOfMask(window);
1613         }
1614       },
1615     ];
1616     let target = this.iterator._getDocShell(window).chromeEventHandler;
1617     target.addEventListener("MozAfterPaint", dict.highlightListeners[0]);
1618     target.addEventListener("resize", dict.highlightListeners[1]);
1619     target.addEventListener("scroll", dict.highlightListeners[2], {
1620       capture: true,
1621       passive: true,
1622     });
1623     target.addEventListener("click", dict.highlightListeners[3]);
1624     target.addEventListener("selectstart", dict.highlightListeners[4]);
1625     window.document.addEventListener(
1626       "visibilitychange",
1627       dict.highlightListeners[5]
1628     );
1629   },
1631   /**
1632    * Remove event listeners from content.
1633    *
1634    * @param {nsIDOMWindow} window
1635    */
1636   _removeModalHighlightListeners(window) {
1637     window = this.getTopWindow(window);
1638     let dict = this.getForWindow(window);
1639     if (!dict.highlightListeners) {
1640       return;
1641     }
1643     let target = this.iterator._getDocShell(window).chromeEventHandler;
1644     target.removeEventListener("MozAfterPaint", dict.highlightListeners[0]);
1645     target.removeEventListener("resize", dict.highlightListeners[1]);
1646     target.removeEventListener("scroll", dict.highlightListeners[2], {
1647       capture: true,
1648       passive: true,
1649     });
1650     target.removeEventListener("click", dict.highlightListeners[3]);
1651     target.removeEventListener("selectstart", dict.highlightListeners[4]);
1652     window.document.removeEventListener(
1653       "visibilitychange",
1654       dict.highlightListeners[5]
1655     );
1657     dict.highlightListeners = null;
1658   },
1660   /**
1661    * For a given node returns its editable parent or null if there is none.
1662    * It's enough to check if node is a text node and its parent's parent is
1663    * an input or textarea.
1664    *
1665    * @param node the node we want to check
1666    * @returns the first node in the parent chain that is editable,
1667    *          null if there is no such node
1668    */
1669   _getEditableNode(node) {
1670     if (
1671       node.nodeType === node.TEXT_NODE &&
1672       node.parentNode &&
1673       node.parentNode.parentNode &&
1674       (ChromeUtils.getClassName(node.parentNode.parentNode) ===
1675         "HTMLInputElement" ||
1676         ChromeUtils.getClassName(node.parentNode.parentNode) ===
1677           "HTMLTextAreaElement")
1678     ) {
1679       return node.parentNode.parentNode;
1680     }
1681     return null;
1682   },
1684   /**
1685    * Add ourselves as an nsIEditActionListener and nsIDocumentStateListener for
1686    * a given editor
1687    *
1688    * @param editor the editor we'd like to listen to
1689    */
1690   _addEditorListeners(editor) {
1691     if (!this._editors) {
1692       this._editors = [];
1693       this._stateListeners = [];
1694     }
1696     let existingIndex = this._editors.indexOf(editor);
1697     if (existingIndex == -1) {
1698       let x = this._editors.length;
1699       this._editors[x] = editor;
1700       this._stateListeners[x] = this._createStateListener();
1701       this._editors[x].addEditActionListener(this);
1702       this._editors[x].addDocumentStateListener(this._stateListeners[x]);
1703     }
1704   },
1706   /**
1707    * Helper method to unhook listeners, remove cached editors
1708    * and keep the relevant arrays in sync
1709    *
1710    * @param idx the index into the array of editors/state listeners
1711    *        we wish to remove
1712    */
1713   _unhookListenersAtIndex(idx) {
1714     this._editors[idx].removeEditActionListener(this);
1715     this._editors[idx].removeDocumentStateListener(this._stateListeners[idx]);
1716     this._editors.splice(idx, 1);
1717     this._stateListeners.splice(idx, 1);
1718     if (!this._editors.length) {
1719       delete this._editors;
1720       delete this._stateListeners;
1721     }
1722   },
1724   /**
1725    * Remove ourselves as an nsIEditActionListener and
1726    * nsIDocumentStateListener from a given cached editor
1727    *
1728    * @param editor the editor we no longer wish to listen to
1729    */
1730   _removeEditorListeners(editor) {
1731     // editor is an editor that we listen to, so therefore must be
1732     // cached. Find the index of this editor
1733     let idx = this._editors.indexOf(editor);
1734     if (idx == -1) {
1735       return;
1736     }
1737     // Now unhook ourselves, and remove our cached copy
1738     this._unhookListenersAtIndex(idx);
1739   },
1741   /*
1742    * nsIEditActionListener logic follows
1743    *
1744    * We implement this interface to allow us to catch the case where
1745    * the findbar found a match in a HTML <input> or <textarea>. If the
1746    * user adjusts the text in some way, it will no longer match, so we
1747    * want to remove the highlight, rather than have it expand/contract
1748    * when letters are added or removed.
1749    */
1751   /**
1752    * Helper method used to check whether a selection intersects with
1753    * some highlighting
1754    *
1755    * @param selectionRange the range from the selection to check
1756    * @param findRange the highlighted range to check against
1757    * @returns true if they intersect, false otherwise
1758    */
1759   _checkOverlap(selectionRange, findRange) {
1760     if (!selectionRange || !findRange) {
1761       return false;
1762     }
1763     // The ranges overlap if one of the following is true:
1764     // 1) At least one of the endpoints of the deleted selection
1765     //    is in the find selection
1766     // 2) At least one of the endpoints of the find selection
1767     //    is in the deleted selection
1768     if (
1769       findRange.isPointInRange(
1770         selectionRange.startContainer,
1771         selectionRange.startOffset
1772       )
1773     ) {
1774       return true;
1775     }
1776     if (
1777       findRange.isPointInRange(
1778         selectionRange.endContainer,
1779         selectionRange.endOffset
1780       )
1781     ) {
1782       return true;
1783     }
1784     if (
1785       selectionRange.isPointInRange(
1786         findRange.startContainer,
1787         findRange.startOffset
1788       )
1789     ) {
1790       return true;
1791     }
1792     if (
1793       selectionRange.isPointInRange(findRange.endContainer, findRange.endOffset)
1794     ) {
1795       return true;
1796     }
1798     return false;
1799   },
1801   /**
1802    * Helper method to determine if an edit occurred within a highlight
1803    *
1804    * @param selection the selection we wish to check
1805    * @param node the node we want to check is contained in selection
1806    * @param offset the offset into node that we want to check
1807    * @returns the range containing (node, offset) or null if no ranges
1808    *          in the selection contain it
1809    */
1810   _findRange(selection, node, offset) {
1811     let rangeCount = selection.rangeCount;
1812     let rangeidx = 0;
1813     let foundContainingRange = false;
1814     let range = null;
1816     // Check to see if this node is inside one of the selection's ranges
1817     while (!foundContainingRange && rangeidx < rangeCount) {
1818       range = selection.getRangeAt(rangeidx);
1819       if (range.isPointInRange(node, offset)) {
1820         foundContainingRange = true;
1821         break;
1822       }
1823       rangeidx++;
1824     }
1826     if (foundContainingRange) {
1827       return range;
1828     }
1830     return null;
1831   },
1833   // Start of nsIEditActionListener implementations
1835   WillDeleteText(textNode, offset, length) {
1836     let editor = this._getEditableNode(textNode).editor;
1837     let controller = editor.selectionController;
1838     let fSelection = controller.getSelection(
1839       Ci.nsISelectionController.SELECTION_FIND
1840     );
1841     let range = this._findRange(fSelection, textNode, offset);
1843     if (range) {
1844       // Don't remove the highlighting if the deleted text is at the
1845       // end of the range
1846       if (textNode != range.endContainer || offset != range.endOffset) {
1847         // Text within the highlight is being removed - the text can
1848         // no longer be a match, so remove the highlighting
1849         fSelection.removeRange(range);
1850         if (fSelection.rangeCount == 0) {
1851           this._removeEditorListeners(editor);
1852         }
1853       }
1854     }
1855   },
1857   DidInsertText(textNode, offset, aString) {
1858     let editor = this._getEditableNode(textNode).editor;
1859     let controller = editor.selectionController;
1860     let fSelection = controller.getSelection(
1861       Ci.nsISelectionController.SELECTION_FIND
1862     );
1863     let range = this._findRange(fSelection, textNode, offset);
1865     if (range) {
1866       // If the text was inserted before the highlight
1867       // adjust the highlight's bounds accordingly
1868       if (textNode == range.startContainer && offset == range.startOffset) {
1869         range.setStart(
1870           range.startContainer,
1871           range.startOffset + aString.length
1872         );
1873       } else if (textNode != range.endContainer || offset != range.endOffset) {
1874         // The edit occurred within the highlight - any addition of text
1875         // will result in the text no longer being a match,
1876         // so remove the highlighting
1877         fSelection.removeRange(range);
1878         if (fSelection.rangeCount == 0) {
1879           this._removeEditorListeners(editor);
1880         }
1881       }
1882     }
1883   },
1885   WillDeleteSelection(selection) {
1886     let editor = this._getEditableNode(selection.getRangeAt(0).startContainer)
1887       .editor;
1888     let controller = editor.selectionController;
1889     let fSelection = controller.getSelection(
1890       Ci.nsISelectionController.SELECTION_FIND
1891     );
1893     let shouldDelete = {};
1894     let numberOfDeletedSelections = 0;
1895     let numberOfMatches = fSelection.rangeCount;
1897     // We need to test if any ranges in the deleted selection (selection)
1898     // are in any of the ranges of the find selection
1899     // Usually both selections will only contain one range, however
1900     // either may contain more than one.
1902     for (let fIndex = 0; fIndex < numberOfMatches; fIndex++) {
1903       shouldDelete[fIndex] = false;
1904       let fRange = fSelection.getRangeAt(fIndex);
1906       for (let index = 0; index < selection.rangeCount; index++) {
1907         if (shouldDelete[fIndex]) {
1908           continue;
1909         }
1911         let selRange = selection.getRangeAt(index);
1912         let doesOverlap = this._checkOverlap(selRange, fRange);
1913         if (doesOverlap) {
1914           shouldDelete[fIndex] = true;
1915           numberOfDeletedSelections++;
1916         }
1917       }
1918     }
1920     // OK, so now we know what matches (if any) are in the selection
1921     // that is being deleted. Time to remove them.
1922     if (!numberOfDeletedSelections) {
1923       return;
1924     }
1926     for (let i = numberOfMatches - 1; i >= 0; i--) {
1927       if (shouldDelete[i]) {
1928         fSelection.removeRange(fSelection.getRangeAt(i));
1929       }
1930     }
1932     // Remove listeners if no more highlights left
1933     if (!fSelection.rangeCount) {
1934       this._removeEditorListeners(editor);
1935     }
1936   },
1938   /*
1939    * nsIDocumentStateListener logic follows
1940    *
1941    * When attaching nsIEditActionListeners, there are no guarantees
1942    * as to whether the findbar or the documents in the browser will get
1943    * destructed first. This leads to the potential to either leak, or to
1944    * hold on to a reference an editable element's editor for too long,
1945    * preventing it from being destructed.
1946    *
1947    * However, when an editor's owning node is being destroyed, the editor
1948    * sends out a DocumentWillBeDestroyed notification. We can use this to
1949    * clean up our references to the object, to allow it to be destroyed in a
1950    * timely fashion.
1951    */
1953   /**
1954    * Unhook ourselves when one of our state listeners has been called.
1955    * This can happen in 4 cases:
1956    *  1) The document the editor belongs to is navigated away from, and
1957    *     the document is not being cached
1958    *
1959    *  2) The document the editor belongs to is expired from the cache
1960    *
1961    *  3) The tab containing the owning document is closed
1962    *
1963    *  4) The <input> or <textarea> that owns the editor is explicitly
1964    *     removed from the DOM
1965    *
1966    * @param the listener that was invoked
1967    */
1968   _onEditorDestruction(aListener) {
1969     // First find the index of the editor the given listener listens to.
1970     // The listeners and editors arrays must always be in sync.
1971     // The listener will be in our array of cached listeners, as this
1972     // method could not have been called otherwise.
1973     let idx = 0;
1974     while (this._stateListeners[idx] != aListener) {
1975       idx++;
1976     }
1978     // Unhook both listeners
1979     this._unhookListenersAtIndex(idx);
1980   },
1982   /**
1983    * Creates a unique document state listener for an editor.
1984    *
1985    * It is not possible to simply have the findbar implement the
1986    * listener interface itself, as it wouldn't have sufficient information
1987    * to work out which editor was being destroyed. Therefore, we create new
1988    * listeners on the fly, and cache them in sync with the editors they
1989    * listen to.
1990    */
1991   _createStateListener() {
1992     return {
1993       findbar: this,
1995       QueryInterface: ChromeUtils.generateQI(["nsIDocumentStateListener"]),
1997       NotifyDocumentWillBeDestroyed() {
1998         this.findbar._onEditorDestruction(this);
1999       },
2001       // Unimplemented
2002       notifyDocumentCreated() {},
2003       notifyDocumentStateChanged(aDirty) {},
2004     };
2005   },