forked from akai/readest
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 443809f5af | |||
| c009edca61 | |||
| 58c907c7cc | |||
| 5ca173818b | |||
| f64b5606f1 | |||
| 010568eeb1 | |||
| d24f737428 | |||
| 1635958ae3 | |||
| c8f0c09426 | |||
| 60a9eb5b57 | |||
| 46fdea3522 | |||
| ba82203629 | |||
| 8dbcf25331 | |||
| 7e9ddcd7cc | |||
| 211fc2cd22 | |||
| 3578d7eba8 | |||
| d220adbfdf | |||
| 752f4449b4 | |||
| 66f369b269 | |||
| 2251d65de4 | |||
| 463ea39467 | |||
| 87bc842525 | |||
| 2ad67a0503 | |||
| 9f894e1d9e | |||
| cc97a1b36f | |||
| 4e42ec4a44 | |||
| 00003a9415 | |||
| 7402141237 | |||
| e0323bb0ed | |||
| 112c5fa979 | |||
| 86dc32dab3 | |||
| 33d4dcd740 | |||
| 36d6074468 | |||
| cc1396876e | |||
| f26de3ab0d | |||
| 814a0032d0 | |||
| c6defb97ee | |||
| ddee347eb3 | |||
| 83b10a1df8 | |||
| 3e43905321 | |||
| 89b2eba2af | |||
| 15d8d61841 | |||
| 07b04b82e6 | |||
| a59a3b633b | |||
| cccd4c2b16 | |||
| 990f61fb72 | |||
| d51f5a632a | |||
| 3e9b89de0e | |||
| f900608d6a | |||
| ca56c5a73d | |||
| 57b72d6d57 | |||
| 76c5f58547 | |||
| e4a6ae01e0 |
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
+6
-5
@@ -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
|
||||
```
|
||||
@@ -77,10 +77,11 @@ Now you're all setup and can start implementing your changes.
|
||||
|
||||
This project is a monorepo. The code for the `readest-app` is in the `app/readest-app` directory. Here are some useful scripts for developing the frontend only without compiling Tauri:
|
||||
|
||||
| Command | Description |
|
||||
| ---------------- | -------------------------------------------------- |
|
||||
| `pnpm dev-web` | Starts the development server for the web app only |
|
||||
| `pnpm build-web` | Builds the web app |
|
||||
| Command | Description |
|
||||
| ------------------------------------------ | -------------------------------------------------- |
|
||||
| `pnpm dev-web` | Starts the development server for the web app only |
|
||||
| `TAURI_DEV_HOST=192.168.1.22 pnpm dev-web` | Start the dev server on a LAN address |
|
||||
| `pnpm build-web` | Builds the web app |
|
||||
|
||||
Recommended Visual Studio Code plugins for development:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
@@ -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>
|
||||
|
||||
@@ -180,12 +182,20 @@ Readest is open-source, and contributions are welcome! Feel free to open issues,
|
||||
|
||||
Readest is free software: you can redistribute it and/or modify it under the terms of the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
The following JavaScript libraries are bundled in this software:
|
||||
The following libraries and software are used in this software:
|
||||
|
||||
- [foliate-js](https://github.com/johnfactotum/foliate-js), which is MIT licensed.
|
||||
- [zip.js](https://github.com/gildas-lormeau/zip.js), which is licensed under the BSD-3-Clause license.
|
||||
- [fflate](https://github.com/101arrowz/fflate), which is MIT licensed.
|
||||
- [PDF.js](https://github.com/mozilla/pdf.js), which is licensed under Apache License 2.0.
|
||||
- [next.js](https://github.com/vercel/next.js), which is MIT licensed.
|
||||
- [react](https://github.com/facebook/react), which is MIT licensed.
|
||||
- [react-icons](https://github.com/react-icons/react-icons), which has various open-source licenses.
|
||||
- [tauri](https://github.com/tauri-apps/tauri), which is MIT licensed.
|
||||
|
||||
The following fonts are utilized in this software, either bundled within the application or provided through web fonts:
|
||||
|
||||
[Bitter](https://fonts.google.com/?query=Bitter), [Fira Code](https://fonts.google.com/?query=Fira+Code), [Literata](https://fonts.google.com/?query=Literata), [Merriweather](https://fonts.google.com/?query=Merriweather), [Noto Sans](https://fonts.google.com/?query=Noto+Sans), [Roboto](https://fonts.google.com/?query=Roboto), [LXGW WenKai](https://github.com/lxgw/LxgwWenKai), [MiSans](https://hyperos.mi.com/font/en/), [Source Han](https://github.com/adobe-fonts/source-han-sans/), [WenQuanYi Micro Hei](http://wenq.org/wqy2/)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
NEXT_PUBLIC_POSTHOG_KEY=YOUR_POSTHOG_KEY
|
||||
NEXT_PUBLIC_POSTHOG_HOST=YOUR_POSTHOG_HOST
|
||||
|
||||
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
|
||||
|
||||
DEEPL_PRO_API_KEY=YOUR_DEEPL_PRO_API_KEY
|
||||
DEEPL_FREE_API_KEY=YOUR_DEEPL_FREE_API_KEY
|
||||
@@ -3,12 +3,27 @@ module.exports = {
|
||||
output: '.',
|
||||
options: {
|
||||
debug: false,
|
||||
sort: true,
|
||||
sort: false,
|
||||
func: {
|
||||
list: ['_'],
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
},
|
||||
lngs: ['de', 'ja', 'es', 'fr', 'it', 'ko', 'pt', 'ru', 'tr', 'id', 'vi', 'zh-CN', 'zh-TW'],
|
||||
lngs: [
|
||||
'de',
|
||||
'ja',
|
||||
'es',
|
||||
'fr',
|
||||
'it',
|
||||
'el',
|
||||
'ko',
|
||||
'pt',
|
||||
'ru',
|
||||
'tr',
|
||||
'id',
|
||||
'vi',
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
],
|
||||
ns: ['translation'],
|
||||
defaultNs: 'translation',
|
||||
defaultValue: '__STRING_NOT_TRANSLATED__',
|
||||
|
||||
@@ -19,14 +19,6 @@ const nextConfig = {
|
||||
// Configure assetPrefix or else the server won't properly resolve your assets.
|
||||
assetPrefix: isProd ? '' : `http://${internalHost}:3000`,
|
||||
reactStrictMode: true,
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/deepl/:path*',
|
||||
destination: 'https://api-free.deepl.com/v2/:path*',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.8.9",
|
||||
"version": "0.9.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
@@ -55,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"
|
||||
},
|
||||
@@ -73,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",
|
||||
@@ -84,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,9 @@
|
||||
{
|
||||
"(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",
|
||||
@@ -15,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",
|
||||
@@ -34,7 +41,6 @@
|
||||
"Font Size": "Schriftgröße",
|
||||
"From Local File": "Aus lokaler Datei",
|
||||
"Full Justification": "Blocksatz",
|
||||
"Gaps (%)": "Abstände (%)",
|
||||
"Global Settings": "Globale Einstellungen",
|
||||
"Go Back": "Zurück",
|
||||
"Go Forward": "Vorwärts",
|
||||
@@ -43,6 +49,7 @@
|
||||
"Grass": "Grasgrün",
|
||||
"Gray": "Grau",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Hervorheben",
|
||||
"Horizontal Direction": "Horizontale Richtung",
|
||||
"Hyphenation": "Silbentrennung",
|
||||
"Identifier:": "Kennung:",
|
||||
@@ -56,7 +63,6 @@
|
||||
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
|
||||
"Logged in": "Angemeldet",
|
||||
"Logged in as {{userDisplayName}}": "Angemeldet als {{userDisplayName}}",
|
||||
"Margins (px)": "Ränder (px)",
|
||||
"Match Case": "Groß-/Kleinschreibung beachten",
|
||||
"Match Diacritics": "Akzente beachten",
|
||||
"Match Whole Words": "Ganze Wörter",
|
||||
@@ -72,7 +78,7 @@
|
||||
"Notes": "Notizen",
|
||||
"Open": "Öffnen",
|
||||
"Open Book": "Buch öffnen",
|
||||
"Override Publisher Font": "Verleger-Schriftart überschreiben",
|
||||
"Original Text": "Originaltext",
|
||||
"Page": "Seite",
|
||||
"Paging Animation": "Blätter-Animation",
|
||||
"Paragraph": "Absatz",
|
||||
@@ -87,6 +93,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",
|
||||
@@ -99,11 +106,17 @@
|
||||
"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:",
|
||||
@@ -111,6 +124,14 @@
|
||||
"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"
|
||||
"Your Library": "Ihre Bibliothek",
|
||||
"TTS not supported for PDF": "TTS wird für PDF nicht unterstützt",
|
||||
"Override Book Font": "Buch-Schriftart überschreiben",
|
||||
"Vertical Margins (px)": "Vertikale Ränder (px)",
|
||||
"Horizontal Margins (%)": "Horizontale Ränder (%)",
|
||||
"Apply to all books": "Auf alle Bücher anwenden",
|
||||
"Apply to this book": "Auf dieses Buch anwenden",
|
||||
"Unable to fetch the translation. Try again later.": "Übersetzung konnte nicht abgerufen werden. Versuchen Sie es später erneut."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"(detected)": "(εντοπίστηκε)",
|
||||
"About Readest": "Σχετικά με το Readest",
|
||||
"Add your notes here...": "Προσθέστε τις σημειώσεις σας εδώ...",
|
||||
"Animation": "Κινούμενα σχέδια",
|
||||
"Annotate": "Σχολιασμός",
|
||||
"Apply": "Εφαρμογή",
|
||||
"Are you sure to delete the selected books?": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τα επιλεγμένα βιβλία;",
|
||||
"Auto Mode": "Αυτόματη λειτουργία",
|
||||
"Behavior": "Συμπεριφορά",
|
||||
"Book": "Βιβλίο",
|
||||
"Book Cover": "Εξώφυλλο βιβλίου",
|
||||
"Bookmark": "Σελιδοδείκτης",
|
||||
"Cancel": "Ακύρωση",
|
||||
"Chapter": "Κεφάλαιο",
|
||||
"Cherry": "Κερασί",
|
||||
"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": "Τύπος γραμματοσειράς",
|
||||
"Font Family": "Οικογένεια γραμματοσειράς",
|
||||
"Font Size": "Μέγεθος γραμματοσειράς",
|
||||
"From Local File": "Από τοπικό αρχείο",
|
||||
"Full Justification": "Πλήρης στοίχιση",
|
||||
"Global Settings": "Γενικές ρυθμίσεις",
|
||||
"Go Back": "Πίσω",
|
||||
"Go Forward": "Μπροστά",
|
||||
"Go Left": "Αριστερά",
|
||||
"Go Right": "Δεξιά",
|
||||
"Grass": "Γρασίδι",
|
||||
"Gray": "Γκρι",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Επισήμανση",
|
||||
"Horizontal Direction": "Οριζόντια κατεύθυνση",
|
||||
"Hyphenation": "Συλλαβισμός",
|
||||
"Identifier:": "Αναγνωριστικό:",
|
||||
"Import Books": "Εισαγωγή βιβλίων",
|
||||
"Invert Colors in Dark Mode": "Αντιστροφή χρωμάτων στη σκοτεινή λειτουργία",
|
||||
"Language:": "Γλώσσα:",
|
||||
"Layout": "Διάταξη",
|
||||
"Light Mode": "Φωτεινή λειτουργία",
|
||||
"Line Height": "Ύψος γραμμής",
|
||||
"Loading...": "Φόρτωση...",
|
||||
"Loc. {{currentPage}} / {{totalPage}}": "Θέση {{currentPage}} / {{totalPage}}",
|
||||
"Logged in": "Συνδεδεμένος",
|
||||
"Logged in as {{userDisplayName}}": "Συνδεδεμένος ως {{userDisplayName}}",
|
||||
"Match Case": "Ταίριασμα πεζών-κεφαλαίων",
|
||||
"Match Diacritics": "Ταίριασμα διακριτικών σημείων",
|
||||
"Match Whole Words": "Ταίριασμα ολόκληρων λέξεων",
|
||||
"Maximum Block Size": "Μέγιστο μέγεθος μπλοκ",
|
||||
"Maximum Inline Size": "Μέγιστο μέγεθος γραμμής",
|
||||
"Maximum Number of Columns": "Μέγιστος αριθμός στηλών",
|
||||
"Minimum Font Size": "Ελάχιστο μέγεθος γραμματοσειράς",
|
||||
"Misc": "Διάφορα",
|
||||
"Monospace Font": "Γραμματοσειρά σταθερού πλάτους",
|
||||
"More Info": "Περισσότερες πληροφορίες",
|
||||
"Nord": "Nord",
|
||||
"Notebook": "Σημειωματάριο",
|
||||
"Notes": "Σημειώσεις",
|
||||
"Open": "Άνοιγμα",
|
||||
"Open Book": "Άνοιγμα βιβλίου",
|
||||
"Original Text": "Αρχικό κείμενο",
|
||||
"Page": "Σελίδα",
|
||||
"Paging Animation": "Κινούμενα σχέδια σελιδοποίησης",
|
||||
"Paragraph": "Παράγραφος",
|
||||
"Parallel Read": "Παράλληλη ανάγνωση",
|
||||
"Published:": "Δημοσιεύτηκε:",
|
||||
"Publisher:": "Εκδότης:",
|
||||
"Reading progress synced": "Ο συγχρονισμός προόδου ανάγνωσης ολοκληρώθηκε",
|
||||
"Reload Page": "Επαναφόρτωση σελίδας",
|
||||
"Reveal in File Explorer": "Εμφάνιση στον εξερευνητή αρχείων",
|
||||
"Reveal in Finder": "Εμφάνιση στον Finder",
|
||||
"Reveal in Folder": "Εμφάνιση στο φάκελο",
|
||||
"Sans-Serif Font": "Γραμματοσειρά Sans-Serif",
|
||||
"Save": "Αποθήκευση",
|
||||
"Scrolled Mode": "Λειτουργία κύλισης",
|
||||
"Search": "Αναζήτηση",
|
||||
"Search books...": "Αναζήτηση βιβλίων...",
|
||||
"Search...": "Αναζήτηση...",
|
||||
"Select Book": "Επιλογή βιβλίου",
|
||||
"Select books": "Επιλογή βιβλίων",
|
||||
"Select multiple books": "Επιλογή πολλαπλών βιβλίων",
|
||||
"Sepia": "Σέπια",
|
||||
"Serif Font": "Γραμματοσειρά Serif",
|
||||
"Show Book Details": "Εμφάνιση λεπτομερειών βιβλίου",
|
||||
"Sidebar": "Πλευρική γραμμή",
|
||||
"Sign In": "Σύνδεση",
|
||||
"Sign Out": "Αποσύνδεση",
|
||||
"Sky": "Ουρανός",
|
||||
"Slow": "Αργά",
|
||||
"Solarized": "Solarized",
|
||||
"Speak": "Ομιλία",
|
||||
"Subjects:": "Θέματα:",
|
||||
"System Fonts": "Γραμματοσειρές συστήματος",
|
||||
"Table of Contents": "Πίνακας περιεχομένων",
|
||||
"TTS not supported in this device": "Η μετατροπή κειμένου σε ομιλία δεν υποστηρίζεται σε αυτή τη συσκευή",
|
||||
"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": "Η βιβλιοθήκη σας",
|
||||
"TTS not supported for PDF": "Η μετατροπή κειμένου σε ομιλία δεν υποστηρίζεται για αρχεία PDF",
|
||||
"Override Book Font": "Παράκαμψη γραμματοσειράς βιβλίου",
|
||||
"Vertical Margins (px)": "Κάθετα περιθώρια (px)",
|
||||
"Horizontal Margins (%)": "Οριζόντια περιθώρια (%)",
|
||||
"Apply to all books": "Εφαρμογή σε όλα τα βιβλία",
|
||||
"Apply to this book": "Εφαρμογή σε αυτό το βιβλίο",
|
||||
"Unable to fetch the translation. Try again later.": "Αδυναμία λήψης της μετάφρασης. Δοκιμάστε ξανά αργότερα."
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"(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",
|
||||
@@ -15,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",
|
||||
@@ -34,7 +41,6 @@
|
||||
"Font Size": "Tamaño de fuente",
|
||||
"From Local File": "Desde archivo local",
|
||||
"Full Justification": "Justificación completa",
|
||||
"Gaps (%)": "Espacios (%)",
|
||||
"Global Settings": "Configuración global",
|
||||
"Go Back": "Volver",
|
||||
"Go Forward": "Avanzar",
|
||||
@@ -43,6 +49,7 @@
|
||||
"Grass": "Hierba",
|
||||
"Gray": "Gris",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Resaltar",
|
||||
"Horizontal Direction": "Dirección horizontal",
|
||||
"Hyphenation": "Guionización",
|
||||
"Identifier:": "Identificador:",
|
||||
@@ -56,7 +63,6 @@
|
||||
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
|
||||
"Logged in": "Sesión iniciada",
|
||||
"Logged in as {{userDisplayName}}": "Sesión iniciada como {{userDisplayName}}",
|
||||
"Margins (px)": "Márgenes (px)",
|
||||
"Match Case": "Coincidir mayúsculas y minúsculas",
|
||||
"Match Diacritics": "Coincidir diacríticos",
|
||||
"Match Whole Words": "Coincidir palabras completas",
|
||||
@@ -72,7 +78,7 @@
|
||||
"Notes": "Notas",
|
||||
"Open": "Abrir",
|
||||
"Open Book": "Abrir libro",
|
||||
"Override Publisher Font": "Sobrescribir fuente del editor",
|
||||
"Original Text": "Texto original",
|
||||
"Page": "Página",
|
||||
"Paging Animation": "Animación de paginación",
|
||||
"Paragraph": "Párrafo",
|
||||
@@ -87,6 +93,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",
|
||||
@@ -99,11 +106,17 @@
|
||||
"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:",
|
||||
@@ -111,6 +124,14 @@
|
||||
"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"
|
||||
"Your Library": "Tu biblioteca",
|
||||
"TTS not supported for PDF": "TTS no es compatible con PDF",
|
||||
"Override Book Font": "Sobrescribir fuente del libro",
|
||||
"Vertical Margins (px)": "Márgenes verticales (px)",
|
||||
"Horizontal Margins (%)": "Márgenes horizontales (%)",
|
||||
"Apply to all books": "Aplicar a todos los libros",
|
||||
"Apply to this book": "Aplicar a este libro",
|
||||
"Unable to fetch the translation. Try again later.": "No se puede obtener la traducción. Inténtalo de nuevo más tarde."
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"(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",
|
||||
@@ -15,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",
|
||||
@@ -34,7 +41,6 @@
|
||||
"Font Size": "Taille de police",
|
||||
"From Local File": "Depuis un fichier local",
|
||||
"Full Justification": "Justification complète",
|
||||
"Gaps (%)": "Espaces (%)",
|
||||
"Global Settings": "Paramètres globaux",
|
||||
"Go Back": "Retour",
|
||||
"Go Forward": "Avancer",
|
||||
@@ -43,6 +49,7 @@
|
||||
"Grass": "Herbe",
|
||||
"Gray": "Gris",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Surligner",
|
||||
"Horizontal Direction": "Direction horizontale",
|
||||
"Hyphenation": "Césure",
|
||||
"Identifier:": "Identifiant :",
|
||||
@@ -56,7 +63,6 @@
|
||||
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
|
||||
"Logged in": "Connecté",
|
||||
"Logged in as {{userDisplayName}}": "Connecté en tant que {{userDisplayName}}",
|
||||
"Margins (px)": "Marges (px)",
|
||||
"Match Case": "Respecter la casse",
|
||||
"Match Diacritics": "Respecter les accents",
|
||||
"Match Whole Words": "Mots entiers",
|
||||
@@ -72,7 +78,7 @@
|
||||
"Notes": "Notes",
|
||||
"Open": "Ouvrir",
|
||||
"Open Book": "Ouvrir le livre",
|
||||
"Override Publisher Font": "Remplacer la police de l'éditeur",
|
||||
"Original Text": "Texte original",
|
||||
"Page": "Page",
|
||||
"Paging Animation": "Animation de page",
|
||||
"Paragraph": "Paragraphe",
|
||||
@@ -87,6 +93,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",
|
||||
@@ -99,11 +106,17 @@
|
||||
"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 :",
|
||||
@@ -111,6 +124,14 @@
|
||||
"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"
|
||||
"Your Library": "Votre bibliothèque",
|
||||
"TTS not supported for PDF": "La synthèse vocale n'est pas prise en charge pour les fichiers PDF",
|
||||
"Override Book Font": "Remplacer la police du livre",
|
||||
"Vertical Margins (px)": "Marges verticales (px)",
|
||||
"Horizontal Margins (%)": "Marges horizontales (%)",
|
||||
"Apply to all books": "Appliquer à tous les livres",
|
||||
"Apply to this book": "Appliquer à ce livre",
|
||||
"Unable to fetch the translation. Try again later.": "Impossible de récupérer la traduction. Réessayez plus tard."
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"(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",
|
||||
@@ -15,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",
|
||||
@@ -34,7 +41,6 @@
|
||||
"Font Size": "Ukuran Font",
|
||||
"From Local File": "Dari File Lokal",
|
||||
"Full Justification": "Rata Penuh",
|
||||
"Gaps (%)": "Jarak (%)",
|
||||
"Global Settings": "Pengaturan Global",
|
||||
"Go Back": "Kembali",
|
||||
"Go Forward": "Maju",
|
||||
@@ -43,6 +49,7 @@
|
||||
"Grass": "Rumput",
|
||||
"Gray": "Abu-abu",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Sorot",
|
||||
"Horizontal Direction": "Arah Horizontal",
|
||||
"Hyphenation": "Pemenggalan Kata",
|
||||
"Identifier:": "Pengenal:",
|
||||
@@ -56,7 +63,6 @@
|
||||
"Loc. {{currentPage}} / {{totalPage}}": "Lok. {{currentPage}} / {{totalPage}}",
|
||||
"Logged in": "Sudah Masuk",
|
||||
"Logged in as {{userDisplayName}}": "Masuk sebagai {{userDisplayName}}",
|
||||
"Margins (px)": "Margin (px)",
|
||||
"Match Case": "Cocokkan Huruf Besar/Kecil",
|
||||
"Match Diacritics": "Cocokkan Diakritik",
|
||||
"Match Whole Words": "Cocokkan Kata Utuh",
|
||||
@@ -72,7 +78,7 @@
|
||||
"Notes": "Catatan",
|
||||
"Open": "Buka",
|
||||
"Open Book": "Buka Buku",
|
||||
"Override Publisher Font": "Ganti Font Penerbit",
|
||||
"Original Text": "Teks Asli",
|
||||
"Page": "Halaman",
|
||||
"Paging Animation": "Animasi Halaman",
|
||||
"Paragraph": "Paragraf",
|
||||
@@ -87,6 +93,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",
|
||||
@@ -99,11 +106,17 @@
|
||||
"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:",
|
||||
@@ -111,6 +124,14 @@
|
||||
"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"
|
||||
"Your Library": "Perpustakaan Anda",
|
||||
"TTS not supported for PDF": "TTS tidak didukung untuk PDF",
|
||||
"Override Book Font": "Ganti Font Buku",
|
||||
"Vertical Margins (px)": "Margin Vertikal (px)",
|
||||
"Horizontal Margins (%)": "Margin Horizontal (%)",
|
||||
"Apply to all books": "Terapkan ke semua buku",
|
||||
"Apply to this book": "Terapkan ke buku ini",
|
||||
"Unable to fetch the translation. Try again later.": "Tidak dapat mengambil terjemahan. Coba lagi nanti."
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"(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",
|
||||
@@ -15,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",
|
||||
@@ -34,7 +41,6 @@
|
||||
"Font Size": "Dimensione carattere",
|
||||
"From Local File": "Da file locale",
|
||||
"Full Justification": "Giustificazione completa",
|
||||
"Gaps (%)": "Spaziature (%)",
|
||||
"Global Settings": "Impostazioni globali",
|
||||
"Go Back": "Indietro",
|
||||
"Go Forward": "Avanti",
|
||||
@@ -43,6 +49,7 @@
|
||||
"Grass": "Erba",
|
||||
"Gray": "Grigio",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Evidenzia",
|
||||
"Horizontal Direction": "Direzione orizzontale",
|
||||
"Hyphenation": "Sillabazione",
|
||||
"Identifier:": "Identificatore:",
|
||||
@@ -56,7 +63,6 @@
|
||||
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
|
||||
"Logged in": "Accesso effettuato",
|
||||
"Logged in as {{userDisplayName}}": "Accesso effettuato come {{userDisplayName}}",
|
||||
"Margins (px)": "Margini (px)",
|
||||
"Match Case": "Maiuscole/minuscole",
|
||||
"Match Diacritics": "Corrispondenza diacritici",
|
||||
"Match Whole Words": "Parole intere",
|
||||
@@ -72,7 +78,7 @@
|
||||
"Notes": "Note",
|
||||
"Open": "Apri",
|
||||
"Open Book": "Apri libro",
|
||||
"Override Publisher Font": "Sostituisci font editore",
|
||||
"Original Text": "Testo originale",
|
||||
"Page": "Pagina",
|
||||
"Paging Animation": "Animazione cambio pagina",
|
||||
"Paragraph": "Paragrafo",
|
||||
@@ -87,6 +93,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",
|
||||
@@ -99,11 +106,17 @@
|
||||
"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:",
|
||||
@@ -111,6 +124,14 @@
|
||||
"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"
|
||||
"Your Library": "La tua biblioteca",
|
||||
"TTS not supported for PDF": "TTS non supportato per PDF",
|
||||
"Override Book Font": "Sovrascrivi font libro",
|
||||
"Vertical Margins (px)": "Margini verticali (px)",
|
||||
"Horizontal Margins (%)": "Margini orizzontali (%)",
|
||||
"Apply to all books": "Applica a tutti i libri",
|
||||
"Apply to this book": "Applica a questo libro",
|
||||
"Unable to fetch the translation. Try again later.": "Impossibile recuperare la traduzione. Riprova più tardi."
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"(detected)": "(検出)",
|
||||
"About Readest": "Readestについて",
|
||||
"Add your notes here...": "ここにメモを追加...",
|
||||
"Animation": "アニメーション",
|
||||
"Annotate": "注釈",
|
||||
"Apply": "適用",
|
||||
"Are you sure to delete the selected books?": "選択した書籍を削除してもよろしいですか?",
|
||||
"Auto Mode": "自動モード",
|
||||
@@ -15,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": "書体",
|
||||
@@ -34,7 +41,6 @@
|
||||
"Font Size": "フォントサイズ",
|
||||
"From Local File": "ローカルファイルから",
|
||||
"Full Justification": "両端揃え",
|
||||
"Gaps (%)": "間隔 (%)",
|
||||
"Global Settings": "全体設定",
|
||||
"Go Back": "戻る",
|
||||
"Go Forward": "進む",
|
||||
@@ -43,6 +49,7 @@
|
||||
"Grass": "グリーン",
|
||||
"Gray": "グレー",
|
||||
"Gruvbox": "グルーブボックス",
|
||||
"Highlight": "ハイライト",
|
||||
"Horizontal Direction": "横書",
|
||||
"Hyphenation": "ハイフネーション",
|
||||
"Identifier:": "識別子:",
|
||||
@@ -56,7 +63,6 @@
|
||||
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
|
||||
"Logged in": "ログイン済み",
|
||||
"Logged in as {{userDisplayName}}": "{{userDisplayName}}としてログイン中",
|
||||
"Margins (px)": "余白 (px)",
|
||||
"Match Case": "大文字・小文字を区別",
|
||||
"Match Diacritics": "アクセント記号を区別",
|
||||
"Match Whole Words": "単語全体を一致",
|
||||
@@ -72,7 +78,7 @@
|
||||
"Notes": "メモ",
|
||||
"Open": "開く",
|
||||
"Open Book": "書籍を開く",
|
||||
"Override Publisher Font": "出版社フォントを上書き",
|
||||
"Original Text": "原文",
|
||||
"Page": "ページ",
|
||||
"Paging Animation": "ページめくりアニメーション",
|
||||
"Paragraph": "段落",
|
||||
@@ -87,6 +93,7 @@
|
||||
"Sans-Serif Font": "ゴシック体",
|
||||
"Save": "保存",
|
||||
"Scrolled Mode": "スクロールモード",
|
||||
"Search": "検索",
|
||||
"Search books...": "書籍を検索...",
|
||||
"Search...": "検索...",
|
||||
"Select Book": "書籍を選択",
|
||||
@@ -99,11 +106,17 @@
|
||||
"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:": "更新日:",
|
||||
@@ -111,6 +124,14 @@
|
||||
"Version {{version}}": "バージョン {{version}}",
|
||||
"Vertical Direction": "縦書",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "ライブラリーへようこそ。ここに書籍をインポートして、いつでも読むことができます。",
|
||||
"Wikipedia": "ウィキペディア",
|
||||
"Writing Mode": "書き込みモード",
|
||||
"Your Library": "ライブラリー"
|
||||
"Your Library": "ライブラリー",
|
||||
"TTS not supported for PDF": "PDFではTTSがサポートされていません",
|
||||
"Override Book Font": "書籍フォントを上書き",
|
||||
"Vertical Margins (px)": "縦マージン(px)",
|
||||
"Horizontal Margins (%)": "横マージン(%)",
|
||||
"Apply to all books": "すべての書籍に適用",
|
||||
"Apply to this book": "この書籍に適用",
|
||||
"Unable to fetch the translation. Try again later.": "翻訳を取得できません。後で再試行してください。"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"(detected)": "(감지됨)",
|
||||
"About Readest": "Readest 정보",
|
||||
"Add your notes here...": "여기에 메모를 추가하세요...",
|
||||
"Animation": "애니메이션",
|
||||
"Annotate": "주석",
|
||||
"Apply": "적용",
|
||||
"Are you sure to delete the selected books?": "선택한 책을 삭제하시겠습니까?",
|
||||
"Auto Mode": "자동 모드",
|
||||
@@ -15,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": "글꼴 스타일",
|
||||
@@ -34,7 +41,6 @@
|
||||
"Font Size": "글꼴 크기",
|
||||
"From Local File": "로컬 파일에서",
|
||||
"Full Justification": "양쪽 정렬",
|
||||
"Gaps (%)": "간격 (%)",
|
||||
"Global Settings": "전역 설정",
|
||||
"Go Back": "뒤로",
|
||||
"Go Forward": "앞으로",
|
||||
@@ -43,6 +49,7 @@
|
||||
"Grass": "잔디색",
|
||||
"Gray": "회색",
|
||||
"Gruvbox": "그러브박스",
|
||||
"Highlight": "하이라이트",
|
||||
"Horizontal Direction": "수평 방향",
|
||||
"Hyphenation": "하이픈 넣기",
|
||||
"Identifier:": "식별자:",
|
||||
@@ -56,7 +63,6 @@
|
||||
"Loc. {{currentPage}} / {{totalPage}}": "위치 {{currentPage}} / {{totalPage}}",
|
||||
"Logged in": "로그인됨",
|
||||
"Logged in as {{userDisplayName}}": "{{userDisplayName}}(으)로 로그인됨",
|
||||
"Margins (px)": "여백 (px)",
|
||||
"Match Case": "대소문자 구분",
|
||||
"Match Diacritics": "발음 구별 부호 구분",
|
||||
"Match Whole Words": "전체 단어 일치",
|
||||
@@ -72,7 +78,7 @@
|
||||
"Notes": "메모",
|
||||
"Open": "열기",
|
||||
"Open Book": "책 열기",
|
||||
"Override Publisher Font": "출판사 글꼴 재정의",
|
||||
"Original Text": "원본 텍스트",
|
||||
"Page": "페이지",
|
||||
"Paging Animation": "페이지 넘김 애니메이션",
|
||||
"Paragraph": "단락",
|
||||
@@ -87,6 +93,7 @@
|
||||
"Sans-Serif Font": "산세리프체",
|
||||
"Save": "저장",
|
||||
"Scrolled Mode": "스크롤 모드",
|
||||
"Search": "검색",
|
||||
"Search books...": "책 검색...",
|
||||
"Search...": "검색...",
|
||||
"Select Book": "책 선택",
|
||||
@@ -99,11 +106,17 @@
|
||||
"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:": "업데이트일:",
|
||||
@@ -111,6 +124,14 @@
|
||||
"Version {{version}}": "버전 {{version}}",
|
||||
"Vertical Direction": "수직 방향",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "내 서재에 오신 것을 환영합니다. 여기에서 책을 가져와 언제든지 읽을 수 있습니다.",
|
||||
"Wikipedia": "위키피디아",
|
||||
"Writing Mode": "글쓰기 모드",
|
||||
"Your Library": "내 서재"
|
||||
"Your Library": "내 서재",
|
||||
"TTS not supported for PDF": "PDF에서 TTS가 지원되지 않음",
|
||||
"Override Book Font": "책 글꼴 덮어쓰기",
|
||||
"Vertical Margins (px)": "수직 여백 (px)",
|
||||
"Horizontal Margins (%)": "수평 여백 (%)",
|
||||
"Apply to all books": "모든 책에 적용",
|
||||
"Apply to this book": "이 책에 적용",
|
||||
"Unable to fetch the translation. Try again later.": "번역을 가져올 수 없습니다. 나중에 다시 시도하세요."
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"(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",
|
||||
@@ -15,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",
|
||||
@@ -34,7 +41,6 @@
|
||||
"Font Size": "Tamanho da Fonte",
|
||||
"From Local File": "Do Arquivo Local",
|
||||
"Full Justification": "Justificação Completa",
|
||||
"Gaps (%)": "Espaçamentos (%)",
|
||||
"Global Settings": "Configurações Globais",
|
||||
"Go Back": "Voltar",
|
||||
"Go Forward": "Avançar",
|
||||
@@ -43,6 +49,7 @@
|
||||
"Grass": "Grama",
|
||||
"Gray": "Cinza",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Destaque",
|
||||
"Horizontal Direction": "Direção Horizontal",
|
||||
"Hyphenation": "Hifenização",
|
||||
"Identifier:": "Identificador:",
|
||||
@@ -56,7 +63,6 @@
|
||||
"Loc. {{currentPage}} / {{totalPage}}": "Loc. {{currentPage}} / {{totalPage}}",
|
||||
"Logged in": "Conectado",
|
||||
"Logged in as {{userDisplayName}}": "Conectado como {{userDisplayName}}",
|
||||
"Margins (px)": "Margens (px)",
|
||||
"Match Case": "Diferenciar Maiúsculas e Minúsculas",
|
||||
"Match Diacritics": "Corresponder Acentos",
|
||||
"Match Whole Words": "Corresponder Palavras Inteiras",
|
||||
@@ -72,7 +78,7 @@
|
||||
"Notes": "Notas",
|
||||
"Open": "Abrir",
|
||||
"Open Book": "Abrir Livro",
|
||||
"Override Publisher Font": "Substituir Fonte do Editor",
|
||||
"Original Text": "Texto Original",
|
||||
"Page": "Página",
|
||||
"Paging Animation": "Animação de Página",
|
||||
"Paragraph": "Parágrafo",
|
||||
@@ -87,6 +93,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",
|
||||
@@ -99,11 +106,17 @@
|
||||
"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:",
|
||||
@@ -111,6 +124,14 @@
|
||||
"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"
|
||||
"Your Library": "Sua Biblioteca",
|
||||
"TTS not supported for PDF": "TTS não suportado para PDF",
|
||||
"Override Book Font": "Sobrescrever Fonte do Livro",
|
||||
"Vertical Margins (px)": "Margens Verticais (px)",
|
||||
"Horizontal Margins (%)": "Margens Horizontais (%)",
|
||||
"Apply to all books": "Aplicar a todos os livros",
|
||||
"Apply to this book": "Aplicar a este livro",
|
||||
"Unable to fetch the translation. Try again later.": "Não foi possível buscar a tradução. Tente novamente mais tarde."
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"(detected)": "(обнаружено)",
|
||||
"About Readest": "О Readest",
|
||||
"Add your notes here...": "Добавьте свои заметки здесь...",
|
||||
"Animation": "Анимация",
|
||||
"Annotate": "Аннотация",
|
||||
"Apply": "Применить",
|
||||
"Are you sure to delete the selected books?": "Вы уверены, что хотите удалить выбранные книги?",
|
||||
"Auto Mode": "Автоматический режим",
|
||||
@@ -15,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": "Начертание шрифта",
|
||||
@@ -34,7 +41,6 @@
|
||||
"Font Size": "Размер шрифта",
|
||||
"From Local File": "Из локального файла",
|
||||
"Full Justification": "Полное выравнивание",
|
||||
"Gaps (%)": "Промежутки (%)",
|
||||
"Global Settings": "Общие настройки",
|
||||
"Go Back": "Назад",
|
||||
"Go Forward": "Вперёд",
|
||||
@@ -43,6 +49,7 @@
|
||||
"Grass": "Трава",
|
||||
"Gray": "Серый",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Выделить",
|
||||
"Horizontal Direction": "Горизонтальное направление",
|
||||
"Hyphenation": "Перенос слов",
|
||||
"Identifier:": "Идентификатор:",
|
||||
@@ -56,7 +63,6 @@
|
||||
"Loc. {{currentPage}} / {{totalPage}}": "Поз. {{currentPage}} / {{totalPage}}",
|
||||
"Logged in": "Выполнен вход",
|
||||
"Logged in as {{userDisplayName}}": "Вход выполнен как {{userDisplayName}}",
|
||||
"Margins (px)": "Отступы (px)",
|
||||
"Match Case": "Учитывать регистр",
|
||||
"Match Diacritics": "Учитывать диакритические знаки",
|
||||
"Match Whole Words": "Только целые слова",
|
||||
@@ -72,7 +78,7 @@
|
||||
"Notes": "Заметки",
|
||||
"Open": "Открыть",
|
||||
"Open Book": "Открыть книгу",
|
||||
"Override Publisher Font": "Переопределить шрифт издателя",
|
||||
"Original Text": "Оригинальный текст",
|
||||
"Page": "Страница",
|
||||
"Paging Animation": "Анимация перелистывания",
|
||||
"Paragraph": "Абзац",
|
||||
@@ -87,6 +93,7 @@
|
||||
"Sans-Serif Font": "Шрифт без засечек",
|
||||
"Save": "Сохранить",
|
||||
"Scrolled Mode": "Режим прокрутки",
|
||||
"Search": "Поиск",
|
||||
"Search books...": "Поиск книг...",
|
||||
"Search...": "Поиск...",
|
||||
"Select Book": "Выбрать книгу",
|
||||
@@ -99,11 +106,17 @@
|
||||
"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:": "Обновлено:",
|
||||
@@ -111,6 +124,14 @@
|
||||
"Version {{version}}": "Версия {{version}}",
|
||||
"Vertical Direction": "Вертикальное направление",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Добро пожаловать в вашу библиотеку. Вы можете импортировать сюда книги и читать их в любое время.",
|
||||
"Wikipedia": "Википедия",
|
||||
"Writing Mode": "Режим записи",
|
||||
"Your Library": "Ваша библиотека"
|
||||
"Your Library": "Ваша библиотека",
|
||||
"TTS not supported for PDF": "TTS не поддерживается для PDF",
|
||||
"Override Book Font": "Переопределить шрифт книги",
|
||||
"Vertical Margins (px)": "Вертикальные поля (px)",
|
||||
"Horizontal Margins (%)": "Горизонтальные поля (%)",
|
||||
"Apply to all books": "Применить ко всем книгам",
|
||||
"Apply to this book": "Применить к этой книге",
|
||||
"Unable to fetch the translation. Try again later.": "Не удалось получить перевод. Попробуйте позже."
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"(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",
|
||||
@@ -15,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ü",
|
||||
@@ -34,7 +41,6 @@
|
||||
"Font Size": "Yazı Boyutu",
|
||||
"From Local File": "Yerel Dosyadan",
|
||||
"Full Justification": "Tam Hizalama",
|
||||
"Gaps (%)": "Boşluklar (%)",
|
||||
"Global Settings": "Genel Ayarlar",
|
||||
"Go Back": "Geri Git",
|
||||
"Go Forward": "İleri Git",
|
||||
@@ -43,6 +49,7 @@
|
||||
"Grass": "Çimen",
|
||||
"Gray": "Gri",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Vurgula",
|
||||
"Horizontal Direction": "Yatay Yön",
|
||||
"Hyphenation": "Heceleme",
|
||||
"Identifier:": "Tanımlayıcı:",
|
||||
@@ -56,7 +63,6 @@
|
||||
"Loc. {{currentPage}} / {{totalPage}}": "Konum {{currentPage}} / {{totalPage}}",
|
||||
"Logged in": "Giriş Yapıldı",
|
||||
"Logged in as {{userDisplayName}}": "{{userDisplayName}} olarak giriş yapıldı",
|
||||
"Margins (px)": "Kenar Boşlukları (px)",
|
||||
"Match Case": "Büyük/Küçük Harf Eşleştir",
|
||||
"Match Diacritics": "Aksan İşaretlerini Eşleştir",
|
||||
"Match Whole Words": "Tam Kelimeleri Eşleştir",
|
||||
@@ -72,7 +78,7 @@
|
||||
"Notes": "Notlar",
|
||||
"Open": "Aç",
|
||||
"Open Book": "Kitabı Aç",
|
||||
"Override Publisher Font": "Yayıncı Yazı Tipini Geçersiz Kıl",
|
||||
"Original Text": "Orijinal Metin",
|
||||
"Page": "Sayfa",
|
||||
"Paging Animation": "Sayfa Çevirme Animasyonu",
|
||||
"Paragraph": "Paragraf",
|
||||
@@ -87,6 +93,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ç",
|
||||
@@ -99,11 +106,17 @@
|
||||
"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:",
|
||||
@@ -111,6 +124,14 @@
|
||||
"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"
|
||||
"Your Library": "Kütüphaneniz",
|
||||
"TTS not supported for PDF": "PDF için TTS desteklenmiyor",
|
||||
"Override Book Font": "Kitap Yazı Tipini Geçersiz Kıl",
|
||||
"Vertical Margins (px)": "Dikey Kenar Boşlukları (px)",
|
||||
"Horizontal Margins (%)": "Yatay Kenar Boşlukları (%)",
|
||||
"Apply to all books": "Tüm kitaplara uygula",
|
||||
"Apply to this book": "Bu kitaba uygula",
|
||||
"Unable to fetch the translation. Try again later.": "Çeviri alınamıyor. Daha sonra tekrar deneyin."
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"(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",
|
||||
@@ -15,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ữ",
|
||||
@@ -34,7 +41,6 @@
|
||||
"Font Size": "Cỡ chữ",
|
||||
"From Local File": "Từ tệp cục bộ",
|
||||
"Full Justification": "Căn đều hai bên",
|
||||
"Gaps (%)": "Khoảng cách (%)",
|
||||
"Global Settings": "Cài đặt chung",
|
||||
"Go Back": "Quay lại",
|
||||
"Go Forward": "Tiến tới",
|
||||
@@ -43,6 +49,7 @@
|
||||
"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:",
|
||||
@@ -56,7 +63,6 @@
|
||||
"Loc. {{currentPage}} / {{totalPage}}": "Vị trí {{currentPage}} / {{totalPage}}",
|
||||
"Logged in": "Đã đăng nhập",
|
||||
"Logged in as {{userDisplayName}}": "Đăng nhập với tên {{userDisplayName}}",
|
||||
"Margins (px)": "Lề (px)",
|
||||
"Match Case": "Phân biệt chữ hoa/thường",
|
||||
"Match Diacritics": "Phân biệt dấu",
|
||||
"Match Whole Words": "Khớp toàn bộ từ",
|
||||
@@ -72,7 +78,7 @@
|
||||
"Notes": "Ghi chú",
|
||||
"Open": "Mở",
|
||||
"Open Book": "Mở sách",
|
||||
"Override Publisher Font": "Ghi đè phông chữ của nhà xuất bản",
|
||||
"Original Text": "Văn bản gốc",
|
||||
"Page": "Trang",
|
||||
"Paging Animation": "Hiệu ứng lật trang",
|
||||
"Paragraph": "Đoạn văn",
|
||||
@@ -87,6 +93,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",
|
||||
@@ -99,11 +106,17 @@
|
||||
"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:",
|
||||
@@ -111,6 +124,14 @@
|
||||
"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"
|
||||
"Your Library": "Thư viện của bạn",
|
||||
"TTS not supported for PDF": "TTS không được hỗ trợ cho PDF",
|
||||
"Override Book Font": "Ghi đè phông chữ sách",
|
||||
"Vertical Margins (px)": "Lề dọc (px)",
|
||||
"Horizontal Margins (%)": "Lề ngang (%)",
|
||||
"Apply to all books": "Áp dụng cho tất cả sách",
|
||||
"Apply to this book": "Áp dụng cho sách này",
|
||||
"Unable to fetch the translation. Try again later.": "Không thể tải dịch vụ. Vui lòng thử lại sau."
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"(detected)": "(检测到)",
|
||||
"About Readest": "关于 Readest",
|
||||
"Add your notes here...": "在这里添加您的笔记...",
|
||||
"Animation": "动画",
|
||||
"Annotate": "笔记",
|
||||
"Apply": "应用",
|
||||
"Are you sure to delete the selected books?": "您确定要删除所选书籍吗?",
|
||||
"Auto Mode": "自动主题",
|
||||
@@ -15,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": "字型",
|
||||
@@ -34,7 +41,6 @@
|
||||
"Font Size": "字号",
|
||||
"From Local File": "从本地文件导入",
|
||||
"Full Justification": "两端对齐",
|
||||
"Gaps (%)": "间距 (%)",
|
||||
"Global Settings": "全局设置",
|
||||
"Go Back": "返回",
|
||||
"Go Forward": "前进",
|
||||
@@ -43,6 +49,7 @@
|
||||
"Grass": "青草",
|
||||
"Gray": "素雅",
|
||||
"Gruvbox": "暖橘",
|
||||
"Highlight": "划线",
|
||||
"Horizontal Direction": "横排",
|
||||
"Hyphenation": "断字",
|
||||
"Identifier:": "识别码",
|
||||
@@ -56,7 +63,6 @@
|
||||
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
|
||||
"Logged in": "已登录",
|
||||
"Logged in as {{userDisplayName}}": "{{userDisplayName}} 已登录",
|
||||
"Margins (px)": "页边距 (px)",
|
||||
"Match Case": "匹配大小写",
|
||||
"Match Diacritics": "匹配重音符号",
|
||||
"Match Whole Words": "匹配整个单词",
|
||||
@@ -72,7 +78,7 @@
|
||||
"Notes": "笔记",
|
||||
"Open": "打开",
|
||||
"Open Book": "打开书籍",
|
||||
"Override Publisher Font": "覆盖内置字体",
|
||||
"Original Text": "原文",
|
||||
"Page": "页面",
|
||||
"Paging Animation": "翻页动画",
|
||||
"Paragraph": "段落",
|
||||
@@ -87,6 +93,7 @@
|
||||
"Sans-Serif Font": "无衬线字体",
|
||||
"Save": "保存",
|
||||
"Scrolled Mode": "滚动模式",
|
||||
"Search": "搜索",
|
||||
"Search books...": "搜索书籍...",
|
||||
"Search...": "搜索...",
|
||||
"Select Book": "选择书籍",
|
||||
@@ -99,11 +106,17 @@
|
||||
"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:": "更新日期",
|
||||
@@ -111,6 +124,14 @@
|
||||
"Version {{version}}": "版本 {{version}}",
|
||||
"Vertical Direction": "竖排",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "书库空空如也,您可以导入您的书籍并随时阅读。",
|
||||
"Wikipedia": "维基百科",
|
||||
"Writing Mode": "排版模式",
|
||||
"Your Library": "书库"
|
||||
"Your Library": "书库",
|
||||
"TTS not supported for PDF": "PDF 文档暂不支持 TTS",
|
||||
"Override Book Font": "覆盖书籍字体",
|
||||
"Vertical Margins (px)": "垂直边距(px)",
|
||||
"Horizontal Margins (%)": "水平边距(%)",
|
||||
"Apply to all books": "应用于所有书籍",
|
||||
"Apply to this book": "应用于此书籍",
|
||||
"Unable to fetch the translation. Try again later.": "无法获取翻译,请稍后重试。"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"(detected)": "(檢測到)",
|
||||
"About Readest": "關於 Readest",
|
||||
"Add your notes here...": "在這裡添加您的筆記...",
|
||||
"Animation": "動畫",
|
||||
"Annotate": "筆記",
|
||||
"Apply": "應用",
|
||||
"Are you sure to delete the selected books?": "您確定要刪除所選書籍嗎?",
|
||||
"Auto Mode": "自動主題",
|
||||
@@ -15,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": "字型",
|
||||
@@ -34,7 +41,6 @@
|
||||
"Font Size": "字號",
|
||||
"From Local File": "從本地檔案導入",
|
||||
"Full Justification": "兩端對齊",
|
||||
"Gaps (%)": "間距 (%)",
|
||||
"Global Settings": "全局設置",
|
||||
"Go Back": "返回",
|
||||
"Go Forward": "前進",
|
||||
@@ -43,6 +49,7 @@
|
||||
"Grass": "青草",
|
||||
"Gray": "素雅",
|
||||
"Gruvbox": "暖橘",
|
||||
"Highlight": "劃線",
|
||||
"Horizontal Direction": "橫排",
|
||||
"Hyphenation": "斷字",
|
||||
"Identifier:": "識別碼",
|
||||
@@ -56,7 +63,6 @@
|
||||
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
|
||||
"Logged in": "已登入",
|
||||
"Logged in as {{userDisplayName}}": "{{userDisplayName}} 已登入",
|
||||
"Margins (px)": "頁邊距 (px)",
|
||||
"Match Case": "匹配大小寫",
|
||||
"Match Diacritics": "匹配重音符號",
|
||||
"Match Whole Words": "匹配整個單詞",
|
||||
@@ -72,7 +78,7 @@
|
||||
"Notes": "筆記",
|
||||
"Open": "打開",
|
||||
"Open Book": "打開書籍",
|
||||
"Override Publisher Font": "覆蓋內置字體",
|
||||
"Original Text": "原文",
|
||||
"Page": "頁面",
|
||||
"Paging Animation": "翻頁動畫",
|
||||
"Paragraph": "段落",
|
||||
@@ -87,6 +93,7 @@
|
||||
"Sans-Serif Font": "無襯線字體",
|
||||
"Save": "保存",
|
||||
"Scrolled Mode": "滾動模式",
|
||||
"Search": "搜索",
|
||||
"Search books...": "搜索書籍...",
|
||||
"Search...": "搜索...",
|
||||
"Select Book": "選擇書籍",
|
||||
@@ -99,11 +106,17 @@
|
||||
"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:": "更新日期",
|
||||
@@ -111,6 +124,14 @@
|
||||
"Version {{version}}": "版本 {{version}}",
|
||||
"Vertical Direction": "豎排",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "書庫空空如也,您可以導入您的書籍並隨時閱讀。",
|
||||
"Wikipedia": "維基百科",
|
||||
"Writing Mode": "排版模式",
|
||||
"Your Library": "書庫"
|
||||
"Your Library": "書庫",
|
||||
"TTS not supported for PDF": "PDF 文檔暫不支持 TTS",
|
||||
"Override Book Font": "覆蓋書籍字體",
|
||||
"Vertical Margins (px)": "垂直邊距(px)",
|
||||
"Horizontal Margins (%)": "水平邊距(%)",
|
||||
"Apply to all books": "應用於所有書籍",
|
||||
"Apply to this book": "應用於此書籍",
|
||||
"Unable to fetch the translation. Try again later.": "無法獲取翻譯,請稍後再試。"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.6": {
|
||||
"date": "2025-01-18",
|
||||
"notes": ["Various enhancements for mobile platforms", "Fix DeepL translation API"]
|
||||
},
|
||||
"0.9.5": {
|
||||
"date": "2025-01-15",
|
||||
"notes": [
|
||||
"Various TTS related UX enhancements",
|
||||
"Add more system fonts as custom fonts",
|
||||
"Show reading progress in bookshelf"
|
||||
]
|
||||
},
|
||||
"0.9.3": {
|
||||
"date": "2025-01-11",
|
||||
"notes": [
|
||||
"Hotfix occasionally current speak not stopped when changing voice or rate",
|
||||
"Inherit tts rate when changing tts engine"
|
||||
]
|
||||
},
|
||||
"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": [
|
||||
|
||||
Generated
+159
-124
@@ -13,7 +13,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build 2.0.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"tauri-build 2.0.4 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"tauri-plugin-cli",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-devtools",
|
||||
@@ -28,6 +28,7 @@ dependencies = [
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -308,7 +309,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -348,7 +349,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -359,13 +360,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.83"
|
||||
version = "0.1.84"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd"
|
||||
checksum = "1b1244b10dcd56c92219da4e14caa97e312079e185f04ba3eea25061561dc0a0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -426,7 +427,7 @@ dependencies = [
|
||||
"rustversion",
|
||||
"serde",
|
||||
"sync_wrapper 0.1.2",
|
||||
"tower",
|
||||
"tower 0.4.13",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
@@ -559,7 +560,7 @@ dependencies = [
|
||||
"proc-macro-crate 3.2.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -688,16 +689,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cargo_metadata"
|
||||
version = "0.18.1"
|
||||
version = "0.19.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037"
|
||||
checksum = "8769706aad5d996120af43197bf46ef6ad0fda35216b4505f926a365a232d924"
|
||||
dependencies = [
|
||||
"camino",
|
||||
"cargo-platform",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -712,9 +713,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.5"
|
||||
version = "1.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c31a0499c1dc64f458ad13872de75c0eb7e3fdb0e67964610c914b034fc5956e"
|
||||
checksum = "a012a0df96dd6d06ba9a1b29d6402d1a5d77c6befd2566afdc26e10603dc93d7"
|
||||
dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
@@ -1095,7 +1096,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1105,7 +1106,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1129,7 +1130,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1140,7 +1141,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1167,7 +1168,7 @@ checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1180,7 +1181,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1204,7 +1205,7 @@ dependencies = [
|
||||
"tonic",
|
||||
"tonic-health",
|
||||
"tonic-web",
|
||||
"tower",
|
||||
"tower 0.4.13",
|
||||
"tower-http",
|
||||
"tower-layer",
|
||||
"tracing",
|
||||
@@ -1270,7 +1271,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1302,7 +1303,7 @@ checksum = "f2b99bf03862d7f545ebc28ddd33a665b50865f4dfd84031a393823879bd4c54"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1424,7 +1425,7 @@ checksum = "de0d48a183585823424a4ce1aa132d174a6a81bd540895822eb4c8373a8e49e8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1564,7 +1565,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1667,7 +1668,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1924,7 +1925,7 @@ dependencies = [
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1939,9 +1940,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
|
||||
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
@@ -2003,7 +2004,7 @@ dependencies = [
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2424,7 +2425,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2995,7 +2996,7 @@ dependencies = [
|
||||
"proc-macro-crate 3.2.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3484,7 +3485,7 @@ dependencies = [
|
||||
"phf_shared 0.11.2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3531,7 +3532,7 @@ checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3718,7 +3719,7 @@ dependencies = [
|
||||
"itertools",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3838,9 +3839,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.37"
|
||||
version = "1.0.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
|
||||
checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
@@ -4013,9 +4014,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.9"
|
||||
version = "0.12.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f"
|
||||
checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -4050,6 +4051,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower 0.5.2",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
@@ -4062,12 +4064,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46f6f80a9b882647d9014673ca9925d30ffc9750f2eed2b4490e189eaebd01e8"
|
||||
checksum = "6a24763657bff09769a8ccf12c8b8a50416fb035fe199263b4c5071e4e3f006f"
|
||||
dependencies = [
|
||||
"ashpd",
|
||||
"block2",
|
||||
"core-foundation 0.10.0",
|
||||
"core-foundation-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
@@ -4080,7 +4084,7 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4243,9 +4247,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.18"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248"
|
||||
checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
@@ -4286,7 +4290,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4338,9 +4342,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.216"
|
||||
version = "1.0.217"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e"
|
||||
checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
@@ -4358,13 +4362,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.216"
|
||||
version = "1.0.217"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e"
|
||||
checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4375,7 +4379,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4398,7 +4402,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4424,9 +4428,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.11.0"
|
||||
version = "3.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817"
|
||||
checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -4442,14 +4446,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.11.0"
|
||||
version = "3.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d"
|
||||
checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4711,9 +4715,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.91"
|
||||
version = "2.0.94"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d53cbcb5a243bd33b7858b1d7f4aca2153490815872d86d955d6ea29f743c035"
|
||||
checksum = "987bc0be1cdea8b10216bd06e2ca407d40b9543468fafd3ddfb02f36e77f71f3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4743,7 +4747,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4791,9 +4795,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tao"
|
||||
version = "0.31.0"
|
||||
version = "0.31.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc6b53216f32e60efc27dfa111268481e4dfba53e553e4cdebcaed9db36c11bb"
|
||||
checksum = "3731d04d4ac210cd5f344087733943b9bfb1a32654387dad4d1c70de21aee2c9"
|
||||
dependencies = [
|
||||
"bitflags 2.6.0",
|
||||
"cocoa 0.26.0",
|
||||
@@ -4835,7 +4839,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4863,7 +4867,7 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "tauri"
|
||||
version = "2.1.1"
|
||||
version = "2.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -4894,11 +4898,11 @@ dependencies = [
|
||||
"serde_repr",
|
||||
"serialize-to-javascript",
|
||||
"swift-rs",
|
||||
"tauri-build 2.0.3",
|
||||
"tauri-build 2.0.4",
|
||||
"tauri-macros",
|
||||
"tauri-runtime",
|
||||
"tauri-runtime-wry",
|
||||
"tauri-utils 2.1.0",
|
||||
"tauri-utils 2.1.1",
|
||||
"thiserror 2.0.9",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -4913,7 +4917,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-build"
|
||||
version = "2.0.3"
|
||||
version = "2.0.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
@@ -4925,7 +4929,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils 2.1.0",
|
||||
"tauri-utils 2.1.1",
|
||||
"tauri-winres",
|
||||
"toml 0.8.19",
|
||||
"walkdir",
|
||||
@@ -4933,9 +4937,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-build"
|
||||
version = "2.0.3"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7bd2a4bcfaf5fb9f4be72520eefcb61ae565038f8ccba2a497d8c28f463b8c01"
|
||||
checksum = "b274ec7239ada504deb615f1c8abd7ba99631e879709e6f10e5d17217058d976"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
@@ -4947,7 +4951,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"tauri-utils 2.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"tauri-winres",
|
||||
"toml 0.8.19",
|
||||
"walkdir",
|
||||
@@ -4955,7 +4959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-codegen"
|
||||
version = "2.0.3"
|
||||
version = "2.0.4"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"brotli",
|
||||
@@ -4969,8 +4973,8 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"syn 2.0.91",
|
||||
"tauri-utils 2.1.0",
|
||||
"syn 2.0.94",
|
||||
"tauri-utils 2.1.1",
|
||||
"thiserror 2.0.9",
|
||||
"time",
|
||||
"url",
|
||||
@@ -4980,21 +4984,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-macros"
|
||||
version = "2.0.3"
|
||||
version = "2.0.4"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
"tauri-codegen",
|
||||
"tauri-utils 2.1.0",
|
||||
"tauri-utils 2.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.0.3"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e753f2a30933a9bbf0a202fa47d7cc4a3401f06e8d6dcc53b79aa62954828c79"
|
||||
checksum = "5841b9a0200e954ef7457f8d327091424328891e267a97b641dc246cc54d0dec"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
@@ -5002,7 +5006,7 @@ dependencies = [
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"tauri-utils 2.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"toml 0.8.19",
|
||||
"walkdir",
|
||||
]
|
||||
@@ -5034,7 +5038,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"tauri-utils 2.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"thiserror 2.0.9",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -5103,7 +5107,7 @@ dependencies = [
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"tauri-utils 2.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"thiserror 2.0.9",
|
||||
"toml 0.8.19",
|
||||
"url",
|
||||
@@ -5286,8 +5290,23 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
name = "tauri-plugin-window-state"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "234dd891cc7960fa28f93ea911f3e0d9ce8375ebf9ff303831bdd7a3443d5714"
|
||||
dependencies = [
|
||||
"bitflags 2.6.0",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.3.0"
|
||||
dependencies = [
|
||||
"dpi",
|
||||
"gtk",
|
||||
@@ -5296,7 +5315,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils 2.1.0",
|
||||
"tauri-utils 2.1.1",
|
||||
"thiserror 2.0.9",
|
||||
"url",
|
||||
"windows",
|
||||
@@ -5304,7 +5323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime-wry"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http 1.2.0",
|
||||
@@ -5318,7 +5337,7 @@ dependencies = [
|
||||
"softbuffer",
|
||||
"tao",
|
||||
"tauri-runtime",
|
||||
"tauri-utils 2.1.0",
|
||||
"tauri-utils 2.1.1",
|
||||
"tracing",
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
@@ -5329,7 +5348,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-utils"
|
||||
version = "2.1.0"
|
||||
version = "2.1.1"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"cargo_metadata",
|
||||
@@ -5364,9 +5383,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-utils"
|
||||
version = "2.1.0"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9271a88f99b4adea0dc71d0baca4505475a0bbd139fb135f62958721aaa8fe54"
|
||||
checksum = "96fb10e7cc97456b2d5b9c03e335b5de5da982039a303a20d10006885e4523a0"
|
||||
dependencies = [
|
||||
"cargo_metadata",
|
||||
"ctor",
|
||||
@@ -5410,12 +5429,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.14.0"
|
||||
version = "3.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c"
|
||||
checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"fastrand",
|
||||
"getrandom 0.2.15",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
@@ -5464,7 +5484,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5475,7 +5495,7 @@ checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5591,7 +5611,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5695,7 +5715,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"winnow 0.6.20",
|
||||
"winnow 0.6.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5719,7 +5739,7 @@ dependencies = [
|
||||
"prost",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
"tower 0.4.13",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -5778,6 +5798,21 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper 1.0.2",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.4.4"
|
||||
@@ -5828,7 +5863,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6145,7 +6180,7 @@ dependencies = [
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -6180,7 +6215,7 @@ checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
@@ -6339,9 +6374,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.33.0"
|
||||
version = "0.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f61ff3d9d0ee4efcb461b14eb3acfda2702d10dc329f339303fc3e57215ae2c"
|
||||
checksum = "823e7ebcfaea51e78f72c87fc3b65a1e602c321f407a0b36dbb327d7bb7cd921"
|
||||
dependencies = [
|
||||
"webview2-com-macros",
|
||||
"webview2-com-sys",
|
||||
@@ -6359,14 +6394,14 @@ checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com-sys"
|
||||
version = "0.33.0"
|
||||
version = "0.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3a3e2eeb58f82361c93f9777014668eb3d07e7d174ee4c819575a9208011886"
|
||||
checksum = "7a82bce72db6e5ee83c68b5de1e2cd6ea195b9fbff91cb37df5884cbe3222df4"
|
||||
dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
"windows",
|
||||
@@ -6458,7 +6493,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6469,7 +6504,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6756,9 +6791,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.6.20"
|
||||
version = "0.6.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b"
|
||||
checksum = "39281189af81c07ec09db316b302a3e67bf9bd7cbf6c820b50e35fee9c2fa980"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -6787,9 +6822,9 @@ checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
|
||||
|
||||
[[package]]
|
||||
name = "wry"
|
||||
version = "0.47.2"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61ce51277d65170f6379d8cda935c80e3c2d1f0ff712a123c8bddb11b31a4b73"
|
||||
checksum = "1e644bf458e27b11b0ecafc9e5633d1304fdae82baca1d42185669752fe6ca4f"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"block2",
|
||||
@@ -6817,7 +6852,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"soup3",
|
||||
"tao-macros",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.9",
|
||||
"tracing",
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
@@ -6900,7 +6935,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -6965,7 +7000,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"windows-sys 0.59.0",
|
||||
"winnow 0.6.20",
|
||||
"winnow 0.6.22",
|
||||
"xdg-home",
|
||||
"zbus_macros 5.2.0",
|
||||
"zbus_names 4.1.0",
|
||||
@@ -6981,7 +7016,7 @@ dependencies = [
|
||||
"proc-macro-crate 3.2.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
"zvariant_utils 2.1.0",
|
||||
]
|
||||
|
||||
@@ -6994,7 +7029,7 @@ dependencies = [
|
||||
"proc-macro-crate 3.2.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
"zbus_names 4.1.0",
|
||||
"zvariant 5.1.0",
|
||||
"zvariant_utils 3.0.2",
|
||||
@@ -7019,7 +7054,7 @@ checksum = "856b7a38811f71846fd47856ceee8bccaec8399ff53fb370247e66081ace647b"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"winnow 0.6.20",
|
||||
"winnow 0.6.22",
|
||||
"zvariant 5.1.0",
|
||||
]
|
||||
|
||||
@@ -7041,7 +7076,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7061,7 +7096,7 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -7090,7 +7125,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7132,7 +7167,7 @@ dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"url",
|
||||
"winnow 0.6.20",
|
||||
"winnow 0.6.22",
|
||||
"zvariant_derive 5.1.0",
|
||||
"zvariant_utils 3.0.2",
|
||||
]
|
||||
@@ -7146,7 +7181,7 @@ dependencies = [
|
||||
"proc-macro-crate 3.2.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
"zvariant_utils 2.1.0",
|
||||
]
|
||||
|
||||
@@ -7159,7 +7194,7 @@ dependencies = [
|
||||
"proc-macro-crate 3.2.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
"zvariant_utils 3.0.2",
|
||||
]
|
||||
|
||||
@@ -7171,7 +7206,7 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.91",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7184,6 +7219,6 @@ dependencies = [
|
||||
"quote",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"syn 2.0.91",
|
||||
"winnow 0.6.20",
|
||||
"syn 2.0.94",
|
||||
"winnow 0.6.22",
|
||||
]
|
||||
|
||||
@@ -47,3 +47,4 @@ rand = "0.8"
|
||||
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",
|
||||
|
||||
@@ -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;
|
||||
@@ -95,7 +95,8 @@ pub fn run() {
|
||||
.get_webview_window("main")
|
||||
.expect("no main window")
|
||||
.set_focus();
|
||||
app.emit("single-instance", Payload { args: argv, cwd }).unwrap();
|
||||
app.emit("single-instance", Payload { args: argv, cwd })
|
||||
.unwrap();
|
||||
}));
|
||||
|
||||
let builder = builder.plugin(tauri_plugin_deep_link::init());
|
||||
@@ -103,8 +104,11 @@ pub fn run() {
|
||||
#[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| {
|
||||
@@ -172,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
|
||||
@@ -185,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();
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
"windows": [],
|
||||
"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",
|
||||
"default-src": "'self' 'unsafe-inline' blob: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
|
||||
"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",
|
||||
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net",
|
||||
"font-src": "'self' data: asset: http://asset.localhost tauri: https://fonts.gstatic.com https://db.onlinewebfonts.com https://cdn.jsdelivr.net",
|
||||
"frame-src": "'self' blob: asset: http://asset.localhost",
|
||||
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com"
|
||||
},
|
||||
@@ -137,7 +138,7 @@
|
||||
}
|
||||
},
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0OTAxMURGQkUzQjFENTQKUldSVUhUdSszeEdRNUExdmFkWnlvYWNYNG5wamkxMmUxRk5SejlMOTJVd28yNXlVTDh6Wld4OC8K",
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJFMEQ1QjE2OEU1NEIzNTEKUldSUnMxU09GbHNOdmpEaWFMT1crRFpEV2VORzQ2MklxaFc0M1R0ci9xY2c1bENXS0xhM1R1L2sK",
|
||||
"endpoints": ["https://github.com/readest/readest/releases/latest/download/latest.json"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ export default function AuthPage() {
|
||||
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
|
||||
@@ -238,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}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
import Alert from '@/components/Alert';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import BookDetailModal from '@/components/BookDetailModal';
|
||||
import ReadingProgress from './ReadingProgress';
|
||||
|
||||
type BookshelfItem = Book | BooksGroup;
|
||||
|
||||
@@ -242,17 +243,50 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-row items-center justify-between p-0 pt-2'>
|
||||
<h4 className='card-title line-clamp-1 text-[0.6em] text-xs font-semibold'>
|
||||
{(item as Book).title}
|
||||
</h4>
|
||||
{isWebAppPlatform() && (
|
||||
<div
|
||||
className='show-detail-button self-start opacity-0 group-hover:opacity-100'
|
||||
role='button'
|
||||
onClick={showBookDetailsModal.bind(null, item as Book)}
|
||||
>
|
||||
<CiCircleMore size={15} />
|
||||
<div
|
||||
className={clsx(
|
||||
'flex w-full p-0 pt-2',
|
||||
isWebAppPlatform() ? 'flex-col' : 'flex-row justify-between',
|
||||
)}
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='block overflow-hidden text-ellipsis whitespace-nowrap text-[0.6em] text-xs font-semibold'>
|
||||
{(item as Book).title}
|
||||
</h4>
|
||||
</div>
|
||||
{item.progress && (
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<ReadingProgress book={item as Book} />
|
||||
{isWebAppPlatform() && (
|
||||
<button
|
||||
type='button'
|
||||
className='show-detail-button opacity-0 group-hover:opacity-100'
|
||||
onClick={showBookDetailsModal.bind(null, item as Book)}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
showBookDetailsModal(item as Book);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CiCircleMore size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!item.progress && isWebAppPlatform() && (
|
||||
<div className={'flex items-center justify-end'}>
|
||||
<button
|
||||
type='button'
|
||||
className='show-detail-button opacity-0 group-hover:opacity-100'
|
||||
onClick={showBookDetailsModal.bind(null, item as Book)}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
showBookDetailsModal(item as Book);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CiCircleMore size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -270,6 +304,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
className='h-48 w-full object-cover'
|
||||
/>
|
||||
</figure>
|
||||
<div className='card-body p-4'>
|
||||
<h3 className='card-title line-clamp-2 text-sm'>{book.title}</h3>
|
||||
<p className='text-neutral-content line-clamp-1 text-xs'>{book.author}</p>
|
||||
<ReadingProgress book={book} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<h2 className='mb-2 text-lg font-bold'>{(item as BooksGroup).name}</h2>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type React from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import type { Book } from '@/types/book';
|
||||
|
||||
interface ReadingProgressProps {
|
||||
book: Book;
|
||||
}
|
||||
|
||||
const getProgressPercentage = (book: Book) => {
|
||||
if (!book.progress || !book.progress[1]) {
|
||||
return null;
|
||||
}
|
||||
if (book.progress && book.progress[1] === 1) {
|
||||
return 100;
|
||||
}
|
||||
return Math.round((book.progress[0] / book.progress[1]) * 100);
|
||||
};
|
||||
|
||||
const ReadingProgress: React.FC<ReadingProgressProps> = memo(
|
||||
({ book }) => {
|
||||
const progressPercentage = useMemo(() => getProgressPercentage(book), [book]);
|
||||
|
||||
if (!progressPercentage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='text-neutral-content/70 flex justify-between text-xs'>
|
||||
<span>{progressPercentage}%</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.book.hash === nextProps.book.hash &&
|
||||
prevProps.book.updatedAt === nextProps.book.updatedAt
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ReadingProgress.displayName = 'ReadingProgress';
|
||||
|
||||
export default ReadingProgress;
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { useState, useRef, useEffect, Suspense } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@@ -8,6 +9,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 +23,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 +48,6 @@ const LibraryPage = () => {
|
||||
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||
const demoBooks = useDemoBooks();
|
||||
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
const toastDismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current);
|
||||
toastDismissTimeout.current = setTimeout(() => setToastMessage(''), 5000);
|
||||
return () => {
|
||||
if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current);
|
||||
};
|
||||
}, [toastMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
const doAppUpdates = async () => {
|
||||
if (hasUpdater()) {
|
||||
@@ -175,11 +167,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);
|
||||
}
|
||||
}
|
||||
@@ -241,7 +234,12 @@ const LibraryPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='library-page rounded-window bg-base-200/50 text-base-content flex h-full min-h-screen select-none flex-col overflow-hidden'>
|
||||
<div
|
||||
className={clsx(
|
||||
'library-page bg-base-200/50 text-base-content flex h-dvh select-none flex-col overflow-hidden',
|
||||
isTauriAppPlatform() && 'rounded-window',
|
||||
)}
|
||||
>
|
||||
<div className='fixed top-0 z-40 w-full'>
|
||||
<LibraryHeader
|
||||
isSelectMode={isSelectMode}
|
||||
@@ -283,13 +281,7 @@ const LibraryPage = () => {
|
||||
</div>
|
||||
))}
|
||||
<AboutWindow />
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage}
|
||||
toastClass='toast-top toast-end pt-11'
|
||||
alertClass='alert-error max-w-80'
|
||||
/>
|
||||
)}
|
||||
<Toast />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import FoliateViewer from './FoliateViewer';
|
||||
import getGridTemplate from '@/utils/grid';
|
||||
import SectionInfo from './SectionInfo';
|
||||
@@ -11,7 +14,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 {
|
||||
@@ -22,9 +24,18 @@ interface BooksGridProps {
|
||||
const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
const { getConfig, getBookData } = useBookDataStore();
|
||||
const { getProgress, getViewState, getViewSettings } = useReaderStore();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
const gridTemplate = getGridTemplate(bookKeys.length, window.innerWidth / window.innerHeight);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sideBarBookKey) return;
|
||||
const bookData = getBookData(sideBarBookKey);
|
||||
if (!bookData || !bookData.book) return;
|
||||
document.title = bookData.book.title;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sideBarBookKey]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className='grid h-full flex-grow'
|
||||
@@ -49,7 +60,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
<div
|
||||
id={`gridcell-${bookKey}`}
|
||||
key={bookKey}
|
||||
className='relative h-full w-full overflow-hidden'
|
||||
className={`${isTauriAppPlatform() ? '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,13 @@ 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 { isTauriAppPlatform } from '@/services/environment';
|
||||
import Button from '@/components/Button';
|
||||
|
||||
interface FooterBarProps {
|
||||
@@ -16,9 +19,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,14 +45,25 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
|
||||
view?.history.forward();
|
||||
};
|
||||
|
||||
const handleSpeakText = async () => {
|
||||
if (!view || !progress) return;
|
||||
const { range } = progress;
|
||||
if (eventDispatcher.dispatchSync('tts-is-speaking')) {
|
||||
eventDispatcher.dispatch('tts-stop', { bookKey });
|
||||
} else {
|
||||
eventDispatcher.dispatch('tts-speak', { bookKey, range });
|
||||
}
|
||||
};
|
||||
|
||||
const pageinfoValid = pageinfo && pageinfo.total > 0 && pageinfo.current >= 0;
|
||||
const progressFraction = pageinfoValid ? pageinfo.current / pageinfo.total : 0;
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'footer-bar absolute bottom-0 z-10 flex h-12 w-full items-center px-4',
|
||||
'shadow-xs bg-base-100 rounded-window-bottom-right transition-opacity duration-300',
|
||||
!isSideBarVisible && 'rounded-window-bottom-left',
|
||||
'shadow-xs bg-base-100 transition-opacity duration-300',
|
||||
isTauriAppPlatform() && 'rounded-window-bottom-right',
|
||||
!isSideBarVisible && isTauriAppPlatform() && 'rounded-window-bottom-left',
|
||||
isHoveredAnim && 'hover-bar-anim',
|
||||
hoveredBookKey === bookKey ? `opacity-100` : `opacity-0`,
|
||||
)}
|
||||
@@ -83,6 +98,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}
|
||||
|
||||
@@ -5,12 +5,14 @@ import { PiDotsThreeVerticalBold } from 'react-icons/pi';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import SidebarToggler from './SidebarToggler';
|
||||
import BookmarkToggler from './BookmarkToggler';
|
||||
import NotebookToggler from './NotebookToggler';
|
||||
import SettingsToggler from './SettingsToggler';
|
||||
import ViewMenu from './ViewMenu';
|
||||
|
||||
interface HeaderBarProps {
|
||||
@@ -48,8 +50,9 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
className={clsx(
|
||||
`header-bar absolute top-0 z-10 flex h-11 w-full items-center pr-4`,
|
||||
isTrafficLightVisible && isTopLeft && !isSideBarVisible ? 'pl-16' : 'pl-4',
|
||||
`shadow-xs bg-base-100 rounded-window-top-right transition-opacity duration-300`,
|
||||
!isSideBarVisible && 'rounded-window-top-left',
|
||||
`shadow-xs bg-base-100 transition-opacity duration-300`,
|
||||
isTauriAppPlatform() && 'rounded-window-top-right',
|
||||
!isSideBarVisible && isTauriAppPlatform() && 'rounded-window-top-left',
|
||||
isHoveredAnim && 'hover-bar-anim',
|
||||
hoveredBookKey === bookKey || isDropdownOpen ? `visible` : `opacity-0`,
|
||||
isDropdownOpen && 'header-bar-pinned',
|
||||
@@ -57,16 +60,17 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
onMouseEnter={() => setHoveredBookKey(bookKey)}
|
||||
onMouseLeave={() => setHoveredBookKey('')}
|
||||
>
|
||||
<div className='sidebar-bookmark-toggler flex h-full items-center'>
|
||||
<div className='sidebar-bookmark-toggler flex h-full items-center space-x-2 sm:space-x-4'>
|
||||
<SidebarToggler bookKey={bookKey} />
|
||||
<BookmarkToggler bookKey={bookKey} />
|
||||
</div>
|
||||
|
||||
<div className='header-title flex flex-1 items-center justify-center'>
|
||||
<div className='header-title pointer-events-none absolute inset-0 hidden items-center justify-center sm:flex'>
|
||||
<h2 className='line-clamp-1 max-w-[80%] text-center text-xs font-semibold'>{bookTitle}</h2>
|
||||
</div>
|
||||
|
||||
<div className='flex h-full items-center space-x-2'>
|
||||
<div className='ml-auto flex h-full items-center space-x-2 sm:space-x-4'>
|
||||
<SettingsToggler />
|
||||
<NotebookToggler bookKey={bookKey} />
|
||||
<Dropdown
|
||||
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { TbLayoutSidebarRight, TbLayoutSidebarRightFilled } from 'react-icons/tb';
|
||||
import { LuNotebookPen } from 'react-icons/lu';
|
||||
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useNotebookStore } from '@/store/notebookStore';
|
||||
@@ -26,9 +26,9 @@ const NotebookToggler: React.FC<NotebookTogglerProps> = ({ bookKey }) => {
|
||||
<Button
|
||||
icon={
|
||||
sideBarBookKey == bookKey && isNotebookVisible ? (
|
||||
<TbLayoutSidebarRightFilled size={20} className='text-base-content' />
|
||||
<LuNotebookPen size={16} className='text-base-content' />
|
||||
) : (
|
||||
<TbLayoutSidebarRight size={20} className='text-base-content' />
|
||||
<LuNotebookPen size={16} className='text-base-content' />
|
||||
)
|
||||
}
|
||||
onClick={handleToggleSidebar}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
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 { isTauriAppPlatform } from '@/services/environment';
|
||||
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 +35,16 @@ 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={clsx(
|
||||
`reader-page bg-base-100 text-base-content select-none`,
|
||||
isTauriAppPlatform() && 'rounded-window',
|
||||
)}
|
||||
>
|
||||
<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();
|
||||
@@ -101,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);
|
||||
};
|
||||
@@ -143,7 +145,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
setTimeout(() => setLoading(true), 300);
|
||||
return (
|
||||
loading && (
|
||||
<div className={'hero hero-content min-h-screen'}>
|
||||
<div className={'hero hero-content h-dvh'}>
|
||||
<Spinner loading={true} />
|
||||
</div>
|
||||
)
|
||||
@@ -151,9 +153,10 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-screen'>
|
||||
<div className='flex h-dvh'>
|
||||
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
|
||||
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
|
||||
<TTSControl />
|
||||
<Notebook />
|
||||
{showDetailsBook && (
|
||||
<BookDetailModal
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { RiFontSize } from 'react-icons/ri';
|
||||
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import Button from '@/components/Button';
|
||||
|
||||
const SettingsToggler = () => {
|
||||
const _ = useTranslation();
|
||||
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
const handleToggleSettings = () => {
|
||||
setFontLayoutSettingsDialogOpen(!isFontLayoutSettingsDialogOpen);
|
||||
};
|
||||
return (
|
||||
<Button
|
||||
icon={<RiFontSize size={16} className='text-base-content' />}
|
||||
onClick={handleToggleSettings}
|
||||
tooltip={_('Font & Layout')}
|
||||
tooltipDirection='bottom'
|
||||
></Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsToggler;
|
||||
@@ -92,7 +92,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
className={clsx(
|
||||
'hover:bg-base-200 text-base-content rounded-full p-2',
|
||||
'hover:bg-base-300 text-base-content rounded-full p-2',
|
||||
zoomLevel <= MIN_ZOOM_LEVEL && 'btn-disabled text-gray-400',
|
||||
)}
|
||||
>
|
||||
@@ -100,7 +100,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
</button>
|
||||
<button
|
||||
className={clsx(
|
||||
'hover:bg-base-200 text-base-content h-8 min-h-8 w-[50%] rounded-md p-1 text-center',
|
||||
'hover:bg-base-300 text-base-content h-8 min-h-8 w-[50%] rounded-md p-1 text-center',
|
||||
)}
|
||||
onClick={resetZoom}
|
||||
>
|
||||
@@ -109,7 +109,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
className={clsx(
|
||||
'hover:bg-base-200 text-base-content rounded-full p-2',
|
||||
'hover:bg-base-300 text-base-content rounded-full p-2',
|
||||
zoomLevel >= MAX_ZOOM_LEVEL && 'btn-disabled text-gray-400',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Popup from '@/components/Popup';
|
||||
import { Position } from '@/utils/sel';
|
||||
import { getAPIBaseUrl } from '@/services/environment';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
const LANGUAGES = {
|
||||
AUTO: 'Auto Detect',
|
||||
@@ -22,6 +24,8 @@ const LANGUAGES = {
|
||||
'ZH-HANT': 'Chinese (Traditional)',
|
||||
};
|
||||
|
||||
const DEEPL_API_ENDPOINT = getAPIBaseUrl() + '/deepl/translate';
|
||||
|
||||
interface DeepLPopupProps {
|
||||
text: string;
|
||||
position: Position;
|
||||
@@ -37,6 +41,8 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
|
||||
popupWidth,
|
||||
popupHeight,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { token } = useAuth();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const [sourceLang, setSourceLang] = useState('AUTO');
|
||||
const [targetLang, setTargetLang] = useState(settings.globalReadSettings.translateTargetLang);
|
||||
@@ -61,23 +67,12 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
|
||||
setError(null);
|
||||
setTranslation(null);
|
||||
|
||||
if (!process.env['NEXT_PUBLIC_DEEPL_API_KEY']) {
|
||||
console.error('DeepL API key not found. Set NEXT_PUBLIC_DEEPL_API_KEY in .env.local');
|
||||
}
|
||||
|
||||
const { fetch, url } = isWebAppPlatform()
|
||||
? { fetch: window.fetch, url: '/api/deepl/translate' }
|
||||
: {
|
||||
fetch: (await import('@tauri-apps/plugin-http')).fetch,
|
||||
url: 'https://api-free.deepl.com/v2/translate',
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await fetch(DEEPL_API_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `DeepL-Auth-Key ${process.env['NEXT_PUBLIC_DEEPL_API_KEY']}`,
|
||||
Authorization: `Bearer ${token ?? ''}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: [text],
|
||||
@@ -104,14 +99,15 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
|
||||
setTranslation(translatedText);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Unable to fetch the translation. Try again later.');
|
||||
setError(_('Unable to fetch the translation. Try again later.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTranslation();
|
||||
}, [text, sourceLang, targetLang]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [text, token, sourceLang, targetLang]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -124,7 +120,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 +130,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 +142,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 +159,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') &&
|
||||
|
||||
@@ -15,7 +15,7 @@ const PopupButton: React.FC<PopupButtonProps> = ({ showTooltip, tooltipText, Ico
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className='tooltip tooltip-bottom'
|
||||
className='lg:tooltip lg:tooltip-bottom'
|
||||
data-tip={!buttonClicked && showTooltip ? tooltipText : null}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -1,26 +1,44 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { FiSearch } from 'react-icons/fi';
|
||||
import { LuNotebookPen } from 'react-icons/lu';
|
||||
import { MdOutlinePushPin, MdPushPin } from 'react-icons/md';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
const NotebookHeader: React.FC<{
|
||||
isPinned: boolean;
|
||||
handleClose: () => void;
|
||||
handleTogglePin: () => void;
|
||||
}> = ({ isPinned, handleTogglePin }) => {
|
||||
}> = ({ isPinned, handleClose, handleTogglePin }) => {
|
||||
const _ = useTranslation();
|
||||
return (
|
||||
<div className='notebook-header relative flex h-11 items-center px-3'>
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<div className='absolute inset-0 flex items-center justify-center space-x-2'>
|
||||
<LuNotebookPen size={14} />
|
||||
<div className='notebook-title text-sm font-medium'>{_('Notebook')}</div>
|
||||
</div>
|
||||
<div className='z-10 flex items-center space-x-3'>
|
||||
<div className='z-10 flex items-center gap-x-3'>
|
||||
<button
|
||||
onClick={handleTogglePin}
|
||||
className={`${isPinned ? 'bg-base-300' : 'bg-base-300/65'} btn btn-ghost btn-circle h-6 min-h-6 w-6`}
|
||||
className={clsx(
|
||||
'btn btn-ghost btn-circle hidden h-6 min-h-6 w-6 sm:flex',
|
||||
isPinned ? 'bg-base-300' : 'bg-base-300/65',
|
||||
)}
|
||||
>
|
||||
{isPinned ? <MdPushPin size={14} /> : <MdOutlinePushPin size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className={'bg-base-300/65 btn btn-ghost btn-circle h-6 min-h-6 w-6 sm:hidden'}
|
||||
>
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'>
|
||||
<path
|
||||
fill='currentColor'
|
||||
d='M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button className='btn btn-ghost left-0 h-8 min-h-8 w-8 p-0'>
|
||||
<FiSearch size={18} />
|
||||
</button>
|
||||
|
||||
@@ -8,13 +8,15 @@ import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useNotebookStore } from '@/store/notebookStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import useDragBar from '../../hooks/useDragBar';
|
||||
import NotebookHeader from './Header';
|
||||
import NoteEditor from './NoteEditor';
|
||||
import { TextSelection } from '@/utils/sel';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { uniqueId } from '@/utils/misc';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import useDragBar from '../../hooks/useDragBar';
|
||||
import BooknoteItem from '../sidebar/BooknoteItem';
|
||||
import NotebookHeader from './Header';
|
||||
import NoteEditor from './NoteEditor';
|
||||
|
||||
const MIN_NOTEBOOK_WIDTH = 0.15;
|
||||
const MAX_NOTEBOOK_WIDTH = 0.45;
|
||||
@@ -31,10 +33,23 @@ const Notebook: React.FC = ({}) => {
|
||||
const { setNotebookWidth, setNotebookVisible, toggleNotebookPin } = useNotebookStore();
|
||||
const { setNotebookNewAnnotation, setNotebookEditAnnotation } = useNotebookStore();
|
||||
|
||||
const onNavigateEvent = async () => {
|
||||
const pinButton = document.querySelector('.sidebar-pin-btn');
|
||||
const isPinButtonHidden = !pinButton || window.getComputedStyle(pinButton).display === 'none';
|
||||
if (isPinButtonHidden) {
|
||||
setNotebookVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setNotebookWidth(settings.globalReadSettings.notebookWidth);
|
||||
setNotebookPin(settings.globalReadSettings.isNotebookPinned);
|
||||
setNotebookVisible(settings.globalReadSettings.isNotebookPinned);
|
||||
|
||||
eventDispatcher.on('navigate', onNavigateEvent);
|
||||
return () => {
|
||||
eventDispatcher.off('navigate', onNavigateEvent);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -126,7 +141,7 @@ const Notebook: React.FC = ({}) => {
|
||||
<div
|
||||
className={clsx(
|
||||
'notebook-container bg-base-200 right-0 z-20 h-full min-w-60 select-none',
|
||||
'rounded-window-top-right rounded-window-bottom-right',
|
||||
isTauriAppPlatform() && 'rounded-window-top-right rounded-window-bottom-right',
|
||||
!isNotebookPinned && 'shadow-2xl',
|
||||
)}
|
||||
style={{
|
||||
@@ -135,11 +150,23 @@ const Notebook: React.FC = ({}) => {
|
||||
position: isNotebookPinned ? 'relative' : 'absolute',
|
||||
}}
|
||||
>
|
||||
<style jsx>{`
|
||||
@media (max-width: 640px) {
|
||||
.notebook-container {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
className='drag-bar absolute left-0 top-0 h-full w-0.5 cursor-col-resize'
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
<NotebookHeader isPinned={isNotebookPinned} handleTogglePin={handleTogglePin} />
|
||||
<NotebookHeader
|
||||
isPinned={isNotebookPinned}
|
||||
handleClose={() => setNotebookVisible(false)}
|
||||
handleTogglePin={handleTogglePin}
|
||||
/>
|
||||
<div className='max-h-[calc(100vh-44px)] overflow-y-auto px-3'>
|
||||
<div>{excerptNotes.length > 0 && <p className='pt-1 text-sm'>{_('Excerpts')}</p>}</div>
|
||||
<ul className=''>
|
||||
|
||||
@@ -28,7 +28,7 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Theme Mode')}</h2>
|
||||
<div className='flex gap-2'>
|
||||
<div className='tooltip tooltip-bottom' data-tip={_('Auto Mode')}>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Auto Mode')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle ${themeMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => updateThemeMode('auto')}
|
||||
@@ -37,7 +37,7 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='tooltip tooltip-bottom' data-tip={_('Light Mode')}>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Light Mode')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle ${themeMode === 'light' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => updateThemeMode('light')}
|
||||
@@ -46,7 +46,7 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='tooltip tooltip-bottom' data-tip={_('Dark Mode')}>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Dark Mode')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle ${themeMode === 'dark' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => updateThemeMode('dark')}
|
||||
|
||||
@@ -29,7 +29,12 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ toggleDropdown }) => {
|
||||
<span style={{ minWidth: '20px' }}>
|
||||
{isFontLayoutSettingsGlobal && <MdCheck size={20} className='text-base-content' />}
|
||||
</span>
|
||||
<div className='tooltip' data-tip='Uncheck for current book settings'>
|
||||
<div
|
||||
className='lg:tooltip'
|
||||
data-tip={
|
||||
isFontLayoutSettingsGlobal ? _('Apply to all books') : _('Apply to this book')
|
||||
}
|
||||
>
|
||||
<span className='ml-2'>{_('Global Settings')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,12 +57,12 @@ const FontDropdown: React.FC<DropdownProps> = ({
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'dropdown-content bgcolor-base-200 menu rounded-box relative z-[1] overflow-y-scroll shadow',
|
||||
'!mr-5 mb-[-46px] max-h-80 w-[292px]',
|
||||
'!mr-5 mb-[-46px] inline max-h-80 w-[250px] overflow-y-scroll',
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
|
||||
@@ -218,7 +218,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
</div>
|
||||
|
||||
<div className='config-item config-item-bottom'>
|
||||
<span className=''>{_('Override Publisher Font')}</span>
|
||||
<span className=''>{_('Override Book Font')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
|
||||
@@ -119,6 +119,17 @@ 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;
|
||||
@@ -137,7 +148,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
<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')}>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Default')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle ${writingMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('auto')}
|
||||
@@ -146,7 +157,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='tooltip tooltip-bottom' data-tip={_('Horizontal Direction')}>
|
||||
<div className='lg:tooltip lg: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')}
|
||||
@@ -155,7 +166,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='tooltip tooltip-bottom' data-tip={_('Vertical Direction')}>
|
||||
<div className='lg:tooltip lg: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')}
|
||||
@@ -209,7 +220,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<NumberInput
|
||||
className='config-item-top'
|
||||
label={_('Margins (px)')}
|
||||
label={_('Vertical Margins (px)')}
|
||||
value={marginPx}
|
||||
onChange={setMarginPx}
|
||||
min={0}
|
||||
@@ -217,7 +228,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
step={4}
|
||||
/>
|
||||
<NumberInput
|
||||
label={_('Gaps (%)')}
|
||||
label={_('Horizontal Margins (%)')}
|
||||
value={gapPercent}
|
||||
onChange={setGapPercent}
|
||||
min={0}
|
||||
@@ -228,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')}
|
||||
|
||||
@@ -99,7 +99,7 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
<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'
|
||||
@@ -116,7 +116,7 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
<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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { BookConfig } from '@/types/book';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -18,6 +19,12 @@ import MiscPanel from './MiscPanel';
|
||||
|
||||
type SettingsPanelType = 'Font' | 'Layout' | 'Color' | 'Misc';
|
||||
|
||||
type TabConfig = {
|
||||
tab: SettingsPanelType;
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const [activePanel, setActivePanel] = useState<SettingsPanelType>('Font');
|
||||
@@ -37,43 +44,51 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const tabConfig = [
|
||||
{
|
||||
tab: 'Font',
|
||||
icon: RiFontSize,
|
||||
label: _('Font'),
|
||||
},
|
||||
{
|
||||
tab: 'Layout',
|
||||
icon: RiDashboardLine,
|
||||
label: _('Layout'),
|
||||
},
|
||||
{
|
||||
tab: 'Color',
|
||||
icon: VscSymbolColor,
|
||||
label: _('Color'),
|
||||
},
|
||||
{
|
||||
tab: 'Misc',
|
||||
icon: IoAccessibilityOutline,
|
||||
label: _('Misc'),
|
||||
},
|
||||
] as TabConfig[];
|
||||
|
||||
return (
|
||||
<dialog className='modal modal-open min-w-90 w-full !bg-[rgba(0,0,0,0.2)]'>
|
||||
<div className='modal-box settings-content flex h-[65%] w-1/2 min-w-[540px] max-w-full flex-col p-0'>
|
||||
<dialog className='modal modal-open sm:min-w-90 h-full w-full !bg-[rgba(0,0,0,0.2)] sm:w-full'>
|
||||
<div className='modal-box settings-content flex h-full max-h-full w-full max-w-full flex-col rounded-none p-0 sm:h-[65%] sm:w-1/2 sm:min-w-[480px] sm:max-w-full sm:rounded-2xl'>
|
||||
<div className='dialog-header bg-base-100 sticky top-0 z-10 flex items-center justify-center px-4 pt-2'>
|
||||
<div className='dialog-tabs flex h-10 max-w-[80%] flex-grow items-center justify-around'>
|
||||
<button
|
||||
className={`btn btn-ghost text-base-content h-8 min-h-8 px-4 ${activePanel === 'Font' ? 'btn-active' : ''}`}
|
||||
onClick={() => setActivePanel('Font')}
|
||||
>
|
||||
<RiFontSize size={20} className='mr-0' />
|
||||
{_('Font')}
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-ghost text-base-content h-8 min-h-8 px-4 ${activePanel === 'Layout' ? 'btn-active' : ''}`}
|
||||
onClick={() => setActivePanel('Layout')}
|
||||
>
|
||||
<RiDashboardLine size={20} className='mr-0' />
|
||||
{_('Layout')}
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-ghost text-base-content h-8 min-h-8 px-4 ${activePanel === 'Color' ? 'btn-active' : ''}`}
|
||||
onClick={() => setActivePanel('Color')}
|
||||
>
|
||||
<VscSymbolColor size={20} className='mr-0' />
|
||||
{_('Color')}
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-ghost text-base-content h-8 min-h-8 px-4 ${activePanel === 'Misc' ? 'btn-active' : ''}`}
|
||||
onClick={() => setActivePanel('Misc')}
|
||||
>
|
||||
<IoAccessibilityOutline size={20} className='mr-0' />
|
||||
{_('Misc')}
|
||||
</button>
|
||||
<div className='dialog-tabs flex h-10 max-w-[75%] flex-grow items-center justify-around sm:max-w-[80%]'>
|
||||
{tabConfig.map(({ tab, icon: Icon, label }) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={clsx(
|
||||
'btn btn-ghost text-base-content h-8 min-h-8',
|
||||
activePanel === tab ? 'btn-active' : '',
|
||||
)}
|
||||
onClick={() => setActivePanel(tab)}
|
||||
>
|
||||
<Icon size={20} className='mr-0' />
|
||||
{window.innerWidth >= 500 ? label : ''}
|
||||
</button>
|
||||
))}
|
||||
</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} />}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
@@ -10,10 +11,11 @@ import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import useBooksManager from '../../hooks/useBooksManager';
|
||||
|
||||
interface BookMenuProps {
|
||||
menuClassName?: string;
|
||||
setIsDropdownOpen?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
const BookMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen }) => {
|
||||
const _ = useTranslation();
|
||||
const { library } = useLibraryStore();
|
||||
const { openParallelView } = useBooksManager();
|
||||
@@ -39,7 +41,10 @@ const BookMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className='book-menu dropdown-content dropdown-center border-base-100 z-20 mt-3 w-60 shadow-2xl'
|
||||
className={clsx(
|
||||
'book-menu dropdown-content border-base-100 z-20 w-60 shadow-2xl',
|
||||
menuClassName,
|
||||
)}
|
||||
>
|
||||
<MenuItem label={_('Parallel Read')} noIcon>
|
||||
<ul className='max-h-60 overflow-y-auto'>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useNotebookStore } from '@/store/notebookStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import useScrollToItem from '../../hooks/useScrollToItem';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
interface BooknoteItemProps {
|
||||
bookKey: string;
|
||||
@@ -29,6 +30,8 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
|
||||
|
||||
const handleClickItem = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
eventDispatcher.dispatch('navigate', { bookKey, cfi });
|
||||
|
||||
getView(bookKey)?.goTo(cfi);
|
||||
if (note) {
|
||||
setNotebookVisible(true);
|
||||
@@ -122,7 +125,16 @@ 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',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'hover:text-blue-600',
|
||||
)}
|
||||
>
|
||||
{_('Edit')}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -132,7 +144,16 @@ 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',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'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>
|
||||
|
||||
@@ -12,9 +12,10 @@ const SidebarHeader: React.FC<{
|
||||
isPinned: boolean;
|
||||
isSearchBarVisible: boolean;
|
||||
onGoToLibrary: () => void;
|
||||
onClose: () => void;
|
||||
onTogglePin: () => void;
|
||||
onToggleSearchBar: () => void;
|
||||
}> = ({ isPinned, isSearchBarVisible, onGoToLibrary, onTogglePin, onToggleSearchBar }) => {
|
||||
}> = ({ isPinned, isSearchBarVisible, onGoToLibrary, onClose, onTogglePin, onToggleSearchBar }) => {
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
return (
|
||||
<div
|
||||
@@ -39,7 +40,11 @@ const SidebarHeader: React.FC<{
|
||||
<FiSearch size={18} className='text-base-content' />
|
||||
</button>
|
||||
<Dropdown
|
||||
className='dropdown-bottom flex justify-center'
|
||||
className={clsx(
|
||||
window.innerWidth < 640 && 'dropdown-end',
|
||||
'dropdown-bottom flex justify-center',
|
||||
)}
|
||||
menuClassName={window.innerWidth < 640 ? 'no-triangle mt-1' : 'dropdown-center mt-3'}
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<MdOutlineMenu size={20} className='fill-base-content' />}
|
||||
>
|
||||
@@ -48,10 +53,24 @@ const SidebarHeader: React.FC<{
|
||||
<div className='right-0 flex h-8 w-8 items-center justify-center'>
|
||||
<button
|
||||
onClick={onTogglePin}
|
||||
className={`${isPinned ? 'bg-base-300' : 'bg-base-300/65'} btn btn-ghost btn-circle h-6 min-h-6 w-6`}
|
||||
className={clsx(
|
||||
'sidebar-pin-btn btn btn-ghost btn-circle hidden h-6 min-h-6 w-6 sm:flex',
|
||||
isPinned ? 'bg-base-300' : 'bg-base-300/65',
|
||||
)}
|
||||
>
|
||||
{isPinned ? <MdPushPin size={14} /> : <MdOutlinePushPin size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={'bg-base-300/65 btn btn-ghost btn-circle h-6 min-h-6 w-6 sm:hidden'}
|
||||
>
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'>
|
||||
<path
|
||||
fill='currentColor'
|
||||
d='M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -166,7 +166,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
className='w-full bg-transparent p-2 font-sans text-sm font-light focus:outline-none'
|
||||
/>
|
||||
|
||||
<div className='flex h-8 w-8 items-center rounded-r-lg bg-gray-300'>
|
||||
<div className='bg-base-300 flex h-8 w-8 items-center rounded-r-lg'>
|
||||
<Dropdown
|
||||
className='dropdown-bottom flex justify-center'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0 rounded-none rounded-r-lg'
|
||||
|
||||
@@ -9,100 +9,68 @@ interface SearchOptionsProps {
|
||||
setIsDropdownOpen?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
interface OptionProps {
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const Option: React.FC<OptionProps> = ({ label, isActive, onClick }) => (
|
||||
<button
|
||||
className='hover:bg-base-300 flex w-full items-center justify-between rounded-md p-2'
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<span style={{ minWidth: '20px' }}>
|
||||
{isActive && <MdCheck size={20} className='text-base-content' />}
|
||||
</span>
|
||||
<span className='ml-2'>{label}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
const SearchOptions: React.FC<SearchOptionsProps> = ({
|
||||
searchConfig,
|
||||
onSearchConfigChanged,
|
||||
setIsDropdownOpen,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const handleSetScope = () => {
|
||||
onSearchConfigChanged({
|
||||
...searchConfig,
|
||||
scope: searchConfig.scope === 'book' ? 'section' : 'book',
|
||||
});
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
const handleSetMatchCase = () => {
|
||||
onSearchConfigChanged({ ...searchConfig, matchCase: !searchConfig.matchCase });
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
const handleSetMatchWholeWords = () => {
|
||||
onSearchConfigChanged({ ...searchConfig, matchWholeWords: !searchConfig.matchWholeWords });
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
const handleSetMatchDiacritics = () => {
|
||||
onSearchConfigChanged({ ...searchConfig, matchDiacritics: !searchConfig.matchDiacritics });
|
||||
const updateConfig = (key: keyof BookSearchConfig, value: boolean | string) => {
|
||||
onSearchConfigChanged({ ...searchConfig, [key]: value });
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className='book-menu dropdown-content dropdown-center z-20 mt-3 w-56 border bg-white shadow-2xl'
|
||||
className='book-menu dropdown-content dropdown-center border-base-200 z-20 mt-3 w-56 border shadow-2xl'
|
||||
>
|
||||
<button
|
||||
className='flex w-full items-center justify-between rounded-md p-2 hover:bg-gray-100'
|
||||
onClick={handleSetScope}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<span style={{ minWidth: '20px' }}>
|
||||
{searchConfig.scope === 'book' && <MdCheck size={20} className='text-base-content' />}
|
||||
</span>
|
||||
<span className='ml-2'>{_('Book')}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className='flex w-full items-center justify-between rounded-md p-2 hover:bg-gray-100'
|
||||
onClick={handleSetScope}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<span style={{ minWidth: '20px' }}>
|
||||
{searchConfig.scope === 'section' && (
|
||||
<MdCheck size={20} className='text-base-content' />
|
||||
)}
|
||||
</span>
|
||||
<span className='ml-2'>{_('Chapter')}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<hr className='my-1' />
|
||||
|
||||
<button
|
||||
className='flex w-full items-center justify-between rounded-md p-2 hover:bg-gray-100'
|
||||
onClick={handleSetMatchCase}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<span style={{ minWidth: '20px' }}>
|
||||
{searchConfig.matchCase && <MdCheck size={20} className='text-base-content' />}
|
||||
</span>
|
||||
<span className='ml-2'>{_('Match Case')}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className='flex w-full items-center justify-between rounded-md p-2 hover:bg-gray-100'
|
||||
onClick={handleSetMatchWholeWords}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<span style={{ minWidth: '20px' }}>
|
||||
{searchConfig.matchWholeWords && <MdCheck size={20} className='text-base-content' />}
|
||||
</span>
|
||||
<span className='ml-2'>{_('Match Whole Words')}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className='flex w-full items-center justify-between rounded-md p-2 hover:bg-gray-100'
|
||||
onClick={handleSetMatchDiacritics}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<span style={{ minWidth: '20px' }}>
|
||||
{searchConfig.matchDiacritics && <MdCheck size={20} className='text-base-content' />}
|
||||
</span>
|
||||
<span className='ml-2'>{_('Match Diacritics')}</span>
|
||||
</div>
|
||||
</button>
|
||||
<Option
|
||||
label={_('Book')}
|
||||
isActive={searchConfig.scope === 'book'}
|
||||
onClick={() => updateConfig('scope', 'book')}
|
||||
/>
|
||||
<Option
|
||||
label={_('Chapter')}
|
||||
isActive={searchConfig.scope === 'section'}
|
||||
onClick={() => updateConfig('scope', 'section')}
|
||||
/>
|
||||
<hr className='border-base-200 my-1' />
|
||||
<Option
|
||||
label={_('Match Case')}
|
||||
isActive={searchConfig.matchCase}
|
||||
onClick={() => updateConfig('matchCase', !searchConfig.matchCase)}
|
||||
/>
|
||||
<Option
|
||||
label={_('Match Whole Words')}
|
||||
isActive={searchConfig.matchWholeWords}
|
||||
onClick={() => updateConfig('matchWholeWords', !searchConfig.matchWholeWords)}
|
||||
/>
|
||||
<Option
|
||||
label={_('Match Diacritics')}
|
||||
isActive={searchConfig.matchDiacritics}
|
||||
onClick={() => updateConfig('matchDiacritics', !searchConfig.matchDiacritics)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { BookSearchResult } from '@/types/book';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import SidebarHeader from './Header';
|
||||
import SidebarContent from './Content';
|
||||
import BookCard from './BookCard';
|
||||
@@ -38,7 +39,7 @@ const SideBar: React.FC<{
|
||||
handleSideBarTogglePin,
|
||||
} = useSidebar(
|
||||
settings.globalReadSettings.sideBarWidth,
|
||||
settings.globalReadSettings.isSideBarPinned,
|
||||
window.innerWidth >= 640 ? settings.globalReadSettings.isSideBarPinned : false,
|
||||
);
|
||||
|
||||
const onSearchEvent = async (event: CustomEvent) => {
|
||||
@@ -48,10 +49,20 @@ const SideBar: React.FC<{
|
||||
setSearchTerm(term);
|
||||
};
|
||||
|
||||
const onNavigateEvent = async () => {
|
||||
const pinButton = document.querySelector('.sidebar-pin-btn');
|
||||
const isPinButtonHidden = !pinButton || window.getComputedStyle(pinButton).display === 'none';
|
||||
if (isPinButtonHidden) {
|
||||
setSideBarVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
eventDispatcher.on('search', onSearchEvent);
|
||||
eventDispatcher.on('navigate', onNavigateEvent);
|
||||
return () => {
|
||||
eventDispatcher.off('search', onSearchEvent);
|
||||
eventDispatcher.off('navigate', onNavigateEvent);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -79,6 +90,7 @@ const SideBar: React.FC<{
|
||||
useShortcuts({ onToggleSearchBar: handleToggleSearchBar }, [sideBarBookKey]);
|
||||
|
||||
const handleSearchResultClick = (cfi: string) => {
|
||||
onNavigateEvent();
|
||||
getView(sideBarBookKey)?.goTo(cfi);
|
||||
};
|
||||
|
||||
@@ -94,8 +106,8 @@ const SideBar: React.FC<{
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
'sidebar-container bg-base-200 z-20 flex h-full min-w-60 flex-col',
|
||||
'rounded-window-top-left rounded-window-bottom-left select-none',
|
||||
'sidebar-container bg-base-200 z-20 flex h-full min-w-60 select-none flex-col',
|
||||
isTauriAppPlatform() && 'rounded-window-top-left rounded-window-bottom-left',
|
||||
!isSideBarPinned && 'shadow-2xl',
|
||||
)}
|
||||
style={{
|
||||
@@ -104,11 +116,20 @@ const SideBar: React.FC<{
|
||||
position: isSideBarPinned ? 'relative' : 'absolute',
|
||||
}}
|
||||
>
|
||||
<style jsx>{`
|
||||
@media (max-width: 640px) {
|
||||
.sidebar-container {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<div className='flex-shrink-0'>
|
||||
<SidebarHeader
|
||||
isPinned={isSideBarPinned}
|
||||
isSearchBarVisible={isSearchBarVisible}
|
||||
onGoToLibrary={onGoToLibrary}
|
||||
onClose={() => setSideBarVisible(false)}
|
||||
onTogglePin={handleSideBarTogglePin}
|
||||
onToggleSearchBar={handleToggleSearchBar}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { TOCItem } from '@/libs/document';
|
||||
@@ -5,7 +6,7 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { findParentPath } from '@/utils/toc';
|
||||
import { getContentMd5 } from '@/utils/misc';
|
||||
import clsx from 'clsx';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
const createExpanderIcon = (isExpanded: boolean) => {
|
||||
return (
|
||||
@@ -43,6 +44,7 @@ const TOCItemView: React.FC<{
|
||||
|
||||
const handleClickItem = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
eventDispatcher.dispatch('navigate', { bookKey, href: item.href });
|
||||
if (item.href) {
|
||||
getView(bookKey)?.goTo(item.href);
|
||||
}
|
||||
@@ -126,7 +128,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,53 @@
|
||||
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 relative flex w-full border-t'>
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-base-300 absolute bottom-1.5 left-1 -z-10 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
|
||||
'transform transition-transform duration-300',
|
||||
activeTab === 'toc' && 'translate-x-0',
|
||||
activeTab === 'annotations' && 'translate-x-[calc(100%+8px)]',
|
||||
activeTab === 'bookmarks' && 'translate-x-[calc(200%+16px)]',
|
||||
)}
|
||||
/>
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab}
|
||||
className='lg:tooltip lg:tooltip-top z-50 m-1.5 flex-1 cursor-pointer rounded-md p-2'
|
||||
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,283 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
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 { throttle } from '@/utils/ui';
|
||||
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 { getBookData } = useBookDataStore();
|
||||
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 [showIndicator, setShowIndicator] = 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);
|
||||
eventDispatcher.onSync('tts-is-speaking', handleQueryIsSpeaking);
|
||||
return () => {
|
||||
eventDispatcher.off('tts-speak', handleTTSSpeak);
|
||||
eventDispatcher.off('tts-stop', handleTTSStop);
|
||||
eventDispatcher.offSync('tts-is-speaking', handleQueryIsSpeaking);
|
||||
};
|
||||
// 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);
|
||||
const bookData = getBookData(bookKey);
|
||||
if (!view || !viewSettings || !bookData) return;
|
||||
if (bookData.book?.format === 'PDF') {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('TTS not supported for PDF'),
|
||||
type: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setBookKey(bookKey);
|
||||
|
||||
if (ttsControllerRef.current) {
|
||||
ttsControllerRef.current.stop();
|
||||
ttsControllerRef.current = null;
|
||||
}
|
||||
setShowIndicator(true);
|
||||
|
||||
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: stopBookKey } = event.detail;
|
||||
if (bookKey === stopBookKey) {
|
||||
handleStop();
|
||||
}
|
||||
};
|
||||
|
||||
const handleQueryIsSpeaking = () => {
|
||||
return !!ttsControllerRef.current;
|
||||
};
|
||||
|
||||
const handleTogglePlay = async () => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (!ttsController) return;
|
||||
|
||||
if (isPlaying) {
|
||||
setIsPlaying(false);
|
||||
setIsPaused(true);
|
||||
await ttsController.pause();
|
||||
} else if (isPaused) {
|
||||
setIsPlaying(true);
|
||||
setIsPaused(false);
|
||||
// start for forward/backward/setvoice-paused
|
||||
// set rate don't pause the tts
|
||||
if (ttsController.state === 'paused') {
|
||||
await ttsController.resume();
|
||||
} else {
|
||||
await ttsController.start();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
await ttsController.stop();
|
||||
ttsControllerRef.current = null;
|
||||
getView(bookKey)?.deselect();
|
||||
setIsPlaying(false);
|
||||
setShowPanel(false);
|
||||
setShowIndicator(false);
|
||||
}
|
||||
};
|
||||
|
||||
// rate range: 0.5 - 3, 1.0 is normal speed
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleSetRate = useCallback(
|
||||
throttle(async (rate: number) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
if (ttsController.state === 'playing') {
|
||||
await ttsController.stop();
|
||||
await ttsController.setRate(rate);
|
||||
await ttsController.start();
|
||||
} else {
|
||||
await ttsController.setRate(rate);
|
||||
}
|
||||
}
|
||||
}, 3000),
|
||||
[],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleSetVoice = useCallback(
|
||||
throttle(async (voice: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
if (ttsController.state === 'playing') {
|
||||
await ttsController.stop();
|
||||
await ttsController.setVoice(voice);
|
||||
await ttsController.start();
|
||||
} else {
|
||||
await ttsController.setVoice(voice);
|
||||
}
|
||||
}
|
||||
}, 3000),
|
||||
[],
|
||||
);
|
||||
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
{showIndicator && (
|
||||
<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 menu-vertical rounded-box absolute right-0 z-[1] shadow',
|
||||
'mt-4 inline 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;
|
||||
@@ -29,14 +29,7 @@ export const useClickEvent = (
|
||||
if (!consumed) {
|
||||
const centerStartX = rect.left + rect.width * 0.375;
|
||||
const centerEndX = rect.left + rect.width * 0.625;
|
||||
const centerStartY = rect.top + rect.height * 0.375;
|
||||
const centerEndY = rect.top + rect.height * 0.625;
|
||||
if (
|
||||
screenX >= centerStartX &&
|
||||
screenX <= centerEndX &&
|
||||
screenY >= centerStartY &&
|
||||
screenY <= centerEndY
|
||||
) {
|
||||
if (screenX >= centerStartX && screenX <= centerEndX) {
|
||||
// toggle visibility of the header bar and the footer bar
|
||||
setHoveredBookKey(hoveredBookKey ? '' : bookKey);
|
||||
} else if (screenX >= rect.left + rect.width / 2) {
|
||||
|
||||
@@ -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;
|
||||
@@ -29,16 +29,32 @@ export const useProgressSync = (
|
||||
const compressedConfig = JSON.parse(
|
||||
serializeConfig(newConfig, settings.globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG),
|
||||
);
|
||||
delete compressedConfig.booknotes;
|
||||
syncConfigs([compressedConfig], bookHash, 'push');
|
||||
};
|
||||
const pullConfig = (bookKey: string) => {
|
||||
if (!user) return;
|
||||
const bookHash = bookKey.split('-')[0]!;
|
||||
syncConfigs([], bookHash, 'pull');
|
||||
};
|
||||
const syncConfig = () => {
|
||||
if (!configSynced.current) {
|
||||
pullConfig(bookKey);
|
||||
} else {
|
||||
if (config && config.progress && config.progress[0] > 0) {
|
||||
pushConfig(bookKey, config);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const bookHash = bookKey.split('-')[0]!;
|
||||
syncConfigs([], bookHash, 'pull');
|
||||
return () => {
|
||||
pushConfig(bookKey, config);
|
||||
};
|
||||
pullConfig(bookKey);
|
||||
// return () => {
|
||||
// if (configSynced.current) {
|
||||
// pushConfig(bookKey, config);
|
||||
// }
|
||||
// };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -46,18 +62,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 +83,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 +94,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'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ import React, { useEffect, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { formatDate, formatSubject } from '@/utils/book';
|
||||
import { formatDate, formatLanguage, formatPublisher, formatSubject } from '@/utils/book';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
import Spinner from './Spinner';
|
||||
import { BookDoc } from '@/libs/document';
|
||||
|
||||
interface BookDetailModalProps {
|
||||
book: Book;
|
||||
@@ -123,7 +123,7 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Publisher:')}</span>
|
||||
<p className='text-neutral-content line-clamp-1 text-sm'>
|
||||
{bookMeta.publisher || _('Unknown')}
|
||||
{formatPublisher(bookMeta.publisher || '') || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
@@ -141,7 +141,9 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Language:')}</span>
|
||||
<p className='text-neutral-content text-sm'>{bookMeta.language || _('Unknown')}</p>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
{formatLanguage(bookMeta.language) || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Identifier:')}</span>
|
||||
|
||||
@@ -20,14 +20,18 @@ const Button: React.FC<ButtonProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx('tooltip z-50', tooltip && `tooltip-${tooltipDirection}`, {
|
||||
'tooltip-hidden': !tooltip,
|
||||
})}
|
||||
className={clsx(
|
||||
'lg:tooltip z-50 h-8 min-h-8 w-8',
|
||||
tooltip && `lg:tooltip-${tooltipDirection}`,
|
||||
{
|
||||
'tooltip-hidden': !tooltip,
|
||||
},
|
||||
)}
|
||||
data-tip={tooltip}
|
||||
>
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-ghost mx-2 h-8 min-h-8 w-8 p-0',
|
||||
'btn btn-ghost h-8 min-h-8 w-8 p-0',
|
||||
disabled && 'btn-disabled !bg-transparent',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -3,14 +3,16 @@ import React, { useState, isValidElement, ReactElement } from 'react';
|
||||
|
||||
interface DropdownProps {
|
||||
className?: string;
|
||||
menuClassName?: string;
|
||||
buttonClassName?: string;
|
||||
toggleButton: React.ReactNode;
|
||||
children: ReactElement<{ setIsDropdownOpen: (isOpen: boolean) => void }>;
|
||||
children: ReactElement<{ setIsDropdownOpen: (isOpen: boolean) => void; menuClassName?: string }>;
|
||||
onToggle?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
const Dropdown: React.FC<DropdownProps> = ({
|
||||
className,
|
||||
menuClassName,
|
||||
buttonClassName,
|
||||
toggleButton,
|
||||
children,
|
||||
@@ -30,7 +32,7 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
};
|
||||
|
||||
const childrenWithToggle = isValidElement(children)
|
||||
? React.cloneElement(children, { setIsDropdownOpen })
|
||||
? React.cloneElement(children, { setIsDropdownOpen, menuClassName })
|
||||
: children;
|
||||
|
||||
return (
|
||||
@@ -42,7 +44,7 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
<div
|
||||
tabIndex={-1}
|
||||
onClick={toggleDropdown}
|
||||
className={clsx('dropdown-toggle', buttonClassName, isOpen && 'bg-base-200')}
|
||||
className={clsx('dropdown-toggle', buttonClassName, isOpen && 'bg-base-300/50')}
|
||||
>
|
||||
{toggleButton}
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ const MenuItem: React.FC<MenuItemProps> = ({
|
||||
const menuButton = (
|
||||
<button
|
||||
className={clsx(
|
||||
'hover:bg-neutral text-base-content flex h-10 w-full items-center justify-between rounded-md p-2',
|
||||
'hover:bg-base-300 text-base-content flex h-10 w-full items-center justify-between rounded-md p-2',
|
||||
disabled && 'btn-disabled text-gray-400',
|
||||
)}
|
||||
onClick={onClick}
|
||||
@@ -44,7 +44,7 @@ const MenuItem: React.FC<MenuItemProps> = ({
|
||||
<ul className='menu rounded-box m-0 p-0'>
|
||||
<li>
|
||||
<details>
|
||||
<summary className='hover:bg-neutral p-0 pr-3'>{menuButton}</summary>
|
||||
<summary className='hover:bg-base-300 p-0 pr-3'>{menuButton}</summary>
|
||||
{children}
|
||||
</details>
|
||||
</li>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,31 +29,39 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const syncSession = (session: { access_token: string; user: User } | null) => {
|
||||
console.log('Syncing session');
|
||||
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 fetchSession = async () => {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
syncSession(data.session);
|
||||
const refreshSession = async () => {
|
||||
try {
|
||||
await supabase.auth.refreshSession();
|
||||
} catch {
|
||||
syncSession(null);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSession();
|
||||
const { data: subscription } = supabase.auth.onAuthStateChange((_, session) => {
|
||||
syncSession(session);
|
||||
});
|
||||
|
||||
refreshSession();
|
||||
return () => {
|
||||
subscription?.subscription.unsubscribe();
|
||||
};
|
||||
@@ -69,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 (
|
||||
|
||||
@@ -30,6 +30,8 @@ const computeMaxTimestamp = (records: BookDataRecord[]): number => {
|
||||
return maxTime;
|
||||
};
|
||||
|
||||
const SEVEN_DAYS_IN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function useSync(bookKey?: string) {
|
||||
const { settings } = useSettingsStore();
|
||||
const { getConfig, setConfig } = useBookDataStore();
|
||||
@@ -40,25 +42,31 @@ export function useSync(bookKey?: string) {
|
||||
const [syncingNotes, setSyncingNotes] = useState(false);
|
||||
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
|
||||
const lastSyncedBooksAt = settings.lastSyncedAtBooks ?? 0;
|
||||
const lastSyncedConfigsAt = config?.lastSyncedAtConfig ?? settings.lastSyncedAtConfigs ?? 0;
|
||||
const lastSyncedNotesAt = config?.lastSyncedAtNotes ?? settings.lastSyncedAtNotes ?? 0;
|
||||
|
||||
const [lastSyncedAtBooks, setLastSyncedAtBooks] = useState<number>(
|
||||
settings.lastSyncedAtBooks ?? 0,
|
||||
lastSyncedBooksAt > 0 ? lastSyncedBooksAt - SEVEN_DAYS_IN_MS : 0,
|
||||
);
|
||||
const [lastSyncedAtConfigs, setLastSyncedAtConfigs] = useState<number>(
|
||||
config?.lastSyncedAtConfig ?? settings.lastSyncedAtConfigs ?? 0,
|
||||
lastSyncedConfigsAt > 0 ? lastSyncedConfigsAt - SEVEN_DAYS_IN_MS : 0,
|
||||
);
|
||||
const [lastSyncedAtNotes, setLastSyncedAtNotes] = useState<number>(
|
||||
config?.lastSyncedAtNotes ?? settings.lastSyncedAtNotes ?? 0,
|
||||
lastSyncedNotesAt > 0 ? lastSyncedNotesAt - SEVEN_DAYS_IN_MS : 0,
|
||||
);
|
||||
|
||||
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 +84,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 +177,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]);
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
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 = {
|
||||
'af-ZA': ['af-ZA-AdriNeural', 'af-ZA-WillemNeural'],
|
||||
'am-ET': ['am-ET-AmehaNeural', 'am-ET-MekdesNeural'],
|
||||
'ar-AE': ['ar-AE-FatimaNeural', 'ar-AE-HamdanNeural'],
|
||||
'ar-BH': ['ar-BH-AliNeural', 'ar-BH-LailaNeural'],
|
||||
'ar-DZ': ['ar-DZ-AminaNeural', 'ar-DZ-IsmaelNeural'],
|
||||
'ar-EG': ['ar-EG-SalmaNeural', 'ar-EG-ShakirNeural'],
|
||||
'ar-IQ': ['ar-IQ-BasselNeural', 'ar-IQ-RanaNeural'],
|
||||
'ar-JO': ['ar-JO-SanaNeural', 'ar-JO-TaimNeural'],
|
||||
'ar-KW': ['ar-KW-FahedNeural', 'ar-KW-NouraNeural'],
|
||||
'ar-LB': ['ar-LB-LaylaNeural', 'ar-LB-RamiNeural'],
|
||||
'ar-LY': ['ar-LY-ImanNeural', 'ar-LY-OmarNeural'],
|
||||
'ar-MA': ['ar-MA-JamalNeural', 'ar-MA-MounaNeural'],
|
||||
'ar-OM': ['ar-OM-AbdullahNeural', 'ar-OM-AyshaNeural'],
|
||||
'ar-QA': ['ar-QA-AmalNeural', 'ar-QA-MoazNeural'],
|
||||
'ar-SA': ['ar-SA-HamedNeural', 'ar-SA-ZariyahNeural'],
|
||||
'ar-SY': ['ar-SY-AmanyNeural', 'ar-SY-LaithNeural'],
|
||||
'ar-TN': ['ar-TN-HediNeural', 'ar-TN-ReemNeural'],
|
||||
'ar-YE': ['ar-YE-MaryamNeural', 'ar-YE-SalehNeural'],
|
||||
'az-AZ': ['az-AZ-BabekNeural', 'az-AZ-BanuNeural'],
|
||||
'bg-BG': ['bg-BG-BorislavNeural', 'bg-BG-KalinaNeural'],
|
||||
'bn-BD': ['bn-BD-NabanitaNeural', 'bn-BD-PradeepNeural'],
|
||||
'bn-IN': ['bn-IN-BashkarNeural', 'bn-IN-TanishaaNeural'],
|
||||
'bs-BA': ['bs-BA-GoranNeural', 'bs-BA-VesnaNeural'],
|
||||
'ca-ES': ['ca-ES-EnricNeural', 'ca-ES-JoanaNeural'],
|
||||
'cs-CZ': ['cs-CZ-AntoninNeural', 'cs-CZ-VlastaNeural'],
|
||||
'cy-GB': ['cy-GB-AledNeural', 'cy-GB-NiaNeural'],
|
||||
'da-DK': ['da-DK-ChristelNeural', 'da-DK-JeppeNeural'],
|
||||
'de-AT': ['de-AT-IngridNeural', 'de-AT-JonasNeural'],
|
||||
'de-CH': ['de-CH-JanNeural', 'de-CH-LeniNeural'],
|
||||
'de-DE': [
|
||||
'de-DE-AmalaNeural',
|
||||
'de-DE-ConradNeural',
|
||||
'de-DE-FlorianMultilingualNeural',
|
||||
'de-DE-KatjaNeural',
|
||||
'de-DE-KillianNeural',
|
||||
'de-DE-SeraphinaMultilingualNeural',
|
||||
],
|
||||
'el-GR': ['el-GR-AthinaNeural', 'el-GR-NestorasNeural'],
|
||||
'en-AU': ['en-AU-NatashaNeural', 'en-AU-WilliamNeural'],
|
||||
'en-CA': ['en-CA-ClaraNeural', 'en-CA-LiamNeural'],
|
||||
'en-GB': [
|
||||
'en-GB-LibbyNeural',
|
||||
'en-GB-MaisieNeural',
|
||||
'en-GB-RyanNeural',
|
||||
'en-GB-SoniaNeural',
|
||||
'en-GB-ThomasNeural',
|
||||
],
|
||||
'en-HK': ['en-HK-SamNeural', 'en-HK-YanNeural'],
|
||||
'en-IE': ['en-IE-ConnorNeural', 'en-IE-EmilyNeural'],
|
||||
'en-IN': ['en-IN-NeerjaExpressiveNeural', 'en-IN-NeerjaNeural', 'en-IN-PrabhatNeural'],
|
||||
'en-KE': ['en-KE-AsiliaNeural', 'en-KE-ChilembaNeural'],
|
||||
'en-NG': ['en-NG-AbeoNeural', 'en-NG-EzinneNeural'],
|
||||
'en-NZ': ['en-NZ-MitchellNeural', 'en-NZ-MollyNeural'],
|
||||
'en-PH': ['en-PH-JamesNeural', 'en-PH-RosaNeural'],
|
||||
'en-SG': ['en-SG-LunaNeural', 'en-SG-WayneNeural'],
|
||||
'en-TZ': ['en-TZ-ElimuNeural', 'en-TZ-ImaniNeural'],
|
||||
'en-US': [
|
||||
'en-US-AnaNeural',
|
||||
'en-US-AndrewMultilingualNeural',
|
||||
'en-US-AndrewNeural',
|
||||
'en-US-AriaNeural',
|
||||
'en-US-AvaMultilingualNeural',
|
||||
'en-US-AvaNeural',
|
||||
'en-US-BrianMultilingualNeural',
|
||||
'en-US-BrianNeural',
|
||||
'en-US-ChristopherNeural',
|
||||
'en-US-EmmaMultilingualNeural',
|
||||
'en-US-EmmaNeural',
|
||||
'en-US-EricNeural',
|
||||
'en-US-GuyNeural',
|
||||
'en-US-JennyNeural',
|
||||
'en-US-MichelleNeural',
|
||||
'en-US-RogerNeural',
|
||||
'en-US-SteffanNeural',
|
||||
],
|
||||
'es-AR': ['es-AR-ElenaNeural', 'es-AR-TomasNeural'],
|
||||
'es-BO': ['es-BO-MarceloNeural', 'es-BO-SofiaNeural'],
|
||||
'es-CL': ['es-CL-CatalinaNeural', 'es-CL-LorenzoNeural'],
|
||||
'es-CO': ['es-CO-GonzaloNeural', 'es-CO-SalomeNeural'],
|
||||
'es-CR': ['es-CR-JuanNeural', 'es-CR-MariaNeural'],
|
||||
'es-CU': ['es-CU-BelkysNeural', 'es-CU-ManuelNeural'],
|
||||
'es-DO': ['es-DO-EmilioNeural', 'es-DO-RamonaNeural'],
|
||||
'es-EC': ['es-EC-AndreaNeural', 'es-EC-LuisNeural'],
|
||||
'es-ES': ['es-ES-AlvaroNeural', 'es-ES-ElviraNeural', 'es-ES-XimenaNeural'],
|
||||
'es-US': ['es-US-AlonsoNeural', 'es-US-PalomaNeural'],
|
||||
'fr-BE': ['fr-BE-CharlineNeural', 'fr-BE-GerardNeural'],
|
||||
'fr-CA': ['fr-CA-AntoineNeural', 'fr-CA-JeanNeural', 'fr-CA-SylvieNeural', 'fr-CA-ThierryNeural'],
|
||||
'fr-CH': ['fr-CH-ArianeNeural', 'fr-CH-FabriceNeural'],
|
||||
'fr-FR': [
|
||||
'fr-FR-DeniseNeural',
|
||||
'fr-FR-EloiseNeural',
|
||||
'fr-FR-HenriNeural',
|
||||
'fr-FR-RemyMultilingualNeural',
|
||||
'fr-FR-VivienneMultilingualNeural',
|
||||
],
|
||||
'ja-JP': ['ja-JP-KeitaNeural', 'ja-JP-NanamiNeural'],
|
||||
'ko-KR': ['ko-KR-HyunsuMultilingualNeural', 'ko-KR-InJoonNeural', 'ko-KR-SunHiNeural'],
|
||||
'pt-BR': ['pt-BR-AntonioNeural', 'pt-BR-FranciscaNeural', 'pt-BR-ThalitaMultilingualNeural'],
|
||||
'pt-PT': ['pt-PT-DuarteNeural', 'pt-PT-RaquelNeural'],
|
||||
'zh-CN': [
|
||||
'zh-CN-XiaoxiaoNeural',
|
||||
'zh-CN-XiaoyiNeural',
|
||||
'zh-CN-YunjianNeural',
|
||||
'zh-CN-YunxiNeural',
|
||||
'zh-CN-YunxiaNeural',
|
||||
'zh-CN-YunyangNeural',
|
||||
'zh-CN-liaoning-XiaobeiNeural',
|
||||
'zh-CN-shaanxi-XiaoniNeural',
|
||||
],
|
||||
'zh-HK': ['zh-HK-HiuGaaiNeural', 'zh-HK-HiuMaanNeural', 'zh-HK-WanLungNeural'],
|
||||
'zh-TW': ['zh-TW-HsiaoChenNeural', 'zh-TW-HsiaoYuNeural', 'zh-TW-YunJheNeural'],
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { Book, BookConfig, BookNote, BookDataRecord } from '@/types/book';
|
||||
import { READEST_WEB_BASE_URL } from '@/services/constants';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { getAPIBaseUrl, isWebAppPlatform } from '@/services/environment';
|
||||
|
||||
// Develop Sync API only in development mode and web platform
|
||||
// with command `pnpm dev-web`
|
||||
const SYNC_API_ENDPOINT =
|
||||
process.env['NODE_ENV'] === 'development' && isWebAppPlatform()
|
||||
? '/api/sync'
|
||||
: `${READEST_WEB_BASE_URL}/api/sync`;
|
||||
const SYNC_API_ENDPOINT = getAPIBaseUrl() + '/sync';
|
||||
|
||||
export type SyncType = 'books' | 'configs' | 'notes';
|
||||
export type SyncOp = 'push' | 'pull' | 'both';
|
||||
@@ -18,9 +12,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 +73,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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AppProps } from 'next/app';
|
||||
import Head from 'next/head';
|
||||
import Providers from '@/components/Providers';
|
||||
|
||||
import '../styles/globals.css';
|
||||
@@ -6,9 +7,17 @@ import '../styles/fonts.css';
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<Providers>
|
||||
<Component {...pageProps} />
|
||||
</Providers>
|
||||
<>
|
||||
<Head>
|
||||
<meta
|
||||
name='viewport'
|
||||
content='width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, user-scalable=no'
|
||||
/>
|
||||
</Head>
|
||||
<Providers>
|
||||
<Component {...pageProps} />
|
||||
</Providers>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
|
||||
const DEEPL_FREE_API = 'https://api-free.deepl.com/v2/translate';
|
||||
const DEEPL_PRO_API = 'https://api.deepl.com/v2/translate';
|
||||
|
||||
const getUserAndToken = async (authHeader: string | undefined) => {
|
||||
if (!authHeader) return {};
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser(token);
|
||||
|
||||
if (error || !user) return {};
|
||||
return { user, token };
|
||||
};
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { user, token } = await getUserAndToken(req.headers['authorization']);
|
||||
const deeplApiUrl = user && token ? DEEPL_PRO_API : DEEPL_FREE_API;
|
||||
const deeplAuthKey =
|
||||
user && token ? process.env['DEEPL_PRO_API_KEY'] : process.env['DEEPL_FREE_API_KEY'];
|
||||
|
||||
try {
|
||||
console.log('Proxying DeepL request:', deeplApiUrl, deeplAuthKey);
|
||||
const response = await fetch(deeplApiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `DeepL-Auth-Key ${deeplAuthKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: req.method === 'POST' ? JSON.stringify(req.body) : undefined,
|
||||
});
|
||||
res.status(response.status);
|
||||
res.json(await response.json());
|
||||
} catch (error) {
|
||||
console.error('Error proxying DeepL request:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -8,7 +15,7 @@ export const SUPPORTED_FILE_EXTS = ['epub', 'mobi', 'azw', 'azw3', 'fb2', 'cbz',
|
||||
export const FILE_ACCEPT_FORMATS = SUPPORTED_FILE_EXTS.map((ext) => `.${ext}`).join(', ');
|
||||
|
||||
export const DEFAULT_READSETTINGS: ReadSettings = {
|
||||
sideBarWidth: '25%',
|
||||
sideBarWidth: '15%',
|
||||
isSideBarPinned: true,
|
||||
notebookWidth: '25%',
|
||||
isNotebookPinned: false,
|
||||
@@ -61,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,
|
||||
@@ -83,6 +95,8 @@ export const SANS_SERIF_FONTS = ['Roboto', 'Noto Sans', 'Open Sans', 'Helvetica'
|
||||
|
||||
export const MONOSPACE_FONTS = ['Fira Code', 'Lucida Console', 'Consolas', 'Courier New'];
|
||||
|
||||
export const FALLBACK_FONTS = ['MiSans L3'];
|
||||
|
||||
export const WINDOWS_FONTS = [
|
||||
'Arial',
|
||||
'Arial Black',
|
||||
@@ -97,6 +111,7 @@ export const WINDOWS_FONTS = [
|
||||
'Corbel',
|
||||
'Courier New',
|
||||
'Ebrima',
|
||||
'FangSong',
|
||||
'Franklin Gothic Medium',
|
||||
'Gabriola',
|
||||
'Gadugi',
|
||||
@@ -105,6 +120,7 @@ export const WINDOWS_FONTS = [
|
||||
'Impact',
|
||||
'Ink Free',
|
||||
'Javanese Text',
|
||||
'KaiTi',
|
||||
'Leelawadee UI',
|
||||
'Lucida Console',
|
||||
'Lucida Sans Unicode',
|
||||
@@ -118,13 +134,16 @@ export const WINDOWS_FONTS = [
|
||||
'Microsoft Tai Le',
|
||||
'Microsoft YaHei',
|
||||
'Microsoft Yi Baiti',
|
||||
'MingLiU',
|
||||
'MingLiU-ExtB',
|
||||
'Mongolian Baiti',
|
||||
'MS Gothic',
|
||||
'MV Boli',
|
||||
'Myanmar Text',
|
||||
'Nirmala UI',
|
||||
'NSimSun',
|
||||
'Palatino Linotype',
|
||||
'PMingLiU',
|
||||
'Segoe MDL2 Assets',
|
||||
'Segoe Print',
|
||||
'Segoe Script',
|
||||
@@ -132,6 +151,7 @@ export const WINDOWS_FONTS = [
|
||||
'Segoe UI Historic',
|
||||
'Segoe UI Emoji',
|
||||
'Segoe UI Symbol',
|
||||
'SimHei',
|
||||
'SimSun',
|
||||
'Sitka',
|
||||
'Sylfaen',
|
||||
@@ -157,6 +177,7 @@ export const MACOS_FONTS = [
|
||||
'Avenir Next',
|
||||
'Avenir Next Condensed',
|
||||
'Baskerville',
|
||||
'BiauKai',
|
||||
'Big Caslon',
|
||||
'Bodoni 72',
|
||||
'Bodoni 72 Oldstyle',
|
||||
@@ -179,11 +200,15 @@ export const MACOS_FONTS = [
|
||||
'Geneva',
|
||||
'Georgia',
|
||||
'Gill Sans',
|
||||
'Heiti SC',
|
||||
'Heiti TC',
|
||||
'Helvetica',
|
||||
'Helvetica Neue',
|
||||
'Herculanum',
|
||||
'Hoefler Text',
|
||||
'Impact',
|
||||
'Kaiti SC',
|
||||
'Kaiti TC',
|
||||
'Lucida Grande',
|
||||
'Luminari',
|
||||
'Marker Felt',
|
||||
@@ -194,12 +219,21 @@ export const MACOS_FONTS = [
|
||||
'Optima',
|
||||
'Palatino',
|
||||
'Papyrus',
|
||||
'PingFang HK',
|
||||
'PingFang SC',
|
||||
'PingFang TC',
|
||||
'Phosphate',
|
||||
'Rockwell',
|
||||
'Savoye LET',
|
||||
'SignPainter',
|
||||
'Skia',
|
||||
'Snell Roundhand',
|
||||
'Songti SC',
|
||||
'Songti TC',
|
||||
'STFangsong',
|
||||
'STKaiti',
|
||||
'STSong',
|
||||
'STXihei',
|
||||
'Tahoma',
|
||||
'Times',
|
||||
'Times New Roman',
|
||||
@@ -229,14 +263,22 @@ export const LINUX_FONTS = [
|
||||
'Liberation Serif',
|
||||
'Noto Mono',
|
||||
'Noto Sans',
|
||||
'Noto Sans CJK SC',
|
||||
'Noto Sans CJK TC',
|
||||
'Noto Serif',
|
||||
'Noto Serif CJK SC',
|
||||
'Noto Serif CJK TC',
|
||||
'Open Sans',
|
||||
'Poppins',
|
||||
'Source Han Sans',
|
||||
'Source Han Serif',
|
||||
'Symbola',
|
||||
'Times New Roman',
|
||||
'Ubuntu',
|
||||
'Ubuntu Mono',
|
||||
'Wingdings',
|
||||
'WenQuanYi Micro Hei',
|
||||
'WenQuanYi Zen Hei',
|
||||
];
|
||||
|
||||
export const ONE_COLUMN_MAX_INLINE_SIZE = 9999;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AppService } from '@/types/system';
|
||||
import { READEST_WEB_BASE_URL } from './constants';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -12,6 +13,14 @@ export const isWebAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] ==
|
||||
export const hasUpdater = () => window.__READEST_UPDATER_ACCESS === true;
|
||||
export const hasCli = () => window.__READEST_CLI_ACCESS === true;
|
||||
|
||||
// Dev API only in development mode and web platform
|
||||
// with command `pnpm dev-web`
|
||||
// for production build or tauri app use the production Web API
|
||||
export const getAPIBaseUrl = () =>
|
||||
process.env['NODE_ENV'] === 'development' && isWebAppPlatform()
|
||||
? '/api'
|
||||
: `${READEST_WEB_BASE_URL}/api`;
|
||||
|
||||
export interface EnvConfigType {
|
||||
getAppService: () => Promise<AppService>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
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,
|
||||
signal: AbortSignal,
|
||||
preload = false,
|
||||
): 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;
|
||||
}
|
||||
|
||||
if (preload) {
|
||||
for (const mark of marks) {
|
||||
await this.#edgeTTS.createAudio(this.getPayload(lang, mark.text, voiceId)).catch((err) => {
|
||||
console.warn('Error preloading:', err);
|
||||
});
|
||||
}
|
||||
yield {
|
||||
code: 'end',
|
||||
message: 'Preload finished',
|
||||
};
|
||||
return;
|
||||
} else {
|
||||
await this.stopInternal();
|
||||
}
|
||||
|
||||
for (const mark of marks) {
|
||||
if (signal.aborted) {
|
||||
yield {
|
||||
code: 'error',
|
||||
message: 'Aborted',
|
||||
};
|
||||
break;
|
||||
}
|
||||
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}`,
|
||||
});
|
||||
};
|
||||
if (signal.aborted) {
|
||||
resolve({
|
||||
code: 'error',
|
||||
message: 'Aborted',
|
||||
});
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
await 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() {
|
||||
await this.stopInternal();
|
||||
}
|
||||
|
||||
private async stopInternal() {
|
||||
this.#isPlaying = false;
|
||||
this.#pausedAt = 0;
|
||||
this.#startedAt = 0;
|
||||
if (this.#sourceNode) {
|
||||
try {
|
||||
this.#sourceNode.stop();
|
||||
} catch (err) {
|
||||
if (!(err instanceof Error) || err.name !== 'InvalidStateError') {
|
||||
console.log('Error stopping source node:', err);
|
||||
}
|
||||
}
|
||||
this.#sourceNode.disconnect();
|
||||
this.#sourceNode = null;
|
||||
}
|
||||
if (this.#audioContext) {
|
||||
await 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, signal: AbortSignal, preload?: boolean): 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,221 @@
|
||||
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'
|
||||
| 'setrate-paused'
|
||||
| 'setvoice-paused';
|
||||
|
||||
export class TTSController extends EventTarget {
|
||||
state: TTSState = 'stopped';
|
||||
view: FoliateView;
|
||||
#nossmlCnt: number = 0;
|
||||
#currentSpeakAbortController: AbortController | null = null;
|
||||
|
||||
ttsRate: number = 1.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.view.initTTS(granularity);
|
||||
}
|
||||
|
||||
async preloadSSML(ssml: string | undefined) {
|
||||
if (!ssml) return;
|
||||
const iter = await this.ttsClient.speak(ssml, new AbortController().signal, true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
for await (const _ of iter);
|
||||
}
|
||||
|
||||
async preloadNextSSML(count: number) {
|
||||
const tts = this.view.tts;
|
||||
if (!tts) return;
|
||||
let preloaded = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const ssml = tts.next();
|
||||
this.preloadSSML(ssml);
|
||||
if (ssml) preloaded++;
|
||||
}
|
||||
for (let i = 0; i < preloaded; i++) {
|
||||
tts.prev();
|
||||
}
|
||||
}
|
||||
|
||||
async #speak(ssml: string | undefined | Promise<string>) {
|
||||
await this.stop();
|
||||
this.#currentSpeakAbortController = new AbortController();
|
||||
const { signal } = this.#currentSpeakAbortController;
|
||||
|
||||
try {
|
||||
console.log('TTS speak');
|
||||
this.state = 'playing';
|
||||
ssml = await ssml;
|
||||
await this.preloadSSML(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);
|
||||
await this.forward();
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
this.#nossmlCnt = 0;
|
||||
}
|
||||
|
||||
const iter = await this.ttsClient.speak(ssml, signal);
|
||||
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') {
|
||||
await this.forward();
|
||||
}
|
||||
} finally {
|
||||
this.#currentSpeakAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async speak(ssml: string | Promise<string>) {
|
||||
await this.initViewTTS();
|
||||
this.#speak(ssml).catch((e) => this.error(e));
|
||||
this.preloadNextSSML(2);
|
||||
}
|
||||
|
||||
play() {
|
||||
if (this.state !== 'playing') {
|
||||
this.start();
|
||||
} else {
|
||||
this.pause();
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
await this.initViewTTS();
|
||||
const ssml = this.state.includes('paused') ? this.view.tts?.resume() : this.view.tts?.start();
|
||||
this.#speak(ssml);
|
||||
this.preloadNextSSML(2);
|
||||
}
|
||||
|
||||
async pause() {
|
||||
this.state = 'paused';
|
||||
await this.ttsClient.pause().catch((e) => this.error(e));
|
||||
}
|
||||
|
||||
async resume() {
|
||||
this.state = 'playing';
|
||||
await this.ttsClient.resume().catch((e) => this.error(e));
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.state = 'stopped';
|
||||
if (this.#currentSpeakAbortController) {
|
||||
this.#currentSpeakAbortController.abort();
|
||||
}
|
||||
await this.ttsClient.stop().catch((e) => this.error(e));
|
||||
}
|
||||
|
||||
// goto previous sentence
|
||||
async backward() {
|
||||
await this.initViewTTS();
|
||||
if (this.state === 'playing') {
|
||||
await 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') {
|
||||
await this.stop();
|
||||
this.#speak(this.view.tts?.next());
|
||||
this.preloadNextSSML(2);
|
||||
} else {
|
||||
this.state = 'forward-paused';
|
||||
this.view.tts?.next(true);
|
||||
}
|
||||
}
|
||||
|
||||
async setRate(rate: number) {
|
||||
this.state = 'setrate-paused';
|
||||
this.ttsRate = rate;
|
||||
await this.ttsClient.setRate(this.ttsRate);
|
||||
}
|
||||
|
||||
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';
|
||||
const useEdgeTTS = !!this.ttsEdgeVoices.find(
|
||||
(voice) => (voiceId === '' || voice.id === voiceId) && !voice.disabled,
|
||||
);
|
||||
if (useEdgeTTS) {
|
||||
this.ttsClient = this.ttsEdgeClient;
|
||||
await this.ttsClient.setRate(this.ttsRate);
|
||||
} else {
|
||||
this.ttsClient = this.ttsWebClient;
|
||||
await this.ttsClient.setRate(this.ttsRate);
|
||||
}
|
||||
await this.ttsClient.setVoice(voiceId);
|
||||
}
|
||||
|
||||
getVoiceId() {
|
||||
return this.ttsClient.getVoiceId();
|
||||
}
|
||||
|
||||
error(e: unknown) {
|
||||
console.error(e);
|
||||
this.state = 'stopped';
|
||||
}
|
||||
|
||||
async kill() {
|
||||
this.state = 'stopped';
|
||||
await this.ttsClient.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
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;
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance();
|
||||
for (const mark of marks) {
|
||||
utterance.text = 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,
|
||||
signal: AbortSignal,
|
||||
preload = false,
|
||||
): AsyncGenerator<TTSMessageEvent> {
|
||||
// no need to preload for web speech
|
||||
if (preload) return;
|
||||
|
||||
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 (signal.aborted) {
|
||||
console.log('TTS aborted');
|
||||
yield { code: 'error', message: 'Aborted' } as TTSMessageEvent;
|
||||
return;
|
||||
}
|
||||
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 seenIds = new Set<string>();
|
||||
const voices = filteredVoices
|
||||
.map(
|
||||
(voice) =>
|
||||
({
|
||||
id: voice.voiceURI,
|
||||
name: voice.name,
|
||||
lang: voice.lang,
|
||||
}) as TTSVoice,
|
||||
)
|
||||
.filter((voice) => {
|
||||
if (seenIds.has(voice.id)) {
|
||||
return false;
|
||||
}
|
||||
seenIds.add(voice.id);
|
||||
return true;
|
||||
});
|
||||
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';
|
||||
@@ -64,7 +64,10 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
getView: (key: string | null) => (key && get().viewStates[key]?.view) || null,
|
||||
setView: (key: string, view) =>
|
||||
set((state) => ({
|
||||
viewStates: { ...state.viewStates, [key]: { ...state.viewStates[key]!, view } },
|
||||
viewStates: {
|
||||
...state.viewStates,
|
||||
[key]: { ...state.viewStates[key]!, view },
|
||||
},
|
||||
})),
|
||||
getViews: () => Object.values(get().viewStates).map((state) => state.view!),
|
||||
getViewsById: (id: string) => {
|
||||
@@ -213,13 +216,31 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
const bookData = useBookDataStore.getState().booksData[id];
|
||||
const viewState = state.viewStates[key];
|
||||
if (!viewState || !bookData) return state;
|
||||
|
||||
const progress: [number, number] = [pageinfo.current, pageinfo.total];
|
||||
|
||||
// Update library book progress
|
||||
const { library, setLibrary } = useLibraryStore.getState();
|
||||
const bookIndex = library.findIndex((b) => b.hash === id);
|
||||
if (bookIndex !== -1) {
|
||||
const updatedLibrary = [...library];
|
||||
const existingBook = updatedLibrary[bookIndex]!;
|
||||
updatedLibrary[bookIndex] = {
|
||||
...existingBook,
|
||||
progress,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
setLibrary(updatedLibrary);
|
||||
}
|
||||
|
||||
const oldConfig = bookData.config;
|
||||
const newConfig = {
|
||||
...bookData.config,
|
||||
updatedAt: Date.now(),
|
||||
progress: [pageinfo.current, pageinfo.total] as [number, number],
|
||||
progress,
|
||||
location,
|
||||
};
|
||||
|
||||
useBookDataStore.setState((state) => ({
|
||||
booksData: {
|
||||
...state.booksData,
|
||||
@@ -229,6 +250,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
viewStates: {
|
||||
...state.viewStates,
|
||||
@@ -248,7 +270,6 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
setBookmarkRibbonVisibility: (key: string, visible: boolean) =>
|
||||
set((state) => ({
|
||||
viewStates: {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
border-radius: 10px;
|
||||
scrollbar-gutter: auto !important;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
||||
@@ -11,7 +11,7 @@ const generateLightPalette = ({ bg, fg, primary }: BaseColor) => {
|
||||
return {
|
||||
'base-100': bg, // Main background
|
||||
'base-200': tinycolor(bg).darken(5).toHexString(), // Slightly darker
|
||||
'base-300': tinycolor(bg).darken(15).toHexString(), // More darker
|
||||
'base-300': tinycolor(bg).darken(12).toHexString(), // More darker
|
||||
'base-content': fg, // Main text color
|
||||
neutral: tinycolor(bg).darken(15).desaturate(20).toHexString(), // Muted neutral
|
||||
'neutral-content': tinycolor(fg).lighten(20).desaturate(20).toHexString(), // Slightly lighter text
|
||||
@@ -25,7 +25,7 @@ const generateDarkPalette = ({ bg, fg, primary }: BaseColor) => {
|
||||
return {
|
||||
'base-100': bg, // Main background
|
||||
'base-200': tinycolor(bg).lighten(5).toHexString(), // Slightly lighter
|
||||
'base-300': tinycolor(bg).lighten(15).toHexString(), // More lighter
|
||||
'base-300': tinycolor(bg).lighten(12).toHexString(), // More lighter
|
||||
'base-content': fg, // Main text color
|
||||
neutral: tinycolor(bg).lighten(15).desaturate(20).toHexString(), // Muted neutral
|
||||
'neutral-content': tinycolor(fg).darken(20).desaturate(20).toHexString(), // Darkened text
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface Book {
|
||||
deletedAt?: number | null;
|
||||
|
||||
lastUpdated?: number; // deprecated in favor of updatedAt
|
||||
progress?: [number, number]; // Add progress field: [current, total]
|
||||
}
|
||||
|
||||
export interface PageInfo {
|
||||
@@ -80,7 +81,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -82,6 +82,14 @@ export const formatTitle = (title: string | LanguageMap) => {
|
||||
return typeof title === 'string' ? title : formatLanguageMap(title);
|
||||
};
|
||||
|
||||
export const formatPublisher = (publisher: string | LanguageMap) => {
|
||||
return typeof publisher === 'string' ? publisher : formatLanguageMap(publisher);
|
||||
};
|
||||
|
||||
export const formatLanguage = (lang: string | string[] | undefined) => {
|
||||
return Array.isArray(lang) ? lang.join(', ') : lang;
|
||||
};
|
||||
|
||||
export const formatDate = (date: string | number | Date | undefined) => {
|
||||
if (!date) return;
|
||||
try {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user