Bug 1435373 - Update shape value to corresponding CSS declaration in Rule view draft
authorRazvan Caliman <rcaliman@mozilla.com>
Mon, 26 Feb 2018 11:14:14 +0100
changeset 760452 a30d0af166a222d0a07b8cd956436873348ddef5
parent 760451 aeb63c00ea0e0ffe08cfefd3927378e3730850ae
push id100648
push userbmo:rcaliman@mozilla.com
push dateTue, 27 Feb 2018 17:08:46 +0000
bugs1435373
milestone60.0a1
Bug 1435373 - Update shape value to corresponding CSS declaration in Rule view - link TextProperty and swatch to in-context shape editor singleton - refactor and internalize most shape point hover logic - disable shape editor restore on refresh MozReview-Commit-ID: 61kz3RHomGm
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/themes/rules.css
--- a/devtools/client/inspector/rules/rules.js
+++ b/devtools/client/inspector/rules/rules.js
@@ -20,17 +20,16 @@ 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_SWATCH_TYPE,
   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/old-event-emitter");
@@ -348,27 +347,16 @@ 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-swatch") && prop) {
-      type = VIEW_NODE_SWATCH_TYPE;
-      value = {
-        enabled: prop.enabled,
-        overridden: prop.overridden,
-        property: getPropertyNameAndValue(node).name,
-        rule: prop.rule,
-        sheetHref: prop.rule.domRule.href,
-        textProperty: prop,
-        value: node.textContent
-      };
     } 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 = [
@@ -89,32 +88,32 @@ function TextPropertyEditor(ruleEditor, 
   this._onExpandClicked = this._onExpandClicked.bind(this);
   this._onStartEditing = this._onStartEditing.bind(this);
   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._onInContextEditorCommit = this._onInContextEditorCommit.bind(this);
+  this._onInContextEditorPreview = this._onInContextEditorPreview.bind(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.
    */
   get editing() {
     return !!(this.nameSpan.inplaceEditor || this.valueSpan.inplaceEditor ||
-      this.ruleView.tooltips.isEditing || this.ruleView.highlighters.isEditing)
-      || this.popup.isOpen;
+      this.ruleView.tooltips.isEditing) || this.popup.isOpen;
   },
 
   /**
    * Get the rule to the current text property
    */
   get rule() {
     return this.prop.rule;
   },
@@ -315,18 +314,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.
@@ -505,38 +502,34 @@ TextPropertyEditor.prototype = {
     if (gridToggle) {
       gridToggle.setAttribute("title", l10n("rule.gridToggle.tooltip"));
       if (this.ruleView.highlighters.gridHighlighterShown ===
           this.ruleView.inspector.selection.nodeFront) {
         gridToggle.classList.add("active");
       }
     }
 
-    this.shapeToggle = this.valueSpan.querySelector(".ruleview-shapeswatch");
-    if (this.shapeToggle) {
+    let shapeToggle = this.valueSpan.querySelector(".ruleview-shapeswatch");
+    if (shapeToggle) {
       let mode = "css" + name.split("-").map(s => {
         return s[0].toUpperCase() + s.slice(1);
       }).join("");
-      this.shapeToggle.setAttribute("data-mode", mode);
+      shapeToggle.setAttribute("data-mode", mode);
 
       let shapesEditor =
         await this.ruleView.highlighters.getInContextEditor("shapesEditor");
 
       let callbacks = {
-        onShow: this._onStartEditing,
-        onPreview: this._onSwatchPreview,
-        onCommit: this._onSwatchCommit,
-        onRevert: this._onSwatchRevert
+        onPreview: this._onInContextEditorPreview,
+        onCommit: this._onInContextEditorCommit
       };
 
-      if (shapesEditor.activeSwatch) {
-        shapesEditor.replaceSwatch(shapesEditor.activeSwatch, this.shapeToggle, callbacks);
-      } else {
-        shapesEditor.addSwatch(this.shapeToggle, callbacks);
-      }
+      shapesEditor.link(this.prop, shapeToggle, callbacks);
+      // Mark this toggle if this property is being currently edited; unmark otherwise.
+      shapeToggle.classList.toggle("active", shapesEditor.activeProperty === this.prop);
     }
 
     // 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;
@@ -831,39 +824,30 @@ TextPropertyEditor.prototype = {
   /**
    * Remove property from style and the editors from DOM.
    * Begin editing next or previous available property given the focus
    * direction.
    *
    * @param {Number} direction
    *        The move focus direction number.
    */
-  remove: async function (direction) {
+  remove: function (direction) {
     if (this._colorSwatchSpans && this._colorSwatchSpans.length) {
       for (let span of this._colorSwatchSpans) {
         this.ruleView.tooltips.getTooltip("colorPicker").removeSwatch(span);
         span.off("unit-change", this._onSwatchCommit);
       }
     }
 
     if (this.angleSwatchSpans && this.angleSwatchSpans.length) {
       for (let span of this.angleSwatchSpans) {
         span.off("unit-change", this._onSwatchCommit);
       }
     }
 
-    if (this.shapeToggle) {
-      let shapesEditor =
-       await this.ruleView.highlighters.getInContextEditor("shapesEditor");
-      if (shapesEditor) {
-        shapesEditor.removeSwatch(this.shapeToggle);
-      }
-      // shapesEditor.off("hover-shape-point")
-    }
-
     this.element.remove();
     this.ruleEditor.rule.editClosestTextProperty(this.prop, direction);
     this.nameSpan.textProperty = null;
     this.valueSpan.textProperty = null;
     this.prop.remove();
   },
 
   /**
@@ -1049,70 +1033,29 @@ TextPropertyEditor.prototype = {
    * @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.
+   * Live preview this property, without committing changes.
    *
-   * @param {Event} event
-   *        The "hover-shape-point" event.
-   * @param {String} point
-   *        The point to highlight.
+   * @param {String} value
+   *        The value to set the current property to.
    */
-  _onHoverShapePoint: function (event, 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);
-        }
-      }
-    }
+  _onInContextEditorPreview(value) {
+    this.ruleEditor.rule.previewPropertyValue(this.prop, value);
   },
 
   /**
-   * Toggle the class "active" on the given shape point in the rule view if the current
-   * inspector selection is highlighted by the shapes highlighter.
+   * Commit this property value. Triggers editor update.
    *
-   * @param {NodeFront} node
-   *        The NodeFront of the shape point to toggle
-   * @param {Boolean} active
-   *        Whether the shape point should be active
+   * @param {String} value
+   *        The value to set the current property to.
    */
-  _toggleShapePointActive: function (node, active) {
-    let { highlighters } = this.ruleView;
-    if (highlighters.inspector.selection.nodeFront !=
-        highlighters.shapesHighlighterShown) {
-      return;
-    }
-
-    node.classList.toggle("active", active);
-  },
+  _onInContextEditorCommit(value) {
+    this.prop.setValue(value);
+  }
 };
 
 module.exports = TextPropertyEditor;
--- a/devtools/client/inspector/shared/highlighters-overlay.js
+++ b/devtools/client/inspector/shared/highlighters-overlay.js
@@ -49,50 +49,35 @@ class HighlightersOverlay {
       grid: {},
       shapes: {},
     };
 
     this.onClick = this.onClick.bind(this);
     this.onMarkupMutation = this.onMarkupMutation.bind(this);
     this.onMouseMove = this.onMouseMove.bind(this);
     this.onMouseOut = this.onMouseOut.bind(this);
-    this.onShapeHover = this.onShapeHover.bind(this);
     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);
 
-    // Store for callbacks for events emitted by highlighters
-    this.highligherEventHandlers = {};
     EventEmitter.decorate(this);
   }
 
   /**
-  * Cycle though all registered in-context editors and return true as soon as _any one_
-  * of them is found to be in editing mode. Called by TextPropertyEditor.editing
-  */
-  get isEditing() {
-    return Object.keys(this.editors).some((key) => {
-      let editor = this.editors[key];
-      return (typeof (editor.isEditing) === "function" && editor.isEditing());
-    });
-  }
-
-  /**
    * Returns whether `node` is somewhere inside the DOM of the rule view.
    *
    * @param {DOMNode} node
    * @return {Boolean}
    */
   isRuleView(node) {
     return !!node.closest("#ruleview-panel");
   }
