Compare commits

..

36 Commits

Author SHA1 Message Date
Huang Xin cc97a1b36f Update README (#140) 2025-01-10 14:36:37 +01:00
Huang Xin 4e42ec4a44 Release 0.9.2 (#139) 2025-01-10 14:13:56 +01:00
Huang Xin 00003a9415 Preloading audio for Edge TTS and other fixes (#138)
* Blacklist some low-quality web tts voices

* Dropdown layout tweaks

* Handle no audio data received in Edge TTS

* Preloading audio for Edge TTS
2025-01-10 14:11:09 +01:00
Huang Xin 7402141237 Add Edge TTS backend, selecting different voices will change TTS backend dynamically (#137) 2025-01-10 02:35:23 +01:00
Huang Xin e0323bb0ed Fix access token sometimes invalid in browser context (#136)
* Update dependencies

* Fix access token sometimes invalid in browser context
2025-01-09 17:58:55 +01:00
Egan Gumiwang Pratama Bisma 112c5fa979 enforce numeric inputs on NumberInput and prevent NaN text (#135) 2025-01-09 12:51:06 +01:00
Egan Gumiwang Pratama Bisma 86dc32dab3 persist max block size setting in LayoutPanel component (#134)
https://github.com/readest/readest/issues/131
2025-01-09 12:23:11 +01:00
Huajin 33d4dcd740 Better animation for sidebar transitions (#133)
* Add transition for BooknoteItem

* Add Tab switch animations and tooltip 'Toc' translations.

* Use 'Table of Contents' instead of 'Toc' as key.
2025-01-09 12:16:07 +01:00
Huang Xin 36d6074468 Filter voices list and shutdown TTS when closing book (#130) 2025-01-09 03:33:52 +01:00
Huang Xin cc1396876e Add select voice button in the TTS panel (#129) 2025-01-09 01:34:30 +01:00
Huang Xin f26de3ab0d Add speech speed slider in the TTS panel (#128) 2025-01-08 21:31:12 +01:00
Huang Xin 814a0032d0 Add 'Speak' button in the footbar (#127) 2025-01-08 19:44:04 +01:00
Huang Xin c6defb97ee Fix TTS might stop at the end of the section (#126) 2025-01-08 17:50:36 +01:00
Huang Xin ddee347eb3 Fix text color in misc panel and adjust order of annotator tools (#125) 2025-01-08 16:50:57 +01:00
Huang Xin 83b10a1df8 Fix color of return button in auth page in dark mode (#124) 2025-01-08 15:44:14 +01:00
Huang Xin 3e43905321 Fix TTS paused after forward/backward (#123) 2025-01-08 15:18:58 +01:00
Huang Xin 89b2eba2af Refactor Toast to global component (#122)
* Refactor Toast to global component, use eventDispatcher to dispatch toast events

* Error handling for TTS
2025-01-08 14:12:38 +01:00
Huang Xin 15d8d61841 Support more than 2 columns in page layout, closes #119 (#121) 2025-01-07 22:32:07 +01:00
Huang Xin 07b04b82e6 Initial implementation of TTS with Web Speech API (#120)
* Initial implementation of TTS with Web Speech API

* Add translations for annotator tools
2025-01-07 21:57:55 +01:00
Huang Xin a59a3b633b Fix progress overridden by newly opened book (#117) 2025-01-06 19:19:02 +01:00
Huang Xin cccd4c2b16 Various fixes (#116)
* Fix missing translations in translator popup

* Disable annotator for PDFs for now, closes #115

* Fix triangle in shadow of popup making it darker
2025-01-06 01:33:35 +01:00
Huang Xin 990f61fb72 Fix layout issues on Windows (#113) 2025-01-05 20:11:23 +01:00
Huang Xin d51f5a632a Regenerate tauri signing key to solve InvalidSignature error when updating 2025-01-05 18:07:49 +01:00
Huang Xin 3e9b89de0e Swap font when downloading online fonts, closes #104 (#111) 2025-01-05 17:26:36 +01:00
Huang Xin f900608d6a Update report-bug template to collect OS and version info 2025-01-05 16:23:48 +01:00
Huang Xin ca56c5a73d Fix no rounded window in reader page on Linux, closes #107 (#110) 2025-01-05 15:50:12 +01:00
Huang Xin 57b72d6d57 Release exe portable files directly to workaround the stupid virus warning on Windows 2025-01-05 14:19:18 +01:00
Huang Xin 76c5f58547 Restore window position and size, closes #7 (#109) 2025-01-05 14:08:56 +01:00
Huang Xin e4a6ae01e0 Release portable binaries for Windows (#102) 2025-01-04 19:46:57 +01:00
Huang Xin 2763c18d6f Fix dropdown close overlay not working on Windows/Linux (#101) 2025-01-04 17:13:53 +01:00
Huang Xin 71d0735917 Various fixes (#99)
* Add shortcut to page turning with the space key on keyboard

* Properly handle auto refresh token with supabase

* Fix settings not stored in direct quit of app, closes #94
2025-01-04 14:18:57 +01:00
Huang Xin 3484c893df Fix deeplink on Windows (#95) 2025-01-04 11:20:31 +01:00
Huang Xin e885d22631 Add deeplink to parse oauth callback url in tauri apps (#92) 2025-01-03 23:24:30 +01:00
Huang Xin 3ad26d9d8f Add vertical/horizontal switch in Layout configs for CJK books (#91) 2025-01-03 21:24:15 +01:00
Huang Xin b699fc98c2 Fix title layout of book details modal and apply of custom css deletion (#90)
* Fix custom css cannot apply after deletion

* Fix title layout of book details modal, closes #89
2025-01-03 19:12:25 +01:00
Huang Xin 2c9fe8e4f4 Better Custom CSS editor (#88) 2025-01-03 14:52:31 +01:00
81 changed files with 2972 additions and 439 deletions
+9 -3
View File
@@ -1,7 +1,7 @@
---
name: Report a bug
about: Report a bug or a functional regression
title: 'Ex: In DarkMode, a blank square appears in bottom right corner while scrolling'
title: 'Example: In DarkMode, a blank square appears in bottom right corner while scrolling'
labels: ['type: bug']
assignees: ''
---
@@ -30,9 +30,15 @@ The blank square should be transparent (invisible)
## Technical inputs
Operating System:
Readest Version:
**Example:**
```
- We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
- Probably fixable with CSS
Operating System: macOS 14.3.1
Readest Version: 0.9.0
We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
Probably fixable with CSS
```
+34 -1
View File
@@ -12,7 +12,9 @@ jobs:
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.get-release.outputs.release_id }}
release_tag: ${{ steps.get-release.outputs.release_tag }}
release_note: ${{ steps.get-release-notes.outputs.release_note }}
release_version: ${{ steps.get-release-notes.outputs.release_version }}
steps:
- uses: actions/checkout@v4
@@ -30,6 +32,7 @@ jobs:
repo: context.repo.repo,
})
core.setOutput('release_id', data.id);
core.setOutput('release_tag', data.tag_name);
- name: get release notes
id: get-release-notes
uses: actions/github-script@v7
@@ -42,6 +45,7 @@ jobs:
const notes = releaseNotes.notes || [];
const releaseNote = notes.map((note, index) => `${index + 1}. ${note}`).join(' ');
console.log('Formatted release note:', releaseNote);
core.setOutput('release_version', version);
core.setOutput('release_note', releaseNote);
build-tauri:
@@ -138,6 +142,34 @@ jobs:
releaseBody: ${{ needs.get-release.outputs.release_note }}
args: ${{ matrix.config.args || '' }}
- name: upload portable binaries (Windows only)
if: matrix.config.os == 'windows-latest'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
echo "Uploading Portable Binaries"
arch=${{ matrix.config.arch }}
version=${{ needs.get-release.outputs.release_version }}
if [ "$arch" = "x86_64" ]; then
bin_file="Readest_${version}_x64-portable.exe"
elif [ "$arch" = "aarch64" ]; then
bin_file="Readest_${version}_arm64-portable.exe"
else
echo "Unknown architecture: $arch"
exit 1
fi
exe_file="apps/readest-app/src-tauri/target/${{ matrix.config.rust_target }}/release/readest.exe"
# Browsers on Windows won't download zip files that contain exe files
# so upload the exe files instead. This is totally stupid.
# powershell.exe -Command "Compress-Archive -Path $exe_file -DestinationPath $bin_file -Force"
cp $exe_file $bin_file
echo "Uploading $bin_file to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file --clobber
update-release:
permissions:
contents: write
@@ -153,11 +185,12 @@ jobs:
release_note: ${{ needs.get-release.outputs.release_note }}
with:
script: |
const body = `## Release Highlight\n${process.env.release_note}`;
github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.release_id,
body: process.env.release_note,
body: body,
draft: false,
prerelease: false
})
+1 -1
View File
@@ -33,7 +33,7 @@ To get started with Readest, follow these steps to clone and build the project.
#### 1. Clone the Repository
```bash
git clone https://github.com/chrox/readest.git
git clone https://github.com/readest/readest.git
cd readest
git submodule update --init --recursive
```
+16 -14
View File
@@ -52,25 +52,25 @@
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
| **Sync across Platforms** | Synchronize reading progress, notes, and bookmarks across all supported platforms. | ✅ |
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | ✅ |
## Planned Features
<div align="left">🛠 Building</div>
<div align="left">🔄 Planned</div>
| **Feature** | **Description** | **Priority** |
| -------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
| **Support iOS and Android** | Expand the APP to work on iOS and Android devices. | 🛠 |
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | 🛠 |
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🔄 |
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🔄 |
| **Feature** | **Description** | **Priority** |
| ------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
| **Support iOS and Android** | Expand the APP to work on iOS and Android devices. | 🛠 |
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🛠 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🔄 |
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
@@ -80,6 +80,8 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
![DeepL](./data/screenshots/deepl.png)
![TTS](./data/screenshots/tts_control.png)
![Footnote](./data/screenshots/footnote_popover.png)
![Wikipedia](./data/screenshots/wikipedia_vertical.png)
@@ -172,7 +174,7 @@ Readest is open-source, and contributions are welcome! Feel free to open issues,
<a href="https://github.com/readest/readest/graphs/contributors">
<p align="left">
<img width="200" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
<img width="300" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
</p>
</a>
+1 -1
View File
@@ -3,7 +3,7 @@ module.exports = {
output: '.',
options: {
debug: false,
sort: true,
sort: false,
func: {
list: ['_'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.8.9",
"version": "0.9.2",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -35,6 +35,7 @@
"@supabase/supabase-js": "^2.47.7",
"@tauri-apps/api": "2.1.1",
"@tauri-apps/plugin-cli": "^2.2.0",
"@tauri-apps/plugin-deep-link": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-http": "^2.2.0",
@@ -54,11 +55,11 @@
"i18next-http-backend": "^3.0.1",
"js-md5": "^0.8.3",
"next": "15.1.0",
"posthog-js": "^1.194.1",
"posthog-js": "^1.205.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "^15.2.0",
"react-icons": "^5.3.0",
"react-icons": "^5.4.0",
"tinycolor2": "^1.6.0",
"zustand": "5.0.1"
},
@@ -72,7 +73,7 @@
"@types/tinycolor2": "^1.4.6",
"autoprefixer": "^10.4.20",
"cpx2": "^8.0.0",
"daisyui": "^4.12.14",
"daisyui": "^4.12.23",
"dotenv-cli": "^7.4.4",
"eslint": "^9.16.0",
"eslint-config-next": "15.0.3",
@@ -83,7 +84,7 @@
"postcss-cli": "^11.0.0",
"postcss-nested": "^7.0.2",
"raw-loader": "^4.0.2",
"tailwindcss": "^3.4.15",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
}
}
@@ -1,7 +1,10 @@
{
"(detected)": "(erkannt)",
"About Readest": "Über Readest",
"Add your notes here...": "Fügen Sie hier Ihre Notizen hinzu...",
"Animation": "Animation",
"Annotate": "Annotieren",
"Apply": "Anwenden",
"Are you sure to delete the selected books?": "Möchten Sie die ausgewählten Bücher wirklich löschen?",
"Auto Mode": "Automatischer Modus",
"Behavior": "Verhalten",
@@ -14,18 +17,23 @@
"Color": "Farbe",
"Confirm": "Bestätigen",
"Confirm Deletion": "Löschen bestätigen",
"Copied to notebook": "In Notizbuch kopiert",
"Copy": "Kopieren",
"Custom CSS": "Benutzerdefiniertes CSS",
"Dark Mode": "Dunkelmodus",
"Default": "Standard",
"Default Font": "Standardschriftart",
"Default Font Size": "Standard-Schriftgröße",
"Delete": "Löschen",
"Delete Highlight": "Hervorhebung löschen",
"Dictionary": "Wörterbuch",
"Disable Click-to-Flip": "Klick zum Blättern deaktivieren",
"Download Readest": "Readest herunterladen",
"Edit": "Bearbeiten",
"Enter your custom CSS here...": "Geben Sie hier Ihr benutzerdefiniertes CSS ein...",
"Excerpts": "Auszüge",
"Failed to import book(s): {{filenames}}": "Fehler beim Importieren von Buch/Büchern: {{filenames}}",
"Fast": "Schnell",
"Font": "Schriftart",
"Font & Layout": "Schrift & Layout",
"Font Face": "Schriftschnitt",
@@ -42,6 +50,8 @@
"Grass": "Grasgrün",
"Gray": "Grau",
"Gruvbox": "Gruvbox",
"Highlight": "Hervorheben",
"Horizontal Direction": "Horizontale Richtung",
"Hyphenation": "Silbentrennung",
"Identifier:": "Kennung:",
"Import Books": "Bücher importieren",
@@ -70,6 +80,7 @@
"Notes": "Notizen",
"Open": "Öffnen",
"Open Book": "Buch öffnen",
"Original Text": "Originaltext",
"Override Publisher Font": "Verleger-Schriftart überschreiben",
"Page": "Seite",
"Paging Animation": "Blätter-Animation",
@@ -85,6 +96,7 @@
"Sans-Serif Font": "Serifenlose Schriftart",
"Save": "Speichern",
"Scrolled Mode": "Scroll-Modus",
"Search": "Suchen",
"Search books...": "Bücher suchen...",
"Search...": "Suchen...",
"Select Book": "Buch auswählen",
@@ -97,16 +109,25 @@
"Sign In": "Anmelden",
"Sign Out": "Abmelden",
"Sky": "Himmelblau",
"Slow": "Langsam",
"Solarized": "Solarisiert",
"Speak": "Sprechen",
"Subjects:": "Themen:",
"System Fonts": "System-Schriftarten",
"Table of Contents": "Inhaltsverzeichnis",
"TTS not supported in this device": "TTS wird auf diesem Gerät nicht unterstützt",
"Theme Color": "Themenfarbe",
"Theme Mode": "Themenmodus",
"Translate": "Übersetzen",
"Translated Text": "Übersetzter Text",
"Unknown": "Unbekannt",
"Untitled": "Ohne Titel",
"Updated:": "Aktualisiert:",
"User avatar": "Benutzer-Avatar",
"Version {{version}}": "Version {{version}}",
"Vertical Direction": "Vertikale Richtung",
"Welcome to your library. You can import your books here and read them anytime.": "Willkommen in Ihrer Bibliothek. Sie können hier Ihre Bücher importieren und jederzeit lesen.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Schreibmodus",
"Your Library": "Ihre Bibliothek"
}
@@ -1,7 +1,10 @@
{
"(detected)": "(detectado)",
"About Readest": "Acerca de Readest",
"Add your notes here...": "Añade tus notas aquí...",
"Animation": "Animación",
"Annotate": "Anotar",
"Apply": "Aplicar",
"Are you sure to delete the selected books?": "¿Estás seguro de eliminar los libros seleccionados?",
"Auto Mode": "Modo automático",
"Behavior": "Comportamiento",
@@ -14,18 +17,23 @@
"Color": "Color",
"Confirm": "Confirmar",
"Confirm Deletion": "Confirmar eliminación",
"Copied to notebook": "Copiado al cuaderno",
"Copy": "Copiar",
"Custom CSS": "CSS personalizado",
"Dark Mode": "Modo oscuro",
"Default": "Predeterminado",
"Default Font": "Fuente predeterminada",
"Default Font Size": "Tamaño de fuente predeterminado",
"Delete": "Eliminar",
"Delete Highlight": "Eliminar resaltado",
"Dictionary": "Diccionario",
"Disable Click-to-Flip": "Desactivar clic para voltear",
"Download Readest": "Descargar Readest",
"Edit": "Editar",
"Enter your custom CSS here...": "Introduce tu CSS personalizado aquí...",
"Excerpts": "Extractos",
"Failed to import book(s): {{filenames}}": "Error al importar libro(s): {{filenames}}",
"Fast": "Rápido",
"Font": "Fuente",
"Font & Layout": "Fuente y diseño",
"Font Face": "Tipo de fuente",
@@ -42,6 +50,8 @@
"Grass": "Hierba",
"Gray": "Gris",
"Gruvbox": "Gruvbox",
"Highlight": "Resaltar",
"Horizontal Direction": "Dirección horizontal",
"Hyphenation": "Guionización",
"Identifier:": "Identificador:",
"Import Books": "Importar libros",
@@ -70,6 +80,7 @@
"Notes": "Notas",
"Open": "Abrir",
"Open Book": "Abrir libro",
"Original Text": "Texto original",
"Override Publisher Font": "Sobrescribir fuente del editor",
"Page": "Página",
"Paging Animation": "Animación de paginación",
@@ -85,6 +96,7 @@
"Sans-Serif Font": "Fuente sans-serif",
"Save": "Guardar",
"Scrolled Mode": "Modo desplazamiento",
"Search": "Buscar",
"Search books...": "Buscar libros...",
"Search...": "Buscar...",
"Select Book": "Seleccionar libro",
@@ -97,16 +109,25 @@
"Sign In": "Iniciar sesión",
"Sign Out": "Cerrar sesión",
"Sky": "Cielo",
"Slow": "Lento",
"Solarized": "Solarizado",
"Speak": "Leer",
"Subjects:": "Temas:",
"System Fonts": "Fuentes del sistema",
"Table of Contents": "Tabla de contenidos",
"TTS not supported in this device": "TTS no es compatible con este dispositivo",
"Theme Color": "Color del tema",
"Theme Mode": "Modo del tema",
"Translate": "Traducir",
"Translated Text": "Texto traducido",
"Unknown": "Desconocido",
"Untitled": "Sin título",
"Updated:": "Actualizado:",
"User avatar": "Avatar del usuario",
"Version {{version}}": "Versión {{version}}",
"Vertical Direction": "Dirección vertical",
"Welcome to your library. You can import your books here and read them anytime.": "Bienvenido a tu biblioteca. Puedes importar tus libros aquí y leerlos en cualquier momento.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Modo de escritura",
"Your Library": "Tu biblioteca"
}
}
@@ -1,7 +1,10 @@
{
"(detected)": "(détecté)",
"About Readest": "À propos de Readest",
"Add your notes here...": "Ajoutez vos notes ici...",
"Animation": "Animation",
"Annotate": "Annoter",
"Apply": "Appliquer",
"Are you sure to delete the selected books?": "Êtes-vous sûr de vouloir supprimer les livres sélectionnés ?",
"Auto Mode": "Mode automatique",
"Behavior": "Comportement",
@@ -14,18 +17,23 @@
"Color": "Couleur",
"Confirm": "Confirmer",
"Confirm Deletion": "Confirmer la suppression",
"Copied to notebook": "Copié dans le carnet de notes",
"Copy": "Copier",
"Custom CSS": "CSS personnalisé",
"Dark Mode": "Mode sombre",
"Default": "Par défaut",
"Default Font": "Police par défaut",
"Default Font Size": "Taille de police par défaut",
"Delete": "Supprimer",
"Delete Highlight": "Supprimer le surlignage",
"Dictionary": "Dictionnaire",
"Disable Click-to-Flip": "Désactiver le clic pour tourner",
"Download Readest": "Télécharger Readest",
"Edit": "Modifier",
"Enter your custom CSS here...": "Saisissez votre CSS personnalisé ici...",
"Excerpts": "Extraits",
"Failed to import book(s): {{filenames}}": "Échec de l'importation du/des livre(s) : {{filenames}}",
"Fast": "Rapide",
"Font": "Police",
"Font & Layout": "Police et mise en page",
"Font Face": "Style de police",
@@ -42,6 +50,8 @@
"Grass": "Herbe",
"Gray": "Gris",
"Gruvbox": "Gruvbox",
"Highlight": "Surligner",
"Horizontal Direction": "Direction horizontale",
"Hyphenation": "Césure",
"Identifier:": "Identifiant :",
"Import Books": "Importer des livres",
@@ -70,6 +80,7 @@
"Notes": "Notes",
"Open": "Ouvrir",
"Open Book": "Ouvrir le livre",
"Original Text": "Texte original",
"Override Publisher Font": "Remplacer la police de l'éditeur",
"Page": "Page",
"Paging Animation": "Animation de page",
@@ -85,6 +96,7 @@
"Sans-Serif Font": "Police sans empattement",
"Save": "Enregistrer",
"Scrolled Mode": "Mode défilement",
"Search": "Rechercher",
"Search books...": "Rechercher des livres...",
"Search...": "Rechercher...",
"Select Book": "Sélectionner un livre",
@@ -97,16 +109,25 @@
"Sign In": "Se connecter",
"Sign Out": "Se déconnecter",
"Sky": "Ciel",
"Slow": "Lent",
"Solarized": "Solarisé",
"Speak": "Lire",
"Subjects:": "Sujets :",
"System Fonts": "Polices système",
"Table of Contents": "Table des matières",
"TTS not supported in this device": "La synthèse vocale n'est pas prise en charge sur cet appareil",
"Theme Color": "Couleur du thème",
"Theme Mode": "Mode du thème",
"Translate": "Traduire",
"Translated Text": "Texte traduit",
"Unknown": "Inconnu",
"Untitled": "Sans titre",
"Updated:": "Mis à jour :",
"User avatar": "Avatar de l'utilisateur",
"Version {{version}}": "Version {{version}}",
"Vertical Direction": "Direction verticale",
"Welcome to your library. You can import your books here and read them anytime.": "Bienvenue dans votre bibliothèque. Vous pouvez importer vos livres ici et les lire à tout moment.",
"Wikipedia": "Wikipédia",
"Writing Mode": "Mode écriture",
"Your Library": "Votre bibliothèque"
}
@@ -1,7 +1,10 @@
{
"(detected)": "(terdeteksi)",
"About Readest": "Tentang Readest",
"Add your notes here...": "Tambahkan catatan Anda di sini...",
"Animation": "Animasi",
"Annotate": "Anotasi",
"Apply": "Terapkan",
"Are you sure to delete the selected books?": "Apakah Anda yakin ingin menghapus buku yang dipilih?",
"Auto Mode": "Mode Otomatis",
"Behavior": "Perilaku",
@@ -14,18 +17,23 @@
"Color": "Warna",
"Confirm": "Konfirmasi",
"Confirm Deletion": "Konfirmasi Penghapusan",
"Copied to notebook": "Disalin ke buku catatan",
"Copy": "Salin",
"Custom CSS": "CSS Kustom",
"Dark Mode": "Mode Gelap",
"Default": "Default",
"Default Font": "Font Default",
"Default Font Size": "Ukuran Font Default",
"Delete": "Hapus",
"Delete Highlight": "Hapus Sorotan",
"Dictionary": "Kamus",
"Disable Click-to-Flip": "Nonaktifkan Klik untuk Membalik",
"Download Readest": "Unduh Readest",
"Edit": "Edit",
"Enter your custom CSS here...": "Masukkan CSS kustom Anda di sini...",
"Excerpts": "Kutipan",
"Failed to import book(s): {{filenames}}": "Gagal mengimpor buku: {{filenames}}",
"Fast": "Cepat",
"Font": "Font",
"Font & Layout": "Font & Tata Letak",
"Font Face": "Jenis Font",
@@ -42,6 +50,8 @@
"Grass": "Rumput",
"Gray": "Abu-abu",
"Gruvbox": "Gruvbox",
"Highlight": "Sorot",
"Horizontal Direction": "Arah Horizontal",
"Hyphenation": "Pemenggalan Kata",
"Identifier:": "Pengenal:",
"Import Books": "Impor Buku",
@@ -70,6 +80,7 @@
"Notes": "Catatan",
"Open": "Buka",
"Open Book": "Buka Buku",
"Original Text": "Teks Asli",
"Override Publisher Font": "Ganti Font Penerbit",
"Page": "Halaman",
"Paging Animation": "Animasi Halaman",
@@ -85,6 +96,7 @@
"Sans-Serif Font": "Font Sans-Serif",
"Save": "Simpan",
"Scrolled Mode": "Mode Gulir",
"Search": "Cari",
"Search books...": "Cari buku...",
"Search...": "Cari...",
"Select Book": "Pilih Buku",
@@ -97,16 +109,25 @@
"Sign In": "Masuk",
"Sign Out": "Keluar",
"Sky": "Langit",
"Slow": "Lambat",
"Solarized": "Solarized",
"Speak": "Bicara",
"Subjects:": "Subjek:",
"System Fonts": "Font Sistem",
"Table of Contents": "Daftar Isi",
"TTS not supported in this device": "TTS tidak didukung di perangkat ini",
"Theme Color": "Warna Tema",
"Theme Mode": "Mode Tema",
"Translate": "Terjemahkan",
"Translated Text": "Teks Terjemahan",
"Unknown": "Tidak Diketahui",
"Untitled": "Tanpa Judul",
"Updated:": "Diperbarui:",
"User avatar": "Avatar Pengguna",
"Version {{version}}": "Versi {{version}}",
"Vertical Direction": "Arah Vertikal",
"Welcome to your library. You can import your books here and read them anytime.": "Selamat datang di perpustakaan Anda. Anda dapat mengimpor buku-buku Anda di sini dan membacanya kapan saja.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Mode Menulis",
"Your Library": "Perpustakaan Anda"
}
@@ -1,7 +1,10 @@
{
"(detected)": "(rilevato)",
"About Readest": "Informazioni su Readest",
"Add your notes here...": "Aggiungi qui le tue note...",
"Animation": "Animazione",
"Annotate": "Annota",
"Apply": "Applica",
"Are you sure to delete the selected books?": "Sei sicuro di voler eliminare i libri selezionati?",
"Auto Mode": "Modalità automatica",
"Behavior": "Comportamento",
@@ -14,18 +17,23 @@
"Color": "Colore",
"Confirm": "Conferma",
"Confirm Deletion": "Conferma eliminazione",
"Copied to notebook": "Copiato nel quaderno",
"Copy": "Copia",
"Custom CSS": "CSS personalizzato",
"Dark Mode": "Modalità scura",
"Default": "Predefinito",
"Default Font": "Font predefinito",
"Default Font Size": "Dimensione font predefinita",
"Delete": "Elimina",
"Delete Highlight": "Elimina evidenziazione",
"Dictionary": "Dizionario",
"Disable Click-to-Flip": "Disattiva click per voltare pagina",
"Download Readest": "Scarica Readest",
"Edit": "Modifica",
"Enter your custom CSS here...": "Inserisci qui il tuo CSS personalizzato...",
"Excerpts": "Estratti",
"Failed to import book(s): {{filenames}}": "Impossibile importare il/i libro/i: {{filenames}}",
"Fast": "Veloce",
"Font": "Font",
"Font & Layout": "Font e Layout",
"Font Face": "Tipo di carattere",
@@ -42,6 +50,8 @@
"Grass": "Erba",
"Gray": "Grigio",
"Gruvbox": "Gruvbox",
"Highlight": "Evidenzia",
"Horizontal Direction": "Direzione orizzontale",
"Hyphenation": "Sillabazione",
"Identifier:": "Identificatore:",
"Import Books": "Importa libri",
@@ -70,6 +80,7 @@
"Notes": "Note",
"Open": "Apri",
"Open Book": "Apri libro",
"Original Text": "Testo originale",
"Override Publisher Font": "Sostituisci font editore",
"Page": "Pagina",
"Paging Animation": "Animazione cambio pagina",
@@ -85,6 +96,7 @@
"Sans-Serif Font": "Font sans-serif",
"Save": "Salva",
"Scrolled Mode": "Modalità scorrimento",
"Search": "Cerca",
"Search books...": "Cerca libri...",
"Search...": "Cerca...",
"Select Book": "Seleziona libro",
@@ -97,16 +109,25 @@
"Sign In": "Accedi",
"Sign Out": "Esci",
"Sky": "Cielo",
"Slow": "Lento",
"Solarized": "Solarizzato",
"Speak": "Leggi",
"Subjects:": "Argomenti:",
"System Fonts": "Font di sistema",
"Table of Contents": "Indice",
"TTS not supported in this device": "TTS non supportato su questo dispositivo",
"Theme Color": "Colore tema",
"Theme Mode": "Modalità tema",
"Translate": "Traduci",
"Translated Text": "Testo tradotto",
"Unknown": "Sconosciuto",
"Untitled": "Senza titolo",
"Updated:": "Aggiornato:",
"User avatar": "Avatar utente",
"Version {{version}}": "Versione {{version}}",
"Vertical Direction": "Direzione verticale",
"Welcome to your library. You can import your books here and read them anytime.": "Benvenuto nella tua biblioteca. Puoi importare i tuoi libri qui e leggerli in qualsiasi momento.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Modalità scrittura",
"Your Library": "La tua biblioteca"
}
@@ -1,7 +1,10 @@
{
"(detected)": "(検出)",
"About Readest": "Readestについて",
"Add your notes here...": "ここにメモを追加...",
"Animation": "アニメーション",
"Annotate": "注釈",
"Apply": "適用",
"Are you sure to delete the selected books?": "選択した書籍を削除してもよろしいですか?",
"Auto Mode": "自動モード",
"Behavior": "動作",
@@ -14,18 +17,23 @@
"Color": "色",
"Confirm": "確認",
"Confirm Deletion": "削除の確認",
"Copied to notebook": "ノートブックにコピー",
"Copy": "コピー",
"Custom CSS": "カスタムCSS",
"Dark Mode": "ダークモード",
"Default": "デフォルト",
"Default Font": "デフォルトフォント",
"Default Font Size": "デフォルトフォントサイズ",
"Delete": "削除",
"Delete Highlight": "ハイライトを削除",
"Dictionary": "辞書",
"Disable Click-to-Flip": "クリックめくりを無効化",
"Download Readest": "Readestをダウンロード",
"Edit": "編集",
"Enter your custom CSS here...": "ここにカスタムCSSを入力...",
"Excerpts": "抜粋",
"Failed to import book(s): {{filenames}}": "書籍のインポートに失敗しました:{{filenames}}",
"Fast": "高速",
"Font": "フォント",
"Font & Layout": "フォントとレイアウト",
"Font Face": "書体",
@@ -42,6 +50,8 @@
"Grass": "グリーン",
"Gray": "グレー",
"Gruvbox": "グルーブボックス",
"Highlight": "ハイライト",
"Horizontal Direction": "横書",
"Hyphenation": "ハイフネーション",
"Identifier:": "識別子:",
"Import Books": "書籍をインポート",
@@ -70,6 +80,7 @@
"Notes": "メモ",
"Open": "開く",
"Open Book": "書籍を開く",
"Original Text": "原文",
"Override Publisher Font": "出版社フォントを上書き",
"Page": "ページ",
"Paging Animation": "ページめくりアニメーション",
@@ -85,6 +96,7 @@
"Sans-Serif Font": "ゴシック体",
"Save": "保存",
"Scrolled Mode": "スクロールモード",
"Search": "検索",
"Search books...": "書籍を検索...",
"Search...": "検索...",
"Select Book": "書籍を選択",
@@ -97,16 +109,25 @@
"Sign In": "サインイン",
"Sign Out": "サインアウト",
"Sky": "スカイ",
"Slow": "低速",
"Solarized": "ソーラライズド",
"Speak": "読み上げ",
"Subjects:": "主題:",
"System Fonts": "システムフォント",
"Table of Contents": "目次",
"TTS not supported in this device": "このデバイスではTTSがサポートされていません",
"Theme Color": "テーマカラー",
"Theme Mode": "テーマモード",
"Translate": "翻訳",
"Translated Text": "翻訳されたテキスト",
"Unknown": "不明",
"Untitled": "無題",
"Updated:": "更新日:",
"User avatar": "ユーザーアバター",
"Version {{version}}": "バージョン {{version}}",
"Vertical Direction": "縦書",
"Welcome to your library. You can import your books here and read them anytime.": "ライブラリーへようこそ。ここに書籍をインポートして、いつでも読むことができます。",
"Wikipedia": "ウィキペディア",
"Writing Mode": "書き込みモード",
"Your Library": "ライブラリー"
}
@@ -1,7 +1,10 @@
{
"(detected)": "(감지됨)",
"About Readest": "Readest 정보",
"Add your notes here...": "여기에 메모를 추가하세요...",
"Animation": "애니메이션",
"Annotate": "주석",
"Apply": "적용",
"Are you sure to delete the selected books?": "선택한 책을 삭제하시겠습니까?",
"Auto Mode": "자동 모드",
"Behavior": "동작",
@@ -14,18 +17,23 @@
"Color": "색상",
"Confirm": "확인",
"Confirm Deletion": "삭제 확인",
"Copied to notebook": "노트북에 복사됨",
"Copy": "복사",
"Custom CSS": "사용자 정의 CSS",
"Dark Mode": "다크 모드",
"Default": "기본값",
"Default Font": "기본 글꼴",
"Default Font Size": "기본 글꼴 크기",
"Delete": "삭제",
"Delete Highlight": "하이라이트 삭제",
"Dictionary": "사전",
"Disable Click-to-Flip": "클릭 넘기기 비활성화",
"Download Readest": "Readest 다운로드",
"Edit": "편집",
"Enter your custom CSS here...": "여기에 사용자 정의 CSS를 입력하세요...",
"Excerpts": "발췌",
"Failed to import book(s): {{filenames}}": "책 가져오기 실패: {{filenames}}",
"Fast": "빠름",
"Font": "글꼴",
"Font & Layout": "글꼴 및 레이아웃",
"Font Face": "글꼴 스타일",
@@ -42,6 +50,8 @@
"Grass": "잔디색",
"Gray": "회색",
"Gruvbox": "그러브박스",
"Highlight": "하이라이트",
"Horizontal Direction": "수평 방향",
"Hyphenation": "하이픈 넣기",
"Identifier:": "식별자:",
"Import Books": "책 가져오기",
@@ -70,6 +80,7 @@
"Notes": "메모",
"Open": "열기",
"Open Book": "책 열기",
"Original Text": "원본 텍스트",
"Override Publisher Font": "출판사 글꼴 재정의",
"Page": "페이지",
"Paging Animation": "페이지 넘김 애니메이션",
@@ -85,6 +96,7 @@
"Sans-Serif Font": "산세리프체",
"Save": "저장",
"Scrolled Mode": "스크롤 모드",
"Search": "검색",
"Search books...": "책 검색...",
"Search...": "검색...",
"Select Book": "책 선택",
@@ -97,16 +109,25 @@
"Sign In": "로그인",
"Sign Out": "로그아웃",
"Sky": "하늘색",
"Slow": "느림",
"Solarized": "솔라라이즈드",
"Speak": "말하기",
"Subjects:": "주제:",
"System Fonts": "시스템 글꼴",
"Table of Contents": "목차",
"TTS not supported in this device": "이 장치에서 TTS가 지원되지 않음",
"Theme Color": "테마 색상",
"Theme Mode": "테마 모드",
"Translate": "번역",
"Translated Text": "번역된 텍스트",
"Unknown": "알 수 없음",
"Untitled": "제목 없음",
"Updated:": "업데이트일:",
"User avatar": "사용자 아바타",
"Version {{version}}": "버전 {{version}}",
"Vertical Direction": "수직 방향",
"Welcome to your library. You can import your books here and read them anytime.": "내 서재에 오신 것을 환영합니다. 여기에서 책을 가져와 언제든지 읽을 수 있습니다.",
"Wikipedia": "위키피디아",
"Writing Mode": "글쓰기 모드",
"Your Library": "내 서재"
}
@@ -1,7 +1,10 @@
{
"(detected)": "(detectado)",
"About Readest": "Sobre o Readest",
"Add your notes here...": "Adicione suas notas aqui...",
"Animation": "Animação",
"Annotate": "Anotar",
"Apply": "Aplicar",
"Are you sure to delete the selected books?": "Tem certeza de que deseja excluir os livros selecionados?",
"Auto Mode": "Modo Automático",
"Behavior": "Comportamento",
@@ -14,18 +17,23 @@
"Color": "Cor",
"Confirm": "Confirmar",
"Confirm Deletion": "Confirmar Exclusão",
"Copied to notebook": "Copiado para o caderno",
"Copy": "Copiar",
"Custom CSS": "CSS Personalizado",
"Dark Mode": "Modo Escuro",
"Default": "Padrão",
"Default Font": "Fonte Padrão",
"Default Font Size": "Tamanho da Fonte Padrão",
"Delete": "Excluir",
"Delete Highlight": "Excluir Destaque",
"Dictionary": "Dicionário",
"Disable Click-to-Flip": "Desativar Clique para Virar",
"Download Readest": "Baixar Readest",
"Edit": "Editar",
"Enter your custom CSS here...": "Insira seu CSS personalizado aqui...",
"Excerpts": "Trechos",
"Failed to import book(s): {{filenames}}": "Falha ao importar livro(s): {{filenames}}",
"Fast": "Rápido",
"Font": "Fonte",
"Font & Layout": "Fonte e Layout",
"Font Face": "Estilo da Fonte",
@@ -42,6 +50,8 @@
"Grass": "Grama",
"Gray": "Cinza",
"Gruvbox": "Gruvbox",
"Highlight": "Destaque",
"Horizontal Direction": "Direção Horizontal",
"Hyphenation": "Hifenização",
"Identifier:": "Identificador:",
"Import Books": "Importar Livros",
@@ -70,6 +80,7 @@
"Notes": "Notas",
"Open": "Abrir",
"Open Book": "Abrir Livro",
"Original Text": "Texto Original",
"Override Publisher Font": "Substituir Fonte do Editor",
"Page": "Página",
"Paging Animation": "Animação de Página",
@@ -85,6 +96,7 @@
"Sans-Serif Font": "Fonte Sans-Serif",
"Save": "Salvar",
"Scrolled Mode": "Modo de Rolagem",
"Search": "Buscar",
"Search books...": "Procurar livros...",
"Search...": "Pesquisar...",
"Select Book": "Selecionar Livro",
@@ -97,16 +109,25 @@
"Sign In": "Entrar",
"Sign Out": "Sair",
"Sky": "Céu",
"Slow": "Lento",
"Solarized": "Solarizado",
"Speak": "Falar",
"Subjects:": "Assuntos:",
"System Fonts": "Fontes do Sistema",
"Table of Contents": "Índice",
"TTS not supported in this device": "TTS não suportado neste dispositivo",
"Theme Color": "Cor do Tema",
"Theme Mode": "Modo do Tema",
"Translate": "Traduzir",
"Translated Text": "Texto Traduzido",
"Unknown": "Desconhecido",
"Untitled": "Sem Título",
"Updated:": "Atualizado:",
"User avatar": "Avatar do usuário",
"Version {{version}}": "Versão {{version}}",
"Vertical Direction": "Direção Vertical",
"Welcome to your library. You can import your books here and read them anytime.": "Bem-vindo à sua biblioteca. Você pode importar seus livros aqui e lê-los a qualquer momento.",
"Wikipedia": "Wikipédia",
"Writing Mode": "Modo de Escrita",
"Your Library": "Sua Biblioteca"
}
@@ -1,7 +1,10 @@
{
"(detected)": "(обнаружено)",
"About Readest": "О Readest",
"Add your notes here...": "Добавьте свои заметки здесь...",
"Animation": "Анимация",
"Annotate": "Аннотация",
"Apply": "Применить",
"Are you sure to delete the selected books?": "Вы уверены, что хотите удалить выбранные книги?",
"Auto Mode": "Автоматический режим",
"Behavior": "Поведение",
@@ -14,18 +17,23 @@
"Color": "Цвет",
"Confirm": "Подтвердить",
"Confirm Deletion": "Подтвердить удаление",
"Copied to notebook": "Скопировано в блокнот",
"Copy": "Копировать",
"Custom CSS": "Пользовательский CSS",
"Dark Mode": "Тёмная тема",
"Default": "По умолчанию",
"Default Font": "Шрифт по умолчанию",
"Default Font Size": "Размер шрифта по умолчанию",
"Delete": "Удалить",
"Delete Highlight": "Удалить выделение",
"Dictionary": "Словарь",
"Disable Click-to-Flip": "Отключить переворот по клику",
"Download Readest": "Скачать Readest",
"Edit": "Редактировать",
"Enter your custom CSS here...": "Введите ваш пользовательский CSS здесь...",
"Excerpts": "Отрывки",
"Failed to import book(s): {{filenames}}": "Не удалось импортировать книгу(и): {{filenames}}",
"Fast": "Быстро",
"Font": "Шрифт",
"Font & Layout": "Шрифт и макет",
"Font Face": "Начертание шрифта",
@@ -42,6 +50,8 @@
"Grass": "Трава",
"Gray": "Серый",
"Gruvbox": "Gruvbox",
"Highlight": "Выделить",
"Horizontal Direction": "Горизонтальное направление",
"Hyphenation": "Перенос слов",
"Identifier:": "Идентификатор:",
"Import Books": "Импорт книг",
@@ -70,6 +80,7 @@
"Notes": "Заметки",
"Open": "Открыть",
"Open Book": "Открыть книгу",
"Original Text": "Оригинальный текст",
"Override Publisher Font": "Переопределить шрифт издателя",
"Page": "Страница",
"Paging Animation": "Анимация перелистывания",
@@ -85,6 +96,7 @@
"Sans-Serif Font": "Шрифт без засечек",
"Save": "Сохранить",
"Scrolled Mode": "Режим прокрутки",
"Search": "Поиск",
"Search books...": "Поиск книг...",
"Search...": "Поиск...",
"Select Book": "Выбрать книгу",
@@ -97,16 +109,25 @@
"Sign In": "Войти",
"Sign Out": "Выйти",
"Sky": "Небесный",
"Slow": "Медленно",
"Solarized": "Солнечный",
"Speak": "Произнести",
"Subjects:": "Темы:",
"System Fonts": "Системные шрифты",
"Table of Contents": "Содержание",
"TTS not supported in this device": "TTS не поддерживается на этом устройстве",
"Theme Color": "Цвет темы",
"Theme Mode": "Режим темы",
"Translate": "Перевести",
"Translated Text": "Переведённый текст",
"Unknown": "Неизвестно",
"Untitled": "Без названия",
"Updated:": "Обновлено:",
"User avatar": "Аватар пользователя",
"Version {{version}}": "Версия {{version}}",
"Vertical Direction": "Вертикальное направление",
"Welcome to your library. You can import your books here and read them anytime.": "Добро пожаловать в вашу библиотеку. Вы можете импортировать сюда книги и читать их в любое время.",
"Wikipedia": "Википедия",
"Writing Mode": "Режим записи",
"Your Library": "Ваша библиотека"
}
@@ -1,7 +1,10 @@
{
"(detected)": "(algılandı)",
"About Readest": "Readest Hakkında",
"Add your notes here...": "Notlarınızı buraya ekleyin...",
"Animation": "Animasyon",
"Annotate": "Not Ekle",
"Apply": "Uygula",
"Are you sure to delete the selected books?": "Seçili kitapları silmek istediğinizden emin misiniz?",
"Auto Mode": "Otomatik Mod",
"Behavior": "Davranış",
@@ -14,18 +17,23 @@
"Color": "Renk",
"Confirm": "Onayla",
"Confirm Deletion": "Silmeyi Onayla",
"Copied to notebook": "Not defterine kopyalandı",
"Copy": "Kopyala",
"Custom CSS": "Özel CSS",
"Dark Mode": "Karanlık Mod",
"Default": "Varsayılan",
"Default Font": "Varsayılan Yazı Tipi",
"Default Font Size": "Varsayılan Yazı Boyutu",
"Delete": "Sil",
"Delete Highlight": "Vurguyu Sil",
"Dictionary": "Sözlük",
"Disable Click-to-Flip": "Tıkla-Çevir'i Devre Dışı Bırak",
"Download Readest": "Readest'i İndir",
"Edit": "Düzenle",
"Enter your custom CSS here...": "Özel CSS'nizi buraya girin...",
"Excerpts": "Alıntılar",
"Failed to import book(s): {{filenames}}": "Kitap(lar) içe aktarılamadı: {{filenames}}",
"Fast": "Hızlı",
"Font": "Yazı Tipi",
"Font & Layout": "Yazı Tipi ve Düzen",
"Font Face": "Yazı Tipi Yüzü",
@@ -42,6 +50,8 @@
"Grass": "Çimen",
"Gray": "Gri",
"Gruvbox": "Gruvbox",
"Highlight": "Vurgula",
"Horizontal Direction": "Yatay Yön",
"Hyphenation": "Heceleme",
"Identifier:": "Tanımlayıcı:",
"Import Books": "Kitapları İçe Aktar",
@@ -70,6 +80,7 @@
"Notes": "Notlar",
"Open": "Aç",
"Open Book": "Kitabı Aç",
"Original Text": "Orijinal Metin",
"Override Publisher Font": "Yayıncı Yazı Tipini Geçersiz Kıl",
"Page": "Sayfa",
"Paging Animation": "Sayfa Çevirme Animasyonu",
@@ -85,6 +96,7 @@
"Sans-Serif Font": "Sans-Serif Yazı Tipi",
"Save": "Kaydet",
"Scrolled Mode": "Kaydırma Modu",
"Search": "Ara",
"Search books...": "Kitap ara...",
"Search...": "Ara...",
"Select Book": "Kitap Seç",
@@ -97,16 +109,25 @@
"Sign In": "Giriş Yap",
"Sign Out": "Çıkış Yap",
"Sky": "Gökyüzü",
"Slow": "Yavaş",
"Solarized": "Solarized",
"Speak": "Konuş",
"Subjects:": "Konular:",
"System Fonts": "Sistem Yazı Tipleri",
"Table of Contents": "İçindekiler",
"TTS not supported in this device": "Bu cihazda TTS desteklenmiyor",
"Theme Color": "Tema Rengi",
"Theme Mode": "Tema Modu",
"Translate": "Çevir",
"Translated Text": "Çevrilen Metin",
"Unknown": "Bilinmiyor",
"Untitled": "Başlıksız",
"Updated:": "Güncellendi:",
"User avatar": "Kullanıcı avatarı",
"Version {{version}}": "Sürüm {{version}}",
"Vertical Direction": "Dikey Yön",
"Welcome to your library. You can import your books here and read them anytime.": "Kütüphanenize hoş geldiniz. Buradan kitaplarınızı içe aktarabilir ve istediğiniz zaman okuyabilirsiniz.",
"Wikipedia": "Vikipedi",
"Writing Mode": "Yazma Modu",
"Your Library": "Kütüphaneniz"
}
@@ -1,7 +1,10 @@
{
"(detected)": "(đã phát hiện)",
"About Readest": "Về Readest",
"Add your notes here...": "Thêm ghi chú của bạn vào đây...",
"Animation": "Hiệu ứng động",
"Annotate": "Chú thích",
"Apply": "Áp dụng",
"Are you sure to delete the selected books?": "Bạn có chắc chắn muốn xóa các sách đã chọn?",
"Auto Mode": "Chế độ tự động",
"Behavior": "Hành vi",
@@ -14,18 +17,23 @@
"Color": "Màu sắc",
"Confirm": "Xác nhận",
"Confirm Deletion": "Xác nhận xóa",
"Copied to notebook": "Đã sao chép vào sổ ghi chú",
"Copy": "Sao chép",
"Custom CSS": "CSS tùy chỉnh",
"Dark Mode": "Chế độ tối",
"Default": "Mặc định",
"Default Font": "Phông chữ mặc định",
"Default Font Size": "Cỡ chữ mặc định",
"Delete": "Xóa",
"Delete Highlight": "Xóa văn bản được đánh dấu",
"Dictionary": "Từ điển",
"Disable Click-to-Flip": "Tắt tính năng nhấp để lật trang",
"Download Readest": "Tải Readest",
"Edit": "Chỉnh sửa",
"Enter your custom CSS here...": "Nhập CSS tùy chỉnh của bạn vào đây...",
"Excerpts": "Trích dẫn",
"Failed to import book(s): {{filenames}}": "Không thể nhập sách: {{filenames}}",
"Fast": "Nhanh",
"Font": "Phông chữ",
"Font & Layout": "Phông chữ & Bố cục",
"Font Face": "Kiểu chữ",
@@ -42,6 +50,8 @@
"Grass": "Xanh cỏ",
"Gray": "Xám",
"Gruvbox": "Gruvbox",
"Highlight": "Đánh dấu",
"Horizontal Direction": "Hướng ngang",
"Hyphenation": "Gạch nối từ",
"Identifier:": "Định danh:",
"Import Books": "Nhập sách",
@@ -70,6 +80,7 @@
"Notes": "Ghi chú",
"Open": "Mở",
"Open Book": "Mở sách",
"Original Text": "Văn bản gốc",
"Override Publisher Font": "Ghi đè phông chữ của nhà xuất bản",
"Page": "Trang",
"Paging Animation": "Hiệu ứng lật trang",
@@ -85,6 +96,7 @@
"Sans-Serif Font": "Phông chữ không chân",
"Save": "Lưu",
"Scrolled Mode": "Chế độ cuộn",
"Search": "Tìm kiếm",
"Search books...": "Tìm kiếm sách...",
"Search...": "Tìm kiếm...",
"Select Book": "Chọn sách",
@@ -97,16 +109,25 @@
"Sign In": "Đăng nhập",
"Sign Out": "Đăng xuất",
"Sky": "Xanh trời",
"Slow": "Chậm",
"Solarized": "Solarized",
"Speak": "Đọc",
"Subjects:": "Chủ đề:",
"System Fonts": "Phông chữ hệ thống",
"Table of Contents": "Mục lục",
"TTS not supported in this device": "TTS không được hỗ trợ trên thiết bị này",
"Theme Color": "Màu chủ đề",
"Theme Mode": "Chế độ chủ đề",
"Translate": "Dịch",
"Translated Text": "Văn bản dịch",
"Unknown": "Không xác định",
"Untitled": "Không có tiêu đề",
"Updated:": "Cập nhật:",
"User avatar": "Ảnh đại diện",
"Version {{version}}": "Phiên bản {{version}}",
"Vertical Direction": "Hướng dọc",
"Welcome to your library. You can import your books here and read them anytime.": "Chào mừng đến với thư viện của bạn. Bạn có thể nhập sách vào đây và đọc bất cứ lúc nào.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Chế độ viết",
"Your Library": "Thư viện của bạn"
}
@@ -1,7 +1,10 @@
{
"(detected)": "(检测到)",
"About Readest": "关于 Readest",
"Add your notes here...": "在这里添加您的笔记...",
"Animation": "动画",
"Annotate": "笔记",
"Apply": "应用",
"Are you sure to delete the selected books?": "您确定要删除所选书籍吗?",
"Auto Mode": "自动主题",
"Behavior": "行为",
@@ -14,18 +17,23 @@
"Color": "颜色",
"Confirm": "确认",
"Confirm Deletion": "确认删除",
"Copied to notebook": "已复制到笔记本",
"Copy": "复制",
"Custom CSS": "自定义 CSS",
"Dark Mode": "深色主题",
"Default": "默认",
"Default Font": "默认字体",
"Default Font Size": "默认字号",
"Delete": "删除",
"Delete Highlight": "删除划线",
"Dictionary": "词典",
"Disable Click-to-Flip": "禁用点击翻页",
"Download Readest": "下载 Readest",
"Edit": "编辑",
"Enter your custom CSS here...": "在此处输入您的自定义 CSS...",
"Excerpts": "摘录",
"Failed to import book(s): {{filenames}}": "导入书籍失败:{{filenames}}",
"Fast": "快",
"Font": "字体",
"Font & Layout": "字体和布局",
"Font Face": "字型",
@@ -42,6 +50,8 @@
"Grass": "青草",
"Gray": "素雅",
"Gruvbox": "暖橘",
"Highlight": "划线",
"Horizontal Direction": "横排",
"Hyphenation": "断字",
"Identifier:": "识别码",
"Import Books": "导入书籍",
@@ -70,6 +80,7 @@
"Notes": "笔记",
"Open": "打开",
"Open Book": "打开书籍",
"Original Text": "原文",
"Override Publisher Font": "覆盖内置字体",
"Page": "页面",
"Paging Animation": "翻页动画",
@@ -85,6 +96,7 @@
"Sans-Serif Font": "无衬线字体",
"Save": "保存",
"Scrolled Mode": "滚动模式",
"Search": "搜索",
"Search books...": "搜索书籍...",
"Search...": "搜索...",
"Select Book": "选择书籍",
@@ -97,16 +109,25 @@
"Sign In": "登录",
"Sign Out": "登出",
"Sky": "天青",
"Slow": "慢",
"Solarized": "日晖",
"Speak": "朗读",
"Subjects:": "主题",
"System Fonts": "系统字体",
"Table of Contents": "目录",
"TTS not supported in this device": "此设备不支持 TTS",
"Theme Color": "主题颜色",
"Theme Mode": "主题模式",
"Translate": "翻译",
"Translated Text": "译文",
"Unknown": "未知",
"Untitled": "无标题",
"Updated:": "更新日期",
"User avatar": "用户头像",
"Version {{version}}": "版本 {{version}}",
"Vertical Direction": "竖排",
"Welcome to your library. You can import your books here and read them anytime.": "书库空空如也,您可以导入您的书籍并随时阅读。",
"Wikipedia": "维基百科",
"Writing Mode": "排版模式",
"Your Library": "书库"
}
@@ -1,7 +1,10 @@
{
"(detected)": "(檢測到)",
"About Readest": "關於 Readest",
"Add your notes here...": "在這裡添加您的筆記...",
"Animation": "動畫",
"Annotate": "筆記",
"Apply": "應用",
"Are you sure to delete the selected books?": "您確定要刪除所選書籍嗎?",
"Auto Mode": "自動主題",
"Behavior": "行為",
@@ -14,18 +17,23 @@
"Color": "顏色",
"Confirm": "確認",
"Confirm Deletion": "確認刪除",
"Copied to notebook": "已複製到筆記本",
"Copy": "複製",
"Custom CSS": "自定義 CSS",
"Dark Mode": "深色主題",
"Default": "預設",
"Default Font": "預設字體",
"Default Font Size": "預設字號",
"Delete": "刪除",
"Delete Highlight": "刪除劃線",
"Dictionary": "詞典",
"Disable Click-to-Flip": "禁用點擊翻頁",
"Download Readest": "下載 Readest",
"Edit": "編輯",
"Enter your custom CSS here...": "在此處輸入您的自定義 CSS...",
"Excerpts": "摘錄",
"Failed to import book(s): {{filenames}}": "導入書籍失敗:{{filenames}}",
"Fast": "快",
"Font": "字體",
"Font & Layout": "字體和版面",
"Font Face": "字型",
@@ -42,6 +50,8 @@
"Grass": "青草",
"Gray": "素雅",
"Gruvbox": "暖橘",
"Highlight": "劃線",
"Horizontal Direction": "橫排",
"Hyphenation": "斷字",
"Identifier:": "識別碼",
"Import Books": "導入書籍",
@@ -70,6 +80,7 @@
"Notes": "筆記",
"Open": "打開",
"Open Book": "打開書籍",
"Original Text": "原文",
"Override Publisher Font": "覆蓋內置字體",
"Page": "頁面",
"Paging Animation": "翻頁動畫",
@@ -85,6 +96,7 @@
"Sans-Serif Font": "無襯線字體",
"Save": "保存",
"Scrolled Mode": "滾動模式",
"Search": "搜索",
"Search books...": "搜索書籍...",
"Search...": "搜索...",
"Select Book": "選擇書籍",
@@ -97,16 +109,25 @@
"Sign In": "登入",
"Sign Out": "登出",
"Sky": "天青",
"Slow": "慢",
"Solarized": "日暉",
"Speak": "朗讀",
"Subjects:": "主題",
"System Fonts": "系統字體",
"Table of Contents": "目錄",
"TTS not supported in this device": "此設備不支持 TTS",
"Theme Color": "主題顏色",
"Theme Mode": "主題模式",
"Translate": "翻譯",
"Translated Text": "譯文",
"Unknown": "未知",
"Untitled": "無標題",
"Updated:": "更新日期",
"User avatar": "用戶頭像",
"Version {{version}}": "版本 {{version}}",
"Vertical Direction": "豎排",
"Welcome to your library. You can import your books here and read them anytime.": "書庫空空如也,您可以導入您的書籍並隨時閱讀。",
"Wikipedia": "維基百科",
"Writing Mode": "排版模式",
"Your Library": "書庫"
}
+23
View File
@@ -1,5 +1,28 @@
{
"releases": {
"0.9.2": {
"date": "2025-01-10",
"notes": [
"Support Text-to-Speech with Edge TTS and Web Speech API.",
"Various fixes and enhancements."
]
},
"0.9.1": {
"date": "2025-01-06",
"notes": [
"Restore window position and size when reopening app.",
"Swap font when downloading online fonts.",
"Fix layout issues on Windows and Linux."
]
},
"0.9.0": {
"date": "2025-01-04",
"notes": [
"Better Custom CSS editor and OAuth process.",
"Add vertical/horizontal switch for CJK books.",
"Fix dropdown close overlay not working on Windows/Linux."
]
},
"0.8.9": {
"date": "2025-01-02",
"notes": [
+295 -126
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -6,7 +6,7 @@ authors = ["Bilingify LLC"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.71"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -34,6 +34,7 @@ tauri-plugin-shell = "2"
tauri-plugin-process = "2"
tauri-plugin-oauth = "2"
tauri-plugin-opener = "2.2.2"
tauri-plugin-deep-link = "2"
[patch.crates-io]
tauri = { path = "../../../packages/tauri/crates/tauri" }
@@ -44,4 +45,6 @@ rand = "0.8"
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-cli = "2"
tauri-plugin-single-instance = "2.2.0"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2.2.0"
@@ -51,7 +51,12 @@
"os:default",
"core:window:default",
"core:window:allow-close",
"core:window:allow-center",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-set-size",
"core:window:allow-set-focus",
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"shell:default",
@@ -60,6 +65,7 @@
"process:allow-restart",
"oauth:allow-start",
"oauth:allow-cancel",
"opener:default"
"opener:default",
"deep-link:default"
]
}
+32 -4
View File
@@ -9,7 +9,7 @@ extern crate objc;
#[cfg(target_os = "macos")]
mod menu;
#[cfg(target_os = "macos")]
mod tauri_traffic_light_positioner_plugin;
mod traffic_light_plugin;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
@@ -70,6 +70,12 @@ async fn start_server(window: Window) -> Result<u16, String> {
.map_err(|err| err.to_string())
}
#[derive(Clone, serde::Serialize)]
struct Payload {
args: Vec<String>,
cwd: String,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
@@ -83,11 +89,26 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init());
#[cfg(desktop)]
let builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
let _ = app
.get_webview_window("main")
.expect("no main window")
.set_focus();
app.emit("single-instance", Payload { args: argv, cwd })
.unwrap();
}));
let builder = builder.plugin(tauri_plugin_deep_link::init());
#[cfg(desktop)]
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
#[cfg(desktop)]
let builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
#[cfg(target_os = "macos")]
let builder = builder.plugin(tauri_traffic_light_positioner_plugin::init());
let builder = builder.plugin(traffic_light_plugin::init());
builder
.setup(|#[allow(unused_variables)] app| {
@@ -135,6 +156,12 @@ pub fn run() {
});
}
#[cfg(any(windows, target_os = "linux"))]
{
use tauri_plugin_deep_link::DeepLinkExt;
app.deep_link().register_all()?;
}
#[cfg(desktop)]
if cfg!(debug_assertions) {
app.handle().plugin(
@@ -149,8 +176,7 @@ pub fn run() {
#[cfg(desktop)]
let win_builder = win_builder
.inner_size(800.0, 600.0)
.resizable(true)
.maximized(true);
.resizable(true);
#[cfg(target_os = "macos")]
let win_builder = win_builder
@@ -162,6 +188,8 @@ pub fn run() {
let win_builder = win_builder
.decorations(false)
.transparent(true)
.visible(false)
.shadow(true)
.title("Readest");
win_builder.build().unwrap();
+8 -2
View File
@@ -15,7 +15,7 @@
"security": {
"csp": {
"default-src": "'self' 'unsafe-inline' blob: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost https://fonts.gstatic.com https://db.onlinewebfonts.com",
"connect-src": "'self' blob: 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",
"connect-src": "'self' blob: 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",
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost",
"frame-src": "'self' blob: asset: http://asset.localhost",
@@ -130,8 +130,14 @@
}
]
},
"deep-link": {
"mobile": [{ "host": "web.readest.com" }],
"desktop": {
"schemes": ["readest"]
}
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0OTAxMURGQkUzQjFENTQKUldSVUhUdSszeEdRNUExdmFkWnlvYWNYNG5wamkxMmUxRk5SejlMOTJVd28yNXlVTDh6Wld4OC8K",
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJFMEQ1QjE2OEU1NEIzNTEKUldSUnMxU09GbHNOdmpEaWFMT1crRFpEV2VORzQ2MklxaFc0M1R0ci9xY2c1bENXS0xhM1R1L2sK",
"endpoints": ["https://github.com/readest/readest/releases/latest/download/latest.json"]
}
}
+58 -26
View File
@@ -17,12 +17,18 @@ import { useTheme } from '@/hooks/useTheme';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { isTauriAppPlatform } from '@/services/environment';
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { start, cancel, onUrl, onInvalidUrl } from '@fabianlars/tauri-plugin-oauth';
import { openUrl } from '@tauri-apps/plugin-opener';
import { handleAuthCallback } from '@/helpers/auth';
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github';
interface SingleInstancePayload {
args: string[];
cwd: string;
}
interface ProviderLoginProp {
provider: OAuthProvider;
handleSignIn: (provider: OAuthProvider) => void;
@@ -64,7 +70,10 @@ export default function AuthPage() {
provider,
options: {
skipBrowserRedirect: true,
redirectTo: `http://localhost:${port}`,
redirectTo:
process.env.NODE_ENV === 'production'
? 'readest://auth/callback'
: `http://localhost:${port}`,
},
});
@@ -75,34 +84,54 @@ export default function AuthPage() {
openUrl(data.url);
};
const startOAuthServer = async () => {
const handleOAuthUrl = async (url: string) => {
console.log('Received OAuth URL:', url);
const hashMatch = url.match(/#(.*)/);
if (hashMatch) {
const hash = hashMatch[1];
const params = new URLSearchParams(hash);
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
const next = params.get('next') ?? '/';
if (accessToken) {
handleAuthCallback({ accessToken, refreshToken, next, login, navigate: router.push });
}
}
};
const startTauriOAuth = async () => {
try {
const port = await start();
setPort(port);
console.log(`OAuth server started on port ${port}`);
if (process.env.NODE_ENV === 'production') {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const currentWindow = getCurrentWindow();
currentWindow.listen('single-instance', ({ event, payload }) => {
console.log('Received deep link:', event, payload);
const { args } = payload as SingleInstancePayload;
if (args?.[1]) {
handleOAuthUrl(args[1]);
}
});
await onOpenUrl((urls) => {
urls.forEach((url) => {
handleOAuthUrl(url);
});
});
} else {
const port = await start();
setPort(port);
console.log(`OAuth server started on port ${port}`);
await onUrl((url) => {
console.log('Received OAuth URL:', url);
const hashMatch = url.match(/#(.*)/);
if (hashMatch) {
const hash = hashMatch[1];
const params = new URLSearchParams(hash);
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
const next = params.get('next') ?? '/';
handleAuthCallback({ accessToken, refreshToken, next, login, navigate: router.push });
}
});
await onInvalidUrl((url) => {
console.log('Received invalid OAuth URL:', url);
});
await onUrl(handleOAuthUrl);
await onInvalidUrl((url) => {
console.log('Received invalid OAuth URL:', url);
});
}
} catch (error) {
console.error('Error starting OAuth server:', error);
}
};
const stopOAuthServer = async () => {
const stopTauriOAuth = async () => {
try {
if (port) {
await cancel(port);
@@ -126,10 +155,10 @@ export default function AuthPage() {
if (isOAuthServerRunning.current) return;
isOAuthServerRunning.current = true;
startOAuthServer();
startTauriOAuth();
return () => {
isOAuthServerRunning.current = false;
stopOAuthServer();
stopTauriOAuth();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -156,13 +185,16 @@ export default function AuthPage() {
return null;
}
// For tauri app development, use a custom OAuth server to handle the OAuth callback
// For tauri app production, use deeplink to handle the OAuth callback
// For web app, use the built-in OAuth callback page /auth/callback
return isTauriAppPlatform() ? (
<div className='flex pt-11'>
<button
onClick={handleGoBack}
className='btn btn-ghost fixed left-3 top-11 h-8 min-h-8 w-8 p-0'
>
<IoArrowBack size={20} />
<IoArrowBack size={20} className='text-base-content' />
</button>
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem' }}>
<ProviderLogin
@@ -206,7 +238,7 @@ export default function AuthPage() {
onClick={handleGoBack}
className='btn btn-ghost fixed left-10 top-6 h-8 min-h-8 w-8 p-0'
>
<IoArrowBack size={20} />
<IoArrowBack size={20} className='text-base-content' />
</button>
<Auth
supabaseClient={supabase}
+16 -25
View File
@@ -8,6 +8,7 @@ import { Book } from '@/types/book';
import { AppService } from '@/types/system';
import { navigateToReader } from '@/utils/nav';
import { getBaseFilename, listFormater } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { parseOpenWithFiles } from '@/helpers/cli';
import { isTauriAppPlatform, hasUpdater } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
@@ -21,11 +22,11 @@ import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useDemoBooks } from './hooks/useDemoBooks';
import { AboutWindow } from '@/components/AboutWindow';
import { Toast } from '@/components/Toast';
import Spinner from '@/components/Spinner';
import LibraryHeader from './components/LibraryHeader';
import Bookshelf from './components/Bookshelf';
import { AboutWindow } from '@/components/AboutWindow';
import Toast from '@/components/Toast';
const LibraryPage = () => {
const router = useRouter();
@@ -46,16 +47,6 @@ const LibraryPage = () => {
const [isSelectMode, setIsSelectMode] = useState(false);
const demoBooks = useDemoBooks();
const [toastMessage, setToastMessage] = useState('');
const toastDismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current);
toastDismissTimeout.current = setTimeout(() => setToastMessage(''), 5000);
return () => {
if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current);
};
}, [toastMessage]);
useEffect(() => {
const doAppUpdates = async () => {
if (hasUpdater()) {
@@ -69,9 +60,14 @@ const LibraryPage = () => {
async (appService: AppService, openWithFiles: string[], libraryBooks: Book[]) => {
const bookIds: string[] = [];
for (const file of openWithFiles) {
const book = await appService.importBook(file, libraryBooks);
if (book) {
bookIds.push(book.hash);
console.log('Open with book:', file);
try {
const book = await appService.importBook(file, libraryBooks);
if (book) {
bookIds.push(book.hash);
}
} catch (error) {
console.log('Failed to import book:', file, error);
}
}
setLibrary(libraryBooks);
@@ -170,11 +166,12 @@ const LibraryPage = () => {
const filename = typeof file === 'string' ? file : file.name;
const baseFilename = getBaseFilename(filename);
failedFiles.push(baseFilename);
setToastMessage(
_('Failed to import book(s): {{filenames}}', {
eventDispatcher.dispatch('toast', {
message: _('Failed to import book(s): {{filenames}}', {
filenames: listFormater(false).format(failedFiles),
}),
);
type: 'error',
});
console.error('Failed to import book:', filename, error);
}
}
@@ -278,13 +275,7 @@ const LibraryPage = () => {
</div>
))}
<AboutWindow />
{toastMessage && (
<Toast
message={toastMessage}
toastClass='toast-top toast-end pt-11'
alertClass='alert-error max-w-80'
/>
)}
<Toast />
</div>
);
};
@@ -2,6 +2,7 @@ import React from 'react';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import FoliateViewer from './FoliateViewer';
import getGridTemplate from '@/utils/grid';
import SectionInfo from './SectionInfo';
@@ -11,7 +12,6 @@ import PageInfoView from './PageInfo';
import Ribbon from './Ribbon';
import SettingsDialog from './settings/SettingsDialog';
import Annotator from './annotator/Annotator';
import { useBookDataStore } from '@/store/bookDataStore';
import FootnotePopup from './FootnotePopup';
interface BooksGridProps {
@@ -49,7 +49,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
<div
id={`gridcell-${bookKey}`}
key={bookKey}
className='relative h-full w-full overflow-hidden'
className='rounded-window relative h-full w-full overflow-hidden'
>
{isBookmarked && <Ribbon width={marginGap} />}
<HeaderBar
@@ -18,7 +18,6 @@ import {
handleClick,
handleWheel,
} from '../utils/iframeEventHandlers';
import Toast from '@/components/Toast';
const FoliateViewer: React.FC<{
bookKey: string;
@@ -39,7 +38,7 @@ const FoliateViewer: React.FC<{
return () => clearTimeout(timer);
}, [toastMessage]);
useProgressSync(bookKey, setToastMessage);
useProgressSync(bookKey);
const progressRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
@@ -164,9 +163,6 @@ const FoliateViewer: React.FC<{
onClick={(event) => handleTurnPage(event)}
ref={containerRef}
/>
{toastMessage && (
<Toast message={toastMessage} toastClass='toast-top toast-end' alertClass='alert-success' />
)}
</>
);
};
@@ -2,10 +2,12 @@ import React from 'react';
import clsx from 'clsx';
import { RiArrowLeftWideLine, RiArrowRightWideLine } from 'react-icons/ri';
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
import { FaHeadphones } from 'react-icons/fa6';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import Button from '@/components/Button';
interface FooterBarProps {
@@ -16,9 +18,10 @@ interface FooterBarProps {
const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim }) => {
const _ = useTranslation();
const { hoveredBookKey, setHoveredBookKey, getView } = useReaderStore();
const { hoveredBookKey, setHoveredBookKey, getView, getProgress } = useReaderStore();
const { isSideBarVisible } = useSidebarStore();
const view = getView(bookKey);
const progress = getProgress(bookKey);
const handleProgressChange = (event: React.ChangeEvent) => {
const newProgress = parseInt((event.target as HTMLInputElement).value, 10);
@@ -41,6 +44,12 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
view?.history.forward();
};
const handleSpeakText = async () => {
if (!view || !progress) return;
const { range } = progress;
eventDispatcher.dispatch('tts-speak', { bookKey, range });
};
const pageinfoValid = pageinfo && pageinfo.total > 0 && pageinfo.current >= 0;
const progressFraction = pageinfoValid ? pageinfo.current / pageinfo.total : 0;
return (
@@ -83,6 +92,7 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
value={pageinfoValid ? progressFraction * 100 : 0}
onChange={(e) => handleProgressChange(e)}
/>
<Button icon={<FaHeadphones size={20} />} onClick={handleSpeakText} tooltip={_('Speak')} />
<Button
icon={<RiArrowRightWideLine size={20} />}
onClick={handleGoNext}
@@ -28,6 +28,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const { getView, getViewSettings } = useReaderStore();
const { themeCode } = useTheme();
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const footnoteHandler = new FootnoteHandler();
useEffect(() => {
@@ -73,7 +74,13 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const rect = gridFrame.getBoundingClientRect();
const viewSettings = getViewSettings(bookKey)!;
const triangPos = getPosition(detail.a, rect, viewSettings.vertical);
const popupPos = getPopupPosition(triangPos, rect, popupWidth, popupHeight, popupPadding);
const popupPos = getPopupPosition(
triangPos,
rect,
viewSettings.vertical ? popupHeight : popupWidth,
viewSettings.vertical ? popupWidth : popupHeight,
popupPadding,
);
setTrianglePosition(triangPos);
setPopupPosition(popupPos);
@@ -107,6 +114,9 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
}
}, [footnoteRef]);
const width = viewSettings.vertical ? popupHeight : popupWidth;
const height = viewSettings.vertical ? popupWidth : popupHeight;
return (
<div>
{showPopup && (
@@ -117,8 +127,8 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
/>
)}
<Popup
width={popupWidth}
height={popupHeight}
width={width}
height={height}
position={showPopup ? popupPosition! : undefined}
trianglePosition={showPopup ? trianglePosition! : undefined}
className='select-text overflow-y-auto'
@@ -127,8 +137,8 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
className=''
ref={footnoteRef}
style={{
width: `${popupWidth}px`,
height: `${popupHeight}px`,
width: `${width}px`,
height: `${height}px`,
}}
></div>
</Popup>
@@ -5,10 +5,10 @@ import { useEffect, Suspense, useRef } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import ReaderContent from './ReaderContent';
import { AboutWindow } from '@/components/AboutWindow';
import { useSettingsStore } from '@/store/settingsStore';
import { AboutWindow } from '@/components/AboutWindow';
import { Toast } from '@/components/Toast';
import ReaderContent from './ReaderContent';
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { envConfig } = useEnv();
@@ -33,10 +33,11 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
return (
library.length > 0 &&
settings.globalReadSettings && (
<div className='reader-page bg-base-100 text-base-content min-h-screen select-none'>
<div className='reader-page rounded-window bg-base-100 text-base-content min-h-screen select-none'>
<Suspense>
<ReaderContent ids={ids} settings={settings} />
<AboutWindow />
<Toast />
</Suspense>
</div>
)
@@ -26,6 +26,7 @@ import Spinner from '@/components/Spinner';
import SideBar from './sidebar/SideBar';
import Notebook from './notebook/Notebook';
import BooksGrid from './BooksGrid';
import TTSControl from './tts/TTSControl';
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
const router = useRouter();
@@ -88,6 +89,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const { book } = getBookData(bookKey) || {};
const { isPrimary } = getViewState(bookKey) || {};
if (isPrimary && book && config) {
const settings = useSettingsStore.getState().settings;
await saveConfig(envConfig, bookKey, config, settings);
}
};
@@ -100,6 +102,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
} catch {
console.info('Error closing book', bookKey);
}
eventDispatcher.dispatch('tts-stop', { bookKey });
await saveBookConfig(bookKey);
clearViewState(bookKey);
};
@@ -110,6 +113,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
};
const handleCloseBooks = async () => {
const settings = useSettingsStore.getState().settings;
await Promise.all(bookKeys.map((key) => saveConfigAndCloseBook(key)));
await saveSettings(envConfig, settings);
};
@@ -152,6 +156,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
<div className='flex h-screen'>
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
<TTSControl />
<Notebook />
{showDetailsBook && (
<BookDetailModal
@@ -6,7 +6,8 @@ import { FaWikipediaW } from 'react-icons/fa';
import { BsPencilSquare } from 'react-icons/bs';
import { RiDeleteBinLine } from 'react-icons/ri';
import { BsTranslate } from 'react-icons/bs';
import { SiDeepl } from 'react-icons/si';
import { TbHexagonLetterD } from 'react-icons/tb';
import { FaHeadphones } from 'react-icons/fa6';
import * as CFI from 'foliate-js/epubcfi.js';
import { Overlayer } from 'foliate-js/overlayer.js';
@@ -17,17 +18,18 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useFoliateEvents } from '../../hooks/useFoliateEvents';
import { useNotesSync } from '../../hooks/useNotesSync';
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import Toast from '@/components/Toast';
import AnnotationPopup from './AnnotationPopup';
import WiktionaryPopup from './WiktionaryPopup';
import WikipediaPopup from './WikipediaPopup';
import DeepLPopup from './DeepLPopup';
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore();
@@ -54,7 +56,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [annotPopupPosition, setAnnotPopupPosition] = useState<Position>();
const [dictPopupPosition, setDictPopupPosition] = useState<Position>();
const [translatorPopupPosition, setTranslatorPopupPosition] = useState<Position>();
const [toastMessage, setToastMessage] = useState('');
const [highlightOptionsVisible, setHighlightOptionsVisible] = useState(false);
const [selectedStyle, setSelectedStyle] = useState<HighlightStyle>(
@@ -82,7 +83,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setSelection({ key: bookKey, text: sel.toString(), range: sel.getRangeAt(0), index });
}
};
detail.doc?.addEventListener('pointerup', handlePointerup);
if (bookData.book?.format !== 'PDF') {
detail.doc?.addEventListener('pointerup', handlePointerup);
}
};
const onDrawAnnotation = (event: Event) => {
@@ -191,11 +194,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection, bookKey]);
useEffect(() => {
const timer = setTimeout(() => setToastMessage(''), 2000);
return () => clearTimeout(timer);
}, [toastMessage]);
useEffect(() => {
if (!progress) return;
const { location } = progress;
@@ -220,7 +218,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const handleCopy = () => {
if (!selection || !selection.text) return;
setToastMessage('Copied to notebook');
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Copied to notebook'),
className: 'whitespace-nowrap',
timeout: 2000,
});
const { booknotes: annotations = [] } = config;
if (selection) navigator.clipboard.writeText(selection.text);
@@ -332,19 +335,26 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setShowDeepLPopup(true);
};
const handleSpeakText = async () => {
if (!selection || !selection.text) return;
setShowAnnotPopup(false);
eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range });
};
const selectionAnnotated = selection?.annotated;
const buttons = [
{ tooltipText: 'Copy', Icon: FiCopy, onClick: handleCopy },
{ tooltipText: _('Copy'), Icon: FiCopy, onClick: handleCopy },
{
tooltipText: selectionAnnotated ? 'Delete Highlight' : 'Highlight',
tooltipText: selectionAnnotated ? _('Delete Highlight') : _('Highlight'),
Icon: selectionAnnotated ? RiDeleteBinLine : PiHighlighterFill,
onClick: handleHighlight,
},
{ tooltipText: 'Annotate', Icon: BsPencilSquare, onClick: handleAnnotate },
{ tooltipText: 'Search', Icon: FiSearch, onClick: handleSearch },
{ tooltipText: 'Dictionary', Icon: BsTranslate, onClick: handleDictionary },
{ tooltipText: 'Translate', Icon: SiDeepl, onClick: handleTranslation },
{ tooltipText: 'Wikipedia', Icon: FaWikipediaW, onClick: handleWikipedia },
{ tooltipText: _('Annotate'), Icon: BsPencilSquare, onClick: handleAnnotate },
{ tooltipText: _('Search'), Icon: FiSearch, onClick: handleSearch },
{ tooltipText: _('Dictionary'), Icon: TbHexagonLetterD, onClick: handleDictionary },
{ tooltipText: _('Wikipedia'), Icon: FaWikipediaW, onClick: handleWikipedia },
{ tooltipText: _('Translate'), Icon: BsTranslate, onClick: handleTranslation },
{ tooltipText: _('Speak'), Icon: FaHeadphones, onClick: handleSpeakText },
];
return (
@@ -399,8 +409,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onHighlight={handleHighlight}
/>
)}
{toastMessage && <Toast message={toastMessage} alertClass='bg-neutual text-content' />}
</div>
);
};
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import Popup from '@/components/Popup';
import { Position } from '@/utils/sel';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { isWebAppPlatform } from '@/services/environment';
const LANGUAGES = {
@@ -37,6 +38,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
popupWidth,
popupHeight,
}) => {
const _ = useTranslation();
const { settings, setSettings } = useSettingsStore();
const [sourceLang, setSourceLang] = useState('AUTO');
const [targetLang, setTargetLang] = useState(settings.globalReadSettings.translateTargetLang);
@@ -124,7 +126,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
>
<div className='text-neutral-content relative h-[50%] overflow-y-auto border-b border-neutral-400/75 p-4 font-sans'>
<div className='mb-2 flex items-center justify-between'>
<h1 className='text-base font-semibold'>Original Text</h1>
<h1 className='text-base font-semibold'>{_('Original Text')}</h1>
<select
value={sourceLang}
onChange={handleSourceLangChange}
@@ -134,7 +136,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
return (
<option key={code} value={code}>
{detectedSourceLang && sourceLang === 'AUTO' && code === 'AUTO'
? `${LANGUAGES[detectedSourceLang] || detectedSourceLang} (detected)`
? `${LANGUAGES[detectedSourceLang] || detectedSourceLang} ` + _('(detected)')
: name}
</option>
);
@@ -146,7 +148,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
<div className='text-neutral-content relative h-[50%] overflow-y-auto p-4 font-sans'>
<div className='mb-2 flex items-center justify-between'>
<h2 className='text-base font-semibold'>Translated Text</h2>
<h2 className='text-base font-semibold'>{_('Translated Text')}</h2>
<select
value={targetLang}
onChange={handleTargetLangChange}
@@ -163,7 +165,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
</div>
{loading ? (
<p className='text-base italic text-gray-500'>Loading...</p>
<p className='text-base italic text-gray-500'>{_('Loading...')}</p>
) : error ? (
<p className='text-base text-red-600'>{error}</p>
) : (
@@ -51,7 +51,8 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
>
<div
className={clsx(
'h-4 w-4 p-0 text-center leading-none',
'w-4 p-0 text-center leading-none',
style === 'highlight' ? 'h-4' : 'h-5',
style === 'highlight' &&
(selectedStyle === 'highlight' ? `bg-${selectedColor}-400` : `bg-gray-300`),
(style === 'underline' || style === 'squiggly') &&
@@ -62,7 +62,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
>
{moreOptions.map((option) => (
<li key={option} onClick={() => onSelect(option)}>
<div className='flex items-center px-0'>
<div className='flex items-center px-2'>
<span style={{ minWidth: '20px' }}>
{selected === option && <MdCheck size={20} className='text-base-content' />}
</span>
@@ -1,17 +1,24 @@
import React, { useEffect, useState } from 'react';
import { MdOutlineAutoMode } from 'react-icons/md';
import { MdOutlineTextRotationDown, MdOutlineTextRotationNone } from 'react-icons/md';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getStyles } from '@/utils/style';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import NumberInput from './NumberInput';
import { useTheme } from '@/hooks/useTheme';
import { getStyles } from '@/utils/style';
import { getBookLangCode } from '@/utils/book';
import NumberInput from './NumberInput';
const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const { getBookData } = useBookDataStore();
const view = getView(bookKey);
const bookData = getBookData(bookKey)!;
const viewSettings = getViewSettings(bookKey)!;
const { themeCode } = useTheme();
@@ -23,6 +30,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [maxColumnCount, setMaxColumnCount] = useState(viewSettings.maxColumnCount!);
const [maxInlineSize, setMaxInlineSize] = useState(viewSettings.maxInlineSize!);
const [maxBlockSize, setMaxBlockSize] = useState(viewSettings.maxBlockSize!);
const [writingMode, setWritingMode] = useState(viewSettings.writingMode!);
useEffect(() => {
viewSettings.lineHeight = lineHeight;
@@ -111,8 +119,66 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxInlineSize]);
useEffect(() => {
viewSettings.maxBlockSize = maxBlockSize;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.maxBlockSize = maxBlockSize;
setSettings(settings);
}
view?.renderer.setAttribute('max-block-size', `${maxBlockSize}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxBlockSize]);
useEffect(() => {
// global settings are not supported for writing mode
viewSettings.writingMode = writingMode;
setViewSettings(bookKey, viewSettings);
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [writingMode]);
const langCode = getBookLangCode(bookData.bookDoc?.metadata?.language);
const isCJKBook = langCode === 'zh' || langCode === 'ja' || langCode === 'ko';
return (
<div className='my-4 w-full space-y-6'>
{isCJKBook && (
<div className='w-full'>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Writing Mode')}</h2>
<div className='flex gap-2'>
<div className='tooltip tooltip-bottom' data-tip={_('Default')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('auto')}
>
<MdOutlineAutoMode size={20} />
</button>
</div>
<div className='tooltip tooltip-bottom' data-tip={_('Horizontal Direction')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'horizontal-tb' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('horizontal-tb')}
>
<MdOutlineTextRotationNone size={20} />
</button>
</div>
<div className='tooltip tooltip-bottom' data-tip={_('Vertical Direction')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'vertical-rl' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('vertical-rl')}
>
<MdOutlineTextRotationDown size={20} />
</button>
</div>
</div>
</div>
</div>
)}
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Paragraph')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
@@ -173,7 +239,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
value={maxColumnCount}
onChange={setMaxColumnCount}
min={1}
max={2}
max={4}
/>
<NumberInput
label={_('Maximum Inline Size')}
@@ -1,13 +1,12 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import cssbeautify from 'cssbeautify';
import { getStyles } from '@/utils/style';
import { useTheme } from '@/hooks/useTheme';
const cssRegex =
/((?:\s*)([\w#.@*,:\-.:>+~$$$$\"=(),*\s]+)\s*{(?:[\s]*)((?:[^\}]+[:][^\}]+;?)*)*\s*}(?:\s*))/gim;
import cssbeautify from 'cssbeautify';
import cssValidate from '@/utils/css';
const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
@@ -18,44 +17,51 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [animated, setAnimated] = useState(viewSettings.animated!);
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
const [userStylesheet, setUserStylesheet] = useState(viewSettings.userStylesheet!);
const [draftStylesheet, setDraftStylesheet] = useState(viewSettings.userStylesheet!);
const [draftStylesheetSaved, setDraftStylesheetSaved] = useState(true);
const [error, setError] = useState<string | null>(null);
let cssInput = userStylesheet;
const validateCSS = (css: string) => {
return cssRegex.test(css);
};
const handleUserStylesheetChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
cssInput = e.target.value;
const cssInput = e.target.value;
setDraftStylesheet(cssInput);
setDraftStylesheetSaved(false);
try {
const formattedCSS = cssbeautify(cssInput, {
indent: ' ',
openbrace: 'end-of-line',
autosemicolon: true,
});
setUserStylesheet(formattedCSS);
if (cssInput && !validateCSS(cssInput)) {
throw new Error('Invalid CSS');
const { isValid, error } = cssValidate(cssInput);
if (cssInput && !isValid) {
throw new Error(error || 'Invalid CSS');
}
setError(null);
viewSettings.userStylesheet = formattedCSS;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.userStylesheet = formattedCSS;
setSettings(settings);
} catch (err: unknown) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('Invalid CSS: Please check your input.');
}
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
} catch (err) {
setError('Invalid CSS: Please check your input.');
console.log('CSS Error:', err);
}
};
const applyStyles = () => {
const formattedCSS = cssbeautify(draftStylesheet, {
indent: ' ',
openbrace: 'end-of-line',
autosemicolon: true,
});
setDraftStylesheet(formattedCSS);
setDraftStylesheetSaved(true);
viewSettings.userStylesheet = formattedCSS;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.userStylesheet = formattedCSS;
setSettings(settings);
}
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
};
const handleInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
@@ -90,10 +96,10 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='my-4 w-full space-y-6'>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Animation')}</h2>
<div className='card bg-base-100 border shadow'>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-y'>
<div className='config-item config-item-top config-item-bottom'>
<span className='text-gray-700'>{_('Paging Animation')}</span>
<span className=''>{_('Paging Animation')}</span>
<input
type='checkbox'
className='toggle'
@@ -107,10 +113,10 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Behavior')}</h2>
<div className='card bg-base-100 border shadow'>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-y'>
<div className='config-item config-item-top config-item-bottom'>
<span className='text-gray-700'>{_('Disable Click-to-Flip')}</span>
<span className=''>{_('Disable Click-to-Flip')}</span>
<input
type='checkbox'
className='toggle'
@@ -124,22 +130,34 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Custom CSS')}</h2>
<div className={`card bg-base-100 border shadow ${error ? 'border-red-500' : ''}`}>
<div className='divide-y'>
<div className='css-text-area config-item-top config-item-bottom p-1'>
<textarea
className='textarea textarea-ghost h-48 w-full border-0 p-3 !outline-none'
placeholder={_('Enter your custom CSS here...')}
spellCheck='false'
value={cssInput}
onInput={handleInput}
onKeyDown={handleInput}
onKeyUp={handleInput}
onChange={handleUserStylesheetChange}
/>
</div>
<div
className={`card border-base-200 bg-base-100 border shadow ${error ? 'border-red-500' : ''}`}
>
<div className='relative p-1'>
<textarea
className='textarea textarea-ghost h-48 w-full border-0 p-3 !outline-none'
placeholder={_('Enter your custom CSS here...')}
spellCheck='false'
value={draftStylesheet}
onInput={handleInput}
onKeyDown={handleInput}
onKeyUp={handleInput}
onChange={handleUserStylesheetChange}
/>
<button
className={clsx(
'btn btn-ghost bg-base-200 absolute bottom-2 right-4 h-8 min-h-8 px-4 py-2',
draftStylesheetSaved ? 'hidden' : '',
error ? 'btn-disabled' : '',
)}
onClick={applyStyles}
disabled={!!error}
>
{_('Apply')}
</button>
</div>
</div>
{error && <p className='mt-1 text-sm text-red-500'>{error}</p>}
</div>
</div>
);
@@ -25,11 +25,17 @@ const NumberInput: React.FC<NumberInputProps> = ({
const numberStep = step || 1;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value === '' ? 0 : parseFloat(e.target.value);
setLocalValue(newValue);
if (!isNaN(newValue)) {
const roundedValue = Math.round(newValue * 10) / 10;
onChange(Math.max(min, Math.min(max, roundedValue)));
const value = e.target.value;
// Allow empty string or valid numbers without leading zeros
if (value === '' || /^[1-9]\d*\.?\d*$|^0?\.?\d*$/.test(value)) {
const newValue = value === '' ? 0 : parseFloat(value);
setLocalValue(newValue);
if (!isNaN(newValue)) {
const roundedValue = Math.round(newValue * 10) / 10;
onChange(Math.max(min, Math.min(max, roundedValue)));
}
}
};
@@ -59,6 +65,7 @@ const NumberInput: React.FC<NumberInputProps> = ({
<div className='text-base-content flex items-center gap-2'>
<input
type='text'
inputMode='decimal'
value={localValue}
onChange={handleChange}
onBlur={handleOnBlur}
@@ -73,7 +73,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
</div>
<div className='flex h-full items-center justify-end'>
<Dropdown
className='dropdown-bottom dropdown-end absolute right-[7%]'
className='dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiDotsThreeVerticalBold size={16} />}
>
@@ -122,7 +122,12 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
)}
onClick={editNote.bind(null, item)}
>
<div className='align-bottom text-blue-400'>{_('Edit')}</div>
<div className={clsx(
'align-bottom text-blue-400',
'transition duration-300 ease-in-out',
'group-hover:opacity-100 opacity-0',
'hover:text-blue-600',
)}>{_('Edit')}</div>
</button>
)}
<button
@@ -132,7 +137,12 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
)}
onClick={deleteNote.bind(null, item)}
>
<div className='align-bottom text-red-400'>{_('Delete')}</div>
<div className={clsx(
'align-bottom text-red-400',
'transition duration-300 ease-in-out',
'group-hover:opacity-100 opacity-0',
'hover:text-red-600',
)}>{_('Delete')}</div>
</button>
</div>
</div>
@@ -15,6 +15,8 @@ const SidebarContent: React.FC<{
const { getConfig, setConfig } = useBookDataStore();
const config = getConfig(sideBarBookKey);
const [activeTab, setActiveTab] = useState(config?.viewSettings?.sideBarTab || 'toc');
const [fade, setFade] = useState(false);
const [targetTab, setTargetTab] = useState(activeTab);
useEffect(() => {
const container = scrollContainerRef.current;
@@ -49,10 +51,17 @@ const SidebarContent: React.FC<{
}, [sideBarBookKey]);
const handleTabChange = (tab: string) => {
setFade(true);
const timeout = setTimeout(() => {
setFade(false)
setTargetTab(tab);
setConfig(sideBarBookKey!, config);
clearTimeout(timeout);
}, 300);
setActiveTab(tab);
const config = getConfig(sideBarBookKey!)!;
config.viewSettings!.sideBarTab = tab;
setConfig(sideBarBookKey!, config);
};
return (
@@ -63,14 +72,20 @@ const SidebarContent: React.FC<{
'font-sans text-sm font-normal shadow-inner',
)}
>
<div ref={scrollContainerRef} className='scroll-container overflow-y-auto'>
{activeTab === 'toc' && bookDoc.toc && (
<div
ref={scrollContainerRef}
className={clsx(
'scroll-container overflow-y-auto transition-opacity duration-300 ease-in-out',
{ 'opacity-0': fade, 'opacity-100': !fade }
)}
>
{targetTab === 'toc' && bookDoc.toc && (
<TOCView toc={bookDoc.toc} bookKey={sideBarBookKey} />
)}
{activeTab === 'annotations' && (
{targetTab === 'annotations' && (
<BooknoteView type='annotation' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
)}
{activeTab === 'bookmarks' && (
{targetTab === 'bookmarks' && (
<BooknoteView type='bookmark' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
)}
</div>
@@ -126,7 +126,7 @@ const TOCView: React.FC<{
const rect = currentItem.getBoundingClientRect();
const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
if (!isVisible) {
(currentItem as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'center' });
(currentItem as HTMLElement).scrollIntoView({ behavior: 'instant', block: 'center' });
}
(currentItem as HTMLElement).setAttribute('aria-current', 'page');
}
@@ -1,23 +1,54 @@
import clsx from 'clsx';
import React from 'react';
import { MdToc, MdEditNote, MdBookmarkBorder } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
const TabNavigation: React.FC<{
activeTab: string;
onTabChange: (tab: string) => void;
}> = ({ activeTab, onTabChange }) => (
<div className='bottom-tab border-base-300/50 flex w-full border-t'>
{['toc', 'annotations', 'bookmarks'].map((tab) => (
<button
key={tab}
className={`m-1.5 flex-1 rounded-md p-2 ${activeTab === tab ? 'bg-base-300' : ''}`}
onClick={() => onTabChange(tab)}
>
{tab === 'toc' && <MdToc size={20} className='mx-auto' />}
{tab === 'annotations' && <MdEditNote size={20} className='mx-auto' />}
{tab === 'bookmarks' && <MdBookmarkBorder size={20} className='mx-auto' />}
</button>
))}
</div>
);
}> = ({ activeTab, onTabChange }) => {
const _ = useTranslation();
const tabs = ['toc', 'annotations', 'bookmarks'];
return (
<div className="bottom-tab border-base-300/50 flex w-full border-t relative">
<div
className={clsx(
'absolute bottom-0 left-0 h-full w-1/3 bg-base-300 -z-10',
'transition-transform duration-300 transform',
activeTab === 'toc' && 'translate-x-0',
activeTab === 'annotations' && 'translate-x-full',
activeTab === 'bookmarks' && 'translate-x-[200%]',
)}
/>
{tabs.map((tab) => (
<div
key={tab}
className='tooltip z-50 tooltip-top m-1.5 flex-1 rounded-md p-2 cursor-pointer'
data-tip={tab === 'toc' ? _('Table of Contents') : tab === 'annotations' ? _('Annotate') : _('Bookmark')}
>
<div
className={clsx(
'',
)}
onClick={() => onTabChange(tab)}
>
{
tab === 'toc' ? (
<MdToc size={20} className='mx-auto' />
) : tab === 'annotations' ? (
<MdEditNote size={20} className='mx-auto' />
) : (
<MdBookmarkBorder size={20} className='mx-auto' />
)
}
</div>
</div>
))}
</div>
)
};
export default TabNavigation;
@@ -0,0 +1,254 @@
import React, { useState, useRef, useEffect } from 'react';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { TTSController } from '@/services/tts/TTSController';
import { getPopupPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { parseSSMLLang } from '@/utils/ssml';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
const POPUP_WIDTH = 282;
const POPUP_HEIGHT = 160;
const POPUP_PADDING = 10;
const TTSControl = () => {
const _ = useTranslation();
const { getView, getViewSettings } = useReaderStore();
const [bookKey, setBookKey] = useState<string>('');
const [ttsLang, setTtsLang] = useState<string>('en');
const [isPlaying, setIsPlaying] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [showPanel, setShowPanel] = useState(false);
const [panelPosition, setPanelPosition] = useState<Position>();
const [trianglePosition, setTrianglePosition] = useState<Position>();
const iconRef = useRef<HTMLDivElement>(null);
const ttsControllerRef = useRef<TTSController | null>(null);
useEffect(() => {
return () => {
if (ttsControllerRef.current) {
ttsControllerRef.current.kill();
ttsControllerRef.current = null;
}
};
}, []);
useEffect(() => {
eventDispatcher.on('tts-speak', handleTTSSpeak);
eventDispatcher.on('tts-stop', handleTTSStop);
return () => {
eventDispatcher.off('tts-speak', handleTTSSpeak);
eventDispatcher.off('tts-stop', handleTTSStop);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleTTSSpeak = async (event: CustomEvent) => {
const { bookKey, range } = event.detail;
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey);
if (!view || !viewSettings) return;
setBookKey(bookKey);
if (ttsControllerRef.current) {
ttsControllerRef.current.stop();
ttsControllerRef.current = null;
}
try {
const ttsController = new TTSController(view);
await ttsController.init();
await ttsController.initViewTTS();
const ssml = view.tts?.from(range);
if (ssml) {
ttsController.setRate(viewSettings.ttsRate);
ttsController.setVoice(viewSettings.ttsVoice);
ttsController.speak(ssml);
ttsControllerRef.current = ttsController;
const lang = parseSSMLLang(ssml) || 'en';
setTtsLang(lang);
setIsPlaying(true);
}
} catch (error) {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported in this device'),
type: 'error',
});
console.error(error);
}
};
const handleTTSStop = async (event: CustomEvent) => {
const { bookKey } = event.detail;
if (bookKey === bookKey) {
handleStop();
}
};
const handleTogglePlay = async () => {
const ttsController = ttsControllerRef.current;
if (!ttsController) return;
if (isPlaying) {
ttsController.pause();
setIsPlaying(false);
setIsPaused(true);
} else if (isPaused) {
// start for forward/backward/setvoice-paused
// set rate don't pause the tts
if (ttsController.state === 'paused') {
ttsController.resume();
} else {
ttsController.start();
}
setIsPlaying(true);
setIsPaused(false);
}
};
const handleBackward = async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.backward();
}
};
const handleForward = async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.forward();
}
};
const handleStop = async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
ttsController.stop();
ttsControllerRef.current = null;
setIsPlaying(false);
setShowPanel(false);
}
};
// rate range: 0.5 - 3, 1.0 is normal speed
const handleSetRate = async (rate: number) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
if (ttsController.state === 'playing') {
ttsController.pause();
ttsController.setRate(rate);
ttsController.start();
} else {
ttsController.setRate(rate);
}
}
};
const handleSetVoice = async (voice: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
if (ttsController.state === 'playing') {
ttsController.pause();
ttsController.setVoice(voice);
ttsController.start();
} else {
ttsController.setVoice(voice);
}
}
};
const handleGetVoices = async (lang: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
return ttsController.getVoices(lang);
}
return [];
};
const handleGetVoiceId = () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
return ttsController.getVoiceId();
}
return '';
};
const updatePanelPosition = () => {
if (iconRef.current) {
const rect = iconRef.current.getBoundingClientRect();
const windowRect = document.documentElement.getBoundingClientRect();
const trianglePos = {
dir: 'up',
point: { x: rect.left + rect.width / 2, y: rect.top - 12 },
} as Position;
const popupPos = getPopupPosition(
trianglePos,
windowRect,
POPUP_WIDTH,
POPUP_HEIGHT,
POPUP_PADDING,
);
setPanelPosition(popupPos);
setTrianglePosition(trianglePos);
}
};
const togglePopup = () => {
updatePanelPosition();
setShowPanel((prev) => !prev);
};
const handleDismissPopup = () => {
setShowPanel(false);
};
return (
<div>
{showPanel && (
<div
className='fixed inset-0'
onClick={handleDismissPopup}
onContextMenu={handleDismissPopup}
/>
)}
{ttsControllerRef.current && (
<div ref={iconRef} className='absolute bottom-12 right-6 h-12 w-12'>
<TTSIcon isPlaying={isPlaying} onClick={togglePopup} />
</div>
)}
{showPanel && panelPosition && trianglePosition && (
<Popup
width={POPUP_WIDTH}
height={POPUP_HEIGHT}
position={panelPosition}
trianglePosition={trianglePosition}
className='bg-base-200 absolute flex shadow-lg'
>
<TTSPanel
bookKey={bookKey}
ttsLang={ttsLang}
isPlaying={isPlaying}
onTogglePlay={handleTogglePlay}
onBackward={handleBackward}
onForward={handleForward}
onStop={handleStop}
onSetRate={handleSetRate}
onGetVoices={handleGetVoices}
onSetVoice={handleSetVoice}
onGetVoiceId={handleGetVoiceId}
/>
</Popup>
)}
</div>
);
};
export default TTSControl;
@@ -0,0 +1,54 @@
import React from 'react';
type TTSIconProps = {
isPlaying: boolean;
onClick: () => void;
};
const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, onClick }) => {
const bars = [1, 2, 3, 4];
return (
<div className='relative h-full w-full cursor-pointer' 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',
}}
/>
</div>
<div className='absolute inset-0 flex items-center justify-center'>
<style>{`
@keyframes moveGradient {
0% { transform: translate(0, 0); }
100% { transform: translate(25%, 25%); }
}
@keyframes bounce {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(0.6); }
}
`}</style>
<div className='flex items-end space-x-1'>
{bars.map((bar) => (
<div
key={bar}
className='w-1 rounded-t bg-white'
style={{
height: '16px',
animationName: isPlaying ? 'bounce' : 'none',
animationDuration: isPlaying ? `${1 + bar * 0.1}s` : '0s',
animationTimingFunction: 'ease-in-out',
animationIterationCount: 'infinite',
animationDelay: `${bar * 0.1}s`,
}}
/>
))}
</div>
</div>
</div>
);
};
export default TTSIcon;
@@ -0,0 +1,161 @@
import clsx from 'clsx';
import React, { useState, ChangeEvent, useEffect } from 'react';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdStop } from 'react-icons/md';
import { RiVoiceAiFill } from 'react-icons/ri';
import { MdCheck } from 'react-icons/md';
import { TTSVoice } from '@/services/tts';
type TTSPanelProps = {
bookKey: string;
ttsLang: string;
isPlaying: boolean;
onTogglePlay: () => void;
onBackward: () => void;
onForward: () => void;
onStop: () => void;
onSetRate: (rate: number) => void;
onGetVoices: (lang: string) => Promise<TTSVoice[]>;
onSetVoice: (voice: string) => void;
onGetVoiceId: () => string;
};
const TTSPanel = ({
bookKey,
ttsLang,
isPlaying,
onTogglePlay,
onBackward,
onForward,
onStop,
onSetRate,
onGetVoices,
onSetVoice,
onGetVoiceId,
}: TTSPanelProps) => {
const _ = useTranslation();
const { getViewSettings, setViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const [voices, setVoices] = useState<TTSVoice[]>([]);
const [rate, setRate] = useState(viewSettings?.ttsRate ?? 1.0);
const [selectedVoice, setSelectedVoice] = useState(viewSettings?.ttsVoice ?? '');
const handleSetRate = (e: ChangeEvent<HTMLInputElement>) => {
let newRate = parseFloat(e.target.value);
newRate = Math.max(0.2, Math.min(3.0, newRate));
setRate(newRate);
onSetRate(newRate);
const viewSettings = getViewSettings(bookKey)!;
viewSettings.ttsRate = newRate;
setViewSettings(bookKey, viewSettings);
};
const handleSelectVoice = (voice: string) => {
onSetVoice(voice);
setSelectedVoice(voice);
const viewSettings = getViewSettings(bookKey)!;
viewSettings.ttsVoice = voice;
setViewSettings(bookKey, viewSettings);
};
useEffect(() => {
const voiceId = onGetVoiceId();
setSelectedVoice(voiceId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const fetchVoices = async () => {
const voices = await onGetVoices(ttsLang);
setVoices(voices);
};
fetchVoices();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ttsLang]);
return (
<div className='flex w-full flex-col items-center justify-center gap-2 rounded-2xl p-4'>
<div className='flex w-full flex-col items-center gap-0.5'>
<input
className='range'
type='range'
min={0.0}
max={3.0}
step='0.1'
value={rate}
onChange={handleSetRate}
/>
<div className='grid w-full grid-cols-7 text-xs'>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
</div>
<div className='grid w-full grid-cols-7 text-xs'>
<span className='text-center'>{_('Slow')}</span>
<span className='text-center'></span>
<span className='text-center'>1.0</span>
<span className='text-center'>1.5</span>
<span className='text-center'>2.0</span>
<span className='text-center'></span>
<span className='text-center'>{_('Fast')}</span>
</div>
</div>
<div className='flex items-center justify-between space-x-2'>
<button onClick={onBackward} className='hover:bg-base-200/75 rounded-full p-1'>
<MdFastRewind size={32} />
</button>
<button onClick={onTogglePlay} className='hover:bg-base-200/75 rounded-full p-1'>
{isPlaying ? (
<MdPauseCircle size={48} className='fill-primary' />
) : (
<MdPlayCircle size={48} className='fill-primary' />
)}
</button>
<button onClick={onForward} className='hover:bg-base-200/75 rounded-full p-1'>
<MdFastForward size={32} />
</button>
<button onClick={onStop} className='hover:bg-base-200/75 rounded-full p-1'>
<MdStop size={32} />
</button>
<div className='dropdown dropdown-top'>
<button tabIndex={0} className='hover:bg-base-200/75 rounded-full p-1'>
<RiVoiceAiFill size={32} />
</button>
<ul
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute right-0 z-[1] shadow',
'mt-4 max-h-96 w-[250px] overflow-y-scroll',
)}
>
{voices.map((voice, index) => (
<li
key={`${index}-${voice.id}`}
onClick={() => !voice.disabled && handleSelectVoice(voice.id)}
>
<div className='flex items-center px-2'>
<span style={{ minWidth: '20px' }}>
{selectedVoice === voice.id && (
<MdCheck size={20} className='text-base-content' />
)}
</span>
<span className={clsx('text-sm', voice.disabled && 'text-gray-400')}>
{voice.name}
</span>
</div>
</li>
))}
</ul>
</div>
</div>
</div>
);
};
export default TTSPanel;
@@ -7,12 +7,10 @@ import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import { eventDispatcher } from '@/utils/event';
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
export const useProgressSync = (
bookKey: string,
setToastMessage?: React.Dispatch<React.SetStateAction<string>>,
) => {
export const useProgressSync = (bookKey: string) => {
const _ = useTranslation();
const { getConfig, setConfig } = useBookDataStore();
const { getView } = useReaderStore();
@@ -21,6 +19,8 @@ export const useProgressSync = (
const { user } = useAuth();
const view = getView(bookKey);
const config = getConfig(bookKey);
// flag to prevent accidental sync without first pulling the config
const configSynced = useRef(false);
const pushConfig = (bookKey: string, config: BookConfig | null) => {
if (!config || !user) return;
@@ -31,13 +31,26 @@ export const useProgressSync = (
);
syncConfigs([compressedConfig], bookHash, 'push');
};
useEffect(() => {
const pullConfig = (bookKey: string) => {
if (!user) return;
const bookHash = bookKey.split('-')[0]!;
syncConfigs([], bookHash, 'pull');
return () => {
};
const syncConfig = () => {
if (!configSynced.current) {
pullConfig(bookKey);
} else {
pushConfig(bookKey, config);
}
};
useEffect(() => {
if (!user) return;
pullConfig(bookKey);
return () => {
if (configSynced.current) {
pushConfig(bookKey, config);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -46,18 +59,19 @@ export const useProgressSync = (
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!config?.location || !user) return;
const now = Date.now();
const timeSinceLastSync = now - lastProgressSyncTime.current;
if (configSynced.current && timeSinceLastSync > SYNC_PROGRESS_INTERVAL_SEC * 1000) {
if (timeSinceLastSync > SYNC_PROGRESS_INTERVAL_SEC * 1000) {
lastProgressSyncTime.current = now;
pushConfig(bookKey, config);
syncConfig();
} else {
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
syncTimeoutRef.current = setTimeout(
() => {
lastProgressSyncTime.current = Date.now();
pushConfig(bookKey, config);
syncTimeoutRef.current = null;
syncConfig();
},
SYNC_PROGRESS_INTERVAL_SEC * 1000 - timeSinceLastSync,
);
@@ -66,9 +80,9 @@ export const useProgressSync = (
}, [config]);
// sync progress once when the book is opened
const configSynced = useRef(false);
useEffect(() => {
if (!configSynced.current && syncedConfigs?.length > 0) {
if (!configSynced.current && syncedConfigs) {
configSynced.current = true;
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
if (syncedConfig) {
const newConfig = deserializeConfig(
@@ -77,13 +91,15 @@ export const useProgressSync = (
DEFAULT_BOOK_SEARCH_CONFIG,
);
setConfig(bookKey, { ...config, ...newConfig });
configSynced.current = true;
if ((syncedConfig.progress?.[1] ?? 0) > 0 && (config?.progress?.[1] ?? 0) > 0) {
const syncedFraction = syncedConfig.progress![0] / syncedConfig.progress![1];
const configFraction = config!.progress![0] / config!.progress![1];
if (syncedFraction > configFraction) {
view?.goToFraction(syncedFraction);
setToastMessage?.(_('Reading progress synced'));
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Reading progress synced'),
});
}
}
}
@@ -105,9 +105,9 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
</div>
</div>
<div className='title-author flex h-40 flex-col justify-between pr-4'>
<div className='title-author flex h-40 max-w-[60%] flex-col justify-between pr-4'>
<div>
<h2 className='text-base-content mb-2 line-clamp-2 text-2xl font-bold'>
<h2 className='text-base-content mb-2 line-clamp-2 break-all text-2xl font-bold'>
{book.title || _('Untitled')}
</h2>
<p className='text-neutral-content line-clamp-1'>{book.author || _('Unknown')}</p>
+2 -2
View File
@@ -34,7 +34,7 @@ const Dropdown: React.FC<DropdownProps> = ({
: children;
return (
<>
<div className='dropdown-container'>
{isOpen && (
<div className='fixed inset-0 bg-transparent' onClick={() => setIsDropdownOpen(false)} />
)}
@@ -48,7 +48,7 @@ const Dropdown: React.FC<DropdownProps> = ({
</div>
{isOpen && childrenWithToggle}
</div>
</>
</div>
);
};
+2 -2
View File
@@ -21,7 +21,7 @@ const Popup = ({
}) => (
<div>
<div
className={`triangle text-base-200 absolute z-10 ${triangleClassName}`}
className={`triangle text-base-200 absolute z-40 ${triangleClassName}`}
style={{
left:
trianglePosition?.dir === 'left'
@@ -67,7 +67,7 @@ const Popup = ({
/>
<div
id='popup-container'
className={`bg-base-200 absolute rounded-lg font-sans shadow-xl ${className}`}
className={`bg-base-200 absolute z-30 rounded-lg font-sans shadow-xl ${className}`}
style={{
width: `${width}px`,
height: `${height}px`,
+62 -13
View File
@@ -1,16 +1,65 @@
import { eventDispatcher } from '@/utils/event';
import clsx from 'clsx';
import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
const Toast: React.FC<{ message: string; toastClass?: string; alertClass?: string }> = ({
message,
toastClass,
alertClass,
}) => (
<div className={clsx('toast toast-center toast-middle', toastClass)}>
<div className={clsx('alert flex items-center justify-center border-0', alertClass)}>
<span className='whitespace-normal break-words'>{message}</span>
</div>
</div>
);
export type ToastType = 'info' | 'success' | 'warning' | 'error';
export default Toast;
export const Toast = () => {
const [toastMessage, setToastMessage] = useState('');
const toastType = useRef<ToastType>('info');
const toastTimeout = useRef(5000);
const messageClass = useRef('');
const toastDismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const toastClassMap = {
info: 'toast-info toast-center toast-middle',
success: 'toast-success toast-top toast-end pt-11',
warning: 'toast-warning toast-top toast-end pt-11',
error: 'toast-error toast-top toast-end pt-11',
};
const alertClassMap = {
info: 'alert-primary',
success: 'alert-success',
warning: 'alert-warning',
error: 'alert-error',
};
useEffect(() => {
if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current);
toastDismissTimeout.current = setTimeout(() => setToastMessage(''), toastTimeout.current);
return () => {
if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current);
};
}, [toastMessage]);
const handleShowToast = async (event: CustomEvent) => {
const { message, type = 'info', timeout = 5000, className = '' } = event.detail;
setToastMessage(message);
toastType.current = type;
toastTimeout.current = timeout;
messageClass.current = className;
};
useEffect(() => {
eventDispatcher.on('toast', handleShowToast);
return () => {
eventDispatcher.off('toast', handleShowToast);
};
}, []);
return (
toastMessage && (
<div className={clsx('toast toast-center toast-middle', toastClassMap[toastType.current])}>
<div
className={clsx(
'alert flex max-w-80 items-center justify-center border-0',
alertClassMap[toastType.current],
)}
>
<span className={clsx('whitespace-normal break-words', messageClass.current)}>
{toastMessage}
</span>
</div>
</div>
)
);
};
@@ -53,6 +53,7 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
if (
target.closest('.btn') ||
target.closest('.window-button') ||
target.closest('.dropdown-container') ||
target.closest('.exclude-title-bar-mousedown')
) {
return;
+36 -16
View File
@@ -29,27 +29,43 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
});
useEffect(() => {
const fetchSession = async () => {
const {
data: { session },
} = await supabase.auth.getSession();
const syncSession = (
session: { access_token: string; refresh_token: string; user: User } | null,
) => {
if (session) {
const { access_token, user } = session;
console.log('Syncing session');
const { access_token, refresh_token, user } = session;
localStorage.setItem('token', access_token);
localStorage.setItem('refresh_token', refresh_token);
localStorage.setItem('user', JSON.stringify(user));
setToken(access_token);
setUser(user);
localStorage.setItem('token', access_token);
localStorage.setItem('user', JSON.stringify(user));
} else {
console.log('Clearing session');
localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
}
};
const refreshSession = async () => {
try {
await supabase.auth.refreshSession();
} catch {
syncSession(null);
}
};
console.log('Fetching session');
fetchSession();
}, [token]);
const { data: subscription } = supabase.auth.onAuthStateChange((_, session) => {
syncSession(session);
});
refreshSession();
return () => {
subscription?.subscription.unsubscribe();
};
}, []);
const login = (newToken: string, newUser: User) => {
console.log('Logging in');
@@ -61,12 +77,16 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
const logout = async () => {
console.log('Logging out');
await supabase.auth.refreshSession();
await supabase.auth.signOut();
localStorage.removeItem('token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
try {
await supabase.auth.refreshSession();
} catch {
} finally {
await supabase.auth.signOut();
localStorage.removeItem('token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
}
};
return (
+1 -1
View File
@@ -28,7 +28,7 @@ const DEFAULT_SHORTCUTS: ShortcutConfig = {
onReloadPage: ['shift+r'],
onQuitApp: ['ctrl+q', 'cmd+q'],
onGoLeft: ['ArrowLeft', 'PageUp', 'h'],
onGoRight: ['ArrowRight', 'PageDown', 'l'],
onGoRight: ['ArrowRight', 'PageDown', 'l', ' '],
onGoNext: ['ArrowDown', 'j'],
onGoPrev: ['ArrowUp', 'k'],
onGoBack: ['shift+ArrowLeft', 'shift+h'],
+18 -15
View File
@@ -51,14 +51,15 @@ export function useSync(bookKey?: string) {
);
const [syncing, setSyncing] = useState(false);
// null means unsynced, empty array means synced no changes
const [syncResult, setSyncResult] = useState<SyncResult>({
books: [],
configs: [],
notes: [],
books: null,
configs: null,
notes: null,
});
const [syncedBooks, setSyncedBooks] = useState<Book[]>([]);
const [syncedConfigs, setSyncedConfigs] = useState<BookConfig[]>([]);
const [syncedNotes, setSyncedNotes] = useState<BookNote[]>([]);
const [syncedBooks, setSyncedBooks] = useState<Book[] | null>(null);
const [syncedConfigs, setSyncedConfigs] = useState<BookConfig[] | null>(null);
const [syncedNotes, setSyncedNotes] = useState<BookNote[] | null>(null);
const { syncClient } = useSyncContext();
@@ -76,11 +77,11 @@ export function useSync(bookKey?: string) {
try {
const result = await syncClient.pullChanges(since, type, bookId);
setSyncResult({ ...syncResult, [type]: result[type] });
const records = result[type];
if (!records.length) return;
const maxTime = computeMaxTimestamp(result[type]);
if (!records?.length) return;
const maxTime = computeMaxTimestamp(records);
setLastSyncedAt(maxTime);
setSyncResult(result);
switch (type) {
case 'books':
settings.lastSyncedAtBooks = maxTime;
@@ -169,16 +170,18 @@ export function useSync(bookKey?: string) {
useEffect(() => {
if (!syncing && syncResult) {
const { books: dbBooks, configs: dbBookConfigs, notes: dbBookNotes } = syncResult;
const books = dbBooks.map((dbBook) => transformsFromDB['books'](dbBook as unknown as DBBook));
const configs = dbBookConfigs.map((dbBookConfig) =>
const books = dbBooks?.map((dbBook) =>
transformsFromDB['books'](dbBook as unknown as DBBook),
);
const configs = dbBookConfigs?.map((dbBookConfig) =>
transformsFromDB['configs'](dbBookConfig as unknown as DBBookConfig),
);
const notes = dbBookNotes.map((dbBookNote) =>
const notes = dbBookNotes?.map((dbBookNote) =>
transformsFromDB['notes'](dbBookNote as unknown as DBBookNote),
);
if (books.length) setSyncedBooks(books);
if (configs.length) setSyncedConfigs(configs);
if (notes.length) setSyncedNotes(notes);
if (books) setSyncedBooks(books);
if (configs) setSyncedConfigs(configs);
if (notes) setSyncedNotes(notes);
}
}, [syncResult, syncing]);
+204
View File
@@ -0,0 +1,204 @@
import { md5 } from 'js-md5';
import { randomMd5 } from '@/utils/misc';
import { LRUCache } from '@/utils/lru';
const EDGE_SPEECH_URL =
'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1';
const EDGE_API_TOKEN = '6A5AA1D4EAFF4E9FB37E23D68491D6F4';
const EDGE_TTS_VOICES = {
'ar-SA': ['ar-SA-HamedNeural', 'ar-SA-ZariyahNeural'],
'de-DE': ['de-DE-AmalaNeural', 'de-DE-ConradNeural', 'de-DE-KatjaNeural', 'de-DE-KillianNeural'],
'en-US': [
'en-US-AriaNeural',
'en-US-AnaNeural',
'en-US-ChristopherNeural',
'en-US-EricNeural',
'en-US-GuyNeural',
'en-US-JennyNeural',
'en-US-MichelleNeural',
'en-US-RogerNeural',
'en-US-SteffanNeural',
],
'es-ES': ['es-ES-AlvaroNeural', 'es-ES-ElviraNeural'],
'fr-FR': ['fr-FR-DeniseNeural', 'fr-FR-EloiseNeural', 'fr-FR-HenriNeural'],
'ja-JP': ['ja-JP-KeitaNeural', 'ja-JP-NanamiNeural'],
'ko-KR': ['ko-KR-InJoonNeural', 'ko-KR-SunHiNeural'],
'pt-BR': ['pt-BR-AntonioNeural', 'pt-BR-FranciscaNeural'],
'ru-RU': ['ru-RU-DmitryNeural', 'ru-RU-SvetlanaNeural'],
'zh-CN': [
'zh-CN-XiaoxiaoNeural',
'zh-CN-XiaoyiNeural',
'zh-CN-YunjianNeural',
'zh-CN-liaoning-XiaobeiNeural',
'zh-CN-shaanxi-XiaoniNeural',
'zh-CN-YunxiNeural',
'zh-CN-YunxiaNeural',
'zh-CN-YunyangNeural',
],
'zh-TW': ['zh-TW-HsiaoChenNeural', 'zh-TW-YunJheNeural', 'zh-TW-HsiaoYuNeural'],
};
const genVoiceList = (voices: Record<string, string[]>) => {
return Object.entries(voices).flatMap(([lang, voices]) => {
return voices.map((id) => {
const name = id.replace(`${lang}-`, '').replace('Neural', '');
return { name, id, lang };
});
});
};
export interface EdgeTTSPayload {
lang: string;
text: string;
voice: string;
rate: number;
pitch: number;
}
const hashPayload = (payload: EdgeTTSPayload): string => {
const base = JSON.stringify(payload);
return md5(base);
};
export class EdgeSpeechTTS {
static voices = genVoiceList(EDGE_TTS_VOICES);
private static audioCache = new LRUCache<string, AudioBuffer>(200);
constructor() {}
async #fetchEdgeSpeechWs({ lang, text, voice, rate }: EdgeTTSPayload): Promise<Response> {
const connectId = randomMd5();
const url = `${EDGE_SPEECH_URL}?ConnectionId=${connectId}&TrustedClientToken=${EDGE_API_TOKEN}`;
const date = new Date().toString();
const configHeaders = {
'Content-Type': 'application/json; charset=utf-8',
Path: 'speech.config',
'X-Timestamp': date,
};
const contentHeaders = {
'Content-Type': 'application/ssml+xml',
Path: 'ssml',
'X-RequestId': connectId,
'X-Timestamp': date,
};
const configContent = JSON.stringify({
context: {
synthesis: {
audio: {
metadataoptions: { sentenceBoundaryEnabled: false, wordBoundaryEnabled: true },
outputFormat: 'audio-24khz-48kbitrate-mono-mp3',
},
},
},
});
const genSSML = (lang: string, text: string, voice: string, rate: number) => {
return `
<speak version="1.0" xml:lang="${lang}">
<voice name="${voice}">
<prosody rate="${rate}">
${text}
</prosody>
</voice>
</speak>
`;
};
const genSendContent = (headerObj: Record<string, string>, content: string) => {
let header = '';
for (const key of Object.keys(headerObj)) {
header += `${key}: ${headerObj[key]}\r\n`;
}
return `${header}\r\n${content}`;
};
const getHeadersAndData = (message: string) => {
const lines = message.split('\n');
const headers: Record<string, string> = {};
let body = '';
let lineIdx = 0;
for (lineIdx = 0; lineIdx < lines.length; lineIdx++) {
const line = lines[lineIdx]!.trim();
if (!line) break;
const separatorIndex = line.indexOf(':');
if (separatorIndex === -1) continue;
const key = line.slice(0, separatorIndex).trim();
const value = line.slice(separatorIndex + 1).trim();
headers[key] = value;
}
for (lineIdx = lineIdx + 1; lineIdx < lines.length; lineIdx++) {
body += lines[lineIdx] + '\n';
}
return { headers, body };
};
const ssml = genSSML(lang, text, voice, rate);
const content = genSendContent(contentHeaders, ssml);
const config = genSendContent(configHeaders, configContent);
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
let audioData = new ArrayBuffer(0);
ws.addEventListener('open', () => {
ws.send(config);
ws.send(content);
});
ws.addEventListener('message', (event: MessageEvent) => {
if (typeof event.data === 'string') {
const { headers } = getHeadersAndData(event.data);
if (headers['Path'] === 'turn.end') {
ws.close();
if (!audioData.byteLength) {
return reject(new Error('No audio data received.'));
}
const res = new Response(audioData);
resolve(res);
}
} else if (event.data instanceof ArrayBuffer) {
const dataView = new DataView(event.data);
const headerLength = dataView.getInt16(0);
if (event.data.byteLength > headerLength + 2) {
const newBody = event.data.slice(2 + headerLength);
const merged = new Uint8Array(audioData.byteLength + newBody.byteLength);
merged.set(new Uint8Array(audioData), 0);
merged.set(new Uint8Array(newBody), audioData.byteLength);
audioData = merged.buffer;
}
}
});
ws.addEventListener('error', () => {
ws.close();
reject(new Error('WebSocket error occurred.'));
});
});
}
async create(payload: EdgeTTSPayload): Promise<Response> {
return this.#fetchEdgeSpeechWs(payload);
}
async createAudio(payload: EdgeTTSPayload): Promise<AudioBuffer> {
const cacheKey = hashPayload(payload);
if (EdgeSpeechTTS.audioCache.has(cacheKey)) {
return EdgeSpeechTTS.audioCache.get(cacheKey)!;
}
try {
const res = await this.create(payload);
const arrayBuffer = await res.arrayBuffer();
const audioContext = new AudioContext();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer.slice(0));
EdgeSpeechTTS.audioCache.set(cacheKey, audioBuffer);
return audioBuffer;
} catch (error) {
throw error;
}
}
}
+9 -3
View File
@@ -18,9 +18,9 @@ interface BookConfigRecord extends BookDataRecord, BookConfig {}
interface BookNoteRecord extends BookDataRecord, BookNote {}
export interface SyncResult {
books: BookRecord[];
notes: BookNoteRecord[];
configs: BookConfigRecord[];
books: BookRecord[] | null;
notes: BookNoteRecord[] | null;
configs: BookConfigRecord[] | null;
}
export interface SyncData {
@@ -79,6 +79,12 @@ export class SyncClient {
}
private async getAccessToken(): 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
// making the access token invalid for API calls. In that case we should use localStorage.
if (isWebAppPlatform()) {
return localStorage.getItem('token') ?? null;
}
const { data } = await supabase.auth.getSession();
return data?.session?.access_token ?? null;
}
@@ -25,6 +25,7 @@ import {
DEFAULT_READSETTINGS,
SYSTEM_SETTINGS_VERSION,
DEFAULT_BOOK_SEARCH_CONFIG,
DEFAULT_TTS_CONFIG,
} from './constants';
import { isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
@@ -68,6 +69,7 @@ export abstract class BaseAppService implements AppService {
...DEFAULT_BOOK_STYLE,
...DEFAULT_BOOK_FONT,
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
...settings.globalViewSettings,
};
} catch {
@@ -84,6 +86,7 @@ export abstract class BaseAppService implements AppService {
...DEFAULT_BOOK_STYLE,
...DEFAULT_BOOK_FONT,
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
},
};
+14 -1
View File
@@ -1,4 +1,11 @@
import { BookFont, BookLayout, BookSearchConfig, BookStyle, ViewConfig } from '@/types/book';
import {
BookFont,
BookLayout,
BookSearchConfig,
BookStyle,
TTSConfig,
ViewConfig,
} from '@/types/book';
import { ReadSettings } from '@/types/settings';
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
@@ -42,6 +49,7 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
maxInlineSize: 720,
maxBlockSize: 1440,
animated: false,
writingMode: 'auto',
vertical: false,
};
@@ -60,6 +68,11 @@ export const DEFAULT_VIEW_CONFIG: ViewConfig = {
sideBarTab: 'toc',
};
export const DEFAULT_TTS_CONFIG: TTSConfig = {
ttsRate: 1.0,
ttsVoice: '',
};
export const DEFAULT_BOOK_SEARCH_CONFIG: BookSearchConfig = {
scope: 'book',
matchCase: false,
@@ -0,0 +1,199 @@
import { getUserLocale } from '@/utils/misc';
import { TTSClient, TTSMessageEvent, TTSVoice } from './TTSClient';
import { EdgeSpeechTTS, EdgeTTSPayload } from '@/libs/edgeTTS';
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
import { TTSGranularity } from '@/types/view';
export class EdgeTTSClient implements TTSClient {
#rate = 1.0;
#pitch = 1.0;
#voice: TTSVoice | null = null;
#voices: TTSVoice[] = [];
#edgeTTS: EdgeSpeechTTS;
#audioContext: AudioContext | null = null;
#sourceNode: AudioBufferSourceNode | null = null;
#isPlaying = false;
#pausedAt = 0;
#startedAt = 0;
#audioBuffer: AudioBuffer | null = null;
available = true;
constructor() {
this.#edgeTTS = new EdgeSpeechTTS();
}
async init() {
this.#voices = EdgeSpeechTTS.voices;
try {
await this.#edgeTTS.create({
lang: 'en',
text: 'test',
voice: 'en-US-AriaNeural',
rate: 1.0,
pitch: 1.0,
});
this.available = true;
} catch {
this.available = false;
}
return this.available;
}
getPayload = (lang: string, text: string, voiceId: string) => {
return { lang, text, voice: voiceId, rate: this.#rate, pitch: this.#pitch } as EdgeTTSPayload;
};
async *speak(ssml: string): AsyncGenerator<TTSMessageEvent> {
const { marks } = parseSSMLMarks(ssml);
const lang = parseSSMLLang(ssml) || 'en';
let voiceId = 'en-US-AriaNeural';
if (!this.#voice) {
const voices = await this.getVoices(lang);
this.#voice = voices[0] ? voices[0] : this.#voices.find((v) => v.id === voiceId) || null;
}
if (this.#voice) {
voiceId = this.#voice.id;
}
this.stopInternal();
// Preloading for longer ssml
if (marks.length > 1) {
for (const mark of marks.slice(1)) {
this.#edgeTTS.createAudio(this.getPayload(lang, mark.text, voiceId)).catch((error) => {
console.warn('Error preloading mark:', mark, error);
});
}
}
for (const mark of marks) {
try {
this.#audioBuffer = await this.#edgeTTS.createAudio(
this.getPayload(lang, mark.text, voiceId),
);
this.#audioContext = new AudioContext();
this.#sourceNode = this.#audioContext.createBufferSource();
this.#sourceNode.buffer = this.#audioBuffer;
this.#sourceNode.connect(this.#audioContext.destination);
yield {
code: 'boundary',
message: `Start chunk: ${mark.name}`,
mark: mark.name,
};
const result = await new Promise<TTSMessageEvent>((resolve) => {
if (this.#audioContext === null || this.#sourceNode === null) {
throw new Error('Audio context or source node is null');
}
this.#sourceNode.onended = () => {
this.#isPlaying = false;
resolve({
code: 'end',
message: `Chunk finished: ${mark.name}`,
});
};
this.#sourceNode.start(0);
this.#isPlaying = true;
this.#startedAt = this.#audioContext.currentTime;
});
yield result;
} catch (error) {
if (error instanceof Error && error.message === 'No audio data received.') {
console.warn('No audio data received for:', mark.text);
yield {
code: 'end',
message: `Chunk finished: ${mark.name}`,
};
continue;
}
console.log('Error:', error);
yield {
code: 'error',
message: error instanceof Error ? error.message : String(error),
};
break;
}
this.stopInternal();
}
}
async pause() {
if (!this.#isPlaying || !this.#audioContext || !this.#sourceNode) return;
this.#pausedAt = this.#audioContext.currentTime - this.#startedAt;
await this.#audioContext.suspend();
this.#isPlaying = false;
}
async resume() {
if (this.#isPlaying || !this.#audioContext || !this.#sourceNode) return;
await this.#audioContext.resume();
this.#isPlaying = true;
this.#startedAt = this.#audioContext.currentTime - this.#pausedAt;
}
async stop() {
this.stopInternal();
}
private stopInternal() {
this.#isPlaying = false;
this.#pausedAt = 0;
this.#startedAt = 0;
if (this.#sourceNode) {
try {
this.#sourceNode.stop();
} catch (err) {
console.error('Error stopping source node:', err);
}
this.#sourceNode.disconnect();
this.#sourceNode = null;
}
if (this.#audioContext) {
this.#audioContext.close();
this.#audioContext = null;
}
this.#audioBuffer = null;
}
async setRate(rate: number) {
// The Edge TTS API uses rate in [0.5 .. 2.0].
this.#rate = rate;
}
async setPitch(pitch: number) {
// The Edge TTS API uses pitch in [0.5 .. 1.5].
this.#pitch = pitch;
}
async setVoice(voice: string) {
const selectedVoice = this.#voices.find((v) => v.id === voice);
if (selectedVoice) {
this.#voice = selectedVoice;
}
}
async getAllVoices(): Promise<TTSVoice[]> {
this.#voices.forEach((voice) => {
voice.disabled = !this.available;
});
return this.#voices;
}
async getVoices(lang: string): Promise<TTSVoice[]> {
const locale = lang === 'en' ? getUserLocale(lang) || lang : lang;
const voices = await this.getAllVoices();
return voices.filter((v) => v.lang.startsWith(locale));
}
getGranularities(): TTSGranularity[] {
return ['sentence'];
}
getVoiceId(): string {
return this.#voice?.id || '';
}
}
@@ -0,0 +1,31 @@
import { TTSGranularity } from '@/types/view';
export type TTSMessageCode = 'boundary' | 'error' | 'end';
export interface TTSMessageEvent {
code: TTSMessageCode;
message?: string;
mark?: string;
}
export interface TTSVoice {
id: string;
name: string;
lang: string;
disabled?: boolean;
}
export interface TTSClient {
init(): Promise<boolean>;
speak(ssml: string): AsyncIterable<TTSMessageEvent>;
pause(): Promise<void>;
resume(): Promise<void>;
stop(): Promise<void>;
setRate(rate: number): Promise<void>;
setPitch(pitch: number): Promise<void>;
setVoice(voice: string): Promise<void>;
getAllVoices(): Promise<TTSVoice[]>;
getVoices(lang: string): Promise<TTSVoice[]>;
getGranularities(): TTSGranularity[];
getVoiceId(): string;
}
@@ -0,0 +1,179 @@
import { FoliateView, TTSGranularity } from '@/types/view';
import { TTSClient, TTSMessageCode, TTSVoice } from './TTSClient';
import { WebSpeechClient } from './WebSpeechClient';
import { EdgeTTSClient } from './EdgeTTSClient';
type TTSState =
| 'stopped'
| 'playing'
| 'paused'
| 'backward-paused'
| 'forward-paused'
| 'setvoice-paused';
export class TTSController extends EventTarget {
state: TTSState = 'stopped';
view: FoliateView;
#nossmlCnt: number = 0;
ttsClient: TTSClient;
ttsWebClient: TTSClient;
ttsEdgeClient: TTSClient;
ttsWebVoices: TTSVoice[] = [];
ttsEdgeVoices: TTSVoice[] = [];
constructor(view: FoliateView) {
super();
this.ttsWebClient = new WebSpeechClient();
this.ttsEdgeClient = new EdgeTTSClient();
this.ttsClient = this.ttsWebClient;
this.view = view;
}
async init() {
await this.ttsWebClient.init();
const success = await this.ttsEdgeClient.init();
if (success) {
this.ttsClient = this.ttsEdgeClient;
} else {
this.ttsClient = this.ttsWebClient;
}
this.ttsWebVoices = await this.ttsWebClient.getAllVoices();
this.ttsEdgeVoices = await this.ttsEdgeClient.getAllVoices();
}
async initViewTTS() {
let granularity: TTSGranularity = this.view.language.isCJK ? 'sentence' : 'word';
const supportedGranularities = this.ttsClient.getGranularities();
if (!supportedGranularities.includes(granularity)) {
granularity = supportedGranularities[0]!;
}
await this.ttsClient.stop();
await this.view.initTTS(granularity);
}
async #speak(ssml: string | undefined | Promise<string>) {
console.log('TTS speak');
this.state = 'playing';
ssml = await ssml;
if (!ssml) {
this.#nossmlCnt++;
// FIXME: in case we are at the end of the book, need a better way to handle this
if (this.#nossmlCnt < 10 && this.state === 'playing') {
await this.view.next(1);
this.forward();
}
return;
} else {
this.#nossmlCnt = 0;
}
const iter = await this.ttsClient.speak(ssml);
let lastCode: TTSMessageCode = 'boundary';
for await (const { code, mark } of iter) {
if (mark && this.state === 'playing') {
this.view.tts?.setMark(mark);
}
lastCode = code;
}
if (lastCode === 'end' && this.state === 'playing') {
this.forward();
}
}
async speak(ssml: string | Promise<string>) {
await this.initViewTTS();
this.#speak(ssml).catch((e) => this.error(e));
}
play() {
if (this.state !== 'playing') {
this.start();
} else {
this.pause();
}
}
async start() {
await this.initViewTTS();
const resumeOrStart = this.state.includes('paused')
? this.view.tts?.resume()
: this.view.tts?.start();
return this.#speak(resumeOrStart);
}
pause() {
this.state = 'paused';
this.ttsClient.pause().catch((e) => this.error(e));
}
resume() {
this.state = 'playing';
this.ttsClient.resume().catch((e) => this.error(e));
}
stop() {
this.state = 'stopped';
this.ttsClient.stop().catch((e) => this.error(e));
}
// goto previous sentence
async backward() {
this.initViewTTS();
if (this.state === 'playing') {
this.stop();
this.#speak(this.view.tts?.prev());
} else {
this.state = 'backward-paused';
this.view.tts?.prev(true);
}
}
// goto next sentence
async forward() {
await this.initViewTTS();
if (this.state === 'playing') {
this.stop();
this.#speak(this.view.tts?.next());
} else {
this.state = 'forward-paused';
this.view.tts?.next(true);
}
}
async setRate(rate: number) {
this.ttsClient.setRate(rate);
}
async getVoices(lang: string) {
const ttsWebVoices = await this.ttsWebClient.getVoices(lang);
const ttsEdgeVoices = await this.ttsEdgeClient.getVoices(lang);
return [...ttsEdgeVoices, ...ttsWebVoices];
}
async setVoice(voiceId: string) {
this.state = 'setvoice-paused';
this.ttsClient.stop();
if (this.ttsEdgeVoices.find((voice) => voice.id === voiceId && !voice.disabled)) {
this.ttsClient = this.ttsEdgeClient;
} else {
this.ttsClient = this.ttsWebClient;
}
await this.ttsClient.setVoice(voiceId);
}
getVoiceId() {
return this.ttsClient.getVoiceId();
}
error(e: unknown) {
console.error(e);
this.state = 'stopped';
}
kill() {
this.state = 'stopped';
this.ttsClient.stop();
}
}
@@ -0,0 +1,306 @@
import { getUserLocale } from '@/utils/misc';
import { TTSClient, TTSMessageEvent, TTSVoice } from './TTSClient';
import { AsyncQueue } from '@/utils/queue';
import { findSSMLMark, parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
import { TTSGranularity } from '@/types/view';
const BLACKLISTED_VOICES = [
'Albert',
'Bad News',
'Bahh',
'Bells',
'Boing',
'Bubbles',
'Cellos',
'Eddy',
'Flo',
'Fred',
'Good News',
'Grandma',
'Grandpa',
'Jester',
'Junior',
'Kathy',
'Organ',
'Ralph',
'Reed',
'Rocko',
'Sandy',
'Shelley',
'Superstar',
'Trinoids',
'Whisper',
'Wobble',
'Zarvox',
];
interface TTSBoundaryEvent {
type: 'boundary' | 'end' | 'error';
speaking: boolean;
name?: string;
mark?: string;
charIndex?: number;
charLength?: number;
error?: string;
}
async function* speakWithBoundary(
ssml: string,
getRate: () => number,
getPitch: () => number,
getVoice: () => SpeechSynthesisVoice | null,
) {
const lang = parseSSMLLang(ssml);
const { plainText, marks } = parseSSMLMarks(ssml);
// console.log('ssml', ssml, marks);
// console.log('text', plainText);
const synth = window.speechSynthesis;
const utterance = new SpeechSynthesisUtterance(plainText);
utterance.rate = getRate();
utterance.pitch = getPitch();
const voice = getVoice();
if (voice) {
utterance.voice = voice;
}
if (lang) {
utterance.lang = lang;
}
const queue = new AsyncQueue<TTSBoundaryEvent>();
utterance.onboundary = (event: SpeechSynthesisEvent) => {
utterance.rate = getRate();
utterance.pitch = getPitch();
const voice = getVoice();
if (voice) {
utterance.voice = voice;
}
const mark = findSSMLMark(event.charIndex, marks);
// console.log('boundary', event.charIndex, mark);
queue.enqueue({
type: 'boundary',
speaking: true,
name: event.name,
mark: mark?.name ?? '',
charIndex: event.charIndex,
charLength: event.charLength,
});
};
utterance.onend = () => {
queue.enqueue({ type: 'end', speaking: false });
queue.finish();
};
utterance.onerror = (event) => {
queue.enqueue({ type: 'error', speaking: false, error: event.error });
queue.finish();
};
synth.speak(utterance);
while (true) {
const ev = await queue.dequeue();
if (ev === null) {
break;
}
yield ev;
}
}
async function* speakWithMarks(
ssml: string,
getRate: () => number,
getPitch: () => number,
getVoice: () => SpeechSynthesisVoice | null,
) {
const { plainText, marks } = parseSSMLMarks(ssml);
const lang = parseSSMLLang(ssml);
const isCJK = (lang: string | null) => {
const cjkLangs = ['zh', 'ja', 'kr'];
if (lang && cjkLangs.some((cjk) => lang.startsWith(cjk))) return true;
return /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(plainText);
};
if (!isCJK(lang)) {
yield* speakWithBoundary(ssml, getRate, getPitch, getVoice);
return;
}
const synth = window.speechSynthesis;
for (const mark of marks) {
const utterance = new SpeechSynthesisUtterance(mark.text);
utterance.rate = getRate();
utterance.pitch = getPitch();
const voice = getVoice();
if (voice) {
utterance.voice = voice;
}
if (lang) {
utterance.lang = lang;
}
yield {
type: 'boundary',
speaking: true,
name: 'sentence',
mark: mark.name,
} as TTSBoundaryEvent;
const result = await new Promise<TTSBoundaryEvent>((resolve) => {
utterance.onend = () => resolve({ type: 'end', speaking: false });
utterance.onerror = (event) =>
resolve({
type: 'error',
speaking: false,
error: event.error,
});
synth.speak(utterance);
});
yield result;
if (result.type === 'error') {
break;
}
}
}
export class WebSpeechClient implements TTSClient {
#rate = 1.0;
#pitch = 1.0;
#voice: SpeechSynthesisVoice | null = null;
#voices: SpeechSynthesisVoice[] = [];
#synth = window.speechSynthesis;
available = true;
async init() {
if (!this.#synth) {
this.available = false;
return this.available;
}
await new Promise<void>((resolve) => {
const populateVoices = () => {
this.#voices = this.#synth.getVoices();
// console.log('Voices', this.#voices);
if (this.#voices.length > 0) {
resolve();
}
};
if (this.#synth.getVoices().length > 0) {
populateVoices();
} else if (this.#synth.onvoiceschanged !== undefined) {
this.#synth.onvoiceschanged = populateVoices;
} else {
console.warn('Voiceschanged event not supported.');
resolve();
}
});
return this.available;
}
async *speak(ssml: string): AsyncGenerator<TTSMessageEvent> {
const lang = parseSSMLLang(ssml) || 'en';
if (!this.#voice) {
const voices = await this.getVoices(lang);
const voiceId = voices[0]?.id ?? '';
this.#voice = this.#voices.find((v) => v.voiceURI === voiceId) || null;
}
for await (const ev of speakWithMarks(
ssml,
() => this.#rate,
() => this.#pitch,
() => this.#voice,
)) {
if (ev.type === 'boundary') {
yield {
code: 'boundary',
mark: ev.mark ?? '',
message: `${ev.name ?? 'Unknown'} ${ev.charIndex ?? 0}/${ev.charLength ?? 0}`,
} as TTSMessageEvent;
} else if (ev.type === 'error') {
yield { code: 'error', message: ev.error ?? 'Unknown error' } as TTSMessageEvent;
} else if (ev.type === 'end') {
yield { code: 'end', message: 'Speech finished' } as TTSMessageEvent;
}
}
}
async pause() {
this.#synth.pause();
}
async resume() {
this.#synth.resume();
}
async stop() {
this.#synth.cancel();
}
async setRate(rate: number) {
// The Web Speech API uses utterance.rate in [0.1 .. 10],
this.#rate = rate;
}
async setPitch(pitch: number) {
// The Web Speech API uses pitch in [0 .. 2].
this.#pitch = pitch;
}
async setVoice(voiceId: string) {
const selectedVoice = this.#voices.find((v) => v.voiceURI === voiceId);
if (selectedVoice) {
this.#voice = selectedVoice;
}
}
async getAllVoices(): Promise<TTSVoice[]> {
const voices = this.#voices.map((voice) => {
return {
id: voice.voiceURI,
name: voice.name,
lang: voice.lang,
disabled: !this.available,
} as TTSVoice;
});
return voices;
}
async getVoices(lang: string) {
const locale = lang === 'en' ? getUserLocale(lang) || lang : lang;
const isValidVoice = (id: string) => {
return !id.includes('com.apple') || id.includes('com.apple.voice.compact');
};
const isNotBlacklisted = (voice: SpeechSynthesisVoice) => {
return BLACKLISTED_VOICES.some((name) => voice.name.includes(name)) === false;
};
const filteredVoices = this.#voices
.filter((voice) => voice.lang.startsWith(locale))
.filter((voice) => isValidVoice(voice.voiceURI || ''))
.filter(isNotBlacklisted);
const voices = filteredVoices.map((voice) => {
return { id: voice.voiceURI, name: voice.name, lang: voice.lang } as TTSVoice;
});
voices.forEach((voice) => {
voice.disabled = !this.available;
});
return voices;
}
getGranularities(): TTSGranularity[] {
// currently only support sentence boundary and disable word boundary as changing voice
// in the middle of speech is not possible for different granularities
return ['sentence'];
}
getVoiceId(): string {
return this.#voice?.voiceURI ?? '';
}
}
@@ -0,0 +1,4 @@
export * from './TTSClient';
export * from './WebSpeechClient';
export * from './EdgeTTSClient';
export * from './TTSController';
+1
View File
@@ -6,6 +6,7 @@
--background: #ffffff;
--foreground: #171717;
border-radius: 10px;
scrollbar-gutter: auto !important;
}
@media (prefers-color-scheme: dark) {
+7 -1
View File
@@ -51,6 +51,7 @@ export interface BookLayout {
maxInlineSize: number;
maxBlockSize: number;
animated: boolean;
writingMode: string;
vertical: boolean;
}
@@ -79,7 +80,12 @@ export interface ViewConfig {
sideBarTab: string;
}
export interface ViewSettings extends BookLayout, BookStyle, BookFont, ViewConfig {}
export interface TTSConfig {
ttsRate: number;
ttsVoice: string;
}
export interface ViewSettings extends BookLayout, BookStyle, BookFont, ViewConfig, TTSConfig {}
export interface BookProgress {
location: string;
+9
View File
@@ -1,5 +1,8 @@
import { BookDoc } from '@/libs/document';
import { BookNote, BookSearchConfig, BookSearchResult } from '@/types/book';
import { TTS } from 'foliate-js/tts.js';
export type TTSGranularity = 'sentence' | 'word';
export interface FoliateView extends HTMLElement {
open: (book: BookDoc) => Promise<void>;
@@ -17,6 +20,12 @@ export interface FoliateView extends HTMLElement {
clearSearch: () => void;
select: (target: string | number | { fraction: number }) => void;
deselect: () => void;
initTTS: (granularity?: TTSGranularity) => Promise<void>;
tts: TTS | null;
language: {
locale?: string;
isCJK?: boolean;
};
history: {
canGoBack: boolean;
canGoForward: boolean;
+1 -1
View File
@@ -54,7 +54,7 @@ export const listFormater = (narrow = false, lang = userLang) => {
}
};
const getBookLangCode = (lang: string | string[] | undefined) => {
export const getBookLangCode = (lang: string | string[] | undefined) => {
try {
const bookLang = typeof lang === 'string' ? lang : lang?.[0];
return bookLang ? bookLang.split('-')[0]! : 'en';
+77
View File
@@ -0,0 +1,77 @@
const cssValidate = (css: string) => {
// Remove comments and normalize whitespace
css = css.replace(/\/\*[\s\S]*?\*\//g, '').trim();
// CSS property pattern (validate both property name and value)
const propertyPattern = /^[\s\n]*[-\w]+\s*:\s*[^;]+;?$/;
// Check if empty
if (!css) return { isValid: false, error: 'Empty CSS' };
// Ensure balanced curly braces
const openBraces = (css.match(/{/g) || []).length;
const closeBraces = (css.match(/}/g) || []).length;
if (openBraces !== closeBraces) {
return { isValid: false, error: 'Unbalanced curly braces' };
}
// Split into rule blocks
const blocks = css
.split('}')
.map((block) => block.trim())
.filter(Boolean);
for (const block of blocks) {
// Ensure the block has a selector and declarations
const parts = block.split('{').map((part) => part.trim());
if (parts.length !== 2) {
return { isValid: false, error: 'Invalid CSS structure' };
}
const [selector, decls] = parts;
// Ensure selector is not empty
if (!selector) {
return { isValid: false, error: 'Missing selector' };
}
// Ensure declarations are not empty
if (!decls) {
return { isValid: false, error: `Missing declarations for selector: ${selector}` };
}
// Validate declarations
const props = decls
.split(';')
.map((prop) => prop.trim())
.filter(Boolean);
if (props.length === 0) {
return { isValid: false, error: `No valid properties for selector: ${selector}` };
}
for (const prop of props) {
// Check if property is missing a name or value
if (!prop.includes(':')) {
return { isValid: false, error: `Missing property or value: ${prop}` };
}
const [name, value] = prop.split(':').map((part) => part.trim());
if (!name) {
return { isValid: false, error: `Missing property name: ${prop}` };
}
if (!value) {
return { isValid: false, error: `Missing property value: ${prop}` };
}
// Validate full property format
if (!propertyPattern.test(prop.endsWith(';') ? prop : prop + ';')) {
return { isValid: false, error: `Invalid property: ${prop}` };
}
}
}
return { isValid: true, error: null };
};
export default cssValidate;
+54
View File
@@ -0,0 +1,54 @@
export class LRUCache<K, V> {
private capacity: number;
private map: Map<K, V>;
constructor(capacity: number) {
if (capacity <= 0) {
throw new Error('LRUCache capacity must be greater than 0');
}
this.capacity = capacity;
this.map = new Map();
}
get(key: K): V | undefined {
if (!this.map.has(key)) {
return undefined;
}
const value = this.map.get(key)!;
this.map.delete(key);
this.map.set(key, value);
return value;
}
set(key: K, value: V): void {
if (this.map.has(key)) {
this.map.delete(key);
} else if (this.map.size === this.capacity) {
const oldestKey = this.map.keys().next().value;
if (oldestKey) {
this.map.delete(oldestKey);
}
}
this.map.set(key, value);
}
has(key: K): boolean {
return this.map.has(key);
}
delete(key: K): boolean {
return this.map.delete(key);
}
clear(): void {
this.map.clear();
}
size(): number {
return this.map.size;
}
entries(): Array<[K, V]> {
return Array.from(this.map).reverse();
}
}
+12
View File
@@ -2,6 +2,8 @@ import { md5 } from 'js-md5';
export const uniqueId = () => Math.random().toString(36).substring(2, 9);
export const randomMd5 = () => md5(Math.random().toString());
export const getContentMd5 = (content: unknown) => md5(JSON.stringify(content));
export const makeSafeFilename = (filename: string, replacement = '_') => {
@@ -25,6 +27,16 @@ export const makeSafeFilename = (filename: string, replacement = '_') => {
export const getUserLang = () => navigator?.language.split('-')[0] || 'en';
export const getUserLocale = (lang: string): string | undefined => {
const languages =
navigator.languages && navigator.languages.length > 0
? navigator.languages
: [navigator.language];
const filteredLocales = languages.filter((locale) => locale.startsWith(lang));
return filteredLocales.length > 0 ? filteredLocales[0] : undefined;
};
export const getOSPlatform = () => {
const userAgent = navigator.userAgent.toLowerCase();
+39
View File
@@ -0,0 +1,39 @@
export class AsyncQueue<T> {
private queue: T[] = [];
private resolves: ((value: void) => void)[] = [];
private done = false;
enqueue(item: T) {
this.queue.push(item);
if (this.resolves.length > 0) {
const resolve = this.resolves.shift();
resolve?.();
}
}
finish() {
this.done = true;
while (this.resolves.length > 0) {
const resolve = this.resolves.shift();
resolve?.();
}
}
async dequeue(): Promise<T | null> {
if (this.queue.length > 0) {
return this.queue.shift()!;
}
if (this.done) {
return null;
}
return new Promise<T | null>((resolve) => {
this.resolves.push(() => {
if (this.queue.length > 0) {
resolve(this.queue.shift()!);
} else {
resolve(null);
}
});
});
}
}
+62
View File
@@ -0,0 +1,62 @@
type TTSMark = {
offset: number;
name: string;
text: string;
};
export const parseSSMLMarks = (ssml: string) => {
ssml = ssml.replace(/<speak[^>]*>/i, '');
ssml = ssml.replace(/<\/speak>/i, '');
const markRegex = /<mark\s+name="([^"]+)"\s*\/>/g;
let plainText = '';
const marks: TTSMark[] = [];
let match;
while ((match = markRegex.exec(ssml)) !== null) {
const markTagEndIndex = markRegex.lastIndex;
const nextMarkIndex = ssml.indexOf('<mark', markTagEndIndex);
const nextChunk = ssml.slice(
markTagEndIndex,
nextMarkIndex !== -1 ? nextMarkIndex : ssml.length,
);
const cleanedChunk = nextChunk
.replace(/<[^>]+>/g, '')
.replace(/\r\n/g, ' ')
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.trimStart();
plainText += cleanedChunk;
const offset = plainText.length - cleanedChunk.length;
const markName = match[1]!;
marks.push({ offset, name: markName, text: cleanedChunk });
}
return { plainText, marks };
};
export const findSSMLMark = (charIndex: number, marks: TTSMark[]) => {
let left = 0;
let right = marks.length - 1;
let result: TTSMark | null = null;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const mark = marks[mid]!;
if (mark.offset <= charIndex) {
result = mark;
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
};
export const parseSSMLLang = (ssml: string): string | null => {
const match = ssml.match(/xml:lang\s*=\s*"([^"]+)"/);
return match ? match[1]! : null;
};
+11 -4
View File
@@ -35,7 +35,8 @@ const getFontStyles = (
const getAdditionalFontFaces = () => `
@font-face {
font-family: "FangSong";
src: url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot");
font-display: swap;
src: local("Fang Song"), local("FangSong"), local("Noto Serif CJK"), url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot");
src: url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot?#iefix") format("embedded-opentype"),
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff2") format("woff2"),
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff") format("woff"),
@@ -44,7 +45,8 @@ const getAdditionalFontFaces = () => `
}
@font-face {
font-family: "Kaiti";
src: url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot");
font-display: swap;
src: local("Kai"), local("KaiTi"), local("AR PL UKai"), url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot");
src: url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot?#iefix")format("embedded-opentype"),
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff2")format("woff2"),
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff")format("woff"),
@@ -53,7 +55,8 @@ const getAdditionalFontFaces = () => `
}
@font-face {
font-family: "Heiti";
src: url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot");
font-display: swap;
src: local("Hei"), local("SimHei"), local("WenQuanYi Zen Hei"), url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot");
src: url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot?#iefix")format("embedded-opentype"),
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff2")format("woff2"),
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff")format("woff"),
@@ -62,7 +65,8 @@ const getAdditionalFontFaces = () => `
}
@font-face {
font-family: "XiHeiti";
src: url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.eot");
font-display: swap;
src: local("PingFang SC"), local("Microsoft YaHei"), local("WenQuanYi Micro Hei"), url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.eot");
src: url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.eot?#iefix")format("embedded-opentype"),
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.woff2")format("woff2"),
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.woff")format("woff"),
@@ -76,6 +80,7 @@ const getLayoutStyles = (
justify: boolean,
hyphenate: boolean,
zoomLevel: number,
writingMode: string,
bg: string,
fg: string,
primary: string,
@@ -122,6 +127,7 @@ const getLayoutStyles = (
}
html, body {
color: ${fg};
${writingMode === 'auto' ? '' : `writing-mode: ${writingMode};`}
text-align: var(--default-text-align);
background-color: var(--theme-bg-color, transparent);
background: var(--background-set, none);
@@ -182,6 +188,7 @@ export const getStyles = (viewSettings: ViewSettings, themeCode: ThemeCode) => {
viewSettings.fullJustification!,
viewSettings.hyphenation!,
viewSettings.zoomLevel! / 100.0,
viewSettings.writingMode!,
themeCode.bg,
themeCode.fg,
themeCode.primary,
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+38 -28
View File
@@ -50,6 +50,9 @@ importers:
'@tauri-apps/plugin-cli':
specifier: ^2.2.0
version: 2.2.0
'@tauri-apps/plugin-deep-link':
specifier: ^2.2.0
version: 2.2.0
'@tauri-apps/plugin-dialog':
specifier: ^2.2.0
version: 2.2.0
@@ -108,8 +111,8 @@ importers:
specifier: 15.1.0
version: 15.1.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
posthog-js:
specifier: ^1.194.1
version: 1.194.1
specifier: ^1.205.0
version: 1.205.0
react:
specifier: 19.0.0
version: 19.0.0
@@ -120,8 +123,8 @@ importers:
specifier: ^15.2.0
version: 15.2.0(i18next@24.2.0(typescript@5.7.2))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react-icons:
specifier: ^5.3.0
version: 5.3.0(react@19.0.0)
specifier: ^5.4.0
version: 5.4.0(react@19.0.0)
tinycolor2:
specifier: ^1.6.0
version: 1.6.0
@@ -157,8 +160,8 @@ importers:
specifier: ^8.0.0
version: 8.0.0
daisyui:
specifier: ^4.12.14
version: 4.12.14(postcss@8.4.49)
specifier: ^4.12.23
version: 4.12.23(postcss@8.4.49)
dotenv-cli:
specifier: ^7.4.4
version: 7.4.4
@@ -190,8 +193,8 @@ importers:
specifier: ^4.0.2
version: 4.0.2(webpack@5.96.1)
tailwindcss:
specifier: ^3.4.15
version: 3.4.15(ts-node@10.9.2(@types/node@22.10.1)(typescript@5.7.2))
specifier: ^3.4.17
version: 3.4.17(ts-node@10.9.2(@types/node@22.10.1)(typescript@5.7.2))
typescript:
specifier: ^5.7.2
version: 5.7.2
@@ -787,6 +790,9 @@ packages:
'@tauri-apps/plugin-cli@2.2.0':
resolution: {integrity: sha512-rvNhMog9rHr01Xk+trBFKJ0eZICIvPkm9GX6ogB89/0hROU/lf+a/sb4vC0wtSeR7zrJuCSxwxYuvHCZheaYFA==}
'@tauri-apps/plugin-deep-link@2.2.0':
resolution: {integrity: sha512-H6mkxr2KZ3XJcKL44tiq6cOjCw9DL8OgU1xjn3j26Qsn+H/roPFiyhR7CHuB8Ar+sQFj4YVlfmJwtBajK2FETQ==}
'@tauri-apps/plugin-dialog@2.2.0':
resolution: {integrity: sha512-6bLkYK68zyK31418AK5fNccCdVuRnNpbxquCl8IqgFByOgWFivbiIlvb79wpSXi0O+8k8RCSsIpOquebusRVSg==}
@@ -1346,8 +1352,8 @@ packages:
resolution: {integrity: sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
daisyui@4.12.14:
resolution: {integrity: sha512-hA27cdBasdwd4/iEjn+aidoCrRroDuo3G5W9NDKaVCJI437Mm/3eSL/2u7MkZ0pt8a+TrYF3aT2pFVemTS3how==}
daisyui@4.12.23:
resolution: {integrity: sha512-EM38duvxutJ5PD65lO/AFMpcw+9qEy6XAZrTpzp7WyaPeO/l+F/Qiq0ECHHmFNcFXh5aVoALY4MGrrxtCiaQCQ==}
engines: {node: '>=16.9.0'}
damerau-levenshtein@1.0.8:
@@ -2153,14 +2159,14 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lilconfig@2.1.0:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'}
lilconfig@3.1.2:
resolution: {integrity: sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==}
engines: {node: '>=14'}
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -2543,8 +2549,8 @@ packages:
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
engines: {node: ^10 || ^12 || >=14}
posthog-js@1.194.1:
resolution: {integrity: sha512-d68hmU9DY4iPe3WneBlnglERhimRhXuF7Lx0Au6OTmOL+IFdFUxB3Qf5LaLqJc1QLt3NUolMq1HiXOaIULe3kQ==}
posthog-js@1.205.0:
resolution: {integrity: sha512-zP4SQ9Dg9JwqkEteoAOviAAAMdT/nJ4vk1jqfE6fVudziEa3szkQWd7czk5ehlEdrKFUE85MonCKW4L/uwtybA==}
preact@10.25.0:
resolution: {integrity: sha512-6bYnzlLxXV3OSpUxLdaxBmE7PMOu0aR3pG6lryK/0jmvcDFPlcXGQAt5DpK3RITWiDrfYZRI0druyaK/S9kYLg==}
@@ -2677,8 +2683,8 @@ packages:
react-native:
optional: true
react-icons@5.3.0:
resolution: {integrity: sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==}
react-icons@5.4.0:
resolution: {integrity: sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==}
peerDependencies:
react: '*'
@@ -2974,8 +2980,8 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
tailwindcss@3.4.15:
resolution: {integrity: sha512-r4MeXnfBmSOuKUWmXe6h2CcyfzJCEk4F0pptO5jlnYSIViUkVmsawj80N5h2lO3gwcmSb4n3PuN+e+GC1Guylw==}
tailwindcss@3.4.17:
resolution: {integrity: sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==}
engines: {node: '>=14.0.0'}
hasBin: true
@@ -3741,6 +3747,10 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.1.1
'@tauri-apps/plugin-deep-link@2.2.0':
dependencies:
'@tauri-apps/api': 2.1.1
'@tauri-apps/plugin-dialog@2.2.0':
dependencies:
'@tauri-apps/api': 2.1.1
@@ -4395,7 +4405,7 @@ snapshots:
culori@3.3.0: {}
daisyui@4.12.14(postcss@8.4.49):
daisyui@4.12.23(postcss@8.4.49):
dependencies:
css-selector-tokenizer: 0.8.0
culori: 3.3.0
@@ -5334,10 +5344,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lilconfig@2.1.0: {}
lilconfig@3.1.2: {}
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
loader-runner@4.3.0: {}
@@ -5645,7 +5655,7 @@ snapshots:
postcss-load-config@4.0.2(postcss@8.4.49)(ts-node@10.9.2(@types/node@22.10.1)(typescript@5.7.2)):
dependencies:
lilconfig: 3.1.2
lilconfig: 3.1.3
yaml: 2.6.1
optionalDependencies:
postcss: 8.4.49
@@ -5699,7 +5709,7 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
posthog-js@1.194.1:
posthog-js@1.205.0:
dependencies:
core-js: 3.39.0
fflate: 0.4.8
@@ -5792,7 +5802,7 @@ snapshots:
optionalDependencies:
react-dom: 19.0.0(react@19.0.0)
react-icons@5.3.0(react@19.0.0):
react-icons@5.4.0(react@19.0.0):
dependencies:
react: 19.0.0
@@ -6170,7 +6180,7 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
tailwindcss@3.4.15(ts-node@10.9.2(@types/node@22.10.1)(typescript@5.7.2)):
tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.10.1)(typescript@5.7.2)):
dependencies:
'@alloc/quick-lru': 5.2.0
arg: 5.0.2
@@ -6181,7 +6191,7 @@ snapshots:
glob-parent: 6.0.2
is-glob: 4.0.3
jiti: 1.21.6
lilconfig: 2.1.0
lilconfig: 3.1.3
micromatch: 4.0.8
normalize-path: 3.0.0
object-hash: 3.0.0