Bug 1282717 - Highlight CSS shapes points in the page from the rule-view and vice versa. r=pbro
authorMike Park <mikeparkms@gmail.com>
Wed, 05 Jul 2017 10:57:42 -0400
changeset 419456 2f8b9d3feecbe55eb6faf5f419870aca27c8023d
parent 419455 02a519055f6fdbd69f4269333c54d1422acf84e2
child 419457 69e573abaccb3eba8446c669328f133b7c503a48
push id7566
push usermtabara@mozilla.com
push dateWed, 02 Aug 2017 08:25:16 +0000
treeherdermozilla-beta@86913f512c3c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspbro
bugs1282717
milestone56.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 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")