/* 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;