Bug 1290914 - use inline styles for the modal highlighting anonymous content nodes to dramatically improve performance when find in page is used on large documents. r?jaws draft
authorMike de Boer <mdeboer@mozilla.com>
Wed, 07 Sep 2016 12:03:47 +0200
changeset 410969 04873d7e224d9c76b78915a14cab02b425db5a4e
parent 410968 0d56ae5f3cdda0ad88735139c35ebdc321ebc0ae
child 530644 e6117acb84fda344cc7e75d428933fb4dcbe77f0
push id28805
push usermdeboer@mozilla.com
push dateWed, 07 Sep 2016 10:05:39 +0000
reviewersjaws
bugs1290914
milestone51.0a1
Bug 1290914 - use inline styles for the modal highlighting anonymous content nodes to dramatically improve performance when find in page is used on large documents. r?jaws MozReview-Commit-ID: 3mw0gfn0w4p
toolkit/modules/FinderHighlighter.jsm
--- a/toolkit/modules/FinderHighlighter.jsm
+++ b/toolkit/modules/FinderHighlighter.jsm
@@ -15,112 +15,83 @@ Cu.import("resource://gre/modules/XPCOMU
 XPCOMUtils.defineLazyModuleGetter(this, "Color", "resource://gre/modules/Color.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Rect", "resource://gre/modules/Geometry.jsm");
 XPCOMUtils.defineLazyGetter(this, "kDebug", () => {
   const kDebugPref = "findbar.modalHighlight.debug";
   return Services.prefs.getPrefType(kDebugPref) && Services.prefs.getBoolPref(kDebugPref);
 });
 
 const kContentChangeThresholdPx = 5;
-const kModalHighlightRepaintFreqMs = 200;
+const kModalHighlightRepaintFreqMs = 100;
 const kHighlightAllPref = "findbar.highlightAll";
 const kModalHighlightPref = "findbar.modalHighlight";
 const kFontPropsCSS = ["color", "font-family", "font-kerning", "font-size",
   "font-size-adjust", "font-stretch", "font-variant", "font-weight", "line-height",
   "letter-spacing", "text-emphasis", "text-orientation", "text-transform", "word-spacing"];
 const kFontPropsCamelCase = kFontPropsCSS.map(prop => {
   let parts = prop.split("-");
   return parts.shift() + parts.map(part => part.charAt(0).toUpperCase() + part.slice(1)).join("");
 });
 const kRGBRE = /^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*/i;
-// This uuid is used to prefix HTML element IDs and classNames in order to make
-// them unique and hard to clash with IDs and classNames content authors come up
-// with, since the stylesheet for modal highlighting is inserted as an agent-sheet
-// in the active HTML document.
+// This uuid is used to prefix HTML element IDs in order to make them unique and
+// hard to clash with IDs content authors come up with.
 const kModalIdPrefix = "cedee4d0-74c5-4f2d-ab43-4d37c0f9d463";
 const kModalOutlineId = kModalIdPrefix + "-findbar-modalHighlight-outline";
-const kModalStyle = `
-.findbar-modalHighlight-outline {
-  position: absolute;
-  background: #ffc535;
-  border-radius: 3px;
-  box-shadow: 0 2px 0 0 rgba(0,0,0,.1);
-  color: #000;
-  display: -moz-box;
-  margin: -2px 0 0 -2px !important;
-  padding: 2px !important;
-  pointer-events: none;
-  z-index: 2;
-}
-
-.findbar-modalHighlight-outline.findbar-debug {
-  z-index: 2147483647;
-}
-
-.findbar-modalHighlight-outline[grow] {
-  animation-name: findbar-modalHighlight-outlineAnim;
-}
-
-@keyframes findbar-modalHighlight-outlineAnim {
-  from {
-    transform: scaleX(0) scaleY(0);
-  }
-  50% {
-    transform: scaleX(1.5) scaleY(1.5);
-  }
-  to {
-    transform: scaleX(0) scaleY(0);
-  }
-}
-
-.findbar-modalHighlight-outline[hidden] {
-  opacity: 0;
-}
-
-.findbar-modalHighlight-outline:not([disable-transitions]) {
-  transition-property: opacity, transform, top, left;
-  transition-duration: 50ms;
-  transition-timing-function: linear;
-}
-
-.findbar-modalHighlight-outline-text {
-  margin: 0 !important;
-  padding: 0 !important;
-  vertical-align: top !important;
-}
-
-.findbar-modalHighlight-outlineMask {
-  background: #000;
-  mix-blend-mode: multiply;
-  opacity: .35;
-  pointer-events: none;
-  position: absolute;
-  z-index: 1;
-}
-
-.findbar-modalHighlight-outlineMask.findbar-debug {
-  z-index: 2147483646;
-  top: 0;
-  left: 0;
-}
-
-.findbar-modalHighlight-outlineMask[brighttext] {
-  background: #fff;
-}
-
-.findbar-modalHighlight-rect {
-  background: #fff;
-  margin: -1px 0 0 -1px !important;
-  padding: 0 1px 2px 1px !important;
-  position: absolute;
-}
-
-.findbar-modalHighlight-outlineMask[brighttext] > .findbar-modalHighlight-rect {
-  background: #000;
-}`;
+const kModalStyles = {
+  outlineNode: [
+    ["position", "absolute"],
+    ["background", "#ffc535"],
+    ["border-radius", "3px"],
+    ["box-shadow", "0 2px 0 0 rgba(0,0,0,.1)"],
+    ["color", "#000"],
+    ["display", "-moz-box"],
+    ["margin", "-2px 0 0 -2px !important"],
+    ["padding", "2px !important"],
+    ["pointer-events", "none"],
+    ["transition-property", "opacity, transform, top, left"],
+    ["transition-duration", "50ms"],
+    ["transition-timing-function", "linear"],
+    ["z-index", 2]
+  ],
+  outlineNodeDebug: [ ["z-index", 2147483647] ],
+  outlineText: [
+    ["margin", "0 !important"],
+    ["padding", "0 !important"],
+    ["vertical-align", "top !important"]
+  ],
+  maskNode: [
+    ["background", "#000"],
+    ["mix-blend-mode", "multiply"],
+    ["opacity", ".35"],
+    ["pointer-events", "none"],
+    ["position", "absolute"],
+    ["z-index", 1]
+  ],
+  maskNodeDebug: [
+    ["z-index", 2147483646],
+    ["top", 0],
+    ["left", 0]
+  ],
+  maskNodeBrightText: [ ["background", "#fff"] ],
+  maskRect: [
+    ["background", "#fff"],
+    ["margin", "-1px 0 0 -1px !important"],
+    ["padding", "0 1px 2px 1px !important"],
+    ["position", "absolute"]
+  ],
+  maskRectBrightText: [ "background", "#000" ]
+};
+const kModalOutlineAnim = {
+  "keyframes": [
+    { transform: "scaleX(1) scaleY(1)" },
+    { transform: "scaleX(1.5) scaleY(1.5)", offset: .5, easing: "ease-in" },
+    { transform: "scaleX(1) scaleY(1)" }
+  ],
+  duration: 50,
+};
 
 function mockAnonymousContentNode(domNode) {
   return {
     setTextContentForElement(id, text) {
       (domNode.querySelector("#" + id) || domNode).textContent = text;
     },
     getAttributeForElement(id, attrName) {
       let node = domNode.querySelector("#" + id) || domNode;
@@ -162,54 +133,35 @@ function FinderHighlighter(finder) {
 FinderHighlighter.prototype = {
   get iterator() {
     if (this._iterator)
       return this._iterator;
     this._iterator = Cu.import("resource://gre/modules/FinderIterator.jsm", null).FinderIterator;
     return this._iterator;
   },
 
-  get modalStyleSheet() {
-    if (!this._modalStyleSheet) {
-      this._modalStyleSheet = kModalStyle.replace(/findbar-/g,
-        kModalIdPrefix + "-findbar-");
-    }
-    return this._modalStyleSheet;
-  },
-
-  get modalStyleSheetURI() {
-    if (!this._modalStyleSheetURI) {
-      this._modalStyleSheetURI = "data:text/css;charset=utf-8," +
-        encodeURIComponent(this.modalStyleSheet.replace(/[\n]+/g, " "));
-    }
-    return this._modalStyleSheetURI;
-  },
-
   /**
    * Each window is unique, globally, and the relation between an active
    * highlighting session and a window is 1:1.
    * For each window we track a number of properties which _at least_ consist of
    *  - {Set}     dynamicRangesSet       Set of ranges that may move around, depending
    *                                     on page layout changes and user input
    *  - {Map}     frames                 Collection of frames that were encountered
    *                                     when inspecting the found ranges
-   *  - {Boolean} installedSheet         Whether the modal stylesheet was loaded
-   *                                     already
    *  - {Map}     modalHighlightRectsMap Collection of ranges and their corresponding
    *                                     Rects
    *
    * @param  {nsIDOMWindow} window
    * @return {Object}
    */
   getForWindow(window, propName = null) {
     if (!gWindows.has(window)) {
       gWindows.set(window, {
         dynamicRangesSet: new Set(),
         frames: new Map(),
-        installedSheet: false,
         modalHighlightRectsMap: new Map()
       });
     }
     return gWindows.get(window);
   },
 
   /**
    * Notify all registered listeners that the 'Highlight All' operation finished.
@@ -392,18 +344,21 @@ FinderHighlighter.prototype = {
     }
 
     if (dict.modalRepaintScheduler) {
       window.clearTimeout(dict.modalRepaintScheduler);
       dict.modalRepaintScheduler = null;
     }
     dict.lastWindowDimensions = null;
 
-    if (dict.modalHighlightOutline)
-      dict.modalHighlightOutline.setAttributeForElement(kModalOutlineId, "hidden", "true");
+    if (dict.modalHighlightOutline) {
+      dict.modalHighlightOutline.setAttributeForElement(kModalOutlineId, "style",
+        dict.modalHighlightOutline.getAttributeForElement(kModalOutlineId, "style") +
+        "; opacity: 0");
+    }
 
     this._removeHighlightAllMask(window);
     this._removeModalHighlightListeners(window);
     delete dict.brightText;
 
     dict.visible = false;
   },
 
@@ -469,22 +424,21 @@ FinderHighlighter.prototype = {
         this.show(window);
       else
         this._maybeCreateModalHighlightNodes(window);
 
       this._updateRangeOutline(dict, textContent, fontStyle);
     }
 
     outlineNode = dict.modalHighlightOutline;
-    try {
-      outlineNode.removeAttributeForElement(kModalOutlineId, "grow");
-    } catch (ex) {}
-    window.requestAnimationFrame(() => {
-      outlineNode.setAttributeForElement(kModalOutlineId, "grow", true);
-    });
+    if (dict.animation)
+      dict.animation.finish();
+    dict.animation = outlineNode.setAnimationForElement(kModalOutlineId,
+      Cu.cloneInto(kModalOutlineAnim.keyframes, window), kModalOutlineAnim.duration);
+    dict.animation.onfinish = () => dict.animation = null;
 
     if (this._highlightAll && data.searchString)
       this.highlight(true, data.searchString, data.linksOnly);
   },
 
   /**
    * Invalidates the list by clearing the map of highglighted ranges that we
    * keep to build the mask for.
@@ -495,16 +449,18 @@ FinderHighlighter.prototype = {
       for (let win of gWindows.keys())
         this.hide(win);
       // Reset the Map, because no range references a node anymore.
       gWindows.clear();
       return;
     }
 
     let dict = this.getForWindow(window.top);
+    if (dict.animation)
+      dict.animation.finish();
     dict.currentFoundRange = null;
     dict.dynamicRangesSet.clear();
     dict.frames.clear();
     dict.modalHighlightRectsMap.clear();
   },
 
   /**
    * When the current page is refreshed or navigated away from, the CanvasFrame
@@ -708,19 +664,38 @@ FinderHighlighter.prototype = {
    * @return {String}
    */
   _getHTMLFontStyle(fontStyle) {
     let style = [];
     for (let prop of Object.getOwnPropertyNames(fontStyle)) {
       let idx = kFontPropsCamelCase.indexOf(prop);
       if (idx == -1)
         continue;
-      style.push(`${kFontPropsCSS[idx]}: ${fontStyle[prop]};`);
+      style.push(`${kFontPropsCSS[idx]}: ${fontStyle[prop]}`);
     }
-    return style.join(" ");
+    return style.join("; ");
+  },
+
+  /**
+   * Transform a style definition array as defined in `kModalStyles` into a CSS
+   * string that can be used to set the 'style' property of a DOM node.
+   *
+   * @param  {Array}    stylePairs         Two-dimensional array of style pairs
+   * @param  {...Array} [additionalStyles] Optional set of style pairs that will
+   *                                       augment or override the styles defined
+   *                                       by `stylePairs`
+   * @return {String}
+   */
+  _getStyleString(stylePairs, ...additionalStyles) {
+    let baseStyle = new Map(stylePairs);
+    for (let additionalStyle of additionalStyles) {
+      for (let [prop, value] of additionalStyle)
+        baseStyle.set(prop, value);
+    }
+    return [...baseStyle].map(([cssProp, cssVal]) => `${cssProp}: ${cssVal}`).join("; ");
   },
 
   /**
    * Checks whether a CSS RGB color value can be classified as being 'bright'.
    *
    * @param  {String} cssColor RGB color value in the default format rgb[a](r,g,b)
    * @return {Boolean}
    */
@@ -733,17 +708,17 @@ FinderHighlighter.prototype = {
   },
 
   /**
    * Checks if a range is inside a DOM node that's positioned in a way that it
    * doesn't scroll along when the document is scrolled and/ or zoomed. This
    * is the case for 'fixed' and 'sticky' positioned elements and elements inside
    * (i)frames.
    *
-   * @param  {nsIDOMRange} range Range that be enclosed in a fixed container
+   * @param  {nsIDOMRange} range Range that be enclosed in a dynamic container
    * @return {Boolean}
    */
   _isInDynamicContainer(range) {
     const kFixed = new Set(["fixed", "sticky"]);
     let node = range.startContainer;
     while (node.nodeType != 1)
       node = node.parentNode;
     let document = node.ownerDocument;
@@ -810,22 +785,22 @@ FinderHighlighter.prototype = {
     dict.modalHighlightRectsMap.set(range, rects);
     if (checkIfDynamic && this._isInDynamicContainer(range))
       dict.dynamicRangesSet.add(range);
     return rects;
   },
 
   /**
    * Re-read the rectangles of the ranges that we keep track of separately,
-   * because they're enclosed by a position: fixed container DOM node.
+   * because they're enclosed by a position: fixed container DOM node or (i)frame.
    *
    * @param {Object} dict Dictionary of properties belonging to the currently
    *                      active window
    */
-  _updateFixedRangesRects(dict) {
+  _updateDynamicRangesRects(dict) {
     for (let range of dict.dynamicRangesSet)
       this._updateRangeRects(range, false, dict);
     // Reset the frame bounds cache.
     for (let frame of dict.frames.keys())
       dict.frames.set(frame, null);
   },
 
   /**
@@ -845,34 +820,37 @@ FinderHighlighter.prototype = {
     if (!outlineNode || !range)
       return;
     let rect = range.getClientRects()[0];
     if (!rect)
       return;
 
     if (!fontStyle)
       fontStyle = this._getRangeFontStyle(range);
-    // Text color in the outline is determined by our stylesheet.
+    // Text color in the outline is determined by kModalStyles.
     delete fontStyle.color;
 
     if (textContent.length)
       outlineNode.setTextContentForElement(kModalOutlineId + "-text", textContent.join(" "));
     // Correct the line-height to align the text in the middle of the box.
     fontStyle.lineHeight = rect.height + "px";
     outlineNode.setAttributeForElement(kModalOutlineId + "-text", "style",
+      this._getStyleString(kModalStyles.outlineText) + "; " +
       this._getHTMLFontStyle(fontStyle));
 
-    if (typeof outlineNode.getAttributeForElement(kModalOutlineId, "hidden") == "string")
-      outlineNode.removeAttributeForElement(kModalOutlineId, "hidden");
-
     let window = range.startContainer.ownerDocument.defaultView;
     let { left, top } = this._getRootBounds(window);
     outlineNode.setAttributeForElement(kModalOutlineId, "style",
-      `top: ${top + rect.top}px; left: ${left + rect.left}px;
-      height: ${rect.height}px; width: ${rect.width}px;`);
+      this._getStyleString(kModalStyles.outlineNode, [
+        ["top", top + rect.top + "px"],
+        ["left", left + rect.left + "px"],
+        ["height", rect.height + "px"],
+        ["width", rect.width + "px"]],
+        kDebug ? kModalStyles.outlineNodeDebug : []
+    ));
   },
 
   /**
    * Add a range to the list of ranges to highlight on, or cut out of, the dimmed
    * background.
    *
    * @param {nsIDOMRange}  range  Range object that should be inspected
    * @param {nsIDOMWindow} window Window object, whose DOM tree is being traversed
@@ -915,30 +893,29 @@ FinderHighlighter.prototype = {
       let onVisibilityChange = () => {
         document.removeEventListener("visibilitychange", onVisibilityChange);
         this._maybeCreateModalHighlightNodes(window);
       };
       document.addEventListener("visibilitychange", onVisibilityChange);
       return;
     }
 
-    this._maybeInstallStyleSheet(window);
-
     // The outline needs to be sitting inside a container, otherwise the anonymous
     // content API won't find it by its ID later...
     let container = document.createElement("div");
 
     // Create the main (yellow) highlight outline box.
     let outlineBox = document.createElement("div");
     outlineBox.setAttribute("id", kModalOutlineId);
-    outlineBox.className = kModalOutlineId + (kDebug ? ` ${kModalIdPrefix}-findbar-debug` : "");
+    outlineBox.setAttribute("style", this._getStyleString(kModalStyles.outlineNode,
+      kDebug ? kModalStyles.outlineNodeDebug : []));
     let outlineBoxText = document.createElement("span");
     let attrValue = kModalOutlineId + "-text";
     outlineBoxText.setAttribute("id", attrValue);
-    outlineBoxText.setAttribute("class", attrValue);
+    outlineBoxText.setAttribute("style", this._getStyleString(kModalStyles.outlineText));
     outlineBox.appendChild(outlineBoxText);
 
     container.appendChild(outlineBox);
     dict.modalHighlightOutline = kDebug ?
       mockAnonymousContentNode(document.body.appendChild(container.firstChild)) :
       document.insertAnonymousContent(container);
 
     // Make sure to at least show the dimmed background.
@@ -959,32 +936,35 @@ FinderHighlighter.prototype = {
     let document = window.document;
 
     const kMaskId = kModalIdPrefix + "-findbar-modalHighlight-outlineMask";
     let maskNode = document.createElement("div");
 
     // Make sure the dimmed mask node takes the full width and height that's available.
     let {width, height} = dict.lastWindowDimensions = this._getWindowDimensions(window);
     maskNode.setAttribute("id", kMaskId);
-    maskNode.setAttribute("class", kMaskId + (kDebug ? ` ${kModalIdPrefix}-findbar-debug` : ""));
-    maskNode.setAttribute("style", `width: ${width}px; height: ${height}px;`);
+    maskNode.setAttribute("style", this._getStyleString(kModalStyles.maskNode,
+      [ ["width", width + "px"], ["height", height + "px"] ],
+      dict.brightText ? kModalStyles.maskNodeBrightText : [],
+      kDebug ? kModalStyles.maskNodeDebug : []));
     if (dict.brightText)
       maskNode.setAttribute("brighttext", "true");
 
     if (paintContent || dict.modalHighlightAllMask) {
       this._updateRangeOutline(dict);
-      this._updateFixedRangesRects(dict);
+      this._updateDynamicRangesRects(dict);
       // Create a DOM node for each rectangle representing the ranges we found.
       let maskContent = [];
-      const kRectClassName = kModalIdPrefix + "-findbar-modalHighlight-rect";
+      const rectStyle = this._getStyleString(kModalStyles.maskRect,
+        dict.brightText ? kModalStyles.maskRectBrightText : []);
       for (let [range, rects] of dict.modalHighlightRectsMap) {
         if (dict.updateAllRanges)
           rects = this._updateRangeRects(range);
         for (let rect of rects) {
-          maskContent.push(`<div class="${kRectClassName}" style="top: ${rect.y}px;
+          maskContent.push(`<div style="${rectStyle}; top: ${rect.y}px;
             left: ${rect.x}px; height: ${rect.height}px; width: ${rect.width}px;"></div>`);
         }
       }
       dict.updateAllRanges = false;
       maskNode.innerHTML = maskContent.join("");
     }
 
     // Always remove the current mask and insert it a-fresh, because we're not
@@ -1026,34 +1006,34 @@ FinderHighlighter.prototype = {
    *
    * @param {nsIDOMWindow} window
    * @param {Object}       options Dictionary of painter hints that contains the
    *                               following properties:
    *   {Boolean} contentChanged  Whether the documents' content changed in the
    *                             meantime. This happens when the DOM is updated
    *                             whilst the page is loaded.
    *   {Boolean} scrollOnly      TRUE when the page has scrolled in the meantime,
-   *                             which means that the fixed positioned elements
-   *                             need to be repainted.
+   *                             which means that the dynamically positioned
+   *                             elements need to be repainted.
    *   {Boolean} updateAllRanges Whether to recalculate the rects of all ranges
    *                             that were found up until now.
    */
   _scheduleRepaintOfMask(window, { contentChanged, scrollOnly, updateAllRanges } =
                                  { contentChanged: false, scrollOnly: false, updateAllRanges: false }) {
     if (!this._modal)
       return;
 
     window = window.top;
     let dict = this.getForWindow(window);
-    let repaintFixedNodes = (scrollOnly && !!dict.dynamicRangesSet.size);
+    let repaintDynamicRanges = (scrollOnly && !!dict.dynamicRangesSet.size);
 
     // When we request to repaint unconditionally, we mean to call
     // `_repaintHighlightAllMask()` right after the timeout.
     if (!dict.unconditionalRepaintRequested)
-      dict.unconditionalRepaintRequested = !contentChanged || repaintFixedNodes;
+      dict.unconditionalRepaintRequested = !contentChanged || repaintDynamicRanges;
     // Some events, like a resize, call for recalculation of all the rects of all ranges.
     if (!dict.updateAllRanges)
       dict.updateAllRanges = updateAllRanges;
 
     if (dict.modalRepaintScheduler)
       return;
 
     dict.modalRepaintScheduler = window.setTimeout(() => {
@@ -1072,39 +1052,16 @@ FinderHighlighter.prototype = {
           (dict.modalHighlightRectsMap.size && pageContentChanged)) {
         dict.unconditionalRepaintRequested = false;
         this._repaintHighlightAllMask(window);
       }
     }, kModalHighlightRepaintFreqMs);
   },
 
   /**
-   * The outline that shows/ highlights the current found range is styled and
-   * animated using CSS. This style can be found in `kModalStyle`, but to have it
-   * applied on any DOM node we insert using the AnonymousContent API we need to
-   * inject an agent sheet into the document.
-   *
-   * @param {nsIDOMWindow} window
-   */
-  _maybeInstallStyleSheet(window) {
-    window = window.top;
-    let dict = this.getForWindow(window);
-    let document = window.document;
-    if (dict.installedSheet == document)
-      return;
-
-    let dwu = this._getDWU(window);
-    let uri = this.modalStyleSheetURI;
-    try {
-      dwu.loadSheetUsingURIString(uri, dwu.AGENT_SHEET);
-    } catch (e) {}
-    dict.installedSheet = document;
-  },
-
-  /**
    * Add event listeners to the content which will cause the modal highlight
    * AnonymousContent to be re-painted or hidden.
    *
    * @param {nsIDOMWindow} window
    */
   _addModalHighlightListeners(window) {
     window = window.top;
     let dict = this.getForWindow(window);