import React, { useEffect, useState, useRef } from "react"; import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme } from "@mantine/core"; import { getDocument, GlobalWorkerOptions } from "pdfjs-dist"; import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew"; import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos"; import FirstPageIcon from "@mui/icons-material/FirstPage"; import LastPageIcon from "@mui/icons-material/LastPage"; import ViewSidebarIcon from "@mui/icons-material/ViewSidebar"; import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book) import DescriptionIcon from "@mui/icons-material/Description"; // for single page import { useLocalStorage } from "@mantine/hooks"; GlobalWorkerOptions.workerSrc = `${process.env.PUBLIC_URL}/pdf.worker.js`; export interface ViewerProps { pdfFile: { file: File; url: string } | null; setPdfFile: (file: { file: File; url: string } | null) => void; sidebarsVisible: boolean; setSidebarsVisible: (v: boolean) => void; } const Viewer: React.FC = ({ pdfFile, setPdfFile, sidebarsVisible, setSidebarsVisible, }) => { const theme = useMantineTheme(); const [numPages, setNumPages] = useState(0); const [pageImages, setPageImages] = useState([]); const [loading, setLoading] = useState(false); const [currentPage, setCurrentPage] = useState(null); const [dualPage, setDualPage] = useState(false); const [zoom, setZoom] = useState(1); // 1 = 100% const pageRefs = useRef<(HTMLImageElement | null)[]>([]); const scrollAreaRef = useRef(null); const userInitiatedRef = useRef(false); const suppressScrollRef = useRef(false); // Listen for hash changes and update currentPage useEffect(() => { function handleHashChange() { if (window.location.hash.startsWith("#page=")) { const page = parseInt(window.location.hash.replace("#page=", ""), 10); if (!isNaN(page) && page >= 1 && page <= numPages) { setCurrentPage(page); } } userInitiatedRef.current = false; } window.addEventListener("hashchange", handleHashChange); handleHashChange(); // Run on mount return () => window.removeEventListener("hashchange", handleHashChange); }, [numPages]); // Scroll to the current page when it changes useEffect(() => { if (currentPage && pageRefs.current[currentPage - 1]) { suppressScrollRef.current = true; const el = pageRefs.current[currentPage - 1]; el?.scrollIntoView({ behavior: "smooth", block: "center" }); // Try to use scrollend if supported const viewport = scrollAreaRef.current; let timeout: NodeJS.Timeout | null = null; let scrollEndHandler: (() => void) | null = null; if (viewport && "onscrollend" in viewport) { scrollEndHandler = () => { suppressScrollRef.current = false; viewport.removeEventListener("scrollend", scrollEndHandler!); }; viewport.addEventListener("scrollend", scrollEndHandler); } else { // Fallback for non-Chromium browsers timeout = setTimeout(() => { suppressScrollRef.current = false; }, 1000); } return () => { if (viewport && scrollEndHandler) { viewport.removeEventListener("scrollend", scrollEndHandler); } if (timeout) clearTimeout(timeout); }; } }, [currentPage, pageImages]); // Detect visible page on scroll and update hash const handleScroll = () => { if (suppressScrollRef.current) return; const scrollArea = scrollAreaRef.current; if (!scrollArea || !pageRefs.current.length) return; const areaRect = scrollArea.getBoundingClientRect(); let closestIdx = 0; let minDist = Infinity; pageRefs.current.forEach((img, idx) => { if (img) { const imgRect = img.getBoundingClientRect(); const dist = Math.abs(imgRect.top - areaRect.top); if (dist < minDist) { minDist = dist; closestIdx = idx; } } }); if (currentPage !== closestIdx + 1) { setCurrentPage(closestIdx + 1); if (window.location.hash !== `#page=${closestIdx + 1}`) { window.location.hash = `#page=${closestIdx + 1}`; } } }; useEffect(() => { let cancelled = false; async function renderPages() { if (!pdfFile || !pdfFile.url) { setNumPages(0); setPageImages([]); return; } setLoading(true); try { const pdf = await getDocument(pdfFile.url).promise; setNumPages(pdf.numPages); const images: string[] = []; for (let i = 1; i <= pdf.numPages; i++) { const page = await pdf.getPage(i); const viewport = page.getViewport({ scale: 1.2 }); const canvas = document.createElement("canvas"); canvas.width = viewport.width; canvas.height = viewport.height; const ctx = canvas.getContext("2d"); if (ctx) { await page.render({ canvasContext: ctx, viewport }).promise; images.push(canvas.toDataURL()); } } if (!cancelled) setPageImages(images); } catch { if (!cancelled) setPageImages([]); } if (!cancelled) setLoading(false); } renderPages(); return () => { cancelled = true; }; }, [pdfFile]); useEffect(() => { const viewport = scrollAreaRef.current; if (!viewport) return; const handler = () => { handleScroll(); }; viewport.addEventListener("scroll", handler); return () => viewport.removeEventListener("scroll", handler); }, [pageImages]); return ( {!pdfFile ? (
No PDF loaded. Click to upload a PDF.
) : loading ? (
) : ( {pageImages.length === 0 && ( No pages to display. )} {dualPage ? Array.from({ length: Math.ceil(pageImages.length / 2) }).map((_, i) => ( { pageRefs.current[i * 2] = el; }} src={pageImages[i * 2]} alt={`Page ${i * 2 + 1}`} style={{ width: `${100 * zoom}%`, maxWidth: 700 * zoom, boxShadow: "0 2px 8px rgba(0,0,0,0.08)", borderRadius: 8, marginTop: i === 0 ? theme.spacing.xl : 0, // <-- add gap to first row }} /> {pageImages[i * 2 + 1] && ( { pageRefs.current[i * 2 + 1] = el; }} src={pageImages[i * 2 + 1]} alt={`Page ${i * 2 + 2}`} style={{ width: `${100 * zoom}%`, maxWidth: 700 * zoom, boxShadow: "0 2px 8px rgba(0,0,0,0.08)", borderRadius: 8, marginTop: i === 0 ? theme.spacing.xl : 0, // <-- add gap to first row }} /> )} )) : pageImages.map((img, idx) => ( { pageRefs.current[idx] = el; }} src={img} alt={`Page ${idx + 1}`} style={{ width: `${100 * zoom}%`, maxWidth: 700 * zoom, boxShadow: "0 2px 8px rgba(0,0,0,0.08)", borderRadius: 8, marginTop: idx === 0 ? theme.spacing.xl : 0, // <-- add gap to first page }} /> ))} {/* Navigation bar overlays the scroll area */}
{ const page = Number(value); if (!isNaN(page) && page >= 1 && page <= numPages) { window.location.hash = `#page=${page}`; } }} min={1} max={numPages} hideControls styles={{ input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16}, }} /> / {numPages} {Math.round(zoom * 100)}%
)}
); }; export default Viewer;