Leon4gr45/builder
0
1import html2canvas from 'html2canvas';2import { logger } from '@/lib/utils';3 4/**5 * Waits for document resources (fonts, images, idle) to finish loading.6 * All resource promises race against a timeout to prevent indefinite blocking.7 * @param doc The document to wait on8 * @param minDelay Minimum delay in ms regardless of resource readiness (default: 2000)9 * @param timeout Maximum time to wait for resources in ms (default: 8000)10 */11export async function waitForResources(doc: Document, minDelay = 2000, timeout = 8000): Promise<void> {12 const win = doc.defaultView;13 14 const resourcePromises: Promise<unknown>[] = [15 // Minimum buffer delay16 new Promise(resolve => setTimeout(resolve, minDelay)),17 ];18 19 // Wait for fonts20 if (doc.fonts?.ready) {21 resourcePromises.push(doc.fonts.ready.catch(() => {}));22 }23 24 // Wait for all <img> elements to load25 const images = doc.querySelectorAll('img');26 images.forEach((img) => {27 if (!img.complete) {28 resourcePromises.push(29 new Promise<void>(resolve => {30 img.addEventListener('load', () => resolve(), { once: true });31 img.addEventListener('error', () => resolve(), { once: true });32 })33 );34 }35 });36 37 // Wait for idle callback (indicates browser has finished layout/paint work)38 if (win) {39 resourcePromises.push(40 new Promise<void>(resolve => {41 if ('requestIdleCallback' in win) {42 (win as Window & { requestIdleCallback: (cb: () => void, opts?: { timeout: number }) => void })43 .requestIdleCallback(() => resolve(), { timeout: 500 });44 } else {45 setTimeout(resolve, 500);46 }47 })48 );49 }50 51 // Race all resource promises against timeout52 await Promise.race([53 Promise.all(resourcePromises),54 new Promise(resolve => setTimeout(resolve, timeout)),55 ]);56}57 58/**59 * Internal function to attempt screenshot capture60 */61async function attemptCapture(62 iframeDoc: Document,63 captureWidth: number,64 captureHeight: number,65 fullPage: boolean66): Promise<HTMLCanvasElement> {67 // Determine capture height based on mode68 let effectiveHeight: number;69 70 if (fullPage) {71 // Get actual document height for full-page capture72 effectiveHeight = Math.max(73 iframeDoc.body.scrollHeight,74 iframeDoc.body.offsetHeight,75 iframeDoc.documentElement.clientHeight,76 iframeDoc.documentElement.scrollHeight,77 iframeDoc.documentElement.offsetHeight78 );79 logger.debug('[Screenshot] Full-page mode: document height =', effectiveHeight);80 } else {81 // Use viewport height for initial view capture82 effectiveHeight = captureHeight;83 logger.debug('[Screenshot] Viewport-only mode: using height =', effectiveHeight);84 }85 86 logger.debug('[Screenshot] Capture dimensions:', captureWidth, 'x', effectiveHeight);87 88 return Promise.race([89 html2canvas(iframeDoc.body, {90 width: captureWidth,91 height: effectiveHeight,92 scale: 1,93 useCORS: true,94 allowTaint: true,95 logging: false,96 windowWidth: captureWidth,97 windowHeight: effectiveHeight,98 scrollX: 0,99 scrollY: 0,100 imageTimeout: 3000,101 backgroundColor: '#ffffff',102 removeContainer: true,103 // Clean up problematic elements in the cloned document104 onclone: (clonedDoc) => {105 // Remove external stylesheets that cause CORS errors106 const externalLinks = clonedDoc.querySelectorAll('link[rel="stylesheet"]');107 externalLinks.forEach((link) => {108 const href = link.getAttribute('href');109 if (href && (href.startsWith('http://') || href.startsWith('https://'))) {110 link.remove();111 }112 });113 114 // Remove ALL gradient backgrounds (not just ones with "gradient" in class name)115 // Tailwind gradients can cause "non-finite" errors in html2canvas116 const allElements = clonedDoc.querySelectorAll('*');117 118 // CRITICAL: Use cloned document's window for getComputedStyle, not parent window119 const clonedWindow = clonedDoc.defaultView;120 if (!clonedWindow) {121 return;122 }123 124 allElements.forEach((el: Element) => {125 const htmlEl = el as HTMLElement;126 // Read styles from CLONED document's context127 const computedStyle = clonedWindow.getComputedStyle(htmlEl);128 const bg = computedStyle.backgroundImage;129 130 // Check if element has a gradient background131 if (bg && (bg.includes('gradient') || bg.includes('linear-gradient') || bg.includes('radial-gradient'))) {132 // Replace gradient with solid color from gradient's first color if possible133 // or use a neutral fallback134 const bgColor = computedStyle.backgroundColor;135 htmlEl.style.backgroundImage = 'none';136 if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {137 htmlEl.style.backgroundColor = bgColor;138 } else {139 htmlEl.style.backgroundColor = '#64748b'; // slate-500 as neutral fallback140 }141 }142 });143 }144 }),145 new Promise<never>((_, reject) =>146 setTimeout(() => reject(new Error('html2canvas timeout after 4 seconds')), 4000)147 )148 ]);149}150 151export async function captureIframeScreenshot(152 iframe: HTMLIFrameElement,153 captureWidth: number = 1280,154 captureHeight: number = 720,155 outputWidth: number = 640,156 outputHeight: number = 360,157 quality: number = 0.8,158 fullPage: boolean = true,159 waitForContent: boolean = false,160 minWaitDelay: number = 1500161): Promise<string | null> {162 try {163 // Get the iframe's document164 const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;165 166 if (!iframeDoc || !iframeDoc.body) {167 logger.warn('Cannot access iframe document');168 return null;169 }170 171 // Wait for resources if requested172 if (waitForContent) {173 try {174 await waitForResources(iframeDoc, minWaitDelay);175 } catch {176 // Fall back to simple delay if resource waiting fails177 await new Promise(resolve => setTimeout(resolve, minWaitDelay));178 }179 }180 181 // Attempt capture with automatic retry on gradient errors182 let canvas: HTMLCanvasElement;183 try {184 canvas = await attemptCapture(iframeDoc, captureWidth, captureHeight, fullPage);185 } catch (firstError) {186 // Check if this is a gradient-related error187 const errorMsg = String(firstError);188 if (errorMsg.includes('non-finite') || errorMsg.includes('addColorStop') || errorMsg.includes('CanvasGradient')) {189 // Wait a bit for styles to stabilize further190 await new Promise(resolve => setTimeout(resolve, 500));191 canvas = await attemptCapture(iframeDoc, captureWidth, captureHeight, fullPage);192 } else {193 // Not a gradient error, rethrow194 throw firstError;195 }196 }197 198 // Scale down the captured image maintaining aspect ratio199 const aspectRatio = canvas.height / canvas.width;200 const scaledHeight = Math.round(outputWidth * aspectRatio);201 202 const scaledCanvas = document.createElement('canvas');203 scaledCanvas.width = outputWidth;204 scaledCanvas.height = scaledHeight;205 const ctx = scaledCanvas.getContext('2d');206 207 if (!ctx) {208 logger.error('Failed to get canvas context');209 return null;210 }211 212 // Draw the captured image scaled down maintaining aspect ratio213 ctx.drawImage(canvas, 0, 0, outputWidth, scaledHeight);214 215 // Convert scaled canvas to base64 JPEG216 const dataUrl = scaledCanvas.toDataURL('image/jpeg', quality);217 218 // Validate size (max 250KB)219 const sizeInBytes = Math.ceil((dataUrl.length * 3) / 4);220 const sizeInKB = sizeInBytes / 1024;221 222 if (sizeInKB > 250) {223 logger.warn(`Screenshot too large: ${sizeInKB.toFixed(0)}KB, trying with lower quality`);224 // Retry with lower quality using scaled canvas225 const retryDataUrl = scaledCanvas.toDataURL('image/jpeg', 0.6);226 const retrySizeInKB = Math.ceil((retryDataUrl.length * 3) / 4) / 1024;227 228 if (retrySizeInKB > 250) {229 logger.warn(`Screenshot still too large: ${retrySizeInKB.toFixed(0)}KB`);230 return retryDataUrl; // Return anyway, let VFS handle size limit231 }232 233 return retryDataUrl;234 }235 236 return dataUrl;237 238 } catch (error) {239 logger.error('Failed to capture screenshot:', error);240 return null;241 }242}243 