Contexts refactor + commenting

This commit is contained in:
Richard Roberson 2025-01-28 21:22:22 -07:00
parent 8f90455862
commit dd7154cccf
2 changed files with 297 additions and 113 deletions

View file

@ -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<string>} A data URL representing the PDF
*/
const convertPDFDataToURL = (pdfData: Blob): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@ -33,13 +51,27 @@ const convertPDFDataToURL = (pdfData: Blob): Promise<string> => {
});
};
/**
* Interface defining all available methods and properties in the PDF context
*/
interface PDFContextType {
// Documents management
documents: PDFDocument[];
addDocument: (file: File) => Promise<string>;
getDocument: (id: string) => Promise<PDFDocument | undefined>;
removeDocument: (id: string) => Promise<void>;
isLoading: boolean;
extractTextFromPDF: (pdfURL: string, currDocPage: number) => Promise<string>;
// Current document state
currDocURL: string | undefined;
currDocName: string | undefined;
currDocPages: number | undefined;
currDocPage: number;
currDocText: string | undefined;
setCurrentDocument: (id: string) => Promise<void>;
clearCurrDoc: () => void;
// PDF functionality
onDocumentLoadSuccess: ({ numPages }: { numPages: number }) => void;
highlightPattern: (text: string, pattern: string, containerRef: React.RefObject<HTMLDivElement>) => 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<void>;
currDocURL: string | undefined;
currDocName: string | undefined;
currDocPages: number | undefined;
currDocPage: number;
currDocText: string | undefined;
clearCurrDoc: () => void;
}
// Create the context
const PDFContext = createContext<PDFContextType | undefined>(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<PDFDocument[]>([]);
const [isLoading, setIsLoading] = useState(true);
@ -78,7 +115,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const [currDocName, setCurrDocName] = useState<string>();
const [currDocText, setCurrDocText] = useState<string>();
// 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<string>} The ID of the added document
*/
const addDocument = useCallback(async (file: File): Promise<string> => {
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<PDFDocument | undefined> => {
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<void> => {
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<string>} The extracted text
*/
const extractTextFromPDF = useCallback(async (pdfURL: string, currDocPage: number): Promise<string> => {
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<void> => {
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<HTMLDivElement>) => {
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<HTMLDivElement>,
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) {

View file

@ -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<string, AudioBuffer>({ 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<void>}
*/
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<void>}
*/
const getAudio = useCallback(async (sentence: string): Promise<AudioBuffer | undefined> => {
// 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<void>}
*/
const processSentence = useCallback(async (sentence: string, preload = false): Promise<string> => {
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<void>}
*/
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<void>}
*/
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 (
<TTSContext.Provider value={value}>
{children}