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/. */
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(
17 "resource://gre/modules/Color.jsm"
19 ChromeUtils.defineModuleGetter(
22 "resource://gre/modules/Geometry.jsm"
24 XPCOMUtils.defineLazyGetter(this, "kDebug", () => {
25 const kDebugPref = "findbar.modalHighlight.debug";
27 Services.prefs.getPrefType(kDebugPref) &&
28 Services.prefs.getBoolPref(kDebugPref)
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 = [
58 const kFontPropsCamelCase = kFontPropsCSS.map(prop => {
59 let parts = prop.split("-");
62 parts.map(part => part.charAt(0).toUpperCase() + part.slice(1)).join("")
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 = {
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)`],
81 ["display", "-moz-box"],
84 `-${kOutlineBoxBorderSize}px 0 0 -${kOutlineBoxBorderSize}px !important`,
86 ["overflow", "hidden"],
87 ["pointer-events", "none"],
88 ["position", "absolute"],
89 ["white-space", "nowrap"],
90 ["will-change", "transform"],
93 outlineNodeDebug: [["z-index", 2147483647]],
95 ["margin", "0 !important"],
96 ["padding", "0 !important"],
97 ["vertical-align", "top !important"],
100 ["background", "rgba(0,0,0,.25)"],
101 ["pointer-events", "none"],
102 ["position", "absolute"],
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 = {
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)" },
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) {
124 setTextContentForElement(id, text) {
125 (domNode.querySelector("#" + id) || domNode).textContent = text;
127 getAttributeForElement(id, attrName) {
128 let node = domNode.querySelector("#" + id) || domNode;
129 if (!node.hasAttribute(attrName)) {
132 return node.getAttribute(attrName);
134 setAttributeForElement(id, attrName, attrValue) {
135 (domNode.querySelector("#" + id) || domNode).setAttribute(
140 removeAttributeForElement(id, attrName) {
141 let node = domNode.querySelector("#" + id) || domNode;
142 if (!node.hasAttribute(attrName)) {
145 node.removeAttribute(attrName);
152 setAnimationForElement(id, keyframes, duration) {
153 return (domNode.querySelector("#" + id) || domNode).animate(
158 setCutoutRectsForElement(id, rects) {
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.
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 = {
184 return this.finder.iterator;
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)) {
202 // Modal highlighting is currently only enabled when there are no
203 // out-of-process subframes.
204 return this._modal && this._useSubFrames;
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
220 * @param {nsIDOMWindow} window
223 getForWindow(window, propName = null) {
224 if (!gWindows.has(window)) {
225 gWindows.set(window, {
226 detectedGeometryChange: false,
227 dynamicRangesSet: new Set(),
229 lastWindowDimensions: { width: 0, height: 0 },
230 modalHighlightRectsMap: new Map(),
231 previousRangeRectsAndTexts: { rectList: [], textList: [] },
232 repaintSchedulerState: kRepaintSchedulerStopped,
235 return gWindows.get(window);
239 * Notify all registered listeners that the 'Highlight All' operation finished.
241 * @param {Boolean} highlight Whether highlighting was turned on
243 notifyFinished(highlight) {
244 for (let l of this.finder._listeners) {
246 l.onHighlightFinished(highlight);
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.
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
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;
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
281 caseSensitive: this.finder._fastFind.caseSensitive,
282 entireWord: this.finder._fastFind.entireWord,
287 matchDiacritics: this.finder._fastFind.matchDiacritics,
293 this.iterator.isAlreadyRunning(params) ||
295 this.iterator._areParamsEqual(params, dict.lastIteratorParams))
300 if (!this.useModal()) {
303 await this.iterator.start(params);
305 this.finder._outlineLink(drawOutline);
310 // Removing the highlighting always succeeds, so return true.
314 result.found = this._found;
315 this.notifyFinished(result);
319 // FinderIterator listener implementation
321 onIteratorRangeFound(range) {
322 this.highlightRange(range);
326 onIteratorReset() {},
328 onIteratorRestart() {
329 this.clear(this.finder._getWindow());
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());
344 * Add a range to the find selection, i.e. highlight it, and if it's inside an
345 * editable node, track it.
347 * @param {Range} range Range object to be highlighted
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);
355 controller = editableNode.editor.selectionController;
358 if (this.useModal()) {
359 this._modalHighlight(range, controller, window);
361 let findSelection = controller.getSelection(
362 Ci.nsISelectionController.SELECTION_FIND
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, {});
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);
382 * If modal highlighting is enabled, show the dimmed background that will overlay
385 * @param {nsIDOMWindow} window The dimmed background will overlay this window.
386 * Optional, defaults to the finder window.
388 show(window = null) {
389 window = this.getTopWindow(window || this.finder._getWindow());
390 let dict = this.getForWindow(window);
391 if (!this.useModal() || dict.visible) {
397 this._maybeCreateModalHighlightNodes(window);
398 this._addModalHighlightListeners(window);
402 * Clear all highlighted matches. If modal highlighting is enabled and
403 * the outline + dimmed background is currently visible, both will be hidden.
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
409 * @param {Event} event When called from an event handler, this will
410 * be the triggering event.
412 hide(window, skipRange = null, event = null) {
414 window = this.getTopWindow(window);
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.
426 event.type == "click" &&
427 (event.button !== 0 ||
432 event.relatedTarget ||
434 (event.target.localName == "a" && event.target.href))
439 this._clearSelection(
440 this.finder._getSelectionController(window),
443 for (let frame of dict.frames.keys()) {
444 this._clearSelection(
445 this.finder._getSelectionController(frame),
450 // Next, check our editor cache, for editors belonging to this
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);
463 if (dict.modalRepaintScheduler) {
464 window.clearTimeout(dict.modalRepaintScheduler);
465 dict.modalRepaintScheduler = null;
466 dict.repaintSchedulerState = kRepaintSchedulerStopped;
468 dict.lastWindowDimensions = { width: 0, height: 0 };
470 this._removeRangeOutline(window);
471 this._removeHighlightAllMask(window);
472 this._removeModalHighlightListeners(window);
474 dict.visible = false;
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.
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,
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.
498 async update(data, foundInThisFrame) {
499 let window = this.finder._getWindow();
500 let dict = this.getForWindow(window);
501 let foundRange = this.finder._fastFind.getFoundRange();
504 data.result == Ci.nsITypeAheadFind.FIND_NOTFOUND ||
505 !data.searchString ||
506 (foundInThisFrame && !foundRange)
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;
520 this.iterator._areParamsEqual(params, dict.lastIteratorParams)
524 if (!dict.visible && !params) {
525 params = { word: data.searchString, linksOnly: data.linksOnly };
528 await this.highlight(
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;
551 this._maybeCreateModalHighlightNodes(window);
555 if (this._highlightAll) {
556 await this.highlight(
567 * Invalidates the list by clearing the map of highlighted ranges that we
568 * keep to build the mask for.
570 clear(window = null) {
571 if (!window || !this.getTopWindow(window)) {
575 let dict = this.getForWindow(this.getTopWindow(window));
576 this._finishOutlineAnimations(dict);
577 dict.dynamicRangesSet.clear();
579 dict.modalHighlightRectsMap.clear();
580 dict.brightText = null;
584 * Removes the outline from a single window. This is done when
585 * switching the current search to a new frame.
587 clearCurrentOutline(window = null) {
588 let dict = this.getForWindow(this.getTopWindow(window));
589 this._finishOutlineAnimations(dict);
590 this._removeRangeOutline(window);
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.
600 let window = this.finder._getWindow();
601 if (!window || !this.getTopWindow(window)) {
606 this._removeRangeOutline(window);
608 gWindows.delete(this.getTopWindow(window));
612 * When `kModalHighlightPref` pref changed during a session, this callback is
613 * invoked. When modal highlighting is turned off, we hide the CanvasFrame
616 * @param {Boolean} useModalHighlight
618 onModalHighlightChange(useModalHighlight) {
619 let window = this.finder._getWindow();
620 if (window && this.useModal() && !useModalHighlight) {
624 this._modal = useModalHighlight;
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.
631 * @param {Boolean} highlightAll
633 onHighlightAllChange(highlightAll) {
634 this._highlightAll = highlightAll;
636 let window = this.finder._getWindow();
637 if (!this.useModal()) {
641 this._scheduleRepaintOfMask(window);
646 * Utility; removes all ranges from the find selection that belongs to a
647 * controller. Optionally skips a specific range.
649 * @param {nsISelectionController} controller
650 * @param {Range} restoreRange
652 _clearSelection(controller, restoreRange = null) {
656 let sel = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
657 sel.removeAllRanges();
659 sel = controller.getSelection(Ci.nsISelectionController.SELECTION_NORMAL);
660 sel.addRange(restoreRange);
661 controller.setDisplaySelection(
662 Ci.nsISelectionController.SELECTION_ATTENTION
664 controller.repaintSelection(Ci.nsISelectionController.SELECTION_NORMAL);
669 * Utility; get the nsIDOMWindowUtils for a window.
671 * @param {nsIDOMWindow} window Optional, defaults to the finder window.
672 * @return {nsIDOMWindowUtils}
674 _getDWU(window = null) {
675 return (window || this.finder._getWindow()).windowUtils;
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.
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`
690 _getRootBounds(window, includeScroll = true) {
691 let dwu = this._getDWU(this.getTopWindow(window, true));
692 let cssPageRect = Rect.fromRect(dwu.getRootBounds());
695 if (includeScroll && window == this.getTopWindow(window, true)) {
696 dwu.getScrollXY(false, scrollX, scrollY);
697 cssPageRect.translate(scrollX.value, scrollY.value);
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));
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
721 el.getAttribute("scrolling") == "no" &&
722 currWin != this.getTopWindow(window, true)
724 let docEl = currWin.document.documentElement;
725 parentRect.translate(-docEl.scrollLeft, -docEl.scrollTop);
729 cssPageRect.translate(parentRect.left, parentRect.top);
731 let frameOffsets = this._getFrameElementOffsets(currWin);
732 cssPageRect.translate(frameOffsets.x, frameOffsets.y);
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
741 * This method fetches this offset of the frame element to the respective window.
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.
748 _getFrameElementOffsets(window) {
749 let frame = window.frameElement;
751 return { x: 0, y: 0 };
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);
759 dict.frames.set(window, (frameData = {}));
761 if (frameData.offset) {
762 return frameData.offset;
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.
769 parseInt(style.borderLeftWidth, 10) || 0,
770 parseInt(style.borderTopWidth, 10) || 0,
772 let paddingOffset = [
773 parseInt(style.paddingLeft, 10) || 0,
774 parseInt(style.paddingTop, 10) || 0,
776 return (frameData.offset = {
777 x: borderOffset[0] + paddingOffset[0],
778 y: borderOffset[1] + paddingOffset[1],
783 * Utility; fetch the full width and height of the current window, excluding
786 * @param {nsiDOMWindow} window The current finder window.
787 * @return {Object} The current full page dimensions with `width` and `height`
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;
807 return { width, height };
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`.
814 * @param {Range} range Range to fetch style info from.
815 * @return {Object} Dictionary consisting of the styles that were found.
817 _getRangeFontStyle(range) {
818 let node = range.startContainer;
819 while (node.nodeType != 1) {
820 node = node.parentNode;
822 let style = node.ownerGlobal.getComputedStyle(node);
824 for (let prop of kFontPropsCamelCase) {
825 if (prop in style && style[prop]) {
826 props[prop] = style[prop];
833 * Utility; transform a dictionary object as returned by `_getRangeFontStyle`
834 * above into a HTML style attribute value.
836 * @param {Object} fontStyle
839 _getHTMLFontStyle(fontStyle) {
841 for (let prop of Object.getOwnPropertyNames(fontStyle)) {
842 let idx = kFontPropsCamelCase.indexOf(prop);
846 style.push(`${kFontPropsCSS[idx]}: ${fontStyle[prop]}`);
848 return style.join("; ");
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.
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
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);
868 return [...baseStyle]
869 .map(([cssProp, cssVal]) => `${cssProp}: ${cssVal}`)
874 * Checks whether a CSS RGB color value can be classified as being 'bright'.
876 * @param {String} cssColor RGB color value in the default format rgb[a](r,g,b)
879 _isColorBright(cssColor) {
880 cssColor = cssColor.match(kRGBRE);
881 if (!cssColor || !cssColor.length) {
885 return !new Color(...cssColor).useBrightText;
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`
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.
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`.
904 _detectBrightText(dict) {
905 let sampleSize = Math.min(
906 dict.modalHighlightRectsMap.size,
907 kBrightTextSampleSize
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);
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)) {
931 dict.brightText = brightCount >= Math.ceil(sampleSize / 2);
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
941 * @param {Range} range Range that be enclosed in a dynamic container
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;
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, {});
963 let style = window.getComputedStyle(node);
965 kFixed.has(style.position) ||
966 kFixed.has(style.overflow) ||
967 kFixed.has(style.overflowX) ||
968 kFixed.has(style.overflowY)
972 node = node.parentNode;
973 } while (node && node != document.documentElement);
979 * Read and store the rectangles that encompass the entire region of a range
980 * for use by the drawing function of the highlighter.
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
987 _getRangeRectsAndTexts(range, dict = null) {
988 let window = range.startContainer.ownerGlobal;
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;
995 bounds = frameData.bounds = this._getRootBounds(window);
998 bounds = this._getRootBounds(window);
1001 let topBounds = this._getRootBounds(this.getTopWindow(window, true), false);
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);
1011 // If the rect is not even visible from the top document, we can ignore it.
1012 if (rect.intersects(topBounds)) {
1016 return { rectList: rects, textList };
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
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
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.
1045 oldRectsAndTexts.rectList.length &&
1046 !rectsAndTexts.rectList.length
1048 dict.detectedGeometryChange = true;
1050 if (checkIfDynamic && this._isInDynamicContainer(range)) {
1051 dict.dynamicRangesSet.add(range);
1053 return rectsAndTexts;
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.
1060 * @param {Object} dict Dictionary of properties belonging to the currently
1063 _updateDynamicRangesRects(dict) {
1064 // Reset the frame bounds cache.
1065 for (let frameData of dict.frames.values()) {
1066 frameData.bounds = null;
1068 for (let range of dict.dynamicRangesSet) {
1069 this._updateRangeRects(range, false, dict);
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
1079 * @param {Object} dict Dictionary of properties belonging to the currently
1082 _updateRangeOutline(dict) {
1083 let range = dict.currentFoundRange;
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);
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.
1116 !rectsAndTexts.textList.length ||
1118 dict.previousUpdatedRange == range &&
1119 !dict.dynamicRangesSet.has(range))
1125 if (rebuildOutline) {
1126 // Create the main (yellow) highlight outline box.
1127 outlineBox = document.createElementNS(kNSHTML, "div");
1128 outlineBox.setAttribute("id", kModalOutlineId);
1131 const kModalOutlineTextId = kModalOutlineId + "-text";
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");
1148 let next = rectsAndTexts.rectList[i + 1];
1149 if (next && next.left - rect.right <= 2 * kOutlineBoxBorderSize) {
1150 intersectingSides.add("right");
1152 let borderStyles = [...intersectingSides].map(side => [
1156 if (intersectingSides.size) {
1159 `-${kOutlineBoxBorderSize}px 0 0 ${
1160 intersectingSides.has("left") ? 0 : -kOutlineBoxBorderSize
1165 (intersectingSides.has("left") ? 0 : kOutlineBoxBorderRadius) +
1167 (intersectingSides.has("right") ? 0 : kOutlineBoxBorderRadius) +
1169 (intersectingSides.has("right") ? 0 : kOutlineBoxBorderRadius) +
1171 (intersectingSides.has("left") ? 0 : kOutlineBoxBorderRadius) +
1176 let outlineStyle = this._getStyleString(
1177 kModalStyles.outlineNode,
1179 ["top", rect.top + "px"],
1180 ["left", rect.left + "px"],
1181 ["height", rect.height + "px"],
1182 ["width", rect.width + "px"],
1185 kDebug ? kModalStyles.outlineNodeDebug : []
1187 fontStyle.lineHeight = rect.height + "px";
1189 this._getStyleString(kModalStyles.outlineText) +
1191 this._getHTMLFontStyle(fontStyle);
1193 if (rebuildOutline) {
1194 let textBoxParent = outlineBox.appendChild(
1195 document.createElementNS(kNSHTML, "div")
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);
1206 // Set the appropriate properties on the existing nodes, which will also
1207 // activate the transitions.
1208 outlineAnonNode.setAttributeForElement(
1209 kModalOutlineId + i,
1213 outlineAnonNode.setAttributeForElement(
1214 kModalOutlineTextId + i,
1218 outlineAnonNode.setTextContentForElement(kModalOutlineTextId + i, text);
1224 if (rebuildOutline) {
1225 dict.modalHighlightOutline = kDebug
1226 ? mockAnonymousContentNode(
1227 (document.body || document.documentElement).appendChild(outlineBox)
1229 : document.insertAnonymousContent(outlineBox);
1232 if (dict.animateOutline && !this._isPageTooBig(dict)) {
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
1241 animation.onfinish = function() {
1242 dict.animations.delete(this);
1244 dict.animations.add(animation);
1247 dict.animateOutline = false;
1248 dict.ignoreNextContentChange = true;
1250 dict.previousUpdatedRange = range;
1254 * Finish any currently playing animations on the found range outline node.
1256 * @param {Object} dict Dictionary of properties belonging to the currently
1259 _finishOutlineAnimations(dict) {
1260 if (!dict.animations) {
1263 for (let animation of dict.animations) {
1269 * Safely remove the outline AnoymousContent node from the CanvasFrame.
1271 * @param {nsIDOMWindow} window
1273 _removeRangeOutline(window) {
1274 let dict = this.getForWindow(window);
1275 if (!dict.modalHighlightOutline) {
1280 dict.modalHighlightOutline.remove();
1283 window.document.removeAnonymousContent(dict.modalHighlightOutline);
1287 dict.modalHighlightOutline = null;
1291 * Add a range to the list of ranges to highlight on, or cut out of, the dimmed
1294 * @param {Range} range Range object that should be inspected
1295 * @param {nsIDOMWindow} window Window object, whose DOM tree is being traversed
1297 _modalHighlight(range, controller, window) {
1298 this._updateRangeRects(range);
1301 // We don't repaint the mask right away, but pass it off to a render loop of
1303 this._scheduleRepaintOfMask(window);
1307 * Lazily insert the nodes we need as anonymous content into the CanvasFrame
1310 * @param {nsIDOMWindow} window Window to draw in.
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);
1321 this._scheduleRepaintOfMask(window, { contentChanged: true });
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);
1333 document.addEventListener("visibilitychange", onVisibilityChange);
1337 // Make sure to at least show the dimmed background.
1338 this._repaintHighlightAllMask(window, false);
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.
1346 * @param {nsIDOMWindow} window Window to draw in.
1347 * @param {Boolean} [paintContent]
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)
1362 : document.insertAnonymousContent(maskNode);
1365 // Make sure the dimmed mask node takes the full width and height that's available.
1369 } = (dict.lastWindowDimensions = this._getWindowDimensions(window));
1370 if (typeof dict.brightText != "boolean" || dict.updateAllRanges) {
1371 this._detectBrightText(dict);
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 : []
1380 dict.modalHighlightAllMask.setAttributeForElement(
1386 this._updateRangeOutline(dict);
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);
1398 let DOMRect = window.DOMRect;
1399 for (let [range, rectsAndTexts] of dict.modalHighlightRectsMap) {
1400 if (!this.finder._fastFind.isRangeVisible(range, false)) {
1404 if (dict.updateAllRanges) {
1405 rectsAndTexts = this._updateRangeRects(range);
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) {
1414 for (let rect of rectsAndTexts.rectList) {
1415 allRects.push(new DOMRect(rect.x, rect.y, rect.width, rect.height));
1418 dict.updateAllRanges = false;
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;
1429 * Safely remove the mask AnoymousContent node from the CanvasFrame.
1431 * @param {nsIDOMWindow} window
1433 _removeHighlightAllMask(window) {
1434 window = this.getTopWindow(window);
1435 let dict = this.getForWindow(window);
1436 if (!dict.modalHighlightAllMask) {
1440 // If the current window isn't the one the content was inserted into, this
1441 // will fail, but that's fine.
1443 dict.modalHighlightAllMask.remove();
1446 window.document.removeAnonymousContent(dict.modalHighlightAllMask);
1449 dict.modalHighlightAllMask = null;
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.
1457 * @param {Object} dict Dictionary of properties belonging to the currently
1461 _isPageTooBig(dict) {
1462 let { height, width } = dict.lastWindowDimensions;
1463 return height >= kPageIsTooBigPx || width >= kPageIsTooBigPx;
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`.
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.
1485 _scheduleRepaintOfMask(
1487 { contentChanged = false, scrollOnly = false, updateAllRanges = false } = {}
1489 if (!this.useModal()) {
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).
1498 dict.repaintSchedulerState == kRepaintSchedulerPaused ||
1499 (contentChanged && dict.ignoreNextContentChange)
1501 dict.ignoreNextContentChange = false;
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);
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
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;
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;
1537 // Some events, like a resize, call for recalculation of all the rects of all ranges.
1538 if (!dict.updateAllRanges) {
1539 dict.updateAllRanges = updateAllRanges;
1542 if (dict.modalRepaintScheduler) {
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) {
1558 width: previousWidth,
1559 height: previousHeight,
1560 } = dict.lastWindowDimensions;
1564 } = (dict.lastWindowDimensions = this._getWindowDimensions(window));
1565 pageContentChanged =
1566 dict.detectedGeometryChange ||
1567 (Math.abs(previousWidth - width) > kContentChangeThresholdPx ||
1568 Math.abs(previousHeight - height) > kContentChangeThresholdPx);
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);
1578 dict.unconditionalRepaintRequested ||
1579 (dict.modalHighlightRectsMap.size && pageContentChanged)
1581 dict.unconditionalRepaintRequested = false;
1582 this._repaintHighlightAllMask(window);
1585 dict.repaintSchedulerState = kRepaintSchedulerRunning;
1589 * Add event listeners to the content which will cause the modal highlight
1590 * AnonymousContent to be re-painted or hidden.
1592 * @param {nsIDOMWindow} window
1594 _addModalHighlightListeners(window) {
1595 window = this.getTopWindow(window);
1596 let dict = this.getForWindow(window);
1597 if (dict.highlightListeners) {
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),
1608 if (window.document.hidden) {
1609 dict.repaintSchedulerState = kRepaintSchedulerPaused;
1610 } else if (dict.repaintSchedulerState == kRepaintSchedulerPaused) {
1611 dict.repaintSchedulerState = kRepaintSchedulerRunning;
1612 this._scheduleRepaintOfMask(window);
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], {
1623 target.addEventListener("click", dict.highlightListeners[3]);
1624 target.addEventListener("selectstart", dict.highlightListeners[4]);
1625 window.document.addEventListener(
1627 dict.highlightListeners[5]
1632 * Remove event listeners from content.
1634 * @param {nsIDOMWindow} window
1636 _removeModalHighlightListeners(window) {
1637 window = this.getTopWindow(window);
1638 let dict = this.getForWindow(window);
1639 if (!dict.highlightListeners) {
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], {
1650 target.removeEventListener("click", dict.highlightListeners[3]);
1651 target.removeEventListener("selectstart", dict.highlightListeners[4]);
1652 window.document.removeEventListener(
1654 dict.highlightListeners[5]
1657 dict.highlightListeners = null;
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.
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
1669 _getEditableNode(node) {
1671 node.nodeType === node.TEXT_NODE &&
1673 node.parentNode.parentNode &&
1674 (ChromeUtils.getClassName(node.parentNode.parentNode) ===
1675 "HTMLInputElement" ||
1676 ChromeUtils.getClassName(node.parentNode.parentNode) ===
1677 "HTMLTextAreaElement")
1679 return node.parentNode.parentNode;
1685 * Add ourselves as an nsIEditActionListener and nsIDocumentStateListener for
1688 * @param editor the editor we'd like to listen to
1690 _addEditorListeners(editor) {
1691 if (!this._editors) {
1693 this._stateListeners = [];
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]);
1707 * Helper method to unhook listeners, remove cached editors
1708 * and keep the relevant arrays in sync
1710 * @param idx the index into the array of editors/state listeners
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;
1725 * Remove ourselves as an nsIEditActionListener and
1726 * nsIDocumentStateListener from a given cached editor
1728 * @param editor the editor we no longer wish to listen to
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);
1737 // Now unhook ourselves, and remove our cached copy
1738 this._unhookListenersAtIndex(idx);
1742 * nsIEditActionListener logic follows
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.
1752 * Helper method used to check whether a selection intersects with
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
1759 _checkOverlap(selectionRange, findRange) {
1760 if (!selectionRange || !findRange) {
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
1769 findRange.isPointInRange(
1770 selectionRange.startContainer,
1771 selectionRange.startOffset
1777 findRange.isPointInRange(
1778 selectionRange.endContainer,
1779 selectionRange.endOffset
1785 selectionRange.isPointInRange(
1786 findRange.startContainer,
1787 findRange.startOffset
1793 selectionRange.isPointInRange(findRange.endContainer, findRange.endOffset)
1802 * Helper method to determine if an edit occurred within a highlight
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
1810 _findRange(selection, node, offset) {
1811 let rangeCount = selection.rangeCount;
1813 let foundContainingRange = false;
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;
1826 if (foundContainingRange) {
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
1841 let range = this._findRange(fSelection, textNode, offset);
1844 // Don't remove the highlighting if the deleted text is at the
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);
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
1863 let range = this._findRange(fSelection, textNode, offset);
1866 // If the text was inserted before the highlight
1867 // adjust the highlight's bounds accordingly
1868 if (textNode == range.startContainer && offset == range.startOffset) {
1870 range.startContainer,
1871 range.startOffset + aString.length
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);
1885 WillDeleteSelection(selection) {
1886 let editor = this._getEditableNode(selection.getRangeAt(0).startContainer)
1888 let controller = editor.selectionController;
1889 let fSelection = controller.getSelection(
1890 Ci.nsISelectionController.SELECTION_FIND
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]) {
1911 let selRange = selection.getRangeAt(index);
1912 let doesOverlap = this._checkOverlap(selRange, fRange);
1914 shouldDelete[fIndex] = true;
1915 numberOfDeletedSelections++;
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) {
1926 for (let i = numberOfMatches - 1; i >= 0; i--) {
1927 if (shouldDelete[i]) {
1928 fSelection.removeRange(fSelection.getRangeAt(i));
1932 // Remove listeners if no more highlights left
1933 if (!fSelection.rangeCount) {
1934 this._removeEditorListeners(editor);
1939 * nsIDocumentStateListener logic follows
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.
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
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
1959 * 2) The document the editor belongs to is expired from the cache
1961 * 3) The tab containing the owning document is closed
1963 * 4) The <input> or <textarea> that owns the editor is explicitly
1964 * removed from the DOM
1966 * @param the listener that was invoked
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.
1974 while (this._stateListeners[idx] != aListener) {
1978 // Unhook both listeners
1979 this._unhookListenersAtIndex(idx);
1983 * Creates a unique document state listener for an editor.
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
1991 _createStateListener() {
1995 QueryInterface: ChromeUtils.generateQI(["nsIDocumentStateListener"]),
1997 NotifyDocumentWillBeDestroyed() {
1998 this.findbar._onEditorDestruction(this);
2002 notifyDocumentCreated() {},
2003 notifyDocumentStateChanged(aDirty) {},