diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs | 1593 |
1 files changed, 1593 insertions, 0 deletions
diff --git a/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs b/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs new file mode 100644 index 0000000000..5d96a46c88 --- /dev/null +++ b/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs @@ -0,0 +1,1593 @@ +/* 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"; + +const STATES = { + CROSSHAIRS: "crosshairs", + DRAGGING_READY: "draggingReady", + DRAGGING: "dragging", + SELECTED: "selected", + RESIZING: "resizing", +}; + +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "overlayLocalization", () => { + return new Localization(["browser/screenshotsOverlay.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 [cancel, instructions, download, copy] = + lazy.overlayLocalization.formatMessagesSync([ + { id: "screenshots-overlay-cancel-button" }, + { id: "screenshots-overlay-instructions" }, + { id: "screenshots-overlay-download-button" }, + { id: "screenshots-overlay-copy-button" }, + ]); + + return ` + <template> + <link rel="stylesheet" href="chrome://browser/content/screenshots/overlay/overlay.css" /> + <div id="screenshots-component"> + <div id="preview-container" hidden> + <div class="face-container"> + <div class="eye left"><div id="left-eye" class="eyeball"></div></div> + <div class="eye right"><div id="right-eye" class="eyeball"></div></div> + <div class="face"></div> + </div> + <div class="preview-instructions">${instructions.value}</div> + <button class="screenshots-button ghost-button" id="screenshots-cancel-button">${cancel.value}</button> + </div> + <div id="hover-highlight" hidden></div> + <div id="selection-container" hidden> + <div id="top-background" class="bghighlight"></div> + <div id="bottom-background" class="bghighlight"></div> + <div id="left-background" class="bghighlight"></div> + <div id="right-background" class="bghighlight"></div> + <div id="highlight" class="highlight" tabindex="0"> + <div id="mover-topLeft" class="mover-target direction-topLeft" tabindex="0"> + <div class="mover"></div> + </div> + <div id="mover-top" class="mover-target direction-top"> + <div class="mover"></div> + </div> + <div id="mover-topRight" class="mover-target direction-topRight" tabindex="0"> + <div class="mover"></div> + </div> + <div id="mover-left" class="mover-target direction-left"> + <div class="mover"></div> + </div> + <div id="mover-right" class="mover-target direction-right"> + <div class="mover"></div> + </div> + <div id="mover-bottomLeft" class="mover-target direction-bottomLeft" tabindex="0"> + <div class="mover"></div> + </div> + <div id="mover-bottom" class="mover-target direction-bottom"> + <div class="mover"></div> + </div> + <div id="mover-bottomRight" class="mover-target direction-bottomRight" tabindex="0"> + <div class="mover"></div> + </div> + <div id="selection-size-container"> + <span id="selection-size"></span> + </div> + </div> + </div> + <div id="buttons-container" hidden> + <div class="buttons-wrapper"> + <button id="cancel" class="screenshots-button" title="${cancel.value}" aria-label="${cancel.value}" tabindex="0"><img/></button> + <button id="copy" class="screenshots-button" title="${copy.value}" aria-label="${copy.value}" tabindex="0"><img/>${copy.value}</button> + <button id="download" class="screenshots-button primary" title="${download.value}" aria-label="${download.value}" tabindex="0"><img/>${download.value}</button> + </div> + </div> + </div> + </template>`; + } + + 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(); + } + + 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(); + 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) { + if (event.button > 0) { + return; + } + + 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; + } + } + + handleClick(event) { + switch (event.originalTarget.id) { + case "screenshots-cancel-button": + case "cancel": + this.maybeCancelScreenshots(); + break; + case "copy": + this.#dispatchEvent("Screenshots:Copy", { + region: this.selectionRegion.dimensions, + }); + break; + case "download": + this.#dispatchEvent("Screenshots:Download", { + region: this.selectionRegion.dimensions, + }); + 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) { + 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) { + 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; + } + } + + /** + * 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 }); + } else { + this.downloadButton.focus({ focusVisible: 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.dispatchEvent( + 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; + } + } + } + + /** + * 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(); + } + + updateSelectionSizeText() { + let dpr = this.windowDimensions.devicePixelRatio; + let { width, height } = this.selectionRegion.dimensions; + + let [selectionSizeTranslation] = + lazy.overlayLocalization.formatMessagesSync([ + { + id: "screenshots-overlay-selection-region-size", + args: { + width: Math.floor(width * dpr), + height: Math.floor(height * dpr), + }, + }, + ]); + 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)); + } +} |