'use client'; import { RefObject, useCallback, useState, useEffect, useRef, useMemo } from 'react'; import { Document, Page } from 'react-pdf'; import type { Dest } from 'react-pdf/src/shared/types.js'; import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/TextLayer.css'; import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton'; import { useTTS } from '@/contexts/TTSContext'; import { usePDF } from '@/contexts/PDFContext'; import { useConfig } from '@/contexts/ConfigContext'; import { usePDFResize } from '@/hooks/pdf/usePDFResize'; interface PDFViewerProps { zoomLevel: number; } interface PDFOnLinkClickArgs { pageNumber?: number; dest?: Dest; } export function PDFViewer({ zoomLevel }: PDFViewerProps) { const containerRef = useRef(null); const [isPageRendering, setIsPageRendering] = useState(false); const scaleRef = useRef(1); const { containerWidth, containerHeight } = usePDFResize(containerRef); const sentenceHighlightSeqRef = useRef(0); const wordHighlightSeqRef = useRef(0); const sentenceHighlightTimeoutsRef = useRef[]>([]); const wordHighlightTimeoutsRef = useRef[]>([]); const lastSentenceLayoutKeyRef = useRef(''); const lastWordLayoutKeyRef = useRef(''); // Config context const { viewType, pdfHighlightEnabled, pdfWordHighlightEnabled } = useConfig(); // TTS context const { currentSentence, currentWordIndex, currentSentenceAlignment, skipToLocation, } = useTTS(); // PDF context const { highlightPattern, clearHighlights, clearWordHighlights, highlightWordIndex, onDocumentLoadSuccess, currDocId, currDocData, currDocPages, currDocText, currDocPage, } = usePDF(); // IMPORTANT: // - pdf.js may transfer/detach ArrayBuffers when sending them to its worker, so we must clone. // - react-pdf warns if `file` changes by reference but is deep-equal to the previous value. // We use useMemo to create a stable file object that only changes when currDocId or currDocData changes. const documentFile = useMemo(() => { if (!currDocId || !currDocData) return undefined; try { return { data: new Uint8Array(currDocData.slice(0)) }; } catch (e) { console.error('Failed to prepare PDF data for viewer:', e); return undefined; } }, [currDocId, currDocData]); const layoutKey = `${zoomLevel}:${containerWidth}:${containerHeight}:${viewType}:${currDocPage}`; // Track page turns so we can keep the previous canvas visible until the new one paints. const lastRenderedLayoutKeyRef = useRef(''); useEffect(() => { if (layoutKey !== lastRenderedLayoutKeyRef.current) { setIsPageRendering(true); } }, [layoutKey]); const clearSentenceHighlightTimeouts = useCallback(() => { for (const t of sentenceHighlightTimeoutsRef.current) clearTimeout(t); sentenceHighlightTimeoutsRef.current = []; }, []); const clearWordHighlightTimeouts = useCallback(() => { for (const t of wordHighlightTimeoutsRef.current) clearTimeout(t); wordHighlightTimeoutsRef.current = []; }, []); const scheduleSentenceTimeout = useCallback((fn: () => void, ms: number) => { const t = setTimeout(fn, ms); sentenceHighlightTimeoutsRef.current.push(t); }, []); const scheduleWordTimeout = useCallback((fn: () => void, ms: number) => { const t = setTimeout(fn, ms); wordHighlightTimeoutsRef.current.push(t); }, []); useEffect(() => { /* * Handles highlighting the current sentence being read by TTS. * Includes a small delay for smooth highlighting and cleans up on unmount. * * Dependencies: * - pdfText: Re-run when the text content changes * - currentSentence: Re-run when the TTS position changes * - highlightPattern: Function from context that could change * - clearHighlights: Function from context that could change */ if (!currDocText || !pdfHighlightEnabled) { clearHighlights(); return; } clearSentenceHighlightTimeouts(); if (!currentSentence) { // Cancel any in-flight retry loops and ensure stale highlights don't remain // when the current sentence becomes null/undefined. sentenceHighlightSeqRef.current += 1; clearHighlights(); return; } const seq = ++sentenceHighlightSeqRef.current; const isLayoutChange = layoutKey !== lastSentenceLayoutKeyRef.current; lastSentenceLayoutKeyRef.current = layoutKey; if (isLayoutChange) { clearHighlights(); } const tryApply = (attempt: number) => { if (seq !== sentenceHighlightSeqRef.current) return; const container = containerRef.current; if (!container) return; const spans = container.querySelectorAll('.react-pdf__Page__textContent span'); if (!spans.length) { if (attempt < 10) scheduleSentenceTimeout(() => tryApply(attempt + 1), 75); return; } highlightPattern(currDocText, currentSentence, containerRef as RefObject); }; scheduleSentenceTimeout(() => tryApply(0), 200); return () => { clearSentenceHighlightTimeouts(); clearHighlights(); }; }, [ currDocText, currentSentence, highlightPattern, clearHighlights, pdfHighlightEnabled, layoutKey, clearSentenceHighlightTimeouts, scheduleSentenceTimeout ]); // Word-level highlight layered on top of the block highlight useEffect(() => { clearWordHighlightTimeouts(); if (!pdfHighlightEnabled || !pdfWordHighlightEnabled) { clearWordHighlights(); return; } if (!currentSentence || currentWordIndex === null || currentWordIndex === undefined || currentWordIndex < 0) { clearWordHighlights(); return; } const wordEntry = currentSentenceAlignment && currentWordIndex < currentSentenceAlignment.words.length ? currentSentenceAlignment.words[currentWordIndex] : undefined; const wordText = wordEntry?.text || null; if (!wordText) { clearWordHighlights(); return; } const seq = ++wordHighlightSeqRef.current; const isLayoutChange = layoutKey !== lastWordLayoutKeyRef.current; lastWordLayoutKeyRef.current = layoutKey; const tryApplyWord = (attempt: number) => { if (seq !== wordHighlightSeqRef.current) return; const container = containerRef.current; if (!container) return; highlightWordIndex( currentSentenceAlignment, currentWordIndex, currentSentence || '', containerRef as RefObject ); if (isLayoutChange) { // If we don't see a word overlay yet, the sentence highlight worker may not // have produced `lastSentenceTokenWindow` (or the text layer isn't ready). const overlayCount = container.querySelectorAll('.pdf-word-highlight-overlay').length; if (overlayCount === 0 && attempt < 12) { scheduleWordTimeout(() => tryApplyWord(attempt + 1), 75); } } }; const cleanup = () => { clearWordHighlightTimeouts(); }; if (isLayoutChange) { clearWordHighlights(); scheduleWordTimeout(() => tryApplyWord(0), 250); return cleanup; } tryApplyWord(0); return cleanup; }, [ currentWordIndex, currentSentence, currentSentenceAlignment, pdfHighlightEnabled, pdfWordHighlightEnabled, clearWordHighlights, highlightWordIndex, layoutKey, clearWordHighlightTimeouts, scheduleWordTimeout ]); // Add page dimensions state const [pageWidth, setPageWidth] = useState(595); // default A4 width const [pageHeight, setPageHeight] = useState(842); // default A4 height // Calculate which pages to show based on viewType const leftPage = viewType === 'dual' ? (currDocPage % 2 === 0 ? currDocPage - 1 : currDocPage) : currDocPage; const rightPage = viewType === 'dual' ? (currDocPage % 2 === 0 ? currDocPage : currDocPage + 1) : null; // Modify scale calculation to be more efficient const calculateScale = useCallback((width = pageWidth, height = pageHeight): number => { const margin = viewType === 'dual' ? 48 : 24; // adjust margin based on view type const effectiveContainerHeight = containerHeight || (containerRef.current?.clientHeight ?? window.innerHeight); const targetWidth = viewType === 'dual' ? (containerWidth - margin) / 2 // divide by 2 for dual pages : containerWidth - margin; const targetHeight = effectiveContainerHeight - margin; if (viewType === 'scroll') { // For scroll mode, use a more comfortable width-based scale // Use 75% of the width-based scale to make it less zoomed in const scaleByWidth = (targetWidth / width) * 0.75; return scaleByWidth * (zoomLevel / 100); } const scaleByWidth = targetWidth / width; const scaleByHeight = targetHeight / height; const baseScale = Math.min(scaleByWidth, scaleByHeight); return baseScale * (zoomLevel / 100); }, [containerWidth, containerHeight, zoomLevel, pageWidth, pageHeight, viewType]); // Add memoized scale to prevent unnecessary recalculations const currentScale = useCallback(() => { const newScale = calculateScale(); if (Math.abs(newScale - scaleRef.current) > 0.01) { scaleRef.current = newScale; } return scaleRef.current; }, [calculateScale]); return (
} noData={} file={documentFile} onLoadSuccess={(pdf) => { onDocumentLoadSuccess(pdf); }} onItemClick={(args: PDFOnLinkClickArgs) => { if (args?.pageNumber) { skipToLocation(args.pageNumber, true); } else if (args?.dest) { const destArray = args.dest as Array || []; const pageNum = destArray[0] + 1 || null; if (pageNum) { skipToLocation(pageNum, true); } } }} className="flex flex-col items-center m-0 z-0" >
{viewType === 'scroll' ? ( // Scroll mode: render all pages
{currDocPages && [...Array(currDocPages)].map((_, i) => ( { lastRenderedLayoutKeyRef.current = layoutKey; setIsPageRendering(false); }} onLoadSuccess={(page) => { setPageWidth(page.originalWidth); setPageHeight(page.originalHeight); }} /> ))}
) : ( // Single/Dual page mode
{currDocPages && leftPage > 0 && ( { lastRenderedLayoutKeyRef.current = layoutKey; setIsPageRendering(false); }} onLoadSuccess={(page) => { setPageWidth(page.originalWidth); setPageHeight(page.originalHeight); }} /> )} {currDocPages && rightPage && rightPage <= currDocPages && viewType === 'dual' && ( { lastRenderedLayoutKeyRef.current = layoutKey; setIsPageRendering(false); }} onLoadSuccess={(page) => { setPageWidth(page.originalWidth); setPageHeight(page.originalHeight); }} /> )}
)}
); }