Readme.md + Refactor

This commit is contained in:
Richard Roberson 2025-01-19 15:39:44 -07:00
parent 728eb185b7
commit 46c497f062
5 changed files with 427 additions and 411 deletions

View file

@ -1,36 +1,89 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# OpenReader WebUI
OpenReader WebUI is a modern, user-friendly web interface for reading and analyzing PDF documents. Built with Next.js and React, it provides an intuitive interface for document viewing, analysis, and interaction. The application features drop-in support for any OpenAI-compatible Text-to-Speech (TTS) API, making it highly flexible for various voice synthesis implementations.
## Features
- PDF document viewing and navigation
- Interactive document interface
- Modern UI with Tailwind CSS
- Fast performance with Next.js and Turbopack
- PDF text analysis capabilities
- Drop-in support for OpenAI-compatible TTS APIs
- Responsive design for various screen sizes
## Tech Stack
- **Framework**: [Next.js 15](https://nextjs.org/)
- **UI Library**: [React 19](https://react.dev/)
- **Styling**: [Tailwind CSS](https://tailwindcss.com/)
- **PDF Processing**: [React-PDF](https://react-pdf.org/) and [PDF.js](https://mozilla.github.io/pdf.js/)
- **UI Components**: [Headless UI](https://headlessui.com/)
- **Type Safety**: [TypeScript](https://www.typescriptlang.org/)
## Prerequisites
- Node.js 18.x or higher
- npm or yarn package manager
## Getting Started
First, run the development server:
1. Clone the repository:
```bash
git clone [repository-url]
cd openreader-webui
```
2. Install dependencies:
```bash
npm install
# or
yarn install
```
3. Set up environment variables:
```bash
cp .env.template .env
```
Edit the `.env` file with your configuration settings.
4. Start the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
The application will be available at [http://localhost:3000](http://localhost:3000).
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## Available Scripts
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
- `npm run dev` - Start development server with Turbopack
- `npm run build` - Create production build
- `npm run start` - Start production server
- `npm run lint` - Run ESLint for code quality
## Learn More
## Project Structure
To learn more about Next.js, take a look at the following resources:
```
openreader-webui/
├── src/
│ ├── app/ # Next.js app router pages
│ ├── components/ # Reusable React components
│ ├── contexts/ # React context providers
│ └── services/ # Business logic and services
├── public/ # Static assets
└── scripts/ # Utility scripts
```
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## Contributing
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
Contributions are welcome! Please feel free to submit a Pull Request.
## Deploy on Vercel
## License
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
This project is licensed under the terms of the license included in the repository.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
---
Built with ❤️ using [Next.js](https://nextjs.org/)

View file

@ -1,40 +1,55 @@
'use client';
import { Document, Page, pdfjs } from 'react-pdf';
import { RefObject } from 'react';
import { Document, Page } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useState, useEffect, useRef } from 'react';
import { PDFSkeleton } from './PDFSkeleton';
import { useTTS } from '@/contexts/TTSContext';
import stringSimilarity from 'string-similarity';
import nlp from 'compromise';
// Set worker from public directory
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs';
import { usePDF } from '@/contexts/PDFContext';
interface PDFViewerProps {
pdfData: Blob | undefined;
}
interface TextHighlight {
pageIndex: number;
content: string;
position: {
boundingRect: DOMRect;
};
}
export function PDFViewer({ pdfData }: PDFViewerProps) {
const [numPages, setNumPages] = useState<number>();
const { setText, currentSentence, stopAndPlayFromIndex, sentences } = useTTS();
const { setText, currentSentence, stopAndPlayFromIndex } = useTTS();
const [pdfText, setPdfText] = useState('');
const [highlights, setHighlights] = useState<TextHighlight[]>([]);
const [pdfDataUrl, setPdfDataUrl] = useState<string>();
const [loadingError, setLoadingError] = useState<string>();
const containerRef = useRef<HTMLDivElement>(null);
const { extractTextFromPDF, highlightPattern, clearHighlights, handleTextClick } = usePDF();
// Convert Blob to data URL when pdfData changes
// Add static styles once during component initialization
const styleElement = document.createElement('style');
styleElement.textContent = `
.react-pdf__Page__textContent span {
cursor: pointer;
transition: background-color 0.2s ease;
}
.react-pdf__Page__textContent span:hover {
background-color: rgba(255, 255, 0, 0.2) !important;
}
`;
document.head.appendChild(styleElement);
// Cleanup styles when component unmounts
useEffect(() => {
return () => {
styleElement.remove();
};
}, []);
useEffect(() => {
/*
* Converts PDF blob to a data URL for display.
* Cleans up by clearing the data URL when component unmounts.
*
* Dependencies:
* - pdfData: Re-run when the PDF blob changes to convert it to a new data URL
*/
if (!pdfData) return;
const reader = new FileReader();
@ -52,410 +67,90 @@ export function PDFViewer({ pdfData }: PDFViewerProps) {
};
}, [pdfData]);
// Load PDF text content
useEffect(() => {
if (!pdfDataUrl) return;
let isCurrentPdf = true;
let currentLoadingTask: any = null;
setLoadingError(undefined);
/*
* Extracts text content from the PDF once it's loaded.
* Sets the extracted text for both display and text-to-speech.
*
* Dependencies:
* - pdfDataUrl: Re-run when the data URL is ready
* - extractTextFromPDF: Function from context that could change
* - setText: Function from context that could change
* - pdfData: Source PDF blob that's being processed
*/
if (!pdfDataUrl || !pdfData) return;
const loadPdfText = async () => {
try {
// Create a typed array from the base64 data
const base64Data = pdfDataUrl.split(',')[1];
const binaryData = atob(base64Data);
const length = binaryData.length;
const bytes = new Uint8Array(length);
for (let i = 0; i < length; i++) {
bytes[i] = binaryData.charCodeAt(i);
}
const loadingTask = pdfjs.getDocument({
data: bytes,
disableAutoFetch: true,
disableStream: false,
});
currentLoadingTask = loadingTask;
const pdf = await loadingTask.promise;
if (!isCurrentPdf) return;
let fullText = '';
for (let i = 1; i <= pdf.numPages; i++) {
if (!isCurrentPdf) break;
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items
.map((item: any) => item.str)
.join(' ');
fullText += pageText + ' ';
}
if (!isCurrentPdf) return;
console.log('Loaded PDF text sample:', fullText.substring(0, 100));
setPdfText(fullText);
setText(fullText);
const text = await extractTextFromPDF(pdfData);
setPdfText(text);
setText(text);
} catch (error) {
if (!isCurrentPdf) return;
console.error('Error loading PDF text:', error);
setLoadingError('Failed to extract PDF text');
}
};
loadPdfText();
}, [pdfDataUrl, extractTextFromPDF, setText, pdfData]);
return () => {
isCurrentPdf = false;
if (currentLoadingTask) {
currentLoadingTask.destroy();
}
setPdfText('');
};
}, [pdfDataUrl, setText]);
// Function to clear all highlights
const clearHighlights = useCallback(() => {
const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span');
textNodes.forEach((node) => {
const element = node as HTMLElement;
element.style.backgroundColor = '';
element.style.opacity = '1';
});
}, []);
function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
setNumPages(numPages);
}
const findBestMatch = useCallback((searchText: string, content: string) => {
if (!searchText?.trim() || !content?.trim()) {
return null;
}
try {
// Ensure we have valid sentences before matching
if (sentences.length === 0) {
return null;
}
// Find the best matching sentence
const matches = stringSimilarity.findBestMatch(searchText.trim(), sentences);
return matches.bestMatch.rating >= 0.5 ? matches.bestMatch : null;
} catch (error) {
console.error('Error in findBestMatch:', error);
return null;
}
}, [sentences]);
const highlightPattern = useCallback((text: string, pattern: string) => {
console.log('Highlighting pattern:', pattern);
// Always clear existing highlights first
clearHighlights();
if (!pattern?.trim()) {
console.log('No pattern to highlight');
return;
}
// Clean up the pattern
const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
const patternLength = cleanPattern.length;
console.log('Clean pattern:', cleanPattern, 'Length:', patternLength);
// Get all text nodes within the container
useEffect(() => {
/*
* Sets up click event listeners for text selection in the PDF.
* Cleans up by removing the event listener when component unmounts.
*
* Dependencies:
* - pdfText: Re-run when the extracted text content changes
* - handleTextClick: Function from context that could change
* - stopAndPlayFromIndex: Function from context that could change
*/
const container = containerRef.current;
if (!container) return;
const textNodes = container.querySelectorAll('.react-pdf__Page__textContent span');
const allText = Array.from(textNodes).map(node => ({
element: node as HTMLElement,
text: (node.textContent || '').trim()
})).filter(node => node.text.length > 0); // Remove empty nodes
// Find the best matching position
let bestMatch = {
elements: [] as HTMLElement[],
rating: 0,
text: '',
lengthDiff: Infinity
};
// Try different combinations of consecutive spans
for (let i = 0; i < allText.length; i++) {
let combinedText = '';
let currentElements = [];
// Look ahead up to 10 spans, but stop if we exceed 2x pattern length
for (let j = i; j < Math.min(i + 10, allText.length); j++) {
const node = allText[j];
const newText = combinedText + (combinedText ? ' ' : '') + node.text;
// Stop if we're getting too far from target length
if (newText.length > patternLength * 2) {
break;
}
combinedText = newText;
currentElements.push(node.element);
// Calculate similarity and length difference
const similarity = stringSimilarity.compareTwoStrings(cleanPattern, combinedText);
const lengthDiff = Math.abs(combinedText.length - patternLength);
// Score based on both similarity and length difference
const lengthPenalty = lengthDiff / patternLength; // Normalized length difference
const adjustedRating = similarity * (1 - lengthPenalty * 0.5); // Reduce score based on length difference
// console.log('Comparing:', {
// text: combinedText,
// similarity,
// lengthDiff,
// adjustedRating
// });
// Update best match if we have better adjusted rating
if (adjustedRating > bestMatch.rating) {
bestMatch = {
elements: [...currentElements],
rating: adjustedRating,
text: combinedText,
lengthDiff
};
}
}
}
// Only highlight if we found a good match
// Adjust threshold based on match quality
const similarityThreshold = bestMatch.lengthDiff < patternLength * 0.3 ? 0.3 : 0.5;
if (bestMatch.rating >= similarityThreshold) {
console.log('Found match:', {
rating: bestMatch.rating,
text: bestMatch.text,
lengthDiff: bestMatch.lengthDiff,
threshold: similarityThreshold
});
bestMatch.elements.forEach(element => {
element.style.backgroundColor = 'grey';
element.style.opacity = '0.4';
});
// Scroll the first highlighted element into view with a slight delay
if (bestMatch.elements.length > 0) {
setTimeout(() => {
const element = bestMatch.elements[0];
const container = containerRef.current;
if (!container || !element) return;
// Calculate the element's position relative to the container
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
// Scroll the container
container.scrollTo({
top: container.scrollTop + (elementRect.top - containerRect.top) - containerRect.height / 2,
behavior: 'smooth'
});
}, 100);
}
} else {
console.log('No good match found:', {
bestRating: bestMatch.rating,
bestText: bestMatch.text,
threshold: similarityThreshold
});
}
}, [clearHighlights]);
// Function to handle text span clicks
const handleTextClick = useCallback((event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.matches('.react-pdf__Page__textContent span')) return;
// Get surrounding text for context (combine nearby spans)
const parentElement = target.closest('.react-pdf__Page__textContent');
if (!parentElement) return;
const spans = Array.from(parentElement.querySelectorAll('span'));
const clickedIndex = spans.indexOf(target);
// Get text from clicked span and several spans before/after for context
const contextWindow = 3;
const startIndex = Math.max(0, clickedIndex - contextWindow);
const endIndex = Math.min(spans.length - 1, clickedIndex + contextWindow);
const contextText = spans
.slice(startIndex, endIndex + 1)
.map(span => span.textContent)
.join(' ')
.trim();
if (!contextText?.trim()) return;
// Clean up the context text
const cleanContext = contextText.trim().replace(/\s+/g, ' ');
const contextLength = cleanContext.length;
// Get all text nodes within the container
const allText = Array.from(parentElement.querySelectorAll('span')).map(node => ({
element: node as HTMLElement,
text: (node.textContent || '').trim()
})).filter(node => node.text.length > 0);
// Find the best matching position using the same logic as highlightPattern
let bestMatch = {
elements: [] as HTMLElement[],
rating: 0,
text: '',
lengthDiff: Infinity
};
// Try different combinations of consecutive spans
for (let i = 0; i < allText.length; i++) {
let combinedText = '';
let currentElements = [];
// Look ahead up to 10 spans, but stop if we exceed 2x context length
for (let j = i; j < Math.min(i + 10, allText.length); j++) {
const node = allText[j];
const newText = combinedText + (combinedText ? ' ' : '') + node.text;
// Stop if we're getting too far from target length
if (newText.length > contextLength * 2) {
break;
}
combinedText = newText;
currentElements.push(node.element);
// Calculate similarity and length difference
const similarity = stringSimilarity.compareTwoStrings(cleanContext, combinedText);
const lengthDiff = Math.abs(combinedText.length - contextLength);
// Score based on both similarity and length difference
const lengthPenalty = lengthDiff / contextLength;
const adjustedRating = similarity * (1 - lengthPenalty * 0.5);
if (adjustedRating > bestMatch.rating) {
bestMatch = {
elements: [...currentElements],
rating: adjustedRating,
text: combinedText,
lengthDiff
};
}
}
}
// Only proceed if we found a good match
const similarityThreshold = bestMatch.lengthDiff < contextLength * 0.3 ? 0.3 : 0.5;
if (bestMatch.rating >= similarityThreshold) {
// Find the corresponding sentence in the full text
const matchText = bestMatch.text;
const sentences = nlp(pdfText).sentences().out('array') as string[];
// Find the sentence that best matches our chunk
let bestSentenceMatch = {
sentence: '',
rating: 0
};
for (const sentence of sentences) {
const rating = stringSimilarity.compareTwoStrings(matchText, sentence);
if (rating > bestSentenceMatch.rating) {
bestSentenceMatch = { sentence, rating };
}
}
console.log('Best matched sentence:', bestSentenceMatch.sentence);
console.log('Match rating:', bestSentenceMatch.rating);
if (bestSentenceMatch.rating >= 0.5) {
// Update TTS context to this sentence and start playing
const sentenceIndex = sentences.findIndex(sentence => sentence === bestSentenceMatch.sentence);
console.log('Calculated sentence index:', sentenceIndex);
if (sentenceIndex !== -1) {
stopAndPlayFromIndex(sentenceIndex);
highlightPattern(pdfText, bestSentenceMatch.sentence);
}
}
}
}, [pdfText, stopAndPlayFromIndex, highlightPattern]);
// Add click event listener to the container
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener('click', handleTextClick);
const handleClick = (event: MouseEvent) => handleTextClick(event, pdfText, containerRef as RefObject<HTMLDivElement>, stopAndPlayFromIndex);
container.addEventListener('click', handleClick);
return () => {
container.removeEventListener('click', handleTextClick);
container.removeEventListener('click', handleClick);
};
}, [handleTextClick]);
}, [pdfText, handleTextClick, stopAndPlayFromIndex]);
// Update highlights when current sentence changes
useEffect(() => {
console.log('Current sentence changed:', currentSentence);
/*
* 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
*/
const highlightTimeout = setTimeout(() => {
highlightPattern(pdfText, currentSentence || '');
}, 100); // Quick response for TTS
if (containerRef.current) {
highlightPattern(pdfText, currentSentence || '', containerRef as RefObject<HTMLDivElement>);
}
}, 100);
// Clear highlights when effect is cleaned up
return () => {
clearTimeout(highlightTimeout);
clearHighlights();
};
}, [pdfText, currentSentence, highlightPattern, clearHighlights]);
// Add useEffect for initializing click styles
useEffect(() => {
const addClickStyles = () => {
const style = document.createElement('style');
style.textContent = `
.react-pdf__Page__textContent span {
cursor: pointer;
transition: background-color 0.2s ease;
}
.react-pdf__Page__textContent span:hover {
background-color: rgba(255, 255, 0, 0.2) !important;
}
`;
document.head.appendChild(style);
};
addClickStyles();
return () => {
// Remove any styles with the same selectors on cleanup
const styles = document.querySelectorAll('style');
styles.forEach(style => {
if (style.textContent?.includes('react-pdf__Page__textContent')) {
style.remove();
}
});
};
}, []);
function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
setNumPages(numPages);
}
return (
<div
ref={containerRef}
<div
ref={containerRef}
className="flex flex-col items-center overflow-auto max-h-[calc(100vh-100px)]"
style={{ WebkitTapHighlightColor: 'transparent' }} // Remove tap highlight on mobile
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{loadingError ? (
<div className="text-red-500 mb-4">{loadingError}</div>
) : null}
<Document
<Document
loading={<PDFSkeleton />}
noData={<PDFSkeleton />}
file={pdfDataUrl}
@ -486,4 +181,4 @@ export function PDFViewer({ pdfData }: PDFViewerProps) {
</Document>
</div>
);
}
}

