Bug 1282716 - Add static highlighter for polygon and circle shapes. r=pbro
authorMike Park <mikeparkms@gmail.com>
Mon, 15 May 2017 13:56:04 -0400
changeset 360827 28bd68d3022281ca77d8c3618d6d61867ffc5cf9
parent 360826 4f4dee0ff7b2af7282170a107b20385a122d27bf
child 360828 e9e3bea0eddd364642cfae82c6fd185f8df542dd
push id31902
push userryanvm@gmail.com
push dateFri, 26 May 2017 19:43:26 +0000
treeherdermozilla-central@ba1a33add29d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspbro
bugs1282716
milestone55.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 1282716 - Add static highlighter for polygon and circle shapes. r=pbro MozReview-Commit-ID: 37v4L7qKKWa
devtools/client/inspector/test/browser.ini
devtools/client/inspector/test/browser_inspector_highlighter-cssshape_01.js
devtools/client/inspector/test/browser_inspector_highlighter-cssshape_02.js
devtools/client/inspector/test/doc_inspector_highlighter_cssshapes.html
devtools/server/actors/highlighters.css
devtools/server/actors/highlighters.js
devtools/server/actors/highlighters/moz.build
devtools/server/actors/highlighters/shapes.js
devtools/server/tests/unit/test_shapes_highlighter_helpers.js
devtools/server/tests/unit/xpcshell.ini
--- a/devtools/client/inspector/test/browser.ini
+++ b/devtools/client/inspector/test/browser.ini
@@ -10,16 +10,17 @@ support-files =
   doc_inspector_delete-selected-node-01.html
   doc_inspector_delete-selected-node-02.html
   doc_inspector_embed.html
   doc_inspector_gcli-inspect-command.html
   doc_inspector_highlight_after_transition.html
   doc_inspector_highlighter-comments.html
   doc_inspector_highlighter-geometry_01.html
   doc_inspector_highlighter-geometry_02.html
+  doc_inspector_highlighter_cssshapes.html
   doc_inspector_highlighter_csstransform.html
   doc_inspector_highlighter_dom.html
   doc_inspector_highlighter_inline.html
   doc_inspector_highlighter.html
   doc_inspector_highlighter_rect.html
   doc_inspector_highlighter_rect_iframe.html
   doc_inspector_highlighter_xbl.xul
   doc_inspector_infobar_01.html
