From 411330bb0c27302db2c86799b55ba780d8b5df55 Mon Sep 17 00:00:00 2001 From: shadowusr Date: Mon, 13 Apr 2026 23:18:50 +0300 Subject: [PATCH] feat: rewrite client-scripts to typescript and refactor them --- src/browser/client-scripts/.eslintrc.js | 3 - .../browser-utils/implementation.ts | 13 + .../client-scripts/browser-utils/inject.ts | 15 + .../browser-utils/tsconfig.compat.json | 8 + .../browser-utils/tsconfig.json | 8 + src/browser/client-scripts/build.js | 63 +- src/browser/client-scripts/ignore-areas.js | 7 - src/browser/client-scripts/index.js | 1029 ----------------- src/browser/client-scripts/lib.native.js | 28 - src/browser/client-scripts/rect.js | 265 ----- .../errors/outside-of-viewport.ts | 16 + .../screen-shooter/implementation.ts | 385 ++++++ .../client-scripts/screen-shooter/inject.ts | 15 + .../screen-shooter/operations.ts | 740 ++++++++++++ .../screen-shooter/tsconfig.compat.json | 8 + .../screen-shooter/tsconfig.json | 8 + .../client-scripts/screen-shooter/types.ts | 156 +++ .../screen-shooter/utils/clip-rect.ts | 136 +++ .../screen-shooter/utils/descriptions.ts | 19 + .../screen-shooter/utils/dom.ts | 113 ++ .../screen-shooter/utils/element-rect.ts | 230 ++++ .../utils/pseudo-element-rect.ts | 274 +++++ .../screen-shooter/utils/scroll.ts | 172 +++ .../screen-shooter/utils/trusted-types.ts | 19 + .../screen-shooter/utils/user-agent.ts | 8 + .../screen-shooter/utils/z-index.ts | 158 +++ .../{lib.compat.js => shared/lib.compat.ts} | 25 +- .../client-scripts/shared/lib.native.ts | 27 + src/browser/client-scripts/shared/logger.ts | 49 + .../{ => shared}/polyfills/LICENSE.md | 0 .../polyfills/getComputedStyle.ts} | 82 +- .../polyfills/matchMedia.ts} | 54 +- src/browser/client-scripts/shared/sizzle.d.ts | 3 + src/browser/client-scripts/shared/xpath.ts | 26 + .../tsconfig.compat.common.json | 24 + .../tsconfig.native.common.json | 24 + src/browser/client-scripts/util.js | 474 -------- src/browser/client-scripts/xpath.js | 31 - src/browser/isomorphic/geometry.ts | 10 +- src/browser/isomorphic/index.ts | 2 + src/browser/isomorphic/tsconfig.json | 8 + src/browser/isomorphic/types.ts | 20 + src/config/defaults.js | 4 +- src/config/types.ts | 7 +- .../vite/browser-modules/tsconfig.json | 1 + test/src/browser/client-scripts/rect.js | 313 ----- test/tsconfig.json | 3 +- tsconfig.common.json | 3 +- tsconfig.json | 14 +- 49 files changed, 2851 insertions(+), 2249 deletions(-) delete mode 100644 src/browser/client-scripts/.eslintrc.js create mode 100644 src/browser/client-scripts/browser-utils/implementation.ts create mode 100644 src/browser/client-scripts/browser-utils/inject.ts create mode 100644 src/browser/client-scripts/browser-utils/tsconfig.compat.json create mode 100644 src/browser/client-scripts/browser-utils/tsconfig.json delete mode 100644 src/browser/client-scripts/ignore-areas.js delete mode 100644 src/browser/client-scripts/index.js delete mode 100644 src/browser/client-scripts/lib.native.js delete mode 100644 src/browser/client-scripts/rect.js create mode 100644 src/browser/client-scripts/screen-shooter/errors/outside-of-viewport.ts create mode 100644 src/browser/client-scripts/screen-shooter/implementation.ts create mode 100644 src/browser/client-scripts/screen-shooter/inject.ts create mode 100644 src/browser/client-scripts/screen-shooter/operations.ts create mode 100644 src/browser/client-scripts/screen-shooter/tsconfig.compat.json create mode 100644 src/browser/client-scripts/screen-shooter/tsconfig.json create mode 100644 src/browser/client-scripts/screen-shooter/types.ts create mode 100644 src/browser/client-scripts/screen-shooter/utils/clip-rect.ts create mode 100644 src/browser/client-scripts/screen-shooter/utils/descriptions.ts create mode 100644 src/browser/client-scripts/screen-shooter/utils/dom.ts create mode 100644 src/browser/client-scripts/screen-shooter/utils/element-rect.ts create mode 100644 src/browser/client-scripts/screen-shooter/utils/pseudo-element-rect.ts create mode 100644 src/browser/client-scripts/screen-shooter/utils/scroll.ts create mode 100644 src/browser/client-scripts/screen-shooter/utils/trusted-types.ts create mode 100644 src/browser/client-scripts/screen-shooter/utils/user-agent.ts create mode 100644 src/browser/client-scripts/screen-shooter/utils/z-index.ts rename src/browser/client-scripts/{lib.compat.js => shared/lib.compat.ts} (54%) create mode 100644 src/browser/client-scripts/shared/lib.native.ts create mode 100644 src/browser/client-scripts/shared/logger.ts rename src/browser/client-scripts/{ => shared}/polyfills/LICENSE.md (100%) rename src/browser/client-scripts/{polyfills/getComputedStyle.js => shared/polyfills/getComputedStyle.ts} (55%) rename src/browser/client-scripts/{polyfills/matchMedia.js => shared/polyfills/matchMedia.ts} (58%) create mode 100644 src/browser/client-scripts/shared/sizzle.d.ts create mode 100644 src/browser/client-scripts/shared/xpath.ts create mode 100644 src/browser/client-scripts/tsconfig.compat.common.json create mode 100644 src/browser/client-scripts/tsconfig.native.common.json delete mode 100644 src/browser/client-scripts/util.js delete mode 100644 src/browser/client-scripts/xpath.js create mode 100644 src/browser/isomorphic/tsconfig.json create mode 100644 src/browser/isomorphic/types.ts delete mode 100644 test/src/browser/client-scripts/rect.js diff --git a/src/browser/client-scripts/.eslintrc.js b/src/browser/client-scripts/.eslintrc.js deleted file mode 100644 index adc5b8914..000000000 --- a/src/browser/client-scripts/.eslintrc.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - extends: "gemini-testing/browser" -}; diff --git a/src/browser/client-scripts/browser-utils/implementation.ts b/src/browser/client-scripts/browser-utils/implementation.ts new file mode 100644 index 000000000..7054e2856 --- /dev/null +++ b/src/browser/client-scripts/browser-utils/implementation.ts @@ -0,0 +1,13 @@ +import * as lib from "@lib"; + +export function resetZoom(): void { + let meta = lib.queryFirst('meta[name="viewport"]') as HTMLMetaElement; + if (!meta) { + meta = document.createElement("meta"); + meta.name = "viewport"; + + const head = lib.queryFirst("head"); + head && head.appendChild(meta); + } + meta.content = "width=device-width,initial-scale=1.0,user-scalable=no"; +} diff --git a/src/browser/client-scripts/browser-utils/inject.ts b/src/browser/client-scripts/browser-utils/inject.ts new file mode 100644 index 000000000..bea736c29 --- /dev/null +++ b/src/browser/client-scripts/browser-utils/inject.ts @@ -0,0 +1,15 @@ +import * as implementation from "./implementation"; + +declare global { + // eslint-disable-next-line no-var + var __geminiCore: Record | undefined; + // eslint-disable-next-line no-var + var __geminiNamespace: string; +} + +const globalObj = typeof window === "undefined" ? globalThis : window; + +if (!globalObj.__geminiCore) { + globalObj.__geminiCore = {}; +} +globalObj.__geminiCore[__geminiNamespace] = implementation; diff --git a/src/browser/client-scripts/browser-utils/tsconfig.compat.json b/src/browser/client-scripts/browser-utils/tsconfig.compat.json new file mode 100644 index 000000000..23e862b4b --- /dev/null +++ b/src/browser/client-scripts/browser-utils/tsconfig.compat.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.compat.common.json", + "include": [".", "../../isomorphic", "../shared/"], + "exclude": ["./tsc-out", "../shared/lib.native.ts"], + "compilerOptions": { + "outDir": "./tsc-out" + } +} diff --git a/src/browser/client-scripts/browser-utils/tsconfig.json b/src/browser/client-scripts/browser-utils/tsconfig.json new file mode 100644 index 000000000..efdd433a9 --- /dev/null +++ b/src/browser/client-scripts/browser-utils/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.native.common.json", + "include": [".", "../../isomorphic", "../shared"], + "exclude": ["./tsc-out"], + "compilerOptions": { + "outDir": "./tsc-out" + } +} diff --git a/src/browser/client-scripts/build.js b/src/browser/client-scripts/build.js index 65f3104b8..4962dd326 100644 --- a/src/browser/client-scripts/build.js +++ b/src/browser/client-scripts/build.js @@ -1,20 +1,36 @@ -/* global process, __dirname */ const path = require("path"); +const childProcess = require("node:child_process"); const browserify = require("browserify"); const uglifyify = require("uglifyify"); const aliasify = require("aliasify"); const fs = require("fs-extra"); +const compileTypescript = async (targetDir, tsConfigName = "tsconfig.json") => { + const tsConfigPath = path.join(targetDir, tsConfigName); + + if (!(await fs.pathExists(tsConfigPath))) { + throw new Error(`Could not find tsconfig: ${tsConfigPath}`); + } + + childProcess.spawnSync(process.execPath, [require.resolve("typescript/bin/tsc"), "--project", tsConfigPath], { + cwd: targetDir, + stdio: "inherit" + }); +}; + /** * @param {object} opts * @param {boolean} opts.needsCompatLib + * @param {string} opts.entryFilePath + * @param {string} opts.libPath * @returns {Promise} */ const bundleScript = async opts => { - const lib = opts.needsCompatLib ? "./lib.compat.js" : "./lib.native.js"; + const basedir = path.dirname(opts.entryFilePath); + const script = browserify({ - entries: "./index.js", - basedir: __dirname + entries: [opts.entryFilePath], + basedir }); script.transform( @@ -31,7 +47,8 @@ const bundleScript = async opts => { script.transform( { aliases: { - "./lib": { relative: lib } + "@lib": opts.libPath, + "@isomorphic": opts.isomorphicPath }, verbose: false }, @@ -41,26 +58,46 @@ const bundleScript = async opts => { return new Promise((resolve, reject) => { script.bundle((err, buffer) => { if (err) { + console.error(err); reject(err); } - resolve(buffer); + const resultingScript = `(function (__geminiNamespace) { ${buffer.toString()} })(arguments[0])`; + + resolve(resultingScript); }); }); }; async function main() { - const targetDir = path.join("build", path.relative(process.cwd(), __dirname)); + const targetDir = path.resolve(process.argv[2]); + + if (!(await fs.pathExists(targetDir))) { + throw new Error(`Target directory does not exist: ${targetDir}`); + } + + const tscOutDir = path.join(targetDir, "tsc-out"); - await fs.ensureDir(targetDir); + const compatLibPath = + "./" + path.relative(process.cwd(), path.join(tscOutDir, "client-scripts", "shared", "lib.compat.js")); + const nativeLibPath = + "./" + path.relative(process.cwd(), path.join(tscOutDir, "client-scripts", "shared", "lib.native.js")); await Promise.all( [ - { needsCompatLib: true, fileName: "bundle.compat.js" }, - { needsCompatLib: false, fileName: "bundle.native.js" } - ].map(async ({ needsCompatLib, fileName }) => { - const buffer = await bundleScript({ needsCompatLib }); - const filePath = path.join(targetDir, fileName); + { needsCompatLib: true, fileName: "bundle.compat.js", libPath: compatLibPath }, + { needsCompatLib: false, fileName: "bundle.native.js", libPath: nativeLibPath } + ].map(async ({ needsCompatLib, fileName, libPath }) => { + await compileTypescript(targetDir, needsCompatLib ? "tsconfig.compat.json" : "tsconfig.json"); + + const projectDirName = path.basename(targetDir); + const entryFilePath = path.join(tscOutDir, "client-scripts", projectDirName, "inject.js"); + const isomorphicPath = path.join(tscOutDir, "isomorphic", "index.js"); + const buffer = await bundleScript({ needsCompatLib, entryFilePath, libPath, isomorphicPath }); + + const buildDir = path.join(targetDir, "build"); + await fs.ensureDir(buildDir); + const filePath = path.join(buildDir, fileName); await fs.writeFile(filePath, buffer); }) diff --git a/src/browser/client-scripts/ignore-areas.js b/src/browser/client-scripts/ignore-areas.js deleted file mode 100644 index ac2db1755..000000000 --- a/src/browser/client-scripts/ignore-areas.js +++ /dev/null @@ -1,7 +0,0 @@ -"use strict"; - -var lib = require("./lib"); - -module.exports = function queryIgnoreAreas(selector) { - return lib.queryAll(selector); -}; diff --git a/src/browser/client-scripts/index.js b/src/browser/client-scripts/index.js deleted file mode 100644 index 6d5b3cd05..000000000 --- a/src/browser/client-scripts/index.js +++ /dev/null @@ -1,1029 +0,0 @@ -/*jshint browserify:true*/ -"use strict"; - -var util = require("./util"), - rect = require("./rect"), - lib = require("./lib"), - queryIgnoreAreas = require("./ignore-areas"), - Rect = rect.Rect; - -if (typeof window === "undefined") { - global.__geminiCore = exports; -} else { - window.__geminiCore = exports; -} - -exports.queryFirst = lib.queryFirst; - -exports.prepareScreenshot = function prepareScreenshot(areas, opts) { - opts = opts || {}; - try { - return prepareScreenshotUnsafe(areas, opts); - } catch (e) { - return { - errorCode: "JS", - message: e.stack || e.message - }; - } -}; - -exports.disableFrameAnimations = function disableFrameAnimations() { - try { - return disableFrameAnimationsUnsafe(); - } catch (e) { - return { - errorCode: "JS", - message: e.stack || e.message - }; - } -}; - -exports.cleanupFrameAnimations = function cleanupFrameAnimations() { - if (window.__cleanupAnimation) { - window.__cleanupAnimation(); - } -}; - -exports.disablePointerEvents = function disablePointerEvents() { - try { - return disablePointerEventsUnsafe(); - } catch (e) { - return { - errorCode: "JS", - message: e.stack || e.message - }; - } -}; - -exports.cleanupPointerEvents = function cleanupPointerEvents() {}; - -function prepareScreenshotUnsafe(areas, opts) { - var logger = util.createDebugLogger(opts); - - var allowViewportOverflow = opts.allowViewportOverflow; - var captureElementFromTop = opts.captureElementFromTop; - var disableAnimation = opts.disableAnimation; - var scrollElem = window; - - var mainDocumentElem = util.getMainDocumentElem(), - viewportWidth = mainDocumentElem.clientWidth, - viewportHeight = mainDocumentElem.clientHeight, - documentWidth = mainDocumentElem.scrollWidth, - documentHeight = mainDocumentElem.scrollHeight, - viewPort = new Rect({ - left: util.getScrollLeft(scrollElem), - top: util.getScrollTop(scrollElem), - width: viewportWidth, - height: viewportHeight - }), - pixelRatio = configurePixelRatio(opts.usePixelRatio), - rect, - selectors = []; - - logger("prepareScreenshotUnsafe, viewport size at the start:", viewPort); - - areas.forEach(function (area) { - if (Rect.isRect(area)) { - rect = rect ? rect.merge(new Rect(area)) : new Rect(area); - } else { - selectors.push(area); - } - }); - - var initialRect = rect; - var captureElements = getCaptureElements(selectors); - - if (opts.selectorToScroll) { - scrollElem = document.querySelector(opts.selectorToScroll); - - if (!scrollElem) { - return { - errorCode: "NOTFOUND", - message: - 'Could not find element with css selector specified in "selectorToScroll" option: ' + - opts.selectorToScroll, - selector: opts.selectorToScroll, - debugLog: logger() - }; - } - } else { - // Try to determine scroll element automatically or fallback to window - var scrollParents = captureElements.map(function (element) { - return util.getScrollParent(element, logger); - }); - - if ( - scrollParents[0] && - scrollParents.every(function (element) { - return scrollParents[0] === element; - }) - ) { - scrollElem = scrollParents[0]; - } - } - - rect = getCaptureRect( - captureElements, - { - initialRect: initialRect, - allowViewportOverflow: allowViewportOverflow, - scrollElem: scrollElem, - viewportWidth: viewportWidth, - documentHeight: documentHeight - }, - logger - ); - logger("getCaptureRect, resulting rect:", rect); - - if (!rect) { - return { - errorCode: "HIDDEN", - message: "Area with css selector : " + selectors + " is hidden", - selector: selectors, - debugLog: logger() - }; - } - - if (rect.error) { - return rect; - } - - var ignoreAreas = findIgnoreAreas( - opts.ignoreSelectors, - { - scrollElem: scrollElem, - pixelRatio: pixelRatio, - viewportWidth: viewportWidth, - documentHeight: documentHeight - }, - logger - ); - - var safeArea = getSafeAreaRect( - rect, - captureElements, - { - scrollElem: scrollElem, - viewportWidth: viewportWidth, - viewportHeight: viewportHeight - }, - logger - ); - - var topmostCaptureElementTop = captureElements.reduce(function (top, currentElement) { - var currentElementTop = currentElement.getBoundingClientRect().top; - if (currentElementTop < top) { - return currentElementTop; - } - - return top; - }, 9999999); - - var scrollOffsetTopForFit = scrollElem === window || !scrollElem.parentElement ? 0 : util.getScrollTop(scrollElem); - var rectTopInViewportForFit = rect.top - scrollOffsetTopForFit - window.scrollY; - var rectBottomInViewportForFit = rectTopInViewportForFit + rect.height; - var fitsInSafeArea = - rectTopInViewportForFit >= safeArea.top && rectBottomInViewportForFit <= safeArea.top + safeArea.height; - - if (captureElementFromTop && !fitsInSafeArea) { - logger("captureElementFromTop=true and capture element is outside of viewport, going to perform scroll"); - if (!util.isRootElement(scrollElem) && captureElementFromTop) { - var scrollElemBoundingRect = getBoundingClientContentRect(scrollElem); - var targetWindowScrollY = Math.floor(scrollElemBoundingRect.top - safeArea.top); - - logger( - " performing window.scrollTo to scroll to scrollElement, coords: " + - window.scrollX + - ", " + - targetWindowScrollY - ); - window.scrollTo(window.scrollX, targetWindowScrollY); - - rect = getCaptureRect( - captureElements, - { - initialRect: initialRect, - allowViewportOverflow: allowViewportOverflow, - scrollElem: scrollElem, - viewportWidth: viewportWidth, - documentHeight: documentHeight - }, - logger - ); - - ignoreAreas = findIgnoreAreas( - opts.ignoreSelectors, - { - scrollElem: scrollElem, - pixelRatio: pixelRatio, - viewportWidth: viewportWidth, - documentHeight: documentHeight - }, - logger - ); - - safeArea = getSafeAreaRect( - rect, - captureElements, - { - scrollElem: scrollElem, - viewportWidth: viewportWidth, - viewportHeight: viewportHeight - }, - logger - ); - } - logger(" capture rect before scrolling to capture area:", rect); - - // If we are scrolling window, we just need to scroll to element, taking safeArea into account. - // If we are scrolling inside some container, we should take both safe area and existing window scroll offset into account. - // Example: We have container at 1000px and target block inside it at 2000px (measured in global page coords). - // In the code above we scrolled window by 1000px to container. - // So now we only need to scroll by 1000px inside that container to our block, not by 2000px, because we already scrolled window by 1000px. - var targetScrollY = Math.max( - Math.floor(rect.top - (util.isRootElement(scrollElem) ? safeArea.top : safeArea.top + window.scrollY)), - 0 - ); - var targetScrollX = util.isRootElement(scrollElem) ? window.scrollX : scrollElem.scrollLeft; - - logger(" performing scroll to capture area, coords: " + targetScrollY + ", " + targetScrollX); - - if (util.isSafariMobile()) { - scrollToCaptureAreaInSafari( - viewPort, - new Rect({ left: rect.left, top: targetScrollY, width: rect.width, height: rect.height }), - scrollElem - ); - } else { - scrollElem.scrollTo(targetScrollX, targetScrollY); - } - - rect = getCaptureRect( - captureElements, - { - initialRect: initialRect, - allowViewportOverflow: allowViewportOverflow, - scrollElem: scrollElem, - viewportWidth: viewportWidth, - documentHeight: documentHeight - }, - logger - ); - - ignoreAreas = findIgnoreAreas( - opts.ignoreSelectors, - { - scrollElem: scrollElem, - pixelRatio: pixelRatio, - viewportWidth: viewportWidth, - documentHeight: documentHeight - }, - logger - ); - - safeArea = getSafeAreaRect( - rect, - captureElements, - { - scrollElem: scrollElem, - viewportWidth: viewportWidth, - viewportHeight: viewportHeight - }, - logger - ); - - logger(" capture rect after scrolling to capture area:", rect); - } else if (!viewPort.rectIntersects(rect)) { - // Element is completely outside viewport with no intersection - always error - return { - errorCode: "OUTSIDE_OF_VIEWPORT", - message: - "Can not capture element, because it is completely outside of viewport with no intersection. " + - 'Try to set "captureElementFromTop=true" to scroll to it before capture.', - debugLog: logger() - }; - } - - // Check if element has intersection with safeArea (viewport minus sticky/fixed interfering elements) - var safeAreaRect = new Rect({ - left: safeArea.left + util.getScrollLeft(scrollElem) + (scrollElem !== window ? window.pageXOffset : 0), - top: safeArea.top + util.getScrollTop(scrollElem) + (scrollElem !== window ? window.pageYOffset : 0), - width: safeArea.width, - height: safeArea.height - }); - - if (!safeAreaRect.rectIntersects(rect)) { - // Element has no intersection with safe area - completely obscured by sticky/fixed elements - return { - errorCode: "OUTSIDE_OF_SAFE_AREA", - message: - "Can not capture element at position: " + - rect.toString() + - ", because it has no intersection with the safe area at position: " + - safeAreaRect.toString() + - ". " + - "The element is completely obscured by fixed or sticky elements. " + - 'Try to set "captureElementFromTop=true" to scroll to it before capture.', - debugLog: logger() - }; - } - - var visibleAreaRect = getVisibleAreaRect({ - scrollElem: scrollElem, - viewportWidth: viewportWidth, - viewportHeight: viewportHeight - }); - - if (allowViewportOverflow && viewPort.rectIntersects(rect)) { - // Element has intersection with viewport and overflow is allowed - adjust bounds - rect.overflowsTopBound(viewPort) && rect.recalculateHeight(viewPort); - rect.overflowsLeftBound(viewPort) && rect.recalculateWidth(viewPort); - } else if ( - !captureElementFromTop && - !allowViewportOverflow && - (topmostCaptureElementTop < visibleAreaRect.top || - topmostCaptureElementTop >= visibleAreaRect.top + visibleAreaRect.height) - ) { - // captureElementFromTop is false, element is outside safe area, overflow not allowed - error - return { - errorCode: "OUTSIDE_OF_VISIBLE_AREA", - message: - "Can not capture element, because it is outside of the visible area (viewport area minus interfering sticky/fixed elements).\n" + - "Top bound of capture area is: " + - topmostCaptureElementTop + - ", visible area is: " + - visibleAreaRect.toString() + - "\n" + - "The element might be obscured by fixed or sticky elements. " + - 'Try to set "captureElementFromTop=true" to scroll to it before capture' + - ' or to set "allowViewportOverflow=true" to ignore this error. ' + - 'Or try to set "selectorToScroll" to a parent element of the element to capture.', - debugLog: logger() - }; - } - - if (disableAnimation) { - disableFrameAnimationsUnsafe(); - } - - var disableHover = opts.disableHover; - var pointerEventsDisabled = false; - if (disableHover === "always") { - logger("adding stylesheet with pointer-events: none on all elements"); - disablePointerEventsUnsafe(); - pointerEventsDisabled = true; - } else if (disableHover === "when-scrolling-needed" && opts.compositeImage) { - var scrollOffsetTop = scrollElem === window || !scrollElem.parentElement ? 0 : util.getScrollTop(scrollElem); - var rectTopInViewport = rect.top - scrollOffsetTop - window.scrollY; - var needsScrolling = - rectTopInViewport < safeArea.top || rectTopInViewport + rect.height > safeArea.top + safeArea.height; - - if (needsScrolling) { - logger("adding stylesheet with pointer-events: none on all elements (composite capture needs scrolling)"); - disablePointerEventsUnsafe(); - pointerEventsDisabled = true; - } - } - - logger("prepareScreenshotUnsafe, final capture rect:", rect); - logger("prepareScreenshotUnsafe, pixelRatio:", pixelRatio); - - return { - captureArea: rect.scale(pixelRatio).serialize(), - ignoreAreas: ignoreAreas, - viewport: new Rect({ - left: util.getScrollLeft(scrollElem), - top: util.getScrollTop(scrollElem), - width: viewportWidth, - height: viewportHeight - }) - .scale(pixelRatio) - .serialize(), - viewportOffset: { - top: Math.floor(window.scrollY * pixelRatio), - left: Math.floor(window.scrollX * pixelRatio) - }, - safeArea: safeArea.scale(pixelRatio).serialize(), - documentHeight: Math.ceil(documentHeight * pixelRatio), - documentWidth: Math.ceil(documentWidth * pixelRatio), - canHaveCaret: isEditable(document.activeElement), - pixelRatio: pixelRatio, - scrollElementOffset: { - top: - scrollElem === window || scrollElem.parentElement === null - ? 0 - : Math.floor(util.getScrollTop(scrollElem) * pixelRatio), - left: - scrollElem === window || scrollElem.parentElement === null - ? 0 - : Math.floor(util.getScrollLeft(scrollElem) * pixelRatio) - }, - pointerEventsDisabled: pointerEventsDisabled, - debugLog: logger() - }; -} - -function createDefaultTrustedTypesPolicy() { - if (window.trustedTypes && window.trustedTypes.createPolicy) { - window.trustedTypes.createPolicy("default", { - createHTML: function (string) { - return string; - } - }); - } -} - -function disableFrameAnimationsUnsafe() { - var everyElementSelector = "*:not(#testplane-q.testplane-w.testplane-e.testplane-r.testplane-t.testplane-y)"; - var everythingSelector = ["", "::before", "::after"] - .map(function (pseudo) { - return everyElementSelector + pseudo; - }) - .join(", "); - - var styleElements = []; - - function appendDisableAnimationStyleElement(root) { - var styleElement = document.createElement("style"); - styleElement.innerHTML = - everythingSelector + - [ - "{", - " animation-delay: 0ms !important;", - " animation-duration: 0ms !important;", - " animation-timing-function: step-start !important;", - " transition-timing-function: step-start !important;", - " scroll-behavior: auto !important;", - " transition: none !important;", - "}" - ].join("\n"); - - root.appendChild(styleElement); - styleElements.push(styleElement); - } - - util.forEachRoot(function (root) { - try { - appendDisableAnimationStyleElement(root); - } catch (err) { - if (err && err.message && err.message.includes("This document requires 'TrustedHTML' assignment")) { - createDefaultTrustedTypesPolicy(); - - appendDisableAnimationStyleElement(root); - } else { - throw err; - } - } - }); - - window.__cleanupAnimation = function () { - for (var i = 0; i < styleElements.length; i++) { - // IE11 doesn't have remove() on node - styleElements[i].parentNode.removeChild(styleElements[i]); - } - - delete window.__cleanupAnimation; - }; -} - -function disablePointerEventsUnsafe() { - var everyElementSelector = "*:not(#testplane-q.testplane-w.testplane-e.testplane-r.testplane-t.testplane-y)"; - var everythingSelector = ["", "::before", "::after"] - .map(function (pseudo) { - return everyElementSelector + pseudo; - }) - .join(", "); - - var styleElements = []; - - function appendDisablePointerEventsStyleElement(root) { - var styleElement = document.createElement("style"); - styleElement.innerHTML = everythingSelector + ["{", " pointer-events: none !important;", "}"].join("\n"); - - root.appendChild(styleElement); - styleElements.push(styleElement); - } - - util.forEachRoot(function (root) { - try { - appendDisablePointerEventsStyleElement(root); - } catch (err) { - if (err && err.message && err.message.includes("This document requires 'TrustedHTML' assignment")) { - createDefaultTrustedTypesPolicy(); - - appendDisablePointerEventsStyleElement(root); - } else { - throw err; - } - } - }); - - exports.cleanupPointerEvents = function () { - for (var i = 0; i < styleElements.length; i++) { - styleElements[i].parentNode.removeChild(styleElements[i]); - } - exports.cleanupPointerEvents = function () {}; - }; -} - -exports.resetZoom = function () { - var meta = lib.queryFirst('meta[name="viewport"]'); - if (!meta) { - meta = document.createElement("meta"); - meta.name = "viewport"; - var head = lib.queryFirst("head"); - head && head.appendChild(meta); - } - meta.content = "width=device-width,initial-scale=1.0,user-scalable=no"; -}; - -function getBoundingClientContentRect(element) { - var elemStyle = lib.getComputedStyle(element); - var borderLeft = parseFloat(elemStyle.borderLeftWidth) || 0; - var borderTop = parseFloat(elemStyle.borderTopWidth) || 0; - var borderRight = parseFloat(elemStyle.borderRightWidth) || 0; - var borderBottom = parseFloat(elemStyle.borderBottomWidth) || 0; - - return new Rect({ - left: element.getBoundingClientRect().left + borderLeft, - top: element.getBoundingClientRect().top + borderTop, - width: element.getBoundingClientRect().width - borderLeft - borderRight, - height: element.getBoundingClientRect().height - borderTop - borderBottom - }); -} - -function getVisibleAreaRect(opts) { - var scrollElem = opts.scrollElem; - var viewportWidth = opts.viewportWidth; - var viewportHeight = opts.viewportHeight; - - if (scrollElem === window) { - return new Rect({ left: 0, top: 0, width: viewportWidth, height: viewportHeight }); - } else { - var scrollElemBoundingRect = getBoundingClientContentRect(scrollElem); - - var viewportRect = new Rect({ left: 0, top: 0, width: viewportWidth, height: viewportHeight }); - - var scrollElemInsideViewport = _getIntersectionRect(scrollElemBoundingRect, viewportRect); - - return scrollElemInsideViewport || viewportRect; - } -} - -function getSafeAreaRect(captureArea, captureElements, opts, logger) { - // Safe area is the dimensions of current scrollable container minus vertical space of sticky elements that may interfere with our target elements. - if (!captureArea || !opts) { - return new Rect({ - left: 0, - top: 0, - width: (opts || {}).viewportWidth || 0, - height: (opts || {}).viewportHeight || 0 - }); - } - - var captureElementsOrBody = captureElements.length > 0 ? captureElements : [document.body]; - var scrollElem = opts.scrollElem; - var viewportHeight = opts.viewportHeight; - - // 1. Base safe area equals the visible rectangle of the scroll container. - var safeArea = getVisibleAreaRect(opts); - var originalSafeArea = new Rect({ - left: safeArea.left, - top: safeArea.top, - width: safeArea.width, - height: safeArea.height - }); - - var captureAreaInViewportCoords = new Rect({ - left: captureArea.left - util.getScrollLeft(scrollElem), - top: captureArea.top - util.getScrollTop(scrollElem), - width: captureArea.width, - height: captureArea.height - }); - - // 2. Build z-index chains for all capture elements - // One z-chain is a list of objects: { stacking context, z-index } -> { stacking context, z-index } -> ... - // It is used to determine which element is on top of the other. - var targetChains = captureElementsOrBody.map(function (el) { - return util.buildZChain(el); - }); - - // 3. Detect interfering elements - var root = document.documentElement; - var allElements = root.querySelectorAll ? root.querySelectorAll("*") : []; - - var interferingRects = []; - - allElements.forEach(function (el) { - // Skip elements that contain capture elements - if ( - util.some(captureElementsOrBody, function (capEl) { - return el.contains(capEl); - }) - ) { - return; - } - - var computedStyle = lib.getComputedStyle(el); - var position = computedStyle.position; - var br = el.getBoundingClientRect(); - - // Skip invisible elements - if (br.width < 1 || br.height < 1) { - return; - } - - // Skip elements that don't horizontally intersect with capture area - if (br.right <= captureAreaInViewportCoords.left || br.left >= captureAreaInViewportCoords.right) { - return; - } - - var likelyInterferes = false; - var interferenceReason = ""; - if (position === "fixed") { - likelyInterferes = true; - } else if (position === "absolute") { - // Skip absolutely positioned elements that are inside capture elements - if ( - captureElementsOrBody.some(function (captureEl) { - return captureEl.contains(el); - }) - ) { - return; - } - // Absolute elements interfere only if positioned relative to ancestor outside scroll container - var containingBlock = util.findContainingBlock(el); - // scrollElem may be window, in which case it doesn't have a contains method - if ( - containingBlock && - scrollElem && - typeof scrollElem.contains === "function" && - !scrollElem.contains(containingBlock) - ) { - interferenceReason = "absolute element is positioned relative to ancestor outside scroll container"; - likelyInterferes = true; - } - } else if (position === "sticky") { - // Sticky elements interfere based on their top/bottom values - var topValue = parseFloat(computedStyle.top); - var bottomValue = parseFloat(computedStyle.bottom); - - var scrollParent = util.getScrollParent(el, logger); - if (scrollParent && typeof scrollParent.getBoundingClientRect === "function") { - var scrollParentBr = scrollParent.getBoundingClientRect(); - topValue += scrollParentBr.top; - } - - if (!isNaN(topValue)) { - br = new Rect({ - left: br.left, - top: topValue, - width: br.width, - height: br.height - }); - likelyInterferes = true; - interferenceReason = - "sticky element is positioned to top, topValue: " + - topValue + - " bounding rect: " + - JSON.stringify(br); - } else if (!isNaN(bottomValue)) { - var viewportBottom = util.isRootElement(scrollElem) ? viewportHeight : safeArea.top + safeArea.height; - br = new Rect({ - left: br.left, - top: viewportBottom - bottomValue - br.height, - width: br.width, - height: br.height - }); - likelyInterferes = true; - interferenceReason = - "sticky element is positioned to bottom, bottomValue: " + - bottomValue + - " bounding rect: " + - JSON.stringify(br); - } - } - - if (likelyInterferes) { - logger( - "getSafeAreaRect(), this element likely interferes: " + - el.classList.toString() + - " interference reason: " + - interferenceReason - ); - var candChain = util.buildZChain(el, { includeReasons: false }); - - var behindAll = targetChains.every(function (tChain) { - return util.isChainBehind(candChain, tChain); - }); - - logger(" is candidate z chain behind all target chains? : " + behindAll); - - if (!behindAll) { - var extRect = getExtRect(computedStyle, br, true); - if ( - extRect.right <= captureAreaInViewportCoords.left || - extRect.left >= captureAreaInViewportCoords.right - ) { - return; - } - interferingRects.push({ - x: extRect.left, - y: extRect.top, - width: extRect.width, - height: extRect.height - }); - } - } - }); - - logger("getSafeAreaRect, safeArea before shrinking:", safeArea); - logger("getSafeAreaRect, interferingRects:", interferingRects); - - // 4. Shrink safe area according to interfering elements - interferingRects.forEach(function (br) { - logger("getSafeAreaRect, interferingRects, br:", br); - - var safeAreaBottom = safeArea.top + safeArea.height; - - var shrinkTop = br.y + br.height - safeArea.top; - var shrinkBottom = safeAreaBottom - br.y; - - logger(" getSafeAreaRect, shrinkTop:", shrinkTop); - logger(" getSafeAreaRect, shrinkBottom:", shrinkBottom); - - var resultingTop = safeArea.top, - resultingHeight = safeArea.height; - - if (shrinkTop < shrinkBottom) { - resultingTop = Math.max(safeArea.top, br.y + br.height); - resultingHeight = safeAreaBottom - resultingTop; - } else { - resultingHeight = Math.min(safeArea.height, br.y - safeArea.top); - } - - if (resultingHeight < originalSafeArea.height / 2) { - logger( - " getSafeAreaRect, resultingHeight is less than half of originalSafeArea.height, skipping due to too large shrinking" - ); - return; - } - - safeArea.top = resultingTop; - safeArea.height = resultingHeight; - - logger(" getSafeAreaRect, safeArea after shrinking:", safeArea); - }); - - // 5. Ensure we didn't shrink more than 50% of original height - if (safeArea.height < originalSafeArea.height / 2) { - safeArea.top = originalSafeArea.top; - safeArea.height = originalSafeArea.height; - } - - logger("getSafeAreaRect, final safeArea after shrinking:", safeArea); - logger("getSafeAreaRect, final originalSafeArea:", originalSafeArea); - - return new Rect({ - left: Math.floor(safeArea.left), - top: Math.floor(safeArea.top), - width: Math.floor(safeArea.width), - height: Math.floor(safeArea.height) - }); -} - -function _getIntersectionRect(rectA, rectB) { - var left = Math.max(rectA.left, rectB.left); - var top = Math.max(rectA.top, rectB.top); - var right = Math.min(rectA.right, rectB.right); - var bottom = Math.min(rectA.bottom, rectB.bottom); - - if (left >= right || top >= bottom) { - return null; - } - - return new Rect({ left: left, top: top, right: right, bottom: bottom }); -} - -function getCaptureElements(selectors) { - var elements = []; - for (var i = 0; i < selectors.length; i++) { - var element = lib.queryFirst(selectors[i]); - if (!element) { - return { - errorCode: "NOTFOUND", - message: "Could not find element with css selector specified in setCaptureElements: " + selectors[i], - selector: selectors[i] - }; - } - elements.push(element); - } - - return elements; -} - -function getCaptureRect(captureElements, opts, logger) { - var element, - elementRect, - rect = opts.initialRect; - for (var i = 0; i < captureElements.length; i++) { - element = captureElements[i]; - - elementRect = getElementCaptureRect(element, opts, logger); - - logger("getElementCaptureRect resulting elementRect:", elementRect); - - if (elementRect) { - rect = rect ? rect.merge(elementRect) : elementRect; - } - } - - return rect ? rect.round() : rect; -} - -function configurePixelRatio(usePixelRatio) { - if (usePixelRatio === false) { - return 1; - } - - if (window.devicePixelRatio) { - return window.devicePixelRatio; - } - - // for ie6-ie10 (https://developer.mozilla.org/ru/docs/Web/API/Window/devicePixelRatio) - return window.screen.deviceXDPI / window.screen.logicalXDPI || 1; -} - -function findIgnoreAreas(selectors, opts, logger) { - var result = []; - util.each(selectors, function (selector) { - var elements = queryIgnoreAreas(selector); - - util.each(elements, function (elem) { - var ignoreArea = addIgnoreArea.call(result, elem, opts, logger); - - return ignoreArea; - }); - }); - - return result; -} - -function addIgnoreArea(element, opts, logger) { - var rect = element && getElementCaptureRect(element, opts, logger); - - if (!rect) { - return; - } - - var ignoreArea = rect.round().scale(opts.pixelRatio).serialize(); - - this.push(ignoreArea); -} - -function isHidden(css, clientRect) { - return ( - css.display === "none" || - css.visibility === "hidden" || - css.opacity < 0.0001 || - clientRect.width < 0.0001 || - clientRect.height < 0.0001 - ); -} - -function getElementCaptureRect(element, opts, logger) { - /* Terminology: - - clientRect = the result of calling getBoundingClientRect on the element - - extRect = clientRect + outline + box shadow - - elementCaptureRect = sum of extRects of the element and its pseudo-elements - - captureRect = sum of all elementCaptureRects for each selector to capture - */ - var pseudo = [":before", ":after"], - css = lib.getComputedStyle(element), - clientRect = rect.getAbsoluteClientRect(element, opts, logger); - logger("getAbsoluteClientRect result: ", clientRect); - - if (isHidden(css, clientRect)) { - return null; - } - - var elementRect = getExtRect(css, clientRect, opts.allowViewportOverflow); - - util.each(pseudo, function (pseudoEl) { - css = lib.getComputedStyle(element, pseudoEl); - elementRect = elementRect.merge(getExtRect(css, clientRect, opts.allowViewportOverflow)); - }); - - return elementRect; -} - -function getExtRect(css, clientRect, allowViewportOverflow) { - var shadows = parseBoxShadow(css.boxShadow), - outline = parseInt(css.outlineWidth, 10); - - if (isNaN(outline)) { - outline = 0; - } - - return adjustRect(clientRect, shadows, outline, allowViewportOverflow); -} - -function parseBoxShadow(value) { - value = value || ""; - var regex = /[-+]?\d*\.?\d+px/g, - values = value.split(","), - results = [], - match; - - util.each(values, function (value) { - if ((match = value.match(regex))) { - results.push({ - offsetX: parseFloat(match[0]), - offsetY: parseFloat(match[1]) || 0, - blurRadius: parseFloat(match[2]) || 0, - spreadRadius: parseFloat(match[3]) || 0, - inset: value.indexOf("inset") !== -1 - }); - } - }); - return results; -} - -function adjustRect(rect, shadows, outline, allowViewportOverflow) { - var shadowRect = calculateShadowRect(rect, shadows, allowViewportOverflow), - outlineRect = calculateOutlineRect(rect, outline, allowViewportOverflow); - return shadowRect.merge(outlineRect); -} - -function calculateOutlineRect(rect, outline, allowViewportOverflow) { - var top = rect.top - outline, - left = rect.left - outline; - - return new Rect({ - top: allowViewportOverflow ? top : Math.max(0, top), - left: allowViewportOverflow ? left : Math.max(0, left), - bottom: rect.bottom + outline, - right: rect.right + outline - }); -} - -function calculateShadowRect(rect, shadows, allowViewportOverflow) { - var extent = calculateShadowExtent(shadows), - left = rect.left + extent.left, - top = rect.top + extent.top; - - return new Rect({ - left: allowViewportOverflow ? left : Math.max(0, left), - top: allowViewportOverflow ? top : Math.max(0, top), - width: rect.width - extent.left + extent.right, - height: rect.height - extent.top + extent.bottom - }); -} - -function calculateShadowExtent(shadows) { - var result = { top: 0, left: 0, right: 0, bottom: 0 }; - - util.each(shadows, function (shadow) { - if (shadow.inset) { - //skip inset shadows - return; - } - - var blurAndSpread = shadow.spreadRadius + shadow.blurRadius; - result.left = Math.min(shadow.offsetX - blurAndSpread, result.left); - result.right = Math.max(shadow.offsetX + blurAndSpread, result.right); - result.top = Math.min(shadow.offsetY - blurAndSpread, result.top); - result.bottom = Math.max(shadow.offsetY + blurAndSpread, result.bottom); - }); - return result; -} - -function isEditable(element) { - if (!element) { - return false; - } - return /^(input|textarea)$/i.test(element.tagName) || element.isContentEditable; -} - -function scrollToCaptureAreaInSafari(viewportCurr, captureArea, scrollElem) { - var mainDocumentElem = util.getMainDocumentElem(); - var documentHeight = Math.round(mainDocumentElem.scrollHeight); - var viewportHeight = Math.round(mainDocumentElem.clientHeight); - var maxScrollByY = documentHeight - viewportHeight; - - scrollElem.scrollTo(viewportCurr.left, Math.min(captureArea.top, maxScrollByY)); - - // TODO: uncomment after fix bug in safari - https://bugs.webkit.org/show_bug.cgi?id=179735 - /* - var viewportAfterScroll = new Rect({ - left: util.getScrollLeft(scrollElem), - top: util.getScrollTop(scrollElem), - width: viewportCurr.width, - height: viewportCurr.height - }); - - if (!viewportAfterScroll.rectInside(captureArea)) { - scrollElem.scrollTo(captureArea.left, captureArea.top); - } - */ -} diff --git a/src/browser/client-scripts/lib.native.js b/src/browser/client-scripts/lib.native.js deleted file mode 100644 index c070d8c07..000000000 --- a/src/browser/client-scripts/lib.native.js +++ /dev/null @@ -1,28 +0,0 @@ -"use strict"; -var xpath = require("./xpath"); - -exports.queryFirst = function (selector) { - if (xpath.isXpathSelector(selector)) { - return xpath.queryFirst(selector); - } - return document.querySelector(selector); -}; - -exports.queryAll = function (selector) { - if (xpath.isXpathSelector(selector)) { - return xpath.queryAll(selector); - } - return document.querySelectorAll(selector); -}; - -exports.getComputedStyle = function (element, pseudoElement) { - return getComputedStyle(element, pseudoElement); -}; - -exports.matchMedia = function (mediaQuery) { - return matchMedia(mediaQuery); -}; - -exports.trim = function (str) { - return str.trim(); -}; diff --git a/src/browser/client-scripts/rect.js b/src/browser/client-scripts/rect.js deleted file mode 100644 index b403e2fe2..000000000 --- a/src/browser/client-scripts/rect.js +++ /dev/null @@ -1,265 +0,0 @@ -"use strict"; - -var util = require("./util"); - -function Rect(data) { - this.top = data.top; - this.left = data.left; - - if ("width" in data && "height" in data) { - this.width = data.width; - this.height = data.height; - this.bottom = data.bottom || this.top + this.height; - this.right = data.right || this.left + this.width; - } else if ("bottom" in data && "right" in data) { - this.bottom = data.bottom; - this.right = data.right; - this.width = data.right - Math.max(0, data.left); - this.height = data.bottom - Math.max(0, data.top); - } else { - throw new Error("Not enough data for the rect construction"); - } -} - -Rect.isRect = function (data) { - if (typeof data !== "object" || data === null || Array.isArray(data)) { - return false; - } - - return ( - "left" in data && - "top" in data && - (("width" in data && "height" in data) || ("right" in data && "bottom" in data)) - ); -}; - -Rect.prototype = { - constructor: Rect, - merge: function (otherRect) { - return new Rect({ - left: Math.min(this.left, otherRect.left), - top: Math.min(this.top, otherRect.top), - bottom: Math.max(this.bottom, otherRect.bottom), - right: Math.max(this.right, otherRect.right) - }); - }, - - translate: function (x, y) { - return new Rect({ - left: this.left + x, - top: this.top + y, - width: this.width, - height: this.height - }); - }, - - pointInside: function (x, y) { - return x >= this.left && x <= this.right && y >= this.top && y <= this.bottom; - }, - - rectInside: function (rect) { - return util.every( - rect._keyPoints(), - function (point) { - return this.pointInside(point[0], point[1]); - }, - this - ); - }, - - rectIntersects: function (other) { - var isOtherOutside = - other.right <= this.left || - other.bottom <= this.top || - other.left >= this.right || - other.top >= this.bottom; - - return !isOtherOutside; - }, - - round: function () { - return new Rect({ - top: Math.floor(this.top), - left: Math.floor(this.left), - right: Math.ceil(this.right), - bottom: Math.ceil(this.bottom) - }); - }, - - scale: function (scaleFactor) { - var rect = new Rect({ - top: this.top * scaleFactor, - left: this.left * scaleFactor, - right: this.right * scaleFactor, - bottom: this.bottom * scaleFactor - }); - - return util.isInteger(scaleFactor) ? rect : rect.round(); - }, - - serialize: function () { - return { - left: this.left, - top: this.top, - width: this.width, - height: this.height - }; - }, - - toString: function () { - return ( - "Rect(left: " + - this.left + - ", top: " + - this.top + - ", width: " + - this.width + - ", height: " + - this.height + - ")" - ); - }, - - overflowsTopBound: function (rect) { - return this._overflowsBound(rect, "top"); - }, - - overflowsLeftBound: function (rect) { - return this._overflowsBound(rect, "left"); - }, - - /** @type Function */ - recalculateHeight: function (rect) { - this.height = this.height - (rect.top - Math.max(0, this.top)); - }, - - /** @type Function */ - recalculateWidth: function (rect) { - this.width = this.width - (rect.left - Math.max(0, this.left)); - }, - - _overflowsBound: function (rect, prop) { - return Math.max(0, this[prop]) < rect[prop]; - }, - - _anyPointInside: function (points) { - return util.some( - points, - function (point) { - return this.pointInside(point[0], point[1]); - }, - this - ); - }, - - _keyPoints: function () { - return [ - [this.left, this.top], - [this.left, this.bottom], - [this.right, this.top], - [this.right, this.bottom] - ]; - } -}; - -exports.Rect = Rect; -exports.getAbsoluteClientRect = function getAbsoluteClientRect(element, opts, logger) { - var coords = getNestedBoundingClientRect(element, window); - var widthRatio = coords.width % opts.viewportWidth; - var heightRatio = coords.height % opts.documentHeight; - - var clientRect = new Rect({ - left: coords.left, - top: coords.top, - // to correctly calculate "width" and "height" in devices with fractional pixelRatio - width: widthRatio > 0 && widthRatio < 1 ? opts.viewportWidth : coords.width, - height: heightRatio > 0 && heightRatio < 1 ? opts.documentHeight : coords.height - }); - - logger("getAbsoluteClientRect, client rect: ", clientRect); - - var scrollLeft = util.isRootElement(opts.scrollElem) - ? util.getScrollLeft(window) - : util.getScrollLeft(opts.scrollElem) + util.getScrollLeft(window); - var scrollTop = util.isRootElement(opts.scrollElem) - ? util.getScrollTop(window) - : util.getScrollTop(opts.scrollElem) + util.getScrollTop(window); - - logger("getAbsoluteClientRect, is scroll element window? : ", util.isRootElement(opts.scrollElem)); - logger("getAbsoluteClientRect, scrollTop: ", scrollTop); - - return clientRect.translate(scrollLeft, scrollTop); -}; - -function getNestedBoundingClientRect(node, boundaryWindow) { - var ownerIframe = util.getOwnerIframe(node); - if (ownerIframe === null || util.getOwnerWindow(ownerIframe) === boundaryWindow) { - return node.getBoundingClientRect(); - } - - var rects = [node.getBoundingClientRect()]; - var currentIframe = ownerIframe; - - while (currentIframe) { - var rect = getBoundingClientRectWithBorderOffset(currentIframe); - rects.push(rect); - - currentIframe = util.getOwnerIframe(currentIframe); - if (currentIframe && util.getOwnerWindow(currentIframe) === boundaryWindow) { - rect = getBoundingClientRectWithBorderOffset(currentIframe); - rects.push(rect); - break; - } - } - - return mergeRectOffsets(rects); -} - -function getBoundingClientRectWithBorderOffset(node) { - var dimensions = getElementDimensions(node); - - return mergeRectOffsets([ - node.getBoundingClientRect(), - { - top: dimensions.borderTop, - left: dimensions.borderLeft, - bottom: dimensions.borderBottom, - right: dimensions.borderRight, - x: dimensions.borderLeft, - y: dimensions.borderTop - } - ]); -} - -function getElementDimensions(element) { - var calculatedStyle = util.getOwnerWindow(element).getComputedStyle(element); - - return { - borderLeft: parseFloat(calculatedStyle.borderLeftWidth), - borderRight: parseFloat(calculatedStyle.borderRightWidth), - borderTop: parseFloat(calculatedStyle.borderTopWidth), - borderBottom: parseFloat(calculatedStyle.borderBottomWidth) - }; -} - -function mergeRectOffsets(rects) { - return rects.reduce(function (previousRect, rect) { - if (previousRect === null) { - return rect; - } - - var nextTop = previousRect.top + rect.top; - var nextLeft = previousRect.left + rect.left; - - return { - top: nextTop, - left: nextLeft, - width: previousRect.width, - height: previousRect.height, - bottom: nextTop + previousRect.height, - right: nextLeft + previousRect.width, - x: nextLeft, - y: nextTop - }; - }); -} diff --git a/src/browser/client-scripts/screen-shooter/errors/outside-of-viewport.ts b/src/browser/client-scripts/screen-shooter/errors/outside-of-viewport.ts new file mode 100644 index 000000000..234a4e0ff --- /dev/null +++ b/src/browser/client-scripts/screen-shooter/errors/outside-of-viewport.ts @@ -0,0 +1,16 @@ +import { BrowserSideErrorCode } from "@isomorphic"; + +export class OutsideOfViewportError extends Error { + errorCode: BrowserSideErrorCode; + debugLog?: string; + + constructor(debugLog?: string) { + super( + "Can not capture element, because it is completely outside of viewport with no intersection. " + + 'Try to set "captureElementFromTop=true" to scroll to it before capture.' + ); + this.name = "OutsideOfViewportError"; + this.errorCode = BrowserSideErrorCode.OUTSIDE_OF_VIEWPORT; + this.debugLog = debugLog; + } +} diff --git a/src/browser/client-scripts/screen-shooter/implementation.ts b/src/browser/client-scripts/screen-shooter/implementation.ts new file mode 100644 index 000000000..26f85042f --- /dev/null +++ b/src/browser/client-scripts/screen-shooter/implementation.ts @@ -0,0 +1,385 @@ +import { + BrowserSideError, + BrowserSideErrorCode, + Coord, + DisableHoverMode, + Length, + ceilCoords, + floorCoords, + fromCssToDevice, + fromCssToDeviceNumber, + fromDeviceToCssNumber, + getBottom, + getCoveringRect, + roundCoords +} from "@isomorphic"; +import { + PrepareScreenshotOptions, + PrepareScreenshotResult, + PrepareScreenshotSuccess, + PrepareFullPageScreenshotResult, + PrepareViewportScreenshotResult, + ScrollFullPageResult, + ScrollResult, + GetCaptureStateResult +} from "./types"; +import { createDebugLogger } from "../shared/logger"; +import { + scrollToCaptureAreaIfNeeded, + disableAnimations, + computeCaptureSpecs, + computeIgnoreAreas, + computeViewportSize, + computeViewportOffset, + computeSafeArea, + computeDocumentSize, + computeCanHaveCaret, + computePixelRatio, + disablePointerEvents as disablePointerEventsUnsafe, + computeElementPositionsProbe, + saveScrollPositions, + prepareFullPageScrollCleanup, + cleanupSavedScrolls, + computeScrollOffset +} from "./operations"; +import { getReadableElementDescriptor } from "./utils/descriptions"; +import { getScreenshooterNamespaceData } from "./utils/dom"; +import { getCommonScrollParent, scrollElementBy, scrollElementToOffset } from "./utils/scroll"; + +declare global { + // eslint-disable-next-line no-var + var __cleanupAnimation: undefined | (() => void); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function safeCall any>( + callback: T, + ...args: Parameters +): ReturnType | BrowserSideError { + try { + return callback(...args) as ReturnType; + } catch (e: unknown) { + if (e instanceof Error) { + return { + errorCode: BrowserSideErrorCode.JS, + message: e.stack || e.message + }; + } + return { + errorCode: BrowserSideErrorCode.JS, + message: "Unknown error: " + String(e) + }; + } +} + +export function prepareElementsScreenshot( + selectorsToCapture: string[], + opts: PrepareScreenshotOptions +): PrepareScreenshotResult { + return safeCall(prepareElementsScreenshotUnsafe, selectorsToCapture, opts); +} + +export function scrollBy( + selectorsToCapture: string[], + scrollDelta: Length<"device", "y"> | Coord<"page", "device", "y">, + selectorToScroll?: string | null, + debug?: string[] +): ScrollResult { + return safeCall((): ScrollResult => { + const logger = createDebugLogger({ debug }, "scrollAndRecomputeAreas:scroll"); + const pixelRatio = computePixelRatio().pixelRatio; + const scrollTarget = selectorToScroll ? document.querySelector(selectorToScroll) : null; + const scrollElement = scrollTarget ?? getCommonScrollParent(selectorsToCapture); + + const readableAutoScrollElementDescr = getReadableElementDescriptor(scrollElement); + const readableSelectorToScrollDescr = selectorToScroll + ? scrollTarget + ? `${selectorToScroll} (${readableAutoScrollElementDescr})` + : `${selectorToScroll} (not found, auto-detected ${readableAutoScrollElementDescr})` + : `auto-detected ${readableAutoScrollElementDescr}`; + + // Subtracting 1px to avoid a case when element boundary gets rounded up and it appears during screenshots stitching + const scrollHeightCss = (fromDeviceToCssNumber(scrollDelta as Coord<"page", "device", "y">, pixelRatio) - + 1) as Coord<"page", "css", "y">; + scrollElementBy(scrollElement, scrollHeightCss); + + return { + readableSelectorToScrollDescr, + debugLog: logger() + }; + }); +} + +export function scrollTo( + selectorsToCapture: string[], + scrollOffset: Length<"device", "y"> | Coord<"page", "device", "y">, + selectorToScroll?: string | null, + debug?: string[] +): ScrollResult { + return safeCall((): ScrollResult => { + const logger = createDebugLogger({ debug }, "scrollAndRecomputeAreas:scroll"); + const pixelRatio = computePixelRatio().pixelRatio; + const scrollTarget = selectorToScroll ? document.querySelector(selectorToScroll) : null; + const scrollElement = scrollTarget ?? getCommonScrollParent(selectorsToCapture); + + const readableAutoScrollElementDescr = getReadableElementDescriptor(scrollElement); + const readableSelectorToScrollDescr = selectorToScroll + ? scrollTarget + ? `${selectorToScroll} (${readableAutoScrollElementDescr})` + : `${selectorToScroll} (not found, auto-detected ${readableAutoScrollElementDescr})` + : `auto-detected ${readableAutoScrollElementDescr}`; + + const scrollOffsetCss = fromDeviceToCssNumber( + scrollOffset as Coord<"page", "device", "y">, + pixelRatio + ) as Coord<"page", "css", "y">; + scrollElementToOffset(scrollElement, scrollOffsetCss); + + return { + readableSelectorToScrollDescr, + debugLog: logger() + }; + }); +} + +/** Returns current state: positions of elements to capture, ignore areas, safe area, scroll offset */ +export function getCaptureState( + selectorsToCapture: string[], + selectorsToIgnore: string[], + selectorToScroll?: string | null, + debug?: string[] +): GetCaptureStateResult { + return safeCall((): GetCaptureStateResult => { + const logger = createDebugLogger({ debug }, "scrollAndRecomputeAreas:scroll"); + const pixelRatio = computePixelRatio().pixelRatio; + const scrollTarget = selectorToScroll ? document.querySelector(selectorToScroll) : null; + const scrollElement = scrollTarget ?? getCommonScrollParent(selectorsToCapture); + const readableAutoScrollElementDescr = getReadableElementDescriptor(scrollElement); + const readableSelectorToScrollDescr = selectorToScroll + ? scrollTarget + ? `${selectorToScroll} (${readableAutoScrollElementDescr})` + : `${selectorToScroll} (not found, auto-detected ${readableAutoScrollElementDescr})` + : `auto-detected ${readableAutoScrollElementDescr}`; + const ignoreAreas = computeIgnoreAreas(selectorsToIgnore).ignoreAreas; + const safeArea = computeSafeArea(selectorsToCapture, scrollElement, logger).safeArea; + const captureSpecsAfterCss = computeCaptureSpecs(selectorsToCapture, logger).captureSpecs; + const captureSpecs = captureSpecsAfterCss.map(spec => ({ + full: fromCssToDevice(roundCoords(spec.full), pixelRatio), + visible: fromCssToDevice(roundCoords(spec.visible), pixelRatio) + })); + const scrollOffset = computeScrollOffset(scrollElement); + + logger("scrollOffset:", scrollOffset); + + return { + captureSpecs, + ignoreAreas: ignoreAreas.map(area => fromCssToDevice(roundCoords(area), pixelRatio)), + safeArea: fromCssToDevice(roundCoords(safeArea), pixelRatio), + scrollOffset: fromCssToDeviceNumber(scrollOffset, pixelRatio), + readableSelectorToScrollDescr, + debugLog: logger() + }; + }); +} + +export function prepareFullPageScreenshot( + opts: { usePixelRatio?: boolean; disableAnimation?: boolean; disableHover?: DisableHoverMode } = {} +): PrepareFullPageScreenshotResult { + return safeCall((): PrepareFullPageScreenshotResult => { + prepareFullPageScrollCleanup(); + + const pixelRatio = computePixelRatio(opts.usePixelRatio).pixelRatio; + + window.scrollTo(0, 0); + + const documentSize = computeDocumentSize().documentSize; + const viewportSize = computeViewportSize().viewportSize; + const viewportOffset = computeViewportOffset().viewportOffset; + const safeArea = computeSafeArea(["body"], document.documentElement).safeArea; + + if (opts.disableAnimation) { + disableAnimations(); + } + + let pointerEventsDisabled = false; + if (opts.disableHover === DisableHoverMode.Always) { + disablePointerEventsUnsafe(); + pointerEventsDisabled = true; + } else if (opts.disableHover === DisableHoverMode.WhenScrollingNeeded) { + const needsScrolling = documentSize.height > viewportSize.height; + + if (needsScrolling) { + disablePointerEventsUnsafe(); + pointerEventsDisabled = true; + } + } + + const elementPositionsProbe = computeElementPositionsProbe().map(rect => + rect ? fromCssToDevice(roundCoords(rect), pixelRatio) : null + ); + + return { + documentSize: ceilCoords(fromCssToDevice(documentSize, pixelRatio)), + viewportSize: fromCssToDevice(viewportSize, pixelRatio), + viewportOffset: fromCssToDevice(floorCoords(viewportOffset), pixelRatio), + safeArea: fromCssToDevice(roundCoords(safeArea), pixelRatio), + elementPositionsProbe, + pixelRatio, + pointerEventsDisabled + }; + }); +} + +export function scrollFullPage( + scrollHeight: Length<"device", "y"> | Coord<"page", "device", "y">, + opts: { usePixelRatio?: boolean } = {} +): ScrollFullPageResult { + return safeCall((): ScrollFullPageResult => { + const pixelRatio = computePixelRatio(opts.usePixelRatio).pixelRatio; + const scrollHeightCss = (fromDeviceToCssNumber(scrollHeight as Coord<"page", "device", "y">, pixelRatio) - + 1) as Coord<"page", "css", "y">; + + scrollElementBy(document.documentElement, scrollHeightCss); + + const viewportOffset = computeViewportOffset().viewportOffset; + const elementPositionsProbe = computeElementPositionsProbe().map(rect => + rect ? fromCssToDevice(roundCoords(rect), pixelRatio) : null + ); + + return { + viewportOffset: fromCssToDevice(floorCoords(viewportOffset), pixelRatio), + elementPositionsProbe + }; + }); +} + +export function prepareViewportScreenshot( + opts: { usePixelRatio?: boolean; disableAnimation?: boolean; disableHover?: DisableHoverMode } = {} +): PrepareViewportScreenshotResult { + return safeCall((): PrepareViewportScreenshotResult => { + const pixelRatio = computePixelRatio(opts.usePixelRatio).pixelRatio; + const viewportSize = computeViewportSize().viewportSize; + const viewportOffset = computeViewportOffset().viewportOffset; + const documentSize = computeDocumentSize().documentSize; + const canHaveCaret = computeCanHaveCaret().canHaveCaret; + + if (opts.disableAnimation) { + disableAnimations(); + } + + let pointerEventsDisabled = false; + if (opts.disableHover === DisableHoverMode.Always) { + disablePointerEventsUnsafe(); + pointerEventsDisabled = true; + } + + return { + viewportSize: fromCssToDevice(viewportSize, pixelRatio), + viewportOffset: fromCssToDevice(floorCoords(viewportOffset), pixelRatio), + documentSize: ceilCoords(fromCssToDevice(documentSize, pixelRatio)), + canHaveCaret, + pixelRatio, + pointerEventsDisabled + }; + }); +} + +export function disableFrameAnimations(): void | BrowserSideError { + return safeCall(disableAnimations); +} + +export function cleanupFrameAnimations(): void { + if (window.__cleanupAnimation) { + window.__cleanupAnimation(); + } +} + +export function disablePointerEvents(): void | BrowserSideError { + return safeCall(disablePointerEventsUnsafe); +} + +export function cleanupPointerEvents(): void { + const screenshooterNamespaceData = getScreenshooterNamespaceData(); + if (screenshooterNamespaceData.cleanupPointerEventsCb) { + screenshooterNamespaceData.cleanupPointerEventsCb(); + } +} + +export function cleanupScrolls(): void { + cleanupSavedScrolls(); +} + +function prepareElementsScreenshotUnsafe( + selectorsToCapture: string[], + opts: PrepareScreenshotOptions +): PrepareScreenshotResult { + const logger = createDebugLogger(opts, "prepareScreenshot:areas-computation"); + + saveScrollPositions(selectorsToCapture, opts.selectorToScroll); + + const { readableSelectorToScrollDescr } = scrollToCaptureAreaIfNeeded( + selectorsToCapture, + opts.captureElementFromTop, + opts.allowViewportOverflow, + opts.selectorToScroll, + logger + ); + + if (opts.disableAnimation) { + disableAnimations(); + } + + const pixelRatio = computePixelRatio(opts.usePixelRatio).pixelRatio; + const scrollTarget = opts.selectorToScroll ? document.querySelector(opts.selectorToScroll) : null; + const scrollElement = scrollTarget ?? getCommonScrollParent(selectorsToCapture); + + const ignoreAreas = computeIgnoreAreas(opts.ignoreSelectors).ignoreAreas; + const captureSpecs = computeCaptureSpecs(selectorsToCapture, logger).captureSpecs; + const viewportSize = computeViewportSize().viewportSize; + const viewportOffset = computeViewportOffset().viewportOffset; + const safeArea = computeSafeArea(selectorsToCapture, scrollElement, logger).safeArea; + const scrollOffset = computeScrollOffset(scrollElement); + + const documentSize = computeDocumentSize().documentSize; + const canHaveCaret = computeCanHaveCaret().canHaveCaret; + + let pointerEventsDisabled = false; + if (opts.disableHover === DisableHoverMode.Always) { + disablePointerEventsUnsafe(); + pointerEventsDisabled = true; + } else if (opts.disableHover === DisableHoverMode.WhenScrollingNeeded && opts.compositeImage) { + const captureArea = getCoveringRect(captureSpecs.map(s => s.full)); + const needsScrolling = getBottom(captureArea) > getBottom(safeArea); + + if (needsScrolling) { + logger( + "adding stylesheet with pointer-events: none on all elements (composite capture needs scrolling). captureArea:", + captureArea, + "safeArea:", + safeArea + ); + disablePointerEventsUnsafe(); + pointerEventsDisabled = true; + } + } + + logger("scrollOffset:", scrollOffset); + + return { + ignoreAreas: ignoreAreas.map(area => fromCssToDevice(roundCoords(area), pixelRatio)), + captureSpecs: captureSpecs.map(s => ({ + full: fromCssToDevice(roundCoords(s.full), pixelRatio), + visible: fromCssToDevice(roundCoords(s.visible), pixelRatio) + })), + viewportSize: fromCssToDevice(viewportSize, pixelRatio), + viewportOffset: fromCssToDevice(floorCoords(viewportOffset), pixelRatio), + safeArea: fromCssToDevice(roundCoords(safeArea), pixelRatio), + documentSize: ceilCoords(fromCssToDevice(documentSize, pixelRatio)), + canHaveCaret, + pixelRatio: pixelRatio, + pointerEventsDisabled: pointerEventsDisabled, + debugLog: logger(), + readableSelectorToScrollDescr, + scrollOffset: fromCssToDeviceNumber(scrollOffset, pixelRatio) + } satisfies PrepareScreenshotSuccess; +} diff --git a/src/browser/client-scripts/screen-shooter/inject.ts b/src/browser/client-scripts/screen-shooter/inject.ts new file mode 100644 index 000000000..bea736c29 --- /dev/null +++ b/src/browser/client-scripts/screen-shooter/inject.ts @@ -0,0 +1,15 @@ +import * as implementation from "./implementation"; + +declare global { + // eslint-disable-next-line no-var + var __geminiCore: Record | undefined; + // eslint-disable-next-line no-var + var __geminiNamespace: string; +} + +const globalObj = typeof window === "undefined" ? globalThis : window; + +if (!globalObj.__geminiCore) { + globalObj.__geminiCore = {}; +} +globalObj.__geminiCore[__geminiNamespace] = implementation; diff --git a/src/browser/client-scripts/screen-shooter/operations.ts b/src/browser/client-scripts/screen-shooter/operations.ts new file mode 100644 index 000000000..b24b919a5 --- /dev/null +++ b/src/browser/client-scripts/screen-shooter/operations.ts @@ -0,0 +1,740 @@ +import { + Coord, + Length, + Rect, + fromBcrToRect, + getBottom, + getCoveringRect, + getHeight, + getIntersection +} from "@isomorphic"; +import { OutsideOfViewportError } from "./errors/outside-of-viewport"; +import { + ComputeCanHaveCaretResult, + ComputeDocumentSizeResult, + ComputeCaptureSpecsResult, + ComputeIgnoreAreasResult, + ComputePixelRatioResult, + ComputeSafeAreaResult, + ComputeViewportOffsetResult, + ComputeViewportSizeResult, + SavedScrollPosition as ElementScrollPosition, + ScrollToCaptureSpecResult +} from "./types"; +import { getReadableElementDescriptor } from "./utils/descriptions"; +import { + findContainingBlock, + findFixedPositionedParent, + forEachRoot, + getMainDocumentElem, + getScreenshooterNamespaceData +} from "./utils/dom"; +import { + domRectToViewportCss, + getBoundingClientContentRect, + getElementCaptureRect, + getExtRect, + getPseudoElementCaptureRect, + getVerticalRadiusInsets +} from "./utils/element-rect"; +import { getClipRect } from "./utils/clip-rect"; +import { + getCommonScrollParent, + getScrollParent, + getScrollParentsChain, + isRootLikeElement, + performScrollFixForSafariIfNeeded, + scrollElementBy +} from "./utils/scroll"; +import { createDefaultTrustedTypesPolicy } from "./utils/trusted-types"; +import { buildZChain, isChainBehind } from "./utils/z-index"; +import { parseCaptureSelector, PseudoElementSelector } from "./utils/pseudo-element-rect"; +import { isSafariMobile } from "./utils/user-agent"; + +export function computeScrollOffset(element: Element): Coord<"page", "css", "y"> { + return (isRootLikeElement(element) ? window.scrollY : element.scrollTop) as Coord<"page", "css", "y">; +} + +export function computeViewportSize(): ComputeViewportSizeResult { + return { + viewportSize: { + width: window.innerWidth as Length<"css", "x">, + height: window.innerHeight as Length<"css", "y"> + } + }; +} + +export function computeViewportOffset(): ComputeViewportOffsetResult { + return { + viewportOffset: { + left: window.scrollX as Coord<"page", "css", "x">, + top: window.scrollY as Coord<"page", "css", "y"> + } + }; +} + +const ELEMENT_POSITIONS_PROBE_GRID_SIZE = 5; + +function getProbeAxisCoordinates(length: number, gridSize: number): number[] { + const safeLength = Math.max(1, Math.floor(length)); + if (gridSize <= 1) { + return [0]; + } + + const maxCoord = safeLength - 1; + const step = safeLength / (gridSize - 1); + const coordinates: number[] = []; + + for (let i = 0; i < gridSize; i++) { + coordinates.push(Math.min(maxCoord, Math.round(i * step))); + } + + return coordinates; +} + +export function computeElementPositionsProbe( + gridSize = ELEMENT_POSITIONS_PROBE_GRID_SIZE +): Array | null> { + const viewportSize = computeViewportSize().viewportSize; + const xCoordinates = getProbeAxisCoordinates(viewportSize.width as number, gridSize); + const yCoordinates = getProbeAxisCoordinates(viewportSize.height as number, gridSize); + const probe: Array | null> = []; + + for (let yIndex = 0; yIndex < yCoordinates.length; yIndex++) { + for (let xIndex = 0; xIndex < xCoordinates.length; xIndex++) { + const x = xCoordinates[xIndex]; + const y = yCoordinates[yIndex]; + const bcr = document.elementFromPoint(x, y)?.getBoundingClientRect() ?? null; + probe.push(bcr ? fromBcrToRect(bcr) : null); + } + } + + return probe; +} + +export function computeCaptureSpecs( + selectors: string[], + logger?: (...args: unknown[]) => unknown +): ComputeCaptureSpecsResult { + if (selectors.length === 0) { + throw new Error("No selectors to compute capture area"); + } + logger?.("========== =========="); + logger?.("selectors:", selectors); + const startTime = performance.now(); + + const elements: Array<{ element: Element; pseudoElement: PseudoElementSelector | null }> = []; + for (let i = 0; i < selectors.length; i++) { + const parsedSelector = parseCaptureSelector(selectors[i]); + const element = document.querySelector(parsedSelector.elementSelector); + if (element) { + elements.push({ element, pseudoElement: parsedSelector.pseudoElement }); + } + } + + const captureSpecs = elements + .map(function ({ element, pseudoElement }) { + const full = pseudoElement + ? getPseudoElementCaptureRect(element, pseudoElement) + : getElementCaptureRect(element, logger); + if (!full) return null; + const clip = getClipRect(element, logger); + const visible = getIntersection(full, clip) ?? { + top: full.top, + left: full.left, + width: 0 as typeof full.width, + height: 0 as typeof full.height + }; + return { full, visible }; + }) + .filter(function (r): r is NonNullable { + return r !== null; + }); + + logger?.("captureSpecs:", captureSpecs); + + logger?.("computeCaptureSpecs time taken:", (performance.now() - startTime).toFixed(1) + "ms"); + logger?.("========== =========="); + + return { captureSpecs }; +} + +export function computeIgnoreAreas(selectors: string[] = []): ComputeIgnoreAreasResult { + const ignoreAreas: Rect<"viewport", "css">[] = []; + + for (let s = 0; s < selectors.length; s++) { + const parsedSelector = parseCaptureSelector(selectors[s]); + const nodeList = document.querySelectorAll(parsedSelector.elementSelector); + for (let i = 0; i < nodeList.length; i++) { + const rect = parsedSelector.pseudoElement + ? getPseudoElementCaptureRect(nodeList[i], parsedSelector.pseudoElement) + : getElementCaptureRect(nodeList[i]); + if (rect !== null) { + ignoreAreas.push(rect); + } + } + } + + return { ignoreAreas }; +} + +export function computeSafeArea( + selectorsToCapture: string[], + scrollElement?: Element, + logger?: (...args: unknown[]) => unknown +): ComputeSafeAreaResult { + logger?.("========== =========="); + const startTime = performance.now(); + + const viewportSize = computeViewportSize().viewportSize; + const viewportRect: Rect<"viewport", "css"> = { + left: 0 as Coord<"viewport", "css", "x">, + top: 0 as Coord<"viewport", "css", "y">, + width: viewportSize.width as Length<"css", "x">, + height: viewportSize.height as Length<"css", "y"> + }; + const captureElements = selectorsToCapture + .map(s => document.querySelector(parseCaptureSelector(s).elementSelector)) + .filter((e): e is NonNullable => e !== null); + + if (captureElements.length === 0) { + return { + safeArea: { top: viewportRect.top, height: viewportRect.height } + }; + } + + const captureArea = getCoveringRect(computeCaptureSpecs(selectorsToCapture).captureSpecs.map(s => s.full)); + const scrollEl = scrollElement ?? document.documentElement; + + // 1. Base safe area equals the visible rectangle of the scroll container + let safeArea: Rect<"viewport", "css">; + if (scrollEl === document.documentElement) { + logger?.("setting base safe area to viewport rect"); + safeArea = { ...viewportRect }; + } else { + const contentRect = getBoundingClientContentRect(scrollEl); + logger?.( + "setting base safe area to visible part of scroll container:", + getReadableElementDescriptor(scrollEl), + "contentRect:", + contentRect + ); + safeArea = getIntersection(contentRect, viewportRect) ?? { ...viewportRect }; + + const { top: topRadiusInset, bottom: bottomRadiusInset } = getVerticalRadiusInsets(scrollEl); + if (topRadiusInset > 0 || bottomRadiusInset > 0) { + const safeAreaHeight = safeArea.height as number; + const topInset = Math.min(topRadiusInset, safeAreaHeight); + const bottomInset = Math.min(bottomRadiusInset, safeAreaHeight - topInset); + + logger?.("applying radius insets to safe area:", { topInset, bottomInset }); + safeArea = { + ...safeArea, + top: ((safeArea.top as number) + topInset) as Coord<"viewport", "css", "y">, + height: (safeAreaHeight - topInset - bottomInset) as Length<"css", "y"> + }; + } + } + + const originalSafeArea = { ...safeArea }; + + // 2. Build z-index chains for all capture elements + // One z-chain is a list of objects: { stacking context, z-index } -> { stacking context, z-index } -> ... + // It is used to determine which element is on top of the other + const targetChains = captureElements.map(el => buildZChain(el)); + + const captureLeft = captureArea.left as number; + const captureRight = captureLeft + (captureArea.width as number); + + // 3. Detect interfering elements + const interferences: { element: Element; rect: Rect<"viewport", "css"> }[] = []; + const allElements = document.documentElement.querySelectorAll("*"); + + for (let idx = 0; idx < allElements.length; idx++) { + const el = allElements[idx]; + + // Skip elements that contain capture elements + if (captureElements.some(capEl => el !== capEl && el.contains(capEl))) continue; + + const computedStyle = getComputedStyle(el); + const position = computedStyle.position; + const bcr = el.getBoundingClientRect(); + + // Skip invisible elements + if ( + bcr.width < 1 || + bcr.height < 1 || + (bcr.width === 1 && bcr.height === 1) || + computedStyle.visibility === "hidden" || + computedStyle.display === "none" || + parseFloat(computedStyle.opacity) < 0.0001 + ) + continue; + // Skip elements that don't horizontally intersect with capture area + if (bcr.right <= captureLeft || bcr.left >= captureRight) continue; + // Skip elements that are outside of viewport + if (getIntersection(fromBcrToRect(bcr), viewportRect) === null) continue; + + let likelyInterferes = false; + let adjustedRect: Rect<"viewport", "css"> = domRectToViewportCss(bcr); + + const fixedPositionedParent = findFixedPositionedParent(el); + + if ( + position === "fixed" || + (fixedPositionedParent && !captureElements.some(capEl => fixedPositionedParent.contains(capEl))) + ) { + likelyInterferes = true; + } else if (position === "absolute") { + // Skip absolutely positioned elements that are inside capture elements + if (captureElements.some(capEl => capEl.contains(el))) continue; + + // Absolute elements interfere only if positioned relative to ancestor outside scroll container + const containingBlock = findContainingBlock(el); + // scrollElem may be window, in which case it doesn't have a contains method + if (scrollEl !== document.documentElement && !scrollEl.contains(containingBlock)) { + likelyInterferes = true; + } + } else if (position === "sticky") { + // Sticky elements interfere based on their top/bottom values + let topValue = parseFloat(computedStyle.top); + const bottomValue = parseFloat(computedStyle.bottom); + + const scrollParent = getScrollParent(el) ?? document.documentElement; + logger?.("scrollParent:", getReadableElementDescriptor(scrollParent)); + const scrollParentBcr = scrollParent.getBoundingClientRect(); + topValue += isRootLikeElement(scrollParent) ? 0 : scrollParentBcr.top; + + if (!isNaN(topValue)) { + adjustedRect = { + left: bcr.left as Coord<"viewport", "css", "x">, + top: topValue as Coord<"viewport", "css", "y">, + width: bcr.width as Length<"css", "x">, + height: bcr.height as Length<"css", "y"> + }; + likelyInterferes = true; + } else if (!isNaN(bottomValue)) { + const isRoot = scrollEl === document.documentElement; + const viewportBottom = isRoot + ? (viewportRect.height as number) + : (safeArea.top as number) + (safeArea.height as number); + adjustedRect = { + left: bcr.left as Coord<"viewport", "css", "x">, + top: (viewportBottom - bottomValue - bcr.height) as Coord<"viewport", "css", "y">, + width: bcr.width as Length<"css", "x">, + height: bcr.height as Length<"css", "y"> + }; + likelyInterferes = true; + } + } + + if (!likelyInterferes) continue; + + const candChain = buildZChain(el); + const behindAll = targetChains.every(tChain => isChainBehind(candChain, tChain)); + + if (!behindAll) { + const extRect = getExtRect(computedStyle, adjustedRect); + const extLeft = extRect.left as number; + const extRight = extLeft + (extRect.width as number); + + if (extRight <= captureLeft || extLeft >= captureRight) continue; + + interferences.push({ element: el, rect: extRect }); + } + } + + let safeTop = safeArea.top as Coord<"viewport", "css", "y">; + let safeHeight = safeArea.height as Length<"css", "y">; + const origHeight = originalSafeArea.height as number; + + // 4. Shrink safe area according to interfering elements + for (const interference of interferences) { + logger?.("processing interference:", { + element: getReadableElementDescriptor(interference.element), + rect: interference.rect + }); + + const br = interference.rect; + const safeBottom = getBottom({ top: safeTop, height: safeHeight }); + const brBottom = getBottom(br); + const shrinkTop = brBottom > safeTop ? brBottom - safeTop : null; + const shrinkBottom = safeBottom > br.top ? safeBottom - br.top : null; + + let resultingTop = safeTop; + let resultingHeight = safeHeight; + + if (shrinkTop && shrinkBottom && shrinkTop < shrinkBottom) { + resultingTop = brBottom; + resultingHeight = getHeight(safeBottom, resultingTop); + logger?.("decided to shrink top"); + } else if (shrinkBottom) { + resultingHeight = getHeight(safeTop, br.top); + logger?.("decided to shrink bottom"); + } + + if (resultingHeight < origHeight / 2) { + logger?.("decided to skip, because shrinking is too large"); + continue; + } + + logger?.("resulting safe area top:", resultingTop, "resulting safe area height:", resultingHeight); + + safeTop = resultingTop; + safeHeight = resultingHeight; + } + + // 5. Ensure we didn't shrink more than 50% of original height + if (safeHeight < origHeight / 2) { + safeTop = originalSafeArea.top; + safeHeight = originalSafeArea.height; + } + + // Safari on iOS 26 has a large blur at the bottom that interferes with scrolling, so + // if safe area ends too low, we shrink it by 40px which is enough to avoid the blur. + if (isSafariMobile() && viewportSize.height - (safeTop + safeHeight) < 40) { + safeHeight = (safeHeight - 40) as Length<"css", "y">; + } + + const finalSafeArea = { + top: safeTop, + height: safeHeight + }; + + logger?.("final safe area:", finalSafeArea); + logger?.("computeSafeArea time taken:", (performance.now() - startTime).toFixed(1) + "ms"); + logger?.("========== =========="); + + return { + safeArea: finalSafeArea + }; +} + +export function computeDocumentSize(): ComputeDocumentSizeResult { + const mainDocumentElem = getMainDocumentElem(); + return { + documentSize: { + width: mainDocumentElem.scrollWidth as Length<"css", "x">, + height: mainDocumentElem.scrollHeight as Length<"css", "y"> + } + }; +} + +export function computeCanHaveCaret(): ComputeCanHaveCaretResult { + const el = document.activeElement; + const canHaveCaret = el instanceof HTMLElement && (/^(input|textarea)$/i.test(el.tagName) || el.isContentEditable); + + return { canHaveCaret }; +} + +export function computePixelRatio(usePixelRatio: boolean = true): ComputePixelRatioResult { + if (usePixelRatio === false) { + return { pixelRatio: 1 }; + } + + if (window.devicePixelRatio) { + return { pixelRatio: window.devicePixelRatio }; + } + + // for ie6-ie10 (https://developer.mozilla.org/ru/docs/Web/API/Window/devicePixelRatio) + // @ts-expect-error - IE hack + return { pixelRatio: window.screen.deviceXDPI / window.screen.logicalXDPI || 1 }; +} + +export function scrollToCaptureAreaIfNeeded( + selectorsToCapture: string[], + captureElementFromTop?: boolean, + allowViewportOverflow?: boolean, + selectorToScroll?: string, + logger?: (...args: unknown[]) => unknown +): ScrollToCaptureSpecResult { + const viewportSize = computeViewportSize().viewportSize; + const viewport = { + top: 0 as Coord<"viewport", "css", "y">, + left: 0 as Coord<"viewport", "css", "x">, + ...viewportSize + }; + + const captureSpecsResult = computeCaptureSpecs(selectorsToCapture); + if (!captureSpecsResult) return {}; + + const captureArea = getCoveringRect(captureSpecsResult.captureSpecs.map(s => s.full)); + // const captureElements = selectorsToCapture.flatMap(s => Array.from(document.querySelectorAll(s))); + const safeArea = computeSafeArea(selectorsToCapture).safeArea; + + const captureAndSafeAreasIntersection = getIntersection(captureArea, safeArea); + const captureAndViewportIntersection = getIntersection(captureArea, viewport); + const isIntersectionWithSafeAreaTooSmall = + !captureAndSafeAreasIntersection || captureAndSafeAreasIntersection.height < captureArea.height / 2; + const isCaptureAreaStartVisible = captureArea.top >= safeArea.top; + logger?.("scrollToCaptureAreaIfNeeded: intersection check", { + captureArea, + safeArea, + viewport, + hasViewportIntersection: Boolean(captureAndViewportIntersection), + hasSafeAreaIntersection: Boolean(captureAndSafeAreasIntersection), + isIntersectionWithSafeAreaTooSmall, + captureElementFromTop: Boolean(captureElementFromTop) + }); + + if (!captureElementFromTop && !captureAndViewportIntersection) { + logger?.( + "scrollToCaptureAreaIfNeeded: throwing OutsideOfViewportError because captureElementFromTop is disabled and target is outside viewport" + ); + throw new OutsideOfViewportError(); + } + + if ((!captureElementFromTop || !isIntersectionWithSafeAreaTooSmall) && isCaptureAreaStartVisible) { + logger?.("scrollToCaptureAreaIfNeeded: skipping scroll", { + reason: !captureElementFromTop + ? "captureElementFromTop is disabled" + : "target already has enough safe area visibility" + }); + return {}; + } + + if (!captureElementFromTop && allowViewportOverflow) { + logger?.( + "scrollToCaptureAreaIfNeeded: skipping scroll because allowViewportOverflow is true and captureElementFromTop is false" + ); + return {}; + } + + const scrollTarget = selectorToScroll ? document.querySelector(selectorToScroll) : null; + const selectorsForScrollParentSearch = selectorsToCapture.map( + selector => parseCaptureSelector(selector).elementSelector + ); + const initialScrollElem = scrollTarget ?? getCommonScrollParent(selectorsForScrollParentSearch); + const readableSelectorToScrollDescr = selectorToScroll ?? getReadableElementDescriptor(initialScrollElem); + logger?.("scrollToCaptureAreaIfNeeded: scrolling is required", { + scrollElement: readableSelectorToScrollDescr, + requestedSelectorToScroll: selectorToScroll ?? null, + selectorMatched: Boolean(scrollTarget) + }); + + const scrollChain = [...getScrollParentsChain(initialScrollElem)]; + if (scrollChain[scrollChain.length - 1] !== initialScrollElem) { + scrollChain.push(initialScrollElem); + } + + for (let i = 1; i < scrollChain.length; i++) { + const currentSafeArea = computeSafeArea(selectorsToCapture, scrollChain[i - 1]).safeArea; + const childTop = scrollChain[i].getBoundingClientRect().top; + const scrollDelta = childTop - currentSafeArea.top; + logger?.("scrollToCaptureAreaIfNeeded: scrolling chain element", { + scrollElement: getReadableElementDescriptor(scrollChain[i - 1]), + childElement: getReadableElementDescriptor(scrollChain[i]), + scrollDelta + }); + scrollElementBy(scrollChain[i - 1], scrollDelta as Coord<"page", "css", "y">, logger); + } + + const finalCaptureArea = getCoveringRect(computeCaptureSpecs(selectorsToCapture).captureSpecs.map(s => s.full)); + if (!finalCaptureArea) return {}; + + const finalSafeArea = computeSafeArea(selectorsToCapture, initialScrollElem).safeArea; + const finalScrollDelta = finalCaptureArea.top - finalSafeArea.top; + logger?.("scrollToCaptureAreaIfNeeded: final alignment scroll", { + scrollElement: readableSelectorToScrollDescr, + finalScrollDelta + }); + scrollElementBy(initialScrollElem, finalScrollDelta as Coord<"page", "css", "y">, logger); + + return { + readableSelectorToScrollDescr + }; +} + +function saveElementScrollPosition(element: Element): void { + const namespaceData = getScreenshooterNamespaceData(); + if (!namespaceData.savedScrollPositions) { + namespaceData.savedScrollPositions = []; + } + + if (namespaceData.savedScrollPositions.some(saved => saved.element === element)) { + return; + } + + const savedPosition: ElementScrollPosition = isRootLikeElement(element) + ? { + element, + left: window.scrollX, + top: window.scrollY + } + : { + element, + left: (element as HTMLElement).scrollLeft, + top: (element as HTMLElement).scrollTop + }; + + namespaceData.savedScrollPositions.push(savedPosition); +} + +export function saveScrollPositions(selectorsToCapture: string[], selectorToScroll?: string): void { + getScreenshooterNamespaceData().savedScrollPositions = []; + + const scrollTarget = selectorToScroll ? document.querySelector(selectorToScroll) : null; + const selectorsForScrollParentSearch = selectorsToCapture.map( + selector => parseCaptureSelector(selector).elementSelector + ); + const initialScrollElement = scrollTarget ?? getCommonScrollParent(selectorsForScrollParentSearch); + const scrollChain = [...getScrollParentsChain(initialScrollElement)]; + + if (scrollChain[scrollChain.length - 1] !== initialScrollElement) { + scrollChain.push(initialScrollElement); + } + + for (const scrollElement of scrollChain) { + saveElementScrollPosition(scrollElement); + } +} + +export function prepareFullPageScrollCleanup(): void { + getScreenshooterNamespaceData().savedScrollPositions = []; + saveElementScrollPosition(document.documentElement); +} + +function restoreScrollPosition(savedPosition: ElementScrollPosition): void { + if (isRootLikeElement(savedPosition.element)) { + performScrollFixForSafariIfNeeded(savedPosition.top); + window.scrollTo(savedPosition.left, savedPosition.top); + return; + } + + const scrollElement = savedPosition.element as Element & { + scrollTo?: (left: number, top: number) => void; + scrollLeft?: number; + scrollTop?: number; + }; + + if (typeof scrollElement.scrollTo === "function") { + scrollElement.scrollTo(savedPosition.left, savedPosition.top); + return; + } + + if (typeof scrollElement.scrollLeft === "number") { + scrollElement.scrollLeft = savedPosition.left; + } + if (typeof scrollElement.scrollTop === "number") { + scrollElement.scrollTop = savedPosition.top; + } +} + +export function cleanupSavedScrolls(): void { + try { + const namespaceData = getScreenshooterNamespaceData(); + const savedScrollPositions = namespaceData.savedScrollPositions ?? []; + namespaceData.savedScrollPositions = []; + + for (const savedScrollPosition of savedScrollPositions) { + try { + restoreScrollPosition(savedScrollPosition); + } catch (error) { + void error; + } + } + } catch (error) { + void error; + } +} + +export function disableAnimations(): void { + const everyElementSelector = "*:not(#testplane-q.testplane-w.testplane-e.testplane-r.testplane-t.testplane-y)"; + const everythingSelector = ["", "::before", "::after"] + .map(function (pseudo) { + return everyElementSelector + pseudo; + }) + .join(", "); + + const styleElements: HTMLStyleElement[] = []; + + function appendDisableAnimationStyleElement(root: Element | ShadowRoot): void { + const styleElement = document.createElement("style"); + styleElement.innerHTML = + everythingSelector + + [ + "{", + " animation-delay: 0ms !important;", + " animation-duration: 0ms !important;", + " animation-timing-function: step-start !important;", + " transition-timing-function: step-start !important;", + " scroll-behavior: auto !important;", + " transition: none !important;", + "}" + ].join("\n"); + + root.appendChild(styleElement); + styleElements.push(styleElement); + } + + forEachRoot(function (root) { + try { + appendDisableAnimationStyleElement(root); + } catch (err: unknown) { + if ( + err && + (err as Error).message && + (err as Error).message.indexOf("This document requires 'TrustedHTML' assignment") !== -1 + ) { + createDefaultTrustedTypesPolicy(); + + appendDisableAnimationStyleElement(root); + } else { + throw err; + } + } + }); + + window.__cleanupAnimation = function (): void { + for (let i = 0; i < styleElements.length; i++) { + // IE11 doesn't have remove() on node + styleElements[i].parentNode!.removeChild(styleElements[i]); + } + + delete window.__cleanupAnimation; + }; +} + +export function disablePointerEvents(): void { + const everyElementSelector = "*:not(#testplane-q.testplane-w.testplane-e.testplane-r.testplane-t.testplane-y)"; + const everythingSelector = ["", "::before", "::after"] + .map(function (pseudo) { + return everyElementSelector + pseudo; + }) + .join(", "); + + const styleElements: HTMLStyleElement[] = []; + + function appendDisablePointerEventsStyleElement(root: Element | ShadowRoot): void { + const styleElement = document.createElement("style"); + styleElement.innerHTML = everythingSelector + ["{", " pointer-events: none !important;", "}"].join("\n"); + + root.appendChild(styleElement); + styleElements.push(styleElement); + } + + forEachRoot(function (root) { + try { + appendDisablePointerEventsStyleElement(root); + } catch (err) { + if ( + err && + (err as Error).message && + (err as Error).message.indexOf("This document requires 'TrustedHTML' assignment") !== -1 + ) { + createDefaultTrustedTypesPolicy(); + + appendDisablePointerEventsStyleElement(root); + } else { + throw err; + } + } + }); + + const namespaceData = getScreenshooterNamespaceData(); + namespaceData.cleanupPointerEventsCb = function (): void { + for (let i = 0; i < styleElements.length; i++) { + styleElements[i].parentNode!.removeChild(styleElements[i]); + } + }; +} diff --git a/src/browser/client-scripts/screen-shooter/tsconfig.compat.json b/src/browser/client-scripts/screen-shooter/tsconfig.compat.json new file mode 100644 index 000000000..02d24e982 --- /dev/null +++ b/src/browser/client-scripts/screen-shooter/tsconfig.compat.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.compat.common.json", + "include": [".", "../../isomorphic", "../shared"], + "exclude": ["./tsc-out", "../shared/lib.native.ts"], + "compilerOptions": { + "outDir": "./tsc-out" + } +} diff --git a/src/browser/client-scripts/screen-shooter/tsconfig.json b/src/browser/client-scripts/screen-shooter/tsconfig.json new file mode 100644 index 000000000..27be579ad --- /dev/null +++ b/src/browser/client-scripts/screen-shooter/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.native.common.json", + "include": [".", "../shared", "../../isomorphic"], + "exclude": ["./tsc-out"], + "compilerOptions": { + "outDir": "./tsc-out" + } +} diff --git a/src/browser/client-scripts/screen-shooter/types.ts b/src/browser/client-scripts/screen-shooter/types.ts new file mode 100644 index 000000000..9cc9c74fa --- /dev/null +++ b/src/browser/client-scripts/screen-shooter/types.ts @@ -0,0 +1,156 @@ +import { BrowserSideError, Coord, DisableHoverMode, Point, Rect, Size, Space, Unit, YBand } from "@isomorphic"; + +export interface CaptureSpec { + /** Full element rect, unconstrained by ancestor overflow clipping */ + full: Rect; + /** Visible portion: full rect intersected with all ancestor overflow clip boundaries */ + visible: Rect; +} + +export interface CaptureState { + scrollOffset: Coord<"page", "device", "y">; + captureSpecs: CaptureSpec<"viewport", "device">[]; + ignoreAreas: Rect<"viewport", "device">[]; + safeArea: YBand<"viewport", "device">; +} + +export interface SavedScrollPosition { + element: Element; + left: number; + top: number; +} + +export interface ScreenshooterNamespaceData { + cleanupPointerEventsCb?: () => void; + savedScrollPositions?: SavedScrollPosition[]; +} + +export interface PrepareScreenshotOptions { + ignoreSelectors?: string[]; + allowViewportOverflow?: boolean; + captureElementFromTop?: boolean; + selectorToScroll?: string; + disableAnimation?: boolean; + disableHover?: DisableHoverMode; + compositeImage?: boolean; + debug?: string[]; + usePixelRatio?: boolean; +} + +export interface PrepareScreenshotSuccess { + // Area free of sticky elements, inside which it's safe to capture element that's interesting to us + // Measured relative to browser viewport (not the whole page!) + safeArea: YBand<"viewport", "device">; + // Current scroll position of the scroll element, if window is being used, this will always be 0 + // scrollElementOffset: Point; + // Boundaries of elements that we should ignore when comparing screenshots (these areas will be painted in black) + ignoreAreas: Rect<"viewport", "device">[]; + // Element capture areas with full (unconstrained) and visible (clipped by ancestor overflow) rects + captureSpecs: CaptureSpec<"viewport", "device">[]; + // Viewport size + viewportSize: Size<"device">; + // Viewport scroll offsets, window.scrollX / window.scrollY respectively + viewportOffset: Point<"page", "device">; + // Total height of the document, may be larger than viewport + documentSize: Size<"device">; + // Whether the document.activeElement is likely editable (e.g. input, textarea, etc.) + canHaveCaret: boolean; + // Pixel ratio: window.devicePixelRatio or 1 if usePixelRatio was set to false + pixelRatio: number; + // Whether pointer-events were disabled during prepareScreenshot. Useful for "when-scrolling-needed", because in that case it's determined on browser side + pointerEventsDisabled?: boolean; + // Debug log, returned only if DEBUG env includes scope "testplane:screenshots:browser:prepareScreenshot" + debugLog?: string; + // Description of the element that is being scrolled, used for human-readable errors + readableSelectorToScrollDescr?: string; + // Current vertical scroll offset of the resolved scroll element (or window/document root) + scrollOffset: Coord<"page", "device", "y">; +} + +export type PrepareScreenshotResult = PrepareScreenshotSuccess | BrowserSideError; + +export interface ScrollToCaptureSpecResult { + readableSelectorToScrollDescr?: string; +} + +export interface ComputeCaptureSpecsResult { + captureSpecs: CaptureSpec<"viewport", "css">[]; +} + +export interface ComputeCaptureSpecResult { + captureArea: Rect<"viewport", "css">; +} + +export interface ComputeIgnoreAreasResult { + ignoreAreas: Rect<"viewport", "css">[]; +} + +export interface ComputeSafeAreaResult { + safeArea: YBand<"viewport", "css">; +} + +export interface ComputeDocumentSizeResult { + documentSize: Size<"css">; +} + +export interface ComputeCanHaveCaretResult { + canHaveCaret: boolean; +} + +export interface ComputePixelRatioResult { + pixelRatio: number; +} + +export interface ComputeViewportSizeResult { + viewportSize: Size<"css">; +} + +export interface ComputeViewportOffsetResult { + viewportOffset: Point<"page", "css">; +} + +export type ElementPositionsProbe = Array | null>; + +export interface PrepareFullPageScreenshotSuccess { + documentSize: Size<"device">; + viewportSize: Size<"device">; + viewportOffset: Point<"page", "device">; + safeArea: YBand<"viewport", "device">; + elementPositionsProbe: ElementPositionsProbe<"device">; + pixelRatio: number; + pointerEventsDisabled?: boolean; +} + +export type PrepareFullPageScreenshotResult = PrepareFullPageScreenshotSuccess | BrowserSideError; + +export interface ScrollFullPageSuccess { + viewportOffset: Point<"page", "device">; + elementPositionsProbe: ElementPositionsProbe<"device">; +} + +export interface PrepareViewportScreenshotSuccess { + viewportSize: Size<"device">; + viewportOffset: Point<"page", "device">; + documentSize: Size<"device">; + canHaveCaret: boolean; + pixelRatio: number; + pointerEventsDisabled?: boolean; +} + +export type PrepareViewportScreenshotResult = PrepareViewportScreenshotSuccess | BrowserSideError; + +export type ScrollFullPageResult = ScrollFullPageSuccess | BrowserSideError; + +export type ScrollResult = + | { + readableSelectorToScrollDescr?: string; + debugLog?: string; + } + | BrowserSideError; + +export type GetCaptureStateResult = + | (CaptureState & { + readableSelectorToScrollDescr?: string; + debugLog?: string; + }) + | BrowserSideError; diff --git a/src/browser/client-scripts/screen-shooter/utils/clip-rect.ts b/src/browser/client-scripts/screen-shooter/utils/clip-rect.ts new file mode 100644 index 000000000..b7d24aae6 --- /dev/null +++ b/src/browser/client-scripts/screen-shooter/utils/clip-rect.ts @@ -0,0 +1,136 @@ +import { Rect, Coord, Length, getIntersection } from "@isomorphic"; +import { getBoundingClientContentRect } from "./element-rect"; +import { isRootLikeElement } from "./scroll"; +import { findContainingBlock } from "./dom"; +import { getReadableElementDescriptor } from "./descriptions"; + +function getViewportRect(): Rect<"viewport", "css"> { + return { + top: 0 as Coord<"viewport", "css", "y">, + left: 0 as Coord<"viewport", "css", "x">, + width: window.innerWidth as Length<"css", "x">, + height: window.innerHeight as Length<"css", "y"> + }; +} + +function hasOverflowClipping(style: CSSStyleDeclaration): boolean { + return style.overflow !== "visible" || style.overflowX !== "visible" || style.overflowY !== "visible"; +} + +type AbsoluteContainingBlock = { + /** Absolute ancestor of element is the element itself or one of its parents that has position: absolute */ + absoluteAncestor: Element; + /** Containing block of absoluteAncestor is an element relative to which it's positioned, e.g. parent having position: relative */ + containingBlock: Element; +}; + +function getAbsoluteContainingBlocks(element: Element): AbsoluteContainingBlock[] { + const absoluteContainingBlocks: AbsoluteContainingBlock[] = []; + let current: Element | null = element; + + while (current && !isRootLikeElement(current)) { + const style = getComputedStyle(current); + + if (style.position === "absolute") { + const containingBlock = findContainingBlock(current); + absoluteContainingBlocks.push({ absoluteAncestor: current, containingBlock }); + } + + current = current.parentElement; + } + + return absoluteContainingBlocks; +} + +function getFixedAncestors(element: Element): Element[] { + const fixedAncestors: Element[] = []; + let current: Element | null = element; + + while (current && !isRootLikeElement(current)) { + if (getComputedStyle(current).position === "fixed") { + fixedAncestors.push(current); + } + + current = current.parentElement; + } + + return fixedAncestors; +} + +/** Absolute-positioned descendants may escape clipping when their containing block is outside clippingElement */ +function escapesOverflowClippingViaAbsoluteContainingBlocks( + clippingElement: Element, + absoluteContainingBlocks: AbsoluteContainingBlock[] +): boolean { + return absoluteContainingBlocks.some(({ absoluteAncestor, containingBlock }) => { + if (absoluteAncestor === clippingElement || !clippingElement.contains(absoluteAncestor)) { + return false; + } + + return containingBlock !== clippingElement && containingBlock.contains(clippingElement); + }); +} + +/** Fixed-position descendants escape clipping of ancestors above the fixed ancestor */ +function escapesOverflowClippingViaFixedAncestors(clippingElement: Element, fixedAncestors: Element[]): boolean { + return fixedAncestors.some( + fixedAncestor => fixedAncestor !== clippingElement && clippingElement.contains(fixedAncestor) + ); +} + +/** + * Computes the clip rect for an element by intersecting the content boxes + * of all ancestor elements with overflow clipping, starting from the viewport. + * + * Elements with `position: fixed` are only clipped by the viewport, + * since they are positioned relative to the viewport and escape all + * ancestor overflow clipping. + */ +export function getClipRect(element: Element, logger?: (...args: unknown[]) => unknown): Rect<"viewport", "css"> { + const viewportRect = getViewportRect(); + + const absoluteContainingBlocks = getAbsoluteContainingBlocks(element); + const fixedAncestors = getFixedAncestors(element); + + let clipRect: Rect<"viewport", "css"> = viewportRect; + let current: Element | null = element.parentElement; + + while (current) { + if (isRootLikeElement(current)) { + break; + } + const style = getComputedStyle(current); + + if (hasOverflowClipping(style)) { + const isEscapingCurrentOverflowClipping = + escapesOverflowClippingViaAbsoluteContainingBlocks(current, absoluteContainingBlocks) || + escapesOverflowClippingViaFixedAncestors(current, fixedAncestors); + if (isEscapingCurrentOverflowClipping) { + current = current.parentElement; + continue; + } + + const contentBox = getBoundingClientContentRect(current); + logger?.("intersecting with:", getReadableElementDescriptor(current), "contentBox:", contentBox); + const intersection = getIntersection(clipRect, contentBox); + + if (!intersection) { + logger?.("no intersection found for:", getReadableElementDescriptor(current)); + + // Element is fully clipped — return zero-sized rect at clip origin + return { + top: clipRect.top, + left: clipRect.left, + width: 0 as Length<"css", "x">, + height: 0 as Length<"css", "y"> + }; + } + + clipRect = intersection; + } + + current = current.parentElement; + } + + return clipRect; +} diff --git a/src/browser/client-scripts/screen-shooter/utils/descriptions.ts b/src/browser/client-scripts/screen-shooter/utils/descriptions.ts new file mode 100644 index 000000000..8bf9c5e67 --- /dev/null +++ b/src/browser/client-scripts/screen-shooter/utils/descriptions.ts @@ -0,0 +1,19 @@ +export function getReadableElementDescriptor(element: Element): string { + if (element === document.documentElement) return "html"; + + const tag = element.tagName.toLowerCase(); + + if (element.id) return `${tag}#${element.id}`; + + if (element.classList.length) { + const classes: string[] = []; + + for (let i = 0; i < element.classList.length; i++) { + classes.push(element.classList[i]); + } + + return tag + "." + classes.join("."); + } + + return tag; +} diff --git a/src/browser/client-scripts/screen-shooter/utils/dom.ts b/src/browser/client-scripts/screen-shooter/utils/dom.ts new file mode 100644 index 000000000..54f958317 --- /dev/null +++ b/src/browser/client-scripts/screen-shooter/utils/dom.ts @@ -0,0 +1,113 @@ +import { ScreenshooterNamespaceData } from "../types"; + +declare global { + // eslint-disable-next-line no-var + var __geminiCore: Record | undefined; + // eslint-disable-next-line no-var + var __geminiNamespace: string; +} + +const FALLBACK_SCREENSHOOTER_NAMESPACE = "__testplane_screenshooter__"; + +export function getOwnerWindow(node: Node): Window | null { + if (!node.ownerDocument) { + return null; + } + return node.ownerDocument.defaultView; +} + +export function getOwnerIframe(node: Node): Element | null { + const nodeWindow = getOwnerWindow(node); + if (nodeWindow) { + return nodeWindow.frameElement; + } + return null; +} + +export function getMainDocumentElem(currDocumentElem?: HTMLElement): HTMLElement { + if (!currDocumentElem) { + currDocumentElem = document.documentElement; + } + + const currIframe = getOwnerIframe(currDocumentElem); + if (!currIframe) { + return currDocumentElem; + } + + const currWindow = getOwnerWindow(currIframe); + if (!currWindow) { + return currDocumentElem; + } + + return getMainDocumentElem(currWindow.document.documentElement); +} + +export function forEachRoot(cb: (root: Element | ShadowRoot) => void): void { + function traverseRoots(root: Element | ShadowRoot): void { + cb(root); + // @ts-expect-error - IE11 requires the third and fourth arguments + const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null, false); + for (let node: Node | null = treeWalker.currentNode; node !== null; node = treeWalker.nextNode()) { + if (node instanceof Element && node.shadowRoot) { + traverseRoots(node.shadowRoot); + } + } + } + traverseRoots(document.documentElement); +} + +export function getParentElement(node: Node): Element | null { + if (node instanceof ShadowRoot) return node.host; + if (node instanceof Element) { + const root = node.getRootNode(); + return node.parentElement || (root instanceof ShadowRoot ? root.host : null); + } + return node.parentNode instanceof Element ? node.parentNode : null; +} + +export function findFixedPositionedParent(element: Element): Element | null { + let parent = element.parentElement; + while (parent) { + if (getComputedStyle(parent).position === "fixed") { + return parent; + } + parent = parent.parentElement; + } + return null; +} + +export function findContainingBlock(element: Element): Element { + let parent = element.parentElement; + while (parent) { + const style = getComputedStyle(parent); + if ( + style.position === "relative" || + style.position === "absolute" || + style.position === "fixed" || + style.position === "sticky" || + style.transform !== "none" || + style.perspective !== "none" + ) { + return parent; + } + parent = parent.parentElement; + } + return document.documentElement; +} + +export function getScreenshooterNamespaceData(): ScreenshooterNamespaceData { + if (!window.__geminiCore) { + window.__geminiCore = {}; + } + + const namespace = + typeof __geminiNamespace === "string" && __geminiNamespace + ? __geminiNamespace + : FALLBACK_SCREENSHOOTER_NAMESPACE; + + if (!window.__geminiCore[namespace]) { + window.__geminiCore[namespace] = {}; + } + + return window.__geminiCore[namespace] as ScreenshooterNamespaceData; +} diff --git a/src/browser/client-scripts/screen-shooter/utils/element-rect.ts b/src/browser/client-scripts/screen-shooter/utils/element-rect.ts new file mode 100644 index 000000000..1cac482e5 --- /dev/null +++ b/src/browser/client-scripts/screen-shooter/utils/element-rect.ts @@ -0,0 +1,230 @@ +import { Rect, Coord, Length, getCoveringRect } from "@isomorphic"; +import { getOwnerWindow, getOwnerIframe } from "./dom"; +import { PSEUDO_ELEMENTS, PseudoElementSelector, getPseudoElementRect } from "./pseudo-element-rect"; + +interface BoxShadow { + offsetX: number; + offsetY: number; + blurRadius: number; + spreadRadius: number; + inset: boolean; +} + +export function domRectToViewportCss(domRect: DOMRect): Rect<"viewport", "css"> { + return { + top: domRect.top as Coord<"viewport", "css", "y">, + left: domRect.left as Coord<"viewport", "css", "x">, + width: domRect.width as Length<"css", "x">, + height: domRect.height as Length<"css", "y"> + }; +} + +function getElementBorderWidths(element: Element): { top: number; left: number } { + const ownerWindow = getOwnerWindow(element) || window; + const style = ownerWindow.getComputedStyle(element); + + return { + top: parseFloat(style.borderTopWidth), + left: parseFloat(style.borderLeftWidth) + }; +} + +function getIframeContentOrigin(node: Element): Rect<"viewport", "css"> { + const border = getElementBorderWidths(node); + const bcr = node.getBoundingClientRect(); + + return { + top: (bcr.top + border.top) as Coord<"viewport", "css", "y">, + left: (bcr.left + border.left) as Coord<"viewport", "css", "x">, + width: bcr.width as Length<"css", "x">, + height: bcr.height as Length<"css", "y"> + }; +} + +function getNestedBoundingClientRect(node: Element, logger?: (...args: unknown[]) => unknown): Rect<"viewport", "css"> { + const ownerIframe = getOwnerIframe(node); + + if (ownerIframe === null || getOwnerWindow(ownerIframe) === window) { + logger?.("getNestedBoundingClientRect ownerIframe is null or window, returning bounding rect untouched"); + return domRectToViewportCss(node.getBoundingClientRect()); + } + + logger?.( + "getNestedBoundingClientRect ownerIframe is not null or window, returning bounding rect with iframe origin" + ); + + const elementRect = domRectToViewportCss(node.getBoundingClientRect()); + let top = elementRect.top as number; + let left = elementRect.left as number; + + let currentIframe: Element | null = ownerIframe; + while (currentIframe) { + const iframeOrigin = getIframeContentOrigin(currentIframe); + top += iframeOrigin.top as number; + left += iframeOrigin.left as number; + + currentIframe = getOwnerIframe(currentIframe); + if (currentIframe && getOwnerWindow(currentIframe) === window) { + const outerOrigin = getIframeContentOrigin(currentIframe); + top += outerOrigin.top as number; + left += outerOrigin.left as number; + break; + } + } + + return { + top: top as Coord<"viewport", "css", "y">, + left: left as Coord<"viewport", "css", "x">, + width: elementRect.width, + height: elementRect.height + }; +} + +function parseBoxShadow(value: string): BoxShadow[] { + const regex = /[-+]?\d*\.?\d+px/g; + const results: BoxShadow[] = []; + + for (const part of (value || "").split(",")) { + const match = part.match(regex); + if (match) { + results.push({ + offsetX: parseFloat(match[0]), + offsetY: parseFloat(match[1]) || 0, + blurRadius: parseFloat(match[2]) || 0, + spreadRadius: parseFloat(match[3]) || 0, + inset: part.indexOf("inset") !== -1 + }); + } + } + + return results; +} + +function calculateShadowExtent(shadows: BoxShadow[]): { top: number; left: number; right: number; bottom: number } { + const result = { top: 0, left: 0, right: 0, bottom: 0 }; + + for (const shadow of shadows) { + if (shadow.inset) continue; + const blurAndSpread = shadow.spreadRadius + shadow.blurRadius; + result.left = Math.min(shadow.offsetX - blurAndSpread, result.left); + result.right = Math.max(shadow.offsetX + blurAndSpread, result.right); + result.top = Math.min(shadow.offsetY - blurAndSpread, result.top); + result.bottom = Math.max(shadow.offsetY + blurAndSpread, result.bottom); + } + + return result; +} + +function calculateShadowRect(clientRect: Rect<"viewport", "css">, shadows: BoxShadow[]): Rect<"viewport", "css"> { + const extent = calculateShadowExtent(shadows); + + return { + left: ((clientRect.left as number) + extent.left) as Coord<"viewport", "css", "x">, + top: ((clientRect.top as number) + extent.top) as Coord<"viewport", "css", "y">, + width: ((clientRect.width as number) - extent.left + extent.right) as Length<"css", "x">, + height: ((clientRect.height as number) - extent.top + extent.bottom) as Length<"css", "y"> + }; +} + +function calculateOutlineRect(clientRect: Rect<"viewport", "css">, outline: number): Rect<"viewport", "css"> { + return { + top: ((clientRect.top as number) - outline) as Coord<"viewport", "css", "y">, + left: ((clientRect.left as number) - outline) as Coord<"viewport", "css", "x">, + width: ((clientRect.width as number) + outline * 2) as Length<"css", "x">, + height: ((clientRect.height as number) + outline * 2) as Length<"css", "y"> + }; +} + +export function getExtRect(css: CSSStyleDeclaration, clientRect: Rect<"viewport", "css">): Rect<"viewport", "css"> { + const shadows = parseBoxShadow(css.boxShadow); + const outlineWidth = parseInt(css.outlineWidth, 10); + const outline = !isNaN(outlineWidth) && css.outlineStyle !== "none" ? outlineWidth : 0; + + return getCoveringRect([calculateShadowRect(clientRect, shadows), calculateOutlineRect(clientRect, outline)])!; +} + +function isHidden(css: CSSStyleDeclaration, rect: Rect<"viewport", "css">): boolean { + return ( + css.display === "none" || + css.visibility === "hidden" || + parseFloat(css.opacity) < 0.0001 || + rect.width < 0.0001 || + rect.height < 0.0001 + ); +} + +export function getBoundingClientContentRect(element: Element): Rect<"viewport", "css"> { + const style = getComputedStyle(element); + const bcr = element.getBoundingClientRect(); + const borderLeft = parseFloat(style.borderLeftWidth) || 0; + const borderTop = parseFloat(style.borderTopWidth) || 0; + const borderRight = parseFloat(style.borderRightWidth) || 0; + const borderBottom = parseFloat(style.borderBottomWidth) || 0; + + return { + left: (bcr.left + borderLeft) as Coord<"viewport", "css", "x">, + top: (bcr.top + borderTop) as Coord<"viewport", "css", "y">, + width: (bcr.width - borderLeft - borderRight) as Length<"css", "x">, + height: (bcr.height - borderTop - borderBottom) as Length<"css", "y"> + }; +} + +export function getElementCaptureRect( + element: Element, + logger?: (...args: unknown[]) => unknown +): Rect<"viewport", "css"> | null { + const css = getComputedStyle(element); + const clientRect = getNestedBoundingClientRect(element, logger); + logger?.("getElementCaptureRect clientRect:", clientRect); + + if (isHidden(css, clientRect)) { + return null; + } + + let elementRect = getExtRect(css, clientRect); + + for (const pseudoElement of PSEUDO_ELEMENTS) { + const pseudoCss = getComputedStyle(element, pseudoElement); + elementRect = getCoveringRect([elementRect, getExtRect(pseudoCss, clientRect)])!; + } + + return elementRect; +} + +export function getPseudoElementCaptureRect( + element: Element, + pseudo: PseudoElementSelector +): Rect<"viewport", "css"> | null { + const css = getComputedStyle(element); + const clientRect = getNestedBoundingClientRect(element); + + if (isHidden(css, clientRect)) { + return null; + } + + const pseudoRect = getPseudoElementRect(element, pseudo, clientRect); + + if (!pseudoRect) { + return null; + } + + return getExtRect(getComputedStyle(element, pseudo), pseudoRect); +} + +function parseBorderRadius(value: string): number { + const [first, second] = value.trim().split(/\s+/); + const parsed = parseFloat(second ?? first); + return isNaN(parsed) ? 0 : parsed; +} + +export function getVerticalRadiusInsets(element: Element): { top: number; bottom: number } { + const style = getComputedStyle(element); + + return { + top: Math.max(parseBorderRadius(style.borderTopLeftRadius), parseBorderRadius(style.borderTopRightRadius)), + bottom: Math.max( + parseBorderRadius(style.borderBottomLeftRadius), + parseBorderRadius(style.borderBottomRightRadius) + ) + }; +} diff --git a/src/browser/client-scripts/screen-shooter/utils/pseudo-element-rect.ts b/src/browser/client-scripts/screen-shooter/utils/pseudo-element-rect.ts new file mode 100644 index 000000000..7a7f2bc89 --- /dev/null +++ b/src/browser/client-scripts/screen-shooter/utils/pseudo-element-rect.ts @@ -0,0 +1,274 @@ +import { Rect, Coord, Length } from "@isomorphic"; + +interface TransformMatrix { + a: number; + b: number; + c: number; + d: number; + tx: number; + ty: number; +} + +export type PseudoElementSelector = "::before" | "::after"; + +export const PSEUDO_ELEMENTS: PseudoElementSelector[] = ["::before", "::after"]; + +const PSEUDO_SELECTOR_REGEXP = /(.*?)(::before|::after)\s*$/i; + +interface ParsedCaptureSelector { + elementSelector: string; + pseudoElement: PseudoElementSelector | null; +} + +export function parseCaptureSelector(selector: string): ParsedCaptureSelector { + const match = selector.match(PSEUDO_SELECTOR_REGEXP); + + if (!match) { + return { elementSelector: selector, pseudoElement: null }; + } + + const elementSelector = match[1].trim(); + + if (!elementSelector) { + return { elementSelector: selector, pseudoElement: null }; + } + + return { + elementSelector, + pseudoElement: match[2].toLowerCase() as PseudoElementSelector + }; +} + +function getElementBorderWidths(element: Element): { top: number; left: number } { + const style = getComputedStyle(element); + + return { + top: parseFloat(style.borderTopWidth), + left: parseFloat(style.borderLeftWidth) + }; +} + +function parseLengthOrZero(value: string): number { + const parsed = parseFloat(value); + return isNaN(parsed) ? 0 : parsed; +} + +function getElementContentBottom(element: Element): number | null { + const range = document.createRange(); + + try { + range.selectNodeContents(element); + const rects = range.getClientRects(); + let bottom = -Infinity; + + for (let i = 0; i < rects.length; i++) { + const rect = rects[i]; + if (rect.width < 0.0001 && rect.height < 0.0001) { + continue; + } + bottom = Math.max(bottom, rect.bottom); + } + + return bottom !== -Infinity ? bottom : null; + } catch { + return null; + } finally { + if (typeof range.detach === "function") { + range.detach(); + } + } +} + +function getContainingBlockPaddingEdge(element: Element): { top: number; left: number } | null { + let current: Element | null = element.parentElement; + + while (current) { + const pos = getComputedStyle(current).position; + if (pos !== "static") { + const bcr = current.getBoundingClientRect(); + const borders = getElementBorderWidths(current); + return { + top: bcr.top + borders.top, + left: bcr.left + borders.left + }; + } + current = current.parentElement; + } + + return null; // initial containing block = viewport +} + +function parseTransformMatrix(transform: string): TransformMatrix | null { + if (!transform || transform === "none") return null; + + const match2d = transform.match(/matrix\(([^)]+)\)/); + if (match2d) { + const v = match2d[1].split(",").map(s => parseFloat(s.trim())); + return { a: v[0], b: v[1], c: v[2], d: v[3], tx: v[4], ty: v[5] }; + } + + const match3d = transform.match(/matrix3d\(([^)]+)\)/); + if (match3d) { + const v = match3d[1].split(",").map(s => parseFloat(s.trim())); + return { a: v[0], b: v[1], c: v[4], d: v[5], tx: v[12], ty: v[13] }; + } + + return null; +} + +function resolveTransformOrigin(originStr: string, width: number, height: number): { x: number; y: number } { + const parts = originStr.split(/\s+/); + + function resolve(val: string, size: number): number { + if (val.slice(-1) === "%") { + return (parseFloat(val) / 100) * size; + } + return parseFloat(val) || 0; + } + + return { + x: resolve(parts[0], width), + y: resolve(parts[1] || parts[0], height) + }; +} + +function applyTransformToRect(rect: Rect<"viewport", "css">, css: CSSStyleDeclaration): Rect<"viewport", "css"> { + const matrix = parseTransformMatrix(css.transform); + if (!matrix) return rect; + + const w = rect.width as number; + const h = rect.height as number; + const origin = resolveTransformOrigin(css.transformOrigin, w, h); + const ox = (rect.left as number) + origin.x; + const oy = (rect.top as number) + origin.y; + + const corners = [ + [rect.left as number, rect.top as number], + [(rect.left as number) + w, rect.top as number], + [rect.left as number, (rect.top as number) + h], + [(rect.left as number) + w, (rect.top as number) + h] + ]; + + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const [cx, cy] of corners) { + const dx = cx - ox, + dy = cy - oy; + const nx = matrix.a * dx + matrix.c * dy + matrix.tx + ox; + const ny = matrix.b * dx + matrix.d * dy + matrix.ty + oy; + minX = Math.min(minX, nx); + maxX = Math.max(maxX, nx); + minY = Math.min(minY, ny); + maxY = Math.max(maxY, ny); + } + + return { + left: minX as Coord<"viewport", "css", "x">, + top: minY as Coord<"viewport", "css", "y">, + width: (maxX - minX) as Length<"css", "x">, + height: (maxY - minY) as Length<"css", "y"> + }; +} + +function computeBaseRect( + element: Element, + pseudo: PseudoElementSelector, + css: CSSStyleDeclaration, + elementRect: Rect<"viewport", "css"> +): Rect<"viewport", "css"> | null { + const width = parseFloat(css.width); + const height = parseFloat(css.height); + + if (isNaN(width) || isNaN(height) || width < 0.0001 || height < 0.0001) { + return null; + } + + const position = css.position; + + if (position === "fixed") { + const top = parseFloat(css.top); + const left = parseFloat(css.left); + + return { + top: (isNaN(top) ? 0 : top) as Coord<"viewport", "css", "y">, + left: (isNaN(left) ? 0 : left) as Coord<"viewport", "css", "x">, + width: width as Length<"css", "x">, + height: height as Length<"css", "y"> + }; + } + + if (position === "absolute") { + const elementCss = getComputedStyle(element); + let originTop: number; + let originLeft: number; + + if (elementCss.position !== "static") { + const borders = getElementBorderWidths(element); + originTop = (elementRect.top as number) + borders.top; + originLeft = (elementRect.left as number) + borders.left; + } else { + const containingBlock = getContainingBlockPaddingEdge(element); + originTop = containingBlock ? containingBlock.top : 0; + originLeft = containingBlock ? containingBlock.left : 0; + } + + const top = parseFloat(css.top); + const left = parseFloat(css.left); + + return { + top: (originTop + (isNaN(top) ? 0 : top)) as Coord<"viewport", "css", "y">, + left: (originLeft + (isNaN(left) ? 0 : left)) as Coord<"viewport", "css", "x">, + width: width as Length<"css", "x">, + height: height as Length<"css", "y"> + }; + } + + const elementCss = getComputedStyle(element); + const borderTop = parseLengthOrZero(elementCss.borderTopWidth); + const borderLeft = parseLengthOrZero(elementCss.borderLeftWidth); + const paddingTop = parseLengthOrZero(elementCss.paddingTop); + const paddingLeft = parseLengthOrZero(elementCss.paddingLeft); + const marginTop = parseLengthOrZero(css.marginTop); + const marginLeft = parseLengthOrZero(css.marginLeft); + const relativeTop = position === "relative" ? parseLengthOrZero(css.top) : 0; + const relativeLeft = position === "relative" ? parseLengthOrZero(css.left) : 0; + let flowTop = (elementRect.top as number) + borderTop + paddingTop; + + if (pseudo === "::after" && css.display === "block") { + const contentBottom = getElementContentBottom(element); + if (contentBottom !== null) { + flowTop = Math.max(flowTop, contentBottom); + } + } + + // In-flow pseudo-element: anchor at the element content box and apply pseudo margins + return { + top: (flowTop + marginTop + relativeTop) as Coord<"viewport", "css", "y">, + left: ((elementRect.left as number) + borderLeft + paddingLeft + marginLeft + relativeLeft) as Coord< + "viewport", + "css", + "x" + >, + width: width as Length<"css", "x">, + height: height as Length<"css", "y"> + }; +} + +export function getPseudoElementRect( + element: Element, + pseudo: "::before" | "::after", + elementRect: Rect<"viewport", "css"> +): Rect<"viewport", "css"> | null { + const css = getComputedStyle(element, pseudo); + + if (css.content === "none" || css.display === "none" || css.visibility === "hidden") { + return null; + } + + const baseRect = computeBaseRect(element, pseudo, css, elementRect); + if (!baseRect) return null; + + return applyTransformToRect(baseRect, css); +} diff --git a/src/browser/client-scripts/screen-shooter/utils/scroll.ts b/src/browser/client-scripts/screen-shooter/utils/scroll.ts new file mode 100644 index 000000000..7100466d3 --- /dev/null +++ b/src/browser/client-scripts/screen-shooter/utils/scroll.ts @@ -0,0 +1,172 @@ +import { Coord } from "../../../isomorphic/geometry"; +import { getParentElement } from "./dom"; +import { isSafariMobile } from "./user-agent"; + +const PSEUDO_SELECTOR_REGEXP = /(.*?)(::before|::after)\s*$/i; + +function getElementSelector(selector: string): string { + const match = selector.match(PSEUDO_SELECTOR_REGEXP); + + if (!match) { + return selector; + } + + const elementSelector = match[1].trim(); + + return elementSelector || selector; +} + +function isScrollable(element: Element): boolean { + const overflowY = getComputedStyle(element).overflowY; + return ( + element.scrollHeight > element.clientHeight && + (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") + ); +} + +export function getScrollParent(element: Element): Element | null { + if (getComputedStyle(element).position === "fixed") { + return null; + } + + let current = getParentElement(element); + + while (current && current !== document.documentElement) { + if (isScrollable(current)) return current; + if (getComputedStyle(current).position === "fixed") return null; + current = getParentElement(current); + } + + return current === document.documentElement ? document.documentElement : null; +} + +export function getScrollParentsChain(element: Element): Element[] { + const chain: Element[] = []; + let parent = getScrollParent(element); + + while (parent && parent !== document.documentElement) { + chain.unshift(parent); + parent = getScrollParent(parent); + } + + chain.unshift(document.documentElement); + return chain; +} + +export function getCommonScrollParent(selectors: string[]): Element { + const elements = selectors + .map(s => document.querySelector(getElementSelector(s))) + .filter((e): e is NonNullable => e !== null); + if (elements.length === 0) return document.documentElement; + if (elements.length === 1) { + const parent = getScrollParent(elements[0]); + return parent ? normalizeRootLikeElement(parent) : document.documentElement; + } + + const chains = elements.map(el => getScrollParentsChain(el)); + const minLength = Math.min(...chains.map(c => c.length)); + + let common: Element = document.documentElement; + for (let i = 0; i < minLength; i++) { + if (chains.every(chain => chain[i] === chains[0][i])) { + common = chains[0][i]; + } else { + break; + } + } + + return normalizeRootLikeElement(common); +} + +function getElementScrollTop(element: Element): number { + return isRootLikeElement(element) ? window.scrollY : element.scrollTop; +} + +const SCROLL_APPLY_MAX_WAIT_MS = 50; +const SCROLL_APPLY_MAX_ITERATIONS = 10000; + +function getPageScrollElement(): Element { + return document.scrollingElement ?? document.documentElement; +} + +export function isRootLikeElement(element: Element): boolean { + const pageScrollElement = getPageScrollElement(); + return element === document.documentElement || element === document.body || element === pageScrollElement; +} + +function normalizeRootLikeElement(element: Element): Element { + return isRootLikeElement(element) ? document.documentElement : element; +} + +/** + * iOS Safari has quirks when calling window.scrollTo near page top + * @see https://gist.github.com/shadowusr/da03a7d66059c44baeb698db2d4e8658 + */ +export function performScrollFixForSafariIfNeeded(targetY: number): void { + if (!isSafariMobile()) { + return; + } + if (window.scrollY < 100 && targetY < 100) { + window.scrollTo(window.scrollX, 100); + } +} + +export function scrollElementBy( + element: Element, + deltaY: Coord<"page", "css", "y">, + logger?: (...args: unknown[]) => unknown +): void { + const delta = deltaY; + const isRootLike = isRootLikeElement(element); + const scrollMetricsElement = isRootLike ? getPageScrollElement() : element; + const currentScrollY = isRootLike ? window.scrollY : element.scrollTop; + + // Clamping is needed due to a bug in safari - https://bugs.webkit.org/show_bug.cgi?id=179735 + const maxScrollY = scrollMetricsElement.scrollHeight - scrollMetricsElement.clientHeight; + const targetY = Math.max(0, Math.min(currentScrollY + delta, maxScrollY)); + + if (isRootLike) { + logger?.("scrollElementBy: scrolling window.scrollTo(" + window.scrollX + ", " + targetY + ")"); + + performScrollFixForSafariIfNeeded(targetY); + window.scrollTo(window.scrollX, targetY); + } else { + logger?.("scrollElementBy: scrolling element.scrollTo(" + element.scrollLeft + ", " + targetY + ")"); + element.scrollTo(element.scrollLeft, targetY); + } + + const startedAt = performance.now(); + let iterations = 0; + while (performance.now() - startedAt < SCROLL_APPLY_MAX_WAIT_MS && iterations < SCROLL_APPLY_MAX_ITERATIONS) { + if (getElementScrollTop(element) !== currentScrollY) { + return; + } + iterations++; + } +} + +export function scrollElementToOffset(element: Element, offset: Coord<"page", "css", "y">): void { + const isRootLike = isRootLikeElement(element); + const scrollMetricsElement = isRootLike ? getPageScrollElement() : element; + const currentScrollY = isRootLike ? window.scrollY : element.scrollTop; + + // Clamping is needed due to a bug in safari - https://bugs.webkit.org/show_bug.cgi?id=179735 + const maxScrollY = scrollMetricsElement.scrollHeight - scrollMetricsElement.clientHeight; + const targetY = Math.max(0, Math.min(offset, maxScrollY)); + + if (isRootLike) { + performScrollFixForSafariIfNeeded(targetY); + window.scrollTo(window.scrollX, targetY); + } else { + element.scrollTo(element.scrollLeft, targetY); + } + + const startedAt = performance.now(); + let iterations = 0; + while (performance.now() - startedAt < SCROLL_APPLY_MAX_WAIT_MS && iterations < SCROLL_APPLY_MAX_ITERATIONS) { + if (getElementScrollTop(element) === currentScrollY) { + return; + } + iterations++; + } +} diff --git a/src/browser/client-scripts/screen-shooter/utils/trusted-types.ts b/src/browser/client-scripts/screen-shooter/utils/trusted-types.ts new file mode 100644 index 000000000..52b376f50 --- /dev/null +++ b/src/browser/client-scripts/screen-shooter/utils/trusted-types.ts @@ -0,0 +1,19 @@ +declare global { + // eslint-disable-next-line no-var + var trustedTypes: + | { + createPolicy(name: string, rules: { createHTML: (string: string) => string }): void; + } + | undefined; +} + +export function createDefaultTrustedTypesPolicy(): void { + const w = window; + if (w.trustedTypes && w.trustedTypes.createPolicy) { + w.trustedTypes.createPolicy("default", { + createHTML: function (string: string) { + return string; + } + }); + } +} diff --git a/src/browser/client-scripts/screen-shooter/utils/user-agent.ts b/src/browser/client-scripts/screen-shooter/utils/user-agent.ts new file mode 100644 index 000000000..38012cd72 --- /dev/null +++ b/src/browser/client-scripts/screen-shooter/utils/user-agent.ts @@ -0,0 +1,8 @@ +export function isSafariMobile(): boolean { + return Boolean( + navigator && + typeof navigator.vendor === "string" && + navigator.vendor.match(/apple/i) && + /(iPhone|iPad).*AppleWebKit.*Safari/i.test(navigator.userAgent) + ); +} diff --git a/src/browser/client-scripts/screen-shooter/utils/z-index.ts b/src/browser/client-scripts/screen-shooter/utils/z-index.ts new file mode 100644 index 000000000..7a8080350 --- /dev/null +++ b/src/browser/client-scripts/screen-shooter/utils/z-index.ts @@ -0,0 +1,158 @@ +import { getParentElement } from "./dom"; + +function getCssProp(style: CSSStyleDeclaration, prop: string): string | undefined { + return (style as unknown as Record)[prop]; +} + +function hasCssProp(style: CSSStyleDeclaration, prop: string, defaultValue = "none"): boolean { + const value = getCssProp(style, prop); + return value !== undefined && value !== defaultValue; +} + +function isFlexContainer(style: CSSStyleDeclaration): boolean { + return style.display === "flex" || style.display === "inline-flex"; +} + +function isGridContainer(style: CSSStyleDeclaration): boolean { + return (style.display || "").indexOf("grid") !== -1; +} + +function hasContainStackingContext(contain: string): boolean { + return ( + contain === "layout" || + contain === "paint" || + contain === "strict" || + contain === "content" || + contain.indexOf("paint") !== -1 || + contain.indexOf("layout") !== -1 + ); +} + +function createsStackingContext(element: Element): boolean { + const style = getComputedStyle(element); + const position = style.position; + const zIndex = style.zIndex; + + if (position === "fixed" || position === "sticky") return true; + if (getCssProp(style, "containerType") === "size" || getCssProp(style, "containerType") === "inline-size") + return true; + if (zIndex !== "auto" && position !== "static") return true; + if (parseFloat(style.opacity) < 1) return true; + if (style.transform !== "none") return true; + if (hasCssProp(style, "scale")) return true; + if (hasCssProp(style, "rotate")) return true; + if (hasCssProp(style, "translate")) return true; + if (style.mixBlendMode !== "normal") return true; + if (style.filter !== "none") return true; + if (hasCssProp(style, "backdropFilter")) return true; + if (hasCssProp(style, "webkitBackdropFilter")) return true; + if (style.perspective !== "none") return true; + if (hasCssProp(style, "clipPath")) return true; + + const mask = getCssProp(style, "mask") || getCssProp(style, "webkitMask"); + if (mask !== undefined && mask !== "none") return true; + + const maskImage = getCssProp(style, "maskImage") || getCssProp(style, "webkitMaskImage"); + if (maskImage !== undefined && maskImage !== "none") return true; + + const maskBorder = getCssProp(style, "maskBorder") || getCssProp(style, "webkitMaskBorder"); + if (maskBorder !== undefined && maskBorder !== "none") return true; + + if (style.isolation === "isolate") return true; + + const willChange = style.willChange || ""; + if (willChange.indexOf("transform") !== -1 || willChange.indexOf("opacity") !== -1) return true; + + if (getCssProp(style, "webkitOverflowScrolling") === "touch") return true; + + if (zIndex !== "auto") { + const parent = getParentElement(element); + if (parent) { + const parentStyle = getComputedStyle(parent); + if (isFlexContainer(parentStyle) || isGridContainer(parentStyle)) return true; + } + } + + if (hasContainStackingContext(style.contain || "")) return true; + + return false; +} + +function getClosestStackingContext(node: Node | null): Element { + if (!node || node === document.documentElement) { + return document.documentElement; + } + + if (node instanceof ShadowRoot) { + return getClosestStackingContext(node.host); + } + + if (!(node instanceof Element)) { + return getClosestStackingContext(node.parentNode); + } + + if (createsStackingContext(node)) { + return node; + } + + return getClosestStackingContext(getParentElement(node)); +} + +function getStackingContextRoot(element: Element): Element { + return getClosestStackingContext(getParentElement(element)); +} + +function getEffectiveZIndex(element: Element): number { + let curr: Element | null = element; + + while (curr && curr !== document.documentElement) { + const style = getComputedStyle(curr); + + if (style.zIndex !== "auto") { + const num = parseFloat(style.zIndex); + return isNaN(num) ? 0 : num; + } + + if (createsStackingContext(curr)) { + return 0; + } + + curr = curr.parentElement; + } + + return 0; +} + +interface ZChainItem { + ctx: Element; + z: number; +} + +export function buildZChain(element: Element): ZChainItem[] { + const chain: ZChainItem[] = []; + let curr: Element | null = element; + + while (curr && curr !== document.documentElement) { + const ctx = getStackingContextRoot(curr); + const z = getEffectiveZIndex(curr); + + chain.unshift({ ctx, z }); + + if (ctx === document.documentElement) break; + curr = ctx; + } + + return chain; +} + +export function isChainBehind(candChain: ZChainItem[], targetChain: ZChainItem[]): boolean { + for (let j = targetChain.length - 1; j >= 0; j--) { + for (let i = candChain.length - 1; i >= 0; i--) { + if (candChain[i].ctx === targetChain[j].ctx) { + return candChain[i].z < targetChain[j].z; + } + } + } + + return false; +} diff --git a/src/browser/client-scripts/lib.compat.js b/src/browser/client-scripts/shared/lib.compat.ts similarity index 54% rename from src/browser/client-scripts/lib.compat.js rename to src/browser/client-scripts/shared/lib.compat.ts index 4e39a4f68..a240d7807 100644 --- a/src/browser/client-scripts/lib.compat.js +++ b/src/browser/client-scripts/shared/lib.compat.ts @@ -1,28 +1,27 @@ -"use strict"; -/*jshint newcap:false*/ -var Sizzle = require("sizzle"); -var xpath = require("./xpath"); +/// +import Sizzle from "sizzle"; +import * as xpath from "./xpath"; -exports.queryFirst = function (selector) { +export function queryFirst(selector: string): Element | null { if (xpath.isXpathSelector(selector)) { return xpath.queryFirst(selector); } - var elems = Sizzle(exports.trim(selector) + ":first"); + const elems = Sizzle(trim(selector) + ":first"); return elems.length > 0 ? elems[0] : null; -}; +} -exports.queryAll = function (selector) { +export function queryAll(selector: string): Element[] { if (xpath.isXpathSelector(selector)) { return xpath.queryAll(selector); } return Sizzle(selector); -}; +} -exports.trim = function (str) { +export function trim(str: string): string { // trim spaces, unicode BOM and NBSP and the beginning and the end of the line // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/Trim#Polyfill return str.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, ""); -}; +} -exports.getComputedStyle = require("./polyfills/getComputedStyle").getComputedStyle; -exports.matchMedia = require("./polyfills/matchMedia").matchMedia; +export { getComputedStyle } from "./polyfills/getComputedStyle"; +export { matchMedia } from "./polyfills/matchMedia"; diff --git a/src/browser/client-scripts/shared/lib.native.ts b/src/browser/client-scripts/shared/lib.native.ts new file mode 100644 index 000000000..a3c259ebe --- /dev/null +++ b/src/browser/client-scripts/shared/lib.native.ts @@ -0,0 +1,27 @@ +import * as xpath from "./xpath"; + +export function queryFirst(selector: string): Element | null { + if (xpath.isXpathSelector(selector)) { + return xpath.queryFirst(selector); + } + return document.querySelector(selector); +} + +export function queryAll(selector: string): Element[] { + if (xpath.isXpathSelector(selector)) { + return xpath.queryAll(selector); + } + return Array.from(document.querySelectorAll(selector)); +} + +export function getComputedStyle(element: Element, pseudoElement: string): CSSStyleDeclaration { + return getComputedStyle(element, pseudoElement); +} + +export function matchMedia(mediaQuery: string): MediaQueryList { + return matchMedia(mediaQuery); +} + +export function trim(str: string): string { + return str.trim(); +} diff --git a/src/browser/client-scripts/shared/logger.ts b/src/browser/client-scripts/shared/logger.ts new file mode 100644 index 000000000..f5c3d0a6a --- /dev/null +++ b/src/browser/client-scripts/shared/logger.ts @@ -0,0 +1,49 @@ +interface DebugOpts { + debug?: string[]; +} + +interface DebugLogger { + (...args: unknown[]): string; + log: string; + enabled: boolean; +} + +interface CreateDebugLoggerFn { + (opts: DebugOpts, debugTopic: string): DebugLogger; +} + +function makeCreateDebugLogger(): CreateDebugLoggerFn { + const fn = function (opts: DebugOpts, debugTopic: string): DebugLogger { + // fn.log = ""; + + if (opts.debug && opts.debug.indexOf(debugTopic) !== -1) { + const enabledLogger = function (...args: unknown[]): string { + for (const arg of args) { + if (typeof arg === "object" && arg !== null) { + try { + enabledLogger.log += JSON.stringify(arg, null, 2) + "\n"; + } catch (e) { + enabledLogger.log += "failed to log message due to an error: " + e; + } + } else { + enabledLogger.log += String(arg) + "\n"; + } + } + return enabledLogger.log; + } as DebugLogger; + enabledLogger.enabled = true; + enabledLogger.log = ""; + return enabledLogger; + } + + const disabledLogger = function (): string { + return ""; + } as DebugLogger; + disabledLogger.enabled = false; + return disabledLogger; + } as CreateDebugLoggerFn; + // fn.log = ""; + return fn; +} + +export const createDebugLogger: CreateDebugLoggerFn = makeCreateDebugLogger(); diff --git a/src/browser/client-scripts/polyfills/LICENSE.md b/src/browser/client-scripts/shared/polyfills/LICENSE.md similarity index 100% rename from src/browser/client-scripts/polyfills/LICENSE.md rename to src/browser/client-scripts/shared/polyfills/LICENSE.md diff --git a/src/browser/client-scripts/polyfills/getComputedStyle.js b/src/browser/client-scripts/shared/polyfills/getComputedStyle.ts similarity index 55% rename from src/browser/client-scripts/polyfills/getComputedStyle.js rename to src/browser/client-scripts/shared/polyfills/getComputedStyle.ts index 984ef3ad9..6ea638bb4 100644 --- a/src/browser/client-scripts/polyfills/getComputedStyle.js +++ b/src/browser/client-scripts/shared/polyfills/getComputedStyle.ts @@ -1,31 +1,28 @@ /** * Adapted from: https://raw.githubusercontent.com/Financial-Times/polyfill-service */ -function getComputedStylePixel(element, property, fontSize) { - var // Internet Explorer sometimes struggles to read currentStyle until the element's document is accessed. - value = (element.document && element.currentStyle[property].match(/([\d.]+)(%|cm|em|in|mm|pc|pt|)/)) || [ - 0, - 0, - "" - ], +function getComputedStylePixel(element: Element, property: string, fontSize?: number | null): number { + const // Internet Explorer sometimes struggles to read currentStyle until the element's document is accessed. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value = ((element as any).document && + (element as any).currentStyle[property].match(/([\d.]+)(%|cm|em|in|mm|pc|pt|)/)) || [0, 0, ""], size = value[1], - suffix = value[2], - rootSize; + suffix = value[2]; fontSize = !fontSize ? fontSize : /%|em/.test(suffix) && element.parentElement ? getComputedStylePixel(element.parentElement, "fontSize", null) : 16; - rootSize = + const rootSize = property === "fontSize" ? fontSize : /width/i.test(property) ? element.clientWidth : element.clientHeight; return suffix === "%" - ? (size / 100) * rootSize + ? (size / 100) * (rootSize as number) : suffix === "cm" ? size * 0.3937 * 96 : suffix === "em" - ? size * fontSize + ? size * (fontSize as number) : suffix === "in" ? size * 96 : suffix === "mm" @@ -37,13 +34,14 @@ function getComputedStylePixel(element, property, fontSize) { : size; } -function setShortStyleProperty(style, property) { - var borderSuffix = property === "border" ? "Width" : "", - t = property + "Top" + borderSuffix, - r = property + "Right" + borderSuffix, - b = property + "Bottom" + borderSuffix, - l = property + "Left" + borderSuffix; +function setShortStyleProperty(style: CSSStyleDeclaration, property: string & keyof CSSStyleDeclaration): void { + const borderSuffix = property === "border" ? "Width" : "", + t = (property + "Top" + borderSuffix) as keyof CSSStyleDeclaration, + r = (property + "Right" + borderSuffix) as keyof CSSStyleDeclaration, + b = (property + "Bottom" + borderSuffix) as keyof CSSStyleDeclaration, + l = (property + "Left" + borderSuffix) as keyof CSSStyleDeclaration; + // @ts-expect-error This is a polyfill, we need manual overrides here style[property] = ( style[t] === style[r] && style[t] === style[b] && style[t] === style[l] ? [style[t]] @@ -56,28 +54,34 @@ function setShortStyleProperty(style, property) { } // -function CSSStyleDeclaration(element) { - var currentStyle = element.currentStyle, +function CSSStyleDeclaration(this: CSSStyleDeclaration, element: Element): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const currentStyle = (element as any).currentStyle, fontSize = getComputedStylePixel(element, "fontSize"), - unCamelCase = function (match) { + unCamelCase = function (match: string): string { return "-" + match.toLowerCase(); - }, - property; + }; + let property: string; for (property in currentStyle) { Array.prototype.push.call(this, property === "styleFloat" ? "float" : property.replace(/[A-Z]/, unCamelCase)); if (property === "width") { - this[property] = element.offsetWidth + "px"; + this[property] = (element as HTMLElement).offsetWidth + "px"; } else if (property === "height") { - this[property] = element.offsetHeight + "px"; + this[property] = (element as HTMLElement).offsetHeight + "px"; } else if (property === "styleFloat") { this.float = currentStyle[property]; - } else if (/margin.|padding.|border.+W/.test(property) && this[property] !== "auto") { + } else if ( + /margin.|padding.|border.+W/.test(property) && + this[property as keyof CSSStyleDeclaration] !== "auto" + ) { + // @ts-expect-error This is a polyfill, we need manual overrides here this[property] = Math.round(getComputedStylePixel(element, property, fontSize)) + "px"; } else if (/^outline/.test(property)) { // errors on checking outline try { + // @ts-expect-error This is a polyfill, we need manual overrides here this[property] = currentStyle[property]; } catch (error) { this.outlineColor = currentStyle.color; @@ -86,6 +90,7 @@ function CSSStyleDeclaration(element) { this.outline = [this.outlineColor, this.outlineWidth, this.outlineStyle].join(" "); } } else { + // @ts-expect-error This is a polyfill, we need manual overrides here this[property] = currentStyle[property]; } } @@ -100,39 +105,42 @@ function CSSStyleDeclaration(element) { CSSStyleDeclaration.prototype = { constructor: CSSStyleDeclaration, // .getPropertyPriority - getPropertyPriority: function () { + getPropertyPriority: function (): never { throw new Error("NotSupportedError: DOM Exception 9"); }, // .getPropertyValue - getPropertyValue: function (property) { + getPropertyValue: function (property: string): string { return this[ - property.replace(/-\w/g, function (match) { + property.replace(/-\w/g, function (match: string): string { return match[1].toUpperCase(); }) ]; }, // .item - item: function (index) { + item: function (index: number): string { return this[index]; }, // .removeProperty - removeProperty: function () { + removeProperty: function (): never { throw new Error("NoModificationAllowedError: DOM Exception 7"); }, // .setProperty - setProperty: function () { + setProperty: function (): never { throw new Error("NoModificationAllowedError: DOM Exception 7"); }, // .getPropertyCSSValue - getPropertyCSSValue: function () { + getPropertyCSSValue: function (): never { throw new Error("NotSupportedError: DOM Exception 9"); } }; -exports.CSSStyleDeclaration = CSSStyleDeclaration; +export { CSSStyleDeclaration }; // .getComputedStyle -exports.getComputedStyle = function getComputedStyle(element, pseudoEl) { +export function getComputedStyle(element: Element, pseudoEl: string): CSSStyleDeclaration { // IE9 needs matchMedia support but already support getComputedStyle - return window.getComputedStyle ? window.getComputedStyle(element, pseudoEl) : new CSSStyleDeclaration(element); -}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return window.getComputedStyle + ? window.getComputedStyle(element, pseudoEl) + : new (CSSStyleDeclaration as any)(element); +} diff --git a/src/browser/client-scripts/polyfills/matchMedia.js b/src/browser/client-scripts/shared/polyfills/matchMedia.ts similarity index 58% rename from src/browser/client-scripts/polyfills/matchMedia.js rename to src/browser/client-scripts/shared/polyfills/matchMedia.ts index fe5c9ffb5..7a7f2872f 100644 --- a/src/browser/client-scripts/polyfills/matchMedia.js +++ b/src/browser/client-scripts/shared/polyfills/matchMedia.ts @@ -1,8 +1,7 @@ /** * Adapted from: https://raw.githubusercontent.com/Financial-Times/polyfill-service */ -function evalQuery(query) { - /* jshint evil: true */ +function evalQuery(query: string): boolean { query = (query || "true") .replace(/^only\s+/, "") .replace(/(device)-([\w.]+)/g, "$1.$2") @@ -14,9 +13,9 @@ function evalQuery(query) { .replace(/,/g, "||") .replace(/\band\b/g, "&&") .replace(/dpi/g, "") - .replace(/(\d+)(cm|em|in|dppx|mm|pc|pt|px|rem)/g, function ($0, $1, $2) { + .replace(/(\d+)(cm|em|in|dppx|mm|pc|pt|px|rem)/g, function ($0: string, $1: string, $2: string): string { return ( - $1 * + parseFloat($1) * ($2 === "cm" ? 0.3937 * 96 : $2 === "em" || $2 === "rem" @@ -30,38 +29,48 @@ function evalQuery(query) { : $2 === "pt" ? 96 / 72 : 1) - ); + ).toString(); }); + // @ts-expect-error global might be present in old browsers + const globalObj = window || globalThis || global; return new Function("media", "try{ return !!(%s) }catch(e){ return false }".replace("%s", query))({ - width: global.innerWidth, - height: global.innerHeight, - orientation: global.orientation || "landscape", + width: globalObj.innerWidth, + height: globalObj.innerHeight, + orientation: globalObj.orientation || "landscape", device: { - width: global.screen.width, - height: global.screen.height, - orientation: global.screen.orientation || global.orientation || "landscape" + width: globalObj.screen.width, + height: globalObj.screen.height, + orientation: globalObj.screen.orientation || globalObj.orientation || "landscape" } }); } -function MediaQueryList() { +interface MediaQueryListPolyfill { + matches: boolean; + media: string; + addListener: (listener: () => void) => void; + removeListener: (listener: () => void) => void; +} + +function MediaQueryList(this: MediaQueryListPolyfill): void { this.matches = false; this.media = "invalid"; } -MediaQueryList.prototype.addListener = function addListener(listener) { +MediaQueryList.prototype.addListener = function addListener(listener: () => void): void { this.addListener.listeners.push(listener); }; -MediaQueryList.prototype.removeListener = function removeListener(listener) { +MediaQueryList.prototype.removeListener = function removeListener(listener: () => void): void { this.addListener.listeners.splice(this.addListener.listeners.indexOf(listener), 1); }; -exports.MediaQueryList = MediaQueryList; +export { MediaQueryList }; // .matchMedia -exports.matchMedia = function matchMedia(query) { - var list = new MediaQueryList(); +export function matchMedia(query: string): MediaQueryList { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const list = new (MediaQueryList as any)(); if (arguments.length === 0) { throw new TypeError("Not enough arguments to matchMedia"); @@ -71,17 +80,20 @@ exports.matchMedia = function matchMedia(query) { list.matches = evalQuery(list.media); list.addListener.listeners = []; + // @ts-expect-error global might be present in old browsers + const globalObj = window || globalThis || global; + window.addEventListener("resize", function () { - var listeners = [].concat(list.addListener.listeners), + const listeners = [].concat(list.addListener.listeners), matches = evalQuery(list.media); if (matches !== list.matches) { list.matches = matches; - for (var index = 0, length = listeners.length; index < length; ++index) { - listeners[index].call(global, list); + for (let index = 0, length = listeners.length; index < length; ++index) { + (listeners[index] as (...args: unknown[]) => void).call(globalObj, list); } } }); return list; -}; +} diff --git a/src/browser/client-scripts/shared/sizzle.d.ts b/src/browser/client-scripts/shared/sizzle.d.ts new file mode 100644 index 000000000..4d05accb8 --- /dev/null +++ b/src/browser/client-scripts/shared/sizzle.d.ts @@ -0,0 +1,3 @@ +declare module "sizzle" { + export default function (selector: string): Element[]; +} diff --git a/src/browser/client-scripts/shared/xpath.ts b/src/browser/client-scripts/shared/xpath.ts new file mode 100644 index 000000000..2acb02d04 --- /dev/null +++ b/src/browser/client-scripts/shared/xpath.ts @@ -0,0 +1,26 @@ +const XPATH_SELECTORS_START = ["/", "(", "../", "./", "*/"]; + +export function isXpathSelector(selector: string): boolean { + return XPATH_SELECTORS_START.some(function (startString) { + return selector.indexOf(startString) === 0; + }); +} + +export function queryFirst(selector: string): Element | null { + return document.evaluate(selector, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null) + .singleNodeValue as Element | null; +} + +export function queryAll(selector: string): Element[] { + const elements = document.evaluate(selector, document, null, XPathResult.ANY_TYPE, null); + let node: Node | null; + const nodes: Element[] = []; + node = elements.iterateNext(); + + while (node) { + nodes.push(node as Element); + node = elements.iterateNext(); + } + + return nodes; +} diff --git a/src/browser/client-scripts/tsconfig.compat.common.json b/src/browser/client-scripts/tsconfig.compat.common.json new file mode 100644 index 000000000..d9c977472 --- /dev/null +++ b/src/browser/client-scripts/tsconfig.compat.common.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "CommonJS", + "composite": true, + "target": "ES3", + "lib": ["es5", "dom"], + "moduleResolution": "node", + "rootDir": "..", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "ignoreDeprecations": "5.0", // TODO: latest versions of TypeScript don't support ES3. We should migrate to babel build instead of emitting ES3 code! + "types": [], + "paths": { + "@lib": ["./shared/lib.compat.ts"], + "@isomorphic": ["../isomorphic/index.ts"] + } + }, + "references": [ + { + "path": "../isomorphic/tsconfig.json" + } + ] +} diff --git a/src/browser/client-scripts/tsconfig.native.common.json b/src/browser/client-scripts/tsconfig.native.common.json new file mode 100644 index 000000000..e9f339003 --- /dev/null +++ b/src/browser/client-scripts/tsconfig.native.common.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "CommonJS", + "composite": true, + "target": "es3", + "lib": ["es2020", "dom"], + "moduleResolution": "node", + "rootDir": "..", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "ignoreDeprecations": "5.0", // TODO: latest versions of TypeScript don't support ES3. We should migrate to babel build instead of emitting ES3 code! + "types": [], + "paths": { + "@lib": ["./shared/lib.native.ts"], + "@isomorphic": ["../isomorphic/index.ts"] + } + }, + "references": [ + { + "path": "../isomorphic/tsconfig.json" + } + ] +} diff --git a/src/browser/client-scripts/util.js b/src/browser/client-scripts/util.js deleted file mode 100644 index ff86e448b..000000000 --- a/src/browser/client-scripts/util.js +++ /dev/null @@ -1,474 +0,0 @@ -"use strict"; - -var lib = require("./lib"); - -var SCROLL_DIR_NAME = { - top: "scrollTop", - left: "scrollLeft" -}; - -var PAGE_OFFSET_NAME = { - x: "pageXOffset", - y: "pageYOffset" -}; - -exports.each = arrayUtil(Array.prototype.forEach, myForEach); -exports.some = arrayUtil(Array.prototype.some, mySome); -exports.every = arrayUtil(Array.prototype.every, myEvery); - -function arrayUtil(nativeFunc, shimFunc) { - return nativeFunc ? contextify(nativeFunc) : shimFunc; -} - -/** - * Makes function f to accept context as a - * first argument - */ -function contextify(f) { - return function (ctx) { - var rest = Array.prototype.slice.call(arguments, 1); - return f.apply(ctx, rest); - }; -} - -function myForEach(array, cb, context) { - for (var i = 0; i < array.length; i++) { - cb.call(context, array[i], i, array); - } -} - -function mySome(array, cb, context) { - for (var i = 0; i < array.length; i++) { - if (cb.call(context, array[i], i, array)) { - return true; - } - } - return false; -} - -function myEvery(array, cb, context) { - for (var i = 0; i < array.length; i++) { - if (!cb.call(context, array[i], i, array)) { - return false; - } - } - return true; -} - -function getScroll(scrollElem, direction, coord) { - var scrollDir = SCROLL_DIR_NAME[direction]; - var pageOffset = PAGE_OFFSET_NAME[coord]; - - if (scrollElem && scrollElem !== window) { - return scrollElem[scrollDir]; - } - - if (typeof window[pageOffset] !== "undefined") { - return window[pageOffset]; - } - - return document.documentElement[scrollDir]; -} - -exports.getScrollTop = function (scrollElem) { - return getScroll(scrollElem, "top", "y"); -}; - -exports.getScrollLeft = function (scrollElem) { - return getScroll(scrollElem, "left", "x"); -}; - -exports.isSafariMobile = function () { - return ( - navigator && - typeof navigator.vendor === "string" && - navigator.vendor.match(/apple/i) && - /(iPhone|iPad).*AppleWebKit.*Safari/i.test(navigator.userAgent) - ); -}; - -exports.isInteger = function (num) { - return num % 1 === 0; -}; - -exports.forEachRoot = function (cb) { - function traverseRoots(root) { - cb(root); - - // In IE 11, we need to pass the third and fourth arguments - var treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null, false); - - for (var node = treeWalker.currentNode; node !== null; node = treeWalker.nextNode()) { - if (node instanceof Element && node.shadowRoot) { - traverseRoots(node.shadowRoot); - } - } - } - - traverseRoots(document.documentElement); -}; - -exports.getOwnerWindow = function (node) { - if (!node.ownerDocument) { - return null; - } - - return node.ownerDocument.defaultView; -}; - -exports.getOwnerIframe = function (node) { - var nodeWindow = exports.getOwnerWindow(node); - if (nodeWindow) { - return nodeWindow.frameElement; - } - - return null; -}; - -exports.getMainDocumentElem = function (currDocumentElem) { - if (!currDocumentElem) { - currDocumentElem = document.documentElement; - } - - var currIframe = exports.getOwnerIframe(currDocumentElem); - if (!currIframe) { - return currDocumentElem; - } - - var currWindow = exports.getOwnerWindow(currIframe); - if (!currWindow) { - return currDocumentElem; - } - - return exports.getMainDocumentElem(currWindow.document.documentElement); -}; - -exports.createDebugLogger = function createDebugLogger(opts) { - var log = ""; - if (opts.debug) { - return function () { - for (var i = 0; i < arguments.length; i++) { - if (typeof arguments[i] === "object") { - try { - log += JSON.stringify(arguments[i], null, 2) + "\n"; - } catch (e) { - log += "failed to log message due to an error: " + e; - } - } else { - log += arguments[i] + "\n"; - } - } - - return log; - }; - } - - return function () {}; -}; - -function getParentNode(node) { - if (!node) return null; - if (node instanceof ShadowRoot) return node.host; - if (node instanceof Element) { - var root = node.getRootNode(); - return node.parentElement || (root instanceof ShadowRoot ? root.host : null); - } - return node.parentNode; // for Text/Comment nodes -} - -exports.getScrollParent = function getScrollParent(element, logger) { - if (element === null) { - return null; - } - - if (element === window) { - return window; - } - - var hasOverflow = element.scrollHeight > element.clientHeight; - if (element instanceof Element) { - var computedStyleOverflowY = lib.getComputedStyle(element).overflowY; - } else { - return getScrollParent(getParentNode(element), logger); - } - - var canBeScrolled = - computedStyleOverflowY === "auto" || - computedStyleOverflowY === "scroll" || - computedStyleOverflowY === "overlay"; - - if (hasOverflow && canBeScrolled) { - if (element.tagName === "BODY") { - return window; - } - return element; - } else { - return getScrollParent(getParentNode(element), logger); - } -}; - -exports.isRootElement = function (element) { - return element === window || element.parentElement === null; -}; - -/* Returns an element relative to which given absolutely positioned element is positioned */ -exports.findContainingBlock = function findContainingBlock(element) { - var parent = element.parentElement; - while (parent) { - var style = lib.getComputedStyle(parent); - if ( - ["relative", "absolute", "fixed", "sticky"].includes(style.position) || - style.transform !== "none" || - style.perspective !== "none" - ) { - return parent; - } - parent = parent.parentElement; - } - return document.documentElement; -}; - -function _matchesProp(style, propName, defaultValue) { - if (typeof style[propName] === "undefined") { - return false; - } - - return style[propName] !== (typeof defaultValue === "undefined" ? "none" : defaultValue); -} - -function _isFlexContainer(style) { - return style.display === "flex" || style.display === "inline-flex"; -} - -function _isGridContainer(style) { - return (style.display || "").indexOf("grid") !== -1; -} - -function _hasContainStackingContext(contain) { - if (!contain) { - return false; - } - - return ( - contain === "layout" || - contain === "paint" || - contain === "strict" || - contain === "content" || - contain.indexOf("paint") !== -1 || - contain.indexOf("layout") !== -1 - ); -} - -// This method was inspired by https://github.com/gwwar/z-context/blob/dea7c1c220c77281ce6a02b910460b3a5d4744c8/content-script.js#L30 -function _stackingContextReason(node, computedStyle, includeReason) { - var position = computedStyle.position; - var zIndexStr = computedStyle.zIndex; - var reasonValue = includeReason - ? function (text) { - return text; - } - : function () { - return true; - }; - - if (position === "fixed" || position === "sticky") { - return reasonValue("position: " + position); - } - - if (computedStyle.containerType === "size" || computedStyle.containerType === "inline-size") { - return reasonValue("container-type: " + computedStyle.containerType); - } - - if (zIndexStr !== "auto" && position !== "static") { - return reasonValue("position: " + position + "; z-index: " + zIndexStr); - } - - if (parseFloat(computedStyle.opacity) < 1) { - return reasonValue("opacity: " + computedStyle.opacity); - } - - if (computedStyle.transform !== "none") { - return reasonValue("transform: " + computedStyle.transform); - } - - if (_matchesProp(computedStyle, "scale")) { - return reasonValue("scale: " + computedStyle.scale); - } - - if (_matchesProp(computedStyle, "rotate")) { - return reasonValue("rotate: " + computedStyle.rotate); - } - - if (_matchesProp(computedStyle, "translate")) { - return reasonValue("translate: " + computedStyle.translate); - } - - if (computedStyle.mixBlendMode !== "normal") { - return reasonValue("mix-blend-mode: " + computedStyle.mixBlendMode); - } - - if (computedStyle.filter !== "none") { - return reasonValue("filter: " + computedStyle.filter); - } - - if (_matchesProp(computedStyle, "backdropFilter")) { - return reasonValue("backdrop-filter: " + computedStyle.backdropFilter); - } - - if (_matchesProp(computedStyle, "webkitBackdropFilter")) { - return reasonValue("-webkit-backdrop-filter: " + computedStyle.webkitBackdropFilter); - } - - if (computedStyle.perspective !== "none") { - return reasonValue("perspective: " + computedStyle.perspective); - } - - if (_matchesProp(computedStyle, "clipPath")) { - return reasonValue("clip-path: " + computedStyle.clipPath); - } - - var mask = computedStyle.mask || computedStyle.webkitMask; - if (typeof mask !== "undefined" && mask !== "none") { - return reasonValue("mask: " + mask); - } - - var maskImage = computedStyle.maskImage || computedStyle.webkitMaskImage; - if (typeof maskImage !== "undefined" && maskImage !== "none") { - return reasonValue("mask-image: " + maskImage); - } - - var maskBorder = computedStyle.maskBorder || computedStyle.webkitMaskBorder; - if (typeof maskBorder !== "undefined" && maskBorder !== "none") { - return reasonValue("mask-border: " + maskBorder); - } - - if (computedStyle.isolation === "isolate") { - return reasonValue("isolation: isolate"); - } - - var willChange = computedStyle.willChange || ""; - if (willChange.indexOf("transform") !== -1 || willChange.indexOf("opacity") !== -1) { - return reasonValue("will-change: " + willChange); - } - - if (computedStyle.webkitOverflowScrolling === "touch") { - return reasonValue("-webkit-overflow-scrolling: touch"); - } - - if (zIndexStr !== "auto") { - var parentNode = getParentNode(node); - if (parentNode instanceof Element) { - var parentStyle = lib.getComputedStyle(parentNode); - if (_isFlexContainer(parentStyle)) { - return reasonValue("flex-item; z-index: " + zIndexStr); - } - if (_isGridContainer(parentStyle)) { - return reasonValue("grid-item; z-index: " + zIndexStr); - } - } - } - - if (_hasContainStackingContext(computedStyle.contain || "")) { - return reasonValue("contain: " + computedStyle.contain); - } - - return null; -} - -function _createsStackingContext(node, computedStyle) { - return Boolean(_stackingContextReason(node, computedStyle, false)); -} - -function _getClosestStackingContext(node, includeReason) { - if (!node || node.nodeName === "HTML") { - return includeReason ? { node: document.documentElement, reason: "root" } : document.documentElement; - } - - if (node.nodeName === "#document-fragment") { - return _getClosestStackingContext(node.host, includeReason); - } - - if (!(node instanceof Element)) { - return _getClosestStackingContext(getParentNode(node), includeReason); - } - - var computedStyle = lib.getComputedStyle(node); - var reason = _stackingContextReason(node, computedStyle, includeReason); - - if (reason) { - return includeReason ? { node: node, reason: reason } : node; - } - - return _getClosestStackingContext(getParentNode(node), includeReason); -} - -function _getStackingContextRoot(element, includeReason) { - return _getClosestStackingContext(getParentNode(element), includeReason); -} - -function _getEffectiveZIndex(element) { - var curr = element; - while (curr && curr !== document.documentElement) { - var style = lib.getComputedStyle(curr); - var zIndexStr = style.zIndex; - var createsStackingContext = _createsStackingContext(curr, style); - - if (zIndexStr !== "auto") { - var num = parseFloat(zIndexStr); - - return isNaN(num) ? 0 : num; - } - - if (createsStackingContext) { - // z-index is auto but this is the root of current stacking context, so treat as 0 - return 0; - } - - curr = curr.parentElement; - } - - return 0; -} - -exports.buildZChain = function buildZChain(element, opts) { - opts = opts || {}; - // includeReasons is useful for debugging only, but should not cause overhead in production - var includeReasons = Boolean(opts.includeReasons); - var chain = []; - var curr = element; - - while (curr && curr !== document.documentElement) { - var context = _getStackingContextRoot(curr, includeReasons); - var ctx = includeReasons ? context.node : context; - var z = _getEffectiveZIndex(curr); - - var chainItem = { ctx: ctx, z: z }; - if (includeReasons) { - chainItem.reason = context.reason; - } - chain.unshift(chainItem); - - if (ctx === document.documentElement) { - break; - } - curr = ctx; - } - - return chain; -}; - -exports.isChainBehind = function isChainBehind(candChain, targetChain) { - // Algorithm: - // 1. Find the closest common stacking context between two chains - // 2. Compare z-indices of elements in the common stacking context - // If there's no common stacking context, we can't be sure, so treat as overlapping - for (var j = targetChain.length - 1; j >= 0; j--) { - for (var i = candChain.length - 1; i >= 0; i--) { - if (candChain[i].ctx === targetChain[j].ctx) { - return candChain[i].z < targetChain[j].z; - } - } - } - - return false; -}; diff --git a/src/browser/client-scripts/xpath.js b/src/browser/client-scripts/xpath.js deleted file mode 100644 index b88ce31f5..000000000 --- a/src/browser/client-scripts/xpath.js +++ /dev/null @@ -1,31 +0,0 @@ -"use strict"; - -var XPATH_SELECTORS_START = ["/", "(", "../", "./", "*/"]; - -function isXpathSelector(selector) { - return XPATH_SELECTORS_START.some(function (startString) { - return selector.indexOf(startString) === 0; - }); -} - -function queryFirst(selector) { - return document.evaluate(selector, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; -} - -function queryAll(selector) { - var elements = document.evaluate(selector, document, null, XPathResult.ANY_TYPE, null); - var node, - nodes = []; - node = elements.iterateNext(); - while (node) { - nodes.push(node); - node = elements.iterateNext(); - } - return nodes; -} - -module.exports = { - isXpathSelector: isXpathSelector, - queryFirst: queryFirst, - queryAll: queryAll -}; diff --git a/src/browser/isomorphic/geometry.ts b/src/browser/isomorphic/geometry.ts index d4040960d..fc6b9a216 100644 --- a/src/browser/isomorphic/geometry.ts +++ b/src/browser/isomorphic/geometry.ts @@ -54,8 +54,16 @@ export const equalsSize = >(a: T, b: T): boolean => { return a.width === b.width && a.height === b.height; }; +export const prettyPoint = >(point: T): string => { + return `{ left: ${point.left}, top: ${point.top} }`; +}; + export const prettySize = >(size: T): string => { - return `${size.width} x ${size.height} (width x height)`; + return `{ width: ${size.width}, height: ${size.height} }`; +}; + +export const prettyRect = >(rect: T): string => { + return `{ left: ${rect.left}, top: ${rect.top}, width: ${rect.width}, height: ${rect.height} }`; }; export const intersectYBands = >(a: T | null, b: T | null): T | null => { diff --git a/src/browser/isomorphic/index.ts b/src/browser/isomorphic/index.ts index fa882b436..48375ec19 100644 --- a/src/browser/isomorphic/index.ts +++ b/src/browser/isomorphic/index.ts @@ -1,3 +1,5 @@ export * from "./assign"; export * from "./geometry"; + +export * from "./types"; diff --git a/src/browser/isomorphic/tsconfig.json b/src/browser/isomorphic/tsconfig.json new file mode 100644 index 000000000..fe7f3e52d --- /dev/null +++ b/src/browser/isomorphic/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.common.json", + "include": ["."], + "compilerOptions": { + "composite": true, + "declaration": true, + } +} \ No newline at end of file diff --git a/src/browser/isomorphic/types.ts b/src/browser/isomorphic/types.ts new file mode 100644 index 000000000..18a7a1fd8 --- /dev/null +++ b/src/browser/isomorphic/types.ts @@ -0,0 +1,20 @@ +export enum DisableHoverMode { + Always = "always", + WhenScrollingNeeded = "when-scrolling-needed", + Never = "never", +} + +export enum BrowserSideErrorCode { + JS = "JS", + OUTSIDE_OF_VIEWPORT = "OUTSIDE_OF_VIEWPORT", +} + +export interface BrowserSideError { + errorCode: BrowserSideErrorCode; + message: string; + debugLog?: string; +} + +export const isBrowserSideError = (error: unknown): error is BrowserSideError => { + return Boolean(error && (error as BrowserSideError).errorCode && (error as BrowserSideError).message); +}; diff --git a/src/config/defaults.js b/src/config/defaults.js index b54777f30..e831888c1 100644 --- a/src/config/defaults.js +++ b/src/config/defaults.js @@ -1,7 +1,9 @@ "use strict"; +const { DisableHoverMode } = require("../browser/isomorphic"); const { WEBDRIVER_PROTOCOL, SAVE_HISTORY_MODE, NODEJS_TEST_RUN_ENV } = require("../constants/config"); -const { TimeTravelMode, DisableHoverMode } = require("./types"); +const { TimeTravelMode } = require("./types"); + module.exports = { baseUrl: "http://localhost", diff --git a/src/config/types.ts b/src/config/types.ts index c1765f1ed..8c15ffe10 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -5,6 +5,7 @@ import type { ChildProcessWithoutNullStreams } from "child_process"; import type { RequestOptions } from "https"; import type { Config } from "./index"; import type { SelectivityCompressionType } from "../browser/cdp/selectivity/types"; +import { DisableHoverMode } from "../browser/isomorphic/types"; export interface CompareOptsConfig { shouldCluster: boolean; @@ -17,12 +18,6 @@ export interface BuildDiffOptsConfig { ignoreCaret: boolean; } -export enum DisableHoverMode { - Always = "always", - WhenScrollingNeeded = "when-scrolling-needed", - Never = "never", -} - export interface AssertViewOpts { /** * DOM-node selectors which will be ignored (painted with a black rectangle) when comparing images. diff --git a/src/runner/browser-env/vite/browser-modules/tsconfig.json b/src/runner/browser-env/vite/browser-modules/tsconfig.json index 6abd56434..7d0824b4e 100644 --- a/src/runner/browser-env/vite/browser-modules/tsconfig.json +++ b/src/runner/browser-env/vite/browser-modules/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../../../../tsconfig.common.json", "include": ["."], "compilerOptions": { + "rootDir": ".", "outDir": "../../../../../build/src/runner/browser-env/vite/browser-modules", "composite": true, diff --git a/test/src/browser/client-scripts/rect.js b/test/src/browser/client-scripts/rect.js deleted file mode 100644 index 585064843..000000000 --- a/test/src/browser/client-scripts/rect.js +++ /dev/null @@ -1,313 +0,0 @@ -"use strict"; - -const Rect = require("src/browser/client-scripts/rect").Rect; - -describe("Rect", () => { - const rect = new Rect({ - left: 20, - top: 10, - width: 100, - height: 100, - }); - - describe("constructor", () => { - it("should create instance using width/height properties", () => { - assert.doesNotThrow(() => { - return new Rect({ - left: 20, - top: 10, - width: 100, - height: 100, - }); - }); - }); - - it("should create instance using bottom/right properties", () => { - assert.doesNotThrow(() => { - return new Rect({ - top: 10, - left: 20, - bottom: 100, - right: 100, - }); - }); - }); - - it("should fail when there are no bottom/right or width/height properties", () => { - assert.throws(() => { - return new Rect({ top: 10, left: 20 }); - }); - }); - }); - - describe("isRect", () => { - it("should return false if argument is not an object", () => { - assert.isFalse(Rect.isRect("foo")); - }); - - it("should return false if argument is null", () => { - assert.isFalse(Rect.isRect(null)); - }); - - it("should return false if argument is array", () => { - assert.isFalse(Rect.isRect([])); - }); - - it("should return false if argument has no left property", () => { - assert.isFalse( - Rect.isRect({ - top: 1, - width: 1, - height: 1, - }), - ); - }); - - it("should return false if argument has no top property", () => { - assert.isFalse( - Rect.isRect({ - left: 1, - width: 1, - height: 1, - }), - ); - }); - - it("should return false if argument has no width property, but has height", () => { - assert.isFalse( - Rect.isRect({ - left: 1, - top: 1, - height: 1, - }), - ); - }); - - it("should return false if argument has no height property, but has width", () => { - assert.isFalse( - Rect.isRect({ - left: 1, - top: 1, - width: 1, - }), - ); - }); - - it("should return false if argument has no right property, but has bottom", () => { - assert.isFalse( - Rect.isRect({ - left: 1, - top: 1, - bottom: 1, - }), - ); - }); - - it("should return false if argument has no bottom property, but has right", () => { - assert.isFalse( - Rect.isRect({ - left: 1, - top: 1, - right: 1, - }), - ); - }); - - it("should return true if argument has left, top, width, height properties", () => { - assert.isTrue( - Rect.isRect({ - left: 1, - top: 1, - width: 1, - height: 1, - }), - ); - }); - - it("should return true if argument has left, top, right, bottom properties", () => { - assert.isTrue( - Rect.isRect({ - left: 1, - top: 1, - right: 1, - bottom: 1, - }), - ); - }); - }); - - describe("rectInside", () => { - it("should return true when rect is inside", () => { - assert.isTrue( - rect.rectInside( - new Rect({ - left: rect.left + 10, - top: rect.top + 10, - width: rect.width - 50, - height: rect.height - 50, - }), - ), - ); - }); - - it("should return false when rect is not inside", () => { - assert.isFalse( - rect.rectInside( - new Rect({ - left: rect.left - 5, - top: rect.top - 5, - width: rect.width, - height: rect.width, - }), - ), - ); - }); - - it("should return false when rect intersects on top-left", () => { - assert.isFalse( - rect.rectInside( - new Rect({ - left: rect.left - 5, - top: rect.top + 5, - width: rect.width + 5, - height: rect.height - 5, - }), - ), - ); - }); - - it("should return false when rect intersects on bottom-right", () => { - assert.isFalse( - new Rect({ - left: rect.left - 5, - top: rect.top + 5, - width: rect.width + 5, - height: rect.height - 5, - }).rectInside(rect), - ); - }); - }); - - describe("rectIntersects", () => { - describe("should return true when rect", () => { - it("intersects on left side", () => { - assert.isTrue( - rect.rectIntersects( - new Rect({ - left: rect.left - 5, - top: rect.top + 5, - width: rect.width - 5, - height: rect.height - 5, - }), - ), - ); - }); - - it("intersects on top side", () => { - assert.isTrue( - rect.rectIntersects( - new Rect({ - left: rect.left + 5, - top: rect.top + 5, - width: rect.width - 5, - height: rect.height + 5, - }), - ), - ); - }); - - it("intersects on right side", () => { - assert.isTrue( - rect.rectIntersects( - new Rect({ - left: rect.left + 5, - top: rect.top + 5, - width: rect.width + 5, - height: rect.height - 5, - }), - ), - ); - }); - - it("intersects on bottom side", () => { - assert.isTrue( - rect.rectIntersects( - new Rect({ - left: rect.left + 5, - top: rect.top - 5, - width: rect.width - 5, - height: rect.height - 5, - }), - ), - ); - }); - - it("intersects on left and right sides", () => { - assert.isTrue( - rect.rectIntersects( - new Rect({ - left: rect.left - 5, - top: rect.top + 5, - width: rect.width + 5, - height: rect.height - 5, - }), - ), - ); - }); - }); - - describe("should return false when rect is near on the", () => { - it("top", () => { - assert.isFalse( - rect.rectIntersects( - new Rect({ - left: rect.left + 1, - top: rect.top - 1, - width: 1, - height: 1, - }), - ), - ); - }); - - it("left", () => { - assert.isFalse( - rect.rectIntersects( - new Rect({ - left: rect.left - 1, - top: rect.top + 1, - width: 1, - height: 1, - }), - ), - ); - }); - - it("bottom", () => { - assert.isFalse( - rect.rectIntersects( - new Rect({ - left: rect.left + 1, - top: rect.top + rect.height, - width: 1, - height: 1, - }), - ), - ); - }); - - it("right", () => { - assert.isFalse( - rect.rectIntersects( - new Rect({ - left: rect.left + rect.width, - top: rect.top + 1, - width: 1, - height: 1, - }), - ), - ); - }); - }); - }); -}); diff --git a/test/tsconfig.json b/test/tsconfig.json index 5288823fb..3ccc84cf3 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -6,7 +6,8 @@ "baseUrl": "..", "noEmit": true, "paths": { - "src/*": ["src/*"] + "src/*": ["src/*"], + "@isomorphic": ["src/browser/isomorphic/index.ts"] } } } diff --git a/tsconfig.common.json b/tsconfig.common.json index 0a0717589..ec21578c6 100644 --- a/tsconfig.common.json +++ b/tsconfig.common.json @@ -1,6 +1,7 @@ { - "include": ["src"], "compilerOptions": { + "rootDir": ".", + "outDir": "build", "lib": ["DOM", "DOM.Iterable", "es2022"], "allowJs": true, "declaration": true, diff --git a/tsconfig.json b/tsconfig.json index 24406c2df..0fdad425e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,9 +12,13 @@ "compilerOptions": { "outDir": "build" }, - "references": [ - { - "path": "./src/runner/browser-env/vite/browser-modules/tsconfig.json" - } - ] + "references": [{ + "path": "./src/runner/browser-env/vite/browser-modules/tsconfig.json" + }, { + "path": "./src/browser/client-scripts/screen-shooter/tsconfig.json" + }, { + "path": "./src/browser/client-scripts/browser-utils/tsconfig.json" + }, { + "path": "./src/browser/isomorphic/tsconfig.json" + }] }