diff --git a/components/download-report-button.tsx b/components/download-report-button.tsx index b938c871..62d763f8 100644 --- a/components/download-report-button.tsx +++ b/components/download-report-button.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useRef, useCallback } from 'react' import { Button } from '@/components/ui/button' import { FileText, Loader2 } from 'lucide-react' import { useAIState } from 'ai/rsc' @@ -22,11 +22,39 @@ export const DownloadReportButton = () => { const [reportTitle, setReportTitle] = useState('QCX Analysis Report') const [isMounted, setIsMounted] = useState(false) + // Holds the callback that runs the PDF generation once the portal is in the DOM. + const pendingGenerateRef = useRef<(() => void) | null>(null) + useEffect(() => { setIsMounted(true) }, []) - const handleDownload = async () => { + // When showTemplate flips to true, React has committed the portal to the DOM + // but the browser hasn't necessarily painted yet. A double requestAnimationFrame + // ensures we wait for the next paint before calling html2canvas so that the + // element is actually in the DOM and fully laid out. + useEffect(() => { + if (!showTemplate) return + const callback = pendingGenerateRef.current + if (!callback) return + + let raf1: number + let raf2: number + + raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(() => { + callback() + pendingGenerateRef.current = null + }) + }) + + return () => { + cancelAnimationFrame(raf1) + cancelAnimationFrame(raf2) + } + }, [showTemplate]) + + const handleDownload = useCallback(async () => { if (!aiState || aiState.messages.length === 0) { toast.error('No conversation to export') return @@ -38,48 +66,60 @@ export const DownloadReportButton = () => { try { let snapshot: string | undefined if (map) { - // Use a smaller snapshot if possible, or just the current canvas snapshot = map.getCanvas().toDataURL('image/jpeg', 0.5) setMapSnapshot(snapshot) } - // Extract title + // Derive a title from the first message's text content. let chatTitle = 'Untitled Chat' if (aiState.messages.length > 0) { const firstMessage = aiState.messages[0] - const content = typeof firstMessage.content === 'string' - ? firstMessage.content - : Array.isArray(firstMessage.content) - ? (firstMessage.content as any[]).map(p => p.type === 'text' ? p.text : '').join(' ') + const rawContent = + typeof firstMessage.content === 'string' + ? firstMessage.content + : Array.isArray(firstMessage.content) + ? (firstMessage.content as any[]) + .map((p: any) => (p.type === 'text' ? p.text : '')) + .join(' ') : '' try { - const parsed = JSON.parse(content) - chatTitle = parsed.input || content - } catch (e) { - chatTitle = content + const parsed = JSON.parse(rawContent) + chatTitle = parsed.input || rawContent + } catch { + chatTitle = rawContent } } const finalTitle = (chatTitle || 'QCX Analysis Report').substring(0, 50) setReportTitle(finalTitle) - setShowTemplate(true) - - // Short delay for React portal to mount - await new Promise(resolve => setTimeout(resolve, 800)) - - await generatePDFReport('report-template', finalTitle) + // Store the PDF generation work in a ref so the useEffect can run it + // after React has committed the portal and the browser has painted. + pendingGenerateRef.current = async () => { + try { + await generatePDFReport('report-template', finalTitle) + toast.success('Report generated successfully', { id: toastId }) + } catch (error) { + console.error('Failed to generate report:', error) + toast.error('Failed to generate report', { id: toastId }) + } finally { + setIsGenerating(false) + setShowTemplate(false) + setMapSnapshot(undefined) + } + } - toast.success('Report generated successfully', { id: toastId }) + // Trigger the portal render; the useEffect above will fire once React + // commits this state change and two animation frames have elapsed. + setShowTemplate(true) } catch (error) { - console.error('Failed to generate report:', error) + console.error('Failed to prepare report:', error) toast.error('Failed to generate report', { id: toastId }) - } finally { setIsGenerating(false) setShowTemplate(false) - setMapSnapshot(undefined) // Clear snapshot memory + setMapSnapshot(undefined) } - } + }, [aiState, map]) if (!isMounted) return null @@ -104,7 +144,7 @@ export const DownloadReportButton = () => { {showTemplate && createPortal(
AI Response
Analysis Result
@@ -99,6 +125,15 @@ export const ReportTemplate: React.FCAnalysis Result
+{rawContent}
+