View file

@ -1,8 +1,14 @@
'use client';
import { createContext, useContext, useState, ReactNode, useEffect } from 'react';
import { createContext, useContext, useState, ReactNode, useEffect, useCallback } from 'react';
import { indexedDBService, type PDFDocument } from '@/services/indexedDB';
import { v4 as uuidv4 } from 'uuid';
import { pdfjs } from 'react-pdf';
import stringSimilarity from 'string-similarity';
import nlp from 'compromise';
// Set worker from public directory
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs';
interface PDFContextType {
documents: PDFDocument[];
@ -11,6 +17,10 @@ interface PDFContextType {
removeDocument: (id: string) => Promise<void>;
isLoading: boolean;
error: string | null;
extractTextFromPDF: (pdfData: Blob) => Promise<string>;
highlightPattern: (text: string, pattern: string, containerRef: React.RefObject<HTMLDivElement>) => void;
clearHighlights: () => void;
handleTextClick: (event: MouseEvent, pdfText: string, containerRef: React.RefObject<HTMLDivElement>, stopAndPlayFromIndex: (index: number) => void) => void;
}
const PDFContext = createContext<PDFContextType | undefined>(undefined);
@ -21,6 +31,14 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
/*
* Initializes the PDF document storage and loads existing documents.
* Sets up IndexedDB and retrieves all stored documents on component mount.
* Handles errors if IndexedDB initialization fails.
*
* Dependencies:
* - Empty array: Only runs once on mount as initialization should only happen once
*/
const loadDocuments = async () => {
try {
setError(null);
@ -46,7 +64,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
name: file.name,
size: file.size,
lastModified: file.lastModified,
data: new Blob([file], { type: file.type })
data: new Blob([file], { type: file.type }),
};
try {
@ -83,8 +101,225 @@ export function PDFProvider({ children }: { children: ReactNode }) {
}
};
const extractTextFromPDF = useCallback(async (pdfData: Blob): Promise<string> => {
try {
const reader = new FileReader();
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(pdfData);
});
const base64Data = dataUrl.split(',')[1];
const binaryData = atob(base64Data);
const length = binaryData.length;
const bytes = new Uint8Array(length);
for (let i = 0; i < length; i++) {
bytes[i] = binaryData.charCodeAt(i);
}
const loadingTask = pdfjs.getDocument({
data: bytes,
disableAutoFetch: true,
disableStream: false,
});
const pdf = await loadingTask.promise;
let fullText = '';
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items.map((item: any) => item.str).join(' ');
fullText += pageText + ' ';
}
return fullText;
} catch (error) {
console.error('Error extracting text from PDF:', error);
throw new Error('Failed to extract text from PDF');
}
}, []);
const clearHighlights = useCallback(() => {
const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span');
textNodes.forEach((node) => {
const element = node as HTMLElement;
element.style.backgroundColor = '';
element.style.opacity = '1';
});
}, []);
const highlightPattern = useCallback((text: string, pattern: string, containerRef: React.RefObject<HTMLDivElement>) => {
clearHighlights();
if (!pattern?.trim()) {
return;
}
const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
const patternLength = cleanPattern.length;
const container = containerRef.current;
if (!container) return;
const textNodes = container.querySelectorAll('.react-pdf__Page__textContent span');
const allText = Array.from(textNodes).map(node => ({
element: node as HTMLElement,
text: (node.textContent || '').trim(),
})).filter(node => node.text.length > 0);
let bestMatch = {
elements: [] as HTMLElement[],
rating: 0,
text: '',
lengthDiff: Infinity,
};
for (let i = 0; i < allText.length; i++) {
let combinedText = '';
let currentElements = [];
for (let j = i; j < Math.min(i + 10, allText.length); j++) {
const node = allText[j];
const newText = combinedText + (combinedText ? ' ' : '') + node.text;
if (newText.length > patternLength * 2) {
break;
}
combinedText = newText;
currentElements.push(node.element);
const similarity = stringSimilarity.compareTwoStrings(cleanPattern, combinedText);
const lengthDiff = Math.abs(combinedText.length - patternLength);
const lengthPenalty = lengthDiff / patternLength;
const adjustedRating = similarity * (1 - lengthPenalty * 0.5);
if (adjustedRating > bestMatch.rating) {
bestMatch = {
elements: [...currentElements],
rating: adjustedRating,
text: combinedText,
lengthDiff,
};
}
}
}
const similarityThreshold = bestMatch.lengthDiff < patternLength * 0.3 ? 0.3 : 0.5;
if (bestMatch.rating >= similarityThreshold) {
bestMatch.elements.forEach(element => {
element.style.backgroundColor = 'grey';
element.style.opacity = '0.4';
});
if (bestMatch.elements.length > 0) {
setTimeout(() => {
const element = bestMatch.elements[0];
const container = containerRef.current;
if (!container || !element) return;
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
container.scrollTo({
top: container.scrollTop + (elementRect.top - containerRect.top) - containerRect.height / 2,
behavior: 'smooth',
});
}, 100);
}
}
}, [clearHighlights]);
const handleTextClick = useCallback((event: MouseEvent, pdfText: string, containerRef: React.RefObject<HTMLDivElement>, stopAndPlayFromIndex: (index: number) => void) => {
const target = event.target as HTMLElement;
if (!target.matches('.react-pdf__Page__textContent span')) return;
const parentElement = target.closest('.react-pdf__Page__textContent');
if (!parentElement) return;
const spans = Array.from(parentElement.querySelectorAll('span'));
const clickedIndex = spans.indexOf(target);
const contextWindow = 3;
const startIndex = Math.max(0, clickedIndex - contextWindow);
const endIndex = Math.min(spans.length - 1, clickedIndex + contextWindow);
const contextText = spans
.slice(startIndex, endIndex + 1)
.map(span => span.textContent)
.join(' ')
.trim();
if (!contextText?.trim()) return;
const cleanContext = contextText.trim().replace(/\s+/g, ' ');
const contextLength = cleanContext.length;
const allText = Array.from(parentElement.querySelectorAll('span')).map(node => ({
element: node as HTMLElement,
text: (node.textContent || '').trim(),
})).filter(node => node.text.length > 0);
let bestMatch = {
elements: [] as HTMLElement[],
rating: 0,
text: '',
lengthDiff: Infinity,
};
for (let i = 0; i < allText.length; i++) {
let combinedText = '';
let currentElements = [];
for (let j = i; j < Math.min(i + 10, allText.length); j++) {
const node = allText[j];
const newText = combinedText + (combinedText ? ' ' : '') + node.text;
if (newText.length > contextLength * 2) {
break;
}
combinedText = newText;
currentElements.push(node.element);
const similarity = stringSimilarity.compareTwoStrings(cleanContext, combinedText);
const lengthDiff = Math.abs(combinedText.length - contextLength);
const lengthPenalty = lengthDiff / contextLength;
const adjustedRating = similarity * (1 - lengthPenalty * 0.5);
if (adjustedRating > bestMatch.rating) {
bestMatch = {
elements: [...currentElements],
rating: adjustedRating,
text: combinedText,
lengthDiff,
};
}
}
}
const similarityThreshold = bestMatch.lengthDiff < contextLength * 0.3 ? 0.3 : 0.5;
if (bestMatch.rating >= similarityThreshold) {
const matchText = bestMatch.text;
const sentences = nlp(pdfText).sentences().out('array') as string[];
let bestSentenceMatch = {
sentence: '',
rating: 0,
};
for (const sentence of sentences) {
const rating = stringSimilarity.compareTwoStrings(matchText, sentence);
if (rating > bestSentenceMatch.rating) {
bestSentenceMatch = { sentence, rating };
}
}
if (bestSentenceMatch.rating >= 0.5) {
const sentenceIndex = sentences.findIndex(sentence => sentence === bestSentenceMatch.sentence);
if (sentenceIndex !== -1) {
stopAndPlayFromIndex(sentenceIndex);
highlightPattern(pdfText, bestSentenceMatch.sentence, containerRef);
}
}
}
}, [highlightPattern]);
return (
<PDFContext.Provider value={{ documents, addDocument, getDocument, removeDocument, isLoading, error }}>
<PDFContext.Provider value={{ documents, addDocument, getDocument, removeDocument, isLoading, error, extractTextFromPDF, highlightPattern, clearHighlights, handleTextClick }}>
{children}
</PDFContext.Provider>
);
@ -96,4 +331,4 @@ export function usePDF() {
throw new Error('usePDF must be used within a PDFProvider');
}
return context;
}
}

