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 { useTranslation } from "react-i18next"; 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"; import { fileStorage } from "../services/fileStorage"; GlobalWorkerOptions.workerSrc = "/pdf.worker.js"; // Lazy loading page image component interface LazyPageImageProps { pageIndex: number; zoom: number; theme: any; isFirst: boolean; renderPage: (pageIndex: number) => Promise; pageImages: (string | null)[]; setPageRef: (index: number, ref: HTMLImageElement | null) => void; } const LazyPageImage: React.FC = ({ pageIndex, zoom, theme, isFirst, renderPage, pageImages, setPageRef }) => { const [isVisible, setIsVisible] = useState(false); const [imageUrl, setImageUrl] = useState(pageImages[pageIndex]); const imgRef = useRef(null); useEffect(() => { const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting && !imageUrl) { setIsVisible(true); } }); }, { rootMargin: '200px', // Start loading 200px before visible threshold: 0.1 } ); if (imgRef.current) { observer.observe(imgRef.current); } return () => observer.disconnect(); }, [imageUrl]); useEffect(() => { if (isVisible && !imageUrl) { renderPage(pageIndex).then((url) => { if (url) setImageUrl(url); }); } }, [isVisible, imageUrl, pageIndex, renderPage]); useEffect(() => { if (imgRef.current) { setPageRef(pageIndex, imgRef.current); } }, [pageIndex, setPageRef]); if (imageUrl) { return ( {`Page ); } // Placeholder while loading return (
{isVisible ? (
Loading page {pageIndex + 1}...
) : ( Page {pageIndex + 1} )}
); }; 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 { t } = useTranslation(); 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); const pdfDocRef = useRef(null); const renderingPagesRef = useRef>(new Set()); const currentArrayBufferRef = useRef(null); // Function to render a specific page on-demand const renderPage = async (pageIndex: number): Promise => { if (!pdfFile || !pdfDocRef.current || renderingPagesRef.current.has(pageIndex)) { return null; } const pageNum = pageIndex + 1; if (pageImages[pageIndex]) { return pageImages[pageIndex]; // Already rendered } renderingPagesRef.current.add(pageIndex); try { const page = await pdfDocRef.current.getPage(pageNum); 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; const dataUrl = canvas.toDataURL(); // Update the pageImages array setPageImages(prev => { const newImages = [...prev]; newImages[pageIndex] = dataUrl; return newImages; }); renderingPagesRef.current.delete(pageIndex); return dataUrl; } } catch (error) { console.error(`Failed to render page ${pageNum}:`, error); } renderingPagesRef.current.delete(pageIndex); return null; }; // 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 loadPdfInfo() { if (!pdfFile || !pdfFile.url) { setNumPages(0); setPageImages([]); return; } setLoading(true); try { let pdfUrl = pdfFile.url; // Handle special IndexedDB URLs for large files if (pdfFile.url.startsWith('indexeddb:')) { const fileId = pdfFile.url.replace('indexeddb:', ''); console.log('Loading large file from IndexedDB:', fileId); // Get data directly from IndexedDB const arrayBuffer = await fileStorage.getFileData(fileId); if (!arrayBuffer) { throw new Error('File not found in IndexedDB - may have been purged by browser'); } // Store reference for cleanup currentArrayBufferRef.current = arrayBuffer; // Use ArrayBuffer directly instead of creating blob URL const pdf = await getDocument({ data: arrayBuffer }).promise; pdfDocRef.current = pdf; setNumPages(pdf.numPages); if (!cancelled) setPageImages(new Array(pdf.numPages).fill(null)); } else { // Standard blob URL or regular URL const pdf = await getDocument(pdfUrl).promise; pdfDocRef.current = pdf; setNumPages(pdf.numPages); if (!cancelled) setPageImages(new Array(pdf.numPages).fill(null)); } } catch (error) { console.error('Failed to load PDF:', error); if (!cancelled) { setPageImages([]); setNumPages(0); } } if (!cancelled) setLoading(false); } loadPdfInfo(); return () => { cancelled = true; // Cleanup ArrayBuffer reference to help garbage collection currentArrayBufferRef.current = null; }; }, [pdfFile]); useEffect(() => { const viewport = scrollAreaRef.current; if (!viewport) return; const handler = () => { handleScroll(); }; viewport.addEventListener("scroll", handler); return () => viewport.removeEventListener("scroll", handler); }, [pageImages]); return ( {!pdfFile ? (
{t("viewer.noPdfLoaded", "No PDF loaded. Click to upload a PDF.")}
) : loading ? (
) : ( {numPages === 0 && ( {t("viewer.noPagesToDisplay", "No pages to display.")} )} {dualPage ? Array.from({ length: Math.ceil(numPages / 2) }).map((_, i) => ( { pageRefs.current[index] = ref; }} /> {i * 2 + 1 < numPages && ( { pageRefs.current[index] = ref; }} /> )} )) : Array.from({ length: numPages }).map((_, idx) => ( { pageRefs.current[index] = ref; }} /> ))} {/* 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;