feat(html): add HTML/TXT/MD sentence and word highlighting, settings, and audiobook export support
Introduce sentence and word highlighting for HTML/TXT/MD documents, including new config options (`htmlHighlightEnabled`, `htmlWordHighlightEnabled`) and synced user preferences. Update the HTML viewer to support these highlights using custom spans, and add corresponding CSS styles. Extend document settings to allow toggling these features. Implement block-level locator sorting and display improvements. Add audiobook export support for HTML documents, including adapter and pipeline integration. - Add `htmlHighlightEnabled` and `htmlWordHighlightEnabled` to config, user state, and Dexie storage - Update HTML viewer to apply and manage highlights for sentences and words - Add CSS for HTML highlight classes and scroll margin - Extend document settings UI for HTML highlight toggles - Support HTML audiobook export in modal and pipeline - Improve block locator sorting and sidebar display for HTML - Add HTML audiobook adapter and block parsing utilities
This commit is contained in:
parent
92762fe858
commit
d90e48aaf3
20 changed files with 1755 additions and 95 deletions
|
|
@ -14,18 +14,33 @@ import { resolveDocumentId } from '@/lib/client/dexie';
|
||||||
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
|
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
|
||||||
import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
|
import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
|
||||||
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
||||||
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||||
|
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||||
|
import type { TTSAudiobookChapter } from '@/types/tts';
|
||||||
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
import { useHtmlDocument } from './useHtmlDocument';
|
import { useHtmlDocument } from './useHtmlDocument';
|
||||||
|
|
||||||
export default function HTMLPage() {
|
export default function HTMLPage() {
|
||||||
|
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { setCurrentDocument, currDocData, currDocName, clearCurrDoc } = useHtmlDocument();
|
const htmlState = useHtmlDocument();
|
||||||
|
const {
|
||||||
|
setCurrentDocument,
|
||||||
|
currDocData,
|
||||||
|
currDocName,
|
||||||
|
blocks,
|
||||||
|
isTxt,
|
||||||
|
clearCurrDoc,
|
||||||
|
createFullAudioBook,
|
||||||
|
regenerateChapter,
|
||||||
|
} = htmlState;
|
||||||
const { stop } = useTTS();
|
const { stop } = useTTS();
|
||||||
const { isAtLimit } = useAuthRateLimit();
|
const { isAtLimit } = useAuthRateLimit();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [activeSidebar, setActiveSidebar] = useState<null | 'settings' | 'segments'>(null);
|
const [activeSidebar, setActiveSidebar] = useState<null | 'settings' | 'segments' | 'audiobook'>(null);
|
||||||
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
||||||
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width)
|
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width)
|
||||||
const [maxPadPx, setMaxPadPx] = useState<number>(0);
|
const [maxPadPx, setMaxPadPx] = useState<number>(0);
|
||||||
|
|
@ -110,6 +125,24 @@ export default function HTMLPage() {
|
||||||
return () => window.removeEventListener('resize', compute);
|
return () => window.removeEventListener('resize', compute);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleGenerateAudiobook = useCallback(async (
|
||||||
|
onProgress: (progress: number) => void,
|
||||||
|
signal: AbortSignal,
|
||||||
|
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
||||||
|
settings: AudiobookGenerationSettings,
|
||||||
|
) => {
|
||||||
|
return createFullAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings);
|
||||||
|
}, [createFullAudioBook, id]);
|
||||||
|
|
||||||
|
const handleRegenerateChapter = useCallback(async (
|
||||||
|
chapterIndex: number,
|
||||||
|
bookId: string,
|
||||||
|
settings: AudiobookGenerationSettings,
|
||||||
|
signal: AbortSignal,
|
||||||
|
) => {
|
||||||
|
return regenerateChapter(chapterIndex, bookId, settings.format, signal, settings);
|
||||||
|
}, [regenerateChapter]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
|
|
@ -153,8 +186,11 @@ export default function HTMLPage() {
|
||||||
onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))}
|
onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))}
|
||||||
onOpenSettings={() => setActiveSidebar((prev) => prev === 'settings' ? null : 'settings')}
|
onOpenSettings={() => setActiveSidebar((prev) => prev === 'settings' ? null : 'settings')}
|
||||||
onOpenSegments={() => setActiveSidebar((prev) => prev === 'segments' ? null : 'segments')}
|
onOpenSegments={() => setActiveSidebar((prev) => prev === 'segments' ? null : 'segments')}
|
||||||
|
onOpenAudiobook={() => setActiveSidebar((prev) => prev === 'audiobook' ? null : 'audiobook')}
|
||||||
isSettingsOpen={activeSidebar === 'settings'}
|
isSettingsOpen={activeSidebar === 'settings'}
|
||||||
isSegmentsOpen={activeSidebar === 'segments'}
|
isSegmentsOpen={activeSidebar === 'segments'}
|
||||||
|
isAudiobookOpen={activeSidebar === 'audiobook'}
|
||||||
|
showAudiobookExport={canExportAudiobook}
|
||||||
minZoom={0}
|
minZoom={0}
|
||||||
maxZoom={100}
|
maxZoom={100}
|
||||||
/>
|
/>
|
||||||
|
|
@ -162,16 +198,31 @@ export default function HTMLPage() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="overflow-hidden" style={{ height: containerHeight }}>
|
<div className="overflow-hidden" style={{ height: containerHeight }}>
|
||||||
{isLoading ? (
|
{isLoading || !currDocData ? (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<DocumentSkeleton />
|
<DocumentSkeleton />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full w-full" style={{ paddingLeft: `${Math.round(maxPadPx * ((100 - padPct) / 100))}px`, paddingRight: `${Math.round(maxPadPx * ((100 - padPct) / 100))}px` }}>
|
<div className="h-full w-full" style={{ paddingLeft: `${Math.round(maxPadPx * ((100 - padPct) / 100))}px`, paddingRight: `${Math.round(maxPadPx * ((100 - padPct) / 100))}px` }}>
|
||||||
<HTMLViewer className="h-full" currDocData={currDocData} currDocName={currDocName} />
|
<HTMLViewer
|
||||||
|
className="h-full"
|
||||||
|
blocks={blocks}
|
||||||
|
isTxt={isTxt}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{canExportAudiobook && (
|
||||||
|
<AudiobookExportModal
|
||||||
|
isOpen={activeSidebar === 'audiobook'}
|
||||||
|
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'audiobook' : (prev === 'audiobook' ? null : prev))}
|
||||||
|
documentType="html"
|
||||||
|
documentId={id as string}
|
||||||
|
onGenerateAudiobook={handleGenerateAudiobook}
|
||||||
|
onRegenerateChapter={handleRegenerateChapter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isAtLimit ? (
|
{isAtLimit ? (
|
||||||
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
|
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
|
||||||
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
|
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
|
||||||
|
|
|
||||||
|
|
@ -3,33 +3,106 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
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 { parseHtmlBlocks, type HtmlBlock } from '@/lib/client/html/blocks';
|
||||||
|
import { createHtmlAudiobookSourceAdapter } from '@/lib/client/audiobooks/adapters/html';
|
||||||
|
import { regenerateAudiobookChapter, runAudiobookGeneration } from '@/lib/client/audiobooks/pipeline';
|
||||||
|
import type {
|
||||||
|
AudiobookGenerationSettings,
|
||||||
|
TTSRetryOptions,
|
||||||
|
} from '@/types/client';
|
||||||
|
import type {
|
||||||
|
TTSAudiobookChapter,
|
||||||
|
TTSAudiobookFormat,
|
||||||
|
} from '@/types/tts';
|
||||||
|
|
||||||
interface HtmlDocumentState {
|
export interface HtmlDocumentState {
|
||||||
currDocData: string | undefined;
|
currDocData: string | undefined;
|
||||||
currDocName: string | undefined;
|
currDocName: string | undefined;
|
||||||
currDocText: string | undefined;
|
currDocText: string | undefined;
|
||||||
|
blocks: HtmlBlock[];
|
||||||
|
isTxt: boolean;
|
||||||
setCurrentDocument: (id: string) => Promise<void>;
|
setCurrentDocument: (id: string) => Promise<void>;
|
||||||
clearCurrDoc: () => void;
|
clearCurrDoc: () => void;
|
||||||
|
createFullAudioBook: (
|
||||||
|
onProgress: (progress: number) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
onChapterComplete?: (chapter: TTSAudiobookChapter) => void,
|
||||||
|
providedBookId?: string,
|
||||||
|
format?: TTSAudiobookFormat,
|
||||||
|
settings?: AudiobookGenerationSettings,
|
||||||
|
retryOptions?: TTSRetryOptions,
|
||||||
|
) => Promise<string>;
|
||||||
|
regenerateChapter: (
|
||||||
|
chapterIndex: number,
|
||||||
|
bookId: string,
|
||||||
|
format: TTSAudiobookFormat,
|
||||||
|
signal: AbortSignal,
|
||||||
|
settings?: AudiobookGenerationSettings,
|
||||||
|
retryOptions?: TTSRetryOptions,
|
||||||
|
) => Promise<TTSAudiobookChapter>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTxtName(name: string | undefined | null): boolean {
|
||||||
|
return !!name && name.toLowerCase().endsWith('.txt');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concatenate every block's plain text into one TTS source. We treat the
|
||||||
|
* entire HTML/TXT/MD document as a single "page" with a flat sequence of
|
||||||
|
* segments (sentence indices), so playback advances naturally through the
|
||||||
|
* doc without any per-block locator bookkeeping.
|
||||||
|
*/
|
||||||
|
function buildFullDocumentText(blocks: HtmlBlock[]): string {
|
||||||
|
return blocks
|
||||||
|
.map((b) => b.plainText)
|
||||||
|
.filter((t) => t && t.trim())
|
||||||
|
.join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useHtmlDocument(): HtmlDocumentState {
|
export function useHtmlDocument(): HtmlDocumentState {
|
||||||
const { setText: setTTSText, stop } = useTTS();
|
const { setText: setTTSText, stop, setIsEPUB } = useTTS();
|
||||||
const setTTSTextRef = useRef(setTTSText);
|
const {
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
providerRef,
|
||||||
|
smartSentenceSplitting,
|
||||||
|
ttsSegmentMaxBlockLength,
|
||||||
|
} = useConfig();
|
||||||
|
|
||||||
const [currDocData, setCurrDocData] = useState<string>();
|
const [currDocData, setCurrDocData] = useState<string>();
|
||||||
const [currDocName, setCurrDocName] = useState<string>();
|
const [currDocName, setCurrDocName] = useState<string>();
|
||||||
const [currDocText, setCurrDocText] = useState<string>();
|
|
||||||
|
|
||||||
|
const isTxt = useMemo(() => isTxtName(currDocName), [currDocName]);
|
||||||
|
const blocks = useMemo(
|
||||||
|
() => (currDocData !== undefined ? parseHtmlBlocks(currDocData, isTxt) : []),
|
||||||
|
[currDocData, isTxt],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currDocText = useMemo(() => buildFullDocumentText(blocks), [blocks]);
|
||||||
|
|
||||||
|
// HTML reader is not an EPUB reader.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTTSTextRef.current = setTTSText;
|
setIsEPUB(false);
|
||||||
}, [setTTSText]);
|
}, [setIsEPUB]);
|
||||||
|
|
||||||
|
// Feed the entire document into TTS once it's parsed. The TTS context owns
|
||||||
|
// sentence splitting + sequential advancement from there.
|
||||||
|
const lastFedDocRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currDocText) return;
|
||||||
|
const key = `${currDocData ?? ''}::${currDocText.length}`;
|
||||||
|
if (lastFedDocRef.current === key) return;
|
||||||
|
lastFedDocRef.current = key;
|
||||||
|
setTTSText(currDocText);
|
||||||
|
}, [currDocText, currDocData, setTTSText]);
|
||||||
|
|
||||||
const clearCurrDoc = useCallback(() => {
|
const clearCurrDoc = useCallback(() => {
|
||||||
setCurrDocData(undefined);
|
setCurrDocData(undefined);
|
||||||
setCurrDocName(undefined);
|
setCurrDocName(undefined);
|
||||||
setCurrDocText(undefined);
|
lastFedDocRef.current = null;
|
||||||
stop();
|
stop();
|
||||||
}, [stop]);
|
}, [stop]);
|
||||||
|
|
||||||
|
|
@ -49,22 +122,110 @@ export function useHtmlDocument(): HtmlDocumentState {
|
||||||
|
|
||||||
setCurrDocName(doc.name);
|
setCurrDocName(doc.name);
|
||||||
setCurrDocData(doc.data);
|
setCurrDocData(doc.data);
|
||||||
setCurrDocText(doc.data);
|
|
||||||
setTTSTextRef.current(doc.data);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get HTML document:', error);
|
console.error('Failed to get HTML document:', error);
|
||||||
clearCurrDoc();
|
clearCurrDoc();
|
||||||
}
|
}
|
||||||
}, [clearCurrDoc]);
|
}, [clearCurrDoc]);
|
||||||
|
|
||||||
|
const audiobookAdapter = useMemo(
|
||||||
|
() =>
|
||||||
|
createHtmlAudiobookSourceAdapter({
|
||||||
|
blocks,
|
||||||
|
isTxt,
|
||||||
|
smartSentenceSplitting,
|
||||||
|
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||||
|
}),
|
||||||
|
[blocks, isTxt, smartSentenceSplitting, ttsSegmentMaxBlockLength],
|
||||||
|
);
|
||||||
|
|
||||||
|
const createFullAudioBook = useCallback(
|
||||||
|
async (
|
||||||
|
onProgress: (progress: number) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
onChapterComplete?: (chapter: TTSAudiobookChapter) => void,
|
||||||
|
providedBookId?: string,
|
||||||
|
format: TTSAudiobookFormat = 'mp3',
|
||||||
|
settings?: AudiobookGenerationSettings,
|
||||||
|
retryOptions?: TTSRetryOptions,
|
||||||
|
): Promise<string> => {
|
||||||
|
try {
|
||||||
|
return await runAudiobookGeneration({
|
||||||
|
adapter: audiobookAdapter,
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
defaultProvider: providerRef,
|
||||||
|
onProgress,
|
||||||
|
signal,
|
||||||
|
onChapterComplete,
|
||||||
|
providedBookId,
|
||||||
|
format,
|
||||||
|
settings,
|
||||||
|
retryOptions,
|
||||||
|
});
|
||||||
|
} 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,
|
||||||
|
retryOptions?: TTSRetryOptions,
|
||||||
|
): Promise<TTSAudiobookChapter> => {
|
||||||
|
try {
|
||||||
|
return await regenerateAudiobookChapter({
|
||||||
|
adapter: audiobookAdapter,
|
||||||
|
chapterIndex,
|
||||||
|
bookId,
|
||||||
|
format,
|
||||||
|
signal,
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
defaultProvider: providerRef,
|
||||||
|
settings,
|
||||||
|
retryOptions,
|
||||||
|
});
|
||||||
|
} 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 useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
currDocData,
|
currDocData,
|
||||||
currDocName,
|
currDocName,
|
||||||
currDocText,
|
currDocText,
|
||||||
|
blocks,
|
||||||
|
isTxt,
|
||||||
setCurrentDocument,
|
setCurrentDocument,
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
|
createFullAudioBook,
|
||||||
|
regenerateChapter,
|
||||||
}),
|
}),
|
||||||
[currDocData, currDocName, currDocText, setCurrentDocument, clearCurrDoc],
|
[
|
||||||
|
currDocData,
|
||||||
|
currDocName,
|
||||||
|
currDocText,
|
||||||
|
blocks,
|
||||||
|
isTxt,
|
||||||
|
setCurrentDocument,
|
||||||
|
clearCurrDoc,
|
||||||
|
createFullAudioBook,
|
||||||
|
regenerateChapter,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,8 @@ function sanitizePreferencesPatch(
|
||||||
case 'pdfWordHighlightEnabled':
|
case 'pdfWordHighlightEnabled':
|
||||||
case 'epubHighlightEnabled':
|
case 'epubHighlightEnabled':
|
||||||
case 'epubWordHighlightEnabled':
|
case 'epubWordHighlightEnabled':
|
||||||
|
case 'htmlHighlightEnabled':
|
||||||
|
case 'htmlWordHighlightEnabled':
|
||||||
if (typeof value === 'boolean') out[key] = value;
|
if (typeof value === 'boolean') out[key] = value;
|
||||||
break;
|
break;
|
||||||
case 'savedVoices':
|
case 'savedVoices':
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,28 @@ h1, h2, h3, h4, h5, h6 {
|
||||||
100% { opacity: 0.8; }
|
100% { opacity: 0.8; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Block container scroll target — keeps anchor scroll-into-view aligned with
|
||||||
|
the page padding when the reader has top header chrome. */
|
||||||
|
.openreader-html-block {
|
||||||
|
scroll-margin-top: 4rem;
|
||||||
|
scroll-margin-bottom: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HTML/TXT/MD reader sentence + word highlights. These are applied by
|
||||||
|
`src/lib/client/html/highlight.ts` wrapping the matched text in spans, so
|
||||||
|
they survive any browser quirks around the (still-spotty) CSS Custom
|
||||||
|
Highlight API. */
|
||||||
|
.openreader-html-highlight-sentence {
|
||||||
|
background-color: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 1px;
|
||||||
|
}
|
||||||
|
.openreader-html-highlight-word {
|
||||||
|
background-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Static prism gradient divider (no animation) */
|
/* Static prism gradient divider (no animation) */
|
||||||
.prism-divider {
|
.prism-divider {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
interface AudiobookExportModalProps {
|
interface AudiobookExportModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
documentType: 'epub' | 'pdf';
|
documentType: 'epub' | 'pdf' | 'html';
|
||||||
documentId: string;
|
documentId: string;
|
||||||
onGenerateAudiobook: (
|
onGenerateAudiobook: (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
epubHighlightEnabled,
|
epubHighlightEnabled,
|
||||||
pdfWordHighlightEnabled,
|
pdfWordHighlightEnabled,
|
||||||
epubWordHighlightEnabled,
|
epubWordHighlightEnabled,
|
||||||
|
htmlHighlightEnabled,
|
||||||
|
htmlWordHighlightEnabled,
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
const [localMargins, setLocalMargins] = useState({
|
const [localMargins, setLocalMargins] = useState({
|
||||||
header: headerMargin,
|
header: headerMargin,
|
||||||
|
|
@ -161,12 +163,12 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
panelClassName="w-full sm:w-[30rem]"
|
panelClassName="w-full sm:w-[30rem]"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!html && (
|
<Section
|
||||||
<Section
|
title="Playback Flow"
|
||||||
title="Playback Flow"
|
subtitle="Segment and queue behavior."
|
||||||
subtitle="Segment and queue behavior."
|
variant="flat"
|
||||||
variant="flat"
|
>
|
||||||
>
|
{!html && (
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
label="Skip blank pages"
|
label="Skip blank pages"
|
||||||
description="Skip pages with no readable text."
|
description="Skip pages with no readable text."
|
||||||
|
|
@ -174,64 +176,64 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
onChange={(checked) => updateConfigKey('skipBlank', checked)}
|
onChange={(checked) => updateConfigKey('skipBlank', checked)}
|
||||||
variant="flat"
|
variant="flat"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
label="Smart sentence splitting"
|
label="Smart sentence splitting"
|
||||||
description="Merge fragments across pages/sections."
|
description="Merge fragments across pages/sections."
|
||||||
checked={smartSentenceSplitting}
|
checked={smartSentenceSplitting}
|
||||||
onChange={(checked) => updateConfigKey('smartSentenceSplitting', checked)}
|
onChange={(checked) => updateConfigKey('smartSentenceSplitting', checked)}
|
||||||
variant="flat"
|
variant="flat"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-3 pt-1">
|
||||||
|
<RangeSetting
|
||||||
|
label="Segment preload depth"
|
||||||
|
value={localPreloadDepth}
|
||||||
|
min={SEGMENT_PRELOAD_DEPTH_MIN}
|
||||||
|
max={SEGMENT_PRELOAD_DEPTH_MAX}
|
||||||
|
step={1}
|
||||||
|
description="Upcoming pages/locations to queue."
|
||||||
|
formatter={(value) => String(value)}
|
||||||
|
onChange={(value) => {
|
||||||
|
const next = clampSegmentPreloadDepth(value);
|
||||||
|
setLocalPreloadDepth(next);
|
||||||
|
void updateConfigKey('segmentPreloadDepthPages', next);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-3 pt-1">
|
<RangeSetting
|
||||||
<RangeSetting
|
label="Segment lookahead per page/location"
|
||||||
label="Segment preload depth"
|
value={localSentenceLookahead}
|
||||||
value={localPreloadDepth}
|
min={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN}
|
||||||
min={SEGMENT_PRELOAD_DEPTH_MIN}
|
max={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX}
|
||||||
max={SEGMENT_PRELOAD_DEPTH_MAX}
|
step={1}
|
||||||
step={1}
|
description="Segments to prepare per queued page/section."
|
||||||
description="Upcoming pages/locations to queue."
|
formatter={(value) => String(value)}
|
||||||
formatter={(value) => String(value)}
|
onChange={(value) => {
|
||||||
onChange={(value) => {
|
const next = clampSegmentPreloadSentenceLookahead(value);
|
||||||
const next = clampSegmentPreloadDepth(value);
|
setLocalSentenceLookahead(next);
|
||||||
setLocalPreloadDepth(next);
|
void updateConfigKey('segmentPreloadSentenceLookahead', next);
|
||||||
void updateConfigKey('segmentPreloadDepthPages', next);
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<RangeSetting
|
<RangeSetting
|
||||||
label="Segment lookahead per page/location"
|
label="TTS segment max block length"
|
||||||
value={localSentenceLookahead}
|
value={localMaxBlockLength}
|
||||||
min={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN}
|
min={TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN}
|
||||||
max={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX}
|
max={TTS_SEGMENT_MAX_BLOCK_LENGTH_MAX}
|
||||||
step={1}
|
step={TTS_SEGMENT_MAX_BLOCK_LENGTH_STEP}
|
||||||
description="Segments to prepare per queued page/section."
|
description="Max characters per TTS segment block."
|
||||||
formatter={(value) => String(value)}
|
valueWidth="w-14"
|
||||||
onChange={(value) => {
|
formatter={(value) => String(value)}
|
||||||
const next = clampSegmentPreloadSentenceLookahead(value);
|
onChange={(value) => {
|
||||||
setLocalSentenceLookahead(next);
|
const next = clampTtsSegmentMaxBlockLength(value);
|
||||||
void updateConfigKey('segmentPreloadSentenceLookahead', next);
|
setLocalMaxBlockLength(next);
|
||||||
}}
|
void updateConfigKey('ttsSegmentMaxBlockLength', next);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
<RangeSetting
|
</div>
|
||||||
label="TTS segment max block length"
|
</Section>
|
||||||
value={localMaxBlockLength}
|
|
||||||
min={TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN}
|
|
||||||
max={TTS_SEGMENT_MAX_BLOCK_LENGTH_MAX}
|
|
||||||
step={TTS_SEGMENT_MAX_BLOCK_LENGTH_STEP}
|
|
||||||
description="Max characters per TTS segment block."
|
|
||||||
valueWidth="w-14"
|
|
||||||
formatter={(value) => String(value)}
|
|
||||||
onChange={(value) => {
|
|
||||||
const next = clampTtsSegmentMaxBlockLength(value);
|
|
||||||
setLocalMaxBlockLength(next);
|
|
||||||
void updateConfigKey('ttsSegmentMaxBlockLength', next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!epub && !html && (
|
{!epub && !html && (
|
||||||
<Section
|
<Section
|
||||||
|
|
@ -352,6 +354,30 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{html && (
|
||||||
|
<Section
|
||||||
|
title="Text & Markdown Highlighting"
|
||||||
|
subtitle="Playback highlighting in text/markdown mode."
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
<ToggleRow
|
||||||
|
label="Highlight text during playback"
|
||||||
|
description="Highlight the current sentence in the rendered text."
|
||||||
|
checked={htmlHighlightEnabled}
|
||||||
|
onChange={(checked) => updateConfigKey('htmlHighlightEnabled', checked)}
|
||||||
|
variant="flat"
|
||||||
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label="Word-by-word highlighting"
|
||||||
|
description={`Highlight words using timing data${!canWordHighlight ? ' (disabled by config)' : ''}.`}
|
||||||
|
checked={htmlWordHighlightEnabled && htmlHighlightEnabled}
|
||||||
|
disabled={!htmlHighlightEnabled || !canWordHighlight}
|
||||||
|
onChange={(checked) => updateConfigKey('htmlWordHighlightEnabled', checked)}
|
||||||
|
variant="flat"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ReaderSidebarShell>
|
</ReaderSidebarShell>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,9 @@ function formatLocatorGroupLabel(locator: TTSSegmentLocator | null): string {
|
||||||
return `Page ${Math.floor(locator.page)} · PDF`;
|
return `Page ${Math.floor(locator.page)} · PDF`;
|
||||||
}
|
}
|
||||||
if (isHtmlLocator(locator)) {
|
if (isHtmlLocator(locator)) {
|
||||||
|
if (/^\d+$/.test(locator.location)) {
|
||||||
|
return `Block ${locator.location} · HTML`;
|
||||||
|
}
|
||||||
return `${locator.location} · HTML`;
|
return `${locator.location} · HTML`;
|
||||||
}
|
}
|
||||||
return 'Unknown location';
|
return 'Unknown location';
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,217 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
|
||||||
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
|
import type { HtmlBlock } from '@/lib/client/html/blocks';
|
||||||
|
import {
|
||||||
|
clearHtmlSentenceHighlight,
|
||||||
|
clearHtmlWordHighlight,
|
||||||
|
highlightHtmlSentence,
|
||||||
|
highlightHtmlWord,
|
||||||
|
scrollSentenceIntoView,
|
||||||
|
} from '@/lib/client/html/highlight';
|
||||||
|
|
||||||
interface HTMLViewerProps {
|
interface HTMLViewerProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
currDocData?: string;
|
blocks: HtmlBlock[];
|
||||||
currDocName?: string;
|
isTxt: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HTMLViewer({ className = '', currDocData, currDocName }: HTMLViewerProps) {
|
export function HTMLViewer({
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
className = '',
|
||||||
|
blocks,
|
||||||
|
isTxt,
|
||||||
|
isLoading = false,
|
||||||
|
}: HTMLViewerProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
if (!currDocData) {
|
const {
|
||||||
|
currentSentence,
|
||||||
|
currentSentenceAlignment,
|
||||||
|
currentWordIndex,
|
||||||
|
} = useTTS();
|
||||||
|
const { htmlHighlightEnabled, htmlWordHighlightEnabled } = useConfig();
|
||||||
|
|
||||||
|
// ---- Sentence highlight scheduling -------------------------------------
|
||||||
|
// Mirrors PDFViewer: schedule + retry via setTimeout, with a sequence
|
||||||
|
// counter so stale retries from a previous sentence are aborted as soon as
|
||||||
|
// the user (or TTS) moves on. The cleanup only cancels pending timeouts —
|
||||||
|
// it does NOT clear the wrap. Wrap removal happens at the top of the next
|
||||||
|
// effect run, which keeps Strict Mode's mount→unmount→mount cycle from
|
||||||
|
// wiping the very first highlight.
|
||||||
|
const sentenceSeqRef = useRef(0);
|
||||||
|
const sentenceTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||||
|
const wordSeqRef = useRef(0);
|
||||||
|
const wordTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||||
|
|
||||||
|
const clearSentenceTimeouts = useCallback(() => {
|
||||||
|
for (const t of sentenceTimeoutsRef.current) clearTimeout(t);
|
||||||
|
sentenceTimeoutsRef.current = [];
|
||||||
|
}, []);
|
||||||
|
const scheduleSentence = useCallback((fn: () => void, ms: number) => {
|
||||||
|
const t = setTimeout(fn, ms);
|
||||||
|
sentenceTimeoutsRef.current.push(t);
|
||||||
|
}, []);
|
||||||
|
const clearWordTimeouts = useCallback(() => {
|
||||||
|
for (const t of wordTimeoutsRef.current) clearTimeout(t);
|
||||||
|
wordTimeoutsRef.current = [];
|
||||||
|
}, []);
|
||||||
|
const scheduleWord = useCallback((fn: () => void, ms: number) => {
|
||||||
|
const t = setTimeout(fn, ms);
|
||||||
|
wordTimeoutsRef.current.push(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sentence highlight.
|
||||||
|
// The `blocks` dep ensures we re-attempt when ReactMarkdown rerenders for
|
||||||
|
// a new document (so the FIRST sentence after load gets a fresh tokenize).
|
||||||
|
useEffect(() => {
|
||||||
|
clearSentenceTimeouts();
|
||||||
|
|
||||||
|
if (!htmlHighlightEnabled || !currentSentence) {
|
||||||
|
// Invalidate any in-flight retries from a previous sentence and wipe
|
||||||
|
// whatever wrap is currently on screen — there's nothing valid to draw.
|
||||||
|
sentenceSeqRef.current += 1;
|
||||||
|
clearHtmlSentenceHighlight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New highlight pass — bump the sequence so any retries still pending
|
||||||
|
// from a previous sentence short-circuit before touching the DOM.
|
||||||
|
const seq = ++sentenceSeqRef.current;
|
||||||
|
|
||||||
|
const tryApply = (attempt: number) => {
|
||||||
|
if (seq !== sentenceSeqRef.current) return;
|
||||||
|
const container = contentRef.current;
|
||||||
|
if (!container) {
|
||||||
|
if (attempt < 10) scheduleSentence(() => tryApply(attempt + 1), 75);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Clear any prior wrap right before we try to apply a new one. Doing it
|
||||||
|
// here (not in cleanup) avoids a Strict-Mode-induced wipe of the very
|
||||||
|
// first highlight.
|
||||||
|
clearHtmlSentenceHighlight();
|
||||||
|
const matched = highlightHtmlSentence(container, currentSentence);
|
||||||
|
if (matched) {
|
||||||
|
scrollSentenceIntoView(scrollRef.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// DOM tokens couldn't satisfy the sentence yet (rare — async font load,
|
||||||
|
// late ReactMarkdown commit, etc.). Try again shortly.
|
||||||
|
if (attempt < 10) {
|
||||||
|
scheduleSentence(() => tryApply(attempt + 1), 75);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Small initial defer so React's commit + browser layout finish before
|
||||||
|
// we walk the DOM. Matches the cadence PDFViewer uses.
|
||||||
|
scheduleSentence(() => tryApply(0), 30);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearSentenceTimeouts();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
htmlHighlightEnabled,
|
||||||
|
currentSentence,
|
||||||
|
blocks,
|
||||||
|
clearSentenceTimeouts,
|
||||||
|
scheduleSentence,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Word highlight is layered inside the current sentence wrap. Same
|
||||||
|
// scheduling pattern as the sentence effect.
|
||||||
|
useEffect(() => {
|
||||||
|
clearWordTimeouts();
|
||||||
|
|
||||||
|
if (!htmlHighlightEnabled || !htmlWordHighlightEnabled) {
|
||||||
|
wordSeqRef.current += 1;
|
||||||
|
clearHtmlWordHighlight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!currentSentenceAlignment ||
|
||||||
|
currentWordIndex === null ||
|
||||||
|
currentWordIndex === undefined ||
|
||||||
|
currentWordIndex < 0
|
||||||
|
) {
|
||||||
|
wordSeqRef.current += 1;
|
||||||
|
clearHtmlWordHighlight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seq = ++wordSeqRef.current;
|
||||||
|
|
||||||
|
const tryApplyWord = (attempt: number) => {
|
||||||
|
if (seq !== wordSeqRef.current) return;
|
||||||
|
const container = contentRef.current;
|
||||||
|
if (!container) {
|
||||||
|
if (attempt < 8) scheduleWord(() => tryApplyWord(attempt + 1), 60);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = highlightHtmlWord(container, currentSentenceAlignment, currentWordIndex);
|
||||||
|
if (!ok && attempt < 8) {
|
||||||
|
// Sentence wrap may not have settled yet — retry briefly so the very
|
||||||
|
// first word of the very first sentence isn't dropped.
|
||||||
|
scheduleWord(() => tryApplyWord(attempt + 1), 60);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tryApplyWord(0);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearWordTimeouts();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
htmlHighlightEnabled,
|
||||||
|
htmlWordHighlightEnabled,
|
||||||
|
currentSentenceAlignment,
|
||||||
|
currentWordIndex,
|
||||||
|
clearWordTimeouts,
|
||||||
|
scheduleWord,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Real cleanup on unmount — clear timeouts and tear down any leftover
|
||||||
|
// wraps. The empty deps ensure this only fires when HTMLViewer itself
|
||||||
|
// unmounts (route change, etc.), not on every prop/context update.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearSentenceTimeouts();
|
||||||
|
clearWordTimeouts();
|
||||||
|
clearHtmlSentenceHighlight();
|
||||||
|
clearHtmlWordHighlight();
|
||||||
|
};
|
||||||
|
}, [clearSentenceTimeouts, clearWordTimeouts]);
|
||||||
|
|
||||||
|
if (isLoading || !blocks.length) {
|
||||||
return <DocumentSkeleton />;
|
return <DocumentSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the file is a txt file
|
|
||||||
const isTxtFile = currDocName?.toLowerCase().endsWith('.txt');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col h-full ${className}`} ref={containerRef}>
|
<div className={`flex flex-col h-full ${className}`}>
|
||||||
<div className="flex-1 overflow-auto">
|
<div ref={scrollRef} className="flex-1 overflow-auto">
|
||||||
<div className={`html-container min-w-full px-4 py-4 ${isTxtFile ? 'whitespace-pre-wrap font-mono text-sm' : 'prose prose-base'}`}>
|
<div
|
||||||
{isTxtFile ? (
|
ref={contentRef}
|
||||||
currDocData
|
className={`html-container min-w-full px-4 py-4 ${isTxt ? 'font-mono text-sm' : 'prose prose-base'}`}
|
||||||
) : (
|
>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
{blocks.map((block) => (
|
||||||
{currDocData}
|
<div
|
||||||
</ReactMarkdown>
|
key={block.anchorId}
|
||||||
)}
|
id={block.anchorId}
|
||||||
|
data-block-kind={block.kind}
|
||||||
|
className="openreader-html-block"
|
||||||
|
>
|
||||||
|
{isTxt ? (
|
||||||
|
<pre className="whitespace-pre-wrap font-mono text-sm m-0">{block.raw}</pre>
|
||||||
|
) : (
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{block.raw}</ReactMarkdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ interface ConfigContextType {
|
||||||
pdfWordHighlightEnabled: boolean;
|
pdfWordHighlightEnabled: boolean;
|
||||||
epubHighlightEnabled: boolean;
|
epubHighlightEnabled: boolean;
|
||||||
epubWordHighlightEnabled: boolean;
|
epubWordHighlightEnabled: boolean;
|
||||||
|
htmlHighlightEnabled: boolean;
|
||||||
|
htmlWordHighlightEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
||||||
|
|
@ -358,6 +360,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
pdfWordHighlightEnabled,
|
pdfWordHighlightEnabled,
|
||||||
epubHighlightEnabled,
|
epubHighlightEnabled,
|
||||||
epubWordHighlightEnabled,
|
epubWordHighlightEnabled,
|
||||||
|
htmlHighlightEnabled,
|
||||||
|
htmlWordHighlightEnabled,
|
||||||
} = config || APP_CONFIG_DEFAULTS;
|
} = config || APP_CONFIG_DEFAULTS;
|
||||||
const providerType = useMemo(
|
const providerType = useMemo(
|
||||||
() => resolveEffectiveProviderType({
|
() => resolveEffectiveProviderType({
|
||||||
|
|
@ -491,7 +495,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
pdfHighlightEnabled,
|
pdfHighlightEnabled,
|
||||||
pdfWordHighlightEnabled,
|
pdfWordHighlightEnabled,
|
||||||
epubHighlightEnabled,
|
epubHighlightEnabled,
|
||||||
epubWordHighlightEnabled
|
epubWordHighlightEnabled,
|
||||||
|
htmlHighlightEnabled,
|
||||||
|
htmlWordHighlightEnabled,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</ConfigContext.Provider>
|
</ConfigContext.Provider>
|
||||||
|
|
|
||||||
|
|
@ -1036,6 +1036,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
|
|
||||||
// For PDFs and other documents, check page bounds
|
// For PDFs and other documents, check page bounds
|
||||||
if (!isEPUB) {
|
if (!isEPUB) {
|
||||||
|
// The HTML reader treats the entire document as a single page, so
|
||||||
|
// `currDocPages` is left `undefined`. In that mode there's nowhere to
|
||||||
|
// turn to — when we run past the last sentence, just stop playback.
|
||||||
|
// (We do this before the page-bound checks below so the `< undefined`
|
||||||
|
// / `>= undefined` NaN comparisons don't silently swallow the end.)
|
||||||
|
if (currDocPages === undefined) {
|
||||||
|
if (nextIndex >= sentences.length) {
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle next/previous page transitions
|
// Handle next/previous page transitions
|
||||||
if ((nextIndex >= sentences.length && currDocPageNumber < currDocPages!) ||
|
if ((nextIndex >= sentences.length && currDocPageNumber < currDocPages!) ||
|
||||||
(nextIndex < 0 && currDocPageNumber > 1)) {
|
(nextIndex < 0 && currDocPageNumber > 1)) {
|
||||||
|
|
|
||||||
126
src/lib/client/audiobooks/adapters/html.ts
Normal file
126
src/lib/client/audiobooks/adapters/html.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import type { AudiobookSourceAdapter, PreparedAudiobookChapter } from '@/lib/client/audiobooks/pipeline';
|
||||||
|
import { normalizeTextForTts } from '@/lib/shared/nlp';
|
||||||
|
import type { HtmlBlock } from '@/lib/client/html/blocks';
|
||||||
|
|
||||||
|
interface HtmlAudiobookAdapterOptions {
|
||||||
|
blocks: HtmlBlock[];
|
||||||
|
isTxt: boolean;
|
||||||
|
smartSentenceSplitting: boolean;
|
||||||
|
maxBlockLength?: number;
|
||||||
|
/**
|
||||||
|
* For markdown: any heading with `headingLevel <= chapterHeadingLevel`
|
||||||
|
* begins a new chapter. Defaults to 2 (h1/h2).
|
||||||
|
*/
|
||||||
|
chapterHeadingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
|
/**
|
||||||
|
* Fallback chapter size (in blocks) used when no headings exist (MD) or for
|
||||||
|
* TXT documents.
|
||||||
|
*/
|
||||||
|
fallbackBlocksPerChapter?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChapterDraft {
|
||||||
|
title: string;
|
||||||
|
blocks: HtmlBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChapterDrafts({
|
||||||
|
blocks,
|
||||||
|
isTxt,
|
||||||
|
chapterHeadingLevel = 2,
|
||||||
|
fallbackBlocksPerChapter = 50,
|
||||||
|
}: HtmlAudiobookAdapterOptions): ChapterDraft[] {
|
||||||
|
if (!blocks.length) return [];
|
||||||
|
|
||||||
|
if (!isTxt) {
|
||||||
|
const hasChapterHeadings = blocks.some(
|
||||||
|
(b) => b.kind === 'heading' && (b.headingLevel ?? 6) <= chapterHeadingLevel,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasChapterHeadings) {
|
||||||
|
const drafts: ChapterDraft[] = [];
|
||||||
|
let current: ChapterDraft | null = null;
|
||||||
|
const prelude: HtmlBlock[] = [];
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
const isChapterStart =
|
||||||
|
block.kind === 'heading' && (block.headingLevel ?? 6) <= chapterHeadingLevel;
|
||||||
|
|
||||||
|
if (isChapterStart) {
|
||||||
|
if (current) drafts.push(current);
|
||||||
|
current = {
|
||||||
|
title: block.headingText?.trim() || `Chapter ${drafts.length + 1}`,
|
||||||
|
blocks: [block],
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
prelude.push(block);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current.blocks.push(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prelude.length) {
|
||||||
|
drafts.unshift({ title: 'Introduction', blocks: prelude });
|
||||||
|
}
|
||||||
|
if (current) drafts.push(current);
|
||||||
|
return drafts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: chunk by N blocks
|
||||||
|
const drafts: ChapterDraft[] = [];
|
||||||
|
for (let i = 0; i < blocks.length; i += fallbackBlocksPerChapter) {
|
||||||
|
drafts.push({
|
||||||
|
title: `Part ${drafts.length + 1}`,
|
||||||
|
blocks: blocks.slice(i, i + fallbackBlocksPerChapter),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return drafts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chapterText(
|
||||||
|
draft: ChapterDraft,
|
||||||
|
smartSentenceSplitting: boolean,
|
||||||
|
maxBlockLength?: number,
|
||||||
|
): string {
|
||||||
|
const joined = draft.blocks
|
||||||
|
.map((b) => b.plainText)
|
||||||
|
.filter((t) => t && t.trim())
|
||||||
|
.join('\n\n');
|
||||||
|
if (!joined) return '';
|
||||||
|
return smartSentenceSplitting ? normalizeTextForTts(joined, { maxBlockLength }) : joined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preparedChapters(options: HtmlAudiobookAdapterOptions): PreparedAudiobookChapter[] {
|
||||||
|
const drafts = buildChapterDrafts(options);
|
||||||
|
const out: PreparedAudiobookChapter[] = [];
|
||||||
|
for (const draft of drafts) {
|
||||||
|
const text = chapterText(draft, options.smartSentenceSplitting, options.maxBlockLength);
|
||||||
|
if (!text.trim()) continue;
|
||||||
|
out.push({
|
||||||
|
index: out.length,
|
||||||
|
title: draft.title,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHtmlAudiobookSourceAdapter(options: HtmlAudiobookAdapterOptions): AudiobookSourceAdapter {
|
||||||
|
return {
|
||||||
|
noContentMessage: 'No text content found in document',
|
||||||
|
noAudioGeneratedMessage: 'No audio was generated from the document content',
|
||||||
|
prepareChapters: async () => preparedChapters(options),
|
||||||
|
prepareChapter: async (chapterIndex: number) => {
|
||||||
|
const chapters = preparedChapters(options);
|
||||||
|
const chapter = chapters[chapterIndex];
|
||||||
|
if (!chapter) {
|
||||||
|
throw new Error('Invalid chapter index');
|
||||||
|
}
|
||||||
|
return chapter;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -212,6 +212,10 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
|
||||||
raw.epubHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.epubHighlightEnabled,
|
raw.epubHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.epubHighlightEnabled,
|
||||||
epubWordHighlightEnabled:
|
epubWordHighlightEnabled:
|
||||||
raw.epubWordHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.epubWordHighlightEnabled,
|
raw.epubWordHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.epubWordHighlightEnabled,
|
||||||
|
htmlHighlightEnabled:
|
||||||
|
raw.htmlHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.htmlHighlightEnabled,
|
||||||
|
htmlWordHighlightEnabled:
|
||||||
|
raw.htmlWordHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.htmlWordHighlightEnabled,
|
||||||
firstVisit: raw.firstVisit === 'true',
|
firstVisit: raw.firstVisit === 'true',
|
||||||
documentListState,
|
documentListState,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
284
src/lib/client/html/blocks.ts
Normal file
284
src/lib/client/html/blocks.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
/**
|
||||||
|
* Block-level parser for markdown and plain-text documents used by the HTML
|
||||||
|
* reader. The reader assigns one stable anchor per top-level block so each
|
||||||
|
* block can become a TTS segment locator (`{ readerType: 'html', location }`)
|
||||||
|
* and so sentence/word highlights have a scoped DOM root.
|
||||||
|
*
|
||||||
|
* The parser is intentionally hand-rolled and line-based: we don't need a full
|
||||||
|
* mdast tree, just block boundaries plus a TTS-clean `plainText`. ReactMarkdown
|
||||||
|
* still renders the raw markdown for each block, so inline formatting
|
||||||
|
* (bold/italic/links/code) round-trips visually.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type HtmlBlockKind =
|
||||||
|
| 'heading'
|
||||||
|
| 'paragraph'
|
||||||
|
| 'list'
|
||||||
|
| 'blockquote'
|
||||||
|
| 'code'
|
||||||
|
| 'table'
|
||||||
|
| 'hr';
|
||||||
|
|
||||||
|
export interface HtmlBlock {
|
||||||
|
index: number;
|
||||||
|
anchorId: string;
|
||||||
|
kind: HtmlBlockKind;
|
||||||
|
raw: string;
|
||||||
|
plainText: string;
|
||||||
|
headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
|
headingText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEADING_RE = /^(#{1,6})\s+(.*)$/;
|
||||||
|
const SETEXT_H1_RE = /^=+\s*$/;
|
||||||
|
const SETEXT_H2_RE = /^-{2,}\s*$/;
|
||||||
|
const HR_RE = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
|
||||||
|
const FENCE_RE = /^(\s*)(```+|~~~+)(.*)$/;
|
||||||
|
const BLOCKQUOTE_RE = /^\s{0,3}>/;
|
||||||
|
const LIST_RE = /^\s*(?:[-*+]|\d+[.)])\s+/;
|
||||||
|
const TABLE_SEP_RE = /^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)+\|?\s*$/;
|
||||||
|
const TABLE_ROW_RE = /^\s*\|.*\|\s*$/;
|
||||||
|
|
||||||
|
export function anchorIdForIndex(index: number): string {
|
||||||
|
return `b-${index.toString().padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitMarkdownBlocks(source: string): HtmlBlock[] {
|
||||||
|
const lines = source.replace(/\r\n?/g, '\n').split('\n');
|
||||||
|
const blocks: HtmlBlock[] = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
const pushBlock = (
|
||||||
|
kind: HtmlBlockKind,
|
||||||
|
rawLines: string[],
|
||||||
|
extra: Partial<Pick<HtmlBlock, 'headingLevel' | 'headingText'>> = {},
|
||||||
|
) => {
|
||||||
|
const raw = rawLines.join('\n').replace(/\s+$/u, '');
|
||||||
|
if (!raw) return;
|
||||||
|
const plainText = mdToPlainText(raw, kind);
|
||||||
|
const index = blocks.length;
|
||||||
|
blocks.push({
|
||||||
|
index,
|
||||||
|
anchorId: anchorIdForIndex(index),
|
||||||
|
kind,
|
||||||
|
raw,
|
||||||
|
plainText,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
if (!line.trim()) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fence = FENCE_RE.exec(line);
|
||||||
|
if (fence) {
|
||||||
|
const marker = fence[2];
|
||||||
|
const start = i;
|
||||||
|
i += 1;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const ll = lines[i];
|
||||||
|
const closing = FENCE_RE.exec(ll);
|
||||||
|
i += 1;
|
||||||
|
if (closing && closing[2].startsWith(marker[0]) && closing[2].length >= marker.length && !closing[3].trim()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pushBlock('code', lines.slice(start, i));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HR_RE.test(line)) {
|
||||||
|
pushBlock('hr', [line]);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = HEADING_RE.exec(line);
|
||||||
|
if (heading) {
|
||||||
|
const level = Math.min(6, heading[1].length) as 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
|
const text = heading[2].replace(/\s+#+\s*$/, '').trim();
|
||||||
|
pushBlock('heading', [line], { headingLevel: level, headingText: text });
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setext heading: previous-line text + underline of = or -
|
||||||
|
if (i + 1 < lines.length && lines[i].trim() && (SETEXT_H1_RE.test(lines[i + 1]) || SETEXT_H2_RE.test(lines[i + 1]))) {
|
||||||
|
const level: 1 | 2 = SETEXT_H1_RE.test(lines[i + 1]) ? 1 : 2;
|
||||||
|
const text = lines[i].trim();
|
||||||
|
pushBlock('heading', [lines[i], lines[i + 1]], { headingLevel: level, headingText: text });
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BLOCKQUOTE_RE.test(line)) {
|
||||||
|
const start = i;
|
||||||
|
while (i < lines.length && (BLOCKQUOTE_RE.test(lines[i]) || (lines[i].trim() && !HEADING_RE.test(lines[i]) && !HR_RE.test(lines[i])))) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
pushBlock('blockquote', lines.slice(start, i));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LIST_RE.test(line)) {
|
||||||
|
const start = i;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const ll = lines[i];
|
||||||
|
if (!ll.trim()) {
|
||||||
|
// peek: if next line continues the list, include the blank line; otherwise stop
|
||||||
|
if (i + 1 < lines.length && (LIST_RE.test(lines[i + 1]) || /^\s+\S/.test(lines[i + 1]))) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (LIST_RE.test(ll) || /^\s+\S/.test(ll)) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pushBlock('list', lines.slice(start, i));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TABLE_ROW_RE.test(line) && i + 1 < lines.length && TABLE_SEP_RE.test(lines[i + 1])) {
|
||||||
|
const start = i;
|
||||||
|
i += 2;
|
||||||
|
while (i < lines.length && TABLE_ROW_RE.test(lines[i])) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
pushBlock('table', lines.slice(start, i));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph: consume until blank line or a new block-level construct
|
||||||
|
const start = i;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const ll = lines[i];
|
||||||
|
if (!ll.trim()) break;
|
||||||
|
if (HEADING_RE.test(ll) || HR_RE.test(ll) || FENCE_RE.test(ll) || BLOCKQUOTE_RE.test(ll) || LIST_RE.test(ll)) break;
|
||||||
|
if (i + 1 < lines.length && (SETEXT_H1_RE.test(lines[i + 1]) || SETEXT_H2_RE.test(lines[i + 1])) && i > start) break;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
pushBlock('paragraph', lines.slice(start, i));
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitTxtBlocks(source: string): HtmlBlock[] {
|
||||||
|
const normalized = source.replace(/\r\n?/g, '\n');
|
||||||
|
const rawBlocks = normalized.split(/\n{2,}/);
|
||||||
|
const blocks: HtmlBlock[] = [];
|
||||||
|
for (const raw of rawBlocks) {
|
||||||
|
const trimmed = raw.replace(/\s+$/u, '');
|
||||||
|
if (!trimmed.trim()) continue;
|
||||||
|
const index = blocks.length;
|
||||||
|
blocks.push({
|
||||||
|
index,
|
||||||
|
anchorId: anchorIdForIndex(index),
|
||||||
|
kind: 'paragraph',
|
||||||
|
raw: trimmed,
|
||||||
|
plainText: trimmed.replace(/\s+/g, ' ').trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHtmlBlocks(source: string, isTxt: boolean): HtmlBlock[] {
|
||||||
|
return isTxt ? splitTxtBlocks(source) : splitMarkdownBlocks(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip markdown formatting from a block to produce text suitable for TTS.
|
||||||
|
*
|
||||||
|
* This is intentionally conservative — we keep semantic words but remove
|
||||||
|
* structural noise (heading hashes, list bullets, fence markers, link URLs).
|
||||||
|
*/
|
||||||
|
export function mdToPlainText(raw: string, kind: HtmlBlockKind): string {
|
||||||
|
if (kind === 'hr') return '';
|
||||||
|
|
||||||
|
let text = raw;
|
||||||
|
|
||||||
|
if (kind === 'code') {
|
||||||
|
text = text
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => !FENCE_RE.test(line))
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'heading') {
|
||||||
|
text = text.replace(HEADING_RE, '$2').replace(/\s+#+\s*$/, '');
|
||||||
|
// setext form: drop the underline line
|
||||||
|
text = text
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => !SETEXT_H1_RE.test(line) && !SETEXT_H2_RE.test(line))
|
||||||
|
.join('\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'blockquote') {
|
||||||
|
text = text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(/^\s{0,3}>\s?/, ''))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'list') {
|
||||||
|
text = text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(LIST_RE, '').replace(/^\s+/, ''))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'table') {
|
||||||
|
text = text
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => !TABLE_SEP_RE.test(line))
|
||||||
|
.map((line) => line.replace(/^\s*\|/, '').replace(/\|\s*$/, '').replace(/\|/g, ' '))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline markdown stripping (run for every kind except code, where contents
|
||||||
|
// are read verbatim).
|
||||||
|
if (kind !== 'code') {
|
||||||
|
text = stripInlineMarkdown(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripInlineMarkdown(text: string): string {
|
||||||
|
return text
|
||||||
|
// Image-link wrappers: [](https://...) — common for
|
||||||
|
// shields/license/CI badges. The visible DOM is just an `<a><img></a>`
|
||||||
|
// with no text node, so keeping the alt text in `plainText` would cause
|
||||||
|
// TTS to read words ("GitHub License", "build passing", …) that the
|
||||||
|
// sentence-highlight pattern matcher can't find in the rendered DOM,
|
||||||
|
// and the whole first-segment match falls below threshold. Drop them.
|
||||||
|
.replace(/\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)/g, '')
|
||||||
|
// Standalone images:  — same reasoning. The browser renders
|
||||||
|
// an `<img>` with no text content; alt is only read by AT, not TTS.
|
||||||
|
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
||||||
|
// Reference-style images: ![alt][ref] — drop for the same reason.
|
||||||
|
.replace(/!\[[^\]]*\]\[[^\]]*\]/g, '')
|
||||||
|
// links: [label](url) → label (label IS rendered as visible link text)
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
||||||
|
// reference-style links: [label][ref] → label
|
||||||
|
.replace(/\[([^\]]+)\]\[[^\]]*\]/g, '$1')
|
||||||
|
// bold/italic markers around words: **x**, *x*, __x__, _x_
|
||||||
|
.replace(/(\*\*|__)(.+?)\1/g, '$2')
|
||||||
|
.replace(/(\*|_)(.+?)\1/g, '$2')
|
||||||
|
// inline code: `x` → x
|
||||||
|
.replace(/`([^`]+)`/g, '$1')
|
||||||
|
// strikethrough: ~~x~~ → x
|
||||||
|
.replace(/~~(.+?)~~/g, '$1')
|
||||||
|
// stray html tags (also strips inline <img> / <svg> / <picture>)
|
||||||
|
.replace(/<[^>]+>/g, '');
|
||||||
|
}
|
||||||
441
src/lib/client/html/highlight.ts
Normal file
441
src/lib/client/html/highlight.ts
Normal file
|
|
@ -0,0 +1,441 @@
|
||||||
|
/**
|
||||||
|
* HTML / TXT / MD reader highlight layer.
|
||||||
|
*
|
||||||
|
* Wraps text in `<span class="openreader-html-highlight-...">` so the visible
|
||||||
|
* background change works in every browser (no CSS Custom Highlight API
|
||||||
|
* dependency). The HTMLViewer renders the document once and never re-runs
|
||||||
|
* ReactMarkdown until a new doc is loaded, so wrapping DOM nodes is safe.
|
||||||
|
*
|
||||||
|
* Two layers:
|
||||||
|
* - `SENTENCE` — softer translucent background covering the current TTS
|
||||||
|
* sentence
|
||||||
|
* - `WORD` — saturated background on the currently-spoken word
|
||||||
|
*
|
||||||
|
* Word-to-DOM alignment is done via Needleman-Wunsch (same approach the PDF
|
||||||
|
* reader uses) so DOM token counts that diverge from whisper's word count
|
||||||
|
* still produce a smooth, monotonic word highlight rather than a proportional
|
||||||
|
* approximation that snaps around when the counts disagree.
|
||||||
|
*/
|
||||||
|
import { CmpStr } from 'cmpstr';
|
||||||
|
import type { TTSSentenceAlignment } from '@/types/tts';
|
||||||
|
|
||||||
|
export const HTML_SENTENCE_CLASS = 'openreader-html-highlight-sentence';
|
||||||
|
export const HTML_WORD_CLASS = 'openreader-html-highlight-word';
|
||||||
|
|
||||||
|
interface DomToken {
|
||||||
|
textNode: Text;
|
||||||
|
startOffset: number;
|
||||||
|
endOffset: number;
|
||||||
|
norm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
|
||||||
|
|
||||||
|
let sentenceWraps: HTMLSpanElement[] = [];
|
||||||
|
let wordWraps: HTMLSpanElement[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-sentence state used by the word highlighter. Built once when the
|
||||||
|
* sentence wrap is applied and then read by every word-advance event, so we
|
||||||
|
* don't re-walk the DOM or re-run the DP on every whisper tick.
|
||||||
|
*/
|
||||||
|
interface SentenceState {
|
||||||
|
sentence: string;
|
||||||
|
// DOM tokens inside the wrapped sentence, captured AFTER the sentence wrap
|
||||||
|
// is in place. Stable across word wrap/unwrap cycles because clear() calls
|
||||||
|
// `parent.normalize()` which restores the original text-node structure.
|
||||||
|
wordTokens: DomToken[];
|
||||||
|
// For an alignment we've already seen, the cached wordIndex → tokenIndex map.
|
||||||
|
alignment: TTSSentenceAlignment | null;
|
||||||
|
wordToToken: number[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sentenceState: SentenceState | null = null;
|
||||||
|
|
||||||
|
function normalizeWord(word: string): string {
|
||||||
|
return word
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\p{P}\p{S}]+/gu, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizePattern(pattern: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const wordRe = /\S+/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = wordRe.exec(pattern)) !== null) {
|
||||||
|
const norm = normalizeWord(m[0]);
|
||||||
|
if (norm) out.push(norm);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrap(span: HTMLSpanElement): void {
|
||||||
|
const parent = span.parentNode;
|
||||||
|
if (!parent) return;
|
||||||
|
while (span.firstChild) {
|
||||||
|
parent.insertBefore(span.firstChild, span);
|
||||||
|
}
|
||||||
|
parent.removeChild(span);
|
||||||
|
if (typeof (parent as Element).normalize === 'function') {
|
||||||
|
(parent as Element).normalize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHtmlSentenceHighlight(): void {
|
||||||
|
// Word wraps live inside sentence wraps. Tear them down first so we don't
|
||||||
|
// orphan them in the DOM when the sentence wrap is unwrapped — otherwise
|
||||||
|
// collectDomTokens would later refuse to walk those text nodes (they'd
|
||||||
|
// still be inside a highlight-class span) and the NEXT sentence highlight
|
||||||
|
// would silently miss matching tokens.
|
||||||
|
clearHtmlWordHighlight();
|
||||||
|
for (const span of sentenceWraps) unwrap(span);
|
||||||
|
sentenceWraps = [];
|
||||||
|
// A sentence clear also invalidates the word-mapping cache; the new
|
||||||
|
// sentence will get its own state when highlightHtmlSentence runs again.
|
||||||
|
sentenceState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHtmlWordHighlight(): void {
|
||||||
|
for (const span of wordWraps) unwrap(span);
|
||||||
|
wordWraps = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHighlightWrapper(node: Node | null): node is HTMLSpanElement {
|
||||||
|
if (!node || node.nodeType !== Node.ELEMENT_NODE) return false;
|
||||||
|
const el = node as Element;
|
||||||
|
if (el.tagName !== 'SPAN') return false;
|
||||||
|
return el.classList.contains(HTML_SENTENCE_CLASS) || el.classList.contains(HTML_WORD_CLASS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDomTokens(root: HTMLElement, opts: { skipHighlightWraps: boolean } = { skipHighlightWraps: true }): DomToken[] {
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||||
|
acceptNode(node) {
|
||||||
|
const parent = node.parentElement;
|
||||||
|
if (!parent) return NodeFilter.FILTER_REJECT;
|
||||||
|
const tag = parent.tagName;
|
||||||
|
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT') return NodeFilter.FILTER_REJECT;
|
||||||
|
if (opts.skipHighlightWraps) {
|
||||||
|
let ancestor: Node | null = parent;
|
||||||
|
while (ancestor && ancestor !== root) {
|
||||||
|
if (isHighlightWrapper(ancestor)) return NodeFilter.FILTER_REJECT;
|
||||||
|
ancestor = ancestor.parentNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node.nodeValue && node.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokens: DomToken[] = [];
|
||||||
|
let current: Node | null = walker.nextNode();
|
||||||
|
while (current) {
|
||||||
|
const textNode = current as Text;
|
||||||
|
const text = textNode.nodeValue || '';
|
||||||
|
const wordRe = /\S+/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = wordRe.exec(text)) !== null) {
|
||||||
|
const norm = normalizeWord(m[0]);
|
||||||
|
if (!norm) continue;
|
||||||
|
tokens.push({
|
||||||
|
textNode,
|
||||||
|
startOffset: m.index,
|
||||||
|
endOffset: m.index + m[0].length,
|
||||||
|
norm,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
current = walker.nextNode();
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk only inside the current sentence wrap spans (used after the sentence
|
||||||
|
* wrap is applied; lets us index just the words *within* the highlighted
|
||||||
|
* sentence rather than the whole document).
|
||||||
|
*/
|
||||||
|
function collectTokensInsideWraps(wraps: HTMLSpanElement[]): DomToken[] {
|
||||||
|
const tokens: DomToken[] = [];
|
||||||
|
for (const wrap of wraps) {
|
||||||
|
const walker = document.createTreeWalker(wrap, NodeFilter.SHOW_TEXT);
|
||||||
|
let current: Node | null = walker.nextNode();
|
||||||
|
while (current) {
|
||||||
|
const t = current as Text;
|
||||||
|
const text = t.nodeValue || '';
|
||||||
|
const wordRe = /\S+/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = wordRe.exec(text)) !== null) {
|
||||||
|
const norm = normalizeWord(m[0]);
|
||||||
|
if (!norm) continue;
|
||||||
|
tokens.push({
|
||||||
|
textNode: t,
|
||||||
|
startOffset: m.index,
|
||||||
|
endOffset: m.index + m[0].length,
|
||||||
|
norm,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
current = walker.nextNode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBestWindow(tokens: DomToken[], patternTokens: string[]): { start: number; end: number } | null {
|
||||||
|
if (!tokens.length || !patternTokens.length) return null;
|
||||||
|
const pLen = patternTokens.length;
|
||||||
|
|
||||||
|
let bestStart = -1;
|
||||||
|
let bestEnd = -1;
|
||||||
|
let bestScore = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i + Math.max(1, Math.ceil(pLen * 0.5)) - 1 < tokens.length; i += 1) {
|
||||||
|
if (tokens[i].norm !== patternTokens[0]) continue;
|
||||||
|
let matches = 1;
|
||||||
|
let domCursor = i + 1;
|
||||||
|
for (let p = 1; p < pLen && domCursor < tokens.length; p += 1) {
|
||||||
|
let stepped = false;
|
||||||
|
for (let k = 0; k < 3 && domCursor + k < tokens.length; k += 1) {
|
||||||
|
if (tokens[domCursor + k].norm === patternTokens[p]) {
|
||||||
|
matches += 1;
|
||||||
|
domCursor += k + 1;
|
||||||
|
stepped = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!stepped) domCursor += 1;
|
||||||
|
}
|
||||||
|
const end = Math.min(tokens.length - 1, domCursor - 1);
|
||||||
|
const score = matches / pLen;
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestStart = i;
|
||||||
|
bestEnd = end;
|
||||||
|
if (score >= 0.95) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestScore >= 0.5 && bestStart !== -1) {
|
||||||
|
return { start: bestStart, end: bestEnd };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapTokenRange(tokens: DomToken[], start: number, end: number, className: string): HTMLSpanElement[] {
|
||||||
|
const perNode = new Map<Text, { start: number; end: number }>();
|
||||||
|
for (let i = start; i <= end; i += 1) {
|
||||||
|
const t = tokens[i];
|
||||||
|
const existing = perNode.get(t.textNode);
|
||||||
|
if (existing) {
|
||||||
|
existing.start = Math.min(existing.start, t.startOffset);
|
||||||
|
existing.end = Math.max(existing.end, t.endOffset);
|
||||||
|
} else {
|
||||||
|
perNode.set(t.textNode, { start: t.startOffset, end: t.endOffset });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wraps: HTMLSpanElement[] = [];
|
||||||
|
for (const [textNode, { start: s, end: e }] of perNode) {
|
||||||
|
const parent = textNode.parentNode;
|
||||||
|
if (!parent) continue;
|
||||||
|
try {
|
||||||
|
let target: Text = textNode;
|
||||||
|
if (s > 0) {
|
||||||
|
target = target.splitText(s);
|
||||||
|
}
|
||||||
|
const innerLen = e - s;
|
||||||
|
if (innerLen < target.length) {
|
||||||
|
target.splitText(innerLen);
|
||||||
|
}
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = className;
|
||||||
|
parent.insertBefore(span, target);
|
||||||
|
span.appendChild(target);
|
||||||
|
wraps.push(span);
|
||||||
|
} catch {
|
||||||
|
// skip any text node that can't be split (already wrapped, detached, etc.)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wraps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightHtmlSentence(
|
||||||
|
container: HTMLElement | null | undefined,
|
||||||
|
sentence: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
clearHtmlSentenceHighlight();
|
||||||
|
if (!container || !sentence?.trim()) return false;
|
||||||
|
|
||||||
|
const patternTokens = tokenizePattern(sentence);
|
||||||
|
if (!patternTokens.length) return false;
|
||||||
|
|
||||||
|
const domTokens = collectDomTokens(container);
|
||||||
|
if (!domTokens.length) return false;
|
||||||
|
|
||||||
|
const win = findBestWindow(domTokens, patternTokens);
|
||||||
|
if (!win) return false;
|
||||||
|
|
||||||
|
sentenceWraps = wrapTokenRange(domTokens, win.start, win.end, HTML_SENTENCE_CLASS);
|
||||||
|
if (!sentenceWraps.length) return false;
|
||||||
|
|
||||||
|
// Capture the per-token DOM map AFTER the sentence wrap is in place so we
|
||||||
|
// can look up individual word tokens without re-walking the doc.
|
||||||
|
sentenceState = {
|
||||||
|
sentence,
|
||||||
|
wordTokens: collectTokensInsideWraps(sentenceWraps),
|
||||||
|
alignment: null,
|
||||||
|
wordToToken: null,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a wordIndex → tokenIndex map via Needleman-Wunsch alignment between
|
||||||
|
* whisper's word list and the DOM tokens inside the sentence. Mirrors the
|
||||||
|
* approach in `src/lib/client/pdf.ts#highlightWordIndex` so PDF and HTML
|
||||||
|
* highlights behave the same way under count mismatches (contractions,
|
||||||
|
* stripped punctuation, missing whitespace, etc.).
|
||||||
|
*/
|
||||||
|
function buildAlignmentMap(
|
||||||
|
alignment: TTSSentenceAlignment,
|
||||||
|
domTokens: DomToken[],
|
||||||
|
): number[] {
|
||||||
|
const words = alignment.words || [];
|
||||||
|
const wordToToken = new Array<number>(words.length).fill(-1);
|
||||||
|
|
||||||
|
const domFiltered: { tokenIndex: number; norm: string }[] = [];
|
||||||
|
for (let i = 0; i < domTokens.length; i += 1) {
|
||||||
|
const norm = domTokens[i].norm;
|
||||||
|
if (norm) domFiltered.push({ tokenIndex: i, norm });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttsFiltered: { wordIndex: number; norm: string }[] = [];
|
||||||
|
for (let i = 0; i < words.length; i += 1) {
|
||||||
|
const norm = normalizeWord(words[i].text);
|
||||||
|
if (norm) ttsFiltered.push({ wordIndex: i, norm });
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = domFiltered.length;
|
||||||
|
const n = ttsFiltered.length;
|
||||||
|
if (!m || !n) return wordToToken;
|
||||||
|
|
||||||
|
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||||
|
new Array<number>(n + 1).fill(Number.POSITIVE_INFINITY),
|
||||||
|
);
|
||||||
|
const bt: number[][] = Array.from({ length: m + 1 }, () =>
|
||||||
|
new Array<number>(n + 1).fill(0),
|
||||||
|
); // 0=diag (substitute), 1=up (skip dom), 2=left (skip tts)
|
||||||
|
|
||||||
|
dp[0][0] = 0;
|
||||||
|
const GAP_COST = 0.7;
|
||||||
|
|
||||||
|
for (let i = 0; i <= m; i += 1) {
|
||||||
|
for (let j = 0; j <= n; j += 1) {
|
||||||
|
if (i > 0 && j > 0) {
|
||||||
|
const a = domFiltered[i - 1].norm;
|
||||||
|
const b = ttsFiltered[j - 1].norm;
|
||||||
|
const sim = a === b ? 1 : cmp.compare(a, b);
|
||||||
|
const cand = dp[i - 1][j - 1] + (1 - sim);
|
||||||
|
if (cand < dp[i][j]) {
|
||||||
|
dp[i][j] = cand;
|
||||||
|
bt[i][j] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i > 0) {
|
||||||
|
const cand = dp[i - 1][j] + GAP_COST;
|
||||||
|
if (cand < dp[i][j]) {
|
||||||
|
dp[i][j] = cand;
|
||||||
|
bt[i][j] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (j > 0) {
|
||||||
|
const cand = dp[i][j - 1] + GAP_COST;
|
||||||
|
if (cand < dp[i][j]) {
|
||||||
|
dp[i][j] = cand;
|
||||||
|
bt[i][j] = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let i = m;
|
||||||
|
let j = n;
|
||||||
|
while (i > 0 || j > 0) {
|
||||||
|
const move = bt[i][j];
|
||||||
|
if (i > 0 && j > 0 && move === 0) {
|
||||||
|
const domIdx = domFiltered[i - 1].tokenIndex;
|
||||||
|
const ttsIdx = ttsFiltered[j - 1].wordIndex;
|
||||||
|
if (wordToToken[ttsIdx] === -1) wordToToken[ttsIdx] = domIdx;
|
||||||
|
i -= 1;
|
||||||
|
j -= 1;
|
||||||
|
} else if (i > 0 && (move === 1 || j === 0)) {
|
||||||
|
i -= 1;
|
||||||
|
} else if (j > 0 && (move === 2 || i === 0)) {
|
||||||
|
j -= 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward-fill, then backward-fill, so every wordIndex has a nearest known
|
||||||
|
// DOM token. This keeps the word highlight stable when whisper emits a
|
||||||
|
// word that didn't survive normalization (e.g. an apostrophe-only token).
|
||||||
|
let lastSeen = -1;
|
||||||
|
for (let k = 0; k < wordToToken.length; k += 1) {
|
||||||
|
if (wordToToken[k] !== -1) lastSeen = wordToToken[k];
|
||||||
|
else if (lastSeen !== -1) wordToToken[k] = lastSeen;
|
||||||
|
}
|
||||||
|
let nextSeen = -1;
|
||||||
|
for (let k = wordToToken.length - 1; k >= 0; k -= 1) {
|
||||||
|
if (wordToToken[k] !== -1) nextSeen = wordToToken[k];
|
||||||
|
else if (nextSeen !== -1) wordToToken[k] = nextSeen;
|
||||||
|
}
|
||||||
|
|
||||||
|
return wordToToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightHtmlWord(
|
||||||
|
container: HTMLElement | null | undefined,
|
||||||
|
alignment: TTSSentenceAlignment | undefined,
|
||||||
|
wordIndex: number | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
// Always tear down the previous word wrap first. The `unwrap` call
|
||||||
|
// normalizes the parent, restoring the post-sentence-wrap text-node
|
||||||
|
// structure that `sentenceState.wordTokens` points at, so the cached map
|
||||||
|
// stays valid across consecutive word advances.
|
||||||
|
clearHtmlWordHighlight();
|
||||||
|
if (!container || !alignment) return false;
|
||||||
|
if (wordIndex === null || wordIndex === undefined || wordIndex < 0) return false;
|
||||||
|
if (!sentenceState || !sentenceState.wordTokens.length) return false;
|
||||||
|
if (!sentenceWraps.length) return false;
|
||||||
|
|
||||||
|
const words = alignment.words || [];
|
||||||
|
if (!words.length || wordIndex >= words.length) return false;
|
||||||
|
|
||||||
|
// (Re)build the alignment map when this is a new alignment object.
|
||||||
|
if (sentenceState.alignment !== alignment || !sentenceState.wordToToken) {
|
||||||
|
sentenceState.alignment = alignment;
|
||||||
|
sentenceState.wordToToken = buildAlignmentMap(alignment, sentenceState.wordTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenIndex = sentenceState.wordToToken[wordIndex];
|
||||||
|
if (tokenIndex === undefined || tokenIndex < 0) return false;
|
||||||
|
if (tokenIndex >= sentenceState.wordTokens.length) return false;
|
||||||
|
|
||||||
|
wordWraps = wrapTokenRange(sentenceState.wordTokens, tokenIndex, tokenIndex, HTML_WORD_CLASS);
|
||||||
|
return wordWraps.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll the first sentence wrapper into view if it's not already visible.
|
||||||
|
* Cheap idempotent — call after highlightHtmlSentence.
|
||||||
|
*/
|
||||||
|
export function scrollSentenceIntoView(container: HTMLElement | null | undefined): void {
|
||||||
|
if (!container || !sentenceWraps.length) return;
|
||||||
|
const first = sentenceWraps[0];
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const rect = first.getBoundingClientRect();
|
||||||
|
const above = rect.top < containerRect.top + 40;
|
||||||
|
const below = rect.bottom > containerRect.bottom - 40;
|
||||||
|
if (above || below) {
|
||||||
|
first.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -115,6 +115,14 @@ export function compareSegmentLocators(
|
||||||
return Math.floor(a.page) - Math.floor(b.page);
|
return Math.floor(a.page) - Math.floor(b.page);
|
||||||
}
|
}
|
||||||
if (isHtmlLocator(a) && isHtmlLocator(b)) {
|
if (isHtmlLocator(a) && isHtmlLocator(b)) {
|
||||||
|
// When both locations look like positive integers (HTML reader blocks),
|
||||||
|
// compare numerically so "10" sorts after "2" instead of between "1" and
|
||||||
|
// "2". Falls back to lexicographic for legacy free-form locations.
|
||||||
|
const an = /^\d+$/.test(a.location) ? Number(a.location) : NaN;
|
||||||
|
const bn = /^\d+$/.test(b.location) ? Number(b.location) : NaN;
|
||||||
|
if (Number.isFinite(an) && Number.isFinite(bn)) {
|
||||||
|
return an - bn;
|
||||||
|
}
|
||||||
return a.location.localeCompare(b.location);
|
return a.location.localeCompare(b.location);
|
||||||
}
|
}
|
||||||
// One or both are legacy/draft — fall back to grouped-key compare so the
|
// One or both are legacy/draft — fall back to grouped-key compare so the
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,8 @@ export interface AppConfigValues {
|
||||||
pdfWordHighlightEnabled: boolean;
|
pdfWordHighlightEnabled: boolean;
|
||||||
epubHighlightEnabled: boolean;
|
epubHighlightEnabled: boolean;
|
||||||
epubWordHighlightEnabled: boolean;
|
epubWordHighlightEnabled: boolean;
|
||||||
|
htmlHighlightEnabled: boolean;
|
||||||
|
htmlWordHighlightEnabled: boolean;
|
||||||
firstVisit: boolean;
|
firstVisit: boolean;
|
||||||
documentListState: DocumentListState;
|
documentListState: DocumentListState;
|
||||||
privacyAccepted: boolean;
|
privacyAccepted: boolean;
|
||||||
|
|
@ -127,6 +129,8 @@ export function getAppConfigDefaults(): AppConfigValues {
|
||||||
pdfWordHighlightEnabled: wordHighlightEnabledByDefault,
|
pdfWordHighlightEnabled: wordHighlightEnabledByDefault,
|
||||||
epubHighlightEnabled: true,
|
epubHighlightEnabled: true,
|
||||||
epubWordHighlightEnabled: wordHighlightEnabledByDefault,
|
epubWordHighlightEnabled: wordHighlightEnabledByDefault,
|
||||||
|
htmlHighlightEnabled: true,
|
||||||
|
htmlWordHighlightEnabled: wordHighlightEnabledByDefault,
|
||||||
firstVisit: false,
|
firstVisit: false,
|
||||||
documentListState: {
|
documentListState: {
|
||||||
sortBy: 'name',
|
sortBy: 'name',
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ export const SYNCED_PREFERENCE_KEYS = [
|
||||||
'pdfWordHighlightEnabled',
|
'pdfWordHighlightEnabled',
|
||||||
'epubHighlightEnabled',
|
'epubHighlightEnabled',
|
||||||
'epubWordHighlightEnabled',
|
'epubWordHighlightEnabled',
|
||||||
|
'htmlHighlightEnabled',
|
||||||
|
'htmlWordHighlightEnabled',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type SyncedPreferenceKey = (typeof SYNCED_PREFERENCE_KEYS)[number];
|
export type SyncedPreferenceKey = (typeof SYNCED_PREFERENCE_KEYS)[number];
|
||||||
|
|
|
||||||
162
tests/unit/html-audiobook-adapter.spec.ts
Normal file
162
tests/unit/html-audiobook-adapter.spec.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { createHtmlAudiobookSourceAdapter } from '../../src/lib/client/audiobooks/adapters/html';
|
||||||
|
import { parseHtmlBlocks, type HtmlBlock } from '../../src/lib/client/html/blocks';
|
||||||
|
|
||||||
|
const blocksFromMd = (src: string): HtmlBlock[] => parseHtmlBlocks(src, false);
|
||||||
|
const blocksFromTxt = (src: string): HtmlBlock[] => parseHtmlBlocks(src, true);
|
||||||
|
|
||||||
|
test.describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', () => {
|
||||||
|
test('starts a new chapter at each h1/h2 heading by default', async () => {
|
||||||
|
const blocks = blocksFromMd(
|
||||||
|
[
|
||||||
|
'# Alpha',
|
||||||
|
'',
|
||||||
|
'First chapter body.',
|
||||||
|
'',
|
||||||
|
'## Beta',
|
||||||
|
'',
|
||||||
|
'Second chapter body.',
|
||||||
|
'',
|
||||||
|
'### Gamma',
|
||||||
|
'',
|
||||||
|
'Subsection — should NOT begin a new chapter (h3 by default).',
|
||||||
|
'',
|
||||||
|
'## Delta',
|
||||||
|
'',
|
||||||
|
'Third chapter body.',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const adapter = createHtmlAudiobookSourceAdapter({
|
||||||
|
blocks,
|
||||||
|
isTxt: false,
|
||||||
|
smartSentenceSplitting: false,
|
||||||
|
});
|
||||||
|
const chapters = await adapter.prepareChapters();
|
||||||
|
expect(chapters.map((c) => c.title)).toEqual(['Alpha', 'Beta', 'Delta']);
|
||||||
|
// Subsection content lands inside its parent chapter (Beta).
|
||||||
|
expect(chapters[1].text).toContain('Subsection');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats blocks before the first heading as an "Introduction" chapter', async () => {
|
||||||
|
const blocks = blocksFromMd(
|
||||||
|
['Top-of-doc paragraph.', '', '# First Heading', '', 'Body.'].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const adapter = createHtmlAudiobookSourceAdapter({
|
||||||
|
blocks,
|
||||||
|
isTxt: false,
|
||||||
|
smartSentenceSplitting: false,
|
||||||
|
});
|
||||||
|
const chapters = await adapter.prepareChapters();
|
||||||
|
expect(chapters.map((c) => c.title)).toEqual(['Introduction', 'First Heading']);
|
||||||
|
expect(chapters[0].text).toContain('Top-of-doc paragraph.');
|
||||||
|
expect(chapters[1].text).toContain('Body.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to "Part N" chunks when markdown has no chapter-level headings', async () => {
|
||||||
|
// No #/## headings → fallback path. Use a small fallback size for the
|
||||||
|
// test so we don't have to generate 50+ blocks.
|
||||||
|
const blocks = blocksFromMd(
|
||||||
|
Array.from({ length: 7 }, (_, i) => `Paragraph ${i + 1}.`).join('\n\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const adapter = createHtmlAudiobookSourceAdapter({
|
||||||
|
blocks,
|
||||||
|
isTxt: false,
|
||||||
|
smartSentenceSplitting: false,
|
||||||
|
fallbackBlocksPerChapter: 3,
|
||||||
|
});
|
||||||
|
const chapters = await adapter.prepareChapters();
|
||||||
|
expect(chapters.map((c) => c.title)).toEqual(['Part 1', 'Part 2', 'Part 3']);
|
||||||
|
// 7 blocks / 3 per chapter → 3, 3, 1.
|
||||||
|
expect(chapters[0].text).toContain('Paragraph 1.');
|
||||||
|
expect(chapters[0].text).toContain('Paragraph 3.');
|
||||||
|
expect(chapters[1].text).toContain('Paragraph 4.');
|
||||||
|
expect(chapters[1].text).toContain('Paragraph 6.');
|
||||||
|
expect(chapters[2].text).toContain('Paragraph 7.');
|
||||||
|
expect(chapters[2].text).not.toContain('Paragraph 6.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honors a custom chapterHeadingLevel (e.g. h1 only)', async () => {
|
||||||
|
const blocks = blocksFromMd(
|
||||||
|
['# Alpha', '', 'A body.', '', '## Beta', '', 'B body.', '', '# Gamma', '', 'G body.'].join(
|
||||||
|
'\n',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const adapter = createHtmlAudiobookSourceAdapter({
|
||||||
|
blocks,
|
||||||
|
isTxt: false,
|
||||||
|
smartSentenceSplitting: false,
|
||||||
|
chapterHeadingLevel: 1,
|
||||||
|
});
|
||||||
|
const chapters = await adapter.prepareChapters();
|
||||||
|
// h2 (Beta) is absorbed into the Alpha chapter when only h1 starts chapters.
|
||||||
|
expect(chapters.map((c) => c.title)).toEqual(['Alpha', 'Gamma']);
|
||||||
|
expect(chapters[0].text).toContain('Beta');
|
||||||
|
expect(chapters[0].text).toContain('B body.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('createHtmlAudiobookSourceAdapter (txt chapter splitting)', () => {
|
||||||
|
test('chunks TXT documents into "Part N" of 50 blocks by default', async () => {
|
||||||
|
const blocks = blocksFromTxt(
|
||||||
|
Array.from({ length: 120 }, (_, i) => `Block ${i + 1}.`).join('\n\n'),
|
||||||
|
);
|
||||||
|
expect(blocks.length).toBe(120);
|
||||||
|
|
||||||
|
const adapter = createHtmlAudiobookSourceAdapter({
|
||||||
|
blocks,
|
||||||
|
isTxt: true,
|
||||||
|
smartSentenceSplitting: false,
|
||||||
|
});
|
||||||
|
const chapters = await adapter.prepareChapters();
|
||||||
|
expect(chapters.map((c) => c.title)).toEqual(['Part 1', 'Part 2', 'Part 3']);
|
||||||
|
// First chapter contains blocks 1..50, last contains 101..120.
|
||||||
|
expect(chapters[0].text).toContain('Block 1.');
|
||||||
|
expect(chapters[0].text).toContain('Block 50.');
|
||||||
|
expect(chapters[0].text).not.toContain('Block 51.');
|
||||||
|
expect(chapters[2].text).toContain('Block 101.');
|
||||||
|
expect(chapters[2].text).toContain('Block 120.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores headings in TXT mode (everything goes through the Part-N path)', async () => {
|
||||||
|
// A line that LOOKS like a markdown heading in a .txt file is still
|
||||||
|
// just text — we shouldn't carve a chapter at it.
|
||||||
|
const blocks = blocksFromTxt('# Looks like a heading\n\nBut TXT mode treats it as a paragraph.');
|
||||||
|
const adapter = createHtmlAudiobookSourceAdapter({
|
||||||
|
blocks,
|
||||||
|
isTxt: true,
|
||||||
|
smartSentenceSplitting: false,
|
||||||
|
});
|
||||||
|
const chapters = await adapter.prepareChapters();
|
||||||
|
expect(chapters.length).toBe(1);
|
||||||
|
expect(chapters[0].title).toBe('Part 1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('createHtmlAudiobookSourceAdapter — prepareChapter', () => {
|
||||||
|
test('returns the same chapter by index that prepareChapters lists', async () => {
|
||||||
|
const blocks = blocksFromMd(['# A', '', 'one', '', '## B', '', 'two'].join('\n'));
|
||||||
|
const adapter = createHtmlAudiobookSourceAdapter({
|
||||||
|
blocks,
|
||||||
|
isTxt: false,
|
||||||
|
smartSentenceSplitting: false,
|
||||||
|
});
|
||||||
|
const list = await adapter.prepareChapters();
|
||||||
|
const second = await adapter.prepareChapter(1);
|
||||||
|
expect(second.title).toBe(list[1].title);
|
||||||
|
expect(second.text).toBe(list[1].text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on out-of-range chapter index', async () => {
|
||||||
|
const blocks = blocksFromMd('# Only\n\nbody.');
|
||||||
|
const adapter = createHtmlAudiobookSourceAdapter({
|
||||||
|
blocks,
|
||||||
|
isTxt: false,
|
||||||
|
smartSentenceSplitting: false,
|
||||||
|
});
|
||||||
|
await expect(adapter.prepareChapter(42)).rejects.toThrow(/invalid chapter index/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
148
tests/unit/html-blocks.spec.ts
Normal file
148
tests/unit/html-blocks.spec.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
mdToPlainText,
|
||||||
|
parseHtmlBlocks,
|
||||||
|
splitMarkdownBlocks,
|
||||||
|
splitTxtBlocks,
|
||||||
|
} from '../../src/lib/client/html/blocks';
|
||||||
|
|
||||||
|
test.describe('parseHtmlBlocks (markdown)', () => {
|
||||||
|
test('splits headings, paragraphs, and lists into separate blocks', () => {
|
||||||
|
const src = [
|
||||||
|
'# Title',
|
||||||
|
'',
|
||||||
|
'First paragraph of text.',
|
||||||
|
'',
|
||||||
|
'- one',
|
||||||
|
'- two',
|
||||||
|
'',
|
||||||
|
'## Subhead',
|
||||||
|
'',
|
||||||
|
'Second paragraph.',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const blocks = parseHtmlBlocks(src, false);
|
||||||
|
expect(blocks.map((b) => b.kind)).toEqual([
|
||||||
|
'heading',
|
||||||
|
'paragraph',
|
||||||
|
'list',
|
||||||
|
'heading',
|
||||||
|
'paragraph',
|
||||||
|
]);
|
||||||
|
expect(blocks[0].headingLevel).toBe(1);
|
||||||
|
expect(blocks[0].headingText).toBe('Title');
|
||||||
|
expect(blocks[3].headingLevel).toBe(2);
|
||||||
|
expect(blocks[3].headingText).toBe('Subhead');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assigns stable padded anchor ids in document order', () => {
|
||||||
|
const src = '# A\n\nB\n\nC';
|
||||||
|
const blocks = parseHtmlBlocks(src, false);
|
||||||
|
expect(blocks.map((b) => b.anchorId)).toEqual(['b-0000', 'b-0001', 'b-0002']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats fenced code blocks as a single block and preserves content verbatim', () => {
|
||||||
|
const src = ['Intro.', '', '```ts', 'const x = 1;', 'const y = 2;', '```', '', 'Outro.'].join(
|
||||||
|
'\n',
|
||||||
|
);
|
||||||
|
const blocks = parseHtmlBlocks(src, false);
|
||||||
|
expect(blocks.map((b) => b.kind)).toEqual(['paragraph', 'code', 'paragraph']);
|
||||||
|
// Code plainText keeps the inner lines as-is (fence lines stripped).
|
||||||
|
expect(blocks[1].plainText).toContain('const x = 1;');
|
||||||
|
expect(blocks[1].plainText).toContain('const y = 2;');
|
||||||
|
// Fence markers themselves shouldn't survive into plainText.
|
||||||
|
expect(blocks[1].plainText).not.toContain('```');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('parseHtmlBlocks (txt)', () => {
|
||||||
|
test('splits on blank-line boundaries and preserves intra-block whitespace', () => {
|
||||||
|
const src = 'First block\nmore.\n\nSecond block.\n\n\nThird block.';
|
||||||
|
const blocks = parseHtmlBlocks(src, true);
|
||||||
|
expect(blocks.length).toBe(3);
|
||||||
|
expect(blocks[0].kind).toBe('paragraph');
|
||||||
|
expect(blocks[0].plainText).toBe('First block more.');
|
||||||
|
expect(blocks[1].plainText).toBe('Second block.');
|
||||||
|
expect(blocks[2].plainText).toBe('Third block.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips empty blocks produced by trailing newlines', () => {
|
||||||
|
const blocks = splitTxtBlocks('A\n\n\n\n');
|
||||||
|
expect(blocks.length).toBe(1);
|
||||||
|
expect(blocks[0].plainText).toBe('A');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('mdToPlainText badge/image stripping', () => {
|
||||||
|
// The bug: badge alt text was being kept in plainText. Since the rendered
|
||||||
|
// DOM is just an <img> with no text node, the sentence-highlight pattern
|
||||||
|
// matcher couldn't find those words and the WHOLE first-segment match
|
||||||
|
// silently dropped below threshold. These tests are the regression guard.
|
||||||
|
|
||||||
|
test('drops image-link wrappers entirely ([](link) → "")', () => {
|
||||||
|
expect(
|
||||||
|
mdToPlainText(
|
||||||
|
'[](https://opensource.org/licenses/MIT)',
|
||||||
|
'paragraph',
|
||||||
|
),
|
||||||
|
).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops standalone images entirely ( → "")', () => {
|
||||||
|
expect(mdToPlainText('', 'paragraph')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops reference-style images (![alt][ref] → "")', () => {
|
||||||
|
expect(mdToPlainText('![cover][cover-ref]', 'paragraph')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops inline <img> HTML tags', () => {
|
||||||
|
expect(mdToPlainText('<img src="x.png" alt="x"/>', 'paragraph')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps surrounding text when a paragraph mixes badges and prose', () => {
|
||||||
|
const out = mdToPlainText(
|
||||||
|
'[](ci) Welcome to the project.  Read on.',
|
||||||
|
'paragraph',
|
||||||
|
);
|
||||||
|
// Badge / image syntax gone; visible prose preserved (with collapsed
|
||||||
|
// whitespace from the trim/normalize pass).
|
||||||
|
expect(out).toBe('Welcome to the project. Read on.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still extracts visible label text from regular links', () => {
|
||||||
|
// Links DO render visible text in the DOM, so the label must survive.
|
||||||
|
expect(mdToPlainText('See the [docs](https://example.com) for more.', 'paragraph')).toBe(
|
||||||
|
'See the docs for more.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strips inline markdown emphasis but keeps the words', () => {
|
||||||
|
expect(mdToPlainText('A **bold** and *italic* and `code` word.', 'paragraph')).toBe(
|
||||||
|
'A bold and italic and code word.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('buildFullDocumentText-style integration (badge-only blocks)', () => {
|
||||||
|
// If a paragraph is composed only of badges, its plainText is empty after
|
||||||
|
// stripping. The reader filters empty plainText out of the TTS source
|
||||||
|
// (`useHtmlDocument#buildFullDocumentText`), so badge blocks don't generate
|
||||||
|
// a phantom segment that would later fail to highlight.
|
||||||
|
test('badge-only paragraphs collapse to empty plainText', () => {
|
||||||
|
const src = [
|
||||||
|
'# Project',
|
||||||
|
'',
|
||||||
|
'[](LICENSE) [](ci)',
|
||||||
|
'',
|
||||||
|
'Real description here.',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const blocks = splitMarkdownBlocks(src);
|
||||||
|
expect(blocks.length).toBe(3);
|
||||||
|
const [heading, badgesBlock, description] = blocks;
|
||||||
|
expect(heading.plainText).toBe('Project');
|
||||||
|
expect(badgesBlock.plainText.trim()).toBe('');
|
||||||
|
expect(description.plainText).toBe('Real description here.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -112,6 +112,24 @@ test.describe('compareSegmentLocators', () => {
|
||||||
expect(compareSegmentLocators(a, b)).toBeLessThan(0);
|
expect(compareSegmentLocators(a, b)).toBeLessThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('orders HTML locators by location numerically when both look like integers', () => {
|
||||||
|
// Same regression class as the EPUB spineIndex test: "2" must come BEFORE
|
||||||
|
// "10" in the segments sidebar, not after it (lexicographic compare would
|
||||||
|
// sort "10" between "1" and "2").
|
||||||
|
const a: TTSSegmentLocator = { readerType: 'html', location: '2' };
|
||||||
|
const b: TTSSegmentLocator = { readerType: 'html', location: '10' };
|
||||||
|
expect(compareSegmentLocators(a, b)).toBeLessThan(0);
|
||||||
|
expect(compareSegmentLocators(b, a)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to lexicographic compare for free-form HTML locations', () => {
|
||||||
|
// Legacy / non-numeric HTML locations (e.g. anchor ids) should still
|
||||||
|
// produce a stable ordering.
|
||||||
|
const a: TTSSegmentLocator = { readerType: 'html', location: '#alpha' };
|
||||||
|
const b: TTSSegmentLocator = { readerType: 'html', location: '#beta' };
|
||||||
|
expect(compareSegmentLocators(a, b)).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('null locators sort last', () => {
|
test('null locators sort last', () => {
|
||||||
expect(compareSegmentLocators(null, { readerType: 'pdf', page: 1 })).toBeGreaterThan(0);
|
expect(compareSegmentLocators(null, { readerType: 'pdf', page: 1 })).toBeGreaterThan(0);
|
||||||
expect(compareSegmentLocators({ readerType: 'pdf', page: 1 }, null)).toBeLessThan(0);
|
expect(compareSegmentLocators({ readerType: 'pdf', page: 1 }, null)).toBeLessThan(0);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue