171 lines
5.3 KiB
TypeScript
171 lines
5.3 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from "react";
|
|
import { Document, Page, pdfjs } from "react-pdf";
|
|
import "react-pdf/dist/Page/AnnotationLayer.css";
|
|
import "react-pdf/dist/Page/TextLayer.css";
|
|
|
|
pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
|
|
|
|
export default function PdfViewer({
|
|
fileUrl,
|
|
currentPage,
|
|
onPageChange,
|
|
}: {
|
|
fileUrl: string;
|
|
currentPage?: number;
|
|
onPageChange?: (page: number) => void;
|
|
}) {
|
|
const [numPages, setNumPages] = useState(0);
|
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const pageRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
|
const [jumpPage, setJumpPage] = useState("");
|
|
const programmaticScroll = useRef(false);
|
|
|
|
// Resize observer for container width
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
const observer = new ResizeObserver((entries) => {
|
|
setContainerWidth(entries[0].contentRect.width);
|
|
});
|
|
observer.observe(containerRef.current);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
// Scroll to page when currentPage changes (programmatic)
|
|
useEffect(() => {
|
|
if (!currentPage || currentPage < 1) return;
|
|
const el = pageRefs.current.get(currentPage);
|
|
if (el) {
|
|
programmaticScroll.current = true;
|
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
setTimeout(() => { programmaticScroll.current = false; }, 2000);
|
|
}
|
|
}, [currentPage]);
|
|
|
|
// IntersectionObserver to detect visible page on user scroll
|
|
useEffect(() => {
|
|
if (numPages === 0 || !scrollRef.current) return;
|
|
|
|
const visiblePages = new Map<number, number>();
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
const pageNum = Number(entry.target.getAttribute("data-page"));
|
|
if (entry.isIntersecting) {
|
|
visiblePages.set(pageNum, entry.intersectionRatio);
|
|
} else {
|
|
visiblePages.delete(pageNum);
|
|
}
|
|
}
|
|
|
|
// Don't fire callback during programmatic scroll
|
|
if (programmaticScroll.current) return;
|
|
|
|
// Find the page with the highest visibility ratio
|
|
let bestPage = 0;
|
|
let bestRatio = 0;
|
|
for (const [page, ratio] of visiblePages) {
|
|
if (ratio > bestRatio) {
|
|
bestRatio = ratio;
|
|
bestPage = page;
|
|
}
|
|
}
|
|
if (bestPage > 0) {
|
|
onPageChange?.(bestPage);
|
|
}
|
|
},
|
|
{
|
|
root: scrollRef.current,
|
|
threshold: [0, 0.25, 0.5, 0.75, 1],
|
|
},
|
|
);
|
|
|
|
for (const [, el] of pageRefs.current) {
|
|
observer.observe(el);
|
|
}
|
|
|
|
return () => observer.disconnect();
|
|
}, [numPages, onPageChange]);
|
|
|
|
const setPageRef = useCallback((pageNum: number, el: HTMLDivElement | null) => {
|
|
if (el) {
|
|
el.setAttribute("data-page", String(pageNum));
|
|
pageRefs.current.set(pageNum, el);
|
|
} else {
|
|
pageRefs.current.delete(pageNum);
|
|
}
|
|
}, []);
|
|
|
|
const handleJump = () => {
|
|
const p = parseInt(jumpPage, 10);
|
|
if (p >= 1 && p <= numPages) {
|
|
const el = pageRefs.current.get(p);
|
|
el?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}
|
|
setJumpPage("");
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className="h-full flex flex-col bg-gray-100">
|
|
{/* Page controls */}
|
|
<div className="flex items-center justify-center gap-3 py-2 bg-white border-b border-gray-200 text-sm shrink-0">
|
|
<span className="text-gray-600">
|
|
{numPages} pages
|
|
</span>
|
|
<span className="text-gray-300">|</span>
|
|
<span className="text-gray-600">
|
|
Go to{" "}
|
|
<input
|
|
type="number"
|
|
value={jumpPage}
|
|
onChange={(e) => setJumpPage(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === "Enter") handleJump(); }}
|
|
placeholder="#"
|
|
className="w-12 text-center border border-gray-300 rounded px-1 py-0.5 text-sm"
|
|
min={1}
|
|
max={numPages}
|
|
/>
|
|
</span>
|
|
</div>
|
|
|
|
{/* All pages scrollable */}
|
|
<div ref={scrollRef} className="flex-1 overflow-auto">
|
|
<Document
|
|
file={fileUrl}
|
|
onLoadSuccess={({ numPages: n }) => setNumPages(n)}
|
|
loading={
|
|
<div className="flex items-center justify-center h-64 text-gray-400">
|
|
Loading PDF...
|
|
</div>
|
|
}
|
|
error={
|
|
<div className="flex items-center justify-center h-64 text-red-400">
|
|
Failed to load PDF
|
|
</div>
|
|
}
|
|
>
|
|
{numPages > 0 &&
|
|
Array.from({ length: numPages }, (_, i) => i + 1).map((pageNum) => (
|
|
<div
|
|
key={pageNum}
|
|
ref={(el) => setPageRef(pageNum, el)}
|
|
className="flex justify-center mb-2"
|
|
>
|
|
<div className="bg-white shadow-sm">
|
|
<Page
|
|
pageNumber={pageNum}
|
|
width={containerWidth > 0 ? containerWidth - 48 : undefined}
|
|
renderAnnotationLayer
|
|
renderTextLayer
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</Document>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|