diff options
Diffstat (limited to 'browser/components/screenshots')
49 files changed, 9587 insertions, 0 deletions
diff --git a/browser/components/screenshots/ScreenshotsHelperChild.sys.mjs b/browser/components/screenshots/ScreenshotsHelperChild.sys.mjs new file mode 100644 index 0000000000..550c3fb13c --- /dev/null +++ b/browser/components/screenshots/ScreenshotsHelperChild.sys.mjs @@ -0,0 +1,47 @@ +/* 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/. */ + +import { + getBestRectForElement, + getElementFromPoint, +} from "chrome://browser/content/screenshots/overlayHelpers.mjs"; + +/** + * This class is used to get the dimensions of hovered elements within iframes. + * The main content process cannot get the dimensions of elements within + * iframes so a message will be send to this actor to get the dimensions of the + * element for a given point inside the iframe. + */ +export class ScreenshotsHelperChild extends JSWindowActorChild { + receiveMessage(message) { + if (message.name === "ScreenshotsHelper:GetElementRectFromPoint") { + return this.getBestElementRectFromPoint(message.data); + } + return null; + } + + async getBestElementRectFromPoint(data) { + let { x, y } = data; + + x -= this.contentWindow.mozInnerScreenX; + y -= this.contentWindow.mozInnerScreenY; + + let { ele, rect } = await getElementFromPoint(x, y, this.document); + + if (!rect) { + rect = getBestRectForElement(ele, this.document); + } + + if (rect) { + rect = { + left: rect.left + this.contentWindow.mozInnerScreenX, + right: rect.right + this.contentWindow.mozInnerScreenX, + top: rect.top + this.contentWindow.mozInnerScreenY, + bottom: rect.bottom + this.contentWindow.mozInnerScreenY, + }; + } + + return rect; + } +} 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)); + } +} diff --git a/browser/components/screenshots/ScreenshotsUtils.sys.mjs b/browser/components/screenshots/ScreenshotsUtils.sys.mjs new file mode 100644 index 0000000000..68e4f896bf --- /dev/null +++ b/browser/components/screenshots/ScreenshotsUtils.sys.mjs @@ -0,0 +1,993 @@ +/* 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/. */ + +import { getFilename } from "chrome://browser/content/screenshots/fileHelpers.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const SCREENSHOTS_LAST_SCREENSHOT_METHOD_PREF = + "screenshots.browser.component.last-screenshot-method"; +const SCREENSHOTS_LAST_SAVED_METHOD_PREF = + "screenshots.browser.component.last-saved-method"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Downloads: "resource://gre/modules/Downloads.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + AlertsService: ["@mozilla.org/alerts-service;1", "nsIAlertsService"], +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "SCREENSHOTS_LAST_SAVED_METHOD", + SCREENSHOTS_LAST_SAVED_METHOD_PREF, + "download" +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "SCREENSHOTS_LAST_SCREENSHOT_METHOD", + SCREENSHOTS_LAST_SCREENSHOT_METHOD_PREF, + "visible" +); + +ChromeUtils.defineLazyGetter(lazy, "screenshotsLocalization", () => { + return new Localization(["browser/screenshots.ftl"], true); +}); + +// The max dimension for a canvas is 32,767 https://searchfox.org/mozilla-central/rev/f40d29a11f2eb4685256b59934e637012ea6fb78/gfx/cairo/cairo/src/cairo-image-surface.c#62. +// The max number of pixels for a canvas is 472,907,776 pixels (i.e., 22,528 x 20,992) https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#maximum_canvas_size +// We have to limit screenshots to these dimensions otherwise it will cause an error. +export const MAX_CAPTURE_DIMENSION = 32766; +export const MAX_CAPTURE_AREA = 472907776; +export const MAX_SNAPSHOT_DIMENSION = 1024; + +export class ScreenshotsComponentParent extends JSWindowActorParent { + async receiveMessage(message) { + let region, title; + let browser = message.target.browsingContext.topFrameElement; + // ignore message from child actors with no associated browser element + if (!browser) { + return; + } + if ( + ScreenshotsUtils.getUIPhase(browser) == UIPhases.CLOSED && + !ScreenshotsUtils.browserToScreenshotsState.has(browser) + ) { + // We've already exited or never opened and there's no UI or state that could + // handle this message. We additionally check for screenshot-state to ensure we + // don't ignore an overlay message when there is no current selection - which + // otherwise looks like the UIPhases.CLOSED state. + return; + } + switch (message.name) { + case "Screenshots:CancelScreenshot": + let { reason } = message.data; + ScreenshotsUtils.cancel(browser, reason); + break; + case "Screenshots:CopyScreenshot": + ScreenshotsUtils.closePanel(browser); + ({ region } = message.data); + await ScreenshotsUtils.copyScreenshotFromRegion(region, browser); + ScreenshotsUtils.exit(browser); + break; + case "Screenshots:DownloadScreenshot": + ScreenshotsUtils.closePanel(browser); + ({ title, region } = message.data); + await ScreenshotsUtils.downloadScreenshotFromRegion( + title, + region, + browser + ); + ScreenshotsUtils.exit(browser); + break; + case "Screenshots:OverlaySelection": + ScreenshotsUtils.setPerBrowserState(browser, { + hasOverlaySelection: message.data.hasSelection, + }); + break; + case "Screenshots:ShowPanel": + ScreenshotsUtils.openPanel(browser); + break; + case "Screenshots:HidePanel": + ScreenshotsUtils.closePanel(browser); + break; + } + } + + didDestroy() { + // When restoring a crashed tab the browser is null + let browser = this.browsingContext.topFrameElement; + if (browser) { + ScreenshotsUtils.exit(browser); + } + } +} + +export class ScreenshotsHelperParent extends JSWindowActorParent { + receiveMessage(message) { + switch (message.name) { + case "ScreenshotsHelper:GetElementRectFromPoint": + let cxt = BrowsingContext.get(message.data.bcId); + return cxt.currentWindowGlobal + .getActor("ScreenshotsHelper") + .sendQuery("ScreenshotsHelper:GetElementRectFromPoint", message.data); + } + return null; + } +} + +export const UIPhases = { + CLOSED: 0, // nothing showing + INITIAL: 1, // panel and overlay showing + OVERLAYSELECTION: 2, // something selected in the overlay + PREVIEW: 3, // preview dialog showing +}; + +export var ScreenshotsUtils = { + browserToScreenshotsState: new WeakMap(), + initialized: false, + methodsUsed: {}, + + /** + * Figures out which of various states the screenshots UI is in, for the given browser. + * @param browser The selected browser + * @returns One of the `UIPhases` constants + */ + getUIPhase(browser) { + let perBrowserState = this.browserToScreenshotsState.get(browser); + if (perBrowserState?.previewDialog) { + return UIPhases.PREVIEW; + } + const buttonsPanel = this.panelForBrowser(browser); + if (buttonsPanel && !buttonsPanel.hidden) { + return UIPhases.INITIAL; + } + if (perBrowserState?.hasOverlaySelection) { + return UIPhases.OVERLAYSELECTION; + } + return UIPhases.CLOSED; + }, + + resetMethodsUsed() { + this.methodsUsed = { fullpage: 0, visible: 0 }; + }, + + initialize() { + if (!this.initialized) { + if ( + !Services.prefs.getBoolPref( + "screenshots.browser.component.enabled", + false + ) + ) { + return; + } + this.resetMethodsUsed(); + Services.telemetry.setEventRecordingEnabled("screenshots", true); + Services.obs.addObserver(this, "menuitem-screenshot"); + this.initialized = true; + if (Cu.isInAutomation) { + Services.obs.notifyObservers(null, "screenshots-component-initialized"); + } + } + }, + + uninitialize() { + if (this.initialized) { + Services.obs.removeObserver(this, "menuitem-screenshot"); + this.initialized = false; + } + }, + + handleEvent(event) { + // Escape should cancel and exit + if (event.type === "keydown" && event.key === "Escape") { + let browser = event.view.gBrowser.selectedBrowser; + this.cancel(browser, "escape"); + } + }, + + observe(subj, topic, data) { + let { gBrowser } = subj; + let browser = gBrowser.selectedBrowser; + + switch (topic) { + case "menuitem-screenshot": { + const uiPhase = this.getUIPhase(browser); + if (uiPhase !== UIPhases.CLOSED) { + // toggle from already-open to closed + this.cancel(browser, data); + return; + } + this.start(browser, data); + break; + } + } + }, + + /** + * Notify screenshots when screenshot command is used. + * @param window The current window the screenshot command was used. + * @param type The type of screenshot taken. Used for telemetry. + */ + notify(window, type) { + if (Services.prefs.getBoolPref("screenshots.browser.component.enabled")) { + Services.obs.notifyObservers( + window.event.currentTarget.ownerGlobal, + "menuitem-screenshot", + type + ); + } else { + Services.obs.notifyObservers(null, "menuitem-screenshot-extension", type); + } + }, + + /** + * Creates/gets and returns a Screenshots actor. + * + * @param browser The current browser. + * @returns JSWindowActor The screenshot actor. + */ + getActor(browser) { + let actor = browser.browsingContext.currentWindowGlobal.getActor( + "ScreenshotsComponent" + ); + return actor; + }, + + /** + * Show the Screenshots UI and start the capture flow + * @param browser The current browser. + * @param reason [string] Optional reason string passed along when recording telemetry events + */ + start(browser, reason = "") { + const uiPhase = this.getUIPhase(browser); + switch (uiPhase) { + case UIPhases.CLOSED: + this.captureFocusedElement(browser, "previousFocusRef"); + this.showPanelAndOverlay(browser, reason); + break; + case UIPhases.INITIAL: + // nothing to do, panel & overlay are already open + break; + case UIPhases.PREVIEW: { + this.closeDialogBox(browser); + this.showPanelAndOverlay(browser, reason); + break; + } + } + }, + + /** + * Exit the Screenshots UI for the given browser + * Closes any of open UI elements (preview dialog, panel, overlay) and cleans up internal state. + * @param browser The current browser. + */ + exit(browser) { + this.captureFocusedElement(browser, "currentFocusRef"); + this.closeDialogBox(browser); + this.closePanel(browser); + this.closeOverlay(browser); + this.resetMethodsUsed(); + this.attemptToRestoreFocus(browser); + + this.browserToScreenshotsState.delete(browser); + if (Cu.isInAutomation) { + Services.obs.notifyObservers(null, "screenshots-exit"); + } + }, + + /** + * Cancel/abort the screenshots operation for the given browser + * + * @param browser The current browser. + */ + cancel(browser, reason) { + this.recordTelemetryEvent("canceled", reason, {}); + this.exit(browser); + }, + + /** + * Update internal UI state associated with the given browser + * + * @param browser The current browser. + * @param nameValues {object} An object with one or more named property values + */ + setPerBrowserState(browser, nameValues = {}) { + if (!this.browserToScreenshotsState.has(browser)) { + // we should really have this state already, created when the preview dialog was opened + this.browserToScreenshotsState.set(browser, {}); + } + let perBrowserState = this.browserToScreenshotsState.get(browser); + Object.assign(perBrowserState, nameValues); + }, + + /** + * Attempt to place focus on the element that had focus before screenshots UI was shown + * + * @param browser The current browser. + */ + attemptToRestoreFocus(browser) { + const document = browser.ownerDocument; + const window = browser.ownerGlobal; + + const doFocus = () => { + // Move focus it back to where it was previously. + prevFocus.setAttribute("refocused-by-panel", true); + try { + let fm = Services.focus; + fm.setFocus(prevFocus, fm.FLAG_NOSCROLL); + } catch (e) { + prevFocus.focus(); + } + prevFocus.removeAttribute("refocused-by-panel"); + let focusedElement; + try { + focusedElement = document.commandDispatcher.focusedElement; + if (!focusedElement) { + focusedElement = document.activeElement; + } + } catch (ex) { + focusedElement = document.activeElement; + } + }; + + let perBrowserState = this.browserToScreenshotsState.get(browser) || {}; + let prevFocus = perBrowserState.previousFocusRef?.get(); + let currentFocus = perBrowserState.currentFocusRef?.get(); + delete perBrowserState.currentFocusRef; + + // Avoid changing focus if focus changed during exit - perhaps exit was caused + // by a user action which resulted in focus moving + let nowFocus; + try { + nowFocus = document.commandDispatcher.focusedElement; + } catch (e) { + nowFocus = document.activeElement; + } + if (nowFocus && nowFocus != currentFocus) { + return; + } + + let dialog = this.getDialog(browser); + let panel = this.panelForBrowser(browser); + + if (prevFocus) { + // Try to restore focus + try { + if (document.commandDispatcher.focusedWindow != window) { + // Focus has already been set to a different window + return; + } + } catch (ex) {} + + if (!currentFocus) { + doFocus(); + return; + } + while (currentFocus) { + if ( + (dialog && currentFocus == dialog) || + (panel && currentFocus == panel) || + currentFocus == browser + ) { + doFocus(); + return; + } + currentFocus = currentFocus.parentNode; + if ( + currentFocus && + currentFocus.nodeType == currentFocus.DOCUMENT_FRAGMENT_NODE && + currentFocus.host + ) { + // focus was in a shadowRoot, we'll try the host", + currentFocus = currentFocus.host; + } + } + doFocus(); + } + }, + + /** + * Set a flag so we don't try to exit when preview dialog next closes. + * + * @param browser The current browser. + * @param reason [string] Optional reason string passed along when recording telemetry events + */ + scheduleRetry(browser, reason) { + let perBrowserState = this.browserToScreenshotsState.get(browser); + if (!perBrowserState?.closedPromise) { + console.warn( + "Expected perBrowserState with a closedPromise for the preview dialog" + ); + return; + } + this.setPerBrowserState(browser, { exitOnPreviewClose: false }); + perBrowserState?.closedPromise.then(() => { + this.start(browser, reason); + }); + }, + + /** + * Open the tab dialog for preview + * + * @param browser The current browser + */ + async openPreviewDialog(browser) { + let dialogBox = browser.ownerGlobal.gBrowser.getTabDialogBox(browser); + let { dialog, closedPromise } = await dialogBox.open( + `chrome://browser/content/screenshots/screenshots.html?browsingContextId=${browser.browsingContext.id}`, + { + features: "resizable=no", + sizeTo: "available", + allowDuplicateDialogs: false, + }, + browser + ); + + this.setPerBrowserState(browser, { + previewDialog: dialog, + exitOnPreviewClose: true, + closedPromise: closedPromise.finally(() => { + this.onDialogClose(browser); + }), + }); + return dialog; + }, + + /** + * Take a weak-reference to whatever element currently has focus and associate it with + * the UI state for this browser. + * + * @param browser The current browser. + * @param {string} stateRefName The property name for this element reference. + */ + captureFocusedElement(browser, stateRefName) { + let document = browser.ownerDocument; + let focusedElement; + try { + focusedElement = document.commandDispatcher.focusedElement; + if (!focusedElement) { + focusedElement = document.activeElement; + } + } catch (ex) { + focusedElement = document.activeElement; + } + this.setPerBrowserState(browser, { + [stateRefName]: Cu.getWeakReference(focusedElement), + }); + }, + + /** + * Returns the buttons panel for the given browser + * @param browser The current browser + * @returns The buttons panel + */ + panelForBrowser(browser) { + return browser.ownerDocument.getElementById("screenshotsPagePanel"); + }, + + /** + * Create the buttons container from its template, for this browser + * @param browser The current browser + * @returns The buttons panel + */ + createPanelForBrowser(browser) { + let buttonsPanel = this.panelForBrowser(browser); + if (!buttonsPanel) { + let doc = browser.ownerDocument; + let template = doc.getElementById("screenshotsPagePanelTemplate"); + let fragmentClone = template.content.cloneNode(true); + buttonsPanel = fragmentClone.firstElementChild; + template.replaceWith(buttonsPanel); + + let anchor = browser.ownerDocument.querySelector("#navigator-toolbox"); + anchor.appendChild(buttonsPanel); + } + + return this.panelForBrowser(browser); + }, + + /** + * Open the buttons panel. + * @param browser The current browser + */ + openPanel(browser) { + let buttonsPanel = this.panelForBrowser(browser); + if (!buttonsPanel.hidden) { + return; + } + buttonsPanel.hidden = false; + buttonsPanel.ownerDocument.addEventListener("keydown", this); + + buttonsPanel + .querySelector("screenshots-buttons") + .focusButton(lazy.SCREENSHOTS_LAST_SCREENSHOT_METHOD); + }, + + /** + * Close the panel + * @param browser The current browser + */ + closePanel(browser) { + let buttonsPanel = this.panelForBrowser(browser); + if (!buttonsPanel) { + return; + } + buttonsPanel.hidden = true; + buttonsPanel.ownerDocument.removeEventListener("keydown", this); + }, + + /** + * If the buttons panel exists and is open we will hide both the panel + * and the overlay. If the overlay is showing, we will hide the overlay. + * Otherwise create or display the buttons. + * @param browser The current browser. + */ + async showPanelAndOverlay(browser, data) { + let actor = this.getActor(browser); + actor.sendAsyncMessage("Screenshots:ShowOverlay"); + this.createPanelForBrowser(browser); + this.recordTelemetryEvent("started", data, {}); + this.openPanel(browser); + }, + + /** + * Close the overlay UI, and clear out internal state if there was an overlay selection + * The overlay lives in the child document; so although closing is actually async, we assume success. + * @param browser The current browser. + */ + closeOverlay(browser, options = {}) { + let actor = this.getActor(browser); + actor?.sendAsyncMessage("Screenshots:HideOverlay", options); + + if (this.browserToScreenshotsState.has(browser)) { + this.setPerBrowserState(browser, { + hasOverlaySelection: false, + }); + } + }, + + /** + * Gets the screenshots dialog box + * @param browser The selected browser + * @returns Screenshots dialog box if it exists otherwise null + */ + getDialog(browser) { + let currTabDialogBox = browser.tabDialogBox; + let browserContextId = browser.browsingContext.id; + if (currTabDialogBox) { + currTabDialogBox.getTabDialogManager(); + let manager = currTabDialogBox.getTabDialogManager(); + let dialogs = manager.hasDialogs && manager.dialogs; + if (dialogs.length) { + for (let dialog of dialogs) { + if ( + dialog._openedURL.endsWith( + `browsingContextId=${browserContextId}` + ) && + dialog._openedURL.includes("screenshots.html") + ) { + return dialog; + } + } + } + } + return null; + }, + + /** + * Closes the dialog box it it exists + * @param browser The selected browser + */ + closeDialogBox(browser) { + let perBrowserState = this.browserToScreenshotsState.get(browser); + if (perBrowserState?.previewDialog) { + perBrowserState.previewDialog.close(); + return true; + } + return false; + }, + + /** + * Callback fired when the preview dialog window closes + * Will exit the screenshots UI if the `exitOnPreviewClose` flag is set for this browser + * @param browser The associated browser + */ + onDialogClose(browser) { + let perBrowserState = this.browserToScreenshotsState.get(browser); + if (!perBrowserState) { + return; + } + delete perBrowserState.previewDialog; + if (perBrowserState?.exitOnPreviewClose) { + this.exit(browser); + } + }, + + /** + * Gets the screenshots button if it is visible, otherwise it will get the + * element that the screenshots button is nested under. If the screenshots + * button doesn't exist then we will default to the navigator toolbox. + * @param browser The selected browser + * @returns The anchor element for the ConfirmationHint + */ + getWidgetAnchor(browser) { + let window = browser.ownerGlobal; + let widgetGroup = window.CustomizableUI.getWidget("screenshot-button"); + let widget = widgetGroup?.forWindow(window); + let anchor = widget?.anchor; + + // Check if the anchor exists and is visible + if ( + !anchor || + !anchor.isConnected || + !window.isElementVisible(anchor.parentNode) + ) { + // Use the hamburger button if the screenshots button isn't available + anchor = browser.ownerDocument.getElementById("PanelUI-menu-button"); + } + return anchor; + }, + + /** + * Indicate that the screenshot has been copied via ConfirmationHint. + * @param browser The selected browser + */ + showCopiedConfirmationHint(browser) { + let anchor = this.getWidgetAnchor(browser); + + browser.ownerGlobal.ConfirmationHint.show( + anchor, + "confirmation-hint-screenshot-copied" + ); + }, + + /** + * Gets the full page bounds from the screenshots child actor. + * @param browser The current browser. + * @returns { object } + * Contains the full page bounds from the screenshots child actor. + */ + fetchFullPageBounds(browser) { + let actor = this.getActor(browser); + return actor.sendQuery("Screenshots:getFullPageBounds"); + }, + + /** + * Gets the visible bounds from the screenshots child actor. + * @param browser The current browser. + * @returns { object } + * Contains the visible bounds from the screenshots child actor. + */ + fetchVisibleBounds(browser) { + let actor = this.getActor(browser); + return actor.sendQuery("Screenshots:getVisibleBounds"); + }, + + showAlertMessage(title, message) { + lazy.AlertsService.showAlertNotification(null, title, message); + }, + + /** + * The max dimension of any side of a canvas is 32767 and the max canvas area is + * 124925329. If the width or height is greater or equal to 32766 we will crop the + * screenshot to the max width. If the area is still too large for the canvas + * we will adjust the height so we can successfully capture the screenshot. + * @param {Object} rect The dimensions of the screenshot. The rect will be + * modified in place + */ + cropScreenshotRectIfNeeded(rect) { + let cropped = false; + let width = rect.width * rect.devicePixelRatio; + let height = rect.height * rect.devicePixelRatio; + + if (width > MAX_CAPTURE_DIMENSION) { + width = MAX_CAPTURE_DIMENSION; + cropped = true; + } + if (height > MAX_CAPTURE_DIMENSION) { + height = MAX_CAPTURE_DIMENSION; + cropped = true; + } + if (width * height > MAX_CAPTURE_AREA) { + height = Math.floor(MAX_CAPTURE_AREA / width); + cropped = true; + } + + rect.width = Math.floor(width / rect.devicePixelRatio); + rect.height = Math.floor(height / rect.devicePixelRatio); + rect.right = rect.left + rect.width; + rect.bottom = rect.top + rect.height; + + if (cropped) { + let [errorTitle, errorMessage] = + lazy.screenshotsLocalization.formatMessagesSync([ + { id: "screenshots-too-large-error-title" }, + { id: "screenshots-too-large-error-details" }, + ]); + this.showAlertMessage(errorTitle.value, errorMessage.value); + this.recordTelemetryEvent("failed", "screenshot_too_large", null); + } + }, + + /** + * Open and add screenshot-ui to the dialog box and then take the screenshot + * @param browser The current browser. + * @param type The type of screenshot taken. + */ + async doScreenshot(browser, type) { + this.closePanel(browser); + this.closeOverlay(browser, { doNotResetMethods: true }); + + let dialog = await this.openPreviewDialog(browser); + await dialog._dialogReady; + let screenshotsUI = + dialog._frame.contentDocument.createElement("screenshots-ui"); + dialog._frame.contentDocument.body.appendChild(screenshotsUI); + + screenshotsUI.focusButton(lazy.SCREENSHOTS_LAST_SAVED_METHOD); + + let rect; + let lastUsedMethod; + if (type === "full_page") { + rect = await this.fetchFullPageBounds(browser); + lastUsedMethod = "fullpage"; + } else { + rect = await this.fetchVisibleBounds(browser); + lastUsedMethod = "visible"; + } + + Services.prefs.setStringPref( + SCREENSHOTS_LAST_SCREENSHOT_METHOD_PREF, + lastUsedMethod + ); + this.methodsUsed[lastUsedMethod] += 1; + this.recordTelemetryEvent("selected", type, {}); + return this.takeScreenshot(browser, dialog, rect); + }, + + /** + * Take the screenshot and add the image to the dialog box + * @param browser The current browser. + * @param dialog The dialog box to show the screenshot preview. + * @param rect DOMRect containing bounds of the screenshot. + */ + async takeScreenshot(browser, dialog, rect) { + let canvas = await this.createCanvas(rect, browser); + + let newImg = dialog._frame.contentDocument.createElement("img"); + let url = canvas.toDataURL(); + + newImg.id = "placeholder-image"; + + newImg.src = url; + dialog._frame.contentDocument + .getElementById("preview-image-div") + .appendChild(newImg); + + if (Cu.isInAutomation) { + Services.obs.notifyObservers(null, "screenshots-preview-ready"); + } + }, + + /** + * Creates a canvas and draws a snapshot of the screenshot on the canvas + * @param region The bounds of screenshots + * @param browser The current browser + * @returns The canvas + */ + async createCanvas(region, browser) { + region.left = Math.round(region.left); + region.right = Math.round(region.right); + region.top = Math.round(region.top); + region.bottom = Math.round(region.bottom); + region.width = Math.round(region.right - region.left); + region.height = Math.round(region.bottom - region.top); + + this.cropScreenshotRectIfNeeded(region); + + let { devicePixelRatio } = region; + + let browsingContext = BrowsingContext.get(browser.browsingContext.id); + + let canvas = browser.ownerDocument.createElementNS( + "http://www.w3.org/1999/xhtml", + "html:canvas" + ); + let context = canvas.getContext("2d"); + + canvas.width = region.width * devicePixelRatio; + canvas.height = region.height * devicePixelRatio; + + for ( + let startLeft = region.left; + startLeft < region.right; + startLeft += MAX_SNAPSHOT_DIMENSION + ) { + for ( + let startTop = region.top; + startTop < region.bottom; + startTop += MAX_SNAPSHOT_DIMENSION + ) { + let height = + startTop + MAX_SNAPSHOT_DIMENSION > region.bottom + ? region.bottom - startTop + : MAX_SNAPSHOT_DIMENSION; + let width = + startLeft + MAX_SNAPSHOT_DIMENSION > region.right + ? region.right - startLeft + : MAX_SNAPSHOT_DIMENSION; + let rect = new DOMRect(startLeft, startTop, width, height); + + let snapshot = await browsingContext.currentWindowGlobal.drawSnapshot( + rect, + devicePixelRatio, + "rgb(255,255,255)" + ); + + context.drawImage( + snapshot, + (startLeft - region.left) * devicePixelRatio, + (startTop - region.top) * devicePixelRatio, + width * devicePixelRatio, + height * devicePixelRatio + ); + + snapshot.close(); + } + } + + return canvas; + }, + + /** + * Copy the screenshot + * @param region The bounds of the screenshots + * @param browser The current browser + */ + async copyScreenshotFromRegion(region, browser) { + let canvas = await this.createCanvas(region, browser); + let url = canvas.toDataURL(); + + await this.copyScreenshot(url, browser, { + object: "overlay_copy", + }); + }, + + /** + * Copy the image to the clipboard + * This is called from the preview dialog + * @param dataUrl The image data + * @param browser The current browser + * @param data Telemetry data + */ + async copyScreenshot(dataUrl, browser, data) { + // Guard against missing image data. + if (!dataUrl) { + return; + } + + const imageTools = Cc["@mozilla.org/image/tools;1"].getService( + Ci.imgITools + ); + + const base64Data = dataUrl.replace("data:image/png;base64,", ""); + + const image = atob(base64Data); + const imgDecoded = imageTools.decodeImageFromBuffer( + image, + image.length, + "image/png" + ); + + const transferable = Cc[ + "@mozilla.org/widget/transferable;1" + ].createInstance(Ci.nsITransferable); + transferable.init(null); + transferable.addDataFlavor("image/png"); + transferable.setTransferData("image/png", imgDecoded); + + Services.clipboard.setData( + transferable, + null, + Services.clipboard.kGlobalClipboard + ); + + this.showCopiedConfirmationHint(browser); + + let extra = await this.getActor(browser).sendQuery( + "Screenshots:GetMethodsUsed" + ); + this.recordTelemetryEvent("copy", data.object, { + ...extra, + ...this.methodsUsed, + }); + this.resetMethodsUsed(); + + Services.prefs.setStringPref(SCREENSHOTS_LAST_SAVED_METHOD_PREF, "copy"); + }, + + /** + * Download the screenshot + * @param title The title of the current page + * @param region The bounds of the screenshot + * @param browser The current browser + */ + async downloadScreenshotFromRegion(title, region, browser) { + let canvas = await this.createCanvas(region, browser); + let dataUrl = canvas.toDataURL(); + + await this.downloadScreenshot(title, dataUrl, browser, { + object: "overlay_download", + }); + }, + + /** + * Download the screenshot + * This is called from the preview dialog + * @param title The title of the current page or null and getFilename will get the title + * @param dataUrl The image data + * @param browser The current browser + * @param data Telemetry data + */ + async downloadScreenshot(title, dataUrl, browser, data) { + // Guard against missing image data. + if (!dataUrl) { + return; + } + + let filename = await getFilename(title, browser); + + const targetFile = new lazy.FileUtils.File(filename); + + // Create download and track its progress. + try { + const download = await lazy.Downloads.createDownload({ + source: dataUrl, + target: targetFile, + }); + + let isPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate( + browser.ownerGlobal + ); + const list = await lazy.Downloads.getList( + isPrivate ? lazy.Downloads.PRIVATE : lazy.Downloads.PUBLIC + ); + // add the download to the download list in the Downloads list in the Browser UI + list.add(download); + + // Await successful completion of the save via the download manager + await download.start(); + } catch (ex) {} + + let extra = await this.getActor(browser).sendQuery( + "Screenshots:GetMethodsUsed" + ); + this.recordTelemetryEvent("download", data.object, { + ...extra, + ...this.methodsUsed, + }); + this.resetMethodsUsed(); + + Services.prefs.setStringPref( + SCREENSHOTS_LAST_SAVED_METHOD_PREF, + "download" + ); + }, + + recordTelemetryEvent(type, object, args) { + if (args) { + for (let key of Object.keys(args)) { + args[key] = args[key].toString(); + } + } + Services.telemetry.recordEvent("screenshots", type, object, null, args); + }, +}; diff --git a/browser/components/screenshots/content/cancel.svg b/browser/components/screenshots/content/cancel.svg new file mode 100644 index 0000000000..0c176be25f --- /dev/null +++ b/browser/components/screenshots/content/cancel.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M10.5 8.7L5.2 3.3c-.5-.5-1.3-.5-1.8 0s-.5 1.3 0 1.8l5.3 5.3-5.3 5.3c-.5.5-.5 1.3 0 1.8s1.3.5 1.8 0l5.3-5.3 5.3 5.3c.5.5 1.3.5 1.8 0s.5-1.3 0-1.8l-5.3-5.3 5.3-5.3c.5-.5.5-1.3 0-1.8s-1.3-.5-1.8 0l-5.3 5.4z" fill="context-fill #3e3d40"/></svg> diff --git a/browser/components/screenshots/content/copied-notification.svg b/browser/components/screenshots/content/copied-notification.svg new file mode 100644 index 0000000000..2310b41aef --- /dev/null +++ b/browser/components/screenshots/content/copied-notification.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path fill="context-fill" d="M44.121 24.879l-9-9A3 3 0 0 0 33 15h-3v-3a3 3 0 0 0-.879-2.121l-9-9A3 3 0 0 0 18 0H9a6 6 0 0 0-6 6v21a6 6 0 0 0 6 6h9v9a6 6 0 0 0 6 6h15a6 6 0 0 0 6-6V27a3 3 0 0 0-.879-2.121zM37.758 27H33v-4.758zm-15-15H18V7.242zM18 21v6H9V6h6v7.5a1.5 1.5 0 0 0 1.5 1.5H24a6 6 0 0 0-6 6zm6 21V21h6v7.5a1.5 1.5 0 0 0 1.5 1.5H39v12z"/></svg>
\ No newline at end of file diff --git a/browser/components/screenshots/content/copy.svg b/browser/components/screenshots/content/copy.svg new file mode 100644 index 0000000000..3e3d49122c --- /dev/null +++ b/browser/components/screenshots/content/copy.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path fill="context-fill #3e3d40" d="M14.707 8.293l-3-3A1 1 0 0 0 11 5h-1V4a1 1 0 0 0-.293-.707l-3-3A1 1 0 0 0 6 0H3a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h3v3a2 2 0 0 0 2 2h5a2 2 0 0 0 2-2V9a1 1 0 0 0-.293-.707zM12.586 9H11V7.414zm-5-5H6V2.414zM6 7v2H3V2h2v2.5a.5.5 0 0 0 .5.5H8a2 2 0 0 0-2 2zm2 7V7h2v2.5a.5.5 0 0 0 .5.5H13v4z"/></svg> diff --git a/browser/components/screenshots/content/download-white.svg b/browser/components/screenshots/content/download-white.svg new file mode 100644 index 0000000000..bb6a7de845 --- /dev/null +++ b/browser/components/screenshots/content/download-white.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M9.1 12L4.9 7.9c-.5-.5-1.3-.5-1.8 0s-.5 1.3 0 1.8l6.2 6.2c.5.5 1.3.5 1.8 0l6.2-6.2c.5-.5.5-1.3 0-1.8s-1.3-.5-1.8 0L11.6 12V1.2C11.6.6 11 0 10.3 0c-.7 0-1.2.6-1.2 1.2V12zM4 20c-.7 0-1.2-.6-1.2-1.2s.6-1.2 1.2-1.2h12.5c.7 0 1.2.6 1.2 1.2s-.5 1.2-1.2 1.2H4z" fill="#fff"/></svg>
\ No newline at end of file diff --git a/browser/components/screenshots/content/download.svg b/browser/components/screenshots/content/download.svg new file mode 100644 index 0000000000..a85f745937 --- /dev/null +++ b/browser/components/screenshots/content/download.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M9.1 12L4.9 7.9c-.5-.5-1.3-.5-1.8 0s-.5 1.3 0 1.8l6.2 6.2c.5.5 1.3.5 1.8 0l6.2-6.2c.5-.5.5-1.3 0-1.8s-1.3-.5-1.8 0L11.6 12V1.2C11.6.6 11 0 10.3 0c-.7 0-1.2.6-1.2 1.2V12zM4 20c-.7 0-1.2-.6-1.2-1.2s.6-1.2 1.2-1.2h12.5c.7 0 1.2.6 1.2 1.2s-.5 1.2-1.2 1.2H4z" fill="context-fill #3e3d40"/></svg> diff --git a/browser/components/screenshots/content/icon-welcome-face-without-eyes.svg b/browser/components/screenshots/content/icon-welcome-face-without-eyes.svg new file mode 100644 index 0000000000..138308af57 --- /dev/null +++ b/browser/components/screenshots/content/icon-welcome-face-without-eyes.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><style>.st0{fill:#fff}</style><g id="Visual-design"><g id="_x31_.2-Div-selection" transform="translate(-575 -503)"><g id="Introduction" transform="translate(250 503)"><g id="icon-welcomeface" transform="translate(325)"><g id="Layer_1_1_"><path id="Shape" class="st0" d="M11.4.9v2.9h-6c-.9 0-1.5.8-1.5 1.5v6H.8V3.8C.8 2.1 2.2.7 3.9.7h7.6v.2h-.1z"/><path id="Shape_1_" class="st0" d="M63.2 11.4h-3.1v-6c0-.8-.6-1.5-1.5-1.5h-6v-3h7.6c1.7 0 3.1 1.4 3.1 3.1l-.1 7.4z"/><path id="Shape_2_" class="st0" d="M52.6 63.2v-3.1h6c.9 0 1.5-.6 1.5-1.5v-6h3.1v7.6c0 1.7-1.4 3.1-3.1 3.1l-7.5-.1z"/><path id="Shape_3_" class="st0" d="M.8 52.7h3.1v6c0 .9.6 1.5 1.5 1.5h6v3.1H3.8c-1.7 0-3.1-1.4-3.1-3.1l.1-7.5z"/><path id="Shape_6_" class="st0" d="M33.3 49.2H33c-4.6-.1-7.8-3.6-7.9-3.8-.6-.8-.6-2 .1-2.7.8-.8 1.9-.6 2.6.1 0 0 2.3 2.6 5.2 2.6 1.8 0 3.6-.9 5.2-2.6.8-.8 1.9-.8 2.7 0s.8 1.9 0 2.7c-2.2 2.4-4.9 3.7-7.6 3.7z"/></g></g></g></g></g></svg>
\ No newline at end of file diff --git a/browser/components/screenshots/content/menu-fullpage.svg b/browser/components/screenshots/content/menu-fullpage.svg new file mode 100644 index 0000000000..6552ef8cdd --- /dev/null +++ b/browser/components/screenshots/content/menu-fullpage.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 46 46"><path id="bg" d="M7 42h32V5.1H7z" fill="context-stroke #00fdff"/><g id="frame" transform="translate(0 6)"><path d="M40 5c.5 0 1 .4 1 1v24c0 .5-.5 1-1 1H6c-.6 0-1-.5-1-1V6c0-.6.4-1 1-1h34zM7 29h32V7H7v22z" fill="context-fill"/><path id="Fill-4" fill="context-fill" d="M7 7h32V5H7z"/><path id="Fill-6" fill="context-fill" d="M7 31h32v-2H7z"/></g><path id="dash" d="M38 11h1V9h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm-1 1h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-2-3H7v3h2v-1H8v-2zm-1-1h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1V9H7v2zm2-6H7v3h1V6h1V5zm1 1h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm5-1h-2v1h1v2h1V5z" fill="context-fill" opacity="0.5"/></svg>
\ No newline at end of file diff --git a/browser/components/screenshots/content/menu-visible.svg b/browser/components/screenshots/content/menu-visible.svg new file mode 100644 index 0000000000..98cb1bfd3e --- /dev/null +++ b/browser/components/screenshots/content/menu-visible.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 46 46"><path d="M5 12c0-.6.5-1 1-1h34c.6 0 1 .5 1 1v24c0 .6-.5 1-1 1H6c-.6 0-1-.5-1-1V12zm2 23V13h32v22H7z" fill="context-fill"/><path d="M7 35h32V13H7z" fill="context-stroke #00fdff"/><path id="dash" d="M38 19h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm-1 1h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-2-3H7v3h2v-1H8v-2zm-1-1h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm2-6H7v3h1v-2h1v-1zm1 1h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm5-1h-2v1h1v2h1v-3z" fill="context-fill" opacity="0.5"/></svg>
\ No newline at end of file diff --git a/browser/components/screenshots/content/screenshots.css b/browser/components/screenshots/content/screenshots.css new file mode 100644 index 0000000000..506f3658c9 --- /dev/null +++ b/browser/components/screenshots/content/screenshots.css @@ -0,0 +1,68 @@ +/* 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/. */ + +html, +body { + height: 100vh; + width: 100vw; +} + +.image-view { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +.preview-buttons { + display: flex; + align-items: center; + justify-content: flex-end; + width: 100%; + border: 0; + box-sizing: border-box; + margin: 4px 0; + margin-inline-start: calc(-2% + 4px); +} + +.preview-button { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + text-align: center; + user-select: none; + white-space: nowrap; + min-height: 36px; + font-size: 15px; + min-width: 36px; +} + +.preview-button > img { + -moz-context-properties: fill; + fill: currentColor; + width: 16px; + height: 16px; +} + +#download > img, +#copy > img { + margin-inline-end: 5px; +} + +.preview-image { + height: 100%; + width: 100%; + overflow: auto; +} + +#preview-image-div { + margin: 2%; + margin-top: 0; +} + +#placeholder-image { + width: 100%; + height: 100%; +} diff --git a/browser/components/screenshots/content/screenshots.html b/browser/components/screenshots/content/screenshots.html new file mode 100644 index 0000000000..88c71fb4fe --- /dev/null +++ b/browser/components/screenshots/content/screenshots.html @@ -0,0 +1,68 @@ +<!DOCTYPE html> +<!-- 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/. --> +<html> + <head> + <meta charset="utf-8" /> + <title></title> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:;img-src data:; object-src 'none'" + /> + + <link rel="localization" href="browser/screenshots.ftl" /> + + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link + rel="stylesheet" + href="chrome://browser/content/screenshots/screenshots.css" + /> + <script + defer + src="chrome://browser/content/screenshots/screenshots.js" + ></script> + </head> + + <body> + <template id="screenshots-dialog-template"> + <div class="image-view"> + <div class="preview-buttons"> + <button + id="retry" + class="preview-button" + data-l10n-id="screenshots-retry-button-title" + > + <img src="chrome://global/skin/icons/reload.svg" /> + </button> + <button + id="cancel" + class="preview-button" + data-l10n-id="screenshots-cancel-button-title" + > + <img src="chrome://global/skin/icons/close.svg" /> + </button> + <button + id="copy" + class="preview-button" + data-l10n-id="screenshots-copy-button-title" + > + <img src="chrome://global/skin/icons/edit-copy.svg" /> + <span data-l10n-id="screenshots-copy-button" /> + </button> + <button + id="download" + class="preview-button primary" + data-l10n-id="screenshots-download-button-title" + > + <img src="chrome://browser/skin/downloads/downloads.svg" /> + <span data-l10n-id="screenshots-download-button" /> + </button> + </div> + <div class="preview-image"> + <div id="preview-image-div"></div> + </div> + </div> + </template> + </body> +</html> diff --git a/browser/components/screenshots/content/screenshots.js b/browser/components/screenshots/content/screenshots.js new file mode 100644 index 0000000000..9e47570e07 --- /dev/null +++ b/browser/components/screenshots/content/screenshots.js @@ -0,0 +1,105 @@ +/* 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/. */ +/* eslint-env mozilla/browser-window */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs", +}); + +class ScreenshotsUI extends HTMLElement { + constructor() { + super(); + // we get passed the <browser> as a param via TabDialogBox.open() + this.openerBrowser = window.arguments[0]; + } + async connectedCallback() { + this.initialize(); + } + + initialize() { + if (this._initialized) { + return; + } + this._initialized = true; + let template = this.ownerDocument.getElementById( + "screenshots-dialog-template" + ); + let templateContent = template.content; + this.appendChild(templateContent.cloneNode(true)); + + this._retryButton = this.querySelector("#retry"); + this._retryButton.addEventListener("click", this); + this._cancelButton = this.querySelector("#cancel"); + this._cancelButton.addEventListener("click", this); + this._copyButton = this.querySelector("#copy"); + this._copyButton.addEventListener("click", this); + this._downloadButton = this.querySelector("#download"); + this._downloadButton.addEventListener("click", this); + } + + close() { + URL.revokeObjectURL(document.getElementById("placeholder-image").src); + window.close(); + } + + async handleEvent(event) { + if (event.type == "click" && event.currentTarget == this._cancelButton) { + this.close(); + ScreenshotsUtils.recordTelemetryEvent("canceled", "preview_cancel", {}); + } else if ( + event.type == "click" && + event.currentTarget == this._copyButton + ) { + this.saveToClipboard( + this.ownerDocument.getElementById("placeholder-image").src + ); + } else if ( + event.type == "click" && + event.currentTarget == this._downloadButton + ) { + await this.saveToFile( + this.ownerDocument.getElementById("placeholder-image").src + ); + } else if ( + event.type == "click" && + event.currentTarget == this._retryButton + ) { + ScreenshotsUtils.scheduleRetry(this.openerBrowser, "preview_retry"); + this.close(); + } + } + + async saveToFile(dataUrl) { + await ScreenshotsUtils.downloadScreenshot( + null, + dataUrl, + this.openerBrowser, + { object: "preview_download" } + ); + this.close(); + } + + async saveToClipboard(dataUrl) { + await ScreenshotsUtils.copyScreenshot(dataUrl, this.openerBrowser, { + object: "preview_copy", + }); + this.close(); + } + + /** + * Set the focus to the most recent saved method. + * This will default to the download button. + * @param {String} buttonToFocus + */ + focusButton(buttonToFocus) { + if (buttonToFocus === "copy") { + this._copyButton.focus({ focusVisible: true }); + } else { + this._downloadButton.focus({ focusVisible: true }); + } + } +} +customElements.define("screenshots-ui", ScreenshotsUI); diff --git a/browser/components/screenshots/fileHelpers.mjs b/browser/components/screenshots/fileHelpers.mjs new file mode 100644 index 0000000000..42cb868bea --- /dev/null +++ b/browser/components/screenshots/fileHelpers.mjs @@ -0,0 +1,269 @@ +/* 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/. */ + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DownloadLastDir: "resource://gre/modules/DownloadLastDir.sys.mjs", + DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs", + Downloads: "resource://gre/modules/Downloads.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs", +}); + +/** + * Gets the filename automatically or by a file picker depending on "browser.download.useDownloadDir" + * @param filenameTitle The title of the current page + * @param browser The current browser + * @returns Path of the chosen filename + */ +export async function getFilename(filenameTitle, browser) { + if (filenameTitle === null) { + filenameTitle = await lazy.ScreenshotsUtils.getActor(browser).sendQuery( + "Screenshots:getDocumentTitle" + ); + } + const date = new Date(); + /* eslint-disable no-control-regex */ + filenameTitle = filenameTitle + .replace(/[\\/]/g, "_") + .replace(/[\u200e\u200f\u202a-\u202e]/g, "") + .replace(/[\x00-\x1f\x7f-\x9f:*?|"<>;,+=\[\]]+/g, " ") + .replace(/^[\s\u180e.]+|[\s\u180e.]+$/g, ""); + /* eslint-enable no-control-regex */ + filenameTitle = filenameTitle.replace(/\s{1,4000}/g, " "); + const currentDateTime = new Date( + date.getTime() - date.getTimezoneOffset() * 60 * 1000 + ).toISOString(); + const filenameDate = currentDateTime.substring(0, 10); + const filenameTime = currentDateTime.substring(11, 19).replace(/:/g, "-"); + let clipFilename = `Screenshot ${filenameDate} at ${filenameTime} ${filenameTitle}`; + + // Crop the filename size at less than 246 bytes, so as to leave + // room for the extension and an ellipsis [...]. Note that JS + // strings are UTF16 but the filename will be converted to UTF8 + // when saving which could take up more space, and we want a + // maximum of 255 bytes (not characters). Here, we iterate + // and crop at shorter and shorter points until we fit into + // 255 bytes. + let suffix = ""; + for (let cropSize = 246; cropSize >= 0; cropSize -= 32) { + if (new Blob([clipFilename]).size > 246) { + clipFilename = clipFilename.substring(0, cropSize); + suffix = "[...]"; + } else { + break; + } + } + + clipFilename += suffix; + + let extension = ".png"; + let filename = clipFilename + extension; + + let useDownloadDir = Services.prefs.getBoolPref( + "browser.download.useDownloadDir" + ); + if (useDownloadDir) { + const downloadsDir = await lazy.Downloads.getPreferredDownloadsDirectory(); + const downloadsDirExists = await IOUtils.exists(downloadsDir); + if (downloadsDirExists) { + // If filename is absolute, it will override the downloads directory and + // still be applied as expected. + filename = PathUtils.join(downloadsDir, filename); + } + } else { + let fileInfo = new FileInfo(filename); + let file; + + let fpParams = { + fpTitleKey: "SaveImageTitle", + fileInfo, + contentType: "image/png", + saveAsType: 0, + file, + }; + + let accepted = await promiseTargetFile(fpParams, browser.ownerGlobal); + if (!accepted) { + return null; + } + + filename = fpParams.file.path; + } + + return filename; +} + +// The below functions are a modified copy from toolkit/content/contentAreaUtils.js +/** + * Structure for holding info about a URL and the target filename it should be + * saved to. + * @param aFileName The target filename + */ +class FileInfo { + constructor(aFileName) { + this.fileName = aFileName; + this.fileBaseName = aFileName.replace(".png", ""); + this.fileExt = "png"; + } +} + +const ContentAreaUtils = { + get stringBundle() { + delete this.stringBundle; + return (this.stringBundle = Services.strings.createBundle( + "chrome://global/locale/contentAreaCommands.properties" + )); + }, +}; + +function makeFilePicker() { + const fpContractID = "@mozilla.org/filepicker;1"; + const fpIID = Ci.nsIFilePicker; + return Cc[fpContractID].createInstance(fpIID); +} + +function getMIMEService() { + const mimeSvcContractID = "@mozilla.org/mime;1"; + const mimeSvcIID = Ci.nsIMIMEService; + const mimeSvc = Cc[mimeSvcContractID].getService(mimeSvcIID); + return mimeSvc; +} + +function getMIMEInfoForType(aMIMEType, aExtension) { + if (aMIMEType || aExtension) { + try { + return getMIMEService().getFromTypeAndExtension(aMIMEType, aExtension); + } catch (e) {} + } + return null; +} + +// This is only used after the user has entered a filename. +function validateFileName(aFileName) { + let processed = + lazy.DownloadPaths.sanitize(aFileName, { + compressWhitespaces: false, + allowInvalidFilenames: true, + }) || "_"; + if (AppConstants.platform == "android") { + // If a large part of the filename has been sanitized, then we + // will use a default filename instead + if (processed.replace(/_/g, "").length <= processed.length / 2) { + // We purposefully do not use a localized default filename, + // which we could have done using + // ContentAreaUtils.stringBundle.GetStringFromName("UntitledSaveFileName") + // since it may contain invalid characters. + let original = processed; + processed = "download"; + + // Preserve a suffix, if there is one + if (original.includes(".")) { + let suffix = original.split(".").pop(); + if (suffix && !suffix.includes("_")) { + processed += "." + suffix; + } + } + } + } + return processed; +} + +function appendFiltersForContentType( + aFilePicker, + aContentType, + aFileExtension +) { + let mimeInfo = getMIMEInfoForType(aContentType, aFileExtension); + if (mimeInfo) { + let extString = ""; + for (let extension of mimeInfo.getFileExtensions()) { + if (extString) { + extString += "; "; + } // If adding more than one extension, + // separate by semi-colon + extString += "*." + extension; + } + + if (extString) { + aFilePicker.appendFilter(mimeInfo.description, extString); + } + } + + // Always append the all files (*) filter + aFilePicker.appendFilters(Ci.nsIFilePicker.filterAll); +} + +/** + * Given the Filepicker Parameters (aFpP), show the file picker dialog, + * prompting the user to confirm (or change) the fileName. + * @param aFpP + * A structure (see definition in internalSave(...) method) + * containing all the data used within this method. + * @param win + * The window used for opening the file picker + * @return Promise + * @resolve a boolean. When true, it indicates that the file picker dialog + * is accepted. + */ +function promiseTargetFile(aFpP, win) { + return (async function () { + let downloadLastDir = new lazy.DownloadLastDir(win); + + // Default to the user's default downloads directory configured + // through download prefs. + let dirPath = await lazy.Downloads.getPreferredDownloadsDirectory(); + let dirExists = await IOUtils.exists(dirPath); + let dir = new lazy.FileUtils.File(dirPath); + + // We must prompt for the file name explicitly. + // If we must prompt because we were asked to... + let file = await downloadLastDir.getFileAsync(null); + if (file && (await IOUtils.exists(file.path))) { + dir = file; + dirExists = true; + } + + if (!dirExists) { + // Default to desktop. + dir = Services.dirsvc.get("Desk", Ci.nsIFile); + } + + let fp = makeFilePicker(); + let titleKey = aFpP.fpTitleKey; + fp.init( + win, + ContentAreaUtils.stringBundle.GetStringFromName(titleKey), + Ci.nsIFilePicker.modeSave + ); + + fp.displayDirectory = dir; + fp.defaultExtension = aFpP.fileInfo.fileExt; + fp.defaultString = aFpP.fileInfo.fileName; + appendFiltersForContentType(fp, aFpP.contentType, aFpP.fileInfo.fileExt); + + let result = await new Promise(resolve => { + fp.open(function (aResult) { + resolve(aResult); + }); + }); + if (result == Ci.nsIFilePicker.returnCancel || !fp.file) { + return false; + } + + // Do not store the last save directory as a pref inside the private browsing mode + downloadLastDir.setFile(null, fp.file.parent); + + aFpP.saveAsType = fp.filterIndex; + aFpP.file = fp.file; + aFpP.file.leafName = validateFileName(aFpP.file.leafName); + + return true; + })(); +} diff --git a/browser/components/screenshots/jar.mn b/browser/components/screenshots/jar.mn new file mode 100644 index 0000000000..7a4e2ed73a --- /dev/null +++ b/browser/components/screenshots/jar.mn @@ -0,0 +1,24 @@ +# 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/. + +browser.jar: + content/browser/screenshots/cancel.svg (content/cancel.svg) + content/browser/screenshots/copied-notification.svg (content/copied-notification.svg) + content/browser/screenshots/copy.svg (content/copy.svg) + content/browser/screenshots/download-white.svg (content/download-white.svg) + content/browser/screenshots/download.svg (content/download.svg) + content/browser/screenshots/fileHelpers.mjs + content/browser/screenshots/icon-welcome-face-without-eyes.svg (content/icon-welcome-face-without-eyes.svg) + content/browser/screenshots/menu-fullpage.svg (content/menu-fullpage.svg) + content/browser/screenshots/menu-visible.svg (content/menu-visible.svg) + content/browser/screenshots/screenshots.js (content/screenshots.js) + content/browser/screenshots/screenshots-buttons.js (screenshots-buttons.js) + content/browser/screenshots/screenshots-buttons.css (screenshots-buttons.css) + content/browser/screenshots/screenshots.css (content/screenshots.css) + content/browser/screenshots/screenshots.html (content/screenshots.html) + content/browser/screenshots/overlay/ (overlay/**) + content/browser/screenshots/overlayHelpers.mjs + + +% content screenshots-overlay %overlay/ diff --git a/browser/components/screenshots/moz.build b/browser/components/screenshots/moz.build new file mode 100644 index 0000000000..c66eed8f49 --- /dev/null +++ b/browser/components/screenshots/moz.build @@ -0,0 +1,20 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +EXTRA_JS_MODULES += [ + "ScreenshotsHelperChild.sys.mjs", + "ScreenshotsOverlayChild.sys.mjs", + "ScreenshotsUtils.sys.mjs", +] + +JAR_MANIFESTS += ["jar.mn"] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Screenshots") + +BROWSER_CHROME_MANIFESTS += [ + "tests/browser/browser.toml", +] diff --git a/browser/components/screenshots/overlay/overlay.css b/browser/components/screenshots/overlay/overlay.css new file mode 100644 index 0000000000..6eeda8b44c --- /dev/null +++ b/browser/components/screenshots/overlay/overlay.css @@ -0,0 +1,349 @@ +/* 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/. */ + +@import url("chrome://global/skin/in-content/common.css"); + +:host { + display: contents; +} + +[hidden] { + display: none !important; +} + +#screenshots-component { + position: absolute; + inset: 0; + font: message-box; + user-select: none; + touch-action: none; + pointer-events: auto; + cursor: crosshair; + + &[dragging] { + cursor: grabbing; + } + + &[resizing] { + position: fixed; + width: 100% !important; + height: 100% !important; + } +} + +#selection-container { + overflow: clip; + position: absolute; + inset: 0; + pointer-events: none; +} + +#preview-container { + overflow: clip; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: sticky; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.7); +} + +#buttons-container { + position: absolute; + margin: 10px 0; + cursor: auto; +} + +#selection-size, +#buttons-container { + padding: 4px; + background-color: var(--in-content-page-background); + color: var(--in-content-text-color); + border-radius: 4px; +} + +.buttons-wrapper, +#selection-size-container { + display: flex; + align-items: center; + justify-content: center; +} + +.screenshots-button { + display: inline-flex; + align-items: center; + cursor: pointer; + text-align: center; + user-select: none; + white-space: nowrap; + z-index: 6; + min-width: 32px; + margin-inline: 4px; +} + +#selection-size-container { + width: 100%; + height: 100%; + pointer-events: none; +} + +#screenshots-cancel-button { + margin-top: 20px; + border-color: #fff; + color: #fff; + + @media (prefers-contrast) { + background-color: var(--in-content-button-background); + color: var(--in-content-button-text-color); + border-color: var(--in-content-button-border-color); + } +} + +#screenshots-cancel-button:hover { + background-color: #fff; + color: #000; + + @media (prefers-contrast) { + background-color: var(--in-content-button-background-hover); + color: var(--in-content-button-text-color-hover); + border-color: var(--in-content-button-border-color-hover); + } +} + +.screenshots-button > img { + -moz-context-properties: fill; + fill: currentColor; + width: 16px; + height: 16px; + pointer-events: none; +} + +#cancel > img { + content: url("chrome://global/skin/icons/close.svg"); +} + +#copy > img { + content: url("chrome://global/skin/icons/edit-copy.svg"); +} + +#download > img { + content: url("chrome://browser/skin/downloads/downloads.svg"); +} + +#download > img, +#copy > img { + margin-inline-end: 5px; +} + +.face-container { + position: relative; + width: 64px; + height: 64px; +} + +.face { + width: 62px; + height: 62px; + display: block; + background-image: url("chrome://browser/content/screenshots/icon-welcome-face-without-eyes.svg"); +} + +.eye { + background-color: #fff; + width: 10px; + height: 14px; + position: absolute; + border-radius: 100%; + overflow: hidden; + inset-inline-start: 16px; + top: 19px; +} + +.eyeball { + position: absolute; + width: 6px; + height: 6px; + background-color: #000; + border-radius: 50%; + inset-inline-start: 2px; + top: 4px; + z-index: 10; +} + +.left { + margin-inline-start: 0; +} + +.right { + margin-inline-start: 20px; +} + +.preview-instructions { + display: flex; + align-items: center; + justify-content: center; + animation: pulse 125ms cubic-bezier(0.07, 0.95, 0, 1); + color: #fff; + font-size: 24px; + line-height: 32px; + text-align: center; + padding: 20px; + width: 400px; + + @media (prefers-contrast) { + color: CanvasText; + background-color: Canvas; + } +} + +#hover-highlight { + animation: fade-in 125ms forwards cubic-bezier(0.07, 0.95, 0, 1); + background: rgba(255, 255, 255, 0.2); + border: 2px dashed rgba(255, 255, 255, 0.4); + border-radius: 1px; + box-sizing: border-box; + pointer-events: none; + position: absolute; + z-index: 11; +} + +#top-background { + top: 0; + left: 0; + width: 100%; +} + +#left-background { + left: 0; +} + +#bottom-background { + left: 0; + width: 100%; +} + +.bghighlight { + background-color: rgba(0, 0, 0, 0.7); + position: absolute; + overflow: clip; + pointer-events: none; + /* FIXME(bug 1859421): This shouldn't be needed */ + z-index: -1; +} + +.highlight { + border: 2px dashed rgba(255, 255, 255, 0.8); + box-sizing: border-box; + cursor: move; + position: absolute; + pointer-events: auto; + z-index: 2; + outline-offset: 8px; +} + +.mover-target { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + z-index: 5; + pointer-events: auto; + outline-offset: -15px; +} + +.mover-target.direction-topLeft { + cursor: nwse-resize; + height: 60px; + left: -30px; + top: -30px; + width: 60px; +} + +.mover-target.direction-top { + cursor: ns-resize; + height: 60px; + inset-inline-start: 0; + top: -30px; + width: 100%; + z-index: 4; +} + +.mover-target.direction-topRight { + cursor: nesw-resize; + height: 60px; + right: -30px; + top: -30px; + width: 60px; +} + +.mover-target.direction-left { + cursor: ew-resize; + height: 100%; + left: -30px; + top: 0; + width: 60px; + z-index: 4; +} + +.mover-target.direction-right { + cursor: ew-resize; + height: 100%; + right: -30px; + top: 0; + width: 60px; + z-index: 4; +} + +.mover-target.direction-bottomLeft { + bottom: -30px; + cursor: nesw-resize; + height: 60px; + left: -30px; + width: 60px; +} + +.mover-target.direction-bottom { + bottom: -30px; + cursor: ns-resize; + height: 60px; + inset-inline-start: 0; + width: 100%; + z-index: 4; +} + +.mover-target.direction-bottomRight { + bottom: -30px; + cursor: nwse-resize; + height: 60px; + right: -30px; + width: 60px; +} + +.mover-target:hover .mover { + transform: scale(1.05); +} + +.mover { + background-color: #fff; + border-radius: 50%; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.5); + transition: transform 125ms cubic-bezier(0.07, 0.95, 0, 1); + position: relative; + height: 16px; + width: 16px; + pointer-events: none; + + @media (prefers-contrast) { + background-color: ButtonText; + } +} + +.small-selection .mover { + height: 10px; + width: 10px; +} diff --git a/browser/components/screenshots/overlayHelpers.mjs b/browser/components/screenshots/overlayHelpers.mjs new file mode 100644 index 0000000000..70a1bd86d0 --- /dev/null +++ b/browser/components/screenshots/overlayHelpers.mjs @@ -0,0 +1,497 @@ +/* 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/. */ + +// An autoselection smaller than these will be ignored entirely: +const MIN_DETECT_ABSOLUTE_HEIGHT = 10; +const MIN_DETECT_ABSOLUTE_WIDTH = 30; +// An autoselection smaller than these will not be preferred: +const MIN_DETECT_HEIGHT = 30; +const MIN_DETECT_WIDTH = 100; +// An autoselection bigger than either of these will be ignored: +let MAX_DETECT_HEIGHT = 700; +let MAX_DETECT_WIDTH = 1000; + +const doNotAutoselectTags = { + H1: true, + H2: true, + H3: true, + H4: true, + H5: true, + H6: true, +}; + +/** + * Gets the rect for an element if getBoundingClientRect exists + * @param ele The element to get the rect from + * @returns The bounding client rect of the element or null + */ +function getBoundingClientRect(ele) { + if (!ele.getBoundingClientRect) { + return null; + } + + return ele.getBoundingClientRect(); +} + +export function setMaxDetectHeight(maxHeight) { + MAX_DETECT_HEIGHT = maxHeight; +} + +export function setMaxDetectWidth(maxWidth) { + MAX_DETECT_WIDTH = maxWidth; +} + +/** + * This function will try to get an element from a given point in the doc. + * This function is recursive because when sending a message to the + * ScreenshotsHelper, the ScreenshotsHelper will call into this function. + * This only occurs when the element at the given point is an iframe. + * + * If the element is an iframe, we will send a message to the ScreenshotsHelper + * actor in the correct context to get the element at the given point. + * The message will return the "getBestRectForElement" for the element at the + * given point. + * + * If the element is not an iframe, then we will just return the element. + * + * @param {Number} x The x coordinate + * @param {Number} y The y coordinate + * @param {Document} doc The document + * @returns {Object} + * ele: The element for a given point (x, y) + * rect: The rect for the given point if ele is an iframe + * otherwise null + */ +export async function getElementFromPoint(x, y, doc) { + let ele = null; + let rect = null; + try { + ele = doc.elementFromPoint(x, y); + // if the element is an iframe, we need to send a message to that browsing context + // to get the coordinates of the element in the iframe + if (doc.defaultView.HTMLIFrameElement.isInstance(ele)) { + let actor = + ele.browsingContext.parentWindowContext.windowGlobalChild.getActor( + "ScreenshotsHelper" + ); + rect = await actor.sendQuery( + "ScreenshotsHelper:GetElementRectFromPoint", + { + x: x + ele.ownerGlobal.mozInnerScreenX, + y: y + ele.ownerGlobal.mozInnerScreenY, + bcId: ele.browsingContext.id, + } + ); + + if (rect) { + rect = { + left: rect.left - ele.ownerGlobal.mozInnerScreenX, + right: rect.right - ele.ownerGlobal.mozInnerScreenX, + top: rect.top - ele.ownerGlobal.mozInnerScreenY, + bottom: rect.bottom - ele.ownerGlobal.mozInnerScreenY, + }; + } + } + } catch (e) { + console.error(e); + } + + return { ele, rect }; +} + +/** + * This function takes an element and finds a suitable rect to draw the hover box on + * @param {Element} ele The element to find a suitale rect of + * @param {Document} doc The current document + * @returns A suitable rect or null + */ +export function getBestRectForElement(ele, doc) { + let lastRect; + let lastNode; + let rect; + let attemptExtend = false; + let node = ele; + while (node) { + rect = getBoundingClientRect(node); + if (!rect) { + rect = lastRect; + break; + } + if (rect.width < MIN_DETECT_WIDTH || rect.height < MIN_DETECT_HEIGHT) { + // Avoid infinite loop for elements with zero or nearly zero height, + // like non-clearfixed float parents with or without borders. + break; + } + if (rect.width > MAX_DETECT_WIDTH || rect.height > MAX_DETECT_HEIGHT) { + // Then the last rectangle is better + rect = lastRect; + attemptExtend = true; + break; + } + if (rect.width >= MIN_DETECT_WIDTH && rect.height >= MIN_DETECT_HEIGHT) { + if (!doNotAutoselectTags[node.tagName]) { + break; + } + } + lastRect = rect; + lastNode = node; + node = node.parentNode; + } + if (rect && node) { + const evenBetter = evenBetterElement(node, doc); + if (evenBetter) { + node = lastNode = evenBetter; + rect = getBoundingClientRect(evenBetter); + attemptExtend = false; + } + } + if (rect && attemptExtend) { + let extendNode = lastNode.nextSibling; + while (extendNode) { + if (extendNode.nodeType === doc.ELEMENT_NODE) { + break; + } + extendNode = extendNode.nextSibling; + if (!extendNode) { + const parentNode = lastNode.parentNode; + for (let i = 0; i < parentNode.childNodes.length; i++) { + if (parentNode.childNodes[i] === lastNode) { + extendNode = parentNode.childNodes[i + 1]; + } + } + } + } + if (extendNode) { + const extendRect = getBoundingClientRect(extendNode); + let x = Math.min(rect.x, extendRect.x); + let y = Math.min(rect.y, extendRect.y); + let width = Math.max(rect.right, extendRect.right) - x; + let height = Math.max(rect.bottom, extendRect.bottom) - y; + const combinedRect = new DOMRect(x, y, width, height); + if ( + combinedRect.width <= MAX_DETECT_WIDTH && + combinedRect.height <= MAX_DETECT_HEIGHT + ) { + rect = combinedRect; + } + } + } + + if ( + rect && + (rect.width < MIN_DETECT_ABSOLUTE_WIDTH || + rect.height < MIN_DETECT_ABSOLUTE_HEIGHT) + ) { + rect = null; + } + + return rect; +} + +/** + * This finds a better element by looking for elements with role article + * @param {Element} node The currently hovered node + * @param {Document} doc The current document + * @returns A better node or null + */ +function evenBetterElement(node, doc) { + let el = node.parentNode; + const ELEMENT_NODE = doc.ELEMENT_NODE; + while (el && el.nodeType === ELEMENT_NODE) { + if (!el.getAttribute) { + return null; + } + if (el.getAttribute("role") === "article") { + const rect = getBoundingClientRect(el); + if (!rect) { + return null; + } + if (rect.width <= MAX_DETECT_WIDTH && rect.height <= MAX_DETECT_HEIGHT) { + return el; + } + return null; + } + el = el.parentNode; + } + return null; +} + +export class Region { + #x1; + #x2; + #y1; + #y2; + #xOffset; + #yOffset; + #windowDimensions; + + constructor(windowDimensions) { + this.resetDimensions(); + this.#windowDimensions = windowDimensions; + } + + /** + * Sets the dimensions if the given dimension is defined. + * Otherwise will reset the dimensions + * @param {Object} dims The new region dimensions + * { + * left: new left dimension value or undefined + * top: new top dimension value or undefined + * right: new right dimension value or undefined + * bottom: new bottom dimension value or undefined + * } + */ + set dimensions(dims) { + if (dims == null) { + this.resetDimensions(); + return; + } + + if (dims.left != null) { + this.left = dims.left; + } + if (dims.top != null) { + this.top = dims.top; + } + if (dims.right != null) { + this.right = dims.right; + } + if (dims.bottom != null) { + this.bottom = dims.bottom; + } + } + + get dimensions() { + return { + left: this.left, + top: this.top, + right: this.right, + bottom: this.bottom, + width: this.width, + height: this.height, + }; + } + + get isRegionValid() { + return this.#x1 + this.#x2 + this.#y1 + this.#y2 > 0; + } + + resetDimensions() { + this.#x1 = 0; + this.#x2 = 0; + this.#y1 = 0; + this.#y2 = 0; + this.#xOffset = 0; + this.#yOffset = 0; + } + + /** + * Sort the coordinates so x1 < x2 and y1 < y2 + */ + sortCoords() { + if (this.#x1 > this.#x2) { + [this.#x1, this.#x2] = [this.#x2, this.#x1]; + } + if (this.#y1 > this.#y2) { + [this.#y1, this.#y2] = [this.#y2, this.#y1]; + } + } + + /** + * The region should never appear outside the document so the region will + * be shifted if the region is outside the page's width or height. + */ + shift() { + let didShift = false; + let xDiff = this.right - this.#windowDimensions.scrollWidth; + if (xDiff > 0) { + this.left -= xDiff; + this.right -= xDiff; + + didShift = true; + } + + let yDiff = this.bottom - this.#windowDimensions.scrollHeight; + if (yDiff > 0) { + this.top -= yDiff; + this.bottom -= yDiff; + + didShift = true; + } + + return didShift; + } + + /** + * The diagonal distance of the region + */ + get distance() { + return Math.sqrt(Math.pow(this.width, 2) + Math.pow(this.height, 2)); + } + + get xOffset() { + return this.#xOffset; + } + set xOffset(val) { + this.#xOffset = val; + } + + get yOffset() { + return this.#yOffset; + } + set yOffset(val) { + this.#yOffset = val; + } + + get top() { + return Math.min(this.#y1, this.#y2); + } + set top(val) { + this.#y1 = Math.min(this.#windowDimensions.scrollHeight, Math.max(0, val)); + } + + get left() { + return Math.min(this.#x1, this.#x2); + } + set left(val) { + this.#x1 = Math.min(this.#windowDimensions.scrollWidth, Math.max(0, val)); + } + + get right() { + return Math.max(this.#x1, this.#x2); + } + set right(val) { + this.#x2 = Math.min(this.#windowDimensions.scrollWidth, Math.max(0, val)); + } + + get bottom() { + return Math.max(this.#y1, this.#y2); + } + set bottom(val) { + this.#y2 = Math.min(this.#windowDimensions.scrollHeight, Math.max(0, val)); + } + + get width() { + return Math.abs(this.#x2 - this.#x1); + } + get height() { + return Math.abs(this.#y2 - this.#y1); + } + + get x1() { + return this.#x1; + } + get x2() { + return this.#x2; + } + get y1() { + return this.#y1; + } + get y2() { + return this.#y2; + } +} + +export class WindowDimensions { + #clientHeight = null; + #clientWidth = null; + #scrollHeight = null; + #scrollWidth = null; + #scrollX = null; + #scrollY = null; + #scrollMinX = null; + #scrollMinY = null; + #devicePixelRatio = null; + + set dimensions(dimensions) { + if (dimensions.clientHeight != null) { + this.#clientHeight = dimensions.clientHeight; + } + if (dimensions.clientWidth != null) { + this.#clientWidth = dimensions.clientWidth; + } + if (dimensions.scrollHeight != null) { + this.#scrollHeight = dimensions.scrollHeight; + } + if (dimensions.scrollWidth != null) { + this.#scrollWidth = dimensions.scrollWidth; + } + if (dimensions.scrollX != null) { + this.#scrollX = dimensions.scrollX; + } + if (dimensions.scrollY != null) { + this.#scrollY = dimensions.scrollY; + } + if (dimensions.scrollMinX != null) { + this.#scrollMinX = dimensions.scrollMinX; + } + if (dimensions.scrollMinY != null) { + this.#scrollMinY = dimensions.scrollMinY; + } + if (dimensions.devicePixelRatio != null) { + this.#devicePixelRatio = dimensions.devicePixelRatio; + } + } + + get dimensions() { + return { + clientHeight: this.#clientHeight, + clientWidth: this.#clientWidth, + scrollHeight: this.#scrollHeight, + scrollWidth: this.#scrollWidth, + scrollX: this.#scrollX, + scrollY: this.#scrollY, + scrollMinX: this.#scrollMinX, + scrollMinY: this.#scrollMinY, + devicePixelRatio: this.#devicePixelRatio, + }; + } + + get clientWidth() { + return this.#clientWidth; + } + + get clientHeight() { + return this.#clientHeight; + } + + get scrollWidth() { + return this.#scrollWidth; + } + + get scrollHeight() { + return this.#scrollHeight; + } + + get scrollX() { + return this.#scrollX; + } + + get scrollY() { + return this.#scrollY; + } + + get scrollMinX() { + return this.#scrollMinX; + } + + get scrollMinY() { + return this.#scrollMinY; + } + + get devicePixelRatio() { + return this.#devicePixelRatio; + } + + reset() { + this.#clientHeight = 0; + this.#clientWidth = 0; + this.#scrollHeight = 0; + this.#scrollWidth = 0; + this.#scrollX = 0; + this.#scrollY = 0; + this.#scrollMinX = 0; + this.#scrollMinY = 0; + } +} diff --git a/browser/components/screenshots/screenshots-buttons.css b/browser/components/screenshots/screenshots-buttons.css new file mode 100644 index 0000000000..82b075bccb --- /dev/null +++ b/browser/components/screenshots/screenshots-buttons.css @@ -0,0 +1,35 @@ +/* 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/. */ + +:host { + position: absolute; + /* position about 4px above the container ensuring overlap with toolbar/chrome */ + top: -8px; + inset-inline-end: 10px; + z-index: 3; + padding: 12px; + background-color: var(--arrowpanel-background); + color: var(--arrowpanel-color); + border-radius: var(--arrowpanel-border-radius); +} + +.full-page { + background-image: url("chrome://browser/content/screenshots/menu-fullpage.svg"); +} + +.visible-page { + background-image: url("chrome://browser/content/screenshots/menu-visible.svg"); +} + +.full-page, .visible-page { + -moz-context-properties: fill, stroke; + fill: currentColor; + stroke: var(--color-accent-primary); + background-position: center top; + background-repeat: no-repeat; + background-size: 46px 46px; + min-width: 90px; + padding: 46px 5px 5px; + margin: 2px; +} diff --git a/browser/components/screenshots/screenshots-buttons.js b/browser/components/screenshots/screenshots-buttons.js new file mode 100644 index 0000000000..864505ae2f --- /dev/null +++ b/browser/components/screenshots/screenshots-buttons.js @@ -0,0 +1,68 @@ +/* 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/. */ +/* eslint-env mozilla/browser-window */ + +"use strict"; + +// This is loaded into chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + ChromeUtils.defineESModuleGetters(this, { + ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs", + }); + + class ScreenshotsButtons extends MozXULElement { + static get markup() { + return ` + <html:link rel="stylesheet" href="chrome://global/skin/global.css"/> + <html:link rel="stylesheet" href="chrome://browser/content/screenshots/screenshots-buttons.css"/> + <html:button class="visible-page footer-button" data-l10n-id="screenshots-save-visible-button"></html:button> + <html:button class="full-page footer-button" data-l10n-id="screenshots-save-page-button"></html:button> + `; + } + + connectedCallback() { + const shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + + let fragment = MozXULElement.parseXULToFragment(this.constructor.markup); + this.shadowRoot.append(fragment); + + let visibleButton = shadowRoot.querySelector(".visible-page"); + visibleButton.onclick = function () { + ScreenshotsUtils.doScreenshot(gBrowser.selectedBrowser, "visible"); + }; + + let fullpageButton = shadowRoot.querySelector(".full-page"); + fullpageButton.onclick = function () { + ScreenshotsUtils.doScreenshot(gBrowser.selectedBrowser, "full_page"); + }; + } + + disconnectedCallback() { + document.l10n.disconnectRoot(this.shadowRoot); + } + + /** + * Focus the last used button. + * This will default to the visible page button. + * @param {String} buttonToFocus + */ + focusButton(buttonToFocus) { + if (buttonToFocus === "fullpage") { + this.shadowRoot + .querySelector(".full-page") + .focus({ focusVisible: true }); + } else { + this.shadowRoot + .querySelector(".visible-page") + .focus({ focusVisible: true }); + } + } + } + + customElements.define("screenshots-buttons", ScreenshotsButtons, { + extends: "toolbar", + }); +} diff --git a/browser/components/screenshots/tests/browser/browser.toml b/browser/components/screenshots/tests/browser/browser.toml new file mode 100644 index 0000000000..b363c14732 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser.toml @@ -0,0 +1,55 @@ +[DEFAULT] +support-files = [ + "head.js", + "iframe-test-page.html", + "first-iframe.html", + "second-iframe.html", + "test-page.html", + "short-test-page.html", + "large-test-page.html", + "test-page-resize.html", +] + +prefs = [ + "extensions.screenshots.disabled=false", + "screenshots.browser.component.enabled=true", +] + +["browser_iframe_test.js"] + +["browser_overlay_keyboard_test.js"] + +["browser_screenshots_drag_scroll_test.js"] + +["browser_screenshots_drag_test.js"] + +["browser_screenshots_focus_test.js"] + +["browser_screenshots_overlay_panel_sync.js"] + +["browser_screenshots_page_unload.js"] + +["browser_screenshots_short_page_test.js"] + +["browser_screenshots_telemetry_tests.js"] + +["browser_screenshots_test_downloads.js"] + +["browser_screenshots_test_escape.js"] + +["browser_screenshots_test_full_page.js"] + +["browser_screenshots_test_page_crash.js"] +skip-if = ["!crashreporter"] + +["browser_screenshots_test_screenshot_too_big.js"] + +["browser_screenshots_test_toggle_pref.js"] + +["browser_screenshots_test_toolbar_button.js"] + +["browser_screenshots_test_visible.js"] + +["browser_test_element_picker.js"] + +["browser_test_resize.js"] diff --git a/browser/components/screenshots/tests/browser/browser_iframe_test.js b/browser/components/screenshots/tests/browser/browser_iframe_test.js new file mode 100644 index 0000000000..bb853fbe28 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_iframe_test.js @@ -0,0 +1,123 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_selectingElementsInIframes() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: IFRAME_TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + helper.triggerUIFromToolbar(); + + // There are two iframes in the test page. One iframe is nested in the + // other so we SpecialPowers.spawn into the iframes to get the + // dimension/position of the elements within each iframe. + let elementDimensions = await SpecialPowers.spawn( + browser, + [], + async () => { + let divDims = content.document + .querySelector("div") + .getBoundingClientRect(); + + let iframe = content.document.querySelector("iframe"); + let iframesDivsDimArr = await SpecialPowers.spawn( + iframe, + [], + async () => { + let iframeDivDims = content.document + .querySelector("div") + .getBoundingClientRect(); + + // Element within the first iframe + iframeDivDims = { + left: iframeDivDims.left + content.window.mozInnerScreenX, + top: iframeDivDims.top + content.window.mozInnerScreenY, + width: iframeDivDims.width, + height: iframeDivDims.height, + }; + + let nestedIframe = content.document.querySelector("iframe"); + let nestedIframeDivDims = await SpecialPowers.spawn( + nestedIframe, + [], + async () => { + let secondIframeDivDims = content.document + .querySelector("div") + .getBoundingClientRect(); + + // Element within the nested iframe + secondIframeDivDims = { + left: + secondIframeDivDims.left + + content.document.defaultView.mozInnerScreenX, + top: + secondIframeDivDims.top + + content.document.defaultView.mozInnerScreenY, + width: secondIframeDivDims.width, + height: secondIframeDivDims.height, + }; + + return secondIframeDivDims; + } + ); + + return [iframeDivDims, nestedIframeDivDims]; + } + ); + + // Offset each element position for the browser window + for (let dims of iframesDivsDimArr) { + dims.left -= content.window.mozInnerScreenX; + dims.top -= content.window.mozInnerScreenY; + } + + return [divDims].concat(iframesDivsDimArr); + } + ); + + info(JSON.stringify(elementDimensions, null, 2)); + + for (let el of elementDimensions) { + let x = el.left + el.width / 2; + let y = el.top + el.height / 2; + + mouse.move(x, y); + await helper.waitForHoverElementRect(el.width, el.height); + mouse.click(x, y); + + await helper.waitForStateChange("selected"); + + let dimensions = await helper.getSelectionRegionDimensions(); + + is( + dimensions.left, + el.left, + "The region left position matches the elements left position" + ); + is( + dimensions.top, + el.top, + "The region top position matches the elements top position" + ); + is( + dimensions.width, + el.width, + "The region width matches the elements width" + ); + is( + dimensions.height, + el.height, + "The region height matches the elements height" + ); + + mouse.click(500, 500); + await helper.waitForStateChange("crosshairs"); + } + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_overlay_keyboard_test.js b/browser/components/screenshots/tests/browser/browser_overlay_keyboard_test.js new file mode 100644 index 0000000000..592587a67d --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_overlay_keyboard_test.js @@ -0,0 +1,748 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +const KEY_TO_EXPECTED_POSITION_ARRAY = [ + [ + "ArrowRight", + { + top: 10, + left: 20, + bottom: 20, + right: 30, + }, + ], + [ + "ArrowDown", + { + top: 20, + left: 20, + bottom: 30, + right: 30, + }, + ], + [ + "ArrowLeft", + { + top: 20, + left: 10, + bottom: 30, + right: 20, + }, + ], + [ + "ArrowUp", + { + top: 10, + left: 10, + bottom: 20, + right: 20, + }, + ], + ["ArrowDown", { top: 20, left: 10, bottom: 30, right: 20 }], + [ + "ArrowRight", + { + top: 20, + left: 20, + bottom: 30, + right: 30, + }, + ], + [ + "ArrowUp", + { + top: 10, + left: 20, + bottom: 20, + right: 30, + }, + ], + [ + "ArrowLeft", + { + top: 10, + left: 10, + bottom: 20, + right: 20, + }, + ], +]; + +const SHIFT_PLUS_KEY_TO_EXPECTED_POSITION_ARRAY = [ + [ + "ArrowRight", + { + top: 100, + left: 200, + bottom: 200, + right: 300, + }, + ], + [ + "ArrowDown", + { + top: 200, + left: 200, + bottom: 300, + right: 300, + }, + ], + [ + "ArrowLeft", + { + top: 200, + left: 100, + bottom: 300, + right: 200, + }, + ], + [ + "ArrowUp", + { + top: 100, + left: 100, + bottom: 200, + right: 200, + }, + ], + ["ArrowDown", { top: 200, left: 100, bottom: 300, right: 200 }], + [ + "ArrowRight", + { + top: 200, + left: 200, + bottom: 300, + right: 300, + }, + ], + [ + "ArrowUp", + { + top: 100, + left: 200, + bottom: 200, + right: 300, + }, + ], + [ + "ArrowLeft", + { + top: 100, + left: 100, + bottom: 200, + right: 200, + }, + ], +]; + +/** + * + */ +add_task(async function test_moveRegionWithKeyboard() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + // Because the screenshots state won't go from draggingReady to + // dragging until the diagonal distance is 40px, we have to resize + // it to get the region to 10px x 10px + await helper.dragOverlay(10, 10, 100, 100); + mouse.down(100, 100); + await helper.assertStateChange("resizing"); + mouse.move(20, 20); + mouse.up(20, 20); + await helper.assertStateChange("selected"); + + await SpecialPowers.spawn( + browser, + [KEY_TO_EXPECTED_POSITION_ARRAY], + async keyToExpectedPositionArray => { + function assertSelectionRegionDimensions( + actualDimensions, + expectedDimensions + ) { + is( + actualDimensions.top, + expectedDimensions.top, + "Top dimension is correct" + ); + is( + actualDimensions.left, + expectedDimensions.left, + "Left dimension is correct" + ); + is( + actualDimensions.bottom, + expectedDimensions.bottom, + "Bottom dimension is correct" + ); + is( + actualDimensions.right, + expectedDimensions.right, + "Right dimension is correct" + ); + } + + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + + // Test moving each corner of the region + screenshotsChild.overlay.topLeftMover.focus(); + + // Check that initial position is correct + assertSelectionRegionDimensions( + screenshotsChild.overlay.selectionRegion.dimensions, + { + top: 10, + left: 10, + bottom: 20, + right: 20, + } + ); + + for (let [key, expectedDimensions] of keyToExpectedPositionArray) { + EventUtils.synthesizeKey(key, { repeat: 20 }, content); + assertSelectionRegionDimensions( + screenshotsChild.overlay.selectionRegion.dimensions, + expectedDimensions + ); + } + + // Test moving the highlight element + screenshotsChild.overlay.highlightEl.focus(); + + // Check that initial position is correct + assertSelectionRegionDimensions( + screenshotsChild.overlay.selectionRegion.dimensions, + { + top: 10, + left: 10, + bottom: 20, + right: 20, + } + ); + + for (let [key, expectedDimensions] of keyToExpectedPositionArray) { + EventUtils.synthesizeKey(key, { repeat: 10 }, content); + assertSelectionRegionDimensions( + screenshotsChild.overlay.selectionRegion.dimensions, + expectedDimensions + ); + } + } + ); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlayClosed(); + } + ); +}); + +add_task(async function test_moveRegionWithKeyboardWithShift() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + await helper.dragOverlay(100, 100, 200, 200); + + await SpecialPowers.spawn( + browser, + [SHIFT_PLUS_KEY_TO_EXPECTED_POSITION_ARRAY], + async shiftPlusKeyToExpectedPositionArray => { + function assertSelectionRegionDimensions( + actualDimensions, + expectedDimensions + ) { + is( + actualDimensions.top, + expectedDimensions.top, + "Top dimension is correct" + ); + is( + actualDimensions.left, + expectedDimensions.left, + "Left dimension is correct" + ); + is( + actualDimensions.bottom, + expectedDimensions.bottom, + "Bottom dimension is correct" + ); + is( + actualDimensions.right, + expectedDimensions.right, + "Right dimension is correct" + ); + } + + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + + // Test moving each corner of the region + screenshotsChild.overlay.topLeftMover.focus(); + + // Check that initial position is correct + assertSelectionRegionDimensions( + screenshotsChild.overlay.selectionRegion.dimensions, + { + top: 100, + left: 100, + bottom: 200, + right: 200, + } + ); + + for (let [ + key, + expectedDimensions, + ] of shiftPlusKeyToExpectedPositionArray) { + EventUtils.synthesizeKey( + key, + { repeat: 20, shiftKey: true }, + content + ); + assertSelectionRegionDimensions( + screenshotsChild.overlay.selectionRegion.dimensions, + expectedDimensions + ); + } + + // Test moving the highlight element + screenshotsChild.overlay.highlightEl.focus(); + + // Check that initial position is correct + assertSelectionRegionDimensions( + screenshotsChild.overlay.selectionRegion.dimensions, + { + top: 100, + left: 100, + bottom: 200, + right: 200, + } + ); + + for (let [ + key, + expectedDimensions, + ] of shiftPlusKeyToExpectedPositionArray) { + EventUtils.synthesizeKey( + key, + { repeat: 10, shiftKey: true }, + content + ); + assertSelectionRegionDimensions( + screenshotsChild.overlay.selectionRegion.dimensions, + expectedDimensions + ); + } + } + ); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlayClosed(); + } + ); +}); + +add_task(async function test_moveRegionWithKeyboardWithAccelKey() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + await helper.scrollContentWindow(100, 100); + + let contentWindowDimensions = await helper.getContentDimensions(); + ok(contentWindowDimensions, "Got dimensions back from the content"); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + await helper.dragOverlay(100, 100, 200, 200); + + info("Test moving the the highlight element"); + await SpecialPowers.spawn( + browser, + [contentWindowDimensions], + async contentDimensions => { + function assertSelectionRegionDimensions( + actualDimensions, + expectedDimensions + ) { + is( + actualDimensions.top, + expectedDimensions.top, + "Top dimension is correct" + ); + is( + actualDimensions.left, + expectedDimensions.left, + "Left dimension is correct" + ); + is( + actualDimensions.bottom, + expectedDimensions.bottom, + "Bottom dimension is correct" + ); + is( + actualDimensions.right, + expectedDimensions.right, + "Right dimension is correct" + ); + } + + let { scrollX, scrollY, clientHeight, clientWidth } = + contentDimensions; + + const HIGHLIGHT_CONTROL_PLUS_KEY_TO_EXPECTED_POSITION_ARRAY = [ + [ + "ArrowRight", + { + top: scrollY, + left: scrollX + clientWidth - 100, + bottom: scrollY + 100, + right: scrollX + clientWidth, + }, + ], + [ + "ArrowDown", + { + top: scrollY + clientHeight - 100, + left: scrollX + clientWidth - 100, + bottom: scrollY + clientHeight, + right: scrollX + clientWidth, + }, + ], + [ + "ArrowLeft", + { + top: scrollY + clientHeight - 100, + left: scrollX, + bottom: scrollY + clientHeight, + right: scrollX + 100, + }, + ], + [ + "ArrowUp", + { + top: scrollY, + left: scrollX, + bottom: scrollY + 100, + right: scrollX + 100, + }, + ], + [ + "ArrowDown", + { + top: scrollY + clientHeight - 100, + left: scrollX, + bottom: scrollY + clientHeight, + right: scrollX + 100, + }, + ], + [ + "ArrowRight", + { + top: scrollY + clientHeight - 100, + left: scrollX + clientWidth - 100, + bottom: scrollY + clientHeight, + right: scrollX + clientWidth, + }, + ], + [ + "ArrowUp", + { + top: scrollY, + left: scrollX + clientWidth - 100, + bottom: scrollY + 100, + right: scrollX + clientWidth, + }, + ], + [ + "ArrowLeft", + { + top: scrollY, + left: scrollX, + bottom: scrollY + 100, + right: scrollX + 100, + }, + ], + ]; + + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + screenshotsChild.overlay.highlightEl.focus(); + + // Move the region around in a clockwise direction + // Check that original position is correct + assertSelectionRegionDimensions( + screenshotsChild.overlay.selectionRegion.dimensions, + { + top: 100, + left: 100, + bottom: 200, + right: 200, + } + ); + + for (let [ + key, + expectedDimensions, + ] of HIGHLIGHT_CONTROL_PLUS_KEY_TO_EXPECTED_POSITION_ARRAY) { + EventUtils.synthesizeKey(key, { accelKey: true }, content); + assertSelectionRegionDimensions( + screenshotsChild.overlay.selectionRegion.dimensions, + expectedDimensions + ); + } + } + ); + + mouse.click(300, 300); + await helper.assertStateChange("crosshairs"); + + await helper.dragOverlay(200, 200, 300, 300); + + info("Test moving the corners clockwise"); + await SpecialPowers.spawn( + browser, + [contentWindowDimensions], + async contentDimensions => { + function assertSelectionRegionDimensions( + actualDimensions, + expectedDimensions + ) { + is( + actualDimensions.top, + expectedDimensions.top, + "Top dimension is correct" + ); + is( + actualDimensions.left, + expectedDimensions.left, + "Left dimension is correct" + ); + is( + actualDimensions.bottom, + expectedDimensions.bottom, + "Bottom dimension is correct" + ); + is( + actualDimensions.right, + expectedDimensions.right, + "Right dimension is correct" + ); + } + + let { scrollX, scrollY, clientHeight, clientWidth } = + contentDimensions; + + const CONTROL_PLUS_KEY_TO_EXPECTED_POSITION_ARRAY = [ + [ + "ArrowRight", + { + top: scrollY + 100, + left: scrollX + 100 + 100, + bottom: scrollY + 100 + 100, + right: scrollX + clientWidth, + }, + ], + [ + "ArrowDown", + { + top: scrollY + 100 + 100, + left: scrollX + 100 + 100, + bottom: scrollY + clientHeight, + right: scrollX + clientWidth, + }, + ], + [ + "ArrowLeft", + { + top: scrollY + 100 + 100, + left: scrollX, + bottom: scrollY + clientHeight, + right: scrollX + 100 + 100, + }, + ], + [ + "ArrowUp", + { + top: scrollY, + left: scrollX, + bottom: scrollY + 100 + 100, + right: scrollX + 100 + 100, + }, + ], + ]; + + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + screenshotsChild.overlay.topLeftMover.focus(); + + // Move the region around in a clockwise direction + // Check that original position is correct + assertSelectionRegionDimensions( + screenshotsChild.overlay.selectionRegion.dimensions, + { + top: 200, + left: 200, + bottom: 300, + right: 300, + } + ); + + for (let [ + key, + expectedDimensions, + ] of CONTROL_PLUS_KEY_TO_EXPECTED_POSITION_ARRAY) { + EventUtils.synthesizeKey(key, { accelKey: true }, content); + assertSelectionRegionDimensions( + screenshotsChild.overlay.selectionRegion.dimensions, + expectedDimensions + ); + } + } + ); + + mouse.click(400, 400); + await helper.assertStateChange("crosshairs"); + + await helper.dragOverlay(200, 200, 300, 300); + + info("Test moving the corners counter clockwise"); + await SpecialPowers.spawn( + browser, + [contentWindowDimensions], + async contentDimensions => { + function assertSelectionRegionDimensions( + actualDimensions, + expectedDimensions + ) { + is( + actualDimensions.top, + expectedDimensions.top, + "Top dimension is correct" + ); + is( + actualDimensions.left, + expectedDimensions.left, + "Left dimension is correct" + ); + is( + actualDimensions.bottom, + expectedDimensions.bottom, + "Bottom dimension is correct" + ); + is( + actualDimensions.right, + expectedDimensions.right, + "Right dimension is correct" + ); + } + + let { scrollX, scrollY, clientHeight, clientWidth } = + contentDimensions; + + const CONTROL_PLUS_KEY_TO_EXPECTED_POSITION_ARRAY = [ + [ + "ArrowDown", + { + top: scrollY + 100 + 100, + left: scrollX + 100, + bottom: scrollY + clientHeight, + right: scrollX + 100 + 100, + }, + ], + [ + "ArrowRight", + { + top: scrollY + 100 + 100, + left: scrollX + 100 + 100, + bottom: scrollY + clientHeight, + right: scrollX + clientWidth, + }, + ], + [ + "ArrowUp", + { + top: scrollY, + left: scrollX + 100 + 100, + bottom: scrollY + 100 + 100, + right: scrollX + clientWidth, + }, + ], + [ + "ArrowLeft", + { + top: scrollY, + left: scrollX, + bottom: scrollY + 100 + 100, + right: scrollX + 100 + 100, + }, + ], + ]; + + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + screenshotsChild.overlay.topLeftMover.focus(); + + // Move the region around in a clockwise direction + // Check that original position is correct + assertSelectionRegionDimensions( + screenshotsChild.overlay.selectionRegion.dimensions, + { + top: 200, + left: 200, + bottom: 300, + right: 300, + } + ); + + for (let [ + key, + expectedDimensions, + ] of CONTROL_PLUS_KEY_TO_EXPECTED_POSITION_ARRAY) { + EventUtils.synthesizeKey(key, { accelKey: true }, content); + assertSelectionRegionDimensions( + screenshotsChild.overlay.selectionRegion.dimensions, + expectedDimensions + ); + } + } + ); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js b/browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js new file mode 100644 index 0000000000..7fdb084ca6 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js @@ -0,0 +1,465 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test that screenshots overlay covers the entire page + */ +add_task(async function test_overlayCoversEntirePage() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + let contentInfo = await helper.getContentDimensions(); + info(JSON.stringify(contentInfo, null, 2)); + ok(contentInfo, "Got dimensions back from the content"); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + await helper.dragOverlay(10, 10, 500, 500); + + let { scrollWidth, scrollHeight } = + await helper.getScreenshotsOverlayDimensions(); + + is( + scrollWidth, + contentInfo.scrollWidth, + "The overlay spans the entire width of the page" + ); + + is( + scrollHeight, + contentInfo.scrollHeight, + "The overlay spans the entire height of the page" + ); + } + ); +}); + +/** + * Test dragging screenshots box off top left of screen + */ +add_task(async function test_draggingBoxOffTopLeft() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + let startX = 10; + let startY = 10; + let endX = 500; + let endY = 500; + await helper.dragOverlay(startX, startY, endX, endY); + + mouse.down( + startX + Math.floor((endX - startX) / 2), + startY + Math.floor((endY - startY) / 2) + ); + + await helper.assertStateChange("resizing"); + + mouse.move(10, 10); + + // We moved the box to the edge of the screen so we need to wait until the box size is updated + await helper.waitForSelectionRegionSizeChange(490); + + let dimensions = await helper.getSelectionRegionDimensions(); + + is(dimensions.left, 0, "The box x1 position is now 0"); + is(dimensions.top, 0, "The box y1 position is now 0"); + is(dimensions.width, 255, "The box width is now 255"); + is(dimensions.height, 255, "The box height is now 255"); + + mouse.move( + startX + Math.floor((endX - startX) / 2), + startY + Math.floor((endY - startY) / 2) + ); + + mouse.up( + startX + Math.floor((endX - startX) / 2), + startY + Math.floor((endY - startY) / 2) + ); + + // We moved the box off the edge of the screen so we need to wait until the box size is updated + await helper.waitForSelectionRegionSizeChange(255); + + dimensions = await helper.getSelectionRegionDimensions(); + + is(dimensions.left, 10, "The box x1 position is now 10 again"); + is(dimensions.top, 10, "The box y1 position is now 10 again"); + is(dimensions.width, 490, "The box width is now 490 again"); + is(dimensions.height, 490, "The box height is now 490 again"); + } + ); +}); + +/** + * Test dragging screenshots box off bottom right of screen + */ +add_task(async function test_draggingBoxOffBottomRight() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + let contentInfo = await helper.getContentDimensions(); + info(JSON.stringify(contentInfo)); + ok(contentInfo, "Got dimensions back from the content"); + + await helper.scrollContentWindow( + contentInfo.scrollWidth - contentInfo.clientWidth, + contentInfo.scrollHeight - contentInfo.clientHeight + ); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + let startX = contentInfo.scrollWidth - 500; + let startY = contentInfo.scrollHeight - 500; + let endX = contentInfo.scrollWidth - 20; + let endY = contentInfo.scrollHeight - 20; + + await helper.dragOverlay(startX, startY, endX, endY); + + // move box off the bottom right of the screen + mouse.down( + startX + Math.floor((endX - startX) / 2), + startY + Math.floor((endY - startY) / 2) + ); + mouse.move( + startX + 50 + Math.floor((endX - startX) / 2), + startY + 50 + Math.floor((endY - startY) / 2) + ); + + await helper.assertStateChange("resizing"); + + mouse.move(endX, endY); + + // We moved the box to the edge of the screen so we need to wait until the box size is updated + await helper.waitForSelectionRegionSizeChange(480); + + let dimensions = await helper.getSelectionRegionDimensions(); + + is(dimensions.left, startX + 240, "The box x1 position is now 3748"); + is(dimensions.top, startY + 240, "The box y1 position is now 3756"); + is(dimensions.width, 260, "The box width is now 260"); + is(dimensions.height, 260, "The box height is now 260"); + + mouse.move( + startX + Math.floor((endX - startX) / 2), + startY + Math.floor((endY - startY) / 2) + ); + + mouse.up( + startX + Math.floor((endX - startX) / 2), + startY + Math.floor((endY - startY) / 2) + ); + + // We moved the box off the edge of the screen so we need to wait until the box size is updated + await helper.waitForSelectionRegionSizeChange(252); + + dimensions = await helper.getSelectionRegionDimensions(); + + is(dimensions.left, startX, "The box x1 position is now 3508 again"); + is(dimensions.top, startY, "The box y1 position is now 3516 again"); + is(dimensions.width, 480, "The box width is now 480 again"); + is(dimensions.height, 480, "The box height is now 480 again"); + } + ); +}); + +/** + * test scrolling while screenshots is open + */ +add_task(async function test_scrollingScreenshotsOpen() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + let contentInfo = await helper.getContentDimensions(); + info(JSON.stringify(contentInfo)); + ok(contentInfo, "Got dimensions back from the content"); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + let startX = 10; + let startY = 10; + let endX = 100; + let endY = 100; + + await helper.dragOverlay(startX, startY, endX, endY); + + let scrollX = 1000; + let scrollY = 1000; + + await helper.scrollContentWindow(scrollX, scrollY); + + let dimensions = await helper.getSelectionRegionDimensions(); + + is(dimensions.left, startX, "The box x1 position is 10"); + is(dimensions.top, startY, "The box y1 position is 10"); + is(dimensions.width, endX - startX, "The box width is now 90"); + is(dimensions.height, endY - startY, "The box height is now 90"); + + // reset screenshots box + await helper.escapeKeyInContent(); + await helper.assertStateChange("crosshairs"); + + await helper.dragOverlay( + scrollX + startX, + scrollY + startY, + scrollX + endX, + scrollY + endY + ); + + await helper.scrollContentWindow(0, 0); + + dimensions = await helper.getSelectionRegionDimensions(); + + is(dimensions.left, scrollX + startX, "The box x1 position is 1010"); + is(dimensions.top, scrollY + startY, "The box y1 position is 1010"); + is(dimensions.width, endX - startX, "The box width is now 90"); + is(dimensions.height, endY - startY, "The box height is now 90"); + + // reset screenshots box + await helper.escapeKeyInContent(); + await helper.assertStateChange("crosshairs"); + + await helper.dragOverlay( + startX, + startY, + contentInfo.clientWidth - 10, + contentInfo.clientHeight - 10 + ); + + await helper.scrollContentWindow( + contentInfo.clientWidth - 20, + contentInfo.clientHeight - 20 + ); + + mouse.down(contentInfo.clientWidth - 10, contentInfo.clientHeight - 10); + + await helper.assertStateChange("resizing"); + + mouse.move( + contentInfo.clientWidth * 2 - 30, + contentInfo.clientHeight * 2 - 30 + ); + + mouse.up( + contentInfo.clientWidth * 2 - 30, + contentInfo.clientHeight * 2 - 30 + ); + + await helper.assertStateChange("selected"); + + let { left, top, right, bottom, width, height } = + await helper.getSelectionRegionDimensions(); + let { scrollWidth, scrollHeight } = + await helper.getScreenshotsOverlayDimensions(); + + is(left, startX, "The box left is 10"); + is(top, startY, "The box top is 10"); + is( + right, + contentInfo.clientWidth * 2 - 30, + "The box right is 2 x clientWidth - 30" + ); + is( + bottom, + contentInfo.clientHeight * 2 - 30, + "The box right is 2 x clientHeight - 30" + ); + is( + width, + contentInfo.clientWidth * 2 - 40, + "The box right is 2 x clientWidth - 40" + ); + is( + height, + contentInfo.clientHeight * 2 - 40, + "The box right is 2 x clientHeight - 40" + ); + is( + scrollWidth, + contentInfo.scrollWidth, + "The overlay spans the entire width of the page" + ); + is( + scrollHeight, + contentInfo.scrollHeight, + "The overlay spans the entire height of the page" + ); + } + ); +}); + +/** + * test scroll if by edge + */ +add_task(async function test_scrollIfByEdge() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + let windowX = 1000; + let windowY = 1000; + + await helper.scrollContentWindow(windowX, windowY); + + await TestUtils.waitForTick(); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + let { scrollX, scrollY } = await helper.getContentDimensions(); + + is(scrollX, windowX, "Window x position is 1000"); + is(scrollY, windowY, "Window y position is 1000"); + + let startX = 1100; + let startY = 1100; + let endX = 1010; + let endY = 1010; + + // The window won't scroll if the state is draggingReady so we move to + // get into the dragging state and then move again to scroll the window + mouse.down(startX, startY); + await helper.assertStateChange("draggingReady"); + mouse.move(1050, 1050); + await helper.assertStateChange("dragging"); + mouse.move(endX, endY); + mouse.up(endX, endY); + await helper.assertStateChange("selected"); + + windowX = 990; + windowY = 990; + await helper.waitForScrollTo(windowX, windowY); + + ({ scrollX, scrollY } = await helper.getContentDimensions()); + + is(scrollX, windowX, "Window x position is 990"); + is(scrollY, windowY, "Window y position is 990"); + + let contentInfo = await helper.getContentDimensions(); + + endX = windowX + contentInfo.clientWidth - 10; + endY = windowY + contentInfo.clientHeight - 10; + + info( + `starting to drag overlay to ${endX}, ${endY} in test\nclientInfo: ${JSON.stringify( + contentInfo + )}\n` + ); + await helper.dragOverlay(startX, startY, endX, endY, "selected"); + + windowX = 1000; + windowY = 1000; + await helper.waitForScrollTo(windowX, windowY); + + ({ scrollX, scrollY } = await helper.getContentDimensions()); + + is(scrollX, windowX, "Window x position is 1000"); + is(scrollY, windowY, "Window y position is 1000"); + } + ); +}); + +add_task(async function test_scrollIfByEdgeWithKeyboard() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + let windowX = 1000; + let windowY = 1000; + + await helper.scrollContentWindow(windowX, windowY); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + let { scrollX, scrollY, clientWidth, clientHeight } = + await helper.getContentDimensions(); + + is(scrollX, windowX, "Window x position is 1000"); + is(scrollY, windowY, "Window y position is 1000"); + + await helper.dragOverlay(1020, 1020, 1120, 1120); + + await helper.moveOverlayViaKeyboard("highlight", [ + { key: "ArrowLeft", options: { shiftKey: true } }, + { key: "ArrowLeft", options: {} }, + { key: "ArrowUp", options: { shiftKey: true } }, + { key: "ArrowUp", options: {} }, + ]); + + windowX = 989; + windowY = 989; + await helper.waitForScrollTo(windowX, windowY); + + ({ scrollX, scrollY, clientWidth, clientHeight } = + await helper.getContentDimensions()); + + is(scrollX, windowX, "Window x position is 989"); + is(scrollY, windowY, "Window y position is 989"); + + mouse.click(1200, 1200); + await helper.assertStateChange("crosshairs"); + await helper.dragOverlay( + scrollX + clientWidth - 100 - 20, + scrollY + clientHeight - 100 - 20, + scrollX + clientWidth - 20, + scrollY + clientHeight - 20 + ); + + await helper.moveOverlayViaKeyboard("highlight", [ + { key: "ArrowRight", options: { shiftKey: true } }, + { key: "ArrowRight", options: {} }, + { key: "ArrowDown", options: { shiftKey: true } }, + { key: "ArrowDown", options: {} }, + ]); + + windowX = 1000; + windowY = 1000; + await helper.waitForScrollTo(windowX, windowY); + + ({ scrollX, scrollY } = await helper.getContentDimensions()); + + is(scrollX, windowX, "Window x position is 1000"); + is(scrollY, windowY, "Window y position is 1000"); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_drag_test.js b/browser/components/screenshots/tests/browser/browser_screenshots_drag_test.js new file mode 100644 index 0000000000..605e0ae75c --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_drag_test.js @@ -0,0 +1,488 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This function drags to a 490x490 area and copies to the clipboard + */ +add_task(async function dragTest() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + let expected = Math.floor( + 490 * (await getContentDevicePixelRatio(browser)) + ); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + await helper.dragOverlay(10, 10, 500, 500); + + let clipboardChanged = helper.waitForRawClipboardChange( + expected, + expected + ); + + await helper.clickCopyButton(); + + info("Waiting for clipboard change"); + let result = await clipboardChanged; + + info("result: " + JSON.stringify(result, null, 2)); + + Assert.equal( + result.width, + expected, + `The copied image from the overlay is ${expected}px in width` + ); + Assert.equal( + result.height, + expected, + `The copied image from the overlay is ${expected}px in height` + ); + } + ); +}); + +/** + * This function drags a 1.5 zoomed browser to a 490x490 area and copies to the clipboard + */ +add_task(async function dragTest1Point5Zoom() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + const zoom = 1.5; + let helper = new ScreenshotsHelper(browser); + helper.zoomBrowser(zoom); + + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + let expected = Math.floor( + 50 * (await getContentDevicePixelRatio(browser)) + ); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + await helper.dragOverlay(300, 100, 350, 150); + + let clipboardChanged = helper.waitForRawClipboardChange( + expected, + expected + ); + + await helper.clickCopyButton(); + + info("Waiting for clipboard change"); + let result = await clipboardChanged; + + result.zoom = zoom; + result.devicePixelRatio = window.devicePixelRatio; + result.contentDevicePixelRatio = await getContentDevicePixelRatio( + browser + ); + + info("result: " + JSON.stringify(result, null, 2)); + + Assert.equal( + result.width, + expected, + `The copied image from the overlay is ${expected}px in width` + ); + Assert.equal( + result.height, + expected, + `The copied image from the overlay is ${expected}px in height` + ); + } + ); +}); + +/** + * This function drags an area and clicks elsewhere + * on the overlay to go back to the crosshairs state + */ +add_task(async function clickOverlayResetState() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + await helper.dragOverlay(10, 10, 100, 100); + + // click outside overlay + mouse.click(200, 200); + + await helper.assertStateChange("crosshairs"); + } + ); +}); + +/** + * This function drags an area and clicks the + * cancel button to restart the overlay + */ +add_task(async function overlayCancelButton() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + await helper.dragOverlay(10, 10, 300, 300); + + await helper.clickCancelButton(); + + await helper.assertStateChange("crosshairs"); + } + ); +}); + +/** + * This function drags a 490x490 area and moves it along the edges + * and back to the center to confirm that the original size is preserved + */ +add_task(async function preserveBoxSizeWhenMovingOutOfWindowBounds() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: SHORT_TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + let expected = Math.floor( + 490 * (await getContentDevicePixelRatio(browser)) + ); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + await helper.dragOverlay(10, 10, 500, 500); + + let startX = 10; + let startY = 10; + let endX = 500; + let endY = 500; + + mouse.down( + Math.floor((endX - startX) / 2), + Math.floor((endY - startY) / 2) + ); + + await helper.assertStateChange("resizing"); + + mouse.move(10, 10); + + mouse.move(contentInfo.clientWidth - 10, contentInfo.clientHeight - 10); + + mouse.up( + Math.floor((endX - startX) / 2), + Math.floor((endY - startY) / 2) + ); + + await helper.assertStateChange("selected"); + + let clipboardChanged = helper.waitForRawClipboardChange( + expected, + expected + ); + + await helper.clickCopyButton(); + + info("Waiting for clipboard change"); + let result = await clipboardChanged; + + info("result: " + JSON.stringify(result, null, 2)); + + Assert.equal( + result.width, + expected, + `The copied image from the overlay is ${expected}px in width` + ); + Assert.equal( + result.height, + expected, + `The copied image from the overlay is ${expected}px in height` + ); + } + ); +}); + +/** + * This function drags a 490x490 area and resizes it to a 300x300 area + * with the 4 sides of the box + */ +add_task(async function resizeAllEdges() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + let expected = Math.floor( + 300 * (await getContentDevicePixelRatio(browser)) + ); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + await helper.dragOverlay(10, 10, 500, 500); + + let startX = 10; + let startY = 10; + let endX = 500; + let endY = 500; + + let x = Math.floor((endX - startX) / 2); + + // drag top + mouse.down(x, 10); + + await helper.assertStateChange("resizing"); + + mouse.move(x, 100); + mouse.up(x, 100); + + await helper.assertStateChange("selected"); + + // drag bottom + mouse.down(x, 500); + + await helper.assertStateChange("resizing"); + + mouse.move(x, 400); + mouse.up(x, 400); + + await helper.assertStateChange("selected"); + + // drag right + let y = Math.floor((endY - startY) / 2); + mouse.down(500, y); + + await helper.assertStateChange("resizing"); + + mouse.move(400, y); + mouse.up(400, y); + + await helper.assertStateChange("selected"); + + // drag left + mouse.down(10, y); + + await helper.assertStateChange("resizing"); + + mouse.move(100, y); + mouse.up(100, y); + + await helper.assertStateChange("selected"); + + let clipboardChanged = helper.waitForRawClipboardChange( + expected, + expected + ); + + helper.endX = 400; + helper.endY = 400; + + await helper.clickCopyButton(); + + info("Waiting for clipboard change"); + let result = await clipboardChanged; + + info("result: " + JSON.stringify(result, null, 2)); + + Assert.equal( + result.width, + expected, + `The copied image from the overlay is ${expected}px in width` + ); + Assert.equal( + result.height, + expected, + `The copied image from the overlay is ${expected}px in height` + ); + } + ); +}); + +/** + * This function drags a 490x490 area and resizes it to a 300x300 area + * with the 4 corners of the box + */ +add_task(async function resizeAllCorners() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + let expected = Math.floor( + 300 * (await getContentDevicePixelRatio(browser)) + ); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + await helper.dragOverlay(10, 10, 500, 500); + + // drag topright + mouse.down(500, 10); + + await helper.assertStateChange("resizing"); + + mouse.move(450, 50); + mouse.up(450, 50); + + await helper.assertStateChange("selected"); + + // drag bottomright + mouse.down(450, 500); + + await helper.assertStateChange("resizing"); + + mouse.move(400, 450); + mouse.up(400, 450); + + await helper.assertStateChange("selected"); + + // drag bottomleft + mouse.down(10, 450); + + await helper.assertStateChange("resizing"); + + mouse.move(50, 400); + mouse.up(50, 400); + + await helper.assertStateChange("selected"); + + // drag topleft + mouse.down(50, 50); + + await helper.assertStateChange("resizing"); + + mouse.move(100, 100); + mouse.up(100, 100); + + await helper.assertStateChange("selected"); + + let clipboardChanged = helper.waitForRawClipboardChange( + expected, + expected + ); + + helper.endX = 400; + helper.endY = 400; + + await helper.clickCopyButton(); + + info("Waiting for clipboard change"); + let result = await clipboardChanged; + + info("result: " + JSON.stringify(result, null, 2)); + + Assert.equal( + result.width, + expected, + `The copied image from the overlay is ${expected}px in width` + ); + Assert.equal( + result.height, + expected, + `The copied image from the overlay is ${expected}px in height` + ); + } + ); +}); + +/** + * This function tests clicking the overlay with the different mouse buttons + */ +add_task(async function test_otherMouseButtons() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + await helper.dragOverlay(10, 10, 100, 100); + + // click outside overlay + mouse.click(200, 200, { button: 1 }); + mouse.click(200, 200, { button: 2 }); + + await TestUtils.waitForTick(); + + await helper.assertStateChange("selected"); + + mouse.click(200, 200); + + await helper.assertStateChange("crosshairs"); + + mouse.down(10, 10, { button: 1 }); + mouse.move(100, 100, { button: 1 }); + mouse.up(100, 100, { button: 1 }); + + await TestUtils.waitForTick(); + + await helper.assertStateChange("crosshairs"); + + mouse.down(10, 10, { button: 2 }); + mouse.move(100, 100, { button: 2 }); + mouse.up(100, 100, { button: 2 }); + + await TestUtils.waitForTick(); + + await helper.assertStateChange("crosshairs"); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_focus_test.js b/browser/components/screenshots/tests/browser/browser_screenshots_focus_test.js new file mode 100644 index 0000000000..367f62205e --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_focus_test.js @@ -0,0 +1,384 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const SCREENSHOTS_EVENTS = [ + { category: "screenshots", method: "started", object: "toolbar_button" }, + { category: "screenshots", method: "canceled", object: "escape" }, +]; + +const SCREENSHOTS_LAST_SCREENSHOT_METHOD_PREF = + "screenshots.browser.component.last-screenshot-method"; +const SCREENSHOTS_LAST_SAVED_METHOD_PREF = + "screenshots.browser.component.last-saved-method"; + +async function restoreFocusOnEscape(initialFocusElem, helper) { + info( + `restoreFocusOnEscape, focusedElement: ${Services.focus.focusedElement.localName}#${Services.focus.focusedElement.id}` + ); + is( + window, + BrowserWindowTracker.getTopWindow(), + "Our window is the top window" + ); + + let gotFocus; + if (Services.focus.focusedElement !== initialFocusElem) { + gotFocus = BrowserTestUtils.waitForEvent(initialFocusElem, "focus"); + await SimpleTest.promiseFocus(initialFocusElem.ownerGlobal); + Services.focus.setFocus(initialFocusElem, Services.focus.FLAG_BYKEY); + info( + `Waiting to place focus on initialFocusElem: ${initialFocusElem.localName}#${initialFocusElem.id}` + ); + await gotFocus; + } + is( + Services.focus.focusedElement, + initialFocusElem, + `The initial element #${initialFocusElem.id} has focus` + ); + + helper.assertPanelNotVisible(); + // open Screenshots with the keyboard shortcut + info( + "Triggering screenshots UI with the ctrl+shift+s and waiting for the panel" + ); + EventUtils.synthesizeKey("s", { shiftKey: true, accelKey: true }); + + let button = await helper.getPanelButton(".visible-page"); + info("Panel is now visible, got button: " + button.className); + info( + `focusedElement: ${Services.focus.focusedElement.localName}.${Services.focus.focusedElement.className}` + ); + + await BrowserTestUtils.waitForCondition(async () => { + return button.getRootNode().activeElement === button; + }, "The first button in the panel should have focus"); + + info( + "Sending Escape to dismiss the screenshots UI and for the panel to be closed" + ); + + let exitObserved = TestUtils.topicObserved("screenshots-exit"); + EventUtils.synthesizeKey("KEY_Escape"); + await helper.waitForPanelClosed(); + await exitObserved; + info("Waiting for the initialFocusElem to be the focusedElement"); + await BrowserTestUtils.waitForCondition(async () => { + return Services.focus.focusedElement === initialFocusElem; + }, "The initially focused element should have focus"); + + info( + `Screenshots did exit, focusedElement: ${Services.focus.focusedElement.localName}#${Services.focus.focusedElement.id}` + ); + helper.assertPanelNotVisible(); +} + +add_task(async function testPanelFocused() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await clearAllTelemetryEvents(); + let helper = new ScreenshotsHelper(browser); + + info("Opening Screenshots and waiting for the panel"); + helper.triggerUIFromToolbar(); + + let button = await helper.getPanelButton(".visible-page"); + info("Panel is now visible, got button: " + button.className); + info( + `focusedElement: ${Services.focus.focusedElement.localName}.${Services.focus.focusedElement.className}` + ); + + info("Waiting for the button to be the activeElement"); + await BrowserTestUtils.waitForCondition(() => { + return button.getRootNode().activeElement === button; + }, "The first button in the panel should have focus"); + + info("Sending Escape to close Screenshots"); + let exitObserved = TestUtils.topicObserved("screenshots-exit"); + EventUtils.synthesizeKey("KEY_Escape"); + + info("waiting for the panel to be closed"); + await helper.waitForPanelClosed(); + info("waiting for the overlay to be closed"); + await helper.waitForOverlayClosed(); + await exitObserved; + + info("Checking telemetry"); + await assertScreenshotsEvents(SCREENSHOTS_EVENTS); + helper.assertPanelNotVisible(); + } + ); +}); + +add_task(async function testRestoreFocusToChromeOnEscape() { + for (let focusSelector of [ + "#urlbar-input", // A focusable HTML chrome element + "tab[selected='true']", // The selected tab element + ]) { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + helper.assertPanelNotVisible(); + let initialFocusElem = document.querySelector(focusSelector); + await SimpleTest.promiseFocus(window); + await restoreFocusOnEscape(initialFocusElem, helper); + } + ); + } +}); + +add_task(async function testRestoreFocusToToolbarbuttonOnEscape() { + const focusId = "PanelUI-menu-button"; // a toolbarbutton + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + helper.assertPanelNotVisible(); + let initialFocusElem = document.getElementById(focusId); + await SimpleTest.promiseFocus(window); + await restoreFocusOnEscape(initialFocusElem, helper); + } + ); +}).skip(); // Bug 1867687 + +add_task(async function testRestoreFocusToContentOnEscape() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: 'data:text/html;charset=utf-8,%3Cinput type%3D"text" id%3D"field"%3E', + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + await SimpleTest.promiseFocus(browser); + await BrowserTestUtils.synthesizeMouse("#field", 2, 2, {}, browser); + + let initialFocusElem = Services.focus.focusedElement; + await restoreFocusOnEscape(initialFocusElem, helper); + + is( + initialFocusElem, + document.activeElement, + "The browser element has focus" + ); + let focusId = await SpecialPowers.spawn(browser, [], () => { + return content.document.activeElement.id; + }); + is(focusId, "field", "The button in the content document has focus"); + } + ); +}); + +add_task(async function test_focusLastUsedMethod() { + await SpecialPowers.pushPrefEnv({ + set: [ + [SCREENSHOTS_LAST_SCREENSHOT_METHOD_PREF, ""], + [SCREENSHOTS_LAST_SAVED_METHOD_PREF, ""], + ["browser.download.useDownloadDir", true], + ], + }); + + let publicDownloads = await Downloads.getList(Downloads.PUBLIC); + // First ensure we catch the download finishing. + let downloadFinishedPromise = new Promise(resolve => { + publicDownloads.addView({ + onDownloadChanged(download) { + info("Download changed!"); + if (download.succeeded || download.error) { + info("Download succeeded or errored"); + publicDownloads.removeView(this); + resolve(download); + } + }, + }); + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: SHORT_TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + let expectedFocusedButton = await helper.getPanelButton(".visible-page"); + + await BrowserTestUtils.waitForCondition(() => { + return ( + expectedFocusedButton.getRootNode().activeElement === + expectedFocusedButton + ); + }, "The visible button in the panel should have focus"); + + is( + Services.focus.focusedElement, + expectedFocusedButton, + "The visible button in the panel should have focus" + ); + + let screenshotReady = TestUtils.topicObserved( + "screenshots-preview-ready" + ); + let fullpageButton = await helper.getPanelButton(".full-page"); + fullpageButton.click(); + await screenshotReady; + + let dialog = helper.getDialog(); + let retryButton = dialog._frame.contentDocument.getElementById("retry"); + retryButton.click(); + + await helper.waitForOverlay(); + + expectedFocusedButton = await helper.getPanelButton(".full-page"); + + await BrowserTestUtils.waitForCondition(() => { + return ( + expectedFocusedButton.getRootNode().activeElement === + expectedFocusedButton + ); + }, "The full page button in the panel should have focus"); + + is( + Services.focus.focusedElement, + expectedFocusedButton, + "The full button in the panel should have focus" + ); + + screenshotReady = TestUtils.topicObserved("screenshots-preview-ready"); + let visiblepageButton = await helper.getPanelButton(".visible-page"); + visiblepageButton.click(); + await screenshotReady; + + dialog = helper.getDialog(); + retryButton = dialog._frame.contentDocument.getElementById("retry"); + retryButton.click(); + + await helper.waitForOverlay(); + + expectedFocusedButton = await helper.getPanelButton(".visible-page"); + + await BrowserTestUtils.waitForCondition(() => { + return ( + expectedFocusedButton.getRootNode().activeElement === + expectedFocusedButton + ); + }, "The visible button in the panel should have focus"); + + is( + Services.focus.focusedElement, + expectedFocusedButton, + "The visible button in the panel should have focus" + ); + + screenshotReady = TestUtils.topicObserved("screenshots-preview-ready"); + expectedFocusedButton.click(); + await screenshotReady; + + dialog = helper.getDialog(); + + expectedFocusedButton = + dialog._frame.contentDocument.getElementById("download"); + + await BrowserTestUtils.waitForCondition(() => { + return ( + expectedFocusedButton.getRootNode().activeElement === + expectedFocusedButton + ); + }, "The download button in the preview dialog should have focus"); + + is( + Services.focus.focusedElement, + expectedFocusedButton, + "The download button in the preview dialog should have focus" + ); + + let screenshotExit = TestUtils.topicObserved("screenshots-exit"); + let copyButton = dialog._frame.contentDocument.getElementById("copy"); + copyButton.click(); + await screenshotExit; + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + let visibleButton = await helper.getPanelButton(".visible-page"); + + screenshotReady = TestUtils.topicObserved("screenshots-preview-ready"); + visibleButton.click(); + await screenshotReady; + + dialog = helper.getDialog(); + + expectedFocusedButton = + dialog._frame.contentDocument.getElementById("copy"); + + await BrowserTestUtils.waitForCondition(() => { + return ( + expectedFocusedButton.getRootNode().activeElement === + expectedFocusedButton + ); + }, "The copy button in the preview dialog should have focus"); + + is( + Services.focus.focusedElement, + expectedFocusedButton, + "The copy button in the preview dialog should have focus" + ); + + screenshotExit = TestUtils.topicObserved("screenshots-exit"); + let downloadButton = + dialog._frame.contentDocument.getElementById("download"); + downloadButton.click(); + + await Promise.all([screenshotExit, downloadFinishedPromise]); + + await publicDownloads.removeFinished(); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + visibleButton = await helper.getPanelButton(".visible-page"); + + screenshotReady = TestUtils.topicObserved("screenshots-preview-ready"); + visibleButton.click(); + await screenshotReady; + + dialog = helper.getDialog(); + + expectedFocusedButton = + dialog._frame.contentDocument.getElementById("download"); + + await BrowserTestUtils.waitForCondition(() => { + return ( + expectedFocusedButton.getRootNode().activeElement === + expectedFocusedButton + ); + }, "The download button in the preview dialog should have focus"); + + is( + Services.focus.focusedElement, + expectedFocusedButton, + "The download button in the preview dialog should have focus" + ); + + screenshotExit = TestUtils.topicObserved("screenshots-exit"); + helper.triggerUIFromToolbar(); + await screenshotExit; + } + ); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_overlay_panel_sync.js b/browser/components/screenshots/tests/browser/browser_screenshots_overlay_panel_sync.js new file mode 100644 index 0000000000..0f54255dc0 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_overlay_panel_sync.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function waitOnTabSwitch() { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 300)); +} + +add_task(async function test_overlay_and_panel_state() { + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE); + + let screenshotsTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PAGE + ); + let browser = screenshotsTab.linkedBrowser; + + let helper = new ScreenshotsHelper(browser); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + helper.assertPanelVisible(); + + await helper.dragOverlay(10, 10, 500, 500); + + await helper.assertStateChange("selected"); + + helper.assertPanelNotVisible(); + + mouse.click(600, 600); + + await helper.assertStateChange("crosshairs"); + + await helper.waitForOverlay(); + + helper.assertPanelVisible(); + + await BrowserTestUtils.switchTab(gBrowser, tab1); + await BrowserTestUtils.switchTab(gBrowser, screenshotsTab); + + await helper.waitForOverlayClosed(); + + Assert.ok(!(await helper.isOverlayInitialized()), "Overlay is closed"); + helper.assertPanelNotVisible(); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + helper.assertPanelVisible(); + + await helper.dragOverlay(10, 10, 500, 500); + + await helper.assertStateChange("selected"); + + await BrowserTestUtils.switchTab(gBrowser, tab1); + await BrowserTestUtils.switchTab(gBrowser, screenshotsTab); + + Assert.ok(await helper.isOverlayInitialized(), "Overlay is open"); + helper.assertPanelNotVisible(); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlayClosed(); + + Assert.ok(!(await helper.isOverlayInitialized()), "Overlay is closed"); + helper.assertPanelNotVisible(); + + gBrowser.removeTab(tab1); + gBrowser.removeTab(screenshotsTab); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_page_unload.js b/browser/components/screenshots/tests/browser/browser_screenshots_page_unload.js new file mode 100644 index 0000000000..fbb5788a5a --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_page_unload.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const SCREENSHOTS_EVENTS = [ + { category: "screenshots", method: "started", object: "toolbar_button" }, + { category: "screenshots", method: "canceled", object: "navigation" }, +]; + +add_task(async function test() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: SHORT_TEST_PAGE, + }, + async browser => { + await clearAllTelemetryEvents(); + await SpecialPowers.spawn(browser, [SHORT_TEST_PAGE], url => { + let a = content.document.createElement("a"); + a.id = "clickMe"; + a.href = url; + a.textContent = "Click me to unload page"; + content.document.querySelector("body").appendChild(a); + }); + + let helper = new ScreenshotsHelper(browser); + + // click toolbar button so panel shows + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + await SpecialPowers.spawn(browser, [], () => { + content.document.querySelector("#clickMe").click(); + }); + + await helper.waitForOverlayClosed(); + + await assertScreenshotsEvents(SCREENSHOTS_EVENTS); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_short_page_test.js b/browser/components/screenshots/tests/browser/browser_screenshots_short_page_test.js new file mode 100644 index 0000000000..bebc70e915 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_short_page_test.js @@ -0,0 +1,123 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test ensures the overlay is covering the entire window event thought + * the body is smaller than the viewport + */ +add_task(async function test_overlay() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: SHORT_TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + await helper.dragOverlay(10, 10, 500, 500); + + let { scrollWidth, scrollHeight } = + await helper.getScreenshotsOverlayDimensions(); + Assert.equal( + scrollWidth, + contentInfo.clientWidth, + "The overlay spans the width of the window" + ); + + Assert.equal( + scrollHeight, + contentInfo.clientHeight, + "The overlay spans the height of the window" + ); + } + ); +}); + +add_task(async function test_window_resize() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: SHORT_TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + + const originalWindowWidth = window.outerWidth; + const originalWindowHeight = window.outerHeight; + + const BIG_WINDOW_SIZE = 920; + const SMALL_WINDOW_SIZE = 620; + + await helper.resizeContentWindow(SMALL_WINDOW_SIZE, SMALL_WINDOW_SIZE); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + await helper.dragOverlay(10, 10, 100, 100); + + let dimensions = await helper.getScreenshotsOverlayDimensions(); + let oldWidth = dimensions.scrollWidth; + let oldHeight = dimensions.scrollHeight; + + await helper.resizeContentWindow(BIG_WINDOW_SIZE, BIG_WINDOW_SIZE); + await helper.waitForSelectionLayerDimensionChange(oldWidth, oldHeight); + + contentInfo = await helper.getContentDimensions(); + dimensions = await helper.getScreenshotsOverlayDimensions(); + Assert.equal( + dimensions.scrollWidth, + contentInfo.clientWidth, + "The overlay spans the width of the window" + ); + Assert.equal( + dimensions.scrollHeight, + contentInfo.clientHeight, + "The overlay spans the height of the window" + ); + + oldWidth = dimensions.scrollWidth; + oldHeight = dimensions.scrollHeight; + + await helper.resizeContentWindow(SMALL_WINDOW_SIZE, SMALL_WINDOW_SIZE); + await helper.waitForSelectionLayerDimensionChange(oldWidth, oldHeight); + + contentInfo = await helper.getContentDimensions(); + dimensions = await helper.getScreenshotsOverlayDimensions(); + Assert.equal( + dimensions.scrollWidth, + contentInfo.clientWidth, + "The overlay spans the width of the window" + ); + Assert.equal( + dimensions.scrollHeight, + contentInfo.clientHeight, + "The overlay spans the height of the window" + ); + + Assert.less( + dimensions.scrollWidth, + BIG_WINDOW_SIZE, + "Screenshots overlay is smaller than the big window width" + ); + Assert.less( + dimensions.scrollHeight, + BIG_WINDOW_SIZE, + "Screenshots overlay is smaller than the big window height" + ); + + await helper.resizeContentWindow( + originalWindowWidth, + originalWindowHeight + ); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_telemetry_tests.js b/browser/components/screenshots/tests/browser/browser_screenshots_telemetry_tests.js new file mode 100644 index 0000000000..782ffa3fd3 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_telemetry_tests.js @@ -0,0 +1,466 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const STARTED_AND_CANCELED_EVENTS = [ + { category: "screenshots", method: "started", object: "toolbar_button" }, + { category: "screenshots", method: "canceled", object: "toolbar_button" }, + { category: "screenshots", method: "started", object: "shortcut" }, + { category: "screenshots", method: "canceled", object: "shortcut" }, + { category: "screenshots", method: "started", object: "context_menu" }, + { category: "screenshots", method: "canceled", object: "context_menu" }, + { category: "screenshots", method: "started", object: "quick_actions" }, + { category: "screenshots", method: "canceled", object: "quick_actions" }, +]; + +const STARTED_RETRY_EVENTS = [ + { category: "screenshots", method: "started", object: "toolbar_button" }, + { category: "screenshots", method: "selected", object: "visible" }, + { category: "screenshots", method: "started", object: "preview_retry" }, +]; + +const CANCEL_EVENTS = [ + { category: "screenshots", method: "started", object: "toolbar_button" }, + { category: "screenshots", method: "selected", object: "full_page" }, + { category: "screenshots", method: "canceled", object: "preview_cancel" }, + { category: "screenshots", method: "started", object: "toolbar_button" }, + { category: "screenshots", method: "canceled", object: "overlay_cancel" }, +]; + +const COPY_EVENTS = [ + { category: "screenshots", method: "started", object: "toolbar_button" }, + { category: "screenshots", method: "selected", object: "visible" }, + { category: "screenshots", method: "copy", object: "preview_copy" }, + { category: "screenshots", method: "started", object: "toolbar_button" }, + { category: "screenshots", method: "copy", object: "overlay_copy" }, +]; + +const CONTENT_EVENTS = [ + { category: "screenshots", method: "selected", object: "region_selection" }, + { category: "screenshots", method: "selected", object: "region_selection" }, + { category: "screenshots", method: "started", object: "overlay_retry" }, + { category: "screenshots", method: "selected", object: "element" }, +]; + +const EXTRA_OVERLAY_EVENTS = [ + { category: "screenshots", method: "started", object: "toolbar_button" }, + { + category: "screenshots", + method: "copy", + object: "overlay_copy", + extra: { + element: "1", + region: "1", + move: "1", + resize: "1", + fullpage: "0", + visible: "0", + }, + }, +]; + +const EXTRA_EVENTS = [ + { category: "screenshots", method: "started", object: "toolbar_button" }, + { category: "screenshots", method: "selected", object: "visible" }, + { category: "screenshots", method: "started", object: "preview_retry" }, + { category: "screenshots", method: "selected", object: "full_page" }, + { + category: "screenshots", + method: "copy", + object: "preview_copy", + extra: { + element: "0", + region: "0", + move: "0", + resize: "0", + fullpage: "1", + visible: "1", + }, + }, +]; + +add_task(async function test_started_and_canceled_events() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.quickactions.enabled", true], + ["browser.urlbar.suggest.quickactions", true], + ["browser.urlbar.shortcuts.quickactions", true], + ], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: SHORT_TEST_PAGE, + }, + async browser => { + await clearAllTelemetryEvents(); + let helper = new ScreenshotsHelper(browser); + let screenshotExit; + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + screenshotExit = TestUtils.topicObserved("screenshots-exit"); + helper.triggerUIFromToolbar(); + await helper.waitForOverlayClosed(); + await screenshotExit; + + EventUtils.synthesizeKey("s", { shiftKey: true, accelKey: true }); + await helper.waitForOverlay(); + + screenshotExit = TestUtils.topicObserved("screenshots-exit"); + EventUtils.synthesizeKey("s", { shiftKey: true, accelKey: true }); + await helper.waitForOverlayClosed(); + await screenshotExit; + + let contextMenu = document.getElementById("contentAreaContextMenu"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtPoint( + 50, + 50, + { + type: "contextmenu", + button: 2, + }, + browser + ); + await popupShownPromise; + + contextMenu.activateItem( + contextMenu.querySelector("#context-take-screenshot") + ); + await helper.waitForOverlay(); + + popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtPoint( + 50, + 50, + { + type: "contextmenu", + button: 2, + }, + browser + ); + await popupShownPromise; + + screenshotExit = TestUtils.topicObserved("screenshots-exit"); + contextMenu.activateItem( + contextMenu.querySelector("#context-take-screenshot") + ); + await helper.waitForOverlayClosed(); + await screenshotExit; + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "screenshot", + waitForFocus: SimpleTest.waitForFocus, + }); + let { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.DYNAMIC); + Assert.equal(result.providerName, "quickactions"); + + info("Trigger the screenshot mode"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + await helper.waitForOverlay(); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "screenshot", + waitForFocus: SimpleTest.waitForFocus, + }); + ({ result } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1)); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.DYNAMIC); + Assert.equal(result.providerName, "quickactions"); + + info("Trigger the screenshot mode"); + screenshotExit = TestUtils.topicObserved("screenshots-exit"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + await helper.waitForOverlayClosed(); + await screenshotExit; + + await assertScreenshotsEvents(STARTED_AND_CANCELED_EVENTS); + } + ); +}); + +add_task(async function test_started_retry() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await clearAllTelemetryEvents(); + let helper = new ScreenshotsHelper(browser); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + let panel = await helper.waitForPanel(); + + let screenshotReady = TestUtils.topicObserved( + "screenshots-preview-ready" + ); + + // click the visible page button in panel + let visiblePageButton = panel + .querySelector("screenshots-buttons") + .shadowRoot.querySelector(".visible-page"); + visiblePageButton.click(); + await screenshotReady; + + let dialog = helper.getDialog(); + let retryButton = dialog._frame.contentDocument.getElementById("retry"); + ok(retryButton, "Got the retry button"); + retryButton.click(); + + await helper.waitForOverlay(); + + await assertScreenshotsEvents(STARTED_RETRY_EVENTS); + } + ); +}); + +add_task(async function test_canceled() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await clearAllTelemetryEvents(); + let helper = new ScreenshotsHelper(browser); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + let panel = await helper.waitForPanel(); + + let screenshotReady = TestUtils.topicObserved( + "screenshots-preview-ready" + ); + + // click the full page button in panel + let fullPageButton = panel + .querySelector("screenshots-buttons") + .shadowRoot.querySelector(".full-page"); + fullPageButton.click(); + await screenshotReady; + + let dialog = helper.getDialog(); + let cancelButton = dialog._frame.contentDocument.getElementById("cancel"); + ok(cancelButton, "Got the cancel button"); + + let screenshotExit = TestUtils.topicObserved("screenshots-exit"); + cancelButton.click(); + + await helper.waitForOverlayClosed(); + await screenshotExit; + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + screenshotExit = TestUtils.topicObserved("screenshots-exit"); + await helper.clickPreviewCancelButton(); + + await helper.waitForOverlayClosed(); + await screenshotExit; + + await assertScreenshotsEvents(CANCEL_EVENTS); + } + ); +}); + +add_task(async function test_copy() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await clearAllTelemetryEvents(); + let helper = new ScreenshotsHelper(browser); + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + let devicePixelRatio = await getContentDevicePixelRatio(browser); + + let expectedWidth = Math.floor( + devicePixelRatio * contentInfo.clientWidth + ); + let expectedHeight = Math.floor( + devicePixelRatio * contentInfo.clientHeight + ); + + helper.triggerUIFromToolbar(); + info("waiting for overlay"); + await helper.waitForOverlay(); + + info("waiting for panel"); + let panel = await helper.waitForPanel(); + + let screenshotReady = TestUtils.topicObserved( + "screenshots-preview-ready" + ); + + // click the visible page button in panel + let visiblePageButton = panel + .querySelector("screenshots-buttons") + .shadowRoot.querySelector(".visible-page"); + visiblePageButton.click(); + info("clicked visible page, waiting for screenshots-preview-ready"); + await screenshotReady; + + let dialog = helper.getDialog(); + let copyButton = dialog._frame.contentDocument.getElementById("copy"); + + let screenshotExit = TestUtils.topicObserved("screenshots-exit"); + let clipboardChanged = helper.waitForRawClipboardChange( + expectedWidth, + expectedHeight + ); + + // click copy button on dialog box + info("clicking the copy button"); + copyButton.click(); + + info("Waiting for clipboard change"); + await clipboardChanged; + info("waiting for screenshot exit"); + await screenshotExit; + + helper.triggerUIFromToolbar(); + info("waiting for overlay again"); + await helper.waitForOverlay(); + + await helper.dragOverlay(50, 50, 300, 300); + + clipboardChanged = helper.waitForRawClipboardChange( + devicePixelRatio * 250, + devicePixelRatio * 250 + ); + + screenshotExit = TestUtils.topicObserved("screenshots-exit"); + await helper.clickCopyButton(); + + info("Waiting for clipboard change"); + await clipboardChanged; + info("Waiting for exit again"); + await screenshotExit; + + info("Waiting for assertScreenshotsEvents"); + await assertScreenshotsEvents(COPY_EVENTS); + } + ); +}); + +add_task(async function test_content_events() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await clearAllTelemetryEvents(); + let helper = new ScreenshotsHelper(browser); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + await helper.dragOverlay(50, 50, 300, 300); + + await helper.dragOverlay(300, 300, 333, 333, "selected"); + + await helper.dragOverlay(150, 150, 200, 200, "selected"); + + mouse.click(11, 11); + await helper.assertStateChange("crosshairs"); + + await helper.clickTestPageElement(); + + let screenshotExit = TestUtils.topicObserved("screenshots-exit"); + + await helper.clickCopyButton(); + + info("Waiting for exit"); + await screenshotExit; + + await assertScreenshotsEvents(CONTENT_EVENTS, "content", false); + await assertScreenshotsEvents(EXTRA_OVERLAY_EVENTS); + } + ); +}); + +add_task(async function test_extra_telemetry() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await clearAllTelemetryEvents(); + let helper = new ScreenshotsHelper(browser); + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + + helper.triggerUIFromToolbar(); + info("waiting for overlay"); + await helper.waitForOverlay(); + + info("waiting for panel"); + let panel = await helper.waitForPanel(); + + let screenshotReady = TestUtils.topicObserved( + "screenshots-preview-ready" + ); + + // click the visible page button in panel + let visiblePageButton = panel + .querySelector("screenshots-buttons") + .shadowRoot.querySelector(".visible-page"); + visiblePageButton.click(); + info("clicked visible page, waiting for screenshots-preview-ready"); + await screenshotReady; + + let dialog = helper.getDialog(); + let retryButton = dialog._frame.contentDocument.getElementById("retry"); + retryButton.click(); + + info("waiting for panel"); + panel = await helper.waitForPanel(); + + screenshotReady = TestUtils.topicObserved("screenshots-preview-ready"); + + // click the full page button in panel + let fullPageButton = panel + .querySelector("screenshots-buttons") + .shadowRoot.querySelector(".full-page"); + fullPageButton.click(); + await screenshotReady; + + let screenshotExit = TestUtils.topicObserved("screenshots-exit"); + + dialog = helper.getDialog(); + let copyButton = dialog._frame.contentDocument.getElementById("copy"); + retryButton.click(); + // click copy button on dialog box + info("clicking the copy button"); + copyButton.click(); + + info("waiting for screenshot exit"); + await screenshotExit; + + info("Waiting for assertScreenshotsEvents"); + await assertScreenshotsEvents(EXTRA_EVENTS); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js new file mode 100644 index 0000000000..770a7ae06b --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js @@ -0,0 +1,186 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const SCREENSHOTS_EVENTS = [ + { category: "screenshots", method: "started", object: "toolbar_button" }, + { category: "screenshots", method: "download", object: "overlay_download" }, + { category: "screenshots", method: "started", object: "toolbar_button" }, + { category: "screenshots", method: "selected", object: "visible" }, + { category: "screenshots", method: "download", object: "preview_download" }, +]; + +const MockFilePicker = SpecialPowers.MockFilePicker; + +add_setup(async function () { + let tmpDir = PathUtils.join( + PathUtils.tempDir, + "testsavedir" + Math.floor(Math.random() * 2 ** 32) + ); + // Create this dir if it doesn't exist (ignores existing dirs) + await IOUtils.makeDirectory(tmpDir); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.download.start_downloads_in_tmp_dir", true], + ["browser.helperApps.deleteTempFileOnExit", true], + ["browser.download.folderList", 2], + ["browser.download.dir", tmpDir], + ], + }); + + MockFilePicker.init(window); + MockFilePicker.useAnyFile(); + MockFilePicker.returnValue = MockFilePicker.returnOK; + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.download.dir"); + Services.prefs.clearUserPref("browser.download.folderList"); + + MockFilePicker.cleanup(); + }); +}); + +function waitForFilePicker() { + return new Promise(resolve => { + MockFilePicker.showCallback = () => { + MockFilePicker.showCallback = null; + ok(true, "Saw the file picker"); + resolve(); + }; + }); +} + +add_task(async function test_download_without_filepicker() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.useDownloadDir", true]], + }); + + let publicDownloads = await Downloads.getList(Downloads.PUBLIC); + // First ensure we catch the download finishing. + let downloadFinishedPromise = new Promise(resolve => { + publicDownloads.addView({ + onDownloadChanged(download) { + info("Download changed!"); + if (download.succeeded || download.error) { + info("Download succeeded or errored"); + publicDownloads.removeView(this); + resolve(download); + } + }, + }); + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await clearAllTelemetryEvents(); + let helper = new ScreenshotsHelper(browser); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + await helper.dragOverlay(10, 10, 500, 500); + + await helper.clickDownloadButton(); + + info("wait for download to finish"); + let download = await downloadFinishedPromise; + + ok(download.succeeded, "Download should succeed"); + + await publicDownloads.removeFinished(); + + await waitForScreenshotsEventCount(2); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + let screenshotReady = TestUtils.topicObserved( + "screenshots-preview-ready" + ); + + let panel = gBrowser.selectedBrowser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + + // click the visible page button in panel + let visiblePageButton = panel + .querySelector("screenshots-buttons") + .shadowRoot.querySelector(".visible-page"); + visiblePageButton.click(); + + let dialog = helper.getDialog(); + + await screenshotReady; + + let downloadButton = + dialog._frame.contentDocument.getElementById("download"); + ok(downloadButton, "Got the download button"); + + let screenshotExit = TestUtils.topicObserved("screenshots-exit"); + // click download button on dialog box + downloadButton.click(); + + info("wait for download to finish"); + download = await downloadFinishedPromise; + + ok(download.succeeded, "Download should succeed"); + + await publicDownloads.removeFinished(); + await screenshotExit; + await assertScreenshotsEvents(SCREENSHOTS_EVENTS); + } + ); +}); + +add_task(async function test_download_with_filepicker() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.useDownloadDir", false]], + }); + + let publicDownloads = await Downloads.getList(Downloads.PUBLIC); + // First ensure we catch the download finishing. + let downloadFinishedPromise = new Promise(resolve => { + publicDownloads.addView({ + onDownloadChanged(download) { + info("Download changed!"); + if (download.succeeded || download.error) { + info("Download succeeded or errored"); + publicDownloads.removeView(this); + resolve(download); + } + }, + }); + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + await helper.dragOverlay(10, 10, 500, 500); + + let filePicker = waitForFilePicker(); + let screenshotExit = TestUtils.topicObserved("screenshots-exit"); + await helper.clickDownloadButton(); + + await filePicker; + ok(true, "Export file picker opened"); + + info("wait for download to finish"); + let download = await downloadFinishedPromise; + + ok(download.succeeded, "Download should succeed"); + await publicDownloads.removeFinished(); + await screenshotExit; + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_escape.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_escape.js new file mode 100644 index 0000000000..73db436ff1 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_escape.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const SCREENSHOTS_EVENTS = [ + { category: "screenshots", method: "started", object: "toolbar_button" }, + { category: "screenshots", method: "canceled", object: "escape" }, +]; + +add_task(async function test_fullpageScreenshot() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await clearAllTelemetryEvents(); + let helper = new ScreenshotsHelper(browser); + + // click toolbar button so panel shows + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + EventUtils.synthesizeKey("KEY_F6", { shiftKey: true }); + + EventUtils.synthesizeKey("KEY_Escape"); + + await helper.waitForOverlayClosed(); + + await assertScreenshotsEvents(SCREENSHOTS_EVENTS); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js new file mode 100644 index 0000000000..51cda963d9 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js @@ -0,0 +1,175 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function assertRange(lhs, rhsMin, rhsMax, msg) { + Assert.ok(lhs >= rhsMin && lhs <= rhsMax, msg); +} + +add_task(async function test_fullpageScreenshot() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + let devicePixelRatio = await getContentDevicePixelRatio(browser); + + let expectedWidth = Math.floor( + devicePixelRatio * contentInfo.scrollWidth + ); + let expectedHeight = Math.floor( + devicePixelRatio * contentInfo.scrollHeight + ); + + // click toolbar button so panel shows + helper.triggerUIFromToolbar(); + + let panel = await helper.waitForPanel(); + + let screenshotReady = TestUtils.topicObserved( + "screenshots-preview-ready" + ); + + // click the full page button in panel + let visiblePage = panel + .querySelector("screenshots-buttons") + .shadowRoot.querySelector(".full-page"); + visiblePage.click(); + + let dialog = helper.getDialog(); + + await screenshotReady; + + let copyButton = dialog._frame.contentDocument.getElementById("copy"); + ok(copyButton, "Got the copy button"); + + let clipboardChanged = helper.waitForRawClipboardChange( + expectedWidth, + expectedHeight + ); + + // click copy button on dialog box + copyButton.click(); + + info("Waiting for clipboard change"); + let result = await clipboardChanged; + + info("result: " + JSON.stringify(result, null, 2)); + info("contentInfo: " + JSON.stringify(contentInfo, null, 2)); + + Assert.equal(result.width, expectedWidth, "Widths should be equal"); + Assert.equal(result.height, expectedHeight, "Heights should be equal"); + + // top left + assertRange(result.color.topLeft[0], 110, 111, "R color value"); + assertRange(result.color.topLeft[1], 110, 111, "G color value"); + assertRange(result.color.topLeft[2], 110, 111, "B color value"); + + // top right + assertRange(result.color.topRight[0], 55, 56, "R color value"); + assertRange(result.color.topRight[1], 155, 156, "G color value"); + assertRange(result.color.topRight[2], 155, 156, "B color value"); + + // bottom left + assertRange(result.color.bottomLeft[0], 105, 106, "R color value"); + assertRange(result.color.bottomLeft[1], 55, 56, "G color value"); + assertRange(result.color.bottomLeft[2], 105, 106, "B color value"); + + // bottom right + assertRange(result.color.bottomRight[0], 52, 53, "R color value"); + assertRange(result.color.bottomRight[1], 127, 128, "G color value"); + assertRange(result.color.bottomRight[2], 152, 153, "B color value"); + } + ); +}); + +add_task(async function test_fullpageScreenshotScrolled() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + await SpecialPowers.spawn(browser, [], () => { + content.scrollTo(0, 2008); + }); + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + let devicePixelRatio = await getContentDevicePixelRatio(browser); + + let expectedWidth = Math.floor( + devicePixelRatio * contentInfo.scrollWidth + ); + let expectedHeight = Math.floor( + devicePixelRatio * contentInfo.scrollHeight + ); + + // click toolbar button so panel shows + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + let panel = await helper.waitForPanel(); + + let screenshotReady = TestUtils.topicObserved( + "screenshots-preview-ready" + ); + + // click the full page button in panel + let visiblePage = panel + .querySelector("screenshots-buttons") + .shadowRoot.querySelector(".full-page"); + visiblePage.click(); + + let dialog = helper.getDialog(); + + await screenshotReady; + + let copyButton = dialog._frame.contentDocument.getElementById("copy"); + ok(copyButton, "Got the copy button"); + + let clipboardChanged = helper.waitForRawClipboardChange( + expectedWidth, + expectedHeight + ); + + // click copy button on dialog box + copyButton.click(); + + info("Waiting for clipboard change"); + let result = await clipboardChanged; + + info("result: " + JSON.stringify(result, null, 2)); + info("contentInfo: " + JSON.stringify(contentInfo, null, 2)); + + Assert.equal(result.width, expectedWidth, "Widths should be equal"); + Assert.equal(result.height, expectedHeight, "Heights should be equal"); + + // top left + assertRange(result.color.topLeft[0], 110, 111, "R color value"); + assertRange(result.color.topLeft[1], 110, 111, "G color value"); + assertRange(result.color.topLeft[2], 110, 111, "B color value"); + + // top right + assertRange(result.color.topRight[0], 55, 56, "R color value"); + assertRange(result.color.topRight[1], 155, 156, "G color value"); + assertRange(result.color.topRight[2], 155, 156, "B color value"); + + // bottom left + assertRange(result.color.bottomLeft[0], 105, 106, "R color value"); + assertRange(result.color.bottomLeft[1], 55, 56, "G color value"); + assertRange(result.color.bottomLeft[2], 105, 106, "B color value"); + + // bottom right + assertRange(result.color.bottomRight[0], 52, 53, "R color value"); + assertRange(result.color.bottomRight[1], 127, 128, "G color value"); + assertRange(result.color.bottomRight[2], 152, 153, "B color value"); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_page_crash.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_page_crash.js new file mode 100644 index 0000000000..bed9d006db --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_page_crash.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_fullpageScreenshot() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + // click toolbar button so UI shows + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + let panel = await helper.waitForPanel(gBrowser.selectedBrowser); + + let waitForPanelHide = BrowserTestUtils.waitForMutationCondition( + panel, + { attributes: true }, + () => { + return BrowserTestUtils.isHidden(panel); + } + ); + + await BrowserTestUtils.crashFrame(browser); + + await waitForPanelHide; + ok( + BrowserTestUtils.isHidden(panel), + "Panel buttons are hidden after page crash" + ); + + await ContentTask.spawn(browser, null, async () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + Assert.ok(!screenshotsChild._overlay, "The overlay doesn't exist"); + }); + + let tab = gBrowser.getTabForBrowser(browser); + + SessionStore.reviveCrashedTab(tab); + + ok( + BrowserTestUtils.isHidden(panel), + "Panel buttons are hidden after page crash" + ); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_screenshot_too_big.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_screenshot_too_big.js new file mode 100644 index 0000000000..4e2da3d494 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_screenshot_too_big.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const SCREENSHOTS_EVENTS = [ + { category: "screenshots", method: "failed", object: "screenshot_too_large" }, + { category: "screenshots", method: "failed", object: "screenshot_too_large" }, + { category: "screenshots", method: "failed", object: "screenshot_too_large" }, + { category: "screenshots", method: "failed", object: "screenshot_too_large" }, +]; + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +ChromeUtils.defineESModuleGetters(this, { + ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs", +}); + +add_task(async function test_screenshot_too_large_cropped() { + await clearAllTelemetryEvents(); + const screenshotsLocalization = new Localization( + ["browser/screenshots.ftl"], + true + ); + + let [errorTitle, errorMessage] = screenshotsLocalization.formatMessagesSync([ + { id: "screenshots-too-large-error-title" }, + { id: "screenshots-too-large-error-details" }, + ]); + let showAlertMessageStub = sinon + .stub(ScreenshotsUtils, "showAlertMessage") + .callsFake(function (title, message) { + is(title, errorTitle.value, "Got error title"); + is(message, errorMessage.value, "Got error message"); + }); + + let rect = { x: 0, y: 0, width: 40000, height: 40000, devicePixelRatio: 1 }; + + ScreenshotsUtils.cropScreenshotRectIfNeeded(rect); + + is( + rect.width, + MAX_CAPTURE_DIMENSION, + `The width is ${MAX_CAPTURE_DIMENSION}` + ); + is( + rect.height, + Math.floor(MAX_CAPTURE_AREA / MAX_CAPTURE_DIMENSION), + `The height is ${MAX_CAPTURE_AREA} / ${MAX_CAPTURE_DIMENSION}` + ); + + rect.width = 40000; + rect.hegith = 1; + + ScreenshotsUtils.cropScreenshotRectIfNeeded(rect); + + is( + rect.width, + MAX_CAPTURE_DIMENSION, + `The width was cropped to the max capture dimension (${MAX_CAPTURE_DIMENSION}).` + ); + + rect.width = 1; + rect.height = 40000; + + ScreenshotsUtils.cropScreenshotRectIfNeeded(rect); + + is( + rect.height, + MAX_CAPTURE_DIMENSION, + `The height was cropped to the max capture dimension (${MAX_CAPTURE_DIMENSION}).` + ); + + rect.width = 25000; + rect.height = 25000; + + ScreenshotsUtils.cropScreenshotRectIfNeeded(rect); + + is(rect.width, 25000, "The width was not cropped"); + is( + rect.height, + Math.floor(MAX_CAPTURE_AREA / 25000), + `The height was cropped to ${MAX_CAPTURE_AREA / 25000}` + ); + + showAlertMessageStub.restore(); + + await assertScreenshotsEvents(SCREENSHOTS_EVENTS); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js new file mode 100644 index 0000000000..0aafba8fb3 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js @@ -0,0 +1,289 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs", +}); +ChromeUtils.defineLazyGetter(this, "ExtensionManagement", () => { + const { Management } = ChromeUtils.importESModule( + "resource://gre/modules/Extension.sys.mjs" + ); + return Management; +}); + +add_task(async function test() { + let observerSpy = sinon.spy(); + let notifierSpy = sinon.spy(); + + let observerStub = sinon + .stub(ScreenshotsUtils, "observe") + .callsFake(observerSpy); + let notifierStub = sinon + .stub(ScreenshotsUtils, "notify") + .callsFake(function (window, type) { + notifierSpy(); + ScreenshotsUtils.notify.wrappedMethod.apply(this, arguments); + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: SHORT_TEST_PAGE, + }, + async browser => { + function awaitExtensionEvent(eventName, id) { + return new Promise(resolve => { + let listener = (_eventName, ...args) => { + let extension = args[0]; + if (_eventName === eventName && extension.id == id) { + ExtensionManagement.off(eventName, listener); + resolve(); + } + }; + ExtensionManagement.on(eventName, listener); + }); + } + const SCREENSHOT_EXTENSION = "screenshots@mozilla.org"; + + let helper = new ScreenshotsHelper(browser); + + ok(observerSpy.notCalled, "Observer not called"); + helper.triggerUIFromToolbar(); + Assert.equal(observerSpy.callCount, 1, "Observer function called once"); + + ok(notifierSpy.notCalled, "Notifier not called"); + EventUtils.synthesizeKey("s", { accelKey: true, shiftKey: true }); + + await TestUtils.waitForCondition(() => notifierSpy.callCount == 1); + Assert.equal(notifierSpy.callCount, 1, "Notify function called once"); + + await TestUtils.waitForCondition(() => observerSpy.callCount == 2); + Assert.equal(observerSpy.callCount, 2, "Observer function called twice"); + + let menu = document.getElementById("contentAreaContextMenu"); + let popupshown = BrowserTestUtils.waitForPopupEvent(menu, "shown"); + EventUtils.synthesizeMouseAtCenter(document.body, { + type: "contextmenu", + }); + await popupshown; + Assert.equal(menu.state, "open", "Context menu is open"); + + let popuphidden = BrowserTestUtils.waitForPopupEvent(menu, "hidden"); + menu.activateItem(menu.querySelector("#context-take-screenshot")); + await popuphidden; + + Assert.equal(observerSpy.callCount, 3, "Observer function called thrice"); + + const COMPONENT_PREF = "screenshots.browser.component.enabled"; + await SpecialPowers.pushPrefEnv({ + set: [[COMPONENT_PREF, false]], + }); + ok(!Services.prefs.getBoolPref(COMPONENT_PREF), "Extension enabled"); + await awaitExtensionEvent("ready", SCREENSHOT_EXTENSION); + + helper.triggerUIFromToolbar(); + Assert.equal( + observerSpy.callCount, + 3, + "Observer function still called thrice" + ); + + await SpecialPowers.spawn( + browser, + ["#firefox-screenshots-preselection-iframe"], + async function (iframeSelector) { + info( + `in waitForUIContent content function, iframeSelector: ${iframeSelector}` + ); + let iframe; + await ContentTaskUtils.waitForCondition(() => { + iframe = content.document.querySelector(iframeSelector); + if (!iframe || !ContentTaskUtils.isVisible(iframe)) { + info("in waitForUIContent, no visible iframe yet"); + return false; + } + return true; + }); + // wait a frame for the screenshots UI to finish any init + await new content.Promise(res => content.requestAnimationFrame(res)); + } + ); + + helper.triggerUIFromToolbar(); + await SpecialPowers.spawn( + browser, + ["#firefox-screenshots-preselection-iframe"], + async function (iframeSelector) { + info( + `in waitForUIContent content function, iframeSelector: ${iframeSelector}` + ); + let iframe; + await ContentTaskUtils.waitForCondition(() => { + iframe = content.document.querySelector(iframeSelector); + if (!iframe || !ContentTaskUtils.isVisible(iframe)) { + info("in waitForUIContent, no visible iframe yet"); + return true; + } + return false; + }); + // wait a frame for the screenshots UI to finish any init + await new content.Promise(res => content.requestAnimationFrame(res)); + } + ); + + popupshown = BrowserTestUtils.waitForPopupEvent(menu, "shown"); + EventUtils.synthesizeMouseAtCenter(document.body, { + type: "contextmenu", + }); + await popupshown; + Assert.equal(menu.state, "open", "Context menu is open"); + + popuphidden = BrowserTestUtils.waitForPopupEvent(menu, "hidden"); + menu.activateItem(menu.querySelector("#context-take-screenshot")); + await popuphidden; + + Assert.equal( + observerSpy.callCount, + 3, + "Observer function still called thrice" + ); + + await SpecialPowers.spawn( + browser, + ["#firefox-screenshots-preselection-iframe"], + async function (iframeSelector) { + info( + `in waitForUIContent content function, iframeSelector: ${iframeSelector}` + ); + let iframe; + await ContentTaskUtils.waitForCondition(() => { + iframe = content.document.querySelector(iframeSelector); + if (!iframe || !ContentTaskUtils.isVisible(iframe)) { + info("in waitForUIContent, no visible iframe yet"); + return false; + } + return true; + }); + // wait a frame for the screenshots UI to finish any init + await new content.Promise(res => content.requestAnimationFrame(res)); + } + ); + + helper.triggerUIFromToolbar(); + await SpecialPowers.spawn( + browser, + ["#firefox-screenshots-preselection-iframe"], + async function (iframeSelector) { + info( + `in waitForUIContent content function, iframeSelector: ${iframeSelector}` + ); + let iframe; + await ContentTaskUtils.waitForCondition(() => { + iframe = content.document.querySelector(iframeSelector); + if (!iframe || !ContentTaskUtils.isVisible(iframe)) { + return true; + } + info("in waitForUIContent, iframe still visible"); + info(iframe); + return false; + }); + // wait a frame for the screenshots UI to finish any init + await new content.Promise(res => content.requestAnimationFrame(res)); + } + ); + + let componentReady = TestUtils.topicObserved( + "screenshots-component-initialized" + ); + + await SpecialPowers.pushPrefEnv({ + set: [[COMPONENT_PREF, true]], + }); + ok(Services.prefs.getBoolPref(COMPONENT_PREF), "Component enabled"); + // Needed for component to initialize + await componentReady; + + helper.triggerUIFromToolbar(); + Assert.equal( + observerSpy.callCount, + 4, + "Observer function called four times" + ); + + const SCREENSHOTS_PREF = "extensions.screenshots.disabled"; + await SpecialPowers.pushPrefEnv({ + set: [[SCREENSHOTS_PREF, true]], + }); + ok(Services.prefs.getBoolPref(SCREENSHOTS_PREF), "Screenshots disabled"); + } + ); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: SHORT_TEST_PAGE, + }, + async browser => { + const SCREENSHOTS_PREF = "extensions.screenshots.disabled"; + ok(Services.prefs.getBoolPref(SCREENSHOTS_PREF), "Screenshots disabled"); + + ok( + document.getElementById("screenshot-button").disabled, + "Toolbar button disabled" + ); + + let menu = document.getElementById("contentAreaContextMenu"); + let popupshown = BrowserTestUtils.waitForPopupEvent(menu, "shown"); + EventUtils.synthesizeMouseAtCenter(document.body, { + type: "contextmenu", + }); + await popupshown; + Assert.equal(menu.state, "open", "Context menu is open"); + + ok( + menu.querySelector("#context-take-screenshot").hidden, + "Take screenshot is not in context menu" + ); + + let popuphidden = BrowserTestUtils.waitForPopupEvent(menu, "hidden"); + menu.hidePopup(); + await popuphidden; + + await SpecialPowers.pushPrefEnv({ + set: [[SCREENSHOTS_PREF, false]], + }); + ok(!Services.prefs.getBoolPref(SCREENSHOTS_PREF), "Screenshots enabled"); + } + ); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: SHORT_TEST_PAGE, + }, + async browser => { + const SCREENSHOTS_PREF = "extensions.screenshots.disabled"; + ok(!Services.prefs.getBoolPref(SCREENSHOTS_PREF), "Screenshots enabled"); + + let helper = new ScreenshotsHelper(browser); + + helper.triggerUIFromToolbar(); + Assert.equal( + observerSpy.callCount, + 5, + "Observer function called for the fifth time" + ); + } + ); + + observerStub.restore(); + notifierStub.restore(); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_toolbar_button.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_toolbar_button.js new file mode 100644 index 0000000000..5ad7d32192 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_toolbar_button.js @@ -0,0 +1,26 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function testScreenshotButtonDisabled() { + info("Test the Screenshots button in the panel"); + + let screenshotBtn = document.getElementById("screenshot-button"); + Assert.ok(screenshotBtn, "The screenshots button was added to the nav bar"); + + await BrowserTestUtils.withNewTab("https://example.com/", () => { + Assert.equal( + screenshotBtn.disabled, + false, + "Screenshots button is enabled" + ); + }); + await BrowserTestUtils.withNewTab("about:home", () => { + Assert.equal( + screenshotBtn.disabled, + false, + "Screenshots button is still enabled on about pages" + ); + }); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_visible.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_visible.js new file mode 100644 index 0000000000..7b7a46f73d --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_visible.js @@ -0,0 +1,356 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_visibleScreenshot() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + let devicePixelRatio = await getContentDevicePixelRatio(browser); + + let expectedWidth = Math.floor( + devicePixelRatio * contentInfo.clientWidth + ); + let expectedHeight = Math.floor( + devicePixelRatio * contentInfo.clientHeight + ); + + // click toolbar button so panel shows + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + let screenshotReady = TestUtils.topicObserved( + "screenshots-preview-ready" + ); + + let panel = gBrowser.selectedBrowser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + + // click the visible page button in panel + let visiblePageButton = panel + .querySelector("screenshots-buttons") + .shadowRoot.querySelector(".visible-page"); + visiblePageButton.click(); + + let dialog = helper.getDialog(); + + await screenshotReady; + + let copyButton = dialog._frame.contentDocument.getElementById("copy"); + ok(copyButton, "Got the copy button"); + + let clipboardChanged = helper.waitForRawClipboardChange( + expectedWidth, + expectedHeight + ); + + // click copy button on dialog box + copyButton.click(); + + info("Waiting for clipboard change"); + let result = await clipboardChanged; + + info("result: " + JSON.stringify(result, null, 2)); + info("contentInfo: " + JSON.stringify(contentInfo, null, 2)); + + Assert.equal(result.width, expectedWidth, "Widths should be equal"); + Assert.equal(result.height, expectedHeight, "Heights should be equal"); + + // top left + Assert.equal(111, result.color.topLeft[0], "R color value"); + Assert.equal(111, result.color.topLeft[1], "G color value"); + Assert.equal(111, result.color.topLeft[2], "B color value"); + + // top right + Assert.equal(111, result.color.topRight[0], "R color value"); + Assert.equal(111, result.color.topRight[1], "G color value"); + Assert.equal(111, result.color.topRight[2], "B color value"); + + // bottom left + Assert.equal(111, result.color.bottomLeft[0], "R color value"); + Assert.equal(111, result.color.bottomLeft[1], "G color value"); + Assert.equal(111, result.color.bottomLeft[2], "B color value"); + + // bottom right + Assert.equal(111, result.color.bottomRight[0], "R color value"); + Assert.equal(111, result.color.bottomRight[1], "G color value"); + Assert.equal(111, result.color.bottomRight[2], "B color value"); + } + ); +}); + +add_task(async function test_visibleScreenshotScrolledY() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await SpecialPowers.spawn(browser, [], () => { + content.scrollTo(0, 2008); + }); + + let helper = new ScreenshotsHelper(browser); + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + let devicePixelRatio = await getContentDevicePixelRatio(browser); + + let expectedWidth = Math.floor( + devicePixelRatio * contentInfo.clientWidth + ); + let expectedHeight = Math.floor( + devicePixelRatio * contentInfo.clientHeight + ); + + // click toolbar button so panel shows + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + let panel = gBrowser.selectedBrowser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + + let screenshotReady = TestUtils.topicObserved( + "screenshots-preview-ready" + ); + + // click the visible page button in panel + let visiblePageButton = panel + .querySelector("screenshots-buttons") + .shadowRoot.querySelector(".visible-page"); + visiblePageButton.click(); + + let dialog = helper.getDialog(); + + await screenshotReady; + + let copyButton = dialog._frame.contentDocument.getElementById("copy"); + ok(copyButton, "Got the copy button"); + + let clipboardChanged = helper.waitForRawClipboardChange( + expectedWidth, + expectedHeight + ); + + // click copy button on dialog box + copyButton.click(); + + info("Waiting for clipboard change"); + let result = await clipboardChanged; + + // let result = await helper.getImageSizeAndColorFromClipboard(); + // debugger; + + info("result: " + JSON.stringify(result, null, 2)); + info("contentInfo: " + JSON.stringify(contentInfo, null, 2)); + + Assert.equal(result.width, expectedWidth, "Widths should be equal"); + Assert.equal(result.height, expectedHeight, "Heights should be equal"); + + // top left + Assert.equal(105, result.color.topLeft[0], "R color value"); + Assert.equal(55, result.color.topLeft[1], "G color value"); + Assert.equal(105, result.color.topLeft[2], "B color value"); + + // top right + Assert.equal(105, result.color.topRight[0], "R color value"); + Assert.equal(55, result.color.topRight[1], "G color value"); + Assert.equal(105, result.color.topRight[2], "B color value"); + + // bottom left + Assert.equal(105, result.color.bottomLeft[0], "R color value"); + Assert.equal(55, result.color.bottomLeft[1], "G color value"); + Assert.equal(105, result.color.bottomLeft[2], "B color value"); + + // bottom right + Assert.equal(105, result.color.bottomRight[0], "R color value"); + Assert.equal(55, result.color.bottomRight[1], "G color value"); + Assert.equal(105, result.color.bottomRight[2], "B color value"); + } + ); +}); + +add_task(async function test_visibleScreenshotScrolledX() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await SpecialPowers.spawn(browser, [], () => { + content.scrollTo(2004, 0); + }); + + let helper = new ScreenshotsHelper(browser); + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + let devicePixelRatio = await getContentDevicePixelRatio(browser); + + let expectedWidth = Math.floor( + devicePixelRatio * contentInfo.clientWidth + ); + let expectedHeight = Math.floor( + devicePixelRatio * contentInfo.clientHeight + ); + + // click toolbar button so panel shows + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + let panel = gBrowser.selectedBrowser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + + let screenshotReady = TestUtils.topicObserved( + "screenshots-preview-ready" + ); + + // click the visible page button in panel + let visiblePageButton = panel + .querySelector("screenshots-buttons") + .shadowRoot.querySelector(".visible-page"); + visiblePageButton.click(); + + let dialog = helper.getDialog(); + + await screenshotReady; + + let copyButton = dialog._frame.contentDocument.getElementById("copy"); + ok(copyButton, "Got the copy button"); + + let clipboardChanged = helper.waitForRawClipboardChange( + expectedWidth, + expectedHeight + ); + + // click copy button on dialog box + copyButton.click(); + + info("Waiting for clipboard change"); + let result = await clipboardChanged; + + info("result: " + JSON.stringify(result, null, 2)); + info("contentInfo: " + JSON.stringify(contentInfo, null, 2)); + + Assert.equal(result.width, expectedWidth, "Widths should be equal"); + Assert.equal(result.height, expectedHeight, "Heights should be equal"); + + // top left + Assert.equal(55, result.color.topLeft[0], "R color value"); + Assert.equal(155, result.color.topLeft[1], "G color value"); + Assert.equal(155, result.color.topLeft[2], "B color value"); + + // top right + Assert.equal(55, result.color.topRight[0], "R color value"); + Assert.equal(155, result.color.topRight[1], "G color value"); + Assert.equal(155, result.color.topRight[2], "B color value"); + + // bottom left + Assert.equal(55, result.color.bottomLeft[0], "R color value"); + Assert.equal(155, result.color.bottomLeft[1], "G color value"); + Assert.equal(155, result.color.bottomLeft[2], "B color value"); + + // bottom right + Assert.equal(55, result.color.bottomRight[0], "R color value"); + Assert.equal(155, result.color.bottomRight[1], "G color value"); + Assert.equal(155, result.color.bottomRight[2], "B color value"); + } + ); +}); + +add_task(async function test_visibleScreenshotScrolledXAndY() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await SpecialPowers.spawn(browser, [], () => { + content.scrollTo(2004, 2008); + }); + + let helper = new ScreenshotsHelper(browser); + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + let devicePixelRatio = await getContentDevicePixelRatio(browser); + + let expectedWidth = Math.floor( + devicePixelRatio * contentInfo.clientWidth + ); + let expectedHeight = Math.floor( + devicePixelRatio * contentInfo.clientHeight + ); + + // click toolbar button so panel shows + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + let panel = gBrowser.selectedBrowser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + + let screenshotReady = TestUtils.topicObserved( + "screenshots-preview-ready" + ); + + // click the visible page button in panel + let visiblePageButton = panel + .querySelector("screenshots-buttons") + .shadowRoot.querySelector(".visible-page"); + visiblePageButton.click(); + + let dialog = helper.getDialog(); + + await screenshotReady; + + let copyButton = dialog._frame.contentDocument.getElementById("copy"); + ok(copyButton, "Got the copy button"); + + let clipboardChanged = helper.waitForRawClipboardChange( + expectedWidth, + expectedHeight + ); + + // click copy button on dialog box + copyButton.click(); + + info("Waiting for clipboard change"); + let result = await clipboardChanged; + + info("result: " + JSON.stringify(result, null, 2)); + info("contentInfo: " + JSON.stringify(contentInfo, null, 2)); + + Assert.equal(result.width, expectedWidth, "Widths should be equal"); + Assert.equal(result.height, expectedHeight, "Heights should be equal"); + + // top left + Assert.equal(52, result.color.topLeft[0], "R color value"); + Assert.equal(127, result.color.topLeft[1], "G color value"); + Assert.equal(152, result.color.topLeft[2], "B color value"); + + // top right + Assert.equal(52, result.color.topRight[0], "R color value"); + Assert.equal(127, result.color.topRight[1], "G color value"); + Assert.equal(152, result.color.topRight[2], "B color value"); + + // bottom left + Assert.equal(52, result.color.bottomLeft[0], "R color value"); + Assert.equal(127, result.color.bottomLeft[1], "G color value"); + Assert.equal(152, result.color.bottomLeft[2], "B color value"); + + // bottom right + Assert.equal(52, result.color.bottomRight[0], "R color value"); + Assert.equal(127, result.color.bottomRight[1], "G color value"); + Assert.equal(152, result.color.bottomRight[2], "B color value"); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_test_element_picker.js b/browser/components/screenshots/tests/browser/browser_test_element_picker.js new file mode 100644 index 0000000000..17ed2a0190 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_test_element_picker.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_element_picker() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await clearAllTelemetryEvents(); + let helper = new ScreenshotsHelper(browser); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + await helper.clickTestPageElement(); + + let rect = await helper.getTestPageElementRect(); + let region = await helper.getSelectionRegionDimensions(); + + is( + region.left, + rect.left, + "The selected region left is the same as the element left" + ); + is( + region.right, + rect.right, + "The selected region right is the same as the element right" + ); + is( + region.top, + rect.top, + "The selected region top is the same as the element top" + ); + is( + region.bottom, + rect.bottom, + "The selected region bottom is the same as the element bottom" + ); + + mouse.click(10, 10); + await helper.waitForStateChange("crosshairs"); + + let hoverElementRegionValid = await helper.isHoverElementRegionValid(); + + ok(!hoverElementRegionValid, "Hover element rect is null"); + + mouse.click(10, 10); + await helper.waitForStateChange("crosshairs"); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_test_resize.js b/browser/components/screenshots/tests/browser/browser_test_resize.js new file mode 100644 index 0000000000..b249a346d6 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_test_resize.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const windowWidth = 768; + +add_task(async function test_window_resize() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: RESIZE_TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + await helper.resizeContentWindow(windowWidth, window.outerHeight); + const originalContentDimensions = await helper.getContentDimensions(); + info(JSON.stringify(originalContentDimensions, null, 2)); + + await helper.zoomBrowser(1.5); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + await helper.scrollContentWindow(windowWidth, window.outerHeight); + + await helper.clickTestPageElement("hello"); + + await helper.zoomBrowser(1); + + await helper.waitForOverlaySizeChangeTo( + originalContentDimensions.scrollWidth, + originalContentDimensions.scrollHeight + ); + + let contentDims = await helper.getContentDimensions(); + info(JSON.stringify(contentDims, null, 2)); + + is( + contentDims.scrollWidth, + originalContentDimensions.scrollWidth, + "Width of page is back to original" + ); + is( + contentDims.scrollHeight, + originalContentDimensions.scrollHeight, + "Height of page is back to original" + ); + } + ); +}); + +add_task(async function test_window_resize_vertical_writing_mode() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: RESIZE_TEST_PAGE, + }, + async browser => { + await SpecialPowers.spawn(browser, [], () => { + content.document.documentElement.style = "writing-mode: vertical-lr;"; + }); + + let helper = new ScreenshotsHelper(browser); + await helper.resizeContentWindow(windowWidth, window.outerHeight); + const originalContentDimensions = await helper.getContentDimensions(); + info(JSON.stringify(originalContentDimensions, null, 2)); + + await helper.zoomBrowser(1.5); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + await helper.scrollContentWindow(windowWidth, window.outerHeight); + + await helper.clickTestPageElement("hello"); + + await helper.zoomBrowser(1); + + await helper.waitForOverlaySizeChangeTo( + originalContentDimensions.scrollWidth, + originalContentDimensions.scrollHeight + ); + + let contentDims = await helper.getContentDimensions(); + info(JSON.stringify(contentDims, null, 2)); + + is( + contentDims.scrollWidth, + originalContentDimensions.scrollWidth, + "Width of page is back to original" + ); + is( + contentDims.scrollHeight, + originalContentDimensions.scrollHeight, + "Height of page is back to original" + ); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/first-iframe.html b/browser/components/screenshots/tests/browser/first-iframe.html new file mode 100644 index 0000000000..9b0c123486 --- /dev/null +++ b/browser/components/screenshots/tests/browser/first-iframe.html @@ -0,0 +1,23 @@ +<!DOCTYPE HTML> +<html lang="en"> + <head> + <meta charset="utf-8"> + <style> + div { + font-size: 40px; + margin: 30px; + width: 234px; + height: 51px; + color: blue; + } + </style> + </head> +<body> + <div>Hello world!</div> + <iframe + width="300" + height="300" + src="https://example.org/browser/browser/components/screenshots/tests/browser/second-iframe.html" + ></iframe> +</body> +</html> diff --git a/browser/components/screenshots/tests/browser/head.js b/browser/components/screenshots/tests/browser/head.js new file mode 100644 index 0000000000..171e3b8c41 --- /dev/null +++ b/browser/components/screenshots/tests/browser/head.js @@ -0,0 +1,951 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); +const { UrlbarTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" +); + +const TEST_ROOT = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +const TEST_PAGE = TEST_ROOT + "test-page.html"; +const SHORT_TEST_PAGE = TEST_ROOT + "short-test-page.html"; +const LARGE_TEST_PAGE = TEST_ROOT + "large-test-page.html"; +const IFRAME_TEST_PAGE = TEST_ROOT + "iframe-test-page.html"; +const RESIZE_TEST_PAGE = TEST_ROOT + "test-page-resize.html"; + +const { MAX_CAPTURE_DIMENSION, MAX_CAPTURE_AREA } = ChromeUtils.importESModule( + "resource:///modules/ScreenshotsUtils.sys.mjs" +); + +const gScreenshotUISelectors = { + panel: "#screenshotsPagePanel", + fullPageButton: "button.full-page", + visiblePageButton: "button.visible-page", + copyButton: "button.#copy", +}; + +// MouseEvents is for the mouse events on the Anonymous content +const MouseEvents = { + mouse: new Proxy( + {}, + { + get: (target, name) => + async function (x, y, options = {}) { + if (name === "click") { + this.down(x, y, options); + this.up(x, y, options); + } else { + await safeSynthesizeMouseEventInContentPage(":root", x, y, { + type: "mouse" + name, + ...options, + }); + } + }, + } + ), +}; + +const { mouse } = MouseEvents; + +class ScreenshotsHelper { + constructor(browser) { + this.browser = browser; + this.selector = gScreenshotUISelectors; + } + + get toolbarButton() { + return this.browser.ownerDocument.getElementById("screenshot-button"); + } + + get panel() { + return this.browser.ownerDocument.querySelector(this.selector.panel); + } + + /** + * Click the screenshots button in the toolbar + */ + triggerUIFromToolbar() { + let button = this.toolbarButton; + ok( + BrowserTestUtils.isVisible(button), + "The screenshot toolbar button is visible" + ); + button.click(); + } + + async getPanelButton(selector) { + let panel = await this.waitForPanel(); + let screenshotsButtons = panel.querySelector("screenshots-buttons"); + ok(screenshotsButtons, "Found the screenshots-buttons"); + let button = screenshotsButtons.shadowRoot.querySelector(selector); + ok(button, `Found ${selector} button`); + return button; + } + + async waitForPanel() { + let panel = this.panel; + await BrowserTestUtils.waitForCondition(async () => { + if (!panel) { + panel = this.panel; + } + return panel && BrowserTestUtils.isVisible(panel); + }); + return panel; + } + + async waitForOverlay() { + const panel = await this.waitForPanel(); + ok(BrowserTestUtils.isVisible(panel), "Panel buttons are visible"); + + await BrowserTestUtils.waitForCondition(async () => { + let init = await this.isOverlayInitialized(); + return init; + }); + info("Overlay is visible"); + } + + async waitForPanelClosed() { + let panel = this.panel; + if (!panel) { + info("waitForPanelClosed: Panel doesnt exist"); + return; + } + if (panel.hidden) { + info("waitForPanelClosed: panel is already hidden"); + return; + } + info("waitForPanelClosed: waiting for the panel to become hidden"); + await BrowserTestUtils.waitForMutationCondition( + panel, + { attributes: true }, + () => { + return BrowserTestUtils.isHidden(panel); + } + ); + ok(BrowserTestUtils.isHidden(panel), "Panel buttons are hidden"); + info("waitForPanelClosed, panel is hidden: " + panel.hidden); + } + + async waitForOverlayClosed() { + await this.waitForPanelClosed(); + await BrowserTestUtils.waitForCondition(async () => { + let init = !(await this.isOverlayInitialized()); + info("Is overlay initialized: " + !init); + return init; + }); + info("Overlay is not visible"); + } + + async isOverlayInitialized() { + return SpecialPowers.spawn(this.browser, [], () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + return screenshotsChild?.overlay?.initialized; + }); + } + + waitForStateChange(newState) { + return SpecialPowers.spawn(this.browser, [newState], async state => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + + await ContentTaskUtils.waitForCondition(() => { + info(`got ${screenshotsChild.overlay.state}. expected ${state}`); + return screenshotsChild.overlay.state === state; + }, `Wait for overlay state to be ${state}`); + + return screenshotsChild.overlay.state; + }); + } + + async assertStateChange(newState) { + let currentState = await this.waitForStateChange(newState); + + is( + currentState, + newState, + `The current state is ${currentState}, expected ${newState}` + ); + } + + getHoverElementRect() { + return ContentTask.spawn(this.browser, null, async () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + return screenshotsChild.overlay.hoverElementRegion.dimensions; + }); + } + + isHoverElementRegionValid() { + return ContentTask.spawn(this.browser, null, async () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + return screenshotsChild.overlay.hoverElementRegion.isRegionValid; + }); + } + + async waitForHoverElementRect(expectedWidth, expectedHeight) { + return SpecialPowers.spawn( + this.browser, + [expectedWidth, expectedHeight], + async (width, height) => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + let dimensions; + await ContentTaskUtils.waitForCondition(() => { + dimensions = screenshotsChild.overlay.hoverElementRegion.dimensions; + return dimensions.width === width && dimensions.height === height; + }, "The hover element region is the expected width and height"); + return dimensions; + } + ); + } + + async waitForSelectionRegionSizeChange(currentWidth) { + await ContentTask.spawn( + this.browser, + [currentWidth], + async ([currWidth]) => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + + let dimensions = screenshotsChild.overlay.selectionRegion.dimensions; + await ContentTaskUtils.waitForCondition(() => { + dimensions = screenshotsChild.overlay.selectionRegion.dimensions; + return dimensions.width !== currWidth; + }, "Wait for selection box width change"); + } + ); + } + + /** + * This will drag an overlay starting at the given startX and startY coordinates and ending + * at the given endX and endY coordinates. + * + * endY should be at least 70px from the bottom of window and endX should be at least + * 265px from the left of the window. If these requirements are not met then the + * overlay buttons (cancel, copy, download) will be positioned different from the default + * and the methods to click the overlay buttons will not work unless the updated + * position coordinates are supplied. + * See https://searchfox.org/mozilla-central/rev/af78418c4b5f2c8721d1a06486cf4cf0b33e1e8d/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs#1789,1798 + * for how the overlay buttons are positioned when the overlay rect is near the bottom or + * left edge of the window. + * + * Note: The distance of the rect should be greater than 40 to enter in the "dragging" state. + * See https://searchfox.org/mozilla-central/rev/af78418c4b5f2c8721d1a06486cf4cf0b33e1e8d/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs#809 + * @param {Number} startX The starting X coordinate. The left edge of the overlay rect. + * @param {Number} startY The starting Y coordinate. The top edge of the overlay rect. + * @param {Number} endX The end X coordinate. The right edge of the overlay rect. + * @param {Number} endY The end Y coordinate. The bottom edge of the overlay rect. + */ + async dragOverlay( + startX, + startY, + endX, + endY, + expectedStartingState = "crosshairs" + ) { + await this.assertStateChange(expectedStartingState); + + mouse.down(startX, startY); + + await Promise.any([ + this.waitForStateChange("draggingReady"), + this.waitForStateChange("resizing"), + ]); + Assert.ok(true, "The overlay is in the draggingReady or resizing state"); + + mouse.move(endX, endY); + + await Promise.any([ + this.waitForStateChange("dragging"), + this.waitForStateChange("resizing"), + ]); + Assert.ok(true, "The overlay is in the dragging or resizing state"); + + mouse.up(endX, endY); + + await this.assertStateChange("selected"); + + this.endX = endX; + this.endY = endY; + } + + async moveOverlayViaKeyboard(mover, events) { + await SpecialPowers.spawn( + this.browser, + [mover, events], + async (moverToFocus, eventsArr) => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + + let overlay = screenshotsChild.overlay; + + switch (moverToFocus) { + case "highlight": + overlay.highlightEl.focus({ focusVisible: true }); + break; + case "mover-bottomLeft": + overlay.bottomLeftMover.focus({ focusVisible: true }); + break; + case "mover-bottomRight": + overlay.bottomRightMover.focus({ focusVisible: true }); + break; + case "mover-topLeft": + overlay.topLeftMover.focus({ focusVisible: true }); + break; + case "mover-topRight": + overlay.topRightMover.focus({ focusVisible: true }); + break; + } + screenshotsChild.overlay.highlightEl.focus(); + + for (let event of eventsArr) { + EventUtils.synthesizeKey( + event.key, + { type: "keydown", ...event.options }, + content + ); + + await ContentTaskUtils.waitForCondition( + () => overlay.state === "resizing", + "Wait for overlay state to be resizing" + ); + + EventUtils.synthesizeKey( + event.key, + { type: "keyup", ...event.options }, + content + ); + + await ContentTaskUtils.waitForCondition( + () => overlay.state === "selected", + "Wait for overlay state to be selected" + ); + } + } + ); + } + + async scrollContentWindow(x, y) { + let promise = BrowserTestUtils.waitForContentEvent(this.browser, "scroll"); + let contentDims = await this.getContentDimensions(); + await ContentTask.spawn( + this.browser, + [x, y, contentDims], + async ([xPos, yPos, cDims]) => { + content.window.scroll(xPos, yPos); + + info(JSON.stringify(cDims, null, 2)); + const scrollbarHeight = {}; + const scrollbarWidth = {}; + content.window.windowUtils.getScrollbarSize( + false, + scrollbarWidth, + scrollbarHeight + ); + + await ContentTaskUtils.waitForCondition(() => { + function isCloseEnough(a, b, diff) { + return Math.abs(a - b) <= diff; + } + + info( + `scrollbarWidth: ${scrollbarWidth.value}, scrollbarHeight: ${scrollbarHeight.value}` + ); + info( + `scrollX: ${content.window.scrollX}, scrollY: ${content.window.scrollY}, scrollMaxX: ${content.window.scrollMaxX}, scrollMaxY: ${content.window.scrollMaxY}` + ); + + // Sometimes (read intermittently) the scroll width/height will be + // off by the width/height of the scrollbar when we are expecting the + // page to be scrolled to the very end. To mitigate this, we check + // that the below differences are within the scrollbar width/height. + return ( + (content.window.scrollX === xPos || + isCloseEnough( + cDims.clientWidth + content.window.scrollX, + cDims.scrollWidth, + scrollbarWidth.value + 1 + )) && + (content.window.scrollY === yPos || + isCloseEnough( + cDims.clientHeight + content.window.scrollY, + cDims.scrollHeight, + scrollbarHeight.value + 1 + )) + ); + }, `Waiting for window to scroll to ${xPos}, ${yPos}`); + } + ); + await promise; + } + + async waitForScrollTo(x, y) { + await ContentTask.spawn(this.browser, [x, y], async ([xPos, yPos]) => { + await ContentTaskUtils.waitForCondition(() => { + info( + `Got scrollX: ${content.window.scrollX}. scrollY: ${content.window.scrollY}` + ); + return ( + content.window.scrollX === xPos && content.window.scrollY === yPos + ); + }, `Waiting for window to scroll to ${xPos}, ${yPos}`); + }); + } + + async resizeContentWindow(width, height) { + this.browser.ownerGlobal.resizeTo(width, height); + await TestUtils.waitForCondition( + () => window.outerHeight === height && window.outerWidth === width, + "Waiting for window to resize" + ); + } + + async clickDownloadButton() { + let { centerX: x, centerY: y } = await ContentTask.spawn( + this.browser, + null, + async () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + let { left, top, width, height } = + screenshotsChild.overlay.downloadButton.getBoundingClientRect(); + let centerX = left + width / 2; + let centerY = top + height / 2; + return { centerX, centerY }; + } + ); + + info(`clicking download button at ${x}, ${y}`); + mouse.click(x, y); + } + + async clickCopyButton() { + let { centerX: x, centerY: y } = await ContentTask.spawn( + this.browser, + null, + async () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + let { left, top, width, height } = + screenshotsChild.overlay.copyButton.getBoundingClientRect(); + let centerX = left + width / 2; + let centerY = top + height / 2; + return { centerX, centerY }; + } + ); + + info(`clicking copy button at ${x}, ${y}`); + mouse.click(x, y); + } + + async clickCancelButton() { + let { centerX: x, centerY: y } = await ContentTask.spawn( + this.browser, + null, + async () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + let { left, top, width, height } = + screenshotsChild.overlay.cancelButton.getBoundingClientRect(); + let centerX = left + width / 2; + let centerY = top + height / 2; + return { centerX, centerY }; + } + ); + + info(`clicking cancel button at ${x}, ${y}`); + mouse.click(x, y); + } + + async clickPreviewCancelButton() { + let { centerX: x, centerY: y } = await ContentTask.spawn( + this.browser, + null, + async () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + let { left, top, width, height } = + screenshotsChild.overlay.previewCancelButton.getBoundingClientRect(); + let centerX = left + width / 2; + let centerY = top + height / 2; + return { centerX, centerY }; + } + ); + + info(`clicking cancel button at ${x}, ${y}`); + mouse.click(x, y); + } + + escapeKeyInContent() { + return SpecialPowers.spawn(this.browser, [], () => { + EventUtils.synthesizeKey("KEY_Escape", {}, content); + }); + } + + getTestPageElementRect(elementId = "testPageElement") { + return ContentTask.spawn(this.browser, [elementId], async id => { + let ele = content.document.getElementById(id); + return ele.getBoundingClientRect(); + }); + } + + async clickTestPageElement(elementId = "testPageElement") { + let rect = await this.getTestPageElementRect(elementId); + let dims = await this.getContentDimensions(); + + let x = Math.floor(rect.x + dims.scrollX + rect.width / 2); + let y = Math.floor(rect.y + dims.scrollY + rect.height / 2); + + mouse.move(x, y); + await this.waitForHoverElementRect(rect.width, rect.height); + mouse.down(x, y); + await this.assertStateChange("draggingReady"); + mouse.up(x, y); + await this.assertStateChange("selected"); + } + + async zoomBrowser(zoom) { + let promise = BrowserTestUtils.waitForContentEvent(this.browser, "resize"); + await SpecialPowers.spawn(this.browser, [zoom], zoomLevel => { + const { Layout } = ChromeUtils.importESModule( + "chrome://mochitests/content/browser/accessible/tests/browser/Layout.sys.mjs" + ); + Layout.zoomDocument(content.document, zoomLevel); + }); + await promise; + } + + /** + * Gets the dialog box + * @returns The dialog box + */ + getDialog() { + let currDialogBox = this.browser.tabDialogBox; + let manager = currDialogBox.getTabDialogManager(); + let dialogs = manager.hasDialogs && manager.dialogs; + return dialogs[0]; + } + + assertPanelVisible() { + info("assertPanelVisible, panel.hidden:" + this.panel?.hidden); + Assert.ok( + BrowserTestUtils.isVisible(this.panel), + "Screenshots panel is visible" + ); + } + + assertPanelNotVisible() { + info("assertPanelNotVisible, panel.hidden:" + this.panel?.hidden); + Assert.ok( + !this.panel || BrowserTestUtils.isHidden(this.panel), + "Screenshots panel is not visible" + ); + } + + /** + * Copied from screenshots extension + * Returns a promise that resolves when the clipboard data has changed + * Otherwise rejects + */ + waitForRawClipboardChange(epectedWidth, expectedHeight) { + const initialClipboardData = Date.now().toString(); + SpecialPowers.clipboardCopyString(initialClipboardData); + + return TestUtils.waitForCondition( + async () => { + let data; + try { + data = await this.getImageSizeAndColorFromClipboard(); + } catch (e) { + console.log("Failed to get image/png clipboard data:", e); + return false; + } + if ( + data && + initialClipboardData !== data && + data.height === expectedHeight && + data.width === epectedWidth + ) { + return data; + } + return false; + }, + "Waiting for screenshot to copy to clipboard", + 200 + ); + } + + /** + * Gets the client and scroll demensions on the window + * @returns { Object } + * clientHeight The visible height + * clientWidth The visible width + * scrollHeight The scrollable height + * scrollWidth The scrollable width + * scrollX The scroll x position + * scrollY The scroll y position + */ + getContentDimensions() { + return SpecialPowers.spawn(this.browser, [], async function () { + let { + innerWidth, + innerHeight, + scrollMaxX, + scrollMaxY, + scrollX, + scrollY, + } = content.window; + let width = innerWidth + scrollMaxX; + let height = innerHeight + scrollMaxY; + + const scrollbarHeight = {}; + const scrollbarWidth = {}; + content.window.windowUtils.getScrollbarSize( + false, + scrollbarWidth, + scrollbarHeight + ); + width -= scrollbarWidth.value; + height -= scrollbarHeight.value; + innerWidth -= scrollbarWidth.value; + innerHeight -= scrollbarHeight.value; + + return { + clientHeight: innerHeight, + clientWidth: innerWidth, + scrollHeight: height, + scrollWidth: width, + scrollX, + scrollY, + }; + }); + } + + async getScreenshotsOverlayDimensions() { + return ContentTask.spawn(this.browser, null, async () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + Assert.ok(screenshotsChild.overlay.initialized, "The overlay exists"); + + let screenshotsContainer = screenshotsChild.overlay.screenshotsContainer; + + await ContentTaskUtils.waitForCondition(() => { + return !screenshotsContainer.hasAttribute("resizing"); + }, "Waiting for overlay to be done resizing"); + + info( + `${screenshotsContainer.style.width} ${ + screenshotsContainer.style.height + } ${screenshotsContainer.hasAttribute("resizing")}` + ); + + return { + scrollWidth: screenshotsContainer.scrollWidth, + scrollHeight: screenshotsContainer.scrollHeight, + }; + }); + } + + async waitForSelectionLayerDimensionChange(oldWidth, oldHeight) { + await ContentTask.spawn( + this.browser, + [oldWidth, oldHeight], + async ([prevWidth, prevHeight]) => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + + await ContentTaskUtils.waitForCondition(() => { + let screenshotsContainer = + screenshotsChild.overlay.screenshotsContainer; + info( + `old height: ${prevHeight}. new height: ${screenshotsContainer.scrollHeight}.\nold width: ${prevWidth}. new width: ${screenshotsContainer.scrollWidth}` + ); + return ( + screenshotsContainer.scrollHeight !== prevHeight && + screenshotsContainer.scrollWidth !== prevWidth + ); + }, "Wait for selection box width change"); + } + ); + } + + waitForOverlaySizeChangeTo(width, height) { + return ContentTask.spawn( + this.browser, + [width, height], + async ([newWidth, newHeight]) => { + await ContentTaskUtils.waitForCondition(() => { + let { + innerHeight, + innerWidth, + scrollMaxY, + scrollMaxX, + scrollMinY, + scrollMinX, + } = content.window; + let scrollWidth = innerWidth + scrollMaxX - scrollMinX; + let scrollHeight = innerHeight + scrollMaxY - scrollMinY; + + const scrollbarHeight = {}; + const scrollbarWidth = {}; + content.window.windowUtils.getScrollbarSize( + false, + scrollbarWidth, + scrollbarHeight + ); + scrollWidth -= scrollbarWidth.value; + scrollHeight -= scrollbarHeight.value; + info( + `${scrollHeight}, ${newHeight}, ${scrollWidth}, ${newWidth}, ${content.window.scrollMaxX}` + ); + return scrollHeight === newHeight && scrollWidth === newWidth; + }, "Wait for document size change"); + } + ); + } + + getSelectionRegionDimensions() { + return ContentTask.spawn(this.browser, null, async () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + Assert.ok(screenshotsChild.overlay.initialized, "The overlay exists"); + + return screenshotsChild.overlay.selectionRegion.dimensions; + }); + } + + /** + * Copied from screenshots extension + * A helper that returns the size of the image that was just put into the clipboard by the + * :screenshot command. + * @return The {width, height, color} dimension and color object. + */ + async getImageSizeAndColorFromClipboard() { + let flavor = "image/png"; + let image = getRawClipboardData(flavor); + if (!image) { + return false; + } + + // Due to the differences in how images could be stored in the clipboard the + // checks below are needed. The clipboard could already provide the image as + // byte streams or as image container. If it's not possible obtain a + // byte stream, the function throws. + + if (image instanceof Ci.imgIContainer) { + image = Cc["@mozilla.org/image/tools;1"] + .getService(Ci.imgITools) + .encodeImage(image, flavor); + } + + if (!(image instanceof Ci.nsIInputStream)) { + throw new Error("Unable to read image data"); + } + + const binaryStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( + Ci.nsIBinaryInputStream + ); + binaryStream.setInputStream(image); + const available = binaryStream.available(); + const buffer = new ArrayBuffer(available); + info( + `${binaryStream.readArrayBuffer( + available, + buffer + )} read, ${available} available` + ); + + // We are going to load the image in the content page to measure its size. + // We don't want to insert the image directly in the browser's document + // which could mess all sorts of things up + return SpecialPowers.spawn( + this.browser, + [buffer], + async function (_buffer) { + const img = content.document.createElement("img"); + const loaded = new Promise(r => { + img.addEventListener("load", r, { once: true }); + }); + const url = content.URL.createObjectURL( + new Blob([_buffer], { type: "image/png" }) + ); + + img.src = url; + content.document.documentElement.appendChild(img); + + info("Waiting for the clipboard image to load in the content page"); + await loaded; + + let canvas = content.document.createElementNS( + "http://www.w3.org/1999/xhtml", + "html:canvas" + ); + let context = canvas.getContext("2d"); + canvas.width = img.width; + canvas.height = img.height; + context.drawImage(img, 0, 0); + let topLeft = context.getImageData(0, 0, 1, 1); + let topRight = context.getImageData(img.width - 1, 0, 1, 1); + let bottomLeft = context.getImageData(0, img.height - 1, 1, 1); + let bottomRight = context.getImageData( + img.width - 1, + img.height - 1, + 1, + 1 + ); + + img.remove(); + content.URL.revokeObjectURL(url); + + return { + width: img.width, + height: img.height, + color: { + topLeft: topLeft.data, + topRight: topRight.data, + bottomLeft: bottomLeft.data, + bottomRight: bottomRight.data, + }, + }; + } + ); + } +} + +/** + * Get the raw clipboard data + * @param flavor Type of data to get from clipboard + * @returns The data from the clipboard + */ +function getRawClipboardData(flavor) { + const whichClipboard = Services.clipboard.kGlobalClipboard; + const xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(null); + xferable.addDataFlavor(flavor); + Services.clipboard.getData( + xferable, + whichClipboard, + SpecialPowers.wrap(window).browsingContext.currentWindowContext + ); + let data = {}; + try { + // xferable.getTransferData(flavor, data); + xferable.getAnyTransferData({}, data); + info(JSON.stringify(data, null, 2)); + } catch (e) { + info(e); + } + data = data.value || null; + return data; +} + +/** + * Synthesize a mouse event on an element, after ensuring that it is visible + * in the viewport. + * + * @param {String} selector: The node selector to get the node target for the event. + * @param {number} x + * @param {number} y + * @param {object} options: Options that will be passed to BrowserTestUtils.synthesizeMouse + */ +async function safeSynthesizeMouseEventInContentPage( + selector, + x, + y, + options = {} +) { + let context = gBrowser.selectedBrowser.browsingContext; + BrowserTestUtils.synthesizeMouse(selector, x, y, options, context); +} + +add_setup(async () => { + CustomizableUI.addWidgetToArea( + "screenshot-button", + CustomizableUI.AREA_NAVBAR, + 0 + ); + let screenshotBtn = document.getElementById("screenshot-button"); + Assert.ok(screenshotBtn, "The screenshots button was added to the nav bar"); +}); + +function getContentDevicePixelRatio(browser) { + return SpecialPowers.spawn(browser, [], async function () { + return content.window.devicePixelRatio; + }); +} + +async function clearAllTelemetryEvents() { + // Clear everything. + info("Clearing all telemetry events"); + await TestUtils.waitForCondition(() => { + Services.telemetry.clearEvents(); + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ); + let content = events.content; + let parent = events.parent; + + return (!content && !parent) || (!content.length && !parent.length); + }); +} + +async function waitForScreenshotsEventCount(count, process = "parent") { + await TestUtils.waitForCondition( + () => { + let events = TelemetryTestUtils.getEvents( + { category: "screenshots" }, + { process } + ); + + info(`Got ${events?.length} event(s)`); + info(`Actual events: ${JSON.stringify(events, null, 2)}`); + return events.length === count ? events : null; + }, + `Waiting for ${count} ${process} event(s).`, + 200, + 100 + ); +} + +async function assertScreenshotsEvents( + expectedEvents, + process = "parent", + clearEvents = true +) { + info(`Expected events: ${JSON.stringify(expectedEvents, null, 2)}`); + // Make sure we have recorded the correct number of events + await waitForScreenshotsEventCount(expectedEvents.length, process); + + TelemetryTestUtils.assertEvents( + expectedEvents, + { category: "screenshots" }, + { clear: clearEvents, process } + ); +} diff --git a/browser/components/screenshots/tests/browser/iframe-test-page.html b/browser/components/screenshots/tests/browser/iframe-test-page.html new file mode 100644 index 0000000000..5439552734 --- /dev/null +++ b/browser/components/screenshots/tests/browser/iframe-test-page.html @@ -0,0 +1,23 @@ +<!DOCTYPE HTML> +<html lang="en"> + <head> + <title>Screenshots</title> + <style> + div { + font-size: 40px; + margin: 30px; + width: 233px; + height: 50px; + color: green; + } + </style> + </head> + <body> + <div>Hello world!</div> + <iframe + width="500" + height="500" + src="https://example.com/browser/browser/components/screenshots/tests/browser/first-iframe.html" + ></iframe> + </body> +</html> diff --git a/browser/components/screenshots/tests/browser/large-test-page.html b/browser/components/screenshots/tests/browser/large-test-page.html new file mode 100644 index 0000000000..ab2eb8d601 --- /dev/null +++ b/browser/components/screenshots/tests/browser/large-test-page.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Screenshots</title> +</head> +<body style="height:40000px; width:40000px; background-repeat: no-repeat; background-size: 40008px 40016px; background-color: rgb(111, 111, 111); background-image:linear-gradient(to right, transparent 50%, rgba(0, 200, 200, 0.5) 50%),linear-gradient(to bottom, transparent 50%, rgba(100, 0, 100, 0.5) 50%);"> +</body> +</html> diff --git a/browser/components/screenshots/tests/browser/second-iframe.html b/browser/components/screenshots/tests/browser/second-iframe.html new file mode 100644 index 0000000000..ca5de26bb9 --- /dev/null +++ b/browser/components/screenshots/tests/browser/second-iframe.html @@ -0,0 +1,18 @@ +<!DOCTYPE HTML> +<html lang="en"> + <head> + <meta charset="utf-8"> + <style> + div { + font-size: 40px; + margin: 30px; + width: 235px; + height: 52px; + color: red; + } + </style> + </head> +<body> + <div>Hello world!</div> +</body> +</html> diff --git a/browser/components/screenshots/tests/browser/short-test-page.html b/browser/components/screenshots/tests/browser/short-test-page.html new file mode 100644 index 0000000000..7718892f8c --- /dev/null +++ b/browser/components/screenshots/tests/browser/short-test-page.html @@ -0,0 +1,8 @@ +<html lang="en"> +<head> + <title>Screenshots</title> +</head> +<body> + <div style="height:500px; width:500px; background-color: blue;"></div> +</body> +</html> diff --git a/browser/components/screenshots/tests/browser/test-page-resize.html b/browser/components/screenshots/tests/browser/test-page-resize.html new file mode 100644 index 0000000000..dea57909b4 --- /dev/null +++ b/browser/components/screenshots/tests/browser/test-page-resize.html @@ -0,0 +1,25 @@ +<html> +<head> + <title>Screenshots</title> +</head> +<body> + <div style="display: flex;flex-flow: row wrap;gap: 8px;"> + <div style="height:100px; width:100px; background-color: blue;"></div> + <div style="height:100px; width:100px; background-color: blue;"></div> + <div style="height:100px; width:100px; background-color: blue;"></div> + <div style="height:100px; width:100px; background-color: blue;"></div> + <div style="height:100px; width:100px; background-color: blue;"></div> + <div style="height:100px; width:100px; background-color: blue;"></div> + </div> + <div style="display: flex;flex-direction: column;gap: 8px;"> + <div style="height:100px; width:100px; background-color: red;"></div> + <div style="height:100px; width:100px; background-color: red;"></div> + <div style="height:100px; width:100px; background-color: red;"></div> + <div style="height:100px; width:100px; background-color: red;"></div> + <div style="height:100px; width:100px; background-color: red;"></div> + <div style="height:100px; width:100px; background-color: red;"></div> + <div style="height:100px; width:100px; background-color: red;"></div> + <div id="hello" style="height:100px; width:100px; background-color: red;"></div> + </div> +</body> +</html> diff --git a/browser/components/screenshots/tests/browser/test-page.html b/browser/components/screenshots/tests/browser/test-page.html new file mode 100644 index 0000000000..5ddc1d6eb6 --- /dev/null +++ b/browser/components/screenshots/tests/browser/test-page.html @@ -0,0 +1,27 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Screenshots</title> +</head> +<body style="height:4000px; width:4000px; background-repeat: no-repeat; background-size: 4008px 4016px; background-color: rgb(111, 111, 111); background-image:linear-gradient(to right, transparent 50%, rgba(0, 200, 200, 0.5) 50%),linear-gradient(to bottom, transparent 50%, rgba(100, 0, 100, 0.5) 50%);"> + <div id="testPageElement" style="position:absolute; top:91px; left:93px; width:92px; height:94px; border:solid red;"></div> + <script> + // Make sure the screenshots overlay anonymous document always receives events + // that web content would normally be able to intercept, as that could break the + // overlay + function disabledEvent(event) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + + window.addEventListener("click", disabledEvent, true); + window.addEventListener("pointerdown", disabledEvent, true); + window.addEventListener("pointermove", disabledEvent, true); + window.addEventListener("mousemove", disabledEvent, true); + window.addEventListener("pointerup", disabledEvent, true); + window.addEventListener("keydown", disabledEvent, true); + window.addEventListener("keyup", disabledEvent, true); + </script> +</body> +</html> |