commit
eb2fc4eb0d
8 changed files with 139 additions and 33 deletions
2
.github/workflows/playwright.yml
vendored
2
.github/workflows/playwright.yml
vendored
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 "Start Generation" 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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,11 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
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);
|
||||||
|
|
@ -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 />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue