refactor epub reader hooks and add unit coverage
This commit is contained in:
parent
fd6a06e467
commit
fdeeb60f7e
11 changed files with 1093 additions and 811 deletions
|
|
@ -19,13 +19,14 @@ import { resolveDocumentId } from '@/lib/client/dexie';
|
||||||
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
||||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||||
import { useEPUB } from './useEpubDocument';
|
import { useEpubDocument } from './useEpubDocument';
|
||||||
|
|
||||||
export default function EPUBPage() {
|
export default function EPUBPage() {
|
||||||
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
|
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const epubState = useEPUB();
|
const routeDocumentId = typeof id === 'string' ? id : undefined;
|
||||||
|
const epubState = useEpubDocument(routeDocumentId);
|
||||||
const {
|
const {
|
||||||
setCurrentDocument,
|
setCurrentDocument,
|
||||||
currDocName,
|
currDocName,
|
||||||
|
|
@ -52,19 +53,19 @@ export default function EPUBPage() {
|
||||||
setActiveSidebar(null);
|
setActiveSidebar(null);
|
||||||
inFlightDocIdRef.current = null;
|
inFlightDocIdRef.current = null;
|
||||||
loadedDocIdRef.current = null;
|
loadedDocIdRef.current = null;
|
||||||
}, [id]);
|
}, [routeDocumentId]);
|
||||||
|
|
||||||
const loadDocument = useCallback(async () => {
|
const loadDocument = useCallback(async () => {
|
||||||
console.log('Loading new epub (from page.tsx)');
|
console.log('Loading new epub (from page.tsx)');
|
||||||
let didRedirect = false;
|
let didRedirect = false;
|
||||||
let startedLoad = false;
|
let startedLoad = false;
|
||||||
try {
|
try {
|
||||||
if (!id) {
|
if (!routeDocumentId) {
|
||||||
setError('Document not found');
|
setError('Document not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resolved = await resolveDocumentId(id as string);
|
const resolved = await resolveDocumentId(routeDocumentId);
|
||||||
if (resolved !== (id as string)) {
|
if (resolved !== routeDocumentId) {
|
||||||
didRedirect = true;
|
didRedirect = true;
|
||||||
router.replace(`/epub/${resolved}`);
|
router.replace(`/epub/${resolved}`);
|
||||||
return;
|
return;
|
||||||
|
|
@ -93,7 +94,7 @@ export default function EPUBPage() {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [id, router, setCurrentDocument, stop]);
|
}, [routeDocumentId, router, setCurrentDocument, stop]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading) return;
|
if (!isLoading) return;
|
||||||
|
|
@ -143,8 +144,8 @@ export default function EPUBPage() {
|
||||||
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
||||||
settings: AudiobookGenerationSettings
|
settings: AudiobookGenerationSettings
|
||||||
) => {
|
) => {
|
||||||
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings);
|
return createEPUBAudioBook(onProgress, signal, onChapterComplete, routeDocumentId, settings.format, settings);
|
||||||
}, [createEPUBAudioBook, id]);
|
}, [createEPUBAudioBook, routeDocumentId]);
|
||||||
|
|
||||||
const handleRegenerateChapter = useCallback(async (
|
const handleRegenerateChapter = useCallback(async (
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
|
|
@ -226,7 +227,7 @@ export default function EPUBPage() {
|
||||||
isOpen={activeSidebar === 'audiobook'}
|
isOpen={activeSidebar === 'audiobook'}
|
||||||
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'audiobook' : (prev === 'audiobook' ? null : prev))}
|
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'audiobook' : (prev === 'audiobook' ? null : prev))}
|
||||||
documentType="epub"
|
documentType="epub"
|
||||||
documentId={id as string}
|
documentId={routeDocumentId || ''}
|
||||||
onGenerateAudiobook={handleGenerateAudiobook}
|
onGenerateAudiobook={handleGenerateAudiobook}
|
||||||
onRegenerateChapter={handleRegenerateChapter}
|
onRegenerateChapter={handleRegenerateChapter}
|
||||||
/>
|
/>
|
||||||
|
|
@ -249,7 +250,7 @@ export default function EPUBPage() {
|
||||||
<SegmentsSidebar
|
<SegmentsSidebar
|
||||||
isOpen={activeSidebar === 'segments'}
|
isOpen={activeSidebar === 'segments'}
|
||||||
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'segments' : (prev === 'segments' ? null : prev))}
|
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'segments' : (prev === 'segments' ? null : prev))}
|
||||||
documentId={id as string}
|
documentId={routeDocumentId || ''}
|
||||||
epubBookRef={bookRef}
|
epubBookRef={bookRef}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,12 @@ import {
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
RefObject,
|
RefObject,
|
||||||
useEffect
|
useEffect,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import type { NavItem } from 'epubjs';
|
import type { NavItem } from 'epubjs';
|
||||||
import type { SpineItem } from 'epubjs/types/section';
|
|
||||||
import type { Book, Rendition } from 'epubjs';
|
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 { getDocumentMetadata } from '@/lib/client/api/documents';
|
||||||
import { ensureCachedDocument } from '@/lib/client/cache/documents';
|
import { ensureCachedDocument } from '@/lib/client/cache/documents';
|
||||||
import { EpubRenderedLocationCloneManager } from '@/lib/client/epub/rendered-location-walker';
|
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 { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
||||||
import { createRangeCfi } from '@/lib/client/epub';
|
import { createRangeCfi } from '@/lib/client/epub';
|
||||||
import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator';
|
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 { 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 {
|
import type {
|
||||||
EpubRenderedLocationWalker,
|
EpubRenderedLocationWalker,
|
||||||
TTSSentenceAlignment,
|
TTSSentenceAlignment,
|
||||||
|
|
@ -44,7 +46,7 @@ import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/cli
|
||||||
import { isStableEpubLocator } from '@/types/client';
|
import { isStableEpubLocator } from '@/types/client';
|
||||||
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
|
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
|
||||||
|
|
||||||
export interface EPUBState {
|
export interface EpubDocumentState {
|
||||||
currDocData: ArrayBuffer | undefined;
|
currDocData: ArrayBuffer | undefined;
|
||||||
currDocName: string | undefined;
|
currDocName: string | undefined;
|
||||||
currDocPages: number | undefined;
|
currDocPages: number | undefined;
|
||||||
|
|
@ -87,446 +89,12 @@ export interface EPUBState {
|
||||||
clearWordHighlights: () => void;
|
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.
|
* 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 { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop, skipToLocation, setIsEPUB } = useTTS();
|
||||||
const { authEnabled } = useAuthConfig();
|
const { authEnabled } = useAuthConfig();
|
||||||
const { id } = useParams();
|
|
||||||
// Configuration context to get TTS settings
|
// Configuration context to get TTS settings
|
||||||
const {
|
const {
|
||||||
apiKey,
|
apiKey,
|
||||||
|
|
@ -564,6 +132,21 @@ export function useEPUB(): EPUBState {
|
||||||
const renderedLocationCloneManagerRef = useRef<EpubRenderedLocationCloneManager>(
|
const renderedLocationCloneManagerRef = useRef<EpubRenderedLocationCloneManager>(
|
||||||
new EpubRenderedLocationCloneManager(),
|
new EpubRenderedLocationCloneManager(),
|
||||||
);
|
);
|
||||||
|
const {
|
||||||
|
clearHighlights,
|
||||||
|
highlightSegment,
|
||||||
|
clearWordHighlights,
|
||||||
|
highlightWordIndex,
|
||||||
|
setRenderedTextMaps,
|
||||||
|
resetHighlightState,
|
||||||
|
} = useEPUBHighlighting({
|
||||||
|
renditionRef,
|
||||||
|
epubHighlightEnabled,
|
||||||
|
currentHighlightCfiRef: currentHighlightCfi,
|
||||||
|
currentWordHighlightCfiRef: currentWordHighlightCfi,
|
||||||
|
renderedTextMapsRef,
|
||||||
|
wordHighlightMapCacheRef,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
void renderedLocationCloneManagerRef.current.destroy();
|
void renderedLocationCloneManagerRef.current.destroy();
|
||||||
|
|
@ -582,15 +165,15 @@ export function useEPUB(): EPUBState {
|
||||||
setCurrDocText(undefined);
|
setCurrDocText(undefined);
|
||||||
setCurrDocPages(undefined);
|
setCurrDocPages(undefined);
|
||||||
isEPUBSetOnce.current = false;
|
isEPUBSetOnce.current = false;
|
||||||
|
shouldPauseRef.current = true;
|
||||||
bookRef.current = null;
|
bookRef.current = null;
|
||||||
renditionRef.current = undefined;
|
renditionRef.current = undefined;
|
||||||
locationRef.current = 1;
|
locationRef.current = 1;
|
||||||
tocRef.current = [];
|
tocRef.current = [];
|
||||||
renderedTextMapsRef.current = [];
|
resetHighlightState();
|
||||||
wordHighlightMapCacheRef.current = null;
|
|
||||||
renderedLocationCloneManagerRef.current.invalidate();
|
renderedLocationCloneManagerRef.current.invalidate();
|
||||||
stop();
|
stop();
|
||||||
}, [setCurrDocPages, stop]);
|
}, [resetHighlightState, setCurrDocPages, stop]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the current document based on its ID by fetching from IndexedDB
|
* Sets the current document based on its ID by fetching from IndexedDB
|
||||||
|
|
@ -648,12 +231,11 @@ export function useEPUB(): EPUBState {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
const textContent = range.toString().trim();
|
const textContent = range.toString().trim();
|
||||||
renderedTextMapsRef.current = buildRenderedTextMaps(
|
setRenderedTextMaps(buildRenderedTextMaps(
|
||||||
rendition,
|
rendition,
|
||||||
rangeCfi,
|
rangeCfi,
|
||||||
normalizeTtsLocationKey(start.cfi),
|
normalizeTtsLocationKey(start.cfi),
|
||||||
);
|
));
|
||||||
wordHighlightMapCacheRef.current = null;
|
|
||||||
const leadingPreview = collectLeadingContextFromRange(range);
|
const leadingPreview = collectLeadingContextFromRange(range);
|
||||||
const continuationPreview = collectContinuationFromRange(range);
|
const continuationPreview = collectContinuationFromRange(range);
|
||||||
|
|
||||||
|
|
@ -680,71 +262,7 @@ export function useEPUB(): EPUBState {
|
||||||
console.error('Error extracting EPUB text:', error);
|
console.error('Error extracting EPUB text:', error);
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}, [setTTSText, smartSentenceSplitting]);
|
}, [setRenderedTextMaps, setTTSText, smartSentenceSplitting]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts text content from the entire EPUB book
|
|
||||||
* @returns {Promise<string[]>} 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<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;
|
|
||||||
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]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a draft EPUB locator (typically `{ readerType: 'epub', location:
|
* Resolves a draft EPUB locator (typically `{ readerType: 'epub', location:
|
||||||
|
|
@ -849,287 +367,31 @@ export function useEPUB(): EPUBState {
|
||||||
});
|
});
|
||||||
}, [currDocData, epubTheme]);
|
}, [currDocData, epubTheme]);
|
||||||
|
|
||||||
const audiobookAdapter = useMemo(() => createEpubAudiobookSourceAdapter({
|
const { createFullAudioBook, regenerateChapter } = useEPUBAudiobook({
|
||||||
extractBookText,
|
bookRef,
|
||||||
getTocItems: () => tocRef.current || [],
|
tocRef,
|
||||||
}), [extractBookText]);
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
/**
|
providerRef,
|
||||||
* 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<string> => {
|
|
||||||
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<TTSAudiobookChapter> => {
|
|
||||||
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 setRendition = useCallback((rendition: Rendition) => {
|
const setRendition = useCallback((rendition: Rendition) => {
|
||||||
bookRef.current = rendition.book;
|
bookRef.current = rendition.book;
|
||||||
renditionRef.current = rendition;
|
renditionRef.current = rendition;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const safeRenditionNavigate = useCallback((navigation: 'next' | 'prev' | 'display', location?: string) => {
|
const handleLocationChanged = useEPUBLocationController({
|
||||||
const book = bookRef.current;
|
documentId,
|
||||||
const rendition = renditionRef.current;
|
authEnabled,
|
||||||
if (!book?.isOpen || !rendition) return false;
|
isEpubSetOnceRef: isEPUBSetOnce,
|
||||||
|
shouldPauseRef,
|
||||||
const guardNavigationPromise = (promiseLike: unknown): void => {
|
setIsEpub: setIsEPUB,
|
||||||
const promise = Promise.resolve(promiseLike);
|
skipToLocation,
|
||||||
void promise.catch((error) => {
|
extractPageText,
|
||||||
console.warn(`EPUB rendition ${navigation} failed:`, error);
|
bookRef,
|
||||||
});
|
renditionRef,
|
||||||
};
|
locationRef,
|
||||||
|
});
|
||||||
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]);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
|
||||||
import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
|
import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
|
||||||
import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
|
import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
|
||||||
import { DotsVerticalIcon, ChevronLeftIcon, ChevronRightIcon } from '@/components/icons/Icons';
|
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), {
|
const ReactReader = dynamic(() => import('react-reader').then(mod => mod.ReactReader), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|
@ -18,7 +18,7 @@ const ReactReader = dynamic(() => import('react-reader').then(mod => mod.ReactRe
|
||||||
interface EPUBViewerProps {
|
interface EPUBViewerProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
epubState: Pick<
|
epubState: Pick<
|
||||||
EPUBState,
|
EpubDocumentState,
|
||||||
| 'currDocData'
|
| 'currDocData'
|
||||||
| 'currDocName'
|
| 'currDocName'
|
||||||
| 'currDocPage'
|
| 'currDocPage'
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ import { isStableEpubLocator } from '@/types/client';
|
||||||
/**
|
/**
|
||||||
* Resolves an EPUB segment's draft locator (typically `{ readerType: 'epub',
|
* Resolves an EPUB segment's draft locator (typically `{ readerType: 'epub',
|
||||||
* location: <CFI> }`) into a stable book coordinate. The resolver lives in
|
* location: <CFI> }`) 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
|
* just before posting segments to the server so what gets persisted is
|
||||||
* viewport-independent. Returns null when the CFI can't be resolved.
|
* 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
|
* 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`
|
* time so the saved locator carries `spineHref`/`spineIndex`/`charOffset`
|
||||||
* rather than the viewport-dependent CFI string. Returns null when the CFI
|
* rather than the viewport-dependent CFI string. Returns null when the CFI
|
||||||
* can't be resolved — callers should drop the segment from the persist
|
* 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) {
|
if (!resolver) {
|
||||||
// No book available to resolve — drop. This can happen during early
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
const keyPrefix = buildSegmentKeyPrefix(documentId, 'epub');
|
const keyPrefix = buildSegmentKeyPrefix(documentId, 'epub');
|
||||||
|
|
|
||||||
182
src/hooks/epub/useEPUBAudiobook.ts
Normal file
182
src/hooks/epub/useEPUBAudiobook.ts
Normal file
|
|
@ -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<T extends { text: string }>(entries: T[]): T[] {
|
||||||
|
return entries.filter((entry) => entry.text.trim() !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseEpubAudiobookParams = {
|
||||||
|
bookRef: RefObject<Book | null>;
|
||||||
|
tocRef: RefObject<NavItem[]>;
|
||||||
|
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<string>;
|
||||||
|
regenerateChapter: (
|
||||||
|
chapterIndex: number,
|
||||||
|
bookId: string,
|
||||||
|
format: TTSAudiobookFormat,
|
||||||
|
signal: AbortSignal,
|
||||||
|
settings?: AudiobookGenerationSettings
|
||||||
|
) => Promise<TTSAudiobookChapter>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<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;
|
||||||
|
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<string> => {
|
||||||
|
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<TTSAudiobookChapter> => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
193
src/hooks/epub/useEPUBHighlighting.ts
Normal file
193
src/hooks/epub/useEPUBHighlighting.ts
Normal file
|
|
@ -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<Rendition | undefined>;
|
||||||
|
epubHighlightEnabled: boolean;
|
||||||
|
currentHighlightCfiRef: MutableRefObject<string | null>;
|
||||||
|
currentWordHighlightCfiRef: MutableRefObject<string | null>;
|
||||||
|
renderedTextMapsRef: MutableRefObject<EpubRenderedTextMap[]>;
|
||||||
|
wordHighlightMapCacheRef: MutableRefObject<EpubWordHighlightMapCache | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
170
src/hooks/epub/useEPUBLocationController.ts
Normal file
170
src/hooks/epub/useEPUBLocationController.ts
Normal file
|
|
@ -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<boolean>;
|
||||||
|
shouldPauseRef: MutableRefObject<boolean>;
|
||||||
|
setIsEpub: (isEpub: boolean) => void;
|
||||||
|
skipToLocation: (location: EpubLocation) => void;
|
||||||
|
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
||||||
|
bookRef: RefObject<Book | null>;
|
||||||
|
renditionRef: RefObject<Rendition | undefined>;
|
||||||
|
locationRef: RefObject<EpubLocation>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
166
src/lib/client/epub/epub-range-context.ts
Normal file
166
src/lib/client/epub/epub-range-context.ts
Normal file
|
|
@ -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();
|
||||||
|
};
|
||||||
237
src/lib/client/epub/epub-rendered-text-maps.ts
Normal file
237
src/lib/client/epub/epub-rendered-text-maps.ts
Normal file
|
|
@ -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;
|
||||||
|
};
|
||||||
40
tests/unit/epub-audiobook-hook.spec.ts
Normal file
40
tests/unit/epub-audiobook-hook.spec.ts
Normal file
|
|
@ -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' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
31
tests/unit/epub-location-controller.spec.ts
Normal file
31
tests/unit/epub-location-controller.spec.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue