diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /browser/extensions/screenshots | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/extensions/screenshots')
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> |