/* 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/. */ /** * The Screenshots overlay is inserted into the document's * anonymous content container (see dom/webidl/Document.webidl). * * This container gets cleared automatically when the document navigates. * * To retrieve the AnonymousContent instance, use the `content` getter. */ /* * Below are the states of the screenshots overlay * States: * "crosshairs": * Nothing has happened, and the crosshairs will follow the movement of the mouse * "draggingReady": * The user has pressed the mouse button, but hasn't moved enough to create a selection * "dragging": * The user has pressed down a mouse button, and is dragging out an area far enough to show a selection * "selected": * The user has selected an area * "resizing": * The user is resizing the selection */ import { setMaxDetectHeight, setMaxDetectWidth, getBestRectForElement, getElementFromPoint, Region, WindowDimensions, } from "chrome://browser/content/screenshots/overlayHelpers.mjs"; import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; import { ShortcutUtils } from "resource://gre/modules/ShortcutUtils.sys.mjs"; const STATES = { CROSSHAIRS: "crosshairs", DRAGGING_READY: "draggingReady", DRAGGING: "dragging", SELECTED: "selected", RESIZING: "resizing", }; const lazy = {}; ChromeUtils.defineLazyGetter(lazy, "overlayLocalization", () => { return new Localization(["browser/screenshots.ftl"], true); }); const SCREENSHOTS_LAST_SAVED_METHOD_PREF = "screenshots.browser.component.last-saved-method"; XPCOMUtils.defineLazyPreferenceGetter( lazy, "SCREENSHOTS_LAST_SAVED_METHOD", SCREENSHOTS_LAST_SAVED_METHOD_PREF, "download" ); const REGION_CHANGE_THRESHOLD = 5; const SCROLL_BY_EDGE = 20; export class ScreenshotsOverlay { #content; #initialized = false; #state = ""; #moverId; #cachedEle; #lastPageX; #lastPageY; #lastClientX; #lastClientY; #previousDimensions; #methodsUsed; get markup() { let accelString = ShortcutUtils.getModifierString("accel"); let copyShorcut = accelString + this.copyKey; let downloadShortcut = accelString + this.downloadKey; let [ cancelLabel, cancelAttributes, instructions, downloadLabel, downloadAttributes, copyLabel, copyAttributes, ] = lazy.overlayLocalization.formatMessagesSync([ { id: "screenshots-cancel-button" }, { id: "screenshots-component-cancel-button" }, { id: "screenshots-instructions" }, { id: "screenshots-component-download-button-label" }, { id: "screenshots-component-download-button", args: { shortcut: downloadShortcut }, }, { id: "screenshots-component-copy-button-label" }, { id: "screenshots-component-copy-button", args: { shortcut: copyShorcut }, }, ]); return ` `; } get fragment() { if (!this.overlayTemplate) { let parser = new DOMParser(); let doc = parser.parseFromString(this.markup, "text/html"); this.overlayTemplate = this.document.importNode( doc.querySelector("template"), true ); } let fragment = this.overlayTemplate.content.cloneNode(true); return fragment; } get initialized() { return this.#initialized; } get state() { return this.#state; } get methodsUsed() { return this.#methodsUsed; } constructor(contentDocument) { this.document = contentDocument; this.window = contentDocument.ownerGlobal; this.windowDimensions = new WindowDimensions(); this.selectionRegion = new Region(this.windowDimensions); this.hoverElementRegion = new Region(this.windowDimensions); this.resetMethodsUsed(); let [downloadKey, copyKey] = lazy.overlayLocalization.formatMessagesSync([ { id: "screenshots-component-download-key" }, { id: "screenshots-component-copy-key" }, ]); this.downloadKey = downloadKey.value; this.copyKey = copyKey.value; } get content() { if (!this.#content || Cu.isDeadWrapper(this.#content)) { return null; } return this.#content; } getElementById(id) { return this.content.root.getElementById(id); } async initialize() { if (this.initialized) { return; } this.windowDimensions.reset(); this.#content = this.document.insertAnonymousContent(); this.#content.root.appendChild(this.fragment); this.initializeElements(); this.screenshotsContainer.dir = Services.locale.isAppLocaleRTL ? "rtl" : "ltr"; await this.updateWindowDimensions(); this.#setState(STATES.CROSSHAIRS); this.#initialized = true; } /** * Get all the elements that will be used. */ initializeElements() { this.previewCancelButton = this.getElementById("screenshots-cancel-button"); this.cancelButton = this.getElementById("cancel"); this.copyButton = this.getElementById("copy"); this.downloadButton = this.getElementById("download"); this.previewContainer = this.getElementById("preview-container"); this.hoverElementContainer = this.getElementById("hover-highlight"); this.selectionContainer = this.getElementById("selection-container"); this.buttonsContainer = this.getElementById("buttons-container"); this.screenshotsContainer = this.getElementById("screenshots-component"); this.leftEye = this.getElementById("left-eye"); this.rightEye = this.getElementById("right-eye"); this.leftBackgroundEl = this.getElementById("left-background"); this.topBackgroundEl = this.getElementById("top-background"); this.rightBackgroundEl = this.getElementById("right-background"); this.bottomBackgroundEl = this.getElementById("bottom-background"); this.highlightEl = this.getElementById("highlight"); this.topLeftMover = this.getElementById("mover-topLeft"); this.topRightMover = this.getElementById("mover-topRight"); this.bottomLeftMover = this.getElementById("mover-bottomLeft"); this.bottomRightMover = this.getElementById("mover-bottomRight"); this.selectionSize = this.getElementById("selection-size"); } /** * Removes all event listeners and removes the overlay from the Anonymous Content */ tearDown(options = {}) { if (this.#content) { if (!(options.doNotResetMethods === true)) { this.resetMethodsUsed(); } try { this.document.removeAnonymousContent(this.#content); } catch (e) { // If the current window isn't the one the content was inserted into, this // will fail, but that's fine. } } this.#initialized = false; this.#setState(""); } resetMethodsUsed() { this.#methodsUsed = { element: 0, region: 0, move: 0, resize: 0, }; } /** * Returns the x and y coordinates of the event relative to both the * viewport and the page. * @param {Event} event The event * @returns * { * clientX: The x position relative to the viewport * clientY: The y position relative to the viewport * pageX: The x position relative to the entire page * pageY: The y position relative to the entire page * } */ getCoordinatesFromEvent(event) { const { clientX, clientY, pageX, pageY } = event; return { clientX, clientY, pageX, pageY }; } handleEvent(event) { switch (event.type) { case "click": this.handleClick(event); break; case "pointerdown": this.handlePointerDown(event); break; case "pointermove": this.handlePointerMove(event); break; case "pointerup": this.handlePointerUp(event); break; case "keydown": this.handleKeyDown(event); break; case "keyup": this.handleKeyUp(event); break; } } /** * If the event came from the primary button, return false as we should not * early return in the event handler function. * If the event had another button, set to the crosshairs or selected state * and return true to early return from the event handler function. * @param {PointerEvent} event * @returns true if the event button(s) was the non primary button * false otherwise */ preEventHandler(event) { if (event.button > 0 || event.buttons > 1) { switch (this.#state) { case STATES.DRAGGING_READY: this.#setState(STATES.CROSSHAIRS); break; case STATES.DRAGGING: case STATES.RESIZING: this.#setState(STATES.SELECTED); break; } return true; } return false; } handleClick(event) { if (this.preEventHandler(event)) { return; } switch (event.originalTarget.id) { case "screenshots-cancel-button": case "cancel": this.maybeCancelScreenshots(); break; case "copy": this.copySelectedRegion(); break; case "download": this.downloadSelectedRegion(); break; } } maybeCancelScreenshots() { if (this.#state === STATES.CROSSHAIRS) { this.#dispatchEvent("Screenshots:Close", { reason: "overlay_cancel", }); } else { this.#setState(STATES.CROSSHAIRS); } } /** * Handles the pointerdown event depending on the state. * Early return when a pointer down happens on a button. * @param {Event} event The pointerown event */ handlePointerDown(event) { // Early return if the event target is not within the screenshots component // element. if (!event.originalTarget.closest("#screenshots-component")) { return; } if (this.preEventHandler(event)) { return; } if ( event.originalTarget.id === "screenshots-cancel-button" || event.originalTarget.closest("#buttons-container") === this.buttonsContainer ) { event.stopPropagation(); return; } const { pageX, pageY } = this.getCoordinatesFromEvent(event); switch (this.#state) { case STATES.CROSSHAIRS: { this.crosshairsDragStart(pageX, pageY); break; } case STATES.SELECTED: { this.selectedDragStart(pageX, pageY, event.originalTarget.id); break; } } } /** * Handles the pointermove event depending on the state * @param {Event} event The pointermove event */ handlePointerMove(event) { if (this.preEventHandler(event)) { return; } const { pageX, pageY, clientX, clientY } = this.getCoordinatesFromEvent(event); switch (this.#state) { case STATES.CROSSHAIRS: { this.crosshairsMove(clientX, clientY); break; } case STATES.DRAGGING_READY: { this.draggingReadyDrag(pageX, pageY); break; } case STATES.DRAGGING: { this.draggingDrag(pageX, pageY); break; } case STATES.RESIZING: { this.resizingDrag(pageX, pageY); break; } } } /** * Handles the pointerup event depending on the state * @param {Event} event The pointerup event */ handlePointerUp(event) { const { pageX, pageY, clientX, clientY } = this.getCoordinatesFromEvent(event); switch (this.#state) { case STATES.DRAGGING_READY: { this.draggingReadyDragEnd(pageX - clientX, pageY - clientY); break; } case STATES.DRAGGING: { this.draggingDragEnd(pageX, pageY, event.originalTarget.id); break; } case STATES.RESIZING: { this.resizingDragEnd(pageX, pageY); break; } } } /** * Handles when a keydown occurs in the screenshots component. * @param {Event} event The keydown event */ handleKeyDown(event) { switch (event.key) { case "ArrowLeft": this.handleArrowLeftKeyDown(event); break; case "ArrowUp": this.handleArrowUpKeyDown(event); break; case "ArrowRight": this.handleArrowRightKeyDown(event); break; case "ArrowDown": this.handleArrowDownKeyDown(event); break; case "Tab": this.maybeLockFocus(event); break; case "Escape": this.maybeCancelScreenshots(); break; case this.copyKey.toLowerCase(): if (this.state === "selected" && this.getAccelKey(event)) { event.preventDefault(); this.copySelectedRegion(); } break; case this.downloadKey.toLowerCase(): if (this.state === "selected" && this.getAccelKey(event)) { event.preventDefault(); this.downloadSelectedRegion(); } break; } } /** * Gets the accel key depending on the platform. * metaKey for macOS. ctrlKey for Windows and Linux. * @param {Event} event The keydown event * @returns {Boolean} True if the accel key is pressed, false otherwise. */ getAccelKey(event) { if (AppConstants.platform === "macosx") { return event.metaKey; } return event.ctrlKey; } /** * Move the region or its left or right side to the left. * Just the arrow key will move the region by 1px. * Arrow key + shift will move the region by 10px. * Arrow key + control/meta will move to the edge of the window. * @param {Event} event The keydown event */ handleArrowLeftKeyDown(event) { switch (event.originalTarget.id) { case "highlight": if (this.getAccelKey(event)) { let width = this.selectionRegion.width; this.selectionRegion.left = this.windowDimensions.scrollX; this.selectionRegion.right = this.windowDimensions.scrollX + width; break; } this.selectionRegion.right -= 10 ** event.shiftKey; // eslint-disable-next-line no-fallthrough case "mover-topLeft": case "mover-bottomLeft": if (this.getAccelKey(event)) { this.selectionRegion.left = this.windowDimensions.scrollX; break; } this.selectionRegion.left -= 10 ** event.shiftKey; this.scrollIfByEdge( this.selectionRegion.left, this.windowDimensions.scrollY + this.windowDimensions.clientHeight / 2 ); break; case "mover-topRight": case "mover-bottomRight": if (this.getAccelKey(event)) { let left = this.selectionRegion.left; this.selectionRegion.left = this.windowDimensions.scrollX; this.selectionRegion.right = left; if (event.originalTarget.id === "mover-topRight") { this.topLeftMover.focus({ focusVisible: true }); } else if (event.originalTarget.id === "mover-bottomRight") { this.bottomLeftMover.focus({ focusVisible: true }); } break; } this.selectionRegion.right -= 10 ** event.shiftKey; if (this.selectionRegion.x1 >= this.selectionRegion.x2) { this.selectionRegion.sortCoords(); if (event.originalTarget.id === "mover-topRight") { this.topLeftMover.focus({ focusVisible: true }); } else if (event.originalTarget.id === "mover-bottomRight") { this.bottomLeftMover.focus({ focusVisible: true }); } } break; default: return; } if (this.#state !== STATES.RESIZING) { this.#setState(STATES.RESIZING); } event.preventDefault(); this.drawSelectionContainer(); } /** * Move the region or its top or bottom side upward. * Just the arrow key will move the region by 1px. * Arrow key + shift will move the region by 10px. * Arrow key + control/meta will move to the edge of the window. * @param {Event} event The keydown event */ handleArrowUpKeyDown(event) { switch (event.originalTarget.id) { case "highlight": if (this.getAccelKey(event)) { let height = this.selectionRegion.height; this.selectionRegion.top = this.windowDimensions.scrollY; this.selectionRegion.bottom = this.windowDimensions.scrollY + height; break; } this.selectionRegion.bottom -= 10 ** event.shiftKey; // eslint-disable-next-line no-fallthrough case "mover-topLeft": case "mover-topRight": if (this.getAccelKey(event)) { this.selectionRegion.top = this.windowDimensions.scrollY; break; } this.selectionRegion.top -= 10 ** event.shiftKey; this.scrollIfByEdge( this.windowDimensions.scrollX + this.windowDimensions.clientWidth / 2, this.selectionRegion.top ); break; case "mover-bottomLeft": case "mover-bottomRight": if (this.getAccelKey(event)) { let top = this.selectionRegion.top; this.selectionRegion.top = this.windowDimensions.scrollY; this.selectionRegion.bottom = top; if (event.originalTarget.id === "mover-bottomLeft") { this.topLeftMover.focus({ focusVisible: true }); } else if (event.originalTarget.id === "mover-bottomRight") { this.topRightMover.focus({ focusVisible: true }); } break; } this.selectionRegion.bottom -= 10 ** event.shiftKey; if (this.selectionRegion.y1 >= this.selectionRegion.y2) { this.selectionRegion.sortCoords(); if (event.originalTarget.id === "mover-bottomLeft") { this.topLeftMover.focus({ focusVisible: true }); } else if (event.originalTarget.id === "mover-bottomRight") { this.topRightMover.focus({ focusVisible: true }); } } break; default: return; } if (this.#state !== STATES.RESIZING) { this.#setState(STATES.RESIZING); } event.preventDefault(); this.drawSelectionContainer(); } /** * Move the region or its left or right side to the right. * Just the arrow key will move the region by 1px. * Arrow key + shift will move the region by 10px. * Arrow key + control/meta will move to the edge of the window. * @param {Event} event The keydown event */ handleArrowRightKeyDown(event) { switch (event.originalTarget.id) { case "highlight": if (this.getAccelKey(event)) { let width = this.selectionRegion.width; let { scrollX, clientWidth } = this.windowDimensions.dimensions; this.selectionRegion.right = scrollX + clientWidth; this.selectionRegion.left = this.selectionRegion.right - width; break; } this.selectionRegion.left += 10 ** event.shiftKey; // eslint-disable-next-line no-fallthrough case "mover-topRight": case "mover-bottomRight": if (this.getAccelKey(event)) { this.selectionRegion.right = this.windowDimensions.scrollX + this.windowDimensions.clientWidth; break; } this.selectionRegion.right += 10 ** event.shiftKey; this.scrollIfByEdge( this.selectionRegion.right, this.windowDimensions.scrollY + this.windowDimensions.clientHeight / 2 ); break; case "mover-topLeft": case "mover-bottomLeft": if (this.getAccelKey(event)) { let right = this.selectionRegion.right; this.selectionRegion.right = this.windowDimensions.scrollX + this.windowDimensions.clientWidth; this.selectionRegion.left = right; if (event.originalTarget.id === "mover-topLeft") { this.topRightMover.focus({ focusVisible: true }); } else if (event.originalTarget.id === "mover-bottomLeft") { this.bottomRightMover.focus({ focusVisible: true }); } break; } this.selectionRegion.left += 10 ** event.shiftKey; if (this.selectionRegion.x1 >= this.selectionRegion.x2) { this.selectionRegion.sortCoords(); if (event.originalTarget.id === "mover-topLeft") { this.topRightMover.focus({ focusVisible: true }); } else if (event.originalTarget.id === "mover-bottomLeft") { this.bottomRightMover.focus({ focusVisible: true }); } } break; default: return; } if (this.#state !== STATES.RESIZING) { this.#setState(STATES.RESIZING); } event.preventDefault(); this.drawSelectionContainer(); } /** * Move the region or its top or bottom side downward. * Just the arrow key will move the region by 1px. * Arrow key + shift will move the region by 10px. * Arrow key + control/meta will move to the edge of the window. * @param {Event} event The keydown event */ handleArrowDownKeyDown(event) { switch (event.originalTarget.id) { case "highlight": if (this.getAccelKey(event)) { let height = this.selectionRegion.height; let { scrollY, clientHeight } = this.windowDimensions.dimensions; this.selectionRegion.bottom = scrollY + clientHeight; this.selectionRegion.top = this.selectionRegion.bottom - height; break; } this.selectionRegion.top += 10 ** event.shiftKey; // eslint-disable-next-line no-fallthrough case "mover-bottomLeft": case "mover-bottomRight": if (this.getAccelKey(event)) { this.selectionRegion.bottom = this.windowDimensions.scrollY + this.windowDimensions.clientHeight; break; } this.selectionRegion.bottom += 10 ** event.shiftKey; this.scrollIfByEdge( this.windowDimensions.scrollX + this.windowDimensions.clientWidth / 2, this.selectionRegion.bottom ); break; case "mover-topLeft": case "mover-topRight": if (this.getAccelKey(event)) { let bottom = this.selectionRegion.bottom; this.selectionRegion.bottom = this.windowDimensions.scrollY + this.windowDimensions.clientHeight; this.selectionRegion.top = bottom; if (event.originalTarget.id === "mover-topLeft") { this.bottomLeftMover.focus({ focusVisible: true }); } else if (event.originalTarget.id === "mover-topRight") { this.bottomRightMover.focus({ focusVisible: true }); } break; } this.selectionRegion.top += 10 ** event.shiftKey; if (this.selectionRegion.y1 >= this.selectionRegion.y2) { this.selectionRegion.sortCoords(); if (event.originalTarget.id === "mover-topLeft") { this.bottomLeftMover.focus({ focusVisible: true }); } else if (event.originalTarget.id === "mover-topRight") { this.bottomRightMover.focus({ focusVisible: true }); } } break; default: return; } if (this.#state !== STATES.RESIZING) { this.#setState(STATES.RESIZING); } event.preventDefault(); this.drawSelectionContainer(); } /** * We lock focus to the overlay when a region is selected. * Can still escape with shift + F6. * @param {Event} event The keydown event */ maybeLockFocus(event) { if (this.#state !== STATES.SELECTED) { return; } event.preventDefault(); if (event.originalTarget.id === "highlight" && event.shiftKey) { this.downloadButton.focus({ focusVisible: true }); } else if (event.originalTarget.id === "download" && !event.shiftKey) { this.highlightEl.focus({ focusVisible: true }); } else { // The content document can listen for keydown events and prevent moving // focus so we manually move focus to the next element here. let direction = event.shiftKey ? Services.focus.MOVEFOCUS_BACKWARD : Services.focus.MOVEFOCUS_FORWARD; Services.focus.moveFocus( this.window, null, direction, Services.focus.FLAG_BYKEY ); } } /** * Set the focus to the most recent saved method. * This will default to the download button. */ setFocusToActionButton() { if (lazy.SCREENSHOTS_LAST_SAVED_METHOD === "copy") { this.copyButton.focus({ focusVisible: true, preventScroll: true }); } else { this.downloadButton.focus({ focusVisible: true, preventScroll: true }); } } /** * Handles when a keydown occurs in the screenshots component. * All we need to do on keyup is set the state to selected. * @param {Event} event The keydown event */ handleKeyUp(event) { switch (event.key) { case "ArrowLeft": case "ArrowUp": case "ArrowRight": case "ArrowDown": switch (event.originalTarget.id) { case "highlight": case "mover-bottomLeft": case "mover-bottomRight": case "mover-topLeft": case "mover-topRight": event.preventDefault(); this.#setState(STATES.SELECTED); break; } break; } } /** * Dispatch a custom event to the ScreenshotsComponentChild actor * @param {String} eventType The name of the event * @param {object} detail Extra details to send to the child actor */ #dispatchEvent(eventType, detail) { this.window.windowUtils.dispatchEventToChromeOnly( this.window, new CustomEvent(eventType, { bubbles: true, detail, }) ); } /** * Set a new state for the overlay * @param {String} newState */ #setState(newState) { if (this.#state === STATES.SELECTED && newState === STATES.CROSSHAIRS) { this.#dispatchEvent("Screenshots:RecordEvent", { eventName: "started", reason: "overlay_retry", }); } if (newState !== this.#state) { this.#dispatchEvent("Screenshots:OverlaySelection", { hasSelection: newState == STATES.SELECTED, }); } this.#state = newState; switch (this.#state) { case STATES.CROSSHAIRS: { this.crosshairsStart(); break; } case STATES.DRAGGING_READY: { this.draggingReadyStart(); break; } case STATES.DRAGGING: { this.draggingStart(); break; } case STATES.SELECTED: { this.selectedStart(); break; } case STATES.RESIZING: { this.resizingStart(); break; } } } copySelectedRegion() { this.#dispatchEvent("Screenshots:Copy", { region: this.selectionRegion.dimensions, }); } downloadSelectedRegion() { this.#dispatchEvent("Screenshots:Download", { region: this.selectionRegion.dimensions, }); } /** * Hide hover element, selection and buttons containers. * Show the preview container and the panel. * This is the initial state of the overlay. */ crosshairsStart() { this.hideHoverElementContainer(); this.hideSelectionContainer(); this.hideButtonsContainer(); this.showPreviewContainer(); this.#dispatchEvent("Screenshots:ShowPanel"); this.#previousDimensions = null; this.#cachedEle = null; this.hoverElementRegion.resetDimensions(); } /** * Hide the panel because we have started dragging. */ draggingReadyStart() { this.#dispatchEvent("Screenshots:HidePanel"); } /** * Hide the preview, hover element and buttons containers. * Show the selection container. */ draggingStart() { this.hidePreviewContainer(); this.hideButtonsContainer(); this.hideHoverElementContainer(); this.drawSelectionContainer(); } /** * Hide the preview and hover element containers. * Draw the selection and buttons containers. */ selectedStart() { this.hidePreviewContainer(); this.hideHoverElementContainer(); this.drawSelectionContainer(); this.drawButtonsContainer(); } /** * Hide the buttons container. * Store the width and height of the current selected region. * The dimensions will be used when moving the region along the edge of the * page and for recording telemetry. */ resizingStart() { this.hideButtonsContainer(); let { width, height } = this.selectionRegion.dimensions; this.#previousDimensions = { width, height }; } /** * Dragging has started so we set the initial selection region and set the * state to draggingReady. * @param {Number} pageX The x position relative to the page * @param {Number} pageY The y position relative to the page */ crosshairsDragStart(pageX, pageY) { this.selectionRegion.dimensions = { left: pageX, top: pageY, right: pageX, bottom: pageY, }; this.#setState(STATES.DRAGGING_READY); } /** * If the background is clicked we set the state to crosshairs * otherwise set the state to resizing * @param {Number} pageX The x position relative to the page * @param {Number} pageY The y position relative to the page * @param {String} targetId The id of the event target */ selectedDragStart(pageX, pageY, targetId) { if (targetId === this.screenshotsContainer.id) { this.#setState(STATES.CROSSHAIRS); return; } this.#moverId = targetId; this.#lastPageX = pageX; this.#lastPageY = pageY; this.#setState(STATES.RESIZING); } /** * Draw the eyes in the preview container and find the element currently * being hovered. * @param {Number} clientX The x position relative to the viewport * @param {Number} clientY The y position relative to the viewport */ crosshairsMove(clientX, clientY) { this.drawPreviewEyes(clientX, clientY); this.handleElementHover(clientX, clientY); } /** * Set the selection region dimensions and if the region is at least 40 * pixels diagnally in distance, set the state to dragging. * @param {Number} pageX The x position relative to the page * @param {Number} pageY The y position relative to the page */ draggingReadyDrag(pageX, pageY) { this.selectionRegion.dimensions = { right: pageX, bottom: pageY, }; if (this.selectionRegion.distance > 40) { this.#setState(STATES.DRAGGING); } } /** * Scroll if along the edge of the viewport, update the selection region * dimensions and draw the selection container. * @param {Number} pageX The x position relative to the page * @param {Number} pageY The y position relative to the page */ draggingDrag(pageX, pageY) { this.scrollIfByEdge(pageX, pageY); this.selectionRegion.dimensions = { right: pageX, bottom: pageY, }; this.drawSelectionContainer(); } /** * Resize the selection region depending on the mover that started the resize. * @param {Number} pageX The x position relative to the page * @param {Number} pageY The y position relative to the page */ resizingDrag(pageX, pageY) { this.scrollIfByEdge(pageX, pageY); switch (this.#moverId) { case "mover-topLeft": { this.selectionRegion.dimensions = { left: pageX, top: pageY, }; break; } case "mover-top": { this.selectionRegion.dimensions = { top: pageY }; break; } case "mover-topRight": { this.selectionRegion.dimensions = { top: pageY, right: pageX, }; break; } case "mover-right": { this.selectionRegion.dimensions = { right: pageX, }; break; } case "mover-bottomRight": { this.selectionRegion.dimensions = { right: pageX, bottom: pageY, }; break; } case "mover-bottom": { this.selectionRegion.dimensions = { bottom: pageY, }; break; } case "mover-bottomLeft": { this.selectionRegion.dimensions = { left: pageX, bottom: pageY, }; break; } case "mover-left": { this.selectionRegion.dimensions = { left: pageX }; break; } case "highlight": { let diffX = this.#lastPageX - pageX; let diffY = this.#lastPageY - pageY; let newLeft; let newRight; let newTop; let newBottom; // Unpack dimensions to use here let { left: boxLeft, top: boxTop, right: boxRight, bottom: boxBottom, width: boxWidth, height: boxHeight, } = this.selectionRegion.dimensions; let { scrollWidth, scrollHeight } = this.windowDimensions.dimensions; // wait until all 4 if elses have completed before setting box dimensions if (boxWidth <= this.#previousDimensions.width && boxLeft === 0) { newLeft = boxRight - this.#previousDimensions.width; } else { newLeft = boxLeft; } if ( boxWidth <= this.#previousDimensions.width && boxRight === scrollWidth ) { newRight = boxLeft + this.#previousDimensions.width; } else { newRight = boxRight; } if (boxHeight <= this.#previousDimensions.height && boxTop === 0) { newTop = boxBottom - this.#previousDimensions.height; } else { newTop = boxTop; } if ( boxHeight <= this.#previousDimensions.height && boxBottom === scrollHeight ) { newBottom = boxTop + this.#previousDimensions.height; } else { newBottom = boxBottom; } this.selectionRegion.dimensions = { left: newLeft - diffX, top: newTop - diffY, right: newRight - diffX, bottom: newBottom - diffY, }; this.#lastPageX = pageX; this.#lastPageY = pageY; break; } } this.drawSelectionContainer(); } /** * If there is a valid element region, update and draw the selection * container and set the state to selected. * Otherwise set the state to crosshairs. */ draggingReadyDragEnd() { if (this.hoverElementRegion.isRegionValid) { this.selectionRegion.dimensions = this.hoverElementRegion.dimensions; this.#setState(STATES.SELECTED); this.setFocusToActionButton(); this.#dispatchEvent("Screenshots:RecordEvent", { eventName: "selected", reason: "element", }); this.#methodsUsed.element += 1; } else { this.#setState(STATES.CROSSHAIRS); } } /** * Update the selection region dimensions and set the state to selected. * @param {Number} pageX The x position relative to the page * @param {Number} pageY The y position relative to the page */ draggingDragEnd(pageX, pageY) { this.selectionRegion.dimensions = { right: pageX, bottom: pageY, }; this.selectionRegion.sortCoords(); this.#setState(STATES.SELECTED); this.maybeRecordRegionSelected(); this.#methodsUsed.region += 1; this.setFocusToActionButton(); } /** * Update the selection region dimensions by calling `resizingDrag` and set * the state to selected. * @param {Number} pageX The x position relative to the page * @param {Number} pageY The y position relative to the page */ resizingDragEnd(pageX, pageY) { this.resizingDrag(pageX, pageY); this.selectionRegion.sortCoords(); this.#setState(STATES.SELECTED); this.setFocusToActionButton(); this.maybeRecordRegionSelected(); if (this.#moverId === "highlight") { this.#methodsUsed.move += 1; } else { this.#methodsUsed.resize += 1; } } maybeRecordRegionSelected() { let { width, height } = this.selectionRegion.dimensions; if ( !this.#previousDimensions || (Math.abs(this.#previousDimensions.width - width) > REGION_CHANGE_THRESHOLD && Math.abs(this.#previousDimensions.height - height) > REGION_CHANGE_THRESHOLD) ) { this.#dispatchEvent("Screenshots:RecordEvent", { eventName: "selected", reason: "region_selection", }); } this.#previousDimensions = { width, height }; } /** * Draw the preview eyes pointer towards the mouse. * @param {Number} clientX The x position relative to the viewport * @param {Number} clientY The y position relative to the viewport */ drawPreviewEyes(clientX, clientY) { let { clientWidth, clientHeight } = this.windowDimensions.dimensions; const xpos = Math.floor((10 * (clientX - clientWidth / 2)) / clientWidth); const ypos = Math.floor((10 * (clientY - clientHeight / 2)) / clientHeight); const move = `transform:translate(${xpos}px, ${ypos}px);`; this.leftEye.style = move; this.rightEye.style = move; } showPreviewContainer() { this.previewContainer.hidden = false; } hidePreviewContainer() { this.previewContainer.hidden = true; } updatePreviewContainer() { let { clientWidth, clientHeight } = this.windowDimensions.dimensions; this.previewContainer.style.width = `${clientWidth}px`; this.previewContainer.style.height = `${clientHeight}px`; } /** * Update the screenshots overlay container based on the window dimensions. */ updateScreenshotsOverlayContainer() { let { scrollWidth, scrollHeight } = this.windowDimensions.dimensions; this.screenshotsContainer.style = `width:${scrollWidth}px;height:${scrollHeight}px;`; } showScreenshotsOverlayContainer() { this.screenshotsContainer.hidden = false; } hideScreenshotsOverlayContainer() { this.screenshotsContainer.hidden = true; } /** * Draw the hover element container based on the hover element region. */ drawHoverElementRegion() { this.showHoverElementContainer(); let { top, left, width, height } = this.hoverElementRegion.dimensions; this.hoverElementContainer.style = `top:${top}px;left:${left}px;width:${width}px;height:${height}px;`; } showHoverElementContainer() { this.hoverElementContainer.hidden = false; } hideHoverElementContainer() { this.hoverElementContainer.hidden = true; } /** * Draw each background element and the highlight element base on the * selection region. */ drawSelectionContainer() { this.showSelectionContainer(); let { top, left, right, bottom, width, height } = this.selectionRegion.dimensions; this.highlightEl.style = `top:${top}px;left:${left}px;width:${width}px;height:${height}px;`; this.leftBackgroundEl.style = `top:${top}px;width:${left}px;height:${height}px;`; this.topBackgroundEl.style.height = `${top}px`; this.rightBackgroundEl.style = `top:${top}px;left:${right}px;width:calc(100% - ${right}px);height:${height}px;`; this.bottomBackgroundEl.style = `top:${bottom}px;height:calc(100% - ${bottom}px);`; this.updateSelectionSizeText(); } /** * Update the size of the selected region. Use the zoom to correctly display * the region dimensions. */ updateSelectionSizeText() { let { width, height } = this.selectionRegion.dimensions; let zoom = Math.round(this.window.browsingContext.fullZoom * 100) / 100; let [selectionSizeTranslation] = lazy.overlayLocalization.formatMessagesSync([ { id: "screenshots-overlay-selection-region-size-2", args: { width: Math.floor(width * zoom), height: Math.floor(height * zoom), }, }, ]); this.selectionSize.textContent = selectionSizeTranslation.value; } showSelectionContainer() { this.selectionContainer.hidden = false; } hideSelectionContainer() { this.selectionContainer.hidden = true; } /** * Draw the buttons container in the bottom right corner of the selection * container if possible. * The buttons will be visible in the viewport if the selection container * is within the viewport, otherwise skip drawing the buttons. */ drawButtonsContainer() { this.showButtonsContainer(); let { left: boxLeft, top: boxTop, right: boxRight, bottom: boxBottom, } = this.selectionRegion.dimensions; let { clientWidth, clientHeight, scrollX, scrollY } = this.windowDimensions.dimensions; if ( boxTop > scrollY + clientHeight || boxBottom < scrollY || boxLeft > scrollX + clientWidth || boxRight < scrollX ) { // The box is offscreen so need to draw the buttons return; } let top = boxBottom; if (scrollY + clientHeight - boxBottom < 70) { if (boxBottom < scrollY + clientHeight) { top = boxBottom - 60; } else if (scrollY + clientHeight - boxTop < 70) { top = boxTop - 60; } else { top = scrollY + clientHeight - 60; } } if (boxRight < 300) { this.buttonsContainer.style.left = `${boxLeft}px`; this.buttonsContainer.style.right = ""; } else { this.buttonsContainer.style.right = `calc(100% - ${boxRight}px)`; this.buttonsContainer.style.left = ""; } this.buttonsContainer.style.top = `${top}px`; } showButtonsContainer() { this.buttonsContainer.hidden = false; } hideButtonsContainer() { this.buttonsContainer.hidden = true; } /** * Set the pointer events to none on the screenshots elements so * elementFromPoint can find the real element at the given point. */ setPointerEventsNone() { this.screenshotsContainer.style.pointerEvents = "none"; } resetPointerEvents() { this.screenshotsContainer.style.pointerEvents = ""; } /** * Try to find a reasonable element for a given point. * If a reasonable element is found, draw the hover element container for * that element region. * @param {Number} clientX The x position relative to the viewport * @param {Number} clientY The y position relative to the viewport */ async handleElementHover(clientX, clientY) { this.setPointerEventsNone(); let promise = getElementFromPoint(clientX, clientY, this.document); this.resetPointerEvents(); let { ele, rect } = await promise; if ( this.#cachedEle && !this.window.HTMLIFrameElement.isInstance(this.#cachedEle) && this.#cachedEle === ele ) { // Still hovering over the same element return; } this.#cachedEle = ele; if (!rect) { // this means we found an element that wasn't an iframe rect = getBestRectForElement(ele, this.document); } if (rect) { let { scrollX, scrollY } = this.windowDimensions.dimensions; let { left, top, right, bottom } = rect; let newRect = { left: left + scrollX, top: top + scrollY, right: right + scrollX, bottom: bottom + scrollY, }; this.hoverElementRegion.dimensions = newRect; this.drawHoverElementRegion(); } else { this.hoverElementRegion.resetDimensions(); this.hideHoverElementContainer(); } } /** * Scroll the viewport if near one or both of the edges. * @param {Number} pageX The x position relative to the page * @param {Number} pageY The y position relative to the page */ scrollIfByEdge(pageX, pageY) { let { scrollX, scrollY, clientWidth, clientHeight } = this.windowDimensions.dimensions; if (pageY - scrollY < SCROLL_BY_EDGE) { // Scroll up this.scrollWindow(0, -(SCROLL_BY_EDGE - (pageY - scrollY))); } else if (scrollY + clientHeight - pageY < SCROLL_BY_EDGE) { // Scroll down this.scrollWindow(0, SCROLL_BY_EDGE - (scrollY + clientHeight - pageY)); } if (pageX - scrollX <= SCROLL_BY_EDGE) { // Scroll left this.scrollWindow(-(SCROLL_BY_EDGE - (pageX - scrollX)), 0); } else if (scrollX + clientWidth - pageX <= SCROLL_BY_EDGE) { // Scroll right this.scrollWindow(SCROLL_BY_EDGE - (scrollX + clientWidth - pageX), 0); } } /** * Scroll the window by the given amount. * @param {Number} x The x amount to scroll * @param {Number} y The y amount to scroll */ scrollWindow(x, y) { this.window.scrollBy(x, y); this.updateScreenshotsOverlayDimensions("scroll"); } /** * The page was resized or scrolled. We need to update the screenshots * container size so we don't draw outside the page bounds. * @param {String} eventType will be "scroll" or "resize" */ async updateScreenshotsOverlayDimensions(eventType) { let updateWindowDimensionsPromise = this.updateWindowDimensions(); if (this.#state === STATES.CROSSHAIRS) { if (eventType === "resize") { this.hideHoverElementContainer(); this.#cachedEle = null; } else if (eventType === "scroll") { if (this.#lastClientX && this.#lastClientY) { this.#cachedEle = null; this.handleElementHover(this.#lastClientX, this.#lastClientY); } } } else if (this.#state === STATES.SELECTED) { await updateWindowDimensionsPromise; this.selectionRegion.shift(); this.drawSelectionContainer(); this.drawButtonsContainer(); this.updateSelectionSizeText(); } } /** * Returns the window's dimensions for the current window. * * @return {Object} An object containing window dimensions * { * clientWidth: The width of the viewport * clientHeight: The height of the viewport * scrollWidth: The width of the enitre page * scrollHeight: The height of the entire page * scrollX: The X scroll offset of the viewport * scrollY: The Y scroll offest of the viewport * scrollMinX: The X mininmun the viewport can scroll to * scrollMinY: The Y mininmun the viewport can scroll to * } */ getDimensionsFromWindow() { let { innerHeight, innerWidth, scrollMaxY, scrollMaxX, scrollMinY, scrollMinX, scrollY, scrollX, } = this.window; let scrollWidth = innerWidth + scrollMaxX - scrollMinX; let scrollHeight = innerHeight + scrollMaxY - scrollMinY; let clientHeight = innerHeight; let clientWidth = innerWidth; const scrollbarHeight = {}; const scrollbarWidth = {}; this.window.windowUtils.getScrollbarSize( false, scrollbarWidth, scrollbarHeight ); scrollWidth -= scrollbarWidth.value; scrollHeight -= scrollbarHeight.value; clientWidth -= scrollbarWidth.value; clientHeight -= scrollbarHeight.value; return { clientWidth, clientHeight, scrollWidth, scrollHeight, scrollX, scrollY, scrollMinX, scrollMinY, }; } /** * We have to be careful not to draw the overlay larger than the document * because the overlay is absolutely position and within the document so we * can cause the document to overflow when it shouldn't. To mitigate this, * we will temporarily position the overlay to position fixed with width and * height 100% so the overlay is within the document bounds. Then we will get * the dimensions of the document to correctly draw the overlay. */ async updateWindowDimensions() { // Setting the screenshots container attribute "resizing" will make the // overlay fixed position with width and height of 100% percent so it // does not draw outside the actual document. this.screenshotsContainer.toggleAttribute("resizing", true); await new Promise(r => this.window.requestAnimationFrame(r)); let { clientWidth, clientHeight, scrollWidth, scrollHeight, scrollX, scrollY, scrollMinX, scrollMinY, } = this.getDimensionsFromWindow(); this.screenshotsContainer.toggleAttribute("resizing", false); this.windowDimensions.dimensions = { clientWidth, clientHeight, scrollWidth, scrollHeight, scrollX, scrollY, scrollMinX, scrollMinY, devicePixelRatio: this.window.devicePixelRatio, }; this.updatePreviewContainer(); this.updateScreenshotsOverlayContainer(); setMaxDetectHeight(Math.max(clientHeight + 100, 700)); setMaxDetectWidth(Math.max(clientWidth + 100, 1000)); } }