Bug 1139187 - Allow moving and resizing elements in content; r=pbro draft
authorMatteo Ferretti <mferretti@mozilla.com>
Thu, 17 Mar 2016 10:59:03 -0400
changeset 345857 f2b398dac98c9735bb228b40667884df6558c3ea
parent 345849 a8cb004e06e7c61c6ca3676e2c0d33773882e702
child 345858 ee5329e825e4d37763e6e351f383d381574b33cb
push id14186
push userbmo:zer0@mozilla.com
push dateWed, 30 Mar 2016 14:42:09 +0000
reviewerspbro
bugs1139187
milestone48.0a1
Bug 1139187 - Allow moving and resizing elements in content; r=pbro MozReview-Commit-ID: EmmFBXW22dk
devtools/client/inspector/inspector-panel.js
devtools/client/inspector/inspector.xul
devtools/client/inspector/layout/layout.js
devtools/client/inspector/markup/markup.js
devtools/client/inspector/test/browser.ini
devtools/client/inspector/test/browser_inspector_highlighter-geometry_06.js
devtools/client/jar.mn
devtools/client/locales/en-US/layoutview.dtd
devtools/client/themes/images/geometry-editor.svg
devtools/client/themes/layout.css
devtools/server/actors/highlighters.css
devtools/server/actors/highlighters.js
devtools/server/actors/highlighters/geometry-editor.js
devtools/server/actors/highlighters/utils/markup.js
devtools/server/actors/styles.js
--- a/devtools/client/inspector/inspector-panel.js
+++ b/devtools/client/inspector/inspector-panel.js
@@ -495,25 +495,27 @@ InspectorPanel.prototype = {
    */
   updating: function(name) {
     if (this._updateProgress && this._updateProgress.node != this.selection.nodeFront) {
       this.cancelUpdate();
     }
 
     if (!this._updateProgress) {
       // Start an update in progress.
-      var self = this;
+      let self = this;
       this._updateProgress = {
         node: this.selection.nodeFront,
         outstanding: new Set(),
         checkDone: function() {
           if (this !== self._updateProgress) {
             return;
           }
-          if (this.node !== self.selection.nodeFront) {
+          // Cancel update if there is no `selection` anymore.
+          // It can happen if the inspector panel is already destroyed.
+          if (!self.selection || (this.node !== self.selection.nodeFront)) {
             self.cancelUpdate();
             return;
           }
           if (this.outstanding.size !== 0) {
             return;
           }
 
           self._updateProgress = null;
--- a/devtools/client/inspector/inspector.xul
+++ b/devtools/client/inspector/inspector.xul
@@ -279,17 +279,21 @@
               </html:div>
             </html:section>
           </html:div>
         </tabpanel>
 
         <tabpanel id="sidebar-panel-layoutview" class="devtools-monospace theme-sidebar inspector-tabpanel">
           <html:div id="layout-container">
             <html:p id="layout-header">
-              <html:span id="layout-element-size"></html:span><html:span id="layout-element-position"></html:span>
+              <html:span id="layout-element-size"></html:span>
+              <html:section id="layout-position-group">
+                <html:button class="devtools-button" id="layout-geometry-editor" title="&geometry.button.tooltip;"></html:button>
+                <html:span id="layout-element-position"></html:span>
+              </html:section>
             </html:p>
 
             <html:div id="layout-main">
               <html:span class="layout-legend" data-box="margin" title="&margin.tooltip;">&margin.tooltip;</html:span>
               <html:div id="layout-margins" data-box="margin" title="&margin.tooltip;">
                 <html:span class="layout-legend" data-box="border" title="&border.tooltip;">&border.tooltip;</html:span>
                 <html:div id="layout-borders" data-box="border" title="&border.tooltip;">
                   <html:span class="layout-legend" data-box="padding" title="&padding.tooltip;">&padding.tooltip;</html:span>
--- a/devtools/client/inspector/layout/layout.js
+++ b/devtools/client/inspector/layout/layout.js
@@ -135,16 +135,17 @@ EditingSession.prototype = {
  * currently loaded in the toolbox
  * @param {Window} win The window containing the panel
  */
 function LayoutView(inspector, win) {
   this.inspector = inspector;
   this.doc = win.document;
   this.sizeLabel = this.doc.querySelector(".layout-size > span");
   this.sizeHeadingLabel = this.doc.getElementById("layout-element-size");
+  this._geometryEditorHighlighter = null;
 
   this.init();
 }
 
 LayoutView.prototype = {
   init: function() {
     this.update = this.update.bind(this);
 
@@ -152,16 +153,21 @@ LayoutView.prototype = {
     this.inspector.selection.on("new-node-front", this.onNewSelection);
 
     this.onNewNode = this.onNewNode.bind(this);
     this.inspector.sidebar.on("layoutview-selected", this.onNewNode);
 
     this.onSidebarSelect = this.onSidebarSelect.bind(this);
     this.inspector.sidebar.on("select", this.onSidebarSelect);
 
+    this.onPickerStarted = this.onPickerStarted.bind(this);
+    this.onMarkupViewLeave = this.onMarkupViewLeave.bind(this);
+    this.onMarkupViewNodeHover = this.onMarkupViewNodeHover.bind(this);
+    this.onWillNavigate = this.onWillNavigate.bind(this);
+
     this.initBoxModelHighlighter();
 
     // Store for the different dimensions of the node.
     // 'selector' refers to the element that holds the value in view.xhtml;
     // 'property' is what we are measuring;
     // 'value' is the computed dimension, computed in update().
     this.map = {
       position: {
@@ -248,16 +254,21 @@ LayoutView.prototype = {
     this.onNewNode();
 
     // Mark document as RTL or LTR:
     let chromeReg = Cc["@mozilla.org/chrome/chrome-registry;1"]
                     .getService(Ci.nsIXULChromeRegistry);
     let dir = chromeReg.isLocaleRTL("global");
     let container = this.doc.getElementById("layout-container");
     container.setAttribute("dir", dir ? "rtl" : "ltr");
+
+    let nodeGeometry = this.doc.getElementById("layout-geometry-editor");
+
+    this.onGeometryButtonClick = this.onGeometryButtonClick.bind(this);
+    nodeGeometry.addEventListener("click", this.onGeometryButtonClick);
   },
 
   initBoxModelHighlighter: function() {
     let highlightElts = this.doc.querySelectorAll("#layout-container *[title]");
     this.onHighlightMouseOver = this.onHighlightMouseOver.bind(this);
     this.onHighlightMouseOut = this.onHighlightMouseOut.bind(this);
 
     for (let element of highlightElts) {
@@ -371,19 +382,33 @@ LayoutView.prototype = {
   destroy: function() {
     let highlightElts = this.doc.querySelectorAll("#layout-container *[title]");
 
     for (let element of highlightElts) {
       element.removeEventListener("mouseover", this.onHighlightMouseOver, true);
       element.removeEventListener("mouseout", this.onHighlightMouseOut, true);
     }
 
+    let nodeGeometry = this.doc.getElementById("layout-geometry-editor");
+    nodeGeometry.removeEventListener("click", this.onGeometryButtonClick);
+
+    this.inspector.off("picker-started", this.onPickerStarted);
+
+    // Inspector Panel will destroy `markup` object on "will-navigate" event,
+    // therefore we have to check if it's still available in case LayoutView
+    // is destroyed immediately after.
+    if (this.inspector.markup) {
+      this.inspector.markup.off("leave", this.onMarkupViewLeave);
+      this.inspector.markup.off("node-hover", this.onMarkupViewNodeHover);
+    }
+
     this.inspector.sidebar.off("layoutview-selected", this.onNewNode);
     this.inspector.selection.off("new-node-front", this.onNewSelection);
     this.inspector.sidebar.off("select", this.onSidebarSelect);
+    this.inspector._target.off("will-navigate", this.onWillNavigate);
 
     this.sizeHeadingLabel = null;
     this.sizeLabel = null;
     this.inspector = null;
     this.doc = null;
 
     if (this.reflowFront) {
       this.untrackReflows();
@@ -396,20 +421,22 @@ LayoutView.prototype = {
     this.setActive(sidebar === "layoutview");
   },
 
   /**
    * Selection 'new-node-front' event handler.
    */
   onNewSelection: function() {
     let done = this.inspector.updating("layoutview");
-    this.onNewNode().then(done, err => {
-      console.error(err);
-      done();
-    });
+    this.onNewNode()
+      .then(() => this.hideGeometryEditor())
+      .then(done, (err) => {
+        console.error(err);
+        done();
+      }).catch(console.error);
   },
 
   /**
    * @return a promise that resolves when the view has been updated
    */
   onNewNode: function() {
     this.setActive(this.isViewVisibleAndNodeValid());
     return this.update();
@@ -427,16 +454,43 @@ LayoutView.prototype = {
       onlyRegionArea: true
     });
   },
 
   onHighlightMouseOut: function() {
     this.hideBoxModel();
   },
 
+  onGeometryButtonClick: function({target}) {
+    if (target.hasAttribute("checked")) {
+      target.removeAttribute("checked");
+      this.hideGeometryEditor();
+    } else {
+      target.setAttribute("checked", "true");
+      this.showGeometryEditor();
+    }
+  },
+
+  onPickerStarted: function() {
+    this.hideGeometryEditor();
+  },
+
+  onMarkupViewLeave: function() {
+    this.showGeometryEditor(true);
+  },
+
+  onMarkupViewNodeHover: function() {
+    this.hideGeometryEditor(false);
+  },
+
+  onWillNavigate: function() {
+    this._geometryEditorHighlighter.release().catch(console.error);
+    this._geometryEditorHighlighter = null;
+  },
+
   /**
    * Stop tracking reflows and hide all values when no node is selected or the
    * layout-view is hidden, otherwise track reflows and show values.
    * @param {Boolean} isActive
    */
   setActive: function(isActive) {
     if (isActive === this.isActive) {
       return;
@@ -454,27 +508,29 @@ LayoutView.prototype = {
   },
 
   /**
    * Compute the dimensions of the node and update the values in
    * the layoutview/view.xhtml document.
    * @return a promise that will be resolved when complete.
    */
   update: function() {
-    let lastRequest = Task.spawn((function*() {
+    let lastRequest = Task.spawn((function* () {
       if (!this.isViewVisibleAndNodeValid()) {
         return null;
       }
 
       let node = this.inspector.selection.nodeFront;
       let layout = yield this.inspector.pageStyle.getLayout(node, {
         autoMargins: this.isActive
       });
       let styleEntries = yield this.inspector.pageStyle.getApplied(node, {});
 
+      yield this.updateGeometryButton();
+
       // If a subsequent request has been made, wait for that one instead.
       if (this._lastRequest != lastRequest) {
         return this._lastRequest;
       }
 
       this._lastRequest = null;
       let width = layout.width;
       let height = layout.height;
@@ -543,17 +599,17 @@ LayoutView.prototype = {
       let newValue = width + "\u00D7" + height;
       if (this.sizeLabel.textContent != newValue) {
         this.sizeLabel.textContent = newValue;
       }
 
       this.elementRules = styleEntries.map(e => e.rule);
 
       this.inspector.emit("layoutview-updated");
-    }).bind(this)).then(null, console.error);
+    }).bind(this)).catch(console.error);
 
     this._lastRequest = lastRequest;
     return this._lastRequest;
   },
 
   /**
    * Update the text in the tooltip shown when hovering over a value to provide
    * information about the source CSS rule that sets this value.
@@ -603,16 +659,87 @@ LayoutView.prototype = {
    * Hide the box-model highlighter on the currently selected element
    */
   hideBoxModel: function() {
     let toolbox = this.inspector.toolbox;
 
     toolbox.highlighterUtils.unhighlight();
   },
 
+  /**
+   * Show the geometry editor highlighter on the currently selected element
+   * @param {Boolean} [showOnlyIfActive=false]
+   *   Indicates if the Geometry Editor should be shown only if it's active but
+   *   hidden.
+   */
+  showGeometryEditor: function(showOnlyIfActive = false) {
+    let toolbox = this.inspector.toolbox;
+    let nodeFront = this.inspector.selection.nodeFront;
+    let nodeGeometry = this.doc.getElementById("layout-geometry-editor");
+    let isActive = nodeGeometry.hasAttribute("checked");
+
+    if (showOnlyIfActive && !isActive) {
+      return;
+    }
+
+    if (this._geometryEditorHighlighter) {
+      this._geometryEditorHighlighter.show(nodeFront).catch(console.error);
+      return;
+    }
+
+    // instantiate Geometry Editor highlighter
+    toolbox.highlighterUtils
+      .getHighlighterByType("GeometryEditorHighlighter").then(highlighter => {
+        highlighter.show(nodeFront).catch(console.error);
+        this._geometryEditorHighlighter = highlighter;
+
+        // Hide completely the geometry editor if the picker is clicked
+        toolbox.on("picker-started", this.onPickerStarted);
+
+        // Temporary hide the geometry editor
+        this.inspector.markup.on("leave", this.onMarkupViewLeave);
+        this.inspector.markup.on("node-hover", this.onMarkupViewNodeHover);
+
+        // Release the actor on will-navigate event
+        this.inspector._target.once("will-navigate", this.onWillNavigate);
+      });
+  },
+
+  /**
+   * Hide the geometry editor highlighter on the currently selected element
+   * @param {Boolean} [updateButton=true]
+   *   Indicates if the Geometry Editor's button needs to be unchecked too
+   */
+  hideGeometryEditor: function(updateButton = true) {
+    if (this._geometryEditorHighlighter) {
+      this._geometryEditorHighlighter.hide().catch(console.error);
+    }
+
+    if (updateButton) {
+      let nodeGeometry = this.doc.getElementById("layout-geometry-editor");
+      nodeGeometry.removeAttribute("checked");
+    }
+  },
+
+  /**
+   * Update the visibility and the state of the geometry editor button,
+   * based on the selected node.
+   */
+  updateGeometryButton: Task.async(function* () {
+    let node = this.inspector.selection.nodeFront;
+    let isEditable = false;
+
+    if (node) {
+      isEditable = yield this.inspector.pageStyle.isPositionEditable(node);
+    }
+
+    let nodeGeometry = this.doc.getElementById("layout-geometry-editor");
+    nodeGeometry.style.visibility = isEditable ? "visible" : "hidden";
+  }),
+
   manageOverflowingText: function(span) {
     let classList = span.parentNode.classList;
 
     if (classList.contains("layout-left") ||
         classList.contains("layout-right")) {
       let force = span.textContent.length > LONG_TEXT_ROTATE_LIMIT;
       classList.toggle("layout-rotate", force);
     }
--- a/devtools/client/inspector/markup/markup.js
+++ b/devtools/client/inspector/markup/markup.js
@@ -204,16 +204,18 @@ MarkupView.prototype = {
     if (this._hoveredNode !== container.node) {
       if (container.node.nodeType !== Ci.nsIDOMNode.TEXT_NODE) {
         this._showBoxModel(container.node);
       } else {
         this._hideBoxModel();
       }
     }
     this._showContainerAsHovered(container.node);
+
+    this.emit("node-hover");
   },
 
   /**
    * Executed on each mouse-move while a node is being dragged in the view.
    * Auto-scrolls the view to reveal nodes below the fold to drop the dragged
    * node in.
    */
   _autoScroll: function(event) {
@@ -336,16 +338,18 @@ MarkupView.prototype = {
       return;
     }
 
     this._hideBoxModel(true);
     if (this._hoveredNode) {
       this.getContainer(this._hoveredNode).hovered = false;
     }
     this._hoveredNode = null;
+
+    this.emit("leave");
   },
 
   /**
    * Show the box model highlighter on a given node front
    *
    * @param  {NodeFront} nodeFront
    *         The node to show the highlighter for
    * @return {Promise} Resolves when the highlighter for this nodeFront is
--- a/devtools/client/inspector/test/browser.ini
+++ b/devtools/client/inspector/test/browser.ini
@@ -52,16 +52,17 @@ support-files =
 [browser_inspector_highlighter-csstransform_01.js]
 [browser_inspector_highlighter-csstransform_02.js]
 [browser_inspector_highlighter-embed.js]
 [browser_inspector_highlighter-geometry_01.js]
 [browser_inspector_highlighter-geometry_02.js]
 [browser_inspector_highlighter-geometry_03.js]
 [browser_inspector_highlighter-geometry_04.js]
 [browser_inspector_highlighter-geometry_05.js]
+[browser_inspector_highlighter-geometry_06.js]
 [browser_inspector_highlighter-hover_01.js]
 [browser_inspector_highlighter-hover_02.js]
 [browser_inspector_highlighter-hover_03.js]
 [browser_inspector_highlighter-iframes_01.js]
 [browser_inspector_highlighter-iframes_02.js]
 [browser_inspector_highlighter-inline.js]
 [browser_inspector_highlighter-keybinding_01.js]
 [browser_inspector_highlighter-keybinding_02.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-geometry_06.js
@@ -0,0 +1,166 @@
+/* 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";
+
+// Test that the geometry editor resizes properly an element on all sides,
+// with different unit measures, and that arrow/handlers are updated correctly.
+
+const TEST_URL = URL_ROOT + "doc_inspector_highlighter-geometry_01.html";
+const ID = "geometry-editor-";
+const HIGHLIGHTER_TYPE = "GeometryEditorHighlighter";
+
+const SIDES = ["top", "right", "bottom", "left"];
+
+// The object below contains all the tests for this unit test.
+// The property's name is the test's description, that points to an
+// object contains the steps (what side of the geometry editor to drag,
+// the amount of pixels) and the expectation.
+const TESTS = {
+  "Drag top's handler along x and y, south-east direction": {
+    "expects": "Only y axis is used to updated the top's element value",
+    "drag": "top",
+    "by": {x: 10, y: 10}
+  },
+  "Drag right's handler along x and y, south-east direction": {
+    "expects": "Only x axis is used to updated the right's element value",
+    "drag": "right",
+    "by": {x: 10, y: 10}
+  },
+  "Drag bottom's handler along x and y, south-east direction": {
+    "expects": "Only y axis is used to updated the bottom's element value",
+    "drag": "bottom",
+    "by": {x: 10, y: 10}
+  },
+  "Drag left's handler along x and y, south-east direction": {
+    "expects": "Only y axis is used to updated the left's element value",
+    "drag": "left",
+    "by": {x: 10, y: 10}
+  },
+  "Drag top's handler along x and y, north-west direction": {
+    "expects": "Only y axis is used to updated the top's element value",
+    "drag": "top",
+    "by": {x: -20, y: -20}
+  },
+  "Drag right's handler along x and y, north-west direction": {
+    "expects": "Only x axis is used to updated the right's element value",
+    "drag": "right",
+    "by": {x: -20, y: -20}
+  },
+  "Drag bottom's handler along x and y, north-west direction": {
+    "expects": "Only y axis is used to updated the bottom's element value",
+    "drag": "bottom",
+    "by": {x: -20, y: -20}
+  },
+  "Drag left's handler along x and y, north-west direction": {
+    "expects": "Only y axis is used to updated the left's element value",
+    "drag": "left",
+    "by": {x: -20, y: -20}
+  }
+};
+
+add_task(function* () {
+  let inspector = yield openInspectorForURL(TEST_URL);
+  let helper = yield getHighlighterHelperFor(HIGHLIGHTER_TYPE)(inspector);
+
+  helper.prefix = ID;
+
+  let { show, hide, finalize } = helper;
+
+  info("Showing the highlighter");
+  yield show("#node2");
+
+  for (let desc in TESTS) {
+    yield executeTest(helper, desc, TESTS[desc]);
+  }
+
+  info("Hiding the highlighter");
+  yield hide();
+  yield finalize();
+});
+
+function* executeTest(helper, desc, data) {
+  info(desc);
+
+  ok((yield areElementAndHighlighterMovedCorrectly(
+    helper, data.drag, data.by)), data.expects);
+}
+
+function* areElementAndHighlighterMovedCorrectly(helper, side, by) {
+  let { mouse, reflow, highlightedNode } = helper;
+
+  let {x, y} = yield getHandlerCoords(helper, side);
+
+  let dx = x + by.x;
+  let dy = y + by.y;
+
+  let beforeDragStyle = yield highlightedNode.getComputedStyle();
+
+  // simulate drag & drop
+  yield mouse.down(x, y);
+  yield mouse.move(dx, dy);
+  yield mouse.up();
+
+  yield reflow();
+
+  info(`Checking ${side} handler is moved correctly`);
+  yield isHandlerPositionUpdated(helper, side, x, y, by);
+
+  let delta = (side === "left" || side === "right") ? by.x : by.y;
+  delta = delta * ((side === "right" || side === "bottom") ? -1 : 1);
+
+  info("Checking element's sides are correct after drag & drop");
+  return yield areElementSideValuesCorrect(highlightedNode, beforeDragStyle,
+                                           side, delta);
+}
+
+function* isHandlerPositionUpdated(helper, name, x, y, by) {
+  let {x: afterDragX, y: afterDragY} = yield getHandlerCoords(helper, name);
+
+  if (name === "left" || name === "right") {
+    is(afterDragX, x + by.x,
+      `${name} handler's x axis updated.`);
+    is(afterDragY, y,
+      `${name} handler's y axis unchanged.`);
+  } else {
+    is(afterDragX, x,
+      `${name} handler's x axis unchanged.`);
+    is(afterDragY, y + by.y,
+      `${name} handler's y axis updated.`);
+  }
+}
+
+function* areElementSideValuesCorrect(node, beforeDragStyle, name, delta) {
+  let afterDragStyle = yield node.getComputedStyle();
+  let isSideCorrect = true;
+
+  for (let side of SIDES) {
+    let afterValue = Math.round(parseFloat(afterDragStyle[side].value));
+    let beforeValue = Math.round(parseFloat(beforeDragStyle[side].value));
+
+    if (side === name) {
+      // `isSideCorrect` is used only as test's return value, not to perform
+      // the actual test, because with `is` instead of `ok` we gather more
+      // information in case of failure
+      isSideCorrect = isSideCorrect && (afterValue === beforeValue + delta);
+
+      is(afterValue, beforeValue + delta,
+        `${side} is updated.`);
+    } else {
+      isSideCorrect = isSideCorrect && (afterValue === beforeValue);
+
+      is(afterValue, beforeValue,
+        `${side} is unchaged.`);
+    }
+  }
+
+  return isSideCorrect;
+}
+
+function* getHandlerCoords({getElementAttribute}, side) {
+  return {
+    x: Math.round(yield getElementAttribute("handler-" + side, "cx")),
+    y: Math.round(yield getElementAttribute("handler-" + side, "cy"))
+  };
+}
--- a/devtools/client/jar.mn
+++ b/devtools/client/jar.mn
@@ -217,16 +217,17 @@ devtools.jar:
     skin/images/itemToggle@2x.png (themes/images/itemToggle@2x.png)
     skin/images/itemArrow-dark-rtl.svg (themes/images/itemArrow-dark-rtl.svg)
     skin/images/itemArrow-dark-ltr.svg (themes/images/itemArrow-dark-ltr.svg)
     skin/images/itemArrow-rtl.svg (themes/images/itemArrow-rtl.svg)
     skin/images/itemArrow-ltr.svg (themes/images/itemArrow-ltr.svg)
     skin/images/noise.png (themes/images/noise.png)
     skin/images/dropmarker.svg (themes/images/dropmarker.svg)
     skin/layout.css (themes/layout.css)
+    skin/images/geometry-editor.svg (themes/images/geometry-editor.svg)
     skin/images/debugger-pause.png (themes/images/debugger-pause.png)
     skin/images/debugger-pause@2x.png (themes/images/debugger-pause@2x.png)
     skin/images/debugger-play.png (themes/images/debugger-play.png)
     skin/images/debugger-play@2x.png (themes/images/debugger-play@2x.png)
     skin/images/fast-forward.png (themes/images/fast-forward.png)
     skin/images/fast-forward@2x.png (themes/images/fast-forward@2x.png)
     skin/images/rewind.png (themes/images/rewind.png)
     skin/images/rewind@2x.png (themes/images/rewind@2x.png)
--- a/devtools/client/locales/en-US/layoutview.dtd
+++ b/devtools/client/locales/en-US/layoutview.dtd
@@ -11,13 +11,18 @@
   - You want to make that choice consistent across the developer tools.
   - A good criteria is the language in which you'd find the best
   - documentation on web development on the web. -->
 
 <!-- LOCALIZATION NOTE (*.tooltip): These tooltips are not regular tooltips.
   -  The text appears on the bottom right corner of the layout view when
   -  the corresponding box is hovered. -->
 
-<!ENTITY layoutViewTitle        "Box Model">
-<!ENTITY margin.tooltip         "margin">
-<!ENTITY border.tooltip         "border">
-<!ENTITY padding.tooltip        "padding">
-<!ENTITY content.tooltip        "content">
+<!ENTITY layoutViewTitle          "Box Model">
+<!ENTITY margin.tooltip           "margin">
+<!ENTITY border.tooltip           "border">
+<!ENTITY padding.tooltip          "padding">
+<!ENTITY content.tooltip          "content">
+
+<!-- LOCALIZATION NOTE: This label is displayed as a tooltip that appears when
+  -  hovering over the button that allows users to edit the position of an
+  -  element in the page. -->
+<!ENTITY geometry.button.tooltip  "Edit position">
new file mode 100644
--- /dev/null
+++ b/devtools/client/themes/images/geometry-editor.svg
@@ -0,0 +1,4 @@
+<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="#babec3">
+  <path d="M14,8 L12,8 L12,11.25 L12,12 L11.5,12 L3.5,12 L3,12 L3,11.75 L3,11.5 L3,8 L1,8 L1,8 L1,8.5 L1,9 L0,9 L0,8.5 L0,6.5 L0,6 L1,6 L1,6.5 L1,7 L3,7 L3,3.5 L3,3 L3.72222222,3 L3.72222222,3 L10.5555556,3 L11,3 L11,4 L10.5555556,4 L4,4 L4,11 L11,11 L11,3.5 L11,3 L12,3 L12,3.5 L12,7 L14,7 L14,6.5 L14,6 L15,6 L15,6.5 L15,8.5 L15,9 L14,9 L14,8.5 L14,8 Z M8,14 L8.5,14 L9,14 L9,15 L8.5,15 L6.5,15 L6,15 L6,14 L6.5,14 L7,14 L7,11.5 L7,11 L8,11 L8,11.5 L8,14 Z M7,1 L6.5,1 L6,1 L6,0 L6.5,0 L8.5,0 L9,0 L9,1 L8.5,1 L8,1 L8,3.5 L8,4 L7,4 L7,3.5 L7,1 L7,1 Z"/>
+  <path d="M3.5,9 C4.32842712,9 5,8.32842712 5,7.5 C5,6.67157288 4.32842712,6 3.5,6 C2.67157288,6 2,6.67157288 2,7.5 C2,8.32842712 2.67157288,9 3.5,9 Z M7.5,13 C8.32842712,13 9,12.3284271 9,11.5 C9,10.6715729 8.32842712,10 7.5,10 C6.67157288,10 6,10.6715729 6,11.5 C6,12.3284271 6.67157288,13 7.5,13 Z M11.5,9 C12.3284271,9 13,8.32842712 13,7.5 C13,6.67157288 12.3284271,6 11.5,6 C10.6715729,6 10,6.67157288 10,7.5 C10,8.32842712 10.6715729,9 11.5,9 Z M7.5,5 C8.32842712,5 9,4.32842712 9,3.5 C9,2.67157288 8.32842712,2 7.5,2 C6.67157288,2 6,2.67157288 6,3.5 C6,4.32842712 6.67157288,5 7.5,5 Z"/>
+</svg>
\ No newline at end of file
--- a/devtools/client/themes/layout.css
+++ b/devtools/client/themes/layout.css
@@ -331,8 +331,21 @@
 
 /* Hide all values when the view is inactive */
 
 #layout-container.inactive > #layout-header > #layout-element-position,
 #layout-container.inactive > #layout-header > #layout-element-size,
 #layout-container.inactive > #layout-main > p {
    visibility: hidden;
 }
+
+#layout-position-group {
+  display: flex;
+  align-items: center;
+}
+
+#layout-geometry-editor {
+  visibility: hidden;
+}
+
+#layout-geometry-editor::before {
+  background: url(images/geometry-editor.svg) no-repeat center center / 16px 16px;
+}
--- a/devtools/server/actors/highlighters.css
+++ b/devtools/server/actors/highlighters.css
@@ -24,16 +24,20 @@
      'pointer-events:auto;' on its container element. */
   pointer-events: none;
 }
 
 :-moz-native-anonymous .highlighter-container [hidden] {
   display: none;
 }
 
+:-moz-native-anonymous .highlighter-container [dragging] {
+  cursor: grabbing;
+}
+
 /* Box model highlighter */
 
 :-moz-native-anonymous .box-model-regions {
   opacity: 0.6;
 }
 
 /* Box model regions can be faded (see the onlyRegionArea option in
    highlighters.js) in order to only display certain regions. */
@@ -202,16 +206,17 @@
 }
 
 /* Element geometry highlighter */
 
 :-moz-native-anonymous .geometry-editor-root {
   /* The geometry editor can be interacted with, so it needs to react to
      pointer events */
   pointer-events: auto;
+  -moz-user-select: none;
 }
 
 :-moz-native-anonymous .geometry-editor-offset-parent {
   stroke: #08c;
   shape-rendering: crispEdges;
   stroke-dasharray: 5 3;
   fill: transparent;
 }
@@ -223,16 +228,45 @@
   opacity: 0.6;
 }
 
 :-moz-native-anonymous .geometry-editor-arrow {
   stroke: #08c;
   shape-rendering: crispEdges;
 }
 
+:-moz-native-anonymous .geometry-editor-root circle {
+  stroke: #08c;
+  fill: #87ceeb;
+}
+
+:-moz-native-anonymous .geometry-editor-handler-top,
+:-moz-native-anonymous .geometry-editor-handler-bottom {
+  cursor: ns-resize;
+}
+
+:-moz-native-anonymous .geometry-editor-handler-right,
+:-moz-native-anonymous .geometry-editor-handler-left {
+  cursor: ew-resize;
+}
+
+:-moz-native-anonymous [dragging] .geometry-editor-handler-top,
+:-moz-native-anonymous [dragging] .geometry-editor-handler-right,
+:-moz-native-anonymous [dragging] .geometry-editor-handler-bottom,
+:-moz-native-anonymous [dragging] .geometry-editor-handler-left {
+  cursor: grabbing;
+}
+
+:-moz-native-anonymous .geometry-editor-handler-top.dragging,
+:-moz-native-anonymous .geometry-editor-handler-right.dragging,
+:-moz-native-anonymous .geometry-editor-handler-bottom.dragging,
+:-moz-native-anonymous .geometry-editor-handler-left.dragging {
+  fill: #08c;
+}
+
 :-moz-native-anonymous .geometry-editor-label-bubble {
   fill: hsl(214,13%,24%);
   shape-rendering: crispEdges;
 }
 
 :-moz-native-anonymous .geometry-editor-label-text {
   fill: hsl(216,33%,97%);
   font: message-box;
--- a/devtools/server/actors/highlighters.js
+++ b/devtools/server/actors/highlighters.js
@@ -457,16 +457,18 @@ var CustomHighlighterActor = exports.Cus
   },
 
   destroy: function() {
     protocol.Actor.prototype.destroy.call(this);
     this.finalize();
     this._inspector = null;
   },
 
+  release: method(function() {}, { release: true }),
+
   /**
    * Show the highlighter.
    * This calls through to the highlighter instance's |show(node, options)|
    * method.
    *
    * Most custom highlighters are made to highlight DOM nodes, hence the first
    * NodeActor argument (NodeActor as in
    * devtools/server/actor/inspector).
--- a/devtools/server/actors/highlighters/geometry-editor.js
+++ b/devtools/server/actors/highlighters/geometry-editor.js
@@ -10,16 +10,22 @@ const {
   CanvasFrameAnonymousContentHelper, getCSSStyleRules, getComputedStyle,
   createSVGNode, createNode } = require("./utils/markup");
 
 const { setIgnoreLayoutChanges,
   getAdjustedQuads } = require("devtools/shared/layout/utils");
 
 const GEOMETRY_LABEL_SIZE = 6;
 
+// List of all DOM Events subscribed directly to the document from the
+// Geometry Editor highlighter
+const DOM_EVENTS = ["mousemove", "mouseup", "pagehide"];
+
+const _dragging = Symbol("geometry/dragging");
+
 /**
  * Element geometry properties helper that gives names of position and size
  * properties.
  */
 var GeoProp = {
   SIDES: ["top", "right", "bottom", "left"],
   SIZES: ["width", "height"],
 
@@ -108,16 +114,85 @@ function getOffsetParent(node) {
 
   return {
     element: offsetParent,
     dimension: {width, height}
   };
 }
 
 /**
+ * Get the list of geometry properties that are actually set on the provided
+ * node.
+ *
+ * @param {nsIDOMNode} node The node to analyze.
+ * @return {Map} A map indexed by property name and where the value is an
+ * object having the cssRule property.
+ */
+function getDefinedGeometryProperties(node) {
+  let props = new Map();
+  if (!node) {
+    return props;
+  }
+
+  // Get the list of css rules applying to the current node.
+  let cssRules = getCSSStyleRules(node);
+  for (let i = 0; i < cssRules.Count(); i++) {
+    let rule = cssRules.GetElementAt(i);
+    for (let name of GeoProp.allProps()) {
+      let value = rule.style.getPropertyValue(name);
+      if (value && value !== "auto") {
+        // getCSSStyleRules returns rules ordered from least to most specific
+        // so just override any previous properties we have set.
+        props.set(name, {
+          cssRule: rule
+        });
+      }
+    }
+  }
+
+  // Go through the inline styles last, only if the node supports inline style
+  // (e.g. pseudo elements don't have a style property)
+  if (node.style) {
+    for (let name of GeoProp.allProps()) {
+      let value = node.style.getPropertyValue(name);
+      if (value && value !== "auto") {
+        props.set(name, {
+          // There's no cssRule to store here, so store the node instead since
+          // node.style exists.
+          cssRule: node
+        });
+      }
+    }
+  }
+
+  // Post-process the list for invalid properties. This is done after the fact
+  // because of cases like relative positioning with both top and bottom where
+  // only top will actually be used, but both exists in css rules and computed
+  // styles.
+  let { position } = getComputedStyle(node);
+  for (let [name] of props) {
+    // Top/left/bottom/right on static positioned elements have no effect.
+    if (position === "static" && GeoProp.SIDES.indexOf(name) !== -1) {
+      props.delete(name);
+    }
+
+    // Bottom/right on relative positioned elements are only used if top/left
+    // are not defined.
+    let hasRightAndLeft = name === "right" && props.has("left");
+    let hasBottomAndTop = name === "bottom" && props.has("top");
+    if (position === "relative" && (hasRightAndLeft || hasBottomAndTop)) {
+      props.delete(name);
+    }
+  }
+
+  return props;
+}
+exports.getDefinedGeometryProperties = getDefinedGeometryProperties;
+
+/**
  * The GeometryEditor highlights an elements's top, left, bottom, right, width
  * and height dimensions, when they are set.
  *
  * To determine if an element has a set size and position, the highlighter lists
  * the CSS rules that apply to the element and checks for the top, left, bottom,
  * right, width and height properties.
  * The highlighter won't be shown if the element doesn't have any of these
  * properties set, but will be shown when at least 1 property is defined.
@@ -133,33 +208,48 @@ function getOffsetParent(node) {
 function GeometryEditorHighlighter(highlighterEnv) {
   AutoRefreshHighlighter.call(this, highlighterEnv);
 
   // The list of element geometry properties that can be set.
   this.definedProperties = new Map();
 
   this.markup = new CanvasFrameAnonymousContentHelper(highlighterEnv,
     this._buildMarkup.bind(this));
+
+  let { pageListenerTarget } = this.highlighterEnv;
+
+  // Register the geometry editor instance to all events we're interested in.
+  DOM_EVENTS.forEach(type => pageListenerTarget.addEventListener(type, this));
+
+  // Register the mousedown event for each Geometry Editor's handler.
+  // Those events are automatically removed when the markup is destroyed.
+  let onMouseDown = this.handleEvent.bind(this);
+
+  for (let side of GeoProp.SIDES) {
+    this.getElement("handler-" + side)
+      .addEventListener("mousedown", onMouseDown);
+  }
 }
 
 GeometryEditorHighlighter.prototype = extend(AutoRefreshHighlighter.prototype, {
   typeName: "GeometryEditorHighlighter",
 
   ID_CLASS_PREFIX: "geometry-editor-",
 
   _buildMarkup: function() {
     let container = createNode(this.win, {
       attributes: {"class": "highlighter-container"}
     });
 
     let root = createNode(this.win, {
       parent: container,
       attributes: {
         "id": "root",
-        "class": "root"
+        "class": "root",
+        "hidden": "true"
       },
       prefix: this.ID_CLASS_PREFIX
     });
 
     let svg = createSVGNode(this.win, {
       nodeType: "svg",
       parent: root,
       attributes: {
@@ -189,29 +279,42 @@ GeometryEditorHighlighter.prototype = ex
       attributes: {
         "class": "current-node",
         "id": "current-node",
         "hidden": "true"
       },
       prefix: this.ID_CLASS_PREFIX
     });
 
-    // Build the 4 side arrows and labels.
+    // Build the 4 side arrows, handlers and labels.
     for (let name of GeoProp.SIDES) {
       createSVGNode(this.win, {
         nodeType: "line",
         parent: svg,
         attributes: {
           "class": "arrow " + name,
           "id": "arrow-" + name,
           "hidden": "true"
         },
         prefix: this.ID_CLASS_PREFIX
       });
 
+      createSVGNode(this.win, {
+        nodeType: "circle",
+        parent: svg,
+        attributes: {
+          "class": "handler-" + name,
+          "id": "handler-" + name,
+          "r": "4",
+          "data-side": name,
+          "hidden": "true"
+        },
+        prefix: this.ID_CLASS_PREFIX
+      });
+
       // Labels are positioned by using a translated <g>. This group contains
       // a path and text that are themselves positioned using another translated
       // <g>. This is so that the label arrow points at the 0,0 coordinates of
       // parent <g>.
       let labelG = createSVGNode(this.win, {
         nodeType: "g",
         parent: svg,
         attributes: {
@@ -251,176 +354,173 @@ GeometryEditorHighlighter.prototype = ex
           "id": "label-text-" + name,
           "x": GeoProp.isHorizontal(name) ? "30" : "35",
           "y": "10"
         },
         prefix: this.ID_CLASS_PREFIX
       });
     }
 
-    // Build the width/height label and resize handle.
-    let labelSizeG = createSVGNode(this.win, {
-      nodeType: "g",
-      parent: svg,
-      attributes: {
-        "id": "label-size",
-        "hidden": "true"
-      },
-      prefix: this.ID_CLASS_PREFIX
-    });
-
-    let subSizeG = createSVGNode(this.win, {
-      nodeType: "g",
-      parent: labelSizeG,
-      attributes: {
-        "transform": "translate(-50 -10)"
-      }
-    });
-
-    createSVGNode(this.win, {
-      nodeType: "path",
-      parent: subSizeG,
-      attributes: {
-        "class": "label-bubble",
-        "d": "M0 0 L100 0 L100 20 L0 20z"
-      },
-      prefix: this.ID_CLASS_PREFIX
-    });
-
-    createSVGNode(this.win, {
-      nodeType: "text",
-      parent: subSizeG,
-      attributes: {
-        "class": "label-text",
-        "id": "label-text-size",
-        "x": "50",
-        "y": "10"
-      },
-      prefix: this.ID_CLASS_PREFIX
-    });
-
     return container;
   },
 
   destroy: function() {
+    // Avoiding exceptions if `destroy` is called multiple times; and / or the
+    // highlighter environment was already destroyed.
+    if (!this.highlighterEnv) {
+      return;
+    }
+
+    let { pageListenerTarget } = this.highlighterEnv;
+
+    DOM_EVENTS.forEach(type =>
+      pageListenerTarget.removeEventListener(type, this));
+
     AutoRefreshHighlighter.prototype.destroy.call(this);
 
     this.markup.destroy();
     this.definedProperties.clear();
     this.definedProperties = null;
     this.offsetParent = null;
   },
 
-  getElement: function(id) {
-    return this.markup.getElement(this.ID_CLASS_PREFIX + id);
-  },
-
-  /**
-   * Get the list of geometry properties that are actually set on the current
-   * node.
-   * @return {Map} A map indexed by property name and where the value is an
-   * object having the cssRule property.
-   */
-  getDefinedGeometryProperties: function() {
-    let props = new Map();
-    if (!this.currentNode) {
-      return props;
-    }
-
-    // Get the list of css rules applying to the current node.
-    let cssRules = getCSSStyleRules(this.currentNode);
-    for (let i = 0; i < cssRules.Count(); i++) {
-      let rule = cssRules.GetElementAt(i);
-      for (let name of GeoProp.allProps()) {
-        let value = rule.style.getPropertyValue(name);
-        if (value && value !== "auto") {
-          // getCSSStyleRules returns rules ordered from least to most specific
-          // so just override any previous properties we have set.
-          props.set(name, {
-            cssRule: rule
-          });
-        }
-      }
+  handleEvent: function(event, id) {
+    // No event handling if the highlighter is hidden
+    if (this.getElement("root").hasAttribute("hidden")) {
+      return;
     }
 
-    // Go through the inline styles last.
-    for (let name of GeoProp.allProps()) {
-      let value = this.currentNode.style.getPropertyValue(name);
-      if (value && value !== "auto") {
-        props.set(name, {
-          // There's no cssRule to store here, so store the node instead since
-          // node.style exists.
-          cssRule: this.currentNode
-        });
-      }
-    }
+    const { type, pageX, pageY } = event;
+
+    switch (type) {
+      case "pagehide":
+        this.destroy();
+        break;
+      case "mousedown":
+        // The mousedown event is intended only for the handler
+        if (!id) {
+          return;
+        }
+
+        let handlerSide = this.markup.getElement(id).getAttribute("data-side");
+
+        if (handlerSide) {
+          let side = handlerSide;
+          let sideProp = this.definedProperties.get(side);
+
+          if (!sideProp) {
+            return;
+          }
+
+          let value = sideProp.cssRule.style.getPropertyValue(side);
+          let computedValue = this.computedStyle.getPropertyValue(side);
+
+          let [unit] = value.match(/[^\d]+$/) || [""];
+
+          value = parseFloat(value);
+
+          let ratio = (value / parseFloat(computedValue)) || 1;
+          let dir = GeoProp.isInverted(side) ? -1 : 1;
+
+          // Store all the initial values needed for drag & drop
+          this[_dragging] = {
+            side,
+            value,
+            unit,
+            x: pageX,
+            y: pageY,
+            inc: ratio * dir
+          };
 
-    // Post-process the list for invalid properties. This is done after the fact
-    // because of cases like relative positioning with both top and bottom where
-    // only top will actually be used, but both exists in css rules and computed
-    // styles.
-    for (let [name] of props) {
-      let pos = this.computedStyle.position;
+          this.getElement("handler-" + side).classList.add("dragging");
+        }
+
+        this.getElement("root").setAttribute("dragging", "true");
+        break;
+      case "mouseup":
+        // If we're dragging, drop it.
+        if (this[_dragging]) {
+          let { side } = this[_dragging];
+          this.getElement("root").removeAttribute("dragging");
+          this.getElement("handler-" + side).classList.remove("dragging");
+          this[_dragging] = null;
+        }
+        break;
+      case "mousemove":
+        if (!this[_dragging]) {
+          return;
+        }
 
-      // Top/left/bottom/right on static positioned elements have no effect.
-      if (pos === "static" && GeoProp.SIDES.indexOf(name) !== -1) {
-        props.delete(name);
-      }
+        let { side, x, y, value, unit, inc } = this[_dragging];
+        let sideProps = this.definedProperties.get(side);
+
+        if (!sideProps) {
+          return;
+        }
+
+        let delta = (GeoProp.isHorizontal(side) ? pageX - x : pageY - y) * inc;
 
-      // Bottom/right on relative positioned elements are only used if top/left
-      // are not defined.
-      let hasRightAndLeft = name === "right" && props.has("left");
-      let hasBottomAndTop = name === "bottom" && props.has("top");
-      if (pos === "relative" && (hasRightAndLeft || hasBottomAndTop)) {
-        props.delete(name);
-      }
+        // The inline style has usually the priority over any other CSS rule
+        // set in stylesheets. However, if a rule has `!important` keyword,
+        // it will override the inline style too. To ensure Geometry Editor
+        // will always update the element, we have to add `!important` as
+        // well.
+        this.currentNode.style.setProperty(
+          side, (value + delta) + unit, "important");
+
+        break;
     }
+  },
 
-    return props;
+  getElement: function(id) {
+    return this.markup.getElement(this.ID_CLASS_PREFIX + id);
   },
 
   _show: function() {
     this.computedStyle = getComputedStyle(this.currentNode);
     let pos = this.computedStyle.position;
     // XXX: sticky positioning is ignored for now. To be implemented next.
     if (pos === "sticky") {
       this.hide();
       return false;
     }
 
     let hasUpdated = this._update();
     if (!hasUpdated) {
       this.hide();
       return false;
     }
+
+    this.getElement("root").removeAttribute("hidden");
+
     return true;
   },
 
   _update: function() {
     // At each update, the position or/and size may have changed, so get the
     // list of defined properties, and re-position the arrows and highlighters.
-    this.definedProperties = this.getDefinedGeometryProperties();
+    this.definedProperties = getDefinedGeometryProperties(this.currentNode);
 
     if (!this.definedProperties.size) {
       console.warn("The element does not have editable geometry properties");
       return false;
     }
 
     setIgnoreLayoutChanges(true);
 
     // Update the highlighters and arrows.
     this.updateOffsetParent();
     this.updateCurrentNode();
     this.updateArrows();
-    this.updateSize();
 
     // Avoid zooming the arrows when content is zoomed.
-    this.markup.scaleRootElement(this.currentNode, this.ID_CLASS_PREFIX + "root");
+    let node = this.currentNode;
+    this.markup.scaleRootElement(node, this.ID_CLASS_PREFIX + "root");
 
-    setIgnoreLayoutChanges(false, this.currentNode.ownerDocument.documentElement);
+    setIgnoreLayoutChanges(false, node.ownerDocument.documentElement);
     return true;
   },
 
   /**
    * Update the offset parent rectangle.
    * There are 3 different cases covered here:
    * - the node is absolutely/fixed positioned, and an offsetParent is defined
    *   (i.e. it's not just positioned in the viewport): the offsetParent node
@@ -483,62 +583,32 @@ GeometryEditorHighlighter.prototype = ex
                p4.x + "," + p4.y;
     box.setAttribute("points", attr);
     box.removeAttribute("hidden");
   },
 
   _hide: function() {
     setIgnoreLayoutChanges(true);
 
+    this.getElement("root").setAttribute("hidden", "true");
     this.getElement("current-node").setAttribute("hidden", "true");
     this.getElement("offset-parent").setAttribute("hidden", "true");
     this.hideArrows();
-    this.hideSize();
 
     this.definedProperties.clear();
 
-    setIgnoreLayoutChanges(false, this.currentNode.ownerDocument.documentElement);
+    setIgnoreLayoutChanges(false,
+      this.currentNode.ownerDocument.documentElement);
   },
 
   hideArrows: function() {
     for (let side of GeoProp.SIDES) {
       this.getElement("arrow-" + side).setAttribute("hidden", "true");
       this.getElement("label-" + side).setAttribute("hidden", "true");
-    }
-  },
-
-  hideSize: function() {
-    this.getElement("label-size").setAttribute("hidden", "true");
-  },
-
-  updateSize: function() {
-    this.hideSize();
-
-    let labels = [];
-    let width = this.definedProperties.get("width");
-    let height = this.definedProperties.get("height");
-
-    if (width) {
-      labels.push("↔ " + width.cssRule.style.getPropertyValue("width"));
-    }
-    if (height) {
-      labels.push("↕ " + height.cssRule.style.getPropertyValue("height"));
-    }
-
-    if (labels.length) {
-      let labelEl = this.getElement("label-size");
-      let labelTextEl = this.getElement("label-text-size");
-
-      let {bounds} = this.currentQuads.margin[0];
-
-      labelEl.setAttribute("transform", "translate(" +
-        (bounds.left + bounds.width / 2) + " " +
-        (bounds.top + bounds.height / 2) + ")");
-      labelEl.removeAttribute("hidden");
-      labelTextEl.setTextContent(labels.join(" "));
+      this.getElement("handler-" + side).setAttribute("hidden", "true");
     }
   },
 
   updateArrows: function() {
     this.hideArrows();
 
     // Position arrows always end at the node's margin box.
     let marginBox = this.currentQuads.margin[0].bounds;
@@ -595,27 +665,32 @@ GeometryEditorHighlighter.prototype = ex
                        sideProp.cssRule.style.getPropertyValue(side));
     }
   },
 
   updateArrow: function(side, mainStart, mainEnd, crossPos, labelValue) {
     let arrowEl = this.getElement("arrow-" + side);
     let labelEl = this.getElement("label-" + side);
     let labelTextEl = this.getElement("label-text-" + side);
+    let handlerEl = this.getElement("handler-" + side);
 
     // Position the arrow <line>.
     arrowEl.setAttribute(GeoProp.axis(side) + "1", mainStart);
     arrowEl.setAttribute(GeoProp.crossAxis(side) + "1", crossPos);
     arrowEl.setAttribute(GeoProp.axis(side) + "2", mainEnd);
     arrowEl.setAttribute(GeoProp.crossAxis(side) + "2", crossPos);
     arrowEl.removeAttribute("hidden");
 
+    handlerEl.setAttribute("c" + GeoProp.axis(side), mainEnd);
+    handlerEl.setAttribute("c" + GeoProp.crossAxis(side), crossPos);
+    handlerEl.removeAttribute("hidden");
+
     // Position the label <text> in the middle of the arrow (making sure it's
     // not hidden below the fold).
-    let capitalize = str => str.substring(0, 1).toUpperCase() + str.substring(1);
+    let capitalize = str => str[0].toUpperCase() + str.substring(1);
     let winMain = this.win["inner" + capitalize(GeoProp.mainAxisSize(side))];
     let labelMain = mainStart + (mainEnd - mainStart) / 2;
     if ((mainStart > 0 && mainStart < winMain) ||
         (mainEnd > 0 && mainEnd < winMain)) {
       if (labelMain < GEOMETRY_LABEL_SIZE) {
         labelMain = GEOMETRY_LABEL_SIZE;
       } else if (labelMain > winMain - GEOMETRY_LABEL_SIZE) {
         labelMain = winMain - GEOMETRY_LABEL_SIZE;
--- a/devtools/server/actors/highlighters/utils/markup.js
+++ b/devtools/server/actors/highlighters/utils/markup.js
@@ -2,16 +2,17 @@
  * 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 { Cc, Ci, Cu } = require("chrome");
 const { getCurrentZoom,
   getRootBindingParent } = require("devtools/shared/layout/utils");
+const { on, emit } = require("sdk/event/core");
 
 const lazyContainer = {};
 
 loader.lazyRequireGetter(lazyContainer, "CssLogic",
   "devtools/shared/inspector/css-logic", true);
 exports.getComputedStyle = (node) =>
   lazyContainer.CssLogic.getComputedStyle(node);
 
@@ -32,16 +33,68 @@ exports.removePseudoClassLock = (...args
 exports.getCSSStyleRules = (...args) =>
   lazyContainer.DOMUtils.getCSSStyleRules(...args);
 
 const SVG_NS = "http://www.w3.org/2000/svg";
 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
 const STYLESHEET_URI = "resource://devtools/server/actors/" +
                        "highlighters.css";
 
+const _tokens = Symbol("classList/tokens");
+
+/**
+ * Shims the element's `classList` for anonymous content elements; used
+ * internally by `CanvasFrameAnonymousContentHelper.getElement()` method.
+ */
+function ClassList(className) {
+  let trimmed = (className || "").trim();
+  this[_tokens] = trimmed ? trimmed.split(/\s+/) : [];
+}
+
+ClassList.prototype = {
+  item(index) {
+    return this[_tokens][index];
+  },
+  contains(token) {
+    return this[_tokens].includes(token);
+  },
+  add(token) {
+    if (!this.contains(token)) {
+      this[_tokens].push(token);
+    }
+    emit(this, "update");
+  },
+  remove(token) {
+    let index = this[_tokens].indexOf(token);
+
+    if (index > -1) {
+      this[_tokens].splice(index, 1);
+    }
+    emit(this, "update");
+  },
+  toggle(token) {
+    if (this.contains(token)) {
+      this.remove(token);
+    } else {
+      this.add(token);
+    }
+  },
+  get length() {
+    return this[_tokens].length;
+  },
+  [Symbol.iterator]: function* () {
+    for (let i = 0; i < this.tokens.length; i++) {
+      yield this[_tokens][i];
+    }
+  },
+  toString() {
+    return this[_tokens].join(" ");
+  }
+};
+
 /**
  * Is this content window a XUL window?
  * @param {Window} window
  * @return {Boolean}
  */
 function isXUL(window) {
   return window.document.documentElement.namespaceURI === XUL_NS;
 }
@@ -271,16 +324,20 @@ CanvasFrameAnonymousContentHelper.protot
   },
 
   removeAttributeForElement: function(id, name) {
     if (this.content) {
       this.content.removeAttributeForElement(id, name);
     }
   },
 
+  hasAttributeForElement: function(id, name) {
+    return typeof this.getAttributeForElement(id, name) === "string";
+  },
+
   /**
    * Add an event listener to one of the elements inserted in the canvasFrame
    * native anonymous container.
    * Like other methods in this helper, this requires the ID of the element to
    * be passed in.
    *
    * Note that if the content page navigates, the event listeners won't be
    * added again.
@@ -393,29 +450,36 @@ CanvasFrameAnonymousContentHelper.protot
       for (let [type] of this.listeners) {
         target.removeEventListener(type, this, true);
       }
     }
     this.listeners.clear();
   },
 
   getElement: function(id) {
-    let self = this;
+    let classList = new ClassList(this.getAttributeForElement(id, "class"));
+
+    on(classList, "update", () => {
+      this.setAttributeForElement(id, "class", classList.toString());
+    });
+
     return {
-      getTextContent: () => self.getTextContentForElement(id),
-      setTextContent: text => self.setTextContentForElement(id, text),
-      setAttribute: (name, value) => self.setAttributeForElement(id, name, value),
-      getAttribute: name => self.getAttributeForElement(id, name),
-      removeAttribute: name => self.removeAttributeForElement(id, name),
+      getTextContent: () => this.getTextContentForElement(id),
+      setTextContent: text => this.setTextContentForElement(id, text),
+      setAttribute: (name, val) => this.setAttributeForElement(id, name, val),
+      getAttribute: name => this.getAttributeForElement(id, name),
+      removeAttribute: name => this.removeAttributeForElement(id, name),
+      hasAttribute: name => this.hasAttributeForElement(id, name),
       addEventListener: (type, handler) => {
-        return self.addEventListenerForElement(id, type, handler);
+        return this.addEventListenerForElement(id, type, handler);
       },
       removeEventListener: (type, handler) => {
-        return self.removeEventListenerForElement(id, type, handler);
-      }
+        return this.removeEventListenerForElement(id, type, handler);
+      },
+      classList
     };
   },
 
   get content() {
     if (!this._content || Cu.isDeadWrapper(this._content)) {
       return null;
     }
     return this._content;
--- a/devtools/server/actors/styles.js
+++ b/devtools/server/actors/styles.js
@@ -6,16 +6,19 @@
 
 const {Cc, Ci} = require("chrome");
 const promise = require("promise");
 const protocol = require("devtools/server/protocol");
 const {Arg, Option, method, RetVal, types} = protocol;
 const events = require("sdk/event/core");
 const {Class} = require("sdk/core/heritage");
 const {LongStringActor} = require("devtools/server/actors/string");
+const {
+  getDefinedGeometryProperties
+} = require("devtools/server/actors/highlighters/geometry-editor");
 
 // This will also add the "stylesheet" actor type for protocol.js to recognize
 const {UPDATE_PRESERVING_RULES, UPDATE_GENERAL} =
       require("devtools/server/actors/stylesheets");
 
 loader.lazyRequireGetter(this, "CSS", "CSS");
 
 loader.lazyGetter(this, "CssLogic", () => {
@@ -560,17 +563,17 @@ var PageStyleActor = protocol.ActorClass
    *   `filter`: A string filter that affects the "matched" handling.
    *     'user': Include properties from user style sheets.
    *     'ua': Include properties from user and user-agent sheets.
    *     Default value is 'ua'
    *   `inherited`: Include styles inherited from parent nodes.
    *   `matchedSelectors`: Include an array of specific selectors that
    *     caused this rule to match its node.
    */
-  getApplied: method(Task.async(function*(node, options) {
+  getApplied: method(Task.async(function* (node, options) {
     if (!node) {
       return {entries: [], rules: [], sheets: []};
     }
 
     this.cssLogic.highlight(node.rawNode);
     let entries = [];
     entries = entries.concat(this._getAllElementRules(node, undefined,
                                                       options));
@@ -592,16 +595,34 @@ var PageStyleActor = protocol.ActorClass
   }),
 
   _hasInheritedProps: function(style) {
     return Array.prototype.some.call(style, prop => {
       return DOMUtils.isInheritedProperty(prop);
     });
   },
 
+  isPositionEditable: method(Task.async(function* (node) {
+    if (!node || node.rawNode.nodeType !== node.rawNode.ELEMENT_NODE) {
+      return false;
+    }
+
+    let props = getDefinedGeometryProperties(node.rawNode);
+
+    // Elements with only `width` and `height` are currently not considered
+    // editable.
+    return props.has("top") ||
+           props.has("right") ||
+           props.has("left") ||
+           props.has("bottom");
+  }), {
+    request: { node: Arg(0, "domnode")},
+    response: { value: RetVal("boolean") }
+  }),
+
   /**
    * Helper function for getApplied, gets all the rules from a given
    * element. See getApplied for documentation on parameters.
    * @param NodeActor node
    * @param bool inherited
    * @param object options
 
    * @return Array The rules for a given element. Each item in the
@@ -964,17 +985,17 @@ var PageStyleActor = protocol.ActorClass
    * @param {String} pseudoClasses The list of pseudo classes to append to the
    *        new selector.
    * @param {Boolean} editAuthored
    *        True if the selector should be updated by editing the
    *        authored text; false if the selector should be updated via
    *        CSSOM.
    * @returns {StyleRuleActor} the new rule
    */
-  addNewRule: method(Task.async(function*(node, pseudoClasses,
+  addNewRule: method(Task.async(function* (node, pseudoClasses,
                                           editAuthored = false) {
     let style = this.styleElement;
     let sheet = style.sheet;
     let cssRules = sheet.cssRules;
     let rawNode = node.rawNode;
 
     let selector;
     if (rawNode.id) {
@@ -1044,17 +1065,17 @@ protocol.FrontClass(PageStyleActor, {
   getMatchedSelectors: protocol.custom(function(node, property, options) {
     return this._getMatchedSelectors(node, property, options).then(ret => {
       return ret.matched;
     });
   }, {
     impl: "_getMatchedSelectors"
   }),
 
-  getApplied: protocol.custom(Task.async(function*(node, options = {}) {
+  getApplied: protocol.custom(Task.async(function* (node, options = {}) {
     // If the getApplied method doesn't recreate the style cache itself, this
     // means a call to cssLogic.highlight is required before trying to access
     // the applied rules. Issue a request to getLayout if this is the case.
     // See https://bugzilla.mozilla.org/show_bug.cgi?id=1103993#c16.
     if (!this._form.traits || !this._form.traits.getAppliedCreatesStyleCache) {
       yield this.getLayout(node);
     }
     let ret = yield this._getApplied(node, options);
@@ -1398,17 +1419,17 @@ var StyleRuleActor = protocol.ActorClass
 
   /**
    * Set the contents of the rule.  This rewrites the rule in the
    * stylesheet and causes it to be re-evaluated.
    *
    * @param {String} newText the new text of the rule
    * @returns the rule with updated properties
    */
-  setRuleText: method(Task.async(function*(newText) {
+  setRuleText: method(Task.async(function* (newText) {
     if (!this.canSetRuleText ||
         (this.type !== Ci.nsIDOMCSSRule.STYLE_RULE &&
          this.type !== Ci.nsIDOMCSSRule.KEYFRAME_RULE)) {
       throw new Error("invalid call to setRuleText");
     }
 
     let parentStyleSheet = this.pageStyle._sheetRef(this._parentSheet);
     let {str: cssText} = yield parentStyleSheet.getText();
@@ -1489,17 +1510,17 @@ var StyleRuleActor = protocol.ActorClass
    * @param {Boolean} editAuthored
    *        True if the selector should be updated by editing the
    *        authored text; false if the selector should be updated via
    *        CSSOM.
    *
    * @returns {CSSRule}
    *        The new CSS rule added
    */
-  _addNewSelector: Task.async(function*(value, editAuthored) {
+  _addNewSelector: Task.async(function* (value, editAuthored) {
     let rule = this.rawRule;
     let parentStyleSheet = this._parentSheet;
 
     // We know the selector modification is ok, so if the client asked
     // for the authored text to be edited, do it now.
     if (editAuthored) {
       let document = this.getDocument(this._parentSheet);
       try {
@@ -1548,17 +1569,17 @@ var StyleRuleActor = protocol.ActorClass
    * support was added in FF41.
    *
    * @param string value
    *        The new selector value
    * @returns boolean
    *        Returns a boolean if the selector in the stylesheet was modified,
    *        and false otherwise
    */
-  modifySelector: method(Task.async(function*(value) {
+  modifySelector: method(Task.async(function* (value) {
     if (this.type === ELEMENT_STYLE) {
       return false;
     }
 
     let document = this.getDocument(this._parentSheet);
     // Extract the selector, and pseudo elements and classes
     let [selector] = value.split(/(:{1,2}.+$)/);
     let selectorElement;
@@ -1806,17 +1827,17 @@ protocol.FrontClass(StyleRuleActor, {
         if (!source) {
           location.href = this.href;
         }
         this._originalLocation = location;
         return location;
       });
   },
 
-  modifySelector: protocol.custom(Task.async(function*(node, value) {
+  modifySelector: protocol.custom(Task.async(function* (node, value) {
     let response;
     if (this.supportsModifySelectorUnmatched) {
       // If the debugee supports adding unmatched rules (post FF41)
       if (this.canSetRuleText) {
         response = yield this.modifySelector2(node, value, true);
       } else {
         response = yield this.modifySelector2(node, value);
       }