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