Add PDF page virtualization + virtualized scaling + make highlight better

This commit is contained in:
Richard Roberson 2025-01-23 00:55:46 -07:00
parent 86c263cb86
commit eb88262b34
5 changed files with 180 additions and 69 deletions

54
package-lock.json generated
View file

@ -10,6 +10,7 @@
"dependencies": {
"@headlessui/react": "^2.2.0",
"@types/howler": "^2.2.12",
"@types/react-window": "^1.8.8",
"@types/string-similarity": "^4.0.2",
"@types/uuid": "^10.0.0",
"compromise": "^14.14.4",
@ -22,6 +23,7 @@
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.5",
"react-pdf": "^9.2.1",
"react-window": "^1.8.11",
"string-similarity": "^4.0.4",
"uuid": "^11.0.5"
},
@ -50,6 +52,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@babel/runtime": {
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
"integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz",
@ -1153,7 +1167,6 @@
"version": "19.0.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.7.tgz",
"integrity": "sha512-MoFsEJKkAtZCrC1r6CM8U22GzhG7u2Wir8ons/aCKH6MBdD1ibV24zOSSkdZVUKqN5i396zG5VKLYZ3yaUZdLA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@ -1169,6 +1182,15 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/react-window": {
"version": "1.8.8",
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/string-similarity": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/string-similarity/-/string-similarity-4.0.2.tgz",
@ -2186,7 +2208,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@ -4449,6 +4470,12 @@
"node": ">= 0.4"
}
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/merge-refs": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-1.3.0.tgz",
@ -5556,6 +5583,23 @@
}
}
},
"node_modules/react-window": {
"version": "1.8.11",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
"integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.0.0",
"memoize-one": ">=3.1.1 <6"
},
"engines": {
"node": ">8.0.0"
},
"peerDependencies": {
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -5617,6 +5661,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",

View file

@ -11,6 +11,7 @@
"dependencies": {
"@headlessui/react": "^2.2.0",
"@types/howler": "^2.2.12",
"@types/react-window": "^1.8.8",
"@types/string-similarity": "^4.0.2",
"@types/uuid": "^10.0.0",
"compromise": "^14.14.4",
@ -23,6 +24,7 @@
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.5",
"react-pdf": "^9.2.1",
"react-window": "^1.8.11",
"string-similarity": "^4.0.4",
"uuid": "^11.0.5"
},

View file

@ -35,3 +35,7 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.pdf-list > div {
padding: 0 !important;
}

View file

@ -1,19 +1,62 @@
'use client';
import { RefObject } from 'react';
import { RefObject, useCallback, useState, useEffect, useRef } from 'react';
import { Document, Page } from 'react-pdf';
import { FixedSizeList } from 'react-window';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
import { useState, useEffect, useRef } from 'react';
import { PDFSkeleton } from './PDFSkeleton';
import { useTTS } from '@/contexts/TTSContext';
import { usePDF } from '@/contexts/PDFContext';
import { pdfjs } from 'react-pdf';
interface PDFViewerProps {
pdfData: Blob | undefined;
zoomLevel: number;
}
interface PageItemData {
numPages: number;
scale: number;
containerWidth: number;
pageWidth: number;
pageHeight: number;
}
const PageComponent = ({ index, style, data }: { index: number; style: any; data: PageItemData }) => {
const { numPages, scale, pageHeight } = data;
return (
<div style={{
...style,
height: pageHeight,
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}>
{index==0 ? (<div className="bg-offbase m-4 px-2 py-0.5 rounded-full w-fit">
<p className="text-xs">
{index + 1} / {numPages}
</p>
</div>) :
(<div className="bg-offbase m-4 px-2 py-0.5 rounded-full w-fit">
<p className="text-xs">
{index + 1} / {numPages}
</p>
</div>)}
<div className="flex justify-center">
<Page
pageNumber={index + 1}
renderAnnotationLayer={true}
renderTextLayer={true}
className="shadow-lg"
scale={scale}
/>
</div>
</div>
);
};
export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
const [numPages, setNumPages] = useState<number>();
const [containerWidth, setContainerWidth] = useState<number>(0);
@ -23,6 +66,8 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
const [loadingError, setLoadingError] = useState<string>();
const containerRef = useRef<HTMLDivElement>(null);
const { extractTextFromPDF, highlightPattern, clearHighlights, handleTextClick } = usePDF();
const [pageHeight, setPageHeight] = useState(800); // Default height
const [pageSize, setPageSize] = useState({ width: 595, height: 842 }); // A4 default
// Add static styles once during component initialization
const styleElement = document.createElement('style');
@ -146,12 +191,18 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
}, [pdfText, currentSentence, highlightPattern, clearHighlights]);
// Add scale calculation function
const calculateScale = (pageWidth: number = 595) => { // 595 is default PDF width in points
const margin = 24; // 24px padding on each side
const targetWidth = containerWidth - margin;
const baseScale = targetWidth / pageWidth;
const calculateScale = useCallback(() => {
//const margin = 48; // 24px padding on each side
//const targetWidth = containerWidth - margin;
const baseScale = containerWidth / pageSize.width;
return baseScale * (zoomLevel / 100);
};
}, [containerWidth, zoomLevel, pageSize.width]);
const calculatePageHeight = useCallback((viewport: { width: number; height: number }) => {
const scale = calculateScale();
const scaledHeight = viewport.height * scale;
return scaledHeight + 55; // 40px padding top and bottom
}, [calculateScale]);
// Add resize observer effect
useEffect(() => {
@ -168,9 +219,37 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
return () => observer.disconnect();
}, []);
function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
const handleLoadSuccess = useCallback(async ({ numPages }: { numPages: number }) => {
setNumPages(numPages);
}
if (pdfData) {
try {
// Convert Blob to ArrayBuffer
const arrayBuffer = await pdfData.arrayBuffer();
const pdf = await pdfjs.getDocument(arrayBuffer).promise;
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 1 });
setPageSize({
width: viewport.width,
height: viewport.height
});
const newPageHeight = calculatePageHeight(viewport);
setPageHeight(newPageHeight);
} catch (error) {
console.error('Error measuring page:', error);
}
}
}, [pdfData, calculatePageHeight]);
// Update page height when zoom level changes
useEffect(() => {
if (pageSize.width && pageSize.height) {
const newPageHeight = calculatePageHeight(pageSize);
setPageHeight(newPageHeight);
}
}, [zoomLevel, pageSize, calculatePageHeight]);
return (
<div
@ -185,30 +264,28 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
loading={<PDFSkeleton />}
noData={<PDFSkeleton />}
file={pdfDataUrl}
onLoadSuccess={onDocumentLoadSuccess}
className="flex flex-col items-center m-0"
onLoadSuccess={handleLoadSuccess}
className="flex flex-col items-center"
>
{Array.from(
new Array(numPages),
(el, index) => (
<div key={`page_${index + 1}`}>
<div className="bg-offbase my-4 px-2 py-0.5 rounded-full w-fit">
<p className="text-xs">
{index + 1} / {numPages}
</p>
</div>
<div className="flex justify-center">
<Page
pageNumber={index + 1}
renderAnnotationLayer={true}
renderTextLayer={true}
className="shadow-lg"
scale={calculateScale()}
/>
</div>
</div>
),
)}
{numPages && containerWidth ? (
<FixedSizeList
height={Math.min(window.innerHeight - 100, numPages * pageHeight)}
width={containerWidth}
itemCount={numPages}
itemSize={pageHeight}
itemData={{
numPages,
scale: calculateScale(),
containerWidth,
pageWidth: pageSize.width,
pageHeight: pageHeight,
}}
className="pdf-list"
overscanCount={2} // Add overscanning for smoother scrolling
>
{PageComponent}
</FixedSizeList>
) : null}
</Document>
</div>
);

