Files
PastpaperMaster/frontend/src/components/workbench/PdfViewer.tsx
Zhao 7a09167261 Initial commit: PastPaper Master full stack
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 12:27:47 +07:00

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