diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /devtools/server/actors/highlighters/measuring-tool.js | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | devtools/server/actors/highlighters/measuring-tool.js | 853 |
1 files changed, 853 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..41c3b3098f --- /dev/null +++ b/devtools/server/actors/highlighters/measuring-tool.js @@ -0,0 +1,853 @@ +/* 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; |