diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index 7e8df4e..5aa0fa1 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -1,3 +1,16 @@ +/** + * PDF Context Provider + * + * This module provides a React context for managing PDF document functionality. + * It handles document loading, text extraction, highlighting, and integration with TTS. + * + * Key features: + * - PDF document management (add/remove/load) + * - Text extraction and processing + * - Text highlighting and navigation + * - Document state management + */ + 'use client'; import { @@ -23,7 +36,12 @@ import { useTTS } from '@/contexts/TTSContext'; // Set worker from public directory pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs'; -// Convert PDF data to PDF data URL +/** + * Converts PDF binary data to a data URL for display + * + * @param {Blob} pdfData - The PDF binary data + * @returns {Promise} A data URL representing the PDF + */ const convertPDFDataToURL = (pdfData: Blob): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -33,13 +51,27 @@ const convertPDFDataToURL = (pdfData: Blob): Promise => { }); }; +/** + * Interface defining all available methods and properties in the PDF context + */ interface PDFContextType { + // Documents management documents: PDFDocument[]; addDocument: (file: File) => Promise; - getDocument: (id: string) => Promise; removeDocument: (id: string) => Promise; isLoading: boolean; - extractTextFromPDF: (pdfURL: string, currDocPage: number) => Promise; + + // Current document state + currDocURL: string | undefined; + currDocName: string | undefined; + currDocPages: number | undefined; + currDocPage: number; + currDocText: string | undefined; + setCurrentDocument: (id: string) => Promise; + clearCurrDoc: () => void; + + // PDF functionality + onDocumentLoadSuccess: ({ numPages }: { numPages: number }) => void; highlightPattern: (text: string, pattern: string, containerRef: React.RefObject) => void; clearHighlights: () => void; handleTextClick: ( @@ -49,19 +81,24 @@ interface PDFContextType { stopAndPlayFromIndex: (index: number) => void, isProcessing: boolean ) => void; - onDocumentLoadSuccess: ({ numPages }: { numPages: number }) => void; - setCurrentDocument: (id: string) => Promise; - currDocURL: string | undefined; - currDocName: string | undefined; - currDocPages: number | undefined; - currDocPage: number; - currDocText: string | undefined; - clearCurrDoc: () => void; } +// Create the context const PDFContext = createContext(undefined); +/** + * PDFProvider Component + * + * Main provider component that manages PDF state and functionality. + * Handles document loading, text processing, and integration with TTS. + */ export function PDFProvider({ children }: { children: ReactNode }) { + /** + * State Management + * - Document management + * - Current document state + * - Loading states + */ const [documents, setDocuments] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -78,7 +115,9 @@ export function PDFProvider({ children }: { children: ReactNode }) { const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); - // Load documents from IndexedDB once DB is ready + /** + * Load documents from IndexedDB when the database is ready + */ useEffect(() => { const loadDocuments = async () => { if (!isDBReady) return; @@ -96,7 +135,12 @@ export function PDFProvider({ children }: { children: ReactNode }) { loadDocuments(); }, [isDBReady]); - // Add a new document to IndexedDB + /** + * Adds a new document to IndexedDB + * + * @param {File} file - The PDF file to add + * @returns {Promise} The ID of the added document + */ const addDocument = useCallback(async (file: File): Promise => { const id = uuidv4(); const newDoc: PDFDocument = { @@ -117,17 +161,11 @@ export function PDFProvider({ children }: { children: ReactNode }) { } }, []); - // Get a document by ID from IndexedDB - const getDocument = useCallback(async (id: string): Promise => { - try { - return await indexedDBService.getDocument(id); - } catch (error) { - console.error('Failed to get document:', error); - return undefined; - } - }, []); - - // Remove a document by ID from IndexedDB + /** + * Removes a document from IndexedDB + * + * @param {string} id - The ID of the document to remove + */ const removeDocument = useCallback(async (id: string): Promise => { try { await indexedDBService.removeDocument(id); @@ -138,12 +176,23 @@ export function PDFProvider({ children }: { children: ReactNode }) { } }, []); + /** + * Handles successful document load + * + * @param {Object} param0 - Object containing numPages + */ const onDocumentLoadSuccess = useCallback(({ numPages }: { numPages: number }) => { console.log('Document loaded:', numPages); setCurrDocPages(numPages); }, [setCurrDocPages]); - // Extract text from a PDF file + /** + * Extracts text content from a specific page of the PDF + * + * @param {string} pdfURL - The URL of the PDF + * @param {number} currDocPage - The page number to extract + * @returns {Promise} The extracted text + */ const extractTextFromPDF = useCallback(async (pdfURL: string, currDocPage: number): Promise => { try { const base64Data = pdfURL.split(',')[1]; @@ -222,7 +271,9 @@ export function PDFProvider({ children }: { children: ReactNode }) { } }, []); - // Load curr doc text + /** + * Loads and processes text from the current document page + */ const loadCurrDocText = useCallback(async () => { try { if (!currDocURL) return; @@ -234,17 +285,23 @@ export function PDFProvider({ children }: { children: ReactNode }) { } }, [currDocURL, currDocPage, extractTextFromPDF, setTTSText]); - // Update the current document text when the page changes + /** + * Updates the current document text when the page changes + */ useEffect(() => { if (currDocURL) { loadCurrDocText(); } }, [currDocPage, currDocURL, loadCurrDocText]); - // Set curr document + /** + * Sets the current document based on its ID + * + * @param {string} id - The ID of the document to set + */ const setCurrentDocument = useCallback(async (id: string): Promise => { try { - const doc = await getDocument(id); + const doc = await indexedDBService.getDocument(id); if (doc) { const url = await convertPDFDataToURL(doc.data); setCurrDocName(doc.name); @@ -254,8 +311,11 @@ export function PDFProvider({ children }: { children: ReactNode }) { } catch (error) { console.error('Failed to get document URL:', error); } - }, [getDocument]); + }, [indexedDBService]); + /** + * Clears the current document state + */ const clearCurrDoc = useCallback(() => { setCurrDocName(undefined); setCurrDocURL(undefined); @@ -267,7 +327,9 @@ export function PDFProvider({ children }: { children: ReactNode }) { }, [setCurrDocPages, setTTSText]); - // Clear all highlights in the PDF viewer + /** + * Removes all text highlights from the PDF viewer + */ const clearHighlights = useCallback(() => { const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span'); textNodes.forEach((node) => { @@ -277,7 +339,13 @@ export function PDFProvider({ children }: { children: ReactNode }) { }); }, []); - // Find the best text match using string similarity + /** + * Finds the best matching text segment using string similarity + * + * @param {Array} elements - Array of elements and their text content + * @param {string} targetText - The text to match against + * @param {number} maxCombinedLength - Maximum length of combined text to consider + */ const findBestTextMatch = useCallback(( elements: Array<{ element: HTMLElement; text: string }>, targetText: string, @@ -320,7 +388,14 @@ export function PDFProvider({ children }: { children: ReactNode }) { return bestMatch; }, []); - // Highlight matching text in the PDF viewer with sliding context window + /** + * Highlights matching text in the PDF viewer + * Uses a sliding context window for improved accuracy + * + * @param {string} text - The document text + * @param {string} pattern - The pattern to highlight + * @param {RefObject} containerRef - Reference to the container element + */ const highlightPattern = useCallback((text: string, pattern: string, containerRef: React.RefObject) => { clearHighlights(); @@ -381,10 +456,19 @@ export function PDFProvider({ children }: { children: ReactNode }) { } }, [clearHighlights, findBestTextMatch]); - // Handle text click events in the PDF viewer + /** + * Handles text click events in the PDF viewer + * Integrates with TTS for synchronized playback + * + * @param {MouseEvent} event - The click event + * @param {string} pdfText - The text content of the page + * @param {RefObject} containerRef - Reference to the container element + * @param {Function} stopAndPlayFromIndex - Function to control TTS playback + * @param {boolean} isProcessing - Whether TTS is currently processing + */ const handleTextClick = useCallback(( event: MouseEvent, - pageText: string, // Renamed from pdfText to pageText for clarity + pdfText: string, // Renamed from pdfText to pageText for clarity containerRef: React.RefObject, stopAndPlayFromIndex: (index: number) => void, isProcessing: boolean @@ -422,7 +506,7 @@ export function PDFProvider({ children }: { children: ReactNode }) { if (bestMatch.rating >= similarityThreshold) { const matchText = bestMatch.text; // Use pageText instead of full PDF text for sentence splitting - const sentences = nlp(pageText).sentences().out('array') as string[]; + const sentences = nlp(pdfText).sentences().out('array') as string[]; let bestSentenceMatch = { sentence: '', rating: 0 }; for (const sentence of sentences) { @@ -436,24 +520,19 @@ export function PDFProvider({ children }: { children: ReactNode }) { const sentenceIndex = sentences.findIndex((sentence) => sentence === bestSentenceMatch.sentence); if (sentenceIndex !== -1) { stopAndPlayFromIndex(sentenceIndex); - highlightPattern(pageText, bestSentenceMatch.sentence, containerRef); + highlightPattern(pdfText, bestSentenceMatch.sentence, containerRef); } } } }, [highlightPattern, findBestTextMatch]); - // Memoize the context value to prevent unnecessary re-renders + // Context value memoization const contextValue = useMemo( () => ({ documents, addDocument, - getDocument, removeDocument, isLoading, - extractTextFromPDF, - highlightPattern, - clearHighlights, - handleTextClick, onDocumentLoadSuccess, setCurrentDocument, currDocURL, @@ -462,17 +541,15 @@ export function PDFProvider({ children }: { children: ReactNode }) { currDocPage, currDocText, clearCurrDoc, + highlightPattern, + clearHighlights, + handleTextClick, }), [ documents, addDocument, - getDocument, removeDocument, isLoading, - extractTextFromPDF, - highlightPattern, - clearHighlights, - handleTextClick, onDocumentLoadSuccess, setCurrentDocument, currDocURL, @@ -481,6 +558,9 @@ export function PDFProvider({ children }: { children: ReactNode }) { currDocPage, currDocText, clearCurrDoc, + highlightPattern, + clearHighlights, + handleTextClick, ] ); @@ -491,6 +571,13 @@ export function PDFProvider({ children }: { children: ReactNode }) { ); } +/** + * Custom hook to consume the PDF context + * Ensures the context is used within a provider + * + * @throws {Error} If used outside of PDFProvider + * @returns {PDFContextType} The PDF context value + */ export function usePDF() { const context = useContext(PDFContext); if (context === undefined) { diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index a06c31a..b2d8303 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -21,6 +21,7 @@ import React, { useCallback, useEffect, useRef, + useMemo, } from 'react'; import OpenAI from 'openai'; import { LRUCache } from 'lru-cache'; // Import LRUCache directly @@ -48,33 +49,28 @@ type AudioContextType = typeof window extends undefined * Interface defining all available methods and properties in the TTS context */ interface TTSContextType { + // Playback state isPlaying: boolean; - currentText: string; + isProcessing: boolean; + currentSentence: string; + + // Navigation + currDocPage: number; + currDocPages: number | undefined; + + // Voice settings + availableVoices: string[]; + + // Control functions togglePlay: () => void; skipForward: () => void; skipBackward: () => void; - setText: (text: string) => void; - currentSentence: string; - audioQueue: AudioBuffer[]; stop: () => void; stopAndPlayFromIndex: (index: number) => void; - sentences: string[]; - isProcessing: boolean; - setIsProcessing: (value: boolean) => void; - setIsPlaying: (value: boolean) => void; - speed: number; - setSpeed: (speed: number) => void; - setSpeedAndRestart: (speed: number) => void; - voice: string; - setVoice: (voice: string) => void; - setVoiceAndRestart: (voice: string) => void; - availableVoices: string[]; - currentIndex: number; - setCurrentIndex: (index: number) => void; - currDocPage: number; - currDocPages: number | undefined; + setText: (text: string) => void; setCurrDocPages: (num: number | undefined) => void; - incrementPage: (num?: number) => void; + setSpeedAndRestart: (speed: number) => void; + setVoiceAndRestart: (voice: string) => void; skipToPage: (page: number) => void; } @@ -127,14 +123,15 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { const [nextPageLoading, setNextPageLoading] = useState(false); /** - * Audio Cache - * LRU cache to store processed audio buffers and improve performance + * Cache for storing audio buffers using LRU (Least Recently Used) strategy */ const audioCacheRef = useRef(new LRUCache({ max: 50 })); /** - * Text Processing Functions - * Handle text input and sentence splitting + * Sets the current text and splits it into sentences + * + * @param {string} text - The text to be processed + * @returns {void} */ const setText = useCallback((text: string) => { setCurrentText(text); @@ -148,6 +145,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { setNextPageLoading(false); }, []); + /** + * Stops the current audio playback and clears the active Howl instance + * + * @returns {void} + */ const abortAudio = useCallback(() => { if (activeHowl) { activeHowl.stop(); @@ -156,8 +158,9 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { }, [activeHowl]); /** - * Playback Control Functions - * Manage audio playback, navigation, and state + * Toggles the playback state between playing and paused + * + * @returns {void} */ const togglePlay = useCallback(() => { setIsPlaying((prev) => { @@ -170,6 +173,12 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { }); }, [abortAudio]); + /** + * Navigates to a specific page in the document + * + * @param {number} page - The target page number + * @returns {void} + */ const skipToPage = useCallback((page: number) => { abortAudio(); setIsPlaying(false); @@ -178,11 +187,23 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { setCurrDocPage(page); }, [abortAudio]); + /** + * Changes the current page by a specified amount + * + * @param {number} [num=1] - The number of pages to increment by + * @returns {void} + */ const incrementPage = useCallback((num = 1) => { setNextPageLoading(true); setCurrDocPage(currDocPage + num); }, [currDocPage]); + /** + * Moves to the next or previous sentence + * + * @param {boolean} [backwards=false] - Whether to move backwards + * @returns {Promise} + */ const advance = useCallback(async (backwards = false) => { setCurrentIndex((prev) => { const nextIndex = prev + (backwards ? -1 : 1); @@ -211,6 +232,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { }, [sentences, currDocPage, currDocPages, incrementPage]); + /** + * Moves forward one sentence in the text + * + * @returns {void} + */ const skipForward = useCallback(() => { setIsProcessing(true); @@ -221,6 +247,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { setIsProcessing(false); }, [abortAudio, advance]); + /** + * Moves backward one sentence in the text + * + * @returns {void} + */ const skipBackward = useCallback(() => { setIsProcessing(true); @@ -232,10 +263,20 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { }, [abortAudio, advance]); /** - * Audio Processing Functions - * Handle audio generation, caching, and playback + * Updates the voice and speed settings from the configuration + * + * @returns {void} + */ + const updateVoiceAndSpeed = useCallback(() => { + setVoice(configVoice); + setSpeed(voiceSpeed); + }, [configVoice, voiceSpeed]); + + /** + * Initializes OpenAI configuration and fetches available voices + * + * This effect runs when the config is loaded or API settings change */ - // Initialize OpenAI instance when config loads useEffect(() => { const fetchVoices = async () => { try { @@ -264,19 +305,14 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { dangerouslyAllowBrowser: true, }); fetchVoices(); + updateVoiceAndSpeed(); } - }, [configIsLoading, openApiKey, openApiBaseUrl]); + }, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed]); - // Initialize AudioContext + /** + * Initializes the AudioContext when component mounts + */ useEffect(() => { - /* - * 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) { @@ -297,7 +333,9 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { } }, [audioContext]); - // Set up MediaSession API + /** + * Sets up MediaSession API for media controls + */ useEffect(() => { if ('mediaSession' in navigator) { navigator.mediaSession.metadata = new MediaMetadata({ @@ -313,7 +351,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { } }, [togglePlay, skipForward, skipBackward]); - //new function to return audio buffer with caching + /** + * Generates and plays audio for the current sentence + * + * @returns {Promise} + */ const getAudio = useCallback(async (sentence: string): Promise => { // Check if the audio is already cached const cachedAudio = audioCacheRef.current.get(sentence); @@ -343,6 +385,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { } }, [audioContext, voice, speed]); + /** + * Processes and plays the current sentence + * + * @returns {Promise} + */ const processSentence = useCallback(async (sentence: string, preload = false): Promise => { if (isProcessing && !preload) throw new Error('Audio is already being processed'); if (!audioContext || !openaiRef.current) throw new Error('Audio context not initialized'); @@ -356,6 +403,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { return audioBufferToURL(audioBuffer!); }, [isProcessing, audioContext, getAudio]); + /** + * Plays the current sentence with Howl + * + * @returns {Promise} + */ const playSentenceWithHowl = useCallback(async (sentence: string) => { try { const audioUrl = await processSentence(sentence); @@ -408,6 +460,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { } }, [isPlaying, processSentence, advance]); + /** + * Preloads the next sentence's audio + * + * @returns {void} + */ const preloadNextAudio = useCallback(() => { try { if (sentences[currentIndex + 1] && !audioCacheRef.current.has(sentences[currentIndex + 1])) { @@ -418,6 +475,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { } }, [currentIndex, sentences, audioCacheRef, processSentence]); + /** + * Plays the current sentence's audio + * + * @returns {Promise} + */ const playAudio = useCallback(async () => { await playSentenceWithHowl(sentences[currentIndex]); }, [sentences, currentIndex, playSentenceWithHowl]); @@ -454,6 +516,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { abortAudio ]); + /** + * Stops the current audio playback + * + * @returns {void} + */ const stop = useCallback(() => { // Cancel any ongoing request abortAudio(); @@ -464,6 +531,12 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { setIsProcessing(false); }, [abortAudio]); + /** + * Stops the current audio playback and starts playing from a specified index + * + * @param {number} index - The index to start playing from + * @returns {void} + */ const stopAndPlayFromIndex = useCallback((index: number) => { abortAudio(); @@ -471,12 +544,24 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { setIsPlaying(true); }, [abortAudio]); + /** + * Sets the current index without playing + * + * @param {number} index - The index to set + * @returns {void} + */ const setCurrentIndexWithoutPlay = useCallback((index: number) => { abortAudio(); setCurrentIndex(index); }, [abortAudio]); + /** + * Sets the speed and restarts the playback + * + * @param {number} newSpeed - The new speed to set + * @returns {void} + */ const setSpeedAndRestart = useCallback((newSpeed: number) => { setSpeed(newSpeed); updateConfigKey('voiceSpeed', newSpeed); @@ -490,6 +575,12 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { } }, [isPlaying, abortAudio, updateConfigKey]); + /** + * Sets the voice and restarts the playback + * + * @param {string} newVoice - The new voice to set + * @returns {void} + */ const setVoiceAndRestart = useCallback((newVoice: string) => { setVoice(newVoice); updateConfigKey('voice', newVoice); @@ -504,45 +595,51 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { }, [isPlaying, abortAudio, updateConfigKey]); /** - * Context Value - * Aggregate all functions and state to be provided to consumers + * Provides the TTS context value to child components */ - const value = { + const value = useMemo(() => ({ isPlaying, - currentText, + isProcessing, + currentSentence: sentences[currentIndex] || '', + currDocPage, + currDocPages, + availableVoices, togglePlay, skipForward, skipBackward, - setText, - currentSentence: sentences[currentIndex] || '', - audioQueue, stop, - setCurrentIndex: setCurrentIndexWithoutPlay, stopAndPlayFromIndex, - sentences, - isProcessing, - setIsProcessing, - setIsPlaying, - speed, - setSpeed, + setText, + setCurrDocPages, setSpeedAndRestart, - voice, - setVoice, setVoiceAndRestart, - availableVoices, + skipToPage, + }), [ + isPlaying, + isProcessing, + sentences, currentIndex, currDocPage, currDocPages, + availableVoices, + togglePlay, + skipForward, + skipBackward, + stop, + stopAndPlayFromIndex, + setText, setCurrDocPages, - incrementPage, + setSpeedAndRestart, + setVoiceAndRestart, skipToPage, - }; - - // Render provider with value - if (configIsLoading) { - return null; - } + ]); + /** + * Renders the TTS context provider with its children + * + * @param {ReactNode} children - Child components to be wrapped + * @returns {JSX.Element} + */ return ( {children}