Bug 1139187 - Allow moving and resizing elements in content; r=pbro
authorMatteo Ferretti <mferretti@mozilla.com>
Thu, 17 Mar 2016 10:59:03 -0400
changeset 291316 bc7bd1234a7f992045ceb5e271abe8f9834bf4ba
parent 291315 62620131e03767e04753f5c26053af0759492e12
child 291317 5fd9689bcac1185b7c228ac3d1bdd1eadb5c9f00
push id74545
push userkwierso@gmail.com
push dateFri, 01 Apr 2016 23:05:42 +0000
treeherdermozilla-inbound@c410d4e20586 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspbro
bugs1139187
milestone48.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 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);
       }