feat: add keyboard shortcut t to toggle TTS (#1405)

This commit is contained in:
Huang Xin
2025-06-16 14:50:10 +08:00
committed by GitHub
parent c5a3b44cbf
commit 261ce95ed1
7 changed files with 48 additions and 35 deletions
@@ -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();
@@ -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,
+6 -27
View File
@@ -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;
@@ -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();
}
@@ -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;
+3 -2
View File
@@ -20,7 +20,7 @@ export interface FoliateView extends HTMLElement {
clearSearch: () => void;
select: (target: string | number | { fraction: number }) => void;
deselect: () => void;
initTTS: (granularity?: TTSGranularity) => Promise<void>;
initTTS: (granularity?: TTSGranularity, highlight?: (range: Range) => void) => Promise<void>;
book: BookDoc;
tts: TTS | null;
language: {
@@ -49,7 +49,8 @@ export interface FoliateView extends HTMLElement {
prevSection?: () => Promise<void>;
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,