Skip to content

Commit 7308430

Browse files
authored
Merge pull request #145 from mean-weasel/fix/screen-capture-api-108
Add native viewport capture for complex pages
2 parents 6d2ba84 + fb5433e commit 7308430

4 files changed

Lines changed: 472 additions & 31 deletions

File tree

e2e/widget.spec.ts

Lines changed: 189 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2202,6 +2202,17 @@ test.describe('Screenshot Crash Prevention (#67)', () => {
22022202
}`;
22032203
}
22042204

2205+
function mockGetDisplayMedia(page: Page, body: string) {
2206+
return page.addInitScript(`
2207+
Object.defineProperty(navigator, 'mediaDevices', {
2208+
value: {
2209+
getDisplayMedia: ${body}
2210+
},
2211+
configurable: true
2212+
});
2213+
`);
2214+
}
2215+
22052216
function reporterLikePng(
22062217
variant: 'preview-size' | 'small-wide-area' | 'annotation-style' | 'undo'
22072218
) {
@@ -2703,10 +2714,16 @@ test.describe('Screenshot Crash Prevention (#67)', () => {
27032714

27042715
// --- Full-page disable threshold (10k+ nodes) ---
27052716

2706-
test('hides Full Page and Select Area buttons on very complex pages (>10k nodes)', async ({
2717+
test('hides Full Page and Select Area buttons on very complex pages without native viewport capture', async ({
27072718
page,
27082719
}) => {
27092720
await mockHtmlToImage(page, spyToPng());
2721+
await page.addInitScript(() => {
2722+
Object.defineProperty(navigator, 'mediaDevices', {
2723+
value: {},
2724+
configurable: true,
2725+
});
2726+
});
27102727
await page.goto('/test/complex-dom.html?nodes=12000');
27112728

27122729
const nodeCount = await page.evaluate(() => document.body.querySelectorAll('*').length);
@@ -2726,6 +2743,177 @@ test.describe('Screenshot Crash Prevention (#67)', () => {
27262743
await expect(notice).toBeVisible();
27272744
});
27282745

2746+
test('offers native viewport capture on very complex secure-context pages', async ({ page }) => {
2747+
const payloads = await trackFeedbackPayloads(page);
2748+
await mockHtmlToImage(
2749+
page,
2750+
"function() { throw new Error('html-to-image should not run for viewport capture'); }"
2751+
);
2752+
await mockGetDisplayMedia(
2753+
page,
2754+
`function(opts) {
2755+
window.__viewportCaptureCalls = (window.__viewportCaptureCalls || 0) + 1;
2756+
window.__viewportCaptureUserActivation = navigator.userActivation.isActive;
2757+
window.__viewportCaptureOpts = opts;
2758+
var canvas = document.createElement('canvas');
2759+
canvas.width = 960;
2760+
canvas.height = 540;
2761+
var ctx = canvas.getContext('2d');
2762+
ctx.fillStyle = '#101827';
2763+
ctx.fillRect(0, 0, canvas.width, canvas.height);
2764+
ctx.fillStyle = '#22d3ee';
2765+
ctx.fillRect(40, 40, 880, 120);
2766+
ctx.fillStyle = '#ffffff';
2767+
ctx.font = 'bold 42px sans-serif';
2768+
ctx.fillText('Native viewport capture', 80, 115);
2769+
var stream = canvas.captureStream();
2770+
var track = stream.getVideoTracks()[0];
2771+
var originalStop = track.stop.bind(track);
2772+
track.getSettings = function() { return { displaySurface: 'browser' }; };
2773+
track.stop = function() {
2774+
window.__viewportTrackStops = (window.__viewportTrackStops || 0) + 1;
2775+
originalStop();
2776+
};
2777+
return Promise.resolve(stream);
2778+
}`
2779+
);
2780+
await page.goto('/test/complex-dom.html?nodes=12000');
2781+
2782+
const nodeCount = await page.evaluate(() => document.body.querySelectorAll('*').length);
2783+
expect(nodeCount).toBeGreaterThanOrEqual(10000);
2784+
2785+
const host = await navigateToScreenshotOptions(page);
2786+
2787+
const viewportBtn = host.locator('css=[data-action="viewport"]');
2788+
await expect(viewportBtn).toBeVisible();
2789+
await expect(viewportBtn).toHaveText('Capture Viewport');
2790+
await expect(host.locator('css=[data-action="capture"]')).not.toBeAttached();
2791+
await expect(host.locator('css=[data-action="area"]')).not.toBeAttached();
2792+
await expect(host.locator('css=p >> text=visible viewport')).toBeVisible();
2793+
2794+
await viewportBtn.click();
2795+
2796+
await expect(host.locator('css=.bd-modal--annotator')).toBeVisible({ timeout: 10000 });
2797+
await expect(host.locator('css=#annotation-canvas canvas')).toBeVisible();
2798+
await expect
2799+
.poll(() =>
2800+
page.evaluate(
2801+
() => (window as Window & { __viewportCaptureCalls?: number }).__viewportCaptureCalls || 0
2802+
)
2803+
)
2804+
.toBe(1);
2805+
await expect
2806+
.poll(() =>
2807+
page.evaluate(
2808+
() => (window as Window & { __viewportTrackStops?: number }).__viewportTrackStops || 0
2809+
)
2810+
)
2811+
.toBe(1);
2812+
const viewportCapture = await page.evaluate(() => {
2813+
const win = window as Window & {
2814+
__viewportCaptureOpts?: DisplayMediaStreamOptions & { preferCurrentTab?: boolean };
2815+
__viewportCaptureUserActivation?: boolean;
2816+
};
2817+
return {
2818+
opts: win.__viewportCaptureOpts,
2819+
userActivation: win.__viewportCaptureUserActivation,
2820+
};
2821+
});
2822+
expect(viewportCapture.userActivation).toBe(true);
2823+
expect(viewportCapture.opts).toEqual({
2824+
video: { displaySurface: 'browser' },
2825+
audio: false,
2826+
preferCurrentTab: true,
2827+
});
2828+
const captureOpts = await page.evaluate(
2829+
() => (window as Window & { __captureOpts?: unknown }).__captureOpts
2830+
);
2831+
expect(captureOpts).toBeUndefined();
2832+
2833+
await host.locator('css=[data-action="done"]').click();
2834+
await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 10000 });
2835+
expect(payloads).toHaveLength(1);
2836+
expect(payloads[0].screenshot).toEqual(expect.stringMatching(/^data:image\/png;base64,/));
2837+
});
2838+
2839+
test('retries native viewport capture from the capture error modal', async ({ page }) => {
2840+
await mockGetDisplayMedia(
2841+
page,
2842+
`function() {
2843+
window.__viewportCaptureCalls = (window.__viewportCaptureCalls || 0) + 1;
2844+
if (window.__viewportCaptureCalls === 1) {
2845+
return Promise.reject(new Error('Permission denied'));
2846+
}
2847+
var canvas = document.createElement('canvas');
2848+
canvas.width = 2;
2849+
canvas.height = 2;
2850+
canvas.getContext('2d').fillRect(0, 0, 2, 2);
2851+
var stream = canvas.captureStream();
2852+
stream.getVideoTracks()[0].getSettings = function() { return { displaySurface: 'browser' }; };
2853+
return Promise.resolve(stream);
2854+
}`
2855+
);
2856+
await page.goto('/test/complex-dom.html?nodes=12000');
2857+
2858+
const host = await navigateToScreenshotOptions(page);
2859+
await host.locator('css=[data-action="viewport"]').click();
2860+
2861+
const errorText = host.locator('css=.bd-error-message__text');
2862+
await expect(errorText).toBeVisible({ timeout: 5000 });
2863+
await expect
2864+
.poll(() =>
2865+
page.evaluate(
2866+
() => (window as Window & { __viewportCaptureCalls?: number }).__viewportCaptureCalls || 0
2867+
)
2868+
)
2869+
.toBe(1);
2870+
2871+
await host.locator('css=[data-action="retry"]').click();
2872+
2873+
await expect(host.locator('css=.bd-modal--annotator')).toBeVisible({ timeout: 10000 });
2874+
await expect
2875+
.poll(() =>
2876+
page.evaluate(
2877+
() => (window as Window & { __viewportCaptureCalls?: number }).__viewportCaptureCalls || 0
2878+
)
2879+
)
2880+
.toBe(2);
2881+
});
2882+
2883+
test('rejects non-browser native capture surfaces before annotation', async ({ page }) => {
2884+
await mockGetDisplayMedia(
2885+
page,
2886+
`function() {
2887+
var canvas = document.createElement('canvas');
2888+
canvas.width = 2;
2889+
canvas.height = 2;
2890+
var stream = canvas.captureStream();
2891+
var track = stream.getVideoTracks()[0];
2892+
var originalStop = track.stop.bind(track);
2893+
track.getSettings = function() { return { displaySurface: 'monitor' }; };
2894+
track.stop = function() {
2895+
window.__viewportTrackStops = (window.__viewportTrackStops || 0) + 1;
2896+
originalStop();
2897+
};
2898+
return Promise.resolve(stream);
2899+
}`
2900+
);
2901+
await page.goto('/test/complex-dom.html?nodes=12000');
2902+
2903+
const host = await navigateToScreenshotOptions(page);
2904+
await host.locator('css=[data-action="viewport"]').click();
2905+
2906+
await expect(host.locator('css=.bd-error-message__text')).toBeVisible({ timeout: 5000 });
2907+
await expect(host.locator('css=.bd-modal--annotator')).not.toBeAttached();
2908+
await expect
2909+
.poll(() =>
2910+
page.evaluate(
2911+
() => (window as Window & { __viewportTrackStops?: number }).__viewportTrackStops || 0
2912+
)
2913+
)
2914+
.toBe(1);
2915+
});
2916+
27292917
test('remembers complex-page screenshot skip for issue #116 repeated reports', async ({
27302918
page,
27312919
}) => {

src/widget/index.ts

Lines changed: 73 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import {
2+
beginViewportCapture,
3+
canCaptureViewportNatively,
24
captureScreenshot,
35
cropScreenshot,
46
FULL_PAGE_DISABLE_THRESHOLD,
@@ -85,6 +87,14 @@ interface FeedbackData {
8587
email?: string;
8688
}
8789

90+
type ScreenshotChoice =
91+
| 'skip'
92+
| 'capture'
93+
| 'element'
94+
| 'area'
95+
| 'cancel'
96+
| { type: 'viewport'; capture: Promise<string> };
97+
8898
// localStorage key for dismissed state
8999
const BUGDROP_DISMISSED_KEY = 'bugdrop_dismissed';
90100
const BUGDROP_WELCOMED_PREFIX = 'bugdrop_welcomed_';
@@ -741,7 +751,23 @@ async function openFeedbackFlow(
741751
theme: config.theme,
742752
};
743753

744-
if (screenshotChoice === 'capture') {
754+
if (typeof screenshotChoice === 'object' && screenshotChoice.type === 'viewport') {
755+
const result = await capturePromiseWithLoading(
756+
root,
757+
screenshotChoice.capture,
758+
() => beginViewportCapture(),
759+
{
760+
allowSkip: !screenshotRequired,
761+
showLoading: false,
762+
}
763+
);
764+
if (result === 'cancel') {
765+
returnToForm = true;
766+
break;
767+
}
768+
if (!result && !screenshotRequired) rememberComplexScreenshotSkip(config, formResult);
769+
screenshot = result;
770+
} else if (screenshotChoice === 'capture') {
745771
const result = await captureWithLoading(root, undefined, config.screenshotScale, {
746772
allowSkip: !screenshotRequired,
747773
});
@@ -822,24 +848,41 @@ async function captureWithLoading(
822848
screenshotScale?: number,
823849
opts?: { allowSkip?: boolean }
824850
): Promise<string | null | 'cancel'> {
825-
// Show a temporary loading indicator
826-
const loadingModal = createModal(
851+
return capturePromiseWithLoading(
827852
root,
828-
'Capturing...',
829-
`
830-
<div style="display: flex; flex-direction: column; align-items: center; padding: 20px;">
831-
<div class="bd-spinner bd-spinner--lg"></div>
832-
<p class="bd-loading-text" style="margin-top: 12px;">Capturing screenshot...</p>
833-
</div>
834-
`
853+
captureScreenshot(element, screenshotScale),
854+
() => captureScreenshot(element, screenshotScale),
855+
opts
835856
);
857+
}
858+
859+
async function capturePromiseWithLoading(
860+
root: HTMLElement,
861+
capturePromise: Promise<string>,
862+
retryCapture: () => Promise<string>,
863+
opts?: { allowSkip?: boolean; showLoading?: boolean }
864+
): Promise<string | null | 'cancel'> {
865+
// Show a temporary loading indicator
866+
const loadingModal =
867+
opts?.showLoading === false
868+
? null
869+
: createModal(
870+
root,
871+
'Capturing...',
872+
`
873+
<div style="display: flex; flex-direction: column; align-items: center; padding: 20px;">
874+
<div class="bd-spinner bd-spinner--lg"></div>
875+
<p class="bd-loading-text" style="margin-top: 12px;">Capturing screenshot...</p>
876+
</div>
877+
`
878+
);
836879

837880
try {
838-
const screenshot = await captureScreenshot(element, screenshotScale);
839-
loadingModal.remove();
881+
const screenshot = await capturePromise;
882+
loadingModal?.remove();
840883
return screenshot;
841884
} catch (_error) {
842-
loadingModal.remove();
885+
loadingModal?.remove();
843886
const allowSkip = opts?.allowSkip !== false;
844887

845888
// Show error with retry option
@@ -878,7 +921,7 @@ async function captureWithLoading(
878921

879922
retryBtn?.addEventListener('click', async () => {
880923
errorModal.remove();
881-
const result = await captureWithLoading(root, element, screenshotScale, opts);
924+
const result = await capturePromiseWithLoading(root, retryCapture(), retryCapture, opts);
882925
resolve(result);
883926
});
884927
});
@@ -1182,14 +1225,20 @@ function escapeHtml(value: string): string {
11821225
function showScreenshotOptions(
11831226
root: HTMLElement,
11841227
opts?: { allowSkip?: boolean }
1185-
): Promise<'skip' | 'capture' | 'element' | 'area' | 'cancel'> {
1228+
): Promise<ScreenshotChoice> {
11861229
const fullPageDisabled = isFullPageDisabled();
1230+
const nativeViewportAvailable = fullPageDisabled && canCaptureViewportNatively();
11871231
const allowSkip = opts?.allowSkip !== false;
11881232

11891233
return new Promise(resolve => {
11901234
const complexNote = fullPageDisabled
1191-
? `<p style="margin: 0 0 12px; padding: 8px 12px; background: var(--bd-bg-secondary, #f5f5f5); border-radius: 6px; font-size: 13px; color: var(--bd-text-secondary);">This page is too complex for full-page or area capture. Select a specific element instead.</p>`
1235+
? `<p style="margin: 0 0 12px; padding: 8px 12px; background: var(--bd-bg-secondary, #f5f5f5); border-radius: 6px; font-size: 13px; color: var(--bd-text-secondary);">${nativeViewportAvailable ? 'This page is too complex for full-page or area capture. Capture the visible viewport or select a specific element instead.' : 'This page is too complex for full-page or area capture. Select a specific element instead.'}</p>`
11921236
: '';
1237+
const primaryCaptureButton = fullPageDisabled
1238+
? nativeViewportAvailable
1239+
? '<button class="bd-btn bd-btn-primary" data-action="viewport">Capture Viewport</button>'
1240+
: ''
1241+
: '<button class="bd-btn bd-btn-primary" data-action="capture">Full Page</button>';
11931242

11941243
const modal = createModal(
11951244
root,
@@ -1198,7 +1247,7 @@ function showScreenshotOptions(
11981247
<p style="margin: 0 0 16px; color: var(--bd-text-secondary);">Choose what to capture:</p>
11991248
${complexNote}
12001249
<div class="bd-actions bd-screenshot-actions">
1201-
${fullPageDisabled ? '' : '<button class="bd-btn bd-btn-primary" data-action="capture">Full Page</button>'}
1250+
${primaryCaptureButton}
12021251
${fullPageDisabled ? '' : '<button class="bd-btn bd-btn-secondary" data-action="area">Select Area</button>'}
12031252
<button class="bd-btn bd-btn-secondary" data-action="element">Select Element</button>
12041253
${allowSkip ? '<button class="bd-btn bd-btn-quiet" data-action="skip">Skip Screenshot</button>' : ''}
@@ -1211,6 +1260,7 @@ function showScreenshotOptions(
12111260
const elementBtn = modal.querySelector('[data-action="element"]') as HTMLElement;
12121261
const areaBtn = modal.querySelector('[data-action="area"]') as HTMLElement;
12131262
const captureBtn = modal.querySelector('[data-action="capture"]') as HTMLElement;
1263+
const viewportBtn = modal.querySelector('[data-action="viewport"]') as HTMLElement;
12141264

12151265
closeBtn?.addEventListener('click', () => {
12161266
modal.remove();
@@ -1236,6 +1286,12 @@ function showScreenshotOptions(
12361286
modal.remove();
12371287
resolve('capture');
12381288
});
1289+
1290+
viewportBtn?.addEventListener('click', () => {
1291+
modal.remove();
1292+
const capture = beginViewportCapture();
1293+
resolve({ type: 'viewport', capture });
1294+
});
12391295
});
12401296
}
12411297

0 commit comments

Comments
 (0)