Merge pull request #66 from richardr1126/v1.0.1

v1.0.1
This commit is contained in:
Richard Roberson 2025-11-21 10:15:40 -07:00 committed by GitHub
commit eb2fc4eb0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 139 additions and 33 deletions

View file

@ -1,7 +1,7 @@
name: Playwright Tests name: Playwright Tests
on: on:
push: push:
branches: [ main, master, version1.0.0 ] branches: [ main, master, 'v*.*.*' ]
pull_request: pull_request:
branches: [ main, master ] branches: [ main, master ]
jobs: jobs:

View file

@ -688,7 +688,7 @@ export function AudiobookExportModal({
{chapters.length === 0 && !isGenerating && !isLoadingExisting && ( {chapters.length === 0 && !isGenerating && !isLoadingExisting && (
<div className="text-center py-8"> <div className="text-center py-8">
<p className="text-sm text-muted"> <p className="text-sm text-muted">
Click &quot;Start Generation&quot; to begin creating your audiobook. Generation will use current TTS playback options.
<br /> <br />
Individual chapters will appear here as they are generated. Individual chapters will appear here as they are generated.
</p> </p>

View file

@ -34,6 +34,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
rightMargin, rightMargin,
updateConfigKey, updateConfigKey,
pdfHighlightEnabled, pdfHighlightEnabled,
epubHighlightEnabled,
} = useConfig(); } = useConfig();
const { createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB(); const { createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
const { createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF(); const { createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
@ -110,7 +111,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
onGenerateAudiobook={handleGenerateAudiobook} onGenerateAudiobook={handleGenerateAudiobook}
onRegenerateChapter={handleRegenerateChapter} onRegenerateChapter={handleRegenerateChapter}
/> />
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}> <Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
<TransitionChild <TransitionChild
@ -343,6 +344,22 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
</p> </p>
</div> </div>
)} )}
{epub && (
<div className="space-y-1">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={epubHighlightEnabled}
onChange={(e) => updateConfigKey('epubHighlightEnabled', e.target.checked)}
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
/>
<span className="text-sm font-medium text-foreground">Highlight text during playback</span>
</label>
<p className="text-sm text-muted pl-6">
Show visual highlighting in the EPUB viewer while TTS is reading.
</p>
</div>
)}
{epub && ( {epub && (
<div className="space-y-1"> <div className="space-y-1">
<label className="flex items-center space-x-2"> <label className="flex items-center space-x-2">

View file

@ -19,18 +19,20 @@ interface EPUBViewerProps {
} }
export function EPUBViewer({ className = '' }: EPUBViewerProps) { export function EPUBViewer({ className = '' }: EPUBViewerProps) {
const { const {
currDocData, currDocData,
currDocName, currDocName,
locationRef, locationRef,
handleLocationChanged, handleLocationChanged,
bookRef, bookRef,
renditionRef, renditionRef,
tocRef, tocRef,
setRendition, setRendition,
extractPageText extractPageText,
highlightPattern,
clearHighlights
} = useEPUB(); } = useEPUB();
const { registerLocationChangeHandler, pause } = useTTS(); const { registerLocationChangeHandler, pause, currentSentence } = useTTS();
const { epubTheme } = useConfig(); const { epubTheme } = useConfig();
const { updateTheme } = useEPUBTheme(epubTheme, renditionRef.current); const { updateTheme } = useEPUBTheme(epubTheme, renditionRef.current);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -42,7 +44,7 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
// Only extract text when we have dimensions, ensuring the resize is complete // Only extract text when we have dimensions, ensuring the resize is complete
extractPageText(bookRef.current, renditionRef.current, true); extractPageText(bookRef.current, renditionRef.current, true);
setIsResizing(false); setIsResizing(false);
return true; return true;
} else { } else {
return false; return false;
@ -59,6 +61,15 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
registerLocationChangeHandler(handleLocationChanged); registerLocationChangeHandler(handleLocationChanged);
}, [registerLocationChangeHandler, handleLocationChanged]); }, [registerLocationChangeHandler, handleLocationChanged]);
// Handle highlighting
useEffect(() => {
if (currentSentence) {
highlightPattern(currentSentence);
} else {
clearHighlights();
}
}, [currentSentence, highlightPattern, clearHighlights]);
if (!currDocData) { if (!currDocData) {
return <DocumentSkeleton />; return <DocumentSkeleton />;
} }

View file

@ -32,6 +32,7 @@ interface ConfigContextType {
isLoading: boolean; isLoading: boolean;
isDBReady: boolean; isDBReady: boolean;
pdfHighlightEnabled: boolean; pdfHighlightEnabled: boolean;
epubHighlightEnabled: boolean;
} }
const ConfigContext = createContext<ConfigContextType | undefined>(undefined); const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
@ -79,7 +80,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
if (!appConfig) return null; if (!appConfig) return null;
const { id, ...rest } = appConfig; const { id, ...rest } = appConfig;
void id; void id;
return rest; return { ...APP_CONFIG_DEFAULTS, ...rest };
}, [appConfig]); }, [appConfig]);
// Destructure for convenience and to match context shape // Destructure for convenience and to match context shape
@ -101,7 +102,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
ttsInstructions, ttsInstructions,
savedVoices, savedVoices,
smartSentenceSplitting, smartSentenceSplitting,
pdfHighlightEnabled, pdfHighlightEnabled,
epubHighlightEnabled,
} = config || APP_CONFIG_DEFAULTS; } = config || APP_CONFIG_DEFAULTS;
/** /**
@ -135,7 +137,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const updateConfigKey = async <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => { const updateConfigKey = async <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => {
try { try {
setIsLoading(true); setIsLoading(true);
// Special handling for voice - only update savedVoices // Special handling for voice - only update savedVoices
if (key === 'voice') { if (key === 'voice') {
const voiceKey = getVoiceKey(ttsProvider, ttsModel); const voiceKey = getVoiceKey(ttsProvider, ttsModel);
@ -198,7 +200,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
updateConfigKey, updateConfigKey,
isLoading, isLoading,
isDBReady, isDBReady,
pdfHighlightEnabled pdfHighlightEnabled,
epubHighlightEnabled
}}> }}>
{children} {children}
</ConfigContext.Provider> </ConfigContext.Provider>

View file

@ -8,7 +8,8 @@ import {
useCallback, useCallback,
useMemo, useMemo,
useRef, useRef,
RefObject RefObject,
useEffect
} from 'react'; } from 'react';
import type { NavItem } from 'epubjs'; import type { NavItem } from 'epubjs';
@ -41,6 +42,8 @@ interface EPUBContextType {
handleLocationChanged: (location: string | number) => void; handleLocationChanged: (location: string | number) => void;
setRendition: (rendition: Rendition) => void; setRendition: (rendition: Rendition) => void;
isAudioCombining: boolean; isAudioCombining: boolean;
highlightPattern: (text: string) => void;
clearHighlights: () => void;
} }
const EPUBContext = createContext<EPUBContextType | undefined>(undefined); const EPUBContext = createContext<EPUBContextType | undefined>(undefined);
@ -139,6 +142,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
ttsModel, ttsModel,
ttsInstructions, ttsInstructions,
smartSentenceSplitting, smartSentenceSplitting,
epubHighlightEnabled,
} = useConfig(); } = useConfig();
// Current document state // Current document state
const [currDocData, setCurrDocData] = useState<ArrayBuffer>(); const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
@ -154,6 +158,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
const isEPUBSetOnce = useRef(false); const isEPUBSetOnce = useRef(false);
// Should pause ref // Should pause ref
const shouldPauseRef = useRef(true); const shouldPauseRef = useRef(true);
// Track current highlight CFI for removal
const currentHighlightCfi = useRef<string | null>(null);
/** /**
* Clears all current document state and stops any active TTS * 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 // Get TOC for chapter titles
const chapters = tocRef.current || []; const chapters = tocRef.current || [];
// If we have a bookId, check for existing chapters to determine which indices already exist // If we have a bookId, check for existing chapters to determine which indices already exist
const existingIndices = new Set<number>(); const existingIndices = new Set<number>();
if (bookId) { if (bookId) {
@ -329,21 +335,21 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
console.error('Error checking existing chapters:', error); console.error('Error checking existing chapters:', error);
} }
} }
// Create a map of section hrefs to their chapter titles // Create a map of section hrefs to their chapter titles
const sectionTitleMap = new Map<string, string>(); const sectionTitleMap = new Map<string, string>();
// First, loop through all chapters to create the mapping // First, loop through all chapters to create the mapping
for (const chapter of chapters) { for (const chapter of chapters) {
if (!chapter.href) continue; if (!chapter.href) continue;
const chapterBaseHref = chapter.href.split('#')[0]; const chapterBaseHref = chapter.href.split('#')[0];
const chapterTitle = chapter.label.trim(); const chapterTitle = chapter.label.trim();
// For each chapter, find all matching sections // For each chapter, find all matching sections
for (const section of sections) { for (const section of sections) {
const sectionHref = section.href; const sectionHref = section.href;
const sectionBaseHref = sectionHref.split('#')[0]; const sectionBaseHref = sectionHref.split('#')[0];
// If this section matches this chapter, map it // If this section matches this chapter, map it
if (sectionHref === chapter.href || sectionBaseHref === chapterBaseHref) { if (sectionHref === chapter.href || sectionBaseHref === chapterBaseHref) {
sectionTitleMap.set(sectionHref, chapterTitle); sectionTitleMap.set(sectionHref, chapterTitle);
@ -426,7 +432,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
// Get the chapter title from our pre-computed map // Get the chapter title from our pre-computed map
let chapterTitle = sectionTitleMap.get(section.href); let chapterTitle = sectionTitleMap.get(section.href);
// If no chapter title found, use index-based naming // If no chapter title found, use index-based naming
if (!chapterTitle) { if (!chapterTitle) {
chapterTitle = `Chapter ${i + 1}`; chapterTitle = `Chapter ${i + 1}`;
@ -466,7 +472,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
} }
const { bookId: returnedBookId, chapterIndex, duration } = await convertResponse.json(); const { bookId: returnedBookId, chapterIndex, duration } = await convertResponse.json();
if (!bookId) { if (!bookId) {
bookId = returnedBookId; bookId = returnedBookId;
} }
@ -495,7 +501,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
throw new Error('Audiobook generation cancelled'); throw new Error('Audiobook generation cancelled');
} }
console.error('Error processing section:', error); console.error('Error processing section:', error);
// Notify about error // Notify about error
if (onChapterComplete) { if (onChapterComplete) {
onChapterComplete({ onChapterComplete({
@ -537,7 +543,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
const section = sections[chapterIndex]; const section = sections[chapterIndex];
const trimmedText = section.text.trim(); const trimmedText = section.text.trim();
if (!trimmedText) { if (!trimmedText) {
throw new Error('No text content found in chapter'); throw new Error('No text content found in chapter');
} }
@ -545,16 +551,16 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
// Get TOC for chapter title // Get TOC for chapter title
const chapters = tocRef.current || []; const chapters = tocRef.current || [];
const sectionTitleMap = new Map<string, string>(); const sectionTitleMap = new Map<string, string>();
for (const chapter of chapters) { for (const chapter of chapters) {
if (!chapter.href) continue; if (!chapter.href) continue;
const chapterBaseHref = chapter.href.split('#')[0]; const chapterBaseHref = chapter.href.split('#')[0];
const chapterTitle = chapter.label.trim(); const chapterTitle = chapter.label.trim();
for (const sect of sections) { for (const sect of sections) {
const sectionHref = sect.href; const sectionHref = sect.href;
const sectionBaseHref = sectionHref.split('#')[0]; const sectionBaseHref = sectionHref.split('#')[0];
if (sectionHref === chapter.href || sectionBaseHref === chapterBaseHref) { if (sectionHref === chapter.href || sectionBaseHref === chapterBaseHref) {
sectionTitleMap.set(sectionHref, chapterTitle); sectionTitleMap.set(sectionHref, chapterTitle);
} }
@ -705,6 +711,69 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
} }
}, [id, skipToLocation, extractPageText, setIsEPUB]); }, [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 // Context value memoization
const contextValue = useMemo( const contextValue = useMemo(
() => ({ () => ({
@ -725,6 +794,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
handleLocationChanged, handleLocationChanged,
setRendition, setRendition,
isAudioCombining, isAudioCombining,
highlightPattern,
clearHighlights,
}), }),
[ [
setCurrentDocument, setCurrentDocument,
@ -740,6 +811,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
handleLocationChanged, handleLocationChanged,
setRendition, setRendition,
isAudioCombining, isAudioCombining,
highlightPattern,
clearHighlights,
] ]
); );

View file

@ -23,6 +23,7 @@ export interface AppConfigValues {
savedVoices: SavedVoices; savedVoices: SavedVoices;
smartSentenceSplitting: boolean; smartSentenceSplitting: boolean;
pdfHighlightEnabled: boolean; pdfHighlightEnabled: boolean;
epubHighlightEnabled: boolean;
firstVisit: boolean; firstVisit: boolean;
documentListState: DocumentListState; documentListState: DocumentListState;
} }
@ -46,6 +47,7 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = {
savedVoices: {}, savedVoices: {},
smartSentenceSplitting: true, smartSentenceSplitting: true,
pdfHighlightEnabled: true, pdfHighlightEnabled: true,
epubHighlightEnabled: true,
firstVisit: false, firstVisit: false,
documentListState: { documentListState: {
sortBy: 'name', sortBy: 'name',

View file

@ -89,7 +89,7 @@ async function resetAudiobookIfPresent(page: Page) {
await confirmReset.click(); await confirmReset.click();
await expect( 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 }); ).toBeVisible({ timeout: 60_000 });
} }
@ -267,7 +267,7 @@ test.describe('Audiobook export', () => {
// After reset, the hint text for starting generation should re-appear // After reset, the hint text for starting generation should re-appear
await expect( 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 }); ).toBeVisible({ timeout: 60_000 });
// Backend should report no existing chapters for this bookId // Backend should report no existing chapters for this bookId