Basic Picture Preview Impl
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
18
Req.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# 项目架构
|
||||
使用 tauri 作为 GUI 框架,前端使用TypeScript语言,UI使用React框架,使用组件库开发比较好看的UI(例如shadcn/ui、Ant Design等)。
|
||||
包管理工具使用 yarn。
|
||||
使用msg_tool作为GalGame游戏资源的解析库,注意msg_tool目前无法在wasm上工作(依赖std::io),所以必须在本地环境中运行,不要在前端中尝试使用。
|
||||
使用Rust2024。
|
||||
|
||||
# 基础界面设计
|
||||
左侧一个类似资源管理器一样的列表式结构,用于显示文件夹或者归档中某个文件夹中的内容。
|
||||
右侧显示选中项的详细信息,或者预览选中项的内容。
|
||||
对于文本资源,右侧显示文本内容。
|
||||
对于图片资源,右侧显示图片内容。图片内容要求支持使用鼠标滚轮、触控板、触摸屏等进行缩放,支持拖动进行平移。
|
||||
对于音频资源,右侧显示一个播放器,可以播放音频内容。
|
||||
对于左侧列表部分,上面显示一个导航栏,包含前进、后退、上一级以及可编辑的当前路径输入框。
|
||||
对于归档文件,按文件夹结构显示层级。
|
||||
使用双击来打开文件夹或者归档,单击来选中项。
|
||||
启动时默认打开上次关闭时所在的目录(如果上次关闭时是在归档内,则打开该归档所在的目录),如果该目录不存在或者是首次启动,则打开程序所在目录。
|
||||
|
||||
归档中的文件路径表达方式 采用 `归档文件路径|归档内路径`的形式,例如 `game.dat|assets/image.png`。
|
||||
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + React + Typescript</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "garbro-rs",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"antd": "^6.3.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3"
|
||||
},
|
||||
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
6
public/tauri.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
7
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
7059
src-tauri/Cargo.lock
generated
Normal file
30
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "garbro-rs"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["lifegpc"]
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "garbro_rs_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
encoding = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "bmp", "tiff", "ico", "webp"] }
|
||||
lazy_static = "1.5"
|
||||
msg_tool = { path = "../../MsgTool" }
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
10
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/[email protected]
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
620
src-tauri/src/backend.rs
Normal file
@@ -0,0 +1,620 @@
|
||||
use anyhow::Result;
|
||||
use msg_tool::scripts::base::ReadSeek;
|
||||
use msg_tool::scripts::{BUILDER, ScriptBuilder};
|
||||
use msg_tool::types::{ExtraConfig, ImageOutputType, ScriptType};
|
||||
use msg_tool::utils::img::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref ENTRY_TYPE_CACHE: Mutex<BTreeMap<ScriptType, EntryType>> = Mutex::new(BTreeMap::new());
|
||||
}
|
||||
|
||||
fn query_entry_type(script_type: &ScriptType) -> EntryType {
|
||||
let mut cache = ENTRY_TYPE_CACHE.lock().unwrap();
|
||||
if let Some(entry_type) = cache.get(script_type) {
|
||||
return entry_type.clone();
|
||||
}
|
||||
let entry_type = if script_type.is_audio() {
|
||||
EntryType::Audio
|
||||
} else {
|
||||
let builder = BUILDER
|
||||
.iter()
|
||||
.find(|b| b.script_type() == script_type)
|
||||
.unwrap_or_else(|| panic!("不支持的文件格式: {:?}", script_type));
|
||||
builder.entry_type()
|
||||
};
|
||||
cache.insert(script_type.clone(), entry_type.clone());
|
||||
entry_type
|
||||
}
|
||||
|
||||
/// 到时候可能考虑把识别写到msg_tool那里
|
||||
trait ScriptTypeExt {
|
||||
fn is_audio(&self) -> bool;
|
||||
}
|
||||
|
||||
impl ScriptTypeExt for ScriptType {
|
||||
fn is_audio(&self) -> bool {
|
||||
matches!(self, ScriptType::BGIAudio | ScriptType::CircusPcm)
|
||||
}
|
||||
}
|
||||
|
||||
trait ScriptBuilderExt {
|
||||
fn entry_type(&self) -> EntryType;
|
||||
}
|
||||
|
||||
impl<T: ScriptBuilder + ?Sized> ScriptBuilderExt for T {
|
||||
fn entry_type(&self) -> EntryType {
|
||||
if self.is_image() {
|
||||
EntryType::Image
|
||||
} else if self.is_archive() {
|
||||
EntryType::Archive
|
||||
} else if self.script_type().is_audio() {
|
||||
EntryType::Audio
|
||||
} else {
|
||||
EntryType::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum EntryType {
|
||||
Archive,
|
||||
Text,
|
||||
Image,
|
||||
Audio,
|
||||
Folder,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct Entry {
|
||||
name: String,
|
||||
is_dir: bool,
|
||||
entry_type: EntryType,
|
||||
msg_tool_type: Option<ScriptType>,
|
||||
/// 归档中目前还不支持
|
||||
size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct GameTitle {
|
||||
name: String,
|
||||
alias: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Clone)]
|
||||
pub struct Xp3Option {
|
||||
/// 设置游戏标题,用于解密xp3文件
|
||||
game_title: Option<String>,
|
||||
/// 强制解密,部分xp3需要该参数才能正确解密
|
||||
force_decrypt: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Clone)]
|
||||
pub struct FileOptions {
|
||||
xp3: Option<Xp3Option>,
|
||||
}
|
||||
|
||||
impl FileOptions {
|
||||
fn to_extra_config(&self) -> ExtraConfig {
|
||||
let mut config = ExtraConfig::default();
|
||||
if let Some(xp3) = &self.xp3 {
|
||||
config.xp3_game_title = xp3.game_title.clone();
|
||||
if config.xp3_game_title.is_some() {
|
||||
config.xp3_force_decrypt = xp3.force_decrypt;
|
||||
}
|
||||
}
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub enum ErrorType {
|
||||
NotFound,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct ErrorMsg {
|
||||
typ: ErrorType,
|
||||
msg: String,
|
||||
}
|
||||
|
||||
pub fn get_last_directory(app: &AppHandle) -> Result<String> {
|
||||
let path = app.path().app_data_dir()?.join("last_directory.txt");
|
||||
let dir = std::fs::read_to_string(path)?.trim().to_string();
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
/// 获取启动时默认打开的目录
|
||||
pub fn get_start_directory(app: AppHandle) -> String {
|
||||
if let Ok(dir) = get_last_directory(&app) {
|
||||
if std::path::Path::new(&dir).exists() {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
// 尝试获取上次关闭时的目录
|
||||
std::env::current_dir()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|p| p.to_string_lossy().to_string()))
|
||||
.unwrap_or_else(|| ".".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn detect_file_type(filename: &str, data: &[u8]) -> (EntryType, Option<ScriptType>) {
|
||||
if data.starts_with(PNG_SIGNATURE) {
|
||||
return (EntryType::Image, None);
|
||||
}
|
||||
let filenames = filename.to_lowercase();
|
||||
let mut exts_builder = Vec::new();
|
||||
for builder in BUILDER.iter() {
|
||||
let exts = builder.extensions();
|
||||
for ext in exts {
|
||||
if filenames.ends_with(ext) {
|
||||
exts_builder.push(builder);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let exts_builder = if exts_builder.is_empty() {
|
||||
BUILDER.iter().collect::<Vec<_>>()
|
||||
} else {
|
||||
exts_builder
|
||||
};
|
||||
if exts_builder.len() == 1 {
|
||||
let builder = exts_builder[0];
|
||||
return (builder.entry_type(), Some(builder.script_type().clone()));
|
||||
}
|
||||
let mut scores = Vec::new();
|
||||
for builder in exts_builder.iter() {
|
||||
if let Some(score) = builder.is_this_format(filename, &data, data.len()) {
|
||||
scores.push((score, builder));
|
||||
}
|
||||
}
|
||||
if !scores.is_empty() {
|
||||
let max_score = scores.iter().map(|s| s.0).max().unwrap();
|
||||
let mut best_builders = Vec::new();
|
||||
for (score, builder) in scores.iter() {
|
||||
if *score == max_score {
|
||||
best_builders.push(builder);
|
||||
}
|
||||
}
|
||||
if best_builders.len() == 1 {
|
||||
let builder = best_builders[0];
|
||||
return (builder.entry_type(), Some(builder.script_type().clone()));
|
||||
}
|
||||
}
|
||||
(EntryType::Unknown, None)
|
||||
}
|
||||
|
||||
fn list_fs_directory(path: &Path) -> Result<Vec<Entry>> {
|
||||
let mut result = Vec::new();
|
||||
for entry in std::fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
let metadata = entry.metadata()?;
|
||||
let is_dir = metadata.is_dir();
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let (entry_type, msg_tool_type) = if is_dir {
|
||||
(EntryType::Folder, None)
|
||||
} else {
|
||||
let mut file = std::fs::File::open(entry.path())?;
|
||||
let mut buffer = [0; 1024];
|
||||
let n = file.read(&mut buffer)?;
|
||||
detect_file_type(&name, &buffer[..n])
|
||||
};
|
||||
let size = if is_dir { None } else { Some(metadata.len()) };
|
||||
result.push(Entry {
|
||||
name,
|
||||
is_dir,
|
||||
entry_type,
|
||||
msg_tool_type,
|
||||
size,
|
||||
});
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn list_archive_directory(path: &Path, option: Option<&Vec<FileOptions>>) -> Result<Vec<Entry>> {
|
||||
let option = option
|
||||
.and_then(|opts| opts.get(0).cloned())
|
||||
.unwrap_or_default();
|
||||
let mut header = [0; 1024];
|
||||
let n = {
|
||||
let mut file = File::open(path)?;
|
||||
file.read(&mut header)?
|
||||
};
|
||||
let (entry_type, msg_tool_type) = detect_file_type(&path.to_string_lossy(), &header[..n]);
|
||||
if entry_type != EntryType::Archive {
|
||||
return Err(anyhow::anyhow!("不是归档文件"));
|
||||
}
|
||||
let script_type = msg_tool_type.ok_or_else(|| anyhow::anyhow!("无法识别的归档格式"))?;
|
||||
let builder = BUILDER
|
||||
.iter()
|
||||
.find(|b| b.script_type() == &script_type)
|
||||
.ok_or_else(|| anyhow::anyhow!("不支持的归档格式"))?;
|
||||
let extra_config = option.to_extra_config();
|
||||
let encoding = builder.default_encoding();
|
||||
let archive_encoding = builder.default_archive_encoding().unwrap_or(encoding);
|
||||
let archive = builder.build_script_from_file(
|
||||
&path.to_string_lossy(),
|
||||
encoding,
|
||||
archive_encoding,
|
||||
&extra_config,
|
||||
None,
|
||||
)?;
|
||||
let mut result = Vec::new();
|
||||
let mut index = 0;
|
||||
for entry in archive.iter_archive_filename()? {
|
||||
let name = entry?;
|
||||
let mut entry = archive.open_file(index)?;
|
||||
index += 1;
|
||||
let (entry_type, msg_tool_type) = if let Some(typ) = entry.script_type() {
|
||||
let entry_type = if typ.is_audio() {
|
||||
EntryType::Audio
|
||||
} else {
|
||||
query_entry_type(&typ)
|
||||
};
|
||||
(entry_type, Some(typ.clone()))
|
||||
} else {
|
||||
let mut buffer = [0; 1024];
|
||||
let n = entry.read(&mut buffer)?;
|
||||
detect_file_type(&name, &buffer[..n])
|
||||
};
|
||||
// 扁平结构,不区分文件夹,前端根据路径解析出文件夹结构
|
||||
result.push(Entry {
|
||||
name,
|
||||
is_dir: false,
|
||||
entry_type,
|
||||
msg_tool_type,
|
||||
size: None,
|
||||
});
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn list_archive_directory_in_archive<'a>(
|
||||
path: &str,
|
||||
mut reader: Box<dyn ReadSeek + 'a>,
|
||||
filename: &str,
|
||||
option: Option<&Vec<FileOptions>>,
|
||||
typ: Option<ScriptType>,
|
||||
index: usize,
|
||||
) -> Result<Vec<Entry>> {
|
||||
let foption = option
|
||||
.and_then(|opts| opts.get(index).cloned())
|
||||
.unwrap_or_default();
|
||||
let (entry_type, msg_tool_type) = if let Some(typ) = typ {
|
||||
let entry_type = if typ.is_audio() {
|
||||
EntryType::Audio
|
||||
} else {
|
||||
query_entry_type(&typ)
|
||||
};
|
||||
(entry_type, Some(typ.clone()))
|
||||
} else {
|
||||
let mut buffer = [0; 1024];
|
||||
let n = reader.read(&mut buffer)?;
|
||||
reader.rewind()?;
|
||||
detect_file_type("", &buffer[..n])
|
||||
};
|
||||
if entry_type != EntryType::Archive {
|
||||
return Err(anyhow::anyhow!("不是归档文件"));
|
||||
}
|
||||
let msg_tool_type = msg_tool_type.ok_or_else(|| anyhow::anyhow!("无法识别的归档格式"))?;
|
||||
let builder = BUILDER
|
||||
.iter()
|
||||
.find(|b| b.script_type() == &msg_tool_type)
|
||||
.ok_or_else(|| anyhow::anyhow!("不支持的归档格式"))?;
|
||||
let extra_config = foption.to_extra_config();
|
||||
let encoding = builder.default_encoding();
|
||||
let archive_encoding = builder.default_archive_encoding().unwrap_or(encoding);
|
||||
let archive = builder.build_script_from_reader(
|
||||
reader,
|
||||
filename,
|
||||
encoding,
|
||||
archive_encoding,
|
||||
&extra_config,
|
||||
None,
|
||||
)?;
|
||||
if path.contains("|") {
|
||||
let filename = path.split("|").nth(1).unwrap();
|
||||
let mut entry = archive.open_file_by_name(filename, false)?;
|
||||
let typ = entry.script_type().map(|t| t.clone());
|
||||
let path = path.splitn(2, "|").nth(1).unwrap();
|
||||
let entry = entry.to_data()?;
|
||||
return list_archive_directory_in_archive(
|
||||
path,
|
||||
Box::new(entry),
|
||||
filename,
|
||||
option,
|
||||
typ,
|
||||
index + 1,
|
||||
);
|
||||
}
|
||||
let mut result = Vec::new();
|
||||
let mut index = 0;
|
||||
for entry in archive.iter_archive_filename()? {
|
||||
let name = entry?;
|
||||
let mut entry = archive.open_file(index)?;
|
||||
index += 1;
|
||||
let (entry_type, msg_tool_type) = if let Some(typ) = entry.script_type() {
|
||||
let entry_type = if typ.is_audio() {
|
||||
EntryType::Audio
|
||||
} else {
|
||||
query_entry_type(&typ)
|
||||
};
|
||||
(entry_type, Some(typ.clone()))
|
||||
} else {
|
||||
let mut buffer = [0; 1024];
|
||||
let n = entry.read(&mut buffer)?;
|
||||
detect_file_type(&name, &buffer[..n])
|
||||
};
|
||||
// 扁平结构,不区分文件夹,前端根据路径解析出文件夹结构
|
||||
result.push(Entry {
|
||||
name,
|
||||
is_dir: false,
|
||||
entry_type,
|
||||
msg_tool_type,
|
||||
size: None,
|
||||
});
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn set_last_directory(app: &AppHandle, dir: &str) -> Result<()> {
|
||||
let path = app.path().app_data_dir()?.join("last_directory.txt");
|
||||
std::fs::write(path, dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// options 如果path是文件系统中的文件夹,options 没有作用
|
||||
/// 如果path是文件系统中的归档,options[0] 会用于打开该归档
|
||||
/// 如果归档的文件内有嵌套归档,options[1] 会用于打开内层归档,以此类推
|
||||
/// options可以None或者长度不足
|
||||
/// 如果是归档,该函数会返回所有归档内的文件。不包含文件夹,文件夹需要前端根据文件路径解析。
|
||||
/// # Example
|
||||
/// path: /path/to/directory 列出目录下的所有文件和文件夹,options没有作用
|
||||
/// path: /path/to/archive.zip 列出归档内的所有文件,options[0] 会用于打开该归档
|
||||
/// path: /path/to/archive.zip|inner/ 不支持的路径格式,不会实现该种形式(需要在前端自行模拟文件夹结构)
|
||||
/// path: /path/to/archive.zip|inner/archive2.zip 列出archive2.zip内的所有文件,options[0] 会用于打开archive.zip,options[1] 会用于打开archive2.zip
|
||||
#[tauri::command]
|
||||
pub fn list_directory(
|
||||
app: AppHandle,
|
||||
path: &str,
|
||||
options: Option<Vec<FileOptions>>,
|
||||
) -> Result<Vec<Entry>, ErrorMsg> {
|
||||
if path.contains("|") {
|
||||
let filename = path.split("|").nth(0).unwrap();
|
||||
let reader = Box::new(std::io::BufReader::new(File::open(filename).map_err(
|
||||
|e| ErrorMsg {
|
||||
typ: ErrorType::NotFound,
|
||||
msg: format!("无法打开文件: {}", e),
|
||||
},
|
||||
)?));
|
||||
return list_archive_directory_in_archive(
|
||||
path,
|
||||
reader,
|
||||
filename,
|
||||
options.as_ref(),
|
||||
None,
|
||||
0,
|
||||
)
|
||||
.map_err(|e| ErrorMsg {
|
||||
typ: ErrorType::Other,
|
||||
msg: e.to_string(),
|
||||
});
|
||||
}
|
||||
let path = std::path::Path::new(path);
|
||||
if !path.exists() {
|
||||
return Err(ErrorMsg {
|
||||
typ: ErrorType::NotFound,
|
||||
msg: "目录不存在".to_string(),
|
||||
});
|
||||
}
|
||||
if path.is_file() {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = set_last_directory(&app, parent.to_string_lossy().as_ref());
|
||||
}
|
||||
return list_archive_directory(path, options.as_ref()).map_err(|e| ErrorMsg {
|
||||
typ: ErrorType::Other,
|
||||
msg: e.to_string(),
|
||||
});
|
||||
}
|
||||
let _ = set_last_directory(&app, path.to_string_lossy().as_ref());
|
||||
list_fs_directory(path).map_err(|e| ErrorMsg {
|
||||
typ: ErrorType::Other,
|
||||
msg: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_xp3_supported_games() -> Vec<GameTitle> {
|
||||
let mut games = Vec::new();
|
||||
for (title, alias) in
|
||||
msg_tool::scripts::kirikiri::archive::xp3::get_supported_games_with_title()
|
||||
{
|
||||
let title = title.to_string();
|
||||
let alias = alias.map(|a| a.split("|").map(|s| s.trim().to_string()).collect());
|
||||
games.push(GameTitle { name: title, alias });
|
||||
}
|
||||
games
|
||||
}
|
||||
|
||||
fn preview_image_in_directory<'a>(
|
||||
mut reader: Box<dyn ReadSeek + 'a>,
|
||||
filename: &str,
|
||||
options: Option<&Vec<FileOptions>>,
|
||||
script_type: Option<ScriptType>,
|
||||
index: usize,
|
||||
) -> Result<Vec<u8>> {
|
||||
let (entry_type, msg_tool_type) = if let Some(typ) = script_type {
|
||||
let entry_type = if typ.is_audio() {
|
||||
EntryType::Audio
|
||||
} else {
|
||||
query_entry_type(&typ)
|
||||
};
|
||||
(entry_type, Some(typ.clone()))
|
||||
} else {
|
||||
let mut buffer = [0; 1024];
|
||||
let n = reader.read(&mut buffer)?;
|
||||
reader.rewind()?;
|
||||
detect_file_type(filename, &buffer[..n])
|
||||
};
|
||||
if entry_type != EntryType::Image {
|
||||
return Err(anyhow::anyhow!("无法预览非图片文件"));
|
||||
}
|
||||
if let Some(msg_tool_type) = msg_tool_type {
|
||||
let builder = BUILDER
|
||||
.iter()
|
||||
.find(|b| b.script_type() == &msg_tool_type)
|
||||
.ok_or_else(|| anyhow::anyhow!("不支持的图片格式"))?;
|
||||
let option = options
|
||||
.as_ref()
|
||||
.and_then(|opts| opts.get(index).cloned())
|
||||
.unwrap_or_default();
|
||||
let extra_config = option.to_extra_config();
|
||||
let encoding = builder.default_encoding();
|
||||
let archive_encoding = builder.default_archive_encoding().unwrap_or(encoding);
|
||||
let image = builder.build_script_from_reader(
|
||||
reader,
|
||||
filename,
|
||||
encoding,
|
||||
archive_encoding,
|
||||
&extra_config,
|
||||
None,
|
||||
)?;
|
||||
let mut buffer = Vec::new();
|
||||
let raw_image = image.export_image()?;
|
||||
encode_img_writer(raw_image, ImageOutputType::Png, &mut buffer, &extra_config)?;
|
||||
Ok(buffer)
|
||||
} else {
|
||||
// 直接返回原始数据
|
||||
let mut buffer = Vec::new();
|
||||
reader.read_to_end(&mut buffer)?;
|
||||
Ok(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
fn preview_image_in_archive<'a>(
|
||||
path: &str,
|
||||
mut reader: Box<dyn ReadSeek + 'a>,
|
||||
filename: &str,
|
||||
option: Option<&Vec<FileOptions>>,
|
||||
typ: Option<ScriptType>,
|
||||
index: usize,
|
||||
) -> Result<Vec<u8>> {
|
||||
let foption = option
|
||||
.and_then(|opts| opts.get(index).cloned())
|
||||
.unwrap_or_default();
|
||||
let (entry_type, msg_tool_type) = if let Some(typ) = typ {
|
||||
let entry_type = if typ.is_audio() {
|
||||
EntryType::Audio
|
||||
} else {
|
||||
query_entry_type(&typ)
|
||||
};
|
||||
(entry_type, Some(typ.clone()))
|
||||
} else {
|
||||
let mut buffer = [0; 1024];
|
||||
let n = reader.read(&mut buffer)?;
|
||||
reader.rewind()?;
|
||||
detect_file_type("", &buffer[..n])
|
||||
};
|
||||
if entry_type != EntryType::Archive {
|
||||
return Err(anyhow::anyhow!("不是归档文件"));
|
||||
}
|
||||
let msg_tool_type = msg_tool_type.ok_or_else(|| anyhow::anyhow!("无法识别的归档格式"))?;
|
||||
let builder = BUILDER
|
||||
.iter()
|
||||
.find(|b| b.script_type() == &msg_tool_type)
|
||||
.ok_or_else(|| anyhow::anyhow!("不支持的归档格式"))?;
|
||||
let extra_config = foption.to_extra_config();
|
||||
let encoding = builder.default_encoding();
|
||||
let archive_encoding = builder.default_archive_encoding().unwrap_or(encoding);
|
||||
let archive = builder.build_script_from_reader(
|
||||
reader,
|
||||
filename,
|
||||
encoding,
|
||||
archive_encoding,
|
||||
&extra_config,
|
||||
None,
|
||||
)?;
|
||||
if path.contains("|") {
|
||||
let filename = path.split("|").nth(0).unwrap();
|
||||
let mut entry = archive.open_file_by_name(filename, false)?;
|
||||
let typ = entry.script_type().map(|t| t.clone());
|
||||
let path = path.splitn(2, "|").nth(1).unwrap();
|
||||
let entry = entry.to_data()?;
|
||||
return preview_image_in_archive(path, Box::new(entry), filename, option, typ, index + 1);
|
||||
}
|
||||
let mut entry = archive.open_file_by_name(path, false)?;
|
||||
let typ = entry.script_type().map(|t| t.clone());
|
||||
let entry = entry.to_data()?;
|
||||
preview_image_in_directory(Box::new(entry), filename, option, typ, index)
|
||||
}
|
||||
|
||||
/// options 如果path是普通的图片文件,options没有作用
|
||||
/// 如果path是文件系统中的归档,options[0] 会用于打开该图片文件
|
||||
/// 如果归档的文件内有嵌套归档,options[1] 会用于打开归档内的图片文件,以此类推
|
||||
/// options可以None或者长度不足
|
||||
/// # Example
|
||||
/// path: /path/to/image 预览该图片,options根据图片类型确定有没有作用
|
||||
/// path: /path/to/archive.zip|image.png 预览archive.zip内的image.png,options[0] 会用于打开archive.zip, options[1] 会用于打开image.png(如果需要的话)
|
||||
/// path: /path/to/archive.zip|inner/archive2.zip|image.png 预览archive2.zip内的image.png,options[0] 会用于打开archive.zip, options[1] 会用于打开archive2.zip, options[2] 会用于打开image.png(如果需要的话)
|
||||
#[tauri::command]
|
||||
pub fn preview_image(path: &str, options: Option<Vec<FileOptions>>) -> Result<Vec<u8>, ErrorMsg> {
|
||||
if path.contains("|") {
|
||||
let filename = path.split("|").nth(0).unwrap();
|
||||
let reader = Box::new(std::io::BufReader::new(File::open(filename).map_err(
|
||||
|e| ErrorMsg {
|
||||
typ: ErrorType::NotFound,
|
||||
msg: format!("无法打开文件: {}", e),
|
||||
},
|
||||
)?));
|
||||
let path = path.splitn(2, "|").nth(1).unwrap();
|
||||
return preview_image_in_archive(path, reader, filename, options.as_ref(), None, 0)
|
||||
.map_err(|e| ErrorMsg {
|
||||
typ: ErrorType::Other,
|
||||
msg: format!("预览图片失败: {}", e),
|
||||
});
|
||||
}
|
||||
let path = std::path::Path::new(path);
|
||||
if !path.exists() {
|
||||
return Err(ErrorMsg {
|
||||
typ: ErrorType::NotFound,
|
||||
msg: "文件不存在".to_string(),
|
||||
});
|
||||
}
|
||||
if path.is_dir() {
|
||||
return Err(ErrorMsg {
|
||||
typ: ErrorType::Other,
|
||||
msg: "无法预览文件夹".to_string(),
|
||||
});
|
||||
}
|
||||
let file = File::open(path).map_err(|e| ErrorMsg {
|
||||
typ: ErrorType::NotFound,
|
||||
msg: format!("无法打开文件: {}", e),
|
||||
})?;
|
||||
let file = std::io::BufReader::new(file);
|
||||
preview_image_in_directory(
|
||||
Box::new(file),
|
||||
path.to_string_lossy().as_ref(),
|
||||
options.as_ref(),
|
||||
None,
|
||||
0,
|
||||
)
|
||||
.map_err(|e| ErrorMsg {
|
||||
typ: ErrorType::Other,
|
||||
msg: format!("预览图片失败: {}", e),
|
||||
})
|
||||
}
|
||||
15
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
mod backend;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
backend::get_start_directory,
|
||||
backend::get_xp3_supported_games,
|
||||
backend::list_directory,
|
||||
backend::preview_image,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
garbro_rs_lib::run()
|
||||
}
|
||||
35
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "garbro-rs",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.lifegpc.garbro-rs",
|
||||
"build": {
|
||||
"beforeDevCommand": "yarn dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "yarn build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "garbro-rs",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/[email protected]",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
98
src/App.css
Normal file
@@ -0,0 +1,98 @@
|
||||
:root {
|
||||
color: #17221d;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(250, 225, 177, 0.7), transparent 28%),
|
||||
radial-gradient(circle at bottom right, rgba(77, 138, 116, 0.22), transparent 24%),
|
||||
linear-gradient(145deg, #f5efe6 0%, #ebe4d7 100%);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Two-panel layout */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
width: 360px;
|
||||
min-width: 200px;
|
||||
max-width: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid rgba(23, 34, 29, 0.12);
|
||||
background: rgba(255, 251, 244, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* FileExplorer */
|
||||
.file-explorer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-explorer__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid rgba(23, 34, 29, 0.08);
|
||||
background: rgba(255, 253, 248, 0.9);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-explorer .ant-table-wrapper {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-explorer .ant-table-wrapper,
|
||||
.file-explorer .ant-spin-nested-loading,
|
||||
.file-explorer .ant-spin-container,
|
||||
.file-explorer .ant-table,
|
||||
.file-explorer .ant-table-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.file-explorer .ant-table-body {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.file-explorer__row--selected td {
|
||||
background: rgba(22, 119, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
.file-explorer .ant-table-row:hover td {
|
||||
background: rgba(23, 34, 29, 0.04) !important;
|
||||
}
|
||||
|
||||
.file-explorer__row--selected:hover td {
|
||||
background: rgba(22, 119, 255, 0.15) !important;
|
||||
}
|
||||
56
src/App.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { App as AntApp } from "antd";
|
||||
import { getStartDirectory } from "./api";
|
||||
import { FileExplorer } from "./components/FileExplorer";
|
||||
import { ImagePreviewPanel } from "./components/ImagePreviewPanel";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Entry, FileOptions } from "./types";
|
||||
import "./App.css";
|
||||
|
||||
interface PreviewTarget {
|
||||
path: string;
|
||||
options?: FileOptions[];
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const [startDirectory, setStartDirectory] = useState<string>("");
|
||||
const [preview, setPreview] = useState<PreviewTarget | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getStartDirectory().then(setStartDirectory);
|
||||
}, []);
|
||||
|
||||
const handleEntrySelect = (entry: Entry, fullPath: string, options?: FileOptions[]) => {
|
||||
if (entry.entry_type === 'Image') {
|
||||
setPreview({ path: fullPath, options });
|
||||
} else {
|
||||
setPreview(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<div className="app-sidebar">
|
||||
{startDirectory && (
|
||||
<FileExplorer initialPath={startDirectory} onEntrySelect={handleEntrySelect} />
|
||||
)}
|
||||
</div>
|
||||
<div className="app-content">
|
||||
{preview ? (
|
||||
<ImagePreviewPanel path={preview.path} options={preview.options} />
|
||||
) : (
|
||||
<span style={{ color: "#8c8c8c" }}>选择图片文件以预览</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AntApp>
|
||||
<AppContent />
|
||||
</AntApp>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
19
src/api.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { GameTitle, Entry, FileOptions } from "./types";
|
||||
|
||||
export async function getStartDirectory(): Promise<string> {
|
||||
return await invoke("get_start_directory");
|
||||
}
|
||||
|
||||
export async function getXp3SupportedGames(): Promise<GameTitle[]> {
|
||||
return await invoke("get_xp3_supported_games");
|
||||
}
|
||||
|
||||
export async function listDirectory(path: string, options?: FileOptions[]): Promise<Entry[]> {
|
||||
return await invoke("list_directory", { path, options: options ?? null });
|
||||
}
|
||||
|
||||
export async function previewImage(path: string, options?: FileOptions[]): Promise<Uint8Array> {
|
||||
const bytes: number[] = await invoke("preview_image", { path, options: options ?? null });
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
390
src/components/FileExplorer.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Input, Button, Tooltip, Table, App as AntApp } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
FolderOutlined,
|
||||
FileOutlined,
|
||||
FileImageOutlined,
|
||||
SoundOutlined,
|
||||
CompressOutlined,
|
||||
FileTextOutlined,
|
||||
ArrowLeftOutlined,
|
||||
ArrowRightOutlined,
|
||||
ArrowUpOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Entry, EntryType, FileOptions } from '../types';
|
||||
import { listDirectory } from '../api';
|
||||
import { Xp3OptionsDialog } from './Xp3OptionsDialog';
|
||||
|
||||
interface FileExplorerProps {
|
||||
initialPath: string;
|
||||
onEntrySelect?: (entry: Entry, fullPath: string, options?: FileOptions[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A location in the navigation history.
|
||||
* archiveSubdir === undefined -> FS directory (always call backend)
|
||||
* archiveSubdir === "" -> root of an archive
|
||||
* archiveSubdir === "bg/" -> virtual subdir inside an archive
|
||||
*
|
||||
* backendPath for nested archives: "game.xp3|inner.dat"
|
||||
* options[N] corresponds to the N-th archive in the pipe-separated backendPath
|
||||
*/
|
||||
interface Location {
|
||||
backendPath: string;
|
||||
archiveSubdir?: string;
|
||||
options?: FileOptions[];
|
||||
}
|
||||
|
||||
function locationDisplayPath(loc: Location): string {
|
||||
if (loc.archiveSubdir === undefined || loc.archiveSubdir === '') return loc.backendPath;
|
||||
return `${loc.backendPath}|${loc.archiveSubdir}`;
|
||||
}
|
||||
|
||||
function parsePath(path: string): Location {
|
||||
const pipeIdx = path.indexOf('|');
|
||||
if (pipeIdx === -1) return { backendPath: path, archiveSubdir: undefined };
|
||||
return { backendPath: path.slice(0, pipeIdx), archiveSubdir: path.slice(pipeIdx + 1) };
|
||||
}
|
||||
|
||||
function fsDirParent(path: string): string | null {
|
||||
const norm = path.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const lastSlash = norm.lastIndexOf('/');
|
||||
if (lastSlash === -1) return null;
|
||||
if (lastSlash === 0) return '/';
|
||||
const parent = norm.slice(0, lastSlash);
|
||||
if (/^[A-Za-z]:$/.test(parent)) return parent + '/';
|
||||
return parent;
|
||||
}
|
||||
|
||||
function archiveSubdirParent(subdir: string): string | null {
|
||||
if (subdir === '') return null;
|
||||
const trimmed = subdir.replace(/\/$/, '');
|
||||
const lastSlash = trimmed.lastIndexOf('/');
|
||||
if (lastSlash === -1) return '';
|
||||
return trimmed.slice(0, lastSlash + 1);
|
||||
}
|
||||
|
||||
function getParentLocation(loc: Location): Location | null {
|
||||
if (loc.archiveSubdir === undefined) {
|
||||
const parent = fsDirParent(loc.backendPath);
|
||||
return parent ? { backendPath: parent } : null;
|
||||
}
|
||||
const parentSubdir = archiveSubdirParent(loc.archiveSubdir);
|
||||
if (parentSubdir !== null) return { ...loc, archiveSubdir: parentSubdir };
|
||||
const archiveParent = fsDirParent(loc.backendPath);
|
||||
return archiveParent ? { backendPath: archiveParent } : null;
|
||||
}
|
||||
|
||||
function joinFsPath(dir: string, name: string): string {
|
||||
return dir.replace(/[/\\]+$/, '') + '/' + name;
|
||||
}
|
||||
|
||||
function sortEntries(entries: Entry[]): Entry[] {
|
||||
return [...entries].sort((a, b) => {
|
||||
const aIsFolder = a.entry_type === 'Folder' ? 0 : 1;
|
||||
const bIsFolder = b.entry_type === 'Folder' ? 0 : 1;
|
||||
if (aIsFolder !== bIsFolder) return aIsFolder - bIsFolder;
|
||||
// 文件夹之间按名字排序,其他类型保持原始顺序
|
||||
if (aIsFolder === 0) return a.name.localeCompare(b.name);
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a flat archive listing into entries visible at the given virtual subdir.
|
||||
* virtualSubdir: "" = root, "bg/" = inside bg folder
|
||||
*/
|
||||
function buildVirtualDirEntries(flatEntries: Entry[], virtualSubdir: string): Entry[] {
|
||||
const result = new Map<string, Entry>();
|
||||
for (const entry of flatEntries) {
|
||||
const fullName = entry.name.replace(/\\/g, '/');
|
||||
if (!fullName.startsWith(virtualSubdir)) continue;
|
||||
const rest = fullName.slice(virtualSubdir.length);
|
||||
if (rest === '') continue;
|
||||
const slashIdx = rest.indexOf('/');
|
||||
if (slashIdx === -1) {
|
||||
result.set(rest, { ...entry, name: rest });
|
||||
} else {
|
||||
const folderName = rest.slice(0, slashIdx);
|
||||
if (!result.has(folderName)) {
|
||||
result.set(folderName, {
|
||||
name: folderName,
|
||||
is_dir: true,
|
||||
entry_type: 'Folder',
|
||||
msg_tool_type: undefined,
|
||||
size: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortEntries(Array.from(result.values()));
|
||||
}
|
||||
|
||||
function getEntryIcon(entryType: EntryType): React.ReactNode {
|
||||
switch (entryType) {
|
||||
case 'Folder': return <FolderOutlined style={{ color: '#faad14' }} />;
|
||||
case 'Image': return <FileImageOutlined style={{ color: '#52c41a' }} />;
|
||||
case 'Audio': return <SoundOutlined style={{ color: '#1677ff' }} />;
|
||||
case 'Archive': return <CompressOutlined style={{ color: '#eb2f96' }} />;
|
||||
case 'Text': return <FileTextOutlined style={{ color: '#722ed1' }} />;
|
||||
default: return <FileOutlined style={{ color: '#8c8c8c' }} />;
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(entryType: EntryType): string {
|
||||
switch (entryType) {
|
||||
case 'Folder': return '文件夹';
|
||||
case 'Archive': return '归档';
|
||||
case 'Image': return '图片';
|
||||
case 'Audio': return '音频';
|
||||
case 'Text': return '文本';
|
||||
default: return '未知';
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(size?: number): string {
|
||||
if (size == null) return '';
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
interface NavState {
|
||||
history: Location[];
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function FileExplorer({ initialPath, onEntrySelect }: FileExplorerProps) {
|
||||
const { message: messageApi } = AntApp.useApp();
|
||||
|
||||
const [nav, setNav] = useState<NavState>({
|
||||
history: [{ backendPath: initialPath, archiveSubdir: undefined }],
|
||||
index: 0,
|
||||
});
|
||||
const currentLoc = nav.history[nav.index];
|
||||
|
||||
const [pathInput, setPathInput] = useState(initialPath);
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
|
||||
const archiveCache = useRef<Map<string, Entry[]>>(new Map());
|
||||
|
||||
// Pending navigation for XP3 dialog
|
||||
const [xp3DialogOpen, setXp3DialogOpen] = useState(false);
|
||||
const pendingNavRef = useRef<{ target: Location; currentOptions?: FileOptions[] } | null>(null);
|
||||
|
||||
const loadLocation = useCallback(async (loc: Location) => {
|
||||
setLoading(true);
|
||||
setSelectedKey(null);
|
||||
try {
|
||||
if (loc.archiveSubdir !== undefined) {
|
||||
const cacheKey = loc.options
|
||||
? `${loc.backendPath}::${JSON.stringify(loc.options)}`
|
||||
: loc.backendPath;
|
||||
let flat = archiveCache.current.get(cacheKey);
|
||||
if (!flat) {
|
||||
flat = await listDirectory(loc.backendPath, loc.options);
|
||||
archiveCache.current.set(cacheKey, flat);
|
||||
}
|
||||
setEntries(buildVirtualDirEntries(flat, loc.archiveSubdir));
|
||||
} else {
|
||||
const result = await listDirectory(loc.backendPath);
|
||||
setEntries(sortEntries(result));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const e = err as { msg?: string };
|
||||
messageApi.error(`无法打开: ${e?.msg ?? String(err)}`);
|
||||
setEntries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [messageApi]);
|
||||
|
||||
useEffect(() => {
|
||||
setPathInput(locationDisplayPath(currentLoc));
|
||||
loadLocation(currentLoc);
|
||||
}, [currentLoc, loadLocation]);
|
||||
|
||||
const navigateTo = useCallback((loc: Location) => {
|
||||
setNav(prev => ({
|
||||
history: [...prev.history.slice(0, prev.index + 1), loc],
|
||||
index: prev.index + 1,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
setNav(prev => prev.index > 0 ? { ...prev, index: prev.index - 1 } : prev);
|
||||
}, []);
|
||||
|
||||
const goForward = useCallback(() => {
|
||||
setNav(prev => prev.index < prev.history.length - 1 ? { ...prev, index: prev.index + 1 } : prev);
|
||||
}, []);
|
||||
|
||||
const goUp = useCallback(() => {
|
||||
const parent = getParentLocation(currentLoc);
|
||||
if (parent) navigateTo(parent);
|
||||
}, [currentLoc, navigateTo]);
|
||||
|
||||
const handlePathSubmit = useCallback(() => {
|
||||
const trimmed = pathInput.trim();
|
||||
const current = locationDisplayPath(currentLoc);
|
||||
if (trimmed && trimmed !== current) {
|
||||
navigateTo(parsePath(trimmed));
|
||||
} else {
|
||||
loadLocation(currentLoc);
|
||||
}
|
||||
}, [pathInput, currentLoc, navigateTo, loadLocation]);
|
||||
|
||||
const handleRowClick = useCallback((entry: Entry) => {
|
||||
setSelectedKey(entry.name);
|
||||
let fullPath: string;
|
||||
if (currentLoc.archiveSubdir !== undefined) {
|
||||
fullPath = `${currentLoc.backendPath}|${currentLoc.archiveSubdir}${entry.name}`;
|
||||
} else {
|
||||
fullPath = joinFsPath(currentLoc.backendPath, entry.name);
|
||||
}
|
||||
onEntrySelect?.(entry, fullPath, currentLoc.options);
|
||||
}, [currentLoc, onEntrySelect]);
|
||||
|
||||
const handleRowDoubleClick = useCallback((entry: Entry) => {
|
||||
if (currentLoc.archiveSubdir !== undefined) {
|
||||
if (entry.entry_type === 'Folder') {
|
||||
navigateTo({ ...currentLoc, archiveSubdir: currentLoc.archiveSubdir + entry.name + '/' });
|
||||
} else if (entry.entry_type === 'Archive') {
|
||||
const innerPath = currentLoc.archiveSubdir + entry.name;
|
||||
const targetLoc: Location = { backendPath: `${currentLoc.backendPath}|${innerPath}`, archiveSubdir: '' };
|
||||
if (entry.msg_tool_type === 'KirikiriXp3') {
|
||||
pendingNavRef.current = { target: targetLoc, currentOptions: currentLoc.options };
|
||||
setXp3DialogOpen(true);
|
||||
} else {
|
||||
navigateTo(targetLoc);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (entry.entry_type === 'Folder') {
|
||||
navigateTo({ backendPath: joinFsPath(currentLoc.backendPath, entry.name) });
|
||||
} else if (entry.entry_type === 'Archive') {
|
||||
const targetLoc: Location = { backendPath: joinFsPath(currentLoc.backendPath, entry.name), archiveSubdir: '' };
|
||||
if (entry.msg_tool_type === 'KirikiriXp3') {
|
||||
pendingNavRef.current = { target: targetLoc, currentOptions: currentLoc.options };
|
||||
setXp3DialogOpen(true);
|
||||
} else {
|
||||
navigateTo(targetLoc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [currentLoc, navigateTo]);
|
||||
|
||||
const handleXp3Confirm = useCallback((options: FileOptions | null) => {
|
||||
setXp3DialogOpen(false);
|
||||
const pending = pendingNavRef.current;
|
||||
pendingNavRef.current = null;
|
||||
if (!pending) return;
|
||||
|
||||
if (options) {
|
||||
const { target, currentOptions } = pending;
|
||||
// Index of the new archive in the pipe-separated backendPath (0-based)
|
||||
const idx = target.backendPath.split('|').length - 1;
|
||||
const opts: FileOptions[] = [...(currentOptions ?? [])];
|
||||
while (opts.length < idx) opts.push({});
|
||||
opts[idx] = options;
|
||||
navigateTo({ ...target, options: opts });
|
||||
} else {
|
||||
navigateTo(pending.target);
|
||||
}
|
||||
}, [navigateTo]);
|
||||
|
||||
const handleXp3Cancel = useCallback(() => {
|
||||
setXp3DialogOpen(false);
|
||||
pendingNavRef.current = null;
|
||||
}, []);
|
||||
|
||||
const canGoBack = nav.index > 0;
|
||||
const canGoForward = nav.index < nav.history.length - 1;
|
||||
const canGoUp = getParentLocation(currentLoc) !== null;
|
||||
|
||||
const columns: ColumnsType<Entry> = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
ellipsis: true,
|
||||
render: (name: string, record) => (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{getEntryIcon(record.entry_type)}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{name}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'entry_type',
|
||||
key: 'entry_type',
|
||||
width: 60,
|
||||
render: (type: EntryType) => (
|
||||
<span style={{ color: '#8c8c8c', fontSize: 12 }}>{getTypeLabel(type)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '大小',
|
||||
dataIndex: 'size',
|
||||
key: 'size',
|
||||
width: 75,
|
||||
align: 'right' as const,
|
||||
render: (size?: number) => (
|
||||
<span style={{ color: '#8c8c8c', fontSize: 12 }}>{formatSize(size)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="file-explorer">
|
||||
<Xp3OptionsDialog
|
||||
open={xp3DialogOpen}
|
||||
onConfirm={handleXp3Confirm}
|
||||
onCancel={handleXp3Cancel}
|
||||
/>
|
||||
<div className="file-explorer__toolbar">
|
||||
<Tooltip title="后退">
|
||||
<Button size="small" type="text" icon={<ArrowLeftOutlined />}
|
||||
disabled={!canGoBack} onClick={goBack} />
|
||||
</Tooltip>
|
||||
<Tooltip title="前进">
|
||||
<Button size="small" type="text" icon={<ArrowRightOutlined />}
|
||||
disabled={!canGoForward} onClick={goForward} />
|
||||
</Tooltip>
|
||||
<Tooltip title="上一级">
|
||||
<Button size="small" type="text" icon={<ArrowUpOutlined />}
|
||||
disabled={!canGoUp} onClick={goUp} />
|
||||
</Tooltip>
|
||||
<Input
|
||||
size="small"
|
||||
value={pathInput}
|
||||
onChange={e => setPathInput(e.target.value)}
|
||||
onPressEnter={handlePathSubmit}
|
||||
onBlur={handlePathSubmit}
|
||||
style={{ flex: 1, fontFamily: 'monospace', fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
<Table<Entry>
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={entries}
|
||||
rowKey="name"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ y: 'calc(100vh - 80px)' }}
|
||||
rowClassName={record => record.name === selectedKey ? 'file-explorer__row--selected' : ''}
|
||||
onRow={record => ({
|
||||
onClick: () => handleRowClick(record),
|
||||
onDoubleClick: () => handleRowDoubleClick(record),
|
||||
style: { cursor: 'default' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
308
src/components/ImagePreviewPanel.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Spin, Button, Tooltip, Space } from 'antd';
|
||||
import { CompressOutlined, BorderOuterOutlined } from '@ant-design/icons';
|
||||
import { FileOptions } from '../types';
|
||||
import { previewImage } from '../api';
|
||||
|
||||
interface ImagePreviewPanelProps {
|
||||
path: string;
|
||||
options?: FileOptions[];
|
||||
}
|
||||
|
||||
type ScaleMode = 'fit' | '100%' | 'fill' | 'custom';
|
||||
|
||||
export function ImagePreviewPanel({ path, options }: ImagePreviewPanelProps) {
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [imageSize, setImageSize] = useState<{ w: number, h: number } | null>(null);
|
||||
const [containerSize, setContainerSize] = useState<{ w: number, h: number } | null>(null);
|
||||
const [scaleMode, setScaleMode] = useState<ScaleMode>('fit');
|
||||
const [customScale, setCustomScale] = useState<number>(1);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const prevUrlRef = useRef<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isDragging = useRef(false);
|
||||
const dragStart = useRef({ mouseX: 0, mouseY: 0, imgX: 0, imgY: 0 });
|
||||
|
||||
// Load image
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setObjectUrl(null);
|
||||
setImageSize(null);
|
||||
setScaleMode('fit');
|
||||
setCustomScale(1);
|
||||
setPosition({ x: 0, y: 0 });
|
||||
|
||||
previewImage(path, options)
|
||||
.then(bytes => {
|
||||
if (cancelled) return;
|
||||
const blob = new Blob([bytes as any], { type: 'image/png' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (prevUrlRef.current) URL.revokeObjectURL(prevUrlRef.current);
|
||||
prevUrlRef.current = url;
|
||||
setObjectUrl(url);
|
||||
})
|
||||
.catch(err => {
|
||||
if (cancelled) return;
|
||||
const msg = (err as { msg?: string })?.msg ?? String(err);
|
||||
setError(msg);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [path, options]);
|
||||
|
||||
// Cleanup object URL
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (prevUrlRef.current) URL.revokeObjectURL(prevUrlRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Track container size
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setContainerSize({
|
||||
w: entry.contentRect.width,
|
||||
h: entry.contentRect.height
|
||||
});
|
||||
}
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [objectUrl]);
|
||||
|
||||
// Drag logic
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
setPosition({
|
||||
x: dragStart.current.imgX + (e.clientX - dragStart.current.mouseX),
|
||||
y: dragStart.current.imgY + (e.clientY - dragStart.current.mouseY),
|
||||
});
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
isDragging.current = false;
|
||||
};
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
setImageSize({ w: target.naturalWidth, h: target.naturalHeight });
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging.current = true;
|
||||
dragStart.current = {
|
||||
mouseX: e.clientX,
|
||||
mouseY: e.clientY,
|
||||
imgX: position.x,
|
||||
imgY: position.y,
|
||||
};
|
||||
};
|
||||
|
||||
const getScale = (
|
||||
mode = scaleMode,
|
||||
cScale = customScale,
|
||||
iSize = imageSize,
|
||||
cSize = containerSize
|
||||
) => {
|
||||
if (!iSize || !cSize) return 1;
|
||||
const { w, h } = iSize;
|
||||
const { w: cw, h: ch } = cSize;
|
||||
if (w === 0 || h === 0 || cw === 0 || ch === 0) return 1;
|
||||
|
||||
const rw = cw / w;
|
||||
const rh = ch / h;
|
||||
|
||||
if (mode === 'fit') return Math.min(1, rw, rh); // 只缩小不放大
|
||||
if (mode === 'fill') return Math.max(rw, rh); // 可以放大超过100%
|
||||
if (mode === '100%') return 1;
|
||||
return cScale;
|
||||
};
|
||||
|
||||
const currentScale = getScale();
|
||||
|
||||
const stateRef = useRef({ imageSize, containerSize, scaleMode, customScale, position });
|
||||
stateRef.current = { imageSize, containerSize, scaleMode, customScale, position };
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const state = stateRef.current;
|
||||
const scaleStr = getScale(state.scaleMode, state.customScale, state.imageSize, state.containerSize);
|
||||
|
||||
const scaleFactor = 1.15;
|
||||
const delta = e.deltaY < 0 ? 1 : -1;
|
||||
let newScale = scaleStr * (delta > 0 ? scaleFactor : 1 / scaleFactor);
|
||||
newScale = Math.max(0.05, Math.min(newScale, 100)); // 缩放范围限制 5% 到 10000%
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const cx = rect.width / 2;
|
||||
const cy = rect.height / 2;
|
||||
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
const dx = mouseX - cx;
|
||||
const dy = mouseY - cy;
|
||||
|
||||
const scaleRatio = newScale / scaleStr;
|
||||
|
||||
setPosition({
|
||||
x: dx - (dx - state.position.x) * scaleRatio,
|
||||
y: dy - (dy - state.position.y) * scaleRatio,
|
||||
});
|
||||
setCustomScale(newScale);
|
||||
setScaleMode('custom');
|
||||
};
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => container.removeEventListener('wheel', handleWheel);
|
||||
}, [objectUrl]);
|
||||
|
||||
const resetPositionAndSetMode = (mode: ScaleMode) => {
|
||||
setScaleMode(mode);
|
||||
setPosition({ x: 0, y: 0 }); // Switch mode resets the drag position to center
|
||||
};
|
||||
|
||||
if (loading && !objectUrl) {
|
||||
return (
|
||||
<div style={centerStyle}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={centerStyle}>
|
||||
<span style={{ color: '#ff4d4f', fontSize: 13 }}>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!objectUrl) return null;
|
||||
|
||||
return (
|
||||
<div style={containerStyle} ref={containerRef}>
|
||||
<img
|
||||
src={objectUrl!}
|
||||
alt={path.split('|').pop()?.split(/[/\\]/).pop() ?? ''}
|
||||
style={{
|
||||
...imgStyle,
|
||||
transform: `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px)) scale(${currentScale})`,
|
||||
cursor: 'grab',
|
||||
}}
|
||||
draggable={false}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={handleMouseDown}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={toolbarContainerStyle}>
|
||||
<Space size={8} style={{ padding: '6px 12px', background: 'rgba(255, 255, 255, 0.85)', backdropFilter: 'blur(8px)', borderRadius: 6, boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }}>
|
||||
<Tooltip title="适应窗口 (默认只缩小不放大)">
|
||||
<Button
|
||||
type={scaleMode === 'fit' ? 'primary' : 'text'}
|
||||
icon={<CompressOutlined />}
|
||||
onClick={() => resetPositionAndSetMode('fit')}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="100% 大小">
|
||||
<Button
|
||||
type={scaleMode === '100%' ? 'primary' : 'text'}
|
||||
icon={<span style={{ fontSize: 12, fontWeight: 'bold' }}>1:1</span>}
|
||||
onClick={() => resetPositionAndSetMode('100%')}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="填充窗口">
|
||||
<Button
|
||||
type={scaleMode === 'fill' ? 'primary' : 'text'}
|
||||
icon={<BorderOuterOutlined />}
|
||||
onClick={() => resetPositionAndSetMode('fill')}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Info Status */}
|
||||
<div style={infoContainerStyle}>
|
||||
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.7)', fontWeight: 'bold', fontFamily: 'monospace' }}>
|
||||
{Math.round(currentScale * 100)}%
|
||||
{imageSize && ` | ${imageSize.w} x ${imageSize.h}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const centerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
};
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
padding: 0,
|
||||
};
|
||||
|
||||
const imgStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transformOrigin: 'center',
|
||||
imageRendering: 'pixelated',
|
||||
borderRadius: 0,
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
|
||||
willChange: 'transform',
|
||||
};
|
||||
|
||||
const toolbarContainerStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
zIndex: 10,
|
||||
};
|
||||
|
||||
const infoContainerStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
zIndex: 10,
|
||||
background: 'rgba(255, 255, 255, 0.85)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
};
|
||||
92
src/components/Xp3OptionsDialog.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal, Select, Switch, Form } from 'antd';
|
||||
import { GameTitle, FileOptions } from '../types';
|
||||
import { getXp3SupportedGames } from '../api';
|
||||
|
||||
interface Xp3OptionsDialogProps {
|
||||
open: boolean;
|
||||
onConfirm: (options: FileOptions | null) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function Xp3OptionsDialog({ open, onConfirm, onCancel }: Xp3OptionsDialogProps) {
|
||||
const [games, setGames] = useState<GameTitle[]>([]);
|
||||
const [gamesLoading, setGamesLoading] = useState(false);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [gameTitle, setGameTitle] = useState<string | undefined>(undefined);
|
||||
const [forceDecrypt, setForceDecrypt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setEnabled(false);
|
||||
setGameTitle(undefined);
|
||||
setForceDecrypt(false);
|
||||
if (games.length === 0) {
|
||||
setGamesLoading(true);
|
||||
getXp3SupportedGames().then(g => {
|
||||
setGames(g);
|
||||
setGamesLoading(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleOk = () => {
|
||||
if (!enabled) {
|
||||
onConfirm(null);
|
||||
} else {
|
||||
onConfirm({
|
||||
xp3: {
|
||||
game_title: gameTitle,
|
||||
force_decrypt: forceDecrypt,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const gameOptions = games.map(g => {
|
||||
const label = g.alias ? `${g.name} (${g.alias.join(' / ')})` : g.name;
|
||||
return { value: g.name, label };
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="XP3 解密选项"
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
width={420}
|
||||
>
|
||||
<Form layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item label="启用解密">
|
||||
<Switch checked={enabled} onChange={v => setEnabled(v)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="游戏">
|
||||
<Select
|
||||
disabled={!enabled}
|
||||
value={gameTitle}
|
||||
onChange={setGameTitle}
|
||||
options={gameOptions}
|
||||
placeholder="选择游戏"
|
||||
allowClear
|
||||
showSearch
|
||||
loading={gamesLoading}
|
||||
filterOption={(input, option) =>
|
||||
String(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="强制解密">
|
||||
<Switch
|
||||
disabled={!enabled}
|
||||
checked={enabled && forceDecrypt}
|
||||
onChange={v => setForceDecrypt(v)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "antd/dist/reset.css";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
28
src/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface GameTitle {
|
||||
name: string;
|
||||
alias?: string[];
|
||||
}
|
||||
|
||||
export type EntryType = 'Archive' | 'Text' | 'Image' | 'Audio' | 'Folder' | 'Unknown';
|
||||
|
||||
export interface Entry {
|
||||
name: string;
|
||||
is_dir: boolean;
|
||||
entry_type: EntryType;
|
||||
msg_tool_type?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface FileOptions {
|
||||
xp3?: {
|
||||
game_title?: string;
|
||||
force_decrypt: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type ErrorType = 'NotFound' | 'Other';
|
||||
|
||||
export interface ErrorMsg {
|
||||
typ: ErrorType;
|
||||
msg: string;
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
32
vite.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react()],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||