Source code

Revision control

Copy as Markdown

Other Tools

/* 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 */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
ScreenshotsOverlay: "resource:///modules/ScreenshotsOverlayChild.sys.mjs",
});
const SCREENSHOTS_PREVENT_CONTENT_EVENTS_PREF =
"screenshots.browser.component.preventContentEvents";
export class ScreenshotsComponentChild extends JSWindowActorChild {
#resizeTask;
#scrollTask;
#overlay;
#preventableEventsAdded = false;
static OVERLAY_EVENTS = [
"click",
"pointerdown",
"pointermove",
"pointerup",
"keyup",
"keydown",
];
// The following events are only listened to so we can prevent them from
// reaching the content page. The events in OVERLAY_EVENTS are also prevented.
static PREVENTABLE_EVENTS = [
"mousemove",
"mousedown",
"mouseup",
"mouseenter",
"mouseover",
"mouseout",
"mouseleave",
"touchstart",
"touchmove",
"touchend",
"dblclick",
"auxclick",
"keypress",
"contextmenu",
"pointerenter",
"pointerover",
"pointerout",
"pointerleave",
];
get overlay() {
return this.#overlay;
}
receiveMessage(message) {
switch (message.name) {
case "Screenshots:ShowOverlay":
return this.startScreenshotsOverlay();
case "Screenshots:HideOverlay":
return this.endScreenshotsOverlay(message.data);
case "Screenshots:isOverlayShowing":
return this.overlay?.initialized;
case "Screenshots:getFullPageBounds":
return this.getFullPageBounds();
case "Screenshots:getVisibleBounds":
return this.getVisibleBounds();
case "Screenshots:getDocumentTitle":
return this.getDocumentTitle();
case "Screenshots:GetMethodsUsed":
return this.getMethodsUsed();
case "Screenshots:RemoveEventListeners":
return this.removeEventListeners();
case "Screenshots:AddEventListeners":
return this.addEventListeners();
case "Screenshots:MoveFocusToContent":
return this.focusOverlay(message.data);
case "Screenshots:ClearFocus":
Services.focus.clearFocus(this.contentWindow);
return null;
}
return null;
}
handleEvent(event) {
if (!event.isTrusted) {
return;
}
// Handle overlay events here
if (
[
...ScreenshotsComponentChild.OVERLAY_EVENTS,
...ScreenshotsComponentChild.PREVENTABLE_EVENTS,
"selectionchange",
].includes(event.type)
) {
if (!this.overlay?.initialized) {
return;
}
// Preventing a pointerdown event throws an error in debug builds.
// Don't prevent the default context menu.
if (!["contextmenu", "pointerdown"].includes(event.type)) {
event.preventDefault();
}
event.stopImmediatePropagation();
this.overlay.handleEvent(event);
return;
}
switch (event.type) {
case "beforeunload":
this.requestCancelScreenshot("Navigation");
break;
case "resize":
if (!this.#resizeTask && this.overlay?.initialized) {
this.#resizeTask = new lazy.DeferredTask(() => {
this.overlay.updateScreenshotsOverlayDimensions("resize");
}, 16);
}
this.#resizeTask.arm();
break;
case "scroll":
if (!this.#scrollTask && this.overlay?.initialized) {
this.#scrollTask = new lazy.DeferredTask(() => {
this.overlay.updateScreenshotsOverlayDimensions("scroll");
}, 16);
}
this.#scrollTask.arm();
break;
case "Screenshots:Close":
this.requestCancelScreenshot(event.detail.reason);
break;
case "Screenshots:Copy":
this.requestCopyScreenshot(event.detail.region);
break;
case "Screenshots:Download":
this.requestDownloadScreenshot(event.detail.region);
break;
case "Screenshots:OverlaySelection": {
let { hasSelection, overlayState } = event.detail;
this.sendOverlaySelection({ hasSelection, overlayState });
break;
}
case "Screenshots:RecordEvent": {
let { eventName, args } = event.detail;
Glean.screenshots[eventName].record(args);
break;
}
case "Screenshots:ShowPanel":
this.sendAsyncMessage("Screenshots:ShowPanel");
break;
case "Screenshots:HidePanel":
this.sendAsyncMessage("Screenshots:HidePanel");
break;
case "Screenshots:FocusPanel":
this.sendAsyncMessage("Screenshots:MoveFocusToParent", event.detail);
break;
}
}
/**
* Send a request to cancel the screenshot to the parent process
*/
requestCancelScreenshot(reason) {
this.sendAsyncMessage("Screenshots:CancelScreenshot", {
closeOverlay: false,
reason,
});
this.endScreenshotsOverlay();
}
/**
* Send a request to copy the screenshots
* @param {Object} region The region dimensions of the screenshot to be copied
*/
requestCopyScreenshot(region) {
region.devicePixelRatio = this.contentWindow.devicePixelRatio;
this.sendAsyncMessage("Screenshots:CopyScreenshot", { region });
this.endScreenshotsOverlay({ doNotResetMethods: true });
}
/**
* Send a request to download the screenshots
* @param {Object} region The region dimensions of the screenshot to be downloaded
*/
requestDownloadScreenshot(region) {
region.devicePixelRatio = this.contentWindow.devicePixelRatio;
this.sendAsyncMessage("Screenshots:DownloadScreenshot", {
title: this.getDocumentTitle(),
region,
});
this.endScreenshotsOverlay({ doNotResetMethods: true });
}
getDocumentTitle() {
return this.document.title;
}
sendOverlaySelection(data) {
this.sendAsyncMessage("Screenshots:OverlaySelection", data);
}
getMethodsUsed() {
let methodsUsed = this.#overlay.methodsUsed;
this.#overlay.resetMethodsUsed();
return methodsUsed;
}
focusOverlay(direction) {
this.contentWindow.focus();
this.#overlay.focus(direction);
}
/**
* Resolves when the document is ready to have an overlay injected into it.
*
* @returns {Promise}
* @resolves {Boolean} true when document is ready or rejects
*/
documentIsReady() {
const document = this.document;
// Some pages take ages to finish loading - if at all.
// We want to respond to enable the screenshots UI as soon that is possible
function readyEnough() {
return (
document.readyState !== "uninitialized" && document.documentElement
);
}
if (readyEnough()) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
function onChange(event) {
if (event.type === "pagehide") {
document.removeEventListener("readystatechange", onChange);
this.contentWindow.removeEventListener("pagehide", onChange);
reject(new Error("document unloaded before it was ready"));
} else if (readyEnough()) {
document.removeEventListener("readystatechange", onChange);
this.contentWindow.removeEventListener("pagehide", onChange);
resolve();
}
}
document.addEventListener("readystatechange", onChange);
this.contentWindow.addEventListener("pagehide", onChange, { once: true });
});
}
addEventListeners() {
this.contentWindow.addEventListener("beforeunload", this);
this.contentWindow.addEventListener("resize", this);
this.contentWindow.addEventListener("scroll", this);
this.addOverlayEventListeners();
}
addOverlayEventListeners() {
let chromeEventHandler = this.docShell.chromeEventHandler;
for (let event of ScreenshotsComponentChild.OVERLAY_EVENTS) {
chromeEventHandler.addEventListener(event, this, true);
}
this.document.addEventListener("selectionchange", this);
if (Services.prefs.getBoolPref(SCREENSHOTS_PREVENT_CONTENT_EVENTS_PREF)) {
for (let event of ScreenshotsComponentChild.PREVENTABLE_EVENTS) {
chromeEventHandler.addEventListener(event, this, true);
}
this.#preventableEventsAdded = true;
}
}
/**
* Wait until the document is ready and then show the screenshots overlay
*
* @returns {Boolean} true when document is ready and the overlay is shown
* otherwise false
*/
async startScreenshotsOverlay() {
try {
await this.documentIsReady();
} catch (ex) {
console.warn(`ScreenshotsComponentChild: ${ex.message}`);
return false;
}
await this.documentIsReady();
let overlay =
this.overlay ||
(this.#overlay = new lazy.ScreenshotsOverlay(this.document));
this.addEventListeners();
overlay.initialize();
return true;
}
removeEventListeners() {
this.contentWindow.removeEventListener("beforeunload", this);
this.contentWindow.removeEventListener("resize", this);
this.contentWindow.removeEventListener("scroll", this);
this.removeOverlayEventListeners();
}
removeOverlayEventListeners() {
let chromeEventHandler = this.docShell.chromeEventHandler;
for (let event of ScreenshotsComponentChild.OVERLAY_EVENTS) {
chromeEventHandler.removeEventListener(event, this, true);
}
this.document.removeEventListener("selectionchange", this);
if (this.#preventableEventsAdded) {
for (let event of ScreenshotsComponentChild.PREVENTABLE_EVENTS) {
chromeEventHandler.removeEventListener(event, this, true);
}
}
this.#preventableEventsAdded = false;
}
/**
* Removes event listeners and the screenshots overlay.
*/
endScreenshotsOverlay(options = {}) {
this.removeEventListeners();
this.overlay?.tearDown(options);
this.#resizeTask?.disarm();
this.#scrollTask?.disarm();
}
didDestroy() {
this.#resizeTask?.disarm();
this.#scrollTask?.disarm();
}
/**
* Gets the full page bounds for a full page screenshot.
*
* @returns { object }
* The device pixel ratio and a DOMRect of the scrollable content bounds.
*
* devicePixelRatio (float):
* The device pixel ratio of the screen
*
* rect (object):
* top (int):
* The scroll top position for the content window.
*
* left (int):
* The scroll left position for the content window.
*
* width (int):
* The scroll width of the content window.
*
* height (int):
* The scroll height of the content window.
*/
getFullPageBounds() {
let {
scrollMinX,
scrollMinY,
scrollWidth,
scrollHeight,
devicePixelRatio,
} = this.#overlay.windowDimensions.dimensions;
let rect = {
left: scrollMinX,
top: scrollMinY,
right: scrollMinX + scrollWidth,
bottom: scrollMinY + scrollHeight,
width: scrollWidth,
height: scrollHeight,
devicePixelRatio,
};
return rect;
}
/**
* Gets the visible page bounds for a visible screenshot.
*
* @returns { object }
* The device pixel ratio and a DOMRect of the current visible
* content bounds.
*
* devicePixelRatio (float):
* The device pixel ratio of the screen
*
* rect (object):
* top (int):
* The top position for the content window.
*
* left (int):
* The left position for the content window.
*
* width (int):
* The width of the content window.
*
* height (int):
* The height of the content window.
*/
getVisibleBounds() {
let {
pageScrollX,
pageScrollY,
clientWidth,
clientHeight,
devicePixelRatio,
} = this.#overlay.windowDimensions.dimensions;
let rect = {
left: pageScrollX,
top: pageScrollY,
right: pageScrollX + clientWidth,
bottom: pageScrollY + clientHeight,
width: clientWidth,
height: clientHeight,
devicePixelRatio,
};
return rect;
}
}