mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-15 02:53:25 +10:00
259 lines
8.5 KiB
TypeScript
259 lines
8.5 KiB
TypeScript
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<number, PreviewPageSize>;
|
|
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<PreviewPdf[]>([]);
|
|
|
|
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 (
|
|
<>
|
|
<ResumeAccessibleText data={resumeData} />
|
|
<ResumePreviewLoader
|
|
pageCount={getResumePreviewPageCount(resumeData)}
|
|
pageClassName={pageClassName}
|
|
pageGap={pageGap}
|
|
pageLayout={pageLayout}
|
|
pageScale={pageScale}
|
|
showPageNumbers={showPageNumbers}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn("grid", className)}>
|
|
<ResumeAccessibleText data={resumeData} />
|
|
<AnimatePresence initial={false}>
|
|
{previewLayers.map((visiblePdf) => (
|
|
<m.div
|
|
key={visiblePdf.id}
|
|
aria-hidden={visiblePdf.phase !== "active"}
|
|
data-resume-preview-template={visiblePdf.template}
|
|
style={{ "--resume-preview-page-gap": resolvedPageGap } as CSSProperties}
|
|
className={cn("col-start-1 row-start-1", visiblePdf.phase !== "active" && "pointer-events-none")}
|
|
initial={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
|
|
animate={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: CROSSFADE_DURATION_MS / 1000, ease: "easeOut" }}
|
|
onAnimationComplete={() => {
|
|
if (visiblePdf.phase !== "exiting") return;
|
|
setPreviewLayers((current) => removePreviewLayer(current, visiblePdf.id));
|
|
}}
|
|
>
|
|
<PdfCanvasDocument
|
|
file={visiblePdf.file}
|
|
onLoadSuccess={(document) => {
|
|
setPreviewLayers((current) => setPreviewPageCount(current, visiblePdf.id, document.numPages));
|
|
}}
|
|
>
|
|
{(document) => (
|
|
<div
|
|
dir={isRTL(resumeData.metadata.page.locale) ? "rtl" : "ltr"}
|
|
className={cn(
|
|
"flex justify-start gap-(--resume-preview-page-gap)",
|
|
pageLayout === "horizontal" ? "flex-row items-start" : "flex-col items-center",
|
|
)}
|
|
>
|
|
{Array.from({ length: visiblePdf.numPages }, (_, index) => {
|
|
const pageNumber = index + 1;
|
|
const totalPages = visiblePdf.numPages;
|
|
const pageSize = visiblePdf.pageSizes[pageNumber];
|
|
|
|
return (
|
|
<PdfCanvasPage
|
|
key={`${visiblePdf.id}-${pageNumber}`}
|
|
document={document}
|
|
pageSize={pageSize}
|
|
pageNumber={pageNumber}
|
|
pageScale={pageScale}
|
|
totalPages={totalPages}
|
|
className={pageClassName}
|
|
showPageNumbers={showPageNumbers}
|
|
onLoadSuccess={(_, pageSize) => {
|
|
setPreviewLayers((current) =>
|
|
setPreviewPageSize(current, visiblePdf.id, pageNumber, pageSize),
|
|
);
|
|
}}
|
|
onRenderSuccess={() => {
|
|
if (visiblePdf.phase !== "staged") return;
|
|
|
|
setPreviewLayers((current) => markPreviewPageRendered(current, visiblePdf.id, pageNumber));
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</PdfCanvasDocument>
|
|
</m.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|