Bug 1373817 - Convert between length units in CSS shapes highlighter. r=zer0 draft
authorMike Park <mikeparkms@gmail.com>
Fri, 14 Jul 2017 10:32:36 -0400
changeset 683402 f6f0d58ecc85cc2974573bd84552252bab1aa2fa
parent 683249 31af3ee0436093bfd3300e9002f1118df0420309
child 683403 7543c33031d70e182a5aaf4ec2015cad1539c864
child 683405 913369b27f3fc78f3b1e973cd594185e2a5ff819
child 684964 40c136061398fae439a63fa726ac4ef0d772ac98
child 685505 625f792e6d8dd26bf4538573fbc4d475d6af5ec9
child 686314 9ced79fea867b22646e0b7db5377ec5833761071
child 686875 9322df6670536f4fae80ca25599d7b0aebd18873
push id85368
push userbmo:mpark@mozilla.com
push dateThu, 19 Oct 2017 17:32:14 +0000
reviewerszer0
bugs1373817
milestone58.0a1
Bug 1373817 - Convert between length units in CSS shapes highlighter. r=zer0 MozReview-Commit-ID: C3SjxwX8F2a
devtools/client/inspector/rules/test/browser.ini
devtools/client/inspector/rules/test/browser_rules_shapes-toggle_07.js
devtools/client/inspector/shared/highlighters-overlay.js
devtools/server/actors/highlighters/shapes.js
devtools/server/actors/utils/moz.build
devtools/server/actors/utils/shapes-geometry-utils.js
devtools/server/actors/utils/shapes-utils.js
--- a/devtools/client/inspector/rules/test/browser.ini
+++ b/devtools/client/inspector/rules/test/browser.ini
@@ -237,16 +237,17 @@ skip-if = (os == 'linux' && bits == 32 &
 [browser_rules_selector-highlighter_05.js]
 [browser_rules_selector_highlight.js]
 [browser_rules_shapes-toggle_01.js]
 [browser_rules_shapes-toggle_02.js]
 [browser_rules_shapes-toggle_03.js]
 [browser_rules_shapes-toggle_04.js]
 [browser_rules_shapes-toggle_05.js]
 [browser_rules_shapes-toggle_06.js]
+[browser_rules_shapes-toggle_07.js]
 [browser_rules_shorthand-overridden-lists.js]
 [browser_rules_strict-search-filter-computed-list_01.js]
 [browser_rules_strict-search-filter_01.js]
 [browser_rules_strict-search-filter_02.js]
 [browser_rules_strict-search-filter_03.js]
 [browser_rules_style-editor-link.js]
 skip-if = true # Bug 1309759
 [browser_rules_url-click-opens-new-tab.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_07.js
@@ -0,0 +1,76 @@
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test toggling units for shape coords in the rule-view
+
+const TEST_URI = `
+  <style type='text/css'>
+    #shape {
+      width: 800px;
+      height: 800px;
+      clip-path: polygon(0 0, 100px 10%, 100% 100%);
+    }
+  </style>
+  <div id="shape"></div>
+`;
+
+const CSS_SHAPES_ENABLED_PREF = "devtools.inspector.shapesHighlighter.enabled";
+
+add_task(function* () {
+  SpecialPowers.setBoolPref(CSS_SHAPES_ENABLED_PREF, true);
+  yield addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+  let {inspector, view} = yield openRuleView();
+  let highlighters = view.highlighters;
+
+  yield selectNode("#shape", inspector);
+  let container = getRuleViewProperty(view, "#shape", "clip-path").valueSpan;
+  let shapesToggle = container.querySelector(".ruleview-shape");
+  let selector = ".ruleview-shape-point[data-point='1'][data-pair='x']";
+  let coordSpan = container.querySelector(selector);
+
+  info("Toggling ON the CSS shapes highlighter from the rule-view.");
+  let onHighlighterShown = highlighters.once("shapes-highlighter-shown");
+  shapesToggle.click();
+  yield onHighlighterShown;
+
+  info("Shift-clicking on coord, convert to %");
+  let propUpdated = view.once("property-value-updated");
+  EventUtils.sendMouseEvent({type: "click", shiftKey: true}, coordSpan, view.styleWindow);
+  yield propUpdated;
+  let elContainer = getRuleViewProperty(view, "element", "clip-path").valueSpan;
+  coordSpan = elContainer.querySelector(selector);
+  is(coordSpan.textContent, "12.5%", "Coord was converted to %");
+
+  info("Shift-clicking on coord, convert to em");
+  propUpdated = view.once("property-value-updated");
+  EventUtils.sendMouseEvent({type: "click", shiftKey: true}, coordSpan, view.styleWindow);
+  yield propUpdated;
+  coordSpan = elContainer.querySelector(selector);
+  is(coordSpan.textContent, "6.25em", "Coord was converted to em");
+
+  info("Cmd-shift-clicking on coord twice, convert to px");
+  propUpdated = view.once("property-value-updated");
+  EventUtils.sendMouseEvent({type: "click", shiftKey: true, metaKey: true},
+                            coordSpan, view.styleWindow);
+  yield propUpdated;
+  coordSpan = elContainer.querySelector(selector);
+  propUpdated = view.once("property-value-updated");
+  EventUtils.sendMouseEvent({type: "click", shiftKey: true, metaKey: true},
+  coordSpan, view.styleWindow);
+  yield propUpdated;
+  coordSpan = elContainer.querySelector(selector);
+  is(coordSpan.textContent, "100px", "Coord was converted to px");
+
+  info("Cmd-shift-clicking on y coord, convert to px");
+  let ySelector = ".ruleview-shape-point[data-point='1'][data-pair='y']";
+  coordSpan = elContainer.querySelector(ySelector);
+  propUpdated = view.once("property-value-updated");
+  EventUtils.sendMouseEvent({type: "click", shiftKey: true, metaKey: true},
+                            coordSpan, view.styleWindow);
+  yield propUpdated;
+  coordSpan = elContainer.querySelector(ySelector);
+  is(coordSpan.textContent, "80px", "Y coord was converted to px");
+});
--- a/devtools/client/inspector/shared/highlighters-overlay.js
+++ b/devtools/client/inspector/shared/highlighters-overlay.js
@@ -194,16 +194,27 @@ HighlightersOverlay.prototype = {
   hoverPointShapesHighlighter: Task.async(function* (node, point) {
     if (node == this.shapesHighlighterShown) {
       let options = Object.assign({}, this.state.shapes.options);
       options.hoverPoint = point;
       yield this.showShapesHighlighter(node, options);
     }
   }),
 
+  convertPointShapesHighlighter: Task.async(function* (node, point, pair, forward) {
+    if (node == this.shapesHighlighterShown) {
+      let options = Object.assign({}, this.state.shapes.options);
+      options.convertPoint = point;
+      options.convertPair = pair;
+      options.forward = forward;
+      yield this.showShapesHighlighter(node, options);
+      this.state.shapes.options.convertPoint = null;
+    }
+  }),
+
   /**
    * Highlight the given shape point in the rule view.
    *
    * @param {String} point
    *        The point to highlight.
    */
   highlightRuleViewShapePoint: function (point) {
     let view = this.inspector.getPanel("ruleview").view;
@@ -211,19 +222,20 @@ HighlightersOverlay.prototype = {
     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}']`;
+      let dataPoint = point.split(",")[0];
+      selector = (INSET_POINT_TYPES.includes(dataPoint)) ?
+                 `.ruleview-shape-point.${dataPoint}` :
+                 `.ruleview-shape-point[data-point='${dataPoint}']`;
       for (let pointNode of ruleViewEl.querySelectorAll(selector)) {
         let nodeInfo = view.getNodeInfo(pointNode);
         if (this.isRuleViewShapePoint(nodeInfo)) {
           this._toggleShapePointActive(pointNode, true);
         }
       }
     }
   },
@@ -591,45 +603,74 @@ HighlightersOverlay.prototype = {
                     !nodeInfo.value.pseudoElement;
     return this.isRuleView && isTransform && isEnabled;
   },
 
   /**
    * Is the current hovered node a highlightable shape point in the rule-view.
    *
    * @param  {Object} nodeInfo
+   * @param  {Object} dataset the dataset of the node
    * @return {Boolean}
    */
   isRuleViewShapePoint: function (nodeInfo) {
     let isShape = nodeInfo.type === VIEW_NODE_SHAPE_POINT_TYPE &&
                   (nodeInfo.value.property === "clip-path" ||
                   nodeInfo.value.property === "shape-outside");
     let isEnabled = nodeInfo.value.enabled &&
                     !nodeInfo.value.overridden &&
                     !nodeInfo.value.pseudoElement;
     return this.isRuleView && isShape && isEnabled && nodeInfo.value.toggleActive;
   },
 
   onClick: function (event) {
+    let classList = event.target.classList;
     if (this._isRuleViewDisplayGrid(event.target)) {
       event.stopPropagation();
 
       let { store } = this.inspector;
       let { grids, highlighterSettings } = store.getState();
       let grid = grids.find(g => g.nodeFront == this.inspector.selection.nodeFront);
 
       highlighterSettings.color = grid ? grid.color : DEFAULT_GRID_COLOR;
 
       this.toggleGridHighlighter(this.inspector.selection.nodeFront, highlighterSettings,
         "rule");
     } else if (this._isRuleViewShape(event.target)) {
       event.stopPropagation();
 
       let settings = { mode: event.target.dataset.mode };
       this.toggleShapesHighlighter(this.inspector.selection.nodeFront, settings);
+    } else if (classList.contains("ruleview-shape-point") && event.shiftKey) {
+      let view = this.isRuleView ?
+        this.inspector.getPanel("ruleview").view :
+        this.inspector.getPanel("computedview").computedView;
+      let nodeInfo = view.getNodeInfo(event.target);
+      if (!nodeInfo || !this.isRuleViewShapePoint(nodeInfo)) {
+        return;
+      }
+
+      event.stopPropagation();
+      event.preventDefault();
+      let point = event.target.dataset.point;
+      let pair = event.target.dataset.pair;
+      // Inset points use classes instead of data because a single span can represent
+      // multiple points.
+      let insetClasses = [];
+      classList.forEach(className => {
+        if (INSET_POINT_TYPES.includes(className)) {
+          insetClasses.push(className);
+        }
+      });
+      if (insetClasses.length > 0) {
+        point = insetClasses.join(",");
+      }
+      let forward = !event.ctrlKey && !event.metaKey;
+      this.convertPointShapesHighlighter(this.inspector.selection.nodeFront,
+        point, pair, forward);
     }
   },
 
   onMouseMove: function (event) {
     // Bail out if the target is the same as for the last mousemove.
     if (event.target === this._lastHovered) {
       return;
     }
--- a/devtools/server/actors/highlighters/shapes.js
+++ b/devtools/server/actors/highlighters/shapes.js
@@ -9,25 +9,28 @@ const { CanvasFrameAnonymousContentHelpe
 const { setIgnoreLayoutChanges, getCurrentZoom,
         getAdjustedQuads, getFrameOffsets } = require("devtools/shared/layout/utils");
 const { AutoRefreshHighlighter } = require("./auto-refresh");
 const {
   getDistance,
   clickedOnEllipseEdge,
   distanceToLine,
   projection,
-  clickedOnPoint
-} = require("devtools/server/actors/utils/shapes-geometry-utils");
+  clickedOnPoint,
+  roundTo
+} = require("devtools/server/actors/utils/shapes-utils");
 const EventEmitter = require("devtools/shared/old-event-emitter");
 const { getCSSStyleRules } = require("devtools/shared/inspector/css-logic");
 
 const BASE_MARKER_SIZE = 5;
 // the width of the area around highlighter lines that can be clicked, in px
 const LINE_CLICK_WIDTH = 5;
 const DOM_EVENTS = ["mousedown", "mousemove", "mouseup", "dblclick"];
+const UNITS = ["px", "%", "em", "rem", "in", "cm", "mm", "pt",
+               "pc", "vh", "vw", "vmin", "vmax"];
 const _dragging = Symbol("shapes/dragging");
 
 /**
  * The ShapesHighlighter draws an outline shapes in the page.
  * The idea is to have something that is able to wrap complex shapes for css properties
  * such as shape-outside/inside, clip-path but also SVG elements.
  */
 class ShapesHighlighter extends AutoRefreshHighlighter {
@@ -37,16 +40,17 @@ class ShapesHighlighter extends AutoRefr
 
     this.ID_CLASS_PREFIX = "shapes-";
 
     this.referenceBox = "border";
     this.useStrokeBox = false;
     this.geometryBox = "";
     this.hoveredPoint = null;
     this.fillRule = "";
+    this.numInsetPoints = 0;
 
     this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv,
       this._buildMarkup.bind(this));
     this.onPageHide = this.onPageHide.bind(this);
 
     let { pageListenerTarget } = this.highlighterEnv;
     DOM_EVENTS.forEach(event => pageListenerTarget.addEventListener(event, this));
     pageListenerTarget.addEventListener("pagehide", this.onPageHide);
@@ -986,19 +990,22 @@ class ShapesHighlighter extends AutoRefr
    * converted to percentages.
    * @param {String} definition the arguments of the polygon() function
    * @returns {Array} an array of the points of the polygon, with all values
    *          evaluated and converted to percentages
    */
   polygonPoints(definition) {
     this.coordUnits = this.polygonRawPoints();
     let splitDef = definition.split(", ");
-    if (splitDef[0] === "evenodd" || splitDef[0] === "nonzero") {
+    if (splitDef[0].includes("nonzero") || splitDef[0].includes("evenodd")) {
       splitDef.shift();
     }
+    this.pixelCoords = splitDef.map(coords => {
+      return splitCoords(coords).map(this.convertCoordsToPixel.bind(this));
+    });
     return splitDef.map(coords => {
       return splitCoords(coords).map(this.convertCoordsToPercent.bind(this));
     });
   }
 
   /**
    * Parse the raw (non-computed) definition of the CSS polygon.
    * @returns {Array} an array of the points of the polygon, with units preserved.
@@ -1034,42 +1041,68 @@ class ShapesHighlighter extends AutoRefr
    *          center of the circle. All values are evaluated and converted to percentages.
    */
   circlePoints(definition) {
     this.coordUnits = this.circleRawPoints();
     // The computed value of circle() always has the keyword "at".
     let values = definition.split(" at ");
     let radius = values[0];
     let { width, height } = this.zoomAdjustedDimensions;
-    let center = splitCoords(values[1]).map(this.convertCoordsToPercent.bind(this));
+    let splitCenter = splitCoords(values[1]);
+    let pxCenter = splitCenter.map(this.convertCoordsToPixel.bind(this));
+    let center = splitCenter.map(this.convertCoordsToPercent.bind(this));
 
     // Percentage values for circle() are resolved from the
     // used width and height of the reference box as sqrt(width^2+height^2)/sqrt(2).
     let computedSize = Math.sqrt((width ** 2) + (height ** 2)) / Math.sqrt(2);
 
+    let pxRadius;
+    let ratioX = width / computedSize;
+    let ratioY = height / computedSize;
     if (radius === "closest-side") {
       // radius is the distance from center to closest side of reference box
-      radius = Math.min(center[0], center[1], 100 - center[0], 100 - center[1]);
+      pxRadius = Math.min(pxCenter[0], pxCenter[1],
+                          width - pxCenter[0], height - pxCenter[1]);
+      if (pxRadius === pxCenter[0] || pxRadius === width - pxCenter[0]) {
+        radius = pxRadius * 100 / width;
+        ratioX = 1;
+        ratioY = height / width;
+      } else {
+        radius = pxRadius * 100 / height;
+        ratioX = width / height;
+        ratioY = 1;
+      }
     } else if (radius === "farthest-side") {
       // radius is the distance from center to farthest side of reference box
-      radius = Math.max(center[0], center[1], 100 - center[0], 100 - center[1]);
+      pxRadius = Math.max(pxCenter[0], pxCenter[1],
+                          width - pxCenter[0], height - pxCenter[1]);
+      if (pxRadius === pxCenter[0] || pxRadius === width - pxCenter[0]) {
+        radius = pxRadius * 100 / width;
+        ratioX = 1;
+        ratioY = height / width;
+      } else {
+        radius = pxRadius * 100 / height;
+        ratioX = width / height;
+        ratioY = 1;
+      }
     } else if (radius.includes("calc(")) {
       radius = evalCalcExpression(radius.substring(5, radius.length - 1), computedSize);
+      pxRadius = radius / 100 * computedSize;
     } else {
+      pxRadius = coordToPixel(radius, computedSize);
       radius = coordToPercent(radius, computedSize);
     }
 
     // Scale both radiusX and radiusY to match the radius computed
     // using the above equation.
-    let ratioX = width / computedSize;
-    let ratioY = height / computedSize;
     let radiusX = radius / ratioX;
     let radiusY = radius / ratioY;
 
     // rx, ry, cx, ry
+    this.pixelCoords = { radius: pxRadius, cx: pxCenter[0], cy: pxCenter[1] };
     return { radius, rx: radiusX, ry: radiusY, cx: center[0], cy: center[1] };
   }
 
   /**
    * Parse the raw (non-computed) definition of the CSS circle.
    * @returns {Object} an object of the points of the circle (cx, cy, radius),
    *          with units preserved.
    */
@@ -1096,31 +1129,48 @@ class ShapesHighlighter extends AutoRefr
    * @param {String} definition the arguments of the ellipse() function
    * @returns {Object} an object of the form { rx, ry, cx, cy }, where rx and ry are the
    *          radiuses for the x and y axes, and cx and cy are the x/y coordinates for the
    *          center of the ellipse. All values are evaluated and converted to percentages
    */
   ellipsePoints(definition) {
     this.coordUnits = this.ellipseRawPoints();
     let values = definition.split(" at ");
-    let center = splitCoords(values[1]).map(this.convertCoordsToPercent.bind(this));
+    let splitCenter = splitCoords(values[1]);
+    let pxCenter = splitCenter.map(this.convertCoordsToPixel.bind(this));
+    let center = splitCenter.map(this.convertCoordsToPercent.bind(this));
 
     let radii = splitCoords(values[0]).map((radius, i) => {
       if (radius === "closest-side") {
         // radius is the distance from center to closest x/y side of reference box
         return i % 2 === 0 ? Math.min(center[0], 100 - center[0])
                            : Math.min(center[1], 100 - center[1]);
       } else if (radius === "farthest-side") {
         // radius is the distance from center to farthest x/y side of reference box
         return i % 2 === 0 ? Math.max(center[0], 100 - center[0])
                            : Math.max(center[1], 100 - center[1]);
       }
       return this.convertCoordsToPercent(radius, i);
     });
 
+    let pxRadii = splitCoords(values[0]).map((radius, i) => {
+      if (radius === "closest-side") {
+        // radius is the distance from center to closest x/y side of reference box
+        return i % 2 === 0 ? Math.min(pxCenter[0], 100 - pxCenter[0])
+                           : Math.min(pxCenter[1], 100 - pxCenter[1]);
+      } else if (radius === "farthest-side") {
+        // radius is the distance from center to farthest x/y side of reference box
+        return i % 2 === 0 ? Math.max(pxCenter[0], 100 - pxCenter[0])
+                           : Math.max(pxCenter[1], 100 - pxCenter[1]);
+      }
+      return this.convertCoordsToPixel(radius, i);
+    });
+
+    this.pixelCoords = { rx: pxRadii[0], ry: pxRadii[1],
+                         cx: pxCenter[0], cy: pxCenter[1] };
     return { rx: radii[0], ry: radii[1], cx: center[0], cy: center[1] };
   }
 
   /**
    * Parse the raw (non-computed) definition of the CSS ellipse.
    * @returns {Object} an object of the points of the ellipse (cx, cy, rx, ry),
    *          with units preserved.
    */
@@ -1150,37 +1200,51 @@ class ShapesHighlighter extends AutoRefr
    * "round" in the definition) are currently ignored.
    * @param {String} definition the arguments of the inset() function
    * @returns {Object} an object of the form { x, y, width, height }, which are the top/
    *          left positions and width/height of the shape.
    */
   insetPoints(definition) {
     this.coordUnits = this.insetRawPoints();
     let values = definition.split(" round ");
-    let offsets = splitCoords(values[0]).map(this.convertCoordsToPercent.bind(this));
+    let offsets = splitCoords(values[0]);
 
-    let top, left = 0;
-    let { width: right, height: bottom } = this.currentDimensions;
+    let top, left, right, bottom = 0;
+    let { width, height } = this.zoomAdjustedDimensions;
+    let pxTop, pxLeft, pxRight, pxBottom;
     // The offsets, like margin/padding/border, are in order: top, right, bottom, left.
     if (offsets.length === 1) {
-      top = left = right = bottom = offsets[0];
+      top = bottom = coordToPercent(offsets[0], height);
+      left = right = coordToPercent(offsets[0], width);
+      pxTop = pxBottom = coordToPixel(offsets[0], height);
+      pxLeft = pxRight = coordToPixel(offsets[0], width);
     } else if (offsets.length === 2) {
-      top = bottom = offsets[0];
-      left = right = offsets[1];
+      top = bottom = coordToPercent(offsets[0], height);
+      left = right = coordToPercent(offsets[1], width);
+      pxTop = pxBottom = coordToPixel(offsets[0], height);
+      pxLeft = pxRight = coordToPixel(offsets[1], width);
     } else if (offsets.length === 3) {
-      top = offsets[0];
-      left = right = offsets[1];
-      bottom = offsets[2];
+      top = coordToPercent(offsets[0], height);
+      left = right = coordToPercent(offsets[1], width);
+      bottom = coordToPercent(offsets[2], height);
+      pxTop = coordToPixel(offsets[0], height);
+      pxLeft = pxRight = coordToPixel(offsets[1], width);
+      pxBottom = coordToPixel(offsets[2], height);
     } else if (offsets.length === 4) {
-      top = offsets[0];
-      right = offsets[1];
-      bottom = offsets[2];
-      left = offsets[3];
+      top = coordToPercent(offsets[0], height);
+      right = coordToPercent(offsets[1], width);
+      bottom = coordToPercent(offsets[2], height);
+      left = coordToPercent(offsets[3], width);
+      pxTop = coordToPixel(offsets[0], height);
+      pxRight = coordToPixel(offsets[1], width);
+      pxBottom = coordToPixel(offsets[2], height);
+      pxLeft = coordToPixel(offsets[3], width);
     }
 
+    this.pixelCoords = { top: pxTop, left: pxLeft, right: pxRight, bottom: pxBottom };
     return { top, left, right, bottom };
   }
 
   /**
    * Parse the raw (non-computed) definition of the CSS inset.
    * @returns {Object} an object of the points of the inset (top, right, bottom, left),
    *          with units preserved.
    */
@@ -1195,16 +1259,17 @@ class ShapesHighlighter extends AutoRefr
     let values = definition.split(" round ");
     this.insetRound = values[1];
     let offsets = splitCoords(values[0]).map(coord => {
       // Undo the insertion of &nbsp; that was done in splitCoords.
       return coord.replace(/\u00a0/g, " ");
     });
 
     let top, left, right, bottom = 0;
+    this.numInsetPoints = offsets.length;
 
     if (offsets.length === 1) {
       top = left = right = bottom = offsets[0];
     } else if (offsets.length === 2) {
       top = bottom = offsets[0];
       left = right = offsets[1];
     } else if (offsets.length === 3) {
       top = offsets[0];
@@ -1224,16 +1289,25 @@ class ShapesHighlighter extends AutoRefr
     let { width, height } = this.zoomAdjustedDimensions;
     let size = i % 2 === 0 ? width : height;
     if (coord.includes("calc(")) {
       return evalCalcExpression(coord.substring(5, coord.length - 1), size);
     }
     return coordToPercent(coord, size);
   }
 
+  convertCoordsToPixel(coord, i) {
+    let { width, height } = this.zoomAdjustedDimensions;
+    let size = i % 2 === 0 ? width : height;
+    if (coord.includes("calc(")) {
+      return evalCalcExpression(coord.substring(5, coord.length - 1), size) / 100 * size;
+    }
+    return coordToPixel(coord, size);
+  }
+
   /**
    * Destroy the nodes. Remove listeners.
    */
   destroy() {
     let { pageListenerTarget } = this.highlighterEnv;
     if (pageListenerTarget) {
       DOM_EVENTS.forEach(type => pageListenerTarget.removeEventListener(type, this));
     }
@@ -1339,16 +1413,22 @@ class ShapesHighlighter extends AutoRefr
     } else if (this.shapeType === "ellipse") {
       this._updateEllipseShape(width, height, zoom);
     } else if (this.shapeType === "inset") {
       this._updateInsetShape(width, height, zoom);
     }
 
     this._handleMarkerHover(this.hoveredPoint);
 
+    if (this.options.convertPoint) {
+      let { convertPoint, convertPair, forward } = this.options;
+      this.handleUnitConversion(convertPoint, convertPair, forward);
+      this.options.convertPoint = null;
+    }
+
     let { width: winWidth, height: winHeight } = this._winDimensions;
     root.removeAttribute("hidden");
     root.setAttribute("style",
       `position:absolute; width:${winWidth}px;height:${winHeight}px; overflow:hidden`);
 
     setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement);
 
     return true;
@@ -1463,16 +1543,238 @@ class ShapesHighlighter extends AutoRefr
 
   onPageHide({ target }) {
     // If a page hide event is triggered for current window's highlighter, hide the
     // highlighter.
     if (target.defaultView === this.win) {
       this.hide();
     }
   }
+
+  handleUnitConversion(point, pair, forward) {
+    if (this.shapeType === "polygon") {
+      this._handlePolygonConversion(point, pair, forward);
+    } else if (this.shapeType === "circle") {
+      this._handleCircleConversion(point, pair, forward);
+    } else if (this.shapeType === "ellipse") {
+      this._handleEllipseConversion(point, pair, forward);
+    } else if (this.shapeType === "inset") {
+      this._handleInsetConversion(point, forward);
+    }
+  }
+
+  _handlePolygonConversion(point, pair, forward) {
+    if (!pair) {
+      return;
+    }
+    let { width, height } = this.zoomAdjustedDimensions;
+    let pairIndex = (pair === "x") ? 0 : 1;
+    let size = (pair === "x") ? width : height;
+
+    let coord = this.pixelCoords[point][pairIndex];
+    let currCoord = this.coordUnits[point][pairIndex];
+    let converted = this.convertToNextUnit(coord, currCoord, forward, size);
+    this.coordUnits[point][pairIndex] = converted;
+
+    let polygonDef = (this.fillRule) ? `${this.fillRule}, ` : "";
+    polygonDef += this.coordUnits.map((coords, i) => {
+      return coords.join(" ");
+    }).join(", ");
+    polygonDef = (this.geometryBox) ? `polygon(${polygonDef}) ${this.geometryBox}` :
+                                        `polygon(${polygonDef})`;
+
+    this.currentNode.style.setProperty(this.property, polygonDef, "important");
+  }
+
+  _handleCircleConversion(point, pair, forward) {
+    if (point === "center") {
+      if (!pair) {
+        return;
+      }
+      let { cx, cy } = this.pixelCoords;
+      let { radius, cx: currCx, cy: currCy } = this.coordUnits;
+      let { width, height } = this.zoomAdjustedDimensions;
+
+      let coord = (pair === "x") ? cx : cy;
+      let currCoord = (pair === "x") ? currCx : currCy;
+      let size = (pair === "x") ? width : height;
+      let newCoord = this.convertToNextUnit(coord, currCoord, forward, size);
+      this.coordUnits[`c${pair}`] = newCoord;
+      if (pair === "x") {
+        currCx = newCoord;
+      } else {
+        currCy = newCoord;
+      }
+
+      let circleDef = (this.geometryBox) ?
+              `circle(${radius} at ${currCx} ${currCy}) ${this.geometryBox}` :
+              `circle(${radius} at ${currCx} ${currCy})`;
+      this.currentNode.style.setProperty(this.property, circleDef, "important");
+    } else if (point === "radius") {
+      let { radius } = this.pixelCoords;
+      let { radius: currRadius, cx, cy } = this.coordUnits;
+      let { width, height } = this.zoomAdjustedDimensions;
+      let size = Math.sqrt((width ** 2) + (height ** 2)) / Math.sqrt(2);
+      let newRadius = this.convertToNextUnit(radius, currRadius, forward, size);
+      this.coordUnits.radius = newRadius;
+
+      let circleDef = (this.geometryBox) ?
+                        `circle(${newRadius} at ${cx} ${cy} ${this.geometryBox}` :
+                        `circle(${newRadius} at ${cx} ${cy}`;
+      this.currentNode.style.setProperty(this.property, circleDef, "important");
+    }
+  }
+
+  _handleEllipseConversion(point, pair, forward) {
+    if (point === "center") {
+      if (!pair) {
+        return;
+      }
+      let { cx, cy } = this.pixelCoords;
+      let { rx, ry, cx: currCx, cy: currCy } = this.coordUnits;
+      let { width, height } = this.zoomAdjustedDimensions;
+
+      let coord = (pair === "x") ? cx : cy;
+      let currCoord = (pair === "x") ? currCx : currCy;
+      let size = (pair === "x") ? width : height;
+      let newCoord = this.convertToNextUnit(coord, currCoord, forward, size);
+      this.coordUnits[`c${pair}`] = newCoord;
+      if (pair === "x") {
+        currCx = newCoord;
+      } else {
+        currCy = newCoord;
+      }
+
+      let ellipseDef = (this.geometryBox) ?
+              `ellipse(${rx} ${ry} at ${currCx} ${currCy}) ${this.geometryBox}` :
+              `ellipse(${rx} ${ry} at ${currCx} ${currCy})`;
+      this.currentNode.style.setProperty(this.property, ellipseDef, "important");
+    } else if (point === "rx") {
+      let { rx } = this.pixelCoords;
+      let { rx: currRx, ry, cx, cy } = this.coordUnits;
+      let { width } = this.zoomAdjustedDimensions;
+      let newRx = this.convertToNextUnit(rx, currRx, forward, width);
+      this.coordUnits.rx = newRx;
+      let ellipseDef = (this.geometryBox) ?
+          `ellipse(${newRx} ${ry} at ${cx} ${cy}) ${this.geometryBox}` :
+          `ellipse(${newRx} ${ry} at ${cx} ${cy})`;
+
+      this.currentNode.style.setProperty(this.property, ellipseDef, "important");
+    } else if (point === "ry") {
+      let { ry } = this.pixelCoords;
+      let { rx, ry: currRy, cx, cy } = this.coordUnits;
+      let { height } = this.zoomAdjustedDimensions;
+      let newRy = this.convertToNextUnit(ry, currRy, forward, height);
+      this.coordUnits.ry = newRy;
+      let ellipseDef = (this.geometryBox) ?
+          `ellipse(${rx} ${newRy} at ${cx} ${cy}) ${this.geometryBox}` :
+          `ellipse(${rx} ${newRy} at ${cx} ${cy})`;
+
+      this.currentNode.style.setProperty(this.property, ellipseDef, "important");
+    }
+  }
+
+  _handleInsetConversion(point, forward) {
+    let points = point.split(",");
+    let coordX = (points[0] === "left") ?
+        this.pixelCoords[points[0]] : this.pixelCoords.right;
+    let coordY = (points[0] === "bottom") ?
+        this.pixelCoords[points[0]] : this.pixelCoords.top;
+    let currCoordX = (points[0] === "left") ?
+        this.coordUnits[points[0]] : this.coordUnits.right;
+    let currCoordY = (points[0] === "bottom") ?
+        this.coordUnits[points[0]] : this.coordUnits.top;
+    let { width, height } = this.zoomAdjustedDimensions;
+    let convertedX = this.convertToNextUnit(coordX, currCoordX, forward, width);
+    let convertedY = this.convertToNextUnit(coordY, currCoordY, forward, height);
+
+      // If we are converting all 4 points at once, and convertedX and convertedY
+      // are different, that means we are converting to or from %, and the
+      // width/height are different. We shouldn't do this because we will get
+      // different values for top/bottom and left/right.
+    if (points.length === 4 && convertedX !== convertedY) {
+        // If we have converted to %, we skip it and convert to the next unit.
+        // If we have converted from %, we return and do not save the conversion.
+      if (getUnit(convertedX) === "%") {
+        convertedX = this.convertToNextUnit(coordX, convertedX, forward, width);
+        convertedY = this.convertToNextUnit(coordY, convertedY, forward, height);
+      } else {
+        return;
+      }
+    }
+
+    for (let insetPoint of points) {
+      if (insetPoint === "top" || insetPoint === "bottom") {
+        this.coordUnits[insetPoint] = convertedY;
+      } else {
+        this.coordUnits[insetPoint] = convertedX;
+      }
+    }
+
+    let { top, right, bottom, left } = this.coordUnits;
+    let definitions = [top, right, bottom, left];
+    definitions = definitions.slice(0, this.numInsetPoints);
+    let insetDef = (this.insetRound) ?
+        `inset(${definitions.join(" ")} round ${this.insetRound})` :
+        `inset(${definitions.join(" ")})`;
+
+    insetDef += (this.geometryBox) ? this.geometryBox : "";
+
+    this.currentNode.style.setProperty(this.property, insetDef, "important");
+  }
+
+  convertToNextUnit(coord, currCoord, forward, size) {
+    if (isUnitless(currCoord)) {
+      return currCoord;
+    }
+    let oldUnit = getUnit(currCoord);
+
+    let unitIndex = UNITS.indexOf(oldUnit);
+    let newUnit = (unitIndex === UNITS.length - 1) ? UNITS[0] : UNITS[unitIndex + 1];
+    if (!forward) {
+      newUnit = (unitIndex <= 0) ? UNITS[UNITS.length - 1] : UNITS[unitIndex - 1];
+    }
+    let newValue = this.convertFromPx(coord, newUnit, size);
+    return roundTo(newValue, -4) + newUnit;
+  }
+
+  convertFromPx(value, unit, size) {
+    switch (unit) {
+      case "px":
+        return value;
+      case "in":
+        return value / 96;
+      case "cm":
+        return value / 37.8;
+      case "mm":
+        return value / 3.78;
+      case "em":
+        return value / parseFloat(getComputedStyle(this.currentNode).fontSize);
+      case "rem":
+        let root = this.currentNode.ownerDocument.documentElement;
+        return value / parseFloat(getComputedStyle(root).fontSize);
+      case "pt":
+        return value * 0.75;
+      case "pc":
+        return value * 0.0625;
+      case "vh":
+        return value * 100 / this.win.innerHeight;
+      case "vw":
+        return value * 100 / this.win.innerWidth;
+      case "vmin":
+        let vmin = Math.min(this.win.innerHeight, this.win.innerWidth);
+        return value * 100 / vmin;
+      case "vmax":
+        let vmax = Math.max(this.win.innerHeight, this.win.innerWidth);
+        return value * 100 / vmax;
+      case "%":
+        return value * 100 / size;
+    }
+    return 0;
+  }
 }
 
 /**
  * Get the "raw" (i.e. non-computed) shape definition on the given node.
  * @param {nsIDOMNode} node the node to analyze
  * @param {String} property the CSS property for which a value should be retrieved.
  * @returns {String} the value of the given CSS property on the given node.
  */
@@ -1535,16 +1837,27 @@ function coordToPercent(coord, size) {
     return px * 100 / size;
   }
 
   // Unit-less value, so 0.
   return 0;
 }
 exports.coordToPercent = coordToPercent;
 
+function coordToPixel(coord, size) {
+  if (coord.includes("%")) {
+    let percent = parseFloat(coord);
+    return percent / 100 * size;
+  } else if (coord.includes("px")) {
+    return parseFloat(coord);
+  }
+
+  return 0;
+}
+
 /**
  * Evaluates a CSS calc() expression (only handles addition)
  * @param {String} expression the arguments to the calc() function
  * @param {Number} size the size of the element (width or height) that percentage values
  *        are relative to
  * @returns {Number} the result of the expression as a percentage value
  */
 function evalCalcExpression(expression, size) {
--- a/devtools/server/actors/utils/moz.build
+++ b/devtools/server/actors/utils/moz.build
@@ -6,13 +6,13 @@
 
 DevToolsModules(
     'actor-registry-utils.js',
     'audionodes.json',
     'automation-timeline.js',
     'css-grid-utils.js',
     'make-debugger.js',
     'map-uri-to-addon-id.js',
-    'shapes-geometry-utils.js',
+    'shapes-utils.js',
     'stack.js',
     'TabSources.js',
     'walker-search.js',
 )
deleted file mode 100644
--- a/devtools/server/actors/utils/shapes-geometry-utils.js
+++ /dev/null
@@ -1,110 +0,0 @@
-"use strict";
-
-/**
- * Get the distance between two points on a plane.
- * @param {Number} x1 the x coord of the first point
- * @param {Number} y1 the y coord of the first point
- * @param {Number} x2 the x coord of the second point
- * @param {Number} y2 the y coord of the second point
- * @returns {Number} the distance between the two points
- */
-const getDistance = (x1, y1, x2, y2) => {
-  return Math.hypot(x2 - x1, y2 - y1);
-};
-
-/**
- * Determine if the given x/y coords are along the edge of the given ellipse.
- * We allow for a small area around the edge that still counts as being on the edge.
- * @param {Number} x the x coordinate of the click
- * @param {Number} y the y coordinate of the click
- * @param {Number} cx the x coordinate of the center of the ellipse
- * @param {Number} cy the y coordinate of the center of the ellipse
- * @param {Number} rx the x radius of the ellipse
- * @param {Number} ry the y radius of the ellipse
- * @param {Number} clickWidthX the width of the area that counts as being on the edge
- *                             along the x radius.
- * @param {Number} clickWidthY the width of the area that counts as being on the edge
- *                             along the y radius.
- * @returns {Boolean} whether the click counts as being on the edge of the ellipse.
- */
-const clickedOnEllipseEdge = (x, y, cx, cy, rx, ry, clickWidthX, clickWidthY) => {
-  // The formula to determine if something is inside or on the edge of an ellipse is:
-  // (x - cx)^2/rx^2 + (y - cy)^2/ry^2 <= 1. If > 1, it's outside.
-  // We make two ellipses, adjusting rx and ry with clickWidthX and clickWidthY
-  // to allow for an area around the edge of the ellipse that can be clicked on.
-  // If the click was outside the inner ellipse and inside the outer ellipse, return true.
-  let inner = ((x - cx) ** 2) / (rx - clickWidthX) ** 2 +
-              ((y - cy) ** 2) / (ry - clickWidthY) ** 2;
-  let outer = ((x - cx) ** 2) / (rx + clickWidthX) ** 2 +
-              ((y - cy) ** 2) / (ry + clickWidthY) ** 2;
-  return inner >= 1 && outer <= 1;
-};
-
-/**
- * Get the distance between a point and a line defined by two other points.
- * @param {Number} x1 the x coordinate of the first point in the line
- * @param {Number} y1 the y coordinate of the first point in the line
- * @param {Number} x2 the x coordinate of the second point in the line
- * @param {Number} y2 the y coordinate of the second point in the line
- * @param {Number} x3 the x coordinate of the point for which the distance is found
- * @param {Number} y3 the y coordinate of the point for which the distance is found
- * @returns {Number} the distance between (x3,y3) and the line defined by
- *          (x1,y1) and (y1,y2)
- */
-const distanceToLine = (x1, y1, x2, y2, x3, y3) => {
-  // https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line#Line_defined_by_two_points
-  let num = Math.abs((y2 - y1) * x3 - (x2 - x1) * y3 + x2 * y1 - y2 * x1);
-  let denom = getDistance(x1, y1, x2, y2);
-  return num / denom;
-};
-
-/**
- * Get the point on the line defined by points a,b that is closest to point c
- * @param {Number} ax the x coordinate of point a
- * @param {Number} ay the y coordinate of point a
- * @param {Number} bx the x coordinate of point b
- * @param {Number} by the y coordinate of point b
- * @param {Number} cx the x coordinate of point c
- * @param {Number} cy the y coordinate of point c
- * @returns {Array} a 2 element array that contains the x/y coords of the projected point
- */
-const projection = (ax, ay, bx, by, cx, cy) => {
-  // https://en.wikipedia.org/wiki/Vector_projection#Vector_projection_2
-  let ab = [bx - ax, by - ay];
-  let ac = [cx - ax, cy - ay];
-  let scalar = dotProduct(ab, ac) / dotProduct(ab, ab);
-  return [ax + scalar * ab[0], ay + scalar * ab[1]];
-};
-
-/**
- * Get the dot product of two vectors, represented by arrays of numbers.
- * @param {Array} a the first vector
- * @param {Array} b the second vector
- * @returns {Number} the dot product of a and b
- */
-const dotProduct = (a, b) => {
-  return a.reduce((prev, curr, i) => {
-    return prev + curr * b[i];
-  }, 0);
-};
-
-/**
- * Determine if the given x/y coords are above the given point.
- * @param {Number} x the x coordinate of the click
- * @param {Number} y the y coordinate of the click
- * @param {Number} pointX the x coordinate of the center of the point
- * @param {Number} pointY the y coordinate of the center of the point
- * @param {Number} radiusX the x radius of the point
- * @param {Number} radiusY the y radius of the point
- * @returns {Boolean} whether the click was on the point
- */
-const clickedOnPoint = (x, y, pointX, pointY, radiusX, radiusY) => {
-  return x >= pointX - radiusX && x <= pointX + radiusX &&
-         y >= pointY - radiusY && y <= pointY + radiusY;
-};
-
-exports.getDistance = getDistance;
-exports.clickedOnEllipseEdge = clickedOnEllipseEdge;
-exports.distanceToLine = distanceToLine;
-exports.projection = projection;
-exports.clickedOnPoint = clickedOnPoint;
new file mode 100644
--- /dev/null
+++ b/devtools/server/actors/utils/shapes-utils.js
@@ -0,0 +1,130 @@
+"use strict";
+
+/**
+ * Get the distance between two points on a plane.
+ * @param {Number} x1 the x coord of the first point
+ * @param {Number} y1 the y coord of the first point
+ * @param {Number} x2 the x coord of the second point
+ * @param {Number} y2 the y coord of the second point
+ * @returns {Number} the distance between the two points
+ */
+const getDistance = (x1, y1, x2, y2) => {
+  return Math.hypot(x2 - x1, y2 - y1);
+};
+
+/**
+ * Determine if the given x/y coords are along the edge of the given ellipse.
+ * We allow for a small area around the edge that still counts as being on the edge.
+ * @param {Number} x the x coordinate of the click
+ * @param {Number} y the y coordinate of the click
+ * @param {Number} cx the x coordinate of the center of the ellipse
+ * @param {Number} cy the y coordinate of the center of the ellipse
+ * @param {Number} rx the x radius of the ellipse
+ * @param {Number} ry the y radius of the ellipse
+ * @param {Number} clickWidthX the width of the area that counts as being on the edge
+ *                             along the x radius.
+ * @param {Number} clickWidthY the width of the area that counts as being on the edge
+ *                             along the y radius.
+ * @returns {Boolean} whether the click counts as being on the edge of the ellipse.
+ */
+const clickedOnEllipseEdge = (x, y, cx, cy, rx, ry, clickWidthX, clickWidthY) => {
+  // The formula to determine if something is inside or on the edge of an ellipse is:
+  // (x - cx)^2/rx^2 + (y - cy)^2/ry^2 <= 1. If > 1, it's outside.
+  // We make two ellipses, adjusting rx and ry with clickWidthX and clickWidthY
+  // to allow for an area around the edge of the ellipse that can be clicked on.
+  // If the click was outside the inner ellipse and inside the outer ellipse, return true.
+  let inner = ((x - cx) ** 2) / (rx - clickWidthX) ** 2 +
+              ((y - cy) ** 2) / (ry - clickWidthY) ** 2;
+  let outer = ((x - cx) ** 2) / (rx + clickWidthX) ** 2 +
+              ((y - cy) ** 2) / (ry + clickWidthY) ** 2;
+  return inner >= 1 && outer <= 1;
+};
+
+/**
+ * Get the distance between a point and a line defined by two other points.
+ * @param {Number} x1 the x coordinate of the first point in the line
+ * @param {Number} y1 the y coordinate of the first point in the line
+ * @param {Number} x2 the x coordinate of the second point in the line
+ * @param {Number} y2 the y coordinate of the second point in the line
+ * @param {Number} x3 the x coordinate of the point for which the distance is found
+ * @param {Number} y3 the y coordinate of the point for which the distance is found
+ * @returns {Number} the distance between (x3,y3) and the line defined by
+ *          (x1,y1) and (y1,y2)
+ */
+const distanceToLine = (x1, y1, x2, y2, x3, y3) => {
+  // https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line#Line_defined_by_two_points
+  let num = Math.abs((y2 - y1) * x3 - (x2 - x1) * y3 + x2 * y1 - y2 * x1);
+  let denom = getDistance(x1, y1, x2, y2);
+  return num / denom;
+};
+
+/**
+ * Get the point on the line defined by points a,b that is closest to point c
+ * @param {Number} ax the x coordinate of point a
+ * @param {Number} ay the y coordinate of point a
+ * @param {Number} bx the x coordinate of point b
+ * @param {Number} by the y coordinate of point b
+ * @param {Number} cx the x coordinate of point c
+ * @param {Number} cy the y coordinate of point c
+ * @returns {Array} a 2 element array that contains the x/y coords of the projected point
+ */
+const projection = (ax, ay, bx, by, cx, cy) => {
+  // https://en.wikipedia.org/wiki/Vector_projection#Vector_projection_2
+  let ab = [bx - ax, by - ay];
+  let ac = [cx - ax, cy - ay];
+  let scalar = dotProduct(ab, ac) / dotProduct(ab, ab);
+  return [ax + scalar * ab[0], ay + scalar * ab[1]];
+};
+
+/**
+ * Get the dot product of two vectors, represented by arrays of numbers.
+ * @param {Array} a the first vector
+ * @param {Array} b the second vector
+ * @returns {Number} the dot product of a and b
+ */
+const dotProduct = (a, b) => {
+  return a.reduce((prev, curr, i) => {
+    return prev + curr * b[i];
+  }, 0);
+};
+
+/**
+ * Determine if the given x/y coords are above the given point.
+ * @param {Number} x the x coordinate of the click
+ * @param {Number} y the y coordinate of the click
+ * @param {Number} pointX the x coordinate of the center of the point
+ * @param {Number} pointY the y coordinate of the center of the point
+ * @param {Number} radiusX the x radius of the point
+ * @param {Number} radiusY the y radius of the point
+ * @returns {Boolean} whether the click was on the point
+ */
+const clickedOnPoint = (x, y, pointX, pointY, radiusX, radiusY) => {
+  return x >= pointX - radiusX && x <= pointX + radiusX &&
+         y >= pointY - radiusY && y <= pointY + radiusY;
+};
+
+const roundTo = (value, exp) => {
+  // If the exp is undefined or zero...
+  if (typeof exp === "undefined" || +exp === 0) {
+    return Math.round(value);
+  }
+  value = +value;
+  exp = +exp;
+        // If the value is not a number or the exp is not an integer...
+  if (isNaN(value) || !(typeof exp === "number" && exp % 1 === 0)) {
+    return NaN;
+  }
+        // Shift
+  value = value.toString().split("e");
+  value = Math.round(+(value[0] + "e" + (value[1] ? (+value[1] - exp) : -exp)));
+        // Shift back
+  value = value.toString().split("e");
+  return +(value[0] + "e" + (value[1] ? (+value[1] + exp) : exp));
+};
+
+exports.getDistance = getDistance;
+exports.clickedOnEllipseEdge = clickedOnEllipseEdge;
+exports.distanceToLine = distanceToLine;
+exports.projection = projection;
+exports.clickedOnPoint = clickedOnPoint;
+exports.roundTo = roundTo;