diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index 3ae9bcf..94d2033 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -1,7 +1,7 @@
name: Playwright Tests
on:
push:
- branches: [ main, master, version1.0.0 ]
+ branches: [ main, master, 'v*.*.*' ]
pull_request:
branches: [ main, master ]
jobs:
diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx
index c36970c..51fc052 100644
--- a/src/components/AudiobookExportModal.tsx
+++ b/src/components/AudiobookExportModal.tsx
@@ -688,7 +688,7 @@ export function AudiobookExportModal({
{chapters.length === 0 && !isGenerating && !isLoadingExisting && (
- Click "Start Generation" to begin creating your audiobook.
+ Generation will use current TTS playback options.
Individual chapters will appear here as they are generated.
diff --git a/src/components/DocumentSettings.tsx b/src/components/DocumentSettings.tsx
index b41b15c..b216730 100644
--- a/src/components/DocumentSettings.tsx
+++ b/src/components/DocumentSettings.tsx
@@ -34,6 +34,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
rightMargin,
updateConfigKey,
pdfHighlightEnabled,
+ epubHighlightEnabled,
} = useConfig();
const { createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
const { createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
@@ -110,7 +111,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
onGenerateAudiobook={handleGenerateAudiobook}
onRegenerateChapter={handleRegenerateChapter}
/>
-
+
setIsOpen(false)}>
)}
+ {epub && (
+
+
+ updateConfigKey('epubHighlightEnabled', e.target.checked)}
+ className="form-checkbox h-4 w-4 text-accent rounded border-muted"
+ />
+ Highlight text during playback
+
+
+ Show visual highlighting in the EPUB viewer while TTS is reading.
+
+
+ )}
{epub && (
diff --git a/src/components/EPUBViewer.tsx b/src/components/EPUBViewer.tsx
index 63568be..03e101a 100644
--- a/src/components/EPUBViewer.tsx
+++ b/src/components/EPUBViewer.tsx
@@ -19,18 +19,20 @@ interface EPUBViewerProps {
}
export function EPUBViewer({ className = '' }: EPUBViewerProps) {
- const {
- currDocData,
- currDocName,
- locationRef,
- handleLocationChanged,
- bookRef,
- renditionRef,
- tocRef,
+ const {
+ currDocData,
+ currDocName,
+ locationRef,
+ handleLocationChanged,
+ bookRef,
+ renditionRef,
+ tocRef,
setRendition,
- extractPageText
+ extractPageText,
+ highlightPattern,
+ clearHighlights
} = useEPUB();
- const { registerLocationChangeHandler, pause } = useTTS();
+ const { registerLocationChangeHandler, pause, currentSentence } = useTTS();
const { epubTheme } = useConfig();
const { updateTheme } = useEPUBTheme(epubTheme, renditionRef.current);
const containerRef = useRef(null);
@@ -42,7 +44,7 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
// Only extract text when we have dimensions, ensuring the resize is complete
extractPageText(bookRef.current, renditionRef.current, true);
setIsResizing(false);
-
+
return true;
} else {
return false;
@@ -59,6 +61,15 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
registerLocationChangeHandler(handleLocationChanged);
}, [registerLocationChangeHandler, handleLocationChanged]);
+ // Handle highlighting
+ useEffect(() => {
+ if (currentSentence) {
+ highlightPattern(currentSentence);
+ } else {
+ clearHighlights();
+ }
+ }, [currentSentence, highlightPattern, clearHighlights]);
+
if (!currDocData) {
return ;
}
diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx
index f119d33..f560b93 100644
--- a/src/contexts/ConfigContext.tsx
+++ b/src/contexts/ConfigContext.tsx
@@ -32,6 +32,7 @@ interface ConfigContextType {
isLoading: boolean;
isDBReady: boolean;
pdfHighlightEnabled: boolean;
+ epubHighlightEnabled: boolean;
}
const ConfigContext = createContext(undefined);
@@ -79,7 +80,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
if (!appConfig) return null;
const { id, ...rest } = appConfig;
void id;
- return rest;
+ return { ...APP_CONFIG_DEFAULTS, ...rest };
}, [appConfig]);
// Destructure for convenience and to match context shape
@@ -101,7 +102,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
ttsInstructions,
savedVoices,
smartSentenceSplitting,
- pdfHighlightEnabled,
+ pdfHighlightEnabled,
+ epubHighlightEnabled,
} = config || APP_CONFIG_DEFAULTS;
/**
@@ -135,7 +137,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const updateConfigKey = async (key: K, value: AppConfigValues[K]) => {
try {
setIsLoading(true);
-
+
// Special handling for voice - only update savedVoices
if (key === 'voice') {
const voiceKey = getVoiceKey(ttsProvider, ttsModel);
@@ -198,7 +200,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
updateConfigKey,
isLoading,
isDBReady,
- pdfHighlightEnabled
+ pdfHighlightEnabled,
+ epubHighlightEnabled
}}>
{children}
diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx
index 1961acc..46ab2bf 100644
--- a/src/contexts/EPUBContext.tsx
+++ b/src/contexts/EPUBContext.tsx
@@ -8,7 +8,8 @@ import {
useCallback,
useMemo,
useRef,
- RefObject
+ RefObject,
+ useEffect
} from 'react';
import type { NavItem } from 'epubjs';
@@ -41,6 +42,8 @@ interface EPUBContextType {
handleLocationChanged: (location: string | number) => void;
setRendition: (rendition: Rendition) => void;
isAudioCombining: boolean;
+ highlightPattern: (text: string) => void;
+ clearHighlights: () => void;
}
const EPUBContext = createContext(undefined);
@@ -139,6 +142,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
ttsModel,
ttsInstructions,
smartSentenceSplitting,
+ epubHighlightEnabled,
} = useConfig();
// Current document state
const [currDocData, setCurrDocData] = useState();
@@ -154,6 +158,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
const isEPUBSetOnce = useRef(false);
// Should pause ref
const shouldPauseRef = useRef(true);
+ // Track current highlight CFI for removal
+ const currentHighlightCfi = useRef(null);
/**
* Clears all current document state and stops any active TTS
@@ -307,7 +313,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
// Get TOC for chapter titles
const chapters = tocRef.current || [];
-
+
// If we have a bookId, check for existing chapters to determine which indices already exist
const existingIndices = new Set();
if (bookId) {
@@ -329,21 +335,21 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
console.error('Error checking existing chapters:', error);
}
}
-
+
// Create a map of section hrefs to their chapter titles
const sectionTitleMap = new Map();
-
+
// First, loop through all chapters to create the mapping
for (const chapter of chapters) {
if (!chapter.href) continue;
const chapterBaseHref = chapter.href.split('#')[0];
const chapterTitle = chapter.label.trim();
-
+
// For each chapter, find all matching sections
for (const section of sections) {
const sectionHref = section.href;
const sectionBaseHref = sectionHref.split('#')[0];
-
+
// If this section matches this chapter, map it
if (sectionHref === chapter.href || sectionBaseHref === chapterBaseHref) {
sectionTitleMap.set(sectionHref, chapterTitle);
@@ -426,7 +432,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
// Get the chapter title from our pre-computed map
let chapterTitle = sectionTitleMap.get(section.href);
-
+
// If no chapter title found, use index-based naming
if (!chapterTitle) {
chapterTitle = `Chapter ${i + 1}`;
@@ -466,7 +472,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
}
const { bookId: returnedBookId, chapterIndex, duration } = await convertResponse.json();
-
+
if (!bookId) {
bookId = returnedBookId;
}
@@ -495,7 +501,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
throw new Error('Audiobook generation cancelled');
}
console.error('Error processing section:', error);
-
+
// Notify about error
if (onChapterComplete) {
onChapterComplete({
@@ -537,7 +543,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
const section = sections[chapterIndex];
const trimmedText = section.text.trim();
-
+
if (!trimmedText) {
throw new Error('No text content found in chapter');
}
@@ -545,16 +551,16 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
// Get TOC for chapter title
const chapters = tocRef.current || [];
const sectionTitleMap = new Map();
-
+
for (const chapter of chapters) {
if (!chapter.href) continue;
const chapterBaseHref = chapter.href.split('#')[0];
const chapterTitle = chapter.label.trim();
-
+
for (const sect of sections) {
const sectionHref = sect.href;
const sectionBaseHref = sectionHref.split('#')[0];
-
+
if (sectionHref === chapter.href || sectionBaseHref === chapterBaseHref) {
sectionTitleMap.set(sectionHref, chapterTitle);
}
@@ -705,6 +711,69 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
}
}, [id, skipToLocation, extractPageText, setIsEPUB]);
+ const clearHighlights = useCallback(() => {
+ if (renditionRef.current) {
+ if (currentHighlightCfi.current) {
+ renditionRef.current.annotations.remove(currentHighlightCfi.current, 'highlight');
+ currentHighlightCfi.current = null;
+ }
+ }
+ }, []);
+
+ const highlightPattern = useCallback(async (text: string) => {
+ if (!renditionRef.current) return;
+
+ // Clear existing highlights first
+ clearHighlights();
+
+ if (!epubHighlightEnabled) return;
+
+ if (!text || !text.trim()) return;
+
+ try {
+ const contents = renditionRef.current.getContents();
+ const contentsArray = Array.isArray(contents) ? contents : [contents];
+ for (const content of contentsArray) {
+ const win = content.window;
+ if (win && win.find) {
+ // Reset selection to start of document to ensure full search
+ const sel = win.getSelection();
+ sel?.removeAllRanges();
+
+ // Attempt to find the text
+ // window.find(aString, aCaseSensitive, aBackwards, aWrapAround, aWholeWord, aSearchInFrames, aShowDialog);
+ // Note: We search for the trimmed text.
+ if (win.find(text.trim(), false, false, true, false, false, false)) {
+ const range = sel?.getRangeAt(0);
+ if (range) {
+ const cfi = content.cfiFromRange(range);
+ // Store CFI for removal
+ currentHighlightCfi.current = cfi;
+ renditionRef.current.annotations.add('highlight', cfi, {}, (e: MouseEvent) => {
+ console.log('Highlight clicked', e);
+ }, '', { fill: 'grey', 'fill-opacity': '0.4', 'mix-blend-mode': 'multiply' });
+
+ // Clear the browser selection so it doesn't look like user selected text
+ sel?.removeAllRanges();
+ return; // Stop after first match
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.error('Error highlighting text:', error);
+ }
+ }, [clearHighlights, epubHighlightEnabled]);
+
+ // Effect to clear highlights when disabled
+ useEffect(() => {
+ if (!epubHighlightEnabled) {
+ clearHighlights();
+ }
+ }, [epubHighlightEnabled, clearHighlights]);
+
+
+
// Context value memoization
const contextValue = useMemo(
() => ({
@@ -725,6 +794,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
handleLocationChanged,
setRendition,
isAudioCombining,
+ highlightPattern,
+ clearHighlights,
}),
[
setCurrentDocument,
@@ -740,6 +811,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
handleLocationChanged,
setRendition,
isAudioCombining,
+ highlightPattern,
+ clearHighlights,
]
);
diff --git a/src/types/config.ts b/src/types/config.ts
index 7d6c046..30401c3 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -23,6 +23,7 @@ export interface AppConfigValues {
savedVoices: SavedVoices;
smartSentenceSplitting: boolean;
pdfHighlightEnabled: boolean;
+ epubHighlightEnabled: boolean;
firstVisit: boolean;
documentListState: DocumentListState;
}
@@ -46,6 +47,7 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = {
savedVoices: {},
smartSentenceSplitting: true,
pdfHighlightEnabled: true,
+ epubHighlightEnabled: true,
firstVisit: false,
documentListState: {
sortBy: 'name',
diff --git a/tests/export.spec.ts b/tests/export.spec.ts
index 5374f85..a25bfe4 100644
--- a/tests/export.spec.ts
+++ b/tests/export.spec.ts
@@ -89,7 +89,7 @@ async function resetAudiobookIfPresent(page: Page) {
await confirmReset.click();
await expect(
- page.getByText(/Click "Start Generation" to begin creating your audiobook/i)
+ page.getByText(/Generation will use current TTS playback options./i)
).toBeVisible({ timeout: 60_000 });
}
@@ -267,7 +267,7 @@ test.describe('Audiobook export', () => {
// After reset, the hint text for starting generation should re-appear
await expect(
- page.getByText(/Click "Start Generation" to begin creating your audiobook/i)
+ page.getByText(/Generation will use current TTS playback options./i)
).toBeVisible({ timeout: 60_000 });
// Backend should report no existing chapters for this bookId