Bug 1282719 - Make CSS shapes highlighter points editable. r=zer0
authorMike Park <mikeparkms@gmail.com>
Wed, 14 Jun 2017 17:18:57 -0400
changeset 369908 536ed14795b2d4818ba7d47555314444915a3d52
parent 369907 4c64ef2265d50824cafe9aec1ee149b3a7c8c0e7
child 369909 2774b700ace8b6b6c850dd7e75904473ce7c95ad
push id32208
push userarchaeopteryx@coole-files.de
push dateFri, 21 Jul 2017 09:12:51 +0000
treeherdermozilla-central@0faada5c2f30 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerszer0
bugs1282719
milestone56.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 1282719 - Make CSS shapes highlighter points editable. r=zer0 Click and drag markers, circle edges, and radius edges to move them. Double click on a polygon edge to add a new point. Double click on a polygon point to remove it. MozReview-Commit-ID: EbPH1pVVBOT
devtools/client/inspector/test/browser.ini
devtools/client/inspector/test/browser_inspector_highlighter-cssshape_02.js
devtools/client/inspector/test/browser_inspector_highlighter-cssshape_04.js
devtools/client/inspector/test/doc_inspector_highlighter_cssshapes.html
devtools/client/inspector/test/head.js
devtools/server/actors/highlighters/shapes.js
devtools/server/actors/utils/moz.build
devtools/server/actors/utils/shapes-geometry-utils.js
devtools/server/tests/unit/test_shapes_highlighter_helpers.js
--- a/devtools/client/inspector/test/browser.ini
+++ b/devtools/client/inspector/test/browser.ini
@@ -73,16 +73,17 @@ skip-if = os == "mac" # Full keyboard na
 [browser_inspector_highlighter-by-type.js]
 [browser_inspector_highlighter-cancel.js]
 [browser_inspector_highlighter-comments.js]
 [browser_inspector_highlighter-cssgrid_01.js]
 [browser_inspector_highlighter-cssgrid_02.js]
 [browser_inspector_highlighter-cssshape_01.js]
 [browser_inspector_highlighter-cssshape_02.js]
 [browser_inspector_highlighter-cssshape_03.js]
+[browser_inspector_highlighter-cssshape_04.js]
 [browser_inspector_highlighter-csstransform_01.js]
 [browser_inspector_highlighter-csstransform_02.js]
 [browser_inspector_highlighter-embed.js]
 [browser_inspector_highlighter-eyedropper-clipboard.js]
 subsuite = clipboard
 skip-if = (os == 'linux' && bits == 32 && debug) # bug 1328915, disable linux32 debug devtools for timeouts
 [browser_inspector_highlighter-eyedropper-csp.js]
 [browser_inspector_highlighter-eyedropper-events.js]
