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 { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
|
||||
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||
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';
|
||||
|
||||
export default function HTMLPage() {
|
||||
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
|
||||
const { id } = useParams();
|
||||
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 { isAtLimit } = useAuthRateLimit();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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 [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width)
|
||||
const [maxPadPx, setMaxPadPx] = useState<number>(0);
|
||||
|
|
@ -110,6 +125,24 @@ export default function HTMLPage() {
|
|||
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) {
|
||||
return (
|
||||
<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))}
|
||||
onOpenSettings={() => setActiveSidebar((prev) => prev === 'settings' ? null : 'settings')}
|
||||
onOpenSegments={() => setActiveSidebar((prev) => prev === 'segments' ? null : 'segments')}
|
||||
onOpenAudiobook={() => setActiveSidebar((prev) => prev === 'audiobook' ? null : 'audiobook')}
|
||||
isSettingsOpen={activeSidebar === 'settings'}
|
||||
isSegmentsOpen={activeSidebar === 'segments'}
|
||||
isAudiobookOpen={activeSidebar === 'audiobook'}
|
||||
showAudiobookExport={canExportAudiobook}
|
||||
minZoom={0}
|
||||
maxZoom={100}
|
||||
/>
|
||||
|
|
@ -162,16 +198,31 @@ export default function HTMLPage() {
|
|||
}
|
||||
/>
|
||||
<div className="overflow-hidden" style={{ height: containerHeight }}>
|
||||
{isLoading ? (
|
||||
{isLoading || !currDocData ? (
|
||||
<div className="p-4">
|
||||
<DocumentSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
{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 ? (
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -3,33 +3,106 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { getDocumentMetadata } from '@/lib/client/api/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;
|
||||
currDocName: string | undefined;
|
||||
currDocText: string | undefined;
|
||||
blocks: HtmlBlock[];
|
||||
isTxt: boolean;
|
||||
setCurrentDocument: (id: string) => Promise<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 {
|
||||
const { setText: setTTSText, stop } = useTTS();
|
||||
const setTTSTextRef = useRef(setTTSText);
|
||||
const { setText: setTTSText, stop, setIsEPUB } = useTTS();
|
||||
const {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
providerRef,
|
||||
smartSentenceSplitting,
|
||||
ttsSegmentMaxBlockLength,
|
||||
} = useConfig();
|
||||
|
||||
const [currDocData, setCurrDocData] = 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(() => {
|
||||
setTTSTextRef.current = setTTSText;
|
||||
}, [setTTSText]);
|
||||
setIsEPUB(false);
|
||||
}, [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(() => {
|
||||
setCurrDocData(undefined);
|
||||
setCurrDocName(undefined);
|
||||
setCurrDocText(undefined);
|
||||
lastFedDocRef.current = null;
|
||||
stop();
|
||||
}, [stop]);
|
||||
|
||||
|
|
@ -49,22 +122,110 @@ export function useHtmlDocument(): HtmlDocumentState {
|
|||
|
||||
setCurrDocName(doc.name);
|
||||
setCurrDocData(doc.data);
|
||||
setCurrDocText(doc.data);
|
||||
setTTSTextRef.current(doc.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to get HTML document:', error);
|
||||
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(
|
||||
() => ({
|
||||
currDocData,
|
||||
currDocName,
|
||||
currDocText,
|
||||
blocks,
|
||||
isTxt,
|
||||
setCurrentDocument,
|
||||
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 'epubHighlightEnabled':
|
||||
case 'epubWordHighlightEnabled':
|
||||
case 'htmlHighlightEnabled':
|
||||
case 'htmlWordHighlightEnabled':
|
||||
if (typeof value === 'boolean') out[key] = value;
|
||||
break;
|
||||
case 'savedVoices':
|
||||
|
|
|
|||
|
|
@ -321,6 +321,28 @@ h1, h2, h3, h4, h5, h6 {
|
|||
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) */
|
||||
.prism-divider {
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import type { AudiobookGenerationSettings } from '@/types/client';
|
|||
interface AudiobookExportModalProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
documentType: 'epub' | 'pdf';
|
||||
documentType: 'epub' | 'pdf' | 'html';
|
||||
documentId: string;
|
||||
onGenerateAudiobook: (
|
||||
onProgress: (progress: number) => void,
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
epubHighlightEnabled,
|
||||
pdfWordHighlightEnabled,
|
||||
epubWordHighlightEnabled,
|
||||
htmlHighlightEnabled,
|
||||
htmlWordHighlightEnabled,
|
||||
} = useConfig();
|
||||
const [localMargins, setLocalMargins] = useState({
|
||||
header: headerMargin,
|
||||
|
|
@ -161,12 +163,12 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
panelClassName="w-full sm:w-[30rem]"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{!html && (
|
||||
<Section
|
||||
title="Playback Flow"
|
||||
subtitle="Segment and queue behavior."
|
||||
variant="flat"
|
||||
>
|
||||
<Section
|
||||
title="Playback Flow"
|
||||
subtitle="Segment and queue behavior."
|
||||
variant="flat"
|
||||
>
|
||||
{!html && (
|
||||
<ToggleRow
|
||||
label="Skip blank pages"
|
||||
description="Skip pages with no readable text."
|
||||
|
|
@ -174,64 +176,64 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
onChange={(checked) => updateConfigKey('skipBlank', checked)}
|
||||
variant="flat"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ToggleRow
|
||||
label="Smart sentence splitting"
|
||||
description="Merge fragments across pages/sections."
|
||||
checked={smartSentenceSplitting}
|
||||
onChange={(checked) => updateConfigKey('smartSentenceSplitting', checked)}
|
||||
variant="flat"
|
||||
<ToggleRow
|
||||
label="Smart sentence splitting"
|
||||
description="Merge fragments across pages/sections."
|
||||
checked={smartSentenceSplitting}
|
||||
onChange={(checked) => updateConfigKey('smartSentenceSplitting', checked)}
|
||||
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
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
<RangeSetting
|
||||
label="Segment lookahead per page/location"
|
||||
value={localSentenceLookahead}
|
||||
min={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN}
|
||||
max={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX}
|
||||
step={1}
|
||||
description="Segments to prepare per queued page/section."
|
||||
formatter={(value) => String(value)}
|
||||
onChange={(value) => {
|
||||
const next = clampSegmentPreloadSentenceLookahead(value);
|
||||
setLocalSentenceLookahead(next);
|
||||
void updateConfigKey('segmentPreloadSentenceLookahead', next);
|
||||
}}
|
||||
/>
|
||||
|
||||
<RangeSetting
|
||||
label="Segment lookahead per page/location"
|
||||
value={localSentenceLookahead}
|
||||
min={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN}
|
||||
max={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX}
|
||||
step={1}
|
||||
description="Segments to prepare per queued page/section."
|
||||
formatter={(value) => String(value)}
|
||||
onChange={(value) => {
|
||||
const next = clampSegmentPreloadSentenceLookahead(value);
|
||||
setLocalSentenceLookahead(next);
|
||||
void updateConfigKey('segmentPreloadSentenceLookahead', next);
|
||||
}}
|
||||
/>
|
||||
|
||||
<RangeSetting
|
||||
label="TTS segment max block length"
|
||||
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>
|
||||
)}
|
||||
<RangeSetting
|
||||
label="TTS segment max block length"
|
||||
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 && (
|
||||
<Section
|
||||
|
|
@ -352,6 +354,30 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
/>
|
||||
</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>
|
||||
</ReaderSidebarShell>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -117,6 +117,9 @@ function formatLocatorGroupLabel(locator: TTSSegmentLocator | null): string {
|
|||
return `Page ${Math.floor(locator.page)} · PDF`;
|
||||
}
|
||||
if (isHtmlLocator(locator)) {
|
||||
if (/^\d+$/.test(locator.location)) {
|
||||
return `Block ${locator.location} · HTML`;
|
||||
}
|
||||
return `${locator.location} · HTML`;
|
||||
}
|
||||
return 'Unknown location';
|
||||
|
|
|
|||
|
|
@ -1,37 +1,217 @@
|
|||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
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 {
|
||||
className?: string;
|
||||
currDocData?: string;
|
||||
currDocName?: string;
|
||||
blocks: HtmlBlock[];
|
||||
isTxt: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function HTMLViewer({ className = '', currDocData, currDocName }: HTMLViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
export function HTMLViewer({
|
||||
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 />;
|
||||
}
|
||||
|
||||
// Check if the file is a txt file
|
||||
const isTxtFile = currDocName?.toLowerCase().endsWith('.txt');
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className}`} ref={containerRef}>
|
||||
<div 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'}`}>
|
||||
{isTxtFile ? (
|
||||
currDocData
|
||||
) : (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{currDocData}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
<div className={`flex flex-col h-full ${className}`}>
|
||||
<div ref={scrollRef} className="flex-1 overflow-auto">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={`html-container min-w-full px-4 py-4 ${isTxt ? 'font-mono text-sm' : 'prose prose-base'}`}
|
||||
>
|
||||
{blocks.map((block) => (
|
||||
<div
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ interface ConfigContextType {
|
|||
pdfWordHighlightEnabled: boolean;
|
||||
epubHighlightEnabled: boolean;
|
||||
epubWordHighlightEnabled: boolean;
|
||||
htmlHighlightEnabled: boolean;
|
||||
htmlWordHighlightEnabled: boolean;
|
||||
}
|
||||
|
||||
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
||||
|
|
@ -358,6 +360,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
pdfWordHighlightEnabled,
|
||||
epubHighlightEnabled,
|
||||
epubWordHighlightEnabled,
|
||||
htmlHighlightEnabled,
|
||||
htmlWordHighlightEnabled,
|
||||
} = config || APP_CONFIG_DEFAULTS;
|
||||
const providerType = useMemo(
|
||||
() => resolveEffectiveProviderType({
|
||||
|
|
@ -491,7 +495,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
pdfHighlightEnabled,
|
||||
pdfWordHighlightEnabled,
|
||||
epubHighlightEnabled,
|
||||
epubWordHighlightEnabled
|
||||
epubWordHighlightEnabled,
|
||||
htmlHighlightEnabled,
|
||||
htmlWordHighlightEnabled,
|
||||
}}>
|
||||
{children}
|
||||
</ConfigContext.Provider>
|
||||
|
|
|
|||
|
|
@ -1036,6 +1036,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
|
||||
// For PDFs and other documents, check page bounds
|
||||
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
|
||||
if ((nextIndex >= sentences.length && currDocPageNumber < currDocPages!) ||
|
||||
(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,
|
||||
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',
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
// One or both are legacy/draft — fall back to grouped-key compare so the
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ export interface AppConfigValues {
|
|||
pdfWordHighlightEnabled: boolean;
|
||||
epubHighlightEnabled: boolean;
|
||||
epubWordHighlightEnabled: boolean;
|
||||
htmlHighlightEnabled: boolean;
|
||||
htmlWordHighlightEnabled: boolean;
|
||||
firstVisit: boolean;
|
||||
documentListState: DocumentListState;
|
||||
privacyAccepted: boolean;
|
||||
|
|
@ -127,6 +129,8 @@ export function getAppConfigDefaults(): AppConfigValues {
|
|||
pdfWordHighlightEnabled: wordHighlightEnabledByDefault,
|
||||
epubHighlightEnabled: true,
|
||||
epubWordHighlightEnabled: wordHighlightEnabledByDefault,
|
||||
htmlHighlightEnabled: true,
|
||||
htmlWordHighlightEnabled: wordHighlightEnabledByDefault,
|
||||
firstVisit: false,
|
||||
documentListState: {
|
||||
sortBy: 'name',
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ export const SYNCED_PREFERENCE_KEYS = [
|
|||
'pdfWordHighlightEnabled',
|
||||
'epubHighlightEnabled',
|
||||
'epubWordHighlightEnabled',
|
||||
'htmlHighlightEnabled',
|
||||
'htmlWordHighlightEnabled',
|
||||
] as const;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
expect(compareSegmentLocators(null, { readerType: 'pdf', page: 1 })).toBeGreaterThan(0);
|
||||
expect(compareSegmentLocators({ readerType: 'pdf', page: 1 }, null)).toBeLessThan(0);
|
||||
|
|
|
|||
Loading…
Reference in a new issue