/* 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, setIgnoreLayoutChanges, } = require("resource://devtools/shared/layout/utils.js"); const { CanvasFrameAnonymousContentHelper, } = require("resource://devtools/server/actors/highlighters/utils/markup.js"); // Maximum size, in pixel, for the horizontal ruler and vertical ruler // used by RulersHighlighter const RULERS_MAX_X_AXIS = 10000; const RULERS_MAX_Y_AXIS = 15000; // Number of steps after we add a graduation, marker and text in // RulersHighliter; currently the unit is in pixel. const RULERS_GRADUATION_STEP = 5; const RULERS_MARKER_STEP = 50; const RULERS_TEXT_STEP = 100; /** * The RulersHighlighter is a class that displays both horizontal and * vertical rules on the page, along the top and left edges, with pixel * graduations, useful for users to quickly check distances */ class RulersHighlighter { constructor(highlighterEnv) { this.env = highlighterEnv; this.markup = new CanvasFrameAnonymousContentHelper( highlighterEnv, this._buildMarkup.bind(this) ); this.isReady = this.markup.initialize(); const { pageListenerTarget } = highlighterEnv; pageListenerTarget.addEventListener("scroll", this); pageListenerTarget.addEventListener("pagehide", this); } ID_CLASS_PREFIX = "rulers-highlighter-"; _buildMarkup() { const prefix = this.ID_CLASS_PREFIX; const createRuler = (axis, size) => { let width, height; let isHorizontal = true; if (axis === "x") { width = size; height = 16; } else if (axis === "y") { width = 16; height = size; isHorizontal = false; } else { throw new Error( `Invalid type of axis given; expected "x" or "y" but got "${axis}"` ); } const g = this.markup.createSVGNode({ nodeType: "g", attributes: { id: `${axis}-axis`, }, parent: svg, prefix, }); this.markup.createSVGNode({ nodeType: "rect", attributes: { y: isHorizontal ? 0 : 16, width, height, }, parent: g, }); const gRule = this.markup.createSVGNode({ nodeType: "g", attributes: { id: `${axis}-axis-ruler`, }, parent: g, prefix, }); const pathGraduations = this.markup.createSVGNode({ nodeType: "path", attributes: { class: "ruler-graduations", width, height, }, parent: gRule, prefix, }); const pathMarkers = this.markup.createSVGNode({ nodeType: "path", attributes: { class: "ruler-markers", width, height, }, parent: gRule, prefix, }); const gText = this.markup.createSVGNode({ nodeType: "g", attributes: { id: `${axis}-axis-text`, class: (isHorizontal ? "horizontal" : "vertical") + "-labels", }, parent: g, prefix, }); let dGraduations = ""; let dMarkers = ""; let graduationLength; for (let i = 0; i < size; i += RULERS_GRADUATION_STEP) { if (i === 0) { continue; } graduationLength = i % 2 === 0 ? 6 : 4; if (i % RULERS_TEXT_STEP === 0) { graduationLength = 8; this.markup.createSVGNode({ nodeType: "text", parent: gText, attributes: { x: isHorizontal ? 2 + i : -i - 1, y: 5, }, }).textContent = i; } if (isHorizontal) { if (i % RULERS_MARKER_STEP === 0) { dMarkers += `M${i} 0 L${i} ${graduationLength}`; } else { dGraduations += `M${i} 0 L${i} ${graduationLength} `; } } else if (i % 50 === 0) { dMarkers += `M0 ${i} L${graduationLength} ${i}`; } else { dGraduations += `M0 ${i} L${graduationLength} ${i}`; } } pathGraduations.setAttribute("d", dGraduations); pathMarkers.setAttribute("d", dMarkers); return g; }; const container = this.markup.createNode({ attributes: { class: "highlighter-container" }, }); const root = this.markup.createNode({ parent: container, attributes: { id: "root", class: "root", }, prefix, }); const svg = this.markup.createSVGNode({ nodeType: "svg", parent: root, attributes: { id: "elements", class: "elements", width: "100%", height: "100%", hidden: "true", }, prefix, }); createRuler("x", RULERS_MAX_X_AXIS); createRuler("y", RULERS_MAX_Y_AXIS); return container; } handleEvent(event) { switch (event.type) { case "scroll": this._onScroll(event); break; case "pagehide": // If a page hide event is triggered for current window's highlighter, hide the // highlighter. if (event.target.defaultView === this.env.window) { this.destroy(); } break; } } _onScroll(event) { const prefix = this.ID_CLASS_PREFIX; const { scrollX, scrollY } = event.view; this.markup .getElement(`${prefix}x-axis-ruler`) .setAttribute("transform", `translate(${-scrollX})`); this.markup .getElement(`${prefix}x-axis-text`) .setAttribute("transform", `translate(${-scrollX})`); this.markup .getElement(`${prefix}y-axis-ruler`) .setAttribute("transform", `translate(0, ${-scrollY})`); this.markup .getElement(`${prefix}y-axis-text`) .setAttribute("transform", `translate(0, ${-scrollY})`); } _update() { const { window } = this.env; setIgnoreLayoutChanges(true); const zoom = getCurrentZoom(window); const isZoomChanged = zoom !== this._zoom; if (isZoomChanged) { this._zoom = zoom; 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; } } updateViewport() { const { devicePixelRatio } = this.env.window; // 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 / this._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 = Math.min(minWidth, minWidth / this._zoom); this.markup .getElement(this.ID_CLASS_PREFIX + "root") .setAttribute("style", `stroke-width:${strokeWidth};`); } destroy() { this.hide(); const { pageListenerTarget } = this.env; if (pageListenerTarget) { pageListenerTarget.removeEventListener("scroll", this); pageListenerTarget.removeEventListener("pagehide", this); } this.markup.destroy(); EventEmitter.emit(this, "destroy"); } show() { this.markup.removeAttributeForElement( this.ID_CLASS_PREFIX + "elements", "hidden" ); this._update(); return true; } hide() { this.markup.setAttributeForElement( this.ID_CLASS_PREFIX + "elements", "hidden", "true" ); this._cancelUpdate(); } } exports.RulersHighlighter = RulersHighlighter;