Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bad45a72ba | |||
| 852d16daf3 | |||
| 410d57e6f1 | |||
| 89c6c4d971 | |||
| 3f4a274412 | |||
| 9dd7e6de5e | |||
| 902a230095 | |||
| 5a521b6e6f | |||
| fb31212005 | |||
| b57fd8bd3d | |||
| 811f377dc0 | |||
| 0cbb950c37 | |||
| 6efdbb78e5 | |||
| d940fb8d06 | |||
| 515a133731 | |||
| eb90854f69 | |||
| 76667fa2f6 | |||
| 7b2bd8a3f8 | |||
| 8bdcb65883 | |||
| b985c2664e | |||
| bdf619f2e6 | |||
| 53db2777af | |||
| a10bde2254 | |||
| cf7390915b | |||
| b694203d26 | |||
| 242fbf6743 | |||
| f21f3a3fc0 | |||
| aa16bc09f1 | |||
| 23eb2514bc | |||
| 9eacf7e203 | |||
| 32ffaa0e05 | |||
| 999e4828db | |||
| 626dd2fbd7 | |||
| ec1ec2265d | |||
| 1429f111bc | |||
| 5ed74fe9c5 | |||
| abb3ace57e | |||
| f897067683 | |||
| a189cd6d4f | |||
| 0076bab4e3 | |||
| 433f707455 | |||
| dbb8347554 | |||
| f1a31b9590 | |||
| 22fb45e54b | |||
| 3d09eb761c | |||
| 0d50edbff1 | |||
| 96fd2752ff | |||
| 4c4f067857 | |||
| d6bfbbc0b6 | |||
| e851582264 | |||
| c02d63367a |
@@ -0,0 +1,38 @@
|
||||
---
|
||||
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'
|
||||
labels: ['type: bug']
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Bug Description
|
||||
|
||||
A clear and concise description of what the current behavior is.
|
||||
Please also add **screenshots** of the existing application.
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
In DarkMode, when scrollbar are displayed (for example on Companies page, with enough companies in the list), we see a blank square in the bottom right corner
|
||||
[screenshot]
|
||||
```
|
||||
|
||||
## Expected behavior
|
||||
|
||||
A clear and concise description of what the expected behavior is.
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
The blank square should be transparent (invisible)
|
||||
```
|
||||
|
||||
## Technical inputs
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
- We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
|
||||
- Probably fixable with CSS
|
||||
```
|
||||
@@ -0,0 +1,144 @@
|
||||
name: Release Readest
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.create-release.outputs.result }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v3
|
||||
- name: get version
|
||||
run: echo "PACKAGE_VERSION=$(node -p "require('./apps/readest-app/package.json').version")" >> $GITHUB_ENV
|
||||
- name: create release
|
||||
id: create-release
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.getLatestRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
})
|
||||
return data.id
|
||||
|
||||
build-tauri:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
config:
|
||||
- os: ubuntu-latest
|
||||
arch: x86_64
|
||||
rust_target: x86_64-unknown-linux-gnu
|
||||
- os: macos-latest
|
||||
arch: aarch64
|
||||
rust_target: x86_64-apple-darwin,aarch64-apple-darwin
|
||||
- os: windows-latest
|
||||
arch: x86_64
|
||||
rust_target: x86_64-pc-windows-msvc
|
||||
|
||||
runs-on: ${{ matrix.config.os }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: initialize git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.14.4
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: install rimraf
|
||||
run: npm install -g rimraf
|
||||
|
||||
- name: install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: copy pdfjs-dist to public directory
|
||||
run: pnpm --filter @readest/readest-app setup-pdfjs
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.config.rust_target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.config.os }}-cargo-${{ hashFiles('apps/readest-app/src-tauri/Cargo.lock') }}
|
||||
workspaces: apps/readest-app/src-tauri -> target
|
||||
|
||||
- name: create .env.local file for Next.js
|
||||
run: |
|
||||
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_DEEPL_API_KEY=${{ secrets.NEXT_PUBLIC_DEEPL_API_KEY }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local
|
||||
|
||||
- name: copy .env.local to apps/readest-app
|
||||
run: cp .env.local apps/readest-app/.env.local
|
||||
|
||||
- name: fix dynamic route for Next.js, see https://github.com/vercel/next.js/discussions/55393
|
||||
run: rimraf "apps/readest-app/src/app/reader/[ids]"
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.config.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
with:
|
||||
projectPath: apps/readest-app
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.config.os == 'macos-latest' && '--target universal-apple-darwin' || '' }}
|
||||
|
||||
publish-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: [create-release, build-tauri]
|
||||
|
||||
steps:
|
||||
- name: publish release
|
||||
id: publish-release
|
||||
uses: actions/github-script@v6
|
||||
env:
|
||||
release_id: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: process.env.release_id,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
[submodule "packages/foliate-js"]
|
||||
path = packages/foliate-js
|
||||
url = git@github.com:johnfactotum/foliate-js.git
|
||||
url = https://github.com/chrox/foliate-js.git
|
||||
[submodule "packages/tauri"]
|
||||
path = packages/tauri
|
||||
url = git@github.com:chrox/tauri.git
|
||||
url = https://github.com/chrox/tauri.git
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Contribution Guidelines
|
||||
|
||||
When contributing to `Readest`, whether on GitHub or in other community spaces:
|
||||
|
||||
- Be respectful, civil, and open-minded.
|
||||
- Before opening a new pull request, try searching through the [issue tracker](https://github.com/chrox/readest/issues) for known issues or fixes.
|
||||
- If you want to make code changes based on your personal opinion(s), make sure you open an issue first describing the changes you want to make, and open a pull request only when your suggestions get approved by maintainers.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
### Prerequisites
|
||||
|
||||
In order to not waste your time implementing a change that has already been declined, or is generally not needed, start by [opening an issue](https://github.com/chrox/readest/issues/new/choose) describing the problem you would like to solve.
|
||||
|
||||
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
|
||||
|
||||
Basically you need to install or update the following development tools:
|
||||
|
||||
- **Node.js** and **pnpm** for Next.js development
|
||||
- **Rust** and **Cargo** for Tauri development
|
||||
|
||||
```bash
|
||||
nvm install v22
|
||||
nvm use v22
|
||||
npm install -g pnpm
|
||||
rustup update
|
||||
```
|
||||
|
||||
### Getting Started
|
||||
|
||||
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
|
||||
cd readest
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
#### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
# might need to rerun this when code is updated
|
||||
pnpm install
|
||||
# copy pdfjs-dist to Next.js public directory
|
||||
pnpm --filter @readest/readest-app setup-pdfjs
|
||||
```
|
||||
|
||||
#### 3. Verify Dependencies Installation
|
||||
|
||||
To confirm that all dependencies are correctly installed, run the following command:
|
||||
|
||||
```bash
|
||||
pnpm tauri info
|
||||
```
|
||||
|
||||
This command will display information about the installed Tauri dependencies and configuration on your platform. Note that the output may vary depending on the operating system and environment setup. Please review the output specific to your platform for any potential issues.
|
||||
|
||||
For Windows targets, “Build Tools for Visual Studio 2022” (or a higher edition of Visual Studio) and the “Desktop development with C++” workflow must be installed. For Windows ARM64 targets, the “VS 2022 C++ ARM64 build tools” and "C++ Clang Compiler for Windows" components must be installed. And make sure `clang` can be found in the path by adding `C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\Llvm\x64\bin` for example in the environment variable `Path`.
|
||||
|
||||
#### 4. Build for Development
|
||||
|
||||
```bash
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
#### 5. Build for Production
|
||||
|
||||
```bash
|
||||
pnpm tauri build
|
||||
```
|
||||
|
||||
Now you're all setup and can start implementing your changes.
|
||||
|
||||
### Implement 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 |
|
||||
|
||||
### When you're done
|
||||
|
||||
Check that your code follows the project's style guidelines by running:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Please also make a manual, functional test of your changes. When all that's done, it's time to file a pull request to upstream and fill out the title and body appropriately.
|
||||
|
||||
## Credits
|
||||
|
||||
This documented was inspired by the contributing guidelines for [cloudflare/wrangler2](https://github.com/cloudflare/wrangler2/blob/main/CONTRIBUTING.md).
|
||||
@@ -1,27 +1,113 @@
|
||||
# Readest
|
||||
<div align="center">
|
||||
<a href="https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme" target="_blank">
|
||||
<img src="https://github.com/chrox/readest/blob/main/apps/readest-app/src-tauri/icons/icon.png?raw=true" alt="Readest Logo" width="20%" />
|
||||
</a>
|
||||
<h1>Readest</h1>
|
||||
<br>
|
||||
|
||||
Readest is an open-source ebook reading software designed for immersive and deep reading experiences. It supports EPUB and PDF document formats, and with Tauri v2, Readest is cross-platform, running seamlessly on macOS, Windows, and Linux.
|
||||
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 15](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to offer a seamless cross-platform experience on macOS, Windows, Linux and Web, with support for mobile platforms coming soon.
|
||||
|
||||
[![Website][badge-website]][link-website]
|
||||
[![Web App][badge-web-app]][link-web-readest]
|
||||
[![OS][badge-platforms]][link-website]
|
||||
[![][badge-discord]][link-discord]
|
||||
<br>
|
||||
[![AGPL Licence][badge-license]](LICENSE)
|
||||
[![Latest release][badge-release]][link-gh-releases]
|
||||
[![Last commit][badge-last-commit]][link-gh-commits]
|
||||
[![Commits][badge-commit-activity]][link-gh-pulse]
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="#features">Features</a> •
|
||||
<a href="#planned-features">Planned Features</a> •
|
||||
<a href="#screenshots">Screenshots</a> •
|
||||
<a href="#downloads">Downloads</a> •
|
||||
<a href="#getting-started">Getting Started</a> •
|
||||
<a href="#contributors">Contributors</a> •
|
||||
<a href="#license">License</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://readest.com" target="_blank">
|
||||
<img src="./data/screenshots/landing_preview.png" alt="Readest Banner" width="100%" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## Features
|
||||
|
||||
- **EPUB and PDF Support**: Enjoy both EPUB and PDF formats, making Readest versatile for all your reading needs.
|
||||
- **Cross-Platform Compatibility**: Runs on macOS, Windows, and Linux with Tauri v2.
|
||||
- **Immersive Reading Experience**: Supports advanced reading features like note-taking, highlighting, and progress syncing.
|
||||
- **Optimized Performance**: Readest is lightweight, ensuring smooth performance even with large files.
|
||||
- **Customizable Interface**: Built with daisyUI for a modern and user-friendly UI.
|
||||
<div align="left">✅ Implemented</div>
|
||||
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| --------------------------------------- | -------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, PDF (experimental) | ✅ |
|
||||
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
|
||||
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
|
||||
| **Excerpt Text for Note-Taking** | Easily excerpt text from books for detailed notes and analysis. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **Translate with DeepL** | Translate selected text instantly using DeepL for accurate translations. | ✅ |
|
||||
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
|
||||
| **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. | ✅ |
|
||||
|
||||
## 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. | 🛠 |
|
||||
| **Sync Across Platforms** | Synchronize reading progress, notes, and bookmarks across all supported platforms. | 🛠 |
|
||||
| **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. | 🔄 |
|
||||
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | 🔄 |
|
||||
| **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. | 🔄 |
|
||||
|
||||
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Downloads
|
||||
|
||||
The Readest app is available for download! 🥳 🚀
|
||||
|
||||
- macOS : Search for "Readest" on the [macOS App Store][link-macos-appstore].
|
||||
- Windows / Linux: Visit [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
|
||||
- Web: Visit [https://web.readest.com][link-web-readest].
|
||||
- iOS / Android: coming soon 👀
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Node.js** and **pnpm** for Next.js development
|
||||
- **Rust and Cargo** for Tauri development
|
||||
- **Rust** and **Cargo** for Tauri development
|
||||
|
||||
For the best experience, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
|
||||
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
|
||||
|
||||
```bash
|
||||
nvm install v22
|
||||
nvm use v22
|
||||
npm install -g pnpm
|
||||
rustup update
|
||||
```
|
||||
|
||||
@@ -40,7 +126,7 @@ git submodule update --init --recursive
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install -g pnpm
|
||||
# might need to rerun this when code is updated
|
||||
pnpm install
|
||||
# copy pdfjs-dist to Next.js public directory
|
||||
pnpm --filter @readest/readest-app setup-pdfjs
|
||||
@@ -58,7 +144,7 @@ This command will display information about the installed Tauri dependencies and
|
||||
|
||||
For Windows targets, “Build Tools for Visual Studio 2022” (or a higher edition of Visual Studio) and the “Desktop development with C++” workflow must be installed. For Windows ARM64 targets, the “VS 2022 C++ ARM64 build tools” and "C++ Clang Compiler for Windows" components must be installed. And make sure `clang` can be found in the path by adding `C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\Llvm\x64\bin` for example in the environment variable `Path`.
|
||||
|
||||
### 4. Build the Development
|
||||
### 4. Build for Development
|
||||
|
||||
```bash
|
||||
pnpm tauri dev
|
||||
@@ -70,14 +156,45 @@ pnpm tauri dev
|
||||
pnpm tauri build
|
||||
```
|
||||
|
||||
## Contributing
|
||||
## Contributors
|
||||
|
||||
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please review our contributing guidelines before you start.
|
||||
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please **review our [contributing guidelines](CONTRIBUTING.md) before you start**. We also welcome you to join our [Discord][link-discord] community for either support or contributing guidance.
|
||||
|
||||
<a href="https://github.com/chrox/readest/graphs/contributors">
|
||||
<p align="left">
|
||||
<img width="50" src="https://contrib.rocks/image?repo=chrox/readest" alt="A table of avatars from the project's contributors" />
|
||||
</p>
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the AGPL V3 License. See the LICENSE file for details.
|
||||
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:
|
||||
|
||||
- [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.
|
||||
|
||||
---
|
||||
|
||||
Happy reading with Readest!
|
||||
<div align="center" style="color: gray;">Happy reading with Readest!</div>
|
||||
|
||||
[badge-website]: https://img.shields.io/badge/website-readest.com-orange
|
||||
[badge-web-app]: https://img.shields.io/badge/read%20online-web.readest.com-orange
|
||||
[badge-license]: https://img.shields.io/github/license/chrox/readest?color=teal
|
||||
[badge-release]: https://img.shields.io/github/release/chrox/readest?color=green
|
||||
[badge-platforms]: https://img.shields.io/badge/OS-macOS%2C%20Windows%2C%20Linux%2C%20Web-green
|
||||
[badge-last-commit]: https://img.shields.io/github/last-commit/chrox/readest?color=green
|
||||
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/chrox/readest
|
||||
[badge-discord]: https://img.shields.io/discord/1314226120886976544?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square
|
||||
[link-macos-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
|
||||
[link-website]: https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme
|
||||
[link-web-readest]: https://web.readest.com
|
||||
[link-gh-releases]: https://github.com/chrox/readest/releases
|
||||
[link-gh-commits]: https://github.com/chrox/readest/commits/main
|
||||
[link-gh-pulse]: https://github.com/chrox/readest/pulse
|
||||
[link-discord]: https://discord.gg/jb2nzDts
|
||||
[link-parallel-read]: https://readest.com/#parallel-read
|
||||
[link-koreader]: https://github.com/koreader/koreader
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_APP_PLATFORM=tauri
|
||||
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_APP_PLATFORM=web
|
||||
@@ -7,7 +7,7 @@ const internalHost = process.env.TAURI_DEV_HOST || 'localhost';
|
||||
const nextConfig = {
|
||||
// Ensure Next.js uses SSG instead of SSR
|
||||
// https://nextjs.org/docs/pages/building-your-application/deploying/static-exports
|
||||
output: 'export',
|
||||
output: process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web' ? undefined : 'export',
|
||||
// Note: This feature is required to use the Next.js Image component in SSG mode.
|
||||
// See https://nextjs.org/docs/messages/export-image-api for different workarounds.
|
||||
images: {
|
||||
@@ -19,6 +19,14 @@ 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,34 +1,41 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.7.9",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
"build": "dotenv -e .env.tauri -- next build",
|
||||
"start": "dotenv -e .env.tauri -- next start",
|
||||
"dev-web": "dotenv -e .env.web -- next dev",
|
||||
"build-web": "dotenv -e .env.web -- next build",
|
||||
"start-web": "dotenv -e .env.web -- next start",
|
||||
"lint": "next lint",
|
||||
"tauri": "tauri",
|
||||
"prepare-public-vendor": "mkdirp ./public/vendor/pdfjs",
|
||||
"copy-pdfjs-js": "dotenv -- cross-var cpx \"%PDFJS_BUILD_PATH%/pdf*\" ./public/vendor/pdfjs",
|
||||
"copy-pdfjs-fonts": "dotenv -- cross-var cpx \"%PDFJS_FONTS_PATH%/{cmaps,standard_fonts}/*\" ./public/vendor/pdfjs",
|
||||
"copy-pdfjs-css": "dotenv -- cross-var cpx \"%PDFJS_STYLE_PATH%/{annotation_layer_builder,text_layer_builder}.css\" ./public/vendor/pdfjs",
|
||||
"copy-pdfjs": "pnpm copy-pdfjs-js && pnpm copy-pdfjs-fonts && pnpm copy-pdfjs-css",
|
||||
"copy-flatten-pdfjs-annotation-layer-css": "dotenv -- cross-var npx postcss \"%PDFJS_STYLE_PATH%/annotation_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/annotation_layer_builder.css",
|
||||
"copy-flatten-pdfjs-text-layer-css": "dotenv -- cross-var npx postcss \"%PDFJS_STYLE_PATH%/text_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/text_layer_builder.css",
|
||||
"copy-flatten-pdfjs-css": "pnpm copy-flatten-pdfjs-annotation-layer-css && pnpm copy-flatten-pdfjs-text-layer-css",
|
||||
"copy-pdfjs": "pnpm copy-pdfjs-js && pnpm copy-pdfjs-fonts && pnpm copy-flatten-pdfjs-css",
|
||||
"setup-pdfjs": "pnpm prepare-public-vendor && pnpm copy-pdfjs",
|
||||
"build-win-x64": "tauri build --target i686-pc-windows-msvc --bundles nsis",
|
||||
"build-win-arm64": "tauri build --target aarch64-pc-windows-msvc --bundles nsis",
|
||||
"build-linux-x64": "tauri build --target x86_64-unknown-linux-gnu --bundles appimage",
|
||||
"build-macos-universial": "dotenv -e .env.apple-nonstore.local -- tauri build -t universal-apple-darwin --bundles dmg",
|
||||
"build-macos-universial-appstore": "dotenv -e .env.apple-appstore.local -- tauri build -t universal-apple-darwin --bundles app --config src-tauri/tauri.appstore.conf.json",
|
||||
"build-macos-universial-appstore-dev": "dotenv -e .env.apple-appstore-dev.local -- tauri build -t universal-apple-darwin --bundles app --config src-tauri/tauri.appstore-dev.conf.json",
|
||||
"release-macos-universial-appstore": "dotenv -e .env.apple-appstore.local -- bash scripts/release-mac-appstore.sh"
|
||||
"build-win-x64": "dotenv -e .env.tauri.local -- tauri build --target i686-pc-windows-msvc --bundles nsis",
|
||||
"build-win-arm64": "dotenv -e .env.tauri.local -- tauri build --target aarch64-pc-windows-msvc --bundles nsis",
|
||||
"build-linux-x64": "dotenv -e .env.tauri.local -- tauri build --target x86_64-unknown-linux-gnu --bundles appimage",
|
||||
"build-macos-universial": "dotenv -e .env.tauri.local -e .env.apple-nonstore.local -- tauri build -t universal-apple-darwin --bundles dmg",
|
||||
"build-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- tauri build -t universal-apple-darwin --bundles app --config src-tauri/tauri.appstore.conf.json",
|
||||
"build-macos-universial-appstore-dev": "dotenv -e .env.tauri.local -e .env.apple-appstore-dev.local -- tauri build -t universal-apple-darwin --bundles app --config src-tauri/tauri.appstore-dev.conf.json",
|
||||
"release-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- bash scripts/release-mac-appstore.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "2.1.1",
|
||||
"@tauri-apps/plugin-cli": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.1",
|
||||
"@tauri-apps/plugin-fs": "^2.0.2",
|
||||
"@tauri-apps/plugin-fs": "^2.0.3",
|
||||
"@tauri-apps/plugin-http": "^2.0.1",
|
||||
"@tauri-apps/plugin-log": "^2.0.0",
|
||||
"@tauri-apps/plugin-log": "^2.0.1",
|
||||
"@tauri-apps/plugin-os": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "~2.0.1",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
"@zip.js/zip.js": "^2.7.53",
|
||||
@@ -61,6 +68,8 @@
|
||||
"eslint-config-next": "15.0.3",
|
||||
"mkdirp": "^3.0.1",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"postcss-nested": "^7.0.2",
|
||||
"raw-loader": "^4.0.2",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 83 KiB |
@@ -31,6 +31,7 @@ tauri-plugin-os = "2.0.1"
|
||||
tauri-plugin-http = "2.0.3"
|
||||
tauri-plugin-devtools = "2.0.0"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-process = "2"
|
||||
[patch.crates-io]
|
||||
tauri = { path = "../../../packages/tauri/crates/tauri" }
|
||||
|
||||
@@ -39,8 +40,6 @@ cocoa = "0.25"
|
||||
objc = "0.2.7"
|
||||
rand = "0.8"
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
|
||||
tauri-plugin-cli = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"shell:default"
|
||||
"shell:default",
|
||||
"updater:default",
|
||||
"process:default",
|
||||
"process:allow-restart",
|
||||
"cli:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,37 +6,62 @@ extern crate cocoa;
|
||||
#[macro_use]
|
||||
extern crate objc;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod tauri_traffic_light_positioner_plugin;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use {
|
||||
tauri::menu::{SubmenuBuilder, HELP_SUBMENU_ID},
|
||||
tauri::TitleBarStyle,
|
||||
tauri_plugin_shell::ShellExt,
|
||||
};
|
||||
use tauri::TitleBarStyle;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Emitter, Manager, Url};
|
||||
use tauri::{WebviewUrl, WebviewWindowBuilder};
|
||||
use tauri_plugin_dialog;
|
||||
use tauri_plugin_fs::FsExt;
|
||||
|
||||
fn handle_menu_event(app: &tauri::AppHandle, event: &tauri::menu::MenuEvent) {
|
||||
if event.id() == "privacy_policy" {
|
||||
if let Err(e) = app.shell().open("https://readest.com/privacy-policy", None) {
|
||||
eprintln!("Failed to open privacy policy: {}", e);
|
||||
#[cfg(desktop)]
|
||||
use tauri::Listener;
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn allow_file_in_scopes(app: &AppHandle, files: Vec<PathBuf>) {
|
||||
let fs_scope = app.fs_scope();
|
||||
let asset_protocol_scope = app.asset_protocol_scope();
|
||||
for file in &files {
|
||||
if let Err(e) = fs_scope.allow_file(&file) {
|
||||
eprintln!("Failed to allow file in fs_scope: {}", e);
|
||||
} else {
|
||||
println!("Allowed file in fs_scope: {:?}", file);
|
||||
}
|
||||
} else if event.id() == "report_issue" {
|
||||
if let Err(e) = app.shell().open("mailto:support@bilingify.com", None) {
|
||||
eprintln!("Failed to open mail client: {}", e);
|
||||
}
|
||||
} else if event.id() == "readest_help" {
|
||||
if let Err(e) = app.shell().open("https://readest.com/support", None) {
|
||||
eprintln!("Failed to open support page: {}", e);
|
||||
if let Err(e) = asset_protocol_scope.allow_file(&file) {
|
||||
eprintln!("Failed to allow file in asset_protocol_scope: {}", e);
|
||||
} else {
|
||||
println!("Allowed file in asset_protocol_scope: {:?}", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn set_window_open_with_files(app: &AppHandle, files: Vec<PathBuf>) {
|
||||
let files = files
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let file = f.to_string_lossy().replace("\\", "\\\\");
|
||||
format!("\"{file}\"",)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
let script = format!("window.OPEN_WITH_FILES = [{}];", files);
|
||||
if let Err(e) = window.eval(&script) {
|
||||
eprintln!("Failed to set open files variable: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
@@ -47,10 +72,41 @@ pub fn run() {
|
||||
let builder = builder.plugin(tauri_traffic_light_positioner_plugin::init());
|
||||
|
||||
builder
|
||||
.setup(|app| {
|
||||
.setup(|#[allow(unused_variables)] app| {
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
let mut files = Vec::new();
|
||||
// NOTICE: `args` may include URL protocol (`your-app-protocol://`)
|
||||
// or arguments (`--`) if your app supports them.
|
||||
// files may aslo be passed as `file://path/to/file`
|
||||
for maybe_file in std::env::args().skip(1) {
|
||||
// skip flags like -f or --flag
|
||||
if maybe_file.starts_with("-") {
|
||||
continue;
|
||||
}
|
||||
// handle `file://` path urls and skip other urls
|
||||
if let Ok(url) = Url::parse(&maybe_file) {
|
||||
if let Ok(path) = url.to_file_path() {
|
||||
files.push(path);
|
||||
} else {
|
||||
files.push(PathBuf::from(maybe_file))
|
||||
}
|
||||
} else {
|
||||
files.push(PathBuf::from(maybe_file))
|
||||
}
|
||||
}
|
||||
if !files.is_empty() {
|
||||
let app_handle = app.handle().clone();
|
||||
allow_file_in_scopes(&app_handle, files.clone());
|
||||
app.listen("window-ready", move |_| {
|
||||
println!("Window is ready, proceeding to handle files.");
|
||||
set_window_open_with_files(&app_handle, files.clone());
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(desktop)]
|
||||
app.handle().plugin(tauri_plugin_cli::init())?;
|
||||
#[cfg(desktop)]
|
||||
app.handle()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
@@ -73,29 +129,35 @@ pub fn run() {
|
||||
let win_builder = win_builder.decorations(false).transparent(true);
|
||||
|
||||
win_builder.build().unwrap();
|
||||
// let win = win_builder.build().unwrap();
|
||||
// win.open_devtools();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let global_menu = app.menu().unwrap();
|
||||
if let Some(item) = global_menu.get(HELP_SUBMENU_ID) {
|
||||
global_menu.remove(&item)?;
|
||||
}
|
||||
global_menu.append(
|
||||
&SubmenuBuilder::new(app, "Help")
|
||||
.text("privacy_policy", "Privacy Policy")
|
||||
.separator()
|
||||
.text("report_issue", "Report An Issue...")
|
||||
.text("readest_help", "Readest Help")
|
||||
.build()?,
|
||||
)?;
|
||||
menu::setup_macos_menu(&app.handle())?;
|
||||
|
||||
app.on_menu_event(move |app, event| {
|
||||
handle_menu_event(app, &event);
|
||||
});
|
||||
}
|
||||
app.handle().emit("window-ready", {}).unwrap();
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while running tauri application")
|
||||
.run(
|
||||
#[allow(unused_variables)]
|
||||
|app_handle, event| {
|
||||
#[cfg(any(target_os = "macos", target_os = "ios"))]
|
||||
if let tauri::RunEvent::Opened { urls } = event {
|
||||
let files = urls
|
||||
.into_iter()
|
||||
.filter_map(|url| url.to_file_path().ok())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let app_handler_clone = app_handle.clone();
|
||||
allow_file_in_scopes(&app_handle, files.clone());
|
||||
app_handle.listen("window-ready", move |_| {
|
||||
println!("Window is ready, proceeding to handle files.");
|
||||
set_window_open_with_files(&app_handler_clone, files.clone());
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
use tauri::menu::MenuEvent;
|
||||
use tauri::menu::{SubmenuBuilder, HELP_SUBMENU_ID};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
|
||||
pub fn setup_macos_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
let global_menu = app.menu().unwrap();
|
||||
|
||||
if let Some(item) = global_menu.get(HELP_SUBMENU_ID) {
|
||||
global_menu.remove(&item)?;
|
||||
}
|
||||
|
||||
global_menu.append(
|
||||
&SubmenuBuilder::new(app, "Help")
|
||||
.text("privacy_policy", "Privacy Policy")
|
||||
.separator()
|
||||
.text("report_issue", "Report An Issue...")
|
||||
.text("readest_help", "Readest Help")
|
||||
.build()?,
|
||||
)?;
|
||||
|
||||
app.on_menu_event(|app, event| {
|
||||
handle_menu_event(app, &event);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn handle_menu_event(app: &AppHandle, event: &MenuEvent) {
|
||||
if event.id() == "privacy_policy" {
|
||||
if let Err(e) = app.shell().open("https://readest.com/privacy-policy", None) {
|
||||
eprintln!("Failed to open privacy policy: {}", e);
|
||||
}
|
||||
} else if event.id() == "report_issue" {
|
||||
if let Err(e) = app.shell().open("mailto:support@bilingify.com", None) {
|
||||
eprintln!("Failed to open mail client: {}", e);
|
||||
}
|
||||
} else if event.id() == "readest_help" {
|
||||
if let Err(e) = app.shell().open("https://readest.com/support", None) {
|
||||
eprintln!("Failed to open support page: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": {
|
||||
"default-src": "'self' 'unsafe-inline' blob: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost https://fonts.gstatic.com",
|
||||
"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",
|
||||
"img-src": "'self' blob: data: asset: http://asset.localhost",
|
||||
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost",
|
||||
@@ -49,14 +49,83 @@
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "12.0"
|
||||
},
|
||||
"fileAssociations": [
|
||||
{
|
||||
"name": "epub",
|
||||
"ext": ["epub"],
|
||||
"description": "EPUB file",
|
||||
"mimeType": "application/epub+zip",
|
||||
"role": "Viewer"
|
||||
},
|
||||
{
|
||||
"name": "mobi",
|
||||
"ext": ["mobi"],
|
||||
"description": "MOBI file",
|
||||
"mimeType": "application/x-mobipocket-ebook",
|
||||
"role": "Viewer"
|
||||
},
|
||||
{
|
||||
"name": "azw",
|
||||
"ext": ["azw", "azw3"],
|
||||
"description": "AZW file",
|
||||
"mimeType": "application/vnd.amazon.ebook",
|
||||
"role": "Viewer"
|
||||
},
|
||||
{
|
||||
"name": "fb2",
|
||||
"ext": ["fb2"],
|
||||
"description": "FB2 file",
|
||||
"mimeType": "application/x-fictionbook+xml",
|
||||
"role": "Viewer"
|
||||
},
|
||||
{
|
||||
"name": "cbz",
|
||||
"ext": ["cbz"],
|
||||
"description": "CBZ file",
|
||||
"mimeType": "application/vnd.comicbook+zip",
|
||||
"role": "Viewer"
|
||||
},
|
||||
{
|
||||
"name": "pdf",
|
||||
"ext": ["pdf"],
|
||||
"description": "PDF file",
|
||||
"mimeType": "application/pdf",
|
||||
"role": "Viewer"
|
||||
}
|
||||
],
|
||||
"createUpdaterArtifacts": true
|
||||
},
|
||||
"plugins": {
|
||||
"fs": {
|
||||
"requireLiteralLeadingDot": false
|
||||
},
|
||||
"cli": {
|
||||
"description": "Readest CLI",
|
||||
"args": [
|
||||
{
|
||||
"name": "file1",
|
||||
"index": 1,
|
||||
"takesValue": true
|
||||
},
|
||||
{
|
||||
"name": "file2",
|
||||
"index": 2,
|
||||
"takesValue": true
|
||||
},
|
||||
{
|
||||
"name": "file3",
|
||||
"index": 3,
|
||||
"takesValue": true
|
||||
},
|
||||
{
|
||||
"name": "file4",
|
||||
"index": 4,
|
||||
"takesValue": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNDMDdERDIyMTQ1QzE5NUQKUldSZEdWd1VJdDBIUE50UXVDeHZtUXVyb1lnNUtVUFVKS0Fva2FDRVc0K0NubWpmajlxY2I1bCsK",
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0OTAxMURGQkUzQjFENTQKUldSVUhUdSszeEdRNUExdmFkWnlvYWNYNG5wamkxMmUxRk5SejlMOTJVd28yNXlVTDh6Wld4OC8K",
|
||||
"endpoints": ["https://github.com/chrox/readest/releases/latest/download/latest.json"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { EnvProvider } from '@/context/EnvContext';
|
||||
import { CSPostHogProvider } from '@/context/PHContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
|
||||
import '../styles/globals.css';
|
||||
import '../styles/fonts.css';
|
||||
|
||||
const url = 'https://web.readest.com/';
|
||||
const title = 'Readest — Where You Read, Digest and Get Insight';
|
||||
const description = 'Readest brings your entire library to your fingertips.';
|
||||
const previewImage = 'https://cdn.readest.com/images/open_graph_preview_read_now.png';
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
useTheme();
|
||||
const pathname = usePathname();
|
||||
React.useEffect(() => {
|
||||
document.documentElement.setAttribute('data-page', pathname.replace('/', '') || 'default');
|
||||
}, [pathname]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// TODO: disabled for now
|
||||
// if (process.env['NODE_ENV'] === 'production') {
|
||||
// document.oncontextmenu = (event) => {
|
||||
// event.preventDefault();
|
||||
// };
|
||||
// }
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<meta name='mobile-web-app-capable' content='yes' />
|
||||
<meta name='apple-mobile-web-app-status-bar-style' content='default' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1' />
|
||||
<link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon.png' />
|
||||
<link rel='icon' href='/favicon.ico' />
|
||||
<meta name='description' content={description} />
|
||||
<meta property='og:url' content={url} />
|
||||
<meta property='og:type' content='website' />
|
||||
<meta property='og:title' content={title} />
|
||||
<meta property='og:description' content={description} />
|
||||
<meta property='og:image' content={previewImage} />
|
||||
<meta name='twitter:card' content='summary_large_image' />
|
||||
<meta property='twitter:domain' content='web.readest.com' />
|
||||
<meta property='twitter:url' content={url} />
|
||||
<meta name='twitter:title' content={title} />
|
||||
<meta name='twitter:description' content={description} />
|
||||
<meta name='twitter:image' content={previewImage} />
|
||||
</head>
|
||||
<CSPostHogProvider>
|
||||
<body>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import { MdDelete, MdOpenInNew } from 'react-icons/md';
|
||||
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { Book, BooksGroup } from '@/types/book';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { navigateToReader } from '@/utils/nav';
|
||||
import Alert from '@/components/Alert';
|
||||
import Spinner from '@/components/Spinner';
|
||||
|
||||
@@ -48,17 +49,41 @@ interface BookshelfProps {
|
||||
|
||||
const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImportBooks }) => {
|
||||
const router = useRouter();
|
||||
const { envConfig } = useEnv();
|
||||
const searchParams = useSearchParams();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { deleteBook } = useLibraryStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedBooks, setSelectedBooks] = useState<string[]>([]);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
const [clickedImage, setClickedImage] = useState<string | null>(null);
|
||||
const [importBookUrl] = useState(searchParams.get('url') || '');
|
||||
const isImportingBook = useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const { setLibrary } = useLibraryStore();
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedBooks([]);
|
||||
}, [isSelectMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isImportingBook.current) return;
|
||||
isImportingBook.current = true;
|
||||
|
||||
if (importBookUrl && appService) {
|
||||
const importBook = async () => {
|
||||
console.log('Importing book from URL:', importBookUrl);
|
||||
const book = await appService.importBook(importBookUrl, libraryBooks);
|
||||
if (book) {
|
||||
setLibrary(libraryBooks);
|
||||
appService.saveLibraryBooks(libraryBooks);
|
||||
navigateToReader(router, [book.hash]);
|
||||
}
|
||||
};
|
||||
importBook();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [importBookUrl, appService]);
|
||||
|
||||
const bookshelfItems = generateBookshelfItems(libraryBooks);
|
||||
|
||||
const handleBookClick = (id: string) => {
|
||||
@@ -68,7 +93,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
setClickedImage(id);
|
||||
setTimeout(() => setClickedImage(null), 300);
|
||||
setTimeout(() => setLoading(true), 200);
|
||||
router.push(`/reader?ids=${id}`);
|
||||
navigateToReader(router, [id]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,7 +105,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
|
||||
const openSelectedBooks = () => {
|
||||
setTimeout(() => setLoading(true), 200);
|
||||
router.push(`/reader?ids=${selectedBooks.join(',')}`);
|
||||
navigateToReader(router, selectedBooks);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FaSearch } from 'react-icons/fa';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import { PiSelectionAllDuotone } from 'react-icons/pi';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
|
||||
@@ -18,6 +19,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
onImportBooks,
|
||||
onToggleSelectMode,
|
||||
}) => {
|
||||
const { appService } = useEnv();
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -33,21 +35,6 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
};
|
||||
}, [onToggleSelectMode]);
|
||||
|
||||
const handleMinimize = async () => {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
getCurrentWindow().minimize();
|
||||
};
|
||||
|
||||
const handleToggleMaximize = async () => {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
getCurrentWindow().toggleMaximize();
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
getCurrentWindow().close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={headerRef}
|
||||
@@ -57,7 +44,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center justify-between space-x-6'>
|
||||
<div className='sm:w relative flex w-full items-center pl-4'>
|
||||
<div className='exclude-title-bar-mousedown sm:w relative flex w-full items-center pl-4'>
|
||||
<span className='absolute left-8 text-gray-500'>
|
||||
<FaSearch className='h-4 w-4' />
|
||||
</span>
|
||||
@@ -100,12 +87,9 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
</div>
|
||||
<WindowButtons
|
||||
headerRef={headerRef}
|
||||
showMinimize={!isTrafficLightVisible}
|
||||
showMaximize={!isTrafficLightVisible}
|
||||
showClose={!isTrafficLightVisible}
|
||||
onMinimize={handleMinimize}
|
||||
onToggleMaximize={handleToggleMaximize}
|
||||
onClose={handleClose}
|
||||
showMinimize={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
|
||||
showMaximize={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
|
||||
showClose={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { Book } from '@/types/book';
|
||||
import { getUserLang } from '@/utils/misc';
|
||||
|
||||
import libraryEn from '@/data/demo/library.en.json';
|
||||
import libraryZh from '@/data/demo/library.zh.json';
|
||||
|
||||
const libraries = {
|
||||
en: libraryEn,
|
||||
zh: libraryZh,
|
||||
};
|
||||
|
||||
interface DemoBooks {
|
||||
library: string[];
|
||||
}
|
||||
|
||||
export const useDemoBooks = () => {
|
||||
const { envConfig } = useEnv();
|
||||
const userLang = getUserLang() as keyof typeof libraries;
|
||||
const [books, setBooks] = useState<Book[]>([]);
|
||||
const isLoading = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading.current) return;
|
||||
isLoading.current = true;
|
||||
|
||||
const fetchDemoBooks = async () => {
|
||||
try {
|
||||
const appService = await envConfig.getAppService();
|
||||
const demoBooks = libraries[userLang] || (libraries.en as DemoBooks);
|
||||
const books = await Promise.all(
|
||||
demoBooks.library.map((url) => appService.importBook(url, [], false, true)),
|
||||
);
|
||||
setBooks(books.filter((book) => book !== null) as Book[]);
|
||||
} catch (error) {
|
||||
console.error('Failed to import demo books:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const demoBooksFetchedFlag = localStorage.getItem('demoBooksFetched');
|
||||
if (!demoBooksFetchedFlag) {
|
||||
fetchDemoBooks();
|
||||
localStorage.setItem('demoBooksFetched', 'true');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return books;
|
||||
};
|
||||
@@ -1,36 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useEffect, Suspense } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { AppService } from '@/types/system';
|
||||
import { navigateToReader } from '@/utils/nav';
|
||||
import { parseOpenWithFiles } from '@/helpers/cli';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { checkForAppUpdates } from '@/helpers/updater';
|
||||
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useDemoBooks } from './hooks/useDemoBooks';
|
||||
|
||||
import Spinner from '@/components/Spinner';
|
||||
import LibraryHeader from '@/app/library/components/LibraryHeader';
|
||||
import Bookshelf from '@/app/library/components/Bookshelf';
|
||||
import LibraryHeader from './components/LibraryHeader';
|
||||
import Bookshelf from './components/Bookshelf';
|
||||
|
||||
const LibraryPage = () => {
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { library: libraryBooks, setLibrary } = useLibraryStore();
|
||||
const {
|
||||
library: libraryBooks,
|
||||
setLibrary,
|
||||
checkOpenWithBooks,
|
||||
clearOpenWithBooks,
|
||||
} = useLibraryStore();
|
||||
useTheme();
|
||||
const { setSettings } = useSettingsStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isInitiating = useRef(false);
|
||||
const [libraryLoaded, setLibraryLoaded] = useState(false);
|
||||
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||
const demoBooks = useDemoBooks();
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const doAppUpdates = async () => {
|
||||
if (isTauriAppPlatform()) {
|
||||
await checkForAppUpdates();
|
||||
}
|
||||
};
|
||||
doAppUpdates();
|
||||
}, []);
|
||||
|
||||
const processOpenWithFiles = React.useCallback(
|
||||
async (appService: AppService, openWithFiles: string[], libraryBooks: Book[]) => {
|
||||
const bookIds: string[] = [];
|
||||
for (const file of openWithFiles) {
|
||||
const book = await appService.importBook(file, libraryBooks);
|
||||
if (book) {
|
||||
bookIds.push(book.hash);
|
||||
}
|
||||
}
|
||||
setLibrary(libraryBooks);
|
||||
appService.saveLibraryBooks(libraryBooks);
|
||||
|
||||
console.log('Opening books:', bookIds);
|
||||
if (bookIds.length > 0) {
|
||||
navigateToReader(router, bookIds);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitiating.current) return;
|
||||
isInitiating.current = true;
|
||||
|
||||
const loadingTimeout = setTimeout(() => setLoading(true), 200);
|
||||
envConfig.getAppService().then(async (appService) => {
|
||||
console.log('Loading library books...');
|
||||
setLibrary(await appService.loadLibraryBooks());
|
||||
const loadingTimeout = setTimeout(() => setLoading(true), 300);
|
||||
const initLibrary = async () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
const settings = await appService.loadSettings();
|
||||
setSettings(settings);
|
||||
|
||||
const libraryBooks = await appService.loadLibraryBooks();
|
||||
if (checkOpenWithBooks && isTauriAppPlatform()) {
|
||||
await handleOpenWithBooks(appService, libraryBooks);
|
||||
} else {
|
||||
clearOpenWithBooks();
|
||||
setLibrary(libraryBooks);
|
||||
}
|
||||
|
||||
setLibraryLoaded(true);
|
||||
if (loadingTimeout) clearTimeout(loadingTimeout);
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenWithBooks = async (appService: AppService, libraryBooks: Book[]) => {
|
||||
const openWithFiles = (await parseOpenWithFiles()) || [];
|
||||
|
||||
if (openWithFiles.length > 0) {
|
||||
await processOpenWithFiles(appService, openWithFiles, libraryBooks);
|
||||
} else {
|
||||
clearOpenWithBooks();
|
||||
setLibrary(libraryBooks);
|
||||
}
|
||||
};
|
||||
|
||||
initLibrary();
|
||||
return () => {
|
||||
clearOpenWithBooks();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (demoBooks.length > 0 && libraryLoaded) {
|
||||
const newLibrary = [...libraryBooks];
|
||||
for (const book of demoBooks) {
|
||||
const idx = newLibrary.findIndex((b) => b.hash === book.hash);
|
||||
if (idx === -1) {
|
||||
newLibrary.push(book);
|
||||
} else {
|
||||
newLibrary[idx] = book;
|
||||
}
|
||||
}
|
||||
setLibrary(newLibrary);
|
||||
appService?.saveLibraryBooks(newLibrary);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [demoBooks, libraryLoaded]);
|
||||
|
||||
const importBooks = async (files: [string | File]) => {
|
||||
setLoading(true);
|
||||
for (const file of files) {
|
||||
@@ -42,14 +138,14 @@ const LibraryPage = () => {
|
||||
};
|
||||
|
||||
const selectFilesTauri = async () => {
|
||||
return appService?.selectFiles('Select Books', ['epub', 'pdf']);
|
||||
return appService?.selectFiles('Select Books', SUPPORTED_FILE_EXTS);
|
||||
};
|
||||
|
||||
const selectFilesWeb = () => {
|
||||
return new Promise((resolve) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = '.epub, .pdf';
|
||||
fileInput.accept = FILE_ACCEPT_FORMATS;
|
||||
fileInput.multiple = true;
|
||||
fileInput.click();
|
||||
|
||||
@@ -61,12 +157,17 @@ const LibraryPage = () => {
|
||||
|
||||
const handleImportBooks = async () => {
|
||||
console.log('Importing books...');
|
||||
const { type } = await import('@tauri-apps/plugin-os');
|
||||
let files;
|
||||
if (['android', 'ios'].includes(type())) {
|
||||
files = (await selectFilesWeb()) as [File];
|
||||
|
||||
if (isTauriAppPlatform()) {
|
||||
const { type } = await import('@tauri-apps/plugin-os');
|
||||
if (['android', 'ios'].includes(type())) {
|
||||
files = (await selectFilesWeb()) as [File];
|
||||
} else {
|
||||
files = (await selectFilesTauri()) as [string];
|
||||
}
|
||||
} else {
|
||||
files = (await selectFilesTauri()) as [string];
|
||||
files = (await selectFilesWeb()) as [File];
|
||||
}
|
||||
importBooks(files);
|
||||
};
|
||||
@@ -79,6 +180,16 @@ const LibraryPage = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (checkOpenWithBooks) {
|
||||
return (
|
||||
loading && (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<Spinner loading />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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='fixed top-0 z-40 w-full'>
|
||||
@@ -93,29 +204,32 @@ const LibraryPage = () => {
|
||||
<Spinner loading />
|
||||
</div>
|
||||
)}
|
||||
{libraryBooks.length > 0 ? (
|
||||
<div className='mt-12 flex-grow overflow-auto px-2'>
|
||||
<Bookshelf
|
||||
libraryBooks={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
onImportBooks={handleImportBooks}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='hero h-screen items-center justify-center'>
|
||||
<div className='hero-content text-neutral-content text-center'>
|
||||
<div className='max-w-md'>
|
||||
<h1 className='mb-5 text-5xl font-bold'>Your Library</h1>
|
||||
<p className='mb-5'>
|
||||
Welcome to your library. You can import your books here and read them anytime.
|
||||
</p>
|
||||
<button className='btn btn-primary rounded-xl' onClick={handleImportBooks}>
|
||||
Import Books
|
||||
</button>
|
||||
{libraryLoaded &&
|
||||
(libraryBooks.length > 0 ? (
|
||||
<div className='mt-12 flex-grow overflow-auto px-2'>
|
||||
<Suspense>
|
||||
<Bookshelf
|
||||
libraryBooks={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
onImportBooks={handleImportBooks}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
) : (
|
||||
<div className='hero h-screen items-center justify-center'>
|
||||
<div className='hero-content text-neutral-content text-center'>
|
||||
<div className='max-w-md'>
|
||||
<h1 className='mb-5 text-5xl font-bold'>Your Library</h1>
|
||||
<p className='mb-5'>
|
||||
Welcome to your library. You can import your books here and read them anytime.
|
||||
</p>
|
||||
<button className='btn btn-primary rounded-xl' onClick={handleImportBooks}>
|
||||
Import Books
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
|
||||
import Spinner from '@/components/Spinner';
|
||||
|
||||
@@ -9,7 +10,7 @@ const HomePage = () => {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.push('/library');
|
||||
navigateToLibrary(router);
|
||||
}, [router]);
|
||||
|
||||
return <Spinner loading={true} />;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import Reader from '../components/Reader';
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ ids: string }> }) {
|
||||
const ids = decodeURIComponent((await params).ids);
|
||||
return <Reader ids={ids} />;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ 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 {
|
||||
bookKeys: string[];
|
||||
@@ -60,6 +61,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
onSetSettingsDialogOpen={setFontLayoutSettingsDialogOpen}
|
||||
/>
|
||||
<FoliateViewer bookKey={bookKey} bookDoc={bookDoc} config={config} />
|
||||
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
|
||||
{viewSettings.scrolled ? null : (
|
||||
<>
|
||||
<SectionInfo section={sectionLabel} gapLeft={marginGap} />
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { BookDoc, getDirection } from '@/libs/document';
|
||||
import { BookConfig, BookNote, BookSearchConfig, BookSearchResult } from '@/types/book';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useParallelViewStore } from '@/store/parallelViewStore';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { getStyles, mountAdditionalFonts } from '@/utils/style';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
|
||||
import {
|
||||
handleKeydown,
|
||||
handleMousedown,
|
||||
handleClick,
|
||||
handleMouseup,
|
||||
handleClick,
|
||||
handleWheel,
|
||||
} from '../utils/iframeEventHandlers';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
@@ -45,7 +47,7 @@ export interface FoliateView extends HTMLElement {
|
||||
removeAttribute: (name: string) => void;
|
||||
next: () => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
scrollTo?: (offset: number, reason: string, smooth: boolean) => void;
|
||||
goTo?: (params: { index: number; anchor: number }) => void;
|
||||
setStyles?: (css: string) => void;
|
||||
addEventListener: (type: string, listener: EventListener) => void;
|
||||
removeEventListener: (type: string, listener: EventListener) => void;
|
||||
@@ -73,71 +75,128 @@ const FoliateViewer: React.FC<{
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<FoliateView | null>(null);
|
||||
const isViewCreated = useRef(false);
|
||||
const isScrolling = useRef(false);
|
||||
const { getView, setView: setFoliateView, setProgress, getViewSettings } = useReaderStore();
|
||||
const { hoveredBookKey, setHoveredBookKey, setViewSettings } = useReaderStore();
|
||||
const { getParallels } = useParallelViewStore();
|
||||
const { themeCode } = useTheme();
|
||||
|
||||
const shouldAutoHideScrollbar = ['macos', 'ios'].includes(getOSPlatform());
|
||||
|
||||
const progressRelocateHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
// console.log('relocate:', detail);
|
||||
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
|
||||
};
|
||||
|
||||
const handleScrollbarAutoHide = (doc: Document) => {
|
||||
if (doc && doc.defaultView && doc.defaultView.frameElement) {
|
||||
const iframe = doc.defaultView.frameElement as HTMLIFrameElement;
|
||||
const container = iframe.parentElement?.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
let hideScrollbarTimeout: ReturnType<typeof setTimeout>;
|
||||
const showScrollbar = () => {
|
||||
container.style.overflow = 'auto';
|
||||
container.style.scrollbarWidth = 'thin';
|
||||
};
|
||||
|
||||
const hideScrollbar = () => {
|
||||
container.style.overflow = 'hidden';
|
||||
container.style.scrollbarWidth = 'none';
|
||||
requestAnimationFrame(() => {
|
||||
container.style.overflow = 'auto';
|
||||
});
|
||||
};
|
||||
container.addEventListener('scroll', () => {
|
||||
showScrollbar();
|
||||
clearTimeout(hideScrollbarTimeout);
|
||||
hideScrollbarTimeout = setTimeout(hideScrollbar, 1000);
|
||||
});
|
||||
hideScrollbar();
|
||||
}
|
||||
};
|
||||
|
||||
const docLoadHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
console.log('doc loaded:', detail);
|
||||
if (detail.doc) {
|
||||
const writingDir = viewRef.current?.renderer.setStyles && getDirection(detail.doc);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
viewSettings.vertical = writingDir?.vertical || false;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (viewSettings.scrolled && shouldAutoHideScrollbar) {
|
||||
handleScrollbarAutoHide(detail.doc);
|
||||
}
|
||||
|
||||
mountAdditionalFonts(detail.doc);
|
||||
|
||||
if (!detail.doc.isEventListenersAdded) {
|
||||
detail.doc.isEventListenersAdded = true;
|
||||
detail.doc.addEventListener('keydown', handleKeydown.bind(null, bookKey));
|
||||
detail.doc.addEventListener('mousedown', handleMousedown.bind(null, bookKey));
|
||||
detail.doc.addEventListener('mouseup', handleMouseup.bind(null, bookKey));
|
||||
detail.doc.addEventListener('click', handleClick.bind(null, bookKey));
|
||||
detail.doc.addEventListener('wheel', handleWheel.bind(null, bookKey));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const docScrollHandler = (event: Event) => {
|
||||
setTimeout(() => {
|
||||
isScrolling.current = false;
|
||||
}, 300);
|
||||
if (isScrolling.current) return;
|
||||
isScrolling.current = true;
|
||||
|
||||
const docRelocateHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
if (detail.reason !== 'scroll') return;
|
||||
if (!viewRef.current!.renderer.scrolled) return;
|
||||
if (detail.reason !== 'scroll' && detail.reason !== 'page') return;
|
||||
const parallelViews = getParallels(bookKey);
|
||||
if (parallelViews && parallelViews.size > 0) {
|
||||
parallelViews.forEach((key) => {
|
||||
if (key !== bookKey) {
|
||||
const target = getView(key)?.renderer;
|
||||
if (target && target.scrolled && target.viewSize) {
|
||||
target.scrollTo?.(detail.fraction * target.viewSize, 'follow-scroll', true);
|
||||
if (target) {
|
||||
target.goTo?.({ index: detail.index, anchor: detail.fraction });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickTurnPage = (
|
||||
msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
) => {
|
||||
const handleTurnPage = (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (msg instanceof MessageEvent) {
|
||||
if (msg.data && msg.data.type === 'iframe-single-click' && msg.data.bookKey === bookKey) {
|
||||
const viewElement = containerRef.current;
|
||||
if (viewElement) {
|
||||
const rect = viewElement.getBoundingClientRect();
|
||||
const { screenX } = msg.data;
|
||||
|
||||
const eventConsumed = eventDispatcher.dispatchSync('iframe-single-click', { screenX });
|
||||
if (!eventConsumed) {
|
||||
if (screenX >= rect.left + rect.width / 2) {
|
||||
viewRef.current?.goRight();
|
||||
} else if (screenX < rect.left + rect.width / 2) {
|
||||
viewRef.current?.goLeft();
|
||||
if (msg.data && msg.data.bookKey === bookKey) {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
if (msg.data.type === 'iframe-single-click') {
|
||||
const viewElement = containerRef.current;
|
||||
if (viewElement) {
|
||||
const rect = viewElement.getBoundingClientRect();
|
||||
const { screenX, screenY } = msg.data;
|
||||
const consumed = eventDispatcher.dispatchSync('iframe-single-click', {
|
||||
screenX,
|
||||
screenY,
|
||||
});
|
||||
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
|
||||
) {
|
||||
// toggle visibility of the header bar and the footer bar
|
||||
setHoveredBookKey(hoveredBookKey ? '' : bookKey);
|
||||
} else if (screenX >= rect.left + rect.width / 2) {
|
||||
viewRef.current?.goRight();
|
||||
} else if (screenX < rect.left + rect.width / 2) {
|
||||
viewRef.current?.goLeft();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) {
|
||||
const { deltaY } = msg.data;
|
||||
if (deltaY > 0) {
|
||||
viewRef.current?.next(1);
|
||||
} else if (deltaY < 0) {
|
||||
viewRef.current?.prev(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -153,10 +212,18 @@ const FoliateViewer: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('message', handleTurnPage);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleTurnPage);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hoveredBookKey, viewRef]);
|
||||
|
||||
useFoliateEvents(viewRef.current, {
|
||||
onLoad: docLoadHandler,
|
||||
onRelocate: progressRelocateHandler,
|
||||
onRendererRelocate: docScrollHandler,
|
||||
onRendererRelocate: docRelocateHandler,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -177,11 +244,12 @@ const FoliateViewer: React.FC<{
|
||||
const view = wrappedFoliateView(document.createElement('foliate-view') as FoliateView);
|
||||
document.body.append(view);
|
||||
containerRef.current?.appendChild(view);
|
||||
setFoliateView(bookKey, view);
|
||||
|
||||
await view.open(bookDoc);
|
||||
// make sure we can listen renderer events after opening book
|
||||
viewRef.current = view;
|
||||
setFoliateView(bookKey, view);
|
||||
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
view.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
|
||||
@@ -213,8 +281,6 @@ const FoliateViewer: React.FC<{
|
||||
} else {
|
||||
await view.goToFraction(0);
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleClickTurnPage);
|
||||
};
|
||||
|
||||
openBook();
|
||||
@@ -224,7 +290,7 @@ const FoliateViewer: React.FC<{
|
||||
return (
|
||||
<div
|
||||
className='foliate-viewer h-[100%] w-[100%]'
|
||||
onClick={(event) => handleClickTurnPage(event)}
|
||||
onClick={(event) => handleTurnPage(event)}
|
||||
ref={containerRef}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
|
||||
import { FoliateView } from './FoliateViewer';
|
||||
import { FootnoteHandler } from 'foliate-js/footnotes.js';
|
||||
import Popup from '@/components/Popup';
|
||||
|
||||
interface FootnotePopupProps {
|
||||
bookKey: string;
|
||||
bookDoc: BookDoc;
|
||||
}
|
||||
|
||||
const popupWidth = 360;
|
||||
const popupHeight = 88;
|
||||
const popupPadding = 10;
|
||||
|
||||
const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
const footnoteRef = useRef<HTMLDivElement>(null);
|
||||
const footnoteViewRef = useRef<FoliateView | null>(null);
|
||||
const [trianglePosition, setTrianglePosition] = useState<Position | null>();
|
||||
const [popupPosition, setPopupPosition] = useState<Position | null>();
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const { themeCode } = useTheme();
|
||||
const view = getView(bookKey);
|
||||
const footnoteHandler = new FootnoteHandler();
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeRender = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
const { view } = detail;
|
||||
footnoteViewRef.current = view;
|
||||
footnoteRef.current?.replaceChildren(view);
|
||||
const { renderer } = view;
|
||||
renderer.setAttribute('flow', 'scrolled');
|
||||
renderer.setAttribute('margin', '0px');
|
||||
renderer.setAttribute('gap', '5%');
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
};
|
||||
|
||||
const handleRender = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
console.log('render footnote', detail);
|
||||
setShowPopup(true);
|
||||
};
|
||||
|
||||
footnoteHandler.addEventListener('before-render', handleBeforeRender);
|
||||
footnoteHandler.addEventListener('render', handleRender);
|
||||
return () => {
|
||||
footnoteHandler.removeEventListener('before-render', handleBeforeRender);
|
||||
footnoteHandler.removeEventListener('render', handleRender);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view]);
|
||||
|
||||
const docLinkHandler = async (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
console.log('doc link click', detail);
|
||||
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
|
||||
if (!gridFrame) return;
|
||||
const rect = gridFrame.getBoundingClientRect();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const triangPos = getPosition(detail.a, rect, viewSettings.vertical);
|
||||
const popupPos = getPopupPosition(triangPos, rect, popupWidth, popupHeight, popupPadding);
|
||||
setTrianglePosition(triangPos);
|
||||
setPopupPosition(popupPos);
|
||||
|
||||
footnoteHandler.handle(bookDoc, event)?.catch((err) => {
|
||||
console.warn(err);
|
||||
const detail = (event as CustomEvent).detail;
|
||||
view?.goTo(detail.href);
|
||||
});
|
||||
};
|
||||
|
||||
const closePopup = () => {
|
||||
const view = footnoteRef.current?.querySelector('foliate-view') as FoliateView;
|
||||
view?.close();
|
||||
view?.remove();
|
||||
};
|
||||
|
||||
const handleDismissPopup = () => {
|
||||
closePopup();
|
||||
setPopupPosition(null);
|
||||
setTrianglePosition(null);
|
||||
setShowPopup(false);
|
||||
};
|
||||
|
||||
useFoliateEvents(view, {
|
||||
onLinkClick: docLinkHandler,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (footnoteViewRef.current) {
|
||||
footnoteRef.current?.replaceChildren(footnoteViewRef.current);
|
||||
}
|
||||
}, [footnoteRef]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showPopup && (
|
||||
<div
|
||||
className='fixed inset-0'
|
||||
onClick={handleDismissPopup}
|
||||
onContextMenu={handleDismissPopup}
|
||||
/>
|
||||
)}
|
||||
<Popup
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
position={showPopup ? popupPosition! : undefined}
|
||||
trianglePosition={showPopup ? trianglePosition! : undefined}
|
||||
className='select-text overflow-y-auto'
|
||||
>
|
||||
<div
|
||||
className=''
|
||||
ref={footnoteRef}
|
||||
style={{
|
||||
width: `${popupWidth}px`,
|
||||
height: `${popupHeight}px`,
|
||||
}}
|
||||
></div>
|
||||
</Popup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FootnotePopup;
|
||||
@@ -2,6 +2,7 @@ import clsx from 'clsx';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
@@ -29,10 +30,11 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
onCloseBook,
|
||||
onSetSettingsDialogOpen,
|
||||
}) => {
|
||||
const { appService } = useEnv();
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
|
||||
const { hoveredBookKey, setHoveredBookKey, bookKeys } = useReaderStore();
|
||||
const { isSideBarVisible } = useSidebarStore();
|
||||
|
||||
const handleToggleDropdown = (isOpen: boolean) => {
|
||||
@@ -67,7 +69,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
<div className='flex h-full items-center space-x-2'>
|
||||
<NotebookToggler bookKey={bookKey} />
|
||||
<Dropdown
|
||||
className='dropdown-bottom dropdown-end'
|
||||
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<PiDotsThreeVerticalBold size={16} />}
|
||||
onToggle={handleToggleDropdown}
|
||||
@@ -78,8 +80,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
<WindowButtons
|
||||
className='window-buttons flex h-full items-center'
|
||||
headerRef={headerRef}
|
||||
showMinimize={false}
|
||||
showMaximize={false}
|
||||
showMinimize={bookKeys.length == 1 && appService?.appPlatform !== 'web'}
|
||||
showMaximize={bookKeys.length == 1 && appService?.appPlatform !== 'web'}
|
||||
onClose={() => onCloseBook(bookKey)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
|
||||
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
const { envConfig } = useEnv();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const { library, setLibrary } = useLibraryStore();
|
||||
const isInitiating = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitiating.current) return;
|
||||
isInitiating.current = true;
|
||||
const initLibrary = async () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
const settings = await appService.loadSettings();
|
||||
setSettings(settings);
|
||||
setLibrary(await appService.loadLibraryBooks());
|
||||
};
|
||||
|
||||
initLibrary();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
library.length > 0 &&
|
||||
settings.globalReadSettings && (
|
||||
<div className='reader-page bg-base-100 text-base-content min-h-screen select-none'>
|
||||
<Suspense>
|
||||
<ReaderContent ids={ids} settings={settings} />
|
||||
<AboutWindow />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default Reader;
|
||||
@@ -10,7 +10,11 @@ import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { SystemSettings } from '@/types/settings';
|
||||
import { parseOpenWithFiles } from '@/helpers/cli';
|
||||
import { tauriHandleClose } from '@/utils/window';
|
||||
import { uniqueId } from '@/utils/misc';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
|
||||
|
||||
import useBooksManager from '../hooks/useBooksManager';
|
||||
import useBookShortcuts from '../hooks/useBookShortcuts';
|
||||
@@ -19,7 +23,7 @@ import SideBar from './sidebar/SideBar';
|
||||
import Notebook from './notebook/Notebook';
|
||||
import BooksGrid from './BooksGrid';
|
||||
|
||||
const ReaderContent: React.FC<{ settings: SystemSettings }> = ({ settings }) => {
|
||||
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { envConfig } = useEnv();
|
||||
@@ -38,7 +42,8 @@ const ReaderContent: React.FC<{ settings: SystemSettings }> = ({ settings }) =>
|
||||
if (isInitiating.current) return;
|
||||
isInitiating.current = true;
|
||||
|
||||
const initialIds = (searchParams.get('ids') || '').split(',').filter(Boolean);
|
||||
const bookIds = ids || searchParams.get('ids') || '';
|
||||
const initialIds = bookIds.split(BOOK_IDS_SEPARATOR).filter(Boolean);
|
||||
const initialBookKeys = initialIds.map((id) => `${id}-${uniqueId()}`);
|
||||
setBookKeys(initialBookKeys);
|
||||
const uniqueIds = new Set<string>();
|
||||
@@ -70,7 +75,7 @@ const ReaderContent: React.FC<{ settings: SystemSettings }> = ({ settings }) =>
|
||||
|
||||
const saveSettingsAndGoToLibrary = () => {
|
||||
saveSettings(envConfig, settings);
|
||||
router.replace('/library');
|
||||
navigateToLibrary(router);
|
||||
};
|
||||
|
||||
const handleCloseBooks = () => {
|
||||
@@ -80,21 +85,26 @@ const ReaderContent: React.FC<{ settings: SystemSettings }> = ({ settings }) =>
|
||||
saveSettingsAndGoToLibrary();
|
||||
};
|
||||
|
||||
const handleCloseBook = (bookKey: string) => {
|
||||
const handleCloseBook = async (bookKey: string) => {
|
||||
saveConfigAndCloseBook(bookKey);
|
||||
if (sideBarBookKey === bookKey) {
|
||||
setSideBarBookKey(getNextBookKey(sideBarBookKey));
|
||||
}
|
||||
dismissBook(bookKey);
|
||||
if (bookKeys.filter((key) => key !== bookKey).length == 0) {
|
||||
saveSettingsAndGoToLibrary();
|
||||
const openWithFiles = (await parseOpenWithFiles()) || [];
|
||||
if (openWithFiles.length > 0) {
|
||||
tauriHandleClose();
|
||||
} else {
|
||||
saveSettingsAndGoToLibrary();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!bookKeys || bookKeys.length === 0) return null;
|
||||
const bookData = getBookData(bookKeys[0]!);
|
||||
if (!bookData || !bookData.book || !bookData.bookDoc) {
|
||||
setTimeout(() => setLoading(true), 200);
|
||||
setTimeout(() => setLoading(true), 300);
|
||||
return (
|
||||
loading && (
|
||||
<div className={'hero hero-content min-h-screen'}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BiMoon, BiSun } from 'react-icons/bi';
|
||||
import { TbSunMoon } from 'react-icons/tb';
|
||||
import { MdZoomOut, MdZoomIn, MdCheck } from 'react-icons/md';
|
||||
|
||||
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTheme, ThemeMode } from '@/hooks/useTheme';
|
||||
@@ -22,7 +23,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
onSetSettingsDialogOpen,
|
||||
}) => {
|
||||
const { getView, getViews, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const { themeMode, isDarkMode, themeCode, updateThemeMode } = useTheme();
|
||||
const [isScrolledMode, setScrolledMode] = useState(viewSettings!.scrolled);
|
||||
@@ -55,6 +56,11 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
getView(bookKey)?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
|
||||
getView(bookKey)?.renderer.setAttribute(
|
||||
'max-inline-size',
|
||||
`${viewSettings.maxColumnCount === 1 || isScrolledMode ? ONE_COLUMN_MAX_INLINE_SIZE : viewSettings.maxInlineSize}px`,
|
||||
);
|
||||
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
|
||||
viewSettings!.scrolled = isScrolledMode;
|
||||
setViewSettings(bookKey, viewSettings!);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -80,7 +86,6 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
id='exclude-title-bar-mousedown'
|
||||
tabIndex={0}
|
||||
className='view-menu dropdown-content dropdown-right no-triangle border-base-100 z-20 mt-1 w-72 border shadow-2xl'
|
||||
>
|
||||
|
||||
@@ -30,13 +30,14 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const { envConfig } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore();
|
||||
const { getProgress, getView, getViewsById } = useReaderStore();
|
||||
const { getProgress, getView, getViewsById, getViewSettings } = useReaderStore();
|
||||
const { isNotebookPinned, isNotebookVisible } = useNotebookStore();
|
||||
const { setNotebookVisible, setNotebookNewAnnotation } = useNotebookStore();
|
||||
const config = getConfig(bookKey)!;
|
||||
const progress = getProgress(bookKey)!;
|
||||
const bookData = getBookData(bookKey)!;
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const isShowingPopup = useRef(false);
|
||||
const isTextSelected = useRef(false);
|
||||
@@ -48,6 +49,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const [trianglePosition, setTrianglePosition] = useState<Position>();
|
||||
const [annotPopupPosition, setAnnotPopupPosition] = useState<Position>();
|
||||
const [dictPopupPosition, setDictPopupPosition] = useState<Position>();
|
||||
const [translatorPopupPosition, setTranslatorPopupPosition] = useState<Position>();
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
const [highlightOptionsVisible, setHighlightOptionsVisible] = useState(false);
|
||||
|
||||
@@ -58,8 +60,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalReadSettings.highlightStyles[selectedStyle],
|
||||
);
|
||||
|
||||
const dictPopupWidth = 400;
|
||||
const dictPopupWidth = 480;
|
||||
const dictPopupHeight = 300;
|
||||
const transPopupWidth = 480;
|
||||
const transPopupHeight = 360;
|
||||
const annotPopupWidth = 280;
|
||||
const annotPopupHeight = 44;
|
||||
const popupPadding = 10;
|
||||
@@ -105,7 +109,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
setSelection(selection);
|
||||
};
|
||||
|
||||
useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation }, [config]);
|
||||
useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation });
|
||||
|
||||
const handleDismissPopup = () => {
|
||||
setSelection(null);
|
||||
@@ -148,7 +152,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
|
||||
if (!gridFrame) return;
|
||||
const rect = gridFrame.getBoundingClientRect();
|
||||
const triangPos = getPosition(selection.range, rect);
|
||||
const triangPos = getPosition(selection.range, rect, viewSettings.vertical);
|
||||
const annotPopupPos = getPopupPosition(
|
||||
triangPos,
|
||||
rect,
|
||||
@@ -163,13 +167,22 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
dictPopupHeight,
|
||||
popupPadding,
|
||||
);
|
||||
const transPopupPos = getPopupPosition(
|
||||
triangPos,
|
||||
rect,
|
||||
transPopupWidth,
|
||||
transPopupHeight,
|
||||
popupPadding,
|
||||
);
|
||||
if (triangPos.point.x == 0 || triangPos.point.y == 0) return;
|
||||
setShowAnnotPopup(true);
|
||||
setAnnotPopupPosition(annotPopupPos);
|
||||
setDictPopupPosition(dictPopupPos);
|
||||
setTranslatorPopupPosition(transPopupPos);
|
||||
setTrianglePosition(triangPos);
|
||||
isShowingPopup.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selection, bookKey]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -357,13 +370,13 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
popupHeight={dictPopupHeight}
|
||||
/>
|
||||
)}
|
||||
{showDeepLPopup && trianglePosition && dictPopupPosition && (
|
||||
{showDeepLPopup && trianglePosition && translatorPopupPosition && (
|
||||
<DeepLPopup
|
||||
text={selection?.text as string}
|
||||
position={dictPopupPosition}
|
||||
position={translatorPopupPosition}
|
||||
trianglePosition={trianglePosition}
|
||||
popupWidth={dictPopupWidth}
|
||||
popupHeight={dictPopupHeight}
|
||||
popupWidth={transPopupWidth}
|
||||
popupHeight={transPopupHeight}
|
||||
/>
|
||||
)}
|
||||
{showAnnotPopup && trianglePosition && annotPopupPosition && (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Popup from '@/components/Popup';
|
||||
import { Position } from '@/utils/sel';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
|
||||
const LANGUAGES = {
|
||||
AUTO: 'Auto Detect',
|
||||
@@ -61,8 +61,19 @@ 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('https://api-free.deepl.com/v2/translate', {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -78,7 +89,6 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch translation');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const translatedText = data.translations[0]?.text;
|
||||
const detectedSource = data.translations[0]?.detected_source_language;
|
||||
@@ -110,8 +120,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
position={position}
|
||||
className='bg-neutral select-text'
|
||||
triangleClassName='text-neutral'
|
||||
className='select-text'
|
||||
>
|
||||
<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'>
|
||||
|
||||
@@ -96,7 +96,8 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const langCode = typeof lang === 'string' ? lang : lang?.[0];
|
||||
const bookLang = typeof lang === 'string' ? lang : lang?.[0];
|
||||
const langCode = bookLang ? bookLang.split('-')[0]! : 'en';
|
||||
fetchSummary(text, langCode);
|
||||
}, [text, lang]);
|
||||
|
||||
@@ -107,8 +108,7 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
|
||||
height={popupHeight}
|
||||
position={position}
|
||||
trianglePosition={trianglePosition}
|
||||
className='bg-neutral select-text overflow-y-auto'
|
||||
triangleClassName='text-neutral'
|
||||
className='select-text overflow-y-auto'
|
||||
>
|
||||
<main className='p-2 font-sans'></main>
|
||||
<footer className='hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
|
||||
@@ -163,8 +163,7 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
position={position}
|
||||
className='bg-neutral select-text overflow-y-auto'
|
||||
triangleClassName='text-neutral'
|
||||
className='select-text overflow-y-auto'
|
||||
>
|
||||
<main className='p-4 font-sans' />
|
||||
<footer className='hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { MdInfoOutline } from 'react-icons/md';
|
||||
import { formatAuthors } from '@/utils/book';
|
||||
|
||||
interface BookCardProps {
|
||||
cover: string;
|
||||
@@ -24,7 +23,7 @@ const BookCard: React.FC<BookCardProps> = ({ cover, title, author }) => {
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='line-clamp-2 w-[90%] text-sm font-semibold'>{title}</h4>
|
||||
<p className='text-neutral-content truncate text-xs'>{formatAuthors(author)}</p>
|
||||
<p className='text-neutral-content truncate text-xs'>{author}</p>
|
||||
</div>
|
||||
<button
|
||||
className='btn btn-ghost hover:bg-base-300 h-6 min-h-6 w-6 rounded-full p-0 transition-colors'
|
||||
|
||||
@@ -3,8 +3,10 @@ import Image from 'next/image';
|
||||
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import { setAboutDialogVisible } from '@/components/AboutWindow';
|
||||
import useBooksManager from '../../hooks/useBooksManager';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import useBooksManager from '../../hooks/useBooksManager';
|
||||
|
||||
interface BookMenuProps {
|
||||
setIsDropdownOpen?: (isOpen: boolean) => void;
|
||||
@@ -25,6 +27,12 @@ const BookMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setAboutDialogVisible(true);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
const downloadReadest = () => {
|
||||
window.open(DOWNLOAD_READEST_URL, '_blank');
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const isWebApp = isWebAppPlatform();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -56,6 +64,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
</MenuItem>
|
||||
<MenuItem label='Reload Page' noIcon shortcut='Shift+R' onClick={handleReloadPage} />
|
||||
<hr className='border-base-200 my-1' />
|
||||
{isWebApp && <MenuItem label='Download Readest' noIcon onClick={downloadReadest} />}
|
||||
<MenuItem label='About Readest' noIcon onClick={showAboutReadest} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { uniqueId } from '@/utils/misc';
|
||||
import { useParallelViewStore } from '@/store/parallelViewStore';
|
||||
import { navigateToReader } from '@/utils/nav';
|
||||
|
||||
const useBooksManager = () => {
|
||||
const router = useRouter();
|
||||
@@ -18,11 +19,9 @@ const useBooksManager = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldUpdateSearchParams) {
|
||||
const ids = bookKeys.map((key) => key.split('-')[0]).join(',');
|
||||
const ids = bookKeys.map((key) => key.split('-')[0]!);
|
||||
if (ids) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('ids', ids);
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
navigateToReader(router, ids, searchParams.toString(), { scroll: false });
|
||||
}
|
||||
setShouldUpdateSearchParams(false);
|
||||
}
|
||||
|
||||
@@ -4,18 +4,16 @@ import { FoliateView } from '@/app/reader/components/FoliateViewer';
|
||||
type FoliateEventHandler = {
|
||||
onLoad?: (event: Event) => void;
|
||||
onRelocate?: (event: Event) => void;
|
||||
onLinkClick?: (event: Event) => void;
|
||||
onRendererRelocate?: (event: Event) => void;
|
||||
onDrawAnnotation?: (event: Event) => void;
|
||||
onShowAnnotation?: (event: Event) => void;
|
||||
};
|
||||
|
||||
export const useFoliateEvents = (
|
||||
view: FoliateView | null,
|
||||
handlers?: FoliateEventHandler,
|
||||
dependencies: React.DependencyList = [],
|
||||
) => {
|
||||
export const useFoliateEvents = (view: FoliateView | null, handlers?: FoliateEventHandler) => {
|
||||
const onLoad = handlers?.onLoad;
|
||||
const onRelocate = handlers?.onRelocate;
|
||||
const onLinkClick = handlers?.onLinkClick;
|
||||
const onRendererRelocate = handlers?.onRendererRelocate;
|
||||
const onDrawAnnotation = handlers?.onDrawAnnotation;
|
||||
const onShowAnnotation = handlers?.onShowAnnotation;
|
||||
@@ -24,6 +22,7 @@ export const useFoliateEvents = (
|
||||
if (!view) return;
|
||||
if (onLoad) view.addEventListener('load', onLoad);
|
||||
if (onRelocate) view.addEventListener('relocate', onRelocate);
|
||||
if (onLinkClick) view.addEventListener('link', onLinkClick);
|
||||
if (onRendererRelocate) view.renderer.addEventListener('relocate', onRendererRelocate);
|
||||
if (onDrawAnnotation) view.addEventListener('draw-annotation', onDrawAnnotation);
|
||||
if (onShowAnnotation) view.addEventListener('show-annotation', onShowAnnotation);
|
||||
@@ -31,10 +30,11 @@ export const useFoliateEvents = (
|
||||
return () => {
|
||||
if (onLoad) view.removeEventListener('load', onLoad);
|
||||
if (onRelocate) view.removeEventListener('relocate', onRelocate);
|
||||
if (onLinkClick) view.removeEventListener('link', onLinkClick);
|
||||
if (onRendererRelocate) view.renderer.removeEventListener('relocate', onRendererRelocate);
|
||||
if (onDrawAnnotation) view.removeEventListener('draw-annotation', onDrawAnnotation);
|
||||
if (onShowAnnotation) view.removeEventListener('show-annotation', onShowAnnotation);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view, ...dependencies]);
|
||||
}, [view]);
|
||||
};
|
||||
|
||||
@@ -1,47 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect, Suspense, useRef } from 'react';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
|
||||
import ReaderContent from './components/ReaderContent';
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
|
||||
const ReaderPage = () => {
|
||||
const { envConfig } = useEnv();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const { library, setLibrary } = useLibraryStore();
|
||||
const isInitiating = useRef(false);
|
||||
import { useEffect } from 'react';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { checkForAppUpdates } from '@/helpers/updater';
|
||||
import Reader from './components/Reader';
|
||||
|
||||
export default function Page() {
|
||||
useTheme();
|
||||
useEffect(() => {
|
||||
if (isInitiating.current) return;
|
||||
isInitiating.current = true;
|
||||
const initLibrary = async () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
const settings = await appService.loadSettings();
|
||||
setSettings(settings);
|
||||
console.log('initializing library in reader');
|
||||
setLibrary(await appService.loadLibraryBooks());
|
||||
const doAppUpdates = async () => {
|
||||
if (isTauriAppPlatform()) {
|
||||
await checkForAppUpdates();
|
||||
}
|
||||
};
|
||||
|
||||
initLibrary();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
doAppUpdates();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
library.length > 0 &&
|
||||
settings.globalReadSettings && (
|
||||
<div className='reader-page bg-base-100 text-base-content min-h-screen select-none'>
|
||||
<Suspense>
|
||||
<ReaderContent settings={settings} />
|
||||
<AboutWindow />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default ReaderPage;
|
||||
return <Reader />;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,26 @@ export const handleMouseup = (bookKey: string, event: MouseEvent) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const handleWheel = (bookKey: string, event: WheelEvent) => {
|
||||
window.postMessage(
|
||||
{
|
||||
type: 'iframe-wheel',
|
||||
bookKey,
|
||||
deltaMode: event.deltaMode,
|
||||
deltaX: event.deltaX,
|
||||
deltaY: event.deltaY,
|
||||
deltaZ: event.deltaZ,
|
||||
screenX: event.screenX,
|
||||
screenY: event.screenY,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
offsetX: event.offsetX,
|
||||
offsetY: event.offsetY,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
};
|
||||
|
||||
export const handleClick = (bookKey: string, event: MouseEvent) => {
|
||||
const now = Date.now();
|
||||
|
||||
@@ -84,7 +104,7 @@ export const handleClick = (bookKey: string, event: MouseEvent) => {
|
||||
if (Date.now() - lastClickTime >= doubleClickThreshold) {
|
||||
let element: HTMLElement | null = event.target as HTMLElement;
|
||||
while (element) {
|
||||
if (element.tagName.toLowerCase() === 'a') {
|
||||
if (['sup', 'a', 'audio', 'video'].includes(element.tagName.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
element = element.parentElement;
|
||||
|
||||
@@ -12,8 +12,8 @@ const Popup = ({
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
position: Position;
|
||||
trianglePosition: Position;
|
||||
position?: Position;
|
||||
trianglePosition?: Position;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
triangleClassName?: string;
|
||||
@@ -21,24 +21,57 @@ const Popup = ({
|
||||
}) => (
|
||||
<div>
|
||||
<div
|
||||
className={`triangle absolute z-10 ${triangleClassName}`}
|
||||
className={`triangle text-base-200 absolute z-10 ${triangleClassName}`}
|
||||
style={{
|
||||
left: `${trianglePosition.point.x}px`,
|
||||
top: `${trianglePosition.point.y}px`,
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderBottom: trianglePosition.dir === 'up' ? 'none' : `6px solid`,
|
||||
borderTop: trianglePosition.dir === 'up' ? `6px solid` : 'none',
|
||||
transform: 'translateX(-50%)',
|
||||
left:
|
||||
trianglePosition?.dir === 'left'
|
||||
? `${trianglePosition.point.x}px`
|
||||
: trianglePosition?.dir === 'right'
|
||||
? `${trianglePosition.point.x}px`
|
||||
: `${trianglePosition ? trianglePosition.point.x : -999}px`,
|
||||
top:
|
||||
trianglePosition?.dir === 'up'
|
||||
? `${trianglePosition.point.y}px`
|
||||
: trianglePosition?.dir === 'down'
|
||||
? `${trianglePosition.point.y}px`
|
||||
: `${trianglePosition ? trianglePosition.point.y : -999}px`,
|
||||
borderLeft:
|
||||
trianglePosition?.dir === 'right'
|
||||
? 'none'
|
||||
: trianglePosition?.dir === 'left'
|
||||
? `6px solid`
|
||||
: '6px solid transparent',
|
||||
borderRight:
|
||||
trianglePosition?.dir === 'left'
|
||||
? 'none'
|
||||
: trianglePosition?.dir === 'right'
|
||||
? `6px solid`
|
||||
: '6px solid transparent',
|
||||
borderTop:
|
||||
trianglePosition?.dir === 'down'
|
||||
? 'none'
|
||||
: trianglePosition?.dir === 'up'
|
||||
? `6px solid`
|
||||
: '6px solid transparent',
|
||||
borderBottom:
|
||||
trianglePosition?.dir === 'up'
|
||||
? 'none'
|
||||
: trianglePosition?.dir === 'down'
|
||||
? `6px solid`
|
||||
: '6px solid transparent',
|
||||
transform:
|
||||
trianglePosition?.dir === 'left' || trianglePosition?.dir === 'right'
|
||||
? 'translateY(-50%)'
|
||||
: 'translateX(-50%)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`absolute rounded-lg font-sans shadow-lg ${className}`}
|
||||
className={`bg-base-200 absolute rounded-lg font-sans shadow-xl ${className}`}
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
left: `${position.point.x}px`,
|
||||
top: `${position.point.y}px`,
|
||||
left: `${position ? position.point.x : -999}px`,
|
||||
top: `${position ? position.point.y : -999}px`,
|
||||
...additionalStyle,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import { tauriHandleMinimize, tauriHandleToggleMaximize, tauriHandleClose } from '@/utils/window';
|
||||
|
||||
interface WindowButtonsProps {
|
||||
className?: string;
|
||||
headerRef?: React.RefObject<HTMLDivElement>;
|
||||
@@ -12,6 +14,24 @@ interface WindowButtonsProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface WindowButtonProps {
|
||||
id: string;
|
||||
onClick: () => void;
|
||||
ariaLabel: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const WindowButton: React.FC<WindowButtonProps> = ({ onClick, ariaLabel, id, children }) => (
|
||||
<button
|
||||
id={id}
|
||||
onClick={onClick}
|
||||
className='window-button text-base-content/85 hover:text-base-content'
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
const WindowButtons: React.FC<WindowButtonsProps> = ({
|
||||
className,
|
||||
headerRef,
|
||||
@@ -27,11 +47,11 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
|
||||
const handleMouseDown = async (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
if (target !== e.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.closest('#exclude-title-bar-mousedown')) {
|
||||
if (
|
||||
target.closest('.btn') ||
|
||||
target.closest('.window-button') ||
|
||||
target.closest('.exclude-title-bar-mousedown')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,18 +78,24 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
|
||||
const handleMinimize = async () => {
|
||||
if (onMinimize) {
|
||||
onMinimize();
|
||||
} else {
|
||||
tauriHandleMinimize();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMaximize = async () => {
|
||||
if (onToggleMaximize) {
|
||||
onToggleMaximize();
|
||||
} else {
|
||||
tauriHandleToggleMaximize();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
} else {
|
||||
tauriHandleClose();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -83,45 +109,30 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
|
||||
)}
|
||||
>
|
||||
{showMinimize && (
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
className='window-button text-base-content'
|
||||
aria-label='Minimize'
|
||||
id='titlebar-minimize'
|
||||
>
|
||||
<WindowButton onClick={handleMinimize} ariaLabel='Minimize' id='titlebar-minimize'>
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'>
|
||||
<path fill='currentColor' d='M20 14H4v-2h16' />
|
||||
</svg>
|
||||
</button>
|
||||
</WindowButton>
|
||||
)}
|
||||
|
||||
{showMaximize && (
|
||||
<button
|
||||
onClick={handleMaximize}
|
||||
className='window-button text-base-content'
|
||||
aria-label='Maximize/Restore'
|
||||
id='titlebar-maximize'
|
||||
>
|
||||
<WindowButton onClick={handleMaximize} ariaLabel='Maximize/Restore' id='titlebar-maximize'>
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'>
|
||||
<path fill='currentColor' d='M4 4h16v16H4zm2 4v10h12V8z' />
|
||||
</svg>
|
||||
</button>
|
||||
</WindowButton>
|
||||
)}
|
||||
|
||||
{showClose && (
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className='window-button text-base-content'
|
||||
aria-label='Close'
|
||||
id='titlebar-close'
|
||||
>
|
||||
<WindowButton onClick={handleClose} ariaLabel='Close' id='titlebar-close'>
|
||||
<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>
|
||||
</WindowButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"library": [
|
||||
"https://cdn.readest.com/books/a-room-of-one-s-own.epub",
|
||||
"https://cdn.readest.com/books/hamlet.epub",
|
||||
"https://cdn.readest.com/books/meditations.epub",
|
||||
"https://cdn.readest.com/books/the-great-gatsby.epub",
|
||||
"https://cdn.readest.com/books/the-scarlet-letter.epub",
|
||||
"https://cdn.readest.com/books/this-side-of-paradise.epub"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"library": ["https://cdn.readest.com/books/four-great-classics.epub"]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
OPEN_WITH_FILES?: string[];
|
||||
}
|
||||
}
|
||||
|
||||
interface CliArgument {
|
||||
value: string;
|
||||
occurrences: number;
|
||||
}
|
||||
|
||||
const parseWindowOpenWithFiles = () => {
|
||||
return window.OPEN_WITH_FILES;
|
||||
};
|
||||
|
||||
const parseCLIOpenWithFiles = async () => {
|
||||
const { getMatches } = await import('@tauri-apps/plugin-cli');
|
||||
const matches = await getMatches();
|
||||
const args = matches?.args;
|
||||
const files: string[] = [];
|
||||
if (args) {
|
||||
for (const name of ['file1', 'file2', 'file3', 'file4']) {
|
||||
const arg = args[name] as CliArgument;
|
||||
if (arg && arg.occurrences > 0) {
|
||||
files.push(arg.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
export const parseOpenWithFiles = async () => {
|
||||
if (isWebAppPlatform()) return [];
|
||||
|
||||
let files = parseWindowOpenWithFiles();
|
||||
if (!files) {
|
||||
files = await parseCLIOpenWithFiles();
|
||||
}
|
||||
return files;
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { check } from '@tauri-apps/plugin-updater';
|
||||
import { ask } from '@tauri-apps/plugin-dialog';
|
||||
import { relaunch } from '@tauri-apps/plugin-process';
|
||||
|
||||
export const checkForAppUpdates = async () => {
|
||||
const update = await check();
|
||||
if (update) {
|
||||
const yes = await ask(
|
||||
`
|
||||
Update to ${update.version} is available!
|
||||
Release notes: ${update.body}
|
||||
`,
|
||||
{
|
||||
title: 'Update Now!',
|
||||
kind: 'info',
|
||||
okLabel: 'Update',
|
||||
cancelLabel: 'Cancel',
|
||||
},
|
||||
);
|
||||
|
||||
if (yes) {
|
||||
console.log(`found update ${update.version} from ${update.date} with notes ${update.body}`);
|
||||
let downloaded = 0;
|
||||
let contentLength = 0;
|
||||
await update.downloadAndInstall((event) => {
|
||||
switch (event.event) {
|
||||
case 'Started':
|
||||
contentLength = event.data.contentLength!;
|
||||
console.log(`started downloading ${event.data.contentLength} bytes`);
|
||||
break;
|
||||
case 'Progress':
|
||||
downloaded += event.data.chunkLength;
|
||||
console.log(`downloaded ${downloaded} from ${contentLength}`);
|
||||
break;
|
||||
case 'Finished':
|
||||
console.log('download finished');
|
||||
break;
|
||||
}
|
||||
});
|
||||
console.log('update installed');
|
||||
await relaunch();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -30,7 +30,10 @@ const useTrafficLight = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
|
||||
handleSwitchFullScreen();
|
||||
|
||||
return () => {
|
||||
unlistenEnterFullScreen?.();
|
||||
unlistenExitFullScreen?.();
|
||||
|
||||
@@ -166,3 +166,11 @@ export class DocumentLoader {
|
||||
return { book, format } as { book: BookDoc; format: BookFormat };
|
||||
}
|
||||
}
|
||||
|
||||
export const getDirection = (doc: Document) => {
|
||||
const { defaultView } = doc;
|
||||
const { writingMode, direction } = defaultView!.getComputedStyle(doc.body);
|
||||
const vertical = writingMode === 'vertical-rl' || writingMode === 'vertical-lr';
|
||||
const rtl = doc.body.dir === 'rtl' || direction === 'rtl' || doc.documentElement.dir === 'rtl';
|
||||
return { vertical, rtl };
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppService, ToastType } from '@/types/system';
|
||||
import { AppPlatform, AppService, ToastType } from '@/types/system';
|
||||
|
||||
import { SystemSettings } from '@/types/settings';
|
||||
import { FileSystem, BaseDir } from '@/types/system';
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
getConfigFilename,
|
||||
getLibraryFilename,
|
||||
INIT_BOOK_CONFIG,
|
||||
formatTitle,
|
||||
formatAuthors,
|
||||
} from '@/utils/book';
|
||||
import { RemoteFile } from '@/utils/file';
|
||||
import { partialMD5 } from '@/utils/md5';
|
||||
@@ -23,17 +25,19 @@ import {
|
||||
SYSTEM_SETTINGS_VERSION,
|
||||
DEFAULT_BOOK_SEARCH_CONFIG,
|
||||
} from './constants';
|
||||
import { isValidURL } from '@/utils/misc';
|
||||
|
||||
export abstract class BaseAppService implements AppService {
|
||||
localBooksDir: string = '';
|
||||
abstract fs: FileSystem;
|
||||
abstract appPlatform: AppPlatform;
|
||||
abstract isAppDataSandbox: boolean;
|
||||
abstract hasTrafficLight: boolean;
|
||||
abstract fs: FileSystem;
|
||||
|
||||
abstract resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string };
|
||||
abstract getCoverImageUrl(book: Book): string;
|
||||
abstract getCoverImageBlobUrl(book: Book): Promise<string>;
|
||||
abstract getInitBooksDir(): Promise<string>;
|
||||
abstract selectDirectory(title: string): Promise<string>;
|
||||
abstract selectFiles(name: string, extensions: string[]): Promise<string[]>;
|
||||
abstract showMessage(
|
||||
msg: string,
|
||||
@@ -92,8 +96,10 @@ export abstract class BaseAppService implements AppService {
|
||||
async importBook(
|
||||
file: string | File,
|
||||
books: Book[],
|
||||
saveBook: boolean = true,
|
||||
saveCover: boolean = true,
|
||||
overwrite: boolean = false,
|
||||
): Promise<Book[]> {
|
||||
): Promise<Book | null> {
|
||||
try {
|
||||
let loadedBook: BookDoc;
|
||||
let format: BookFormat;
|
||||
@@ -129,23 +135,21 @@ export abstract class BaseAppService implements AppService {
|
||||
const book: Book = {
|
||||
hash,
|
||||
format,
|
||||
title: loadedBook.metadata.title,
|
||||
author: loadedBook.metadata.author,
|
||||
title: formatTitle(loadedBook.metadata.title),
|
||||
author: formatAuthors(loadedBook.metadata.language, loadedBook.metadata.author),
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
book.coverImageUrl = this.getCoverImageUrl(book);
|
||||
|
||||
if (!(await this.fs.exists(getDir(book), 'Books'))) {
|
||||
await this.fs.createDir(getDir(book), 'Books');
|
||||
}
|
||||
if (!(await this.fs.exists(getFilename(book), 'Books')) || overwrite) {
|
||||
if (typeof file === 'string') {
|
||||
if (saveBook && (!(await this.fs.exists(getFilename(book), 'Books')) || overwrite)) {
|
||||
if (typeof file === 'string' && !isValidURL(file)) {
|
||||
await this.fs.copyFile(file, getFilename(book), 'Books');
|
||||
} else {
|
||||
await this.fs.writeFile(getFilename(book), 'Books', await file.arrayBuffer());
|
||||
await this.fs.writeFile(getFilename(book), 'Books', await fileobj.arrayBuffer());
|
||||
}
|
||||
}
|
||||
if (!(await this.fs.exists(getCoverFilename(book), 'Books')) || overwrite) {
|
||||
if (saveCover && (!(await this.fs.exists(getCoverFilename(book), 'Books')) || overwrite)) {
|
||||
const cover = await loadedBook.getCover();
|
||||
if (cover) {
|
||||
await this.fs.writeFile(getCoverFilename(book), 'Books', await cover.arrayBuffer());
|
||||
@@ -156,11 +160,21 @@ export abstract class BaseAppService implements AppService {
|
||||
await this.saveBookConfig(book, INIT_BOOK_CONFIG);
|
||||
books.splice(0, 0, book);
|
||||
}
|
||||
|
||||
if (typeof file === 'string' && isValidURL(file)) {
|
||||
book.url = file;
|
||||
}
|
||||
if (this.appPlatform === 'web') {
|
||||
book.coverImageUrl = await this.getCoverImageBlobUrl(book);
|
||||
} else {
|
||||
book.coverImageUrl = this.getCoverImageUrl(book);
|
||||
}
|
||||
|
||||
return book;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return books;
|
||||
return null;
|
||||
}
|
||||
|
||||
async deleteBook(book: Book): Promise<void> {
|
||||
@@ -172,8 +186,20 @@ export abstract class BaseAppService implements AppService {
|
||||
}
|
||||
|
||||
async loadBookContent(book: Book, settings: SystemSettings): Promise<BookContent> {
|
||||
let file: File;
|
||||
const fp = getFilename(book);
|
||||
const file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), fp).open();
|
||||
if (await this.fs.exists(fp, 'Books')) {
|
||||
if (this.appPlatform === 'web') {
|
||||
const content = await this.fs.readFile(fp, 'Books', 'binary');
|
||||
file = new File([content], fp);
|
||||
} else {
|
||||
file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), fp).open();
|
||||
}
|
||||
} else if (book.url) {
|
||||
file = await new RemoteFile(book.url).open();
|
||||
} else {
|
||||
throw new Error('Book file not found');
|
||||
}
|
||||
return { book, file, config: await this.loadBookConfig(book, settings) };
|
||||
}
|
||||
|
||||
@@ -210,6 +236,7 @@ export abstract class BaseAppService implements AppService {
|
||||
}
|
||||
|
||||
async loadLibraryBooks(): Promise<Book[]> {
|
||||
console.log('Loading library books...');
|
||||
let books: Book[] = [];
|
||||
const libraryFilename = getLibraryFilename();
|
||||
|
||||
@@ -221,9 +248,16 @@ export abstract class BaseAppService implements AppService {
|
||||
await this.fs.writeFile(libraryFilename, 'Books', '[]');
|
||||
}
|
||||
|
||||
books.forEach((book) => {
|
||||
book.coverImageUrl = this.getCoverImageUrl(book);
|
||||
});
|
||||
await Promise.all(
|
||||
books.map(async (book) => {
|
||||
if (this.appPlatform === 'web') {
|
||||
book.coverImageUrl = await this.getCoverImageBlobUrl(book);
|
||||
} else {
|
||||
book.coverImageUrl = this.getCoverImageUrl(book);
|
||||
}
|
||||
return book;
|
||||
}),
|
||||
);
|
||||
|
||||
return books;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { ReadSettings } from '@/types/settings';
|
||||
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
|
||||
export const CLOUD_BOOKS_SUBDIR = 'Readest/Books';
|
||||
|
||||
export const SUPPORTED_FILE_EXTS = ['epub', 'mobi', 'azw', 'azw3', 'fb2', 'cbz', 'pdf'];
|
||||
export const FILE_ACCEPT_FORMATS = SUPPORTED_FILE_EXTS.map((ext) => `.${ext}`).join(', ');
|
||||
|
||||
export const DEFAULT_READSETTINGS: ReadSettings = {
|
||||
sideBarWidth: '25%',
|
||||
isSideBarPinned: true,
|
||||
@@ -39,6 +42,7 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
|
||||
maxInlineSize: 720,
|
||||
maxBlockSize: 1440,
|
||||
animated: false,
|
||||
vertical: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_BOOK_STYLE: BookStyle = {
|
||||
@@ -75,3 +79,7 @@ export const SANS_SERIF_FONTS = ['Roboto', 'Noto Sans', 'Open Sans', 'Helvetica'
|
||||
export const MONOSPACE_FONTS = ['Fira Code', 'Lucida Console', 'Consolas', 'Courier New'];
|
||||
|
||||
export const ONE_COLUMN_MAX_INLINE_SIZE = 9999;
|
||||
|
||||
export const BOOK_IDS_SEPARATOR = '+';
|
||||
|
||||
export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web';
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
import { AppService } from '@/types/system';
|
||||
|
||||
let appService: AppService | null = null;
|
||||
export const isTauriAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'tauri';
|
||||
export const isWebAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web';
|
||||
|
||||
export interface EnvConfigType {
|
||||
getAppService: () => Promise<AppService>;
|
||||
}
|
||||
|
||||
let nativeAppService: AppService | null = null;
|
||||
const getNativeAppService = async () => {
|
||||
if (!nativeAppService) {
|
||||
const { NativeAppService } = await import('@/services/nativeAppService');
|
||||
nativeAppService = new NativeAppService();
|
||||
await nativeAppService.loadSettings();
|
||||
}
|
||||
return nativeAppService;
|
||||
};
|
||||
|
||||
let webAppService: AppService | null = null;
|
||||
const getWebAppService = async () => {
|
||||
if (!webAppService) {
|
||||
const { WebAppService } = await import('@/services/webAppService');
|
||||
webAppService = new WebAppService();
|
||||
await webAppService.loadSettings();
|
||||
}
|
||||
return webAppService;
|
||||
};
|
||||
|
||||
const environmentConfig: EnvConfigType = {
|
||||
getAppService: async () => {
|
||||
if (!appService) {
|
||||
const { NativeAppService } = await import('@/services/nativeAppService');
|
||||
appService = new NativeAppService();
|
||||
await appService.loadSettings();
|
||||
if (isTauriAppPlatform()) {
|
||||
return getNativeAppService();
|
||||
} else {
|
||||
return getWebAppService();
|
||||
}
|
||||
return appService;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -16,18 +16,16 @@ import { join, appDataDir } from '@tauri-apps/api/path';
|
||||
import { type as osType } from '@tauri-apps/plugin-os';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { ToastType, FileSystem, BaseDir } from '@/types/system';
|
||||
import { ToastType, FileSystem, BaseDir, AppPlatform } from '@/types/system';
|
||||
import { getCoverFilename } from '@/utils/book';
|
||||
import { isValidURL } from '@/utils/misc';
|
||||
|
||||
import { BaseAppService } from './appService';
|
||||
import { LOCAL_BOOKS_SUBDIR } from './constants';
|
||||
|
||||
export const isMobile = ['android', 'ios'].includes(osType());
|
||||
|
||||
export const resolvePath = (
|
||||
fp: string,
|
||||
base: BaseDir,
|
||||
): { baseDir: number; base: BaseDir; fp: string } => {
|
||||
const resolvePath = (fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } => {
|
||||
switch (base) {
|
||||
case 'Settings':
|
||||
return { baseDir: BaseDirectory.AppConfig, fp, base };
|
||||
@@ -54,7 +52,11 @@ export const resolvePath = (
|
||||
|
||||
export const nativeFileSystem: FileSystem = {
|
||||
getURL(path: string) {
|
||||
return convertFileSrc(path);
|
||||
return isValidURL(path) ? path : convertFileSrc(path);
|
||||
},
|
||||
async getBlobURL(path: string, base: BaseDir) {
|
||||
const content = await this.readFile(path, base, 'binary');
|
||||
return URL.createObjectURL(new Blob([content]));
|
||||
},
|
||||
async copyFile(srcPath: string, dstPath: string, base: BaseDir) {
|
||||
const { fp, baseDir } = resolvePath(dstPath, base);
|
||||
@@ -114,6 +116,7 @@ export const nativeFileSystem: FileSystem = {
|
||||
|
||||
export class NativeAppService extends BaseAppService {
|
||||
fs = nativeFileSystem;
|
||||
appPlatform = 'tauri' as AppPlatform;
|
||||
isAppDataSandbox = isMobile;
|
||||
hasTrafficLight = osType() === 'macos';
|
||||
|
||||
@@ -125,14 +128,6 @@ export class NativeAppService extends BaseAppService {
|
||||
return join(await appDataDir(), LOCAL_BOOKS_SUBDIR);
|
||||
}
|
||||
|
||||
async selectDirectory(title: string): Promise<string> {
|
||||
const selected = await open({
|
||||
title,
|
||||
directory: true,
|
||||
});
|
||||
return selected as string;
|
||||
}
|
||||
|
||||
async selectFiles(name: string, extensions: string[]): Promise<string[]> {
|
||||
const selected = await open({
|
||||
multiple: true,
|
||||
@@ -153,4 +148,8 @@ export class NativeAppService extends BaseAppService {
|
||||
getCoverImageUrl = (book: Book): string => {
|
||||
return this.fs.getURL(`${this.localBooksDir}/${getCoverFilename(book)}`);
|
||||
};
|
||||
|
||||
getCoverImageBlobUrl = async (book: Book): Promise<string> => {
|
||||
return this.fs.getBlobURL(`${this.localBooksDir}/${getCoverFilename(book)}`, 'None');
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { Book } from '@/types/book';
|
||||
import { ToastType, FileSystem, BaseDir, AppPlatform } from '@/types/system';
|
||||
import { getCoverFilename } from '@/utils/book';
|
||||
import { isValidURL } from '@/utils/misc';
|
||||
|
||||
import { BaseAppService } from './appService';
|
||||
import { LOCAL_BOOKS_SUBDIR } from './constants';
|
||||
|
||||
const resolvePath = (fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } => {
|
||||
switch (base) {
|
||||
case 'Books':
|
||||
return { baseDir: 0, fp: `${LOCAL_BOOKS_SUBDIR}/${fp}`, base };
|
||||
case 'None':
|
||||
return { baseDir: 0, fp, base };
|
||||
default:
|
||||
return { baseDir: 0, fp: `${base}/${fp}`, base };
|
||||
}
|
||||
};
|
||||
|
||||
const dbName = 'AppFileSystem';
|
||||
const dbVersion = 1;
|
||||
|
||||
async function openIndexedDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, dbVersion);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains('files')) {
|
||||
db.createObjectStore('files', { keyPath: 'path' });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
const indexedDBFileSystem: FileSystem = {
|
||||
getURL(path: string) {
|
||||
if (isValidURL(path)) {
|
||||
return path;
|
||||
} else {
|
||||
return URL.createObjectURL(new Blob([path]));
|
||||
}
|
||||
},
|
||||
async getBlobURL(path: string, base: BaseDir) {
|
||||
try {
|
||||
const content = await this.readFile(path, base, 'binary');
|
||||
return URL.createObjectURL(new Blob([content]));
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
},
|
||||
async copyFile(srcPath: string, dstPath: string, base: BaseDir) {
|
||||
const { fp } = resolvePath(dstPath, base);
|
||||
const db = await openIndexedDB();
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const transaction = db.transaction('files', 'readwrite');
|
||||
const store = transaction.objectStore('files');
|
||||
const getRequest = store.get(srcPath);
|
||||
|
||||
getRequest.onsuccess = () => {
|
||||
const data = getRequest.result;
|
||||
if (data) {
|
||||
store.put({ path: fp, content: data.content });
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`File not found: ${srcPath}`));
|
||||
}
|
||||
};
|
||||
|
||||
getRequest.onerror = () => reject(getRequest.error);
|
||||
});
|
||||
},
|
||||
async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') {
|
||||
const { fp } = resolvePath(path, base);
|
||||
const db = await openIndexedDB();
|
||||
|
||||
return new Promise<string | ArrayBuffer>((resolve, reject) => {
|
||||
const transaction = db.transaction('files', 'readonly');
|
||||
const store = transaction.objectStore('files');
|
||||
const request = store.get(fp);
|
||||
|
||||
request.onsuccess = async () => {
|
||||
if (request.result) {
|
||||
const content = request.result.content;
|
||||
if (mode === 'text') resolve(content);
|
||||
else {
|
||||
if (content instanceof Blob) {
|
||||
const arrayBuffer = await content.arrayBuffer();
|
||||
resolve(arrayBuffer);
|
||||
} else if (content instanceof ArrayBuffer) {
|
||||
resolve(content);
|
||||
} else if (typeof content === 'string') {
|
||||
resolve(new TextEncoder().encode(content).buffer as ArrayBuffer);
|
||||
} else {
|
||||
reject(new Error('Unsupported content type in IndexedDB'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`File not found: ${fp}`));
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
},
|
||||
async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer) {
|
||||
const { fp } = resolvePath(path, base);
|
||||
const db = await openIndexedDB();
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const transaction = db.transaction('files', 'readwrite');
|
||||
const store = transaction.objectStore('files');
|
||||
|
||||
store.put({ path: fp, content });
|
||||
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
},
|
||||
async removeFile(path: string, base: BaseDir) {
|
||||
const { fp } = resolvePath(path, base);
|
||||
const db = await openIndexedDB();
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const transaction = db.transaction('files', 'readwrite');
|
||||
const store = transaction.objectStore('files');
|
||||
|
||||
store.delete(fp);
|
||||
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
},
|
||||
async createDir() {
|
||||
// Directories are virtual in IndexedDB; no-op
|
||||
},
|
||||
async removeDir() {
|
||||
// Directories are virtual in IndexedDB; no-op
|
||||
},
|
||||
async readDir(path: string) {
|
||||
const db = await openIndexedDB();
|
||||
return new Promise<{ path: string; isDir: boolean }[]>((resolve, reject) => {
|
||||
const transaction = db.transaction('files', 'readonly');
|
||||
const store = transaction.objectStore('files');
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const files = request.result as { path: string }[];
|
||||
resolve(
|
||||
files
|
||||
.filter((file) => file.path.startsWith(path))
|
||||
.map((file) => ({ path: file.path, isDir: false })),
|
||||
);
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
},
|
||||
async exists(path: string, base: BaseDir) {
|
||||
const { fp } = resolvePath(path, base);
|
||||
const db = await openIndexedDB();
|
||||
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
const transaction = db.transaction('files', 'readonly');
|
||||
const store = transaction.objectStore('files');
|
||||
const request = store.get(fp);
|
||||
|
||||
request.onsuccess = () => resolve(!!request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export class WebAppService extends BaseAppService {
|
||||
fs = indexedDBFileSystem;
|
||||
appPlatform = 'web' as AppPlatform;
|
||||
isAppDataSandbox = false;
|
||||
hasTrafficLight = false;
|
||||
|
||||
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
|
||||
return resolvePath(fp, base);
|
||||
}
|
||||
|
||||
async getInitBooksDir(): Promise<string> {
|
||||
return LOCAL_BOOKS_SUBDIR;
|
||||
}
|
||||
|
||||
async selectDirectory(): Promise<string> {
|
||||
throw new Error('selectDirectory is not supported in browser');
|
||||
}
|
||||
|
||||
async selectFiles(): Promise<string[]> {
|
||||
throw new Error('selectFiles is not supported in browser');
|
||||
}
|
||||
|
||||
async showMessage(msg: string, kind: ToastType = 'info'): Promise<void> {
|
||||
alert(`${kind.toUpperCase()}: ${msg}`);
|
||||
}
|
||||
|
||||
getCoverImageUrl = (book: Book): string => {
|
||||
return this.fs.getURL(`${LOCAL_BOOKS_SUBDIR}/${getCoverFilename(book)}`);
|
||||
};
|
||||
|
||||
getCoverImageBlobUrl = async (book: Book): Promise<string> => {
|
||||
return this.fs.getBlobURL(`${LOCAL_BOOKS_SUBDIR}/${getCoverFilename(book)}`, 'None');
|
||||
};
|
||||
}
|
||||
@@ -4,12 +4,16 @@ import { EnvConfigType } from '@/services/environment';
|
||||
|
||||
interface LibraryState {
|
||||
library: Book[];
|
||||
checkOpenWithBooks: boolean;
|
||||
clearOpenWithBooks: () => void;
|
||||
setLibrary: (books: Book[]) => void;
|
||||
deleteBook: (envConfig: EnvConfigType, book: Book) => void;
|
||||
}
|
||||
|
||||
export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
library: [],
|
||||
checkOpenWithBooks: true,
|
||||
clearOpenWithBooks: () => set({ checkOpenWithBooks: false }),
|
||||
setLibrary: (books) => set({ library: books }),
|
||||
deleteBook: async (envConfig: EnvConfigType, book: Book) => {
|
||||
const appService = await envConfig.getAppService();
|
||||
|
||||
@@ -4,6 +4,8 @@ export type HighlightStyle = 'highlight' | 'underline' | 'squiggly';
|
||||
export type HighlightColor = 'red' | 'yellow' | 'green' | 'blue' | 'violet';
|
||||
|
||||
export interface Book {
|
||||
// if Book is a remote book we just lazy load the book content
|
||||
url?: string;
|
||||
hash: string;
|
||||
format: BookFormat;
|
||||
title: string;
|
||||
@@ -42,6 +44,7 @@ export interface BookLayout {
|
||||
maxInlineSize: number;
|
||||
maxBlockSize: number;
|
||||
animated: boolean;
|
||||
vertical: boolean;
|
||||
}
|
||||
|
||||
export interface BookStyle {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { SystemSettings } from './settings';
|
||||
import { Book, BookConfig, BookContent } from './book';
|
||||
|
||||
export type AppPlatform = 'web' | 'tauri';
|
||||
export type BaseDir = 'Books' | 'Settings' | 'Data' | 'Log' | 'Cache' | 'None';
|
||||
export type ToastType = 'info' | 'warning' | 'error';
|
||||
|
||||
export interface FileSystem {
|
||||
getURL(path: string): string;
|
||||
getBlobURL(path: string, base: BaseDir): Promise<string>;
|
||||
copyFile(srcPath: string, dstPath: string, base: BaseDir): Promise<void>;
|
||||
readFile(path: string, base: BaseDir, mode: 'text' | 'binary'): Promise<string | ArrayBuffer>;
|
||||
writeFile(path: string, base: BaseDir, content: string | ArrayBuffer): Promise<void>;
|
||||
@@ -18,16 +20,22 @@ export interface FileSystem {
|
||||
|
||||
export interface AppService {
|
||||
fs: FileSystem;
|
||||
appPlatform: AppPlatform;
|
||||
hasTrafficLight: boolean;
|
||||
isAppDataSandbox: boolean;
|
||||
|
||||
selectDirectory(title: string): Promise<string>;
|
||||
selectFiles(name: string, extensions: string[]): Promise<string[]>;
|
||||
showMessage(msg: string, kind?: ToastType, title?: string, okLabel?: string): Promise<void>;
|
||||
|
||||
loadSettings(): Promise<SystemSettings>;
|
||||
saveSettings(settings: SystemSettings): Promise<void>;
|
||||
importBook(file: string | File, books: Book[], overwrite?: boolean): Promise<Book[]>;
|
||||
importBook(
|
||||
file: string | File,
|
||||
books: Book[],
|
||||
saveBook?: boolean,
|
||||
saveCover?: boolean,
|
||||
overwrite?: boolean,
|
||||
): Promise<Book | null>;
|
||||
deleteBook(book: Book): Promise<void>;
|
||||
loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig>;
|
||||
saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings): Promise<void>;
|
||||
@@ -35,4 +43,5 @@ export interface AppService {
|
||||
loadLibraryBooks(): Promise<Book[]>;
|
||||
saveLibraryBooks(books: Book[]): Promise<void>;
|
||||
getCoverImageUrl(book: Book): string;
|
||||
getCoverImageBlobUrl(book: Book): Promise<string>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EXTS } from '@/libs/document';
|
||||
import { Book, BookConfig } from '@/types/book';
|
||||
import { makeSafeFilename } from './misc';
|
||||
import { getUserLang, makeSafeFilename } from './misc';
|
||||
|
||||
export const getDir = (book: Book) => {
|
||||
return `${book.hash}`;
|
||||
@@ -37,18 +37,38 @@ interface Contributor {
|
||||
name: LanguageMap;
|
||||
}
|
||||
|
||||
const userLang = getUserLang();
|
||||
|
||||
const formatLanguageMap = (x: string | LanguageMap): string => {
|
||||
if (!x) return '';
|
||||
if (typeof x === 'string') return x;
|
||||
const keys = Object.keys(x);
|
||||
return x[keys[0]!]!;
|
||||
return x[userLang] || x[keys[0]!]!;
|
||||
};
|
||||
|
||||
const listFormat = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' });
|
||||
const listFormat = (lang: string) => {
|
||||
if (lang === 'zh') {
|
||||
return new Intl.ListFormat('en', { style: 'narrow', type: 'unit' });
|
||||
} else {
|
||||
return new Intl.ListFormat(lang, { style: 'long', type: 'conjunction' });
|
||||
}
|
||||
};
|
||||
|
||||
export const formatAuthors = (contributors: string | Contributor | [string | Contributor]) =>
|
||||
const getBookLangCode = (lang: string | string[] | undefined) => {
|
||||
try {
|
||||
const bookLang = typeof lang === 'string' ? lang : lang?.[0];
|
||||
return bookLang ? bookLang.split('-')[0]! : 'en';
|
||||
} catch {
|
||||
return 'en';
|
||||
}
|
||||
};
|
||||
|
||||
export const formatAuthors = (
|
||||
bookLang: string | string[] | undefined,
|
||||
contributors: string | Contributor | [string | Contributor],
|
||||
) =>
|
||||
Array.isArray(contributors)
|
||||
? listFormat.format(
|
||||
? listFormat(getBookLangCode(bookLang)).format(
|
||||
contributors.map((contributor) =>
|
||||
typeof contributor === 'string' ? contributor : formatLanguageMap(contributor?.name),
|
||||
),
|
||||
@@ -56,3 +76,7 @@ export const formatAuthors = (contributors: string | Contributor | [string | Con
|
||||
: typeof contributors === 'string'
|
||||
? contributors
|
||||
: formatLanguageMap(contributors?.name);
|
||||
|
||||
export const formatTitle = (title: string | LanguageMap) => {
|
||||
return typeof title === 'string' ? title : formatLanguageMap(title);
|
||||
};
|
||||
|
||||
@@ -22,3 +22,26 @@ export const makeSafeFilename = (filename: string, replacement = '_') => {
|
||||
|
||||
return safeName.trim();
|
||||
};
|
||||
|
||||
export const getUserLang = () => navigator?.language.split('-')[0] || 'en';
|
||||
|
||||
export const getOSPlatform = () => {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
if (userAgent.includes('macintosh') || userAgent.includes('mac os x')) return 'macos';
|
||||
if (userAgent.includes('windows nt')) return 'windows';
|
||||
if (userAgent.includes('linux')) return 'linux';
|
||||
if (/iphone|ipad|ipod/.test(userAgent)) return 'ios';
|
||||
if (userAgent.includes('android')) return 'android';
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
export const isValidURL = (url: string, allowedSchemes: string[] = ['http', 'https']) => {
|
||||
try {
|
||||
const { protocol } = new URL(url);
|
||||
return allowedSchemes.some((scheme) => `${scheme}:` === protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
|
||||
|
||||
export const navigateToReader = (
|
||||
router: ReturnType<typeof useRouter>,
|
||||
bookIds: string[],
|
||||
queryParams?: string,
|
||||
navOptions?: { scroll?: boolean },
|
||||
) => {
|
||||
const ids = bookIds.join(BOOK_IDS_SEPARATOR);
|
||||
if (isWebAppPlatform()) {
|
||||
router.push(`/reader/${ids}${queryParams ? `?${queryParams}` : ''}`, navOptions);
|
||||
} else {
|
||||
const params = new URLSearchParams(queryParams || '');
|
||||
params.set('ids', ids);
|
||||
router.push(`/reader?${params.toString()}`, navOptions);
|
||||
}
|
||||
};
|
||||
|
||||
export const navigateToLibrary = (router: ReturnType<typeof useRouter>) => {
|
||||
router.push('/library');
|
||||
};
|
||||
@@ -15,7 +15,7 @@ export interface Point {
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type PositionDir = 'up' | 'down';
|
||||
export type PositionDir = 'up' | 'down' | 'left' | 'right';
|
||||
|
||||
export interface Position {
|
||||
point: Point;
|
||||
@@ -42,9 +42,15 @@ const frameRect = (frame: Frame, rect: Rect, sx = 1, sy = 1) => {
|
||||
const pointIsInView = ({ x, y }: Point) =>
|
||||
x > 0 && y > 0 && x < window.innerWidth && y < window.innerHeight;
|
||||
|
||||
const getIframeElement = (range: Range): HTMLIFrameElement | null => {
|
||||
let node: Node | null = range.commonAncestorContainer;
|
||||
|
||||
const getIframeElement = (nodeElement: Range | Element): HTMLIFrameElement | null => {
|
||||
let node: Node | null;
|
||||
if (nodeElement && typeof nodeElement === 'object' && 'tagName' in nodeElement) {
|
||||
node = nodeElement as Element;
|
||||
} else if (nodeElement && typeof nodeElement === 'object' && 'collapse' in nodeElement) {
|
||||
node = nodeElement.commonAncestorContainer;
|
||||
} else {
|
||||
node = nodeElement;
|
||||
}
|
||||
while (node) {
|
||||
if (node.nodeType === Node.DOCUMENT_NODE) {
|
||||
const doc = node as Document;
|
||||
@@ -58,8 +64,7 @@ const getIframeElement = (range: Range): HTMLIFrameElement | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getPosition = (target: Range, rect: Rect) => {
|
||||
// TODO: vertical text
|
||||
export const getPosition = (target: Range | Element, rect: Rect, isVertical: boolean = false) => {
|
||||
const frameElement = getIframeElement(target);
|
||||
const transform = frameElement ? getComputedStyle(frameElement).transform : '';
|
||||
const match = transform.match(/matrix\((.+)\)/);
|
||||
@@ -69,6 +74,22 @@ export const getPosition = (target: Range, rect: Rect) => {
|
||||
const rects = Array.from(target.getClientRects());
|
||||
const first = frameRect(frame, rects[0] as Rect, sx, sy);
|
||||
const last = frameRect(frame, rects.at(-1) as Rect, sx, sy);
|
||||
|
||||
if (isVertical) {
|
||||
const leftSpace = first.left - rect.left;
|
||||
const rightSpace = rect.right - first.right;
|
||||
const dir = leftSpace > rightSpace ? 'left' : 'right';
|
||||
const position = {
|
||||
point: {
|
||||
x: dir === 'left' ? first.left - rect.left - 6 : first.right - rect.left + 6,
|
||||
y: (first.top + first.bottom) / 2 - rect.top,
|
||||
},
|
||||
dir,
|
||||
} as Position;
|
||||
const inView = pointIsInView(position.point);
|
||||
return inView ? position : ({ point: { x: 0, y: 0 }, dir } as Position);
|
||||
}
|
||||
|
||||
const start = {
|
||||
point: { x: (first.left + first.right) / 2 - rect.left, y: first.top - rect.top - 12 },
|
||||
dir: 'up',
|
||||
@@ -92,10 +113,20 @@ export const getPopupPosition = (
|
||||
popupHeightPx: number,
|
||||
popupPaddingPx: number,
|
||||
) => {
|
||||
const popupPoint = {
|
||||
x: position.point.x - popupWidthPx / 2,
|
||||
y: position.dir === 'up' ? position.point.y - popupHeightPx : position.point.y + 6,
|
||||
};
|
||||
const popupPoint = { x: 0, y: 0 };
|
||||
if (position.dir === 'up') {
|
||||
popupPoint.x = position.point.x - popupWidthPx / 2;
|
||||
popupPoint.y = position.point.y - popupHeightPx;
|
||||
} else if (position.dir === 'down') {
|
||||
popupPoint.x = position.point.x - popupWidthPx / 2;
|
||||
popupPoint.y = position.point.y + 6;
|
||||
} else if (position.dir === 'left') {
|
||||
popupPoint.x = position.point.x - popupWidthPx;
|
||||
popupPoint.y = position.point.y - popupHeightPx / 2;
|
||||
} else if (position.dir === 'right') {
|
||||
popupPoint.x = position.point.x + 6;
|
||||
popupPoint.y = position.point.y - popupHeightPx / 2;
|
||||
}
|
||||
|
||||
if (popupPoint.x < popupPaddingPx) {
|
||||
popupPoint.x = popupPaddingPx;
|
||||
|
||||
@@ -29,6 +29,36 @@ const getFontStyles = (
|
||||
return fontStyles;
|
||||
};
|
||||
|
||||
const getAdditionalFontFaces = () => `
|
||||
@font-face {
|
||||
font-family: "FangSong";
|
||||
src: url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot");
|
||||
src: url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot?#iefix") format("embedded-opentype"),
|
||||
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff2") format("woff2"),
|
||||
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff") format("woff"),
|
||||
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.ttf") format("truetype"),
|
||||
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.svg#FangSong") format("svg");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Kaiti";
|
||||
src: url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot");
|
||||
src: url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot?#iefix")format("embedded-opentype"),
|
||||
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff2")format("woff2"),
|
||||
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff")format("woff"),
|
||||
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.ttf")format("truetype"),
|
||||
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.svg#STKaiti")format("svg");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Heiti";
|
||||
src: url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot");
|
||||
src: url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot?#iefix")format("embedded-opentype"),
|
||||
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff2")format("woff2"),
|
||||
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff")format("woff"),
|
||||
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.ttf")format("truetype"),
|
||||
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.svg#WenQuanYi Micro Hei")format("svg");
|
||||
}
|
||||
`;
|
||||
|
||||
const getLayoutStyles = (
|
||||
spacing: number,
|
||||
justify: boolean,
|
||||
@@ -74,13 +104,16 @@ const getLayoutStyles = (
|
||||
white-space: pre-wrap !important;
|
||||
tab-size: 2;
|
||||
}
|
||||
body {
|
||||
html, body {
|
||||
color: ${fg};
|
||||
zoom: ${zoomLevel}%;
|
||||
background-color: ${bg} !important;
|
||||
}
|
||||
body *:not(#b1):not(#b1 *):not(#b2):not(#b2 *):not(.bg):not(.bg *):not(.vol):not(.vol *):not(.background):not(.background *) {
|
||||
border-color: currentColor !important;
|
||||
${bg === '#ffffff' ? '' : `background-color: ${bg} !important;`}
|
||||
}
|
||||
svg, img {
|
||||
background-color: transparent !important;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
p, li, blockquote, dd {
|
||||
text-align: ${justify ? 'justify' : 'start'};
|
||||
@@ -107,6 +140,11 @@ const getLayoutStyles = (
|
||||
aside[epub|type~="rearnote"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.duokan-footnote-content,
|
||||
.duokan-footnote-item {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface ThemeCode {
|
||||
@@ -137,3 +175,9 @@ export const getStyles = (viewSettings: ViewSettings, themeCode: ThemeCode) => {
|
||||
const userStylesheet = viewSettings.userStylesheet!;
|
||||
return `${layoutStyles}\n${fontStyles}\n${fontfacesCSS}\n${userStylesheet}`;
|
||||
};
|
||||
|
||||
export const mountAdditionalFonts = (document: Document) => {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = getAdditionalFontFaces();
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export const tauriHandleMinimize = async () => {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
getCurrentWindow().minimize();
|
||||
};
|
||||
|
||||
export const tauriHandleToggleMaximize = async () => {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
getCurrentWindow().toggleMaximize();
|
||||
};
|
||||
|
||||
export const tauriHandleClose = async () => {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
getCurrentWindow().close();
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "@sindresorhus/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 948 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@readest/monorepo",
|
||||
"private": true,
|
||||
"repository": "chrox/readest",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"tauri": "pnpm --filter @readest/readest-app tauri"
|
||||
|
||||
@@ -10,7 +10,7 @@ importers:
|
||||
devDependencies:
|
||||
'@sindresorhus/tsconfig':
|
||||
specifier: ^6.0.0
|
||||
version: 7.0.0
|
||||
version: 6.0.0
|
||||
cross-env:
|
||||
specifier: ^7.0.3
|
||||
version: 7.0.3
|
||||
@@ -38,19 +38,25 @@ importers:
|
||||
'@tauri-apps/api':
|
||||
specifier: 2.1.1
|
||||
version: 2.1.1
|
||||
'@tauri-apps/plugin-cli':
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
'@tauri-apps/plugin-dialog':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
'@tauri-apps/plugin-fs':
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2
|
||||
specifier: ^2.0.3
|
||||
version: 2.0.3
|
||||
'@tauri-apps/plugin-http':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
'@tauri-apps/plugin-log':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
'@tauri-apps/plugin-os':
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
'@tauri-apps/plugin-os':
|
||||
'@tauri-apps/plugin-process':
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
'@tauri-apps/plugin-shell':
|
||||
@@ -144,6 +150,12 @@ importers:
|
||||
postcss:
|
||||
specifier: ^8.4.49
|
||||
version: 8.4.49
|
||||
postcss-cli:
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.0(jiti@1.21.6)(postcss@8.4.49)
|
||||
postcss-nested:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2(postcss@8.4.49)
|
||||
raw-loader:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2(webpack@5.96.1)
|
||||
@@ -610,8 +622,12 @@ packages:
|
||||
'@rushstack/eslint-patch@1.10.4':
|
||||
resolution: {integrity: sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==}
|
||||
|
||||
'@sindresorhus/tsconfig@7.0.0':
|
||||
resolution: {integrity: sha512-i5K04hLAP44Af16zmDjG07E1NHuDgCM07SJAT4gY0LZSRrWYzwt4qkLem6TIbIVh0k51RkN2bF+lP+lM5eC9fw==}
|
||||
'@sindresorhus/merge-streams@2.3.0':
|
||||
resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sindresorhus/tsconfig@6.0.0':
|
||||
resolution: {integrity: sha512-+fUdfuDd/7O2OZ9/UvJy76IEWn2Tpvm2l+rwUoS2Yz4jCUTSNOQQv2PLWrwekt8cPLwHmpHaBpay34bkBmVl2Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@swc/counter@0.1.3':
|
||||
@@ -692,21 +708,27 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/plugin-cli@2.0.0':
|
||||
resolution: {integrity: sha512-glQmlL1IiCGEa1FHYa/PTPSeYhfu56omLRgHXWlJECDt6DbJyRuJWVgtkQfUxtqnVdYnnU+DGIGeiInoEqtjLw==}
|
||||
|
||||
'@tauri-apps/plugin-dialog@2.0.1':
|
||||
resolution: {integrity: sha512-fnUrNr6EfvTqdls/ufusU7h6UbNFzLKvHk/zTuOiBq01R3dTODqwctZlzakdbfSp/7pNwTKvgKTAgl/NAP/Z0Q==}
|
||||
|
||||
'@tauri-apps/plugin-fs@2.0.2':
|
||||
resolution: {integrity: sha512-4YZaX2j7ta81M5/DL8aN10kTnpUkEpkPo1FTYPT8Dd0ImHe3azM8i8MrtjrDGoyBYLPO3zFv7df/mSCYF8oA0Q==}
|
||||
'@tauri-apps/plugin-fs@2.0.3':
|
||||
resolution: {integrity: sha512-3Xv9cg5krj/2gSo1HFn9GbKUE0keh+DMx7O+hQeSzg1T4yGY5Fv/+yBISmrPa2j9thi3qcjgPj8PnMH4rbl/Dw==}
|
||||
|
||||
'@tauri-apps/plugin-http@2.0.1':
|
||||
resolution: {integrity: sha512-j6IA3pVBybSCwPpsihpX4z8bs6PluuGtr06ahL/xy4D8HunNBTmRmadJrFOQi0gOAbaig4MkQ15nzNLBLy8R1A==}
|
||||
|
||||
'@tauri-apps/plugin-log@2.0.0':
|
||||
resolution: {integrity: sha512-C+NII9vzswqnOQE8k7oRtnaw0z5TZsMmnirRhXkCKDEhQQH9841Us/PC1WHtGiAaJ8za1A1JB2xXndEq/47X/w==}
|
||||
'@tauri-apps/plugin-log@2.0.1':
|
||||
resolution: {integrity: sha512-zFT3S/Q5liMKIjPPdUZL8aNbjvhHvyfkhuqct3/MQ6/7rk4ODstcUPu320KJBjZrNNH/UM+kaQBGrioIwtmP/Q==}
|
||||
|
||||
'@tauri-apps/plugin-os@2.0.0':
|
||||
resolution: {integrity: sha512-M7hG/nNyQYTJxVG/UhTKhp9mpXriwWzrs9mqDreB8mIgqA3ek5nHLdwRZJWhkKjZrnDT4v9CpA9BhYeplTlAiA==}
|
||||
|
||||
'@tauri-apps/plugin-process@2.0.0':
|
||||
resolution: {integrity: sha512-OYzi0GnkrF4NAnsHZU7U3tjSoP0PbeAlO7T1Z+vJoBUH9sFQ1NSLqWYWQyf8hcb3gVWe7P1JggjiskO+LST1ug==}
|
||||
|
||||
'@tauri-apps/plugin-shell@2.0.1':
|
||||
resolution: {integrity: sha512-akU1b77sw3qHiynrK0s930y8zKmcdrSD60htjH+mFZqv5WaakZA/XxHR3/sF1nNv9Mgmt/Shls37HwnOr00aSw==}
|
||||
|
||||
@@ -747,9 +769,6 @@ packages:
|
||||
resolution: {integrity: sha512-tJxahnjm9dEI1X+hQSC5f2BSd/coZaqbIl1m3TCl0q9SVuC52XcXfV0XmoCU1+PmjyucuVITwoTnN8OlTbEXXA==}
|
||||
deprecated: This is a stub types definition for localforage (https://github.com/localForage/localForage). localforage provides its own type definitions, so you don't need @types/localforage installed!
|
||||
|
||||
'@types/node@20.17.9':
|
||||
resolution: {integrity: sha512-0JOXkRyLanfGPE2QRCwgxhzlBAvaRdCNMcvbd7jFfpmD4eEXll7LRwy5ymJmyeZqk7Nh7eD2LeUyQ68BbndmXw==}
|
||||
|
||||
'@types/node@22.10.1':
|
||||
resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==}
|
||||
|
||||
@@ -1391,6 +1410,10 @@ packages:
|
||||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1583,6 +1606,10 @@ packages:
|
||||
resolution: {integrity: sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
dependency-graph@0.11.0:
|
||||
resolution: {integrity: sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
detect-indent@4.0.0:
|
||||
resolution: {integrity: sha512-BDKtmHlOzwI7iRuEkhzsnPoi5ypEhWAJB5RvHWe1kMr06js3uK5B3734i3ui5Yd+wOJV1cpE4JnivPD283GU/A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2005,10 +2032,18 @@ packages:
|
||||
functions-have-names@1.2.3:
|
||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||
|
||||
get-caller-file@2.0.5:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-intrinsic@1.2.4:
|
||||
resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
get-stdin@9.0.0:
|
||||
resolution: {integrity: sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
get-symbol-description@1.0.2:
|
||||
resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2069,6 +2104,10 @@ packages:
|
||||
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
globby@14.0.2:
|
||||
resolution: {integrity: sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
gopd@1.1.0:
|
||||
resolution: {integrity: sha512-FQoVQnqcdk4hVM4JN1eromaun4iuS34oStkdlLENLdpULsuQcTyXj8w7ayhuUfPwEYZ1ZOooOTT6fdA9Vmx/RA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2795,6 +2834,10 @@ packages:
|
||||
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
|
||||
engines: {node: '>=16 || 14 >=14.18'}
|
||||
|
||||
path-type@5.0.0:
|
||||
resolution: {integrity: sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
path-webpack@0.0.3:
|
||||
resolution: {integrity: sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==}
|
||||
|
||||
@@ -2829,6 +2872,13 @@ packages:
|
||||
resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
postcss-cli@11.0.0:
|
||||
resolution: {integrity: sha512-xMITAI7M0u1yolVcXJ9XTZiO9aO49mcoKQy6pCDFdMh9kGqhzLVpWxeD/32M/QBmkhcGypZFFOLNLmIW4Pg4RA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
postcss: ^8.0.0
|
||||
|
||||
postcss-import@15.1.0:
|
||||
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -2853,16 +2903,47 @@ packages:
|
||||
ts-node:
|
||||
optional: true
|
||||
|
||||
postcss-load-config@5.1.0:
|
||||
resolution: {integrity: sha512-G5AJ+IX0aD0dygOE0yFZQ/huFFMSNneyfp0e3/bT05a8OfPC5FUoZRPfGijUdGOJNMewJiwzcHJXFafFzeKFVA==}
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
jiti: '>=1.21.0'
|
||||
postcss: '>=8.0.9'
|
||||
tsx: ^4.8.1
|
||||
peerDependenciesMeta:
|
||||
jiti:
|
||||
optional: true
|
||||
postcss:
|
||||
optional: true
|
||||
tsx:
|
||||
optional: true
|
||||
|
||||
postcss-nested@6.2.0:
|
||||
resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
|
||||
engines: {node: '>=12.0'}
|
||||
peerDependencies:
|
||||
postcss: ^8.2.14
|
||||
|
||||
postcss-nested@7.0.2:
|
||||
resolution: {integrity: sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==}
|
||||
engines: {node: '>=18.0'}
|
||||
peerDependencies:
|
||||
postcss: ^8.2.14
|
||||
|
||||
postcss-reporter@7.1.0:
|
||||
resolution: {integrity: sha512-/eoEylGWyy6/DOiMP5lmFRdmDKThqgn7D6hP2dXKJI/0rJSO1ADFNngZfDzxL0YAxFvws+Rtpuji1YIHj4mySA==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
postcss: ^8.1.0
|
||||
|
||||
postcss-selector-parser@6.1.2:
|
||||
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss-selector-parser@7.0.0:
|
||||
resolution: {integrity: sha512-9RbEr1Y7FFfptd/1eEdntyjMwLeghW1bHX9GWjXo19vx4ytPQhANltvVxDggzJl7mnWM+dX28kb6cyS/4iQjlQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss-value-parser@4.2.0:
|
||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||
|
||||
@@ -2953,6 +3034,10 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
pretty-hrtime@1.0.3:
|
||||
resolution: {integrity: sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
private@0.1.8:
|
||||
resolution: {integrity: sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -3078,6 +3163,10 @@ packages:
|
||||
resolution: {integrity: sha512-ZqtSMuVybkISo2OWvqvm7iHSWngvdaW3IpsT9/uP8v4gMi591LY6h35wdOfvQdWCKFWZWm2Y1Opp4kV7vQKT6A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -3212,6 +3301,10 @@ packages:
|
||||
resolution: {integrity: sha512-3TYDR7xWt4dIqV2JauJr+EJeW356RXijHeUlO+8djJ+uBXPn8/2dpzBc8yQhh583sVvc9CvFAeQVgijsH+PNNg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
slash@5.1.0:
|
||||
resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
smob@1.5.0:
|
||||
resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==}
|
||||
|
||||
@@ -3399,6 +3492,9 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
thenby@1.3.4:
|
||||
resolution: {integrity: sha512-89Gi5raiWA3QZ4b2ePcEwswC3me9JIg+ToSgtE0JWeCynLnLxNr/f9G+xfo9K+Oj4AFdom8YNJjibIARTJmapQ==}
|
||||
|
||||
thenify-all@1.6.0:
|
||||
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
||||
engines: {node: '>=0.8'}
|
||||
@@ -3496,12 +3592,13 @@ packages:
|
||||
unbox-primitive@1.0.2:
|
||||
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
|
||||
|
||||
undici-types@6.19.8:
|
||||
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
|
||||
|
||||
undici-types@6.20.0:
|
||||
resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
|
||||
|
||||
unicorn-magic@0.1.0:
|
||||
resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
union-value@1.0.1:
|
||||
resolution: {integrity: sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3597,6 +3694,10 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
yallist@2.1.2:
|
||||
resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==}
|
||||
|
||||
@@ -3605,6 +3706,14 @@ packages:
|
||||
engines: {node: '>= 14'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yargs@17.7.2:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yn@3.1.1:
|
||||
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3940,7 +4049,9 @@ snapshots:
|
||||
|
||||
'@rushstack/eslint-patch@1.10.4': {}
|
||||
|
||||
'@sindresorhus/tsconfig@7.0.0': {}
|
||||
'@sindresorhus/merge-streams@2.3.0': {}
|
||||
|
||||
'@sindresorhus/tsconfig@6.0.0': {}
|
||||
|
||||
'@swc/counter@0.1.3': {}
|
||||
|
||||
@@ -3993,11 +4104,15 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.1.0
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.1.0
|
||||
|
||||
'@tauri-apps/plugin-cli@2.0.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.1.1
|
||||
|
||||
'@tauri-apps/plugin-dialog@2.0.1':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.1.1
|
||||
|
||||
'@tauri-apps/plugin-fs@2.0.2':
|
||||
'@tauri-apps/plugin-fs@2.0.3':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.1.1
|
||||
|
||||
@@ -4005,7 +4120,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.1.1
|
||||
|
||||
'@tauri-apps/plugin-log@2.0.0':
|
||||
'@tauri-apps/plugin-log@2.0.1':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.1.1
|
||||
|
||||
@@ -4013,6 +4128,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.1.1
|
||||
|
||||
'@tauri-apps/plugin-process@2.0.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.1.1
|
||||
|
||||
'@tauri-apps/plugin-shell@2.0.1':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.1.1
|
||||
@@ -4055,10 +4174,6 @@ snapshots:
|
||||
dependencies:
|
||||
localforage: 1.10.0
|
||||
|
||||
'@types/node@20.17.9':
|
||||
dependencies:
|
||||
undici-types: 6.19.8
|
||||
|
||||
'@types/node@22.10.1':
|
||||
dependencies:
|
||||
undici-types: 6.20.0
|
||||
@@ -5100,6 +5215,12 @@ snapshots:
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
cliui@8.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 7.0.0
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
collection-visit@1.0.0:
|
||||
@@ -5299,6 +5420,8 @@ snapshots:
|
||||
is-descriptor: 1.0.3
|
||||
isobject: 3.0.1
|
||||
|
||||
dependency-graph@0.11.0: {}
|
||||
|
||||
detect-indent@4.0.0:
|
||||
dependencies:
|
||||
repeating: 2.0.1
|
||||
@@ -5889,6 +6012,8 @@ snapshots:
|
||||
|
||||
functions-have-names@1.2.3: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-intrinsic@1.2.4:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -5897,6 +6022,8 @@ snapshots:
|
||||
has-symbols: 1.0.3
|
||||
hasown: 2.0.2
|
||||
|
||||
get-stdin@9.0.0: {}
|
||||
|
||||
get-symbol-description@1.0.2:
|
||||
dependencies:
|
||||
call-bind: 1.0.7
|
||||
@@ -5964,6 +6091,15 @@ snapshots:
|
||||
define-properties: 1.2.1
|
||||
gopd: 1.1.0
|
||||
|
||||
globby@14.0.2:
|
||||
dependencies:
|
||||
'@sindresorhus/merge-streams': 2.3.0
|
||||
fast-glob: 3.3.2
|
||||
ignore: 5.3.2
|
||||
path-type: 5.0.0
|
||||
slash: 5.1.0
|
||||
unicorn-magic: 0.1.0
|
||||
|
||||
gopd@1.1.0:
|
||||
dependencies:
|
||||
get-intrinsic: 1.2.4
|
||||
@@ -6255,7 +6391,7 @@ snapshots:
|
||||
|
||||
jest-worker@27.5.1:
|
||||
dependencies:
|
||||
'@types/node': 20.17.9
|
||||
'@types/node': 22.10.1
|
||||
merge-stream: 2.0.0
|
||||
supports-color: 8.1.1
|
||||
|
||||
@@ -6672,6 +6808,8 @@ snapshots:
|
||||
lru-cache: 10.4.3
|
||||
minipass: 7.1.2
|
||||
|
||||
path-type@5.0.0: {}
|
||||
|
||||
path-webpack@0.0.3: {}
|
||||
|
||||
path2d@0.2.1:
|
||||
@@ -6694,6 +6832,25 @@ snapshots:
|
||||
|
||||
possible-typed-array-names@1.0.0: {}
|
||||
|
||||
postcss-cli@11.0.0(jiti@1.21.6)(postcss@8.4.49):
|
||||
dependencies:
|
||||
chokidar: 3.6.0
|
||||
dependency-graph: 0.11.0
|
||||
fs-extra: 11.2.0
|
||||
get-stdin: 9.0.0
|
||||
globby: 14.0.2
|
||||
picocolors: 1.1.1
|
||||
postcss: 8.4.49
|
||||
postcss-load-config: 5.1.0(jiti@1.21.6)(postcss@8.4.49)
|
||||
postcss-reporter: 7.1.0(postcss@8.4.49)
|
||||
pretty-hrtime: 1.0.3
|
||||
read-cache: 1.0.0
|
||||
slash: 5.1.0
|
||||
yargs: 17.7.2
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- tsx
|
||||
|
||||
postcss-import@15.1.0(postcss@8.4.49):
|
||||
dependencies:
|
||||
postcss: 8.4.49
|
||||
@@ -6714,16 +6871,40 @@ snapshots:
|
||||
postcss: 8.4.49
|
||||
ts-node: 10.9.2(@types/node@22.10.1)(typescript@5.7.2)
|
||||
|
||||
postcss-load-config@5.1.0(jiti@1.21.6)(postcss@8.4.49):
|
||||
dependencies:
|
||||
lilconfig: 3.1.2
|
||||
yaml: 2.6.1
|
||||
optionalDependencies:
|
||||
jiti: 1.21.6
|
||||
postcss: 8.4.49
|
||||
|
||||
postcss-nested@6.2.0(postcss@8.4.49):
|
||||
dependencies:
|
||||
postcss: 8.4.49
|
||||
postcss-selector-parser: 6.1.2
|
||||
|
||||
postcss-nested@7.0.2(postcss@8.4.49):
|
||||
dependencies:
|
||||
postcss: 8.4.49
|
||||
postcss-selector-parser: 7.0.0
|
||||
|
||||
postcss-reporter@7.1.0(postcss@8.4.49):
|
||||
dependencies:
|
||||
picocolors: 1.1.1
|
||||
postcss: 8.4.49
|
||||
thenby: 1.3.4
|
||||
|
||||
postcss-selector-parser@6.1.2:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
postcss-selector-parser@7.0.0:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
postcss-value-parser@4.2.0: {}
|
||||
|
||||
postcss@8.4.31:
|
||||
@@ -6773,6 +6954,8 @@ snapshots:
|
||||
|
||||
prettier@3.4.1: {}
|
||||
|
||||
pretty-hrtime@1.0.3: {}
|
||||
|
||||
private@0.1.8: {}
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
@@ -6926,6 +7109,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-finite: 1.1.0
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
@@ -7114,6 +7299,8 @@ snapshots:
|
||||
|
||||
slash@1.0.0: {}
|
||||
|
||||
slash@5.1.0: {}
|
||||
|
||||
smob@1.5.0: {}
|
||||
|
||||
snapdragon-node@2.1.1:
|
||||
@@ -7353,6 +7540,8 @@ snapshots:
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
|
||||
thenby@1.3.4: {}
|
||||
|
||||
thenify-all@1.6.0:
|
||||
dependencies:
|
||||
thenify: 3.3.1
|
||||
@@ -7474,10 +7663,10 @@ snapshots:
|
||||
has-symbols: 1.0.3
|
||||
which-boxed-primitive: 1.0.2
|
||||
|
||||
undici-types@6.19.8: {}
|
||||
|
||||
undici-types@6.20.0: {}
|
||||
|
||||
unicorn-magic@0.1.0: {}
|
||||
|
||||
union-value@1.0.1:
|
||||
dependencies:
|
||||
arr-union: 3.1.0
|
||||
@@ -7613,10 +7802,24 @@ snapshots:
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yallist@2.1.2: {}
|
||||
|
||||
yaml@2.6.1: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs@17.7.2:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
escalade: 3.2.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
string-width: 4.2.3
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
yn@3.1.1:
|
||||
optional: true
|
||||
|
||||
|
||||