mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
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:
parent
5ecbc26345
commit
3ec2cbb369
3 changed files with 241 additions and 24 deletions
|
|
@ -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>
|
||||
|
|
|
|||
55
apps/web/lib/pdf-page-nav.test.ts
Normal file
55
apps/web/lib/pdf-page-nav.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
48
apps/web/lib/pdf-page-nav.ts
Normal file
48
apps/web/lib/pdf-page-nav.ts
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue