From 36d22d82aa202bb199967e9512281e9a53db42c9 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 21:33:14 +0200 Subject: Adding upstream version 115.7.0esr. Signed-off-by: Daniel Baumann --- .../screenshots/ScreenshotsOverlayChild.sys.mjs | 2148 ++++++++++++++++++++ .../screenshots/ScreenshotsUtils.sys.mjs | 578 ++++++ browser/components/screenshots/content/cancel.svg | 4 + .../screenshots/content/copied-notification.svg | 4 + browser/components/screenshots/content/copy.svg | 4 + .../screenshots/content/download-white.svg | 4 + .../components/screenshots/content/download.svg | 4 + .../content/icon-welcome-face-without-eyes.svg | 4 + .../screenshots/content/menu-fullpage.svg | 4 + .../screenshots/content/menu-visible.svg | 4 + .../components/screenshots/content/screenshots.css | 68 + .../screenshots/content/screenshots.html | 68 + .../components/screenshots/content/screenshots.js | 101 + browser/components/screenshots/fileHelpers.mjs | 271 +++ browser/components/screenshots/jar.mn | 23 + browser/components/screenshots/moz.build | 19 + browser/components/screenshots/overlay/overlay.css | 481 +++++ .../components/screenshots/screenshots-buttons.css | 27 + .../components/screenshots/screenshots-buttons.js | 60 + .../screenshots/tests/browser/browser.ini | 29 + .../browser_screenshots_drag_scroll_test.js | 400 ++++ .../tests/browser/browser_screenshots_drag_test.js | 475 +++++ .../browser/browser_screenshots_focus_test.js | 44 + .../browser_screenshots_overlay_panel_sync.js | 82 + .../browser/browser_screenshots_page_unload.js | 42 + .../browser/browser_screenshots_short_page_test.js | 148 ++ .../browser/browser_screenshots_telemetry_tests.js | 309 +++ .../browser/browser_screenshots_test_downloads.js | 185 ++ .../browser/browser_screenshots_test_escape.js | 35 + .../browser/browser_screenshots_test_full_page.js | 173 ++ .../browser/browser_screenshots_test_page_crash.js | 54 + .../browser_screenshots_test_screenshot_too_big.js | 86 + .../browser_screenshots_test_toggle_pref.js | 287 +++ .../browser_screenshots_test_toolbar_button.js | 26 + .../browser/browser_screenshots_test_visible.js | 345 ++++ .../components/screenshots/tests/browser/head.js | 681 +++++++ .../screenshots/tests/browser/large-test-page.html | 9 + .../screenshots/tests/browser/short-test-page.html | 8 + .../screenshots/tests/browser/test-page.html | 10 + 39 files changed, 7304 insertions(+) create mode 100644 browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs create mode 100644 browser/components/screenshots/ScreenshotsUtils.sys.mjs create mode 100644 browser/components/screenshots/content/cancel.svg create mode 100644 browser/components/screenshots/content/copied-notification.svg create mode 100644 browser/components/screenshots/content/copy.svg create mode 100644 browser/components/screenshots/content/download-white.svg create mode 100644 browser/components/screenshots/content/download.svg create mode 100644 browser/components/screenshots/content/icon-welcome-face-without-eyes.svg create mode 100644 browser/components/screenshots/content/menu-fullpage.svg create mode 100644 browser/components/screenshots/content/menu-visible.svg create mode 100644 browser/components/screenshots/content/screenshots.css create mode 100644 browser/components/screenshots/content/screenshots.html create mode 100644 browser/components/screenshots/content/screenshots.js create mode 100644 browser/components/screenshots/fileHelpers.mjs create mode 100644 browser/components/screenshots/jar.mn create mode 100644 browser/components/screenshots/moz.build create mode 100644 browser/components/screenshots/overlay/overlay.css create mode 100644 browser/components/screenshots/screenshots-buttons.css create mode 100644 browser/components/screenshots/screenshots-buttons.js create mode 100644 browser/components/screenshots/tests/browser/browser.ini create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_drag_test.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_focus_test.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_overlay_panel_sync.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_page_unload.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_short_page_test.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_telemetry_tests.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_test_escape.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_test_page_crash.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_test_screenshot_too_big.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_test_toolbar_button.js create mode 100644 browser/components/screenshots/tests/browser/browser_screenshots_test_visible.js create mode 100644 browser/components/screenshots/tests/browser/head.js create mode 100644 browser/components/screenshots/tests/browser/large-test-page.html create mode 100644 browser/components/screenshots/tests/browser/short-test-page.html create mode 100644 browser/components/screenshots/tests/browser/test-page.html (limited to 'browser/components/screenshots') diff --git a/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs b/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs new file mode 100644 index 0000000000..db2c24f3dc --- /dev/null +++ b/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs @@ -0,0 +1,2148 @@ +/* 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 + * canvasFrame anonymous content container (see dom/webidl/Document.webidl). + * + * This container gets cleared automatically when the document navigates. + * + * Since the overlay markup is inserted in the canvasFrame using + * insertAnonymousContent, this means that it can be modified using the API + * described in AnonymousContent.webidl. + * + * Any mutation of this content must be via the AnonymousContent API. + * This is similar in design to [devtools' highlighters](https://firefox-source-docs.mozilla.org/devtools/tools/highlighters.html#inserting-content-in-the-page), + * though as Screenshots doesnt need to work on XUL documents, or allow multiple kinds of + * highlight/overlay our case is a little simpler. + * + * To retrieve the AnonymousContent instance, use the `content` getter. + */ + +/* 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 + + A pointerdown goes from crosshairs to dragging. + A pointerup goes from dragging to selected + A click outside of the selection goes from selected to crosshairs + A pointerdown on one of the draggers goes from selected to resizing + + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "overlayLocalization", () => { + return new Localization(["browser/screenshotsOverlay.ftl"], true); +}); + +const STYLESHEET_URL = + "chrome://browser/content/screenshots/overlay/overlay.css"; + +// 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 REGION_CHANGE_THRESHOLD = 5; +const SCROLL_BY_EDGE = 20; + +const doNotAutoselectTags = { + H1: true, + H2: true, + H3: true, + H4: true, + H5: true, + H6: true, +}; + +class AnonymousContentOverlay { + constructor(contentDocument, screenshotsChild) { + this.listeners = new Map(); + this.elements = new Map(); + + this.screenshotsChild = screenshotsChild; + + this.contentDocument = contentDocument; + // aliased for easier diffs/maintenance of the event management code borrowed from devtools highlighters + this.pageListenerTarget = contentDocument.ownerGlobal; + + this.overlayFragment = null; + + this.overlayId = "screenshots-overlay-container"; + this.previewId = "preview-container"; + this.selectionId = "selection-container"; + this.hoverBoxId = "hover-highlight"; + + this._initialized = false; + + this.moverIds = [ + "mover-left", + "mover-top", + "mover-right", + "mover-bottom", + "mover-topLeft", + "mover-topRight", + "mover-bottomLeft", + "mover-bottomRight", + ]; + } + get content() { + if (!this._content || Cu.isDeadWrapper(this._content)) { + return null; + } + return this._content; + } + async initialize() { + if (this._initialized) { + return; + } + + let document = this.contentDocument; + let window = document.ownerGlobal; + + // Inject stylesheet + if (!this.overlayFragment) { + try { + window.windowUtils.loadSheetUsingURIString( + STYLESHEET_URL, + window.windowUtils.AGENT_SHEET + ); + } catch { + // The method fails if the url is already loaded. + } + // Inject markup for the overlay UI + this.overlayFragment = this.buildOverlay(); + } + + this._content = document.insertAnonymousContent( + this.overlayFragment.children[0] + ); + + this.addEventListeners(); + + const hoverElementBox = new HoverElementBox( + this.hoverBoxId, + this.content, + document + ); + + const previewLayer = new PreviewLayer(this.previewId, this.content); + const selectionLayer = new SelectionLayer( + this.selectionId, + this.content, + hoverElementBox + ); + + this.screenshotsContainer = new ScreenshotsContainerLayer( + this.overlayId, + this.content, + previewLayer, + selectionLayer + ); + + this.stateHandler = new StateHandler( + this.screenshotsContainer, + this.screenshotsChild + ); + + this.screenshotsContainer.updateSize(window); + + this.stateHandler.setState("crosshairs"); + + this._initialized = true; + } + + /** + * The Anonymous Content doesn't shrink when the window is resized so we need + * to find the largest element that isn't the Anonymous Content and we will + * use that width and height. + * Otherwise we will fallback to the documentElement scroll width and height + * @param eventType If "resize", we called this from a resize event so we will + * try shifting the SelectionBox. + * If "scroll", we called this from a scroll event so we will redraw the buttons + */ + updateScreenshotsSize(eventType) { + this.stateHandler.updateScreenshotsContainerSize( + this.contentDocument.ownerGlobal, + eventType + ); + } + + /** + * Add required event listeners to the overlay + */ + addEventListeners() { + let cancelScreenshotsFunciton = () => { + this.screenshotsChild.requestCancelScreenshot("overlay_cancel"); + }; + this.addEventListenerForElement( + "screenshots-cancel-button", + "click", + cancelScreenshotsFunciton + ); + this.addEventListenerForElement( + "cancel", + "click", + cancelScreenshotsFunciton + ); + this.addEventListenerForElement("copy", "click", (event, targetId) => { + this.screenshotsChild.requestCopyScreenshot( + this.screenshotsContainer.getSelectionLayerBoxDimensions() + ); + }); + this.addEventListenerForElement("download", "click", (event, targetId) => { + this.screenshotsChild.requestDownloadScreenshot( + this.screenshotsContainer.getSelectionLayerBoxDimensions() + ); + }); + + // The pointerdown event is added to the selection buttons to prevent the + // pointerdown event from occurring on the "screenshots-overlay-container" + this.addEventListenerForElement( + "cancel", + "pointerdown", + (event, targetId) => { + event.stopPropagation(); + } + ); + this.addEventListenerForElement( + "copy", + "pointerdown", + (event, targetId) => { + event.stopPropagation(); + } + ); + this.addEventListenerForElement( + "download", + "pointerdown", + (event, targetId) => { + event.stopPropagation(); + } + ); + + this.addEventListenerForElement( + this.overlayId, + "pointerdown", + (event, targetId) => { + this.dragStart(event, targetId); + } + ); + this.addEventListenerForElement( + this.overlayId, + "pointerup", + (event, targetId) => { + this.dragEnd(event, targetId); + } + ); + this.addEventListenerForElement( + this.overlayId, + "pointermove", + (event, targetId) => { + this.drag(event, targetId); + } + ); + + for (let id of this.moverIds.concat(["highlight"])) { + this.addEventListenerForElement(id, "pointerdown", (event, targetId) => { + this.dragStart(event, targetId); + }); + this.addEventListenerForElement(id, "pointerup", (event, targetId) => { + this.dragEnd(event, targetId); + }); + this.addEventListenerForElement(id, "pointermove", (event, targetId) => { + this.drag(event, targetId); + }); + } + } + + /** + * Removes all event listeners and removes the overlay from the Anonymous Content + */ + tearDown() { + if (this._content) { + this._removeAllListeners(); + try { + this.contentDocument.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; + } + + /** + * Creates the document fragment that will be added to the Anonymous Content + * @returns document fragment that can be injected into the Anonymous Content + */ + buildOverlay() { + 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" }, + ]); + + const htmlString = ` +
+
+
+
+
+
+
+
+
+
${instructions.value}
+ +
+
+
+ +
+
`; + + const parser = new this.contentDocument.ownerGlobal.DOMParser(); + const tmpDoc = parser.parseFromSafeString(htmlString, "text/html"); + const fragment = this.contentDocument.createDocumentFragment(); + + fragment.appendChild(tmpDoc.body.children[0]); + return fragment; + } + + // The event tooling is borrowed directly from devtools' highlighters (CanvasFrameAnonymousContentHelper) + /** + * Add an event listener to one of the elements inserted in the canvasFrame + * native anonymous container. + * Like other methods in this helper, this requires the ID of the element to + * be passed in. + * + * Note that if the content page navigates, the event listeners won't be + * added again. + * + * Also note that unlike traditional DOM events, the events handled by + * listeners added here will propagate through the document only through + * bubbling phase, so the useCapture parameter isn't supported. + * It is possible however to call e.stopPropagation() to stop the bubbling. + * + * IMPORTANT: the chrome-only canvasFrame insertion API takes great care of + * not leaking references to inserted elements to chrome JS code. That's + * because otherwise, chrome JS code could freely modify native anon elements + * inside the canvasFrame and probably change things that are assumed not to + * change by the C++ code managing this frame. + * See https://wiki.mozilla.org/DevTools/Highlighter#The_AnonymousContent_API + * Unfortunately, the inserted nodes are still available via + * event.originalTarget, and that's what the event handler here uses to check + * that the event actually occured on the right element, but that also means + * consumers of this code would be able to access the inserted elements. + * Therefore, the originalTarget property will be nullified before the event + * is passed to your handler. + * + * IMPL DETAIL: A single event listener is added per event types only, at + * browser level and if the event originalTarget is found to have the provided + * ID, the callback is executed (and then IDs of parent nodes of the + * originalTarget are checked too). + * + * @param {String} id + * @param {String} type + * @param {Function} handler + */ + addEventListenerForElement(id, type, handler) { + if (typeof id !== "string") { + throw new Error( + "Expected a string ID in addEventListenerForElement but got: " + id + ); + } + + // If no one is listening for this type of event yet, add one listener. + if (!this.listeners.has(type)) { + const target = this.pageListenerTarget; + target.addEventListener(type, this, true); + // Each type entry in the map is a map of ids:handlers. + this.listeners.set(type, new Map()); + } + + const listeners = this.listeners.get(type); + listeners.set(id, handler); + } + + /** + * Remove an event listener from one of the elements inserted in the + * canvasFrame native anonymous container. + * @param {String} id + * @param {String} type + */ + removeEventListenerForElement(id, type) { + const listeners = this.listeners.get(type); + if (!listeners) { + return; + } + listeners.delete(id); + + // If no one is listening for event type anymore, remove the listener. + if (!listeners.size) { + const target = this.pageListenerTarget; + target.removeEventListener(type, this, true); + } + } + + handleEvent(event) { + const listeners = this.listeners.get(event.type); + if (!listeners) { + return; + } + + // Hide the originalTarget property to avoid exposing references to native + // anonymous elements. See addEventListenerForElement's comment. + let isPropagationStopped = false; + const eventProxy = new Proxy(event, { + get: (obj, name) => { + if (name === "originalTarget") { + return null; + } else if (name === "stopPropagation") { + return () => { + isPropagationStopped = true; + }; + } + return obj[name]; + }, + }); + + // Start at originalTarget, bubble through ancestors and call handlers when + // needed. + let node = event.originalTarget; + while (node) { + let nodeId = node.id; + if (nodeId) { + const handler = listeners.get(node.id); + if (handler) { + handler(eventProxy, nodeId); + if (isPropagationStopped) { + break; + } + } + if (nodeId == this.overlayId) { + break; + } + } + node = node.parentNode; + } + } + + _removeAllListeners() { + if (this.pageListenerTarget) { + const target = this.pageListenerTarget; + for (const [type] of this.listeners) { + target.removeEventListener(type, this, true); + } + } + this.listeners.clear(); + } + + /** + * Pass the pointer down event to the state handler + * @param event The pointer down event + * @param targetId The target element id + */ + dragStart(event, targetId) { + this.stateHandler.dragStart(event, targetId); + } + + /** + * Pass the pointer move event to the state handler + * @param event The pointer move event + * @param targetId The target element id + */ + drag(event, targetId) { + this.stateHandler.drag(event, targetId); + } + + /** + * Pass the pointer up event to the state handler + * @param event The pointer up event + * @param targetId The target element id + */ + dragEnd(event, targetId) { + this.stateHandler.dragEnd(event); + } +} + +export var ScreenshotsOverlayChild = { + AnonymousContentOverlay, +}; + +/** + * The StateHandler class handles the state of the overlay + */ +class StateHandler { + #state; + #lastBox; + #moverId; + #lastX; + #lastY; + #screenshotsContainer; + #screenshotsChild; + #previousDimensions; + + constructor(screenshotsContainer, screenshotsChild) { + this.#state = "crosshairs"; + this.#lastBox = {}; + + this.#screenshotsContainer = screenshotsContainer; + this.#screenshotsChild = screenshotsChild; + } + + setState(newState) { + if (this.#state === "selected" && newState === "crosshairs") { + this.#screenshotsChild.recordTelemetryEvent( + "started", + "overlay_retry", + {} + ); + } + this.#state = newState; + this.start(); + } + + getState() { + return this.#state; + } + + getHoverElementBoxRect() { + return this.#screenshotsContainer.hoverElementBoxRect; + } + + /** + * At the start of the some states we need to perform some actions + */ + start() { + switch (this.#state) { + case "crosshairs": { + this.crosshairsStart(); + break; + } + case "draggingReady": { + this.draggingReadyStart(); + break; + } + case "dragging": { + this.draggingStart(); + break; + } + case "selected": { + this.selectedStart(); + break; + } + case "resizing": { + this.resizingStart(); + break; + } + } + } + + /** + * Returns the x and y coordinates of the event + * @param event The mouse or touch event + * @returns object containing the x and y coordinates of the mouse + */ + getCoordinates(event) { + const { clientX, clientY, pageX, pageY } = event; + + MAX_DETECT_HEIGHT = Math.max(event.target.clientHeight + 100, 700); + MAX_DETECT_WIDTH = Math.max(event.target.clientWidth + 100, 1000); + + return { clientX, clientY, pageX, pageY }; + } + + /** + * Handles the mousedown/touchstart event depending on the state + * @param event The mousedown or touchstart event + * @param targetId The id of the event target + */ + dragStart(event, targetId) { + const { pageX, pageY } = this.getCoordinates(event); + + switch (this.#state) { + case "crosshairs": { + this.crosshairsDragStart(pageX, pageY); + break; + } + case "selected": { + this.selectedDragStart(pageX, pageY, targetId); + break; + } + } + } + + /** + * Handles the move event depending on the state + * @param event The mousemove or touchmove event + * @param targetId The id of the event target + */ + drag(event, targetId) { + const { pageX, pageY, clientX, clientY } = this.getCoordinates(event); + + switch (this.#state) { + case "crosshairs": { + this.crosshairsMove(clientX, clientY, targetId); + break; + } + case "draggingReady": { + this.draggingReadyDrag(pageX, pageY); + break; + } + case "dragging": { + this.draggingDrag(pageX, pageY); + break; + } + case "resizing": { + this.resizingDrag(pageX, pageY); + break; + } + } + } + + /** + * Handles the move event depending on the state + * @param event The mouseup event + * @param targetId The id of the event target + */ + dragEnd(event, targetId) { + const { pageX, pageY, clientX, clientY } = this.getCoordinates(event); + + switch (this.#state) { + case "draggingReady": { + this.draggingReadyDragEnd(pageX - clientX, pageY - clientY); + break; + } + case "dragging": { + this.draggingDragEnd(pageX, pageY, targetId); + break; + } + case "resizing": { + this.resizingDragEnd(pageX, pageY, targetId); + break; + } + } + } + + /** + * Hide the box and highlighter and show the overlay at the start of crosshairs state + */ + crosshairsStart() { + this.#screenshotsContainer.hideSelectionLayer(); + this.#screenshotsContainer.showPreviewLayer(); + this.#screenshotsChild.showPanel(); + this.#previousDimensions = null; + } + + /** + * + */ + draggingReadyStart() { + this.#screenshotsChild.hidePanel(); + } + + /** + * Hide the overlay and draw the box at the start of dragging state + */ + draggingStart() { + this.#screenshotsContainer.hidePreviewLayer(); + this.#screenshotsContainer.hideButtonsLayer(); + this.#screenshotsContainer.drawSelectionBox(); + } + + /** + * Show the buttons at the start of the selected state + */ + selectedStart() { + this.#screenshotsContainer.drawButtonsLayer(); + } + + /** + * Hide the buttons and store width and height of box at the start of the resizing state + */ + resizingStart() { + this.#screenshotsContainer.hideButtonsLayer(); + let { width, height } = + this.#screenshotsContainer.getSelectionLayerBoxDimensions(); + this.#lastBox = { + width, + height, + }; + } + + /** + * Set the initial box coordinates and set the state to "draggingReady" + * @param pageX x coordinate + * @param pageY y coordinate + */ + crosshairsDragStart(pageX, pageY) { + this.#screenshotsContainer.setSelectionBoxDimensions({ + left: pageX, + top: pageY, + right: pageX, + bottom: pageY, + }); + + this.setState("draggingReady"); + } + + /** + * If the background is clicked we set the state to crosshairs + * otherwise set the state to resizing + * @param pageX x coordinate + * @param pageY y coordinate + * @param targetId The id of the event target + */ + selectedDragStart(pageX, pageY, targetId) { + if (targetId === this.#screenshotsContainer.id) { + this.setState("crosshairs"); + return; + } + this.#moverId = targetId; + this.#lastX = pageX; + this.#lastY = pageY; + + this.setState("resizing"); + } + + /** + * Handles the pointer move for the crosshairs state + * @param clientX x pointer position in the visible window + * @param clientY y pointer position in the visible window + * @param targetId The id of the target element + */ + crosshairsMove(clientX, clientY, targetId) { + this.#screenshotsContainer.drawPreviewEyes(clientX, clientY); + + this.#screenshotsContainer.handleElementHover(clientX, clientY, targetId); + } + + /** + * Set the bottom and right coordinates of the box and draw the box + * @param pageX x coordinate + * @param pageY y coordinate + */ + draggingDrag(pageX, pageY) { + this.scrollIfByEdge(pageX, pageY); + this.#screenshotsContainer.setSelectionBoxDimensions({ + right: pageX, + bottom: pageY, + }); + + this.#screenshotsContainer.drawSelectionBox(); + } + + /** + * If the mouse has moved at least 40 pixels then set the state to "dragging" + * @param pageX x coordinate + * @param pageY y coordinate + */ + draggingReadyDrag(pageX, pageY) { + this.#screenshotsContainer.setSelectionBoxDimensions({ + right: pageX, + bottom: pageY, + }); + + if (this.#screenshotsContainer.selectionBoxDistance() > 40) { + this.setState("dragging"); + } + } + + /** + * Depending on what mover was selected we will resize the box accordingly + * @param pageX x coordinate + * @param pageY y coordinate + */ + resizingDrag(pageX, pageY) { + this.scrollIfByEdge(pageX, pageY); + switch (this.#moverId) { + case "mover-topLeft": { + this.#screenshotsContainer.setSelectionBoxDimensions({ + left: pageX, + top: pageY, + }); + break; + } + case "mover-top": { + this.#screenshotsContainer.setSelectionBoxDimensions({ top: pageY }); + break; + } + case "mover-topRight": { + this.#screenshotsContainer.setSelectionBoxDimensions({ + top: pageY, + right: pageX, + }); + break; + } + case "mover-right": { + this.#screenshotsContainer.setSelectionBoxDimensions({ + right: pageX, + }); + break; + } + case "mover-bottomRight": { + this.#screenshotsContainer.setSelectionBoxDimensions({ + right: pageX, + bottom: pageY, + }); + break; + } + case "mover-bottom": { + this.#screenshotsContainer.setSelectionBoxDimensions({ + bottom: pageY, + }); + break; + } + case "mover-bottomLeft": { + this.#screenshotsContainer.setSelectionBoxDimensions({ + left: pageX, + bottom: pageY, + }); + break; + } + case "mover-left": { + this.#screenshotsContainer.setSelectionBoxDimensions({ left: pageX }); + break; + } + case "highlight": { + let lastBox = this.#lastBox; + let diffX = this.#lastX - pageX; + let diffY = this.#lastY - pageY; + + let newLeft; + let newRight; + let newTop; + let newBottom; + + // Unpack SelectionBox dimensions to use here + let { + boxLeft, + boxTop, + boxRight, + boxBottom, + boxWidth, + boxHeight, + scrollWidth, + scrollHeight, + } = this.#screenshotsContainer.getSelectionLayerDimensions(); + + // wait until all 4 if elses have completed before setting box dimensions + if (boxWidth <= lastBox.width && boxLeft === 0) { + newLeft = boxRight - lastBox.width; + } else { + newLeft = boxLeft; + } + + if (boxWidth <= lastBox.width && boxRight === scrollWidth) { + newRight = boxLeft + lastBox.width; + } else { + newRight = boxRight; + } + + if (boxHeight <= lastBox.height && boxTop === 0) { + newTop = boxBottom - lastBox.height; + } else { + newTop = boxTop; + } + + if (boxHeight <= lastBox.height && boxBottom === scrollHeight) { + newBottom = boxTop + lastBox.height; + } else { + newBottom = boxBottom; + } + + this.#screenshotsContainer.setSelectionBoxDimensions({ + left: newLeft - diffX, + top: newTop - diffY, + right: newRight - diffX, + bottom: newBottom - diffY, + }); + + this.#lastX = pageX; + this.#lastY = pageY; + break; + } + } + this.#screenshotsContainer.drawSelectionBox(); + } + + /** + * Draw the selection box from the hover element box if it exists + * Else set the state to "crosshairs" + */ + draggingReadyDragEnd(scrollX, scrollY) { + if (this.#screenshotsContainer.hoverElementBoxRect) { + this.#screenshotsContainer.hidePreviewLayer(); + this.#screenshotsContainer.updateSelectionBoxFromRect(scrollX, scrollY); + this.#screenshotsContainer.drawSelectionBox(); + this.setState("selected"); + this.#screenshotsChild.recordTelemetryEvent("selected", "element", {}); + } else { + this.setState("crosshairs"); + } + } + + /** + * Draw the box one last time and set the state to "selected" + * @param pageX x coordinate + * @param pageY y coordinate + */ + draggingDragEnd(pageX, pageY) { + this.#screenshotsContainer.setSelectionBoxDimensions({ + right: pageX, + bottom: pageY, + }); + this.#screenshotsContainer.sortSelectionLayerBoxCoords(); + this.setState("selected"); + + let { width, height } = + this.#screenshotsContainer.getSelectionLayerBoxDimensions(); + + if ( + !this.#previousDimensions || + (Math.abs(this.#previousDimensions.width - width) > + REGION_CHANGE_THRESHOLD && + Math.abs(this.#previousDimensions.height - height) > + REGION_CHANGE_THRESHOLD) + ) { + this.#screenshotsChild.recordTelemetryEvent( + "selected", + "region_selection", + {} + ); + } + this.#previousDimensions = { width, height }; + } + + /** + * Draw the box one last time and set the state to "selected" + * @param pageX x coordinate + * @param pageY y coordinate + */ + resizingDragEnd(pageX, pageY, targetId) { + this.resizingDrag(pageX, pageY, targetId); + this.#screenshotsContainer.sortSelectionLayerBoxCoords(); + this.setState("selected"); + } + + /** + * The page was resized or scrolled. We need to update the + * ScreenshotsContainer size so we don't draw outside the window bounds + * If the current state is "selected" and this was called from a resize event + * then we need to maybe shift the SelectionBox + * @param win The window object of the page + * @param eventType If this was called from a resize event + */ + updateScreenshotsContainerSize(win, eventType) { + if (this.#state === "crosshairs" && eventType === "resize") { + this.#screenshotsContainer.hideHoverElementBox(); + } + + this.#screenshotsContainer.updateSize(win); + + if (this.#state === "selected" && eventType === "resize") { + this.#screenshotsContainer.shiftSelectionLayerBox(); + } else if ( + this.#state !== "resizing" && + this.#state !== "dragging" && + eventType === "scroll" + ) { + this.#screenshotsContainer.drawButtonsLayer(); + if (this.#state === "crosshairs") { + this.#screenshotsContainer.handleElementScroll(); + } + } + } + + scrollIfByEdge(pageX, pageY) { + let dimensions = this.#screenshotsContainer.getSelectionLayerDimensions(); + + if (pageY - dimensions.scrollY <= SCROLL_BY_EDGE) { + // Scroll up + this.#screenshotsChild.scrollWindow(0, -SCROLL_BY_EDGE); + } else if ( + dimensions.scrollY + dimensions.clientHeight - pageY <= + SCROLL_BY_EDGE + ) { + // Scroll down + this.#screenshotsChild.scrollWindow(0, SCROLL_BY_EDGE); + } + + if (pageX - dimensions.scrollX <= SCROLL_BY_EDGE) { + // Scroll left + this.#screenshotsChild.scrollWindow(-SCROLL_BY_EDGE, 0); + } else if ( + dimensions.scrollX + dimensions.clientWidth - pageX <= + SCROLL_BY_EDGE + ) { + // Scroll right + this.#screenshotsChild.scrollWindow(SCROLL_BY_EDGE, 0); + } + } +} + +class AnonLayer { + id; + content; + + constructor(id, content) { + this.id = id; + this.content = content; + } + + /** + * Show element with id this.id + */ + show() { + this.content.removeAttributeForElement(this.id, "style"); + } + + /** + * Hide element with id this.id + */ + hide() { + this.content.setAttributeForElement(this.id, "style", "display:none;"); + } +} + +class HoverElementBox extends AnonLayer { + #document; + #rect; + #lastX; + #lastY; + + constructor(id, content, document) { + super(id, content); + + this.#document = document; + } + + get rect() { + return this.#rect; + } + + /** + * Draws the hover box over an element from the given rect + * @param rect The rect to draw the hover element box + */ + drawHoverBox(rect) { + if (!rect) { + this.hide(); + } else { + let maxHeight = this.selectionLayer.scrollHeight; + let maxWidth = this.selectionLayer.scrollWidth; + let top = this.#document.documentElement.scrollTop + rect.top; + top = top > 0 ? top : 0; + let left = this.#document.documentElement.scrollLeft + rect.left; + left = left > 0 ? left : 0; + let height = + rect.top + rect.height > maxHeight ? maxHeight - rect.top : rect.height; + let width = + rect.left + rect.width > maxWidth ? maxWidth - rect.left : rect.width; + + this.content.setAttributeForElement( + this.id, + "style", + `top:${top}px;left:${left}px;height:${height}px;width:${width}px;` + ); + } + } + + /** + * Handles when the user moves the mouse over an element + * @param clientX The x coordinate in the visible window + * @param clientY The y coordinate in the visible window + * @param targetId The target element id + */ + handleElementHover(clientX, clientY, targetId) { + if (targetId === "screenshots-overlay-container") { + let ele = this.getElementFromPoint(clientX, clientY); + + if (this.cachedEle && this.cachedEle === ele) { + // Still hovering over the same element + return; + } + this.cachedEle = ele; + + this.getBestRectForElement(ele); + + this.#lastX = clientX; + this.#lastY = clientY; + } + } + + /** + * Handles moving the rect when the user has scrolled but not moved the mouse + * It uses the last x and y coordinates to find the new element at the mouse position + */ + handleElementScroll() { + if (this.#lastX && this.#lastY) { + this.cachedEle = null; + this.handleElementHover( + this.#lastX, + this.#lastY, + "screenshots-overlay-container" + ); + } + } + + /** + * Finds an element for the given coordinates within the viewport + * @param x The x coordinate in the visible window + * @param y The y coordinate in the visible window + * @returns An element location at the given coordinates + */ + getElementFromPoint(x, y) { + this.setPointerEventsNone(); + let ele; + try { + ele = this.#document.elementFromPoint(x, y); + } finally { + this.resetPointerEvents(); + } + + return ele; + } + + /** + * 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 + */ + getBoundingClientRect(ele) { + if (!ele.getBoundingClientRect) { + return null; + } + + return ele.getBoundingClientRect(); + } + + /** + * This function takes an element and finds a suitable rect to draw the hover box on + * @param ele The element to find a suitale rect of + */ + getBestRectForElement(ele) { + let lastRect; + let lastNode; + let rect; + let attemptExtend = false; + let node = ele; + while (node) { + rect = this.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 = this.evenBetterElement(node); + if (evenBetter) { + node = lastNode = evenBetter; + rect = this.getBoundingClientRect(evenBetter); + attemptExtend = false; + } + } + if (rect && attemptExtend) { + let extendNode = lastNode.nextSibling; + while (extendNode) { + if (extendNode.nodeType === this.#document.ELEMENT_NODE) { + break; + } + extendNode = extendNode.nextSibling; + if (!extendNode) { + const parent = lastNode.parentNode; + for (let i = 0; i < parent.childNodes.length; i++) { + if (parent.childNodes[i] === lastNode) { + extendNode = parent.childNodes[i + 1]; + } + } + } + } + if (extendNode) { + const extendRect = this.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; + } + + if (!rect) { + this.hide(); + } else { + this.drawHoverBox(rect); + } + + this.#rect = rect; + } + + /** + * This finds a better element by looking for elements with role article + * @param node The currently hovered node + * @returns A better node or null + */ + evenBetterElement(node) { + let el = node.parentNode; + const ELEMENT_NODE = this.#document.ELEMENT_NODE; + while (el && el.nodeType === ELEMENT_NODE) { + if (!el.getAttribute) { + return null; + } + if (el.getAttribute("role") === "article") { + const rect = this.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; + } + + /** + * The pointer events need to be removed temporarily so we can find the + * correct element from document.elementFromPoint() + * If the pointer events are on for the screenshots elements, then we will always + * get the screenshots elements as the elements from a given point + */ + setPointerEventsNone() { + this.content.setAttributeForElement( + "screenshots-component", + "style", + "pointer-events:none;" + ); + + let temp = this.content.getAttributeForElement( + "screenshots-overlay-container", + "style" + ); + this.content.setAttributeForElement( + "screenshots-overlay-container", + "style", + temp + "pointer-events:none;" + ); + } + + /** + * Return the pointer events to the original state because we found the element + */ + resetPointerEvents() { + this.content.setAttributeForElement("screenshots-component", "style", ""); + + let temp = this.content.getAttributeForElement( + "screenshots-overlay-container", + "style" + ); + this.content.setAttributeForElement( + "screenshots-overlay-container", + "style", + temp.replace("pointer-events:none;", "") + ); + } +} + +class SelectionLayer extends AnonLayer { + #selectionBox; + #hoverElementBox; + #buttons; + #hidden; + /** + * the documentDimensions follows the below structure + * { + * scrollWidth: the total document width + * scrollHeight: the total document height + * scrollX: the x scrolled offset + * scrollY: the y scrolled offset + * clientWidth: the viewport width + * clientHeight: the viewport height + * } + */ + #documentDimensions; + + constructor(id, content, hoverElementBox) { + super(id, content); + this.#selectionBox = new SelectionBox(content, this); + this.#buttons = new ButtonsLayer("buttons", content, this); + this.#hoverElementBox = hoverElementBox; + this.#hoverElementBox.selectionLayer = this; + + this.#hidden = true; + this.#documentDimensions = {}; + } + + /** + * Hide the buttons layer + */ + hideButtons() { + this.#buttons.hide(); + } + + /** + * Call + */ + drawButtonsLayer() { + this.#buttons.show(); + } + + /** + * Hide the selection-container element + */ + hide() { + super.hide(); + this.#hidden = true; + } + + /** + * Draw the SelectionBox + */ + drawSelectionBox() { + if (this.#hidden) { + this.show(); + this.#hidden = false; + } + this.#selectionBox.show(); + } + + /** + * Sort the SelectionBox coordinates + */ + sortSelectionBoxCoords() { + this.#selectionBox.sortCoords(); + } + + /** + * Sets the SelectionBox dimensions + * @param {Object} dims The new box 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 + * } + */ + setSelectionBoxDimensions(dims) { + if (dims.left) { + this.#selectionBox.left = dims.left; + } + if (dims.top) { + this.#selectionBox.top = dims.top; + } + if (dims.right) { + this.#selectionBox.right = dims.right; + } + if (dims.bottom) { + this.#selectionBox.bottom = dims.bottom; + } + } + + /** + * Gets the selections box dimensions + * @returns {Object} + * { + * x1: the left dimension value + * y1: the top dimension value + * width: the width of the selected region + * height: the height of the selected region + * } + */ + getSelectionBoxDimensions() { + return this.#selectionBox.getDimensions(); + } + + /** + * Returns the box dimensions and the page dimensions + * @returns {Object} + * { + * boxLeft: the left position of the box + * boxTop: the top position of the box + * boxRight: the right position of the box + * boxBottom: the bottom position of the box + * scrollWidth: the total document width + * scrollHeight: the total document height + * scrollX: the x scrolled offset + * scrollY: the y scrolled offset + * clientWidth: the viewport width + * clientHeight: the viewport height + * } + */ + getDimensions() { + return { + boxLeft: this.#selectionBox.left, + boxTop: this.#selectionBox.top, + boxRight: this.#selectionBox.right, + boxBottom: this.#selectionBox.bottom, + boxWidth: this.#selectionBox.width, + boxHeight: this.#selectionBox.height, + ...this.#documentDimensions, + }; + } + + /** + * Gets the diagonal distance of the SelectionBox + * @returns The diagonal distance of the SelectionBox + */ + getSelectionBoxDistance() { + return this.#selectionBox.distance; + } + + /** + * Shift the SelectionBox so that it is always within the document + */ + shiftSelectionBox() { + this.#selectionBox.shiftBox(); + } + + /** + * Update the box coordinates from the hover element rect + */ + updateSelectionBoxFromRect(scrollX, scrollY) { + this.#selectionBox.updateBoxFromRect( + this.#hoverElementBox.rect, + scrollX, + scrollY + ); + } + + /** + * Handles when the user moves the mouse over an element + * @param clientX The x coordinate in the visible window + * @param clientY The y coordinate in the visible window + * @param targetId The target element id + */ + handleElementHover(clientX, clientY, targetId) { + this.#hoverElementBox.handleElementHover(clientX, clientY, targetId); + } + + /** + * Handles moving the rect when the user has scrolled but not moved the mouse + * It uses the last x and y coordinates to find the new element at the mouse position + */ + handleElementScroll() { + this.#hoverElementBox.handleElementScroll(); + } + + hideHoverElementSelection() { + this.#hoverElementBox.hide(); + } + + get hoverElementBoxRect() { + return this.#hoverElementBox.rect; + } + + get scrollWidth() { + return this.#documentDimensions.scrollWidth; + } + set scrollWidth(val) { + this.#documentDimensions.scrollWidth = val; + } + + get scrollHeight() { + return this.#documentDimensions.scrollHeight; + } + set scrollHeight(val) { + this.#documentDimensions.scrollHeight = val; + } + + get scrollX() { + return this.#documentDimensions.scrollX; + } + set scrollX(val) { + this.#documentDimensions.scrollX = val; + } + + get scrollY() { + return this.#documentDimensions.scrollY; + } + set scrollY(val) { + this.#documentDimensions.scrollY = val; + } + + get clientWidth() { + return this.#documentDimensions.clientWidth; + } + set clientWidth(val) { + this.#documentDimensions.clientWidth = val; + } + + get clientHeight() { + return this.#documentDimensions.clientHeight; + } + set clientHeight(val) { + this.#documentDimensions.clientHeight = val; + } +} + +/** + * The SelectionBox class handles drawing the highlight and background + */ +class SelectionBox extends AnonLayer { + #x1; + #x2; + #y1; + #y2; + #xOffset; + #yOffset; + #selectionLayer; + + constructor(content, selectionLayer) { + super("", content); + + this.#selectionLayer = selectionLayer; + + this.#x1 = 0; + this.#x2 = 0; + this.#y1 = 0; + this.#y2 = 0; + this.#xOffset = 0; + this.#yOffset = 0; + } + + /** + * Draw the selected region for screenshotting + */ + show() { + this.content.setAttributeForElement( + "highlight", + "style", + `top:${this.top}px;left:${this.left}px;height:${this.height}px;width:${this.width}px;` + ); + + this.content.setAttributeForElement( + "bgTop", + "style", + `top:0px;height:${this.top}px;left:0px;width:100%;` + ); + + this.content.setAttributeForElement( + "bgBottom", + "style", + `top:${this.bottom}px;height:calc(100% - ${this.bottom}px);left:0px;width:100%;` + ); + + this.content.setAttributeForElement( + "bgLeft", + "style", + `top:${this.top}px;height:${this.height}px;left:0px;width:${this.left}px;` + ); + + this.content.setAttributeForElement( + "bgRight", + "style", + `top:${this.top}px;height:${this.height}px;left:${this.right}px;width:calc(100% - ${this.right}px);` + ); + } + + /** + * Update the box coordinates from the rect + * @param rect The hover element box + * @param scrollX The x offset the page is scrolled + * @param scrollY The y offset the page is scrolled + */ + updateBoxFromRect(rect, scrollX, scrollY) { + this.top = rect.top + scrollY; + this.left = rect.left + scrollX; + this.right = rect.right + scrollX; + this.bottom = rect.bottom + scrollY; + } + + /** + * Hide the selected region + */ + hide() { + this.content.setAttributeForElement("highlight", "style", "display:none;"); + this.content.setAttributeForElement("bgTop", "style", "display:none;"); + this.content.setAttributeForElement("bgBottom", "style", "display:none;"); + this.content.setAttributeForElement("bgLeft", "style", "display:none;"); + this.content.setAttributeForElement("bgRight", "style", "display:none;"); + } + + /** + * The box should never appear outside the document so the SelectionBox will + * be shifted if the bounds of the box are outside the documents width or height + */ + shiftBox() { + let didShift = false; + let xDiff = this.right - this.#selectionLayer.scrollWidth; + if (xDiff > 0) { + this.right -= xDiff; + this.left -= xDiff; + + didShift = true; + } + + let yDiff = this.bottom - this.#selectionLayer.scrollHeight; + if (yDiff > 0) { + let curHeight = this.height; + + this.bottom -= yDiff; + this.top = this.bottom - curHeight; + + didShift = true; + } + + if (didShift) { + this.show(); + this.#selectionLayer.drawButtonsLayer(); + } + } + + /** + * 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]; + } + } + + /** + * Gets the dimensions of the currently selected region + * @returns {Object} + * { + * x1: the left dimension value + * y1: the top dimension value + * width: the width of the selected region + * height: the height of the selected region + * } + */ + getDimensions() { + return { + x1: this.left, + y1: this.top, + width: this.width, + height: this.height, + }; + } + + 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 = val > 0 ? val : 0; + } + + get left() { + return Math.min(this.#x1, this.#x2); + } + set left(val) { + this.#x1 = val > 0 ? val : 0; + } + + get right() { + return Math.max(this.#x1, this.#x2); + } + set right(val) { + this.#x2 = + val > this.#selectionLayer.scrollWidth + ? this.#selectionLayer.scrollWidth + : val; + } + + get bottom() { + return Math.max(this.#y1, this.#y2); + } + set bottom(val) { + this.#y2 = + val > this.#selectionLayer.scrollHeight + ? this.#selectionLayer.scrollHeight + : val; + } + + get width() { + return Math.abs(this.#x2 - this.#x1); + } + get height() { + return Math.abs(this.#y2 - this.#y1); + } +} + +class ButtonsLayer extends AnonLayer { + #selectionLayer; + + constructor(id, content, selectionLayer) { + super(id, content); + + this.#selectionLayer = selectionLayer; + } + + /** + * Draw the buttons. Check if the box is too near the bottom or left of the + * viewport and adjust the buttons accordingly + */ + show() { + let { + boxLeft, + boxTop, + boxRight, + boxBottom, + scrollX, + scrollY, + clientWidth, + clientHeight, + } = this.#selectionLayer.getDimensions(); + + 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; + let leftOrRight = `right:calc(100% - ${boxRight}px);`; + + 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) { + leftOrRight = `left:${boxLeft}px;`; + } + + this.content.setAttributeForElement( + "buttons", + "style", + `top:${top}px;${leftOrRight}` + ); + } +} + +class PreviewLayer extends AnonLayer { + constructor(id, content) { + super(id, content); + } + + /** + * Draw the eyeballs facing the mouse + * @param clientX x pointer position + * @param clientY y pointer position + * @param width width of the viewport + * @param height height of the viewport + */ + drawEyes(clientX, clientY, width, height) { + const xpos = Math.floor((10 * (clientX - width / 2)) / width); + const ypos = Math.floor((10 * (clientY - height / 2)) / height); + const move = `transform:translate(${xpos}px, ${ypos}px);`; + this.content.setAttributeForElement("left-eye", "style", move); + this.content.setAttributeForElement("right-eye", "style", move); + } +} + +class ScreenshotsContainerLayer extends AnonLayer { + #width; + #height; + #previewLayer; + #selectionLayer; + + constructor(id, content, previewLayer, selectionLayer) { + super(id, content); + + this.#previewLayer = previewLayer; + this.#selectionLayer = selectionLayer; + } + + /** + * Hide the SelectionLayer + */ + hideSelectionLayer() { + this.#selectionLayer.hide(); + } + + /** + * Show the PreviewLayer + */ + showPreviewLayer() { + this.#previewLayer.show(); + } + + /** + * Hide the PreviewLayer + */ + hidePreviewLayer() { + this.#previewLayer.hide(); + this.#selectionLayer.hideHoverElementSelection(); + } + + /** + * Show the ButtonsLayer + */ + drawButtonsLayer() { + this.#selectionLayer.drawButtonsLayer(); + } + + /** + * Hide the ButtonsLayer + */ + hideButtonsLayer() { + this.#selectionLayer.hideButtons(); + } + + /** + * Show the SelectionBox + */ + drawSelectionBox() { + this.#selectionLayer.drawSelectionBox(); + } + + hideHoverElementBox() { + this.#selectionLayer.hideHoverElementSelection(); + } + + /** + * Update the box coordinates from the hover element rect + */ + updateSelectionBoxFromRect(scrollX, scrollY) { + this.#selectionLayer.updateSelectionBoxFromRect(scrollX, scrollY); + } + + /** + * Handles when the user moves the mouse over an element + * @param clientX The x coordinate in the visible window + * @param clientY The y coordinate in the visible window + * @param targetId The target element id + */ + handleElementHover(clientX, clientY, targetId) { + this.#selectionLayer.handleElementHover(clientX, clientY, targetId); + } + + /** + * Handles moving the rect when the user has scrolled but not moved the mouse + * It uses the last x and y coordinates to find the new element at the mouse position + */ + handleElementScroll() { + this.#selectionLayer.handleElementScroll(); + } + + /** + * Draw the eyes in the PreviewLayer + * @param clientX The x mouse position + * @param clientY The y mouse position + */ + drawPreviewEyes(clientX, clientY) { + this.#previewLayer.drawEyes( + clientX, + clientY, + this.#selectionLayer.clientWidth, + this.#selectionLayer.clientHeight + ); + } + + /** + * Get the diagonal distance of the SelectionBox + * @returns The diagonal distance of the currently selected region + */ + selectionBoxDistance() { + return this.#selectionLayer.getSelectionBoxDistance(); + } + + /** + * Sort the coordinates of the SelectionBox + */ + sortSelectionLayerBoxCoords() { + this.#selectionLayer.sortSelectionBoxCoords(); + } + + /** + * Get the SelectionLayer dimensions + * @returns {Object} + * { + * x1: the left dimension value + * y1: the top dimension value + * width: the width of the selected region + * height: the height of the selected region + * } + */ + getSelectionLayerBoxDimensions() { + return this.#selectionLayer.getSelectionBoxDimensions(); + } + + /** + * Gets the SelectionBox and page dimensions + * @returns {Object} + * { + * boxLeft: the left position of the box + * boxTop: the top position of the box + * boxRight: the right position of the box + * boxBottom: the bottom position of the box + * scrollWidth: the total document width + * scrollHeight: the total document height + * scrollX: the x scrolled offset + * scrollY: the y scrolled offset + * clientWidth: the viewport width + * clientHeight: the viewport height + * } + */ + getSelectionLayerDimensions() { + return this.#selectionLayer.getDimensions(); + } + + /** + * Shift the SelectionBox + */ + shiftSelectionLayerBox() { + this.#selectionLayer.shiftSelectionBox(); + } + + /** + * Set the respective dimensions of the SelectionBox + * @param {Object} boxDimensionObject The new box 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 + * } + */ + setSelectionBoxDimensions(boxDimensionObject) { + this.#selectionLayer.setSelectionBoxDimensions(boxDimensionObject); + } + + /** + * Returns the window's dimensions for the `window` given. + * + * @return {Object} An object containing window dimensions + * { + * clientWidth: The width of the viewport + * clientHeight: The height of the viewport + * width: The width of the enitre page + * height: The height of the entire page + * scrollX: The X scroll offset of the viewport + * scrollY: The Y scroll offest of the viewport + * } + */ + getDimensionsFromWindow(window) { + let { + innerHeight, + innerWidth, + scrollMaxY, + scrollMaxX, + scrollMinY, + scrollMinX, + scrollY, + scrollX, + } = window; + + let width = innerWidth + scrollMaxX - scrollMinX; + let height = innerHeight + scrollMaxY - scrollMinY; + let clientHeight = innerHeight; + let clientWidth = innerWidth; + + const scrollbarHeight = {}; + const scrollbarWidth = {}; + window.windowUtils.getScrollbarSize(false, scrollbarWidth, scrollbarHeight); + width -= scrollbarWidth.value; + height -= scrollbarHeight.value; + clientWidth -= scrollbarWidth.value; + clientHeight -= scrollbarHeight.value; + + return { clientWidth, clientHeight, width, height, scrollX, scrollY }; + } + + /** + * The screenshots-overlay-container doesn't shrink with the window when the + * window is resized so we have to manually find the width and height of the + * window by looping throught the documentElement's children + * If the children mysteriously have a height or width of 0 then we will + * fallback to the scrollWidth and scrollHeight which can cause the container + * to be larger than the window dimensions + * @param win The window object + */ + updateSize(win) { + let { clientWidth, clientHeight, width, height, scrollX, scrollY } = + this.getDimensionsFromWindow(win); + + let shouldDraw = true; + + if ( + clientHeight < this.#selectionLayer.clientHeight || + clientWidth < this.#selectionLayer.clientWidth + ) { + let widthDiff = this.#selectionLayer.clientWidth - clientWidth; + let heightDiff = this.#selectionLayer.clientHeight - clientHeight; + + this.#width -= widthDiff; + this.#height -= heightDiff; + + this.drawScreenshotsContainer(); + // We just updated the screenshots container so we check if the window + // dimensions are still accurate + let { width: updatedWidth, height: updatedHeight } = + this.getDimensionsFromWindow(win); + + // If the width and height are the same then we don't need to draw the overlay again + if (updatedWidth === width && updatedHeight === height) { + shouldDraw = false; + } + + width = updatedWidth; + height = updatedHeight; + } + + this.#selectionLayer.clientWidth = clientWidth; + this.#selectionLayer.clientHeight = clientHeight; + this.#selectionLayer.scrollX = scrollX; + this.#selectionLayer.scrollY = scrollY; + + this.#selectionLayer.scrollWidth = width; + this.#selectionLayer.scrollHeight = height; + + this.#width = width; + this.#height = height; + + if (shouldDraw) { + this.drawScreenshotsContainer(); + } + } + + /** + * Return the dimensions of the screenshots container + * @returns {Object} + * width: the container width + * height: the container height + */ + getDimension() { + return { width: this.#width, height: this.#height }; + } + + /** + * Draw the screenshots container + */ + drawScreenshotsContainer() { + this.content.setAttributeForElement( + this.id, + "style", + `top:0;left:0;width:${this.#width}px;height:${this.#height}px;` + ); + } + + get hoverElementBoxRect() { + return this.#selectionLayer.hoverElementBoxRect; + } +} diff --git a/browser/components/screenshots/ScreenshotsUtils.sys.mjs b/browser/components/screenshots/ScreenshotsUtils.sys.mjs new file mode 100644 index 0000000000..48f61078e9 --- /dev/null +++ b/browser/components/screenshots/ScreenshotsUtils.sys.mjs @@ -0,0 +1,578 @@ +/* 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 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.defineLazyGetter(lazy, "screenshotsLocalization", () => { + return new Localization(["browser/screenshots.ftl"], true); +}); + +const PanelPosition = "bottomright topright"; +const PanelOffsetX = -33; +const PanelOffsetY = -8; +// The max dimension for a canvas is defined 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 124925329 or 11177 x 11177. +// We have to limit screenshots to these dimensions otherwise it will cause an error. +const MAX_CAPTURE_DIMENSION = 32767; +const MAX_CAPTURE_AREA = 124925329; + +export class ScreenshotsComponentParent extends JSWindowActorParent { + async receiveMessage(message) { + let browser = message.target.browsingContext.topFrameElement; + switch (message.name) { + case "Screenshots:CancelScreenshot": + await ScreenshotsUtils.closePanel(browser); + let { reason } = message.data; + ScreenshotsUtils.recordTelemetryEvent("canceled", reason, {}); + break; + case "Screenshots:CopyScreenshot": + await ScreenshotsUtils.closePanel(browser); + let copyBox = message.data; + ScreenshotsUtils.copyScreenshotFromRegion(copyBox, browser); + break; + case "Screenshots:DownloadScreenshot": + await ScreenshotsUtils.closePanel(browser); + let { title, downloadBox } = message.data; + ScreenshotsUtils.downloadScreenshotFromRegion( + title, + downloadBox, + browser + ); + 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.closePanel(browser); + } + } +} + +export var ScreenshotsUtils = { + initialized: false, + initialize() { + if (!this.initialized) { + if ( + !Services.prefs.getBoolPref( + "screenshots.browser.component.enabled", + false + ) + ) { + return; + } + Services.telemetry.setEventRecordingEnabled("screenshots", true); + Services.obs.addObserver(this, "menuitem-screenshot"); + Services.obs.addObserver(this, "screenshots-take-screenshot"); + this.initialized = true; + if (Cu.isInAutomation) { + Services.obs.notifyObservers(null, "screenshots-component-initialized"); + } + } + }, + uninitialize() { + if (this.initialized) { + Services.obs.removeObserver(this, "menuitem-screenshot"); + Services.obs.removeObserver(this, "screenshots-take-screenshot"); + this.initialized = false; + } + }, + handleEvent(event) { + // We need to add back Escape to hide behavior as we have set noautohide="true" + if (event.type === "keydown" && event.key === "Escape") { + this.closePanel(event.view.gBrowser.selectedBrowser, true); + this.recordTelemetryEvent("canceled", "escape", {}); + } + }, + observe(subj, topic, data) { + let { gBrowser } = subj; + let browser = gBrowser.selectedBrowser; + + switch (topic) { + case "menuitem-screenshot": + let success = this.closeDialogBox(browser); + if (!success || data === "retry") { + // only toggle the buttons if no dialog box is found because + // if dialog box is found then the buttons are hidden and we return early + // else no dialog box is found and we need to toggle the buttons + // or if retry because the dialog box was closed and we need to show the panel + this.togglePanelAndOverlay(browser, data); + } + break; + case "screenshots-take-screenshot": + // need to close the preview because screenshot was taken + this.closePanel(browser, true); + + // init UI as a tab dialog box + let dialogBox = gBrowser.getTabDialogBox(browser); + + let { dialog } = dialogBox.open( + `chrome://browser/content/screenshots/screenshots.html?browsingContextId=${browser.browsingContext.id}`, + { + features: "resizable=no", + sizeTo: "available", + allowDuplicateDialogs: false, + } + ); + this.doScreenshot(browser, dialog, data); + } + return null; + }, + /** + * 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 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; + }, + /** + * Open the panel buttons + * @param browser The current browser + */ + async openPanel(browser) { + this.createOrDisplayButtons(browser); + let buttonsPanel = this.panelForBrowser(browser); + if (buttonsPanel.state !== "open") { + await new Promise(resolve => { + buttonsPanel.addEventListener("popupshown", resolve, { once: true }); + }); + } + buttonsPanel + .querySelector("screenshots-buttons") + .focusFirst({ focusVisible: true }); + }, + /** + * Close the panel and call child actor to close the overlay + * @param browser The current browser + * @param {bool} closeOverlay Whether or not to + * send a message to the child to close the overly. + * Defaults to false. Will be false when called from didDestroy. + */ + async closePanel(browser, closeOverlay = false) { + let buttonsPanel = this.panelForBrowser(browser); + if (buttonsPanel && buttonsPanel.state !== "closed") { + buttonsPanel.hidePopup(); + } + buttonsPanel?.ownerDocument.removeEventListener("keydown", this); + if (closeOverlay) { + let actor = this.getActor(browser); + await actor.sendQuery("Screenshots:HideOverlay"); + } + }, + /** + * 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 togglePanelAndOverlay(browser, data) { + let buttonsPanel = this.panelForBrowser(browser); + let isOverlayShowing = await this.getActor(browser).sendQuery( + "Screenshots:isOverlayShowing" + ); + + data = data === "retry" ? "preview_retry" : data; + if (buttonsPanel && (isOverlayShowing || buttonsPanel.state !== "closed")) { + this.recordTelemetryEvent("canceled", data, {}); + return this.closePanel(browser, true); + } + let actor = this.getActor(browser); + actor.sendQuery("Screenshots:ShowOverlay"); + this.recordTelemetryEvent("started", data, {}); + return this.openPanel(browser); + }, + /** + * 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 dialog = this.getDialog(browser); + if (dialog) { + dialog.close(); + return true; + } + return false; + }, + panelForBrowser(browser) { + return browser.ownerDocument.querySelector("#screenshotsPagePanel"); + }, + /** + * 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 || !window.isElementVisible(anchor.parentNode)) { + anchor = browser.ownerDocument.getElementById("navigator-toolbox"); + } + 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" + ); + }, + /** + * If the buttons panel does not exist then we will replace the buttons + * panel template with the buttons panel then open the buttons panel and + * show the screenshots overaly. + * @param browser The current browser. + */ + createOrDisplayButtons(browser) { + let doc = browser.ownerDocument; + let buttonsPanel = this.panelForBrowser(browser); + + if (!buttonsPanel) { + let template = doc.querySelector("#screenshotsPagePanelTemplate"); + let clone = template.content.cloneNode(true); + template.replaceWith(clone); + buttonsPanel = doc.querySelector("#screenshotsPagePanel"); + } else if (buttonsPanel.state !== "closed") { + // early return if the panel is already open + return; + } + + buttonsPanel.ownerDocument.addEventListener("keydown", this); + + let anchor = doc.querySelector("#navigator-toolbox"); + buttonsPanel.openPopup(anchor, PanelPosition, PanelOffsetX, PanelOffsetY); + }, + /** + * 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 one dimesion for a canvas is 32767 and the max canvas area is + * 124925329. If the width or height is greater than 32767 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); + + 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); + } + }, + /** + * Add screenshot-ui to the dialog box and then take the screenshot + * @param browser The current browser. + * @param dialog The dialog box to show the screenshot preview. + * @param type The type of screenshot taken. + */ + async doScreenshot(browser, dialog, type) { + await dialog._dialogReady; + let screenshotsUI = + dialog._frame.contentDocument.createElement("screenshots-ui"); + dialog._frame.contentDocument.body.appendChild(screenshotsUI); + + let rect; + if (type === "full-page") { + rect = await this.fetchFullPageBounds(browser); + type = "full_page"; + } else { + rect = await this.fetchVisibleBounds(browser); + } + 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, snapshot } = 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"); + } + + snapshot.close(); + }, + /** + * Creates a canvas and draws a snapshot of the screenshot on the canvas + * @param box The bounds of screenshots + * @param browser The current browser + * @returns The canvas and snapshot in an object + */ + async createCanvas(box, browser) { + this.cropScreenshotRectIfNeeded(box); + + let rect = new DOMRect(box.x1, box.y1, box.width, box.height); + let { devicePixelRatio } = box; + + let browsingContext = BrowsingContext.get(browser.browsingContext.id); + + let snapshot = await browsingContext.currentWindowGlobal.drawSnapshot( + rect, + devicePixelRatio, + "rgb(255,255,255)" + ); + + let canvas = browser.ownerDocument.createElementNS( + "http://www.w3.org/1999/xhtml", + "html:canvas" + ); + let context = canvas.getContext("2d"); + + canvas.width = snapshot.width; + canvas.height = snapshot.height; + + context.drawImage(snapshot, 0, 0); + + return { canvas, snapshot }; + }, + /** + * Copy the screenshot + * @param region The bounds of the screenshots + * @param browser The current browser + */ + async copyScreenshotFromRegion(region, browser) { + let { canvas, snapshot } = await this.createCanvas(region, browser); + + let url = canvas.toDataURL(); + + this.copyScreenshot(url, browser); + + snapshot.close(); + + this.recordTelemetryEvent("copy", "overlay_copy", {}); + }, + /** + * Copy the image to the clipboard + * @param dataUrl The image data + * @param browser The current browser + */ + copyScreenshot(dataUrl, browser) { + // 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); + }, + /** + * Download the screenshot + * @param title The title of the current page + * @param box The bounds of the screenshot + * @param browser The current browser + */ + async downloadScreenshotFromRegion(title, box, browser) { + let { canvas, snapshot } = await this.createCanvas(box, browser); + + let dataUrl = canvas.toDataURL(); + + await this.downloadScreenshot(title, dataUrl, browser); + + snapshot.close(); + + this.recordTelemetryEvent("download", "overlay_download", {}); + }, + /** + * Download the screenshot + * @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 + */ + async downloadScreenshot(title, dataUrl, browser) { + // 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) {} + }, + + recordTelemetryEvent(type, object, args) { + 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 @@ + + 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 @@ + + \ 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 @@ + + 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 @@ + + \ 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 @@ + + 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 @@ + + \ 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 @@ + + \ 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 @@ + + \ 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 @@ + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/screenshots/content/screenshots.js b/browser/components/screenshots/content/screenshots.js new file mode 100644 index 0000000000..9037038f38 --- /dev/null +++ b/browser/components/screenshots/content/screenshots.js @@ -0,0 +1,101 @@ +/* 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, { + Downloads: "resource://gre/modules/Downloads.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs", +}); + +class ScreenshotsUI extends HTMLElement { + constructor() { + super(); + } + 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 + ) { + Services.obs.notifyObservers( + window.parent.ownerGlobal, + "menuitem-screenshot", + "retry" + ); + } + } + + async saveToFile(dataUrl) { + await ScreenshotsUtils.downloadScreenshot( + null, + dataUrl, + window.parent.ownerGlobal.gBrowser.selectedBrowser + ); + + this.close(); + + ScreenshotsUtils.recordTelemetryEvent("download", "preview_download", {}); + } + + saveToClipboard(dataUrl) { + ScreenshotsUtils.copyScreenshot( + dataUrl, + window.parent.ownerGlobal.gBrowser.selectedBrowser + ); + + this.close(); + + ScreenshotsUtils.recordTelemetryEvent("copy", "preview_copy", {}); + } +} +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..4bbdbbbefc --- /dev/null +++ b/browser/components/screenshots/fileHelpers.mjs @@ -0,0 +1,271 @@ +/* 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); + + fp.file.leafName = validateFileName(fp.file.leafName); + + aFpP.saveAsType = fp.filterIndex; + aFpP.file = fp.file; + aFpP.fileURL = fp.fileURL; + + return true; + })(); +} diff --git a/browser/components/screenshots/jar.mn b/browser/components/screenshots/jar.mn new file mode 100644 index 0000000000..0e3d87ba11 --- /dev/null +++ b/browser/components/screenshots/jar.mn @@ -0,0 +1,23 @@ +# 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 screenshots-overlay %overlay/ diff --git a/browser/components/screenshots/moz.build b/browser/components/screenshots/moz.build new file mode 100644 index 0000000000..73b03cd0bd --- /dev/null +++ b/browser/components/screenshots/moz.build @@ -0,0 +1,19 @@ +# -*- 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 += [ + "ScreenshotsOverlayChild.sys.mjs", + "ScreenshotsUtils.sys.mjs", +] + +JAR_MANIFESTS += ["jar.mn"] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Screenshots") + +BROWSER_CHROME_MANIFESTS += [ + "tests/browser/browser.ini", +] diff --git a/browser/components/screenshots/overlay/overlay.css b/browser/components/screenshots/overlay/overlay.css new file mode 100644 index 0000000000..7b008fb8aa --- /dev/null +++ b/browser/components/screenshots/overlay/overlay.css @@ -0,0 +1,481 @@ +/* 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/. */ + +:-moz-native-anonymous #screenshots-component { + --in-content-page-background: #fff; + + --in-content-button-text-color: rgb(21, 20, 26); + --in-content-button-text-color-hover: var(--in-content-text-color); + --in-content-button-text-color-active: var(--in-content-button-text-color); + --in-content-button-background: rgba(207,207,216,.33); + --in-content-button-background-hover: rgba(207,207,216,.66); + --in-content-button-background-active: rgb(207,207,216); + --in-content-button-border-color: transparent; + --in-content-button-border-color-hover: transparent; + --in-content-button-border-color-active: transparent; + + --in-content-primary-button-text-color: rgb(251,251,254); + --in-content-primary-button-text-color-hover: var(--in-content-primary-button-text-color); + --in-content-primary-button-text-color-active: var(--in-content-primary-button-text-color); + --in-content-primary-button-background: #0061e0; + --in-content-primary-button-background-hover: #0250bb; + --in-content-primary-button-background-active: #053e94; + --in-content-primary-button-border-color: transparent; + --in-content-primary-button-border-color-hover: transparent; + --in-content-primary-button-border-color-active: transparent; + + --in-content-focus-outline-color: var(--in-content-primary-button-background); +} + +@media (prefers-color-scheme: dark) { + :-moz-native-anonymous #screenshots-component { + --in-content-page-background: #42414d; + + --in-content-button-text-color: rgb(251,251,254); + --in-content-button-background: rgb(43,42,51); + --in-content-button-background-hover: rgb(82,82,94); + --in-content-button-background-active: rgb(91,91,102); + + --in-content-primary-button-text-color: rgb(43,42,51); + --in-content-primary-button-background: rgb(0,221,255); + --in-content-primary-button-background-hover: rgb(128,235,255); + --in-content-primary-button-background-active: rgb(170,242,255); + } +} + +@media (forced-colors: active), (prefers-contrast) { + :-moz-native-anonymous #screenshots-component { + --in-content-page-background: Canvas; + + --in-content-button-text-color: ButtonText; + --in-content-button-text-color-hover: SelectedItemText; + --in-content-button-text-color-active: SelectedItem; + --in-content-button-background: ButtonFace; + --in-content-button-background-hover: SelectedItem; + --in-content-button-background-active: SelectedItemText; + --in-content-button-border-color: ButtonText; + --in-content-button-border-color-hover: SelectedItemText; + --in-content-button-border-color-active: SelectedItem; + + --in-content-primary-button-text-color: ButtonFace; + --in-content-primary-button-text-color-hover: SelectedItemText; + --in-content-primary-button-text-color-active: SelectedItem; + --in-content-primary-button-background: ButtonText; + --in-content-primary-button-background-hover: SelectedItem; + --in-content-primary-button-background-active: SelectedItemText; + --in-content-primary-button-border-color: ButtonFace; + --in-content-primary-button-border-color-hover: SelectedItemText; + --in-content-primary-button-border-color-active: SelectedItem; + + --in-content-focus-outline-color: -moz-DialogText; + } +} + +:-moz-native-anonymous #screenshots-component { + width: 100%; + height: 100%; + overflow: clip; + user-select: none; + pointer-events: auto; + touch-action: none; +} + +/** + * Overlay content is position: fixed as we need to allow for the possiblily + * of the document scrolling or changing size while the overlay is visible + */ +:-moz-native-anonymous #screenshots-overlay-container { + /* + Content CSS applying to the html element can impact the overlay. + To avoid that, possible cases have been set to initial. + */ + text-transform: initial; + text-indent: initial; + letter-spacing: initial; + word-spacing: initial; + color: initial; + direction: initial; + writing-mode: initial; + z-index: 1; + position: absolute; + pointer-events: auto; + cursor: crosshair; +} + +:-moz-native-anonymous #preview-container { + background-color: rgba(0, 0, 0, 0.7); + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +:-moz-native-anonymous #selection-container { + overflow: clip; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +:-moz-native-anonymous #screenshots-overlay-container[hidden] { + display: none; +} + +:-moz-native-anonymous #screenshots-overlay-container[dragging] { + cursor: grabbing; +} + +:-moz-native-anonymous #buttons { + position: absolute; + margin: 10px 0; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--in-content-page-background); + border-radius: 4px; + padding: 4px; +} + +:-moz-native-anonymous .screenshots-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; + z-index: 6; + margin-inline: 4px; + font-family: "Segoe UI", sans-serif; + + /* Below styles are copied from common-shared.css */ + appearance: none; + padding: 7px 15px; + text-decoration: none; + font-weight: 600; + margin: 4px; + border: 1px solid var(--in-content-button-border-color); + color: var(--in-content-button-text-color); + border-radius: 4px; + background-color: var(--in-content-button-background); +} + +:-moz-native-anonymous .screenshots-button:focus-visible, +:-moz-native-anonymous #screenshots-cancel-button:focus-visible { + outline: 2px solid var(--in-content-focus-outline-color); + outline-offset: 2px; +} + +:-moz-native-anonymous .screenshots-button:hover { + background-color: var(--in-content-button-background-hover); + border-color: var(--in-content-button-border-color-hover); + color: var(--in-content-button-text-color-hover); +} + +:-moz-native-anonymous .screenshots-button:active { + background-color: var(--in-content-button-background-active); + border-color: var(--in-content-button-border-color-active); + color: var(--in-content-button-text-color-active); +} + +:-moz-native-anonymous .primary { + background-color: var(--in-content-primary-button-background); + border-color: var(--in-content-primary-button-border-color); + color: var(--in-content-primary-button-text-color); +} + +:-moz-native-anonymous .primary:hover { + background-color: var(--in-content-primary-button-background-hover); + border-color: var(--in-content-primary-button-border-color-hover); + color: var(--in-content-primary-button-text-color-hover); +} + +:-moz-native-anonymous .primary:active { + background-color: var(--in-content-primary-button-background-active); + border-color: var(--in-content-primary-button-border-color-active); + color: var(--in-content-primary-button-text-color-active); +} + +:-moz-native-anonymous #screenshots-cancel-button { + background-color: transparent; + margin-top: 40px; + width: fit-content; + border-color: #fff; + color: #fff; +} + +:-moz-native-anonymous #screenshots-cancel-button:hover { + background-color: #fff; + color: #000; +} + +@media (forced-colors: active), (prefers-contrast) { + :-moz-native-anonymous #screenshots-cancel-button { + border-color: ButtonBorder; + color: CanvasText; + } +} + +:-moz-native-anonymous .screenshots-button > img { + -moz-context-properties: fill; + fill: currentColor; + width: 16px; + height: 16px; +} + +:-moz-native-anonymous #cancel > img { + content: url("chrome://global/skin/icons/close.svg"); +} + +:-moz-native-anonymous #copy > img { + content: url("chrome://global/skin/icons/edit-copy.svg"); +} + +:-moz-native-anonymous #download > img { + content: url("chrome://browser/skin/downloads/downloads.svg"); +} + +:-moz-native-anonymous #download > img, +:-moz-native-anonymous #copy > img { + margin-inline-end: 5px; +} + +:-moz-native-anonymous .fixed-container { + align-items: center; + display: flex; + flex-direction: column; + height: 100vh; + justify-content: center; + inset-inline-start: 0; + margin: 0; + padding: 0; + position: fixed; + top: 0; + width: 100%; +} + +:-moz-native-anonymous .face-container { + position: relative; + width: 64px; + height: 64px; +} + +:-moz-native-anonymous .face { + width: 62px; + height: 62px; + display: block; + background-image: url("chrome://browser/content/screenshots/icon-welcome-face-without-eyes.svg"); +} + +:-moz-native-anonymous .eye { + background-color: #fff; + width: 10px; + height: 14px; + position: absolute; + border-radius: 100%; + overflow: hidden; + inset-inline-start: 16px; + top: 19px; +} + +:-moz-native-anonymous .eyeball { + position: absolute; + width: 6px; + height: 6px; + background-color: #000; + border-radius: 50%; + inset-inline-start: 2px; + top: 4px; + z-index: 10; +} + +:-moz-native-anonymous .left { + margin-inline-start: 0; +} + +:-moz-native-anonymous .right { + margin-inline-start: 20px; +} + +:-moz-native-anonymous .preview-instructions { + display: flex; + align-items: center; + justify-content: center; + animation: pulse 125mm cubic-bezier(0.07, 0.95, 0, 1); + color: #fff; + font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif; + font-size: 24px; + line-height: 32px; + text-align: center; + padding-top: 20px; + width: 400px; +} + +@media (forced-colors: active), (prefers-contrast) { + :-moz-native-anonymous .preview-instructions { + color: CanvasText; + } +} + +:-moz-native-anonymous #hover-highlight { + animation: fade-in 125ms forwards cubic-bezier(0.07, 0.95, 0, 1); + background: rgba(255, 255, 255, 0.2); + border-radius: 1px; + pointer-events: none; + position: absolute; + z-index: 11; +} + +:-moz-native-anonymous #hover-highlight::before { + border: 2px dashed rgba(255, 255, 255, 0.4); + bottom: 0; + content: ""; + inset-inline-start: 0; + position: absolute; + inset-inline-end: 0; + top: 0; +} + +:-moz-native-anonymous .bghighlight { + background-color: rgba(0, 0, 0, 0.7); + position: absolute; + overflow: clip; +} + +:-moz-native-anonymous .highlight { + border-radius: 1px; + border: 2px dashed rgba(255, 255, 255, 0.8); + box-sizing: border-box; + cursor: move; + position: absolute; + pointer-events: auto; + z-index: 2; +} + +:-moz-native-anonymous .mover-target { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + z-index: 5; + pointer-events: auto; +} + +:-moz-native-anonymous .mover-target.direction-topLeft { + cursor: nwse-resize; + height: 60px; + left: -30px; + top: -30px; + width: 60px; +} + +:-moz-native-anonymous .mover-target.direction-top { + cursor: ns-resize; + height: 60px; + inset-inline-start: 0; + top: -30px; + width: 100%; + z-index: 4; +} + +:-moz-native-anonymous .mover-target.direction-topRight { + cursor: nesw-resize; + height: 60px; + right: -30px; + top: -30px; + width: 60px; +} + +:-moz-native-anonymous .mover-target.direction-left { + cursor: ew-resize; + height: 100%; + left: -30px; + top: 0; + width: 60px; + z-index: 4; +} + +:-moz-native-anonymous .mover-target.direction-right { + cursor: ew-resize; + height: 100%; + right: -30px; + top: 0; + width: 60px; + z-index: 4; +} + +:-moz-native-anonymous .mover-target.direction-bottomLeft { + bottom: -30px; + cursor: nesw-resize; + height: 60px; + left: -30px; + width: 60px; +} + +:-moz-native-anonymous .mover-target.direction-bottom { + bottom: -30px; + cursor: ns-resize; + height: 60px; + inset-inline-start: 0; + width: 100%; + z-index: 4; +} + +:-moz-native-anonymous .mover-target.direction-bottomRight { + bottom: -30px; + cursor: nwse-resize; + height: 60px; + right: -30px; + width: 60px; +} + +:-moz-native-anonymous .mover-target:hover .mover { + transform: scale(1.05); +} + +:-moz-native-anonymous .mover { + background-color: #fff; + border-radius: 50%; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.5); + height: 16px; + opacity: 1; + position: relative; + transition: transform 125ms cubic-bezier(0.07, 0.95, 0, 1); + width: 16px; +} + +:-moz-native-anonymous .small-selection .mover { + height: 10px; + width: 10px; +} + +:-moz-native-anonymous .direction-topLeft .mover, +:-moz-native-anonymous .direction-left .mover, +:-moz-native-anonymous .direction-bottomLeft .mover { + left: -1px; +} + +:-moz-native-anonymous .direction-topLeft .mover, +:-moz-native-anonymous .direction-top .mover, +:-moz-native-anonymous .direction-topRight .mover { + top: -1px; +} + +:-moz-native-anonymous .direction-topRight .mover, +:-moz-native-anonymous .direction-right .mover, +:-moz-native-anonymous .direction-bottomRight .mover { + right: -1px; +} + +:-moz-native-anonymous .direction-bottomRight .mover, +:-moz-native-anonymous .direction-bottom .mover, +:-moz-native-anonymous .direction-bottomLeft .mover { + bottom: -1px; +} diff --git a/browser/components/screenshots/screenshots-buttons.css b/browser/components/screenshots/screenshots-buttons.css new file mode 100644 index 0000000000..2b0ccbd004 --- /dev/null +++ b/browser/components/screenshots/screenshots-buttons.css @@ -0,0 +1,27 @@ +/* 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/. */ + +.all-buttons-container button { + background-position: center top; + background-repeat: no-repeat; + background-size: 46px 46px; + min-width: 90px; + padding: 46px 5px 5px; + margin: 2px; + color: var(--panel-color); +} + +.all-buttons-container .full-page { + background-image: url("chrome://browser/content/screenshots/menu-fullpage.svg"); +} + +.all-buttons-container .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); +} diff --git a/browser/components/screenshots/screenshots-buttons.js b/browser/components/screenshots/screenshots-buttons.js new file mode 100644 index 0000000000..66188bd3e2 --- /dev/null +++ b/browser/components/screenshots/screenshots-buttons.js @@ -0,0 +1,60 @@ +/* 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`. +{ + class ScreenshotsButtons extends MozXULElement { + static get markup() { + return ` + + + + + + + `; + } + + connectedCallback() { + const shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + + let fragment = MozXULElement.parseXULToFragment(this.constructor.markup); + this.shadowRoot.append(fragment); + + let button1 = shadowRoot.querySelector(".visible-page"); + button1.onclick = function () { + Services.obs.notifyObservers( + gBrowser.ownerGlobal, + "screenshots-take-screenshot", + "visible" + ); + }; + + let button2 = shadowRoot.querySelector(".full-page"); + button2.onclick = function () { + Services.obs.notifyObservers( + gBrowser.ownerGlobal, + "screenshots-take-screenshot", + "full-page" + ); + }; + } + + disconnectedCallback() { + document.l10n.disconnectRoot(this.shadowRoot); + } + + focusFirst(focusOptions) { + this.shadowRoot.querySelector("button:enabled").focus(focusOptions); + } + } + customElements.define("screenshots-buttons", ScreenshotsButtons, { + extends: "toolbar", + }); +} diff --git a/browser/components/screenshots/tests/browser/browser.ini b/browser/components/screenshots/tests/browser/browser.ini new file mode 100644 index 0000000000..c850f03a97 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser.ini @@ -0,0 +1,29 @@ +[DEFAULT] +support-files = + head.js + test-page.html + short-test-page.html + large-test-page.html + +prefs = + extensions.screenshots.disabled=false + screenshots.browser.component.enabled=true + +[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] +skip-if = + (!debug && os == 'win' && os_version == '6.1') # Bug 1746281 +[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] 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..19669b7427 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js @@ -0,0 +1,400 @@ +/* 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 dimensions = await helper.getSelectionLayerDimensions(); + info(JSON.stringify(dimensions)); + is( + dimensions.scrollWidth, + contentInfo.scrollWidth, + "The overlay spans the entire width of the page" + ); + + is( + dimensions.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.waitForStateChange("resizing"); + let state = await helper.getOverlayState(); + is(state, "resizing", "The overlay is in the resizing state"); + + 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.waitForSelectionBoxSizeChange(490); + + let dimensions = await helper.getSelectionBoxDimensions(); + + is(dimensions.x1, 0, "The box x1 position is now 0"); + is(dimensions.y1, 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.waitForSelectionBoxSizeChange(255); + + dimensions = await helper.getSelectionBoxDimensions(); + + is(dimensions.x1, 10, "The box x1 position is now 10 again"); + is(dimensions.y1, 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.waitForStateChange("resizing"); + let state = await helper.getOverlayState(); + is(state, "resizing", "The overlay is in the resizing state"); + + 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.waitForSelectionBoxSizeChange(480); + + let dimensions = await helper.getSelectionBoxDimensions(); + + is(dimensions.x1, startX + 240, "The box x1 position is now 3748"); + is(dimensions.y1, 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.waitForSelectionBoxSizeChange(252); + + dimensions = await helper.getSelectionBoxDimensions(); + + is(dimensions.x1, startX, "The box x1 position is now 3508 again"); + is(dimensions.y1, 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.getSelectionBoxDimensions(); + + is(dimensions.x1, startX, "The box x1 position is 10"); + is(dimensions.y1, 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 + mouse.click(scrollX + startX, scrollY + endY); + await helper.waitForStateChange("crosshairs"); + + await helper.dragOverlay( + scrollX + startX, + scrollY + startY, + scrollX + endX, + scrollY + endY + ); + + await helper.scrollContentWindow(0, 0); + + dimensions = await helper.getSelectionBoxDimensions(); + + is(dimensions.x1, scrollX + startX, "The box x1 position is 1010"); + is(dimensions.y1, 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 + mouse.click(10, 10); + await helper.waitForStateChange("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.waitForStateChange("resizing"); + + mouse.move( + contentInfo.clientWidth * 2 - 30, + contentInfo.clientHeight * 2 - 30 + ); + + mouse.up( + contentInfo.clientWidth * 2 - 30, + contentInfo.clientHeight * 2 - 30 + ); + + await helper.waitForStateChange("selected"); + + dimensions = await helper.getSelectionLayerDimensions(); + info(JSON.stringify(dimensions)); + is(dimensions.boxLeft, startX, "The box left is 10"); + is(dimensions.boxTop, startY, "The box top is 10"); + is( + dimensions.boxRight, + contentInfo.clientWidth * 2 - 30, + "The box right is 2 x clientWidth - 30" + ); + is( + dimensions.boxBottom, + contentInfo.clientHeight * 2 - 30, + "The box right is 2 x clientHeight - 30" + ); + is( + dimensions.boxWidth, + contentInfo.clientWidth * 2 - 40, + "The box right is 2 x clientWidth - 40" + ); + is( + dimensions.boxHeight, + contentInfo.clientHeight * 2 - 40, + "The box right is 2 x clientHeight - 40" + ); + is( + dimensions.scrollWidth, + contentInfo.scrollWidth, + "The overlay spans the entire width of the page" + ); + is( + dimensions.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.getWindowPosition(); + + 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.waitForStateChange("draggingReady"); + mouse.move(1050, 1050); + await helper.waitForStateChange("dragging"); + mouse.move(endX, endY); + mouse.up(endX, endY); + await helper.waitForStateChange("selected"); + + windowX = 980; + windowY = 980; + await helper.waitForScrollTo(windowX, windowY); + + ({ scrollX, scrollY } = await helper.getWindowPosition()); + + is(scrollX, windowX, "Window x position is 980"); + is(scrollY, windowY, "Window y position is 980"); + + 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` + ); + mouse.down(startX, startY); + await helper.waitForStateChange("resizing"); + mouse.move(endX, endY); + mouse.up(endX, endY); + await helper.waitForStateChange("selected"); + + windowX = 1000; + windowY = 1000; + await helper.waitForScrollTo(windowX, windowY); + + ({ scrollX, scrollY } = await helper.getWindowPosition()); + + 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..a8c05dcb49 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_drag_test.js @@ -0,0 +1,475 @@ +/* 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"); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + await helper.dragOverlay(10, 10, 500, 500); + + let clipboardChanged = helper.waitForRawClipboardChange(); + + helper.clickCopyButton(); + + info("Waiting for clipboard change"); + await clipboardChanged; + + let result = await helper.getImageSizeAndColorFromClipboard(); + + info("result: " + JSON.stringify(result, null, 2)); + + let expected = Math.floor( + 490 * (await getContentDevicePixelRatio(browser)) + ); + + 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"); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + await helper.dragOverlay(300, 100, 350, 150); + + let clipboardChanged = helper.waitForRawClipboardChange(); + + helper.clickCopyButton(); + + info("Waiting for clipboard change"); + await clipboardChanged; + + let result = await helper.getImageSizeAndColorFromClipboard(); + result.zoom = zoom; + result.devicePixelRatio = window.devicePixelRatio; + result.contentDevicePixelRatio = await getContentDevicePixelRatio( + browser + ); + + info("result: " + JSON.stringify(result, null, 2)); + + let expected = Math.floor( + 50 * (await getContentDevicePixelRatio(browser)) + ); + + 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.waitForStateChange("crosshairs"); + let state = await helper.getOverlayState(); + Assert.equal(state, "crosshairs", "The state is back to crosshairs"); + } + ); +}); + +/** + * This function drags an area and clicks the + * cancel button to cancel 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); + + helper.clickCancelButton(); + + await helper.waitForOverlayClosed(); + + ok(!(await helper.isOverlayInitialized()), "Overlay is not initialized"); + } + ); +}); + +/** + * 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: 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 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.waitForStateChange("resizing"); + let state = await helper.getOverlayState(); + Assert.equal(state, "resizing", "The overlay is in the resizing state"); + + mouse.move(10, 10); + + mouse.move(contentInfo.clientWidth - 10, contentInfo.clientHeight - 10); + + mouse.up( + Math.floor((endX - startX) / 2), + Math.floor((endY - startY) / 2) + ); + + let clipboardChanged = helper.waitForRawClipboardChange(); + + helper.clickCopyButton(); + + info("Waiting for clipboard change"); + await clipboardChanged; + + let result = await helper.getImageSizeAndColorFromClipboard(); + + info("result: " + JSON.stringify(result, null, 2)); + + let expected = Math.floor( + 490 * (await getContentDevicePixelRatio(browser)) + ); + + 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"); + + 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.waitForStateChange("resizing"); + let state = await helper.getOverlayState(); + Assert.equal(state, "resizing", "The overlay is in the resizing state"); + + mouse.move(x, 100); + mouse.up(x, 100); + + await helper.waitForStateChange("selected"); + state = await helper.getOverlayState(); + Assert.equal(state, "selected", "The overlay is in the selected state"); + + // drag bottom + mouse.down(x, 500); + + await helper.waitForStateChange("resizing"); + state = await helper.getOverlayState(); + Assert.equal(state, "resizing", "The overlay is in the resizing state"); + + mouse.move(x, 400); + mouse.up(x, 400); + + await helper.waitForStateChange("selected"); + state = await helper.getOverlayState(); + Assert.equal(state, "selected", "The overlay is in the selected state"); + + // drag right + let y = Math.floor((endY - startY) / 2); + mouse.down(500, y); + + await helper.waitForStateChange("resizing"); + state = await helper.getOverlayState(); + Assert.equal(state, "resizing", "The overlay is in the resizing state"); + + mouse.move(400, y); + mouse.up(400, y); + + await helper.waitForStateChange("selected"); + state = await helper.getOverlayState(); + Assert.equal(state, "selected", "The overlay is in the selected state"); + + // drag left + mouse.down(10, y); + + await helper.waitForStateChange("resizing"); + state = await helper.getOverlayState(); + Assert.equal(state, "resizing", "The overlay is in the resizing state"); + + mouse.move(100, y); + mouse.up(100, y); + + await helper.waitForStateChange("selected"); + state = await helper.getOverlayState(); + Assert.equal(state, "selected", "The overlay is in the selected state"); + + let clipboardChanged = helper.waitForRawClipboardChange(); + + helper.endX = 400; + helper.endY = 400; + + helper.clickCopyButton(); + + info("Waiting for clipboard change"); + await clipboardChanged; + + let result = await helper.getImageSizeAndColorFromClipboard(); + + info("result: " + JSON.stringify(result, null, 2)); + + let expected = Math.floor( + 300 * (await getContentDevicePixelRatio(browser)) + ); + + 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"); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + await helper.dragOverlay(10, 10, 500, 500); + + // drag topright + mouse.down(500, 10); + + await helper.waitForStateChange("resizing"); + let state = await helper.getOverlayState(); + Assert.equal(state, "resizing", "The overlay is in the resizing state"); + + mouse.move(450, 50); + mouse.up(450, 50); + + await helper.waitForStateChange("selected"); + state = await helper.getOverlayState(); + Assert.equal(state, "selected", "The overlay is in the selected state"); + + // drag bottomright + mouse.down(450, 500); + + await helper.waitForStateChange("resizing"); + state = await helper.getOverlayState(); + Assert.equal(state, "resizing", "The overlay is in the resizing state"); + + mouse.move(400, 450); + mouse.up(400, 450); + + await helper.waitForStateChange("selected"); + state = await helper.getOverlayState(); + Assert.equal(state, "selected", "The overlay is in the selected state"); + + // drag bottomleft + mouse.down(10, 450); + + await helper.waitForStateChange("resizing"); + state = await helper.getOverlayState(); + Assert.equal(state, "resizing", "The overlay is in the resizing state"); + + mouse.move(50, 400); + mouse.up(50, 400); + + await helper.waitForStateChange("selected"); + state = await helper.getOverlayState(); + Assert.equal(state, "selected", "The overlay is in the selected state"); + + // drag topleft + mouse.down(50, 50); + + await helper.waitForStateChange("resizing"); + state = await helper.getOverlayState(); + Assert.equal(state, "resizing", "The overlay is in the resizing state"); + + mouse.move(100, 100); + mouse.up(100, 100); + + await helper.waitForStateChange("selected"); + state = await helper.getOverlayState(); + Assert.equal(state, "selected", "The overlay is in the selected state"); + + let clipboardChanged = helper.waitForRawClipboardChange(); + + helper.endX = 400; + helper.endY = 400; + + helper.clickCopyButton(); + + info("Waiting for clipboard change"); + await clipboardChanged; + + let result = await helper.getImageSizeAndColorFromClipboard(); + + info("result: " + JSON.stringify(result, null, 2)); + + let expected = Math.floor( + 300 * (await getContentDevicePixelRatio(browser)) + ); + + 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` + ); + } + ); +}); 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..5e9937f8e1 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_focus_test.js @@ -0,0 +1,44 @@ +/* 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 testPanelFocused() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await clearAllTelemetryEvents(); + let helper = new ScreenshotsHelper(browser); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + let screenshotsButtons = gBrowser.selectedBrowser.ownerDocument + .querySelector("#screenshotsPagePanel") + .querySelector("screenshots-buttons").shadowRoot; + + let focusedElement = screenshotsButtons.querySelector(".visible-page"); + + is( + focusedElement, + screenshotsButtons.activeElement, + "Visible button is focused" + ); + + EventUtils.synthesizeKey("KEY_Escape"); + + await helper.waitForOverlayClosed(); + + await assertScreenshotsEvents(SCREENSHOTS_EVENTS); + } + ); +}); 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..b66a4d2b57 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_overlay_panel_sync.js @@ -0,0 +1,82 @@ +/* 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.waitForStateChange("selected"); + let state = await helper.getOverlayState(); + is(state, "selected", "The overlay is in the selected state"); + + helper.assertPanelNotVisible(); + + mouse.click(600, 600); + + await helper.waitForStateChange("crosshairs"); + state = await helper.getOverlayState(); + is(state, "crosshairs", "The overlay is in the crosshairs state"); + + 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.waitForStateChange("selected"); + state = await helper.getOverlayState(); + is(state, "selected", "The overlay is in the selected state"); + + await BrowserTestUtils.switchTab(gBrowser, tab1); + await BrowserTestUtils.switchTab(gBrowser, screenshotsTab); + + Assert.ok(await helper.isOverlayInitialized(), "Overlay is open"); + helper.assertPanelNotVisible(); + state = await helper.getOverlayState(); + is(state, "selected", "The overlay is in the selected state"); + + 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..e228e304b8 --- /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: TEST_PAGE, + }, + async browser => { + await clearAllTelemetryEvents(); + await SpecialPowers.spawn(browser, [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..f31b9cc71d --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_short_page_test.js @@ -0,0 +1,148 @@ +/* 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 dimensions = await helper.getSelectionLayerDimensions(); + 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" + ); + } + ); +}); + +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; + + window.resizeTo(SMALL_WINDOW_SIZE, SMALL_WINDOW_SIZE); + await TestUtils.waitForCondition(() => { + info( + `Got ${window.outerWidth}x${ + window.outerHeight + }. Expecting ${SMALL_WINDOW_SIZE}. ${ + window.outerHeight === SMALL_WINDOW_SIZE + } ${window.outerWidth === SMALL_WINDOW_SIZE}` + ); + return ( + window.outerHeight === SMALL_WINDOW_SIZE && + window.outerWidth === SMALL_WINDOW_SIZE + ); + }, "Waiting for window to resize"); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + await helper.dragOverlay(10, 10, 100, 100); + + let dimensions = await helper.getSelectionLayerDimensions(); + let oldWidth = dimensions.scrollWidth; + let oldHeight = dimensions.scrollHeight; + + window.resizeTo(BIG_WINDOW_SIZE, BIG_WINDOW_SIZE); + await TestUtils.waitForCondition( + () => + window.outerHeight === BIG_WINDOW_SIZE && + window.outerWidth === BIG_WINDOW_SIZE, + "Waiting for window to resize" + ); + await helper.waitForSelectionLayerDimensionChange(oldWidth, oldHeight); + + contentInfo = await helper.getContentDimensions(); + dimensions = await helper.getSelectionLayerDimensions(); + 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; + + window.resizeTo(SMALL_WINDOW_SIZE, SMALL_WINDOW_SIZE); + await TestUtils.waitForCondition( + () => + window.outerHeight === SMALL_WINDOW_SIZE && + window.outerWidth === SMALL_WINDOW_SIZE, + "Waiting for window to resize" + ); + await helper.waitForSelectionLayerDimensionChange(oldWidth, oldHeight); + + contentInfo = await helper.getContentDimensions(); + dimensions = await helper.getSelectionLayerDimensions(); + 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" + ); + + ok( + dimensions.scrollWidth < BIG_WINDOW_SIZE, + "Screenshots overlay is smaller than the big window width" + ); + ok( + dimensions.scrollHeight < BIG_WINDOW_SIZE, + "Screenshots overlay is smaller than the big window height" + ); + + window.resizeTo(originalWindowWidth, originalWindowHeight); + await TestUtils.waitForCondition( + () => + window.outerHeight === originalWindowHeight && + window.outerWidth === originalWindowWidth, + "Waiting for window to resize" + ); + } + ); +}); 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..74c2335beb --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_telemetry_tests.js @@ -0,0 +1,309 @@ +/* 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: "started", object: "overlay_retry" }, + { category: "screenshots", method: "selected", object: "element" }, +]; + +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: TEST_PAGE, + }, + async browser => { + await clearAllTelemetryEvents(); + let helper = new ScreenshotsHelper(browser); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlayClosed(); + + EventUtils.synthesizeKey("s", { shiftKey: true, accelKey: true }); + await helper.waitForOverlay(); + + EventUtils.synthesizeKey("s", { shiftKey: true, accelKey: true }); + await helper.waitForOverlayClosed(); + + 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; + + contextMenu.activateItem( + contextMenu.querySelector("#context-take-screenshot") + ); + await helper.waitForOverlayClosed(); + + 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"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + await helper.waitForOverlayClosed(); + + 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"); + cancelButton.click(); + + await helper.waitForOverlayClosed(); + + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + await helper.dragOverlay(50, 50, 300, 300); + helper.clickCancelButton(); + + await helper.waitForOverlayClosed(); + + 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); + + 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 copyButton = dialog._frame.contentDocument.getElementById("copy"); + + let clipboardChanged = helper.waitForRawClipboardChange(); + + // click copy button on dialog box + copyButton.click(); + + info("Waiting for clipboard change"); + await clipboardChanged; + helper.triggerUIFromToolbar(); + await helper.waitForOverlay(); + + await helper.dragOverlay(50, 50, 300, 300); + + clipboardChanged = helper.waitForRawClipboardChange(); + + helper.clickCopyButton(); + + info("Waiting for clipboard change"); + await clipboardChanged; + + 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); + + mouse.click(11, 11); + await helper.waitForStateChange("crosshairs"); + + await helper.clickTestPageElement(); + + await assertScreenshotsEvents(CONTENT_EVENTS, "content"); + } + ); +}); 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..3f7af492fa --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js @@ -0,0 +1,185 @@ +/* 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); + + 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"); + + // 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 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(); + + 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(); + } + ); +}); 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..c66b4c77d9 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js @@ -0,0 +1,173 @@ +/* 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"); + + // 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(); + + // click copy button on dialog box + copyButton.click(); + + info("Waiting for clipboard change"); + await clipboardChanged; + + let result = await helper.getImageSizeAndColorFromClipboard(); + info("result: " + JSON.stringify(result, null, 2)); + info("contentInfo: " + JSON.stringify(contentInfo, null, 2)); + + Assert.equal( + contentInfo.scrollWidth, + result.width, + "Widths should be equal" + ); + + Assert.equal( + contentInfo.scrollHeight, + result.height, + "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"); + + // 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(); + + // click copy button on dialog box + copyButton.click(); + + info("Waiting for clipboard change"); + await clipboardChanged; + + let result = await helper.getImageSizeAndColorFromClipboard(); + info("result: " + JSON.stringify(result, null, 2)); + info("contentInfo: " + JSON.stringify(contentInfo, null, 2)); + + Assert.equal( + contentInfo.scrollWidth, + result.width, + "Widths should be equal" + ); + + Assert.equal( + contentInfo.scrollHeight, + result.height, + "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..4b65110c50 --- /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.is_hidden(panel); + } + ); + + await BrowserTestUtils.crashFrame(browser); + + await waitForPanelHide; + ok( + BrowserTestUtils.is_hidden(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.is_hidden(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..1c1bac32db --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_screenshot_too_big.js @@ -0,0 +1,86 @@ +/* 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 32767"); + is( + rect.height, + Math.floor(MAX_CAPTURE_AREA / MAX_CAPTURE_DIMENSION), + "The height is 124925329 / 32767" + ); + + rect.width = 40000; + rect.hegith = 1; + + ScreenshotsUtils.cropScreenshotRectIfNeeded(rect); + + is( + rect.width, + MAX_CAPTURE_DIMENSION, + "The width was cropped to the max capture dimension (32767)." + ); + + rect.width = 1; + rect.height = 40000; + + ScreenshotsUtils.cropScreenshotRectIfNeeded(rect); + + is( + rect.height, + MAX_CAPTURE_DIMENSION, + "The height was cropped to the max capture dimension (32767)." + ); + + rect.width = 12345; + rect.height = 12345; + + ScreenshotsUtils.cropScreenshotRectIfNeeded(rect); + + is(rect.width, 12345, "The width was not cropped"); + is( + rect.height, + Math.floor(MAX_CAPTURE_AREA / 12345), + "The height was cropped to 10119" + ); + + 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..1bd1933799 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js @@ -0,0 +1,287 @@ +/* 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", +}); +XPCOMUtils.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: 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.is_visible(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.is_visible(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.is_visible(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.is_visible(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: 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: 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(); +}); 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..26d28566c8 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_visible.js @@ -0,0 +1,345 @@ +/* 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); + + // 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(); + + // click copy button on dialog box + copyButton.click(); + + info("Waiting for clipboard change"); + await clipboardChanged; + + let result = await helper.getImageSizeAndColorFromClipboard(); + info("result: " + JSON.stringify(result, null, 2)); + info("contentInfo: " + JSON.stringify(contentInfo, null, 2)); + + let expectedWidth = Math.floor( + devicePixelRatio * contentInfo.clientWidth + ); + Assert.equal(result.width, expectedWidth, "Widths should be equal"); + + let expectedHeight = Math.floor( + devicePixelRatio * contentInfo.clientHeight + ); + 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_visibleScreenshotScrolled() { + 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"); + + // 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(); + + // click copy button on dialog box + copyButton.click(); + + info("Waiting for clipboard change"); + await clipboardChanged; + + let result = await helper.getImageSizeAndColorFromClipboard(); + + info("result: " + JSON.stringify(result, null, 2)); + info("contentInfo: " + JSON.stringify(contentInfo, null, 2)); + + let expectedWidth = Math.floor( + devicePixelRatio * contentInfo.clientWidth + ); + Assert.equal(result.width, expectedWidth, "Widths should be equal"); + + let expectedHeight = Math.floor( + devicePixelRatio * contentInfo.clientHeight + ); + 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_visibleScreenshotScrolled() { + 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"); + + // 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(); + + // click copy button on dialog box + copyButton.click(); + + info("Waiting for clipboard change"); + await clipboardChanged; + + let result = await helper.getImageSizeAndColorFromClipboard(); + + info("result: " + JSON.stringify(result, null, 2)); + info("contentInfo: " + JSON.stringify(contentInfo, null, 2)); + + let expectedWidth = Math.floor( + devicePixelRatio * contentInfo.clientWidth + ); + Assert.equal(result.width, expectedWidth, "Widths should be equal"); + + let expectedHeight = Math.floor( + devicePixelRatio * contentInfo.clientHeight + ); + 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_visibleScreenshotScrolled() { + 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"); + + // 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(); + + // click copy button on dialog box + copyButton.click(); + + info("Waiting for clipboard change"); + await clipboardChanged; + + let result = await helper.getImageSizeAndColorFromClipboard(); + + info("result: " + JSON.stringify(result, null, 2)); + info("contentInfo: " + JSON.stringify(contentInfo, null, 2)); + + let expectedWidth = Math.floor( + devicePixelRatio * contentInfo.clientWidth + ); + Assert.equal(result.width, expectedWidth, "Widths should be equal"); + + let expectedHeight = Math.floor( + devicePixelRatio * contentInfo.clientHeight + ); + 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/head.js b/browser/components/screenshots/tests/browser/head.js new file mode 100644 index 0000000000..5ab054a70c --- /dev/null +++ b/browser/components/screenshots/tests/browser/head.js @@ -0,0 +1,681 @@ +/* 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", + "http://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 MAX_CAPTURE_DIMENSION = 32767; +const MAX_CAPTURE_AREA = 124925329; + +const gScreenshotUISelectors = { + panelButtons: "#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, selector = ":root") { + if (name === "click") { + this.down(x, y); + this.up(x, y); + } else { + await safeSynthesizeMouseEventInContentPage(selector, x, y, { + type: "mouse" + name, + }); + } + }, + } + ), +}; + +const { mouse } = MouseEvents; + +class ScreenshotsHelper { + constructor(browser) { + this.browser = browser; + this.selector = gScreenshotUISelectors; + } + + get toolbarButton() { + return document.getElementById("screenshot-button"); + } + + /** + * Click the screenshots button in the toolbar + */ + triggerUIFromToolbar() { + let button = this.toolbarButton; + ok( + BrowserTestUtils.is_visible(button), + "The screenshot toolbar button is visible" + ); + button.click(); + } + + async waitForPanel() { + let panel = this.browser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + await BrowserTestUtils.waitForCondition(async () => { + if (!panel) { + panel = this.browser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + } + return panel?.state === "open" && BrowserTestUtils.is_visible(panel); + }); + return panel; + } + + async waitForOverlay() { + const panel = await this.waitForPanel(); + ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible"); + + await BrowserTestUtils.waitForCondition(async () => { + let init = await this.isOverlayInitialized(); + return init; + }); + info("Overlay is visible"); + } + + async waitForOverlayClosed() { + let panel = this.browser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + if (!panel) { + panel = await this.waitForPanel(); + } + await BrowserTestUtils.waitForMutationCondition( + panel, + { attributes: true }, + () => { + return BrowserTestUtils.is_hidden(panel); + } + ); + ok(BrowserTestUtils.is_hidden(panel), "Panel buttons are hidden"); + + 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; + }); + } + + async getOverlayState() { + return ContentTask.spawn(this.browser, null, async () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + return screenshotsChild._overlay.stateHandler.getState(); + }); + } + + async waitForStateChange(newState) { + await BrowserTestUtils.waitForCondition(async () => { + let state = await this.getOverlayState(); + return state === newState; + }, `Waiting for state change to ${newState}`); + } + + async getHoverElementRect() { + return ContentTask.spawn(this.browser, null, async () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + return screenshotsChild._overlay.stateHandler.getHoverElementBoxRect(); + }); + } + + async waitForHoverElementRect() { + return TestUtils.waitForCondition(async () => { + let rect = await this.getHoverElementRect(); + return rect; + }); + } + + async waitForSelectionBoxSizeChange(currentWidth) { + await ContentTask.spawn( + this.browser, + [currentWidth], + async ([currWidth]) => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + + let dimensions = + screenshotsChild._overlay.screenshotsContainer.getSelectionLayerDimensions(); + // return dimensions.boxWidth; + await ContentTaskUtils.waitForCondition(() => { + dimensions = + screenshotsChild._overlay.screenshotsContainer.getSelectionLayerDimensions(); + return dimensions.boxWidth !== 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) { + await this.waitForStateChange("crosshairs"); + let state = await this.getOverlayState(); + Assert.equal(state, "crosshairs", "The overlay is in the crosshairs state"); + + mouse.down(startX, startY); + + await this.waitForStateChange("draggingReady"); + state = await this.getOverlayState(); + Assert.equal( + state, + "draggingReady", + "The overlay is in the draggingReady state" + ); + + mouse.move(endX, endY); + + await this.waitForStateChange("dragging"); + state = await this.getOverlayState(); + Assert.equal(state, "dragging", "The overlay is in the dragging state"); + + mouse.up(endX, endY); + + await this.waitForStateChange("selected"); + state = await this.getOverlayState(); + Assert.equal(state, "selected", "The overlay is in the selected state"); + + this.endX = endX; + this.endY = endY; + } + + async scrollContentWindow(x, y) { + let promise = BrowserTestUtils.waitForContentEvent(this.browser, "scroll"); + await ContentTask.spawn(this.browser, [x, y], async ([xPos, yPos]) => { + content.window.scroll(xPos, yPos); + + await ContentTaskUtils.waitForCondition(() => { + return ( + content.window.scrollX === xPos && content.window.scrollY === yPos + ); + }, `Waiting for window to scroll to ${xPos}, ${yPos}`); + }); + await promise; + } + + getWindowPosition() { + return ContentTask.spawn(this.browser, [], () => { + return { + scrollX: content.window.scrollX, + scrollY: content.window.scrollY, + }; + }); + } + + 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}`); + }); + } + + clickDownloadButton() { + // Click the download button with last x and y position from dragOverlay. + // The middle of the copy button is last X - 70 and last Y + 36. + // Ex. 500, 500 would be 530, 536 + mouse.click(this.endX - 70, this.endY + 36); + } + + clickCopyButton(overrideX = null, overrideY = null) { + // Click the copy button with last x and y position from dragOverlay. + // The middle of the copy button is last X - 183 and last Y + 36. + // Ex. 500, 500 would be 317, 536 + if (overrideX && overrideY) { + mouse.click(overrideX - 183, overrideY + 36); + } else { + mouse.click(this.endX - 183, this.endY + 36); + } + } + + clickCancelButton() { + // Click the cancel button with last x and y position from dragOverlay. + // The middle of the copy button is last X - 259 and last Y + 36. + // Ex. 500, 500 would be 241, 536 + mouse.click(this.endX - 259, this.endY + 36); + } + + async clickTestPageElement() { + let rect = await ContentTask.spawn(this.browser, [], async () => { + let ele = content.document.getElementById("testPageElement"); + return ele.getBoundingClientRect(); + }); + + let x = Math.floor(rect.x + rect.width / 2); + let y = Math.floor(rect.y + rect.height / 2); + + mouse.move(x, y); + await this.waitForHoverElementRect(); + mouse.down(x, y); + await this.waitForStateChange("draggingReady"); + mouse.up(x, y); + await this.waitForStateChange("selected"); + } + + async zoomBrowser(zoom) { + 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); + }); + } + + /** + * 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() { + let panel = this.browser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + Assert.ok( + BrowserTestUtils.is_visible(panel), + "Screenshots panel is visible" + ); + } + + assertPanelNotVisible() { + let panel = this.browser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + Assert.ok( + BrowserTestUtils.is_hidden(panel), + "Screenshots panel is not visible" + ); + } + + /** + * Copied from screenshots extension + * Returns a promise that resolves when the clipboard data has changed + * Otherwise rejects + */ + waitForRawClipboardChange() { + const initialClipboardData = Date.now().toString(); + SpecialPowers.clipboardCopyString(initialClipboardData); + + let promiseChanged = TestUtils.waitForCondition(() => { + let data; + try { + data = getRawClipboardData("image/png"); + } catch (e) { + console.log("Failed to get image/png clipboard data:", e); + return false; + } + return data && initialClipboardData !== data; + }); + return promiseChanged; + } + + /** + * 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 + */ + getContentDimensions() { + return SpecialPowers.spawn(this.browser, [], async function () { + let { innerWidth, innerHeight, scrollMaxX, scrollMaxY } = 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, + }; + }); + } + + getSelectionLayerDimensions() { + return ContentTask.spawn(this.browser, null, async () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + Assert.ok(screenshotsChild._overlay._initialized, "The overlay exists"); + + return screenshotsChild._overlay.screenshotsContainer.getSelectionLayerDimensions(); + }); + } + + async waitForSelectionLayerDimensionChange(oldWidth, oldHeight) { + await ContentTask.spawn( + this.browser, + [oldWidth, oldHeight], + async ([prevWidth, prevHeight]) => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + + await ContentTaskUtils.waitForCondition(() => { + let dimensions = + screenshotsChild._overlay.screenshotsContainer.getSelectionLayerDimensions(); + info( + `old height: ${prevHeight}. new height: ${dimensions.scrollHeight}.\nold width: ${prevWidth}. new width: ${dimensions.scrollWidth}` + ); + return ( + dimensions.scrollHeight !== prevHeight && + dimensions.scrollWidth !== prevWidth + ); + }, "Wait for selection box width change"); + } + ); + } + + getSelectionBoxDimensions() { + return ContentTask.spawn(this.browser, null, async () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + Assert.ok(screenshotsChild._overlay._initialized, "The overlay exists"); + + return screenshotsChild._overlay.screenshotsContainer.getSelectionLayerBoxDimensions(); + }); + } + + /** + * Clicks an element on the screen + * @param eleSel The selector for the element to click + */ + async clickUIElement(eleSel) { + await SpecialPowers.spawn( + this.browser, + [eleSel], + async function (eleSelector) { + info( + `in clickScreenshotsUIElement content function, eleSelector: ${eleSelector}` + ); + const EventUtils = ContentTaskUtils.getEventUtils(content); + let ele = content.document.querySelector(eleSelector); + info(`Found the thing to click: ${eleSelector}: ${!!ele}`); + + EventUtils.synthesizeMouseAtCenter(ele, {}); + // wait a frame for the screenshots UI to finish any init + await new content.Promise(res => content.requestAnimationFrame(res)); + } + ); + } + + /** + * 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); + ok(image, "screenshot data exists on the clipboard"); + + // 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); + is( + binaryStream.readArrayBuffer(available, buffer), + available, + "Read expected amount of data" + ); + + // 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); + 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 + ); + 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") { + 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: true }, + { process } + ); +} 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 @@ + + + + + Screenshots + + + + 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 @@ + + + Screenshots + + +
+ + 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..4a3f7b0304 --- /dev/null +++ b/browser/components/screenshots/tests/browser/test-page.html @@ -0,0 +1,10 @@ + + + + + Screenshots + + +
+ + -- cgit v1.2.3