Compare commits
8 Commits
c5a5a4e55b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 81a0484654 | |||
| 63d1f0b729 | |||
| abd9f2912f | |||
| 0f75a3a6af | |||
| 05898a14e0 | |||
| f725261370 | |||
| 3c6b142098 | |||
| 164e658e5b |
@@ -58,5 +58,23 @@
|
|||||||
"userTurns": 4,
|
"userTurns": 4,
|
||||||
"basisHash": "aaae120b1f33c2f2",
|
"basisHash": "aaae120b1f33c2f2",
|
||||||
"updatedAt": 1783674217586
|
"updatedAt": 1783674217586
|
||||||
|
},
|
||||||
|
"topic_20260710-090003_60717bd90da5ec68": {
|
||||||
|
"stage": 3,
|
||||||
|
"userTurns": 3,
|
||||||
|
"basisHash": "de6e56c00454cedd",
|
||||||
|
"updatedAt": 1784540365461
|
||||||
|
},
|
||||||
|
"topic_20260720-095943_8dd2c8de3f143692": {
|
||||||
|
"stage": 1,
|
||||||
|
"userTurns": 1,
|
||||||
|
"basisHash": "b2acebe8a17318f4",
|
||||||
|
"updatedAt": 1784541735771
|
||||||
|
},
|
||||||
|
"topic_20260722-163413_8ebd34b08091d260": {
|
||||||
|
"stage": 1,
|
||||||
|
"userTurns": 1,
|
||||||
|
"basisHash": "c61a582d55782664",
|
||||||
|
"updatedAt": 1784738151003
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,5 @@
|
|||||||
{
|
{
|
||||||
"topic_20260630-144952_fd0584fe8f266d6a": 1782830992641,
|
"topic_20260707-131941_481a70d2be9881dd": 1783430381000,
|
||||||
"topic_20260703-022850_50330b4eb93f116d": 1783045730616,
|
"topic_20260720-095943_8dd2c8de3f143692": 1784541583642,
|
||||||
"topic_20260706-145507_076a32df5f4276b5": 1783349707873,
|
"topic_20260722-163413_8ebd34b08091d260": 1784738053267
|
||||||
"topic_20260706-153317_238f65a7a470703c": 1783351997029,
|
|
||||||
"topic_20260710-024855_5c4b1833149d66bb": 1783651735446,
|
|
||||||
"topic_20260710-030936_cedad2907c3954fe": 1783652976310,
|
|
||||||
"topic_20260710-032907_600adc6b00791607": 1783654147796,
|
|
||||||
"topic_20260710-034033_6432ee6566f7b432": 1783654833341,
|
|
||||||
"topic_20260710-034724_3c6fb29d8d0a4119": 1783655244536,
|
|
||||||
"topic_20260710-054543_5e7c427932aecd7b": 1783662343716,
|
|
||||||
"topic_20260710-063053_353d579222d3b70b": 1783665053391,
|
|
||||||
"topic_20260710-090003_60717bd90da5ec68": 1783674003004
|
|
||||||
}
|
}
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"topic_20260707-093602_5c75f77654cab0c3": "manual",
|
"topic_20260707-093602_5c75f77654cab0c3": "manual",
|
||||||
"topic_20260707-094645_05255aaa45d5f673": "manual",
|
"topic_20260707-094645_05255aaa45d5f673": "manual",
|
||||||
"topic_20260707-095503_bf879b65e4666516": "auto",
|
"topic_20260707-095503_bf879b65e4666516": "auto",
|
||||||
|
"topic_20260707-131941_481a70d2be9881dd": "manual",
|
||||||
"topic_20260708-020616_9ab54d98daf65053": "auto",
|
"topic_20260708-020616_9ab54d98daf65053": "auto",
|
||||||
"topic_20260708-093419_71d1fea2e4b5a40f": "auto",
|
"topic_20260708-093419_71d1fea2e4b5a40f": "auto",
|
||||||
"topic_20260708-095428_990617acd4168b40": "auto",
|
"topic_20260708-095428_990617acd4168b40": "auto",
|
||||||
@@ -48,5 +49,7 @@
|
|||||||
"topic_20260710-034724_3c6fb29d8d0a4119": "auto",
|
"topic_20260710-034724_3c6fb29d8d0a4119": "auto",
|
||||||
"topic_20260710-054543_5e7c427932aecd7b": "auto",
|
"topic_20260710-054543_5e7c427932aecd7b": "auto",
|
||||||
"topic_20260710-063053_353d579222d3b70b": "auto",
|
"topic_20260710-063053_353d579222d3b70b": "auto",
|
||||||
"topic_20260710-090003_60717bd90da5ec68": "auto"
|
"topic_20260710-090003_60717bd90da5ec68": "auto",
|
||||||
|
"topic_20260720-095943_8dd2c8de3f143692": "auto",
|
||||||
|
"topic_20260722-163413_8ebd34b08091d260": "auto"
|
||||||
}
|
}
|
||||||
@@ -38,6 +38,8 @@
|
|||||||
"topic_20260707-093602_5c75f77654cab0c3": "隐藏单栏和双栏的切换功能,把mark…",
|
"topic_20260707-093602_5c75f77654cab0c3": "隐藏单栏和双栏的切换功能,把mark…",
|
||||||
"topic_20260707-094645_05255aaa45d5f673": "帮我移除markdownEditor…",
|
"topic_20260707-094645_05255aaa45d5f673": "帮我移除markdownEditor…",
|
||||||
"topic_20260707-095503_bf879b65e4666516": "帮我移除markdownEditor…",
|
"topic_20260707-095503_bf879b65e4666516": "帮我移除markdownEditor…",
|
||||||
|
"topic_20260707-131941_481a70d2be9881dd": "新的会话",
|
||||||
|
"topic_20260707-132824_49b096d569ffa3d3": "现在帮我把这个markdownEdi…",
|
||||||
"topic_20260708-020616_9ab54d98daf65053": "帮我分析一下现在的markdownE…",
|
"topic_20260708-020616_9ab54d98daf65053": "帮我分析一下现在的markdownE…",
|
||||||
"topic_20260708-093419_71d1fea2e4b5a40f": "src/components/Dir…",
|
"topic_20260708-093419_71d1fea2e4b5a40f": "src/components/Dir…",
|
||||||
"topic_20260708-095428_990617acd4168b40": "帮我检查一下这个应用在进行放大缩小的…",
|
"topic_20260708-095428_990617acd4168b40": "帮我检查一下这个应用在进行放大缩小的…",
|
||||||
@@ -48,5 +50,7 @@
|
|||||||
"topic_20260710-034724_3c6fb29d8d0a4119": "目录树中鼠标悬浮弹框dropmenu…",
|
"topic_20260710-034724_3c6fb29d8d0a4119": "目录树中鼠标悬浮弹框dropmenu…",
|
||||||
"topic_20260710-054543_5e7c427932aecd7b": "现在在markdown的编辑区域中…",
|
"topic_20260710-054543_5e7c427932aecd7b": "现在在markdown的编辑区域中…",
|
||||||
"topic_20260710-063053_353d579222d3b70b": "这个应用里有一个全局搜索的功能,帮我…",
|
"topic_20260710-063053_353d579222d3b70b": "这个应用里有一个全局搜索的功能,帮我…",
|
||||||
"topic_20260710-090003_60717bd90da5ec68": "新的会话"
|
"topic_20260710-090003_60717bd90da5ec68": "D:\\code\\yurou\\src…",
|
||||||
|
"topic_20260720-095943_8dd2c8de3f143692": "帮我调整一下项目的logo,用D…",
|
||||||
|
"topic_20260722-163413_8ebd34b08091d260": "markdownEditor底部区域…"
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ This template should help get you started developing with Vue 3 and TypeScript i
|
|||||||
## Recommended IDE Setup
|
## Recommended IDE Setup
|
||||||
|
|
||||||
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
$env:HTTPS_PROXY="http://127.0.0.1:10808"
|
$env:HTTPS_PROXY="http://127.0.0.1:10808"
|
||||||
|
|
||||||
|
hello
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/png" href="/yurou-logo.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Markdown Editor</title>
|
<title>Markdown Editor</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
953
package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"@codemirror/search": "^6.7.1",
|
"@codemirror/search": "^6.7.1",
|
||||||
"@codemirror/state": "^6.7.1",
|
"@codemirror/state": "^6.7.1",
|
||||||
"@codemirror/view": "^6.43.6",
|
"@codemirror/view": "^6.43.6",
|
||||||
|
"@fancyapps/ui": "^6.1.14",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@lucide/vue": "^1.23.0",
|
"@lucide/vue": "^1.23.0",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
"rehype-stringify": "^10.0.1",
|
"rehype-stringify": "^10.0.1",
|
||||||
"reka-ui": "^2.10.1",
|
"reka-ui": "^2.10.1",
|
||||||
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"remark-rehype": "^11.1.2",
|
"remark-rehype": "^11.1.2",
|
||||||
|
|||||||
43
pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
|||||||
'@codemirror/view':
|
'@codemirror/view':
|
||||||
specifier: ^6.43.6
|
specifier: ^6.43.6
|
||||||
version: 6.43.6
|
version: 6.43.6
|
||||||
|
'@fancyapps/ui':
|
||||||
|
specifier: ^6.1.14
|
||||||
|
version: 6.1.14
|
||||||
'@lezer/highlight':
|
'@lezer/highlight':
|
||||||
specifier: ^1.2.3
|
specifier: ^1.2.3
|
||||||
version: 1.2.3
|
version: 1.2.3
|
||||||
@@ -68,6 +71,9 @@ importers:
|
|||||||
reka-ui:
|
reka-ui:
|
||||||
specifier: ^2.10.1
|
specifier: ^2.10.1
|
||||||
version: 2.10.1(vue@3.5.39(typescript@5.6.3))
|
version: 2.10.1(vue@3.5.39(typescript@5.6.3))
|
||||||
|
remark-breaks:
|
||||||
|
specifier: ^4.0.0
|
||||||
|
version: 4.0.0
|
||||||
remark-gfm:
|
remark-gfm:
|
||||||
specifier: ^4.0.1
|
specifier: ^4.0.1
|
||||||
version: 4.0.1
|
version: 4.0.1
|
||||||
@@ -540,6 +546,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
|
resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
|
||||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||||
|
|
||||||
|
'@fancyapps/ui@6.1.14':
|
||||||
|
resolution: {integrity: sha512-cBV73XXHrc0qMca+PJJ6qay3xweIgsoYse13rGRstgUs0xA5WAv1tgbSzPCIOOjiLs2S2jiVFJxyszgvgyfxMg==}
|
||||||
|
|
||||||
'@floating-ui/core@1.7.5':
|
'@floating-ui/core@1.7.5':
|
||||||
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
|
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
|
||||||
|
|
||||||
@@ -1838,6 +1847,7 @@ packages:
|
|||||||
glob@11.0.0:
|
glob@11.0.0:
|
||||||
resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==}
|
resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
glob@7.2.0:
|
glob@7.2.0:
|
||||||
@@ -2211,6 +2221,9 @@ packages:
|
|||||||
mdast-util-gfm@3.1.0:
|
mdast-util-gfm@3.1.0:
|
||||||
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
|
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
|
||||||
|
|
||||||
|
mdast-util-newline-to-break@2.0.0:
|
||||||
|
resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==}
|
||||||
|
|
||||||
mdast-util-phrasing@4.1.0:
|
mdast-util-phrasing@4.1.0:
|
||||||
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
|
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
|
||||||
|
|
||||||
@@ -2650,6 +2663,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: '>= 3.4.0'
|
vue: '>= 3.4.0'
|
||||||
|
|
||||||
|
remark-breaks@4.0.0:
|
||||||
|
resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
|
||||||
|
|
||||||
remark-gfm@4.0.1:
|
remark-gfm@4.0.1:
|
||||||
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
|
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
|
||||||
|
|
||||||
@@ -2804,10 +2820,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==}
|
resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
string-width@4.2.0:
|
|
||||||
resolution: {integrity: sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==}
|
|
||||||
engines: {node: '>=8'}
|
|
||||||
|
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -3689,6 +3701,8 @@ snapshots:
|
|||||||
'@eslint/core': 1.2.1
|
'@eslint/core': 1.2.1
|
||||||
levn: 0.4.1
|
levn: 0.4.1
|
||||||
|
|
||||||
|
'@fancyapps/ui@6.1.14': {}
|
||||||
|
|
||||||
'@floating-ui/core@1.7.5':
|
'@floating-ui/core@1.7.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/utils': 0.2.11
|
'@floating-ui/utils': 0.2.11
|
||||||
@@ -3740,7 +3754,7 @@ snapshots:
|
|||||||
'@isaacs/cliui@8.0.2':
|
'@isaacs/cliui@8.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 5.1.2
|
string-width: 5.1.2
|
||||||
string-width-cjs: string-width@4.2.0
|
string-width-cjs: string-width@4.2.3
|
||||||
strip-ansi: 7.2.0
|
strip-ansi: 7.2.0
|
||||||
strip-ansi-cjs: strip-ansi@6.0.1
|
strip-ansi-cjs: strip-ansi@6.0.1
|
||||||
wrap-ansi: 8.1.0
|
wrap-ansi: 8.1.0
|
||||||
@@ -5352,6 +5366,11 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
mdast-util-newline-to-break@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/mdast': 4.0.4
|
||||||
|
mdast-util-find-and-replace: 3.0.2
|
||||||
|
|
||||||
mdast-util-phrasing@4.1.0:
|
mdast-util-phrasing@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
@@ -5911,6 +5930,12 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
|
|
||||||
|
remark-breaks@4.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/mdast': 4.0.4
|
||||||
|
mdast-util-newline-to-break: 2.0.0
|
||||||
|
unified: 11.0.0
|
||||||
|
|
||||||
remark-gfm@4.0.1:
|
remark-gfm@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
@@ -6166,12 +6191,6 @@ snapshots:
|
|||||||
|
|
||||||
stdin-discarder@0.3.2: {}
|
stdin-discarder@0.3.2: {}
|
||||||
|
|
||||||
string-width@4.2.0:
|
|
||||||
dependencies:
|
|
||||||
emoji-regex: 8.0.0
|
|
||||||
is-fullwidth-code-point: 3.0.0
|
|
||||||
strip-ansi: 6.0.1
|
|
||||||
|
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
emoji-regex: 8.0.0
|
emoji-regex: 8.0.0
|
||||||
@@ -6450,7 +6469,7 @@ snapshots:
|
|||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
string-width: 4.2.0
|
string-width: 4.2.3
|
||||||
strip-ansi: 6.0.1
|
strip-ansi: 6.0.1
|
||||||
|
|
||||||
wrap-ansi@8.1.0:
|
wrap-ansi@8.1.0:
|
||||||
|
|||||||
BIN
public/yurou-logo.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
@@ -8,6 +8,7 @@
|
|||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-destroy",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"dialog:default"
|
"dialog:default"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 131 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
BIN
src-tauri/icons/file-icon.ico
Normal file
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 181 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 690 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 618 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
53
src-tauri/nsis-hooks.nsh
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
; NSIS installer hooks for yurou
|
||||||
|
; Override file association DefaultIcon to use custom file icon (yurou-sys.png → file-icon.ico)
|
||||||
|
|
||||||
|
!macro NSIS_HOOK_POSTINSTALL
|
||||||
|
; Read the file class (ProgID) registered by Tauri's APP_ASSOCIATE,
|
||||||
|
; then overwrite its DefaultIcon to point to our custom file-icon.ico
|
||||||
|
; instead of the application exe icon.
|
||||||
|
;
|
||||||
|
; This repeats for every extension we registered.
|
||||||
|
; If the extension was not associated (backup value is empty) we skip it.
|
||||||
|
|
||||||
|
!define YUROU_FILE_ICON '"$INSTDIR\file-icon.ico"'
|
||||||
|
|
||||||
|
; .md
|
||||||
|
ReadRegStr $0 SHCTX "Software\Classes\.md" ""
|
||||||
|
StrCmp $0 "" +2 +1
|
||||||
|
WriteRegStr SHCTX "Software\Classes\$0\DefaultIcon" "" ${YUROU_FILE_ICON}
|
||||||
|
|
||||||
|
; .markdown
|
||||||
|
ReadRegStr $0 SHCTX "Software\Classes\.markdown" ""
|
||||||
|
StrCmp $0 "" +2 +1
|
||||||
|
WriteRegStr SHCTX "Software\Classes\$0\DefaultIcon" "" ${YUROU_FILE_ICON}
|
||||||
|
|
||||||
|
; .mdown
|
||||||
|
ReadRegStr $0 SHCTX "Software\Classes\.mdown" ""
|
||||||
|
StrCmp $0 "" +2 +1
|
||||||
|
WriteRegStr SHCTX "Software\Classes\$0\DefaultIcon" "" ${YUROU_FILE_ICON}
|
||||||
|
|
||||||
|
; .mkdn
|
||||||
|
ReadRegStr $0 SHCTX "Software\Classes\.mkdn" ""
|
||||||
|
StrCmp $0 "" +2 +1
|
||||||
|
WriteRegStr SHCTX "Software\Classes\$0\DefaultIcon" "" ${YUROU_FILE_ICON}
|
||||||
|
|
||||||
|
; .mkd
|
||||||
|
ReadRegStr $0 SHCTX "Software\Classes\.mkd" ""
|
||||||
|
StrCmp $0 "" +2 +1
|
||||||
|
WriteRegStr SHCTX "Software\Classes\$0\DefaultIcon" "" ${YUROU_FILE_ICON}
|
||||||
|
|
||||||
|
; .mdwn
|
||||||
|
ReadRegStr $0 SHCTX "Software\Classes\.mdwn" ""
|
||||||
|
StrCmp $0 "" +2 +1
|
||||||
|
WriteRegStr SHCTX "Software\Classes\$0\DefaultIcon" "" ${YUROU_FILE_ICON}
|
||||||
|
|
||||||
|
!undef YUROU_FILE_ICON
|
||||||
|
|
||||||
|
; Notify Windows shell about the association change
|
||||||
|
System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)"
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro NSIS_HOOK_PREUNINSTALL
|
||||||
|
; Cleanup is handled automatically by APP_UNASSOCIATE which deletes the
|
||||||
|
; entire ProgID key — our custom DefaultIcon is removed along with it.
|
||||||
|
!macroend
|
||||||
@@ -552,12 +552,37 @@ struct SyncStats {
|
|||||||
skipped: usize,
|
skipped: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
enum SyncFileAction {
|
||||||
|
Upload,
|
||||||
|
Download,
|
||||||
|
DeleteLocal,
|
||||||
|
DeleteRemote,
|
||||||
|
Skip,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SyncFileDetail {
|
||||||
|
path: String,
|
||||||
|
action: SyncFileAction,
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct SyncResult {
|
struct SyncResult {
|
||||||
message: String,
|
message: String,
|
||||||
last_sync_at: String,
|
last_sync_at: String,
|
||||||
stats: SyncStats,
|
stats: SyncStats,
|
||||||
|
files: Vec<SyncFileDetail>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SyncConfigSaveResult {
|
||||||
|
config_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
@@ -1173,6 +1198,7 @@ async fn sync_workspace(
|
|||||||
let remote_files = scan_remote_files(&op).await?;
|
let remote_files = scan_remote_files(&op).await?;
|
||||||
let mut history = load_sync_history(app, &key)?;
|
let mut history = load_sync_history(app, &key)?;
|
||||||
let mut stats = SyncStats::default();
|
let mut stats = SyncStats::default();
|
||||||
|
let mut file_details = Vec::new();
|
||||||
|
|
||||||
let all_paths: BTreeSet<String> = local_files
|
let all_paths: BTreeSet<String> = local_files
|
||||||
.keys()
|
.keys()
|
||||||
@@ -1199,6 +1225,11 @@ async fn sync_workspace(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
stats.uploaded += 1;
|
stats.uploaded += 1;
|
||||||
|
file_details.push(SyncFileDetail {
|
||||||
|
path: relative.clone(),
|
||||||
|
action: SyncFileAction::Upload,
|
||||||
|
size: local.map(|item| item.size).unwrap_or(0),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
SyncAction::Download => {
|
SyncAction::Download => {
|
||||||
let local_path = local_path_for(workspace, &relative);
|
let local_path = local_path_for(workspace, &relative);
|
||||||
@@ -1216,6 +1247,11 @@ async fn sync_workspace(
|
|||||||
fs::write(&local_path, bytes)
|
fs::write(&local_path, bytes)
|
||||||
.map_err(|e| format!("无法写入本地文件 {}: {}", local_path.display(), e))?;
|
.map_err(|e| format!("无法写入本地文件 {}: {}", local_path.display(), e))?;
|
||||||
stats.downloaded += 1;
|
stats.downloaded += 1;
|
||||||
|
file_details.push(SyncFileDetail {
|
||||||
|
path: relative.clone(),
|
||||||
|
action: SyncFileAction::Download,
|
||||||
|
size: remote.map(|item| item.size).unwrap_or(0),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
SyncAction::DeleteLocal => {
|
SyncAction::DeleteLocal => {
|
||||||
let local_path = local_path_for(workspace, &relative);
|
let local_path = local_path_for(workspace, &relative);
|
||||||
@@ -1225,6 +1261,11 @@ async fn sync_workspace(
|
|||||||
}
|
}
|
||||||
history.files.remove(&relative);
|
history.files.remove(&relative);
|
||||||
stats.deleted_local += 1;
|
stats.deleted_local += 1;
|
||||||
|
file_details.push(SyncFileDetail {
|
||||||
|
path: relative.clone(),
|
||||||
|
action: SyncFileAction::DeleteLocal,
|
||||||
|
size: local.map(|item| item.size).unwrap_or(0),
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
SyncAction::DeleteRemote => {
|
SyncAction::DeleteRemote => {
|
||||||
@@ -1236,10 +1277,23 @@ async fn sync_workspace(
|
|||||||
.await?;
|
.await?;
|
||||||
history.files.remove(&relative);
|
history.files.remove(&relative);
|
||||||
stats.deleted_remote += 1;
|
stats.deleted_remote += 1;
|
||||||
|
file_details.push(SyncFileDetail {
|
||||||
|
path: relative.clone(),
|
||||||
|
action: SyncFileAction::DeleteRemote,
|
||||||
|
size: remote.map(|item| item.size).unwrap_or(0),
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
SyncAction::Skip => {
|
SyncAction::Skip => {
|
||||||
stats.skipped += 1;
|
stats.skipped += 1;
|
||||||
|
file_details.push(SyncFileDetail {
|
||||||
|
path: relative.clone(),
|
||||||
|
action: SyncFileAction::Skip,
|
||||||
|
size: local
|
||||||
|
.map(|item| item.size)
|
||||||
|
.or_else(|| remote.map(|item| item.size))
|
||||||
|
.unwrap_or(0),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1282,6 +1336,7 @@ async fn sync_workspace(
|
|||||||
message: "同步完成".to_string(),
|
message: "同步完成".to_string(),
|
||||||
last_sync_at,
|
last_sync_at,
|
||||||
stats,
|
stats,
|
||||||
|
files: file_details,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1291,8 +1346,13 @@ fn load_sync_config(app: tauri::AppHandle) -> Result<SyncConfig, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn save_sync_config(app: tauri::AppHandle, config: SyncConfig) -> Result<(), String> {
|
fn save_sync_config(
|
||||||
save_sync_config_to_disk(&app, &config)
|
app: tauri::AppHandle,
|
||||||
|
config: SyncConfig,
|
||||||
|
) -> Result<SyncConfigSaveResult, String> {
|
||||||
|
save_sync_config_to_disk(&app, &config)?;
|
||||||
|
let config_path = sync_config_path(&app)?.to_string_lossy().into_owned();
|
||||||
|
Ok(SyncConfigSaveResult { config_path })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1430,7 +1490,7 @@ pub fn run() {
|
|||||||
let id = event.id().0.as_str();
|
let id = event.id().0.as_str();
|
||||||
match id {
|
match id {
|
||||||
"quit" => {
|
"quit" => {
|
||||||
std::process::exit(0);
|
let _ = app_handle.emit("menu-quit", ());
|
||||||
}
|
}
|
||||||
"open" => {
|
"open" => {
|
||||||
let handle = app_handle.clone();
|
let handle = app_handle.clone();
|
||||||
|
|||||||
@@ -34,10 +34,28 @@
|
|||||||
"targets": ["nsis"],
|
"targets": ["nsis"],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
|
"icons/64x64.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
]
|
],
|
||||||
|
"resources": {
|
||||||
|
"icons/file-icon.ico": "file-icon.ico"
|
||||||
|
},
|
||||||
|
"fileAssociations": [
|
||||||
|
{
|
||||||
|
"ext": ["md", "markdown", "mdown", "mkdn", "mkd", "mdwn"],
|
||||||
|
"name": "Markdown",
|
||||||
|
"description": "Markdown Document",
|
||||||
|
"role": "Editor",
|
||||||
|
"mimeType": "text/markdown"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"windows": {
|
||||||
|
"nsis": {
|
||||||
|
"installerHooks": "nsis-hooks.nsh"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
365
src/App.vue
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref, watch, onMounted, onUnmounted, nextTick} from "vue";
|
import {computed, ref, onMounted, onUnmounted, nextTick} from "vue";
|
||||||
import {listen, type EventCallback, type EventName} from "@tauri-apps/api/event";
|
import {listen, type EventCallback, type EventName} from "@tauri-apps/api/event";
|
||||||
import {convertFileSrc, invoke, isTauri} from "@tauri-apps/api/core";
|
import {convertFileSrc, invoke, isTauri} from "@tauri-apps/api/core";
|
||||||
import {getCurrentWindow} from "@tauri-apps/api/window";
|
import {getCurrentWindow, type CloseRequestedEvent} from "@tauri-apps/api/window";
|
||||||
import MarkdownEditor from "./components/MarkdownEditor.vue";
|
import MarkdownEditor from "./components/MarkdownEditor.vue";
|
||||||
import DirectorySidebar from "./components/DirectorySidebar.vue";
|
import DirectorySidebar from "./components/DirectorySidebar.vue";
|
||||||
import OutlinePanel from "./components/OutlinePanel.vue";
|
import OutlinePanel from "./components/OutlinePanel.vue";
|
||||||
@@ -17,6 +17,8 @@ type WorkspaceFileType = "markdown" | "image" | "video";
|
|||||||
type SyncProvider = "none" | "webdav" | "s3";
|
type SyncProvider = "none" | "webdav" | "s3";
|
||||||
const DEFAULT_DOCUMENT_MODE_KEY = "yurou:default-document-mode";
|
const DEFAULT_DOCUMENT_MODE_KEY = "yurou:default-document-mode";
|
||||||
const APP_THEME_KEY = "yurou:app-theme";
|
const APP_THEME_KEY = "yurou:app-theme";
|
||||||
|
const RENDER_SINGLE_LINE_BREAKS_KEY = "yurou:render-single-line-breaks";
|
||||||
|
const PARAGRAPH_INDENT_KEY = "yurou:paragraph-indent";
|
||||||
const MARKDOWN_EXTENSIONS = new Set(["md"]);
|
const MARKDOWN_EXTENSIONS = new Set(["md"]);
|
||||||
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif"]);
|
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif"]);
|
||||||
const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]);
|
const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]);
|
||||||
@@ -79,7 +81,10 @@ const mediaPreviewSrc = ref("");
|
|||||||
const mediaPreviewError = ref("");
|
const mediaPreviewError = ref("");
|
||||||
const mediaPreviewKey = ref(0);
|
const mediaPreviewKey = ref(0);
|
||||||
const saveStatus = ref("");
|
const saveStatus = ref("");
|
||||||
|
const saveNotice = ref("");
|
||||||
|
const saveNoticeType = ref<"success" | "error">("success");
|
||||||
const hasDraftDocument = ref(false);
|
const hasDraftDocument = ref(false);
|
||||||
|
let lastSavedContent = "";
|
||||||
|
|
||||||
// Folder state
|
// Folder state
|
||||||
interface DirEntry {
|
interface DirEntry {
|
||||||
@@ -105,6 +110,36 @@ interface WorkspaceWatchErrorPayload {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadRenderSingleLineBreaks() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(RENDER_SINGLE_LINE_BREAKS_KEY) === "true";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRenderSingleLineBreaks(value: boolean) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(RENDER_SINGLE_LINE_BREAKS_KEY, String(value));
|
||||||
|
} catch { /* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadParagraphIndent() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(PARAGRAPH_INDENT_KEY) === "true";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveParagraphIndent(value: boolean) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PARAGRAPH_INDENT_KEY, String(value));
|
||||||
|
} catch { /* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface SyncConfig {
|
interface SyncConfig {
|
||||||
provider: SyncProvider;
|
provider: SyncProvider;
|
||||||
webdav: {
|
webdav: {
|
||||||
@@ -151,7 +186,10 @@ const WORKSPACE_REFRESH_DELAY_MS = 140;
|
|||||||
let workspaceRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
let workspaceRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let workspaceRefreshInFlight = false;
|
let workspaceRefreshInFlight = false;
|
||||||
let workspaceRefreshQueued = false;
|
let workspaceRefreshQueued = false;
|
||||||
let syncInFlight = false;
|
const syncInFlight = ref(false);
|
||||||
|
let fileOpenRequest = 0;
|
||||||
|
let saveNoticeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let closeSaveInFlight = false;
|
||||||
|
|
||||||
// ---- editor zoom (Ctrl+scroll) ----
|
// ---- editor zoom (Ctrl+scroll) ----
|
||||||
const ZOOM_MIN = 0.7;
|
const ZOOM_MIN = 0.7;
|
||||||
@@ -207,7 +245,8 @@ const showOutline = ref(false);
|
|||||||
const defaultDocumentMode = ref<DocumentMode>(loadDefaultDocumentMode());
|
const defaultDocumentMode = ref<DocumentMode>(loadDefaultDocumentMode());
|
||||||
const documentMode = ref<DocumentMode>(defaultDocumentMode.value);
|
const documentMode = ref<DocumentMode>(defaultDocumentMode.value);
|
||||||
const appTheme = ref<AppTheme>(loadAppTheme());
|
const appTheme = ref<AppTheme>(loadAppTheme());
|
||||||
const showMoreMenu = ref(false);
|
const renderSingleLineBreaks = ref(loadRenderSingleLineBreaks());
|
||||||
|
const paragraphIndent = ref(loadParagraphIndent());
|
||||||
const settingsRequest = ref(0);
|
const settingsRequest = ref(0);
|
||||||
const settingsInitialPage = ref<"about" | "editor" | "sync">("about");
|
const settingsInitialPage = ref<"about" | "editor" | "sync">("about");
|
||||||
const markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined; scrollToLine: (line: number) => void } | null>(null);
|
const markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined; scrollToLine: (line: number) => void } | null>(null);
|
||||||
@@ -230,6 +269,7 @@ type TitlebarDragState = {
|
|||||||
const TITLEBAR_DRAG_THRESHOLD = 4;
|
const TITLEBAR_DRAG_THRESHOLD = 4;
|
||||||
let titlebarDragState: TitlebarDragState | null = null;
|
let titlebarDragState: TitlebarDragState | null = null;
|
||||||
let unlistenWindowResize: (() => void) | null = null;
|
let unlistenWindowResize: (() => void) | null = null;
|
||||||
|
let unlistenWindowCloseRequested: (() => void) | null = null;
|
||||||
|
|
||||||
function isInteractiveTitlebarTarget(target: EventTarget | null) {
|
function isInteractiveTitlebarTarget(target: EventTarget | null) {
|
||||||
if (!(target instanceof Element)) return false;
|
if (!(target instanceof Element)) return false;
|
||||||
@@ -321,6 +361,26 @@ function openSettings(page: "about" | "editor" | "sync" = "about") {
|
|||||||
settingsRequest.value += 1;
|
settingsRequest.value += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onWindowCloseRequested(event: CloseRequestedEvent) {
|
||||||
|
if (closeSaveInFlight) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeSaveInFlight = true;
|
||||||
|
const shouldShowClosingSave = isCurrentMarkdownDocumentDirty();
|
||||||
|
const saved = await saveCurrentWorkspaceDocumentBeforeSwitch();
|
||||||
|
if (!saved) {
|
||||||
|
closeSaveInFlight = false;
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldShowClosingSave) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setDocumentMode(mode: DocumentMode) {
|
function setDocumentMode(mode: DocumentMode) {
|
||||||
documentMode.value = mode;
|
documentMode.value = mode;
|
||||||
}
|
}
|
||||||
@@ -337,23 +397,6 @@ function updateAppTheme(theme: AppTheme) {
|
|||||||
saveAppTheme(theme);
|
saveAppTheme(theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleExportPdf() {
|
|
||||||
showMoreMenu.value = false;
|
|
||||||
// TODO: implement PDF export
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMoreMenu() {
|
|
||||||
showMoreMenu.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(showMoreMenu, (val) => {
|
|
||||||
if (val) {
|
|
||||||
setTimeout(() => {
|
|
||||||
document.addEventListener("click", closeMoreMenu, {once: true});
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
applyAppTheme(appTheme.value);
|
applyAppTheme(appTheme.value);
|
||||||
applyZoomCssVars();
|
applyZoomCssVars();
|
||||||
@@ -367,6 +410,11 @@ onMounted(() => {
|
|||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error("Failed to listen for window resize:", error);
|
console.error("Failed to listen for window resize:", error);
|
||||||
});
|
});
|
||||||
|
void getCurrentWindow().onCloseRequested(onWindowCloseRequested).then((unlisten) => {
|
||||||
|
unlistenWindowCloseRequested = unlisten;
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error("Failed to listen for window close requests:", error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
void restoreLastWorkspace();
|
void restoreLastWorkspace();
|
||||||
});
|
});
|
||||||
@@ -378,7 +426,12 @@ onUnmounted(() => {
|
|||||||
unlistenWindowResize();
|
unlistenWindowResize();
|
||||||
unlistenWindowResize = null;
|
unlistenWindowResize = null;
|
||||||
}
|
}
|
||||||
|
if (unlistenWindowCloseRequested) {
|
||||||
|
unlistenWindowCloseRequested();
|
||||||
|
unlistenWindowCloseRequested = null;
|
||||||
|
}
|
||||||
if (zoomIndicatorTimer) clearTimeout(zoomIndicatorTimer);
|
if (zoomIndicatorTimer) clearTimeout(zoomIndicatorTimer);
|
||||||
|
if (saveNoticeTimer) clearTimeout(saveNoticeTimer);
|
||||||
});
|
});
|
||||||
|
|
||||||
function stripMarkdown(md: string): string {
|
function stripMarkdown(md: string): string {
|
||||||
@@ -422,7 +475,9 @@ function setDocumentContent(md: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetMarkdownEditorState() {
|
function resetMarkdownEditorState() {
|
||||||
|
fileOpenRequest += 1;
|
||||||
setDocumentContent("");
|
setDocumentContent("");
|
||||||
|
lastSavedContent = "";
|
||||||
currentFilePath.value = null;
|
currentFilePath.value = null;
|
||||||
currentFileType.value = "markdown";
|
currentFileType.value = "markdown";
|
||||||
resetMediaPreviewState();
|
resetMediaPreviewState();
|
||||||
@@ -430,7 +485,6 @@ function resetMarkdownEditorState() {
|
|||||||
saveStatus.value = "";
|
saveStatus.value = "";
|
||||||
showOutline.value = false;
|
showOutline.value = false;
|
||||||
documentMode.value = defaultDocumentMode.value;
|
documentMode.value = defaultDocumentMode.value;
|
||||||
showMoreMenu.value = false;
|
|
||||||
editorResetKey.value += 1;
|
editorResetKey.value += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,8 +655,19 @@ async function saveCurrentWorkspaceDocumentBeforeSwitch() {
|
|||||||
|
|
||||||
const contentToSave = getLatestEditorContent();
|
const contentToSave = getLatestEditorContent();
|
||||||
if (!currentFilePath.value && !contentToSave.trim()) return true;
|
if (!currentFilePath.value && !contentToSave.trim()) return true;
|
||||||
|
if (contentToSave === lastSavedContent) return true;
|
||||||
|
|
||||||
return doSave(contentToSave, { syncAfterSave: false });
|
return doSave(contentToSave, { syncAfterSave: false, trigger: "automatic" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRenderSingleLineBreaks(value: boolean) {
|
||||||
|
renderSingleLineBreaks.value = value;
|
||||||
|
saveRenderSingleLineBreaks(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateParagraphIndent(value: boolean) {
|
||||||
|
paragraphIndent.value = value;
|
||||||
|
saveParagraphIndent(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetWorkspaceAndEditorState(options: { stopWatcher?: boolean } = {}) {
|
function resetWorkspaceAndEditorState(options: { stopWatcher?: boolean } = {}) {
|
||||||
@@ -696,6 +761,7 @@ async function refreshCurrentOpenFileFromDisk() {
|
|||||||
if (isMarkdownDocument.value) {
|
if (isMarkdownDocument.value) {
|
||||||
const fileContent = await invoke<string>("read_file", { path: currentFilePath.value });
|
const fileContent = await invoke<string>("read_file", { path: currentFilePath.value });
|
||||||
setDocumentContent(fileContent);
|
setDocumentContent(fileContent);
|
||||||
|
lastSavedContent = fileContent;
|
||||||
hasDraftDocument.value = false;
|
hasDraftDocument.value = false;
|
||||||
editorResetKey.value += 1;
|
editorResetKey.value += 1;
|
||||||
return;
|
return;
|
||||||
@@ -713,36 +779,41 @@ async function refreshCurrentOpenFileFromDisk() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runConfiguredSync(trigger: "startup" | "save" | "manual") {
|
async function runConfiguredSync(trigger: "startup" | "save" | "manual") {
|
||||||
if (!folderPath.value || syncInFlight) return false;
|
if (!folderPath.value || syncInFlight.value) return false;
|
||||||
|
|
||||||
const config = await loadSyncConfig();
|
const config = await loadSyncConfig();
|
||||||
if (trigger !== "manual" && !shouldRunSync(config, trigger)) return false;
|
if (trigger !== "manual" && !shouldRunSync(config, trigger)) return false;
|
||||||
if (!config || config.provider === "none") return false;
|
if (!config || config.provider === "none") {
|
||||||
|
if (trigger === "manual") {
|
||||||
|
showSaveNotice("请先在设置中配置同步方式", "error");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (trigger !== "save") {
|
if (trigger !== "save") {
|
||||||
const saved = await saveCurrentWorkspaceDocumentBeforeSwitch();
|
const saved = await saveCurrentWorkspaceDocumentBeforeSwitch();
|
||||||
if (!saved) return false;
|
if (!saved) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
syncInFlight = true;
|
syncInFlight.value = true;
|
||||||
|
showSaveNotice("正在同步...", "success");
|
||||||
try {
|
try {
|
||||||
const result = await invoke<SyncResult>("sync_now", {
|
const result = await invoke<SyncResult>("sync_now", {
|
||||||
workspace: folderPath.value,
|
workspace: folderPath.value,
|
||||||
config,
|
config,
|
||||||
});
|
});
|
||||||
saveStatus.value = describeSyncResult(result);
|
saveStatus.value = describeSyncResult(result);
|
||||||
setTimeout(() => {
|
showSaveNotice(describeSyncResult(result), "success");
|
||||||
saveStatus.value = "";
|
|
||||||
}, 3600);
|
|
||||||
await refreshWorkspaceTree();
|
await refreshWorkspaceTree();
|
||||||
await refreshCurrentOpenFileFromDisk();
|
await refreshCurrentOpenFileFromDisk();
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Sync failed:", e);
|
console.error("Sync failed:", e);
|
||||||
saveStatus.value = `同步失败: ${e}`;
|
saveStatus.value = `同步失败: ${e}`;
|
||||||
|
showSaveNotice(`同步失败: ${e}`, "error");
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
syncInFlight = false;
|
syncInFlight.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,10 +897,10 @@ function openMediaPreview(path: string, fileType: Extract<WorkspaceFileType, "im
|
|||||||
mediaPreviewError.value = "";
|
mediaPreviewError.value = "";
|
||||||
mediaPreviewKey.value += 1;
|
mediaPreviewKey.value += 1;
|
||||||
setDocumentContent("");
|
setDocumentContent("");
|
||||||
|
lastSavedContent = "";
|
||||||
hasDraftDocument.value = false;
|
hasDraftDocument.value = false;
|
||||||
saveStatus.value = "";
|
saveStatus.value = "";
|
||||||
showOutline.value = false;
|
showOutline.value = false;
|
||||||
showMoreMenu.value = false;
|
|
||||||
editorResetKey.value += 1;
|
editorResetKey.value += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -984,6 +1055,15 @@ async function onRenameEntry(path: string, newName: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onOpenFile(path: string) {
|
async function onOpenFile(path: string) {
|
||||||
|
const request = ++fileOpenRequest;
|
||||||
|
|
||||||
|
if (currentFilePath.value && getWorkspaceIdentity(currentFilePath.value) === getWorkspaceIdentity(path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await saveCurrentWorkspaceDocumentBeforeSwitch();
|
||||||
|
if (!saved || request !== fileOpenRequest) return;
|
||||||
|
|
||||||
const fileType = getWorkspaceFileType(path);
|
const fileType = getWorkspaceFileType(path);
|
||||||
if (fileType === "image" || fileType === "video") {
|
if (fileType === "image" || fileType === "video") {
|
||||||
openMediaPreview(path, fileType);
|
openMediaPreview(path, fileType);
|
||||||
@@ -992,9 +1072,11 @@ async function onOpenFile(path: string) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const fileContent = await invoke<string>("read_file", {path});
|
const fileContent = await invoke<string>("read_file", {path});
|
||||||
|
if (request !== fileOpenRequest) return;
|
||||||
currentFileType.value = "markdown";
|
currentFileType.value = "markdown";
|
||||||
resetMediaPreviewState();
|
resetMediaPreviewState();
|
||||||
setDocumentContent(fileContent);
|
setDocumentContent(fileContent);
|
||||||
|
lastSavedContent = fileContent;
|
||||||
currentFilePath.value = path;
|
currentFilePath.value = path;
|
||||||
hasDraftDocument.value = false;
|
hasDraftDocument.value = false;
|
||||||
saveStatus.value = "";
|
saveStatus.value = "";
|
||||||
@@ -1009,7 +1091,29 @@ function getLatestEditorContent(contentOverride?: string) {
|
|||||||
return markdownEditorRef.value?.flushPendingChanges() ?? content.value;
|
return markdownEditorRef.value?.flushPendingChanges() ?? content.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSave(contentOverride?: string, options: { syncAfterSave?: boolean } = {}) {
|
function isCurrentMarkdownDocumentDirty() {
|
||||||
|
if (!isMarkdownDocument.value) return false;
|
||||||
|
if (!currentFilePath.value && !hasDraftDocument.value) return false;
|
||||||
|
|
||||||
|
const latestContent = getLatestEditorContent();
|
||||||
|
if (!currentFilePath.value && !latestContent.trim()) return false;
|
||||||
|
return latestContent !== lastSavedContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSaveNotice(message: string, type: "success" | "error" = "success") {
|
||||||
|
if (saveNoticeTimer) clearTimeout(saveNoticeTimer);
|
||||||
|
saveNotice.value = message;
|
||||||
|
saveNoticeType.value = type;
|
||||||
|
saveNoticeTimer = setTimeout(() => {
|
||||||
|
saveNotice.value = "";
|
||||||
|
saveNoticeTimer = null;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSave(
|
||||||
|
contentOverride?: string,
|
||||||
|
options: { syncAfterSave?: boolean; trigger?: "manual" | "automatic" } = {},
|
||||||
|
) {
|
||||||
if (!isMarkdownDocument.value) return false;
|
if (!isMarkdownDocument.value) return false;
|
||||||
|
|
||||||
const contentToSave = getLatestEditorContent(contentOverride);
|
const contentToSave = getLatestEditorContent(contentOverride);
|
||||||
@@ -1018,17 +1122,25 @@ async function doSave(contentOverride?: string, options: { syncAfterSave?: boole
|
|||||||
// No file path yet — prompt user to pick a save location
|
// No file path yet — prompt user to pick a save location
|
||||||
try {
|
try {
|
||||||
const chosen = await invoke<string | null>("pick_save_file");
|
const chosen = await invoke<string | null>("pick_save_file");
|
||||||
if (!chosen) return false; // user cancelled
|
if (!chosen) {
|
||||||
|
showSaveNotice(options.trigger === "automatic" ? "已取消自动保存" : "已取消保存", "error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
currentFilePath.value = chosen;
|
currentFilePath.value = chosen;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Save dialog failed:", e);
|
console.error("Save dialog failed:", e);
|
||||||
|
showSaveNotice(`保存失败: ${e}`, "error");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invoke("write_file", {path: currentFilePath.value, content: contentToSave});
|
await invoke("write_file", {path: currentFilePath.value, content: contentToSave});
|
||||||
saveStatus.value = `已保存: ${getFileName(currentFilePath.value)}`;
|
const fileName = getFileName(currentFilePath.value);
|
||||||
|
const message = options.trigger === "automatic" ? `已自动保存: ${fileName}` : `已手动保存: ${fileName}`;
|
||||||
|
saveStatus.value = message;
|
||||||
|
showSaveNotice(message);
|
||||||
|
lastSavedContent = contentToSave;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
saveStatus.value = "";
|
saveStatus.value = "";
|
||||||
}, 3000);
|
}, 3000);
|
||||||
@@ -1040,6 +1152,7 @@ async function doSave(contentOverride?: string, options: { syncAfterSave?: boole
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Save failed:", e);
|
console.error("Save failed:", e);
|
||||||
saveStatus.value = `保存失败: ${e}`;
|
saveStatus.value = `保存失败: ${e}`;
|
||||||
|
showSaveNotice(saveStatus.value, "error");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1051,10 +1164,15 @@ async function doSaveAs() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const chosen = await invoke<string | null>("pick_save_file");
|
const chosen = await invoke<string | null>("pick_save_file");
|
||||||
if (!chosen) return; // user cancelled
|
if (!chosen) {
|
||||||
|
showSaveNotice("已取消另存为", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
currentFilePath.value = chosen;
|
currentFilePath.value = chosen;
|
||||||
await invoke("write_file", {path: currentFilePath.value, content: contentToSave});
|
await invoke("write_file", {path: currentFilePath.value, content: contentToSave});
|
||||||
saveStatus.value = `已保存: ${getFileName(currentFilePath.value)}`;
|
saveStatus.value = `已手动保存: ${getFileName(currentFilePath.value)}`;
|
||||||
|
showSaveNotice(saveStatus.value);
|
||||||
|
lastSavedContent = contentToSave;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
saveStatus.value = "";
|
saveStatus.value = "";
|
||||||
}, 3000);
|
}, 3000);
|
||||||
@@ -1063,13 +1181,19 @@ async function doSaveAs() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Save failed:", e);
|
console.error("Save failed:", e);
|
||||||
saveStatus.value = `保存失败: ${e}`;
|
saveStatus.value = `保存失败: ${e}`;
|
||||||
|
showSaveNotice(saveStatus.value, "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function doNew() {
|
async function doNew() {
|
||||||
|
fileOpenRequest += 1;
|
||||||
|
const saved = await saveCurrentWorkspaceDocumentBeforeSwitch();
|
||||||
|
if (!saved) return;
|
||||||
|
|
||||||
currentFileType.value = "markdown";
|
currentFileType.value = "markdown";
|
||||||
resetMediaPreviewState();
|
resetMediaPreviewState();
|
||||||
setDocumentContent("");
|
setDocumentContent("");
|
||||||
|
lastSavedContent = "";
|
||||||
currentFilePath.value = null;
|
currentFilePath.value = null;
|
||||||
hasDraftDocument.value = true;
|
hasDraftDocument.value = true;
|
||||||
saveStatus.value = "";
|
saveStatus.value = "";
|
||||||
@@ -1112,7 +1236,11 @@ registerTauriListener("menu-save-as", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
registerTauriListener("menu-new", () => {
|
registerTauriListener("menu-new", () => {
|
||||||
doNew();
|
void doNew();
|
||||||
|
});
|
||||||
|
|
||||||
|
registerTauriListener("menu-quit", () => {
|
||||||
|
void closeWindow();
|
||||||
});
|
});
|
||||||
|
|
||||||
registerTauriListener("show-about", () => {
|
registerTauriListener("show-about", () => {
|
||||||
@@ -1147,6 +1275,8 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
|
|||||||
:settings-initial-page="settingsInitialPage"
|
:settings-initial-page="settingsInitialPage"
|
||||||
:default-document-mode="defaultDocumentMode"
|
:default-document-mode="defaultDocumentMode"
|
||||||
:app-theme="appTheme"
|
:app-theme="appTheme"
|
||||||
|
:render-single-line-breaks="renderSingleLineBreaks"
|
||||||
|
:paragraph-indent="paragraphIndent"
|
||||||
:recent-workspaces="recentWorkspaces"
|
:recent-workspaces="recentWorkspaces"
|
||||||
@open-folder="onOpenFolder"
|
@open-folder="onOpenFolder"
|
||||||
@switch-workspace="(path: string) => switchWorkspace(path)"
|
@switch-workspace="(path: string) => switchWorkspace(path)"
|
||||||
@@ -1160,6 +1290,8 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
|
|||||||
@search-result-click="handleSearchResultClick"
|
@search-result-click="handleSearchResultClick"
|
||||||
@update-default-document-mode="updateDefaultDocumentMode"
|
@update-default-document-mode="updateDefaultDocumentMode"
|
||||||
@update-app-theme="updateAppTheme"
|
@update-app-theme="updateAppTheme"
|
||||||
|
@update-render-single-line-breaks="updateRenderSingleLineBreaks"
|
||||||
|
@update-paragraph-indent="updateParagraphIndent"
|
||||||
@prepare-workspace-sync="prepareWorkspaceSync"
|
@prepare-workspace-sync="prepareWorkspaceSync"
|
||||||
@workspace-synced="handleWorkspaceSynced"
|
@workspace-synced="handleWorkspaceSynced"
|
||||||
/>
|
/>
|
||||||
@@ -1188,8 +1320,8 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
|
|||||||
<i class="ri-menu-line text-base"></i>
|
<i class="ri-menu-line text-base"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="titlebar-brand app-muted-text shrink-0 pointer-events-none">
|
<div v-else class="titlebar-brand shrink-0 pointer-events-none">
|
||||||
yurou
|
<img src="/yurou-logo.png" alt="yurou" class="titlebar-logo" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center: Filename -->
|
<!-- Center: Filename -->
|
||||||
@@ -1233,34 +1365,16 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- More menu -->
|
<!-- Sync button -->
|
||||||
<div v-if="isMarkdownDocument" class="relative">
|
<button
|
||||||
<button
|
v-if="folderPath"
|
||||||
class="toolbar-btn"
|
class="toolbar-btn"
|
||||||
title="更多"
|
:title="syncInFlight ? '正在同步...' : '同步工作目录'"
|
||||||
@click="showMoreMenu = !showMoreMenu"
|
:disabled="syncInFlight"
|
||||||
>
|
@click="runConfiguredSync('manual')"
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
>
|
||||||
<circle cx="3" cy="8" r="1.5"/>
|
<i :class="syncInFlight ? 'ri-loader-4-line sync-spinning' : 'ri-loop-left-line'"></i>
|
||||||
<circle cx="8" cy="8" r="1.5"/>
|
</button>
|
||||||
<circle cx="13" cy="8" r="1.5"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
v-if="showMoreMenu"
|
|
||||||
class="dropdown-menu"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<button class="dropdown-item" @click="handleExportPdf">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
||||||
<path d="M5 1h6l4 4v9a1 1 0 01-1 1H2a1 1 0 01-1-1V2a1 1 0 011-1z"/>
|
|
||||||
<path d="M5 1v4h4"/>
|
|
||||||
<path d="M5 9h6M5 12h4"/>
|
|
||||||
</svg>
|
|
||||||
导出 PDF
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Window controls -->
|
<!-- Window controls -->
|
||||||
@@ -1307,6 +1421,8 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
|
|||||||
:model-value="content"
|
:model-value="content"
|
||||||
:document-mode="documentMode"
|
:document-mode="documentMode"
|
||||||
:file-path="currentFilePath"
|
:file-path="currentFilePath"
|
||||||
|
:render-single-line-breaks="renderSingleLineBreaks"
|
||||||
|
:paragraph-indent="paragraphIndent"
|
||||||
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等"
|
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等"
|
||||||
@update:model-value="onUpdate"
|
@update:model-value="onUpdate"
|
||||||
@save-shortcut="doSave"
|
@save-shortcut="doSave"
|
||||||
@@ -1379,6 +1495,18 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
|
|||||||
{{ Math.round(editorZoom * 100) }}%
|
{{ Math.round(editorZoom * 100) }}%
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
<Transition name="save-notice">
|
||||||
|
<div
|
||||||
|
v-if="saveNotice"
|
||||||
|
class="save-notice fixed top-12 right-4 z-50 pointer-events-none select-none"
|
||||||
|
:class="{ 'save-notice-error': saveNoticeType === 'error' }"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<i :class="saveNoticeType === 'error' ? 'ri-error-warning-line' : 'ri-checkbox-circle-line'"></i>
|
||||||
|
<span>{{ saveNotice }}</span>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<!-- <footer class="app-footer backdrop-blur-sm">-->
|
<!-- <footer class="app-footer backdrop-blur-sm">-->
|
||||||
@@ -1416,6 +1544,17 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.save-notice-enter-active,
|
||||||
|
.save-notice-leave-active {
|
||||||
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-notice-enter-from,
|
||||||
|
.save-notice-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
|
||||||
.outline-slide-enter-active,
|
.outline-slide-enter-active,
|
||||||
.outline-slide-leave-active {
|
.outline-slide-leave-active {
|
||||||
transition:
|
transition:
|
||||||
@@ -1465,6 +1604,48 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
|
|||||||
color: var(--app-control-contrast);
|
color: var(--app-control-contrast);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.save-notice {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
max-width: min(360px, calc(100vw - 32px));
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 7px 11px;
|
||||||
|
border: 1px solid #86efac;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #14532d;
|
||||||
|
box-shadow:
|
||||||
|
0 10px 30px rgba(0, 0, 0, 0.28),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-notice i {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #15803d;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-notice span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-notice-error {
|
||||||
|
border-color: #fca5a5;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #7f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-notice-error i {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Media preview ──────────────────────────────────────────────── */
|
/* ── Media preview ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
.media-preview {
|
.media-preview {
|
||||||
@@ -1550,6 +1731,11 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
|
|||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.titlebar-logo {
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.titlebar-file-title {
|
.titlebar-file-title {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
@@ -1658,38 +1844,6 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
|
|||||||
box-shadow: 0 1px 3px var(--app-shadow);
|
box-shadow: 0 1px 3px var(--app-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-menu {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 100%;
|
|
||||||
margin-top: 4px;
|
|
||||||
background: var(--app-surface);
|
|
||||||
border: 1px solid var(--app-border-strong);
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
||||||
min-width: 140px;
|
|
||||||
padding: 4px;
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--app-text);
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item:hover {
|
|
||||||
background-color: var(--app-surface-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.start-actions {
|
.start-actions {
|
||||||
background:
|
background:
|
||||||
@@ -1720,4 +1874,15 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
|
|||||||
text-decoration-color: currentColor;
|
text-decoration-color: currentColor;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sync button spin animation */
|
||||||
|
@keyframes sync-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-spinning {
|
||||||
|
display: inline-block;
|
||||||
|
animation: sync-spin 1s linear infinite;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
BIN
src/assets/yurou-logo.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
src/assets/yurou-sys.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
@@ -21,6 +21,11 @@ interface WorkspaceEntry {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TreeExpansionCommand {
|
||||||
|
id: number;
|
||||||
|
expanded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -31,6 +36,8 @@ const props = withDefaults(
|
|||||||
settingsInitialPage?: SettingsPage;
|
settingsInitialPage?: SettingsPage;
|
||||||
defaultDocumentMode?: DocumentMode;
|
defaultDocumentMode?: DocumentMode;
|
||||||
appTheme?: AppTheme;
|
appTheme?: AppTheme;
|
||||||
|
renderSingleLineBreaks?: boolean;
|
||||||
|
paragraphIndent?: boolean;
|
||||||
recentWorkspaces?: WorkspaceEntry[];
|
recentWorkspaces?: WorkspaceEntry[];
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
@@ -39,6 +46,8 @@ const props = withDefaults(
|
|||||||
settingsInitialPage: "about",
|
settingsInitialPage: "about",
|
||||||
defaultDocumentMode: "edit",
|
defaultDocumentMode: "edit",
|
||||||
appTheme: "warm",
|
appTheme: "warm",
|
||||||
|
renderSingleLineBreaks: false,
|
||||||
|
paragraphIndent: false,
|
||||||
recentWorkspaces: () => [],
|
recentWorkspaces: () => [],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -55,6 +64,8 @@ const emit = defineEmits<{
|
|||||||
renameEntry: [path: string, newName: string];
|
renameEntry: [path: string, newName: string];
|
||||||
updateDefaultDocumentMode: [mode: DocumentMode];
|
updateDefaultDocumentMode: [mode: DocumentMode];
|
||||||
updateAppTheme: [theme: AppTheme];
|
updateAppTheme: [theme: AppTheme];
|
||||||
|
updateRenderSingleLineBreaks: [value: boolean];
|
||||||
|
updateParagraphIndent: [value: boolean];
|
||||||
prepareWorkspaceSync: [done: (saved: boolean) => void];
|
prepareWorkspaceSync: [done: (saved: boolean) => void];
|
||||||
workspaceSynced: [];
|
workspaceSynced: [];
|
||||||
"search-result-click": [filePath: string, lineNumber: number];
|
"search-result-click": [filePath: string, lineNumber: number];
|
||||||
@@ -455,6 +466,7 @@ watch(showWorkspaceMenu, (open) => {
|
|||||||
|
|
||||||
watch(() => props.folderPath, () => {
|
watch(() => props.folderPath, () => {
|
||||||
closeWorkspaceMenu();
|
closeWorkspaceMenu();
|
||||||
|
treeExpansionCommand.value = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -532,6 +544,16 @@ function handleCreateRootFile() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const treeExpansionCommand = ref<TreeExpansionCommand | null>(null);
|
||||||
|
let treeExpansionCommandId = 0;
|
||||||
|
|
||||||
|
function setAllFoldersExpanded(expanded: boolean) {
|
||||||
|
treeExpansionCommand.value = {
|
||||||
|
id: ++treeExpansionCommandId,
|
||||||
|
expanded,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -597,6 +619,25 @@ function handleCreateRootFile() {
|
|||||||
>
|
>
|
||||||
<i class="ri-file-add-line"></i>
|
<i class="ri-file-add-line"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<span class="tree-toolbar-separator" aria-hidden="true"></span>
|
||||||
|
<button
|
||||||
|
class="tree-toolbar-btn"
|
||||||
|
type="button"
|
||||||
|
title="全部展开"
|
||||||
|
aria-label="全部展开工作目录文件夹"
|
||||||
|
@click="setAllFoldersExpanded(true)"
|
||||||
|
>
|
||||||
|
<i class="ri-expand-vertical-line"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tree-toolbar-btn"
|
||||||
|
type="button"
|
||||||
|
title="全部折叠"
|
||||||
|
aria-label="全部折叠工作目录文件夹"
|
||||||
|
@click="setAllFoldersExpanded(false)"
|
||||||
|
>
|
||||||
|
<i class="ri-collapse-vertical-line"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- File tree -->
|
<!-- File tree -->
|
||||||
<div
|
<div
|
||||||
@@ -609,6 +650,7 @@ function handleCreateRootFile() {
|
|||||||
:entry="entry"
|
:entry="entry"
|
||||||
:depth="0"
|
:depth="0"
|
||||||
:currentFilePath="currentFilePath"
|
:currentFilePath="currentFilePath"
|
||||||
|
:expansion-command="treeExpansionCommand"
|
||||||
@toggle-folder="(path: string) => emit('toggleFolder', path)"
|
@toggle-folder="(path: string) => emit('toggleFolder', path)"
|
||||||
@open-file="(path: string) => emit('openFile', path)"
|
@open-file="(path: string) => emit('openFile', path)"
|
||||||
@create-folder="(path: string) => emit('createFolder', path)"
|
@create-folder="(path: string) => emit('createFolder', path)"
|
||||||
@@ -842,8 +884,12 @@ function handleCreateRootFile() {
|
|||||||
:workspace-path="folderPath"
|
:workspace-path="folderPath"
|
||||||
:default-document-mode="defaultDocumentMode"
|
:default-document-mode="defaultDocumentMode"
|
||||||
:app-theme="appTheme"
|
:app-theme="appTheme"
|
||||||
|
:render-single-line-breaks="renderSingleLineBreaks"
|
||||||
|
:paragraph-indent="paragraphIndent"
|
||||||
@update-default-document-mode="(mode: DocumentMode) => emit('updateDefaultDocumentMode', mode)"
|
@update-default-document-mode="(mode: DocumentMode) => emit('updateDefaultDocumentMode', mode)"
|
||||||
@update-app-theme="(theme: AppTheme) => emit('updateAppTheme', theme)"
|
@update-app-theme="(theme: AppTheme) => emit('updateAppTheme', theme)"
|
||||||
|
@update-render-single-line-breaks="(value: boolean) => emit('updateRenderSingleLineBreaks', value)"
|
||||||
|
@update-paragraph-indent="(value: boolean) => emit('updateParagraphIndent', value)"
|
||||||
@prepare-workspace-sync="forwardPrepareWorkspaceSync"
|
@prepare-workspace-sync="forwardPrepareWorkspaceSync"
|
||||||
@workspace-synced="emit('workspaceSynced')"
|
@workspace-synced="emit('workspaceSynced')"
|
||||||
/>
|
/>
|
||||||
@@ -1090,6 +1136,13 @@ function handleCreateRootFile() {
|
|||||||
color: var(--app-text);
|
color: var(--app-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-toolbar-separator {
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
margin: 0 2px;
|
||||||
|
background: var(--app-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-search-input {
|
.sidebar-search-input {
|
||||||
border: 1px solid var(--app-border-strong);
|
border: 1px solid var(--app-border-strong);
|
||||||
background: var(--app-hover);
|
background: var(--app-hover);
|
||||||
|
|||||||
496
src/components/MarkdownCodeEditor.vue
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
|
import {
|
||||||
|
acceptCompletion,
|
||||||
|
autocompletion,
|
||||||
|
closeBrackets,
|
||||||
|
closeBracketsKeymap,
|
||||||
|
completionKeymap,
|
||||||
|
pickedCompletion,
|
||||||
|
startCompletion,
|
||||||
|
type CompletionContext,
|
||||||
|
type CompletionResult,
|
||||||
|
} from "@codemirror/autocomplete";
|
||||||
|
import {
|
||||||
|
defaultKeymap,
|
||||||
|
history,
|
||||||
|
historyKeymap,
|
||||||
|
indentWithTab,
|
||||||
|
} from "@codemirror/commands";
|
||||||
|
import { markdown } from "@codemirror/lang-markdown";
|
||||||
|
import { languages } from "@codemirror/language-data";
|
||||||
|
import {
|
||||||
|
bracketMatching,
|
||||||
|
defaultHighlightStyle,
|
||||||
|
foldGutter,
|
||||||
|
HighlightStyle,
|
||||||
|
indentOnInput,
|
||||||
|
syntaxHighlighting,
|
||||||
|
} from "@codemirror/language";
|
||||||
|
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
|
||||||
|
import {
|
||||||
|
EditorSelection,
|
||||||
|
EditorState,
|
||||||
|
StateEffect,
|
||||||
|
StateField,
|
||||||
|
type Text,
|
||||||
|
} from "@codemirror/state";
|
||||||
|
import {
|
||||||
|
crosshairCursor,
|
||||||
|
Decoration,
|
||||||
|
type DecorationSet,
|
||||||
|
drawSelection,
|
||||||
|
dropCursor,
|
||||||
|
EditorView,
|
||||||
|
highlightActiveLine,
|
||||||
|
highlightActiveLineGutter,
|
||||||
|
highlightSpecialChars,
|
||||||
|
keymap,
|
||||||
|
placeholder as placeholderExtension,
|
||||||
|
rectangularSelection,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
import { tags } from "@lezer/highlight";
|
||||||
|
import { highlightCodeToTokens } from "@/lib/codeHighlight";
|
||||||
|
import { getMarkdownLanguageCompletions } from "@/lib/markdownLanguages";
|
||||||
|
import { resolveMarkdownCodeLanguage } from "@/lib/markdownLanguages";
|
||||||
|
|
||||||
|
interface PastePayload {
|
||||||
|
event: ClipboardEvent;
|
||||||
|
value: string;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
placeholder?: string;
|
||||||
|
autofocus?: boolean;
|
||||||
|
minHeight?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
placeholder: "",
|
||||||
|
autofocus: false,
|
||||||
|
minHeight: "1.625em",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: string];
|
||||||
|
blur: [];
|
||||||
|
escape: [];
|
||||||
|
paste: [payload: PastePayload];
|
||||||
|
"backspace-at-start": [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const hostRef = ref<HTMLDivElement>();
|
||||||
|
let editorView: EditorView | null = null;
|
||||||
|
let highlightRequestId = 0;
|
||||||
|
|
||||||
|
const setCodeDecorations = StateEffect.define<DecorationSet>();
|
||||||
|
const codeDecorationField = StateField.define<DecorationSet>({
|
||||||
|
create: () => Decoration.none,
|
||||||
|
update(value, transaction) {
|
||||||
|
let next = value.map(transaction.changes);
|
||||||
|
for (const effect of transaction.effects) {
|
||||||
|
if (effect.is(setCodeDecorations)) next = effect.value;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
provide: (field) => EditorView.decorations.from(field),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface FencedCodeBlock {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
code: string;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFencedCodeBlocks(doc: Text): FencedCodeBlock[] {
|
||||||
|
const blocks: FencedCodeBlock[] = [];
|
||||||
|
|
||||||
|
for (let lineNumber = 1; lineNumber <= doc.lines; lineNumber += 1) {
|
||||||
|
const line = doc.line(lineNumber);
|
||||||
|
const opener = /^(\s*)(`{3,}|~{3,})\s*([\w.+#-]*)/.exec(line.text);
|
||||||
|
if (!opener) continue;
|
||||||
|
|
||||||
|
const fenceChar = opener[2][0];
|
||||||
|
const fenceLength = opener[2].length;
|
||||||
|
const closingPattern = new RegExp(`^\\s*${fenceChar === "`" ? "`" : "~"}{${fenceLength},}\\s*$`);
|
||||||
|
const contentFrom = Math.min(doc.length, line.to + 1);
|
||||||
|
|
||||||
|
for (let closingLineNumber = lineNumber + 1; closingLineNumber <= doc.lines; closingLineNumber += 1) {
|
||||||
|
const closingLine = doc.line(closingLineNumber);
|
||||||
|
if (!closingPattern.test(closingLine.text)) continue;
|
||||||
|
|
||||||
|
const requestedLanguage = opener[3] || "text";
|
||||||
|
blocks.push({
|
||||||
|
from: contentFrom,
|
||||||
|
to: closingLine.from,
|
||||||
|
code: doc.sliceString(contentFrom, closingLine.from),
|
||||||
|
language: resolveMarkdownCodeLanguage(requestedLanguage)?.shiki || "text",
|
||||||
|
});
|
||||||
|
lineNumber = closingLineNumber;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCodeDecorations(view: EditorView) {
|
||||||
|
const requestId = ++highlightRequestId;
|
||||||
|
const documentText = view.state.doc.toString();
|
||||||
|
const blocks = findFencedCodeBlocks(view.state.doc);
|
||||||
|
const ranges: ReturnType<Decoration["range"]>[] = [];
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
const tokenLines = await highlightCodeToTokens(block.code, block.language);
|
||||||
|
if (!tokenLines || requestId !== highlightRequestId) return;
|
||||||
|
|
||||||
|
for (const tokens of tokenLines) {
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (!token.content || !token.color) continue;
|
||||||
|
const from = block.from + token.offset;
|
||||||
|
const to = Math.min(block.to, from + token.content.length);
|
||||||
|
if (to <= from) continue;
|
||||||
|
ranges.push(
|
||||||
|
Decoration.mark({
|
||||||
|
class: "cm-shiki-token",
|
||||||
|
attributes: { style: `color: ${token.color}` },
|
||||||
|
}).range(from, to),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
requestId !== highlightRequestId ||
|
||||||
|
!editorView ||
|
||||||
|
editorView !== view ||
|
||||||
|
view.state.doc.toString() !== documentText
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.dispatch({ effects: setCodeDecorations.of(Decoration.set(ranges, true)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fenceLanguageCompletion(context: CompletionContext): CompletionResult | null {
|
||||||
|
const line = context.state.doc.lineAt(context.pos);
|
||||||
|
const beforeCursor = context.state.sliceDoc(line.from, context.pos);
|
||||||
|
const match = /^(\s*)(`{3,}|~{3,})([\w.+#-]*)$/.exec(beforeCursor);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: line.from + match[1].length + match[2].length,
|
||||||
|
options: getMarkdownLanguageCompletions(),
|
||||||
|
validFor: /^[\w.+#-]*$/,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertClosedCodeFence(view: EditorView) {
|
||||||
|
const selection = view.state.selection.main;
|
||||||
|
if (!selection.empty) return false;
|
||||||
|
|
||||||
|
const line = view.state.doc.lineAt(selection.head);
|
||||||
|
const beforeCursor = view.state.sliceDoc(line.from, selection.head);
|
||||||
|
const afterCursor = view.state.sliceDoc(selection.head, line.to);
|
||||||
|
const match = /^(\s*)(`{3,}|~{3,})[\w.+#-]*\s*$/.exec(beforeCursor);
|
||||||
|
if (!match || afterCursor.trim()) return false;
|
||||||
|
|
||||||
|
const nextLine = line.number < view.state.doc.lines
|
||||||
|
? view.state.doc.line(line.number + 1).text
|
||||||
|
: "";
|
||||||
|
const fence = match[2][0].repeat(match[2].length);
|
||||||
|
if (nextLine.trimStart().startsWith(fence)) return false;
|
||||||
|
|
||||||
|
const insert = `\n${match[1]}\n${match[1]}${fence}`;
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: selection.head, insert },
|
||||||
|
selection: { anchor: selection.head + 1 + match[1].length },
|
||||||
|
scrollIntoView: true,
|
||||||
|
userEvent: "input",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptCompletionOrCloseFence(view: EditorView) {
|
||||||
|
return acceptCompletion(view) || insertClosedCodeFence(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorHighlightStyle = HighlightStyle.define([
|
||||||
|
{ tag: tags.heading, color: "var(--app-text-strong)", fontWeight: "700" },
|
||||||
|
{ tag: tags.emphasis, fontStyle: "italic" },
|
||||||
|
{ tag: tags.strong, fontWeight: "700" },
|
||||||
|
{ tag: tags.link, color: "var(--code-token-link)", textDecoration: "underline" },
|
||||||
|
{ tag: tags.url, color: "var(--code-token-link)" },
|
||||||
|
{
|
||||||
|
tag: tags.monospace,
|
||||||
|
color: "var(--code-token-string)",
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||||
|
},
|
||||||
|
{ tag: [tags.keyword, tags.operatorKeyword], color: "var(--code-token-keyword)" },
|
||||||
|
{ tag: [tags.string, tags.regexp], color: "var(--code-token-string-expression)" },
|
||||||
|
{ tag: [tags.number, tags.bool, tags.null, tags.atom], color: "var(--code-token-constant)" },
|
||||||
|
{ tag: [tags.comment, tags.meta], color: "var(--code-token-comment)" },
|
||||||
|
{
|
||||||
|
tag: [tags.function(tags.variableName), tags.labelName],
|
||||||
|
color: "var(--code-token-function)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: [tags.typeName, tags.className, tags.namespace, tags.tagName],
|
||||||
|
color: "var(--code-token-function)",
|
||||||
|
},
|
||||||
|
{ tag: tags.propertyName, color: "var(--code-token-constant)" },
|
||||||
|
{ tag: tags.attributeName, color: "var(--code-token-parameter)" },
|
||||||
|
{ tag: [tags.punctuation, tags.separator, tags.bracket], color: "var(--code-token-punctuation)" },
|
||||||
|
{ tag: tags.inserted, color: "var(--code-token-inserted)" },
|
||||||
|
{ tag: tags.deleted, color: "var(--code-token-deleted)" },
|
||||||
|
{ tag: tags.changed, color: "var(--code-token-changed)" },
|
||||||
|
{ tag: tags.invalid, color: "var(--code-token-deleted)" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const editorTheme = EditorView.theme({
|
||||||
|
"&": {
|
||||||
|
color: "var(--code-foreground)",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
fontSize: "inherit",
|
||||||
|
minHeight: "var(--markdown-code-editor-min-height)",
|
||||||
|
},
|
||||||
|
"&.cm-focused": { outline: "none" },
|
||||||
|
".cm-scroller": {
|
||||||
|
fontFamily: "inherit",
|
||||||
|
lineHeight: "1.625",
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
".cm-content": {
|
||||||
|
padding: "0",
|
||||||
|
minHeight: "var(--markdown-code-editor-min-height)",
|
||||||
|
caretColor: "var(--app-accent)",
|
||||||
|
},
|
||||||
|
".cm-line": { padding: "0" },
|
||||||
|
".cm-shiki-token > span": { color: "inherit !important" },
|
||||||
|
".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--app-accent)" },
|
||||||
|
".cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection": {
|
||||||
|
backgroundColor: "var(--app-selection) !important",
|
||||||
|
},
|
||||||
|
".cm-placeholder": { color: "var(--app-subtle)" },
|
||||||
|
".cm-activeLine": { backgroundColor: "transparent" },
|
||||||
|
".cm-gutters": { display: "none" },
|
||||||
|
".cm-tooltip": {
|
||||||
|
border: "1px solid var(--app-border-strong)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
backgroundColor: "var(--app-surface)",
|
||||||
|
color: "var(--app-text)",
|
||||||
|
boxShadow: "0 10px 28px var(--app-shadow)",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
".cm-tooltip-autocomplete > ul": {
|
||||||
|
fontFamily: '"Geist", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||||
|
maxHeight: "240px",
|
||||||
|
},
|
||||||
|
".cm-tooltip-autocomplete > ul > li": {
|
||||||
|
minWidth: "220px",
|
||||||
|
padding: "6px 10px",
|
||||||
|
},
|
||||||
|
".cm-tooltip-autocomplete > ul > li[aria-selected]": {
|
||||||
|
backgroundColor: "var(--app-active-bg)",
|
||||||
|
color: "var(--app-text-strong)",
|
||||||
|
},
|
||||||
|
".cm-completionLabel": { fontWeight: "600" },
|
||||||
|
".cm-completionDetail": { color: "var(--app-muted)", fontStyle: "normal" },
|
||||||
|
});
|
||||||
|
|
||||||
|
function createEditor() {
|
||||||
|
const host = hostRef.value;
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
editorView = new EditorView({
|
||||||
|
parent: host,
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: props.modelValue,
|
||||||
|
extensions: [
|
||||||
|
highlightSpecialChars(),
|
||||||
|
history(),
|
||||||
|
foldGutter(),
|
||||||
|
drawSelection(),
|
||||||
|
dropCursor(),
|
||||||
|
EditorState.allowMultipleSelections.of(true),
|
||||||
|
indentOnInput(),
|
||||||
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||||
|
bracketMatching(),
|
||||||
|
rectangularSelection(),
|
||||||
|
crosshairCursor(),
|
||||||
|
highlightActiveLine(),
|
||||||
|
highlightActiveLineGutter(),
|
||||||
|
highlightSelectionMatches(),
|
||||||
|
markdown({ codeLanguages: languages }),
|
||||||
|
syntaxHighlighting(editorHighlightStyle),
|
||||||
|
codeDecorationField,
|
||||||
|
closeBrackets(),
|
||||||
|
autocompletion({ override: [fenceLanguageCompletion], activateOnTyping: true }),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
editorTheme,
|
||||||
|
placeholderExtension(props.placeholder),
|
||||||
|
keymap.of([
|
||||||
|
{ key: "Enter", run: acceptCompletionOrCloseFence },
|
||||||
|
...completionKeymap,
|
||||||
|
{
|
||||||
|
key: "Escape",
|
||||||
|
run() {
|
||||||
|
emit("escape");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
indentWithTab,
|
||||||
|
...closeBracketsKeymap,
|
||||||
|
{
|
||||||
|
key: "Backspace",
|
||||||
|
run(view) {
|
||||||
|
const selection = view.state.selection.main;
|
||||||
|
if (selection.empty && selection.head === 0) {
|
||||||
|
emit("backspace-at-start");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...defaultKeymap,
|
||||||
|
...searchKeymap,
|
||||||
|
...historyKeymap,
|
||||||
|
]),
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
emit("update:modelValue", update.state.doc.toString());
|
||||||
|
void updateCodeDecorations(update.view);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!update.docChanged ||
|
||||||
|
update.transactions.some((transaction) => transaction.annotation(pickedCompletion))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = update.state.selection.main;
|
||||||
|
if (!selection.empty) return;
|
||||||
|
const line = update.state.doc.lineAt(selection.head);
|
||||||
|
const beforeCursor = update.state.sliceDoc(line.from, selection.head);
|
||||||
|
if (/^\s*(`{3,}|~{3,})[\w.+#-]*$/.test(beforeCursor)) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (editorView) startCompletion(editorView);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
EditorView.domEventHandlers({
|
||||||
|
blur() {
|
||||||
|
emit("blur");
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
paste(event, view) {
|
||||||
|
const selection = view.state.selection.main;
|
||||||
|
emit("paste", {
|
||||||
|
event,
|
||||||
|
value: view.state.doc.toString(),
|
||||||
|
from: selection.from,
|
||||||
|
to: selection.to,
|
||||||
|
});
|
||||||
|
return event.defaultPrevented;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (props.autofocus) {
|
||||||
|
requestAnimationFrame(() => focusAtEnd());
|
||||||
|
}
|
||||||
|
void updateCodeDecorations(editorView);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
const view = editorView;
|
||||||
|
if (!view || value === view.state.doc.toString()) return;
|
||||||
|
|
||||||
|
const cursor = Math.min(view.state.selection.main.head, value.length);
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||||
|
selection: EditorSelection.cursor(cursor),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function focus() {
|
||||||
|
editorView?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusAtEnd() {
|
||||||
|
const view = editorView;
|
||||||
|
if (!view) return;
|
||||||
|
const end = view.state.doc.length;
|
||||||
|
view.dispatch({ selection: EditorSelection.cursor(end), scrollIntoView: true });
|
||||||
|
view.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceRange(text: string, from?: number, to?: number) {
|
||||||
|
const view = editorView;
|
||||||
|
if (!view) return;
|
||||||
|
const selection = view.state.selection.main;
|
||||||
|
const replaceFrom = from ?? selection.from;
|
||||||
|
const replaceTo = to ?? selection.to;
|
||||||
|
const cursor = replaceFrom + text.length;
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: replaceFrom, to: replaceTo, insert: text },
|
||||||
|
selection: EditorSelection.cursor(cursor),
|
||||||
|
scrollIntoView: true,
|
||||||
|
userEvent: "input.paste",
|
||||||
|
});
|
||||||
|
view.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToLine(lineNumber: number) {
|
||||||
|
const view = editorView;
|
||||||
|
if (!view) return;
|
||||||
|
const safeLine = Math.max(1, Math.min(lineNumber, view.state.doc.lines));
|
||||||
|
const line = view.state.doc.line(safeLine);
|
||||||
|
view.dispatch({ selection: EditorSelection.cursor(line.from), scrollIntoView: true });
|
||||||
|
view.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCursorPosition(pos: number) {
|
||||||
|
const view = editorView;
|
||||||
|
if (!view) return;
|
||||||
|
const safePos = Math.max(0, Math.min(pos, view.state.doc.length));
|
||||||
|
view.dispatch({ selection: EditorSelection.cursor(safePos), scrollIntoView: true });
|
||||||
|
view.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ focus, focusAtEnd, replaceRange, scrollToLine, setCursorPosition });
|
||||||
|
|
||||||
|
onMounted(createEditor);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
highlightRequestId += 1;
|
||||||
|
editorView?.destroy();
|
||||||
|
editorView = null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="hostRef"
|
||||||
|
class="markdown-code-editor"
|
||||||
|
:style="{ '--markdown-code-editor-min-height': props.minHeight }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.markdown-code-editor {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,12 +3,16 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
nextTick,
|
nextTick,
|
||||||
onBeforeUnmount,
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
ref,
|
ref,
|
||||||
watch,
|
watch,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
|
import { Fancybox } from "@fancyapps/ui";
|
||||||
|
import "@fancyapps/ui/dist/fancybox/fancybox.css";
|
||||||
import { unified, type Plugin, type Transformer } from "unified";
|
import { unified, type Plugin, type Transformer } from "unified";
|
||||||
import remarkParse from "remark-parse";
|
import remarkParse from "remark-parse";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
import remarkBreaks from "remark-breaks";
|
||||||
import remarkRehype from "remark-rehype";
|
import remarkRehype from "remark-rehype";
|
||||||
import rehypeStringify from "rehype-stringify";
|
import rehypeStringify from "rehype-stringify";
|
||||||
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||||
@@ -66,6 +70,7 @@ interface MarkdownCodeEditorApi {
|
|||||||
focusAtEnd: () => void;
|
focusAtEnd: () => void;
|
||||||
replaceRange: (text: string, from?: number, to?: number) => void;
|
replaceRange: (text: string, from?: number, to?: number) => void;
|
||||||
scrollToLine: (lineNumber: number) => void;
|
scrollToLine: (lineNumber: number) => void;
|
||||||
|
setCursorPosition: (pos: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -75,6 +80,8 @@ const props = withDefaults(
|
|||||||
documentMode?: DocumentMode;
|
documentMode?: DocumentMode;
|
||||||
previewOnly?: boolean;
|
previewOnly?: boolean;
|
||||||
filePath?: string | null;
|
filePath?: string | null;
|
||||||
|
renderSingleLineBreaks?: boolean;
|
||||||
|
paragraphIndent?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
modelValue: "",
|
modelValue: "",
|
||||||
@@ -82,6 +89,8 @@ const props = withDefaults(
|
|||||||
documentMode: undefined,
|
documentMode: undefined,
|
||||||
previewOnly: false,
|
previewOnly: false,
|
||||||
filePath: null,
|
filePath: null,
|
||||||
|
renderSingleLineBreaks: false,
|
||||||
|
paragraphIndent: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -220,14 +229,23 @@ const mediaSanitizeSchema = {
|
|||||||
tagNames: [...(defaultSchema.tagNames || []), "video"],
|
tagNames: [...(defaultSchema.tagNames || []), "video"],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Full pipeline: markdown → HTML */
|
function createMarkdownProcessor(renderSingleLineBreaks: boolean) {
|
||||||
const md2html = unified()
|
const processor = unified().use(remarkParse).use(remarkGfm);
|
||||||
.use(remarkParse)
|
|
||||||
.use(remarkGfm)
|
if (renderSingleLineBreaks) {
|
||||||
.use(remarkLocalMedia)
|
processor.use(remarkBreaks);
|
||||||
.use(remarkRehype, { allowDangerousHtml: false })
|
}
|
||||||
.use(rehypeSanitize, mediaSanitizeSchema)
|
|
||||||
.use(rehypeStringify);
|
return processor
|
||||||
|
.use(remarkLocalMedia)
|
||||||
|
.use(remarkRehype, { allowDangerousHtml: false })
|
||||||
|
.use(rehypeSanitize, mediaSanitizeSchema)
|
||||||
|
.use(rehypeStringify);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full pipelines: standard Markdown and optional single-line breaks. */
|
||||||
|
const standardMd2html = createMarkdownProcessor(false);
|
||||||
|
const singleLineBreaksMd2html = createMarkdownProcessor(true);
|
||||||
|
|
||||||
const mdParser = unified().use(remarkParse).use(remarkGfm);
|
const mdParser = unified().use(remarkParse).use(remarkGfm);
|
||||||
|
|
||||||
@@ -256,8 +274,12 @@ const pendingLocalModelUpdate = ref(false);
|
|||||||
const isEditingEmptyDocument = computed(
|
const isEditingEmptyDocument = computed(
|
||||||
() => editingBlockId.value === EMPTY_BLOCK_ID,
|
() => editingBlockId.value === EMPTY_BLOCK_ID,
|
||||||
);
|
);
|
||||||
|
const previewParagraphStyle = computed(() => ({
|
||||||
|
"--paragraph-indent": props.paragraphIndent ? "2em" : "0",
|
||||||
|
}));
|
||||||
let modeScrollRestoreFrame: number | null = null;
|
let modeScrollRestoreFrame: number | null = null;
|
||||||
let renderRequestId = 0;
|
let renderRequestId = 0;
|
||||||
|
const previewImageSelector = ".markdown-preview:not(.markdown-mixed) img";
|
||||||
|
|
||||||
interface ScrollSnapshot {
|
interface ScrollSnapshot {
|
||||||
ratio: number;
|
ratio: number;
|
||||||
@@ -350,7 +372,8 @@ async function highlightCodeBlocks(html: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function renderMarkdownHtml(md: string) {
|
async function renderMarkdownHtml(md: string) {
|
||||||
const file = md2html.processSync(md);
|
const processor = props.renderSingleLineBreaks ? singleLineBreaksMd2html : standardMd2html;
|
||||||
|
const file = processor.processSync(md);
|
||||||
return highlightCodeBlocks(String(file.value));
|
return highlightCodeBlocks(String(file.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,6 +492,13 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.renderSingleLineBreaks,
|
||||||
|
() => {
|
||||||
|
syncRenderedState(props.modelValue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ── Editor handlers ────────────────────────────────────────────────
|
// ── Editor handlers ────────────────────────────────────────────────
|
||||||
|
|
||||||
function focusBlockEditor() {
|
function focusBlockEditor() {
|
||||||
@@ -567,6 +597,56 @@ function flushPendingChanges(): string | undefined {
|
|||||||
return finalizeBlockEdit();
|
return finalizeBlockEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function mergeCurrentBlockWithPrevious() {
|
||||||
|
if (activeDocumentMode.value !== "mixed") return;
|
||||||
|
const blockId = editingBlockId.value;
|
||||||
|
if (!blockId) return;
|
||||||
|
|
||||||
|
const blockIndex = markdownBlocks.value.findIndex((b) => b.id === blockId);
|
||||||
|
if (blockIndex <= 0) return;
|
||||||
|
|
||||||
|
const previousBlock = markdownBlocks.value[blockIndex - 1];
|
||||||
|
const currentBlock = markdownBlocks.value[blockIndex];
|
||||||
|
|
||||||
|
// 将当前编辑中的源码与前一个块的源码合并
|
||||||
|
const mergedSource = previousBlock.source + editingSource.value;
|
||||||
|
|
||||||
|
// 构建新文档:用合并后的源码替换两个块的范围
|
||||||
|
const nextValue =
|
||||||
|
props.modelValue.slice(0, previousBlock.startOffset) +
|
||||||
|
mergedSource +
|
||||||
|
props.modelValue.slice(currentBlock.endOffset);
|
||||||
|
|
||||||
|
// 记录合并点位置(上一个块的原末尾),用于后续光标定位
|
||||||
|
const cursorOffset = previousBlock.source.length;
|
||||||
|
|
||||||
|
// 取消当前编辑状态
|
||||||
|
cancelBlockEdit();
|
||||||
|
|
||||||
|
// 更新文档
|
||||||
|
emitModelValue(nextValue);
|
||||||
|
|
||||||
|
// 等待重新解析块结构
|
||||||
|
await parseBlocks(nextValue);
|
||||||
|
|
||||||
|
// 找到合并后的新块(startOffset 仍为 previousBlock.startOffset)
|
||||||
|
const newBlock = markdownBlocks.value.find(
|
||||||
|
(b) => b.startOffset === previousBlock.startOffset,
|
||||||
|
);
|
||||||
|
if (newBlock) {
|
||||||
|
// 进入编辑模式,光标定位在合并点
|
||||||
|
editingBlockId.value = newBlock.id;
|
||||||
|
editingSource.value = newBlock.source;
|
||||||
|
editingBaseValue.value = nextValue;
|
||||||
|
editingRange.value = {
|
||||||
|
startOffset: newBlock.startOffset,
|
||||||
|
endOffset: newBlock.endOffset,
|
||||||
|
};
|
||||||
|
await nextTick();
|
||||||
|
blockCodeRef.value?.setCursorPosition(cursorOffset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(activeDocumentMode, (mode, oldMode) => {
|
watch(activeDocumentMode, (mode, oldMode) => {
|
||||||
const scrollSnapshot = captureScrollSnapshot();
|
const scrollSnapshot = captureScrollSnapshot();
|
||||||
let committedValue: string | undefined;
|
let committedValue: string | undefined;
|
||||||
@@ -579,7 +659,17 @@ watch(activeDocumentMode, (mode, oldMode) => {
|
|||||||
restoreScrollAfterModeChange(scrollSnapshot);
|
restoreScrollAfterModeChange(scrollSnapshot);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
Fancybox.bind(scrollRef.value ?? null, previewImageSelector, {
|
||||||
|
groupAll: true,
|
||||||
|
placeFocusBack: true,
|
||||||
|
theme: "auto",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
Fancybox.unbind(scrollRef.value ?? null, previewImageSelector);
|
||||||
|
|
||||||
if (modeScrollRestoreFrame !== null) {
|
if (modeScrollRestoreFrame !== null) {
|
||||||
cancelAnimationFrame(modeScrollRestoreFrame);
|
cancelAnimationFrame(modeScrollRestoreFrame);
|
||||||
}
|
}
|
||||||
@@ -918,7 +1008,7 @@ defineExpose({
|
|||||||
@keydown.capture="onRootKeydown"
|
@keydown.capture="onRootKeydown"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx-auto px-8 py-4 h-full"
|
class="mx-auto px-8 pt-4 pb-10 min-h-full"
|
||||||
:style="{
|
:style="{
|
||||||
maxWidth: `calc(700px * var(--editor-zoom, 1))`,
|
maxWidth: `calc(700px * var(--editor-zoom, 1))`,
|
||||||
fontSize: `calc(1rem * var(--editor-zoom, 1))`,
|
fontSize: `calc(1rem * var(--editor-zoom, 1))`,
|
||||||
@@ -941,11 +1031,11 @@ defineExpose({
|
|||||||
<div v-if="renderError" class="text-red-500 text-sm">
|
<div v-if="renderError" class="text-red-500 text-sm">
|
||||||
⚠ Markdown 解析错误
|
⚠ Markdown 解析错误
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="markdown-preview" v-html="renderedHtml" />
|
<div v-else class="markdown-preview" :style="previewParagraphStyle" v-html="renderedHtml" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mixed mode -->
|
<!-- Mixed mode -->
|
||||||
<div v-else class="min-h-[60vh] markdown-preview markdown-mixed">
|
<div v-else class="min-h-[60vh] markdown-preview markdown-mixed" :style="previewParagraphStyle">
|
||||||
<div v-if="renderError" class="text-red-500 text-sm">
|
<div v-if="renderError" class="text-red-500 text-sm">
|
||||||
⚠ Markdown 解析错误
|
⚠ Markdown 解析错误
|
||||||
</div>
|
</div>
|
||||||
@@ -978,7 +1068,10 @@ defineExpose({
|
|||||||
v-for="block in markdownBlocks"
|
v-for="block in markdownBlocks"
|
||||||
:key="block.id"
|
:key="block.id"
|
||||||
class="markdown-block"
|
class="markdown-block"
|
||||||
:class="{ 'markdown-block-editing': editingBlockId === block.id }"
|
:class="{
|
||||||
|
'markdown-block-editing': editingBlockId === block.id,
|
||||||
|
'markdown-block-paragraph': block.type === 'paragraph',
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<MarkdownCodeEditor
|
<MarkdownCodeEditor
|
||||||
v-if="editingBlockId === block.id"
|
v-if="editingBlockId === block.id"
|
||||||
@@ -993,6 +1086,7 @@ defineExpose({
|
|||||||
@escape="cancelBlockEdit"
|
@escape="cancelBlockEdit"
|
||||||
@blur="finalizeBlockEdit"
|
@blur="finalizeBlockEdit"
|
||||||
@paste="onBlockPaste"
|
@paste="onBlockPaste"
|
||||||
|
@backspace-at-start="mergeCurrentBlockWithPrevious"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
@@ -1054,7 +1148,7 @@ defineExpose({
|
|||||||
.markdown-preview h3 { font-size: 1.125em; }
|
.markdown-preview h3 { font-size: 1.125em; }
|
||||||
.markdown-preview h4 { font-size: 1em; }
|
.markdown-preview h4 { font-size: 1em; }
|
||||||
|
|
||||||
.markdown-preview p { margin: 0.5em 0; }
|
.markdown-preview p { margin: 0.8em 0; text-indent: var(--paragraph-indent, 0); }
|
||||||
|
|
||||||
.markdown-preview strong { font-weight: 700; color: var(--app-text-strong); }
|
.markdown-preview strong { font-weight: 700; color: var(--app-text-strong); }
|
||||||
.markdown-preview em { font-style: italic; }
|
.markdown-preview em { font-style: italic; }
|
||||||
@@ -1157,6 +1251,10 @@ defineExpose({
|
|||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-preview:not(.markdown-mixed) img {
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Mixed mode block editing ────────────────────────────────────── */
|
/* ── Mixed mode block editing ────────────────────────────────────── */
|
||||||
|
|
||||||
.markdown-block {
|
.markdown-block {
|
||||||
@@ -1174,15 +1272,16 @@ defineExpose({
|
|||||||
box-shadow 0.15s ease;
|
box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-block:not(.markdown-block-editing):hover > .markdown-block-preview > * {
|
|
||||||
background-color: var(--app-surface-muted);
|
|
||||||
cursor: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-block-editing {
|
.markdown-block-editing {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-mixed
|
||||||
|
> .markdown-block-paragraph
|
||||||
|
+ .markdown-block-paragraph.markdown-block-editing {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-block-editing,
|
.markdown-block-editing,
|
||||||
.markdown-block-editing:hover {
|
.markdown-block-editing:hover {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface SyncResult {
|
|||||||
deletedRemote: number;
|
deletedRemote: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
};
|
};
|
||||||
|
files: SyncFileDetail[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SyncConnectionTestResult {
|
interface SyncConnectionTestResult {
|
||||||
@@ -48,6 +49,27 @@ interface SyncConnectionTestResult {
|
|||||||
details: string[];
|
details: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SyncConfigSaveResult {
|
||||||
|
configPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncFileAction = "upload" | "download" | "deleteLocal" | "deleteRemote" | "skip";
|
||||||
|
|
||||||
|
interface SyncFileDetail {
|
||||||
|
path: string;
|
||||||
|
action: SyncFileAction;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncFeedback {
|
||||||
|
kind: "progress" | "save" | "test" | "sync" | "error";
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
details?: string[];
|
||||||
|
configPath?: string;
|
||||||
|
result?: SyncResult;
|
||||||
|
}
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -55,12 +77,16 @@ const props = withDefaults(
|
|||||||
workspacePath?: string;
|
workspacePath?: string;
|
||||||
defaultDocumentMode?: DocumentMode;
|
defaultDocumentMode?: DocumentMode;
|
||||||
appTheme?: AppTheme;
|
appTheme?: AppTheme;
|
||||||
|
renderSingleLineBreaks?: boolean;
|
||||||
|
paragraphIndent?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
initialPage: "about",
|
initialPage: "about",
|
||||||
workspacePath: "",
|
workspacePath: "",
|
||||||
defaultDocumentMode: "edit",
|
defaultDocumentMode: "edit",
|
||||||
appTheme: "warm",
|
appTheme: "warm",
|
||||||
|
renderSingleLineBreaks: false,
|
||||||
|
paragraphIndent: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -68,6 +94,8 @@ const emit = defineEmits<{
|
|||||||
"update:show": [value: boolean];
|
"update:show": [value: boolean];
|
||||||
updateDefaultDocumentMode: [mode: DocumentMode];
|
updateDefaultDocumentMode: [mode: DocumentMode];
|
||||||
updateAppTheme: [theme: AppTheme];
|
updateAppTheme: [theme: AppTheme];
|
||||||
|
updateRenderSingleLineBreaks: [value: boolean];
|
||||||
|
updateParagraphIndent: [value: boolean];
|
||||||
prepareWorkspaceSync: [done: (saved: boolean) => void];
|
prepareWorkspaceSync: [done: (saved: boolean) => void];
|
||||||
workspaceSynced: [];
|
workspaceSynced: [];
|
||||||
}>();
|
}>();
|
||||||
@@ -83,8 +111,7 @@ const autoUpdate = ref(loadBooleanPref(AUTO_UPDATE_KEY, true));
|
|||||||
const language = ref(loadStringPref(LANGUAGE_KEY, "zh-Hans"));
|
const language = ref(loadStringPref(LANGUAGE_KEY, "zh-Hans"));
|
||||||
const updateStatus = ref("");
|
const updateStatus = ref("");
|
||||||
const syncConfig = ref<SyncConfig>(createDefaultSyncConfig());
|
const syncConfig = ref<SyncConfig>(createDefaultSyncConfig());
|
||||||
const syncStatus = ref("");
|
const syncFeedback = ref<SyncFeedback | null>(null);
|
||||||
const syncError = ref("");
|
|
||||||
const syncSaving = ref(false);
|
const syncSaving = ref(false);
|
||||||
const syncTesting = ref(false);
|
const syncTesting = ref(false);
|
||||||
const syncRunning = ref(false);
|
const syncRunning = ref(false);
|
||||||
@@ -98,6 +125,30 @@ const pageTitle = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isSyncBusy = computed(() => syncSaving.value || syncTesting.value || syncRunning.value);
|
const isSyncBusy = computed(() => syncSaving.value || syncTesting.value || syncRunning.value);
|
||||||
|
const syncProviderLabel = computed(() => {
|
||||||
|
if (syncConfig.value.provider === "webdav") return "WebDAV";
|
||||||
|
if (syncConfig.value.provider === "s3") return "S3 兼容";
|
||||||
|
return "关闭同步";
|
||||||
|
});
|
||||||
|
const syncTargetDescription = computed(() => {
|
||||||
|
if (syncConfig.value.provider === "webdav") {
|
||||||
|
const endpoint = syncConfig.value.webdav.endpoint.trim() || "未填写服务器地址";
|
||||||
|
const remoteDir = syncConfig.value.webdav.remoteDir.trim() || "/";
|
||||||
|
return `远端目标:${endpoint} · ${remoteDir}`;
|
||||||
|
}
|
||||||
|
if (syncConfig.value.provider === "s3") {
|
||||||
|
const bucket = syncConfig.value.s3.bucket.trim() || "未填写 Bucket";
|
||||||
|
const prefix = syncConfig.value.s3.remotePrefix.trim() || "/";
|
||||||
|
return `远端目标:Bucket ${bucket} · ${prefix}`;
|
||||||
|
}
|
||||||
|
return "同步已关闭";
|
||||||
|
});
|
||||||
|
const syncFileDetails = computed(() => {
|
||||||
|
const files = syncFeedback.value?.result?.files || [];
|
||||||
|
return files
|
||||||
|
.filter((file) => file.action !== "skip")
|
||||||
|
.sort((left, right) => left.path.localeCompare(right.path));
|
||||||
|
});
|
||||||
|
|
||||||
const menuItems: Array<{
|
const menuItems: Array<{
|
||||||
id: SettingsPage;
|
id: SettingsPage;
|
||||||
@@ -270,33 +321,62 @@ function onDefaultDocumentModeChange(event: Event) {
|
|||||||
emit("updateDefaultDocumentMode", mode);
|
emit("updateDefaultDocumentMode", mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSyncStatus(message: string, isError = false) {
|
function setSyncError(message: string, title = "操作失败") {
|
||||||
if (isError) {
|
syncFeedback.value = {
|
||||||
syncError.value = message;
|
kind: "error",
|
||||||
syncStatus.value = "";
|
title,
|
||||||
} else {
|
summary: message,
|
||||||
syncStatus.value = message;
|
};
|
||||||
syncError.value = "";
|
}
|
||||||
|
|
||||||
|
function syncActionLabel(action: SyncFileAction) {
|
||||||
|
if (action === "upload") return "上传";
|
||||||
|
if (action === "download") return "下载";
|
||||||
|
if (action === "deleteLocal") return "删除本地";
|
||||||
|
if (action === "deleteRemote") return "删除远端";
|
||||||
|
return "未变更";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) {
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
|
||||||
}
|
}
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSyncConfig() {
|
async function loadSyncConfig() {
|
||||||
try {
|
try {
|
||||||
const config = await invoke<SyncConfig>("load_sync_config");
|
const config = await invoke<SyncConfig>("load_sync_config");
|
||||||
syncConfig.value = mergeSyncConfig(config);
|
syncConfig.value = mergeSyncConfig(config);
|
||||||
syncError.value = "";
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSyncStatus(`读取同步配置失败: ${error}`, true);
|
setSyncError(`读取同步配置失败: ${error}`, "无法读取同步配置");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSyncConfig() {
|
async function saveSyncConfig() {
|
||||||
syncSaving.value = true;
|
syncSaving.value = true;
|
||||||
|
syncFeedback.value = {
|
||||||
|
kind: "progress",
|
||||||
|
title: "正在保存配置",
|
||||||
|
summary: `正在保存 ${syncProviderLabel.value} 配置。`,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
await invoke("save_sync_config", { config: syncConfig.value });
|
const result = await invoke<SyncConfigSaveResult>("save_sync_config", { config: syncConfig.value });
|
||||||
setSyncStatus("同步配置已保存");
|
syncFeedback.value = {
|
||||||
|
kind: "save",
|
||||||
|
title: "同步配置已保存",
|
||||||
|
summary: `已保存 ${syncProviderLabel.value} 配置。`,
|
||||||
|
configPath: result.configPath,
|
||||||
|
details: [
|
||||||
|
syncTargetDescription.value,
|
||||||
|
`启动时自动同步:${syncConfig.value.autoSyncOnStartup ? "开启" : "关闭"}`,
|
||||||
|
`保存后自动同步:${syncConfig.value.autoSyncAfterSave ? "开启" : "关闭"}`,
|
||||||
|
],
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSyncStatus(`保存失败: ${error}`, true);
|
setSyncError(String(error), "保存同步配置失败");
|
||||||
} finally {
|
} finally {
|
||||||
syncSaving.value = false;
|
syncSaving.value = false;
|
||||||
}
|
}
|
||||||
@@ -304,36 +384,45 @@ async function saveSyncConfig() {
|
|||||||
|
|
||||||
async function testSyncConnection() {
|
async function testSyncConnection() {
|
||||||
syncTesting.value = true;
|
syncTesting.value = true;
|
||||||
|
syncFeedback.value = {
|
||||||
|
kind: "progress",
|
||||||
|
title: "正在测试连接",
|
||||||
|
summary: `正在检查 ${syncProviderLabel.value} 服务的读取、写入和删除权限。`,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const result = await invoke<SyncConnectionTestResult>("test_sync_connection", { config: syncConfig.value });
|
const result = await invoke<SyncConnectionTestResult>("test_sync_connection", { config: syncConfig.value });
|
||||||
setSyncStatus([result.message, ...result.details.map((item) => `- ${item}`)].join("\n"));
|
syncFeedback.value = {
|
||||||
|
kind: "test",
|
||||||
|
title: "连接测试通过",
|
||||||
|
summary: result.message,
|
||||||
|
details: result.details,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSyncStatus(String(error), true);
|
setSyncError(String(error), "连接测试失败");
|
||||||
} finally {
|
} finally {
|
||||||
syncTesting.value = false;
|
syncTesting.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSyncResult(result: SyncResult) {
|
|
||||||
const { stats } = result;
|
|
||||||
return `同步完成:上传 ${stats.uploaded},下载 ${stats.downloaded},删除本地 ${stats.deletedLocal},删除远端 ${stats.deletedRemote},跳过 ${stats.skipped}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runSyncNow() {
|
async function runSyncNow() {
|
||||||
if (!props.workspacePath) {
|
if (!props.workspacePath) {
|
||||||
setSyncStatus("请先打开一个工作目录", true);
|
setSyncError("请先打开一个工作目录", "无法开始同步");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
syncRunning.value = true;
|
syncRunning.value = true;
|
||||||
try {
|
try {
|
||||||
setSyncStatus("正在同步:扫描本地和远端文件,远端列表请求最长等待 120 秒。");
|
syncFeedback.value = {
|
||||||
|
kind: "progress",
|
||||||
|
title: "正在同步",
|
||||||
|
summary: "正在扫描本地和远端文件,远端列表请求最长等待 120 秒。",
|
||||||
|
};
|
||||||
await invoke("save_sync_config", { config: syncConfig.value });
|
await invoke("save_sync_config", { config: syncConfig.value });
|
||||||
const prepared = await new Promise<boolean>((resolve) => {
|
const prepared = await new Promise<boolean>((resolve) => {
|
||||||
emit("prepareWorkspaceSync", resolve);
|
emit("prepareWorkspaceSync", resolve);
|
||||||
});
|
});
|
||||||
if (!prepared) {
|
if (!prepared) {
|
||||||
setSyncStatus("同步已取消,当前文档尚未保存", true);
|
setSyncError("当前文档尚未保存,同步已取消。", "同步未开始");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,10 +431,16 @@ async function runSyncNow() {
|
|||||||
config: syncConfig.value,
|
config: syncConfig.value,
|
||||||
});
|
});
|
||||||
syncConfig.value.lastSyncAt = result.lastSyncAt;
|
syncConfig.value.lastSyncAt = result.lastSyncAt;
|
||||||
setSyncStatus(formatSyncResult(result));
|
const changedCount = result.stats.uploaded + result.stats.downloaded + result.stats.deletedLocal + result.stats.deletedRemote;
|
||||||
|
syncFeedback.value = {
|
||||||
|
kind: "sync",
|
||||||
|
title: "同步完成",
|
||||||
|
summary: changedCount > 0 ? `本次同步处理了 ${changedCount} 个变更文件。` : "本地与远端内容已一致,没有文件需要变更。",
|
||||||
|
result,
|
||||||
|
};
|
||||||
emit("workspaceSynced");
|
emit("workspaceSynced");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSyncStatus(`同步失败: ${error}`, true);
|
setSyncError(String(error), "同步失败");
|
||||||
} finally {
|
} finally {
|
||||||
syncRunning.value = false;
|
syncRunning.value = false;
|
||||||
}
|
}
|
||||||
@@ -357,6 +452,7 @@ watch(
|
|||||||
if (!show) return;
|
if (!show) return;
|
||||||
activePage.value = props.initialPage;
|
activePage.value = props.initialPage;
|
||||||
updateStatus.value = "";
|
updateStatus.value = "";
|
||||||
|
syncFeedback.value = null;
|
||||||
void loadAppVersion();
|
void loadAppVersion();
|
||||||
void loadSyncConfig();
|
void loadSyncConfig();
|
||||||
},
|
},
|
||||||
@@ -421,6 +517,9 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<div v-if="activePage === 'about'" class="settings-content">
|
<div v-if="activePage === 'about'" class="settings-content">
|
||||||
<section class="settings-about-card" aria-label="关于 yurou">
|
<section class="settings-about-card" aria-label="关于 yurou">
|
||||||
|
<div class="settings-about-logo">
|
||||||
|
<img src="/yurou-logo.png" alt="yurou logo" class="settings-logo-img" />
|
||||||
|
</div>
|
||||||
<div class="settings-row settings-row-top">
|
<div class="settings-row settings-row-top">
|
||||||
<div class="settings-copy">
|
<div class="settings-copy">
|
||||||
<h3>版本 {{ appVersion }}</h3>
|
<h3>版本 {{ appVersion }}</h3>
|
||||||
@@ -497,6 +596,34 @@ onBeforeUnmount(() => {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-copy">
|
||||||
|
<h3>单换行显示为换行</h3>
|
||||||
|
<p>开启后,预览和混合模式会保留普通回车产生的换行,不修改 Markdown 原文。</p>
|
||||||
|
</div>
|
||||||
|
<label class="settings-switch" title="单换行显示为换行">
|
||||||
|
<input
|
||||||
|
:checked="renderSingleLineBreaks"
|
||||||
|
type="checkbox"
|
||||||
|
@change="emit('updateRenderSingleLineBreaks', ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
<span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-copy">
|
||||||
|
<h3>段落首行缩进</h3>
|
||||||
|
<p>开启后,预览和混合模式下段落首行会缩进两个字符,更符合中文排版习惯。</p>
|
||||||
|
</div>
|
||||||
|
<label class="settings-switch" title="段落首行缩进">
|
||||||
|
<input
|
||||||
|
:checked="paragraphIndent"
|
||||||
|
type="checkbox"
|
||||||
|
@change="emit('updateParagraphIndent', ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
<span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -669,41 +796,119 @@ onBeforeUnmount(() => {
|
|||||||
<p>
|
<p>
|
||||||
{{ syncConfig.lastSyncAt ? `上次同步:${syncConfig.lastSyncAt}` : '尚未同步' }}
|
{{ syncConfig.lastSyncAt ? `上次同步:${syncConfig.lastSyncAt}` : '尚未同步' }}
|
||||||
</p>
|
</p>
|
||||||
<div
|
|
||||||
v-if="syncStatus || syncError"
|
|
||||||
class="settings-sync-log"
|
|
||||||
:class="{ 'settings-sync-log-error': Boolean(syncError) }"
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
{{ syncError || syncStatus }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-action-stack">
|
<div class="settings-action-stack">
|
||||||
|
<div class="settings-config-actions" aria-label="同步配置操作">
|
||||||
|
<button
|
||||||
|
class="settings-secondary-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="isSyncBusy"
|
||||||
|
@click="saveSyncConfig"
|
||||||
|
>
|
||||||
|
<i :class="syncSaving ? 'ri-loader-4-line settings-spin' : 'ri-save-3-line'"></i>
|
||||||
|
{{ syncSaving ? '保存中' : '保存配置' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="settings-secondary-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="isSyncBusy || syncConfig.provider === 'none'"
|
||||||
|
@click="testSyncConnection"
|
||||||
|
>
|
||||||
|
<i :class="syncTesting ? 'ri-loader-4-line settings-spin' : 'ri-pulse-line'"></i>
|
||||||
|
{{ syncTesting ? '测试中' : '测试连接' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
class="settings-secondary-btn"
|
class="settings-primary-btn settings-sync-now-btn"
|
||||||
type="button"
|
|
||||||
:disabled="isSyncBusy"
|
|
||||||
@click="saveSyncConfig"
|
|
||||||
>
|
|
||||||
{{ syncSaving ? '保存中' : '保存配置' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="settings-secondary-btn"
|
|
||||||
type="button"
|
|
||||||
:disabled="isSyncBusy || syncConfig.provider === 'none'"
|
|
||||||
@click="testSyncConnection"
|
|
||||||
>
|
|
||||||
{{ syncTesting ? '测试中' : '测试连接' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="settings-primary-btn"
|
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="isSyncBusy || syncConfig.provider === 'none' || !workspacePath"
|
:disabled="isSyncBusy || syncConfig.provider === 'none' || !workspacePath"
|
||||||
@click="runSyncNow"
|
@click="runSyncNow"
|
||||||
>
|
>
|
||||||
|
<i :class="syncRunning ? 'ri-loader-4-line settings-spin' : 'ri-loop-left-line'"></i>
|
||||||
{{ syncRunning ? '同步中' : '立即同步' }}
|
{{ syncRunning ? '同步中' : '立即同步' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-if="syncFeedback"
|
||||||
|
class="settings-sync-feedback"
|
||||||
|
:class="{
|
||||||
|
'settings-sync-feedback-error': syncFeedback.kind === 'error',
|
||||||
|
'settings-sync-feedback-progress': syncFeedback.kind === 'progress',
|
||||||
|
}"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div class="settings-sync-feedback-header">
|
||||||
|
<i
|
||||||
|
:class="
|
||||||
|
syncFeedback.kind === 'error'
|
||||||
|
? 'ri-error-warning-line'
|
||||||
|
: syncFeedback.kind === 'progress'
|
||||||
|
? 'ri-loader-4-line settings-spin'
|
||||||
|
: 'ri-checkbox-circle-line'
|
||||||
|
"
|
||||||
|
></i>
|
||||||
|
<div>
|
||||||
|
<strong>{{ syncFeedback.title }}</strong>
|
||||||
|
<p>{{ syncFeedback.summary }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="syncFeedback.configPath" class="settings-config-path">
|
||||||
|
<span>配置保存位置</span>
|
||||||
|
<code :title="syncFeedback.configPath">{{ syncFeedback.configPath }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-if="syncFeedback.details?.length" class="settings-feedback-details">
|
||||||
|
<li v-for="detail in syncFeedback.details" :key="detail">
|
||||||
|
<i class="ri-check-line"></i>
|
||||||
|
<span>{{ detail }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<template v-if="syncFeedback.result">
|
||||||
|
<dl class="settings-sync-stats" aria-label="同步数量汇总">
|
||||||
|
<div>
|
||||||
|
<dt>上传</dt>
|
||||||
|
<dd>{{ syncFeedback.result.stats.uploaded }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>下载</dt>
|
||||||
|
<dd>{{ syncFeedback.result.stats.downloaded }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>删除本地</dt>
|
||||||
|
<dd>{{ syncFeedback.result.stats.deletedLocal }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>删除远端</dt>
|
||||||
|
<dd>{{ syncFeedback.result.stats.deletedRemote }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>未变更</dt>
|
||||||
|
<dd>{{ syncFeedback.result.stats.skipped }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="settings-sync-file-section">
|
||||||
|
<div class="settings-sync-file-heading">
|
||||||
|
<strong>变更文件</strong>
|
||||||
|
<span>{{ syncFileDetails.length }} 个文件</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="syncFileDetails.length" class="settings-sync-file-list">
|
||||||
|
<div v-for="file in syncFileDetails" :key="`${file.action}:${file.path}`" class="settings-sync-file">
|
||||||
|
<span class="settings-sync-file-action" :data-action="file.action">
|
||||||
|
{{ syncActionLabel(file.action) }}
|
||||||
|
</span>
|
||||||
|
<code :title="file.path">{{ file.path }}</code>
|
||||||
|
<span class="settings-sync-file-size">{{ formatFileSize(file.size) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="settings-sync-file-empty">本次同步没有文件变更。</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -892,6 +1097,20 @@ onBeforeUnmount(() => {
|
|||||||
box-shadow: inset 0 0 0 1px var(--app-border);
|
box-shadow: inset 0 0 0 1px var(--app-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-about-logo {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 28px 20px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-logo-img {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-row {
|
.settings-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -948,9 +1167,16 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.settings-action-stack {
|
.settings-action-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
width: 244px;
|
||||||
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 7px;
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-config-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-primary-btn,
|
.settings-primary-btn,
|
||||||
@@ -963,6 +1189,7 @@ onBeforeUnmount(() => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
gap: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
background-color 0.15s ease,
|
background-color 0.15s ease,
|
||||||
@@ -983,7 +1210,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-secondary-btn {
|
.settings-secondary-btn {
|
||||||
min-width: 50px;
|
min-width: 0;
|
||||||
border: 1px solid var(--app-border-strong);
|
border: 1px solid var(--app-border-strong);
|
||||||
background: var(--app-surface);
|
background: var(--app-surface);
|
||||||
color: var(--app-text);
|
color: var(--app-text);
|
||||||
@@ -1149,28 +1376,224 @@ onBeforeUnmount(() => {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-sync-log {
|
.settings-sync-now-btn {
|
||||||
width: min(100%, 420px);
|
width: 100%;
|
||||||
max-height: 138px;
|
}
|
||||||
margin-top: 10px;
|
|
||||||
overflow: auto;
|
.settings-sync-feedback {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
border: 1px solid color-mix(in srgb, var(--app-accent) 26%, var(--app-border));
|
border: 1px solid color-mix(in srgb, var(--app-accent) 26%, var(--app-border));
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: color-mix(in srgb, var(--app-accent) 7%, var(--app-surface));
|
background: color-mix(in srgb, var(--app-accent) 7%, var(--app-surface));
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-feedback-error {
|
||||||
|
border-color: color-mix(in srgb, #dc2626 32%, var(--app-border));
|
||||||
|
background: color-mix(in srgb, #dc2626 7%, var(--app-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-feedback-progress {
|
||||||
|
border-color: var(--app-border-strong);
|
||||||
|
background: var(--app-surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-feedback-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 18px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-feedback-header > i {
|
||||||
|
margin-top: 1px;
|
||||||
color: var(--app-accent);
|
color: var(--app-accent);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-feedback-error .settings-sync-feedback-header > i,
|
||||||
|
.settings-sync-feedback-error .settings-sync-feedback-header strong {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-feedback-header strong,
|
||||||
|
.settings-sync-file-heading strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--app-text-strong);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-feedback-header p,
|
||||||
|
.settings-sync-file-empty {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
color: var(--app-text-soft);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
padding: 9px 10px;
|
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-sync-log-error {
|
.settings-config-path {
|
||||||
border-color: color-mix(in srgb, #dc2626 32%, var(--app-border));
|
display: grid;
|
||||||
background: color-mix(in srgb, #dc2626 7%, var(--app-surface));
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid var(--app-border-strong);
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-config-path span,
|
||||||
|
.settings-sync-file-heading span,
|
||||||
|
.settings-sync-file-size {
|
||||||
|
color: var(--app-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-config-path code {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--app-text);
|
||||||
|
font-family: var(--app-font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feedback-details {
|
||||||
|
display: flex;
|
||||||
|
margin: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feedback-details li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 16px minmax(0, 1fr);
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--app-text-soft);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feedback-details i {
|
||||||
|
color: var(--app-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
margin: 0;
|
||||||
|
border-top: 1px solid var(--app-border-strong);
|
||||||
|
border-bottom: 1px solid var(--app-border-strong);
|
||||||
|
padding: 9px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-stats > div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-left: 1px solid var(--app-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-stats > div:first-child {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-stats dt {
|
||||||
|
color: var(--app-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-stats dd {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
color: var(--app-text-strong);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-file-section {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-file-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-file-list {
|
||||||
|
display: flex;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--app-border-strong);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: color-mix(in srgb, var(--app-surface) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-file {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 64px minmax(0, 1fr) auto;
|
||||||
|
gap: 9px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
border-top: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-file:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-file-action {
|
||||||
|
color: var(--app-accent);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-file-action[data-action="deleteLocal"],
|
||||||
|
.settings-sync-file-action[data-action="deleteRemote"] {
|
||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-sync-file-action[data-action="skip"] {
|
||||||
|
color: var(--app-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-file code {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--app-text);
|
||||||
|
font-family: var(--app-font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-spin {
|
||||||
|
animation: settings-spin 0.85s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes settings-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.theme-option-grid {
|
.theme-option-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -1289,11 +1712,45 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-action-stack {
|
.settings-action-stack {
|
||||||
|
width: 100%;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-config-actions,
|
||||||
|
.settings-sync-now-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-config-path {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-stats {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
row-gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-stats > div:nth-child(4) {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-file {
|
||||||
|
grid-template-columns: 58px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sync-file-size {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-switch-group {
|
.settings-switch-group {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.settings-spin {
|
||||||
|
animation-duration: 1.8s;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,12 +15,18 @@ interface DirEntry {
|
|||||||
children?: DirEntry[];
|
children?: DirEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TreeExpansionCommand {
|
||||||
|
id: number;
|
||||||
|
expanded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const TREE_CONTEXT_MENU_CLOSE_EVENT = "yurou-tree-context-menu-close";
|
const TREE_CONTEXT_MENU_CLOSE_EVENT = "yurou-tree-context-menu-close";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
entry: DirEntry;
|
entry: DirEntry;
|
||||||
depth: number;
|
depth: number;
|
||||||
currentFilePath?: string | null;
|
currentFilePath?: string | null;
|
||||||
|
expansionCommand?: TreeExpansionCommand | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -33,6 +39,7 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const expanded = ref(true);
|
const expanded = ref(true);
|
||||||
|
const handledExpansionCommandId = ref(0);
|
||||||
const contextMenuOpen = ref(false);
|
const contextMenuOpen = ref(false);
|
||||||
const contextMenuPoint = ref<{ x: number; y: number } | null>(null);
|
const contextMenuPoint = ref<{ x: number; y: number } | null>(null);
|
||||||
const contextMenuStyle = computed(() => {
|
const contextMenuStyle = computed(() => {
|
||||||
@@ -76,12 +83,35 @@ function onContextMenuKeydown(event: KeyboardEvent) {
|
|||||||
watch(
|
watch(
|
||||||
() => props.entry.children,
|
() => props.entry.children,
|
||||||
(children, oldChildren) => {
|
(children, oldChildren) => {
|
||||||
if (children !== undefined && oldChildren === undefined) {
|
if (
|
||||||
|
children !== undefined &&
|
||||||
|
oldChildren === undefined &&
|
||||||
|
props.expansionCommand?.expanded !== false
|
||||||
|
) {
|
||||||
expanded.value = true;
|
expanded.value = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.expansionCommand,
|
||||||
|
(command) => {
|
||||||
|
if (
|
||||||
|
!command ||
|
||||||
|
!props.entry.is_dir ||
|
||||||
|
command.id <= handledExpansionCommandId.value
|
||||||
|
) return;
|
||||||
|
|
||||||
|
handledExpansionCommandId.value = command.id;
|
||||||
|
expanded.value = command.expanded;
|
||||||
|
|
||||||
|
if (command.expanded && props.entry.children === undefined) {
|
||||||
|
emit("toggleFolder", props.entry.path);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
const isActive = computed(() => {
|
const isActive = computed(() => {
|
||||||
return props.currentFilePath != null && props.currentFilePath === props.entry.path;
|
return props.currentFilePath != null && props.currentFilePath === props.entry.path;
|
||||||
});
|
});
|
||||||
@@ -307,6 +337,7 @@ function onContextMenu(event: MouseEvent) {
|
|||||||
:entry="child"
|
:entry="child"
|
||||||
:depth="depth + 1"
|
:depth="depth + 1"
|
||||||
:currentFilePath="currentFilePath"
|
:currentFilePath="currentFilePath"
|
||||||
|
:expansion-command="expansionCommand"
|
||||||
@toggle-folder="(path: string) => emit('toggleFolder', path)"
|
@toggle-folder="(path: string) => emit('toggleFolder', path)"
|
||||||
@open-file="(path: string) => emit('openFile', path)"
|
@open-file="(path: string) => emit('openFile', path)"
|
||||||
@create-folder="(path: string) => emit('createFolder', path)"
|
@create-folder="(path: string) => emit('createFolder', path)"
|
||||||
|
|||||||
100
src/lib/codeHighlight.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import type { HighlighterCore, LanguageInput, ThemedToken } from "shiki/core";
|
||||||
|
|
||||||
|
type LanguageLoader = () => Promise<{ default: LanguageInput }>;
|
||||||
|
|
||||||
|
const languageLoaders: Record<string, LanguageLoader> = {
|
||||||
|
bash: () => import("shiki/langs/bash.mjs"),
|
||||||
|
c: () => import("shiki/langs/c.mjs"),
|
||||||
|
cpp: () => import("shiki/langs/cpp.mjs"),
|
||||||
|
csharp: () => import("shiki/langs/csharp.mjs"),
|
||||||
|
css: () => import("shiki/langs/css.mjs"),
|
||||||
|
dockerfile: () => import("shiki/langs/dockerfile.mjs"),
|
||||||
|
go: () => import("shiki/langs/go.mjs"),
|
||||||
|
html: () => import("shiki/langs/html.mjs"),
|
||||||
|
java: () => import("shiki/langs/java.mjs"),
|
||||||
|
javascript: () => import("shiki/langs/javascript.mjs"),
|
||||||
|
json: () => import("shiki/langs/json.mjs"),
|
||||||
|
jsx: () => import("shiki/langs/jsx.mjs"),
|
||||||
|
kotlin: () => import("shiki/langs/kotlin.mjs"),
|
||||||
|
markdown: () => import("shiki/langs/markdown.mjs"),
|
||||||
|
php: () => import("shiki/langs/php.mjs"),
|
||||||
|
powershell: () => import("shiki/langs/powershell.mjs"),
|
||||||
|
python: () => import("shiki/langs/python.mjs"),
|
||||||
|
ruby: () => import("shiki/langs/ruby.mjs"),
|
||||||
|
rust: () => import("shiki/langs/rust.mjs"),
|
||||||
|
scss: () => import("shiki/langs/scss.mjs"),
|
||||||
|
sql: () => import("shiki/langs/sql.mjs"),
|
||||||
|
swift: () => import("shiki/langs/swift.mjs"),
|
||||||
|
toml: () => import("shiki/langs/toml.mjs"),
|
||||||
|
tsx: () => import("shiki/langs/tsx.mjs"),
|
||||||
|
typescript: () => import("shiki/langs/typescript.mjs"),
|
||||||
|
vue: () => import("shiki/langs/vue.mjs"),
|
||||||
|
xml: () => import("shiki/langs/xml.mjs"),
|
||||||
|
yaml: () => import("shiki/langs/yaml.mjs"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let highlighterPromise: Promise<HighlighterCore> | null = null;
|
||||||
|
const languagePromises = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
async function getCodeHighlighter() {
|
||||||
|
if (!highlighterPromise) {
|
||||||
|
highlighterPromise = Promise.all([
|
||||||
|
import("shiki/core"),
|
||||||
|
import("shiki/engine/javascript"),
|
||||||
|
]).then(([core, engine]) =>
|
||||||
|
core.createHighlighterCore({
|
||||||
|
themes: [
|
||||||
|
core.createCssVariablesTheme({
|
||||||
|
name: "yurou-code",
|
||||||
|
variablePrefix: "--code-",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
langs: [],
|
||||||
|
engine: engine.createJavaScriptRegexEngine(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlighterPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCodeLanguage(highlighter: HighlighterCore, language: string) {
|
||||||
|
if (highlighter.getLoadedLanguages().includes(language)) return;
|
||||||
|
|
||||||
|
let promise = languagePromises.get(language);
|
||||||
|
if (!promise) {
|
||||||
|
const loader = languageLoaders[language];
|
||||||
|
if (!loader) return;
|
||||||
|
promise = loader().then((module) => highlighter.loadLanguage(module.default));
|
||||||
|
languagePromises.set(language, promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function highlightCodeToHtml(code: string, language: string) {
|
||||||
|
const highlighter = await getCodeHighlighter();
|
||||||
|
await loadCodeLanguage(highlighter, language);
|
||||||
|
|
||||||
|
if (!highlighter.getLoadedLanguages().includes(language)) return null;
|
||||||
|
|
||||||
|
return highlighter.codeToHtml(code, {
|
||||||
|
lang: language,
|
||||||
|
theme: "yurou-code",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function highlightCodeToTokens(
|
||||||
|
code: string,
|
||||||
|
language: string,
|
||||||
|
): Promise<ThemedToken[][] | null> {
|
||||||
|
const highlighter = await getCodeHighlighter();
|
||||||
|
await loadCodeLanguage(highlighter, language);
|
||||||
|
|
||||||
|
if (!highlighter.getLoadedLanguages().includes(language)) return null;
|
||||||
|
|
||||||
|
return highlighter.codeToTokens(code, {
|
||||||
|
lang: language,
|
||||||
|
theme: "yurou-code",
|
||||||
|
}).tokens;
|
||||||
|
}
|
||||||
96
src/lib/markdownLanguages.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { pickedCompletion, type Completion } from "@codemirror/autocomplete";
|
||||||
|
|
||||||
|
export interface MarkdownCodeLanguage {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
aliases: string[];
|
||||||
|
shiki: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const markdownCodeLanguages: MarkdownCodeLanguage[] = [
|
||||||
|
{ label: "text", name: "Plain text", aliases: ["txt", "plaintext"], shiki: "text" },
|
||||||
|
{ label: "javascript", name: "JavaScript", aliases: ["js", "node"], shiki: "javascript" },
|
||||||
|
{ label: "typescript", name: "TypeScript", aliases: ["ts"], shiki: "typescript" },
|
||||||
|
{ label: "jsx", name: "JSX", aliases: [], shiki: "jsx" },
|
||||||
|
{ label: "tsx", name: "TSX", aliases: [], shiki: "tsx" },
|
||||||
|
{ label: "vue", name: "Vue", aliases: ["vuejs"], shiki: "vue" },
|
||||||
|
{ label: "html", name: "HTML", aliases: ["htm"], shiki: "html" },
|
||||||
|
{ label: "css", name: "CSS", aliases: [], shiki: "css" },
|
||||||
|
{ label: "scss", name: "SCSS", aliases: ["sass"], shiki: "scss" },
|
||||||
|
{ label: "json", name: "JSON", aliases: ["jsonc"], shiki: "json" },
|
||||||
|
{ label: "yaml", name: "YAML", aliases: ["yml"], shiki: "yaml" },
|
||||||
|
{ label: "markdown", name: "Markdown", aliases: ["md"], shiki: "markdown" },
|
||||||
|
{ label: "python", name: "Python", aliases: ["py"], shiki: "python" },
|
||||||
|
{ label: "java", name: "Java", aliases: [], shiki: "java" },
|
||||||
|
{ label: "kotlin", name: "Kotlin", aliases: ["kt"], shiki: "kotlin" },
|
||||||
|
{ label: "c", name: "C", aliases: [], shiki: "c" },
|
||||||
|
{ label: "cpp", name: "C++", aliases: ["c++"], shiki: "cpp" },
|
||||||
|
{ label: "csharp", name: "C#", aliases: ["cs", "c#"], shiki: "csharp" },
|
||||||
|
{ label: "go", name: "Go", aliases: ["golang"], shiki: "go" },
|
||||||
|
{ label: "rust", name: "Rust", aliases: ["rs"], shiki: "rust" },
|
||||||
|
{ label: "php", name: "PHP", aliases: [], shiki: "php" },
|
||||||
|
{ label: "ruby", name: "Ruby", aliases: ["rb"], shiki: "ruby" },
|
||||||
|
{ label: "swift", name: "Swift", aliases: [], shiki: "swift" },
|
||||||
|
{ label: "sql", name: "SQL", aliases: [], shiki: "sql" },
|
||||||
|
{ label: "bash", name: "Bash", aliases: ["sh", "shell", "zsh"], shiki: "bash" },
|
||||||
|
{ label: "powershell", name: "PowerShell", aliases: ["ps1", "pwsh"], shiki: "powershell" },
|
||||||
|
{ label: "dockerfile", name: "Dockerfile", aliases: ["docker"], shiki: "dockerfile" },
|
||||||
|
{ label: "xml", name: "XML", aliases: [], shiki: "xml" },
|
||||||
|
{ label: "toml", name: "TOML", aliases: [], shiki: "toml" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const languageLookup = new Map<string, MarkdownCodeLanguage>();
|
||||||
|
|
||||||
|
for (const language of markdownCodeLanguages) {
|
||||||
|
languageLookup.set(language.label, language);
|
||||||
|
for (const alias of language.aliases) {
|
||||||
|
languageLookup.set(alias, language);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMarkdownCodeLanguage(value: string | null | undefined) {
|
||||||
|
const normalized = value?.trim().toLowerCase().split(/\s+/, 1)[0] || "";
|
||||||
|
return languageLookup.get(normalized) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECENT_LANGUAGES_KEY = "markdown-recent-code-languages";
|
||||||
|
|
||||||
|
function readRecentLanguages() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(RECENT_LANGUAGES_KEY);
|
||||||
|
const value = stored ? JSON.parse(stored) : [];
|
||||||
|
return Array.isArray(value)
|
||||||
|
? value.filter((item): item is string => typeof item === "string").slice(0, 5)
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberMarkdownCodeLanguage(label: string) {
|
||||||
|
const next = [label, ...readRecentLanguages().filter((item) => item !== label)].slice(0, 5);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(RECENT_LANGUAGES_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
// Completion remains available when storage is disabled.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMarkdownLanguageCompletions(): Completion[] {
|
||||||
|
const recent = readRecentLanguages();
|
||||||
|
|
||||||
|
return markdownCodeLanguages.map((language, index) => ({
|
||||||
|
label: language.label,
|
||||||
|
displayLabel: language.name,
|
||||||
|
detail: language.aliases.length > 0 ? language.aliases.join(", ") : language.label,
|
||||||
|
type: "keyword",
|
||||||
|
boost: recent.includes(language.label) ? 100 - recent.indexOf(language.label) : -index,
|
||||||
|
apply(view, completion, from, to) {
|
||||||
|
rememberMarkdownCodeLanguage(language.label);
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from, to, insert: language.label },
|
||||||
|
annotations: pickedCompletion.of(completion),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||