Bug 1435373 - Shapes editor: implementation to map shape value changes to rule. r=pbro
authorRazvan Caliman <rcaliman@mozilla.com>
Tue, 10 Apr 2018 13:59:00 +0200
changeset 412586 bc52ed730824a5cdc45169d27e187a7e90fbac72
parent 412585 4dac79a5a4a5a4c97f8ef176793b3cb811ea204b
child 412587 b9f53df7f40072fc40126914778d82ab4a6c4b3a
push id33809
push userrgurzau@mozilla.com
push dateTue, 10 Apr 2018 16:54:59 +0000
treeherdermozilla-central@0a2dae2d8cf9 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspbro
bugs1435373
milestone61.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 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);