Bug 1282721 - Add transform mode to shapes highlighter with translation/scaling for all shapes. r=pbro
authorMike Park <mikeparkms@gmail.com>
Tue, 10 Oct 2017 14:05:50 -0400
changeset 443379 40e53d1e45443af27e1b73c6e2646b25aa976796
parent 443378 09807e4448d083dab90a50b709608da810e3b85b
child 443380 209581894f955f0daa5857247def66daa0a6b819
push id1618
push userCallek@gmail.com
push dateThu, 11 Jan 2018 17:45:48 +0000
treeherdermozilla-release@882ca853e05a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspbro
bugs1282721
milestone58.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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/moz.build
devtools/server/actors/utils/shapes-geometry-utils.js
devtools/server/actors/utils/shapes-utils.js
--- a/devtools/client/inspector/shared/highlighters-overlay.js
+++ b/devtools/client/inspector/shared/highlighters-overlay.js
@@ -112,20 +112,26 @@ HighlightersOverlay.prototype = {
    * Toggle the shapes highlighter for the given element with a shape.
    *
    * @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 = {}) {
+    options.transformMode = options.ctrlOrMetaPressed;
     if (node == this.shapesHighlighterShown &&
         options.mode === this.state.shapes.options.mode) {
-      yield this.hideShapesHighlighter(node);
-      return;
+      // If meta/ctrl is not pressed, hide the highlighter.
+      if (!options.ctrlOrMetaPressed) {
+        yield this.hideShapesHighlighter(node);
+        return;
+      }
+      // If meta/ctrl is pressed, toggle transform mode on the highlighter.
+      options.transformMode = !this.state.shapes.options.transformMode;
     }
 
     yield this.showShapesHighlighter(node, options);
   }),
 
   /**
    * Show the shapes highlighter for the given element with a shape.
    *
@@ -600,17 +606,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) {
     if (this._isRuleViewDisplayGrid(event.target)) {
       event.stopPropagation();
 
       let { store } = this.inspector;
       let { grids, highlighterSettings } = store.getState();
@@ -618,17 +625,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,
+        ctrlOrMetaPressed: event.metaKey || event.ctrlKey
+      };
       this.toggleShapesHighlighter(this.inspector.selection.nodeFront, settings);
     }
   },
 
   onMouseMove: function (event) {
     // Bail out if the target is the same as for the last mousemove.
     if (event.target === this._lastHovered) {
       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
@@ -9,18 +9,19 @@ const { CanvasFrameAnonymousContentHelpe
 const { setIgnoreLayoutChanges, getCurrentZoom,
         getAdjustedQuads, getFrameOffsets } = require("devtools/shared/layout/utils");
 const { AutoRefreshHighlighter } = require("./auto-refresh");
 const {
   getDistance,
   clickedOnEllipseEdge,
   distanceToLine,
   projection,
-  clickedOnPoint
-} = require("devtools/server/actors/utils/shapes-geometry-utils");
+  clickedOnPoint,
+  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"];
 const _dragging = Symbol("shapes/dragging");
@@ -37,16 +38,18 @@ 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);
@@ -112,16 +115,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",
       },
@@ -225,17 +240,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);
         }
@@ -251,45 +268,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;
     }
@@ -367,18 +733,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;
     }
@@ -448,18 +814,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;
     }
@@ -547,18 +913,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;
     }
@@ -608,17 +974,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") {
@@ -650,17 +1020,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]]);
@@ -747,33 +1138,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;
@@ -989,19 +1415,38 @@ class ShapesHighlighter extends AutoRefr
    *          evaluated and converted to percentages
    */
   polygonPoints(definition) {
     this.coordUnits = this.polygonRawPoints();
     let splitDef = definition.split(", ");
     if (splitDef[0] === "evenodd" || splitDef[0] === "nonzero") {
       splitDef.shift();
     }
-    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);
@@ -1059,17 +1504,18 @@ class ShapesHighlighter extends AutoRefr
 
     // Scale both radiusX and radiusY to match the radius computed
     // using the above equation.
     let ratioX = width / computedSize;
     let ratioY = height / computedSize;
     let radiusX = radius / ratioX;
     let radiusY = radius / ratioY;
 
