From 43a97878ce14b72f0981164f87f2e35e14151312 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 11:22:09 +0200 Subject: Adding upstream version 110.0.1. Signed-off-by: Daniel Baumann --- .../screenshots/ScreenshotsOverlayChild.sys.mjs | 2028 ++++++++++++++++++++ .../screenshots/ScreenshotsUtils.sys.mjs | 459 +++++ 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 | 535 ++++++ .../screenshots/content/screenshots.html | 44 + .../components/screenshots/content/screenshots.js | 97 + browser/components/screenshots/fileHelpers.mjs | 278 +++ browser/components/screenshots/jar.mn | 23 + browser/components/screenshots/moz.build | 19 + browser/components/screenshots/overlay/overlay.css | 435 +++++ .../components/screenshots/screenshots-buttons.css | 31 + .../components/screenshots/screenshots-buttons.js | 55 + .../screenshots/tests/browser/browser.ini | 25 + .../browser_screenshots_drag_scroll_test.js | 317 +++ .../tests/browser/browser_screenshots_drag_test.js | 475 +++++ .../browser/browser_screenshots_focus_test.js | 34 + .../browser_screenshots_overlay_panel_sync.js | 82 + .../browser/browser_screenshots_page_unload.js | 72 + .../browser/browser_screenshots_short_page_test.js | 40 + .../browser/browser_screenshots_test_downloads.js | 137 ++ .../browser/browser_screenshots_test_escape.js | 25 + .../browser/browser_screenshots_test_full_page.js | 192 ++ .../browser/browser_screenshots_test_page_crash.js | 70 + .../browser_screenshots_test_toggle_pref.js | 288 +++ .../browser_screenshots_test_toolbar_button.js | 26 + .../browser/browser_screenshots_test_visible.js | 374 ++++ .../components/screenshots/tests/browser/head.js | 496 +++++ .../screenshots/tests/browser/short-test-page.html | 11 + .../screenshots/tests/browser/test-page.html | 11 + 36 files changed, 6711 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_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_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/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..18c2227d2b --- /dev/null +++ b/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs @@ -0,0 +1,2028 @@ +/* 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 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(); + + this.hoverElementBox = new HoverElementBox( + this.hoverBoxId, + this.content, + document + ); + + this.previewLayer = new PreviewLayer(this.previewId, this.content); + this.selectionLayer = new SelectionLayer( + this.selectionId, + this.content, + this.hoverElementBox + ); + + this.screenshotsContainer = new ScreenshotsContainerLayer( + this.overlayId, + this.content, + this.previewLayer, + this.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() { + this.addEventListenerForElement( + "screenshots-cancel-button", + "click", + (event, targetId) => { + this.screenshotsChild.requestCancelScreenshot(); + } + ); + this.addEventListenerForElement("cancel", "click", (event, targetId) => { + this.screenshotsChild.requestCancelScreenshot(); + }); + 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}
+
${cancel.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; + + constructor(screenshotsContainer, screenshotsChild) { + this.#state = "crosshairs"; + this.#lastBox = {}; + + this.#screenshotsContainer = screenshotsContainer; + this.#screenshotsChild = screenshotsChild; + } + + setState(newState) { + this.#state = newState; + this.start(); + } + + getState() { + return this.#state; + } + + /** + * 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: viewX, clientY: viewY, pageX, pageY } = event; + + MAX_DETECT_HEIGHT = Math.max(event.target.clientHeight + 100, 700); + MAX_DETECT_WIDTH = Math.max(event.target.clientWidth + 100, 1000); + + return { viewX, viewY, 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, viewX, viewY } = this.getCoordinates(event); + + switch (this.#state) { + case "crosshairs": { + this.crosshairsMove(pageX, pageY, viewX, viewY, 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, viewX, viewY } = this.getCoordinates(event); + + switch (this.#state) { + case "draggingReady": { + this.draggingReadyDragEnd(pageX - viewX, pageY - viewY); + 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(); + } + + /** + * + */ + 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 clientX x coordinate + * @param clientY y coordinate + */ + crosshairsDragStart(clientX, clientY) { + this.#screenshotsContainer.setSelectionBoxDimensions({ + left: clientX, + top: clientY, + right: clientX, + bottom: clientY, + }); + + this.setState("draggingReady"); + } + + /** + * If the background is clicked we set the state to crosshairs + * otherwise set the state to resizing + * @param clientX x coordinate + * @param clientY y coordinate + * @param targetId The id of the event target + */ + selectedDragStart(clientX, clientY, targetId) { + if (targetId === this.#screenshotsContainer.id) { + this.setState("crosshairs"); + return; + } + this.#moverId = targetId; + this.#lastX = clientX; + this.#lastY = clientY; + + this.setState("resizing"); + } + + /** + * Handles the pointer move for the crosshairs state + * @param pageX x pointer position + * @param pageY y pointer position + * @param viewX x pointer position in viewport + * @param viewY y pointer position in viewport + * @param targetId The id of the target element + */ + crosshairsMove(pageX, pageY, viewX, viewY, targetId) { + this.#screenshotsContainer.drawPreviewEyes(pageX, pageY); + + this.#screenshotsContainer.handleElementHover(viewX, viewY, targetId); + } + + /** + * Set the bottom and right coordinates of the box and draw the box + * @param clientX x coordinate + * @param clientY y coordinate + */ + draggingDrag(clientX, clientY) { + this.#screenshotsContainer.setSelectionBoxDimensions({ + right: clientX, + bottom: clientY, + }); + + this.#screenshotsContainer.drawSelectionBox(); + } + + /** + * If the mouse has moved at least 40 pixels then set the state to "dragging" + * @param clientX x coordinate + * @param clientY y coordinate + */ + draggingReadyDrag(clientX, clientY) { + this.#screenshotsContainer.setSelectionBoxDimensions({ + right: clientX, + bottom: clientY, + }); + + if (this.#screenshotsContainer.selectionBoxDistance() > 40) { + this.setState("dragging"); + } + } + + /** + * Depending on what mover was selected we will resize the box accordingly + * @param clientX x coordinate + * @param clientY y coordinate + */ + resizingDrag(clientX, clientY) { + switch (this.#moverId) { + case "mover-topLeft": { + this.#screenshotsContainer.setSelectionBoxDimensions({ + left: clientX, + top: clientY, + }); + break; + } + case "mover-top": { + this.#screenshotsContainer.setSelectionBoxDimensions({ top: clientY }); + break; + } + case "mover-topRight": { + this.#screenshotsContainer.setSelectionBoxDimensions({ + top: clientY, + right: clientX, + }); + break; + } + case "mover-right": { + this.#screenshotsContainer.setSelectionBoxDimensions({ + right: clientX, + }); + break; + } + case "mover-bottomRight": { + this.#screenshotsContainer.setSelectionBoxDimensions({ + right: clientX, + bottom: clientY, + }); + break; + } + case "mover-bottom": { + this.#screenshotsContainer.setSelectionBoxDimensions({ + bottom: clientY, + }); + break; + } + case "mover-bottomLeft": { + this.#screenshotsContainer.setSelectionBoxDimensions({ + left: clientX, + bottom: clientY, + }); + break; + } + case "mover-left": { + this.#screenshotsContainer.setSelectionBoxDimensions({ left: clientX }); + break; + } + case "highlight": { + let lastBox = this.#lastBox; + let diffX = this.#lastX - clientX; + let diffY = this.#lastY - clientY; + + 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 = clientX; + this.#lastY = clientY; + 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"); + } else { + this.setState("crosshairs"); + } + } + + /** + * Draw the box one last time and set the state to "selected" + * @param clientX x coordinate + * @param clientY y coordinate + */ + draggingDragEnd(clientX, clientY) { + this.#screenshotsContainer.setSelectionBoxDimensions({ + right: clientX, + bottom: clientY, + }); + this.#screenshotsContainer.sortSelectionLayerBoxCoords(); + this.setState("selected"); + } + + /** + * Draw the box one last time and set the state to "selected" + * @param clientX x coordinate + * @param clientY y coordinate + */ + resizingDragEnd(clientX, clientY, targetId) { + this.resizingDrag(clientX, clientY, 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) { + this.#screenshotsContainer.updateSize(win); + + if (this.#state === "selected" && eventType === "resize") { + this.#screenshotsContainer.shiftSelectionLayerBox(); + } else if (this.#state && eventType === "scroll") { + this.#screenshotsContainer.drawButtonsLayer(); + if (this.#state === "crosshairs") { + this.#screenshotsContainer.handleElementScroll(); + } + } + } +} + +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 viewX The viewport x coordinate + * @param viewY The viewport y coordinate + * @param targetId The target element id + */ + handleElementHover(viewX, viewY, targetId) { + if (targetId === "screenshots-overlay-container") { + let ele = this.getElementFromPoint(viewX, viewY); + + if (this.cachedEle && this.cachedEle === ele) { + // Still hovering over the same element + return; + } + this.cachedEle = ele; + + this.getBestRectForElement(ele); + + this.#lastX = viewX; + this.#lastY = viewY; + } + } + + /** + * Handles moving the rect when the user has scrolled but not moved the mouse + * It uses the last x and y viewport 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 viewport x coordinate + * @param y The viewport y coordinate + * @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 + * innerWidth: the viewport width + * innerHeight: 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 + * innerWidth: the viewport width + * innerHeight: 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 viewX The viewport x coordinate + * @param viewY The viewport y coordinate + * @param targetId The target element id + */ + handleElementHover(viewX, viewY, targetId) { + this.#hoverElementBox.handleElementHover(viewX, viewY, targetId); + } + + /** + * Handles moving the rect when the user has scrolled but not moved the mouse + * It uses the last x and y viewport 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 innerWidth() { + return this.#documentDimensions.innerWidth; + } + set innerWidth(val) { + this.#documentDimensions.innerWidth = val; + } + + get innerHeight() { + return this.#documentDimensions.innerHeight; + } + set innerHeight(val) { + this.#documentDimensions.innerHeight = 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 curWidth = this.width; + + this.bottom -= yDiff; + this.top = this.bottom - curWidth; + + 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, + innerWidth, + innerHeight, + } = this.#selectionLayer.getDimensions(); + + if ( + boxTop > scrollY + innerHeight || + boxBottom < scrollY || + boxLeft > scrollX + innerWidth || + 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 + innerHeight - boxBottom < 70) { + if (boxBottom < scrollY + innerHeight) { + top = boxBottom - 60; + } else if (scrollY + innerHeight - boxTop < 70) { + top = boxTop - 60; + } else { + top = scrollY + innerHeight - 60; + } + } + if (boxRight < 265) { + 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(); + } + + /** + * 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 viewX The viewport x coordinate + * @param viewY The viewport y coordinate + * @param targetId The target element id + */ + handleElementHover(viewX, viewY, targetId) { + this.#selectionLayer.handleElementHover(viewX, viewY, targetId); + } + + /** + * Handles moving the rect when the user has scrolled but not moved the mouse + * It uses the last x and y viewport 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 - this.#selectionLayer.scrollX, + clientY - this.#selectionLayer.scrollY, + this.#selectionLayer.innerWidth, + this.#selectionLayer.innerHeight + ); + } + + /** + * 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 + * innerWidth: the viewport width + * innerHeight: 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); + } + + /** + * 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 { innerWidth, innerHeight, scrollX, scrollY } = win; + this.#selectionLayer.innerWidth = innerWidth; + this.#selectionLayer.innerHeight = innerHeight; + this.#selectionLayer.scrollX = scrollX; + this.#selectionLayer.scrollY = scrollY; + + const doc = win.document.documentElement; + let width = Math.max.apply( + null, + Array.from(doc.children, x => x.scrollWidth) + ); + let height = Math.max.apply( + null, + Array.from(doc.children, x => x.scrollHeight) + ); + + if (width < 1) { + width = doc.scrollWidth; + } else if (width < innerWidth) { + width = innerWidth; + } + + if (height < 1) { + height = doc.scrollHeight; + } else if (height < innerHeight) { + height = innerHeight; + } + + this.#selectionLayer.scrollWidth = width; + this.#selectionLayer.scrollHeight = height; + + this.#width = width; + this.#height = height; + + 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..d0f827acf3 --- /dev/null +++ b/browser/components/screenshots/ScreenshotsUtils.sys.mjs @@ -0,0 +1,459 @@ +/* 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"; + +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", +}); + +const PanelPosition = "bottomright topright"; +const PanelOffsetX = -33; +const PanelOffsetY = -8; + +export class ScreenshotsComponentParent extends JSWindowActorParent { + async receiveMessage(message) { + let browser = message.target.browsingContext.topFrameElement; + switch (message.name) { + case "Screenshots:CancelScreenshot": + await ScreenshotsUtils.closePanel(browser); + 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.createOrDisplayButtons(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.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) { + if (event.type === "keydown" && event.key === "Escape") { + this.closePanel(event.view.gBrowser.selectedBrowser, true); + } + }, + 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); + } + 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" + ); + } 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 and call child actor to open the overlay + * @param browser The current browser + */ + openPanel(browser) { + let actor = this.getActor(browser); + actor.sendQuery("Screenshots:ShowOverlay"); + this.createOrDisplayButtons(browser); + }, + /** + * 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 = browser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + 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) { + let buttonsPanel = browser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + let isOverlayShowing = await this.getActor(browser).sendQuery( + "Screenshots:isOverlayShowing" + ); + if (buttonsPanel && (isOverlayShowing || buttonsPanel.state !== "closed")) { + buttonsPanel.hidePopup(); + let actor = this.getActor(browser); + return actor.sendQuery("Screenshots:HideOverlay"); + } + let actor = this.getActor(browser); + actor.sendQuery("Screenshots:ShowOverlay"); + return this.createOrDisplayButtons(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; + }, + /** + * 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 = doc.querySelector("#screenshotsPagePanel"); + if (!buttonsPanel) { + let template = doc.querySelector("#screenshotsPagePanelTemplate"); + let clone = template.content.cloneNode(true); + template.replaceWith(clone); + buttonsPanel = doc.querySelector("#screenshotsPagePanel"); + } + + 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"); + }, + /** + * 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); + } else { + rect = await this.fetchVisibleBounds(browser); + } + 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) { + 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(); + }, + /** + * Copy the image to the clipboard + * @param dataUrl The image data + */ + copyScreenshot(dataUrl) { + // 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 + ); + }, + /** + * 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(); + }, + /** + * 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) {} + }, +}; 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..48fe7c3038 --- /dev/null +++ b/browser/components/screenshots/content/screenshots.css @@ -0,0 +1,535 @@ +/* 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 { + padding: 0; + margin: 0; + background-color: transparent; + height: 100vh; + width: 100vw; +} + +.button, +.preview-button { + display: flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 3px; + cursor: pointer; + font-size: 16px; + font-weight: 400; + height: 40px; + min-width: 40px; + outline: none; + padding: 0 10px; + position: relative; + text-align: center; + text-decoration: none; + transition: background 150ms cubic-bezier(0.07, 0.95, 0, 1), border 150ms cubic-bezier(0.07, 0.95, 0, 1); + user-select: none; + white-space: nowrap; +} + +button img { + -moz-context-properties: fill; + fill: currentColor; +} + +.button.hidden, +.hidden.preview-button { + display: none; +} + +.button.small, +.small.preview-button { + height: 32px; + line-height: 32px; + padding: 0 8px; +} + +.button.active, +.active.preview-button { + background-color: #dedede; +} + +@media (forced-colors: active), (prefers-contrast) { + .button.active, + .active.preview-button { + background-color: ButtonFace; + } +} + +.button.tiny, +.tiny.preview-button { + font-size: 14px; + height: 26px; + border: 1px solid #c7c7c7; +} + +.button.tiny:hover, +.tiny.preview-button:hover, +.button.tiny:focus, +.tiny.preview-button:focus { + background: #ededf0; + border-color: #989898; +} + +.button.tiny:active, +.tiny.preview-button:active { + background: #dedede; + border-color: #989898; +} + +.button.block-button, +.block-button.preview-button { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + border: 0; + border-inline-end: 1px solid #c7c7c7; + box-shadow: none; + border-radius: 0; + flex-shrink: 0; + font-size: 20px; + height: 100px; + line-height: 100%; + overflow: hidden; +} + +@media (max-width: 719px) { + .button.block-button, + .block-button.preview-button { + justify-content: flex-start; + font-size: 16px; + height: 72px; + margin-inline-end: 10px; + padding: 0 5px; + } +} + +.button.block-button:hover, +.block-button.preview-button:hover { + background: #ededf0; +} + +.button.block-button:active, +.block-button.preview-button:active { + background: #dedede; +} + +@media (forced-colors: active), (prefers-contrast) { + .button.block-button:hover, + .block-button.preview-button:hover { + background-color: ButtonText; + } + + .button.block-button:active, + .block-button.preview-button:active { + background-color: ButtonFace; + } +} + +.button.download, +.download.preview-button, +.button.flag, +.flag.preview-button { + background-repeat: no-repeat; + background-size: 50%; + background-position: center; + margin-inline-end: 10px; + transition: background-color 150ms cubic-bezier(0.07, 0.95, 0, 1); +} + +.button.download, +.download.highlight-button-retry, +.download.highlight-button-cancel, +.download.highlight-button-download, +.download.highlight-button-copy { + background-image: url("chrome://browser/content/screenshots/download.svg"); +} + +.button.download:hover, +.download.preview-button:hover { + background-color: #ededf0; +} + +.button.download:active, +.download.preview-button:active { + background-color: #dedede; +} + +@media (forced-colors: active), (prefers-contrast) { + .button.download:hover, + .download.preview-button:hover { + background-color: ButtonText; + color: ButtonFace; + } + + .button.download:active, + .download.preview-button:active { + background-color: ButtonFace; + color: ButtonText; + } +} + +.button.primary, +.primary.highlight-retry-cancel, +.primary.highlight-button-cancel, +.highlight-button-download, +.primary.highlight-button-copy { + background-color: #0a84ff; + color: #fff; +} + +.button.primary:hover, +.primary.highlight-button-retry:hover, +.primary.highlight-button-cancel:hover, +.highlight-button-download:hover, +.primary.highlight-button-copy:hover, +.button.primary:focus, +.primary.highlight-button-retry:focus, +.primary.highlight-button-cancel:focus, +.highlight-button-download:focus, +.primary.highlight-button-copy:focus { + background-color: #0072e5; +} + +.button.primary:active, +.primary.highlight-button-retry:active, +.primary.highlight-button-cancel:active, +.highlight-button-download:active, +.primary.highlight-button-copy:active { + background-color: #0065cc; +} + +@media (forced-colors: active), (prefers-contrast) { + .button.primary, + .primary.highlight-retry-cancel, + .primary.highlight-button-cancel, + .highlight-button-download, + .primary.highlight-button-copy { + background-color: ButtonFace; + color: ButtonText; + } + + .button.primary:hover, + .primary.highlight-button-retry:hover, + .primary.highlight-button-cancel:hover, + .highlight-button-download:hover, + .primary.highlight-button-copy:hover, + .button.primary:focus, + .primary.highlight-button-retry:focus, + .primary.highlight-button-cancel:focus, + .highlight-button-download:focus, + .primary.highlight-button-copy:focus { + background-color: ButtonText; + color: ButtonFace; + } + + .button.primary:active, + .primary.highlight-button-retry:active, + .primary.highlight-button-cancel:active, + .highlight-button-download:active, + .primary.highlight-button-copy:active { + background-color: ButtonFace; + color: ButtonText; + } +} + +.button.secondary, +.highlight-button-retry, +.highlight-button-cancel, +.secondary.highlight-button-download, +.highlight-button-copy { + background-color: #f9f9fa; + color: #38383d; +} + +.button.secondary:hover, +.highlight-button-retry:hover, +.highlight-button-cancel:hover, +.secondary.highlight-button-download:hover, +.highlight-button-copy:hover { + background-color: #ededf0; +} + +.button.secondary:active, +.highlight-button-retry:active, +.highlight-button-cancel:active, +.secondary.highlight-button-download:active, +.highlight-button-copy:active { + background-color: #dedede; +} + +@media (forced-colors: active), (prefers-contrast) { + .button.secondary, + .highlight-button-retry, + .highlight-button-cancel, + .secondary.highlight-button-download, + .highlight-button-copy { + background-color: ButtonFace; + color: ButtonText; + } + + .button.secondary:hover, + .highlight-button-retry:hover, + .highlight-button-cancel:hover, + .secondary.highlight-button-download:hover, + .highlight-button-copy:hover { + background-color: ButtonText; + color: ButtonFace; + } + + .button.secondary:active, + .highlight-button-retry:active, + .highlight-button-cancel:active, + .secondary.highlight-button-download:active, + .highlight-button-copy:active { + background-color: ButtonFace; + color: ButtonText; + } +} + +.button.transparent, +.transparent.preview-button { + background-color: transparent; + color: #38383d; +} + +.button.transparent:hover, +.transparent.preview-button:hover { + background-color: #ededf0; +} + +.button.transparent:focus, +.transparent.preview-button:focus, +.button.transparent:active, +.transparent.preview-button:active { + background-color: #dedede; +} + +@media (forced-colors: active), (prefers-contrast) { + .button.transparent, + .transparent.preview-button { + background-color: ButtonFace; + color: ButtonText; + } + + .button.transparent:hover, + .transparent.preview-button:hover { + background-color: ButtonText; + color: ButtonFace; + } + + .button.transparent:focus, + .transparent.preview-button:focus, + .button.transparent:active, + .transparent.preview-button:active { + background-color: ButtonFace; + color: ButtonText; + } +} + +.button.warning, +.warning.preview-button { + color: #fff; + background: #d92215; +} + +.button.warning:hover, +.warning.preview-button:hover, +.button.warning:focus, +.warning.preview-button:focus { + background: #b81d12; +} + +.button.warning:active, +.warning.preview-button:active { + background: #a11910; +} + +@media (forced-colors: active), (prefers-contrast) { + .button.warning, + .warning.preview-button { + color: ButtonText; + background-color: ButtonFace; + } + + .button.warning:hover, + .warning.preview-button:hover, + .button.warning:focus, + .warning.preview-button:focus { + background-color: ButtonText; + color: ButtonFace; + } + + .button.warning:active, + .warning.preview-button:active { + background-color: ButtonFace; + } +} + +@keyframes bounce { + 0% { + transform: translateX(-40px); + } + 100% { + transform: translate(190px); + } +} + +@keyframes pulse { + 0% { + opacity: 0.3; + transform: scale(1); + } + 70% { + opacity: 0.25; + transform: scale(1.04); + } + 100% { + opacity: 0.3; + transform: scale(1); + } +} + +.highlight { + border-radius: 1px; + border: 2px dashed rgba(255, 255, 255, 0.8); + box-sizing: border-box; + cursor: move; + position: absolute; + z-index: 9999999999; +} + +/* When prefers contrast is fully supported, we should change these quereies to cover both high and low prefers contrast cases */ +@media (forced-colors: active) { + .highlight { + border: 2px dashed white; + opacity: 1.0; + } +} + +.highlight-button-cancel { + margin: 5px; + width: 40px; +} + +.highlight-button-download { + margin: 5px; + width: auto; + font-size: 18px; +} + +.highlight-button-download img { + height: 16px; + width: 16px; +} + +.highlight-button-download:-moz-locale-dir(rtl) { + flex-direction: column-reverse; +} + +.highlight-button-download img:-moz-locale-dir(ltr) { + padding-inline-end: 8px; +} + +.highlight-button-download img:-moz-locale-dir(rtl) { + padding-inline-start: 8px; +} + +.highlight-button-copy { + margin: 5px; + width: auto; +} + +.highlight-button-copy img { + height: 16px; + width: 16px; +} + +.highlight-button-copy:-moz-locale-dir(rtl) { + flex-direction: column-reverse; +} + +.highlight-button-copy img:-moz-locale-dir(ltr) { + padding-inline-end: 8px; +} + +.highlight-button-copy img:-moz-locale-dir(rtl) { + padding-inline-start: 8px; +} + +.preview-buttons { + display: flex; + align-items: center; + justify-content: flex-end; + padding-inline-end: 4px; + inset-inline-end: 0; + width: 100%; + height: 60px; + border-radius: 4px 4px 0 0; + background: rgba(249, 249, 250, 0.8); + top: 0; + border: 1px solid rgba(249, 249, 250, 0.2); + border-bottom: 0; + box-sizing: border-box; +} + +.preview-image { + background-color: rgba(249, 249, 250, 0.8); + height: 100%; + width: 100%; + overflow: auto; +} + +.preview-image-area { + margin: 10%; + margin-top: 2%; +} + +.image-view { + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + flex-direction: column; +} + +.left { + margin-inline-start: 0; +} + +.right { + margin-inline-start: 20px; +} + +#placeholder-image { + width: 100%; + height: 100%; +} + +@keyframes pulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.06); + } + 100% { + transform: scale(1); + } +} + +@keyframes fade-in { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} diff --git a/browser/components/screenshots/content/screenshots.html b/browser/components/screenshots/content/screenshots.html new file mode 100644 index 0000000000..e7ccc26797 --- /dev/null +++ b/browser/components/screenshots/content/screenshots.html @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/screenshots/content/screenshots.js b/browser/components/screenshots/content/screenshots.js new file mode 100644 index 0000000000..1436b3803f --- /dev/null +++ b/browser/components/screenshots/content/screenshots.js @@ -0,0 +1,97 @@ +/* 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"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +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(".highlight-button-retry"); + this._retryButton.addEventListener("click", this); + this._cancelButton = this.querySelector(".highlight-button-cancel"); + this._cancelButton.addEventListener("click", this); + this._copyButton = this.querySelector(".highlight-button-copy"); + this._copyButton.addEventListener("click", this); + this._downloadButton = this.querySelector(".highlight-button-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(); + } 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(); + } + + saveToClipboard(dataUrl) { + ScreenshotsUtils.copyScreenshot(dataUrl); + + this.close(); + } +} +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..172c8584f2 --- /dev/null +++ b/browser/components/screenshots/fileHelpers.mjs @@ -0,0 +1,278 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + Downloads: "resource://gre/modules/Downloads.jsm", + DownloadPaths: "resource://gre/modules/DownloadPaths.jsm", + DownloadLastDir: "resource://gre/modules/DownloadLastDir.jsm", +}); + +/** + * 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) || "_"; + 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("DefaultSaveFileName") + // 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 window + * 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, window) { + return (async function() { + let downloadLastDir = new lazy.DownloadLastDir(window); + + // 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 new Promise(resolve => { + downloadLastDir.getFileAsync(null, function getFileAsyncCB(aFile) { + resolve(aFile); + }); + }); + 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( + window, + 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..0c03e18ce7 --- /dev/null +++ b/browser/components/screenshots/overlay/overlay.css @@ -0,0 +1,435 @@ +/* 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 { + 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 #screenshots-cancel-button { + background-color: transparent; + width: fit-content; + cursor: pointer; + outline: none; + border-radius: 3px; + border: 1px #9b9b9b solid; + color: #fff; + font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif; + font-size: 16px; + margin-top: 40px; + padding: 10px 25px; +} + +@media (forced-colors: active), (prefers-contrast) { + :-moz-native-anonymous #screenshots-cancel-button { + border-color: ButtonBorder; + } +} + +:-moz-native-anonymous .screenshots-button { + cursor: pointer; + appearance: none; + pointer-events: auto; + background-color: #f9f9fa; + color: #38383d; + border: 0; + border-radius: 3px; + font-size: 16px; + font-weight: 400; + height: 40px; + min-width: 40px; + outline: none; + padding: 0 10px; + position: relative; + text-align: center; + text-decoration: none; + transition: background 150ms cubic-bezier(0.07, 0.95, 0, 1), border 150ms cubic-bezier(0.07, 0.95, 0, 1); + user-select: none; + white-space: nowrap; + margin: 0 5px; + box-shadow: 0 0 0 1px rgba(12, 12, 13, 0.1), 0 2px 8px rgba(12, 12, 13, 0.1); + z-index: 6; +} + +:-moz-native-anonymous .screenshots-button:hover, +:-moz-native-anonymous #download:hover { + background-color: #52525e; + color: #fff; +} + +:-moz-native-anonymous .screenshots-button:hover > img { + fill: #fff; +} + +@media (forced-colors: active), (prefers-contrast) { + :-moz-native-anonymous .screenshots-button { + background-color: ButtonFace; + color: ButtonText; + } + + :-moz-native-anonymous .screenshots-button:hover { + background-color: ButtonText; + color: ButtonFace; + } +} + +:-moz-native-anonymous button img { + -moz-context-properties: fill; + fill: currentColor; +} + +:-moz-native-anonymous #buttons { + position: absolute; + margin: 10px 8px; + display: flex; + align-items: center; + justify-content: center; +} + +:-moz-native-anonymous #cancel { + width: 40px; +} + +:-moz-native-anonymous #cancel > img { + color: #38383d; + content: url('chrome://browser/content/screenshots/cancel.svg'); + width: 20px; + height: 20px; + padding: 10px 0; +} + +@media (forced-colors: active), (prefers-contrast) { + :-moz-native-anonymous #cancel > img { + color: ButtonText; + } + + :-moz-native-anonymous #cancel:hover > img { + color: ButtonFace; + } +} + +:-moz-native-anonymous #copy > img { + color: #38383d; + content: url('chrome://browser/content/screenshots/copy.svg'); + width: 16px; + height: 16px; +} + +@media (forced-colors: active), (prefers-contrast) { + :-moz-native-anonymous #copy > img { + color: ButtonText; + } + + :-moz-native-anonymous #copy:hover > img { + color: ButtonFace; + } +} + +:-moz-native-anonymous #download { + background-color: #0a84ff; + color: #fff; +} + +:-moz-native-anonymous #download > img { + content: url('chrome://browser/content/screenshots/download.svg'); + width: 16px; + height: 16px; +} + +@media (forced-colors: active), (prefers-contrast) { + :-moz-native-anonymous #download { + background-color: ButtonFace; + color: ButtonText; + } + + :-moz-native-anonymous #download:hover { + background-color: ButtonText; + color: ButtonFace; + } +} + +:-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..fcf6519e35 --- /dev/null +++ b/browser/components/screenshots/screenshots-buttons.css @@ -0,0 +1,31 @@ +/* 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; + border: 1px solid transparent; + height: 100%; + min-width: 90px; + padding: 46px 5px 5px; +} + +.all-buttons-container button:hover { + background-color: var(--button-hover-bgcolor, color-mix(in srgb, currentColor 17%, transparent)); +} + +.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: buttonText; + stroke: #00fdff; +} diff --git a/browser/components/screenshots/screenshots-buttons.js b/browser/components/screenshots/screenshots-buttons.js new file mode 100644 index 0000000000..6ea9c2eee3 --- /dev/null +++ b/browser/components/screenshots/screenshots-buttons.js @@ -0,0 +1,55 @@ +/* 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); + } + } + 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..47cafe5bb2 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser.ini @@ -0,0 +1,25 @@ +[DEFAULT] +support-files = + head.js + test-page.html + short-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_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_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..77d361b26b --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js @@ -0,0 +1,317 @@ +/* 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(); + 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, + 4000, + "The overlay spans the entire width of the page" + ); + + is( + dimensions.scrollHeight, + 4000, + "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(4000, 4000); + + 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, 252, "The box width is now 252"); + is(dimensions.height, 244, "The box height is now 244"); + + 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, + 4000, + "The overlay spans the entire width of the page" + ); + is( + dimensions.scrollHeight, + 4000, + "The overlay spans the entire height of the page" + ); + } + ); +}); 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..aba38dccc0 --- /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() { + 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() { + 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(10, 10, 500, 500); + + 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( + 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 an area and clicks elsewhere + * on the overlay to go back to the crosshairs state + */ +add_task(async function() { + 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() { + 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() { + 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() { + 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() { + 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..d992327128 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_focus_test.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function testPanelFocused() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let helper = new ScreenshotsHelper(browser); + + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + EventUtils.synthesizeKey("KEY_Tab"); + + 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" + ); + } + ); +}); 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..dea97978e4 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_page_unload.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + 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(); + + let panel = gBrowser.selectedBrowser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + await BrowserTestUtils.waitForMutationCondition( + panel, + { attributes: true }, + () => { + return BrowserTestUtils.is_visible(panel); + } + ); + ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible"); + + await ContentTask.spawn(browser, null, async () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + Assert.ok(screenshotsChild._overlay._initialized, "The overlay exists"); + }); + + await SpecialPowers.spawn(browser, [], () => { + content.document.querySelector("#clickMe").click(); + }); + + await BrowserTestUtils.waitForMutationCondition( + panel, + { attributes: true }, + () => { + return BrowserTestUtils.is_hidden(panel); + } + ); + ok( + BrowserTestUtils.is_hidden(panel), + "Panel buttons are hidden after page unload" + ); + + await ContentTask.spawn(browser, null, async () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + Assert.ok( + !screenshotsChild._overlay._initialized, + "The overlay doesn't exist" + ); + }); + } + ); +}); 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..5772eeb7c3 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_short_page_test.js @@ -0,0 +1,40 @@ +/* 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 only 100px by 100px + */ +add_task(async function() { + 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" + ); + } + ); +}); 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..45dc960fc6 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +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 => { + 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(); + } + ); +}); + +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..09605fd316 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_escape.js @@ -0,0 +1,25 @@ +/* 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 panel shows + helper.triggerUIFromToolbar(); + + await helper.waitForOverlay(); + + EventUtils.synthesizeKey("KEY_Escape"); + + await helper.waitForOverlayClosed(); + } + ); +}); 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..cf83537d87 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js @@ -0,0 +1,192 @@ +/* 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); + let contentInfo = await helper.getContentDimensions(); + ok(contentInfo, "Got dimensions back from the content"); + + // click toolbar button so panel shows + helper.triggerUIFromToolbar(); + + let panel = gBrowser.selectedBrowser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + await BrowserTestUtils.waitForMutationCondition( + panel, + { attributes: true }, + () => { + return BrowserTestUtils.is_visible(panel); + } + ); + ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible"); + + 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.querySelector( + ".highlight-button-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 + 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(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(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(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"); + } + ); +}); + +add_task(async function test_fullpageScreenshotScrolled() { + 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(); + + let panel = gBrowser.selectedBrowser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + await BrowserTestUtils.waitForMutationCondition( + panel, + { attributes: true }, + () => { + return BrowserTestUtils.is_visible(panel); + } + ); + ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible"); + + 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.querySelector( + ".highlight-button-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 + 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(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(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(52, result.color.bottomRight[0], "R color value"); + Assert.equal(127, result.color.bottomRight[1], "G color value"); + Assert.equal(152, result.color.bottomRight[2], "B color value"); + } + ); +}); diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_page_crash.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_page_crash.js new file mode 100644 index 0000000000..ab5afe49a1 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_page_crash.js @@ -0,0 +1,70 @@ +/* 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(); + + let panel = gBrowser.selectedBrowser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + await BrowserTestUtils.waitForMutationCondition( + panel, + { attributes: true }, + () => { + return BrowserTestUtils.is_visible(panel); + } + ); + ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible"); + + await ContentTask.spawn(browser, null, async () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + Assert.ok(screenshotsChild._overlay._initialized, "The overlay exists"); + }); + + 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_toggle_pref.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js new file mode 100644 index 0000000000..b778c63163 --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js @@ -0,0 +1,288 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm"); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs", +}); +XPCOMUtils.defineLazyGetter(this, "ExtensionManagement", () => { + const { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm" + ); + 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..a23d07140e --- /dev/null +++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_visible.js @@ -0,0 +1,374 @@ +/* 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.querySelector( + ".highlight-button-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(); + + let panel = gBrowser.selectedBrowser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + await BrowserTestUtils.waitForMutationCondition( + panel, + { attributes: true }, + () => { + return BrowserTestUtils.is_visible(panel); + } + ); + ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible"); + + 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.querySelector( + ".highlight-button-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(); + + let panel = gBrowser.selectedBrowser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + await BrowserTestUtils.waitForMutationCondition( + panel, + { attributes: true }, + () => { + return BrowserTestUtils.is_visible(panel); + } + ); + ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible"); + + 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.querySelector( + ".highlight-button-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(); + + let panel = gBrowser.selectedBrowser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + await BrowserTestUtils.waitForMutationCondition( + panel, + { attributes: true }, + () => { + return BrowserTestUtils.is_visible(panel); + } + ); + ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible"); + + 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.querySelector( + ".highlight-button-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..cda6aed601 --- /dev/null +++ b/browser/components/screenshots/tests/browser/head.js @@ -0,0 +1,496 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +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 gScreenshotUISelectors = { + panelButtons: "#screenshotsPagePanel", + fullPageButton: "button.full-page", + visiblePageButton: "button.visible-page", + copyButton: "button.highlight-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() { + return BrowserTestUtils.waitForCondition(async () => { + return gBrowser.selectedBrowser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + }); + } + + async waitForOverlay() { + let panel = gBrowser.selectedBrowser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + if (!panel) { + panel = await this.waitForPanel(); + } + await BrowserTestUtils.waitForMutationCondition( + panel, + { attributes: true }, + () => { + return BrowserTestUtils.is_visible(panel); + } + ); + 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 = gBrowser.selectedBrowser.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; + }); + } + + 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"); + } + ); + } + + 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) { + await ContentTask.spawn(this.browser, [x, y], async ([xPos, yPos]) => { + content.window.scroll(xPos, yPos); + }); + } + + clickDownloadButton() { + mouse.click(this.endX - 60, this.endY + 30); + } + + clickCopyButton(overrideX = null, overrideY = null) { + // click copy button with last x and y position from dragOverlay + // the middle of the copy button is last X - 163 and last Y + 30. + // Ex. 500, 500 would be 336, 530 + if (overrideX && overrideY) { + mouse.click(overrideX - 166, overrideY + 30); + } else { + mouse.click(this.endX - 166, this.endY + 30); + } + } + + clickCancelButton() { + // click copy button with last x and y position from dragOverlay + // the middle of the copy button is last X - 230 and last Y + 30. + // Ex. 500, 500 would be 270, 530 + mouse.click(this.endX - 230, this.endY + 30); + } + + async zoomBrowser(zoom) { + await SpecialPowers.spawn(this.browser, [zoom], zoomLevel => { + const { Layout } = ChromeUtils.import( + "chrome://mochitests/content/browser/accessible/tests/browser/Layout.jsm" + ); + 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 = gBrowser.selectedBrowser.ownerDocument.querySelector( + "#screenshotsPagePanel" + ); + Assert.ok( + BrowserTestUtils.is_visible(panel), + "Screenshots panel is visible" + ); + } + + assertPanelNotVisible() { + let panel = gBrowser.selectedBrowser.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 doc = content.document.documentElement; + return { + clientHeight: doc.clientHeight, + clientWidth: doc.clientWidth, + scrollHeight: doc.scrollHeight, + scrollWidth: doc.scrollWidth, + }; + }); + } + + 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(); + }); + } + + 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; + }); +} 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..4ba4f10394 --- /dev/null +++ b/browser/components/screenshots/tests/browser/short-test-page.html @@ -0,0 +1,11 @@ + + + + + + + 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..9f9ae8d652 --- /dev/null +++ b/browser/components/screenshots/tests/browser/test-page.html @@ -0,0 +1,11 @@ + + + + + + + Screenshots + + + + -- cgit v1.2.3