/* 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/. */
/**
* The Screenshots overlay is inserted into the document's
* canvasFrame anonymous content container (see dom/webidl/Document.webidl).
*
* This container gets cleared automatically when the document navigates.
*
* Since the overlay markup is inserted in the canvasFrame using
* insertAnonymousContent, this means that it can be modified using the API
* described in AnonymousContent.webidl.
*
* Any mutation of this content must be via the AnonymousContent API.
* This is similar in design to [devtools' highlighters](https://firefox-source-docs.mozilla.org/devtools/tools/highlighters.html#inserting-content-in-the-page),
* though as Screenshots doesnt need to work on XUL documents, or allow multiple kinds of
* highlight/overlay our case is a little simpler.
*
* To retrieve the AnonymousContent instance, use the `content` getter.
*/
/* 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
A pointerdown goes from crosshairs to dragging.
A pointerup goes from dragging to selected
A click outside of the selection goes from selected to crosshairs
A pointerdown on one of the draggers goes from selected to resizing
*/
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
XPCOMUtils.defineLazyGetter(lazy, "overlayLocalization", () => {
return new Localization(["browser/screenshotsOverlay.ftl"], true);
});
const STYLESHEET_URL =
"chrome://browser/content/screenshots/overlay/overlay.css";
// 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:
let MAX_DETECT_HEIGHT = 700;
let MAX_DETECT_WIDTH = 1000;
const doNotAutoselectTags = {
H1: true,
H2: true,
H3: true,
H4: true,
H5: true,
H6: true,
};
class AnonymousContentOverlay {
constructor(contentDocument, screenshotsChild) {
this.listeners = new Map();
this.elements = new Map();
this.screenshotsChild = screenshotsChild;
this.contentDocument = contentDocument;
// aliased for easier diffs/maintenance of the event management code borrowed from devtools highlighters
this.pageListenerTarget = contentDocument.ownerGlobal;
this.overlayFragment = null;
this.overlayId = "screenshots-overlay-container";
this.previewId = "preview-container";
this.selectionId = "selection-container";
this.hoverBoxId = "hover-highlight";
this._initialized = false;
this.moverIds = [
"mover-left",
"mover-top",
"mover-right",
"mover-bottom",
"mover-topLeft",
"mover-topRight",
"mover-bottomLeft",
"mover-bottomRight",
];
}
get content() {
if (!this._content || Cu.isDeadWrapper(this._content)) {
return null;
}
return this._content;
}
async initialize() {
if (this._initialized) {
return;
}
let document = this.contentDocument;
let window = document.ownerGlobal;
// Inject stylesheet
if (!this.overlayFragment) {
try {
window.windowUtils.loadSheetUsingURIString(
STYLESHEET_URL,
window.windowUtils.AGENT_SHEET
);
} catch {
// The method fails if the url is already loaded.
}
// Inject markup for the overlay UI
this.overlayFragment = this.buildOverlay();
}
this._content = document.insertAnonymousContent(
this.overlayFragment.children[0]
);
this.addEventListeners();
this.hoverElementBox = new HoverElementBox(
this.hoverBoxId,
this.content,
document
);
this.previewLayer = new PreviewLayer(this.previewId, this.content);
this.selectionLayer = new SelectionLayer(
this.selectionId,
this.content,
this.hoverElementBox
);
this.screenshotsContainer = new ScreenshotsContainerLayer(
this.overlayId,
this.content,
this.previewLayer,
this.selectionLayer
);
this.stateHandler = new StateHandler(
this.screenshotsContainer,
this.screenshotsChild
);
this.screenshotsContainer.updateSize(window);
this.stateHandler.setState("crosshairs");
this._initialized = true;
}
/**
* The Anonymous Content doesn't shrink when the window is resized so we need
* to find the largest element that isn't the Anonymous Content and we will
* use that width and height.
* Otherwise we will fallback to the documentElement scroll width and height
* @param eventType If "resize", we called this from a resize event so we will
* try shifting the SelectionBox.
* If "scroll", we called this from a scroll event so we will redraw the buttons
*/
updateScreenshotsSize(eventType) {
this.stateHandler.updateScreenshotsContainerSize(
this.contentDocument.ownerGlobal,
eventType
);
}
/**
* Add required event listeners to the overlay
*/
addEventListeners() {
this.addEventListenerForElement(
"screenshots-cancel-button",
"click",
(event, targetId) => {
this.screenshotsChild.requestCancelScreenshot();
}
);
this.addEventListenerForElement("cancel", "click", (event, targetId) => {
this.screenshotsChild.requestCancelScreenshot();
});
this.addEventListenerForElement("copy", "click", (event, targetId) => {
this.screenshotsChild.requestCopyScreenshot(
this.screenshotsContainer.getSelectionLayerBoxDimensions()
);
});
this.addEventListenerForElement("download", "click", (event, targetId) => {
this.screenshotsChild.requestDownloadScreenshot(
this.screenshotsContainer.getSelectionLayerBoxDimensions()
);
});
// The pointerdown event is added to the selection buttons to prevent the
// pointerdown event from occurring on the "screenshots-overlay-container"
this.addEventListenerForElement(
"cancel",
"pointerdown",
(event, targetId) => {
event.stopPropagation();
}
);
this.addEventListenerForElement(
"copy",
"pointerdown",
(event, targetId) => {
event.stopPropagation();
}
);
this.addEventListenerForElement(
"download",
"pointerdown",
(event, targetId) => {
event.stopPropagation();
}
);
this.addEventListenerForElement(
this.overlayId,
"pointerdown",
(event, targetId) => {
this.dragStart(event, targetId);
}
);
this.addEventListenerForElement(
this.overlayId,
"pointerup",
(event, targetId) => {
this.dragEnd(event, targetId);
}
);
this.addEventListenerForElement(
this.overlayId,
"pointermove",
(event, targetId) => {
this.drag(event, targetId);
}
);
for (let id of this.moverIds.concat(["highlight"])) {
this.addEventListenerForElement(id, "pointerdown", (event, targetId) => {
this.dragStart(event, targetId);
});
this.addEventListenerForElement(id, "pointerup", (event, targetId) => {
this.dragEnd(event, targetId);
});
this.addEventListenerForElement(id, "pointermove", (event, targetId) => {
this.drag(event, targetId);
});
}
}
/**
* Removes all event listeners and removes the overlay from the Anonymous Content
*/
tearDown() {
if (this._content) {
this._removeAllListeners();
try {
this.contentDocument.removeAnonymousContent(this._content);
} catch (e) {
// If the current window isn't the one the content was inserted into, this
// will fail, but that's fine.
}
}
this._initialized = false;
}
/**
* Creates the document fragment that will be added to the Anonymous Content
* @returns document fragment that can be injected into the Anonymous Content
*/
buildOverlay() {
let [
cancel,
instructions,
download,
copy,
] = lazy.overlayLocalization.formatMessagesSync([
{ id: "screenshots-overlay-cancel-button" },
{ id: "screenshots-overlay-instructions" },
{ id: "screenshots-overlay-download-button" },
{ id: "screenshots-overlay-copy-button" },
]);
const htmlString = `
${instructions.value}
${cancel.value}
`;
const parser = new this.contentDocument.ownerGlobal.DOMParser();
const tmpDoc = parser.parseFromSafeString(htmlString, "text/html");
const fragment = this.contentDocument.createDocumentFragment();
fragment.appendChild(tmpDoc.body.children[0]);
return fragment;
}
// The event tooling is borrowed directly from devtools' highlighters (CanvasFrameAnonymousContentHelper)
/**
* Add an event listener to one of the elements inserted in the canvasFrame
* native anonymous container.
* Like other methods in this helper, this requires the ID of the element to
* be passed in.
*
* Note that if the content page navigates, the event listeners won't be
* added again.
*
* Also note that unlike traditional DOM events, the events handled by
* listeners added here will propagate through the document only through
* bubbling phase, so the useCapture parameter isn't supported.
* It is possible however to call e.stopPropagation() to stop the bubbling.
*
* IMPORTANT: the chrome-only canvasFrame insertion API takes great care of
* not leaking references to inserted elements to chrome JS code. That's
* because otherwise, chrome JS code could freely modify native anon elements
* inside the canvasFrame and probably change things that are assumed not to
* change by the C++ code managing this frame.
* See https://wiki.mozilla.org/DevTools/Highlighter#The_AnonymousContent_API
* Unfortunately, the inserted nodes are still available via
* event.originalTarget, and that's what the event handler here uses to check
* that the event actually occured on the right element, but that also means
* consumers of this code would be able to access the inserted elements.
* Therefore, the originalTarget property will be nullified before the event
* is passed to your handler.
*
* IMPL DETAIL: A single event listener is added per event types only, at
* browser level and if the event originalTarget is found to have the provided
* ID, the callback is executed (and then IDs of parent nodes of the
* originalTarget are checked too).
*
* @param {String} id
* @param {String} type
* @param {Function} handler
*/
addEventListenerForElement(id, type, handler) {
if (typeof id !== "string") {
throw new Error(
"Expected a string ID in addEventListenerForElement but got: " + id
);
}
// If no one is listening for this type of event yet, add one listener.
if (!this.listeners.has(type)) {
const target = this.pageListenerTarget;
target.addEventListener(type, this, true);
// Each type entry in the map is a map of ids:handlers.
this.listeners.set(type, new Map());
}
const listeners = this.listeners.get(type);
listeners.set(id, handler);
}
/**
* Remove an event listener from one of the elements inserted in the
* canvasFrame native anonymous container.
* @param {String} id
* @param {String} type
*/
removeEventListenerForElement(id, type) {
const listeners = this.listeners.get(type);
if (!listeners) {
return;
}
listeners.delete(id);
// If no one is listening for event type anymore, remove the listener.
if (!listeners.size) {
const target = this.pageListenerTarget;
target.removeEventListener(type, this, true);
}
}
handleEvent(event) {
const listeners = this.listeners.get(event.type);
if (!listeners) {
return;
}
// Hide the originalTarget property to avoid exposing references to native
// anonymous elements. See addEventListenerForElement's comment.
let isPropagationStopped = false;
const eventProxy = new Proxy(event, {
get: (obj, name) => {
if (name === "originalTarget") {
return null;
} else if (name === "stopPropagation") {
return () => {
isPropagationStopped = true;
};
}
return obj[name];
},
});
// Start at originalTarget, bubble through ancestors and call handlers when
// needed.
let node = event.originalTarget;
while (node) {
let nodeId = node.id;
if (nodeId) {
const handler = listeners.get(node.id);
if (handler) {
handler(eventProxy, nodeId);
if (isPropagationStopped) {
break;
}
}
if (nodeId == this.overlayId) {
break;
}
}
node = node.parentNode;
}
}
_removeAllListeners() {
if (this.pageListenerTarget) {
const target = this.pageListenerTarget;
for (const [type] of this.listeners) {
target.removeEventListener(type, this, true);
}
}
this.listeners.clear();
}
/**
* Pass the pointer down event to the state handler
* @param event The pointer down event
* @param targetId The target element id
*/
dragStart(event, targetId) {
this.stateHandler.dragStart(event, targetId);
}
/**
* Pass the pointer move event to the state handler
* @param event The pointer move event
* @param targetId The target element id
*/
drag(event, targetId) {
this.stateHandler.drag(event, targetId);
}
/**
* Pass the pointer up event to the state handler
* @param event The pointer up event
* @param targetId The target element id
*/
dragEnd(event, targetId) {
this.stateHandler.dragEnd(event);
}
}
export var ScreenshotsOverlayChild = {
AnonymousContentOverlay,
};
/**
* The StateHandler class handles the state of the overlay
*/
class StateHandler {
#state;
#lastBox;
#moverId;
#lastX;
#lastY;
#screenshotsContainer;
#screenshotsChild;
constructor(screenshotsContainer, screenshotsChild) {
this.#state = "crosshairs";
this.#lastBox = {};
this.#screenshotsContainer = screenshotsContainer;
this.#screenshotsChild = screenshotsChild;
}
setState(newState) {
this.#state = newState;
this.start();
}
getState() {
return this.#state;
}
/**
* At the start of the some states we need to perform some actions
*/
start() {
switch (this.#state) {
case "crosshairs": {
this.crosshairsStart();
break;
}
case "draggingReady": {
this.draggingReadyStart();
break;
}
case "dragging": {
this.draggingStart();
break;
}
case "selected": {
this.selectedStart();
break;
}
case "resizing": {
this.resizingStart();
break;
}
}
}
/**
* Returns the x and y coordinates of the event
* @param event The mouse or touch event
* @returns object containing the x and y coordinates of the mouse
*/
getCoordinates(event) {
const { clientX: viewX, clientY: viewY, pageX, pageY } = event;
MAX_DETECT_HEIGHT = Math.max(event.target.clientHeight + 100, 700);
MAX_DETECT_WIDTH = Math.max(event.target.clientWidth + 100, 1000);
return { viewX, viewY, pageX, pageY };
}
/**
* Handles the mousedown/touchstart event depending on the state
* @param event The mousedown or touchstart event
* @param targetId The id of the event target
*/
dragStart(event, targetId) {
const { pageX, pageY } = this.getCoordinates(event);
switch (this.#state) {
case "crosshairs": {
this.crosshairsDragStart(pageX, pageY);
break;
}
case "selected": {
this.selectedDragStart(pageX, pageY, targetId);
break;
}
}
}
/**
* Handles the move event depending on the state
* @param event The mousemove or touchmove event
* @param targetId The id of the event target
*/
drag(event, targetId) {
const { pageX, pageY, viewX, viewY } = this.getCoordinates(event);
switch (this.#state) {
case "crosshairs": {
this.crosshairsMove(pageX, pageY, viewX, viewY, targetId);
break;
}
case "draggingReady": {
this.draggingReadyDrag(pageX, pageY);
break;
}
case "dragging": {
this.draggingDrag(pageX, pageY);
break;
}
case "resizing": {
this.resizingDrag(pageX, pageY);
break;
}
}
}
/**
* Handles the move event depending on the state
* @param event The mouseup event
* @param targetId The id of the event target
*/
dragEnd(event, targetId) {
const { pageX, pageY, viewX, viewY } = this.getCoordinates(event);
switch (this.#state) {
case "draggingReady": {
this.draggingReadyDragEnd(pageX - viewX, pageY - viewY);
break;
}
case "dragging": {
this.draggingDragEnd(pageX, pageY, targetId);
break;
}
case "resizing": {
this.resizingDragEnd(pageX, pageY, targetId);
break;
}
}
}
/**
* Hide the box and highlighter and show the overlay at the start of crosshairs state
*/
crosshairsStart() {
this.#screenshotsContainer.hideSelectionLayer();
this.#screenshotsContainer.showPreviewLayer();
this.#screenshotsChild.showPanel();
}
/**
*
*/
draggingReadyStart() {
this.#screenshotsChild.hidePanel();
}
/**
* Hide the overlay and draw the box at the start of dragging state
*/
draggingStart() {
this.#screenshotsContainer.hidePreviewLayer();
this.#screenshotsContainer.hideButtonsLayer();
this.#screenshotsContainer.drawSelectionBox();
}
/**
* Show the buttons at the start of the selected state
*/
selectedStart() {
this.#screenshotsContainer.drawButtonsLayer();
}
/**
* Hide the buttons and store width and height of box at the start of the resizing state
*/
resizingStart() {
this.#screenshotsContainer.hideButtonsLayer();
let {
width,
height,
} = this.#screenshotsContainer.getSelectionLayerBoxDimensions();
this.#lastBox = {
width,
height,
};
}
/**
* Set the initial box coordinates and set the state to "draggingReady"
* @param clientX x coordinate
* @param clientY y coordinate
*/
crosshairsDragStart(clientX, clientY) {
this.#screenshotsContainer.setSelectionBoxDimensions({
left: clientX,
top: clientY,
right: clientX,
bottom: clientY,
});
this.setState("draggingReady");
}
/**
* If the background is clicked we set the state to crosshairs
* otherwise set the state to resizing
* @param clientX x coordinate
* @param clientY y coordinate
* @param targetId The id of the event target
*/
selectedDragStart(clientX, clientY, targetId) {
if (targetId === this.#screenshotsContainer.id) {
this.setState("crosshairs");
return;
}
this.#moverId = targetId;
this.#lastX = clientX;
this.#lastY = clientY;
this.setState("resizing");
}
/**
* Handles the pointer move for the crosshairs state
* @param pageX x pointer position
* @param pageY y pointer position
* @param viewX x pointer position in viewport
* @param viewY y pointer position in viewport
* @param targetId The id of the target element
*/
crosshairsMove(pageX, pageY, viewX, viewY, targetId) {
this.#screenshotsContainer.drawPreviewEyes(pageX, pageY);
this.#screenshotsContainer.handleElementHover(viewX, viewY, targetId);
}
/**
* Set the bottom and right coordinates of the box and draw the box
* @param clientX x coordinate
* @param clientY y coordinate
*/
draggingDrag(clientX, clientY) {
this.#screenshotsContainer.setSelectionBoxDimensions({
right: clientX,
bottom: clientY,
});
this.#screenshotsContainer.drawSelectionBox();
}
/**
* If the mouse has moved at least 40 pixels then set the state to "dragging"
* @param clientX x coordinate
* @param clientY y coordinate
*/
draggingReadyDrag(clientX, clientY) {
this.#screenshotsContainer.setSelectionBoxDimensions({
right: clientX,
bottom: clientY,
});
if (this.#screenshotsContainer.selectionBoxDistance() > 40) {
this.setState("dragging");
}
}
/**
* Depending on what mover was selected we will resize the box accordingly
* @param clientX x coordinate
* @param clientY y coordinate
*/
resizingDrag(clientX, clientY) {
switch (this.#moverId) {
case "mover-topLeft": {
this.#screenshotsContainer.setSelectionBoxDimensions({
left: clientX,
top: clientY,
});
break;
}
case "mover-top": {
this.#screenshotsContainer.setSelectionBoxDimensions({ top: clientY });
break;
}
case "mover-topRight": {
this.#screenshotsContainer.setSelectionBoxDimensions({
top: clientY,
right: clientX,
});
break;
}
case "mover-right": {
this.#screenshotsContainer.setSelectionBoxDimensions({
right: clientX,
});
break;
}
case "mover-bottomRight": {
this.#screenshotsContainer.setSelectionBoxDimensions({
right: clientX,
bottom: clientY,
});
break;
}
case "mover-bottom": {
this.#screenshotsContainer.setSelectionBoxDimensions({
bottom: clientY,
});
break;
}
case "mover-bottomLeft": {
this.#screenshotsContainer.setSelectionBoxDimensions({
left: clientX,
bottom: clientY,
});
break;
}
case "mover-left": {
this.#screenshotsContainer.setSelectionBoxDimensions({ left: clientX });
break;
}
case "highlight": {
let lastBox = this.#lastBox;
let diffX = this.#lastX - clientX;
let diffY = this.#lastY - clientY;
let newLeft;
let newRight;
let newTop;
let newBottom;
// Unpack SelectionBox dimensions to use here
let {
boxLeft,
boxTop,
boxRight,
boxBottom,
boxWidth,
boxHeight,
scrollWidth,
scrollHeight,
} = this.#screenshotsContainer.getSelectionLayerDimensions();
// wait until all 4 if elses have completed before setting box dimensions
if (boxWidth <= lastBox.width && boxLeft === 0) {
newLeft = boxRight - lastBox.width;
} else {
newLeft = boxLeft;
}
if (boxWidth <= lastBox.width && boxRight === scrollWidth) {
newRight = boxLeft + lastBox.width;
} else {
newRight = boxRight;
}
if (boxHeight <= lastBox.height && boxTop === 0) {
newTop = boxBottom - lastBox.height;
} else {
newTop = boxTop;
}
if (boxHeight <= lastBox.height && boxBottom === scrollHeight) {
newBottom = boxTop + lastBox.height;
} else {
newBottom = boxBottom;
}
this.#screenshotsContainer.setSelectionBoxDimensions({
left: newLeft - diffX,
top: newTop - diffY,
right: newRight - diffX,
bottom: newBottom - diffY,
});
this.#lastX = clientX;
this.#lastY = clientY;
break;
}
}
this.#screenshotsContainer.drawSelectionBox();
}
/**
* Draw the selection box from the hover element box if it exists
* Else set the state to "crosshairs"
*/
draggingReadyDragEnd(scrollX, scrollY) {
if (this.#screenshotsContainer.hoverElementBoxRect) {
this.#screenshotsContainer.hidePreviewLayer();
this.#screenshotsContainer.updateSelectionBoxFromRect(scrollX, scrollY);
this.#screenshotsContainer.drawSelectionBox();
this.setState("selected");
} else {
this.setState("crosshairs");
}
}
/**
* Draw the box one last time and set the state to "selected"
* @param clientX x coordinate
* @param clientY y coordinate
*/
draggingDragEnd(clientX, clientY) {
this.#screenshotsContainer.setSelectionBoxDimensions({
right: clientX,
bottom: clientY,
});
this.#screenshotsContainer.sortSelectionLayerBoxCoords();
this.setState("selected");
}
/**
* Draw the box one last time and set the state to "selected"
* @param clientX x coordinate
* @param clientY y coordinate
*/
resizingDragEnd(clientX, clientY, targetId) {
this.resizingDrag(clientX, clientY, targetId);
this.#screenshotsContainer.sortSelectionLayerBoxCoords();
this.setState("selected");
}
/**
* The page was resized or scrolled. We need to update the
* ScreenshotsContainer size so we don't draw outside the window bounds
* If the current state is "selected" and this was called from a resize event
* then we need to maybe shift the SelectionBox
* @param win The window object of the page
* @param eventType If this was called from a resize event
*/
updateScreenshotsContainerSize(win, eventType) {
this.#screenshotsContainer.updateSize(win);
if (this.#state === "selected" && eventType === "resize") {
this.#screenshotsContainer.shiftSelectionLayerBox();
} else if (this.#state && eventType === "scroll") {
this.#screenshotsContainer.drawButtonsLayer();
if (this.#state === "crosshairs") {
this.#screenshotsContainer.handleElementScroll();
}
}
}
}
class AnonLayer {
id;
content;
constructor(id, content) {
this.id = id;
this.content = content;
}
/**
* Show element with id this.id
*/
show() {
this.content.removeAttributeForElement(this.id, "style");
}
/**
* Hide element with id this.id
*/
hide() {
this.content.setAttributeForElement(this.id, "style", "display:none;");
}
}
class HoverElementBox extends AnonLayer {
#document;
#rect;
#lastX;
#lastY;
constructor(id, content, document) {
super(id, content);
this.#document = document;
}
get rect() {
return this.#rect;
}
/**
* Draws the hover box over an element from the given rect
* @param rect The rect to draw the hover element box
*/
drawHoverBox(rect) {
if (!rect) {
this.hide();
} else {
let maxHeight = this.selectionLayer.scrollHeight;
let maxWidth = this.selectionLayer.scrollWidth;
let top = this.#document.documentElement.scrollTop + rect.top;
top = top > 0 ? top : 0;
let left = this.#document.documentElement.scrollLeft + rect.left;
left = left > 0 ? left : 0;
let height =
rect.top + rect.height > maxHeight ? maxHeight - rect.top : rect.height;
let width =
rect.left + rect.width > maxWidth ? maxWidth - rect.left : rect.width;
this.content.setAttributeForElement(
this.id,
"style",
`top:${top}px;left:${left}px;height:${height}px;width:${width}px;`
);
}
}
/**
* Handles when the user moves the mouse over an element
* @param viewX The viewport x coordinate
* @param viewY The viewport y coordinate
* @param targetId The target element id
*/
handleElementHover(viewX, viewY, targetId) {
if (targetId === "screenshots-overlay-container") {
let ele = this.getElementFromPoint(viewX, viewY);
if (this.cachedEle && this.cachedEle === ele) {
// Still hovering over the same element
return;
}
this.cachedEle = ele;
this.getBestRectForElement(ele);
this.#lastX = viewX;
this.#lastY = viewY;
}
}
/**
* Handles moving the rect when the user has scrolled but not moved the mouse
* It uses the last x and y viewport coordinates to find the new element at the mouse position
*/
handleElementScroll() {
if (this.#lastX && this.#lastY) {
this.cachedEle = null;
this.handleElementHover(
this.#lastX,
this.#lastY,
"screenshots-overlay-container"
);
}
}
/**
* Finds an element for the given coordinates within the viewport
* @param x The viewport x coordinate
* @param y The viewport y coordinate
* @returns An element location at the given coordinates
*/
getElementFromPoint(x, y) {
this.setPointerEventsNone();
let ele;
try {
ele = this.#document.elementFromPoint(x, y);
} finally {
this.resetPointerEvents();
}
return ele;
}
/**
* Gets the rect for an element if getBoundingClientRect exists
* @param ele The element to get the rect from
* @returns The bounding client rect of the element or null
*/
getBoundingClientRect(ele) {
if (!ele.getBoundingClientRect) {
return null;
}
return ele.getBoundingClientRect();
}
/**
* This function takes an element and finds a suitable rect to draw the hover box on
* @param ele The element to find a suitale rect of
*/
getBestRectForElement(ele) {
let lastRect;
let lastNode;
let rect;
let attemptExtend = false;
let node = ele;
while (node) {
rect = this.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);
if (evenBetter) {
node = lastNode = evenBetter;
rect = this.getBoundingClientRect(evenBetter);
attemptExtend = false;
}
}
if (rect && attemptExtend) {
let extendNode = lastNode.nextSibling;
while (extendNode) {
if (extendNode.nodeType === this.#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 extendRect = this.getBoundingClientRect(extendNode);
let x = Math.min(rect.x, extendRect.x);
let y = Math.min(rect.y, extendRect.y);
let width = Math.max(rect.right, extendRect.right) - x;
let height = Math.max(rect.bottom, extendRect.bottom) - y;
const combinedRect = new DOMRect(x, y, width, height);
if (
combinedRect.width <= MAX_DETECT_WIDTH &&
combinedRect.height <= MAX_DETECT_HEIGHT
) {
rect = combinedRect;
}
}
}
if (
rect &&
(rect.width < MIN_DETECT_ABSOLUTE_WIDTH ||
rect.height < MIN_DETECT_ABSOLUTE_HEIGHT)
) {
rect = null;
}
if (!rect) {
this.hide();
} else {
this.drawHoverBox(rect);
}
this.#rect = rect;
}
/**
* This finds a better element by looking for elements with role article
* @param node The currently hovered node
* @returns A better node or null
*/
evenBetterElement(node) {
let el = node.parentNode;
const ELEMENT_NODE = this.#document.ELEMENT_NODE;
while (el && el.nodeType === ELEMENT_NODE) {
if (!el.getAttribute) {
return null;
}
if (el.getAttribute("role") === "article") {
const rect = this.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;
}
/**
* The pointer events need to be removed temporarily so we can find the
* correct element from document.elementFromPoint()
* If the pointer events are on for the screenshots elements, then we will always
* get the screenshots elements as the elements from a given point
*/
setPointerEventsNone() {
this.content.setAttributeForElement(
"screenshots-component",
"style",
"pointer-events:none;"
);
let temp = this.content.getAttributeForElement(
"screenshots-overlay-container",
"style"
);
this.content.setAttributeForElement(
"screenshots-overlay-container",
"style",
temp + "pointer-events:none;"
);
}
/**
* Return the pointer events to the original state because we found the element
*/
resetPointerEvents() {
this.content.setAttributeForElement("screenshots-component", "style", "");
let temp = this.content.getAttributeForElement(
"screenshots-overlay-container",
"style"
);
this.content.setAttributeForElement(
"screenshots-overlay-container",
"style",
temp.replace("pointer-events:none;", "")
);
}
}
class SelectionLayer extends AnonLayer {
#selectionBox;
#hoverElementBox;
#buttons;
#hidden;
/**
* the documentDimensions follows the below structure
* {
* scrollWidth: the total document width
* scrollHeight: the total document height
* scrollX: the x scrolled offset
* scrollY: the y scrolled offset
* innerWidth: the viewport width
* innerHeight: the viewport height
* }
*/
#documentDimensions;
constructor(id, content, hoverElementBox) {
super(id, content);
this.#selectionBox = new SelectionBox(content, this);
this.#buttons = new ButtonsLayer("buttons", content, this);
this.#hoverElementBox = hoverElementBox;
this.#hoverElementBox.selectionLayer = this;
this.#hidden = true;
this.#documentDimensions = {};
}
/**
* Hide the buttons layer
*/
hideButtons() {
this.#buttons.hide();
}
/**
* Call
*/
drawButtonsLayer() {
this.#buttons.show();
}
/**
* Hide the selection-container element
*/
hide() {
super.hide();
this.#hidden = true;
}
/**
* Draw the SelectionBox
*/
drawSelectionBox() {
if (this.#hidden) {
this.show();
this.#hidden = false;
}
this.#selectionBox.show();
}
/**
* Sort the SelectionBox coordinates
*/
sortSelectionBoxCoords() {
this.#selectionBox.sortCoords();
}
/**
* Sets the SelectionBox dimensions
* @param {Object} dims The new box dimensions
* {
* left: new left dimension value or undefined
* top: new top dimension value or undefined
* right: new right dimension value or undefined
* bottom: new bottom dimension value or undefined
* }
*/
setSelectionBoxDimensions(dims) {
if (dims.left) {
this.#selectionBox.left = dims.left;
}
if (dims.top) {
this.#selectionBox.top = dims.top;
}
if (dims.right) {
this.#selectionBox.right = dims.right;
}
if (dims.bottom) {
this.#selectionBox.bottom = dims.bottom;
}
}
/**
* Gets the selections box dimensions
* @returns {Object}
* {
* x1: the left dimension value
* y1: the top dimension value
* width: the width of the selected region
* height: the height of the selected region
* }
*/
getSelectionBoxDimensions() {
return this.#selectionBox.getDimensions();
}
/**
* Returns the box dimensions and the page dimensions
* @returns {Object}
* {
* boxLeft: the left position of the box
* boxTop: the top position of the box
* boxRight: the right position of the box
* boxBottom: the bottom position of the box
* scrollWidth: the total document width
* scrollHeight: the total document height
* scrollX: the x scrolled offset
* scrollY: the y scrolled offset
* innerWidth: the viewport width
* innerHeight: the viewport height
* }
*/
getDimensions() {
return {
boxLeft: this.#selectionBox.left,
boxTop: this.#selectionBox.top,
boxRight: this.#selectionBox.right,
boxBottom: this.#selectionBox.bottom,
boxWidth: this.#selectionBox.width,
boxHeight: this.#selectionBox.height,
...this.#documentDimensions,
};
}
/**
* Gets the diagonal distance of the SelectionBox
* @returns The diagonal distance of the SelectionBox
*/
getSelectionBoxDistance() {
return this.#selectionBox.distance;
}
/**
* Shift the SelectionBox so that it is always within the document
*/
shiftSelectionBox() {
this.#selectionBox.shiftBox();
}
/**
* Update the box coordinates from the hover element rect
*/
updateSelectionBoxFromRect(scrollX, scrollY) {
this.#selectionBox.updateBoxFromRect(
this.#hoverElementBox.rect,
scrollX,
scrollY
);
}
/**
* Handles when the user moves the mouse over an element
* @param viewX The viewport x coordinate
* @param viewY The viewport y coordinate
* @param targetId The target element id
*/
handleElementHover(viewX, viewY, targetId) {
this.#hoverElementBox.handleElementHover(viewX, viewY, targetId);
}
/**
* Handles moving the rect when the user has scrolled but not moved the mouse
* It uses the last x and y viewport coordinates to find the new element at the mouse position
*/
handleElementScroll() {
this.#hoverElementBox.handleElementScroll();
}
hideHoverElementSelection() {
this.#hoverElementBox.hide();
}
get hoverElementBoxRect() {
return this.#hoverElementBox.rect;
}
get scrollWidth() {
return this.#documentDimensions.scrollWidth;
}
set scrollWidth(val) {
this.#documentDimensions.scrollWidth = val;
}
get scrollHeight() {
return this.#documentDimensions.scrollHeight;
}
set scrollHeight(val) {
this.#documentDimensions.scrollHeight = val;
}
get scrollX() {
return this.#documentDimensions.scrollX;
}
set scrollX(val) {
this.#documentDimensions.scrollX = val;
}
get scrollY() {
return this.#documentDimensions.scrollY;
}
set scrollY(val) {
this.#documentDimensions.scrollY = val;
}
get innerWidth() {
return this.#documentDimensions.innerWidth;
}
set innerWidth(val) {
this.#documentDimensions.innerWidth = val;
}
get innerHeight() {
return this.#documentDimensions.innerHeight;
}
set innerHeight(val) {
this.#documentDimensions.innerHeight = val;
}
}
/**
* The SelectionBox class handles drawing the highlight and background
*/
class SelectionBox extends AnonLayer {
#x1;
#x2;
#y1;
#y2;
#xOffset;
#yOffset;
#selectionLayer;
constructor(content, selectionLayer) {
super("", content);
this.#selectionLayer = selectionLayer;
this.#x1 = 0;
this.#x2 = 0;
this.#y1 = 0;
this.#y2 = 0;
this.#xOffset = 0;
this.#yOffset = 0;
}
/**
* Draw the selected region for screenshotting
*/
show() {
this.content.setAttributeForElement(
"highlight",
"style",
`top:${this.top}px;left:${this.left}px;height:${this.height}px;width:${this.width}px;`
);
this.content.setAttributeForElement(
"bgTop",
"style",
`top:0px;height:${this.top}px;left:0px;width:100%;`
);
this.content.setAttributeForElement(
"bgBottom",
"style",
`top:${this.bottom}px;height:calc(100% - ${this.bottom}px);left:0px;width:100%;`
);
this.content.setAttributeForElement(
"bgLeft",
"style",
`top:${this.top}px;height:${this.height}px;left:0px;width:${this.left}px;`
);
this.content.setAttributeForElement(
"bgRight",
"style",
`top:${this.top}px;height:${this.height}px;left:${this.right}px;width:calc(100% - ${this.right}px);`
);
}
/**
* Update the box coordinates from the rect
* @param rect The hover element box
* @param scrollX The x offset the page is scrolled
* @param scrollY The y offset the page is scrolled
*/
updateBoxFromRect(rect, scrollX, scrollY) {
this.top = rect.top + scrollY;
this.left = rect.left + scrollX;
this.right = rect.right + scrollX;
this.bottom = rect.bottom + scrollY;
}
/**
* Hide the selected region
*/
hide() {
this.content.setAttributeForElement("highlight", "style", "display:none;");
this.content.setAttributeForElement("bgTop", "style", "display:none;");
this.content.setAttributeForElement("bgBottom", "style", "display:none;");
this.content.setAttributeForElement("bgLeft", "style", "display:none;");
this.content.setAttributeForElement("bgRight", "style", "display:none;");
}
/**
* The box should never appear outside the document so the SelectionBox will
* be shifted if the bounds of the box are outside the documents width or height
*/
shiftBox() {
let didShift = false;
let xDiff = this.right - this.#selectionLayer.scrollWidth;
if (xDiff > 0) {
this.right -= xDiff;
this.left -= xDiff;
didShift = true;
}
let yDiff = this.bottom - this.#selectionLayer.scrollHeight;
if (yDiff > 0) {
let curWidth = this.width;
this.bottom -= yDiff;
this.top = this.bottom - curWidth;
didShift = true;
}
if (didShift) {
this.show();
this.#selectionLayer.drawButtonsLayer();
}
}
/**
* Sort the coordinates so x1 < x2 and y1 < y2
*/
sortCoords() {
if (this.#x1 > this.#x2) {
[this.#x1, this.#x2] = [this.#x2, this.#x1];
}
if (this.#y1 > this.#y2) {
[this.#y1, this.#y2] = [this.#y2, this.#y1];
}
}
/**
* Gets the dimensions of the currently selected region
* @returns {Object}
* {
* x1: the left dimension value
* y1: the top dimension value
* width: the width of the selected region
* height: the height of the selected region
* }
*/
getDimensions() {
return {
x1: this.left,
y1: this.top,
width: this.width,
height: this.height,
};
}
get distance() {
return Math.sqrt(Math.pow(this.width, 2) + Math.pow(this.height, 2));
}
get xOffset() {
return this.#xOffset;
}
set xOffset(val) {
this.#xOffset = val;
}
get yOffset() {
return this.#yOffset;
}
set yOffset(val) {
this.#yOffset = val;
}
get top() {
return Math.min(this.#y1, this.#y2);
}
set top(val) {
this.#y1 = val > 0 ? val : 0;
}
get left() {
return Math.min(this.#x1, this.#x2);
}
set left(val) {
this.#x1 = val > 0 ? val : 0;
}
get right() {
return Math.max(this.#x1, this.#x2);
}
set right(val) {
this.#x2 =
val > this.#selectionLayer.scrollWidth
? this.#selectionLayer.scrollWidth
: val;
}
get bottom() {
return Math.max(this.#y1, this.#y2);
}
set bottom(val) {
this.#y2 =
val > this.#selectionLayer.scrollHeight
? this.#selectionLayer.scrollHeight
: val;
}
get width() {
return Math.abs(this.#x2 - this.#x1);
}
get height() {
return Math.abs(this.#y2 - this.#y1);
}
}
class ButtonsLayer extends AnonLayer {
#selectionLayer;
constructor(id, content, selectionLayer) {
super(id, content);
this.#selectionLayer = selectionLayer;
}
/**
* Draw the buttons. Check if the box is too near the bottom or left of the
* viewport and adjust the buttons accordingly
*/
show() {
let {
boxLeft,
boxTop,
boxRight,
boxBottom,
scrollX,
scrollY,
innerWidth,
innerHeight,
} = this.#selectionLayer.getDimensions();
if (
boxTop > scrollY + innerHeight ||
boxBottom < scrollY ||
boxLeft > scrollX + innerWidth ||
boxRight < scrollX
) {
// The box is offscreen so need to draw the buttons
return;
}
let top = boxBottom;
let leftOrRight = `right:calc(100% - ${boxRight}px);`;
if (scrollY + innerHeight - boxBottom < 70) {
if (boxBottom < scrollY + innerHeight) {
top = boxBottom - 60;
} else if (scrollY + innerHeight - boxTop < 70) {
top = boxTop - 60;
} else {
top = scrollY + innerHeight - 60;
}
}
if (boxRight < 265) {
leftOrRight = `left:${boxLeft}px;`;
}
this.content.setAttributeForElement(
"buttons",
"style",
`top:${top}px;${leftOrRight}`
);
}
}
class PreviewLayer extends AnonLayer {
constructor(id, content) {
super(id, content);
}
/**
* Draw the eyeballs facing the mouse
* @param clientX x pointer position
* @param clientY y pointer position
* @param width width of the viewport
* @param height height of the viewport
*/
drawEyes(clientX, clientY, width, height) {
const xpos = Math.floor((10 * (clientX - width / 2)) / width);
const ypos = Math.floor((10 * (clientY - height / 2)) / height);
const move = `transform:translate(${xpos}px, ${ypos}px);`;
this.content.setAttributeForElement("left-eye", "style", move);
this.content.setAttributeForElement("right-eye", "style", move);
}
}
class ScreenshotsContainerLayer extends AnonLayer {
#width;
#height;
#previewLayer;
#selectionLayer;
constructor(id, content, previewLayer, selectionLayer) {
super(id, content);
this.#previewLayer = previewLayer;
this.#selectionLayer = selectionLayer;
}
/**
* Hide the SelectionLayer
*/
hideSelectionLayer() {
this.#selectionLayer.hide();
}
/**
* Show the PreviewLayer
*/
showPreviewLayer() {
this.#previewLayer.show();
}
/**
* Hide the PreviewLayer
*/
hidePreviewLayer() {
this.#previewLayer.hide();
this.#selectionLayer.hideHoverElementSelection();
}
/**
* Show the ButtonsLayer
*/
drawButtonsLayer() {
this.#selectionLayer.drawButtonsLayer();
}
/**
* Hide the ButtonsLayer
*/
hideButtonsLayer() {
this.#selectionLayer.hideButtons();
}
/**
* Show the SelectionBox
*/
drawSelectionBox() {
this.#selectionLayer.drawSelectionBox();
}
/**
* Update the box coordinates from the hover element rect
*/
updateSelectionBoxFromRect(scrollX, scrollY) {
this.#selectionLayer.updateSelectionBoxFromRect(scrollX, scrollY);
}
/**
* Handles when the user moves the mouse over an element
* @param viewX The viewport x coordinate
* @param viewY The viewport y coordinate
* @param targetId The target element id
*/
handleElementHover(viewX, viewY, targetId) {
this.#selectionLayer.handleElementHover(viewX, viewY, targetId);
}
/**
* Handles moving the rect when the user has scrolled but not moved the mouse
* It uses the last x and y viewport coordinates to find the new element at the mouse position
*/
handleElementScroll() {
this.#selectionLayer.handleElementScroll();
}
/**
* Draw the eyes in the PreviewLayer
* @param clientX The x mouse position
* @param clientY The y mouse position
*/
drawPreviewEyes(clientX, clientY) {
this.#previewLayer.drawEyes(
clientX - this.#selectionLayer.scrollX,
clientY - this.#selectionLayer.scrollY,
this.#selectionLayer.innerWidth,
this.#selectionLayer.innerHeight
);
}
/**
* Get the diagonal distance of the SelectionBox
* @returns The diagonal distance of the currently selected region
*/
selectionBoxDistance() {
return this.#selectionLayer.getSelectionBoxDistance();
}
/**
* Sort the coordinates of the SelectionBox
*/
sortSelectionLayerBoxCoords() {
this.#selectionLayer.sortSelectionBoxCoords();
}
/**
* Get the SelectionLayer dimensions
* @returns {Object}
* {
* x1: the left dimension value
* y1: the top dimension value
* width: the width of the selected region
* height: the height of the selected region
* }
*/
getSelectionLayerBoxDimensions() {
return this.#selectionLayer.getSelectionBoxDimensions();
}
/**
* Gets the SelectionBox and page dimensions
* @returns {Object}
* {
* boxLeft: the left position of the box
* boxTop: the top position of the box
* boxRight: the right position of the box
* boxBottom: the bottom position of the box
* scrollWidth: the total document width
* scrollHeight: the total document height
* scrollX: the x scrolled offset
* scrollY: the y scrolled offset
* innerWidth: the viewport width
* innerHeight: the viewport height
* }
*/
getSelectionLayerDimensions() {
return this.#selectionLayer.getDimensions();
}
/**
* Shift the SelectionBox
*/
shiftSelectionLayerBox() {
this.#selectionLayer.shiftSelectionBox();
}
/**
* Set the respective dimensions of the SelectionBox
* @param {Object} boxDimensionObject The new box dimensions
* {
* left: new left dimension value or undefined
* top: new top dimension value or undefined
* right: new right dimension value or undefined
* bottom: new bottom dimension value or undefined
* }
*/
setSelectionBoxDimensions(boxDimensionObject) {
this.#selectionLayer.setSelectionBoxDimensions(boxDimensionObject);
}
/**
* The screenshots-overlay-container doesn't shrink with the window when the
* window is resized so we have to manually find the width and height of the
* window by looping throught the documentElement's children
* If the children mysteriously have a height or width of 0 then we will
* fallback to the scrollWidth and scrollHeight which can cause the container
* to be larger than the window dimensions
* @param win The window object
*/
updateSize(win) {
let { innerWidth, innerHeight, scrollX, scrollY } = win;
this.#selectionLayer.innerWidth = innerWidth;
this.#selectionLayer.innerHeight = innerHeight;
this.#selectionLayer.scrollX = scrollX;
this.#selectionLayer.scrollY = scrollY;
const doc = win.document.documentElement;
let width = Math.max.apply(
null,
Array.from(doc.children, x => x.scrollWidth)
);
let height = Math.max.apply(
null,
Array.from(doc.children, x => x.scrollHeight)
);
if (width < 1) {
width = doc.scrollWidth;
} else if (width < innerWidth) {
width = innerWidth;
}
if (height < 1) {
height = doc.scrollHeight;
} else if (height < innerHeight) {
height = innerHeight;
}
this.#selectionLayer.scrollWidth = width;
this.#selectionLayer.scrollHeight = height;
this.#width = width;
this.#height = height;
this.drawScreenshotsContainer();
}
/**
* Return the dimensions of the screenshots container
* @returns {Object}
* width: the container width
* height: the container height
*/
getDimension() {
return { width: this.#width, height: this.#height };
}
/**
* Draw the screenshots container
*/
drawScreenshotsContainer() {
this.content.setAttributeForElement(
this.id,
"style",
`top:0;left:0;width:${this.#width}px;height:${this.#height}px;`
);
}
get hoverElementBoxRect() {
return this.#selectionLayer.hoverElementBoxRect;
}
}