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.mjs2028
-rw-r--r--browser/components/screenshots/ScreenshotsUtils.sys.mjs459
-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.css535
-rw-r--r--browser/components/screenshots/content/screenshots.html44
-rw-r--r--browser/components/screenshots/content/screenshots.js97
-rw-r--r--browser/components/screenshots/fileHelpers.mjs278
-rw-r--r--browser/components/screenshots/jar.mn23
-rw-r--r--browser/components/screenshots/moz.build19
-rw-r--r--browser/components/screenshots/overlay/overlay.css435
-rw-r--r--browser/components/screenshots/screenshots-buttons.css31
-rw-r--r--browser/components/screenshots/screenshots-buttons.js55
-rw-r--r--browser/components/screenshots/tests/browser/browser.ini25
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js317
-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.js34
-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.js72
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_short_page_test.js40
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js137
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_test_escape.js25
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js192
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_test_page_crash.js70
-rw-r--r--browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js288
-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.js374
-rw-r--r--browser/components/screenshots/tests/browser/head.js496
-rw-r--r--browser/components/screenshots/tests/browser/short-test-page.html11
-rw-r--r--browser/components/screenshots/tests/browser/test-page.html11
36 files changed, 6711 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..18c2227d2b
--- /dev/null
+++ b/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs
@@ -0,0 +1,2028 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * The Screenshots overlay is inserted into the document's
+ * canvasFrame anonymous content container (see dom/webidl/Document.webidl).
+ *
+ * This container gets cleared automatically when the document navigates.
+ *
+ * Since the overlay markup is inserted in the canvasFrame using
+ * insertAnonymousContent, this means that it can be modified using the API
+ * described in AnonymousContent.webidl.
+ *
+ * Any mutation of this content must be via the AnonymousContent API.
+ * This is similar in design to [devtools' highlighters](https://firefox-source-docs.mozilla.org/devtools/tools/highlighters.html#inserting-content-in-the-page),
+ * though as Screenshots doesnt need to work on XUL documents, or allow multiple kinds of
+ * highlight/overlay our case is a little simpler.
+ *
+ * To retrieve the AnonymousContent instance, use the `content` getter.
+ */
+
+/* States:
+
+ "crosshairs":
+ Nothing has happened, and the crosshairs will follow the movement of the mouse
+ "draggingReady":
+ The user has pressed the mouse button, but hasn't moved enough to create a selection
+ "dragging":
+ The user has pressed down a mouse button, and is dragging out an area far enough to show a selection
+ "selected":
+ The user has selected an area
+ "resizing":
+ The user is resizing the selection
+
+ A pointerdown goes from crosshairs to dragging.
+ A pointerup goes from dragging to selected
+ A click outside of the selection goes from selected to crosshairs
+ A pointerdown on one of the draggers goes from selected to resizing
+
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "overlayLocalization", () => {
+ return new Localization(["browser/screenshotsOverlay.ftl"], true);
+});
+
+const STYLESHEET_URL =
+ "chrome://browser/content/screenshots/overlay/overlay.css";
+
+// An autoselection smaller than these will be ignored entirely:
+const MIN_DETECT_ABSOLUTE_HEIGHT = 10;
+const MIN_DETECT_ABSOLUTE_WIDTH = 30;
+// An autoselection smaller than these will not be preferred:
+const MIN_DETECT_HEIGHT = 30;
+const MIN_DETECT_WIDTH = 100;
+// An autoselection bigger than either of these will be ignored:
+let MAX_DETECT_HEIGHT = 700;
+let MAX_DETECT_WIDTH = 1000;
+
+const doNotAutoselectTags = {
+ H1: true,
+ H2: true,
+ H3: true,
+ H4: true,
+ H5: true,
+ H6: true,
+};
+
+class AnonymousContentOverlay {
+ constructor(contentDocument, screenshotsChild) {
+ this.listeners = new Map();
+ this.elements = new Map();
+
+ this.screenshotsChild = screenshotsChild;
+
+ this.contentDocument = contentDocument;
+ // aliased for easier diffs/maintenance of the event management code borrowed from devtools highlighters
+ this.pageListenerTarget = contentDocument.ownerGlobal;
+
+ this.overlayFragment = null;
+
+ this.overlayId = "screenshots-overlay-container";
+ this.previewId = "preview-container";
+ this.selectionId = "selection-container";
+ this.hoverBoxId = "hover-highlight";
+
+ this._initialized = false;
+
+ this.moverIds = [
+ "mover-left",
+ "mover-top",
+ "mover-right",
+ "mover-bottom",
+ "mover-topLeft",
+ "mover-topRight",
+ "mover-bottomLeft",
+ "mover-bottomRight",
+ ];
+ }
+ get content() {
+ if (!this._content || Cu.isDeadWrapper(this._content)) {
+ return null;
+ }
+ return this._content;
+ }
+ async initialize() {
+ if (this._initialized) {
+ return;
+ }
+
+ let document = this.contentDocument;
+ let window = document.ownerGlobal;
+
+ // Inject stylesheet
+ if (!this.overlayFragment) {
+ try {
+ window.windowUtils.loadSheetUsingURIString(
+ STYLESHEET_URL,
+ window.windowUtils.AGENT_SHEET
+ );
+ } catch {
+ // The method fails if the url is already loaded.
+ }
+ // Inject markup for the overlay UI
+ this.overlayFragment = this.buildOverlay();
+ }
+
+ this._content = document.insertAnonymousContent(
+ this.overlayFragment.children[0]
+ );
+
+ this.addEventListeners();
+
+ this.hoverElementBox = new HoverElementBox(
+ this.hoverBoxId,
+ this.content,
+ document
+ );
+
+ this.previewLayer = new PreviewLayer(this.previewId, this.content);
+ this.selectionLayer = new SelectionLayer(
+ this.selectionId,
+ this.content,
+ this.hoverElementBox
+ );
+
+ this.screenshotsContainer = new ScreenshotsContainerLayer(
+ this.overlayId,
+ this.content,
+ this.previewLayer,
+ this.selectionLayer
+ );
+
+ this.stateHandler = new StateHandler(
+ this.screenshotsContainer,
+ this.screenshotsChild
+ );
+
+ this.screenshotsContainer.updateSize(window);
+
+ this.stateHandler.setState("crosshairs");
+
+ this._initialized = true;
+ }
+
+ /**
+ * The Anonymous Content doesn't shrink when the window is resized so we need
+ * to find the largest element that isn't the Anonymous Content and we will
+ * use that width and height.
+ * Otherwise we will fallback to the documentElement scroll width and height
+ * @param eventType If "resize", we called this from a resize event so we will
+ * try shifting the SelectionBox.
+ * If "scroll", we called this from a scroll event so we will redraw the buttons
+ */
+ updateScreenshotsSize(eventType) {
+ this.stateHandler.updateScreenshotsContainerSize(
+ this.contentDocument.ownerGlobal,
+ eventType
+ );
+ }
+
+ /**
+ * Add required event listeners to the overlay
+ */
+ addEventListeners() {
+ this.addEventListenerForElement(
+ "screenshots-cancel-button",
+ "click",
+ (event, targetId) => {
+ this.screenshotsChild.requestCancelScreenshot();
+ }
+ );
+ this.addEventListenerForElement("cancel", "click", (event, targetId) => {
+ this.screenshotsChild.requestCancelScreenshot();
+ });
+ this.addEventListenerForElement("copy", "click", (event, targetId) => {
+ this.screenshotsChild.requestCopyScreenshot(
+ this.screenshotsContainer.getSelectionLayerBoxDimensions()
+ );
+ });
+ this.addEventListenerForElement("download", "click", (event, targetId) => {
+ this.screenshotsChild.requestDownloadScreenshot(
+ this.screenshotsContainer.getSelectionLayerBoxDimensions()
+ );
+ });
+
+ // The pointerdown event is added to the selection buttons to prevent the
+ // pointerdown event from occurring on the "screenshots-overlay-container"
+ this.addEventListenerForElement(
+ "cancel",
+ "pointerdown",
+ (event, targetId) => {
+ event.stopPropagation();
+ }
+ );
+ this.addEventListenerForElement(
+ "copy",
+ "pointerdown",
+ (event, targetId) => {
+ event.stopPropagation();
+ }
+ );
+ this.addEventListenerForElement(
+ "download",
+ "pointerdown",
+ (event, targetId) => {
+ event.stopPropagation();
+ }
+ );
+
+ this.addEventListenerForElement(
+ this.overlayId,
+ "pointerdown",
+ (event, targetId) => {
+ this.dragStart(event, targetId);
+ }
+ );
+ this.addEventListenerForElement(
+ this.overlayId,
+ "pointerup",
+ (event, targetId) => {
+ this.dragEnd(event, targetId);
+ }
+ );
+ this.addEventListenerForElement(
+ this.overlayId,
+ "pointermove",
+ (event, targetId) => {
+ this.drag(event, targetId);
+ }
+ );
+
+ for (let id of this.moverIds.concat(["highlight"])) {
+ this.addEventListenerForElement(id, "pointerdown", (event, targetId) => {
+ this.dragStart(event, targetId);
+ });
+ this.addEventListenerForElement(id, "pointerup", (event, targetId) => {
+ this.dragEnd(event, targetId);
+ });
+ this.addEventListenerForElement(id, "pointermove", (event, targetId) => {
+ this.drag(event, targetId);
+ });
+ }
+ }
+
+ /**
+ * Removes all event listeners and removes the overlay from the Anonymous Content
+ */
+ tearDown() {
+ if (this._content) {
+ this._removeAllListeners();
+ try {
+ this.contentDocument.removeAnonymousContent(this._content);
+ } catch (e) {
+ // If the current window isn't the one the content was inserted into, this
+ // will fail, but that's fine.
+ }
+ }
+ this._initialized = false;
+ }
+
+ /**
+ * Creates the document fragment that will be added to the Anonymous Content
+ * @returns document fragment that can be injected into the Anonymous Content
+ */
+ buildOverlay() {
+ let [
+ cancel,
+ instructions,
+ download,
+ copy,
+ ] = lazy.overlayLocalization.formatMessagesSync([
+ { id: "screenshots-overlay-cancel-button" },
+ { id: "screenshots-overlay-instructions" },
+ { id: "screenshots-overlay-download-button" },
+ { id: "screenshots-overlay-copy-button" },
+ ]);
+
+ const htmlString = `
+ <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>
+ <div class="cancel-shot" id="screenshots-cancel-button">${cancel.value}</div>
+ </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" 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;
+
+ constructor(screenshotsContainer, screenshotsChild) {
+ this.#state = "crosshairs";
+ this.#lastBox = {};
+
+ this.#screenshotsContainer = screenshotsContainer;
+ this.#screenshotsChild = screenshotsChild;
+ }
+
+ setState(newState) {
+ this.#state = newState;
+ this.start();
+ }
+
+ getState() {
+ return this.#state;
+ }
+
+ /**
+ * At the start of the some states we need to perform some actions
+ */
+ start() {
+ switch (this.#state) {
+ case "crosshairs": {
+ this.crosshairsStart();
+ break;
+ }
+ case "draggingReady": {
+ this.draggingReadyStart();
+ break;
+ }
+ case "dragging": {
+ this.draggingStart();
+ break;
+ }
+ case "selected": {
+ this.selectedStart();
+ break;
+ }
+ case "resizing": {
+ this.resizingStart();
+ break;
+ }
+ }
+ }
+
+ /**
+ * Returns the x and y coordinates of the event
+ * @param event The mouse or touch event
+ * @returns object containing the x and y coordinates of the mouse
+ */
+ getCoordinates(event) {
+ const { clientX: viewX, clientY: viewY, pageX, pageY } = event;
+
+ MAX_DETECT_HEIGHT = Math.max(event.target.clientHeight + 100, 700);
+ MAX_DETECT_WIDTH = Math.max(event.target.clientWidth + 100, 1000);
+
+ return { viewX, viewY, pageX, pageY };
+ }
+
+ /**
+ * Handles the mousedown/touchstart event depending on the state
+ * @param event The mousedown or touchstart event
+ * @param targetId The id of the event target
+ */
+ dragStart(event, targetId) {
+ const { pageX, pageY } = this.getCoordinates(event);
+
+ switch (this.#state) {
+ case "crosshairs": {
+ this.crosshairsDragStart(pageX, pageY);
+ break;
+ }
+ case "selected": {
+ this.selectedDragStart(pageX, pageY, targetId);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Handles the move event depending on the state
+ * @param event The mousemove or touchmove event
+ * @param targetId The id of the event target
+ */
+ drag(event, targetId) {
+ const { pageX, pageY, viewX, viewY } = this.getCoordinates(event);
+
+ switch (this.#state) {
+ case "crosshairs": {
+ this.crosshairsMove(pageX, pageY, viewX, viewY, targetId);
+ break;
+ }
+ case "draggingReady": {
+ this.draggingReadyDrag(pageX, pageY);
+ break;
+ }
+ case "dragging": {
+ this.draggingDrag(pageX, pageY);
+ break;
+ }
+ case "resizing": {
+ this.resizingDrag(pageX, pageY);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Handles the move event depending on the state
+ * @param event The mouseup event
+ * @param targetId The id of the event target
+ */
+ dragEnd(event, targetId) {
+ const { pageX, pageY, viewX, viewY } = this.getCoordinates(event);
+
+ switch (this.#state) {
+ case "draggingReady": {
+ this.draggingReadyDragEnd(pageX - viewX, pageY - viewY);
+ break;
+ }
+ case "dragging": {
+ this.draggingDragEnd(pageX, pageY, targetId);
+ break;
+ }
+ case "resizing": {
+ this.resizingDragEnd(pageX, pageY, targetId);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Hide the box and highlighter and show the overlay at the start of crosshairs state
+ */
+ crosshairsStart() {
+ this.#screenshotsContainer.hideSelectionLayer();
+ this.#screenshotsContainer.showPreviewLayer();
+ this.#screenshotsChild.showPanel();
+ }
+
+ /**
+ *
+ */
+ draggingReadyStart() {
+ this.#screenshotsChild.hidePanel();
+ }
+
+ /**
+ * Hide the overlay and draw the box at the start of dragging state
+ */
+ draggingStart() {
+ this.#screenshotsContainer.hidePreviewLayer();
+ this.#screenshotsContainer.hideButtonsLayer();
+ this.#screenshotsContainer.drawSelectionBox();
+ }
+
+ /**
+ * Show the buttons at the start of the selected state
+ */
+ selectedStart() {
+ this.#screenshotsContainer.drawButtonsLayer();
+ }
+
+ /**
+ * Hide the buttons and store width and height of box at the start of the resizing state
+ */
+ resizingStart() {
+ this.#screenshotsContainer.hideButtonsLayer();
+ let {
+ width,
+ height,
+ } = this.#screenshotsContainer.getSelectionLayerBoxDimensions();
+ this.#lastBox = {
+ width,
+ height,
+ };
+ }
+
+ /**
+ * Set the initial box coordinates and set the state to "draggingReady"
+ * @param clientX x coordinate
+ * @param clientY y coordinate
+ */
+ crosshairsDragStart(clientX, clientY) {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ left: clientX,
+ top: clientY,
+ right: clientX,
+ bottom: clientY,
+ });
+
+ this.setState("draggingReady");
+ }
+
+ /**
+ * If the background is clicked we set the state to crosshairs
+ * otherwise set the state to resizing
+ * @param clientX x coordinate
+ * @param clientY y coordinate
+ * @param targetId The id of the event target
+ */
+ selectedDragStart(clientX, clientY, targetId) {
+ if (targetId === this.#screenshotsContainer.id) {
+ this.setState("crosshairs");
+ return;
+ }
+ this.#moverId = targetId;
+ this.#lastX = clientX;
+ this.#lastY = clientY;
+
+ this.setState("resizing");
+ }
+
+ /**
+ * Handles the pointer move for the crosshairs state
+ * @param pageX x pointer position
+ * @param pageY y pointer position
+ * @param viewX x pointer position in viewport
+ * @param viewY y pointer position in viewport
+ * @param targetId The id of the target element
+ */
+ crosshairsMove(pageX, pageY, viewX, viewY, targetId) {
+ this.#screenshotsContainer.drawPreviewEyes(pageX, pageY);
+
+ this.#screenshotsContainer.handleElementHover(viewX, viewY, targetId);
+ }
+
+ /**
+ * Set the bottom and right coordinates of the box and draw the box
+ * @param clientX x coordinate
+ * @param clientY y coordinate
+ */
+ draggingDrag(clientX, clientY) {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ right: clientX,
+ bottom: clientY,
+ });
+
+ this.#screenshotsContainer.drawSelectionBox();
+ }
+
+ /**
+ * If the mouse has moved at least 40 pixels then set the state to "dragging"
+ * @param clientX x coordinate
+ * @param clientY y coordinate
+ */
+ draggingReadyDrag(clientX, clientY) {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ right: clientX,
+ bottom: clientY,
+ });
+
+ if (this.#screenshotsContainer.selectionBoxDistance() > 40) {
+ this.setState("dragging");
+ }
+ }
+
+ /**
+ * Depending on what mover was selected we will resize the box accordingly
+ * @param clientX x coordinate
+ * @param clientY y coordinate
+ */
+ resizingDrag(clientX, clientY) {
+ switch (this.#moverId) {
+ case "mover-topLeft": {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ left: clientX,
+ top: clientY,
+ });
+ break;
+ }
+ case "mover-top": {
+ this.#screenshotsContainer.setSelectionBoxDimensions({ top: clientY });
+ break;
+ }
+ case "mover-topRight": {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ top: clientY,
+ right: clientX,
+ });
+ break;
+ }
+ case "mover-right": {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ right: clientX,
+ });
+ break;
+ }
+ case "mover-bottomRight": {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ right: clientX,
+ bottom: clientY,
+ });
+ break;
+ }
+ case "mover-bottom": {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ bottom: clientY,
+ });
+ break;
+ }
+ case "mover-bottomLeft": {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ left: clientX,
+ bottom: clientY,
+ });
+ break;
+ }
+ case "mover-left": {
+ this.#screenshotsContainer.setSelectionBoxDimensions({ left: clientX });
+ break;
+ }
+ case "highlight": {
+ let lastBox = this.#lastBox;
+ let diffX = this.#lastX - clientX;
+ let diffY = this.#lastY - clientY;
+
+ let newLeft;
+ let newRight;
+ let newTop;
+ let newBottom;
+
+ // Unpack SelectionBox dimensions to use here
+ let {
+ boxLeft,
+ boxTop,
+ boxRight,
+ boxBottom,
+ boxWidth,
+ boxHeight,
+ scrollWidth,
+ scrollHeight,
+ } = this.#screenshotsContainer.getSelectionLayerDimensions();
+
+ // wait until all 4 if elses have completed before setting box dimensions
+ if (boxWidth <= lastBox.width && boxLeft === 0) {
+ newLeft = boxRight - lastBox.width;
+ } else {
+ newLeft = boxLeft;
+ }
+
+ if (boxWidth <= lastBox.width && boxRight === scrollWidth) {
+ newRight = boxLeft + lastBox.width;
+ } else {
+ newRight = boxRight;
+ }
+
+ if (boxHeight <= lastBox.height && boxTop === 0) {
+ newTop = boxBottom - lastBox.height;
+ } else {
+ newTop = boxTop;
+ }
+
+ if (boxHeight <= lastBox.height && boxBottom === scrollHeight) {
+ newBottom = boxTop + lastBox.height;
+ } else {
+ newBottom = boxBottom;
+ }
+
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ left: newLeft - diffX,
+ top: newTop - diffY,
+ right: newRight - diffX,
+ bottom: newBottom - diffY,
+ });
+
+ this.#lastX = clientX;
+ this.#lastY = clientY;
+ break;
+ }
+ }
+ this.#screenshotsContainer.drawSelectionBox();
+ }
+
+ /**
+ * Draw the selection box from the hover element box if it exists
+ * Else set the state to "crosshairs"
+ */
+ draggingReadyDragEnd(scrollX, scrollY) {
+ if (this.#screenshotsContainer.hoverElementBoxRect) {
+ this.#screenshotsContainer.hidePreviewLayer();
+ this.#screenshotsContainer.updateSelectionBoxFromRect(scrollX, scrollY);
+ this.#screenshotsContainer.drawSelectionBox();
+ this.setState("selected");
+ } else {
+ this.setState("crosshairs");
+ }
+ }
+
+ /**
+ * Draw the box one last time and set the state to "selected"
+ * @param clientX x coordinate
+ * @param clientY y coordinate
+ */
+ draggingDragEnd(clientX, clientY) {
+ this.#screenshotsContainer.setSelectionBoxDimensions({
+ right: clientX,
+ bottom: clientY,
+ });
+ this.#screenshotsContainer.sortSelectionLayerBoxCoords();
+ this.setState("selected");
+ }
+
+ /**
+ * Draw the box one last time and set the state to "selected"
+ * @param clientX x coordinate
+ * @param clientY y coordinate
+ */
+ resizingDragEnd(clientX, clientY, targetId) {
+ this.resizingDrag(clientX, clientY, targetId);
+ this.#screenshotsContainer.sortSelectionLayerBoxCoords();
+ this.setState("selected");
+ }
+
+ /**
+ * The page was resized or scrolled. We need to update the
+ * ScreenshotsContainer size so we don't draw outside the window bounds
+ * If the current state is "selected" and this was called from a resize event
+ * then we need to maybe shift the SelectionBox
+ * @param win The window object of the page
+ * @param eventType If this was called from a resize event
+ */
+ updateScreenshotsContainerSize(win, eventType) {
+ this.#screenshotsContainer.updateSize(win);
+
+ if (this.#state === "selected" && eventType === "resize") {
+ this.#screenshotsContainer.shiftSelectionLayerBox();
+ } else if (this.#state && eventType === "scroll") {
+ this.#screenshotsContainer.drawButtonsLayer();
+ if (this.#state === "crosshairs") {
+ this.#screenshotsContainer.handleElementScroll();
+ }
+ }
+ }
+}
+
+class AnonLayer {
+ id;
+ content;
+
+ constructor(id, content) {
+ this.id = id;
+ this.content = content;
+ }
+
+ /**
+ * Show element with id this.id
+ */
+ show() {
+ this.content.removeAttributeForElement(this.id, "style");
+ }
+
+ /**
+ * Hide element with id this.id
+ */
+ hide() {
+ this.content.setAttributeForElement(this.id, "style", "display:none;");
+ }
+}
+
+class HoverElementBox extends AnonLayer {
+ #document;
+ #rect;
+ #lastX;
+ #lastY;
+
+ constructor(id, content, document) {
+ super(id, content);
+
+ this.#document = document;
+ }
+
+ get rect() {
+ return this.#rect;
+ }
+
+ /**
+ * Draws the hover box over an element from the given rect
+ * @param rect The rect to draw the hover element box
+ */
+ drawHoverBox(rect) {
+ if (!rect) {
+ this.hide();
+ } else {
+ let maxHeight = this.selectionLayer.scrollHeight;
+ let maxWidth = this.selectionLayer.scrollWidth;
+ let top = this.#document.documentElement.scrollTop + rect.top;
+ top = top > 0 ? top : 0;
+ let left = this.#document.documentElement.scrollLeft + rect.left;
+ left = left > 0 ? left : 0;
+ let height =
+ rect.top + rect.height > maxHeight ? maxHeight - rect.top : rect.height;
+ let width =
+ rect.left + rect.width > maxWidth ? maxWidth - rect.left : rect.width;
+
+ this.content.setAttributeForElement(
+ this.id,
+ "style",
+ `top:${top}px;left:${left}px;height:${height}px;width:${width}px;`
+ );
+ }
+ }
+
+ /**
+ * Handles when the user moves the mouse over an element
+ * @param viewX The viewport x coordinate
+ * @param viewY The viewport y coordinate
+ * @param targetId The target element id
+ */
+ handleElementHover(viewX, viewY, targetId) {
+ if (targetId === "screenshots-overlay-container") {
+ let ele = this.getElementFromPoint(viewX, viewY);
+
+ if (this.cachedEle && this.cachedEle === ele) {
+ // Still hovering over the same element
+ return;
+ }
+ this.cachedEle = ele;
+
+ this.getBestRectForElement(ele);
+
+ this.#lastX = viewX;
+ this.#lastY = viewY;
+ }
+ }
+
+ /**
+ * Handles moving the rect when the user has scrolled but not moved the mouse
+ * It uses the last x and y viewport coordinates to find the new element at the mouse position
+ */
+ handleElementScroll() {
+ if (this.#lastX && this.#lastY) {
+ this.cachedEle = null;
+ this.handleElementHover(
+ this.#lastX,
+ this.#lastY,
+ "screenshots-overlay-container"
+ );
+ }
+ }
+
+ /**
+ * Finds an element for the given coordinates within the viewport
+ * @param x The viewport x coordinate
+ * @param y The viewport y coordinate
+ * @returns An element location at the given coordinates
+ */
+ getElementFromPoint(x, y) {
+ this.setPointerEventsNone();
+ let ele;
+ try {
+ ele = this.#document.elementFromPoint(x, y);
+ } finally {
+ this.resetPointerEvents();
+ }
+
+ return ele;
+ }
+
+ /**
+ * Gets the rect for an element if getBoundingClientRect exists
+ * @param ele The element to get the rect from
+ * @returns The bounding client rect of the element or null
+ */
+ getBoundingClientRect(ele) {
+ if (!ele.getBoundingClientRect) {
+ return null;
+ }
+
+ return ele.getBoundingClientRect();
+ }
+
+ /**
+ * This function takes an element and finds a suitable rect to draw the hover box on
+ * @param ele The element to find a suitale rect of
+ */
+ getBestRectForElement(ele) {
+ let lastRect;
+ let lastNode;
+ let rect;
+ let attemptExtend = false;
+ let node = ele;
+ while (node) {
+ rect = this.getBoundingClientRect(node);
+ if (!rect) {
+ rect = lastRect;
+ break;
+ }
+ if (rect.width < MIN_DETECT_WIDTH || rect.height < MIN_DETECT_HEIGHT) {
+ // Avoid infinite loop for elements with zero or nearly zero height,
+ // like non-clearfixed float parents with or without borders.
+ break;
+ }
+ if (rect.width > MAX_DETECT_WIDTH || rect.height > MAX_DETECT_HEIGHT) {
+ // Then the last rectangle is better
+ rect = lastRect;
+ attemptExtend = true;
+ break;
+ }
+ if (rect.width >= MIN_DETECT_WIDTH && rect.height >= MIN_DETECT_HEIGHT) {
+ if (!doNotAutoselectTags[node.tagName]) {
+ break;
+ }
+ }
+ lastRect = rect;
+ lastNode = node;
+ node = node.parentNode;
+ }
+ if (rect && node) {
+ const evenBetter = this.evenBetterElement(node);
+ if (evenBetter) {
+ node = lastNode = evenBetter;
+ rect = this.getBoundingClientRect(evenBetter);
+ attemptExtend = false;
+ }
+ }
+ if (rect && attemptExtend) {
+ let extendNode = lastNode.nextSibling;
+ while (extendNode) {
+ if (extendNode.nodeType === this.#document.ELEMENT_NODE) {
+ break;
+ }
+ extendNode = extendNode.nextSibling;
+ if (!extendNode) {
+ const parent = lastNode.parentNode;
+ for (let i = 0; i < parent.childNodes.length; i++) {
+ if (parent.childNodes[i] === lastNode) {
+ extendNode = parent.childNodes[i + 1];
+ }
+ }
+ }
+ }
+ if (extendNode) {
+ const extendRect = this.getBoundingClientRect(extendNode);
+ let x = Math.min(rect.x, extendRect.x);
+ let y = Math.min(rect.y, extendRect.y);
+ let width = Math.max(rect.right, extendRect.right) - x;
+ let height = Math.max(rect.bottom, extendRect.bottom) - y;
+ const combinedRect = new DOMRect(x, y, width, height);
+ if (
+ combinedRect.width <= MAX_DETECT_WIDTH &&
+ combinedRect.height <= MAX_DETECT_HEIGHT
+ ) {
+ rect = combinedRect;
+ }
+ }
+ }
+
+ if (
+ rect &&
+ (rect.width < MIN_DETECT_ABSOLUTE_WIDTH ||
+ rect.height < MIN_DETECT_ABSOLUTE_HEIGHT)
+ ) {
+ rect = null;
+ }
+
+ if (!rect) {
+ this.hide();
+ } else {
+ this.drawHoverBox(rect);
+ }
+
+ this.#rect = rect;
+ }
+
+ /**
+ * This finds a better element by looking for elements with role article
+ * @param node The currently hovered node
+ * @returns A better node or null
+ */
+ evenBetterElement(node) {
+ let el = node.parentNode;
+ const ELEMENT_NODE = this.#document.ELEMENT_NODE;
+ while (el && el.nodeType === ELEMENT_NODE) {
+ if (!el.getAttribute) {
+ return null;
+ }
+ if (el.getAttribute("role") === "article") {
+ const rect = this.getBoundingClientRect(el);
+ if (!rect) {
+ return null;
+ }
+ if (
+ rect.width <= MAX_DETECT_WIDTH &&
+ rect.height <= MAX_DETECT_HEIGHT
+ ) {
+ return el;
+ }
+ return null;
+ }
+ el = el.parentNode;
+ }
+ return null;
+ }
+
+ /**
+ * The pointer events need to be removed temporarily so we can find the
+ * correct element from document.elementFromPoint()
+ * If the pointer events are on for the screenshots elements, then we will always
+ * get the screenshots elements as the elements from a given point
+ */
+ setPointerEventsNone() {
+ this.content.setAttributeForElement(
+ "screenshots-component",
+ "style",
+ "pointer-events:none;"
+ );
+
+ let temp = this.content.getAttributeForElement(
+ "screenshots-overlay-container",
+ "style"
+ );
+ this.content.setAttributeForElement(
+ "screenshots-overlay-container",
+ "style",
+ temp + "pointer-events:none;"
+ );
+ }
+
+ /**
+ * Return the pointer events to the original state because we found the element
+ */
+ resetPointerEvents() {
+ this.content.setAttributeForElement("screenshots-component", "style", "");
+
+ let temp = this.content.getAttributeForElement(
+ "screenshots-overlay-container",
+ "style"
+ );
+ this.content.setAttributeForElement(
+ "screenshots-overlay-container",
+ "style",
+ temp.replace("pointer-events:none;", "")
+ );
+ }
+}
+
+class SelectionLayer extends AnonLayer {
+ #selectionBox;
+ #hoverElementBox;
+ #buttons;
+ #hidden;
+ /**
+ * the documentDimensions follows the below structure
+ * {
+ * scrollWidth: the total document width
+ * scrollHeight: the total document height
+ * scrollX: the x scrolled offset
+ * scrollY: the y scrolled offset
+ * innerWidth: the viewport width
+ * innerHeight: the viewport height
+ * }
+ */
+ #documentDimensions;
+
+ constructor(id, content, hoverElementBox) {
+ super(id, content);
+ this.#selectionBox = new SelectionBox(content, this);
+ this.#buttons = new ButtonsLayer("buttons", content, this);
+ this.#hoverElementBox = hoverElementBox;
+ this.#hoverElementBox.selectionLayer = this;
+
+ this.#hidden = true;
+ this.#documentDimensions = {};
+ }
+
+ /**
+ * Hide the buttons layer
+ */
+ hideButtons() {
+ this.#buttons.hide();
+ }
+
+ /**
+ * Call
+ */
+ drawButtonsLayer() {
+ this.#buttons.show();
+ }
+
+ /**
+ * Hide the selection-container element
+ */
+ hide() {
+ super.hide();
+ this.#hidden = true;
+ }
+
+ /**
+ * Draw the SelectionBox
+ */
+ drawSelectionBox() {
+ if (this.#hidden) {
+ this.show();
+ this.#hidden = false;
+ }
+ this.#selectionBox.show();
+ }
+
+ /**
+ * Sort the SelectionBox coordinates
+ */
+ sortSelectionBoxCoords() {
+ this.#selectionBox.sortCoords();
+ }
+
+ /**
+ * Sets the SelectionBox dimensions
+ * @param {Object} dims The new box dimensions
+ * {
+ * left: new left dimension value or undefined
+ * top: new top dimension value or undefined
+ * right: new right dimension value or undefined
+ * bottom: new bottom dimension value or undefined
+ * }
+ */
+ setSelectionBoxDimensions(dims) {
+ if (dims.left) {
+ this.#selectionBox.left = dims.left;
+ }
+ if (dims.top) {
+ this.#selectionBox.top = dims.top;
+ }
+ if (dims.right) {
+ this.#selectionBox.right = dims.right;
+ }
+ if (dims.bottom) {
+ this.#selectionBox.bottom = dims.bottom;
+ }
+ }
+
+ /**
+ * Gets the selections box dimensions
+ * @returns {Object}
+ * {
+ * x1: the left dimension value
+ * y1: the top dimension value
+ * width: the width of the selected region
+ * height: the height of the selected region
+ * }
+ */
+ getSelectionBoxDimensions() {
+ return this.#selectionBox.getDimensions();
+ }
+
+ /**
+ * Returns the box dimensions and the page dimensions
+ * @returns {Object}
+ * {
+ * boxLeft: the left position of the box
+ * boxTop: the top position of the box
+ * boxRight: the right position of the box
+ * boxBottom: the bottom position of the box
+ * scrollWidth: the total document width
+ * scrollHeight: the total document height
+ * scrollX: the x scrolled offset
+ * scrollY: the y scrolled offset
+ * innerWidth: the viewport width
+ * innerHeight: the viewport height
+ * }
+ */
+ getDimensions() {
+ return {
+ boxLeft: this.#selectionBox.left,
+ boxTop: this.#selectionBox.top,
+ boxRight: this.#selectionBox.right,
+ boxBottom: this.#selectionBox.bottom,
+ boxWidth: this.#selectionBox.width,
+ boxHeight: this.#selectionBox.height,
+ ...this.#documentDimensions,
+ };
+ }
+
+ /**
+ * Gets the diagonal distance of the SelectionBox
+ * @returns The diagonal distance of the SelectionBox
+ */
+ getSelectionBoxDistance() {
+ return this.#selectionBox.distance;
+ }
+
+ /**
+ * Shift the SelectionBox so that it is always within the document
+ */
+ shiftSelectionBox() {
+ this.#selectionBox.shiftBox();
+ }
+
+ /**
+ * Update the box coordinates from the hover element rect
+ */
+ updateSelectionBoxFromRect(scrollX, scrollY) {
+ this.#selectionBox.updateBoxFromRect(
+ this.#hoverElementBox.rect,
+ scrollX,
+ scrollY
+ );
+ }
+
+ /**
+ * Handles when the user moves the mouse over an element
+ * @param viewX The viewport x coordinate
+ * @param viewY The viewport y coordinate
+ * @param targetId The target element id
+ */
+ handleElementHover(viewX, viewY, targetId) {
+ this.#hoverElementBox.handleElementHover(viewX, viewY, targetId);
+ }
+
+ /**
+ * Handles moving the rect when the user has scrolled but not moved the mouse
+ * It uses the last x and y viewport coordinates to find the new element at the mouse position
+ */
+ handleElementScroll() {
+ this.#hoverElementBox.handleElementScroll();
+ }
+
+ hideHoverElementSelection() {
+ this.#hoverElementBox.hide();
+ }
+
+ get hoverElementBoxRect() {
+ return this.#hoverElementBox.rect;
+ }
+
+ get scrollWidth() {
+ return this.#documentDimensions.scrollWidth;
+ }
+ set scrollWidth(val) {
+ this.#documentDimensions.scrollWidth = val;
+ }
+
+ get scrollHeight() {
+ return this.#documentDimensions.scrollHeight;
+ }
+ set scrollHeight(val) {
+ this.#documentDimensions.scrollHeight = val;
+ }
+
+ get scrollX() {
+ return this.#documentDimensions.scrollX;
+ }
+ set scrollX(val) {
+ this.#documentDimensions.scrollX = val;
+ }
+
+ get scrollY() {
+ return this.#documentDimensions.scrollY;
+ }
+ set scrollY(val) {
+ this.#documentDimensions.scrollY = val;
+ }
+
+ get innerWidth() {
+ return this.#documentDimensions.innerWidth;
+ }
+ set innerWidth(val) {
+ this.#documentDimensions.innerWidth = val;
+ }
+
+ get innerHeight() {
+ return this.#documentDimensions.innerHeight;
+ }
+ set innerHeight(val) {
+ this.#documentDimensions.innerHeight = val;
+ }
+}
+
+/**
+ * The SelectionBox class handles drawing the highlight and background
+ */
+class SelectionBox extends AnonLayer {
+ #x1;
+ #x2;
+ #y1;
+ #y2;
+ #xOffset;
+ #yOffset;
+ #selectionLayer;
+
+ constructor(content, selectionLayer) {
+ super("", content);
+
+ this.#selectionLayer = selectionLayer;
+
+ this.#x1 = 0;
+ this.#x2 = 0;
+ this.#y1 = 0;
+ this.#y2 = 0;
+ this.#xOffset = 0;
+ this.#yOffset = 0;
+ }
+
+ /**
+ * Draw the selected region for screenshotting
+ */
+ show() {
+ this.content.setAttributeForElement(
+ "highlight",
+ "style",
+ `top:${this.top}px;left:${this.left}px;height:${this.height}px;width:${this.width}px;`
+ );
+
+ this.content.setAttributeForElement(
+ "bgTop",
+ "style",
+ `top:0px;height:${this.top}px;left:0px;width:100%;`
+ );
+
+ this.content.setAttributeForElement(
+ "bgBottom",
+ "style",
+ `top:${this.bottom}px;height:calc(100% - ${this.bottom}px);left:0px;width:100%;`
+ );
+
+ this.content.setAttributeForElement(
+ "bgLeft",
+ "style",
+ `top:${this.top}px;height:${this.height}px;left:0px;width:${this.left}px;`
+ );
+
+ this.content.setAttributeForElement(
+ "bgRight",
+ "style",
+ `top:${this.top}px;height:${this.height}px;left:${this.right}px;width:calc(100% - ${this.right}px);`
+ );
+ }
+
+ /**
+ * Update the box coordinates from the rect
+ * @param rect The hover element box
+ * @param scrollX The x offset the page is scrolled
+ * @param scrollY The y offset the page is scrolled
+ */
+ updateBoxFromRect(rect, scrollX, scrollY) {
+ this.top = rect.top + scrollY;
+ this.left = rect.left + scrollX;
+ this.right = rect.right + scrollX;
+ this.bottom = rect.bottom + scrollY;
+ }
+
+ /**
+ * Hide the selected region
+ */
+ hide() {
+ this.content.setAttributeForElement("highlight", "style", "display:none;");
+ this.content.setAttributeForElement("bgTop", "style", "display:none;");
+ this.content.setAttributeForElement("bgBottom", "style", "display:none;");
+ this.content.setAttributeForElement("bgLeft", "style", "display:none;");
+ this.content.setAttributeForElement("bgRight", "style", "display:none;");
+ }
+
+ /**
+ * The box should never appear outside the document so the SelectionBox will
+ * be shifted if the bounds of the box are outside the documents width or height
+ */
+ shiftBox() {
+ let didShift = false;
+ let xDiff = this.right - this.#selectionLayer.scrollWidth;
+ if (xDiff > 0) {
+ this.right -= xDiff;
+ this.left -= xDiff;
+
+ didShift = true;
+ }
+
+ let yDiff = this.bottom - this.#selectionLayer.scrollHeight;
+ if (yDiff > 0) {
+ let curWidth = this.width;
+
+ this.bottom -= yDiff;
+ this.top = this.bottom - curWidth;
+
+ didShift = true;
+ }
+
+ if (didShift) {
+ this.show();
+ this.#selectionLayer.drawButtonsLayer();
+ }
+ }
+
+ /**
+ * Sort the coordinates so x1 < x2 and y1 < y2
+ */
+ sortCoords() {
+ if (this.#x1 > this.#x2) {
+ [this.#x1, this.#x2] = [this.#x2, this.#x1];
+ }
+ if (this.#y1 > this.#y2) {
+ [this.#y1, this.#y2] = [this.#y2, this.#y1];
+ }
+ }
+
+ /**
+ * Gets the dimensions of the currently selected region
+ * @returns {Object}
+ * {
+ * x1: the left dimension value
+ * y1: the top dimension value
+ * width: the width of the selected region
+ * height: the height of the selected region
+ * }
+ */
+ getDimensions() {
+ return {
+ x1: this.left,
+ y1: this.top,
+ width: this.width,
+ height: this.height,
+ };
+ }
+
+ get distance() {
+ return Math.sqrt(Math.pow(this.width, 2) + Math.pow(this.height, 2));
+ }
+
+ get xOffset() {
+ return this.#xOffset;
+ }
+ set xOffset(val) {
+ this.#xOffset = val;
+ }
+
+ get yOffset() {
+ return this.#yOffset;
+ }
+ set yOffset(val) {
+ this.#yOffset = val;
+ }
+
+ get top() {
+ return Math.min(this.#y1, this.#y2);
+ }
+ set top(val) {
+ this.#y1 = val > 0 ? val : 0;
+ }
+
+ get left() {
+ return Math.min(this.#x1, this.#x2);
+ }
+ set left(val) {
+ this.#x1 = val > 0 ? val : 0;
+ }
+
+ get right() {
+ return Math.max(this.#x1, this.#x2);
+ }
+ set right(val) {
+ this.#x2 =
+ val > this.#selectionLayer.scrollWidth
+ ? this.#selectionLayer.scrollWidth
+ : val;
+ }
+
+ get bottom() {
+ return Math.max(this.#y1, this.#y2);
+ }
+ set bottom(val) {
+ this.#y2 =
+ val > this.#selectionLayer.scrollHeight
+ ? this.#selectionLayer.scrollHeight
+ : val;
+ }
+
+ get width() {
+ return Math.abs(this.#x2 - this.#x1);
+ }
+ get height() {
+ return Math.abs(this.#y2 - this.#y1);
+ }
+}
+
+class ButtonsLayer extends AnonLayer {
+ #selectionLayer;
+
+ constructor(id, content, selectionLayer) {
+ super(id, content);
+
+ this.#selectionLayer = selectionLayer;
+ }
+
+ /**
+ * Draw the buttons. Check if the box is too near the bottom or left of the
+ * viewport and adjust the buttons accordingly
+ */
+ show() {
+ let {
+ boxLeft,
+ boxTop,
+ boxRight,
+ boxBottom,
+ scrollX,
+ scrollY,
+ innerWidth,
+ innerHeight,
+ } = this.#selectionLayer.getDimensions();
+
+ if (
+ boxTop > scrollY + innerHeight ||
+ boxBottom < scrollY ||
+ boxLeft > scrollX + innerWidth ||
+ boxRight < scrollX
+ ) {
+ // The box is offscreen so need to draw the buttons
+ return;
+ }
+
+ let top = boxBottom;
+ let leftOrRight = `right:calc(100% - ${boxRight}px);`;
+
+ if (scrollY + innerHeight - boxBottom < 70) {
+ if (boxBottom < scrollY + innerHeight) {
+ top = boxBottom - 60;
+ } else if (scrollY + innerHeight - boxTop < 70) {
+ top = boxTop - 60;
+ } else {
+ top = scrollY + innerHeight - 60;
+ }
+ }
+ if (boxRight < 265) {
+ leftOrRight = `left:${boxLeft}px;`;
+ }
+
+ this.content.setAttributeForElement(
+ "buttons",
+ "style",
+ `top:${top}px;${leftOrRight}`
+ );
+ }
+}
+
+class PreviewLayer extends AnonLayer {
+ constructor(id, content) {
+ super(id, content);
+ }
+
+ /**
+ * Draw the eyeballs facing the mouse
+ * @param clientX x pointer position
+ * @param clientY y pointer position
+ * @param width width of the viewport
+ * @param height height of the viewport
+ */
+ drawEyes(clientX, clientY, width, height) {
+ const xpos = Math.floor((10 * (clientX - width / 2)) / width);
+ const ypos = Math.floor((10 * (clientY - height / 2)) / height);
+ const move = `transform:translate(${xpos}px, ${ypos}px);`;
+ this.content.setAttributeForElement("left-eye", "style", move);
+ this.content.setAttributeForElement("right-eye", "style", move);
+ }
+}
+
+class ScreenshotsContainerLayer extends AnonLayer {
+ #width;
+ #height;
+ #previewLayer;
+ #selectionLayer;
+
+ constructor(id, content, previewLayer, selectionLayer) {
+ super(id, content);
+
+ this.#previewLayer = previewLayer;
+ this.#selectionLayer = selectionLayer;
+ }
+
+ /**
+ * Hide the SelectionLayer
+ */
+ hideSelectionLayer() {
+ this.#selectionLayer.hide();
+ }
+
+ /**
+ * Show the PreviewLayer
+ */
+ showPreviewLayer() {
+ this.#previewLayer.show();
+ }
+
+ /**
+ * Hide the PreviewLayer
+ */
+ hidePreviewLayer() {
+ this.#previewLayer.hide();
+ this.#selectionLayer.hideHoverElementSelection();
+ }
+
+ /**
+ * Show the ButtonsLayer
+ */
+ drawButtonsLayer() {
+ this.#selectionLayer.drawButtonsLayer();
+ }
+
+ /**
+ * Hide the ButtonsLayer
+ */
+ hideButtonsLayer() {
+ this.#selectionLayer.hideButtons();
+ }
+
+ /**
+ * Show the SelectionBox
+ */
+ drawSelectionBox() {
+ this.#selectionLayer.drawSelectionBox();
+ }
+
+ /**
+ * Update the box coordinates from the hover element rect
+ */
+ updateSelectionBoxFromRect(scrollX, scrollY) {
+ this.#selectionLayer.updateSelectionBoxFromRect(scrollX, scrollY);
+ }
+
+ /**
+ * Handles when the user moves the mouse over an element
+ * @param viewX The viewport x coordinate
+ * @param viewY The viewport y coordinate
+ * @param targetId The target element id
+ */
+ handleElementHover(viewX, viewY, targetId) {
+ this.#selectionLayer.handleElementHover(viewX, viewY, targetId);
+ }
+
+ /**
+ * Handles moving the rect when the user has scrolled but not moved the mouse
+ * It uses the last x and y viewport coordinates to find the new element at the mouse position
+ */
+ handleElementScroll() {
+ this.#selectionLayer.handleElementScroll();
+ }
+
+ /**
+ * Draw the eyes in the PreviewLayer
+ * @param clientX The x mouse position
+ * @param clientY The y mouse position
+ */
+ drawPreviewEyes(clientX, clientY) {
+ this.#previewLayer.drawEyes(
+ clientX - this.#selectionLayer.scrollX,
+ clientY - this.#selectionLayer.scrollY,
+ this.#selectionLayer.innerWidth,
+ this.#selectionLayer.innerHeight
+ );
+ }
+
+ /**
+ * Get the diagonal distance of the SelectionBox
+ * @returns The diagonal distance of the currently selected region
+ */
+ selectionBoxDistance() {
+ return this.#selectionLayer.getSelectionBoxDistance();
+ }
+
+ /**
+ * Sort the coordinates of the SelectionBox
+ */
+ sortSelectionLayerBoxCoords() {
+ this.#selectionLayer.sortSelectionBoxCoords();
+ }
+
+ /**
+ * Get the SelectionLayer dimensions
+ * @returns {Object}
+ * {
+ * x1: the left dimension value
+ * y1: the top dimension value
+ * width: the width of the selected region
+ * height: the height of the selected region
+ * }
+ */
+ getSelectionLayerBoxDimensions() {
+ return this.#selectionLayer.getSelectionBoxDimensions();
+ }
+
+ /**
+ * Gets the SelectionBox and page dimensions
+ * @returns {Object}
+ * {
+ * boxLeft: the left position of the box
+ * boxTop: the top position of the box
+ * boxRight: the right position of the box
+ * boxBottom: the bottom position of the box
+ * scrollWidth: the total document width
+ * scrollHeight: the total document height
+ * scrollX: the x scrolled offset
+ * scrollY: the y scrolled offset
+ * innerWidth: the viewport width
+ * innerHeight: the viewport height
+ * }
+ */
+ getSelectionLayerDimensions() {
+ return this.#selectionLayer.getDimensions();
+ }
+
+ /**
+ * Shift the SelectionBox
+ */
+ shiftSelectionLayerBox() {
+ this.#selectionLayer.shiftSelectionBox();
+ }
+
+ /**
+ * Set the respective dimensions of the SelectionBox
+ * @param {Object} boxDimensionObject The new box dimensions
+ * {
+ * left: new left dimension value or undefined
+ * top: new top dimension value or undefined
+ * right: new right dimension value or undefined
+ * bottom: new bottom dimension value or undefined
+ * }
+ */
+ setSelectionBoxDimensions(boxDimensionObject) {
+ this.#selectionLayer.setSelectionBoxDimensions(boxDimensionObject);
+ }
+
+ /**
+ * The screenshots-overlay-container doesn't shrink with the window when the
+ * window is resized so we have to manually find the width and height of the
+ * window by looping throught the documentElement's children
+ * If the children mysteriously have a height or width of 0 then we will
+ * fallback to the scrollWidth and scrollHeight which can cause the container
+ * to be larger than the window dimensions
+ * @param win The window object
+ */
+ updateSize(win) {
+ let { innerWidth, innerHeight, scrollX, scrollY } = win;
+ this.#selectionLayer.innerWidth = innerWidth;
+ this.#selectionLayer.innerHeight = innerHeight;
+ this.#selectionLayer.scrollX = scrollX;
+ this.#selectionLayer.scrollY = scrollY;
+
+ const doc = win.document.documentElement;
+ let width = Math.max.apply(
+ null,
+ Array.from(doc.children, x => x.scrollWidth)
+ );
+ let height = Math.max.apply(
+ null,
+ Array.from(doc.children, x => x.scrollHeight)
+ );
+
+ if (width < 1) {
+ width = doc.scrollWidth;
+ } else if (width < innerWidth) {
+ width = innerWidth;
+ }
+
+ if (height < 1) {
+ height = doc.scrollHeight;
+ } else if (height < innerHeight) {
+ height = innerHeight;
+ }
+
+ this.#selectionLayer.scrollWidth = width;
+ this.#selectionLayer.scrollHeight = height;
+
+ this.#width = width;
+ this.#height = height;
+
+ this.drawScreenshotsContainer();
+ }
+
+ /**
+ * Return the dimensions of the screenshots container
+ * @returns {Object}
+ * width: the container width
+ * height: the container height
+ */
+ getDimension() {
+ return { width: this.#width, height: this.#height };
+ }
+
+ /**
+ * Draw the screenshots container
+ */
+ drawScreenshotsContainer() {
+ this.content.setAttributeForElement(
+ this.id,
+ "style",
+ `top:0;left:0;width:${this.#width}px;height:${this.#height}px;`
+ );
+ }
+
+ get hoverElementBoxRect() {
+ return this.#selectionLayer.hoverElementBoxRect;
+ }
+}
diff --git a/browser/components/screenshots/ScreenshotsUtils.sys.mjs b/browser/components/screenshots/ScreenshotsUtils.sys.mjs
new file mode 100644
index 0000000000..d0f827acf3
--- /dev/null
+++ b/browser/components/screenshots/ScreenshotsUtils.sys.mjs
@@ -0,0 +1,459 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { getFilename } from "chrome://browser/content/screenshots/fileHelpers.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+const PanelPosition = "bottomright topright";
+const PanelOffsetX = -33;
+const PanelOffsetY = -8;
+
+export class ScreenshotsComponentParent extends JSWindowActorParent {
+ async receiveMessage(message) {
+ let browser = message.target.browsingContext.topFrameElement;
+ switch (message.name) {
+ case "Screenshots:CancelScreenshot":
+ await ScreenshotsUtils.closePanel(browser);
+ break;
+ case "Screenshots:CopyScreenshot":
+ await ScreenshotsUtils.closePanel(browser);
+ let copyBox = message.data;
+ ScreenshotsUtils.copyScreenshotFromRegion(copyBox, browser);
+ break;
+ case "Screenshots:DownloadScreenshot":
+ await ScreenshotsUtils.closePanel(browser);
+ let { title, downloadBox } = message.data;
+ ScreenshotsUtils.downloadScreenshotFromRegion(
+ title,
+ downloadBox,
+ browser
+ );
+ break;
+ case "Screenshots:ShowPanel":
+ ScreenshotsUtils.createOrDisplayButtons(browser);
+ break;
+ case "Screenshots:HidePanel":
+ ScreenshotsUtils.closePanel(browser);
+ break;
+ }
+ }
+
+ didDestroy() {
+ // When restoring a crashed tab the browser is null
+ let browser = this.browsingContext.topFrameElement;
+ if (browser) {
+ ScreenshotsUtils.closePanel(browser);
+ }
+ }
+}
+
+export var ScreenshotsUtils = {
+ initialized: false,
+ initialize() {
+ if (!this.initialized) {
+ if (
+ !Services.prefs.getBoolPref(
+ "screenshots.browser.component.enabled",
+ false
+ )
+ ) {
+ return;
+ }
+ Services.obs.addObserver(this, "menuitem-screenshot");
+ Services.obs.addObserver(this, "screenshots-take-screenshot");
+ this.initialized = true;
+ if (Cu.isInAutomation) {
+ Services.obs.notifyObservers(null, "screenshots-component-initialized");
+ }
+ }
+ },
+ uninitialize() {
+ if (this.initialized) {
+ Services.obs.removeObserver(this, "menuitem-screenshot");
+ Services.obs.removeObserver(this, "screenshots-take-screenshot");
+ this.initialized = false;
+ }
+ },
+ handleEvent(event) {
+ if (event.type === "keydown" && event.key === "Escape") {
+ this.closePanel(event.view.gBrowser.selectedBrowser, true);
+ }
+ },
+ observe(subj, topic, data) {
+ let { gBrowser } = subj;
+ let browser = gBrowser.selectedBrowser;
+
+ switch (topic) {
+ case "menuitem-screenshot":
+ let success = this.closeDialogBox(browser);
+ if (!success || data === "retry") {
+ // only toggle the buttons if no dialog box is found because
+ // if dialog box is found then the buttons are hidden and we return early
+ // else no dialog box is found and we need to toggle the buttons
+ // or if retry because the dialog box was closed and we need to show the panel
+ this.togglePanelAndOverlay(browser);
+ }
+ break;
+ case "screenshots-take-screenshot":
+ // need to close the preview because screenshot was taken
+ this.closePanel(browser, true);
+
+ // init UI as a tab dialog box
+ let dialogBox = gBrowser.getTabDialogBox(browser);
+
+ let { dialog } = dialogBox.open(
+ `chrome://browser/content/screenshots/screenshots.html?browsingContextId=${browser.browsingContext.id}`,
+ {
+ features: "resizable=no",
+ sizeTo: "available",
+ allowDuplicateDialogs: false,
+ }
+ );
+ this.doScreenshot(browser, dialog, data);
+ }
+ return null;
+ },
+ /**
+ * Notify screenshots when screenshot command is used.
+ * @param window The current window the screenshot command was used.
+ * @param type The type of screenshot taken. Used for telemetry.
+ */
+ notify(window, type) {
+ if (Services.prefs.getBoolPref("screenshots.browser.component.enabled")) {
+ Services.obs.notifyObservers(
+ window.event.currentTarget.ownerGlobal,
+ "menuitem-screenshot"
+ );
+ } else {
+ Services.obs.notifyObservers(null, "menuitem-screenshot-extension", type);
+ }
+ },
+ /**
+ * Creates and returns a Screenshots actor.
+ * @param browser The current browser.
+ * @returns JSWindowActor The screenshot actor.
+ */
+ getActor(browser) {
+ let actor = browser.browsingContext.currentWindowGlobal.getActor(
+ "ScreenshotsComponent"
+ );
+ return actor;
+ },
+ /**
+ * Open the panel buttons and call child actor to open the overlay
+ * @param browser The current browser
+ */
+ openPanel(browser) {
+ let actor = this.getActor(browser);
+ actor.sendQuery("Screenshots:ShowOverlay");
+ this.createOrDisplayButtons(browser);
+ },
+ /**
+ * Close the panel and call child actor to close the overlay
+ * @param browser The current browser
+ * @param {bool} closeOverlay Whether or not to
+ * send a message to the child to close the overly.
+ * Defaults to false. Will be false when called from didDestroy.
+ */
+ async closePanel(browser, closeOverlay = false) {
+ let buttonsPanel = browser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ if (buttonsPanel && buttonsPanel.state !== "closed") {
+ buttonsPanel.hidePopup();
+ }
+ buttonsPanel?.ownerDocument.removeEventListener("keydown", this);
+ if (closeOverlay) {
+ let actor = this.getActor(browser);
+ await actor.sendQuery("Screenshots:HideOverlay");
+ }
+ },
+ /**
+ * If the buttons panel exists and is open we will hide both the panel
+ * and the overlay. If the overlay is showing, we will hide the overlay.
+ * Otherwise create or display the buttons.
+ * @param browser The current browser.
+ */
+ async togglePanelAndOverlay(browser) {
+ let buttonsPanel = browser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ let isOverlayShowing = await this.getActor(browser).sendQuery(
+ "Screenshots:isOverlayShowing"
+ );
+ if (buttonsPanel && (isOverlayShowing || buttonsPanel.state !== "closed")) {
+ buttonsPanel.hidePopup();
+ let actor = this.getActor(browser);
+ return actor.sendQuery("Screenshots:HideOverlay");
+ }
+ let actor = this.getActor(browser);
+ actor.sendQuery("Screenshots:ShowOverlay");
+ return this.createOrDisplayButtons(browser);
+ },
+ /**
+ * Gets the screenshots dialog box
+ * @param browser The selected browser
+ * @returns Screenshots dialog box if it exists otherwise null
+ */
+ getDialog(browser) {
+ let currTabDialogBox = browser.tabDialogBox;
+ let browserContextId = browser.browsingContext.id;
+ if (currTabDialogBox) {
+ currTabDialogBox.getTabDialogManager();
+ let manager = currTabDialogBox.getTabDialogManager();
+ let dialogs = manager.hasDialogs && manager.dialogs;
+ if (dialogs.length) {
+ for (let dialog of dialogs) {
+ if (
+ dialog._openedURL.endsWith(
+ `browsingContextId=${browserContextId}`
+ ) &&
+ dialog._openedURL.includes("screenshots.html")
+ ) {
+ return dialog;
+ }
+ }
+ }
+ }
+ return null;
+ },
+ /**
+ * Closes the dialog box it it exists
+ * @param browser The selected browser
+ */
+ closeDialogBox(browser) {
+ let dialog = this.getDialog(browser);
+ if (dialog) {
+ dialog.close();
+ return true;
+ }
+ return false;
+ },
+ /**
+ * If the buttons panel does not exist then we will replace the buttons
+ * panel template with the buttons panel then open the buttons panel and
+ * show the screenshots overaly.
+ * @param browser The current browser.
+ */
+ createOrDisplayButtons(browser) {
+ let doc = browser.ownerDocument;
+ let buttonsPanel = doc.querySelector("#screenshotsPagePanel");
+ if (!buttonsPanel) {
+ let template = doc.querySelector("#screenshotsPagePanelTemplate");
+ let clone = template.content.cloneNode(true);
+ template.replaceWith(clone);
+ buttonsPanel = doc.querySelector("#screenshotsPagePanel");
+ }
+
+ buttonsPanel.ownerDocument.addEventListener("keydown", this);
+
+ let anchor = doc.querySelector("#navigator-toolbox");
+ buttonsPanel.openPopup(anchor, PanelPosition, PanelOffsetX, PanelOffsetY);
+ },
+ /**
+ * Gets the full page bounds from the screenshots child actor.
+ * @param browser The current browser.
+ * @returns { object }
+ * Contains the full page bounds from the screenshots child actor.
+ */
+ fetchFullPageBounds(browser) {
+ let actor = this.getActor(browser);
+ return actor.sendQuery("Screenshots:getFullPageBounds");
+ },
+ /**
+ * Gets the visible bounds from the screenshots child actor.
+ * @param browser The current browser.
+ * @returns { object }
+ * Contains the visible bounds from the screenshots child actor.
+ */
+ fetchVisibleBounds(browser) {
+ let actor = this.getActor(browser);
+ return actor.sendQuery("Screenshots:getVisibleBounds");
+ },
+ /**
+ * Add screenshot-ui to the dialog box and then take the screenshot
+ * @param browser The current browser.
+ * @param dialog The dialog box to show the screenshot preview.
+ * @param type The type of screenshot taken.
+ */
+ async doScreenshot(browser, dialog, type) {
+ await dialog._dialogReady;
+ let screenshotsUI = dialog._frame.contentDocument.createElement(
+ "screenshots-ui"
+ );
+ dialog._frame.contentDocument.body.appendChild(screenshotsUI);
+
+ let rect;
+ if (type === "full-page") {
+ rect = await this.fetchFullPageBounds(browser);
+ } else {
+ rect = await this.fetchVisibleBounds(browser);
+ }
+ return this.takeScreenshot(browser, dialog, rect);
+ },
+ /**
+ * Take the screenshot and add the image to the dialog box
+ * @param browser The current browser.
+ * @param dialog The dialog box to show the screenshot preview.
+ * @param rect DOMRect containing bounds of the screenshot.
+ */
+ async takeScreenshot(browser, dialog, rect) {
+ let { canvas, snapshot } = await this.createCanvas(rect, browser);
+
+ let newImg = dialog._frame.contentDocument.createElement("img");
+ let url = canvas.toDataURL();
+
+ newImg.id = "placeholder-image";
+
+ newImg.src = url;
+ dialog._frame.contentDocument
+ .getElementById("preview-image-div")
+ .appendChild(newImg);
+
+ if (Cu.isInAutomation) {
+ Services.obs.notifyObservers(null, "screenshots-preview-ready");
+ }
+
+ snapshot.close();
+ },
+ /**
+ * Creates a canvas and draws a snapshot of the screenshot on the canvas
+ * @param box The bounds of screenshots
+ * @param browser The current browser
+ * @returns The canvas and snapshot in an object
+ */
+ async createCanvas(box, browser) {
+ let rect = new DOMRect(box.x1, box.y1, box.width, box.height);
+ let { devicePixelRatio } = box;
+
+ let browsingContext = BrowsingContext.get(browser.browsingContext.id);
+
+ let snapshot = await browsingContext.currentWindowGlobal.drawSnapshot(
+ rect,
+ devicePixelRatio,
+ "rgb(255,255,255)"
+ );
+
+ let canvas = browser.ownerDocument.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "html:canvas"
+ );
+ let context = canvas.getContext("2d");
+
+ canvas.width = snapshot.width;
+ canvas.height = snapshot.height;
+
+ context.drawImage(snapshot, 0, 0);
+
+ return { canvas, snapshot };
+ },
+ /**
+ * Copy the screenshot
+ * @param region The bounds of the screenshots
+ * @param browser The current browser
+ */
+ async copyScreenshotFromRegion(region, browser) {
+ let { canvas, snapshot } = await this.createCanvas(region, browser);
+
+ let url = canvas.toDataURL();
+
+ this.copyScreenshot(url, browser);
+
+ snapshot.close();
+ },
+ /**
+ * Copy the image to the clipboard
+ * @param dataUrl The image data
+ */
+ copyScreenshot(dataUrl) {
+ // Guard against missing image data.
+ if (!dataUrl) {
+ return;
+ }
+
+ const imageTools = Cc["@mozilla.org/image/tools;1"].getService(
+ Ci.imgITools
+ );
+
+ const base64Data = dataUrl.replace("data:image/png;base64,", "");
+
+ const image = atob(base64Data);
+ const imgDecoded = imageTools.decodeImageFromBuffer(
+ image,
+ image.length,
+ "image/png"
+ );
+
+ const transferable = Cc[
+ "@mozilla.org/widget/transferable;1"
+ ].createInstance(Ci.nsITransferable);
+ transferable.init(null);
+ transferable.addDataFlavor("image/png");
+ transferable.setTransferData("image/png", imgDecoded);
+
+ Services.clipboard.setData(
+ transferable,
+ null,
+ Services.clipboard.kGlobalClipboard
+ );
+ },
+ /**
+ * Download the screenshot
+ * @param title The title of the current page
+ * @param box The bounds of the screenshot
+ * @param browser The current browser
+ */
+ async downloadScreenshotFromRegion(title, box, browser) {
+ let { canvas, snapshot } = await this.createCanvas(box, browser);
+
+ let dataUrl = canvas.toDataURL();
+
+ await this.downloadScreenshot(title, dataUrl, browser);
+
+ snapshot.close();
+ },
+ /**
+ * Download the screenshot
+ * @param title The title of the current page or null and getFilename will get the title
+ * @param dataUrl The image data
+ * @param browser The current browser
+ */
+ async downloadScreenshot(title, dataUrl, browser) {
+ // Guard against missing image data.
+ if (!dataUrl) {
+ return;
+ }
+
+ let filename = await getFilename(title, browser);
+
+ const targetFile = new lazy.FileUtils.File(filename);
+
+ // Create download and track its progress.
+ try {
+ const download = await lazy.Downloads.createDownload({
+ source: dataUrl,
+ target: targetFile,
+ });
+
+ let isPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate(
+ browser.ownerGlobal
+ );
+ const list = await lazy.Downloads.getList(
+ isPrivate ? lazy.Downloads.PRIVATE : lazy.Downloads.PUBLIC
+ );
+ // add the download to the download list in the Downloads list in the Browser UI
+ list.add(download);
+
+ // Await successful completion of the save via the download manager
+ await download.start();
+ } catch (ex) {}
+ },
+};
diff --git a/browser/components/screenshots/content/cancel.svg b/browser/components/screenshots/content/cancel.svg
new file mode 100644
index 0000000000..0c176be25f
--- /dev/null
+++ b/browser/components/screenshots/content/cancel.svg
@@ -0,0 +1,4 @@
+<!-- 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..48fe7c3038
--- /dev/null
+++ b/browser/components/screenshots/content/screenshots.css
@@ -0,0 +1,535 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+html,
+body {
+ padding: 0;
+ margin: 0;
+ background-color: transparent;
+ height: 100vh;
+ width: 100vw;
+}
+
+.button,
+.preview-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 16px;
+ font-weight: 400;
+ height: 40px;
+ min-width: 40px;
+ outline: none;
+ padding: 0 10px;
+ position: relative;
+ text-align: center;
+ text-decoration: none;
+ transition: background 150ms cubic-bezier(0.07, 0.95, 0, 1), border 150ms cubic-bezier(0.07, 0.95, 0, 1);
+ user-select: none;
+ white-space: nowrap;
+}
+
+button img {
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.button.hidden,
+.hidden.preview-button {
+ display: none;
+}
+
+.button.small,
+.small.preview-button {
+ height: 32px;
+ line-height: 32px;
+ padding: 0 8px;
+}
+
+.button.active,
+.active.preview-button {
+ background-color: #dedede;
+}
+
+@media (forced-colors: active), (prefers-contrast) {
+ .button.active,
+ .active.preview-button {
+ background-color: ButtonFace;
+ }
+}
+
+.button.tiny,
+.tiny.preview-button {
+ font-size: 14px;
+ height: 26px;
+ border: 1px solid #c7c7c7;
+}
+
+.button.tiny:hover,
+.tiny.preview-button:hover,
+.button.tiny:focus,
+.tiny.preview-button:focus {
+ background: #ededf0;
+ border-color: #989898;
+}
+
+.button.tiny:active,
+.tiny.preview-button:active {
+ background: #dedede;
+ border-color: #989898;
+}
+
+.button.block-button,
+.block-button.preview-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ border: 0;
+ border-inline-end: 1px solid #c7c7c7;
+ box-shadow: none;
+ border-radius: 0;
+ flex-shrink: 0;
+ font-size: 20px;
+ height: 100px;
+ line-height: 100%;
+ overflow: hidden;
+}
+
+@media (max-width: 719px) {
+ .button.block-button,
+ .block-button.preview-button {
+ justify-content: flex-start;
+ font-size: 16px;
+ height: 72px;
+ margin-inline-end: 10px;
+ padding: 0 5px;
+ }
+}
+
+.button.block-button:hover,
+.block-button.preview-button:hover {
+ background: #ededf0;
+}
+
+.button.block-button:active,
+.block-button.preview-button:active {
+ background: #dedede;
+}
+
+@media (forced-colors: active), (prefers-contrast) {
+ .button.block-button:hover,
+ .block-button.preview-button:hover {
+ background-color: ButtonText;
+ }
+
+ .button.block-button:active,
+ .block-button.preview-button:active {
+ background-color: ButtonFace;
+ }
+}
+
+.button.download,
+.download.preview-button,
+.button.flag,
+.flag.preview-button {
+ background-repeat: no-repeat;
+ background-size: 50%;
+ background-position: center;
+ margin-inline-end: 10px;
+ transition: background-color 150ms cubic-bezier(0.07, 0.95, 0, 1);
+}
+
+.button.download,
+.download.highlight-button-retry,
+.download.highlight-button-cancel,
+.download.highlight-button-download,
+.download.highlight-button-copy {
+ background-image: url("chrome://browser/content/screenshots/download.svg");
+}
+
+.button.download:hover,
+.download.preview-button:hover {
+ background-color: #ededf0;
+}
+
+.button.download:active,
+.download.preview-button:active {
+ background-color: #dedede;
+}
+
+@media (forced-colors: active), (prefers-contrast) {
+ .button.download:hover,
+ .download.preview-button:hover {
+ background-color: ButtonText;
+ color: ButtonFace;
+ }
+
+ .button.download:active,
+ .download.preview-button:active {
+ background-color: ButtonFace;
+ color: ButtonText;
+ }
+}
+
+.button.primary,
+.primary.highlight-retry-cancel,
+.primary.highlight-button-cancel,
+.highlight-button-download,
+.primary.highlight-button-copy {
+ background-color: #0a84ff;
+ color: #fff;
+}
+
+.button.primary:hover,
+.primary.highlight-button-retry:hover,
+.primary.highlight-button-cancel:hover,
+.highlight-button-download:hover,
+.primary.highlight-button-copy:hover,
+.button.primary:focus,
+.primary.highlight-button-retry:focus,
+.primary.highlight-button-cancel:focus,
+.highlight-button-download:focus,
+.primary.highlight-button-copy:focus {
+ background-color: #0072e5;
+}
+
+.button.primary:active,
+.primary.highlight-button-retry:active,
+.primary.highlight-button-cancel:active,
+.highlight-button-download:active,
+.primary.highlight-button-copy:active {
+ background-color: #0065cc;
+}
+
+@media (forced-colors: active), (prefers-contrast) {
+ .button.primary,
+ .primary.highlight-retry-cancel,
+ .primary.highlight-button-cancel,
+ .highlight-button-download,
+ .primary.highlight-button-copy {
+ background-color: ButtonFace;
+ color: ButtonText;
+ }
+
+ .button.primary:hover,
+ .primary.highlight-button-retry:hover,
+ .primary.highlight-button-cancel:hover,
+ .highlight-button-download:hover,
+ .primary.highlight-button-copy:hover,
+ .button.primary:focus,
+ .primary.highlight-button-retry:focus,
+ .primary.highlight-button-cancel:focus,
+ .highlight-button-download:focus,
+ .primary.highlight-button-copy:focus {
+ background-color: ButtonText;
+ color: ButtonFace;
+ }
+
+ .button.primary:active,
+ .primary.highlight-button-retry:active,
+ .primary.highlight-button-cancel:active,
+ .highlight-button-download:active,
+ .primary.highlight-button-copy:active {
+ background-color: ButtonFace;
+ color: ButtonText;
+ }
+}
+
+.button.secondary,
+.highlight-button-retry,
+.highlight-button-cancel,
+.secondary.highlight-button-download,
+.highlight-button-copy {
+ background-color: #f9f9fa;
+ color: #38383d;
+}
+
+.button.secondary:hover,
+.highlight-button-retry:hover,
+.highlight-button-cancel:hover,
+.secondary.highlight-button-download:hover,
+.highlight-button-copy:hover {
+ background-color: #ededf0;
+}
+
+.button.secondary:active,
+.highlight-button-retry:active,
+.highlight-button-cancel:active,
+.secondary.highlight-button-download:active,
+.highlight-button-copy:active {
+ background-color: #dedede;
+}
+
+@media (forced-colors: active), (prefers-contrast) {
+ .button.secondary,
+ .highlight-button-retry,
+ .highlight-button-cancel,
+ .secondary.highlight-button-download,
+ .highlight-button-copy {
+ background-color: ButtonFace;
+ color: ButtonText;
+ }
+
+ .button.secondary:hover,
+ .highlight-button-retry:hover,
+ .highlight-button-cancel:hover,
+ .secondary.highlight-button-download:hover,
+ .highlight-button-copy:hover {
+ background-color: ButtonText;
+ color: ButtonFace;
+ }
+
+ .button.secondary:active,
+ .highlight-button-retry:active,
+ .highlight-button-cancel:active,
+ .secondary.highlight-button-download:active,
+ .highlight-button-copy:active {
+ background-color: ButtonFace;
+ color: ButtonText;
+ }
+}
+
+.button.transparent,
+.transparent.preview-button {
+ background-color: transparent;
+ color: #38383d;
+}
+
+.button.transparent:hover,
+.transparent.preview-button:hover {
+ background-color: #ededf0;
+}
+
+.button.transparent:focus,
+.transparent.preview-button:focus,
+.button.transparent:active,
+.transparent.preview-button:active {
+ background-color: #dedede;
+}
+
+@media (forced-colors: active), (prefers-contrast) {
+ .button.transparent,
+ .transparent.preview-button {
+ background-color: ButtonFace;
+ color: ButtonText;
+ }
+
+ .button.transparent:hover,
+ .transparent.preview-button:hover {
+ background-color: ButtonText;
+ color: ButtonFace;
+ }
+
+ .button.transparent:focus,
+ .transparent.preview-button:focus,
+ .button.transparent:active,
+ .transparent.preview-button:active {
+ background-color: ButtonFace;
+ color: ButtonText;
+ }
+}
+
+.button.warning,
+.warning.preview-button {
+ color: #fff;
+ background: #d92215;
+}
+
+.button.warning:hover,
+.warning.preview-button:hover,
+.button.warning:focus,
+.warning.preview-button:focus {
+ background: #b81d12;
+}
+
+.button.warning:active,
+.warning.preview-button:active {
+ background: #a11910;
+}
+
+@media (forced-colors: active), (prefers-contrast) {
+ .button.warning,
+ .warning.preview-button {
+ color: ButtonText;
+ background-color: ButtonFace;
+ }
+
+ .button.warning:hover,
+ .warning.preview-button:hover,
+ .button.warning:focus,
+ .warning.preview-button:focus {
+ background-color: ButtonText;
+ color: ButtonFace;
+ }
+
+ .button.warning:active,
+ .warning.preview-button:active {
+ background-color: ButtonFace;
+ }
+}
+
+@keyframes bounce {
+ 0% {
+ transform: translateX(-40px);
+ }
+ 100% {
+ transform: translate(190px);
+ }
+}
+
+@keyframes pulse {
+ 0% {
+ opacity: 0.3;
+ transform: scale(1);
+ }
+ 70% {
+ opacity: 0.25;
+ transform: scale(1.04);
+ }
+ 100% {
+ opacity: 0.3;
+ transform: scale(1);
+ }
+}
+
+.highlight {
+ border-radius: 1px;
+ border: 2px dashed rgba(255, 255, 255, 0.8);
+ box-sizing: border-box;
+ cursor: move;
+ position: absolute;
+ z-index: 9999999999;
+}
+
+/* When prefers contrast is fully supported, we should change these quereies to cover both high and low prefers contrast cases */
+@media (forced-colors: active) {
+ .highlight {
+ border: 2px dashed white;
+ opacity: 1.0;
+ }
+}
+
+.highlight-button-cancel {
+ margin: 5px;
+ width: 40px;
+}
+
+.highlight-button-download {
+ margin: 5px;
+ width: auto;
+ font-size: 18px;
+}
+
+.highlight-button-download img {
+ height: 16px;
+ width: 16px;
+}
+
+.highlight-button-download:-moz-locale-dir(rtl) {
+ flex-direction: column-reverse;
+}
+
+.highlight-button-download img:-moz-locale-dir(ltr) {
+ padding-inline-end: 8px;
+}
+
+.highlight-button-download img:-moz-locale-dir(rtl) {
+ padding-inline-start: 8px;
+}
+
+.highlight-button-copy {
+ margin: 5px;
+ width: auto;
+}
+
+.highlight-button-copy img {
+ height: 16px;
+ width: 16px;
+}
+
+.highlight-button-copy:-moz-locale-dir(rtl) {
+ flex-direction: column-reverse;
+}
+
+.highlight-button-copy img:-moz-locale-dir(ltr) {
+ padding-inline-end: 8px;
+}
+
+.highlight-button-copy img:-moz-locale-dir(rtl) {
+ padding-inline-start: 8px;
+}
+
+.preview-buttons {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding-inline-end: 4px;
+ inset-inline-end: 0;
+ width: 100%;
+ height: 60px;
+ border-radius: 4px 4px 0 0;
+ background: rgba(249, 249, 250, 0.8);
+ top: 0;
+ border: 1px solid rgba(249, 249, 250, 0.2);
+ border-bottom: 0;
+ box-sizing: border-box;
+}
+
+.preview-image {
+ background-color: rgba(249, 249, 250, 0.8);
+ height: 100%;
+ width: 100%;
+ overflow: auto;
+}
+
+.preview-image-area {
+ margin: 10%;
+ margin-top: 2%;
+}
+
+.image-view {
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.7);
+ display: flex;
+ flex-direction: column;
+}
+
+.left {
+ margin-inline-start: 0;
+}
+
+.right {
+ margin-inline-start: 20px;
+}
+
+#placeholder-image {
+ width: 100%;
+ height: 100%;
+}
+
+@keyframes pulse {
+ 0% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(1.06);
+ }
+ 100% {
+ transform: scale(1);
+ }
+}
+
+@keyframes fade-in {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
diff --git a/browser/components/screenshots/content/screenshots.html b/browser/components/screenshots/content/screenshots.html
new file mode 100644
index 0000000000..e7ccc26797
--- /dev/null
+++ b/browser/components/screenshots/content/screenshots.html
@@ -0,0 +1,44 @@
+<!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 class="highlight-button-retry preview-button" data-l10n-id="screenshots-retry-button-title">
+ <img src="chrome://global/skin/icons/reload.svg"/>
+ </button>
+ <button class="highlight-button-cancel preview-button" data-l10n-id="screenshots-cancel-button-title">
+ <img src="chrome://browser/content/screenshots/cancel.svg"/>
+ </button>
+ <button class="highlight-button-copy preview-button" data-l10n-id="screenshots-copy-button-title">
+ <img src="chrome://browser/content/screenshots/copy.svg"/>
+ <span data-l10n-id="screenshots-copy-button"/>
+ </button>
+ <button class="highlight-button-download preview-button" data-l10n-id="screenshots-download-button-title">
+ <img src="chrome://browser/content/screenshots/download-white.svg"/>
+ <span data-l10n-id="screenshots-download-button"/>
+ </button>
+ </div>
+ <div class="preview-image">
+ <div id="preview-image-div" class="preview-image-area">
+ </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..1436b3803f
--- /dev/null
+++ b/browser/components/screenshots/content/screenshots.js
@@ -0,0 +1,97 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* eslint-env mozilla/browser-window */
+
+"use strict";
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs",
+});
+
+class ScreenshotsUI extends HTMLElement {
+ constructor() {
+ super();
+ }
+ async connectedCallback() {
+ this.initialize();
+ }
+
+ initialize() {
+ if (this._initialized) {
+ return;
+ }
+ this._initialized = true;
+ let template = this.ownerDocument.getElementById(
+ "screenshots-dialog-template"
+ );
+ let templateContent = template.content;
+ this.appendChild(templateContent.cloneNode(true));
+
+ this._retryButton = this.querySelector(".highlight-button-retry");
+ this._retryButton.addEventListener("click", this);
+ this._cancelButton = this.querySelector(".highlight-button-cancel");
+ this._cancelButton.addEventListener("click", this);
+ this._copyButton = this.querySelector(".highlight-button-copy");
+ this._copyButton.addEventListener("click", this);
+ this._downloadButton = this.querySelector(".highlight-button-download");
+ this._downloadButton.addEventListener("click", this);
+ }
+
+ close() {
+ URL.revokeObjectURL(document.getElementById("placeholder-image").src);
+ window.close();
+ }
+
+ async handleEvent(event) {
+ if (event.type == "click" && event.currentTarget == this._cancelButton) {
+ this.close();
+ } else if (
+ event.type == "click" &&
+ event.currentTarget == this._copyButton
+ ) {
+ this.saveToClipboard(
+ this.ownerDocument.getElementById("placeholder-image").src
+ );
+ } else if (
+ event.type == "click" &&
+ event.currentTarget == this._downloadButton
+ ) {
+ await this.saveToFile(
+ this.ownerDocument.getElementById("placeholder-image").src
+ );
+ } else if (
+ event.type == "click" &&
+ event.currentTarget == this._retryButton
+ ) {
+ Services.obs.notifyObservers(
+ window.parent.ownerGlobal,
+ "menuitem-screenshot",
+ "retry"
+ );
+ }
+ }
+
+ async saveToFile(dataUrl) {
+ await ScreenshotsUtils.downloadScreenshot(
+ null,
+ dataUrl,
+ window.parent.ownerGlobal.gBrowser.selectedBrowser
+ );
+
+ this.close();
+ }
+
+ saveToClipboard(dataUrl) {
+ ScreenshotsUtils.copyScreenshot(dataUrl);
+
+ this.close();
+ }
+}
+customElements.define("screenshots-ui", ScreenshotsUI);
diff --git a/browser/components/screenshots/fileHelpers.mjs b/browser/components/screenshots/fileHelpers.mjs
new file mode 100644
index 0000000000..172c8584f2
--- /dev/null
+++ b/browser/components/screenshots/fileHelpers.mjs
@@ -0,0 +1,278 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ Downloads: "resource://gre/modules/Downloads.jsm",
+ DownloadPaths: "resource://gre/modules/DownloadPaths.jsm",
+ DownloadLastDir: "resource://gre/modules/DownloadLastDir.jsm",
+});
+
+/**
+ * Gets the filename automatically or by a file picker depending on "browser.download.useDownloadDir"
+ * @param filenameTitle The title of the current page
+ * @param browser The current browser
+ * @returns Path of the chosen filename
+ */
+export async function getFilename(filenameTitle, browser) {
+ if (filenameTitle === null) {
+ filenameTitle = await lazy.ScreenshotsUtils.getActor(browser).sendQuery(
+ "Screenshots:getDocumentTitle"
+ );
+ }
+ const date = new Date();
+ /* eslint-disable no-control-regex */
+ filenameTitle = filenameTitle
+ .replace(/[\\/]/g, "_")
+ .replace(/[\u200e\u200f\u202a-\u202e]/g, "")
+ .replace(/[\x00-\x1f\x7f-\x9f:*?|"<>;,+=\[\]]+/g, " ")
+ .replace(/^[\s\u180e.]+|[\s\u180e.]+$/g, "");
+ /* eslint-enable no-control-regex */
+ filenameTitle = filenameTitle.replace(/\s{1,4000}/g, " ");
+ const currentDateTime = new Date(
+ date.getTime() - date.getTimezoneOffset() * 60 * 1000
+ ).toISOString();
+ const filenameDate = currentDateTime.substring(0, 10);
+ const filenameTime = currentDateTime.substring(11, 19).replace(/:/g, "-");
+ let clipFilename = `Screenshot ${filenameDate} at ${filenameTime} ${filenameTitle}`;
+
+ // Crop the filename size at less than 246 bytes, so as to leave
+ // room for the extension and an ellipsis [...]. Note that JS
+ // strings are UTF16 but the filename will be converted to UTF8
+ // when saving which could take up more space, and we want a
+ // maximum of 255 bytes (not characters). Here, we iterate
+ // and crop at shorter and shorter points until we fit into
+ // 255 bytes.
+ let suffix = "";
+ for (let cropSize = 246; cropSize >= 0; cropSize -= 32) {
+ if (new Blob([clipFilename]).size > 246) {
+ clipFilename = clipFilename.substring(0, cropSize);
+ suffix = "[...]";
+ } else {
+ break;
+ }
+ }
+
+ clipFilename += suffix;
+
+ let extension = ".png";
+ let filename = clipFilename + extension;
+
+ let useDownloadDir = Services.prefs.getBoolPref(
+ "browser.download.useDownloadDir"
+ );
+ if (useDownloadDir) {
+ const downloadsDir = await lazy.Downloads.getPreferredDownloadsDirectory();
+ const downloadsDirExists = await IOUtils.exists(downloadsDir);
+ if (downloadsDirExists) {
+ // If filename is absolute, it will override the downloads directory and
+ // still be applied as expected.
+ filename = PathUtils.join(downloadsDir, filename);
+ }
+ } else {
+ let fileInfo = new FileInfo(filename);
+ let file;
+
+ let fpParams = {
+ fpTitleKey: "SaveImageTitle",
+ fileInfo,
+ contentType: "image/png",
+ saveAsType: 0,
+ file,
+ };
+
+ let accepted = await promiseTargetFile(fpParams, browser.ownerGlobal);
+ if (!accepted) {
+ return null;
+ }
+
+ filename = fpParams.file.path;
+ }
+
+ return filename;
+}
+
+// The below functions are a modified copy from toolkit/content/contentAreaUtils.js
+/**
+ * Structure for holding info about a URL and the target filename it should be
+ * saved to.
+ * @param aFileName The target filename
+ */
+class FileInfo {
+ constructor(aFileName) {
+ this.fileName = aFileName;
+ this.fileBaseName = aFileName.replace(".png", "");
+ this.fileExt = "png";
+ }
+}
+
+const ContentAreaUtils = {
+ get stringBundle() {
+ delete this.stringBundle;
+ return (this.stringBundle = Services.strings.createBundle(
+ "chrome://global/locale/contentAreaCommands.properties"
+ ));
+ },
+};
+
+function makeFilePicker() {
+ const fpContractID = "@mozilla.org/filepicker;1";
+ const fpIID = Ci.nsIFilePicker;
+ return Cc[fpContractID].createInstance(fpIID);
+}
+
+function getMIMEService() {
+ const mimeSvcContractID = "@mozilla.org/mime;1";
+ const mimeSvcIID = Ci.nsIMIMEService;
+ const mimeSvc = Cc[mimeSvcContractID].getService(mimeSvcIID);
+ return mimeSvc;
+}
+
+function getMIMEInfoForType(aMIMEType, aExtension) {
+ if (aMIMEType || aExtension) {
+ try {
+ return getMIMEService().getFromTypeAndExtension(aMIMEType, aExtension);
+ } catch (e) {}
+ }
+ return null;
+}
+
+// This is only used after the user has entered a filename.
+function validateFileName(aFileName) {
+ let processed = lazy.DownloadPaths.sanitize(aFileName) || "_";
+ if (AppConstants.platform == "android") {
+ // If a large part of the filename has been sanitized, then we
+ // will use a default filename instead
+ if (processed.replace(/_/g, "").length <= processed.length / 2) {
+ // We purposefully do not use a localized default filename,
+ // which we could have done using
+ // ContentAreaUtils.stringBundle.GetStringFromName("DefaultSaveFileName")
+ // since it may contain invalid characters.
+ let original = processed;
+ processed = "download";
+
+ // Preserve a suffix, if there is one
+ if (original.includes(".")) {
+ let suffix = original.split(".").pop();
+ if (suffix && !suffix.includes("_")) {
+ processed += "." + suffix;
+ }
+ }
+ }
+ }
+ return processed;
+}
+
+function appendFiltersForContentType(
+ aFilePicker,
+ aContentType,
+ aFileExtension
+) {
+ let mimeInfo = getMIMEInfoForType(aContentType, aFileExtension);
+ if (mimeInfo) {
+ let extString = "";
+ for (let extension of mimeInfo.getFileExtensions()) {
+ if (extString) {
+ extString += "; ";
+ } // If adding more than one extension,
+ // separate by semi-colon
+ extString += "*." + extension;
+ }
+
+ if (extString) {
+ aFilePicker.appendFilter(mimeInfo.description, extString);
+ }
+ }
+
+ // Always append the all files (*) filter
+ aFilePicker.appendFilters(Ci.nsIFilePicker.filterAll);
+}
+
+/**
+ * Given the Filepicker Parameters (aFpP), show the file picker dialog,
+ * prompting the user to confirm (or change) the fileName.
+ * @param aFpP
+ * A structure (see definition in internalSave(...) method)
+ * containing all the data used within this method.
+ * @param window
+ * The window used for opening the file picker
+ * @return Promise
+ * @resolve a boolean. When true, it indicates that the file picker dialog
+ * is accepted.
+ */
+function promiseTargetFile(aFpP, window) {
+ return (async function() {
+ let downloadLastDir = new lazy.DownloadLastDir(window);
+
+ // Default to the user's default downloads directory configured
+ // through download prefs.
+ let dirPath = await lazy.Downloads.getPreferredDownloadsDirectory();
+ let dirExists = await IOUtils.exists(dirPath);
+ let dir = new lazy.FileUtils.File(dirPath);
+
+ // We must prompt for the file name explicitly.
+ // If we must prompt because we were asked to...
+ let file = await new Promise(resolve => {
+ downloadLastDir.getFileAsync(null, function getFileAsyncCB(aFile) {
+ resolve(aFile);
+ });
+ });
+ if (file && (await IOUtils.exists(file.path))) {
+ dir = file;
+ dirExists = true;
+ }
+
+ if (!dirExists) {
+ // Default to desktop.
+ dir = Services.dirsvc.get("Desk", Ci.nsIFile);
+ }
+
+ let fp = makeFilePicker();
+ let titleKey = aFpP.fpTitleKey;
+ fp.init(
+ window,
+ ContentAreaUtils.stringBundle.GetStringFromName(titleKey),
+ Ci.nsIFilePicker.modeSave
+ );
+
+ fp.displayDirectory = dir;
+ fp.defaultExtension = aFpP.fileInfo.fileExt;
+ fp.defaultString = aFpP.fileInfo.fileName;
+ appendFiltersForContentType(fp, aFpP.contentType, aFpP.fileInfo.fileExt);
+
+ let result = await new Promise(resolve => {
+ fp.open(function(aResult) {
+ resolve(aResult);
+ });
+ });
+ if (result == Ci.nsIFilePicker.returnCancel || !fp.file) {
+ return false;
+ }
+
+ // Do not store the last save directory as a pref inside the private browsing mode
+ downloadLastDir.setFile(null, fp.file.parent);
+
+ fp.file.leafName = validateFileName(fp.file.leafName);
+
+ aFpP.saveAsType = fp.filterIndex;
+ aFpP.file = fp.file;
+ aFpP.fileURL = fp.fileURL;
+
+ return true;
+ })();
+}
diff --git a/browser/components/screenshots/jar.mn b/browser/components/screenshots/jar.mn
new file mode 100644
index 0000000000..0e3d87ba11
--- /dev/null
+++ b/browser/components/screenshots/jar.mn
@@ -0,0 +1,23 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+browser.jar:
+ content/browser/screenshots/cancel.svg (content/cancel.svg)
+ content/browser/screenshots/copied-notification.svg (content/copied-notification.svg)
+ content/browser/screenshots/copy.svg (content/copy.svg)
+ content/browser/screenshots/download-white.svg (content/download-white.svg)
+ content/browser/screenshots/download.svg (content/download.svg)
+ content/browser/screenshots/fileHelpers.mjs
+ content/browser/screenshots/icon-welcome-face-without-eyes.svg (content/icon-welcome-face-without-eyes.svg)
+ content/browser/screenshots/menu-fullpage.svg (content/menu-fullpage.svg)
+ content/browser/screenshots/menu-visible.svg (content/menu-visible.svg)
+ content/browser/screenshots/screenshots.js (content/screenshots.js)
+ content/browser/screenshots/screenshots-buttons.js (screenshots-buttons.js)
+ content/browser/screenshots/screenshots-buttons.css (screenshots-buttons.css)
+ content/browser/screenshots/screenshots.css (content/screenshots.css)
+ content/browser/screenshots/screenshots.html (content/screenshots.html)
+ content/browser/screenshots/overlay/ (overlay/**)
+
+
+% content screenshots-overlay %overlay/
diff --git a/browser/components/screenshots/moz.build b/browser/components/screenshots/moz.build
new file mode 100644
index 0000000000..73b03cd0bd
--- /dev/null
+++ b/browser/components/screenshots/moz.build
@@ -0,0 +1,19 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES += [
+ "ScreenshotsOverlayChild.sys.mjs",
+ "ScreenshotsUtils.sys.mjs",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Screenshots")
+
+BROWSER_CHROME_MANIFESTS += [
+ "tests/browser/browser.ini",
+]
diff --git a/browser/components/screenshots/overlay/overlay.css b/browser/components/screenshots/overlay/overlay.css
new file mode 100644
index 0000000000..0c03e18ce7
--- /dev/null
+++ b/browser/components/screenshots/overlay/overlay.css
@@ -0,0 +1,435 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:-moz-native-anonymous #screenshots-component {
+ width: 100%;
+ height: 100%;
+ overflow: clip;
+ user-select: none;
+ pointer-events: auto;
+ touch-action: none;
+}
+
+/**
+ * Overlay content is position: fixed as we need to allow for the possiblily
+ * of the document scrolling or changing size while the overlay is visible
+ */
+:-moz-native-anonymous #screenshots-overlay-container {
+ /*
+ Content CSS applying to the html element can impact the overlay.
+ To avoid that, possible cases have been set to initial.
+ */
+ text-transform: initial;
+ text-indent: initial;
+ letter-spacing: initial;
+ word-spacing: initial;
+ color: initial;
+ direction: initial;
+ writing-mode: initial;
+ z-index: 1;
+ position: absolute;
+ pointer-events: auto;
+ cursor: crosshair;
+}
+
+:-moz-native-anonymous #preview-container {
+ background-color: rgba(0, 0, 0, 0.7);
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
+
+:-moz-native-anonymous #selection-container {
+ overflow: clip;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
+
+:-moz-native-anonymous #screenshots-overlay-container[hidden] {
+ display: none;
+}
+
+:-moz-native-anonymous #screenshots-overlay-container[dragging] {
+ cursor: grabbing;
+}
+
+:-moz-native-anonymous #screenshots-cancel-button {
+ background-color: transparent;
+ width: fit-content;
+ cursor: pointer;
+ outline: none;
+ border-radius: 3px;
+ border: 1px #9b9b9b solid;
+ color: #fff;
+ font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif;
+ font-size: 16px;
+ margin-top: 40px;
+ padding: 10px 25px;
+}
+
+@media (forced-colors: active), (prefers-contrast) {
+ :-moz-native-anonymous #screenshots-cancel-button {
+ border-color: ButtonBorder;
+ }
+}
+
+:-moz-native-anonymous .screenshots-button {
+ cursor: pointer;
+ appearance: none;
+ pointer-events: auto;
+ background-color: #f9f9fa;
+ color: #38383d;
+ border: 0;
+ border-radius: 3px;
+ font-size: 16px;
+ font-weight: 400;
+ height: 40px;
+ min-width: 40px;
+ outline: none;
+ padding: 0 10px;
+ position: relative;
+ text-align: center;
+ text-decoration: none;
+ transition: background 150ms cubic-bezier(0.07, 0.95, 0, 1), border 150ms cubic-bezier(0.07, 0.95, 0, 1);
+ user-select: none;
+ white-space: nowrap;
+ margin: 0 5px;
+ box-shadow: 0 0 0 1px rgba(12, 12, 13, 0.1), 0 2px 8px rgba(12, 12, 13, 0.1);
+ z-index: 6;
+}
+
+:-moz-native-anonymous .screenshots-button:hover,
+:-moz-native-anonymous #download:hover {
+ background-color: #52525e;
+ color: #fff;
+}
+
+:-moz-native-anonymous .screenshots-button:hover > img {
+ fill: #fff;
+}
+
+@media (forced-colors: active), (prefers-contrast) {
+ :-moz-native-anonymous .screenshots-button {
+ background-color: ButtonFace;
+ color: ButtonText;
+ }
+
+ :-moz-native-anonymous .screenshots-button:hover {
+ background-color: ButtonText;
+ color: ButtonFace;
+ }
+}
+
+:-moz-native-anonymous button img {
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+:-moz-native-anonymous #buttons {
+ position: absolute;
+ margin: 10px 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+:-moz-native-anonymous #cancel {
+ width: 40px;
+}
+
+:-moz-native-anonymous #cancel > img {
+ color: #38383d;
+ content: url('chrome://browser/content/screenshots/cancel.svg');
+ width: 20px;
+ height: 20px;
+ padding: 10px 0;
+}
+
+@media (forced-colors: active), (prefers-contrast) {
+ :-moz-native-anonymous #cancel > img {
+ color: ButtonText;
+ }
+
+ :-moz-native-anonymous #cancel:hover > img {
+ color: ButtonFace;
+ }
+}
+
+:-moz-native-anonymous #copy > img {
+ color: #38383d;
+ content: url('chrome://browser/content/screenshots/copy.svg');
+ width: 16px;
+ height: 16px;
+}
+
+@media (forced-colors: active), (prefers-contrast) {
+ :-moz-native-anonymous #copy > img {
+ color: ButtonText;
+ }
+
+ :-moz-native-anonymous #copy:hover > img {
+ color: ButtonFace;
+ }
+}
+
+:-moz-native-anonymous #download {
+ background-color: #0a84ff;
+ color: #fff;
+}
+
+:-moz-native-anonymous #download > img {
+ content: url('chrome://browser/content/screenshots/download.svg');
+ width: 16px;
+ height: 16px;
+}
+
+@media (forced-colors: active), (prefers-contrast) {
+ :-moz-native-anonymous #download {
+ background-color: ButtonFace;
+ color: ButtonText;
+ }
+
+ :-moz-native-anonymous #download:hover {
+ background-color: ButtonText;
+ color: ButtonFace;
+ }
+}
+
+:-moz-native-anonymous .fixed-container {
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ justify-content: center;
+ inset-inline-start: 0;
+ margin: 0;
+ padding: 0;
+ position: fixed;
+ top: 0;
+ width: 100%;
+}
+
+:-moz-native-anonymous .face-container {
+ position: relative;
+ width: 64px;
+ height: 64px;
+}
+
+:-moz-native-anonymous .face {
+ width: 62px;
+ height: 62px;
+ display: block;
+ background-image: url("chrome://browser/content/screenshots/icon-welcome-face-without-eyes.svg");
+}
+
+:-moz-native-anonymous .eye {
+ background-color: #fff;
+ width: 10px;
+ height: 14px;
+ position: absolute;
+ border-radius: 100%;
+ overflow: hidden;
+ inset-inline-start: 16px;
+ top: 19px;
+}
+
+:-moz-native-anonymous .eyeball {
+ position: absolute;
+ width: 6px;
+ height: 6px;
+ background-color: #000;
+ border-radius: 50%;
+ inset-inline-start: 2px;
+ top: 4px;
+ z-index: 10;
+}
+
+:-moz-native-anonymous .left {
+ margin-inline-start: 0;
+}
+
+:-moz-native-anonymous .right {
+ margin-inline-start: 20px;
+}
+
+:-moz-native-anonymous .preview-instructions {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ animation: pulse 125mm cubic-bezier(0.07, 0.95, 0, 1);
+ color: #fff;
+ font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif;
+ font-size: 24px;
+ line-height: 32px;
+ text-align: center;
+ padding-top: 20px;
+ width: 400px;
+}
+
+@media (forced-colors: active), (prefers-contrast) {
+ :-moz-native-anonymous .preview-instructions {
+ color: CanvasText;
+ }
+}
+
+:-moz-native-anonymous #hover-highlight {
+ animation: fade-in 125ms forwards cubic-bezier(0.07, 0.95, 0, 1);
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 1px;
+ pointer-events: none;
+ position: absolute;
+ z-index: 11;
+}
+
+:-moz-native-anonymous #hover-highlight::before {
+ border: 2px dashed rgba(255, 255, 255, 0.4);
+ bottom: 0;
+ content: "";
+ inset-inline-start: 0;
+ position: absolute;
+ inset-inline-end: 0;
+ top: 0;
+}
+
+:-moz-native-anonymous .bghighlight {
+ background-color: rgba(0, 0, 0, 0.7);
+ position: absolute;
+ overflow: clip;
+}
+
+:-moz-native-anonymous .highlight {
+ border-radius: 1px;
+ border: 2px dashed rgba(255, 255, 255, 0.8);
+ box-sizing: border-box;
+ cursor: move;
+ position: absolute;
+ pointer-events: auto;
+ z-index: 2;
+}
+
+:-moz-native-anonymous .mover-target {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: absolute;
+ z-index: 5;
+ pointer-events: auto;
+}
+
+:-moz-native-anonymous .mover-target.direction-topLeft {
+ cursor: nwse-resize;
+ height: 60px;
+ left: -30px;
+ top: -30px;
+ width: 60px;
+}
+
+:-moz-native-anonymous .mover-target.direction-top {
+ cursor: ns-resize;
+ height: 60px;
+ inset-inline-start: 0;
+ top: -30px;
+ width: 100%;
+ z-index: 4;
+}
+
+:-moz-native-anonymous .mover-target.direction-topRight {
+ cursor: nesw-resize;
+ height: 60px;
+ right: -30px;
+ top: -30px;
+ width: 60px;
+}
+
+:-moz-native-anonymous .mover-target.direction-left {
+ cursor: ew-resize;
+ height: 100%;
+ left: -30px;
+ top: 0;
+ width: 60px;
+ z-index: 4;
+}
+
+:-moz-native-anonymous .mover-target.direction-right {
+ cursor: ew-resize;
+ height: 100%;
+ right: -30px;
+ top: 0;
+ width: 60px;
+ z-index: 4;
+}
+
+:-moz-native-anonymous .mover-target.direction-bottomLeft {
+ bottom: -30px;
+ cursor: nesw-resize;
+ height: 60px;
+ left: -30px;
+ width: 60px;
+}
+
+:-moz-native-anonymous .mover-target.direction-bottom {
+ bottom: -30px;
+ cursor: ns-resize;
+ height: 60px;
+ inset-inline-start: 0;
+ width: 100%;
+ z-index: 4;
+}
+
+:-moz-native-anonymous .mover-target.direction-bottomRight {
+ bottom: -30px;
+ cursor: nwse-resize;
+ height: 60px;
+ right: -30px;
+ width: 60px;
+}
+
+:-moz-native-anonymous .mover-target:hover .mover {
+ transform: scale(1.05);
+}
+
+:-moz-native-anonymous .mover {
+ background-color: #fff;
+ border-radius: 50%;
+ box-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
+ height: 16px;
+ opacity: 1;
+ position: relative;
+ transition: transform 125ms cubic-bezier(0.07, 0.95, 0, 1);
+ width: 16px;
+}
+
+:-moz-native-anonymous .small-selection .mover {
+ height: 10px;
+ width: 10px;
+}
+
+:-moz-native-anonymous .direction-topLeft .mover,
+:-moz-native-anonymous .direction-left .mover,
+:-moz-native-anonymous .direction-bottomLeft .mover {
+ left: -1px;
+}
+
+:-moz-native-anonymous .direction-topLeft .mover,
+:-moz-native-anonymous .direction-top .mover,
+:-moz-native-anonymous .direction-topRight .mover {
+ top: -1px;
+}
+
+:-moz-native-anonymous .direction-topRight .mover,
+:-moz-native-anonymous .direction-right .mover,
+:-moz-native-anonymous .direction-bottomRight .mover {
+ right: -1px;
+}
+
+:-moz-native-anonymous .direction-bottomRight .mover,
+:-moz-native-anonymous .direction-bottom .mover,
+:-moz-native-anonymous .direction-bottomLeft .mover {
+ bottom: -1px;
+}
diff --git a/browser/components/screenshots/screenshots-buttons.css b/browser/components/screenshots/screenshots-buttons.css
new file mode 100644
index 0000000000..fcf6519e35
--- /dev/null
+++ b/browser/components/screenshots/screenshots-buttons.css
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.all-buttons-container button {
+ background-position: center top;
+ background-repeat: no-repeat;
+ background-size: 46px 46px;
+ border: 1px solid transparent;
+ height: 100%;
+ min-width: 90px;
+ padding: 46px 5px 5px;
+}
+
+.all-buttons-container button:hover {
+ background-color: var(--button-hover-bgcolor, color-mix(in srgb, currentColor 17%, transparent));
+}
+
+.all-buttons-container .full-page {
+ background-image: url("chrome://browser/content/screenshots/menu-fullpage.svg");
+}
+
+.all-buttons-container .visible-page {
+ background-image: url("chrome://browser/content/screenshots/menu-visible.svg");
+}
+
+.full-page, .visible-page {
+ -moz-context-properties: fill, stroke;
+ fill: buttonText;
+ stroke: #00fdff;
+}
diff --git a/browser/components/screenshots/screenshots-buttons.js b/browser/components/screenshots/screenshots-buttons.js
new file mode 100644
index 0000000000..6ea9c2eee3
--- /dev/null
+++ b/browser/components/screenshots/screenshots-buttons.js
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* eslint-env mozilla/browser-window */
+
+"use strict";
+
+// This is loaded into chrome windows with the subscript loader. Wrap in
+// a block to prevent accidentally leaking globals onto `window`.
+{
+ class ScreenshotsButtons extends MozXULElement {
+ static get markup() {
+ return `
+ <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);
+ }
+ }
+ customElements.define("screenshots-buttons", ScreenshotsButtons, {
+ extends: "toolbar",
+ });
+}
diff --git a/browser/components/screenshots/tests/browser/browser.ini b/browser/components/screenshots/tests/browser/browser.ini
new file mode 100644
index 0000000000..47cafe5bb2
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser.ini
@@ -0,0 +1,25 @@
+[DEFAULT]
+support-files =
+ head.js
+ test-page.html
+ short-test-page.html
+
+prefs =
+ extensions.screenshots.disabled=false
+ screenshots.browser.component.enabled=true
+
+[browser_screenshots_drag_scroll_test.js]
+[browser_screenshots_drag_test.js]
+[browser_screenshots_focus_test.js]
+[browser_screenshots_overlay_panel_sync.js]
+[browser_screenshots_page_unload.js]
+[browser_screenshots_short_page_test.js]
+[browser_screenshots_test_downloads.js]
+[browser_screenshots_test_escape.js]
+[browser_screenshots_test_full_page.js]
+skip-if = (!debug && os == 'win' && os_version == '6.1') # Bug 1746281
+[browser_screenshots_test_page_crash.js]
+skip-if = !crashreporter
+[browser_screenshots_test_toggle_pref.js]
+[browser_screenshots_test_toolbar_button.js]
+[browser_screenshots_test_visible.js]
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js b/browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js
new file mode 100644
index 0000000000..77d361b26b
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_drag_scroll_test.js
@@ -0,0 +1,317 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that screenshots overlay covers the entire page
+ */
+add_task(async function test_overlayCoversEntirePage() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ let dimensions = await helper.getSelectionLayerDimensions();
+ info(JSON.stringify(dimensions));
+ is(
+ dimensions.scrollWidth,
+ 4000,
+ "The overlay spans the entire width of the page"
+ );
+
+ is(
+ dimensions.scrollHeight,
+ 4000,
+ "The overlay spans the entire height of the page"
+ );
+ }
+ );
+});
+
+/**
+ * Test dragging screenshots box off top left of screen
+ */
+add_task(async function test_draggingBoxOffTopLeft() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ let startX = 10;
+ let startY = 10;
+ let endX = 500;
+ let endY = 500;
+ await helper.dragOverlay(startX, startY, endX, endY);
+
+ mouse.down(
+ startX + Math.floor((endX - startX) / 2),
+ startY + Math.floor((endY - startY) / 2)
+ );
+
+ await helper.waitForStateChange("resizing");
+ let state = await helper.getOverlayState();
+ is(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(10, 10);
+
+ // We moved the box to the edge of the screen so we need to wait until the box size is updated
+ await helper.waitForSelectionBoxSizeChange(490);
+
+ let dimensions = await helper.getSelectionBoxDimensions();
+
+ is(dimensions.x1, 0, "The box x1 position is now 0");
+ is(dimensions.y1, 0, "The box y1 position is now 0");
+ is(dimensions.width, 255, "The box width is now 255");
+ is(dimensions.height, 255, "The box height is now 255");
+
+ mouse.move(
+ startX + Math.floor((endX - startX) / 2),
+ startY + Math.floor((endY - startY) / 2)
+ );
+
+ mouse.up(
+ startX + Math.floor((endX - startX) / 2),
+ startY + Math.floor((endY - startY) / 2)
+ );
+
+ // We moved the box off the edge of the screen so we need to wait until the box size is updated
+ await helper.waitForSelectionBoxSizeChange(255);
+
+ dimensions = await helper.getSelectionBoxDimensions();
+
+ is(dimensions.x1, 10, "The box x1 position is now 10 again");
+ is(dimensions.y1, 10, "The box y1 position is now 10 again");
+ is(dimensions.width, 490, "The box width is now 490 again");
+ is(dimensions.height, 490, "The box height is now 490 again");
+ }
+ );
+});
+
+/**
+ * Test dragging screenshots box off bottom right of screen
+ */
+add_task(async function test_draggingBoxOffBottomRight() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ let contentInfo = await helper.getContentDimensions();
+ info(JSON.stringify(contentInfo));
+ ok(contentInfo, "Got dimensions back from the content");
+
+ await helper.scrollContentWindow(4000, 4000);
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ let startX = contentInfo.scrollWidth - 500;
+ let startY = contentInfo.scrollHeight - 500;
+ let endX = contentInfo.scrollWidth - 20;
+ let endY = contentInfo.scrollHeight - 20;
+
+ await helper.dragOverlay(startX, startY, endX, endY);
+
+ // move box off the bottom right of the screen
+ mouse.down(
+ startX + Math.floor((endX - startX) / 2),
+ startY + Math.floor((endY - startY) / 2)
+ );
+ mouse.move(
+ startX + 50 + Math.floor((endX - startX) / 2),
+ startY + 50 + Math.floor((endY - startY) / 2)
+ );
+
+ await helper.waitForStateChange("resizing");
+ let state = await helper.getOverlayState();
+ is(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(endX, endY);
+
+ // We moved the box to the edge of the screen so we need to wait until the box size is updated
+ await helper.waitForSelectionBoxSizeChange(480);
+
+ let dimensions = await helper.getSelectionBoxDimensions();
+
+ is(dimensions.x1, startX + 240, "The box x1 position is now 3748");
+ is(dimensions.y1, startY + 240, "The box y1 position is now 3756");
+ is(dimensions.width, 252, "The box width is now 252");
+ is(dimensions.height, 244, "The box height is now 244");
+
+ mouse.move(
+ startX + Math.floor((endX - startX) / 2),
+ startY + Math.floor((endY - startY) / 2)
+ );
+
+ mouse.up(
+ startX + Math.floor((endX - startX) / 2),
+ startY + Math.floor((endY - startY) / 2)
+ );
+
+ // We moved the box off the edge of the screen so we need to wait until the box size is updated
+ await helper.waitForSelectionBoxSizeChange(252);
+
+ dimensions = await helper.getSelectionBoxDimensions();
+
+ is(dimensions.x1, startX, "The box x1 position is now 3508 again");
+ is(dimensions.y1, startY, "The box y1 position is now 3516 again");
+ is(dimensions.width, 480, "The box width is now 480 again");
+ is(dimensions.height, 480, "The box height is now 480 again");
+ }
+ );
+});
+
+/**
+ * test scrolling while screenshots is open
+ */
+add_task(async function test_scrollingScreenshotsOpen() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ let contentInfo = await helper.getContentDimensions();
+ info(JSON.stringify(contentInfo));
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ let startX = 10;
+ let startY = 10;
+ let endX = 100;
+ let endY = 100;
+
+ await helper.dragOverlay(startX, startY, endX, endY);
+
+ let scrollX = 1000;
+ let scrollY = 1000;
+
+ await helper.scrollContentWindow(scrollX, scrollY);
+
+ let dimensions = await helper.getSelectionBoxDimensions();
+
+ is(dimensions.x1, startX, "The box x1 position is 10");
+ is(dimensions.y1, startY, "The box y1 position is 10");
+ is(dimensions.width, endX - startX, "The box width is now 90");
+ is(dimensions.height, endY - startY, "The box height is now 90");
+
+ // reset screenshots box
+ mouse.click(scrollX + startX, scrollY + endY);
+ await helper.waitForStateChange("crosshairs");
+
+ await helper.dragOverlay(
+ scrollX + startX,
+ scrollY + startY,
+ scrollX + endX,
+ scrollY + endY
+ );
+
+ await helper.scrollContentWindow(0, 0);
+
+ dimensions = await helper.getSelectionBoxDimensions();
+
+ is(dimensions.x1, scrollX + startX, "The box x1 position is 1010");
+ is(dimensions.y1, scrollY + startY, "The box y1 position is 1010");
+ is(dimensions.width, endX - startX, "The box width is now 90");
+ is(dimensions.height, endY - startY, "The box height is now 90");
+
+ // reset screenshots box
+ mouse.click(10, 10);
+ await helper.waitForStateChange("crosshairs");
+
+ await helper.dragOverlay(
+ startX,
+ startY,
+ contentInfo.clientWidth - 10,
+ contentInfo.clientHeight - 10
+ );
+
+ await helper.scrollContentWindow(
+ contentInfo.clientWidth - 20,
+ contentInfo.clientHeight - 20
+ );
+
+ mouse.down(contentInfo.clientWidth - 10, contentInfo.clientHeight - 10);
+
+ await helper.waitForStateChange("resizing");
+
+ mouse.move(
+ contentInfo.clientWidth * 2 - 30,
+ contentInfo.clientHeight * 2 - 30
+ );
+
+ mouse.up(
+ contentInfo.clientWidth * 2 - 30,
+ contentInfo.clientHeight * 2 - 30
+ );
+
+ await helper.waitForStateChange("selected");
+
+ dimensions = await helper.getSelectionLayerDimensions();
+ info(JSON.stringify(dimensions));
+ is(dimensions.boxLeft, startX, "The box left is 10");
+ is(dimensions.boxTop, startY, "The box top is 10");
+ is(
+ dimensions.boxRight,
+ contentInfo.clientWidth * 2 - 30,
+ "The box right is 2 x clientWidth - 30"
+ );
+ is(
+ dimensions.boxBottom,
+ contentInfo.clientHeight * 2 - 30,
+ "The box right is 2 x clientHeight - 30"
+ );
+ is(
+ dimensions.boxWidth,
+ contentInfo.clientWidth * 2 - 40,
+ "The box right is 2 x clientWidth - 40"
+ );
+ is(
+ dimensions.boxHeight,
+ contentInfo.clientHeight * 2 - 40,
+ "The box right is 2 x clientHeight - 40"
+ );
+ is(
+ dimensions.scrollWidth,
+ 4000,
+ "The overlay spans the entire width of the page"
+ );
+ is(
+ dimensions.scrollHeight,
+ 4000,
+ "The overlay spans the entire height of the page"
+ );
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_drag_test.js b/browser/components/screenshots/tests/browser/browser_screenshots_drag_test.js
new file mode 100644
index 0000000000..aba38dccc0
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_drag_test.js
@@ -0,0 +1,475 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This function drags to a 490x490 area and copies to the clipboard
+ */
+add_task(async function() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ helper.clickCopyButton();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+
+ info("result: " + JSON.stringify(result, null, 2));
+
+ let expected = Math.floor(
+ 490 * (await getContentDevicePixelRatio(browser))
+ );
+
+ Assert.equal(
+ result.width,
+ expected,
+ `The copied image from the overlay is ${expected}px in width`
+ );
+ Assert.equal(
+ result.height,
+ expected,
+ `The copied image from the overlay is ${expected}px in height`
+ );
+ }
+ );
+});
+
+/**
+ * This function drags a 1.5 zoomed browser to a 490x490 area and copies to the clipboard
+ */
+add_task(async function() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ const zoom = 1.5;
+ let helper = new ScreenshotsHelper(browser);
+ helper.zoomBrowser(zoom);
+
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ helper.clickCopyButton();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+ result.zoom = zoom;
+ result.devicePixelRatio = window.devicePixelRatio;
+ result.contentDevicePixelRatio = await getContentDevicePixelRatio(
+ browser
+ );
+
+ info("result: " + JSON.stringify(result, null, 2));
+
+ let expected = Math.floor(
+ 490 * (await getContentDevicePixelRatio(browser))
+ );
+
+ Assert.equal(
+ result.width,
+ expected,
+ `The copied image from the overlay is ${expected}px in width`
+ );
+ Assert.equal(
+ result.height,
+ expected,
+ `The copied image from the overlay is ${expected}px in height`
+ );
+ }
+ );
+});
+
+/**
+ * This function drags an area and clicks elsewhere
+ * on the overlay to go back to the crosshairs state
+ */
+add_task(async function() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(10, 10, 100, 100);
+
+ // click outside overlay
+ mouse.click(200, 200);
+
+ await helper.waitForStateChange("crosshairs");
+ let state = await helper.getOverlayState();
+ Assert.equal(state, "crosshairs", "The state is back to crosshairs");
+ }
+ );
+});
+
+/**
+ * This function drags an area and clicks the
+ * cancel button to cancel the overlay
+ */
+add_task(async function() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(10, 10, 300, 300);
+
+ helper.clickCancelButton();
+
+ await helper.waitForOverlayClosed();
+
+ ok(!(await helper.isOverlayInitialized()), "Overlay is not initialized");
+ }
+ );
+});
+
+/**
+ * This function drags a 490x490 area and moves it along the edges
+ * and back to the center to confirm that the original size is preserved
+ */
+add_task(async function() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ let startX = 10;
+ let startY = 10;
+ let endX = 500;
+ let endY = 500;
+
+ mouse.down(
+ Math.floor((endX - startX) / 2),
+ Math.floor((endY - startY) / 2)
+ );
+
+ await helper.waitForStateChange("resizing");
+ let state = await helper.getOverlayState();
+ Assert.equal(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(10, 10);
+
+ mouse.move(contentInfo.clientWidth - 10, contentInfo.clientHeight - 10);
+
+ mouse.up(
+ Math.floor((endX - startX) / 2),
+ Math.floor((endY - startY) / 2)
+ );
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ helper.clickCopyButton();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+
+ info("result: " + JSON.stringify(result, null, 2));
+
+ let expected = Math.floor(
+ 490 * (await getContentDevicePixelRatio(browser))
+ );
+
+ Assert.equal(
+ result.width,
+ expected,
+ `The copied image from the overlay is ${expected}px in width`
+ );
+ Assert.equal(
+ result.height,
+ expected,
+ `The copied image from the overlay is ${expected}px in height`
+ );
+ }
+ );
+});
+
+/**
+ * This function drags a 490x490 area and resizes it to a 300x300 area
+ * with the 4 sides of the box
+ */
+add_task(async function() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ let startX = 10;
+ let startY = 10;
+ let endX = 500;
+ let endY = 500;
+
+ let x = Math.floor((endX - startX) / 2);
+
+ // drag top
+ mouse.down(x, 10);
+
+ await helper.waitForStateChange("resizing");
+ let state = await helper.getOverlayState();
+ Assert.equal(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(x, 100);
+ mouse.up(x, 100);
+
+ await helper.waitForStateChange("selected");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "selected", "The overlay is in the selected state");
+
+ // drag bottom
+ mouse.down(x, 500);
+
+ await helper.waitForStateChange("resizing");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(x, 400);
+ mouse.up(x, 400);
+
+ await helper.waitForStateChange("selected");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "selected", "The overlay is in the selected state");
+
+ // drag right
+ let y = Math.floor((endY - startY) / 2);
+ mouse.down(500, y);
+
+ await helper.waitForStateChange("resizing");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(400, y);
+ mouse.up(400, y);
+
+ await helper.waitForStateChange("selected");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "selected", "The overlay is in the selected state");
+
+ // drag left
+ mouse.down(10, y);
+
+ await helper.waitForStateChange("resizing");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(100, y);
+ mouse.up(100, y);
+
+ await helper.waitForStateChange("selected");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "selected", "The overlay is in the selected state");
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ helper.endX = 400;
+ helper.endY = 400;
+
+ helper.clickCopyButton();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+
+ info("result: " + JSON.stringify(result, null, 2));
+
+ let expected = Math.floor(
+ 300 * (await getContentDevicePixelRatio(browser))
+ );
+
+ Assert.equal(
+ result.width,
+ expected,
+ `The copied image from the overlay is ${expected}px in width`
+ );
+ Assert.equal(
+ result.height,
+ expected,
+ `The copied image from the overlay is ${expected}px in height`
+ );
+ }
+ );
+});
+
+/**
+ * This function drags a 490x490 area and resizes it to a 300x300 area
+ * with the 4 corners of the box
+ */
+add_task(async function() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ // drag topright
+ mouse.down(500, 10);
+
+ await helper.waitForStateChange("resizing");
+ let state = await helper.getOverlayState();
+ Assert.equal(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(450, 50);
+ mouse.up(450, 50);
+
+ await helper.waitForStateChange("selected");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "selected", "The overlay is in the selected state");
+
+ // drag bottomright
+ mouse.down(450, 500);
+
+ await helper.waitForStateChange("resizing");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(400, 450);
+ mouse.up(400, 450);
+
+ await helper.waitForStateChange("selected");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "selected", "The overlay is in the selected state");
+
+ // drag bottomleft
+ mouse.down(10, 450);
+
+ await helper.waitForStateChange("resizing");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(50, 400);
+ mouse.up(50, 400);
+
+ await helper.waitForStateChange("selected");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "selected", "The overlay is in the selected state");
+
+ // drag topleft
+ mouse.down(50, 50);
+
+ await helper.waitForStateChange("resizing");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "resizing", "The overlay is in the resizing state");
+
+ mouse.move(100, 100);
+ mouse.up(100, 100);
+
+ await helper.waitForStateChange("selected");
+ state = await helper.getOverlayState();
+ Assert.equal(state, "selected", "The overlay is in the selected state");
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ helper.endX = 400;
+ helper.endY = 400;
+
+ helper.clickCopyButton();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+
+ info("result: " + JSON.stringify(result, null, 2));
+
+ let expected = Math.floor(
+ 300 * (await getContentDevicePixelRatio(browser))
+ );
+
+ Assert.equal(
+ result.width,
+ expected,
+ `The copied image from the overlay is ${expected}px in width`
+ );
+ Assert.equal(
+ result.height,
+ expected,
+ `The copied image from the overlay is ${expected}px in height`
+ );
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_focus_test.js b/browser/components/screenshots/tests/browser/browser_screenshots_focus_test.js
new file mode 100644
index 0000000000..d992327128
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_focus_test.js
@@ -0,0 +1,34 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function testPanelFocused() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ EventUtils.synthesizeKey("KEY_Tab");
+
+ let screenshotsButtons = gBrowser.selectedBrowser.ownerDocument
+ .querySelector("#screenshotsPagePanel")
+ .querySelector("screenshots-buttons").shadowRoot;
+
+ let focusedElement = screenshotsButtons.querySelector(".visible-page");
+
+ is(
+ focusedElement,
+ screenshotsButtons.activeElement,
+ "Visible button is focused"
+ );
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_overlay_panel_sync.js b/browser/components/screenshots/tests/browser/browser_screenshots_overlay_panel_sync.js
new file mode 100644
index 0000000000..b66a4d2b57
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_overlay_panel_sync.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function waitOnTabSwitch() {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 300));
+}
+
+add_task(async function test_overlay_and_panel_state() {
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+
+ let screenshotsTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_PAGE
+ );
+ let browser = screenshotsTab.linkedBrowser;
+
+ let helper = new ScreenshotsHelper(browser);
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ helper.assertPanelVisible();
+
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ await helper.waitForStateChange("selected");
+ let state = await helper.getOverlayState();
+ is(state, "selected", "The overlay is in the selected state");
+
+ helper.assertPanelNotVisible();
+
+ mouse.click(600, 600);
+
+ await helper.waitForStateChange("crosshairs");
+ state = await helper.getOverlayState();
+ is(state, "crosshairs", "The overlay is in the crosshairs state");
+
+ await helper.waitForOverlay();
+
+ helper.assertPanelVisible();
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await BrowserTestUtils.switchTab(gBrowser, screenshotsTab);
+
+ await helper.waitForOverlayClosed();
+
+ Assert.ok(!(await helper.isOverlayInitialized()), "Overlay is closed");
+ helper.assertPanelNotVisible();
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ helper.assertPanelVisible();
+
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ await helper.waitForStateChange("selected");
+ state = await helper.getOverlayState();
+ is(state, "selected", "The overlay is in the selected state");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await BrowserTestUtils.switchTab(gBrowser, screenshotsTab);
+
+ Assert.ok(await helper.isOverlayInitialized(), "Overlay is open");
+ helper.assertPanelNotVisible();
+ state = await helper.getOverlayState();
+ is(state, "selected", "The overlay is in the selected state");
+
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlayClosed();
+
+ Assert.ok(!(await helper.isOverlayInitialized()), "Overlay is closed");
+ helper.assertPanelNotVisible();
+
+ gBrowser.removeTab(tab1);
+ gBrowser.removeTab(screenshotsTab);
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_page_unload.js b/browser/components/screenshots/tests/browser/browser_screenshots_page_unload.js
new file mode 100644
index 0000000000..dea97978e4
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_page_unload.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [TEST_PAGE], url => {
+ let a = content.document.createElement("a");
+ a.id = "clickMe";
+ a.href = url;
+ a.textContent = "Click me to unload page";
+ content.document.querySelector("body").appendChild(a);
+ });
+
+ let helper = new ScreenshotsHelper(browser);
+
+ // click toolbar button so panel shows
+ helper.triggerUIFromToolbar();
+
+ let panel = gBrowser.selectedBrowser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ await BrowserTestUtils.waitForMutationCondition(
+ panel,
+ { attributes: true },
+ () => {
+ return BrowserTestUtils.is_visible(panel);
+ }
+ );
+ ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible");
+
+ await ContentTask.spawn(browser, null, async () => {
+ let screenshotsChild = content.windowGlobalChild.getActor(
+ "ScreenshotsComponent"
+ );
+ Assert.ok(screenshotsChild._overlay._initialized, "The overlay exists");
+ });
+
+ await SpecialPowers.spawn(browser, [], () => {
+ content.document.querySelector("#clickMe").click();
+ });
+
+ await BrowserTestUtils.waitForMutationCondition(
+ panel,
+ { attributes: true },
+ () => {
+ return BrowserTestUtils.is_hidden(panel);
+ }
+ );
+ ok(
+ BrowserTestUtils.is_hidden(panel),
+ "Panel buttons are hidden after page unload"
+ );
+
+ await ContentTask.spawn(browser, null, async () => {
+ let screenshotsChild = content.windowGlobalChild.getActor(
+ "ScreenshotsComponent"
+ );
+ Assert.ok(
+ !screenshotsChild._overlay._initialized,
+ "The overlay doesn't exist"
+ );
+ });
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_short_page_test.js b/browser/components/screenshots/tests/browser/browser_screenshots_short_page_test.js
new file mode 100644
index 0000000000..5772eeb7c3
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_short_page_test.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test ensures the overlay is covering the entire window event thought the body is only 100px by 100px
+ */
+add_task(async function() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: SHORT_TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ let dimensions = await helper.getSelectionLayerDimensions();
+ Assert.equal(
+ dimensions.scrollWidth,
+ contentInfo.clientWidth,
+ "The overlay spans the width of the window"
+ );
+
+ Assert.equal(
+ dimensions.scrollHeight,
+ contentInfo.clientHeight,
+ "The overlay spans the height of the window"
+ );
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js
new file mode 100644
index 0000000000..45dc960fc6
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_downloads.js
@@ -0,0 +1,137 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const MockFilePicker = SpecialPowers.MockFilePicker;
+
+add_setup(async function() {
+ let tmpDir = PathUtils.join(
+ PathUtils.tempDir,
+ "testsavedir" + Math.floor(Math.random() * 2 ** 32)
+ );
+ // Create this dir if it doesn't exist (ignores existing dirs)
+ await IOUtils.makeDirectory(tmpDir);
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.start_downloads_in_tmp_dir", true],
+ ["browser.helperApps.deleteTempFileOnExit", true],
+ ["browser.download.folderList", 2],
+ ["browser.download.dir", tmpDir],
+ ],
+ });
+
+ MockFilePicker.init(window);
+ MockFilePicker.useAnyFile();
+ MockFilePicker.returnValue = MockFilePicker.returnOK;
+
+ registerCleanupFunction(async () => {
+ Services.prefs.clearUserPref("browser.download.dir");
+ Services.prefs.clearUserPref("browser.download.folderList");
+
+ MockFilePicker.cleanup();
+ });
+});
+
+function waitForFilePicker() {
+ return new Promise(resolve => {
+ MockFilePicker.showCallback = () => {
+ MockFilePicker.showCallback = null;
+ ok(true, "Saw the file picker");
+ resolve();
+ };
+ });
+}
+
+add_task(async function test_download_without_filepicker() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.useDownloadDir", true]],
+ });
+
+ let publicDownloads = await Downloads.getList(Downloads.PUBLIC);
+ // First ensure we catch the download finishing.
+ let downloadFinishedPromise = new Promise(resolve => {
+ publicDownloads.addView({
+ onDownloadChanged(download) {
+ info("Download changed!");
+ if (download.succeeded || download.error) {
+ info("Download succeeded or errored");
+ publicDownloads.removeView(this);
+ resolve(download);
+ }
+ },
+ });
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ helper.clickDownloadButton();
+
+ info("wait for download to finish");
+ let download = await downloadFinishedPromise;
+
+ ok(download.succeeded, "Download should succeed");
+
+ await publicDownloads.removeFinished();
+ }
+ );
+});
+
+add_task(async function test_download_with_filepicker() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.useDownloadDir", false]],
+ });
+
+ let publicDownloads = await Downloads.getList(Downloads.PUBLIC);
+ // First ensure we catch the download finishing.
+ let downloadFinishedPromise = new Promise(resolve => {
+ publicDownloads.addView({
+ onDownloadChanged(download) {
+ info("Download changed!");
+ if (download.succeeded || download.error) {
+ info("Download succeeded or errored");
+ publicDownloads.removeView(this);
+ resolve(download);
+ }
+ },
+ });
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ helper.triggerUIFromToolbar();
+ await helper.waitForOverlay();
+ await helper.dragOverlay(10, 10, 500, 500);
+
+ let filePicker = waitForFilePicker();
+
+ helper.clickDownloadButton();
+
+ await filePicker;
+ ok(true, "Export file picker opened");
+
+ info("wait for download to finish");
+ let download = await downloadFinishedPromise;
+
+ ok(download.succeeded, "Download should succeed");
+
+ await publicDownloads.removeFinished();
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_escape.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_escape.js
new file mode 100644
index 0000000000..09605fd316
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_escape.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_fullpageScreenshot() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ // click toolbar button so panel shows
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ EventUtils.synthesizeKey("KEY_Escape");
+
+ await helper.waitForOverlayClosed();
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js
new file mode 100644
index 0000000000..cf83537d87
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_full_page.js
@@ -0,0 +1,192 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_fullpageScreenshot() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ // click toolbar button so panel shows
+ helper.triggerUIFromToolbar();
+
+ let panel = gBrowser.selectedBrowser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ await BrowserTestUtils.waitForMutationCondition(
+ panel,
+ { attributes: true },
+ () => {
+ return BrowserTestUtils.is_visible(panel);
+ }
+ );
+ ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible");
+
+ let screenshotReady = TestUtils.topicObserved(
+ "screenshots-preview-ready"
+ );
+
+ // click the full page button in panel
+ let visiblePage = panel
+ .querySelector("screenshots-buttons")
+ .shadowRoot.querySelector(".full-page");
+ visiblePage.click();
+
+ let dialog = helper.getDialog();
+
+ await screenshotReady;
+
+ let copyButton = dialog._frame.contentDocument.querySelector(
+ ".highlight-button-copy"
+ );
+ ok(copyButton, "Got the copy button");
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ // click copy button on dialog box
+ copyButton.click();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+ info("result: " + JSON.stringify(result, null, 2));
+ info("contentInfo: " + JSON.stringify(contentInfo, null, 2));
+
+ Assert.equal(
+ contentInfo.scrollWidth,
+ result.width,
+ "Widths should be equal"
+ );
+
+ Assert.equal(
+ contentInfo.scrollHeight,
+ result.height,
+ "Heights should be equal"
+ );
+
+ // top left
+ Assert.equal(111, result.color.topLeft[0], "R color value");
+ Assert.equal(111, result.color.topLeft[1], "G color value");
+ Assert.equal(111, result.color.topLeft[2], "B color value");
+
+ // top right
+ Assert.equal(55, result.color.topRight[0], "R color value");
+ Assert.equal(155, result.color.topRight[1], "G color value");
+ Assert.equal(155, result.color.topRight[2], "B color value");
+
+ // bottom left
+ Assert.equal(105, result.color.bottomLeft[0], "R color value");
+ Assert.equal(55, result.color.bottomLeft[1], "G color value");
+ Assert.equal(105, result.color.bottomLeft[2], "B color value");
+
+ // bottom right
+ Assert.equal(52, result.color.bottomRight[0], "R color value");
+ Assert.equal(127, result.color.bottomRight[1], "G color value");
+ Assert.equal(152, result.color.bottomRight[2], "B color value");
+ }
+ );
+});
+
+add_task(async function test_fullpageScreenshotScrolled() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], () => {
+ content.scrollTo(0, 2008);
+ });
+
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ // click toolbar button so panel shows
+ helper.triggerUIFromToolbar();
+
+ let panel = gBrowser.selectedBrowser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ await BrowserTestUtils.waitForMutationCondition(
+ panel,
+ { attributes: true },
+ () => {
+ return BrowserTestUtils.is_visible(panel);
+ }
+ );
+ ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible");
+
+ let screenshotReady = TestUtils.topicObserved(
+ "screenshots-preview-ready"
+ );
+
+ // click the full page button in panel
+ let visiblePage = panel
+ .querySelector("screenshots-buttons")
+ .shadowRoot.querySelector(".full-page");
+ visiblePage.click();
+
+ let dialog = helper.getDialog();
+
+ await screenshotReady;
+
+ let copyButton = dialog._frame.contentDocument.querySelector(
+ ".highlight-button-copy"
+ );
+ ok(copyButton, "Got the copy button");
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ // click copy button on dialog box
+ copyButton.click();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+ info("result: " + JSON.stringify(result, null, 2));
+ info("contentInfo: " + JSON.stringify(contentInfo, null, 2));
+
+ Assert.equal(
+ contentInfo.scrollWidth,
+ result.width,
+ "Widths should be equal"
+ );
+
+ Assert.equal(
+ contentInfo.scrollHeight,
+ result.height,
+ "Heights should be equal"
+ );
+
+ // top left
+ Assert.equal(111, result.color.topLeft[0], "R color value");
+ Assert.equal(111, result.color.topLeft[1], "G color value");
+ Assert.equal(111, result.color.topLeft[2], "B color value");
+
+ // top right
+ Assert.equal(55, result.color.topRight[0], "R color value");
+ Assert.equal(155, result.color.topRight[1], "G color value");
+ Assert.equal(155, result.color.topRight[2], "B color value");
+
+ // bottom left
+ Assert.equal(105, result.color.bottomLeft[0], "R color value");
+ Assert.equal(55, result.color.bottomLeft[1], "G color value");
+ Assert.equal(105, result.color.bottomLeft[2], "B color value");
+
+ // bottom right
+ Assert.equal(52, result.color.bottomRight[0], "R color value");
+ Assert.equal(127, result.color.bottomRight[1], "G color value");
+ Assert.equal(152, result.color.bottomRight[2], "B color value");
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_page_crash.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_page_crash.js
new file mode 100644
index 0000000000..ab5afe49a1
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_page_crash.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_fullpageScreenshot() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+
+ // click toolbar button so UI shows
+ helper.triggerUIFromToolbar();
+
+ let panel = gBrowser.selectedBrowser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ await BrowserTestUtils.waitForMutationCondition(
+ panel,
+ { attributes: true },
+ () => {
+ return BrowserTestUtils.is_visible(panel);
+ }
+ );
+ ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible");
+
+ await ContentTask.spawn(browser, null, async () => {
+ let screenshotsChild = content.windowGlobalChild.getActor(
+ "ScreenshotsComponent"
+ );
+ Assert.ok(screenshotsChild._overlay._initialized, "The overlay exists");
+ });
+
+ let waitForPanelHide = BrowserTestUtils.waitForMutationCondition(
+ panel,
+ { attributes: true },
+ () => {
+ return BrowserTestUtils.is_hidden(panel);
+ }
+ );
+
+ await BrowserTestUtils.crashFrame(browser);
+
+ await waitForPanelHide;
+ ok(
+ BrowserTestUtils.is_hidden(panel),
+ "Panel buttons are hidden after page crash"
+ );
+
+ await ContentTask.spawn(browser, null, async () => {
+ let screenshotsChild = content.windowGlobalChild.getActor(
+ "ScreenshotsComponent"
+ );
+ Assert.ok(!screenshotsChild._overlay, "The overlay doesn't exist");
+ });
+
+ let tab = gBrowser.getTabForBrowser(browser);
+
+ SessionStore.reviveCrashedTab(tab);
+
+ ok(
+ BrowserTestUtils.is_hidden(panel),
+ "Panel buttons are hidden after page crash"
+ );
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js
new file mode 100644
index 0000000000..b778c63163
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_toggle_pref.js
@@ -0,0 +1,288 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs",
+});
+XPCOMUtils.defineLazyGetter(this, "ExtensionManagement", () => {
+ const { Management } = ChromeUtils.import(
+ "resource://gre/modules/Extension.jsm"
+ );
+ return Management;
+});
+
+add_task(async function test() {
+ let observerSpy = sinon.spy();
+ let notifierSpy = sinon.spy();
+
+ let observerStub = sinon
+ .stub(ScreenshotsUtils, "observe")
+ .callsFake(observerSpy);
+ let notifierStub = sinon
+ .stub(ScreenshotsUtils, "notify")
+ .callsFake(function(window, type) {
+ notifierSpy();
+ ScreenshotsUtils.notify.wrappedMethod.apply(this, arguments);
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ function awaitExtensionEvent(eventName, id) {
+ return new Promise(resolve => {
+ let listener = (_eventName, ...args) => {
+ let extension = args[0];
+ if (_eventName === eventName && extension.id == id) {
+ ExtensionManagement.off(eventName, listener);
+ resolve();
+ }
+ };
+ ExtensionManagement.on(eventName, listener);
+ });
+ }
+ const SCREENSHOT_EXTENSION = "screenshots@mozilla.org";
+
+ let helper = new ScreenshotsHelper(browser);
+
+ ok(observerSpy.notCalled, "Observer not called");
+ helper.triggerUIFromToolbar();
+ Assert.equal(observerSpy.callCount, 1, "Observer function called once");
+
+ ok(notifierSpy.notCalled, "Notifier not called");
+ EventUtils.synthesizeKey("s", { accelKey: true, shiftKey: true });
+
+ await TestUtils.waitForCondition(() => notifierSpy.callCount == 1);
+ Assert.equal(notifierSpy.callCount, 1, "Notify function called once");
+
+ await TestUtils.waitForCondition(() => observerSpy.callCount == 2);
+ Assert.equal(observerSpy.callCount, 2, "Observer function called twice");
+
+ let menu = document.getElementById("contentAreaContextMenu");
+ let popupshown = BrowserTestUtils.waitForPopupEvent(menu, "shown");
+ EventUtils.synthesizeMouseAtCenter(document.body, {
+ type: "contextmenu",
+ });
+ await popupshown;
+ Assert.equal(menu.state, "open", "Context menu is open");
+
+ let popuphidden = BrowserTestUtils.waitForPopupEvent(menu, "hidden");
+ menu.activateItem(menu.querySelector("#context-take-screenshot"));
+ await popuphidden;
+
+ Assert.equal(observerSpy.callCount, 3, "Observer function called thrice");
+
+ const COMPONENT_PREF = "screenshots.browser.component.enabled";
+ await SpecialPowers.pushPrefEnv({
+ set: [[COMPONENT_PREF, false]],
+ });
+ ok(!Services.prefs.getBoolPref(COMPONENT_PREF), "Extension enabled");
+ await awaitExtensionEvent("ready", SCREENSHOT_EXTENSION);
+
+ helper.triggerUIFromToolbar();
+ Assert.equal(
+ observerSpy.callCount,
+ 3,
+ "Observer function still called thrice"
+ );
+
+ await SpecialPowers.spawn(
+ browser,
+ ["#firefox-screenshots-preselection-iframe"],
+ async function(iframeSelector) {
+ info(
+ `in waitForUIContent content function, iframeSelector: ${iframeSelector}`
+ );
+ let iframe;
+ await ContentTaskUtils.waitForCondition(() => {
+ iframe = content.document.querySelector(iframeSelector);
+ if (!iframe || !ContentTaskUtils.is_visible(iframe)) {
+ info("in waitForUIContent, no visible iframe yet");
+ return false;
+ }
+ return true;
+ });
+ // wait a frame for the screenshots UI to finish any init
+ await new content.Promise(res => content.requestAnimationFrame(res));
+ }
+ );
+
+ helper.triggerUIFromToolbar();
+ await SpecialPowers.spawn(
+ browser,
+ ["#firefox-screenshots-preselection-iframe"],
+ async function(iframeSelector) {
+ info(
+ `in waitForUIContent content function, iframeSelector: ${iframeSelector}`
+ );
+ let iframe;
+ await ContentTaskUtils.waitForCondition(() => {
+ iframe = content.document.querySelector(iframeSelector);
+ if (!iframe || !ContentTaskUtils.is_visible(iframe)) {
+ info("in waitForUIContent, no visible iframe yet");
+ return true;
+ }
+ return false;
+ });
+ // wait a frame for the screenshots UI to finish any init
+ await new content.Promise(res => content.requestAnimationFrame(res));
+ }
+ );
+
+ popupshown = BrowserTestUtils.waitForPopupEvent(menu, "shown");
+ EventUtils.synthesizeMouseAtCenter(document.body, {
+ type: "contextmenu",
+ });
+ await popupshown;
+ Assert.equal(menu.state, "open", "Context menu is open");
+
+ popuphidden = BrowserTestUtils.waitForPopupEvent(menu, "hidden");
+ menu.activateItem(menu.querySelector("#context-take-screenshot"));
+ await popuphidden;
+
+ Assert.equal(
+ observerSpy.callCount,
+ 3,
+ "Observer function still called thrice"
+ );
+
+ await SpecialPowers.spawn(
+ browser,
+ ["#firefox-screenshots-preselection-iframe"],
+ async function(iframeSelector) {
+ info(
+ `in waitForUIContent content function, iframeSelector: ${iframeSelector}`
+ );
+ let iframe;
+ await ContentTaskUtils.waitForCondition(() => {
+ iframe = content.document.querySelector(iframeSelector);
+ if (!iframe || !ContentTaskUtils.is_visible(iframe)) {
+ info("in waitForUIContent, no visible iframe yet");
+ return false;
+ }
+ return true;
+ });
+ // wait a frame for the screenshots UI to finish any init
+ await new content.Promise(res => content.requestAnimationFrame(res));
+ }
+ );
+
+ helper.triggerUIFromToolbar();
+ await SpecialPowers.spawn(
+ browser,
+ ["#firefox-screenshots-preselection-iframe"],
+ async function(iframeSelector) {
+ info(
+ `in waitForUIContent content function, iframeSelector: ${iframeSelector}`
+ );
+ let iframe;
+ await ContentTaskUtils.waitForCondition(() => {
+ iframe = content.document.querySelector(iframeSelector);
+ if (!iframe || !ContentTaskUtils.is_visible(iframe)) {
+ return true;
+ }
+ info("in waitForUIContent, iframe still visible");
+ info(iframe);
+ return false;
+ });
+ // wait a frame for the screenshots UI to finish any init
+ await new content.Promise(res => content.requestAnimationFrame(res));
+ }
+ );
+
+ let componentReady = TestUtils.topicObserved(
+ "screenshots-component-initialized"
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[COMPONENT_PREF, true]],
+ });
+ ok(Services.prefs.getBoolPref(COMPONENT_PREF), "Component enabled");
+ // Needed for component to initialize
+ await componentReady;
+
+ helper.triggerUIFromToolbar();
+ Assert.equal(
+ observerSpy.callCount,
+ 4,
+ "Observer function called four times"
+ );
+
+ const SCREENSHOTS_PREF = "extensions.screenshots.disabled";
+ await SpecialPowers.pushPrefEnv({
+ set: [[SCREENSHOTS_PREF, true]],
+ });
+ ok(Services.prefs.getBoolPref(SCREENSHOTS_PREF), "Screenshots disabled");
+ }
+ );
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ const SCREENSHOTS_PREF = "extensions.screenshots.disabled";
+ ok(Services.prefs.getBoolPref(SCREENSHOTS_PREF), "Screenshots disabled");
+
+ ok(
+ document.getElementById("screenshot-button").disabled,
+ "Toolbar button disabled"
+ );
+
+ let menu = document.getElementById("contentAreaContextMenu");
+ let popupshown = BrowserTestUtils.waitForPopupEvent(menu, "shown");
+ EventUtils.synthesizeMouseAtCenter(document.body, {
+ type: "contextmenu",
+ });
+ await popupshown;
+ Assert.equal(menu.state, "open", "Context menu is open");
+
+ ok(
+ menu.querySelector("#context-take-screenshot").hidden,
+ "Take screenshot is not in context menu"
+ );
+
+ let popuphidden = BrowserTestUtils.waitForPopupEvent(menu, "hidden");
+ menu.hidePopup();
+ await popuphidden;
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[SCREENSHOTS_PREF, false]],
+ });
+ ok(!Services.prefs.getBoolPref(SCREENSHOTS_PREF), "Screenshots enabled");
+ }
+ );
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ const SCREENSHOTS_PREF = "extensions.screenshots.disabled";
+ ok(!Services.prefs.getBoolPref(SCREENSHOTS_PREF), "Screenshots enabled");
+
+ let helper = new ScreenshotsHelper(browser);
+
+ helper.triggerUIFromToolbar();
+ Assert.equal(
+ observerSpy.callCount,
+ 5,
+ "Observer function called for the fifth time"
+ );
+ }
+ );
+
+ observerStub.restore();
+ notifierStub.restore();
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_toolbar_button.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_toolbar_button.js
new file mode 100644
index 0000000000..5ad7d32192
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_toolbar_button.js
@@ -0,0 +1,26 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function testScreenshotButtonDisabled() {
+ info("Test the Screenshots button in the panel");
+
+ let screenshotBtn = document.getElementById("screenshot-button");
+ Assert.ok(screenshotBtn, "The screenshots button was added to the nav bar");
+
+ await BrowserTestUtils.withNewTab("https://example.com/", () => {
+ Assert.equal(
+ screenshotBtn.disabled,
+ false,
+ "Screenshots button is enabled"
+ );
+ });
+ await BrowserTestUtils.withNewTab("about:home", () => {
+ Assert.equal(
+ screenshotBtn.disabled,
+ false,
+ "Screenshots button is still enabled on about pages"
+ );
+ });
+});
diff --git a/browser/components/screenshots/tests/browser/browser_screenshots_test_visible.js b/browser/components/screenshots/tests/browser/browser_screenshots_test_visible.js
new file mode 100644
index 0000000000..a23d07140e
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/browser_screenshots_test_visible.js
@@ -0,0 +1,374 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_visibleScreenshot() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+ let devicePixelRatio = await getContentDevicePixelRatio(browser);
+
+ // click toolbar button so panel shows
+ helper.triggerUIFromToolbar();
+
+ await helper.waitForOverlay();
+
+ let screenshotReady = TestUtils.topicObserved(
+ "screenshots-preview-ready"
+ );
+
+ let panel = gBrowser.selectedBrowser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+
+ // click the visible page button in panel
+ let visiblePageButton = panel
+ .querySelector("screenshots-buttons")
+ .shadowRoot.querySelector(".visible-page");
+ visiblePageButton.click();
+
+ let dialog = helper.getDialog();
+
+ await screenshotReady;
+
+ let copyButton = dialog._frame.contentDocument.querySelector(
+ ".highlight-button-copy"
+ );
+ ok(copyButton, "Got the copy button");
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ // click copy button on dialog box
+ copyButton.click();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+ info("result: " + JSON.stringify(result, null, 2));
+ info("contentInfo: " + JSON.stringify(contentInfo, null, 2));
+
+ let expectedWidth = Math.floor(
+ devicePixelRatio * contentInfo.clientWidth
+ );
+ Assert.equal(result.width, expectedWidth, "Widths should be equal");
+
+ let expectedHeight = Math.floor(
+ devicePixelRatio * contentInfo.clientHeight
+ );
+ Assert.equal(result.height, expectedHeight, "Heights should be equal");
+
+ // top left
+ Assert.equal(111, result.color.topLeft[0], "R color value");
+ Assert.equal(111, result.color.topLeft[1], "G color value");
+ Assert.equal(111, result.color.topLeft[2], "B color value");
+
+ // top right
+ Assert.equal(111, result.color.topRight[0], "R color value");
+ Assert.equal(111, result.color.topRight[1], "G color value");
+ Assert.equal(111, result.color.topRight[2], "B color value");
+
+ // bottom left
+ Assert.equal(111, result.color.bottomLeft[0], "R color value");
+ Assert.equal(111, result.color.bottomLeft[1], "G color value");
+ Assert.equal(111, result.color.bottomLeft[2], "B color value");
+
+ // bottom right
+ Assert.equal(111, result.color.bottomRight[0], "R color value");
+ Assert.equal(111, result.color.bottomRight[1], "G color value");
+ Assert.equal(111, result.color.bottomRight[2], "B color value");
+ }
+ );
+});
+
+add_task(async function test_visibleScreenshotScrolled() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], () => {
+ content.scrollTo(0, 2008);
+ });
+
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ // click toolbar button so panel shows
+ helper.triggerUIFromToolbar();
+
+ let panel = gBrowser.selectedBrowser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ await BrowserTestUtils.waitForMutationCondition(
+ panel,
+ { attributes: true },
+ () => {
+ return BrowserTestUtils.is_visible(panel);
+ }
+ );
+ ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible");
+
+ let screenshotReady = TestUtils.topicObserved(
+ "screenshots-preview-ready"
+ );
+
+ // click the visible page button in panel
+ let visiblePageButton = panel
+ .querySelector("screenshots-buttons")
+ .shadowRoot.querySelector(".visible-page");
+ visiblePageButton.click();
+
+ let dialog = helper.getDialog();
+
+ await screenshotReady;
+
+ let copyButton = dialog._frame.contentDocument.querySelector(
+ ".highlight-button-copy"
+ );
+ ok(copyButton, "Got the copy button");
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ // click copy button on dialog box
+ copyButton.click();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+
+ info("result: " + JSON.stringify(result, null, 2));
+ info("contentInfo: " + JSON.stringify(contentInfo, null, 2));
+
+ let expectedWidth = Math.floor(
+ devicePixelRatio * contentInfo.clientWidth
+ );
+ Assert.equal(result.width, expectedWidth, "Widths should be equal");
+
+ let expectedHeight = Math.floor(
+ devicePixelRatio * contentInfo.clientHeight
+ );
+ Assert.equal(result.height, expectedHeight, "Heights should be equal");
+
+ // top left
+ Assert.equal(105, result.color.topLeft[0], "R color value");
+ Assert.equal(55, result.color.topLeft[1], "G color value");
+ Assert.equal(105, result.color.topLeft[2], "B color value");
+
+ // top right
+ Assert.equal(105, result.color.topRight[0], "R color value");
+ Assert.equal(55, result.color.topRight[1], "G color value");
+ Assert.equal(105, result.color.topRight[2], "B color value");
+
+ // bottom left
+ Assert.equal(105, result.color.bottomLeft[0], "R color value");
+ Assert.equal(55, result.color.bottomLeft[1], "G color value");
+ Assert.equal(105, result.color.bottomLeft[2], "B color value");
+
+ // bottom right
+ Assert.equal(105, result.color.bottomRight[0], "R color value");
+ Assert.equal(55, result.color.bottomRight[1], "G color value");
+ Assert.equal(105, result.color.bottomRight[2], "B color value");
+ }
+ );
+});
+
+add_task(async function test_visibleScreenshotScrolled() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], () => {
+ content.scrollTo(2004, 0);
+ });
+
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ // click toolbar button so panel shows
+ helper.triggerUIFromToolbar();
+
+ let panel = gBrowser.selectedBrowser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ await BrowserTestUtils.waitForMutationCondition(
+ panel,
+ { attributes: true },
+ () => {
+ return BrowserTestUtils.is_visible(panel);
+ }
+ );
+ ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible");
+
+ let screenshotReady = TestUtils.topicObserved(
+ "screenshots-preview-ready"
+ );
+
+ // click the visible page button in panel
+ let visiblePageButton = panel
+ .querySelector("screenshots-buttons")
+ .shadowRoot.querySelector(".visible-page");
+ visiblePageButton.click();
+
+ let dialog = helper.getDialog();
+
+ await screenshotReady;
+
+ let copyButton = dialog._frame.contentDocument.querySelector(
+ ".highlight-button-copy"
+ );
+ ok(copyButton, "Got the copy button");
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ // click copy button on dialog box
+ copyButton.click();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+
+ info("result: " + JSON.stringify(result, null, 2));
+ info("contentInfo: " + JSON.stringify(contentInfo, null, 2));
+
+ let expectedWidth = Math.floor(
+ devicePixelRatio * contentInfo.clientWidth
+ );
+ Assert.equal(result.width, expectedWidth, "Widths should be equal");
+
+ let expectedHeight = Math.floor(
+ devicePixelRatio * contentInfo.clientHeight
+ );
+ Assert.equal(result.height, expectedHeight, "Heights should be equal");
+
+ // top left
+ Assert.equal(55, result.color.topLeft[0], "R color value");
+ Assert.equal(155, result.color.topLeft[1], "G color value");
+ Assert.equal(155, result.color.topLeft[2], "B color value");
+
+ // top right
+ Assert.equal(55, result.color.topRight[0], "R color value");
+ Assert.equal(155, result.color.topRight[1], "G color value");
+ Assert.equal(155, result.color.topRight[2], "B color value");
+
+ // bottom left
+ Assert.equal(55, result.color.bottomLeft[0], "R color value");
+ Assert.equal(155, result.color.bottomLeft[1], "G color value");
+ Assert.equal(155, result.color.bottomLeft[2], "B color value");
+
+ // bottom right
+ Assert.equal(55, result.color.bottomRight[0], "R color value");
+ Assert.equal(155, result.color.bottomRight[1], "G color value");
+ Assert.equal(155, result.color.bottomRight[2], "B color value");
+ }
+ );
+});
+
+add_task(async function test_visibleScreenshotScrolled() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], () => {
+ content.scrollTo(2004, 2008);
+ });
+
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ // click toolbar button so panel shows
+ helper.triggerUIFromToolbar();
+
+ let panel = gBrowser.selectedBrowser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ await BrowserTestUtils.waitForMutationCondition(
+ panel,
+ { attributes: true },
+ () => {
+ return BrowserTestUtils.is_visible(panel);
+ }
+ );
+ ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible");
+
+ let screenshotReady = TestUtils.topicObserved(
+ "screenshots-preview-ready"
+ );
+
+ // click the visible page button in panel
+ let visiblePageButton = panel
+ .querySelector("screenshots-buttons")
+ .shadowRoot.querySelector(".visible-page");
+ visiblePageButton.click();
+
+ let dialog = helper.getDialog();
+
+ await screenshotReady;
+
+ let copyButton = dialog._frame.contentDocument.querySelector(
+ ".highlight-button-copy"
+ );
+ ok(copyButton, "Got the copy button");
+
+ let clipboardChanged = helper.waitForRawClipboardChange();
+
+ // click copy button on dialog box
+ copyButton.click();
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ let result = await helper.getImageSizeAndColorFromClipboard();
+
+ info("result: " + JSON.stringify(result, null, 2));
+ info("contentInfo: " + JSON.stringify(contentInfo, null, 2));
+
+ let expectedWidth = Math.floor(
+ devicePixelRatio * contentInfo.clientWidth
+ );
+ Assert.equal(result.width, expectedWidth, "Widths should be equal");
+
+ let expectedHeight = Math.floor(
+ devicePixelRatio * contentInfo.clientHeight
+ );
+ Assert.equal(result.height, expectedHeight, "Heights should be equal");
+
+ // top left
+ Assert.equal(52, result.color.topLeft[0], "R color value");
+ Assert.equal(127, result.color.topLeft[1], "G color value");
+ Assert.equal(152, result.color.topLeft[2], "B color value");
+
+ // top right
+ Assert.equal(52, result.color.topRight[0], "R color value");
+ Assert.equal(127, result.color.topRight[1], "G color value");
+ Assert.equal(152, result.color.topRight[2], "B color value");
+
+ // bottom left
+ Assert.equal(52, result.color.bottomLeft[0], "R color value");
+ Assert.equal(127, result.color.bottomLeft[1], "G color value");
+ Assert.equal(152, result.color.bottomLeft[2], "B color value");
+
+ // bottom right
+ Assert.equal(52, result.color.bottomRight[0], "R color value");
+ Assert.equal(127, result.color.bottomRight[1], "G color value");
+ Assert.equal(152, result.color.bottomRight[2], "B color value");
+ }
+ );
+});
diff --git a/browser/components/screenshots/tests/browser/head.js b/browser/components/screenshots/tests/browser/head.js
new file mode 100644
index 0000000000..cda6aed601
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/head.js
@@ -0,0 +1,496 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.com"
+);
+
+const TEST_PAGE = TEST_ROOT + "test-page.html";
+const SHORT_TEST_PAGE = TEST_ROOT + "short-test-page.html";
+
+const gScreenshotUISelectors = {
+ panelButtons: "#screenshotsPagePanel",
+ fullPageButton: "button.full-page",
+ visiblePageButton: "button.visible-page",
+ copyButton: "button.highlight-button-copy",
+};
+
+// MouseEvents is for the mouse events on the Anonymous content
+const MouseEvents = {
+ mouse: new Proxy(
+ {},
+ {
+ get: (target, name) =>
+ async function(x, y, selector = ":root") {
+ if (name === "click") {
+ this.down(x, y);
+ this.up(x, y);
+ } else {
+ await safeSynthesizeMouseEventInContentPage(selector, x, y, {
+ type: "mouse" + name,
+ });
+ }
+ },
+ }
+ ),
+};
+
+const { mouse } = MouseEvents;
+
+class ScreenshotsHelper {
+ constructor(browser) {
+ this.browser = browser;
+ this.selector = gScreenshotUISelectors;
+ }
+
+ get toolbarButton() {
+ return document.getElementById("screenshot-button");
+ }
+
+ /**
+ * Click the screenshots button in the toolbar
+ */
+ triggerUIFromToolbar() {
+ let button = this.toolbarButton;
+ ok(
+ BrowserTestUtils.is_visible(button),
+ "The screenshot toolbar button is visible"
+ );
+ button.click();
+ }
+
+ async waitForPanel() {
+ return BrowserTestUtils.waitForCondition(async () => {
+ return gBrowser.selectedBrowser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ });
+ }
+
+ async waitForOverlay() {
+ let panel = gBrowser.selectedBrowser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ if (!panel) {
+ panel = await this.waitForPanel();
+ }
+ await BrowserTestUtils.waitForMutationCondition(
+ panel,
+ { attributes: true },
+ () => {
+ return BrowserTestUtils.is_visible(panel);
+ }
+ );
+ ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible");
+
+ await BrowserTestUtils.waitForCondition(async () => {
+ let init = await this.isOverlayInitialized();
+ return init;
+ });
+ info("Overlay is visible");
+ }
+
+ async waitForOverlayClosed() {
+ let panel = gBrowser.selectedBrowser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ if (!panel) {
+ panel = await this.waitForPanel();
+ }
+ await BrowserTestUtils.waitForMutationCondition(
+ panel,
+ { attributes: true },
+ () => {
+ return BrowserTestUtils.is_hidden(panel);
+ }
+ );
+ ok(BrowserTestUtils.is_hidden(panel), "Panel buttons are hidden");
+
+ await BrowserTestUtils.waitForCondition(async () => {
+ let init = !(await this.isOverlayInitialized());
+ info("Is overlay initialized: " + !init);
+ return init;
+ });
+ info("Overlay is not visible");
+ }
+
+ async isOverlayInitialized() {
+ return SpecialPowers.spawn(this.browser, [], () => {
+ let screenshotsChild = content.windowGlobalChild.getActor(
+ "ScreenshotsComponent"
+ );
+ return screenshotsChild?._overlay?._initialized;
+ });
+ }
+
+ async getOverlayState() {
+ return ContentTask.spawn(this.browser, null, async () => {
+ let screenshotsChild = content.windowGlobalChild.getActor(
+ "ScreenshotsComponent"
+ );
+ return screenshotsChild._overlay.stateHandler.getState();
+ });
+ }
+
+ async waitForStateChange(newState) {
+ await BrowserTestUtils.waitForCondition(async () => {
+ let state = await this.getOverlayState();
+ return state === newState;
+ });
+ }
+
+ async waitForSelectionBoxSizeChange(currentWidth) {
+ await ContentTask.spawn(
+ this.browser,
+ [currentWidth],
+ async ([currWidth]) => {
+ let screenshotsChild = content.windowGlobalChild.getActor(
+ "ScreenshotsComponent"
+ );
+
+ let dimensions = screenshotsChild._overlay.screenshotsContainer.getSelectionLayerDimensions();
+ // return dimensions.boxWidth;
+ await ContentTaskUtils.waitForCondition(() => {
+ dimensions = screenshotsChild._overlay.screenshotsContainer.getSelectionLayerDimensions();
+ return dimensions.boxWidth !== currWidth;
+ }, "Wait for selection box width change");
+ }
+ );
+ }
+
+ async dragOverlay(startX, startY, endX, endY) {
+ await this.waitForStateChange("crosshairs");
+ let state = await this.getOverlayState();
+ Assert.equal(state, "crosshairs", "The overlay is in the crosshairs state");
+
+ mouse.down(startX, startY);
+
+ await this.waitForStateChange("draggingReady");
+ state = await this.getOverlayState();
+ Assert.equal(
+ state,
+ "draggingReady",
+ "The overlay is in the draggingReady state"
+ );
+
+ mouse.move(endX, endY);
+
+ await this.waitForStateChange("dragging");
+ state = await this.getOverlayState();
+ Assert.equal(state, "dragging", "The overlay is in the dragging state");
+
+ mouse.up(endX, endY);
+
+ await this.waitForStateChange("selected");
+ state = await this.getOverlayState();
+ Assert.equal(state, "selected", "The overlay is in the selected state");
+
+ this.endX = endX;
+ this.endY = endY;
+ }
+
+ async scrollContentWindow(x, y) {
+ await ContentTask.spawn(this.browser, [x, y], async ([xPos, yPos]) => {
+ content.window.scroll(xPos, yPos);
+ });
+ }
+
+ clickDownloadButton() {
+ mouse.click(this.endX - 60, this.endY + 30);
+ }
+
+ clickCopyButton(overrideX = null, overrideY = null) {
+ // click copy button with last x and y position from dragOverlay
+ // the middle of the copy button is last X - 163 and last Y + 30.
+ // Ex. 500, 500 would be 336, 530
+ if (overrideX && overrideY) {
+ mouse.click(overrideX - 166, overrideY + 30);
+ } else {
+ mouse.click(this.endX - 166, this.endY + 30);
+ }
+ }
+
+ clickCancelButton() {
+ // click copy button with last x and y position from dragOverlay
+ // the middle of the copy button is last X - 230 and last Y + 30.
+ // Ex. 500, 500 would be 270, 530
+ mouse.click(this.endX - 230, this.endY + 30);
+ }
+
+ async zoomBrowser(zoom) {
+ await SpecialPowers.spawn(this.browser, [zoom], zoomLevel => {
+ const { Layout } = ChromeUtils.import(
+ "chrome://mochitests/content/browser/accessible/tests/browser/Layout.jsm"
+ );
+ Layout.zoomDocument(content.document, zoomLevel);
+ });
+ }
+
+ /**
+ * Gets the dialog box
+ * @returns The dialog box
+ */
+ getDialog() {
+ let currDialogBox = this.browser.tabDialogBox;
+ let manager = currDialogBox.getTabDialogManager();
+ let dialogs = manager.hasDialogs && manager.dialogs;
+ return dialogs[0];
+ }
+
+ assertPanelVisible() {
+ let panel = gBrowser.selectedBrowser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(panel),
+ "Screenshots panel is visible"
+ );
+ }
+
+ assertPanelNotVisible() {
+ let panel = gBrowser.selectedBrowser.ownerDocument.querySelector(
+ "#screenshotsPagePanel"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(panel),
+ "Screenshots panel is not visible"
+ );
+ }
+
+ /**
+ * Copied from screenshots extension
+ * Returns a promise that resolves when the clipboard data has changed
+ * Otherwise rejects
+ */
+ waitForRawClipboardChange() {
+ const initialClipboardData = Date.now().toString();
+ SpecialPowers.clipboardCopyString(initialClipboardData);
+
+ let promiseChanged = TestUtils.waitForCondition(() => {
+ let data;
+ try {
+ data = getRawClipboardData("image/png");
+ } catch (e) {
+ console.log("Failed to get image/png clipboard data:", e);
+ return false;
+ }
+ return data && initialClipboardData !== data;
+ });
+ return promiseChanged;
+ }
+
+ /**
+ * Gets the client and scroll demensions on the window
+ * @returns { Object }
+ * clientHeight The visible height
+ * clientWidth The visible width
+ * scrollHeight The scrollable height
+ * scrollWidth The scrollable width
+ */
+ getContentDimensions() {
+ return SpecialPowers.spawn(this.browser, [], async function() {
+ let doc = content.document.documentElement;
+ return {
+ clientHeight: doc.clientHeight,
+ clientWidth: doc.clientWidth,
+ scrollHeight: doc.scrollHeight,
+ scrollWidth: doc.scrollWidth,
+ };
+ });
+ }
+
+ getSelectionLayerDimensions() {
+ return ContentTask.spawn(this.browser, null, async () => {
+ let screenshotsChild = content.windowGlobalChild.getActor(
+ "ScreenshotsComponent"
+ );
+ Assert.ok(screenshotsChild._overlay._initialized, "The overlay exists");
+
+ return screenshotsChild._overlay.screenshotsContainer.getSelectionLayerDimensions();
+ });
+ }
+
+ getSelectionBoxDimensions() {
+ return ContentTask.spawn(this.browser, null, async () => {
+ let screenshotsChild = content.windowGlobalChild.getActor(
+ "ScreenshotsComponent"
+ );
+ Assert.ok(screenshotsChild._overlay._initialized, "The overlay exists");
+
+ return screenshotsChild._overlay.screenshotsContainer.getSelectionLayerBoxDimensions();
+ });
+ }
+
+ /**
+ * Clicks an element on the screen
+ * @param eleSel The selector for the element to click
+ */
+ async clickUIElement(eleSel) {
+ await SpecialPowers.spawn(this.browser, [eleSel], async function(
+ eleSelector
+ ) {
+ info(
+ `in clickScreenshotsUIElement content function, eleSelector: ${eleSelector}`
+ );
+ const EventUtils = ContentTaskUtils.getEventUtils(content);
+ let ele = content.document.querySelector(eleSelector);
+ info(`Found the thing to click: ${eleSelector}: ${!!ele}`);
+
+ EventUtils.synthesizeMouseAtCenter(ele, {});
+ // wait a frame for the screenshots UI to finish any init
+ await new content.Promise(res => content.requestAnimationFrame(res));
+ });
+ }
+
+ /**
+ * Copied from screenshots extension
+ * A helper that returns the size of the image that was just put into the clipboard by the
+ * :screenshot command.
+ * @return The {width, height, color} dimension and color object.
+ */
+ async getImageSizeAndColorFromClipboard() {
+ let flavor = "image/png";
+ let image = getRawClipboardData(flavor);
+ ok(image, "screenshot data exists on the clipboard");
+
+ // Due to the differences in how images could be stored in the clipboard the
+ // checks below are needed. The clipboard could already provide the image as
+ // byte streams or as image container. If it's not possible obtain a
+ // byte stream, the function throws.
+
+ if (image instanceof Ci.imgIContainer) {
+ image = Cc["@mozilla.org/image/tools;1"]
+ .getService(Ci.imgITools)
+ .encodeImage(image, flavor);
+ }
+
+ if (!(image instanceof Ci.nsIInputStream)) {
+ throw new Error("Unable to read image data");
+ }
+
+ const binaryStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ binaryStream.setInputStream(image);
+ const available = binaryStream.available();
+ const buffer = new ArrayBuffer(available);
+ is(
+ binaryStream.readArrayBuffer(available, buffer),
+ available,
+ "Read expected amount of data"
+ );
+
+ // We are going to load the image in the content page to measure its size.
+ // We don't want to insert the image directly in the browser's document
+ // which could mess all sorts of things up
+ return SpecialPowers.spawn(this.browser, [buffer], async function(_buffer) {
+ const img = content.document.createElement("img");
+ const loaded = new Promise(r => {
+ img.addEventListener("load", r, { once: true });
+ });
+ const url = content.URL.createObjectURL(
+ new Blob([_buffer], { type: "image/png" })
+ );
+
+ img.src = url;
+ content.document.documentElement.appendChild(img);
+
+ info("Waiting for the clipboard image to load in the content page");
+ await loaded;
+
+ let canvas = content.document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "html:canvas"
+ );
+ let context = canvas.getContext("2d");
+ canvas.width = img.width;
+ canvas.height = img.height;
+ context.drawImage(img, 0, 0);
+ let topLeft = context.getImageData(0, 0, 1, 1);
+ let topRight = context.getImageData(img.width - 1, 0, 1, 1);
+ let bottomLeft = context.getImageData(0, img.height - 1, 1, 1);
+ let bottomRight = context.getImageData(
+ img.width - 1,
+ img.height - 1,
+ 1,
+ 1
+ );
+
+ img.remove();
+ content.URL.revokeObjectURL(url);
+
+ return {
+ width: img.width,
+ height: img.height,
+ color: {
+ topLeft: topLeft.data,
+ topRight: topRight.data,
+ bottomLeft: bottomLeft.data,
+ bottomRight: bottomRight.data,
+ },
+ };
+ });
+ }
+}
+
+/**
+ * Get the raw clipboard data
+ * @param flavor Type of data to get from clipboard
+ * @returns The data from the clipboard
+ */
+function getRawClipboardData(flavor) {
+ const whichClipboard = Services.clipboard.kGlobalClipboard;
+ const xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ xferable.init(null);
+ xferable.addDataFlavor(flavor);
+ Services.clipboard.getData(xferable, whichClipboard);
+ let data = {};
+ try {
+ // xferable.getTransferData(flavor, data);
+ xferable.getAnyTransferData({}, data);
+ info(JSON.stringify(data, null, 2));
+ } catch (e) {
+ info(e);
+ }
+ data = data.value || null;
+ return data;
+}
+
+/**
+ * Synthesize a mouse event on an element, after ensuring that it is visible
+ * in the viewport.
+ *
+ * @param {String} selector: The node selector to get the node target for the event.
+ * @param {number} x
+ * @param {number} y
+ * @param {object} options: Options that will be passed to BrowserTestUtils.synthesizeMouse
+ */
+async function safeSynthesizeMouseEventInContentPage(
+ selector,
+ x,
+ y,
+ options = {}
+) {
+ let context = gBrowser.selectedBrowser.browsingContext;
+ BrowserTestUtils.synthesizeMouse(selector, x, y, options, context);
+}
+
+add_setup(async () => {
+ CustomizableUI.addWidgetToArea(
+ "screenshot-button",
+ CustomizableUI.AREA_NAVBAR
+ );
+ let screenshotBtn = document.getElementById("screenshot-button");
+ Assert.ok(screenshotBtn, "The screenshots button was added to the nav bar");
+});
+
+function getContentDevicePixelRatio(browser) {
+ return SpecialPowers.spawn(browser, [], async function() {
+ return content.window.devicePixelRatio;
+ });
+}
diff --git a/browser/components/screenshots/tests/browser/short-test-page.html b/browser/components/screenshots/tests/browser/short-test-page.html
new file mode 100644
index 0000000000..4ba4f10394
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/short-test-page.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Screenshots</title>
+</head>
+<body style="height:100px; width:100px;">
+</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..9f9ae8d652
--- /dev/null
+++ b/browser/components/screenshots/tests/browser/test-page.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <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%);">
+</body>
+</html>