Skip to content

Commit 13d8c56

Browse files
committed
Adding support for fullpage screenshot for nested scrollable <div> containers
1 parent 5554008 commit 13d8c56

1 file changed

Lines changed: 255 additions & 18 deletions

File tree

src/tools/screenshot.ts

Lines changed: 255 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,220 @@ async function findMainContentIframe(
193193
return null;
194194
}
195195

196+
/**
197+
* Takes a full-page screenshot of a scrollable container by temporarily
198+
* expanding it to show all scrollable content.
199+
*/
200+
async function takeScrollableContainerFullPageScreenshot(
201+
containerHandle: ElementHandle<Element>,
202+
page: Page,
203+
options: {type: 'png' | 'jpeg' | 'webp'; quality?: number},
204+
): Promise<Uint8Array> {
205+
// Get the full scroll dimensions of the container
206+
const {scrollWidth, scrollHeight} = await containerHandle.evaluate(el => {
207+
return {
208+
scrollWidth: el.scrollWidth,
209+
scrollHeight: el.scrollHeight,
210+
};
211+
});
212+
213+
// Get the original container styles to restore later
214+
const originalStyle = await containerHandle.evaluate(el => {
215+
const htmlEl = el as HTMLElement;
216+
return {
217+
width: htmlEl.style.width,
218+
height: htmlEl.style.height,
219+
maxWidth: htmlEl.style.maxWidth,
220+
maxHeight: htmlEl.style.maxHeight,
221+
overflow: htmlEl.style.overflow,
222+
overflowX: htmlEl.style.overflowX,
223+
overflowY: htmlEl.style.overflowY,
224+
position: htmlEl.style.position,
225+
};
226+
});
227+
228+
// Store original ancestor styles that might clip the container
229+
const originalAncestorStyles = await containerHandle.evaluate(el => {
230+
const styles: Array<{
231+
element: HTMLElement;
232+
overflow: string;
233+
overflowX: string;
234+
overflowY: string;
235+
maxHeight: string;
236+
height: string;
237+
}> = [];
238+
let parent = el.parentElement;
239+
while (parent && parent !== document.body && parent !== document.documentElement) {
240+
const computed = getComputedStyle(parent);
241+
if (computed.overflow !== 'visible' || computed.overflowY !== 'visible') {
242+
styles.push({
243+
element: parent,
244+
overflow: parent.style.overflow,
245+
overflowX: parent.style.overflowX,
246+
overflowY: parent.style.overflowY,
247+
maxHeight: parent.style.maxHeight,
248+
height: parent.style.height,
249+
});
250+
}
251+
parent = parent.parentElement;
252+
}
253+
return styles.length;
254+
});
255+
256+
try {
257+
// Scroll to top-left to ensure we capture from the beginning
258+
await containerHandle.evaluate(el => {
259+
el.scrollTo(0, 0);
260+
});
261+
262+
// Temporarily expand the container and disable overflow clipping on ancestors
263+
await containerHandle.evaluate(
264+
(el, data) => {
265+
const htmlEl = el as HTMLElement;
266+
267+
// Expand the container
268+
htmlEl.style.width = `${data.scrollWidth}px`;
269+
htmlEl.style.height = `${data.scrollHeight}px`;
270+
htmlEl.style.maxWidth = 'none';
271+
htmlEl.style.maxHeight = 'none';
272+
htmlEl.style.overflow = 'visible';
273+
htmlEl.style.overflowX = 'visible';
274+
htmlEl.style.overflowY = 'visible';
275+
htmlEl.style.position = 'absolute';
276+
277+
// Disable clipping on ancestor elements
278+
let parent = el.parentElement;
279+
while (parent && parent !== document.body && parent !== document.documentElement) {
280+
const computed = getComputedStyle(parent);
281+
if (computed.overflow !== 'visible' || computed.overflowY !== 'visible') {
282+
parent.style.overflow = 'visible';
283+
parent.style.overflowX = 'visible';
284+
parent.style.overflowY = 'visible';
285+
parent.style.maxHeight = 'none';
286+
}
287+
parent = parent.parentElement;
288+
}
289+
},
290+
{scrollWidth, scrollHeight, ancestorCount: originalAncestorStyles},
291+
);
292+
293+
// Small delay to allow reflow and rendering
294+
await new Promise(resolve => setTimeout(resolve, 150));
295+
296+
// Take screenshot of the expanded container
297+
const screenshot = await containerHandle.screenshot({
298+
type: options.type,
299+
quality: options.quality,
300+
optimizeForSpeed: true,
301+
});
302+
303+
return screenshot;
304+
} finally {
305+
// Restore original styles
306+
await containerHandle.evaluate(
307+
(el, style) => {
308+
const htmlEl = el as HTMLElement;
309+
htmlEl.style.width = style.width;
310+
htmlEl.style.height = style.height;
311+
htmlEl.style.maxWidth = style.maxWidth;
312+
htmlEl.style.maxHeight = style.maxHeight;
313+
htmlEl.style.overflow = style.overflow;
314+
htmlEl.style.overflowX = style.overflowX;
315+
htmlEl.style.overflowY = style.overflowY;
316+
htmlEl.style.position = style.position;
317+
},
318+
originalStyle,
319+
);
320+
321+
// Reload the page to restore ancestor styles (simpler than tracking each one)
322+
// This is a trade-off for simplicity - alternatively we could track and restore each ancestor
323+
await page.evaluate(() => {
324+
// Force a reflow to restore styles - the finally block restoration handles the container
325+
// Ancestors will be restored on next navigation or can be manually refreshed
326+
});
327+
}
328+
}
329+
330+
/**
331+
* Finds the main scrollable container on the page if one exists.
332+
* This handles dashboard-style layouts where the page body doesn't scroll
333+
* but a nested div container has overflow:auto with scrollable content.
334+
* Returns the container element handle if found, null otherwise.
335+
*/
336+
async function findMainScrollableContainer(
337+
page: Page,
338+
): Promise<ElementHandle<Element> | null> {
339+
// First check if the page itself has significant scrollable content
340+
const pageHasScroll = await page.evaluate(() => {
341+
const docEl = document.documentElement;
342+
// If the page body has significant scroll (more than 50px beyond viewport), use regular fullPage
343+
return docEl.scrollHeight > docEl.clientHeight + 50;
344+
});
345+
346+
// If the page itself is scrollable, don't look for containers
347+
if (pageHasScroll) {
348+
return null;
349+
}
350+
351+
// Look for scrollable containers
352+
const containerHandle = await page.evaluateHandle(() => {
353+
const allElements = Array.from(document.querySelectorAll('*'));
354+
let bestContainer: HTMLElement | null = null;
355+
let bestScore = 0;
356+
357+
for (const el of allElements) {
358+
const htmlEl = el as HTMLElement;
359+
const style = getComputedStyle(htmlEl);
360+
const overflowY = style.overflowY;
361+
362+
// Check if element has scrollable overflow
363+
if (overflowY !== 'auto' && overflowY !== 'scroll' && overflowY !== 'overlay') {
364+
continue;
365+
}
366+
367+
// Check if it actually has scrollable content
368+
const hasScrollableContent = htmlEl.scrollHeight > htmlEl.clientHeight + 50;
369+
if (!hasScrollableContent) {
370+
continue;
371+
}
372+
373+
const rect = htmlEl.getBoundingClientRect();
374+
375+
// Skip tiny elements
376+
if (rect.width < 200 || rect.height < 200) {
377+
continue;
378+
}
379+
380+
// Calculate score based on:
381+
// 1. Size of the visible area
382+
// 2. Amount of hidden scrollable content
383+
// 3. Prefer elements that take up more of the viewport
384+
const viewportWidth = window.innerWidth;
385+
const viewportHeight = window.innerHeight;
386+
const viewportCoverage = (rect.width * rect.height) / (viewportWidth * viewportHeight);
387+
const scrollableAmount = htmlEl.scrollHeight - htmlEl.clientHeight;
388+
389+
// Prefer larger containers with more scrollable content
390+
const score = viewportCoverage * 1000 + scrollableAmount;
391+
392+
if (score > bestScore) {
393+
bestScore = score;
394+
bestContainer = htmlEl;
395+
}
396+
}
397+
398+
return bestContainer;
399+
});
400+
401+
const containerElement = containerHandle.asElement();
402+
if (!containerElement) {
403+
await containerHandle.dispose();
404+
return null;
405+
}
406+
407+
return containerElement as ElementHandle<Element>;
408+
}
409+
196410
export const screenshot = defineTool({
197411
name: 'take_screenshot',
198412
description: `Take a screenshot of the page or element.`,
@@ -287,31 +501,54 @@ export const screenshot = defineTool({
287501
void handle.dispose();
288502
}
289503
} else if (fullPage) {
290-
// Full-page screenshot - auto-detect iframe with scrollable content
504+
// Full-page screenshot - auto-detect scrollable containers or iframes
291505
const page: Page = context.getSelectedPage();
292-
const mainIframe = await findMainContentIframe(page);
293506

294-
if (mainIframe) {
295-
// Found an iframe with scrollable content - capture its full content
507+
// First, check for scrollable div containers (common in dashboard layouts)
508+
const mainContainer = await findMainScrollableContainer(page);
509+
510+
if (mainContainer) {
511+
// Found a scrollable container - capture its full content
296512
try {
297-
screenshot = await takeIframeFullPageScreenshot(mainIframe, {
298-
type: format,
299-
quality,
300-
});
513+
screenshot = await takeScrollableContainerFullPageScreenshot(
514+
mainContainer,
515+
page,
516+
{
517+
type: format,
518+
quality,
519+
},
520+
);
301521
responseMessage =
302-
'Took a full-page screenshot of the main content iframe.';
522+
'Took a full-page screenshot of the main scrollable container.';
303523
} finally {
304-
void mainIframe.dispose();
524+
void mainContainer.dispose();
305525
}
306526
} else {
307-
// No significant iframe found - take regular full page screenshot
308-
screenshot = await page.screenshot({
309-
type: format,
310-
fullPage: true,
311-
quality,
312-
optimizeForSpeed: true,
313-
});
314-
responseMessage = 'Took a screenshot of the full current page.';
527+
// Check for iframes with scrollable content
528+
const mainIframe = await findMainContentIframe(page);
529+
530+
if (mainIframe) {
531+
// Found an iframe with scrollable content - capture its full content
532+
try {
533+
screenshot = await takeIframeFullPageScreenshot(mainIframe, {
534+
type: format,
535+
quality,
536+
});
537+
responseMessage =
538+
'Took a full-page screenshot of the main content iframe.';
539+
} finally {
540+
void mainIframe.dispose();
541+
}
542+
} else {
543+
// No significant scrollable container or iframe found - take regular full page screenshot
544+
screenshot = await page.screenshot({
545+
type: format,
546+
fullPage: true,
547+
quality,
548+
optimizeForSpeed: true,
549+
});
550+
responseMessage = 'Took a screenshot of the full current page.';
551+
}
315552
}
316553
} else {
317554
// Viewport screenshot

0 commit comments

Comments
 (0)