diff --git a/package.json b/package.json index dcddfc34..6666fbb6 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "driver.js": "^1.3.1", "i18next": "^24.2.0", "lodash": "^4.17.21", + "jszip": "^3.10.1", "lucide-react": "^0.469.0", "markdown-it": "^14.1.0", "react": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a5abf4b..bba4e04a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,9 @@ importers: i18next: specifier: ^24.2.0 version: 24.2.0(typescript@5.7.2) + jszip: + specifier: ^3.10.1 + version: 3.10.1 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -2050,6 +2053,10 @@ packages: { integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w== } engines: { node: ">=12.13" } + core-util-is@1.0.3: + resolution: + { integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== } + cosmiconfig@8.3.6: resolution: { integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA== } @@ -2652,6 +2659,10 @@ packages: engines: { node: ">=0.10.0" } hasBin: true + immediate@3.0.6: + resolution: + { integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== } + immutable@5.0.3: resolution: { integrity: sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw== } @@ -2666,6 +2677,10 @@ packages: { integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== } engines: { node: ">=0.8.19" } + inherits@2.0.4: + resolution: + { integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== } + internal-slot@1.1.0: resolution: { integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw== } @@ -2828,6 +2843,10 @@ packages: { integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A== } engines: { node: ">=12.13" } + isarray@1.0.0: + resolution: + { integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== } + isarray@2.0.5: resolution: { integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== } @@ -2916,6 +2935,10 @@ packages: { integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ== } engines: { node: ">=4.0" } + jszip@3.10.1: + resolution: + { integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g== } + keyv@4.5.4: resolution: { integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== } @@ -2936,6 +2959,10 @@ packages: { integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== } engines: { node: ">= 0.8.0" } + lie@3.3.0: + resolution: + { integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== } + lightningcss-darwin-arm64@1.29.1: resolution: { integrity: sha512-HtR5XJ5A0lvCqYAoSv2QdZZyoHNttBpa5EP9aNuzBQeKGfbyH5+UipLWvVzpP4Uml5ej4BYs5I9Lco9u1fECqw== } @@ -3289,6 +3316,10 @@ packages: { integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== } engines: { node: ">=10" } + pako@1.0.11: + resolution: + { integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== } + parent-module@1.0.1: resolution: { integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== } @@ -3456,6 +3487,10 @@ packages: engines: { node: ">=14" } hasBin: true + process-nextick-args@2.0.1: + resolution: + { integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== } + prop-types@15.8.1: resolution: { integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== } @@ -3530,6 +3565,10 @@ packages: { integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ== } engines: { node: ">=0.10.0" } + readable-stream@2.3.8: + resolution: + { integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== } + reflect.getprototypeof@1.0.9: resolution: { integrity: sha512-r0Ay04Snci87djAsI4U+WNRcSw5S4pOH7qFjd/veA5gC7TbqESR3tcj28ia95L/fYUDw11JKP7uqUKUAfVvV5Q== } @@ -3603,6 +3642,10 @@ packages: { integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q== } engines: { node: ">=0.4" } + safe-buffer@5.1.2: + resolution: + { integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== } + safe-regex-test@1.1.0: resolution: { integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== } @@ -3810,6 +3853,10 @@ packages: { integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== } engines: { node: ">= 0.4" } + setimmediate@1.0.5: + resolution: + { integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== } + shebang-command@2.0.0: resolution: { integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== } @@ -3932,6 +3979,10 @@ packages: { integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== } engines: { node: ">= 0.4" } + string_decoder@1.1.1: + resolution: + { integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== } + stringify-entities@4.0.4: resolution: { integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg== } @@ -4174,6 +4225,10 @@ packages: resolution: { integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== } + util-deprecate@1.0.2: + resolution: + { integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== } + uuid@11.0.3: resolution: { integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg== } @@ -6034,6 +6089,8 @@ snapshots: dependencies: is-what: 4.1.16 + core-util-is@1.0.3: {} + cosmiconfig@8.3.6(typescript@5.7.2): dependencies: import-fresh: 3.3.0 @@ -6655,6 +6712,8 @@ snapshots: image-size@0.5.5: optional: true + immediate@3.0.6: {} + immutable@5.0.3: {} import-fresh@3.3.0: @@ -6664,6 +6723,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -6786,6 +6847,8 @@ snapshots: is-what@4.1.16: {} + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -6864,6 +6927,13 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -6889,6 +6959,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-darwin-arm64@1.29.1: optional: true @@ -7179,6 +7253,8 @@ snapshots: dependencies: p-limit: 3.1.0 + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -7245,6 +7321,8 @@ snapshots: prettier@3.4.2: {} + process-nextick-args@2.0.1: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -7296,6 +7374,16 @@ snapshots: react@19.0.0: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + reflect.getprototypeof@1.0.9: dependencies: call-bind: 1.0.8 @@ -7386,6 +7474,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-regex-test@1.1.0: dependencies: call-bound: 1.0.3 @@ -7527,6 +7617,8 @@ snapshots: functions-have-names: 1.2.3 has-property-descriptors: 1.0.2 + setimmediate@1.0.5: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -7656,6 +7748,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -7846,6 +7942,8 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + uuid@11.0.3: {} varint@6.0.0: {} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b6eba25b..58006a36 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,77 +1,124 @@ -use std::env; -use std::io::Read; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, MAIN_SEPARATOR}; +use std::time::UNIX_EPOCH; +use tauri::Manager; -use base64::engine::general_purpose; -use base64::Engine; +// 新增路径规范化函数 +fn normalize_path(path: &str) -> String { + path.replace('/', &MAIN_SEPARATOR.to_string()) +} -use tauri::Manager; +#[derive(Debug, Serialize, Deserialize)] +struct FileStats { + name: String, + #[serde(rename = "isDir")] + is_directory: bool, + size: u64, + modified: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +struct DirectoryEntry { + name: String, + #[serde(rename = "isDir")] + is_directory: bool, +} -/// 判断文件是否存在 #[tauri::command] -fn exists(path: String) -> bool { - std::path::Path::new(&path).exists() +async fn read_file(path: String) -> Result, String> { + let path = normalize_path(&path); + std::fs::read(&path).map_err(|e| e.to_string()) } -/// 读取文件,返回字符串 #[tauri::command] -fn read_text_file(path: String) -> String { - let mut file = std::fs::File::open(path).unwrap(); - let mut contents = String::new(); - file.read_to_string(&mut contents).unwrap(); - contents +async fn write_file(path: String, content: Vec) -> Result<(), String> { + let path = normalize_path(&path); + fs::write(path, content).map_err(|e| e.to_string()) } -/// 读取文件,返回base64 #[tauri::command] -fn read_file_base64(path: String) -> Result { - Ok(general_purpose::STANDARD - .encode(&std::fs::read(path).map_err(|e| format!("无法读取文件: {}", e))?)) +async fn read_dir(path: String) -> Result, String> { + let path = normalize_path(&path); + let entries = fs::read_dir(path).map_err(|e| e.to_string())?; + let mut result = Vec::new(); + + for entry in entries { + let entry = entry.map_err(|e| e.to_string())?; + let metadata = entry.metadata().map_err(|e| e.to_string())?; + + result.push(DirectoryEntry { + name: entry.file_name().to_string_lossy().to_string(), + is_directory: metadata.is_dir(), + }); + } + + Ok(result) +} + +#[tauri::command] +async fn mkdir(path: String, recursive: bool) -> Result<(), String> { + let path = normalize_path(&path); + if recursive { + fs::create_dir_all(path).map_err(|e| e.to_string()) + } else { + fs::create_dir(path).map_err(|e| e.to_string()) + } } -/// 写入文件 #[tauri::command] -fn write_text_file(path: String, content: String) -> Result<(), String> { - std::fs::write(path, content).map_err(|e| e.to_string())?; - Ok(()) +async fn stat(path: String) -> Result { + let normalized_path = normalize_path(&path); + let metadata = fs::metadata(&normalized_path).map_err(|e| e.to_string())?; + + let name = Path::new(&normalized_path) + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + .ok_or_else(|| "无法解析文件名".to_string())?; + + let modified = metadata + .modified() + .map_err(|e| e.to_string())? + .duration_since(UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_millis() as i64; + + Ok(FileStats { + name, + is_directory: metadata.is_dir(), + size: metadata.len(), + modified, + }) } -/// 写入文件,base64字符串 #[tauri::command] -fn write_file_base64(content: String, path: String) -> Result<(), String> { - std::fs::write( - &path, - &general_purpose::STANDARD - .decode(content) - .map_err(|e| format!("解码失败: {}", e))?, - ) - .map_err(|e| { - eprintln!("写入文件失败: {}", e); - return e.to_string(); - })?; - Ok(()) +async fn rename(old_path: String, new_path: String) -> Result<(), String> { + let old_path = normalize_path(&old_path); + let new_path = normalize_path(&new_path); + fs::rename(old_path, new_path).map_err(|e| e.to_string()) } #[tauri::command] -fn write_stdout(content: String) { - println!("{}", content); +async fn delete_file(path: String) -> Result<(), String> { + let path = normalize_path(&path); + fs::remove_file(path).map_err(|e| e.to_string()) } #[tauri::command] -fn write_stderr(content: String) { - eprintln!("{}", content); +async fn delete_directory(path: String) -> Result<(), String> { + let path = normalize_path(&path); + fs::remove_dir_all(path).map_err(|e| e.to_string()) } #[tauri::command] -fn exit(code: i32) { - std::process::exit(code); +async fn exists(path: String) -> Result { + let path = normalize_path(&path); + Ok(fs::metadata(path).is_ok()) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - // 在 Linux 上禁用 DMA-BUF 渲染器 - // 否则无法在 Linux 上运行 - // 相同的bug: https://github.com/tauri-apps/tauri/issues/10702 - // 解决方案来源: https://github.com/clash-verge-rev/clash-verge-rev/blob/ae5b2cfb79423c7e76a281725209b812774367fa/src-tauri/src/lib.rs#L27-L28 #[cfg(target_os = "linux")] std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); @@ -98,14 +145,15 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ - read_text_file, - write_text_file, - exists, - read_file_base64, - write_file_base64, - write_stdout, - write_stderr, - exit + read_file, + write_file, + read_dir, + mkdir, + stat, + rename, + delete_file, + delete_directory, + exists ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/cli.tsx b/src/cli.tsx index 73d44ae9..e9a02b7e 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -1,7 +1,7 @@ import { CliMatches } from "@tauri-apps/plugin-cli"; import { StageExportSvg } from "./core/service/dataGenerateService/stageExportEngine/StageExportSvg"; -import { writeTextFile } from "./utils/fs"; import { writeStdout } from "./utils/otherApi"; +import { TauriBaseFS } from "./utils/fs/TauriFileSystem"; export async function runCli(matches: CliMatches) { if (matches.args.output?.occurrences > 0) { @@ -12,7 +12,7 @@ export async function runCli(matches: CliMatches) { if (outputPath === "-") { writeStdout(result); } else { - await writeTextFile(outputPath, result); + await TauriBaseFS.writeTextFile(outputPath, result); } } else { throw new Error("Invalid output format. Only SVG format is supported."); diff --git a/src/core/service/controlService/controller/concrete/ControllerDragFile.tsx b/src/core/service/controlService/controller/concrete/ControllerDragFile.tsx index 6ff92533..e978d2bb 100644 --- a/src/core/service/controlService/controller/concrete/ControllerDragFile.tsx +++ b/src/core/service/controlService/controller/concrete/ControllerDragFile.tsx @@ -1,6 +1,4 @@ import { v4 as uuidv4 } from "uuid"; -import { writeFileBase64 } from "../../../../../utils/fs"; -import { PathString } from "../../../../../utils/pathString"; import { Color } from "../../../../dataStruct/Color"; import { Vector } from "../../../../dataStruct/Vector"; import { Renderer } from "../../../../render/canvas2d/renderer"; @@ -12,6 +10,7 @@ import { TextNode } from "../../../../stage/stageObject/entity/TextNode"; import { TextRiseEffect } from "../../../feedbackService/effectEngine/concrete/TextRiseEffect"; import { ViewFlashEffect } from "../../../feedbackService/effectEngine/concrete/ViewFlashEffect"; import { ControllerClassDragFile } from "../ControllerClassDragFile"; +import { VFileSystem } from "../../../dataFileService/VFileSystem"; /** * BUG: 始终无法触发文件拖入事件 @@ -156,7 +155,7 @@ function dealJsonFileDrop(file: File, mouseWorldLocation: Vector) { function dealPngFileDrop(file: File, mouseWorldLocation: Vector) { const reader = new FileReader(); reader.readAsDataURL(file); // 以文本格式读取文件内容 - reader.onload = (e) => { + reader.onload = async (e) => { const fileContent = e.target?.result; // 读取的文件内容 if (typeof fileContent !== "string") { @@ -169,8 +168,7 @@ function dealPngFileDrop(file: File, mouseWorldLocation: Vector) { // data:image/png;base64,iVBORw0KGgoAAAANS... // 在这里处理读取到的内容 const imageUUID = uuidv4(); - const folderPath = PathString.dirPath(Stage.path.getFilePath()); - writeFileBase64(`${folderPath}${PathString.getSep()}${imageUUID}.png`, fileContent.split(",")[1]); + await VFileSystem.getFS().writeFileBase64(`/picture/${imageUUID}.png`, fileContent.split(",")[1]); const imageNode = new ImageNode({ uuid: imageUUID, location: [mouseWorldLocation.x, mouseWorldLocation.y], diff --git a/src/core/service/dataFileService/RecentFileManager.tsx b/src/core/service/dataFileService/RecentFileManager.tsx index d00d0390..d0f9f1dd 100644 --- a/src/core/service/dataFileService/RecentFileManager.tsx +++ b/src/core/service/dataFileService/RecentFileManager.tsx @@ -1,7 +1,7 @@ import { Store } from "@tauri-apps/plugin-store"; // import { exists } from "@tauri-apps/plugin-fs"; // 导入文件相关函数 import { Serialized } from "../../../types/node"; -import { exists, readTextFile } from "../../../utils/fs"; +import { exists } from "../../../utils/fs/com"; import { createStore } from "../../../utils/store"; import { Camera } from "../../stage/Camera"; import { Stage } from "../../stage/Stage"; @@ -16,6 +16,7 @@ import { TextNode } from "../../stage/stageObject/entity/TextNode"; import { UrlNode } from "../../stage/stageObject/entity/UrlNode"; import { ViewFlashEffect } from "../feedbackService/effectEngine/concrete/ViewFlashEffect"; import { PenStroke } from "../../stage/stageObject/entity/PenStroke"; +import { VFileSystem } from "./VFileSystem"; /** * 管理最近打开的文件列表 @@ -144,16 +145,16 @@ export namespace RecentFileManager { */ export async function openFileByPath(path: string) { StageManager.destroy(); - let content: string; + try { - content = await readTextFile(path); + await VFileSystem.loadFromPath(path); } catch (e) { console.error("打开文件失败:", path); console.error(e); return; } - const data = StageLoader.validate(JSON.parse(content)); + const data = StageLoader.validate(JSON.parse(await VFileSystem.getMetaData())); loadStageByData(data); StageHistoryManager.reset(data); diff --git a/src/core/service/dataFileService/StageSaveManager.tsx b/src/core/service/dataFileService/StageSaveManager.tsx index 3b123bba..2811a87a 100644 --- a/src/core/service/dataFileService/StageSaveManager.tsx +++ b/src/core/service/dataFileService/StageSaveManager.tsx @@ -1,9 +1,10 @@ import { Serialized } from "../../../types/node"; -import { exists, writeTextFile } from "../../../utils/fs"; +import { exists, writeTextFile } from "../../../utils/fs/com"; import { PathString } from "../../../utils/pathString"; import { Stage } from "../../stage/Stage"; import { StageHistoryManager } from "../../stage/stageManager/StageHistoryManager"; import { ViewFlashEffect } from "../feedbackService/effectEngine/concrete/ViewFlashEffect"; +import { VFileSystem } from "./VFileSystem"; /** * 管理所有和保存相关的内容 @@ -16,7 +17,7 @@ export namespace StageSaveManager { * @param errorCallback */ export async function saveHandle(path: string, data: Serialized.File) { - await writeTextFile(path, JSON.stringify(data)); + await VFileSystem.saveToPath(path); Stage.effectMachine.addEffect(ViewFlashEffect.SaveFile()); StageHistoryManager.reset(data); // 重置历史 isCurrentSaved = true; @@ -37,7 +38,7 @@ export namespace StageSaveManager { if (Stage.path.isDraft()) { throw new Error("当前文档的状态为草稿,请您先保存为文件"); } - await writeTextFile(Stage.path.getFilePath(), JSON.stringify(data)); + await VFileSystem.saveToPath(Stage.path.getFilePath()); if (addFlashEffect) { Stage.effectMachine.addEffect(ViewFlashEffect.SaveFile()); } @@ -55,6 +56,7 @@ export namespace StageSaveManager { * @param errorCallback * @returns */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars export async function backupHandle(path: string, data: Serialized.File) { const backupFolderPath = PathString.dirPath(path); const isExists = await exists(backupFolderPath); @@ -62,7 +64,7 @@ export namespace StageSaveManager { throw new Error("备份文件路径错误:" + backupFolderPath); } - await writeTextFile(path, JSON.stringify(data)); + await VFileSystem.saveToPath(path); Stage.effectMachine.addEffect(ViewFlashEffect.SaveFile()); } /** diff --git a/src/core/service/dataFileService/VFileSystem.tsx b/src/core/service/dataFileService/VFileSystem.tsx new file mode 100644 index 00000000..37dc1a92 --- /dev/null +++ b/src/core/service/dataFileService/VFileSystem.tsx @@ -0,0 +1,106 @@ +import JSZip, * as jszip from "jszip"; +import { IndexedDBFileSystem } from "../../../utils/fs/IndexedDBFileSystem"; +import { readFile, writeFile } from "../../../utils/fs/com"; +import { StageDumper } from "../../stage/StageDumper"; + +export enum FSType { + Tauri = "Tauri", + WebFS = "WebFS", + IndexedDB = "IndexedDB", +} +export namespace VFileSystem { + const fs = new IndexedDBFileSystem("PG", "Project"); + export async function getMetaData() { + return fs.readTextFile("/meta.json"); + } + export async function setMetaData(content: string) { + return fs.writeTextFile("/meta.json", content); + } + export async function pullMetaData() { + setMetaData(JSON.stringify(StageDumper.dump())); + } + export async function loadFromPath(path: string) { + await clear(); + const data = await readFile(path); + const zip = await jszip.loadAsync(data); + const entries = zip.files; + + const operations: Promise[] = []; + + for (const [rawPath, file] of Object.entries(entries)) { + // 标准化路径:替换多个斜杠为单个,并移除末尾斜杠 + const normalizedPath = rawPath.replace(/\/+/g, "/").replace(/\/$/, ""); + + if (file.dir) { + await fs.mkdir(normalizedPath, true); + } else { + // 处理文件 + operations.push( + (async () => { + try { + // 分离目录和文件名 + const lastSlashIndex = normalizedPath.lastIndexOf("/"); + const parentDir = lastSlashIndex >= 0 ? normalizedPath.slice(0, lastSlashIndex) : ""; + + // 创建父目录(如果存在) + if (parentDir) { + await fs.mkdir(parentDir, true); + } + + // 写入文件内容 + const content = await file.async("uint8array"); + await fs.writeFile(normalizedPath, content); + } catch (error) { + console.error(`Process file failed: ${normalizedPath}`, error); + } + })(), + ); + } + } + + await Promise.all(operations); + } + export async function saveToPath(path: string) { + await setMetaData(JSON.stringify(StageDumper.dump())); + await writeFile(path, await VFileSystem.exportZipData()); + } + export async function exportZipData(): Promise { + const zip = new JSZip(); + + // 递归添加目录和文件到zip + async function addToZip(zipParent: jszip, path: string) { + console.log(zipParent, path); + const entries = await fs.readDir(path); + + for (const entry of entries) { + const fullPath = path ? `${path}/${entry.name}` : entry.name; + + if (entry.isDir) { + // 创建目录节点并递归处理子项 + const dirZip = zipParent.folder(entry.name); + await addToZip(dirZip!, fullPath); + } else { + // 添加文件内容到zip + const content = await fs.readFile(fullPath); + zipParent.file(entry.name, content); + } + } + } + + // 从根目录开始处理 + await addToZip(zip, "/"); + + // 生成zip文件内容 + return zip.generateAsync({ + type: "uint8array", + compression: "DEFLATE", // 使用压缩 + compressionOptions: { level: 6 }, + }); + } + export async function clear() { + return fs.clear(); + } + export function getFS() { + return fs; + } +} diff --git a/src/core/service/dataGenerateService/stageExportEngine/stageExportEngine.tsx b/src/core/service/dataGenerateService/stageExportEngine/stageExportEngine.tsx index 1ee9af64..222038b9 100644 --- a/src/core/service/dataGenerateService/stageExportEngine/stageExportEngine.tsx +++ b/src/core/service/dataGenerateService/stageExportEngine/stageExportEngine.tsx @@ -1,4 +1,4 @@ -import { writeTextFile } from "../../../../utils/fs"; +import { writeTextFile } from "../../../../utils/fs/com"; import { GraphMethods } from "../../../stage/stageManager/basicMethods/GraphMethods"; import { ConnectableEntity } from "../../../stage/stageObject/abstract/ConnectableEntity"; import { Entity } from "../../../stage/stageObject/abstract/StageEntity"; diff --git a/src/core/service/dataManageService/copyEngine/copyEngine.tsx b/src/core/service/dataManageService/copyEngine/copyEngine.tsx index f992126d..185f62e9 100644 --- a/src/core/service/dataManageService/copyEngine/copyEngine.tsx +++ b/src/core/service/dataManageService/copyEngine/copyEngine.tsx @@ -1,7 +1,6 @@ import { v4 as uuidv4 } from "uuid"; import { Dialog } from "../../../../components/dialog"; import { Serialized } from "../../../../types/node"; -import { writeFileBase64 } from "../../../../utils/fs"; import { PathString } from "../../../../utils/pathString"; import { Rectangle } from "../../../dataStruct/shape/Rectangle"; import { Vector } from "../../../dataStruct/Vector"; @@ -15,6 +14,7 @@ import { ImageNode } from "../../../stage/stageObject/entity/ImageNode"; import { TextNode } from "../../../stage/stageObject/entity/TextNode"; import { UrlNode } from "../../../stage/stageObject/entity/UrlNode"; import { MouseLocation } from "../../controlService/MouseLocation"; +import { VFileSystem } from "../../dataFileService/VFileSystem"; /** * 专门用来管理节点复制的引擎 @@ -129,13 +129,14 @@ async function readClipboardItems(mouseLocation: Vector) { } const blob = await item.getType(item.types[0]); // 获取 Blob 对象 const imageUUID = uuidv4(); - const folder = PathString.dirPath(Stage.path.getFilePath()); - const imagePath = `${folder}${PathString.getSep()}${imageUUID}.png`; + //const folder = PathString.dirPath(Stage.path.getFilePath()); + await VFileSystem.getFS().writeFile(`/picture/${imageUUID}.png`, await blob.bytes()); + //const imagePath = `${folder}${PathString.getSep()}${imageUUID}.png`; // 2024.12.31 测试发现这样的写法会导致读取时base64解码失败 // writeFile(imagePath, new Uint8Array(await blob.arrayBuffer())); // 下面这样的写法是没有问题的 - writeFileBase64(imagePath, await convertBlobToBase64(blob)); + // writeFileBase64(imagePath, await convertBlobToBase64(blob)); // 要延迟一下,等待保存完毕 setTimeout(() => { @@ -190,17 +191,17 @@ function blobToText(blob: Blob): Promise { }); } -async function convertBlobToBase64(blob: Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => { - if (typeof reader.result === "string") { - resolve(reader.result.split(",")[1]); // 去掉"data:image/png;base64,"前缀 - } else { - reject(new Error("Invalid result type")); - } - }; - reader.onerror = reject; - reader.readAsDataURL(blob); - }); -} +// async function convertBlobToBase64(blob: Blob): Promise { +// return new Promise((resolve, reject) => { +// const reader = new FileReader(); +// reader.onloadend = () => { +// if (typeof reader.result === "string") { +// resolve(reader.result.split(",")[1]); // 去掉"data:image/png;base64,"前缀 +// } else { +// reject(new Error("Invalid result type")); +// } +// }; +// reader.onerror = reject; +// reader.readAsDataURL(blob); +// }); +// } diff --git a/src/core/service/feedbackService/SoundService.tsx b/src/core/service/feedbackService/SoundService.tsx index c4c1cfa5..6fe304f5 100644 --- a/src/core/service/feedbackService/SoundService.tsx +++ b/src/core/service/feedbackService/SoundService.tsx @@ -2,7 +2,7 @@ // @tauri-apps/plugin-fs 只能读取文本文件,不能强行读取流文件并强转为ArrayBuffer // import { readTextFile } from "@tauri-apps/plugin-fs"; -import { readFile } from "../../../utils/fs"; +import { readFile } from "../../../utils/fs/com"; import { StringDict } from "../../dataStruct/StringDict"; import { Settings } from "../Settings"; diff --git a/src/core/stage/stageObject/entity/ImageNode.tsx b/src/core/stage/stageObject/entity/ImageNode.tsx index a4b39f43..620f2d6f 100644 --- a/src/core/stage/stageObject/entity/ImageNode.tsx +++ b/src/core/stage/stageObject/entity/ImageNode.tsx @@ -1,12 +1,11 @@ -import { join } from "@tauri-apps/api/path"; import { Serialized } from "../../../../types/node"; -import { readFileBase64 } from "../../../../utils/fs"; import { PathString } from "../../../../utils/pathString"; import { Rectangle } from "../../../dataStruct/shape/Rectangle"; import { Vector } from "../../../dataStruct/Vector"; import { Stage } from "../../Stage"; import { ConnectableEntity } from "../abstract/ConnectableEntity"; import { CollisionBox } from "../collisionBox/collisionBox"; +import { VFileSystem } from "../../../service/dataFileService/VFileSystem"; /** * 一个图片节点 @@ -109,13 +108,14 @@ export class ImageNode extends ConnectableEntity { * @param folderPath 工程文件所在路径文件夹,不加尾部斜杠 * @returns */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars public updateBase64StringByPath(folderPath: string) { if (this.path === "") { return; } - join(folderPath, this.path) - .then((path) => readFileBase64(path)) + VFileSystem.getFS() + .readFileBase64(`/picture/${this.path}`) .then((res) => { // 获取base64String成功 diff --git a/src/main.tsx b/src/main.tsx index d82a89b5..985d1c83 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -38,10 +38,12 @@ import { EdgeCollisionBoxGetter } from "./core/stage/stageObject/association/Edg import "./index.css"; import { ColorPanel } from "./pages/_toolbar"; import "./polyfills/roundRect"; -import { exists } from "./utils/fs"; +import { exists } from "./utils/fs/com"; import { exit, openDevtools, writeStderr, writeStdout } from "./utils/otherApi"; import { getCurrentWindow, isDesktop, isWeb } from "./utils/platform"; import { Tourials } from "./core/service/Tourials"; +// import { VFileSystem } from "./core/service/dataFileService/VFileSystem"; +import { IndexedDBFileSystem } from "./utils/fs/IndexedDBFileSystem"; const router = createMemoryRouter(routes); const Routes = () => ; @@ -53,6 +55,7 @@ const el = document.getElementById("root")!; (async () => { const matches = !isWeb && isDesktop ? await getMatches() : null; const isCliMode = isDesktop && matches?.args.output?.occurrences === 1; + IndexedDBFileSystem.testFileSystem("A", "B"); await Promise.all([ Settings.init(), RecentFileManager.init(), diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index b8bba138..14a80f5a 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -21,6 +21,7 @@ import LogicNodePanel from "./_logic_node_panel"; import RecentFilesPanel from "./_recent_files_panel"; import StartFilePanel from "./_start_file_panel"; import TagPanel from "./_tag_panel"; +import { VFileSystem } from "../core/service/dataFileService/VFileSystem"; export default function App() { const [maxmized, setMaxmized] = React.useState(false); @@ -104,6 +105,7 @@ export default function App() { { text: "不保存", onClick: async () => { + await VFileSystem.clear(); await getCurrentWindow().destroy(); }, }, @@ -118,10 +120,12 @@ export default function App() { if (isAutoSave) { // 开启了自动保存,不弹窗 await StageSaveManager.saveHandle(file, StageDumper.dump()); + await VFileSystem.clear(); getCurrentWindow().destroy(); } else { // 没开启自动保存,逐步确认 if (StageSaveManager.isSaved()) { + await VFileSystem.clear(); getCurrentWindow().destroy(); } else { await Dialog.show({ @@ -132,6 +136,7 @@ export default function App() { text: "保存并关闭", onClick: async () => { await StageSaveManager.saveHandle(file, StageDumper.dump()); + await VFileSystem.clear(); await getCurrentWindow().destroy(); }, }, diff --git a/src/pages/_app_menu.tsx b/src/pages/_app_menu.tsx index 23821435..5fcbfaeb 100644 --- a/src/pages/_app_menu.tsx +++ b/src/pages/_app_menu.tsx @@ -46,6 +46,7 @@ import { Stage } from "../core/stage/Stage"; import { GraphMethods } from "../core/stage/stageManager/basicMethods/GraphMethods"; import { TextNode } from "../core/stage/stageObject/entity/TextNode"; import { PathString } from "../utils/pathString"; +import { VFileSystem } from "../core/service/dataFileService/VFileSystem"; export default function AppMenu({ className = "", open = false }: { className?: string; open: boolean }) { const navigate = useNavigate(); @@ -57,9 +58,10 @@ export default function AppMenu({ className = "", open = false }: { className?: /** * 新建草稿 */ - const onNewDraft = () => { + const onNewDraft = async () => { if (StageSaveManager.isSaved() || StageManager.isEmpty()) { StageManager.destroy(); + await VFileSystem.clear(); setFile("Project Graph"); } else { // 当前文件未保存 @@ -76,8 +78,9 @@ export default function AppMenu({ className = "", open = false }: { className?: }, { text: "丢弃当前并直接新开", - onClick: () => { + onClick: async () => { StageManager.destroy(); + await VFileSystem.clear(); setFile("Project Graph"); }, }, @@ -132,7 +135,7 @@ export default function AppMenu({ className = "", open = false }: { className?: const openFileByDialogWindow = async () => { const path = isWeb - ? "file.json" + ? "file.gp" : await openFileDialog({ title: "打开文件", directory: false, @@ -141,7 +144,7 @@ export default function AppMenu({ className = "", open = false }: { className?: ? [ { name: "Project Graph", - extensions: ["json"], + extensions: ["gp"], }, ] : [], @@ -149,9 +152,9 @@ export default function AppMenu({ className = "", open = false }: { className?: if (!path) { return; } - if (isDesktop && !path.endsWith(".json")) { + if (isDesktop && !path.endsWith(".gp")) { Dialog.show({ - title: "请选择一个JSON文件", + title: "请选择一个gp文件", type: "error", }); return; @@ -162,7 +165,7 @@ export default function AppMenu({ className = "", open = false }: { className?: setFile(path); } catch (e) { Dialog.show({ - title: "请选择正确的JSON文件", + title: "请选择正确的gp文件", content: String(e), type: "error", }); @@ -183,7 +186,8 @@ export default function AppMenu({ className = "", open = false }: { className?: // await writeTextFile(path, JSON.stringify(data, null, 2)); // 将数据写入文件 try { await StageSaveManager.saveHandle(path_, data); - } catch { + } catch (e) { + console.error(e); await Dialog.show({ title: "保存失败", content: "保存失败,请重试", @@ -193,14 +197,14 @@ export default function AppMenu({ className = "", open = false }: { className?: const onSaveNew = async () => { const path = isWeb - ? "file.json" + ? "file.gp" : await saveFileDialog({ title: "另存为", - defaultPath: "新文件.json", // 提供一个默认的文件名 + defaultPath: "新文件.gp", // 提供一个默认的文件名 filters: [ { name: "Project Graph", - extensions: ["json"], + extensions: ["gp"], }, ], }); @@ -213,7 +217,8 @@ export default function AppMenu({ className = "", open = false }: { className?: try { await StageSaveManager.saveHandle(path, data); setFile(path); - } catch { + } catch (e) { + console.error(e); await Dialog.show({ title: "保存失败", content: "保存失败,请重试", diff --git a/src/pages/_recent_files_panel.tsx b/src/pages/_recent_files_panel.tsx index 7cd5b18b..4e1f8eef 100644 --- a/src/pages/_recent_files_panel.tsx +++ b/src/pages/_recent_files_panel.tsx @@ -76,9 +76,9 @@ export default function RecentFilesPanel() { try { const path = file.path; setFile(decodeURIComponent(path)); - if (isDesktop && !path.endsWith(".json")) { + if (isDesktop && !path.endsWith(".gp")) { Dialog.show({ - title: "请选择一个JSON文件", + title: "请选择一个GP文件", type: "error", }); return; @@ -87,7 +87,7 @@ export default function RecentFilesPanel() { setRecentFilePanelOpen(false); } catch (error) { Dialog.show({ - title: "请选择正确的JSON文件", + title: "请选择正确的GP文件", content: String(error), type: "error", }); diff --git a/src/pages/_start_file_panel.tsx b/src/pages/_start_file_panel.tsx index 30f94fe7..fdc98bb8 100644 --- a/src/pages/_start_file_panel.tsx +++ b/src/pages/_start_file_panel.tsx @@ -72,7 +72,7 @@ export default function StartFilePanel({ open = false }: { open: boolean }) { ? [ { name: "Project Graph", - extensions: ["json"], + extensions: ["gp"], }, ] : [], @@ -80,9 +80,9 @@ export default function StartFilePanel({ open = false }: { open: boolean }) { if (!path) { return; } - if (isDesktop && !path.endsWith(".json")) { + if (isDesktop && !path.endsWith(".gp")) { Dialog.show({ - title: "请选择一个JSON文件", + title: "请选择一个gp文件", type: "error", }); return; @@ -99,7 +99,7 @@ export default function StartFilePanel({ open = false }: { open: boolean }) { updateStartFiles(); } catch (e) { Dialog.show({ - title: "请选择正确的JSON文件", + title: "请选择正确的gp文件", content: String(e), type: "error", }); @@ -157,9 +157,9 @@ export default function StartFilePanel({ open = false }: { open: boolean }) { const checkoutFile = (path: string) => { try { setFile(decodeURIComponent(path)); - if (isDesktop && !path.endsWith(".json")) { + if (isDesktop && !path.endsWith(".gp")) { Dialog.show({ - title: "请选择一个JSON文件", + title: "请选择一个GP文件", type: "error", }); return; diff --git a/src/pages/_toolbar.tsx b/src/pages/_toolbar.tsx index 5d95bc0a..89b58093 100644 --- a/src/pages/_toolbar.tsx +++ b/src/pages/_toolbar.tsx @@ -40,7 +40,7 @@ import { cn } from "../utils/cn"; // import { StageSaveManager } from "../core/stage/StageSaveManager"; import { Dialog } from "../components/dialog"; import { Popup } from "../components/popup"; -import { writeTextFile } from "../utils/fs"; +import { writeTextFile } from "../utils/fs/com"; // import { PathString } from "../utils/pathString"; import { CopyEngine } from "../core/service/dataManageService/copyEngine/copyEngine"; import { ColorManager } from "../core/service/feedbackService/ColorManager"; @@ -477,11 +477,11 @@ export default function Toolbar({ className = "" }: { className?: string }) { const onSaveSelectedNew = async () => { const path = await saveFileDialog({ title: "另存为", - defaultPath: "新文件.json", // 提供一个默认的文件名 + defaultPath: "新文件.gp", // 提供一个默认的文件名 filters: [ { name: "Project Graph", - extensions: ["json"], + extensions: ["gp"], }, ], }); diff --git a/src/pages/index.tsx b/src/pages/index.tsx index f20fbb48..909d1795 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -13,6 +13,7 @@ import SearchingNodePanel from "./_searching_node_panel"; import Toolbar from "./_toolbar"; import Button from "../components/Button"; import { isMobile } from "../utils/platform"; +// import { WebFileApiSystem } from "../utils/fs/WebFileApiSystem"; export default function Home() { const canvasRef: React.RefObject = useRef(null); @@ -54,6 +55,11 @@ export default function Home() { }); } + // window.addEventListener("click", () => { + // const p = window.showDirectoryPicker(); + // const sys = new WebFileApiSystem(p); + // sys.mkdir("/a/b/c/d/e/f", true); + // }); window.addEventListener("resize", handleResize); window.addEventListener("focus", handleFocus); window.addEventListener("blur", handleBlur); diff --git a/src/utils/fs/FileSystemFactory.tsx b/src/utils/fs/FileSystemFactory.tsx new file mode 100644 index 00000000..75bac99b --- /dev/null +++ b/src/utils/fs/FileSystemFactory.tsx @@ -0,0 +1,15 @@ +import { IFileSystem } from "./IFileSystem"; +import { TauriFileSystem } from "./TauriFileSystem"; + +export type StorageType = "tauri" | "memory"; + +export class FileSystemFactory { + static create(storageType: StorageType = "tauri"): IFileSystem { + switch (storageType) { + case "tauri": + return new TauriFileSystem(); + default: + throw new Error(`Unsupported storage type: ${storageType}`); + } + } +} diff --git a/src/utils/fs/IFileSystem.tsx b/src/utils/fs/IFileSystem.tsx new file mode 100644 index 00000000..4f8ac636 --- /dev/null +++ b/src/utils/fs/IFileSystem.tsx @@ -0,0 +1,117 @@ +export interface FileStats { + name: string; + isDir: boolean; + size: number; + modified: Date; +} + +export interface DirectoryEntry { + name: string; + isDir: boolean; +} +export function base64ToUint8Array(base64String: string) { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + + const sliceSize = 572; + const bytes = new Uint8Array((base64.length / 4) * 3); + let byteIndex = 0; + + for (let offset = 0; offset < base64.length; offset += sliceSize) { + const slice = base64.slice(offset, offset + sliceSize); + + const byteChars = atob(slice); + + for (let i = 0; i < byteChars.length; i++) { + bytes[byteIndex++] = byteChars.charCodeAt(i); + } + } + + return bytes.subarray(0, byteIndex); +} + +export function uint8ArrayToBase64(u8Arr: Uint8Array): string { + let binaryStr = ""; + for (let i = 0; i < u8Arr.length; i++) { + binaryStr += String.fromCharCode(u8Arr[i]); + } + return btoa(binaryStr); +} + +// 强制使用 `/` 作为分隔符 +export abstract class IFileSystem { + // 私有方法:统一规范化路径格式 + static normalizePath(path: string): string { + return path.replace(/[\\/]+/g, "/"); // 替换所有分隔符为 / + } + + // 抽象原始方法(带下划线版本) + abstract _readFile(path: string): Promise; + abstract _writeFile( + path: string, + content: Uint8Array | string, + ): Promise; + abstract _readDir(path: string): Promise; + abstract _mkdir(path: string, recursive?: boolean): Promise; + abstract _stat(path: string): Promise; + abstract _rename(oldPath: string, newPath: string): Promise; + abstract _deleteFile(path: string): Promise; + abstract _deleteDirectory(path: string): Promise; + abstract _exists(path: string): Promise; + + // 公共方法(自动处理路径分隔符) + readFile(path: string) { + return this._readFile(IFileSystem.normalizePath(path)); + } + + writeFile(path: string, content: Uint8Array | string) { + return this._writeFile(IFileSystem.normalizePath(path), content); + } + + readDir(path: string) { + return this._readDir(IFileSystem.normalizePath(path)); + } + + mkdir(path: string, recursive?: boolean) { + return this._mkdir(IFileSystem.normalizePath(path), recursive); + } + + stat(path: string) { + return this._stat(IFileSystem.normalizePath(path)); + } + + rename(oldPath: string, newPath: string) { + return this._rename( + IFileSystem.normalizePath(oldPath), + IFileSystem.normalizePath(newPath), + ); + } + + deleteFile(path: string) { + return this._deleteFile(IFileSystem.normalizePath(path)); + } + + deleteDirectory(path: string) { + return this._deleteDirectory(IFileSystem.normalizePath(path)); + } + + exists(path: string) { + return this._exists(IFileSystem.normalizePath(path)); + } + + async readTextFile(path: string) { + const content = await this.readFile(path); // 注意这里调用的是处理后的公共方法 + return new TextDecoder("utf-8").decode(content); + } + async writeTextFile(path: string, content: string) { + const text = new TextEncoder().encode(content); + return this.writeFile(path, text); // 注意这里调用的是处理后的公共方法 + } + + async readFileBase64(path: string) { + return uint8ArrayToBase64(await this.readFile(path)); + } + async writeFileBase64(path: string, str: string) { + return this.writeFile(path, base64ToUint8Array(str)); + } +} diff --git a/src/utils/fs/IndexedDBFileSystem.tsx b/src/utils/fs/IndexedDBFileSystem.tsx new file mode 100644 index 00000000..e5c49314 --- /dev/null +++ b/src/utils/fs/IndexedDBFileSystem.tsx @@ -0,0 +1,359 @@ +import { IFileSystem, type FileStats, type DirectoryEntry } from "./IFileSystem"; + +const DB_VERSION = 1; + +function asPromise(i: IDBRequest) { + return new Promise((resolve, reject) => { + i.onsuccess = () => resolve(i.result); + i.onerror = () => reject(i.error); + }); +} + +export class IndexedDBFileSystem extends IFileSystem { + private db: IDBDatabase | null = null; + private DIR_STORE_NAME: string; + constructor( + private DB_NAME: string, + private STORE_NAME: string, + ) { + super(); + this.DIR_STORE_NAME = STORE_NAME + "_DIR"; + this.initDB(); + } + + private async initDB(): Promise { + const request = indexedDB.open(this.DB_NAME, DB_VERSION); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(this.STORE_NAME)) { + db.createObjectStore(this.STORE_NAME, { keyPath: "path" }); + } + if (!db.objectStoreNames.contains(this.DIR_STORE_NAME)) { + db.createObjectStore(this.DIR_STORE_NAME, { keyPath: "path" }); + } + }; + this.db = await asPromise(request); + } + + private async getTransaction( + storeNames: string | string[], + mode: IDBTransactionMode = "readonly", + ): Promise { + if (!this.db) { + await this.initDB(); + } + return this.db!.transaction(storeNames, mode); + } + + private async getStore(storeName: string, mode: IDBTransactionMode = "readonly"): Promise { + const transaction = await this.getTransaction(storeName, mode); + return transaction.objectStore(storeName); + } + + private normalizePath(path: string): string { + let normalized = path.replace(/\/+/g, "/").replace(/\/$/, ""); + if (normalized === "") return "/"; + if (!normalized.startsWith("/")) normalized = "/" + normalized; + return normalized; + } + + private getParentPath(path: string): string | null { + const normalized = this.normalizePath(path); + if (normalized === "/") return null; + const parts = normalized.split("/").filter((p) => p !== ""); + if (parts.length === 0) return null; + parts.pop(); + return parts.length === 0 ? "/" : `/${parts.join("/")}`; + } + + private async addEntryToParent(childPath: string, isDir: boolean): Promise { + const parentPath = this.getParentPath(childPath); + if (!parentPath) return; + + // 确保父目录存在(递归创建) + if (!(await this._exists(parentPath))) { + await this._mkdir(parentPath, true); + } + + const store = await this.getStore(this.DIR_STORE_NAME, "readwrite"); + const parentDir = await asPromise(store.get(parentPath)); + const childName = childPath.split("/").pop()!; + + if (!parentDir) { + throw new Error(`Parent directory ${parentPath} not found`); + } + + const exists = parentDir.entries.some((e: DirectoryEntry) => e.name === childName); + if (!exists) { + parentDir.entries.push({ name: childName, isDir }); + await asPromise(store.put(parentDir)); + } + } + + async _exists(path: string): Promise { + path = this.normalizePath(path); + const trans = await this.getTransaction([this.STORE_NAME, this.DIR_STORE_NAME], "readonly"); + const fileExists = !!(await asPromise(trans.objectStore(this.STORE_NAME).get(path))); + if (fileExists) return true; + return !!(await asPromise(trans.objectStore(this.DIR_STORE_NAME).get(path))); + } + + async _readFile(path: string): Promise { + const store = await this.getStore(this.STORE_NAME); + const result = await asPromise(store.get(path)); + if (result) return result.content; + throw new Error(`File not found: ${path}`); + } + + async _mkdir(path: string, recursive = false): Promise { + path = this.normalizePath(path); + if (path === "/") { + const exists = await this._exists("/"); + if (!exists) { + const store = await this.getStore(this.DIR_STORE_NAME, "readwrite"); + await asPromise(store.put({ path: "/", entries: [] })); + } + return; + } + + if (!recursive) { + const parentPath = this.getParentPath(path); + if (parentPath && !(await this._exists(parentPath))) { + throw new Error(`Parent directory does not exist: ${parentPath}`); + } + const store = await this.getStore(this.DIR_STORE_NAME, "readwrite"); + await asPromise(store.put({ path, entries: [] })); + if (parentPath) await this.addEntryToParent(path, true); + return; + } + + const parts = path.split("/").filter((p) => p !== ""); + let currentPath = ""; + const pathsToCreate: string[] = []; + for (const part of parts) { + currentPath = currentPath ? `${currentPath}/${part}` : `/${part}`; + currentPath = this.normalizePath(currentPath); + if (!(await this._exists(currentPath))) { + pathsToCreate.push(currentPath); + } + } + + const store = await this.getStore(this.DIR_STORE_NAME, "readwrite"); + for (const p of pathsToCreate) { + await asPromise(store.put({ path: p, entries: [] })); + await this.addEntryToParent(p, true); + } + } + + async _writeFile(path: string, content: Uint8Array | string): Promise { + path = this.normalizePath(path); + const parentPath = this.getParentPath(path); + if (parentPath) { + await this._mkdir(parentPath, true); + } + + const store = await this.getStore(this.STORE_NAME, "readwrite"); + const data = typeof content === "string" ? new TextEncoder().encode(content) : content; + await asPromise(store.put({ path, content: data })); + await this.addEntryToParent(path, false); + } + + async _readDir(path: string): Promise { + path = this.normalizePath(path); + const store = await this.getStore(this.DIR_STORE_NAME); + const dirEntry = await asPromise(store.get(path)); + if (!dirEntry) { + throw new Error(`Directory not found: ${path}`); + } + return dirEntry.entries; + } + + async _stat(path: string): Promise { + const fileStore = await this.getStore(this.STORE_NAME); + const dirStore = await this.getStore(this.DIR_STORE_NAME); + const file = await asPromise(fileStore.get(path)); + if (file) { + return { + name: path.split("/").pop() || "", + isDir: false, + size: file.content.byteLength, + modified: new Date(), + }; + } + const dir = await asPromise(dirStore.get(path)); + if (dir) { + return { + name: path.split("/").pop() || "", + isDir: true, + size: 0, + modified: new Date(), + }; + } + throw new Error(`Path not found: ${path}`); + } + + async _rename(oldPath: string, newPath: string): Promise { + oldPath = this.normalizePath(oldPath); + newPath = this.normalizePath(newPath); + const transaction = await this.getTransaction([this.STORE_NAME, this.DIR_STORE_NAME], "readwrite"); + const filesStore = transaction.objectStore(this.STORE_NAME); + const dirsStore = transaction.objectStore(this.DIR_STORE_NAME); + + const isDir = !!(await asPromise(dirsStore.get(oldPath))); + if (isDir) { + // 处理目录重命名及子项 + const dir = await asPromise(dirsStore.get(oldPath)); + if (!dir) throw new Error(`Directory not found: ${oldPath}`); + await asPromise(dirsStore.delete(oldPath)); + await asPromise(dirsStore.put({ ...dir, path: newPath })); + + // 更新所有子路径 + const filesCursor = await asPromise(filesStore.openCursor()); + const dirsCursor = await asPromise(dirsStore.openCursor()); + const updateEntries = async (cursor: IDBCursorWithValue | null) => { + while (cursor) { + const oldChildPath = cursor.key as string; + if (oldChildPath.startsWith(`${oldPath}/`)) { + const newChildPath = newPath + oldChildPath.slice(oldPath.length); + if (cursor.source.name === this.STORE_NAME) { + await asPromise(filesStore.delete(oldChildPath)); + await asPromise(filesStore.put({ ...cursor.value, path: newChildPath })); + } else { + await asPromise(dirsStore.delete(oldChildPath)); + await asPromise(dirsStore.put({ ...cursor.value, path: newChildPath })); + } + } + cursor.continue(); + } + }; + await updateEntries(filesCursor); + await updateEntries(dirsCursor); + } else { + // 处理文件重命名 + const file = await asPromise(filesStore.get(oldPath)); + if (!file) throw new Error(`File not found: ${oldPath}`); + await asPromise(filesStore.delete(oldPath)); + await asPromise(filesStore.put({ ...file, path: newPath })); + } + + // 更新父目录条目 + const oldParentPath = this.getParentPath(oldPath); + const newParentPath = this.getParentPath(newPath); + if (oldParentPath) { + const oldParentStore = await this.getStore(this.DIR_STORE_NAME, "readwrite"); + const oldParent = await asPromise(oldParentStore.get(oldParentPath)); + if (oldParent) { + const entryName = oldPath.split("/").pop()!; + oldParent.entries = oldParent.entries.filter((e: DirectoryEntry) => e.name !== entryName); + await asPromise(oldParentStore.put(oldParent)); + } + } + if (newParentPath) { + await this.addEntryToParent(newPath, isDir); + } + } + + async _deleteFile(path: string): Promise { + const store = await this.getStore(this.STORE_NAME, "readwrite"); + await asPromise(store.delete(path)); + const parentPath = this.getParentPath(path); + if (parentPath) { + const parentStore = await this.getStore(this.DIR_STORE_NAME, "readwrite"); + const parent = await asPromise(parentStore.get(parentPath)); + if (parent) { + const entryName = path.split("/").pop()!; + parent.entries = parent.entries.filter((e: DirectoryEntry) => e.name !== entryName); + await asPromise(parentStore.put(parent)); + } + } + } + + async _deleteDirectory(path: string): Promise { + const store = await this.getStore(this.DIR_STORE_NAME, "readwrite"); + await asPromise(store.delete(path)); + const parentPath = this.getParentPath(path); + if (parentPath) { + const parentStore = await this.getStore(this.DIR_STORE_NAME, "readwrite"); + const parent = await asPromise(parentStore.get(parentPath)); + if (parent) { + const entryName = path.split("/").pop()!; + parent.entries = parent.entries.filter((e: DirectoryEntry) => e.name !== entryName); + await asPromise(parentStore.put(parent)); + } + } + } + + async clear() { + const filesStore = await this.getStore(this.STORE_NAME, "readwrite"); + await asPromise(filesStore.clear()); + const dirsStore = await this.getStore(this.DIR_STORE_NAME, "readwrite"); + await asPromise(dirsStore.clear()); + } + + /** + * 验证文件内容是否匹配 + * @param fs 文件系统实例 + * @param path 文件路径 + * @param expectedContent 预期内容 + */ + private static async verifyFileContent( + fs: IndexedDBFileSystem, + path: string, + expectedContent: string, + ): Promise { + const content = await fs.readFile(path); + const actualContent = new TextDecoder("utf-8").decode(content); + if (actualContent !== expectedContent) { + throw new Error( + `File content verification failed at ${path}\n` + `Expected: ${expectedContent}\n` + `Actual: ${actualContent}`, + ); + } + } + + /** + * 测试IndexedDB文件系统功能 + * @param dbName 测试数据库名称 + * @param storeName 测试存储名称 + */ + static async testFileSystem(dbName: string, storeName: string): Promise { + const fs = new IndexedDBFileSystem(dbName, storeName); + + // 初始化数据库 + await fs.initDB(); + + // 测试目录操作 + const testDirPath = "/test-dir"; + await fs.mkdir(testDirPath, true); + console.log(`Created directory: ${testDirPath}`); + + // 测试文件操作 + const testFilePath = `${testDirPath}/test.txt`; + const testContent = "Hello IndexedDB File System!"; + + // 写入前验证文件不存在 + if (await fs.exists(testFilePath)) { + throw new Error(`File already exists: ${testFilePath}`); + } + + // 写入文件 + await fs.writeFile(testFilePath, testContent); + console.log(`Wrote file: ${testFilePath}`); + + // 写入后验证内容 + await this.verifyFileContent(fs, testFilePath, testContent); + console.log(`Verified file content: ${testFilePath}`); + + // 删除文件 + await fs.deleteFile(testFilePath); + console.log(`Deleted file: ${testFilePath}`); + + // 删除目录 + await fs.deleteDirectory(testDirPath); + console.log(`Deleted directory: ${testDirPath}`); + + // 清理测试数据库 + indexedDB.deleteDatabase(dbName); + console.log(`Cleaned up test database: ${dbName}`); + } +} diff --git a/src/utils/fs/TauriFileSystem.tsx b/src/utils/fs/TauriFileSystem.tsx new file mode 100644 index 00000000..b56a42b0 --- /dev/null +++ b/src/utils/fs/TauriFileSystem.tsx @@ -0,0 +1,121 @@ +import { invoke } from "@tauri-apps/api/core"; +import { IFileSystem, FileStats, DirectoryEntry } from "./IFileSystem"; + +/** + * Tauri 文件系统工具类 + */ +export class TauriFileSystem extends IFileSystem { + constructor(private basePath: string = "") { + super(); + } + async _exists(path: string): Promise { + return invoke("exists", { path: this.basePath + path }); + } + + async _readFile(path: string): Promise { + return new Uint8Array( + await invoke("read_file", { path: this.basePath + path }), + ); + } + + async _writeFile(path: string, content: Uint8Array | string): Promise { + let data: Uint8Array; + if (typeof content === "string") { + data = new TextEncoder().encode(content); + } else { + data = content; + } + return invoke("write_file", { + path: this.basePath + path, + content: Array.from(data), + }); + } + + async _mkdir(path: string, recursive = false): Promise { + return invoke("mkdir", { path: this.basePath + path, recursive }); + } + + async _stat(path: string): Promise { + return invoke("stat", { path: this.basePath + path }); + } + + async _readDir(path: string): Promise { + return invoke("read_dir", { path: this.basePath + path }); + } + + async _rename(oldPath: string, newPath: string): Promise { + return invoke("rename", { + oldPath: this.basePath + oldPath, + newPath: this.basePath + newPath, + }); + } + + async _deleteFile(path: string): Promise { + return invoke("delete_file", { path: this.basePath + path }); + } + + async _deleteDirectory(path: string): Promise { + return invoke("delete_directory", { path: this.basePath + path }); + } + + /** + * 验证文件内容是否匹配 + * @param fs 文件系统实例 + * @param path 文件路径 + * @param expectedContent 预期内容 + */ + private static async verifyFileContent( + fs: TauriFileSystem, + path: string, + expectedContent: string, + ): Promise { + const content = await fs.readFile(path); + const actualContent = new TextDecoder("utf-8").decode(content); + if (actualContent !== expectedContent) { + throw new Error( + `File content verification failed at ${path}\n` + + `Expected: ${expectedContent}\n` + + `Actual: ${actualContent}`, + ); + } + } + + /** + * 测试Tauri文件系统功能 + * @param dirPath 要测试的目录路径 + */ + static async testFileSystem(dirPath: string): Promise { + const fs = new TauriFileSystem(); + + // 测试目录操作 + await fs.mkdir(dirPath, true); + console.log(`Created directory: ${dirPath}`); + + // 测试文件操作 + const testFilePath = `${dirPath}/test.txt`; + const testContent = "Hello Tauri File System!"; + + // 写入前验证文件不存在 + if (await fs.exists(testFilePath)) { + throw new Error(`File already exists: ${testFilePath}`); + } + + // 写入文件 + await fs.writeFile(testFilePath, testContent); + console.log(`Wrote file: ${testFilePath}`); + + // 写入后验证内容 + await this.verifyFileContent(fs, testFilePath, testContent); + console.log(`Verified file content: ${testFilePath}`); + + // 删除文件 + await fs.deleteFile(testFilePath); + console.log(`Deleted file: ${testFilePath}`); + + // 删除目录 + await fs.deleteDirectory(dirPath); + console.log(`Deleted directory: ${dirPath}`); + } +} + +export const TauriBaseFS = new TauriFileSystem(); diff --git a/src/utils/fs/WebFileApiSystem.tsx b/src/utils/fs/WebFileApiSystem.tsx new file mode 100644 index 00000000..8379d678 --- /dev/null +++ b/src/utils/fs/WebFileApiSystem.tsx @@ -0,0 +1,192 @@ +import { + IFileSystem, + type FileStats, + type DirectoryEntry, +} from "./IFileSystem"; + +type FSAPHandle = FileSystemDirectoryHandle; + +export class WebFileApiSystem extends IFileSystem { + constructor(private rootHandle: FSAPHandle) { + super(); + } + + private async resolvePathComponents(path: string): Promise { + return IFileSystem.normalizePath(path) + .split("/") + .filter((p) => p !== ""); + } + + private async resolveHandle(path: string): Promise { + const parts = await this.resolvePathComponents(path); + let currentHandle: FileSystemHandle = this.rootHandle; + + for (const part of parts) { + if (currentHandle.kind !== "directory") { + throw new Error(`Cannot traverse into non-directory at: ${part}`); + } + + try { + currentHandle = await ( + currentHandle as FileSystemDirectoryHandle + ).getDirectoryHandle(part); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (dirError) { + try { + currentHandle = await ( + currentHandle as FileSystemDirectoryHandle + ).getFileHandle(part); + // 提前终止检查:文件节点不能在路径中间 + if (part !== parts[parts.length - 1]) { + throw new Error(`File node cannot be in path middle: ${path}`); + } + } catch (fileError) { + throw new Error(`Path resolution failed: ${path} (${fileError})`); + } + } + } + return currentHandle; + } + + async _readFile(path: string): Promise { + const handle = await this.resolveHandle(path); + if (handle.kind !== "file") { + throw new Error(`Path is not a file: ${path}`); + } + const file = await (handle as FileSystemFileHandle).getFile(); + return new Uint8Array(await file.arrayBuffer()); + } + + async _writeFile(path: string, content: Uint8Array | string): Promise { + const buffer = + typeof content === "string" ? new TextEncoder().encode(content) : content; + + const parts = await this.resolvePathComponents(path); + const fileName = parts.pop()!; + const parentHandle = await this.ensureDirectoryPath(parts); + + const fileHandle = await parentHandle.getFileHandle(fileName, { + create: true, + }); + const writable = await fileHandle.createWritable(); + await writable.write(buffer); + await writable.close(); + } + + private async ensureDirectoryPath( + parts: string[], + recursive = false, + ): Promise { + let currentHandle = this.rootHandle; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + currentHandle = await currentHandle.getDirectoryHandle(part, { + create: recursive, + }); + } + return currentHandle; + } + + async _readDir(path: string): Promise { + const handle = await this.resolveHandle(path); + if (handle.kind !== "directory") { + throw new Error(`Path is not a directory: ${path}`); + } + + const entries: DirectoryEntry[] = []; + + for await (const [name, entry] of (handle as FileSystemDirectoryHandle) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + .entries()) { + entries.push({ name, isDir: entry.kind === "directory" }); + } + return entries; + } + + async _mkdir(path: string, recursive = false): Promise { + const parts = await this.resolvePathComponents(path); + await this.ensureDirectoryPath(parts, recursive); + } + + async _stat(path: string): Promise { + const handle = await this.resolveHandle(path); + let size = 0; + if (handle.kind === "file") { + const file = await (handle as FileSystemFileHandle).getFile(); + size = file.size; + } + return { + name: path.split("/").pop() || "", + isDir: handle.kind === "directory", + size, + modified: new Date(), // 使用当前时间作为替代方案 + }; + } + + async _rename(oldPath: string, newPath: string): Promise { + // 递归复制函数 + const copyRecursive = async ( + srcHandle: FileSystemHandle, + destDir: FileSystemDirectoryHandle, + newName: string, + ) => { + if (srcHandle.kind === "file") { + const file = await (srcHandle as FileSystemFileHandle).getFile(); + const newFile = await destDir.getFileHandle(newName, { create: true }); + const writable = await newFile.createWritable(); + await writable.write(await file.arrayBuffer()); + await writable.close(); + } else { + const newDir = await destDir.getDirectoryHandle(newName, { + create: true, + }); + const srcDir = srcHandle as FileSystemDirectoryHandle; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + for await (const [name, entry] of srcDir.entries()) { + await copyRecursive(entry, newDir, name); + } + } + }; + + const oldHandle = await this.resolveHandle(oldPath); + const newParts = await this.resolvePathComponents(newPath); + const newName = newParts.pop()!; + const newDirHandle = await this.ensureDirectoryPath(newParts, true); + + await copyRecursive(oldHandle, newDirHandle, newName); + await this._delete(oldHandle.kind, oldPath); + } + + private async _delete( + kind: "file" | "directory", + path: string, + ): Promise { + const parts = await this.resolvePathComponents(path); + const targetName = parts.pop()!; + const parentHandle = await this.ensureDirectoryPath(parts); + + await parentHandle.removeEntry(targetName, { + recursive: kind === "directory", + }); + } + + async _deleteFile(path: string): Promise { + await this._delete("file", path); + } + + async _deleteDirectory(path: string): Promise { + await this._delete("directory", path); + } + + async _exists(path: string): Promise { + try { + await this.resolveHandle(path); + return true; + } catch { + return false; + } + } +} diff --git a/src/utils/fs.tsx b/src/utils/fs/com.tsx similarity index 58% rename from src/utils/fs.tsx rename to src/utils/fs/com.tsx index f78507a9..c2af0ac1 100644 --- a/src/utils/fs.tsx +++ b/src/utils/fs/com.tsx @@ -1,6 +1,6 @@ -import { invoke } from "@tauri-apps/api/core"; -import { PathString } from "./pathString"; -import { isWeb } from "./platform"; +import { isWeb } from "../platform"; +import { TauriBaseFS } from "./TauriFileSystem"; +import { PathString } from "../pathString"; /** * 检查一个文件是否存在 @@ -11,7 +11,7 @@ export async function exists(path: string): Promise { if (isWeb) { return true; } else { - return invoke("exists", { path }); + return TauriBaseFS.exists(path); } } @@ -40,7 +40,7 @@ export async function readTextFile(path: string): Promise { input.click(); }); } else { - return invoke("read_text_file", { path }); + return TauriBaseFS.readTextFile(path); } } @@ -70,61 +70,55 @@ export async function readFile(path: string): Promise { input.click(); }); } else { - const base64 = await invoke("read_file_base64", { path }); - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; + return TauriBaseFS.readFile(path); } } -/** - * 读取文件并以 base64 编码返回 - * @param path 文件路径 - * @returns 文件的 base64 编码 - */ -export async function readFileBase64(path: string): Promise { - if (isWeb) { - return new Promise((resolve, reject) => { - const input = document.createElement("input"); - input.type = "file"; - input.onchange = () => { - const file = input.files?.item(0); - if (file) { - const reader = new FileReader(); - reader.onload = () => { - const content = reader.result as string; - resolve(content); - }; - reader.onerror = reject; - reader.readAsDataURL(file); - } - }; - input.click(); - }); - } else { - return invoke("read_file_base64", { path }); - } -} +// /** +// * 读取文件并以 base64 编码返回 +// * @param path 文件路径 +// * @returns 文件的 base64 编码 +// */ +// export async function readFileBase64(path: string): Promise { +// if (isWeb) { +// return new Promise((resolve, reject) => { +// const input = document.createElement("input"); +// input.type = "file"; +// input.onchange = () => { +// const file = input.files?.item(0); +// if (file) { +// const reader = new FileReader(); +// reader.onload = () => { +// const content = reader.result as string; +// resolve(content); +// }; +// reader.onerror = reject; +// reader.readAsDataURL(file); +// } +// }; +// input.click(); +// }); +// } else { +// return invoke("read_file_base64", { path }); +// } +// } -/** - * 将内容写入文本文件 - * @param path 文件路径 - * @param content 文件内容 - */ +// /** +// * 将内容写入文本文件 +// * @param path 文件路径 +// * @param content 文件内容 +// */ export async function writeTextFile(path: string, content: string): Promise { if (isWeb) { const blob = new Blob([content], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = PathString.absolute2file(path); + a.download = PathString.absolute2fileWithExt(path); a.click(); URL.revokeObjectURL(url); } else { - return invoke("write_text_file", { path, content }); + return TauriBaseFS.writeFile(path, content); } } @@ -139,17 +133,11 @@ export async function writeFile(path: string, content: Uint8Array): Promise((resolve) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.readAsDataURL(new Blob([content])); - }); - const base64 = btoa(base64url.split(",")[1]); - return invoke("write_file_base64", { path, content: base64 }); + return TauriBaseFS.writeFile(path, content); } } @@ -168,6 +156,27 @@ export async function writeFileBase64(path: string, content: string): Promise { +// if (isWeb) { +// const blob = new Blob([content], { type: "text/plain" }); +// const url = URL.createObjectURL(blob); +// const a = document.createElement("a"); +// a.href = url; +// a.download = PathString.absolute2file(path); +// a.click(); +// URL.revokeObjectURL(url); +// } else { +// return invoke("write_file_base64", { path, content }); +// } +// } diff --git a/src/utils/pathString.tsx b/src/utils/pathString.tsx index c4dc5a27..7fd5d979 100644 --- a/src/utils/pathString.tsx +++ b/src/utils/pathString.tsx @@ -20,6 +20,21 @@ export namespace PathString { * @returns */ export function absolute2file(path: string): string { + const file = absolute2fileWithExt(path); + const parts = file.split("."); + if (parts.length > 1) { + return parts.slice(0, -1).join("."); + } else { + return file; + } + } + + /** + * 将绝对路径转换为文件名(含文件后缀) + * @param path + * @returns + */ + export function absolute2fileWithExt(path: string): string { const fam = family(); // const fam = "windows"; // vitest 测试时打开此行注释 @@ -30,12 +45,7 @@ export namespace PathString { if (!file) { throw new Error("Invalid path"); } - const parts = file.split("."); - if (parts.length > 1) { - return parts.slice(0, -1).join("."); - } else { - return file; - } + return file; } /** diff --git a/src/utils/platform.tsx b/src/utils/platform.tsx index 35bc42b2..f60d7cf2 100644 --- a/src/utils/platform.tsx +++ b/src/utils/platform.tsx @@ -7,6 +7,10 @@ export const isDesktop = !isMobile; export const isMac = !isWeb && platform() === "macos"; export const appScale = isMobile ? 0.5 : 1; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +//@ts-ignore +export const webFileApiSupport = !!window.showDirectoryPicker; + export function family() { if (isWeb) { // 从userAgent判断unix|windows