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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
9 ChromeUtils.defineESModuleGetters(lazy, {
10 Color: "resource://gre/modules/Color.sys.mjs",
11 Rect: "resource://gre/modules/Geometry.sys.mjs",
13 XPCOMUtils.defineLazyGetter(lazy, "kDebug", () => {
14 const kDebugPref = "findbar.modalHighlight.debug";
16 Services.prefs.getPrefType(kDebugPref) &&
17 Services.prefs.getBoolPref(kDebugPref)
21 const kContentChangeThresholdPx = 5;
22 const kBrightTextSampleSize = 5;
23 // This limit is arbitrary and doesn't scale for low-powered machines or
24 // high-powered machines. Netbooks will probably need a much lower limit, for
25 // example. Though getting something out there is better than nothing.
26 const kPageIsTooBigPx = 500000;
27 const kModalHighlightRepaintLoFreqMs = 100;
28 const kModalHighlightRepaintHiFreqMs = 16;
29 const kHighlightAllPref = "findbar.highlightAll";
30 const kModalHighlightPref = "findbar.modalHighlight";
31 const kFontPropsCSS = [
47 const kFontPropsCamelCase = kFontPropsCSS.map(prop => {
48 let parts = prop.split("-");
51 parts.map(part => part.charAt(0).toUpperCase() + part.slice(1)).join("")
54 const kRGBRE = /^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*/i;
55 // This uuid is used to prefix HTML element IDs in order to make them unique and
56 // hard to clash with IDs content authors come up with.
57 const kModalIdPrefix = "cedee4d0-74c5-4f2d-ab43-4d37c0f9d463";
58 const kModalOutlineId = kModalIdPrefix + "-findbar-modalHighlight-outline";
59 const kOutlineBoxColor = "255,197,53";
60 const kOutlineBoxBorderSize = 1;
61 const kOutlineBoxBorderRadius = 2;
62 const kModalStyles = {
64 ["background-color", `rgb(${kOutlineBoxColor})`],
65 ["background-clip", "padding-box"],
66 ["border", `${kOutlineBoxBorderSize}px solid rgba(${kOutlineBoxColor},.7)`],
67 ["border-radius", `${kOutlineBoxBorderRadius}px`],
68 ["box-shadow", `0 2px 0 0 rgba(0,0,0,.1)`],
73 `-${kOutlineBoxBorderSize}px 0 0 -${kOutlineBoxBorderSize}px !important`,
75 ["overflow", "hidden"],
76 ["pointer-events", "none"],
77 ["position", "absolute"],
78 ["white-space", "nowrap"],
79 ["will-change", "transform"],
82 outlineNodeDebug: [["z-index", 2147483647]],
84 ["margin", "0 !important"],
85 ["padding", "0 !important"],
86 ["vertical-align", "top !important"],
89 ["background", "rgba(0,0,0,.25)"],
90 ["pointer-events", "none"],
91 ["position", "absolute"],
94 maskNodeTransition: [["transition", "background .2s ease-in"]],
96 ["z-index", 2147483646],
100 maskNodeBrightText: [["background", "rgba(255,255,255,.25)"]],
102 const kModalOutlineAnim = {
104 { transform: "scaleX(1) scaleY(1)" },
105 { transform: "scaleX(1.5) scaleY(1.5)", offset: 0.5, easing: "ease-in" },
106 { transform: "scaleX(1) scaleY(1)" },
110 const kNSHTML = "http://www.w3.org/1999/xhtml";
111 const kRepaintSchedulerStopped = 1;
112 const kRepaintSchedulerPaused = 2;
113 const kRepaintSchedulerRunning = 3;
115 function mockAnonymousContentNode(domNode) {
117 setTextContentForElement(id, text) {
118 (domNode.querySelector("#" + id) || domNode).textContent = text;
120 getAttributeForElement(id, attrName) {
121 let node = domNode.querySelector("#" + id) || domNode;
122 if (!node.hasAttribute(attrName)) {
125 return node.getAttribute(attrName);
127 setAttributeForElement(id, attrName, attrValue) {
128 (domNode.querySelector("#" + id) || domNode).setAttribute(
133 removeAttributeForElement(id, attrName) {
134 let node = domNode.querySelector("#" + id) || domNode;
135 if (!node.hasAttribute(attrName)) {
138 node.removeAttribute(attrName);
145 setAnimationForElement(id, keyframes, duration) {
146 return (domNode.querySelector("#" + id) || domNode).animate(
151 setCutoutRectsForElement(id, rects) {
157 let gWindows = new WeakMap();
160 * FinderHighlighter class that is used by Finder.sys.mjs to take care of the
161 * 'Highlight All' feature, which can highlight all find occurrences in a page.
163 * @param {Finder} finder Finder.sys.mjs instance
164 * @param {boolean} useTop check and use top-level windows for rectangle
165 * computation, if possible.
167 export function FinderHighlighter(finder, useTop = false) {
168 this._highlightAll = Services.prefs.getBoolPref(kHighlightAllPref);
169 this._modal = Services.prefs.getBoolPref(kModalHighlightPref);
170 this._useSubFrames = false;
171 this._useTop = useTop;
172 this._marksListener = null;
173 this._testing = false;
174 this.finder = finder;
177 FinderHighlighter.prototype = {
179 return this.finder.iterator;
182 enableTesting(enable) {
183 this._testing = enable;
186 // Get the top-most window when allowed. When out-of-process frames are used,
187 // this will usually be the same as the passed-in window. The checkUseTop
188 // argument can be used to instead check the _useTop flag which can be used
189 // to enable rectangle coordinate checks.
190 getTopWindow(window, checkUseTop) {
191 if (this._useSubFrames || (checkUseTop && this._useTop)) {
201 // Modal highlighting is currently only enabled when there are no
202 // out-of-process subframes.
203 return this._modal && this._useSubFrames;
207 * Each window is unique, globally, and the relation between an active
208 * highlighting session and a window is 1:1.
209 * For each window we track a number of properties which _at least_ consist of
210 * - {Boolean} detectedGeometryChange Whether the geometry of the found ranges'
211 * rectangles has changed substantially
212 * - {Set} dynamicRangesSet Set of ranges that may move around, depending
213 * on page layout changes and user input
214 * - {Map} frames Collection of frames that were encountered
215 * when inspecting the found ranges
216 * - {Map} modalHighlightRectsMap Collection of ranges and their corresponding
219 * @param {nsIDOMWindow} window
222 getForWindow(window, propName = null) {
223 if (!gWindows.has(window)) {
224 gWindows.set(window, {
225 detectedGeometryChange: false,
226 dynamicRangesSet: new Set(),
228 lastWindowDimensions: { width: 0, height: 0 },
229 modalHighlightRectsMap: new Map(),
230 previousRangeRectsAndTexts: { rectList: [], textList: [] },
231 repaintSchedulerState: kRepaintSchedulerStopped,
234 return gWindows.get(window);
238 * Notify all registered listeners that the 'Highlight All' operation finished.
240 * @param {Boolean} highlight Whether highlighting was turned on
242 notifyFinished(highlight) {
243 for (let l of this.finder._listeners) {
245 l.onHighlightFinished(highlight);
251 * Toggle highlighting all occurrences of a word in a page. This method will
252 * be called recursively for each (i)frame inside a page.
254 * @param {Booolean} highlight Whether highlighting should be turned on
255 * @param {String} word Needle to search for and highlight when found
256 * @param {Boolean} linksOnly Only consider nodes that are links for the search
257 * @param {Boolean} drawOutline Whether found links should be outlined.
258 * @param {Boolean} useSubFrames Whether to iterate over subframes.
259 * @yield {Promise} that resolves once the operation has finished
261 async highlight(highlight, word, linksOnly, drawOutline, useSubFrames) {
262 let window = this.finder._getWindow();
263 let dict = this.getForWindow(window);
264 let controller = this.finder._getSelectionController(window);
265 let doc = window.document;
267 this._useSubFrames = useSubFrames;
269 let result = { searchString: word, highlight, found: false };
271 if (!controller || !doc || !doc.documentElement) {
272 // Without the selection controller,
273 // we are unable to (un)highlight any matches
280 caseSensitive: this.finder._fastFind.caseSensitive,
281 entireWord: this.finder._fastFind.entireWord,
286 matchDiacritics: this.finder._fastFind.matchDiacritics,
292 this.iterator.isAlreadyRunning(params) ||
294 this.iterator._areParamsEqual(params, dict.lastIteratorParams))
299 if (!this.useModal()) {
302 await this.iterator.start(params);
304 this.finder._outlineLink(drawOutline);
309 // Removing the highlighting always succeeds, so return true.
313 result.found = this._found;
314 this.notifyFinished(result);
318 // FinderIterator listener implementation
320 onIteratorRangeFound(range) {
321 this.highlightRange(range);
325 onIteratorReset() {},
327 onIteratorRestart() {
328 this.clear(this.finder._getWindow());
331 onIteratorStart(params) {
332 let window = this.finder._getWindow();
333 let dict = this.getForWindow(window);
334 // Save a clean params set for use later in the `update()` method.
335 dict.lastIteratorParams = params;
336 if (!this.useModal()) {
337 this.hide(window, this.finder._fastFind.getFoundRange());
343 * Add a range to the find selection, i.e. highlight it, and if it's inside an
344 * editable node, track it.
346 * @param {Range} range Range object to be highlighted
348 highlightRange(range) {
349 let node = range.startContainer;
350 let editableNode = this._getEditableNode(node);
351 let window = node.ownerGlobal;
352 let controller = this.finder._getSelectionController(window);
354 controller = editableNode.editor.selectionController;
357 if (this.useModal()) {
358 this._modalHighlight(range, controller, window);
360 let findSelection = controller.getSelection(
361 Ci.nsISelectionController.SELECTION_FIND
363 findSelection.addRange(range);
364 // Check if the range is inside an (i)frame.
365 if (window != this.getTopWindow(window)) {
366 let dict = this.getForWindow(this.getTopWindow(window));
367 // Add this frame to the list, so that we'll be able to find it later
368 // when we need to clear its selection(s).
369 dict.frames.set(window, {});
374 // Highlighting added, so cache this editor, and hook up listeners
375 // to ensure we deal properly with edits within the highlighting
376 this._addEditorListeners(editableNode.editor);
381 * If modal highlighting is enabled, show the dimmed background that will overlay
384 * @param {nsIDOMWindow} window The dimmed background will overlay this window.
385 * Optional, defaults to the finder window.
387 show(window = null) {
388 window = this.getTopWindow(window || this.finder._getWindow());
389 let dict = this.getForWindow(window);
390 if (!this.useModal() || dict.visible) {
396 this._maybeCreateModalHighlightNodes(window);
397 this._addModalHighlightListeners(window);
401 * Clear all highlighted matches. If modal highlighting is enabled and
402 * the outline + dimmed background is currently visible, both will be hidden.
404 * @param {nsIDOMWindow} window The dimmed background will overlay this window.
405 * Optional, defaults to the finder window.
406 * @param {Range} skipRange A range that should not be removed from the
408 * @param {Event} event When called from an event handler, this will
409 * be the triggering event.
411 hide(window, skipRange = null, event = null) {
413 window = this.getTopWindow(window);
418 let dict = this.getForWindow(window);
420 let isBusySelecting = dict.busySelecting;
421 dict.busySelecting = false;
422 // Do not hide on anything but a left-click.
425 event.type == "click" &&
426 (event.button !== 0 ||
431 event.relatedTarget ||
433 (event.target.localName == "a" && event.target.href))
438 this._clearSelection(
439 this.finder._getSelectionController(window),
442 for (let frame of dict.frames.keys()) {
443 this._clearSelection(
444 this.finder._getSelectionController(frame),
449 // Next, check our editor cache, for editors belonging to this
452 let doc = window.document;
453 for (let x = this._editors.length - 1; x >= 0; --x) {
454 if (this._editors[x].document == doc) {
455 this._clearSelection(this._editors[x].selectionController, skipRange);
456 // We don't need to listen to this editor any more
457 this._unhookListenersAtIndex(x);
462 if (dict.modalRepaintScheduler) {
463 window.clearTimeout(dict.modalRepaintScheduler);
464 dict.modalRepaintScheduler = null;
465 dict.repaintSchedulerState = kRepaintSchedulerStopped;
467 dict.lastWindowDimensions = { width: 0, height: 0 };
469 this._removeRangeOutline(window);
470 this._removeHighlightAllMask(window);
471 this._removeModalHighlightListeners(window);
473 dict.visible = false;
477 * Called by the Finder after a find result comes in; update the position and
478 * content of the outline to the newly found occurrence.
479 * To make sure that the outline covers the found range completely, all the
480 * CSS styles that influence the text are copied and applied to the outline.
482 * @param {Object} data Dictionary coming from Finder that contains the
483 * following properties:
484 * {Number} result One of the nsITypeAheadFind.FIND_* constants
485 * indicating the result of a search operation.
486 * {Boolean} findBackwards If TRUE, the search was performed backwards,
488 * {Boolean} findAgain If TRUE, the search was performed using the same
489 * search string as before.
490 * {String} linkURL If a link was hit, this will contain a URL string.
491 * {Rect} rect An object with top, left, width and height
492 * coordinates of the current selection.
493 * {String} searchString The string the search was performed with.
494 * {Boolean} storeResult Indicator if the search string should be stored
495 * by the consumer of the Finder.
497 async update(data, foundInThisFrame) {
498 let window = this.finder._getWindow();
499 let dict = this.getForWindow(window);
500 let foundRange = this.finder._fastFind.getFoundRange();
503 data.result == Ci.nsITypeAheadFind.FIND_NOTFOUND ||
504 !data.searchString ||
505 (foundInThisFrame && !foundRange)
511 this._useSubFrames = data.useSubFrames;
512 if (!this.useModal()) {
513 if (this._highlightAll) {
514 dict.previousFoundRange = dict.currentFoundRange;
515 dict.currentFoundRange = foundRange;
516 let params = this.iterator.params;
519 this.iterator._areParamsEqual(params, dict.lastIteratorParams)
523 if (!dict.visible && !params) {
524 params = { word: data.searchString, linksOnly: data.linksOnly };
527 await this.highlight(
539 dict.animateOutline = true;
540 // Immediately finish running animations, if any.
541 this._finishOutlineAnimations(dict);
543 if (foundRange !== dict.currentFoundRange || data.findAgain) {
544 dict.previousFoundRange = dict.currentFoundRange;
545 dict.currentFoundRange = foundRange;
550 this._maybeCreateModalHighlightNodes(window);
554 if (this._highlightAll) {
555 await this.highlight(
566 * Invalidates the list by clearing the map of highlighted ranges that we
567 * keep to build the mask for.
569 clear(window = null) {
570 if (!window || !this.getTopWindow(window)) {
574 let dict = this.getForWindow(this.getTopWindow(window));
575 this._finishOutlineAnimations(dict);
576 dict.dynamicRangesSet.clear();
578 dict.modalHighlightRectsMap.clear();
579 dict.brightText = null;
583 * Removes the outline from a single window. This is done when
584 * switching the current search to a new frame.
586 clearCurrentOutline(window = null) {
587 let dict = this.getForWindow(this.getTopWindow(window));
588 this._finishOutlineAnimations(dict);
589 this._removeRangeOutline(window);
592 // Update the tick marks that should appear on the page's scrollbar(s).
593 updateScrollMarks() {
594 // Only show scrollbar marks when normal highlighting is enabled.
595 if (this.useModal() || !this._highlightAll) {
596 this.removeScrollMarks();
600 let marks = new Set(); // Use a set so duplicate values are removed.
601 let window = this.finder._getWindow();
602 // Show the marks on the horizontal scrollbar for vertical writing modes.
603 let onHorizontalScrollbar = !window
604 .getComputedStyle(window.document.body || window.document.documentElement)
605 .writingMode.startsWith("horizontal");
606 let yStart = window.scrollY - window.scrollMinY;
607 let xStart = window.scrollX - window.scrollMinX;
609 let hasRanges = false;
611 let controllers = [this.finder._getSelectionController(window)];
612 let editors = this.editors;
614 // Add the selection controllers from any input fields.
615 controllers.push(...editors.map(editor => editor.selectionController));
618 for (let controller of controllers) {
619 let findSelection = controller.getSelection(
620 Ci.nsISelectionController.SELECTION_FIND
623 let rangeCount = findSelection.rangeCount;
624 if (rangeCount > 0) {
628 // No need to calculate the mark positions if there is no visible scrollbar.
629 if (window.scrollMaxY > window.scrollMinY && !onHorizontalScrollbar) {
630 // Use the body's scrollHeight if available.
632 window.document.body?.scrollHeight ||
633 window.document.documentElement.scrollHeight;
634 let yAdj = (window.scrollMaxY - window.scrollMinY) / scrollHeight;
636 for (let r = 0; r < rangeCount; r++) {
637 let rect = findSelection.getRangeAt(r).getBoundingClientRect();
638 let yPos = Math.round((yStart + rect.y + rect.height / 2) * yAdj); // use the midpoint
642 window.scrollMaxX > window.scrollMinX &&
643 onHorizontalScrollbar
645 // Use the body's scrollWidth if available.
647 window.document.body?.scrollWidth ||
648 window.document.documentElement.scrollWidth;
649 let xAdj = (window.scrollMaxX - window.scrollMinX) / scrollWidth;
651 for (let r = 0; r < rangeCount; r++) {
652 let rect = findSelection.getRangeAt(r).getBoundingClientRect();
653 let xPos = Math.round((xStart + rect.x + rect.width / 2) * xAdj);
661 // Assign the marks to the window and add a listener for the MozScrolledAreaChanged
662 // event which fires whenever the scrollable area's size is updated.
663 this.setScrollMarks(window, Array.from(marks), onHorizontalScrollbar);
665 if (!this._marksListener) {
666 this._marksListener = event => {
667 this.updateScrollMarks();
670 window.addEventListener(
671 "MozScrolledAreaChanged",
675 window.addEventListener("resize", this._marksListener);
677 } else if (this._marksListener) {
678 // No results were found so remove any existing ones and the MozScrolledAreaChanged listener.
679 this.removeScrollMarks();
683 removeScrollMarks() {
686 window = this.finder._getWindow();
688 // An exception can happen after changing remoteness but this
689 // would have deleted the marks anyway.
693 if (this._marksListener) {
694 window.removeEventListener(
695 "MozScrolledAreaChanged",
699 window.removeEventListener("resize", this._marksListener);
700 this._marksListener = null;
702 this.setScrollMarks(window, []);
706 * Set the scrollbar marks for a current search. If testing mode is enabled, fire a
707 * find-scrollmarks-changed event at the window.
709 * @param window window to set the scrollbar marks on
710 * @param marks array of integer scrollbar mark positions
711 * @param onHorizontalScrollbar whether to display the marks on the horizontal scrollbar
713 setScrollMarks(window, marks, onHorizontalScrollbar = false) {
714 window.setScrollMarks(marks, onHorizontalScrollbar);
716 // Fire an event containing the found mark values if testing mode is enabled.
718 window.dispatchEvent(
719 new CustomEvent("find-scrollmarks-changed", {
721 marks: Array.from(marks),
722 onHorizontalScrollbar,
730 * When the current page is refreshed or navigated away from, the CanvasFrame
731 * contents is not valid anymore, i.e. all anonymous content is destroyed.
732 * We need to clear the references we keep, which'll make sure we redraw
733 * everything when the user starts to find in page again.
736 let window = this.finder._getWindow();
737 if (!window || !this.getTopWindow(window)) {
742 this._removeRangeOutline(window);
744 gWindows.delete(this.getTopWindow(window));
748 * When `kModalHighlightPref` pref changed during a session, this callback is
749 * invoked. When modal highlighting is turned off, we hide the CanvasFrame
752 * @param {Boolean} useModalHighlight
754 onModalHighlightChange(useModalHighlight) {
755 let window = this.finder._getWindow();
756 if (window && this.useModal() && !useModalHighlight) {
760 this._modal = useModalHighlight;
761 this.updateScrollMarks();
765 * When 'Highlight All' is toggled during a session, this callback is invoked
766 * and when it's turned off, the found occurrences will be removed from the mask.
768 * @param {Boolean} highlightAll
770 onHighlightAllChange(highlightAll) {
771 this._highlightAll = highlightAll;
773 let window = this.finder._getWindow();
774 if (!this.useModal()) {
778 this._scheduleRepaintOfMask(window);
781 this.updateScrollMarks();
785 * Utility; removes all ranges from the find selection that belongs to a
786 * controller. Optionally skips a specific range.
788 * @param {nsISelectionController} controller
789 * @param {Range} restoreRange
791 _clearSelection(controller, restoreRange = null) {
795 let sel = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
796 sel.removeAllRanges();
798 sel = controller.getSelection(Ci.nsISelectionController.SELECTION_NORMAL);
799 sel.addRange(restoreRange);
800 controller.setDisplaySelection(
801 Ci.nsISelectionController.SELECTION_ATTENTION
803 controller.repaintSelection(Ci.nsISelectionController.SELECTION_NORMAL);
808 * Utility; get the nsIDOMWindowUtils for a window.
810 * @param {nsIDOMWindow} window Optional, defaults to the finder window.
811 * @return {nsIDOMWindowUtils}
813 _getDWU(window = null) {
814 return (window || this.finder._getWindow()).windowUtils;
818 * Utility; returns the bounds of the page relative to the viewport.
819 * If the pages is part of a frameset or inside an iframe of any kind, its
820 * offset is accounted for.
821 * Geometry.sys.mjs takes care of the DOMRect calculations.
823 * @param {nsIDOMWindow} window Window to read the boundary rect from
824 * @param {Boolean} [includeScroll] Whether to ignore the scroll offset,
825 * which is useful for comparing DOMRects.
826 * Optional, defaults to `true`
829 _getRootBounds(window, includeScroll = true) {
830 let dwu = this._getDWU(this.getTopWindow(window, true));
831 let cssPageRect = lazy.Rect.fromRect(dwu.getRootBounds());
834 if (includeScroll && window == this.getTopWindow(window, true)) {
835 dwu.getScrollXY(false, scrollX, scrollY);
836 cssPageRect.translate(scrollX.value, scrollY.value);
839 // If we're in a frame, update the position of the rect (top/ left).
840 let currWin = window;
841 while (currWin != this.getTopWindow(window, true)) {
842 let frameOffsets = this._getFrameElementOffsets(currWin);
843 cssPageRect.translate(frameOffsets.x, frameOffsets.y);
845 // Since the frame is an element inside a parent window, we'd like to
846 // learn its position relative to it.
847 let el = currWin.browsingContext.embedderElement;
848 currWin = currWin.parent;
849 dwu = this._getDWU(currWin);
850 let parentRect = lazy.Rect.fromRect(dwu.getBoundsWithoutFlushing(el));
853 dwu.getScrollXY(false, scrollX, scrollY);
854 parentRect.translate(scrollX.value, scrollY.value);
855 // If the current window is an iframe with scrolling="no" and its parent
856 // is also an iframe the scroll offsets from the parents' documentElement
857 // (inverse scroll position) needs to be subtracted from the parent
860 el.getAttribute("scrolling") == "no" &&
861 currWin != this.getTopWindow(window, true)
863 let docEl = currWin.document.documentElement;
864 parentRect.translate(-docEl.scrollLeft, -docEl.scrollTop);
868 cssPageRect.translate(parentRect.left, parentRect.top);
870 let frameOffsets = this._getFrameElementOffsets(currWin);
871 cssPageRect.translate(frameOffsets.x, frameOffsets.y);
877 * (I)Frame elements may have a border and/ or padding set, which is not
878 * included in the bounds returned by nsDOMWindowUtils#getRootBounds() for the
880 * This method fetches this offset of the frame element to the respective window.
882 * @param {nsIDOMWindow} window Window to read the boundary rect from
883 * @return {Object} Simple object that contains the following two properties:
884 * - {Number} x Offset along the horizontal axis.
885 * - {Number} y Offset along the vertical axis.
887 _getFrameElementOffsets(window) {
888 let frame = window.frameElement;
890 return { x: 0, y: 0 };
893 // Getting style info is super expensive, causing reflows, so let's cache
894 // frame border widths and padding values aggressively.
895 let dict = this.getForWindow(this.getTopWindow(window, true));
896 let frameData = dict.frames.get(window);
898 dict.frames.set(window, (frameData = {}));
900 if (frameData.offset) {
901 return frameData.offset;
904 let style = frame.ownerGlobal.getComputedStyle(frame);
905 // We only need to left sides, because ranges are offset from point 0,0 in
906 // the top-left corner.
908 parseInt(style.borderLeftWidth, 10) || 0,
909 parseInt(style.borderTopWidth, 10) || 0,
911 let paddingOffset = [
912 parseInt(style.paddingLeft, 10) || 0,
913 parseInt(style.paddingTop, 10) || 0,
915 return (frameData.offset = {
916 x: borderOffset[0] + paddingOffset[0],
917 y: borderOffset[1] + paddingOffset[1],
922 * Utility; fetch the full width and height of the current window, excluding
925 * @param {nsiDOMWindow} window The current finder window.
926 * @return {Object} The current full page dimensions with `width` and `height`
929 _getWindowDimensions(window) {
930 // First we'll try without flushing layout, because it's way faster.
931 let dwu = this._getDWU(window);
932 let { width, height } = dwu.getRootBounds();
934 if (!width || !height) {
935 // We need a flush after all :'(
936 width = window.innerWidth + window.scrollMaxX - window.scrollMinX;
937 height = window.innerHeight + window.scrollMaxY - window.scrollMinY;
939 let scrollbarHeight = {};
940 let scrollbarWidth = {};
941 dwu.getScrollbarSize(false, scrollbarWidth, scrollbarHeight);
942 width -= scrollbarWidth.value;
943 height -= scrollbarHeight.value;
946 return { width, height };
950 * Utility; get all available font styles as applied to the content of a given
951 * range. The CSS properties we look for can be found in `kFontPropsCSS`.
953 * @param {Range} range Range to fetch style info from.
954 * @return {Object} Dictionary consisting of the styles that were found.
956 _getRangeFontStyle(range) {
957 let node = range.startContainer;
958 while (node.nodeType != 1) {
959 node = node.parentNode;
961 let style = node.ownerGlobal.getComputedStyle(node);
963 for (let prop of kFontPropsCamelCase) {
964 if (prop in style && style[prop]) {
965 props[prop] = style[prop];
972 * Utility; transform a dictionary object as returned by `_getRangeFontStyle`
973 * above into a HTML style attribute value.
975 * @param {Object} fontStyle
978 _getHTMLFontStyle(fontStyle) {
980 for (let prop of Object.getOwnPropertyNames(fontStyle)) {
981 let idx = kFontPropsCamelCase.indexOf(prop);
985 style.push(`${kFontPropsCSS[idx]}: ${fontStyle[prop]}`);
987 return style.join("; ");
991 * Transform a style definition array as defined in `kModalStyles` into a CSS
992 * string that can be used to set the 'style' property of a DOM node.
994 * @param {Array} stylePairs Two-dimensional array of style pairs
995 * @param {...Array} [additionalStyles] Optional set of style pairs that will
996 * augment or override the styles defined
1000 _getStyleString(stylePairs, ...additionalStyles) {
1001 let baseStyle = new Map(stylePairs);
1002 for (let additionalStyle of additionalStyles) {
1003 for (let [prop, value] of additionalStyle) {
1004 baseStyle.set(prop, value);
1007 return [...baseStyle]
1008 .map(([cssProp, cssVal]) => `${cssProp}: ${cssVal}`)
1013 * Checks whether a CSS RGB color value can be classified as being 'bright'.
1015 * @param {String} cssColor RGB color value in the default format rgb[a](r,g,b)
1018 _isColorBright(cssColor) {
1019 cssColor = cssColor.match(kRGBRE);
1020 if (!cssColor || !cssColor.length) {
1024 return !new lazy.Color(...cssColor).useBrightText;
1028 * Detects if the overall text color in the page can be described as bright.
1029 * This is done according to the following algorithm:
1030 * 1. With the entire set of ranges that we have found thusfar;
1031 * 2. Get an odd-numbered `sampleSize`, with a maximum of `kBrightTextSampleSize`
1033 * 3. Slice the set of ranges into `sampleSize` number of equal parts,
1034 * 4. Grab the first range for each slice and inspect the brightness of the
1035 * color of its text content.
1036 * 5. When the majority of ranges are counted as contain bright colored text,
1037 * the page is considered to contain bright text overall.
1039 * @param {Object} dict Dictionary of properties belonging to the
1040 * currently active window. The page text color property
1041 * will be recorded in `dict.brightText` as `true` or `false`.
1043 _detectBrightText(dict) {
1044 let sampleSize = Math.min(
1045 dict.modalHighlightRectsMap.size,
1046 kBrightTextSampleSize
1048 let ranges = [...dict.modalHighlightRectsMap.keys()];
1049 let rangesCount = ranges.length;
1050 // Make sure the sample size is an odd number.
1051 if (sampleSize % 2 == 0) {
1052 // Make the previously or currently found range weigh heavier.
1053 if (dict.previousFoundRange || dict.currentFoundRange) {
1054 ranges.push(dict.previousFoundRange || dict.currentFoundRange);
1061 let brightCount = 0;
1062 for (let i = 0; i < sampleSize; ++i) {
1063 let range = ranges[Math.floor((rangesCount / sampleSize) * i)];
1064 let fontStyle = this._getRangeFontStyle(range);
1065 if (this._isColorBright(fontStyle.color)) {
1070 dict.brightText = brightCount >= Math.ceil(sampleSize / 2);
1074 * Checks if a range is inside a DOM node that's positioned in a way that it
1075 * doesn't scroll along when the document is scrolled and/ or zoomed. This
1076 * is the case for 'fixed' and 'sticky' positioned elements, elements inside
1077 * (i)frames and elements that have their overflow styles set to 'auto' or
1080 * @param {Range} range Range that be enclosed in a dynamic container
1083 _isInDynamicContainer(range) {
1084 const kFixed = new Set(["fixed", "sticky", "scroll", "auto"]);
1085 let node = range.startContainer;
1086 while (node.nodeType != 1) {
1087 node = node.parentNode;
1089 let document = node.ownerDocument;
1090 let window = document.defaultView;
1091 let dict = this.getForWindow(this.getTopWindow(window));
1093 // Check if we're in a frameset (including iframes).
1094 if (window != this.getTopWindow(window)) {
1095 if (!dict.frames.has(window)) {
1096 dict.frames.set(window, {});
1102 let style = window.getComputedStyle(node);
1104 kFixed.has(style.position) ||
1105 kFixed.has(style.overflow) ||
1106 kFixed.has(style.overflowX) ||
1107 kFixed.has(style.overflowY)
1111 node = node.parentNode;
1112 } while (node && node != document.documentElement);
1118 * Read and store the rectangles that encompass the entire region of a range
1119 * for use by the drawing function of the highlighter.
1121 * @param {Range} range Range to fetch the rectangles from
1122 * @param {Object} [dict] Dictionary of properties belonging to
1123 * the currently active window
1124 * @return {Set} Set of rects that were found for the range
1126 _getRangeRectsAndTexts(range, dict = null) {
1127 let window = range.startContainer.ownerGlobal;
1129 // If the window is part of a frameset, try to cache the bounds query.
1130 if (dict && dict.frames.has(window)) {
1131 let frameData = dict.frames.get(window);
1132 bounds = frameData.bounds;
1134 bounds = frameData.bounds = this._getRootBounds(window);
1137 bounds = this._getRootBounds(window);
1140 let topBounds = this._getRootBounds(this.getTopWindow(window, true), false);
1142 // A range may consist of multiple rectangles, we can also do these kind of
1143 // precise cut-outs. range.getBoundingClientRect() returns the fully
1144 // encompassing rectangle, which is too much for our purpose here.
1145 let { rectList, textList } = range.getClientRectsAndTexts();
1146 for (let rect of rectList) {
1147 rect = lazy.Rect.fromRect(rect);
1150 // If the rect is not even visible from the top document, we can ignore it.
1151 if (rect.intersects(topBounds)) {
1155 return { rectList: rects, textList };
1159 * Read and store the rectangles that encompass the entire region of a range
1160 * for use by the drawing function of the highlighter and store them in the
1163 * @param {Range} range Range to fetch the rectangles from
1164 * @param {Boolean} [checkIfDynamic] Whether we should check if the range
1165 * is dynamic as per the rules in
1166 * `_isInDynamicContainer()`. Optional,
1167 * defaults to `true`
1168 * @param {Object} [dict] Dictionary of properties belonging to
1169 * the currently active window
1170 * @return {Set} Set of rects that were found for the range
1172 _updateRangeRects(range, checkIfDynamic = true, dict = null) {
1173 let window = range.startContainer.ownerGlobal;
1174 let rectsAndTexts = this._getRangeRectsAndTexts(range, dict);
1176 // Only fetch the rect at this point, if not passed in as argument.
1177 dict = dict || this.getForWindow(this.getTopWindow(window));
1178 let oldRectsAndTexts = dict.modalHighlightRectsMap.get(range);
1179 dict.modalHighlightRectsMap.set(range, rectsAndTexts);
1180 // Check here if we suddenly went down to zero rects from more than zero before,
1181 // which indicates that we should re-iterate the document.
1184 oldRectsAndTexts.rectList.length &&
1185 !rectsAndTexts.rectList.length
1187 dict.detectedGeometryChange = true;
1189 if (checkIfDynamic && this._isInDynamicContainer(range)) {
1190 dict.dynamicRangesSet.add(range);
1192 return rectsAndTexts;
1196 * Re-read the rectangles of the ranges that we keep track of separately,
1197 * because they're enclosed by a position: fixed container DOM node or (i)frame.
1199 * @param {Object} dict Dictionary of properties belonging to the currently
1202 _updateDynamicRangesRects(dict) {
1203 // Reset the frame bounds cache.
1204 for (let frameData of dict.frames.values()) {
1205 frameData.bounds = null;
1207 for (let range of dict.dynamicRangesSet) {
1208 this._updateRangeRects(range, false, dict);
1213 * Update the content, position and style of the yellow current found range
1214 * outline that floats atop the mask with the dimmed background.
1215 * Rebuild it, if necessary, This will deactivate the animation between
1218 * @param {Object} dict Dictionary of properties belonging to the currently
1221 _updateRangeOutline(dict) {
1222 let range = dict.currentFoundRange;
1227 let fontStyle = this._getRangeFontStyle(range);
1228 // Text color in the outline is determined by kModalStyles.
1229 delete fontStyle.color;
1231 let rectsAndTexts = this._updateRangeRects(range, true, dict);
1232 let outlineAnonNode = dict.modalHighlightOutline;
1233 let rectCount = rectsAndTexts.rectList.length;
1234 let previousRectCount = dict.previousRangeRectsAndTexts.rectList.length;
1235 // (re-)Building the outline is conditional and happens when one of the
1236 // following conditions is met:
1237 // 1. No outline nodes were built before, or
1238 // 2. When the amount of rectangles to draw is different from before, or
1239 // 3. When there's more than one rectangle to draw, because it's impossible
1240 // to animate that consistently with AnonymousContent nodes.
1241 let rebuildOutline =
1242 !outlineAnonNode || rectCount !== previousRectCount || rectCount != 1;
1243 dict.previousRangeRectsAndTexts = rectsAndTexts;
1245 let window = this.getTopWindow(range.startContainer.ownerGlobal);
1246 let document = window.document;
1247 // First see if we need to and can remove the previous outline nodes.
1248 if (rebuildOutline) {
1249 this._removeRangeOutline(window);
1252 // Abort when there's no text to highlight OR when it's the exact same range
1253 // as the previous call and isn't inside a dynamic container.
1255 !rectsAndTexts.textList.length ||
1257 dict.previousUpdatedRange == range &&
1258 !dict.dynamicRangesSet.has(range))
1264 if (rebuildOutline) {
1265 // Create the main (yellow) highlight outline box.
1266 outlineBox = document.createElementNS(kNSHTML, "div");
1267 outlineBox.setAttribute("id", kModalOutlineId);
1270 const kModalOutlineTextId = kModalOutlineId + "-text";
1272 for (let rect of rectsAndTexts.rectList) {
1273 let text = rectsAndTexts.textList[i];
1275 // Next up is to check of the outline box' borders will not overlap with
1276 // rects that we drew before or will draw after this one.
1277 // We're taking the width of the border into account, which is
1278 // `kOutlineBoxBorderSize` pixels.
1279 // When left and/ or right sides will overlap with the current, previous
1280 // or next rect, make sure to make the necessary adjustments to the style.
1281 // These adjustments will override the styles as defined in `kModalStyles.outlineNode`.
1282 let intersectingSides = new Set();
1283 let previous = rectsAndTexts.rectList[i - 1];
1284 if (previous && rect.left - previous.right <= 2 * kOutlineBoxBorderSize) {
1285 intersectingSides.add("left");
1287 let next = rectsAndTexts.rectList[i + 1];
1288 if (next && next.left - rect.right <= 2 * kOutlineBoxBorderSize) {
1289 intersectingSides.add("right");
1291 let borderStyles = [...intersectingSides].map(side => [
1295 if (intersectingSides.size) {
1298 `-${kOutlineBoxBorderSize}px 0 0 ${
1299 intersectingSides.has("left") ? 0 : -kOutlineBoxBorderSize
1304 (intersectingSides.has("left") ? 0 : kOutlineBoxBorderRadius) +
1306 (intersectingSides.has("right") ? 0 : kOutlineBoxBorderRadius) +
1308 (intersectingSides.has("right") ? 0 : kOutlineBoxBorderRadius) +
1310 (intersectingSides.has("left") ? 0 : kOutlineBoxBorderRadius) +
1315 let outlineStyle = this._getStyleString(
1316 kModalStyles.outlineNode,
1318 ["top", rect.top + "px"],
1319 ["left", rect.left + "px"],
1320 ["height", rect.height + "px"],
1321 ["width", rect.width + "px"],
1324 lazy.kDebug ? kModalStyles.outlineNodeDebug : []
1326 fontStyle.lineHeight = rect.height + "px";
1328 this._getStyleString(kModalStyles.outlineText) +
1330 this._getHTMLFontStyle(fontStyle);
1332 if (rebuildOutline) {
1333 let textBoxParent = outlineBox.appendChild(
1334 document.createElementNS(kNSHTML, "div")
1336 textBoxParent.setAttribute("id", kModalOutlineId + i);
1337 textBoxParent.setAttribute("style", outlineStyle);
1339 let textBox = document.createElementNS(kNSHTML, "span");
1340 textBox.setAttribute("id", kModalOutlineTextId + i);
1341 textBox.setAttribute("style", textStyle);
1342 textBox.textContent = text;
1343 textBoxParent.appendChild(textBox);
1345 // Set the appropriate properties on the existing nodes, which will also
1346 // activate the transitions.
1347 outlineAnonNode.setAttributeForElement(
1348 kModalOutlineId + i,
1352 outlineAnonNode.setAttributeForElement(
1353 kModalOutlineTextId + i,
1357 outlineAnonNode.setTextContentForElement(kModalOutlineTextId + i, text);
1363 if (rebuildOutline) {
1364 dict.modalHighlightOutline = lazy.kDebug
1365 ? mockAnonymousContentNode(
1366 (document.body || document.documentElement).appendChild(outlineBox)
1368 : document.insertAnonymousContent(outlineBox);
1371 if (dict.animateOutline && !this._isPageTooBig(dict)) {
1373 dict.animations = new Set();
1374 for (let i = rectsAndTexts.rectList.length - 1; i >= 0; --i) {
1375 animation = dict.modalHighlightOutline.setAnimationForElement(
1376 kModalOutlineId + i,
1377 Cu.cloneInto(kModalOutlineAnim.keyframes, window),
1378 kModalOutlineAnim.duration
1380 animation.onfinish = function () {
1381 dict.animations.delete(this);
1383 dict.animations.add(animation);
1386 dict.animateOutline = false;
1387 dict.ignoreNextContentChange = true;
1389 dict.previousUpdatedRange = range;
1393 * Finish any currently playing animations on the found range outline node.
1395 * @param {Object} dict Dictionary of properties belonging to the currently
1398 _finishOutlineAnimations(dict) {
1399 if (!dict.animations) {
1402 for (let animation of dict.animations) {
1408 * Safely remove the outline AnoymousContent node from the CanvasFrame.
1410 * @param {nsIDOMWindow} window
1412 _removeRangeOutline(window) {
1413 let dict = this.getForWindow(window);
1414 if (!dict.modalHighlightOutline) {
1419 dict.modalHighlightOutline.remove();
1422 window.document.removeAnonymousContent(dict.modalHighlightOutline);
1426 dict.modalHighlightOutline = null;
1430 * Add a range to the list of ranges to highlight on, or cut out of, the dimmed
1433 * @param {Range} range Range object that should be inspected
1434 * @param {nsIDOMWindow} window Window object, whose DOM tree is being traversed
1436 _modalHighlight(range, controller, window) {
1437 this._updateRangeRects(range);
1440 // We don't repaint the mask right away, but pass it off to a render loop of
1442 this._scheduleRepaintOfMask(window);
1446 * Lazily insert the nodes we need as anonymous content into the CanvasFrame
1449 * @param {nsIDOMWindow} window Window to draw in.
1451 _maybeCreateModalHighlightNodes(window) {
1452 window = this.getTopWindow(window);
1453 let dict = this.getForWindow(window);
1454 if (dict.modalHighlightOutline) {
1455 if (!dict.modalHighlightAllMask) {
1456 // Make sure to at least show the dimmed background.
1457 this._repaintHighlightAllMask(window, false);
1458 this._scheduleRepaintOfMask(window);
1460 this._scheduleRepaintOfMask(window, { contentChanged: true });
1465 let document = window.document;
1466 // A hidden document doesn't accept insertAnonymousContent calls yet.
1467 if (document.hidden) {
1468 let onVisibilityChange = () => {
1469 document.removeEventListener("visibilitychange", onVisibilityChange);
1470 this._maybeCreateModalHighlightNodes(window);
1472 document.addEventListener("visibilitychange", onVisibilityChange);
1476 // Make sure to at least show the dimmed background.
1477 this._repaintHighlightAllMask(window, false);
1481 * Build and draw the mask that takes care of the dimmed background that
1482 * overlays the current page and the mask that cuts out all the rectangles of
1483 * the ranges that were found.
1485 * @param {nsIDOMWindow} window Window to draw in.
1486 * @param {Boolean} [paintContent]
1488 _repaintHighlightAllMask(window, paintContent = true) {
1489 window = this.getTopWindow(window);
1490 let dict = this.getForWindow(window);
1492 const kMaskId = kModalIdPrefix + "-findbar-modalHighlight-outlineMask";
1493 if (!dict.modalHighlightAllMask) {
1494 let document = window.document;
1495 let maskNode = document.createElementNS(kNSHTML, "div");
1496 maskNode.setAttribute("id", kMaskId);
1497 dict.modalHighlightAllMask = lazy.kDebug
1498 ? mockAnonymousContentNode(
1499 (document.body || document.documentElement).appendChild(maskNode)
1501 : document.insertAnonymousContent(maskNode);
1504 // Make sure the dimmed mask node takes the full width and height that's available.
1505 let { width, height } = (dict.lastWindowDimensions =
1506 this._getWindowDimensions(window));
1507 if (typeof dict.brightText != "boolean" || dict.updateAllRanges) {
1508 this._detectBrightText(dict);
1510 let maskStyle = this._getStyleString(
1511 kModalStyles.maskNode,
1513 ["width", width + "px"],
1514 ["height", height + "px"],
1516 dict.brightText ? kModalStyles.maskNodeBrightText : [],
1517 paintContent ? kModalStyles.maskNodeTransition : [],
1518 lazy.kDebug ? kModalStyles.maskNodeDebug : []
1520 dict.modalHighlightAllMask.setAttributeForElement(
1526 this._updateRangeOutline(dict);
1529 // When the user's busy scrolling the document, don't bother cutting out rectangles,
1530 // because they're not going to keep up with scrolling speed anyway.
1531 if (!dict.busyScrolling && (paintContent || dict.modalHighlightAllMask)) {
1532 // No need to update dynamic ranges separately when we already about to
1533 // update all of them anyway.
1534 if (!dict.updateAllRanges) {
1535 this._updateDynamicRangesRects(dict);
1538 let DOMRect = window.DOMRect;
1539 for (let [range, rectsAndTexts] of dict.modalHighlightRectsMap) {
1540 if (!this.finder._fastFind.isRangeVisible(range, false)) {
1544 if (dict.updateAllRanges) {
1545 rectsAndTexts = this._updateRangeRects(range);
1548 // If a geometry change was detected, we bail out right away here, because
1549 // the current set of ranges has been invalidated.
1550 if (dict.detectedGeometryChange) {
1554 for (let rect of rectsAndTexts.rectList) {
1555 allRects.push(new DOMRect(rect.x, rect.y, rect.width, rect.height));
1558 dict.updateAllRanges = false;
1561 // We may also want to cut out zero rects, which effectively clears out the mask.
1562 dict.modalHighlightAllMask.setCutoutRectsForElement(kMaskId, allRects);
1564 // The reflow observer may ignore the reflow we cause ourselves here.
1565 dict.ignoreNextContentChange = true;
1569 * Safely remove the mask AnoymousContent node from the CanvasFrame.
1571 * @param {nsIDOMWindow} window
1573 _removeHighlightAllMask(window) {
1574 window = this.getTopWindow(window);
1575 let dict = this.getForWindow(window);
1576 if (!dict.modalHighlightAllMask) {
1580 // If the current window isn't the one the content was inserted into, this
1581 // will fail, but that's fine.
1583 dict.modalHighlightAllMask.remove();
1586 window.document.removeAnonymousContent(dict.modalHighlightAllMask);
1589 dict.modalHighlightAllMask = null;
1593 * Check if the width or height of the current document is too big to handle
1594 * for certain operations. This allows us to degrade gracefully when we expect
1595 * the performance to be negatively impacted due to drawing-intensive operations.
1597 * @param {Object} dict Dictionary of properties belonging to the currently
1601 _isPageTooBig(dict) {
1602 let { height, width } = dict.lastWindowDimensions;
1603 return height >= kPageIsTooBigPx || width >= kPageIsTooBigPx;
1607 * Doing a full repaint each time a range is delivered by the highlight iterator
1608 * is way too costly, thus we pipe the frequency down to every
1609 * `kModalHighlightRepaintLoFreqMs` milliseconds. If there are dynamic ranges
1610 * found (see `_isInDynamicContainer()` for the definition), the frequency
1611 * will be upscaled to `kModalHighlightRepaintHiFreqMs`.
1613 * @param {nsIDOMWindow} window
1614 * @param {Object} options Dictionary of painter hints that contains the
1615 * following properties:
1616 * {Boolean} contentChanged Whether the documents' content changed in the
1617 * meantime. This happens when the DOM is updated
1618 * whilst the page is loaded.
1619 * {Boolean} scrollOnly TRUE when the page has scrolled in the meantime,
1620 * which means that the dynamically positioned
1621 * elements need to be repainted.
1622 * {Boolean} updateAllRanges Whether to recalculate the rects of all ranges
1623 * that were found up until now.
1625 _scheduleRepaintOfMask(
1627 { contentChanged = false, scrollOnly = false, updateAllRanges = false } = {}
1629 if (!this.useModal()) {
1633 window = this.getTopWindow(window);
1634 let dict = this.getForWindow(window);
1635 // Bail out early if the repaint scheduler is paused or when we're supposed
1636 // to ignore the next paint (i.e. content change).
1638 dict.repaintSchedulerState == kRepaintSchedulerPaused ||
1639 (contentChanged && dict.ignoreNextContentChange)
1641 dict.ignoreNextContentChange = false;
1645 let hasDynamicRanges = !!dict.dynamicRangesSet.size;
1646 let pageIsTooBig = this._isPageTooBig(dict);
1647 let repaintDynamicRanges =
1648 (scrollOnly || contentChanged) && hasDynamicRanges && !pageIsTooBig;
1650 // Determine scroll behavior and keep that state around.
1651 let startedScrolling = !dict.busyScrolling && scrollOnly;
1652 // When the user started scrolling the document, hide the other highlights.
1653 if (startedScrolling) {
1654 dict.busyScrolling = startedScrolling;
1655 this._repaintHighlightAllMask(window);
1657 // Whilst scrolling, suspend the repaint scheduler, but only when the page is
1658 // too big or the find results contains ranges that are inside dynamic
1660 if (dict.busyScrolling && (pageIsTooBig || hasDynamicRanges)) {
1661 dict.ignoreNextContentChange = true;
1662 this._updateRangeOutline(dict);
1663 // NB: we're not using `kRepaintSchedulerPaused` on purpose here, otherwise
1664 // we'd break the `busyScrolling` detection (re-)using the timer.
1665 if (dict.modalRepaintScheduler) {
1666 window.clearTimeout(dict.modalRepaintScheduler);
1667 dict.modalRepaintScheduler = null;
1671 // When we request to repaint unconditionally, we mean to call
1672 // `_repaintHighlightAllMask()` right after the timeout.
1673 if (!dict.unconditionalRepaintRequested) {
1674 dict.unconditionalRepaintRequested =
1675 !contentChanged || repaintDynamicRanges;
1677 // Some events, like a resize, call for recalculation of all the rects of all ranges.
1678 if (!dict.updateAllRanges) {
1679 dict.updateAllRanges = updateAllRanges;
1682 if (dict.modalRepaintScheduler) {
1687 hasDynamicRanges && !dict.busyScrolling
1688 ? kModalHighlightRepaintHiFreqMs
1689 : kModalHighlightRepaintLoFreqMs;
1690 dict.modalRepaintScheduler = window.setTimeout(() => {
1691 dict.modalRepaintScheduler = null;
1692 dict.repaintSchedulerState = kRepaintSchedulerStopped;
1693 dict.busyScrolling = false;
1695 let pageContentChanged = dict.detectedGeometryChange;
1696 if (!pageContentChanged && !pageIsTooBig) {
1697 let { width: previousWidth, height: previousHeight } =
1698 dict.lastWindowDimensions;
1699 let { width, height } = (dict.lastWindowDimensions =
1700 this._getWindowDimensions(window));
1701 pageContentChanged =
1702 dict.detectedGeometryChange ||
1703 Math.abs(previousWidth - width) > kContentChangeThresholdPx ||
1704 Math.abs(previousHeight - height) > kContentChangeThresholdPx;
1706 dict.detectedGeometryChange = false;
1707 // When the page has changed significantly enough in size, we'll restart
1708 // the iterator with the same parameters as before to find us new ranges.
1709 if (pageContentChanged && !pageIsTooBig) {
1710 this.iterator.restart(this.finder);
1714 dict.unconditionalRepaintRequested ||
1715 (dict.modalHighlightRectsMap.size && pageContentChanged)
1717 dict.unconditionalRepaintRequested = false;
1718 this._repaintHighlightAllMask(window);
1721 dict.repaintSchedulerState = kRepaintSchedulerRunning;
1725 * Add event listeners to the content which will cause the modal highlight
1726 * AnonymousContent to be re-painted or hidden.
1728 * @param {nsIDOMWindow} window
1730 _addModalHighlightListeners(window) {
1731 window = this.getTopWindow(window);
1732 let dict = this.getForWindow(window);
1733 if (dict.highlightListeners) {
1737 dict.highlightListeners = [
1738 this._scheduleRepaintOfMask.bind(this, window, { contentChanged: true }),
1739 this._scheduleRepaintOfMask.bind(this, window, { updateAllRanges: true }),
1740 this._scheduleRepaintOfMask.bind(this, window, { scrollOnly: true }),
1741 this.hide.bind(this, window, null),
1742 () => (dict.busySelecting = true),
1744 if (window.document.hidden) {
1745 dict.repaintSchedulerState = kRepaintSchedulerPaused;
1746 } else if (dict.repaintSchedulerState == kRepaintSchedulerPaused) {
1747 dict.repaintSchedulerState = kRepaintSchedulerRunning;
1748 this._scheduleRepaintOfMask(window);
1752 let target = this.iterator._getDocShell(window).chromeEventHandler;
1753 target.addEventListener("MozAfterPaint", dict.highlightListeners[0]);
1754 target.addEventListener("resize", dict.highlightListeners[1]);
1755 target.addEventListener("scroll", dict.highlightListeners[2], {
1759 target.addEventListener("click", dict.highlightListeners[3]);
1760 target.addEventListener("selectstart", dict.highlightListeners[4]);
1761 window.document.addEventListener(
1763 dict.highlightListeners[5]
1768 * Remove event listeners from content.
1770 * @param {nsIDOMWindow} window
1772 _removeModalHighlightListeners(window) {
1773 window = this.getTopWindow(window);
1774 let dict = this.getForWindow(window);
1775 if (!dict.highlightListeners) {
1779 let target = this.iterator._getDocShell(window).chromeEventHandler;
1780 target.removeEventListener("MozAfterPaint", dict.highlightListeners[0]);
1781 target.removeEventListener("resize", dict.highlightListeners[1]);
1782 target.removeEventListener("scroll", dict.highlightListeners[2], {
1786 target.removeEventListener("click", dict.highlightListeners[3]);
1787 target.removeEventListener("selectstart", dict.highlightListeners[4]);
1788 window.document.removeEventListener(
1790 dict.highlightListeners[5]
1793 dict.highlightListeners = null;
1797 * For a given node returns its editable parent or null if there is none.
1798 * It's enough to check if node is a text node and its parent's parent is
1799 * an input or textarea.
1801 * @param node the node we want to check
1802 * @returns the first node in the parent chain that is editable,
1803 * null if there is no such node
1805 _getEditableNode(node) {
1807 node.nodeType === node.TEXT_NODE &&
1809 node.parentNode.parentNode &&
1810 (ChromeUtils.getClassName(node.parentNode.parentNode) ===
1811 "HTMLInputElement" ||
1812 ChromeUtils.getClassName(node.parentNode.parentNode) ===
1813 "HTMLTextAreaElement")
1815 return node.parentNode.parentNode;
1821 * Add ourselves as an nsIEditActionListener and nsIDocumentStateListener for
1824 * @param editor the editor we'd like to listen to
1826 _addEditorListeners(editor) {
1827 if (!this._editors) {
1829 this._stateListeners = [];
1832 let existingIndex = this._editors.indexOf(editor);
1833 if (existingIndex == -1) {
1834 let x = this._editors.length;
1835 this._editors[x] = editor;
1836 this._stateListeners[x] = this._createStateListener();
1837 this._editors[x].addEditActionListener(this);
1838 this._editors[x].addDocumentStateListener(this._stateListeners[x]);
1843 * Helper method to unhook listeners, remove cached editors
1844 * and keep the relevant arrays in sync
1846 * @param idx the index into the array of editors/state listeners
1849 _unhookListenersAtIndex(idx) {
1850 this._editors[idx].removeEditActionListener(this);
1851 this._editors[idx].removeDocumentStateListener(this._stateListeners[idx]);
1852 this._editors.splice(idx, 1);
1853 this._stateListeners.splice(idx, 1);
1854 if (!this._editors.length) {
1855 delete this._editors;
1856 delete this._stateListeners;
1861 * Remove ourselves as an nsIEditActionListener and
1862 * nsIDocumentStateListener from a given cached editor
1864 * @param editor the editor we no longer wish to listen to
1866 _removeEditorListeners(editor) {
1867 // editor is an editor that we listen to, so therefore must be
1868 // cached. Find the index of this editor
1869 let idx = this._editors.indexOf(editor);
1873 // Now unhook ourselves, and remove our cached copy
1874 this._unhookListenersAtIndex(idx);
1878 * nsIEditActionListener logic follows
1880 * We implement this interface to allow us to catch the case where
1881 * the findbar found a match in a HTML <input> or <textarea>. If the
1882 * user adjusts the text in some way, it will no longer match, so we
1883 * want to remove the highlight, rather than have it expand/contract
1884 * when letters are added or removed.
1888 * Helper method used to check whether a selection intersects with
1891 * @param selectionRange the range from the selection to check
1892 * @param findRange the highlighted range to check against
1893 * @returns true if they intersect, false otherwise
1895 _checkOverlap(selectionRange, findRange) {
1896 if (!selectionRange || !findRange) {
1899 // The ranges overlap if one of the following is true:
1900 // 1) At least one of the endpoints of the deleted selection
1901 // is in the find selection
1902 // 2) At least one of the endpoints of the find selection
1903 // is in the deleted selection
1905 findRange.isPointInRange(
1906 selectionRange.startContainer,
1907 selectionRange.startOffset
1913 findRange.isPointInRange(
1914 selectionRange.endContainer,
1915 selectionRange.endOffset
1921 selectionRange.isPointInRange(
1922 findRange.startContainer,
1923 findRange.startOffset
1929 selectionRange.isPointInRange(findRange.endContainer, findRange.endOffset)
1938 * Helper method to determine if an edit occurred within a highlight
1940 * @param selection the selection we wish to check
1941 * @param node the node we want to check is contained in selection
1942 * @param offset the offset into node that we want to check
1943 * @returns the range containing (node, offset) or null if no ranges
1944 * in the selection contain it
1946 _findRange(selection, node, offset) {
1947 let rangeCount = selection.rangeCount;
1949 let foundContainingRange = false;
1952 // Check to see if this node is inside one of the selection's ranges
1953 while (!foundContainingRange && rangeidx < rangeCount) {
1954 range = selection.getRangeAt(rangeidx);
1955 if (range.isPointInRange(node, offset)) {
1956 foundContainingRange = true;
1962 if (foundContainingRange) {
1969 // Start of nsIEditActionListener implementations
1971 WillDeleteText(textNode, offset, length) {
1972 let editor = this._getEditableNode(textNode).editor;
1973 let controller = editor.selectionController;
1974 let fSelection = controller.getSelection(
1975 Ci.nsISelectionController.SELECTION_FIND
1977 let range = this._findRange(fSelection, textNode, offset);
1980 // Don't remove the highlighting if the deleted text is at the
1982 if (textNode != range.endContainer || offset != range.endOffset) {
1983 // Text within the highlight is being removed - the text can
1984 // no longer be a match, so remove the highlighting
1985 fSelection.removeRange(range);
1986 if (fSelection.rangeCount == 0) {
1987 this._removeEditorListeners(editor);
1993 DidInsertText(textNode, offset, aString) {
1994 let editor = this._getEditableNode(textNode).editor;
1995 let controller = editor.selectionController;
1996 let fSelection = controller.getSelection(
1997 Ci.nsISelectionController.SELECTION_FIND
1999 let range = this._findRange(fSelection, textNode, offset);
2002 // If the text was inserted before the highlight
2003 // adjust the highlight's bounds accordingly
2004 if (textNode == range.startContainer && offset == range.startOffset) {
2006 range.startContainer,
2007 range.startOffset + aString.length
2009 } else if (textNode != range.endContainer || offset != range.endOffset) {
2010 // The edit occurred within the highlight - any addition of text
2011 // will result in the text no longer being a match,
2012 // so remove the highlighting
2013 fSelection.removeRange(range);
2014 if (fSelection.rangeCount == 0) {
2015 this._removeEditorListeners(editor);
2021 WillDeleteRanges(rangesToDelete) {
2022 let { editor } = this._getEditableNode(rangesToDelete[0].startContainer);
2023 let controller = editor.selectionController;
2024 let fSelection = controller.getSelection(
2025 Ci.nsISelectionController.SELECTION_FIND
2028 let shouldDelete = {};
2029 let numberOfDeletedSelections = 0;
2030 let numberOfMatches = fSelection.rangeCount;
2032 // We need to test if any ranges to be deleted
2033 // are in any of the ranges of the find selection
2034 // Usually both selections will only contain one range, however
2035 // either may contain more than one.
2037 for (let fIndex = 0; fIndex < numberOfMatches; fIndex++) {
2038 shouldDelete[fIndex] = false;
2039 let fRange = fSelection.getRangeAt(fIndex);
2041 for (let selRange of rangesToDelete) {
2042 if (shouldDelete[fIndex]) {
2046 let doesOverlap = this._checkOverlap(selRange, fRange);
2048 shouldDelete[fIndex] = true;
2049 numberOfDeletedSelections++;
2054 // OK, so now we know what matches (if any) are in the selection
2055 // that is being deleted. Time to remove them.
2056 if (!numberOfDeletedSelections) {
2060 for (let i = numberOfMatches - 1; i >= 0; i--) {
2061 if (shouldDelete[i]) {
2062 fSelection.removeRange(fSelection.getRangeAt(i));
2066 // Remove listeners if no more highlights left
2067 if (!fSelection.rangeCount) {
2068 this._removeEditorListeners(editor);
2073 * nsIDocumentStateListener logic follows
2075 * When attaching nsIEditActionListeners, there are no guarantees
2076 * as to whether the findbar or the documents in the browser will get
2077 * destructed first. This leads to the potential to either leak, or to
2078 * hold on to a reference an editable element's editor for too long,
2079 * preventing it from being destructed.
2081 * However, when an editor's owning node is being destroyed, the editor
2082 * sends out a DocumentWillBeDestroyed notification. We can use this to
2083 * clean up our references to the object, to allow it to be destroyed in a
2088 * Unhook ourselves when one of our state listeners has been called.
2089 * This can happen in 4 cases:
2090 * 1) The document the editor belongs to is navigated away from, and
2091 * the document is not being cached
2093 * 2) The document the editor belongs to is expired from the cache
2095 * 3) The tab containing the owning document is closed
2097 * 4) The <input> or <textarea> that owns the editor is explicitly
2098 * removed from the DOM
2100 * @param the listener that was invoked
2102 _onEditorDestruction(aListener) {
2103 // First find the index of the editor the given listener listens to.
2104 // The listeners and editors arrays must always be in sync.
2105 // The listener will be in our array of cached listeners, as this
2106 // method could not have been called otherwise.
2108 while (this._stateListeners[idx] != aListener) {
2112 // Unhook both listeners
2113 this._unhookListenersAtIndex(idx);
2117 * Creates a unique document state listener for an editor.
2119 * It is not possible to simply have the findbar implement the
2120 * listener interface itself, as it wouldn't have sufficient information
2121 * to work out which editor was being destroyed. Therefore, we create new
2122 * listeners on the fly, and cache them in sync with the editors they
2125 _createStateListener() {
2129 QueryInterface: ChromeUtils.generateQI(["nsIDocumentStateListener"]),
2131 NotifyDocumentWillBeDestroyed() {
2132 this.findbar._onEditorDestruction(this);
2136 notifyDocumentStateChanged(aDirty) {},