Bug 1282717 - Highlight CSS shapes points in the page from the rule-view and vice versa. r=pbro draft
authorMike Park <mikeparkms@gmail.com>
Wed, 05 Jul 2017 10:57:42 -0400
changeset 614515 4e6731704d720356abbb4788e03d8e3cb56b6e1a
parent 614349 ec3a434008e87f279c6498c44cdd157a80aa7e97
child 614567 90efd2743ff44574e601542653ae3fe1d0db782c
push id70032
push userbmo:mpark@mozilla.com
push dateMon, 24 Jul 2017 17:34:59 +0000
reviewerspbro
bugs1282717
milestone56.0a1
Bug 1282717 - Highlight CSS shapes points in the page from the rule-view and vice versa. r=pbro MozReview-Commit-ID: 9pXkbAwgcXO
devtools/client/inspector/rules/rules.js
devtools/client/inspector/rules/views/text-property-editor.js
devtools/client/inspector/shared/highlighters-overlay.js
devtools/client/inspector/shared/node-types.js
devtools/client/inspector/test/browser.ini
devtools/client/inspector/test/browser_inspector_highlighter-cssshape_05.js
devtools/client/inspector/test/head.js
devtools/client/shared/output-parser.js
devtools/client/shared/test/browser_outputparser.js
devtools/client/themes/rules.css
devtools/server/actors/highlighters.css
devtools/server/actors/highlighters.js
devtools/server/actors/highlighters/shapes.js
devtools/shared/specs/highlighters.js
--- a/devtools/client/inspector/rules/rules.js
+++ b/devtools/client/inspector/rules/rules.js
@@ -21,16 +21,17 @@ const ClassListPreviewer = require("devt
 const {gDevTools} = require("devtools/client/framework/devtools");
 const {getCssProperties} = require("devtools/shared/fronts/css-properties");
 const {
   VIEW_NODE_SELECTOR_TYPE,
   VIEW_NODE_PROPERTY_TYPE,
   VIEW_NODE_VALUE_TYPE,
   VIEW_NODE_IMAGE_URL_TYPE,
   VIEW_NODE_LOCATION_TYPE,
+  VIEW_NODE_SHAPE_POINT_TYPE,
 } = require("devtools/client/inspector/shared/node-types");
 const StyleInspectorMenu = require("devtools/client/inspector/shared/style-inspector-menu");
 const TooltipsOverlay = require("devtools/client/inspector/shared/tooltips-overlay");
 const {createChild, promiseWarn, debounce} = require("devtools/client/inspector/shared/utils");
 const EventEmitter = require("devtools/shared/event-emitter");
 const KeyShortcuts = require("devtools/client/shared/key-shortcuts");
 const clipboardHelper = require("devtools/shared/platform/clipboard");
 const AutocompletePopup = require("devtools/client/shared/autocomplete-popup");
@@ -43,16 +44,17 @@ const PREF_ENABLE_MDN_DOCS_TOOLTIP =
 const FILTER_CHANGED_TIMEOUT = 150;
 const PREF_ORIG_SOURCES = "devtools.styleeditor.source-maps-enabled";
 
 // This is used to parse user input when filtering.
 const FILTER_PROP_RE = /\s*([^:\s]*)\s*:\s*(.*?)\s*;?$/;
 // This is used to parse the filter search value to see if the filter
 // should be strict or not
 const FILTER_STRICT_RE = /\s*`(.*?)`\s*$/;
+const INSET_POINT_TYPES = ["top", "right", "bottom", "left"];
 
 /**
  * Our model looks like this:
  *
  * ElementStyle:
  *   Responsible for keeping track of which properties are overridden.
  *   Maintains a list of Rule objects that apply to the element.
  * Rule:
@@ -328,16 +330,29 @@ CssRuleView.prototype = {
         property: getPropertyNameAndValue(node).name,
         value: node.textContent,
         enabled: prop.enabled,
         overridden: prop.overridden,
         pseudoElement: prop.rule.pseudoElement,
         sheetHref: prop.rule.domRule.href,
         textProperty: prop
       };
+    } else if (classes.contains("ruleview-shape-point") && prop) {
+      type = VIEW_NODE_SHAPE_POINT_TYPE;
+      value = {
+        property: getPropertyNameAndValue(node).name,
+        value: node.textContent,
+        enabled: prop.enabled,
+        overridden: prop.overridden,
+        pseudoElement: prop.rule.pseudoElement,
+        sheetHref: prop.rule.domRule.href,
+        textProperty: prop,
+        toggleActive: getShapeToggleActive(node),
+        point: getShapePoint(node)
+      };
     } else if (classes.contains("theme-link") &&
                !classes.contains("ruleview-rule-source") && prop) {
       type = VIEW_NODE_IMAGE_URL_TYPE;
       value = {
         property: getPropertyNameAndValue(node).name,
         value: node.parentNode.textContent,
         url: node.href,
         enabled: prop.enabled,
@@ -1534,16 +1549,62 @@ function getPropertyNameAndValue(node) {
         name: node.querySelector(".ruleview-propertyname").textContent,
         value: node.querySelector(".ruleview-propertyvalue").textContent
       };
     }
     node = node.parentNode;
   }
 }
 
+/**
+ * Walk up the DOM from a given node until a parent property holder is found,
+ * and return an active shape toggle if one exists.
+ *
+ * @param {DOMNode} node
+ *        The node to start from
+ * @returns {DOMNode} The active shape toggle node, if one exists.
+ */
+function getShapeToggleActive(node) {
+  while (true) {
+    if (!node || !node.classList) {
+      return null;
+    }
+    // Check first for ruleview-computed since it's the deepest
+    if (node.classList.contains("ruleview-computed") ||
+        node.classList.contains("ruleview-property")) {
+      return node.querySelector(".ruleview-shape.active");
+    }
+    node = node.parentNode;
+  }
+}
+
+/**
+ * Get the point associated with a shape point node.
+ *
+ * @param {DOMNode} node
+ *        A shape point node
+ * @returns {String} The point associated with the given node.
+ */
+function getShapePoint(node) {
+  let classList = node.classList;
+  let point = node.dataset.point;
+  // Inset points use classes instead of data because a single span can represent
+  // multiple points.
+  let insetClasses = [];
+  classList.forEach(className => {
+    if (INSET_POINT_TYPES.includes(className)) {
+      insetClasses.push(className);
+    }
+  });
+  if (insetClasses.length > 0) {
+    point = insetClasses.join(",");
+  }
+  return point;
+}
+
 function RuleViewTool(inspector, window) {
   this.inspector = inspector;
   this.document = window.document;
 
   this.view = new CssRuleView(this.inspector, this.document);
 
   this.clearUserProperties = this.clearUserProperties.bind(this);
   this.refresh = this.refresh.bind(this);
--- a/devtools/client/inspector/rules/views/text-property-editor.js
+++ b/devtools/client/inspector/rules/views/text-property-editor.js
@@ -22,16 +22,17 @@ const Services = require("Services");
 
 const HTML_NS = "http://www.w3.org/1999/xhtml";
 
 const SHARED_SWATCH_CLASS = "ruleview-swatch";
 const COLOR_SWATCH_CLASS = "ruleview-colorswatch";
 const BEZIER_SWATCH_CLASS = "ruleview-bezierswatch";
 const FILTER_SWATCH_CLASS = "ruleview-filterswatch";
 const ANGLE_SWATCH_CLASS = "ruleview-angleswatch";
+const INSET_POINT_TYPES = ["top", "right", "bottom", "left"];
 
 /*
  * An actionable element is an element which on click triggers a specific action
  * (e.g. shows a color tooltip, opens a link, …).
  */
 const ACTIONABLE_ELEMENTS_SELECTORS = [
   `.${COLOR_SWATCH_CLASS}`,
   `.${BEZIER_SWATCH_CLASS}`,
@@ -73,16 +74,17 @@ function TextPropertyEditor(ruleEditor, 
   this._onNameDone = this._onNameDone.bind(this);
   this._onValueDone = this._onValueDone.bind(this);
   this._onSwatchCommit = this._onSwatchCommit.bind(this);
   this._onSwatchPreview = this._onSwatchPreview.bind(this);
   this._onSwatchRevert = this._onSwatchRevert.bind(this);
   this._onValidate = this.ruleView.debounce(this._previewValue, 10, this);
   this.update = this.update.bind(this);
   this.updatePropertyState = this.updatePropertyState.bind(this);
+  this._onHoverShapePoint = this._onHoverShapePoint.bind(this);
 
   this._create();
   this.update();
 }
 
 TextPropertyEditor.prototype = {
   /**
    * Boolean indicating if the name or value is being currently edited.
@@ -295,16 +297,18 @@ TextPropertyEditor.prototype = {
         contentType: InplaceEditor.CONTENT_TYPES.CSS_VALUE,
         property: this.prop,
         popup: this.popup,
         multiline: true,
         maxWidth: () => this.container.getBoundingClientRect().width,
         cssProperties: this.cssProperties,
         contextMenu: this.ruleView.inspector.onTextBoxContextMenu
       });
+
+      this.ruleView.highlighters.on("hover-shape-point", this._onHoverShapePoint);
     }
   },
 
   /**
    * Get the path from which to resolve requests for this
    * rule's stylesheet.
    *
    * @return {String} the stylesheet's href.
@@ -449,16 +453,17 @@ TextPropertyEditor.prototype = {
         return s[0].toUpperCase() + s.slice(1);
       }).join("");
       shapeToggle.setAttribute("data-mode", mode);
 
       let { highlighters, inspector } = this.ruleView;
       if (highlighters.shapesHighlighterShown === inspector.selection.nodeFront &&
           highlighters.state.shapes.options.mode === mode) {
         shapeToggle.classList.add("active");
+        highlighters.highlightRuleViewShapePoint(highlighters.state.shapes.hoverPoint);
       }
     }
 
     // Now that we have updated the property's value, we might have a pending
     // click on the value container. If we do, we have to trigger a click event
     // on the right element.
     if (this._hasPendingClick) {
       this._hasPendingClick = false;
@@ -939,12 +944,73 @@ TextPropertyEditor.prototype = {
    * Returns true if the property is a `display: [inline-]grid` declaration.
    *
    * @return {Boolean} true if the property is a `display: [inline-]grid` declaration.
    */
   isDisplayGrid: function () {
     return this.prop.name === "display" &&
       (this.prop.value === "grid" ||
        this.prop.value === "inline-grid");
-  }
+  },
+
+  /**
+   * Highlight the given shape point in the rule view. Called when "hover-shape-point"
+   * event is emitted.
+   *
+   * @param {Event} event
+   *        The "hover-shape-point" event.
+   * @param {String} point
+   *        The point to highlight.
+   */
+  _onHoverShapePoint: function (event, point) {
+    // If there is no shape toggle, or it is not active, return.
+    let shapeToggle = this.valueSpan.querySelector(".ruleview-shape.active");
+    if (!shapeToggle) {
+      return;
+    }
+
+    let view = this.ruleView;
+    let { highlighters } = view;
+    let ruleViewEl = view.element;
+    let selector = `.ruleview-shape-point.active`;
+    for (let pointNode of ruleViewEl.querySelectorAll(selector)) {
+      this._toggleShapePointActive(pointNode, false);
+    }
+
+    if (typeof point === "string") {
+      if (point.includes(",")) {
+        point = point.split(",")[0];
+      }
+      // Because one inset value can represent multiple points, inset points use classes
+      // instead of data.
+      selector = (INSET_POINT_TYPES.includes(point)) ?
+                 `.ruleview-shape-point.${point}` :
+                 `.ruleview-shape-point[data-point='${point}']`;
+      for (let pointNode of this.valueSpan.querySelectorAll(selector)) {
+        let nodeInfo = view.getNodeInfo(pointNode);
+        if (highlighters.isRuleViewShapePoint(nodeInfo)) {
+          this._toggleShapePointActive(pointNode, true);
+        }
+      }
+    }
+  },
+
+  /**
+   * Toggle the class "active" on the given shape point in the rule view if the current
+   * inspector selection is highlighted by the shapes highlighter.
+   *
+   * @param {NodeFront} node
+   *        The NodeFront of the shape point to toggle
+   * @param {Boolean} active
+   *        Whether the shape point should be active
+   */
+  _toggleShapePointActive: function (node, active) {
+    let { highlighters } = this.ruleView;
+    if (highlighters.inspector.selection.nodeFront !=
+        highlighters.shapesHighlighterShown) {
+      return;
+    }
+
+    node.classList.toggle("active", active);
+  },
 };
 
 exports.TextPropertyEditor = TextPropertyEditor;
--- a/devtools/client/inspector/shared/highlighters-overlay.js
+++ b/devtools/client/inspector/shared/highlighters-overlay.js
@@ -4,19 +4,23 @@
  * 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 Services = require("Services");
 const {Task} = require("devtools/shared/task");
 const EventEmitter = require("devtools/shared/event-emitter");
-const { VIEW_NODE_VALUE_TYPE } = require("devtools/client/inspector/shared/node-types");
+const {
+  VIEW_NODE_VALUE_TYPE,
+  VIEW_NODE_SHAPE_POINT_TYPE
+} = require("devtools/client/inspector/shared/node-types");
 
 const DEFAULT_GRID_COLOR = "#4B0082";
+const INSET_POINT_TYPES = ["top", "right", "bottom", "left"];
 
 /**
  * Highlighters overlay is a singleton managing all highlighters in the Inspector.
  *
  * @param  {Inspector} inspector
  *         Inspector toolbox panel.
  */
 function HighlightersOverlay(inspector) {
@@ -47,16 +51,17 @@ function HighlightersOverlay(inspector) 
   this.onMarkupMutation = this.onMarkupMutation.bind(this);
   this.onMouseMove = this.onMouseMove.bind(this);
   this.onMouseOut = this.onMouseOut.bind(this);
   this.onWillNavigate = this.onWillNavigate.bind(this);
   this.onNavigate = this.onNavigate.bind(this);
   this.showGridHighlighter = this.showGridHighlighter.bind(this);
   this.showShapesHighlighter = this.showShapesHighlighter.bind(this);
   this._handleRejection = this._handleRejection.bind(this);
+  this._onHighlighterEvent = this._onHighlighterEvent.bind(this);
 
   // Add inspector events, not specific to a given view.
   this.inspector.on("markupmutation", this.onMarkupMutation);
   this.inspector.target.on("navigate", this.onNavigate);
   this.inspector.target.on("will-navigate", this.onWillNavigate);
 
   EventEmitter.decorate(this);
 }
@@ -176,16 +181,61 @@ HighlightersOverlay.prototype = {
     yield this.highlighters.ShapesHighlighter.hide();
     this.emit("shapes-highlighter-hidden", this.shapesHighlighterShown,
       this.state.shapes.options);
     this.shapesHighlighterShown = null;
     this.state.shapes = {};
   }),
 
   /**
+   * Show the shapes highlighter for the given element, with the given point highlighted.
+   *
+   * @param {NodeFront} node
+   *        The NodeFront of the element to highlight.
+   * @param {String} point
+   *        The point to highlight in the shapes highlighter.
+   */
+  hoverPointShapesHighlighter: Task.async(function* (node, point) {
+    if (node == this.shapesHighlighterShown) {
+      let options = Object.assign({}, this.state.shapes.options);
+      options.hoverPoint = point;
+      yield this.showShapesHighlighter(node, options);
+    }
+  }),
+
+  /**
+   * Highlight the given shape point in the rule view.
+   *
+   * @param {String} point
+   *        The point to highlight.
+   */
+  highlightRuleViewShapePoint: function (point) {
+    let view = this.inspector.getPanel("ruleview").view;
+    let ruleViewEl = view.element;
+    let selector = `.ruleview-shape-point.active`;
+    for (let pointNode of ruleViewEl.querySelectorAll(selector)) {
+      this._toggleShapePointActive(pointNode, false);
+    }
+
+    if (point !== null && point !== undefined) {
+      // Because one inset value can represent multiple points, inset points use classes
+      // instead of data.
+      selector = (INSET_POINT_TYPES.includes(point)) ?
+                 `.ruleview-shape-point.${point}` :
+                 `.ruleview-shape-point[data-point='${point}']`;
+      for (let pointNode of ruleViewEl.querySelectorAll(selector)) {
+        let nodeInfo = view.getNodeInfo(pointNode);
+        if (this.isRuleViewShapePoint(nodeInfo)) {
+          this._toggleShapePointActive(pointNode, true);
+        }
+      }
+    }
+  },
+
+  /**
    * Toggle the grid highlighter for the given grid container element.
    *
    * @param  {NodeFront} node
    *         The NodeFront of the grid container element to highlight.
    * @param  {Object} options
    *         Object used for passing options to the grid highlighter.
    * @param. {String|null} trigger
    *         String name matching "grid" or "rule" to indicate where the
@@ -315,45 +365,62 @@ HighlightersOverlay.prototype = {
 
     yield this.highlighters.GeometryEditorHighlighter.hide();
 
     this.emit("geometry-editor-highlighter-hidden");
     this.geometryEditorHighlighterShown = null;
   }),
 
   /**
+   * Handle events emitted by the highlighter.
+   *
+   * @param {Object} data
+   *        The data object sent in the event.
+   */
+  _onHighlighterEvent: function (data) {
+    if (data.type === "shape-hover-on") {
+      this.state.shapes.hoverPoint = data.point;
+      this.emit("hover-shape-point", data.point);
+    } else if (data.type === "shape-hover-off") {
+      this.state.shapes.hoverPoint = null;
+      this.emit("hover-shape-point", null);
+    }
+    this.emit("highlighter-event-handled");
+  },
+
+  /**
    * Restore the saved highlighter states.
    * @param {String} name
    *        The name of the highlighter to be restored
-   * @param {String} selector
-   *        The selector of the node that was previously highlighted
-   * @param {Object} options
-   *        The options previously supplied to the highlighter
-   * @param {String} url
-   *        The URL of the page the highlighter was active on
+   * @param {Object} state
+   *        The state of the highlighter to be restored
    * @param {Function} showFunction
    *        The function that shows the highlighter
    * @return {Promise} that resolves when the highlighter state was restored, and the
    *         expected highlighters are displayed.
    */
-  restoreState: Task.async(function* (name, {selector, options, url}, showFunction) {
+  restoreState: Task.async(function* (name, state, showFunction) {
+    let { selector, options, url } = state;
     if (!selector || url !== this.inspector.target.url) {
       // Bail out if no selector was saved, or if we are on a different page.
       this.emit(`${name}-state-restored`, { restored: false });
       return;
     }
 
     // Wait for the new root to be ready in the inspector.
     yield this.onInspectorNewRoot;
 
     let walker = this.inspector.walker;
     let rootNode = yield walker.getRootNode();
     let nodeFront = yield walker.querySelector(rootNode, selector);
 
     if (nodeFront) {
+      if (options.hoverPoint) {
+        options.hoverPoint = null;
+      }
       yield showFunction(nodeFront, options);
       this.emit(`${name}-state-restored`, { restored: true });
     }
 
     this.emit(`${name}-state-restored`, { restored: false });
   }),
 
   /**
@@ -377,16 +444,17 @@ HighlightersOverlay.prototype = {
     } catch (e) {
       // Ignore any error
     }
 
     if (!highlighter) {
       return null;
     }
 
+    highlighter.on("highlighter-event", this._onHighlighterEvent);
     this.highlighters[type] = highlighter;
     return highlighter;
   }),
 
   _handleRejection: function (error) {
     if (!this.destroyed) {
       console.error(error);
     }
@@ -411,16 +479,33 @@ HighlightersOverlay.prototype = {
     let ruleViewEl = this.inspector.getPanel("ruleview").view.element;
 
     for (let icon of ruleViewEl.querySelectorAll(selector)) {
       icon.classList.toggle("active", active);
     }
   },
 
   /**
+   * Toggle the class "active" on the given shape point in the rule view if the current
+   * inspector selection is highlighted by the shapes highlighter.
+   *
+   * @param {NodeFront} node
+   *        The NodeFront of the shape point to toggle
+   * @param {Boolean} active
+   *        Whether the shape point should be active
+   */
+  _toggleShapePointActive: function (node, active) {
+    if (this.inspector.selection.nodeFront != this.shapesHighlighterShown) {
+      return;
+    }
+
+    node.classList.toggle("active", active);
+  },
+
+  /**
    * Hide the currently shown hovered highlighter.
    */
   _hideHoveredHighlighter: function () {
     if (!this.hoveredHighlighterShown ||
         !this.highlighters[this.hoveredHighlighterShown]) {
       return;
     }
 
@@ -482,16 +567,32 @@ HighlightersOverlay.prototype = {
     let isTransform = nodeInfo.type === VIEW_NODE_VALUE_TYPE &&
                       nodeInfo.value.property === "transform";
     let isEnabled = nodeInfo.value.enabled &&
                     !nodeInfo.value.overridden &&
                     !nodeInfo.value.pseudoElement;
     return this.isRuleView && isTransform && isEnabled;
   },
 
+  /**
+   * Is the current hovered node a highlightable shape point in the rule-view.
+   *
+   * @param  {Object} nodeInfo
+   * @return {Boolean}
+   */
+  isRuleViewShapePoint: function (nodeInfo) {
+    let isShape = nodeInfo.type === VIEW_NODE_SHAPE_POINT_TYPE &&
+                  (nodeInfo.value.property === "clip-path" ||
+                  nodeInfo.value.property === "shape-outside");
+    let isEnabled = nodeInfo.value.enabled &&
+                    !nodeInfo.value.overridden &&
+                    !nodeInfo.value.pseudoElement;
+    return this.isRuleView && isShape && isEnabled && nodeInfo.value.toggleActive;
+  },
+
   onClick: function (event) {
     if (this._isRuleViewDisplayGrid(event.target)) {
       event.stopPropagation();
 
       let { store } = this.inspector;
       let { grids, highlighterSettings } = store.getState();
       let grid = grids.find(g => g.nodeFront == this.inspector.selection.nodeFront);
 
@@ -521,16 +622,23 @@ HighlightersOverlay.prototype = {
     let view = this.isRuleView ?
       this.inspector.getPanel("ruleview").view :
       this.inspector.getPanel("computedview").computedView;
     let nodeInfo = view.getNodeInfo(event.target);
     if (!nodeInfo) {
       return;
     }
 
+    if (this.isRuleViewShapePoint(nodeInfo)) {
+      let { point } = nodeInfo.value;
+      this.hoverPointShapesHighlighter(this.inspector.selection.nodeFront, point);
+      this.emit("hover-shape-point", point);
+      return;
+    }
+
     // Choose the type of highlighter required for the hovered node.
     let type;
     if (this._isRuleViewTransform(nodeInfo) ||
         this._isComputedViewTransform(nodeInfo)) {
       type = "CssTransformHighlighter";
     }
 
     if (type) {
@@ -549,16 +657,24 @@ HighlightersOverlay.prototype = {
   onMouseOut: function (event) {
     // Only hide the highlighter if the mouse leaves the currently hovered node.
     if (!this._lastHovered ||
         (event && this._lastHovered.contains(event.relatedTarget))) {
       return;
     }
 
     // Otherwise, hide the highlighter.
+    let view = this.isRuleView ?
+      this.inspector.getPanel("ruleview").view :
+      this.inspector.getPanel("computedview").computedView;
+    let nodeInfo = view.getNodeInfo(this._lastHovered);
+    if (nodeInfo && this.isRuleViewShapePoint(nodeInfo)) {
+      this.hoverPointShapesHighlighter(this.inspector.selection.nodeFront, null);
+      this.emit("hover-shape-point", null);
+    }
     this._lastHovered = null;
     this._hideHoveredHighlighter();
   },
 
   /**
    * Handler function for "markupmutation" events. Hides the grid/shapes highlighter
    * if the grid/shapes container is no longer in the DOM tree.
    */
@@ -625,16 +741,19 @@ HighlightersOverlay.prototype = {
 
   /**
    * Destroy this overlay instance, removing it from the view and destroying
    * all initialized highlighters.
    */
   destroy: function () {
     for (let type in this.highlighters) {
       if (this.highlighters[type]) {
+        if (this.highlighters[type].off) {
+          this.highlighters[type].off("highlighter-event", this._onHighlighterEvent);
+        }
         this.highlighters[type].finalize();
         this.highlighters[type] = null;
       }
     }
 
     // Remove inspector events.
     this.inspector.off("markupmutation", this.onMarkupMutation);
     this.inspector.target.off("navigate", this.onNavigate);
--- a/devtools/client/inspector/shared/node-types.js
+++ b/devtools/client/inspector/shared/node-types.js
@@ -10,8 +10,9 @@
  * Types of nodes used in the rule and omputed view.
  */
 
 exports.VIEW_NODE_SELECTOR_TYPE = 1;
 exports.VIEW_NODE_PROPERTY_TYPE = 2;
 exports.VIEW_NODE_VALUE_TYPE = 3;
 exports.VIEW_NODE_IMAGE_URL_TYPE = 4;
 exports.VIEW_NODE_LOCATION_TYPE = 5;
+exports.VIEW_NODE_SHAPE_POINT_TYPE = 6;
--- a/devtools/client/inspector/test/browser.ini
+++ b/devtools/client/inspector/test/browser.ini
@@ -74,16 +74,17 @@ skip-if = os == "mac" # Full keyboard na
 [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-cssshape_05.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_05.js
@@ -0,0 +1,110 @@
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test hovering over shape points in the rule-view and shapes highlighter.
+
+const TEST_URL = URL_ROOT + "doc_inspector_highlighter_cssshapes.html";
+
+const HIGHLIGHTER_TYPE = "ShapesHighlighter";
+const CSS_SHAPES_ENABLED_PREF = "devtools.inspector.shapesHighlighter.enabled";
+
+add_task(function* () {
+  yield pushPref(CSS_SHAPES_ENABLED_PREF, true);
+  let env = yield openInspectorForURL(TEST_URL);
+  let helper = yield getHighlighterHelperFor(HIGHLIGHTER_TYPE)(env);
+  let { testActor, inspector } = env;
+  let view = selectRuleView(inspector);
+  let highlighters = view.highlighters;
+
+  yield highlightFromRuleView(inspector, view, highlighters, testActor);
+  yield highlightFromHighlighter(view, highlighters, testActor, helper);
+});
+
+function* highlightFromRuleView(inspector, view, highlighters, testActor) {
+  yield selectNode("#polygon", inspector);
+  yield toggleShapesHighlighter(view, highlighters, "#polygon", "clip-path", true);
+  let container = getRuleViewProperty(view, "#polygon", "clip-path").valueSpan;
+  let shapesToggle = container.querySelector(".ruleview-shape");
+
+  let highlighterFront = highlighters.highlighters[HIGHLIGHTER_TYPE];
+  let markerHidden = yield testActor.getHighlighterNodeAttribute(
+    "shapes-marker-hover", "hidden", highlighterFront);
+  ok(markerHidden, "Hover marker on highlighter is not visible");
+
+  info("Hover over point 0 in rule view");
+  let pointSpan = container.querySelector(".ruleview-shape-point[data-point='0']");
+  let onHighlighterShown = highlighters.once("shapes-highlighter-shown");
+  EventUtils.synthesizeMouseAtCenter(pointSpan, {type: "mousemove"}, view.styleWindow);
+  yield onHighlighterShown;
+
+  ok(pointSpan.classList.contains("active"), "Hovered span is active");
+  is(highlighters.state.shapes.options.hoverPoint, "0",
+     "Hovered point is saved to state");
+
+  markerHidden = yield testActor.getHighlighterNodeAttribute(
+    "shapes-marker-hover", "hidden", highlighterFront);
+  ok(!markerHidden, "Marker on highlighter is visible");
+
+  info("Move mouse off point");
+  onHighlighterShown = highlighters.once("shapes-highlighter-shown");
+  EventUtils.synthesizeMouseAtCenter(shapesToggle, {type: "mousemove"}, view.styleWindow);
+  yield onHighlighterShown;
+
+  ok(!pointSpan.classList.contains("active"), "Hovered span is no longer active");
+  is(highlighters.state.shapes.options.hoverPoint, null, "Hovered point is null");
+
+  markerHidden = yield testActor.getHighlighterNodeAttribute(
+    "shapes-marker-hover", "hidden", highlighterFront);
+  ok(markerHidden, "Marker on highlighter is not visible");
+
+  info("Hide shapes highlighter");
+  yield toggleShapesHighlighter(view, highlighters, "#polygon", "clip-path", false);
+}
+
+function* highlightFromHighlighter(view, highlighters, testActor, helper) {
+  let highlighterFront = highlighters.highlighters[HIGHLIGHTER_TYPE];
+  let { mouse } = helper;
+
+  yield toggleShapesHighlighter(view, highlighters, "#polygon", "clip-path", true);
+  let container = getRuleViewProperty(view, "#polygon", "clip-path").valueSpan;
+
+  info("Hover over first point in highlighter");
+  let onEventHandled = highlighters.once("highlighter-event-handled");
+  yield mouse.move(0, 0);
+  yield onEventHandled;
+  let markerHidden = yield testActor.getHighlighterNodeAttribute(
+    "shapes-marker-hover", "hidden", highlighterFront);
+  ok(!markerHidden, "Marker on highlighter is visible");
+
+  let pointSpan = container.querySelector(".ruleview-shape-point[data-point='0']");
+  ok(pointSpan.classList.contains("active"), "Span for point 0 is active");
+  is(highlighters.state.shapes.hoverPoint, "0", "Hovered point is saved to state");
+
+  info("Check that point is still highlighted after moving it");
+  yield mouse.down(0, 0);
+  yield mouse.move(10, 10);
+  yield mouse.up(10, 10);
+  markerHidden = yield testActor.getHighlighterNodeAttribute(
+    "shapes-marker-hover", "hidden", highlighterFront);
+  ok(!markerHidden, "Marker on highlighter is visible after moving point");
+
+  container = getRuleViewProperty(view, "element", "clip-path").valueSpan;
+  pointSpan = container.querySelector(".ruleview-shape-point[data-point='0']");
+  ok(pointSpan.classList.contains("active"),
+     "Span for point 0 is active after moving point");
+  is(highlighters.state.shapes.hoverPoint, "0",
+     "Hovered point is saved to state after moving point");
+
+  info("Move mouse off point");
+  onEventHandled = highlighters.once("highlighter-event-handled");
+  yield mouse.move(100, 100);
+  yield onEventHandled;
+  markerHidden = yield testActor.getHighlighterNodeAttribute(
+    "shapes-marker-hover", "hidden", highlighterFront);
+  ok(markerHidden, "Marker on highlighter is no longer visible");
+  ok(!pointSpan.classList.contains("active"), "Span for point 0 is no longer active");
+  is(highlighters.state.shapes.hoverPoint, null, "Hovered point is null");
+}
--- a/devtools/client/inspector/test/head.js
+++ b/devtools/client/inspector/test/head.js
@@ -798,8 +798,38 @@ function* getDisplayedNodeTextContent(se
   yield inspector.markup.expandNode(container.node);
   yield waitForMultipleChildrenUpdates(inspector);
   if (container) {
     let textContainer = container.elt.querySelector("pre");
     return textContainer.textContent;
   }
   return null;
 }
+
+/**
+ * Toggle the shapes highlighter by simulating a click on the toggle
+ * in the rules view with the given selector and property
+ *
+ * @param {CssRuleView} view
+ *        The instance of the rule-view panel
+ * @param {Object} highlighters
+ *        The highlighters instance of the rule-view panel
+ * @param {String} selector
+ *        The selector in the rule-view to look for the property in
+ * @param {String} property
+ *        The name of the property
+ * @param {Boolean} show
+ *        If true, the shapes highlighter is being shown. If false, it is being hidden
+ */
+function* toggleShapesHighlighter(view, highlighters, selector, property, show) {
+  info("Toggle shapes highlighter");
+  let container = getRuleViewProperty(view, selector, property).valueSpan;
+  let shapesToggle = container.querySelector(".ruleview-shape");
+  if (show) {
+    let onHighlighterShown = highlighters.once("shapes-highlighter-shown");
+    shapesToggle.click();
+    yield onHighlighterShown;
+  } else {
+    let onHighlighterHidden = highlighters.once("shapes-highlighter-hidden");
+    shapesToggle.click();
+    yield onHighlighterHidden;
+  }
+}
--- a/devtools/client/shared/output-parser.js
+++ b/devtools/client/shared/output-parser.js
@@ -10,16 +10,17 @@ const {getCSSLexer} = require("devtools/
 const EventEmitter = require("devtools/shared/event-emitter");
 const {
   ANGLE_TAKING_FUNCTIONS,
   BASIC_SHAPE_FUNCTIONS,
   BEZIER_KEYWORDS,
   COLOR_TAKING_FUNCTIONS,
   CSS_TYPES
 } = require("devtools/shared/css/properties-db");
+const {appendText} = require("devtools/client/inspector/shared/utils");
 const Services = require("Services");
 
 const HTML_NS = "http://www.w3.org/1999/xhtml";
 const CSS_GRID_ENABLED_PREF = "layout.css.grid.enabled";
 const CSS_SHAPES_ENABLED_PREF = "devtools.inspector.shapesHighlighter.enabled";
 
 /**
  * This module is used to process text for output by developer tools. This means
@@ -355,29 +356,546 @@ OutputParser.prototype = {
     let value = this._createNode("span", {});
     value.textContent = grid;
 
     container.appendChild(toggle);
     container.appendChild(value);
     this.parsed.push(container);
   },
 
+  /**
+   * Append a CSS shapes highlighter toggle next to the value, and parse the value
+   * into spans, each containing a point that can be hovered over.
+   *
+   * @param {String} shape
+   *        The shape text value to append
+   * @param {Object} options
+   *        Options object. For valid options and default values see
+   *        _mergeOptions()
+   */
   _appendShape: function (shape, options) {
+    const shapeTypes = [{
+      prefix: "polygon(",
+      coordParser: this._addPolygonPointNodes.bind(this)
+    }, {
+      prefix: "circle(",
+      coordParser: this._addCirclePointNodes.bind(this)
+    }, {
+      prefix: "ellipse(",
+      coordParser: this._addEllipsePointNodes.bind(this)
+    }, {
+      prefix: "inset(",
+      coordParser: this._addInsetPointNodes.bind(this)
+    }];
+
     let container = this._createNode("span", {});
 
     let toggle = this._createNode("span", {
       class: options.shapeClass
     });
 
-    let value = this._createNode("span", {});
-    value.textContent = shape;
+    for (let { prefix, coordParser } of shapeTypes) {
+      if (shape.includes(prefix)) {
+        let coordsBegin = prefix.length;
+        let coordsEnd = shape.lastIndexOf(")");
+        let valContainer = this._createNode("span", {});
+
+        container.appendChild(toggle);
+
+        appendText(valContainer, shape.substring(0, coordsBegin));
+
+        let coordsString = shape.substring(coordsBegin, coordsEnd);
+        valContainer = coordParser(coordsString, valContainer);
+
+        appendText(valContainer, shape.substring(coordsEnd));
+        container.appendChild(valContainer);
+      }
+    }
+
+    this.parsed.push(container);
+  },
+
+  /**
+   * Parse the given polygon coordinates and create a span for each coordinate pair,
+   * adding it to the given container node.
+   *
+   * @param {String} coords
+   *        The string of coordinate pairs.
+   * @param {Node} container
+   *        The node to which spans containing points are added.
+   * @returns {Node} The container to which spans have been added.
+   */
+  _addPolygonPointNodes: function (coords, container) {
+    let tokenStream = getCSSLexer(coords);
+    let token = tokenStream.nextToken();
+    let coord = "";
+    let i = 0;
+    let depth = 0;
+    let isXCoord = true;
+    let fillRule = false;
+    let coordNode = this._createNode("span", {
+      class: "ruleview-shape-point",
+      "data-point": `${i}`,
+    });
+
+    while (token) {
+      if (token.tokenType === "symbol" && token.text === ",") {
+        // Comma separating coordinate pairs; add coordNode to container and reset vars
+        if (!isXCoord) {
+          // Y coord not added to coordNode yet
+          let node = this._createNode("span", {
+            class: "ruleview-shape-point",
+            "data-point": `${i}`,
+            "data-pair": (isXCoord) ? "x" : "y"
+          }, coord);
+          coordNode.appendChild(node);
+          coord = "";
+          isXCoord = !isXCoord;
+        }
+
+        if (fillRule) {
+          // If the last text added was a fill-rule, do not increment i.
+          fillRule = false;
+        } else {
+          container.appendChild(coordNode);
+          i++;
+        }
+        appendText(container, coords.substring(token.startOffset, token.endOffset));
+        coord = "";
+        depth = 0;
+        isXCoord = true;
+        coordNode = this._createNode("span", {
+          class: "ruleview-shape-point",
+          "data-point": `${i}`,
+        });
+      } else if (token.tokenType === "symbol" && token.text === "(") {
+        depth++;
+        coord += coords.substring(token.startOffset, token.endOffset);
+      } else if (token.tokenType === "symbol" && token.text === ")") {
+        depth--;
+        coord += coords.substring(token.startOffset, token.endOffset);
+      } else if (token.tokenType === "whitespace" && coord === "") {
+        // Whitespace at beginning of coord; add to container
+        appendText(container, coords.substring(token.startOffset, token.endOffset));
+      } else if (token.tokenType === "whitespace" && depth === 0) {
+        // Whitespace signifying end of coord
+        let node = this._createNode("span", {
+          class: "ruleview-shape-point",
+          "data-point": `${i}`,
+          "data-pair": (isXCoord) ? "x" : "y"
+        }, coord);
+        coordNode.appendChild(node);
+        appendText(coordNode, coords.substring(token.startOffset, token.endOffset));
+        coord = "";
+        isXCoord = !isXCoord;
+      } else if ((token.tokenType === "number" || token.tokenType === "dimension" ||
+                  token.tokenType === "percentage" || token.tokenType === "function")) {
+        if (isXCoord && coord && depth === 0) {
+          // Whitespace is not necessary between x/y coords.
+          let node = this._createNode("span", {
+            class: "ruleview-shape-point",
+            "data-point": `${i}`,
+            "data-pair": "x"
+          }, coord);
+          coordNode.appendChild(node);
+          isXCoord = false;
+          coord = "";
+        }
+
+        coord += coords.substring(token.startOffset, token.endOffset);
+        if (token.tokenType === "function") {
+          depth++;
+        }
+      } else if (token.tokenType === "ident" &&
+                 (token.text === "nonzero" || token.text === "evenodd")) {
+        // A fill-rule (nonzero or evenodd).
+        appendText(container, coords.substring(token.startOffset, token.endOffset));
+        fillRule = true;
+      } else {
+        coord += coords.substring(token.startOffset, token.endOffset);
+      }
+      token = tokenStream.nextToken();
+    }
+
+    // Add coords if any are left over
+    if (coord) {
+      let node = this._createNode("span", {
+        class: "ruleview-shape-point",
+        "data-point": `${i}`,
+        "data-pair": (isXCoord) ? "x" : "y"
+      }, coord);
+      coordNode.appendChild(node);
+      container.appendChild(coordNode);
+    }
+    return container;
+  },
+
+  /**
+   * Parse the given circle coordinates and populate the given container appropriately
+   * with a separate span for the center point.
+   *
+   * @param {String} coords
+   *        The circle definition.
+   * @param {Node} container
+   *        The node to which the definition is added.
+   * @returns {Node} The container to which the definition has been added.
+   */
+  _addCirclePointNodes: function (coords, container) {
+    let tokenStream = getCSSLexer(coords);
+    let token = tokenStream.nextToken();
+    let depth = 0;
+    let coord = "";
+    let point = "radius";
+    let centerNode = this._createNode("span", {
+      class: "ruleview-shape-point",
+      "data-point": "center"
+    });
+    while (token) {
+      if (token.tokenType === "symbol" && token.text === "(") {
+        depth++;
+        coord += coords.substring(token.startOffset, token.endOffset);
+      } else if (token.tokenType === "symbol" && token.text === ")") {
+        depth--;
+        coord += coords.substring(token.startOffset, token.endOffset);
+      } else if (token.tokenType === "whitespace" && coord === "") {
+        // Whitespace at beginning of coord; add to container
+        appendText(container, coords.substring(token.startOffset, token.endOffset));
+      } else if (token.tokenType === "whitespace" && point === "radius" && depth === 0) {
+        // Whitespace signifying end of radius
+        let node = this._createNode("span", {
+          class: "ruleview-shape-point",
+          "data-point": "radius"
+        }, coord);
+        container.appendChild(node);
+        appendText(container, coords.substring(token.startOffset, token.endOffset));
+        point = "cx";
+        coord = "";
+        depth = 0;
+      } else if (token.tokenType === "whitespace" && depth === 0) {
+        // Whitespace signifying end of cx/cy
+        let node = this._createNode("span", {
+          class: "ruleview-shape-point",
+          "data-point": "center",
+          "data-pair": (point === "cx") ? "x" : "y"
+        }, coord);
+        centerNode.appendChild(node);
+        appendText(centerNode, coords.substring(token.startOffset, token.endOffset));
+        point = (point === "cx") ? "cy" : "cx";
+        coord = "";
+        depth = 0;
+      } else if (token.tokenType === "ident" && token.text === "at") {
+        // "at"; Add radius to container if not already done so
+        if (point === "radius" && coord) {
+          let node = this._createNode("span", {
+            class: "ruleview-shape-point",
+            "data-point": "radius"
+          }, coord);
+          container.appendChild(node);
+        }
+        appendText(container, coords.substring(token.startOffset, token.endOffset));
+        point = "cx";
+        coord = "";
+        depth = 0;
+      } else if ((token.tokenType === "number" || token.tokenType === "dimension" ||
+                  token.tokenType === "percentage" || token.tokenType === "function")) {
+        if (point === "cx" && coord && depth === 0) {
+          // Center coords don't require whitespace between x/y. So if current point is
+          // cx, we have the cx coord, and depth is 0, then this token is actually cy.
+          // Add cx to centerNode and set point to cy.
+          let node = this._createNode("span", {
+            class: "ruleview-shape-point",
+            "data-point": "center",
+            "data-pair": "x"
+          }, coord);
+          centerNode.appendChild(node);
+          point = "cy";
+          coord = "";
+        }
+
+        coord += coords.substring(token.startOffset, token.endOffset);
+        if (token.tokenType === "function") {
+          depth++;
+        }
+      } else {
+        coord += coords.substring(token.startOffset, token.endOffset);
+      }
+      token = tokenStream.nextToken();
+    }
+
+    // Add coords if any are left over.
+    if (coord) {
+      if (point === "radius") {
+        let node = this._createNode("span", {
+          class: "ruleview-shape-point",
+          "data-point": "radius"
+        }, coord);
+        container.appendChild(node);
+      } else {
+        let node = this._createNode("span", {
+          class: "ruleview-shape-point",
+          "data-point": "center",
+          "data-pair": (point === "cx") ? "x" : "y"
+        }, coord);
+        centerNode.appendChild(node);
+      }
+    }
 
-    container.appendChild(toggle);
-    container.appendChild(value);
-    this.parsed.push(container);
+    if (centerNode.textContent) {
+      container.appendChild(centerNode);
+    }
+    return container;
+  },
+
+  /**
+   * Parse the given ellipse coordinates and populate the given container appropriately
+   * with a separate span for each point
+   *
+   * @param {String} coords
+   *        The ellipse definition.
+   * @param {Node} container
+   *        The node to which the definition is added.
+   * @returns {Node} The container to which the definition has been added.
+   */
+  _addEllipsePointNodes: function (coords, container) {
+    let tokenStream = getCSSLexer(coords);
+    let token = tokenStream.nextToken();
+    let depth = 0;
+    let coord = "";
+    let point = "rx";
+    let centerNode = this._createNode("span", {
+      class: "ruleview-shape-point",
+      "data-point": "center"
+    });
+    while (token) {
+      if (token.tokenType === "symbol" && token.text === "(") {
+        depth++;
+        coord += coords.substring(token.startOffset, token.endOffset);
+      } else if (token.tokenType === "symbol" && token.text === ")") {
+        depth--;
+        coord += coords.substring(token.startOffset, token.endOffset);
+      } else if (token.tokenType === "whitespace" && coord === "") {
+        // Whitespace at beginning of coord; add to container
+        appendText(container, coords.substring(token.startOffset, token.endOffset));
+      } else if (token.tokenType === "whitespace" && depth === 0) {
+        if (point === "rx" || point === "ry") {
+          // Whitespace signifying end of rx/ry
+          let node = this._createNode("span", {
+            class: "ruleview-shape-point",
+            "data-point": point,
+          }, coord);
+          container.appendChild(node);
+          appendText(container, coords.substring(token.startOffset, token.endOffset));
+          point = (point === "rx") ? "ry" : "cx";
+          coord = "";
+          depth = 0;
+        } else {
+          // Whitespace signifying end of cx/cy
+          let node = this._createNode("span", {
+            class: "ruleview-shape-point",
+            "data-point": "center",
+            "data-pair": (point === "cx") ? "x" : "y"
+          }, coord);
+          centerNode.appendChild(node);
+          appendText(centerNode, coords.substring(token.startOffset, token.endOffset));
+          point = (point === "cx") ? "cy" : "cx";
+          coord = "";
+          depth = 0;
+        }
+      } else if (token.tokenType === "ident" && token.text === "at") {
+        // "at"; Add radius to container if not already done so
+        if (point === "ry" && coord) {
+          let node = this._createNode("span", {
+            class: "ruleview-shape-point",
+            "data-point": "ry"
+          }, coord);
+          container.appendChild(node);
+        }
+        appendText(container, coords.substring(token.startOffset, token.endOffset));
+        point = "cx";
+        coord = "";
+        depth = 0;
+      } else if ((token.tokenType === "number" || token.tokenType === "dimension" ||
+                  token.tokenType === "percentage" || token.tokenType === "function")) {
+        if (point === "rx" && coord && depth === 0) {
+          // Radius coords don't require whitespace between x/y.
+          let node = this._createNode("span", {
+            class: "ruleview-shape-point",
+            "data-point": "rx",
+          }, coord);
+          container.appendChild(node);
+          point = "ry";
+          coord = "";
+        }
+        if (point === "cx" && coord && depth === 0) {
+          // Center coords don't require whitespace between x/y.
+          let node = this._createNode("span", {
+            class: "ruleview-shape-point",
+            "data-point": "center",
+            "data-pair": "x"
+          }, coord);
+          centerNode.appendChild(node);
+          point = "cy";
+          coord = "";
+        }
+
+        coord += coords.substring(token.startOffset, token.endOffset);
+        if (token.tokenType === "function") {
+          depth++;
+        }
+      } else {
+        coord += coords.substring(token.startOffset, token.endOffset);
+      }
+      token = tokenStream.nextToken();
+    }
+
+    // Add coords if any are left over.
+    if (coord) {
+      if (point === "rx" || point === "ry") {
+        let node = this._createNode("span", {
+          class: "ruleview-shape-point",
+          "data-point": point
+        }, coord);
+        container.appendChild(node);
+      } else {
+        let node = this._createNode("span", {
+          class: "ruleview-shape-point",
+          "data-point": "center",
+          "data-pair": (point === "cx") ? "x" : "y"
+        }, coord);
+        centerNode.appendChild(node);
+      }
+    }
+
+    if (centerNode.textContent) {
+      container.appendChild(centerNode);
+    }
+    return container;
+  },
+
+  /**
+   * Parse the given inset coordinates and populate the given container appropriately.
+   *
+   * @param {String} coords
+   *        The inset definition.
+   * @param {Node} container
+   *        The node to which the definition is added.
+   * @returns {Node} The container to which the definition has been added.
+   */
+  _addInsetPointNodes: function (coords, container) {
+    const insetPoints = ["top", "right", "bottom", "left"];
+    let tokenStream = getCSSLexer(coords);
+    let token = tokenStream.nextToken();
+    let depth = 0;
+    let coord = "";
+    let i = 0;
+    let round = false;
+    // nodes is an array containing all the coordinate spans. otherText is an array of
+    // arrays, each containing the text that should be inserted into container before
+    // the node with the same index. i.e. all elements of otherText[i] is inserted
+    // into container before nodes[i].
+    let nodes = [];
+    let otherText = [[]];
+
+    while (token) {
+      if (round) {
+        // Everything that comes after "round" should just be plain text
+        otherText[i].push(coords.substring(token.startOffset, token.endOffset));
+      } else if (token.tokenType === "symbol" && token.text === "(") {
+        depth++;
+        coord += coords.substring(token.startOffset, token.endOffset);
+      } else if (token.tokenType === "symbol" && token.text === ")") {
+        depth--;
+        coord += coords.substring(token.startOffset, token.endOffset);
+      } else if (token.tokenType === "whitespace" && coord === "") {
+        // Whitespace at beginning of coord; add to container
+        otherText[i].push(coords.substring(token.startOffset, token.endOffset));
+      } else if (token.tokenType === "whitespace" && depth === 0) {
+        // Whitespace signifying end of coord; create node and push to nodes
+        let node = this._createNode("span", {
+          class: "ruleview-shape-point"
+        }, coord);
+        nodes.push(node);
+        i++;
+        coord = "";
+        otherText[i] = [coords.substring(token.startOffset, token.endOffset)];
+        depth = 0;
+      } else if ((token.tokenType === "number" || token.tokenType === "dimension" ||
+                  token.tokenType === "percentage" || token.tokenType === "function")) {
+        if (coord && depth === 0) {
+          // Inset coords don't require whitespace between each coord.
+          let node = this._createNode("span", {
+            class: "ruleview-shape-point",
+          }, coord);
+          nodes.push(node);
+          i++;
+          coord = "";
+          otherText[i] = [];
+        }
+
+        coord += coords.substring(token.startOffset, token.endOffset);
+        if (token.tokenType === "function") {
+          depth++;
+        }
+      } else if (token.tokenType === "ident" && token.text === "round") {
+        if (coord && depth === 0) {
+          // Whitespace is not necessary before "round"; create a new node for the coord
+          let node = this._createNode("span", {
+            class: "ruleview-shape-point",
+          }, coord);
+          nodes.push(node);
+          i++;
+          coord = "";
+          otherText[i] = [];
+        }
+        round = true;
+        otherText[i].push(coords.substring(token.startOffset, token.endOffset));
+      } else {
+        coord += coords.substring(token.startOffset, token.endOffset);
+      }
+      token = tokenStream.nextToken();
+    }
+
+    // Take care of any leftover text
+    if (coord) {
+      if (round) {
+        otherText[i].push(coord);
+      } else {
+        let node = this._createNode("span", {
+          class: "ruleview-shape-point",
+        }, coord);
+        nodes.push(node);
+      }
+    }
+
+    // insetPoints contains the 4 different possible inset points in the order they are
+    // defined. By taking the modulo of the index in insetPoints with the number of nodes,
+    // we can get which node represents each point (e.g. if there is only 1 node, it
+    // represents all 4 points). The exception is "left" when there are 3 nodes. In that
+    // case, it is nodes[1] that represents the left point rather than nodes[0].
+    for (let j = 0; j < 4; j++) {
+      let point = insetPoints[j];
+      let nodeIndex = (point === "left" && nodes.length === 3) ? 1 : j % nodes.length;
+      nodes[nodeIndex].classList.add(point);
+    }
+
+    nodes.forEach((node, j, array) => {
+      for (let text of otherText[j]) {
+        appendText(container, text);
+      }
+      container.appendChild(node);
+    });
+
+    // Add text that comes after the last node, if any exists
+    if (otherText[nodes.length]) {
+      for (let text of otherText[nodes.length]) {
+        appendText(container, text);
+      }
+    }
+
+    return container;
   },
 
   /**
    * Append a angle value to the output
    *
    * @param {String} angle
    *        angle to append
    * @param {Object} options
--- a/devtools/client/shared/test/browser_outputparser.js
+++ b/devtools/client/shared/test/browser_outputparser.js
@@ -1,15 +1,16 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 const OutputParser = require("devtools/client/shared/output-parser");
 const {initCssProperties, getCssProperties} = require("devtools/shared/fronts/css-properties");
+const CSS_SHAPES_ENABLED_PREF = "devtools.inspector.shapesHighlighter.enabled";
 
 add_task(function* () {
   yield addTab("about:blank");
   yield performTest();
   gBrowser.removeCurrentTab();
 });
 
 function* performTest() {
@@ -22,16 +23,17 @@ function* performTest() {
   let cssProperties = getCssProperties(toolbox);
 
   let parser = new OutputParser(doc, cssProperties);
   testParseCssProperty(doc, parser);
   testParseCssVar(doc, parser);
   testParseURL(doc, parser);
   testParseFilter(doc, parser);
   testParseAngle(doc, parser);
+  testParseShape(doc, parser);
 
   host.destroy();
 }
 
 // Class name used in color swatch.
 var COLOR_TEST_CLASS = "test-class";
 
 // Create a new CSS color-parsing test.  |name| is the name of the CSS
@@ -288,8 +290,126 @@ function testParseAngle(doc, parser) {
   frag = parser.parseCssProperty("background-image",
     "linear-gradient(90deg, red, blue", {
       angleSwatchClass: "test-angleswatch"
     });
 
   swatchCount = frag.querySelectorAll(".test-angleswatch").length;
   is(swatchCount, 1, "angle swatch was created");
 }
+
+function testParseShape(doc, parser) {
+  info("Test shape parsing");
+  pushPref(CSS_SHAPES_ENABLED_PREF, true);
+  const tests = [
+    {
+      desc: "Polygon shape",
+      definition: "polygon(evenodd, 0px 0px, 10%200px,30%30% , calc(250px - 10px) 0 ,\n "
+                  + "12em var(--variable), 100% 100%) margin-box",
+      spanCount: 18
+    },
+    {
+      desc: "Invalid polygon shape",
+      definition: "polygon(0px 0px 100px 20px, 20% 20%)",
+      spanCount: 0
+    },
+    {
+      desc: "Circle shape with all arguments",
+      definition: "circle(25% at\n 30% 200px) border-box",
+      spanCount: 4
+    },
+    {
+      desc: "Circle shape with only one center",
+      definition: "circle(25em at 40%)",
+      spanCount: 3
+    },
+    {
+      desc: "Circle shape with no radius",
+      definition: "circle(at 30% 40%)",
+      spanCount: 3
+    },
+    {
+      desc: "Circle shape with no center",
+      definition: "circle(12em)",
+      spanCount: 1
+    },
+    {
+      desc: "Circle shape with no arguments",
+      definition: "circle()",
+      spanCount: 0
+    },
+    {
+      desc: "Circle shape with no space before at",
+      definition: "circle(25%at 30% 30%)",
+      spanCount: 4
+    },
+    {
+      desc: "Invalid circle shape",
+      definition: "circle(25%at30%30%)",
+      spanCount: 0
+    },
+    {
+      desc: "Ellipse shape with all arguments",
+      definition: "ellipse(200px 10em at 25% 120px) content-box",
+      spanCount: 5
+    },
+    {
+      desc: "Ellipse shape with only one center",
+      definition: "ellipse(200px 10% at 120px)",
+      spanCount: 4
+    },
+    {
+      desc: "Ellipse shape with no radius",
+      definition: "ellipse(at 25% 120px)",
+      spanCount: 3
+    },
+    {
+      desc: "Ellipse shape with no center",
+      definition: "ellipse(200px\n10em)",
+      spanCount: 2
+    },
+    {
+      desc: "Ellipse shape with no arguments",
+      definition: "ellipse()",
+      spanCount: 0
+    },
+    {
+      desc: "Invalid ellipse shape",
+      definition: "ellipse(200px100px at 30$ 20%)",
+      spanCount: 0
+    },
+    {
+      desc: "Inset shape with 4 arguments",
+      definition: "inset(200px 100px\n 30%15%)",
+      spanCount: 4
+    },
+    {
+      desc: "Inset shape with 3 arguments",
+      definition: "inset(200px 100px 15%)",
+      spanCount: 3
+    },
+    {
+      desc: "Inset shape with 2 arguments",
+      definition: "inset(200px 100px)",
+      spanCount: 2
+    },
+    {
+      desc: "Inset shape with 1 argument",
+      definition: "inset(200px)",
+      spanCount: 1
+    },
+    {
+      desc: "Inset shape with 0 arguments",
+      definition: "inset()",
+      spanCount: 0
+    }
+  ];
+
+  for (let {desc, definition, spanCount} of tests) {
+    info(desc);
+    let frag = parser.parseCssProperty("clip-path", definition, {
+      shapeClass: "ruleview-shape"
+    });
+    let spans = frag.querySelectorAll(".ruleview-shape-point");
+    is(spans.length, spanCount, desc + " span count");
+    is(frag.textContent, definition, desc + " text content");
+  }
+}
--- a/devtools/client/themes/rules.css
+++ b/devtools/client/themes/rules.css
@@ -472,16 +472,20 @@
 }
 
 .ruleview-shape {
   background: url("chrome://devtools/skin/images/tool-shadereditor.svg");
   border-radius: 0;
   background-size: 1em;
 }
 
+.ruleview-shape-point.active {
+  background-color: var(--rule-highlight-background-color);
+}
+
 .ruleview-colorswatch::before {
   content: '';
   background-color: #eee;
   background-image: linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc),
                     linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc);
   background-size: 12px 12px;
   background-position: 0 0, 6px 6px;
   position: absolute;
--- a/devtools/server/actors/highlighters.css
+++ b/devtools/server/actors/highlighters.css
@@ -608,8 +608,12 @@
   stroke: var(--highlighter-guide-color);
   shape-rendering: geometricPrecision;
   vector-effect: non-scaling-stroke;
 }
 
 :-moz-native-anonymous .shapes-markers {
   fill: var(--highlighter-marker-color);
 }
+
+:-moz-native-anonymous .shapes-marker-hover {
+  fill: var(--highlighter-guide-color);
+}
--- a/devtools/server/actors/highlighters.js
+++ b/devtools/server/actors/highlighters.js
@@ -454,16 +454,19 @@ exports.CustomHighlighterActor = protoco
     }
 
     // The assumption is that all custom highlighters need the canvasframe
     // container to append their elements, so if this is a XUL window, bail out.
     if (!isXUL(this._inspector.tabActor.window)) {
       this._highlighterEnv = new HighlighterEnvironment();
       this._highlighterEnv.initFromTabActor(inspector.tabActor);
       this._highlighter = new constructor(this._highlighterEnv);
+      if (this._highlighter.on) {
+        this._highlighter.on("highlighter-event", this._onHighlighterEvent.bind(this));
+      }
     } else {
       throw new Error("Custom " + typeName +
         "highlighter cannot be created in a XUL window");
     }
   },
 
   get conn() {
     return this._inspector && this._inspector.conn;
@@ -507,21 +510,31 @@ exports.CustomHighlighterActor = protoco
    */
   hide: function () {
     if (this._highlighter) {
       this._highlighter.hide();
     }
   },
 
   /**
+   * Upon receiving an event from the highlighter, forward it to the client.
+   */
+  _onHighlighterEvent: function (type, data) {
+    events.emit(this, "highlighter-event", data);
+  },
+
+  /**
    * Kill this actor. This method is called automatically just before the actor
    * is destroyed.
    */
   finalize: function () {
     if (this._highlighter) {
+      if (this._highlighter.off) {
+        this._highlighter.off("highlighter-event", this._onHighlighterEvent.bind(this));
+      }
       this._highlighter.destroy();
       this._highlighter = null;
     }
 
     if (this._highlighterEnv) {
       this._highlighterEnv.destroy();
       this._highlighterEnv = null;
     }
--- a/devtools/server/actors/highlighters/shapes.js
+++ b/devtools/server/actors/highlighters/shapes.js
@@ -10,37 +10,41 @@ const { setIgnoreLayoutChanges, getCurre
 const { AutoRefreshHighlighter } = require("./auto-refresh");
 const {
   getDistance,
   clickedOnEllipseEdge,
   distanceToLine,
   projection,
   clickedOnPoint
 } = require("devtools/server/actors/utils/shapes-geometry-utils");
+const EventEmitter = require("devtools/shared/event-emitter");
 
 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);
+    EventEmitter.decorate(this);
 
     this.ID_CLASS_PREFIX = "shapes-";
 
     this.referenceBox = "border";
     this.useStrokeBox = false;
     this.geometryBox = "";
+    this.hoveredPoint = null;
+    this.fillRule = "";
 
     this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv,
       this._buildMarkup.bind(this));
     this.onPageHide = this.onPageHide.bind(this);
 
     let { pageListenerTarget } = this.highlighterEnv;
     DOM_EVENTS.forEach(event => pageListenerTarget.addEventListener(event, this));
     pageListenerTarget.addEventListener("pagehide", this.onPageHide);
@@ -117,16 +121,27 @@ class ShapesHighlighter extends AutoRefr
       parent: mainSvg,
       attributes: {
         "id": "markers",
         "class": "markers",
       },
       prefix: this.ID_CLASS_PREFIX
     });
 
+    createSVGNode(this.win, {
+      nodeType: "path",
+      parent: mainSvg,
+      attributes: {
+        "id": "marker-hover",
+        "class": "marker-hover",
+        "hidden": true
+      },
+      prefix: this.ID_CLASS_PREFIX
+    });
+
     return container;
   }
 
   get currentDimensions() {
     let { top, left, width, height } = this.currentQuads[this.referenceBox][0].bounds;
 
     // If an SVG element has a stroke, currentQuads will return the stroke bounding box.
     // However, clip-path always uses the object bounding box unless "stroke-box" is
@@ -196,16 +211,17 @@ class ShapesHighlighter extends AutoRefr
           if (this.property === "shape-outside") {
             this.currentNode.style.setProperty("width", this[_dragging].origWidth);
           }
           this[_dragging] = null;
         }
         break;
       case "mousemove":
         if (!this[_dragging]) {
+          this._handleMouseMoveNotDragging(pageX, pageY);
           return;
         }
         event.stopPropagation();
         event.preventDefault();
 
         let { point } = this[_dragging];
         if (this.shapeType === "polygon") {
           this._handlePolygonMove(pageX, pageY);
@@ -215,17 +231,17 @@ class ShapesHighlighter extends AutoRefr
           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);
+          let index = this.getPolygonPointAt(percentX, percentY);
           if (index === -1) {
             this.getPolygonClickedLine(percentX, percentY);
             return;
           }
 
           this._deletePolygonPoint(index);
         }
         break;
@@ -235,17 +251,17 @@ class ShapesHighlighter extends AutoRefr
   /**
    * 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);
+    let point = this.getPolygonPointAt(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);
@@ -268,66 +284,73 @@ class ShapesHighlighter extends AutoRefr
    */
   _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) => {
+    let polygonDef = (this.fillRule) ? `${this.fillRule}, ` : "";
+    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) => {
+    let polygonDef = (this.fillRule) ? `${this.fillRule}, ` : "";
+    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.hoveredPoint = after + 1;
+    this._emitHoverEvent(this.hoveredPoint);
     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) => {
+    let polygonDef = (this.fillRule) ? `${this.fillRule}, ` : "";
+    polygonDef += coordinates.map((coords, i) => {
       return `${coords[0]} ${coords[1]}`;
     }).join(", ");
     polygonDef = (this.geometryBox) ? `polygon(${polygonDef}) ${this.geometryBox}` :
                                       `polygon(${polygonDef})`;
 
+    this.hoveredPoint = null;
+    this._emitHoverEvent(this.hoveredPoint);
     this.currentNode.style.setProperty(this.property, polygonDef, "important");
   }
   /**
    * Handle a click when highlighting a circle.
    * @param {any} pageX the x coordinate of the click
    * @param {any} pageY the y coordinate of the click
    */
   _handleCircleClick(pageX, pageY) {
     let { width, height } = this.zoomAdjustedDimensions;
     let { percentX, percentY } = this.convertPageCoordsToPercent(pageX, pageY);
-    let point = this.getCircleClickedPoint(percentX, percentY);
+    let point = this.getCirclePointAt(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;
@@ -398,17 +421,17 @@ class ShapesHighlighter extends AutoRefr
   /**
    * 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);
+    let point = this.getEllipsePointAt(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;
@@ -497,17 +520,17 @@ class ShapesHighlighter extends AutoRefr
   /**
    * 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);
+    let point = this.getInsetPointAt(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);
@@ -550,16 +573,134 @@ class ShapesHighlighter extends AutoRefr
       `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");
   }
 
+  _handleMouseMoveNotDragging(pageX, pageY) {
+    let { percentX, percentY } = this.convertPageCoordsToPercent(pageX, pageY);
+    if (this.shapeType === "polygon") {
+      let point = this.getPolygonPointAt(percentX, percentY);
+      let oldHoveredPoint = this.hoveredPoint;
+      this.hoveredPoint = (point !== -1) ? point : null;
+      if (this.hoveredPoint !== oldHoveredPoint) {
+        this._emitHoverEvent(this.hoveredPoint);
+      }
+      this._handleMarkerHover(point);
+    } else if (this.shapeType === "circle") {
+      let point = this.getCirclePointAt(percentX, percentY);
+      let oldHoveredPoint = this.hoveredPoint;
+      this.hoveredPoint = point ? point : null;
+      if (this.hoveredPoint !== oldHoveredPoint) {
+        this._emitHoverEvent(this.hoveredPoint);
+      }
+      this._handleMarkerHover(point);
+    } else if (this.shapeType === "ellipse") {
+      let point = this.getEllipsePointAt(percentX, percentY);
+      let oldHoveredPoint = this.hoveredPoint;
+      this.hoveredPoint = point ? point : null;
+      if (this.hoveredPoint !== oldHoveredPoint) {
+        this._emitHoverEvent(this.hoveredPoint);
+      }
+      this._handleMarkerHover(point);
+    } else if (this.shapeType === "inset") {
+      let point = this.getInsetPointAt(percentX, percentY);
+      let oldHoveredPoint = this.hoveredPoint;
+      this.hoveredPoint = point ? point : null;
+      if (this.hoveredPoint !== oldHoveredPoint) {
+        this._emitHoverEvent(this.hoveredPoint);
+      }
+      this._handleMarkerHover(point);
+    }
+  }
+
+  _handleMarkerHover(point) {
+    // Hide hover marker for now, will be shown if point is a valid hover target
+    this.getElement("marker-hover").setAttribute("hidden", true);
+    if (point === null || point === undefined) {
+      return;
+    }
+
+    if (this.shapeType === "polygon") {
+      if (point === -1) {
+        return;
+      }
+      this._drawHoverMarker([this.coordinates[point]]);
+    } else if (this.shapeType === "circle") {
+      let { cx, cy, rx } = this.coordinates;
+      if (point === "radius") {
+        this._drawHoverMarker([[cx + rx, cy]]);
+      } else if (point === "center") {
+        this._drawHoverMarker([[cx, cy]]);
+      }
+    } else if (this.shapeType === "ellipse") {
+      if (point === "center") {
+        let { cx, cy } = this.coordinates;
+        this._drawHoverMarker([[cx, cy]]);
+      } else if (point === "rx") {
+        let { cx, cy, rx } = this.coordinates;
+        this._drawHoverMarker([[cx + rx, cy]]);
+      } else if (point === "ry") {
+        let { cx, cy, ry } = this.coordinates;
+        this._drawHoverMarker([[cx, cy + ry]]);
+      }
+    } else if (this.shapeType === "inset") {
+      if (!point) {
+        return;
+      }
+
+      let { top, right, bottom, left } = this.coordinates;
+      let centerX = (left + (100 - right)) / 2;
+      let centerY = (top + (100 - bottom)) / 2;
+      let points = point.split(",");
+      let coords = points.map(side => {
+        if (side === "top") {
+          return [centerX, top];
+        } else if (side === "right") {
+          return [100 - right, centerY];
+        } else if (side === "bottom") {
+          return [centerX, 100 - bottom];
+        } else if (side === "left") {
+          return [left, centerY];
+        }
+        return null;
+      });
+
+      this._drawHoverMarker(coords);
+    }
+  }
+
+  _drawHoverMarker(points) {
+    let { width, height } = this.zoomAdjustedDimensions;
+    let zoom = getCurrentZoom(this.win);
+    let path = points.map(([x, y]) => {
+      return getCirclePath(x, y, width, height, zoom);
+    }).join(" ");
+
+    let markerHover = this.getElement("marker-hover");
+    markerHover.setAttribute("d", path);
+    markerHover.removeAttribute("hidden");
+  }
+
+  _emitHoverEvent(point) {
+    if (point === null || point === undefined) {
+      this.emit("highlighter-event", {
+        type: "shape-hover-off"
+      });
+    } else {
+      this.emit("highlighter-event", {
+        type: "shape-hover-on",
+        point: point.toString()
+      });
+    }
+  }
+
   /**
    * 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.
    */
@@ -587,23 +728,23 @@ class ShapesHighlighter extends AutoRefr
     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.
+   * Get the id of the point on the polygon highlighter at the given coordinate.
    * @param {Number} pageX the x coordinate on the page, in % relative to the element
    * @param {Number} pageY the y coordinate on the page, in % relative to the element
    * @returns {Number} the index of the point that was clicked on in this.coordinates,
    *          or -1 if none of the points were clicked on.
    */
-  getPolygonClickedPoint(pageX, pageY) {
+  getPolygonPointAt(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;
@@ -642,23 +783,23 @@ class ShapesHighlighter extends AutoRefr
         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
+   * Check if the center point or radius of the circle highlighter is at given coords
    * @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) {
+  getCirclePointAt(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";
@@ -670,24 +811,24 @@ class ShapesHighlighter extends AutoRefr
         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
+   * Check if the center or rx/ry points of the ellipse highlighter is at given point
    * @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) {
+  getEllipsePointAt(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";
@@ -700,23 +841,23 @@ class ShapesHighlighter extends AutoRefr
     if (clickedOnPoint(pageX, pageY, cx, cy + ry, clickRadiusX, clickRadiusY)) {
       return "ry";
     }
 
     return "";
   }
 
   /**
-   * Check if the edges of the inset highlighter was clicked
+   * Check if the edges of the inset highlighter is at given coords
    * @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) {
+  getInsetPointAt(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;
@@ -808,33 +949,44 @@ 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) {
     this.coordUnits = this.polygonRawPoints();
-    return definition.split(", ").map(coords => {
+    let splitDef = definition.split(", ");
+    if (splitDef[0] === "evenodd" || splitDef[0] === "nonzero") {
+      splitDef.shift();
+    }
+    return splitDef.map(coords => {
       return splitCoords(coords).map(this.convertCoordsToPercent.bind(this));
     });
   }
 
   /**
    * 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 => {
+    let splitDef = definition.split(", ");
+    if (splitDef[0].includes("evenodd") || splitDef[0].includes("nonzero")) {
+      this.fillRule = splitDef[0].trim();
+      splitDef.shift();
+    } else {
+      this.fillRule = "";
+    }
+    return splitDef.map(coords => {
       return splitCoords(coords).map(coord => {
         // Undo the insertion of &nbsp; that was done in splitCoords.
         return coord.replace(/\u00a0/g, " ");
       });
     });
   }
 
   /**
@@ -1071,16 +1223,17 @@ class ShapesHighlighter extends AutoRefr
            this.getElement("polygon").hasAttribute("hidden") &&
            this.getElement("rect").hasAttribute("hidden");
   }
 
   /**
    * Show the highlighter on a given node
    */
   _show() {
+    this.hoveredPoint = this.options.hoverPoint;
     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).
    */
@@ -1147,16 +1300,18 @@ class ShapesHighlighter extends AutoRefr
     } else if (this.shapeType === "circle") {
       this._updateCircleShape(width, height, zoom);
     } else if (this.shapeType === "ellipse") {
       this._updateEllipseShape(width, height, zoom);
     } else if (this.shapeType === "inset") {
       this._updateInsetShape(width, height, zoom);
     }
 
+    this._handleMarkerHover(this.hoveredPoint);
+
     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;
--- a/devtools/shared/specs/highlighters.js
+++ b/devtools/shared/specs/highlighters.js
@@ -33,16 +33,23 @@ const highlighterSpec = generateActorSpe
   }
 });
 
 exports.highlighterSpec = highlighterSpec;
 
 const customHighlighterSpec = generateActorSpec({
   typeName: "customhighlighter",
 
+  events: {
+    "highlighter-event": {
+      type: "highlighter-event",
+      data: Arg(0, "json")
+    }
+  },
+
   methods: {
     release: {
       release: true
     },
     show: {
       request: {
         node: Arg(0, "domnode"),
         options: Arg(1, "nullable:json")