import type { Template } from "@reactive-resume/schema/templates"; import type { CSSProperties } from "react"; import type { ResolvedResumePreviewProps } from "./preview.shared"; import type { PreviewPageSize } from "./preview.shared.utils"; import { t } from "@lingui/core/macro"; import { AnimatePresence, m } from "motion/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { isRTL } from "@reactive-resume/utils/locale"; import { cn } from "@reactive-resume/utils/style"; import { createResumePdfBlob } from "@/features/resume/export/pdf-document"; import { useStylesheetStore } from "@/features/resume/stylesheet/store"; import { usePreviewPausedStore, useResumeData, useResumeStore } from "../builder/draft"; import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas"; import { ResumePreviewLoader } from "./preview.shared"; import { getResumePreviewGapValue, getResumePreviewPageCount } from "./preview.shared.utils"; import { ResumeAccessibleText } from "./resume-accessible-text"; type PreviewPdf = { file: Blob; id: number; numPages: number; pageSizes: Record; phase: "active" | "exiting" | "staged"; renderedPages: number[]; template: Template; }; const UPDATE_DEBOUNCE_MS = 100; const CROSSFADE_DURATION_MS = 180; const createPreviewPdf = (file: Blob, id: number, hasExistingPreview: boolean, template: Template): PreviewPdf => ({ file, id, numPages: 0, pageSizes: {}, phase: hasExistingPreview ? "staged" : "active", renderedPages: [], template, }); const addPreviewLayer = (layers: PreviewPdf[], nextPdf: PreviewPdf) => { const activeLayers = layers.filter((layer) => layer.phase === "active"); return activeLayers.length === 0 ? [nextPdf] : [...activeLayers, nextPdf]; }; const getActivePreviewLayer = (layers: PreviewPdf[]) => layers.find((layer) => layer.phase === "active") ?? null; const setPreviewPageCount = (layers: PreviewPdf[], layerId: number, numPages: number) => layers.map((layer) => (layer.id === layerId ? { ...layer, numPages } : layer)); const setPreviewPageSize = (layers: PreviewPdf[], layerId: number, pageNumber: number, pageSize: PreviewPageSize) => layers.map((layer) => layer.id === layerId ? { ...layer, pageSizes: { ...layer.pageSizes, [pageNumber]: pageSize, }, } : layer, ); const markPreviewPageRendered = (layers: PreviewPdf[], layerId: number, pageNumber: number) => { let shouldPromoteLayer = false; const nextLayers = layers.map((layer) => { if (layer.id !== layerId || layer.renderedPages.includes(pageNumber)) return layer; const renderedPages = [...layer.renderedPages, pageNumber]; const nextLayer = { ...layer, renderedPages }; if (layer.phase === "staged" && renderedPages.length >= layer.numPages) { shouldPromoteLayer = true; return { ...nextLayer, phase: "active" as const }; } return nextLayer; }); if (!shouldPromoteLayer) return nextLayers; return nextLayers.map((layer) => { if (layer.id === layerId) return layer; if (layer.phase === "active") return { ...layer, phase: "exiting" as const }; return layer; }); }; const removePreviewLayer = (layers: PreviewPdf[], layerId: number) => layers.filter((layer) => layer.id !== layerId); export function ResumePreviewClient({ className, data, pageGap = 16, pageLayout, pageScale, pageClassName, showPageNumbers, }: ResolvedResumePreviewProps) { const builderResumeData = useResumeData(); const resumeData = data ?? builderResumeData; const builderResumeId = useResumeStore((state) => state.resumeId); const stylesheetResumeId = useStylesheetStore((state) => state.resumeId); const mode = useStylesheetStore((state) => state.mode); const applied = useStylesheetStore((state) => state.applied); const presentation = useMemo( () => data === undefined && builderResumeId !== undefined && stylesheetResumeId === builderResumeId ? { stylesheet: { mode, applied } } : undefined, [applied, builderResumeId, data, mode, stylesheetResumeId], ); const paused = usePreviewPausedStore((state) => state.paused); const [previewLayers, setPreviewLayers] = useState([]); const pdfIdRef = useRef(0); const requestIdRef = useRef(0); const hasPreviewRef = useRef(false); useEffect(() => { if (!resumeData) return; // Mobile hides the preview behind the Edit/Design overlay; skip re-rendering and keep the last PDF shown. if (paused) return; let cancelled = false; const requestId = ++requestIdRef.current; const delay = hasPreviewRef.current ? UPDATE_DEBOUNCE_MS : 0; const generatePdfPreview = async () => { try { if (cancelled || requestId !== requestIdRef.current) return; const blob = await createResumePdfBlob(resumeData, undefined, undefined, presentation); if (!cancelled && requestId === requestIdRef.current) { const nextPdf = createPreviewPdf( blob, pdfIdRef.current++, hasPreviewRef.current, resumeData.metadata.template, ); hasPreviewRef.current = true; setPreviewLayers((current) => addPreviewLayer(current, nextPdf)); } } catch { if (cancelled || requestId !== requestIdRef.current) return; toast.error(t`The resume preview could not be updated. The last valid preview is still shown.`, { id: "resume-preview-render-error", }); } }; const timeoutId = window.setTimeout(() => { void generatePdfPreview(); }, delay); return () => { cancelled = true; window.clearTimeout(timeoutId); }; }, [paused, presentation, resumeData]); if (!resumeData) return null; const visiblePdf = getActivePreviewLayer(previewLayers); const resolvedPageGap = getResumePreviewGapValue(pageGap); if (!visiblePdf) { return ( <> ); } return (
{previewLayers.map((visiblePdf) => ( { if (visiblePdf.phase !== "exiting") return; setPreviewLayers((current) => removePreviewLayer(current, visiblePdf.id)); }} > { setPreviewLayers((current) => setPreviewPageCount(current, visiblePdf.id, document.numPages)); }} > {(document) => (
{Array.from({ length: visiblePdf.numPages }, (_, index) => { const pageNumber = index + 1; const totalPages = visiblePdf.numPages; const pageSize = visiblePdf.pageSizes[pageNumber]; return ( { setPreviewLayers((current) => setPreviewPageSize(current, visiblePdf.id, pageNumber, pageSize), ); }} onRenderSuccess={() => { if (visiblePdf.phase !== "staged") return; setPreviewLayers((current) => markPreviewPageRendered(current, visiblePdf.id, pageNumber)); }} /> ); })}
)}
))}
); }