Readme.md + Refactor
This commit is contained in:
parent
728eb185b7
commit
46c497f062
5 changed files with 427 additions and 411 deletions
87
README.md
87
README.md
|
|
@ -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/)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue