Abstracted direct fetch calls across components and contexts into new functions within `src/lib/client.ts`. This provides a consistent and centralized interface for interacting with backend APIs. - Introduced `src/lib/client.ts` to encapsulate API request logic. - Standardized audio buffer types (`TTSAudioBuffer`, `TTSAudioBytes`) in `src/types/tts.ts`. - Moved client-specific request types (`TTSRequestPayload`, `TTSRequestHeaders`, `TTSRetryOptions`) to `src/types/client.ts`. - Updated API routes and consumer components/contexts to leverage the new client library functions and type definitions. - Removed `src/utils/audio.ts` as its utility functions are now part of `src/lib/client.ts`.
1048 lines
33 KiB
TypeScript
1048 lines
33 KiB
TypeScript
'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<void>;
|
|
clearCurrDoc: () => void;
|
|
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
|
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, bookId?: string, format?: TTSAudiobookFormat) => Promise<string>;
|
|
regenerateChapter: (chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal) => Promise<TTSAudiobookChapter>;
|
|
bookRef: RefObject<Book | null>;
|
|
renditionRef: RefObject<Rendition | undefined>;
|
|
tocRef: RefObject<NavItem[]>;
|
|
locationRef: RefObject<string | number>;
|
|
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<EPUBContextType | undefined>(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<ArrayBuffer>();
|
|
const [currDocName, setCurrDocName] = useState<string>();
|
|
const [currDocText, setCurrDocText] = useState<string>();
|
|
const [isAudioCombining] = useState(false);
|
|
|
|
// Add new refs
|
|
const bookRef = useRef<Book | null>(null);
|
|
const renditionRef = useRef<Rendition | undefined>(undefined);
|
|
const tocRef = useRef<NavItem[]>([]);
|
|
const locationRef = useRef<string | number>(currDocPage);
|
|
const isEPUBSetOnce = useRef(false);
|
|
// Should pause ref
|
|
const shouldPauseRef = useRef(true);
|
|
// Track current highlight CFI for removal
|
|
const currentHighlightCfi = useRef<string | null>(null);
|
|
const currentWordHighlightCfi = useRef<string | null>(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<void> => {
|
|
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<string>} The extracted text content
|
|
*/
|
|
const extractPageText = useCallback(async (book: Book, rendition: Rendition, shouldPause = false): Promise<string> => {
|
|
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<string[]>} Array of text content from each section
|
|
*/
|
|
const extractBookText = useCallback(async (): Promise<Array<{ text: string; href: string }>> => {
|
|
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<string> => {
|
|
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<number>();
|
|
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<string, string>();
|
|
|
|
// 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<TTSAudiobookChapter> => {
|
|
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<string, string>();
|
|
|
|
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<number>(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<number>(n + 1).fill(Number.POSITIVE_INFINITY)
|
|
);
|
|
const bt: number[][] = Array.from({ length: m + 1 }, () =>
|
|
new Array<number>(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 (
|
|
<EPUBContext.Provider value={contextValue}>
|
|
{children}
|
|
</EPUBContext.Provider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|