This commit is contained in:
Abhay Singh 2026-08-26 04:01:27 +05:30 committed by GitHub
commit 9fbb13f4cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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)
}