summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/highlighters/measuring-tool.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/highlighters/measuring-tool.js')
-rw-r--r--devtools/server/actors/highlighters/measuring-tool.js763
1 files changed, 763 insertions, 0 deletions
diff --git a/devtools/server/actors/highlighters/measuring-tool.js b/devtools/server/actors/highlighters/measuring-tool.js
new file mode 100644
index 0000000000..1e760d4d48
--- /dev/null
+++ b/devtools/server/actors/highlighters/measuring-tool.js
@@ -0,0 +1,763 @@
+/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const {
+ getCurrentZoom,
+ getWindowDimensions,
+ setIgnoreLayoutChanges,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+
+// 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;
+
+// List of all DOM Events subscribed directly to the document from the
+// Measuring Tool highlighter
+const DOM_EVENTS = [
+ "mousedown",
+ "mousemove",
+ "mouseup",
+ "mouseleave",
+ "scroll",
+ "pagehide",
+];
+
+const SIDES = ["top", "right", "bottom", "left"];
+const HANDLERS = [...SIDES, "topleft", "topright", "bottomleft", "bottomright"];
+const HANDLER_SIZE = 6;
+
+/**
+ * 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.
+ */
+class MeasuringToolHighlighter {
+ constructor(highlighterEnv) {
+ this.env = highlighterEnv;
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+
+ this.coords = {
+ x: 0,
+ y: 0,
+ };
+
+ const { pageListenerTarget } = highlighterEnv;
+
+ // Register the measuring tool instance to all events we're interested in.
+ DOM_EVENTS.forEach(type => pageListenerTarget.addEventListener(type, this));
+ }
+
+ ID_CLASS_PREFIX = "measuring-tool-";
+
+ _buildMarkup() {
+ const prefix = this.ID_CLASS_PREFIX;
+
+ const container = this.markup.createNode({
+ attributes: { class: "highlighter-container" },
+ });
+
+ const root = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ hidden: "true",
+ },
+ prefix,
+ });
+
+ const svg = this.markup.createSVGNode({
+ nodeType: "svg",
+ parent: root,
+ attributes: {
+ id: "elements",
+ class: "elements",
+ width: "100%",
+ height: "100%",
+ },
+ prefix,
+ });
+
+ for (const side of SIDES) {
+ this.markup.createSVGNode({
+ nodeType: "line",
+ parent: svg,
+ attributes: {
+ class: `guide-${side}`,
+ id: `guide-${side}`,
+ hidden: "true",
+ },
+ prefix,
+ });
+ }
+
+ this.markup.createNode({
+ nodeType: "label",
+ attributes: {
+ id: "label-size",
+ class: "label-size",
+ hidden: "true",
+ },
+ parent: root,
+ prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "label",
+ attributes: {
+ id: "label-position",
+ class: "label-position",
+ hidden: "true",
+ },
+ parent: root,
+ prefix,
+ });
+
+ // Creating a <g> element in order to group all the paths below, that
+ // together represent the measuring tool; so that would be easier move them
+ // around
+ const g = this.markup.createSVGNode({
+ nodeType: "g",
+ attributes: {
+ id: "tool",
+ },
+ parent: svg,
+ prefix,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ attributes: {
+ id: "box-path",
+ class: "box-path",
+ },
+ parent: g,
+ prefix,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ attributes: {
+ id: "diagonal-path",
+ class: "diagonal-path",
+ },
+ parent: g,
+ prefix,
+ });
+
+ for (const handler of HANDLERS) {
+ this.markup.createSVGNode({
+ nodeType: "circle",
+ parent: g,
+ attributes: {
+ class: `handler-${handler}`,
+ id: `handler-${handler}`,
+ r: HANDLER_SIZE,
+ hidden: "true",
+ },
+ prefix,
+ });
+ }
+
+ return container;
+ }
+
+ _update() {
+ const { window } = this.env;
+
+ setIgnoreLayoutChanges(true);
+
+ const zoom = getCurrentZoom(window);
+
+ const { width, height } = getWindowDimensions(window);
+
+ const { coords } = this;
+
+ const isZoomChanged = zoom !== coords.zoom;
+
+ if (isZoomChanged) {
+ coords.zoom = zoom;
+ this.updateLabel();
+ }
+
+ const 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, window.document.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();
+
+ const { pageListenerTarget } = this.env;
+
+ if (pageListenerTarget) {
+ DOM_EVENTS.forEach(type =>
+ pageListenerTarget.removeEventListener(type, this)
+ );
+ }
+
+ this.markup.destroy();
+
+ EventEmitter.emit(this, "destroy");
+ }
+
+ show() {
+ setIgnoreLayoutChanges(true);
+
+ this.getElement("root").removeAttribute("hidden");
+
+ this._update();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ }
+
+ hide() {
+ setIgnoreLayoutChanges(true);
+
+ this.hideLabel("size");
+ this.hideLabel("position");
+
+ this.getElement("root").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) {
+ const { 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._dragging) {
+ this.updatePaths();
+ this.updateHandlers();
+ }
+
+ this.updateLabel();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ }
+
+ updatePaths() {
+ const { x, y, w, h } = this.coords;
+ const 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.
+ const x1 = w > 0 ? 0.5 : 0;
+ const y1 = w < 0 && h < 0 ? -0.5 : 0;
+ const w1 = w + (h < 0 && w < 0 ? 0.5 : 0);
+ const h1 = h + (h > 0 && w > 0 ? -0.5 : 0);
+
+ const 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._dragging ? "size" : "position");
+
+ const isSizeLabel = type === "size";
+
+ const label = this.getElement(`label-${type}`);
+
+ let origin = "top left";
+
+ const { innerWidth, innerHeight, scrollX, scrollY } = this.env.window;
+ let { x, y, w, h, zoom } = this.coords;
+ const scale = 1 / zoom;
+
+ w = w || 0;
+ h = h || 0;
+ x = x || 0;
+ y = y || 0;
+ if (type === "size") {
+ x += w;
+ y += h;
+ }
+
+ let labelMargin, labelHeight, labelWidth;
+
+ if (isSizeLabel) {
+ labelMargin = LABEL_SIZE_MARGIN;
+ labelWidth = LABEL_SIZE_WIDTH;
+ labelHeight = LABEL_SIZE_HEIGHT;
+
+ const 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
+ const labelBoxWidth = (labelWidth + labelMargin) * scale;
+ const labelBoxHeight = (labelHeight + labelMargin) * scale;
+
+ const isGoingLeft = w < scrollX;
+ const isSizeGoingLeft = isSizeLabel && isGoingLeft;
+ const isExceedingLeftMargin = x - labelBoxWidth < scrollX;
+ const isExceedingRightMargin = x + labelBoxWidth > innerWidth + scrollX;
+ const isExceedingTopMargin = y - labelBoxHeight < scrollY;
+ const 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) {
+ const labelSize = this.getElement("label-size");
+ const style = labelSize.getAttribute("style");
+
+ if (style) {
+ labelSize.setAttribute(
+ "style",
+ style.replace(/scale[^)]+\)/, `scale(${scale})`)
+ );
+ }
+ }
+ }
+
+ updateViewport() {
+ const { devicePixelRatio } = this.env.window;
+ const { 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`
+ const 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.
+ const minWidth = 1 / pixelRatio;
+ const strokeWidth = minWidth / zoom;
+
+ this.getElement("root").setAttribute(
+ "style",
+ `stroke-width:${strokeWidth};
+ width:${documentWidth}px;
+ height:${documentHeight}px;`
+ );
+ }
+
+ updateGuides() {
+ const { 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%");
+ }
+
+ setHandlerPosition(handler, x, y) {
+ const handlerElement = this.getElement(`handler-${handler}`);
+ handlerElement.setAttribute("cx", x);
+ handlerElement.setAttribute("cy", y);
+ }
+
+ updateHandlers() {
+ const { w, h } = this.coords;
+
+ this.setHandlerPosition("top", w / 2, 0);
+ this.setHandlerPosition("topright", w, 0);
+ this.setHandlerPosition("right", w, h / 2);
+ this.setHandlerPosition("bottomright", w, h);
+ this.setHandlerPosition("bottom", w / 2, h);
+ this.setHandlerPosition("bottomleft", 0, h);
+ this.setHandlerPosition("left", 0, h / 2);
+ this.setHandlerPosition("topleft", 0, 0);
+ }
+
+ 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() {
+ const prefix = this.ID_CLASS_PREFIX + "guide-";
+
+ for (const side of SIDES) {
+ this.markup.removeAttributeForElement(`${prefix + side}`, "hidden");
+ }
+ }
+
+ hideGuides() {
+ const prefix = this.ID_CLASS_PREFIX + "guide-";
+
+ for (const side of SIDES) {
+ this.markup.setAttributeForElement(`${prefix + side}`, "hidden", "true");
+ }
+ }
+
+ showHandler(id) {
+ const prefix = this.ID_CLASS_PREFIX + "handler-";
+ this.markup.removeAttributeForElement(prefix + id, "hidden");
+ }
+
+ showHandlers() {
+ const prefix = this.ID_CLASS_PREFIX + "handler-";
+
+ for (const handler of HANDLERS) {
+ this.markup.removeAttributeForElement(prefix + handler, "hidden");
+ }
+ }
+
+ hideAll() {
+ this.hideLabel("position");
+ this.hideLabel("size");
+ this.hideGuides();
+ this.hideHandlers();
+ }
+
+ showGuidesAndHandlers() {
+ // Shows the guides and handlers only if an actual area is selected
+ if (this.coords.w !== 0 && this.coords.h !== 0) {
+ this.updateGuides();
+ this.showGuides();
+ this.updateHandlers();
+ this.showHandlers();
+ }
+ }
+
+ hideHandlers() {
+ const prefix = this.ID_CLASS_PREFIX + "handler-";
+
+ for (const handler of HANDLERS) {
+ this.markup.setAttributeForElement(prefix + handler, "hidden", "true");
+ }
+ }
+
+ handleEvent(event) {
+ const { target, type } = event;
+
+ switch (type) {
+ case "mousedown":
+ if (event.button || this._dragging) {
+ return;
+ }
+
+ const isHandler = event.originalTarget.id.includes("handler");
+ if (isHandler) {
+ this.handleResizingMouseDownEvent(event);
+ } else {
+ this.handleMouseDownEvent(event);
+ }
+ break;
+ case "mousemove":
+ if (this._dragging && this._dragging.handler) {
+ this.handleResizingMouseMoveEvent(event);
+ } else {
+ this.handleMouseMoveEvent(event);
+ }
+ break;
+ case "mouseup":
+ if (this._dragging) {
+ if (this._dragging.handler) {
+ this.handleResizingMouseUpEvent();
+ } else {
+ this.handleMouseUpEvent();
+ }
+ }
+ break;
+ case "mouseleave": {
+ if (!this._dragging) {
+ this.hideLabel("position");
+ }
+ break;
+ }
+ case "scroll": {
+ this.hideLabel("position");
+ break;
+ }
+ case "pagehide": {
+ // If a page hide event is triggered for current window's highlighter, hide the
+ // highlighter.
+ if (target.defaultView === this.env.window) {
+ this.destroy();
+ }
+ break;
+ }
+ }
+ }
+
+ handleMouseDownEvent(event) {
+ const { pageX, pageY } = event;
+ const { window } = this.env;
+ const elementId = `${this.ID_CLASS_PREFIX}tool`;
+
+ setIgnoreLayoutChanges(true);
+
+ this.markup.getElement(elementId).classList.add("dragging");
+
+ this.hideAll();
+
+ setIgnoreLayoutChanges(false, window.document.documentElement);
+
+ // Store all the initial values needed for drag & drop
+ this._dragging = {
+ handler: null,
+ x: pageX,
+ y: pageY,
+ };
+
+ this.setCoords(pageX, pageY, 0, 0);
+ }
+
+ handleMouseMoveEvent(event) {
+ const { pageX, pageY } = event;
+ const { coords } = this;
+ let { x, y, w, h } = coords;
+ let labelType;
+
+ if (this._dragging) {
+ w = pageX - coords.x;
+ h = pageY - coords.y;
+
+ this.setCoords(x, y, w, h);
+
+ labelType = "size";
+ } else {
+ labelType = "position";
+
+ this.setCoords(pageX, pageY);
+ }
+
+ this.showLabel(labelType);
+ }
+
+ handleMouseUpEvent() {
+ setIgnoreLayoutChanges(true);
+
+ this.getElement("tool").classList.remove("dragging");
+
+ this.showGuidesAndHandlers();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ this._dragging = null;
+ }
+
+ handleResizingMouseDownEvent(event) {
+ const { originalTarget, pageX, pageY } = event;
+ const { window } = this.env;
+ const prefix = this.ID_CLASS_PREFIX + "handler-";
+ const handler = originalTarget.id.replace(prefix, "");
+
+ setIgnoreLayoutChanges(true);
+
+ this.markup.getElement(originalTarget.id).classList.add("dragging");
+
+ this.hideAll();
+ this.showHandler(handler);
+
+ // Set coordinates to the current measurement area's position
+ const [, x, y] = this.getElement("tool")
+ .getAttribute("transform")
+ .match(/(\d+),(\d+)/);
+ this.setCoords(Number(x), Number(y));
+
+ setIgnoreLayoutChanges(false, window.document.documentElement);
+
+ // Store all the initial values needed for drag & drop
+ this._dragging = {
+ handler,
+ x: pageX,
+ y: pageY,
+ };
+ }
+
+ handleResizingMouseMoveEvent(event) {
+ const { pageX, pageY } = event;
+ const { coords } = this;
+ let { x, y, w, h } = coords;
+
+ const { handler } = this._dragging;
+
+ switch (handler) {
+ case "top":
+ y = pageY;
+ h = coords.y + coords.h - pageY;
+ break;
+ case "topright":
+ y = pageY;
+ w = pageX - coords.x;
+ h = coords.y + coords.h - pageY;
+ break;
+ case "right":
+ w = pageX - coords.x;
+ break;
+ case "bottomright":
+ w = pageX - coords.x;
+ h = pageY - coords.y;
+ break;
+ case "bottom":
+ h = pageY - coords.y;
+ break;
+ case "bottomleft":
+ x = pageX;
+ w = coords.x + coords.w - pageX;
+ h = pageY - coords.y;
+ break;
+ case "left":
+ x = pageX;
+ w = coords.x + coords.w - pageX;
+ break;
+ case "topleft":
+ x = pageX;
+ y = pageY;
+ w = coords.x + coords.w - pageX;
+ h = coords.y + coords.h - pageY;
+ break;
+ }
+
+ this.setCoords(x, y, w, h);
+
+ // Changes the resizing cursors in case the measuring box is mirrored
+ const isMirrored =
+ (coords.w < 0 || coords.h < 0) && !(coords.w < 0 && coords.h < 0);
+ this.getElement("tool").classList.toggle("mirrored", isMirrored);
+
+ this.showLabel("size");
+ }
+
+ handleResizingMouseUpEvent() {
+ const { handler } = this._dragging;
+
+ setIgnoreLayoutChanges(true);
+
+ this.getElement(`handler-${handler}`).classList.remove("dragging");
+ this.showHandlers();
+
+ this.showGuidesAndHandlers();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ this._dragging = null;
+ }
+}
+exports.MeasuringToolHighlighter = MeasuringToolHighlighter;