Skip to content

Commit 50538bf

Browse files
committed
feat(epub): Add EPUB text highlighting for the current TTS sentence with a new configuration option
1 parent 264f3c1 commit 50538bf

File tree

6 files changed

+136
-30
lines changed

6 files changed

+136
-30
lines changed

.github/workflows/playwright.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: Playwright Tests
22
on:
33
push:
4-
branches: [ main, master, version1.0.0 ]
4+
branches: [ main, master, 'v*.*.*' ]
55
pull_request:
66
branches: [ main, master ]
77
jobs:

src/components/DocumentSettings.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
3434
rightMargin,
3535
updateConfigKey,
3636
pdfHighlightEnabled,
37+
epubHighlightEnabled,
3738
} = useConfig();
3839
const { createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
3940
const { createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
@@ -110,7 +111,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
110111
onGenerateAudiobook={handleGenerateAudiobook}
111112
onRegenerateChapter={handleRegenerateChapter}
112113
/>
113-
114+
114115
<Transition appear show={isOpen} as={Fragment}>
115116
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
116117
<TransitionChild
@@ -343,6 +344,22 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
343344
</p>
344345
</div>
345346
)}
347+
{epub && (
348+
<div className="space-y-1">
349+
<label className="flex items-center space-x-2">
350+
<input
351+
type="checkbox"
352+
checked={epubHighlightEnabled}
353+
onChange={(e) => updateConfigKey('epubHighlightEnabled', e.target.checked)}
354+
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
355+
/>
356+
<span className="text-sm font-medium text-foreground">Highlight text during playback</span>
357+
</label>
358+
<p className="text-sm text-muted pl-6">
359+
Show visual highlighting in the EPUB viewer while TTS is reading.
360+
</p>
361+
</div>
362+
)}
346363
{epub && (
347364
<div className="space-y-1">
348365
<label className="flex items-center space-x-2">

src/components/EPUBViewer.tsx

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,20 @@ interface EPUBViewerProps {
1919
}
2020

2121
export function EPUBViewer({ className = '' }: EPUBViewerProps) {
22-
const {
23-
currDocData,
24-
currDocName,
25-
locationRef,
26-
handleLocationChanged,
27-
bookRef,
28-
renditionRef,
29-
tocRef,
22+
const {
23+
currDocData,
24+
currDocName,
25+
locationRef,
26+
handleLocationChanged,
27+
bookRef,
28+
renditionRef,
29+
tocRef,
3030
setRendition,
31-
extractPageText
31+
extractPageText,
32+
highlightPattern,
33+
clearHighlights
3234
} = useEPUB();
33-
const { registerLocationChangeHandler, pause } = useTTS();
35+
const { registerLocationChangeHandler, pause, currentSentence } = useTTS();
3436
const { epubTheme } = useConfig();
3537
const { updateTheme } = useEPUBTheme(epubTheme, renditionRef.current);
3638
const containerRef = useRef<HTMLDivElement>(null);
@@ -42,7 +44,7 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
4244
// Only extract text when we have dimensions, ensuring the resize is complete
4345
extractPageText(bookRef.current, renditionRef.current, true);
4446
setIsResizing(false);
45-
47+
4648
return true;
4749
} else {
4850
return false;
@@ -59,6 +61,15 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
5961
registerLocationChangeHandler(handleLocationChanged);
6062
}, [registerLocationChangeHandler, handleLocationChanged]);
6163

64+
// Handle highlighting
65+
useEffect(() => {
66+
if (currentSentence) {
67+
highlightPattern(currentSentence);
68+
} else {
69+
clearHighlights();
70+
}
71+
}, [currentSentence, highlightPattern, clearHighlights]);
72+
6273
if (!currDocData) {
6374
return <DocumentSkeleton />;
6475
}

src/contexts/ConfigContext.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ interface ConfigContextType {
3232
isLoading: boolean;
3333
isDBReady: boolean;
3434
pdfHighlightEnabled: boolean;
35+
epubHighlightEnabled: boolean;
3536
}
3637

3738
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
@@ -79,7 +80,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
7980
if (!appConfig) return null;
8081
const { id, ...rest } = appConfig;
8182
void id;
82-
return rest;
83+
return { ...APP_CONFIG_DEFAULTS, ...rest };
8384
}, [appConfig]);
8485

8586
// Destructure for convenience and to match context shape
@@ -101,7 +102,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
101102
ttsInstructions,
102103
savedVoices,
103104
smartSentenceSplitting,
104-
pdfHighlightEnabled,
105+
pdfHighlightEnabled,
106+
epubHighlightEnabled,
105107
} = config || APP_CONFIG_DEFAULTS;
106108

107109
/**
@@ -135,7 +137,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
135137
const updateConfigKey = async <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => {
136138
try {
137139
setIsLoading(true);
138-
140+
139141
// Special handling for voice - only update savedVoices
140142
if (key === 'voice') {
141143
const voiceKey = getVoiceKey(ttsProvider, ttsModel);
@@ -198,7 +200,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
198200
updateConfigKey,
199201
isLoading,
200202
isDBReady,
201-
pdfHighlightEnabled
203+
pdfHighlightEnabled,
204+
epubHighlightEnabled
202205
}}>
203206
{children}
204207
</ConfigContext.Provider>

src/contexts/EPUBContext.tsx

Lines changed: 86 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
useCallback,
99
useMemo,
1010
useRef,
11-
RefObject
11+
RefObject,
12+
useEffect
1213
} from 'react';
1314

1415
import type { NavItem } from 'epubjs';
@@ -41,6 +42,8 @@ interface EPUBContextType {
4142
handleLocationChanged: (location: string | number) => void;
4243
setRendition: (rendition: Rendition) => void;
4344
isAudioCombining: boolean;
45+
highlightPattern: (text: string) => void;
46+
clearHighlights: () => void;
4447
}
4548

4649
const EPUBContext = createContext<EPUBContextType | undefined>(undefined);
@@ -139,6 +142,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
139142
ttsModel,
140143
ttsInstructions,
141144
smartSentenceSplitting,
145+
epubHighlightEnabled,
142146
} = useConfig();
143147
// Current document state
144148
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
@@ -154,6 +158,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
154158
const isEPUBSetOnce = useRef(false);
155159
// Should pause ref
156160
const shouldPauseRef = useRef(true);
161+
// Track current highlight CFI for removal
162+
const currentHighlightCfi = useRef<string | null>(null);
157163

158164
/**
159165
* Clears all current document state and stops any active TTS
@@ -307,7 +313,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
307313

308314
// Get TOC for chapter titles
309315
const chapters = tocRef.current || [];
310-
316+
311317
// If we have a bookId, check for existing chapters to determine which indices already exist
312318
const existingIndices = new Set<number>();
313319
if (bookId) {
@@ -329,21 +335,21 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
329335
console.error('Error checking existing chapters:', error);
330336
}
331337
}
332-
338+
333339
// Create a map of section hrefs to their chapter titles
334340
const sectionTitleMap = new Map<string, string>();
335-
341+
336342
// First, loop through all chapters to create the mapping
337343
for (const chapter of chapters) {
338344
if (!chapter.href) continue;
339345
const chapterBaseHref = chapter.href.split('#')[0];
340346
const chapterTitle = chapter.label.trim();
341-
347+
342348
// For each chapter, find all matching sections
343349
for (const section of sections) {
344350
const sectionHref = section.href;
345351
const sectionBaseHref = sectionHref.split('#')[0];
346-
352+
347353
// If this section matches this chapter, map it
348354
if (sectionHref === chapter.href || sectionBaseHref === chapterBaseHref) {
349355
sectionTitleMap.set(sectionHref, chapterTitle);
@@ -426,7 +432,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
426432

427433
// Get the chapter title from our pre-computed map
428434
let chapterTitle = sectionTitleMap.get(section.href);
429-
435+
430436
// If no chapter title found, use index-based naming
431437
if (!chapterTitle) {
432438
chapterTitle = `Chapter ${i + 1}`;
@@ -466,7 +472,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
466472
}
467473

468474
const { bookId: returnedBookId, chapterIndex, duration } = await convertResponse.json();
469-
475+
470476
if (!bookId) {
471477
bookId = returnedBookId;
472478
}
@@ -495,7 +501,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
495501
throw new Error('Audiobook generation cancelled');
496502
}
497503
console.error('Error processing section:', error);
498-
504+
499505
// Notify about error
500506
if (onChapterComplete) {
501507
onChapterComplete({
@@ -537,24 +543,24 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
537543

538544
const section = sections[chapterIndex];
539545
const trimmedText = section.text.trim();
540-
546+
541547
if (!trimmedText) {
542548
throw new Error('No text content found in chapter');
543549
}
544550

545551
// Get TOC for chapter title
546552
const chapters = tocRef.current || [];
547553
const sectionTitleMap = new Map<string, string>();
548-
554+
549555
for (const chapter of chapters) {
550556
if (!chapter.href) continue;
551557
const chapterBaseHref = chapter.href.split('#')[0];
552558
const chapterTitle = chapter.label.trim();
553-
559+
554560
for (const sect of sections) {
555561
const sectionHref = sect.href;
556562
const sectionBaseHref = sectionHref.split('#')[0];
557-
563+
558564
if (sectionHref === chapter.href || sectionBaseHref === chapterBaseHref) {
559565
sectionTitleMap.set(sectionHref, chapterTitle);
560566
}
@@ -705,6 +711,69 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
705711
}
706712
}, [id, skipToLocation, extractPageText, setIsEPUB]);
707713

714+
const clearHighlights = useCallback(() => {
715+
if (renditionRef.current) {
716+
if (currentHighlightCfi.current) {
717+
renditionRef.current.annotations.remove(currentHighlightCfi.current, 'highlight');
718+
currentHighlightCfi.current = null;
719+
}
720+
}
721+
}, []);
722+
723+
const highlightPattern = useCallback(async (text: string) => {
724+
if (!renditionRef.current) return;
725+
726+
// Clear existing highlights first
727+
clearHighlights();
728+
729+
if (!epubHighlightEnabled) return;
730+
731+
if (!text || !text.trim()) return;
732+
733+
try {
734+
const contents = renditionRef.current.getContents();
735+
const contentsArray = Array.isArray(contents) ? contents : [contents];
736+
for (const content of contentsArray) {
737+
const win = content.window;
738+
if (win && win.find) {
739+
// Reset selection to start of document to ensure full search
740+
const sel = win.getSelection();
741+
sel?.removeAllRanges();
742+
743+
// Attempt to find the text
744+
// window.find(aString, aCaseSensitive, aBackwards, aWrapAround, aWholeWord, aSearchInFrames, aShowDialog);
745+
// Note: We search for the trimmed text.
746+
if (win.find(text.trim(), false, false, true, false, false, false)) {
747+
const range = sel?.getRangeAt(0);
748+
if (range) {
749+
const cfi = content.cfiFromRange(range);
750+
// Store CFI for removal
751+
currentHighlightCfi.current = cfi;
752+
renditionRef.current.annotations.add('highlight', cfi, {}, (e: MouseEvent) => {
753+
console.log('Highlight clicked', e);
754+
}, '', { fill: 'grey', 'fill-opacity': '0.4', 'mix-blend-mode': 'multiply' });
755+
756+
// Clear the browser selection so it doesn't look like user selected text
757+
sel?.removeAllRanges();
758+
return; // Stop after first match
759+
}
760+
}
761+
}
762+
}
763+
} catch (error) {
764+
console.error('Error highlighting text:', error);
765+
}
766+
}, [clearHighlights, epubHighlightEnabled]);
767+
768+
// Effect to clear highlights when disabled
769+
useEffect(() => {
770+
if (!epubHighlightEnabled) {
771+
clearHighlights();
772+
}
773+
}, [epubHighlightEnabled, clearHighlights]);
774+
775+
776+
708777
// Context value memoization
709778
const contextValue = useMemo(
710779
() => ({
@@ -725,6 +794,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
725794
handleLocationChanged,
726795
setRendition,
727796
isAudioCombining,
797+
highlightPattern,
798+
clearHighlights,
728799
}),
729800
[
730801
setCurrentDocument,
@@ -740,6 +811,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
740811
handleLocationChanged,
741812
setRendition,
742813
isAudioCombining,
814+
highlightPattern,
815+
clearHighlights,
743816
]
744817
);
745818

src/types/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface AppConfigValues {
2323
savedVoices: SavedVoices;
2424
smartSentenceSplitting: boolean;
2525
pdfHighlightEnabled: boolean;
26+
epubHighlightEnabled: boolean;
2627
firstVisit: boolean;
2728
documentListState: DocumentListState;
2829
}
@@ -46,6 +47,7 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = {
4647
savedVoices: {},
4748
smartSentenceSplitting: true,
4849
pdfHighlightEnabled: true,
50+
epubHighlightEnabled: true,
4951
firstVisit: false,
5052
documentListState: {
5153
sortBy: 'name',

0 commit comments

Comments
 (0)