Compare commits
47 Commits
f618d1442c
...
master
Author | SHA1 | Date | |
---|---|---|---|
|
5c092cad17 | ||
|
f38165d8d2 | ||
|
49a800fb4b | ||
|
45cbcaf95f | ||
|
8fab1a2c74 | ||
|
17e8bda099 | ||
|
ea19698036 | ||
|
0eb69d777d | ||
|
65943e33b9 | ||
|
6d4dd4de06 | ||
|
a6f043e0d1 | ||
|
50ba85afa3 | ||
|
7331f42e56 | ||
|
36e0c7714c | ||
|
c63da5222e | ||
|
32e797f35d | ||
|
d7aeb00212 | ||
|
45844453b4 | ||
|
a6ac162bfd | ||
|
02f8e70a71 | ||
|
32ad1017ad | ||
|
17a4b3fda1 | ||
|
0697450604 | ||
|
fed1ed9265 | ||
|
fea1c9e6e5 | ||
|
0469f18ef6 | ||
|
8024738334 | ||
|
2fc2bd7185 | ||
|
3e6b89ab39 | ||
|
925b055318 | ||
|
0685e7f4e1 | ||
|
a80e8eb74f | ||
|
39accb3cb7 | ||
|
6609d64752 | ||
|
c12a0075e7 | ||
|
48fbe067a0 | ||
|
733dd48663 | ||
|
55de6f7993 | ||
|
477634978f | ||
|
12feefaaec | ||
|
fae39d0146 | ||
|
12960348c0 | ||
|
675460f085 | ||
|
ee31a37dbc | ||
|
de3511a8dd | ||
|
e7c481afa6 | ||
|
5a191e3c4d |
56
.github/workflows/alpha-release.yml
vendored
Normal file
56
.github/workflows/alpha-release.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
name: Alpha Release CI
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-tauri:
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
platform: [macos-latest, windows-latest]
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install dependencies (ubuntu only)
|
||||||
|
if: matrix.platform == 'ubuntu-20.04'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev librsvg2-dev
|
||||||
|
|
||||||
|
- name: setup node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 16
|
||||||
|
|
||||||
|
- name: install Rust nightly
|
||||||
|
uses: dtolnay/rust-toolchain@nightly
|
||||||
|
|
||||||
|
- name: install dependencies (ubuntu only)
|
||||||
|
if: matrix.platform == 'ubuntu-20.04'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: latest-7
|
||||||
|
|
||||||
|
- name: install frontend dependencies
|
||||||
|
run: pnpm install # change this to npm or pnpm depending on which one you use
|
||||||
|
|
||||||
|
- name: build app
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
tauriScript: pnpm tauri
|
||||||
|
tagName: ${{ github.ref_name }} # This only works if your workflow triggers on new tags.
|
||||||
|
releaseName: '漫画阅读器 v__VERSION__' # tauri-action replaces \_\_VERSION\_\_ with the app version.
|
||||||
|
releaseBody: '从附件中下载对应平台的安装包以及应用。'
|
||||||
|
releaseDraft: true
|
||||||
|
prerelease: false
|
58
.github/workflows/release.yml
vendored
Normal file
58
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
name: Release CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-tauri:
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
platform: [macos-latest, windows-latest]
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install dependencies (ubuntu only)
|
||||||
|
if: matrix.platform == 'ubuntu-20.04'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev librsvg2-dev
|
||||||
|
|
||||||
|
- name: setup node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 16
|
||||||
|
|
||||||
|
- name: install Rust nightly
|
||||||
|
uses: dtolnay/rust-toolchain@nightly
|
||||||
|
|
||||||
|
- name: install dependencies (ubuntu only)
|
||||||
|
if: matrix.platform == 'ubuntu-20.04'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: latest-7
|
||||||
|
|
||||||
|
- name: install frontend dependencies
|
||||||
|
run: pnpm install # change this to npm or pnpm depending on which one you use
|
||||||
|
|
||||||
|
- name: build app
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
tauriScript: pnpm tauri
|
||||||
|
tagName: ${{ github.ref_name }} # This only works if your workflow triggers on new tags.
|
||||||
|
releaseName: '漫画阅读器 v__VERSION__' # tauri-action replaces \_\_VERSION\_\_ with the app version.
|
||||||
|
releaseBody: '从附件中下载对应平台的安装包以及应用。'
|
||||||
|
releaseDraft: true
|
||||||
|
prerelease: false
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,3 +22,5 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
**/src-tauri/.cargo/
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "comic_viewer",
|
"name": "comic_viewer",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.2.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-use": "^17.4.0",
|
"react-use": "^17.4.0",
|
||||||
"react-window": "^1.8.8",
|
"react-virtuoso": "^4.1.0",
|
||||||
"use-immer": "^0.8.1",
|
"use-immer": "^0.8.1",
|
||||||
"zustand": "^4.2.0"
|
"zustand": "^4.2.0"
|
||||||
},
|
},
|
||||||
|
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@@ -28,7 +28,7 @@ specifiers:
|
|||||||
react: ^18.2.0
|
react: ^18.2.0
|
||||||
react-dom: ^18.2.0
|
react-dom: ^18.2.0
|
||||||
react-use: ^17.4.0
|
react-use: ^17.4.0
|
||||||
react-window: ^1.8.8
|
react-virtuoso: ^4.1.0
|
||||||
typescript: ^4.6.4
|
typescript: ^4.6.4
|
||||||
use-immer: ^0.8.1
|
use-immer: ^0.8.1
|
||||||
vite: ^4.0.0
|
vite: ^4.0.0
|
||||||
@@ -53,7 +53,7 @@ dependencies:
|
|||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
react-dom: 18.2.0_react@18.2.0
|
react-dom: 18.2.0_react@18.2.0
|
||||||
react-use: 17.4.0_biqbaboplfbrettd7655fr4n2y
|
react-use: 17.4.0_biqbaboplfbrettd7655fr4n2y
|
||||||
react-window: 1.8.8_biqbaboplfbrettd7655fr4n2y
|
react-virtuoso: 4.1.0_biqbaboplfbrettd7655fr4n2y
|
||||||
use-immer: 0.8.1_immer@9.0.19+react@18.2.0
|
use-immer: 0.8.1_immer@9.0.19+react@18.2.0
|
||||||
zustand: 4.3.6_immer@9.0.19+react@18.2.0
|
zustand: 4.3.6_immer@9.0.19+react@18.2.0
|
||||||
|
|
||||||
@@ -1526,10 +1526,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
|
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/memoize-one/5.2.1:
|
|
||||||
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/ms/2.1.2:
|
/ms/2.1.2:
|
||||||
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
|
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -1750,15 +1746,13 @@ packages:
|
|||||||
tslib: 2.5.0
|
tslib: 2.5.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/react-window/1.8.8_biqbaboplfbrettd7655fr4n2y:
|
/react-virtuoso/4.1.0_biqbaboplfbrettd7655fr4n2y:
|
||||||
resolution: {integrity: sha512-D4IiBeRtGXziZ1n0XklnFGu7h9gU684zepqyKzgPNzrsrk7xOCxni+TCckjg2Nr/DiaEEGVVmnhYSlT2rB47dQ==}
|
resolution: {integrity: sha512-Vcq5WXn18PvPT55kdeGQ8BN3K95XyPe7hum8zG6Tx7g1CtUYVsQKN7fouMxBSy+XymEDB5ynGy8JWhuqyLLtPw==}
|
||||||
engines: {node: '>8.0.0'}
|
engines: {node: '>=10'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
|
react: '>=16 || >=17 || >= 18'
|
||||||
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
|
react-dom: '>=16 || >=17 || >= 18'
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.21.0
|
|
||||||
memoize-one: 5.2.1
|
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
react-dom: 18.2.0_react@18.2.0
|
react-dom: 18.2.0_react@18.2.0
|
||||||
dev: false
|
dev: false
|
||||||
|
66
src-tauri/Cargo.lock
generated
66
src-tauri/Cargo.lock
generated
@@ -326,12 +326,16 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "comic_viewer"
|
name = "comic_viewer"
|
||||||
version = "0.0.0"
|
version = "0.2.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
"image",
|
"image",
|
||||||
|
"md-5",
|
||||||
|
"mime_guess",
|
||||||
|
"mountpoints",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -339,8 +343,8 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"urlencoding",
|
||||||
"uuid 1.3.0",
|
"uuid 1.3.0",
|
||||||
"walkdir",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1590,6 +1594,15 @@ version = "0.1.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "md-5"
|
||||||
|
version = "0.10.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6365506850d44bff6e2fbcb5176cf63650e48bd45ef2fe2665ae1570e0f4b9ca"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -1605,6 +1618,22 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime"
|
||||||
|
version = "0.3.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime_guess"
|
||||||
|
version = "2.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef"
|
||||||
|
dependencies = [
|
||||||
|
"mime",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -1626,6 +1655,16 @@ dependencies = [
|
|||||||
"windows-sys 0.45.0",
|
"windows-sys 0.45.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mountpoints"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3090e3ea5226b07cedd89a57c735d3ed0374a84236a85529a1d8bfec98eb8ec5"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nanorand"
|
name = "nanorand"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -2247,9 +2286,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.7.1"
|
version = "1.7.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48aaa5748ba571fb95cd2c85c09f629215d3a6ece942baa100950af03a34f733"
|
checksum = "cce168fea28d3e05f158bda4576cf0c844d5045bc2cc3620fa0292ed5bb5814c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -2267,9 +2306,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-syntax"
|
name = "regex-syntax"
|
||||||
version = "0.6.28"
|
version = "0.6.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848"
|
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rfd"
|
name = "rfd"
|
||||||
@@ -3186,6 +3225,15 @@ version = "1.16.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba"
|
checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6"
|
||||||
|
dependencies = [
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.10"
|
version = "0.3.10"
|
||||||
@@ -3231,6 +3279,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf-8"
|
name = "utf-8"
|
||||||
version = "0.7.6"
|
version = "0.7.6"
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "comic_viewer"
|
name = "comic_viewer"
|
||||||
version = "0.0.0"
|
version = "0.2.7"
|
||||||
description = "A Tauri App"
|
description = "漫画、条漫简易阅读器"
|
||||||
authors = ["you"]
|
authors = ["Khadgar"]
|
||||||
license = ""
|
license = "MIT"
|
||||||
repository = ""
|
repository = "https://github.com/vixalie/comic_viewer"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
@@ -14,17 +14,21 @@ tauri-build = { version = "1.2", features = [] }
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
once_cell = "1.17.0"
|
once_cell = "1.17.0"
|
||||||
tauri = { version = "1.2", features = ["dialog-open", "fs-read-file", "protocol-all", "shell-open"] }
|
tauri = { version = "1.2", features = ["dialog-open", "fs-exists", "fs-read-dir", "fs-read-file", "protocol-all", "shell-open"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
chrono = { version = "0.4.23", features = ["serde"] }
|
chrono = { version = "0.4.23", features = ["serde"] }
|
||||||
anyhow = "1.0.68"
|
anyhow = "1.0.68"
|
||||||
thiserror = "1.0.38"
|
thiserror = "1.0.38"
|
||||||
walkdir = "2.3.2"
|
|
||||||
serde_repr = "0.1.10"
|
serde_repr = "0.1.10"
|
||||||
tokio = { version = "1.23.1", features = ["full"] }
|
tokio = { version = "1.23.1", features = ["full"] }
|
||||||
image = "0.24.5"
|
image = "0.24.5"
|
||||||
uuid = "1.3.0"
|
uuid = "1.3.0"
|
||||||
|
mountpoints = "0.2.1"
|
||||||
|
md-5 = "0.10.5"
|
||||||
|
urlencoding = "2.1.2"
|
||||||
|
mime_guess = "2.0.4"
|
||||||
|
regex = "1.7.2"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# this feature is used for production builds or when `devPath` points to the filesystem
|
# this feature is used for production builds or when `devPath` points to the filesystem
|
||||||
|
@@ -1,8 +1,24 @@
|
|||||||
use anyhow::anyhow;
|
use std::{
|
||||||
use serde::Serialize;
|
fs::{self, DirEntry},
|
||||||
use walkdir::WalkDir;
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
use mountpoints::mountinfos;
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::Runtime;
|
||||||
|
|
||||||
|
use crate::utils;
|
||||||
|
|
||||||
|
fn compute_all_digits(text: &str) -> usize {
|
||||||
|
let re = Regex::new(r#"\d+"#).unwrap();
|
||||||
|
re.find_iter(&["a", text, "0"].join(" "))
|
||||||
|
.map(|b| b.as_str())
|
||||||
|
.map(|b| usize::from_str_radix(b, 10).unwrap_or(0))
|
||||||
|
.sum::<usize>()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
pub struct FileItem {
|
pub struct FileItem {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
@@ -11,36 +27,219 @@ pub struct FileItem {
|
|||||||
pub width: u32,
|
pub width: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PartialOrd for FileItem {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||||
|
let this_digit = compute_all_digits(&self.filename);
|
||||||
|
let other_digit = compute_all_digits(&other.filename);
|
||||||
|
if this_digit == other_digit {
|
||||||
|
self.filename.partial_cmp(&other.filename)
|
||||||
|
} else {
|
||||||
|
this_digit.partial_cmp(&other_digit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
|
pub struct DirItem {
|
||||||
|
pub id: String,
|
||||||
|
pub dirname: String,
|
||||||
|
pub path: String,
|
||||||
|
pub root: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialOrd for DirItem {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||||
|
let this_digit = compute_all_digits(&self.dirname);
|
||||||
|
let other_digit = compute_all_digits(&other.dirname);
|
||||||
|
if this_digit == other_digit {
|
||||||
|
self.dirname.partial_cmp(&other.dirname)
|
||||||
|
} else {
|
||||||
|
this_digit.partial_cmp(&other_digit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_hidden(entry: &DirEntry) -> bool {
|
||||||
|
entry
|
||||||
|
.file_name()
|
||||||
|
.to_str()
|
||||||
|
.map(|s| s.starts_with(".") || s.starts_with("$"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_root(entry: &DirEntry) -> bool {
|
||||||
|
entry
|
||||||
|
.path()
|
||||||
|
.to_str()
|
||||||
|
.map(|s| s.eq_ignore_ascii_case("/"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn scan_directory(target: String) -> Result<Vec<FileItem>, String> {
|
pub async fn scan_directory(target: String) -> Result<Vec<FileItem>, String> {
|
||||||
let mut file_items = WalkDir::new(target)
|
let mut file_items: Vec<FileItem> = Vec::new();
|
||||||
.into_iter()
|
for entry in std::fs::read_dir(target).map_err(|e| format!("无法读取指定文件夹,{}", e))?
|
||||||
.filter_map(|f| f.ok())
|
{
|
||||||
.filter(|f| f.path().is_file())
|
let entry = entry.map_err(|e| format!("无法获取指定文件夹信息,{}", e))?;
|
||||||
.map(|f| {
|
if !entry.path().is_file() {
|
||||||
let (width, height) = image::image_dimensions(f.path())?;
|
continue;
|
||||||
Ok(FileItem {
|
}
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
let image_detect_result = image::image_dimensions(entry.path());
|
||||||
filename: f
|
if image_detect_result.is_err() {
|
||||||
|
println!("读取图片文件 {} 元信息失败。", entry.path().display());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (width, height) = image_detect_result.unwrap();
|
||||||
|
let file_hash_id = entry
|
||||||
|
.path()
|
||||||
|
.to_str()
|
||||||
|
.map(crate::utils::md5_hash)
|
||||||
|
.map(|hash| utils::uuid_from(hash.as_slice()))
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or(uuid::Uuid::new_v4().to_string());
|
||||||
|
let filename = entry
|
||||||
.path()
|
.path()
|
||||||
.file_name()
|
.file_name()
|
||||||
.ok_or(anyhow!("不能获取到文件名。"))?
|
.ok_or(String::from("不能获取到文件名。"))
|
||||||
.to_owned()
|
.map(ToOwned::to_owned)
|
||||||
.into_string()
|
.map(|s| s.into_string().unwrap());
|
||||||
.unwrap(),
|
if filename.is_err() {
|
||||||
path: f
|
println!("不能获取到文件 {} 文件名。", entry.path().display());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry
|
||||||
.path()
|
.path()
|
||||||
.clone()
|
.clone()
|
||||||
.to_str()
|
.to_str()
|
||||||
.ok_or(anyhow!("不能获取到文件路径。"))?
|
.ok_or(String::from("不能获取到文件路径。"))
|
||||||
.to_string(),
|
.map(ToString::to_string);
|
||||||
width,
|
if path.is_err() {
|
||||||
|
println!("不能获取到文件 {} 路径。", entry.path().display());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
file_items.push(FileItem {
|
||||||
|
id: file_hash_id,
|
||||||
|
filename: filename.unwrap(),
|
||||||
|
path: path.unwrap(),
|
||||||
height,
|
height,
|
||||||
})
|
width,
|
||||||
})
|
});
|
||||||
.collect::<Result<Vec<FileItem>, anyhow::Error>>()
|
}
|
||||||
.map_err(|e| e.to_string())?;
|
file_items.sort_by(|a, b| a.partial_cmp(&b).unwrap());
|
||||||
file_items.sort_by(|a, b| a.filename.partial_cmp(&b.filename).unwrap());
|
|
||||||
|
|
||||||
Ok(file_items)
|
Ok(file_items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn show_drives<R: Runtime>(
|
||||||
|
_app: tauri::AppHandle<R>,
|
||||||
|
_window: tauri::Window<R>,
|
||||||
|
) -> Result<Vec<DirItem>, String> {
|
||||||
|
let mut drives = vec![];
|
||||||
|
match mountinfos() {
|
||||||
|
Ok(mounts) => {
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
let mounts = mounts
|
||||||
|
.iter()
|
||||||
|
.filter(|m| !m.path.starts_with("/System") && !m.path.starts_with("/dev"));
|
||||||
|
for mount in mounts {
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
let dirname = mount
|
||||||
|
.path
|
||||||
|
.as_path()
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_os_string()
|
||||||
|
.into_string()
|
||||||
|
.unwrap();
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let dirname = mount.path.display().to_string();
|
||||||
|
let dirname = if dirname.len() == 0 {
|
||||||
|
String::from("/")
|
||||||
|
} else {
|
||||||
|
dirname
|
||||||
|
};
|
||||||
|
let dir_hash_id = mount
|
||||||
|
.path
|
||||||
|
.to_str()
|
||||||
|
.map(crate::utils::md5_hash)
|
||||||
|
.map(|hash| utils::uuid_from(hash.as_slice()))
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||||
|
drives.push(DirItem {
|
||||||
|
id: dir_hash_id,
|
||||||
|
dirname,
|
||||||
|
path: String::from(mount.path.to_str().unwrap_or_default()),
|
||||||
|
root: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => return Err(format!("不能列出系统中已挂载的磁盘:{}", err)),
|
||||||
|
}
|
||||||
|
Ok(drives)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn scan_for_child_dirs<R: Runtime>(
|
||||||
|
_app: tauri::AppHandle<R>,
|
||||||
|
_window: tauri::Window<R>,
|
||||||
|
target: String,
|
||||||
|
) -> Result<Vec<DirItem>, String> {
|
||||||
|
println!("请求扫描文件夹:{}", target);
|
||||||
|
let target = if target.eq_ignore_ascii_case("/") {
|
||||||
|
Path::new(r"/")
|
||||||
|
} else {
|
||||||
|
Path::new(&target)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut child_dirs: Vec<DirItem> = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(target).map_err(|e| format!("无法读取指定文件夹,{}", e))?
|
||||||
|
{
|
||||||
|
let entry = entry.map_err(|e| format!("无法获取指定文件夹信息,{}", e))?;
|
||||||
|
if is_hidden(&entry) || is_root(&entry) || entry.path().is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dir_hash_id = entry
|
||||||
|
.path()
|
||||||
|
.to_str()
|
||||||
|
.map(crate::utils::md5_hash)
|
||||||
|
.map(|hash| utils::uuid_from(hash.as_slice()))
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or(uuid::Uuid::new_v4().to_string());
|
||||||
|
let dirname = entry
|
||||||
|
.path()
|
||||||
|
.file_name()
|
||||||
|
.ok_or(String::from("不能获取到文件夹的名称。"))?
|
||||||
|
.to_owned()
|
||||||
|
.into_string()
|
||||||
|
.unwrap();
|
||||||
|
let path = entry
|
||||||
|
.path()
|
||||||
|
.clone()
|
||||||
|
.to_str()
|
||||||
|
.ok_or(String::from("不能获取到文件夹路径。"))?
|
||||||
|
.to_string();
|
||||||
|
child_dirs.push(DirItem {
|
||||||
|
id: dir_hash_id,
|
||||||
|
dirname,
|
||||||
|
path,
|
||||||
|
root: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
child_dirs.sort_by(|a, b| a.partial_cmp(&b).unwrap());
|
||||||
|
Ok(child_dirs)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn rename_file<R: Runtime>(
|
||||||
|
_app: tauri::AppHandle<R>,
|
||||||
|
_window: tauri::Window<R>,
|
||||||
|
store_path: String,
|
||||||
|
origin_name: String,
|
||||||
|
new_name: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let origin_file = PathBuf::from(store_path.clone()).join(origin_name);
|
||||||
|
let new_file = PathBuf::from(store_path).join(new_name);
|
||||||
|
fs::rename(origin_file, new_file).map_err(|e| format!("重命名问文件失败,{}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
@@ -20,8 +20,8 @@ pub fn update_window_title_with_app<R: Runtime>(
|
|||||||
ext: Option<String>,
|
ext: Option<String>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let app_name = match app {
|
let app_name = match app {
|
||||||
AppHold::Instance(app) => app.package_info().name.clone(),
|
AppHold::Instance(app) => app.config().tauri.windows[0].title.clone(),
|
||||||
AppHold::Handle(app) => app.package_info().name.clone(),
|
AppHold::Handle(app) => app.config().tauri.windows[0].title.clone(),
|
||||||
};
|
};
|
||||||
if let Some(ext) = ext {
|
if let Some(ext) = ext {
|
||||||
window.set_title(&format!("{} - {}", app_name, ext))?;
|
window.set_title(&format!("{} - {}", app_name, ext))?;
|
||||||
|
@@ -2,13 +2,22 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod commands;
|
mod commands;
|
||||||
|
mod protocol;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
use commands::AppHold;
|
use commands::AppHold;
|
||||||
|
use protocol::comic_protocol;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.invoke_handler(tauri::generate_handler![commands::prelude::scan_directory])
|
.register_uri_scheme_protocol("comic", comic_protocol)
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::prelude::scan_directory,
|
||||||
|
commands::prelude::show_drives,
|
||||||
|
commands::prelude::scan_for_child_dirs,
|
||||||
|
commands::prelude::rename_file
|
||||||
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let main_window = app.get_window("main").unwrap();
|
let main_window = app.get_window("main").unwrap();
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
|
42
src-tauri/src/protocol.rs
Normal file
42
src-tauri/src/protocol.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
use std::{error::Error, fs, path::Path};
|
||||||
|
|
||||||
|
use tauri::{
|
||||||
|
http::{Request, Response, ResponseBuilder},
|
||||||
|
AppHandle, Runtime,
|
||||||
|
};
|
||||||
|
use urlencoding::decode;
|
||||||
|
|
||||||
|
pub fn comic_protocol<R: Runtime>(
|
||||||
|
_app_handler: &AppHandle<R>,
|
||||||
|
request: &Request,
|
||||||
|
) -> Result<Response, Box<dyn Error>> {
|
||||||
|
let response_builder = ResponseBuilder::new();
|
||||||
|
let path = request
|
||||||
|
.uri()
|
||||||
|
.strip_prefix("comic://localhost/")
|
||||||
|
.map(|u| decode(u).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
println!("[debug]request: {}", path);
|
||||||
|
if path.len() == 0 {
|
||||||
|
return response_builder
|
||||||
|
.mimetype("text/plain")
|
||||||
|
.status(404)
|
||||||
|
.body(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = Path::new(path.as_ref());
|
||||||
|
if !path.exists() {
|
||||||
|
return response_builder
|
||||||
|
.mimetype("text/plain")
|
||||||
|
.status(404)
|
||||||
|
.body(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mimetype = mime_guess::from_path(path);
|
||||||
|
let content = fs::read(path)?;
|
||||||
|
|
||||||
|
response_builder
|
||||||
|
.mimetype(mimetype.first_or_text_plain().essence_str())
|
||||||
|
.status(200)
|
||||||
|
.body(content)
|
||||||
|
}
|
19
src-tauri/src/utils.rs
Normal file
19
src-tauri/src/utils.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
use md5::{Digest, Md5};
|
||||||
|
use uuid::Builder;
|
||||||
|
|
||||||
|
pub fn md5_hash(source: &str) -> Vec<u8> {
|
||||||
|
let mut hasher = Md5::new();
|
||||||
|
hasher.update(source);
|
||||||
|
let hash = hasher.finalize();
|
||||||
|
hash.to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn uuid_from(source: &[u8]) -> Result<String, String> {
|
||||||
|
let builder = Builder::from_md5_bytes(
|
||||||
|
source
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| String::from("源内容长度不足。"))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(builder.into_uuid().to_string())
|
||||||
|
}
|
@@ -7,8 +7,8 @@
|
|||||||
"withGlobalTauri": false
|
"withGlobalTauri": false
|
||||||
},
|
},
|
||||||
"package": {
|
"package": {
|
||||||
"productName": "漫画阅读器",
|
"productName": "comic_viewer",
|
||||||
"version": "0.1.0"
|
"version": "../package.json"
|
||||||
},
|
},
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": {
|
"allowlist": {
|
||||||
@@ -17,13 +17,15 @@
|
|||||||
"all": false,
|
"all": false,
|
||||||
"copyFile": false,
|
"copyFile": false,
|
||||||
"createDir": false,
|
"createDir": false,
|
||||||
"exists": false,
|
"exists": true,
|
||||||
"readDir": false,
|
"readDir": true,
|
||||||
"readFile": true,
|
"readFile": true,
|
||||||
"removeDir": false,
|
"removeDir": false,
|
||||||
"removeFile": false,
|
"removeFile": false,
|
||||||
"renameFile": false,
|
"renameFile": false,
|
||||||
"scope": [],
|
"scope": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
"writeFile": false
|
"writeFile": false
|
||||||
},
|
},
|
||||||
"protocol": {
|
"protocol": {
|
||||||
@@ -52,10 +54,14 @@
|
|||||||
"icons/eyeicon.ico"
|
"icons/eyeicon.ico"
|
||||||
],
|
],
|
||||||
"identifier": "xyz.archgrid.comic-viewer",
|
"identifier": "xyz.archgrid.comic-viewer",
|
||||||
"targets": "all"
|
"targets": "all",
|
||||||
|
"windows": {
|
||||||
|
"wix": {
|
||||||
|
"language": "zh-CN"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"devCsp": "default-src 'self'; img-src 'self' asset: https://asset.localhost",
|
|
||||||
"csp": null
|
"csp": null
|
||||||
},
|
},
|
||||||
"updater": {
|
"updater": {
|
||||||
@@ -66,7 +72,7 @@
|
|||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"title": "漫画阅读器",
|
"title": "漫画阅读器",
|
||||||
"width": 1000,
|
"width": 1200,
|
||||||
"height": 800
|
"height": 800
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
19
src/App.tsx
19
src/App.tsx
@@ -1,15 +1,16 @@
|
|||||||
import { useState } from "react";
|
//@ts-nocheck
|
||||||
import reactLogo from "./assets/react.svg";
|
import { invoke } from '@tauri-apps/api/tauri';
|
||||||
import { invoke } from "@tauri-apps/api/tauri";
|
import { useState } from 'react';
|
||||||
import "./App.css";
|
import './App.css';
|
||||||
|
import reactLogo from './assets/react.svg';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [greetMsg, setGreetMsg] = useState("");
|
const [greetMsg, setGreetMsg] = useState('');
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState('');
|
||||||
|
|
||||||
async function greet() {
|
async function greet() {
|
||||||
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
|
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
|
||||||
setGreetMsg(await invoke("greet", { name }));
|
setGreetMsg(await invoke('greet', { name }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -32,14 +33,14 @@ function App() {
|
|||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
greet();
|
greet();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id="greet-input"
|
id="greet-input"
|
||||||
onChange={(e) => setName(e.currentTarget.value)}
|
onChange={e => setName(e.currentTarget.value)}
|
||||||
placeholder="Enter a name..."
|
placeholder="Enter a name..."
|
||||||
/>
|
/>
|
||||||
<button type="submit">Greet</button>
|
<button type="submit">Greet</button>
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
//@ts-nocheck
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { createContext } from 'react';
|
import { createContext } from 'react';
|
||||||
|
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
//@ts-nocheck
|
||||||
import { Group, Stack } from '@mantine/core';
|
import { Group, Stack } from '@mantine/core';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { ComicView } from './components/ComicView';
|
import { ComicView } from './components/ComicView';
|
||||||
|
@@ -1,9 +1,13 @@
|
|||||||
//@ts-nocheck
|
//@ts-nocheck
|
||||||
import { Stack, useMantineTheme } from '@mantine/core';
|
import { Stack, Tabs, useMantineTheme } from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { IconFiles, IconFolders } from '@tabler/icons-react';
|
||||||
import { ifElse, path, propEq } from 'ramda';
|
import { ifElse, path, propEq } from 'ramda';
|
||||||
import { FC, useMemo } from 'react';
|
import { FC, useMemo } from 'react';
|
||||||
|
import { useMount } from 'react-use';
|
||||||
|
import { DirTree } from './components/DirTree';
|
||||||
import { FileList } from './components/FileList';
|
import { FileList } from './components/FileList';
|
||||||
import { FileToolbar } from './components/FileTools';
|
import { loadDrives } from './queries/directories';
|
||||||
|
|
||||||
const bgSelectFn = ifElse(
|
const bgSelectFn = ifElse(
|
||||||
propEq('colorScheme', 'dark'),
|
propEq('colorScheme', 'dark'),
|
||||||
@@ -18,10 +22,19 @@ export const NavMenu: FC = () => {
|
|||||||
const disabledColor = useMemo(() => path<string>(['gray', 7])(theme.colors), [theme.colors]);
|
const disabledColor = useMemo(() => path<string>(['gray', 7])(theme.colors), [theme.colors]);
|
||||||
const navMenuBg = useMemo(() => bgSelectFn(theme), [theme, theme]);
|
const navMenuBg = useMemo(() => bgSelectFn(theme), [theme, theme]);
|
||||||
|
|
||||||
|
useMount(() => {
|
||||||
|
try {
|
||||||
|
loadDrives();
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({ message: `未能成功加载全部磁盘列表,${e.message}`, color: 'red' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
spacing={8}
|
spacing={8}
|
||||||
miw={200}
|
w={300}
|
||||||
|
mw={200}
|
||||||
h="inherit"
|
h="inherit"
|
||||||
sx={theme => ({
|
sx={theme => ({
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
@@ -32,8 +45,24 @@ export const NavMenu: FC = () => {
|
|||||||
py={4}
|
py={4}
|
||||||
align="center"
|
align="center"
|
||||||
>
|
>
|
||||||
<FileToolbar />
|
<Tabs defaultValue="folder" w="100%" h="100%">
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Tab value="folder" icon={<IconFolders stroke={1.5} size={16} />}>
|
||||||
|
文件夹
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="files" icon={<IconFiles stroke={1.5} size={16} />}>
|
||||||
|
文件列表
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
<Tabs.Panel value="folder" h="100%">
|
||||||
|
<DirTree />
|
||||||
|
</Tabs.Panel>
|
||||||
|
<Tabs.Panel value="files" h="100%">
|
||||||
|
<Stack spacing={8} py={4} w="100%" h="100%" align="center">
|
||||||
<FileList />
|
<FileList />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -13,12 +13,16 @@ export const ComicView: FC = () => {
|
|||||||
const files = useFileListStore(sortedFilesSelector());
|
const files = useFileListStore(sortedFilesSelector());
|
||||||
const viewMode = useZoomState.use.viewMode();
|
const viewMode = useZoomState.use.viewMode();
|
||||||
const updateViewHeight = useZoomState.use.updateViewHeight();
|
const updateViewHeight = useZoomState.use.updateViewHeight();
|
||||||
const [containerRef, { height }] = useMeasure();
|
const updateViewWidth = useZoomState.use.updateViewWidth();
|
||||||
|
const [containerRef, { height, width }] = useMeasure();
|
||||||
const firstFileId = useMemo(() => head(files)?.id ?? '', [files, files.length]);
|
const firstFileId = useMemo(() => head(files)?.id ?? '', [files, files.length]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
updateViewHeight(height);
|
updateViewHeight(height);
|
||||||
}, [height]);
|
}, [height]);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
updateViewWidth(width);
|
||||||
|
}, [width]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box w="100%" h="100%" sx={{ overflow: 'hidden' }} ref={containerRef}>
|
<Box w="100%" h="100%" sx={{ overflow: 'hidden' }} ref={containerRef}>
|
||||||
|
@@ -1,35 +1,34 @@
|
|||||||
//@ts-nocheck
|
//@ts-nocheck
|
||||||
import EventEmitter from 'events';
|
import EventEmitter from 'events';
|
||||||
import { indexOf, isEmpty, length, map, mergeLeft, pluck, range } from 'ramda';
|
import { indexOf, isEmpty, length, map, pluck, range } from 'ramda';
|
||||||
import { FC, useCallback, useContext, useEffect, useMemo, useRef } from 'react';
|
import { FC, useCallback, useContext, useEffect, useMemo, useRef } from 'react';
|
||||||
import { VariableSizeList } from 'react-window';
|
import { ListRange, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||||
import { EventBusContext } from '../EventBus';
|
import { EventBusContext } from '../EventBus';
|
||||||
import { sortedFilesSelector, useFileListStore } from '../states/files';
|
import { sortedFilesSelector, useFileListStore } from '../states/files';
|
||||||
import { useZoomState } from '../states/zoom';
|
import { useZoomState } from '../states/zoom';
|
||||||
|
|
||||||
export const ContinuationView: FC = () => {
|
export const ContinuationView: FC = () => {
|
||||||
const files = useFileListStore(sortedFilesSelector());
|
const files = useFileListStore(sortedFilesSelector());
|
||||||
const zoom = useZoomState.use.currentZoom();
|
const { currentZoom: zoom, viewWidth, viewHeight } = useZoomState();
|
||||||
const viewHeight = useZoomState.use.viewHeight();
|
|
||||||
const updateActives = useFileListStore.use.updateActiveFiles();
|
const updateActives = useFileListStore.use.updateActiveFiles();
|
||||||
const fileCount = useMemo(() => length(files), [files]);
|
const fileCount = useMemo(() => length(files), [files]);
|
||||||
const ebus = useContext<EventEmitter>(EventBusContext);
|
const ebus = useContext<EventEmitter>(EventBusContext);
|
||||||
const virtualListRef = useRef<VariableSizeList | null>();
|
const virtualListRef = useRef<VirtuosoHandle | null>(null);
|
||||||
const handleOnRenderAction = useCallback(
|
const handleOnRenderAction = useCallback(
|
||||||
({ visibleStartIndex, visibleStopIndex }) => {
|
({ startIndex, endIndex }: ListRange) => {
|
||||||
updateActives(map(i => files[i].filename, range(visibleStartIndex, visibleStopIndex + 1)));
|
updateActives(map(i => files[i].filename, range(startIndex, endIndex + 1)));
|
||||||
},
|
},
|
||||||
[files]
|
[files]
|
||||||
);
|
);
|
||||||
const fileHeights = useMemo(() => map(item => item.height * (zoom / 100), files), [files, zoom]);
|
const maxImageWidth = useMemo(() => Math.floor(viewWidth * (zoom / 100)), [viewWidth, zoom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ebus?.addListener('navigate_offset', ({ filename }) => {
|
ebus?.addListener('navigate_offset', ({ filename }) => {
|
||||||
let index = indexOf(filename, pluck('filename', files));
|
let index = indexOf(filename, pluck('filename', files));
|
||||||
virtualListRef.current?.scrollToItem(index);
|
virtualListRef.current?.scrollToIndex({ index, align: 'start' });
|
||||||
});
|
});
|
||||||
ebus?.addListener('reset_views', () => {
|
ebus?.addListener('reset_views', () => {
|
||||||
virtualListRef.current?.scrollTo(0);
|
virtualListRef.current?.scrollTo({ top: 0 });
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
ebus?.removeAllListeners('navigate_offset');
|
ebus?.removeAllListeners('navigate_offset');
|
||||||
@@ -46,29 +45,27 @@ export const ContinuationView: FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!isEmpty(files) && (
|
{!isEmpty(files) && (
|
||||||
<VariableSizeList
|
<Virtuoso
|
||||||
itemData={files}
|
style={{ height: viewHeight }}
|
||||||
itemCount={fileCount}
|
|
||||||
itemSize={index => fileHeights[index]}
|
|
||||||
itemKey={index => files[index].id}
|
|
||||||
height={viewHeight}
|
|
||||||
width="100%"
|
|
||||||
ref={virtualListRef}
|
ref={virtualListRef}
|
||||||
onItemsRendered={handleOnRenderAction}
|
totalCount={fileCount}
|
||||||
>
|
computeItemKey={index => files[index].id}
|
||||||
{({ index, style, data }) => (
|
rangeChanged={handleOnRenderAction}
|
||||||
|
itemContent={index => (
|
||||||
<div
|
<div
|
||||||
style={mergeLeft(style, {
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'flex-start'
|
alignItems: 'flex-start',
|
||||||
})}
|
width: '100%',
|
||||||
|
height: Math.round(files[index].height * (maxImageWidth / files[index].width))
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<img src={data[index].path} style={{ width: data[index].width * (zoom / 100) }} />
|
<img src={files[index].path} style={{ width: maxImageWidth }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</VariableSizeList>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
175
src/components/DirTree.tsx
Normal file
175
src/components/DirTree.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
//@ts-nocheck
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { ActionIcon, Box, Flex, Stack, Text, Tooltip } from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { IconEye, IconSquareMinus, IconSquarePlus } from '@tabler/icons-react';
|
||||||
|
import { invoke } from '@tauri-apps/api';
|
||||||
|
import EventEmitter from 'events';
|
||||||
|
import { equals, isEmpty, isNil, map, not } from 'ramda';
|
||||||
|
import { FC, PropsWithChildren, useCallback, useContext } from 'react';
|
||||||
|
import { useLifecycles, useMeasure } from 'react-use';
|
||||||
|
import { EventBusContext } from '../EventBus';
|
||||||
|
import { DirItem } from '../models';
|
||||||
|
import { loadSubDirectories } from '../queries/directories';
|
||||||
|
import {
|
||||||
|
currentRootsSelector,
|
||||||
|
isExpandedSelector,
|
||||||
|
subDirectoriesSelector,
|
||||||
|
useDirTreeStore
|
||||||
|
} from '../states/dirs';
|
||||||
|
import { useFileListStore } from '../states/files';
|
||||||
|
|
||||||
|
const Tree = styled.ul`
|
||||||
|
--spacing: 0.5rem;
|
||||||
|
list-style-type: none;
|
||||||
|
list-style-position: outside;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: max-content;
|
||||||
|
li ul {
|
||||||
|
padding-left: calc(2 * var(--spacing));
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Branch: FC<PropsWithChildren<{ current: DirItem }>> = ({ children, current }) => {
|
||||||
|
const { directories: allSubDirs } = useDirTreeStore();
|
||||||
|
const subDirs = useDirTreeStore(subDirectoriesSelector(current.id));
|
||||||
|
const isCurrentExpanded = useDirTreeStore(isExpandedSelector(current.id));
|
||||||
|
const expend = useDirTreeStore.use.expandDir();
|
||||||
|
const fold = useDirTreeStore.use.foldDir();
|
||||||
|
const selectDir = useDirTreeStore.use.selectDirectory();
|
||||||
|
const selectedDirectory = useDirTreeStore.use.selected();
|
||||||
|
const storeFiles = useFileListStore.use.updateFiles();
|
||||||
|
const ebus = useContext<EventEmitter>(EventBusContext);
|
||||||
|
|
||||||
|
useLifecycles(
|
||||||
|
() => {
|
||||||
|
ebus.addListener(`expand:${current.id}`, () => {
|
||||||
|
expend(current.id);
|
||||||
|
loadSubDirectories(current);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
ebus.removeAllListeners(`expand:${current.id}`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleExpandAction = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (isCurrentExpanded) {
|
||||||
|
fold(current.id);
|
||||||
|
} else {
|
||||||
|
await loadSubDirectories(current);
|
||||||
|
expend(current.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({
|
||||||
|
message: `未能成功加载指定文件夹下的子文件夹,${e}`,
|
||||||
|
color: 'red'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [current, allSubDirs, isCurrentExpanded]);
|
||||||
|
const handleSelectAction = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
selectDir(current.id);
|
||||||
|
const files = await invoke('scan_directory', { target: current.path });
|
||||||
|
storeFiles(files);
|
||||||
|
ebus.emit('reset_views');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[error]打开文件夹', e);
|
||||||
|
notifications.show({ message: `未能成功打开指定文件夹,请重试。${e}`, color: 'red' });
|
||||||
|
}
|
||||||
|
}, [current]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<Flex direction="row" justify="flex-start" align="center" spacing={8} maw={250}>
|
||||||
|
<ActionIcon onClick={handleExpandAction}>
|
||||||
|
{isCurrentExpanded ? (
|
||||||
|
<IconSquareMinus stroke={1.5} size={16} />
|
||||||
|
) : (
|
||||||
|
<IconSquarePlus stroke={1.5} size={16} />
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
<Tooltip label={children}>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
truncate
|
||||||
|
onClick={handleSelectAction}
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
bg={equals(current.id, selectedDirectory) && 'blue'}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
</Flex>
|
||||||
|
{not(isEmpty(subDirs)) && isCurrentExpanded && (
|
||||||
|
<Tree>
|
||||||
|
{map(
|
||||||
|
item => (
|
||||||
|
<Branch key={item.id} current={item}>
|
||||||
|
{item.dirname}
|
||||||
|
</Branch>
|
||||||
|
),
|
||||||
|
subDirs
|
||||||
|
)}
|
||||||
|
</Tree>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DirTree: FC = () => {
|
||||||
|
const roots = useDirTreeStore(currentRootsSelector());
|
||||||
|
const { focused, focus, unfocus, selected, foldDir } = useDirTreeStore();
|
||||||
|
const [viewRef, { width }] = useMeasure();
|
||||||
|
const storeFiles = useFileListStore.use.updateFiles();
|
||||||
|
const ebus = useContext<EventEmitter>(EventBusContext);
|
||||||
|
|
||||||
|
const handleFocusAction = useCallback(() => {
|
||||||
|
if (isNil(focused) && not(isNil(selected))) {
|
||||||
|
ebus.emit(`expand:${selected}`);
|
||||||
|
focus(selected);
|
||||||
|
} else {
|
||||||
|
foldDir(focused.id);
|
||||||
|
unfocus();
|
||||||
|
}
|
||||||
|
}, [focused, selected]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
w="auto"
|
||||||
|
h="100%"
|
||||||
|
spacing={8}
|
||||||
|
px={4}
|
||||||
|
pt={4}
|
||||||
|
pb={40}
|
||||||
|
pos="relative"
|
||||||
|
sx={{ overflow: 'auto' }}
|
||||||
|
ref={viewRef}
|
||||||
|
>
|
||||||
|
<Box pos="fixed" style={{ transform: `translateX(${width * 0.85}px)` }}>
|
||||||
|
<Tooltip label={isNil(focused) ? '聚焦当前选择的目录' : '解除聚焦'}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color={isNil(focused) ? 'grape' : 'green'}
|
||||||
|
disabled={isNil(selected)}
|
||||||
|
onClick={handleFocusAction}
|
||||||
|
>
|
||||||
|
<IconEye stroke={1.5} size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Tree>
|
||||||
|
{map(
|
||||||
|
item => (
|
||||||
|
<Branch key={item.id} current={item}>
|
||||||
|
{item.dirname}
|
||||||
|
</Branch>
|
||||||
|
),
|
||||||
|
roots
|
||||||
|
)}
|
||||||
|
</Tree>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
@@ -1,63 +1,120 @@
|
|||||||
//@ts-nocheck
|
//@ts-nocheck
|
||||||
import { Box, Center, Text } from '@mantine/core';
|
import { Box, Center, Group, Text, TextInput, Tooltip } from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { invoke } from '@tauri-apps/api';
|
||||||
import EventEmitter from 'events';
|
import EventEmitter from 'events';
|
||||||
import { head, includes, indexOf, isEmpty, length, mergeLeft, not, pluck } from 'ramda';
|
import { equals, find, head, includes, indexOf, isEmpty, length, not, pluck, propEq } from 'ramda';
|
||||||
import { FC, useCallback, useContext, useLayoutEffect, useMemo, useRef } from 'react';
|
import {
|
||||||
|
FC,
|
||||||
|
KeyboardEvent,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from 'react';
|
||||||
import { useMeasure } from 'react-use';
|
import { useMeasure } from 'react-use';
|
||||||
import { FixedSizeList } from 'react-window';
|
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||||
import { EventBusContext } from '../EventBus';
|
import { EventBusContext } from '../EventBus';
|
||||||
|
import { useDirTreeStore } from '../states/dirs';
|
||||||
import { sortedFilesSelector, useFileListStore } from '../states/files';
|
import { sortedFilesSelector, useFileListStore } from '../states/files';
|
||||||
|
|
||||||
export const FileList: FC = () => {
|
export const FileList: FC = () => {
|
||||||
const files = useFileListStore(sortedFilesSelector());
|
const files = useFileListStore(sortedFilesSelector());
|
||||||
|
const storeFiles = useFileListStore.use.updateFiles();
|
||||||
const activeFiles = useFileListStore.use.actives();
|
const activeFiles = useFileListStore.use.actives();
|
||||||
|
const cwd = useDirTreeStore.use.selected();
|
||||||
|
const directories = useDirTreeStore.use.directories();
|
||||||
const ebus = useContext<EventEmitter>(EventBusContext);
|
const ebus = useContext<EventEmitter>(EventBusContext);
|
||||||
const filesCount = useMemo(() => length(files), [files]);
|
const filesCount = useMemo(() => length(files), [files]);
|
||||||
|
const [editingFile, setEditing] = useState<string | null>(null);
|
||||||
const [parentRef, { height: parentHeight }] = useMeasure();
|
const [parentRef, { height: parentHeight }] = useMeasure();
|
||||||
const listRef = useRef<FixedSizeList | null>();
|
const listRef = useRef<VirtuosoHandle | null>(null);
|
||||||
const handleFileSelectAction = useCallback(
|
const handleFileSelectAction = useCallback(
|
||||||
(filename: string) => {
|
(filename: string) => {
|
||||||
ebus.emit('navigate_offset', { filename });
|
ebus.emit('navigate_offset', { filename });
|
||||||
},
|
},
|
||||||
[ebus]
|
[ebus]
|
||||||
);
|
);
|
||||||
|
const handleFileRenameAction = useCallback(
|
||||||
|
async (fileId: string, event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (equals(event.key, 'Enter')) {
|
||||||
|
const newFileName = event.target.value;
|
||||||
|
const originalFile = find(propEq('id', fileId), files);
|
||||||
|
const storeDirectory = find(propEq('id', cwd), directories);
|
||||||
|
try {
|
||||||
|
await invoke('rename_file', {
|
||||||
|
storePath: storeDirectory?.path,
|
||||||
|
originName: originalFile?.filename,
|
||||||
|
newName: newFileName
|
||||||
|
});
|
||||||
|
const refreshedFiles = await invoke('scan_directory', { target: storeDirectory?.path });
|
||||||
|
storeFiles(refreshedFiles);
|
||||||
|
ebus.emit('reset_views');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[debug]重命名文件:', e);
|
||||||
|
notifications.show({ message: `重命名文件失败,${e}`, color: 'red' });
|
||||||
|
}
|
||||||
|
setEditing(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[files, cwd, directories]
|
||||||
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
let firstActived = head(activeFiles);
|
let firstActived = head(activeFiles);
|
||||||
let firstActivedIndex = indexOf(firstActived, pluck('filename', files));
|
let firstActivedIndex = indexOf(firstActived, pluck('filename', files));
|
||||||
listRef.current?.scrollToItem(firstActivedIndex, 'smart');
|
listRef.current?.scrollToIndex({
|
||||||
|
index: firstActivedIndex,
|
||||||
|
align: 'center'
|
||||||
|
});
|
||||||
}, [activeFiles]);
|
}, [activeFiles]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
w="100%"
|
w="100%"
|
||||||
h="100%"
|
|
||||||
pl={4}
|
pl={4}
|
||||||
sx={{ flexGrow: 1, overflowY: 'auto', contain: 'strict', overflowX: 'hidden' }}
|
sx={{ flexGrow: 1, overflowY: 'auto', contain: 'strict', overflowX: 'hidden' }}
|
||||||
ref={parentRef}
|
ref={parentRef}
|
||||||
>
|
>
|
||||||
{!isEmpty(files) && (
|
{!isEmpty(files) && (
|
||||||
<FixedSizeList itemCount={filesCount} itemSize={35} height={parentHeight} ref={listRef}>
|
<Virtuoso
|
||||||
{({ index, style }) => (
|
style={{ height: parentHeight - 36 }}
|
||||||
|
totalCount={filesCount}
|
||||||
|
ref={listRef}
|
||||||
|
itemContent={index => (
|
||||||
<Box
|
<Box
|
||||||
bg={includes(files[index].filename, activeFiles) && 'grape'}
|
bg={includes(files[index].filename, activeFiles) && 'grape'}
|
||||||
key={index}
|
key={index}
|
||||||
px={4}
|
px={4}
|
||||||
py={2}
|
py={2}
|
||||||
onClick={() => handleFileSelectAction(files[index].filename)}
|
onClick={() => handleFileSelectAction(files[index].filename)}
|
||||||
sx={theme =>
|
onDoubleClick={() => setEditing(files[index].id)}
|
||||||
mergeLeft(style, {
|
sx={theme => ({
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
color: not(includes(files[index].filename, activeFiles)) && theme.colors.red
|
color: not(includes(files[index].filename, activeFiles)) && theme.colors.red
|
||||||
}
|
}
|
||||||
})
|
})}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{files[index].filename}
|
<Tooltip label={files[index].filename} zIndex={999}>
|
||||||
|
{propEq('id', editingFile)(files[index]) ? (
|
||||||
|
<Group>
|
||||||
|
<TextInput
|
||||||
|
defaultValue={files[index].filename}
|
||||||
|
size="xs"
|
||||||
|
variant="unstyled"
|
||||||
|
onKeyDown={event => handleFileRenameAction(files[index].id, event)}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Text truncate>{files[index].filename}</Text>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</FixedSizeList>
|
/>
|
||||||
)}
|
)}
|
||||||
{isEmpty(files) && (
|
{isEmpty(files) && (
|
||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
|
@@ -1,48 +0,0 @@
|
|||||||
//@ts-nocheck
|
|
||||||
import { Button, Group, Tooltip } from '@mantine/core';
|
|
||||||
import { notifications } from '@mantine/notifications';
|
|
||||||
import { IconFolder } from '@tabler/icons-react';
|
|
||||||
import { invoke } from '@tauri-apps/api';
|
|
||||||
import { open } from '@tauri-apps/api/dialog';
|
|
||||||
import EventEmitter from 'events';
|
|
||||||
import { length } from 'ramda';
|
|
||||||
import { FC, useCallback, useContext } from 'react';
|
|
||||||
import { EventBusContext } from '../EventBus';
|
|
||||||
import { useFileListStore } from '../states/files';
|
|
||||||
|
|
||||||
export const FileToolbar: FC = () => {
|
|
||||||
const storeFiles = useFileListStore.use.updateFiles();
|
|
||||||
const ebus = useContext<EventEmitter>(EventBusContext);
|
|
||||||
const handleOpenAction = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const directory = await open({
|
|
||||||
title: '打开要浏览的漫画所在的文件夹',
|
|
||||||
directory: true,
|
|
||||||
multiple: false
|
|
||||||
});
|
|
||||||
const files = await invoke('scan_directory', { target: directory });
|
|
||||||
console.log('[debug]获取到文件个数:', length(files));
|
|
||||||
storeFiles(files);
|
|
||||||
ebus.emit('reset_views');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[error]打开文件夹', e);
|
|
||||||
notifications.show({ title: '未能成功打开指定文件夹,请重试。', color: 'red' });
|
|
||||||
}
|
|
||||||
}, [storeFiles]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group align="start" w="100%" spacing={4}>
|
|
||||||
<Tooltip label="打开漫画所在文件夹">
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
fullWidth
|
|
||||||
leftIcon={<IconFolder stroke={1.5} size={14} />}
|
|
||||||
onClick={handleOpenAction}
|
|
||||||
>
|
|
||||||
打开文件夹
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
@@ -10,8 +10,8 @@ import { useZoomState } from '../states/zoom';
|
|||||||
export const SingleView: FC = () => {
|
export const SingleView: FC = () => {
|
||||||
const files = useFileListStore.use.files();
|
const files = useFileListStore.use.files();
|
||||||
const actives = useFileListStore.use.actives();
|
const actives = useFileListStore.use.actives();
|
||||||
const zoom = useZoomState.use.currentZoom();
|
const { currentZoom: zoom, viewHeight, viewWidth } = useZoomState();
|
||||||
const viewHeight = useZoomState.use.viewHeight();
|
const maxImageWidth = useMemo(() => viewWidth * (zoom / 100), [viewWidth, zoom]);
|
||||||
const updateActives = useFileListStore.use.updateActiveFiles();
|
const updateActives = useFileListStore.use.updateActiveFiles();
|
||||||
const ebus = useContext<EventEmitter>(EventBusContext);
|
const ebus = useContext<EventEmitter>(EventBusContext);
|
||||||
const [pageConRef, { width: pageConWidth }] = useMeasure();
|
const [pageConRef, { width: pageConWidth }] = useMeasure();
|
||||||
@@ -21,9 +21,9 @@ export const SingleView: FC = () => {
|
|||||||
}, [files, actives]);
|
}, [files, actives]);
|
||||||
const largerThanView = useMemo(() => {
|
const largerThanView = useMemo(() => {
|
||||||
if (isNil(activeFile)) return false;
|
if (isNil(activeFile)) return false;
|
||||||
let imageHeightAfterZoom = activeFile?.height * (zoom / 100);
|
let imageHeightAfterZoom = activeFile?.height * (maxImageWidth / activeFile?.width);
|
||||||
return gt(imageHeightAfterZoom, viewHeight);
|
return gt(imageHeightAfterZoom, viewHeight);
|
||||||
}, [activeFile, viewHeight, zoom]);
|
}, [activeFile, viewHeight, maxImageWidth]);
|
||||||
const handlePaginationAction = useCallback(
|
const handlePaginationAction = useCallback(
|
||||||
(event: BaseSyntheticEvent) => {
|
(event: BaseSyntheticEvent) => {
|
||||||
let middle = pageConWidth / 2;
|
let middle = pageConWidth / 2;
|
||||||
@@ -62,7 +62,7 @@ export const SingleView: FC = () => {
|
|||||||
height: largerThanView ? '100%' : viewHeight
|
height: largerThanView ? '100%' : viewHeight
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img src={activeFile.path} style={{ width: `${zoom}%` }} />
|
<img src={activeFile.path} style={{ width: maxImageWidth }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@@ -6,3 +6,13 @@ export type FileItem = {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DirItem = {
|
||||||
|
sort: number;
|
||||||
|
parent?: string;
|
||||||
|
id: string;
|
||||||
|
dirname: string;
|
||||||
|
path: string;
|
||||||
|
root: boolean;
|
||||||
|
expanded: boolean;
|
||||||
|
};
|
||||||
|
25
src/queries/directories.ts
Normal file
25
src/queries/directories.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
//@ts-nocheck
|
||||||
|
import { invoke } from '@tauri-apps/api';
|
||||||
|
import { useDirTreeStore } from '../states/dirs';
|
||||||
|
|
||||||
|
export async function loadDrives() {
|
||||||
|
try {
|
||||||
|
const drives = await invoke('show_drives');
|
||||||
|
const { getState } = useDirTreeStore;
|
||||||
|
getState().updateDrives(drives);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[error]fetch drives', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSubDirectories(target: DirItem) {
|
||||||
|
try {
|
||||||
|
const directories = await invoke('scan_for_child_dirs', { target: target.path });
|
||||||
|
const { getState } = useDirTreeStore;
|
||||||
|
getState().saveDirectories(directories, target.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[error]fetch subdirs', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
142
src/states/dirs.ts
Normal file
142
src/states/dirs.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
//@ts-nocheck
|
||||||
|
import {
|
||||||
|
addIndex,
|
||||||
|
append,
|
||||||
|
compose,
|
||||||
|
equals,
|
||||||
|
filter,
|
||||||
|
find,
|
||||||
|
includes,
|
||||||
|
isNil,
|
||||||
|
map,
|
||||||
|
mergeLeft,
|
||||||
|
not,
|
||||||
|
propEq,
|
||||||
|
reduce,
|
||||||
|
uniq
|
||||||
|
} from 'ramda';
|
||||||
|
import { DirItem } from '../models';
|
||||||
|
import { SyncAction, SyncObjectCallback, SyncParamAction } from '../types';
|
||||||
|
import { createStoreHook } from '../utils/store_creator';
|
||||||
|
|
||||||
|
interface DirsStates {
|
||||||
|
drives: DirItem[];
|
||||||
|
directories: DirItem[];
|
||||||
|
focused?: DirItem;
|
||||||
|
selected?: string;
|
||||||
|
expanded: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type DirsActions = {
|
||||||
|
updateDrives: (dirs: Omit<DirItem, 'sort' | 'parent'>[]) => void;
|
||||||
|
saveDirectories: (dirs: Omit<DirItem, 'sort' | 'parent'>[], parent: string) => void;
|
||||||
|
focus: SyncParamAction<string>;
|
||||||
|
unfocus: SyncAction;
|
||||||
|
selectDirectory: SyncParamAction<string>;
|
||||||
|
unselectDirectory: SyncAction;
|
||||||
|
expandDir: SyncParamAction<string>;
|
||||||
|
foldDir: SyncParamAction<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: DirsStates = {
|
||||||
|
drives: [],
|
||||||
|
directories: [],
|
||||||
|
focused: undefined,
|
||||||
|
selected: undefined,
|
||||||
|
expanded: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDirTreeStore = createStoreHook<DirsStates & DirsActions>((set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
updateDrives(dirs) {
|
||||||
|
set(df => {
|
||||||
|
df.drives = addIndex<Omit<DirItem, 'sort' | 'paraent'>, DirItem>(map)(
|
||||||
|
(item, index) => mergeLeft({ sort: index * 10, path: item.path, parent: undefined }, item),
|
||||||
|
dirs
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
saveDirectories(dirs, parent) {
|
||||||
|
const convertedDirs = addIndex<Omit<DirItem, 'sort' | 'parent'>, DirItem>(map)(
|
||||||
|
(item, index) => mergeLeft({ sort: index * 10, path: item.path, parent }, item),
|
||||||
|
dirs
|
||||||
|
);
|
||||||
|
const premerged = reduce(
|
||||||
|
(acc, elem) => {
|
||||||
|
const dir = find(propEq('id', elem.id), convertedDirs);
|
||||||
|
if (not(isNil(dir))) {
|
||||||
|
acc = append(mergeLeft(dir, elem), acc);
|
||||||
|
} else {
|
||||||
|
acc = append(elem, acc);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
get().directories
|
||||||
|
);
|
||||||
|
const afterMerged = reduce(
|
||||||
|
(acc, elem) => {
|
||||||
|
const dir = find(propEq('id', elem.id), acc);
|
||||||
|
if (isNil(dir)) {
|
||||||
|
return append(elem, acc);
|
||||||
|
} else {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
premerged,
|
||||||
|
convertedDirs
|
||||||
|
);
|
||||||
|
set(df => {
|
||||||
|
df.directories = afterMerged;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
focus(specifiedDirId) {
|
||||||
|
const requestedDir = find(propEq('id', specifiedDirId), get().directories);
|
||||||
|
if (not(isNil(requestedDir))) {
|
||||||
|
set(df => {
|
||||||
|
df.focused = requestedDir;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unfocus() {
|
||||||
|
set(df => {
|
||||||
|
df.focused = undefined;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectDirectory(dirId) {
|
||||||
|
set(df => {
|
||||||
|
df.selected = dirId;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
unselectDirectory() {
|
||||||
|
set(df => {
|
||||||
|
df.selected = undefined;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
expandDir(dirId) {
|
||||||
|
set(df => {
|
||||||
|
df.expanded = uniq(append(dirId, df.expanded));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
foldDir(dirId) {
|
||||||
|
set(df => {
|
||||||
|
df.expanded = filter(compose(not, equals(dirId)), df.expanded);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function currentRootsSelector(): SyncObjectCallback<DirsStates, DirItem[]> {
|
||||||
|
return state => (isNil(state.focused) ? state.drives : [state.focused]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectDirectories(parent: string): SyncObjectCallback<DirItem[], DirItem[]> {
|
||||||
|
return dirs => filter(propEq('parent', parent), dirs) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subDirectoriesSelector(parent: string): SyncObjectCallback<DirsStates, DirItem[]> {
|
||||||
|
return state => filter(propEq('parent', parent), state.directories) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExpandedSelector(dirId: string): SyncObjectCallback<DirsStates, bool> {
|
||||||
|
return state => includes(dirId, state.expanded);
|
||||||
|
}
|
@@ -1,3 +1,4 @@
|
|||||||
|
//@ts-nocheck
|
||||||
import { convertFileSrc } from '@tauri-apps/api/tauri';
|
import { convertFileSrc } from '@tauri-apps/api/tauri';
|
||||||
import { addIndex, map, mergeLeft, sort } from 'ramda';
|
import { addIndex, map, mergeLeft, sort } from 'ramda';
|
||||||
import { FileItem } from '../models';
|
import { FileItem } from '../models';
|
||||||
@@ -24,7 +25,8 @@ export const useFileListStore = createStoreHook<FileListState & FileListActions>
|
|||||||
updateFiles(files) {
|
updateFiles(files) {
|
||||||
set(df => {
|
set(df => {
|
||||||
df.files = addIndex<Omit<FileItem, 'sort'>, FileItem>(map)(
|
df.files = addIndex<Omit<FileItem, 'sort'>, FileItem>(map)(
|
||||||
(item, index) => mergeLeft({ sort: index * 10, path: convertFileSrc(item.path) }, item),
|
(item, index) =>
|
||||||
|
mergeLeft({ sort: index * 10, path: convertFileSrc(item.path, 'comic') }, item),
|
||||||
files
|
files
|
||||||
);
|
);
|
||||||
df.actives = [];
|
df.actives = [];
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
//@ts-nocheck
|
||||||
import { SyncObjectCallback } from '../types';
|
import { SyncObjectCallback } from '../types';
|
||||||
import { createStoreHook } from '../utils/store_creator';
|
import { createStoreHook } from '../utils/store_creator';
|
||||||
|
|
||||||
@@ -6,11 +7,13 @@ interface ZoomState {
|
|||||||
currentZoom: number;
|
currentZoom: number;
|
||||||
viewMode: 'single' | 'continuation';
|
viewMode: 'single' | 'continuation';
|
||||||
viewHeight: number;
|
viewHeight: number;
|
||||||
|
viewWidth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ZoomActions = {
|
type ZoomActions = {
|
||||||
zoom: SyncObjectCallback<number>;
|
zoom: SyncObjectCallback<number>;
|
||||||
updateViewHeight: SyncObjectCallback<number>;
|
updateViewHeight: SyncObjectCallback<number>;
|
||||||
|
updateViewWidth: SyncObjectCallback<number>;
|
||||||
switchViewMode: SyncObjectCallback<'single' | 'continuation'>;
|
switchViewMode: SyncObjectCallback<'single' | 'continuation'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -18,7 +21,8 @@ const initialState: ZoomState = {
|
|||||||
autoFit: false,
|
autoFit: false,
|
||||||
currentZoom: 80,
|
currentZoom: 80,
|
||||||
viewMode: 'continuation',
|
viewMode: 'continuation',
|
||||||
viewHeight: 0
|
viewHeight: 0,
|
||||||
|
viewWidth: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useZoomState = createStoreHook<ZoomState & ZoomActions>(set => ({
|
export const useZoomState = createStoreHook<ZoomState & ZoomActions>(set => ({
|
||||||
@@ -33,6 +37,11 @@ export const useZoomState = createStoreHook<ZoomState & ZoomActions>(set => ({
|
|||||||
df.viewHeight = height;
|
df.viewHeight = height;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
updateViewWidth(width) {
|
||||||
|
set(df => {
|
||||||
|
df.viewWidth = width;
|
||||||
|
});
|
||||||
|
},
|
||||||
switchViewMode(mode) {
|
switchViewMode(mode) {
|
||||||
set(df => {
|
set(df => {
|
||||||
df.viewMode = mode;
|
df.viewMode = mode;
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
//@ts-nocheck
|
||||||
import { and, gt, lt } from 'ramda';
|
import { and, gt, lt } from 'ramda';
|
||||||
|
|
||||||
export function withinRange(
|
export function withinRange(
|
||||||
|
Reference in New Issue
Block a user