fune/browser/components/screenshots/ScreenshotsUtils.sys.mjs

547 lines
18 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { getFilename } from "chrome://browser/content/screenshots/fileHelpers.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
Downloads: "resource://gre/modules/Downloads.sys.mjs",
FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
});
XPCOMUtils.defineLazyServiceGetters(lazy, {
AlertsService: ["@mozilla.org/alerts-service;1", "nsIAlertsService"],
});
XPCOMUtils.defineLazyGetter(lazy, "screenshotsLocalization", () => {
return new Localization(["browser/screenshots.ftl"], true);
});
const PanelPosition = "bottomright topright";
const PanelOffsetX = -33;
const PanelOffsetY = -8;
// The max dimension for a canvas is defined https://searchfox.org/mozilla-central/rev/f40d29a11f2eb4685256b59934e637012ea6fb78/gfx/cairo/cairo/src/cairo-image-surface.c#62.
// The max number of pixels for a canvas is 124925329 or 11177 x 11177.
// We have to limit screenshots to these dimensions otherwise it will cause an error.
const MAX_CAPTURE_DIMENSION = 32767;
const MAX_CAPTURE_AREA = 124925329;
export class ScreenshotsComponentParent extends JSWindowActorParent {
async receiveMessage(message) {
let browser = message.target.browsingContext.topFrameElement;
switch (message.name) {
case "Screenshots:CancelScreenshot":
await ScreenshotsUtils.closePanel(browser);
let { reason } = message.data;
ScreenshotsUtils.recordTelemetryEvent("canceled", reason, {});
break;
case "Screenshots:CopyScreenshot":
await ScreenshotsUtils.closePanel(browser);
let copyBox = message.data;
ScreenshotsUtils.copyScreenshotFromRegion(copyBox, browser);
break;
case "Screenshots:DownloadScreenshot":
await ScreenshotsUtils.closePanel(browser);
let { title, downloadBox } = message.data;
ScreenshotsUtils.downloadScreenshotFromRegion(
title,
downloadBox,
browser
);
break;
case "Screenshots:ShowPanel":
ScreenshotsUtils.openPanel(browser);
break;
case "Screenshots:HidePanel":
ScreenshotsUtils.closePanel(browser);
break;
}
}
didDestroy() {
// When restoring a crashed tab the browser is null
let browser = this.browsingContext.topFrameElement;
if (browser) {
ScreenshotsUtils.closePanel(browser);
}
}
}
export var ScreenshotsUtils = {
initialized: false,
initialize() {
if (!this.initialized) {
if (
!Services.prefs.getBoolPref(
"screenshots.browser.component.enabled",
false
)
) {
return;
}
Services.telemetry.setEventRecordingEnabled("screenshots", true);
Services.obs.addObserver(this, "menuitem-screenshot");
Services.obs.addObserver(this, "screenshots-take-screenshot");
this.initialized = true;
if (Cu.isInAutomation) {
Services.obs.notifyObservers(null, "screenshots-component-initialized");
}
}
},
uninitialize() {
if (this.initialized) {
Services.obs.removeObserver(this, "menuitem-screenshot");
Services.obs.removeObserver(this, "screenshots-take-screenshot");
this.initialized = false;
}
},
handleEvent(event) {
// We need to add back Escape to hide behavior as we have set noautohide="true"
if (event.type === "keydown" && event.key === "Escape") {
this.closePanel(event.view.gBrowser.selectedBrowser, true);
this.recordTelemetryEvent("canceled", "escape", {});
}
},
observe(subj, topic, data) {
let { gBrowser } = subj;
let browser = gBrowser.selectedBrowser;
switch (topic) {
case "menuitem-screenshot":
let success = this.closeDialogBox(browser);
if (!success || data === "retry") {
// only toggle the buttons if no dialog box is found because
// if dialog box is found then the buttons are hidden and we return early
// else no dialog box is found and we need to toggle the buttons
// or if retry because the dialog box was closed and we need to show the panel
this.togglePanelAndOverlay(browser, data);
}
break;
case "screenshots-take-screenshot":
// need to close the preview because screenshot was taken
this.closePanel(browser, true);
// init UI as a tab dialog box
let dialogBox = gBrowser.getTabDialogBox(browser);
let { dialog } = dialogBox.open(
`chrome://browser/content/screenshots/screenshots.html?browsingContextId=${browser.browsingContext.id}`,
{
features: "resizable=no",
sizeTo: "available",
allowDuplicateDialogs: false,
}
);
this.doScreenshot(browser, dialog, data);
}
return null;
},
/**
* Notify screenshots when screenshot command is used.
* @param window The current window the screenshot command was used.
* @param type The type of screenshot taken. Used for telemetry.
*/
notify(window, type) {
if (Services.prefs.getBoolPref("screenshots.browser.component.enabled")) {
Services.obs.notifyObservers(
window.event.currentTarget.ownerGlobal,
"menuitem-screenshot",
type
);
} else {
Services.obs.notifyObservers(null, "menuitem-screenshot-extension", type);
}
},
/**
* Creates and returns a Screenshots actor.
* @param browser The current browser.
* @returns JSWindowActor The screenshot actor.
*/
getActor(browser) {
let actor = browser.browsingContext.currentWindowGlobal.getActor(
"ScreenshotsComponent"
);
return actor;
},
/**
* Open the panel buttons
* @param browser The current browser
*/
async openPanel(browser) {
this.createOrDisplayButtons(browser);
let buttonsPanel = this.panelForBrowser(browser);
if (buttonsPanel.state !== "open") {
await new Promise(resolve => {
buttonsPanel.addEventListener("popupshown", resolve, { once: true });
});
}
buttonsPanel
.querySelector("screenshots-buttons")
.focusFirst({ focusVisible: true });
},
/**
* Close the panel and call child actor to close the overlay
* @param browser The current browser
* @param {bool} closeOverlay Whether or not to
* send a message to the child to close the overly.
* Defaults to false. Will be false when called from didDestroy.
*/
async closePanel(browser, closeOverlay = false) {
let buttonsPanel = this.panelForBrowser(browser);
if (buttonsPanel && buttonsPanel.state !== "closed") {
buttonsPanel.hidePopup();
}
buttonsPanel?.ownerDocument.removeEventListener("keydown", this);
if (closeOverlay) {
let actor = this.getActor(browser);
await actor.sendQuery("Screenshots:HideOverlay");
}
},
/**
* If the buttons panel exists and is open we will hide both the panel
* and the overlay. If the overlay is showing, we will hide the overlay.
* Otherwise create or display the buttons.
* @param browser The current browser.
*/
async togglePanelAndOverlay(browser, data) {
let buttonsPanel = this.panelForBrowser(browser);
let isOverlayShowing = await this.getActor(browser).sendQuery(
"Screenshots:isOverlayShowing"
);
data = data === "retry" ? "preview_retry" : data;
if (buttonsPanel && (isOverlayShowing || buttonsPanel.state !== "closed")) {
this.recordTelemetryEvent("canceled", data, {});
return this.closePanel(browser, true);
}
let actor = this.getActor(browser);
actor.sendQuery("Screenshots:ShowOverlay");
this.recordTelemetryEvent("started", data, {});
return this.openPanel(browser);
},
/**
* Gets the screenshots dialog box
* @param browser The selected browser
* @returns Screenshots dialog box if it exists otherwise null
*/
getDialog(browser) {
let currTabDialogBox = browser.tabDialogBox;
let browserContextId = browser.browsingContext.id;
if (currTabDialogBox) {
currTabDialogBox.getTabDialogManager();
let manager = currTabDialogBox.getTabDialogManager();
let dialogs = manager.hasDialogs && manager.dialogs;
if (dialogs.length) {
for (let dialog of dialogs) {
if (
dialog._openedURL.endsWith(
`browsingContextId=${browserContextId}`
) &&
dialog._openedURL.includes("screenshots.html")
) {
return dialog;
}
}
}
}
return null;
},
/**
* Closes the dialog box it it exists
* @param browser The selected browser
*/
closeDialogBox(browser) {
let dialog = this.getDialog(browser);
if (dialog) {
dialog.close();
return true;
}
return false;
},
panelForBrowser(browser) {
return browser.ownerDocument.querySelector("#screenshotsPagePanel");
},
/**
* If the buttons panel does not exist then we will replace the buttons
* panel template with the buttons panel then open the buttons panel and
* show the screenshots overaly.
* @param browser The current browser.
*/
createOrDisplayButtons(browser) {
let doc = browser.ownerDocument;
let buttonsPanel = this.panelForBrowser(browser);
if (!buttonsPanel) {
let template = doc.querySelector("#screenshotsPagePanelTemplate");
let clone = template.content.cloneNode(true);
template.replaceWith(clone);
buttonsPanel = doc.querySelector("#screenshotsPagePanel");
} else if (buttonsPanel.state !== "closed") {
// early return if the panel is already open
return;
}
buttonsPanel.ownerDocument.addEventListener("keydown", this);
let anchor = doc.querySelector("#navigator-toolbox");
buttonsPanel.openPopup(anchor, PanelPosition, PanelOffsetX, PanelOffsetY);
},
/**
* Gets the full page bounds from the screenshots child actor.
* @param browser The current browser.
* @returns { object }
* Contains the full page bounds from the screenshots child actor.
*/
fetchFullPageBounds(browser) {
let actor = this.getActor(browser);
return actor.sendQuery("Screenshots:getFullPageBounds");
},
/**
* Gets the visible bounds from the screenshots child actor.
* @param browser The current browser.
* @returns { object }
* Contains the visible bounds from the screenshots child actor.
*/
fetchVisibleBounds(browser) {
let actor = this.getActor(browser);
return actor.sendQuery("Screenshots:getVisibleBounds");
},
showAlertMessage(title, message) {
lazy.AlertsService.showAlertNotification(null, title, message);
},
/**
* The max one dimesion for a canvas is 32767 and the max canvas area is
* 124925329. If the width or height is greater than 32767 we will crop the
* screenshot to the max width. If the area is still too large for the canvas
* we will adjust the height so we can successfully capture the screenshot.
* @param {Object} rect The dimensions of the screenshot. The rect will be
* modified in place
*/
cropScreenshotRectIfNeeded(rect) {
let cropped = false;
let width = rect.width * rect.devicePixelRatio;
let height = rect.height * rect.devicePixelRatio;
if (width > MAX_CAPTURE_DIMENSION) {
width = MAX_CAPTURE_DIMENSION;
cropped = true;
}
if (height > MAX_CAPTURE_DIMENSION) {
height = MAX_CAPTURE_DIMENSION;
cropped = true;
}
if (width * height > MAX_CAPTURE_AREA) {
height = Math.floor(MAX_CAPTURE_AREA / width);
cropped = true;
}
rect.width = Math.floor(width / rect.devicePixelRatio);
rect.height = Math.floor(height / rect.devicePixelRatio);
if (cropped) {
let [
errorTitle,
errorMessage,
] = lazy.screenshotsLocalization.formatMessagesSync([
{ id: "screenshots-too-large-error-title" },
{ id: "screenshots-too-large-error-details" },
]);
this.showAlertMessage(errorTitle.value, errorMessage.value);
this.recordTelemetryEvent("failed", "screenshot_too_large", null);
}
},
/**
* Add screenshot-ui to the dialog box and then take the screenshot
* @param browser The current browser.
* @param dialog The dialog box to show the screenshot preview.
* @param type The type of screenshot taken.
*/
async doScreenshot(browser, dialog, type) {
await dialog._dialogReady;
let screenshotsUI = dialog._frame.contentDocument.createElement(
"screenshots-ui"
);
dialog._frame.contentDocument.body.appendChild(screenshotsUI);
let rect;
if (type === "full-page") {
rect = await this.fetchFullPageBounds(browser);
type = "full_page";
} else {
rect = await this.fetchVisibleBounds(browser);
}
this.recordTelemetryEvent("selected", type, {});
return this.takeScreenshot(browser, dialog, rect);
},
/**
* Take the screenshot and add the image to the dialog box
* @param browser The current browser.
* @param dialog The dialog box to show the screenshot preview.
* @param rect DOMRect containing bounds of the screenshot.
*/
async takeScreenshot(browser, dialog, rect) {
let { canvas, snapshot } = await this.createCanvas(rect, browser);
let newImg = dialog._frame.contentDocument.createElement("img");
let url = canvas.toDataURL();
newImg.id = "placeholder-image";
newImg.src = url;
dialog._frame.contentDocument
.getElementById("preview-image-div")
.appendChild(newImg);
if (Cu.isInAutomation) {
Services.obs.notifyObservers(null, "screenshots-preview-ready");
}
snapshot.close();
},
/**
* Creates a canvas and draws a snapshot of the screenshot on the canvas
* @param box The bounds of screenshots
* @param browser The current browser
* @returns The canvas and snapshot in an object
*/
async createCanvas(box, browser) {
this.cropScreenshotRectIfNeeded(box);
let rect = new DOMRect(box.x1, box.y1, box.width, box.height);
let { devicePixelRatio } = box;
let browsingContext = BrowsingContext.get(browser.browsingContext.id);
let snapshot = await browsingContext.currentWindowGlobal.drawSnapshot(
rect,
devicePixelRatio,
"rgb(255,255,255)"
);
let canvas = browser.ownerDocument.createElementNS(
"http://www.w3.org/1999/xhtml",
"html:canvas"
);
let context = canvas.getContext("2d");
canvas.width = snapshot.width;
canvas.height = snapshot.height;
context.drawImage(snapshot, 0, 0);
return { canvas, snapshot };
},
/**
* Copy the screenshot
* @param region The bounds of the screenshots
* @param browser The current browser
*/
async copyScreenshotFromRegion(region, browser) {
let { canvas, snapshot } = await this.createCanvas(region, browser);
let url = canvas.toDataURL();
this.copyScreenshot(url, browser);
snapshot.close();
this.recordTelemetryEvent("copy", "overlay_copy", {});
},
/**
* Copy the image to the clipboard
* @param dataUrl The image data
*/
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();
this.recordTelemetryEvent("download", "overlay_download", {});
},
/**
* Download the screenshot
* @param title The title of the current page or null and getFilename will get the title
* @param dataUrl The image data
* @param browser The current browser
*/
async downloadScreenshot(title, dataUrl, browser) {
// Guard against missing image data.
if (!dataUrl) {
return;
}
let filename = await getFilename(title, browser);
const targetFile = new lazy.FileUtils.File(filename);
// Create download and track its progress.
try {
const download = await lazy.Downloads.createDownload({
source: dataUrl,
target: targetFile,
});
let isPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate(
browser.ownerGlobal
);
const list = await lazy.Downloads.getList(
isPrivate ? lazy.Downloads.PRIVATE : lazy.Downloads.PUBLIC
);
// add the download to the download list in the Downloads list in the Browser UI
list.add(download);
// Await successful completion of the save via the download manager
await download.start();
} catch (ex) {}
},
recordTelemetryEvent(type, object, args) {
Services.telemetry.recordEvent("screenshots", type, object, null, args);
},
};