feat(web): add Vue workspace with file tree, tabs, autosave and live sync
- Login and first-run setup screens - File tree with inline create/rename, context menu, drag-and-drop move and trash with undo; all operations update the UI first and roll back on failure - Tabs keep a CodeMirror state each; edits autosave after 500 ms with If-Match revisions, unsent edits persist in IndexedDB, and conflicts or remote deletions surface as banners with explicit choices - WebSocket events sync external edits, moves and deletions into open tabs; reconnects trigger a full resync - Pasted or dropped images show instantly and upload to note.assets/ - Responsive layout with a swipe-in sidebar drawer on phones - Switch to TypeScript 6: vue-tsc does not support TypeScript 7 yet
This commit is contained in:
1 parent
c4d2bd4636
commit
02d904056c
28 files changed
+2231
-398
No files matched your search
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>RawMark 编辑器演示</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/demo/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||||
|
<meta name="theme-color" content="#1a1a1a" media="(prefers-color-scheme: dark)" />
|
||||||
<title>RawMark</title>
|
<title>RawMark</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Generated
+312
-377
@@ -14,14 +14,63 @@
|
|||||||
"@lezer/common": "^1.5.3",
|
"@lezer/common": "^1.5.3",
|
||||||
"@lezer/highlight": "^1.2.5",
|
"@lezer/highlight": "^1.2.5",
|
||||||
"@lezer/markdown": "^1.8.0",
|
"@lezer/markdown": "^1.8.0",
|
||||||
"shiki": "^4.5.0"
|
"shiki": "^4.5.0",
|
||||||
|
"vue": "^3.5.44"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.10",
|
||||||
"commonmark-spec": "^0.31.2",
|
"commonmark-spec": "^0.31.2",
|
||||||
"happy-dom": "^21.0.3",
|
"happy-dom": "^21.0.3",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.3.4",
|
"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": {
|
"node_modules/@codemirror/autocomplete": {
|
||||||
@@ -175,7 +224,6 @@
|
|||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||||
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
@@ -728,352 +776,29 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@ungap/structured-clone": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
|
||||||
"integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
|
"integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/@vitest/mocker": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz",
|
||||||
@@ -1112,6 +837,177 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"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": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -1198,6 +1094,12 @@
|
|||||||
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/dequal": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
@@ -1234,7 +1136,6 @@
|
|||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12"
|
"node": ">=0.12"
|
||||||
@@ -1761,11 +1662,17 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.20",
|
"version": "3.3.20",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1811,11 +1718,17 @@
|
|||||||
"regex-recursion": "^6.0.2"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
@@ -1835,7 +1748,6 @@
|
|||||||
"version": "8.5.30",
|
"version": "8.5.30",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz",
|
||||||
"integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==",
|
"integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -1951,7 +1863,6 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -2042,38 +1953,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "7.0.2",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc"
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.20.0"
|
"node": ">=14.17"
|
||||||
},
|
|
||||||
"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_modules/undici-types": {
|
"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": {
|
"node_modules/w3c-keyname": {
|
||||||
"version": "2.2.8",
|
"version": "2.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
|||||||
+7
-4
@@ -5,7 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -17,13 +17,16 @@
|
|||||||
"@lezer/common": "^1.5.3",
|
"@lezer/common": "^1.5.3",
|
||||||
"@lezer/highlight": "^1.2.5",
|
"@lezer/highlight": "^1.2.5",
|
||||||
"@lezer/markdown": "^1.8.0",
|
"@lezer/markdown": "^1.8.0",
|
||||||
"shiki": "^4.5.0"
|
"shiki": "^4.5.0",
|
||||||
|
"vue": "^3.5.44"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.10",
|
||||||
"commonmark-spec": "^0.31.2",
|
"commonmark-spec": "^0.31.2",
|
||||||
"happy-dom": "^21.0.3",
|
"happy-dom": "^21.0.3",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.3.4",
|
"vite": "^8.3.4",
|
||||||
"vitest": "^5.0.5"
|
"vitest": "^5.0.5",
|
||||||
|
"vue-tsc": "^3.3.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { api, setUnauthorizedHandler } from './api'
|
||||||
|
import AuthView from './AuthView.vue'
|
||||||
|
import Toasts from './Toasts.vue'
|
||||||
|
import Workspace from './Workspace.vue'
|
||||||
|
import { stopWorkspace } from './workspace'
|
||||||
|
|
||||||
|
const phase = ref<'loading' | 'setup' | 'login' | 'ready' | 'error'>('loading')
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
try {
|
||||||
|
const s = await api.authState()
|
||||||
|
phase.value = !s.setUp ? 'setup' : s.authenticated ? 'ready' : 'login'
|
||||||
|
} catch {
|
||||||
|
phase.value = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setUnauthorizedHandler(() => {
|
||||||
|
stopWorkspace()
|
||||||
|
phase.value = 'login'
|
||||||
|
})
|
||||||
|
onMounted(check)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AuthView v-if="phase === 'setup' || phase === 'login'" :mode="phase" @done="phase = 'ready'" />
|
||||||
|
<Workspace v-else-if="phase === 'ready'" @logout="phase = 'login'" />
|
||||||
|
<div v-else class="rm-splash">
|
||||||
|
<template v-if="phase === 'error'">
|
||||||
|
<p>无法连接到服务器</p>
|
||||||
|
<button class="rm-btn" @click="check">重试</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<Toasts />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { api, ApiError } from './api'
|
||||||
|
|
||||||
|
const props = defineProps<{ mode: 'setup' | 'login' }>()
|
||||||
|
const emit = defineEmits<{ done: [] }>()
|
||||||
|
|
||||||
|
const code = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const confirm = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const busy = ref(false)
|
||||||
|
const isSetup = computed(() => props.mode === 'setup')
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
error.value = ''
|
||||||
|
if (isSetup.value && password.value !== confirm.value) {
|
||||||
|
error.value = '两次输入的密码不一致'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
if (isSetup.value) await api.setup(code.value, password.value)
|
||||||
|
else await api.login(password.value)
|
||||||
|
emit('done')
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof ApiError ? err.message : '网络错误,请检查连接'
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rm-auth">
|
||||||
|
<form class="rm-auth-card" @submit.prevent="submit">
|
||||||
|
<h1>RawMark</h1>
|
||||||
|
<p v-if="isSetup" class="rm-auth-hint">
|
||||||
|
首次使用,请设置登录密码。启动码位于服务器数据目录的 <code>setup-code.txt</code> 中。
|
||||||
|
</p>
|
||||||
|
<label v-if="isSetup">
|
||||||
|
<span>启动码</span>
|
||||||
|
<input v-model="code" autocomplete="one-time-code" autocapitalize="characters" placeholder="XXXX-XXXX" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>密码</span>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
:autocomplete="isSetup ? 'new-password' : 'current-password'"
|
||||||
|
:minlength="isSetup ? 8 : undefined"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label v-if="isSetup">
|
||||||
|
<span>确认密码</span>
|
||||||
|
<input v-model="confirm" type="password" autocomplete="new-password" required />
|
||||||
|
</label>
|
||||||
|
<p v-if="error" class="rm-auth-error">{{ error }}</p>
|
||||||
|
<button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">{{ isSetup ? '完成设置' : '登录' }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { EditorState } from '@codemirror/state'
|
||||||
|
import type { EditorView } from '@codemirror/view'
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { createEditorView } from '../editor'
|
||||||
|
import { activeTab, attachView, editorStateOf, noteRefOf, storeViewState, ws } from './workspace'
|
||||||
|
|
||||||
|
const host = ref<HTMLElement>()
|
||||||
|
const tab = computed(activeTab)
|
||||||
|
let view: EditorView
|
||||||
|
// 当前显示的笔记(以身份对象标识,重命名后不变)及各笔记的滚动位置
|
||||||
|
let shown: { path: string } | undefined
|
||||||
|
const scrollTops = new WeakMap<object, number>()
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
const path = ws.active
|
||||||
|
const ref = path ? noteRefOf(path) : undefined
|
||||||
|
const state = path ? editorStateOf(path) : undefined
|
||||||
|
if (shown && shown !== ref) {
|
||||||
|
storeViewState(shown.path, view.state)
|
||||||
|
scrollTops.set(shown, view.scrollDOM.scrollTop)
|
||||||
|
}
|
||||||
|
if (!ref || !state) {
|
||||||
|
shown = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 切换笔记,或该笔记的状态在别处被整体替换(如恢复草稿)时才重设
|
||||||
|
if (shown !== ref || state.doc !== view.state.doc) {
|
||||||
|
view.setState(state)
|
||||||
|
const top = scrollTops.get(ref) ?? 0
|
||||||
|
requestAnimationFrame(() => (view.scrollDOM.scrollTop = top))
|
||||||
|
}
|
||||||
|
shown = ref
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
view = createEditorView(host.value!, EditorState.create())
|
||||||
|
attachView(view)
|
||||||
|
show()
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (shown) storeViewState(shown.path, view.state)
|
||||||
|
attachView(null)
|
||||||
|
view.destroy()
|
||||||
|
})
|
||||||
|
watch(() => [ws.active, tab.value?.status], show, { flush: 'post' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rm-editor-pane">
|
||||||
|
<div v-show="tab && tab.status !== 'loading' && tab.status !== 'error'" ref="host" class="rm-editor-host" />
|
||||||
|
<div v-if="!tab" class="rm-empty">
|
||||||
|
<p>选择或新建一篇笔记</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="tab.status === 'error'" class="rm-empty"><p>笔记打开失败</p></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// 线性图标(24×24 视框,描边绘制)
|
||||||
|
const paths = {
|
||||||
|
menu: 'M4 6h16M4 12h16M4 18h16',
|
||||||
|
noteAdd: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6M12 12v6M9 15h6',
|
||||||
|
folderAdd: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM12 10v6M9 13h6',
|
||||||
|
folder: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
|
||||||
|
note: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6',
|
||||||
|
chevron: 'm9 6 6 6-6 6',
|
||||||
|
more: 'M12 6h.01M12 12h.01M12 18h.01',
|
||||||
|
close: 'M18 6 6 18M6 6l12 12',
|
||||||
|
trash: 'M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6',
|
||||||
|
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
|
||||||
|
restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5',
|
||||||
|
edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z',
|
||||||
|
}
|
||||||
|
defineProps<{ name: keyof typeof paths; size?: number }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="size ?? 16"
|
||||||
|
:height="size ?? 16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path :d="paths[name]" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { activeTab, keepLocalVersion, retrySave, useServerVersion } from './workspace'
|
||||||
|
|
||||||
|
const tab = computed(activeTab)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="tab?.status === 'conflict'" class="rm-banner warn">
|
||||||
|
<span>这篇笔记已在其他地方被修改,与你的修改冲突。</span>
|
||||||
|
<button class="rm-btn" @click="useServerVersion(tab.path)">载入服务器版本</button>
|
||||||
|
<button class="rm-btn" @click="keepLocalVersion(tab.path)">保留我的修改</button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="tab?.status === 'deleted'" class="rm-banner warn">
|
||||||
|
<span>这篇笔记已在其他地方被删除或移动。</span>
|
||||||
|
<button class="rm-btn" @click="keepLocalVersion(tab.path)">重新保存</button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="tab?.saveError" class="rm-banner error">
|
||||||
|
<span>{{ tab.saveError }}</span>
|
||||||
|
<button class="rm-btn" @click="retrySave(tab.path)">立即重试</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
import { basename } from './paths'
|
||||||
|
import TrashDialog from './TrashDialog.vue'
|
||||||
|
import { tree } from './tree'
|
||||||
|
import TreeNode from './TreeNode.vue'
|
||||||
|
import { createFolder, createNote, move } from './workspace'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ logout: [] }>()
|
||||||
|
const trashOpen = ref(false)
|
||||||
|
const rootDrop = ref(false)
|
||||||
|
|
||||||
|
function onRootDragOver(e: DragEvent) {
|
||||||
|
if (!e.dataTransfer?.types.includes('application/x-rawmark-path')) return
|
||||||
|
e.preventDefault()
|
||||||
|
rootDrop.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRootDrop(e: DragEvent) {
|
||||||
|
rootDrop.value = false
|
||||||
|
const src = e.dataTransfer?.getData('application/x-rawmark-path')
|
||||||
|
if (!src || !src.includes('/')) return
|
||||||
|
e.preventDefault()
|
||||||
|
void move(src, basename(src))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rm-sidebar-inner">
|
||||||
|
<div class="rm-sidebar-head">
|
||||||
|
<span class="rm-brand">RawMark</span>
|
||||||
|
<button class="rm-icon-btn" title="新建笔记" @click="createNote('')"><Icon name="noteAdd" /></button>
|
||||||
|
<button class="rm-icon-btn" title="新建文件夹" @click="createFolder('')"><Icon name="folderAdd" /></button>
|
||||||
|
</div>
|
||||||
|
<div class="rm-tree" :class="{ drop: rootDrop }" @dragover="onRootDragOver" @dragleave="rootDrop = false" @drop="onRootDrop">
|
||||||
|
<ul class="rm-tree-list">
|
||||||
|
<TreeNode v-for="node in tree" :key="node.path" :node="node" :depth="0" />
|
||||||
|
</ul>
|
||||||
|
<p v-if="!tree.length" class="rm-tree-empty">还没有笔记</p>
|
||||||
|
</div>
|
||||||
|
<div class="rm-sidebar-foot">
|
||||||
|
<button class="rm-foot-btn" @click="trashOpen = true"><Icon name="trash" />回收站</button>
|
||||||
|
<button class="rm-foot-btn" @click="emit('logout')"><Icon name="logout" />退出</button>
|
||||||
|
</div>
|
||||||
|
<TrashDialog v-if="trashOpen" @close="trashOpen = false" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
import { noteTitle } from './paths'
|
||||||
|
import { closeTab, ws } from './workspace'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="rm-tabs" role="tablist">
|
||||||
|
<div
|
||||||
|
v-for="tab in ws.tabs"
|
||||||
|
:key="tab.path"
|
||||||
|
class="rm-tab"
|
||||||
|
:class="{ active: ws.active === tab.path }"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="ws.active === tab.path"
|
||||||
|
:title="tab.path"
|
||||||
|
@click="ws.active = tab.path"
|
||||||
|
@auxclick.prevent="$event.button === 1 && closeTab(tab.path)"
|
||||||
|
>
|
||||||
|
<span class="rm-tab-title">{{ noteTitle(tab.path) }}</span>
|
||||||
|
<button class="rm-tab-close" :class="{ dirty: tab.dirty }" title="关闭" @click.stop="closeTab(tab.path)">
|
||||||
|
<span class="rm-dirty-dot" />
|
||||||
|
<Icon name="close" :size="12" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { dismissToast, ws, type Toast } from './workspace'
|
||||||
|
|
||||||
|
function act(t: Toast) {
|
||||||
|
t.action?.run()
|
||||||
|
dismissToast(t.id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rm-toasts" aria-live="polite">
|
||||||
|
<div v-for="t in ws.toasts" :key="t.id" class="rm-toast">
|
||||||
|
<span>{{ t.text }}</span>
|
||||||
|
<button v-if="t.action" class="rm-toast-action" @click="act(t)">{{ t.action.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { api, type TrashItem } from './api'
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
import { restore, toast } from './workspace'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
const items = ref<TrashItem[] | null>(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
items.value = await api.trash()
|
||||||
|
} catch {
|
||||||
|
toast('读取回收站失败')
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRestore(item: TrashItem) {
|
||||||
|
items.value = items.value!.filter((i) => i.id !== item.id)
|
||||||
|
await restore(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purge(item: TrashItem) {
|
||||||
|
if (!confirm(`彻底删除“${item.path}”?此操作无法撤销。`)) return
|
||||||
|
items.value = items.value!.filter((i) => i.id !== item.id)
|
||||||
|
try {
|
||||||
|
await api.purge(item.id)
|
||||||
|
} catch {
|
||||||
|
toast('删除失败')
|
||||||
|
void load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rm-dialog-backdrop" @click.self="emit('close')">
|
||||||
|
<div class="rm-dialog" role="dialog" aria-label="回收站">
|
||||||
|
<div class="rm-dialog-head">
|
||||||
|
<h2>回收站</h2>
|
||||||
|
<button class="rm-icon-btn" title="关闭" @click="emit('close')"><Icon name="close" /></button>
|
||||||
|
</div>
|
||||||
|
<p v-if="items && !items.length" class="rm-tree-empty">回收站是空的</p>
|
||||||
|
<ul v-else class="rm-trash-list">
|
||||||
|
<li v-for="item in items ?? []" :key="item.id">
|
||||||
|
<Icon :name="item.dir ? 'folder' : 'note'" />
|
||||||
|
<div class="rm-trash-info">
|
||||||
|
<span class="rm-trash-path">{{ item.path.replace(/\.md$/, '') }}</span>
|
||||||
|
<span class="rm-trash-time">{{ fmt.format(item.deletedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="rm-btn" @click="doRestore(item)">恢复</button>
|
||||||
|
<button class="rm-btn rm-btn-danger" @click="purge(item)">彻底删除</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
import { basename, joinPath } from './paths'
|
||||||
|
import { expanded, type TreeNode } from './tree'
|
||||||
|
import { createFolder, createNote, move, openNote, remove, renameTarget, ws } from './workspace'
|
||||||
|
|
||||||
|
const props = defineProps<{ node: TreeNode; depth: number }>()
|
||||||
|
|
||||||
|
const isOpen = computed(() => expanded.has(props.node.path))
|
||||||
|
const isActive = computed(() => ws.active === props.node.path)
|
||||||
|
const isRenaming = computed(() => ws.renaming === props.node.path)
|
||||||
|
const menuOpen = ref(false)
|
||||||
|
const dropTarget = ref(false)
|
||||||
|
const input = ref<HTMLInputElement>()
|
||||||
|
const draft = ref('')
|
||||||
|
|
||||||
|
function activate() {
|
||||||
|
if (props.node.dir) {
|
||||||
|
if (isOpen.value) expanded.delete(props.node.path)
|
||||||
|
else expanded.add(props.node.path)
|
||||||
|
} else void openNote(props.node.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
isRenaming,
|
||||||
|
async (on) => {
|
||||||
|
if (!on) return
|
||||||
|
draft.value = props.node.dir ? props.node.name : props.node.name.replace(/\.md$/, '')
|
||||||
|
await nextTick()
|
||||||
|
input.value?.focus()
|
||||||
|
input.value?.select()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function commitRename() {
|
||||||
|
if (!isRenaming.value) return
|
||||||
|
ws.renaming = null
|
||||||
|
const target = renameTarget(props.node.path, draft.value)
|
||||||
|
if (target && target !== props.node.path) void move(props.node.path, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
function runMenu(action: 'note' | 'folder' | 'rename' | 'delete') {
|
||||||
|
menuOpen.value = false
|
||||||
|
const path = props.node.path
|
||||||
|
if (action === 'note' || action === 'folder') {
|
||||||
|
expanded.add(path)
|
||||||
|
void (action === 'note' ? createNote(path) : createFolder(path))
|
||||||
|
} else if (action === 'rename') ws.renaming = path
|
||||||
|
else void remove(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRename() {
|
||||||
|
ws.renaming = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 拖放移动(桌面端) ----
|
||||||
|
function onDragStart(e: DragEvent) {
|
||||||
|
e.dataTransfer?.setData('application/x-rawmark-path', props.node.path)
|
||||||
|
e.dataTransfer!.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e: DragEvent) {
|
||||||
|
if (!props.node.dir || !e.dataTransfer?.types.includes('application/x-rawmark-path')) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
dropTarget.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
dropTarget.value = false
|
||||||
|
const src = e.dataTransfer?.getData('application/x-rawmark-path')
|
||||||
|
if (!props.node.dir || !src) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const to = joinPath(props.node.path, basename(src))
|
||||||
|
if (src !== props.node.path && !props.node.path.startsWith(src + '/') && to !== src) void move(src, to)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li class="rm-tree-item">
|
||||||
|
<div
|
||||||
|
class="rm-tree-row"
|
||||||
|
:class="{ active: isActive, drop: dropTarget, 'menu-open': menuOpen }"
|
||||||
|
:style="{ paddingLeft: depth * 14 + 8 + 'px' }"
|
||||||
|
:draggable="!isRenaming"
|
||||||
|
@click="activate"
|
||||||
|
@contextmenu.prevent="menuOpen = true"
|
||||||
|
@dragstart="onDragStart"
|
||||||
|
@dragover="onDragOver"
|
||||||
|
@dragleave="dropTarget = false"
|
||||||
|
@drop="onDrop"
|
||||||
|
>
|
||||||
|
<span class="rm-tree-chevron" :class="{ open: isOpen, hidden: !node.dir }"><Icon name="chevron" :size="12" /></span>
|
||||||
|
<input
|
||||||
|
v-if="isRenaming"
|
||||||
|
ref="input"
|
||||||
|
v-model="draft"
|
||||||
|
class="rm-tree-input"
|
||||||
|
@click.stop
|
||||||
|
@keydown.enter.prevent="commitRename"
|
||||||
|
@keydown.esc.prevent="cancelRename"
|
||||||
|
@blur="commitRename"
|
||||||
|
/>
|
||||||
|
<span v-else class="rm-tree-name">{{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}</span>
|
||||||
|
<button class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen = !menuOpen">
|
||||||
|
<Icon name="more" />
|
||||||
|
</button>
|
||||||
|
<div v-if="menuOpen" class="rm-menu" @click.stop>
|
||||||
|
<template v-if="node.dir">
|
||||||
|
<button @click="runMenu('note')"><Icon name="noteAdd" />新建笔记</button>
|
||||||
|
<button @click="runMenu('folder')"><Icon name="folderAdd" />新建文件夹</button>
|
||||||
|
</template>
|
||||||
|
<button @click="runMenu('rename')"><Icon name="edit" />重命名</button>
|
||||||
|
<button class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="menuOpen" class="rm-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" />
|
||||||
|
</div>
|
||||||
|
<ul v-if="node.dir && isOpen" class="rm-tree-list">
|
||||||
|
<TreeNode v-for="child in node.children" :key="child.path" :node="child" :depth="depth + 1" />
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||||
|
import { api } from './api'
|
||||||
|
import EditorPane from './EditorPane.vue'
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
import NoteBanner from './NoteBanner.vue'
|
||||||
|
import { noteTitle } from './paths'
|
||||||
|
import Sidebar from './Sidebar.vue'
|
||||||
|
import TabBar from './TabBar.vue'
|
||||||
|
import { activeTab, flushAll, startWorkspace, stopWorkspace, toast, ws } from './workspace'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ logout: [] }>()
|
||||||
|
const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark'))
|
||||||
|
const saving = computed(() => activeTab()?.dirty)
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await flushAll()
|
||||||
|
try {
|
||||||
|
await api.logout()
|
||||||
|
} catch {
|
||||||
|
toast('退出失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stopWorkspace()
|
||||||
|
emit('logout')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端:从屏幕左边缘右滑打开侧栏
|
||||||
|
let touchX: number | null = null
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
touchX = e.touches[0].clientX < 24 ? e.touches[0].clientX : null
|
||||||
|
}
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
if (touchX !== null && e.touches[0].clientX - touchX > 60) {
|
||||||
|
ws.sidebarOpen = true
|
||||||
|
touchX = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => void startWorkspace())
|
||||||
|
onBeforeUnmount(stopWorkspace)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rm-shell" :class="{ 'sidebar-open': ws.sidebarOpen }" @touchstart.passive="onTouchStart" @touchmove.passive="onTouchMove">
|
||||||
|
<aside class="rm-sidebar"><Sidebar @logout="logout" /></aside>
|
||||||
|
<div class="rm-backdrop" @click="ws.sidebarOpen = false" />
|
||||||
|
<main class="rm-main">
|
||||||
|
<header class="rm-mobile-bar">
|
||||||
|
<button class="rm-icon-btn" title="目录" @click="ws.sidebarOpen = true"><Icon name="menu" :size="20" /></button>
|
||||||
|
<span class="rm-mobile-title">{{ title }}</span>
|
||||||
|
<span class="rm-save-state" :class="{ on: saving }" />
|
||||||
|
</header>
|
||||||
|
<TabBar />
|
||||||
|
<NoteBanner />
|
||||||
|
<EditorPane />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
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<string, string>; keepalive?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const headers: Record<string, string> = { ...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<string, unknown> = {}
|
||||||
|
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 <T>(p: Promise<Response>): Promise<T> => (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<string> {
|
||||||
|
const headers: Record<string, string> = { '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<TrashItem>(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<string> {
|
||||||
|
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<TrashItem>(request('POST', `/api/v1/trash/${id}/restore`)),
|
||||||
|
purge: (id: string) => request('DELETE', `/api/v1/trash/${id}`),
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// 未成功保存到服务器的内容暂存在 IndexedDB,刷新页面或断网都不会丢失
|
||||||
|
|
||||||
|
export interface Draft {
|
||||||
|
path: string
|
||||||
|
content: string
|
||||||
|
// 本地修改所基于的服务器版本;新建文件时为空字符串
|
||||||
|
baseRev: string
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBDatabase> | undefined
|
||||||
|
|
||||||
|
function db(): Promise<IDBDatabase> {
|
||||||
|
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<T>(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest<T>): Promise<T> {
|
||||||
|
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<Draft[]>('readonly', (s) => s.getAll()),
|
||||||
|
}
|
||||||
@@ -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<typeof setTimeout> | 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('/'))
|
||||||
|
}
|
||||||
@@ -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<string, TreeNode>()
|
||||||
|
// 先按路径排序,保证父目录先于子项出现
|
||||||
|
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<string>(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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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<string, EditorState>()
|
||||||
|
// 每个打开的笔记一个可变引用,编辑器扩展通过它取得笔记的当前路径(重命名后随之更新)
|
||||||
|
const noteRefs = new Map<string, { path: string }>()
|
||||||
|
|
||||||
|
interface SaveSlot {
|
||||||
|
timer?: ReturnType<typeof setTimeout>
|
||||||
|
inflight?: Promise<void>
|
||||||
|
// 修改序号与已保存序号,二者相等即没有待保存的修改
|
||||||
|
seq: number
|
||||||
|
savedSeq: number
|
||||||
|
uploads: number
|
||||||
|
paused: number
|
||||||
|
retries: number
|
||||||
|
}
|
||||||
|
const slots = new Map<string, SaveSlot>()
|
||||||
|
|
||||||
|
let view: EditorView | null = null
|
||||||
|
// 进行中的本地文件操作数;期间收到的目录事件不用于判断标签页是否被删除
|
||||||
|
let opsInFlight = 0
|
||||||
|
|
||||||
|
const externalChange = Annotation.define<boolean>()
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<string, unknown>[]) {
|
||||||
|
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<typeof setTimeout> | undefined
|
||||||
|
export function refreshTree(): Promise<void> {
|
||||||
|
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<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
+14
-7
@@ -1,21 +1,24 @@
|
|||||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
|
||||||
import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'
|
import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'
|
||||||
import { EditorState, Prec, type Extension } from '@codemirror/state'
|
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 { codeBlockHighlight } from './codeblocks'
|
||||||
import { livePreview, resolveImageUrl } from './livepreview'
|
import { livePreview, resolveImageUrl } from './livepreview'
|
||||||
import { tableEditing } from './table'
|
import { tableEditing } from './table'
|
||||||
import { markdownTheme } from './theme'
|
import { markdownTheme } from './theme'
|
||||||
|
|
||||||
export interface EditorOptions {
|
export { pasteImages } from './paste'
|
||||||
parent: HTMLElement
|
|
||||||
|
export interface EditorStateOptions {
|
||||||
doc: string
|
doc: string
|
||||||
resolveImage?: (src: string) => string
|
resolveImage?: (src: string) => string
|
||||||
onChange?: (state: EditorState) => void
|
// 文档变化回调,仅对挂载在视图上的状态触发;未激活标签页的状态由调用方直接更新
|
||||||
|
onChange?: (update: ViewUpdate) => void
|
||||||
extensions?: Extension[]
|
extensions?: Extension[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEditor(opts: EditorOptions): EditorView {
|
// 每个打开的笔记各自持有一个 EditorState(含撤销历史),切换标签页时整体替换
|
||||||
|
export function createEditorState(opts: EditorStateOptions): EditorState {
|
||||||
const extensions: Extension[] = [
|
const extensions: Extension[] = [
|
||||||
history(),
|
history(),
|
||||||
drawSelection(),
|
drawSelection(),
|
||||||
@@ -34,8 +37,12 @@ export function createEditor(opts: EditorOptions): EditorView {
|
|||||||
if (opts.resolveImage) extensions.push(resolveImageUrl.of(opts.resolveImage))
|
if (opts.resolveImage) extensions.push(resolveImageUrl.of(opts.resolveImage))
|
||||||
if (opts.onChange) {
|
if (opts.onChange) {
|
||||||
const onChange = 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)
|
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 })
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Extension } from '@codemirror/state'
|
||||||
|
import { EditorView } from '@codemirror/view'
|
||||||
|
|
||||||
|
export interface PasteImagesOptions {
|
||||||
|
// 上传文件,返回写入 Markdown 的链接
|
||||||
|
upload: (file: File) => Promise<string>
|
||||||
|
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: '' } })
|
||||||
|
}
|
||||||
+4
-9
@@ -1,11 +1,6 @@
|
|||||||
// M0 原型入口:只挂载编辑器,M1 替换为 Vue 外壳
|
import { createApp } from 'vue'
|
||||||
import { createEditor } from './editor'
|
import App from './app/App.vue'
|
||||||
import sample from './demo/sample.md?raw'
|
|
||||||
import './themes/base.css'
|
import './themes/base.css'
|
||||||
|
import './app/app.css'
|
||||||
|
|
||||||
// ?repeat=N 将示例重复 N 次,用于大文档性能测试
|
createApp(App).mount('#app')
|
||||||
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
|
|
||||||
+1
-1
@@ -12,5 +12,5 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["src", "vite.config.ts"]
|
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
build: {
|
||||||
|
rolldownOptions: {
|
||||||
|
// demo.html 为免登录的编辑器演示页,供自动化性能/交互测试使用
|
||||||
|
input: { index: 'index.html', demo: 'demo.html' },
|
||||||
|
},
|
||||||
|
},
|
||||||
worker: { format: 'es' },
|
worker: { format: 'es' },
|
||||||
|
server: {
|
||||||
|
// 开发时把 API 与 WebSocket 转发给本地 Go 服务
|
||||||
|
proxy: { '/api': { target: 'http://127.0.0.1:8080', ws: true } },
|
||||||
|
},
|
||||||
test: { environment: 'node' },
|
test: { environment: 'node' },
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user