summaryrefslogtreecommitdiffstats
path: root/browser/components/screenshots
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/screenshots')
-rw-r--r--browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs2148
-rw-r--r--browser/components/screenshots/ScreenshotsUtils.sys.mjs578
-rw-r--r--browser/components/screenshots/content/cancel.svg4
-rw-r--r--browser/components/screenshots/content/copied-notification.svg4
-rw-r--r--browser/components/screenshots/content/copy.svg4
-rw-r--r--browser/components/screenshots/content/download-white.svg4
-rw-r--r--browser/components/screenshots/content/download.svg4
-rw-r--r--browser/components/screenshots/content/icon-welcome-face-without-eyes.svg4
-rw-r--r--browser/components/screenshots/content/menu-fullpage.svg4
-rw-r--r--browser/components/screenshots/content/menu-visible.svg4
-rw-r--r--browser/components/screenshots/content/screenshots.css68
-rw-r--r--browser/components/screenshots/content/screenshots.html68
-rw-r--r--browser/components/screenshots/content/screenshots.js101
-rw-r--r--browser/components/screenshots/fileHelpers.mjs271
-rw-r--r--browser/components/screenshots/jar.mn23
-rw-r--r--browser/components/screenshots/moz.build19
-rw-r--r--browser/components/screenshots/overlay/overlay.css481
-rw-r--r--browser/components/screenshots/screenshots-buttons.css27
-rw-r--r--browser/components/screenshots/screenshots-buttons.js60
-rw-r--r--browser/components/screenshots/tests/browser/browser.ini29
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js400
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_drag_test.js475
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_focus_test.js44
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_overlay_panel_sync.js82
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_page_unload.js42
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_short_page_test.js148
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_telemetry_tests.js309
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js185
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_test_escape.js35
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js173
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_test_page_crash.js54
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_test_screenshot_too_big.js86
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js287
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_test_toolbar_button.js26
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_test_visible.js345
-rw-r--r--browser/components/screenshots/tests/browser/head.js681
-rw-r--r--browser/components/screenshots/tests/browser/large-test-page.html9
-rw-r--r--browser/components/screenshots/tests/browser/short-test-page.html8
-rw-r--r--browser/components/screenshots/tests/browser/test-page.html10
39 files changed, 7304 insertions, 0 deletions
diff --git a/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs b/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs
new file mode 100644
index 0000000000..db2c24f3dc
--- /dev/null
+++ b/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs
@@ -0,0 +1,2148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * The Screenshots overlay is inserted into the document's
+ * canvasFrame anonymous content container (see dom/webidl/Document.webidl).
+ *
+ * This container gets cleared automatically when the document navigates.
+ *
+ * Since the overlay markup is inserted in the canvasFrame using
+ * insertAnonymousContent, this means that it can be modified using the API
+ * described in AnonymousContent.webidl.
+ *
+ * Any mutation of this content must be via the AnonymousContent API.
+ * This is similar in design to [devtools' highlighters](https://firefox-source-docs.mozilla.org/devtools/tools/highlighters.html#inserting-content-in-the-page),
+ * though as Screenshots doesnt need to work on XUL documents, or allow multiple kinds of
+ * highlight/overlay our case is a little simpler.
+ *
+ * To retrieve the AnonymousContent instance, use the `content` getter.
+ */
+
+/* States:
+
+ "crosshairs":
+ Nothing has happened, and the crosshairs will follow the movement of the mouse
+ "draggingReady":
+ The user has pressed the mouse button, but hasn't moved enough to create a selection
+ "dragging":
+ The user has pressed down a mouse button, and is dragging out an area far enough to show a selection
+ "selected":
+ The user has selected an area
+ "resizing":
+ The user is resizing the selection
+
+ A pointerdown goes from crosshairs to dragging.
+ A pointerup goes from dragging to selected
+ A click outside of the selection goes from selected to crosshairs
+ A pointerdown on one of the draggers goes from selected to resizing
+
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "overlayLocalization", () => {
+ return new Localization(["browser/screenshotsOverlay.ftl"], true);
+});
+
+const STYLESHEET_URL =
+ "chrome://browser/content/screenshots/overlay/overlay.css";
+
+// An autoselection smaller than these will be ignored entirely:
+const MIN_DETECT_ABSOLUTE_HEIGHT = 10;
+const MIN_DETECT_ABSOLUTE_WIDTH = 30;
+// An autoselection smaller than these will not be preferred:
+const MIN_DETECT_HEIGHT = 30;
+const MIN_DETECT_WIDTH = 100;
+// An autoselection bigger than either of these will be ignored:
+let MAX_DETECT_HEIGHT = 700;
+let MAX_DETECT_WIDTH = 1000;
+
+const REGION_CHANGE_THRESHOLD = 5;
+const SCROLL_BY_EDGE = 20;
+
+const doNotAutoselectTags = {
+ H1: true,
+ H2: true,
+ H3: true,
+ H4: true,
+ H5: true,
+ H6: true,
+};
+
+class AnonymousContentOverlay {
+ constructor(contentDocument, screenshotsChild) {
+ this.listeners = new Map();
+ this.elements = new Map();
+
+ this.screenshotsChild = screenshotsChild;
+
+ this.contentDocument = contentDocument;
+ // aliased for easier diffs/maintenance of the event management code borrowed from devtools highlighters
+ this.pageListenerTarget = contentDocument.ownerGlobal;
+
+ this.overlayFragment = null;
+
+ this.overlayId = "screenshots-overlay-container";
+ this.previewId = "preview-container";
+ this.selectionId = "selection-container";
+ this.hoverBoxId = "hover-highlight";
+
+ this._initialized = false;
+
+ this.moverIds = [
+ "mover-left",
+ "mover-top",
+ "mover-right",
+ "mover-bottom",
+ "mover-topLeft",
+ "mover-topRight",
+ "mover-bottomLeft",
+ "mover-bottomRight",
+ ];
+ }
+ get content() {
+ if (!this._content || Cu.isDeadWrapper(this._content)) {
+ return null;
+ }
+ return this._content;
+ }
+ async initialize() {
+ if (this._initialized) {
+ return;
+ }
+
+ let document = this.contentDocument;
+ let window = document.ownerGlobal;
+
+ // Inject stylesheet
+ if (!this.overlayFragment) {
+ try {
+ window.windowUtils.loadSheetUsingURIString(
+ STYLESHEET_URL,
+ window.windowUtils.AGENT_SHEET
+ );
+ } catch {
+ // The method fails if the url is already loaded.
+ }
+ // Inject markup for the overlay UI
+ this.overlayFragment = this.buildOverlay();
+ }
+
+ this._content = document.insertAnonymousContent(
+ this.overlayFragment.children[0]
+ );
+
+ this.addEventListeners();
+
+ const hoverElementBox = new HoverElementBox(
+ this.hoverBoxId,
+ this.content,
+ document
+ );
+
+ const previewLayer = new PreviewLayer(this.previewId, this.content);
+ const selectionLayer = new SelectionLayer(
+ this.selectionId,
+ this.content,
+ hoverElementBox
+ );
+
+ this.screenshotsContainer = new ScreenshotsContainerLayer(
+ this.overlayId,
+ this.content,
+ previewLayer,
+ selectionLayer
+ );
+
+ this.stateHandler = new StateHandler(
+ this.screenshotsContainer,
+ this.screenshotsChild
+ );
+
+ this.screenshotsContainer.updateSize(window);
+
+ this.stateHandler.setState("crosshairs");
+
+ this._initialized = true;
+ }
+
+ /**
+ * The Anonymous Content doesn't shrink when the window is resized so we need
+ * to find the largest element that isn't the Anonymous Content and we will
+ * use that width and height.
+ * Otherwise we will fallback to the documentElement scroll width and height
+ * @param eventType If "resize", we called this from a resize event so we will
+ * try shifting the SelectionBox.
+ * If "scroll", we called this from a scroll event so we will redraw the buttons
+ */
+ updateScreenshotsSize(eventType) {
+ this.stateHandler.updateScreenshotsContainerSize(
+ this.contentDocument.ownerGlobal,
+ eventType
+ );
+ }
+
+ /**
+ * Add required event listeners to the overlay
+ */
+ addEventListeners() {
+ let cancelScreenshotsFunciton = () => {
+ this.screenshotsChild.requestCancelScreenshot("overlay_cancel");
+ };
+ this.addEventListenerForElement(
+ "screenshots-cancel-button",
+ "click",
+ cancelScreenshotsFunciton
+ );
+ this.addEventListenerForElement(
+ "cancel",
+ "click",
+ cancelScreenshotsFunciton
+ );
+ this.addEventListenerForElement("copy", "click", (event, targetId) => {
+ this.screenshotsChild.requestCopyScreenshot(
+ this.screenshotsContainer.getSelectionLayerBoxDimensions()
+ );
+ });
+ this.addEventListenerForElement("download", "click", (event, targetId) => {
+ this.screenshotsChild.requestDownloadScreenshot(
+ this.screenshotsContainer.getSelectionLayerBoxDimensions()
+ );
+ });
+
+ // The pointerdown event is added to the selection buttons to prevent the
+ // pointerdown event from occurring on the "screenshots-overlay-container"
+ this.addEventListenerForElement(
+ "cancel",
+ "pointerdown",
+ (event, targetId) => {
+ event.stopPropagation();
+ }
+ );
+ this.addEventListenerForElement(
+ "copy",
+ "pointerdown",
+ (event, targetId) => {
+ event.stopPropagation();
+ }
+ );
+ this.addEventListenerForElement(
+ "download",
+ "pointerdown",
+ (event, targetId) => {
+ event.stopPropagation();
+ }
+ );
+
+ this.addEventListenerForElement(
+ this.overlayId,
+ "pointerdown",
+ (event, targetId) => {
+ this.dragStart(event, targetId);
+ }
+ );
+ this.addEventListenerForElement(
+ this.overlayId,
+ "pointerup",
+ (event, targetId) => {
+ this.dragEnd(event, targetId);
+ }
+ );
+ this.addEventListenerForElement(
+ this.overlayId,
+ "pointermove",
+ (event, targetId) => {
+ this.drag(event, targetId);
+ }
+ );
+
+ for (let id of this.moverIds.concat(["highlight"])) {
+ this.addEventListenerForElement(id, "pointerdown", (event, targetId) => {
+ this.dragStart(event, targetId);
+ });
+ this.addEventListenerForElement(id, "pointerup", (event, targetId) => {
+ this.dragEnd(event, targetId);
+ });
+ this.addEventListenerForElement(id, "pointermove", (event, targetId) => {
+ this.drag(event, targetId);
+ });
+ }
+ }
+
+ /**
+ * Removes all event listeners and removes the overlay from the Anonymous Content
+ */
+ tearDown() {
+ if (this._content) {
+ this._removeAllListeners();
+ try {
+ this.contentDocument.removeAnonymousContent(this._content);
+ } catch (e) {
+ // If the current window isn't the one the content was inserted into, this
+ // will fail, but that's fine.
+ }
+ }
+ this._initialized = false;
+ }
+
+ /**
+ * Creates the document fragment that will be added to the Anonymous Content
+ * @returns document fragment that can be injected into the Anonymous Content
+ */
+ buildOverlay() {
+ let [cancel, instructions, download, copy] =
+ lazy.overlayLocalization.formatMessagesSync([
+ { id: "screenshots-overlay-cancel-button" },
+ { id: "screenshots-overlay-instructions" },
+ { id: "screenshots-overlay-download-button" },
+ { id: "screenshots-overlay-copy-button" },
+ ]);
+
+ const htmlString = `
+ <div id="screenshots-component">
+ <div id="${this.overlayId}">
+ <div id="${this.previewId}">
+ <div class="fixed-container">
+ <div class="face-container">
+ <div class="eye left"><div id="left-eye" class="eyeball"></div></div>
+ <div class="eye right"><div id="right-eye" class="eyeball"></div></div>
+ <div class="face"></div>
+ </div>
+ <div class="preview-instructions">${instructions.value}</div>
+ <button class="screenshots-button" id="screenshots-cancel-button">${cancel.value}</button>
+ </div>
+ </div>
+ <div id="${this.hoverBoxId}"></div>
+ <div id="${this.selectionId}" style="display:none;">
+ <div id="bgTop" class="bghighlight" style="display:none;"></div>
+ <div id="bgBottom" class="bghighlight" style="display:none;"></div>
+ <div id="bgLeft" class="bghighlight" style="display:none;"></div>
+ <div id="bgRight" class="bghighlight" style="display:none;"></div>
+ <div id="highlight" class="highlight" style="display:none;">
+ <div id="mover-topLeft" class="mover-target direction-topLeft">
+ <div class="mover"></div>
+ </div>
+ <div id="mover-top" class="mover-target direction-top">
+ <div class="mover"></div>
+ </div>
+ <div id="mover-topRight" class="mover-target direction-topRight">
+ <div class="mover"></div>
+ </div>
+ <div id="mover-left" class="mover-target direction-left">
+ <div class="mover"></div>
+ </div>
+ <div id="mover-right" class="mover-target direction-right">
+ <div class="mover"></div>
+ </div>
+ <div id="mover-bottomLeft" class="mover-target direction-bottomLeft">
+ <div class="mover"></div>
+ </div>
+ <div id="mover-bottom" class="mover-target direction-bottom">
+ <div class="mover"></div>
+ </div>
+ <div id="mover-bottomRight" class="mover-target direction-bottomRight">
+ <div class="mover"></div>
+ </div>
+ </div>
+ <div id="buttons" style="display:none;">
+ <button id="cancel" class="screenshots-button" title="${cancel.value}" aria-label="${cancel.value}"><img/></button>
+ <button id="copy" class="screenshots-button" title="${copy.value}" aria-label="${copy.value}"><img/>${copy.value}</button>
+ <button id="download" class="screenshots-button primary" title="${download.value}" aria-label="${download.value}"><img/>${download.value}</button>
+ </div>
+ </div>
+ </div>
+ </div>`;
+
+ const parser = new this.contentDocument.ownerGlobal.DOMParser();
+ const tmpDoc = parser.parseFromSafeString(htmlString, "text/html");
+ const fragment = this.contentDocument.createDocumentFragment();
+
+ fragment.appendChild(tmpDoc.body.children[0]);
+ return fragment;
+ }
+
+ // The event tooling is borrowed directly from devtools' highlighters (CanvasFrameAnonymousContentHelper)
+ /**
+ * Add an event listener to one of the elements inserted in the canvasFrame
+ * native anonymous container.
+ * Like other methods in this helper, this requires the ID of the element to
+ * be passed in.
+ *
+ * Note that if the content page navigates, the event listeners won't be
+ * added again.
+ *
+ * Also note that unlike traditional DOM events, the events handled by
+ * listeners added here will propagate through the document only through
+ * bubbling phase, so the useCapture parameter isn't supported.
+ * It is possible however to call e.stopPropagation() to stop the bubbling.
+ *
+ * IMPORTANT: the chrome-only canvasFrame insertion API takes great care of
+ * not leaking references to inserted elements to chrome JS code. That's
+ * because otherwise, chrome JS code could freely modify native anon elements
+ * inside the canvasFrame and probably change things that are assumed not to
+ * change by the C++ code managing this frame.
+ * See https://wiki.mozilla.org/DevTools/Highlighter#The_AnonymousContent_API
+ * Unfortunately, the inserted nodes are still available via
+ * event.originalTarget, and that's what the event handler here uses to check
+ * that the event actually occured on the right element, but that also means
+ * consumers of this code would be able to access the inserted elements.
+ * Therefore, the originalTarget property will be nullified before the event
+ * is passed to your handler.
+ *
+ * IMPL DETAIL: A single event listener is added per event types only, at
+ * browser level and if the event originalTarget is found to have the provided
+ * ID, the callback is executed (and then IDs of parent nodes of the
+ * originalTarget are checked too).
+ *
+ * @param {String} id
+ * @param {String} type
+ * @param {Function} handler
+ */
+ addEventListenerForElement(id, type, handler) {
+ if (typeof id !== "string") {
+ throw new Error(
+ "Expected a string ID in addEventListenerForElement but got: " + id
+ );
+ }
+
+ // If no one is listening for this type of event yet, add one listener.
+ if (!this.listeners.has(type)) {
+ const target = this.pageListenerTarget;
+ target.addEventListener(type, this, true);
+ // Each type entry in the map is a map of ids:handlers.
+ this.listeners.set(type, new Map());
+ }
+
+ const listeners = this.listeners.get(type);
+ listeners.set(id, handler);
+ }
+
+ /**
+ * Remove an event listener from one of the elements inserted in the
+ * canvasFrame native anonymous container.
+ * @param {String} id
+ * @param {String} type
+ */
+ removeEventListenerForElement(id, type) {
+ const listeners = this.listeners.get(type);
+ if (!listeners) {
+ return;
+ }
+ listeners.delete(id);
+
+ // If no one is listening for event type anymore, remove the listener.
+ if (!listeners.size) {
+ const target = this.pageListenerTarget;
+ target.removeEventListener(type, this, true);
+ }
+ }
+
+ handleEvent(event) {
+ const listeners = this.listeners.get(event.type);
+ if (!listeners) {
+ return;
+ }
+
+ // Hide the originalTarget property to avoid exposing references to native
+ // anonymous elements. See addEventListenerForElement's comment.
+ let isPropagationStopped = false;
+ const eventProxy = new Proxy(event, {
+ get: (obj, name) => {
+ if (name === "originalTarget") {
+ return null;
+ } else if (name === "stopPropagation") {
+ return () => {
+ isPropagationStopped = true;
+ };
+ }
+ return obj[name];
+ },
+ });
+
+ // Start at originalTarget, bubble through ancestors and call handlers when
+ // needed.
+ let node = event.originalTarget;
+ while (node) {
+ let nodeId = node.id;
+ if (nodeId) {
+ const handler = listeners.get(node.id);
+ if (handler) {
+ handler(eventProxy, nodeId);
+ if (isPropagationStopped) {
+ break;
+ }
+ }
+ if (nodeId == this.overlayId) {
+ break;
+ }
+ }
+ node = node.parentNode;
+ }
+ }
+
+ _removeAllListeners() {
+ if (this.pageListenerTarget) {
+ const target = this.pageListenerTarget;
+ for (const [type] of this.listeners) {
+ target.removeEventListener(type, this, true);
+ }
+ }
+ this.listeners.clear();
+ }
+
+ /**
+ * Pass the pointer down event to the state handler
+ * @param event The pointer down event
+ * @param targetId The target element id
+ */
+ dragStart(event, targetId) {
+ this.stateHandler.dragStart(event, targetId);
+ }
+
+ /**
+ * Pass the pointer move event to the state handler
+ * @param event The pointer move event
+ * @param targetId The target element id
+ */
+ drag(event, targetId) {
+ this.stateHandler.drag(event, targetId);
+ }
+
+ /**
+ * Pass the pointer up event to the state handler
+ * @param event The pointer up event
+ * @param targetId The target element id
+ */
+ dragEnd(event, targetId) {
+ this.stateHandler.dragEnd(event);
+ }
+}
+
+export var ScreenshotsOverlayChild = {
+ AnonymousContentOverlay,
+};
+
+/**
+ * The StateHandler class handles the state of the overlay
+ */
+class StateHandler {
+ #state;
+ #lastBox;
+ #moverId;
+ #lastX;
+ #lastY;
+ #screenshotsContainer;
+ #screenshotsChild;
+ #previousDimensions;
+
+ constructor(screenshotsContainer, screenshotsChild) {
+ this.#state = "crosshairs";
+ this.#lastBox = {};
+
+ this.#screenshotsContainer = screenshotsContainer;
+ this.#screenshotsChild = screenshotsChild;
+ }
+
+ setState(newState) {
+ if (this.#state === "selected" && newState === "crosshairs") {
+ this.#screenshotsChild.recordTelemetryEvent(
+ "started",
+ "overlay_retry",
+ {}
+ );
+ }
+ this.#state = newState;
+ this.start();
+ }
+
+ getState() {
+ return this.#state;
+ }
+
+ getHoverElementBoxRect() {
+ return this.#screenshotsContainer.hoverElementBoxRect;
+ }
+
+ /**
+ * At the start of the some states we need to perform some actions
+ */
+ start() {
+ switch (this.#state) {
+ case "crosshairs": {
+ this.crosshairsStart();
+ break;
+ }
+ case "draggingReady": {
+ this.draggingReadyStart();
+ break;
+ }
+ case "dragging": {
+ this.draggingStart();
+ break;
+ }
+ case "selected": {
+ this.selectedStart();
+ break;
+ }
+ case "resizing": {
+ this.resizingStart();
+ break;
+ }
+ }
+ }
+
+ /**
+ * Returns the x and y coordinates of the event
+ * @param event The mouse or touch event
+ * @returns object containing the x and y coordinates of the mouse
+ */
+ getCoordinates(event) {
+ const { clientX, clientY, pageX, pageY } = event;
+
+ MAX_DETECT_HEIGHT = Math.max(event.target.clientHeight + 100, 700);
+ MAX_DETECT_WIDTH = Math.max(event.target.clientWidth + 100, 1000);
+
+ return { clientX, clientY, pageX, pageY };
+ }
+
+ /**
+ * Handles the mousedown/touchstart event depending on the state
+ * @param event The mousedown or touchstart event
+ * @param targetId The id of the event target
+ */
+ dragStart(event, targetId) {
+ const { pageX, pageY } = this.getCoordinates(event);
+
+ switch (this.#state) {
+ case "crosshairs": {
+ this.crosshairsDragStart(pageX, pageY);
+ break;
+ }
+ case "selected": {
+ this.selectedDragStart(pageX, pageY, targetId);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Handles the move event depending on the state
+ * @param event The mousemove or touchmove event
+ * @param targetId The id of the event target
+ */
+ drag(event, targetId) {
+ const { pageX, pageY, clientX, clientY } = this.getCoordinates(event);
+
+ switch (this.#state) {
+ case "crosshairs": {
+ this.crosshairsMove(clientX, clientY, targetId);
+ break;
+ }
+ case "draggingReady": {
+ this.draggingReadyDrag(pageX, pageY);
+ break;
+ }
+ case "dragging": {
+ this.draggingDrag(pageX, pageY);
+ break;
+ }
+ case "resizing": {
+ this.resizingDrag(pageX, pageY);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Handles the move event depending on the state
+ * @param event The mouseup event
+ * @param targetId The id of the event target
+ */
+ dragEnd(event, targetId) {
+ const { pageX, pageY, clientX, clientY } = this.getCoordinates(event);
+
+ switch (this.#state) {
+ case "draggingReady": {
+ this.draggingReadyDragEnd(pageX - clientX, pageY - clientY);
+ break;
+ }
+ case "dragging": {
+ this.draggingDragEnd(pageX, pageY, targetId);
+ break;
+ }
+ case "resizing": {
+ this.resizingDragEnd(pageX, pageY, targetId);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Hide the box and highlighter and show the overlay at the start of crosshairs state
+ */
+ crosshairsStart() {
+ this.#screenshotsContainer.hideSelectionLayer();
+ this.#screenshotsContainer.showPreviewLayer();
+ this.#screenshotsChild.showPanel();
+ this.#previousDimensions = null;
+ }
+
+ /**
+ *
+ */
+ draggingReadyStart() {
+ this.#screenshotsChild.hidePanel();
+ }
+
+ /**
+ * Hide the overlay and draw the box at the start of dragging state
+ */
+ draggingStart() {
+ this.#screenshotsContainer.hidePreviewLayer();
+ this.#screenshotsContainer.hideButtonsLayer();
+ this.#screenshotsContainer.drawSelectionBox();
+ }
+
+ /**
+ * Show the buttons at the start of the selected state
+ */
+ selectedStart() {
+ this.#screenshotsContainer.drawButtonsLayer();
+ }
+
+ /**
+ * Hide the buttons and store width and height of box at the start of the resizing state
+ */
+ resizingStart() {
+ this.#screenshotsContainer.hideButtonsLayer();
+ let { width, height } =
+ this.#screenshotsContainer.getSelectionLayerBoxDimensions();
+ this.#lastBox = {
+ width,
+ height,
+ };
+ }
+
+ /**
+ * Set the initial box coordinates and set the state to "draggingReady"
+ * @param pageX x coordinate
+ * @param pageY y coordinate
+ */
+ crosshairsDragStart(pageX, pageY) {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ left: pageX,
+ top: pageY,
+ right: pageX,
+ bottom: pageY,
+ });
+
+ this.setState("draggingReady");
+ }
+
+ /**
+ * If the background is clicked we set the state to crosshairs
+ * otherwise set the state to resizing
+ * @param pageX x coordinate
+ * @param pageY y coordinate
+ * @param targetId The id of the event target
+ */
+ selectedDragStart(pageX, pageY, targetId) {
+ if (targetId === this.#screenshotsContainer.id) {
+ this.setState("crosshairs");
+ return;
+ }
+ this.#moverId = targetId;
+ this.#lastX = pageX;
+ this.#lastY = pageY;
+
+ this.setState("resizing");
+ }
+
+ /**
+ * Handles the pointer move for the crosshairs state
+ * @param clientX x pointer position in the visible window
+ * @param clientY y pointer position in the visible window
+ * @param targetId The id of the target element
+ */
+ crosshairsMove(clientX, clientY, targetId) {
+ this.#screenshotsContainer.drawPreviewEyes(clientX, clientY);
+
+ this.#screenshotsContainer.handleElementHover(clientX, clientY, targetId);
+ }
+
+ /**
+ * Set the bottom and right coordinates of the box and draw the box
+ * @param pageX x coordinate
+ * @param pageY y coordinate
+ */
+ draggingDrag(pageX, pageY) {
+ this.scrollIfByEdge(pageX, pageY);
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ right: pageX,
+ bottom: pageY,
+ });
+
+ this.#screenshotsContainer.drawSelectionBox();
+ }
+
+ /**
+ * If the mouse has moved at least 40 pixels then set the state to "dragging"
+ * @param pageX x coordinate
+ * @param pageY y coordinate
+ */
+ draggingReadyDrag(pageX, pageY) {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ right: pageX,
+ bottom: pageY,
+ });
+
+ if (this.#screenshotsContainer.selectionBoxDistance() > 40) {
+ this.setState("dragging");
+ }
+ }
+
+ /**
+ * Depending on what mover was selected we will resize the box accordingly
+ * @param pageX x coordinate
+ * @param pageY y coordinate
+ */
+ resizingDrag(pageX, pageY) {
+ this.scrollIfByEdge(pageX, pageY);
+ switch (this.#moverId) {
+ case "mover-topLeft": {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ left: pageX,
+ top: pageY,
+ });
+ break;
+ }
+ case "mover-top": {
+ this.#screenshotsContainer.setSelectionBoxDimensions({ top: pageY });
+ break;
+ }
+ case "mover-topRight": {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ top: pageY,
+ right: pageX,
+ });
+ break;
+ }
+ case "mover-right": {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ right: pageX,
+ });
+ break;
+ }
+ case "mover-bottomRight": {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ right: pageX,
+ bottom: pageY,
+ });
+ break;
+ }
+ case "mover-bottom": {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ bottom: pageY,
+ });
+ break;
+ }
+ case "mover-bottomLeft": {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ left: pageX,
+ bottom: pageY,
+ });
+ break;
+ }
+ case "mover-left": {
+ this.#screenshotsContainer.setSelectionBoxDimensions({ left: pageX });
+ break;
+ }
+ case "highlight": {
+ let lastBox = this.#lastBox;
+ let diffX = this.#lastX - pageX;
+ let diffY = this.#lastY - pageY;
+
+ let newLeft;
+ let newRight;
+ let newTop;
+ let newBottom;
+
+ // Unpack SelectionBox dimensions to use here
+ let {
+ boxLeft,
+ boxTop,
+ boxRight,
+ boxBottom,
+ boxWidth,
+ boxHeight,
+ scrollWidth,
+ scrollHeight,
+ } = this.#screenshotsContainer.getSelectionLayerDimensions();
+
+ // wait until all 4 if elses have completed before setting box dimensions
+ if (boxWidth <= lastBox.width && boxLeft === 0) {
+ newLeft = boxRight - lastBox.width;
+ } else {
+ newLeft = boxLeft;
+ }
+
+ if (boxWidth <= lastBox.width && boxRight === scrollWidth) {
+ newRight = boxLeft + lastBox.width;
+ } else {
+ newRight = boxRight;
+ }
+
+ if (boxHeight <= lastBox.height && boxTop === 0) {
+ newTop = boxBottom - lastBox.height;
+ } else {
+ newTop = boxTop;
+ }
+
+ if (boxHeight <= lastBox.height && boxBottom === scrollHeight) {
+ newBottom = boxTop + lastBox.height;
+ } else {
+ newBottom = boxBottom;
+ }
+
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ left: newLeft - diffX,
+ top: newTop - diffY,
+ right: newRight - diffX,
+ bottom: newBottom - diffY,
+ });
+
+ this.#lastX = pageX;
+ this.#lastY = pageY;
+ break;
+ }
+ }
+ this.#screenshotsContainer.drawSelectionBox();
+ }
+
+ /**
+ * Draw the selection box from the hover element box if it exists
+ * Else set the state to "crosshairs"
+ */
+ draggingReadyDragEnd(scrollX, scrollY) {
+ if (this.#screenshotsContainer.hoverElementBoxRect) {
+ this.#screenshotsContainer.hidePreviewLayer();
+ this.#screenshotsContainer.updateSelectionBoxFromRect(scrollX, scrollY);
+ this.#screenshotsContainer.drawSelectionBox();
+ this.setState("selected");
+ this.#screenshotsChild.recordTelemetryEvent("selected", "element", {});
+ } else {
+ this.setState("crosshairs");
+ }
+ }
+
+ /**
+ * Draw the box one last time and set the state to "selected"
+ * @param pageX x coordinate
+ * @param pageY y coordinate
+ */
+ draggingDragEnd(pageX, pageY) {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ right: pageX,
+ bottom: pageY,
+ });
+ this.#screenshotsContainer.sortSelectionLayerBoxCoords();
+ this.setState("selected");
+
+ let { width, height } =
+ this.#screenshotsContainer.getSelectionLayerBoxDimensions();
+
+ if (
+ !this.#previousDimensions ||
+ (Math.abs(this.#previousDimensions.width - width) >
+ REGION_CHANGE_THRESHOLD &&
+ Math.abs(this.#previousDimensions.height - height) >
+ REGION_CHANGE_THRESHOLD)
+ ) {
+ this.#screenshotsChild.recordTelemetryEvent(
+ "selected",
+ "region_selection",
+ {}
+ );
+ }
+ this.#previousDimensions = { width, height };
+ }
+
+ /**
+ * Draw the box one last time and set the state to "selected"
+ * @param pageX x coordinate
+ * @param pageY y coordinate
+ */
+ resizingDragEnd(pageX, pageY, targetId) {
+ this.resizingDrag(pageX, pageY, targetId);
+ this.#screenshotsContainer.sortSelectionLayerBoxCoords();
+ this.setState("selected");
+ }
+
+ /**
+ * The page was resized or scrolled. We need to update the
+ * ScreenshotsContainer size so we don't draw outside the window bounds
+ * If the current state is "selected" and this was called from a resize event
+ * then we need to maybe shift the SelectionBox
+ * @param win The window object of the page
+ * @param eventType If this was called from a resize event
+ */
+ updateScreenshotsContainerSize(win, eventType) {
+ if (this.#state === "crosshairs" && eventType === "resize") {
+ this.#screenshotsContainer.hideHoverElementBox();
+ }
+
+ this.#screenshotsContainer.updateSize(win);
+
+ if (this.#state === "selected" && eventType === "resize") {
+ this.#screenshotsContainer.shiftSelectionLayerBox();
+ } else if (
+ this.#state !== "resizing" &&
+ this.#state !== "dragging" &&
+ eventType === "scroll"
+ ) {
+ this.#screenshotsContainer.drawButtonsLayer();
+ if (this.#state === "crosshairs") {
+ this.#screenshotsContainer.handleElementScroll();
+ }
+ }
+ }
+
+ scrollIfByEdge(pageX, pageY) {
+ let dimensions = this.#screenshotsContainer.getSelectionLayerDimensions();
+
+ if (pageY - dimensions.scrollY <= SCROLL_BY_EDGE) {
+ // Scroll up
+ this.#screenshotsChild.scrollWindow(0, -SCROLL_BY_EDGE);
+ } else if (
+ dimensions.scrollY + dimensions.clientHeight - pageY <=
+ SCROLL_BY_EDGE
+ ) {
+ // Scroll down
+ this.#screenshotsChild.scrollWindow(0, SCROLL_BY_EDGE);
+ }
+
+ if (pageX - dimensions.scrollX <= SCROLL_BY_EDGE) {
+ // Scroll left
+ this.#screenshotsChild.scrollWindow(-SCROLL_BY_EDGE, 0);
+ } else if (
+ dimensions.scrollX + dimensions.clientWidth - pageX <=
+ SCROLL_BY_EDGE
+ ) {
+ // Scroll right
+ this.#screenshotsChild.scrollWindow(SCROLL_BY_EDGE, 0);
+ }
+ }
+}
+
+class AnonLayer {
+ id;
+ content;
+
+ constructor(id, content) {
+ this.id = id;
+ this.content = content;
+ }
+
+ /**
+ * Show element with id this.id
+ */
+ show() {
+ this.content.removeAttributeForElement(this.id, "style");
+ }
+
+ /**
+ * Hide element with id this.id
+ */
+ hide() {
+ this.content.setAttributeForElement(this.id, "style", "display:none;");
+ }
+}
+
+class HoverElementBox extends AnonLayer {
+ #document;
+ #rect;
+ #lastX;
+ #lastY;
+
+ constructor(id, content, document) {
+ super(id, content);
+
+ this.#document = document;
+ }
+
+ get rect() {
+ return this.#rect;
+ }
+
+ /**
+ * Draws the hover box over an element from the given rect
+ * @param rect The rect to draw the hover element box
+ */
+ drawHoverBox(rect) {
+ if (!rect) {
+ this.hide();
+ } else {
+ let maxHeight = this.selectionLayer.scrollHeight;
+ let maxWidth = this.selectionLayer.scrollWidth;
+ let top = this.#document.documentElement.scrollTop + rect.top;
+ top = top > 0 ? top : 0;
+ let left = this.#document.documentElement.scrollLeft + rect.left;
+ left = left > 0 ? left : 0;
+ let height =
+ rect.top + rect.height > maxHeight ? maxHeight - rect.top : rect.height;
+ let width =
+ rect.left + rect.width > maxWidth ? maxWidth - rect.left : rect.width;
+
+ this.content.setAttributeForElement(
+ this.id,
+ "style",
+ `top:${top}px;left:${left}px;height:${height}px;width:${width}px;`
+ );
+ }
+ }
+
+ /**
+ * Handles when the user moves the mouse over an element
+ * @param clientX The x coordinate in the visible window
+ * @param clientY The y coordinate in the visible window
+ * @param targetId The target element id
+ */
+ handleElementHover(clientX, clientY, targetId) {
+ if (targetId === "screenshots-overlay-container") {
+ let ele = this.getElementFromPoint(clientX, clientY);
+
+ if (this.cachedEle && this.cachedEle === ele) {
+ // Still hovering over the same element
+ return;
+ }
+ this.cachedEle = ele;
+
+ this.getBestRectForElement(ele);
+
+ this.#lastX = clientX;
+ this.#lastY = clientY;
+ }
+ }
+
+ /**
+ * Handles moving the rect when the user has scrolled but not moved the mouse
+ * It uses the last x and y coordinates to find the new element at the mouse position
+ */
+ handleElementScroll() {
+ if (this.#lastX && this.#lastY) {
+ this.cachedEle = null;
+ this.handleElementHover(
+ this.#lastX,
+ this.#lastY,
+ "screenshots-overlay-container"
+ );
+ }
+ }
+
+ /**
+ * Finds an element for the given coordinates within the viewport
+ * @param x The x coordinate in the visible window
+ * @param y The y coordinate in the visible window
+ * @returns An element location at the given coordinates
+ */
+ getElementFromPoint(x, y) {
+ this.setPointerEventsNone();
+ let ele;
+ try {
+ ele = this.#document.elementFromPoint(x, y);
+ } finally {
+ this.resetPointerEvents();
+ }
+
+ return ele;
+ }
+
+ /**
+ * Gets the rect for an element if getBoundingClientRect exists
+ * @param ele The element to get the rect from
+ * @returns The bounding client rect of the element or null
+ */
+ getBoundingClientRect(ele) {
+ if (!ele.getBoundingClientRect) {
+ return null;
+ }
+
+ return ele.getBoundingClientRect();
+ }
+
+ /**
+ * This function takes an element and finds a suitable rect to draw the hover box on
+ * @param ele The element to find a suitale rect of
+ */
+ getBestRectForElement(ele) {
+ let lastRect;
+ let lastNode;
+ let rect;
+ let attemptExtend = false;
+ let node = ele;
+ while (node) {
+ rect = this.getBoundingClientRect(node);
+ if (!rect) {
+ rect = lastRect;
+ break;
+ }
+ if (rect.width < MIN_DETECT_WIDTH || rect.height < MIN_DETECT_HEIGHT) {
+ // Avoid infinite loop for elements with zero or nearly zero height,
+ // like non-clearfixed float parents with or without borders.
+ break;
+ }
+ if (rect.width > MAX_DETECT_WIDTH || rect.height > MAX_DETECT_HEIGHT) {
+ // Then the last rectangle is better
+ rect = lastRect;
+ attemptExtend = true;
+ break;
+ }
+ if (rect.width >= MIN_DETECT_WIDTH && rect.height >= MIN_DETECT_HEIGHT) {
+ if (!doNotAutoselectTags[node.tagName]) {
+ break;
+ }
+ }
+ lastRect = rect;
+ lastNode = node;
+ node = node.parentNode;
+ }
+ if (rect && node) {
+ const evenBetter = this.evenBetterElement(node);
+ if (evenBetter) {
+ node = lastNode = evenBetter;
+ rect = this.getBoundingClientRect(evenBetter);
+ attemptExtend = false;
+ }
+ }
+ if (rect && attemptExtend) {
+ let extendNode = lastNode.nextSibling;
+ while (extendNode) {
+ if (extendNode.nodeType === this.#document.ELEMENT_NODE) {
+ break;
+ }
+ extendNode = extendNode.nextSibling;
+ if (!extendNode) {
+ const parent = lastNode.parentNode;
+ for (let i = 0; i < parent.childNodes.length; i++) {
+ if (parent.childNodes[i] === lastNode) {
+ extendNode = parent.childNodes[i + 1];
+ }
+ }
+ }
+ }
+ if (extendNode) {
+ const extendRect = this.getBoundingClientRect(extendNode);
+ let x = Math.min(rect.x, extendRect.x);
+ let y = Math.min(rect.y, extendRect.y);
+ let width = Math.max(rect.right, extendRect.right) - x;
+ let height = Math.max(rect.bottom, extendRect.bottom) - y;
+ const combinedRect = new DOMRect(x, y, width, height);
+ if (
+ combinedRect.width <= MAX_DETECT_WIDTH &&
+ combinedRect.height <= MAX_DETECT_HEIGHT
+ ) {
+ rect = combinedRect;
+ }
+ }
+ }
+
+ if (
+ rect &&
+ (rect.width < MIN_DETECT_ABSOLUTE_WIDTH ||
+ rect.height < MIN_DETECT_ABSOLUTE_HEIGHT)
+ ) {
+ rect = null;
+ }
+
+ if (!rect) {
+ this.hide();
+ } else {
+ this.drawHoverBox(rect);
+ }
+
+ this.#rect = rect;
+ }
+
+ /**
+ * This finds a better element by looking for elements with role article
+ * @param node The currently hovered node
+ * @returns A better node or null
+ */
+ evenBetterElement(node) {
+ let el = node.parentNode;
+ const ELEMENT_NODE = this.#document.ELEMENT_NODE;
+ while (el && el.nodeType === ELEMENT_NODE) {
+ if (!el.getAttribute) {
+ return null;
+ }
+ if (el.getAttribute("role") === "article") {
+ const rect = this.getBoundingClientRect(el);
+ if (!rect) {
+ return null;
+ }
+ if (
+ rect.width <= MAX_DETECT_WIDTH &&
+ rect.height <= MAX_DETECT_HEIGHT
+ ) {
+ return el;
+ }
+ return null;
+ }
+ el = el.parentNode;
+ }
+ return null;
+ }
+
+ /**
+ * The pointer events need to be removed temporarily so we can find the
+ * correct element from document.elementFromPoint()
+ * If the pointer events are on for the screenshots elements, then we will always
+ * get the screenshots elements as the elements from a given point
+ */
+ setPointerEventsNone() {
+ this.content.setAttributeForElement(
+ "screenshots-component",
+ "style",
+ "pointer-events:none;"
+ );
+
+ let temp = this.content.getAttributeForElement(
+ "screenshots-overlay-container",
+ "style"
+ );
+ this.content.setAttributeForElement(
+ "screenshots-overlay-container",
+ "style",
+ temp + "pointer-events:none;"
+ );
+ }
+
+ /**
+ * Return the pointer events to the original state because we found the element
+ */
+ resetPointerEvents() {
+ this.content.setAttributeForElement("screenshots-component", "style", "");
+
+ let temp = this.content.getAttributeForElement(
+ "screenshots-overlay-container",
+ "style"
+ );
+ this.content.setAttributeForElement(
+ "screenshots-overlay-container",
+ "style",
+ temp.replace("pointer-events:none;", "")
+ );
+ }
+}
+
+class SelectionLayer extends AnonLayer {
+ #selectionBox;
+ #hoverElementBox;
+ #buttons;
+ #hidden;
+ /**
+ * the documentDimensions follows the below structure
+ * {
+ * scrollWidth: the total document width
+ * scrollHeight: the total document height
+ * scrollX: the x scrolled offset
+ * scrollY: the y scrolled offset
+ * clientWidth: the viewport width
+ * clientHeight: the viewport height
+ * }
+ */
+ #documentDimensions;
+
+ constructor(id, content, hoverElementBox) {
+ super(id, content);
+ this.#selectionBox = new SelectionBox(content, this);
+ this.#buttons = new ButtonsLayer("buttons", content, this);
+ this.#hoverElementBox = hoverElementBox;
+ this.#hoverElementBox.selectionLayer = this;
+
+ this.#hidden = true;
+ this.#documentDimensions = {};
+ }
+
+ /**
+ * Hide the buttons layer
+ */
+ hideButtons() {
+ this.#buttons.hide();
+ }
+
+ /**
+ * Call
+ */
+ drawButtonsLayer() {
+ this.#buttons.show();
+ }
+
+ /**
+ * Hide the selection-container element
+ */
+ hide() {
+ super.hide();
+ this.#hidden = true;
+ }
+
+ /**
+ * Draw the SelectionBox
+ */
+ drawSelectionBox() {
+ if (this.#hidden) {
+ this.show();
+ this.#hidden = false;
+ }
+ this.#selectionBox.show();
+ }
+
+ /**
+ * Sort the SelectionBox coordinates
+ */
+ sortSelectionBoxCoords() {
+ this.#selectionBox.sortCoords();
+ }
+
+ /**
+ * Sets the SelectionBox dimensions
+ * @param {Object} dims The new box dimensions
+ * {
+ * left: new left dimension value or undefined
+ * top: new top dimension value or undefined
+ * right: new right dimension value or undefined
+ * bottom: new bottom dimension value or undefined
+ * }
+ */
+ setSelectionBoxDimensions(dims) {
+ if (dims.left) {
+ this.#selectionBox.left = dims.left;
+ }
+ if (dims.top) {
+ this.#selectionBox.top = dims.top;
+ }
+ if (dims.right) {
+ this.#selectionBox.right = dims.right;
+ }
+ if (dims.bottom) {
+ this.#selectionBox.bottom = dims.bottom;
+ }
+ }
+
+ /**
+ * Gets the selections box dimensions
+ * @returns {Object}
+ * {
+ * x1: the left dimension value
+ * y1: the top dimension value
+ * width: the width of the selected region
+ * height: the height of the selected region
+ * }
+ */
+ getSelectionBoxDimensions() {
+ return this.#selectionBox.getDimensions();
+ }
+
+ /**
+ * Returns the box dimensions and the page dimensions
+ * @returns {Object}
+ * {
+ * boxLeft: the left position of the box
+ * boxTop: the top position of the box
+ * boxRight: the right position of the box
+ * boxBottom: the bottom position of the box
+ * scrollWidth: the total document width
+ * scrollHeight: the total document height
+ * scrollX: the x scrolled offset
+ * scrollY: the y scrolled offset
+ * clientWidth: the viewport width
+ * clientHeight: the viewport height
+ * }
+ */
+ getDimensions() {
+ return {
+ boxLeft: this.#selectionBox.left,
+ boxTop: this.#selectionBox.top,
+ boxRight: this.#selectionBox.right,
+ boxBottom: this.#selectionBox.bottom,
+ boxWidth: this.#selectionBox.width,
+ boxHeight: this.#selectionBox.height,
+ ...this.#documentDimensions,
+ };
+ }
+
+ /**
+ * Gets the diagonal distance of the SelectionBox
+ * @returns The diagonal distance of the SelectionBox
+ */
+ getSelectionBoxDistance() {
+ return this.#selectionBox.distance;
+ }
+
+ /**
+ * Shift the SelectionBox so that it is always within the document
+ */
+ shiftSelectionBox() {
+ this.#selectionBox.shiftBox();
+ }
+
+ /**
+ * Update the box coordinates from the hover element rect
+ */
+ updateSelectionBoxFromRect(scrollX, scrollY) {
+ this.#selectionBox.updateBoxFromRect(
+ this.#hoverElementBox.rect,
+ scrollX,
+ scrollY
+ );
+ }
+
+ /**
+ * Handles when the user moves the mouse over an element
+ * @param clientX The x coordinate in the visible window
+ * @param clientY The y coordinate in the visible window
+ * @param targetId The target element id
+ */
+ handleElementHover(clientX, clientY, targetId) {
+ this.#hoverElementBox.handleElementHover(clientX, clientY, targetId);
+ }
+
+ /**
+ * Handles moving the rect when the user has scrolled but not moved the mouse
+ * It uses the last x and y coordinates to find the new element at the mouse position
+ */
+ handleElementScroll() {
+ this.#hoverElementBox.handleElementScroll();
+ }
+
+ hideHoverElementSelection() {
+ this.#hoverElementBox.hide();
+ }
+
+ get hoverElementBoxRect() {
+ return this.#hoverElementBox.rect;
+ }
+
+ get scrollWidth() {
+ return this.#documentDimensions.scrollWidth;
+ }
+ set scrollWidth(val) {
+ this.#documentDimensions.scrollWidth = val;
+ }
+
+ get scrollHeight() {
+ return this.#documentDimensions.scrollHeight;
+ }
+ set scrollHeight(val) {
+ this.#documentDimensions.scrollHeight = val;
+ }
+
+ get scrollX() {
+ return this.#documentDimensions.scrollX;
+ }
+ set scrollX(val) {
+ this.#documentDimensions.scrollX = val;
+ }
+
+ get scrollY() {
+ return this.#documentDimensions.scrollY;
+ }
+ set scrollY(val) {
+ this.#documentDimensions.scrollY = val;
+ }
+
+ get clientWidth() {
+ return this.#documentDimensions.clientWidth;
+ }
+ set clientWidth(val) {
+ this.#documentDimensions.clientWidth = val;
+ }
+
+ get clientHeight() {
+ return this.#documentDimensions.clientHeight;
+ }
+ set clientHeight(val) {
+ this.#documentDimensions.clientHeight = val;
+ }
+}
+
+/**
+ * The SelectionBox class handles drawing the highlight and background
+ */
+class SelectionBox extends AnonLayer {
+ #x1;
+ #x2;
+ #y1;
+ #y2;
+ #xOffset;
+ #yOffset;
+ #selectionLayer;
+
+ constructor(content, selectionLayer) {
+ super("", content);
+
+ this.#selectionLayer = selectionLayer;
+
+ this.#x1 = 0;
+ this.#x2 = 0;
+ this.#y1 = 0;
+ this.#y2 = 0;
+ this.#xOffset = 0;
+ this.#yOffset = 0;
+ }
+
+ /**
+ * Draw the selected region for screenshotting
+ */
+ show() {
+ this.content.setAttributeForElement(
+ "highlight",
+ "style",
+ `top:${this.top}px;left:${this.left}px;height:${this.height}px;width:${this.width}px;`
+ );
+
+ this.content.setAttributeForElement(
+ "bgTop",
+ "style",
+ `top:0px;height:${this.top}px;left:0px;width:100%;`
+ );
+
+ this.content.setAttributeForElement(
+ "bgBottom",
+ "style",
+ `top:${this.bottom}px;height:calc(100% - ${this.bottom}px);left:0px;width:100%;`
+ );
+
+ this.content.setAttributeForElement(
+ "bgLeft",
+ "style",
+ `top:${this.top}px;height:${this.height}px;left:0px;width:${this.left}px;`
+ );
+
+ this.content.setAttributeForElement(
+ "bgRight",
+ "style",
+ `top:${this.top}px;height:${this.height}px;left:${this.right}px;width:calc(100% - ${this.right}px);`
+ );
+ }
+
+ /**
+ * Update the box coordinates from the rect
+ * @param rect The hover element box
+ * @param scrollX The x offset the page is scrolled
+ * @param scrollY The y offset the page is scrolled
+ */
+ updateBoxFromRect(rect, scrollX, scrollY) {
+ this.top = rect.top + scrollY;
+ this.left = rect.left + scrollX;
+ this.right = rect.right + scrollX;
+ this.bottom = rect.bottom + scrollY;
+ }
+
+ /**
+ * Hide the selected region
+ */
+ hide() {
+ this.content.setAttributeForElement("highlight", "style", "display:none;");
+ this.content.setAttributeForElement("bgTop", "style", "display:none;");
+ this.content.setAttributeForElement("bgBottom", "style", "display:none;");
+ this.content.setAttributeForElement("bgLeft", "style", "display:none;");
+ this.content.setAttributeForElement("bgRight", "style", "display:none;");
+ }
+
+ /**
+ * The box should never appear outside the document so the SelectionBox will
+ * be shifted if the bounds of the box are outside the documents width or height
+ */
+ shiftBox() {
+ let didShift = false;
+ let xDiff = this.right - this.#selectionLayer.scrollWidth;
+ if (xDiff > 0) {
+ this.right -= xDiff;
+ this.left -= xDiff;
+
+ didShift = true;
+ }
+
+ let yDiff = this.bottom - this.#selectionLayer.scrollHeight;
+ if (yDiff > 0) {
+ let curHeight = this.height;
+
+ this.bottom -= yDiff;
+ this.top = this.bottom - curHeight;
+
+ didShift = true;
+ }
+
+ if (didShift) {
+ this.show();
+ this.#selectionLayer.drawButtonsLayer();
+ }
+ }
+
+ /**
+ * Sort the coordinates so x1 < x2 and y1 < y2
+ */
+ sortCoords() {
+ if (this.#x1 > this.#x2) {
+ [this.#x1, this.#x2] = [this.#x2, this.#x1];
+ }
+ if (this.#y1 > this.#y2) {
+ [this.#y1, this.#y2] = [this.#y2, this.#y1];
+ }
+ }
+
+ /**
+ * Gets the dimensions of the currently selected region
+ * @returns {Object}
+ * {
+ * x1: the left dimension value
+ * y1: the top dimension value
+ * width: the width of the selected region
+ * height: the height of the selected region
+ * }
+ */
+ getDimensions() {
+ return {
+ x1: this.left,
+ y1: this.top,
+ width: this.width,
+ height: this.height,
+ };
+ }
+
+ get distance() {
+ return Math.sqrt(Math.pow(this.width, 2) + Math.pow(this.height, 2));
+ }
+
+ get xOffset() {
+ return this.#xOffset;
+ }
+ set xOffset(val) {
+ this.#xOffset = val;
+ }
+
+ get yOffset() {
+ return this.#yOffset;
+ }
+ set yOffset(val) {
+ this.#yOffset = val;
+ }
+
+ get top() {
+ return Math.min(this.#y1, this.#y2);
+ }
+ set top(val) {
+ this.#y1 = val > 0 ? val : 0;
+ }
+
+ get left() {
+ return Math.min(this.#x1, this.#x2);
+ }
+ set left(val) {
+ this.#x1 = val > 0 ? val : 0;
+ }
+
+ get right() {
+ return Math.max(this.#x1, this.#x2);
+ }
+ set right(val) {
+ this.#x2 =
+ val > this.#selectionLayer.scrollWidth
+ ? this.#selectionLayer.scrollWidth
+ : val;
+ }
+
+ get bottom() {
+ return Math.max(this.#y1, this.#y2);
+ }
+ set bottom(val) {
+ this.#y2 =
+ val > this.#selectionLayer.scrollHeight
+ ? this.#selectionLayer.scrollHeight
+ : val;
+ }
+
+ get width() {
+ return Math.abs(this.#x2 - this.#x1);
+ }
+ get height() {
+ return Math.abs(this.#y2 - this.#y1);
+ }
+}
+
+class ButtonsLayer extends AnonLayer {
+ #selectionLayer;
+
+ constructor(id, content, selectionLayer) {
+ super(id, content);
+
+ this.#selectionLayer = selectionLayer;
+ }
+
+ /**
+ * Draw the buttons. Check if the box is too near the bottom or left of the
+ * viewport and adjust the buttons accordingly
+ */
+ show() {
+ let {
+ boxLeft,
+ boxTop,
+ boxRight,
+ boxBottom,
+ scrollX,
+ scrollY,
+ clientWidth,
+ clientHeight,
+ } = this.#selectionLayer.getDimensions();
+
+ if (
+ boxTop > scrollY + clientHeight ||
+ boxBottom < scrollY ||
+ boxLeft > scrollX + clientWidth ||
+ boxRight < scrollX
+ ) {
+ // The box is offscreen so need to draw the buttons
+ return;
+ }
+
+ let top = boxBottom;
+ let leftOrRight = `right:calc(100% - ${boxRight}px);`;
+
+ if (scrollY + clientHeight - boxBottom < 70) {
+ if (boxBottom < scrollY + clientHeight) {
+ top = boxBottom - 60;
+ } else if (scrollY + clientHeight - boxTop < 70) {
+ top = boxTop - 60;
+ } else {
+ top = scrollY + clientHeight - 60;
+ }
+ }
+ if (boxRight < 300) {
+ leftOrRight = `left:${boxLeft}px;`;
+ }
+
+ this.content.setAttributeForElement(
+ "buttons",
+ "style",
+ `top:${top}px;${leftOrRight}`
+ );
+ }
+}
+
+class PreviewLayer extends AnonLayer {
+ constructor(id, content) {
+ super(id, content);
+ }
+
+ /**
+ * Draw the eyeballs facing the mouse
+ * @param clientX x pointer position
+ * @param clientY y pointer position
+ * @param width width of the viewport
+ * @param height height of the viewport
+ */
+ drawEyes(clientX, clientY, width, height) {
+ const xpos = Math.floor((10 * (clientX - width / 2)) / width);
+ const ypos = Math.floor((10 * (clientY - height / 2)) / height);
+ const move = `transform:translate(${xpos}px, ${ypos}px);`;
+ this.content.setAttributeForElement("left-eye", "style", move);
+ this.content.setAttributeForElement("right-eye", "style", move);
+ }
+}
+
+class ScreenshotsContainerLayer extends AnonLayer {
+ #width;
+ #height;
+ #previewLayer;
+ #selectionLayer;
+
+ constructor(id, content, previewLayer, selectionLayer) {
+ super(id, content);
+
+ this.#previewLayer = previewLayer;
+ this.#selectionLayer = selectionLayer;
+ }
+
+ /**
+ * Hide the SelectionLayer
+ */
+ hideSelectionLayer() {
+ this.#selectionLayer.hide();
+ }
+
+ /**
+ * Show the PreviewLayer
+ */
+ showPreviewLayer() {
+ this.#previewLayer.show();
+ }
+
+ /**
+ * Hide the PreviewLayer
+ */
+ hidePreviewLayer() {
+ this.#previewLayer.hide();
+ this.#selectionLayer.hideHoverElementSelection();
+ }
+
+ /**
+ * Show the ButtonsLayer
+ */
+ drawButtonsLayer() {
+ this.#selectionLayer.drawButtonsLayer();
+ }
+
+ /**
+ * Hide the ButtonsLayer
+ */
+ hideButtonsLayer() {
+ this.#selectionLayer.hideButtons();
+ }
+
+ /**
+ * Show the SelectionBox
+ */
+ drawSelectionBox() {
+ this.#selectionLayer.drawSelectionBox();
+ }
+
+ hideHoverElementBox() {
+ this.#selectionLayer.hideHoverElementSelection();
+ }
+
+ /**
+ * Update the box coordinates from the hover element rect
+ */
+ updateSelectionBoxFromRect(scrollX, scrollY) {
+ this.#selectionLayer.updateSelectionBoxFromRect(scrollX, scrollY);
+ }
+
+ /**
+ * Handles when the user moves the mouse over an element
+ * @param clientX The x coordinate in the visible window
+ * @param clientY The y coordinate in the visible window
+ * @param targetId The target element id
+ */
+ handleElementHover(clientX, clientY, targetId) {
+ this.#selectionLayer.handleElementHover(clientX, clientY, targetId);
+ }
+
+ /**
+ * Handles moving the rect when the user has scrolled but not moved the mouse
+ * It uses the last x and y coordinates to find the new element at the mouse position
+ */
+ handleElementScroll() {
+ this.#selectionLayer.handleElementScroll();
+ }
+
+ /**
+ * Draw the eyes in the PreviewLayer
+ * @param clientX The x mouse position
+ * @param clientY The y mouse position
+ */
+ drawPreviewEyes(clientX, clientY) {
+ this.#previewLayer.drawEyes(
+ clientX,
+ clientY,
+ this.#selectionLayer.clientWidth,
+ this.#selectionLayer.clientHeight
+ );
+ }
+
+ /**
+ * Get the diagonal distance of the SelectionBox
+ * @returns The diagonal distance of the currently selected region
+ */
+ selectionBoxDistance() {
+ return this.#selectionLayer.getSelectionBoxDistance();
+ }
+
+ /**
+ * Sort the coordinates of the SelectionBox
+ */
+ sortSelectionLayerBoxCoords() {
+ this.#selectionLayer.sortSelectionBoxCoords();
+ }
+
+ /**
+ * Get the SelectionLayer dimensions
+ * @returns {Object}
+ * {
+ * x1: the left dimension value
+ * y1: the top dimension value
+ * width: the width of the selected region
+ * height: the height of the selected region
+ * }
+ */
+ getSelectionLayerBoxDimensions() {
+ return this.#selectionLayer.getSelectionBoxDimensions();
+ }
+
+ /**
+ * Gets the SelectionBox and page dimensions
+ * @returns {Object}
+ * {
+ * boxLeft: the left position of the box
+ * boxTop: the top position of the box
+ * boxRight: the right position of the box
+ * boxBottom: the bottom position of the box
+ * scrollWidth: the total document width
+ * scrollHeight: the total document height
+ * scrollX: the x scrolled offset
+ * scrollY: the y scrolled offset
+ * clientWidth: the viewport width
+ * clientHeight: the viewport height
+ * }
+ */
+ getSelectionLayerDimensions() {
+ return this.#selectionLayer.getDimensions();
+ }
+
+ /**
+ * Shift the SelectionBox
+ */
+ shiftSelectionLayerBox() {
+ this.#selectionLayer.shiftSelectionBox();
+ }
+
+ /**
+ * Set the respective dimensions of the SelectionBox
+ * @param {Object} boxDimensionObject The new box dimensions
+ * {
+ * left: new left dimension value or undefined
+ * top: new top dimension value or undefined
+ * right: new right dimension value or undefined
+ * bottom: new bottom dimension value or undefined
+ * }
+ */
+ setSelectionBoxDimensions(boxDimensionObject) {
+ this.#selectionLayer.setSelectionBoxDimensions(boxDimensionObject);
+ }
+
+ /**
+ * Returns the window's dimensions for the `window` given.
+ *
+ * @return {Object} An object containing window dimensions
+ * {
+ * clientWidth: The width of the viewport
+ * clientHeight: The height of the viewport
+ * width: The width of the enitre page
+ * height: The height of the entire page
+ * scrollX: The X scroll offset of the viewport
+ * scrollY: The Y scroll offest of the viewport
+ * }
+ */
+ getDimensionsFromWindow(window) {
+ let {
+ innerHeight,
+ innerWidth,
+ scrollMaxY,
+ scrollMaxX,
+ scrollMinY,
+ scrollMinX,
+ scrollY,
+ scrollX,
+ } = window;
+
+ let width = innerWidth + scrollMaxX - scrollMinX;
+ let height = innerHeight + scrollMaxY - scrollMinY;
+ let clientHeight = innerHeight;
+ let clientWidth = innerWidth;
+
+ const scrollbarHeight = {};
+ const scrollbarWidth = {};
+ window.windowUtils.getScrollbarSize(false, scrollbarWidth, scrollbarHeight);
+ width -= scrollbarWidth.value;
+ height -= scrollbarHeight.value;
+ clientWidth -= scrollbarWidth.value;
+ clientHeight -= scrollbarHeight.value;
+
+ return { clientWidth, clientHeight, width, height, scrollX, scrollY };
+ }
+
+ /**
+ * The screenshots-overlay-container doesn't shrink with the window when the
+ * window is resized so we have to manually find the width and height of the
+ * window by looping throught the documentElement's children
+ * If the children mysteriously have a height or width of 0 then we will
+ * fallback to the scrollWidth and scrollHeight which can cause the container
+ * to be larger than the window dimensions
+ * @param win The window object
+ */
+ updateSize(win) {
+ let { clientWidth, clientHeight, width, height, scrollX, scrollY } =
+ this.getDimensionsFromWindow(win);
+
+ let shouldDraw = true;
+
+ if (
+ clientHeight < this.#selectionLayer.clientHeight ||
+ clientWidth < this.#selectionLayer.clientWidth
+ ) {
+ let widthDiff = this.#selectionLayer.clientWidth - clientWidth;
+ let heightDiff = this.#selectionLayer.clientHeight - clientHeight;
+
+ this.#width -= widthDiff;
+ this.#height -= heightDiff;
+
+ this.drawScreenshotsContainer();
+ // We just updated the screenshots container so we check if the window
+ // dimensions are still accurate
+ let { width: updatedWidth, height: updatedHeight } =
+ this.getDimensionsFromWindow(win);
+
+ // If the width and height are the same then we don't need to draw the overlay again
+ if (updatedWidth === width && updatedHeight === height) {
+ shouldDraw = false;
+ }
+
+ width = updatedWidth;
+ height = updatedHeight;
+ }
+
+ this.#selectionLayer.clientWidth = clientWidth;
+ this.#selectionLayer.clientHeight = clientHeight;
+ this.#selectionLayer.scrollX = scrollX;
+ this.#selectionLayer.scrollY = scrollY;
+
+ this.#selectionLayer.scrollWidth = width;
+ this.#selectionLayer.scrollHeight = height;
+
+ this.#width = width;
+ this.#height = height;
+
+ if (shouldDraw) {
+ this.drawScreenshotsContainer();
+ }
+ }
+
+ /**
+ * Return the dimensions of the screenshots container
+ * @returns {Object}
+ * width: the container width
+ * height: the container height
+ */
+ getDimension() {
+ return { width: this.#width, height: this.#height };
+ }
+
+ /**
+ * Draw the screenshots container
+ */
+ drawScreenshotsContainer() {
+ this.content.setAttributeForElement(
+ this.id,
+ "style",
+ `top:0;left:0;width:${this.#width}px;height:${this.#height}px;`
+ );
+ }
+
+ get hoverElementBoxRect() {
+ return this.#selectionLayer.hoverElementBoxRect;
+ }
+}
diff --git a/browser/components/screenshots/ScreenshotsUtils.sys.mjs b/browser/components/screenshots/ScreenshotsUtils.sys.mjs
new file mode 100644
index 0000000000..48f61078e9
--- /dev/null
+++ b/browser/components/screenshots/ScreenshotsUtils.sys.mjs
@@ -0,0 +1,578 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { getFilename } from "chrome://browser/content/screenshots/fileHelpers.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ AlertsService: ["@mozilla.org/alerts-service;1", "nsIAlertsService"],
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "screenshotsLocalization", () => {
+ return new Localization(["browser/screenshots.ftl"], true);
+});
+
+const PanelPosition = "bottomright topright";
+const PanelOffsetX = -33;
+const PanelOffsetY = -8;
+// The max dimension for a canvas is defined https://searchfox.org/mozilla-central/rev/f40d29a11f2eb4685256b59934e637012ea6fb78/gfx/cairo/cairo/src/cairo-image-surface.c#62.
+// The max number of pixels for a canvas is 124925329 or 11177 x 11177.
+// We have to limit screenshots to these dimensions otherwise it will cause an error.
+const MAX_CAPTURE_DIMENSION = 32767;
+const MAX_CAPTURE_AREA = 124925329;
+
+export class ScreenshotsComponentParent extends JSWindowActorParent {
+ async receiveMessage(message) {
+ let browser = message.target.browsingContext.topFrameElement;
+ switch (message.name) {
+ case "Screenshots:CancelScreenshot":
+ await ScreenshotsUtils.closePanel(browser);
+ let { reason } = message.data;
+ ScreenshotsUtils.recordTelemetryEvent("canceled", reason, {});
+ break;
+ case "Screenshots:CopyScreenshot":
+ await ScreenshotsUtils.closePanel(browser);
+ let copyBox = message.data;
+ ScreenshotsUtils.copyScreenshotFromRegion(copyBox, browser);
+ break;
+ case "Screenshots:DownloadScreenshot":
+ await ScreenshotsUtils.closePanel(browser);
+ let { title, downloadBox } = message.data;
+ ScreenshotsUtils.downloadScreenshotFromRegion(
+ title,
+ downloadBox,
+ browser
+ );
+ break;
+ case "Screenshots:ShowPanel":
+ ScreenshotsUtils.openPanel(browser);
+ break;
+ case "Screenshots:HidePanel":
+ ScreenshotsUtils.closePanel(browser);
+ break;
+ }
+ }
+
+ didDestroy() {
+ // When restoring a crashed tab the browser is null
+ let browser = this.browsingContext.topFrameElement;
+ if (browser) {
+ ScreenshotsUtils.closePanel(browser);
+ }
+ }
+}
+
+export var ScreenshotsUtils = {
+ initialized: false,
+ initialize() {
+ if (!this.initialized) {
+ if (
+ !Services.prefs.getBoolPref(
+ "screenshots.browser.component.enabled",
+ false
+ )
+ ) {
+ return;
+ }
+ Services.telemetry.setEventRecordingEnabled("screenshots", true);
+ Services.obs.addObserver(this, "menuitem-screenshot");
+ Services.obs.addObserver(this, "screenshots-take-screenshot");
+ this.initialized = true;
+ if (Cu.isInAutomation) {
+ Services.obs.notifyObservers(null, "screenshots-component-initialized");
+ }
+ }
+ },
+ uninitialize() {
+ if (this.initialized) {
+ Services.obs.removeObserver(this, "menuitem-screenshot");
+ Services.obs.removeObserver(this, "screenshots-take-screenshot");
+ this.initialized = false;
+ }
+ },
+ handleEvent(event) {
+ // We need to add back Escape to hide behavior as we have set noautohide="true"
+ if (event.type === "keydown" && event.key === "Escape") {
+ this.closePanel(event.view.gBrowser.selectedBrowser, true);
+ this.recordTelemetryEvent("canceled", "escape", {});
+ }
+ },
+ observe(subj, topic, data) {
+ let { gBrowser } = subj;
+ let browser = gBrowser.selectedBrowser;
+
+ switch (topic) {
+ case "menuitem-screenshot":
+ let success = this.closeDialogBox(browser);
+ if (!success || data === "retry") {
+ // only toggle the buttons if no dialog box is found because
+ // if dialog box is found then the buttons are hidden and we return early
+ // else no dialog box is found and we need to toggle the buttons
+ // or if retry because the dialog box was closed and we need to show the panel
+ this.togglePanelAndOverlay(browser, data);
+ }
+ break;
+ case "screenshots-take-screenshot":
+ // need to close the preview because screenshot was taken
+ this.closePanel(browser, true);
+
+ // init UI as a tab dialog box
+ let dialogBox = gBrowser.getTabDialogBox(browser);
+
+ let { dialog } = dialogBox.open(
+ `chrome://browser/content/screenshots/screenshots.html?browsingContextId=${browser.browsingContext.id}`,
+ {
+ features: "resizable=no",
+ sizeTo: "available",
+ allowDuplicateDialogs: false,
+ }
+ );
+ this.doScreenshot(browser, dialog, data);
+ }
+ return null;
+ },
+ /**
+ * Notify screenshots when screenshot command is used.
+ * @param window The current window the screenshot command was used.
+ * @param type The type of screenshot taken. Used for telemetry.
+ */
+ notify(window, type) {
+ if (Services.prefs.getBoolPref("screenshots.browser.component.enabled")) {
+ Services.obs.notifyObservers(
+ window.event.currentTarget.ownerGlobal,
+ "menuitem-screenshot",
+ type
+ );
+ } else {
+ Services.obs.notifyObservers(null, "menuitem-screenshot-extension", type);
+ }
+ },
+ /**
+ * Creates and returns a Screenshots actor.
+ * @param browser The current browser.
+ * @returns JSWindowActor The screenshot actor.
+ */
+ getActor(browser) {
+ let actor = browser.browsingContext.currentWindowGlobal.getActor(
+ "ScreenshotsComponent"
+ );
+ return actor;
+ },
+ /**
+ * Open the panel buttons
+ * @param browser The current browser
+ */
+ async openPanel(browser) {
+ this.createOrDisplayButtons(browser);
+ let buttonsPanel = this.panelForBrowser(browser);
+ if (buttonsPanel.state !== "open") {
+ await new Promise(resolve => {
+ buttonsPanel.addEventListener("popupshown", resolve, { once: true });
+ });
+ }
+ buttonsPanel
+ .querySelector("screenshots-buttons")
+ .focusFirst({ focusVisible: true });
+ },
+ /**
+ * Close the panel and call child actor to close the overlay
+ * @param browser The current browser
+ * @param {bool} closeOverlay Whether or not to
+ * send a message to the child to close the overly.
+ * Defaults to false. Will be false when called from didDestroy.
+ */
+ async closePanel(browser, closeOverlay = false) {
+ let buttonsPanel = this.panelForBrowser(browser);
+ if (buttonsPanel && buttonsPanel.state !== "closed") {
+ buttonsPanel.hidePopup();
+ }
+ buttonsPanel?.ownerDocument.removeEventListener("keydown", this);
+ if (closeOverlay) {
+ let actor = this.getActor(browser);
+ await actor.sendQuery("Screenshots:HideOverlay");
+ }
+ },
+ /**
+ * If the buttons panel exists and is open we will hide both the panel
+ * and the overlay. If the overlay is showing, we will hide the overlay.
+ * Otherwise create or display the buttons.
+ * @param browser The current browser.
+ */
+ async togglePanelAndOverlay(browser, data) {
+ let buttonsPanel = this.panelForBrowser(browser);
+ let isOverlayShowing = await this.getActor(browser).sendQuery(
+ "Screenshots:isOverlayShowing"
+ );
+
+ data = data === "retry" ? "preview_retry" : data;
+ if (buttonsPanel && (isOverlayShowing || buttonsPanel.state !== "closed")) {
+ this.recordTelemetryEvent("canceled", data, {});
+ return this.closePanel(browser, true);
+ }
+ let actor = this.getActor(browser);
+ actor.sendQuery("Screenshots:ShowOverlay");
+ this.recordTelemetryEvent("started", data, {});
+ return this.openPanel(browser);
+ },
+ /**
+ * Gets the screenshots dialog box
+ * @param browser The selected browser
+ * @returns Screenshots dialog box if it exists otherwise null
+ */
+ getDialog(browser) {
+ let currTabDialogBox = browser.tabDialogBox;
+ let browserContextId = browser.browsingContext.id;
+ if (currTabDialogBox) {
+ currTabDialogBox.getTabDialogManager();
+ let manager = currTabDialogBox.getTabDialogManager();
+ let dialogs = manager.hasDialogs && manager.dialogs;
+ if (dialogs.length) {
+ for (let dialog of dialogs) {
+ if (
+ dialog._openedURL.endsWith(
+ `browsingContextId=${browserContextId}`
+ ) &&
+ dialog._openedURL.includes("screenshots.html")
+ ) {
+ return dialog;
+ }
+ }
+ }
+ }
+ return null;
+ },
+ /**
+ * Closes the dialog box it it exists
+ * @param browser The selected browser
+ */
+ closeDialogBox(browser) {
+ let dialog = this.getDialog(browser);
+ if (dialog) {
+ dialog.close();
+ return true;
+ }
+ return false;
+ },
+ panelForBrowser(browser) {
+ return browser.ownerDocument.querySelector("#screenshotsPagePanel");
+ },
+ /**
+ * Gets the screenshots button if it is visible, otherwise it will get the
+ * element that the screenshots button is nested under. If the screenshots
+ * button doesn't exist then we will default to the navigator toolbox.
+ * @param browser The selected browser
+ * @returns The anchor element for the ConfirmationHint
+ */
+ getWidgetAnchor(browser) {
+ let window = browser.ownerGlobal;
+ let widgetGroup = window.CustomizableUI.getWidget("screenshot-button");
+ let widget = widgetGroup?.forWindow(window);
+ let anchor = widget?.anchor;
+
+ // Check if the anchor exists and is visible
+ if (!anchor || !window.isElementVisible(anchor.parentNode)) {
+ anchor = browser.ownerDocument.getElementById("navigator-toolbox");
+ }
+ return anchor;
+ },
+ /**
+ * Indicate that the screenshot has been copied via ConfirmationHint.
+ * @param browser The selected browser
+ */
+ showCopiedConfirmationHint(browser) {
+ let anchor = this.getWidgetAnchor(browser);
+
+ browser.ownerGlobal.ConfirmationHint.show(
+ anchor,
+ "confirmation-hint-screenshot-copied"
+ );
+ },
+ /**
+ * If the buttons panel does not exist then we will replace the buttons
+ * panel template with the buttons panel then open the buttons panel and
+ * show the screenshots overaly.
+ * @param browser The current browser.
+ */
+ createOrDisplayButtons(browser) {
+ let doc = browser.ownerDocument;
+ let buttonsPanel = this.panelForBrowser(browser);
+
+ if (!buttonsPanel) {
+ let template = doc.querySelector("#screenshotsPagePanelTemplate");
+ let clone = template.content.cloneNode(true);
+ template.replaceWith(clone);
+ buttonsPanel = doc.querySelector("#screenshotsPagePanel");
+ } else if (buttonsPanel.state !== "closed") {
+ // early return if the panel is already open
+ return;
+ }
+
+ buttonsPanel.ownerDocument.addEventListener("keydown", this);
+
+ let anchor = doc.querySelector("#navigator-toolbox");
+ buttonsPanel.openPopup(anchor, PanelPosition, PanelOffsetX, PanelOffsetY);
+ },
+ /**
+ * Gets the full page bounds from the screenshots child actor.
+ * @param browser The current browser.
+ * @returns { object }
+ * Contains the full page bounds from the screenshots child actor.
+ */
+ fetchFullPageBounds(browser) {
+ let actor = this.getActor(browser);
+ return actor.sendQuery("Screenshots:getFullPageBounds");
+ },
+ /**
+ * Gets the visible bounds from the screenshots child actor.
+ * @param browser The current browser.
+ * @returns { object }
+ * Contains the visible bounds from the screenshots child actor.
+ */
+ fetchVisibleBounds(browser) {
+ let actor = this.getActor(browser);
+ return actor.sendQuery("Screenshots:getVisibleBounds");
+ },
+ showAlertMessage(title, message) {
+ lazy.AlertsService.showAlertNotification(null, title, message);
+ },
+ /**
+ * The max one dimesion for a canvas is 32767 and the max canvas area is
+ * 124925329. If the width or height is greater than 32767 we will crop the
+ * screenshot to the max width. If the area is still too large for the canvas
+ * we will adjust the height so we can successfully capture the screenshot.
+ * @param {Object} rect The dimensions of the screenshot. The rect will be
+ * modified in place
+ */
+ cropScreenshotRectIfNeeded(rect) {
+ let cropped = false;
+ let width = rect.width * rect.devicePixelRatio;
+ let height = rect.height * rect.devicePixelRatio;
+
+ if (width > MAX_CAPTURE_DIMENSION) {
+ width = MAX_CAPTURE_DIMENSION;
+ cropped = true;
+ }
+ if (height > MAX_CAPTURE_DIMENSION) {
+ height = MAX_CAPTURE_DIMENSION;
+ cropped = true;
+ }
+ if (width * height > MAX_CAPTURE_AREA) {
+ height = Math.floor(MAX_CAPTURE_AREA / width);
+ cropped = true;
+ }
+
+ rect.width = Math.floor(width / rect.devicePixelRatio);
+ rect.height = Math.floor(height / rect.devicePixelRatio);
+
+ if (cropped) {
+ let [errorTitle, errorMessage] =
+ lazy.screenshotsLocalization.formatMessagesSync([
+ { id: "screenshots-too-large-error-title" },
+ { id: "screenshots-too-large-error-details" },
+ ]);
+ this.showAlertMessage(errorTitle.value, errorMessage.value);
+ this.recordTelemetryEvent("failed", "screenshot_too_large", null);
+ }
+ },
+ /**
+ * Add screenshot-ui to the dialog box and then take the screenshot
+ * @param browser The current browser.
+ * @param dialog The dialog box to show the screenshot preview.
+ * @param type The type of screenshot taken.
+ */
+ async doScreenshot(browser, dialog, type) {
+ await dialog._dialogReady;
+ let screenshotsUI =
+ dialog._frame.contentDocument.createElement("screenshots-ui");
+ dialog._frame.contentDocument.body.appendChild(screenshotsUI);
+
+ let rect;
+ if (type === "full-page") {
+ rect = await this.fetchFullPageBounds(browser);
+ type = "full_page";
+ } else {
+ rect = await this.fetchVisibleBounds(browser);
+ }
+ this.recordTelemetryEvent("selected", type, {});
+ return this.takeScreenshot(browser, dialog, rect);
+ },
+ /**
+ * Take the screenshot and add the image to the dialog box
+ * @param browser The current browser.
+ * @param dialog The dialog box to show the screenshot preview.
+ * @param rect DOMRect containing bounds of the screenshot.
+ */
+ async takeScreenshot(browser, dialog, rect) {
+ let { canvas, snapshot } = await this.createCanvas(rect, browser);
+
+ let newImg = dialog._frame.contentDocument.createElement("img");
+ let url = canvas.toDataURL();
+
+ newImg.id = "placeholder-image";
+
+ newImg.src = url;
+ dialog._frame.contentDocument
+ .getElementById("preview-image-div")
+ .appendChild(newImg);
+
+ if (Cu.isInAutomation) {
+ Services.obs.notifyObservers(null, "screenshots-preview-ready");
+ }
+
+ snapshot.close();
+ },
+ /**
+ * Creates a canvas and draws a snapshot of the screenshot on the canvas
+ * @param box The bounds of screenshots
+ * @param browser The current browser
+ * @returns The canvas and snapshot in an object
+ */
+ async createCanvas(box, browser) {
+ this.cropScreenshotRectIfNeeded(box);
+
+ let rect = new DOMRect(box.x1, box.y1, box.width, box.height);
+ let { devicePixelRatio } = box;
+
+ let browsingContext = BrowsingContext.get(browser.browsingContext.id);
+
+ let snapshot = await browsingContext.currentWindowGlobal.drawSnapshot(
+ rect,
+ devicePixelRatio,
+ "rgb(255,255,255)"
+ );
+
+ let canvas = browser.ownerDocument.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "html:canvas"
+ );
+ let context = canvas.getContext("2d");
+
+ canvas.width = snapshot.width;
+ canvas.height = snapshot.height;
+
+ context.drawImage(snapshot, 0, 0);
+
+ return { canvas, snapshot };
+ },
+ /**
+ * Copy the screenshot
+ * @param region The bounds of the screenshots
+ * @param browser The current browser
+ */
+ async copyScreenshotFromRegion(region, browser) {
+ let { canvas, snapshot } = await this.createCanvas(region, browser);
+
+ let url = canvas.toDataURL();
+
+ this.copyScreenshot(url, browser);
+
+ snapshot.close();
+
+ this.recordTelemetryEvent("copy", "overlay_copy", {});
+ },
+ /**
+ * Copy the image to the clipboard
+ * @param dataUrl The image data
+ * @param browser The current browser
+ */
+ copyScreenshot(dataUrl, browser) {
+ // Guard against missing image data.
+ if (!dataUrl) {
+ return;
+ }
+
+ const imageTools = Cc["@mozilla.org/image/tools;1"].getService(
+ Ci.imgITools
+ );
+
+ const base64Data = dataUrl.replace("data:image/png;base64,", "");
+
+ const image = atob(base64Data);
+ const imgDecoded = imageTools.decodeImageFromBuffer(
+ image,
+ image.length,
+ "image/png"
+ );
+
+ const transferable = Cc[
+ "@mozilla.org/widget/transferable;1"
+ ].createInstance(Ci.nsITransferable);
+ transferable.init(null);
+ transferable.addDataFlavor("image/png");
+ transferable.setTransferData("image/png", imgDecoded);
+
+ Services.clipboard.setData(
+ transferable,
+ null,
+ Services.clipboard.kGlobalClipboard
+ );
+
+ this.showCopiedConfirmationHint(browser);
+ },
+ /**
+ * Download the screenshot
+ * @param title The title of the current page
+ * @param box The bounds of the screenshot
+ * @param browser The current browser
+ */
+ async downloadScreenshotFromRegion(title, box, browser) {
+ let { canvas, snapshot } = await this.createCanvas(box, browser);
+
+ let dataUrl = canvas.toDataURL();
+
+ await this.downloadScreenshot(title, dataUrl, browser);
+
+ snapshot.close();
+
+ this.recordTelemetryEvent("download", "overlay_download", {});
+ },
+ /**
+ * Download the screenshot
+ * @param title The title of the current page or null and getFilename will get the title
+ * @param dataUrl The image data
+ * @param browser The current browser
+ */
+ async downloadScreenshot(title, dataUrl, browser) {
+ // Guard against missing image data.
+ if (!dataUrl) {
+ return;
+ }
+
+ let filename = await getFilename(title, browser);
+
+ const targetFile = new lazy.FileUtils.File(filename);
+
+ // Create download and track its progress.
+ try {
+ const download = await lazy.Downloads.createDownload({
+ source: dataUrl,
+ target: targetFile,
+ });
+
+ let isPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate(
+ browser.ownerGlobal
+ );
+ const list = await lazy.Downloads.getList(
+ isPrivate ? lazy.Downloads.PRIVATE : lazy.Downloads.PUBLIC
+ );
+ // add the download to the download list in the Downloads list in the Browser UI
+ list.add(download);
+
+ // Await successful completion of the save via the download manager
+ await download.start();
+ } catch (ex) {}
+ },
+
+ recordTelemetryEvent(type, object, args) {
+ Services.telemetry.recordEvent("screenshots", type, object, null, args);
+ },
+};
diff --git a/browser/components/screenshots/content/cancel.svg b/browser/components/screenshots/content/cancel.svg
new file mode 100644
index 0000000000..0c176be25f
--- /dev/null
+++ b/browser/components/screenshots/content/cancel.svg
@@ -0,0 +1,4 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M10.5 8.7L5.2 3.3c-.5-.5-1.3-.5-1.8 0s-.5 1.3 0 1.8l5.3 5.3-5.3 5.3c-.5.5-.5 1.3 0 1.8s1.3.5 1.8 0l5.3-5.3 5.3 5.3c.5.5 1.3.5 1.8 0s.5-1.3 0-1.8l-5.3-5.3 5.3-5.3c.5-.5.5-1.3 0-1.8s-1.3-.5-1.8 0l-5.3 5.4z" fill="context-fill #3e3d40"/></svg>
diff --git a/browser/components/screenshots/content/copied-notification.svg b/browser/components/screenshots/content/copied-notification.svg
new file mode 100644
index 0000000000..2310b41aef
--- /dev/null
+++ b/browser/components/screenshots/content/copied-notification.svg
@@ -0,0 +1,4 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path fill="context-fill" d="M44.121 24.879l-9-9A3 3 0 0 0 33 15h-3v-3a3 3 0 0 0-.879-2.121l-9-9A3 3 0 0 0 18 0H9a6 6 0 0 0-6 6v21a6 6 0 0 0 6 6h9v9a6 6 0 0 0 6 6h15a6 6 0 0 0 6-6V27a3 3 0 0 0-.879-2.121zM37.758 27H33v-4.758zm-15-15H18V7.242zM18 21v6H9V6h6v7.5a1.5 1.5 0 0 0 1.5 1.5H24a6 6 0 0 0-6 6zm6 21V21h6v7.5a1.5 1.5 0 0 0 1.5 1.5H39v12z"/></svg> \ No newline at end of file
diff --git a/browser/components/screenshots/content/copy.svg b/browser/components/screenshots/content/copy.svg
new file mode 100644
index 0000000000..3e3d49122c
--- /dev/null
+++ b/browser/components/screenshots/content/copy.svg
@@ -0,0 +1,4 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path fill="context-fill #3e3d40" d="M14.707 8.293l-3-3A1 1 0 0 0 11 5h-1V4a1 1 0 0 0-.293-.707l-3-3A1 1 0 0 0 6 0H3a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h3v3a2 2 0 0 0 2 2h5a2 2 0 0 0 2-2V9a1 1 0 0 0-.293-.707zM12.586 9H11V7.414zm-5-5H6V2.414zM6 7v2H3V2h2v2.5a.5.5 0 0 0 .5.5H8a2 2 0 0 0-2 2zm2 7V7h2v2.5a.5.5 0 0 0 .5.5H13v4z"/></svg>
diff --git a/browser/components/screenshots/content/download-white.svg b/browser/components/screenshots/content/download-white.svg
new file mode 100644
index 0000000000..bb6a7de845
--- /dev/null
+++ b/browser/components/screenshots/content/download-white.svg
@@ -0,0 +1,4 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M9.1 12L4.9 7.9c-.5-.5-1.3-.5-1.8 0s-.5 1.3 0 1.8l6.2 6.2c.5.5 1.3.5 1.8 0l6.2-6.2c.5-.5.5-1.3 0-1.8s-1.3-.5-1.8 0L11.6 12V1.2C11.6.6 11 0 10.3 0c-.7 0-1.2.6-1.2 1.2V12zM4 20c-.7 0-1.2-.6-1.2-1.2s.6-1.2 1.2-1.2h12.5c.7 0 1.2.6 1.2 1.2s-.5 1.2-1.2 1.2H4z" fill="#fff"/></svg> \ No newline at end of file
diff --git a/browser/components/screenshots/content/download.svg b/browser/components/screenshots/content/download.svg
new file mode 100644
index 0000000000..a85f745937
--- /dev/null
+++ b/browser/components/screenshots/content/download.svg
@@ -0,0 +1,4 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M9.1 12L4.9 7.9c-.5-.5-1.3-.5-1.8 0s-.5 1.3 0 1.8l6.2 6.2c.5.5 1.3.5 1.8 0l6.2-6.2c.5-.5.5-1.3 0-1.8s-1.3-.5-1.8 0L11.6 12V1.2C11.6.6 11 0 10.3 0c-.7 0-1.2.6-1.2 1.2V12zM4 20c-.7 0-1.2-.6-1.2-1.2s.6-1.2 1.2-1.2h12.5c.7 0 1.2.6 1.2 1.2s-.5 1.2-1.2 1.2H4z" fill="context-fill #3e3d40"/></svg>
diff --git a/browser/components/screenshots/content/icon-welcome-face-without-eyes.svg b/browser/components/screenshots/content/icon-welcome-face-without-eyes.svg
new file mode 100644
index 0000000000..138308af57
--- /dev/null
+++ b/browser/components/screenshots/content/icon-welcome-face-without-eyes.svg
@@ -0,0 +1,4 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><style>.st0{fill:#fff}</style><g id="Visual-design"><g id="_x31_.2-Div-selection" transform="translate(-575 -503)"><g id="Introduction" transform="translate(250 503)"><g id="icon-welcomeface" transform="translate(325)"><g id="Layer_1_1_"><path id="Shape" class="st0" d="M11.4.9v2.9h-6c-.9 0-1.5.8-1.5 1.5v6H.8V3.8C.8 2.1 2.2.7 3.9.7h7.6v.2h-.1z"/><path id="Shape_1_" class="st0" d="M63.2 11.4h-3.1v-6c0-.8-.6-1.5-1.5-1.5h-6v-3h7.6c1.7 0 3.1 1.4 3.1 3.1l-.1 7.4z"/><path id="Shape_2_" class="st0" d="M52.6 63.2v-3.1h6c.9 0 1.5-.6 1.5-1.5v-6h3.1v7.6c0 1.7-1.4 3.1-3.1 3.1l-7.5-.1z"/><path id="Shape_3_" class="st0" d="M.8 52.7h3.1v6c0 .9.6 1.5 1.5 1.5h6v3.1H3.8c-1.7 0-3.1-1.4-3.1-3.1l.1-7.5z"/><path id="Shape_6_" class="st0" d="M33.3 49.2H33c-4.6-.1-7.8-3.6-7.9-3.8-.6-.8-.6-2 .1-2.7.8-.8 1.9-.6 2.6.1 0 0 2.3 2.6 5.2 2.6 1.8 0 3.6-.9 5.2-2.6.8-.8 1.9-.8 2.7 0s.8 1.9 0 2.7c-2.2 2.4-4.9 3.7-7.6 3.7z"/></g></g></g></g></g></svg> \ No newline at end of file
diff --git a/browser/components/screenshots/content/menu-fullpage.svg b/browser/components/screenshots/content/menu-fullpage.svg
new file mode 100644
index 0000000000..6552ef8cdd
--- /dev/null
+++ b/browser/components/screenshots/content/menu-fullpage.svg
@@ -0,0 +1,4 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 46 46"><path id="bg" d="M7 42h32V5.1H7z" fill="context-stroke #00fdff"/><g id="frame" transform="translate(0 6)"><path d="M40 5c.5 0 1 .4 1 1v24c0 .5-.5 1-1 1H6c-.6 0-1-.5-1-1V6c0-.6.4-1 1-1h34zM7 29h32V7H7v22z" fill="context-fill"/><path id="Fill-4" fill="context-fill" d="M7 7h32V5H7z"/><path id="Fill-6" fill="context-fill" d="M7 31h32v-2H7z"/></g><path id="dash" d="M38 11h1V9h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm-1 1h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-2-3H7v3h2v-1H8v-2zm-1-1h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1V9H7v2zm2-6H7v3h1V6h1V5zm1 1h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm5-1h-2v1h1v2h1V5z" fill="context-fill" opacity="0.5"/></svg> \ No newline at end of file
diff --git a/browser/components/screenshots/content/menu-visible.svg b/browser/components/screenshots/content/menu-visible.svg
new file mode 100644
index 0000000000..98cb1bfd3e
--- /dev/null
+++ b/browser/components/screenshots/content/menu-visible.svg
@@ -0,0 +1,4 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 46 46"><path d="M5 12c0-.6.5-1 1-1h34c.6 0 1 .5 1 1v24c0 .6-.5 1-1 1H6c-.6 0-1-.5-1-1V12zm2 23V13h32v22H7z" fill="context-fill"/><path d="M7 35h32V13H7z" fill="context-stroke #00fdff"/><path id="dash" d="M38 19h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm-1 1h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-2-3H7v3h2v-1H8v-2zm-1-1h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm2-6H7v3h1v-2h1v-1zm1 1h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm5-1h-2v1h1v2h1v-3z" fill="context-fill" opacity="0.5"/></svg> \ No newline at end of file
diff --git a/browser/components/screenshots/content/screenshots.css b/browser/components/screenshots/content/screenshots.css
new file mode 100644
index 0000000000..506f3658c9
--- /dev/null
+++ b/browser/components/screenshots/content/screenshots.css
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+html,
+body {
+ height: 100vh;
+ width: 100vw;
+}
+
+.image-view {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.preview-buttons {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ width: 100%;
+ border: 0;
+ box-sizing: border-box;
+ margin: 4px 0;
+ margin-inline-start: calc(-2% + 4px);
+}
+
+.preview-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ text-align: center;
+ user-select: none;
+ white-space: nowrap;
+ min-height: 36px;
+ font-size: 15px;
+ min-width: 36px;
+}
+
+.preview-button > img {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: 16px;
+ height: 16px;
+}
+
+#download > img,
+#copy > img {
+ margin-inline-end: 5px;
+}
+
+.preview-image {
+ height: 100%;
+ width: 100%;
+ overflow: auto;
+}
+
+#preview-image-div {
+ margin: 2%;
+ margin-top: 0;
+}
+
+#placeholder-image {
+ width: 100%;
+ height: 100%;
+}
diff --git a/browser/components/screenshots/content/screenshots.html b/browser/components/screenshots/content/screenshots.html
new file mode 100644
index 0000000000..88c71fb4fe
--- /dev/null
+++ b/browser/components/screenshots/content/screenshots.html
@@ -0,0 +1,68 @@
+<!DOCTYPE html>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ - You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title></title>
+ <meta
+ http-equiv="Content-Security-Policy"
+ content="default-src chrome:;img-src data:; object-src 'none'"
+ />
+
+ <link rel="localization" href="browser/screenshots.ftl" />
+
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/screenshots/screenshots.css"
+ />
+ <script
+ defer
+ src="chrome://browser/content/screenshots/screenshots.js"
+ ></script>
+ </head>
+
+ <body>
+ <template id="screenshots-dialog-template">
+ <div class="image-view">
+ <div class="preview-buttons">
+ <button
+ id="retry"
+ class="preview-button"
+ data-l10n-id="screenshots-retry-button-title"
+ >
+ <img src="chrome://global/skin/icons/reload.svg" />
+ </button>
+ <button
+ id="cancel"
+ class="preview-button"
+ data-l10n-id="screenshots-cancel-button-title"
+ >
+ <img src="chrome://global/skin/icons/close.svg" />
+ </button>
+ <button
+ id="copy"
+ class="preview-button"
+ data-l10n-id="screenshots-copy-button-title"
+ >
+ <img src="chrome://global/skin/icons/edit-copy.svg" />
+ <span data-l10n-id="screenshots-copy-button" />
+ </button>
+ <button
+ id="download"
+ class="preview-button primary"
+ data-l10n-id="screenshots-download-button-title"
+ >
+ <img src="chrome://browser/skin/downloads/downloads.svg" />
+ <span data-l10n-id="screenshots-download-button" />
+ </button>
+ </div>
+ <div class="preview-image">
+ <div id="preview-image-div"></div>
+ </div>
+ </div>
+ </template>
+ </body>
+</html>
diff --git a/browser/components/screenshots/content/screenshots.js b/browser/components/screenshots/content/screenshots.js
new file mode 100644
index 0000000000..9037038f38
--- /dev/null
+++ b/browser/components/screenshots/content/screenshots.js
@@ -0,0 +1,101 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* eslint-env mozilla/browser-window */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs",
+});
+
+class ScreenshotsUI extends HTMLElement {
+ constructor() {
+ super();
+ }
+ async connectedCallback() {
+ this.initialize();
+ }
+
+ initialize() {
+ if (this._initialized) {
+ return;
+ }
+ this._initialized = true;
+ let template = this.ownerDocument.getElementById(
+ "screenshots-dialog-template"
+ );
+ let templateContent = template.content;
+ this.appendChild(templateContent.cloneNode(true));
+
+ this._retryButton = this.querySelector("#retry");
+ this._retryButton.addEventListener("click", this);
+ this._cancelButton = this.querySelector("#cancel");
+ this._cancelButton.addEventListener("click", this);
+ this._copyButton = this.querySelector("#copy");
+ this._copyButton.addEventListener("click", this);
+ this._downloadButton = this.querySelector("#download");
+ this._downloadButton.addEventListener("click", this);
+ }
+
+ close() {
+ URL.revokeObjectURL(document.getElementById("placeholder-image").src);
+ window.close();
+ }
+
+ async handleEvent(event) {
+ if (event.type == "click" && event.currentTarget == this._cancelButton) {
+ this.close();
+ ScreenshotsUtils.recordTelemetryEvent("canceled", "preview_cancel", {});
+ } else if (
+ event.type == "click" &&
+ event.currentTarget == this._copyButton
+ ) {
+ this.saveToClipboard(
+ this.ownerDocument.getElementById("placeholder-image").src
+ );
+ } else if (
+ event.type == "click" &&
+ event.currentTarget == this._downloadButton
+ ) {
+ await this.saveToFile(
+ this.ownerDocument.getElementById("placeholder-image").src
+ );
+ } else if (
+ event.type == "click" &&
+ event.currentTarget == this._retryButton
+ ) {
+ Services.obs.notifyObservers(
+ window.parent.ownerGlobal,
+ "menuitem-screenshot",
+ "retry"
+ );
+ }
+ }
+
+ async saveToFile(dataUrl) {
+ await ScreenshotsUtils.downloadScreenshot(
+ null,
+ dataUrl,
+ window.parent.ownerGlobal.gBrowser.selectedBrowser
+ );
+
+ this.close();
+
+ ScreenshotsUtils.recordTelemetryEvent("download", "preview_download", {});
+ }
+
+ saveToClipboard(dataUrl) {
+ ScreenshotsUtils.copyScreenshot(
+ dataUrl,
+ window.parent.ownerGlobal.gBrowser.selectedBrowser
+ );
+
+ this.close();
+
+ ScreenshotsUtils.recordTelemetryEvent("copy", "preview_copy", {});
+ }
+}
+customElements.define("screenshots-ui", ScreenshotsUI);
diff --git a/browser/components/screenshots/fileHelpers.mjs b/browser/components/screenshots/fileHelpers.mjs
new file mode 100644
index 0000000000..4bbdbbbefc
--- /dev/null
+++ b/browser/components/screenshots/fileHelpers.mjs
@@ -0,0 +1,271 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ DownloadLastDir: "resource://gre/modules/DownloadLastDir.sys.mjs",
+ DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs",
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs",
+});
+
+/**
+ * Gets the filename automatically or by a file picker depending on "browser.download.useDownloadDir"
+ * @param filenameTitle The title of the current page
+ * @param browser The current browser
+ * @returns Path of the chosen filename
+ */
+export async function getFilename(filenameTitle, browser) {
+ if (filenameTitle === null) {
+ filenameTitle = await lazy.ScreenshotsUtils.getActor(browser).sendQuery(
+ "Screenshots:getDocumentTitle"
+ );
+ }
+ const date = new Date();
+ /* eslint-disable no-control-regex */
+ filenameTitle = filenameTitle
+ .replace(/[\\/]/g, "_")
+ .replace(/[\u200e\u200f\u202a-\u202e]/g, "")
+ .replace(/[\x00-\x1f\x7f-\x9f:*?|"<>;,+=\[\]]+/g, " ")
+ .replace(/^[\s\u180e.]+|[\s\u180e.]+$/g, "");
+ /* eslint-enable no-control-regex */
+ filenameTitle = filenameTitle.replace(/\s{1,4000}/g, " ");
+ const currentDateTime = new Date(
+ date.getTime() - date.getTimezoneOffset() * 60 * 1000
+ ).toISOString();
+ const filenameDate = currentDateTime.substring(0, 10);
+ const filenameTime = currentDateTime.substring(11, 19).replace(/:/g, "-");
+ let clipFilename = `Screenshot ${filenameDate} at ${filenameTime} ${filenameTitle}`;
+
+ // Crop the filename size at less than 246 bytes, so as to leave
+ // room for the extension and an ellipsis [...]. Note that JS
+ // strings are UTF16 but the filename will be converted to UTF8
+ // when saving which could take up more space, and we want a
+ // maximum of 255 bytes (not characters). Here, we iterate
+ // and crop at shorter and shorter points until we fit into
+ // 255 bytes.
+ let suffix = "";
+ for (let cropSize = 246; cropSize >= 0; cropSize -= 32) {
+ if (new Blob([clipFilename]).size > 246) {
+ clipFilename = clipFilename.substring(0, cropSize);
+ suffix = "[...]";
+ } else {
+ break;
+ }
+ }
+
+ clipFilename += suffix;
+
+ let extension = ".png";
+ let filename = clipFilename + extension;
+
+ let useDownloadDir = Services.prefs.getBoolPref(
+ "browser.download.useDownloadDir"
+ );
+ if (useDownloadDir) {
+ const downloadsDir = await lazy.Downloads.getPreferredDownloadsDirectory();
+ const downloadsDirExists = await IOUtils.exists(downloadsDir);
+ if (downloadsDirExists) {
+ // If filename is absolute, it will override the downloads directory and
+ // still be applied as expected.
+ filename = PathUtils.join(downloadsDir, filename);
+ }
+ } else {
+ let fileInfo = new FileInfo(filename);
+ let file;
+
+ let fpParams = {
+ fpTitleKey: "SaveImageTitle",
+ fileInfo,
+ contentType: "image/png",
+ saveAsType: 0,
+ file,
+ };
+
+ let accepted = await promiseTargetFile(fpParams, browser.ownerGlobal);
+ if (!accepted) {
+ return null;
+ }
+
+ filename = fpParams.file.path;
+ }
+
+ return filename;
+}
+
+// The below functions are a modified copy from toolkit/content/contentAreaUtils.js
+/**
+ * Structure for holding info about a URL and the target filename it should be
+ * saved to.
+ * @param aFileName The target filename
+ */
+class FileInfo {
+ constructor(aFileName) {
+ this.fileName = aFileName;
+ this.fileBaseName = aFileName.replace(".png", "");
+ this.fileExt = "png";
+ }
+}
+
+const ContentAreaUtils = {
+ get stringBundle() {
+ delete this.stringBundle;
+ return (this.stringBundle = Services.strings.createBundle(
+ "chrome://global/locale/contentAreaCommands.properties"
+ ));
+ },
+};
+
+function makeFilePicker() {
+ const fpContractID = "@mozilla.org/filepicker;1";
+ const fpIID = Ci.nsIFilePicker;
+ return Cc[fpContractID].createInstance(fpIID);
+}
+
+function getMIMEService() {
+ const mimeSvcContractID = "@mozilla.org/mime;1";
+ const mimeSvcIID = Ci.nsIMIMEService;
+ const mimeSvc = Cc[mimeSvcContractID].getService(mimeSvcIID);
+ return mimeSvc;
+}
+
+function getMIMEInfoForType(aMIMEType, aExtension) {
+ if (aMIMEType || aExtension) {
+ try {
+ return getMIMEService().getFromTypeAndExtension(aMIMEType, aExtension);
+ } catch (e) {}
+ }
+ return null;
+}
+
+// This is only used after the user has entered a filename.
+function validateFileName(aFileName) {
+ let processed =
+ lazy.DownloadPaths.sanitize(aFileName, {
+ compressWhitespaces: false,
+ allowInvalidFilenames: true,
+ }) || "_";
+ if (AppConstants.platform == "android") {
+ // If a large part of the filename has been sanitized, then we
+ // will use a default filename instead
+ if (processed.replace(/_/g, "").length <= processed.length / 2) {
+ // We purposefully do not use a localized default filename,
+ // which we could have done using
+ // ContentAreaUtils.stringBundle.GetStringFromName("UntitledSaveFileName")
+ // since it may contain invalid characters.
+ let original = processed;
+ processed = "download";
+
+ // Preserve a suffix, if there is one
+ if (original.includes(".")) {
+ let suffix = original.split(".").pop();
+ if (suffix && !suffix.includes("_")) {
+ processed += "." + suffix;
+ }
+ }
+ }
+ }
+ return processed;
+}
+
+function appendFiltersForContentType(
+ aFilePicker,
+ aContentType,
+ aFileExtension
+) {
+ let mimeInfo = getMIMEInfoForType(aContentType, aFileExtension);
+ if (mimeInfo) {
+ let extString = "";
+ for (let extension of mimeInfo.getFileExtensions()) {
+ if (extString) {
+ extString += "; ";
+ } // If adding more than one extension,
+ // separate by semi-colon
+ extString += "*." + extension;
+ }
+
+ if (extString) {
+ aFilePicker.appendFilter(mimeInfo.description, extString);
+ }
+ }
+
+ // Always append the all files (*) filter
+ aFilePicker.appendFilters(Ci.nsIFilePicker.filterAll);
+}
+
+/**
+ * Given the Filepicker Parameters (aFpP), show the file picker dialog,
+ * prompting the user to confirm (or change) the fileName.
+ * @param aFpP
+ * A structure (see definition in internalSave(...) method)
+ * containing all the data used within this method.
+ * @param win
+ * The window used for opening the file picker
+ * @return Promise
+ * @resolve a boolean. When true, it indicates that the file picker dialog
+ * is accepted.
+ */
+function promiseTargetFile(aFpP, win) {
+ return (async function () {
+ let downloadLastDir = new lazy.DownloadLastDir(win);
+
+ // Default to the user's default downloads directory configured
+ // through download prefs.
+ let dirPath = await lazy.Downloads.getPreferredDownloadsDirectory();
+ let dirExists = await IOUtils.exists(dirPath);
+ let dir = new lazy.FileUtils.File(dirPath);
+
+ // We must prompt for the file name explicitly.
+ // If we must prompt because we were asked to...
+ let file = await downloadLastDir.getFileAsync(null);
+ if (file && (await IOUtils.exists(file.path))) {
+ dir = file;
+ dirExists = true;
+ }
+
+ if (!dirExists) {
+ // Default to desktop.
+ dir = Services.dirsvc.get("Desk", Ci.nsIFile);
+ }
+
+ let fp = makeFilePicker();
+ let titleKey = aFpP.fpTitleKey;
+ fp.init(
+ win,
+ ContentAreaUtils.stringBundle.GetStringFromName(titleKey),
+ Ci.nsIFilePicker.modeSave
+ );
+
+ fp.displayDirectory = dir;
+ fp.defaultExtension = aFpP.fileInfo.fileExt;
+ fp.defaultString = aFpP.fileInfo.fileName;
+ appendFiltersForContentType(fp, aFpP.contentType, aFpP.fileInfo.fileExt);
+
+ let result = await new Promise(resolve => {
+ fp.open(function (aResult) {
+ resolve(aResult);
+ });
+ });
+ if (result == Ci.nsIFilePicker.returnCancel || !fp.file) {
+ return false;
+ }
+
+ // Do not store the last save directory as a pref inside the private browsing mode
+ downloadLastDir.setFile(null, fp.file.parent);
+
+ fp.file.leafName = validateFileName(fp.file.leafName);
+
+ aFpP.saveAsType = fp.filterIndex;
+ aFpP.file = fp.file;
+ aFpP.fileURL = fp.fileURL;
+
+ return true;
+ })();
+}
diff --git a/browser/components/screenshots/jar.mn b/browser/components/screenshots/jar.mn
new file mode 100644
index 0000000000..0e3d87ba11
--- /dev/null
+++ b/browser/components/screenshots/jar.mn
@@ -0,0 +1,23 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+browser.jar:
+ content/browser/screenshots/cancel.svg (content/cancel.svg)
+ content/browser/screenshots/copied-notification.svg (content/copied-notification.svg)
+ content/browser/screenshots/copy.svg (content/copy.svg)
+ content/browser/screenshots/download-white.svg (content/download-white.svg)
+ content/browser/screenshots/download.svg (content/download.svg)
+ content/browser/screenshots/fileHelpers.mjs
+ content/browser/screenshots/icon-welcome-face-without-eyes.svg (content/icon-welcome-face-without-eyes.svg)
+ content/browser/screenshots/menu-fullpage.svg (content/menu-fullpage.svg)
+ content/browser/screenshots/menu-visible.svg (content/menu-visible.svg)
+ content/browser/screenshots/screenshots.js (content/screenshots.js)
+ content/browser/screenshots/screenshots-buttons.js (screenshots-buttons.js)
+ content/browser/screenshots/screenshots-buttons.css (screenshots-buttons.css)
+ content/browser/screenshots/screenshots.css (content/screenshots.css)
+ content/browser/screenshots/screenshots.html (content/screenshots.html)
+ content/browser/screenshots/overlay/ (overlay/**)
+
+
+% content screenshots-overlay %overlay/
diff --git a/browser/components/screenshots/moz.build b/browser/components/screenshots/moz.build
new file mode 100644
index 0000000000..73b03cd0bd
--- /dev/null
+++ b/browser/components/screenshots/moz.build
@@ -0,0 +1,19 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES += [
+ "ScreenshotsOverlayChild.sys.mjs",
+ "ScreenshotsUtils.sys.mjs",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Screenshots")
+
+BROWSER_CHROME_MANIFESTS += [
+ "tests/browser/browser.ini",
+]
diff --git a/browser/components/screenshots/overlay/overlay.css b/browser/components/screenshots/overlay/overlay.css
new file mode 100644
index 0000000000..7b008fb8aa
--- /dev/null
+++ b/browser/components/screenshots/overlay/overlay.css
@@ -0,0 +1,481 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:-moz-native-anonymous #screenshots-component {
+ --in-content-page-background: #fff;
+
+ --in-content-button-text-color: rgb(21, 20, 26);
+ --in-content-button-text-color-hover: var(--in-content-text-color);
+ --in-content-button-text-color-active: var(--in-content-button-text-color);
+ --in-content-button-background: rgba(207,207,216,.33);
+ --in-content-button-background-hover: rgba(207,207,216,.66);
+ --in-content-button-background-active: rgb(207,207,216);
+ --in-content-button-border-color: transparent;
+ --in-content-button-border-color-hover: transparent;
+ --in-content-button-border-color-active: transparent;
+
+ --in-content-primary-button-text-color: rgb(251,251,254);
+ --in-content-primary-button-text-color-hover: var(--in-content-primary-button-text-color);
+ --in-content-primary-button-text-color-active: var(--in-content-primary-button-text-color);
+ --in-content-primary-button-background: #0061e0;
+ --in-content-primary-button-background-hover: #0250bb;
+ --in-content-primary-button-background-active: #053e94;
+ --in-content-primary-button-border-color: transparent;
+ --in-content-primary-button-border-color-hover: transparent;
+ --in-content-primary-button-border-color-active: transparent;
+
+ --in-content-focus-outline-color: var(--in-content-primary-button-background);
+}
+
+@media (prefers-color-scheme: dark) {
+ :-moz-native-anonymous #screenshots-component {
+ --in-content-page-background: #42414d;
+
+ --in-content-button-text-color: rgb(251,251,254);
+ --in-content-button-background: rgb(43,42,51);
+ --in-content-button-background-hover: rgb(82,82,94);
+ --in-content-button-background-active: rgb(91,91,102);
+
+ --in-content-primary-button-text-color: rgb(43,42,51);
+ --in-content-primary-button-background: rgb(0,221,255);
+ --in-content-primary-button-background-hover: rgb(128,235,255);
+ --in-content-primary-button-background-active: rgb(170,242,255);
+ }
+}
+
+@media (forced-colors: active), (prefers-contrast) {
+ :-moz-native-anonymous #screenshots-component {
+ --in-content-page-background: Canvas;
+
+ --in-content-button-text-color: ButtonText;
+ --in-content-button-text-color-hover: SelectedItemText;
+ --in-content-button-text-color-active: SelectedItem;
+ --in-content-button-background: ButtonFace;
+ --in-content-button-background-hover: SelectedItem;
+ --in-content-button-background-active: SelectedItemText;
+ --in-content-button-border-color: ButtonText;
+ --in-content-button-border-color-hover: SelectedItemText;
+ --in-content-button-border-color-active: SelectedItem;
+
+ --in-content-primary-button-text-color: ButtonFace;
+ --in-content-primary-button-text-color-hover: SelectedItemText;
+ --in-content-primary-button-text-color-active: SelectedItem;
+ --in-content-primary-button-background: ButtonText;
+ --in-content-primary-button-background-hover: SelectedItem;
+ --in-content-primary-button-background-active: SelectedItemText;
+ --in-content-primary-button-border-color: ButtonFace;
+ --in-content-primary-button-border-color-hover: SelectedItemText;
+ --in-content-primary-button-border-color-active: SelectedItem;
+
+ --in-content-focus-outline-color: -moz-DialogText;
+ }
+}
+
+:-moz-native-anonymous #screenshots-component {
+ width: 100%;
+ height: 100%;
+ overflow: clip;
+ user-select: none;
+ pointer-events: auto;
+ touch-action: none;
+}
+
+/**
+ * Overlay content is position: fixed as we need to allow for the possiblily
+ * of the document scrolling or changing size while the overlay is visible
+ */
+:-moz-native-anonymous #screenshots-overlay-container {
+ /*
+ Content CSS applying to the html element can impact the overlay.
+ To avoid that, possible cases have been set to initial.
+ */
+ text-transform: initial;
+ text-indent: initial;
+ letter-spacing: initial;
+ word-spacing: initial;
+ color: initial;
+ direction: initial;
+ writing-mode: initial;
+ z-index: 1;
+ position: absolute;
+ pointer-events: auto;
+ cursor: crosshair;
+}
+
+:-moz-native-anonymous #preview-container {
+ background-color: rgba(0, 0, 0, 0.7);
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
+
+:-moz-native-anonymous #selection-container {
+ overflow: clip;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
+
+:-moz-native-anonymous #screenshots-overlay-container[hidden] {
+ display: none;
+}
+
+:-moz-native-anonymous #screenshots-overlay-container[dragging] {
+ cursor: grabbing;
+}
+
+:-moz-native-anonymous #buttons {
+ position: absolute;
+ margin: 10px 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--in-content-page-background);
+ border-radius: 4px;
+ padding: 4px;
+}
+
+:-moz-native-anonymous .screenshots-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ text-align: center;
+ user-select: none;
+ white-space: nowrap;
+ min-height: 36px;
+ font-size: 15px;
+ min-width: 36px;
+ z-index: 6;
+ margin-inline: 4px;
+ font-family: "Segoe UI", sans-serif;
+
+ /* Below styles are copied from common-shared.css */
+ appearance: none;
+ padding: 7px 15px;
+ text-decoration: none;
+ font-weight: 600;
+ margin: 4px;
+ border: 1px solid var(--in-content-button-border-color);
+ color: var(--in-content-button-text-color);
+ border-radius: 4px;
+ background-color: var(--in-content-button-background);
+}
+
+:-moz-native-anonymous .screenshots-button:focus-visible,
+:-moz-native-anonymous #screenshots-cancel-button:focus-visible {
+ outline: 2px solid var(--in-content-focus-outline-color);
+ outline-offset: 2px;
+}
+
+:-moz-native-anonymous .screenshots-button:hover {
+ background-color: var(--in-content-button-background-hover);
+ border-color: var(--in-content-button-border-color-hover);
+ color: var(--in-content-button-text-color-hover);
+}
+
+:-moz-native-anonymous .screenshots-button:active {
+ background-color: var(--in-content-button-background-active);
+ border-color: var(--in-content-button-border-color-active);
+ color: var(--in-content-button-text-color-active);
+}
+
+:-moz-native-anonymous .primary {
+ background-color: var(--in-content-primary-button-background);
+ border-color: var(--in-content-primary-button-border-color);
+ color: var(--in-content-primary-button-text-color);
+}
+
+:-moz-native-anonymous .primary:hover {
+ background-color: var(--in-content-primary-button-background-hover);
+ border-color: var(--in-content-primary-button-border-color-hover);
+ color: var(--in-content-primary-button-text-color-hover);
+}
+
+:-moz-native-anonymous .primary:active {
+ background-color: var(--in-content-primary-button-background-active);
+ border-color: var(--in-content-primary-button-border-color-active);
+ color: var(--in-content-primary-button-text-color-active);
+}
+
+:-moz-native-anonymous #screenshots-cancel-button {
+ background-color: transparent;
+ margin-top: 40px;
+ width: fit-content;
+ border-color: #fff;
+ color: #fff;
+}
+
+:-moz-native-anonymous #screenshots-cancel-button:hover {
+ background-color: #fff;
+ color: #000;
+}
+
+@media (forced-colors: active), (prefers-contrast) {
+ :-moz-native-anonymous #screenshots-cancel-button {
+ border-color: ButtonBorder;
+ color: CanvasText;
+ }
+}
+
+:-moz-native-anonymous .screenshots-button > img {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: 16px;
+ height: 16px;
+}
+
+:-moz-native-anonymous #cancel > img {
+ content: url("chrome://global/skin/icons/close.svg");
+}
+
+:-moz-native-anonymous #copy > img {
+ content: url("chrome://global/skin/icons/edit-copy.svg");
+}
+
+:-moz-native-anonymous #download > img {
+ content: url("chrome://browser/skin/downloads/downloads.svg");
+}
+
+:-moz-native-anonymous #download > img,
+:-moz-native-anonymous #copy > img {
+ margin-inline-end: 5px;
+}
+
+:-moz-native-anonymous .fixed-container {
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ justify-content: center;
+ inset-inline-start: 0;
+ margin: 0;
+ padding: 0;
+ position: fixed;
+ top: 0;
+ width: 100%;
+}
+
+:-moz-native-anonymous .face-container {
+ position: relative;
+ width: 64px;
+ height: 64px;
+}
+
+:-moz-native-anonymous .face {
+ width: 62px;
+ height: 62px;
+ display: block;
+ background-image: url("chrome://browser/content/screenshots/icon-welcome-face-without-eyes.svg");
+}
+
+:-moz-native-anonymous .eye {
+ background-color: #fff;
+ width: 10px;
+ height: 14px;
+ position: absolute;
+ border-radius: 100%;
+ overflow: hidden;
+ inset-inline-start: 16px;
+ top: 19px;
+}
+
+:-moz-native-anonymous .eyeball {
+ position: absolute;
+ width: 6px;
+ height: 6px;
+ background-color: #000;
+ border-radius: 50%;
+ inset-inline-start: 2px;
+ top: 4px;
+ z-index: 10;
+}
+
+:-moz-native-anonymous .left {
+ margin-inline-start: 0;
+}
+
+:-moz-native-anonymous .right {
+ margin-inline-start: 20px;
+}
+
+:-moz-native-anonymous .preview-instructions {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ animation: pulse 125mm cubic-bezier(0.07, 0.95, 0, 1);
+ color: #fff;
+ font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif;
+ font-size: 24px;
+ line-height: 32px;
+ text-align: center;
+ padding-top: 20px;
+ width: 400px;
+}
+
+@media (forced-colors: active), (prefers-contrast) {
+ :-moz-native-anonymous .preview-instructions {
+ color: CanvasText;
+ }
+}
+
+:-moz-native-anonymous #hover-highlight {
+ animation: fade-in 125ms forwards cubic-bezier(0.07, 0.95, 0, 1);
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 1px;
+ pointer-events: none;
+ position: absolute;
+ z-index: 11;
+}
+
+:-moz-native-anonymous #hover-highlight::before {
+ border: 2px dashed rgba(255, 255, 255, 0.4);
+ bottom: 0;
+ content: "";
+ inset-inline-start: 0;
+ position: absolute;
+ inset-inline-end: 0;
+ top: 0;
+}
+
+:-moz-native-anonymous .bghighlight {
+ background-color: rgba(0, 0, 0, 0.7);
+ position: absolute;
+ overflow: clip;
+}
+
+:-moz-native-anonymous .highlight {
+ border-radius: 1px;
+ border: 2px dashed rgba(255, 255, 255, 0.8);
+ box-sizing: border-box;
+ cursor: move;
+ position: absolute;
+ pointer-events: auto;
+ z-index: 2;
+}
+
+:-moz-native-anonymous .mover-target {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: absolute;
+ z-index: 5;
+ pointer-events: auto;
+}
+
+:-moz-native-anonymous .mover-target.direction-topLeft {
+ cursor: nwse-resize;
+ height: 60px;
+ left: -30px;
+ top: -30px;
+ width: 60px;
+}
+
+:-moz-native-anonymous .mover-target.direction-top {
+ cursor: ns-resize;
+ height: 60px;
+ inset-inline-start: 0;
+ top: -30px;
+ width: 100%;
+ z-index: 4;
+}
+
+:-moz-native-anonymous .mover-target.direction-topRight {
+ cursor: nesw-resize;
+ height: 60px;
+ right: -30px;
+ top: -30px;
+ width: 60px;
+}
+
+:-moz-native-anonymous .mover-target.direction-left {
+ cursor: ew-resize;
+ height: 100%;
+ left: -30px;
+ top: 0;
+ width: 60px;
+ z-index: 4;
+}
+
+:-moz-native-anonymous .mover-target.direction-right {
+ cursor: ew-resize;
+ height: 100%;
+ right: -30px;
+ top: 0;
+ width: 60px;
+ z-index: 4;
+}
+
+:-moz-native-anonymous .mover-target.direction-bottomLeft {
+ bottom: -30px;
+ cursor: nesw-resize;
+ height: 60px;
+ left: -30px;
+ width: 60px;
+}
+
+:-moz-native-anonymous .mover-target.direction-bottom {
+ bottom: -30px;
+ cursor: ns-resize;
+ height: 60px;
+ inset-inline-start: 0;
+ width: 100%;
+ z-index: 4;
+}
+
+:-moz-native-anonymous .mover-target.direction-bottomRight {
+ bottom: -30px;
+ cursor: nwse-resize;
+ height: 60px;
+ right: -30px;
+ width: 60px;
+}
+
+:-moz-native-anonymous .mover-target:hover .mover {
+ transform: scale(1.05);
+}
+
+:-moz-native-anonymous .mover {
+ background-color: #fff;
+ border-radius: 50%;
+ box-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
+ height: 16px;
+ opacity: 1;
+ position: relative;
+ transition: transform 125ms cubic-bezier(0.07, 0.95, 0, 1);
+ width: 16px;
+}
+
+:-moz-native-anonymous .small-selection .mover {
+ height: 10px;
+ width: 10px;
+}
+
+:-moz-native-anonymous .direction-topLeft .mover,
+:-moz-native-anonymous .direction-left .mover,
+:-moz-native-anonymous .direction-bottomLeft .mover {
+ left: -1px;
+}
+
+:-moz-native-anonymous .direction-topLeft .mover,
+:-moz-native-anonymous .direction-top .mover,
+:-moz-native-anonymous .direction-topRight .mover {
+ top: -1px;
+}
+
+:-moz-native-anonymous .direction-topRight .mover,
+:-moz-native-anonymous .direction-right .mover,
+:-moz-native-anonymous .direction-bottomRight .mover {
+ right: -1px;
+}
+
+:-moz-native-anonymous .direction-bottomRight .mover,
+:-moz-native-anonymous .direction-bottom .mover,
+:-moz-native-anonymous .direction-bottomLeft .mover {
+ bottom: -1px;
+}
diff --git a/browser/components/screenshots/screenshots-buttons.css b/browser/components/screenshots/screenshots-buttons.css
new file mode 100644
index 0000000000..2b0ccbd004
--- /dev/null
+++ b/browser/components/screenshots/screenshots-buttons.css
@@ -0,0 +1,27 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.all-buttons-container button {
+ background-position: center top;
+ background-repeat: no-repeat;
+ background-size: 46px 46px;
+ min-width: 90px;
+ padding: 46px 5px 5px;
+ margin: 2px;
+ color: var(--panel-color);
+}
+
+.all-buttons-container .full-page {
+ background-image: url("chrome://browser/content/screenshots/menu-fullpage.svg");
+}
+
+.all-buttons-container .visible-page {
+ background-image: url("chrome://browser/content/screenshots/menu-visible.svg");
+}
+
+.full-page, .visible-page {
+ -moz-context-properties: fill, stroke;
+ fill: currentColor;
+ stroke: var(--color-accent-primary);
+}
diff --git a/browser/components/screenshots/screenshots-buttons.js b/browser/components/screenshots/screenshots-buttons.js
new file mode 100644
index 0000000000..66188bd3e2
--- /dev/null
+++ b/browser/components/screenshots/screenshots-buttons.js
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* eslint-env mozilla/browser-window */
+
+"use strict";
+
+// This is loaded into chrome windows with the subscript loader. Wrap in
+// a block to prevent accidentally leaking globals onto `window`.
+{
+ class ScreenshotsButtons extends MozXULElement {
+ static get markup() {
+ return `
+ <html:link rel="stylesheet" href="chrome://global/skin/in-content/common.css"/>
+ <html:link rel="stylesheet" href="chrome://browser/content/screenshots/screenshots-buttons.css"/>
+ <html:div id="screenshots-buttons" class="all-buttons-container">
+ <html:button class="visible-page" data-l10n-id="screenshots-save-visible-button"></html:button>
+ <html:button class="full-page" data-l10n-id="screenshots-save-page-button"></html:button>
+ </html:div>
+ `;
+ }
+
+ connectedCallback() {
+ const shadowRoot = this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(shadowRoot);
+
+ let fragment = MozXULElement.parseXULToFragment(this.constructor.markup);
+ this.shadowRoot.append(fragment);
+
+ let button1 = shadowRoot.querySelector(".visible-page");
+ button1.onclick = function () {
+ Services.obs.notifyObservers(
+ gBrowser.ownerGlobal,
+ "screenshots-take-screenshot",
+ "visible"
+ );
+ };
+
+ let button2 = shadowRoot.querySelector(".full-page");
+ button2.onclick = function () {
+ Services.obs.notifyObservers(
+ gBrowser.ownerGlobal,
+ "screenshots-take-screenshot",
+ "full-page"
+ );
+ };
+ }
+
+ disconnectedCallback() {
+ document.l10n.disconnectRoot(this.shadowRoot);
+ }
+
+ focusFirst(focusOptions) {
+ this.shadowRoot.querySelector("button:enabled").focus(focusOptions);
+ }
+ }
+ customElements.define("screenshots-buttons", ScreenshotsButtons, {
+ extends: "toolbar",
+ });
+}
diff --git a/browser/components/screenshots/tests/browser/browser.ini b/browser/components/screenshots/tests/browser/browser.ini
new file mode 100644
index 0000000000..c850f03a97
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser.ini
@@ -0,0 +1,29 @@
+[DEFAULT]
+support-files =
+ head.js
+ test-page.html
+ short-test-page.html
+ large-test-page.html
+
+prefs =
+ extensions.screenshots.disabled=false
+ screenshots.browser.component.enabled=true
+
+[browser_screenshots_drag_scroll_test.js]
+[browser_screenshots_drag_test.js]
+[browser_screenshots_focus_test.js]
+[browser_screenshots_overlay_panel_sync.js]
+[browser_screenshots_page_unload.js]
+[browser_screenshots_short_page_test.js]
+[browser_screenshots_telemetry_tests.js]
+[browser_screenshots_test_downloads.js]
+[browser_screenshots_test_escape.js]
+[browser_screenshots_test_full_page.js]
+skip-if =
+ (!debug && os == 'win' && os_version == '6.1') # Bug 1746281
+[browser_screenshots_test_page_crash.js]
+skip-if = !crashreporter
+[browser_screenshots_test_screenshot_too_big.js]
+[browser_screenshots_test_toggle_pref.js]
+[browser_screenshots_test_toolbar_button.js]
+[browser_screenshots_test_visible.js]
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js b/browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js
new file mode 100644
index 0000000000..19669b7427
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js
@@ -0,0 +1,400 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that screenshots overlay covers the entire page
+ */
+add_task(async function test_overlayCoversEntirePage() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ info(JSON.stringify(contentInfo, null, 2));
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ let dimensions = await helper.getSelectionLayerDimensions();
+ info(JSON.stringify(dimensions));
+ is(
+ dimensions.scrollWidth,
+ contentInfo.scrollWidth,
+ "The overlay spans the entire width of the page"
+ );
+
+ is(
+ dimensions.scrollHeight,
+ contentInfo.scrollHeight,
+ "The overlay spans the entire height of the page"
+ );
+ }
+ );
+});
+
+/**
+ * Test dragging screenshots box off top left of screen
+ */
+add_task(async function test_draggingBoxOffTopLeft() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ let startX = 10;
+ let startY = 10;
+ let endX = 500;
+ let endY = 500;
+ await helper.dragOverlay(startX, startY, endX, endY);
+
+ mouse.down(
+ startX + Math.floor((endX - startX) / 2),
+ startY + Math.floor((endY - startY) / 2)
+ );
+
+ await helper.waitForStateChange("resizing");
+ let state = await helper.getOverlayState();
+ is(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(10, 10);
+
+ // We moved the box to the edge of the screen so we need to wait until the box size is updated
+ await helper.waitForSelectionBoxSizeChange(490);
+
+ let dimensions = await helper.getSelectionBoxDimensions();
+
+ is(dimensions.x1, 0, "The box x1 position is now 0");
+ is(dimensions.y1, 0, "The box y1 position is now 0");
+ is(dimensions.width, 255, "The box width is now 255");
+ is(dimensions.height, 255, "The box height is now 255");
+
+ mouse.move(
+ startX + Math.floor((endX - startX) / 2),
+ startY + Math.floor((endY - startY) / 2)
+ );
+
+ mouse.up(
+ startX + Math.floor((endX - startX) / 2),
+ startY + Math.floor((endY - startY) / 2)
+ );
+
+ // We moved the box off the edge of the screen so we need to wait until the box size is updated
+ await helper.waitForSelectionBoxSizeChange(255);
+
+ dimensions = await helper.getSelectionBoxDimensions();
+
+ is(dimensions.x1, 10, "The box x1 position is now 10 again");
+ is(dimensions.y1, 10, "The box y1 position is now 10 again");
+ is(dimensions.width, 490, "The box width is now 490 again");
+ is(dimensions.height, 490, "The box height is now 490 again");
+ }
+ );
+});
+
+/**
+ * Test dragging screenshots box off bottom right of screen
+ */
+add_task(async function test_draggingBoxOffBottomRight() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ let contentInfo = await helper.getContentDimensions();
+ info(JSON.stringify(contentInfo));
+ ok(contentInfo, "Got dimensions back from the content");
+
+ await helper.scrollContentWindow(
+ contentInfo.scrollWidth - contentInfo.clientWidth,
+ contentInfo.scrollHeight - contentInfo.clientHeight
+ );
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ let startX = contentInfo.scrollWidth - 500;
+ let startY = contentInfo.scrollHeight - 500;
+ let endX = contentInfo.scrollWidth - 20;
+ let endY = contentInfo.scrollHeight - 20;
+
+ await helper.dragOverlay(startX, startY, endX, endY);
+
+ // move box off the bottom right of the screen
+ mouse.down(
+ startX + Math.floor((endX - startX) / 2),
+ startY + Math.floor((endY - startY) / 2)
+ );
+ mouse.move(
+ startX + 50 + Math.floor((endX - startX) / 2),
+ startY + 50 + Math.floor((endY - startY) / 2)
+ );
+
+ await helper.waitForStateChange("resizing");
+ let state = await helper.getOverlayState();
+ is(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(endX, endY);
+
+ // We moved the box to the edge of the screen so we need to wait until the box size is updated
+ await helper.waitForSelectionBoxSizeChange(480);
+
+ let dimensions = await helper.getSelectionBoxDimensions();
+
+ is(dimensions.x1, startX + 240, "The box x1 position is now 3748");
+ is(dimensions.y1, startY + 240, "The box y1 position is now 3756");
+ is(dimensions.width, 260, "The box width is now 260");
+ is(dimensions.height, 260, "The box height is now 260");
+
+ mouse.move(
+ startX + Math.floor((endX - startX) / 2),
+ startY + Math.floor((endY - startY) / 2)
+ );
+
+ mouse.up(
+ startX + Math.floor((endX - startX) / 2),
+ startY + Math.floor((endY - startY) / 2)
+ );
+
+ // We moved the box off the edge of the screen so we need to wait until the box size is updated
+ await helper.waitForSelectionBoxSizeChange(252);
+
+ dimensions = await helper.getSelectionBoxDimensions();
+
+ is(dimensions.x1, startX, "The box x1 position is now 3508 again");
+ is(dimensions.y1, startY, "The box y1 position is now 3516 again");
+ is(dimensions.width, 480, "The box width is now 480 again");
+ is(dimensions.height, 480, "The box height is now 480 again");
+ }
+ );
+});
+
+/**
+ * test scrolling while screenshots is open
+ */
+add_task(async function test_scrollingScreenshotsOpen() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ let contentInfo = await helper.getContentDimensions();
+ info(JSON.stringify(contentInfo));
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ let startX = 10;
+ let startY = 10;
+ let endX = 100;
+ let endY = 100;
+
+ await helper.dragOverlay(startX, startY, endX, endY);
+
+ let scrollX = 1000;
+ let scrollY = 1000;
+
+ await helper.scrollContentWindow(scrollX, scrollY);
+
+ let dimensions = await helper.getSelectionBoxDimensions();
+
+ is(dimensions.x1, startX, "The box x1 position is 10");
+ is(dimensions.y1, startY, "The box y1 position is 10");
+ is(dimensions.width, endX - startX, "The box width is now 90");
+ is(dimensions.height, endY - startY, "The box height is now 90");
+
+ // reset screenshots box
+ mouse.click(scrollX + startX, scrollY + endY);
+ await helper.waitForStateChange("crosshairs");
+
+ await helper.dragOverlay(
+ scrollX + startX,
+ scrollY + startY,
+ scrollX + endX,
+ scrollY + endY
+ );
+
+ await helper.scrollContentWindow(0, 0);
+
+ dimensions = await helper.getSelectionBoxDimensions();
+
+ is(dimensions.x1, scrollX + startX, "The box x1 position is 1010");
+ is(dimensions.y1, scrollY + startY, "The box y1 position is 1010");
+ is(dimensions.width, endX - startX, "The box width is now 90");
+ is(dimensions.height, endY - startY, "The box height is now 90");
+
+ // reset screenshots box
+ mouse.click(10, 10);
+ await helper.waitForStateChange("crosshairs");
+
+ await helper.dragOverlay(
+ startX,
+ startY,
+ contentInfo.clientWidth - 10,
+ contentInfo.clientHeight - 10
+ );
+
+ await helper.scrollContentWindow(
+ contentInfo.clientWidth - 20,
+ contentInfo.clientHeight - 20
+ );
+
+ mouse.down(contentInfo.clientWidth - 10, contentInfo.clientHeight - 10);
+
+ await helper.waitForStateChange("resizing");
+
+ mouse.move(
+ contentInfo.clientWidth * 2 - 30,
+ contentInfo.clientHeight * 2 - 30
+ );
+
+ mouse.up(
+ contentInfo.clientWidth * 2 - 30,
+ contentInfo.clientHeight * 2 - 30
+ );
+
+ await helper.waitForStateChange("selected");
+
+ dimensions = await helper.getSelectionLayerDimensions();
+ info(JSON.stringify(dimensions));
+ is(dimensions.boxLeft, startX, "The box left is 10");
+ is(dimensions.boxTop, startY, "The box top is 10");
+ is(
+ dimensions.boxRight,
+ contentInfo.clientWidth * 2 - 30,
+ "The box right is 2 x clientWidth - 30"
+ );
+ is(
+ dimensions.boxBottom,
+ contentInfo.clientHeight * 2 - 30,
+ "The box right is 2 x clientHeight - 30"
+ );
+ is(
+ dimensions.boxWidth,
+ contentInfo.clientWidth * 2 - 40,
+ "The box right is 2 x clientWidth - 40"
+ );
+ is(
+ dimensions.boxHeight,
+ contentInfo.clientHeight * 2 - 40,
+ "The box right is 2 x clientHeight - 40"
+ );
+ is(
+ dimensions.scrollWidth,
+ contentInfo.scrollWidth,
+ "The overlay spans the entire width of the page"
+ );
+ is(
+ dimensions.scrollHeight,
+ contentInfo.scrollHeight,
+ "The overlay spans the entire height of the page"
+ );
+ }
+ );
+});
+
+/**
+ * test scroll if by edge
+ */
+add_task(async function test_scrollIfByEdge() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ let windowX = 1000;
+ let windowY = 1000;
+
+ await helper.scrollContentWindow(windowX, windowY);
+
+ await TestUtils.waitForTick();
+
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+
+ let { scrollX, scrollY } = await helper.getWindowPosition();
+
+ is(scrollX, windowX, "Window x position is 1000");
+ is(scrollY, windowY, "Window y position is 1000");
+
+ let startX = 1100;
+ let startY = 1100;
+ let endX = 1010;
+ let endY = 1010;
+
+ // The window won't scroll if the state is draggingReady so we move to
+ // get into the dragging state and then move again to scroll the window
+ mouse.down(startX, startY);
+ await helper.waitForStateChange("draggingReady");
+ mouse.move(1050, 1050);
+ await helper.waitForStateChange("dragging");
+ mouse.move(endX, endY);
+ mouse.up(endX, endY);
+ await helper.waitForStateChange("selected");
+
+ windowX = 980;
+ windowY = 980;
+ await helper.waitForScrollTo(windowX, windowY);
+
+ ({ scrollX, scrollY } = await helper.getWindowPosition());
+
+ is(scrollX, windowX, "Window x position is 980");
+ is(scrollY, windowY, "Window y position is 980");
+
+ let contentInfo = await helper.getContentDimensions();
+
+ endX = windowX + contentInfo.clientWidth - 10;
+ endY = windowY + contentInfo.clientHeight - 10;
+
+ info(
+ `starting to drag overlay to ${endX}, ${endY} in test\nclientInfo: ${JSON.stringify(
+ contentInfo
+ )}\n`
+ );
+ mouse.down(startX, startY);
+ await helper.waitForStateChange("resizing");
+ mouse.move(endX, endY);
+ mouse.up(endX, endY);
+ await helper.waitForStateChange("selected");
+
+ windowX = 1000;
+ windowY = 1000;
+ await helper.waitForScrollTo(windowX, windowY);
+
+ ({ scrollX, scrollY } = await helper.getWindowPosition());
+
+ is(scrollX, windowX, "Window x position is 1000");
+ is(scrollY, windowY, "Window y position is 1000");
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_drag_test.js b/browser/components/screenshots/tests/browser/browser_screenshots_drag_test.js
new file mode 100644
index 0000000000..a8c05dcb49
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_drag_test.js
@@ -0,0 +1,475 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This function drags to a 490x490 area and copies to the clipboard
+ */
+add_task(async function dragTest() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ helper.clickCopyButton();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+
+ info("result: " + JSON.stringify(result, null, 2));
+
+ let expected = Math.floor(
+ 490 * (await getContentDevicePixelRatio(browser))
+ );
+
+ Assert.equal(
+ result.width,
+ expected,
+ `The copied image from the overlay is ${expected}px in width`
+ );
+ Assert.equal(
+ result.height,
+ expected,
+ `The copied image from the overlay is ${expected}px in height`
+ );
+ }
+ );
+});
+
+/**
+ * This function drags a 1.5 zoomed browser to a 490x490 area and copies to the clipboard
+ */
+add_task(async function dragTest1Point5Zoom() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ const zoom = 1.5;
+ let helper = new ScreenshotsHelper(browser);
+ helper.zoomBrowser(zoom);
+
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(300, 100, 350, 150);
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ helper.clickCopyButton();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+ result.zoom = zoom;
+ result.devicePixelRatio = window.devicePixelRatio;
+ result.contentDevicePixelRatio = await getContentDevicePixelRatio(
+ browser
+ );
+
+ info("result: " + JSON.stringify(result, null, 2));
+
+ let expected = Math.floor(
+ 50 * (await getContentDevicePixelRatio(browser))
+ );
+
+ Assert.equal(
+ result.width,
+ expected,
+ `The copied image from the overlay is ${expected}px in width`
+ );
+ Assert.equal(
+ result.height,
+ expected,
+ `The copied image from the overlay is ${expected}px in height`
+ );
+ }
+ );
+});
+
+/**
+ * This function drags an area and clicks elsewhere
+ * on the overlay to go back to the crosshairs state
+ */
+add_task(async function clickOverlayResetState() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(10, 10, 100, 100);
+
+ // click outside overlay
+ mouse.click(200, 200);
+
+ await helper.waitForStateChange("crosshairs");
+ let state = await helper.getOverlayState();
+ Assert.equal(state, "crosshairs", "The state is back to crosshairs");
+ }
+ );
+});
+
+/**
+ * This function drags an area and clicks the
+ * cancel button to cancel the overlay
+ */
+add_task(async function overlayCancelButton() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(10, 10, 300, 300);
+
+ helper.clickCancelButton();
+
+ await helper.waitForOverlayClosed();
+
+ ok(!(await helper.isOverlayInitialized()), "Overlay is not initialized");
+ }
+ );
+});
+
+/**
+ * This function drags a 490x490 area and moves it along the edges
+ * and back to the center to confirm that the original size is preserved
+ */
+add_task(async function preserveBoxSizeWhenMovingOutOfWindowBounds() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ let startX = 10;
+ let startY = 10;
+ let endX = 500;
+ let endY = 500;
+
+ mouse.down(
+ Math.floor((endX - startX) / 2),
+ Math.floor((endY - startY) / 2)
+ );
+
+ await helper.waitForStateChange("resizing");
+ let state = await helper.getOverlayState();
+ Assert.equal(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(10, 10);
+
+ mouse.move(contentInfo.clientWidth - 10, contentInfo.clientHeight - 10);
+
+ mouse.up(
+ Math.floor((endX - startX) / 2),
+ Math.floor((endY - startY) / 2)
+ );
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ helper.clickCopyButton();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+
+ info("result: " + JSON.stringify(result, null, 2));
+
+ let expected = Math.floor(
+ 490 * (await getContentDevicePixelRatio(browser))
+ );
+
+ Assert.equal(
+ result.width,
+ expected,
+ `The copied image from the overlay is ${expected}px in width`
+ );
+ Assert.equal(
+ result.height,
+ expected,
+ `The copied image from the overlay is ${expected}px in height`
+ );
+ }
+ );
+});
+
+/**
+ * This function drags a 490x490 area and resizes it to a 300x300 area
+ * with the 4 sides of the box
+ */
+add_task(async function resizeAllEdges() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ let startX = 10;
+ let startY = 10;
+ let endX = 500;
+ let endY = 500;
+
+ let x = Math.floor((endX - startX) / 2);
+
+ // drag top
+ mouse.down(x, 10);
+
+ await helper.waitForStateChange("resizing");
+ let state = await helper.getOverlayState();
+ Assert.equal(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(x, 100);
+ mouse.up(x, 100);
+
+ await helper.waitForStateChange("selected");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "selected", "The overlay is in the selected state");
+
+ // drag bottom
+ mouse.down(x, 500);
+
+ await helper.waitForStateChange("resizing");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(x, 400);
+ mouse.up(x, 400);
+
+ await helper.waitForStateChange("selected");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "selected", "The overlay is in the selected state");
+
+ // drag right
+ let y = Math.floor((endY - startY) / 2);
+ mouse.down(500, y);
+
+ await helper.waitForStateChange("resizing");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(400, y);
+ mouse.up(400, y);
+
+ await helper.waitForStateChange("selected");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "selected", "The overlay is in the selected state");
+
+ // drag left
+ mouse.down(10, y);
+
+ await helper.waitForStateChange("resizing");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(100, y);
+ mouse.up(100, y);
+
+ await helper.waitForStateChange("selected");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "selected", "The overlay is in the selected state");
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ helper.endX = 400;
+ helper.endY = 400;
+
+ helper.clickCopyButton();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+
+ info("result: " + JSON.stringify(result, null, 2));
+
+ let expected = Math.floor(
+ 300 * (await getContentDevicePixelRatio(browser))
+ );
+
+ Assert.equal(
+ result.width,
+ expected,
+ `The copied image from the overlay is ${expected}px in width`
+ );
+ Assert.equal(
+ result.height,
+ expected,
+ `The copied image from the overlay is ${expected}px in height`
+ );
+ }
+ );
+});
+
+/**
+ * This function drags a 490x490 area and resizes it to a 300x300 area
+ * with the 4 corners of the box
+ */
+add_task(async function resizeAllCorners() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ // drag topright
+ mouse.down(500, 10);
+
+ await helper.waitForStateChange("resizing");
+ let state = await helper.getOverlayState();
+ Assert.equal(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(450, 50);
+ mouse.up(450, 50);
+
+ await helper.waitForStateChange("selected");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "selected", "The overlay is in the selected state");
+
+ // drag bottomright
+ mouse.down(450, 500);
+
+ await helper.waitForStateChange("resizing");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(400, 450);
+ mouse.up(400, 450);
+
+ await helper.waitForStateChange("selected");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "selected", "The overlay is in the selected state");
+
+ // drag bottomleft
+ mouse.down(10, 450);
+
+ await helper.waitForStateChange("resizing");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(50, 400);
+ mouse.up(50, 400);
+
+ await helper.waitForStateChange("selected");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "selected", "The overlay is in the selected state");
+
+ // drag topleft
+ mouse.down(50, 50);
+
+ await helper.waitForStateChange("resizing");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(100, 100);
+ mouse.up(100, 100);
+
+ await helper.waitForStateChange("selected");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "selected", "The overlay is in the selected state");
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ helper.endX = 400;
+ helper.endY = 400;
+
+ helper.clickCopyButton();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+
+ info("result: " + JSON.stringify(result, null, 2));
+
+ let expected = Math.floor(
+ 300 * (await getContentDevicePixelRatio(browser))
+ );
+
+ Assert.equal(
+ result.width,
+ expected,
+ `The copied image from the overlay is ${expected}px in width`
+ );
+ Assert.equal(
+ result.height,
+ expected,
+ `The copied image from the overlay is ${expected}px in height`
+ );
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_focus_test.js b/browser/components/screenshots/tests/browser/browser_screenshots_focus_test.js
new file mode 100644
index 0000000000..5e9937f8e1
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_focus_test.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const SCREENSHOTS_EVENTS = [
+ { category: "screenshots", method: "started", object: "toolbar_button" },
+ { category: "screenshots", method: "canceled", object: "escape" },
+];
+
+add_task(async function testPanelFocused() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await clearAllTelemetryEvents();
+ let helper = new ScreenshotsHelper(browser);
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ let screenshotsButtons = gBrowser.selectedBrowser.ownerDocument
+ .querySelector("#screenshotsPagePanel")
+ .querySelector("screenshots-buttons").shadowRoot;
+
+ let focusedElement = screenshotsButtons.querySelector(".visible-page");
+
+ is(
+ focusedElement,
+ screenshotsButtons.activeElement,
+ "Visible button is focused"
+ );
+
+ EventUtils.synthesizeKey("KEY_Escape");
+
+ await helper.waitForOverlayClosed();
+
+ await assertScreenshotsEvents(SCREENSHOTS_EVENTS);
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_overlay_panel_sync.js b/browser/components/screenshots/tests/browser/browser_screenshots_overlay_panel_sync.js
new file mode 100644
index 0000000000..b66a4d2b57
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_overlay_panel_sync.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function waitOnTabSwitch() {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 300));
+}
+
+add_task(async function test_overlay_and_panel_state() {
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+
+ let screenshotsTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_PAGE
+ );
+ let browser = screenshotsTab.linkedBrowser;
+
+ let helper = new ScreenshotsHelper(browser);
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ helper.assertPanelVisible();
+
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ await helper.waitForStateChange("selected");
+ let state = await helper.getOverlayState();
+ is(state, "selected", "The overlay is in the selected state");
+
+ helper.assertPanelNotVisible();
+
+ mouse.click(600, 600);
+
+ await helper.waitForStateChange("crosshairs");
+ state = await helper.getOverlayState();
+ is(state, "crosshairs", "The overlay is in the crosshairs state");
+
+ await helper.waitForOverlay();
+
+ helper.assertPanelVisible();
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await BrowserTestUtils.switchTab(gBrowser, screenshotsTab);
+
+ await helper.waitForOverlayClosed();
+
+ Assert.ok(!(await helper.isOverlayInitialized()), "Overlay is closed");
+ helper.assertPanelNotVisible();
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ helper.assertPanelVisible();
+
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ await helper.waitForStateChange("selected");
+ state = await helper.getOverlayState();
+ is(state, "selected", "The overlay is in the selected state");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await BrowserTestUtils.switchTab(gBrowser, screenshotsTab);
+
+ Assert.ok(await helper.isOverlayInitialized(), "Overlay is open");
+ helper.assertPanelNotVisible();
+ state = await helper.getOverlayState();
+ is(state, "selected", "The overlay is in the selected state");
+
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlayClosed();
+
+ Assert.ok(!(await helper.isOverlayInitialized()), "Overlay is closed");
+ helper.assertPanelNotVisible();
+
+ gBrowser.removeTab(tab1);
+ gBrowser.removeTab(screenshotsTab);
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_page_unload.js b/browser/components/screenshots/tests/browser/browser_screenshots_page_unload.js
new file mode 100644
index 0000000000..e228e304b8
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_page_unload.js
@@ -0,0 +1,42 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const SCREENSHOTS_EVENTS = [
+ { category: "screenshots", method: "started", object: "toolbar_button" },
+ { category: "screenshots", method: "canceled", object: "navigation" },
+];
+
+add_task(async function test() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await clearAllTelemetryEvents();
+ await SpecialPowers.spawn(browser, [TEST_PAGE], url => {
+ let a = content.document.createElement("a");
+ a.id = "clickMe";
+ a.href = url;
+ a.textContent = "Click me to unload page";
+ content.document.querySelector("body").appendChild(a);
+ });
+
+ let helper = new ScreenshotsHelper(browser);
+
+ // click toolbar button so panel shows
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+
+ await SpecialPowers.spawn(browser, [], () => {
+ content.document.querySelector("#clickMe").click();
+ });
+
+ await helper.waitForOverlayClosed();
+
+ await assertScreenshotsEvents(SCREENSHOTS_EVENTS);
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_short_page_test.js b/browser/components/screenshots/tests/browser/browser_screenshots_short_page_test.js
new file mode 100644
index 0000000000..f31b9cc71d
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_short_page_test.js
@@ -0,0 +1,148 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test ensures the overlay is covering the entire window event thought
+ * the body is smaller than the viewport
+ */
+add_task(async function test_overlay() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: SHORT_TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ let dimensions = await helper.getSelectionLayerDimensions();
+ Assert.equal(
+ dimensions.scrollWidth,
+ contentInfo.clientWidth,
+ "The overlay spans the width of the window"
+ );
+
+ Assert.equal(
+ dimensions.scrollHeight,
+ contentInfo.clientHeight,
+ "The overlay spans the height of the window"
+ );
+ }
+ );
+});
+
+add_task(async function test_window_resize() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: SHORT_TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ const originalWindowWidth = window.outerWidth;
+ const originalWindowHeight = window.outerHeight;
+
+ const BIG_WINDOW_SIZE = 920;
+ const SMALL_WINDOW_SIZE = 620;
+
+ window.resizeTo(SMALL_WINDOW_SIZE, SMALL_WINDOW_SIZE);
+ await TestUtils.waitForCondition(() => {
+ info(
+ `Got ${window.outerWidth}x${
+ window.outerHeight
+ }. Expecting ${SMALL_WINDOW_SIZE}. ${
+ window.outerHeight === SMALL_WINDOW_SIZE
+ } ${window.outerWidth === SMALL_WINDOW_SIZE}`
+ );
+ return (
+ window.outerHeight === SMALL_WINDOW_SIZE &&
+ window.outerWidth === SMALL_WINDOW_SIZE
+ );
+ }, "Waiting for window to resize");
+
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+ await helper.dragOverlay(10, 10, 100, 100);
+
+ let dimensions = await helper.getSelectionLayerDimensions();
+ let oldWidth = dimensions.scrollWidth;
+ let oldHeight = dimensions.scrollHeight;
+
+ window.resizeTo(BIG_WINDOW_SIZE, BIG_WINDOW_SIZE);
+ await TestUtils.waitForCondition(
+ () =>
+ window.outerHeight === BIG_WINDOW_SIZE &&
+ window.outerWidth === BIG_WINDOW_SIZE,
+ "Waiting for window to resize"
+ );
+ await helper.waitForSelectionLayerDimensionChange(oldWidth, oldHeight);
+
+ contentInfo = await helper.getContentDimensions();
+ dimensions = await helper.getSelectionLayerDimensions();
+ Assert.equal(
+ dimensions.scrollWidth,
+ contentInfo.clientWidth,
+ "The overlay spans the width of the window"
+ );
+ Assert.equal(
+ dimensions.scrollHeight,
+ contentInfo.clientHeight,
+ "The overlay spans the height of the window"
+ );
+
+ oldWidth = dimensions.scrollWidth;
+ oldHeight = dimensions.scrollHeight;
+
+ window.resizeTo(SMALL_WINDOW_SIZE, SMALL_WINDOW_SIZE);
+ await TestUtils.waitForCondition(
+ () =>
+ window.outerHeight === SMALL_WINDOW_SIZE &&
+ window.outerWidth === SMALL_WINDOW_SIZE,
+ "Waiting for window to resize"
+ );
+ await helper.waitForSelectionLayerDimensionChange(oldWidth, oldHeight);
+
+ contentInfo = await helper.getContentDimensions();
+ dimensions = await helper.getSelectionLayerDimensions();
+ Assert.equal(
+ dimensions.scrollWidth,
+ contentInfo.clientWidth,
+ "The overlay spans the width of the window"
+ );
+ Assert.equal(
+ dimensions.scrollHeight,
+ contentInfo.clientHeight,
+ "The overlay spans the height of the window"
+ );
+
+ ok(
+ dimensions.scrollWidth < BIG_WINDOW_SIZE,
+ "Screenshots overlay is smaller than the big window width"
+ );
+ ok(
+ dimensions.scrollHeight < BIG_WINDOW_SIZE,
+ "Screenshots overlay is smaller than the big window height"
+ );
+
+ window.resizeTo(originalWindowWidth, originalWindowHeight);
+ await TestUtils.waitForCondition(
+ () =>
+ window.outerHeight === originalWindowHeight &&
+ window.outerWidth === originalWindowWidth,
+ "Waiting for window to resize"
+ );
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_telemetry_tests.js b/browser/components/screenshots/tests/browser/browser_screenshots_telemetry_tests.js
new file mode 100644
index 0000000000..74c2335beb
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_telemetry_tests.js
@@ -0,0 +1,309 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const STARTED_AND_CANCELED_EVENTS = [
+ { category: "screenshots", method: "started", object: "toolbar_button" },
+ { category: "screenshots", method: "canceled", object: "toolbar_button" },
+ { category: "screenshots", method: "started", object: "shortcut" },
+ { category: "screenshots", method: "canceled", object: "shortcut" },
+ { category: "screenshots", method: "started", object: "context_menu" },
+ { category: "screenshots", method: "canceled", object: "context_menu" },
+ { category: "screenshots", method: "started", object: "quick_actions" },
+ { category: "screenshots", method: "canceled", object: "quick_actions" },
+];
+
+const STARTED_RETRY_EVENTS = [
+ { category: "screenshots", method: "started", object: "toolbar_button" },
+ { category: "screenshots", method: "selected", object: "visible" },
+ { category: "screenshots", method: "started", object: "preview_retry" },
+];
+
+const CANCEL_EVENTS = [
+ { category: "screenshots", method: "started", object: "toolbar_button" },
+ { category: "screenshots", method: "selected", object: "full_page" },
+ { category: "screenshots", method: "canceled", object: "preview_cancel" },
+ { category: "screenshots", method: "started", object: "toolbar_button" },
+ { category: "screenshots", method: "canceled", object: "overlay_cancel" },
+];
+
+const COPY_EVENTS = [
+ { category: "screenshots", method: "started", object: "toolbar_button" },
+ { category: "screenshots", method: "selected", object: "visible" },
+ { category: "screenshots", method: "copy", object: "preview_copy" },
+ { category: "screenshots", method: "started", object: "toolbar_button" },
+ { category: "screenshots", method: "copy", object: "overlay_copy" },
+];
+
+const CONTENT_EVENTS = [
+ { category: "screenshots", method: "selected", object: "region_selection" },
+ { category: "screenshots", method: "started", object: "overlay_retry" },
+ { category: "screenshots", method: "selected", object: "element" },
+];
+
+add_task(async function test_started_and_canceled_events() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.urlbar.quickactions.enabled", true],
+ ["browser.urlbar.suggest.quickactions", true],
+ ["browser.urlbar.shortcuts.quickactions", true],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await clearAllTelemetryEvents();
+ let helper = new ScreenshotsHelper(browser);
+
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlayClosed();
+
+ EventUtils.synthesizeKey("s", { shiftKey: true, accelKey: true });
+ await helper.waitForOverlay();
+
+ EventUtils.synthesizeKey("s", { shiftKey: true, accelKey: true });
+ await helper.waitForOverlayClosed();
+
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ 50,
+ 50,
+ {
+ type: "contextmenu",
+ button: 2,
+ },
+ browser
+ );
+ await popupShownPromise;
+
+ contextMenu.activateItem(
+ contextMenu.querySelector("#context-take-screenshot")
+ );
+ await helper.waitForOverlay();
+
+ popupShownPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ 50,
+ 50,
+ {
+ type: "contextmenu",
+ button: 2,
+ },
+ browser
+ );
+ await popupShownPromise;
+
+ contextMenu.activateItem(
+ contextMenu.querySelector("#context-take-screenshot")
+ );
+ await helper.waitForOverlayClosed();
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "screenshot",
+ waitForFocus: SimpleTest.waitForFocus,
+ });
+ let { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1);
+ Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.DYNAMIC);
+ Assert.equal(result.providerName, "quickactions");
+
+ info("Trigger the screenshot mode");
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, window);
+ EventUtils.synthesizeKey("KEY_Enter", {}, window);
+ await helper.waitForOverlay();
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "screenshot",
+ waitForFocus: SimpleTest.waitForFocus,
+ });
+ ({ result } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1));
+ Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.DYNAMIC);
+ Assert.equal(result.providerName, "quickactions");
+
+ info("Trigger the screenshot mode");
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, window);
+ EventUtils.synthesizeKey("KEY_Enter", {}, window);
+ await helper.waitForOverlayClosed();
+
+ await assertScreenshotsEvents(STARTED_AND_CANCELED_EVENTS);
+ }
+ );
+});
+
+add_task(async function test_started_retry() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await clearAllTelemetryEvents();
+ let helper = new ScreenshotsHelper(browser);
+
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+
+ let panel = await helper.waitForPanel();
+
+ let screenshotReady = TestUtils.topicObserved(
+ "screenshots-preview-ready"
+ );
+
+ // click the visible page button in panel
+ let visiblePageButton = panel
+ .querySelector("screenshots-buttons")
+ .shadowRoot.querySelector(".visible-page");
+ visiblePageButton.click();
+ await screenshotReady;
+
+ let dialog = helper.getDialog();
+ let retryButton = dialog._frame.contentDocument.getElementById("retry");
+ ok(retryButton, "Got the retry button");
+ retryButton.click();
+
+ await helper.waitForOverlay();
+
+ await assertScreenshotsEvents(STARTED_RETRY_EVENTS);
+ }
+ );
+});
+
+add_task(async function test_canceled() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await clearAllTelemetryEvents();
+ let helper = new ScreenshotsHelper(browser);
+
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+
+ let panel = await helper.waitForPanel();
+
+ let screenshotReady = TestUtils.topicObserved(
+ "screenshots-preview-ready"
+ );
+
+ // click the full page button in panel
+ let fullPageButton = panel
+ .querySelector("screenshots-buttons")
+ .shadowRoot.querySelector(".full-page");
+ fullPageButton.click();
+ await screenshotReady;
+
+ let dialog = helper.getDialog();
+ let cancelButton = dialog._frame.contentDocument.getElementById("cancel");
+ ok(cancelButton, "Got the cancel button");
+ cancelButton.click();
+
+ await helper.waitForOverlayClosed();
+
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(50, 50, 300, 300);
+ helper.clickCancelButton();
+
+ await helper.waitForOverlayClosed();
+
+ await assertScreenshotsEvents(CANCEL_EVENTS);
+ }
+ );
+});
+
+add_task(async function test_copy() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await clearAllTelemetryEvents();
+ let helper = new ScreenshotsHelper(browser);
+
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+
+ let panel = await helper.waitForPanel();
+
+ let screenshotReady = TestUtils.topicObserved(
+ "screenshots-preview-ready"
+ );
+
+ // click the visible page button in panel
+ let visiblePageButton = panel
+ .querySelector("screenshots-buttons")
+ .shadowRoot.querySelector(".visible-page");
+ visiblePageButton.click();
+ await screenshotReady;
+
+ let dialog = helper.getDialog();
+ let copyButton = dialog._frame.contentDocument.getElementById("copy");
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ // click copy button on dialog box
+ copyButton.click();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(50, 50, 300, 300);
+
+ clipboardChanged = helper.waitForRawClipboardChange();
+
+ helper.clickCopyButton();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ await assertScreenshotsEvents(COPY_EVENTS);
+ }
+ );
+});
+
+add_task(async function test_content_events() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await clearAllTelemetryEvents();
+ let helper = new ScreenshotsHelper(browser);
+
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(50, 50, 300, 300);
+
+ mouse.click(11, 11);
+ await helper.waitForStateChange("crosshairs");
+
+ await helper.clickTestPageElement();
+
+ await assertScreenshotsEvents(CONTENT_EVENTS, "content");
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js
new file mode 100644
index 0000000000..3f7af492fa
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js
@@ -0,0 +1,185 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const SCREENSHOTS_EVENTS = [
+ { category: "screenshots", method: "started", object: "toolbar_button" },
+ { category: "screenshots", method: "download", object: "overlay_download" },
+ { category: "screenshots", method: "started", object: "toolbar_button" },
+ { category: "screenshots", method: "selected", object: "visible" },
+ { category: "screenshots", method: "download", object: "preview_download" },
+];
+
+const MockFilePicker = SpecialPowers.MockFilePicker;
+
+add_setup(async function () {
+ let tmpDir = PathUtils.join(
+ PathUtils.tempDir,
+ "testsavedir" + Math.floor(Math.random() * 2 ** 32)
+ );
+ // Create this dir if it doesn't exist (ignores existing dirs)
+ await IOUtils.makeDirectory(tmpDir);
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.start_downloads_in_tmp_dir", true],
+ ["browser.helperApps.deleteTempFileOnExit", true],
+ ["browser.download.folderList", 2],
+ ["browser.download.dir", tmpDir],
+ ],
+ });
+
+ MockFilePicker.init(window);
+ MockFilePicker.useAnyFile();
+ MockFilePicker.returnValue = MockFilePicker.returnOK;
+
+ registerCleanupFunction(async () => {
+ Services.prefs.clearUserPref("browser.download.dir");
+ Services.prefs.clearUserPref("browser.download.folderList");
+
+ MockFilePicker.cleanup();
+ });
+});
+
+function waitForFilePicker() {
+ return new Promise(resolve => {
+ MockFilePicker.showCallback = () => {
+ MockFilePicker.showCallback = null;
+ ok(true, "Saw the file picker");
+ resolve();
+ };
+ });
+}
+
+add_task(async function test_download_without_filepicker() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.useDownloadDir", true]],
+ });
+
+ let publicDownloads = await Downloads.getList(Downloads.PUBLIC);
+ // First ensure we catch the download finishing.
+ let downloadFinishedPromise = new Promise(resolve => {
+ publicDownloads.addView({
+ onDownloadChanged(download) {
+ info("Download changed!");
+ if (download.succeeded || download.error) {
+ info("Download succeeded or errored");
+ publicDownloads.removeView(this);
+ resolve(download);
+ }
+ },
+ });
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await clearAllTelemetryEvents();
+ let helper = new ScreenshotsHelper(browser);
+
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ helper.clickDownloadButton();
+
+ info("wait for download to finish");
+ let download = await downloadFinishedPromise;
+
+ ok(download.succeeded, "Download should succeed");
+
+ await publicDownloads.removeFinished();
+
+ await waitForScreenshotsEventCount(2);
+
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+
+ let screenshotReady = TestUtils.topicObserved(
+ "screenshots-preview-ready"
+ );
+
+ let panel = gBrowser.selectedBrowser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+
+ // click the visible page button in panel
+ let visiblePageButton = panel
+ .querySelector("screenshots-buttons")
+ .shadowRoot.querySelector(".visible-page");
+ visiblePageButton.click();
+
+ let dialog = helper.getDialog();
+
+ await screenshotReady;
+
+ let downloadButton =
+ dialog._frame.contentDocument.getElementById("download");
+ ok(downloadButton, "Got the download button");
+
+ // click download button on dialog box
+ downloadButton.click();
+
+ info("wait for download to finish");
+ download = await downloadFinishedPromise;
+
+ ok(download.succeeded, "Download should succeed");
+
+ await publicDownloads.removeFinished();
+
+ await assertScreenshotsEvents(SCREENSHOTS_EVENTS);
+ }
+ );
+});
+
+add_task(async function test_download_with_filepicker() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.useDownloadDir", false]],
+ });
+
+ let publicDownloads = await Downloads.getList(Downloads.PUBLIC);
+ // First ensure we catch the download finishing.
+ let downloadFinishedPromise = new Promise(resolve => {
+ publicDownloads.addView({
+ onDownloadChanged(download) {
+ info("Download changed!");
+ if (download.succeeded || download.error) {
+ info("Download succeeded or errored");
+ publicDownloads.removeView(this);
+ resolve(download);
+ }
+ },
+ });
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ let filePicker = waitForFilePicker();
+
+ helper.clickDownloadButton();
+
+ await filePicker;
+ ok(true, "Export file picker opened");
+
+ info("wait for download to finish");
+ let download = await downloadFinishedPromise;
+
+ ok(download.succeeded, "Download should succeed");
+
+ await publicDownloads.removeFinished();
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_escape.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_escape.js
new file mode 100644
index 0000000000..73db436ff1
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_escape.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const SCREENSHOTS_EVENTS = [
+ { category: "screenshots", method: "started", object: "toolbar_button" },
+ { category: "screenshots", method: "canceled", object: "escape" },
+];
+
+add_task(async function test_fullpageScreenshot() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await clearAllTelemetryEvents();
+ let helper = new ScreenshotsHelper(browser);
+
+ // click toolbar button so panel shows
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ EventUtils.synthesizeKey("KEY_F6", { shiftKey: true });
+
+ EventUtils.synthesizeKey("KEY_Escape");
+
+ await helper.waitForOverlayClosed();
+
+ await assertScreenshotsEvents(SCREENSHOTS_EVENTS);
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js
new file mode 100644
index 0000000000..c66b4c77d9
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js
@@ -0,0 +1,173 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function assertRange(lhs, rhsMin, rhsMax, msg) {
+ Assert.ok(lhs >= rhsMin && lhs <= rhsMax, msg);
+}
+
+add_task(async function test_fullpageScreenshot() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ // click toolbar button so panel shows
+ helper.triggerUIFromToolbar();
+
+ let panel = await helper.waitForPanel();
+
+ let screenshotReady = TestUtils.topicObserved(
+ "screenshots-preview-ready"
+ );
+
+ // click the full page button in panel
+ let visiblePage = panel
+ .querySelector("screenshots-buttons")
+ .shadowRoot.querySelector(".full-page");
+ visiblePage.click();
+
+ let dialog = helper.getDialog();
+
+ await screenshotReady;
+
+ let copyButton = dialog._frame.contentDocument.getElementById("copy");
+ ok(copyButton, "Got the copy button");
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ // click copy button on dialog box
+ copyButton.click();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+ info("result: " + JSON.stringify(result, null, 2));
+ info("contentInfo: " + JSON.stringify(contentInfo, null, 2));
+
+ Assert.equal(
+ contentInfo.scrollWidth,
+ result.width,
+ "Widths should be equal"
+ );
+
+ Assert.equal(
+ contentInfo.scrollHeight,
+ result.height,
+ "Heights should be equal"
+ );
+
+ // top left
+ assertRange(result.color.topLeft[0], 110, 111, "R color value");
+ assertRange(result.color.topLeft[1], 110, 111, "G color value");
+ assertRange(result.color.topLeft[2], 110, 111, "B color value");
+
+ // top right
+ assertRange(result.color.topRight[0], 55, 56, "R color value");
+ assertRange(result.color.topRight[1], 155, 156, "G color value");
+ assertRange(result.color.topRight[2], 155, 156, "B color value");
+
+ // bottom left
+ assertRange(result.color.bottomLeft[0], 105, 106, "R color value");
+ assertRange(result.color.bottomLeft[1], 55, 56, "G color value");
+ assertRange(result.color.bottomLeft[2], 105, 106, "B color value");
+
+ // bottom right
+ assertRange(result.color.bottomRight[0], 52, 53, "R color value");
+ assertRange(result.color.bottomRight[1], 127, 128, "G color value");
+ assertRange(result.color.bottomRight[2], 152, 153, "B color value");
+ }
+ );
+});
+
+add_task(async function test_fullpageScreenshotScrolled() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ await SpecialPowers.spawn(browser, [], () => {
+ content.scrollTo(0, 2008);
+ });
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ // click toolbar button so panel shows
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+
+ let panel = await helper.waitForPanel();
+
+ let screenshotReady = TestUtils.topicObserved(
+ "screenshots-preview-ready"
+ );
+
+ // click the full page button in panel
+ let visiblePage = panel
+ .querySelector("screenshots-buttons")
+ .shadowRoot.querySelector(".full-page");
+ visiblePage.click();
+
+ let dialog = helper.getDialog();
+
+ await screenshotReady;
+
+ let copyButton = dialog._frame.contentDocument.getElementById("copy");
+ ok(copyButton, "Got the copy button");
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ // click copy button on dialog box
+ copyButton.click();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+ info("result: " + JSON.stringify(result, null, 2));
+ info("contentInfo: " + JSON.stringify(contentInfo, null, 2));
+
+ Assert.equal(
+ contentInfo.scrollWidth,
+ result.width,
+ "Widths should be equal"
+ );
+
+ Assert.equal(
+ contentInfo.scrollHeight,
+ result.height,
+ "Heights should be equal"
+ );
+
+ // top left
+ assertRange(result.color.topLeft[0], 110, 111, "R color value");
+ assertRange(result.color.topLeft[1], 110, 111, "G color value");
+ assertRange(result.color.topLeft[2], 110, 111, "B color value");
+
+ // top right
+ assertRange(result.color.topRight[0], 55, 56, "R color value");
+ assertRange(result.color.topRight[1], 155, 156, "G color value");
+ assertRange(result.color.topRight[2], 155, 156, "B color value");
+
+ // bottom left
+ assertRange(result.color.bottomLeft[0], 105, 106, "R color value");
+ assertRange(result.color.bottomLeft[1], 55, 56, "G color value");
+ assertRange(result.color.bottomLeft[2], 105, 106, "B color value");
+
+ // bottom right
+ assertRange(result.color.bottomRight[0], 52, 53, "R color value");
+ assertRange(result.color.bottomRight[1], 127, 128, "G color value");
+ assertRange(result.color.bottomRight[2], 152, 153, "B color value");
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_page_crash.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_page_crash.js
new file mode 100644
index 0000000000..4b65110c50
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_page_crash.js
@@ -0,0 +1,54 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_fullpageScreenshot() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ // click toolbar button so UI shows
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+
+ let panel = await helper.waitForPanel(gBrowser.selectedBrowser);
+
+ let waitForPanelHide = BrowserTestUtils.waitForMutationCondition(
+ panel,
+ { attributes: true },
+ () => {
+ return BrowserTestUtils.is_hidden(panel);
+ }
+ );
+
+ await BrowserTestUtils.crashFrame(browser);
+
+ await waitForPanelHide;
+ ok(
+ BrowserTestUtils.is_hidden(panel),
+ "Panel buttons are hidden after page crash"
+ );
+
+ await ContentTask.spawn(browser, null, async () => {
+ let screenshotsChild = content.windowGlobalChild.getActor(
+ "ScreenshotsComponent"
+ );
+ Assert.ok(!screenshotsChild._overlay, "The overlay doesn't exist");
+ });
+
+ let tab = gBrowser.getTabForBrowser(browser);
+
+ SessionStore.reviveCrashedTab(tab);
+
+ ok(
+ BrowserTestUtils.is_hidden(panel),
+ "Panel buttons are hidden after page crash"
+ );
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_screenshot_too_big.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_screenshot_too_big.js
new file mode 100644
index 0000000000..1c1bac32db
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_screenshot_too_big.js
@@ -0,0 +1,86 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const SCREENSHOTS_EVENTS = [
+ { category: "screenshots", method: "failed", object: "screenshot_too_large" },
+ { category: "screenshots", method: "failed", object: "screenshot_too_large" },
+ { category: "screenshots", method: "failed", object: "screenshot_too_large" },
+ { category: "screenshots", method: "failed", object: "screenshot_too_large" },
+];
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs",
+});
+
+add_task(async function test_screenshot_too_large_cropped() {
+ await clearAllTelemetryEvents();
+ const screenshotsLocalization = new Localization(
+ ["browser/screenshots.ftl"],
+ true
+ );
+
+ let [errorTitle, errorMessage] = screenshotsLocalization.formatMessagesSync([
+ { id: "screenshots-too-large-error-title" },
+ { id: "screenshots-too-large-error-details" },
+ ]);
+ let showAlertMessageStub = sinon
+ .stub(ScreenshotsUtils, "showAlertMessage")
+ .callsFake(function (title, message) {
+ is(title, errorTitle.value, "Got error title");
+ is(message, errorMessage.value, "Got error message");
+ });
+
+ let rect = { x: 0, y: 0, width: 40000, height: 40000, devicePixelRatio: 1 };
+
+ ScreenshotsUtils.cropScreenshotRectIfNeeded(rect);
+
+ is(rect.width, MAX_CAPTURE_DIMENSION, "The width is 32767");
+ is(
+ rect.height,
+ Math.floor(MAX_CAPTURE_AREA / MAX_CAPTURE_DIMENSION),
+ "The height is 124925329 / 32767"
+ );
+
+ rect.width = 40000;
+ rect.hegith = 1;
+
+ ScreenshotsUtils.cropScreenshotRectIfNeeded(rect);
+
+ is(
+ rect.width,
+ MAX_CAPTURE_DIMENSION,
+ "The width was cropped to the max capture dimension (32767)."
+ );
+
+ rect.width = 1;
+ rect.height = 40000;
+
+ ScreenshotsUtils.cropScreenshotRectIfNeeded(rect);
+
+ is(
+ rect.height,
+ MAX_CAPTURE_DIMENSION,
+ "The height was cropped to the max capture dimension (32767)."
+ );
+
+ rect.width = 12345;
+ rect.height = 12345;
+
+ ScreenshotsUtils.cropScreenshotRectIfNeeded(rect);
+
+ is(rect.width, 12345, "The width was not cropped");
+ is(
+ rect.height,
+ Math.floor(MAX_CAPTURE_AREA / 12345),
+ "The height was cropped to 10119"
+ );
+
+ showAlertMessageStub.restore();
+
+ await assertScreenshotsEvents(SCREENSHOTS_EVENTS);
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js
new file mode 100644
index 0000000000..1bd1933799
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js
@@ -0,0 +1,287 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs",
+});
+XPCOMUtils.defineLazyGetter(this, "ExtensionManagement", () => {
+ const { Management } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+ );
+ return Management;
+});
+
+add_task(async function test() {
+ let observerSpy = sinon.spy();
+ let notifierSpy = sinon.spy();
+
+ let observerStub = sinon
+ .stub(ScreenshotsUtils, "observe")
+ .callsFake(observerSpy);
+ let notifierStub = sinon
+ .stub(ScreenshotsUtils, "notify")
+ .callsFake(function (window, type) {
+ notifierSpy();
+ ScreenshotsUtils.notify.wrappedMethod.apply(this, arguments);
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ function awaitExtensionEvent(eventName, id) {
+ return new Promise(resolve => {
+ let listener = (_eventName, ...args) => {
+ let extension = args[0];
+ if (_eventName === eventName && extension.id == id) {
+ ExtensionManagement.off(eventName, listener);
+ resolve();
+ }
+ };
+ ExtensionManagement.on(eventName, listener);
+ });
+ }
+ const SCREENSHOT_EXTENSION = "screenshots@mozilla.org";
+
+ let helper = new ScreenshotsHelper(browser);
+
+ ok(observerSpy.notCalled, "Observer not called");
+ helper.triggerUIFromToolbar();
+ Assert.equal(observerSpy.callCount, 1, "Observer function called once");
+
+ ok(notifierSpy.notCalled, "Notifier not called");
+ EventUtils.synthesizeKey("s", { accelKey: true, shiftKey: true });
+
+ await TestUtils.waitForCondition(() => notifierSpy.callCount == 1);
+ Assert.equal(notifierSpy.callCount, 1, "Notify function called once");
+
+ await TestUtils.waitForCondition(() => observerSpy.callCount == 2);
+ Assert.equal(observerSpy.callCount, 2, "Observer function called twice");
+
+ let menu = document.getElementById("contentAreaContextMenu");
+ let popupshown = BrowserTestUtils.waitForPopupEvent(menu, "shown");
+ EventUtils.synthesizeMouseAtCenter(document.body, {
+ type: "contextmenu",
+ });
+ await popupshown;
+ Assert.equal(menu.state, "open", "Context menu is open");
+
+ let popuphidden = BrowserTestUtils.waitForPopupEvent(menu, "hidden");
+ menu.activateItem(menu.querySelector("#context-take-screenshot"));
+ await popuphidden;
+
+ Assert.equal(observerSpy.callCount, 3, "Observer function called thrice");
+
+ const COMPONENT_PREF = "screenshots.browser.component.enabled";
+ await SpecialPowers.pushPrefEnv({
+ set: [[COMPONENT_PREF, false]],
+ });
+ ok(!Services.prefs.getBoolPref(COMPONENT_PREF), "Extension enabled");
+ await awaitExtensionEvent("ready", SCREENSHOT_EXTENSION);
+
+ helper.triggerUIFromToolbar();
+ Assert.equal(
+ observerSpy.callCount,
+ 3,
+ "Observer function still called thrice"
+ );
+
+ await SpecialPowers.spawn(
+ browser,
+ ["#firefox-screenshots-preselection-iframe"],
+ async function (iframeSelector) {
+ info(
+ `in waitForUIContent content function, iframeSelector: ${iframeSelector}`
+ );
+ let iframe;
+ await ContentTaskUtils.waitForCondition(() => {
+ iframe = content.document.querySelector(iframeSelector);
+ if (!iframe || !ContentTaskUtils.is_visible(iframe)) {
+ info("in waitForUIContent, no visible iframe yet");
+ return false;
+ }
+ return true;
+ });
+ // wait a frame for the screenshots UI to finish any init
+ await new content.Promise(res => content.requestAnimationFrame(res));
+ }
+ );
+
+ helper.triggerUIFromToolbar();
+ await SpecialPowers.spawn(
+ browser,
+ ["#firefox-screenshots-preselection-iframe"],
+ async function (iframeSelector) {
+ info(
+ `in waitForUIContent content function, iframeSelector: ${iframeSelector}`
+ );
+ let iframe;
+ await ContentTaskUtils.waitForCondition(() => {
+ iframe = content.document.querySelector(iframeSelector);
+ if (!iframe || !ContentTaskUtils.is_visible(iframe)) {
+ info("in waitForUIContent, no visible iframe yet");
+ return true;
+ }
+ return false;
+ });
+ // wait a frame for the screenshots UI to finish any init
+ await new content.Promise(res => content.requestAnimationFrame(res));
+ }
+ );
+
+ popupshown = BrowserTestUtils.waitForPopupEvent(menu, "shown");
+ EventUtils.synthesizeMouseAtCenter(document.body, {
+ type: "contextmenu",
+ });
+ await popupshown;
+ Assert.equal(menu.state, "open", "Context menu is open");
+
+ popuphidden = BrowserTestUtils.waitForPopupEvent(menu, "hidden");
+ menu.activateItem(menu.querySelector("#context-take-screenshot"));
+ await popuphidden;
+
+ Assert.equal(
+ observerSpy.callCount,
+ 3,
+ "Observer function still called thrice"
+ );
+
+ await SpecialPowers.spawn(
+ browser,
+ ["#firefox-screenshots-preselection-iframe"],
+ async function (iframeSelector) {
+ info(
+ `in waitForUIContent content function, iframeSelector: ${iframeSelector}`
+ );
+ let iframe;
+ await ContentTaskUtils.waitForCondition(() => {
+ iframe = content.document.querySelector(iframeSelector);
+ if (!iframe || !ContentTaskUtils.is_visible(iframe)) {
+ info("in waitForUIContent, no visible iframe yet");
+ return false;
+ }
+ return true;
+ });
+ // wait a frame for the screenshots UI to finish any init
+ await new content.Promise(res => content.requestAnimationFrame(res));
+ }
+ );
+
+ helper.triggerUIFromToolbar();
+ await SpecialPowers.spawn(
+ browser,
+ ["#firefox-screenshots-preselection-iframe"],
+ async function (iframeSelector) {
+ info(
+ `in waitForUIContent content function, iframeSelector: ${iframeSelector}`
+ );
+ let iframe;
+ await ContentTaskUtils.waitForCondition(() => {
+ iframe = content.document.querySelector(iframeSelector);
+ if (!iframe || !ContentTaskUtils.is_visible(iframe)) {
+ return true;
+ }
+ info("in waitForUIContent, iframe still visible");
+ info(iframe);
+ return false;
+ });
+ // wait a frame for the screenshots UI to finish any init
+ await new content.Promise(res => content.requestAnimationFrame(res));
+ }
+ );
+
+ let componentReady = TestUtils.topicObserved(
+ "screenshots-component-initialized"
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[COMPONENT_PREF, true]],
+ });
+ ok(Services.prefs.getBoolPref(COMPONENT_PREF), "Component enabled");
+ // Needed for component to initialize
+ await componentReady;
+
+ helper.triggerUIFromToolbar();
+ Assert.equal(
+ observerSpy.callCount,
+ 4,
+ "Observer function called four times"
+ );
+
+ const SCREENSHOTS_PREF = "extensions.screenshots.disabled";
+ await SpecialPowers.pushPrefEnv({
+ set: [[SCREENSHOTS_PREF, true]],
+ });
+ ok(Services.prefs.getBoolPref(SCREENSHOTS_PREF), "Screenshots disabled");
+ }
+ );
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ const SCREENSHOTS_PREF = "extensions.screenshots.disabled";
+ ok(Services.prefs.getBoolPref(SCREENSHOTS_PREF), "Screenshots disabled");
+
+ ok(
+ document.getElementById("screenshot-button").disabled,
+ "Toolbar button disabled"
+ );
+
+ let menu = document.getElementById("contentAreaContextMenu");
+ let popupshown = BrowserTestUtils.waitForPopupEvent(menu, "shown");
+ EventUtils.synthesizeMouseAtCenter(document.body, {
+ type: "contextmenu",
+ });
+ await popupshown;
+ Assert.equal(menu.state, "open", "Context menu is open");
+
+ ok(
+ menu.querySelector("#context-take-screenshot").hidden,
+ "Take screenshot is not in context menu"
+ );
+
+ let popuphidden = BrowserTestUtils.waitForPopupEvent(menu, "hidden");
+ menu.hidePopup();
+ await popuphidden;
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[SCREENSHOTS_PREF, false]],
+ });
+ ok(!Services.prefs.getBoolPref(SCREENSHOTS_PREF), "Screenshots enabled");
+ }
+ );
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ const SCREENSHOTS_PREF = "extensions.screenshots.disabled";
+ ok(!Services.prefs.getBoolPref(SCREENSHOTS_PREF), "Screenshots enabled");
+
+ let helper = new ScreenshotsHelper(browser);
+
+ helper.triggerUIFromToolbar();
+ Assert.equal(
+ observerSpy.callCount,
+ 5,
+ "Observer function called for the fifth time"
+ );
+ }
+ );
+
+ observerStub.restore();
+ notifierStub.restore();
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_toolbar_button.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_toolbar_button.js
new file mode 100644
index 0000000000..5ad7d32192
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_toolbar_button.js
@@ -0,0 +1,26 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function testScreenshotButtonDisabled() {
+ info("Test the Screenshots button in the panel");
+
+ let screenshotBtn = document.getElementById("screenshot-button");
+ Assert.ok(screenshotBtn, "The screenshots button was added to the nav bar");
+
+ await BrowserTestUtils.withNewTab("https://example.com/", () => {
+ Assert.equal(
+ screenshotBtn.disabled,
+ false,
+ "Screenshots button is enabled"
+ );
+ });
+ await BrowserTestUtils.withNewTab("about:home", () => {
+ Assert.equal(
+ screenshotBtn.disabled,
+ false,
+ "Screenshots button is still enabled on about pages"
+ );
+ });
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_visible.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_visible.js
new file mode 100644
index 0000000000..26d28566c8
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_visible.js
@@ -0,0 +1,345 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_visibleScreenshot() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+ let devicePixelRatio = await getContentDevicePixelRatio(browser);
+
+ // click toolbar button so panel shows
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ let screenshotReady = TestUtils.topicObserved(
+ "screenshots-preview-ready"
+ );
+
+ let panel = gBrowser.selectedBrowser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+
+ // click the visible page button in panel
+ let visiblePageButton = panel
+ .querySelector("screenshots-buttons")
+ .shadowRoot.querySelector(".visible-page");
+ visiblePageButton.click();
+
+ let dialog = helper.getDialog();
+
+ await screenshotReady;
+
+ let copyButton = dialog._frame.contentDocument.getElementById("copy");
+ ok(copyButton, "Got the copy button");
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ // click copy button on dialog box
+ copyButton.click();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+ info("result: " + JSON.stringify(result, null, 2));
+ info("contentInfo: " + JSON.stringify(contentInfo, null, 2));
+
+ let expectedWidth = Math.floor(
+ devicePixelRatio * contentInfo.clientWidth
+ );
+ Assert.equal(result.width, expectedWidth, "Widths should be equal");
+
+ let expectedHeight = Math.floor(
+ devicePixelRatio * contentInfo.clientHeight
+ );
+ Assert.equal(result.height, expectedHeight, "Heights should be equal");
+
+ // top left
+ Assert.equal(111, result.color.topLeft[0], "R color value");
+ Assert.equal(111, result.color.topLeft[1], "G color value");
+ Assert.equal(111, result.color.topLeft[2], "B color value");
+
+ // top right
+ Assert.equal(111, result.color.topRight[0], "R color value");
+ Assert.equal(111, result.color.topRight[1], "G color value");
+ Assert.equal(111, result.color.topRight[2], "B color value");
+
+ // bottom left
+ Assert.equal(111, result.color.bottomLeft[0], "R color value");
+ Assert.equal(111, result.color.bottomLeft[1], "G color value");
+ Assert.equal(111, result.color.bottomLeft[2], "B color value");
+
+ // bottom right
+ Assert.equal(111, result.color.bottomRight[0], "R color value");
+ Assert.equal(111, result.color.bottomRight[1], "G color value");
+ Assert.equal(111, result.color.bottomRight[2], "B color value");
+ }
+ );
+});
+
+add_task(async function test_visibleScreenshotScrolled() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], () => {
+ content.scrollTo(0, 2008);
+ });
+
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ // click toolbar button so panel shows
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+
+ let panel = gBrowser.selectedBrowser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+
+ let screenshotReady = TestUtils.topicObserved(
+ "screenshots-preview-ready"
+ );
+
+ // click the visible page button in panel
+ let visiblePageButton = panel
+ .querySelector("screenshots-buttons")
+ .shadowRoot.querySelector(".visible-page");
+ visiblePageButton.click();
+
+ let dialog = helper.getDialog();
+
+ await screenshotReady;
+
+ let copyButton = dialog._frame.contentDocument.getElementById("copy");
+ ok(copyButton, "Got the copy button");
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ // click copy button on dialog box
+ copyButton.click();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+
+ info("result: " + JSON.stringify(result, null, 2));
+ info("contentInfo: " + JSON.stringify(contentInfo, null, 2));
+
+ let expectedWidth = Math.floor(
+ devicePixelRatio * contentInfo.clientWidth
+ );
+ Assert.equal(result.width, expectedWidth, "Widths should be equal");
+
+ let expectedHeight = Math.floor(
+ devicePixelRatio * contentInfo.clientHeight
+ );
+ Assert.equal(result.height, expectedHeight, "Heights should be equal");
+
+ // top left
+ Assert.equal(105, result.color.topLeft[0], "R color value");
+ Assert.equal(55, result.color.topLeft[1], "G color value");
+ Assert.equal(105, result.color.topLeft[2], "B color value");
+
+ // top right
+ Assert.equal(105, result.color.topRight[0], "R color value");
+ Assert.equal(55, result.color.topRight[1], "G color value");
+ Assert.equal(105, result.color.topRight[2], "B color value");
+
+ // bottom left
+ Assert.equal(105, result.color.bottomLeft[0], "R color value");
+ Assert.equal(55, result.color.bottomLeft[1], "G color value");
+ Assert.equal(105, result.color.bottomLeft[2], "B color value");
+
+ // bottom right
+ Assert.equal(105, result.color.bottomRight[0], "R color value");
+ Assert.equal(55, result.color.bottomRight[1], "G color value");
+ Assert.equal(105, result.color.bottomRight[2], "B color value");
+ }
+ );
+});
+
+add_task(async function test_visibleScreenshotScrolled() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], () => {
+ content.scrollTo(2004, 0);
+ });
+
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ // click toolbar button so panel shows
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+
+ let panel = gBrowser.selectedBrowser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+
+ let screenshotReady = TestUtils.topicObserved(
+ "screenshots-preview-ready"
+ );
+
+ // click the visible page button in panel
+ let visiblePageButton = panel
+ .querySelector("screenshots-buttons")
+ .shadowRoot.querySelector(".visible-page");
+ visiblePageButton.click();
+
+ let dialog = helper.getDialog();
+
+ await screenshotReady;
+
+ let copyButton = dialog._frame.contentDocument.getElementById("copy");
+ ok(copyButton, "Got the copy button");
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ // click copy button on dialog box
+ copyButton.click();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+
+ info("result: " + JSON.stringify(result, null, 2));
+ info("contentInfo: " + JSON.stringify(contentInfo, null, 2));
+
+ let expectedWidth = Math.floor(
+ devicePixelRatio * contentInfo.clientWidth
+ );
+ Assert.equal(result.width, expectedWidth, "Widths should be equal");
+
+ let expectedHeight = Math.floor(
+ devicePixelRatio * contentInfo.clientHeight
+ );
+ Assert.equal(result.height, expectedHeight, "Heights should be equal");
+
+ // top left
+ Assert.equal(55, result.color.topLeft[0], "R color value");
+ Assert.equal(155, result.color.topLeft[1], "G color value");
+ Assert.equal(155, result.color.topLeft[2], "B color value");
+
+ // top right
+ Assert.equal(55, result.color.topRight[0], "R color value");
+ Assert.equal(155, result.color.topRight[1], "G color value");
+ Assert.equal(155, result.color.topRight[2], "B color value");
+
+ // bottom left
+ Assert.equal(55, result.color.bottomLeft[0], "R color value");
+ Assert.equal(155, result.color.bottomLeft[1], "G color value");
+ Assert.equal(155, result.color.bottomLeft[2], "B color value");
+
+ // bottom right
+ Assert.equal(55, result.color.bottomRight[0], "R color value");
+ Assert.equal(155, result.color.bottomRight[1], "G color value");
+ Assert.equal(155, result.color.bottomRight[2], "B color value");
+ }
+ );
+});
+
+add_task(async function test_visibleScreenshotScrolled() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], () => {
+ content.scrollTo(2004, 2008);
+ });
+
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ // click toolbar button so panel shows
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+
+ let panel = gBrowser.selectedBrowser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+
+ let screenshotReady = TestUtils.topicObserved(
+ "screenshots-preview-ready"
+ );
+
+ // click the visible page button in panel
+ let visiblePageButton = panel
+ .querySelector("screenshots-buttons")
+ .shadowRoot.querySelector(".visible-page");
+ visiblePageButton.click();
+
+ let dialog = helper.getDialog();
+
+ await screenshotReady;
+
+ let copyButton = dialog._frame.contentDocument.getElementById("copy");
+ ok(copyButton, "Got the copy button");
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ // click copy button on dialog box
+ copyButton.click();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+
+ info("result: " + JSON.stringify(result, null, 2));
+ info("contentInfo: " + JSON.stringify(contentInfo, null, 2));
+
+ let expectedWidth = Math.floor(
+ devicePixelRatio * contentInfo.clientWidth
+ );
+ Assert.equal(result.width, expectedWidth, "Widths should be equal");
+
+ let expectedHeight = Math.floor(
+ devicePixelRatio * contentInfo.clientHeight
+ );
+ Assert.equal(result.height, expectedHeight, "Heights should be equal");
+
+ // top left
+ Assert.equal(52, result.color.topLeft[0], "R color value");
+ Assert.equal(127, result.color.topLeft[1], "G color value");
+ Assert.equal(152, result.color.topLeft[2], "B color value");
+
+ // top right
+ Assert.equal(52, result.color.topRight[0], "R color value");
+ Assert.equal(127, result.color.topRight[1], "G color value");
+ Assert.equal(152, result.color.topRight[2], "B color value");
+
+ // bottom left
+ Assert.equal(52, result.color.bottomLeft[0], "R color value");
+ Assert.equal(127, result.color.bottomLeft[1], "G color value");
+ Assert.equal(152, result.color.bottomLeft[2], "B color value");
+
+ // bottom right
+ Assert.equal(52, result.color.bottomRight[0], "R color value");
+ Assert.equal(127, result.color.bottomRight[1], "G color value");
+ Assert.equal(152, result.color.bottomRight[2], "B color value");
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/head.js b/browser/components/screenshots/tests/browser/head.js
new file mode 100644
index 0000000000..5ab054a70c
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/head.js
@@ -0,0 +1,681 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+const { UrlbarTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/UrlbarTestUtils.sys.mjs"
+);
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.com"
+);
+
+const TEST_PAGE = TEST_ROOT + "test-page.html";
+const SHORT_TEST_PAGE = TEST_ROOT + "short-test-page.html";
+const LARGE_TEST_PAGE = TEST_ROOT + "large-test-page.html";
+
+const MAX_CAPTURE_DIMENSION = 32767;
+const MAX_CAPTURE_AREA = 124925329;
+
+const gScreenshotUISelectors = {
+ panelButtons: "#screenshotsPagePanel",
+ fullPageButton: "button.full-page",
+ visiblePageButton: "button.visible-page",
+ copyButton: "button.#copy",
+};
+
+// MouseEvents is for the mouse events on the Anonymous content
+const MouseEvents = {
+ mouse: new Proxy(
+ {},
+ {
+ get: (target, name) =>
+ async function (x, y, selector = ":root") {
+ if (name === "click") {
+ this.down(x, y);
+ this.up(x, y);
+ } else {
+ await safeSynthesizeMouseEventInContentPage(selector, x, y, {
+ type: "mouse" + name,
+ });
+ }
+ },
+ }
+ ),
+};
+
+const { mouse } = MouseEvents;
+
+class ScreenshotsHelper {
+ constructor(browser) {
+ this.browser = browser;
+ this.selector = gScreenshotUISelectors;
+ }
+
+ get toolbarButton() {
+ return document.getElementById("screenshot-button");
+ }
+
+ /**
+ * Click the screenshots button in the toolbar
+ */
+ triggerUIFromToolbar() {
+ let button = this.toolbarButton;
+ ok(
+ BrowserTestUtils.is_visible(button),
+ "The screenshot toolbar button is visible"
+ );
+ button.click();
+ }
+
+ async waitForPanel() {
+ let panel = this.browser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ await BrowserTestUtils.waitForCondition(async () => {
+ if (!panel) {
+ panel = this.browser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ }
+ return panel?.state === "open" && BrowserTestUtils.is_visible(panel);
+ });
+ return panel;
+ }
+
+ async waitForOverlay() {
+ const panel = await this.waitForPanel();
+ ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible");
+
+ await BrowserTestUtils.waitForCondition(async () => {
+ let init = await this.isOverlayInitialized();
+ return init;
+ });
+ info("Overlay is visible");
+ }
+
+ async waitForOverlayClosed() {
+ let panel = this.browser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ if (!panel) {
+ panel = await this.waitForPanel();
+ }
+ await BrowserTestUtils.waitForMutationCondition(
+ panel,
+ { attributes: true },
+ () => {
+ return BrowserTestUtils.is_hidden(panel);
+ }
+ );
+ ok(BrowserTestUtils.is_hidden(panel), "Panel buttons are hidden");
+
+ await BrowserTestUtils.waitForCondition(async () => {
+ let init = !(await this.isOverlayInitialized());
+ info("Is overlay initialized: " + !init);
+ return init;
+ });
+ info("Overlay is not visible");
+ }
+
+ async isOverlayInitialized() {
+ return SpecialPowers.spawn(this.browser, [], () => {
+ let screenshotsChild = content.windowGlobalChild.getActor(
+ "ScreenshotsComponent"
+ );
+ return screenshotsChild?._overlay?._initialized;
+ });
+ }
+
+ async getOverlayState() {
+ return ContentTask.spawn(this.browser, null, async () => {
+ let screenshotsChild = content.windowGlobalChild.getActor(
+ "ScreenshotsComponent"
+ );
+ return screenshotsChild._overlay.stateHandler.getState();
+ });
+ }
+
+ async waitForStateChange(newState) {
+ await BrowserTestUtils.waitForCondition(async () => {
+ let state = await this.getOverlayState();
+ return state === newState;
+ }, `Waiting for state change to ${newState}`);
+ }
+
+ async getHoverElementRect() {
+ return ContentTask.spawn(this.browser, null, async () => {
+ let screenshotsChild = content.windowGlobalChild.getActor(
+ "ScreenshotsComponent"
+ );
+ return screenshotsChild._overlay.stateHandler.getHoverElementBoxRect();
+ });
+ }
+
+ async waitForHoverElementRect() {
+ return TestUtils.waitForCondition(async () => {
+ let rect = await this.getHoverElementRect();
+ return rect;
+ });
+ }
+
+ async waitForSelectionBoxSizeChange(currentWidth) {
+ await ContentTask.spawn(
+ this.browser,
+ [currentWidth],
+ async ([currWidth]) => {
+ let screenshotsChild = content.windowGlobalChild.getActor(
+ "ScreenshotsComponent"
+ );
+
+ let dimensions =
+ screenshotsChild._overlay.screenshotsContainer.getSelectionLayerDimensions();
+ // return dimensions.boxWidth;
+ await ContentTaskUtils.waitForCondition(() => {
+ dimensions =
+ screenshotsChild._overlay.screenshotsContainer.getSelectionLayerDimensions();
+ return dimensions.boxWidth !== currWidth;
+ }, "Wait for selection box width change");
+ }
+ );
+ }
+
+ /**
+ * This will drag an overlay starting at the given startX and startY coordinates and ending
+ * at the given endX and endY coordinates.
+ *
+ * endY should be at least 70px from the bottom of window and endX should be at least
+ * 265px from the left of the window. If these requirements are not met then the
+ * overlay buttons (cancel, copy, download) will be positioned different from the default
+ * and the methods to click the overlay buttons will not work unless the updated
+ * position coordinates are supplied.
+ * See https://searchfox.org/mozilla-central/rev/af78418c4b5f2c8721d1a06486cf4cf0b33e1e8d/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs#1789,1798
+ * for how the overlay buttons are positioned when the overlay rect is near the bottom or
+ * left edge of the window.
+ *
+ * Note: The distance of the rect should be greater than 40 to enter in the "dragging" state.
+ * See https://searchfox.org/mozilla-central/rev/af78418c4b5f2c8721d1a06486cf4cf0b33e1e8d/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs#809
+ * @param {Number} startX The starting X coordinate. The left edge of the overlay rect.
+ * @param {Number} startY The starting Y coordinate. The top edge of the overlay rect.
+ * @param {Number} endX The end X coordinate. The right edge of the overlay rect.
+ * @param {Number} endY The end Y coordinate. The bottom edge of the overlay rect.
+ */
+ async dragOverlay(startX, startY, endX, endY) {
+ await this.waitForStateChange("crosshairs");
+ let state = await this.getOverlayState();
+ Assert.equal(state, "crosshairs", "The overlay is in the crosshairs state");
+
+ mouse.down(startX, startY);
+
+ await this.waitForStateChange("draggingReady");
+ state = await this.getOverlayState();
+ Assert.equal(
+ state,
+ "draggingReady",
+ "The overlay is in the draggingReady state"
+ );
+
+ mouse.move(endX, endY);
+
+ await this.waitForStateChange("dragging");
+ state = await this.getOverlayState();
+ Assert.equal(state, "dragging", "The overlay is in the dragging state");
+
+ mouse.up(endX, endY);
+
+ await this.waitForStateChange("selected");
+ state = await this.getOverlayState();
+ Assert.equal(state, "selected", "The overlay is in the selected state");
+
+ this.endX = endX;
+ this.endY = endY;
+ }
+
+ async scrollContentWindow(x, y) {
+ let promise = BrowserTestUtils.waitForContentEvent(this.browser, "scroll");
+ await ContentTask.spawn(this.browser, [x, y], async ([xPos, yPos]) => {
+ content.window.scroll(xPos, yPos);
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return (
+ content.window.scrollX === xPos && content.window.scrollY === yPos
+ );
+ }, `Waiting for window to scroll to ${xPos}, ${yPos}`);
+ });
+ await promise;
+ }
+
+ getWindowPosition() {
+ return ContentTask.spawn(this.browser, [], () => {
+ return {
+ scrollX: content.window.scrollX,
+ scrollY: content.window.scrollY,
+ };
+ });
+ }
+
+ async waitForScrollTo(x, y) {
+ await ContentTask.spawn(this.browser, [x, y], async ([xPos, yPos]) => {
+ await ContentTaskUtils.waitForCondition(() => {
+ info(
+ `Got scrollX: ${content.window.scrollX}. scrollY: ${content.window.scrollY}`
+ );
+ return (
+ content.window.scrollX === xPos && content.window.scrollY === yPos
+ );
+ }, `Waiting for window to scroll to ${xPos}, ${yPos}`);
+ });
+ }
+
+ clickDownloadButton() {
+ // Click the download button with last x and y position from dragOverlay.
+ // The middle of the copy button is last X - 70 and last Y + 36.
+ // Ex. 500, 500 would be 530, 536
+ mouse.click(this.endX - 70, this.endY + 36);
+ }
+
+ clickCopyButton(overrideX = null, overrideY = null) {
+ // Click the copy button with last x and y position from dragOverlay.
+ // The middle of the copy button is last X - 183 and last Y + 36.
+ // Ex. 500, 500 would be 317, 536
+ if (overrideX && overrideY) {
+ mouse.click(overrideX - 183, overrideY + 36);
+ } else {
+ mouse.click(this.endX - 183, this.endY + 36);
+ }
+ }
+
+ clickCancelButton() {
+ // Click the cancel button with last x and y position from dragOverlay.
+ // The middle of the copy button is last X - 259 and last Y + 36.
+ // Ex. 500, 500 would be 241, 536
+ mouse.click(this.endX - 259, this.endY + 36);
+ }
+
+ async clickTestPageElement() {
+ let rect = await ContentTask.spawn(this.browser, [], async () => {
+ let ele = content.document.getElementById("testPageElement");
+ return ele.getBoundingClientRect();
+ });
+
+ let x = Math.floor(rect.x + rect.width / 2);
+ let y = Math.floor(rect.y + rect.height / 2);
+
+ mouse.move(x, y);
+ await this.waitForHoverElementRect();
+ mouse.down(x, y);
+ await this.waitForStateChange("draggingReady");
+ mouse.up(x, y);
+ await this.waitForStateChange("selected");
+ }
+
+ async zoomBrowser(zoom) {
+ await SpecialPowers.spawn(this.browser, [zoom], zoomLevel => {
+ const { Layout } = ChromeUtils.importESModule(
+ "chrome://mochitests/content/browser/accessible/tests/browser/Layout.sys.mjs"
+ );
+ Layout.zoomDocument(content.document, zoomLevel);
+ });
+ }
+
+ /**
+ * Gets the dialog box
+ * @returns The dialog box
+ */
+ getDialog() {
+ let currDialogBox = this.browser.tabDialogBox;
+ let manager = currDialogBox.getTabDialogManager();
+ let dialogs = manager.hasDialogs && manager.dialogs;
+ return dialogs[0];
+ }
+
+ assertPanelVisible() {
+ let panel = this.browser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(panel),
+ "Screenshots panel is visible"
+ );
+ }
+
+ assertPanelNotVisible() {
+ let panel = this.browser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(panel),
+ "Screenshots panel is not visible"
+ );
+ }
+
+ /**
+ * Copied from screenshots extension
+ * Returns a promise that resolves when the clipboard data has changed
+ * Otherwise rejects
+ */
+ waitForRawClipboardChange() {
+ const initialClipboardData = Date.now().toString();
+ SpecialPowers.clipboardCopyString(initialClipboardData);
+
+ let promiseChanged = TestUtils.waitForCondition(() => {
+ let data;
+ try {
+ data = getRawClipboardData("image/png");
+ } catch (e) {
+ console.log("Failed to get image/png clipboard data:", e);
+ return false;
+ }
+ return data && initialClipboardData !== data;
+ });
+ return promiseChanged;
+ }
+
+ /**
+ * Gets the client and scroll demensions on the window
+ * @returns { Object }
+ * clientHeight The visible height
+ * clientWidth The visible width
+ * scrollHeight The scrollable height
+ * scrollWidth The scrollable width
+ */
+ getContentDimensions() {
+ return SpecialPowers.spawn(this.browser, [], async function () {
+ let { innerWidth, innerHeight, scrollMaxX, scrollMaxY } = content.window;
+ let width = innerWidth + scrollMaxX;
+ let height = innerHeight + scrollMaxY;
+
+ const scrollbarHeight = {};
+ const scrollbarWidth = {};
+ content.window.windowUtils.getScrollbarSize(
+ false,
+ scrollbarWidth,
+ scrollbarHeight
+ );
+ width -= scrollbarWidth.value;
+ height -= scrollbarHeight.value;
+ innerWidth -= scrollbarWidth.value;
+ innerHeight -= scrollbarHeight.value;
+
+ return {
+ clientHeight: innerHeight,
+ clientWidth: innerWidth,
+ scrollHeight: height,
+ scrollWidth: width,
+ };
+ });
+ }
+
+ getSelectionLayerDimensions() {
+ return ContentTask.spawn(this.browser, null, async () => {
+ let screenshotsChild = content.windowGlobalChild.getActor(
+ "ScreenshotsComponent"
+ );
+ Assert.ok(screenshotsChild._overlay._initialized, "The overlay exists");
+
+ return screenshotsChild._overlay.screenshotsContainer.getSelectionLayerDimensions();
+ });
+ }
+
+ async waitForSelectionLayerDimensionChange(oldWidth, oldHeight) {
+ await ContentTask.spawn(
+ this.browser,
+ [oldWidth, oldHeight],
+ async ([prevWidth, prevHeight]) => {
+ let screenshotsChild = content.windowGlobalChild.getActor(
+ "ScreenshotsComponent"
+ );
+
+ await ContentTaskUtils.waitForCondition(() => {
+ let dimensions =
+ screenshotsChild._overlay.screenshotsContainer.getSelectionLayerDimensions();
+ info(
+ `old height: ${prevHeight}. new height: ${dimensions.scrollHeight}.\nold width: ${prevWidth}. new width: ${dimensions.scrollWidth}`
+ );
+ return (
+ dimensions.scrollHeight !== prevHeight &&
+ dimensions.scrollWidth !== prevWidth
+ );
+ }, "Wait for selection box width change");
+ }
+ );
+ }
+
+ getSelectionBoxDimensions() {
+ return ContentTask.spawn(this.browser, null, async () => {
+ let screenshotsChild = content.windowGlobalChild.getActor(
+ "ScreenshotsComponent"
+ );
+ Assert.ok(screenshotsChild._overlay._initialized, "The overlay exists");
+
+ return screenshotsChild._overlay.screenshotsContainer.getSelectionLayerBoxDimensions();
+ });
+ }
+
+ /**
+ * Clicks an element on the screen
+ * @param eleSel The selector for the element to click
+ */
+ async clickUIElement(eleSel) {
+ await SpecialPowers.spawn(
+ this.browser,
+ [eleSel],
+ async function (eleSelector) {
+ info(
+ `in clickScreenshotsUIElement content function, eleSelector: ${eleSelector}`
+ );
+ const EventUtils = ContentTaskUtils.getEventUtils(content);
+ let ele = content.document.querySelector(eleSelector);
+ info(`Found the thing to click: ${eleSelector}: ${!!ele}`);
+
+ EventUtils.synthesizeMouseAtCenter(ele, {});
+ // wait a frame for the screenshots UI to finish any init
+ await new content.Promise(res => content.requestAnimationFrame(res));
+ }
+ );
+ }
+
+ /**
+ * Copied from screenshots extension
+ * A helper that returns the size of the image that was just put into the clipboard by the
+ * :screenshot command.
+ * @return The {width, height, color} dimension and color object.
+ */
+ async getImageSizeAndColorFromClipboard() {
+ let flavor = "image/png";
+ let image = getRawClipboardData(flavor);
+ ok(image, "screenshot data exists on the clipboard");
+
+ // Due to the differences in how images could be stored in the clipboard the
+ // checks below are needed. The clipboard could already provide the image as
+ // byte streams or as image container. If it's not possible obtain a
+ // byte stream, the function throws.
+
+ if (image instanceof Ci.imgIContainer) {
+ image = Cc["@mozilla.org/image/tools;1"]
+ .getService(Ci.imgITools)
+ .encodeImage(image, flavor);
+ }
+
+ if (!(image instanceof Ci.nsIInputStream)) {
+ throw new Error("Unable to read image data");
+ }
+
+ const binaryStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ binaryStream.setInputStream(image);
+ const available = binaryStream.available();
+ const buffer = new ArrayBuffer(available);
+ is(
+ binaryStream.readArrayBuffer(available, buffer),
+ available,
+ "Read expected amount of data"
+ );
+
+ // We are going to load the image in the content page to measure its size.
+ // We don't want to insert the image directly in the browser's document
+ // which could mess all sorts of things up
+ return SpecialPowers.spawn(
+ this.browser,
+ [buffer],
+ async function (_buffer) {
+ const img = content.document.createElement("img");
+ const loaded = new Promise(r => {
+ img.addEventListener("load", r, { once: true });
+ });
+ const url = content.URL.createObjectURL(
+ new Blob([_buffer], { type: "image/png" })
+ );
+
+ img.src = url;
+ content.document.documentElement.appendChild(img);
+
+ info("Waiting for the clipboard image to load in the content page");
+ await loaded;
+
+ let canvas = content.document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "html:canvas"
+ );
+ let context = canvas.getContext("2d");
+ canvas.width = img.width;
+ canvas.height = img.height;
+ context.drawImage(img, 0, 0);
+ let topLeft = context.getImageData(0, 0, 1, 1);
+ let topRight = context.getImageData(img.width - 1, 0, 1, 1);
+ let bottomLeft = context.getImageData(0, img.height - 1, 1, 1);
+ let bottomRight = context.getImageData(
+ img.width - 1,
+ img.height - 1,
+ 1,
+ 1
+ );
+
+ img.remove();
+ content.URL.revokeObjectURL(url);
+
+ return {
+ width: img.width,
+ height: img.height,
+ color: {
+ topLeft: topLeft.data,
+ topRight: topRight.data,
+ bottomLeft: bottomLeft.data,
+ bottomRight: bottomRight.data,
+ },
+ };
+ }
+ );
+ }
+}
+
+/**
+ * Get the raw clipboard data
+ * @param flavor Type of data to get from clipboard
+ * @returns The data from the clipboard
+ */
+function getRawClipboardData(flavor) {
+ const whichClipboard = Services.clipboard.kGlobalClipboard;
+ const xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ xferable.init(null);
+ xferable.addDataFlavor(flavor);
+ Services.clipboard.getData(xferable, whichClipboard);
+ let data = {};
+ try {
+ // xferable.getTransferData(flavor, data);
+ xferable.getAnyTransferData({}, data);
+ info(JSON.stringify(data, null, 2));
+ } catch (e) {
+ info(e);
+ }
+ data = data.value || null;
+ return data;
+}
+
+/**
+ * Synthesize a mouse event on an element, after ensuring that it is visible
+ * in the viewport.
+ *
+ * @param {String} selector: The node selector to get the node target for the event.
+ * @param {number} x
+ * @param {number} y
+ * @param {object} options: Options that will be passed to BrowserTestUtils.synthesizeMouse
+ */
+async function safeSynthesizeMouseEventInContentPage(
+ selector,
+ x,
+ y,
+ options = {}
+) {
+ let context = gBrowser.selectedBrowser.browsingContext;
+ BrowserTestUtils.synthesizeMouse(selector, x, y, options, context);
+}
+
+add_setup(async () => {
+ CustomizableUI.addWidgetToArea(
+ "screenshot-button",
+ CustomizableUI.AREA_NAVBAR
+ );
+ let screenshotBtn = document.getElementById("screenshot-button");
+ Assert.ok(screenshotBtn, "The screenshots button was added to the nav bar");
+});
+
+function getContentDevicePixelRatio(browser) {
+ return SpecialPowers.spawn(browser, [], async function () {
+ return content.window.devicePixelRatio;
+ });
+}
+
+async function clearAllTelemetryEvents() {
+ // Clear everything.
+ info("Clearing all telemetry events");
+ await TestUtils.waitForCondition(() => {
+ Services.telemetry.clearEvents();
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ );
+ let content = events.content;
+ let parent = events.parent;
+
+ return (!content && !parent) || (!content.length && !parent.length);
+ });
+}
+
+async function waitForScreenshotsEventCount(count, process = "parent") {
+ await TestUtils.waitForCondition(
+ () => {
+ let events = TelemetryTestUtils.getEvents(
+ { category: "screenshots" },
+ { process }
+ );
+
+ info(`Got ${events?.length} event(s)`);
+ info(`Actual events: ${JSON.stringify(events, null, 2)}`);
+ return events.length === count ? events : null;
+ },
+ `Waiting for ${count} ${process} event(s).`,
+ 200,
+ 100
+ );
+}
+
+async function assertScreenshotsEvents(expectedEvents, process = "parent") {
+ info(`Expected events: ${JSON.stringify(expectedEvents, null, 2)}`);
+ // Make sure we have recorded the correct number of events
+ await waitForScreenshotsEventCount(expectedEvents.length, process);
+
+ TelemetryTestUtils.assertEvents(
+ expectedEvents,
+ { category: "screenshots", clear: true },
+ { process }
+ );
+}
diff --git a/browser/components/screenshots/tests/browser/large-test-page.html b/browser/components/screenshots/tests/browser/large-test-page.html
new file mode 100644
index 0000000000..ab2eb8d601
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/large-test-page.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Screenshots</title>
+</head>
+<body style="height:40000px; width:40000px; background-repeat: no-repeat; background-size: 40008px 40016px; background-color: rgb(111, 111, 111); background-image:linear-gradient(to right, transparent 50%, rgba(0, 200, 200, 0.5) 50%),linear-gradient(to bottom, transparent 50%, rgba(100, 0, 100, 0.5) 50%);">
+</body>
+</html>
diff --git a/browser/components/screenshots/tests/browser/short-test-page.html b/browser/components/screenshots/tests/browser/short-test-page.html
new file mode 100644
index 0000000000..7718892f8c
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/short-test-page.html
@@ -0,0 +1,8 @@
+<html lang="en">
+<head>
+ <title>Screenshots</title>
+</head>
+<body>
+ <div style="height:500px; width:500px; background-color: blue;"></div>
+</body>
+</html>
diff --git a/browser/components/screenshots/tests/browser/test-page.html b/browser/components/screenshots/tests/browser/test-page.html
new file mode 100644
index 0000000000..4a3f7b0304
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/test-page.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Screenshots</title>
+</head>
+<body style="height:4000px; width:4000px; background-repeat: no-repeat; background-size: 4008px 4016px; background-color: rgb(111, 111, 111); background-image:linear-gradient(to right, transparent 50%, rgba(0, 200, 200, 0.5) 50%),linear-gradient(to bottom, transparent 50%, rgba(100, 0, 100, 0.5) 50%);">
+ <div id="testPageElement" style="position:absolute; top:91px; left:93px; width:92px; height:94px; border:solid red;"></div>
+</body>
+</html>