diff options
Diffstat (limited to 'devtools/server/actors/highlighters/measuring-tool.js')
-rw-r--r-- | devtools/server/actors/highlighters/measuring-tool.js | 763 |
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; |