Compare commits

...

36 Commits

Author SHA1 Message Date
徐涛
5c092cad17 fix(list):调整文件夹树布局。 2023-03-27 18:47:15 +08:00
徐涛
f38165d8d2 fix(app):修复自动设置窗口标题的功能。 2023-03-24 09:29:20 +08:00
徐涛
49a800fb4b build(version):更改发布版本号。 2023-03-23 16:02:03 +08:00
徐涛
45cbcaf95f enhance(list):文件夹列表与文件列表改用自然数字排序。 2023-03-23 16:01:10 +08:00
徐涛
8fab1a2c74 build(deps):增加正则表达式支持库。 2023-03-23 15:58:57 +08:00
徐涛
17e8bda099 build(version):更新版本号。 2023-03-23 10:01:24 +08:00
徐涛
ea19698036 fix(list):调整文件列表样式,使之可以列出文件列表的最后一项。 2023-03-23 09:58:52 +08:00
徐涛
0eb69d777d feat(file):增加修改文件名称的功能。 2023-03-23 09:52:26 +08:00
徐涛
65943e33b9 enhance(view):调整虚拟列表滚动设置,避免卡顿。 2023-03-23 08:47:03 +08:00
徐涛
6d4dd4de06 build(config):修改Tauri配置。 2023-03-22 14:41:48 +08:00
徐涛
a6f043e0d1 build(action):更新自动编译Action. 2023-03-22 14:30:01 +08:00
徐涛
50ba85afa3 build(action):测试精简的Actions. 2023-03-22 14:13:50 +08:00
徐涛
7331f42e56 build(version):更新版本号。 2023-03-22 14:06:00 +08:00
徐涛
36e0c7714c enhance(list):聚焦时可以自动展开被聚焦的文件夹。 2023-03-22 14:01:01 +08:00
徐涛
c63da5222e enhance(fs):优化文件夹扫描。 2023-03-22 09:25:21 +08:00
徐涛
32e797f35d enhance(debug):去掉一些不再使用的调试语句。 2023-03-22 08:40:30 +08:00
徐涛
d7aeb00212 fix(list):修复Windows中盘符显示。 2023-03-21 17:24:53 +08:00
徐涛
45844453b4 fix(list):继续尝试修复。 2023-03-21 17:18:01 +08:00
徐涛
a6ac162bfd fix(list):更改Windows环境下的盘符获取。 2023-03-21 17:15:11 +08:00
徐涛
02f8e70a71 fix(list):尝试修复磁盘列表。 2023-03-21 17:07:04 +08:00
徐涛
32ad1017ad chore(git):增加屏蔽文件。 2023-03-21 16:46:20 +08:00
徐涛
17a4b3fda1 fix(build):调整Cargo编译配置。 2023-03-21 16:45:42 +08:00
徐涛
0697450604 build(action):Action中使用的Rust改为nightly版本。 2023-03-21 16:36:50 +08:00
徐涛
fed1ed9265 fix(build):屏蔽TS编译问题。 2023-03-21 15:45:27 +08:00
徐涛
fea1c9e6e5 build(action):尝试加入手动激活的Alpha编译Action。 2023-03-21 15:37:16 +08:00
徐涛
0469f18ef6 enhance(list):增加文件夹聚焦功能。 2023-03-21 14:53:04 +08:00
徐涛
8024738334 enhance(view):调整窗口宽度与浏览侧栏宽度。 2023-03-21 13:57:03 +08:00
徐涛
2fc2bd7185 enhance(view):调整宽高的计算取整,消除可能存在的黑线。 2023-03-21 13:55:56 +08:00
徐涛
3e6b89ab39 fix(view):更换虚拟列表支持库,以解决缩放存在的问题。 2023-03-21 13:49:24 +08:00
徐涛
925b055318 fix(fs):修复失误列举文件的问题。 2023-03-21 12:52:17 +08:00
徐涛
0685e7f4e1 enhance(file):调整文件列表的文字样式。 2023-03-21 11:21:38 +08:00
徐涛
a80e8eb74f fix(view):修复单页浏览时缩放计算布局错误的问题。 2023-03-21 10:49:02 +08:00
徐涛
39accb3cb7 enhance(fs):删除Walkdir依赖,改用标准库实现。 2023-03-21 08:35:43 +08:00
徐涛
6609d64752 enhance(open):删除原有的打开文件夹功能。 2023-03-21 06:22:48 +08:00
徐涛
c12a0075e7 enhance(fs):缩短文件读取流程,减少内存复制。 2023-03-21 06:13:20 +08:00
徐涛
48fbe067a0 feat(fs):通过实现自定义协议支持图片的读取。 2023-03-20 22:23:22 +08:00
28 changed files with 532 additions and 319 deletions