@@ -69,16 +70,18 @@ skip-if = os == "mac" # Full keyboard na
 [browser_inspector_highlighter-03.js]
 [browser_inspector_highlighter-04.js]
 [browser_inspector_highlighter-05.js]
 [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-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]
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-cssshape_01.js
@@ -0,0 +1,60 @@
+/* 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 the creation of the CSS shapes highlighter.
+
+const TEST_URL = URL_ROOT + "doc_inspector_highlighter_cssshapes.html";
+const HIGHLIGHTER_TYPE = "ShapesHighlighter";
+
+add_task(function* () {
+  let {inspector, testActor} = yield openInspectorForURL(TEST_URL);
+  let front = inspector.inspector;
+  let highlighter = yield front.getHighlighterByType(HIGHLIGHTER_TYPE);
+
+  yield isHiddenByDefault(testActor, highlighter);
+  yield isVisibleWhenShown(testActor, inspector, highlighter);
+
+  yield highlighter.finalize();
+});
+
+function* isHiddenByDefault(testActor, highlighterFront) {
+  info("Checking that highlighter is hidden by default");
+
+  let polygonHidden = yield testActor.getHighlighterNodeAttribute(
+    "shapes-polygon", "hidden", highlighterFront);
+  let ellipseHidden = yield testActor.getHighlighterNodeAttribute(
+    "shapes-ellipse", "hidden", highlighterFront);
+  ok(polygonHidden && ellipseHidden, "The highlighter is hidden by default");
+}
+
+function* isVisibleWhenShown(testActor, inspector, highlighterFront) {
+  info("Asking to show the highlighter on the polygon node");
+
+  let polygonNode = yield getNodeFront("#polygon", inspector);
+  yield highlighterFront.show(polygonNode, {mode: "cssClipPath"});
+
+  let polygonHidden = yield testActor.getHighlighterNodeAttribute(
+    "shapes-polygon", "hidden", highlighterFront);
+  ok(!polygonHidden, "The polygon highlighter is visible");
+
+  info("Asking to show the highlighter on the circle node");
+  let circleNode = yield getNodeFront("#circle", inspector);
+  yield highlighterFront.show(circleNode, {mode: "cssClipPath"});
+
+  let ellipseHidden = yield testActor.getHighlighterNodeAttribute(
+    "shapes-ellipse", "hidden", highlighterFront);
+  polygonHidden = yield testActor.getHighlighterNodeAttribute(
+    "shapes-polygon", "hidden", highlighterFront);
+  ok(!ellipseHidden, "The circle highlighter is visible");
+  ok(polygonHidden, "The polygon highlighter is no longer visible");
+
+  info("Hiding the highlighter");
+  yield highlighterFront.hide();
+
+  polygonHidden = yield testActor.getHighlighterNodeAttribute(
+    "shapes-polygon", "hidden", highlighterFront);
+  ok(polygonHidden, "The highlighter is hidden");
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-cssshape_02.js
@@ -0,0 +1,55 @@
+/* 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";
+
+// Make sure that the CSS shapes highlighters have the correct attributes.
+
+const TEST_URL = URL_ROOT + "doc_inspector_highlighter_cssshapes.html";
+const HIGHLIGHTER_TYPE = "ShapesHighlighter";
+
+add_task(function* () {
+  let {inspector, testActor} = yield openInspectorForURL(TEST_URL);
+  let front = inspector.inspector;
+  let highlighter = yield front.getHighlighterByType(HIGHLIGHTER_TYPE);
+
+  yield polygonHasCorrectAttrs(testActor, inspector, highlighter);
+  yield circleHasCorrectAttrs(testActor, inspector, highlighter);
+
+  yield highlighter.finalize();
+});
+
+function* polygonHasCorrectAttrs(testActor, inspector, highlighterFront) {
+  info("Checking polygon highlighter has correct points");
+
+  let polygonNode = yield getNodeFront("#polygon", inspector);
+  yield highlighterFront.show(polygonNode, {mode: "cssClipPath"});
+
+  let points = yield testActor.getHighlighterNodeAttribute(
+    "shapes-polygon", "points", highlighterFront);
+  let realPoints = "0,0 12.5,50 25,0 37.5,50 50,0 62.5,50 " +
+                   "75,0 87.5,50 100,0 90,100 50,60 10,100";
+  is(points, realPoints, "Polygon highlighter has correct points");
+}
+
+function* circleHasCorrectAttrs(testActor, inspector, highlighterFront) {
+  info("Checking circle highlighter has correct attributes");
+
+  let circleNode = yield getNodeFront("#circle", inspector);
+  yield highlighterFront.show(circleNode, {mode: "cssClipPath"});
+
+  let rx = yield testActor.getHighlighterNodeAttribute(
+    "shapes-ellipse", "rx", highlighterFront);
+  let ry = yield testActor.getHighlighterNodeAttribute(
+    "shapes-ellipse", "ry", highlighterFront);
+  let cx = yield testActor.getHighlighterNodeAttribute(
+    "shapes-ellipse", "cx", highlighterFront);
+  let cy = yield testActor.getHighlighterNodeAttribute(
+    "shapes-ellipse", "cy", highlighterFront);
+
+  is(rx, 25, "Circle highlighter has correct rx");
+  is(ry, 25, "Circle highlighter has correct ry");
+  is(cx, 30, "Circle highlighter has correct cx");
+  is(cy, 40, "Circle highlighter has correct cy");
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/doc_inspector_highlighter_cssshapes.html
@@ -0,0 +1,42 @@
+<!-- Any copyright is dedicated to the Public Domain.
+     http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE html>
+<meta charset="utf-8">
+<style>
+  html, body {
+    height: 100%;
+    margin: 0;
+  }
+  .wrapper {
+    width: 800px;
+    height: 800px;
+    background: #f06;
+  }
+  #polygon {
+    clip-path: polygon(0 0,
+                       100px 50%,
+                       200px 0,
+                       300px 50%,
+                       400px 0,
+                       500px 50%,
+                       600px 0,
+                       700px 50%,
+                       800px 0,
+                       90% 100%,
+                       50% 60%,
+                       10% 100%);
+  }
+  #circle {
+    clip-path: circle(25% at 30% 40%);
+  }
+  #ellipse {
+    clip-path: ellipse(40% 30% at 25% 75%);
+  }
+  #inset {
+    clip-path: inset(200px 100px 30% 15%);
+  }
+</style>
+<div class="wrapper" id="polygon"></div>
+<div class="wrapper" id="circle"></div>
+<div class="wrapper" id="ellipse"></div>
+<div class="wrapper" id="inset"></div>
\ No newline at end of file
--- a/devtools/server/actors/highlighters.css
+++ b/devtools/server/actors/highlighters.css
@@ -587,8 +587,32 @@
   border-radius: 2px;
   box-shadow: var(--toolbar-box-shadow);
   background-color: var(--toolbar-background);
   border: 1px solid var(--toolbar-border);
 
   font: var(--highlighter-font-family);
   font-size: var(--highlighter-font-size);
 }
+
+/* Shapes highlighter */
+
+:-moz-native-anonymous .shapes-shape-container,
+:-moz-native-anonymous .shapes-markers-container {
+  position: absolute;
+}
+
+:-moz-native-anonymous .shapes-markers-container {
+  width: 10px;
+  height: 10px;
+  transform: translate(-5px, -5px);
+  background: transparent;
+  border-radius: 50%;
+  color: var(--highlighter-bubble-background-color);
+}
+
+:-moz-native-anonymous .shapes-polygon,
+:-moz-native-anonymous .shapes-ellipse {
+  fill: transparent;
+  stroke: var(--highlighter-guide-color);
+  shape-rendering: crispEdges;
+  vector-effect: non-scaling-stroke;
+}
--- a/devtools/server/actors/highlighters.js
+++ b/devtools/server/actors/highlighters.js
@@ -34,22 +34,22 @@ const highlighterTypes = new Map();
  * Returns `true` if a highlighter for the given `typeName` is registered,
  * `false` otherwise.
  */
 const isTypeRegistered = (typeName) => highlighterTypes.has(typeName);
 exports.isTypeRegistered = isTypeRegistered;
 
 /**
  * Registers a given constructor as highlighter, for the `typeName` given.
- * If no `typeName` is provided, is looking for a `typeName` property in
- * the prototype's constructor.
+ * If no `typeName` is provided, the `typeName` property on the constructor's prototype
+ * is used, if one is found, otherwise the name of the constructor function is used.
  */
