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) +}