-    // rx, ry, cx, ry
+    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.
    */
@@ -1111,16 +1557,18 @@ class ShapesHighlighter extends AutoRefr
       } else if (radius === "farthest-side") {
         // radius is the distance from center to farthest x/y side of reference box
         return i % 2 === 0 ? Math.max(center[0], 100 - center[0])
                            : Math.max(center[1], 100 - center[1]);
       }
       return this.convertCoordsToPercent(radius, i);
     });
 
+    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.
    */
@@ -1171,16 +1619,19 @@ class ShapesHighlighter extends AutoRefr
       bottom = offsets[2];
     } else if (offsets.length === 4) {
       top = offsets[0];
       right = offsets[1];
       bottom = offsets[2];
       left = offsets[3];
     }
 
+    // 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.
    */
@@ -1252,24 +1703,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).
    */
@@ -1304,16 +1757,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
@@ -1327,17 +1781,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);
     }
@@ -1350,16 +1806,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/moz.build
+++ b/devtools/server/actors/utils/moz.build
@@ -6,13 +6,13 @@
 
 DevToolsModules(
     'actor-registry-utils.js',
     'audionodes.json',
     'automation-timeline.js',
     'css-grid-utils.js',
     'make-debugger.js',
     'map-uri-to-addon-id.js',
-    'shapes-geometry-utils.js',
+    'shapes-utils.js',
     'stack.js',
     'TabSources.js',
     'walker-search.js',
 )
deleted file mode 100644
--- a/devtools/server/actors/utils/shapes-geometry-utils.js
+++ /dev/null
@@ -1,110 +0,0 @@
-"use strict";
-
-/**
- * Get the distance between two points on a plane.
- * @param {Number} x1 the x coord of the first point
- * @param {Number} y1 the y coord of the first point
- * @param {Number} x2 the x coord of the second point
- * @param {Number} y2 the y coord of the second point
- * @returns {Number} the distance between the two points
- */
-const getDistance = (x1, y1, x2, y2) => {
-  return Math.hypot(x2 - x1, y2 - y1);
-};
-
-/**
- * Determine if the given x/y coords are along the edge of the given ellipse.
- * We allow for a small area around the edge that still counts as being on the edge.
- * @param {Number} x the x coordinate of the click
- * @param {Number} y the y coordinate of the click
- * @param {Number} cx the x coordinate of the center of the ellipse
- * @param {Number} cy the y coordinate of the center of the ellipse
- * @param {Number} rx the x radius of the ellipse
- * @param {Number} ry the y radius of the ellipse
- * @param {Number} clickWidthX the width of the area that counts as being on the edge
- *                             along the x radius.
- * @param {Number} clickWidthY the width of the area that counts as being on the edge
- *                             along the y radius.
- * @returns {Boolean} whether the click counts as being on the edge of the ellipse.
- */
-const clickedOnEllipseEdge = (x, y, cx, cy, rx, ry, clickWidthX, clickWidthY) => {
-  // The formula to determine if something is inside or on the edge of an ellipse is:
-  // (x - cx)^2/rx^2 + (y - cy)^2/ry^2 <= 1. If > 1, it's outside.
-  // We make two ellipses, adjusting rx and ry with clickWidthX and clickWidthY
-  // to allow for an area around the edge of the ellipse that can be clicked on.
-  // If the click was outside the inner ellipse and inside the outer ellipse, return true.
-  let inner = ((x - cx) ** 2) / (rx - clickWidthX) ** 2 +
-              ((y - cy) ** 2) / (ry - clickWidthY) ** 2;
-  let outer = ((x - cx) ** 2) / (rx + clickWidthX) ** 2 +
-              ((y - cy) ** 2) / (ry + clickWidthY) ** 2;
-  return inner >= 1 && outer <= 1;
-};
-
-/**
- * Get the distance between a point and a line defined by two other points.
- * @param {Number} x1 the x coordinate of the first point in the line
- * @param {Number} y1 the y coordinate of the first point in the line
- * @param {Number} x2 the x coordinate of the second point in the line
- * @param {Number} y2 the y coordinate of the second point in the line
- * @param {Number} x3 the x coordinate of the point for which the distance is found
- * @param {Number} y3 the y coordinate of the point for which the distance is found
- * @returns {Number} the distance between (x3,y3) and the line defined by
- *          (x1,y1) and (y1,y2)
- */
-const distanceToLine = (x1, y1, x2, y2, x3, y3) => {
-  // https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line#Line_defined_by_two_points
-  let num = Math.abs((y2 - y1) * x3 - (x2 - x1) * y3 + x2 * y1 - y2 * x1);
-  let denom = getDistance(x1, y1, x2, y2);
-  return num / denom;
-};
-
-/**
- * Get the point on the line defined by points a,b that is closest to point c
- * @param {Number} ax the x coordinate of point a
- * @param {Number} ay the y coordinate of point a
- * @param {Number} bx the x coordinate of point b
- * @param {Number} by the y coordinate of point b
- * @param {Number} cx the x coordinate of point c
- * @param {Number} cy the y coordinate of point c
- * @returns {Array} a 2 element array that contains the x/y coords of the projected point
- */
-const projection = (ax, ay, bx, by, cx, cy) => {
-  // https://en.wikipedia.org/wiki/Vector_projection#Vector_projection_2
-  let ab = [bx - ax, by - ay];
-  let ac = [cx - ax, cy - ay];
-  let scalar = dotProduct(ab, ac) / dotProduct(ab, ab);
-  return [ax + scalar * ab[0], ay + scalar * ab[1]];
-};
-
-/**
- * Get the dot product of two vectors, represented by arrays of numbers.
- * @param {Array} a the first vector
- * @param {Array} b the second vector
- * @returns {Number} the dot product of a and b
- */
-const dotProduct = (a, b) => {
-  return a.reduce((prev, curr, i) => {
-    return prev + curr * b[i];
-  }, 0);
-};
-
-/**
- * Determine if the given x/y coords are above the given point.
- * @param {Number} x the x coordinate of the click
- * @param {Number} y the y coordinate of the click
- * @param {Number} pointX the x coordinate of the center of the point
- * @param {Number} pointY the y coordinate of the center of the point
- * @param {Number} radiusX the x radius of the point
- * @param {Number} radiusY the y radius of the point
- * @returns {Boolean} whether the click was on the point
- */
-const clickedOnPoint = (x, y, pointX, pointY, radiusX, radiusY) => {
-  return x >= pointX - radiusX && x <= pointX + radiusX &&
-         y >= pointY - radiusY && y <= pointY + radiusY;
-};
-
-exports.getDistance = getDistance;
-exports.clickedOnEllipseEdge = clickedOnEllipseEdge;
-exports.distanceToLine = distanceToLine;
-exports.projection = projection;
-exports.clickedOnPoint = clickedOnPoint;
new file mode 100644
--- /dev/null
+++ b/devtools/server/actors/utils/shapes-utils.js
@@ -0,0 +1,147 @@
+"use strict";
+
+/**
+ * Get the distance between two points on a plane.
+ * @param {Number} x1 the x coord of the first point
+ * @param {Number} y1 the y coord of the first point
+ * @param {Number} x2 the x coord of the second point
+ * @param {Number} y2 the y coord of the second point
+ * @returns {Number} the distance between the two points
+ */
+const getDistance = (x1, y1, x2, y2) => {
+  return Math.hypot(x2 - x1, y2 - y1);
+};
+
+/**
+ * Determine if the given x/y coords are along the edge of the given ellipse.
+ * We allow for a small area around the edge that still counts as being on the edge.
+ * @param {Number} x the x coordinate of the click
+ * @param {Number} y the y coordinate of the click
+ * @param {Number} cx the x coordinate of the center of the ellipse
+ * @param {Number} cy the y coordinate of the center of the ellipse
+ * @param {Number} rx the x radius of the ellipse
+ * @param {Number} ry the y radius of the ellipse
+ * @param {Number} clickWidthX the width of the area that counts as being on the edge
+ *                             along the x radius.
+ * @param {Number} clickWidthY the width of the area that counts as being on the edge
+ *                             along the y radius.
+ * @returns {Boolean} whether the click counts as being on the edge of the ellipse.
+ */
+const clickedOnEllipseEdge = (x, y, cx, cy, rx, ry, clickWidthX, clickWidthY) => {
+  // The formula to determine if something is inside or on the edge of an ellipse is:
+  // (x - cx)^2/rx^2 + (y - cy)^2/ry^2 <= 1. If > 1, it's outside.
+  // We make two ellipses, adjusting rx and ry with clickWidthX and clickWidthY
+  // to allow for an area around the edge of the ellipse that can be clicked on.
+  // If the click was outside the inner ellipse and inside the outer ellipse, return true.
+  let inner = ((x - cx) ** 2) / (rx - clickWidthX) ** 2 +
+              ((y - cy) ** 2) / (ry - clickWidthY) ** 2;
+  let outer = ((x - cx) ** 2) / (rx + clickWidthX) ** 2 +
+              ((y - cy) ** 2) / (ry + clickWidthY) ** 2;
+  return inner >= 1 && outer <= 1;
+};
+
+/**
+ * Get the distance between a point and a line defined by two other points.
+ * @param {Number} x1 the x coordinate of the first point in the line
+ * @param {Number} y1 the y coordinate of the first point in the line
+ * @param {Number} x2 the x coordinate of the second point in the line
+ * @param {Number} y2 the y coordinate of the second point in the line
+ * @param {Number} x3 the x coordinate of the point for which the distance is found
+ * @param {Number} y3 the y coordinate of the point for which the distance is found
+ * @returns {Number} the distance between (x3,y3) and the line defined by
+ *          (x1,y1) and (y1,y2)
+ */
+const distanceToLine = (x1, y1, x2, y2, x3, y3) => {
+  // https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line#Line_defined_by_two_points
+  let num = Math.abs((y2 - y1) * x3 - (x2 - x1) * y3 + x2 * y1 - y2 * x1);
+  let denom = getDistance(x1, y1, x2, y2);
+  return num / denom;
+};
+
+/**
+ * Get the point on the line defined by points a,b that is closest to point c
+ * @param {Number} ax the x coordinate of point a
+ * @param {Number} ay the y coordinate of point a
+ * @param {Number} bx the x coordinate of point b
+ * @param {Number} by the y coordinate of point b
+ * @param {Number} cx the x coordinate of point c
+ * @param {Number} cy the y coordinate of point c
+ * @returns {Array} a 2 element array that contains the x/y coords of the projected point
+ */
+const projection = (ax, ay, bx, by, cx, cy) => {
+  // https://en.wikipedia.org/wiki/Vector_projection#Vector_projection_2
+  let ab = [bx - ax, by - ay];
+  let ac = [cx - ax, cy - ay];
+  let scalar = dotProduct(ab, ac) / dotProduct(ab, ab);
+  return [ax + scalar * ab[0], ay + scalar * ab[1]];
+};
+
+/**
+ * Get the dot product of two vectors, represented by arrays of numbers.
+ * @param {Array} a the first vector
+ * @param {Array} b the second vector
+ * @returns {Number} the dot product of a and b
+ */
+const dotProduct = (a, b) => {
+  return a.reduce((prev, curr, i) => {
+    return prev + curr * b[i];
+  }, 0);
+};
+
+/**
+ * Determine if the given x/y coords are above the given point.
+ * @param {Number} x the x coordinate of the click
+ * @param {Number} y the y coordinate of the click
+ * @param {Number} pointX the x coordinate of the center of the point
+ * @param {Number} pointY the y coordinate of the center of the point
+ * @param {Number} radiusX the x radius of the point
+ * @param {Number} radiusY the y radius of the point
+ * @returns {Boolean} whether the click was on the point
+ */
+const clickedOnPoint = (x, y, pointX, pointY, radiusX, radiusY) => {
+  return x >= pointX - radiusX && x <= pointX + radiusX &&
+         y >= pointY - radiusY && y <= pointY + radiusY;
+};
+
+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 (isNaN(value) || !(typeof exp === "number" && exp % 1 === 0)) {
+    return NaN;
+  }
+  // Shift
+  value = value.toString().split("e");
+  value = Math.round(+(value[0] + "e" + (value[1] ? (+value[1] - exp) : -exp)));
+  // 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;