mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
Render public resumes with PDF.js (#3061)
* fix(web): use native pdf viewer for public resumes * fix(web): render public resumes with pdf.js * chore: revert vite hook paths * chore(web): address pdf viewer review
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
.pdf-viewer .pdfViewer {
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
.pdf-viewer .pdfViewer .page {
|
||||
margin-block: 0 1rem;
|
||||
}
|
||||
|
||||
.pdf-viewer .pdfViewer .page:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
|
||||
const pdfViewerMock = vi.hoisted(() => {
|
||||
const pdfDocument = {
|
||||
destroy: vi.fn(),
|
||||
numPages: 1,
|
||||
};
|
||||
|
||||
const loadingTask = {
|
||||
destroy: vi.fn(),
|
||||
promise: Promise.resolve(pdfDocument),
|
||||
};
|
||||
|
||||
return {
|
||||
constructorOptions: [] as Array<{ abortSignal?: AbortSignal; container: HTMLDivElement }>,
|
||||
createResumePdfBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
|
||||
getDocument: vi.fn(() => loadingTask),
|
||||
instances: [] as Array<{
|
||||
abortSignal?: AbortSignal;
|
||||
setDocument: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
loadingTask,
|
||||
pdfDocument,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/libs/resume/pdf-document", () => ({
|
||||
createResumePdfBlob: pdfViewerMock.createResumePdfBlob,
|
||||
}));
|
||||
|
||||
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({
|
||||
AnnotationMode: { ENABLE_FORMS: 2 },
|
||||
getDocument: pdfViewerMock.getDocument,
|
||||
GlobalWorkerOptions: {},
|
||||
}));
|
||||
|
||||
vi.mock("pdfjs-dist/legacy/web/pdf_viewer.mjs", () => {
|
||||
class EventBus {
|
||||
private readonly listeners = new Map<string, Set<() => void>>();
|
||||
|
||||
on(name: string, listener: () => void) {
|
||||
const listeners = this.listeners.get(name) ?? new Set<() => void>();
|
||||
listeners.add(listener);
|
||||
this.listeners.set(name, listeners);
|
||||
}
|
||||
|
||||
off(name: string, listener: () => void) {
|
||||
this.listeners.get(name)?.delete(listener);
|
||||
}
|
||||
|
||||
dispatch(name: string) {
|
||||
for (const listener of this.listeners.get(name) ?? []) listener();
|
||||
}
|
||||
}
|
||||
|
||||
class PDFLinkService {
|
||||
setDocument = vi.fn();
|
||||
setViewer = vi.fn();
|
||||
}
|
||||
|
||||
class PDFViewer {
|
||||
currentScaleValue = "";
|
||||
setDocument: ReturnType<typeof vi.fn>;
|
||||
update = vi.fn();
|
||||
|
||||
constructor(options: { abortSignal?: AbortSignal; container: HTMLDivElement; eventBus: EventBus }) {
|
||||
pdfViewerMock.constructorOptions.push(options);
|
||||
this.setDocument = vi.fn((document: unknown) => {
|
||||
if (document) options.eventBus.dispatch("pagesinit");
|
||||
});
|
||||
pdfViewerMock.instances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
EventBus,
|
||||
LinkTarget: { BLANK: 2 },
|
||||
PDFLinkService,
|
||||
PDFViewer,
|
||||
};
|
||||
});
|
||||
|
||||
const { PdfViewer } = await import("./pdf-viewer");
|
||||
|
||||
beforeEach(() => {
|
||||
pdfViewerMock.constructorOptions.length = 0;
|
||||
pdfViewerMock.instances.length = 0;
|
||||
pdfViewerMock.createResumePdfBlob.mockClear();
|
||||
pdfViewerMock.getDocument.mockClear();
|
||||
pdfViewerMock.loadingTask.destroy.mockClear();
|
||||
pdfViewerMock.pdfDocument.destroy.mockClear();
|
||||
});
|
||||
|
||||
describe("PdfViewer", () => {
|
||||
it("uses PDF.js' absolute container contract and disposes viewer state", async () => {
|
||||
const view = render(<PdfViewer data={sampleResumeData} />);
|
||||
|
||||
await waitFor(() => expect(pdfViewerMock.constructorOptions).toHaveLength(1));
|
||||
|
||||
const [{ abortSignal, container }] = pdfViewerMock.constructorOptions;
|
||||
const [viewer] = pdfViewerMock.instances;
|
||||
|
||||
expect(container).toHaveClass("absolute", "inset-0", "overflow-visible");
|
||||
expect(abortSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(viewer.setDocument).toHaveBeenCalledWith(pdfViewerMock.pdfDocument);
|
||||
|
||||
view.unmount();
|
||||
|
||||
expect(abortSignal?.aborted).toBe(true);
|
||||
expect(viewer.setDocument).toHaveBeenCalledWith(null);
|
||||
expect(pdfViewerMock.pdfDocument.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(pdfViewerMock.loadingTask.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { PDFDocumentLoadingTask, PDFDocumentProxy } from "pdfjs-dist";
|
||||
import { AnnotationMode, GlobalWorkerOptions, getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
import { EventBus, LinkTarget, PDFLinkService, PDFViewer } from "pdfjs-dist/legacy/web/pdf_viewer.mjs";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
|
||||
import "pdfjs-dist/legacy/web/pdf_viewer.css";
|
||||
import "./pdf-viewer.css";
|
||||
|
||||
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url).toString();
|
||||
|
||||
type PdfViewerProps = {
|
||||
className?: string;
|
||||
data: ResumeData;
|
||||
};
|
||||
|
||||
type PdfViewerOptions = ConstructorParameters<typeof PDFViewer>[0] & {
|
||||
abortSignal: AbortSignal;
|
||||
};
|
||||
|
||||
const clearPdfViewerDocument = (pdfViewer: PDFViewer) => {
|
||||
(pdfViewer.setDocument as (document: PDFDocumentProxy | null) => void)(null);
|
||||
};
|
||||
|
||||
export function PdfViewer({ className, data }: PdfViewerProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewerRef = useRef<HTMLDivElement>(null);
|
||||
const [file, setFile] = useState<Blob | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [viewerHeight, setViewerHeight] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
setError(false);
|
||||
setFile(null);
|
||||
setIsReady(false);
|
||||
setViewerHeight(null);
|
||||
|
||||
void createResumePdfBlob(data)
|
||||
.then((blob) => {
|
||||
if (!isCancelled) setFile(blob);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!isCancelled) {
|
||||
console.error("Failed to generate public resume PDF", error);
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
const container = containerRef.current;
|
||||
const viewer = viewerRef.current;
|
||||
|
||||
if (!file || !root || !container || !viewer) return;
|
||||
|
||||
let isCancelled = false;
|
||||
let animationFrameId = 0;
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
const abortController = new AbortController();
|
||||
let loadingTask: PDFDocumentLoadingTask | undefined;
|
||||
let pdfDocument: PDFDocumentProxy | undefined;
|
||||
let pdfViewer: PDFViewer | undefined;
|
||||
|
||||
const eventBus = new EventBus();
|
||||
const linkService = new PDFLinkService({
|
||||
eventBus,
|
||||
externalLinkTarget: LinkTarget.BLANK,
|
||||
externalLinkRel: "noreferrer",
|
||||
});
|
||||
|
||||
const syncViewerHeight = () => {
|
||||
if (isCancelled) return;
|
||||
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = window.requestAnimationFrame(() => {
|
||||
if (isCancelled) return;
|
||||
|
||||
const nextHeight = Math.ceil(viewer.scrollHeight);
|
||||
if (nextHeight > 0) setViewerHeight(nextHeight);
|
||||
pdfViewer?.update();
|
||||
});
|
||||
};
|
||||
|
||||
const setInitialScale = () => {
|
||||
if (!isCancelled && pdfViewer) {
|
||||
pdfViewer.currentScaleValue = "page-width";
|
||||
syncViewerHeight();
|
||||
}
|
||||
};
|
||||
|
||||
eventBus.on("pagesinit", setInitialScale);
|
||||
eventBus.on("pagesloaded", syncViewerHeight);
|
||||
eventBus.on("pagerendered", syncViewerHeight);
|
||||
viewer.replaceChildren();
|
||||
setError(false);
|
||||
setIsReady(false);
|
||||
setViewerHeight(null);
|
||||
resizeObserver = new ResizeObserver(syncViewerHeight);
|
||||
resizeObserver.observe(viewer);
|
||||
|
||||
const loadDocument = async () => {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
if (isCancelled) return;
|
||||
|
||||
loadingTask = getDocument({
|
||||
data: new Uint8Array(arrayBuffer),
|
||||
docBaseUrl: window.location.href,
|
||||
});
|
||||
|
||||
const nextDocument = await loadingTask.promise;
|
||||
if (isCancelled) {
|
||||
void nextDocument.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
pdfDocument = nextDocument;
|
||||
const pdfViewerOptions = {
|
||||
annotationMode: AnnotationMode.ENABLE_FORMS,
|
||||
container,
|
||||
eventBus,
|
||||
linkService,
|
||||
removePageBorders: true,
|
||||
abortSignal: abortController.signal,
|
||||
viewer,
|
||||
} satisfies PdfViewerOptions;
|
||||
|
||||
pdfViewer = new PDFViewer(pdfViewerOptions);
|
||||
|
||||
linkService.setViewer(pdfViewer);
|
||||
pdfViewer.setDocument(pdfDocument);
|
||||
linkService.setDocument(pdfDocument);
|
||||
syncViewerHeight();
|
||||
setIsReady(true);
|
||||
};
|
||||
|
||||
void loadDocument().catch((error: unknown) => {
|
||||
if (!isCancelled) {
|
||||
console.error("Failed to render public resume PDF with PDF.js", error);
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
eventBus.off("pagesinit", setInitialScale);
|
||||
eventBus.off("pagesloaded", syncViewerHeight);
|
||||
eventBus.off("pagerendered", syncViewerHeight);
|
||||
abortController.abort();
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
resizeObserver?.disconnect();
|
||||
if (pdfViewer) clearPdfViewerDocument(pdfViewer);
|
||||
void pdfDocument?.destroy();
|
||||
void loadingTask?.destroy();
|
||||
viewer.replaceChildren();
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn("pdf-viewer relative bg-neutral-100", viewerHeight ? "min-h-0" : "min-h-48", className)}
|
||||
style={viewerHeight ? { height: viewerHeight } : undefined}
|
||||
>
|
||||
<div ref={containerRef} className="absolute inset-0 overflow-visible">
|
||||
<div ref={viewerRef} className="pdfViewer" />
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background px-6 text-center text-muted-foreground text-sm">
|
||||
Unable to display PDF preview.
|
||||
</div>
|
||||
) : isReady ? null : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { ReactNode } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
|
||||
const publicResumeMock = vi.hoisted(() => ({
|
||||
createResumePdfBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
|
||||
downloadWithAnchor: vi.fn(),
|
||||
generateFilename: vi.fn((name: string, extension: string) => `${name}.${extension}`),
|
||||
PdfViewer: vi.fn<(_props: { className?: string; data: ResumeData }) => ReactNode>(() => null),
|
||||
resume: undefined as
|
||||
| undefined
|
||||
| {
|
||||
data: ResumeData;
|
||||
name: string;
|
||||
slug: string;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: publicResumeMock.resume }),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
getRouteApi: () => ({
|
||||
useParams: () => ({ username: "amruth", slug: "sample" }),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/utils/file", () => ({
|
||||
downloadWithAnchor: publicResumeMock.downloadWithAnchor,
|
||||
generateFilename: publicResumeMock.generateFilename,
|
||||
}));
|
||||
|
||||
vi.mock("./pdf-viewer", () => ({
|
||||
PdfViewer: publicResumeMock.PdfViewer,
|
||||
}));
|
||||
|
||||
vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: { resume: { getBySlug: { queryOptions: () => ({}) } } },
|
||||
}));
|
||||
|
||||
vi.mock("@/libs/resume/pdf-document", () => ({
|
||||
createResumePdfBlob: publicResumeMock.createResumePdfBlob,
|
||||
}));
|
||||
|
||||
const { PublicResumeRoute } = await import("./public-resume");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
publicResumeMock.resume = {
|
||||
data: sampleResumeData,
|
||||
name: "Sample Resume",
|
||||
slug: "sample",
|
||||
};
|
||||
publicResumeMock.PdfViewer.mockClear();
|
||||
publicResumeMock.PdfViewer.mockImplementation(({ className }) => (
|
||||
<div className={className} data-testid="pdf-viewer" />
|
||||
));
|
||||
});
|
||||
|
||||
const renderPublicResumeRoute = () =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<PublicResumeRoute />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("PublicResumeRoute", () => {
|
||||
it("renders the public resume through the route-local PDF.js viewer", () => {
|
||||
renderPublicResumeRoute();
|
||||
|
||||
expect(screen.getByTestId("pdf-viewer")).toHaveClass("block", "w-full");
|
||||
expect(publicResumeMock.PdfViewer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: sampleResumeData }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("lets the public resume page grow to the full PDF length", () => {
|
||||
renderPublicResumeRoute();
|
||||
|
||||
const viewerFrame = screen.getByTestId("pdf-viewer").parentElement;
|
||||
const page = viewerFrame?.parentElement;
|
||||
|
||||
expect(page).not.toHaveClass("min-h-svh", "h-svh", "max-h-svh", "overflow-hidden");
|
||||
expect(viewerFrame).not.toHaveClass("min-h-0", "flex-1", "overflow-hidden");
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,9 @@ import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { ResumePreview } from "@/components/resume/preview";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
|
||||
import { PdfViewer } from "./pdf-viewer";
|
||||
|
||||
const publicResumeRoute = getRouteApi("/$username/$slug");
|
||||
|
||||
@@ -43,14 +43,10 @@ export function PublicResumeRoute() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto my-12 flex flex-col items-center gap-12 print:m-0 print:block print:max-w-full print:px-0">
|
||||
<ResumePreview
|
||||
data={resume.data}
|
||||
pageGap="1rem"
|
||||
pageScale={1.25}
|
||||
pageLayout="vertical"
|
||||
pageClassName="print:w-full! w-full max-w-full"
|
||||
/>
|
||||
<div className="mx-auto flex w-full flex-col items-center gap-6 px-4 py-6 print:m-0 print:block print:max-w-full print:p-0">
|
||||
<div className="w-full max-w-5xl bg-white print:max-w-full">
|
||||
<PdfViewer data={resume.data} className="block w-full" />
|
||||
</div>
|
||||
|
||||
<footer className="flex justify-center print:hidden">
|
||||
<BrandIcon variant="icon" className="size-8 opacity-60" />
|
||||
|
||||
Reference in New Issue
Block a user