Bug 1297072 - added support for matrices to handle CSS 2D transformation on grid inspector; r=pbro draft
authorMatteo Ferretti <mferretti@mozilla.com>
Thu, 20 Apr 2017 19:17:02 +0200
changeset 571441 7ddae1eb33a2728bebbb23cdff9bfc52a07e6af8
parent 571440 8d2500866366d40a6702a070373914ccedc2cf8a
child 626761 f8e6ed7a356cdee77f93c5e0d3733c20430a3451
push id56789
push userbmo:zer0@mozilla.com
push dateTue, 02 May 2017 16:18:21 +0000
reviewerspbro
bugs1297072
milestone55.0a1
Bug 1297072 - added support for matrices to handle CSS 2D transformation on grid inspector; r=pbro - Fixed a bug on `getNodeBounds` that would makes the calculation wrong in case of nested frames. - Centralized all the transformation in `updateCurrentMatrix` function, including the scaling due the zoom and display's pixel ratio, and the translation to the top left corner of the node inspected. - Added the transformation from the inspected node to the `currentMatrix`. - Added `drawLine` and `drawRect` functions, that takes a matrix as argument. - Position the line's number to the grid itself even when we've infinite lines (it's not a regression, it is intended since if a grid is transformed, we could have weird results otherwise, so we decided to uniform the behaviors). MozReview-Commit-ID: 7OUfb6u63Qj
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,28 @@ const {
   createNode,
   createSVGNode,
   moveInfobar,
 } = require("./utils/markup");
 const {
   getCurrentZoom,
   getDisplayPixelRatio,
   setIgnoreLayoutChanges,
+  getNodeBounds,
   getViewportDimensions,
 } = require("devtools/shared/layout/utils");
+const {
+  identity,
+  apply,
+  translate,
+  multiply,
+  scale,
+  getNodeTransformationMatrix,
+  getNodeTransformOrigin
+} = require("devtools/shared/layout/dom-matrix-2d");
 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";
 const ROWS = "rows";
@@ -68,46 +78,109 @@ const gCachedGridPattern = new Map();
 //
 // 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;
 
+// This constant is used as value to draw infinite lines on canvas; since we cannot use
+// the canvas boundaries as coordinates to draw the lines, and then applying
+// transformations on top of them (the resulting coordinates might ending before reaching
+// the viewport's edges, therefore the lines won't looks as "infinite").
+const CANVAS_INFINITY = CANVAS_SIZE << 8;
+
+/**
+ * Draws a line to the context given, applying a transformation matrix if passed.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ *        The 2d canvas context.
+ * @param {Number} x1
+ *        The x-axis of the coordinate for the begin of the line.
+ * @param {Number} y1
+ *        The y-axis of the coordinate for the begin of the line.
+ * @param {Number} x2
+ *        The x-axis of the coordinate for the end of the line.
+ * @param {Number} y2
+ *        The y-axis of the coordinate for the end of the line.
+ * @param {Array} [matrix=identity()]
+ *        The transformation matrix to apply.
+ */
+function drawLine(ctx, x1, y1, x2, y2, matrix = identity()) {
+  let fromPoint = apply(matrix, [x1, y1]);
+  let toPoint = apply(matrix, [x2, y2]);
+
+  ctx.moveTo(Math.round(fromPoint[0]), Math.round(fromPoint[1]));
+  ctx.lineTo(Math.round(toPoint[0]), Math.round(toPoint[1]));
+}
+
+/**
+ * Draws a rect to the context given, applying a transformation matrix if passed.
+ * The coordinates are the start and end points of the rectangle's diagonal.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ *        The 2d canvas context.
+ * @param {Number} x1
+ *        The x-axis coordinate of the rectangle's diagonal start point.
+ * @param {Number} y1
+ *        The y-axis coordinate of the rectangle's diagonal start point.
+ * @param {Number} x2
+ *        The x-axis coordinate of the rectangle's diagonal end point.
+ * @param {Number} y2
+ *        The y-axis coordinate of the rectangle's diagonal end point.
+ * @param {Array} [matrix=identity()]
+ *        The transformation matrix to apply.
+ */
+function drawRect(ctx, x1, y1, x2, y2, matrix = identity()) {
+  let p = [
+    [x1, y1],
+    [x2, y1],
+    [x2, y2],
+    [x1, y2]
+  ].map(point => apply(matrix, point).map(Math.round));
+
+  ctx.beginPath();
+  ctx.moveTo(p[0][0], p[0][1]);
+  ctx.lineTo(p[1][0], p[1][1]);
+  ctx.lineTo(p[2][0], p[2][1]);
+  ctx.lineTo(p[3][0], p[3][1]);
+  ctx.closePath();
+}
+
 /**
  * Utility method to draw a rounded rectangle in the provided canvas context.
  *
  * @param  {CanvasRenderingContext2D} ctx
  *         The 2d canvas context.
  * @param  {Number} x
  *         The x-axis origin of the rectangle.
  * @param  {Number} y
  *         The y-axis origin of the rectangle.
  * @param  {Number} width
  *         The width of the rectangle.
  * @param  {Number} height
  *         The height of the rectangle.
  * @param  {Number} radius
  *         The radius of the rounding.
  */
