diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js
index 5912c1b52079..014974bf6f44 100644
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1344,7 +1344,7 @@ pref("devtools.toolbox.sidebar.width", 500);
pref("devtools.toolbox.host", "bottom");
pref("devtools.toolbox.previousHost", "side");
pref("devtools.toolbox.selectedTool", "webconsole");
-pref("devtools.toolbox.toolbarSpec", '["splitconsole", "paintflashing toggle","tilt toggle","scratchpad","resize toggle","eyedropper","screenshot --fullpage", "rulers"]');
+pref("devtools.toolbox.toolbarSpec", '["splitconsole", "paintflashing toggle","tilt toggle","scratchpad","resize toggle","eyedropper","screenshot --fullpage", "rulers", "measure"]');
pref("devtools.toolbox.sideEnabled", true);
pref("devtools.toolbox.zoomValue", "1");
pref("devtools.toolbox.splitconsoleEnabled", false);
@@ -1361,6 +1361,7 @@ pref("devtools.command-button-responsive.enabled", true);
pref("devtools.command-button-eyedropper.enabled", false);
pref("devtools.command-button-screenshot.enabled", false);
pref("devtools.command-button-rulers.enabled", false);
+pref("devtools.command-button-measure.enabled", false);
// Inspector preferences
// Enable the Inspector
diff --git a/devtools/client/commandline/test/browser.ini b/devtools/client/commandline/test/browser.ini
index 9e9832c386a9..ed26456dc9c1 100644
--- a/devtools/client/commandline/test/browser.ini
+++ b/devtools/client/commandline/test/browser.ini
@@ -64,6 +64,7 @@ support-files =
support-files =
browser_cmd_jsb_script.jsi
[browser_cmd_listen.js]
+[browser_cmd_measure.js]
[browser_cmd_media.js]
support-files =
browser_cmd_media.html
diff --git a/devtools/client/commandline/test/browser_cmd_measure.js b/devtools/client/commandline/test/browser_cmd_measure.js
new file mode 100644
index 000000000000..7dd9bd9c9acb
--- /dev/null
+++ b/devtools/client/commandline/test/browser_cmd_measure.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests the highlight command, ensure no invalid arguments are given
+
+const TEST_PAGE = "data:text/html;charset=utf-8,foo";
+
+function test() {
+ return Task.spawn(spawnTest).then(finish, helpers.handleError);
+}
+
+function* spawnTest() {
+ let options = yield helpers.openTab(TEST_PAGE);
+ yield helpers.openToolbar(options);
+
+ yield helpers.audit(options, [
+ {
+ setup: "measure",
+ check: {
+ input: "measure",
+ markup: "VVVVVVV",
+ status: "VALID"
+ }
+ },
+ {
+ setup: "measure on",
+ check: {
+ input: "measure on",
+ markup: "VVVVVVVVEE",
+ status: "ERROR"
+ },
+ exec: {
+ output: "Error: Too many arguments"
+ }
+ },
+ {
+ setup: "measure --visible",
+ check: {
+ input: "measure --visible",
+ markup: "VVVVVVVVEEEEEEEEE",
+ status: "ERROR"
+ },
+ exec: {
+ output: "Error: Too many arguments"
+ }
+ }
+ ]);
+
+ yield helpers.closeToolbar(options);
+ yield helpers.closeTab(options);
+}
diff --git a/devtools/client/framework/toolbox.js b/devtools/client/framework/toolbox.js
index 7f9c1f7d78b7..453367938b6d 100644
--- a/devtools/client/framework/toolbox.js
+++ b/devtools/client/framework/toolbox.js
@@ -92,7 +92,8 @@ const ToolboxButtons = exports.ToolboxButtons = [
{ id: "command-button-scratchpad" },
{ id: "command-button-eyedropper" },
{ id: "command-button-screenshot" },
- { id: "command-button-rulers"}
+ { id: "command-button-rulers" },
+ { id: "command-button-measure" }
];
/**
diff --git a/devtools/client/inspector/test/browser.ini b/devtools/client/inspector/test/browser.ini
index 5a0585fc3bc3..146045654ce4 100644
--- a/devtools/client/inspector/test/browser.ini
+++ b/devtools/client/inspector/test/browser.ini
@@ -63,6 +63,8 @@ skip-if = e10s # GCLI isn't e10s compatible. See bug 1128988.
[browser_inspector_highlighter-keybinding_02.js]
[browser_inspector_highlighter-keybinding_03.js]
[browser_inspector_highlighter-keybinding_04.js]
+[browser_inspector_highlighter-measure_01.js]
+[browser_inspector_highlighter-measure_02.js]
[browser_inspector_highlighter-options.js]
[browser_inspector_highlighter-rect_01.js]
[browser_inspector_highlighter-rect_02.js]
diff --git a/devtools/client/inspector/test/browser_inspector_highlighter-measure_01.js b/devtools/client/inspector/test/browser_inspector_highlighter-measure_01.js
new file mode 100644
index 000000000000..5a398b47925d
--- /dev/null
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-measure_01.js
@@ -0,0 +1,88 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const TEST_URL = `data:text/html;charset=utf-8,
+
+
`;
+
+const PREFIX = "measuring-tool-highlighter-";
+const HIGHLIGHTER_TYPE = "MeasuringToolHighlighter";
+
+const X = 32;
+const Y = 20;
+
+add_task(function*() {
+ let helper = yield openInspectorForURL(TEST_URL)
+ .then(getHighlighterHelperFor(HIGHLIGHTER_TYPE));
+
+ let { finalize } = helper;
+
+ helper.prefix = PREFIX;
+
+ yield isHiddenByDefault(helper);
+ yield areLabelsHiddenByDefaultWhenShows(helper);
+ yield areLabelsProperlyDisplayedWhenMouseMoved(helper);
+
+ yield finalize();
+});
+
+function* isHiddenByDefault({isElementHidden}) {
+ info("Checking the highlighter is hidden by default");
+
+ let hidden = yield isElementHidden("elements");
+ ok(hidden, "highlighter's root is hidden by default");
+
+ hidden = yield isElementHidden("label-size");
+ ok(hidden, "highlighter's label size is hidden by default");
+
+ hidden = yield isElementHidden("label-position");
+ ok(hidden, "highlighter's label position is hidden by default");
+}
+
+function* areLabelsHiddenByDefaultWhenShows({isElementHidden, show}) {
+ info("Checking the highlighter is displayed when asked");
+
+ yield show();
+
+ let hidden = yield isElementHidden("elements");
+ is(hidden, false, "highlighter is visible after show");
+
+ hidden = yield isElementHidden("label-size");
+ ok(hidden, "label's size still hidden");
+
+ hidden = yield isElementHidden("label-position");
+ ok(hidden, "label's position still hidden");
+}
+
+function* areLabelsProperlyDisplayedWhenMouseMoved({isElementHidden,
+ synthesizeMouse, getElementTextContent}) {
+ info("Checking labels are properly displayed when mouse moved");
+
+ yield synthesizeMouse({
+ selector: ":root",
+ options: {type: "mousemove"},
+ x: X,
+ y: Y
+ });
+
+ let hidden = yield isElementHidden("label-position");
+ is(hidden, false, "label's position is displayed after the mouse is moved");
+
+ hidden = yield isElementHidden("label-size");
+ ok(hidden, "label's size still hidden");
+
+ let text = yield getElementTextContent("label-position");
+
+ let [x, y] = text.replace(/ /g, "").split(/\n/);
+
+ is(+x, X, "label's position shows the proper X coord");
+ is(+y, Y, "label's position shows the proper Y coord");
+}
diff --git a/devtools/client/inspector/test/browser_inspector_highlighter-measure_02.js b/devtools/client/inspector/test/browser_inspector_highlighter-measure_02.js
new file mode 100644
index 000000000000..ff8536384543
--- /dev/null
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-measure_02.js
@@ -0,0 +1,130 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const TEST_URL = `data:text/html;charset=utf-8,
+
+
`;
+
+const PREFIX = "measuring-tool-highlighter-";
+const HIGHLIGHTER_TYPE = "MeasuringToolHighlighter";
+
+const SIDES = ["top", "right", "bottom", "left"];
+
+const X = 32;
+const Y = 20;
+const WIDTH = 160;
+const HEIGHT = 100;
+const HYPOTENUSE = Math.hypot(WIDTH, HEIGHT).toFixed(2);
+
+add_task(function*() {
+ let helper = yield openInspectorForURL(TEST_URL)
+ .then(getHighlighterHelperFor(HIGHLIGHTER_TYPE));
+
+ let { show, finalize } = helper;
+
+ helper.prefix = PREFIX;
+
+ yield show();
+
+ yield hasNoLabelsWhenStarts(helper);
+ yield hasSizeLabelWhenMoved(helper);
+ yield hasCorrectSizeLabelValue(helper);
+ yield hasSizeLabelAndGuidesWhenStops(helper);
+ yield hasCorrectSizeLabelValue(helper);
+
+ yield finalize();
+});
+
+function* hasNoLabelsWhenStarts({isElementHidden, synthesizeMouse}) {
+ info("Checking highlighter has no labels when we start to select");
+
+ yield synthesizeMouse({
+ selector: ":root",
+ options: {type: "mousedown"},
+ x: X,
+ y: Y
+ });
+
+ let hidden = yield isElementHidden("label-size");
+ ok(hidden, "label's size still hidden");
+
+ hidden = yield isElementHidden("label-position");
+ ok(hidden, "label's position still hidden");
+
+ info("Checking highlighter has no guides when we start to select");
+
+ let guidesHidden = true;
+ for (let side of SIDES) {
+ guidesHidden = guidesHidden && (yield isElementHidden("guide-" + side));
+ }
+
+ ok(guidesHidden, "guides are hidden during dragging");
+}
+
+function* hasSizeLabelWhenMoved({isElementHidden, synthesizeMouse}) {
+ info("Checking highlighter has size label when we select the area");
+
+ yield synthesizeMouse({
+ selector: ":root",
+ options: {type: "mousemove"},
+ x: X + WIDTH,
+ y: Y + HEIGHT
+ });
+
+ let hidden = yield isElementHidden("label-size");
+ is(hidden, false, "label's size is visible during selection");
+
+ hidden = yield isElementHidden("label-position");
+ ok(hidden, "label's position still hidden");
+
+ info("Checking highlighter has no guides when we select the area");
+
+ let guidesHidden = true;
+ for (let side of SIDES) {
+ guidesHidden = guidesHidden && (yield isElementHidden("guide-" + side));
+ }
+
+ ok(guidesHidden, "guides are hidden during selection");
+}
+
+function* hasSizeLabelAndGuidesWhenStops({isElementHidden, synthesizeMouse}) {
+ info("Checking highlighter has size label and guides when we stop");
+
+ yield synthesizeMouse({
+ selector: ":root",
+ options: {type: "mouseup"},
+ x: X + WIDTH,
+ y: Y + HEIGHT
+ });
+
+ let hidden = yield isElementHidden("label-size");
+ is(hidden, false, "label's size is visible when the selection is done");
+
+ hidden = yield isElementHidden("label-position");
+ ok(hidden, "label's position still hidden");
+
+ let guidesVisible = true;
+ for (let side of SIDES) {
+ guidesVisible = guidesVisible && !(yield isElementHidden("guide-" + side));
+ }
+
+ ok(guidesVisible, "guides are visible when the selection is done");
+}
+
+function* hasCorrectSizeLabelValue({getElementTextContent}) {
+ let text = yield getElementTextContent("label-size");
+
+ let [width, height, hypot] = text.match(/\d.*px/g);
+
+ is(parseFloat(width), WIDTH, "width on label's size is correct");
+ is(parseFloat(height), HEIGHT, "height on label's size is correct");
+ is(parseFloat(hypot), HYPOTENUSE, "hypotenuse on label's size is correct");
+}
diff --git a/devtools/client/inspector/test/head.js b/devtools/client/inspector/test/head.js
index b401bd0a51a2..f4dfae0306c9 100644
--- a/devtools/client/inspector/test/head.js
+++ b/devtools/client/inspector/test/head.js
@@ -480,3 +480,57 @@ function dispatchCommandEvent(node) {
false, false, null);
node.dispatchEvent(commandEvent);
}
+
+/**
+ * Encapsulate some common operations for highlighter's tests, to have
+ * the tests cleaner, without exposing directly `inspector`, `highlighter`, and
+ * `testActor` if not needed.
+ *
+ * @param {String}
+ * The highlighter's type
+ * @return
+ * A generator function that takes an object with `inspector` and `testActor`
+ * properties. (see `openInspector`)
+ */
+const getHighlighterHelperFor = (type) => Task.async(
+ function*({inspector, testActor}) {
+ let front = inspector.inspector;
+ let highlighter = yield front.getHighlighterByType(type);
+
+ let prefix = "";
+
+ return {
+ set prefix(value) {
+ prefix = value;
+ },
+
+ show: function*(selector = ":root") {
+ let node = yield getNodeFront(selector, inspector);
+ yield highlighter.show(node);
+ },
+
+ isElementHidden: function*(id) {
+ return (yield testActor.getHighlighterNodeAttribute(
+ prefix + id, "hidden", highlighter)) === "true";
+ },
+
+ getElementTextContent: function*(id) {
+ return yield testActor.getHighlighterNodeTextContent(
+ prefix + id, highlighter);
+ },
+
+ getElementAttribute: function*(id, name) {
+ return yield testActor.getHighlighterNodeAttribute(
+ prefix + id, name, highlighter);
+ },
+
+ synthesizeMouse: function*(options) {
+ yield testActor.synthesizeMouse(options);
+ },
+
+ finalize: function*() {
+ yield highlighter.finalize();
+ }
+ };
+ }
+);
diff --git a/devtools/client/jar.mn b/devtools/client/jar.mn
index 3c85d7c92dce..f4ea632ad978 100644
--- a/devtools/client/jar.mn
+++ b/devtools/client/jar.mn
@@ -205,6 +205,8 @@ devtools.jar:
skin/themes/images/command-eyedropper@2x.png (themes/images/command-eyedropper@2x.png)
skin/themes/images/command-rulers.png (themes/images/command-rulers.png)
skin/themes/images/command-rulers@2x.png (themes/images/command-rulers@2x.png)
+ skin/themes/images/command-measure.png (themes/images/command-measure.png)
+ skin/themes/images/command-measure@2x.png (themes/images/command-measure@2x.png)
skin/themes/markup-view.css (themes/markup-view.css)
skin/themes/images/editor-error.png (themes/images/editor-error.png)
skin/themes/images/editor-breakpoint.png (themes/images/editor-breakpoint.png)
diff --git a/devtools/client/themes/images/command-measure.png b/devtools/client/themes/images/command-measure.png
new file mode 100644
index 000000000000..b7a58f4c5d5c
Binary files /dev/null and b/devtools/client/themes/images/command-measure.png differ
diff --git a/devtools/client/themes/images/command-measure@2x.png b/devtools/client/themes/images/command-measure@2x.png
new file mode 100644
index 000000000000..bb82b59b8ee7
Binary files /dev/null and b/devtools/client/themes/images/command-measure@2x.png differ
diff --git a/devtools/client/themes/toolbars.inc.css b/devtools/client/themes/toolbars.inc.css
index d4253e7d5347..38fc630f82cf 100644
--- a/devtools/client/themes/toolbars.inc.css
+++ b/devtools/client/themes/toolbars.inc.css
@@ -768,6 +768,10 @@
background-image: url("chrome://devtools/skin/themes/images/command-rulers.png");
}
+#command-button-measure > image {
+ background-image: url("chrome://devtools/skin/themes/images/command-measure.png");
+}
+
@media (min-resolution: 1.1dppx) {
#command-button-paintflashing > image {
background-image: url("chrome://devtools/skin/themes/images/command-paintflashing@2x.png");
@@ -808,6 +812,10 @@
#command-button-rulers > image {
background-image: url("chrome://devtools/skin/themes/images/command-rulers@2x.png");
}
+
+ #command-button-measure > image {
+ background-image: url("chrome://devtools/skin/themes/images/command-measure@2x.png");
+ }
}
/* Tabs */
diff --git a/devtools/server/actors/highlighters.css b/devtools/server/actors/highlighters.css
index 02718d5f8ad1..1c82c28e3637 100644
--- a/devtools/server/actors/highlighters.css
+++ b/devtools/server/actors/highlighters.css
@@ -273,3 +273,62 @@
transform: rotate(-90deg);
text-anchor: end;
}
+
+/* Measuring Tool highlighter */
+
+:-moz-native-anonymous .measuring-tool-highlighter-root {
+ position: absolute;
+ top: 0;
+ left: 0;
+ pointer-events: auto;
+ cursor: crosshair;
+}
+
+:-moz-native-anonymous .measuring-tool-highlighter-root path {
+ shape-rendering: crispEdges;
+ fill: rgba(135, 206, 235, 0.6);
+ stroke: #08c;
+ pointer-events: none;
+}
+
+:-moz-native-anonymous .dragging path {
+ fill: rgba(135, 206, 235, 0.6);
+ stroke: #08c;
+ opacity: 0.45;
+}
+
+:-moz-native-anonymous .measuring-tool-highlighter-label-size,
+:-moz-native-anonymous .measuring-tool-highlighter-label-position {
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: inline-block;
+ border-radius: 4px;
+ padding: 4px;
+ white-space: pre-line;
+ font: message-box;
+ font-size: 10px;
+ pointer-events: none;
+ -moz-user-select: none;
+ box-sizing: border-box;
+}
+
+:-moz-native-anonymous .measuring-tool-highlighter-label-position {
+ color: #fff;
+ background: hsla(214, 13%, 24%, 0.8);
+}
+
+:-moz-native-anonymous .measuring-tool-highlighter-label-size {
+ color: hsl(216, 33%, 97%);
+ background: hsl(214, 13%, 24%);
+ line-height: 1.5em;
+}
+
+:-moz-native-anonymous .measuring-tool-highlighter-guide-top,
+:-moz-native-anonymous .measuring-tool-highlighter-guide-right,
+:-moz-native-anonymous .measuring-tool-highlighter-guide-bottom,
+:-moz-native-anonymous .measuring-tool-highlighter-guide-left {
+ stroke: #08c;
+ stroke-dasharray: 5 3;
+ shape-rendering: crispEdges;
+}
diff --git a/devtools/server/actors/highlighters.js b/devtools/server/actors/highlighters.js
index f7d3ac34c671..aeb4bd18c590 100644
--- a/devtools/server/actors/highlighters.js
+++ b/devtools/server/actors/highlighters.js
@@ -703,3 +703,7 @@ exports.GeometryEditorHighlighter = GeometryEditorHighlighter;
const { RulersHighlighter } = require("./highlighters/rulers");
register(RulersHighlighter);
exports.RulersHighlighter = RulersHighlighter;
+
+const { MeasuringToolHighlighter } = require("./highlighters/measuring-tool");
+register(MeasuringToolHighlighter);
+exports.MeasuringToolHighlighter = MeasuringToolHighlighter;
diff --git a/devtools/server/actors/highlighters/measuring-tool.js b/devtools/server/actors/highlighters/measuring-tool.js
new file mode 100644
index 000000000000..dae3dc58fd23
--- /dev/null
+++ b/devtools/server/actors/highlighters/measuring-tool.js
@@ -0,0 +1,562 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const events = require("sdk/event/core");
+const { getCurrentZoom,
+ setIgnoreLayoutChanges } = require("devtools/shared/layout/utils");
+const {
+ CanvasFrameAnonymousContentHelper,
+ createSVGNode, createNode } = require("./utils/markup");
+
+// Hard coded value about the size of measuring tool label, in order to
+// position and flip it when is needed.
+const LABEL_SIZE_MARGIN = 8;
+const LABEL_SIZE_WIDTH = 80;
+const LABEL_SIZE_HEIGHT = 52;
+const LABEL_POS_MARGIN = 4;
+const LABEL_POS_WIDTH = 40;
+const LABEL_POS_HEIGHT = 34;
+
+const SIDES = ["top", "right", "bottom", "left"];
+
+/**
+ * The MeasuringToolHighlighter is used to measure distances in a content page.
+ * It allows users to click and drag with their mouse to draw an area whose
+ * dimensions will be displayed in a tooltip next to it.
+ * This allows users to measure distances between elements on a page.
+ */
+function MeasuringToolHighlighter(highlighterEnv) {
+ this.env = highlighterEnv;
+ this.markup = new CanvasFrameAnonymousContentHelper(highlighterEnv,
+ this._buildMarkup.bind(this));
+
+ this.coords = {
+ x: 0,
+ y: 0
+ };
+
+ let { pageListenerTarget } = highlighterEnv;
+
+ pageListenerTarget.addEventListener("mousedown", this);
+ pageListenerTarget.addEventListener("mousemove", this);
+ pageListenerTarget.addEventListener("mouseleave", this);
+ pageListenerTarget.addEventListener("scroll", this);
+ pageListenerTarget.addEventListener("pagehide", this);
+}
+
+MeasuringToolHighlighter.prototype = {
+ typeName: "MeasuringToolHighlighter",
+
+ ID_CLASS_PREFIX: "measuring-tool-highlighter-",
+
+ _buildMarkup() {
+ let prefix = this.ID_CLASS_PREFIX;
+ let { window } = this.env;
+
+ let container = createNode(window, {
+ attributes: {"class": "highlighter-container"}
+ });
+
+ let root = createNode(window, {
+ parent: container,
+ attributes: {
+ "id": "root",
+ "class": "root",
+ },
+ prefix
+ });
+
+ let svg = createSVGNode(window, {
+ nodeType: "svg",
+ parent: root,
+ attributes: {
+ id: "elements",
+ "class": "elements",
+ width: "100%",
+ height: "100%",
+ hidden: "true"
+ },
+ prefix
+ });
+
+ createNode(window, {
+ nodeType: "label",
+ attributes: {
+ id: "label-size",
+ "class": "label-size",
+ "hidden": "true"
+ },
+ parent: root,
+ prefix
+ });
+
+ createNode(window, {
+ nodeType: "label",
+ attributes: {
+ id: "label-position",
+ "class": "label-position",
+ "hidden": "true"
+ },
+ parent: root,
+ prefix
+ });
+
+ // Creating a element in order to group all the paths below, that
+ // together represent the measuring tool; so that would be easier move them
+ // around
+ let g = createSVGNode(window, {
+ nodeType: "g",
+ attributes: {
+ id: "tool",
+ },
+ parent: svg,
+ prefix
+ });
+
+ createSVGNode(window, {
+ nodeType: "path",
+ attributes: {
+ id: "box-path"
+ },
+ parent: g,
+ prefix
+ });
+
+ createSVGNode(window, {
+ nodeType: "path",
+ attributes: {
+ id: "diagonal-path"
+ },
+ parent: g,
+ prefix
+ });
+
+ for (let side of SIDES) {
+ createSVGNode(window, {
+ nodeType: "line",
+ parent: svg,
+ attributes: {
+ "class": `guide-${side}`,
+ id: `guide-${side}`,
+ hidden: "true"
+ },
+ prefix
+ });
+ }
+
+ return container;
+ },
+
+ _update() {
+ let { window } = this.env;
+
+ setIgnoreLayoutChanges(true);
+
+ let zoom = getCurrentZoom(window);
+
+ let { documentElement } = window.document;
+
+ let width = Math.max(documentElement.clientWidth,
+ documentElement.scrollWidth,
+ documentElement.offsetWidth);
+
+ let height = Math.max(documentElement.clientHeight,
+ documentElement.scrollHeight,
+ documentElement.offsetHeight);
+
+ let { body } = window.document;
+
+ // get the size of the content document despite the compatMode
+ if (body) {
+ width = Math.max(width, body.scrollWidth, body.offsetWidth);
+ height = Math.max(height, body.scrollHeight, body.offsetHeight);
+ }
+
+ let { coords } = this;
+
+ let isZoomChanged = zoom !== coords.zoom;
+
+ if (isZoomChanged) {
+ coords.zoom = zoom;
+ this.updateLabel();
+ }
+
+ let isDocumentSizeChanged = width !== coords.documentWidth ||
+ height !== coords.documentHeight;
+
+ if (isDocumentSizeChanged) {
+ coords.documentWidth = width;
+ coords.documentHeight = height;
+ }
+
+ // If either the document's size or the zoom is changed since the last
+ // repaint, we update the tool's size as well.
+ if (isZoomChanged || isDocumentSizeChanged) {
+ this.updateViewport();
+ }
+
+ setIgnoreLayoutChanges(false, documentElement);
+
+ this._rafID = window.requestAnimationFrame(() => this._update());
+ },
+
+ _cancelUpdate() {
+ if (this._rafID) {
+ this.env.window.cancelAnimationFrame(this._rafID);
+ this._rafID = 0;
+ }
+ },
+
+ destroy() {
+ this.hide();
+
+ this._cancelUpdate();
+
+ let { pageListenerTarget } = this.env;
+
+ pageListenerTarget.removeEventListener("mousedown", this);
+ pageListenerTarget.removeEventListener("mousemove", this);
+ pageListenerTarget.removeEventListener("mouseup", this);
+ pageListenerTarget.removeEventListener("scroll", this);
+ pageListenerTarget.removeEventListener("pagehide", this);
+
+ this.markup.destroy();
+
+ events.emit(this, "destroy");
+ },
+
+ show() {
+ setIgnoreLayoutChanges(true);
+
+ this.getElement("elements").removeAttribute("hidden");
+
+ this._update();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ },
+
+ hide() {
+ setIgnoreLayoutChanges(true);
+
+ this.hideLabel("size");
+ this.hideLabel("position");
+
+ this.getElement("elements").setAttribute("hidden", "true");
+
+ this._cancelUpdate();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ },
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ },
+
+ setSize(w, h) {
+ this.setCoords(undefined, undefined, w, h);
+ },
+
+ setCoords(x, y, w, h) {
+ let { coords } = this;
+
+ if (typeof x !== "undefined") {
+ coords.x = x;
+ }
+
+ if (typeof y !== "undefined") {
+ coords.y = y;
+ }
+
+ if (typeof w !== "undefined") {
+ coords.w = w;
+ }
+
+ if (typeof h !== "undefined") {
+ coords.h = h;
+ }
+
+ setIgnoreLayoutChanges(true);
+
+ if (this._isDragging) {
+ this.updatePaths();
+ }
+
+ this.updateLabel();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ },
+
+ updatePaths() {
+ let { x, y, w, h } = this.coords;
+ let dir = `M0 0 L${w} 0 L${w} ${h} L0 ${h}z`;
+
+ // Adding correction to the line path, otherwise some pixels are drawn
+ // outside the main rectangle area.
+ let x1 = w > 0 ? 0.5 : 0;
+ let y1 = w < 0 && h < 0 ? -0.5 : 0;
+ let w1 = w + (h < 0 && w < 0 ? 0.5 : 0);
+ let h1 = h + (h > 0 && w > 0 ? -0.5 : 0);
+
+ let linedir = `M${x1} ${y1} L${w1} ${h1}`;
+
+ this.getElement("box-path").setAttribute("d", dir);
+ this.getElement("diagonal-path").setAttribute("d", linedir);
+ this.getElement("tool").setAttribute("transform", `translate(${x},${y})`);
+ },
+
+ updateLabel(type) {
+ type = type || this._isDragging ? "size" : "position";
+
+ let isSizeLabel = type === "size";
+
+ let label = this.getElement(`label-${type}`);
+
+ let origin = "top left";
+
+ let { innerWidth, innerHeight, scrollX, scrollY } = this.env.window;
+ let { x, y, w, h, zoom } = this.coords;
+ let scale = 1 / zoom;
+
+ w = w || 0;
+ h = h || 0;
+ x = (x || 0) + w;
+ y = (y || 0) + h;
+
+ let labelMargin, labelHeight, labelWidth;
+
+ if (isSizeLabel) {
+ labelMargin = LABEL_SIZE_MARGIN;
+ labelWidth = LABEL_SIZE_WIDTH;
+ labelHeight = LABEL_SIZE_HEIGHT;
+
+ let d = Math.hypot(w, h).toFixed(2);
+
+ label.setTextContent(`W: ${Math.abs(w)} px
+ H: ${Math.abs(h)} px
+ ↘: ${d}px`);
+ } else {
+ labelMargin = LABEL_POS_MARGIN;
+ labelWidth = LABEL_POS_WIDTH;
+ labelHeight = LABEL_POS_HEIGHT;
+
+ label.setTextContent(`${x}
+ ${y}`);
+ }
+
+ // Size used to position properly the label
+ let labelBoxWidth = (labelWidth + labelMargin) * scale;
+ let labelBoxHeight = (labelHeight + labelMargin) * scale;
+
+ let isGoingLeft = w < scrollX;
+ let isSizeGoingLeft = isSizeLabel && isGoingLeft;
+ let isExceedingLeftMargin = x - labelBoxWidth < scrollX;
+ let isExceedingRightMargin = x + labelBoxWidth > innerWidth + scrollX;
+ let isExceedingTopMargin = y - labelBoxHeight < scrollY;
+ let isExceedingBottomMargin = y + labelBoxHeight > innerHeight + scrollY;
+
+ if ((isSizeGoingLeft && !isExceedingLeftMargin) || isExceedingRightMargin) {
+ x -= labelBoxWidth;
+ origin = "top right";
+ } else {
+ x += labelMargin * scale;
+ }
+
+ if (isSizeLabel) {
+ y += isExceedingTopMargin ? labelMargin * scale : -labelBoxHeight;
+ } else {
+ y += isExceedingBottomMargin ? -labelBoxHeight : labelMargin * scale;
+ }
+
+ label.setAttribute("style", `
+ width: ${labelWidth}px;
+ height: ${labelHeight}px;
+ transform-origin: ${origin};
+ transform: translate(${x}px,${y}px) scale(${scale})
+ `);
+
+ if (!isSizeLabel) {
+ let labelSize = this.getElement("label-size");
+ let style = labelSize.getAttribute("style");
+
+ if (style) {
+ labelSize.setAttribute("style",
+ style.replace(/scale[^)]+\)/, `scale(${scale})`));
+ }
+ }
+ },
+
+ updateViewport() {
+ let { scrollX, scrollY, devicePixelRatio } = this.env.window;
+ let { documentWidth, documentHeight, zoom } = this.coords;
+
+ // Because `devicePixelRatio` is affected by zoom (see bug 809788),
+ // in order to get the "real" device pixel ratio, we need divide by `zoom`
+ let pixelRatio = devicePixelRatio / zoom;
+
+ // The "real" device pixel ratio is used to calculate the max stroke
+ // width we can actually assign: on retina, for instance, it would be 0.5,
+ // where on non high dpi monitor would be 1.
+ let minWidth = 1 / pixelRatio;
+ let strokeWidth = Math.min(minWidth, minWidth / zoom);
+
+ this.getElement("root").setAttribute("style",
+ `stroke-width:${strokeWidth};
+ width:${documentWidth}px;
+ height:${documentHeight}px;
+ transform: translate(${-scrollX}px,${-scrollY}px)`);
+ },
+
+ updateGuides() {
+ let { x, y, w, h } = this.coords;
+
+ let guide = this.getElement("guide-top");
+
+ guide.setAttribute("x1", "0");
+ guide.setAttribute("y1", y);
+ guide.setAttribute("x2", "100%");
+ guide.setAttribute("y2", y);
+
+ guide = this.getElement("guide-right");
+
+ guide.setAttribute("x1", x + w);
+ guide.setAttribute("y1", 0);
+ guide.setAttribute("x2", x + w);
+ guide.setAttribute("y2", "100%");
+
+ guide = this.getElement("guide-bottom");
+
+ guide.setAttribute("x1", "0");
+ guide.setAttribute("y1", y + h);
+ guide.setAttribute("x2", "100%");
+ guide.setAttribute("y2", y + h);
+
+ guide = this.getElement("guide-left");
+
+ guide.setAttribute("x1", x);
+ guide.setAttribute("y1", 0);
+ guide.setAttribute("x2", x);
+ guide.setAttribute("y2", "100%");
+ },
+
+ showLabel(type) {
+ setIgnoreLayoutChanges(true);
+
+ this.getElement(`label-${type}`).removeAttribute("hidden");
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ },
+
+ hideLabel(type) {
+ setIgnoreLayoutChanges(true);
+
+ this.getElement(`label-${type}`).setAttribute("hidden", "true");
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ },
+
+ showGuides() {
+ let prefix = this.ID_CLASS_PREFIX + "guide-";
+
+ for (let side of SIDES) {
+ this.markup.removeAttributeForElement(`${prefix + side}`, "hidden");
+ }
+ },
+
+ hideGuides() {
+ let prefix = this.ID_CLASS_PREFIX + "guide-";
+
+ for (let side of SIDES) {
+ this.markup.setAttributeForElement(`${prefix + side}`, "hidden", "true");
+ }
+ },
+
+ handleEvent(event) {
+ let scrollX, scrollY, innerWidth, innerHeight;
+ let x, y;
+
+ let { pageListenerTarget } = this.env;
+
+ switch (event.type) {
+ case "mousedown":
+ if (event.button) {
+ return;
+ }
+
+ this._isDragging = true;
+
+ let { window } = this.env;
+
+ ({ scrollX, scrollY } = window);
+ x = event.clientX + scrollX;
+ y = event.clientY + scrollY;
+
+ pageListenerTarget.addEventListener("mouseup", this);
+
+ setIgnoreLayoutChanges(true);
+
+ this.getElement("tool").setAttribute("class", "dragging");
+
+ this.hideLabel("size");
+ this.hideLabel("position");
+
+ this.hideGuides();
+ this.setCoords(x, y, 0, 0);
+
+ setIgnoreLayoutChanges(false, window.document.documentElement);
+
+ break;
+ case "mouseup":
+ this._isDragging = false;
+
+ pageListenerTarget.removeEventListener("mouseup", this);
+
+ setIgnoreLayoutChanges(true);
+
+ this.getElement("tool").removeAttribute("class", "");
+
+ // Shows the guides only if an actual area is selected
+ if (this.coords.w !== 0 && this.coords.h !== 0) {
+ this.updateGuides();
+ this.showGuides();
+ }
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+
+ break;
+ case "mousemove":
+ ({ scrollX, scrollY, innerWidth, innerHeight } = this.env.window);
+ x = event.clientX + scrollX;
+ y = event.clientY + scrollY;
+
+ let { coords } = this;
+
+ x = Math.min(innerWidth + scrollX - 1, Math.max(0 + scrollX, x));
+ y = Math.min(innerHeight + scrollY, Math.max(1 + scrollY, y));
+
+ this.setSize(x - coords.x, y - coords.y);
+
+ let type = this._isDragging ? "size" : "position";
+
+ this.showLabel(type);
+ break;
+ case "mouseleave":
+ if (!this._isDragging) {
+ this.hideLabel("position");
+ }
+ break;
+ case "scroll":
+ setIgnoreLayoutChanges(true);
+ this.updateViewport();
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+
+ break;
+ case "pagehide":
+ this.destroy();
+ break;
+ }
+ }
+};
+exports.MeasuringToolHighlighter = MeasuringToolHighlighter;
diff --git a/devtools/server/actors/highlighters/moz.build b/devtools/server/actors/highlighters/moz.build
index 7b5240e5b812..9dde37795102 100644
--- a/devtools/server/actors/highlighters/moz.build
+++ b/devtools/server/actors/highlighters/moz.build
@@ -13,6 +13,7 @@ DevToolsModules(
'box-model.js',
'css-transform.js',
'geometry-editor.js',
+ 'measuring-tool.js',
'rect.js',
'rulers.js',
'selector.js',
diff --git a/devtools/shared/gcli/commands/index.js b/devtools/shared/gcli/commands/index.js
index a7f83e3396ca..fe4c6e67e39e 100644
--- a/devtools/shared/gcli/commands/index.js
+++ b/devtools/shared/gcli/commands/index.js
@@ -65,6 +65,7 @@ exports.devtoolsModules = [
"devtools/shared/gcli/commands/inject",
"devtools/shared/gcli/commands/jsb",
"devtools/shared/gcli/commands/listen",
+ "devtools/shared/gcli/commands/measure",
"devtools/shared/gcli/commands/media",
"devtools/shared/gcli/commands/pagemod",
"devtools/shared/gcli/commands/paintflashing",
diff --git a/devtools/shared/gcli/commands/measure.js b/devtools/shared/gcli/commands/measure.js
new file mode 100644
index 000000000000..7f6233a95c9f
--- /dev/null
+++ b/devtools/shared/gcli/commands/measure.js
@@ -0,0 +1,112 @@
+/* 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/. */
+ /* globals getOuterId, getBrowserForTab */
+
+"use strict";
+
+const EventEmitter = require("devtools/shared/event-emitter");
+const eventEmitter = new EventEmitter();
+const events = require("sdk/event/core");
+
+loader.lazyRequireGetter(this, "getOuterId", "sdk/window/utils", true);
+loader.lazyRequireGetter(this, "getBrowserForTab", "sdk/tabs/utils", true);
+
+const l10n = require("gcli/l10n");
+require("devtools/server/actors/inspector");
+const { MeasuringToolHighlighter, HighlighterEnvironment } =
+ require("devtools/server/actors/highlighters");
+
+const highlighters = new WeakMap();
+const visibleHighlighters = new Set();
+
+const isCheckedFor = (tab) =>
+ tab ? visibleHighlighters.has(getBrowserForTab(tab).outerWindowID) : false;
+
+exports.items = [
+ // The client measure command is used to maintain the toolbar button state
+ // only and redirects to the server command to actually toggle the measuring
+ // tool (see `measure_server` below).
+ {
+ name: "measure",
+ runAt: "client",
+ description: l10n.lookup("measureDesc"),
+ manual: l10n.lookup("measureManual"),
+ buttonId: "command-button-measure",
+ buttonClass: "command-button command-button-invertable",
+ tooltipText: l10n.lookup("measureTooltip"),
+ state: {
+ isChecked: ({_tab}) => isCheckedFor(_tab),
+ onChange: (target, handler) => eventEmitter.on("changed", handler),
+ offChange: (target, handler) => eventEmitter.off("changed", handler)
+ },
+ exec: function*(args, context) {
+ let { target } = context.environment;
+
+ // Pipe the call to the server command.
+ let response = yield context.updateExec("measure_server");
+ let { visible, id } = response.data;
+
+ if (visible) {
+ visibleHighlighters.add(id);
+ } else {
+ visibleHighlighters.delete(id);
+ }
+
+ eventEmitter.emit("changed", { target });
+
+ // Toggle off the button when the page navigates because the measuring
+ // tool is removed automatically by the MeasuringToolHighlighter on the
+ // server then.
+ let onNavigate = () => {
+ visibleHighlighters.delete(id);
+ eventEmitter.emit("changed", { target });
+ };
+ target.off("will-navigate", onNavigate);
+ target.once("will-navigate", onNavigate);
+ }
+ },
+ // The server measure command is hidden by default, it's just used by the
+ // client command.
+ {
+ name: "measure_server",
+ runAt: "server",
+ hidden: true,
+ returnType: "highlighterVisibility",
+ exec: function(args, context) {
+ let env = context.environment;
+ let { document } = env;
+ let id = getOuterId(env.window);
+
+ // Calling the command again after the measuring tool has been shown once,
+ // hides it.
+ if (highlighters.has(document)) {
+ let { highlighter } = highlighters.get(document);
+ highlighter.destroy();
+ return {visible: false, id};
+ }
+
+ // Otherwise, display the measuring tool.
+ let environment = new HighlighterEnvironment();
+ environment.initFromWindow(env.window);
+ let highlighter = new MeasuringToolHighlighter(environment);
+
+ // Store the instance of the measuring tool highlighter for this document
+ // so we can hide it later.
+ highlighters.set(document, { highlighter, environment });
+
+ // Listen to the highlighter's destroy event which may happen if the
+ // window is refreshed or closed with the measuring tool shown.
+ events.once(highlighter, "destroy", () => {
+ if (highlighters.has(document)) {
+ let { environment } = highlighters.get(document);
+ environment.destroy();
+ highlighters.delete(document);
+ }
+ });
+
+ highlighter.show();
+ return {visible: true, id};
+ }
+ }
+];
diff --git a/devtools/shared/gcli/commands/moz.build b/devtools/shared/gcli/commands/moz.build
index 109a6395b3a6..d902dc52f46a 100644
--- a/devtools/shared/gcli/commands/moz.build
+++ b/devtools/shared/gcli/commands/moz.build
@@ -17,6 +17,7 @@ DevToolsModules(
'inject.js',
'jsb.js',
'listen.js',
+ 'measure.js',
'media.js',
'pagemod.js',
'paintflashing.js',
diff --git a/toolkit/locales/en-US/chrome/global/devtools/gclicommands.properties b/toolkit/locales/en-US/chrome/global/devtools/gclicommands.properties
index 69e27c8148c5..1afa7698cac0 100644
--- a/toolkit/locales/en-US/chrome/global/devtools/gclicommands.properties
+++ b/toolkit/locales/en-US/chrome/global/devtools/gclicommands.properties
@@ -1618,3 +1618,17 @@ rulersManual=Toggle the horizontal and vertical rulers for the current page
# LOCALIZATION NOTE (rulersTooltip) A string displayed as the
# tooltip of button in devtools toolbox which toggles the rulers.
rulersTooltip=Toggle rulers for the page
+
+# LOCALIZATION NOTE (measureDesc) A very short description of the
+# 'measure' command. See measureManual for a fuller description of what
+# it does. This string is designed to be shown in a menu alongside the
+# command name, which is why it should be as short as possible.
+measureDesc=Measure a portion of the page
+
+# LOCALIZATION NOTE (measureManual) A fuller description of the 'measure'
+# command, displayed when the user asks for help on what it does.
+measureManual=Activate the measuring tool to measure an arbitrary area of the page
+
+# LOCALIZATION NOTE (measureTooltip) A string displayed as the
+# tooltip of button in devtools toolbox which toggles the measuring tool.
+measureTooltip=Measure a portion of the page