"use client"; import { domToBlob, waitUntilLoad } from "modern-screenshot"; import { copyImage } from "./clipboard"; export type ImageOpts = { scale?: number; type?: "image/png" | "image/jpeg" | "image/webp"; backgroundColor?: string; /** * Maximum height in CSS pixels for the captured area. * Defaults to (16000 / scale) — the upper bound most browsers accept * for a single canvas / SVG foreignObject. */ maxHeight?: number; }; const NEXT_FRAME = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** * Wait until everything inside the iframe document is reasonably stable: * fonts loaded, images decoded, stylesheets applied, and Tailwind Play CDN * (which injects styles asynchronously) has had a chance to flush. */ async function waitForDocumentReady(doc: Document, win: Window): Promise { if (doc.readyState !== "complete") { await new Promise((res) => { const done = () => res(); doc.addEventListener("readystatechange", () => { if (doc.readyState === "complete") done(); }); win.addEventListener?.("load", done, { once: true }); setTimeout(done, 8000); }); } const sheets = Array.from( doc.querySelectorAll('link[rel="stylesheet"]'), ); await Promise.all( sheets.map( (link) => new Promise((res) => { if (link.sheet) return res(); const done = () => res(); link.addEventListener("load", done, { once: true }); link.addEventListener("error", done, { once: true }); setTimeout(done, 6000); }), ), ); try { const fonts = (doc as Document & { fonts?: FontFaceSet }).fonts; if (fonts?.ready) await fonts.ready; } catch { /* noop */ } const imgs = Array.from(doc.images); await Promise.all( imgs.map( (img) => new Promise((res) => { if (img.complete && img.naturalWidth > 0) return res(); const done = () => res(); img.addEventListener("load", done, { once: true }); img.addEventListener("error", done, { once: true }); if ("decode" in img) img.decode().then(done, done); setTimeout(done, 6000); }), ), ); try { await waitUntilLoad(doc.documentElement, { timeout: 6000 }); } catch { /* noop */ } // Tailwind Play CDN injects styles async; give it two frames + a small // idle window so utilities are applied before we measure layout. await NEXT_FRAME(); await sleep(120); await NEXT_FRAME(); } function resolveBackground(doc: Document, win: Window, override?: string): string { if (override) return override; const tryColor = (c?: string | null) => { if (!c) return null; const v = c.trim(); if (!v || v === "transparent" || v === "rgba(0, 0, 0, 0)") return null; return v; }; try { const bodyInline = tryColor(doc.body?.style.backgroundColor); if (bodyInline) return bodyInline; const bodyComputed = tryColor(win.getComputedStyle(doc.body).backgroundColor); if (bodyComputed) return bodyComputed; const htmlComputed = tryColor( win.getComputedStyle(doc.documentElement).backgroundColor, ); if (htmlComputed) return htmlComputed; } catch { /* cross-origin or detached doc */ } return "#ffffff"; } function fullScrollHeight(doc: Document): number { const b = doc.body; const h = doc.documentElement; return Math.max( b?.scrollHeight ?? 0, b?.offsetHeight ?? 0, h?.scrollHeight ?? 0, h?.offsetHeight ?? 0, h?.clientHeight ?? 0, ); } /** Render a DOM node to a Blob. Used for standalone elements; for iframes prefer {@link iframeToBlob}. */ export async function nodeToBlob(node: HTMLElement, opts: ImageOpts = {}): Promise { const blob = await domToBlob(node, { scale: opts.scale ?? 2, type: opts.type ?? "image/png", backgroundColor: opts.backgroundColor, }); if (!blob) throw new Error("screenshot failed"); return blob; } /** * Render the contents of an