Initial commit: PastPaper Master full stack
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
170
frontend/src/components/workbench/PdfViewer.tsx
Normal file
170
frontend/src/components/workbench/PdfViewer.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user