Skip to content

Commit a4b485a

Browse files
committed
feat(screenshot): add full-page screenshot support for iframes
1 parent 347ed5f commit a4b485a

1 file changed

Lines changed: 266 additions & 25 deletions

File tree

src/tools/screenshot.ts

Lines changed: 266 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,189 @@ import type {ElementHandle, Page} from '../third_party/index.js';
1010
import {ToolCategory} from './categories.js';
1111
import {defineTool} from './ToolDefinition.js';
1212

13+
/**
14+
* Takes a full-page screenshot of an iframe's content by temporarily
15+
* expanding the iframe to show all scrollable content.
16+
*/
17+
async function takeIframeFullPageScreenshot(
18+
iframeHandle: ElementHandle<Element>,
19+
options: {type: 'png' | 'jpeg' | 'webp'; quality?: number},
20+
): Promise<Uint8Array> {
21+
const contentFrame = await iframeHandle.contentFrame();
22+
if (!contentFrame) {
23+
throw new Error(
24+
'The specified element is not an iframe or its content is not accessible.',
25+
);
26+
}
27+
28+
// Get the full scroll dimensions of the iframe content
29+
const {scrollWidth, scrollHeight} = await contentFrame.evaluate(() => {
30+
return {
31+
scrollWidth: document.documentElement.scrollWidth,
32+
scrollHeight: document.documentElement.scrollHeight,
33+
};
34+
});
35+
36+
// Get the original iframe styles to restore later
37+
const originalIframeStyle = await iframeHandle.evaluate(el => {
38+
const iframe = el as HTMLIFrameElement;
39+
return {
40+
width: iframe.style.width,
41+
height: iframe.style.height,
42+
maxWidth: iframe.style.maxWidth,
43+
maxHeight: iframe.style.maxHeight,
44+
position: iframe.style.position,
45+
};
46+
});
47+
48+
// Get the original iframe content styles to restore later
49+
const originalContentStyle = await contentFrame.evaluate(() => {
50+
return {
51+
htmlHeight: document.documentElement.style.height,
52+
htmlOverflow: document.documentElement.style.overflow,
53+
bodyHeight: document.body.style.height,
54+
bodyOverflow: document.body.style.overflow,
55+
};
56+
});
57+
58+
try {
59+
// Temporarily expand the iframe to show all content
60+
// Setting position:absolute helps escape flex/grid layout constraints
61+
await iframeHandle.evaluate(
62+
(el, dims) => {
63+
const iframe = el as HTMLIFrameElement;
64+
iframe.style.width = `${dims.scrollWidth}px`;
65+
iframe.style.height = `${dims.scrollHeight}px`;
66+
iframe.style.maxWidth = 'none';
67+
iframe.style.maxHeight = 'none';
68+
iframe.style.position = 'absolute';
69+
},
70+
{scrollWidth, scrollHeight},
71+
);
72+
73+
// Set overflow:visible on iframe content to allow content to expand
74+
await contentFrame.evaluate(dims => {
75+
document.documentElement.style.height = `${dims.scrollHeight}px`;
76+
document.documentElement.style.overflow = 'visible';
77+
document.body.style.height = `${dims.scrollHeight}px`;
78+
document.body.style.overflow = 'visible';
79+
}, {scrollHeight});
80+
81+
// Scroll to top-left to ensure we capture from the beginning
82+
await contentFrame.evaluate(() => {
83+
window.scrollTo(0, 0);
84+
});
85+
86+
// Small delay to allow the iframe to resize and render
87+
await new Promise(resolve => setTimeout(resolve, 150));
88+
89+
// Take screenshot of the expanded iframe
90+
const screenshot = await iframeHandle.screenshot({
91+
type: options.type,
92+
quality: options.quality,
93+
optimizeForSpeed: true,
94+
});
95+
96+
return screenshot;
97+
} finally {
98+
// Restore original iframe content styles
99+
await contentFrame.evaluate(style => {
100+
document.documentElement.style.height = style.htmlHeight;
101+
document.documentElement.style.overflow = style.htmlOverflow;
102+
document.body.style.height = style.bodyHeight;
103+
document.body.style.overflow = style.bodyOverflow;
104+
}, originalContentStyle);
105+
106+
// Restore original iframe styles
107+
await iframeHandle.evaluate(
108+
(el, style) => {
109+
const iframe = el as HTMLIFrameElement;
110+
iframe.style.width = style.width;
111+
iframe.style.height = style.height;
112+
iframe.style.maxWidth = style.maxWidth;
113+
iframe.style.maxHeight = style.maxHeight;
114+
iframe.style.position = style.position;
115+
},
116+
originalIframeStyle,
117+
);
118+
}
119+
}
120+
121+
/**
122+
* Finds the main content iframe on the page if one exists.
123+
* Returns the iframe element handle if found, null otherwise.
124+
*/
125+
async function findMainContentIframe(
126+
page: Page,
127+
): Promise<ElementHandle<Element> | null> {
128+
// Look for iframes that take up a significant portion of the viewport
129+
const iframeHandle = await page.evaluateHandle(() => {
130+
const iframes = Array.from(document.querySelectorAll('iframe'));
131+
if (iframes.length === 0) return null;
132+
133+
// Find the largest iframe that has scrollable content
134+
let bestIframe: HTMLIFrameElement | null = null;
135+
let bestScore = 0;
136+
137+
for (let i = 0; i < iframes.length; i++) {
138+
const iframe = iframes[i]!;
139+
try {
140+
const rect = iframe.getBoundingClientRect();
141+
const contentDoc = iframe.contentDocument;
142+
143+
// Skip tiny iframes or iframes we can't access
144+
if (rect.width < 100 || rect.height < 100 || !contentDoc) continue;
145+
146+
// Calculate score based on size and scrollable content
147+
const scrollHeight = contentDoc.documentElement.scrollHeight;
148+
const hasScrollableContent = scrollHeight > rect.height;
149+
const areaScore = rect.width * rect.height;
150+
const scrollScore = hasScrollableContent ? scrollHeight : 0;
151+
const score = areaScore + scrollScore * 100;
152+
153+
if (score > bestScore) {
154+
bestScore = score;
155+
bestIframe = iframe;
156+
}
157+
} catch {
158+
// Skip iframes we can't access (cross-origin)
159+
continue;
160+
}
161+
}
162+
163+
return bestIframe;
164+
});
165+
166+
const iframeElement = iframeHandle.asElement();
167+
if (!iframeElement) {
168+
await iframeHandle.dispose();
169+
return null;
170+
}
171+
172+
// Cast to the correct type
173+
const iframe = iframeElement as ElementHandle<Element>;
174+
175+
// Verify the iframe has scrollable content
176+
const contentFrame = await iframe.contentFrame();
177+
if (!contentFrame) {
178+
await iframe.dispose();
179+
return null;
180+
}
181+
182+
const {scrollHeight, clientHeight} = await contentFrame.evaluate(() => ({
183+
scrollHeight: document.documentElement.scrollHeight,
184+
clientHeight: document.documentElement.clientHeight,
185+
}));
186+
187+
// Only return if there's actually scrollable content
188+
if (scrollHeight > clientHeight) {
189+
return iframe;
190+
}
191+
192+
await iframe.dispose();
193+
return null;
194+
}
195+
13196
export const screenshot = defineTool({
14197
name: 'take_screenshot',
15198
description: `Take a screenshot of the page or element.`,
@@ -41,7 +224,13 @@ export const screenshot = defineTool({
41224
.boolean()
42225
.optional()
43226
.describe(
44-
'If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid.',
227+
'If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid unless iframeUid is also provided.',
228+
),
229+
iframeUid: zod
230+
.string()
231+
.optional()
232+
.describe(
233+
'The uid of an iframe element. When used with fullPage=true, captures the full scrollable content of the iframe by temporarily expanding it.',
45234
),
46235
filePath: zod
47236
.string()
@@ -51,41 +240,93 @@ export const screenshot = defineTool({
51240
),
52241
},
53242
handler: async (request, response, context) => {
54-
if (request.params.uid && request.params.fullPage) {
243+
const {uid, fullPage, iframeUid} = request.params;
244+
245+
// Validate parameter combinations
246+
if (uid && fullPage && !iframeUid) {
55247
throw new Error('Providing both "uid" and "fullPage" is not allowed.');
56248
}
57-
58-
let pageOrHandle: Page | ElementHandle;
59-
if (request.params.uid) {
60-
pageOrHandle = await context.getElementByUid(request.params.uid);
61-
} else {
62-
pageOrHandle = context.getSelectedPage();
249+
if (uid && iframeUid) {
250+
throw new Error('Providing both "uid" and "iframeUid" is not allowed.');
251+
}
252+
if (iframeUid && !fullPage) {
253+
throw new Error(
254+
'iframeUid requires fullPage=true to capture the full iframe content.',
255+
);
63256
}
64257

65258
const format = request.params.format;
66259
const quality = format === 'png' ? undefined : request.params.quality;
67260

68-
const screenshot = await pageOrHandle.screenshot({
69-
type: format,
70-
fullPage: request.params.fullPage,
71-
quality,
72-
optimizeForSpeed: true, // Bonus: optimize encoding for speed
73-
});
261+
let screenshot: Uint8Array;
262+
let responseMessage: string;
74263

75-
if (request.params.uid) {
76-
response.appendResponseLine(
77-
`Took a screenshot of node with uid "${request.params.uid}".`,
78-
);
79-
} else if (request.params.fullPage) {
80-
response.appendResponseLine(
81-
'Took a screenshot of the full current page.',
82-
);
264+
if (iframeUid && fullPage) {
265+
// Full-page screenshot of iframe content (explicit iframe specified)
266+
const iframeHandle = await context.getElementByUid(iframeUid);
267+
try {
268+
screenshot = await takeIframeFullPageScreenshot(iframeHandle, {
269+
type: format,
270+
quality,
271+
});
272+
responseMessage = `Took a full-page screenshot of iframe with uid "${iframeUid}".`;
273+
} finally {
274+
void iframeHandle.dispose();
275+
}
276+
} else if (uid) {
277+
// Screenshot of a specific element
278+
const handle = await context.getElementByUid(uid);
279+
try {
280+
screenshot = await handle.screenshot({
281+
type: format,
282+
quality,
283+
optimizeForSpeed: true,
284+
});
285+
responseMessage = `Took a screenshot of node with uid "${uid}".`;
286+
} finally {
287+
void handle.dispose();
288+
}
289+
} else if (fullPage) {
290+
// Full-page screenshot - auto-detect iframe with scrollable content
291+
const page: Page = context.getSelectedPage();
292+
const mainIframe = await findMainContentIframe(page);
293+
294+
if (mainIframe) {
295+
// Found an iframe with scrollable content - capture its full content
296+
try {
297+
screenshot = await takeIframeFullPageScreenshot(mainIframe, {
298+
type: format,
299+
quality,
300+
});
301+
responseMessage =
302+
'Took a full-page screenshot of the main content iframe.';
303+
} finally {
304+
void mainIframe.dispose();
305+
}
306+
} 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.';
315+
}
83316
} else {
84-
response.appendResponseLine(
85-
"Took a screenshot of the current page's viewport.",
86-
);
317+
// Viewport screenshot
318+
const page: Page = context.getSelectedPage();
319+
screenshot = await page.screenshot({
320+
type: format,
321+
fullPage: false,
322+
quality,
323+
optimizeForSpeed: true,
324+
});
325+
responseMessage = "Took a screenshot of the current page's viewport.";
87326
}
88327

328+
response.appendResponseLine(responseMessage);
329+
89330
if (request.params.filePath) {
90331
const file = await context.saveFile(screenshot, request.params.filePath);
91332
response.appendResponseLine(`Saved screenshot to ${file.filename}.`);

0 commit comments

Comments
 (0)