summaryrefslogtreecommitdiffstats
path: root/browser/extensions/screenshots
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--browser/extensions/screenshots/assertIsBlankDocument.js18
-rw-r--r--browser/extensions/screenshots/assertIsTrusted.js24
-rw-r--r--browser/extensions/screenshots/background/analytics.js55
-rw-r--r--browser/extensions/screenshots/background/communication.js69
-rw-r--r--browser/extensions/screenshots/background/deviceInfo.js39
-rw-r--r--browser/extensions/screenshots/background/main.js238
-rw-r--r--browser/extensions/screenshots/background/selectorLoader.js139
-rw-r--r--browser/extensions/screenshots/background/senderror.js144
-rw-r--r--browser/extensions/screenshots/background/startBackground.js123
-rw-r--r--browser/extensions/screenshots/background/takeshot.js85
-rw-r--r--browser/extensions/screenshots/blank.html7
-rw-r--r--browser/extensions/screenshots/blobConverters.js48
-rw-r--r--browser/extensions/screenshots/build/inlineSelectionCss.js667
-rw-r--r--browser/extensions/screenshots/build/selection.js126
-rw-r--r--browser/extensions/screenshots/build/shot.js888
-rw-r--r--browser/extensions/screenshots/build/thumbnailGenerator.js190
-rw-r--r--browser/extensions/screenshots/catcher.js101
-rw-r--r--browser/extensions/screenshots/clipboard.js64
-rw-r--r--browser/extensions/screenshots/domainFromUrl.js32
-rw-r--r--browser/extensions/screenshots/experiments/screenshots/api.js56
-rw-r--r--browser/extensions/screenshots/experiments/screenshots/schema.json35
-rw-r--r--browser/extensions/screenshots/log.js50
-rw-r--r--browser/extensions/screenshots/manifest.json56
-rw-r--r--browser/extensions/screenshots/moz.build60
-rw-r--r--browser/extensions/screenshots/randomString.js19
-rw-r--r--browser/extensions/screenshots/selector/callBackground.js35
-rw-r--r--browser/extensions/screenshots/selector/documentMetadata.js93
-rw-r--r--browser/extensions/screenshots/selector/shooter.js163
-rw-r--r--browser/extensions/screenshots/selector/ui.js904
-rw-r--r--browser/extensions/screenshots/selector/uicontrol.js1026
-rw-r--r--browser/extensions/screenshots/selector/util.js124
-rw-r--r--browser/extensions/screenshots/sitehelper.js63
-rw-r--r--browser/extensions/screenshots/test/browser/browser.ini22
-rw-r--r--browser/extensions/screenshots/test/browser/browser_screenshot_button.js75
-rw-r--r--browser/extensions/screenshots/test/browser/browser_screenshots_dimensions.js109
-rw-r--r--browser/extensions/screenshots/test/browser/browser_screenshots_download.js98
-rw-r--r--browser/extensions/screenshots/test/browser/browser_screenshots_injection.js82
-rw-r--r--browser/extensions/screenshots/test/browser/green2vh.html23
-rw-r--r--browser/extensions/screenshots/test/browser/head.js230
-rw-r--r--browser/extensions/screenshots/test/browser/injection-page.html23
40 files changed, 6403 insertions, 0 deletions
diff --git a/browser/extensions/screenshots/assertIsBlankDocument.js b/browser/extensions/screenshots/assertIsBlankDocument.js
new file mode 100644
index 0000000000..be2371632a
--- /dev/null
+++ b/browser/extensions/screenshots/assertIsBlankDocument.js
@@ -0,0 +1,18 @@
+/* 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/. */
+
+/* globals browser */
+
+/** For use inside an iframe onload function, throws an Error if iframe src is not blank.html
+
+ Should be applied *inside* catcher.watchFunction
+*/
+this.assertIsBlankDocument = function assertIsBlankDocument(doc) {
+ if (doc.documentURI !== browser.runtime.getURL("blank.html")) {
+ const exc = new Error("iframe URL does not match expected blank.html");
+ exc.foundURL = doc.documentURI;
+ throw exc;
+ }
+};
+null;
diff --git a/browser/extensions/screenshots/assertIsTrusted.js b/browser/extensions/screenshots/assertIsTrusted.js
new file mode 100644
index 0000000000..5e2a072f37
--- /dev/null
+++ b/browser/extensions/screenshots/assertIsTrusted.js
@@ -0,0 +1,24 @@
+/* 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/. */
+
+/** For use with addEventListener, assures that any events have event.isTrusted set to true
+ https://developer.mozilla.org/en-US/docs/Web/API/Event/isTrusted
+ Should be applied *inside* catcher.watchFunction
+*/
+this.assertIsTrusted = function assertIsTrusted(handlerFunction) {
+ return function (event) {
+ if (!event) {
+ const exc = new Error("assertIsTrusted did not get an event");
+ exc.noPopup = true;
+ throw exc;
+ }
+ if (!event.isTrusted) {
+ const exc = new Error(`Received untrusted event (type: ${event.type})`);
+ exc.noPopup = true;
+ throw exc;
+ }
+ return handlerFunction.call(this, event);
+ };
+};
+null;
diff --git a/browser/extensions/screenshots/background/analytics.js b/browser/extensions/screenshots/background/analytics.js
new file mode 100644
index 0000000000..20f2c99ffd
--- /dev/null
+++ b/browser/extensions/screenshots/background/analytics.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/. */
+
+/* globals main, browser, catcher, log */
+
+"use strict";
+
+this.analytics = (function () {
+ const exports = {};
+
+ let telemetryEnabled;
+
+ exports.incrementCount = function (scalar) {
+ const allowedScalars = [
+ "download",
+ "upload",
+ "copy",
+ "visible",
+ "full_page",
+ "custom",
+ "element",
+ ];
+ if (!allowedScalars.includes(scalar)) {
+ const err = `incrementCount passed an unrecognized scalar ${scalar}`;
+ log.warn(err);
+ return Promise.resolve();
+ }
+ return browser.telemetry
+ .scalarAdd(`screenshots.${scalar}`, 1)
+ .catch(err => {
+ log.warn(`incrementCount failed with error: ${err}`);
+ });
+ };
+
+ exports.refreshTelemetryPref = function () {
+ return browser.telemetry.canUpload().then(
+ result => {
+ telemetryEnabled = result;
+ },
+ error => {
+ // If there's an error reading the pref, we should assume that we shouldn't send data
+ telemetryEnabled = false;
+ throw error;
+ }
+ );
+ };
+
+ exports.isTelemetryEnabled = function () {
+ catcher.watchPromise(exports.refreshTelemetryPref());
+ return telemetryEnabled;
+ };
+
+ return exports;
+})();
diff --git a/browser/extensions/screenshots/background/communication.js b/browser/extensions/screenshots/background/communication.js
new file mode 100644
index 0000000000..275b8ef7f8
--- /dev/null
+++ b/browser/extensions/screenshots/background/communication.js
@@ -0,0 +1,69 @@
+/* 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/. */
+
+/* globals catcher, log */
+
+"use strict";
+
+this.communication = (function () {
+ const exports = {};
+
+ const registeredFunctions = {};
+
+ exports.onMessage = catcher.watchFunction((req, sender, sendResponse) => {
+ if (!(req.funcName in registeredFunctions)) {
+ log.error(`Received unknown internal message type ${req.funcName}`);
+ sendResponse({ type: "error", name: "Unknown message type" });
+ return;
+ }
+ if (!Array.isArray(req.args)) {
+ log.error("Received message with no .args list");
+ sendResponse({ type: "error", name: "No .args" });
+ return;
+ }
+ const func = registeredFunctions[req.funcName];
+ let result;
+ try {
+ req.args.unshift(sender);
+ result = func.apply(null, req.args);
+ } catch (e) {
+ log.error(`Error in ${req.funcName}:`, e, e.stack);
+ // FIXME: should consider using makeError from catcher here:
+ sendResponse({
+ type: "error",
+ message: e + "",
+ errorCode: e.errorCode,
+ popupMessage: e.popupMessage,
+ });
+ return;
+ }
+ if (result && result.then) {
+ result
+ .then(concreteResult => {
+ sendResponse({ type: "success", value: concreteResult });
+ })
+ .catch(errorResult => {
+ log.error(
+ `Promise error in ${req.funcName}:`,
+ errorResult,
+ errorResult && errorResult.stack
+ );
+ sendResponse({
+ type: "error",
+ message: errorResult + "",
+ errorCode: errorResult.errorCode,
+ popupMessage: errorResult.popupMessage,
+ });
+ });
+ return;
+ }
+ sendResponse({ type: "success", value: result });
+ });
+
+ exports.register = function (name, func) {
+ registeredFunctions[name] = func;
+ };
+
+ return exports;
+})();
diff --git a/browser/extensions/screenshots/background/deviceInfo.js b/browser/extensions/screenshots/background/deviceInfo.js
new file mode 100644
index 0000000000..2a4b15f5ff
--- /dev/null
+++ b/browser/extensions/screenshots/background/deviceInfo.js
@@ -0,0 +1,39 @@
+/* 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/. */
+
+/* globals catcher, browser, navigator */
+
+"use strict";
+
+this.deviceInfo = (function () {
+ const manifest = browser.runtime.getManifest();
+
+ let platformInfo = {};
+ catcher.watchPromise(
+ browser.runtime.getPlatformInfo().then(info => {
+ platformInfo = info;
+ })
+ );
+
+ return function deviceInfo() {
+ let match = navigator.userAgent.match(/Chrom(?:e|ium)\/([0-9.]{1,1000})/);
+ const chromeVersion = match ? match[1] : null;
+ match = navigator.userAgent.match(/Firefox\/([0-9.]{1,1000})/);
+ const firefoxVersion = match ? match[1] : null;
+ const appName = chromeVersion ? "chrome" : "firefox";
+
+ return {
+ addonVersion: manifest.version,
+ platform: platformInfo.os,
+ architecture: platformInfo.arch,
+ version: firefoxVersion || chromeVersion,
+ // These don't seem to apply to Chrome:
+ // build: system.build,
+ // platformVersion: system.platformVersion,
+ userAgent: navigator.userAgent,
+ appVendor: appName,
+ appName,
+ };
+ };
+})();
diff --git a/browser/extensions/screenshots/background/main.js b/browser/extensions/screenshots/background/main.js
new file mode 100644
index 0000000000..594ba798ee
--- /dev/null
+++ b/browser/extensions/screenshots/background/main.js
@@ -0,0 +1,238 @@
+/* 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/. */
+
+/* globals browser, getStrings, selectorLoader, analytics, communication, catcher, log, senderror, startBackground, blobConverters, startSelectionWithOnboarding */
+
+"use strict";
+
+this.main = (function () {
+ const exports = {};
+
+ const { incrementCount } = analytics;
+
+ const manifest = browser.runtime.getManifest();
+ let backend;
+
+ exports.setBackend = function (newBackend) {
+ backend = newBackend;
+ backend = backend.replace(/\/*$/, "");
+ };
+
+ exports.getBackend = function () {
+ return backend;
+ };
+
+ communication.register("getBackend", () => {
+ return backend;
+ });
+
+ for (const permission of manifest.permissions) {
+ if (/^https?:\/\//.test(permission)) {
+ exports.setBackend(permission);
+ break;
+ }
+ }
+
+ function toggleSelector(tab) {
+ return analytics
+ .refreshTelemetryPref()
+ .then(() => selectorLoader.toggle(tab.id))
+ .catch(error => {
+ if (
+ error.message &&
+ /Missing host permission for the tab/.test(error.message)
+ ) {
+ error.noReport = true;
+ }
+ error.popupMessage = "UNSHOOTABLE_PAGE";
+ throw error;
+ });
+ }
+
+ // This is called by startBackground.js, where is registered as a click
+ // handler for the webextension page action.
+ exports.onClicked = catcher.watchFunction(tab => {
+ _startShotFlow(tab, "toolbar-button");
+ });
+
+ exports.onClickedContextMenu = catcher.watchFunction(tab => {
+ _startShotFlow(tab, "context-menu");
+ });
+
+ exports.onShortcut = catcher.watchFunction(tab => {
+ _startShotFlow(tab, "keyboard-shortcut");
+ });
+
+ const _startShotFlow = (tab, inputType) => {
+ if (!tab) {
+ // Not in a page/tab context, ignore
+ return;
+ }
+ if (!urlEnabled(tab.url)) {
+ senderror.showError({
+ popupMessage: "UNSHOOTABLE_PAGE",
+ });
+ return;
+ }
+
+ catcher.watchPromise(
+ toggleSelector(tab).catch(error => {
+ throw error;
+ })
+ );
+ };
+
+ function urlEnabled(url) {
+ // Allow screenshots on urls related to web pages in reader mode.
+ if (url && url.startsWith("about:reader?url=")) {
+ return true;
+ }
+ if (
+ isShotOrMyShotPage(url) ||
+ /^(?:about|data|moz-extension):/i.test(url) ||
+ isBlacklistedUrl(url)
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+ function isShotOrMyShotPage(url) {
+ // It's okay to take a shot of any pages except shot pages and My Shots
+ if (!url.startsWith(backend)) {
+ return false;
+ }
+ const path = url
+ .substr(backend.length)
+ .replace(/^\/*/, "")
+ .replace(/[?#].*/, "");
+ if (path === "shots") {
+ return true;
+ }
+ if (/^[^/]{1,4000}\/[^/]{1,4000}$/.test(path)) {
+ // Blocks {:id}/{:domain}, but not /, /privacy, etc
+ return true;
+ }
+ return false;
+ }
+
+ function isBlacklistedUrl(url) {
+ // These specific domains are not allowed for general WebExtension permission reasons
+ // Discussion: https://bugzilla.mozilla.org/show_bug.cgi?id=1310082
+ // List of domains copied from: https://searchfox.org/mozilla-central/source/browser/app/permissions#18-19
+ // Note we disable it here to be informative, the security check is done in WebExtension code
+ const badDomains = ["testpilot.firefox.com"];
+ let domain = url.replace(/^https?:\/\//i, "");
+ domain = domain.replace(/\/.*/, "").replace(/:.*/, "");
+ domain = domain.toLowerCase();
+ return badDomains.includes(domain);
+ }
+
+ communication.register("getStrings", (sender, ids) => {
+ return getStrings(ids.map(id => ({ id })));
+ });
+
+ communication.register("captureTelemetry", (sender, ...args) => {
+ catcher.watchPromise(incrementCount(...args));
+ });
+
+ communication.register("openShot", async (sender, { url, copied }) => {
+ if (copied) {
+ const id = crypto.randomUUID();
+ const [title, message] = await getStrings([
+ { id: "screenshots-notification-link-copied-title" },
+ { id: "screenshots-notification-link-copied-details" },
+ ]);
+ return browser.notifications.create(id, {
+ type: "basic",
+ iconUrl: "chrome://browser/content/screenshots/copied-notification.svg",
+ title,
+ message,
+ });
+ }
+ return null;
+ });
+
+ communication.register("copyShotToClipboard", async (sender, blob) => {
+ let buffer = await blobConverters.blobToArray(blob);
+ await browser.clipboard.setImageData(buffer, blob.type.split("/", 2)[1]);
+
+ const [title, message] = await getStrings([
+ { id: "screenshots-notification-image-copied-title" },
+ { id: "screenshots-notification-image-copied-details" },
+ ]);
+
+ catcher.watchPromise(incrementCount("copy"));
+ return browser.notifications.create({
+ type: "basic",
+ iconUrl: "chrome://browser/content/screenshots/copied-notification.svg",
+ title,
+ message,
+ });
+ });
+
+ communication.register("downloadShot", (sender, info) => {
+ // 'data:' urls don't work directly, let's use a Blob
+ // see http://stackoverflow.com/questions/40269862/save-data-uri-as-file-using-downloads-download-api
+ const blob = blobConverters.dataUrlToBlob(info.url);
+ const url = URL.createObjectURL(blob);
+ let downloadId;
+ const onChangedCallback = catcher.watchFunction(function (change) {
+ if (!downloadId || downloadId !== change.id) {
+ return;
+ }
+ if (change.state && change.state.current !== "in_progress") {
+ URL.revokeObjectURL(url);
+ browser.downloads.onChanged.removeListener(onChangedCallback);
+ }
+ });
+ browser.downloads.onChanged.addListener(onChangedCallback);
+ catcher.watchPromise(incrementCount("download"));
+ return browser.windows.getLastFocused().then(windowInfo => {
+ return browser.downloads
+ .download({
+ url,
+ incognito: windowInfo.incognito,
+ filename: info.filename,
+ })
+ .catch(error => {
+ // We are not logging error message when user cancels download
+ if (error && error.message && !error.message.includes("canceled")) {
+ log.error(error.message);
+ }
+ })
+ .then(id => {
+ downloadId = id;
+ });
+ });
+ });
+
+ communication.register("abortStartShot", () => {
+ // Note, we only show the error but don't report it, as we know that we can't
+ // take shots of these pages:
+ senderror.showError({
+ popupMessage: "UNSHOOTABLE_PAGE",
+ });
+ });
+
+ // A Screenshots page wants us to start/force onboarding
+ communication.register("requestOnboarding", sender => {
+ return startSelectionWithOnboarding(sender.tab);
+ });
+
+ communication.register("getPlatformOs", () => {
+ return catcher.watchPromise(
+ browser.runtime.getPlatformInfo().then(platformInfo => {
+ return platformInfo.os;
+ })
+ );
+ });
+
+ // This allows the web site show notifications through sitehelper.js
+ communication.register("showNotification", (sender, notification) => {
+ return browser.notifications.create(notification);
+ });
+
+ return exports;
+})();
diff --git a/browser/extensions/screenshots/background/selectorLoader.js b/browser/extensions/screenshots/background/selectorLoader.js
new file mode 100644
index 0000000000..4749f73b30
--- /dev/null
+++ b/browser/extensions/screenshots/background/selectorLoader.js
@@ -0,0 +1,139 @@
+/* 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/. */
+
+/* globals browser, catcher, communication, log, main */
+
+"use strict";
+
+// eslint-disable-next-line no-var
+var global = this;
+
+this.selectorLoader = (function () {
+ const exports = {};
+
+ // These modules are loaded in order, first standardScripts and then selectorScripts
+ // The order is important due to dependencies
+ const standardScripts = [
+ "log.js",
+ "catcher.js",
+ "assertIsTrusted.js",
+ "assertIsBlankDocument.js",
+ "blobConverters.js",
+ "background/selectorLoader.js",
+ "selector/callBackground.js",
+ "selector/util.js",
+ ];
+
+ const selectorScripts = [
+ "clipboard.js",
+ "build/selection.js",
+ "build/shot.js",
+ "randomString.js",
+ "domainFromUrl.js",
+ "build/inlineSelectionCss.js",
+ "selector/documentMetadata.js",
+ "selector/ui.js",
+ "selector/shooter.js",
+ "selector/uicontrol.js",
+ ];
+
+ exports.unloadIfLoaded = function (tabId) {
+ return browser.tabs
+ .executeScript(tabId, {
+ code: "this.selectorLoader && this.selectorLoader.unloadModules()",
+ runAt: "document_start",
+ })
+ .then(result => {
+ return result && result[0];
+ });
+ };
+
+ exports.testIfLoaded = function (tabId) {
+ if (loadingTabs.has(tabId)) {
+ return true;
+ }
+ return browser.tabs
+ .executeScript(tabId, {
+ code: "!!this.selectorLoader",
+ runAt: "document_start",
+ })
+ .then(result => {
+ return result && result[0];
+ });
+ };
+
+ const loadingTabs = new Set();
+
+ exports.loadModules = function (tabId) {
+ loadingTabs.add(tabId);
+ catcher.watchPromise(
+ executeModules(tabId, standardScripts.concat(selectorScripts)).then(
+ () => {
+ loadingTabs.delete(tabId);
+ }
+ )
+ );
+ };
+
+ function executeModules(tabId, scripts) {
+ let lastPromise = Promise.resolve(null);
+ scripts.forEach(file => {
+ lastPromise = lastPromise.then(() => {
+ return browser.tabs
+ .executeScript(tabId, {
+ file,
+ runAt: "document_start",
+ })
+ .catch(error => {
+ log.error("error in script:", file, error);
+ error.scriptName = file;
+ throw error;
+ });
+ });
+ });
+ return lastPromise.then(
+ () => {
+ log.debug("finished loading scripts:", scripts.join(" "));
+ },
+ error => {
+ exports.unloadIfLoaded(tabId);
+ catcher.unhandled(error);
+ throw error;
+ }
+ );
+ }
+
+ exports.unloadModules = function () {
+ const watchFunction = catcher.watchFunction;
+ const allScripts = standardScripts.concat(selectorScripts);
+ const moduleNames = allScripts.map(filename =>
+ filename.replace(/^.*\//, "").replace(/\.js$/, "")
+ );
+ moduleNames.reverse();
+ for (const moduleName of moduleNames) {
+ const moduleObj = global[moduleName];
+ if (moduleObj && moduleObj.unload) {
+ try {
+ watchFunction(moduleObj.unload)();
+ } catch (e) {
+ // ignore (watchFunction handles it)
+ }
+ }
+ delete global[moduleName];
+ }
+ return true;
+ };
+
+ exports.toggle = function (tabId) {
+ return exports.unloadIfLoaded(tabId).then(wasLoaded => {
+ if (!wasLoaded) {
+ exports.loadModules(tabId);
+ }
+ return !wasLoaded;
+ });
+ };
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/background/senderror.js b/browser/extensions/screenshots/background/senderror.js
new file mode 100644
index 0000000000..3d5eae5ec6
--- /dev/null
+++ b/browser/extensions/screenshots/background/senderror.js
@@ -0,0 +1,144 @@
+/* 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/. */
+
+/* globals startBackground, analytics, communication, catcher, log, browser, getStrings */
+
+"use strict";
+
+this.senderror = (function () {
+ const exports = {};
+
+ // Do not show an error more than every ERROR_TIME_LIMIT milliseconds:
+ const ERROR_TIME_LIMIT = 3000;
+
+ const messages = {
+ REQUEST_ERROR: {
+ titleKey: "screenshots-request-error-title",
+ infoKey: "screenshots-request-error-details",
+ },
+ CONNECTION_ERROR: {
+ titleKey: "screenshots-connection-error-title",
+ infoKey: "screenshots-connection-error-details",
+ },
+ LOGIN_ERROR: {
+ titleKey: "screenshots-request-error-title",
+ infoKey: "screenshots-login-error-details",
+ },
+ LOGIN_CONNECTION_ERROR: {
+ titleKey: "screenshots-connection-error-title",
+ infoKey: "screenshots-connection-error-details",
+ },
+ UNSHOOTABLE_PAGE: {
+ titleKey: "screenshots-unshootable-page-error-title",
+ infoKey: "screenshots-unshootable-page-error-details",
+ },
+ EMPTY_SELECTION: {
+ titleKey: "screenshots-empty-selection-error-title",
+ },
+ PRIVATE_WINDOW: {
+ titleKey: "screenshots-private-window-error-title",
+ infoKey: "screenshots-private-window-error-details",
+ },
+ generic: {
+ titleKey: "screenshots-generic-error-title",
+ infoKey: "screenshots-generic-error-details",
+ showMessage: true,
+ },
+ };
+
+ communication.register("reportError", (sender, error) => {
+ catcher.unhandled(error);
+ });
+
+ let lastErrorTime;
+
+ exports.showError = async function (error) {
+ if (lastErrorTime && Date.now() - lastErrorTime < ERROR_TIME_LIMIT) {
+ return;
+ }
+ lastErrorTime = Date.now();
+ const id = crypto.randomUUID();
+ let popupMessage = error.popupMessage || "generic";
+ if (!messages[popupMessage]) {
+ popupMessage = "generic";
+ }
+
+ let item = messages[popupMessage];
+ if (!("title" in item)) {
+ let keys = [{ id: item.titleKey }];
+ if ("infoKey" in item) {
+ keys.push({ id: item.infoKey });
+ }
+
+ [item.title, item.info] = await getStrings(keys);
+ }
+
+ let title = item.title;
+ let message = item.info || "";
+ const showMessage = item.showMessage;
+ if (error.message && showMessage) {
+ if (message) {
+ message += "\n" + error.message;
+ } else {
+ message = error.message;
+ }
+ }
+ if (Date.now() - startBackground.startTime > 5 * 1000) {
+ browser.notifications.create(id, {
+ type: "basic",
+ // FIXME: need iconUrl for an image, see #2239
+ title,
+ message,
+ });
+ }
+ };
+
+ exports.reportError = function (e) {
+ if (!analytics.isTelemetryEnabled()) {
+ log.error("Telemetry disabled. Not sending critical error:", e);
+ return;
+ }
+ const exception = new Error(e.message);
+ exception.stack = e.multilineStack || e.stack || undefined;
+
+ // To improve Sentry reporting & grouping, replace the
+ // moz-extension://$uuid base URL with a generic resource:// URL.
+ if (exception.stack) {
+ exception.stack = exception.stack.replace(
+ /moz-extension:\/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g,
+ "resource://screenshots-addon"
+ );
+ }
+ const rest = {};
+ for (const attr in e) {
+ if (
+ ![
+ "name",
+ "message",
+ "stack",
+ "multilineStack",
+ "popupMessage",
+ "version",
+ "sentryPublicDSN",
+ "help",
+ "fromMakeError",
+ ].includes(attr)
+ ) {
+ rest[attr] = e[attr];
+ }
+ }
+ rest.stack = exception.stack;
+ };
+
+ catcher.registerHandler(errorObj => {
+ if (!errorObj.noPopup) {
+ exports.showError(errorObj);
+ }
+ if (!errorObj.noReport) {
+ exports.reportError(errorObj);
+ }
+ });
+
+ return exports;
+})();
diff --git a/browser/extensions/screenshots/background/startBackground.js b/browser/extensions/screenshots/background/startBackground.js
new file mode 100644
index 0000000000..5d35db3638
--- /dev/null
+++ b/browser/extensions/screenshots/background/startBackground.js
@@ -0,0 +1,123 @@
+/* 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/. */
+
+/* globals browser, main, communication, manifest */
+
+/* This file handles:
+ clicks on the WebExtension page action
+ browser.contextMenus.onClicked
+ browser.runtime.onMessage
+ and loads the rest of the background page in response to those events, forwarding
+ the events to main.onClicked, main.onClickedContextMenu, or communication.onMessage
+*/
+
+const startTime = Date.now();
+
+// Set up to be able to use fluent:
+(function () {
+ let link = document.createElement("link");
+ link.setAttribute("rel", "localization");
+ link.setAttribute("href", "browser/screenshots.ftl");
+ document.head.appendChild(link);
+
+ link = document.createElement("link");
+ link.setAttribute("rel", "localization");
+ link.setAttribute("href", "toolkit/branding/brandings.ftl");
+ document.head.appendChild(link);
+})();
+
+this.getStrings = async function (ids) {
+ if (document.readyState != "complete") {
+ await new Promise(resolve =>
+ window.addEventListener("load", resolve, { once: true })
+ );
+ }
+ await document.l10n.ready;
+ return document.l10n.formatValues(ids);
+};
+
+let zoomFactor = 1;
+this.getZoomFactor = function () {
+ return zoomFactor;
+};
+
+this.startBackground = (function () {
+ const exports = { startTime };
+
+ const backgroundScripts = [
+ "log.js",
+ "catcher.js",
+ "blobConverters.js",
+ "background/selectorLoader.js",
+ "background/communication.js",
+ "background/senderror.js",
+ "build/shot.js",
+ "build/thumbnailGenerator.js",
+ "background/analytics.js",
+ "background/deviceInfo.js",
+ "background/takeshot.js",
+ "background/main.js",
+ ];
+
+ browser.experiments.screenshots.onScreenshotCommand.addListener(
+ async type => {
+ try {
+ let [[tab]] = await Promise.all([
+ browser.tabs.query({ currentWindow: true, active: true }),
+ loadIfNecessary(),
+ ]);
+ zoomFactor = await browser.tabs.getZoom(tab.id);
+ if (type === "contextMenu") {
+ main.onClickedContextMenu(tab);
+ } else if (type === "toolbar" || type === "quickaction") {
+ main.onClicked(tab);
+ } else if (type === "shortcut") {
+ main.onShortcut(tab);
+ }
+ } catch (error) {
+ console.error("Error loading Screenshots:", error);
+ }
+ }
+ );
+
+ browser.runtime.onMessage.addListener((req, sender, sendResponse) => {
+ loadIfNecessary()
+ .then(() => {
+ return communication.onMessage(req, sender, sendResponse);
+ })
+ .catch(error => {
+ console.error("Error loading Screenshots:", error);
+ });
+ return true;
+ });
+
+ let loadedPromise;
+
+ function loadIfNecessary() {
+ if (loadedPromise) {
+ return loadedPromise;
+ }
+ loadedPromise = Promise.resolve();
+ backgroundScripts.forEach(script => {
+ loadedPromise = loadedPromise.then(() => {
+ return new Promise((resolve, reject) => {
+ const tag = document.createElement("script");
+ tag.src = browser.runtime.getURL(script);
+ tag.onload = () => {
+ resolve();
+ };
+ tag.onerror = error => {
+ const exc = new Error(`Error loading script: ${error.message}`);
+ exc.scriptName = script;
+ reject(exc);
+ };
+ document.head.appendChild(tag);
+ });
+ });
+ });
+ return loadedPromise;
+ }
+
+ return exports;
+})();
diff --git a/browser/extensions/screenshots/background/takeshot.js b/browser/extensions/screenshots/background/takeshot.js
new file mode 100644
index 0000000000..c8e229247b
--- /dev/null
+++ b/browser/extensions/screenshots/background/takeshot.js
@@ -0,0 +1,85 @@
+/* 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/. */
+
+/* globals browser, communication, getZoomFactor, shot, main, catcher, analytics, blobConverters, thumbnailGenerator */
+
+"use strict";
+
+this.takeshot = (function () {
+ const exports = {};
+ const MAX_CANVAS_DIMENSION = 32767;
+
+ communication.register(
+ "screenshotPage",
+ (sender, selectedPos, screenshotType, devicePixelRatio) => {
+ return screenshotPage(selectedPos, screenshotType, devicePixelRatio);
+ }
+ );
+
+ communication.register("getZoomFactor", sender => {
+ return getZoomFactor();
+ });
+
+ function screenshotPage(pos, screenshotType, devicePixelRatio) {
+ pos.width = Math.min(pos.right - pos.left, MAX_CANVAS_DIMENSION);
+ pos.height = Math.min(pos.bottom - pos.top, MAX_CANVAS_DIMENSION);
+
+ // If we are printing the full page or a truncated full page,
+ // we must pass in this rectangle to preview the entire image
+ let options = { format: "png" };
+ if (
+ screenshotType === "fullPage" ||
+ screenshotType === "fullPageTruncated"
+ ) {
+ let rectangle = {
+ x: 0,
+ y: 0,
+ width: pos.width,
+ height: pos.height,
+ };
+ options.rect = rectangle;
+ options.resetScrollPosition = true;
+ } else if (screenshotType != "visible") {
+ let rectangle = {
+ x: pos.left,
+ y: pos.top,
+ width: pos.width,
+ height: pos.height,
+ };
+ options.rect = rectangle;
+ }
+
+ return catcher.watchPromise(
+ browser.tabs.captureTab(null, options).then(dataUrl => {
+ const image = new Image();
+ image.src = dataUrl;
+ return new Promise((resolve, reject) => {
+ image.onload = catcher.watchFunction(() => {
+ const xScale = devicePixelRatio;
+ const yScale = devicePixelRatio;
+ const canvas = document.createElement("canvas");
+ canvas.height = pos.height * yScale;
+ canvas.width = pos.width * xScale;
+ const context = canvas.getContext("2d");
+ context.drawImage(
+ image,
+ 0,
+ 0,
+ pos.width * xScale,
+ pos.height * yScale,
+ 0,
+ 0,
+ pos.width * xScale,
+ pos.height * yScale
+ );
+ const result = canvas.toDataURL();
+ resolve(result);
+ });
+ });
+ })
+ );
+ }
+
+ return exports;
+})();
diff --git a/browser/extensions/screenshots/blank.html b/browser/extensions/screenshots/blank.html
new file mode 100644
index 0000000000..fcd85bbe86
--- /dev/null
+++ b/browser/extensions/screenshots/blank.html
@@ -0,0 +1,7 @@
+<!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/. -->
+
+<meta charset="utf-8" />
diff --git a/browser/extensions/screenshots/blobConverters.js b/browser/extensions/screenshots/blobConverters.js
new file mode 100644
index 0000000000..4e727ff271
--- /dev/null
+++ b/browser/extensions/screenshots/blobConverters.js
@@ -0,0 +1,48 @@
+/* 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/. */
+
+this.blobConverters = (function () {
+ const exports = {};
+
+ exports.dataUrlToBlob = function (url) {
+ const binary = atob(url.split(",", 2)[1]);
+ let contentType = exports.getTypeFromDataUrl(url);
+ if (contentType !== "image/png" && contentType !== "image/jpeg") {
+ contentType = "image/png";
+ }
+ const data = Uint8Array.from(binary, char => char.charCodeAt(0));
+ const blob = new Blob([data], { type: contentType });
+ return blob;
+ };
+
+ exports.getTypeFromDataUrl = function (url) {
+ let contentType = url.split(",", 1)[0];
+ contentType = contentType.split(";", 1)[0];
+ contentType = contentType.split(":", 2)[1];
+ return contentType;
+ };
+
+ exports.blobToArray = function (blob) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.addEventListener("loadend", function () {
+ resolve(reader.result);
+ });
+ reader.readAsArrayBuffer(blob);
+ });
+ };
+
+ exports.blobToDataUrl = function (blob) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.addEventListener("loadend", function () {
+ resolve(reader.result);
+ });
+ reader.readAsDataURL(blob);
+ });
+ };
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/build/inlineSelectionCss.js b/browser/extensions/screenshots/build/inlineSelectionCss.js
new file mode 100644
index 0000000000..fa31b642df
--- /dev/null
+++ b/browser/extensions/screenshots/build/inlineSelectionCss.js
@@ -0,0 +1,667 @@
+/* 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/. */
+
+/* Created from build/server/static/css/inline-selection.css */
+window.inlineSelectionCss = `
+.button, .highlight-button-cancel, .highlight-button-download, .highlight-button-copy {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ column-gap: 8px;
+ 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.hidden, .hidden.highlight-button-cancel, .hidden.highlight-button-download, .hidden.highlight-button-copy {
+ display: none; }
+ .button.small, .small.highlight-button-cancel, .small.highlight-button-download, .small.highlight-button-copy {
+ height: 32px;
+ line-height: 32px;
+ padding: 0 8px; }
+ .button.active, .active.highlight-button-cancel, .active.highlight-button-download, .active.highlight-button-copy {
+ background-color: #dedede; }
+ .button.tiny, .tiny.highlight-button-cancel, .tiny.highlight-button-download, .tiny.highlight-button-copy {
+ font-size: 14px;
+ height: 26px;
+ border: 1px solid #c7c7c7; }
+ .button.tiny:hover, .tiny.highlight-button-cancel:hover, .tiny.highlight-button-download:hover, .tiny.highlight-button-copy:hover, .button.tiny:focus, .tiny.highlight-button-cancel:focus, .tiny.highlight-button-download:focus, .tiny.highlight-button-copy:focus {
+ background: #ededf0;
+ border-color: #989898; }
+ .button.tiny:active, .tiny.highlight-button-cancel:active, .tiny.highlight-button-download:active, .tiny.highlight-button-copy:active {
+ background: #dedede;
+ border-color: #989898; }
+ .button.block-button, .block-button.highlight-button-cancel, .block-button.highlight-button-download, .block-button.highlight-button-copy {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ border: 0;
+ border-inline-end: 1px solid #c7c7c7;
+ box-shadow: 0;
+ 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.highlight-button-cancel, .block-button.highlight-button-download, .block-button.highlight-button-copy {
+ justify-content: flex-start;
+ font-size: 16px;
+ height: 72px;
+ margin-inline-end: 10px;
+ padding: 0 5px; } }
+ .button.block-button:hover, .block-button.highlight-button-cancel:hover, .block-button.highlight-button-download:hover, .block-button.highlight-button-copy:hover {
+ background: #ededf0; }
+ .button.block-button:active, .block-button.highlight-button-cancel:active, .block-button.highlight-button-download:active, .block-button.highlight-button-copy:active {
+ background: #dedede; }
+ .button.download, .download.highlight-button-cancel, .download.highlight-button-download, .download.highlight-button-copy, .button.edit, .edit.highlight-button-cancel, .edit.highlight-button-download, .edit.highlight-button-copy, .button.trash, .trash.highlight-button-cancel, .trash.highlight-button-download, .trash.highlight-button-copy, .button.share, .share.highlight-button-cancel, .share.highlight-button-download, .share.highlight-button-copy, .button.flag, .flag.highlight-button-cancel, .flag.highlight-button-download, .flag.highlight-button-copy {
+ 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-cancel, .download.highlight-button-download, .download.highlight-button-copy {
+ background-image: url("chrome://browser/content/screenshots/download.svg"); }
+ .button.download:hover, .download.highlight-button-cancel:hover, .download.highlight-button-download:hover, .download.highlight-button-copy:hover {
+ background-color: #ededf0; }
+ .button.download:active, .download.highlight-button-cancel:active, .download.highlight-button-download:active, .download.highlight-button-copy:active {
+ background-color: #dedede; }
+ .button.share, .share.highlight-button-cancel, .share.highlight-button-download, .share.highlight-button-copy {
+ background-image: url("../img/icon-share.svg"); }
+ .button.share:hover, .share.highlight-button-cancel:hover, .share.highlight-button-download:hover, .share.highlight-button-copy:hover {
+ background-color: #ededf0; }
+ .button.share.active, .share.active.highlight-button-cancel, .share.active.highlight-button-download, .share.active.highlight-button-copy, .button.share:active, .share.highlight-button-cancel:active, .share.highlight-button-download:active, .share.highlight-button-copy:active {
+ background-color: #dedede; }
+ .button.share.newicon, .share.newicon.highlight-button-cancel, .share.newicon.highlight-button-download, .share.newicon.highlight-button-copy {
+ background-image: url("../img/icon-share-alternate.svg"); }
+ .button.trash, .trash.highlight-button-cancel, .trash.highlight-button-download, .trash.highlight-button-copy {
+ background-image: url("../img/icon-trash.svg"); }
+ .button.trash:hover, .trash.highlight-button-cancel:hover, .trash.highlight-button-download:hover, .trash.highlight-button-copy:hover {
+ background-color: #ededf0; }
+ .button.trash:active, .trash.highlight-button-cancel:active, .trash.highlight-button-download:active, .trash.highlight-button-copy:active {
+ background-color: #dedede; }
+ .button.edit, .edit.highlight-button-cancel, .edit.highlight-button-download, .edit.highlight-button-copy {
+ background-image: url("../img/icon-edit.svg"); }
+ .button.edit:hover, .edit.highlight-button-cancel:hover, .edit.highlight-button-download:hover, .edit.highlight-button-copy:hover {
+ background-color: #ededf0; }
+ .button.edit:active, .edit.highlight-button-cancel:active, .edit.highlight-button-download:active, .edit.highlight-button-copy:active {
+ background-color: #dedede; }
+
+.app-body {
+ background: #f9f9fa;
+ color: #38383d; }
+ .app-body a {
+ color: #0a84ff; }
+
+.highlight-color-scheme {
+ background: #0a84ff;
+ color: #fff; }
+ .highlight-color-scheme a {
+ color: #fff;
+ text-decoration: underline; }
+
+.alt-color-scheme {
+ background: #38383d;
+ color: #f9f9fa; }
+ .alt-color-scheme h1 {
+ color: #6f7fb6; }
+ .alt-color-scheme a {
+ color: #e1e1e6;
+ text-decoration: underline; }
+
+.button.primary, .primary.highlight-button-cancel, .highlight-button-download, .primary.highlight-button-copy {
+ background-color: #0a84ff;
+ color: #fff; }
+ .button.primary:hover, .primary.highlight-button-cancel:hover, .highlight-button-download:hover, .primary.highlight-button-copy:hover, .button.primary:focus, .primary.highlight-button-cancel:focus, .highlight-button-download:focus, .primary.highlight-button-copy:focus {
+ background-color: #0072e5; }
+ .button.primary:active, .primary.highlight-button-cancel:active, .highlight-button-download:active, .primary.highlight-button-copy:active {
+ background-color: #0065cc; }
+
+.button.secondary, .highlight-button-cancel, .secondary.highlight-button-download, .highlight-button-copy {
+ background-color: #f9f9fa;
+ color: #38383d; }
+ .button.secondary:hover, .highlight-button-cancel:hover, .secondary.highlight-button-download:hover, .highlight-button-copy:hover {
+ background-color: #ededf0; }
+ .button.secondary:active, .highlight-button-cancel:active, .secondary.highlight-button-download:active, .highlight-button-copy:active {
+ background-color: #dedede; }
+
+.button.transparent, .transparent.highlight-button-cancel, .transparent.highlight-button-download, .transparent.highlight-button-copy {
+ background-color: transparent;
+ color: #38383d; }
+ .button.transparent:hover, .transparent.highlight-button-cancel:hover, .transparent.highlight-button-download:hover, .transparent.highlight-button-copy:hover {
+ background-color: #ededf0; }
+ .button.transparent:focus, .transparent.highlight-button-cancel:focus, .transparent.highlight-button-download:focus, .transparent.highlight-button-copy:focus, .button.transparent:active, .transparent.highlight-button-cancel:active, .transparent.highlight-button-download:active, .transparent.highlight-button-copy:active {
+ background-color: #dedede; }
+
+.button.warning, .warning.highlight-button-cancel, .warning.highlight-button-download, .warning.highlight-button-copy {
+ color: #fff;
+ background: #d92215; }
+ .button.warning:hover, .warning.highlight-button-cancel:hover, .warning.highlight-button-download:hover, .warning.highlight-button-copy:hover, .button.warning:focus, .warning.highlight-button-cancel:focus, .warning.highlight-button-download:focus, .warning.highlight-button-copy:focus {
+ background: #b81d12; }
+ .button.warning:active, .warning.highlight-button-cancel:active, .warning.highlight-button-download:active, .warning.highlight-button-copy:active {
+ background: #a11910; }
+
+.subtitle-link {
+ color: #0a84ff; }
+
+.loader {
+ background: rgba(12, 12, 13, 0.2);
+ border-radius: 2px;
+ height: 4px;
+ overflow: hidden;
+ position: relative;
+ width: 200px; }
+
+.loader-inner {
+ animation: bounce infinite alternate 1250ms cubic-bezier(0.7, 0, 0.3, 1);
+ background: #45a1ff;
+ border-radius: 2px;
+ height: 4px;
+ transform: translateX(-40px);
+ width: 50px; }
+
+@keyframes bounce {
+ 0% {
+ transform: translateX(-40px); }
+ 100% {
+ transform: translate(190px); } }
+
+@keyframes fade-in {
+ 0% {
+ opacity: 0; }
+ 100% {
+ opacity: 1; } }
+
+@keyframes pop {
+ 0% {
+ transform: scale(1); }
+ 97% {
+ transform: scale(1.04); }
+ 100% {
+ transform: scale(1); } }
+
+@keyframes pulse {
+ 0% {
+ opacity: 0.3;
+ transform: scale(1); }
+ 70% {
+ opacity: 0.25;
+ transform: scale(1.04); }
+ 100% {
+ opacity: 0.3;
+ transform: scale(1); } }
+
+@keyframes slide-left {
+ 0% {
+ opacity: 0;
+ transform: translate3d(160px, 0, 0); }
+ 100% {
+ opacity: 1;
+ transform: translate3d(0, 0, 0); } }
+
+@keyframes bounce-in {
+ 0% {
+ opacity: 0;
+ transform: scale(1); }
+ 60% {
+ opacity: 1;
+ transform: scale(1.02); }
+ 100% {
+ transform: scale(1); } }
+
+.mover-target {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: auto;
+ position: absolute;
+ z-index: 5; }
+
+.highlight,
+.mover-target {
+ background-color: transparent;
+ background-image: none; }
+
+.mover-target,
+.bghighlight {
+ border: 0; }
+
+.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: 10000000000; }
+ .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; }
+ /* When prefers contrast is fully supported, we should change these quereies to cover both high and low prefers contrast cases */
+ @media (forced-colors: active) {
+ .hover-highlight {
+ background-color: white;
+ opacity: 0.2; } }
+
+.mover-target.direction-topLeft {
+ cursor: nwse-resize;
+ height: 60px;
+ left: -30px;
+ top: -30px;
+ width: 60px; }
+
+.mover-target.direction-top {
+ cursor: ns-resize;
+ height: 60px;
+ inset-inline-start: 0;
+ top: -30px;
+ width: 100%;
+ z-index: 4; }
+
+.mover-target.direction-topRight {
+ cursor: nesw-resize;
+ height: 60px;
+ right: -30px;
+ top: -30px;
+ width: 60px; }
+
+.mover-target.direction-left {
+ cursor: ew-resize;
+ height: 100%;
+ left: -30px;
+ top: 0;
+ width: 60px;
+ z-index: 4; }
+
+.mover-target.direction-right {
+ cursor: ew-resize;
+ height: 100%;
+ right: -30px;
+ top: 0;
+ width: 60px;
+ z-index: 4; }
+
+.mover-target.direction-bottomLeft {
+ bottom: -30px;
+ cursor: nesw-resize;
+ height: 60px;
+ left: -30px;
+ width: 60px; }
+
+.mover-target.direction-bottom {
+ bottom: -30px;
+ cursor: ns-resize;
+ height: 60px;
+ inset-inline-start: 0;
+ width: 100%;
+ z-index: 4; }
+
+.mover-target.direction-bottomRight {
+ bottom: -30px;
+ cursor: nwse-resize;
+ height: 60px;
+ right: -30px;
+ width: 60px; }
+
+.mover-target:hover .mover {
+ transform: scale(1.05); }
+
+.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; }
+ .small-selection .mover {
+ height: 10px;
+ width: 10px; }
+
+.direction-topLeft .mover,
+.direction-left .mover,
+.direction-bottomLeft .mover {
+ left: -1px; }
+
+.direction-topLeft .mover,
+.direction-top .mover,
+.direction-topRight .mover {
+ top: -1px; }
+
+.direction-topRight .mover,
+.direction-right .mover,
+.direction-bottomRight .mover {
+ right: -1px; }
+
+.direction-bottomRight .mover,
+.direction-bottom .mover,
+.direction-bottomLeft .mover {
+ bottom: -1px; }
+
+.bghighlight {
+ background-color: rgba(0, 0, 0, 0.7);
+ 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) {
+ .bghighlight {
+ background-color: black;
+ opacity: 0.7; } }
+
+.preview-overlay {
+ align-items: center;
+ background-color: rgba(0, 0, 0, 0.7);
+ display: flex;
+ height: 100%;
+ justify-content: center;
+ inset-inline-start: 0;
+ margin: 0;
+ padding: 0;
+ position: fixed;
+ top: 0;
+ width: 100%;
+ 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) {
+ .preview-overlay {
+ background-color: black;
+ opacity: 0.7; } }
+
+.precision-cursor {
+ cursor: crosshair; }
+
+.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-buttons {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ bottom: -58px;
+ position: absolute;
+ inset-inline-end: 5px;
+ z-index: 6; }
+ .bottom-selection .highlight-buttons {
+ bottom: 5px; }
+ .left-selection .highlight-buttons {
+ inset-inline-end: auto;
+ inset-inline-start: 5px; }
+ .highlight-buttons > button {
+ box-shadow: 0 0 0 1px rgba(12, 12, 13, 0.1), 0 2px 8px rgba(12, 12, 13, 0.1); }
+
+.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: 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: 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;
+}
+
+.pixel-dimensions {
+ position: absolute;
+ pointer-events: none;
+ font-weight: bold;
+ font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif;
+ font-size: 70%;
+ color: #000;
+ text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff; }
+
+.preview-buttons {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding-inline-end: 4px;
+ inset-inline-end: 0;
+ width: 100%;
+ position: absolute;
+ 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 {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ justify-content: center;
+ margin: 24px auto;
+ position: relative;
+ max-width: 80%;
+ max-height: 95%;
+ text-align: center;
+ animation-delay: 50ms;
+ display: flex; }
+
+.preview-image-wrapper {
+ background: rgba(249, 249, 250, 0.8);
+ border-radius: 0 0 4px 4px;
+ display: block;
+ height: auto;
+ max-width: 100%;
+ min-width: 320px;
+ overflow-y: scroll;
+ padding: 0 60px;
+ margin-top: 60px;
+ border: 1px solid rgba(249, 249, 250, 0.2);
+ border-top: 0; }
+
+.preview-image-wrapper > img {
+ box-shadow: 0 0 0 1px rgba(12, 12, 13, 0.1), 0 2px 8px rgba(12, 12, 13, 0.1);
+ height: auto;
+ margin-bottom: 60px;
+ max-width: 100%;
+ width: 100%; }
+
+.fixed-container {
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ justify-content: center;
+ inset-inline-start: 0;
+ margin: 0;
+ padding: 0;
+ pointer-events: none;
+ position: fixed;
+ top: 0;
+ width: 100%; }
+
+.face-container {
+ position: relative;
+ width: 64px;
+ height: 64px; }
+
+.face {
+ width: 62.4px;
+ height: 62.4px;
+ display: block;
+ background-image: url("chrome://browser/content/screenshots/icon-welcome-face-without-eyes.svg"); }
+
+.eye {
+ background-color: #fff;
+ width: 10.8px;
+ height: 14.6px;
+ position: absolute;
+ border-radius: 100%;
+ overflow: hidden;
+ inset-inline-start: 16.4px;
+ top: 19.8px; }
+
+.eyeball {
+ position: absolute;
+ width: 6px;
+ height: 6px;
+ background-color: #000;
+ border-radius: 50%;
+ inset-inline-start: 2.4px;
+ top: 4.3px;
+ z-index: 10; }
+
+.left {
+ margin-inline-start: 0; }
+
+.right {
+ margin-inline-start: 20px; }
+
+.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;
+ user-select: none; }
+
+.cancel-shot {
+ background-color: transparent;
+ cursor: pointer;
+ outline: none;
+ border-radius: 3px;
+ border: 1px #9b9b9b solid;
+ color: #fff;
+ cursor: pointer;
+ 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;
+ pointer-events: all; }
+
+.all-buttons-container {
+ display: flex;
+ flex-direction: row-reverse;
+ background: #f5f5f5;
+ border-radius: 2px;
+ box-sizing: border-box;
+ height: 80px;
+ padding: 8px;
+ position: absolute;
+ inset-inline-end: 8px;
+ top: 8px;
+ box-shadow: 0 0 0 1px rgba(12, 12, 13, 0.1), 0 2px 8px rgba(12, 12, 13, 0.1); }
+ .all-buttons-container .spacer {
+ background-color: #c9c9c9;
+ flex: 0 0 1px;
+ height: 80px;
+ margin: 0 10px;
+ position: relative;
+ top: -8px; }
+ .all-buttons-container button {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ justify-content: flex-end;
+ color: #3e3d40;
+ background-color: #f5f5f5;
+ background-position: center top;
+ background-repeat: no-repeat;
+ background-size: 46px 46px;
+ border: 1px solid transparent;
+ cursor: pointer;
+ height: 100%;
+ min-width: 90px;
+ padding: 46px 5px 5px;
+ pointer-events: all;
+ transition: border 150ms cubic-bezier(0.07, 0.95, 0, 1), background-color 150ms cubic-bezier(0.07, 0.95, 0, 1);
+ white-space: nowrap; }
+ .all-buttons-container button:hover {
+ background-color: #ebebeb;
+ border: 1px solid #c7c7c7; }
+ .all-buttons-container button:active {
+ background-color: #dedede;
+ border: 1px solid #989898; }
+ .all-buttons-container .full-page {
+ background-image: url("chrome://browser/content/screenshots/menu-fullpage.svg"); }
+ .all-buttons-container .visible {
+ background-image: url("chrome://browser/content/screenshots/menu-visible.svg"); }
+
+@keyframes pulse {
+ 0% {
+ transform: scale(1); }
+ 50% {
+ transform: scale(1.06); }
+ 100% {
+ transform: scale(1); } }
+
+@keyframes fade-in {
+ 0% {
+ opacity: 0; }
+ 100% {
+ opacity: 1; } }
+
+`;
+null;
diff --git a/browser/extensions/screenshots/build/selection.js b/browser/extensions/screenshots/build/selection.js
new file mode 100644
index 0000000000..db93dce72b
--- /dev/null
+++ b/browser/extensions/screenshots/build/selection.js
@@ -0,0 +1,126 @@
+/* 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/. */
+
+this.selection = (function () {
+ let exports = {};
+ class Selection {
+ constructor(x1, y1, x2, y2) {
+ this.x1 = x1;
+ this.y1 = y1;
+ this.x2 = x2;
+ this.y2 = y2;
+ }
+
+ get top() {
+ return Math.min(this.y1, this.y2);
+ }
+ set top(val) {
+ if (this.y1 < this.y2) {
+ this.y1 = val;
+ } else {
+ this.y2 = val;
+ }
+ }
+
+ get bottom() {
+ return Math.max(this.y1, this.y2);
+ }
+ set bottom(val) {
+ if (this.y1 > this.y2) {
+ this.y1 = val;
+ } else {
+ this.y2 = val;
+ }
+ }
+
+ get left() {
+ return Math.min(this.x1, this.x2);
+ }
+ set left(val) {
+ if (this.x1 < this.x2) {
+ this.x1 = val;
+ } else {
+ this.x2 = val;
+ }
+ }
+
+ get right() {
+ return Math.max(this.x1, this.x2);
+ }
+ set right(val) {
+ if (this.x1 > this.x2) {
+ this.x1 = val;
+ } else {
+ this.x2 = val;
+ }
+ }
+
+ get width() {
+ return Math.abs(this.x2 - this.x1);
+ }
+ get height() {
+ return Math.abs(this.y2 - this.y1);
+ }
+
+ rect() {
+ return {
+ top: Math.floor(this.top),
+ left: Math.floor(this.left),
+ bottom: Math.floor(this.bottom),
+ right: Math.floor(this.right),
+ };
+ }
+
+ union(other) {
+ return new Selection(
+ Math.min(this.left, other.left),
+ Math.min(this.top, other.top),
+ Math.max(this.right, other.right),
+ Math.max(this.bottom, other.bottom)
+ );
+ }
+
+ /** Sort x1/x2 and y1/y2 so x1<x2, 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];
+ }
+ }
+
+ clone() {
+ return new Selection(this.x1, this.y1, this.x2, this.y2);
+ }
+
+ toJSON() {
+ return {
+ left: this.left,
+ right: this.right,
+ top: this.top,
+ bottom: this.bottom,
+ };
+ }
+
+ static getBoundingClientRect(el) {
+ if (!el.getBoundingClientRect) {
+ // Typically the <html> element or somesuch
+ return null;
+ }
+ const rect = el.getBoundingClientRect();
+ if (!rect) {
+ return null;
+ }
+ return new Selection(rect.left, rect.top, rect.right, rect.bottom);
+ }
+ }
+
+ if (typeof exports !== "undefined") {
+ exports.Selection = Selection;
+ }
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/build/shot.js b/browser/extensions/screenshots/build/shot.js
new file mode 100644
index 0000000000..7153562de3
--- /dev/null
+++ b/browser/extensions/screenshots/build/shot.js
@@ -0,0 +1,888 @@
+/* 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/. */
+
+/* globals process, require */
+
+this.shot = (function () {
+ let exports = {}; // Note: in this library we can't use any "system" dependencies because this can be used from multiple
+ // environments
+
+ const isNode =
+ typeof process !== "undefined" &&
+ Object.prototype.toString.call(process) === "[object process]";
+ const URL = (isNode && require("url").URL) || window.URL;
+
+ /** Throws an error if the condition isn't true. Any extra arguments after the condition
+ are used as console.error() arguments. */
+ function assert(condition, ...args) {
+ if (condition) {
+ return;
+ }
+ console.error("Failed assertion", ...args);
+ throw new Error(`Failed assertion: ${args.join(" ")}`);
+ }
+
+ /** True if `url` is a valid URL */
+ function isUrl(url) {
+ try {
+ const parsed = new URL(url);
+
+ if (parsed.protocol === "view-source:") {
+ return isUrl(url.substr("view-source:".length));
+ }
+
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ function isValidClipImageUrl(url) {
+ return isUrl(url) && !(url.indexOf(")") > -1);
+ }
+
+ function assertUrl(url) {
+ if (!url) {
+ throw new Error("Empty value is not URL");
+ }
+ if (!isUrl(url)) {
+ const exc = new Error("Not a URL");
+ exc.scheme = url.split(":")[0];
+ throw exc;
+ }
+ }
+
+ function isSecureWebUri(url) {
+ return isUrl(url) && url.toLowerCase().startsWith("https");
+ }
+
+ function assertOrigin(url) {
+ assertUrl(url);
+ if (url.search(/^https?:/i) !== -1) {
+ let newUrl = new URL(url);
+ if (newUrl.pathname != "/") {
+ throw new Error("Bad origin, might include path");
+ }
+ }
+ }
+
+ function originFromUrl(url) {
+ if (!url) {
+ return null;
+ }
+ if (url.search(/^https?:/i) === -1) {
+ // Non-HTTP URLs don't have an origin
+ return null;
+ }
+ try {
+ let tryUrl = new URL(url);
+ return tryUrl.origin;
+ } catch {
+ return null;
+ }
+ }
+
+ /** Check if the given object has all of the required attributes, and no extra
+ attributes exception those in optional */
+ function checkObject(obj, required, optional) {
+ if (typeof obj !== "object" || obj === null) {
+ throw new Error(
+ "Cannot check non-object: " +
+ typeof obj +
+ " that is " +
+ JSON.stringify(obj)
+ );
+ }
+ required = required || [];
+ for (const attr of required) {
+ if (!(attr in obj)) {
+ return false;
+ }
+ }
+ optional = optional || [];
+ for (const attr in obj) {
+ if (!required.includes(attr) && !optional.includes(attr)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Create a JSON object from a normal object, given the required and optional
+ attributes (filtering out any other attributes). Optional attributes are
+ only kept when they are truthy. */
+ function jsonify(obj, required, optional) {
+ required = required || [];
+ const result = {};
+ for (const attr of required) {
+ result[attr] = obj[attr];
+ }
+ optional = optional || [];
+ for (const attr of optional) {
+ if (obj[attr]) {
+ result[attr] = obj[attr];
+ }
+ }
+ return result;
+ }
+
+ /** True if the two objects look alike. Null, undefined, and absent properties
+ are all treated as equivalent. Traverses objects and arrays */
+ function deepEqual(a, b) {
+ if ((a === null || a === undefined) && (b === null || b === undefined)) {
+ return true;
+ }
+ if (typeof a !== "object" || typeof b !== "object") {
+ return a === b;
+ }
+ if (Array.isArray(a)) {
+ if (!Array.isArray(b)) {
+ return false;
+ }
+ if (a.length !== b.length) {
+ return false;
+ }
+ for (let i = 0; i < a.length; i++) {
+ if (!deepEqual(a[i], b[i])) {
+ return false;
+ }
+ }
+ }
+ if (Array.isArray(b)) {
+ return false;
+ }
+ const seen = new Set();
+ for (const attr of Object.keys(a)) {
+ if (!deepEqual(a[attr], b[attr])) {
+ return false;
+ }
+ seen.add(attr);
+ }
+ for (const attr of Object.keys(b)) {
+ if (!seen.has(attr)) {
+ if (!deepEqual(a[attr], b[attr])) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ function makeRandomId() {
+ // Note: this isn't for secure contexts, only for non-conflicting IDs
+ let id = "";
+ while (id.length < 12) {
+ let num;
+ if (!id) {
+ num = Date.now() % Math.pow(36, 3);
+ } else {
+ num = Math.floor(Math.random() * Math.pow(36, 3));
+ }
+ id += num.toString(36);
+ }
+ return id;
+ }
+
+ class AbstractShot {
+ constructor(backend, id, attrs) {
+ attrs = attrs || {};
+ assert(
+ /^[a-zA-Z0-9]{1,4000}\/[a-z0-9._-]{1,4000}$/.test(id),
+ "Bad ID (should be alphanumeric):",
+ JSON.stringify(id)
+ );
+ this._backend = backend;
+ this._id = id;
+ this.origin = attrs.origin || null;
+ this.fullUrl = attrs.fullUrl || null;
+ if (!attrs.fullUrl && attrs.url) {
+ console.warn("Received deprecated attribute .url");
+ this.fullUrl = attrs.url;
+ }
+ if (this.origin && !isSecureWebUri(this.origin)) {
+ this.origin = "";
+ }
+ if (this.fullUrl && !isSecureWebUri(this.fullUrl)) {
+ this.fullUrl = "";
+ }
+ this.docTitle = attrs.docTitle || null;
+ this.userTitle = attrs.userTitle || null;
+ this.createdDate = attrs.createdDate || Date.now();
+ this.siteName = attrs.siteName || null;
+ this.images = [];
+ if (attrs.images) {
+ this.images = attrs.images.map(json => new this.Image(json));
+ }
+ this.openGraph = attrs.openGraph || null;
+ this.twitterCard = attrs.twitterCard || null;
+ this.documentSize = attrs.documentSize || null;
+ this.thumbnail = attrs.thumbnail || null;
+ this.abTests = attrs.abTests || null;
+ this.firefoxChannel = attrs.firefoxChannel || null;
+ this._clips = {};
+ if (attrs.clips) {
+ for (const clipId in attrs.clips) {
+ const clip = attrs.clips[clipId];
+ this._clips[clipId] = new this.Clip(this, clipId, clip);
+ }
+ }
+
+ const isProd =
+ typeof process !== "undefined" && process.env.NODE_ENV === "production";
+
+ for (const attr in attrs) {
+ if (
+ attr !== "clips" &&
+ attr !== "id" &&
+ !this.REGULAR_ATTRS.includes(attr) &&
+ !this.DEPRECATED_ATTRS.includes(attr)
+ ) {
+ if (isProd) {
+ console.warn("Unexpected attribute: " + attr);
+ } else {
+ throw new Error("Unexpected attribute: " + attr);
+ }
+ } else if (attr === "id") {
+ console.warn("passing id in attrs in AbstractShot constructor");
+ console.trace();
+ assert(attrs.id === this.id);
+ }
+ }
+ }
+
+ /** Update any and all attributes in the json object, with deep updating
+ of `json.clips` */
+ update(json) {
+ const ALL_ATTRS = ["clips"].concat(this.REGULAR_ATTRS);
+ assert(
+ checkObject(json, [], ALL_ATTRS),
+ "Bad attr to new Shot():",
+ Object.keys(json)
+ );
+ for (const attr in json) {
+ if (attr === "clips") {
+ continue;
+ }
+ if (
+ typeof json[attr] === "object" &&
+ typeof this[attr] === "object" &&
+ this[attr] !== null
+ ) {
+ let val = this[attr];
+ if (val.toJSON) {
+ val = val.toJSON();
+ }
+ if (!deepEqual(json[attr], val)) {
+ this[attr] = json[attr];
+ }
+ } else if (json[attr] !== this[attr] && (json[attr] || this[attr])) {
+ this[attr] = json[attr];
+ }
+ }
+ if (json.clips) {
+ for (const clipId in json.clips) {
+ if (!json.clips[clipId]) {
+ this.delClip(clipId);
+ } else if (!this.getClip(clipId)) {
+ this.setClip(clipId, json.clips[clipId]);
+ } else if (
+ !deepEqual(this.getClip(clipId).toJSON(), json.clips[clipId])
+ ) {
+ this.setClip(clipId, json.clips[clipId]);
+ }
+ }
+ }
+ }
+
+ /** Returns a JSON version of this shot */
+ toJSON() {
+ const result = {};
+ for (const attr of this.REGULAR_ATTRS) {
+ let val = this[attr];
+ if (val && val.toJSON) {
+ val = val.toJSON();
+ }
+ result[attr] = val;
+ }
+ result.clips = {};
+ for (const attr in this._clips) {
+ result.clips[attr] = this._clips[attr].toJSON();
+ }
+ return result;
+ }
+
+ /** A more minimal JSON representation for creating indexes of shots */
+ asRecallJson() {
+ const result = { clips: {} };
+ for (const attr of this.RECALL_ATTRS) {
+ let val = this[attr];
+ if (val && val.toJSON) {
+ val = val.toJSON();
+ }
+ result[attr] = val;
+ }
+ for (const name of this.clipNames()) {
+ result.clips[name] = this.getClip(name).toJSON();
+ }
+ return result;
+ }
+
+ get backend() {
+ return this._backend;
+ }
+
+ get id() {
+ return this._id;
+ }
+
+ get url() {
+ return this.fullUrl || this.origin;
+ }
+ set url(val) {
+ throw new Error(".url is read-only");
+ }
+
+ get fullUrl() {
+ return this._fullUrl;
+ }
+ set fullUrl(val) {
+ if (val) {
+ assertUrl(val);
+ }
+ this._fullUrl = val || undefined;
+ }
+
+ get origin() {
+ return this._origin;
+ }
+ set origin(val) {
+ if (val) {
+ assertOrigin(val);
+ }
+ this._origin = val || undefined;
+ }
+
+ get isOwner() {
+ return this._isOwner;
+ }
+
+ set isOwner(val) {
+ this._isOwner = val || undefined;
+ }
+
+ get filename() {
+ let filenameTitle = this.title;
+ const date = new Date(this.createdDate);
+ /* 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;
+
+ const clip = this.getClip(this.clipNames()[0]);
+ let extension = ".png";
+ if (clip && clip.image && clip.image.type) {
+ if (clip.image.type === "jpeg") {
+ extension = ".jpg";
+ }
+ }
+ return clipFilename + extension;
+ }
+
+ get urlDisplay() {
+ if (!this.url) {
+ return null;
+ }
+ if (/^https?:\/\//i.test(this.url)) {
+ let txt = this.url;
+ txt = txt.replace(/^[a-z]{1,4000}:\/\//i, "");
+ txt = txt.replace(/\/.{0,4000}/, "");
+ txt = txt.replace(/^www\./i, "");
+ return txt;
+ } else if (this.url.startsWith("data:")) {
+ return "data:url";
+ }
+ let txt = this.url;
+ txt = txt.replace(/\?.{0,4000}/, "");
+ return txt;
+ }
+
+ get viewUrl() {
+ const url = this.backend + "/" + this.id;
+ return url;
+ }
+
+ get creatingUrl() {
+ let url = `${this.backend}/creating/${this.id}`;
+ url += `?title=${encodeURIComponent(this.title || "")}`;
+ url += `&url=${encodeURIComponent(this.url)}`;
+ return url;
+ }
+
+ get jsonUrl() {
+ return this.backend + "/data/" + this.id;
+ }
+
+ get oembedUrl() {
+ return this.backend + "/oembed?url=" + encodeURIComponent(this.viewUrl);
+ }
+
+ get docTitle() {
+ return this._title;
+ }
+ set docTitle(val) {
+ assert(val === null || typeof val === "string", "Bad docTitle:", val);
+ this._title = val;
+ }
+
+ get openGraph() {
+ return this._openGraph || null;
+ }
+ set openGraph(val) {
+ assert(val === null || typeof val === "object", "Bad openGraph:", val);
+ if (val) {
+ assert(
+ checkObject(val, [], this._OPENGRAPH_PROPERTIES),
+ "Bad attr to openGraph:",
+ Object.keys(val)
+ );
+ this._openGraph = val;
+ } else {
+ this._openGraph = null;
+ }
+ }
+
+ get twitterCard() {
+ return this._twitterCard || null;
+ }
+ set twitterCard(val) {
+ assert(val === null || typeof val === "object", "Bad twitterCard:", val);
+ if (val) {
+ assert(
+ checkObject(val, [], this._TWITTERCARD_PROPERTIES),
+ "Bad attr to twitterCard:",
+ Object.keys(val)
+ );
+ this._twitterCard = val;
+ } else {
+ this._twitterCard = null;
+ }
+ }
+
+ get userTitle() {
+ return this._userTitle;
+ }
+ set userTitle(val) {
+ assert(val === null || typeof val === "string", "Bad userTitle:", val);
+ this._userTitle = val;
+ }
+
+ get title() {
+ // FIXME: we shouldn't support both openGraph.title and ogTitle
+ const ogTitle = this.openGraph && this.openGraph.title;
+ const twitterTitle = this.twitterCard && this.twitterCard.title;
+ let title =
+ this.userTitle || ogTitle || twitterTitle || this.docTitle || this.url;
+ if (Array.isArray(title)) {
+ title = title[0];
+ }
+ if (!title) {
+ title = "Screenshot";
+ }
+ return title;
+ }
+
+ get createdDate() {
+ return this._createdDate;
+ }
+ set createdDate(val) {
+ assert(val === null || typeof val === "number", "Bad createdDate:", val);
+ this._createdDate = val;
+ }
+
+ clipNames() {
+ const names = Object.getOwnPropertyNames(this._clips);
+ names.sort(function (a, b) {
+ return a.sortOrder < b.sortOrder ? 1 : 0;
+ });
+ return names;
+ }
+ getClip(name) {
+ return this._clips[name];
+ }
+ addClip(val) {
+ const name = makeRandomId();
+ this.setClip(name, val);
+ return name;
+ }
+ setClip(name, val) {
+ const clip = new this.Clip(this, name, val);
+ this._clips[name] = clip;
+ }
+ delClip(name) {
+ if (!this._clips[name]) {
+ throw new Error("No existing clip with id: " + name);
+ }
+ delete this._clips[name];
+ }
+ delAllClips() {
+ this._clips = {};
+ }
+ biggestClipSortOrder() {
+ let biggest = 0;
+ for (const clipId in this._clips) {
+ biggest = Math.max(biggest, this._clips[clipId].sortOrder);
+ }
+ return biggest;
+ }
+ updateClipUrl(clipId, clipUrl) {
+ const clip = this.getClip(clipId);
+ if (clip && clip.image) {
+ clip.image.url = clipUrl;
+ } else {
+ console.warn("Tried to update the url of a clip with no image:", clip);
+ }
+ }
+
+ get siteName() {
+ return this._siteName || null;
+ }
+ set siteName(val) {
+ assert(typeof val === "string" || !val);
+ this._siteName = val;
+ }
+
+ get documentSize() {
+ return this._documentSize;
+ }
+ set documentSize(val) {
+ assert(typeof val === "object" || !val);
+ if (val) {
+ assert(
+ checkObject(
+ val,
+ ["height", "width"],
+ "Bad attr to documentSize:",
+ Object.keys(val)
+ )
+ );
+ assert(typeof val.height === "number");
+ assert(typeof val.width === "number");
+ this._documentSize = val;
+ } else {
+ this._documentSize = null;
+ }
+ }
+
+ get thumbnail() {
+ return this._thumbnail;
+ }
+ set thumbnail(val) {
+ assert(typeof val === "string" || !val);
+ if (val) {
+ assert(isUrl(val));
+ this._thumbnail = val;
+ } else {
+ this._thumbnail = null;
+ }
+ }
+
+ get abTests() {
+ return this._abTests;
+ }
+ set abTests(val) {
+ if (val === null || val === undefined) {
+ this._abTests = null;
+ return;
+ }
+ assert(
+ typeof val === "object",
+ "abTests should be an object, not:",
+ typeof val
+ );
+ assert(!Array.isArray(val), "abTests should not be an Array");
+ for (const name in val) {
+ assert(
+ val[name] && typeof val[name] === "string",
+ `abTests.${name} should be a string:`,
+ typeof val[name]
+ );
+ }
+ this._abTests = val;
+ }
+
+ get firefoxChannel() {
+ return this._firefoxChannel;
+ }
+ set firefoxChannel(val) {
+ if (val === null || val === undefined) {
+ this._firefoxChannel = null;
+ return;
+ }
+ assert(
+ typeof val === "string",
+ "firefoxChannel should be a string, not:",
+ typeof val
+ );
+ this._firefoxChannel = val;
+ }
+ }
+
+ AbstractShot.prototype.REGULAR_ATTRS = `
+origin fullUrl docTitle userTitle createdDate images
+siteName openGraph twitterCard documentSize
+thumbnail abTests firefoxChannel
+`.split(/\s+/g);
+
+ // Attributes that will be accepted in the constructor, but ignored/dropped
+ AbstractShot.prototype.DEPRECATED_ATTRS = `
+microdata history ogTitle createdDevice head body htmlAttrs bodyAttrs headAttrs
+readable hashtags comments showPage isPublic resources url
+fullScreenThumbnail favicon
+`.split(/\s+/g);
+
+ AbstractShot.prototype.RECALL_ATTRS = `
+url docTitle userTitle createdDate openGraph twitterCard images thumbnail
+`.split(/\s+/g);
+
+ AbstractShot.prototype._OPENGRAPH_PROPERTIES = `
+title type url image audio description determiner locale site_name video
+image:secure_url image:type image:width image:height
+video:secure_url video:type video:width image:height
+audio:secure_url audio:type
+article:published_time article:modified_time article:expiration_time article:author article:section article:tag
+book:author book:isbn book:release_date book:tag
+profile:first_name profile:last_name profile:username profile:gender
+`.split(/\s+/g);
+
+ AbstractShot.prototype._TWITTERCARD_PROPERTIES = `
+card site title description image
+player player:width player:height player:stream player:stream:content_type
+`.split(/\s+/g);
+
+ /** Represents one found image in the document (not a clip) */
+ class _Image {
+ // FIXME: either we have to notify the shot of updates, or make
+ // this read-only
+ constructor(json) {
+ assert(typeof json === "object", "Clip Image given a non-object", json);
+ assert(
+ checkObject(json, ["url"], ["dimensions", "title", "alt"]),
+ "Bad attrs for Image:",
+ Object.keys(json)
+ );
+ assert(isUrl(json.url), "Bad Image url:", json.url);
+ this.url = json.url;
+ assert(
+ !json.dimensions ||
+ (typeof json.dimensions.x === "number" &&
+ typeof json.dimensions.y === "number"),
+ "Bad Image dimensions:",
+ json.dimensions
+ );
+ this.dimensions = json.dimensions;
+ assert(
+ typeof json.title === "string" || !json.title,
+ "Bad Image title:",
+ json.title
+ );
+ this.title = json.title;
+ assert(
+ typeof json.alt === "string" || !json.alt,
+ "Bad Image alt:",
+ json.alt
+ );
+ this.alt = json.alt;
+ }
+
+ toJSON() {
+ return jsonify(this, ["url"], ["dimensions"]);
+ }
+ }
+
+ AbstractShot.prototype.Image = _Image;
+
+ /** Represents a clip, either a text or image clip */
+ class _Clip {
+ constructor(shot, id, json) {
+ this._shot = shot;
+ assert(
+ checkObject(json, ["createdDate", "image"], ["sortOrder"]),
+ "Bad attrs for Clip:",
+ Object.keys(json)
+ );
+ assert(typeof id === "string" && id, "Bad Clip id:", id);
+ this._id = id;
+ this.createdDate = json.createdDate;
+ if ("sortOrder" in json) {
+ assert(
+ typeof json.sortOrder === "number" || !json.sortOrder,
+ "Bad Clip sortOrder:",
+ json.sortOrder
+ );
+ }
+ if ("sortOrder" in json) {
+ this.sortOrder = json.sortOrder;
+ } else {
+ const biggestOrder = shot.biggestClipSortOrder();
+ this.sortOrder = biggestOrder + 100;
+ }
+ this.image = json.image;
+ }
+
+ toString() {
+ return `[Shot Clip id=${this.id} sortOrder=${this.sortOrder} image ${this.image.dimensions.x}x${this.image.dimensions.y}]`;
+ }
+
+ toJSON() {
+ return jsonify(this, ["createdDate"], ["sortOrder", "image"]);
+ }
+
+ get id() {
+ return this._id;
+ }
+
+ get createdDate() {
+ return this._createdDate;
+ }
+ set createdDate(val) {
+ assert(typeof val === "number" || !val, "Bad Clip createdDate:", val);
+ this._createdDate = val;
+ }
+
+ get image() {
+ return this._image;
+ }
+ set image(image) {
+ if (!image) {
+ this._image = undefined;
+ return;
+ }
+ assert(
+ checkObject(
+ image,
+ ["url"],
+ ["dimensions", "text", "location", "captureType", "type"]
+ ),
+ "Bad attrs for Clip Image:",
+ Object.keys(image)
+ );
+ assert(isValidClipImageUrl(image.url), "Bad Clip image URL:", image.url);
+ assert(
+ image.captureType === "madeSelection" ||
+ image.captureType === "selection" ||
+ image.captureType === "visible" ||
+ image.captureType === "auto" ||
+ image.captureType === "fullPage" ||
+ image.captureType === "fullPageTruncated" ||
+ !image.captureType,
+ "Bad image.captureType:",
+ image.captureType
+ );
+ assert(
+ typeof image.text === "string" || !image.text,
+ "Bad Clip image text:",
+ image.text
+ );
+ if (image.dimensions) {
+ assert(
+ typeof image.dimensions.x === "number" &&
+ typeof image.dimensions.y === "number",
+ "Bad Clip image dimensions:",
+ image.dimensions
+ );
+ }
+ if (image.type) {
+ assert(
+ image.type === "png" || image.type === "jpeg",
+ "Unexpected image type:",
+ image.type
+ );
+ }
+ assert(
+ image.location &&
+ typeof image.location.left === "number" &&
+ typeof image.location.right === "number" &&
+ typeof image.location.top === "number" &&
+ typeof image.location.bottom === "number",
+ "Bad Clip image pixel location:",
+ image.location
+ );
+ if (
+ image.location.topLeftElement ||
+ image.location.topLeftOffset ||
+ image.location.bottomRightElement ||
+ image.location.bottomRightOffset
+ ) {
+ assert(
+ typeof image.location.topLeftElement === "string" &&
+ image.location.topLeftOffset &&
+ typeof image.location.topLeftOffset.x === "number" &&
+ typeof image.location.topLeftOffset.y === "number" &&
+ typeof image.location.bottomRightElement === "string" &&
+ image.location.bottomRightOffset &&
+ typeof image.location.bottomRightOffset.x === "number" &&
+ typeof image.location.bottomRightOffset.y === "number",
+ "Bad Clip image element location:",
+ image.location
+ );
+ }
+ this._image = image;
+ }
+
+ isDataUrl() {
+ if (this.image) {
+ return this.image.url.startsWith("data:");
+ }
+ return false;
+ }
+
+ get sortOrder() {
+ return this._sortOrder || null;
+ }
+ set sortOrder(val) {
+ assert(typeof val === "number" || !val, "Bad Clip sortOrder:", val);
+ this._sortOrder = val;
+ }
+ }
+
+ AbstractShot.prototype.Clip = _Clip;
+
+ if (typeof exports !== "undefined") {
+ exports.AbstractShot = AbstractShot;
+ exports.originFromUrl = originFromUrl;
+ exports.isValidClipImageUrl = isValidClipImageUrl;
+ }
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/build/thumbnailGenerator.js b/browser/extensions/screenshots/build/thumbnailGenerator.js
new file mode 100644
index 0000000000..c80ccb6bac
--- /dev/null
+++ b/browser/extensions/screenshots/build/thumbnailGenerator.js
@@ -0,0 +1,190 @@
+/* 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/. */
+
+this.thumbnailGenerator = (function () {
+ let exports = {}; // This is used in webextension/background/takeshot.js,
+ // server/src/pages/shot/controller.js, and
+ // server/scr/pages/shotindex/view.js. It is used in a browser
+ // environment.
+
+ // Resize down 1/2 at a time produces better image quality.
+ // Not quite as good as using a third-party filter (which will be
+ // slower), but good enough.
+ const maxResizeScaleFactor = 0.5;
+
+ // The shot will be scaled or cropped down to 210px on x, and cropped or
+ // scaled down to a maximum of 280px on y.
+ // x: 210
+ // y: <= 280
+ const maxThumbnailWidth = 210;
+ const maxThumbnailHeight = 280;
+
+ /**
+ * @param {int} imageHeight Height in pixels of the original image.
+ * @param {int} imageWidth Width in pixels of the original image.
+ * @returns {width, height, scaledX, scaledY}
+ */
+ function getThumbnailDimensions(imageWidth, imageHeight) {
+ const displayAspectRatio = 3 / 4;
+ const imageAspectRatio = imageWidth / imageHeight;
+ let thumbnailImageWidth, thumbnailImageHeight;
+ let scaledX, scaledY;
+
+ if (imageAspectRatio > displayAspectRatio) {
+ // "Landscape" mode
+ // Scale on y, crop on x
+ const yScaleFactor =
+ imageHeight > maxThumbnailHeight
+ ? maxThumbnailHeight / imageHeight
+ : 1.0;
+ thumbnailImageHeight = scaledY = Math.round(imageHeight * yScaleFactor);
+ scaledX = Math.round(imageWidth * yScaleFactor);
+ thumbnailImageWidth = Math.min(scaledX, maxThumbnailWidth);
+ } else {
+ // "Portrait" mode
+ // Scale on x, crop on y
+ const xScaleFactor =
+ imageWidth > maxThumbnailWidth ? maxThumbnailWidth / imageWidth : 1.0;
+ thumbnailImageWidth = scaledX = Math.round(imageWidth * xScaleFactor);
+ scaledY = Math.round(imageHeight * xScaleFactor);
+ // The CSS could widen the image, in which case we crop more off of y.
+ thumbnailImageHeight = Math.min(
+ scaledY,
+ maxThumbnailHeight,
+ maxThumbnailHeight / (maxThumbnailWidth / imageWidth)
+ );
+ }
+
+ return {
+ width: thumbnailImageWidth,
+ height: thumbnailImageHeight,
+ scaledX,
+ scaledY,
+ };
+ }
+
+ /**
+ * @param {dataUrl} String Data URL of the original image.
+ * @param {int} imageHeight Height in pixels of the original image.
+ * @param {int} imageWidth Width in pixels of the original image.
+ * @param {String} urlOrBlob 'blob' for a blob, otherwise data url.
+ * @returns A promise that resolves to the data URL or blob of the thumbnail image, or null.
+ */
+ function createThumbnail(dataUrl, imageWidth, imageHeight, urlOrBlob) {
+ // There's cost associated with generating, transmitting, and storing
+ // thumbnails, so we'll opt out if the image size is below a certain threshold
+ const thumbnailThresholdFactor = 1.2;
+ const thumbnailWidthThreshold =
+ maxThumbnailWidth * thumbnailThresholdFactor;
+ const thumbnailHeightThreshold =
+ maxThumbnailHeight * thumbnailThresholdFactor;
+
+ if (
+ imageWidth <= thumbnailWidthThreshold &&
+ imageHeight <= thumbnailHeightThreshold
+ ) {
+ // Do not create a thumbnail.
+ return Promise.resolve(null);
+ }
+
+ const thumbnailDimensions = getThumbnailDimensions(imageWidth, imageHeight);
+
+ return new Promise((resolve, reject) => {
+ const thumbnailImage = new Image();
+ let srcWidth = imageWidth;
+ let srcHeight = imageHeight;
+ let destWidth, destHeight;
+
+ thumbnailImage.onload = function () {
+ destWidth = Math.round(srcWidth * maxResizeScaleFactor);
+ destHeight = Math.round(srcHeight * maxResizeScaleFactor);
+ if (
+ destWidth <= thumbnailDimensions.scaledX ||
+ destHeight <= thumbnailDimensions.scaledY
+ ) {
+ srcWidth = Math.round(
+ srcWidth * (thumbnailDimensions.width / thumbnailDimensions.scaledX)
+ );
+ srcHeight = Math.round(
+ srcHeight *
+ (thumbnailDimensions.height / thumbnailDimensions.scaledY)
+ );
+ destWidth = thumbnailDimensions.width;
+ destHeight = thumbnailDimensions.height;
+ }
+
+ const thumbnailCanvas = document.createElement("canvas");
+ thumbnailCanvas.width = destWidth;
+ thumbnailCanvas.height = destHeight;
+ const ctx = thumbnailCanvas.getContext("2d");
+ ctx.imageSmoothingEnabled = false;
+
+ ctx.drawImage(
+ thumbnailImage,
+ 0,
+ 0,
+ srcWidth,
+ srcHeight,
+ 0,
+ 0,
+ destWidth,
+ destHeight
+ );
+
+ if (
+ thumbnailCanvas.width <= thumbnailDimensions.width ||
+ thumbnailCanvas.height <= thumbnailDimensions.height
+ ) {
+ if (urlOrBlob === "blob") {
+ thumbnailCanvas.toBlob(blob => {
+ resolve(blob);
+ });
+ } else {
+ resolve(thumbnailCanvas.toDataURL("image/png"));
+ }
+ return;
+ }
+
+ srcWidth = destWidth;
+ srcHeight = destHeight;
+ thumbnailImage.src = thumbnailCanvas.toDataURL();
+ };
+ thumbnailImage.src = dataUrl;
+ });
+ }
+
+ function createThumbnailUrl(shot) {
+ const image = shot.getClip(shot.clipNames()[0]).image;
+ if (!image.url) {
+ return Promise.resolve(null);
+ }
+ return createThumbnail(
+ image.url,
+ image.dimensions.x,
+ image.dimensions.y,
+ "dataurl"
+ );
+ }
+
+ function createThumbnailBlobFromPromise(shot, blobToUrlPromise) {
+ return blobToUrlPromise.then(dataUrl => {
+ const image = shot.getClip(shot.clipNames()[0]).image;
+ return createThumbnail(
+ dataUrl,
+ image.dimensions.x,
+ image.dimensions.y,
+ "blob"
+ );
+ });
+ }
+
+ if (typeof exports !== "undefined") {
+ exports.getThumbnailDimensions = getThumbnailDimensions;
+ exports.createThumbnailUrl = createThumbnailUrl;
+ exports.createThumbnailBlobFromPromise = createThumbnailBlobFromPromise;
+ }
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/catcher.js b/browser/extensions/screenshots/catcher.js
new file mode 100644
index 0000000000..1644f86ca0
--- /dev/null
+++ b/browser/extensions/screenshots/catcher.js
@@ -0,0 +1,101 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// eslint-disable-next-line no-var
+var global = this;
+
+this.catcher = (function () {
+ const exports = {};
+
+ let handler;
+
+ let queue = [];
+
+ const log = global.log;
+
+ exports.unhandled = function (error, info) {
+ if (!error.noReport) {
+ log.error("Unhandled error:", error, info);
+ }
+ const e = makeError(error, info);
+ if (!handler) {
+ queue.push(e);
+ } else {
+ handler(e);
+ }
+ };
+
+ /** Turn an exception into an error object */
+ function makeError(exc, info) {
+ let result;
+ if (exc.fromMakeError) {
+ result = exc;
+ } else {
+ result = {
+ fromMakeError: true,
+ name: exc.name || "ERROR",
+ message: String(exc),
+ stack: exc.stack,
+ };
+ for (const attr in exc) {
+ result[attr] = exc[attr];
+ }
+ }
+ if (info) {
+ for (const attr of Object.keys(info)) {
+ result[attr] = info[attr];
+ }
+ }
+ return result;
+ }
+
+ /** Wrap the function, and if it raises any exceptions then call unhandled() */
+ exports.watchFunction = function watchFunction(func, quiet) {
+ return function () {
+ try {
+ return func.apply(this, arguments);
+ } catch (e) {
+ if (!quiet) {
+ exports.unhandled(e);
+ }
+ throw e;
+ }
+ };
+ };
+
+ exports.watchPromise = function watchPromise(promise, quiet) {
+ return promise.catch(e => {
+ if (quiet) {
+ if (!e.noReport) {
+ log.debug("------Error in promise:", e);
+ log.debug(e.stack);
+ }
+ } else {
+ if (!e.noReport) {
+ log.error("------Error in promise:", e);
+ log.error(e.stack);
+ }
+ exports.unhandled(makeError(e));
+ }
+ throw e;
+ });
+ };
+
+ exports.registerHandler = function (h) {
+ if (handler) {
+ log.error("registerHandler called after handler was already registered");
+ return;
+ }
+ handler = h;
+ for (const error of queue) {
+ handler(error);
+ }
+ queue = [];
+ };
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/clipboard.js b/browser/extensions/screenshots/clipboard.js
new file mode 100644
index 0000000000..d4dbc38a14
--- /dev/null
+++ b/browser/extensions/screenshots/clipboard.js
@@ -0,0 +1,64 @@
+/* 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/. */
+
+/* globals catcher, assertIsBlankDocument, browser */
+
+"use strict";
+
+this.clipboard = (function () {
+ const exports = {};
+
+ exports.copy = function (text) {
+ return new Promise((resolve, reject) => {
+ const element = document.createElement("iframe");
+ element.src = browser.runtime.getURL("blank.html");
+ // We can't actually hide the iframe while copying, but we can make
+ // it close to invisible:
+ element.style.opacity = "0";
+ element.style.width = "1px";
+ element.style.height = "1px";
+ element.style.display = "block";
+ element.addEventListener(
+ "load",
+ catcher.watchFunction(() => {
+ try {
+ const doc = element.contentDocument;
+ assertIsBlankDocument(doc);
+ const el = doc.createElement("textarea");
+ doc.body.appendChild(el);
+ el.value = text;
+ if (!text) {
+ const exc = new Error("Clipboard copy given empty text");
+ exc.noPopup = true;
+ catcher.unhandled(exc);
+ }
+ el.select();
+ if (doc.activeElement !== el) {
+ const unhandledTag = doc.activeElement
+ ? doc.activeElement.tagName
+ : "No active element";
+ const exc = new Error("Clipboard el.select failed");
+ exc.activeElement = unhandledTag;
+ exc.noPopup = true;
+ catcher.unhandled(exc);
+ }
+ const copied = doc.execCommand("copy");
+ if (!copied) {
+ catcher.unhandled(new Error("Clipboard copy failed"));
+ }
+ el.remove();
+ resolve(copied);
+ } finally {
+ element.remove();
+ }
+ }),
+ { once: true }
+ );
+ document.body.appendChild(element);
+ });
+ };
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/domainFromUrl.js b/browser/extensions/screenshots/domainFromUrl.js
new file mode 100644
index 0000000000..b610007a34
--- /dev/null
+++ b/browser/extensions/screenshots/domainFromUrl.js
@@ -0,0 +1,32 @@
+/* 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/. */
+
+/** Returns the domain of a URL, but safely and in ASCII; URLs without domains
+ (such as about:blank) return the scheme, Unicode domains get stripped down
+ to ASCII */
+
+"use strict";
+
+this.domainFromUrl = (function () {
+ return function urlDomainForId(location) {
+ // eslint-disable-line no-unused-vars
+ let domain = location.hostname;
+ if (!domain) {
+ domain = location.origin.split(":")[0];
+ if (!domain) {
+ domain = "unknown";
+ }
+ }
+ if (domain.search(/^[a-z0-9._-]{1,1000}$/i) === -1) {
+ // Probably a unicode domain; we could use punycode but it wouldn't decode
+ // well in the URL anyway. Instead we'll punt.
+ domain = domain.replace(/[^a-z0-9._-]/gi, "");
+ if (!domain) {
+ domain = "site";
+ }
+ }
+ return domain;
+ };
+})();
+null;
diff --git a/browser/extensions/screenshots/experiments/screenshots/api.js b/browser/extensions/screenshots/experiments/screenshots/api.js
new file mode 100644
index 0000000000..d3e60aee77
--- /dev/null
+++ b/browser/extensions/screenshots/experiments/screenshots/api.js
@@ -0,0 +1,56 @@
+/* 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/. */
+
+/* globals browser, AppConstants, Services, ExtensionAPI, ExtensionCommon */
+
+"use strict";
+
+const TOPIC = "menuitem-screenshot-extension";
+
+this.screenshots = class extends ExtensionAPI {
+ getAPI(context) {
+ let EventManager = ExtensionCommon.EventManager;
+
+ return {
+ experiments: {
+ screenshots: {
+ // If you are checking for 'nightly', also check for 'nightly-try'.
+ //
+ // Otherwise, just use the standard builds, but be aware of the many
+ // non-standard options that also exist (as of August 2018).
+ //
+ // Standard builds:
+ // 'esr' - ESR channel
+ // 'release' - release channel
+ // 'beta' - beta channel
+ // 'nightly' - nightly channel
+ // Non-standard / deprecated builds:
+ // 'aurora' - deprecated aurora channel (still observed in dxr)
+ // 'default' - local builds from source
+ // 'nightly-try' - nightly Try builds (QA may occasionally need to test with these)
+ getUpdateChannel() {
+ return AppConstants.MOZ_UPDATE_CHANNEL;
+ },
+ isHistoryEnabled() {
+ return Services.prefs.getBoolPref("places.history.enabled", true);
+ },
+ onScreenshotCommand: new EventManager({
+ context,
+ name: "experiments.screenshots.onScreenshotCommand",
+ register: fire => {
+ let observer = (subject, topic, data) => {
+ let type = data;
+ fire.sync(type);
+ };
+ Services.obs.addObserver(observer, TOPIC);
+ return () => {
+ Services.obs.removeObserver(observer, TOPIC);
+ };
+ },
+ }).api(),
+ },
+ },
+ };
+ }
+};
diff --git a/browser/extensions/screenshots/experiments/screenshots/schema.json b/browser/extensions/screenshots/experiments/screenshots/schema.json
new file mode 100644
index 0000000000..354af8c722
--- /dev/null
+++ b/browser/extensions/screenshots/experiments/screenshots/schema.json
@@ -0,0 +1,35 @@
+[
+ {
+ "namespace": "experiments.screenshots",
+ "description": "Firefox Screenshots internal API",
+ "functions": [
+ {
+ "name": "getUpdateChannel",
+ "type": "function",
+ "description": "Returns the Firefox channel (AppConstants.MOZ_UPDATE_CHANNEL)",
+ "parameters": [],
+ "async": true
+ },
+ {
+ "name": "isHistoryEnabled",
+ "type": "function",
+ "description": "Returns the value of the 'places.history.enabled' preference",
+ "parameters": [],
+ "async": true
+ }
+ ],
+ "events": [
+ {
+ "name": "onScreenshotCommand",
+ "type": "function",
+ "description": "Fired when the command event for the Screenshots menuitem is fired.",
+ "parameters": [
+ {
+ "name": "isContextMenuClick",
+ "type": "boolean"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/browser/extensions/screenshots/log.js b/browser/extensions/screenshots/log.js
new file mode 100644
index 0000000000..585633486d
--- /dev/null
+++ b/browser/extensions/screenshots/log.js
@@ -0,0 +1,50 @@
+/* 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/. */
+
+/* globals buildSettings */
+/* eslint-disable no-console */
+
+"use strict";
+
+this.log = (function () {
+ const exports = {};
+ const logLevel = "warn";
+
+ const levels = ["debug", "info", "warn", "error"];
+ const shouldLog = {};
+
+ {
+ let startLogging = false;
+ for (const level of levels) {
+ if (logLevel === level) {
+ startLogging = true;
+ }
+ if (startLogging) {
+ shouldLog[level] = true;
+ }
+ }
+ }
+
+ function stub() {}
+ exports.debug = exports.info = exports.warn = exports.error = stub;
+
+ if (shouldLog.debug) {
+ exports.debug = console.debug;
+ }
+
+ if (shouldLog.info) {
+ exports.info = console.info;
+ }
+
+ if (shouldLog.warn) {
+ exports.warn = console.warn;
+ }
+
+ if (shouldLog.error) {
+ exports.error = console.error;
+ }
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/manifest.json b/browser/extensions/screenshots/manifest.json
new file mode 100644
index 0000000000..d5b164f058
--- /dev/null
+++ b/browser/extensions/screenshots/manifest.json
@@ -0,0 +1,56 @@
+{
+ "manifest_version": 2,
+ "name": "Firefox Screenshots",
+ "version": "39.0.1",
+ "description": "Take clips and screenshots from the Web and save them temporarily or permanently.",
+ "author": "Mozilla <screenshots-feedback@mozilla.com>",
+ "homepage_url": "https://github.com/mozilla-services/screenshots",
+ "incognito": "spanning",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "screenshots@mozilla.org",
+ "strict_min_version": "57.0a1"
+ }
+ },
+ "l10n_resources": ["browser/screenshots.ftl"],
+ "background": {
+ "scripts": ["background/startBackground.js"]
+ },
+ "content_scripts": [
+ {
+ "matches": ["https://screenshots.firefox.com/*"],
+ "js": [
+ "log.js",
+ "catcher.js",
+ "selector/callBackground.js",
+ "sitehelper.js"
+ ]
+ }
+ ],
+ "web_accessible_resources": ["blank.html"],
+ "permissions": [
+ "activeTab",
+ "downloads",
+ "tabs",
+ "storage",
+ "notifications",
+ "clipboardWrite",
+ "contextMenus",
+ "mozillaAddons",
+ "telemetry",
+ "<all_urls>",
+ "https://screenshots.firefox.com/",
+ "resource://pdf.js/",
+ "about:reader*"
+ ],
+ "experiment_apis": {
+ "screenshots": {
+ "schema": "experiments/screenshots/schema.json",
+ "parent": {
+ "scopes": ["addon_parent"],
+ "script": "experiments/screenshots/api.js",
+ "paths": [["experiments", "screenshots"]]
+ }
+ }
+ }
+}
diff --git a/browser/extensions/screenshots/moz.build b/browser/extensions/screenshots/moz.build
new file mode 100644
index 0000000000..f71d5b2ac6
--- /dev/null
+++ b/browser/extensions/screenshots/moz.build
@@ -0,0 +1,60 @@
+# -*- 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Screenshots")
+
+# This file list is automatically generated by Screenshots' export scripts.
+# AUTOMATIC INSERTION START
+FINAL_TARGET_FILES.features["screenshots@mozilla.org"] += [
+ "assertIsBlankDocument.js",
+ "assertIsTrusted.js",
+ "blank.html",
+ "blobConverters.js",
+ "catcher.js",
+ "clipboard.js",
+ "domainFromUrl.js",
+ "log.js",
+ "manifest.json",
+ "moz.build",
+ "randomString.js",
+ "sitehelper.js",
+]
+
+FINAL_TARGET_FILES.features["screenshots@mozilla.org"]["background"] += [
+ "background/analytics.js",
+ "background/communication.js",
+ "background/deviceInfo.js",
+ "background/main.js",
+ "background/selectorLoader.js",
+ "background/senderror.js",
+ "background/startBackground.js",
+ "background/takeshot.js",
+]
+
+FINAL_TARGET_FILES.features["screenshots@mozilla.org"]["build"] += [
+ "build/inlineSelectionCss.js",
+ "build/selection.js",
+ "build/shot.js",
+ "build/thumbnailGenerator.js",
+]
+
+FINAL_TARGET_FILES.features["screenshots@mozilla.org"]["experiments"][
+ "screenshots"
+] += ["experiments/screenshots/api.js", "experiments/screenshots/schema.json"]
+
+FINAL_TARGET_FILES.features["screenshots@mozilla.org"]["selector"] += [
+ "selector/callBackground.js",
+ "selector/documentMetadata.js",
+ "selector/shooter.js",
+ "selector/ui.js",
+ "selector/uicontrol.js",
+ "selector/util.js",
+]
+
+# AUTOMATIC INSERTION END
+
+BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"]
diff --git a/browser/extensions/screenshots/randomString.js b/browser/extensions/screenshots/randomString.js
new file mode 100644
index 0000000000..8ab27c73af
--- /dev/null
+++ b/browser/extensions/screenshots/randomString.js
@@ -0,0 +1,19 @@
+/* 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/. */
+
+/* exported randomString */
+
+"use strict";
+
+this.randomString = function randomString(length, chars) {
+ const randomStringChars =
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+ chars = chars || randomStringChars;
+ let result = "";
+ for (let i = 0; i < length; i++) {
+ result += chars[Math.floor(Math.random() * chars.length)];
+ }
+ return result;
+};
+null;
diff --git a/browser/extensions/screenshots/selector/callBackground.js b/browser/extensions/screenshots/selector/callBackground.js
new file mode 100644
index 0000000000..d92a6ace17
--- /dev/null
+++ b/browser/extensions/screenshots/selector/callBackground.js
@@ -0,0 +1,35 @@
+/* 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/. */
+
+/* globals log, browser */
+
+"use strict";
+
+this.callBackground = function callBackground(funcName, ...args) {
+ return browser.runtime.sendMessage({ funcName, args }).then(result => {
+ if (result && result.type === "success") {
+ return result.value;
+ } else if (result && result.type === "error") {
+ const exc = new Error(result.message || "Unknown error");
+ exc.name = "BackgroundError";
+ if ("errorCode" in result) {
+ exc.errorCode = result.errorCode;
+ }
+ if ("popupMessage" in result) {
+ exc.popupMessage = result.popupMessage;
+ }
+ throw exc;
+ } else {
+ log.error("Unexpected background result:", result);
+ const exc = new Error(
+ `Bad response type from background page: ${
+ (result && result.type) || undefined
+ }`
+ );
+ exc.resultType = result ? result.type || "undefined" : "undefined result";
+ throw exc;
+ }
+ });
+};
+null;
diff --git a/browser/extensions/screenshots/selector/documentMetadata.js b/browser/extensions/screenshots/selector/documentMetadata.js
new file mode 100644
index 0000000000..fe75b2bbae
--- /dev/null
+++ b/browser/extensions/screenshots/selector/documentMetadata.js
@@ -0,0 +1,93 @@
+/* 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/. */
+
+"use strict";
+
+this.documentMetadata = (function () {
+ function findSiteName() {
+ let el = document.querySelector("meta[property~='og:site_name'][content]");
+ if (el) {
+ return el.getAttribute("content");
+ }
+ // nytimes.com uses this property:
+ el = document.querySelector("meta[name='cre'][content]");
+ if (el) {
+ return el.getAttribute("content");
+ }
+ return null;
+ }
+
+ function getOpenGraph() {
+ const openGraph = {};
+ // If you update this, also update _OPENGRAPH_PROPERTIES in shot.js:
+ const forceSingle = `title type url`.split(" ");
+ const openGraphProperties = `
+ title type url image audio description determiner locale site_name video
+ image:secure_url image:type image:width image:height
+ video:secure_url video:type video:width image:height
+ audio:secure_url audio:type
+ article:published_time article:modified_time article:expiration_time article:author article:section article:tag
+ book:author book:isbn book:release_date book:tag
+ profile:first_name profile:last_name profile:username profile:gender
+ `.split(/\s+/g);
+ for (const prop of openGraphProperties) {
+ let elems = document.querySelectorAll(
+ `meta[property~='og:${prop}'][content]`
+ );
+ if (forceSingle.includes(prop) && elems.length > 1) {
+ elems = [elems[0]];
+ }
+ let value;
+ if (elems.length > 1) {
+ value = [];
+ for (const elem of elems) {
+ const v = elem.getAttribute("content");
+ if (v) {
+ value.push(v);
+ }
+ }
+ if (!value.length) {
+ value = null;
+ }
+ } else if (elems.length === 1) {
+ value = elems[0].getAttribute("content");
+ }
+ if (value) {
+ openGraph[prop] = value;
+ }
+ }
+ return openGraph;
+ }
+
+ function getTwitterCard() {
+ const twitterCard = {};
+ // If you update this, also update _TWITTERCARD_PROPERTIES in shot.js:
+ const properties = `
+ card site title description image
+ player player:width player:height player:stream player:stream:content_type
+ `.split(/\s+/g);
+ for (const prop of properties) {
+ const elem = document.querySelector(
+ `meta[name='twitter:${prop}'][content]`
+ );
+ if (elem) {
+ const value = elem.getAttribute("content");
+ if (value) {
+ twitterCard[prop] = value;
+ }
+ }
+ }
+ return twitterCard;
+ }
+
+ return function documentMetadata() {
+ const result = {};
+ result.docTitle = document.title;
+ result.siteName = findSiteName();
+ result.openGraph = getOpenGraph();
+ result.twitterCard = getTwitterCard();
+ return result;
+ };
+})();
+null;
diff --git a/browser/extensions/screenshots/selector/shooter.js b/browser/extensions/screenshots/selector/shooter.js
new file mode 100644
index 0000000000..da9f97e7cd
--- /dev/null
+++ b/browser/extensions/screenshots/selector/shooter.js
@@ -0,0 +1,163 @@
+/* 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/. */
+
+/* globals global, browser, documentMetadata, util, uicontrol, ui, catcher */
+/* globals domainFromUrl, randomString, shot, blobConverters */
+
+"use strict";
+
+this.shooter = (function () {
+ // eslint-disable-line no-unused-vars
+ const exports = {};
+ const { AbstractShot } = shot;
+
+ const RANDOM_STRING_LENGTH = 16;
+ let backend;
+ let shotObject;
+ const callBackground = global.callBackground;
+
+ function regexpEscape(str) {
+ // http://stackoverflow.com/questions/3115150/how-to-escape-regular-expression-special-characters-using-javascript
+ return str.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
+ }
+
+ function sanitizeError(data) {
+ const href = new RegExp(regexpEscape(window.location.href), "g");
+ const origin = new RegExp(
+ `${regexpEscape(window.location.origin)}[^ \t\n\r",>]*`,
+ "g"
+ );
+ const json = JSON.stringify(data)
+ .replace(href, "REDACTED_HREF")
+ .replace(origin, "REDACTED_URL");
+ const result = JSON.parse(json);
+ return result;
+ }
+
+ catcher.registerHandler(errorObj => {
+ callBackground("reportError", sanitizeError(errorObj));
+ });
+
+ function hideUIFrame() {
+ ui.iframe.hide();
+ return Promise.resolve(null);
+ }
+
+ function screenshotPage(dataUrl, selectedPos, type, screenshotTaskFn) {
+ let promise = Promise.resolve(dataUrl);
+
+ if (!dataUrl) {
+ promise = callBackground(
+ "screenshotPage",
+ selectedPos.toJSON(),
+ type,
+ window.devicePixelRatio
+ );
+ }
+
+ catcher.watchPromise(
+ promise.then(dataLoc => {
+ screenshotTaskFn(dataLoc);
+ })
+ );
+ }
+
+ exports.downloadShot = function (selectedPos, previewDataUrl, type) {
+ const shotPromise = previewDataUrl
+ ? Promise.resolve(previewDataUrl)
+ : hideUIFrame();
+ catcher.watchPromise(
+ shotPromise.then(dataUrl => {
+ screenshotPage(dataUrl, selectedPos, type, url => {
+ let typeFromDataUrl = blobConverters.getTypeFromDataUrl(url);
+ typeFromDataUrl = typeFromDataUrl
+ ? typeFromDataUrl.split("/", 2)[1]
+ : null;
+ shotObject.delAllClips();
+ shotObject.addClip({
+ createdDate: Date.now(),
+ image: {
+ url,
+ type: typeFromDataUrl,
+ location: selectedPos,
+ },
+ });
+ ui.triggerDownload(url, shotObject.filename);
+ uicontrol.deactivate();
+ });
+ })
+ );
+ };
+
+ exports.preview = function (selectedPos, type) {
+ catcher.watchPromise(
+ hideUIFrame().then(dataUrl => {
+ screenshotPage(dataUrl, selectedPos, type, url => {
+ ui.iframe.usePreview();
+ ui.Preview.display(url);
+ });
+ })
+ );
+ };
+
+ let copyInProgress = null;
+ exports.copyShot = function (selectedPos, previewDataUrl, type) {
+ // This is pretty slow. We'll ignore additional user triggered copy events
+ // while it is in progress.
+ if (copyInProgress) {
+ return;
+ }
+ // A max of five seconds in case some error occurs.
+ copyInProgress = setTimeout(() => {
+ copyInProgress = null;
+ }, 5000);
+
+ const unsetCopyInProgress = () => {
+ if (copyInProgress) {
+ clearTimeout(copyInProgress);
+ copyInProgress = null;
+ }
+ };
+ const shotPromise = previewDataUrl
+ ? Promise.resolve(previewDataUrl)
+ : hideUIFrame();
+ catcher.watchPromise(
+ shotPromise.then(dataUrl => {
+ screenshotPage(dataUrl, selectedPos, type, url => {
+ const blob = blobConverters.dataUrlToBlob(url);
+ catcher.watchPromise(
+ callBackground("copyShotToClipboard", blob).then(() => {
+ uicontrol.deactivate();
+ unsetCopyInProgress();
+ }, unsetCopyInProgress)
+ );
+ });
+ })
+ );
+ };
+
+ exports.sendEvent = function (...args) {
+ const maybeOptions = args[args.length - 1];
+
+ if (typeof maybeOptions === "object") {
+ maybeOptions.incognito = browser.extension.inIncognitoContext;
+ } else {
+ args.push({ incognito: browser.extension.inIncognitoContext });
+ }
+ };
+
+ catcher.watchFunction(() => {
+ shotObject = new AbstractShot(
+ backend,
+ randomString(RANDOM_STRING_LENGTH) + "/" + domainFromUrl(location),
+ {
+ origin: shot.originFromUrl(location.href),
+ }
+ );
+ shotObject.update(documentMetadata());
+ })();
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/selector/ui.js b/browser/extensions/screenshots/selector/ui.js
new file mode 100644
index 0000000000..c8433a8f84
--- /dev/null
+++ b/browser/extensions/screenshots/selector/ui.js
@@ -0,0 +1,904 @@
+/* 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/. */
+
+/* globals browser, log, util, catcher, inlineSelectionCss, callBackground, assertIsTrusted, assertIsBlankDocument, blobConverters */
+
+"use strict";
+
+this.ui = (function () {
+ // eslint-disable-line no-unused-vars
+ const exports = {};
+ const SAVE_BUTTON_HEIGHT = 50;
+
+ const { watchFunction } = catcher;
+
+ exports.isHeader = function (el) {
+ while (el) {
+ if (
+ el.classList &&
+ (el.classList.contains("visible") ||
+ el.classList.contains("full-page") ||
+ el.classList.contains("cancel-shot"))
+ ) {
+ return true;
+ }
+ el = el.parentNode;
+ }
+ return false;
+ };
+
+ const substitutedCss = inlineSelectionCss.replace(
+ /MOZ_EXTENSION([^"]+)/g,
+ (match, filename) => {
+ return browser.runtime.getURL(filename);
+ }
+ );
+
+ function makeEl(tagName, className) {
+ if (!iframe.document()) {
+ throw new Error("Attempted makeEl before iframe was initialized");
+ }
+ const el = iframe.document().createElement(tagName);
+ if (className) {
+ el.className = className;
+ }
+ return el;
+ }
+
+ function onResize() {
+ if (this.sizeTracking.windowDelayer) {
+ clearTimeout(this.sizeTracking.windowDelayer);
+ }
+ this.sizeTracking.windowDelayer = setTimeout(
+ watchFunction(() => {
+ this.updateElementSize(true);
+ }),
+ 50
+ );
+ }
+
+ function initializeIframe() {
+ const el = document.createElement("iframe");
+ el.src = browser.runtime.getURL("blank.html");
+ el.style.zIndex = "99999999999";
+ el.style.border = "none";
+ el.style.top = "0";
+ el.style.left = "0";
+ el.style.margin = "0";
+ el.scrolling = "no";
+ el.style.clip = "auto";
+ el.style.backgroundColor = "transparent";
+ el.style.colorScheme = "light";
+ return el;
+ }
+
+ const iframeSelection = (exports.iframeSelection = {
+ element: null,
+ addClassName: "",
+ sizeTracking: {
+ timer: null,
+ windowDelayer: null,
+ lastHeight: null,
+ lastWidth: null,
+ },
+ document: null,
+ window: null,
+ display(installHandlerOnDocument) {
+ return new Promise((resolve, reject) => {
+ if (!this.element) {
+ this.element = initializeIframe();
+ this.element.id = "firefox-screenshots-selection-iframe";
+ this.element.style.display = "none";
+ this.element.style.setProperty("max-width", "none", "important");
+ this.element.style.setProperty("max-height", "none", "important");
+ this.element.style.setProperty("position", "absolute", "important");
+ this.element.setAttribute("role", "dialog");
+ this.updateElementSize();
+ this.element.addEventListener(
+ "load",
+ watchFunction(() => {
+ this.document = this.element.contentDocument;
+ this.window = this.element.contentWindow;
+ assertIsBlankDocument(this.document);
+ // eslint-disable-next-line no-unsanitized/property
+ this.document.documentElement.innerHTML = `
+ <head>
+ <style>${substitutedCss}</style>
+ <title></title>
+ </head>
+ <body></body>`;
+ installHandlerOnDocument(this.document);
+ if (this.addClassName) {
+ this.document.body.className = this.addClassName;
+ }
+ this.document.documentElement.dir =
+ browser.i18n.getMessage("@@bidi_dir");
+ this.document.documentElement.lang =
+ browser.i18n.getMessage("@@ui_locale");
+ resolve();
+ }),
+ { once: true }
+ );
+ document.body.appendChild(this.element);
+ } else {
+ resolve();
+ }
+ });
+ },
+
+ hide() {
+ this.element.style.display = "none";
+ this.stopSizeWatch();
+ },
+
+ unhide() {
+ this.updateElementSize();
+ this.element.style.display = "block";
+ this.initSizeWatch();
+ this.element.focus();
+ },
+
+ updateElementSize(force) {
+ // Note: if someone sizes down the page, then the iframe will keep the
+ // document from naturally shrinking. We use force to temporarily hide
+ // the element so that we can tell if the document shrinks
+ const visible = this.element.style.display !== "none";
+ if (force && visible) {
+ this.element.style.display = "none";
+ }
+ const height = Math.max(
+ document.documentElement.clientHeight,
+ document.body.clientHeight,
+ document.documentElement.scrollHeight,
+ document.body.scrollHeight
+ );
+ if (height !== this.sizeTracking.lastHeight) {
+ this.sizeTracking.lastHeight = height;
+ this.element.style.height = height + "px";
+ }
+ // Do not use window.innerWidth since that includes the width of the
+ // scroll bar.
+ const width = Math.max(
+ document.documentElement.clientWidth,
+ document.body.clientWidth,
+ document.documentElement.scrollWidth,
+ document.body.scrollWidth
+ );
+ if (width !== this.sizeTracking.lastWidth) {
+ this.sizeTracking.lastWidth = width;
+ this.element.style.width = width + "px";
+ // Since this frame has an absolute position relative to the parent
+ // document, if the parent document's body has a relative position and
+ // left and/or top not at 0, then the left and/or top of the parent
+ // document's body is not at (0, 0) of the viewport. That makes the
+ // frame shifted relative to the viewport. These margins negates that.
+ if (window.getComputedStyle(document.body).position === "relative") {
+ const docBoundingRect =
+ document.documentElement.getBoundingClientRect();
+ const bodyBoundingRect = document.body.getBoundingClientRect();
+ this.element.style.marginLeft = `-${
+ bodyBoundingRect.left - docBoundingRect.left
+ }px`;
+ this.element.style.marginTop = `-${
+ bodyBoundingRect.top - docBoundingRect.top
+ }px`;
+ }
+ }
+ if (force && visible) {
+ this.element.style.display = "block";
+ }
+ },
+
+ initSizeWatch() {
+ this.stopSizeWatch();
+ this.sizeTracking.timer = setInterval(
+ watchFunction(this.updateElementSize.bind(this)),
+ 2000
+ );
+ window.addEventListener("resize", this.onResize, true);
+ },
+
+ stopSizeWatch() {
+ if (this.sizeTracking.timer) {
+ clearTimeout(this.sizeTracking.timer);
+ this.sizeTracking.timer = null;
+ }
+ if (this.sizeTracking.windowDelayer) {
+ clearTimeout(this.sizeTracking.windowDelayer);
+ this.sizeTracking.windowDelayer = null;
+ }
+ this.sizeTracking.lastHeight = this.sizeTracking.lastWidth = null;
+ window.removeEventListener("resize", this.onResize, true);
+ },
+
+ getElementFromPoint(x, y) {
+ this.element.style.pointerEvents = "none";
+ let el;
+ try {
+ el = document.elementFromPoint(x, y);
+ } finally {
+ this.element.style.pointerEvents = "";
+ }
+ return el;
+ },
+
+ remove() {
+ this.stopSizeWatch();
+ util.removeNode(this.element);
+ this.element = this.document = this.window = null;
+ },
+ });
+
+ iframeSelection.onResize = watchFunction(
+ assertIsTrusted(onResize.bind(iframeSelection)),
+ true
+ );
+
+ const iframePreSelection = (exports.iframePreSelection = {
+ element: null,
+ document: null,
+ window: null,
+ display(installHandlerOnDocument, standardOverlayCallbacks) {
+ return new Promise((resolve, reject) => {
+ if (!this.element) {
+ this.element = initializeIframe();
+ this.element.id = "firefox-screenshots-preselection-iframe";
+ this.element.style.setProperty("position", "fixed", "important");
+ this.element.style.width = "100%";
+ this.element.style.height = "100%";
+ this.element.style.setProperty("max-width", "none", "important");
+ this.element.style.setProperty("max-height", "none", "important");
+ this.element.setAttribute("role", "dialog");
+ this.element.addEventListener(
+ "load",
+ watchFunction(() => {
+ this.document = this.element.contentDocument;
+ this.window = this.element.contentWindow;
+ assertIsBlankDocument(this.document);
+ // eslint-disable-next-line no-unsanitized/property
+ this.document.documentElement.innerHTML = `
+ <head>
+ <link rel="localization" href="browser/screenshots.ftl">
+ <style>${substitutedCss}</style>
+ <title></title>
+ </head>
+ <body>
+ <div class="preview-overlay precision-cursor">
+ <div class="fixed-container">
+ <div class="face-container">
+ <div class="eye left"><div class="eyeball"></div></div>
+ <div class="eye right"><div class="eyeball"></div></div>
+ <div class="face"></div>
+ </div>
+ <div class="preview-instructions" data-l10n-id="screenshots-instructions"></div>
+ <button class="cancel-shot" data-l10n-id="screenshots-cancel-button"></button>
+ <div class="all-buttons-container">
+ <button class="visible" tabindex="2" data-l10n-id="screenshots-save-visible-button"></button>
+ <button class="full-page" tabindex="1" data-l10n-id="screenshots-save-page-button"></button>
+ </div>
+ </div>
+ </div>
+ </body>`;
+ installHandlerOnDocument(this.document);
+ if (this.addClassName) {
+ this.document.body.className = this.addClassName;
+ }
+ this.document.documentElement.dir =
+ browser.i18n.getMessage("@@bidi_dir");
+ this.document.documentElement.lang =
+ browser.i18n.getMessage("@@ui_locale");
+ const overlay = this.document.querySelector(".preview-overlay");
+ overlay
+ .querySelector(".visible")
+ .addEventListener(
+ "click",
+ watchFunction(
+ assertIsTrusted(standardOverlayCallbacks.onClickVisible)
+ )
+ );
+ overlay
+ .querySelector(".full-page")
+ .addEventListener(
+ "click",
+ watchFunction(
+ assertIsTrusted(standardOverlayCallbacks.onClickFullPage)
+ )
+ );
+ overlay
+ .querySelector(".cancel-shot")
+ .addEventListener(
+ "click",
+ watchFunction(
+ assertIsTrusted(standardOverlayCallbacks.onClickCancel)
+ )
+ );
+
+ resolve();
+ }),
+ { once: true }
+ );
+ document.body.appendChild(this.element);
+ } else {
+ resolve();
+ }
+ });
+ },
+
+ hide() {
+ window.removeEventListener(
+ "scroll",
+ watchFunction(assertIsTrusted(this.onScroll))
+ );
+ window.removeEventListener("resize", this.onResize, true);
+ if (this.element) {
+ this.element.style.display = "none";
+ }
+ },
+
+ unhide() {
+ window.addEventListener(
+ "scroll",
+ watchFunction(assertIsTrusted(this.onScroll))
+ );
+ window.addEventListener("resize", this.onResize, true);
+ this.element.style.display = "block";
+ this.element.focus();
+ },
+
+ onScroll() {
+ exports.HoverBox.hide();
+ },
+
+ getElementFromPoint(x, y) {
+ this.element.style.pointerEvents = "none";
+ let el;
+ try {
+ el = document.elementFromPoint(x, y);
+ } finally {
+ this.element.style.pointerEvents = "";
+ }
+ return el;
+ },
+
+ remove() {
+ this.hide();
+ util.removeNode(this.element);
+ this.element = this.document = this.window = null;
+ },
+ });
+
+ let msgsPromise = callBackground("getStrings", [
+ "screenshots-cancel-button",
+ "screenshots-copy-button-tooltip",
+ "screenshots-download-button-tooltip",
+ "screenshots-copy-button",
+ "screenshots-download-button",
+ ]);
+
+ const iframePreview = (exports.iframePreview = {
+ element: null,
+ document: null,
+ window: null,
+ display(installHandlerOnDocument, standardOverlayCallbacks) {
+ return new Promise((resolve, reject) => {
+ if (!this.element) {
+ this.element = initializeIframe();
+ this.element.id = "firefox-screenshots-preview-iframe";
+ this.element.style.display = "none";
+ this.element.style.setProperty("position", "fixed", "important");
+ this.element.style.height = "100%";
+ this.element.style.width = "100%";
+ this.element.style.setProperty("max-width", "none", "important");
+ this.element.style.setProperty("max-height", "none", "important");
+ this.element.setAttribute("role", "dialog");
+ this.element.onload = watchFunction(() => {
+ msgsPromise.then(([cancelTitle, copyTitle, downloadTitle]) => {
+ assertIsBlankDocument(this.element.contentDocument);
+ this.document = this.element.contentDocument;
+ this.window = this.element.contentWindow;
+ // eslint-disable-next-line no-unsanitized/property
+ this.document.documentElement.innerHTML = `
+ <head>
+ <link rel="localization" href="browser/screenshots.ftl">
+ <style>${substitutedCss}</style>
+ <title></title>
+ </head>
+ <body>
+ <div class="preview-overlay">
+ <div class="preview-image">
+ <div class="preview-buttons">
+ <button class="highlight-button-cancel" title="${cancelTitle}">
+ <img src="chrome://browser/content/screenshots/cancel.svg"/>
+ </button>
+ <button class="highlight-button-copy" title="${copyTitle}">
+ <img src="chrome://browser/content/screenshots/copy.svg"/>
+ <span data-l10n-id="screenshots-copy-button"/>
+ </button>
+ <button class="highlight-button-download" title="${downloadTitle}">
+ <img src="chrome://browser/content/screenshots/download-white.svg"/>
+ <span data-l10n-id="screenshots-download-button"/>
+ </button>
+ </div>
+ <div class="preview-image-wrapper"></div>
+ </div>
+ <div class="loader" style="display:none">
+ <div class="loader-inner"></div>
+ </div>
+ </div>
+ </body>`;
+
+ installHandlerOnDocument(this.document);
+ this.document.documentElement.dir =
+ browser.i18n.getMessage("@@bidi_dir");
+ this.document.documentElement.lang =
+ browser.i18n.getMessage("@@ui_locale");
+
+ const overlay = this.document.querySelector(".preview-overlay");
+ overlay
+ .querySelector(".highlight-button-copy")
+ .addEventListener(
+ "click",
+ watchFunction(
+ assertIsTrusted(standardOverlayCallbacks.onCopyPreview)
+ )
+ );
+ overlay
+ .querySelector(".highlight-button-download")
+ .addEventListener(
+ "click",
+ watchFunction(
+ assertIsTrusted(standardOverlayCallbacks.onDownloadPreview)
+ )
+ );
+ overlay
+ .querySelector(".highlight-button-cancel")
+ .addEventListener(
+ "click",
+ watchFunction(
+ assertIsTrusted(standardOverlayCallbacks.cancel)
+ )
+ );
+ resolve();
+ });
+ });
+ document.body.appendChild(this.element);
+ } else {
+ resolve();
+ }
+ });
+ },
+
+ hide() {
+ if (this.element) {
+ this.element.style.display = "none";
+ }
+ },
+
+ unhide() {
+ this.element.style.display = "block";
+ this.element.focus();
+ },
+
+ showLoader() {
+ this.document.body.querySelector(".preview-image").style.display = "none";
+ this.document.body.querySelector(".loader").style.display = "";
+ },
+
+ remove() {
+ this.hide();
+ util.removeNode(this.element);
+ this.element = null;
+ this.document = null;
+ },
+ });
+
+ iframePreSelection.onResize = watchFunction(
+ onResize.bind(iframePreSelection),
+ true
+ );
+
+ const iframe = (exports.iframe = {
+ currentIframe: iframePreSelection,
+ display(installHandlerOnDocument, standardOverlayCallbacks) {
+ return iframeSelection
+ .display(installHandlerOnDocument)
+ .then(() =>
+ iframePreSelection.display(
+ installHandlerOnDocument,
+ standardOverlayCallbacks
+ )
+ )
+ .then(() =>
+ iframePreview.display(
+ installHandlerOnDocument,
+ standardOverlayCallbacks
+ )
+ );
+ },
+
+ hide() {
+ this.currentIframe.hide();
+ },
+
+ unhide() {
+ this.currentIframe.unhide();
+ },
+
+ showLoader() {
+ if (this.currentIframe.showLoader) {
+ this.currentIframe.showLoader();
+ this.currentIframe.unhide();
+ }
+ },
+
+ getElementFromPoint(x, y) {
+ return this.currentIframe.getElementFromPoint(x, y);
+ },
+
+ remove() {
+ iframeSelection.remove();
+ iframePreSelection.remove();
+ iframePreview.remove();
+ },
+
+ getContentWindow() {
+ return this.currentIframe.element.contentWindow;
+ },
+
+ document() {
+ return this.currentIframe.document;
+ },
+
+ useSelection() {
+ if (
+ this.currentIframe === iframePreSelection ||
+ this.currentIframe === iframePreview
+ ) {
+ this.hide();
+ }
+ this.currentIframe = iframeSelection;
+ this.unhide();
+ },
+
+ usePreSelection() {
+ if (
+ this.currentIframe === iframeSelection ||
+ this.currentIframe === iframePreview
+ ) {
+ this.hide();
+ }
+ this.currentIframe = iframePreSelection;
+ this.unhide();
+ },
+
+ usePreview() {
+ if (
+ this.currentIframe === iframeSelection ||
+ this.currentIframe === iframePreSelection
+ ) {
+ this.hide();
+ }
+ this.currentIframe = iframePreview;
+ this.unhide();
+ },
+ });
+
+ const movements = [
+ "topLeft",
+ "top",
+ "topRight",
+ "left",
+ "right",
+ "bottomLeft",
+ "bottom",
+ "bottomRight",
+ ];
+
+ /** Creates the selection box */
+ exports.Box = {
+ async display(pos, callbacks) {
+ await this._createEl();
+ if (callbacks !== undefined && callbacks.cancel) {
+ // We use onclick here because we don't want addEventListener
+ // to add multiple event handlers to the same button
+ this.cancel.onclick = watchFunction(assertIsTrusted(callbacks.cancel));
+ this.cancel.style.display = "";
+ } else {
+ this.cancel.style.display = "none";
+ }
+ if (callbacks !== undefined && callbacks.download) {
+ this.download.removeAttribute("disabled");
+ this.download.onclick = watchFunction(
+ assertIsTrusted(e => {
+ this.download.setAttribute("disabled", true);
+ callbacks.download(e);
+ e.preventDefault();
+ e.stopPropagation();
+ return false;
+ })
+ );
+ this.download.style.display = "";
+ } else {
+ this.download.style.display = "none";
+ }
+ if (callbacks !== undefined && callbacks.copy) {
+ this.copy.removeAttribute("disabled");
+ this.copy.onclick = watchFunction(
+ assertIsTrusted(e => {
+ this.copy.setAttribute("disabled", true);
+ callbacks.copy(e);
+ e.preventDefault();
+ e.stopPropagation();
+ })
+ );
+ this.copy.style.display = "";
+ } else {
+ this.copy.style.display = "none";
+ }
+
+ const winBottom = window.innerHeight;
+ const pageYOffset = window.pageYOffset;
+
+ if (pos.right - pos.left < 78 || pos.bottom - pos.top < 78) {
+ this.el.classList.add("small-selection");
+ } else {
+ this.el.classList.remove("small-selection");
+ }
+
+ // if the selection bounding box is w/in SAVE_BUTTON_HEIGHT px of the bottom of
+ // the window, flip controls into the box
+ if (pos.bottom > winBottom + pageYOffset - SAVE_BUTTON_HEIGHT) {
+ this.el.classList.add("bottom-selection");
+ } else {
+ this.el.classList.remove("bottom-selection");
+ }
+
+ if (pos.right < 200) {
+ this.el.classList.add("left-selection");
+ } else {
+ this.el.classList.remove("left-selection");
+ }
+ this.el.style.top = `${pos.top}px`;
+ this.el.style.left = `${pos.left}px`;
+ this.el.style.height = `${pos.bottom - pos.top}px`;
+ this.el.style.width = `${pos.right - pos.left}px`;
+ this.bgTop.style.top = "0px";
+ this.bgTop.style.height = `${pos.top}px`;
+ this.bgTop.style.left = "0px";
+ this.bgTop.style.width = "100%";
+ this.bgBottom.style.top = `${pos.bottom}px`;
+ this.bgBottom.style.height = `calc(100vh - ${pos.bottom}px)`;
+ this.bgBottom.style.left = "0px";
+ this.bgBottom.style.width = "100%";
+ this.bgLeft.style.top = `${pos.top}px`;
+ this.bgLeft.style.height = `${pos.bottom - pos.top}px`;
+ this.bgLeft.style.left = "0px";
+ this.bgLeft.style.width = `${pos.left}px`;
+ this.bgRight.style.top = `${pos.top}px`;
+ this.bgRight.style.height = `${pos.bottom - pos.top}px`;
+ this.bgRight.style.left = `${pos.right}px`;
+ this.bgRight.style.width = `calc(100% - ${pos.right}px)`;
+ },
+
+ // used to eventually move the download-only warning
+ // when a user ends scrolling or ends resizing a window
+ delayExecution(delay, cb) {
+ let timer;
+ return function () {
+ if (typeof timer !== "undefined") {
+ clearTimeout(timer);
+ }
+ timer = setTimeout(cb, delay);
+ };
+ },
+
+ remove() {
+ for (const name of ["el", "bgTop", "bgLeft", "bgRight", "bgBottom"]) {
+ if (name in this) {
+ util.removeNode(this[name]);
+ this[name] = null;
+ }
+ }
+ },
+
+ async _createEl() {
+ let boxEl = this.el;
+ if (boxEl) {
+ return;
+ }
+ let [cancelTitle, copyTitle, downloadTitle, copyText, downloadText] =
+ await msgsPromise;
+ boxEl = makeEl("div", "highlight");
+ const buttons = makeEl("div", "highlight-buttons");
+ const cancel = makeEl("button", "highlight-button-cancel");
+ const cancelImg = makeEl("img");
+ cancelImg.src = "chrome://browser/content/screenshots/cancel.svg";
+ cancel.title = cancelTitle;
+ cancel.appendChild(cancelImg);
+ buttons.appendChild(cancel);
+
+ const copy = makeEl("button", "highlight-button-copy");
+ copy.title = copyTitle;
+ const copyImg = makeEl("img");
+ const copyString = makeEl("span");
+ copyString.textContent = copyText;
+ copyImg.src = "chrome://browser/content/screenshots/copy.svg";
+ copy.appendChild(copyImg);
+ copy.appendChild(copyString);
+ buttons.appendChild(copy);
+
+ const download = makeEl("button", "highlight-button-download");
+ const downloadImg = makeEl("img");
+ downloadImg.src =
+ "chrome://browser/content/screenshots/download-white.svg";
+ download.appendChild(downloadImg);
+ download.append(downloadText);
+ download.title = downloadTitle;
+ buttons.appendChild(download);
+ this.cancel = cancel;
+ this.download = download;
+ this.copy = copy;
+
+ boxEl.appendChild(buttons);
+ for (const name of movements) {
+ const elTarget = makeEl("div", "mover-target direction-" + name);
+ const elMover = makeEl("div", "mover");
+ elTarget.appendChild(elMover);
+ boxEl.appendChild(elTarget);
+ }
+ this.bgTop = makeEl("div", "bghighlight");
+ iframe.document().body.appendChild(this.bgTop);
+ this.bgLeft = makeEl("div", "bghighlight");
+ iframe.document().body.appendChild(this.bgLeft);
+ this.bgRight = makeEl("div", "bghighlight");
+ iframe.document().body.appendChild(this.bgRight);
+ this.bgBottom = makeEl("div", "bghighlight");
+ iframe.document().body.appendChild(this.bgBottom);
+ iframe.document().body.appendChild(boxEl);
+ this.el = boxEl;
+ },
+
+ draggerDirection(target) {
+ while (target) {
+ if (target.nodeType === document.ELEMENT_NODE) {
+ if (target.classList.contains("mover-target")) {
+ for (const name of movements) {
+ if (target.classList.contains("direction-" + name)) {
+ return name;
+ }
+ }
+ catcher.unhandled(new Error("Surprising mover element"), {
+ element: target.outerHTML,
+ });
+ log.warn("Got mover-target that wasn't a specific direction");
+ }
+ }
+ target = target.parentNode;
+ }
+ return null;
+ },
+
+ isSelection(target) {
+ while (target) {
+ if (target.tagName === "BUTTON") {
+ return false;
+ }
+ if (
+ target.nodeType === document.ELEMENT_NODE &&
+ target.classList.contains("highlight")
+ ) {
+ return true;
+ }
+ target = target.parentNode;
+ }
+ return false;
+ },
+
+ isControl(target) {
+ while (target) {
+ if (
+ target.nodeType === document.ELEMENT_NODE &&
+ target.classList.contains("highlight-buttons")
+ ) {
+ return true;
+ }
+ target = target.parentNode;
+ }
+ return false;
+ },
+
+ el: null,
+ boxTopEl: null,
+ boxLeftEl: null,
+ boxRightEl: null,
+ boxBottomEl: null,
+ };
+
+ exports.HoverBox = {
+ el: null,
+
+ display(rect) {
+ if (!this.el) {
+ this.el = makeEl("div", "hover-highlight");
+ iframe.document().body.appendChild(this.el);
+ }
+ this.el.style.display = "";
+ this.el.style.top = rect.top - 1 + "px";
+ this.el.style.left = rect.left - 1 + "px";
+ this.el.style.width = rect.right - rect.left + 2 + "px";
+ this.el.style.height = rect.bottom - rect.top + 2 + "px";
+ },
+
+ hide() {
+ if (this.el) {
+ this.el.style.display = "none";
+ }
+ },
+
+ remove() {
+ util.removeNode(this.el);
+ this.el = null;
+ },
+ };
+
+ exports.PixelDimensions = {
+ el: null,
+ xEl: null,
+ yEl: null,
+ display(xPos, yPos, x, y) {
+ if (!this.el) {
+ this.el = makeEl("div", "pixel-dimensions");
+ this.xEl = makeEl("div");
+ this.el.appendChild(this.xEl);
+ this.yEl = makeEl("div");
+ this.el.appendChild(this.yEl);
+ iframe.document().body.appendChild(this.el);
+ }
+ this.xEl.textContent = Math.round(x);
+ this.yEl.textContent = Math.round(y);
+ this.el.style.top = yPos + 12 + "px";
+ this.el.style.left = xPos + 12 + "px";
+ },
+ remove() {
+ util.removeNode(this.el);
+ this.el = this.xEl = this.yEl = null;
+ },
+ };
+
+ exports.Preview = {
+ display(dataUrl) {
+ const img = makeEl("IMG");
+ const imgBlob = blobConverters.dataUrlToBlob(dataUrl);
+ img.src = iframe.getContentWindow().URL.createObjectURL(imgBlob);
+ iframe
+ .document()
+ .querySelector(".preview-image-wrapper")
+ .appendChild(img);
+ },
+ };
+
+ /** Removes every UI this module creates */
+ exports.remove = function () {
+ for (const name in exports) {
+ if (name.startsWith("iframe")) {
+ continue;
+ }
+ if (typeof exports[name] === "object" && exports[name].remove) {
+ exports[name].remove();
+ }
+ }
+ exports.iframe.remove();
+ };
+
+ exports.triggerDownload = function (url, filename) {
+ return catcher.watchPromise(
+ callBackground("downloadShot", { url, filename })
+ );
+ };
+
+ exports.unload = exports.remove;
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/selector/uicontrol.js b/browser/extensions/screenshots/selector/uicontrol.js
new file mode 100644
index 0000000000..b690281083
--- /dev/null
+++ b/browser/extensions/screenshots/selector/uicontrol.js
@@ -0,0 +1,1026 @@
+/* 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/. */
+
+/* globals log, catcher, util, ui, slides, global */
+/* globals shooter, callBackground, selectorLoader, assertIsTrusted, selection */
+
+"use strict";
+
+this.uicontrol = (function () {
+ const exports = {};
+
+ /** ********************************************************
+ * selection
+ */
+
+ /* 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
+ "cancelled":
+ Everything has been cancelled
+ "previewing":
+ The user is previewing the full-screen/visible image
+
+ A mousedown goes from crosshairs to dragging.
+ A mouseup goes from dragging to selected
+ A click outside of the selection goes from selected to crosshairs
+ A click on one of the draggers goes from selected to resizing
+
+ State variables:
+
+ state (string, one of the above)
+ mousedownPos (object with x/y during draggingReady, shows where the selection started)
+ selectedPos (object with x/y/h/w during selected or dragging, gives the entire selection)
+ resizeDirection (string: top, topLeft, etc, during resizing)
+ resizeStartPos (x/y position where resizing started)
+ mouseupNoAutoselect (true if a mouseup in draggingReady should not trigger autoselect)
+
+ */
+
+ const { watchFunction, watchPromise } = catcher;
+
+ const MAX_PAGE_HEIGHT = 10000;
+ const MAX_PAGE_WIDTH = 10000;
+ // 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:
+ const MAX_DETECT_HEIGHT = Math.max(window.innerHeight + 100, 700);
+ const MAX_DETECT_WIDTH = Math.max(window.innerWidth + 100, 1000);
+ // This is how close (in pixels) you can get to the edge of the window and then
+ // it will scroll:
+ const SCROLL_BY_EDGE = 20;
+ // This is how wide the inboard scrollbars are, generally 0 except on Mac
+ const SCROLLBAR_WIDTH = window.navigator.platform.match(/Mac/i) ? 17 : 0;
+
+ const { Selection } = selection;
+ const { sendEvent } = shooter;
+ const log = global.log;
+
+ function round10(n) {
+ return Math.floor(n / 10) * 10;
+ }
+
+ function eventOptionsForBox(box) {
+ return {
+ cd1: round10(Math.abs(box.bottom - box.top)),
+ cd2: round10(Math.abs(box.right - box.left)),
+ };
+ }
+
+ function eventOptionsForResize(boxStart, boxEnd) {
+ return {
+ cd1: round10(
+ boxEnd.bottom - boxEnd.top - (boxStart.bottom - boxStart.top)
+ ),
+ cd2: round10(
+ boxEnd.right - boxEnd.left - (boxStart.right - boxStart.left)
+ ),
+ };
+ }
+
+ function eventOptionsForMove(posStart, posEnd) {
+ return {
+ cd1: round10(posEnd.y - posStart.y),
+ cd2: round10(posEnd.x - posStart.x),
+ };
+ }
+
+ function downloadShot() {
+ const previewDataUrl = captureType === "fullPageTruncated" ? null : dataUrl;
+ // Downloaded shots don't have dimension limits
+ removeDimensionLimitsOnFullPageShot();
+ shooter.downloadShot(selectedPos, previewDataUrl, captureType);
+ }
+
+ function copyShot() {
+ const previewDataUrl = captureType === "fullPageTruncated" ? null : dataUrl;
+ // Copied shots don't have dimension limits
+ removeDimensionLimitsOnFullPageShot();
+ shooter.copyShot(selectedPos, previewDataUrl, captureType);
+ }
+
+ /** *********************************************
+ * State and stateHandlers infrastructure
+ */
+
+ // This enumerates all the anchors on the selection, and what part of the
+ // selection they move:
+ const movements = {
+ topLeft: ["x1", "y1"],
+ top: [null, "y1"],
+ topRight: ["x2", "y1"],
+ left: ["x1", null],
+ right: ["x2", null],
+ bottomLeft: ["x1", "y2"],
+ bottom: [null, "y2"],
+ bottomRight: ["x2", "y2"],
+ move: ["*", "*"],
+ };
+
+ const doNotAutoselectTags = {
+ H1: true,
+ H2: true,
+ H3: true,
+ H4: true,
+ H5: true,
+ H6: true,
+ };
+
+ let captureType;
+
+ function removeDimensionLimitsOnFullPageShot() {
+ if (captureType === "fullPageTruncated") {
+ captureType = "fullPage";
+ selectedPos = new Selection(
+ 0,
+ 0,
+ getDocumentWidth(),
+ getDocumentHeight()
+ );
+ }
+ }
+
+ const standardDisplayCallbacks = {
+ cancel: () => {
+ sendEvent("cancel-shot", "overlay-cancel-button");
+ exports.deactivate();
+ },
+ download: () => {
+ sendEvent("download-shot", "overlay-download-button");
+ downloadShot();
+ },
+ copy: () => {
+ sendEvent("copy-shot", "overlay-copy-button");
+ copyShot();
+ },
+ };
+
+ const standardOverlayCallbacks = {
+ cancel: () => {
+ sendEvent("cancel-shot", "cancel-preview-button");
+ exports.deactivate();
+ },
+ onClickCancel: e => {
+ sendEvent("cancel-shot", "cancel-selection-button");
+ e.preventDefault();
+ e.stopPropagation();
+ exports.deactivate();
+ },
+ onClickVisible: () => {
+ callBackground("captureTelemetry", "visible");
+ sendEvent("capture-visible", "selection-button");
+ selectedPos = new Selection(
+ window.scrollX,
+ window.scrollY,
+ window.scrollX + document.documentElement.clientWidth,
+ window.scrollY + window.innerHeight
+ );
+ captureType = "visible";
+ setState("previewing");
+ },
+ onClickFullPage: () => {
+ callBackground("captureTelemetry", "full_page");
+ sendEvent("capture-full-page", "selection-button");
+ captureType = "fullPage";
+ const width = getDocumentWidth();
+ if (width > MAX_PAGE_WIDTH) {
+ captureType = "fullPageTruncated";
+ }
+ const height = getDocumentHeight();
+ if (height > MAX_PAGE_HEIGHT) {
+ captureType = "fullPageTruncated";
+ }
+ selectedPos = new Selection(0, 0, width, height);
+ setState("previewing");
+ },
+ onDownloadPreview: () => {
+ sendEvent(
+ `download-${captureType
+ .replace(/([a-z])([A-Z])/g, "$1-$2")
+ .toLowerCase()}`,
+ "download-preview-button"
+ );
+ downloadShot();
+ },
+ onCopyPreview: () => {
+ sendEvent(
+ `copy-${captureType.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()}`,
+ "copy-preview-button"
+ );
+ copyShot();
+ },
+ };
+
+ /** Holds all the objects that handle events for each state: */
+ const stateHandlers = {};
+
+ function getState() {
+ return getState.state;
+ }
+ getState.state = "cancel";
+
+ function setState(s) {
+ if (!stateHandlers[s]) {
+ throw new Error("Unknown state: " + s);
+ }
+ const cur = getState.state;
+ const handler = stateHandlers[cur];
+ if (handler.end) {
+ handler.end();
+ }
+ getState.state = s;
+ if (stateHandlers[s].start) {
+ stateHandlers[s].start();
+ }
+ }
+
+ /** Various values that the states use: */
+ let mousedownPos;
+ let selectedPos;
+ let resizeDirection;
+ let resizeStartPos;
+ let resizeStartSelected;
+ let resizeHasMoved;
+ let mouseupNoAutoselect = false;
+ let autoDetectRect;
+
+ /** Represents a single x/y point, typically for a mouse click that doesn't have a drag: */
+ class Pos {
+ constructor(x, y) {
+ this.x = x;
+ this.y = y;
+ }
+
+ elementFromPoint() {
+ return ui.iframe.getElementFromPoint(
+ this.x - window.pageXOffset,
+ this.y - window.pageYOffset
+ );
+ }
+
+ distanceTo(x, y) {
+ return Math.sqrt(Math.pow(this.x - x, 2) + Math.pow(this.y - y, 2));
+ }
+ }
+
+ /** *********************************************
+ * all stateHandlers
+ */
+
+ let dataUrl;
+
+ stateHandlers.previewing = {
+ start() {
+ shooter.preview(selectedPos, captureType);
+ },
+ };
+
+ stateHandlers.crosshairs = {
+ cachedEl: null,
+
+ start() {
+ selectedPos = mousedownPos = null;
+ this.cachedEl = null;
+ watchPromise(
+ ui.iframe
+ .display(installHandlersOnDocument, standardOverlayCallbacks)
+ .then(() => {
+ ui.iframe.usePreSelection();
+ ui.Box.remove();
+ })
+ );
+ },
+
+ mousemove(event) {
+ ui.PixelDimensions.display(
+ event.pageX,
+ event.pageY,
+ event.pageX,
+ event.pageY
+ );
+ if (
+ event.target.classList &&
+ !event.target.classList.contains("preview-overlay")
+ ) {
+ // User is hovering over a toolbar button or control
+ autoDetectRect = null;
+ if (this.cachedEl) {
+ this.cachedEl = null;
+ }
+ ui.HoverBox.hide();
+ return;
+ }
+ let el;
+ if (
+ event.target.classList &&
+ event.target.classList.contains("preview-overlay")
+ ) {
+ // The hover is on the overlay, so we need to figure out the real element
+ el = ui.iframe.getElementFromPoint(
+ event.pageX + window.scrollX - window.pageXOffset,
+ event.pageY + window.scrollY - window.pageYOffset
+ );
+ const xpos = Math.floor(
+ (10 * (event.pageX - window.innerWidth / 2)) / window.innerWidth
+ );
+ const ypos = Math.floor(
+ (10 * (event.pageY - window.innerHeight / 2)) / window.innerHeight
+ );
+
+ for (let i = 0; i < 2; i++) {
+ const move = `translate(${xpos}px, ${ypos}px)`;
+ event.target.getElementsByClassName("eyeball")[i].style.transform =
+ move;
+ }
+ } else {
+ // The hover is on the element we care about, so we use that
+ el = event.target;
+ }
+ if (this.cachedEl && this.cachedEl === el) {
+ // Still hovering over the same element
+ return;
+ }
+ this.cachedEl = el;
+ this.setAutodetectBasedOnElement(el);
+ },
+
+ setAutodetectBasedOnElement(el) {
+ let lastRect;
+ let lastNode;
+ let rect;
+ let attemptExtend = false;
+ let node = el;
+ while (node) {
+ rect = Selection.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, rect);
+ if (evenBetter) {
+ node = lastNode = evenBetter;
+ rect = Selection.getBoundingClientRect(evenBetter);
+ attemptExtend = false;
+ }
+ }
+ if (rect && attemptExtend) {
+ let extendNode = lastNode.nextSibling;
+ while (extendNode) {
+ if (extendNode.nodeType === 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 extendSelection = Selection.getBoundingClientRect(extendNode);
+ const extendRect = rect.union(extendSelection);
+ if (
+ extendRect.width <= MAX_DETECT_WIDTH &&
+ extendRect.height <= MAX_DETECT_HEIGHT
+ ) {
+ rect = extendRect;
+ }
+ }
+ }
+
+ if (
+ rect &&
+ (rect.width < MIN_DETECT_ABSOLUTE_WIDTH ||
+ rect.height < MIN_DETECT_ABSOLUTE_HEIGHT)
+ ) {
+ rect = null;
+ }
+ if (!rect) {
+ ui.HoverBox.hide();
+ } else {
+ ui.HoverBox.display(rect);
+ }
+ autoDetectRect = rect;
+ },
+
+ /** When we find an element, maybe there's one that's just a little bit better... */
+ evenBetterElement(node, origRect) {
+ let el = node.parentNode;
+ const ELEMENT_NODE = document.ELEMENT_NODE;
+ while (el && el.nodeType === ELEMENT_NODE) {
+ if (!el.getAttribute) {
+ return null;
+ }
+ const role = el.getAttribute("role");
+ if (
+ role === "article" ||
+ (el.className &&
+ typeof el.className === "string" &&
+ el.className.search("tweet ") !== -1)
+ ) {
+ const rect = Selection.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;
+ },
+
+ mousedown(event) {
+ // FIXME: this is happening but we don't know why, we'll track it now
+ // but avoid popping up messages:
+ if (typeof ui === "undefined") {
+ const exc = new Error("Undefined ui in mousedown");
+ exc.unloadTime = unloadTime;
+ exc.nowTime = Date.now();
+ exc.noPopup = true;
+ exc.noReport = true;
+ throw exc;
+ }
+ if (ui.isHeader(event.target)) {
+ return undefined;
+ }
+ // If the pageX is greater than this, then probably it's an attempt to get
+ // to the scrollbar, or an actual scroll, and not an attempt to start the
+ // selection:
+ const maxX = window.innerWidth - SCROLLBAR_WIDTH;
+ if (event.pageX >= maxX) {
+ event.stopPropagation();
+ event.preventDefault();
+ return false;
+ }
+
+ mousedownPos = new Pos(
+ event.pageX + window.scrollX,
+ event.pageY + window.scrollY
+ );
+ setState("draggingReady");
+ event.stopPropagation();
+ event.preventDefault();
+ return false;
+ },
+
+ end() {
+ ui.HoverBox.remove();
+ ui.PixelDimensions.remove();
+ },
+ };
+
+ stateHandlers.draggingReady = {
+ minMove: 40, // px
+ minAutoImageWidth: 40,
+ minAutoImageHeight: 40,
+ maxAutoElementWidth: 800,
+ maxAutoElementHeight: 600,
+
+ start() {
+ ui.iframe.usePreSelection();
+ ui.Box.remove();
+ },
+
+ mousemove(event) {
+ if (mousedownPos.distanceTo(event.pageX, event.pageY) > this.minMove) {
+ selectedPos = new Selection(
+ mousedownPos.x,
+ mousedownPos.y,
+ event.pageX + window.scrollX,
+ event.pageY + window.scrollY
+ );
+ mousedownPos = null;
+ setState("dragging");
+ }
+ },
+
+ mouseup(event) {
+ // If we don't get into "dragging" then we attempt an autoselect
+ if (mouseupNoAutoselect) {
+ sendEvent("cancel-selection", "selection-background-mousedown");
+ setState("crosshairs");
+ return false;
+ }
+ if (autoDetectRect) {
+ selectedPos = autoDetectRect;
+ selectedPos.x1 += window.scrollX;
+ selectedPos.y1 += window.scrollY;
+ selectedPos.x2 += window.scrollX;
+ selectedPos.y2 += window.scrollY;
+ autoDetectRect = null;
+ mousedownPos = null;
+ ui.iframe.useSelection();
+ ui.Box.display(selectedPos, standardDisplayCallbacks);
+ sendEvent(
+ "make-selection",
+ "selection-click",
+ eventOptionsForBox(selectedPos)
+ );
+ setState("selected");
+ sendEvent("autoselect");
+ callBackground("captureTelemetry", "element");
+ } else {
+ sendEvent("no-selection", "no-element-found");
+ setState("crosshairs");
+ }
+ return undefined;
+ },
+
+ click(event) {
+ this.mouseup(event);
+ },
+
+ findGoodEl() {
+ let el = mousedownPos.elementFromPoint();
+ if (!el) {
+ return null;
+ }
+ const isGoodEl = element => {
+ if (element.nodeType !== document.ELEMENT_NODE) {
+ return false;
+ }
+ if (element.tagName === "IMG") {
+ const rect = element.getBoundingClientRect();
+ return (
+ rect.width >= this.minAutoImageWidth &&
+ rect.height >= this.minAutoImageHeight
+ );
+ }
+ const display = window.getComputedStyle(element).display;
+ if (["block", "inline-block", "table"].includes(display)) {
+ return true;
+ // FIXME: not sure if this is useful:
+ // let rect = el.getBoundingClientRect();
+ // return rect.width <= this.maxAutoElementWidth && rect.height <= this.maxAutoElementHeight;
+ }
+ return false;
+ };
+ while (el) {
+ if (isGoodEl(el)) {
+ return el;
+ }
+ el = el.parentNode;
+ }
+ return null;
+ },
+
+ end() {
+ mouseupNoAutoselect = false;
+ },
+ };
+
+ stateHandlers.dragging = {
+ start() {
+ ui.iframe.useSelection();
+ ui.Box.display(selectedPos);
+ },
+
+ mousemove(event) {
+ selectedPos.x2 = util.truncateX(event.pageX);
+ selectedPos.y2 = util.truncateY(event.pageY);
+ scrollIfByEdge(event.pageX, event.pageY);
+ ui.Box.display(selectedPos);
+ ui.PixelDimensions.display(
+ event.pageX,
+ event.pageY,
+ selectedPos.width,
+ selectedPos.height
+ );
+ },
+
+ mouseup(event) {
+ selectedPos.x2 = util.truncateX(event.pageX);
+ selectedPos.y2 = util.truncateY(event.pageY);
+ ui.Box.display(selectedPos, standardDisplayCallbacks);
+ sendEvent(
+ "make-selection",
+ "selection-drag",
+ eventOptionsForBox({
+ top: selectedPos.y1,
+ bottom: selectedPos.y2,
+ left: selectedPos.x1,
+ right: selectedPos.x2,
+ })
+ );
+ setState("selected");
+ callBackground("captureTelemetry", "custom");
+ },
+
+ end() {
+ ui.PixelDimensions.remove();
+ },
+ };
+
+ stateHandlers.selected = {
+ start() {
+ ui.iframe.useSelection();
+ },
+
+ mousedown(event) {
+ const target = event.target;
+ if (target.tagName === "HTML") {
+ // This happens when you click on the scrollbar
+ return undefined;
+ }
+ const direction = ui.Box.draggerDirection(target);
+ if (direction) {
+ sendEvent("start-resize-selection", "handle");
+ stateHandlers.resizing.startResize(event, direction);
+ } else if (ui.Box.isSelection(target)) {
+ sendEvent("start-move-selection", "selection");
+ stateHandlers.resizing.startResize(event, "move");
+ } else if (!ui.Box.isControl(target)) {
+ mousedownPos = new Pos(event.pageX, event.pageY);
+ setState("crosshairs");
+ }
+ event.preventDefault();
+ return false;
+ },
+ };
+
+ stateHandlers.resizing = {
+ start() {
+ ui.iframe.useSelection();
+ selectedPos.sortCoords();
+ },
+
+ startResize(event, direction) {
+ selectedPos.sortCoords();
+ resizeDirection = direction;
+ resizeStartPos = new Pos(event.pageX, event.pageY);
+ resizeStartSelected = selectedPos.clone();
+ resizeHasMoved = false;
+ setState("resizing");
+ },
+
+ mousemove(event) {
+ this._resize(event);
+ if (resizeDirection !== "move") {
+ ui.PixelDimensions.display(
+ event.pageX,
+ event.pageY,
+ selectedPos.width,
+ selectedPos.height
+ );
+ }
+ return false;
+ },
+
+ mouseup(event) {
+ this._resize(event);
+ sendEvent("selection-resized");
+ ui.Box.display(selectedPos, standardDisplayCallbacks);
+ if (resizeHasMoved) {
+ if (resizeDirection === "move") {
+ const startPos = new Pos(
+ resizeStartSelected.left,
+ resizeStartSelected.top
+ );
+ const endPos = new Pos(selectedPos.left, selectedPos.top);
+ sendEvent(
+ "move-selection",
+ "mouseup",
+ eventOptionsForMove(startPos, endPos)
+ );
+ } else {
+ sendEvent(
+ "resize-selection",
+ "mouseup",
+ eventOptionsForResize(resizeStartSelected, selectedPos)
+ );
+ }
+ } else if (resizeDirection === "move") {
+ sendEvent("keep-resize-selection", "mouseup");
+ } else {
+ sendEvent("keep-move-selection", "mouseup");
+ }
+ setState("selected");
+ callBackground("captureTelemetry", "custom");
+ },
+
+ _resize(event) {
+ const diffX = event.pageX - resizeStartPos.x;
+ const diffY = event.pageY - resizeStartPos.y;
+ const movement = movements[resizeDirection];
+ if (movement[0]) {
+ let moveX = movement[0];
+ moveX = moveX === "*" ? ["x1", "x2"] : [moveX];
+ for (const moveDir of moveX) {
+ selectedPos[moveDir] = util.truncateX(
+ resizeStartSelected[moveDir] + diffX
+ );
+ }
+ }
+ if (movement[1]) {
+ let moveY = movement[1];
+ moveY = moveY === "*" ? ["y1", "y2"] : [moveY];
+ for (const moveDir of moveY) {
+ selectedPos[moveDir] = util.truncateY(
+ resizeStartSelected[moveDir] + diffY
+ );
+ }
+ }
+ if (diffX || diffY) {
+ resizeHasMoved = true;
+ }
+ scrollIfByEdge(event.pageX, event.pageY);
+ ui.Box.display(selectedPos);
+ },
+
+ end() {
+ resizeDirection = resizeStartPos = resizeStartSelected = null;
+ selectedPos.sortCoords();
+ ui.PixelDimensions.remove();
+ },
+ };
+
+ stateHandlers.cancel = {
+ start() {
+ ui.iframe.hide();
+ ui.Box.remove();
+ },
+ };
+
+ function getDocumentWidth() {
+ return Math.max(
+ document.body && document.body.clientWidth,
+ document.documentElement.clientWidth,
+ document.body && document.body.scrollWidth,
+ document.documentElement.scrollWidth
+ );
+ }
+ function getDocumentHeight() {
+ return Math.max(
+ document.body && document.body.clientHeight,
+ document.documentElement.clientHeight,
+ document.body && document.body.scrollHeight,
+ document.documentElement.scrollHeight
+ );
+ }
+
+ function scrollIfByEdge(pageX, pageY) {
+ const top = window.scrollY;
+ const bottom = top + window.innerHeight;
+ const left = window.scrollX;
+ const right = left + window.innerWidth;
+ if (pageY + SCROLL_BY_EDGE >= bottom && bottom < getDocumentHeight()) {
+ window.scrollBy(0, SCROLL_BY_EDGE);
+ } else if (pageY - SCROLL_BY_EDGE <= top) {
+ window.scrollBy(0, -SCROLL_BY_EDGE);
+ }
+ if (pageX + SCROLL_BY_EDGE >= right && right < getDocumentWidth()) {
+ window.scrollBy(SCROLL_BY_EDGE, 0);
+ } else if (pageX - SCROLL_BY_EDGE <= left) {
+ window.scrollBy(-SCROLL_BY_EDGE, 0);
+ }
+ }
+
+ /** *********************************************
+ * Selection communication
+ */
+
+ exports.activate = function () {
+ if (!document.body) {
+ callBackground("abortStartShot");
+ const tagName = String(document.documentElement.tagName || "").replace(
+ /[^a-z0-9]/gi,
+ ""
+ );
+ sendEvent("abort-start-shot", `document-is-${tagName}`);
+ selectorLoader.unloadModules();
+ return;
+ }
+ if (isFrameset()) {
+ callBackground("abortStartShot");
+ sendEvent("abort-start-shot", "frame-page");
+ selectorLoader.unloadModules();
+ return;
+ }
+ addHandlers();
+ setState("crosshairs");
+ };
+
+ function isFrameset() {
+ return document.body.tagName === "FRAMESET";
+ }
+
+ exports.deactivate = function () {
+ try {
+ sendEvent("internal", "deactivate");
+ setState("cancel");
+ selectorLoader.unloadModules();
+ } catch (e) {
+ log.error("Error in deactivate", e);
+ // Sometimes this fires so late that the document isn't available
+ // We don't care about the exception, so we swallow it here
+ }
+ };
+
+ let unloadTime = 0;
+
+ exports.unload = function () {
+ // Note that ui.unload() will be called on its own
+ unloadTime = Date.now();
+ removeHandlers();
+ };
+
+ /** *********************************************
+ * Event handlers
+ */
+
+ const primedDocumentHandlers = new Map();
+ let registeredDocumentHandlers = [];
+
+ function addHandlers() {
+ ["mouseup", "mousedown", "mousemove", "click"].forEach(eventName => {
+ const fn = watchFunction(
+ assertIsTrusted(function (event) {
+ if (typeof event.button === "number" && event.button !== 0) {
+ // Not a left click
+ return undefined;
+ }
+ if (
+ event.ctrlKey ||
+ event.shiftKey ||
+ event.altKey ||
+ event.metaKey
+ ) {
+ // Modified click of key
+ return undefined;
+ }
+ const state = getState();
+ const handler = stateHandlers[state];
+ if (handler[event.type]) {
+ return handler[event.type](event);
+ }
+ return undefined;
+ })
+ );
+ primedDocumentHandlers.set(eventName, fn);
+ });
+ primedDocumentHandlers.set(
+ "keyup",
+ watchFunction(assertIsTrusted(keyupHandler))
+ );
+ primedDocumentHandlers.set(
+ "keydown",
+ watchFunction(assertIsTrusted(keydownHandler))
+ );
+ window.document.addEventListener(
+ "visibilitychange",
+ visibilityChangeHandler
+ );
+ window.addEventListener("beforeunload", beforeunloadHandler);
+ }
+
+ let mousedownSetOnDocument = false;
+
+ function installHandlersOnDocument(docObj) {
+ for (const [eventName, handler] of primedDocumentHandlers) {
+ const watchHandler = watchFunction(handler);
+ const useCapture = eventName !== "keyup";
+ docObj.addEventListener(eventName, watchHandler, useCapture);
+ registeredDocumentHandlers.push({
+ name: eventName,
+ doc: docObj,
+ handler: watchHandler,
+ useCapture,
+ });
+ }
+ if (!mousedownSetOnDocument) {
+ const mousedownHandler = primedDocumentHandlers.get("mousedown");
+ document.addEventListener("mousedown", mousedownHandler, true);
+ registeredDocumentHandlers.push({
+ name: "mousedown",
+ doc: document,
+ handler: mousedownHandler,
+ useCapture: true,
+ });
+ mousedownSetOnDocument = true;
+ }
+ }
+
+ function beforeunloadHandler() {
+ sendEvent("cancel-shot", "tab-load");
+ exports.deactivate();
+ }
+
+ function keydownHandler(event) {
+ // In MacOS, the keyup event for 'c' is not fired when performing cmd+c.
+ if (
+ event.code === "KeyC" &&
+ (event.ctrlKey || event.metaKey) &&
+ ["previewing", "selected"].includes(getState.state)
+ ) {
+ catcher.watchPromise(
+ callBackground("getPlatformOs").then(os => {
+ if (
+ (event.ctrlKey && os !== "mac") ||
+ (event.metaKey && os === "mac")
+ ) {
+ sendEvent("copy-shot", "keyboard-copy");
+ copyShot();
+ }
+ })
+ );
+ }
+ }
+
+ function keyupHandler(event) {
+ if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) {
+ // unused modifier keys
+ return;
+ }
+ if ((event.key || event.code) === "Escape") {
+ sendEvent("cancel-shot", "keyboard-escape");
+ exports.deactivate();
+ }
+ // Enter to trigger Download by default. But if the user tabbed to
+ // select another button, then we do not want this.
+ if (
+ (event.key || event.code) === "Enter" &&
+ getState.state === "selected" &&
+ ui.iframe.document().activeElement.tagName === "BODY"
+ ) {
+ sendEvent("download-shot", "keyboard-enter");
+ downloadShot();
+ }
+ }
+
+ function visibilityChangeHandler(event) {
+ // The document is the event target
+ if (event.target.hidden) {
+ sendEvent("internal", "document-hidden");
+ }
+ }
+
+ function removeHandlers() {
+ window.removeEventListener("beforeunload", beforeunloadHandler);
+ window.document.removeEventListener(
+ "visibilitychange",
+ visibilityChangeHandler
+ );
+ for (const {
+ name,
+ doc,
+ handler,
+ useCapture,
+ } of registeredDocumentHandlers) {
+ doc.removeEventListener(name, handler, !!useCapture);
+ }
+ registeredDocumentHandlers = [];
+ }
+
+ catcher.watchFunction(exports.activate)();
+
+ return exports;
+})();
+
+null;
diff --git a/browser/extensions/screenshots/selector/util.js b/browser/extensions/screenshots/selector/util.js
new file mode 100644
index 0000000000..31366d3047
--- /dev/null
+++ b/browser/extensions/screenshots/selector/util.js
@@ -0,0 +1,124 @@
+/* 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/. */
+
+"use strict";
+
+this.util = (function () {
+ // eslint-disable-line no-unused-vars
+ const exports = {};
+
+ /** Removes a node from its document, if it's a node and the node is attached to a parent */
+ exports.removeNode = function (el) {
+ if (el && el.parentNode) {
+ el.remove();
+ }
+ };
+
+ /** Truncates the X coordinate to the document size */
+ exports.truncateX = function (x) {
+ const max = Math.max(
+ document.documentElement.clientWidth,
+ document.body.clientWidth,
+ document.documentElement.scrollWidth,
+ document.body.scrollWidth
+ );
+ if (x < 0) {
+ return 0;
+ } else if (x > max) {
+ return max;
+ }
+ return x;
+ };
+
+ /** Truncates the Y coordinate to the document size */
+ exports.truncateY = function (y) {
+ const max = Math.max(
+ document.documentElement.clientHeight,
+ document.body.clientHeight,
+ document.documentElement.scrollHeight,
+ document.body.scrollHeight
+ );
+ if (y < 0) {
+ return 0;
+ } else if (y > max) {
+ return max;
+ }
+ return y;
+ };
+
+ // Pixels of wiggle the captured region gets in captureSelectedText:
+ const CAPTURE_WIGGLE = 10;
+ const ELEMENT_NODE = document.ELEMENT_NODE;
+
+ exports.captureEnclosedText = function (box) {
+ const scrollX = window.scrollX;
+ const scrollY = window.scrollY;
+ const text = [];
+ function traverse(el) {
+ let elBox = el.getBoundingClientRect();
+ elBox = {
+ top: elBox.top + scrollY,
+ bottom: elBox.bottom + scrollY,
+ left: elBox.left + scrollX,
+ right: elBox.right + scrollX,
+ };
+ if (
+ elBox.bottom < box.top ||
+ elBox.top > box.bottom ||
+ elBox.right < box.left ||
+ elBox.left > box.right
+ ) {
+ // Totally outside of the box
+ return;
+ }
+ if (
+ elBox.bottom > box.bottom + CAPTURE_WIGGLE ||
+ elBox.top < box.top - CAPTURE_WIGGLE ||
+ elBox.right > box.right + CAPTURE_WIGGLE ||
+ elBox.left < box.left - CAPTURE_WIGGLE
+ ) {
+ // Partially outside the box
+ for (let i = 0; i < el.childNodes.length; i++) {
+ const child = el.childNodes[i];
+ if (child.nodeType === ELEMENT_NODE) {
+ traverse(child);
+ }
+ }
+ return;
+ }
+ addText(el);
+ }
+ function addText(el) {
+ let t;
+ if (el.tagName === "IMG") {
+ t = el.getAttribute("alt") || el.getAttribute("title");
+ } else if (el.tagName === "A") {
+ t = el.innerText;
+ if (
+ el.getAttribute("href") &&
+ !el.getAttribute("href").startsWith("#")
+ ) {
+ t += " (" + el.href + ")";
+ }
+ } else {
+ t = el.innerText;
+ }
+ if (t) {
+ text.push(t);
+ }
+ }
+ traverse(document.body);
+ if (text.length) {
+ let result = text.join("\n");
+ result = result.replace(/^\s+/, "");
+ result = result.replace(/\s+$/, "");
+ result = result.replace(/[ \t]+\n/g, "\n");
+ return result;
+ }
+ return null;
+ };
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/sitehelper.js b/browser/extensions/screenshots/sitehelper.js
new file mode 100644
index 0000000000..719e76dad2
--- /dev/null
+++ b/browser/extensions/screenshots/sitehelper.js
@@ -0,0 +1,63 @@
+/* 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/. */
+
+/* globals catcher, callBackground, content */
+/** This is a content script added to all screenshots.firefox.com pages, and allows the site to
+ communicate with the add-on */
+
+"use strict";
+
+this.sitehelper = (function () {
+ catcher.registerHandler(errorObj => {
+ callBackground("reportError", errorObj);
+ });
+
+ const capabilities = {};
+ function registerListener(name, func) {
+ capabilities[name] = name;
+ document.addEventListener(name, func);
+ }
+
+ function sendCustomEvent(name, detail) {
+ if (typeof detail === "object") {
+ // Note sending an object can lead to security problems, while a string
+ // is safe to transfer:
+ detail = JSON.stringify(detail);
+ }
+ document.dispatchEvent(new CustomEvent(name, { detail }));
+ }
+
+ registerListener(
+ "delete-everything",
+ catcher.watchFunction(event => {
+ // FIXME: reset some data in the add-on
+ }, false)
+ );
+
+ registerListener(
+ "copy-to-clipboard",
+ catcher.watchFunction(event => {
+ catcher.watchPromise(callBackground("copyShotToClipboard", event.detail));
+ })
+ );
+
+ registerListener(
+ "show-notification",
+ catcher.watchFunction(event => {
+ catcher.watchPromise(callBackground("showNotification", event.detail));
+ })
+ );
+
+ // Depending on the script loading order, the site might get the addon-present event,
+ // but probably won't - instead the site will ask for that event after it has loaded
+ registerListener(
+ "request-addon-present",
+ catcher.watchFunction(() => {
+ sendCustomEvent("addon-present", capabilities);
+ })
+ );
+
+ sendCustomEvent("addon-present", capabilities);
+})();
+null;
diff --git a/browser/extensions/screenshots/test/browser/browser.ini b/browser/extensions/screenshots/test/browser/browser.ini
new file mode 100644
index 0000000000..cdca87ae88
--- /dev/null
+++ b/browser/extensions/screenshots/test/browser/browser.ini
@@ -0,0 +1,22 @@
+[DEFAULT]
+prefs =
+ # The Screenshots extension is disabled by default in Mochitests. We re-enable
+ # it here, since it's a more realistic configuration.
+ extensions.screenshots.disabled=false
+
+[browser_screenshot_button.js]
+[browser_screenshots_dimensions.js]
+https_first_disabled = true
+# Bug 1714237 Disabled on tsan due to timeouts interacting with the UI
+# Bug 1714210 Disabled on headless which doesnt support image data on the clipboard
+skip-if =
+ headless || tsan
+ os == 'win' # Bug 1714295
+ os == 'linux' && bits == 64 # Bug 1714295
+[browser_screenshots_download.js]
+[browser_screenshots_injection.js]
+
+support-files =
+ head.js
+ injection-page.html
+ green2vh.html
diff --git a/browser/extensions/screenshots/test/browser/browser_screenshot_button.js b/browser/extensions/screenshots/test/browser/browser_screenshot_button.js
new file mode 100644
index 0000000000..84c5758a1d
--- /dev/null
+++ b/browser/extensions/screenshots/test/browser/browser_screenshot_button.js
@@ -0,0 +1,75 @@
+/* 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/.
+ */
+"use strict";
+
+add_task(async function testScreenshotButtonDisabled() {
+ info("Test the Screenshots button in the panel");
+
+ 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");
+
+ await BrowserTestUtils.withNewTab("https://example.com/", () => {
+ Assert.equal(
+ screenshotBtn.disabled,
+ false,
+ "Screenshots button is enabled"
+ );
+ });
+ await BrowserTestUtils.withNewTab("about:home", () => {
+ Assert.equal(
+ screenshotBtn.disabled,
+ true,
+ "Screenshots button is now disabled"
+ );
+ });
+});
+
+add_task(async function test_disabledMultiWindow() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_GREEN_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+ await helper.triggerUIFromToolbar();
+
+ let screenshotBtn = document.getElementById("screenshot-button");
+ Assert.ok(
+ screenshotBtn,
+ "The screenshots button was added to the nav bar"
+ );
+
+ info("Waiting for the preselect UI");
+ await helper.waitForUIContent(
+ helper.selector.preselectIframe,
+ helper.selector.fullPageButton
+ );
+
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.closeWindow(newWin);
+
+ let deactivatedPromise = helper.waitForToolbarButtonDeactivation();
+ await deactivatedPromise;
+ info("Screenshots is deactivated");
+
+ await EventUtils.synthesizeAndWaitKey("VK_ESCAPE", {});
+ await BrowserTestUtils.waitForCondition(() => {
+ return !screenshotBtn.disabled;
+ });
+
+ Assert.equal(
+ screenshotBtn.disabled,
+ false,
+ "Screenshots button is enabled"
+ );
+ }
+ );
+});
diff --git a/browser/extensions/screenshots/test/browser/browser_screenshots_dimensions.js b/browser/extensions/screenshots/test/browser/browser_screenshots_dimensions.js
new file mode 100644
index 0000000000..8b32d7968b
--- /dev/null
+++ b/browser/extensions/screenshots/test/browser/browser_screenshots_dimensions.js
@@ -0,0 +1,109 @@
+add_task(async function test_fullPageScreenshot() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_GREEN_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ await helper.triggerUIFromToolbar();
+
+ await helper.clickUIElement(
+ helper.selector.preselectIframe,
+ helper.selector.fullPageButton
+ );
+
+ info("Waiting for the preview UI and the copy button");
+ await helper.waitForUIContent(
+ helper.selector.previewIframe,
+ helper.selector.copyButton
+ );
+
+ let deactivatedPromise = helper.waitForToolbarButtonDeactivation();
+ let clipboardChanged = waitForRawClipboardChange();
+
+ await helper.clickUIElement(
+ helper.selector.previewIframe,
+ helper.selector.copyButton
+ );
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ await deactivatedPromise;
+ info("Screenshots is deactivated");
+
+ let result = await getImageSizeFromClipboard(browser);
+ is(
+ result.width,
+ contentInfo.documentWidth,
+ "Got expected screenshot width"
+ );
+ is(
+ result.height,
+ contentInfo.documentHeight,
+ "Got expected screenshot height"
+ );
+ }
+ );
+});
+
+add_task(async function test_visiblePageScreenshot() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_GREEN_PAGE,
+ },
+ async browser => {
+ const DEVICE_PIXEL_RATIO = window.devicePixelRatio;
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ await helper.triggerUIFromToolbar();
+
+ await helper.clickUIElement(
+ helper.selector.preselectIframe,
+ helper.selector.visiblePageButton
+ );
+
+ info("Waiting for the preview UI and the copy button");
+ await helper.waitForUIContent(
+ helper.selector.previewIframe,
+ helper.selector.copyButton
+ );
+
+ let deactivatedPromise = helper.waitForToolbarButtonDeactivation();
+ let clipboardChanged = waitForRawClipboardChange();
+
+ await helper.clickUIElement(
+ helper.selector.previewIframe,
+ helper.selector.copyButton
+ );
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ await deactivatedPromise;
+ info("Screenshots is deactivated");
+
+ let result = await getImageSizeFromClipboard(browser);
+ info("result: " + JSON.stringify(result, null, 2));
+ info("contentInfo: " + JSON.stringify(contentInfo, null, 2));
+
+ is(
+ result.width,
+ contentInfo.documentWidth * DEVICE_PIXEL_RATIO,
+ "Got expected screenshot width"
+ );
+ is(
+ result.height,
+ contentInfo.clientHeight * DEVICE_PIXEL_RATIO,
+ "Got expected screenshot height"
+ );
+ }
+ );
+});
diff --git a/browser/extensions/screenshots/test/browser/browser_screenshots_download.js b/browser/extensions/screenshots/test/browser/browser_screenshots_download.js
new file mode 100644
index 0000000000..3e32f06da6
--- /dev/null
+++ b/browser/extensions/screenshots/test/browser/browser_screenshots_download.js
@@ -0,0 +1,98 @@
+add_task(async function test_fullPageScreenshot() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_GREEN_PAGE,
+ },
+
+ async browser => {
+ const tests = [
+ { title: "Green Page", expected: "Green Page.png" },
+ { title: "\tA*B\\/+?<>\u200f\x1fC ", expected: "A B__ C.png" },
+ {
+ title: "簡単".repeat(35),
+ expected: " " + "簡単".repeat(35) + ".png",
+ },
+ {
+ title: "簡単".repeat(36),
+ expected: " " + "簡単".repeat(26) + "[...].png",
+ },
+ {
+ title: "簡単".repeat(56) + "?",
+ expected: " " + "簡単".repeat(26) + "[...].png",
+ },
+ ];
+
+ for (let test of tests) {
+ info("Testing with title " + test.title);
+
+ await SpecialPowers.spawn(browser, [test.title], titleToUse => {
+ content.document.title = titleToUse;
+ });
+
+ let helper = new ScreenshotsHelper(browser);
+ await helper.triggerUIFromToolbar();
+
+ await helper.clickUIElement(
+ helper.selector.preselectIframe,
+ helper.selector.fullPageButton
+ );
+
+ info("Waiting for the preview UI and the download button");
+ await helper.waitForUIContent(
+ helper.selector.previewIframe,
+ helper.selector.downloadButton
+ );
+
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let downloadPromise = new Promise(resolve => {
+ let downloadView = {
+ onDownloadAdded(download) {
+ publicList.removeView(downloadView);
+ resolve(download);
+ },
+ };
+
+ publicList.addView(downloadView);
+ });
+
+ await helper.clickUIElement(
+ helper.selector.previewIframe,
+ helper.selector.downloadButton
+ );
+
+ let download = await downloadPromise;
+ let filename = PathUtils.filename(download.target.path);
+ ok(
+ filename.endsWith(test.expected),
+ "Used correct filename '" +
+ filename +
+ "', expected: '" +
+ test.expected +
+ "'"
+ );
+
+ await task_resetState();
+ }
+ }
+ );
+});
+
+// This is from browser/components/downloads/test/browser/head.js
+async function task_resetState() {
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let downloads = await publicList.getAll();
+ for (let download of downloads) {
+ await publicList.remove(download);
+ await download.finalize(true);
+ if (await IOUtils.exists(download.target.path)) {
+ if (Services.appinfo.OS === "WINNT") {
+ // We need to make the file writable to delete it on Windows.
+ await IOUtils.setPermissions(download.target.path, 0o600);
+ }
+ await IOUtils.remove(download.target.path);
+ }
+ }
+
+ DownloadsPanel.hidePanel();
+}
diff --git a/browser/extensions/screenshots/test/browser/browser_screenshots_injection.js b/browser/extensions/screenshots/test/browser/browser_screenshots_injection.js
new file mode 100644
index 0000000000..dba932a81d
--- /dev/null
+++ b/browser/extensions/screenshots/test/browser/browser_screenshots_injection.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+/**
+ * Check that web content cannot break into screenshots.
+ */
+add_task(async function test_inject_srcdoc() {
+ // If Screenshots was disabled, enable it just for this test.
+ const addon = await AddonManager.getAddonByID("screenshots@mozilla.org");
+ const isEnabled = addon.enabled;
+ if (!isEnabled) {
+ await addon.enable({ allowSystemAddons: true });
+ registerCleanupFunction(async () => {
+ await addon.disable({ allowSystemAddons: true });
+ });
+ }
+
+ await BrowserTestUtils.withNewTab(
+ TEST_PATH + "injection-page.html",
+ async browser => {
+ // Set up the content hijacking. Do this so we can see it without
+ // awaiting - the promise should never resolve.
+ let response = null;
+ let responsePromise = SpecialPowers.spawn(browser, [], () => {
+ return new Promise(resolve => {
+ // We can't pass `resolve` directly because of sandboxing.
+ // `responseHandler` gets invoked from the content page.
+ content.wrappedJSObject.responseHandler = Cu.exportFunction(function (
+ arg
+ ) {
+ resolve(arg);
+ },
+ content);
+ });
+ }).then(
+ r => {
+ ok(false, "Should not have gotten HTML but got: " + r);
+ response = r;
+ },
+ () => {
+ // Do nothing - we expect this to error when the test finishes
+ // and the actor is destroyed, while the promise still hasn't
+ // been resolved. We need to catch it in order not to throw
+ // uncaught rejection errors and inadvertently fail the test.
+ }
+ );
+
+ let error;
+ let errorPromise = new Promise(resolve => {
+ SpecialPowers.registerConsoleListener(msg => {
+ if (
+ msg.message?.match(/iframe URL does not match expected blank.html/)
+ ) {
+ error = msg;
+ resolve();
+ }
+ });
+ });
+
+ // Now try to start the screenshot flow:
+ CustomizableUI.addWidgetToArea(
+ "screenshot-button",
+ CustomizableUI.AREA_NAVBAR
+ );
+
+ let screenshotBtn = document.getElementById("screenshot-button");
+ screenshotBtn.click();
+ await Promise.race([errorPromise, responsePromise]);
+ ok(error, "Should get the relevant error: " + error?.message);
+ ok(!response, "Should not get a response from the webpage.");
+
+ SpecialPowers.postConsoleSentinel();
+ }
+ );
+});
diff --git a/browser/extensions/screenshots/test/browser/green2vh.html b/browser/extensions/screenshots/test/browser/green2vh.html
new file mode 100644
index 0000000000..bb25e2a021
--- /dev/null
+++ b/browser/extensions/screenshots/test/browser/green2vh.html
@@ -0,0 +1,23 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <style>
+ html, body {
+ padding: 0;
+ margin: 0;
+ }
+ body {
+ background: rgb(0,255,0);
+ min-height: 200vh;
+ }
+ </style>
+ <script>
+ window.addEventListener("DOMContentLoaded", () => {
+ console.log(window.screen);
+ });
+ </script>
+</head>
+<body>
+</body>
+</html>
diff --git a/browser/extensions/screenshots/test/browser/head.js b/browser/extensions/screenshots/test/browser/head.js
new file mode 100644
index 0000000000..f999df71af
--- /dev/null
+++ b/browser/extensions/screenshots/test/browser/head.js
@@ -0,0 +1,230 @@
+"use strict";
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.com"
+);
+const TEST_GREEN_PAGE = TEST_ROOT + "green2vh.html";
+
+const gScreenshotUISelectors = {
+ preselectIframe: "#firefox-screenshots-preselection-iframe",
+ fullPageButton: "button.full-page",
+ visiblePageButton: "button.visible",
+ previewIframe: "#firefox-screenshots-preview-iframe",
+ copyButton: "button.highlight-button-copy",
+ downloadButton: "button.highlight-button-download",
+};
+
+class ScreenshotsHelper {
+ constructor(browser) {
+ this.browser = browser;
+ this.selector = gScreenshotUISelectors;
+ }
+
+ get toolbarButton() {
+ return document.getElementById("screenshot-button");
+ }
+
+ async triggerUIFromToolbar() {
+ let button = this.toolbarButton;
+ ok(
+ BrowserTestUtils.is_visible(button),
+ "The screenshot toolbar button is visible"
+ );
+ EventUtils.synthesizeMouseAtCenter(button, {});
+ // Make sure the Screenshots UI is loaded before yielding
+ await this.waitForUIContent(
+ this.selector.preselectIframe,
+ this.selector.fullPageButton
+ );
+ }
+
+ async waitForUIContent(iframeSel, elemSel) {
+ await SpecialPowers.spawn(
+ this.browser,
+ [iframeSel, elemSel],
+ async function (iframeSelector, elemSelector) {
+ info(
+ `in waitForUIContent content function, iframeSelector: ${iframeSelector}, elemSelector: ${elemSelector}`
+ );
+ 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;
+ }
+ let elem = iframe.contentDocument.querySelector(elemSelector);
+ info(
+ "in waitForUIContent, got visible elem: " +
+ (elem && ContentTaskUtils.is_visible(elem))
+ );
+ return elem && ContentTaskUtils.is_visible(elem);
+ });
+ // wait a frame for the screenshots UI to finish any init
+ await new content.Promise(res => content.requestAnimationFrame(res));
+ }
+ );
+ }
+
+ async clickUIElement(iframeSel, elemSel) {
+ await SpecialPowers.spawn(
+ this.browser,
+ [iframeSel, elemSel],
+ async function (iframeSelector, elemSelector) {
+ info(
+ `in clickScreenshotsUIElement content function, iframeSelector: ${iframeSelector}, elemSelector: ${elemSelector}`
+ );
+ const EventUtils = ContentTaskUtils.getEventUtils(content);
+ let iframe = content.document.querySelector(iframeSelector);
+ let elem = iframe.contentDocument.querySelector(elemSelector);
+ info(`Found the thing to click: ${elemSelector}: ${!!elem}`);
+
+ EventUtils.synthesizeMouseAtCenter(elem, {}, iframe.contentWindow);
+ await new content.Promise(res => content.requestAnimationFrame(res));
+ }
+ );
+ }
+
+ waitForToolbarButtonDeactivation() {
+ return BrowserTestUtils.waitForCondition(() => {
+ return !this.toolbarButton.style.cssText.includes("icon-highlight");
+ });
+ }
+
+ getContentDimensions() {
+ return SpecialPowers.spawn(this.browser, [], async function () {
+ let doc = content.document;
+ let rect = doc.documentElement.getBoundingClientRect();
+ return {
+ clientHeight: doc.documentElement.clientHeight,
+ clientWidth: doc.documentElement.clientWidth,
+ currentURI: doc.documentURI,
+ documentHeight: Math.round(rect.height),
+ documentWidth: Math.round(rect.width),
+ };
+ });
+ }
+}
+
+function waitForRawClipboardChange() {
+ const initialClipboardData = Date.now().toString();
+ SpecialPowers.clipboardCopyString(initialClipboardData);
+
+ let promiseChanged = BrowserTestUtils.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;
+}
+
+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);
+ } catch (e) {}
+ data = data.value || null;
+ return data;
+}
+
+/**
+ * A helper that returns the size of the image that was just put into the clipboard by the
+ * :screenshot command.
+ * @return The {width, height} dimension object.
+ */
+async function getImageSizeFromClipboard(browser) {
+ 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(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;
+
+ img.remove();
+ content.URL.revokeObjectURL(url);
+
+ // TODO: could get pixel data as well so we can check colors at specific locations
+ return {
+ width: img.width,
+ height: img.height,
+ };
+ });
+}
+
+add_setup(async function common_initialize() {
+ // Ensure Screenshots is initially enabled for all tests
+ const addon = await AddonManager.getAddonByID("screenshots@mozilla.org");
+ const isEnabled = addon.enabled;
+ if (!isEnabled) {
+ await addon.enable({ allowSystemAddons: true });
+ registerCleanupFunction(async () => {
+ await addon.disable({ allowSystemAddons: true });
+ });
+ }
+ // Add the Screenshots button to the toolbar for all tests
+ CustomizableUI.addWidgetToArea(
+ "screenshot-button",
+ CustomizableUI.AREA_NAVBAR
+ );
+ registerCleanupFunction(() =>
+ CustomizableUI.removeWidgetFromArea("screenshot-button")
+ );
+ let screenshotBtn = document.getElementById("screenshot-button");
+ Assert.ok(screenshotBtn, "The screenshots button was added to the nav bar");
+});
diff --git a/browser/extensions/screenshots/test/browser/injection-page.html b/browser/extensions/screenshots/test/browser/injection-page.html
new file mode 100644
index 0000000000..ffbda3b25b
--- /dev/null
+++ b/browser/extensions/screenshots/test/browser/injection-page.html
@@ -0,0 +1,23 @@
+<body>
+<script>
+let callback = function(mutationsList, observer) {
+ for (let mutation of mutationsList) {
+ let [added] = mutation.addedNodes;
+ if (added instanceof HTMLIFrameElement && added.id == "firefox-screenshots-preview-iframe") {
+ added.srcdoc = "<html></html>";
+ // Now we have to wait for the doc to be populated.
+ let interval = setInterval(() => {
+ console.log(added.contentDocument.innerHTML);
+ if (added.contentDocument.body.innerHTML) {
+ clearInterval(interval);
+ window.responseHandler(added.contentDocument.body.innerHTML);
+ }
+ }, 100);
+ observer.disconnect();
+ }
+ }
+};
+var observer = new MutationObserver(callback);
+observer.observe(document.body, {childList: true});
+</script>
+</body>