-const roundedRect = function (ctx, x, y, width, height, radius) {
+function drawRoundedRect(ctx, x, y, width, height, radius) {
   ctx.beginPath();
   ctx.moveTo(x, y + radius);
   ctx.lineTo(x, y + height - radius);
   ctx.arcTo(x, y + height, x + radius, y + height, radius);
   ctx.lineTo(x + width - radius, y + height);
   ctx.arcTo(x + width, y + height, x + width, y + height - radius, radius);
   ctx.lineTo(x + width, y + radius);
   ctx.arcTo(x + width, y, x + width - radius, y, radius);
   ctx.lineTo(x + radius, y);
   ctx.arcTo(x, y, x, y + radius, radius);
   ctx.stroke();
   ctx.fill();
-};
+}
 
 /**
  * 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);
@@ -657,21 +730,23 @@ CssGridHighlighter.prototype = extend(Au
     // Updates the <canvas> element's position and size.
     // It also clear the <canvas>'s drawing context.
     this.updateCanvasElement();
 
     // Clear the grid area highlights.
     this.clearGridAreas();
     this.clearGridCell();
 
+    // Update the current matrix used in our canvas' rendering
+    this.updateCurrentMatrix();
+
     // 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);
+      this.renderFragment(fragment);
     }
 
     // Display the grid area highlights if needed.
     if (this.options.showAllGridAreas) {
       this.showAllGridAreas();
     } else if (this.options.showGridArea) {
       this.showGridArea(this.options.showGridArea);
     }
@@ -871,16 +946,59 @@ CssGridHighlighter.prototype = extend(Au
     // 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:${size}px;height:${size}px; transform: translate(${x}px, ${y}px);`);
 
     this.ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
   },
 
+  /**
+   * Updates the current matrix taking in account the following transformations, in this
+   * order:
+   *   1. The scale given by the display pixel ratio.
+   *   2. The translation to the top left corner of the element.
+   *   3. The scale given by the current zoom.
+   *   4. Any CSS transformation applied directly to the element (only 2D
+   *      transformation; the 3D transformation are flattened, see `dom-matrix-2d` module
+   *      for further details.)
+   *
+   *  The transformations of the element's ancestors are not currently computed (see
+   *  bug 1355675).
+   */
+  updateCurrentMatrix() {
+    let origin = getNodeTransformOrigin(this.currentNode);
+    let bounds = getNodeBounds(this.win, this.currentNode);
+    let nodeMatrix = getNodeTransformationMatrix(this.currentNode);
+
+    let ox = origin[0];
+    let oy = origin[1];
+
+    let m = identity();
+
+    // First, we scale based on the display's current pixel ratio.
+    m = multiply(m, scale(getDisplayPixelRatio(this.win)));
+    // Then we translate the origin to the node's top left corner.
+    m = multiply(m, translate(bounds.p1.x, bounds.p1.y));
+    // And scale based on the current zoom factor.
+    m = multiply(m, scale(getCurrentZoom(this.win)));
+    // Finally, we can apply the current node's transformation matrix, taking in account
+    // the `transform-origin` property.
+    if (nodeMatrix) {
+      m = multiply(m, translate(ox, oy));
+      m = multiply(m, nodeMatrix);
+      m = multiply(m, translate(-ox, -oy));
+      this.hasNodeTransformations = true;
+    } else {
+      this.hasNodeTransformations = false;
+    }
+
+    this.currentMatrix = m;
+  },
+
   getFirstRowLinePos(fragment) {
     return fragment.rows.lines[0].start;
   },
 
   getLastRowLinePos(fragment) {
     return fragment.rows.lines[fragment.rows.lines.length - 1].start;
   },
 
