'use client'; import { createContext, useContext, useState, ReactNode, useCallback, useMemo, useRef, RefObject, useEffect } from 'react'; import type { NavItem } from 'epubjs'; import type { SpineItem } from 'epubjs/types/section'; import type { Book, Rendition } from 'epubjs'; import { getEpubDocument, setLastDocumentLocation } from '@/lib/dexie'; import { useTTS } from '@/contexts/TTSContext'; import { createRangeCfi } from '@/lib/epub'; import { useParams } from 'next/navigation'; import { useConfig } from './ConfigContext'; import { withRetry, getAudiobookStatus, generateTTS, createAudiobookChapter } from '@/lib/client'; import { CmpStr } from 'cmpstr'; import type { TTSSentenceAlignment, TTSAudiobookFormat, TTSAudiobookChapter, } from '@/types/tts'; import type { TTSRequestHeaders, TTSRequestPayload, TTSRetryOptions, } from '@/types/client'; interface EPUBContextType { currDocData: ArrayBuffer | undefined; currDocName: string | undefined; currDocPages: number | undefined; currDocPage: number | string; currDocText: string | undefined; setCurrentDocument: (id: string) => Promise; clearCurrDoc: () => void; extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise; createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, bookId?: string, format?: TTSAudiobookFormat) => Promise; regenerateChapter: (chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal) => Promise; bookRef: RefObject; renditionRef: RefObject; tocRef: RefObject; locationRef: RefObject; handleLocationChanged: (location: string | number) => void; setRendition: (rendition: Rendition) => void; isAudioCombining: boolean; highlightPattern: (text: string) => void; clearHighlights: () => void; highlightWordIndex: ( alignment: TTSSentenceAlignment | undefined, wordIndex: number | null | undefined, sentence: string | null | undefined ) => void; clearWordHighlights: () => void; } const EPUBContext = createContext(undefined); const EPUB_CONTINUATION_CHARS = 600; const cmp = CmpStr.create().setMetric('dice').setFlags('itw'); const normalizeWordForMatch = (text: string): string => text .trim() .replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, '') .toLowerCase(); const stepToNextNode = (node: Node | null, root: Node): Node | null => { if (!node) return null; if (node.firstChild) { return node.firstChild; } let current: Node | null = node; while (current) { if (current === root) { return null; } if (current.nextSibling) { return current.nextSibling; } current = current.parentNode; } return null; }; const getNextTextNode = (node: Node | null, root: Node): Text | null => { let next = stepToNextNode(node, root); while (next) { if (next.nodeType === Node.TEXT_NODE) { return next as Text; } next = stepToNextNode(next, root); } return null; }; const collectContinuationFromRange = (range: Range | null | undefined, limit = EPUB_CONTINUATION_CHARS): string => { if (typeof window === 'undefined' || !range) { return ''; } const root = range.commonAncestorContainer; if (!root) { return ''; } const parts: string[] = []; let remaining = limit; const appendFromTextNode = (textNode: Text, offset: number) => { if (remaining <= 0) return; const textContent = textNode.textContent || ''; if (offset >= textContent.length) return; const slice = textContent.slice(offset, offset + remaining); if (slice) { parts.push(slice); remaining -= slice.length; } }; if (range.endContainer.nodeType === Node.TEXT_NODE) { appendFromTextNode(range.endContainer as Text, range.endOffset); let nextNode = getNextTextNode(range.endContainer, root); while (nextNode && remaining > 0) { appendFromTextNode(nextNode, 0); nextNode = getNextTextNode(nextNode, root); } } else { let nextNode = getNextTextNode(range.endContainer, root); while (nextNode && remaining > 0) { appendFromTextNode(nextNode, 0); nextNode = getNextTextNode(nextNode, root); } } return parts.join(' ').replace(/\s+/g, ' ').trim(); }; /** * Provider component for EPUB functionality * Manages the state and operations for EPUB document handling * @param {Object} props - Component props * @param {ReactNode} props.children - Child components to be wrapped by the provider */ export function EPUBProvider({ children }: { children: ReactNode }) { const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop, skipToLocation, setIsEPUB } = useTTS(); const { id } = useParams(); // Configuration context to get TTS settings const { apiKey, baseUrl, voiceSpeed, voice, ttsProvider, ttsModel, ttsInstructions, smartSentenceSplitting, epubHighlightEnabled, } = useConfig(); // Current document state const [currDocData, setCurrDocData] = useState(); const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); const [isAudioCombining] = useState(false); // Add new refs const bookRef = useRef(null); const renditionRef = useRef(undefined); const tocRef = useRef([]); const locationRef = useRef(currDocPage); const isEPUBSetOnce = useRef(false); // Should pause ref const shouldPauseRef = useRef(true); // Track current highlight CFI for removal const currentHighlightCfi = useRef(null); const currentWordHighlightCfi = useRef(null); /** * Clears all current document state and stops any active TTS */ const clearCurrDoc = useCallback(() => { setCurrDocData(undefined); setCurrDocName(undefined); setCurrDocText(undefined); setCurrDocPages(undefined); isEPUBSetOnce.current = false; bookRef.current = null; renditionRef.current = undefined; locationRef.current = 1; tocRef.current = []; stop(); }, [setCurrDocPages, stop]); /** * Sets the current document based on its ID by fetching from IndexedDB * @param {string} id - The unique identifier of the document * @throws {Error} When document data is empty or retrieval fails */ const setCurrentDocument = useCallback(async (id: string): Promise => { try { const doc = await getEpubDocument(id); if (doc) { console.log('Retrieved document size:', doc.size); console.log('Retrieved ArrayBuffer size:', doc.data.byteLength); if (doc.data.byteLength === 0) { console.error('Retrieved ArrayBuffer is empty'); throw new Error('Empty document data'); } setCurrDocName(doc.name); setCurrDocData(doc.data); // Store ArrayBuffer directly } else { console.error('Document not found in IndexedDB'); } } catch (error) { console.error('Failed to get EPUB document:', error); clearCurrDoc(); // Clean up on error } }, [clearCurrDoc]); /** * Extracts text content from the current EPUB page/location * @param {Book} book - The EPUB.js Book instance * @param {Rendition} rendition - The EPUB.js Rendition instance * @param {boolean} shouldPause - Whether to pause TTS * @returns {Promise} The extracted text content */ const extractPageText = useCallback(async (book: Book, rendition: Rendition, shouldPause = false): Promise => { try { const { start, end } = rendition?.location; if (!start?.cfi || !end?.cfi || !book || !book.isOpen || !rendition) return ''; const rangeCfi = createRangeCfi(start.cfi, end.cfi); const range = await book.getRange(rangeCfi); if (!range) { console.warn('Failed to get range from CFI:', rangeCfi); return ''; } const textContent = range.toString().trim(); const continuationPreview = collectContinuationFromRange(range); if (smartSentenceSplitting) { setTTSText(textContent, { shouldPause, location: start.cfi, nextLocation: end.cfi, nextText: continuationPreview }); } else { // When smart splitting is disabled, behave like the original implementation: // send only the current page/location text without any continuation preview. setTTSText(textContent, { shouldPause, location: start.cfi, }); } setCurrDocText(textContent); return textContent; } catch (error) { console.error('Error extracting EPUB text:', error); return ''; } }, [setTTSText, smartSentenceSplitting]); /** * Extracts text content from the entire EPUB book * @returns {Promise} Array of text content from each section */ const extractBookText = useCallback(async (): Promise> => { try { if (!bookRef.current || !bookRef.current.isOpen) return [{ text: '', href: '' }]; const book = bookRef.current; const spine = book.spine; const promises: Promise<{ text: string; href: string }>[] = []; spine.each((item: SpineItem) => { const url = item.href || ''; if (!url) return; //console.log('Extracting text from section:', item as SpineItem); const promise = book.load(url) .then((section) => (section as Document)) .then((section) => ({ text: section.body.textContent || '', href: url })) .catch((err) => { console.error(`Error loading section ${url}:`, err); return { text: '', href: url }; }); promises.push(promise); }); const textArray = await Promise.all(promises); const filteredArray = textArray.filter(item => item.text.trim() !== ''); console.log('Extracted entire EPUB text array:', filteredArray); return filteredArray; } catch (error) { console.error('Error extracting EPUB text:', error); return [{ text: '', href: '' }]; } }, []); /** * Creates a complete audiobook by processing all text through NLP and TTS */ const createFullAudioBook = useCallback(async ( onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, providedBookId?: string, format: TTSAudiobookFormat = 'mp3' ): Promise => { try { const sections = await extractBookText(); if (!sections.length) throw new Error('No text content found in book'); // Calculate total length for accurate progress tracking const totalLength = sections.reduce((sum, section) => sum + section.text.trim().length, 0); let processedLength = 0; let bookId: string = providedBookId || ''; // Get TOC for chapter titles const chapters = tocRef.current || []; // If we have a bookId, check for existing chapters to determine which indices already exist const existingIndices = new Set(); if (bookId) { try { const existingData = await getAudiobookStatus(bookId); if (existingData.chapters && existingData.chapters.length > 0) { for (const ch of existingData.chapters) { existingIndices.add(ch.index); } // Log smallest missing index for visibility let nextMissing = 0; while (existingIndices.has(nextMissing)) nextMissing++; console.log(`Resuming; next missing chapter index is ${nextMissing}`); } } catch (error) { console.error('Error checking existing chapters:', error); } } // Create a map of section hrefs to their chapter titles const sectionTitleMap = new Map(); // First, loop through all chapters to create the mapping for (const chapter of chapters) { if (!chapter.href) continue; const chapterBaseHref = chapter.href.split('#')[0]; const chapterTitle = chapter.label.trim(); // For each chapter, find all matching sections for (const section of sections) { const sectionHref = section.href; const sectionBaseHref = sectionHref.split('#')[0]; // If this section matches this chapter, map it if (sectionHref === chapter.href || sectionBaseHref === chapterBaseHref) { sectionTitleMap.set(sectionHref, chapterTitle); } } } // Process each section for (let i = 0; i < sections.length; i++) { // Check for abort at the start of iteration if (signal?.aborted) { console.log('Generation cancelled by user'); if (bookId) { return bookId; // Return bookId with partial progress } throw new Error('Audiobook generation cancelled'); } const section = sections[i]; const trimmedText = section.text.trim(); if (!trimmedText) continue; // Skip chapters that already exist on disk (supports non-contiguous indices) if (existingIndices.has(i)) { processedLength += trimmedText.length; onProgress((processedLength / totalLength) * 100); continue; } try { const reqHeaders: TTSRequestHeaders = { 'Content-Type': 'application/json', 'x-openai-key': apiKey, 'x-openai-base-url': baseUrl, 'x-tts-provider': ttsProvider, }; const reqBody: TTSRequestPayload = { text: trimmedText, voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), speed: voiceSpeed, format: 'mp3', model: ttsModel, instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined }; const retryOptions: TTSRetryOptions = { maxRetries: 2, initialDelay: 5000, maxDelay: 10000, backoffFactor: 2 }; const audioBuffer = await withRetry( async () => { // Check for abort before starting TTS request if (signal?.aborted) { throw new DOMException('Aborted', 'AbortError'); } return await generateTTS(reqBody, reqHeaders, signal); }, retryOptions ); // Get the chapter title from our pre-computed map let chapterTitle = sectionTitleMap.get(section.href); // If no chapter title found, use index-based naming if (!chapterTitle) { chapterTitle = `Chapter ${i + 1}`; } // Check for abort before sending to server if (signal?.aborted) { console.log('Generation cancelled before saving chapter'); if (bookId) { return bookId; } throw new Error('Audiobook generation cancelled'); } // Send to server for conversion and storage const chapter = await createAudiobookChapter({ chapterTitle, buffer: Array.from(new Uint8Array(audioBuffer)), bookId, format, chapterIndex: i }, signal); if (!bookId) { bookId = chapter.bookId!; } // Notify about completed chapter if (onChapterComplete) { onChapterComplete(chapter); } processedLength += trimmedText.length; onProgress((processedLength / totalLength) * 100); } catch (error) { if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) { console.log('TTS request aborted, returning partial progress'); if (bookId) { return bookId; // Return with partial progress } throw new Error('Audiobook generation cancelled'); } console.error('Error processing section:', error); // Notify about error if (onChapterComplete) { onChapterComplete({ index: i, title: sectionTitleMap.get(section.href) || `Chapter ${i + 1}`, status: 'error', bookId, format }); } } } if (!bookId) { throw new Error('No audio was generated from the book content'); } return bookId; } catch (error) { console.error('Error creating audiobook:', error); throw error; } }, [extractBookText, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions]); /** * Regenerates a specific chapter of the audiobook */ const regenerateChapter = useCallback(async ( chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal ): Promise => { try { const sections = await extractBookText(); if (chapterIndex >= sections.length) { throw new Error('Invalid chapter index'); } const section = sections[chapterIndex]; const trimmedText = section.text.trim(); if (!trimmedText) { throw new Error('No text content found in chapter'); } // Get TOC for chapter title const chapters = tocRef.current || []; const sectionTitleMap = new Map(); for (const chapter of chapters) { if (!chapter.href) continue; const chapterBaseHref = chapter.href.split('#')[0]; const chapterTitle = chapter.label.trim(); for (const sect of sections) { const sectionHref = sect.href; const sectionBaseHref = sectionHref.split('#')[0]; if (sectionHref === chapter.href || sectionBaseHref === chapterBaseHref) { sectionTitleMap.set(sectionHref, chapterTitle); } } } const chapterTitle = sectionTitleMap.get(section.href) || `Chapter ${chapterIndex + 1}`; // Generate audio with retry logic const reqHeaders: TTSRequestHeaders = { 'Content-Type': 'application/json', 'x-openai-key': apiKey, 'x-openai-base-url': baseUrl, 'x-tts-provider': ttsProvider, }; const reqBody: TTSRequestPayload = { text: trimmedText, voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), speed: voiceSpeed, format: 'mp3', model: ttsModel, instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined }; const retryOptions: TTSRetryOptions = { maxRetries: 2, initialDelay: 5000, maxDelay: 10000, backoffFactor: 2 }; const audioBuffer = await withRetry( async () => { if (signal?.aborted) { throw new DOMException('Aborted', 'AbortError'); } return await generateTTS(reqBody, reqHeaders, signal); }, retryOptions ); if (signal?.aborted) { throw new Error('Chapter regeneration cancelled'); } // Send to server for conversion and storage const chapter = await createAudiobookChapter({ chapterTitle, buffer: Array.from(new Uint8Array(audioBuffer)), bookId, format, chapterIndex }, signal); return chapter; } catch (error) { if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) { throw new Error('Chapter regeneration cancelled'); } console.error('Error regenerating chapter:', error); throw error; } }, [extractBookText, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions]); const setRendition = useCallback((rendition: Rendition) => { bookRef.current = rendition.book; renditionRef.current = rendition; }, []); const handleLocationChanged = useCallback((location: string | number) => { // Set the EPUB flag once the location changes if (!isEPUBSetOnce.current) { setIsEPUB(true); isEPUBSetOnce.current = true; renditionRef.current?.display(location.toString()); return; } if (!bookRef.current?.isOpen || !renditionRef.current) return; // Handle special 'next' and 'prev' cases if (location === 'next' && renditionRef.current) { shouldPauseRef.current = false; renditionRef.current.next(); return; } if (location === 'prev' && renditionRef.current) { shouldPauseRef.current = false; renditionRef.current.prev(); return; } // Save the location to IndexedDB if not initial if (id && locationRef.current !== 1) { console.log('Saving location:', location); setLastDocumentLocation(id as string, location.toString()); } skipToLocation(location); locationRef.current = location; if (bookRef.current && renditionRef.current) { extractPageText(bookRef.current, renditionRef.current, shouldPauseRef.current); shouldPauseRef.current = true; } }, [id, skipToLocation, extractPageText, setIsEPUB]); const clearWordHighlights = useCallback(() => { if (!renditionRef.current) return; if (currentWordHighlightCfi.current) { renditionRef.current.annotations.remove(currentWordHighlightCfi.current, 'highlight'); currentWordHighlightCfi.current = null; } }, []); const clearHighlights = useCallback(() => { if (renditionRef.current && currentHighlightCfi.current) { renditionRef.current.annotations.remove(currentHighlightCfi.current, 'highlight'); currentHighlightCfi.current = null; } clearWordHighlights(); }, [clearWordHighlights]); const highlightPattern = useCallback(async (text: string) => { if (!renditionRef.current) return; // Clear existing highlights first clearHighlights(); if (!epubHighlightEnabled) return; if (!text || !text.trim()) return; try { const contents = renditionRef.current.getContents(); const contentsArray = Array.isArray(contents) ? contents : [contents]; for (const content of contentsArray) { const win = content.window; if (win && win.find) { // Reset selection to start of document to ensure full search const sel = win.getSelection(); sel?.removeAllRanges(); // Attempt to find the text // window.find(aString, aCaseSensitive, aBackwards, aWrapAround, aWholeWord, aSearchInFrames, aShowDialog); // Note: We search for the trimmed text. if (win.find(text.trim(), false, false, true, false, false, false)) { const range = sel?.getRangeAt(0); if (range) { const cfi = content.cfiFromRange(range); // Store CFI for removal currentHighlightCfi.current = cfi; renditionRef.current.annotations.add('highlight', cfi, {}, (e: MouseEvent) => { console.log('Highlight clicked', e); }, '', { fill: 'grey', 'fill-opacity': '0.4', 'mix-blend-mode': 'multiply' }); // Clear the browser selection so it doesn't look like user selected text sel?.removeAllRanges(); return; // Stop after first match } } } } } catch (error) { console.error('Error highlighting text:', error); } }, [clearHighlights, epubHighlightEnabled]); // Effect to clear highlights when disabled useEffect(() => { if (!epubHighlightEnabled) { clearHighlights(); } }, [epubHighlightEnabled, clearHighlights]); const highlightWordIndex = useCallback(( alignment: TTSSentenceAlignment | undefined, wordIndex: number | null | undefined, sentence: string | null | undefined ) => { clearWordHighlights(); if (!epubHighlightEnabled) return; if (!alignment) return; if (wordIndex === null || wordIndex === undefined || wordIndex < 0) return; const words = alignment.words || []; if (!words.length || wordIndex >= words.length) return; if (!renditionRef.current) return; if (!currentHighlightCfi.current) return; const cleanSentence = sentence && sentence.trim() ? sentence.trim().replace(/\s+/g, ' ') : null; if (!cleanSentence) return; const alignmentSentenceClean = alignment.sentence ? alignment.sentence.trim().replace(/\s+/g, ' ') : null; if (!alignmentSentenceClean || alignmentSentenceClean !== cleanSentence) { return; } const contents = renditionRef.current.getContents(); const contentsArray = Array.isArray(contents) ? contents : [contents]; for (const content of contentsArray) { let range: Range | null = null; try { range = content.range(currentHighlightCfi.current as string); } catch { range = null; } if (!range) continue; const root = range.commonAncestorContainer; if (!root) continue; const domTokens: Array<{ node: Text; startOffset: number; endOffset: number; norm: string; }> = []; const addTokensFromNode = (textNode: Text, start: number, end: number) => { const full = textNode.textContent || ''; const safeStart = Math.max(0, Math.min(start, full.length)); const safeEnd = Math.max(safeStart, Math.min(end, full.length)); if (safeEnd <= safeStart) return; const slice = full.slice(safeStart, safeEnd); const wordRegex = /\S+/g; let match: RegExpExecArray | null; while ((match = wordRegex.exec(slice)) !== null) { const raw = match[0]; const norm = normalizeWordForMatch(raw); if (!norm) continue; const tokenStart = safeStart + match.index; const tokenEnd = tokenStart + raw.length; domTokens.push({ node: textNode, startOffset: tokenStart, endOffset: tokenEnd, norm, }); } }; const nextTextNode = (node: Node | null): Text | null => { let next = getNextTextNode(node, root); while (next) { if (next.nodeType === Node.TEXT_NODE) { return next as Text; } next = getNextTextNode(next, root); } return null; }; // Collect tokens within the sentence range if (range.startContainer === range.endContainer && range.startContainer.nodeType === Node.TEXT_NODE) { addTokensFromNode(range.startContainer as Text, range.startOffset, range.endOffset); } else { let current: Text | null = null; if (range.startContainer.nodeType === Node.TEXT_NODE) { const startText = range.startContainer as Text; const isEnd = range.endContainer === startText && range.endContainer.nodeType === Node.TEXT_NODE; const endOffset = isEnd ? range.endOffset : (startText.textContent || '').length; addTokensFromNode(startText, range.startOffset, endOffset); if (isEnd) { current = null; } else { current = nextTextNode(startText); } } else { current = nextTextNode(range.startContainer); } while (current) { if (range.endContainer.nodeType === Node.TEXT_NODE && current === range.endContainer) { addTokensFromNode(current, 0, range.endOffset); break; } else { addTokensFromNode(current, 0, (current.textContent || '').length); } current = nextTextNode(current); } } if (!domTokens.length) { return; } const domFiltered: Array<{ tokenIndex: number; norm: string }> = []; for (let i = 0; i < domTokens.length; i++) { const norm = domTokens[i].norm; if (!norm) continue; domFiltered.push({ tokenIndex: i, norm }); } const ttsFiltered: Array<{ wordIndex: number; norm: string }> = []; for (let i = 0; i < words.length; i++) { const norm = normalizeWordForMatch(words[i].text); if (!norm) continue; ttsFiltered.push({ wordIndex: i, norm }); } const wordToToken = new Array(words.length).fill(-1); const m = domFiltered.length; const n = ttsFiltered.length; if (m && n) { const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(Number.POSITIVE_INFINITY) ); const bt: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0) ); // 0=diag,1=up,2=left dp[0][0] = 0; const GAP_COST = 0.7; for (let i = 0; i <= m; i++) { for (let j = 0; j <= n; j++) { if (i > 0 && j > 0) { const a = domFiltered[i - 1].norm; const b = ttsFiltered[j - 1].norm; const sim = cmp.compare(a, b); const subCost = 1 - sim; const cand = dp[i - 1][j - 1] + subCost; if (cand < dp[i][j]) { dp[i][j] = cand; bt[i][j] = 0; } } if (i > 0) { const cand = dp[i - 1][j] + GAP_COST; if (cand < dp[i][j]) { dp[i][j] = cand; bt[i][j] = 1; } } if (j > 0) { const cand = dp[i][j - 1] + GAP_COST; if (cand < dp[i][j]) { dp[i][j] = cand; bt[i][j] = 2; } } } } let i = m; let j = n; while (i > 0 || j > 0) { const move = bt[i][j]; if (i > 0 && j > 0 && move === 0) { const domIdx = domFiltered[i - 1].tokenIndex; const ttsIdx = ttsFiltered[j - 1].wordIndex; if (wordToToken[ttsIdx] === -1) { wordToToken[ttsIdx] = domIdx; } i -= 1; j -= 1; } else if (i > 0 && (move === 1 || j === 0)) { i -= 1; } else if (j > 0 && (move === 2 || i === 0)) { j -= 1; } else { break; } } // Propagate nearest known mapping to fill gaps let lastSeen = -1; for (let k = 0; k < wordToToken.length; k++) { if (wordToToken[k] !== -1) { lastSeen = wordToToken[k]; } else if (lastSeen !== -1) { wordToToken[k] = lastSeen; } } let nextSeen = -1; for (let k = wordToToken.length - 1; k >= 0; k--) { if (wordToToken[k] !== -1) { nextSeen = wordToToken[k]; } else if (nextSeen !== -1) { wordToToken[k] = nextSeen; } } } const mappedIndex = wordIndex < wordToToken.length ? wordToToken[wordIndex] : -1; if (mappedIndex === -1) { return; } const token = domTokens[mappedIndex]; const doc = token.node.ownerDocument || (range.commonAncestorContainer as Document); const wordRange = doc.createRange(); wordRange.setStart(token.node, token.startOffset); wordRange.setEnd(token.node, token.endOffset); try { const wordCfi = content.cfiFromRange(wordRange); currentWordHighlightCfi.current = wordCfi; renditionRef.current.annotations.add( 'highlight', wordCfi, {}, () => {}, '', { fill: 'var(--accent)', 'fill-opacity': '0.4', 'mix-blend-mode': 'multiply', } ); } catch (error) { console.error('Error highlighting EPUB word:', error); } break; } }, [epubHighlightEnabled, clearWordHighlights]); // Context value memoization const contextValue = useMemo( () => ({ setCurrentDocument, currDocData, currDocName, currDocPages, currDocPage, currDocText, clearCurrDoc, extractPageText, createFullAudioBook, regenerateChapter, bookRef, renditionRef, tocRef, locationRef, handleLocationChanged, setRendition, isAudioCombining, highlightPattern, clearHighlights, highlightWordIndex, clearWordHighlights, }), [ setCurrentDocument, currDocData, currDocName, currDocPages, currDocPage, currDocText, clearCurrDoc, extractPageText, createFullAudioBook, regenerateChapter, handleLocationChanged, setRendition, isAudioCombining, highlightPattern, clearHighlights, highlightWordIndex, clearWordHighlights, ] ); return ( {children} ); } /** * Custom hook to consume the EPUB context * @returns {EPUBContextType} The EPUB context value * @throws {Error} When used outside of EPUBProvider */ export function useEPUB() { const context = useContext(EPUBContext); if (context === undefined) { throw new Error('useEPUB must be used within an EPUBProvider'); } return context; }