--- a/devtools/client/inspector/test/browser_inspector_highlighter-cssshape_02.js
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-cssshape_02.js
@@ -69,17 +69,17 @@ function* ellipseHasCorrectAttrs(testAct
   let cx = yield testActor.getHighlighterNodeAttribute(
     "shapes-ellipse", "cx", highlighterFront);
   let cy = yield testActor.getHighlighterNodeAttribute(
     "shapes-ellipse", "cy", highlighterFront);
 
   is(rx, 40, "Ellipse highlighter has correct rx");
   is(ry, 30, "Ellipse highlighter has correct ry");
   is(cx, 25, "Ellipse highlighter has correct cx");
-  is(cy, 75, "Ellipse highlighter has correct cy");
+  is(cy, 30, "Ellipse highlighter has correct cy");
 }
 
 function* insetHasCorrectAttrs(testActor, inspector, highlighterFront) {
   info("Checking rect highlighter has correct attributes");
 
   let insetNode = yield getNodeFront("#inset", inspector);
   yield highlighterFront.show(insetNode, {mode: "cssClipPath"});
 
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-cssshape_04.js
@@ -0,0 +1,232 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Test that shapes are updated correctly on mouse events.
+
+const TEST_URL = URL_ROOT + "doc_inspector_highlighter_cssshapes.html";
+const HIGHLIGHTER_TYPE = "ShapesHighlighter";
+
+add_task(function* () {
+  let inspector = yield openInspectorForURL(TEST_URL);
+  let helper = yield getHighlighterHelperFor(HIGHLIGHTER_TYPE)(inspector);
+  let {testActor} = inspector;
+
+  yield testPolygonMovePoint(testActor, helper);
+  yield testPolygonAddPoint(testActor, helper);
+  yield testPolygonRemovePoint(testActor, helper);
+  yield testCircleMoveCenter(testActor, helper);
+  yield testEllipseMoveRadius(testActor, helper);
+  yield testInsetMoveEdges(testActor, helper);
+
+  helper.finalize();
+});
+
+function* testPolygonMovePoint(testActor, helper) {
+  info("Displaying polygon");
+  yield helper.show("#polygon", {mode: "cssClipPath"});
+  let { mouse, highlightedNode } = helper;
+
+  let points = yield helper.getElementAttribute("shapes-polygon", "points");
+  let [x, y] = points.split(" ")[0].split(",");
+  let quads = yield testActor.getAllAdjustedQuads("#polygon");
+  let { top, left, width, height } = quads.border[0].bounds;
+  x = left + width * x / 100;
+  y = top + height * y / 100;
+  let dx = width / 10;
+  let dy = height / 10;
+
+  info("Moving first polygon point");
+  yield mouse.down(x, y);
+  yield mouse.move(x + dx, y + dy);
+  yield mouse.up();
+  yield testActor.reflow();
+
+  let computedStyle = yield highlightedNode.getComputedStyle();
+  let definition = computedStyle["clip-path"].value;
+  ok(definition.includes(`${dx}px ${dy}px`), `Point moved to ${dx}px ${dy}px`);
+}
+
+function* testPolygonAddPoint(testActor, helper) {
+  yield helper.show("#polygon", {mode: "cssClipPath"});
+  let { mouse, highlightedNode } = helper;
+
+  // Move first point to have same x as second point, then double click between
+  // the two points to add a new one.
+  let points = yield helper.getElementAttribute("shapes-polygon", "points");
+  let pointsArray = points.split(" ");
+  let quads = yield testActor.getAllAdjustedQuads("#polygon");
+  let { top, left, width, height } = quads.border[0].bounds;
+  let [x1, y1] = pointsArray[0].split(",");
+  let [x2, y2] = pointsArray[1].split(",");
+  x1 = left + width * x1 / 100;
+  x2 = left + width * x2 / 100;
+  y1 = top + height * y1 / 100;
+  y2 = top + height * y2 / 100;
+
+  yield mouse.down(x1, y1);
+  yield mouse.move(x2, y1);
+  yield mouse.up();
+  yield testActor.reflow();
+
+  let newPointX = x2;
+  let newPointY = (y1 + y2) / 2;
+  let options = {
+    selector: ":root",
+    x: newPointX,
+    y: newPointY,
+    center: false,
+    options: {clickCount: 2}
+  };
+
+  info("Adding new polygon point");
+  yield testActor.synthesizeMouse(options);
+  yield testActor.reflow();
+
+  let computedStyle = yield highlightedNode.getComputedStyle();
+  let definition = computedStyle["clip-path"].value;
+  ok(definition.includes(`${newPointX * 100 / width}% ${newPointY * 100 / height}%`),
+     "Point successfuly added");
+}
+
+function* testPolygonRemovePoint(testActor, helper) {
+  yield helper.show("#polygon", {mode: "cssClipPath"});
+  let { highlightedNode } = helper;
+
+  let points = yield helper.getElementAttribute("shapes-polygon", "points");
+  let [x, y] = points.split(" ")[0].split(",");
+  let quads = yield testActor.getAllAdjustedQuads("#polygon");
+  let { top, left, width, height } = quads.border[0].bounds;
+
+  let options = {
+    selector: ":root",
+    x: left + width * x / 100,
+    y: top + height * y / 100,
+    center: false,
+    options: {clickCount: 2}
+  };
+
+  info("Removing first polygon point");
+  yield testActor.synthesizeMouse(options);
+  yield testActor.reflow();
+
+  let computedStyle = yield highlightedNode.getComputedStyle();
+  let definition = computedStyle["clip-path"].value;
+  ok(!definition.includes(`${x}% ${y}%`), "Point successfully removed");
+}
+
+function* testCircleMoveCenter(testActor, helper) {
+  yield helper.show("#circle", {mode: "cssClipPath"});
+  let { mouse, highlightedNode } = helper;
+
+  let cx = parseFloat(yield helper.getElementAttribute("shapes-ellipse", "cx"));
+  let cy = parseFloat(yield helper.getElementAttribute("shapes-ellipse", "cy"));
+  let quads = yield testActor.getAllAdjustedQuads("#circle");
+  let { width, height } = quads.border[0].bounds;
+  let cxPixel = width * cx / 100;
+  let cyPixel = height * cy / 100;
+  let dx = width / 10;
+  let dy = height / 10;
+
+  info("Moving circle center");
+  yield mouse.down(cxPixel, cyPixel, "#circle");
+  yield mouse.move(cxPixel + dx, cyPixel + dy, "#circle");
+  yield mouse.up(cxPixel + dx, cyPixel + dy, "#circle");
+  yield testActor.reflow();
+
+  let computedStyle = yield highlightedNode.getComputedStyle();
+  let definition = computedStyle["clip-path"].value;
+  ok(definition.includes(`at ${cx + 10}% ${cy + 10}%`),
+     "Circle center successfully moved");
+}
+
+function* testEllipseMoveRadius(testActor, helper) {
+  yield helper.show("#ellipse", {mode: "cssClipPath"});
+  let { mouse, highlightedNode } = helper;
+
+  let rx = parseFloat(yield helper.getElementAttribute("shapes-ellipse", "rx"));
+  let ry = parseFloat(yield helper.getElementAttribute("shapes-ellipse", "ry"));
+  let cx = parseFloat(yield helper.getElementAttribute("shapes-ellipse", "cx"));
+  let cy = parseFloat(yield helper.getElementAttribute("shapes-ellipse", "cy"));
+  let quads = yield testActor.getAllAdjustedQuads("#ellipse");
+  let { width, height } = quads.content[0].bounds;
+  let computedStyle = yield highlightedNode.getComputedStyle();
+  let paddingTop = parseFloat(computedStyle["padding-top"].value);
+  let paddingLeft = parseFloat(computedStyle["padding-left"].value);
+  let cxPixel = paddingLeft + width * cx / 100;
+  let cyPixel = paddingTop + height * cy / 100;
+  let rxPixel = cxPixel + width * rx / 100;
+  let ryPixel = cyPixel + height * ry / 100;
+  let dx = width / 10;
+  let dy = height / 10;
+
+  info("Moving ellipse rx");
+  yield mouse.down(rxPixel, cyPixel, "#ellipse");
+  yield mouse.move(rxPixel + dx, cyPixel, "#ellipse");
+  yield mouse.up(rxPixel + dx, cyPixel, "#ellipse");
+  yield testActor.reflow();
+
+  info("Moving ellipse ry");
+  yield mouse.down(cxPixel, ryPixel, "#ellipse");
+  yield mouse.move(cxPixel, ryPixel - dy, "#ellipse");
+  yield mouse.up(cxPixel, ryPixel - dy, "#ellipse");
+  yield testActor.reflow();
+
+  computedStyle = yield highlightedNode.getComputedStyle();
+  let definition = computedStyle["clip-path"].value;
+  ok(definition.includes(`${rx + 10}% ${ry - 10}%`),
+     "Ellipse radiuses successfully moved");
+}
+
+function* testInsetMoveEdges(testActor, helper) {
+  yield helper.show("#inset", {mode: "cssClipPath"});
+  let { mouse, highlightedNode } = helper;
+
+  let x = parseFloat(yield helper.getElementAttribute("shapes-rect", "x"));
+  let y = parseFloat(yield helper.getElementAttribute("shapes-rect", "y"));
+  let width = parseFloat(yield helper.getElementAttribute("shapes-rect", "width"));
+  let height = parseFloat(yield helper.getElementAttribute("shapes-rect", "height"));
+  let quads = yield testActor.getAllAdjustedQuads("#inset");
+  let { width: elemWidth, height: elemHeight } = quads.content[0].bounds;
+
+  let left = elemWidth * x / 100;
+  let top = elemHeight * y / 100;
+  let right = left + elemWidth * width / 100;
+  let bottom = top + elemHeight * height / 100;
+  let xCenter = (left + right) / 2;
+  let yCenter = (top + bottom) / 2;
+  let dx = elemWidth / 10;
+  let dy = elemHeight / 10;
+
+  info("Moving inset top");
+  yield mouse.down(xCenter, top, "#inset");
+  yield mouse.move(xCenter, top + dy, "#inset");
+  yield mouse.up(xCenter, top + dy, "#inset");
+  yield testActor.reflow();
+
+  info("Moving inset bottom");
+  yield mouse.down(xCenter, bottom, "#inset");
+  yield mouse.move(xCenter, bottom + dy, "#inset");
+  yield mouse.up(xCenter, bottom + dy, "#inset");
+  yield testActor.reflow();
+
+  info("Moving inset left");
+  yield mouse.down(left, yCenter, "#inset");
+  yield mouse.move(left + dx, yCenter, "#inset");
+  yield mouse.up(left + dx, yCenter, "#inset");
+  yield testActor.reflow();
+
+  info("Moving inset right");
+  yield mouse.down(right, yCenter, "#inset");
+  yield mouse.move(right + dx, yCenter, "#inset");
+  yield mouse.up(right + dx, yCenter, "#inset");
+  yield testActor.reflow();
+
+  let computedStyle = yield highlightedNode.getComputedStyle();
+  let definition = computedStyle["clip-path"].value;
+  ok(definition.includes(
+    `${top + dy}px ${elemWidth - right - dx}px ${100 - y - height - 10}% ${x + 10}%`),
+     "Inset edges successfully moved");
+}
--- a/devtools/client/inspector/test/doc_inspector_highlighter_cssshapes.html
+++ b/devtools/client/inspector/test/doc_inspector_highlighter_cssshapes.html
@@ -25,21 +25,21 @@
                        90% 100%,
                        50% 60%,
                        10% 100%);
   }
   #circle {
     clip-path: circle(25% at 30% 40%);
   }
   #ellipse {
-    clip-path: ellipse(40% 30% at 25% 75%) content-box;
+    clip-path: ellipse(40% 30% at 25% 30%) content-box;
     padding: 20px;
   }
   #ellipse-padding-box {
-    clip-path: ellipse(40% 30% at 25% 75%) padding-box;
+    clip-path: ellipse(40% 30% at 25% 30%) padding-box;
     padding: 20px;
   }
   #inset {
     clip-path: inset(200px 100px 30% 15%);
   }
   .svg {
     width: 800px;
     height: 800px;
--- a/devtools/client/inspector/test/head.js
+++ b/devtools/client/inspector/test/head.js
@@ -498,21 +498,21 @@ const getHighlighterHelperFor = (type) =
       // If no x, y coords are given, the previous ones are used.
       //
       // For example:
       //   mouse.down(10, 20); // synthesize "mousedown" at 10,20
       //   mouse.move(20, 30); // synthesize "mousemove" at 20,30
       //   mouse.up();         // synthesize "mouseup" at 20,30
       mouse: new Proxy({}, {
         get: (target, name) =>
-          function* (x = prevX, y = prevY) {
+          function* (x = prevX, y = prevY, selector = ":root") {
             prevX = x;
             prevY = y;
             yield testActor.synthesizeMouse({
-              selector: ":root", x, y, options: {type: "mouse" + name}});
+              selector, x, y, options: {type: "mouse" + name}});
           }
       }),
 
       reflow: function* () {
         yield testActor.reflow();
       },
 
       finalize: function* () {
--- a/devtools/server/actors/highlighters/shapes.js
+++ b/devtools/server/actors/highlighters/shapes.js
@@ -1,38 +1,52 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
-const { CanvasFrameAnonymousContentHelper,
+const { CanvasFrameAnonymousContentHelper, getCSSStyleRules,
         createSVGNode, createNode, getComputedStyle } = require("./utils/markup");
 const { setIgnoreLayoutChanges, getCurrentZoom } = require("devtools/shared/layout/utils");
 const { AutoRefreshHighlighter } = require("./auto-refresh");
+const {
+  getDistance,
+  clickedOnEllipseEdge,
+  distanceToLine,
+  projection,
+  clickedOnPoint
+} = require("devtools/server/actors/utils/shapes-geometry-utils");
 
-// We use this as an offset to avoid the marker itself from being on top of its shadow.
 const BASE_MARKER_SIZE = 10;
+// 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");
 
 /**
  * The ShapesHighlighter draws an outline shapes in the page.
  * The idea is to have something that is able to wrap complex shapes for css properties
  * such as shape-outside/inside, clip-path but also SVG elements.
  */
 class ShapesHighlighter extends AutoRefreshHighlighter {
   constructor(highlighterEnv) {
     super(highlighterEnv);
 
     this.ID_CLASS_PREFIX = "shapes-";
 
     this.referenceBox = "border";
     this.useStrokeBox = false;
+    this.geometryBox = "";
 
     this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv,
-    this._buildMarkup.bind(this));
+      this._buildMarkup.bind(this));
+
+    let { pageListenerTarget } = this.highlighterEnv;
+    DOM_EVENTS.forEach(event => pageListenerTarget.addEventListener(event, this));
   }
 
   _buildMarkup() {
     let container = createNode(this.win, {
       attributes: {
         "class": "highlighter-container"
       }
     });
@@ -119,16 +133,625 @@ class ShapesHighlighter extends AutoRefr
     // getBBox, which only exists for SVG, to check if currentNode is an SVG element.
     if (this.currentNode.getBBox &&
         getComputedStyle(this.currentNode).stroke !== "none" && !this.useStrokeBox) {
       return getObjectBoundingBox(top, left, width, height, this.currentNode);
     }
     return { top, left, width, height };
   }
 
+  get zoomAdjustedDimensions() {
+    let { top, left, width, height } = this.currentDimensions;
+    let zoom = getCurrentZoom(this.win);
+    return {
+      top: top / zoom,
+      left: left / zoom,
+      width: width / zoom,
+      height: height / zoom
+    };
+  }
+
+  handleEvent(event, id) {
+    // No event handling if the highlighter is hidden
+    if (this.areShapesHidden()) {
+      return;
+    }
+
+    const { target, type, pageX, pageY } = event;
+
+    switch (type) {
+      case "pagehide":
+        // 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") {
+          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);
+        }
+        // Currently, changes to shape-outside do not become visible unless a reflow
+        // is forced (bug 1359834). This is a hack to force a reflow so changes made
+        // using the highlighter can be seen: we change the width of the element
+        // slightly on mousedown on a point, and restore the original width on mouseup.
+        if (this.property === "shape-outside" && this[_dragging]) {
+          let { width } = this.zoomAdjustedDimensions;
+          let origWidth = getDefinedShapeProperties(this.currentNode, "width");
+          this.currentNode.style.setProperty("width", `${width + 1}px`);
+          this[_dragging].origWidth = origWidth;
+        }
+        event.stopPropagation();
+        event.preventDefault();
+        break;
+      case "mouseup":
+        if (this[_dragging]) {
+          if (this.property === "shape-outside") {
+            this.currentNode.style.setProperty("width", this[_dragging].origWidth);
+          }
+          this[_dragging] = null;
+        }
+        break;
+      case "mousemove":
+        if (!this[_dragging]) {
+          return;
+        }
+        event.stopPropagation();
+        event.preventDefault();
+
+        let { point } = this[_dragging];
+        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") {
+          let { percentX, percentY } = this.convertPageCoordsToPercent(pageX, pageY);
+          let index = this.getPolygonClickedPoint(percentX, percentY);
+          if (index === -1) {
+            this.getPolygonClickedLine(percentX, percentY);
+            return;
+          }
+
+          this._deletePolygonPoint(index);
+        }
+        break;
+    }
+  }
+
+  /**
+   * 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
+   */
+  _handlePolygonClick(pageX, pageY) {
+    let { width, height } = this.zoomAdjustedDimensions;
+    let { percentX, percentY } = this.convertPageCoordsToPercent(pageX, pageY);
+    let point = this.getPolygonClickedPoint(percentX, percentY);
+    if (point === -1) {
+      return;
+    }
+
+    let [x, y] = this.coordUnits[point];
+    let xComputed = this.coordinates[point][0] / 100 * width;
+    let yComputed = this.coordinates[point][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;
+
+    this[_dragging] = { point, unitX, unitY, valueX, valueY,
+                        ratioX, ratioY, x: pageX, y: pageY };
+  }
+
+  /**
+   * Set the inline style of the polygon, replacing the given point with the given x/y
+   * coords.
+   * @param {Number} pageX the new x coordinate of the point
+   * @param {Number} pageY the new y coordinate of the point
+   */
+  _handlePolygonMove(pageX, pageY) {
+    let { point, unitX, unitY, valueX, valueY, ratioX, ratioY, x, y } = this[_dragging];
+    let deltaX = (pageX - x) * ratioX;
+    let deltaY = (pageY - y) * ratioY;
+    let newX = `${valueX + deltaX}${unitX}`;
+    let newY = `${valueY + deltaY}${unitY}`;
+
+    let polygonDef = this.coordUnits.map((coords, i) => {
+      return (i === point) ? `${newX} ${newY}` : `${coords[0]} ${coords[1]}`;
+    }).join(", ");
+    polygonDef = (this.geometryBox) ? `polygon(${polygonDef}) ${this.geometryBox}` :
+                                      `polygon(${polygonDef})`;
+
+    this.currentNode.style.setProperty(this.property, polygonDef, "important");
+  }
+
+  /**
+   * Set the inline style of the polygon, adding a new point.
+   * @param {Number} after the index of the point that the new point should be added after
+   * @param {Number} x the x coordinate of the new point
+   * @param {Number} y the y coordinate of the new point
+   */
+  _addPolygonPoint(after, x, y) {
+    let polygonDef = this.coordUnits.map((coords, i) => {
+      return (i === after) ? `${coords[0]} ${coords[1]}, ${x}% ${y}%` :
+                             `${coords[0]} ${coords[1]}`;
+    }).join(", ");
+    polygonDef = (this.geometryBox) ? `polygon(${polygonDef}) ${this.geometryBox}` :
+                                      `polygon(${polygonDef})`;
+
+    this.currentNode.style.setProperty(this.property, polygonDef, "important");
+  }
+
+  /**
+   * Set the inline style of the polygon, deleting the given point.
+   * @param {Number} point the index of the point to delete
+   */
+  _deletePolygonPoint(point) {
+    let coordinates = this.coordUnits.slice();
+    coordinates.splice(point, 1);
+    let polygonDef = coordinates.map((coords, i) => {
+      return `${coords[0]} ${coords[1]}`;
+    }).join(", ");
+    polygonDef = (this.geometryBox) ? `polygon(${polygonDef}) ${this.geometryBox}` :
+                                      `polygon(${polygonDef})`;
+
+    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
+   */
+  _handleCircleClick(pageX, pageY) {
+    let { width, height } = this.zoomAdjustedDimensions;
+    let { percentX, percentY } = this.convertPageCoordsToPercent(pageX, pageY);
+    let point = this.getCircleClickedPoint(percentX, percentY);
+    if (!point) {
+      return;
+    }
+
+    if (point === "center") {
+      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;
+
+      this[_dragging] = { point, unitX, unitY, valueX, valueY,
+                          ratioX, ratioY, x: pageX, y: pageY };
+    } else if (point === "radius") {
+      let { radius } = this.coordinates;
+      let computedSize = Math.sqrt((width ** 2) + (height ** 2)) / Math.sqrt(2);
+      radius = radius / 100 * computedSize;
+      let value = this.coordUnits.radius;
+      let unit = getUnit(value);
+      value = (isUnitless(value)) ? radius : parseFloat(value);
+      let ratio = (value / radius) || 1;
+
+      this[_dragging] = { point, value, origRadius: radius, unit, ratio };
+    }
+  }
+
+  /**
+   * Set the inline style of the circle, setting the center/radius according to the
+   * mouse position.
+   * @param {String} point either "center" or "radius"
+   * @param {Number} pageX the x coordinate of the mouse position, in terms of %
+   *        relative to the element
+   * @param {Number} pageY the y coordinate of the mouse position, in terms of %
+   *        relative to the element
+   */
+  _handleCircleMove(point, pageX, pageY) {
+    let { radius, cx, cy } = this.coordUnits;
+
+    if (point === "center") {
+      let { unitX, unitY, valueX, valueY, ratioX, ratioY, x, y} = this[_dragging];
+      let deltaX = (pageX - x) * ratioX;
+      let deltaY = (pageY - y) * ratioY;
+      let newCx = `${valueX + deltaX}${unitX}`;
+      let newCy = `${valueY + deltaY}${unitY}`;
+      let circleDef = (this.geometryBox) ?
+            `circle(${radius} at ${newCx} ${newCy}) ${this.geometryBox}` :
+            `circle(${radius} at ${newCx} ${newCy})`;
+
+      this.currentNode.style.setProperty(this.property, circleDef, "important");
+    } else if (point === "radius") {
+      let { value, unit, origRadius, ratio } = this[_dragging];
+      // convert center point to px, then get distance between center and mouse.
+      let { x: pageCx, y: pageCy } = this.convertPercentToPageCoords(this.coordinates.cx,
+                                                                     this.coordinates.cy);
+      let newRadiusPx = getDistance(pageCx, pageCy, pageX, pageY);
+
+      let delta = (newRadiusPx - origRadius) * ratio;
+      let newRadius = `${value + delta}${unit}`;
+
+      let circleDef = (this.geometryBox) ?
+                      `circle(${newRadius} at ${cx} ${cy} ${this.geometryBox}` :
+                      `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
+   */
+  _handleEllipseClick(pageX, pageY) {
+    let { width, height } = this.zoomAdjustedDimensions;
+    let { percentX, percentY } = this.convertPageCoordsToPercent(pageX, pageY);
+    let point = this.getEllipseClickedPoint(percentX, percentY);
+    if (!point) {
+      return;
+    }
+
+    if (point === "center") {
+      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;
+
+      this[_dragging] = { point, unitX, unitY, valueX, valueY,
+                          ratioX, ratioY, x: pageX, y: pageY };
+    } else if (point === "rx") {
+      let { rx } = this.coordinates;
+      rx = rx / 100 * width;
+      let value = this.coordUnits.rx;
+      let unit = getUnit(value);
+      value = (isUnitless(value)) ? rx : parseFloat(value);
+      let ratio = (value / rx) || 1;
+
+      this[_dragging] = { point, value, origRadius: rx, unit, ratio };
+    } else if (point === "ry") {
+      let { ry } = this.coordinates;
+      ry = ry / 100 * height;
+      let value = this.coordUnits.ry;
+      let unit = getUnit(value);
+      value = (isUnitless(value)) ? ry : parseFloat(value);
+      let ratio = (value / ry) || 1;
+
+      this[_dragging] = { point, value, origRadius: ry, unit, ratio };
+    }
+  }
+
+  /**
+   * Set the inline style of the ellipse, setting the center/rx/ry according to the
+   * mouse position.
+   * @param {String} point "center", "rx", or "ry"
+   * @param {Number} pageX the x coordinate of the mouse position, in terms of %
+   *        relative to the element
+   * @param {Number} pageY the y coordinate of the mouse position, in terms of %
+   *        relative to the element
+   */
+  _handleEllipseMove(point, pageX, pageY) {
+    let { percentX, percentY } = this.convertPageCoordsToPercent(pageX, pageY);
+    let { rx, ry, cx, cy } = this.coordUnits;
+
+    if (point === "center") {
+      let { unitX, unitY, valueX, valueY, ratioX, ratioY, x, y} = this[_dragging];
+      let deltaX = (pageX - x) * ratioX;
+      let deltaY = (pageY - y) * ratioY;
+      let newCx = `${valueX + deltaX}${unitX}`;
+      let newCy = `${valueY + deltaY}${unitY}`;
+      let ellipseDef = (this.geometryBox) ?
+        `ellipse(${rx} ${ry} at ${newCx} ${newCy}) ${this.geometryBox}` :
+        `ellipse(${rx} ${ry} at ${newCx} ${newCy})`;
+
+      this.currentNode.style.setProperty(this.property, ellipseDef, "important");
+    } else if (point === "rx") {
+      let { value, unit, origRadius, ratio } = this[_dragging];
+      let newRadiusPercent = Math.abs(percentX - this.coordinates.cx);
+      let { width } = this.zoomAdjustedDimensions;
+      let delta = ((newRadiusPercent / 100 * width) - origRadius) * ratio;
+      let newRadius = `${value + delta}${unit}`;
+
+      let ellipseDef = (this.geometryBox) ?
+        `ellipse(${newRadius} ${ry} at ${cx} ${cy}) ${this.geometryBox}` :
+        `ellipse(${newRadius} ${ry} at ${cx} ${cy})`;
+
+      this.currentNode.style.setProperty(this.property, ellipseDef, "important");
+    } else if (point === "ry") {
+      let { value, unit, origRadius, ratio } = this[_dragging];
+      let newRadiusPercent = Math.abs(percentY - this.coordinates.cy);
+      let { height } = this.zoomAdjustedDimensions;
+      let delta = ((newRadiusPercent / 100 * height) - origRadius) * ratio;
+      let newRadius = `${value + delta}${unit}`;
+
+      let ellipseDef = (this.geometryBox) ?
+        `ellipse(${rx} ${newRadius} at ${cx} ${cy}) ${this.geometryBox}` :
+        `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
+   */
+  _handleInsetClick(pageX, pageY) {
+    let { width, height } = this.zoomAdjustedDimensions;
+    let { percentX, percentY } = this.convertPageCoordsToPercent(pageX, pageY);
+    let point = this.getInsetClickedPoint(percentX, percentY);
+    if (!point) {
+      return;
+    }
+
+    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;
+    let origValue = (point === "left" || point === "right") ? pageX : pageY;
+
+    this[_dragging] = { point, value, origValue, unit, ratio };
+  }
+
+  /**
+   * Set the inline style of the inset, setting top/left/right/bottom according to the
+   * mouse position.
+   * @param {String} point "top", "left", "right", or "bottom"
+   * @param {Number} pageX the x coordinate of the mouse position, in terms of %
+   *        relative to the element
+   * @param {Number} pageY the y coordinate of the mouse position, in terms of %
+   *        relative to the element
+   * @memberof ShapesHighlighter
+   */
+  _handleInsetMove(point, pageX, pageY) {
+    let { top, left, right, bottom } = this.coordUnits;
+    let round = this.insetRound;
+    let { value, origValue, unit, ratio } = this[_dragging];
+
+    if (point === "left") {
+      let delta = (pageX - origValue) * ratio;
+      left = `${value + delta}${unit}`;
+    } else if (point === "right") {
+      let delta = (pageX - origValue) * ratio;
+      right = `${value - delta}${unit}`;
+    } else if (point === "top") {
+      let delta = (pageY - origValue) * ratio;
+      top = `${value + delta}${unit}`;
+    } else if (point === "bottom") {
+      let delta = (pageY - origValue) * ratio;
+      bottom = `${value - delta}${unit}`;
+    }
+    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");
+  }
+
+  /**
+   * Convert the given coordinates on the page to percentages relative to the current
+   * element.
+   * @param {Number} pageX the x coordinate on the page
+   * @param {Number} pageY the y coordinate on the page
+   * @returns {Object} object of form {percentX, percentY}, which are the x/y coords
+   *          in percentages relative to the element.
+   */
+  convertPageCoordsToPercent(pageX, pageY) {
+    let { top, left, width, height } = this.zoomAdjustedDimensions;
+    pageX -= left;
+    pageY -= top;
+    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
+   * @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 the id of the point clicked on the polygon highlighter.
+   * @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.
+   */
+  getPolygonClickedPoint(pageX, pageY) {
+    let { coordinates } = this;
+    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;
+
+    for (let [index, coord] of coordinates.entries()) {
+      let [x, y] = coord;
+      if (pageX >= x - clickRadiusX && pageX <= x + clickRadiusX &&
+          pageY >= y - clickRadiusY && pageY <= y + clickRadiusY) {
+        return index;
+      }
+    }
+
+    return -1;
+  }
+
+  /**
+   * Check if the mouse clicked on a line of the polygon, and if so, add a point near
+   * the click.
+   * @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
+   */
+  getPolygonClickedLine(pageX, pageY) {
+    let { coordinates } = this;
+    let { width } = this.zoomAdjustedDimensions;
+    let clickWidth = LINE_CLICK_WIDTH * 100 / width;
+
+    for (let i = 0; i < coordinates.length; i++) {
+      let [x1, y1] = coordinates[i];
+      let [x2, y2] = (i === coordinates.length - 1) ? coordinates[0] : coordinates[i + 1];
+      // Get the distance between clicked point and line drawn between points 1 and 2
+      // to check if the click was on the line between those two points.
+      let distance = distanceToLine(x1, y1, x2, y2, pageX, pageY);
+      if (distance <= clickWidth &&
+          Math.min(x1, x2) - clickWidth <= pageX &&
+          pageX <= Math.max(x1, x2) + clickWidth &&
+          Math.min(y1, y2) - clickWidth <= pageY &&
+          pageY <= Math.max(y1, y2) + clickWidth) {
+        // Get the point on the line closest to the clicked point.
+        let [newX, newY] = projection(x1, y1, x2, y2, pageX, pageY);
+        this._addPolygonPoint(i, newX, newY);
+        return;
+      }
+    }
+  }
+
+  /**
+   * Check if the center point or radius of the circle highlighter was clicked
+   * @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 {String} "center" if the center point was clicked, "radius" if the radius
+   *          was clicked, "" if neither was clicked.
+   */
+  getCircleClickedPoint(pageX, pageY) {
+    let { cx, cy, rx, ry } = this.coordinates;
+    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;
+
+    if (clickedOnPoint(pageX, pageY, cx, cy, clickRadiusX, clickRadiusY)) {
+      return "center";
+    }
+
+    let clickWidthX = LINE_CLICK_WIDTH * 100 / width;
+    let clickWidthY = LINE_CLICK_WIDTH * 100 / height;
+    if (clickedOnEllipseEdge(pageX, pageY, cx, cy, rx, ry, clickWidthX, clickWidthY) ||
+        clickedOnPoint(pageX, pageY, cx + rx, cy, clickRadiusX, clickRadiusY)) {
+      return "radius";
+    }
+
+    return "";
+  }
+
+  /**
+   * Check if the center point or rx/ry points of the ellipse highlighter was clicked
+   * @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 {String} "center" if the center point was clicked, "rx" if the x-radius
+   *          point was clicked, "ry" if the y-radius point was clicked,
+   *          "" if none was clicked.
+   */
+  getEllipseClickedPoint(pageX, pageY) {
+    let { cx, cy, rx, ry } = this.coordinates;
+    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;
+
+    if (clickedOnPoint(pageX, pageY, cx, cy, clickRadiusX, clickRadiusY)) {
+      return "center";
+    }
+
+    if (clickedOnPoint(pageX, pageY, cx + rx, cy, clickRadiusX, clickRadiusY)) {
+      return "rx";
+    }
+
+    if (clickedOnPoint(pageX, pageY, cx, cy + ry, clickRadiusX, clickRadiusY)) {
+      return "ry";
+    }
+
+    return "";
+  }
+
+  /**
+   * Check if the edges of the inset highlighter was clicked
+   * @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 {String} "top", "left", "right", or "bottom" if any of those edges were
+   *          clicked. "" if none were clicked.
+   */
+  getInsetClickedPoint(pageX, pageY) {
+    let { top, left, right, bottom } = this.coordinates;
+    let zoom = getCurrentZoom(this.win);
+    let { width, height } = this.zoomAdjustedDimensions;
+    let clickWidthX = LINE_CLICK_WIDTH * 100 / width;
+    let clickWidthY = LINE_CLICK_WIDTH * 100 / height;
+    let clickRadiusX = BASE_MARKER_SIZE / zoom * 100 / width;
+    let clickRadiusY = BASE_MARKER_SIZE / zoom * 100 / height;
+    let centerX = (left + (100 - right)) / 2;
+    let centerY = (top + (100 - bottom)) / 2;
+
+    if ((pageX >= left - clickWidthX && pageX <= left + clickWidthX &&
+        pageY >= top && pageY <= 100 - bottom) ||
+        clickedOnPoint(pageX, pageY, left, centerY, clickRadiusX, clickRadiusY)) {
+      return "left";
+    }
+
+    if ((pageX >= 100 - right - clickWidthX && pageX <= 100 - right + clickWidthX &&
+        pageY >= top && pageY <= 100 - bottom) ||
+        clickedOnPoint(pageX, pageY, 100 - right, centerY, clickRadiusX, clickRadiusY)) {
+      return "right";
+    }
+
+    if ((pageY >= top - clickWidthY && pageY <= top + clickWidthY &&
+        pageX >= left && pageX <= 100 - right) ||
+        clickedOnPoint(pageX, pageY, centerX, top, clickRadiusX, clickRadiusY)) {
+      return "top";
+    }
+
+    if ((pageY >= 100 - bottom - clickWidthY && pageY <= 100 - bottom + clickWidthY &&
+        pageX >= left && pageX <= 100 - right) ||
+        clickedOnPoint(pageX, pageY, centerX, 100 - bottom, clickRadiusX, clickRadiusY)) {
+      return "bottom";
+    }
+
+    return "";
+  }
+
   /**
    * Parses the CSS definition given and returns the shape type associated
    * with the definition and the coordinates necessary to draw the shape.
    * @param {String} definition the input CSS definition
    * @returns {Object} null if the definition is not of a known shape type,
    *          or an object of the type { shapeType, coordinates }, where
    *          shapeType is the name of the shape and coordinates are an array
    *          or object of the coordinates needed to draw the shape.
@@ -158,16 +781,17 @@ class ShapesHighlighter extends AutoRefr
     for (let geometry of geometryTypes) {
       if (definition.includes(geometry)) {
         referenceBox = geometry;
       }
     }
     this.referenceBox = referenceBox;
 
     this.useStrokeBox = definition.includes("stroke-box");
+    this.geometryBox = definition.substring(definition.lastIndexOf(")") + 1).trim();
 
     for (let { name, prefix, coordParser } of shapeTypes) {
       if (definition.includes(prefix)) {
         // the closing paren of the shape function is always the last one in definition.
         definition = definition.substring(prefix.length, definition.lastIndexOf(")"));
         return {
           shapeType: name,
           coordinates: coordParser(definition)
@@ -181,163 +805,277 @@ class ShapesHighlighter extends AutoRefr
   /**
    * Parses the definition of the CSS polygon() function and returns its points,
    * converted to percentages.
    * @param {String} definition the arguments of the polygon() function
    * @returns {Array} an array of the points of the polygon, with all values
    *          evaluated and converted to percentages
    */
   polygonPoints(definition) {
-    return definition.split(",").map(coords => {
+    this.coordUnits = this.polygonRawPoints();
+    return definition.split(", ").map(coords => {
       return splitCoords(coords).map(this.convertCoordsToPercent.bind(this));
     });
   }
 
   /**
+   * 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);
+    if (definition === this.rawDefinition) {
+      return this.coordUnits;
+    }
+    this.rawDefinition = definition;
+    definition = definition.substring(8, definition.lastIndexOf(")"));
+    return definition.split(", ").map(coords => {
+      return splitCoords(coords).map(coord => {
+        // Undo the insertion of &nbsp; that was done in splitCoords.
+        return coord.replace(/\u00a0/g, " ");
+      });
+    });
+  }
+
+  /**
    * Parses the definition of the CSS circle() function and returns the x/y radiuses and
    * center coordinates, converted to percentages.
    * @param {String} definition the arguments of the circle() function
    * @returns {Object} an object of the form { rx, ry, cx, cy }, where rx and ry are the
    *          radiuses for the x and y axes, and cx and cy are the x/y coordinates for the
    *          center of the circle. All values are evaluated and converted to percentages.
    */
   circlePoints(definition) {
+    this.coordUnits = this.circleRawPoints();
     // The computed value of circle() always has the keyword "at".
     let values = definition.split(" at ");
     let radius = values[0];
-    let zoom = getCurrentZoom(this.win);
-    let elemWidth = this.currentDimensions.width / zoom;
-    let elemHeight = this.currentDimensions.height / zoom;
+    let { width, height } = this.zoomAdjustedDimensions;
     let center = splitCoords(values[1]).map(this.convertCoordsToPercent.bind(this));
 
+    // Percentage values for circle() are resolved from the
+    // used width and height of the reference box as sqrt(width^2+height^2)/sqrt(2).
+    let computedSize = Math.sqrt((width ** 2) + (height ** 2)) / Math.sqrt(2);
+
     if (radius === "closest-side") {
       // radius is the distance from center to closest side of reference box
       radius = Math.min(center[0], center[1], 100 - center[0], 100 - center[1]);
     } else if (radius === "farthest-side") {
       // radius is the distance from center to farthest side of reference box
       radius = Math.max(center[0], center[1], 100 - center[0], 100 - center[1]);
+    } else if (radius.includes("calc(")) {
+      radius = evalCalcExpression(radius.substring(5, radius.length - 1), computedSize);
     } else {
-      // radius is a % or px value
-      radius = coordToPercent(radius, Math.max(elemWidth, elemHeight));
+      radius = coordToPercent(radius, computedSize);
     }
 
-    // Percentage values for circle() are resolved from the
-    // used width and height of the reference box as sqrt(width^2+height^2)/sqrt(2).
     // Scale both radiusX and radiusY to match the radius computed
     // using the above equation.
-    let computedSize = Math.sqrt((elemWidth ** 2) + (elemHeight ** 2)) / Math.sqrt(2);
-    let ratioX = elemWidth / computedSize;
-    let ratioY = elemHeight / computedSize;
+    let ratioX = width / computedSize;
+    let ratioY = height / computedSize;
     let radiusX = radius / ratioX;
     let radiusY = radius / ratioY;
 
     // rx, ry, cx, ry
-    return { rx: radiusX, ry: radiusY, cx: center[0], cy: center[1] };
+    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.
+   */
+  circleRawPoints() {
+    let definition = getDefinedShapeProperties(this.currentNode, this.property);
+    if (definition === this.rawDefinition) {
+      return this.coordUnits;
+    }
+    this.rawDefinition = definition;
+    definition = definition.substring(7, definition.lastIndexOf(")"));
+
+    let values = definition.split("at");
+    let [cx = "", cy = ""] = (values[1]) ? splitCoords(values[1]).map(coord => {
+      // Undo the insertion of &nbsp; that was done in splitCoords.
+      return coord.replace(/\u00a0/g, " ");
+    }) : [];
+    let radius = (values[0]) ? values[0].trim() : "closest-side";
+    return { cx, cy, radius };
   }
 
   /**
    * Parses the definition of the CSS ellipse() function and returns the x/y radiuses and
    * center coordinates, converted to percentages.
    * @param {String} definition the arguments of the ellipse() function
    * @returns {Object} an object of the form { rx, ry, cx, cy }, where rx and ry are the
    *          radiuses for the x and y axes, and cx and cy are the x/y coordinates for the
    *          center of the ellipse. All values are evaluated and converted to percentages
    */
   ellipsePoints(definition) {
+    this.coordUnits = this.ellipseRawPoints();
     let values = definition.split(" at ");
-    let zoom = getCurrentZoom(this.win);
-    let elemWidth = this.currentDimensions.width / zoom;
-    let elemHeight = this.currentDimensions.height / zoom;
     let center = splitCoords(values[1]).map(this.convertCoordsToPercent.bind(this));
 
-    let radii = values[0].trim().split(" ").map((radius, i) => {
-      let size = i % 2 === 0 ? elemWidth : elemHeight;
+    let radii = splitCoords(values[0]).map((radius, i) => {
       if (radius === "closest-side") {
         // radius is the distance from center to closest x/y side of reference box
         return i % 2 === 0 ? Math.min(center[0], 100 - center[0])
                            : Math.min(center[1], 100 - center[1]);
       } 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 coordToPercent(radius, size);
+      return this.convertCoordsToPercent(radius, i);
     });
 
     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.
+   */
+  ellipseRawPoints() {
+    let definition = getDefinedShapeProperties(this.currentNode, this.property);
+    if (definition === this.rawDefinition) {
+      return this.coordUnits;
+    }
+    this.rawDefinition = definition;
+    definition = definition.substring(8, definition.lastIndexOf(")"));
+
+    let values = definition.split("at");
+    let [rx = "closest-side", ry = "closest-side"] = (values[0]) ?
+      splitCoords(values[0]).map(coord => {
+        // Undo the insertion of &nbsp; that was done in splitCoords.
+        return coord.replace(/\u00a0/g, " ");
+      }) : [];
+    let [cx = "", cy = ""] = (values[1]) ? splitCoords(values[1]).map(coord => {
+      return coord.replace(/\u00a0/g, " ");
+    }) : [];
+    return { rx, ry, cx, cy };
+  }
+
+  /**
    * Parses the definition of the CSS inset() function and returns the x/y offsets and
    * width/height of the shape, converted to percentages. Border radiuses (given after
    * "round" in the definition) are currently ignored.
    * @param {String} definition the arguments of the inset() function
    * @returns {Object} an object of the form { x, y, width, height }, which are the top/
    *          left positions and width/height of the shape.
    */
   insetPoints(definition) {
+    this.coordUnits = this.insetRawPoints();
     let values = definition.split(" round ");
     let offsets = splitCoords(values[0]).map(this.convertCoordsToPercent.bind(this));
 
-    let x, y = 0;
-    let width = this.currentDimensions.width;
-    let height = this.currentDimensions.height;
+    let top, left = 0;
+    let { width: right, height: bottom } = this.currentDimensions;
     // The offsets, like margin/padding/border, are in order: top, right, bottom, left.
     if (offsets.length === 1) {
-      x = y = offsets[0];
-      width = height = 100 - 2 * x;
+      top = left = right = bottom = offsets[0];
     } else if (offsets.length === 2) {
-      y = offsets[0];
-      x = offsets[1];
-      height = 100 - 2 * y;
-      width = 100 - 2 * x;
+      top = bottom = offsets[0];
+      left = right = offsets[1];
     } else if (offsets.length === 3) {
-      y = offsets[0];
-      x = offsets[1];
-      height = 100 - y - offsets[2];
-      width = 100 - 2 * x;
+      top = offsets[0];
+      left = right = offsets[1];
+      bottom = offsets[2];
     } else if (offsets.length === 4) {
-      y = offsets[0];
-      x = offsets[3];
-      height = 100 - y - offsets[2];
-      width = 100 - x - offsets[1];
+      top = offsets[0];
+      right = offsets[1];
+      bottom = offsets[2];
+      left = offsets[3];
     }
 
-    return { x, y, width, height };
+    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.
+   */
+  insetRawPoints() {
+    let definition = getDefinedShapeProperties(this.currentNode, this.property);
+    if (definition === this.rawDefinition) {
+      return this.coordUnits;
+    }
+    this.rawDefinition = definition;
+    definition = definition.substring(6, definition.lastIndexOf(")"));
+
+    let values = definition.split(" round ");
+    this.insetRound = values[1];
+    let offsets = splitCoords(values[0]).map(coord => {
+      // Undo the insertion of &nbsp; that was done in splitCoords.
+      return coord.replace(/\u00a0/g, " ");
+    });
+
+    let top, left, right, bottom = 0;
+
+    if (offsets.length === 1) {
+      top = left = right = bottom = offsets[0];
+    } else if (offsets.length === 2) {
+      top = bottom = offsets[0];
+      left = right = offsets[1];
+    } else if (offsets.length === 3) {
+      top = offsets[0];
+      left = right = offsets[1];
+      bottom = offsets[2];
+    } else if (offsets.length === 4) {
+      top = offsets[0];
+      right = offsets[1];
+      bottom = offsets[2];
+      left = offsets[3];
+    }
+
+    return { top, left, right, bottom };
   }
 
   convertCoordsToPercent(coord, i) {
-    let zoom = getCurrentZoom(this.win);
-    let elemWidth = this.currentDimensions.width / zoom;
-    let elemHeight = this.currentDimensions.height / zoom;
-    let size = i % 2 === 0 ? elemWidth : elemHeight;
+    let { width, height } = this.zoomAdjustedDimensions;
+    let size = i % 2 === 0 ? width : height;
     if (coord.includes("calc(")) {
       return evalCalcExpression(coord.substring(5, coord.length - 1), size);
     }
     return coordToPercent(coord, size);
   }
 
   /**
    * Destroy the nodes. Remove listeners.
    */
   destroy() {
-    AutoRefreshHighlighter.prototype.destroy.call(this);
+    let { pageListenerTarget } = this.highlighterEnv;
+    if (pageListenerTarget) {
+      DOM_EVENTS.forEach(type => pageListenerTarget.removeEventListener(type, this));
+    }
+    super.destroy(this);
     this.markup.destroy();
   }
 
   /**
    * Get the element in the highlighter markup with the given id
    * @param {String} id
    * @returns {Object} the element with the given id
    */
   getElement(id) {
     return this.markup.getElement(this.ID_CLASS_PREFIX + id);
   }
 
   /**
+   * 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");
+  }
+
+  /**
    * Show the highlighter on a given node
    */
   _show() {
     return this._update();
   }
 
   /**
    * The AutoRefreshHighlighter's _hasMoved method returns true only if the element's
@@ -347,16 +1085,20 @@ class ShapesHighlighter extends AutoRefr
   _hasMoved() {
     let hasMoved = AutoRefreshHighlighter.prototype._hasMoved.call(this);
 
     let oldShapeCoordinates = JSON.stringify(this.coordinates);
 
     // TODO: need other modes too.
     if (this.options.mode.startsWith("css")) {
       let property = shapeModeToCssPropertyName(this.options.mode);
+      // change camelCase to kebab-case
+      this.property = property.replace(/([a-z][A-Z])/g, g => {
+        return g[0] + "-" + g[1].toLowerCase();
+      });
       let style = getComputedStyle(this.currentNode)[property];
 
       if (!style || style === "none") {
         this.coordinates = [];
         this.shapeType = "none";
       } else {
         let { coordinates, shapeType } = this._parseCSSShapeValue(style);
         this.coordinates = coordinates;
@@ -381,41 +1123,43 @@ class ShapesHighlighter extends AutoRefr
 
   /**
    * 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
    */
   _update() {
     setIgnoreLayoutChanges(true);
-
-    let { top, left, width, height } = this.currentDimensions;
-    let zoom = getCurrentZoom(this.win);
+    let root = this.getElement("root");
+    root.setAttribute("hidden", true);
 
-    top /= zoom;
-    left /= zoom;
-    width /= zoom;
-    height /= zoom;
+    let { top, left, width, height } = this.zoomAdjustedDimensions;
+    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") {
       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();
+      this._updateInsetShape(width, height, zoom);
     }
 
+    let { width: winWidth, height: winHeight } = this._winDimensions;
+    root.removeAttribute("hidden");
+    root.setAttribute("style",
+      `position:absolute; width:${winWidth}px;height:${winHeight}px; overflow:hidden`);
+
     setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement);
 
     return true;
   }
 
   /**
    * Update the SVG polygon to fit the CSS polygon.
    * @param {Number} width the width of the element quads
@@ -443,17 +1187,17 @@ class ShapesHighlighter extends AutoRefr
     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");
 
-    this._drawMarkers([[cx, cy]], width, height, zoom);
+    this._drawMarkers([[cx, cy], [cx + rx, cy]], width, height, zoom);
   }
 
   /**
    * Update the SVG ellipse to fit the CSS ellipse.
    * @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
    */
@@ -467,24 +1211,34 @@ class ShapesHighlighter extends AutoRefr
     ellipseEl.removeAttribute("hidden");
 
     let markerCoords = [ [cx, cy], [cx + rx, cy], [cx, cy + ry] ];
     this._drawMarkers(markerCoords, width, height, zoom);
   }
 
   /**
    * Update the SVG rect to fit the CSS inset.
+   * @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
    */
-  _updateInsetShape() {
+  _updateInsetShape(width, height, zoom) {
+    let { top, left, right, bottom } = this.coordinates;
     let rectEl = this.getElement("rect");
-    rectEl.setAttribute("x", this.coordinates.x);
-    rectEl.setAttribute("y", this.coordinates.y);
-    rectEl.setAttribute("width", this.coordinates.width);
-    rectEl.setAttribute("height", this.coordinates.height);
+    rectEl.setAttribute("x", left);
+    rectEl.setAttribute("y", top);
+    rectEl.setAttribute("width", 100 - left - right);
+    rectEl.setAttribute("height", 100 - top - bottom);
     rectEl.removeAttribute("hidden");
+
+    let centerX = (left + (100 - right)) / 2;
+    let centerY = (top + (100 - bottom)) / 2;
+    let markerCoords = [[centerX, top], [100 - right, centerY],
+                        [centerX, 100 - bottom], [left, centerY]];
+    this._drawMarkers(markerCoords, width, height, zoom);
   }
 
   /**
    * Draw markers for the given coordinates.
    * @param {Array} coords an array of coordinate arrays, of form [[x, y] ...]
    * @param {Number} width the width of the element markers are being drawn for
    * @param {Number} height the height of the element markers are being drawn for
    * @param {Number} zoom the zoom level of the window
@@ -506,26 +1260,61 @@ class ShapesHighlighter extends AutoRefr
     this._hideShapes();
     this.getElement("markers").setAttribute("d", "");
 
     setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement);
   }
 }
 
 /**
+ * Get the "raw" (i.e. non-computed) shape definition on the given node.
+ * @param {nsIDOMNode} node the node to analyze
+ * @param {String} property the CSS property for which a value should be retrieved.
+ * @returns {String} the value of the given CSS property on the given node.
+ */
+function getDefinedShapeProperties(node, property) {
+  let prop = "";
+  if (!node) {
+    return prop;
+  }
+
+  let cssRules = getCSSStyleRules(node);
+  for (let i = 0; i < cssRules.Count(); i++) {
+    let rule = cssRules.GetElementAt(i);
+    let value = rule.style.getPropertyValue(property);
+    if (value && value !== "auto") {
+      prop = value;
+    }
+  }
+
+  if (node.style) {
+    let value = node.style.getPropertyValue(property);
+    if (value && value !== "auto") {
+      prop = value;
+    }
+  }
+
+  return prop.trim();
+}
+
+/**
  * Split coordinate pairs separated by a space and return an array.
  * @param {String} coords the coordinate pair, where each coord is separated by a space.
  * @returns {Array} a 2 element array containing the coordinates.
  */
 function splitCoords(coords) {
   // All coordinate pairs are of the form "x y" where x and y are values or
-  // calc() expressions. calc() expressions have " + " in them, so replace
-  // those with "+" before splitting with " " to get the proper coord pair.
-  return coords.trim().replace(/ \+ /g, "+").split(" ");
+  // calc() expressions. calc() expressions have spaces around operators, so
+  // replace those spaces with \u00a0 (non-breaking space) so they will not be
+  // split later.
+  return coords.trim().replace(/ [\+\-\*\/] /g, match => {
+    return `\u00a0${match.trim()}\u00a0`;
+  }).split(" ");
 }
+exports.splitCoords = splitCoords;
 
 /**
  * Convert a coordinate to a percentage value.
  * @param {String} coord a single coordinate
  * @param {Number} size the size of the element (width or height) that the percentages
  *        are relative to
  * @returns {Number} the coordinate as a percentage value
  */
@@ -538,16 +1327,17 @@ function coordToPercent(coord, size) {
     // Convert the px value to a % value.
     let px = parseFloat(coord.replace("px", ""));
     return px * 100 / size;
   }
 
   // Unit-less value, so 0.
   return 0;
 }
+exports.coordToPercent = coordToPercent;
 
 /**
  * Evaluates a CSS calc() expression (only handles addition)
  * @param {String} expression the arguments to the calc() function
  * @param {Number} size the size of the element (width or height) that percentage values
  *        are relative to
  * @returns {Number} the result of the expression as a percentage value
  */
@@ -556,26 +1346,28 @@ function evalCalcExpression(expression, 
   // computes calc() expressions as much as possible without resolving percentages,
   // leaving only addition.
   let values = expression.split("+").map(v => v.trim());
 
   return values.reduce((prev, curr) => {
     return prev + coordToPercent(curr, size);
   }, 0);
 }
+exports.evalCalcExpression = evalCalcExpression;
 
 /**
  * Converts a shape mode to the proper CSS property name.
  * @param {String} mode the mode of the CSS shape
  * @returns the equivalent CSS property name
  */
 const shapeModeToCssPropertyName = mode => {
   let property = mode.substring(3);
   return property.substring(0, 1).toLowerCase() + property.substring(1);
 };
+exports.shapeModeToCssPropertyName = shapeModeToCssPropertyName;
 
 /**
  * Get the SVG path definition for a circle with given attributes.
  * @param {Number} cx the x coordinate of the centre of the circle
  * @param {Number} cy the y coordinate of the centre of the circle
  * @param {Number} width the width of the element the circle is being drawn for
  * @param {Number} height the height of the element the circle is being drawn for
  * @param {Number} zoom the zoom level of the window the circle is drawn in
@@ -591,16 +1383,17 @@ const getCirclePath = (cx, cy, width, he
   let radius = BASE_MARKER_SIZE * (100 / Math.max(width, height)) / zoom;
   let ratio = width / height;
   let rx = (ratio > 1) ? radius : radius / ratio;
   let ry = (ratio > 1) ? radius * ratio : radius;
   // a circle is drawn as two arc lines, starting at the leftmost point of the circle.
   return `M${cx - rx},${cy}a${rx},${ry} 0 1,0 ${rx * 2},0` +
          `a${rx},${ry} 0 1,0 ${rx * -2},0`;
 };
+exports.getCirclePath = getCirclePath;
 
 /**
  * Calculates the object bounding box for a node given its stroke bounding box.
  * @param {Number} top the y coord of the top edge of the stroke bounding box
  * @param {Number} left the x coord of the left edge of the stroke bounding box
  * @param {Number} width the width of the stroke bounding box
  * @param {Number} height the height of the stroke bounding box
  * @param {Object} node the node object
@@ -631,16 +1424,40 @@ const getObjectBoundingBox = (top, left,
   return {
     top: top + delta,
     left: left + delta,
     width: width - 2 * delta,
     height: height - 2 * delta
   };
 };
 
-exports.ShapesHighlighter = ShapesHighlighter;
+/**
+ * Get the unit (e.g. px, %, em) for the given point value.
+ * @param {any} point a point value for which a unit should be retrieved.
+ * @returns {String} the unit.
+ */
+const getUnit = (point) => {
+  // If the point has no unit, default to px.
+  if (isUnitless(point)) {
+    return "px";
+  }
+  let [unit] = point.match(/[^\d]+$/) || ["px"];
+  return unit;
+};
+exports.getUnit = getUnit;
 
-// Export helper functions so they can be tested
-exports.splitCoords = splitCoords;
-exports.coordToPercent = coordToPercent;
-exports.evalCalcExpression = evalCalcExpression;
-exports.shapeModeToCssPropertyName = shapeModeToCssPropertyName;
-exports.getCirclePath = getCirclePath;
+/**
+ * Check if the given point value has a unit.
+ * @param {any} point a point value.
+ * @returns {Boolean} whether the given value has a unit.
+ */
+const isUnitless = (point) => {
+  // We treat all values that evaluate to 0 as unitless, regardless of whether
+  // they originally had a unit.
+  return !point ||
+         !point.match(/[^\d]+$/) ||
+         parseFloat(point) === 0 ||
+         point.includes("(") ||
+         point === "closest-side" ||
+         point === "farthest-side";
+};
+
+exports.ShapesHighlighter = ShapesHighlighter;
--- a/devtools/server/actors/utils/moz.build
+++ b/devtools/server/actors/utils/moz.build
@@ -6,15 +6,16 @@
 
 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',
     'stack.js',
     'TabSources.js',
     'walker-search.js',
     'webconsole-listeners.js',
     'webconsole-utils.js',
     'webconsole-worker-listeners.js',
 )
new file mode 100644
--- /dev/null
+++ b/devtools/server/actors/utils/shapes-geometry-utils.js
@@ -0,0 +1,110 @@
+"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;
--- a/devtools/server/tests/unit/test_shapes_highlighter_helpers.js
+++ b/devtools/server/tests/unit/test_shapes_highlighter_helpers.js
@@ -7,37 +7,39 @@
 
 "use strict";
 
 const {
   splitCoords,
   coordToPercent,
   evalCalcExpression,
   shapeModeToCssPropertyName,
-  getCirclePath
+  getCirclePath,
+  getUnit
 } = require("devtools/server/actors/highlighters/shapes");
 
 function run_test() {
   test_split_coords();
   test_coord_to_percent();
   test_eval_calc_expression();
   test_shape_mode_to_css_property_name();
   test_get_circle_path();
+  test_get_unit();
   run_next_test();
 }
 
 function test_split_coords() {
   const tests = [{
     desc: "splitCoords for basic coordinate pair",
     expr: "30% 20%",
     expected: ["30%", "20%"]
   }, {
     desc: "splitCoords for coord pair with calc()",
     expr: "calc(50px + 20%) 30%",
-    expected: ["calc(50px+20%)", "30%"]
+    expected: ["calc(50px\u00a0+\u00a020%)", "30%"]
   }];
 
   for (let { desc, expr, expected } of tests) {
     deepEqual(splitCoords(expr), expected, desc);
   }
 }
 
 function test_coord_to_percent() {
@@ -120,8 +122,46 @@ function test_get_circle_path() {
     cx: 0, cy: 0, width: 100, height: 200, zoom: 2,
     expected: "M-5,0a5,2.5 0 1,0 10,0a5,2.5 0 1,0 -10,0"
   }];
 
   for (let { desc, cx, cy, width, height, zoom, expected } of tests) {
     equal(getCirclePath(cx, cy, width, height, zoom), expected, desc);
   }
 }
+
+function test_get_unit() {
+  const tests = [{
+    desc: "getUnit with %",
+    expr: "30%", expected: "%"
+  }, {
+    desc: "getUnit with px",
+    expr: "400px", expected: "px"
+  }, {
+    desc: "getUnit with em",
+    expr: "4em", expected: "em"
+  }, {
+    desc: "getUnit with 0",
+    expr: "0", expected: "px"
+  }, {
+    desc: "getUnit with 0%",
+    expr: "0%", expected: "px"
+  }, {
+    desc: "getUnit with no unit",
+    expr: "30", expected: "px"
+  }, {
+    desc: "getUnit with calc",
+    expr: "calc(30px + 5%)", expected: "px"
+  }, {
+    desc: "getUnit with var",
+    expr: "var(--variable)", expected: "px"
+  }, {
+    desc: "getUnit with closest-side",
+    expr: "closest-side", expected: "px"
+  }, {
+    desc: "getUnit with farthest-side",
+    expr: "farthest-side", expected: "px"
+  }];
+
+  for (let { desc, expr, expected } of tests) {
+    equal(getUnit(expr), expected, desc);
+  }
+}