@@ -906,29 +1024,29 @@ CssGridHighlighter.prototype = extend(Au
     while (trackIndex >= 0 && tracks[trackIndex].type != "explicit") {
       trackIndex--;
     }
 
     // The grid line index is the grid track index + 1.
     return trackIndex + 1;
   },
 
-  renderFragment(fragment, quad) {
-    this.renderLines(fragment.cols, quad, COLUMNS, "left", "top", "height",
+  renderFragment(fragment) {
+    this.renderLines(fragment.cols, COLUMNS, "left", "top", "height",
                      this.getFirstRowLinePos(fragment),
                      this.getLastRowLinePos(fragment));
-    this.renderLines(fragment.rows, quad, ROWS, "top", "left", "width",
+    this.renderLines(fragment.rows, ROWS, "top", "left", "width",
                      this.getFirstColLinePos(fragment),
                      this.getLastColLinePos(fragment));
 
     // Line numbers are rendered in a 2nd step to avoid overlapping with existing lines.
     if (this.options.showGridLineNumbers) {
-      this.renderLineNumbers(fragment.cols, quad, COLUMNS, "left", "top",
+      this.renderLineNumbers(fragment.cols, COLUMNS, "left", "top",
                        this.getFirstRowLinePos(fragment));
-      this.renderLineNumbers(fragment.rows, quad, ROWS, "top", "left",
+      this.renderLineNumbers(fragment.rows, ROWS, "top", "left",
                        this.getFirstColLinePos(fragment));
     }
   },
 
   /**
    * Render the grid lines given the grid dimension information of the
    * column or row lines.
    *
@@ -947,32 +1065,31 @@ CssGridHighlighter.prototype = extend(Au
    * @param  {String} mainSize
    *         The main size of the given grid dimension - "width" for rows and
    *         "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,
+  renderLines(gridDimension, dimensionType, mainSide, crossSide,
               mainSize, startPos, endPos) {
-    let currentZoom = getCurrentZoom(this.win);
-    let lineStartPos = (bounds[crossSide] / currentZoom) + startPos;
-    let lineEndPos = (bounds[crossSide] / currentZoom) + endPos;
+    let lineStartPos = startPos;
+    let lineEndPos = endPos;
 
     if (this.options.showInfiniteLines) {
       lineStartPos = 0;
       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] / currentZoom) + line.start;
+      let linePos = line.start;
 
       if (i == 0 || i == lastEdgeLineIndex) {
         this.renderLine(linePos, lineStartPos, lineEndPos, dimensionType, "edge");
       } else {
         this.renderLine(linePos, lineStartPos, lineEndPos, dimensionType,
                         gridDimension.tracks[i - 1].type);
       }
 
@@ -987,27 +1104,23 @@ CssGridHighlighter.prototype = extend(Au
   },
 
   /**
    * Render the grid lines given the grid dimension information of the
    * column or row lines.
    *
    * see @param for renderLines.
    */
-  renderLineNumbers(gridDimension, {bounds}, dimensionType, mainSide, crossSide,
+  renderLineNumbers(gridDimension, dimensionType, mainSide, crossSide,
               startPos) {
-    let zoom = getCurrentZoom(this.win);
-    let lineStartPos = (bounds[crossSide] / zoom) + startPos;
-    if (this.options.showInfiniteLines) {
-      lineStartPos = 0;
-    }
+    let lineStartPos = startPos;
 
     for (let i = 0; i < gridDimension.lines.length; i++) {
       let line = gridDimension.lines[i];
-      let linePos = (bounds[mainSide] / zoom) + line.start;
+      let linePos = line.start;
       this.renderGridLineNumber(line.number, linePos, lineStartPos, line.breadth,
         dimensionType);
     }
   },
 
   /**
    * Render the grid line on the css grid highlighter canvas.
    *
@@ -1026,33 +1139,41 @@ CssGridHighlighter.prototype = extend(Au
   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);
+    linePos = Math.round(linePos);
+    startPos = Math.round(startPos);
 
     this.ctx.save();
     this.ctx.setLineDash(GRID_LINES_PROPERTIES[lineType].lineDash);
     this.ctx.beginPath();
     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);
+      if (isFinite(endPos)) {
+        endPos = Math.round(endPos);
+      } else {
+        endPos = CANVAS_INFINITY;
+        startPos = -endPos;
+      }
+      drawLine(this.ctx, linePos, startPos, linePos, endPos, this.currentMatrix);
     } else {
-      endPos = isFinite(endPos) ? endPos * devicePixelRatio : CANVAS_SIZE + x;
-      this.ctx.moveTo(startPos, linePos);
-      this.ctx.lineTo(endPos, linePos);
+      if (isFinite(endPos)) {
+        endPos = Math.round(endPos);
+      } else {
+        endPos = CANVAS_INFINITY;
+        startPos = -endPos;
+      }
+      drawLine(this.ctx, startPos, linePos, endPos, linePos, this.currentMatrix);
     }
 
     this.ctx.strokeStyle = this.color;
     this.ctx.globalAlpha = GRID_LINES_PROPERTIES[lineType].alpha;
 
     this.ctx.stroke();
     this.ctx.restore();
   },
@@ -1068,32 +1189,33 @@ CssGridHighlighter.prototype = extend(Au
    * @param  {Number} startPos
    *         The start 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.
    */
   renderGridLineNumber(lineNumber, linePos, startPos, breadth, dimensionType) {
+    let displayPixelRatio = getDisplayPixelRatio(this.win);
     let { devicePixelRatio } = this.win;
-    let displayPixelRatio = getDisplayPixelRatio(this.win);
+    let offset = (displayPixelRatio / 2) % 1;
 
-    linePos = Math.round(linePos * devicePixelRatio);
-    startPos = Math.round(startPos * devicePixelRatio);
-    breadth = Math.round(breadth * devicePixelRatio);
+    linePos = Math.round(linePos);
+    startPos = Math.round(startPos);
+    breadth = Math.round(breadth);
 
     if (linePos + breadth < 0) {
       // The line is not visible on screen, don't render the line number
       return;
     }
 
     this.ctx.save();
     let canvasX = Math.round(this._canvasPosition.x * devicePixelRatio);
     let canvasY = Math.round(this._canvasPosition.y * devicePixelRatio);
-    this.ctx.translate(.5 - canvasX, .5 - canvasY);
+    this.ctx.translate(offset - canvasX, offset - canvasY);
 
     let fontSize = (GRID_FONT_SIZE * displayPixelRatio);
     this.ctx.font = fontSize + "px " + GRID_FONT_FAMILY;
 
     let textWidth = this.ctx.measureText(lineNumber).width;
 
     // The width of the character 'm' approximates the height of the text.
     let textHeight = this.ctx.measureText("m").width;
@@ -1102,36 +1224,42 @@ CssGridHighlighter.prototype = extend(Au
     let padding = 3 * displayPixelRatio;
 
     let boxWidth = textWidth + 2 * padding;
     let boxHeight = textHeight + 2 * padding;
 
     // Calculate the x & y coordinates for the line number container, so that it is
     // centered on the line, and in the middle of the gap if there is any.
     let x, y;
+
     if (dimensionType === COLUMNS) {
-      x = linePos - boxWidth / 2;
-      y = startPos - boxHeight / 2;
-      x += breadth / 2;
+      x = linePos + breadth / 2;
+      y = startPos;
     } else {
-      x = startPos - boxWidth / 2;
-      y = linePos - boxHeight / 2;
-      y += breadth / 2;
+      x = startPos;
+      y = linePos + breadth / 2;
     }
 
-    x = Math.max(x, padding);
-    y = Math.max(y, padding);
+    [x, y] = apply(this.currentMatrix, [x, y]);
+
+    x -= boxWidth / 2;
+    y -= boxHeight / 2;
 
-    // Draw a rounded rectangle with a border width of 4 pixels, a border color matching
+    if (!this.hasNodeTransformations) {
+      x = Math.max(x, padding);
+      y = Math.max(y, padding);
+    }
+
+    // Draw a rounded rectangle with a border width of 2 pixels, a border color matching
     // the grid color and a white background (the line number will be written in black).
     this.ctx.lineWidth = 2 * displayPixelRatio;
     this.ctx.strokeStyle = this.color;
     this.ctx.fillStyle = "white";
     let radius = 2 * displayPixelRatio;
-    roundedRect(this.ctx, x, y, boxWidth, boxHeight, radius);
+    drawRoundedRect(this.ctx, x, y, boxWidth, boxHeight, radius);
 
     // Write the line number inside of the rectangle.
     this.ctx.fillStyle = "black";
     this.ctx.fillText(lineNumber, x + padding, y + textHeight + padding);
 
     this.ctx.restore();
   },
 
@@ -1147,34 +1275,50 @@ 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);
+    let displayPixelRatio = getDisplayPixelRatio(this.win);
+    let offset = (displayPixelRatio / 2) % 1;
 
-    linePos = Math.round(linePos * devicePixelRatio);
-    startPos = Math.round(startPos * devicePixelRatio);
-    breadth = Math.round(breadth * devicePixelRatio);
+    let canvasX = Math.round(this._canvasPosition.x * devicePixelRatio);
+    let canvasY = Math.round(this._canvasPosition.y * devicePixelRatio);
+
+    linePos = Math.round(linePos);
+    startPos = Math.round(startPos);
+    breadth = Math.round(breadth);
 
     this.ctx.save();
     this.ctx.fillStyle = this.getGridGapPattern(devicePixelRatio, dimensionType);
-    this.ctx.translate(.5 - x, .5 - y);
+    this.ctx.translate(offset - canvasX, offset - canvasY);
 
     if (dimensionType === COLUMNS) {
-      endPos = isFinite(endPos) ? Math.round(endPos * devicePixelRatio) : CANVAS_SIZE + y;
-      this.ctx.fillRect(linePos, startPos, breadth, endPos - startPos);
+      if (isFinite(endPos)) {
+        endPos = Math.round(endPos);
+      } else {
+        endPos = this._winDimensions.height;
+        startPos = -endPos;
+      }
+      drawRect(this.ctx, linePos, startPos, linePos + breadth, endPos,
+        this.currentMatrix);
     } else {
-      endPos = isFinite(endPos) ? Math.round(endPos * devicePixelRatio) : CANVAS_SIZE + x;
-      this.ctx.fillRect(startPos, linePos, endPos - startPos, breadth);
+      if (isFinite(endPos)) {
+        endPos = Math.round(endPos);
+      } else {
+        endPos = this._winDimensions.width;
+        startPos = -endPos;
+      }
+      drawRect(this.ctx, startPos, linePos, endPos, linePos + breadth,
+        this.currentMatrix);
     }
+    this.ctx.fill();
     this.ctx.restore();
   },
 
   /**
    * Render the grid area highlight for the given area name or for all the grid areas.
    *
    * @param  {String} areaName
    *         Name of the grid area to be highlighted. If no area name is provided, all
--- a/devtools/shared/layout/utils.js
+++ b/devtools/shared/layout/utils.js
@@ -352,31 +352,34 @@ function getNodeBounds(boundaryWindow, n
     if (el.scrollLeft) {
       offsetLeft -= el.scrollLeft;
     }
     el = el.parentNode;
   }
 
   // And add the potential frame offset if the node is nested
   let [xOffset, yOffset] = getFrameOffsets(boundaryWindow, node);
-  xOffset += offsetLeft + scrollX;
-  yOffset += offsetTop + scrollY;
-
-  xOffset *= scale;
-  yOffset *= scale;
+  xOffset += (offsetLeft + scrollX) * scale;
+  yOffset += (offsetTop + scrollY) * scale;
 
   // Get the width and height
   let width = node.offsetWidth * scale;
   let height = node.offsetHeight * scale;
 
   return {
     p1: {x: xOffset, y: yOffset},
     p2: {x: xOffset + width, y: yOffset},
     p3: {x: xOffset + width, y: yOffset + height},
-    p4: {x: xOffset, y: yOffset + height}
+    p4: {x: xOffset, y: yOffset + height},
+    top: yOffset,
+    right: xOffset + width,
+    bottom: yOffset + height,
+    left: xOffset,
+    width,
+    height
   };
 }
 exports.getNodeBounds = getNodeBounds;
 
 /**
  * Same as doing iframe.contentWindow but works with all types of container
  * elements that act like frames (e.g. <embed>), where 'contentWindow' isn't a
  * property that can be accessed.