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.
This commit is contained in:
abhay-codes07 2026-08-15 15:07:32 +05:30
parent 5ecbc26345
commit 3ec2cbb369
No known key found for this signature in database
3 changed files with 241 additions and 24 deletions

View file

@ -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<string | null>(null)
const [failedSources, setFailedSources] = useState(0)
const scrollRef = useRef<HTMLDivElement>(null)
const [currentPage, setCurrentPage] = useState(1)
const [pageInput, setPageInput] = useState("")
const visibilityRef = useRef<Map<number, number>>(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) {
</div>
)}
{fileSource && (
<div className="flex-1 overflow-auto w-full">
<Document
key={`${failedSources}-${cachedUrl ? "cache" : "remote"}`}
file={fileSource}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
loading={null}
className="w-full"
<div className="relative flex-1 min-h-0 w-full">
{/* biome-ignore lint/a11y/noStaticElementInteractions: keyboard page nav is an optional enhancement; the on-screen buttons/input are the accessible controls */}
<div
ref={scrollRef}
tabIndex={-1}
onKeyDown={(e) => {
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 && (
<div className="flex flex-col items-center gap-4 py-4 w-full">
{Array.from(new Array(numPages), (_, index) => (
<Page
key={`page_${index + 1}`}
pageNumber={index + 1}
renderTextLayer
renderAnnotationLayer
className="shadow-lg"
width={630}
/>
))}
</div>
)}
</Document>
<Document
key={`${failedSources}-${cachedUrl ? "cache" : "remote"}`}
file={fileSource}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
loading={null}
className="w-full"
>
{numPages && (
<div className="flex flex-col items-center gap-4 py-4 w-full">
{Array.from(new Array(numPages), (_, index) => (
<div key={`page_${index + 1}`} data-page-number={index + 1}>
<Page
pageNumber={index + 1}
renderTextLayer
renderAnnotationLayer
className="shadow-lg"
width={630}
/>
</div>
))}
</div>
)}
</Document>
</div>
{numPages && numPages > 1 && (
<div className="pointer-events-auto absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-full border border-white/10 bg-black/60 p-1 pl-2 text-[13px] text-white/90 backdrop-blur-md">
<button
type="button"
aria-label="Previous page"
disabled={currentPage <= 1}
onClick={() => scrollToPage(currentPage - 1)}
className="flex size-7 items-center justify-center rounded-full transition-colors hover:bg-white/15 disabled:opacity-35"
>
<ChevronUp className="size-4" />
</button>
<form
onSubmit={(e) => {
e.preventDefault()
commitPageInput()
}}
className="flex items-center gap-1 tabular-nums"
>
<input
value={pageInput}
onChange={(e) => 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"
/>
<span className="text-white/50">/ {numPages}</span>
</form>
<button
type="button"
aria-label="Next page"
disabled={currentPage >= numPages}
onClick={() => scrollToPage(currentPage + 1)}
className="flex size-7 items-center justify-center rounded-full transition-colors hover:bg-white/15 disabled:opacity-35"
>
<ChevronDown className="size-4" />
</button>
</div>
)}
</div>
)}
</div>

View file

@ -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()
})
})

View file

@ -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<number, number>,
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)
}