-const register = (constructor, typeName = constructor.prototype.typeName) => {
+const register = (constructor, typeName) => {
   if (!typeName) {
-    throw Error("No type's name found, or provided.");
+    typeName = constructor.prototype.typeName || constructor.name;
   }
 
   if (highlighterTypes.has(typeName)) {
     throw Error(`${typeName} is already registered.`);
   }
 
   highlighterTypes.set(typeName, constructor);
 };
@@ -723,8 +723,12 @@ exports.MeasuringToolHighlighter = Measu
 
 const { EyeDropper } = require("./highlighters/eye-dropper");
 register(EyeDropper);
 exports.EyeDropper = EyeDropper;
 
 const { PausedDebuggerOverlay } = require("./highlighters/paused-debugger");
 register(PausedDebuggerOverlay);
 exports.PausedDebuggerOverlay = PausedDebuggerOverlay;
+
+const { ShapesHighlighter } = require("./highlighters/shapes");
+register(ShapesHighlighter);
+exports.ShapesHighlighter = ShapesHighlighter;
--- a/devtools/server/actors/highlighters/moz.build
+++ b/devtools/server/actors/highlighters/moz.build
@@ -14,10 +14,11 @@ DevToolsModules(
     'css-grid.js',
     'css-transform.js',
     'eye-dropper.js',
     'geometry-editor.js',
     'measuring-tool.js',
     'paused-debugger.js',
     'rulers.js',
     'selector.js',
+    'shapes.js',
     'simple-outline.js'
 )
new file mode 100644
--- /dev/null
+++ b/devtools/server/actors/highlighters/shapes.js
@@ -0,0 +1,415 @@
+/* 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,
+        createSVGNode, createNode, getComputedStyle } = require("./utils/markup");
+const { setIgnoreLayoutChanges } = require("devtools/shared/layout/utils");
+const { AutoRefreshHighlighter } = require("./auto-refresh");
+
+// We use this as an offset to avoid the marker itself from being on top of its shadow.
+const MARKER_SIZE = 10;
+
+/**
+ * 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.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv,
+    this._buildMarkup.bind(this));
+  }
+
+  _buildMarkup() {
+    let container = createNode(this.win, {
+      attributes: {
+        "class": "highlighter-container"
+      }
+    });
+
+    // The root wrapper is used to unzoom the highlighter when needed.
+    let rootWrapper = createNode(this.win, {
+      parent: container,
+      attributes: {
+        "id": "root",
+        "class": "root"
+      },
+      prefix: this.ID_CLASS_PREFIX
+    });
+
+    let mainSvg = createSVGNode(this.win, {
+      nodeType: "svg",
+      parent: rootWrapper,
+      attributes: {
+        "id": "shape-container",
+        "class": "shape-container",
+        "viewBox": "0 0 100 100",
+        "preserveAspectRatio": "none"
+      },
+      prefix: this.ID_CLASS_PREFIX
+    });
+
+    // We also need a separate element to draw the shapes' points markers. We can't use
+    // the SVG because it is scaled.
+    createNode(this.win, {
+      nodeType: "div",
+      parent: rootWrapper,
+      attributes: {
+        "id": "markers-container",
+        "class": "markers-container"
+      },
+      prefix: this.ID_CLASS_PREFIX
+    });
+
+    // Append a polygon for polygon shapes.
+    createSVGNode(this.win, {
+      nodeType: "polygon",
+      parent: mainSvg,
+      attributes: {
+        "id": "polygon",
+        "class": "polygon",
+        "hidden": "true"
+      },
+      prefix: this.ID_CLASS_PREFIX
+    });
+
+    // Append an ellipse for circle/ellipse shapes.
+    createSVGNode(this.win, {
+      nodeType: "ellipse",
+      parent: mainSvg,
+      attributes: {
+        "id": "ellipse",
+        "class": "ellipse",
+        "hidden": true
+      },
+      prefix: this.ID_CLASS_PREFIX
+    });
+
+    // TODO: Append different SVG objects for different shapes.
+
+    return container;
+  }
+
+  /**
+   * 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.
+   */
+  _parseCSSShapeValue(definition) {
+    const types = [{
+      name: "polygon",
+      prefix: "polygon(",
+      coordParser: this.polygonPoints.bind(this)
+    }, {
+      name: "circle",
+      prefix: "circle(",
+      coordParser: this.circlePoints.bind(this)
+    }];
+
+    for (let { name, prefix, coordParser } of types) {
+      if (definition.includes(prefix)) {
+        definition = definition.substring(prefix.length, definition.length - 1);
+        return {
+          shapeType: name,
+          coordinates: coordParser(definition)
+        };
+      }
+    }
+
+    return null;
+  }
+
+  /**
+   * 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 => {
+      return splitCoords(coords).map((coord, i) => {
+        let size = i % 2 === 0 ? this.currentQuads.border[0].bounds.width
+                               : this.currentQuads.border[0].bounds.height;
+        if (coord.includes("calc(")) {
+          return evalCalcExpression(coord.substring(5, coord.length - 1), size);
+        }
+        return coordToPercent(coord, size);
+      });
+    });
+  }
+
+  /**
+   * 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) {
+    // The computed value of circle() always has the keyword "at".
+    let values = definition.split(" at ");
+    let radius = values[0];
+    let elemWidth = this.currentQuads.border[0].bounds.width;
+    let elemHeight = this.currentQuads.border[0].bounds.height;
+    let center = splitCoords(values[1]).map((coord, i) => {
+      let size = i % 2 === 0 ? elemWidth : elemHeight;
+      if (coord.includes("calc(")) {
+        return evalCalcExpression(coord.substring(5, coord.length - 1), size);
+      }
+      return coordToPercent(coord, size);
+    });
+
+    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 {
+      // radius is a % or px value
+      radius = coordToPercent(radius, Math.max(elemWidth, elemHeight));
+    }
+
+    // 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 radiusX = radius / ratioX;
+    let radiusY = radius / ratioY;
+
+    // rx, ry, cx, ry
+    return { rx: radiusX, ry: radiusY, cx: center[0], cy: center[1] };
+  }
+
+  /**
+   * Destroy the nodes. Remove listeners.
+   */
+  destroy() {
+    AutoRefreshHighlighter.prototype.destroy.call(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);
+  }
+
+  /**
+   * Show the highlighter on a given node
+   */
+  _show() {
+    return this._update();
+  }
+
+  /**
+   * The AutoRefreshHighlighter's _hasMoved method returns true only if the element's
+   * quads have changed. Override it so it also returns true if the element's shape has
+   * changed (which can happen when you change a CSS properties for instance).
+   */
+  _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);
+
+      let { coordinates, shapeType } =
+        this._parseCSSShapeValue(getComputedStyle(this.currentNode)[property]);
+      this.coordinates = coordinates;
+      this.shapeType = shapeType;
+    }
+
+    let newShapeCoordinates = JSON.stringify(this.coordinates);
+
+    return hasMoved || oldShapeCoordinates !== newShapeCoordinates;
+  }
+
+  /**
+   * Hide all elements used to highlight CSS different shapes.
+   */
+  _hideShapes() {
+    this.getElement("ellipse").setAttribute("hidden", true);
+    this.getElement("polygon").setAttribute("hidden", true);
+  }
+
+  /**
+   * 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.currentQuads.border[0].bounds;
+
+    // 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();
+    this.getElement("markers-container").setAttribute("style", "");
+
+    if (this.shapeType === "polygon") {
+      this._updatePolygonShape(top, left, width, height);
+    } else if (this.shapeType === "circle") {
+      this._updateCircleShape(top, left, width, height);
+    }
+
+    setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement);
+
+    return true;
+  }
+
+  /**
+   * Update the SVG polygon to fit the CSS polygon.
+   * @param {Number} top the top bound of the element quads
+   * @param {Number} left the left bound of the element quads
+   * @param {Number} width the width of the element quads
+   * @param {Number} height the height of the element quads
+   */
+  _updatePolygonShape(top, left, width, height) {
+    // Draw and show the polygon.
+    let points = this.coordinates.map(point => point.join(",")).join(" ");
+
+    let polygonEl = this.getElement("polygon");
+    polygonEl.setAttribute("points", points);
+    polygonEl.removeAttribute("hidden");
+
+    // Draw the points themselves, using the markers-container and multiple box-shadows.
+    let shadows = this.coordinates.map(([x, y]) => {
+      return `${MARKER_SIZE + x * width / 100}px ${MARKER_SIZE + y * height / 100}px 0 0`;
+    }).join(", ");
+
+    this.getElement("markers-container").setAttribute("style",
+      `top:${top - MARKER_SIZE}px;left:${left - MARKER_SIZE}px;box-shadow:${shadows};`);
+  }
+
+  /**
+   * Update the SVG ellipse to fit the CSS circle.
+   * @param {Number} top the top bound of the element quads
+   * @param {Number} left the left bound of the element quads
+   * @param {Number} width the width of the element quads
+   * @param {Number} height the height of the element quads
+   */
+  _updateCircleShape(top, left, width, height) {
+    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");
+
+    let shadows = `${MARKER_SIZE + cx * width / 100}px
+      ${MARKER_SIZE + cy * height / 100}px 0 0,
+      ${MARKER_SIZE + (cx + rx) * width / 100}px
+      ${MARKER_SIZE + cy * height / 100}px 0 0`;
+
+    this.getElement("markers-container").setAttribute("style",
+      `top:${top - MARKER_SIZE}px;left:${left - MARKER_SIZE}px;box-shadow:${shadows};`);
+  }
+
+  /**
+   * Hide the highlighter, the outline and the infobar.
+   */
+  _hide() {
+    setIgnoreLayoutChanges(true);
+
+    this._hideShapes();
+    this.getElement("markers-container").setAttribute("style", "");
+
+    setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement);
+  }
+}
+
+/**
+ * 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(" ");
+}
+
+/**
+ * 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
+ */
+function coordToPercent(coord, size) {
+  if (coord.includes("%")) {
+    // Just remove the % sign, nothing else to do, we're in a viewBox that's 100%
+    // worth.
+    return parseFloat(coord.replace("%", ""));
+  } else if (coord.includes("px")) {
+    // Convert the px value to a % value.
+    let px = parseFloat(coord.replace("px", ""));
+    return px * 100 / size;
+  }
+
+  // Unit-less value, so 0.
+  return 0;
+}
+
+/**
+ * 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
+ */
+function evalCalcExpression(expression, size) {
+  // the calc() values returned by getComputedStyle only have addition, as it
+  // 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);
+}
+
+/**
+ * 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.ShapesHighlighter = ShapesHighlighter;
+
+// Export helper functions so they can be tested
+exports.splitCoords = splitCoords;
+exports.coordToPercent = coordToPercent;
+exports.evalCalcExpression = evalCalcExpression;
+exports.shapeModeToCssPropertyName = shapeModeToCssPropertyName;
new file mode 100644
--- /dev/null
+++ b/devtools/server/tests/unit/test_shapes_highlighter_helpers.js
@@ -0,0 +1,101 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test the helper functions of the shapes highlighter.
+ */
+
+"use strict";
+
+const {
+  splitCoords,
+  coordToPercent,
+  evalCalcExpression,
+  shapeModeToCssPropertyName
+} = 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();
+  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%"]
+  }];
+
+  for (let { desc, expr, expected } of tests) {
+    deepEqual(splitCoords(expr), expected, desc);
+  }
+}
+
+function test_coord_to_percent() {
+  const size = 1000;
+  const tests = [{
+    desc: "coordToPercent for percent value",
+    expr: "50%",
+    expected: 50
+  }, {
+    desc: "coordToPercent for px value",
+    expr: "500px",
+    expected: 50
+  }, {
+    desc: "coordToPercent for zero value",
+    expr: "0",
+    expected: 0
+  }];
+
+  for (let { desc, expr, expected } of tests) {
+    equal(coordToPercent(expr, size), expected, desc);
+  }
+}
+
+function test_eval_calc_expression() {
+  const size = 1000;
+  const tests = [{
+    desc: "evalCalcExpression with one value",
+    expr: "50%",
+    expected: 50
+  }, {
+    desc: "evalCalcExpression with percent and px values",
+    expr: "50% + 100px",
+    expected: 60
+  }, {
+    desc: "evalCalcExpression with a zero value",
+    expr: "0 + 100px",
+    expected: 10
+  }, {
+    desc: "evalCalcExpression with a negative value",
+    expr: "-200px+50%",
+    expected: 30
+  }];
+
+  for (let { desc, expr, expected } of tests) {
+    equal(evalCalcExpression(expr, size), expected, desc);
+  }
+}
+
+function test_shape_mode_to_css_property_name() {
+  const tests = [{
+    desc: "shapeModeToCssPropertyName for clip-path",
+    expr: "cssClipPath",
+    expected: "clipPath"
+  }, {
+    desc: "shapeModeToCssPropertyName for shape-outside",
+    expr: "cssShapeOutside",
+    expected: "shapeOutside"
+  }];
+
+  for (let { desc, expr, expected } of tests) {
+    equal(shapeModeToCssPropertyName(expr), expected, desc);
+  }
+}
--- a/devtools/server/tests/unit/xpcshell.ini
+++ b/devtools/server/tests/unit/xpcshell.ini
@@ -226,8 +226,9 @@ support-files = xpcshell_debugging_scrip
 [test_setBreakpoint-on-line.js]
 [test_setBreakpoint-on-line-in-gcd-script.js]
 [test_setBreakpoint-on-line-with-multiple-offsets.js]
 [test_setBreakpoint-on-line-with-multiple-statements.js]
 [test_setBreakpoint-on-line-with-no-offsets.js]
 [test_setBreakpoint-on-line-with-no-offsets-in-gcd-script.js]
 [test_safe-getter.js]
 [test_client_close.js]
+[test_shapes_highlighter_helpers.js]