import React, { useEffect, useState, useRef, useCallback } from "react"; import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { pdfWorkerManager } from "../../services/pdfWorkerManager"; 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 CloseIcon from "@mui/icons-material/Close"; import { useLocalStorage } from "@mantine/hooks"; import { fileStorage } from "../../services/fileStorage"; import SkeletonLoader from '../shared/SkeletonLoader'; import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext"; import { useFileWithUrl } from "../../hooks/useFileWithUrl"; // 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 = ({ pageIndex, zoom, theme, isFirst, renderPage, pageImages, setPageRef }: LazyPageImageProps) => { const [isVisible, setIsVisible] = useState(false); const [imageUrl, setImageUrl] = useState(null); 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]); // Update local state when pageImages changes (from preloading) useEffect(() => { if (pageImages[pageIndex]) { setImageUrl(pageImages[pageIndex]); } }, [pageImages, pageIndex]); 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 { sidebarsVisible: boolean; setSidebarsVisible: (v: boolean) => void; onClose?: () => void; previewFile: File | null; // For preview mode - bypasses context } const Viewer = ({ sidebarsVisible, setSidebarsVisible, onClose, previewFile, }: ViewerProps) => { const { t } = useTranslation(); const theme = useMantineTheme(); // Get current file from FileContext const { selectors } = useFileState(); const { actions } = useFileActions(); const currentFile = useCurrentFile(); const getCurrentFile = () => currentFile.file; const getCurrentProcessedFile = () => currentFile.record?.processedFile || undefined; const clearAllFiles = actions.clearAllFiles; const addFiles = actions.addFiles; const activeFiles = selectors.getFiles(); // Tab management for multiple files const [activeTab, setActiveTab] = useState("0"); // Reset PDF state when switching tabs const handleTabChange = (newTab: string) => { setActiveTab(newTab); setNumPages(0); setPageImages([]); setCurrentPage(null); setLoading(true); }; 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)[]>([]); // Memoize setPageRef to prevent infinite re-renders const setPageRef = useCallback((index: number, ref: HTMLImageElement | null) => { pageRefs.current[index] = ref; }, []); // Get files with URLs for tabs - we'll need to create these individually const file0WithUrl = useFileWithUrl(activeFiles[0]); const file1WithUrl = useFileWithUrl(activeFiles[1]); const file2WithUrl = useFileWithUrl(activeFiles[2]); const file3WithUrl = useFileWithUrl(activeFiles[3]); const file4WithUrl = useFileWithUrl(activeFiles[4]); const filesWithUrls = React.useMemo(() => { return [file0WithUrl, file1WithUrl, file2WithUrl, file3WithUrl, file4WithUrl] .slice(0, activeFiles.length) .filter(Boolean); }, [file0WithUrl, file1WithUrl, file2WithUrl, file3WithUrl, file4WithUrl, activeFiles.length]); // Use preview file if available, otherwise use active tab file const effectiveFile = React.useMemo(() => { if (previewFile) { // Validate the preview file if (!(previewFile instanceof File)) { return null; } if (previewFile.size === 0) { return null; } return { file: previewFile, url: null }; } else { // Use the file from the active tab const tabIndex = parseInt(activeTab); return filesWithUrls[tabIndex] || null; } }, [previewFile, filesWithUrls, activeTab]); const scrollAreaRef = useRef(null); const pdfDocRef = useRef(null); const renderingPagesRef = useRef>(new Set()); const currentArrayBufferRef = useRef(null); const preloadingRef = useRef(false); // Function to render a specific page on-demand const renderPage = async (pageIndex: number): Promise => { if (!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; }; // Progressive preloading function const startProgressivePreload = async () => { if (!pdfDocRef.current || preloadingRef.current || numPages === 0) return; preloadingRef.current = true; // Start with first few pages for immediate viewing const priorityPages = [0, 1, 2, 3, 4]; // First 5 pages // Render priority pages first for (const pageIndex of priorityPages) { if (pageIndex < numPages && !pageImages[pageIndex]) { await renderPage(pageIndex); // Small delay to allow UI to update await new Promise(resolve => setTimeout(resolve, 50)); } } // Then render remaining pages in background for (let pageIndex = 5; pageIndex < numPages; pageIndex++) { if (!pageImages[pageIndex]) { await renderPage(pageIndex); // Longer delay for background loading to not block UI await new Promise(resolve => setTimeout(resolve, 100)); } } preloadingRef.current = false; }; // Initialize current page when PDF loads useEffect(() => { if (numPages > 0 && !currentPage) { setCurrentPage(1); } }, [numPages, currentPage]); // Function to scroll to a specific page const scrollToPage = (pageNumber: number) => { const el = pageRefs.current[pageNumber - 1]; const scrollArea = scrollAreaRef.current; if (el && scrollArea) { const scrollAreaRect = scrollArea.getBoundingClientRect(); const elRect = el.getBoundingClientRect(); const currentScrollTop = scrollArea.scrollTop; // Position page near top of viewport with some padding const targetScrollTop = currentScrollTop + (elRect.top - scrollAreaRect.top) - 20; scrollArea.scrollTo({ top: targetScrollTop, behavior: "smooth" }); } }; // Throttled scroll handler to prevent jerky updates const handleScrollThrottled = useCallback(() => { const scrollArea = scrollAreaRef.current; if (!scrollArea || !pageRefs.current.length) return; const areaRect = scrollArea.getBoundingClientRect(); const viewportCenter = areaRect.top + areaRect.height / 2; let closestIdx = 0; let minDist = Infinity; pageRefs.current.forEach((img, idx) => { if (img) { const imgRect = img.getBoundingClientRect(); const imgCenter = imgRect.top + imgRect.height / 2; const dist = Math.abs(imgCenter - viewportCenter); if (dist < minDist) { minDist = dist; closestIdx = idx; } } }); // Update page number display only if changed if (currentPage !== closestIdx + 1) { setCurrentPage(closestIdx + 1); } }, [currentPage]); // Throttle scroll events to reduce jerkiness const handleScroll = useCallback(() => { if (window.requestAnimationFrame) { window.requestAnimationFrame(handleScrollThrottled); } else { handleScrollThrottled(); } }, [handleScrollThrottled]); useEffect(() => { let cancelled = false; async function loadPdfInfo() { if (!effectiveFile) { setNumPages(0); setPageImages([]); return; } setLoading(true); try { let pdfData; // For preview files, use ArrayBuffer directly to avoid blob URL issues if (previewFile && effectiveFile.file === previewFile) { const arrayBuffer = await previewFile.arrayBuffer(); pdfData = { data: arrayBuffer }; } // Handle special IndexedDB URLs for large files else if (effectiveFile.url?.startsWith('indexeddb:')) { const fileId = effectiveFile.url.replace('indexeddb:', ''); // 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; pdfData = { data: arrayBuffer }; } else if (effectiveFile.url) { // Standard blob URL or regular URL pdfData = effectiveFile.url; } else { throw new Error('No valid PDF source available'); } const pdf = await pdfWorkerManager.createDocument(pdfData); pdfDocRef.current = pdf; setNumPages(pdf.numPages); if (!cancelled) { setPageImages(new Array(pdf.numPages).fill(null)); // Start progressive preloading after a short delay setTimeout(() => startProgressivePreload(), 100); } } catch (error) { if (!cancelled) { setPageImages([]); setNumPages(0); } } if (!cancelled) setLoading(false); } loadPdfInfo(); return () => { cancelled = true; // Stop any ongoing preloading preloadingRef.current = false; // Cleanup PDF document using worker manager if (pdfDocRef.current) { pdfWorkerManager.destroyDocument(pdfDocRef.current); pdfDocRef.current = null; } // Cleanup ArrayBuffer reference to help garbage collection currentArrayBufferRef.current = null; }; }, [effectiveFile, previewFile]); useEffect(() => { const viewport = scrollAreaRef.current; if (!viewport) return; const handler = () => { handleScroll(); }; viewport.addEventListener("scroll", handler); return () => viewport.removeEventListener("scroll", handler); }, [pageImages]); return ( {/* Close Button - Only show in preview mode */} {onClose && previewFile && ( )} {!effectiveFile ? (
Error: No file provided to viewer
) : ( <> {/* Tabs for multiple files */} {activeFiles.length > 1 && !previewFile && ( handleTabChange(value || "0")}> {activeFiles.map((file: any, index: number) => ( {file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name} ))} )} {loading ? (
) : ( {numPages === 0 && ( {t("viewer.noPagesToDisplay", "No pages to display.")} )} {dualPage ? Array.from({ length: Math.ceil(numPages / 2) }).map((_, i) => ( {i * 2 + 1 < numPages && ( )} )) : Array.from({ length: numPages }).map((_, idx) => ( ))} {/* Navigation bar overlays the scroll area */}
{ const page = Number(value); if (!isNaN(page) && page >= 1 && page <= numPages) { scrollToPage(page); } }} min={1} max={numPages} hideControls styles={{ input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16}, }} /> / {numPages} {Math.round(zoom * 100)}%
)} )}
); }; export default Viewer;