diff options
Diffstat (limited to 'browser/extensions/screenshots/selector')
-rw-r--r-- | browser/extensions/screenshots/selector/callBackground.js | 31 | ||||
-rw-r--r-- | browser/extensions/screenshots/selector/documentMetadata.js | 91 | ||||
-rw-r--r-- | browser/extensions/screenshots/selector/shooter.js | 147 | ||||
-rw-r--r-- | browser/extensions/screenshots/selector/ui.js | 828 | ||||
-rw-r--r-- | browser/extensions/screenshots/selector/uicontrol.js | 901 | ||||
-rw-r--r-- | browser/extensions/screenshots/selector/util.js | 107 |
6 files changed, 2105 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..3fb8734c38 --- /dev/null +++ b/browser/extensions/screenshots/selector/callBackground.js @@ -0,0 +1,31 @@ +/* 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 */ + +"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..ceb532634a --- /dev/null +++ b/browser/extensions/screenshots/selector/documentMetadata.js @@ -0,0 +1,91 @@ +/* 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..d1a4a34e9e --- /dev/null +++ b/browser/extensions/screenshots/selector/shooter.js @@ -0,0 +1,147 @@ +/* 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, documentMetadata, util, uicontrol, ui, catcher */ +/* globals buildSettings, 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; + const MAX_CANVAS_DIMENSION = 32767; + let backend; + let shotObject; + const callBackground = global.callBackground; + const clipboard = global.clipboard; + + 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) { + let isFullPage = type === "fullPage" || type == "fullPageTruncated"; + promise = callBackground( + "screenshotPage", + selectedPos.toJSON(), + isFullPage, + window.devicePixelRatio); + } + + catcher.watchPromise(promise.then((dataUrl) => { + screenshotTaskFn(dataUrl); + })); + } + + exports.downloadShot = function(selectedPos, previewDataUrl, type) { + const shotPromise = previewDataUrl ? Promise.resolve(previewDataUrl) : hideUIFrame(); + catcher.watchPromise(shotPromise.then(dataUrl => { + screenshotPage(dataUrl, selectedPos, type, url => { + let type = blobConverters.getTypeFromDataUrl(url); + type = type ? type.split("/", 2)[1] : null; + shotObject.delAllClips(); + shotObject.addClip({ + createdDate: Date.now(), + image: { + url, + type, + 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}); + } + + callBackground("sendEvent", ...args); + }; + + 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..8a92c80470 --- /dev/null +++ b/browser/extensions/screenshots/selector/ui.js @@ -0,0 +1,828 @@ +/* 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, util, catcher, inlineSelectionCss, callBackground, assertIsTrusted, assertIsBlankDocument, buildSettings 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("myshots-button") || + 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.extension.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 highContrastCheck(win) { + const doc = win.document; + const el = doc.createElement("div"); + el.style.backgroundImage = "url('#')"; + el.style.display = "none"; + doc.body.appendChild(el); + const computed = win.getComputedStyle(el); + doc.body.removeChild(el); + // When Windows is in High Contrast mode, Firefox replaces background + // image URLs with the string "none". + return (computed && computed.backgroundImage === "none"); + } + + const showMyShots = exports.showMyShots = function() { + return window.hasAnyShots; + }; + + function initializeIframe() { + const el = document.createElement("iframe"); + el.src = browser.extension.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"; + 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("position", "absolute", "important"); + this.element.style.setProperty("background-color", "transparent"); + 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"; + catcher.watchPromise(callBackground("sendEvent", "internal", "unhide-selection-frame")); + if (highContrastCheck(this.element.contentWindow)) { + this.element.contentDocument.body.classList.add("hcm"); + } + 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.setProperty("background-color", "transparent"); + this.element.style.width = "100%"; + this.element.style.height = "100%"; + 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="myshots-all-buttons-container"> + ${showMyShots() ? ` + <button class="myshots-button" tabindex="3" data-l10n-id="screenshots-my-shots-button"></button> + <div class="spacer"></div> + ` : ""} + <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"); + if (showMyShots()) { + overlay.querySelector(".myshots-button").addEventListener( + "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.onOpenMyShots))); + } + 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"; + catcher.watchPromise(callBackground("sendEvent", "internal", "unhide-preselection-frame")); + if (highContrastCheck(this.element.contentWindow)) { + this.element.contentDocument.body.classList.add("hcm"); + } + 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.setProperty("background-color", "transparent"); + this.element.style.height = "100%"; + this.element.style.width = "100%"; + 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="${browser.extension.getURL("icons/cancel.svg")}" /> + </button> + <button class="highlight-button-copy" title="${copyTitle}"> + <img src="${browser.extension.getURL("icons/copy.svg")}" /> + <span data-l10n-id="screenshots-copy-button"/> + </button> + <button class="highlight-button-download" title="${downloadTitle}"> + <img src="${browser.extension.getURL("icons/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"; + catcher.watchPromise(callBackground("sendEvent", "internal", "unhide-preview-frame")); + 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.save && this.save) { + // We use onclick here because we don't want addEventListener + // to add multiple event handlers to the same button + this.save.removeAttribute("disabled"); + this.save.onclick = watchFunction(assertIsTrusted((e) => { + this.save.setAttribute("disabled", "true"); + callbacks.save(e); + })); + this.save.style.display = ""; + } else if (this.save) { + this.save.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 = browser.extension.getURL("icons/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 = browser.extension.getURL("icons/copy.svg"); + copy.appendChild(copyImg); + copy.appendChild(copyString); + buttons.appendChild(copy); + + const download = makeEl("button", "highlight-button-download"); + const downloadImg = makeEl("img"); + downloadImg.src = browser.extension.getURL("icons/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; + }, + + clearSaveDisabled() { + if (!this.save) { + // Happens if we try to remove the disabled status after the worker + // has been shut down + return; + } + this.save.removeAttribute("disabled"); + }, + + 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..f3aff2c5c8 --- /dev/null +++ b/browser/extensions/screenshots/selector/uicontrol.js @@ -0,0 +1,901 @@ +/* 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 */ +/* globals shooter, callBackground, selectorLoader, assertIsTrusted, buildSettings, 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 = buildSettings.maxImageHeight; + const MAX_PAGE_WIDTH = buildSettings.maxImageWidth; + // 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(); + }, + onOpenMyShots: () => { + sendEvent("goto-myshots", "selection-button"); + callBackground("openMyShots") + .then(() => exports.deactivate()) + .catch(() => { + // Handled in communication.js + }); + }, + onClickVisible: () => { + 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: () => { + 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"); + } 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 = (el) => { + if (el.nodeType !== document.ELEMENT_NODE) { + return false; + } + if (el.tagName === "IMG") { + const rect = el.getBoundingClientRect(); + return rect.width >= this.minAutoImageWidth && rect.height >= this.minAutoImageHeight; + } + const display = window.getComputedStyle(el).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"); + }, + + 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"); + }, + + _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]/ig, ""); + 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"); + callBackground("closeSelector"); + 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(eventName, 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[eventName]) { + return handler[eventName](event); + } + return undefined; + }).bind(null, eventName))); + 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 Save or 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..91217d58c1 --- /dev/null +++ b/browser/extensions/screenshots/selector/util.js @@ -0,0 +1,107 @@ +/* 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; |