56
.github/workflows/alpha-release.yml vendored Normal file
View 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

View File

@@ -5,39 +5,7 @@ on:
- 'v*' - 'v*'
jobs: jobs:
create-release:
permissions:
contents: write
runs-on: ubuntu-20.04
outputs:
release_id: ${{ steps.create-release.outputs.result }}
steps:
- uses: actions/checkout@v3
- name: setup node
uses: actions/setup-node@v3
with:
node-version: 16
- name: get version
run: echo "PACKAGE_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
- name: create release
id: create-release
uses: actions/github-script@v6
with:
script: |
const { data } = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: `app-v${process.env.PACKAGE_VERSION}`,
name: `漫画阅读器 v${process.env.PACKAGE_VERSION}`,
body: '从附件中下载对应平台的安装包以及应用。',
draft: true,
prerelease: false
})
return data.id
build-tauri: build-tauri:
needs: create-release
permissions: permissions:
contents: write contents: write
strategy: strategy:
@@ -47,48 +15,44 @@ jobs:
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
steps: steps:
- uses: actions/checkout@v3 - 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 - name: setup node
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: 16
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable - name: install Rust nightly
uses: dtolnay/rust-toolchain@nightly
- name: install dependencies (ubuntu only) - name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-20.04' if: matrix.platform == 'ubuntu-20.04'
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v2
with: with:
version: latest-7 version: latest-7
- name: install frontend dependencies - name: install frontend dependencies
run: pnpm install # change this to npm or pnpm depending on which one you use run: pnpm install # change this to npm or pnpm depending on which one you use
- uses: tauri-apps/tauri-action@v0
- name: build app
uses: tauri-apps/tauri-action@v0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
tauriScript: pnpm tauri tauriScript: pnpm tauri
releaseId: ${{ needs.create-release.outputs.release_id }} 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.
publish-release: releaseBody: '从附件中下载对应平台的安装包以及应用。'
permissions: releaseDraft: true
contents: write prerelease: false
runs-on: ubuntu-20.04
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
})

2
.gitignore vendored
View File

@@ -22,3 +22,5 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
**/src-tauri/.cargo/

View File

