From 3ec2cbb369793103131f9d69b3cf46b0a1363b56 Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Sat, 15 Aug 2026 15:07:32 +0530 Subject: [PATCH] feat(web): page navigator for the PDF viewer The PDF viewer rendered every page in one long scroll with no sense of position or way to jump around, which is painful for long documents. Add a floating navigator: - Live "current / total" indicator that tracks the most-visible page as you scroll (via IntersectionObserver). - Previous / next page buttons and a jump-to-page input. - Arrow / Page Up-Down move between pages when the viewer is focused. Each page is wrapped in a `data-page-number` element so the observer and scroll-to-page can target it. The page math (clamping, most-visible selection, input parsing) is pure and unit tested in lib/pdf-page-nav.ts. Tests: lib/pdf-page-nav.test.ts (7 passing). Type-check and Biome clean. --- .../components/document-modal/content/pdf.tsx | 162 +++++++++++++++--- apps/web/lib/pdf-page-nav.test.ts | 55 ++++++ apps/web/lib/pdf-page-nav.ts | 48 ++++++ 3 files changed, 241 insertions(+), 24 deletions(-) create mode 100644 apps/web/lib/pdf-page-nav.test.ts create mode 100644 apps/web/lib/pdf-page-nav.ts diff --git a/apps/web/components/document-modal/content/pdf.tsx b/apps/web/components/document-modal/content/pdf.tsx index ca8d6f90..d2088935 100644 --- a/apps/web/components/document-modal/content/pdf.tsx +++ b/apps/web/components/document-modal/content/pdf.tsx @@ -1,10 +1,16 @@ "use client" import { Document, Page, pdfjs } from "react-pdf" -import { useEffect, useMemo, useRef, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { ChevronDown, ChevronUp } from "lucide-react" import "react-pdf/dist/Page/AnnotationLayer.css" import "react-pdf/dist/Page/TextLayer.css" import { getCachedFileBlob } from "@/lib/file-cache" +import { + clampPage, + parsePageInput, + pickMostVisiblePage, +} from "@/lib/pdf-page-nav" pdfjs.GlobalWorkerOptions.workerSrc = new URL( "pdfjs-dist/build/pdf.worker.min.mjs", @@ -76,6 +82,55 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) { const [error, setError] = useState(null) const [failedSources, setFailedSources] = useState(0) + const scrollRef = useRef(null) + const [currentPage, setCurrentPage] = useState(1) + const [pageInput, setPageInput] = useState("") + const visibilityRef = useRef>(new Map()) + + const scrollToPage = useCallback( + (page: number) => { + if (!numPages) return + const target = clampPage(page, numPages) + scrollRef.current + ?.querySelector(`[data-page-number="${target}"]`) + ?.scrollIntoView({ block: "start", behavior: "smooth" }) + setCurrentPage(target) + }, + [numPages], + ) + + // Track the most-visible page as the user scrolls. + useEffect(() => { + const root = scrollRef.current + if (!root || !numPages) return + const visibility = visibilityRef.current + visibility.clear() + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const page = Number( + (entry.target as HTMLElement).dataset.pageNumber ?? "0", + ) + if (page > 0) visibility.set(page, entry.intersectionRatio) + } + setCurrentPage((prev) => pickMostVisiblePage(visibility, prev)) + }, + { root, threshold: [0, 0.25, 0.5, 0.75, 1] }, + ) + + for (const el of root.querySelectorAll("[data-page-number]")) { + observer.observe(el) + } + return () => observer.disconnect() + }, [numPages]) + + const commitPageInput = useCallback(() => { + const parsed = parsePageInput(pageInput, numPages ?? 1) + if (parsed !== null) scrollToPage(parsed) + setPageInput("") + }, [pageInput, numPages, scrollToPage]) + const fileSource = useMemo((): FileSource => { if (cachedUrl) return cachedUrl if (failedSources === 0) return remoteFileSource @@ -139,30 +194,89 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) { )} {fileSource && ( -
- + {/* biome-ignore lint/a11y/noStaticElementInteractions: keyboard page nav is an optional enhancement; the on-screen buttons/input are the accessible controls */} +
{ + if (!numPages) return + if (e.key === "ArrowDown" || e.key === "PageDown") { + e.preventDefault() + scrollToPage(currentPage + 1) + } else if (e.key === "ArrowUp" || e.key === "PageUp") { + e.preventDefault() + scrollToPage(currentPage - 1) + } + }} + className="size-full overflow-auto outline-none" > - {numPages && ( -
- {Array.from(new Array(numPages), (_, index) => ( - - ))} -
- )} - + + {numPages && ( +
+ {Array.from(new Array(numPages), (_, index) => ( +
+ +
+ ))} +
+ )} +
+
+ + {numPages && numPages > 1 && ( +
+ +
{ + e.preventDefault() + commitPageInput() + }} + className="flex items-center gap-1 tabular-nums" + > + setPageInput(e.target.value)} + onBlur={commitPageInput} + placeholder={String(currentPage)} + aria-label={`Page ${currentPage} of ${numPages}, jump to page`} + inputMode="numeric" + className="w-8 rounded-md bg-white/10 px-1 py-0.5 text-center text-white outline-none placeholder:text-white/60 focus:bg-white/15" + /> + / {numPages} +
+ +
+ )}
)} diff --git a/apps/web/lib/pdf-page-nav.test.ts b/apps/web/lib/pdf-page-nav.test.ts new file mode 100644 index 00000000..a283bb61 --- /dev/null +++ b/apps/web/lib/pdf-page-nav.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "bun:test" +import { clampPage, parsePageInput, pickMostVisiblePage } from "./pdf-page-nav" + +describe("clampPage", () => { + it("clamps into [1, total] and rounds", () => { + expect(clampPage(0, 10)).toBe(1) + expect(clampPage(5, 10)).toBe(5) + expect(clampPage(50, 10)).toBe(10) + expect(clampPage(3.4, 10)).toBe(3) + expect(clampPage(3.6, 10)).toBe(4) + }) + + it("never returns less than 1 even for a 0-page document", () => { + expect(clampPage(1, 0)).toBe(1) + }) +}) + +describe("pickMostVisiblePage", () => { + it("returns the fallback when nothing is visible", () => { + expect(pickMostVisiblePage(new Map(), 1)).toBe(1) + expect(pickMostVisiblePage(new Map([[2, 0]]), 3)).toBe(3) + }) + + it("picks the page with the highest visible ratio", () => { + const ratios = new Map([ + [1, 0.2], + [2, 0.7], + [3, 0.1], + ]) + expect(pickMostVisiblePage(ratios, 1)).toBe(2) + }) + + it("breaks ties toward the lower page number", () => { + const ratios = new Map([ + [4, 0.5], + [3, 0.5], + ]) + expect(pickMostVisiblePage(ratios, 1)).toBe(3) + }) +}) + +describe("parsePageInput", () => { + it("parses and clamps a valid page", () => { + expect(parsePageInput("4", 10)).toBe(4) + expect(parsePageInput(" 99 ", 10)).toBe(10) + }) + + it("rejects non-numeric or non-positive input", () => { + expect(parsePageInput("", 10)).toBeNull() + expect(parsePageInput("abc", 10)).toBeNull() + expect(parsePageInput("0", 10)).toBeNull() + expect(parsePageInput("-3", 10)).toBeNull() + expect(parsePageInput("2.5", 10)).toBeNull() + }) +}) diff --git a/apps/web/lib/pdf-page-nav.ts b/apps/web/lib/pdf-page-nav.ts new file mode 100644 index 00000000..2a1464f7 --- /dev/null +++ b/apps/web/lib/pdf-page-nav.ts @@ -0,0 +1,48 @@ +/** + * Pure helpers for the PDF viewer's page navigator: clamping page numbers, + * choosing the "current" page from per-page visibility, and parsing the + * jump-to-page input. Kept free of DOM/React so they can be unit tested. + */ + +/** Clamp a 1-based page number into [1, total] (total < 1 collapses to 1). */ +export function clampPage(page: number, total: number): number { + const max = Math.max(1, total) + const rounded = Math.round(page) + if (rounded < 1) return 1 + if (rounded > max) return max + return rounded +} + +/** + * Pick the page that occupies the most of the viewport from a map of + * `pageNumber -> visible ratio`. Ties resolve to the lower page number so + * scrolling down only advances once a later page is clearly more visible. + * Returns `fallback` when nothing is visible. + */ +export function pickMostVisiblePage( + ratios: Map, + fallback: number, +): number { + let bestPage = fallback + let bestRatio = 0 + for (const [page, ratio] of ratios) { + if (ratio <= 0) continue + if (ratio > bestRatio || (ratio === bestRatio && page < bestPage)) { + bestPage = page + bestRatio = ratio + } + } + return bestPage +} + +/** + * Parse a jump-to-page input. Returns a clamped page number, or null when the + * input isn't a usable positive integer. + */ +export function parsePageInput(value: string, total: number): number | null { + const trimmed = value.trim() + if (!/^\d+$/.test(trimmed)) return null + const parsed = Number.parseInt(trimmed, 10) + if (!Number.isFinite(parsed) || parsed < 1) return null + return clampPage(parsed, total) +}