From 46c497f062f824711beeee67cc84b96ae5f83a82 Mon Sep 17 00:00:00 2001 From: Richard Roberson Date: Sun, 19 Jan 2025 15:39:44 -0700 Subject: [PATCH] Readme.md + Refactor --- README.md | 87 +++++-- src/components/PDFViewer.tsx | 473 ++++++---------------------------- src/contexts/PDFContext.tsx | 243 ++++++++++++++++- src/contexts/TTSContext.tsx | 27 +- src/contexts/ThemeContext.tsx | 8 + 5 files changed, 427 insertions(+), 411 deletions(-) diff --git a/README.md b/README.md index e215bc4..9cadb20 100644 --- a/README.md +++ b/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/) diff --git a/src/components/PDFViewer.tsx b/src/components/PDFViewer.tsx index 7ded7f6..917312a 100644 --- a/src/components/PDFViewer.tsx +++ b/src/components/PDFViewer.tsx @@ -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(); - const { setText, currentSentence, stopAndPlayFromIndex, sentences } = useTTS(); + const { setText, currentSentence, stopAndPlayFromIndex } = useTTS(); const [pdfText, setPdfText] = useState(''); - const [highlights, setHighlights] = useState([]); const [pdfDataUrl, setPdfDataUrl] = useState(); const [loadingError, setLoadingError] = useState(); const containerRef = useRef(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, 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); + } + }, 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 ( -
{loadingError ? (
{loadingError}
) : null} - } noData={} file={pdfDataUrl} @@ -486,4 +181,4 @@ export function PDFViewer({ pdfData }: PDFViewerProps) {
); -} +} \ No newline at end of file diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index c3b304c..574156d 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -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; isLoading: boolean; error: string | null; + extractTextFromPDF: (pdfData: Blob) => Promise; + highlightPattern: (text: string, pattern: string, containerRef: React.RefObject) => void; + clearHighlights: () => void; + handleTextClick: (event: MouseEvent, pdfText: string, containerRef: React.RefObject, stopAndPlayFromIndex: (index: number) => void) => void; } const PDFContext = createContext(undefined); @@ -21,6 +31,14 @@ export function PDFProvider({ children }: { children: ReactNode }) { const [error, setError] = useState(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 => { + try { + const reader = new FileReader(); + const dataUrl = await new Promise((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) => { + 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, 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 ( - + {children} ); @@ -96,4 +331,4 @@ export function usePDF() { throw new Error('usePDF must be used within a PDFProvider'); } return context; -} +} \ No newline at end of file diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index f9766a1..3f887a4 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -71,7 +71,14 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { const audioCacheRef = useRef(new LRUCache({ 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) { diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx index 721f708..d3b3f24 100644 --- a/src/contexts/ThemeContext.tsx +++ b/src/contexts/ThemeContext.tsx @@ -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 = () => {