forked from akai/readest
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fe188814d | |||
| 6ec3a5b332 | |||
| 1acba2e08c | |||
| 32f9346f84 | |||
| 69c503026a | |||
| 84fc4cec5e | |||
| 5c9c11fafd | |||
| 77fa57438e | |||
| e4d217f3aa | |||
| c7a583c53a | |||
| 6c86917098 | |||
| 7c21f48d68 | |||
| 5820191c26 | |||
| b37a804192 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.50",
|
||||
"version": "0.9.51",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
|
||||
@@ -334,5 +334,8 @@
|
||||
"Scroll": "تمرير",
|
||||
"Overlap Pixels": "تداخل البكسلات",
|
||||
"Click": "نقر",
|
||||
"System Language": "لغة النظام"
|
||||
"System Language": "لغة النظام",
|
||||
"Security": "الأمان",
|
||||
"Allow JavaScript": "السماح بـ JavaScript",
|
||||
"Enable only if you trust the file.": "تمكين فقط إذا كنت تثق في الملف."
|
||||
}
|
||||
|
||||
@@ -330,5 +330,8 @@
|
||||
"Scroll": "Scrollen",
|
||||
"Overlap Pixels": "Überlappungspixel",
|
||||
"Click": "Tippen",
|
||||
"System Language": "Systemsprache"
|
||||
"System Language": "Systemsprache",
|
||||
"Security": "Sicherheit",
|
||||
"Allow JavaScript": "JavaScript zulassen",
|
||||
"Enable only if you trust the file.": "Nur aktivieren, wenn Sie der Datei vertrauen."
|
||||
}
|
||||
|
||||
@@ -330,5 +330,8 @@
|
||||
"Scroll": "Κύλιση",
|
||||
"Overlap Pixels": "Επικάλυψη εικονοστοιχείων",
|
||||
"Click": "Κλικ",
|
||||
"System Language": "Γλώσσα συστήματος"
|
||||
"System Language": "Γλώσσα συστήματος",
|
||||
"Security": "Ασφάλεια",
|
||||
"Allow JavaScript": "Επιτρέψτε JavaScript",
|
||||
"Enable only if you trust the file.": "Ενεργοποιήστε μόνο εάν εμπιστεύεστε το αρχείο."
|
||||
}
|
||||
|
||||
@@ -331,5 +331,8 @@
|
||||
"Scroll": "Desplazar",
|
||||
"Overlap Pixels": "Superponer píxeles",
|
||||
"Click": "Click",
|
||||
"System Language": "Idioma del sistema"
|
||||
"System Language": "Idioma del sistema",
|
||||
"Security": "Seguridad",
|
||||
"Allow JavaScript": "Permitir JavaScript",
|
||||
"Enable only if you trust the file.": "Permitir solo si confías en el archivo."
|
||||
}
|
||||
|
||||
@@ -331,5 +331,8 @@
|
||||
"Scroll": "Défilement",
|
||||
"Overlap Pixels": "Pixels de chevauchement",
|
||||
"Click": "Cliquer",
|
||||
"System Language": "Langue du système"
|
||||
"System Language": "Langue du système",
|
||||
"Security": "Sécurité",
|
||||
"Allow JavaScript": "Autoriser JavaScript",
|
||||
"Enable only if you trust the file.": "Activer uniquement si vous faites confiance au fichier."
|
||||
}
|
||||
|
||||
@@ -330,5 +330,8 @@
|
||||
"Scroll": "स्क्रॉल",
|
||||
"Overlap Pixels": "ओवरलैप पिक्सेल",
|
||||
"Click": "क्लिक करें",
|
||||
"System Language": "सिस्टम भाषा"
|
||||
"System Language": "सिस्टम भाषा",
|
||||
"Security": "सुरक्षा",
|
||||
"Allow JavaScript": "जावास्क्रिप्ट की अनुमति दें",
|
||||
"Enable only if you trust the file.": "केवल तभी सक्षम करें जब आप फ़ाइल पर भरोसा करते हों।"
|
||||
}
|
||||
|
||||
@@ -329,5 +329,8 @@
|
||||
"Scroll": "Scroll",
|
||||
"Overlap Pixels": "Overlap Piksel",
|
||||
"Click": "Klik",
|
||||
"System Language": "Bahasa Sistem"
|
||||
"System Language": "Bahasa Sistem",
|
||||
"Security": "Keamanan",
|
||||
"Allow JavaScript": "Izinkan JavaScript",
|
||||
"Enable only if you trust the file.": "Aktifkan hanya jika Anda mempercayai file tersebut."
|
||||
}
|
||||
|
||||
@@ -331,5 +331,8 @@
|
||||
"Scroll": "Scorri",
|
||||
"Overlap Pixels": "Pixel di sovrapposizione",
|
||||
"Click": "Fai clic",
|
||||
"System Language": "Lingua di sistema"
|
||||
"System Language": "Lingua di sistema",
|
||||
"Security": "Security",
|
||||
"Allow JavaScript": "Consenti JavaScript",
|
||||
"Enable only if you trust the file.": "Abilita solo se ti fidi del file."
|
||||
}
|
||||
|
||||
@@ -329,5 +329,8 @@
|
||||
"Scroll": "スクロール",
|
||||
"Overlap Pixels": "重なりピクセル",
|
||||
"Click": "クリック",
|
||||
"System Language": "システム言語"
|
||||
"System Language": "システム言語",
|
||||
"Security": "セキュリティ",
|
||||
"Allow JavaScript": "JavaScriptを許可",
|
||||
"Enable only if you trust the file.": "ファイルを信頼する場合にのみ有効にしてください。"
|
||||
}
|
||||
|
||||
@@ -329,5 +329,8 @@
|
||||
"Scroll": "스크롤",
|
||||
"Overlap Pixels": "겹치는 픽셀",
|
||||
"Click": "클릭",
|
||||
"System Language": "시스템 언어"
|
||||
"System Language": "시스템 언어",
|
||||
"Security": "보안",
|
||||
"Allow JavaScript": "JavaScript 허용",
|
||||
"Enable only if you trust the file.": "파일을 신뢰하는 경우에만 활성화하세요."
|
||||
}
|
||||
|
||||
@@ -330,5 +330,8 @@
|
||||
"Scroll": "Scrollen",
|
||||
"Overlap Pixels": "Overlap pixels",
|
||||
"Click": "Klik",
|
||||
"System Language": "Systemtaal"
|
||||
"System Language": "Systemtaal",
|
||||
"Security": "Beveiliging",
|
||||
"Allow JavaScript": "JavaScript toestaan",
|
||||
"Enable only if you trust the file.": "Schakel alleen in als u het bestand vertrouwt."
|
||||
}
|
||||
|
||||
@@ -332,5 +332,8 @@
|
||||
"Scroll": "Przewijaj",
|
||||
"Overlap Pixels": "Przesunięcie pikseli",
|
||||
"Click": "Kliknij",
|
||||
"System Language": "Język systemu"
|
||||
"System Language": "Język systemu",
|
||||
"Security": "Bezpieczeństwo",
|
||||
"Allow JavaScript": "Zezwól na JavaScript",
|
||||
"Enable only if you trust the file.": "Zezwól tylko wtedy, gdy ufasz plikowi."
|
||||
}
|
||||
|
||||
@@ -331,5 +331,8 @@
|
||||
"Scroll": "Rolar",
|
||||
"Overlap Pixels": "Sobreposição de Pixels",
|
||||
"Click": "Clique",
|
||||
"System Language": "Idioma do Sistema"
|
||||
"System Language": "Idioma do Sistema",
|
||||
"Security": "Segurança",
|
||||
"Allow JavaScript": "Permitir JavaScript",
|
||||
"Enable only if you trust the file.": "Ativar apenas se você confiar no arquivo."
|
||||
}
|
||||
|
||||
@@ -332,5 +332,8 @@
|
||||
"Scroll": "Прокрутка",
|
||||
"Overlap Pixels": "Перекрывающиеся пиксели",
|
||||
"Click": "Клик",
|
||||
"System Language": "Системный язык"
|
||||
"System Language": "Системный язык",
|
||||
"Security": "Безопасность",
|
||||
"Allow JavaScript": "Разрешить JavaScript",
|
||||
"Enable only if you trust the file.": "Включите только если доверяете файлу."
|
||||
}
|
||||
|
||||
@@ -330,5 +330,8 @@
|
||||
"Scroll": "Kaydır",
|
||||
"Overlap Pixels": "Örtüşen Pikseller",
|
||||
"Click": "Klik",
|
||||
"System Language": "System Dili"
|
||||
"System Language": "System Dili",
|
||||
"Security": "Security",
|
||||
"Allow JavaScript": "JavaScript'e İzin Ver",
|
||||
"Enable only if you trust the file.": "Yalnızca dosyaya güveniyorsanız etkinleştirin."
|
||||
}
|
||||
|
||||
@@ -332,5 +332,8 @@
|
||||
"Scroll": "Прокрутка",
|
||||
"Overlap Pixels": "Пікселі перекриття",
|
||||
"Click": "Клацнути",
|
||||
"System Language": "Системна мова"
|
||||
"System Language": "Системна мова",
|
||||
"Security": "Безпека",
|
||||
"Allow JavaScript": "Дозволити JavaScript",
|
||||
"Enable only if you trust the file.": "Увімкніть лише якщо довіряєте файлу."
|
||||
}
|
||||
|
||||
@@ -329,5 +329,8 @@
|
||||
"Scroll": "Cuộn",
|
||||
"Overlap Pixels": "Chồng lấn pixel",
|
||||
"Click": "Nhấp",
|
||||
"System Language": "Ngôn ngữ hệ thống"
|
||||
"System Language": "Ngôn ngữ hệ thống",
|
||||
"Security": "An ninh",
|
||||
"Allow JavaScript": "Cho phép JavaScript",
|
||||
"Enable only if you trust the file.": "Chỉ bật nếu bạn tin tưởng tệp."
|
||||
}
|
||||
|
||||
@@ -329,5 +329,8 @@
|
||||
"Scroll": "滚动",
|
||||
"Overlap Pixels": "重叠像素",
|
||||
"Click": "点击",
|
||||
"System Language": "系统语言"
|
||||
"System Language": "系统语言",
|
||||
"Security": "安全",
|
||||
"Allow JavaScript": "允许 JavaScript",
|
||||
"Enable only if you trust the file.": "建議僅在您信任該檔案時啟用"
|
||||
}
|
||||
|
||||
@@ -329,5 +329,8 @@
|
||||
"Scroll": "滾動",
|
||||
"Overlap Pixels": "重疊像素",
|
||||
"Click": "點擊",
|
||||
"System Language": "系統語言"
|
||||
"System Language": "系統語言",
|
||||
"Security": "安全",
|
||||
"Allow JavaScript": "允許 JavaScript",
|
||||
"Enable only if you trust the file.": "僅在您信任該文件時啟用"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.51": {
|
||||
"date": "2025-06-01",
|
||||
"notes": [
|
||||
"Translator: Table of contents is now included in translations",
|
||||
"Library Theme: Added light, dark, and system theme options to the library page",
|
||||
"TTS: Now shows the current sentence and chapter in the media session",
|
||||
"TTS: Text-to-speech continues from the last read sentence",
|
||||
"Pagination Fix: Taking notes no longer breaks page turning",
|
||||
"EPUB Scripts: Added a setting to allow JavaScript in EPUB files"
|
||||
]
|
||||
},
|
||||
"0.9.50": {
|
||||
"date": "2025-05-28",
|
||||
"notes": [
|
||||
|
||||
@@ -4,12 +4,15 @@ import { useRouter } from 'next/navigation';
|
||||
import { PiUserCircle } from 'react-icons/pi';
|
||||
import { PiUserCircleCheck } from 'react-icons/pi';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
import { TbSunMoon } from 'react-icons/tb';
|
||||
import { BiMoon, BiSun } from 'react-icons/bi';
|
||||
|
||||
import { setAboutDialogVisible } from '@/components/AboutWindow';
|
||||
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
@@ -31,6 +34,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { token, user } = useAuth();
|
||||
const { themeMode, setThemeMode } = useThemeStore();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
|
||||
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
|
||||
@@ -48,6 +52,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setAboutDialogVisible(true);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const downloadReadest = () => {
|
||||
window.open(DOWNLOAD_READEST_URL, '_blank');
|
||||
setIsDropdownOpen?.(false);
|
||||
@@ -63,6 +68,11 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const cycleThemeMode = () => {
|
||||
const nextMode = themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
|
||||
setThemeMode(nextMode);
|
||||
};
|
||||
|
||||
const handleReloadPage = () => {
|
||||
window.location.reload();
|
||||
setIsDropdownOpen?.(false);
|
||||
@@ -226,6 +236,17 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
onClick={toggleScreenWakeLock}
|
||||
/>
|
||||
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
|
||||
<MenuItem
|
||||
label={
|
||||
themeMode === 'dark'
|
||||
? _('Dark Mode')
|
||||
: themeMode === 'light'
|
||||
? _('Light Mode')
|
||||
: _('Auto Mode')
|
||||
}
|
||||
Icon={themeMode === 'dark' ? BiMoon : themeMode === 'light' ? BiSun : TbSunMoon}
|
||||
onClick={cycleThemeMode}
|
||||
/>
|
||||
<hr className='border-base-200 my-1' />
|
||||
{isWebAppPlatform() && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
|
||||
<MenuItem label={_('About Readest')} onClick={showAboutReadest} />
|
||||
|
||||
@@ -41,8 +41,8 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { hoveredBookKey, setHoveredBookKey, getView, getProgress, getViewSettings } =
|
||||
useReaderStore();
|
||||
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
|
||||
const { getView, getViewState, getProgress, getViewSettings } = useReaderStore();
|
||||
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
|
||||
const [actionTab, setActionTab] = React.useState('');
|
||||
const sliderHeight = useResponsiveSize(28);
|
||||
@@ -53,6 +53,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
const view = getView(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const viewState = getViewState(bookKey);
|
||||
|
||||
const handleProgressChange = (value: number) => {
|
||||
view?.goToFraction(value / 100.0);
|
||||
@@ -108,11 +109,10 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
|
||||
const handleSpeakText = async () => {
|
||||
if (!view || !progress) return;
|
||||
const { range } = progress;
|
||||
if (eventDispatcher.dispatchSync('tts-is-speaking')) {
|
||||
eventDispatcher.dispatch('tts-stop', { bookKey });
|
||||
} else {
|
||||
eventDispatcher.dispatch('tts-speak', { bookKey, range });
|
||||
eventDispatcher.dispatch('tts-speak', { bookKey });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -147,6 +147,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
};
|
||||
|
||||
const isVisible = hoveredBookKey === bookKey;
|
||||
const ttsEnabled = viewState?.ttsEnabled;
|
||||
const progressInfo = bookFormat === 'PDF' ? section : pageinfo;
|
||||
const progressValid = !!progressInfo;
|
||||
const progressFraction =
|
||||
@@ -312,7 +313,10 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
}
|
||||
onClick={() => handleSetActionTab('font')}
|
||||
/>
|
||||
<Button icon={<TTSIcon className='' />} onClick={() => handleSetActionTab('tts')} />
|
||||
<Button
|
||||
icon={<TTSIcon className={ttsEnabled ? 'text-blue-500' : ''} />}
|
||||
onClick={() => handleSetActionTab('tts')}
|
||||
/>
|
||||
</div>
|
||||
{/* Desktop footer bar */}
|
||||
<div className='hidden w-full items-center gap-x-4 px-4 sm:flex'>
|
||||
@@ -351,7 +355,11 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
handleProgressChange(parseInt((e.target as HTMLInputElement).value, 10))
|
||||
}
|
||||
/>
|
||||
<Button icon={<FaHeadphones />} onClick={handleSpeakText} tooltip={_('Speak')} />
|
||||
<Button
|
||||
icon={<FaHeadphones className={ttsEnabled ? 'text-blue-500' : ''} />}
|
||||
onClick={handleSpeakText}
|
||||
tooltip={_('Speak')}
|
||||
/>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowLeftSLine /> : <RiArrowRightSLine />}
|
||||
onClick={viewSettings?.rtl ? handleGoPrevPage : handleGoNextPage}
|
||||
|
||||
@@ -120,6 +120,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
<SidebarToggler bookKey={bookKey} />
|
||||
</div>
|
||||
<BookmarkToggler bookKey={bookKey} />
|
||||
<TranslationToggler bookKey={bookKey} />
|
||||
</div>
|
||||
|
||||
<div className='header-title z-15 bg-base-100 pointer-events-none absolute inset-0 hidden items-center justify-center sm:flex'>
|
||||
@@ -129,7 +130,6 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
</div>
|
||||
|
||||
<div className='bg-base-100 z-20 ml-auto flex h-full items-center space-x-4'>
|
||||
<TranslationToggler bookKey={bookKey} />
|
||||
<SettingsToggler />
|
||||
<NotebookToggler bookKey={bookKey} />
|
||||
<Dropdown
|
||||
|
||||
@@ -8,10 +8,11 @@ import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { saveViewSettings } from '../utils/viewSettingsHelper';
|
||||
import { isSameLang } from '@/utils/lang';
|
||||
import Button from '@/components/Button';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
|
||||
const TranslationToggler = ({ bookKey }: { bookKey: string }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getViewSettings, setViewSettings, setHoveredBookKey } = useReaderStore();
|
||||
|
||||
@@ -19,9 +20,14 @@ const TranslationToggler = ({ bookKey }: { bookKey: string }) => {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const [translationEnabled, setTranslationEnabled] = useState(viewSettings.translationEnabled!);
|
||||
|
||||
const primaryLanguage = bookData?.book?.primaryLanguage;
|
||||
const targetLanguage = viewSettings.translateTargetLang;
|
||||
|
||||
useEffect(() => {
|
||||
if (translationEnabled === viewSettings.translationEnabled) return;
|
||||
setHoveredBookKey('');
|
||||
if (appService?.isMobile) {
|
||||
setHoveredBookKey('');
|
||||
}
|
||||
saveViewSettings(envConfig, bookKey, 'translationEnabled', translationEnabled, true, false);
|
||||
viewSettings.translationEnabled = translationEnabled;
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
@@ -34,7 +40,10 @@ const TranslationToggler = ({ bookKey }: { bookKey: string }) => {
|
||||
<RiTranslateAi className={translationEnabled ? 'text-blue-500' : 'text-base-content'} />
|
||||
}
|
||||
disabled={
|
||||
!bookData || isSameLang(bookData.book?.primaryLanguage, viewSettings.translateTargetLang!)
|
||||
!bookData ||
|
||||
bookData.book?.format === 'PDF' ||
|
||||
isSameLang(primaryLanguage, targetLanguage) ||
|
||||
(!targetLanguage && isSameLang(primaryLanguage, getLocale()))
|
||||
}
|
||||
onClick={() => setTranslationEnabled(!translationEnabled)}
|
||||
tooltip={translationEnabled ? _('Disable Translation') : _('Enable Translation')}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { ThemeMode } from '@/styles/themes';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { tauriHandleToggleFullScreen } from '@/utils/window';
|
||||
@@ -51,8 +50,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
};
|
||||
|
||||
const cycleThemeMode = () => {
|
||||
const nextMode: ThemeMode =
|
||||
themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
|
||||
const nextMode = themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
|
||||
setThemeMode(nextMode);
|
||||
};
|
||||
|
||||
|
||||
@@ -91,8 +91,15 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
view?.deselect();
|
||||
};
|
||||
|
||||
const { handleScroll, handlePointerup, handleSelectionchange, handleShowPopup, handleUpToPopup } =
|
||||
useTextSelector(bookKey, setSelection, handleDismissPopup);
|
||||
const {
|
||||
handleScroll,
|
||||
handleTouchStart,
|
||||
handleTouchEnd,
|
||||
handlePointerup,
|
||||
handleSelectionchange,
|
||||
handleShowPopup,
|
||||
handleUpToPopup,
|
||||
} = useTextSelector(bookKey, setSelection, handleDismissPopup);
|
||||
|
||||
const onLoad = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
@@ -105,7 +112,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
};
|
||||
if (bookData.book?.format !== 'PDF') {
|
||||
view?.renderer?.addEventListener('scroll', handleScroll);
|
||||
detail.doc?.addEventListener('touchstart', handleTouchStart);
|
||||
detail.doc?.addEventListener('touchmove', handleTouchmove);
|
||||
detail.doc?.addEventListener('touchend', handleTouchEnd);
|
||||
detail.doc?.addEventListener('pointerup', () => handlePointerup(doc, index));
|
||||
detail.doc?.addEventListener('selectionchange', () => handleSelectionchange(doc, index));
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
|
||||
setTranslation(null);
|
||||
|
||||
try {
|
||||
const input = text.replaceAll('\n', ' ').trim();
|
||||
const input = text.replaceAll('\n', '').trim();
|
||||
const result = await translate([input]);
|
||||
const translatedText = result[0];
|
||||
const detectedSource = null;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
@@ -12,7 +13,9 @@ const ControlPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
|
||||
const bookData = getBookData(bookKey)!;
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const [isScrolledMode, setScrolledMode] = useState(viewSettings.scrolled!);
|
||||
@@ -22,6 +25,7 @@ const ControlPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
|
||||
const [swapClickArea, setSwapClickArea] = useState(viewSettings.swapClickArea!);
|
||||
const [animated, setAnimated] = useState(viewSettings.animated!);
|
||||
const [allowScript, setAllowScript] = useState(viewSettings.allowScript!);
|
||||
|
||||
useEffect(() => {
|
||||
if (isScrolledMode === viewSettings.scrolled) return;
|
||||
@@ -78,6 +82,13 @@ const ControlPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [animated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewSettings.allowScript === allowScript) return;
|
||||
saveViewSettings(envConfig, bookKey, 'allowScript', allowScript, true, false);
|
||||
setTimeout(() => window.location.reload(), 100);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allowScript]);
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
<div className='w-full'>
|
||||
@@ -169,6 +180,27 @@ const ControlPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Security')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item !h-16'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className=''>{_('Allow JavaScript')}</span>
|
||||
<span className='text-xs'>{_('Enable only if you trust the file.')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={allowScript}
|
||||
disabled={bookData.book?.format !== 'EPUB'}
|
||||
onChange={() => setAllowScript(!allowScript)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { findParentPath } from '@/utils/toc';
|
||||
import { getContentMd5 } from '@/utils/misc';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { BookProgress } from '@/types/book';
|
||||
import { useTextTranslation } from '../../hooks/useTextTranslation';
|
||||
|
||||
const createExpanderIcon = (isExpanded: boolean) => {
|
||||
return (
|
||||
@@ -129,6 +130,8 @@ const TOCView: React.FC<{
|
||||
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
||||
const viewRef = useRef<HTMLUListElement | null>(null);
|
||||
|
||||
useTextTranslation(bookKey, viewRef.current);
|
||||
|
||||
const expandParents = (toc: TOCItem[], href: string) => {
|
||||
const parentPath = findParentPath(toc, href).map((item) => item.href);
|
||||
setExpandedItems(parentPath.filter(Boolean) as string[]);
|
||||
|
||||
@@ -5,13 +5,14 @@ import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { TTSController, SILENCE_DATA } from '@/services/tts';
|
||||
import { TTSController, SILENCE_DATA, TTSMark } from '@/services/tts';
|
||||
import { getPopupPosition, Position } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { parseSSMLLang } from '@/utils/ssml';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { invokeUseBackgroundAudio } from '@/utils/bridge';
|
||||
import { CFI } from '@/libs/document';
|
||||
import Popup from '@/components/Popup';
|
||||
import TTSPanel from './TTSPanel';
|
||||
import TTSIcon from './TTSIcon';
|
||||
@@ -24,7 +25,8 @@ const TTSControl = () => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const { getView, getProgress, getViewSettings } = useReaderStore();
|
||||
const { setViewSettings, setTTSEnabled } = useReaderStore();
|
||||
const [bookKey, setBookKey] = useState<string>('');
|
||||
const [ttsLang, setTtsLang] = useState<string>('en');
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -45,8 +47,10 @@ const TTSControl = () => {
|
||||
const popupHeight = useResponsiveSize(POPUP_HEIGHT);
|
||||
|
||||
const iconRef = useRef<HTMLDivElement>(null);
|
||||
const ttsControllerRef = useRef<TTSController | null>(null);
|
||||
const unblockerAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const ttsControllerRef = useRef<TTSController | null>(null);
|
||||
const [ttsController, setTtsController] = useState<TTSController | null>(null);
|
||||
const [ttsClientsInited, setTtsClientsInitialized] = useState(false);
|
||||
|
||||
// this enables WebAudio to play even when the mute toggle switch is ON
|
||||
const unblockAudio = () => {
|
||||
@@ -95,12 +99,58 @@ const TTSControl = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ttsController || !bookKey) return;
|
||||
const bookData = getBookData(bookKey);
|
||||
if (!bookData || !bookData.book) return;
|
||||
const { title, author, coverImageUrl } = bookData.book;
|
||||
|
||||
const handleSpeakMark = (e: Event) => {
|
||||
const progress = getProgress(bookKey);
|
||||
const { sectionLabel } = progress || {};
|
||||
const mark = (e as CustomEvent<TTSMark>).detail;
|
||||
if (appService?.isMobileApp && 'mediaSession' in navigator) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: mark.text,
|
||||
artist: sectionLabel || title,
|
||||
album: author,
|
||||
artwork: [{ src: coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleHighlightMark = (e: Event) => {
|
||||
const range = (e as CustomEvent<Range>).detail;
|
||||
const view = getView(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
if (!range || !view || !progress || !viewSettings) return;
|
||||
|
||||
const { location } = progress;
|
||||
const { index } = view.resolveCFI(location);
|
||||
const cfi = view?.getCFI(index, range);
|
||||
if (cfi) {
|
||||
viewSettings.ttsLocation = cfi || '';
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
}
|
||||
};
|
||||
|
||||
ttsController.addEventListener('tts-speak-mark', handleSpeakMark);
|
||||
ttsController.addEventListener('tts-highlight-mark', handleHighlightMark);
|
||||
return () => {
|
||||
ttsController.removeEventListener('tts-speak-mark', handleSpeakMark);
|
||||
ttsController.removeEventListener('tts-highlight-mark', handleHighlightMark);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ttsController, bookKey]);
|
||||
|
||||
const handleTTSSpeak = async (event: CustomEvent) => {
|
||||
const { bookKey, range } = event.detail;
|
||||
const view = getView(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const bookData = getBookData(bookKey);
|
||||
if (!view || !viewSettings || !bookData || !bookData.book) return;
|
||||
if (!view || !progress || !viewSettings || !bookData || !bookData.book) return;
|
||||
if (bookData.book?.format === 'PDF') {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('TTS not supported for PDF'),
|
||||
@@ -109,6 +159,21 @@ const TTSControl = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
let ttsFromRange = range || progress.range;
|
||||
if (viewSettings.ttsLocation) {
|
||||
const { location } = progress;
|
||||
const ttsCfi = viewSettings.ttsLocation;
|
||||
const start = CFI.collapse(location);
|
||||
const end = CFI.collapse(location, true);
|
||||
if (CFI.compare(start, ttsCfi) * CFI.compare(end, ttsCfi) <= 0) {
|
||||
const { index, anchor } = view.resolveCFI(ttsCfi);
|
||||
const { doc } = view.renderer.getContents().find((x) => (x.index = index)) || {};
|
||||
if (doc) {
|
||||
ttsFromRange = anchor(doc) || ttsFromRange;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const primaryLang = bookData.book.primaryLanguage;
|
||||
setBookKey(bookKey);
|
||||
|
||||
@@ -116,6 +181,8 @@ const TTSControl = () => {
|
||||
ttsControllerRef.current.stop();
|
||||
ttsControllerRef.current = null;
|
||||
}
|
||||
|
||||
setTTSEnabled(bookKey, true);
|
||||
setShowIndicator(true);
|
||||
|
||||
try {
|
||||
@@ -125,10 +192,11 @@ const TTSControl = () => {
|
||||
if (getOSPlatform() === 'ios' || appService?.isIOSApp) {
|
||||
unblockAudio();
|
||||
}
|
||||
setTtsClientsInitialized(false);
|
||||
const ttsController = new TTSController(view);
|
||||
await ttsController.init();
|
||||
await ttsController.initViewTTS();
|
||||
const ssml = view.tts?.from(range);
|
||||
const ssml = view.tts?.from(ttsFromRange);
|
||||
if (ssml) {
|
||||
let lang = parseSSMLLang(ssml) || 'en';
|
||||
// We will not trust 'en' language from ssml, as it may be a fallback or hardcoded value
|
||||
@@ -142,7 +210,9 @@ const TTSControl = () => {
|
||||
ttsController.setRate(viewSettings.ttsRate);
|
||||
ttsController.speak(ssml);
|
||||
ttsControllerRef.current = ttsController;
|
||||
setTtsController(ttsController);
|
||||
}
|
||||
setTtsClientsInitialized(true);
|
||||
} catch (error) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('TTS not supported in this device'),
|
||||
@@ -152,9 +222,10 @@ const TTSControl = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTTSStop = async () => {
|
||||
const handleTTSStop = async (event: CustomEvent) => {
|
||||
const { bookKey } = event.detail;
|
||||
if (ttsControllerRef.current) {
|
||||
handleStop();
|
||||
handleStop(bookKey);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -197,11 +268,12 @@ const TTSControl = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
const handleStop = async (bookKey: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
await ttsController.stop();
|
||||
ttsControllerRef.current = null;
|
||||
setTtsController(null);
|
||||
getView(bookKey)?.deselect();
|
||||
setIsPlaying(false);
|
||||
setShowPanel(false);
|
||||
@@ -213,6 +285,7 @@ const TTSControl = () => {
|
||||
if (getOSPlatform() === 'ios' || appService?.isIOSApp) {
|
||||
releaseUnblockAudio();
|
||||
}
|
||||
setTTSEnabled(bookKey, false);
|
||||
};
|
||||
|
||||
// rate range: 0.5 - 3, 1.0 is normal speed
|
||||
@@ -266,7 +339,7 @@ const TTSControl = () => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleSelectTimeout = (value: number) => {
|
||||
const handleSelectTimeout = (bookKey: string, value: number) => {
|
||||
setTimeoutOption(value);
|
||||
if (timeoutFunc) {
|
||||
clearTimeout(timeoutFunc);
|
||||
@@ -274,7 +347,7 @@ const TTSControl = () => {
|
||||
if (value > 0) {
|
||||
setTimeoutFunc(
|
||||
setTimeout(() => {
|
||||
handleStop();
|
||||
handleStop(bookKey);
|
||||
}, value * 1000),
|
||||
);
|
||||
setTimeoutTimestamp(Date.now() + value * 1000);
|
||||
@@ -356,10 +429,10 @@ const TTSControl = () => {
|
||||
: 'bottom-[70px] sm:bottom-14',
|
||||
)}
|
||||
>
|
||||
<TTSIcon isPlaying={isPlaying} onClick={togglePopup} />
|
||||
<TTSIcon isPlaying={isPlaying} ttsInited={ttsClientsInited} onClick={togglePopup} />
|
||||
</div>
|
||||
)}
|
||||
{showPanel && panelPosition && trianglePosition && (
|
||||
{showPanel && panelPosition && trianglePosition && ttsClientsInited && (
|
||||
<Popup
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
type TTSIconProps = {
|
||||
isPlaying: boolean;
|
||||
ttsInited: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, onClick }) => {
|
||||
const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, ttsInited, onClick }) => {
|
||||
const bars = [1, 2, 3, 4];
|
||||
|
||||
return (
|
||||
<div className='relative h-full w-full cursor-pointer' onClick={onClick}>
|
||||
<div
|
||||
className={clsx(
|
||||
'relative h-full w-full',
|
||||
ttsInited ? 'cursor-pointer' : 'cursor-not-allowed',
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className='absolute inset-0 overflow-hidden rounded-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'>
|
||||
<div
|
||||
className='absolute -inset-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'
|
||||
style={{
|
||||
animation: isPlaying ? 'moveGradient 2s alternate infinite' : 'none',
|
||||
animation: isPlaying && ttsInited ? 'moveGradient 2s alternate infinite' : 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ type TTSPanelProps = {
|
||||
onGetVoices: (lang: string) => Promise<TTSVoice[]>;
|
||||
onSetVoice: (voice: string, lang: string) => void;
|
||||
onGetVoiceId: () => string;
|
||||
onSelectTimeout: (value: number) => void;
|
||||
onSelectTimeout: (bookKey: string, value: number) => void;
|
||||
};
|
||||
|
||||
const getTTSTimeoutOptions = (_: TranslationFunc) => {
|
||||
@@ -155,7 +155,7 @@ const TTSPanel = ({
|
||||
const updateTimeout = (timeout: number) => {
|
||||
const now = Date.now();
|
||||
if (timeout > 0 && timeout < now) {
|
||||
onSelectTimeout(0);
|
||||
onSelectTimeout(bookKey, 0);
|
||||
setTimeoutCountdown('');
|
||||
} else if (timeout > 0) {
|
||||
setTimeoutCountdown(getCountdownTime(timeout));
|
||||
@@ -256,7 +256,10 @@ const TTSPanel = ({
|
||||
)}
|
||||
>
|
||||
{timeoutOptions.map((option, index) => (
|
||||
<li key={`${index}-${option.value}`} onClick={() => onSelectTimeout(option.value)}>
|
||||
<li
|
||||
key={`${index}-${option.value}`}
|
||||
onClick={() => onSelectTimeout(bookKey, option.value)}
|
||||
>
|
||||
<div className='flex items-center px-2'>
|
||||
<span style={{ minWidth: `${defaultIconSize}px` }}>
|
||||
{timeoutOption === option.value && <MdCheck className='text-base-content' />}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
@@ -14,7 +14,6 @@ export const useTextSelector = (
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const bookData = getBookData(bookKey)!;
|
||||
const primaryLang = bookData.book?.primaryLanguage || 'en';
|
||||
const osPlatform = getOSPlatform();
|
||||
@@ -22,7 +21,9 @@ export const useTextSelector = (
|
||||
const isPopuped = useRef(false);
|
||||
const isUpToPopup = useRef(false);
|
||||
const isTextSelected = useRef(false);
|
||||
const isTouchStarted = useRef(false);
|
||||
const selectionPosition = useRef<number | null>(null);
|
||||
const [textSelected, setTextSelected] = useState(false);
|
||||
|
||||
const isValidSelection = (sel: Selection) => {
|
||||
return sel && sel.toString().trim().length > 0 && sel.rangeCount > 0;
|
||||
@@ -41,6 +42,7 @@ export const useTextSelector = (
|
||||
};
|
||||
const makeSelection = async (sel: Selection, index: number, rebuildRange = false) => {
|
||||
isTextSelected.current = true;
|
||||
setTextSelected(true);
|
||||
const range = sel.getRangeAt(0);
|
||||
if (rebuildRange) {
|
||||
sel.removeAllRanges();
|
||||
@@ -51,6 +53,7 @@ export const useTextSelector = (
|
||||
// FIXME: extremely hacky way to dismiss system selection tools on iOS
|
||||
const makeSelectionOnIOS = async (sel: Selection, index: number) => {
|
||||
isTextSelected.current = true;
|
||||
setTextSelected(true);
|
||||
const range = sel.getRangeAt(0);
|
||||
setTimeout(() => {
|
||||
sel.removeAllRanges();
|
||||
@@ -70,6 +73,7 @@ export const useTextSelector = (
|
||||
if (!isUpToPopup.current) {
|
||||
handleDismissPopup();
|
||||
isTextSelected.current = false;
|
||||
setTextSelected(false);
|
||||
}
|
||||
if (isPopuped.current) {
|
||||
isUpToPopup.current = false;
|
||||
@@ -79,7 +83,8 @@ export const useTextSelector = (
|
||||
|
||||
// On Android no proper events are fired to notify selection done,
|
||||
// we make the popup show when the selection is changed
|
||||
if (osPlatform === 'android') {
|
||||
// note that selection may be initiated by a tts speak
|
||||
if (isTouchStarted.current && osPlatform === 'android') {
|
||||
makeSelection(sel, index, false);
|
||||
}
|
||||
isUpToPopup.current = true;
|
||||
@@ -96,11 +101,19 @@ export const useTextSelector = (
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleTouchStart = () => {
|
||||
isTouchStarted.current = true;
|
||||
};
|
||||
const handleTouchEnd = () => {
|
||||
isTouchStarted.current = false;
|
||||
};
|
||||
const handleScroll = () => {
|
||||
// Prevent the container from scrolling when text is selected in paginated mode
|
||||
// This is a workaround for the issue #873
|
||||
// TODO: support text selection across pages
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
if (!viewSettings?.scrolled && view?.renderer?.containerPosition && selectionPosition.current) {
|
||||
console.warn('Keep container position', selectionPosition.current);
|
||||
view.renderer.containerPosition = selectionPosition.current;
|
||||
}
|
||||
};
|
||||
@@ -128,7 +141,7 @@ export const useTextSelector = (
|
||||
selectionPosition.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isTextSelected.current]);
|
||||
}, [textSelected]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSingleClick = (): boolean => {
|
||||
@@ -139,6 +152,7 @@ export const useTextSelector = (
|
||||
if (isTextSelected.current) {
|
||||
handleDismissPopup();
|
||||
isTextSelected.current = false;
|
||||
setTextSelected(false);
|
||||
view?.deselect();
|
||||
return true;
|
||||
}
|
||||
@@ -158,6 +172,8 @@ export const useTextSelector = (
|
||||
|
||||
return {
|
||||
handleScroll,
|
||||
handleTouchStart,
|
||||
handleTouchEnd,
|
||||
handlePointerup,
|
||||
handleSelectionchange,
|
||||
handleShowPopup,
|
||||
|
||||
@@ -7,13 +7,13 @@ import { walkTextNodes } from '@/utils/walk';
|
||||
import { localeToLang } from '@/utils/lang';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
|
||||
export function useTextTranslation(bookKey: string, view: FoliateView | null) {
|
||||
export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElement | null) {
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
|
||||
const enabled = useRef(viewSettings.translationEnabled);
|
||||
const [provider, setProvider] = useState(viewSettings.translationProvider);
|
||||
const [targetLang, setTargetLang] = useState(viewSettings.translateTargetLang);
|
||||
const enabled = useRef(viewSettings?.translationEnabled);
|
||||
const [provider, setProvider] = useState(viewSettings?.translationProvider);
|
||||
const [targetLang, setTargetLang] = useState(viewSettings?.translateTargetLang);
|
||||
|
||||
const { translate } = useTranslator({
|
||||
provider,
|
||||
@@ -42,6 +42,16 @@ export function useTextTranslation(bookKey: string, view: FoliateView | null) {
|
||||
translateRef.current = translate;
|
||||
}, [translate]);
|
||||
|
||||
const observeTextNodes = () => {
|
||||
if (!view || !enabled.current) return;
|
||||
const observer = createTranslationObserver();
|
||||
observerRef.current = observer;
|
||||
const nodes = walkTextNodes(view);
|
||||
console.log('Observing text nodes for translation:', nodes.length);
|
||||
allTextNodes.current = nodes;
|
||||
nodes.forEach((el) => observer.observe(el));
|
||||
};
|
||||
|
||||
const updateTranslation = () => {
|
||||
translatedElements.current.forEach((element) => {
|
||||
const translationTargets = element.querySelectorAll('.translation-target');
|
||||
@@ -49,7 +59,7 @@ export function useTextTranslation(bookKey: string, view: FoliateView | null) {
|
||||
});
|
||||
|
||||
translatedElements.current = [];
|
||||
if (viewSettings.translationEnabled && view) {
|
||||
if (viewSettings?.translationEnabled && view) {
|
||||
recreateTranslationObserver();
|
||||
}
|
||||
};
|
||||
@@ -136,6 +146,9 @@ export function useTextTranslation(bookKey: string, view: FoliateView | null) {
|
||||
blockWrapper.appendChild(inner);
|
||||
wrapper.appendChild(blockWrapper);
|
||||
|
||||
if (el.querySelector('.translation-target')) {
|
||||
return;
|
||||
}
|
||||
el.appendChild(wrapper);
|
||||
translatedElements.current.push(el);
|
||||
} catch (err) {
|
||||
@@ -165,7 +178,7 @@ export function useTextTranslation(bookKey: string, view: FoliateView | null) {
|
||||
if (enabledChanged) {
|
||||
toggleTranslationVisibility(viewSettings.translationEnabled);
|
||||
if (enabled.current) {
|
||||
recreateTranslationObserver();
|
||||
observeTextNodes();
|
||||
}
|
||||
} else if (providerChanged || targetLangChanged) {
|
||||
updateTranslation();
|
||||
@@ -174,28 +187,20 @@ export function useTextTranslation(bookKey: string, view: FoliateView | null) {
|
||||
}, [bookKey, viewSettings, provider, targetLang]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!view) return;
|
||||
if (!view || !enabled.current) return;
|
||||
|
||||
const onLoad = () => {
|
||||
const observer = createTranslationObserver();
|
||||
observerRef.current = observer;
|
||||
const nodes = walkTextNodes(view);
|
||||
allTextNodes.current = nodes;
|
||||
nodes.forEach((el) => observer.observe(el));
|
||||
};
|
||||
|
||||
view.addEventListener('load', onLoad);
|
||||
return () => {
|
||||
view.removeEventListener('load', onLoad);
|
||||
observerRef.current?.disconnect();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view]);
|
||||
|
||||
useEffect(() => {
|
||||
if ('renderer' in view) {
|
||||
view.addEventListener('load', observeTextNodes);
|
||||
} else {
|
||||
observeTextNodes();
|
||||
}
|
||||
return () => {
|
||||
if ('renderer' in view) {
|
||||
view.removeEventListener('load', observeTextNodes);
|
||||
}
|
||||
observerRef.current?.disconnect();
|
||||
translatedElements.current = [];
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view]);
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function useTranslator({
|
||||
),
|
||||
);
|
||||
|
||||
return results;
|
||||
return enablePolishing ? polish(results, targetLanguage) : results;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
@@ -178,7 +178,7 @@ export class DocumentLoader {
|
||||
);
|
||||
}
|
||||
|
||||
public async open(): Promise<{ book: BookDoc; format: BookFormat }> {
|
||||
public async open({ allowScript = false } = {}): Promise<{ book: BookDoc; format: BookFormat }> {
|
||||
let book = null;
|
||||
let format: BookFormat = 'EPUB';
|
||||
if (!this.file.size) {
|
||||
@@ -200,7 +200,7 @@ export class DocumentLoader {
|
||||
format = 'FBZ';
|
||||
} else {
|
||||
const { EPUB } = await import('foliate-js/epub.js');
|
||||
book = await new EPUB(loader).init();
|
||||
book = await new EPUB(loader).init({ allowScript });
|
||||
format = 'EPUB';
|
||||
}
|
||||
} else if (await this.isPDF()) {
|
||||
|
||||
@@ -2,11 +2,18 @@ import crypto from 'crypto';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { corsAllMethods, runMiddleware } from '@/utils/cors';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { getUserPlan } from '@/utils/access';
|
||||
import { getDailyTranslationPlanData, getUserPlan } from '@/utils/access';
|
||||
|
||||
const DEFAULT_DEEPL_FREE_API = 'https://api-free.deepl.com/v2/translate';
|
||||
const DEFAULT_DEEPL_PRO_API = 'https://api.deepl.com/v2/translate';
|
||||
|
||||
const ErrorCodes = {
|
||||
UNAUTHORIZED: 'Unauthorized',
|
||||
DEEPL_API_ERROR: 'DeepL API Error',
|
||||
DAILY_QUOTA_EXCEEDED: 'Daily Quota Exceeded',
|
||||
INTERNAL_SERVER_ERROR: 'Internal Server Error',
|
||||
};
|
||||
|
||||
interface KVNamespace {
|
||||
get(key: string): Promise<string | null>;
|
||||
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
|
||||
@@ -101,7 +108,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
}
|
||||
}
|
||||
|
||||
// if (!user || !token) return res.status(401).json({ error: ErrorCodes.UNAUTHORIZED });
|
||||
|
||||
return await callDeepLAPI(
|
||||
user?.id,
|
||||
token,
|
||||
singleText,
|
||||
sourceLang,
|
||||
targetLang,
|
||||
@@ -115,11 +126,16 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
return res.status(200).json({ translations });
|
||||
} catch (error) {
|
||||
console.error('Error proxying DeepL request:', error);
|
||||
return res.status(500).json({ error: 'Internal Server Error' });
|
||||
if (error instanceof Error && error.message.includes(ErrorCodes.DAILY_QUOTA_EXCEEDED)) {
|
||||
return res.status(429).json({ error: ErrorCodes.DAILY_QUOTA_EXCEEDED });
|
||||
}
|
||||
return res.status(500).json({ error: ErrorCodes.INTERNAL_SERVER_ERROR });
|
||||
}
|
||||
};
|
||||
|
||||
async function callDeepLAPI(
|
||||
userId: string | undefined,
|
||||
token: string | undefined,
|
||||
text: string,
|
||||
sourceLang: string,
|
||||
targetLang: string,
|
||||
@@ -128,11 +144,22 @@ async function callDeepLAPI(
|
||||
translationsKV: KVNamespace | undefined,
|
||||
useCache: boolean,
|
||||
) {
|
||||
let dailyUsageKey = '';
|
||||
if (userId && token) {
|
||||
const { quota: dailyQuota } = getDailyTranslationPlanData(token);
|
||||
const currentDate = new Date().toISOString().split('T')[0]!;
|
||||
dailyUsageKey = `daily_usage:${currentDate}:${userId}`;
|
||||
const dailyUsage = (await translationsKV?.get(dailyUsageKey)) || '0';
|
||||
if (dailyQuota <= parseInt(dailyUsage) + text.length) {
|
||||
throw new Error(ErrorCodes.DAILY_QUOTA_EXCEEDED);
|
||||
}
|
||||
}
|
||||
|
||||
const isV2Api = apiUrl.endsWith('/v2/translate');
|
||||
|
||||
// TODO: this should be processed in the client, but for now, we need to do it here
|
||||
// please remove this when most clients are updated
|
||||
const input = text.replaceAll('\n', ' ').trim();
|
||||
const input = text.replaceAll('\n', '').trim();
|
||||
const requestBody = {
|
||||
text: isV2Api ? [input] : input,
|
||||
source_lang: isV2Api ? sourceLang : (LANG_V2_V1_MAP[sourceLang] ?? sourceLang),
|
||||
@@ -166,6 +193,20 @@ async function callDeepLAPI(
|
||||
translatedText = data.data;
|
||||
}
|
||||
|
||||
let newDailyUsage = 0;
|
||||
if (dailyUsageKey && translationsKV) {
|
||||
try {
|
||||
const usage = translatedText.length + text.length;
|
||||
const dailyUsage = (await translationsKV.get(dailyUsageKey)) || '0';
|
||||
newDailyUsage = parseInt(dailyUsage) + usage;
|
||||
await translationsKV.put(dailyUsageKey, newDailyUsage.toString(), {
|
||||
expirationTtl: 86400 * 30,
|
||||
});
|
||||
} catch (cacheError) {
|
||||
console.error('Cache storage error:', cacheError);
|
||||
}
|
||||
}
|
||||
|
||||
if (useCache && translationsKV && translatedText) {
|
||||
try {
|
||||
const cacheKey = generateCacheKey(text, sourceLang, targetLang);
|
||||
@@ -177,6 +218,7 @@ async function callDeepLAPI(
|
||||
|
||||
return {
|
||||
text: translatedText,
|
||||
daily_usage: newDailyUsage,
|
||||
detected_source_language: detectedSourceLanguage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -401,7 +401,7 @@ export abstract class BaseAppService implements AppService {
|
||||
}
|
||||
} catch (error) {
|
||||
// don't throw error here since some books may not have cover images at all
|
||||
console.log('Failed to download cover file:', error);
|
||||
console.log(`Failed to download cover file for book: '${book.title}'`, error);
|
||||
}
|
||||
|
||||
if (needDownBook) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
ViewSettings,
|
||||
} from '@/types/book';
|
||||
import { ReadSettings, SystemSettings } from '@/types/settings';
|
||||
import { UserStorageQuota } from '@/types/user';
|
||||
import { UserStorageQuota, UserDailyTranslationQuota } from '@/types/user';
|
||||
import { getDefaultMaxBlockSize, getDefaultMaxInlineSize } from '@/utils/config';
|
||||
import { stubTranslation as _ } from '@/utils/misc';
|
||||
|
||||
@@ -109,6 +109,7 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
|
||||
showFooter: true,
|
||||
showBarsOnScroll: false,
|
||||
scrollingOverlap: 0,
|
||||
allowScript: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_BOOK_STYLE: BookStyle = {
|
||||
@@ -146,6 +147,7 @@ export const DEFAULT_VIEW_CONFIG: ViewConfig = {
|
||||
export const DEFAULT_TTS_CONFIG: TTSConfig = {
|
||||
ttsRate: 1.3,
|
||||
ttsVoice: '',
|
||||
ttsLocation: '',
|
||||
};
|
||||
|
||||
export const DEFAULT_TRANSLATOR_CONFIG: TranslatorConfig = {
|
||||
@@ -527,6 +529,12 @@ export const DEFAULT_STORAGE_QUOTA: UserStorageQuota = {
|
||||
pro: 10 * 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
export const DEFAULT_DAILY_TRANSLATION_QUOTA: UserDailyTranslationQuota = {
|
||||
free: 100 * 1024,
|
||||
plus: 1 * 1024 * 1024,
|
||||
pro: 10 * 1024 * 1024,
|
||||
};
|
||||
|
||||
export const DOUBLE_CLICK_INTERVAL_THRESHOLD_MS = 250;
|
||||
export const DISABLE_DOUBLE_CLICK_ON_MOBILE = true;
|
||||
export const LONG_HOLD_THRESHOLD = 500;
|
||||
|
||||
@@ -3,9 +3,11 @@ import { TTSClient, TTSMessageEvent, TTSVoice } from './TTSClient';
|
||||
import { EdgeSpeechTTS, EdgeTTSPayload } from '@/libs/edgeTTS';
|
||||
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
|
||||
import { TTSGranularity } from '@/types/view';
|
||||
import { TTSController } from './TTSController';
|
||||
import { TTSUtils } from './TTSUtils';
|
||||
|
||||
export class EdgeTTSClient implements TTSClient {
|
||||
controller?: TTSController;
|
||||
#primaryLang = 'en';
|
||||
#speakingLang = '';
|
||||
#rate = 1.0;
|
||||
@@ -20,7 +22,8 @@ export class EdgeTTSClient implements TTSClient {
|
||||
#startedAt = 0;
|
||||
available = true;
|
||||
|
||||
constructor() {
|
||||
constructor(controller?: TTSController) {
|
||||
this.controller = controller;
|
||||
this.#edgeTTS = new EdgeSpeechTTS();
|
||||
}
|
||||
|
||||
@@ -129,6 +132,8 @@ export class EdgeTTSClient implements TTSClient {
|
||||
audio.setAttribute('x-webkit-airplay', 'deny');
|
||||
audio.preload = 'auto';
|
||||
|
||||
this.controller?.dispatchSpeakMark(mark);
|
||||
|
||||
yield {
|
||||
code: 'boundary',
|
||||
message: `Start chunk: ${mark.name}`,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { parseSSMLMarks } from '@/utils/ssml';
|
||||
import { WebSpeechClient } from './WebSpeechClient';
|
||||
import { EdgeTTSClient } from './EdgeTTSClient';
|
||||
import { TTSUtils } from './TTSUtils';
|
||||
import { TTSMark } from './types';
|
||||
|
||||
type TTSState =
|
||||
| 'stopped'
|
||||
@@ -31,8 +32,8 @@ export class TTSController extends EventTarget {
|
||||
|
||||
constructor(view: FoliateView) {
|
||||
super();
|
||||
this.ttsWebClient = new WebSpeechClient();
|
||||
this.ttsEdgeClient = new EdgeTTSClient();
|
||||
this.ttsWebClient = new WebSpeechClient(this);
|
||||
this.ttsEdgeClient = new EdgeTTSClient(this);
|
||||
this.ttsClient = this.ttsWebClient;
|
||||
this.view = view;
|
||||
}
|
||||
@@ -86,6 +87,7 @@ export class TTSController extends EventTarget {
|
||||
.replace(/[–—]/g, ',')
|
||||
.replace('<break/>', ' ')
|
||||
.replace(/\.{3,}/g, ' ')
|
||||
.replace(/……/g, ' ')
|
||||
.replace(/·/g, ' ');
|
||||
|
||||
return ssml;
|
||||
@@ -128,7 +130,8 @@ export class TTSController extends EventTarget {
|
||||
return;
|
||||
}
|
||||
if (mark && this.state === 'playing') {
|
||||
this.view.tts?.setMark(mark);
|
||||
const range = this.view.tts?.setMark(mark);
|
||||
this.dispatchEvent(new CustomEvent('tts-highlight-mark', { detail: range }));
|
||||
}
|
||||
lastCode = code;
|
||||
}
|
||||
@@ -272,6 +275,10 @@ export class TTSController extends EventTarget {
|
||||
return this.ttsClient.getSpeakingLang();
|
||||
}
|
||||
|
||||
dispatchSpeakMark(mark: TTSMark) {
|
||||
this.dispatchEvent(new CustomEvent('tts-speak-mark', { detail: mark }));
|
||||
}
|
||||
|
||||
error(e: unknown) {
|
||||
console.error(e);
|
||||
this.state = 'stopped';
|
||||
|
||||
@@ -2,7 +2,9 @@ import { getUserLocale } from '@/utils/misc';
|
||||
import { TTSClient, TTSMessageEvent, TTSVoice } from './TTSClient';
|
||||
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
|
||||
import { TTSGranularity } from '@/types/view';
|
||||
import { TTSController } from './TTSController';
|
||||
import { TTSUtils } from './TTSUtils';
|
||||
import { TTSMark } from './types';
|
||||
|
||||
const BLACKLISTED_VOICES = [
|
||||
'Albert',
|
||||
@@ -50,7 +52,8 @@ async function* speakWithMarks(
|
||||
getRate: () => number,
|
||||
getPitch: () => number,
|
||||
getVoice: (lang: string) => Promise<SpeechSynthesisVoice | null>,
|
||||
setSpeakingLang: (lang: string) => void = () => {},
|
||||
setSpeakingLang: (lang: string) => void,
|
||||
dispatchSpeakMark: (mark: TTSMark) => void,
|
||||
) {
|
||||
const { marks } = parseSSMLMarks(ssml);
|
||||
|
||||
@@ -61,6 +64,7 @@ async function* speakWithMarks(
|
||||
const { language } = mark;
|
||||
const voiceLang = language || defaultLang;
|
||||
setSpeakingLang(voiceLang);
|
||||
dispatchSpeakMark(mark);
|
||||
utterance.text = mark.text;
|
||||
utterance.rate = getRate();
|
||||
utterance.pitch = getPitch();
|
||||
@@ -99,6 +103,7 @@ async function* speakWithMarks(
|
||||
}
|
||||
|
||||
export class WebSpeechClient implements TTSClient {
|
||||
controller?: TTSController;
|
||||
#primaryLang = 'en';
|
||||
#speakingLang = '';
|
||||
#rate = 1.0;
|
||||
@@ -108,6 +113,10 @@ export class WebSpeechClient implements TTSClient {
|
||||
#synth = window.speechSynthesis;
|
||||
available = true;
|
||||
|
||||
constructor(controller?: TTSController) {
|
||||
this.controller = controller;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.#synth) {
|
||||
this.available = false;
|
||||
@@ -166,6 +175,7 @@ export class WebSpeechClient implements TTSClient {
|
||||
() => this.#pitch,
|
||||
this.getVoiceFromLang,
|
||||
(lang) => (this.#speakingLang = lang),
|
||||
(mark) => this.controller?.dispatchSpeakMark(mark),
|
||||
)) {
|
||||
if (signal.aborted) {
|
||||
console.log('TTS aborted');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './types';
|
||||
export * from './TTSClient';
|
||||
export * from './WebSpeechClient';
|
||||
export * from './EdgeTTSClient';
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export type TTSMark = {
|
||||
offset: number;
|
||||
name: string;
|
||||
text: string;
|
||||
language?: string;
|
||||
};
|
||||
@@ -19,6 +19,7 @@ interface ViewState {
|
||||
error: string | null;
|
||||
progress: BookProgress | null;
|
||||
ribbonVisible: boolean;
|
||||
ttsEnabled: boolean;
|
||||
/* View settings for the view:
|
||||
generally view settings have a hierarchy of global settings < book settings < view settings
|
||||
view settings for primary view are saved to book config which is persisted to config file
|
||||
@@ -33,6 +34,7 @@ interface ReaderStore {
|
||||
setBookKeys: (keys: string[]) => void;
|
||||
setHoveredBookKey: (key: string | null) => void;
|
||||
setBookmarkRibbonVisibility: (key: string, visible: boolean) => void;
|
||||
setTTSEnabled: (key: string, enabled: boolean) => void;
|
||||
setProgress: (
|
||||
key: string,
|
||||
location: string,
|
||||
@@ -103,6 +105,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
error: null,
|
||||
progress: null,
|
||||
ribbonVisible: false,
|
||||
ttsEnabled: false,
|
||||
viewSettings: null,
|
||||
},
|
||||
},
|
||||
@@ -119,7 +122,9 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
const content = (await appService.loadBookContent(book, settings)) as BookContent;
|
||||
const { file, config } = content;
|
||||
console.log('Loading book', key);
|
||||
const { book: loadedBookDoc } = await new DocumentLoader(file).open();
|
||||
const { book: loadedBookDoc } = await new DocumentLoader(file).open({
|
||||
allowScript: config.viewSettings?.allowScript,
|
||||
});
|
||||
const bookDoc = loadedBookDoc as BookDoc;
|
||||
if (bookDoc.toc?.length && bookDoc.sections?.length) {
|
||||
updateToc(bookDoc, bookDoc.toc, bookDoc.sections);
|
||||
@@ -149,6 +154,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
error: null,
|
||||
progress: null,
|
||||
ribbonVisible: false,
|
||||
ttsEnabled: false,
|
||||
viewSettings: JSON.parse(JSON.stringify(configViewSettings)) as ViewSettings,
|
||||
},
|
||||
},
|
||||
@@ -167,6 +173,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
error: 'Failed to load book.',
|
||||
progress: null,
|
||||
ribbonVisible: false,
|
||||
ttsEnabled: false,
|
||||
viewSettings: null,
|
||||
},
|
||||
},
|
||||
@@ -282,4 +289,15 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
setTTSEnabled: (key: string, enabled: boolean) =>
|
||||
set((state) => ({
|
||||
viewStates: {
|
||||
...state.viewStates,
|
||||
[key]: {
|
||||
...state.viewStates[key]!,
|
||||
ttsEnabled: enabled,
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -88,6 +88,7 @@ export interface BookLayout {
|
||||
showFooter: boolean;
|
||||
showBarsOnScroll: boolean;
|
||||
scrollingOverlap: number;
|
||||
allowScript: boolean;
|
||||
}
|
||||
|
||||
export interface BookStyle {
|
||||
@@ -125,6 +126,7 @@ export interface ViewConfig {
|
||||
export interface TTSConfig {
|
||||
ttsRate: number;
|
||||
ttsVoice: string;
|
||||
ttsLocation: string;
|
||||
}
|
||||
|
||||
export interface TranslatorConfig {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
export type UserStorageQuota = {
|
||||
export interface UserQuota {
|
||||
free: number;
|
||||
plus: number;
|
||||
pro: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type UserPlan = keyof UserStorageQuota;
|
||||
export type UserPlan = keyof UserQuota;
|
||||
export type UserStorageQuota = UserQuota;
|
||||
export type UserDailyTranslationQuota = UserQuota;
|
||||
|
||||
export type QuotaType = {
|
||||
name: string;
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface FoliateView extends HTMLElement {
|
||||
goLeft: () => void;
|
||||
goRight: () => void;
|
||||
getCFI: (index: number, range: Range) => string;
|
||||
resolveCFI: (cfi: string) => { index: number; anchor: (doc: Document) => Range };
|
||||
addAnnotation: (note: BookNote, remove?: boolean) => { index: number; label: string };
|
||||
search: (config: BookSearchConfig) => AsyncGenerator<BookSearchResult | string, void, void>;
|
||||
clearSearch: () => void;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import { UserPlan } from '@/types/user';
|
||||
import { DEFAULT_STORAGE_QUOTA } from '@/services/constants';
|
||||
import { DEFAULT_DAILY_TRANSLATION_QUOTA, DEFAULT_STORAGE_QUOTA } from '@/services/constants';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
|
||||
@@ -29,6 +29,19 @@ export const getStoragePlanData = (token: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getDailyTranslationPlanData = (token: string) => {
|
||||
const data = jwtDecode<Token>(token) || {};
|
||||
const plan = data['plan'] || 'free';
|
||||
const fixedQuota = parseInt(process.env['NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA'] || '0');
|
||||
const quota =
|
||||
fixedQuota || DEFAULT_DAILY_TRANSLATION_QUOTA[plan] || DEFAULT_DAILY_TRANSLATION_QUOTA['free'];
|
||||
|
||||
return {
|
||||
plan,
|
||||
quota,
|
||||
};
|
||||
};
|
||||
|
||||
export const getAccessToken = async (): Promise<string | null> => {
|
||||
// In browser context there might be two instances of supabase one in the app route
|
||||
// and the other in the pages route, and they might have different sessions
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
type TTSMark = {
|
||||
offset: number;
|
||||
name: string;
|
||||
text: string;
|
||||
language?: string;
|
||||
};
|
||||
import { TTSMark } from '@/services/tts/types';
|
||||
|
||||
const cleanTextContent = (text: string) =>
|
||||
text.replace(/\r\n/g, ' ').replace(/\r/g, ' ').replace(/\n/g, ' ').trimStart();
|
||||
|
||||
@@ -239,7 +239,7 @@ const getLayoutStyles = (
|
||||
word-spacing: ${wordSpacing}px ${overrideLayout ? '!important' : ''};
|
||||
letter-spacing: ${letterSpacing}px ${overrideLayout ? '!important' : ''};
|
||||
text-indent: ${vertical ? textIndent * 1.2 : textIndent}em ${overrideLayout ? '!important' : ''};
|
||||
text-align: ${justify ? 'justify' : 'inherit'} ${overrideLayout ? '!important' : ''};
|
||||
text-align: ${justify ? 'justify' : ''} ${overrideLayout ? '!important' : ''};
|
||||
-webkit-hyphens: ${hyphenate ? 'auto' : 'manual'};
|
||||
hyphens: ${hyphenate ? 'auto' : 'manual'};
|
||||
-webkit-hyphenate-limit-before: 3;
|
||||
@@ -248,8 +248,7 @@ const getLayoutStyles = (
|
||||
hanging-punctuation: allow-end last;
|
||||
widows: 2;
|
||||
}
|
||||
p:has(> img:only-child), p:has(> span:only-child > img:only-child),
|
||||
div[style*="text-align"] {
|
||||
p:has(> img:only-child), p:has(> span:only-child > img:only-child) {
|
||||
text-indent: unset !important;
|
||||
}
|
||||
p, div {
|
||||
@@ -318,6 +317,10 @@ const getLayoutStyles = (
|
||||
div.center *, p.center * { text-align: center; }
|
||||
div.justify *, p.justify * { text-align: justify; }
|
||||
|
||||
.nonindent, .noindent {
|
||||
text-indent: unset !important;
|
||||
}
|
||||
|
||||
/* for the Gutenberg eBooks */
|
||||
#pg-header * {
|
||||
color: inherit !important;
|
||||
|
||||
@@ -59,31 +59,38 @@ export const updateToc = (bookDoc: BookDoc, items: TOCItem[], sections: SectionI
|
||||
map[section.id] = section;
|
||||
return map;
|
||||
}, {});
|
||||
updateTocData(bookDoc, items, sectionsMap);
|
||||
updateTocData(bookDoc, items, sections, sectionsMap);
|
||||
items.sort((a, b) => {
|
||||
if (a.location && b.location) {
|
||||
return a.location.current - b.location.current;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
const updateTocData = (
|
||||
bookDoc: BookDoc,
|
||||
items: TOCItem[],
|
||||
sections: { [id: string]: SectionItem },
|
||||
sections: SectionItem[],
|
||||
sectionsMap: { [id: string]: SectionItem },
|
||||
index = 0,
|
||||
): number => {
|
||||
items.forEach((item) => {
|
||||
item.id ??= index++;
|
||||
if (item.href) {
|
||||
const id = bookDoc.splitTOCHref(item.href)[0]!;
|
||||
const section = sections[id];
|
||||
const section = sectionsMap[id];
|
||||
if (section) {
|
||||
item.cfi = section.cfi;
|
||||
// Add location only when toc item is at the same level as the section
|
||||
// otherwise the location will not be accurate
|
||||
if (id === item.href) {
|
||||
if (id === item.href || items.length <= sections.length) {
|
||||
item.location = section.location;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (item.subitems) {
|
||||
index = updateTocData(bookDoc, item.subitems, sections, index);
|
||||
index = updateTocData(bookDoc, item.subitems, sections, sectionsMap, index);
|
||||
}
|
||||
});
|
||||
return index;
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: 88edc25834...f50fd42431
Reference in New Issue
Block a user