@@ -109,18 +94,16 @@ class HighlightersOverlay {
       return;
     }
 
     let el = view.element;
     el.addEventListener("click", this.onClick, true);
     el.addEventListener("mousemove", this.onMouseMove);
     el.addEventListener("mouseout", this.onMouseOut);
     el.ownerDocument.defaultView.addEventListener("mouseout", this.onMouseOut);
-    this.highligherEventHandlers["shape-hover-on"] = this.onShapeHover;
-    this.highligherEventHandlers["shape-hover-off"] = this.onShapeHover;
   }
 
   /**
    * Remove the overlay from the given view. This will stop tracking mouse movement and
    * showing highlighters.
    *
    * @param  {CssRuleView|CssComputedView|LayoutView} view
    *         Either the rule-view or computed-view panel to remove the highlighters
@@ -130,17 +113,16 @@ class HighlightersOverlay {
     if (!this.supportsHighlighters) {
       return;
     }
 
     let el = view.element;
     el.removeEventListener("click", this.onClick, true);
     el.removeEventListener("mousemove", this.onMouseMove);
     el.removeEventListener("mouseout", this.onMouseOut);
-    this.highligherEventHandlers = {};
   }
 
   /**
    * Show the shapes highlighter for the given element with a shape.
    * This method delegates to the in-context shapes editor which wraps the
    * shapes highlighter with additional functionality required for passing changes
    * back to the rule view.
    *
@@ -180,42 +162,37 @@ class HighlightersOverlay {
       let selector = await node.getUniqueSelector();
       this.state.shapes = { selector, options, url };
     } catch (e) {
       this._handleRejection(e);
     }
   }
 
   /**
-   * Hide the shapes highlighter for the given element.
+   * Hide the shapes highlighter if visible.
    * This method delegates the to the in-context shapes editor which wraps
    * the shapes highlighter with additional functionality.
-   *
-   * @param  {NodeFront} node
-   *         The NodeFront of the element with a shape to unhighlight.
    */
-  async hideShapesHighlighter(node) {
+  async hideShapesHighlighter() {
     let shapesEditor = await this.getInContextEditor("shapesEditor");
     if (!shapesEditor) {
       return;
     }
-    shapesEditor.hide(node);
+    shapesEditor.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) {
-    let { node } = data;
-    this._toggleRuleViewIcon(node, false, ".ruleview-shapeswatch");
     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.
@@ -444,33 +421,16 @@ class HighlightersOverlay {
 
     await this.highlighters.GeometryEditorHighlighter.hide();
 
     this.emit("geometry-editor-highlighter-hidden");
     this.geometryEditorHighlighterShown = null;
   }
 
   /**
-   * 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.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);
     }
@@ -486,21 +446,21 @@ class HighlightersOverlay {
       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);
-    }
+    // 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
    * and their state.
    *
    * @param  {String} name
@@ -547,17 +507,17 @@ class HighlightersOverlay {
     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);
+        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;
@@ -586,49 +546,27 @@ 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);
     }
   }
 
   /**
-   * Handler for the "shape-change" event of the ShapesHighlighter.
-   * Preivews the new shape live on the page as events come in and persists the shape
-   * value to the Rule view in a throttled manner because this is an expensive operation.
-   *
-   * @param  {Object} data
-   *         Data associated with the "shape-change" event.
-   *         Contains:
-   *         - {String} value: the new shape value
-   *         - {String} type: the event type ("shape-change")
-   */
-  onShapeHover(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);
-    }
-  }
-
-  /**
    * Toggle all the icons with the given selector in the rule view if the current
    * inspector selection is the highlighted node.
    *
    * @param  {NodeFront} node
    *         The NodeFront of the element with a shape to highlight.
    * @param  {Boolean} active
    *         Whether or not the shape icon should be active.
    * @param  {String} selector
@@ -832,17 +770,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";
@@ -870,17 +807,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.
@@ -928,19 +864,16 @@ class HighlightersOverlay {
    * Destroy this overlay instance, removing it from the view and destroying
    * all initialized highlighters.
    */
   destroy() {
     this.destroyEditors();
 
     for (let type in this.highlighters) {
       if (this.highlighters[type]) {
-        if (this.highlighters[type].off) {
-          this.highlighters[type].off("highlighter-event", this._onHighlighterEvent);
-        }
         this.highlighters[type].finalize();
         this.highlighters[type] = null;
       }
     }
 
     // Remove inspector events.
     this.inspector.off("markupmutation", this.onMarkupMutation);
     this.inspector.target.off("will-navigate", this.onWillNavigate);
--- a/devtools/client/inspector/shared/node-types.js
+++ b/devtools/client/inspector/shared/node-types.js
@@ -13,9 +13,8 @@
 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_SWATCH_TYPE = 9;
--- a/devtools/client/shared/widgets/ShapesInContextEditor.js
+++ b/devtools/client/shared/widgets/ShapesInContextEditor.js
@@ -3,262 +3,300 @@
  * 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");
 
 class ShapesInContextEditor {
-  constructor(highlighter, inspector) {
+  constructor(highlighter, inspector, state) {
     EventEmitter.decorate(this);
 
-    this._isEditing = false;
     this._highligherEventHandlers = {};
     this._highligherEventHandlers["shape-change"] = this._onShapeChange;
     this._highligherEventHandlers["shape-hover-on"] = this._onShapeHover;
     this._highligherEventHandlers["shape-hover-off"] = this._onShapeHover;
     this._onHighlighterEvent = this._onHighlighterEvent.bind(this);
+    this._onNodeFrontChanged = this._onNodeFrontChanged.bind(this);
+    this._onRuleViewChanged = this._onRuleViewChanged.bind(this);
     this._onSwatchClick = this._onSwatchClick.bind(this);
-    this._onNodeFrontChanged = this._onNodeFrontChanged.bind(this);
 
-    // When a swatch is clicked, and for as long as the highlighter is shown,
-    // activeSwatch will hold the reference to the swatch DOM element
-    // that was clicked. It is a key in the this.swatches Map which holds
-    // callbacks that map changes back to the original CSS declaration.
     this.activeSwatch = null;
-    // Commit causes expensive changes in Rule view so we debounce it.
-    this.commit = debounce(this.commit, 150, this);
+    this.activeProperty = null;
+
+    // Commit triggers expensive changes so we debounce it.
+    this.commit = debounce(this.commit, 200, this);
     this.inspector = inspector;
     this.highlighter = highlighter;
     this.highlighter.on("highlighter-event", this._onHighlighterEvent);
 
     // Refence to the NodeFront currently being highlighted.
     this.highlighterTargetNode = null;
 
-    // All target swatches are kept in a map, indexed by swatch DOM elements
-    this.swatches = new Map();
+    // Map with data required to preview and persist shape value changes to the Rule view.
+    // keys - TextProperty instances relevant for shape editor (clip-path, shape-outside).
+    // values - objects with references to swatch elements that trigger the shape editor
+    //          and callbacks used to preview and persist shape value changes.
+    this.links = new Map();
+
+    // Reference to Rule view used to listen for changes
+    this.ruleView = this.inspector.getPanel("ruleview").view;
+    this.ruleView.on("ruleview-changed", this._onRuleViewChanged);
+
+    // Reference of |state| from HighlightersOverlay.
+    this.state = state;
   }
 
-  /**
-   * Add a new swatch DOM element to the list of swatch elements this editor
-   * knows about. That means from now on, clicking on that swatch will
-   * toggle the editor.
-   *
-   * @param {node} swatchEl
-   *        The element to add
-   * @param {object} callbacks
-   *        Callbacks that will be executed when the editor wants to preview a
-   *        value change, or revert a change, or commit a change.
-   *        - onShow: called when the highlighter is shown.
-   *        - onPreview: called when there is a shape change event from the highlighter.
-   *        - onRevert:
-   *        - onCommit: called when the highlighter is closed.
-   */
-  addSwatch(swatchEl, callbacks = {}) {
-    if (!callbacks.onShow) {
-      callbacks.onShow = function () {};
+  link(prop, swatch, callbacks = {}) {
+    if (this.links.has(prop)) {
+      this.replaceSwatch(prop, swatch);
+      return;
     }
     if (!callbacks.onPreview) {
       callbacks.onPreview = function () {};
     }
-    if (!callbacks.onRevert) {
-      callbacks.onRevert = function () {};
-    }
     if (!callbacks.onCommit) {
       callbacks.onCommit = function () {};
     }
 
-    this.swatches.set(swatchEl, { callbacks });
-    swatchEl.addEventListener("click", this._onSwatchClick);
+    swatch.addEventListener("click", this._onSwatchClick);
+    this.links.set(prop, { swatch, callbacks });
   }
 
-  removeSwatch(swatchEl) {
-    if (this.swatches.has(swatchEl)) {
-      if (this.activeSwatch === swatchEl) {
-        this.hide();
-        this.activeSwatch = null;
-      }
-      swatchEl.removeEventListener("click", this._onSwatchClick);
-      this.swatches.delete(swatchEl);
+  unlink(prop) {
+    let data = this.links.get(prop);
+    if (!data || !data.swatch) {
+      return;
+    }
+    if (this.activeProperty === prop) {
+      this.hide();
+    }
+
+    data.swatch.classList.remove("active");
+    data.swatch.removeEventListener("click", this._onSwatchClick);
+    this.links.delete(prop);
+  }
+
+  replaceSwatch(prop, swatch) {
+    let data = this.links.get(prop);
+    if (data.swatch) {
+      // Cleanup old
+      data.swatch.removeEventListener("click", this._onSwatchClick);
+      data.swatch = undefined;
+      // Setup new
+      swatch.addEventListener("click", this._onSwatchClick);
+      data.swatch = swatch;
     }
   }
 
-  replaceSwatch(oldSwatchEl, newSwatchEl, callbacks = {}) {
-    if (!oldSwatchEl || !newSwatchEl) {
-      return;
+  /**
+  * 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.
+  */
+  _onRuleViewChanged() {
+    if (this.activeProperty &&
+      (!this.activeProperty.enabled || this.activeProperty.overridden)) {
+      this.hide();
     }
+  }
 
-    this.addSwatch(newSwatchEl, callbacks);
-
-    // Replace old swatch with new one if it was active
-    if (this.activeSwatch === oldSwatchEl) {
-      this.activeSwatch = newSwatchEl;
-      this.activeSwatch.classList.add("active");
+  _onSwatchClick(event) {
+    event.stopPropagation();
+    for (let [prop, data] of this.links) {
+      if (data.swatch == event.target) {
+        this.activeSwatch = data.swatch;
+        this.activeSwatch.classList.add("active");
+        this.activeProperty = prop;
+      }
     }
 
-    oldSwatchEl.removeEventListener("click", this._onSwatchClick);
-    this.swatches.delete(oldSwatchEl);
-  }
+    let nodeFront = this.inspector.selection.nodeFront;
+    let options =  {
+      mode: event.target.dataset.mode,
+      transformMode: event.metaKey || event.ctrlKey
+    };
 
-  async _onSwatchClick(event) {
-    event.stopPropagation();
-    let swatch = this.swatches.get(event.target);
-    if (swatch) {
-      this.activeSwatch = event.target;
-
-      let nodeFront = this.inspector.selection.nodeFront;
-      let options =  {
-        mode: event.target.dataset.mode,
-        transformMode: event.metaKey || event.ctrlKey
-      };
-
-      await this.toggle(nodeFront, options);
-    }
+    this.toggle(nodeFront, options);
   }
 
   /**
    * 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.
+   * @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) {
     if (node == this.highlighterTargetNode) {
       if (!options.transformMode) {
-        await this.hide(node);
+        await this.hide();
         return;
       }
 
-      options.transformMode =
-       !this.inspector.highlighters.state.shapes.options.transformMode;
+      options.transformMode = !this.state.shapes.options.transformMode;
     }
 
     await this.show(node, options);
   }
 
   async show(node, options) {
     let isShown = await this.highlighter.show(node, options);
     if (!isShown) {
       return;
     }
 
-    // When the shapes highlighter is shown as a result of restore operation on page
-    // refresh, it means editing didn't originate in the Rule view. We must identify
-    // the swatch associated with it so it updates values in the right CSS declaration.
-    if (!this.activeSwatch) {
-      this.activeSwatch = this.findActiveSwatch(`.ruleview-shapeswatch`);
-    }
-
-    let swatch = this.swatches.get(this.activeSwatch);
-    if (swatch) {
-      swatch.callbacks.onShow();
-    }
-
     this.inspector.selection.on("detached-front", this._onNodeFrontChanged);
     this.inspector.selection.on("new-node-front", this._onNodeFrontChanged);
-    this.activeSwatch.classList.add("active");
     this.highlighterTargetNode = node;
     this.emit("show", { node, options });
   }
 
-  async hide(node) {
+  async hide() {
     await this.highlighter.hide();
+
     this.activeSwatch.classList.remove("active");
     this.activeSwatch = null;
+    this.activeProperty = 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;
   }
 
-  findActiveSwatch(selector) {
-    for (let [swatch] of this.swatches) {
-      if (swatch.matches(selector)) {
-        return swatch;
-      }
-    }
-
-    return null;
-  }
-
-  isEditing() {
-    return this._isEditing;
-  }
-
+  /**
+   * 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.
+  * Clean up when node selection changes because Rule view and TextPropertyEditor
+  * instances are not automatically destroyed when selection changes.
   */
   _onNodeFrontChanged() {
     this.hide();
-    this.swatches.clear();
+    this.links.clear();
   }
 
+  /**
+  * Called when there's an updated shape value from the 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);
-  }
-
-  _onShapeHover(data) {
+    this.commit(data.value);
   }
 
-  preview(value) {
-    if (this.activeSwatch) {
-      // Update the text of CSS value in the rule view. This makes it inert.
-      // When .commit() is called, the value is parsed and its the DOM structure rebuilt.
-      this.activeSwatch.nextSibling.textContent = value;
+  /**
+  * Called when the mouse moves over or away from a coordinate point inside the shape
+  * highlighter.
+  * Highlights 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.activeProperty) {
+      console.warn("SHAPEHOVER: no active property");
+      return;
+    }
 
-      /**
-      * TextPropertyEditor._previewValue() pings all subscribed editors
-      * (mostly tooltips and inline editors) to check if they are in
-      * "editing on" mode when allowing to preview the value.
-      *
-      * Here we're toggling the perceived "editing on" state of the shape
-      * highlighter just for preview. Once shown, the shapes highlighter is,
-      * technically, always in "editing on" state, but that messes with logic
-      * in TextPropertyEditor which expects all editors to be off in order to
-      * correctly handle behavirous like disabling new properties that overwrite
-      * existing properties.
-      */
-      this._isEditing = true;
-      let swatch = this.swatches.get(this.activeSwatch);
-      swatch.callbacks.onPreview(value);
-      this.commit();
-      // Pretend not to be in editing mode anymore.
-      this._isEditing = false;
+    let shapeValueEl = this.links.get(this.activeProperty).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. For example, if it was just a mouseout.
+    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");
     }
   }
 
-  revert() {
+  /**
+  * Preview this shape value on the element but do commit the changes to the Rule view.
+  *
+  * @param {String} value
+  *        The shape value to set the current property to
+  */
+  preview(value) {
+    if (!this.activeProperty) {
+      console.warn("PREVIEW: no active property");
+      return;
+    }
+    let data = this.links.get(this.activeProperty);
+    // Update the element's styles to see live results.
+    data.callbacks.onPreview(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.
+    data.swatch.nextSibling.textContent = value;
   }
 
   /**
-  * Commits changes to the Rule view. This triggers an expensive operation which
-  * rebuilds the DOM of the TextPropertyEditor and attaches events, therefore
-  * it is called in a debounced manner.
+  * Commit this shape value change which triggers an expensive operation that rebuilds
+  * part of the DOM of the TextPropertyEditor. Called in a debounced manner.
+  *
+  * @param {String} value
+  *        The shape value to set the current property to
   */
-  commit() {
-    if (this.activeSwatch) {
-      let swatch = this.swatches.get(this.activeSwatch);
-      swatch.callbacks.onCommit();
+  commit(value) {
+    if (!this.activeProperty) {
+      console.warn("COMMIT: no active property");
+      return;
     }
+    let data = this.links.get(this.activeProperty);
+    data.callbacks.onCommit(value);
   }
 
   destroy() {
-    this.swatches.clear();
-    this.activeSwatch = null;
-    this.highlighter.off("highlighter-event");
+    this.hide();
+    // TODO remove links one by one.
+    this.links.clear();
+    this.highlighter.off("highlighter-event", this._onHighlighterEvent);
+    this.ruleView.off("ruleview-changed", this._onRuleViewChanged);
     this._highligherEventHandlers = {};
   }
 }
 
 module.exports = ShapesInContextEditor;
--- a/devtools/client/themes/rules.css
+++ b/devtools/client/themes/rules.css
@@ -477,17 +477,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);