@@ -1,7 +1,7 @@
{ {
"name": "comic_viewer", "name": "comic_viewer",
"private": true, "private": true,
"version": "0.2.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
View File

@@ -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

View File

@@ -1,8 +0,0 @@
[target.'cfg(target_os = "linux")']
rustflags = ["-C", "link-arg=-nostartfiles"]
[target.'cfg(target_os = "windows")']
rustflags = ["-C", "link-args=/ENTRY:_start /SUBSYSTEM:console"]
[target.'cfg(target_os = "macos")']
rustflags = ["-C", "link-args=-e __start -static -nostartfiles"]

45
src-tauri/Cargo.lock generated
View File

@@ -326,14 +326,16 @@ dependencies = [
[[package]] [[package]]
name = "comic_viewer" name = "comic_viewer"
version = "0.2.0" version = "0.2.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"image", "image",
"md-5", "md-5",
"mime_guess",
"mountpoints", "mountpoints",
"once_cell", "once_cell",
"regex",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@@ -341,8 +343,8 @@ dependencies = [
"tauri-build", "tauri-build",
"thiserror", "thiserror",
"tokio", "tokio",
"urlencoding",
"uuid 1.3.0", "uuid 1.3.0",
"walkdir",
] ]
[[package]] [[package]]
@@ -1616,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"
@@ -2268,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",
@@ -2288,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"
@@ -3207,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"
@@ -3252,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"

View File

@@ -1,10 +1,10 @@
[package] [package]
name = "comic_viewer" name = "comic_viewer"
version = "0.2.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,19 +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-exists", "fs-read-dir", "fs-read-file", "protocol-asset", "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" mountpoints = "0.2.1"
md-5 = "0.10.5" 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

View File

@@ -1,14 +1,24 @@
use std::path::Path; use std::{
fs::{self, DirEntry},
path::{Path, PathBuf},
};
use anyhow::anyhow;
use mountpoints::mountinfos; use mountpoints::mountinfos;
use regex::Regex;
use serde::Serialize; use serde::Serialize;
use tauri::Runtime; use tauri::Runtime;
use walkdir::{DirEntry, WalkDir};
use crate::utils; use crate::utils;
#[derive(Debug, Clone, Serialize)] 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,
@@ -17,7 +27,19 @@ pub struct FileItem {
pub width: u32, pub width: u32,
} }
#[derive(Debug, Clone, Serialize)] 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 struct DirItem {
pub id: String, pub id: String,
pub dirname: String, pub dirname: String,
@@ -25,6 +47,18 @@ pub struct DirItem {
pub root: bool, 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 { fn is_hidden(entry: &DirEntry) -> bool {
entry entry
.file_name() .file_name()
@@ -33,10 +67,6 @@ fn is_hidden(entry: &DirEntry) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
fn is_self<P: AsRef<Path>>(entry: &DirEntry, target: P) -> bool {
entry.path().eq(target.as_ref())
}
fn is_root(entry: &DirEntry) -> bool { fn is_root(entry: &DirEntry) -> bool {
entry entry
.path() .path()
@@ -47,44 +77,55 @@ fn is_root(entry: &DirEntry) -> bool {
#[tauri::command] #[tauri::command]
pub async 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();
.max_depth(1) for entry in std::fs::read_dir(target).map_err(|e| format!("无法读取指定文件夹,{}", e))?
.into_iter() {
.filter_entry(|entry| !is_hidden(entry)) let entry = entry.map_err(|e| format!("无法获取指定文件夹信息,{}", e))?;
.filter_map(|f| f.ok()) if !entry.path().is_file() {
.filter(|f| f.path().is_file()) continue;
.map(|f| { }
let (width, height) = image::image_dimensions(f.path())?; let image_detect_result = image::image_dimensions(entry.path());
let file_hash_id = f if image_detect_result.is_err() {
.path() println!("读取图片文件 {} 元信息失败。", entry.path().display());
.to_str() continue;
.map(crate::utils::md5_hash) }
.map(|hash| utils::uuid_from(hash.as_slice())) let (width, height) = image_detect_result.unwrap();
.transpose() let file_hash_id = entry
.map_err(|e| anyhow!(e))? .path()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); .to_str()
Ok(FileItem { .map(crate::utils::md5_hash)
id: file_hash_id, .map(|hash| utils::uuid_from(hash.as_slice()))
filename: f .transpose()?
.path() .unwrap_or(uuid::Uuid::new_v4().to_string());
.file_name() let filename = entry
.ok_or(anyhow!("不能获取到文件名。"))? .path()
.to_owned() .file_name()
.into_string() .ok_or(String::from("不能获取到文件名。"))
.unwrap(), .map(ToOwned::to_owned)
path: f .map(|s| s.into_string().unwrap());
.path() if filename.is_err() {
.clone() println!("不能获取到文件 {} 文件名。", entry.path().display());
.to_str() continue;
.ok_or(anyhow!("不能获取到文件路径。"))? }
.to_string(), let path = entry
width, .path()
height, .clone()
}) .to_str()
}) .ok_or(String::from("不能获取到文件路径。"))
.collect::<Result<Vec<FileItem>, anyhow::Error>>() .map(ToString::to_string);
.map_err(|e| e.to_string())?; if path.is_err() {
file_items.sort_by(|a, b| a.filename.partial_cmp(&b.filename).unwrap()); println!("不能获取到文件 {} 路径。", entry.path().display());
continue;
}
file_items.push(FileItem {
id: file_hash_id,
filename: filename.unwrap(),
path: path.unwrap(),
height,
width,
});
}
file_items.sort_by(|a, b| a.partial_cmp(&b).unwrap());
Ok(file_items) Ok(file_items)
} }
@@ -102,6 +143,7 @@ pub async fn show_drives<R: Runtime>(
.iter() .iter()
.filter(|m| !m.path.starts_with("/System") && !m.path.starts_with("/dev")); .filter(|m| !m.path.starts_with("/System") && !m.path.starts_with("/dev"));
for mount in mounts { for mount in mounts {
#[cfg(any(target_os = "macos", target_os = "linux"))]
let dirname = mount let dirname = mount
.path .path
.as_path() .as_path()
@@ -110,6 +152,8 @@ pub async fn show_drives<R: Runtime>(
.to_os_string() .to_os_string()
.into_string() .into_string()
.unwrap(); .unwrap();
#[cfg(target_os = "windows")]
let dirname = mount.path.display().to_string();
let dirname = if dirname.len() == 0 { let dirname = if dirname.len() == 0 {
String::from("/") String::from("/")
} else { } else {
@@ -147,42 +191,55 @@ pub async fn scan_for_child_dirs<R: Runtime>(
} else { } else {
Path::new(&target) Path::new(&target)
}; };
let mut child_dirs = WalkDir::new(target)
.max_depth(1) let mut child_dirs: Vec<DirItem> = Vec::new();
.into_iter() for entry in std::fs::read_dir(target).map_err(|e| format!("无法读取指定文件夹,{}", e))?
.filter_entry(|entry| !is_hidden(entry) && !is_root(entry)) {
.filter_map(|d| d.ok()) let entry = entry.map_err(|e| format!("无法获取指定文件夹信息,{}", e))?;
.filter(|d| d.path().is_dir() && !is_self(d, target)) if is_hidden(&entry) || is_root(&entry) || entry.path().is_file() {
.map(|d| { continue;
println!("扫描到的文件夹:{}", d.path().display()); }
let dir_hash_id = d let dir_hash_id = entry
.path() .path()
.to_str() .to_str()
.map(crate::utils::md5_hash) .map(crate::utils::md5_hash)
.map(|hash| utils::uuid_from(hash.as_slice())) .map(|hash| utils::uuid_from(hash.as_slice()))
.transpose() .transpose()?
.map_err(|e| anyhow!(e))? .unwrap_or(uuid::Uuid::new_v4().to_string());
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); let dirname = entry
Ok(DirItem { .path()
id: dir_hash_id, .file_name()
dirname: d .ok_or(String::from("不能获取到文件夹的名称。"))?
.path() .to_owned()
.file_name() .into_string()
.ok_or(anyhow!("不能获取到文件夹的名称。"))? .unwrap();
.to_owned() let path = entry
.into_string() .path()
.unwrap(), .clone()
path: d .to_str()
.path() .ok_or(String::from("不能获取到文件夹路径。"))?
.clone() .to_string();
.to_str() child_dirs.push(DirItem {
.ok_or(anyhow!("不能获取到文件夹路径。"))? id: dir_hash_id,
.to_string(), dirname,
root: false, path,
}) root: false,
}) });
.collect::<Result<Vec<DirItem>, anyhow::Error>>() }
.map_err(|e| e.to_string())?; child_dirs.sort_by(|a, b| a.partial_cmp(&b).unwrap());
child_dirs.sort_by(|a, b| a.dirname.partial_cmp(&b.dirname).unwrap());
Ok(child_dirs) 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(())
}

View File

@@ -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))?;

View File

@@ -2,17 +2,21 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod commands; mod commands;
mod protocol;
mod utils; 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()
.register_uri_scheme_protocol("comic", comic_protocol)
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::prelude::scan_directory, commands::prelude::scan_directory,
commands::prelude::show_drives, commands::prelude::show_drives,
commands::prelude::scan_for_child_dirs 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();

42
src-tauri/src/protocol.rs Normal file
View 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)
}

View File

@@ -7,8 +7,8 @@
"withGlobalTauri": false "withGlobalTauri": false
}, },
"package": { "package": {
"productName": "漫画阅读器", "productName": "comic_viewer",
"version": "0.2.0" "version": "../package.json"
}, },
"tauri": { "tauri": {
"allowlist": { "allowlist": {
@@ -29,11 +29,7 @@
"writeFile": false "writeFile": false
}, },
"protocol": { "protocol": {
"all": false, "all": true
"asset": true,
"assetScope": [
"*"
]
}, },
"shell": { "shell": {
"all": false, "all": false,
@@ -66,7 +62,7 @@
} }
}, },
"security": { "security": {
"csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost" "csp": null
}, },
"updater": { "updater": {
"active": false "active": false
@@ -76,7 +72,7 @@
"fullscreen": false, "fullscreen": false,
"resizable": true, "resizable": true,
"title": "漫画阅读器", "title": "漫画阅读器",
"width": 1000, "width": 1200,
"height": 800 "height": 800
} }
] ]