View file

@ -71,7 +71,14 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
const audioCacheRef = useRef(new LRUCache<string, AudioBuffer>({ max: 50 }));
useEffect(() => {
// Initialize AudioContext on client side only
/*
* Initializes the AudioContext for text-to-speech playback.
* Creates a new AudioContext instance if one doesn't exist.
* Only runs on the client side to avoid SSR issues.
*
* Dependencies:
* - audioContext: Re-runs if the audioContext is null or changes
*/
if (typeof window !== 'undefined' && !audioContext) {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (AudioContextClass) {
@ -303,6 +310,14 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
// Preload adjacent sentences when currentIndex changes
useEffect(() => {
/*
* Preloads the next sentence in the queue to improve playback performance.
* Only preloads the next sentence to reduce API load.
*
* Dependencies:
* - currentIndex: Re-runs when the currentIndex changes
* - sentences: Re-runs when the sentences array changes
*/
const preloadAdjacentSentences = async () => {
try {
// Only preload next sentence to reduce API load
@ -320,6 +335,16 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
const isMounted = useRef(false);
useEffect(() => {
/*
* Plays the current sentence when the component is mounted or the currentIndex changes.
* Handles audio playback and auto-advances to the next sentence when finished.
*
* Dependencies:
* - isPlaying: Re-runs when the isPlaying state changes
* - currentIndex: Re-runs when the currentIndex changes
* - sentences: Re-runs when the sentences array changes
* - isProcessing: Re-runs when the isProcessing state changes
*/
// Skip the first mount in development
if (process.env.NODE_ENV === 'development') {
if (!isMounted.current) {

View file

@ -43,6 +43,14 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
// Handle system theme changes
useEffect(() => {
/*
* Handles system theme changes by listening to prefers-color-scheme media query.
* Updates the theme when system preferences change and theme is set to 'system'.
* Cleans up event listener on unmount.
*
* Dependencies:
* - theme: Re-runs when the theme changes to update system preference handling
*/
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {