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
|
## 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
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
# or
|
# or
|
||||||
yarn dev
|
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.
|
## Contributing
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
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';
|
'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/AnnotationLayer.css';
|
||||||
import 'react-pdf/dist/Page/TextLayer.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 { PDFSkeleton } from './PDFSkeleton';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import stringSimilarity from 'string-similarity';
|
import { usePDF } from '@/contexts/PDFContext';
|
||||||
import nlp from 'compromise';
|
|
||||||
|
|
||||||
// Set worker from public directory
|
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs';
|
|
||||||
|
|
||||||
interface PDFViewerProps {
|
interface PDFViewerProps {
|
||||||
pdfData: Blob | undefined;
|
pdfData: Blob | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TextHighlight {
|
|
||||||
pageIndex: number;
|
|
||||||
content: string;
|
|
||||||
position: {
|
|
||||||
boundingRect: DOMRect;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PDFViewer({ pdfData }: PDFViewerProps) {
|
export function PDFViewer({ pdfData }: PDFViewerProps) {
|
||||||
const [numPages, setNumPages] = useState<number>();
|
const [numPages, setNumPages] = useState<number>();
|
||||||
const { setText, currentSentence, stopAndPlayFromIndex, sentences } = useTTS();
|
const { setText, currentSentence, stopAndPlayFromIndex } = useTTS();
|
||||||
const [pdfText, setPdfText] = useState('');
|
const [pdfText, setPdfText] = useState('');
|
||||||
const [highlights, setHighlights] = useState<TextHighlight[]>([]);
|
|
||||||
const [pdfDataUrl, setPdfDataUrl] = useState<string>();
|
const [pdfDataUrl, setPdfDataUrl] = useState<string>();
|
||||||
const [loadingError, setLoadingError] = useState<string>();
|
const [loadingError, setLoadingError] = useState<string>();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
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(() => {
|
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;
|
if (!pdfData) return;
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
|
@ -52,405 +67,85 @@ export function PDFViewer({ pdfData }: PDFViewerProps) {
|
||||||
};
|
};
|
||||||
}, [pdfData]);
|
}, [pdfData]);
|
||||||
|
|
||||||
// Load PDF text content
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pdfDataUrl) return;
|
/*
|
||||||
|
* Extracts text content from the PDF once it's loaded.
|
||||||
let isCurrentPdf = true;
|
* Sets the extracted text for both display and text-to-speech.
|
||||||
let currentLoadingTask: any = null;
|
*
|
||||||
setLoadingError(undefined);
|
* 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 () => {
|
const loadPdfText = async () => {
|
||||||
try {
|
try {
|
||||||
// Create a typed array from the base64 data
|
const text = await extractTextFromPDF(pdfData);
|
||||||
const base64Data = pdfDataUrl.split(',')[1];
|
setPdfText(text);
|
||||||
const binaryData = atob(base64Data);
|
setText(text);
|
||||||
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);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isCurrentPdf) return;
|
|
||||||
console.error('Error loading PDF text:', error);
|
console.error('Error loading PDF text:', error);
|
||||||
setLoadingError('Failed to extract PDF text');
|
setLoadingError('Failed to extract PDF text');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadPdfText();
|
loadPdfText();
|
||||||
|
}, [pdfDataUrl, extractTextFromPDF, setText, pdfData]);
|
||||||
|
|
||||||
return () => {
|
useEffect(() => {
|
||||||
isCurrentPdf = false;
|
/*
|
||||||
if (currentLoadingTask) {
|
* Sets up click event listeners for text selection in the PDF.
|
||||||
currentLoadingTask.destroy();
|
* Cleans up by removing the event listener when component unmounts.
|
||||||
}
|
*
|
||||||
setPdfText('');
|
* Dependencies:
|
||||||
};
|
* - pdfText: Re-run when the extracted text content changes
|
||||||
}, [pdfDataUrl, setText]);
|
* - handleTextClick: Function from context that could change
|
||||||
|
* - stopAndPlayFromIndex: Function from context that could change
|
||||||
// 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
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const textNodes = container.querySelectorAll('.react-pdf__Page__textContent span');
|
const handleClick = (event: MouseEvent) => handleTextClick(event, pdfText, containerRef as RefObject<HTMLDivElement>, stopAndPlayFromIndex);
|
||||||
const allText = Array.from(textNodes).map(node => ({
|
container.addEventListener('click', handleClick);
|
||||||
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);
|
|
||||||
return () => {
|
return () => {
|
||||||
container.removeEventListener('click', handleTextClick);
|
container.removeEventListener('click', handleClick);
|
||||||
};
|
};
|
||||||
}, [handleTextClick]);
|
}, [pdfText, handleTextClick, stopAndPlayFromIndex]);
|
||||||
|
|
||||||
// Update highlights when current sentence changes
|
|
||||||
useEffect(() => {
|
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(() => {
|
const highlightTimeout = setTimeout(() => {
|
||||||
highlightPattern(pdfText, currentSentence || '');
|
if (containerRef.current) {
|
||||||
}, 100); // Quick response for TTS
|
highlightPattern(pdfText, currentSentence || '', containerRef as RefObject<HTMLDivElement>);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
// Clear highlights when effect is cleaned up
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(highlightTimeout);
|
clearTimeout(highlightTimeout);
|
||||||
clearHighlights();
|
clearHighlights();
|
||||||
};
|
};
|
||||||
}, [pdfText, currentSentence, highlightPattern, clearHighlights]);
|
}, [pdfText, currentSentence, highlightPattern, clearHighlights]);
|
||||||
|
|
||||||
// Add useEffect for initializing click styles
|
function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
|
||||||
useEffect(() => {
|
setNumPages(numPages);
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="flex flex-col items-center overflow-auto max-h-[calc(100vh-100px)]"
|
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 ? (
|
{loadingError ? (
|
||||||
<div className="text-red-500 mb-4">{loadingError}</div>
|
<div className="text-red-500 mb-4">{loadingError}</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
'use client';
|
'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 { indexedDBService, type PDFDocument } from '@/services/indexedDB';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
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 {
|
interface PDFContextType {
|
||||||
documents: PDFDocument[];
|
documents: PDFDocument[];
|
||||||
|
|
@ -11,6 +17,10 @@ interface PDFContextType {
|
||||||
removeDocument: (id: string) => Promise<void>;
|
removeDocument: (id: string) => Promise<void>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
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);
|
const PDFContext = createContext<PDFContextType | undefined>(undefined);
|
||||||
|
|
@ -21,6 +31,14 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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 () => {
|
const loadDocuments = async () => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -46,7 +64,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
lastModified: file.lastModified,
|
lastModified: file.lastModified,
|
||||||
data: new Blob([file], { type: file.type })
|
data: new Blob([file], { type: file.type }),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
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 (
|
return (
|
||||||
<PDFContext.Provider value={{ documents, addDocument, getDocument, removeDocument, isLoading, error }}>
|
<PDFContext.Provider value={{ documents, addDocument, getDocument, removeDocument, isLoading, error, extractTextFromPDF, highlightPattern, clearHighlights, handleTextClick }}>
|
||||||
{children}
|
{children}
|
||||||
</PDFContext.Provider>
|
</PDFContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,14 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
const audioCacheRef = useRef(new LRUCache<string, AudioBuffer>({ max: 50 }));
|
const audioCacheRef = useRef(new LRUCache<string, AudioBuffer>({ max: 50 }));
|
||||||
|
|
||||||
useEffect(() => {
|
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) {
|
if (typeof window !== 'undefined' && !audioContext) {
|
||||||
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||||
if (AudioContextClass) {
|
if (AudioContextClass) {
|
||||||
|
|
@ -303,6 +310,14 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
|
||||||
// Preload adjacent sentences when currentIndex changes
|
// Preload adjacent sentences when currentIndex changes
|
||||||
useEffect(() => {
|
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 () => {
|
const preloadAdjacentSentences = async () => {
|
||||||
try {
|
try {
|
||||||
// Only preload next sentence to reduce API load
|
// Only preload next sentence to reduce API load
|
||||||
|
|
@ -320,6 +335,16 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
const isMounted = useRef(false);
|
const isMounted = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
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
|
// Skip the first mount in development
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
if (!isMounted.current) {
|
if (!isMounted.current) {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,14 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
|
||||||
// Handle system theme changes
|
// Handle system theme changes
|
||||||
useEffect(() => {
|
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 mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue