diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
commit | 43a97878ce14b72f0981164f87f2e35e14151312 (patch) | |
tree | 620249daf56c0258faa40cbdcf9cfba06de2a846 /browser/extensions/screenshots/selector | |
parent | Initial commit. (diff) | |
download | firefox-43a97878ce14b72f0981164f87f2e35e14151312.tar.xz firefox-43a97878ce14b72f0981164f87f2e35e14151312.zip |
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/extensions/screenshots/selector')
-rw-r--r-- | browser/extensions/screenshots/selector/callBackground.js | 34 | ||||
-rw-r--r-- | browser/extensions/screenshots/selector/documentMetadata.js | 93 | ||||
-rw-r--r-- | browser/extensions/screenshots/selector/shooter.js | 163 | ||||
-rw-r--r-- | browser/extensions/screenshots/selector/ui.js | 912 | ||||
-rw-r--r-- | browser/extensions/screenshots/selector/uicontrol.js | 1027 | ||||
-rw-r--r-- | browser/extensions/screenshots/selector/util.js | 124 |
6 files changed, 2353 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..8ff955f6e5 --- /dev/null +++ b/browser/extensions/screenshots/selector/callBackground.js @@ -0,0 +1,34 @@ +/* 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..8b4c890786 --- /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..5d06fbec7b --- /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..06915483bd --- /dev/null +++ b/browser/extensions/screenshots/selector/ui.js @@ -0,0 +1,912 @@ +/* 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..885abe6719 --- /dev/null +++ b/browser/extensions/screenshots/selector/uicontrol.js @@ -0,0 +1,1027 @@ +/* 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..62d2f923df --- /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; |