Bug 1282716 - Add static highlighter for polygon and circle shapes. r=pbro draft
authorMike Park <mikeparkms@gmail.com>
Mon, 15 May 2017 13:56:04 -0400
changeset 583801 3e6c720c4c0863f71ab37b9bdefc1fc4274ec994
parent 583751 1a735bdbbfa9a11e78b7e977d45cf00af85e7b66
child 583802 0679fb5e156ad6d2048f39a531272c9dedbf9bba
push id60553
push userbmo:mpark@mozilla.com
push dateWed, 24 May 2017 17:08:56 +0000
reviewerspbro
bugs1282716
milestone55.0a1
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]