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 @@
+
+
+
+
+ 这篇笔记已在其他地方被修改,与你的修改冲突。
+
+
+
+
+ 这篇笔记已在其他地方被删除或移动。
+
+
+
+ {{ tab.saveError }}
+
+
+
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 @@
+
+
+
+
+
+ {{ t.text }}
+
+
+
+
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 @@
+
+
+
+
+
+
+
+ {{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}
+
+
+
+
+
+
+
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) => ``).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 = ``
+ 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' },
})