View file

@ -276,48 +276,26 @@ export function PDFProvider({ children }: { children: ReactNode }) {
text: (node.textContent || '').trim(),
})).filter((node) => node.text.length > 0);
// Calculate the visible area of the container
const containerRect = container.getBoundingClientRect();
const visibleTop = container.scrollTop;
const visibleBottom = visibleTop + containerRect.height;
// Find nodes within the visible area and a buffer zone
const bufferSize = containerRect.height; // One screen height buffer
const visibleNodes = allText.filter(({ element }) => {
const rect = element.getBoundingClientRect();
const elementTop = rect.top - containerRect.top + container.scrollTop;
return elementTop >= (visibleTop - bufferSize) && elementTop <= (visibleBottom + bufferSize);
});
// Search for the best match within the visible area first
let bestMatch = findBestTextMatch(visibleNodes, cleanPattern, cleanPattern.length * 2);
// If no good match found in visible area, search the entire document
if (bestMatch.rating < 0.3) {
bestMatch = findBestTextMatch(allText, cleanPattern, cleanPattern.length * 2);
}
// Search for the best match in the entire document
const bestMatch = findBestTextMatch(allText, cleanPattern, cleanPattern.length * 2);
const similarityThreshold = bestMatch.lengthDiff < cleanPattern.length * 0.3 ? 0.3 : 0.5;
if (bestMatch.rating >= similarityThreshold) {
if (bestMatch.rating >= similarityThreshold && bestMatch.elements.length > 0) {
// Highlight all matching elements
bestMatch.elements.forEach((element) => {
element.style.backgroundColor = 'grey';
element.style.opacity = '0.4';
});
if (bestMatch.elements.length > 0) {
const element = bestMatch.elements[0];
const elementRect = element.getBoundingClientRect();
const elementTop = elementRect.top - containerRect.top + container.scrollTop;
// Only scroll if the element is outside the visible area
if (elementTop < visibleTop || elementTop > visibleBottom) {
container.scrollTo({
top: elementTop - containerRect.height / 3, // Position the highlight in the top third
behavior: 'smooth',
});
}
}
// Get the first element to scroll to
const firstElement = bestMatch.elements[0];
// Scroll the element into view with smooth behavior
firstElement.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
}
}, [clearHighlights, findBestTextMatch]);