From fdeeb60f7ecaa351f2e6bc646d84ed53639fbc44 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 13 May 2026 16:56:13 -0600 Subject: [PATCH] refactor epub reader hooks and add unit coverage --- src/app/(app)/epub/[id]/page.tsx | 23 +- src/app/(app)/epub/[id]/useEpubDocument.ts | 852 ++---------------- src/components/views/EPUBViewer.tsx | 4 +- src/contexts/TTSContext.tsx | 6 +- src/hooks/epub/useEPUBAudiobook.ts | 182 ++++ src/hooks/epub/useEPUBHighlighting.ts | 193 ++++ src/hooks/epub/useEPUBLocationController.ts | 170 ++++ src/lib/client/epub/epub-range-context.ts | 166 ++++ .../client/epub/epub-rendered-text-maps.ts | 237 +++++ tests/unit/epub-audiobook-hook.spec.ts | 40 + tests/unit/epub-location-controller.spec.ts | 31 + 11 files changed, 1093 insertions(+), 811 deletions(-) create mode 100644 src/hooks/epub/useEPUBAudiobook.ts create mode 100644 src/hooks/epub/useEPUBHighlighting.ts create mode 100644 src/hooks/epub/useEPUBLocationController.ts create mode 100644 src/lib/client/epub/epub-range-context.ts create mode 100644 src/lib/client/epub/epub-rendered-text-maps.ts create mode 100644 tests/unit/epub-audiobook-hook.spec.ts create mode 100644 tests/unit/epub-location-controller.spec.ts diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx index 5e6f784..5765461 100644 --- a/src/app/(app)/epub/[id]/page.tsx +++ b/src/app/(app)/epub/[id]/page.tsx @@ -19,13 +19,14 @@ import { resolveDocumentId } from '@/lib/client/dexie'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; -import { useEPUB } from './useEpubDocument'; +import { useEpubDocument } from './useEpubDocument'; export default function EPUBPage() { const canExportAudiobook = useFeatureFlag('enableAudiobookExport'); const { id } = useParams(); const router = useRouter(); - const epubState = useEPUB(); + const routeDocumentId = typeof id === 'string' ? id : undefined; + const epubState = useEpubDocument(routeDocumentId); const { setCurrentDocument, currDocName, @@ -52,19 +53,19 @@ export default function EPUBPage() { setActiveSidebar(null); inFlightDocIdRef.current = null; loadedDocIdRef.current = null; - }, [id]); + }, [routeDocumentId]); const loadDocument = useCallback(async () => { console.log('Loading new epub (from page.tsx)'); let didRedirect = false; let startedLoad = false; try { - if (!id) { + if (!routeDocumentId) { setError('Document not found'); return; } - const resolved = await resolveDocumentId(id as string); - if (resolved !== (id as string)) { + const resolved = await resolveDocumentId(routeDocumentId); + if (resolved !== routeDocumentId) { didRedirect = true; router.replace(`/epub/${resolved}`); return; @@ -93,7 +94,7 @@ export default function EPUBPage() { setIsLoading(false); } } - }, [id, router, setCurrentDocument, stop]); + }, [routeDocumentId, router, setCurrentDocument, stop]); useEffect(() => { if (!isLoading) return; @@ -143,8 +144,8 @@ export default function EPUBPage() { onChapterComplete: (chapter: TTSAudiobookChapter) => void, settings: AudiobookGenerationSettings ) => { - return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings); - }, [createEPUBAudioBook, id]); + return createEPUBAudioBook(onProgress, signal, onChapterComplete, routeDocumentId, settings.format, settings); + }, [createEPUBAudioBook, routeDocumentId]); const handleRegenerateChapter = useCallback(async ( chapterIndex: number, @@ -226,7 +227,7 @@ export default function EPUBPage() { isOpen={activeSidebar === 'audiobook'} setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'audiobook' : (prev === 'audiobook' ? null : prev))} documentType="epub" - documentId={id as string} + documentId={routeDocumentId || ''} onGenerateAudiobook={handleGenerateAudiobook} onRegenerateChapter={handleRegenerateChapter} /> @@ -249,7 +250,7 @@ export default function EPUBPage() { setActiveSidebar((prev) => isOpen ? 'segments' : (prev === 'segments' ? null : prev))} - documentId={id as string} + documentId={routeDocumentId || ''} epubBookRef={bookRef} /> diff --git a/src/app/(app)/epub/[id]/useEpubDocument.ts b/src/app/(app)/epub/[id]/useEpubDocument.ts index e3e7546..d487ce2 100644 --- a/src/app/(app)/epub/[id]/useEpubDocument.ts +++ b/src/app/(app)/epub/[id]/useEpubDocument.ts @@ -6,17 +6,12 @@ import { useMemo, useRef, RefObject, - useEffect + useEffect, } from 'react'; import type { NavItem } from 'epubjs'; -import type { SpineItem } from 'epubjs/types/section'; import type { Book, Rendition } from 'epubjs'; -import { createEpubAudiobookSourceAdapter } from '@/lib/client/audiobooks/adapters/epub'; -import { regenerateAudiobookChapter, runAudiobookGeneration } from '@/lib/client/audiobooks/pipeline'; -import { setLastDocumentLocation } from '@/lib/client/dexie'; -import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state'; import { getDocumentMetadata } from '@/lib/client/api/documents'; import { ensureCachedDocument } from '@/lib/client/cache/documents'; import { EpubRenderedLocationCloneManager } from '@/lib/client/epub/rendered-location-walker'; @@ -26,14 +21,21 @@ import { useTTS, type EpubLocatorResolver } from '@/contexts/TTSContext'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { createRangeCfi } from '@/lib/client/epub'; import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator'; -import { - buildMonotonicWordToTokenMap, - buildWordHighlightCacheKey, - tokenizeCanonicalSegment, - type EpubCanonicalWordToken, -} from '@/lib/client/epub/epub-word-highlight'; -import { useParams } from 'next/navigation'; import { useConfig } from '@/contexts/ConfigContext'; +import { + collectContinuationFromRange, + collectLeadingContextFromRange, +} from '@/lib/client/epub/epub-range-context'; +import { + buildRenderedTextMaps, + type EpubRenderedTextMap, +} from '@/lib/client/epub/epub-rendered-text-maps'; +import { + useEPUBHighlighting, + type EpubWordHighlightMapCache, +} from '@/hooks/epub/useEPUBHighlighting'; +import { useEPUBLocationController } from '@/hooks/epub/useEPUBLocationController'; +import { useEPUBAudiobook } from '@/hooks/epub/useEPUBAudiobook'; import type { EpubRenderedLocationWalker, TTSSentenceAlignment, @@ -44,7 +46,7 @@ import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/cli import { isStableEpubLocator } from '@/types/client'; import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan'; -export interface EPUBState { +export interface EpubDocumentState { currDocData: ArrayBuffer | undefined; currDocName: string | undefined; currDocPages: number | undefined; @@ -87,446 +89,12 @@ export interface EPUBState { clearWordHighlights: () => void; } -const EPUB_CONTINUATION_CHARS = 5000; - -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 stepToPreviousNode = (node: Node | null, root: Node): Node | null => { - if (!node) return null; - - if (node.previousSibling) { - let prev: Node | null = node.previousSibling; - while (prev?.lastChild) { - prev = prev.lastChild; - } - return prev; - } - - let current: Node | null = node.parentNode; - while (current) { - if (current === root) { - return null; - } - if (current.previousSibling) { - let prev: Node | null = current.previousSibling; - while (prev?.lastChild) { - prev = prev.lastChild; - } - return prev; - } - 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 getPreviousTextNode = (node: Node | null, root: Node): Text | null => { - let prev = stepToPreviousNode(node, root); - while (prev) { - if (prev.nodeType === Node.TEXT_NODE) { - return prev as Text; - } - prev = stepToPreviousNode(prev, root); - } - return null; -}; - -const getLastTextNodeInSubtree = (node: Node | null): Text | null => { - if (!node) return null; - if (node.nodeType === Node.TEXT_NODE) return node as Text; - - let child: Node | null = node.lastChild; - while (child) { - const nested = getLastTextNodeInSubtree(child); - if (nested) return nested; - child = child.previousSibling; - } - - 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(); -}; - -const collectLeadingContextFromRange = (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 prependFromTextNode = (textNode: Text, endOffset: number) => { - if (remaining <= 0) return; - const textContent = textNode.textContent || ''; - const safeEnd = Math.max(0, Math.min(endOffset, textContent.length)); - if (safeEnd <= 0) return; - const safeStart = Math.max(0, safeEnd - remaining); - const slice = textContent.slice(safeStart, safeEnd); - if (slice) { - parts.unshift(slice); - remaining -= slice.length; - } - }; - - let cursor: Node | null = null; - - if (range.startContainer.nodeType === Node.TEXT_NODE) { - const startText = range.startContainer as Text; - prependFromTextNode(startText, range.startOffset); - cursor = startText; - } else { - const startNode = range.startContainer; - let anchor: Node | null = null; - if (range.startOffset > 0) { - anchor = startNode.childNodes[range.startOffset - 1] ?? null; - } - if (!anchor) { - anchor = stepToPreviousNode(startNode, root); - } - - const anchorText = getLastTextNodeInSubtree(anchor); - if (anchorText) { - prependFromTextNode(anchorText, (anchorText.textContent || '').length); - cursor = anchorText; - } else { - cursor = anchor; - } - } - - let prevNode = getPreviousTextNode(cursor, root); - while (prevNode && remaining > 0) { - prependFromTextNode(prevNode, (prevNode.textContent || '').length); - prevNode = getPreviousTextNode(prevNode, root); - } - - return parts.join(' ').replace(/\s+/g, ' ').trim(); -}; - -type EpubMappedPosition = { - node: Text; - offset: number; -}; - -type EpubMappedChar = { - char: string; - position: EpubMappedPosition; -}; - -type EpubRenderedTextMap = { - sourceKey: string; - chars: EpubMappedPosition[]; - content: { - cfiFromRange: (range: Range) => string; - }; -}; - -type EpubWordHighlightMapCache = { - key: string; - wordToToken: number[]; - tokens: EpubCanonicalWordToken[]; -}; - -const cloneMappedChar = (char: string, source: EpubMappedChar): EpubMappedChar => ({ - char, - position: source.position, -}); - -const replaceMappedUrls = (tokens: EpubMappedChar[]): EpubMappedChar[] => { - const text = tokens.map((token) => token.char).join(''); - const urlPattern = /\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi; - const replaced: EpubMappedChar[] = []; - let cursor = 0; - let match: RegExpExecArray | null; - - while ((match = urlPattern.exec(text)) !== null) { - const start = match.index; - const end = start + match[0].length; - replaced.push(...tokens.slice(cursor, start)); - - const anchor = tokens[start] ?? tokens[Math.max(0, end - 1)]; - if (anchor) { - const replacement = `- (link to ${match[1]}) -`; - for (const char of replacement) { - replaced.push(cloneMappedChar(char, anchor)); - } - } - cursor = end; - } - - replaced.push(...tokens.slice(cursor)); - return replaced; -}; - -const removeMappedHyphenation = (tokens: EpubMappedChar[]): EpubMappedChar[] => { - const text = tokens.map((token) => token.char).join(''); - const hyphenPattern = /(\w+)-\s+(\w+)/g; - const replaced: EpubMappedChar[] = []; - let cursor = 0; - let match: RegExpExecArray | null; - - while ((match = hyphenPattern.exec(text)) !== null) { - const start = match.index; - const full = match[0]; - const first = match[1]; - const second = match[2]; - const secondOffset = full.lastIndexOf(second); - - replaced.push(...tokens.slice(cursor, start)); - replaced.push(...tokens.slice(start, start + first.length)); - replaced.push(...tokens.slice(start + secondOffset, start + secondOffset + second.length)); - cursor = start + full.length; - } - - replaced.push(...tokens.slice(cursor)); - return replaced; -}; - -const normalizeMappedTokensForTts = (tokens: EpubMappedChar[]): EpubMappedChar[] => { - const withoutLinks = replaceMappedUrls(tokens); - const withoutHyphenation = removeMappedHyphenation(withoutLinks); - const normalized: EpubMappedChar[] = []; - let pendingWhitespace: EpubMappedChar | null = null; - - const flushWhitespace = () => { - if (!pendingWhitespace || normalized.length === 0 || normalized[normalized.length - 1].char === ' ') { - pendingWhitespace = null; - return; - } - normalized.push(cloneMappedChar(' ', pendingWhitespace)); - pendingWhitespace = null; - }; - - for (const token of withoutHyphenation) { - if (token.char === '*') continue; - if (/\s/.test(token.char)) { - pendingWhitespace ??= token; - continue; - } - - flushWhitespace(); - normalized.push(token); - } - - if (normalized[normalized.length - 1]?.char === ' ') { - normalized.pop(); - } - - return normalized; -}; - -const collectMappedTextFromRange = (range: Range): EpubMappedChar[] => { - const root = range.commonAncestorContainer; - const doc = range.startContainer.ownerDocument ?? (range.startContainer as Document); - const mapped: EpubMappedChar[] = []; - - const addTextSlice = (textNode: Text, start: number, end: number) => { - const text = textNode.textContent || ''; - const safeStart = Math.max(0, Math.min(start, text.length)); - const safeEnd = Math.max(safeStart, Math.min(end, text.length)); - for (let offset = safeStart; offset < safeEnd; offset += 1) { - mapped.push({ - char: text[offset], - position: { node: textNode, offset }, - }); - } - }; - - if (root.nodeType === Node.TEXT_NODE) { - addTextSlice(root as Text, range.startOffset, range.endOffset); - return mapped; - } - - const nodeFilter = doc.defaultView?.NodeFilter ?? NodeFilter; - const walker = doc.createTreeWalker( - root, - nodeFilter.SHOW_TEXT, - { - acceptNode: (node) => { - try { - return range.intersectsNode(node) - ? nodeFilter.FILTER_ACCEPT - : nodeFilter.FILTER_REJECT; - } catch { - return nodeFilter.FILTER_REJECT; - } - }, - }, - ); - - let textNode = walker.nextNode() as Text | null; - while (textNode) { - const text = textNode.textContent || ''; - let start = 0; - let end = text.length; - - if (textNode === range.startContainer) { - start = range.startOffset; - } - if (textNode === range.endContainer) { - end = range.endOffset; - } - - addTextSlice(textNode, start, end); - textNode = walker.nextNode() as Text | null; - } - - return mapped; -}; - -const buildRenderedTextMaps = ( - rendition: Rendition, - rangeCfi: string, - sourceKey: string, -): EpubRenderedTextMap[] => { - const contents = rendition.getContents(); - const contentsArray = Array.isArray(contents) ? contents : [contents]; - const maps: EpubRenderedTextMap[] = []; - - for (const content of contentsArray) { - try { - const range = content.range(rangeCfi); - if (!range) continue; - - const normalized = normalizeMappedTokensForTts(collectMappedTextFromRange(range)); - if (!normalized.length) continue; - - maps.push({ - sourceKey, - chars: normalized.map((token) => token.position), - content, - }); - } catch { - // Not every displayed iframe can resolve every CFI in spread mode. - } - } - - return maps; -}; - -const createRangeFromMappedOffsets = ( - map: EpubRenderedTextMap, - startOffset: number, - endOffset: number, -): Range | null => { - const start = Math.max(0, Math.min(startOffset, map.chars.length)); - const end = Math.max(start, Math.min(endOffset, map.chars.length)); - if (end <= start) return null; - - const startPosition = map.chars[start]; - const endPosition = map.chars[end - 1]; - if (!startPosition || !endPosition) return null; - - const doc = startPosition.node.ownerDocument; - const range = doc.createRange(); - range.setStart(startPosition.node, startPosition.offset); - range.setEnd(endPosition.node, endPosition.offset + 1); - return range; -}; - -const resolveVisibleSegmentRange = ( - maps: EpubRenderedTextMap[], - segment: CanonicalTtsSegment | null | undefined, -): { map: EpubRenderedTextMap; range: Range; startOffset: number; endOffset: number } | null => { - if (!segment) return null; - - for (const map of maps) { - const startsInMap = segment.startAnchor.sourceKey === map.sourceKey; - const endsInMap = segment.endAnchor.sourceKey === map.sourceKey; - if (!startsInMap && !endsInMap) continue; - - const startOffset = startsInMap ? segment.startAnchor.offset : 0; - const endOffset = endsInMap ? segment.endAnchor.offset : map.chars.length; - const range = createRangeFromMappedOffsets(map, startOffset, endOffset); - if (range) { - return { map, range, startOffset, endOffset }; - } - } - - return null; -}; - - /** * Route-local EPUB reader hook. */ -export function useEPUB(): EPUBState { +export function useEpubDocument(documentId?: string): EpubDocumentState { const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop, skipToLocation, setIsEPUB } = useTTS(); const { authEnabled } = useAuthConfig(); - const { id } = useParams(); // Configuration context to get TTS settings const { apiKey, @@ -564,6 +132,21 @@ export function useEPUB(): EPUBState { const renderedLocationCloneManagerRef = useRef( new EpubRenderedLocationCloneManager(), ); + const { + clearHighlights, + highlightSegment, + clearWordHighlights, + highlightWordIndex, + setRenderedTextMaps, + resetHighlightState, + } = useEPUBHighlighting({ + renditionRef, + epubHighlightEnabled, + currentHighlightCfiRef: currentHighlightCfi, + currentWordHighlightCfiRef: currentWordHighlightCfi, + renderedTextMapsRef, + wordHighlightMapCacheRef, + }); useEffect(() => () => { void renderedLocationCloneManagerRef.current.destroy(); @@ -582,15 +165,15 @@ export function useEPUB(): EPUBState { setCurrDocText(undefined); setCurrDocPages(undefined); isEPUBSetOnce.current = false; + shouldPauseRef.current = true; bookRef.current = null; renditionRef.current = undefined; locationRef.current = 1; tocRef.current = []; - renderedTextMapsRef.current = []; - wordHighlightMapCacheRef.current = null; + resetHighlightState(); renderedLocationCloneManagerRef.current.invalidate(); stop(); - }, [setCurrDocPages, stop]); + }, [resetHighlightState, setCurrDocPages, stop]); /** * Sets the current document based on its ID by fetching from IndexedDB @@ -648,12 +231,11 @@ export function useEPUB(): EPUBState { return ''; } const textContent = range.toString().trim(); - renderedTextMapsRef.current = buildRenderedTextMaps( + setRenderedTextMaps(buildRenderedTextMaps( rendition, rangeCfi, normalizeTtsLocationKey(start.cfi), - ); - wordHighlightMapCacheRef.current = null; + )); const leadingPreview = collectLeadingContextFromRange(range); const continuationPreview = collectContinuationFromRange(range); @@ -680,71 +262,7 @@ export function useEPUB(): EPUBState { 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 loadSpineSection = useCallback(async (href: string) => { - const book = bookRef.current; - if (!book?.isOpen) return null; - const section = book.spine.get(href); - if (!section) return null; - const loaded = await Promise.resolve(section.load(book.load.bind(book))); - const doc = (() => { - if (!loaded) return null; - if (typeof Document !== 'undefined' && loaded instanceof Document) { - return loaded; - } - const element = loaded as unknown as Element; - if (element?.ownerDocument) { - return element.ownerDocument; - } - const sectionWithDocument = section as unknown as { document?: Document }; - return sectionWithDocument.document ?? null; - })(); - if (!doc) return null; - return { section, doc }; - }, []); - - 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; - const promise = loadSpineSection(url) - .then((loaded) => { - if (!loaded?.doc) return { text: '', href: url }; - const text = loaded.doc.body?.textContent || ''; - return { text, href: url }; - }) - .catch((err) => { - console.error(`Error loading section ${url}:`, err); - return { text: '', href: url }; - }) - .finally(() => { - const section = book.spine.get(url); - section?.unload?.(); - }); - - promises.push(promise); - }); - - const textArray = await Promise.all(promises); - const filteredArray = textArray.filter(item => item.text.trim() !== ''); - return filteredArray; - } catch (error) { - console.error('Error extracting EPUB text:', error); - return [{ text: '', href: '' }]; - } - }, [loadSpineSection]); + }, [setRenderedTextMaps, setTTSText, smartSentenceSplitting]); /** * Resolves a draft EPUB locator (typically `{ readerType: 'epub', location: @@ -849,287 +367,31 @@ export function useEPUB(): EPUBState { }); }, [currDocData, epubTheme]); - const audiobookAdapter = useMemo(() => createEpubAudiobookSourceAdapter({ - extractBookText, - getTocItems: () => tocRef.current || [], - }), [extractBookText]); - - /** - * 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', - settings?: AudiobookGenerationSettings - ): Promise => { - try { - return await runAudiobookGeneration({ - adapter: audiobookAdapter, - apiKey, - baseUrl, - defaultProvider: providerRef, - onProgress, - signal, - onChapterComplete, - providedBookId, - format, - settings, - }); - } catch (error) { - console.error('Error creating audiobook:', error); - throw error; - } - }, [audiobookAdapter, apiKey, baseUrl, providerRef]); - - /** - * Regenerates a specific chapter of the audiobook - */ - const regenerateChapter = useCallback(async ( - chapterIndex: number, - bookId: string, - format: TTSAudiobookFormat, - signal: AbortSignal, - settings?: AudiobookGenerationSettings - ): Promise => { - try { - return await regenerateAudiobookChapter({ - adapter: audiobookAdapter, - chapterIndex, - bookId, - format, - signal, - apiKey, - baseUrl, - defaultProvider: providerRef, - settings, - }); - } 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; - } - }, [audiobookAdapter, apiKey, baseUrl, providerRef]); + const { createFullAudioBook, regenerateChapter } = useEPUBAudiobook({ + bookRef, + tocRef, + apiKey, + baseUrl, + providerRef, + }); const setRendition = useCallback((rendition: Rendition) => { bookRef.current = rendition.book; renditionRef.current = rendition; }, []); - const safeRenditionNavigate = useCallback((navigation: 'next' | 'prev' | 'display', location?: string) => { - const book = bookRef.current; - const rendition = renditionRef.current; - if (!book?.isOpen || !rendition) return false; - - const guardNavigationPromise = (promiseLike: unknown): void => { - const promise = Promise.resolve(promiseLike); - void promise.catch((error) => { - console.warn(`EPUB rendition ${navigation} failed:`, error); - }); - }; - - try { - if (navigation === 'display') { - if (!location) return false; - guardNavigationPromise(rendition.display(location)); - return true; - } - if (navigation === 'next') { - guardNavigationPromise(rendition.next()); - return true; - } - guardNavigationPromise(rendition.prev()); - return true; - } catch (error) { - console.warn(`EPUB rendition ${navigation} failed:`, error); - return false; - } - }, []); - - const handleLocationChanged = useCallback((location: string | number) => { - // Handle directional navigation before first-location initialization so - // "prev"/"next" are not treated as raw CFI strings. - if ((location === 'next' || location === 'prev') && renditionRef.current) { - if (!isEPUBSetOnce.current) { - setIsEPUB(true); - isEPUBSetOnce.current = true; - } - shouldPauseRef.current = false; - safeRenditionNavigate(location === 'next' ? 'next' : 'prev'); - return; - } - - // Set the EPUB flag once the location changes - if (!isEPUBSetOnce.current) { - setIsEPUB(true); - isEPUBSetOnce.current = true; - - safeRenditionNavigate('display', location.toString()); - return; - } - - if (!bookRef.current?.isOpen || !renditionRef.current) return; - - // If the location is a CFI string that doesn't match the current rendered position, - // navigate there and let the subsequent locationChanged callback handle text extraction. - if (typeof location === 'string' && location !== 'next' && location !== 'prev' && renditionRef.current?.location) { - const currentStartCfi = renditionRef.current.location?.start?.cfi; - if (currentStartCfi && location !== currentStartCfi) { - // Programmatic cross-location jumps (segments sidebar / TTS navigation) - // should keep autoplay intent after the rendition finishes navigating. - shouldPauseRef.current = false; - safeRenditionNavigate('display', location); - return; - } - } - - // Handle special 'next' and 'prev' cases - if (location === 'next' && renditionRef.current) { - shouldPauseRef.current = false; - safeRenditionNavigate('next'); - return; - } - if (location === 'prev' && renditionRef.current) { - shouldPauseRef.current = false; - safeRenditionNavigate('prev'); - return; - } - - // Save the location to IndexedDB if not initial - if (id && locationRef.current !== 1) { - setLastDocumentLocation(id as string, location.toString()); - if (authEnabled) { - scheduleDocumentProgressSync({ - documentId: id as string, - readerType: 'epub', - location: 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, authEnabled, safeRenditionNavigate]); - - 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 highlightSegment = useCallback((segment: CanonicalTtsSegment | null | undefined) => { - if (!renditionRef.current) return; - - clearHighlights(); - - if (!epubHighlightEnabled || !segment) return; - - const resolved = resolveVisibleSegmentRange(renderedTextMapsRef.current, segment); - if (!resolved) return; - - try { - const cfi = resolved.map.content.cfiFromRange(resolved.range); - currentHighlightCfi.current = cfi; - renditionRef.current.annotations.add( - 'highlight', - cfi, - {}, - () => { }, - '', - { fill: 'grey', 'fill-opacity': '0.4', 'mix-blend-mode': 'multiply' }, - ); - } catch (error) { - console.error('Error highlighting EPUB segment:', 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, - segment: CanonicalTtsSegment | 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 (!segment || segment.startAnchor.sourceKey !== segment.ownerSourceKey) return; - - const resolved = resolveVisibleSegmentRange(renderedTextMapsRef.current, segment); - if (!resolved || segment.startAnchor.sourceKey !== resolved.map.sourceKey) return; - - const cacheKey = buildWordHighlightCacheKey(segment, alignment); - if (wordHighlightMapCacheRef.current?.key !== cacheKey) { - const tokens = tokenizeCanonicalSegment(segment); - wordHighlightMapCacheRef.current = { - key: cacheKey, - tokens, - wordToToken: buildMonotonicWordToTokenMap(words, tokens), - }; - } - - const cached = wordHighlightMapCacheRef.current; - const tokenIndex = cached.wordToToken[wordIndex] ?? -1; - if (tokenIndex < 0) return; - - const token = cached.tokens[tokenIndex]; - if (!token) return; - if (token.sourceStart < resolved.startOffset || token.sourceEnd > resolved.endOffset) return; - - const wordRange = createRangeFromMappedOffsets(resolved.map, token.sourceStart, token.sourceEnd); - if (!wordRange) return; - - try { - const wordCfi = resolved.map.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); - } - }, [epubHighlightEnabled, clearWordHighlights]); + const handleLocationChanged = useEPUBLocationController({ + documentId, + authEnabled, + isEpubSetOnceRef: isEPUBSetOnce, + shouldPauseRef, + setIsEpub: setIsEPUB, + skipToLocation, + extractPageText, + bookRef, + renditionRef, + locationRef, + }); diff --git a/src/components/views/EPUBViewer.tsx b/src/components/views/EPUBViewer.tsx index 37c57c3..7d5dd88 100644 --- a/src/components/views/EPUBViewer.tsx +++ b/src/components/views/EPUBViewer.tsx @@ -8,7 +8,7 @@ import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton'; import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme'; import { useEPUBResize } from '@/hooks/epub/useEPUBResize'; import { DotsVerticalIcon, ChevronLeftIcon, ChevronRightIcon } from '@/components/icons/Icons'; -import type { EPUBState } from '@/app/(app)/epub/[id]/useEpubDocument'; +import type { EpubDocumentState } from '@/app/(app)/epub/[id]/useEpubDocument'; const ReactReader = dynamic(() => import('react-reader').then(mod => mod.ReactReader), { ssr: false, @@ -18,7 +18,7 @@ const ReactReader = dynamic(() => import('react-reader').then(mod => mod.ReactRe interface EPUBViewerProps { className?: string; epubState: Pick< - EPUBState, + EpubDocumentState, | 'currDocData' | 'currDocName' | 'currDocPage' diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 6c957d9..6d44824 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -75,7 +75,7 @@ import { isStableEpubLocator } from '@/types/client'; /** * Resolves an EPUB segment's draft locator (typically `{ readerType: 'epub', * location: }`) into a stable book coordinate. The resolver lives in - * EPUBContext where the live `Book` instance is available; TTSContext calls it + * the route-local EPUB reader hook where the live `Book` instance is available; TTSContext calls it * just before posting segments to the server so what gets persisted is * viewport-independent. Returns null when the CFI can't be resolved. */ @@ -425,7 +425,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement /** * Resolves a CFI + segment text into stable EPUB coordinates. Registered by - * EPUBContext (which owns the live `Book` instance). Used at server-persist + * the route-local EPUB reader hook (which owns the live `Book` instance). Used at server-persist * time so the saved locator carries `spineHref`/`spineIndex`/`charOffset` * rather than the viewport-dependent CFI string. Returns null when the CFI * can't be resolved — callers should drop the segment from the persist @@ -464,7 +464,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } if (!resolver) { // No book available to resolve — drop. This can happen during early - // boot before EPUBContext has mounted. + // boot before the EPUB reader hook has mounted. continue; } const keyPrefix = buildSegmentKeyPrefix(documentId, 'epub'); diff --git a/src/hooks/epub/useEPUBAudiobook.ts b/src/hooks/epub/useEPUBAudiobook.ts new file mode 100644 index 0000000..ded81c1 --- /dev/null +++ b/src/hooks/epub/useEPUBAudiobook.ts @@ -0,0 +1,182 @@ +'use client'; + +import { useCallback, useMemo, type RefObject } from 'react'; +import type { Book, NavItem } from 'epubjs'; +import type { SpineItem } from 'epubjs/types/section'; + +import { createEpubAudiobookSourceAdapter } from '@/lib/client/audiobooks/adapters/epub'; +import { regenerateAudiobookChapter, runAudiobookGeneration } from '@/lib/client/audiobooks/pipeline'; +import type { AudiobookGenerationSettings } from '@/types/client'; +import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; + +type LoadedSection = { + document?: Document; +}; + +export function resolveLoadedSpineSectionDocument( + loaded: unknown, + section: LoadedSection, +): Document | null { + if (!loaded) return null; + if (typeof Document !== 'undefined' && loaded instanceof Document) { + return loaded; + } + const element = loaded as Element; + if (element?.ownerDocument) { + return element.ownerDocument; + } + return section.document ?? null; +} + +export function filterNonEmptySpineTextEntries(entries: T[]): T[] { + return entries.filter((entry) => entry.text.trim() !== ''); +} + +type UseEpubAudiobookParams = { + bookRef: RefObject; + tocRef: RefObject; + apiKey: string; + baseUrl: string; + providerRef: string; +}; + +type UseEpubAudiobookResult = { + createFullAudioBook: ( + onProgress: (progress: number) => void, + signal?: AbortSignal, + onChapterComplete?: (chapter: TTSAudiobookChapter) => void, + providedBookId?: string, + format?: TTSAudiobookFormat, + settings?: AudiobookGenerationSettings + ) => Promise; + regenerateChapter: ( + chapterIndex: number, + bookId: string, + format: TTSAudiobookFormat, + signal: AbortSignal, + settings?: AudiobookGenerationSettings + ) => Promise; +}; + +export function useEPUBAudiobook({ + bookRef, + tocRef, + apiKey, + baseUrl, + providerRef, +}: UseEpubAudiobookParams): UseEpubAudiobookResult { + const loadSpineSection = useCallback(async (href: string) => { + const book = bookRef.current; + if (!book?.isOpen) return null; + const section = book.spine.get(href); + if (!section) return null; + + const loaded = await Promise.resolve(section.load(book.load.bind(book))); + const doc = resolveLoadedSpineSectionDocument(loaded, section as LoadedSection); + + if (!doc) return null; + return { section, doc }; + }, [bookRef]); + + 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; + const promise = loadSpineSection(url) + .then((loaded) => { + if (!loaded?.doc) return { text: '', href: url }; + const text = loaded.doc.body?.textContent || ''; + return { text, href: url }; + }) + .catch((err) => { + console.error(`Error loading section ${url}:`, err); + return { text: '', href: url }; + }) + .finally(() => { + const section = book.spine.get(url); + section?.unload?.(); + }); + + promises.push(promise); + }); + + const textArray = await Promise.all(promises); + return filterNonEmptySpineTextEntries(textArray); + } catch (error) { + console.error('Error extracting EPUB text:', error); + return [{ text: '', href: '' }]; + } + }, [bookRef, loadSpineSection]); + + const audiobookAdapter = useMemo(() => createEpubAudiobookSourceAdapter({ + extractBookText, + getTocItems: () => tocRef.current || [], + }), [extractBookText, tocRef]); + + const createFullAudioBook = useCallback(async ( + onProgress: (progress: number) => void, + signal?: AbortSignal, + onChapterComplete?: (chapter: TTSAudiobookChapter) => void, + providedBookId?: string, + format: TTSAudiobookFormat = 'mp3', + settings?: AudiobookGenerationSettings + ): Promise => { + try { + return await runAudiobookGeneration({ + adapter: audiobookAdapter, + apiKey, + baseUrl, + defaultProvider: providerRef, + onProgress, + signal, + onChapterComplete, + providedBookId, + format, + settings, + }); + } catch (error) { + console.error('Error creating audiobook:', error); + throw error; + } + }, [audiobookAdapter, apiKey, baseUrl, providerRef]); + + const regenerateChapter = useCallback(async ( + chapterIndex: number, + bookId: string, + format: TTSAudiobookFormat, + signal: AbortSignal, + settings?: AudiobookGenerationSettings + ): Promise => { + try { + return await regenerateAudiobookChapter({ + adapter: audiobookAdapter, + chapterIndex, + bookId, + format, + signal, + apiKey, + baseUrl, + defaultProvider: providerRef, + settings, + }); + } 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; + } + }, [audiobookAdapter, apiKey, baseUrl, providerRef]); + + return { + createFullAudioBook, + regenerateChapter, + }; +} diff --git a/src/hooks/epub/useEPUBHighlighting.ts b/src/hooks/epub/useEPUBHighlighting.ts new file mode 100644 index 0000000..ee396a6 --- /dev/null +++ b/src/hooks/epub/useEPUBHighlighting.ts @@ -0,0 +1,193 @@ +'use client'; + +import { useCallback, useEffect, type MutableRefObject, type RefObject } from 'react'; +import type { Rendition } from 'epubjs'; + +import { + buildMonotonicWordToTokenMap, + buildWordHighlightCacheKey, + tokenizeCanonicalSegment, + type EpubCanonicalWordToken, +} from '@/lib/client/epub/epub-word-highlight'; +import { + createRangeFromMappedOffsets, + resolveVisibleSegmentRange, + type EpubRenderedTextMap, +} from '@/lib/client/epub/epub-rendered-text-maps'; +import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan'; +import type { TTSSentenceAlignment } from '@/types/tts'; + +export type EpubWordHighlightMapCache = { + key: string; + wordToToken: number[]; + tokens: EpubCanonicalWordToken[]; +}; + +type UseEpubHighlightingParams = { + renditionRef: RefObject; + epubHighlightEnabled: boolean; + currentHighlightCfiRef: MutableRefObject; + currentWordHighlightCfiRef: MutableRefObject; + renderedTextMapsRef: MutableRefObject; + wordHighlightMapCacheRef: MutableRefObject; +}; + +type UseEpubHighlightingResult = { + clearHighlights: () => void; + highlightSegment: (segment: CanonicalTtsSegment | null | undefined) => void; + clearWordHighlights: () => void; + highlightWordIndex: ( + alignment: TTSSentenceAlignment | undefined, + wordIndex: number | null | undefined, + segment: CanonicalTtsSegment | null | undefined + ) => void; + setRenderedTextMaps: (maps: EpubRenderedTextMap[]) => void; + resetHighlightState: () => void; +}; + +export function useEPUBHighlighting({ + renditionRef, + epubHighlightEnabled, + currentHighlightCfiRef, + currentWordHighlightCfiRef, + renderedTextMapsRef, + wordHighlightMapCacheRef, +}: UseEpubHighlightingParams): UseEpubHighlightingResult { + const clearWordHighlights = useCallback(() => { + if (!renditionRef.current) return; + if (currentWordHighlightCfiRef.current) { + renditionRef.current.annotations.remove(currentWordHighlightCfiRef.current, 'highlight'); + currentWordHighlightCfiRef.current = null; + } + }, [currentWordHighlightCfiRef, renditionRef]); + + const clearHighlights = useCallback(() => { + if (renditionRef.current && currentHighlightCfiRef.current) { + renditionRef.current.annotations.remove(currentHighlightCfiRef.current, 'highlight'); + currentHighlightCfiRef.current = null; + } + clearWordHighlights(); + }, [clearWordHighlights, currentHighlightCfiRef, renditionRef]); + + const highlightSegment = useCallback((segment: CanonicalTtsSegment | null | undefined) => { + if (!renditionRef.current) return; + + clearHighlights(); + + if (!epubHighlightEnabled || !segment) return; + + const resolved = resolveVisibleSegmentRange(renderedTextMapsRef.current, segment); + if (!resolved) return; + + try { + const cfi = resolved.map.content.cfiFromRange(resolved.range); + currentHighlightCfiRef.current = cfi; + renditionRef.current.annotations.add( + 'highlight', + cfi, + {}, + () => { }, + '', + { fill: 'grey', 'fill-opacity': '0.4', 'mix-blend-mode': 'multiply' }, + ); + } catch (error) { + console.error('Error highlighting EPUB segment:', error); + } + }, [clearHighlights, currentHighlightCfiRef, epubHighlightEnabled, renderedTextMapsRef, renditionRef]); + + const highlightWordIndex = useCallback(( + alignment: TTSSentenceAlignment | undefined, + wordIndex: number | null | undefined, + segment: CanonicalTtsSegment | 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 (!segment || segment.startAnchor.sourceKey !== segment.ownerSourceKey) return; + + const resolved = resolveVisibleSegmentRange(renderedTextMapsRef.current, segment); + if (!resolved || segment.startAnchor.sourceKey !== resolved.map.sourceKey) return; + + const cacheKey = buildWordHighlightCacheKey(segment, alignment); + if (wordHighlightMapCacheRef.current?.key !== cacheKey) { + const tokens = tokenizeCanonicalSegment(segment); + wordHighlightMapCacheRef.current = { + key: cacheKey, + tokens, + wordToToken: buildMonotonicWordToTokenMap(words, tokens), + }; + } + + const cached = wordHighlightMapCacheRef.current; + const tokenIndex = cached.wordToToken[wordIndex] ?? -1; + if (tokenIndex < 0) return; + + const token = cached.tokens[tokenIndex]; + if (!token) return; + if (token.sourceStart < resolved.startOffset || token.sourceEnd > resolved.endOffset) return; + + const wordRange = createRangeFromMappedOffsets(resolved.map, token.sourceStart, token.sourceEnd); + if (!wordRange) return; + + try { + const wordCfi = resolved.map.content.cfiFromRange(wordRange); + currentWordHighlightCfiRef.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); + } + }, [ + clearWordHighlights, + currentWordHighlightCfiRef, + epubHighlightEnabled, + renderedTextMapsRef, + renditionRef, + wordHighlightMapCacheRef, + ]); + + const setRenderedTextMaps = useCallback((maps: EpubRenderedTextMap[]) => { + renderedTextMapsRef.current = maps; + wordHighlightMapCacheRef.current = null; + }, []); + + const resetHighlightState = useCallback(() => { + renderedTextMapsRef.current = []; + wordHighlightMapCacheRef.current = null; + clearHighlights(); + }, [clearHighlights, renderedTextMapsRef, wordHighlightMapCacheRef]); + + // Clear any highlight annotations when feature is disabled. + useEffect(() => { + if (!epubHighlightEnabled) { + clearHighlights(); + } + }, [epubHighlightEnabled, clearHighlights]); + + return { + clearHighlights, + highlightSegment, + clearWordHighlights, + highlightWordIndex, + setRenderedTextMaps, + resetHighlightState, + }; +} diff --git a/src/hooks/epub/useEPUBLocationController.ts b/src/hooks/epub/useEPUBLocationController.ts new file mode 100644 index 0000000..309e73c --- /dev/null +++ b/src/hooks/epub/useEPUBLocationController.ts @@ -0,0 +1,170 @@ +'use client'; + +import { useCallback, type MutableRefObject, type RefObject } from 'react'; +import type { Book, Rendition } from 'epubjs'; + +import { setLastDocumentLocation } from '@/lib/client/dexie'; +import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state'; + +type EpubLocation = string | number; + +export function isDirectionalEpubLocation(location: EpubLocation): location is 'next' | 'prev' { + return location === 'next' || location === 'prev'; +} + +export function shouldNavigateToDifferentCfi( + location: EpubLocation, + currentStartCfi: string | undefined, +): location is string { + return ( + typeof location === 'string' + && !isDirectionalEpubLocation(location) + && !!currentStartCfi + && location !== currentStartCfi + ); +} + +export function shouldPersistEpubLocation( + documentId: string | undefined, + previousLocation: EpubLocation, +): documentId is string { + return typeof documentId === 'string' && documentId.length > 0 && previousLocation !== 1; +} + +type UseEpubLocationControllerParams = { + documentId?: string; + authEnabled: boolean; + isEpubSetOnceRef: MutableRefObject; + shouldPauseRef: MutableRefObject; + setIsEpub: (isEpub: boolean) => void; + skipToLocation: (location: EpubLocation) => void; + extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise; + bookRef: RefObject; + renditionRef: RefObject; + locationRef: RefObject; +}; + +export function useEPUBLocationController({ + documentId, + authEnabled, + isEpubSetOnceRef, + shouldPauseRef, + setIsEpub, + skipToLocation, + extractPageText, + bookRef, + renditionRef, + locationRef, +}: UseEpubLocationControllerParams): (location: EpubLocation) => void { + const safeRenditionNavigate = useCallback((navigation: 'next' | 'prev' | 'display', location?: string) => { + const book = bookRef.current; + const rendition = renditionRef.current; + if (!book?.isOpen || !rendition) return false; + + const guardNavigationPromise = (promiseLike: unknown): void => { + const promise = Promise.resolve(promiseLike); + void promise.catch((error) => { + console.warn(`EPUB rendition ${navigation} failed:`, error); + }); + }; + + try { + if (navigation === 'display') { + if (!location) return false; + guardNavigationPromise(rendition.display(location)); + return true; + } + if (navigation === 'next') { + guardNavigationPromise(rendition.next()); + return true; + } + guardNavigationPromise(rendition.prev()); + return true; + } catch (error) { + console.warn(`EPUB rendition ${navigation} failed:`, error); + return false; + } + }, [bookRef, renditionRef]); + + const handleLocationChanged = useCallback((location: EpubLocation) => { + // Handle directional navigation before first-location initialization so + // "prev"/"next" are not treated as raw CFI strings. + if (isDirectionalEpubLocation(location) && renditionRef.current) { + if (!isEpubSetOnceRef.current) { + setIsEpub(true); + isEpubSetOnceRef.current = true; + } + shouldPauseRef.current = false; + safeRenditionNavigate(location === 'next' ? 'next' : 'prev'); + return; + } + + // Set the EPUB flag once the location changes + if (!isEpubSetOnceRef.current) { + setIsEpub(true); + isEpubSetOnceRef.current = true; + + safeRenditionNavigate('display', location.toString()); + return; + } + + if (!bookRef.current?.isOpen || !renditionRef.current) return; + + // If the location is a CFI string that doesn't match the current rendered position, + // navigate there and let the subsequent locationChanged callback handle text extraction. + if (renditionRef.current?.location) { + const currentStartCfi = renditionRef.current.location?.start?.cfi; + if (shouldNavigateToDifferentCfi(location, currentStartCfi)) { + // Programmatic cross-location jumps (segments sidebar / TTS navigation) + // should keep autoplay intent after the rendition finishes navigating. + shouldPauseRef.current = false; + safeRenditionNavigate('display', location); + return; + } + } + + // Handle special 'next' and 'prev' cases + if (location === 'next' && renditionRef.current) { + shouldPauseRef.current = false; + safeRenditionNavigate('next'); + return; + } + if (location === 'prev' && renditionRef.current) { + shouldPauseRef.current = false; + safeRenditionNavigate('prev'); + return; + } + + // Save the location to IndexedDB if not initial + if (shouldPersistEpubLocation(documentId, locationRef.current)) { + setLastDocumentLocation(documentId, location.toString()); + if (authEnabled) { + scheduleDocumentProgressSync({ + documentId, + readerType: 'epub', + location: location.toString(), + }); + } + } + + skipToLocation(location); + + locationRef.current = location; + if (bookRef.current && renditionRef.current) { + extractPageText(bookRef.current, renditionRef.current, shouldPauseRef.current); + shouldPauseRef.current = true; + } + }, [ + authEnabled, + bookRef, + documentId, + extractPageText, + locationRef, + renditionRef, + safeRenditionNavigate, + setIsEpub, + skipToLocation, + ]); + + return handleLocationChanged; +} diff --git a/src/lib/client/epub/epub-range-context.ts b/src/lib/client/epub/epub-range-context.ts new file mode 100644 index 0000000..6d9dd4c --- /dev/null +++ b/src/lib/client/epub/epub-range-context.ts @@ -0,0 +1,166 @@ +'use client'; + +const EPUB_CONTINUATION_CHARS = 5000; + +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 stepToPreviousNode = (node: Node | null, root: Node): Node | null => { + if (!node) return null; + + if (node.previousSibling) { + let prev: Node | null = node.previousSibling; + while (prev?.lastChild) prev = prev.lastChild; + return prev; + } + + let current: Node | null = node.parentNode; + while (current) { + if (current === root) return null; + if (current.previousSibling) { + let prev: Node | null = current.previousSibling; + while (prev?.lastChild) prev = prev.lastChild; + return prev; + } + 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 getPreviousTextNode = (node: Node | null, root: Node): Text | null => { + let prev = stepToPreviousNode(node, root); + while (prev) { + if (prev.nodeType === Node.TEXT_NODE) return prev as Text; + prev = stepToPreviousNode(prev, root); + } + return null; +}; + +const getLastTextNodeInSubtree = (node: Node | null): Text | null => { + if (!node) return null; + if (node.nodeType === Node.TEXT_NODE) return node as Text; + + let child: Node | null = node.lastChild; + while (child) { + const nested = getLastTextNodeInSubtree(child); + if (nested) return nested; + child = child.previousSibling; + } + return null; +}; + +export 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(); +}; + +export const collectLeadingContextFromRange = ( + 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 prependFromTextNode = (textNode: Text, endOffset: number) => { + if (remaining <= 0) return; + const textContent = textNode.textContent || ''; + const safeEnd = Math.max(0, Math.min(endOffset, textContent.length)); + if (safeEnd <= 0) return; + const safeStart = Math.max(0, safeEnd - remaining); + const slice = textContent.slice(safeStart, safeEnd); + if (slice) { + parts.unshift(slice); + remaining -= slice.length; + } + }; + + let cursor: Node | null = null; + if (range.startContainer.nodeType === Node.TEXT_NODE) { + const startText = range.startContainer as Text; + prependFromTextNode(startText, range.startOffset); + cursor = startText; + } else { + const startNode = range.startContainer; + let anchor: Node | null = null; + if (range.startOffset > 0) { + anchor = startNode.childNodes[range.startOffset - 1] ?? null; + } + if (!anchor) { + anchor = stepToPreviousNode(startNode, root); + } + + const anchorText = getLastTextNodeInSubtree(anchor); + if (anchorText) { + prependFromTextNode(anchorText, (anchorText.textContent || '').length); + cursor = anchorText; + } else { + cursor = anchor; + } + } + + let prevNode = getPreviousTextNode(cursor, root); + while (prevNode && remaining > 0) { + prependFromTextNode(prevNode, (prevNode.textContent || '').length); + prevNode = getPreviousTextNode(prevNode, root); + } + + return parts.join(' ').replace(/\s+/g, ' ').trim(); +}; diff --git a/src/lib/client/epub/epub-rendered-text-maps.ts b/src/lib/client/epub/epub-rendered-text-maps.ts new file mode 100644 index 0000000..2c691cc --- /dev/null +++ b/src/lib/client/epub/epub-rendered-text-maps.ts @@ -0,0 +1,237 @@ +'use client'; + +import type { Rendition } from 'epubjs'; +import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan'; + +type EpubMappedPosition = { + node: Text; + offset: number; +}; + +type EpubMappedChar = { + char: string; + position: EpubMappedPosition; +}; + +export type EpubRenderedTextMap = { + sourceKey: string; + chars: EpubMappedPosition[]; + content: { + cfiFromRange: (range: Range) => string; + }; +}; + +const cloneMappedChar = (char: string, source: EpubMappedChar): EpubMappedChar => ({ + char, + position: source.position, +}); + +const replaceMappedUrls = (tokens: EpubMappedChar[]): EpubMappedChar[] => { + const text = tokens.map((token) => token.char).join(''); + const urlPattern = /\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi; + const replaced: EpubMappedChar[] = []; + let cursor = 0; + let match: RegExpExecArray | null; + + while ((match = urlPattern.exec(text)) !== null) { + const start = match.index; + const end = start + match[0].length; + replaced.push(...tokens.slice(cursor, start)); + + const anchor = tokens[start] ?? tokens[Math.max(0, end - 1)]; + if (anchor) { + const replacement = `- (link to ${match[1]}) -`; + for (const char of replacement) { + replaced.push(cloneMappedChar(char, anchor)); + } + } + cursor = end; + } + + replaced.push(...tokens.slice(cursor)); + return replaced; +}; + +const removeMappedHyphenation = (tokens: EpubMappedChar[]): EpubMappedChar[] => { + const text = tokens.map((token) => token.char).join(''); + const hyphenPattern = /(\w+)-\s+(\w+)/g; + const replaced: EpubMappedChar[] = []; + let cursor = 0; + let match: RegExpExecArray | null; + + while ((match = hyphenPattern.exec(text)) !== null) { + const start = match.index; + const full = match[0]; + const first = match[1]; + const second = match[2]; + const secondOffset = full.lastIndexOf(second); + + replaced.push(...tokens.slice(cursor, start)); + replaced.push(...tokens.slice(start, start + first.length)); + replaced.push(...tokens.slice(start + secondOffset, start + secondOffset + second.length)); + cursor = start + full.length; + } + + replaced.push(...tokens.slice(cursor)); + return replaced; +}; + +const normalizeMappedTokensForTts = (tokens: EpubMappedChar[]): EpubMappedChar[] => { + const withoutLinks = replaceMappedUrls(tokens); + const withoutHyphenation = removeMappedHyphenation(withoutLinks); + const normalized: EpubMappedChar[] = []; + let pendingWhitespace: EpubMappedChar | null = null; + + const flushWhitespace = () => { + if (!pendingWhitespace || normalized.length === 0 || normalized[normalized.length - 1].char === ' ') { + pendingWhitespace = null; + return; + } + normalized.push(cloneMappedChar(' ', pendingWhitespace)); + pendingWhitespace = null; + }; + + for (const token of withoutHyphenation) { + if (token.char === '*') continue; + if (/\s/.test(token.char)) { + pendingWhitespace ??= token; + continue; + } + + flushWhitespace(); + normalized.push(token); + } + + if (normalized[normalized.length - 1]?.char === ' ') { + normalized.pop(); + } + + return normalized; +}; + +const collectMappedTextFromRange = (range: Range): EpubMappedChar[] => { + const root = range.commonAncestorContainer; + const doc = range.startContainer.ownerDocument ?? (range.startContainer as Document); + const mapped: EpubMappedChar[] = []; + + const addTextSlice = (textNode: Text, start: number, end: number) => { + const text = textNode.textContent || ''; + const safeStart = Math.max(0, Math.min(start, text.length)); + const safeEnd = Math.max(safeStart, Math.min(end, text.length)); + for (let offset = safeStart; offset < safeEnd; offset += 1) { + mapped.push({ + char: text[offset], + position: { node: textNode, offset }, + }); + } + }; + + if (root.nodeType === Node.TEXT_NODE) { + addTextSlice(root as Text, range.startOffset, range.endOffset); + return mapped; + } + + const nodeFilter = doc.defaultView?.NodeFilter ?? NodeFilter; + const walker = doc.createTreeWalker( + root, + nodeFilter.SHOW_TEXT, + { + acceptNode: (node) => { + try { + return range.intersectsNode(node) + ? nodeFilter.FILTER_ACCEPT + : nodeFilter.FILTER_REJECT; + } catch { + return nodeFilter.FILTER_REJECT; + } + }, + }, + ); + + let textNode = walker.nextNode() as Text | null; + while (textNode) { + const text = textNode.textContent || ''; + let start = 0; + let end = text.length; + + if (textNode === range.startContainer) start = range.startOffset; + if (textNode === range.endContainer) end = range.endOffset; + + addTextSlice(textNode, start, end); + textNode = walker.nextNode() as Text | null; + } + + return mapped; +}; + +export const buildRenderedTextMaps = ( + rendition: Rendition, + rangeCfi: string, + sourceKey: string, +): EpubRenderedTextMap[] => { + const contents = rendition.getContents(); + const contentsArray = Array.isArray(contents) ? contents : [contents]; + const maps: EpubRenderedTextMap[] = []; + + for (const content of contentsArray) { + try { + const range = content.range(rangeCfi); + if (!range) continue; + + const normalized = normalizeMappedTokensForTts(collectMappedTextFromRange(range)); + if (!normalized.length) continue; + + maps.push({ + sourceKey, + chars: normalized.map((token) => token.position), + content, + }); + } catch { + // Not every displayed iframe can resolve every CFI in spread mode. + } + } + + return maps; +}; + +export const createRangeFromMappedOffsets = ( + map: EpubRenderedTextMap, + startOffset: number, + endOffset: number, +): Range | null => { + const start = Math.max(0, Math.min(startOffset, map.chars.length)); + const end = Math.max(start, Math.min(endOffset, map.chars.length)); + if (end <= start) return null; + + const startPosition = map.chars[start]; + const endPosition = map.chars[end - 1]; + if (!startPosition || !endPosition) return null; + + const doc = startPosition.node.ownerDocument; + const range = doc.createRange(); + range.setStart(startPosition.node, startPosition.offset); + range.setEnd(endPosition.node, endPosition.offset + 1); + return range; +}; + +export const resolveVisibleSegmentRange = ( + maps: EpubRenderedTextMap[], + segment: CanonicalTtsSegment | null | undefined, +): { map: EpubRenderedTextMap; range: Range; startOffset: number; endOffset: number } | null => { + if (!segment) return null; + + for (const map of maps) { + const startsInMap = segment.startAnchor.sourceKey === map.sourceKey; + const endsInMap = segment.endAnchor.sourceKey === map.sourceKey; + if (!startsInMap && !endsInMap) continue; + + const startOffset = startsInMap ? segment.startAnchor.offset : 0; + const endOffset = endsInMap ? segment.endAnchor.offset : map.chars.length; + const range = createRangeFromMappedOffsets(map, startOffset, endOffset); + if (range) { + return { map, range, startOffset, endOffset }; + } + } + + return null; +}; diff --git a/tests/unit/epub-audiobook-hook.spec.ts b/tests/unit/epub-audiobook-hook.spec.ts new file mode 100644 index 0000000..8efd21b --- /dev/null +++ b/tests/unit/epub-audiobook-hook.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; + +import { + filterNonEmptySpineTextEntries, + resolveLoadedSpineSectionDocument, +} from '../../src/hooks/epub/useEPUBAudiobook'; + +test.describe('EPUB audiobook hook helpers', () => { + test('resolveLoadedSpineSectionDocument prefers ownerDocument when available', () => { + const ownerDocument = { body: { textContent: 'from ownerDocument' } } as unknown as Document; + const loaded = { ownerDocument } as unknown; + const section = { document: { body: { textContent: 'from section' } } as unknown as Document }; + expect(resolveLoadedSpineSectionDocument(loaded, section)).toBe(ownerDocument); + }); + + test('resolveLoadedSpineSectionDocument falls back to section.document', () => { + const sectionDocument = { body: { textContent: 'from section' } } as unknown as Document; + const section = { document: sectionDocument }; + expect(resolveLoadedSpineSectionDocument({ foo: 'bar' }, section)).toBe(sectionDocument); + }); + + test('resolveLoadedSpineSectionDocument returns null with no loaded value and no section doc', () => { + expect(resolveLoadedSpineSectionDocument(null, {})).toBeNull(); + expect(resolveLoadedSpineSectionDocument(undefined, {})).toBeNull(); + }); + + test('filterNonEmptySpineTextEntries removes blank and whitespace-only entries', () => { + const entries = [ + { text: 'Chapter one', href: 'c1.xhtml' }, + { text: ' ', href: 'blank.xhtml' }, + { text: '\n\t', href: 'ws.xhtml' }, + { text: 'Chapter two', href: 'c2.xhtml' }, + ]; + + expect(filterNonEmptySpineTextEntries(entries)).toEqual([ + { text: 'Chapter one', href: 'c1.xhtml' }, + { text: 'Chapter two', href: 'c2.xhtml' }, + ]); + }); +}); diff --git a/tests/unit/epub-location-controller.spec.ts b/tests/unit/epub-location-controller.spec.ts new file mode 100644 index 0000000..ca165a7 --- /dev/null +++ b/tests/unit/epub-location-controller.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from '@playwright/test'; + +import { + isDirectionalEpubLocation, + shouldNavigateToDifferentCfi, + shouldPersistEpubLocation, +} from '../../src/hooks/epub/useEPUBLocationController'; + +test.describe('EPUB location controller helpers', () => { + test('detects directional locations', () => { + expect(isDirectionalEpubLocation('next')).toBe(true); + expect(isDirectionalEpubLocation('prev')).toBe(true); + expect(isDirectionalEpubLocation('epubcfi(/6/2!/4:0)')).toBe(false); + expect(isDirectionalEpubLocation(4)).toBe(false); + }); + + test('navigates only when target CFI differs from rendered CFI', () => { + expect(shouldNavigateToDifferentCfi('epubcfi(/6/4!/4:0)', 'epubcfi(/6/2!/4:0)')).toBe(true); + expect(shouldNavigateToDifferentCfi('epubcfi(/6/2!/4:0)', 'epubcfi(/6/2!/4:0)')).toBe(false); + expect(shouldNavigateToDifferentCfi('next', 'epubcfi(/6/2!/4:0)')).toBe(false); + expect(shouldNavigateToDifferentCfi(3, 'epubcfi(/6/2!/4:0)')).toBe(false); + expect(shouldNavigateToDifferentCfi('epubcfi(/6/4!/4:0)', undefined)).toBe(false); + }); + + test('persists progress only after first real location and with a document id', () => { + expect(shouldPersistEpubLocation('doc-1', 2)).toBe(true); + expect(shouldPersistEpubLocation('doc-1', 'epubcfi(/6/2!/4:0)')).toBe(true); + expect(shouldPersistEpubLocation('doc-1', 1)).toBe(false); + expect(shouldPersistEpubLocation(undefined, 2)).toBe(false); + }); +});