mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-27 02:14:50 +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 { Button } from "@reactive-resume/ui/components/button";
|
||||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||||
import { ResumePreview } from "@/components/resume/preview";
|
|
||||||
import { orpc } from "@/libs/orpc/client";
|
import { orpc } from "@/libs/orpc/client";
|
||||||
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
|
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
|
||||||
|
import { PdfViewer } from "./pdf-viewer";
|
||||||
|
|
||||||
const publicResumeRoute = getRouteApi("/$username/$slug");
|
const publicResumeRoute = getRouteApi("/$username/$slug");
|
||||||
|
|
||||||
@@ -43,14 +43,10 @@ export function PublicResumeRoute() {
|
|||||||
|
|
||||||
return (
|
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">
|
<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">
|
||||||
<ResumePreview
|
<div className="w-full max-w-5xl bg-white print:max-w-full">
|
||||||
data={resume.data}
|
<PdfViewer data={resume.data} className="block w-full" />
|
||||||
pageGap="1rem"
|
</div>
|
||||||
pageScale={1.25}
|
|
||||||
pageLayout="vertical"
|
|
||||||
pageClassName="print:w-full! w-full max-w-full"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<footer className="flex justify-center print:hidden">
|
<footer className="flex justify-center print:hidden">
|
||||||
<BrandIcon variant="icon" className="size-8 opacity-60" />
|
<BrandIcon variant="icon" className="size-8 opacity-60" />
|
||||||
|
|||||||
Reference in New Issue
Block a user