View File

@@ -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>

View File

@@ -1,3 +1,4 @@
//@ts-nocheck
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { createContext } from 'react'; import { createContext } from 'react';

View File

@@ -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';

View File

@@ -7,7 +7,6 @@ import { FC, useMemo } from 'react';
import { useMount } from 'react-use'; import { useMount } from 'react-use';
import { DirTree } from './components/DirTree'; 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'; import { loadDrives } from './queries/directories';
const bgSelectFn = ifElse( const bgSelectFn = ifElse(
@@ -34,7 +33,8 @@ export const NavMenu: FC = () => {
return ( return (
<Stack <Stack
spacing={8} spacing={8}
miw={220} w={300}
mw={200}
h="inherit" h="inherit"
sx={theme => ({ sx={theme => ({
flexGrow: 1, flexGrow: 1,
@@ -59,7 +59,6 @@ export const NavMenu: FC = () => {
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="files" h="100%"> <Tabs.Panel value="files" h="100%">
<Stack spacing={8} py={4} w="100%" h="100%" align="center"> <Stack spacing={8} py={4} w="100%" h="100%" align="center">
<FileToolbar />
<FileList /> <FileList />
</Stack> </Stack>
</Tabs.Panel> </Tabs.Panel>

View File

@@ -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}>

View File

@@ -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>
); );

View File

@@ -1,18 +1,20 @@
//@ts-nocheck
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { ActionIcon, Flex, Stack, Text, Tooltip } from '@mantine/core'; import { ActionIcon, Box, Flex, Stack, Text, Tooltip } from '@mantine/core';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { IconSquareMinus, IconSquarePlus } from '@tabler/icons-react'; import { IconEye, IconSquareMinus, IconSquarePlus } from '@tabler/icons-react';
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import EventEmitter from 'events'; import EventEmitter from 'events';
import { equals, isEmpty, length, map, not } from 'ramda'; import { equals, isEmpty, isNil, map, not } from 'ramda';
import { FC, PropsWithChildren, useCallback, useContext, useState } from 'react'; import { FC, PropsWithChildren, useCallback, useContext } from 'react';
import { useLifecycles, useMeasure } from 'react-use';
import { EventBusContext } from '../EventBus'; import { EventBusContext } from '../EventBus';
import { DirItem } from '../models'; import { DirItem } from '../models';
import { loadSubDirectories } from '../queries/directories'; import { loadSubDirectories } from '../queries/directories';
import { import {
currentRootsSelector, currentRootsSelector,
isExpandedSelector, isExpandedSelector,
selectDirectories, subDirectoriesSelector,
useDirTreeStore useDirTreeStore
} from '../states/dirs'; } from '../states/dirs';
import { useFileListStore } from '../states/files'; import { useFileListStore } from '../states/files';
@@ -29,12 +31,9 @@ const Tree = styled.ul`
} }
`; `;
const Branch: FC<PropsWithChildren<{ current: DirItem; expanded: boolean }>> = ({ const Branch: FC<PropsWithChildren<{ current: DirItem }>> = ({ children, current }) => {
children,
current
}) => {
const { directories: allSubDirs } = useDirTreeStore(); const { directories: allSubDirs } = useDirTreeStore();
const [subDirs, setSubDirs] = useState<DirItem[]>([]); const subDirs = useDirTreeStore(subDirectoriesSelector(current.id));
const isCurrentExpanded = useDirTreeStore(isExpandedSelector(current.id)); const isCurrentExpanded = useDirTreeStore(isExpandedSelector(current.id));
const expend = useDirTreeStore.use.expandDir(); const expend = useDirTreeStore.use.expandDir();
const fold = useDirTreeStore.use.foldDir(); const fold = useDirTreeStore.use.foldDir();
@@ -43,13 +42,24 @@ const Branch: FC<PropsWithChildren<{ current: DirItem; expanded: boolean }>> = (
const storeFiles = useFileListStore.use.updateFiles(); const storeFiles = useFileListStore.use.updateFiles();
const ebus = useContext<EventEmitter>(EventBusContext); 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 () => { const handleExpandAction = useCallback(async () => {
try { try {
if (isCurrentExpanded) { if (isCurrentExpanded) {
fold(current.id); fold(current.id);
} else { } else {
await loadSubDirectories(current); await loadSubDirectories(current);
setSubDirs(selectDirectories(current.id)(useDirTreeStore.getState().directories));
expend(current.id); expend(current.id);
} }
} catch (e) { } catch (e) {
@@ -63,7 +73,6 @@ const Branch: FC<PropsWithChildren<{ current: DirItem; expanded: boolean }>> = (
try { try {
selectDir(current.id); selectDir(current.id);
const files = await invoke('scan_directory', { target: current.path }); const files = await invoke('scan_directory', { target: current.path });
console.log('[debug]获取到文件个数:', length(files));
storeFiles(files); storeFiles(files);
ebus.emit('reset_views'); ebus.emit('reset_views');
} catch (e) { } catch (e) {
@@ -112,9 +121,45 @@ const Branch: FC<PropsWithChildren<{ current: DirItem; expanded: boolean }>> = (
export const DirTree: FC = () => { export const DirTree: FC = () => {
const roots = useDirTreeStore(currentRootsSelector()); 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 ( return (
<Stack w="auto" h="100%" spacing={8} px={4} py={4} sx={{ overflow: 'auto' }}> <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> <Tree>
{map( {map(
item => ( item => (

View File

@@ -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%">

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -1,3 +1,4 @@
//@ts-nocheck
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import { useDirTreeStore } from '../states/dirs'; import { useDirTreeStore } from '../states/dirs';

View File

@@ -1,3 +1,4 @@
//@ts-nocheck
import { import {
addIndex, addIndex,
append, append,
@@ -22,7 +23,7 @@ interface DirsStates {
drives: DirItem[]; drives: DirItem[];
directories: DirItem[]; directories: DirItem[];
focused?: DirItem; focused?: DirItem;
selected?: DirItem; selected?: string;
expanded: string[]; expanded: string[];
} }
@@ -99,7 +100,7 @@ export const useDirTreeStore = createStoreHook<DirsStates & DirsActions>((set, g
}, },
unfocus() { unfocus() {
set(df => { set(df => {
df.focus = undefined; df.focused = undefined;
}); });
}, },
selectDirectory(dirId) { selectDirectory(dirId) {

View File

@@ -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 = [];

View File

@@ -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;

View File

@@ -1,3 +1,4 @@
//@ts-nocheck
import { and, gt, lt } from 'ramda'; import { and, gt, lt } from 'ramda';
export function withinRange( export function withinRange(