summaryrefslogtreecommitdiffstats
path: root/browser/extensions/screenshots/selector
diff options
context:
space:
mode:
Diffstat (limited to 'browser/extensions/screenshots/selector')
-rw-r--r--browser/extensions/screenshots/selector/callBackground.js35
-rw-r--r--browser/extensions/screenshots/selector/documentMetadata.js93
-rw-r--r--browser/extensions/screenshots/selector/shooter.js163
-rw-r--r--browser/extensions/screenshots/selector/ui.js904
-rw-r--r--browser/extensions/screenshots/selector/uicontrol.js1026
-rw-r--r--browser/extensions/screenshots/selector/util.js124
6 files changed, 2345 insertions, 0 deletions
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;