openreader/src/components/views/PDFViewer.tsx

384 lines
13 KiB
TypeScript

'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<HTMLDivElement>(null);
const [isPageRendering, setIsPageRendering] = useState(false);
const scaleRef = useRef<number>(1);
const { containerWidth, containerHeight } = usePDFResize(containerRef);
const sentenceHighlightSeqRef = useRef(0);
const wordHighlightSeqRef = useRef(0);
const sentenceHighlightTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const wordHighlightTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const lastSentenceLayoutKeyRef = useRef<string>('');
const lastWordLayoutKeyRef = useRef<string>('');
// 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<string>('');
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<HTMLDivElement>);
};
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<HTMLDivElement>
);
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<number>(595); // default A4 width
const [pageHeight, setPageHeight] = useState<number>(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 (
<div
ref={containerRef}
className="flex flex-col items-center overflow-auto w-full px-6 h-full pdf-viewer"
>
<Document
key={currDocId || 'pdf'}
loading={<DocumentSkeleton />}
noData={<DocumentSkeleton />}
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<number> || [];
const pageNum = destArray[0] + 1 || null;
if (pageNum) {
skipToLocation(pageNum, true);
}
}
}}
className="flex flex-col items-center m-0 z-0"
>
<div className="pdf-page-stage" data-rendering={isPageRendering ? 'true' : 'false'}>
{viewType === 'scroll' ? (
// Scroll mode: render all pages
<div className="flex flex-col gap-4">
{currDocPages && [...Array(currDocPages)].map((_, i) => (
<Page
key={`page_${i + 1}`}
pageNumber={i + 1}
renderAnnotationLayer={true}
renderTextLayer={i + 1 === currDocPage}
className="shadow-lg"
scale={currentScale()}
onRenderSuccess={() => {
lastRenderedLayoutKeyRef.current = layoutKey;
setIsPageRendering(false);
}}
onLoadSuccess={(page) => {
setPageWidth(page.originalWidth);
setPageHeight(page.originalHeight);
}}
/>
))}
</div>
) : (
// Single/Dual page mode
<div className="flex justify-center gap-4">
{currDocPages && leftPage > 0 && (
<Page
key={`page_${leftPage}`}
pageNumber={leftPage}
renderAnnotationLayer={true}
renderTextLayer={leftPage === currDocPage}
className="shadow-lg"
scale={currentScale()}
onRenderSuccess={() => {
lastRenderedLayoutKeyRef.current = layoutKey;
setIsPageRendering(false);
}}
onLoadSuccess={(page) => {
setPageWidth(page.originalWidth);
setPageHeight(page.originalHeight);
}}
/>
)}
{currDocPages && rightPage && rightPage <= currDocPages && viewType === 'dual' && (
<Page
key={`page_${rightPage}`}
pageNumber={rightPage}
renderAnnotationLayer={true}
renderTextLayer={rightPage === currDocPage}
className="shadow-lg"
scale={currentScale()}
onRenderSuccess={() => {
lastRenderedLayoutKeyRef.current = layoutKey;
setIsPageRendering(false);
}}
onLoadSuccess={(page) => {
setPageWidth(page.originalWidth);
setPageHeight(page.originalHeight);
}}
/>
)}
</div>
)}
</div>
</Document>
</div>
);
}