diff --git a/package.json b/package.json index dcddfc34..da3f313b 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", @@ -76,7 +77,7 @@ "vite": "^6.0.6", "vite-plugin-svgr": "^4.3.0", "vitepress": "^1.5.0", - "vitest": "^2.1.8", + "vitest": "3.0.4", "vue": "^3.5.13" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a5abf4b..82b9c5be 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 @@ -177,8 +180,8 @@ importers: specifier: ^1.5.0 version: 1.5.0(@algolia/client-search@5.18.0)(@types/react@19.0.2)(less@4.2.1)(lightningcss@1.29.1)(postcss@8.4.49)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass-embedded@1.83.0)(search-insights@2.17.3)(typescript@5.7.2) vitest: - specifier: ^2.1.8 - version: 2.1.8(jsdom@25.0.1)(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0) + specifier: 3.0.4 + version: 3.0.4(jiti@2.4.2)(jsdom@25.0.1)(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0)(yaml@2.6.1) vue: specifier: ^3.5.13 version: 3.5.13(typescript@5.7.2) @@ -1012,70 +1015,60 @@ packages: { integrity: sha512-Py5vFd5HWYN9zxBv3WMrLAXY3yYJ6Q/aVERoeUFwiDGiMOWsMs7FokXihSOaT/PMWUty/Pj60XDQndK3eAfE6A== } cpu: [arm] os: [linux] - libc: [glibc] "@rollup/rollup-linux-arm-musleabihf@4.29.1": resolution: { integrity: sha512-RiWpGgbayf7LUcuSNIbahr0ys2YnEERD4gYdISA06wa0i8RALrnzflh9Wxii7zQJEB2/Eh74dX4y/sHKLWp5uQ== } cpu: [arm] os: [linux] - libc: [musl] "@rollup/rollup-linux-arm64-gnu@4.29.1": resolution: { integrity: sha512-Z80O+taYxTQITWMjm/YqNoe9d10OX6kDh8X5/rFCMuPqsKsSyDilvfg+vd3iXIqtfmp+cnfL1UrYirkaF8SBZA== } cpu: [arm64] os: [linux] - libc: [glibc] "@rollup/rollup-linux-arm64-musl@4.29.1": resolution: { integrity: sha512-fOHRtF9gahwJk3QVp01a/GqS4hBEZCV1oKglVVq13kcK3NeVlS4BwIFzOHDbmKzt3i0OuHG4zfRP0YoG5OF/rA== } cpu: [arm64] os: [linux] - libc: [musl] "@rollup/rollup-linux-loongarch64-gnu@4.29.1": resolution: { integrity: sha512-5a7q3tnlbcg0OodyxcAdrrCxFi0DgXJSoOuidFUzHZ2GixZXQs6Tc3CHmlvqKAmOs5eRde+JJxeIf9DonkmYkw== } cpu: [loong64] os: [linux] - libc: [glibc] "@rollup/rollup-linux-powerpc64le-gnu@4.29.1": resolution: { integrity: sha512-9b4Mg5Yfz6mRnlSPIdROcfw1BU22FQxmfjlp/CShWwO3LilKQuMISMTtAu/bxmmrE6A902W2cZJuzx8+gJ8e9w== } cpu: [ppc64] os: [linux] - libc: [glibc] "@rollup/rollup-linux-riscv64-gnu@4.29.1": resolution: { integrity: sha512-G5pn0NChlbRM8OJWpJFMX4/i8OEU538uiSv0P6roZcbpe/WfhEO+AT8SHVKfp8qhDQzaz7Q+1/ixMy7hBRidnQ== } cpu: [riscv64] os: [linux] - libc: [glibc] "@rollup/rollup-linux-s390x-gnu@4.29.1": resolution: { integrity: sha512-WM9lIkNdkhVwiArmLxFXpWndFGuOka4oJOZh8EP3Vb8q5lzdSCBuhjavJsw68Q9AKDGeOOIHYzYm4ZFvmWez5g== } cpu: [s390x] os: [linux] - libc: [glibc] "@rollup/rollup-linux-x64-gnu@4.29.1": resolution: { integrity: sha512-87xYCwb0cPGZFoGiErT1eDcssByaLX4fc0z2nRM6eMtV9njAfEE6OW3UniAoDhX4Iq5xQVpE6qO9aJbCFumKYQ== } cpu: [x64] os: [linux] - libc: [glibc] "@rollup/rollup-linux-x64-musl@4.29.1": resolution: { integrity: sha512-xufkSNppNOdVRCEC4WKvlR1FBDyqCSCpQeMMgv9ZyXqqtKBfkw1yfGMTUTs9Qsl6WQbJnsGboWCp7pJGkeMhKA== } cpu: [x64] os: [linux] - libc: [musl] "@rollup/rollup-win32-arm64-msvc@4.29.1": resolution: @@ -1226,7 +1219,6 @@ packages: engines: { node: ">=10" } cpu: [arm64] os: [linux] - libc: [glibc] "@swc/core-linux-arm64-musl@1.10.1": resolution: @@ -1234,7 +1226,6 @@ packages: engines: { node: ">=10" } cpu: [arm64] os: [linux] - libc: [musl] "@swc/core-linux-x64-gnu@1.10.1": resolution: @@ -1242,7 +1233,6 @@ packages: engines: { node: ">=10" } cpu: [x64] os: [linux] - libc: [glibc] "@swc/core-linux-x64-musl@1.10.1": resolution: @@ -1250,7 +1240,6 @@ packages: engines: { node: ">=10" } cpu: [x64] os: [linux] - libc: [musl] "@swc/core-win32-arm64-msvc@1.10.1": resolution: @@ -1336,7 +1325,6 @@ packages: engines: { node: ">= 10" } cpu: [arm64] os: [linux] - libc: [glibc] "@tailwindcss/oxide-linux-arm64-musl@4.0.0": resolution: @@ -1344,7 +1332,6 @@ packages: engines: { node: ">= 10" } cpu: [arm64] os: [linux] - libc: [musl] "@tailwindcss/oxide-linux-x64-gnu@4.0.0": resolution: @@ -1352,7 +1339,6 @@ packages: engines: { node: ">= 10" } cpu: [x64] os: [linux] - libc: [glibc] "@tailwindcss/oxide-linux-x64-musl@4.0.0": resolution: @@ -1360,7 +1346,6 @@ packages: engines: { node: ">= 10" } cpu: [x64] os: [linux] - libc: [musl] "@tailwindcss/oxide-win32-arm64-msvc@4.0.0": resolution: @@ -1418,7 +1403,6 @@ packages: engines: { node: ">= 10" } cpu: [arm64] os: [linux] - libc: [glibc] "@tauri-apps/cli-linux-arm64-musl@2.1.0": resolution: @@ -1426,7 +1410,6 @@ packages: engines: { node: ">= 10" } cpu: [arm64] os: [linux] - libc: [musl] "@tauri-apps/cli-linux-x64-gnu@2.1.0": resolution: @@ -1434,7 +1417,6 @@ packages: engines: { node: ">= 10" } cpu: [x64] os: [linux] - libc: [glibc] "@tauri-apps/cli-linux-x64-musl@2.1.0": resolution: @@ -1442,7 +1424,6 @@ packages: engines: { node: ">= 10" } cpu: [x64] os: [linux] - libc: [musl] "@tauri-apps/cli-win32-arm64-msvc@2.1.0": resolution: @@ -1638,41 +1619,41 @@ packages: vite: ^5.0.0 || ^6.0.0 vue: ^3.2.25 - "@vitest/expect@2.1.8": + "@vitest/expect@3.0.4": resolution: - { integrity: sha512-8ytZ/fFHq2g4PJVAtDX57mayemKgDR6X3Oa2Foro+EygiOJHUXhCqBAAKQYYajZpFoIfvBCF1j6R6IYRSIUFuw== } + { integrity: sha512-Nm5kJmYw6P2BxhJPkO3eKKhGYKRsnqJqf+r0yOGRKpEP+bSCBDsjXgiu1/5QFrnPMEgzfC38ZEjvCFgaNBC0Eg== } - "@vitest/mocker@2.1.8": + "@vitest/mocker@3.0.4": resolution: - { integrity: sha512-7guJ/47I6uqfttp33mgo6ga5Gr1VnL58rcqYKyShoRK9ebu8T5Rs6HN3s1NABiBeVTdWNrwUMcHH54uXZBN4zA== } + { integrity: sha512-gEef35vKafJlfQbnyOXZ0Gcr9IBUsMTyTLXsEQwuyYAerpHqvXhzdBnDFuHLpFqth3F7b6BaFr4qV/Cs1ULx5A== } peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 + vite: ^5.0.0 || ^6.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - "@vitest/pretty-format@2.1.8": + "@vitest/pretty-format@3.0.4": resolution: - { integrity: sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ== } + { integrity: sha512-ts0fba+dEhK2aC9PFuZ9LTpULHpY/nd6jhAQ5IMU7Gaj7crPCTdCFfgvXxruRBLFS+MLraicCuFXxISEq8C93g== } - "@vitest/runner@2.1.8": + "@vitest/runner@3.0.4": resolution: - { integrity: sha512-17ub8vQstRnRlIU5k50bG+QOMLHRhYPAna5tw8tYbj+jzjcspnwnwtPtiOlkuKC4+ixDPTuLZiqiWWQ2PSXHVg== } + { integrity: sha512-dKHzTQ7n9sExAcWH/0sh1elVgwc7OJ2lMOBrAm73J7AH6Pf9T12Zh3lNE1TETZaqrWFXtLlx3NVrLRb5hCK+iw== } - "@vitest/snapshot@2.1.8": + "@vitest/snapshot@3.0.4": resolution: - { integrity: sha512-20T7xRFbmnkfcmgVEz+z3AU/3b0cEzZOt/zmnvZEctg64/QZbSDJEVm9fLnnlSi74KibmRsO9/Qabi+t0vCRPg== } + { integrity: sha512-+p5knMLwIk7lTQkM3NonZ9zBewzVp9EVkVpvNta0/PlFWpiqLaRcF4+33L1it3uRUCh0BGLOaXPPGEjNKfWb4w== } - "@vitest/spy@2.1.8": + "@vitest/spy@3.0.4": resolution: - { integrity: sha512-5swjf2q95gXeYPevtW0BLk6H8+bPlMb4Vw/9Em4hFxDcaOxS+e0LOX4yqNxoHzMR2akEB2xfpnWUzkZokmgWDg== } + { integrity: sha512-sXIMF0oauYyUy2hN49VFTYodzEAu744MmGcPR3ZBsPM20G+1/cSW/n1U+3Yu/zHxX2bIDe1oJASOkml+osTU6Q== } - "@vitest/utils@2.1.8": + "@vitest/utils@3.0.4": resolution: - { integrity: sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA== } + { integrity: sha512-8BqC1ksYsHtbWH+DfpOAKrFw3jl3Uf9J7yeFh85Pz52IWuh1hBBtyfEbRNNZNjl8H8A5yMLH9/t+k7HIKzQcZQ== } "@vue/compiler-core@3.5.13": resolution: @@ -2050,6 +2031,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== } @@ -2229,9 +2214,9 @@ packages: { integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w== } engines: { node: ">= 0.4" } - es-module-lexer@1.5.4: + es-module-lexer@1.6.0: resolution: - { integrity: sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw== } + { integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ== } es-object-atoms@1.0.0: resolution: @@ -2652,6 +2637,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 +2655,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 +2821,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 +2913,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 +2937,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== } @@ -2970,7 +2975,6 @@ packages: engines: { node: ">= 12.0.0" } cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.29.1: resolution: @@ -2978,7 +2982,6 @@ packages: engines: { node: ">= 12.0.0" } cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.29.1: resolution: @@ -2986,7 +2989,6 @@ packages: engines: { node: ">= 12.0.0" } cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.29.1: resolution: @@ -2994,7 +2996,6 @@ packages: engines: { node: ">= 12.0.0" } cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.29.1: resolution: @@ -3289,6 +3290,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== } @@ -3332,9 +3337,9 @@ packages: { integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== } engines: { node: ">=8" } - pathe@1.1.2: + pathe@2.0.2: resolution: - { integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ== } + { integrity: sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w== } pathval@2.0.0: resolution: @@ -3456,6 +3461,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 +3539,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 +3616,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 +3827,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 +3953,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== } @@ -4024,18 +4049,18 @@ packages: resolution: { integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== } - tinyexec@0.3.1: + tinyexec@0.3.2: resolution: - { integrity: sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ== } + { integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== } tinypool@1.0.2: resolution: { integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA== } engines: { node: ^18.0.0 || >=20.0.0 } - tinyrainbow@1.2.0: + tinyrainbow@2.0.0: resolution: - { integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ== } + { integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== } engines: { node: ">=14.0.0" } tinyspy@3.0.2: @@ -4174,6 +4199,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== } @@ -4195,10 +4224,10 @@ packages: resolution: { integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q== } - vite-node@2.1.8: + vite-node@3.0.4: resolution: - { integrity: sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg== } - engines: { node: ^18.0.0 || >=20.0.0 } + { integrity: sha512-7JZKEzcYV2Nx3u6rlvN8qdo3QV7Fxyt6hx+CCKz9fbWxdX5IvUOmTWEAxMrWxaiSf7CKGLJQ5rFu8prb/jBjOA== } + engines: { node: ^18.0.0 || ^20.0.0 || >=22.0.0 } hasBin: true vite-plugin-svgr@4.3.0: @@ -4293,21 +4322,24 @@ packages: postcss: optional: true - vitest@2.1.8: + vitest@3.0.4: resolution: - { integrity: sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ== } - engines: { node: ^18.0.0 || >=20.0.0 } + { integrity: sha512-6XG8oTKy2gnJIFTHP6LD7ExFeNLxiTkK3CfMvT7IfR8IN+BYICCf0lXUQmX7i7JoxUP8QmeP4mTnWXgflu4yjw== } + engines: { node: ^18.0.0 || ^20.0.0 || >=22.0.0 } hasBin: true peerDependencies: "@edge-runtime/vm": "*" - "@types/node": ^18.0.0 || >=20.0.0 - "@vitest/browser": 2.1.8 - "@vitest/ui": 2.1.8 + "@types/debug": ^4.1.12 + "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 + "@vitest/browser": 3.0.4 + "@vitest/ui": 3.0.4 happy-dom: "*" jsdom: "*" peerDependenciesMeta: "@edge-runtime/vm": optional: true + "@types/debug": + optional: true "@types/node": optional: true "@vitest/browser": @@ -5661,45 +5693,45 @@ snapshots: vite: 5.4.11(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0) vue: 3.5.13(typescript@5.7.2) - "@vitest/expect@2.1.8": + "@vitest/expect@3.0.4": dependencies: - "@vitest/spy": 2.1.8 - "@vitest/utils": 2.1.8 + "@vitest/spy": 3.0.4 + "@vitest/utils": 3.0.4 chai: 5.1.2 - tinyrainbow: 1.2.0 + tinyrainbow: 2.0.0 - "@vitest/mocker@2.1.8(vite@5.4.11(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0))": + "@vitest/mocker@3.0.4(vite@6.0.6(jiti@2.4.2)(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0)(yaml@2.6.1))": dependencies: - "@vitest/spy": 2.1.8 + "@vitest/spy": 3.0.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 5.4.11(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0) + vite: 6.0.6(jiti@2.4.2)(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0)(yaml@2.6.1) - "@vitest/pretty-format@2.1.8": + "@vitest/pretty-format@3.0.4": dependencies: - tinyrainbow: 1.2.0 + tinyrainbow: 2.0.0 - "@vitest/runner@2.1.8": + "@vitest/runner@3.0.4": dependencies: - "@vitest/utils": 2.1.8 - pathe: 1.1.2 + "@vitest/utils": 3.0.4 + pathe: 2.0.2 - "@vitest/snapshot@2.1.8": + "@vitest/snapshot@3.0.4": dependencies: - "@vitest/pretty-format": 2.1.8 + "@vitest/pretty-format": 3.0.4 magic-string: 0.30.17 - pathe: 1.1.2 + pathe: 2.0.2 - "@vitest/spy@2.1.8": + "@vitest/spy@3.0.4": dependencies: tinyspy: 3.0.2 - "@vitest/utils@2.1.8": + "@vitest/utils@3.0.4": dependencies: - "@vitest/pretty-format": 2.1.8 + "@vitest/pretty-format": 3.0.4 loupe: 3.1.2 - tinyrainbow: 1.2.0 + tinyrainbow: 2.0.0 "@vue/compiler-core@3.5.13": dependencies: @@ -6034,6 +6066,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 @@ -6228,7 +6262,7 @@ snapshots: iterator.prototype: 1.1.4 safe-array-concat: 1.1.3 - es-module-lexer@1.5.4: {} + es-module-lexer@1.6.0: {} es-object-atoms@1.0.0: dependencies: @@ -6655,6 +6689,8 @@ snapshots: image-size@0.5.5: optional: true + immediate@3.0.6: {} + immutable@5.0.3: {} import-fresh@3.3.0: @@ -6664,6 +6700,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -6786,6 +6824,8 @@ snapshots: is-what@4.1.16: {} + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -6864,6 +6904,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 +6936,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 +7230,8 @@ snapshots: dependencies: p-limit: 3.1.0 + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -7206,7 +7259,7 @@ snapshots: path-type@4.0.0: {} - pathe@1.1.2: {} + pathe@2.0.2: {} pathval@2.0.0: {} @@ -7245,6 +7298,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 +7351,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 +7451,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 +7594,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 +7725,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 @@ -7715,11 +7788,11 @@ snapshots: tinybench@2.9.0: {} - tinyexec@0.3.1: {} + tinyexec@0.3.2: {} tinypool@1.0.2: {} - tinyrainbow@1.2.0: {} + tinyrainbow@2.0.0: {} tinyspy@3.0.2: {} @@ -7846,6 +7919,8 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + uuid@11.0.3: {} varint@6.0.0: {} @@ -7864,15 +7939,16 @@ snapshots: "@types/unist": 3.0.3 vfile-message: 4.0.2 - vite-node@2.1.8(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0): + vite-node@3.0.4(jiti@2.4.2)(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0)(yaml@2.6.1): dependencies: cac: 6.7.14 debug: 4.4.0 - es-module-lexer: 1.5.4 - pathe: 1.1.2 - vite: 5.4.11(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0) + es-module-lexer: 1.6.0 + pathe: 2.0.2 + vite: 6.0.6(jiti@2.4.2)(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0)(yaml@2.6.1) transitivePeerDependencies: - "@types/node" + - jiti - less - lightningcss - sass @@ -7881,6 +7957,8 @@ snapshots: - sugarss - supports-color - terser + - tsx + - yaml vite-plugin-svgr@4.3.0(rollup@4.29.1)(typescript@5.7.2)(vite@6.0.6(jiti@2.4.2)(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0)(yaml@2.6.1)): dependencies: @@ -7967,31 +8045,32 @@ snapshots: - typescript - universal-cookie - vitest@2.1.8(jsdom@25.0.1)(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0): + vitest@3.0.4(jiti@2.4.2)(jsdom@25.0.1)(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0)(yaml@2.6.1): dependencies: - "@vitest/expect": 2.1.8 - "@vitest/mocker": 2.1.8(vite@5.4.11(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0)) - "@vitest/pretty-format": 2.1.8 - "@vitest/runner": 2.1.8 - "@vitest/snapshot": 2.1.8 - "@vitest/spy": 2.1.8 - "@vitest/utils": 2.1.8 + "@vitest/expect": 3.0.4 + "@vitest/mocker": 3.0.4(vite@6.0.6(jiti@2.4.2)(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0)(yaml@2.6.1)) + "@vitest/pretty-format": 3.0.4 + "@vitest/runner": 3.0.4 + "@vitest/snapshot": 3.0.4 + "@vitest/spy": 3.0.4 + "@vitest/utils": 3.0.4 chai: 5.1.2 debug: 4.4.0 expect-type: 1.1.0 magic-string: 0.30.17 - pathe: 1.1.2 + pathe: 2.0.2 std-env: 3.8.0 tinybench: 2.9.0 - tinyexec: 0.3.1 + tinyexec: 0.3.2 tinypool: 1.0.2 - tinyrainbow: 1.2.0 - vite: 5.4.11(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0) - vite-node: 2.1.8(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0) + tinyrainbow: 2.0.0 + vite: 6.0.6(jiti@2.4.2)(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0)(yaml@2.6.1) + vite-node: 3.0.4(jiti@2.4.2)(less@4.2.1)(lightningcss@1.29.1)(sass-embedded@1.83.0)(yaml@2.6.1) why-is-node-running: 2.3.0 optionalDependencies: jsdom: 25.0.1 transitivePeerDependencies: + - jiti - less - lightningcss - msw @@ -8001,6 +8080,8 @@ snapshots: - sugarss - supports-color - terser + - tsx + - yaml void-elements@3.1.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/__tests__/README.md b/src/__tests__/README.md deleted file mode 100644 index 6e88dac7..00000000 --- a/src/__tests__/README.md +++ /dev/null @@ -1 +0,0 @@ -这里是放测试的地方 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/render/canvas2d/entityRenderer/EntityDetailsButtonRenderer.tsx b/src/core/render/canvas2d/entityRenderer/EntityDetailsButtonRenderer.tsx index b8c2b0d8..a9dccc39 100644 --- a/src/core/render/canvas2d/entityRenderer/EntityDetailsButtonRenderer.tsx +++ b/src/core/render/canvas2d/entityRenderer/EntityDetailsButtonRenderer.tsx @@ -10,7 +10,7 @@ import { Renderer } from "../renderer"; * 仅仅渲染一个节点右上角的按钮 */ export function EntityDetailsButtonRenderer(entity: Entity) { - if (!entity.details) { + if (!entity.details.trim()) { return; } // ShapeRenderer.renderRect( diff --git a/src/core/render/canvas2d/entityRenderer/EntityRenderer.tsx b/src/core/render/canvas2d/entityRenderer/EntityRenderer.tsx index 53fe6338..c9e508ff 100644 --- a/src/core/render/canvas2d/entityRenderer/EntityRenderer.tsx +++ b/src/core/render/canvas2d/entityRenderer/EntityRenderer.tsx @@ -195,7 +195,7 @@ export namespace EntityRenderer { ), Renderer.FONT_SIZE_DETAILS * Camera.currentScale, Math.max( - Renderer.NODE_DETAILS_WIDTH * Camera.currentScale, + Renderer.ENTITY_DETAILS_WIDTH * Camera.currentScale, entity.collisionBox.getRectangle().size.x * Camera.currentScale, ), StageStyleManager.currentStyle.NodeDetailsTextColor, diff --git a/src/core/render/canvas2d/renderer.tsx b/src/core/render/canvas2d/renderer.tsx index 07576a04..f653709e 100644 --- a/src/core/render/canvas2d/renderer.tsx +++ b/src/core/render/canvas2d/renderer.tsx @@ -4,11 +4,12 @@ import { Color, mixColors } from "../../dataStruct/Color"; import { Vector } from "../../dataStruct/Vector"; import { Rectangle } from "../../dataStruct/shape/Rectangle"; import { Settings } from "../../service/Settings"; +import { MouseLocation } from "../../service/controlService/MouseLocation"; import { Controller } from "../../service/controlService/controller/Controller"; +import { KeyboardOnlyEngine } from "../../service/controlService/keyboardOnlyEngine/keyboardOnlyEngine"; import { CopyEngine } from "../../service/dataManageService/copyEngine/copyEngine"; import { sine } from "../../service/feedbackService/effectEngine/mathTools/animateFunctions"; import { StageStyleManager } from "../../service/feedbackService/stageStyle/StageStyleManager"; -import { KeyboardOnlyEngine } from "../../service/controlService/keyboardOnlyEngine/keyboardOnlyEngine"; import { Camera } from "../../stage/Camera"; import { Canvas } from "../../stage/Canvas"; import { Stage } from "../../stage/Stage"; @@ -29,7 +30,6 @@ import { renderHorizonBackground, renderVerticalBackground, } from "./utilsRenderer/backgroundRenderer"; -import { MouseLocation } from "../../service/controlService/MouseLocation"; /** * 渲染器 @@ -50,10 +50,11 @@ export namespace Renderer { export const NODE_PADDING = 14; /// 节点的圆角半径 export const NODE_ROUNDED_RADIUS = 8; + /** * 节点详细信息最大宽度 */ - export const NODE_DETAILS_WIDTH = 200; + export let ENTITY_DETAILS_WIDTH = 200; export let w = 0; export let h = 0; @@ -114,6 +115,9 @@ export namespace Renderer { Settings.watch("entityDetailsLinesLimit", (value) => { ENTITY_DETAILS_LIENS_LIMIT = value; }); + Settings.watch("entityDetailsWidthLimit", (value) => { + ENTITY_DETAILS_WIDTH = value; + }); Settings.watch("showDebug", (value) => (isShowDebug = value)); Settings.watch("showBackgroundHorizontalLines", (value) => { isShowBackgroundHorizontalLines = value; diff --git a/src/core/service/Settings.tsx b/src/core/service/Settings.tsx index 3e26ac09..a545709f 100644 --- a/src/core/service/Settings.tsx +++ b/src/core/service/Settings.tsx @@ -30,6 +30,7 @@ export namespace Settings { useNativeTitleBar: boolean; entityDetailsFontSize: number; entityDetailsLinesLimit: number; + entityDetailsWidthLimit: number; limitCameraInCycleSpace: boolean; cameraCycleSpaceSizeX: number; cameraCycleSpaceSizeY: number; @@ -91,6 +92,7 @@ export namespace Settings { useNativeTitleBar: false, entityDetailsFontSize: 18, entityDetailsLinesLimit: 4, + entityDetailsWidthLimit: 200, limitCameraInCycleSpace: false, cameraCycleSpaceSizeX: 1000, cameraCycleSpaceSizeY: 1000, 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..3320fb42 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, readFile, readTextFile } from "../../../utils/fs/com"; import { createStore } from "../../../utils/store"; import { Camera } from "../../stage/Camera"; import { Stage } from "../../stage/Stage"; @@ -16,6 +16,8 @@ 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"; +import { PathString } from "../../../utils/pathString"; /** * 管理最近打开的文件列表 @@ -144,16 +146,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); @@ -165,6 +167,34 @@ export namespace RecentFileManager { time: new Date().getTime(), }); } + export async function openLegacyFileByPath(path: string) { + StageManager.destroy(); + const ext = PathString.absolute2Ext(path); + if (ext !== "json") { + throw new Error("不兼容的文件格式"); + } + + const data = StageLoader.validate(JSON.parse(await readTextFile(path))); + const dirPath = PathString.dirPath(path); + const operations = data.entities + .filter((entity) => entity.type === "core:image_node") + .map(async (entity) => { + const ud = await readFile(`${dirPath}${PathString.getSep()}${entity.uuid}.png`); + await VFileSystem.getFS().writeFile(`/picture/${entity.uuid}.png`, ud); + }); + await Promise.all(operations); + loadStageByData(data); + await VFileSystem.pullMetaData(); + + StageHistoryManager.reset(data); + + Camera.reset(); + Stage.effectMachine.addEffect(ViewFlashEffect.SaveFile()); + // RecentFileManager.addRecentFile({ + // path: path, + // time: new Date().getTime(), + // }); + } export function loadStageByData(data: Serialized.File) { for (const entity of data.entities) { 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/autoComputeEngine/functions/nodeLogic.tsx b/src/core/service/dataGenerateService/autoComputeEngine/functions/nodeLogic.tsx index 597e6c81..4c95d2e0 100644 --- a/src/core/service/dataGenerateService/autoComputeEngine/functions/nodeLogic.tsx +++ b/src/core/service/dataGenerateService/autoComputeEngine/functions/nodeLogic.tsx @@ -263,6 +263,9 @@ export namespace NodeLogic { if (AutoComputeUtils.isNodeConnectedWithLogicNode(node)) { continue; } + if (node.text.trim() === "") { + continue; + } // 匹配颜色 if (node.color.equals(matchColor)) { matchNodes.push(node); @@ -306,6 +309,9 @@ export namespace NodeLogic { if (AutoComputeUtils.isNodeConnectedWithLogicNode(node)) { continue; } + if (node.details.trim() === "") { + continue; + } // 匹配颜色 if (node.color.equals(matchColor)) { matchNodes.push(node); 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/locales/en.yml b/src/locales/en.yml index 9d245e5a..67849e6e 100644 --- a/src/locales/en.yml +++ b/src/locales/en.yml @@ -173,6 +173,10 @@ settings: title: Line Limit for Entity Details description: | Limit the maximum number of lines for entity details. Exceeding this limit will result in the omission of the excess content. + entityDetailsWidthLimit: + title: Width Limit for Entity Details + description: | + Limit the maximum width for entity details. Exceeding this limit will result in the excess content wrapping to the next line. limitCameraInCycleSpace: title: Enable Camera Movement Limitation in Cycle Space description: | @@ -364,6 +368,7 @@ appMenu: saveAs: Save As recent: Recents backup: Backup + openLegacy: Open Legacy Project location: title: Dirs items: diff --git a/src/locales/zh_CN.yml b/src/locales/zh_CN.yml index e7c7d53a..79d8514a 100644 --- a/src/locales/zh_CN.yml +++ b/src/locales/zh_CN.yml @@ -158,6 +158,10 @@ settings: title: 实体详细信息行数限制 description: | 限制实体详细信息的最大行数,超过限制的部分将被省略 + entityDetailsWidthLimit: + title: 实体详细信息宽度限制 + description: | + 限制实体详细信息的最大宽度,超过限制的部分将被换行 limitCameraInCycleSpace: title: 开启循环空间限制摄像机移动 description: | @@ -360,6 +364,7 @@ appMenu: saveAs: 另存为 recent: 最近打开 backup: 备份 + openLegacy: 从旧版文件打开 location: title: 位置 items: diff --git a/src/locales/zh_TW.yml b/src/locales/zh_TW.yml index b9a8f6b9..4b05ee3f 100644 --- a/src/locales/zh_TW.yml +++ b/src/locales/zh_TW.yml @@ -145,6 +145,10 @@ settings: title: 实体详细信息行数限制 description: | 限制实体详细信息的最大行数,超过限制的部分将被省略 + entityDetailsWidthLimit: + title: 实体详细信息宽度限制 + description: | + 限制实体详细信息的最大宽度,超过限制的部分将被换行 limitCameraInCycleSpace: title: 开启循环空间限制摄像机移动 description: | @@ -336,6 +340,7 @@ appMenu: saveAs: 另存為 recent: 最近打開 backup: 備份 + openLegacy: 打開舊版 location: title: 位置 items: 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..3c455462 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"); }, }, @@ -87,12 +90,12 @@ export default function AppMenu({ className = "", open = false }: { className?: } }; - const onOpen = async () => { + const onOpen = async (legacy: boolean = false) => { if (!StageSaveManager.isSaved()) { if (StageManager.isEmpty()) { //空项目不需要保存 StageManager.destroy(); - openFileByDialogWindow(); + openFileByDialogWindow(legacy); } else if (Stage.path.isDraft()) { Dialog.show({ title: "草稿未保存", @@ -104,7 +107,7 @@ export default function AppMenu({ className = "", open = false }: { className?: text: "丢弃并打开新文件", onClick: () => { StageManager.destroy(); - openFileByDialogWindow(); + openFileByDialogWindow(legacy); }, }, ], @@ -117,7 +120,7 @@ export default function AppMenu({ className = "", open = false }: { className?: { text: "保存并打开新文件", onClick: () => { - onSave().then(openFileByDialogWindow); + onSave().then(() => openFileByDialogWindow(legacy)); }, }, { text: "我再想想" }, @@ -126,13 +129,39 @@ export default function AppMenu({ className = "", open = false }: { className?: } } else { // 直接打开文件 - openFileByDialogWindow(); + openFileByDialogWindow(legacy); } }; - const openFileByDialogWindow = async () => { + const openLegacyFileByDialogWindow = async () => { const path = isWeb ? "file.json" + : await openFileDialog({ + title: "打开文件", + directory: false, + multiple: false, + filters: [], + }); + if (!path) { + return; + } + try { + await RecentFileManager.openLegacyFileByPath(path); // 已经包含历史记录重置功能 + // 设置为草稿 + setFile("Project Graph"); + } catch (e) { + Dialog.show({ + title: "请选择正确的文件", + content: String(e), + type: "error", + }); + } + }; + + const openFileByDialogWindow = async (legacy: boolean = false) => { + if (legacy) return openLegacyFileByDialogWindow(); + const path = isWeb + ? "file.gp" : await openFileDialog({ title: "打开文件", directory: false, @@ -141,7 +170,7 @@ export default function AppMenu({ className = "", open = false }: { className?: ? [ { name: "Project Graph", - extensions: ["json"], + extensions: ["gp"], }, ] : [], @@ -149,9 +178,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 +191,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 +212,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 +223,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 +243,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: "保存失败,请重试", @@ -381,7 +412,7 @@ export default function AppMenu({ className = "", open = false }: { className?: } onClick={onNewDraft}> {t("file.items.new")} - } onClick={onOpen}> + } onClick={() => onOpen()}> {t("file.items.open")} {!isWeb && ( @@ -403,6 +434,11 @@ export default function AppMenu({ className = "", open = false }: { className?: {t("file.items.backup")} )} + {!isWeb && ( + } onClick={() => onOpen(true)}> + {t("file.items.openLegacy")} + + )} {!isWeb && ( } title={t("location.title")}> 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..58700e60 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"; @@ -274,7 +274,8 @@ function AlignNodePanel() { * @returns */ export default function Toolbar({ className = "" }: { className?: string }) { - const [isCopyClearShow, setIsCopyClearShow] = useState(false); + // 是否显示清空粘贴板 + const [isClipboardClearShow, setIsCopyClearShow] = useState(false); const [isHaveSelectedNode, setSsHaveSelectedNode] = useState(false); const [isHaveSelectedNodeOverTwo, setSsHaveSelectedNodeOverTwo] = useState(false); const [isHaveSelectedEdge, setSsHaveSelectedEdge] = useState(false); @@ -314,13 +315,13 @@ export default function Toolbar({ className = "" }: { className?: string }) { // 因为报错窗口可能会被它遮挡住导致无法在右上角关闭报错窗口 return (
@@ -351,7 +352,7 @@ export default function Toolbar({ className = "" }: { className?: string }) { {(isHaveSelectedNode || isHaveSelectedEdge) && ( } handleFunction={() => Popup.show()} /> @@ -364,7 +365,7 @@ export default function Toolbar({ className = "" }: { className?: string }) { handleFunction={() => Popup.show()} /> )} - {isCopyClearShow && ( + {isClipboardClearShow && ( } @@ -469,6 +470,28 @@ export default function Toolbar({ className = "" }: { className?: string }) { StageManager.autoLayoutFastTreeMode(); }} /> + {/* 测试占位符 */} + {/* } + handleFunction={() => { + StageManager.autoLayoutFastTreeMode(); + }} + /> + } + handleFunction={() => { + StageManager.autoLayoutFastTreeMode(); + }} + /> + } + handleFunction={() => { + StageManager.autoLayoutFastTreeMode(); + }} + /> */}
); @@ -477,11 +500,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/pages/settings/visual.tsx b/src/pages/settings/visual.tsx index d375eed1..09cb2a2d 100644 --- a/src/pages/settings/visual.tsx +++ b/src/pages/settings/visual.tsx @@ -14,6 +14,7 @@ import { Ratio, Rows4, Scaling, + Space, Spline, VenetianMask, } from "lucide-react"; @@ -53,6 +54,14 @@ export default function Visual() { max={200} step={2} /> + } + settingKey="entityDetailsWidthLimit" + type="slider" + min={200} + max={2000} + step={100} + /> } settingKey="limitCameraInCycleSpace" type="switch" /> ; + 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..e0edec86 --- /dev/null +++ b/src/utils/fs/TauriFileSystem.tsx @@ -0,0 +1,113 @@ +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..7f89615a --- /dev/null +++ b/src/utils/fs/WebFileApiSystem.tsx @@ -0,0 +1,173 @@ +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..e1a345e5 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,21 @@ 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; + } + /** + * 将绝对路径转换为文件后缀 + * @param path + * @returns + */ + export function absolute2Ext(path: string): string { + const fam = family(); + // const fam = "windows"; // vitest 测试时打开此行注释 + + if (fam === "windows") { + path = path.replace(/\\/g, "/"); } + return path.split("/").pop()?.split(".").pop() || ""; } /** 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 diff --git a/src/__tests__/unit/deco.test.tsx b/tests/deco.test.tsx similarity index 100% rename from src/__tests__/unit/deco.test.tsx rename to tests/deco.test.tsx diff --git a/src/__tests__/unit/lruCache.test.tsx b/tests/lruCache.test.tsx similarity index 95% rename from src/__tests__/unit/lruCache.test.tsx rename to tests/lruCache.test.tsx index 8de77368..38540068 100644 --- a/src/__tests__/unit/lruCache.test.tsx +++ b/tests/lruCache.test.tsx @@ -1,6 +1,6 @@ // LruCache.test.ts -import { describe, it, expect } from "vitest"; -import { LruCache } from "../../core/dataStruct/Cache"; +import { describe, expect, it } from "vitest"; +import { LruCache } from "../src/core/dataStruct/Cache"; describe("LruCache", () => { it("对于不存在的键应返回 undefined", () => { diff --git a/src/__tests__/unit/mod.test.tsx b/tests/mod.test.tsx similarity index 75% rename from src/__tests__/unit/mod.test.tsx rename to tests/mod.test.tsx index 6ef8d1e0..00cebb83 100644 --- a/src/__tests__/unit/mod.test.tsx +++ b/tests/mod.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { NumberFunctions } from "../../core/algorithm/numberFunctions"; +import { describe, expect, it } from "vitest"; +import { NumberFunctions } from "../src/core/algorithm/numberFunctions"; describe("mod.test.tsx", () => { it("should pass", () => { diff --git a/src/__tests__/unit/monoStack.test.tsx b/tests/monoStack.test.tsx similarity index 96% rename from src/__tests__/unit/monoStack.test.tsx rename to tests/monoStack.test.tsx index 6f38b24e..9500d361 100644 --- a/src/__tests__/unit/monoStack.test.tsx +++ b/tests/monoStack.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { MonoStack } from "../../core/dataStruct/MonoStack"; +import { describe, expect, it } from "vitest"; +import { MonoStack } from "../src/core/dataStruct/MonoStack"; describe("monoStack", () => { /** * a diff --git a/src/__tests__/unit/parseMarkdownToJSON.test.tsx b/tests/parseMarkdownToJSON.test.tsx similarity index 97% rename from src/__tests__/unit/parseMarkdownToJSON.test.tsx rename to tests/parseMarkdownToJSON.test.tsx index 838c3f17..5419af18 100644 --- a/src/__tests__/unit/parseMarkdownToJSON.test.tsx +++ b/tests/parseMarkdownToJSON.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { parseMarkdownToJSON } from "../../utils/markdownParse"; +import { describe, expect, it } from "vitest"; +import { parseMarkdownToJSON } from "../src/utils/markdownParse"; describe("测试测试框架是否正常运行", () => { it("测试用例1", () => { diff --git a/src/__tests__/unit/validUrl.test.tsx b/tests/validUrl.test.tsx similarity index 98% rename from src/__tests__/unit/validUrl.test.tsx rename to tests/validUrl.test.tsx index d25ed3fb..fb7ad684 100644 --- a/src/__tests__/unit/validUrl.test.tsx +++ b/tests/validUrl.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { PathString } from "../../utils/pathString"; +import { describe, expect, it } from "vitest"; +import { PathString } from "../src/utils/pathString"; describe("PathString", () => { it("URL有效性检测", () => { diff --git a/src/__tests__/unit/vector.test.tsx b/tests/vector.test.tsx similarity index 72% rename from src/__tests__/unit/vector.test.tsx rename to tests/vector.test.tsx index e2ae8934..ccd3cacb 100644 --- a/src/__tests__/unit/vector.test.tsx +++ b/tests/vector.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { Vector } from "../../core/dataStruct/Vector"; +import { describe, expect, it } from "vitest"; +import { Vector } from "../src/core/dataStruct/Vector"; describe("Vector", () => { it("1+1=2", () => { diff --git a/src/__tests__/unit/vitest.test.tsx b/tests/vitest.test.tsx similarity index 100% rename from src/__tests__/unit/vitest.test.tsx rename to tests/vitest.test.tsx diff --git a/tsconfig.json b/tsconfig.json index 2e0c745f..3245d4e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,9 +23,16 @@ // 其他 "experimentalDecorators": true // 开启装饰器 }, - "include": ["src"], - "references": [ - { "path": "./tsconfig.node.json" }, - { "path": "./tsconfig.docs.json" } - ] + "include": [ + "src", + "tests/deco.test.tsx", + "tests/lruCache.test.tsx", + "tests/mod.test.tsx", + "tests/monoStack.test.tsx", + "tests/parseMarkdownToJSON.test.tsx", + "tests/validUrl.test.tsx", + "tests/vector.test.tsx", + "tests/vitest.test.tsx" + ], + "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.docs.json" }] } diff --git a/vite.config.ts b/vite.config.ts index b630bc34..10c6ab50 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,5 @@ +/// + import generouted from "@generouted/react-router/plugin"; import ViteYaml from "@modyfi/vite-plugin-yaml"; import tailwindcss from "@tailwindcss/vite"; @@ -59,4 +61,9 @@ export default defineConfig(async () => ({ // 只有名字以LR_开头的环境变量才会被注入到前端 // import.meta.env.LR_xxx envPrefix: "LR_", + + test: { + environment: "jsdom", + include: ["./tests/**/*.test.tsx"], + }, })); diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index 95189d86..00000000 --- a/vitest.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - // 配置测试环境 - environment: "jsdom", // 用jsdom模拟浏览器环境 - // 全局设置 - globals: true, - include: ["src/__tests__/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], - // 设置覆盖率报告 - // coverage: { - // provider: 'c8', - // reporter: ['text', 'json', 'html'] - // }, - // 其他配置选项 - }, -});