@@ -12,6 +12,9 @@ import { WebView } from 'bun'
1212import { existsSync , mkdirSync , readdirSync , statSync } from 'node:fs'
1313import path from 'node:path'
1414
15+ /** WebView viewport dimensions are clamped to [1, 16384] by Bun. */
16+ const MAX_DIMENSION = 16384
17+
1518export interface ScreenshotOptions {
1619 /** Viewport width in CSS px. @default 1440 */
1720 width ?: number
@@ -25,6 +28,19 @@ export interface ScreenshotOptions {
2528 format ?: 'png' | 'jpeg' | 'webp'
2629 /** WebView backend. macOS defaults to system WebKit; elsewhere use 'chrome'. */
2730 backend ?: 'webkit' | 'chrome'
31+ /**
32+ * Capture the entire scrollable page rather than just the viewport. The page
33+ * is measured (`scrollHeight`) and re-rendered at its full height so nothing
34+ * is cut off — ideal for long pages (dashboards, docs). Clamped to WebView's
35+ * 16384px ceiling. @default false
36+ */
37+ fullPage ?: boolean
38+ /**
39+ * Wait until this CSS selector is present in the DOM before capturing (polls
40+ * the page via `evaluate`). Use for content that renders after load (data
41+ * fetched client-side, hydration, fonts swapped in).
42+ */
43+ waitSelector ?: string
2844}
2945
3046export interface ShotResult {
@@ -40,37 +56,88 @@ function toUrl(input: string): string {
4056 return `file://${ path . resolve ( input ) } `
4157}
4258
59+ /** Resolve once navigation completes (+ a short settle), with a timeout fallback. */
60+ function waitForNavigation ( view : any , waitMs : number , timeoutMs : number ) : Promise < void > {
61+ return new Promise < void > ( ( resolve ) => {
62+ let settled = false
63+ const finish = ( ) : void => {
64+ if ( ! settled ) {
65+ settled = true
66+ resolve ( )
67+ }
68+ }
69+ view . onNavigated = ( ) => setTimeout ( finish , waitMs )
70+ setTimeout ( finish , timeoutMs )
71+ } )
72+ }
73+
74+ /** Poll the page until `selector` exists, or `timeoutMs` elapses (best-effort). */
75+ async function waitForSelector ( view : any , selector : string , timeoutMs : number ) : Promise < void > {
76+ const expr = `!!document.querySelector(${ JSON . stringify ( selector ) } )`
77+ const deadline = Date . now ( ) + timeoutMs
78+ while ( Date . now ( ) < deadline ) {
79+ try {
80+ if ( await view . evaluate ( expr ) )
81+ return
82+ }
83+ catch {
84+ // Navigation may still be settling; keep polling until the deadline.
85+ }
86+ await new Promise ( r => setTimeout ( r , 100 ) )
87+ }
88+ }
89+
90+ /** Measure the full scrollable document height in CSS px (0 on failure). */
91+ async function measureFullHeight ( view : any ) : Promise < number > {
92+ try {
93+ const h = await view . evaluate (
94+ 'Math.ceil(Math.max('
95+ + 'document.documentElement.scrollHeight, document.documentElement.offsetHeight, '
96+ + 'document.body ? document.body.scrollHeight : 0, document.body ? document.body.offsetHeight : 0))' ,
97+ )
98+ return typeof h === 'number' && Number . isFinite ( h ) && h > 0 ? Math . min ( h , MAX_DIMENSION ) : 0
99+ }
100+ catch {
101+ return 0
102+ }
103+ }
104+
43105/** Capture a single page to `output` using Bun's headless WebView. */
44106export async function captureScreenshot ( input : string , output : string , opts : ScreenshotOptions = { } ) : Promise < ShotResult > {
45- const width = opts . width ?? 1440
46- const height = opts . height ?? 2200
107+ const width = Math . min ( opts . width ?? 1440 , MAX_DIMENSION )
108+ const height = Math . min ( opts . height ?? 2200 , MAX_DIMENSION )
47109 const waitMs = opts . waitMs ?? 800
48110 const timeoutMs = opts . timeoutMs ?? 15000
49111 const format = opts . format ?? 'png'
112+ const url = toUrl ( input )
50113
51- const view = new WebView ( {
52- url : toUrl ( input ) ,
114+ const open = ( h : number ) : any => new WebView ( {
115+ url,
53116 width,
54- height,
117+ height : h ,
55118 headless : true ,
56119 ...( opts . backend ? { backend : opts . backend } : { } ) ,
57120 } as any )
58121
122+ let view = open ( height )
59123 try {
60- // Resolve once navigation completes (+ a short settle), with a timeout
61- // fallback in case onNavigated never fires.
62- await new Promise < void > ( ( resolve ) => {
63- let settled = false
64- const finish = ( ) : void => {
65- if ( ! settled ) {
66- settled = true
67- resolve ( )
68- }
124+ await waitForNavigation ( view , waitMs , timeoutMs )
125+ if ( opts . waitSelector )
126+ await waitForSelector ( view , opts . waitSelector , timeoutMs )
127+
128+ // Full-page: WebView has no resize(), so measure the document and re-render
129+ // at its full height. Only re-render when the page is actually taller than
130+ // the current viewport (a no-op otherwise).
131+ if ( opts . fullPage ) {
132+ const full = await measureFullHeight ( view )
133+ if ( full > height ) {
134+ view . close ( )
135+ view = open ( full )
136+ await waitForNavigation ( view , waitMs , timeoutMs )
137+ if ( opts . waitSelector )
138+ await waitForSelector ( view , opts . waitSelector , timeoutMs )
69139 }
70- const v = view as any
71- v . onNavigated = ( ) => setTimeout ( finish , waitMs )
72- setTimeout ( finish , timeoutMs )
73- } )
140+ }
74141
75142 const buf = await view . screenshot ( { encoding : 'buffer' , format } as any )
76143 mkdirSync ( path . dirname ( path . resolve ( output ) ) , { recursive : true } )
0 commit comments