diff --git a/apps/web/src/routes/$username/-components/pdf-viewer.css b/apps/web/src/routes/$username/-components/pdf-viewer.css new file mode 100644 index 000000000..beaa4e497 --- /dev/null +++ b/apps/web/src/routes/$username/-components/pdf-viewer.css @@ -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; +} diff --git a/apps/web/src/routes/$username/-components/pdf-viewer.test.tsx b/apps/web/src/routes/$username/-components/pdf-viewer.test.tsx new file mode 100644 index 000000000..4bcc119e7 --- /dev/null +++ b/apps/web/src/routes/$username/-components/pdf-viewer.test.tsx @@ -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; + update: ReturnType; + }>, + 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 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; + 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(); + + 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); + }); +}); diff --git a/apps/web/src/routes/$username/-components/pdf-viewer.tsx b/apps/web/src/routes/$username/-components/pdf-viewer.tsx new file mode 100644 index 000000000..e1e92fcc2 --- /dev/null +++ b/apps/web/src/routes/$username/-components/pdf-viewer.tsx @@ -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[0] & { + abortSignal: AbortSignal; +}; + +const clearPdfViewerDocument = (pdfViewer: PDFViewer) => { + (pdfViewer.setDocument as (document: PDFDocumentProxy | null) => void)(null); +}; + +export function PdfViewer({ className, data }: PdfViewerProps) { + const rootRef = useRef(null); + const containerRef = useRef(null); + const viewerRef = useRef(null); + const [file, setFile] = useState(null); + const [error, setError] = useState(false); + const [isReady, setIsReady] = useState(false); + const [viewerHeight, setViewerHeight] = useState(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 ( +
+
+
+
+ + {error ? ( +
+ Unable to display PDF preview. +
+ ) : isReady ? null : ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/routes/$username/-components/public-resume.test.tsx b/apps/web/src/routes/$username/-components/public-resume.test.tsx new file mode 100644 index 000000000..fe022769a --- /dev/null +++ b/apps/web/src/routes/$username/-components/public-resume.test.tsx @@ -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 }) => ( +
+ )); +}); + +const renderPublicResumeRoute = () => + render( + + + , + ); + +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"); + }); +}); diff --git a/apps/web/src/routes/$username/-components/public-resume.tsx b/apps/web/src/routes/$username/-components/public-resume.tsx index d6049ce8f..91bf3a21f 100644 --- a/apps/web/src/routes/$username/-components/public-resume.tsx +++ b/apps/web/src/routes/$username/-components/public-resume.tsx @@ -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 ( <> -
- +
+
+ +