diff options
Diffstat (limited to 'devtools/server/actors/highlighters/measuring-tool.js')
-rw-r--r-- | devtools/server/actors/highlighters/measuring-tool.js | 563 |
1 files changed, 563 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 000000000..e1e1de94f --- /dev/null +++ b/devtools/server/actors/highlighters/measuring-tool.js @@ -0,0 +1,563 @@ +/* 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 <g> 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); + pageListenerTarget.removeEventListener("mouseleave", 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; |