Compare commits

..

19 Commits

Author SHA1 Message Date
Huang Xin 05e9549410 release: version 0.9.88 (#2258) 2025-10-17 18:06:00 +02:00
Huang Xin 35fbce3506 feat(settings): added an option to disable animation in E-Ink mode, closes #2253 (#2257) 2025-10-17 18:00:58 +02:00
Huang Xin c1d6825793 fix: more sensitive snap to paginate, closes #2252 (#2256) 2025-10-17 16:05:39 +02:00
Huang Xin ae6c6970b4 feat: localize number in progress info in vertical layout (#2255) 2025-10-17 12:17:36 +02:00
Huang Xin f8fef57cf3 fix(layout): overriding book layout no longer overrides explicit text alignment, closes #2249 (#2254) 2025-10-17 11:44:32 +02:00
Huang Xin 4dc191735d fix(layout): prevent TTS indicator from disappearing too quickly to open configuration panel, closes #2247 (#2248) 2025-10-16 16:49:05 +02:00
Huang Xin 4f11f437e1 release: version 0.9.87 (#2245) 2025-10-16 10:27:34 +02:00
Huang Xin 5773e31990 compat(tts): fix crash with TTS on some versions of Android systems (#2244) 2025-10-16 10:10:04 +02:00
Huang Xin 9dda5d4c88 fix(tts): scroll more accurately to the highlighted text in TTS when header/footer bars are shown (#2242) 2025-10-15 17:50:56 +02:00
Huang Xin 7f5b6959e1 font: update font LXGW WenKai to the latest version (#2240) 2025-10-15 15:17:53 +02:00
Huang Xin 126981d085 fix(theme): fixed auto theme mode for new reader window (#2238) 2025-10-15 14:36:02 +02:00
Huang Xin 854a578929 compat: support parsing more footnotes, closes #2234 (#2237) 2025-10-15 11:59:27 +02:00
Huang Xin 6dd0daa597 txt: add chapter title pattern, closes #2232 (#2233) 2025-10-14 18:01:23 +02:00
Huang Xin fd24a5e9ac release: version 0.9.86 (#2230) 2025-10-14 14:08:45 +02:00
Huang Xin 948b35f244 perf: support concurrent book uploading, closes #2208 (#2229) 2025-10-14 14:00:31 +02:00
Huang Xin 96cc182a8c feat: support overriding html language code with language code in metadata, closes #2222 (#2227) 2025-10-14 13:09:23 +02:00
Huang Xin e2291ed5b1 feat(koplugin): register sync actions in gesture manager closes #2224 (#2226) 2025-10-14 09:54:08 +02:00
Huang Xin 60ddb17547 feat: add support for per-book background image settings in addition to global settings, also closes #2223 (#2225) 2025-10-14 08:54:26 +02:00
Huang Xin 9a991a31ba fix: handle empty custom textures (#2221) 2025-10-13 20:08:22 +02:00
64 changed files with 670 additions and 167 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.85",
"version": "0.9.88",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev --turbopack",
@@ -622,5 +622,8 @@
"Opacity": "شفافية",
"Size": "حجم",
"Cover": "غطاء",
"Contain": "احتواء"
"Contain": "احتواء",
"{{number}} pages left in chapter": "تبقّت {{number}} صفحات في هذا الفصل",
"Device": "الجهاز",
"E-Ink Mode": "وضع الحبر الإلكتروني"
}
@@ -606,5 +606,8 @@
"Opacity": "স্বচ্ছতা",
"Size": "আকার",
"Cover": "ঢাকা",
"Contain": "ধারণ"
"Contain": "ধারণ",
"{{number}} pages left in chapter": "অধ্যায়ে {{number}} পৃষ্ঠা বাকি",
"Device": "যন্ত্র",
"E-Ink Mode": "ই-ইঙ্ক মোড"
}
@@ -602,5 +602,8 @@
"Opacity": "སྤོ་སྐོར་གྱི་འོད་ཟེར།",
"Size": "ཨང་",
"Cover": "དེབ་གྱི་སྤྱོད་བྱས་མ་ཐུབ།",
"Contain": "འབྱོར་བ།"
"Contain": "འབྱོར་བ།",
"{{number}} pages left in chapter": "དོན་ཚན་ནང་ཤོག་ཨང་ {{number}} ལོག་གི་འདུག།",
"Device": "རྐྱབ་སྐོར",
"E-Ink Mode": "ཡིག་སྒྱུར་སྤོ་བ"
}
@@ -606,5 +606,8 @@
"Opacity": "Opazität",
"Size": "Größe",
"Cover": "Cover",
"Contain": "Inhalt"
"Contain": "Inhalt",
"{{number}} pages left in chapter": "{{number}} Seiten verbleibend im Kapitel",
"Device": "Gerät",
"E-Ink Mode": "E-Ink-Modus"
}
@@ -606,5 +606,8 @@
"Opacity": "Αδιαφάνεια",
"Size": "Μέγεθος",
"Cover": "Εξώφυλλο",
"Contain": "Περιέχει"
"Contain": "Περιέχει",
"{{number}} pages left in chapter": "Μένουν {{number}} σελίδες στο κεφάλαιο",
"Device": "Συσκευή",
"E-Ink Mode": "Λειτουργία E-Ink"
}
@@ -610,5 +610,8 @@
"Opacity": "Opacidad",
"Size": "Tamaño",
"Cover": "Cubierta",
"Contain": "Contener"
"Contain": "Contener",
"{{number}} pages left in chapter": "{{number}} páginas restantes en el capítulo",
"Device": "Dispositivo",
"E-Ink Mode": "Modo E-Ink"
}
@@ -610,5 +610,8 @@
"Opacity": "Opacité",
"Size": "Taille",
"Cover": "Couverture",
"Contain": "Contenir"
"Contain": "Contenir",
"{{number}} pages left in chapter": "{{number}} pages restantes dans le chapitre",
"Device": "Appareil",
"E-Ink Mode": "Mode E-Ink"
}
@@ -606,5 +606,8 @@
"Opacity": "अस्पष्टता",
"Size": "आकार",
"Cover": "कवर",
"Contain": "समाहित करें"
"Contain": "समाहित करें",
"{{number}} pages left in chapter": "इस अध्याय में {{number}} पृष्ठ शेष हैं",
"Device": "डिवाइस",
"E-Ink Mode": "ई-इंक मोड"
}
@@ -602,5 +602,8 @@
"Opacity": "Keburaman",
"Size": "Ukuran",
"Cover": "Sampul",
"Contain": "Mengandung"
"Contain": "Mengandung",
"{{number}} pages left in chapter": "{{number}} halaman tersisa di bab",
"Device": "Perangkat",
"E-Ink Mode": "Mode E-Ink"
}
@@ -610,5 +610,8 @@
"Opacity": "Opacità",
"Size": "Dimensione",
"Cover": "Copertura",
"Contain": "Contenere"
"Contain": "Contenere",
"{{number}} pages left in chapter": "{{number}} pagine rimaste nel capitolo",
"Device": "Dispositivo",
"E-Ink Mode": "Modalità E-Ink"
}
@@ -602,5 +602,8 @@
"Opacity": "不透明度",
"Size": "サイズ",
"Cover": "カバー",
"Contain": "含む"
"Contain": "含む",
"{{number}} pages left in chapter": "章に{{number}}ページ残り",
"Device": "デバイス",
"E-Ink Mode": "E-Inkモード"
}
@@ -602,5 +602,8 @@
"Opacity": "불투명도",
"Size": "크기",
"Cover": "표지",
"Contain": "포함"
"Contain": "포함",
"{{number}} pages left in chapter": "남은 페이지 {{number}}장",
"Device": "장치",
"E-Ink Mode": "E-Ink 모드"
}
@@ -606,5 +606,8 @@
"Opacity": "Doorzichtigheid",
"Size": "Grootte",
"Cover": "Omslag",
"Contain": "Bevatten"
"Contain": "Bevatten",
"{{number}} pages left in chapter": "Nog {{number}} paginas in hoofdstuk",
"Device": "Apparaat",
"E-Ink Mode": "E-Ink Modus"
}
@@ -614,5 +614,8 @@
"Opacity": "Przezroczystość",
"Size": "Rozmiar",
"Cover": "Okładka",
"Contain": "Zawierać"
"Contain": "Zawierać",
"{{number}} pages left in chapter": "Pozostało {{number}} stron w rozdziale",
"Device": "Urządzenie",
"E-Ink Mode": "Tryb E-Ink"
}
@@ -610,5 +610,8 @@
"Opacity": "Opacidade",
"Size": "Tamanho",
"Cover": "Capa",
"Contain": "Contém"
"Contain": "Contém",
"{{number}} pages left in chapter": "Faltam {{number}} páginas neste capítulo",
"Device": "Dispositivo",
"E-Ink Mode": "Modo E-Ink"
}
@@ -614,5 +614,8 @@
"Opacity": "Непрозрачность",
"Size": "Размер",
"Cover": "Обложка",
"Contain": "Содержать"
"Contain": "Содержать",
"{{number}} pages left in chapter": "Осталось {{number}} страниц в главе",
"Device": "Устройство",
"E-Ink Mode": "E-Ink режим"
}
@@ -606,5 +606,8 @@
"Opacity": "පැහැදිලිත්වය",
"Size": "ප්‍රමාණය",
"Cover": "ආවරණය",
"Contain": "අඩංගු වන්න"
"Contain": "අඩංගු වන්න",
"{{number}} pages left in chapter": "පරිච්ඡේදයේ පිටු {{number}} ඉතිරිව ඇත",
"Device": "උපකරණය",
"E-Ink Mode": "E-Ink ආකාරය"
}
@@ -606,5 +606,8 @@
"Opacity": "Opacitet",
"Size": "Storlek",
"Cover": "Omslag",
"Contain": "Innehålla"
"Contain": "Innehålla",
"{{number}} pages left in chapter": "{{number}} sidor kvar i kapitlet",
"Device": "Enhet",
"E-Ink Mode": "E-Ink-läge"
}
@@ -606,5 +606,8 @@
"Opacity": "பார்வை",
"Size": "அளவு",
"Cover": "மூடு",
"Contain": "அடங்கும்"
"Contain": "அடங்கும்",
"{{number}} pages left in chapter": "அத்தியாயத்தில் {{number}} பக்கங்கள் மீதமுள்ளன",
"Device": "சாதனம்",
"E-Ink Mode": "E-Ink முறை"
}
@@ -602,5 +602,8 @@
"Opacity": "ความโปร่งใส",
"Size": "ขนาด",
"Cover": "ปก",
"Contain": "บรรจุ"
"Contain": "บรรจุ",
"{{number}} pages left in chapter": "เหลือ {{number}} หน้าในบท",
"Device": "อุปกรณ์",
"E-Ink Mode": "โหมด E-Ink"
}
@@ -606,5 +606,8 @@
"Opacity": "Opaklık",
"Size": "Boyut",
"Cover": "Kapak",
"Contain": "İçerir"
"Contain": "İçerir",
"{{number}} pages left in chapter": "Bu bölümde {{number}} sayfa kaldı",
"Device": "Cihaz",
"E-Ink Mode": "E-Ink Modu"
}
@@ -614,5 +614,8 @@
"Opacity": "Непрозорість",
"Size": "Розмір",
"Cover": "Обкладинка",
"Contain": "Вміщати"
"Contain": "Вміщати",
"{{number}} pages left in chapter": "Залишилось {{number}} сторінок у розділі",
"Device": "Пристрій",
"E-Ink Mode": "E-Ink режим"
}
@@ -602,5 +602,8 @@
"Opacity": "Độ mờ",
"Size": "Kích thước",
"Cover": "Bìa",
"Contain": "Chứa"
"Contain": "Chứa",
"{{number}} pages left in chapter": "Còn {{number}} trang trong chương",
"Device": "Thiết bị",
"E-Ink Mode": "Chế độ E-Ink"
}
@@ -601,6 +601,9 @@
"Import Image": "导入图片",
"Opacity": "不透明度",
"Size": "大小",
"Cover": "封面",
"Contain": "包含"
"Cover": "覆盖",
"Contain": "适应",
"{{number}} pages left in chapter": "本章剩余{{number}}页",
"Device": "设备",
"E-Ink Mode": "E-Ink 模式"
}
@@ -601,6 +601,9 @@
"Import Image": "匯入圖片",
"Opacity": "不透明度",
"Size": "大小",
"Cover": "封面",
"Contain": "包含"
"Cover": "覆蓋",
"Contain": "適應",
"{{number}} pages left in chapter": "本章剩餘{{number}}頁",
"Device": "設備",
"E-Ink Mode": "E-Ink 模式"
}
+30
View File
@@ -1,5 +1,35 @@
{
"releases": {
"0.9.88": {
"date": "2025-10-18",
"notes": [
"TTS: Fixed issue where the read-aloud indicator disappeared too quickly",
"Layout: Overriding book layout no longer affects explicitly set text alignment",
"Reader: Added localized number display for reading progress in vertical layout",
"Reader: Enhanced swipe responsiveness for smoother page transitions",
"Settings: Added an option to disable animations when using E-Ink mode"
]
},
"0.9.87": {
"date": "2025-10-16",
"notes": [
"Reader: Improved chapter detection with better chapter title recognition",
"Reader: Enhanced compatibility for parsing more footnote formats",
"Theme: Fixed auto theme switching in new reader windows",
"TTS: Improved scrolling accuracy to highlighted text during read-aloud",
"TTS: Fixed crash issues on certain Android systems"
]
},
"0.9.86": {
"date": "2025-10-14",
"notes": [
"Reader: Added support for per-book background image customization",
"Reader: Added option to override book language metadata for improved hyphenation accuracy",
"Sync: Readest sync in KOReader can now be triggered using gesture controls",
"Sync: Fixed a crash in KOReader that occurred when syncing progress without an open book",
"Sync: Improved upload performance with concurrent upload support"
]
},
"0.9.85": {
"date": "2025-10-14",
"notes": [
@@ -161,10 +161,47 @@ class MediaPlaybackService : MediaBrowserServiceCompat() {
setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
setSmallIcon(R.drawable.notification_icon)
addAction(
android.R.drawable.ic_media_previous,
"Previous",
MediaButtonReceiver.buildMediaButtonPendingIntent(
this@MediaPlaybackService,
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
)
)
if (playbackState == PlaybackStateCompat.STATE_PLAYING) {
addAction(
android.R.drawable.ic_media_pause,
"Pause",
MediaButtonReceiver.buildMediaButtonPendingIntent(
this@MediaPlaybackService,
PlaybackStateCompat.ACTION_PAUSE
)
)
} else {
addAction(
android.R.drawable.ic_media_play,
"Play",
MediaButtonReceiver.buildMediaButtonPendingIntent(
this@MediaPlaybackService,
PlaybackStateCompat.ACTION_PLAY
)
)
}
addAction(
android.R.drawable.ic_media_next,
"Next",
MediaButtonReceiver.buildMediaButtonPendingIntent(
this@MediaPlaybackService,
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
)
)
setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(mediaSession?.sessionToken)
.setShowActionsInCompactView(0)
.setShowActionsInCompactView(0, 1, 2)
)
}
return builder.build()
+2 -2
View File
@@ -17,8 +17,8 @@
"default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://translate.toil.cc https://*.microsofttranslator.com https://edge.microsoft.com https://*.googleusercontent.com",
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://chinese-fonts-cdn.netlify.app",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://chinese-fonts-cdn.netlify.app",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://chinese-fonts-cdn.netlify.app https://cdnjs.cloudflare.com",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://chinese-fonts-cdn.netlify.app https://cdnjs.cloudflare.com",
"frame-src": "'self' blob: asset: http://asset.localhost https://*.stripe.com",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com https://*.stripe.com"
},
@@ -31,11 +31,12 @@ interface BookshelfProps {
isSelectAll: boolean;
isSelectNone: boolean;
handleImportBooks: () => void;
handleBookUpload: (book: Book) => Promise<boolean>;
handleBookDownload: (book: Book) => Promise<boolean>;
handleBookDelete: (book: Book) => Promise<boolean>;
handleBookUpload: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleSetSelectMode: (selectMode: boolean) => void;
handleShowDetailsBook: (book: Book) => void;
handlePushLibrary: () => Promise<void>;
booksTransferProgress: { [key: string]: number | null };
}
@@ -50,6 +51,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
handleBookDelete,
handleSetSelectMode,
handleShowDetailsBook,
handlePushLibrary,
booksTransferProgress,
}) => {
const _ = useTranslation();
@@ -217,9 +219,14 @@ const Bookshelf: React.FC<BookshelfProps> = ({
};
const confirmDelete = async () => {
for (const book of getBooksToDelete()) {
handleBookDelete(book);
const books = getBooksToDelete();
const concurrency = 4;
for (let i = 0; i < books.length; i += concurrency) {
const batch = books.slice(i, i + concurrency);
await Promise.all(batch.map((book) => handleBookDelete(book, false)));
}
handlePushLibrary();
setSelectedBooks([]);
setShowDeleteAlert(false);
setShowSelectModeActions(true);
@@ -77,9 +77,9 @@ interface BookshelfItemProps {
transferProgress: number | null;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
toggleSelection: (hash: string) => void;
handleBookUpload: (book: Book) => Promise<boolean>;
handleBookDownload: (book: Book) => Promise<boolean>;
handleBookDelete: (book: Book) => Promise<boolean>;
handleBookUpload: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleSetSelectMode: (selectMode: boolean) => void;
handleShowDetailsBook: (book: Book) => void;
}
+44 -18
View File
@@ -419,41 +419,56 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const importBooks = async (files: SelectedFile[]) => {
setLoading(true);
const failedFiles = [];
const { library } = useLibraryStore.getState();
const failedImports: Array<{ filename: string; errorMessage: string }> = [];
const errorMap: [string, string][] = [
['No chapters detected.', _('No chapters detected.')],
['Failed to parse EPUB.', _('Failed to parse the EPUB file.')],
['Unsupported format.', _('This book format is not supported.')],
];
const { library } = useLibraryStore.getState();
for (const selectedFile of files) {
const processFile = async (selectedFile: SelectedFile) => {
const file = selectedFile.file || selectedFile.path;
if (!file) continue;
if (!file) return;
try {
const book = await appService?.importBook(file, library);
setLibrary([...library]);
if (user && book && !book.uploadedAt && settings.autoUpload) {
console.log('Uploading book:', book.title);
handleBookUpload(book);
handleBookUpload(book, false);
}
} catch (error) {
const filename = typeof file === 'string' ? file : file.name;
const baseFilename = getFilename(filename);
failedFiles.push(baseFilename);
const errorMessage =
error instanceof Error
? errorMap.find(([substring]) => error.message.includes(substring))?.[1] || ''
: '';
eventDispatcher.dispatch('toast', {
message:
_('Failed to import book(s): {{filenames}}', {
filenames: listFormater(false).format(failedFiles),
}) + (errorMessage ? `\n${errorMessage}` : ''),
type: 'error',
});
failedImports.push({ filename: baseFilename, errorMessage });
console.error('Failed to import book:', filename, error);
}
};
const concurrency = 4;
for (let i = 0; i < files.length; i += concurrency) {
const batch = files.slice(i, i + concurrency);
await Promise.all(batch.map(processFile));
}
pushLibrary();
if (failedImports.length > 0) {
const filenames = failedImports.map((f) => f.filename);
const errorMessage = failedImports.find((f) => f.errorMessage)?.errorMessage || '';
eventDispatcher.dispatch('toast', {
message:
_('Failed to import book(s): {{filenames}}', {
filenames: listFormater(false).format(filenames),
}) + (errorMessage ? `\n${errorMessage}` : ''),
type: 'error',
});
}
setLibrary([...library]);
appService?.saveLibraryBooks(library);
setLoading(false);
};
@@ -468,13 +483,18 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}, 500);
const handleBookUpload = useCallback(
async (book: Book) => {
async (book: Book, syncBooks = true) => {
try {
await appService?.uploadBook(book, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
setBooksTransferProgress((prev) => {
const updated = { ...prev };
delete updated[book.hash];
return updated;
});
await updateBook(envConfig, book);
pushLibrary();
if (syncBooks) pushLibrary();
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
@@ -484,6 +504,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
});
return true;
} catch (err) {
setBooksTransferProgress((prev) => {
const updated = { ...prev };
delete updated[book.hash];
return updated;
});
if (err instanceof Error) {
if (err.message.includes('Not authenticated') && settings.keepLogin) {
settings.keepLogin = false;
@@ -541,7 +566,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
);
const handleBookDelete = (deleteAction: DeleteAction) => {
return async (book: Book) => {
return async (book: Book, syncBooks = true) => {
const deletionMessages = {
both: _('Book deleted: {{title}}', { title: book.title }),
cloud: _('Deleted cloud backup of the book: {{title}}', { title: book.title }),
@@ -555,7 +580,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
try {
await appService?.deleteBook(book, deleteAction);
await updateBook(envConfig, book);
pushLibrary();
if (syncBooks) pushLibrary();
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
@@ -709,6 +734,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook}
booksTransferProgress={booksTransferProgress}
handlePushLibrary={pushLibrary}
/>
</div>
</OverlayScrollbarsComponent>
@@ -15,14 +15,17 @@ import { usePagination } from '../hooks/usePagination';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
import { useBackgroundTexture } from '@/hooks/useBackgroundTexture';
import { useAutoFocus } from '@/hooks/useAutoFocus';
import { useTranslation } from '@/hooks/useTranslation';
import { useEinkMode } from '@/hooks/useEinkMode';
import { useKOSync } from '../hooks/useKOSync';
import {
applyFixedlayoutStyles,
applyImageStyle,
applyTranslationStyle,
getStyles,
keepTextAlignment,
transformStylesheet,
} from '@/utils/style';
import { mountAdditionalFonts, mountCustomFont } from '@/styles/fonts';
@@ -42,6 +45,7 @@ import { getMaxInlineSize } from '@/utils/config';
import { getDirFromUILanguage } from '@/utils/rtl';
import { isCJKLang } from '@/utils/lang';
import { isTauriAppPlatform } from '@/services/environment';
import { TransformContext } from '@/services/transformers/types';
import { transformContent } from '@/services/transformService';
import { lockScreenOrientation } from '@/utils/bridge';
import { useTextTranslation } from '../hooks/useTextTranslation';
@@ -73,6 +77,8 @@ const FoliateViewer: React.FC<{
const { getViewState, getViewSettings, setViewSettings } = useReaderStore();
const { getParallels } = useParallelViewStore();
const { getBookData } = useBookDataStore();
const { applyBackgroundTexture } = useBackgroundTexture();
const { applyEinkMode } = useEinkMode();
const viewState = getViewState(bookKey);
const viewSettings = getViewSettings(bookKey);
@@ -116,15 +122,17 @@ const FoliateViewer: React.FC<{
detail.data = Promise.resolve(detail.data)
.then((data) => {
const viewSettings = getViewSettings(bookKey);
const bookData = getBookData(bookKey);
if (viewSettings && detail.type === 'text/css')
return transformStylesheet(width, height, data);
if (viewSettings && detail.type === 'application/xhtml+xml') {
if (viewSettings && bookData && detail.type === 'application/xhtml+xml') {
const ctx = {
bookKey,
viewSettings,
primaryLanguage: bookData.book?.primaryLanguage,
content: data,
transformers: ['punctuation', 'footnote', 'language'],
};
transformers: ['punctuation', 'footnote', 'whitespace', 'language'],
} as TransformContext;
return Promise.resolve(transformContent(ctx));
}
return data;
@@ -145,14 +153,19 @@ const FoliateViewer: React.FC<{
const writingDir = viewRef.current?.renderer.setStyles && getDirection(detail.doc);
const viewSettings = getViewSettings(bookKey)!;
const bookData = getBookData(bookKey)!;
viewSettings.vertical =
const newVertical =
writingDir?.vertical || viewSettings.writingMode.includes('vertical') || false;
viewSettings.rtl =
const newRtl =
writingDir?.rtl ||
getDirFromUILanguage() === 'rtl' ||
viewSettings.writingMode.includes('rl') ||
false;
setViewSettings(bookKey, { ...viewSettings });
if (viewSettings.vertical !== newVertical || viewSettings.rtl !== newRtl) {
viewSettings.vertical = newVertical;
viewSettings.rtl = newRtl;
setViewSettings(bookKey, { ...viewSettings });
}
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
@@ -166,6 +179,8 @@ const FoliateViewer: React.FC<{
applyImageStyle(detail.doc);
keepTextAlignment(detail.doc);
removeTabIndex(detail.doc);
// Inline scripts in tauri platforms are not executed by default
@@ -300,6 +315,7 @@ const FoliateViewer: React.FC<{
doubleClickDisabled.current = viewSettings.disableDoubleClick!;
const animated = viewSettings.animated!;
const eink = viewSettings.isEink!;
const maxColumnCount = viewSettings.maxColumnCount!;
const maxInlineSize = getMaxInlineSize(viewSettings);
const maxBlockSize = viewSettings.maxBlockSize!;
@@ -312,6 +328,14 @@ const FoliateViewer: React.FC<{
} else {
view.renderer.removeAttribute('animated');
}
if (appService?.isAndroidApp) {
if (eink) {
view.renderer.setAttribute('eink', '');
} else {
view.renderer.removeAttribute('eink');
}
applyEinkMode(eink);
}
if (bookDoc?.rendition?.layout === 'pre-paginated') {
view.renderer.setAttribute('zoom', viewSettings.zoomMode);
view.renderer.setAttribute('spread', viewSettings.spreadMode);
@@ -395,6 +419,17 @@ const FoliateViewer: React.FC<{
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings.customFonts, envConfig]);
useEffect(() => {
if (!viewSettings) return;
applyBackgroundTexture(envConfig, viewSettings);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
viewSettings?.backgroundTextureId,
viewSettings?.backgroundOpacity,
viewSettings?.backgroundSize,
applyBackgroundTexture,
]);
useEffect(() => {
if (viewRef.current && viewRef.current.renderer) {
doubleClickDisabled.current = !!viewSettings?.disableDoubleClick;
@@ -6,7 +6,7 @@ import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { formatReadingProgress } from '@/utils/progress';
import { formatNumber, formatProgress } from '@/utils/progress';
interface PageInfoProps {
bookKey: string;
@@ -40,24 +40,34 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
const isVertical = viewSettings.vertical;
const { progressStyle: readingProgressStyle } = viewSettings;
const formatTemplate =
const template =
readingProgressStyle === 'fraction'
? isVertical
? '{current} · {total}'
: '{current} / {total}'
: '{percent}%';
const pageProgress = bookData?.isFixedLayout ? section : pageinfo;
const progressInfo = bookData?.isFixedLayout
? formatReadingProgress(pageProgress?.current, pageProgress?.total, formatTemplate)
: formatReadingProgress(pageProgress?.current, pageProgress?.total, formatTemplate);
const lang = localStorage?.getItem('i18nextLng') || '';
const localize = isVertical && lang.toLowerCase().startsWith('zh');
const progress = bookData?.isFixedLayout ? section : pageinfo;
const progressInfo = formatProgress(progress?.current, progress?.total, template, localize, lang);
const timeLeft = timeinfo
? _('{{time}} min left in chapter', { time: Math.round(timeinfo.section) })
? _('{{time}} min left in chapter', {
time: formatNumber(Math.round(timeinfo.section), localize, lang),
})
: '';
const { page = 0, pages = 0 } = view?.renderer || {};
const pageLeft =
pages - 1 > page ? _('{{count}} pages left in chapter', { count: pages - 1 - page }) : '';
pages - 1 > page
? localize
? _('{{number}} pages left in chapter', {
number: formatNumber(pages - page - 1, localize, lang),
})
: _('{{count}} pages left in chapter', {
count: pages - page - 1,
})
: '';
return (
<div
@@ -68,10 +78,10 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
isScrolled && !isVertical && 'bg-base-100',
)}
aria-label={[
pageProgress
progress
? _('On {{current}} of {{total}} page', {
current: pageProgress.current + 1,
total: pageProgress.total,
current: progress.current + 1,
total: progress.total,
})
: '',
timeLeft,
@@ -99,7 +109,7 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
<div
aria-hidden='true'
className={clsx(
'flex items-center justify-center',
'flex items-center justify-between',
isVertical ? 'h-full' : 'h-[52px] w-full',
)}
>
@@ -108,7 +118,11 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
) : viewSettings.showRemainingPages ? (
<span className='text-start'>{pageLeft}</span>
) : null}
{viewSettings.showProgressInfo && <span className='ms-auto text-end'>{progressInfo}</span>}
{viewSettings.showProgressInfo && (
<span className={clsx('text-end', isVertical ? 'mt-auto' : 'ms-auto')}>
{progressInfo}
</span>
)}
</div>
</div>
);
@@ -68,8 +68,8 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
}
: {
top: `${topInset}px`,
insetInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`,
width: `calc(100% - ${contentInsets.left + contentInsets.right}px)`,
paddingInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`,
width: '100%',
}
}
>
@@ -11,7 +11,7 @@ import { BookSearchConfig, BookSearchResult } from '@/types/book';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { debounce } from '@/utils/debounce';
import { isCJKStr } from '@/utils/lang';
import { createRejecttFilter } from '@/utils/node';
import { createRejectFilter } from '@/utils/node';
import Dropdown from '@/components/Dropdown';
import SearchOptions from './SearchOptions';
@@ -127,7 +127,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
...searchConfig,
index,
query: term,
acceptNode: createRejecttFilter({
acceptNode: createRejectFilter({
tags: primaryLang.startsWith('ja') ? ['rt'] : [],
}),
});
@@ -54,6 +54,9 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
const [timeoutTimestamp, setTimeoutTimestamp] = useState(0);
const [timeoutFunc, setTimeoutFunc] = useState<ReturnType<typeof setTimeout> | null>(null);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [showIndicatorWithinTimeout, setShowIndicatorWithinTimeout] = useState(true);
const popupPadding = useResponsiveSize(POPUP_PADDING);
const maxWidth = window.innerWidth - 2 * popupPadding;
const popupWidth = Math.min(maxWidth, useResponsiveSize(POPUP_WIDTH));
@@ -208,6 +211,30 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
viewSettings.ttsLocation = cfi || '';
setViewSettings(bookKey, viewSettings);
}
if (!view.renderer.scrolled) {
view.renderer.scrollToAnchor(range);
} else {
const rect = range.getBoundingClientRect();
const { start, size, viewSize, sideProp } = view.renderer;
const positionStart = rect[sideProp === 'height' ? 'y' : 'x'] + viewSettings.marginTopPx;
const positionEnd = rect[sideProp === 'height' ? 'height' : 'width'] + positionStart;
const offsetStart = view.book.dir === 'rtl' ? viewSize - positionStart : positionStart;
const offsetEnd = view.book.dir === 'rtl' ? viewSize - positionEnd : positionEnd;
const showHeader = viewSettings.showHeader;
const showFooter = viewSettings.showFooter;
const showBarsOnScroll = viewSettings.showBarsOnScroll;
const headerScrollOverlap = showHeader && showBarsOnScroll ? 44 : 0;
const footerScrollOverlap = showFooter && showBarsOnScroll ? 44 : 0;
const scrollingOverlap = viewSettings.scrollingOverlap;
const endInNextView = offsetEnd > start + size - footerScrollOverlap - scrollingOverlap;
const startInPrevView = offsetStart < start + headerScrollOverlap + scrollingOverlap;
if (endInNextView || startInPrevView) {
const scrollTo = offsetStart - headerScrollOverlap - scrollingOverlap;
view.renderer.scrollToAnchor(scrollTo / viewSize);
}
}
};
ttsController.addEventListener('tts-speak-mark', handleSpeakMark);
@@ -547,10 +574,38 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showPanel]);
useEffect(() => {
if (hoveredBookKey) {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
const showTimeout = setTimeout(() => {
setShowIndicatorWithinTimeout(true);
}, 100);
hoverTimeoutRef.current = showTimeout;
} else {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
const hideTimeout = setTimeout(() => {
setShowIndicatorWithinTimeout(false);
}, 5000);
hoverTimeoutRef.current = hideTimeout;
}
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
};
}, [hoveredBookKey]);
return (
<>
{showPanel && <Overlay onDismiss={handleDismissPopup} />}
{showIndicator && hoveredBookKey === bookKey && (
{(showPanel || (showIndicator && showIndicatorWithinTimeout)) && (
<div
ref={iconRef}
className={clsx(
@@ -112,7 +112,7 @@ export const useTouchEvent = (
// also check for deltaX to prevent swipe page turn from triggering the toggle
if (
deltaY < -10 &&
Math.abs(deltaY) > Math.abs(deltaX) &&
Math.abs(deltaY) > Math.abs(deltaX) * 2 &&
Math.abs(deltaX) < windowWidth * 0.3
) {
// swipe up to toggle the header bar and the footer bar, only for horizontal page mode
@@ -142,7 +142,11 @@ export const useProgressSync = (bookKey: string) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Object.entries(syncedConfig).filter(([_, value]) => value !== null && value !== undefined),
);
setConfig(bookKey, { ...config, ...filteredSyncedConfig });
if (syncedConfig.updatedAt >= config.updatedAt) {
setConfig(bookKey, { ...config, ...filteredSyncedConfig });
} else {
setConfig(bookKey, { ...filteredSyncedConfig, ...config });
}
if (remoteCFILocation && configCFI) {
if (CFI.compare(configCFI, remoteCFILocation) < 0) {
if (view) {
+8 -17
View File
@@ -12,14 +12,16 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
import { useCustomTextureStore } from '@/store/customTextureStore';
import { useBackgroundTexture } from '@/hooks/useBackgroundTexture';
import { useEinkMode } from '@/hooks/useEinkMode';
import { getLocale } from '@/utils/misc';
const Providers = ({ children }: { children: React.ReactNode }) => {
const { envConfig, appService } = useEnv();
const { applyUILanguage } = useSettingsStore();
const { setScreenBrightness } = useDeviceControlStore();
const { addTexture, applyTexture } = useCustomTextureStore();
const { applyBackgroundTexture } = useBackgroundTexture();
const { applyEinkMode } = useEinkMode();
const iconSize = useDefaultIconSize();
useSafeAreaInsets(); // Initialize safe area insets
@@ -47,24 +49,13 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
if (appService.hasScreenBrightness && brightness >= 0) {
setScreenBrightness(brightness / 100);
}
const backgroundTextureId = globalViewSettings.backgroundTextureId;
const backgroundOpacity = globalViewSettings.backgroundOpacity;
const backgroundSize = globalViewSettings.backgroundSize;
if (backgroundTextureId && backgroundTextureId !== 'none') {
const customTexture = settings.customTextures.find((t) => t.id === backgroundTextureId);
if (customTexture) {
addTexture(customTexture.path);
}
applyTexture(envConfig, backgroundTextureId);
document.documentElement.style.setProperty(
'--bg-texture-opacity',
backgroundOpacity.toString(),
);
document.documentElement.style.setProperty('--bg-texture-size', backgroundSize);
applyBackgroundTexture(envConfig, globalViewSettings);
if (appService.isAndroidApp) {
applyEinkMode(globalViewSettings.isEink);
}
});
}
}, [envConfig, appService, applyUILanguage, addTexture, applyTexture, setScreenBrightness]);
}, [envConfig, appService, applyUILanguage, setScreenBrightness, applyBackgroundTexture]);
// Make sure appService is available in all children components
if (!appService) return;
@@ -142,10 +142,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
const applyBackgroundTexture = () => {
applyTexture(envConfig, selectedTextureId);
document.documentElement.style.setProperty(
'--bg-texture-opacity',
backgroundOpacity.toString(),
);
document.documentElement.style.setProperty('--bg-texture-opacity', `${backgroundOpacity}`);
document.documentElement.style.setProperty('--bg-texture-size', backgroundSize);
};
@@ -6,6 +6,7 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useResetViewSettings } from '@/hooks/useResetSettings';
import { useEinkMode } from '@/hooks/useEinkMode';
import { getStyles } from '@/utils/style';
import { saveAndReload } from '@/utils/reload';
import { getMaxInlineSize } from '@/utils/config';
@@ -19,6 +20,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
const { getView, getViewSettings } = useReaderStore();
const { getBookData } = useBookDataStore();
const { settings } = useSettingsStore();
const { applyEinkMode } = useEinkMode();
const { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
const bookData = getBookData(bookKey);
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
@@ -31,6 +33,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
const [swapClickArea, setSwapClickArea] = useState(viewSettings.swapClickArea);
const [isDisableDoubleClick, setIsDisableDoubleClick] = useState(viewSettings.disableDoubleClick);
const [animated, setAnimated] = useState(viewSettings.animated);
const [isEink, setIsEink] = useState(viewSettings.isEink);
const [allowScript, setAllowScript] = useState(viewSettings.allowScript);
const resetToDefaults = useResetViewSettings();
@@ -44,6 +47,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
disableClick: setIsDisableClick,
swapClickArea: setSwapClickArea,
animated: setAnimated,
isEink: setIsEink,
allowScript: setAllowScript,
});
};
@@ -113,6 +117,17 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animated]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'isEink', isEink, false, false);
if (isEink) {
getView(bookKey)?.renderer.setAttribute('eink', '');
} else {
getView(bookKey)?.renderer.removeAttribute('eink');
}
applyEinkMode(isEink);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEink]);
useEffect(() => {
if (viewSettings.allowScript === allowScript) return;
saveViewSettings(envConfig, bookKey, 'allowScript', allowScript, true, false);
@@ -221,6 +236,25 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
</div>
</div>
{appService?.isAndroidApp && (
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Device')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('E-Ink Mode')}</span>
<input
type='checkbox'
className='toggle'
checked={isEink}
onChange={() => setIsEink(!isEink)}
/>
</div>
</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'>
@@ -462,7 +462,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
onChange={setParagraphMargin}
min={0}
max={4}
step={0.2}
step={0.1}
/>
<NumberInput
label={_('Line Spacing')}
@@ -0,0 +1,31 @@
import { useCallback } from 'react';
import { useCustomTextureStore } from '@/store/customTextureStore';
import { useSettingsStore } from '@/store/settingsStore';
import { EnvConfigType } from '@/services/environment';
import { ViewSettings } from '@/types/book';
export const useBackgroundTexture = () => {
const applyBackgroundTexture = useCallback(
(envConfig: EnvConfigType, viewSettings: ViewSettings) => {
const textureId = viewSettings.backgroundTextureId;
const textureOpacity = viewSettings.backgroundOpacity;
const textureSize = viewSettings.backgroundSize;
if (!textureId || textureId === 'none') return;
document.documentElement.style.setProperty('--bg-texture-opacity', `${textureOpacity}`);
document.documentElement.style.setProperty('--bg-texture-size', textureSize);
const settings = useSettingsStore.getState().settings;
const customTexture = settings.customTextures?.find((t) => t.id === textureId);
if (customTexture) {
useCustomTextureStore.getState().addTexture(customTexture.path);
}
useCustomTextureStore.getState().applyTexture(envConfig, textureId);
},
[],
);
return { applyBackgroundTexture };
};
+13
View File
@@ -0,0 +1,13 @@
import { useCallback } from 'react';
export const useEinkMode = () => {
const applyEinkMode = useCallback((isEink: boolean) => {
if (isEink) {
document.body.classList.add('no-transitions');
} else {
document.body.classList.remove('no-transitions');
}
}, []);
return { applyEinkMode };
};
+7 -3
View File
@@ -132,6 +132,7 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
maxInlineSize: getDefaultMaxInlineSize(),
maxBlockSize: getDefaultMaxBlockSize(),
animated: false,
isEink: false,
writingMode: 'auto',
vertical: false,
rtl: false,
@@ -141,8 +142,8 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
export const DEFAULT_BOOK_STYLE: BookStyle = {
zoomLevel: 100,
paragraphMargin: 1,
lineHeight: 1.6,
paragraphMargin: 0.6,
lineHeight: 1.4,
wordSpacing: 0,
letterSpacing: 0,
textIndent: 0,
@@ -155,7 +156,7 @@ export const DEFAULT_BOOK_STYLE: BookStyle = {
overrideColor: false,
backgroundTextureId: 'none',
backgroundOpacity: 0.6,
backgroundSize: 'auto',
backgroundSize: 'cover',
codeHighlighting: false,
codeLanguage: 'auto-detect',
userStylesheet: '',
@@ -177,6 +178,8 @@ export const DEFAULT_MOBILE_VIEW_SETTINGS: Partial<ViewSettings> = {
export const DEFAULT_CJK_VIEW_SETTINGS: Partial<ViewSettings> = {
fullJustification: true,
textIndent: 2,
paragraphMargin: 1,
lineHeight: 1.6,
};
export const DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS: Partial<ViewSettings> = {
@@ -231,6 +234,7 @@ export const SERIF_FONTS = [
'Bitter',
'Literata',
'Merriweather',
'Roboto Slab',
'Vollkorn',
'Georgia',
'Times New Roman',
@@ -2,10 +2,12 @@ import type { Transformer } from './types';
import { footnoteTransformer } from './footnote';
import { languageTransformer } from './language';
import { punctuationTransformer } from './punctuation';
import { whitespaceTransformer } from './whitespace';
export const availableTransformers: Transformer[] = [
punctuationTransformer,
footnoteTransformer,
languageTransformer,
whitespaceTransformer,
// Add more transformers here
];
@@ -1,10 +1,11 @@
import { detectLanguage, isValidLang } from '@/utils/lang';
import { detectLanguage, isSameLang, isValidLang } from '@/utils/lang';
import type { Transformer } from './types';
export const languageTransformer: Transformer = {
name: 'language',
transform: async (ctx) => {
const primaryLanguage = ctx.primaryLanguage;
let result = ctx.content;
const attrsMatch = result.match(/<html\b([^>]*)>/i);
if (attrsMatch) {
@@ -13,9 +14,10 @@ export const languageTransformer: Transformer = {
const xmlLangRegex = / xml:lang="([^"]*)"/i;
const xmlLangMatch = attrs.match(xmlLangRegex);
const langMatch = attrs.match(langRegex);
if (!isValidLang(langMatch?.[1] || xmlLangMatch?.[1])) {
const docLang = langMatch?.[1] || xmlLangMatch?.[1];
if (!isValidLang(docLang) || !isSameLang(docLang, primaryLanguage)) {
const mainContent = result.replace(/<[^>]+>/g, ' ');
const lang = detectLanguage(mainContent);
const lang = isValidLang(primaryLanguage) ? primaryLanguage : detectLanguage(mainContent);
const newLangAttr = ` lang="${lang}"`;
const newXmlLangAttr = ` xml:lang="${lang}"`;
attrs = langMatch ? attrs.replace(langRegex, newLangAttr) : attrs + newLangAttr;
@@ -3,6 +3,7 @@ import { ViewSettings } from '@/types/book';
export type TransformContext = {
bookKey: string;
viewSettings: ViewSettings;
primaryLanguage?: string;
content: string;
transformers: string[];
reversePunctuationTransform?: boolean;
@@ -0,0 +1,21 @@
import type { Transformer } from './types';
export const whitespaceTransformer: Transformer = {
name: 'whitespace',
transform: async (ctx) => {
const viewSettings = ctx.viewSettings;
if (viewSettings.overrideLayout) {
const cleaned = ctx.content
// Replace &nbsp; but skip literal "&amp;nbsp;"
.replace(/(?<!&amp;)&nbsp;/g, ' ')
// Replace literal non-breaking space characters (U+00A0) with normal spaces
.replace(/\u00A0/g, ' ')
// Collapse consecutive spaces into one
.replace(/ {2,}/g, ' ');
return cleaned;
} else {
return ctx.content;
}
},
};
@@ -3,7 +3,7 @@ import { AppService } from '@/types/system';
import { parseSSMLMarks } from '@/utils/ssml';
import { Overlayer } from 'foliate-js/overlayer.js';
import { TTSGranularity, TTSHighlightOptions, TTSMark, TTSVoice } from './types';
import { createRejecttFilter } from '@/utils/node';
import { createRejectFilter } from '@/utils/node';
import { WebSpeechClient } from './WebSpeechClient';
import { NativeTTSClient } from './NativeTTSClient';
import { EdgeTTSClient } from './EdgeTTSClient';
@@ -85,13 +85,6 @@ export class TTSController extends EventTarget {
const { style, color } = options;
overlayer?.remove(HIGHLIGHT_KEY);
overlayer?.add(HIGHLIGHT_KEY, range, Overlayer[style], { color });
const rect = range.getBoundingClientRect();
const { start, size, viewSize, sideProp } = this.view.renderer;
const position = rect[sideProp === 'height' ? 'y' : 'x'] + 88;
const offset = this.view.book.dir === 'rtl' ? viewSize - position : position;
if (!this.view.renderer.scrolled || offset < start || offset > start + size) {
this.view.renderer.scrollToAnchor(range);
}
};
}
@@ -109,7 +102,7 @@ export class TTSController extends EventTarget {
const highlightOptions: TTSHighlightOptions = { style: 'highlight', color: 'gray' };
await this.view.initTTS(
granularity,
createRejecttFilter({
createRejectFilter({
tags: ['rt', 'sup'],
contents: [{ tag: 'a', content: /^\d+$/ }],
}),
+1 -2
View File
@@ -288,10 +288,9 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
const oldConfig = bookData.config;
const newConfig = {
...bookData.config,
updatedAt: Date.now(),
progress,
location,
};
} as BookConfig;
useBookDataStore.setState((state) => ({
booksData: {
+6 -1
View File
@@ -141,7 +141,12 @@ export const loadDataTheme = () => {
const themeMode = localStorage.getItem('themeMode');
const themeColor = localStorage.getItem('themeColor');
if (themeMode && themeColor) {
document.documentElement.setAttribute('data-theme', `${themeColor}-${themeMode}`);
const systemIsDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDarkMode = themeMode === 'dark' || (themeMode === 'auto' && systemIsDarkMode);
document.documentElement.setAttribute(
'data-theme',
`${themeColor}-${isDarkMode ? 'dark' : 'light'}`,
);
}
};
+2 -1
View File
@@ -12,6 +12,7 @@ const basicGoogleFonts = [
{ family: 'Noto Sans', weights: 'ital,wght@0,100..900;1,100..900' },
{ family: 'Open Sans', weights: 'ital,wght@0,300..800;1,300..800' },
{ family: 'Roboto', weights: 'ital,wght@0,100..900;1,100..900' },
{ family: 'Roboto Slab', weights: 'ital,wght@0,100..900;1,100..900' },
{ family: 'Vollkorn', weights: 'ital,wght@0,400..900;1,400..900' },
];
@@ -33,7 +34,7 @@ const getAdditionalBasicFontLinks = () => `
const getAdditionalCJKFontLinks = () => `
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/misans-webfont@1.0.4/misans-l3/misans-l3/result.min.css" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cn-fontsource-lxgw-wen-kai-gb-screen@1.0.6/font.min.css" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lxgw-wenkai-screen-web/1.520.0/lxgwwenkaigbscreen/result.css" crossorigin="anonymous" />
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/hwmct/dist/%E6%B1%87%E6%96%87%E6%98%8E%E6%9C%9D%E4%BD%93/result.css' crossorigin="anonymous" />
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/jhlst/dist/%E4%BA%AC%E8%8F%AF%E8%80%81%E5%AE%8B%E4%BD%93v2_002/result.css' crossorigin="anonymous" />
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/syst/dist/SourceHanSerifCN/result.css' crossorigin="anonymous" />
+4
View File
@@ -381,4 +381,8 @@ foliate-view {
.animate-reverse-spin {
animation: spin-counterclockwise 1s linear;
}
.no-transitions * {
transition: none !important;
}
+8 -4
View File
@@ -59,7 +59,7 @@ const createTextureCSS = (texture: BackgroundTexture) => {
body::before, .sidebar-container::before, .notebook-container::before,
.foliate-viewer::before {
content: "";
position: fixed;
position: absolute;
top: 0;
left: 0;
right: 0;
@@ -68,10 +68,14 @@ const createTextureCSS = (texture: BackgroundTexture) => {
z-index: 0;
background-image: url("${texture.blobUrl || texture.url}");
background-repeat: repeat;
background-size: var(--bg-texture-size, auto);
mix-blend-mode: var(--bg-texture-blend-mode, normal);
background-size: var(--bg-texture-size, cover);
mix-blend-mode: var(--bg-texture-blend-mode, multiply);
opacity: var(--bg-texture-opacity, 0.6);
}`;
}
body::before {
height: 100vh;
}
`;
return css;
};
+1
View File
@@ -105,6 +105,7 @@ export interface BookLayout {
maxInlineSize: number;
maxBlockSize: number;
animated: boolean;
isEink: boolean;
writingMode: WritingMode;
vertical: boolean;
rtl: boolean;
+1 -1
View File
@@ -1,4 +1,4 @@
export const createRejecttFilter = ({
export const createRejectFilter = ({
tags = [],
classes = [],
attributes = [],
+52
View File
@@ -0,0 +1,52 @@
export const localizeNumber = (num: number, language: string, isIndex = false): string => {
const lang = language.toLowerCase();
const isChinese = lang.startsWith('zh');
if (!isChinese) {
return num.toString();
}
const isTraditional = lang.includes('tw') || lang.includes('hk');
const baseDigits = isTraditional ? '零壹貳叁肆伍陸柒捌玖' : '零一二三四五六七八九';
const zeroChar = isIndex ? '' : '零';
const digits = baseDigits.replace('零', zeroChar);
const units = isTraditional
? ['', '拾', '佰', '仟', '萬', '億']
: ['', '十', '百', '千', '万', '亿'];
const str = String(num);
const len = str.length;
let result = '';
for (let i = 0; i < len; i++) {
const n = Number(str[i]);
const pos = len - i - 1;
if (n !== 0) {
result += digits[n]! + units[pos % 4]!;
} else if (!result.endsWith(zeroChar) && i < len - 1) {
result += zeroChar;
}
if (pos === 4) result += units[4];
if (pos === 8) result += units[5];
}
result = result
.replace(new RegExp(`${zeroChar}+$`), '')
.replace(`${zeroChar}`, '萬')
.replace(`${zeroChar}`, '万')
.replace(new RegExp(`${zeroChar}+`, 'g'), zeroChar);
if (!isIndex) {
if (isTraditional) {
result = result.replace(
/^貳$|(?<![壹貳叁肆伍陸柒捌玖])貳(?![佰仟萬億壹貳叁肆伍陸柒捌玖])/g,
'兩',
);
} else {
result = result.replace(
/^二$|(?<![一二三四五六七八九])二(?![百千万亿一二三四五六七八九])/g,
'两',
);
}
}
return result;
};
+20 -3
View File
@@ -1,14 +1,31 @@
export function formatReadingProgress(
import { localizeNumber } from './number';
export function formatProgress(
current: number | undefined,
total: number | undefined,
template: string,
localize: boolean = false,
language: string = 'en',
): string {
if (current !== undefined && total !== undefined && total > 0 && current >= 0) {
const currentStr = localize ? localizeNumber(current + 1, language, true) : String(current + 1);
const totalStr = localize ? localizeNumber(total, language, true) : String(total);
return template
.replace('{current}', String(current + 1))
.replace('{total}', String(total))
.replace('{current}', currentStr)
.replace('{total}', totalStr)
.replace('{percent}', (((current + 1) / total) * 100).toFixed(1));
} else {
return '';
}
}
export function formatNumber(
number: number | undefined,
localize: boolean = false,
language: string = 'en',
): string {
if (number === undefined || number < 0) {
return '';
}
return localize ? localizeNumber(number, language) : String(number);
}
+28 -3
View File
@@ -249,9 +249,7 @@ const getLayoutStyles = (
line-height: ${lineSpacing} ${overrideLayout ? '!important' : ''};
word-spacing: ${wordSpacing}px ${overrideLayout ? '!important' : ''};
letter-spacing: ${letterSpacing}px ${overrideLayout ? '!important' : ''};
text-indent: ${vertical ? textIndent * 1.2 : textIndent}em ${overrideLayout ? '!important' : ''};
${justify ? `text-align: justify ${overrideLayout ? '!important' : ''};` : ''}
${!justify && overrideLayout ? 'text-align: initial !important;' : ''};
text-indent: ${textIndent}em ${overrideLayout ? '!important' : ''};
-webkit-hyphens: ${hyphenate ? 'auto' : 'manual'};
hyphens: ${hyphenate ? 'auto' : 'manual'};
-webkit-hyphenate-limit-before: 3;
@@ -260,6 +258,18 @@ const getLayoutStyles = (
hanging-punctuation: allow-end last;
widows: 2;
}
p.aligned-center, blockquote.aligned-center,
dd.aligned-center, div.aligned-center {
text-align: center ${overrideLayout ? '!important' : ''};
}
p.aligned-right, blockquote.aligned-right,
dd.aligned-right, div.aligned-right {
text-align: right ${overrideLayout ? '!important' : ''};
}
p.aligned-justify, blockquote.aligned-justify,
dd.aligned-justify, div.aligned-justify {
${!justify && overrideLayout ? 'text-align: initial !important;' : ''};
}
p:has(> img:only-child), p:has(> span:only-child > img:only-child),
p:has(> img:not(.has-text-siblings)),
p:has(> a:first-child + img:last-child) {
@@ -267,6 +277,8 @@ const getLayoutStyles = (
}
blockquote[align="center"], div[align="center"],
p[align="center"], dd[align="center"],
p.aligned-center, blockquote.aligned-center,
dd.aligned-center, div.aligned-center,
li p, ol p, ul p {
text-indent: initial !important;
}
@@ -581,6 +593,19 @@ export const applyImageStyle = (document: Document) => {
});
};
export const keepTextAlignment = (document: Document) => {
document.querySelectorAll('div, p, blockquote, dd').forEach((el) => {
const computedStyle = window.getComputedStyle(el);
if (computedStyle.textAlign === 'center') {
el.classList.add('aligned-center');
} else if (computedStyle.textAlign === 'right') {
el.classList.add('aligned-right');
} else if (computedStyle.textAlign === 'justify') {
el.classList.add('aligned-justify');
}
});
};
export const applyFixedlayoutStyles = (
document: Document,
viewSettings: ViewSettings,
+2 -1
View File
@@ -115,9 +115,10 @@ export class TxtToEpubConverter {
[
String.raw`第[零〇一二三四五六七八九十0-9][零〇一二三四五六七八九十百千万0-9]*(?:[章卷节回讲篇封本册部话])(?:[::、  \(\)0-9]*[^\n-]{0,24})(?!\S)`,
String.raw`(?:楔子|前言|简介|引言|序言|序章|总论|概论|后记)(?:[:  ][^\n-]{0,24})?(?!\S)`,
String.raw`chapter[\s.]*[0-9]+(?:[:.  ]+[^\n-]{0,50})?(?!\S)`,
].join('|') +
')',
'gu',
'gui',
),
);
chapterRegexps.push(
+54 -32
View File
@@ -1,5 +1,6 @@
local Device = require("device")
local Event = require("ui/event")
local Dispatcher = require("dispatcher")
local InfoMessage = require("ui/widget/infomessage")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local MultiInputDialog = require("ui/widget/multiinputdialog")
@@ -40,35 +41,25 @@ function ReadestSync:init()
self.last_sync_timestamp = 0
self.settings = G_reader_settings:readSetting("readest_sync", self.default_settings)
self:onDispatcherRegisterActions()
self.ui.menu:registerToMainMenu(self)
end
function ReadestSync:onDispatcherRegisterActions()
--
Dispatcher:registerAction("readest_sync_set_autosync",
{ category="string", event="ReadestSyncToggleAutoSync", title=_("Set auto progress sync"), reader=true,
args={true, false}, toggle={_("on"), _("off")},})
Dispatcher:registerAction("readest_sync_toggle_autosync", { category="none", event="ReadestSyncToggleAutoSync", title=_("Toggle auto readest sync"), reader=true,})
Dispatcher:registerAction("readest_sync_push_progress", { category="none", event="ReadestSyncPushProgress", title=_("Push readest progress from this device"), reader=true,})
Dispatcher:registerAction("readest_sync_pull_progress", { category="none", event="ReadestSyncPullProgress", title=_("Pull readest progress from other devices"), reader=true, separator=true,})
end
function ReadestSync:needsLogin()
return not self.settings.access_token or not self.settings.expires_at
or self.settings.expires_at < os.time() + 60
end
function ReadestSync:tryRefreshToken()
if self.settings.refresh_token and self.settings.expires_at
and self.settings.expires_at < os.time() - self.settings.expires_in / 2 then
local client = self:getSupabaseAuthClient()
client:refresh_token(self.settings.refresh_token, function(success, response)
if success then
self.settings.access_token = response.access_token
self.settings.refresh_token = response.refresh_token
self.settings.expires_at = response.expires_at
self.settings.expires_in = response.expires_in
G_reader_settings:saveSetting("readest_sync", self.settings)
else
logger.err("ReadestSync: Token refresh failed:", response or "Unknown error")
end
function ReadestSync:onReaderReady()
if self.settings.auto_sync and self.settings.access_token then
UIManager:nextTick(function()
self:pullBookConfig(false)
end)
end
self:onDispatcherRegisterActions()
end
function ReadestSync:addToMainMenu(menu_items)
@@ -98,17 +89,14 @@ function ReadestSync:addToMainMenu(menu_items)
text = _("Auto sync book configs"),
checked_func = function() return self.settings.auto_sync end,
callback = function()
self.settings.auto_sync = not self.settings.auto_sync
if self.settings.auto_sync then
self:pullBookConfig(false)
end
self:onReadestSyncToggleAutoSync()
end,
separator = true,
},
{
text = _("Push book config now"),
enabled_func = function()
return self.settings.access_token ~= nil
return self.settings.access_token ~= nil and self.ui.document ~= nil
end,
callback = function()
self:pushBookConfig(true)
@@ -117,7 +105,7 @@ function ReadestSync:addToMainMenu(menu_items)
{
text = _("Pull book config now"),
enabled_func = function()
return self.settings.access_token ~= nil
return self.settings.access_token ~= nil and self.ui.document ~= nil
end,
callback = function()
self:pullBookConfig(true)
@@ -127,6 +115,29 @@ function ReadestSync:addToMainMenu(menu_items)
}
end
function ReadestSync:needsLogin()
return not self.settings.access_token or not self.settings.expires_at
or self.settings.expires_at < os.time() + 60
end
function ReadestSync:tryRefreshToken()
if self.settings.refresh_token and self.settings.expires_at
and self.settings.expires_at < os.time() - self.settings.expires_in / 2 then
local client = self:getSupabaseAuthClient()
client:refresh_token(self.settings.refresh_token, function(success, response)
if success then
self.settings.access_token = response.access_token
self.settings.refresh_token = response.refresh_token
self.settings.expires_at = response.expires_at
self.settings.expires_in = response.expires_in
G_reader_settings:saveSetting("readest_sync", self.settings)
else
logger.err("ReadestSync: Token refresh failed:", response or "Unknown error")
end
end)
end
end
function ReadestSync:getSupabaseAuthClient()
if not self.settings.supabase_url or not self.settings.supabase_anon_key then
return nil
@@ -596,12 +607,23 @@ function ReadestSync:pullBookConfig(interactive)
)
end
function ReadestSync:onReaderReady()
if self.settings.auto_sync and self.settings.access_token then
UIManager:nextTick(function()
self:pullBookConfig(false)
end)
function ReadestSync:onReadestSyncToggleAutoSync(toggle)
if toggle == self.settings.auto_sync then
return true
end
self.settings.auto_sync = not self.settings.auto_sync
G_reader_settings:saveSetting("readest_sync", self.settings)
if self.settings.auto_sync and self.ui.document then
self:pullBookConfig(false)
end
end
function ReadestSync:onReadestSyncPushProgress()
self:pushBookConfig(true)
end
function ReadestSync:onReadestSyncPullProgress()
self:pullBookConfig(true)
end
function ReadestSync:onCloseDocument()