Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
},
}
);
65 changes: 2 additions & 63 deletions src/widget/capture-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
13 changes: 13 additions & 0 deletions src/widget/capture-timeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const CAPTURE_TIMEOUT_MS = 15_000;

export function withCaptureTimeout<T>(capturePromise: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const timeoutPromise = new Promise<never>((_, 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!));
}
65 changes: 65 additions & 0 deletions src/widget/element-context.ts
Original file line number Diff line number Diff line change
@@ -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;
}
143 changes: 2 additions & 141 deletions src/widget/screenshot.ts
Original file line number Diff line number Diff line change
@@ -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==';
Expand All @@ -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<string>;
__bugdropMockToPng?: typeof htmlToImage.toPng;
}
}
Expand Down Expand Up @@ -79,137 +71,6 @@ export function canCaptureViewportNatively(): boolean {
return isSecureOrigin && hasCaptureApi;
}

export function beginViewportCapture(): Promise<string> {
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<string> {
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<void> {
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<void>(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<void>((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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

function withCaptureTimeout<T>(capturePromise: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const timeoutPromise = new Promise<never>((_, 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,
Expand Down
Loading