Bug 1345434 - Implementation of a virtual canvas technique for grid highlighter; r=pbro draft
authorMatteo Ferretti <mferretti@mozilla.com>
Tue, 04 Apr 2017 15:11:04 +0200
changeset 555558 3e0903aaa5090d0a9e56284eba7c5dc257441e35
parent 555554 b6cbd404c8453d0f726eaab50eceeba22d859554
child 622627 94116a3df344715548ad4621142ae06f7410f204
push id52259
push userbmo:zer0@mozilla.com
push dateTue, 04 Apr 2017 13:16:42 +0000
reviewerspbro
bugs1345434
milestone55.0a1
Bug 1345434 - Implementation of a virtual canvas technique for grid highlighter; r=pbro A virtual canvas is basically a canvas that seems bigger than is actually is. The technique consists in moving a fixed sized canvas during the scrolling, when is needed, to give the illusion that it always covers the entire document. MozReview-Commit-ID: Hp4cUZaBdm8
devtools/server/actors/highlighters/css-grid.js
devtools/shared/layout/utils.js
--- a/devtools/server/actors/highlighters/css-grid.js
+++ b/devtools/server/actors/highlighters/css-grid.js
@@ -12,18 +12,17 @@ const {
   createNode,
   createSVGNode,
   moveInfobar,
 } = require("./utils/markup");
 const {
   getCurrentZoom,
   getDisplayPixelRatio,
   setIgnoreLayoutChanges,
-  getWindowDimensions,
-  getMaxSurfaceSize,
+  getViewportDimensions,
 } = require("devtools/shared/layout/utils");
 const { stringifyGridFragments } = require("devtools/server/actors/utils/css-grid-utils");
 
 const CSS_GRID_ENABLED_PREF = "layout.css.grid.enabled";
 
 const DEFAULT_GRID_COLOR = "#4B0082";
 
 const COLUMNS = "cols";
@@ -52,28 +51,32 @@ const GRID_GAP_PATTERN_HEIGHT = 14; // p
 const GRID_GAP_PATTERN_LINE_DASH = [5, 3]; // px
 const GRID_GAP_ALPHA = 0.5;
 
 /**
  * Cached used by `CssGridHighlighter.getGridGapPattern`.
  */
 const gCachedGridPattern = new Map();
 
-// That's the maximum size we can allocate for the canvas, in bytes. See:
-// http://searchfox.org/mozilla-central/source/gfx/thebes/gfxPrefs.h#401
-// It might become accessible as user preference, but at the moment we have to hard code
-// it (see: https://bugzilla.mozilla.org/show_bug.cgi?id=1282656).
-const MAX_ALLOC_SIZE = 500000000;
-// One pixel on canvas is using 4 bytes (R, G, B and Alpha); we use this to calculate the
-// proper memory allocation below
-const BYTES_PER_PIXEL = 4;
-// The maximum allocable pixels the canvas can have
-const MAX_ALLOC_PIXELS = MAX_ALLOC_SIZE / BYTES_PER_PIXEL;
-// The maximum allocable pixels per side in a square canvas
-const MAX_ALLOC_PIXELS_PER_SIDE = Math.sqrt(MAX_ALLOC_PIXELS)|0;
+// We create a <canvas> element that has always 4096x4096 physical pixels, to displays
+// our grid's overlay.
+// Then, we move the element around when needed, to give the perception that it always
+// covers the screen (See bug 1345434).
+//
+// This canvas size value is the safest we can use because most GPUs can handle it.
+// It's also far from the maximum canvas memory allocation limit (4096x4096x4 is
+// 67.108.864 bytes, where the limit is 500.000.000 bytes, see:
+// http://searchfox.org/mozilla-central/source/gfx/thebes/gfxPrefs.h#401).
+//
+// Note:
+// Once bug 1232491 lands, we could try to refactor this code to use the values from
+// the displayport API instead.
+//
+// Using a fixed value should also solve bug 1348293.
+const CANVAS_SIZE = 4096;
 
 /**
  * The CssGridHighlighter is the class that overlays a visual grid on top of
  * display:[inline-]grid elements.
  *
  * Usage example:
  * let h = new CssGridHighlighter(env);
  * h.show(node, options);
@@ -127,39 +130,38 @@ const MAX_ALLOC_PIXELS_PER_SIDE = Math.s
  *       </div>
  *     </div>
  *   </div>
  * </div>
  */
 function CssGridHighlighter(highlighterEnv) {
   AutoRefreshHighlighter.call(this, highlighterEnv);
 
-  this.maxCanvasSizePerSide = getMaxSurfaceSize(this.highlighterEnv.window);
-
-  // We cache the previous content's size so we're able to understand when it will
-  // change. The `width` and `height` are expressed in physical pixels in order to react
-  // also at any variation of zoom / pixel ratio.
-  // We initialize with `0` so it will check also at the first `_update()` iteration.
-  this._contentSize = {
-    width: 0,
-    height: 0
-  };
-
   this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv,
     this._buildMarkup.bind(this));
 
   this.onNavigate = this.onNavigate.bind(this);
   this.onPageHide = this.onPageHide.bind(this);
   this.onWillNavigate = this.onWillNavigate.bind(this);
 
   this.highlighterEnv.on("navigate", this.onNavigate);
   this.highlighterEnv.on("will-navigate", this.onWillNavigate);
 
   let { pageListenerTarget } = highlighterEnv;
   pageListenerTarget.addEventListener("pagehide", this.onPageHide);
+
+  // Initialize the <canvas> position to the top left corner of the page
+  this._canvasPosition = {
+    x: 0,
+    y: 0
+  };
+
+  // Calling `calculateCanvasPosition` anyway since the highlighter could be initialized
+  // on a page that has scrolled already.
+  this.calculateCanvasPosition();
 }
 
 CssGridHighlighter.prototype = extend(AutoRefreshHighlighter.prototype, {
   typeName: "CssGridHighlighter",
 
   ID_CLASS_PREFIX: "css-grid-",
 
   _buildMarkup() {
@@ -182,17 +184,19 @@ CssGridHighlighter.prototype = extend(Au
     // which wouldn't be possible with HTML or SVG without having to insert and remove
     // the whole markup on every update.
     createNode(this.win, {
       parent: root,
       nodeType: "canvas",
       attributes: {
         "id": "canvas",
         "class": "canvas",
-        "hidden": "true"
+        "hidden": "true",
+        "width": CANVAS_SIZE,
+        "height": CANVAS_SIZE
       },
       prefix: this.ID_CLASS_PREFIX
     });
 
     // Build the SVG element
     let svg = createSVGNode(this.win, {
       nodeType: "svg",
       parent: root,
@@ -538,22 +542,25 @@ CssGridHighlighter.prototype = extend(Au
    */
   _update() {
     setIgnoreLayoutChanges(true);
 
     let root = this.getElement("root");
     // Hide the root element and force the reflow in order to get the proper window's
     // dimensions without increasing them.
     root.setAttribute("style", "display: none");
-    this.currentNode.offsetWidth;
+    this.win.document.documentElement.offsetWidth;
+
+    let { width, height } = this._winDimensions;
 
-    let { width, height } = getWindowDimensions(this.win);
+    // Updates the <canvas> element's position and size.
+    // It also clear the <canvas>'s drawing context.
+    this.updateCanvasElement();
 
-    // Clear the canvas the grid area highlights.
-    this.clearCanvas(width, height);
+    // Clear the grid area highlights.
     this.clearGridAreas();
     this.clearGridCell();
 
     // Start drawing the grid fragments.
     for (let i = 0; i < this.gridData.length; i++) {
       let fragment = this.gridData[i];
       let quad = this.currentQuads.content[i];
       this.renderFragment(fragment, quad);
@@ -646,84 +653,104 @@ CssGridHighlighter.prototype = extend(Au
       width: x2 - x1,
       x: x1,
       y: y1,
     };
 
     moveInfobar(container, bounds, this.win);
   },
 
-  clearCanvas(width, height) {
-    let ratio = parseFloat((this.win.devicePixelRatio || 1).toFixed(2));
-
-    height *= ratio;
-    width *= ratio;
+  /**
+   * The <canvas>'s position needs to be updated if the page scrolls too much, in order
+   * to give the illusion that it always covers the viewport.
+   */
+  _scrollUpdate() {
+    let hasPositionChanged = this.calculateCanvasPosition();
 
-    let hasResolutionChanged = false;
-    if (height !== this._contentSize.height || width !== this._contentSize.width) {
-      hasResolutionChanged = true;
-      this._contentSize.width = width;
-      this._contentSize.height = height;
+    if (hasPositionChanged) {
+      this._update();
     }
+  },
 
-    let isCanvasClipped = false;
+  /**
+   * This method is responsible to do the math that updates the <canvas>'s position,
+   * in accordance with the page's scroll, document's size, canvas size, and
+   * viewport's size.
+   * It's called when a page's scroll is detected.
+   *
+   * @return {Boolean} `true` if the <canvas> position was updated, `false` otherwise.
+   */
+  calculateCanvasPosition() {
+    let cssCanvasSize = CANVAS_SIZE / this.win.devicePixelRatio;
+    let viewportSize = getViewportDimensions(this.win);
+    let documentSize = this._winDimensions;
+    let pageX = this._scroll.x;
+    let pageY = this._scroll.y;
+    let canvasWidth = cssCanvasSize;
+    let canvasHeight = cssCanvasSize;
+    let hasUpdated = false;
 
-    if (height > this.maxCanvasSizePerSide) {
-      height = this.maxCanvasSizePerSide;
-      isCanvasClipped = true;
-    }
+    // Those values indicates the relative horizontal and vertical space the page can
+    // scroll before we have to reposition the <canvas>; they're 1/4 of the delta between
+    // the canvas' size and the viewport's size: that's because we want to consider both
+    // sides (top/bottom, left/right; so 1/2 for each side) and also we don't want to
+    // shown the edges of the canvas in case of fast scrolling (to avoid showing undraw
+    // areas, therefore another 1/2 here).
+    let bufferSizeX = (canvasWidth - viewportSize.width) >> 2;
+    let bufferSizeY = (canvasHeight - viewportSize.height) >> 2;
+
+    let { x, y } = this._canvasPosition;
 
-    if (width > this.maxCanvasSizePerSide) {
-      width = this.maxCanvasSizePerSide;
-      isCanvasClipped = true;
+    // Defines the boundaries for the canvas.
+    let topBoundary = 0;
+    let bottomBoundary = documentSize.height - canvasHeight;
+    let leftBoundary = 0;
+    let rightBoundary = documentSize.width - canvasWidth;
+
+    // Defines the thresholds that triggers the canvas' position to be updated.
+    let topThreshold = pageY - bufferSizeY;
+    let bottomThreshold = pageY - canvasHeight + viewportSize.height + bufferSizeY;
+    let leftThreshold = pageX - bufferSizeX;
+    let rightThreshold = pageX - canvasWidth + viewportSize.width + bufferSizeX;
+
+    if (y < bottomBoundary && y < bottomThreshold) {
+      this._canvasPosition.y = Math.min(topThreshold, bottomBoundary);
+      hasUpdated = true;
+    } else if (y > topBoundary && y > topThreshold) {
+      this._canvasPosition.y = Math.max(bottomThreshold, topBoundary);
+      hasUpdated = true;
     }
 
-    // `maxCanvasSizePerSide` has the maximum size per side, but we have to consider
-    // also the memory allocation limit.
-    // For example, a 16384x16384 canvas will exceeds the current MAX_ALLOC_PIXELS
-    if (width * height > MAX_ALLOC_PIXELS) {
-      isCanvasClipped = true;
-      // We want to keep more or less the same ratio of the document's size.
-      // Therefore we don't only check if `height` is greater than `width`, but also
-      // that `width` is not greater than MAX_ALLOC_PIXELS_PER_SIDE (otherwise we'll end
-      // up to reduce `height` in favor of `width`, for example).
-      if (height > width && width < MAX_ALLOC_PIXELS_PER_SIDE) {
-        height = (MAX_ALLOC_PIXELS / width) |0;
-      } else if (width > height && height < MAX_ALLOC_PIXELS_PER_SIDE) {
-        width = (MAX_ALLOC_PIXELS / height) |0;
-      } else {
-        // fallback to a square canvas with the maximum pixels per side Available
-        height = width = MAX_ALLOC_PIXELS_PER_SIDE;
-      }
+    if (x < rightBoundary && x < rightThreshold) {
+      this._canvasPosition.x = Math.min(leftThreshold, rightBoundary);
+      hasUpdated = true;
+    } else if (x > leftBoundary && x > leftThreshold) {
+      this._canvasPosition.x = Math.max(rightThreshold, leftBoundary);
+      hasUpdated = true;
     }
 
-    // We warn the user that we had to clip the canvas, but only if resolution has
-    // changed since the last time.
-    // This is only a temporary workaround, and the warning message is supposed to be
-    // non-localized.
-    // Bug 1345434 will get rid of this.
-    if (hasResolutionChanged && isCanvasClipped) {
-      // We display the warning in the web console, so the user will be able to see it.
-      // Unfortunately that would also display the source, where if clicked , will ends
-      // in a non-existing document.
-      // It's not ideal, but from an highlighter there is no an easy way to show such
-      // notification elsewhere.
-      this.win.console.warn("The CSS Grid Highlighter could have been clipped, due " +
-                            "the size of the document inspected\n" +
-                            "See https://bugzilla.mozilla.org/show_bug.cgi?id=1343217 " +
-                            "for further information.");
-    }
+    return hasUpdated;
+  },
 
-    // Resize the canvas taking the dpr into account so as to have crisp lines.
-    this.canvas.setAttribute("width", width);
-    this.canvas.setAttribute("height", height);
+  /**
+   *  Updates the <canvas> element's style in accordance with the current window's
+   * devicePixelRatio, and the position calculated in `calculateCanvasPosition`; it also
+   * clears the drawing context.
+   */
+  updateCanvasElement() {
+    let ratio = parseFloat((this.win.devicePixelRatio || 1).toFixed(2));
+    let size = CANVAS_SIZE / ratio;
+    let { x, y } = this._canvasPosition;
+
+    // Resize the canvas taking the dpr into account so as to have crisp lines, and
+    // translating it to give the perception that it always covers the viewport.
     this.canvas.setAttribute("style",
-      `width:${width / ratio}px;height:${height / ratio}px;`);
+      `width:${size}px;height:${size}px; transform: translate(${x}px, ${y}px);`);
 
-    this.ctx.clearRect(0, 0, width, height);
+    this.ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
   },
 
   getFirstRowLinePos(fragment) {
     return fragment.rows.lines[0].start;
   },
 
   getLastRowLinePos(fragment) {
     return fragment.rows.lines[fragment.rows.lines.length - 1].start;
@@ -786,29 +813,30 @@ CssGridHighlighter.prototype = extend(Au
    *         "height" for columns.
    * @param  {Number} startPos
    *         The start position of the cross side of the grid dimension.
    * @param  {Number} endPos
    *         The end position of the cross side of the grid dimension.
    */
   renderLines(gridDimension, {bounds}, dimensionType, mainSide, crossSide,
               mainSize, startPos, endPos) {
-    let lineStartPos = (bounds[crossSide] / getCurrentZoom(this.win)) + startPos;
-    let lineEndPos = (bounds[crossSide] / getCurrentZoom(this.win)) + endPos;
+    let currentZoom = getCurrentZoom(this.win);
+    let lineStartPos = (bounds[crossSide] / currentZoom) + startPos;
+    let lineEndPos = (bounds[crossSide] / currentZoom) + endPos;
 
     if (this.options.showInfiniteLines) {
       lineStartPos = 0;
-      lineEndPos = parseInt(this.canvas.getAttribute(mainSize), 10);
+      lineEndPos = Infinity;
     }
 
     let lastEdgeLineIndex = this.getLastEdgeLineIndex(gridDimension.tracks);
 
     for (let i = 0; i < gridDimension.lines.length; i++) {
       let line = gridDimension.lines[i];
-      let linePos = (bounds[mainSide] / getCurrentZoom(this.win)) + line.start;
+      let linePos = (bounds[mainSide] / currentZoom) + line.start;
 
       if (this.options.showGridLineNumbers) {
         this.renderGridLineNumber(line.number, linePos, lineStartPos, dimensionType);
       }
 
       if (i == 0 || i == lastEdgeLineIndex) {
         this.renderLine(linePos, lineStartPos, lineEndPos, dimensionType, "edge");
       } else {
@@ -841,30 +869,34 @@ CssGridHighlighter.prototype = extend(Au
    * @param  {String} lineType
    *         The grid line type - "edge", "explicit", or "implicit".
    */
   renderLine(linePos, startPos, endPos, dimensionType, lineType) {
     let { devicePixelRatio } = this.win;
     let lineWidth = getDisplayPixelRatio(this.win);
     let offset = (lineWidth / 2) % 1;
 
+    let x = Math.round(this._canvasPosition.x * devicePixelRatio);
+    let y = Math.round(this._canvasPosition.y * devicePixelRatio);
+
     linePos = Math.round(linePos * devicePixelRatio);
     startPos = Math.round(startPos * devicePixelRatio);
-    endPos = Math.round(endPos * devicePixelRatio);
 
     this.ctx.save();
     this.ctx.setLineDash(GRID_LINES_PROPERTIES[lineType].lineDash);
     this.ctx.beginPath();
-    this.ctx.translate(offset, offset);
+    this.ctx.translate(offset - x, offset - y);
     this.ctx.lineWidth = lineWidth;
 
     if (dimensionType === COLUMNS) {
+      endPos = isFinite(endPos) ? endPos * devicePixelRatio : CANVAS_SIZE + y;
       this.ctx.moveTo(linePos, startPos);
       this.ctx.lineTo(linePos, endPos);
     } else {
+      endPos = isFinite(endPos) ? endPos * devicePixelRatio : CANVAS_SIZE + x;
       this.ctx.moveTo(startPos, linePos);
       this.ctx.lineTo(endPos, linePos);
     }
 
     this.ctx.strokeStyle = this.color;
     this.ctx.globalAlpha = GRID_LINES_PROPERTIES[lineType].alpha;
 
     this.ctx.stroke();
@@ -882,21 +914,24 @@ CssGridHighlighter.prototype = extend(Au
    * @param  {Number} startPos
    *         The start position of the cross side of the grid line.
    * @param  {String} dimensionType
    *         The grid dimension type which is either the constant COLUMNS or ROWS.
    */
   renderGridLineNumber(lineNumber, linePos, startPos, dimensionType) {
     let { devicePixelRatio } = this.win;
     let displayPixelRatio = getDisplayPixelRatio(this.win);
+    let x = Math.round(this._canvasPosition.x * devicePixelRatio);
+    let y = Math.round(this._canvasPosition.y * devicePixelRatio);
 
     linePos = Math.round(linePos * devicePixelRatio);
     startPos = Math.round(startPos * devicePixelRatio);
 
     this.ctx.save();
+    this.ctx.translate(.5 - x, .5 - y);
 
     let fontSize = (GRID_FONT_SIZE * displayPixelRatio);
     this.ctx.font = fontSize + "px " + GRID_FONT_FAMILY;
 
     let textWidth = this.ctx.measureText(lineNumber).width;
 
     if (dimensionType === COLUMNS) {
       let yPos = Math.max(startPos, fontSize);
@@ -921,28 +956,32 @@ CssGridHighlighter.prototype = extend(Au
    *         The end position of the cross side of the grid line.
    * @param  {Number} breadth
    *         The grid line breadth value.
    * @param  {String} dimensionType
    *         The grid dimension type which is either the constant COLUMNS or ROWS.
    */
   renderGridGap(linePos, startPos, endPos, breadth, dimensionType) {
     let { devicePixelRatio } = this.win;
+    let x = Math.round(this._canvasPosition.x * devicePixelRatio);
+    let y = Math.round(this._canvasPosition.y * devicePixelRatio);
 
     linePos = Math.round(linePos * devicePixelRatio);
     startPos = Math.round(startPos * devicePixelRatio);
-    endPos = Math.round(endPos * devicePixelRatio);
     breadth = Math.round(breadth * devicePixelRatio);
 
     this.ctx.save();
     this.ctx.fillStyle = this.getGridGapPattern(devicePixelRatio, dimensionType);
+    this.ctx.translate(.5 - x, .5 - y);
 
     if (dimensionType === COLUMNS) {
+      endPos = isFinite(endPos) ? Math.round(endPos * devicePixelRatio) : CANVAS_SIZE + y;
       this.ctx.fillRect(linePos, startPos, breadth, endPos - startPos);
     } else {
+      endPos = isFinite(endPos) ? Math.round(endPos * devicePixelRatio) : CANVAS_SIZE + x;
       this.ctx.fillRect(startPos, linePos, endPos - startPos, breadth);
     }
     this.ctx.restore();
   },
 
   /**
    * Render the grid area highlight for the given area name or for all the grid areas.
    *
--- a/devtools/shared/layout/utils.js
+++ b/devtools/shared/layout/utils.js
@@ -692,36 +692,16 @@ function getViewportDimensions(window) {
   let width = window.innerWidth - scrollbarWidth.value;
   let height = window.innerHeight - scrollbarHeight.value;
 
   return { width, height };
 }
 exports.getViewportDimensions = getViewportDimensions;
 
 /**
- * Returns the max size allowed for a surface like textures or canvas.
- * If no `webgl` context is available, DEFAULT_MAX_SURFACE_SIZE is returned instead.
- *
- * @param {DOMNode|DOMWindow|DOMDocument} node The node to get the window for.
- * @return {Number} the max size allowed
- */
-const DEFAULT_MAX_SURFACE_SIZE = 4096;
-function getMaxSurfaceSize(node) {
-  let canvas = getWindowFor(node).document.createElement("canvas");
-  let gl = canvas.getContext("webgl");
-
-  if (!gl) {
-    return DEFAULT_MAX_SURFACE_SIZE;
-  }
-
-  return gl.getParameter(gl.MAX_TEXTURE_SIZE);
-}
-exports.getMaxSurfaceSize = getMaxSurfaceSize;
-
-/**
  * Return the default view for a given node, where node can be:
  * - a DOM node
  * - the document node
  * - the window itself
  * @param {DOMNode|DOMWindow|DOMDocument} node The node to get the window for.
  * @return {DOMWindow}
  */
 function getWindowFor(node) {