diff --git a/eslint.config.js b/eslint.config.js index d24c885..39039e1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -46,8 +46,8 @@ export default tseslint.config( 'src/routes/api.ts', ], rules: { - 'max-lines': ['warn', { max: 300, skipBlankLines: true, skipComments: true }], - 'max-lines-per-function': ['warn', { max: 150, skipBlankLines: true, skipComments: true }], + 'max-lines': ['error', { max: 300, skipBlankLines: true, skipComments: true }], + 'max-lines-per-function': ['error', { max: 150, skipBlankLines: true, skipComments: true }], }, } ); diff --git a/src/widget/capture-flow.ts b/src/widget/capture-flow.ts index a556401..edd1e27 100644 --- a/src/widget/capture-flow.ts +++ b/src/widget/capture-flow.ts @@ -5,15 +5,12 @@ import { capturePromiseWithLoading, captureWithLoading, } from './capture-loading'; +import { getElementContextCaptureTarget } from './element-context'; import { createElementPicker } from './picker'; import { getElementSelector, getFullElementSelector } from './selector-metadata'; import { beginViewportCapture, getRedactionCount, isFullPageDisabled } from './screenshot'; import { showScreenshotOptions, type ScreenshotChoice } from './screenshot-options'; -import { - DEFAULT_SELECTED_ELEMENT_CONTEXT_MIN_PADDING_PX, - DEFAULT_SELECTED_ELEMENT_SCREENSHOT_PIXEL_RATIO, - resolveSelectedElementContextMaxArea, -} from '../defaults'; +import { DEFAULT_SELECTED_ELEMENT_SCREENSHOT_PIXEL_RATIO } from '../defaults'; export interface CaptureFlowConfig { screenshotMode: 'optional' | 'auto' | 'required'; @@ -322,61 +319,3 @@ function emptyElementMetadata(): ElementMetadata { function assertNever(value: never): never { throw new Error(`Unhandled screenshot choice: ${JSON.stringify(value)}`); } - -interface ElementContextOptions { - maxViewportAreaMultiplier?: number; -} - -function getElementContextCaptureTarget(element: Element, options: ElementContextOptions): Element { - const selectedRect = element.getBoundingClientRect(); - if (!isUsableRect(selectedRect)) return element; - - const viewportArea = Math.max(1, window.innerWidth * window.innerHeight); - const maxContextArea = - viewportArea * resolveSelectedElementContextMaxArea(options.maxViewportAreaMultiplier); - - let best: Element = element; - let current = element.parentElement; - - while (current && current !== document.body && current !== document.documentElement) { - const rect = current.getBoundingClientRect(); - const area = rect.width * rect.height; - - if ( - isUsableRect(rect) && - area <= maxContextArea && - containsRect(rect, selectedRect) && - hasUsefulContext(rect, selectedRect) - ) { - best = current; - } - - current = current.parentElement; - } - - return best; -} - -function isUsableRect(rect: DOMRect): boolean { - return rect.width > 0 && rect.height > 0; -} - -function containsRect(candidate: DOMRect, selected: DOMRect): boolean { - return ( - candidate.left <= selected.left && - candidate.top <= selected.top && - candidate.right >= selected.right && - candidate.bottom >= selected.bottom - ); -} - -function hasUsefulContext(candidate: DOMRect, selected: DOMRect): boolean { - const horizontalContext = - candidate.width >= selected.width + DEFAULT_SELECTED_ELEMENT_CONTEXT_MIN_PADDING_PX * 2; - const verticalContext = - candidate.height >= selected.height + DEFAULT_SELECTED_ELEMENT_CONTEXT_MIN_PADDING_PX * 2; - const selectedArea = selected.width * selected.height; - const candidateArea = candidate.width * candidate.height; - - return horizontalContext || verticalContext || candidateArea >= selectedArea * 4; -} diff --git a/src/widget/capture-timeout.ts b/src/widget/capture-timeout.ts new file mode 100644 index 0000000..ed61841 --- /dev/null +++ b/src/widget/capture-timeout.ts @@ -0,0 +1,13 @@ +const CAPTURE_TIMEOUT_MS = 15_000; + +export function withCaptureTimeout(capturePromise: Promise): Promise { + let timer: ReturnType; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('Screenshot capture timed out — the page may be too complex')), + CAPTURE_TIMEOUT_MS + ); + }); + + return Promise.race([capturePromise, timeoutPromise]).finally(() => clearTimeout(timer!)); +} diff --git a/src/widget/element-context.ts b/src/widget/element-context.ts new file mode 100644 index 0000000..9c80fd1 --- /dev/null +++ b/src/widget/element-context.ts @@ -0,0 +1,65 @@ +import { + DEFAULT_SELECTED_ELEMENT_CONTEXT_MIN_PADDING_PX, + resolveSelectedElementContextMaxArea, +} from '../defaults'; + +interface ElementContextOptions { + maxViewportAreaMultiplier?: number; +} + +export function getElementContextCaptureTarget( + element: Element, + options: ElementContextOptions +): Element { + const selectedRect = element.getBoundingClientRect(); + if (!isUsableRect(selectedRect)) return element; + + const viewportArea = Math.max(1, window.innerWidth * window.innerHeight); + const maxContextArea = + viewportArea * resolveSelectedElementContextMaxArea(options.maxViewportAreaMultiplier); + + let best: Element = element; + let current = element.parentElement; + + while (current && current !== document.body && current !== document.documentElement) { + const rect = current.getBoundingClientRect(); + const area = rect.width * rect.height; + + if ( + isUsableRect(rect) && + area <= maxContextArea && + containsRect(rect, selectedRect) && + hasUsefulContext(rect, selectedRect) + ) { + best = current; + } + + current = current.parentElement; + } + + return best; +} + +function isUsableRect(rect: DOMRect): boolean { + return rect.width > 0 && rect.height > 0; +} + +function containsRect(candidate: DOMRect, selected: DOMRect): boolean { + return ( + candidate.left <= selected.left && + candidate.top <= selected.top && + candidate.right >= selected.right && + candidate.bottom >= selected.bottom + ); +} + +function hasUsefulContext(candidate: DOMRect, selected: DOMRect): boolean { + const horizontalContext = + candidate.width >= selected.width + DEFAULT_SELECTED_ELEMENT_CONTEXT_MIN_PADDING_PX * 2; + const verticalContext = + candidate.height >= selected.height + DEFAULT_SELECTED_ELEMENT_CONTEXT_MIN_PADDING_PX * 2; + const selectedArea = selected.width * selected.height; + const candidateArea = candidate.width * candidate.height; + + return horizontalContext || verticalContext || candidateArea >= selectedArea * 4; +} diff --git a/src/widget/screenshot.ts b/src/widget/screenshot.ts index b079b1a..a5535e1 100644 --- a/src/widget/screenshot.ts +++ b/src/widget/screenshot.ts @@ -1,11 +1,12 @@ import * as htmlToImage from 'html-to-image'; import type { Options as HtmlToImageOptions } from 'html-to-image/lib/types'; +import { withCaptureTimeout } from './capture-timeout'; import { applyMaskToImage, countMaskRects, createRedactionPlan } from './mask'; import { resolveAccentColor } from '../defaults'; +export { beginViewportCapture } from './viewport-capture'; declare const __BUGDROP_ENABLE_TEST_HOOKS__: boolean; -const CAPTURE_TIMEOUT_MS = 15_000; const DOM_COMPLEXITY_THRESHOLD = 3_000; const TRANSPARENT_IMAGE_PLACEHOLDER = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='; @@ -22,17 +23,8 @@ export interface CaptureScreenshotOptions { pixelRatio?: number; } -type DisplayMediaOptionsWithCurrentTab = DisplayMediaStreamOptions & { - preferCurrentTab?: boolean; -}; - -type VideoElementWithFrameCallback = HTMLVideoElement & { - requestVideoFrameCallback?: (callback: () => void) => number; -}; - declare global { interface Window { - __bugdropMockViewportCapture?: () => Promise; __bugdropMockToPng?: typeof htmlToImage.toPng; } } @@ -79,137 +71,6 @@ export function canCaptureViewportNatively(): boolean { return isSecureOrigin && hasCaptureApi; } -export function beginViewportCapture(): Promise { - if (window.__bugdropMockViewportCapture) { - return window.__bugdropMockViewportCapture(); - } - - if (!navigator.mediaDevices?.getDisplayMedia) { - return Promise.reject(new Error('Screen Capture API is not available')); - } - - const displayMediaOptions: DisplayMediaOptionsWithCurrentTab = { - video: { displaySurface: 'browser' }, - audio: false, - preferCurrentTab: true, - }; - - return withCaptureTimeout( - navigator.mediaDevices.getDisplayMedia(displayMediaOptions).then(stream => { - return captureVideoFrame(stream); - }) - ); -} - -async function captureVideoFrame(stream: MediaStream): Promise { - validateBrowserSurface(stream); - - const video = document.createElement('video') as VideoElementWithFrameCallback; - video.muted = true; - video.playsInline = true; - - try { - await waitForVideoFrame(video, stream); - - const width = video.videoWidth || window.innerWidth; - const height = video.videoHeight || window.innerHeight; - if (!width || !height) { - throw new Error('Screen capture stream did not provide a video frame'); - } - - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - - const ctx = canvas.getContext('2d'); - if (!ctx) { - throw new Error('Failed to get canvas context'); - } - - ctx.drawImage(video, 0, 0, width, height); - return canvas.toDataURL('image/png'); - } finally { - for (const track of stream.getTracks()) { - track.stop(); - } - video.srcObject = null; - } -} - -function validateBrowserSurface(stream: MediaStream): void { - const [track] = stream.getVideoTracks(); - const displaySurface = track?.getSettings().displaySurface; - if (displaySurface && displaySurface !== 'browser') { - for (const streamTrack of stream.getTracks()) { - streamTrack.stop(); - } - throw new Error('Please choose the current browser tab for viewport capture'); - } -} - -async function waitForVideoFrame( - video: VideoElementWithFrameCallback, - stream: MediaStream -): Promise { - video.srcObject = stream; - await video.play().catch(() => { - // Some browsers expose the first frame after metadata without requiring play(). - }); - - if (video.requestVideoFrameCallback) { - await Promise.race([ - new Promise(resolve => video.requestVideoFrameCallback?.(() => resolve())), - delay(250), - ]); - if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { - return; - } - } - - if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { - return; - } - - await Promise.race([ - new Promise((resolve, reject) => { - const onReady = () => { - cleanup(); - resolve(); - }; - const onError = () => { - cleanup(); - reject(new Error('Failed to load screen capture stream')); - }; - const cleanup = () => { - video.removeEventListener('loadeddata', onReady); - video.removeEventListener('canplay', onReady); - video.removeEventListener('error', onError); - }; - - video.addEventListener('loadeddata', onReady); - video.addEventListener('canplay', onReady); - video.addEventListener('error', onError); - }), - delay(250), - ]); -} - -function delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -function withCaptureTimeout(capturePromise: Promise): Promise { - let timer: ReturnType; - const timeoutPromise = new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error('Screenshot capture timed out — the page may be too complex')), - CAPTURE_TIMEOUT_MS - ); - }); - - return Promise.race([capturePromise, timeoutPromise]).finally(() => clearTimeout(timer!)); -} - export async function captureScreenshot( element?: Element, screenshotScale?: number, diff --git a/src/widget/viewport-capture.ts b/src/widget/viewport-capture.ts new file mode 100644 index 0000000..27b849f --- /dev/null +++ b/src/widget/viewport-capture.ts @@ -0,0 +1,134 @@ +import { withCaptureTimeout } from './capture-timeout'; + +type DisplayMediaOptionsWithCurrentTab = DisplayMediaStreamOptions & { + preferCurrentTab?: boolean; +}; + +type VideoElementWithFrameCallback = HTMLVideoElement & { + requestVideoFrameCallback?: (callback: () => void) => number; +}; + +declare global { + interface Window { + __bugdropMockViewportCapture?: () => Promise; + } +} + +export function beginViewportCapture(): Promise { + if (window.__bugdropMockViewportCapture) { + return window.__bugdropMockViewportCapture(); + } + + if (!navigator.mediaDevices?.getDisplayMedia) { + return Promise.reject(new Error('Screen Capture API is not available')); + } + + const displayMediaOptions: DisplayMediaOptionsWithCurrentTab = { + video: { displaySurface: 'browser' }, + audio: false, + preferCurrentTab: true, + }; + + return withCaptureTimeout( + navigator.mediaDevices.getDisplayMedia(displayMediaOptions).then(stream => { + return captureVideoFrame(stream); + }) + ); +} + +async function captureVideoFrame(stream: MediaStream): Promise { + validateBrowserSurface(stream); + + const video = document.createElement('video') as VideoElementWithFrameCallback; + video.muted = true; + video.playsInline = true; + + try { + await waitForVideoFrame(video, stream); + + const width = video.videoWidth || window.innerWidth; + const height = video.videoHeight || window.innerHeight; + if (!width || !height) { + throw new Error('Screen capture stream did not provide a video frame'); + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + + ctx.drawImage(video, 0, 0, width, height); + return canvas.toDataURL('image/png'); + } finally { + for (const track of stream.getTracks()) { + track.stop(); + } + video.srcObject = null; + } +} + +function validateBrowserSurface(stream: MediaStream): void { + const [track] = stream.getVideoTracks(); + const displaySurface = track?.getSettings().displaySurface; + if (displaySurface && displaySurface !== 'browser') { + for (const streamTrack of stream.getTracks()) { + streamTrack.stop(); + } + throw new Error('Please choose the current browser tab for viewport capture'); + } +} + +async function waitForVideoFrame( + video: VideoElementWithFrameCallback, + stream: MediaStream +): Promise { + video.srcObject = stream; + await video.play().catch(() => { + // Some browsers expose the first frame after metadata without requiring play(). + }); + + if (video.requestVideoFrameCallback) { + await Promise.race([ + new Promise(resolve => video.requestVideoFrameCallback?.(() => resolve())), + delay(250), + ]); + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + } + + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + + await Promise.race([ + new Promise((resolve, reject) => { + const onReady = () => { + cleanup(); + resolve(); + }; + const onError = () => { + cleanup(); + reject(new Error('Failed to load screen capture stream')); + }; + const cleanup = () => { + video.removeEventListener('loadeddata', onReady); + video.removeEventListener('canplay', onReady); + video.removeEventListener('error', onError); + }; + + video.addEventListener('loadeddata', onReady); + video.addEventListener('canplay', onReady); + video.addEventListener('error', onError); + }), + delay(250), + ]); +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +}