mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Merge 3ec2cbb369 into 3f7b9667c6
This commit is contained in:
commit
9fbb13f4cf
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