diff --git a/web/demo.html b/web/demo.html new file mode 100644 index 0000000..5766f64 --- /dev/null +++ b/web/demo.html @@ -0,0 +1,12 @@ + + + + + + RawMark 编辑器演示 + + +
+ + + diff --git a/web/index.html b/web/index.html index e34407e..418c210 100644 --- a/web/index.html +++ b/web/index.html @@ -3,6 +3,8 @@ + + RawMark diff --git a/web/package-lock.json b/web/package-lock.json index 61ccfec..7dfab37 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -14,14 +14,63 @@ "@lezer/common": "^1.5.3", "@lezer/highlight": "^1.2.5", "@lezer/markdown": "^1.8.0", - "shiki": "^4.5.0" + "shiki": "^4.5.0", + "vue": "^3.5.44" }, "devDependencies": { + "@vitejs/plugin-vue": "^6.0.10", "commonmark-spec": "^0.31.2", "happy-dom": "^21.0.3", - "typescript": "^7.0.2", + "typescript": "^6.0.3", "vite": "^8.3.4", - "vitest": "^5.0.5" + "vitest": "^5.0.5", + "vue-tsc": "^3.3.13" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" } }, "node_modules/@codemirror/autocomplete": { @@ -175,7 +224,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -728,352 +776,29 @@ "@types/node": "*" } }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/@ungap/structured-clone": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", "license": "ISC" }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz", + "integrity": "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25 || ^3.6.0-rc.10" + } + }, "node_modules/@vitest/mocker": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", @@ -1112,6 +837,177 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.44.tgz", + "integrity": "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.9", + "@vue/shared": "3.5.44", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz", + "integrity": "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz", + "integrity": "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.9", + "@vue/compiler-core": "3.5.44", + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-ssr": "3.5.44", + "@vue/shared": "3.5.44", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.29", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-sfc/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz", + "integrity": "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/language-core": { + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.13.tgz", + "integrity": "sha512-axA0Xemn5LuUKuKWob0/kVq7xQ2mJJM6VqA9Exvt35nM9yRC31goWVve/G/Zb2ZCLsXC8FHYjjfXlnshknSCew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.44.tgz", + "integrity": "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.44.tgz", + "integrity": "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz", + "integrity": "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.44", + "@vue/runtime-core": "3.5.44", + "@vue/shared": "3.5.44", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.44.tgz", + "integrity": "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.44.tgz", + "integrity": "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1198,6 +1094,12 @@ "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", "license": "MIT" }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1234,7 +1136,6 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -1761,11 +1662,17 @@ ], "license": "MIT" }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", - "dev": true, "funding": [ { "type": "github", @@ -1811,11 +1718,17 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -1835,7 +1748,6 @@ "version": "8.5.30", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", - "dev": true, "funding": [ { "type": "opencollective", @@ -1951,7 +1863,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -2042,38 +1953,17 @@ } }, "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "dev": true, + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc" + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" + "node": ">=14.17" } }, "node_modules/undici-types": { @@ -2340,6 +2230,51 @@ } } }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.44.tgz", + "integrity": "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-sfc": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/server-renderer": "3.5.44", + "@vue/shared": "3.5.44" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.13.tgz", + "integrity": "sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.13" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", diff --git a/web/package.json b/web/package.json index b6cbc8e..f04dc07 100644 --- a/web/package.json +++ b/web/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "vite", "build": "vite build", - "typecheck": "tsc --noEmit", + "typecheck": "vue-tsc --noEmit", "test": "vitest run" }, "dependencies": { @@ -17,13 +17,16 @@ "@lezer/common": "^1.5.3", "@lezer/highlight": "^1.2.5", "@lezer/markdown": "^1.8.0", - "shiki": "^4.5.0" + "shiki": "^4.5.0", + "vue": "^3.5.44" }, "devDependencies": { + "@vitejs/plugin-vue": "^6.0.10", "commonmark-spec": "^0.31.2", "happy-dom": "^21.0.3", - "typescript": "^7.0.2", + "typescript": "^6.0.3", "vite": "^8.3.4", - "vitest": "^5.0.5" + "vitest": "^5.0.5", + "vue-tsc": "^3.3.13" } } diff --git a/web/src/app/App.vue b/web/src/app/App.vue new file mode 100644 index 0000000..2ad4f71 --- /dev/null +++ b/web/src/app/App.vue @@ -0,0 +1,37 @@ + + + diff --git a/web/src/app/AuthView.vue b/web/src/app/AuthView.vue new file mode 100644 index 0000000..fb255c7 --- /dev/null +++ b/web/src/app/AuthView.vue @@ -0,0 +1,64 @@ + + + diff --git a/web/src/app/EditorPane.vue b/web/src/app/EditorPane.vue new file mode 100644 index 0000000..6d1194d --- /dev/null +++ b/web/src/app/EditorPane.vue @@ -0,0 +1,57 @@ + + + diff --git a/web/src/app/Icon.vue b/web/src/app/Icon.vue new file mode 100644 index 0000000..60418d5 --- /dev/null +++ b/web/src/app/Icon.vue @@ -0,0 +1,34 @@ + + + diff --git a/web/src/app/NoteBanner.vue b/web/src/app/NoteBanner.vue new file mode 100644 index 0000000..e8c8bdf --- /dev/null +++ b/web/src/app/NoteBanner.vue @@ -0,0 +1,22 @@ + + + diff --git a/web/src/app/Sidebar.vue b/web/src/app/Sidebar.vue new file mode 100644 index 0000000..e3e0780 --- /dev/null +++ b/web/src/app/Sidebar.vue @@ -0,0 +1,48 @@ + + + diff --git a/web/src/app/TabBar.vue b/web/src/app/TabBar.vue new file mode 100644 index 0000000..3c6f1d0 --- /dev/null +++ b/web/src/app/TabBar.vue @@ -0,0 +1,27 @@ + + + diff --git a/web/src/app/Toasts.vue b/web/src/app/Toasts.vue new file mode 100644 index 0000000..d00f717 --- /dev/null +++ b/web/src/app/Toasts.vue @@ -0,0 +1,17 @@ + + + diff --git a/web/src/app/TrashDialog.vue b/web/src/app/TrashDialog.vue new file mode 100644 index 0000000..0908b10 --- /dev/null +++ b/web/src/app/TrashDialog.vue @@ -0,0 +1,60 @@ + + + diff --git a/web/src/app/TreeNode.vue b/web/src/app/TreeNode.vue new file mode 100644 index 0000000..6eecde5 --- /dev/null +++ b/web/src/app/TreeNode.vue @@ -0,0 +1,125 @@ + + + diff --git a/web/src/app/Workspace.vue b/web/src/app/Workspace.vue new file mode 100644 index 0000000..03c5adf --- /dev/null +++ b/web/src/app/Workspace.vue @@ -0,0 +1,59 @@ + + + diff --git a/web/src/app/api.ts b/web/src/app/api.ts new file mode 100644 index 0000000..6989af5 --- /dev/null +++ b/web/src/app/api.ts @@ -0,0 +1,104 @@ +// REST API 客户端,与服务端 internal/api 对应 + +export interface Entry { + path: string + dir: boolean + size: number + mtime: number +} + +export interface TrashItem { + id: string + path: string + dir: boolean + deletedAt: number +} + +export class ApiError extends Error { + constructor( + readonly status: number, + message: string, + readonly data: Record = {}, + ) { + super(message) + } +} + +// 401 时通知应用回到登录页 +let onUnauthorized: () => void = () => {} +export function setUnauthorizedHandler(fn: () => void) { + onUnauthorized = fn +} + +export function encodePath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/') +} + +async function request( + method: string, + url: string, + init: { json?: unknown; body?: BodyInit; headers?: Record; keepalive?: boolean } = {}, +) { + const headers: Record = { ...init.headers } + let body = init.body + if (init.json !== undefined) { + headers['Content-Type'] = 'application/json' + body = JSON.stringify(init.json) + } + const res = await fetch(url, { method, headers, body, credentials: 'same-origin', keepalive: init.keepalive }) + if (!res.ok) { + let data: Record = {} + try { + data = await res.json() + } catch { + // 非 JSON 错误体(如反向代理返回的页面) + } + if (res.status === 401 && !url.startsWith('/api/v1/auth/')) onUnauthorized() + throw new ApiError(res.status, typeof data.error === 'string' ? data.error : `请求失败(${res.status})`, data) + } + return res +} + +const json = async (p: Promise): Promise => (await p).json() + +export const api = { + authState: () => json<{ setUp: boolean; authenticated: boolean }>(request('GET', '/api/v1/auth/state')), + setup: (code: string, password: string) => request('POST', '/api/v1/auth/setup', { json: { code, password } }), + login: (password: string) => request('POST', '/api/v1/auth/login', { json: { password } }), + logout: () => request('POST', '/api/v1/auth/logout'), + + tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries, + + async readFile(path: string): Promise<{ content: string; rev: string }> { + const res = await request('GET', '/api/v1/files/' + encodePath(path)) + return { content: await res.text(), rev: (res.headers.get('ETag') ?? '').replace(/"/g, '') } + }, + + // ifMatch 为空字符串表示新建(文件必须不存在),undefined 表示无条件覆盖 + // keepalive 让请求在页面关闭后仍能发出(浏览器限制请求体不超过 64 KB,超出时退回普通请求) + async writeFile(path: string, content: string, ifMatch?: string, keepalive = false): Promise { + const headers: Record = { 'Content-Type': 'text/markdown; charset=utf-8' } + if (ifMatch === '') headers['If-None-Match'] = '*' + else if (ifMatch !== undefined) headers['If-Match'] = `"${ifMatch}"` + const res = request('PUT', '/api/v1/files/' + encodePath(path), { + body: content, + headers, + keepalive: keepalive && new Blob([content]).size < 60_000, + }) + return (await json<{ rev: string }>(res)).rev + }, + + deleteFile: (path: string) => json(request('DELETE', '/api/v1/files/' + encodePath(path))), + move: (from: string, to: string) => request('POST', '/api/v1/move', { json: { from, to } }), + mkdir: (path: string) => request('POST', '/api/v1/folders', { json: { path } }), + + async upload(note: string, file: File): Promise { + const form = new FormData() + form.append('file', file, file.name || 'image') + return (await json<{ link: string }>(request('POST', '/api/v1/assets?note=' + encodeURIComponent(note), { body: form }))).link + }, + + trash: async () => (await json<{ items: TrashItem[] }>(request('GET', '/api/v1/trash'))).items, + restore: (id: string) => json(request('POST', `/api/v1/trash/${id}/restore`)), + purge: (id: string) => request('DELETE', `/api/v1/trash/${id}`), +} diff --git a/web/src/app/app.css b/web/src/app/app.css new file mode 100644 index 0000000..e675a5f --- /dev/null +++ b/web/src/app/app.css @@ -0,0 +1,348 @@ +/* 应用外壳样式,颜色与字体沿用 themes/base.css 中的 --rm-* 变量 */ +:root { + --rm-sidebar-bg: #f9f9f9; + --rm-sidebar-width: 260px; + --rm-hover: rgba(0, 0, 0, 0.045); + --rm-active: rgba(66, 184, 131, 0.14); + --rm-danger: #e5484d; + --rm-warn-bg: #fff8e6; + --rm-warn-text: #8a5a00; + --rm-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); +} +@media (prefers-color-scheme: dark) { + :root { + --rm-sidebar-bg: #161618; + --rm-hover: rgba(255, 255, 255, 0.06); + --rm-active: rgba(66, 211, 146, 0.16); + --rm-warn-bg: #2f2716; + --rm-warn-text: #f0c060; + --rm-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + } +} + +html, +body, +#app { + height: 100%; +} +body { + overflow: hidden; + -webkit-tap-highlight-color: transparent; +} +button { + font: inherit; + color: inherit; +} + +/* ---- 通用控件 ---- */ +.rm-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + padding: 0 12px; + border: 1px solid var(--rm-divider); + border-radius: 6px; + background: var(--rm-bg); + font-size: 13px; + cursor: pointer; + white-space: nowrap; +} +.rm-btn:hover { border-color: var(--rm-accent); color: var(--rm-accent-strong); } +.rm-btn:disabled { opacity: 0.6; cursor: default; } +.rm-btn-primary { background: var(--rm-accent); border-color: var(--rm-accent); color: #fff; } +.rm-btn-primary:hover { background: var(--rm-accent-strong); color: #fff; } +.rm-btn-danger:hover { border-color: var(--rm-danger); color: var(--rm-danger); } +.rm-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--rm-text-muted); + cursor: pointer; + flex: none; +} +.rm-icon-btn:hover { background: var(--rm-hover); color: var(--rm-text); } + +.rm-splash, +.rm-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + height: 100%; + color: var(--rm-text-muted); +} + +/* ---- 登录 ---- */ +.rm-auth { display: flex; align-items: center; justify-content: center; min-height: 100%; padding: 24px; box-sizing: border-box; } +.rm-auth-card { width: 100%; max-width: 340px; display: flex; flex-direction: column; gap: 14px; } +.rm-auth-card h1 { margin: 0 0 8px; font-size: 26px; color: var(--rm-accent-strong); } +.rm-auth-hint { margin: 0; font-size: 13px; color: var(--rm-text-muted); line-height: 1.6; } +.rm-auth-card label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--rm-text-muted); } +.rm-auth-card input { + height: 40px; + padding: 0 12px; + border: 1px solid var(--rm-divider); + border-radius: 8px; + background: var(--rm-bg); + color: var(--rm-text); + font-size: 15px; + outline: none; +} +.rm-auth-card input:focus { border-color: var(--rm-accent); box-shadow: 0 0 0 3px var(--rm-selection); } +.rm-auth-card .rm-btn { height: 40px; justify-content: center; font-size: 15px; } +.rm-auth-error { margin: 0; color: var(--rm-danger); font-size: 13px; } + +/* ---- 布局 ---- */ +.rm-shell { display: flex; height: 100%; } +.rm-sidebar { + width: var(--rm-sidebar-width); + flex: none; + background: var(--rm-sidebar-bg); + border-right: 1px solid var(--rm-divider); +} +.rm-main { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.rm-backdrop, +.rm-mobile-bar { display: none; } + +/* ---- 侧栏 ---- */ +.rm-sidebar-inner { display: flex; flex-direction: column; height: 100%; } +.rm-sidebar-head { display: flex; align-items: center; gap: 2px; padding: 12px 10px 8px 16px; } +.rm-brand { flex: 1; font-weight: 600; font-size: 15px; color: var(--rm-accent-strong); } +.rm-tree { flex: 1; overflow: auto; padding: 0 6px 12px; } +.rm-tree.drop { background: var(--rm-active); } +.rm-tree-list { list-style: none; margin: 0; padding: 0; } +.rm-tree-row { + position: relative; + display: flex; + align-items: center; + gap: 4px; + height: 30px; + padding-right: 4px; + border-radius: 6px; + font-size: 14px; + cursor: pointer; + user-select: none; +} +.rm-tree-row:hover { background: var(--rm-hover); } +.rm-tree-row.active { background: var(--rm-active); color: var(--rm-accent-strong); } +.rm-tree-row.drop { outline: 2px solid var(--rm-accent); outline-offset: -2px; } +.rm-tree-chevron { display: inline-flex; width: 14px; color: var(--rm-text-muted); transition: transform 0.15s; } +.rm-tree-chevron.open { transform: rotate(90deg); } +.rm-tree-chevron.hidden { visibility: hidden; } +.rm-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rm-tree-input { + flex: 1; + min-width: 0; + height: 24px; + padding: 0 6px; + border: 1px solid var(--rm-accent); + border-radius: 4px; + background: var(--rm-bg); + color: var(--rm-text); + font: inherit; + outline: none; +} +.rm-tree-more { width: 24px; height: 24px; opacity: 0; } +.rm-tree-row:hover .rm-tree-more, +.rm-tree-row.menu-open .rm-tree-more { opacity: 1; } +.rm-tree-empty { padding: 16px; color: var(--rm-text-muted); font-size: 13px; text-align: center; } +.rm-sidebar-foot { display: flex; gap: 4px; padding: 8px; border-top: 1px solid var(--rm-divider); } +.rm-foot-btn { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--rm-text-muted); + font-size: 13px; + cursor: pointer; +} +.rm-foot-btn:hover { background: var(--rm-hover); color: var(--rm-text); } + +/* ---- 菜单 ---- */ +.rm-menu { + position: absolute; + top: 100%; + right: 4px; + z-index: 20; + display: flex; + flex-direction: column; + min-width: 150px; + padding: 4px; + border: 1px solid var(--rm-divider); + border-radius: 8px; + background: var(--rm-bg); + box-shadow: var(--rm-shadow); + cursor: default; +} +.rm-menu button { + display: flex; + align-items: center; + gap: 8px; + height: 32px; + padding: 0 10px; + border: 0; + border-radius: 6px; + background: transparent; + font-size: 13px; + text-align: left; + cursor: pointer; +} +.rm-menu button:hover { background: var(--rm-hover); } +.rm-menu button.danger:hover { color: var(--rm-danger); background: rgba(229, 72, 77, 0.1); } +.rm-menu-backdrop { position: fixed; inset: 0; z-index: 19; cursor: default; } + +/* ---- 标签页 ---- */ +.rm-tabs { + display: flex; + gap: 2px; + height: 38px; + padding: 4px 8px 0; + overflow-x: auto; + border-bottom: 1px solid var(--rm-divider); + scrollbar-width: none; + flex: none; +} +.rm-tabs:empty { display: none; } +.rm-tab { + display: flex; + align-items: center; + gap: 4px; + max-width: 200px; + height: 34px; + padding: 0 4px 0 12px; + border-radius: 8px 8px 0 0; + font-size: 13px; + color: var(--rm-text-muted); + cursor: pointer; + flex: none; +} +.rm-tab:hover { background: var(--rm-hover); } +.rm-tab.active { background: var(--rm-active); color: var(--rm-text); } +.rm-tab-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rm-tab-close { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--rm-text-muted); + cursor: pointer; +} +.rm-tab-close:hover { background: var(--rm-hover); } +.rm-tab-close svg { opacity: 0; } +.rm-tab:hover .rm-tab-close svg, +.rm-tab.active .rm-tab-close svg { opacity: 1; } +.rm-dirty-dot { display: none; position: absolute; width: 7px; height: 7px; border-radius: 50%; background: var(--rm-accent); } +/* 有未保存修改时显示圆点,悬停时换回关闭图标 */ +.rm-tab-close.dirty .rm-dirty-dot { display: block; } +.rm-tab-close.dirty svg { opacity: 0 !important; } +.rm-tab-close.dirty:hover .rm-dirty-dot { display: none; } +.rm-tab-close.dirty:hover svg { opacity: 1 !important; } + +/* ---- 提示条 ---- */ +.rm-banner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 8px 16px; + font-size: 13px; + flex: none; +} +.rm-banner span { flex: 1; min-width: 200px; } +.rm-banner.warn, +.rm-banner.error { background: var(--rm-warn-bg); color: var(--rm-warn-text); } + +/* ---- 编辑区 ---- */ +.rm-editor-pane { flex: 1; min-height: 0; position: relative; } +.rm-editor-host { height: 100%; } +.rm-editor-host .cm-editor { height: 100%; } + +/* ---- 对话框 ---- */ +.rm-dialog-backdrop { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.3); padding: 16px; } +.rm-dialog { + width: 100%; + max-width: 560px; + max-height: 80vh; + display: flex; + flex-direction: column; + border-radius: 12px; + background: var(--rm-bg); + box-shadow: var(--rm-shadow); +} +.rm-dialog-head { display: flex; align-items: center; padding: 14px 16px 8px 20px; } +.rm-dialog-head h2 { flex: 1; margin: 0; font-size: 16px; } +.rm-trash-list { list-style: none; margin: 0; padding: 0 12px 12px; overflow: auto; } +.rm-trash-list li { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; color: var(--rm-text-muted); } +.rm-trash-list li:hover { background: var(--rm-hover); } +.rm-trash-info { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.rm-trash-path { color: var(--rm-text); font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rm-trash-time { font-size: 12px; } + +/* ---- 通知 ---- */ +.rm-toasts { position: fixed; left: 50%; bottom: 24px; z-index: 60; transform: translateX(-50%); display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; } +.rm-toast { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + border-radius: 8px; + background: #1f2328; + color: #fff; + font-size: 13px; + box-shadow: var(--rm-shadow); + pointer-events: auto; +} +.rm-toast-action { border: 0; background: none; color: #42d392; font-weight: 600; cursor: pointer; padding: 0; } + +/* ---- 移动端 ---- */ +@media (max-width: 768px) { + .rm-sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 40; + width: min(84vw, 320px); + transform: translateX(-100%); + transition: transform 0.22s ease; + padding-top: env(safe-area-inset-top); + } + .sidebar-open .rm-sidebar { transform: none; box-shadow: var(--rm-shadow); } + .rm-backdrop { position: fixed; inset: 0; z-index: 39; background: rgba(0, 0, 0, 0.3); opacity: 0; pointer-events: none; transition: opacity 0.22s; display: block; } + .sidebar-open .rm-backdrop { opacity: 1; pointer-events: auto; } + .rm-tabs { display: none; } + .rm-mobile-bar { + display: flex; + align-items: center; + gap: 8px; + height: 48px; + padding: env(safe-area-inset-top) 8px 0; + border-bottom: 1px solid var(--rm-divider); + flex: none; + } + .rm-mobile-bar .rm-icon-btn { width: 40px; height: 40px; } + .rm-mobile-title { flex: 1; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .rm-save-state { width: 8px; height: 8px; margin-right: 8px; border-radius: 50%; background: transparent; } + .rm-save-state.on { background: var(--rm-accent); } + .rm-tree-row { height: 40px; font-size: 15px; } + .rm-tree-more { opacity: 1; width: 36px; height: 36px; } + .rm-sidebar-head .rm-icon-btn { width: 40px; height: 40px; } + .rm-trash-list li { flex-wrap: wrap; } +} diff --git a/web/src/app/drafts.ts b/web/src/app/drafts.ts new file mode 100644 index 0000000..b039377 --- /dev/null +++ b/web/src/app/drafts.ts @@ -0,0 +1,36 @@ +// 未成功保存到服务器的内容暂存在 IndexedDB,刷新页面或断网都不会丢失 + +export interface Draft { + path: string + content: string + // 本地修改所基于的服务器版本;新建文件时为空字符串 + baseRev: string + updatedAt: number +} + +let dbPromise: Promise | undefined + +function db(): Promise { + dbPromise ??= new Promise((resolve, reject) => { + const req = indexedDB.open('rawmark', 1) + req.onupgradeneeded = () => req.result.createObjectStore('drafts', { keyPath: 'path' }) + req.onsuccess = () => resolve(req.result) + req.onerror = () => reject(req.error) + }) + return dbPromise +} + +async function tx(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest): Promise { + const store = (await db()).transaction('drafts', mode).objectStore('drafts') + return new Promise((resolve, reject) => { + const req = fn(store) + req.onsuccess = () => resolve(req.result) + req.onerror = () => reject(req.error) + }) +} + +export const drafts = { + put: (d: Draft) => tx('readwrite', (s) => s.put(d)), + delete: (path: string) => tx('readwrite', (s) => s.delete(path)), + all: () => tx('readonly', (s) => s.getAll()), +} diff --git a/web/src/app/events.ts b/web/src/app/events.ts new file mode 100644 index 0000000..87cba60 --- /dev/null +++ b/web/src/app/events.ts @@ -0,0 +1,48 @@ +// 订阅服务端的仓库变更事件;断线后指数退避重连,重连成功时触发全量同步 + +export interface VaultEvent { + type: 'tree' | 'changed' | 'deleted' | 'moved' + path?: string + from?: string + rev?: string +} + +export function connectEvents(handlers: { onEvents: (evs: VaultEvent[]) => void; onResync: () => void }): () => void { + let ws: WebSocket | undefined + let delay = 1000 + let timer: ReturnType | undefined + let stopped = false + let connectedBefore = false + + const connect = () => { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' + ws = new WebSocket(`${proto}//${location.host}/api/v1/events`) + ws.onopen = () => { + delay = 1000 + // 首次连接前的状态由初始化加载,之后每次重连都可能错过事件 + if (connectedBefore) handlers.onResync() + connectedBefore = true + } + ws.onmessage = (e) => handlers.onEvents(JSON.parse(e.data)) + ws.onclose = () => { + if (stopped) return + timer = setTimeout(connect, delay) + delay = Math.min(delay * 2, 30000) + } + } + connect() + // 页面从后台恢复时立即重连,不等退避计时 + const onVisible = () => { + if (document.visibilityState === 'visible' && ws?.readyState === WebSocket.CLOSED) { + clearTimeout(timer) + connect() + } + } + document.addEventListener('visibilitychange', onVisible) + return () => { + stopped = true + clearTimeout(timer) + document.removeEventListener('visibilitychange', onVisible) + ws?.close() + } +} diff --git a/web/src/app/paths.ts b/web/src/app/paths.ts new file mode 100644 index 0000000..ff48d0f --- /dev/null +++ b/web/src/app/paths.ts @@ -0,0 +1,40 @@ +import { encodePath } from './api' + +export const NOTE_EXT = '.md' + +export function dirname(path: string): string { + const i = path.lastIndexOf('/') + return i < 0 ? '' : path.slice(0, i) +} + +export function basename(path: string): string { + return path.slice(path.lastIndexOf('/') + 1) +} + +export function joinPath(dir: string, name: string): string { + return dir ? `${dir}/${name}` : name +} + +export function noteTitle(path: string): string { + return basename(path).replace(/\.md$/, '') +} + +// 把笔记中的图片地址解析为可加载的 URL:绝对地址原样返回,相对地址基于笔记所在目录走 /api/v1/raw +export function resolveAssetUrl(notePath: string, src: string): string { + if (/^[a-z][a-z\d+.-]*:/i.test(src) || src.startsWith('/') || src.startsWith('#')) return src + const parts = dirname(notePath) ? dirname(notePath).split('/') : [] + for (const seg of src.split(/[?#]/)[0].split('/')) { + if (seg === '' || seg === '.') continue + if (seg === '..') parts.pop() + else { + let decoded = seg + try { + decoded = decodeURIComponent(seg) + } catch { + // 非法的百分号序列按原样使用 + } + parts.push(decoded) + } + } + return '/api/v1/raw/' + encodePath(parts.join('/')) +} diff --git a/web/src/app/tree.ts b/web/src/app/tree.ts new file mode 100644 index 0000000..fbaf9c8 --- /dev/null +++ b/web/src/app/tree.ts @@ -0,0 +1,46 @@ +import { computed, reactive, watch } from 'vue' +import type { Entry } from './api' +import { basename, dirname } from './paths' +import { ws } from './workspace' + +export interface TreeNode { + name: string + path: string + dir: boolean + children: TreeNode[] +} + +const collator = new Intl.Collator('zh-CN', { numeric: true, sensitivity: 'base' }) + +function build(entries: Entry[]): TreeNode[] { + const root: TreeNode[] = [] + const dirs = new Map() + // 先按路径排序,保证父目录先于子项出现 + for (const e of [...entries].sort((a, b) => a.path.length - b.path.length)) { + const node: TreeNode = { name: basename(e.path), path: e.path, dir: e.dir, children: [] } + if (e.dir) dirs.set(e.path, node) + ;(dirs.get(dirname(e.path))?.children ?? root).push(node) + } + const sort = (nodes: TreeNode[]) => { + nodes.sort((a, b) => (a.dir !== b.dir ? (a.dir ? -1 : 1) : collator.compare(a.name, b.name))) + nodes.forEach((n) => sort(n.children)) + } + sort(root) + return root +} + +export const tree = computed(() => build(ws.entries)) + +const EXPANDED_KEY = 'rawmark.expanded' +export const expanded = reactive(new Set(JSON.parse(localStorage.getItem(EXPANDED_KEY) ?? '[]') as string[])) +watch(expanded, () => localStorage.setItem(EXPANDED_KEY, JSON.stringify([...expanded]))) + +// 打开或新建笔记时展开其所在的各级目录 +watch( + () => [ws.active, ws.renaming], + (paths) => { + for (const p of paths) { + for (let d = p ? dirname(p) : ''; d; d = dirname(d)) expanded.add(d) + } + }, +) diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts new file mode 100644 index 0000000..c250f73 --- /dev/null +++ b/web/src/app/workspace.ts @@ -0,0 +1,614 @@ +// 工作区状态与操作:目录树、标签页、保存队列、冲突处理、实时同步。 +// 文件操作一律先更新界面再请求服务器,失败时回滚,保证"操作即时生效"。 +import { Annotation, Transaction, type EditorState } from '@codemirror/state' +import type { EditorView } from '@codemirror/view' +import { reactive, watch } from 'vue' +import { createEditorState, pasteImages } from '../editor' +import { api, ApiError, type Entry } from './api' +import { drafts } from './drafts' +import { connectEvents, type VaultEvent } from './events' +import { basename, dirname, joinPath, NOTE_EXT, resolveAssetUrl } from './paths' + +export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error' + +export interface Tab { + path: string + // 最近一次与服务器一致的版本 + rev: string + status: TabStatus + // 有尚未保存成功的修改 + dirty: boolean + saveError?: string + // 冲突时服务器上的版本;为空字符串表示服务器上已不存在 + conflictRev?: string +} + +export interface Toast { + id: number + text: string + action?: { label: string; run: () => void } +} + +export const ws = reactive({ + entries: [] as Entry[], + tabs: [] as Tab[], + active: null as string | null, + // 目录树中正在行内重命名的路径 + renaming: null as string | null, + // 移动端侧栏抽屉 + sidebarOpen: false, + toasts: [] as Toast[], +}) + +const SAVE_DELAY = 500 +const RETRY_DELAY = 5000 +const MAX_RETRIES = 12 + +// 以下状态不放进 reactive:EditorState 体积大且不可变,交给 Vue 代理没有意义 +const editorStates = new Map() +// 每个打开的笔记一个可变引用,编辑器扩展通过它取得笔记的当前路径(重命名后随之更新) +const noteRefs = new Map() + +interface SaveSlot { + timer?: ReturnType + inflight?: Promise + // 修改序号与已保存序号,二者相等即没有待保存的修改 + seq: number + savedSeq: number + uploads: number + paused: number + retries: number +} +const slots = new Map() + +let view: EditorView | null = null +// 进行中的本地文件操作数;期间收到的目录事件不用于判断标签页是否被删除 +let opsInFlight = 0 + +const externalChange = Annotation.define() + +export function tabOf(path: string | null): Tab | undefined { + return path === null ? undefined : ws.tabs.find((t) => t.path === path) +} + +export function activeTab(): Tab | undefined { + return tabOf(ws.active) +} + +// ---- 提示 ---- + +let toastId = 0 +export function toast(text: string, action?: Toast['action']) { + const t: Toast = { id: ++toastId, text, action } + ws.toasts.push(t) + setTimeout(() => dismissToast(t.id), action ? 6000 : 3500) +} + +export function dismissToast(id: number) { + const i = ws.toasts.findIndex((t) => t.id === id) + if (i >= 0) ws.toasts.splice(i, 1) +} + +function errorText(err: unknown): string { + return err instanceof ApiError ? err.message : '网络错误,请检查连接' +} + +// ---- 编辑器状态 ---- + +export function attachView(v: EditorView | null) { + view = v +} + +export function editorStateOf(path: string): EditorState | undefined { + return editorStates.get(path) +} + +// 笔记的身份对象:重命名/移动后保持不变,用于判断编辑器中显示的是否仍是同一篇笔记 +export function noteRefOf(path: string): { path: string } | undefined { + return noteRefs.get(path) +} + +// 切换标签页前由编辑器面板调用,保存当前视图状态(含光标、滚动无关的选区与撤销历史) +export function storeViewState(path: string, state: EditorState) { + if (editorStates.has(path)) editorStates.set(path, state) +} + +function buildState(path: string, doc: string): EditorState { + const ref = { path } + noteRefs.set(path, ref) + return createEditorState({ + doc, + resolveImage: (src) => resolveAssetUrl(ref.path, src), + onChange: (u) => { + editorStates.set(ref.path, u.state) + if (!u.transactions.some((tr) => tr.annotation(externalChange))) scheduleSave(ref.path) + }, + extensions: [ + pasteImages({ + upload: (file) => api.upload(ref.path, file), + onError: (err) => toast('图片上传失败:' + errorText(err)), + onPending: (d) => { + slot(ref.path).uploads += d + }, + }), + ], + }) +} + +// 用服务器内容替换文档:只替换首尾相同部分之间的差异,尽量保留光标位置;不进入撤销历史 +function replaceDoc(path: string, content: string) { + const state = path === ws.active && view ? view.state : editorStates.get(path) + if (!state) return + const old = state.doc.toString() + if (old === content) return + let start = 0 + while (start < old.length && start < content.length && old[start] === content[start]) start++ + let endOld = old.length + let endNew = content.length + while (endOld > start && endNew > start && old[endOld - 1] === content[endNew - 1]) { + endOld-- + endNew-- + } + const spec = { + changes: { from: start, to: endOld, insert: content.slice(start, endNew) }, + annotations: [externalChange.of(true), Transaction.addToHistory.of(false)], + } + if (path === ws.active && view) view.dispatch(spec) + else editorStates.set(path, state.update(spec).state) +} + +function docOf(path: string): string | undefined { + if (path === ws.active && view && editorStates.has(path)) return view.state.doc.toString() + return editorStates.get(path)?.doc.toString() +} + +// ---- 保存队列 ---- + +function slot(path: string): SaveSlot { + let s = slots.get(path) + if (!s) slots.set(path, (s = { seq: 0, savedSeq: 0, uploads: 0, paused: 0, retries: 0 })) + return s +} + +function scheduleSave(path: string, delay = SAVE_DELAY) { + const s = slot(path) + s.seq++ + const tab = tabOf(path) + if (tab) tab.dirty = true + clearTimeout(s.timer) + s.timer = setTimeout(() => void flush(path), delay) +} + +// 立即保存一篇笔记的待保存修改;keepalive 用于页面隐藏/关闭时尽力发出请求 +export async function flush(path: string, keepalive = false): Promise { + const s = slots.get(path) + if (!s) return + clearTimeout(s.timer) + while (s.inflight) await s.inflight + const tab = tabOf(path) + if (!tab || tab.status !== 'ready' || s.seq === s.savedSeq) return + if (s.paused || s.uploads) { + // 图片上传中或文件正在移动,稍后再存,避免写入临时 blob: 地址或写到旧路径 + s.timer = setTimeout(() => void flush(path), SAVE_DELAY) + return + } + const content = docOf(path) + if (content === undefined) return + const seq = s.seq + const baseRev = tab.rev + s.inflight = (async () => { + try { + await drafts.put({ path, content, baseRev, updatedAt: Date.now() }) + const rev = await api.writeFile(path, content, baseRev, keepalive) + tab.rev = rev + tab.saveError = undefined + s.savedSeq = seq + s.retries = 0 + if (s.seq === seq) { + tab.dirty = false + await drafts.delete(path) + } + } catch (err) { + if (err instanceof ApiError && err.status === 412) { + const serverRev = typeof err.data.rev === 'string' ? err.data.rev : '' + tab.status = serverRev ? 'conflict' : 'deleted' + tab.conflictRev = serverRev + } else if (!(err instanceof ApiError) || err.status >= 500) { + // 网络错误与服务器错误可重试,草稿已在本地,不会丢 + tab.saveError = s.retries < MAX_RETRIES ? '保存失败,正在重试…' : '保存失败,请检查网络后重试' + if (s.retries++ < MAX_RETRIES) s.timer = setTimeout(() => void flush(path), RETRY_DELAY) + } else { + tab.saveError = '保存失败:' + err.message + } + } + })() + await s.inflight + s.inflight = undefined + if (s.seq !== s.savedSeq && tab.status === 'ready' && !s.timer) scheduleSave(path) +} + +export function retrySave(path: string) { + slot(path).retries = 0 + void flush(path) +} + +export async function flushAll(keepalive = false) { + await Promise.all([...slots.keys()].map((p) => flush(p, keepalive))) +} + +// ---- 冲突处理 ---- + +// 放弃本地修改,载入服务器版本 +export async function useServerVersion(path: string) { + const tab = tabOf(path) + if (!tab) return + try { + const { content, rev } = await api.readFile(path) + replaceDoc(path, content) + const s = slot(path) + s.savedSeq = s.seq + Object.assign(tab, { rev, status: 'ready', dirty: false, conflictRev: undefined, saveError: undefined }) + await drafts.delete(path) + } catch (err) { + toast('载入失败:' + errorText(err)) + } +} + +// 保留本地修改,覆盖服务器版本(服务器上已删除时重新创建) +export async function keepLocalVersion(path: string) { + const tab = tabOf(path) + const content = docOf(path) + if (!tab || content === undefined) return + try { + const rev = await api.writeFile(path, content, tab.conflictRev ?? '') + const s = slot(path) + s.savedSeq = s.seq + Object.assign(tab, { rev, status: 'ready', dirty: false, conflictRev: undefined, saveError: undefined }) + await drafts.delete(path) + if (!ws.entries.some((e) => e.path === path)) void refreshTree() + } catch (err) { + if (err instanceof ApiError && err.status === 412) { + tab.conflictRev = typeof err.data.rev === 'string' ? err.data.rev : '' + toast('服务器上的版本又变化了,请再次选择') + } else toast('保存失败:' + errorText(err)) + } +} + +// ---- 标签页 ---- + +export async function openNote(path: string) { + ws.sidebarOpen = false + if (tabOf(path)) { + ws.active = path + return + } + const tab: Tab = { path, rev: '', status: 'loading', dirty: false } + ws.tabs.push(tab) + ws.active = path + try { + const { content, rev } = await api.readFile(path) + editorStates.set(path, buildState(path, content)) + const t = tabOf(path) + if (t) Object.assign(t, { rev, status: 'ready' }) + } catch (err) { + const t = tabOf(path) + if (t) t.status = 'error' + toast('打开失败:' + errorText(err)) + } +} + +export async function closeTab(path: string) { + await flush(path) + const i = ws.tabs.findIndex((t) => t.path === path) + if (i < 0) return + const tab = ws.tabs[i] + if (tab.dirty && tab.status !== 'ready') { + // 冲突或已删除且有未保存修改:草稿保留在本地,下次打开时可恢复 + toast('未保存的修改已保留为本地草稿') + } + ws.tabs.splice(i, 1) + forget(path) + if (ws.active === path) ws.active = ws.tabs[Math.min(i, ws.tabs.length - 1)]?.path ?? null +} + +function forget(path: string) { + editorStates.delete(path) + noteRefs.delete(path) + const s = slots.get(path) + if (s) clearTimeout(s.timer) + slots.delete(path) +} + +// 路径前缀替换(移动文件夹时其下所有路径一起变化) +function remapPath(p: string, from: string, to: string): string | null { + if (p === from) return to + if (p.startsWith(from + '/')) return to + p.slice(from.length) + return null +} + +function remapOpenNotes(from: string, to: string) { + for (const tab of ws.tabs) { + const next = remapPath(tab.path, from, to) + if (next === null) continue + const old = tab.path + tab.path = next + if (ws.active === old) ws.active = next + for (const map of [editorStates, noteRefs, slots] as Map[]) { + if (map.has(old)) { + map.set(next, map.get(old)) + map.delete(old) + } + } + const ref = noteRefs.get(next) + if (ref) ref.path = next + } +} + +function affected(path: string): string[] { + return ws.tabs.map((t) => t.path).filter((p) => p === path || p.startsWith(path + '/')) +} + +// ---- 目录树 ---- + +let treeTimer: ReturnType | undefined +export function refreshTree(): Promise { + clearTimeout(treeTimer) + return new Promise((resolve) => { + treeTimer = setTimeout(async () => { + try { + ws.entries = await api.tree() + if (opsInFlight === 0) markMissingTabsDeleted() + } catch (err) { + toast('刷新目录失败:' + errorText(err)) + } + resolve() + }, 50) + }) +} + +function markMissingTabsDeleted() { + const exists = new Set(ws.entries.map((e) => e.path)) + for (const tab of ws.tabs) { + if (tab.status === 'ready' && !exists.has(tab.path)) { + tab.status = 'deleted' + tab.conflictRev = '' + } + } +} + +function uniqueName(dir: string, base: string, ext: string): string { + const taken = new Set(ws.entries.map((e) => e.path)) + for (let i = 0; ; i++) { + const name = base + (i ? ` ${i}` : '') + ext + if (!taken.has(joinPath(dir, name))) return joinPath(dir, name) + } +} + +async function withOp(fn: () => Promise): Promise { + opsInFlight++ + try { + return await fn() + } finally { + opsInFlight-- + } +} + +export async function createNote(dir: string) { + const path = uniqueName(dir, '未命名', NOTE_EXT) + ws.entries.push({ path, dir: false, size: 0, mtime: Date.now() }) + ws.renaming = path + await withOp(async () => { + try { + const rev = await api.writeFile(path, '', '') + ws.tabs.push({ path, rev, status: 'ready', dirty: false }) + editorStates.set(path, buildState(path, '')) + ws.active = path + } catch (err) { + ws.entries = ws.entries.filter((e) => e.path !== path) + if (ws.renaming === path) ws.renaming = null + toast('新建笔记失败:' + errorText(err)) + } + }) +} + +export async function createFolder(dir: string) { + const path = uniqueName(dir, '新建文件夹', '') + ws.entries.push({ path, dir: true, size: 0, mtime: Date.now() }) + ws.renaming = path + await withOp(async () => { + try { + await api.mkdir(path) + } catch (err) { + ws.entries = ws.entries.filter((e) => e.path !== path) + if (ws.renaming === path) ws.renaming = null + toast('新建文件夹失败:' + errorText(err)) + } + }) +} + +export function renameTarget(path: string, name: string): string | null { + name = name.trim().replace(/\//g, '') + if (!name || name.startsWith('.')) return null + const entry = ws.entries.find((e) => e.path === path) + if (entry && !entry.dir && !name.endsWith(NOTE_EXT)) name += NOTE_EXT + return joinPath(dirname(path), name) +} + +export async function move(from: string, to: string) { + if (from === to) return + if (ws.entries.some((e) => e.path === to)) { + toast(`“${basename(to)}”已存在`) + return + } + await withOp(async () => { + // 先保存待保存的修改,确保移动的是最新内容;移动期间暂停保存,避免写到旧路径 + await Promise.all(affected(from).map((p) => flush(p))) + const paused = affected(from).map(slot) + paused.forEach((s) => s.paused++) + const snapshot = ws.entries + ws.entries = ws.entries.map((e) => ({ ...e, path: remapPath(e.path, from, to) ?? e.path })) + remapOpenNotes(from, to) + try { + await api.move(from, to) + } catch (err) { + ws.entries = snapshot + remapOpenNotes(to, from) + toast('移动失败:' + errorText(err)) + return + } finally { + paused.forEach((s) => s.paused--) + } + // 笔记改名时服务器会改写正文中的图片链接,版本随之变化,需要重新同步 + await Promise.all(affected(to).map(async (p) => { + const tab = tabOf(p) + if (!tab) return + if (tab.dirty) { + // 移动期间又有输入:本地内容仍含旧链接,交给用户选择 + const { rev } = await api.readFile(p) + if (rev !== tab.rev) Object.assign(tab, { status: 'conflict', conflictRev: rev }) + } else await syncOpenTab(tab) + })) + }) + void refreshTree() +} + +export async function remove(path: string) { + const entry = ws.entries.find((e) => e.path === path) + if (!entry) return + await withOp(async () => { + const paths = affected(path) + await Promise.all(paths.map((p) => flush(p))) + const snapshot = ws.entries + ws.entries = ws.entries.filter((e) => remapPath(e.path, path, path) === null) // 去掉自身及其下所有条目 + for (const p of paths) await closeTab(p) + try { + const item = await api.deleteFile(path) + toast(`已删除“${basename(path).replace(/\.md$/, '')}”`, { + label: '撤销', + run: () => void restore(item.id), + }) + } catch (err) { + ws.entries = snapshot + toast('删除失败:' + errorText(err)) + } + }) +} + +export async function restore(id: string) { + try { + const item = await api.restore(id) + await refreshTree() + if (!item.dir) void openNote(item.path) + } catch (err) { + toast('恢复失败:' + errorText(err)) + } +} + +// ---- 实时同步 ---- + +async function syncOpenTab(tab: Tab) { + const s = slots.get(tab.path) + if (tab.status !== 'ready' || tab.dirty || s?.inflight) return + try { + const { content, rev } = await api.readFile(tab.path) + if (rev === tab.rev || tab.dirty) return + replaceDoc(tab.path, content) + tab.rev = rev + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + tab.status = 'deleted' + tab.conflictRev = '' + } + } +} + +function handleEvents(evs: VaultEvent[]) { + for (const ev of evs) { + if (ev.type === 'tree') void refreshTree() + else if (ev.type === 'changed') { + const tab = tabOf(ev.path!) + // 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理 + if (tab && tab.rev !== ev.rev) void syncOpenTab(tab) + } else if (ev.type === 'moved') { + if (tabOf(ev.from!) && !tabOf(ev.path!)) remapOpenNotes(ev.from!, ev.path!) + } else if (ev.type === 'deleted') { + const tab = tabOf(ev.path!) + if (tab && tab.status === 'ready' && opsInFlight === 0) { + tab.status = 'deleted' + tab.conflictRev = '' + } + } + } +} + +async function resync() { + await refreshTree() + await Promise.all(ws.tabs.map(syncOpenTab)) +} + +// ---- 启动 ---- + +const TABS_KEY = 'rawmark.tabs' + +// 恢复未保存成功的草稿:服务器版本未变则继续保存,已变则进入冲突状态由用户选择 +async function recoverDrafts() { + for (const d of await drafts.all()) { + try { + const { content, rev } = await api.readFile(d.path) + if (content === d.content) { + await drafts.delete(d.path) + continue + } + if (!tabOf(d.path)) ws.tabs.push({ path: d.path, rev, status: 'ready', dirty: false }) + const tab = tabOf(d.path)! + editorStates.set(d.path, buildState(d.path, d.content)) + tab.rev = rev + if (rev === d.baseRev) { + tab.status = 'ready' + scheduleSave(d.path) + } else Object.assign(tab, { status: 'conflict', conflictRev: rev, dirty: true }) + } catch (err) { + if (!(err instanceof ApiError) || err.status !== 404) throw err + ws.tabs.push({ path: d.path, rev: '', status: 'deleted', dirty: true, conflictRev: '' }) + editorStates.set(d.path, buildState(d.path, d.content)) + } + } +} + +let disconnect: (() => void) | undefined +let listenersInstalled = false + +export async function startWorkspace() { + await refreshTree() + const saved = JSON.parse(localStorage.getItem(TABS_KEY) ?? '{"paths":[],"active":null}') as { paths: string[]; active: string | null } + const exists = new Set(ws.entries.map((e) => e.path)) + await Promise.all(saved.paths.filter((p) => exists.has(p)).map(openNote)) + try { + await recoverDrafts() + } catch (err) { + toast('恢复本地草稿失败:' + errorText(err)) + } + ws.active = saved.active && tabOf(saved.active) ? saved.active : (ws.tabs[0]?.path ?? null) + disconnect = connectEvents({ onEvents: handleEvents, onResync: () => void resync() }) + // 重新登录后工作区会再次启动,以下全局监听只注册一次 + if (listenersInstalled) return + listenersInstalled = true + watch( + () => [ws.tabs.map((t) => t.path), ws.active] as const, + ([paths, active]) => localStorage.setItem(TABS_KEY, JSON.stringify({ paths, active })), + ) + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') void flushAll(true) + }) + window.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { + e.preventDefault() + if (ws.active) void flush(ws.active) + } + }) +} + +export function stopWorkspace() { + disconnect?.() + disconnect = undefined +} diff --git a/web/src/demo/main.ts b/web/src/demo/main.ts new file mode 100644 index 0000000..ca7ea7b --- /dev/null +++ b/web/src/demo/main.ts @@ -0,0 +1,11 @@ +// 编辑器演示页(无需登录),用于性能与交互的自动化测试 +import { createEditorState, createEditorView } from '../editor' +import sample from './sample.md?raw' +import '../themes/base.css' + +// ?repeat=N 将示例重复 N 次,用于大文档性能测试 +const repeat = Number(new URLSearchParams(location.search).get('repeat') ?? 1) +const doc = Array.from({ length: Math.max(1, repeat) }, () => sample).join('\n') + +const view = createEditorView(document.getElementById('app')!, createEditorState({ doc })) +;(window as unknown as { rawmarkView: typeof view }).rawmarkView = view diff --git a/web/src/editor/index.ts b/web/src/editor/index.ts index 8de7966..4a440dd 100644 --- a/web/src/editor/index.ts +++ b/web/src/editor/index.ts @@ -1,21 +1,24 @@ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown' import { EditorState, Prec, type Extension } from '@codemirror/state' -import { drawSelection, EditorView, keymap } from '@codemirror/view' +import { drawSelection, EditorView, keymap, type ViewUpdate } from '@codemirror/view' import { codeBlockHighlight } from './codeblocks' import { livePreview, resolveImageUrl } from './livepreview' import { tableEditing } from './table' import { markdownTheme } from './theme' -export interface EditorOptions { - parent: HTMLElement +export { pasteImages } from './paste' + +export interface EditorStateOptions { doc: string resolveImage?: (src: string) => string - onChange?: (state: EditorState) => void + // 文档变化回调,仅对挂载在视图上的状态触发;未激活标签页的状态由调用方直接更新 + onChange?: (update: ViewUpdate) => void extensions?: Extension[] } -export function createEditor(opts: EditorOptions): EditorView { +// 每个打开的笔记各自持有一个 EditorState(含撤销历史),切换标签页时整体替换 +export function createEditorState(opts: EditorStateOptions): EditorState { const extensions: Extension[] = [ history(), drawSelection(), @@ -34,8 +37,12 @@ export function createEditor(opts: EditorOptions): EditorView { if (opts.resolveImage) extensions.push(resolveImageUrl.of(opts.resolveImage)) if (opts.onChange) { const onChange = opts.onChange - extensions.push(EditorView.updateListener.of((u) => u.docChanged && onChange(u.state))) + extensions.push(EditorView.updateListener.of((u) => u.docChanged && onChange(u))) } if (opts.extensions) extensions.push(...opts.extensions) - return new EditorView({ parent: opts.parent, state: EditorState.create({ doc: opts.doc, extensions }) }) + return EditorState.create({ doc: opts.doc, extensions }) +} + +export function createEditorView(parent: HTMLElement, state: EditorState): EditorView { + return new EditorView({ parent, state }) } diff --git a/web/src/editor/paste.ts b/web/src/editor/paste.ts new file mode 100644 index 0000000..ce94c9f --- /dev/null +++ b/web/src/editor/paste.ts @@ -0,0 +1,70 @@ +import type { Extension } from '@codemirror/state' +import { EditorView } from '@codemirror/view' + +export interface PasteImagesOptions { + // 上传文件,返回写入 Markdown 的链接 + upload: (file: File) => Promise + onError: (err: unknown) => void + // 上传开始/结束,供保存队列在上传期间暂缓保存(避免把临时的 blob: 地址写入文件) + onPending: (delta: 1 | -1) => void +} + +// 粘贴或拖入图片:先用 blob: 地址插入占位,立即显示;上传完成后替换为正式链接 +export function pasteImages(opts: PasteImagesOptions): Extension { + const insert = (view: EditorView, files: File[], pos?: number) => { + const images = files.filter((f) => f.type.startsWith('image/')) + if (!images.length) return false + const urls = images.map((f) => URL.createObjectURL(f)) + const text = urls.map((u) => `![](${u})`).join('\n') + const range = view.state.selection.main + const from = pos ?? range.from + const to = pos ?? range.to + view.dispatch({ + changes: { from, to, insert: text }, + selection: { anchor: from + text.length }, + userEvent: 'input.paste', + }) + images.forEach((file, i) => { + opts.onPending(1) + opts + .upload(file) + .then((link) => replaceUrl(view, urls[i], link)) + .catch((err) => { + removePlaceholder(view, urls[i]) + opts.onError(err) + }) + .finally(() => { + opts.onPending(-1) + URL.revokeObjectURL(urls[i]) + }) + }) + return true + } + return EditorView.domEventHandlers({ + paste(e, view) { + const files = [...(e.clipboardData?.files ?? [])] + if (!insert(view, files)) return false + e.preventDefault() + return true + }, + drop(e, view) { + const files = [...(e.dataTransfer?.files ?? [])] + const pos = view.posAtCoords(e) + if (!files.length || !insert(view, files, pos ?? undefined)) return false + e.preventDefault() + return true + }, + }) +} + +// 占位可能已被用户编辑移动,按 blob 地址在当前文档中查找 +function replaceUrl(view: EditorView, url: string, link: string) { + const at = view.state.doc.toString().indexOf(url) + if (at >= 0) view.dispatch({ changes: { from: at, to: at + url.length, insert: link } }) +} + +function removePlaceholder(view: EditorView, url: string) { + const placeholder = `![](${url})` + const at = view.state.doc.toString().indexOf(placeholder) + if (at >= 0) view.dispatch({ changes: { from: at, to: at + placeholder.length, insert: '' } }) +} diff --git a/web/src/main.ts b/web/src/main.ts index d829818..5866cf9 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,11 +1,6 @@ -// M0 原型入口:只挂载编辑器,M1 替换为 Vue 外壳 -import { createEditor } from './editor' -import sample from './demo/sample.md?raw' +import { createApp } from 'vue' +import App from './app/App.vue' import './themes/base.css' +import './app/app.css' -// ?repeat=N 将示例重复 N 次,用于大文档性能测试 -const repeat = Number(new URLSearchParams(location.search).get('repeat') ?? 1) -const doc = Array.from({ length: Math.max(1, repeat) }, () => sample).join('\n') - -const view = createEditor({ parent: document.getElementById('app')!, doc }) -;(window as unknown as { rawmarkView: typeof view }).rawmarkView = view +createApp(App).mount('#app') diff --git a/web/tsconfig.json b/web/tsconfig.json index e106ff8..5694883 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -12,5 +12,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["src", "vite.config.ts"] + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"] } diff --git a/web/vite.config.ts b/web/vite.config.ts index 2dd8bac..ec0a9c6 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,6 +1,18 @@ +import vue from '@vitejs/plugin-vue' import { defineConfig } from 'vitest/config' export default defineConfig({ + plugins: [vue()], + build: { + rolldownOptions: { + // demo.html 为免登录的编辑器演示页,供自动化性能/交互测试使用 + input: { index: 'index.html', demo: 'demo.html' }, + }, + }, worker: { format: 'es' }, + server: { + // 开发时把 API 与 WebSocket 转发给本地 Go 服务 + proxy: { '/api': { target: 'http://127.0.0.1:8080', ws: true } }, + }, test: { environment: 'node' }, })