diff --git a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx index 9c34fbcf..9484aab0 100644 --- a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx +++ b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx @@ -274,7 +274,7 @@ const TTSControl = () => { const handleStop = async (bookKey: string) => { const ttsController = ttsControllerRef.current; if (ttsController) { - await ttsController.stop(); + await ttsController.shutdown(); ttsControllerRef.current = null; setTtsController(null); getView(bookKey)?.deselect(); diff --git a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts index 92e52282..46dcc318 100644 --- a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts +++ b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts @@ -17,7 +17,7 @@ interface UseBookShortcutsProps { } const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) => { - const { getView, getViewSettings, setViewSettings } = useReaderStore(); + const { getView, getViewState, getViewSettings, setViewSettings } = useReaderStore(); const { toggleSideBar, setSideBarBookKey } = useSidebarStore(); const { setFontLayoutSettingsDialogOpen } = useSettingsStore(); const { toggleNotebook } = useNotebookStore(); @@ -136,6 +136,13 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = view?.renderer.setStyles?.(getStyles(viewSettings!)); }; + const toggleTTS = () => { + if (!sideBarBookKey) return; + const bookKey = sideBarBookKey; + const viewState = getViewState(bookKey); + eventDispatcher.dispatch(viewState?.ttsEnabled ? 'tts-stop' : 'tts-speak', { bookKey }); + }; + useShortcuts( { onSwitchSideBar: switchSideBar, @@ -144,8 +151,9 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = onToggleScrollMode: toggleScrollMode, onOpenFontLayoutSettings: () => setFontLayoutSettingsDialogOpen(true), onToggleSearchBar: showSearchBar, - onReloadPage: reloadPage, onToggleFullscreen: toggleFullscreen, + onToggleTTS: toggleTTS, + onReloadPage: reloadPage, onQuitApp: quitApp, onGoLeft: goLeft, onGoRight: goRight, diff --git a/apps/readest-app/src/helpers/shortcuts.ts b/apps/readest-app/src/helpers/shortcuts.ts index 2cdddf86..96e18722 100644 --- a/apps/readest-app/src/helpers/shortcuts.ts +++ b/apps/readest-app/src/helpers/shortcuts.ts @@ -1,36 +1,11 @@ -export interface ShortcutConfig { - onSwitchSideBar: string[]; - onToggleSideBar: string[]; - onToggleNotebook: string[]; - onToggleSearchBar: string[]; - onToggleScrollMode: string[]; - onToggleSelectMode: string[]; - onOpenFontLayoutSettings: string[]; - onReloadPage: string[]; - onToggleFullscreen: string[]; - onQuitApp: string[]; - onGoLeft: string[]; - onGoRight: string[]; - onGoNext: string[]; - onGoPrev: string[]; - onGoHalfPageDown: string[]; - onGoHalfPageUp: string[]; - onGoBack: string[]; - onGoForward: string[]; - onZoomIn: string[]; - onZoomOut: string[]; - onResetZoom: string[]; - onSaveNote: string[]; - onCloseNote: string[]; -} - -const DEFAULT_SHORTCUTS: ShortcutConfig = { +const DEFAULT_SHORTCUTS = { onSwitchSideBar: ['ctrl+Tab', 'opt+Tab', 'alt+Tab'], onToggleSideBar: ['s'], onToggleNotebook: ['n'], onToggleSearchBar: ['ctrl+f', 'cmd+f'], onToggleScrollMode: ['shift+j'], onToggleSelectMode: ['shift+s'], + onToggleTTS: ['t'], onOpenFontLayoutSettings: ['shift+f'], onReloadPage: ['shift+r'], onToggleFullscreen: ['F11'], @@ -50,6 +25,10 @@ const DEFAULT_SHORTCUTS: ShortcutConfig = { onCloseNote: ['Escape'], }; +export type ShortcutConfig = { + [K in keyof typeof DEFAULT_SHORTCUTS]: string[]; +}; + // Load shortcuts from localStorage or fallback to defaults export const loadShortcuts = (): ShortcutConfig => { if (typeof localStorage === 'undefined') return DEFAULT_SHORTCUTS; diff --git a/apps/readest-app/src/services/tts/TTSController.ts b/apps/readest-app/src/services/tts/TTSController.ts index 990433d0..36e5c00a 100644 --- a/apps/readest-app/src/services/tts/TTSController.ts +++ b/apps/readest-app/src/services/tts/TTSController.ts @@ -1,7 +1,8 @@ import { FoliateView } from '@/types/view'; import { AppService } from '@/types/system'; import { parseSSMLMarks } from '@/utils/ssml'; -import { TTSGranularity, TTSMark, TTSVoice } from './types'; +import { Overlayer } from 'foliate-js/overlayer.js'; +import { TTSGranularity, TTSHighlightOptions, TTSMark, TTSVoice } from './types'; import { WebSpeechClient } from './WebSpeechClient'; import { NativeTTSClient } from './NativeTTSClient'; import { EdgeTTSClient } from './EdgeTTSClient'; @@ -18,6 +19,8 @@ type TTSState = | 'setrate-paused' | 'setvoice-paused'; +const HIGHLIGHT_KEY = 'tts-highlight'; + export class TTSController extends EventTarget { appService: AppService | null = null; view: FoliateView; @@ -75,13 +78,29 @@ export class TTSController extends EventTarget { this.ttsEdgeVoices = await this.ttsEdgeClient.getAllVoices(); } + #getHighlighter(options: TTSHighlightOptions) { + return (range: Range) => { + const { style, color } = options; + const { overlayer } = this.view.renderer.getContents()[0] as { overlayer: Overlayer }; + overlayer?.remove(HIGHLIGHT_KEY); + overlayer?.add(HIGHLIGHT_KEY, range, Overlayer[style], { color }); + this.view.renderer.scrollToAnchor(range); + }; + } + + #clearHighlighter() { + const { overlayer } = this.view.renderer.getContents()[0] as { overlayer: Overlayer }; + overlayer?.remove(HIGHLIGHT_KEY); + } + async initViewTTS() { let granularity: TTSGranularity = this.view.language.isCJK ? 'sentence' : 'word'; const supportedGranularities = this.ttsClient.getGranularities(); if (!supportedGranularities.includes(granularity)) { granularity = supportedGranularities[0]!; } - await this.view.initTTS(granularity); + const highlightOptions: TTSHighlightOptions = { style: 'highlight', color: 'gray' }; + await this.view.initTTS(granularity, this.#getHighlighter(highlightOptions)); } async preloadSSML(ssml: string | undefined) { @@ -334,6 +353,7 @@ export class TTSController extends EventTarget { async shutdown() { await this.stop(); + this.#clearHighlighter(); if (this.ttsWebClient.initialized) { await this.ttsWebClient.shutdown(); } diff --git a/apps/readest-app/src/services/tts/types.ts b/apps/readest-app/src/services/tts/types.ts index b3a4182f..e4091d3d 100644 --- a/apps/readest-app/src/services/tts/types.ts +++ b/apps/readest-app/src/services/tts/types.ts @@ -1,5 +1,10 @@ export type TTSGranularity = 'sentence' | 'word'; +export type TTSHighlightOptions = { + style: 'highlight' | 'underline' | 'squiggly' | 'outline'; + color: string; +}; + export type TTSVoice = { id: string; name: string; diff --git a/apps/readest-app/src/types/view.ts b/apps/readest-app/src/types/view.ts index f8bc6211..27e785ba 100644 --- a/apps/readest-app/src/types/view.ts +++ b/apps/readest-app/src/types/view.ts @@ -20,7 +20,7 @@ export interface FoliateView extends HTMLElement { clearSearch: () => void; select: (target: string | number | { fraction: number }) => void; deselect: () => void; - initTTS: (granularity?: TTSGranularity) => Promise; + initTTS: (granularity?: TTSGranularity, highlight?: (range: Range) => void) => Promise; book: BookDoc; tts: TTS | null; language: { @@ -49,7 +49,8 @@ export interface FoliateView extends HTMLElement { prevSection?: () => Promise; goTo?: (params: { index: number; anchor: number }) => void; setStyles?: (css: string) => void; - getContents: () => { doc: Document; index?: number }[]; + getContents: () => { doc: Document; index?: number; overlayer?: unknown }[]; + scrollToAnchor: (anchor: number | Range) => void; addEventListener: ( type: string, listener: EventListener, diff --git a/packages/foliate-js b/packages/foliate-js index 530237ea..97f98795 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 530237eab50991c46fd4f770ed148d5a6aecb829 +Subproject commit 97f98795681d8a806ebfd756f39943a261f6dcaa