Refactor Toast to global component (#122)

* Refactor Toast to global component, use eventDispatcher to dispatch toast events

* Error handling for TTS
This commit is contained in:
Huang Xin
2025-01-08 14:12:38 +01:00
committed by GitHub
parent 15d8d61841
commit 89b2eba2af
8 changed files with 105 additions and 67 deletions
+8 -22
View File
@@ -8,6 +8,7 @@ import { Book } from '@/types/book';
import { AppService } from '@/types/system';
import { navigateToReader } from '@/utils/nav';
import { getBaseFilename, listFormater } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { parseOpenWithFiles } from '@/helpers/cli';
import { isTauriAppPlatform, hasUpdater } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
@@ -21,11 +22,11 @@ import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useDemoBooks } from './hooks/useDemoBooks';
import { AboutWindow } from '@/components/AboutWindow';
import { Toast } from '@/components/Toast';
import Spinner from '@/components/Spinner';
import LibraryHeader from './components/LibraryHeader';
import Bookshelf from './components/Bookshelf';
import { AboutWindow } from '@/components/AboutWindow';
import Toast from '@/components/Toast';
const LibraryPage = () => {
const router = useRouter();
@@ -46,16 +47,6 @@ const LibraryPage = () => {
const [isSelectMode, setIsSelectMode] = useState(false);
const demoBooks = useDemoBooks();
const [toastMessage, setToastMessage] = useState('');
const toastDismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current);
toastDismissTimeout.current = setTimeout(() => setToastMessage(''), 5000);
return () => {
if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current);
};
}, [toastMessage]);
useEffect(() => {
const doAppUpdates = async () => {
if (hasUpdater()) {
@@ -175,11 +166,12 @@ const LibraryPage = () => {
const filename = typeof file === 'string' ? file : file.name;
const baseFilename = getBaseFilename(filename);
failedFiles.push(baseFilename);
setToastMessage(
_('Failed to import book(s): {{filenames}}', {
eventDispatcher.dispatch('toast', {
message: _('Failed to import book(s): {{filenames}}', {
filenames: listFormater(false).format(failedFiles),
}),
);
type: 'error',
});
console.error('Failed to import book:', filename, error);
}
}
@@ -283,13 +275,7 @@ const LibraryPage = () => {
</div>
))}
<AboutWindow />
{toastMessage && (
<Toast
message={toastMessage}
toastClass='toast-top toast-end pt-11'
alertClass='alert-error max-w-80'
/>
)}
<Toast />
</div>
);
};
@@ -18,7 +18,6 @@ import {
handleClick,
handleWheel,
} from '../utils/iframeEventHandlers';
import Toast from '@/components/Toast';
const FoliateViewer: React.FC<{
bookKey: string;
@@ -39,7 +38,7 @@ const FoliateViewer: React.FC<{
return () => clearTimeout(timer);
}, [toastMessage]);
useProgressSync(bookKey, setToastMessage);
useProgressSync(bookKey);
const progressRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
@@ -164,9 +163,6 @@ const FoliateViewer: React.FC<{
onClick={(event) => handleTurnPage(event)}
ref={containerRef}
/>
{toastMessage && (
<Toast message={toastMessage} toastClass='toast-top toast-end' alertClass='alert-success' />
)}
</>
);
};
@@ -5,10 +5,10 @@ import { useEffect, Suspense, useRef } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import ReaderContent from './ReaderContent';
import { AboutWindow } from '@/components/AboutWindow';
import { useSettingsStore } from '@/store/settingsStore';
import { AboutWindow } from '@/components/AboutWindow';
import { Toast } from '@/components/Toast';
import ReaderContent from './ReaderContent';
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { envConfig } = useEnv();
@@ -37,6 +37,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
<Suspense>
<ReaderContent ids={ids} settings={settings} />
<AboutWindow />
<Toast />
</Suspense>
</div>
)
@@ -23,7 +23,6 @@ import { useFoliateEvents } from '../../hooks/useFoliateEvents';
import { useNotesSync } from '../../hooks/useNotesSync';
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import Toast from '@/components/Toast';
import AnnotationPopup from './AnnotationPopup';
import WiktionaryPopup from './WiktionaryPopup';
import WikipediaPopup from './WikipediaPopup';
@@ -57,7 +56,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [annotPopupPosition, setAnnotPopupPosition] = useState<Position>();
const [dictPopupPosition, setDictPopupPosition] = useState<Position>();
const [translatorPopupPosition, setTranslatorPopupPosition] = useState<Position>();
const [toastMessage, setToastMessage] = useState('');
const [highlightOptionsVisible, setHighlightOptionsVisible] = useState(false);
const [selectedStyle, setSelectedStyle] = useState<HighlightStyle>(
@@ -196,11 +194,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection, bookKey]);
useEffect(() => {
const timer = setTimeout(() => setToastMessage(''), 2000);
return () => clearTimeout(timer);
}, [toastMessage]);
useEffect(() => {
if (!progress) return;
const { location } = progress;
@@ -225,7 +218,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const handleCopy = () => {
if (!selection || !selection.text) return;
setToastMessage(_('Copied to notebook'));
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Copied to notebook'),
className: 'whitespace-nowrap',
timeout: 2000,
});
const { booknotes: annotations = [] } = config;
if (selection) navigator.clipboard.writeText(selection.text);
@@ -414,8 +412,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onHighlight={handleHighlight}
/>
)}
{toastMessage && <Toast message={toastMessage} alertClass='bg-neutual text-content' />}
</div>
);
};
@@ -7,12 +7,14 @@ import { useReaderStore } from '@/store/readerStore';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
import { useTranslation } from '@/hooks/useTranslation';
const POPUP_WIDTH = 260;
const POPUP_HEIGHT = 80;
const POPUP_PADDING = 10;
const TTSControl = () => {
const _ = useTranslation();
const { getView } = useReaderStore();
const [isPlaying, setIsPlaying] = useState(false);
const [showPanel, setShowPanel] = useState(false);
@@ -44,11 +46,19 @@ const TTSControl = () => {
const view = getView(bookKey);
if (!view) return;
const ttsClient = new WebSpeechClient();
const ttsController = new TTSController(ttsClient, view);
ttsControllerRef.current = ttsController;
ttsControllerRef.current.speak(ssml);
setIsPlaying(true);
try {
const ttsClient = new WebSpeechClient();
const ttsController = new TTSController(ttsClient, view);
ttsControllerRef.current = ttsController;
ttsControllerRef.current.speak(ssml);
setIsPlaying(true);
} catch (error) {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported in this device'),
type: 'error',
});
console.error(error);
}
};
const handleTogglePlay = async () => {
@@ -59,7 +69,7 @@ const TTSControl = () => {
ttsController.pause();
setIsPlaying(false);
} else {
await ttsController.start();
ttsController.start();
setIsPlaying(true);
}
};
@@ -7,12 +7,10 @@ import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import { eventDispatcher } from '@/utils/event';
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
export const useProgressSync = (
bookKey: string,
setToastMessage?: React.Dispatch<React.SetStateAction<string>>,
) => {
export const useProgressSync = (bookKey: string) => {
const _ = useTranslation();
const { getConfig, setConfig } = useBookDataStore();
const { getView } = useReaderStore();
@@ -98,7 +96,10 @@ export const useProgressSync = (
const configFraction = config!.progress![0] / config!.progress![1];
if (syncedFraction > configFraction) {
view?.goToFraction(syncedFraction);
setToastMessage?.(_('Reading progress synced'));
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Reading progress synced'),
});
}
}
}
+62 -13
View File
@@ -1,16 +1,65 @@
import { eventDispatcher } from '@/utils/event';
import clsx from 'clsx';
import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
const Toast: React.FC<{ message: string; toastClass?: string; alertClass?: string }> = ({
message,
toastClass,
alertClass,
}) => (
<div className={clsx('toast toast-center toast-middle', toastClass)}>
<div className={clsx('alert flex items-center justify-center border-0', alertClass)}>
<span className='whitespace-normal break-words'>{message}</span>
</div>
</div>
);
export type ToastType = 'info' | 'success' | 'warning' | 'error';
export default Toast;
export const Toast = () => {
const [toastMessage, setToastMessage] = useState('');
const toastType = useRef<ToastType>('info');
const toastTimeout = useRef(5000);
const messageClass = useRef('');
const toastDismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const toastClassMap = {
info: 'toast-info toast-center toast-middle',
success: 'toast-success toast-top toast-end pt-11',
warning: 'toast-warning toast-top toast-end pt-11',
error: 'toast-error toast-top toast-end pt-11',
};
const alertClassMap = {
info: 'alert-primary',
success: 'alert-success',
warning: 'alert-warning',
error: 'alert-error',
};
useEffect(() => {
if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current);
toastDismissTimeout.current = setTimeout(() => setToastMessage(''), toastTimeout.current);
return () => {
if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current);
};
}, [toastMessage]);
const handleShowToast = async (event: CustomEvent) => {
const { message, type = 'info', timeout = 5000, className = '' } = event.detail;
setToastMessage(message);
toastType.current = type;
toastTimeout.current = timeout;
messageClass.current = className;
};
useEffect(() => {
eventDispatcher.on('toast', handleShowToast);
return () => {
eventDispatcher.off('toast', handleShowToast);
};
}, []);
return (
toastMessage && (
<div className={clsx('toast toast-center toast-middle', toastClassMap[toastType.current])}>
<div
className={clsx(
'alert flex max-w-80 items-center justify-center border-0',
alertClassMap[toastType.current],
)}
>
<span className={clsx('whitespace-normal break-words', messageClass.current)}>
{toastMessage}
</span>
</div>
</div>
)
);
};
@@ -72,7 +72,7 @@ function findMark(charIndex: number, marks: TTSMark[]) {
async function* speakWithBoundary(ssml: string) {
const lang = getXmlLang(ssml);
const { plainText, marks } = parseSSMLMarks(ssml);
// console.log('text', plainText, marks);
// console.log('text', ssml, plainText, marks);
const synth = window.speechSynthesis;
const utterance = new SpeechSynthesisUtterance(plainText);
@@ -85,7 +85,7 @@ async function* speakWithBoundary(ssml: string) {
utterance.onboundary = (event: SpeechSynthesisEvent) => {
const mark = findMark(event.charIndex, marks);
// console.log('boundary', mark);
// console.log('boundary', event.charIndex, mark);
queue.enqueue({
type: 'boundary',
speaking: true,
@@ -163,7 +163,6 @@ export class WebSpeechClient implements TTSClient {
async setRate(rate: number) {
// The Web Speech API uses utterance.rate in [0.1 .. 10],
// you might need to scale your “rate” to match.
if (this.#utterance) this.#utterance.rate = rate / 10;
}