Bug 1282719 - Make CSS shapes highlighter points editable. r=zer0 draft
authorMike Park <mikeparkms@gmail.com>
Wed, 14 Jun 2017 17:18:57 -0400 (2017-06-14)
changeset 606157 59d9e60f9b3470771658124355d461a67ed1bf5e
parent 602297 d9a144b7b6d994fc9a497c53b13f51a2a654d85e
child 608458 9b830044706925ea5ecf61f59aaaf965607a7b2f
push id67622
push userbmo:mpark@mozilla.com
push dateMon, 10 Jul 2017 14:35:40 +0000 (2017-07-10)
reviewerszer0
bugs1282719
milestone56.0a1
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);
+  }
+}