/* 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;
const LABEL_TYPE_SIZE =
"size";
const LABEL_TYPE_POSITION =
"position";
// List of all DOM Events subscribed directly to the document from the
// Measuring Tool highlighter
const DOM_EVENTS = [
"mousedown",
"mousemove",
"mouseup",
"mouseleave",
"scroll",
"pagehide",
"keydown",
"keyup",
];
const SIDES = [
"top",
"right",
"bottom",
"left"];
const HANDLERS = [...SIDES,
"topleft",
"topright",
"bottomleft",
"bottomright"];
const HANDLER_SIZE = 6;
const HIGHLIGHTED_HANDLER_CLASSNAME =
"highlight";
const IS_OSX = Services.appinfo.OS ===
"Darwin";
/**
* 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.rect = { x: 0, y: 0, w: 0, h: 0 };
this.mouseCoords = { 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 { rect } =
this;
const isZoomChanged = zoom !== rect.zoom;
if (isZoomChanged) {
rect.zoom = zoom;
this.updateLabel();
}
const isDocumentSizeChanged =
width !== rect.documentWidth || height !== rect.documentHeight;
if (isDocumentSizeChanged) {
rect.documentWidth = width;
rect.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(LABEL_TYPE_SIZE);
this.hideLabel(LABEL_TYPE_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.setRect(undefined, undefined, w, h);
}
setRect(x, y, w, h) {
const { rect } =
this;
if (
typeof x !==
"undefined") {
rect.x = x;
}
if (
typeof y !==
"undefined") {
rect.y = y;
}
if (
typeof w !==
"undefined") {
rect.w = w;
}
if (
typeof h !==
"undefined") {
rect.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.rect;
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 ? LABEL_TYPE_SIZE : LABEL_TYPE_POSITION);
const isSizeLabel = type === LABEL_TYPE_SIZE;
const label =
this.getElement(`label-${type}`);
let origin =
"top left";
const { innerWidth, innerHeight, scrollX, scrollY } =
this.env.window;
const { x: mouseX, y: mouseY } =
this.mouseCoords;
let { x, y, w, h, zoom } =
this.rect;
const scale = 1 / zoom;
w = w || 0;
h = h || 0;
x = x || 0;
y = y || 0;
if (type === LABEL_TYPE_SIZE) {
x += w;
y += h;
}
else {
x = mouseX;
y = mouseY;
}
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(`${mouseX}
${mouseY}`);
}
// 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.rect;
// 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.rect;
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.rect;
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(LABEL_TYPE_POSITION);
this.hideLabel(LABEL_TYPE_SIZE);
this.hideGuides();
this.hideHandlers();
}
showGuidesAndHandlers() {
// Shows the guides and handlers only if an actual area is selected
if (
this.rect.w !== 0 &&
this.rect.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(LABEL_TYPE_POSITION);
}
break;
}
case "scroll": {
this.hideLabel(LABEL_TYPE_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;
}
case "keydown": {
this.handleKeyDown(event);
break;
}
case "keyup": {
if (MeasuringToolHighlighter.#isResizeModifierPressed(event)) {
this.getElement(
"handler-topleft").classList.remove(
HIGHLIGHTED_HANDLER_CLASSNAME
);
}
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.setRect(pageX, pageY, 0, 0);
}
handleMouseMoveEvent(event) {
const { pageX, pageY } = event;
const { mouseCoords } =
this;
let { x, y, w, h } =
this.rect;
let labelType;
if (
this._dragging) {
w = pageX - x;
h = pageY - y;
this.setRect(x, y, w, h);
labelType = LABEL_TYPE_SIZE;
}
else {
mouseCoords.x = pageX;
mouseCoords.y = pageY;
this.updateLabel(LABEL_TYPE_POSITION);
labelType = LABEL_TYPE_POSITION;
}
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.setRect(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 { rect } =
this;
let { x, y, w, h } = rect;
const { handler } =
this._dragging;
switch (handler) {
case "top":
y = pageY;
h = rect.y + rect.h - pageY;
break;
case "topright":
y = pageY;
w = pageX - rect.x;
h = rect.y + rect.h - pageY;
break;
case "right":
w = pageX - rect.x;
break;
case "bottomright":
w = pageX - rect.x;
h = pageY - rect.y;
break;
case "bottom":
h = pageY - rect.y;
break;
case "bottomleft":
x = pageX;
w = rect.x + rect.w - pageX;
h = pageY - rect.y;
break;
case "left":
x = pageX;
w = rect.x + rect.w - pageX;
break;
case "topleft":
x = pageX;
y = pageY;
w = rect.x + rect.w - pageX;
h = rect.y + rect.h - pageY;
break;
}
this.setRect(x, y, w, h);
// Changes the resizing cursors in case the measuring box is mirrored
const isMirrored =
(rect.w < 0 || rect.h < 0) && !(rect.w < 0 && rect.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;
}
handleKeyDown(event) {
if (MeasuringToolHighlighter.#isResizeModifierPressed(event)) {
this.getElement(
"handler-topleft").classList.add(
HIGHLIGHTED_HANDLER_CLASSNAME
);
}
if (
![
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight"].includes(event.key)
) {
return;
}
const { x, y, w, h } =
this.rect;
const modifier = event.shiftKey ? 10 : 1;
event.preventDefault();
if (MeasuringToolHighlighter.#isResizeModifierHeld(event)) {
// If Ctrl (or Command on OS X) is held, resize the tool
switch (event.key) {
case "ArrowUp":
this.setSize(undefined, h - modifier);
break;
case "ArrowDown":
this.setSize(undefined, h + modifier);
break;
case "ArrowLeft":
this.setSize(w - modifier, undefined);
break;
case "ArrowRight":
this.setSize(w + modifier, undefined);
break;
}
}
else {
// Arrow keys with no modifier move the tool
switch (event.key) {
case "ArrowUp":
this.setRect(undefined, y - modifier);
break;
case "ArrowDown":
this.setRect(undefined, y + modifier);
break;
case "ArrowLeft":
this.setRect(x - modifier, undefined);
break;
case "ArrowRight":
this.setRect(x + modifier, undefined);
break;
}
}
this.updatePaths();
this.updateGuides();
this.updateHandlers();
this.updateLabel(LABEL_TYPE_SIZE);
}
static #isResizeModifierPressed(event) {
return (
(!IS_OSX && event.key ===
"Control") || (IS_OSX && event.key ===
"Meta")
);
}
static #isResizeModifierHeld(event) {
return (!IS_OSX && event.ctrlKey) || (IS_OSX && event.metaKey);
}
}
exports.MeasuringToolHighlighter = MeasuringToolHighlighter;