Bug 1435373 - Shapes editor: implementation to map shape value changes to rule. r=pbro draft
authorRazvan Caliman <rcaliman@mozilla.com>
Thu, 05 Apr 2018 12:21:44 +0200
changeset 779201 86bec3760b5ed86c42e4f3a1c77597ce64be0120
parent 779196 8c0b9a89288353152e8409c53bbb41461240c317
child 779202 03b412c9e45e0b293af9050543b3f1e69df88e7c
push id105693
push userbmo:rcaliman@mozilla.com
push dateMon, 09 Apr 2018 13:16:25 +0000
reviewerspbro
bugs1435373
milestone61.0a1
Bug 1435373 - Shapes editor: implementation to map shape value changes to rule. r=pbro MozReview-Commit-ID: i20YChYAxd
devtools/client/inspector/inspector.js
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/shared/widgets/ShapesInContextEditor.js
devtools/client/shared/widgets/moz.build
devtools/client/themes/rules.css
devtools/server/actors/highlighters/shapes.js
--- a/devtools/client/inspector/inspector.js
+++ b/devtools/client/inspector/inspector.js
@@ -1122,18 +1122,17 @@ Inspector.prototype = {
       return;
     }
 
     let onExpand = this.markup.expandNode(this.selection.nodeFront);
 
     // Restore the highlighter states prior to emitting "new-root".
     await Promise.all([
       this.highlighters.restoreFlexboxState(),
-      this.highlighters.restoreGridState(),
-      this.highlighters.restoreShapeState()
+      this.highlighters.restoreGridState()
     ]);
 
     this.emit("new-root");
 
     // Wait for full expand of the selected node in order to ensure
     // the markup view is fully emitted before firing 'reloaded'.
     // 'reloaded' is used to know when the panel is fully updated
     // after a page reload.
@@ -1356,17 +1355,17 @@ Inspector.prototype = {
 
     this.teardownToolbar();
     this.breadcrumbs.destroy();
     this.selection.off("new-node-front", this.onNewSelection);
     this.selection.off("detached-front", this.onDetached);
 
     let markupDestroyer = this._destroyMarkup();
 
-    this.highlighters.destroy();
+    let highlighterDestroyer = this.highlighters.destroy();
     this.prefsObserver.destroy();
     this.reflowTracker.destroy();
     this.styleChangeTracker.destroy();
     this.search.destroy();
 
     this._toolbox = null;
     this.breadcrumbs = null;
     this.highlighters = null;
@@ -1379,16 +1378,17 @@ Inspector.prototype = {
     this.search = null;
     this.searchBox = null;
     this.show3PaneToggle = null;
     this.sidebar = null;
     this.store = null;
     this.target = null;
 
     this._panelDestroyer = promise.all([
+      highlighterDestroyer,
       cssPropertiesDestroyer,
       markupDestroyer,
       sidebarDestroyer,
       ruleViewSideBarDestroyer
     ]);
 
     return this._panelDestroyer;
   },
--- a/devtools/client/inspector/rules/rules.js
+++ b/devtools/client/inspector/rules/rules.js
@@ -19,16 +19,17 @@ const ClassListPreviewer = require("devt
 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,
+  VIEW_NODE_SHAPE_SWATCH,
   VIEW_NODE_VARIABLE_TYPE,
   VIEW_NODE_FONT_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} = require("devtools/client/inspector/shared/utils");
 const {debounce} = require("devtools/shared/debounce");
 const EventEmitter = require("devtools/shared/event-emitter");
@@ -356,16 +357,23 @@ CssRuleView.prototype = {
         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("ruleview-shapeswatch") && prop) {
+      type = VIEW_NODE_SHAPE_SWATCH;
+      value = {
+        enabled: prop.enabled,
+        overridden: prop.overridden,
+        textProperty: prop,
+      };
     } else if ((classes.contains("ruleview-variable") ||
                 classes.contains("ruleview-unmatched-variable")) && prop) {
       type = VIEW_NODE_VARIABLE_TYPE;
       value = {
         property: getPropertyNameAndValue(node).name,
         value: node.textContent,
         enabled: prop.enabled,
         overridden: prop.overridden,
--- a/devtools/client/inspector/rules/views/text-property-editor.js
+++ b/devtools/client/inspector/rules/views/text-property-editor.js
@@ -22,17 +22,16 @@ 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"];
 const FONT_FAMILY_CLASS = "ruleview-font-family";
 const SHAPE_SWATCH_CLASS = "ruleview-shapeswatch";
 
 /*
  * 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 = [
@@ -91,17 +90,16 @@ 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.
@@ -314,18 +312,16 @@ TextPropertyEditor.prototype = {
         property: this.prop,
         defaultIncrement: this.prop.name === "opacity" ? 0.1 : 1,
         popup: this.popup,
         multiline: true,
         maxWidth: () => this.container.getBoundingClientRect().width,
         cssProperties: this.cssProperties,
         cssVariables: this.rule.elementStyle.variables,
       });
-
-      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.
@@ -510,23 +506,16 @@ TextPropertyEditor.prototype = {
     }
 
     let shapeToggle = this.valueSpan.querySelector(".ruleview-shapeswatch");
     if (shapeToggle) {
       let mode = "css" + name.split("-").map(s => {
         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;
       let elToClick;
@@ -1040,72 +1029,11 @@ 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(point) {
-    // If there is no shape toggle, or it is not active, return.
-    let shapeToggle = this.valueSpan.querySelector(".ruleview-shapeswatch.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);
-  },
 };
 
 module.exports = TextPropertyEditor;
--- a/devtools/client/inspector/shared/highlighters-overlay.js
+++ b/devtools/client/inspector/shared/highlighters-overlay.js
@@ -7,31 +7,39 @@
 "use strict";
 
 const Services = require("Services");
 const EventEmitter = require("devtools/shared/event-emitter");
 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.
  */
 class HighlightersOverlay {
   /**
    * @param  {Inspector} inspector
    *         Inspector toolbox panel.
    */
   constructor(inspector) {
+    /*
+    * Collection of instantiated highlighter actors like FlexboxHighlighter,
+    * CssGridHighlighter, ShapesHighlighter and GeometryEditorHighlighter.
+    */
+    this.highlighters = {};
+    /*
+    * Collection of instantiated in-context editors, like ShapesInContextEditor, which
+    * behave like highlighters but with added editing capabilities that need to map value
+    * changes to properties in the Rule view.
+    */
+    this.editors = {};
     this.inspector = inspector;
-    this.highlighters = {};
     this.highlighterUtils = this.inspector.toolbox.highlighterUtils;
 
     // Only initialize the overlay if at least one of the highlighter types is supported.
     this.supportsHighlighters = this.highlighterUtils.supportsCustomHighlighters();
 
     // NodeFront of the flexbox container that is highlighted.
     this.flexboxHighlighterShown = null;
     // NodeFront of element that is highlighted by the geometry editor.
@@ -58,17 +66,18 @@ class HighlightersOverlay {
     this.onWillNavigate = this.onWillNavigate.bind(this);
     this.hideFlexboxHighlighter = this.hideFlexboxHighlighter.bind(this);
     this.hideGridHighlighter = this.hideGridHighlighter.bind(this);
     this.hideShapesHighlighter = this.hideShapesHighlighter.bind(this);
     this.showFlexboxHighlighter = this.showFlexboxHighlighter.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);
+    this.onShapesHighlighterShown = this.onShapesHighlighterShown.bind(this);
+    this.onShapesHighlighterHidden = this.onShapesHighlighterHidden.bind(this);
 
     // Add inspector events, not specific to a given view.
     this.inspector.on("markupmutation", this.onMarkupMutation);
     this.inspector.target.on("will-navigate", this.onWillNavigate);
 
     EventEmitter.decorate(this);
   }
 
@@ -116,91 +125,88 @@ class HighlightersOverlay {
 
     let el = view.element;
     el.removeEventListener("click", this.onClick, true);
     el.removeEventListener("mousemove", this.onMouseMove);
     el.removeEventListener("mouseout", this.onMouseOut);
   }
 
   /**
-   * Toggle the shapes highlighter for the given element with a shape.
-   *
+   * Toggle the shapes highlighter for the given node.
+
    * @param  {NodeFront} node
    *         The NodeFront of the element with a shape to highlight.
    * @param  {Object} options
    *         Object used for passing options to the shapes highlighter.
+   * @param {TextProperty} textProperty
+   *        TextProperty where to write changes.
    */
-  async toggleShapesHighlighter(node, options = {}) {
-    options.transformMode = options.ctrlOrMetaPressed;
-
-    if (node == this.shapesHighlighterShown &&
-        options.mode === this.state.shapes.options.mode) {
-      // If meta/ctrl is not pressed, hide the highlighter.
-      if (!options.ctrlOrMetaPressed) {
-        await this.hideShapesHighlighter(node);
-        return;
-      }
-
-      // If meta/ctrl is pressed, toggle transform mode on the highlighter.
-      options.transformMode = !this.state.shapes.options.transformMode;
+  async toggleShapesHighlighter(node, options, textProperty) {
+    let shapesEditor = await this.getInContextEditor("shapesEditor");
+    if (!shapesEditor) {
+      return;
     }
-
-    await this.showShapesHighlighter(node, options);
+    shapesEditor.toggle(node, options, textProperty);
   }
 
   /**
-   * Show the shapes highlighter for the given element with a shape.
+   * Show the shapes highlighter for the given node.
+   * This method delegates to the in-context shapes editor.
    *
    * @param  {NodeFront} node
    *         The NodeFront of the element with a shape to highlight.
    * @param  {Object} options
    *         Object used for passing options to the shapes highlighter.
    */
   async showShapesHighlighter(node, options) {
-    let highlighter = await this._getHighlighter("ShapesHighlighter");
-    if (!highlighter) {
-      return;
-    }
-
-    let isShown = await highlighter.show(node, options);
-    if (!isShown) {
+    let shapesEditor = await this.getInContextEditor("shapesEditor");
+    if (!shapesEditor) {
       return;
     }
-
-    this.shapesHighlighterShown = node;
-    let { mode } = options;
-    this._toggleRuleViewIcon(node, false, ".ruleview-shapeswatch");
-    this._toggleRuleViewIcon(node, true, `.ruleview-shapeswatch[data-mode='${mode}']`);
-
-    try {
-      // Save shapes highlighter state.
-      let { url } = this.inspector.target;
-      let selector = await node.getUniqueSelector();
-      this.state.shapes = { selector, options, url };
-      this.shapesHighlighterShown = node;
-      this.emit("shapes-highlighter-shown", node, options);
-    } catch (e) {
-      this._handleRejection(e);
-    }
+    shapesEditor.show(node, options);
   }
 
   /**
-   * Hide the shapes highlighter for the given element with a shape.
+   * Called after the shape highlighter was shown.
    *
-   * @param  {NodeFront} node
-   *         The NodeFront of the element with a shape to unhighlight.
+   * @param  {Object} data
+   *         Data associated with the event.
+   *         Contains:
+   *         - {NodeFront} node: The NodeFront of the element that is highlighted.
+   *         - {Object} options: Options that were passed to ShapesHighlighter.show()
    */
-  async hideShapesHighlighter(node) {
-    if (!this.shapesHighlighterShown || !this.highlighters.ShapesHighlighter) {
+  onShapesHighlighterShown(data) {
+    let { node, options } = data;
+    this.shapesHighlighterShown = node;
+    this.state.shapes.options = options;
+    this.emit("shapes-highlighter-shown", node, options);
+  }
+
+  /**
+   * Hide the shapes highlighter if visible.
+   * This method delegates the to the in-context shapes editor which wraps
+   * the shapes highlighter with additional functionality.
+   */
+  async hideShapesHighlighter() {
+    let shapesEditor = await this.getInContextEditor("shapesEditor");
+    if (!shapesEditor) {
       return;
     }
+    shapesEditor.hide();
+  }
 
-    this._toggleRuleViewIcon(node, false, ".ruleview-shapeswatch");
-
-    await this.highlighters.ShapesHighlighter.hide();
+  /**
+   * Called after the shapes highlighter was hidden.
+   *
+   * @param  {Object} data
+   *         Data associated with the event.
+   *         Contains:
+   *         - {NodeFront} node: The NodeFront of the element that was highlighted.
+   */
+  onShapesHighlighterHidden(data) {
     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.
@@ -214,47 +220,16 @@ class HighlightersOverlay {
     if (node == this.shapesHighlighterShown) {
       let options = Object.assign({}, this.state.shapes.options);
       options.hoverPoint = point;
       await this.showShapesHighlighter(node, options);
     }
   }
 
   /**
-   * Highlight the given shape point in the rule view.
-   *
-   * @param {String} point
-   *        The point to highlight.
-   */
-  highlightRuleViewShapePoint(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 flexbox highlighter for the given flexbox container element.
    *
    * @param  {NodeFront} node
    *         The NodeFront of the flexbox container element to highlight.
    * @param  {Object} options
    *         Object used for passing options to the flexbox highlighter.
    */
   async toggleFlexboxHighlighter(node, options = {}) {
@@ -460,34 +435,16 @@ class HighlightersOverlay {
 
     await 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(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");
-  }
-
-  /**
    * Restores the saved flexbox highlighter state.
    */
   async restoreFlexboxState() {
     try {
       await this.restoreState("flexbox", this.state.flexbox, this.showFlexboxHighlighter);
     } catch (e) {
       this._handleRejection(e);
     }
@@ -500,29 +457,18 @@ class HighlightersOverlay {
     try {
       await this.restoreState("grid", this.state.grid, this.showGridHighlighter);
     } catch (e) {
       this._handleRejection(e);
     }
   }
 
   /**
-   * Restores the saved shape highlighter state.
-   */
-  async restoreShapeState() {
-    try {
-      await this.restoreState("shapes", this.state.shapes, this.showShapesHighlighter);
-    } catch (e) {
-      this._handleRejection(e);
-    }
-  }
-
-  /**
-   * Helper function called by restoreFlexboxState, restoreGridState and
-   * restoreShapeState. Restores the saved highlighter state for the given highlighter
+   * Helper function called by restoreFlexboxState, restoreGridState.
+   * Restores the saved highlighter state for the given highlighter
    * and their state.
    *
    * @param  {String} name
    *         The name of the highlighter to be restored
    * @param  {Object} state
    *         The state of the highlighter to be restored
    * @param  {Function} showFunction
    *         The function that shows the highlighter
@@ -550,16 +496,57 @@ class HighlightersOverlay {
       await showFunction(nodeFront, options);
       this.emit(`${name}-state-restored`, { restored: true });
     }
 
     this.emit(`${name}-state-restored`, { restored: false });
   }
 
   /**
+  * Get an instance of an in-context editor for the given type.
+  *
+  * In-context editors behave like highlighters but with added editing capabilities which
+  * need to write value changes back to something, like to properties in the Rule view.
+  * They typically exist in the context of the page, like the ShapesInContextEditor.
+  *
+  * @param  {String} type
+  *         Type of in-context editor. Currently supported: "shapesEditor"
+  *
+  * @return {Object|null}
+  *         Reference to instance for given type of in-context editor or null.
+  */
+  async getInContextEditor(type) {
+    if (this.editors[type]) {
+      return this.editors[type];
+    }
+
+    let editor;
+
+    switch (type) {
+      case "shapesEditor":
+        let highlighter = await this._getHighlighter("ShapesHighlighter");
+        if (!highlighter) {
+          return null;
+        }
+        const ShapesInContextEditor = require("devtools/client/shared/widgets/ShapesInContextEditor");
+
+        editor = new ShapesInContextEditor(highlighter, this.inspector, this.state);
+        editor.on("show", this.onShapesHighlighterShown);
+        editor.on("hide", this.onShapesHighlighterHidden);
+        break;
+      default:
+        throw new Error(`Unsupported in-context editor '${name}'`);
+    }
+
+    this.editors[type] = editor;
+
+    return editor;
+  }
+
+  /**
    * Get a highlighter front given a type. It will only be initialized once.
    *
    * @param  {String} type
    *         The highlighter type. One of this.highlighters.
    * @return {Promise} that resolves to the highlighter
    */
   async _getHighlighter(type) {
     let utils = this.highlighterUtils;
@@ -575,17 +562,16 @@ class HighlightersOverlay {
     } catch (e) {
       // Ignore any error
     }
 
     if (!highlighter) {
       return null;
     }
 
-    highlighter.on("highlighter-event", this._onHighlighterEvent);
     this.highlighters[type] = highlighter;
     return highlighter;
   }
 
   _handleRejection(error) {
     if (!this.destroyed) {
       console.error(error);
     }
@@ -717,17 +703,17 @@ class HighlightersOverlay {
 
   /**
    * Does the current clicked node have the shapes highlighter toggle in the
    * rule-view.
    *
    * @param  {DOMNode} node
    * @return {Boolean}
    */
-  _isRuleViewShape(node) {
+  _isRuleViewShapeSwatch(node) {
     return this.isRuleView(node) && node.classList.contains("ruleview-shapeswatch");
   }
 
   /**
    * Is the current hovered node a css transform property value in the rule-view.
    *
    * @param  {Object} nodeInfo
    * @return {Boolean}
@@ -771,28 +757,34 @@ class HighlightersOverlay {
       let { store } = this.inspector;
       let { grids, highlighterSettings } = store.getState();
       let grid = grids.find(g => g.nodeFront == this.inspector.selection.nodeFront);
 
       highlighterSettings.color = grid ? grid.color : DEFAULT_GRID_COLOR;
 
       this.toggleGridHighlighter(this.inspector.selection.nodeFront, highlighterSettings,
         "rule");
-    } else if (this._isRuleViewDisplayFlex(event.target)) {
+    }
+
+    if (this._isRuleViewDisplayFlex(event.target)) {
       event.stopPropagation();
 
       this.toggleFlexboxHighlighter(this.inspector.selection.nodeFront);
-    } else if (this._isRuleViewShape(event.target)) {
+    }
+
+    if (this._isRuleViewShapeSwatch(event.target)) {
       event.stopPropagation();
 
-      let settings = {
+      const view = this.inspector.getPanel("ruleview").view;
+      const nodeInfo = view.getNodeInfo(event.target);
+
+      this.toggleShapesHighlighter(this.inspector.selection.nodeFront, {
         mode: event.target.dataset.mode,
-        ctrlOrMetaPressed: event.metaKey || event.ctrlKey
-      };
-      this.toggleShapesHighlighter(this.inspector.selection.nodeFront, settings);
+        transformMode: event.metaKey || event.ctrlKey
+      }, nodeInfo.value.textProperty);
     }
   }
 
   onMouseMove(event) {
     // Bail out if the target is the same as for the last mousemove.
     if (event.target === this._lastHovered) {
       return;
     }
@@ -808,17 +800,16 @@ class HighlightersOverlay {
     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";
@@ -846,17 +837,16 @@ class HighlightersOverlay {
 
     // Otherwise, hide the highlighter.
     let view = this.isRuleView(this._lastHovered) ?
       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 flexbox/grid/shapes
    * highlighter if the flexbox/grid/shapes container is no longer in the DOM tree.
@@ -882,41 +872,61 @@ class HighlightersOverlay {
    */
   onWillNavigate() {
     this.flexboxHighlighterShown = null;
     this.geometryEditorHighlighterShown = null;
     this.gridHighlighterShown = null;
     this.hoveredHighlighterShown = null;
     this.selectorHighlighterShown = null;
     this.shapesHighlighterShown = null;
+    this.destroyEditors();
+  }
+
+  /**
+  * Destroy and clean-up all instances of in-context editors.
+  */
+  destroyEditors() {
+    for (let type in this.editors) {
+      this.editors[type].off("show");
+      this.editors[type].off("hide");
+      this.editors[type].destroy();
+    }
+
+    this.editors = {};
+  }
+
+  /**
+  * Destroy and clean-up all instances of highlighters.
+  */
+  destroyHighlighters() {
+    for (let type in this.highlighters) {
+      if (this.highlighters[type]) {
+        this.highlighters[type].finalize();
+        this.highlighters[type] = null;
+      }
+    }
+
+    this.highlighters = null;
   }
 
   /**
    * Destroy this overlay instance, removing it from the view and destroying
    * all initialized highlighters.
    */
-  destroy() {
-    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;
-      }
-    }
+  async destroy() {
+    this.destroyHighlighters();
+    this.destroyEditors();
 
     // Remove inspector events.
     this.inspector.off("markupmutation", this.onMarkupMutation);
     this.inspector.target.off("will-navigate", this.onWillNavigate);
 
     this._lastHovered = null;
 
     this.inspector = null;
-    this.highlighters = null;
     this.highlighterUtils = null;
     this.supportsHighlighters = null;
     this.state = null;
 
     this.flexboxHighlighterShown = null;
     this.geometryEditorHighlighterShown = null;
     this.gridHighlighterShown = null;
     this.hoveredHighlighterShown = null;
--- a/devtools/client/inspector/shared/node-types.js
+++ b/devtools/client/inspector/shared/node-types.js
@@ -13,8 +13,9 @@
 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;
 exports.VIEW_NODE_VARIABLE_TYPE = 7;
 exports.VIEW_NODE_FONT_TYPE = 8;
+exports.VIEW_NODE_SHAPE_SWATCH = 9;
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/widgets/ShapesInContextEditor.js
@@ -0,0 +1,298 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EventEmitter = require("devtools/shared/event-emitter");
+const { debounce } = require("devtools/shared/debounce");
+
+/**
+ * The ShapesInContextEditor:
+ * - communicates with the ShapesHighlighter actor from the server;
+ * - listens to events for shape change and hover point coming from the shape-highlighter;
+ * - writes shape value changes to the CSS declaration it was triggered from;
+ * - synchronises highlighting coordinate points on mouse over between the shapes
+ *   highlighter and the shape value shown in the Rule view.
+ *
+ * It is instantiated once in HighlightersOverlay by calls to .getInContextEditor().
+ */
+class ShapesInContextEditor {
+  constructor(highlighter, inspector, state) {
+    EventEmitter.decorate(this);
+
+    this.inspector = inspector;
+    this.highlighter = highlighter;
+    // Refence to the NodeFront currently being highlighted.
+    this.highlighterTargetNode = null;
+    this.highligherEventHandlers = {};
+    this.highligherEventHandlers["shape-change"] = this.onShapeChange;
+    this.highligherEventHandlers["shape-hover-on"] = this.onShapeHover;
+    this.highligherEventHandlers["shape-hover-off"] = this.onShapeHover;
+    // Mode for shapes highlighter: shape-outside or clip-path. Used to discern
+    // when toggling the highlighter on the same node for different CSS properties.
+    this.mode = null;
+    // Reference to Rule view used to listen for changes
+    this.ruleView = this.inspector.getPanel("ruleview").view;
+    // Reference of |state| from HighlightersOverlay.
+    this.state = state;
+    // Reference to DOM node of the toggle icon for shapes highlighter.
+    this.swatch = null;
+    // Reference to TextProperty where shape changes will be written.
+    this.textProperty = null;
+
+    // Commit triggers expensive DOM changes in TextPropertyEditor.update()
+    // so we debounce it.
+    this.commit = debounce(this.commit, 200, this);
+    this.onChangesApplied = this.onChangesApplied.bind(this);
+    this.onHighlighterEvent = this.onHighlighterEvent.bind(this);
+    this.onNodeFrontChanged = this.onNodeFrontChanged.bind(this);
+    this.onRuleViewChanged = this.onRuleViewChanged.bind(this);
+
+    this.highlighter.on("highlighter-event", this.onHighlighterEvent);
+    this.ruleView.on("ruleview-changed", this.onRuleViewChanged);
+  }
+
+  /**
+  * Called when the element style changes from the Rule view.
+  * If the TextProperty we're acting on isn't enabled anymore or overridden,
+  * turn off the shapes highlighter.
+  */
+  async onRuleViewChanged() {
+    if (this.textProperty &&
+      (!this.textProperty.enabled || this.textProperty.overridden)) {
+      await this.hide();
+    }
+  }
+
+  /**
+   * Toggle the shapes highlighter for the given element.
+   *
+   * @param {NodeFront} node
+   *        The NodeFront of the element with a shape to highlight.
+   * @param {Object} options
+   *        Object used for passing options to the shapes highlighter.
+   */
+  async toggle(node, options, prop) {
+    // Same target node, same mode -> hide and exit OR switch to toggle transform mode.
+    if ((node == this.highlighterTargetNode) && (this.mode === options.mode)) {
+      if (!options.transformMode) {
+        await this.hide();
+        return;
+      }
+
+      options.transformMode = !this.state.shapes.options.transformMode;
+    }
+
+    // Same target node, dfferent modes -> toggle between shape-outside and clip-path.
+    // Hide highlighter for previous property, but continue and show for other property.
+    if ((node == this.highlighterTargetNode) && (this.mode !== options.mode)) {
+      await this.hide();
+    }
+
+    this.textProperty = prop;
+    this.findSwatch();
+    await this.show(node, options);
+  }
+
+  /**
+   * Show the shapes highlighter for the given element.
+   *
+   * @param {NodeFront} node
+   *        The NodeFront of the element with a shape to highlight.
+   * @param {Object} options
+   *        Object used for passing options to the shapes highlighter.
+   */
+  async show(node, options) {
+    let isShown = await this.highlighter.show(node, options);
+    if (!isShown) {
+      return;
+    }
+
+    this.inspector.selection.on("detached-front", this.onNodeFrontChanged);
+    this.inspector.selection.on("new-node-front", this.onNodeFrontChanged);
+    this.highlighterTargetNode = node;
+    this.mode = options.mode;
+    this.emit("show", { node, options });
+  }
+
+  /**
+   * Hide the shapes highlighter.
+   */
+  async hide() {
+    try {
+      await this.highlighter.hide();
+    } catch (err) {
+      // silent error
+    }
+
+    if (this.swatch) {
+      this.swatch.classList.remove("active");
+    }
+    this.swatch = null;
+    this.textProperty = null;
+
+    this.emit("hide", { node: this.highlighterTargetNode });
+    this.inspector.selection.off("detached-front", this.onNodeFrontChanged);
+    this.inspector.selection.off("new-node-front", this.onNodeFrontChanged);
+    this.highlighterTargetNode = null;
+  }
+
+  /**
+   * Identify the swatch (aka toggle icon) DOM node from the TextPropertyEditor of the
+   * TextProperty we're working with. Whenever the TextPropertyEditor is updated (i.e.
+   * when committing the shape value to the Rule view), it rebuilds its DOM and the old
+   * swatch reference becomes invalid. Call this method to identify the current swatch.
+   */
+  findSwatch() {
+    const valueSpan = this.textProperty.editor.valueSpan;
+    this.swatch = valueSpan.querySelector(".ruleview-shapeswatch");
+    this.swatch.classList.add("active");
+  }
+
+  /**
+   * Handle events emitted by the highlighter.
+   * Find any callback assigned to the event type and call it with the given data object.
+   *
+   * @param {Object} data
+   *        The data object sent in the event.
+   */
+  onHighlighterEvent(data) {
+    const handler = this.highligherEventHandlers[data.type];
+    if (!handler || typeof handler !== "function") {
+      return;
+    }
+    handler.call(this, data);
+    this.inspector.highlighters.emit("highlighter-event-handled");
+  }
+
+  /**
+  * Clean up when node selection changes because Rule view and TextPropertyEditor
+  * instances are not automatically destroyed when selection changes.
+  */
+  async onNodeFrontChanged() {
+    try {
+      await this.hide();
+    } catch (err) {
+      // Silent error.
+    }
+  }
+
+  /**
+  * Handler for "shape-change" event from the shapes highlighter.
+  *
+  * @param  {Object} data
+  *         Data associated with the "shape-change" event.
+  *         Contains:
+  *         - {String} value: the new shape value.
+  *         - {String} type: the event type ("shape-change").
+  */
+  onShapeChange(data) {
+    this.preview(data.value);
+    this.commit(data.value);
+  }
+
+  /**
+  * Handler for "shape-hover-on" and "shape-hover-off" events from the shapes highlighter.
+  * Called when the mouse moves over or off of a coordinate point inside the shapes
+  * highlighter. Marks/unmarks the corresponding coordinate node in the shape value
+  * from the Rule view.
+  *
+  * @param  {Object} data
+  *         Data associated with the "shape-hover" event.
+  *         Contains:
+  *         - {String|null} point: coordinate to highlight or null if nothing to highlight
+  *         - {String} type: the event type ("shape-hover-on" or "shape-hover-on").
+  */
+  onShapeHover(data) {
+    if (!this.textProperty) {
+      return;
+    }
+
+    let shapeValueEl = this.swatch.nextSibling;
+    if (!shapeValueEl) {
+      return;
+    }
+    let pointSelector = ".ruleview-shape-point";
+    // First, unmark all highlighted coordinate nodes from Rule view
+    for (let node of shapeValueEl.querySelectorAll(`${pointSelector}.active`)) {
+      node.classList.remove("active");
+    }
+
+    // Exit if there's no coordinate to highlight.
+    if (typeof data.point !== "string") {
+      return;
+    }
+
+    let point = (data.point.includes(",")) ? data.point.split(",")[0] : data.point;
+
+    /**
+    * Build selector for coordinate nodes in shape value that must be highlighted.
+    * Coordinate values for inset() use class names instead of data attributes because
+    * a single node may represent multiple coordinates in shorthand notation.
+    * Example: inset(50px); The node wrapping 50px represents all four inset coordinates.
+    */
+    const INSET_POINT_TYPES = ["top", "right", "bottom", "left"];
+    let selector = INSET_POINT_TYPES.includes(point) ?
+                  `${pointSelector}.${point}` :
+                  `${pointSelector}[data-point='${point}']`;
+
+    for (let node of shapeValueEl.querySelectorAll(selector)) {
+      node.classList.add("active");
+    }
+  }
+
+  /**
+  * Preview a shape value on the element without committing the changes to the Rule view.
+  *
+  * @param {String} value
+  *        The shape value to set the current property to
+  */
+  preview(value) {
+    if (!this.textProperty) {
+      return;
+    }
+    // Update the element's style to see live results.
+    this.textProperty.rule.previewPropertyValue(this.textProperty, value);
+    // Update the text of CSS value in the Rule view. This makes it inert.
+    // When commit() is called, the value is reparsed and its DOM structure rebuilt.
+    this.swatch.nextSibling.textContent = value;
+  }
+
+  /**
+  * Commit a shape value change which triggers an expensive operation that rebuilds
+  * part of the DOM of the TextPropertyEditor. Called in a debounced manner; see
+  * constructor.
+  *
+  * @param {String} value
+  *        The shape value for the current property
+  */
+  commit(value) {
+    if (!this.textProperty) {
+      return;
+    }
+    this.ruleView.once("ruleview-changed", this.onChangesApplied);
+    this.textProperty.setValue(value);
+  }
+
+  /**
+  * Handler for "ruleview-changed" event triggered by the Rule view.
+  * Called once after the shape value has been written to the element's style and Rule
+  * view updated. Triggers an event on the HighlightersOverlay that is expected by
+  * tests in order to check if the shape value has been correctly applied.
+  */
+  onChangesApplied() {
+    // When TextPropertyEditor updates it thrashes the previous swatch DOM node. Find and
+    // store the new swatch node.
+    this.findSwatch();
+    this.inspector.highlighters.emit("shapes-highlighter-changes-applied");
+  }
+
+  destroy() {
+    this.highlighter.off("highlighter-event", this.onHighlighterEvent);
+    this.ruleView.off("ruleview-changed", this.onRuleViewChanged);
+    this.highligherEventHandlers = {};
+  }
+}
+
+module.exports = ShapesInContextEditor;
--- a/devtools/client/shared/widgets/moz.build
+++ b/devtools/client/shared/widgets/moz.build
@@ -17,16 +17,17 @@ DevToolsModules(
     'CubicBezierWidget.js',
     'FastListWidget.js',
     'FilterWidget.js',
     'FlameGraph.js',
     'Graphs.js',
     'GraphsWorker.js',
     'LineGraphWidget.js',
     'MountainGraphWidget.js',
+    'ShapesInContextEditor.js',
     'SideMenuWidget.jsm',
     'SimpleListWidget.jsm',
     'Spectrum.js',
     'TableWidget.js',
     'TreeWidget.js',
     'VariablesView.jsm',
     'VariablesViewController.jsm',
     'view-helpers.js',
--- a/devtools/client/themes/rules.css
+++ b/devtools/client/themes/rules.css
@@ -412,17 +412,18 @@
   background-size: 1em;
 }
 
 .ruleview-grid {
   background: url("chrome://devtools/skin/images/grid.svg");
   border-radius: 0;
 }
 
-.ruleview-shape-point.active {
+.ruleview-shape-point.active,
+.ruleview-shapeswatch.active + .ruleview-shape > .ruleview-shape-point:hover {
   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);
--- a/devtools/server/actors/highlighters/shapes.js
+++ b/devtools/server/actors/highlighters/shapes.js
@@ -838,17 +838,17 @@ class ShapesHighlighter extends AutoRefr
       let precisionY = getDecimalPrecision(unitY);
       newX = (newX * ratioX).toFixed(precisionX);
       newY = (newY * ratioY).toFixed(precisionY);
 
       return `${newX}${unitX} ${newY}${unitY}`;
     }).join(", ");
     polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim();
 
-    this.currentNode.style.setProperty(this.property, polygonDef, "important");
+    this.emit("highlighter-event", { type: "shape-change", value: polygonDef });
   }
 
   /**
    * Transform a circle depending on the current transformation matrix.
    * @param {Number} transX the number of pixels the shape is translated on the x axis
    *                 before scaling
    */
   _transformCircle(transX = null) {
@@ -917,17 +917,17 @@ class ShapesHighlighter extends AutoRefr
     newBottom = `${(height - newBottom) * bottom.ratio}${bottom.unit}`;
 
     let round = this.insetRound;
     let insetDef = (round) ?
           `inset(${newTop} ${newRight} ${newBottom} ${newLeft} round ${round})` :
           `inset(${newTop} ${newRight} ${newBottom} ${newLeft})`;
     insetDef += (this.geometryBox) ? this.geometryBox : "";
 
-    this.currentNode.style.setProperty(this.property, insetDef, "important");
+    this.emit("highlighter-event", { type: "shape-change", value: insetDef });
   }
 
   /**
    * Handle a click when highlighting a polygon.
    * @param {Number} pageX the x coordinate of the click
    * @param {Number} pageY the y coordinate of the click
    */
   _handlePolygonClick(pageX, pageY) {
@@ -950,18 +950,18 @@ class ShapesHighlighter extends AutoRefr
     let ratioY = (valueY / yComputed) || 1;
 
     this.setCursor("grabbing");
     this[_dragging] = { point, unitX, unitY, valueX, valueY,
                         ratioX, ratioY, x: pageX, y: pageY };
   }
 
   /**
-   * Set the inline style of the polygon, replacing the given point with the given x/y
-   * coords.
+   * Update the dragged polygon point with the given x/y coords and update
+   * the element style.
    * @param {Number} pageX the new x coordinate of the point
    * @param {Number} pageY the new y coordinate of the point
    */
   _handlePolygonMove(pageX, pageY) {
     let { point, unitX, unitY, valueX, valueY, ratioX, ratioY, x, y } = this[_dragging];
     let deltaX = (pageX - x) * ratioX;
     let deltaY = (pageY - y) * ratioY;
     let precisionX = getDecimalPrecision(unitX);
@@ -971,21 +971,21 @@ class ShapesHighlighter extends AutoRefr
 
     let polygonDef = (this.fillRule) ? `${this.fillRule}, ` : "";
     polygonDef += this.coordUnits.map((coords, i) => {
       return (i === point) ?
         `${newX}${unitX} ${newY}${unitY}` : `${coords[0]} ${coords[1]}`;
     }).join(", ");
     polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim();
 
-    this.currentNode.style.setProperty(this.property, polygonDef, "important");
+    this.emit("highlighter-event", { type: "shape-change", value: polygonDef });
   }
 
   /**
-   * Set the inline style of the polygon, adding a new point.
+   * Add new point to the polygon defintion and update element style.
    * TODO: Bug 1436054 - Do not default to percentage unit when inserting new point.
    * https://bugzilla.mozilla.org/show_bug.cgi?id=1436054
    *
    * @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) {
@@ -993,35 +993,35 @@ class ShapesHighlighter extends AutoRefr
     polygonDef += this.coordUnits.map((coords, i) => {
       return (i === after) ? `${coords[0]} ${coords[1]}, ${x}% ${y}%` :
                              `${coords[0]} ${coords[1]}`;
     }).join(", ");
     polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim();
 
     this.hoveredPoint = after + 1;
     this._emitHoverEvent(this.hoveredPoint);
-    this.currentNode.style.setProperty(this.property, polygonDef, "important");
+    this.emit("highlighter-event", { type: "shape-change", value: polygonDef });
   }
 
   /**
-   * Set the inline style of the polygon, deleting the given point.
+   * Remove point from polygon defintion and update the element style.
    * @param {Number} point the index of the point to delete
    */
   _deletePolygonPoint(point) {
     let coordinates = this.coordUnits.slice();
     coordinates.splice(point, 1);
     let polygonDef = (this.fillRule) ? `${this.fillRule}, ` : "";
     polygonDef += coordinates.map((coords, i) => {
       return `${coords[0]} ${coords[1]}`;
     }).join(", ");
     polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim();
 
     this.hoveredPoint = null;
     this._emitHoverEvent(this.hoveredPoint);
-    this.currentNode.style.setProperty(this.property, polygonDef, "important");
+    this.emit("highlighter-event", { type: "shape-change", value: polygonDef });
   }
   /**
    * Handle a click when highlighting a circle.
    * @param {Number} pageX the x coordinate of the click
    * @param {Number} pageY the y coordinate of the click
    */
   _handleCircleClick(pageX, pageY) {
     let { width, height } = this.currentDimensions;
@@ -1055,18 +1055,18 @@ class ShapesHighlighter extends AutoRefr
       value = (isUnitless(value)) ? radius : parseFloat(value);
       let ratio = (value / radius) || 1;
 
       this[_dragging] = { point, value, origRadius: radius, unit, ratio };
     }
   }
 
   /**
-   * Set the inline style of the circle, setting the center/radius according to the
-   * mouse position.
+   * Set the center/radius of the circle according to the mouse position and
+   * update the element style.
    * @param {String} point either "center" or "radius"
    * @param {Number} pageX the x coordinate of the mouse position, in terms of %
    *        relative to the element
    * @param {Number} pageY the y coordinate of the mouse position, in terms of %
    *        relative to the element
    */
   _handleCircleMove(point, pageX, pageY) {
     let { radius, cx, cy } = this.coordUnits;
@@ -1075,30 +1075,30 @@ class ShapesHighlighter extends AutoRefr
       let { unitX, unitY, valueX, valueY, ratioX, ratioY, x, y} = this[_dragging];
       let deltaX = (pageX - x) * ratioX;
       let deltaY = (pageY - y) * ratioY;
       let newCx = `${valueX + deltaX}${unitX}`;
       let newCy = `${valueY + deltaY}${unitY}`;
       // if not defined by the user, geometryBox will be an empty string; trim() cleans up
       let circleDef = `circle(${radius} at ${newCx} ${newCy}) ${this.geometryBox}`.trim();
 
-      this.currentNode.style.setProperty(this.property, circleDef, "important");
+      this.emit("highlighter-event", { type: "shape-change", value: circleDef });
     } else if (point === "radius") {
       let { value, unit, origRadius, ratio } = this[_dragging];
       // convert center point to px, then get distance between center and mouse.
       let { x: pageCx, y: pageCy } = this.convertPercentToPageCoords(this.coordinates.cx,
                                                                      this.coordinates.cy);
       let newRadiusPx = getDistance(pageCx, pageCy, pageX, pageY);
 
       let delta = (newRadiusPx - origRadius) * ratio;
       let newRadius = `${value + delta}${unit}`;
 
       let circleDef = `circle(${newRadius} at ${cx} ${cy}) ${this.geometryBox}`.trim();
 
-      this.currentNode.style.setProperty(this.property, circleDef, "important");
+      this.emit("highlighter-event", { type: "shape-change", value: circleDef });
     }
   }
 
   /**
    * Handle a click when highlighting an ellipse.
    * @param {Number} pageX the x coordinate of the click
    * @param {Number} pageY the y coordinate of the click
    */
@@ -1142,18 +1142,18 @@ class ShapesHighlighter extends AutoRefr
       value = (isUnitless(value)) ? ry : parseFloat(value);
       let ratio = (value / ry) || 1;
 
       this[_dragging] = { point, value, origRadius: ry, unit, ratio };
     }
   }
 
   /**
-   * Set the inline style of the ellipse, setting the center/rx/ry according to the
-   * mouse position.
+   * Set center/rx/ry of the ellispe according to the mouse position and update the
+   * element style.
    * @param {String} point "center", "rx", or "ry"
    * @param {Number} pageX the x coordinate of the mouse position, in terms of %
    *        relative to the element
    * @param {Number} pageY the y coordinate of the mouse position, in terms of %
    *        relative to the element
    */
   _handleEllipseMove(point, pageX, pageY) {
     let { percentX, percentY } = this.convertPageCoordsToPercent(pageX, pageY);
@@ -1163,39 +1163,39 @@ class ShapesHighlighter extends AutoRefr
       let { unitX, unitY, valueX, valueY, ratioX, ratioY, x, y} = this[_dragging];
       let deltaX = (pageX - x) * ratioX;
       let deltaY = (pageY - y) * ratioY;
       let newCx = `${valueX + deltaX}${unitX}`;
       let newCy = `${valueY + deltaY}${unitY}`;
       let ellipseDef =
         `ellipse(${rx} ${ry} at ${newCx} ${newCy}) ${this.geometryBox}`.trim();
 
-      this.currentNode.style.setProperty(this.property, ellipseDef, "important");
+      this.emit("highlighter-event", { type: "shape-change", value: ellipseDef });
     } else if (point === "rx") {
       let { value, unit, origRadius, ratio } = this[_dragging];
       let newRadiusPercent = Math.abs(percentX - this.coordinates.cx);
       let { width } = this.currentDimensions;
       let delta = ((newRadiusPercent / 100 * width) - origRadius) * ratio;
       let newRadius = `${value + delta}${unit}`;
 
       let ellipseDef =
         `ellipse(${newRadius} ${ry} at ${cx} ${cy}) ${this.geometryBox}`.trim();
 
-      this.currentNode.style.setProperty(this.property, ellipseDef, "important");
+      this.emit("highlighter-event", { type: "shape-change", value: ellipseDef });
     } else if (point === "ry") {
       let { value, unit, origRadius, ratio } = this[_dragging];
       let newRadiusPercent = Math.abs(percentY - this.coordinates.cy);
       let { height } = this.currentDimensions;
       let delta = ((newRadiusPercent / 100 * height) - origRadius) * ratio;
       let newRadius = `${value + delta}${unit}`;
 
       let ellipseDef =
         `ellipse(${rx} ${newRadius} at ${cx} ${cy}) ${this.geometryBox}`.trim();
 
-      this.currentNode.style.setProperty(this.property, ellipseDef, "important");
+      this.emit("highlighter-event", { type: "shape-change", value: ellipseDef });
     }
   }
 
   /**
    * Handle a click when highlighting an inset.
    * @param {Number} pageX the x coordinate of the click
    * @param {Number} pageY the y coordinate of the click
    */
@@ -1215,18 +1215,18 @@ class ShapesHighlighter extends AutoRefr
     value = (isUnitless(value)) ? computedValue : parseFloat(value);
     let ratio = (value / computedValue) || 1;
     let origValue = (point === "left" || point === "right") ? pageX : pageY;
 
     this[_dragging] = { point, value, origValue, unit, ratio };
   }
 
   /**
-   * Set the inline style of the inset, setting top/left/right/bottom according to the
-   * mouse position.
+   * Set the top/left/right/bottom of the inset shape according to the mouse position
+   * and update the element style.
    * @param {String} point "top", "left", "right", or "bottom"
    * @param {Number} pageX the x coordinate of the mouse position, in terms of %
    *        relative to the element
    * @param {Number} pageY the y coordinate of the mouse position, in terms of %
    *        relative to the element
    * @memberof ShapesHighlighter
    */
   _handleInsetMove(point, pageX, pageY) {
@@ -1248,17 +1248,17 @@ class ShapesHighlighter extends AutoRefr
       bottom = `${value - delta}${unit}`;
     }
     let insetDef = (round) ?
       `inset(${top} ${right} ${bottom} ${left} round ${round})` :
       `inset(${top} ${right} ${bottom} ${left})`;
 
     insetDef += (this.geometryBox) ? this.geometryBox : "";
 
-    this.currentNode.style.setProperty(this.property, insetDef, "important");
+    this.emit("highlighter-event", { type: "shape-change", value: insetDef });
   }
 
   _handleMouseMoveNotDragging(pageX, pageY) {
     let { percentX, percentY } = this.convertPageCoordsToPercent(pageX, pageY);
     if (this.transformMode) {
       let point = this.getTransformPointAt(percentX, percentY);
       this.hoveredPoint = point;
       this._handleMarkerHover(point);