Bug 1282721 - Add transform mode to shapes highlighter with translation/scaling for all shapes. r=pbro draft
authorMike Park <mikeparkms@gmail.com>
Tue, 10 Oct 2017 14:05:50 -0400
changeset 686314 9ced79fea867b22646e0b7db5377ec5833761071
parent 683402 f6f0d58ecc85cc2974573bd84552252bab1aa2fa
child 686315 c20c041a65da865b990464c86f95e1ae7150c79b
push id86153
push userbmo:mpark@mozilla.com
push dateWed, 25 Oct 2017 18:03:13 +0000
reviewerspbro
bugs1282721
milestone58.0a1
Bug 1282721 - Add transform mode to shapes highlighter with translation/scaling for all shapes. r=pbro MozReview-Commit-ID: HhWY1pT7Mqu
devtools/client/inspector/shared/highlighters-overlay.js
devtools/server/actors/highlighters.css
devtools/server/actors/highlighters/shapes.js
devtools/server/actors/utils/shapes-utils.js
--- a/devtools/client/inspector/shared/highlighters-overlay.js
+++ b/devtools/client/inspector/shared/highlighters-overlay.js
@@ -113,17 +113,18 @@ HighlightersOverlay.prototype = {
    *
    * @param  {NodeFront} node
    *         The NodeFront of the element with a shape to highlight.
    * @param  {Object} options
    *         Object used for passing options to the shapes highlighter.
    */
   toggleShapesHighlighter: Task.async(function* (node, options = {}) {
     if (node == this.shapesHighlighterShown &&
-        options.mode === this.state.shapes.options.mode) {
+        options.mode === this.state.shapes.options.mode &&
+        options.transformMode === this.state.shapes.options.transformMode) {
       yield this.hideShapesHighlighter(node);
       return;
     }
 
     yield this.showShapesHighlighter(node, options);
   }),
 
   /**
@@ -613,17 +614,18 @@ HighlightersOverlay.prototype = {
    */
   isRuleViewShapePoint: function (nodeInfo) {
     let isShape = nodeInfo.type === VIEW_NODE_SHAPE_POINT_TYPE &&
                   (nodeInfo.value.property === "clip-path" ||
                   nodeInfo.value.property === "shape-outside");
     let isEnabled = nodeInfo.value.enabled &&
                     !nodeInfo.value.overridden &&
                     !nodeInfo.value.pseudoElement;
-    return this.isRuleView && isShape && isEnabled && nodeInfo.value.toggleActive;
+    return this.isRuleView && isShape && isEnabled && nodeInfo.value.toggleActive &&
+           !this.state.shapes.options.transformMode;
   },
 
   onClick: function (event) {
     let classList = event.target.classList;
     if (this._isRuleViewDisplayGrid(event.target)) {
       event.stopPropagation();
 
       let { store } = this.inspector;
@@ -632,17 +634,20 @@ HighlightersOverlay.prototype = {
 
       highlighterSettings.color = grid ? grid.color : DEFAULT_GRID_COLOR;
 
       this.toggleGridHighlighter(this.inspector.selection.nodeFront, highlighterSettings,
         "rule");
     } else if (this._isRuleViewShape(event.target)) {
       event.stopPropagation();
 
-      let settings = { mode: event.target.dataset.mode };
+      let settings = {
+        mode: event.target.dataset.mode,
+        transformMode: event.metaKey || event.ctrlKey
+      };
       this.toggleShapesHighlighter(this.inspector.selection.nodeFront, settings);
     } else if (classList.contains("ruleview-shape-point") && event.shiftKey) {
       let view = this.isRuleView ?
         this.inspector.getPanel("ruleview").view :
         this.inspector.getPanel("computedview").computedView;
       let nodeInfo = view.getNodeInfo(event.target);
       if (!nodeInfo || !this.isRuleViewShapePoint(nodeInfo)) {
         return;
--- a/devtools/server/actors/highlighters.css
+++ b/devtools/server/actors/highlighters.css
@@ -600,17 +600,18 @@
 
 :-moz-native-anonymous .shapes-shape-container {
   position: absolute;
   overflow: visible;
 }
 
 :-moz-native-anonymous .shapes-polygon,
 :-moz-native-anonymous .shapes-ellipse,
-:-moz-native-anonymous .shapes-rect {
+:-moz-native-anonymous .shapes-rect,
+:-moz-native-anonymous .shapes-bounding-box {
   fill: transparent;
   stroke: var(--highlighter-guide-color);
   shape-rendering: geometricPrecision;
   vector-effect: non-scaling-stroke;
 }
 
 :-moz-native-anonymous .shapes-markers {
   fill: #fff;
--- a/devtools/server/actors/highlighters/shapes.js
+++ b/devtools/server/actors/highlighters/shapes.js
@@ -10,17 +10,18 @@ const { setIgnoreLayoutChanges, getCurre
         getAdjustedQuads, getFrameOffsets } = require("devtools/shared/layout/utils");
 const { AutoRefreshHighlighter } = require("./auto-refresh");
 const {
   getDistance,
   clickedOnEllipseEdge,
   distanceToLine,
   projection,
   clickedOnPoint,
-  roundTo
+  roundTo,
+  scalePoint
 } = require("devtools/server/actors/utils/shapes-utils");
 const EventEmitter = require("devtools/shared/old-event-emitter");
 const { getCSSStyleRules } = require("devtools/shared/inspector/css-logic");
 
 const BASE_MARKER_SIZE = 5;
 // the width of the area around highlighter lines that can be clicked, in px
 const LINE_CLICK_WIDTH = 5;
 const DOM_EVENTS = ["mousedown", "mousemove", "mouseup", "dblclick"];
@@ -41,16 +42,17 @@ class ShapesHighlighter extends AutoRefr
     this.ID_CLASS_PREFIX = "shapes-";
 
     this.referenceBox = "border";
     this.useStrokeBox = false;
     this.geometryBox = "";
     this.hoveredPoint = null;
     this.fillRule = "";
     this.numInsetPoints = 0;
+    this.transformMode = false;
 
     this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv,
       this._buildMarkup.bind(this));
     this.onPageHide = this.onPageHide.bind(this);
 
     let { pageListenerTarget } = this.highlighterEnv;
     DOM_EVENTS.forEach(event => pageListenerTarget.addEventListener(event, this));
     pageListenerTarget.addEventListener("pagehide", this.onPageHide);
@@ -116,16 +118,28 @@ class ShapesHighlighter extends AutoRefr
       attributes: {
         "id": "rect",
         "class": "rect",
         "hidden": true
       },
       prefix: this.ID_CLASS_PREFIX
     });
 
+    createSVGNode(this.win, {
+      nodeType: "rect",
+      parent: mainSvg,
+      attributes: {
+        "id": "bounding-box",
+        "class": "bounding-box",
+        "stroke-dasharray": "5, 5",
+        "hidden": true
+      },
+      prefix: this.ID_CLASS_PREFIX
+    });
+
     // Append a path to display the markers for the shape.
     createSVGNode(this.win, {
       nodeType: "path",
       parent: mainSvg,
       attributes: {
         "id": "markers-outline",
         "class": "markers-outline",
       },
@@ -229,17 +243,19 @@ class ShapesHighlighter extends AutoRefr
         // If a page hide event is triggered for current window's highlighter, hide the
         // highlighter.
         if (target.defaultView === this.win) {
           this.destroy();
         }
 
         break;
       case "mousedown":
-        if (this.shapeType === "polygon") {
+        if (this.transformMode) {
+          this._handleTransformClick(pageX, pageY);
+        } else if (this.shapeType === "polygon") {
           this._handlePolygonClick(pageX, pageY);
         } else if (this.shapeType === "circle") {
           this._handleCircleClick(pageX, pageY);
         } else if (this.shapeType === "ellipse") {
           this._handleEllipseClick(pageX, pageY);
         } else if (this.shapeType === "inset") {
           this._handleInsetClick(pageX, pageY);
         }
@@ -255,45 +271,394 @@ class ShapesHighlighter extends AutoRefr
         if (!this[_dragging]) {
           this._handleMouseMoveNotDragging(pageX, pageY);
           return;
         }
         event.stopPropagation();
         event.preventDefault();
 
         let { point } = this[_dragging];
-        if (this.shapeType === "polygon") {
+        if (this.transformMode) {
+          this._handleTransformMove(pageX, pageY);
+        } else if (this.shapeType === "polygon") {
           this._handlePolygonMove(pageX, pageY);
         } else if (this.shapeType === "circle") {
           this._handleCircleMove(point, pageX, pageY);
         } else if (this.shapeType === "ellipse") {
           this._handleEllipseMove(point, pageX, pageY);
         } else if (this.shapeType === "inset") {
           this._handleInsetMove(point, pageX, pageY);
         }
         break;
       case "dblclick":
-        if (this.shapeType === "polygon") {
+        if (this.shapeType === "polygon" && !this.transformMode) {
           let { percentX, percentY } = this.convertPageCoordsToPercent(pageX, pageY);
           let index = this.getPolygonPointAt(percentX, percentY);
           if (index === -1) {
             this.getPolygonClickedLine(percentX, percentY);
             return;
           }
 
           this._deletePolygonPoint(index);
         }
         break;
     }
   }
 
   /**
+   * Handle a mouse click in transform mode.
+   * @param {Number} pageX the x coordinate of the mouse
+   * @param {Number} pageY the y coordinate of the mouse
+   */
+  _handleTransformClick(pageX, pageY) {
+    let { percentX, percentY } = this.convertPageCoordsToPercent(pageX, pageY);
+    let type = this.getTransformPointAt(percentX, percentY);
+    if (!type) {
+      return;
+    }
+
+    if (this.shapeType === "polygon") {
+      this._handlePolygonTransformClick(pageX, pageY, type);
+    } else if (this.shapeType === "circle") {
+      this._handleCircleTransformClick(pageX, pageY, type);
+    } else if (this.shapeType === "ellipse") {
+      this._handleEllipseTransformClick(pageX, pageY, type);
+    } else if (this.shapeType === "inset") {
+      this._handleInsetTransformClick(pageX, pageY, type);
+    }
+  }
+
+  /**
+   * Handle a click in transform mode while highlighting a polygon.
+   * @param {Number} pageX the x coordinate of the mouse.
+   * @param {Number} pageY the y coordinate of the mouse.
+   * @param {String} type the type of transform handle that was clicked.
+   */
+  _handlePolygonTransformClick(pageX, pageY, type) {
+    let { width, height } = this.zoomAdjustedDimensions;
+    let pointsInfo = this.coordUnits.map(([x, y], i) => {
+      let xComputed = this.coordinates[i][0] / 100 * width;
+      let yComputed = this.coordinates[i][1] / 100 * height;
+      let unitX = getUnit(x);
+      let unitY = getUnit(y);
+      let valueX = (isUnitless(x)) ? xComputed : parseFloat(x);
+      let valueY = (isUnitless(y)) ? yComputed : parseFloat(y);
+
+      let ratioX = (valueX / xComputed) || 1;
+      let ratioY = (valueY / yComputed) || 1;
+      return { unitX, unitY, valueX, valueY, ratioX, ratioY };
+    });
+    this[_dragging] = { type, pointsInfo, x: pageX, y: pageY, bb: this.boundingBox };
+  }
+
+  /**
+   * Handle a click in transform mode while highlighting a circle.
+   * @param {Number} pageX the x coordinate of the mouse.
+   * @param {Number} pageY the y coordinate of the mouse.
+   * @param {String} type the type of transform handle that was clicked.
+   */
+  _handleCircleTransformClick(pageX, pageY, type) {
+    let { width, height } = this.zoomAdjustedDimensions;
+    let { cx, cy } = this.coordUnits;
+    let cxComputed = this.coordinates.cx / 100 * width;
+    let cyComputed = this.coordinates.cy / 100 * height;
+    let unitX = getUnit(cx);
+    let unitY = getUnit(cy);
+    let valueX = (isUnitless(cx)) ? cxComputed : parseFloat(cx);
+    let valueY = (isUnitless(cy)) ? cyComputed : parseFloat(cy);
+
+    let ratioX = (valueX / cxComputed) || 1;
+    let ratioY = (valueY / cyComputed) || 1;
+
+    let { radius } = this.coordinates;
+    let computedSize = Math.sqrt((width ** 2) + (height ** 2)) / Math.sqrt(2);
+    radius = radius / 100 * computedSize;
+    let valueRad = this.coordUnits.radius;
+    let unitRad = getUnit(valueRad);
+    valueRad = (isUnitless(valueRad)) ? radius : parseFloat(valueRad);
+    let ratioRad = (valueRad / radius) || 1;
+
+    this[_dragging] = { type, unitX, unitY, unitRad, valueX, valueY,
+                        ratioX, ratioY, ratioRad, x: pageX, y: pageY,
+                        bb: this.boundingBox };
+  }
+
+  /**
+   * Handle a click in transform mode while highlighting an ellipse.
+   * @param {Number} pageX the x coordinate of the mouse.
+   * @param {Number} pageY the y coordinate of the mouse.
+   * @param {String} type the type of transform handle that was clicked.
+   */
+  _handleEllipseTransformClick(pageX, pageY, type) {
+    let { width, height } = this.zoomAdjustedDimensions;
+    let { cx, cy } = this.coordUnits;
+    let cxComputed = this.coordinates.cx / 100 * width;
+    let cyComputed = this.coordinates.cy / 100 * height;
+    let unitX = getUnit(cx);
+    let unitY = getUnit(cy);
+    let valueX = (isUnitless(cx)) ? cxComputed : parseFloat(cx);
+    let valueY = (isUnitless(cy)) ? cyComputed : parseFloat(cy);
+
+    let ratioX = (valueX / cxComputed) || 1;
+    let ratioY = (valueY / cyComputed) || 1;
+
+    let { rx, ry } = this.coordinates;
+    rx = rx / 100 * width;
+    let valueRX = this.coordUnits.rx;
+    let unitRX = getUnit(valueRX);
+    valueRX = (isUnitless(valueRX)) ? rx : parseFloat(valueRX);
+    let ratioRX = (valueRX / rx) || 1;
+    ry = ry / 100 * height;
+    let valueRY = this.coordUnits.ry;
+    let unitRY = getUnit(valueRY);
+    valueRY = (isUnitless(valueRY)) ? ry : parseFloat(valueRY);
+    let ratioRY = (valueRY / ry) || 1;
+
+    this[_dragging] = { type, unitX, unitY, unitRX, unitRY,
+                        valueX, valueY, ratioX, ratioY, ratioRX, ratioRY,
+                        x: pageX, y: pageY, bb: this.boundingBox };
+  }
+
+  /**
+   * Handle a click in transform mode while highlighting an inset.
+   * @param {Number} pageX the x coordinate of the mouse.
+   * @param {Number} pageY the y coordinate of the mouse.
+   * @param {String} type the type of transform handle that was clicked.
+   */
+  _handleInsetTransformClick(pageX, pageY, type) {
+    let { width, height } = this.zoomAdjustedDimensions;
+    let pointsInfo = ["top", "right", "bottom", "left"].map(point => {
+      let value = this.coordUnits[point];
+      let size = (point === "left" || point === "right") ? width : height;
+      let computedValue = this.coordinates[point] / 100 * size;
+      let unit = getUnit(value);
+      value = (isUnitless(value)) ? computedValue : parseFloat(value);
+      let ratio = (value / computedValue) || 1;
+
+      return { point, value, unit, ratio };
+    });
+    this[_dragging] = { type, pointsInfo, x: pageX, y: pageY, bb: this.boundingBox };
+  }
+
+  /**
+   * Handle mouse movement after a click on a handle in transform mode.
+   * @param {Number} pageX the x coordinate of the mouse
+   * @param {Number} pageY the y coordinate of the mouse
+   */
+  _handleTransformMove(pageX, pageY) {
+    let { type, pointsInfo, x, y } = this[_dragging];
+    if (type === "translate") {
+      if (this.shapeType === "polygon") {
+        let polygonDef = (this.fillRule) ? `${this.fillRule}, ` : "";
+        polygonDef += pointsInfo.map(({ unitX, unitY, valueX,
+                                        valueY, ratioX, ratioY }) => {
+          let deltaX = (pageX - x) * ratioX;
+          let deltaY = (pageY - y) * ratioY;
+          let newX = `${valueX + deltaX}${unitX}`;
+          let newY = `${valueY + deltaY}${unitY}`;
+          return `${newX} ${newY}`;
+        }).join(", ");
+        polygonDef = (this.geometryBox) ? `polygon(${polygonDef}) ${this.geometryBox}` :
+                                          `polygon(${polygonDef})`;
+
+        this.currentNode.style.setProperty(this.property, polygonDef, "important");
+      } else if (this.shapeType === "circle") {
+        this._handleCircleMove("center", pageX, pageY);
+      } else if (this.shapeType === "ellipse") {
+        this._handleEllipseMove("center", pageX, pageY);
+      } else if (this.shapeType === "inset") {
+        let newCoords = {};
+        pointsInfo.forEach(({point, value, unit, ratio}) => {
+          let delta = (point === "top" || point === "bottom") ? pageY - y : pageX - x;
+          let newCoord = (point === "top" || point === "left") ?
+            `${value + delta * ratio}${unit}` : `${value - delta * ratio}${unit}`;
+          newCoords[point] = newCoord;
+        });
+        let { top, right, bottom, left } = newCoords;
+        let round = this.insetRound;
+        let insetDef = (round) ?
+          `inset(${top} ${right} ${bottom} ${left} round ${round})` :
+          `inset(${top} ${right} ${bottom} ${left})`;
+        insetDef += (this.geometryBox) ? this.geometryBox : "";
+
+        this.currentNode.style.setProperty(this.property, insetDef, "important");
+      }
+    } else if (type.includes("scale")) {
+      // To scale a shape:
+      // 1) Calculate the scaling proportion by getting the proportion of the distance
+      //    between the original click and the current mouse position on each axis to
+      //    the width/height of the shape and taking the average.
+      // 2) Translate the shape such that the anchor (the corner diagonally opposite
+      //    to the one being dragged) is at the top left of the element.
+      // 3) Scale each point by multiplying by the scaling proportion.
+      // 4) Translate the shape back such that the anchor is in its original position.
+
+      let { bb } = this[_dragging];
+      let { minX, minY, maxX, maxY } = bb;
+      let { width, height } = this.zoomAdjustedDimensions;
+
+      // How much points on each axis should be translated before scaling
+      let transX = (type === "scale-se" || type === "scale-ne") ?
+      minX / 100 * width : maxX / 100 * width;
+      let transY = (type === "scale-se" || type === "scale-sw") ?
+      minY / 100 * height : maxY / 100 * height;
+
+      let { percentX, percentY } = this.convertPageCoordsToPercent(x, y);
+      let { percentX: percentPageX,
+          percentY: percentPageY } = this.convertPageCoordsToPercent(pageX, pageY);
+      // distance from original click to current mouse position, in %
+      let distanceX = (type === "scale-se" || type === "scale-ne") ?
+      percentPageX - percentX : percentX - percentPageX;
+      let distanceY = (type === "scale-se" || type === "scale-sw") ?
+      percentPageY - percentY : percentY - percentPageY;
+
+      // scale = 1 + proportion of distance to bounding box width/height of shape
+      let scaleX = 1 + distanceX / (maxX - minX);
+      let scaleY = 1 + distanceY / (maxY - minY);
+      let scale = (scaleX + scaleY) / 2;
+
+      if (this.shapeType === "polygon") {
+        this._scalePolygon(pageX, pageY, transX, transY, scale);
+      } else if (this.shapeType === "circle") {
+        this._scaleCircle(pageX, pageY, transX, transY, scale);
+      } else if (this.shapeType === "ellipse") {
+        this._scaleEllipse(pageX, pageY, transX, transY, scale);
+      } else if (this.shapeType === "inset") {
+        this._scaleInset(pageX, pageY, transX, transY, scale);
+      }
+    }
+  }
+
+  /**
+   * Scale a polygon depending on mouse position after clicking on a corner handle.
+   * @param {Number} pageX the x coordinate of the mouse
+   * @param {Number} pageY the y coordinate of the mouse
+   * @param {Number} transX the number of pixels to translate on the x axis before scaling
+   * @param {Number} transY the number of pixels to translate on the y axis before scaling
+   * @param {Number} scale the proportion to scale by
+   */
+  _scalePolygon(pageX, pageY, transX, transY, scale) {
+    let { pointsInfo } = this[_dragging];
+
+    let polygonDef = (this.fillRule) ? `${this.fillRule}, ` : "";
+    polygonDef += pointsInfo.map(point => {
+      let { unitX, unitY, valueX, valueY, ratioX, ratioY } = point;
+      let [newX, newY] = scalePoint(valueX, valueY, transX * ratioX,
+                                    transY * ratioY, scale);
+      return `${newX}${unitX} ${newY}${unitY}`;
+    }).join(", ");
+    polygonDef = (this.geometryBox) ? `polygon(${polygonDef}) ${this.geometryBox}` :
+                                      `polygon(${polygonDef})`;
+
+    this.currentNode.style.setProperty(this.property, polygonDef, "important");
+  }
+
+  /**
+   * Scale a circle depending on mouse position after clicking on a corner handle.
+   * @param {Number} pageX the x coordinate of the mouse
+   * @param {Number} pageY the y coordinate of the mouse
+   * @param {Number} transX the number of pixels to translate on the x axis before scaling
+   * @param {Number} transY the number of pixels to translate on the y axis before scaling
+   * @param {Number} scale the proportion to scale by
+   */
+  _scaleCircle(pageX, pageY, transX, transY, scale) {
+    let { unitX, unitY, unitRad, valueX, valueY,
+          ratioX, ratioY, ratioRad } = this[_dragging];
+
+    let [newCx, newCy] = scalePoint(valueX, valueY, transX * ratioX,
+                                    transY * ratioY, scale);
+    // As part of scaling, the center is translated to be tangent to the line y=0.
+    // To get the new radius, we scale the new cx back to that point and get the distance
+    // to the line y=0.
+    let newRadius = `${Math.abs((newCx / ratioX - transX) * ratioRad)}${unitRad}`;
+
+    let circleDef = (this.geometryBox) ?
+      `circle(${newRadius} at ${newCx}${unitX} ${newCy}${unitY} ${this.geometryBox}` :
+      `circle(${newRadius} at ${newCx}${unitX} ${newCy}${unitY}`;
+    this.currentNode.style.setProperty(this.property, circleDef, "important");
+  }
+
+  /**
+   * Scale an ellipse depending on mouse position after clicking on a corner handle.
+   * @param {Number} pageX the x coordinate of the mouse
+   * @param {Number} pageY the y coordinate of the mouse
+   * @param {Number} transX the number of pixels to translate on the x axis before scaling
+   * @param {Number} transY the number of pixels to translate on the y axis before scaling
+   * @param {Number} scale the proportion to scale by
+   */
+  _scaleEllipse(pageX, pageY, transX, transY, scale) {
+    let { unitX, unitY, unitRX, unitRY, valueX, valueY,
+          ratioX, ratioY, ratioRX, ratioRY } = this[_dragging];
+
+    let [newCx, newCy] = scalePoint(valueX, valueY, transX * ratioX,
+                                    transY * ratioY, scale);
+    // As part of scaling, the center is translated to be tangent to the lines y=0 & x=0.
+    // To get the new radii, we scale the new center back to that point and get the
+    // distances to the line x=0 and y=0.
+    let newRx = `${Math.abs((newCx / ratioX - transX) * ratioRX)}${unitRX}`;
+    let newRy = `${Math.abs((newCy / ratioY - transY) * ratioRY)}${unitRY}`;
+    newCx = `${newCx}${unitX}`;
+    newCy = `${newCy}${unitY}`;
+
+    let ellipseDef = (this.geometryBox) ?
+        `ellipse(${newRx} ${newRy} at ${newCx} ${newCy}) ${this.geometryBox}` :
+        `ellipse(${newRx} ${newRy} at ${newCx} ${newCy})`;
+    this.currentNode.style.setProperty(this.property, ellipseDef, "important");
+  }
+
+  /**
+   * Scale an inset depending on mouse position after clicking on a corner handle.
+   * @param {Number} pageX the x coordinate of the mouse
+   * @param {Number} pageY the y coordinate of the mouse
+   * @param {Number} transX the number of pixels to translate on the x axis before scaling
+   * @param {Number} transY the number of pixels to translate on the y axis before scaling
+   * @param {Number} scale the proportion to scale by
+   */
+  _scaleInset(pageX, pageY, transX, transY, scale) {
+    let { pointsInfo } = this[_dragging];
+    let { width, height } = this.zoomAdjustedDimensions;
+
+    let newCoords = {};
+    pointsInfo.forEach(({ point, value, unit, ratio }) => {
+      let transValue = (point === "left" || point === "right") ?
+        transX * ratio : transY * ratio;
+
+      // Right and bottom values are relative to the right and bottom edges of the
+      // element, so convert to the value relative to the left/top edges before scaling
+      // and convert back.
+      if (point === "right") {
+        value = width * ratio - value;
+        let newPoint = (value - transValue) * scale + transValue;
+        newPoint = width * ratio - newPoint;
+        newCoords[point] = `${newPoint}${unit}`;
+      } else if (point === "bottom") {
+        value = height * ratio - value;
+        let newPoint = (value - transValue) * scale + transValue;
+        newPoint = height * ratio - newPoint;
+        newCoords[point] = `${newPoint}${unit}`;
+      } else {
+        let newPoint = (value - transValue) * scale + transValue;
+        newCoords[point] = `${newPoint}${unit}`;
+      }
+    });
+
+    let { top, right, bottom, left } = newCoords;
+    let round = this.insetRound;
+    let insetDef = (round) ?
+          `inset(${top} ${right} ${bottom} ${left} round ${round})` :
+          `inset(${top} ${right} ${bottom} ${left})`;
+    insetDef += (this.geometryBox) ? this.geometryBox : "";
+
+    this.currentNode.style.setProperty(this.property, insetDef, "important");
+  }
+
+  /**
    * Handle a click when highlighting a polygon.
-   * @param {any} pageX the x coordinate of the click
-   * @param {any} pageY the y coordinate of the click
+   * @param {Number} pageX the x coordinate of the click
+   * @param {Number} pageY the y coordinate of the click
    */
   _handlePolygonClick(pageX, pageY) {
     let { width, height } = this.zoomAdjustedDimensions;
     let { percentX, percentY } = this.convertPageCoordsToPercent(pageX, pageY);
     let point = this.getPolygonPointAt(percentX, percentY);
     if (point === -1) {
       return;
     }
@@ -371,18 +736,18 @@ class ShapesHighlighter extends AutoRefr
                                       `polygon(${polygonDef})`;
 
     this.hoveredPoint = null;
     this._emitHoverEvent(this.hoveredPoint);
     this.currentNode.style.setProperty(this.property, polygonDef, "important");
   }
   /**
    * Handle a click when highlighting a circle.
-   * @param {any} pageX the x coordinate of the click
-   * @param {any} pageY the y coordinate of the click
+   * @param {Number} pageX the x coordinate of the click
+   * @param {Number} pageY the y coordinate of the click
    */
   _handleCircleClick(pageX, pageY) {
     let { width, height } = this.zoomAdjustedDimensions;
     let { percentX, percentY } = this.convertPageCoordsToPercent(pageX, pageY);
     let point = this.getCirclePointAt(percentX, percentY);
     if (!point) {
       return;
     }
@@ -452,18 +817,18 @@ class ShapesHighlighter extends AutoRefr
                       `circle(${newRadius} at ${cx} ${cy}`;
 
       this.currentNode.style.setProperty(this.property, circleDef, "important");
     }
   }
 
   /**
    * Handle a click when highlighting an ellipse.
-   * @param {any} pageX the x coordinate of the click
-   * @param {any} pageY the y coordinate of the click
+   * @param {Number} pageX the x coordinate of the click
+   * @param {Number} pageY the y coordinate of the click
    */
   _handleEllipseClick(pageX, pageY) {
     let { width, height } = this.zoomAdjustedDimensions;
     let { percentX, percentY } = this.convertPageCoordsToPercent(pageX, pageY);
     let point = this.getEllipsePointAt(percentX, percentY);
     if (!point) {
       return;
     }
@@ -551,18 +916,18 @@ class ShapesHighlighter extends AutoRefr
         `ellipse(${rx} ${newRadius} at ${cx} ${cy})`;
 
       this.currentNode.style.setProperty(this.property, ellipseDef, "important");
     }
   }
 
   /**
    * Handle a click when highlighting an inset.
-   * @param {any} pageX the x coordinate of the click
-   * @param {any} pageY the y coordinate of the click
+   * @param {Number} pageX the x coordinate of the click
+   * @param {Number} pageY the y coordinate of the click
    */
   _handleInsetClick(pageX, pageY) {
     let { width, height } = this.zoomAdjustedDimensions;
     let { percentX, percentY } = this.convertPageCoordsToPercent(pageX, pageY);
     let point = this.getInsetPointAt(percentX, percentY);
     if (!point) {
       return;
     }
@@ -612,17 +977,21 @@ class ShapesHighlighter extends AutoRefr
 
     insetDef += (this.geometryBox) ? this.geometryBox : "";
 
     this.currentNode.style.setProperty(this.property, insetDef, "important");
   }
 
   _handleMouseMoveNotDragging(pageX, pageY) {
     let { percentX, percentY } = this.convertPageCoordsToPercent(pageX, pageY);
-    if (this.shapeType === "polygon") {
+    if (this.transformMode) {
+      let point = this.getTransformPointAt(percentX, percentY);
+      this.hoveredPoint = point;
+      this._handleMarkerHover(point);
+    } else if (this.shapeType === "polygon") {
       let point = this.getPolygonPointAt(percentX, percentY);
       let oldHoveredPoint = this.hoveredPoint;
       this.hoveredPoint = (point !== -1) ? point : null;
       if (this.hoveredPoint !== oldHoveredPoint) {
         this._emitHoverEvent(this.hoveredPoint);
       }
       this._handleMarkerHover(point);
     } else if (this.shapeType === "circle") {
@@ -654,17 +1023,38 @@ class ShapesHighlighter extends AutoRefr
 
   _handleMarkerHover(point) {
     // Hide hover marker for now, will be shown if point is a valid hover target
     this.getElement("marker-hover").setAttribute("hidden", true);
     if (point === null || point === undefined) {
       return;
     }
 
-    if (this.shapeType === "polygon") {
+    if (this.transformMode) {
+      if (!point) {
+        return;
+      }
+      let { minX, minY, maxX, maxY } = this.boundingBox;
+      let centerX = (minX + maxX) / 2;
+      let centerY = (minY + maxY) / 2;
+
+      const points = [
+        { pointName: "translate", x: centerX, y: centerY },
+        { pointName: "scale-se", x: maxX, y: maxY },
+        { pointName: "scale-ne", x: maxX, y: minY },
+        { pointName: "scale-sw", x: minX, y: maxY },
+        { pointName: "scale-nw", x: minX, y: minY },
+      ];
+
+      for (let { pointName, x, y } of points) {
+        if (point === pointName) {
+          this._drawHoverMarker([[x, y]]);
+        }
+      }
+    } else if (this.shapeType === "polygon") {
       if (point === -1) {
         return;
       }
       this._drawHoverMarker([this.coordinates[point]]);
     } else if (this.shapeType === "circle") {
       let { cx, cy, rx } = this.coordinates;
       if (point === "radius") {
         this._drawHoverMarker([[cx + rx, cy]]);
@@ -751,33 +1141,68 @@ class ShapesHighlighter extends AutoRefr
     let percentX = pageX * 100 / width;
     let percentY = pageY * 100 / height;
     return { percentX, percentY };
   }
 
   /**
    * Convert the given x/y coordinates, in percentages relative to the current element,
    * to pixel coordinates relative to the page
-   * @param {any} x the x coordinate
-   * @param {any} y the y coordinate
+   * @param {Number} x the x coordinate
+   * @param {Number} y the y coordinate
    * @returns {Object} object of form {x, y}, which are the x/y coords in pixels
    *          relative to the page
    *
    * @memberof ShapesHighlighter
    */
   convertPercentToPageCoords(x, y) {
     let { top, left, width, height } = this.zoomAdjustedDimensions;
     x = x * width / 100;
     y = y * height / 100;
     x += left;
     y += top;
     return { x, y };
   }
 
   /**
+   * Get which transformation should be applied based on the mouse position.
+   * @param {Number} pageX the x coordinate of the mouse.
+   * @param {Number} pageY the y coordinate of the mouse.
+   * @returns {String} a string describing the transformation that should be applied
+   *          to the shape.
+   */
+  getTransformPointAt(pageX, pageY) {
+    let { minX, minY, maxX, maxY } = this.boundingBox;
+    let { width, height } = this.zoomAdjustedDimensions;
+    let zoom = getCurrentZoom(this.win);
+    let clickRadiusX = BASE_MARKER_SIZE / zoom * 100 / width;
+    let clickRadiusY = BASE_MARKER_SIZE / zoom * 100 / height;
+
+    let centerX = (minX + maxX) / 2;
+    let centerY = (minY + maxY) / 2;
+
+    const points = [
+      { point: "translate", x: centerX, y: centerY },
+      { point: "scale-se", x: maxX, y: maxY },
+      { point: "scale-ne", x: maxX, y: minY },
+      { point: "scale-sw", x: minX, y: maxY },
+      { point: "scale-nw", x: minX, y: minY },
+    ];
+
+    for (let { point, x, y } of points) {
+      if (pageX >= x - clickRadiusX && pageX <= x + clickRadiusX &&
+          pageY >= y - clickRadiusY && pageY <= y + clickRadiusY) {
+        return point;
+      }
+    }
+
+    return "";
+  }
+
+  /**
    * Get the id of the point on the polygon highlighter at the given coordinate.
    * @param {Number} pageX the x coordinate on the page, in % relative to the element
    * @param {Number} pageY the y coordinate on the page, in % relative to the element
    * @returns {Number} the index of the point that was clicked on in this.coordinates,
    *          or -1 if none of the points were clicked on.
    */
   getPolygonPointAt(pageX, pageY) {
     let { coordinates } = this;
@@ -996,19 +1421,38 @@ class ShapesHighlighter extends AutoRefr
     this.coordUnits = this.polygonRawPoints();
     let splitDef = definition.split(", ");
     if (splitDef[0].includes("nonzero") || splitDef[0].includes("evenodd")) {
       splitDef.shift();
     }
     this.pixelCoords = splitDef.map(coords => {
       return splitCoords(coords).map(this.convertCoordsToPixel.bind(this));
     });
-    return splitDef.map(coords => {
-      return splitCoords(coords).map(this.convertCoordsToPercent.bind(this));
+    let minX = Number.MAX_SAFE_INTEGER;
+    let minY = Number.MAX_SAFE_INTEGER;
+    let maxX = Number.MIN_SAFE_INTEGER;
+    let maxY = Number.MIN_SAFE_INTEGER;
+    let coordinates = splitDef.map(coords => {
+      let [x, y] = splitCoords(coords).map(this.convertCoordsToPercent.bind(this));
+      if (x < minX) {
+        minX = x;
+      }
+      if (y < minY) {
+        minY = y;
+      }
+      if (x > maxX) {
+        maxX = x;
+      }
+      if (y > maxY) {
+        maxY = y;
+      }
+      return [x, y];
     });
+    this.boundingBox = { minX, minY, maxX, maxY };
+    return coordinates;
   }
 
   /**
    * Parse the raw (non-computed) definition of the CSS polygon.
    * @returns {Array} an array of the points of the polygon, with units preserved.
    */
   polygonRawPoints() {
     let definition = getDefinedShapeProperties(this.currentNode, this.property);
@@ -1093,16 +1537,18 @@ class ShapesHighlighter extends AutoRefr
 
     // Scale both radiusX and radiusY to match the radius computed
     // using the above equation.
     let radiusX = radius / ratioX;
     let radiusY = radius / ratioY;
 
     // rx, ry, cx, ry
     this.pixelCoords = { radius: pxRadius, cx: pxCenter[0], cy: pxCenter[1] };
+    this.boundingBox = { minX: center[0] - radiusX, maxX: center[0] + radiusX,
+                         minY: center[1] - radiusY, maxY: center[1] + radiusY };
     return { radius, rx: radiusX, ry: radiusY, cx: center[0], cy: center[1] };
   }
 
   /**
    * Parse the raw (non-computed) definition of the CSS circle.
    * @returns {Object} an object of the points of the circle (cx, cy, radius),
    *          with units preserved.
    */
@@ -1161,16 +1607,18 @@ class ShapesHighlighter extends AutoRefr
         return i % 2 === 0 ? Math.max(pxCenter[0], 100 - pxCenter[0])
                            : Math.max(pxCenter[1], 100 - pxCenter[1]);
       }
       return this.convertCoordsToPixel(radius, i);
     });
 
     this.pixelCoords = { rx: pxRadii[0], ry: pxRadii[1],
                          cx: pxCenter[0], cy: pxCenter[1] };
+    this.boundingBox = { minX: center[0] - radii[0], maxX: center[0] + radii[0],
+                         minY: center[1] - radii[1], maxY: center[1] + radii[1] };
     return { rx: radii[0], ry: radii[1], cx: center[0], cy: center[1] };
   }
 
   /**
    * Parse the raw (non-computed) definition of the CSS ellipse.
    * @returns {Object} an object of the points of the ellipse (cx, cy, rx, ry),
    *          with units preserved.
    */
@@ -1235,16 +1683,19 @@ class ShapesHighlighter extends AutoRefr
       left = coordToPercent(offsets[3], width);
       pxTop = coordToPixel(offsets[0], height);
       pxRight = coordToPixel(offsets[1], width);
       pxBottom = coordToPixel(offsets[2], height);
       pxLeft = coordToPixel(offsets[3], width);
     }
 
     this.pixelCoords = { top: pxTop, left: pxLeft, right: pxRight, bottom: pxBottom };
+    // maxX/maxY are found by subtracting the right/bottom edges from 100
+    // (the width/height of the element in %)
+    this.boundingBox = { minX: left, maxX: 100 - right, minY: top, maxY: 100 - bottom};
     return { top, left, right, bottom };
   }
 
   /**
    * Parse the raw (non-computed) definition of the CSS inset.
    * @returns {Object} an object of the points of the inset (top, right, bottom, left),
    *          with units preserved.
    */
@@ -1326,24 +1777,26 @@ class ShapesHighlighter extends AutoRefr
 
   /**
    * Return whether all the elements used to draw shapes are hidden.
    * @returns {Boolean}
    */
   areShapesHidden() {
     return this.getElement("ellipse").hasAttribute("hidden") &&
            this.getElement("polygon").hasAttribute("hidden") &&
-           this.getElement("rect").hasAttribute("hidden");
+           this.getElement("rect").hasAttribute("hidden") &&
+           this.getElement("bounding-box").hasAttribute("hidden");
   }
 
   /**
    * Show the highlighter on a given node
    */
   _show() {
     this.hoveredPoint = this.options.hoverPoint;
+    this.transformMode = this.options.transformMode;
     return this._update();
   }
 
   /**
    * The AutoRefreshHighlighter's _hasMoved method returns true only if the element's
    * quads have changed. Override it so it also returns true if the element's shape has
    * changed (which can happen when you change a CSS properties for instance).
    */
@@ -1378,16 +1831,17 @@ class ShapesHighlighter extends AutoRefr
 
   /**
    * Hide all elements used to highlight CSS different shapes.
    */
   _hideShapes() {
     this.getElement("ellipse").setAttribute("hidden", true);
     this.getElement("polygon").setAttribute("hidden", true);
     this.getElement("rect").setAttribute("hidden", true);
+    this.getElement("bounding-box").setAttribute("hidden", true);
     this.getElement("markers").setAttribute("d", "");
     this.getElement("markers-outline").setAttribute("d", "");
   }
 
   /**
    * Update the highlighter for the current node. Called whenever the element's quads
    * or CSS shape has changed.
    * @returns {Boolean} whether the highlighter was successfully updated
@@ -1401,17 +1855,19 @@ class ShapesHighlighter extends AutoRefr
     let zoom = getCurrentZoom(this.win);
 
     // Size the SVG like the current node.
     this.getElement("shape-container").setAttribute("style",
       `top:${top}px;left:${left}px;width:${width}px;height:${height}px;`);
 
     this._hideShapes();
 
-    if (this.shapeType === "polygon") {
+    if (this.transformMode && this.shapeType !== "none") {
+      this._updateTransformMode(width, height, zoom);
+    } else if (this.shapeType === "polygon") {
       this._updatePolygonShape(width, height, zoom);
     } else if (this.shapeType === "circle") {
       this._updateCircleShape(width, height, zoom);
     } else if (this.shapeType === "ellipse") {
       this._updateEllipseShape(width, height, zoom);
     } else if (this.shapeType === "inset") {
       this._updateInsetShape(width, height, zoom);
     }
@@ -1430,16 +1886,54 @@ class ShapesHighlighter extends AutoRefr
       `position:absolute; width:${winWidth}px;height:${winHeight}px; overflow:hidden`);
 
     setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement);
 
     return true;
   }
 
   /**
+   * Update the SVGs for transform mode to fit the new shape.
+   * @param {Number} width the width of the element quads
+   * @param {Number} height the height of the element quads
+   * @param {Number} zoom the zoom level of the window
+   */
+  _updateTransformMode(width, height, zoom) {
+    let { minX, minY, maxX, maxY } = this.boundingBox;
+    let boundingBox = this.getElement("bounding-box");
+    boundingBox.setAttribute("x", minX);
+    boundingBox.setAttribute("y", minY);
+    boundingBox.setAttribute("width", maxX - minX);
+    boundingBox.setAttribute("height", maxY - minY);
+    boundingBox.removeAttribute("hidden");
+
+    let centerX = (minX + maxX) / 2;
+    let centerY = (minY + maxY) / 2;
+    let markerPoints = [[centerX, centerY], [minX, minY],
+                        [maxX, minY], [minX, maxY], [maxX, maxY]];
+    this._drawMarkers(markerPoints, width, height, zoom);
+
+    if (this.shapeType === "polygon") {
+      let points = this.coordinates.map(point => point.join(",")).join(" ");
+
+      let polygonEl = this.getElement("polygon");
+      polygonEl.setAttribute("points", points);
+      polygonEl.removeAttribute("hidden");
+    } else if (this.shapeType === "circle" || this.shapeType === "ellipse") {
+      let { rx, ry, cx, cy } = this.coordinates;
+      let ellipseEl = this.getElement("ellipse");
+      ellipseEl.setAttribute("rx", rx);
+      ellipseEl.setAttribute("ry", ry);
+      ellipseEl.setAttribute("cx", cx);
+      ellipseEl.setAttribute("cy", cy);
+      ellipseEl.removeAttribute("hidden");
+    }
+  }
+
+  /**
    * Update the SVG polygon to fit the CSS polygon.
    * @param {Number} width the width of the element quads
    * @param {Number} height the height of the element quads
    * @param {Number} zoom the zoom level of the window
    */
   _updatePolygonShape(width, height, zoom) {
     // Draw and show the polygon.
     let points = this.coordinates.map(point => point.join(",")).join(" ");
--- a/devtools/server/actors/utils/shapes-utils.js
+++ b/devtools/server/actors/utils/shapes-utils.js
@@ -105,26 +105,43 @@ const clickedOnPoint = (x, y, pointX, po
 
 const roundTo = (value, exp) => {
   // If the exp is undefined or zero...
   if (typeof exp === "undefined" || +exp === 0) {
     return Math.round(value);
   }
   value = +value;
   exp = +exp;
-        // If the value is not a number or the exp is not an integer...
+  // If the value is not a number or the exp is not an integer...
   if (isNaN(value) || !(typeof exp === "number" && exp % 1 === 0)) {
     return NaN;
   }
-        // Shift
+  // Shift
   value = value.toString().split("e");
   value = Math.round(+(value[0] + "e" + (value[1] ? (+value[1] - exp) : -exp)));
-        // Shift back
+  // Shift back
   value = value.toString().split("e");
   return +(value[0] + "e" + (value[1] ? (+value[1] + exp) : exp));
 };
 
+/**
+ * Scale a given x/y coordinate pair by translating, multiplying by the given factor,
+ * then translating back.
+ * @param {Number} x the x coordinate
+ * @param {Number} y the y coordinate
+ * @param {Number} transX the amount to translate the x coord by
+ * @param {Number} transY the amount ot translate the y coord by
+ * @param {Number} scale the scaling factor
+ * @returns {Array} of the form [newX, newY], containing the coord pair after scaling.
+ */
+const scalePoint = (x, y, transX, transY, scale) => {
+  let newX = (x - transX) * scale + transX;
+  let newY = (y - transY) * scale + transY;
+  return [newX, newY];
+};
+
 exports.getDistance = getDistance;
 exports.clickedOnEllipseEdge = clickedOnEllipseEdge;
 exports.distanceToLine = distanceToLine;
 exports.projection = projection;
 exports.clickedOnPoint = clickedOnPoint;
 exports.roundTo = roundTo;
+exports.scalePoint = scalePoint;