Compare commits

..

10 Commits

Author SHA1 Message Date
81a0484654 完善预览功能 2026-07-23 00:48:48 +08:00
63d1f0b729 完善功能 2026-07-21 18:49:30 +08:00
abd9f2912f 完善功能 2026-07-20 18:10:56 +08:00
0f75a3a6af 完善功能 2026-07-20 18:10:08 +08:00
05898a14e0 完善编辑功能 2026-07-20 17:47:24 +08:00
f725261370 完善预览功能 2026-07-19 23:50:02 +08:00
3c6b142098 添加预览图片功能 2026-07-19 19:15:22 +08:00
164e658e5b 完善编辑功能 2026-07-16 17:59:56 +08:00
c5a5a4e55b 完善编辑功能 2026-07-16 16:40:00 +08:00
87e594be52 完善 2026-07-16 15:14:28 +08:00
80 changed files with 12502 additions and 575 deletions

View File

@@ -58,5 +58,23 @@
"userTurns": 4,
"basisHash": "aaae120b1f33c2f2",
"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
}
}

View File

@@ -1,14 +1,5 @@
{
"topic_20260630-144952_fd0584fe8f266d6a": 1782830992641,
"topic_20260703-022850_50330b4eb93f116d": 1783045730616,
"topic_20260706-145507_076a32df5f4276b5": 1783349707873,
"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
"topic_20260707-131941_481a70d2be9881dd": 1783430381000,
"topic_20260720-095943_8dd2c8de3f143692": 1784541583642,
"topic_20260722-163413_8ebd34b08091d260": 1784738053267
}

View File

@@ -38,6 +38,7 @@
"topic_20260707-093602_5c75f77654cab0c3": "manual",
"topic_20260707-094645_05255aaa45d5f673": "manual",
"topic_20260707-095503_bf879b65e4666516": "auto",
"topic_20260707-131941_481a70d2be9881dd": "manual",
"topic_20260708-020616_9ab54d98daf65053": "auto",
"topic_20260708-093419_71d1fea2e4b5a40f": "auto",
"topic_20260708-095428_990617acd4168b40": "auto",
@@ -48,5 +49,7 @@
"topic_20260710-034724_3c6fb29d8d0a4119": "auto",
"topic_20260710-054543_5e7c427932aecd7b": "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"
}

View File

@@ -38,6 +38,8 @@
"topic_20260707-093602_5c75f77654cab0c3": "隐藏单栏和双栏的切换功能把mark…",
"topic_20260707-094645_05255aaa45d5f673": "帮我移除markdownEditor…",
"topic_20260707-095503_bf879b65e4666516": "帮我移除markdownEditor…",
"topic_20260707-131941_481a70d2be9881dd": "新的会话",
"topic_20260707-132824_49b096d569ffa3d3": "现在帮我把这个markdownEdi…",
"topic_20260708-020616_9ab54d98daf65053": "帮我分析一下现在的markdownE…",
"topic_20260708-093419_71d1fea2e4b5a40f": "src/components/Dir…",
"topic_20260708-095428_990617acd4168b40": "帮我检查一下这个应用在进行放大缩小的…",
@@ -48,5 +50,7 @@
"topic_20260710-034724_3c6fb29d8d0a4119": "目录树中鼠标悬浮弹框dropmenu…",
"topic_20260710-054543_5e7c427932aecd7b": "现在在markdown的编辑区域中…",
"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底部区域…"
}

View File

@@ -6,6 +6,6 @@ This template should help get you started developing with Vue 3 and TypeScript i
- [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"
hello

View File

@@ -172,9 +172,38 @@ MarkdownEditor.vue
- **parseBlocks** 每次 modelValue 变化时调用,包含 N 次 `processSync`N = 块数)
- 典型文档50 块)约需 50-100ms在可接受范围内
- 后续可优化为增量更新(只重新渲染变化的块)
- **textarea auto-resize** 通过 `scrollHeight` 动态调整高度,无额外 DOM 操作
- **CodeMirror 实例数** 完整编辑模式保持一个实例;混合模式只为当前活动块创建实例
- **双栏滚动同步** 使用比例计算,避免频繁的绝对位置计算
## 代码编辑体验
Markdown 源码编辑由 CodeMirror 6 提供,完整编辑模式与混合模式的活动块复用
`MarkdownCodeEditor.vue`。组件继续通过 `update:modelValue` 传递纯 Markdown 字符串,
不会改变外层的保存和文件切换数据流。
### 编辑能力
- Markdown 语法着色,以及围栏代码块内部的语言着色
- 括号、引号自动闭合列表续行Tab/Shift+Tab 缩进
- 输入三个反引号或波浪号后自动打开语言提示
- 语言提示支持继续输入筛选、方向键选择、Enter 确认和 Ctrl+Space 手动触发
- 围栏语言确认后按 Enter 自动补齐结束围栏,并将光标放在代码区域
- 最近使用的五种语言通过 localStorage 排在提示列表前面
语言名称、别名和 Shiki 映射集中定义在 `src/lib/markdownLanguages.ts`
无法识别的围栏语言按纯文本处理,不阻断 Markdown 渲染或编辑。
### 预览高亮
预览仍先经过 unified 和 rehype-sanitize。安全过滤完成后可信的 Shiki 高亮器
替换 `<pre><code>` 节点,因此不会放宽 Markdown 原始 HTML 的安全策略。
Shiki 核心和语言语法均按需加载:只有文档包含代码块时才初始化高亮器,只有某种
语言第一次出现在预览中时才加载对应语法。高亮器和已加载语言会在应用生命周期内
复用。Shiki 与 CodeMirror 共用 `--code-*` 语义色变量,每套应用主题同时定义界面色
和代码色CodeMirror 围栏内容还复用 Shiki 的 token 划分,确保混合模式点击前后逐词
颜色一致。切换主题时预览与活动编辑器会即时使用新颜色,无需重新解析文档。
## 样式体系
| 层级 | 设计 |
@@ -284,8 +313,8 @@ MarkdownEditor.vue
paste 事件同时绑定在两种模式的 textarea 上:
- **edit 模式**`@paste="onEditorPaste"`操作 `props.modelValue` + `editorTextareaRef`
- **mixed 模式**`@paste="onBlockPaste"`操作 `editingSource` + `blockTextareaRef`(包含空文档和 block 编辑两种场景)
- **edit 模式**`@paste="onEditorPaste"`按 CodeMirror 当前选区替换 `modelValue`
- **mixed 模式**`@paste="onBlockPaste"`按活动块 CodeMirror 选区替换 `editingSource`
粘贴后的 markdown 语法统一为:
```markdown

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<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" />
<title>Markdown Editor</title>
</head>

9531
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,16 @@
"tauri": "tauri"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-markdown": "6.4.0",
"@codemirror/language": "^6.12.4",
"@codemirror/language-data": "^6.5.2",
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.6",
"@fancyapps/ui": "^6.1.14",
"@lezer/highlight": "^1.2.3",
"@lucide/vue": "^1.23.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
@@ -20,11 +30,13 @@
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
"reka-ui": "^2.10.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"remixicon": "^4.9.1",
"shadcn-vue": "^2.7.4",
"shiki": "^4.3.1",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"unified": "^11.0.0",

709
pnpm-lock.yaml generated
View File

@@ -4,10 +4,43 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
'@codemirror/lang-markdown': 6.4.0
importers:
.:
dependencies:
'@codemirror/autocomplete':
specifier: ^6.20.3
version: 6.20.3
'@codemirror/commands':
specifier: ^6.10.4
version: 6.10.4
'@codemirror/lang-markdown':
specifier: 6.4.0
version: 6.4.0
'@codemirror/language':
specifier: ^6.12.4
version: 6.12.4
'@codemirror/language-data':
specifier: ^6.5.2
version: 6.5.2
'@codemirror/search':
specifier: ^6.7.1
version: 6.7.1
'@codemirror/state':
specifier: ^6.7.1
version: 6.7.1
'@codemirror/view':
specifier: ^6.43.6
version: 6.43.6
'@fancyapps/ui':
specifier: ^6.1.14
version: 6.1.14
'@lezer/highlight':
specifier: ^1.2.3
version: 1.2.3
'@lucide/vue':
specifier: ^1.23.0
version: 1.23.0(vue@3.5.39(typescript@5.6.3))
@@ -38,6 +71,9 @@ importers:
reka-ui:
specifier: ^2.10.1
version: 2.10.1(vue@3.5.39(typescript@5.6.3))
remark-breaks:
specifier: ^4.0.0
version: 4.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
@@ -53,6 +89,9 @@ importers:
shadcn-vue:
specifier: ^2.7.4
version: 2.7.4(eslint@10.6.0(jiti@2.7.0))(vue@3.5.39(typescript@5.6.3))
shiki:
specifier: ^4.3.1
version: 4.3.1
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
@@ -224,6 +263,96 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
'@codemirror/autocomplete@6.20.3':
resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
'@codemirror/commands@6.10.4':
resolution: {integrity: sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==}
'@codemirror/lang-angular@0.1.4':
resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==}
'@codemirror/lang-cpp@6.0.3':
resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==}
'@codemirror/lang-css@6.3.1':
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
'@codemirror/lang-go@6.0.1':
resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==}
'@codemirror/lang-html@6.4.11':
resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
'@codemirror/lang-java@6.0.2':
resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==}
'@codemirror/lang-javascript@6.2.5':
resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==}
'@codemirror/lang-jinja@6.0.1':
resolution: {integrity: sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==}
'@codemirror/lang-json@6.0.2':
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
'@codemirror/lang-less@6.0.2':
resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==}
'@codemirror/lang-liquid@6.3.2':
resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==}
'@codemirror/lang-markdown@6.4.0':
resolution: {integrity: sha512-ZeArR54seh4laFbUTVy0ZmQgO+C/cxxlW4jEoQMhL3HALScBpZBeZcLzrQmJsTEx4is9GzOe0bFAke2B1KZqeA==}
'@codemirror/lang-php@6.0.2':
resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==}
'@codemirror/lang-python@6.2.1':
resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==}
'@codemirror/lang-rust@6.0.2':
resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==}
'@codemirror/lang-sass@6.0.2':
resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==}
'@codemirror/lang-sql@6.10.0':
resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
'@codemirror/lang-vue@0.1.3':
resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==}
'@codemirror/lang-wast@6.0.2':
resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==}
'@codemirror/lang-xml@6.1.0':
resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==}
'@codemirror/lang-yaml@6.1.3':
resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==}
'@codemirror/language-data@6.5.2':
resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==}
'@codemirror/language@6.12.4':
resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==}
'@codemirror/legacy-modes@6.5.3':
resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==}
'@codemirror/lint@6.9.7':
resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
'@codemirror/search@6.7.1':
resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==}
'@codemirror/state@6.7.1':
resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==}
'@codemirror/view@6.43.6':
resolution: {integrity: sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==}
'@dotenvx/dotenvx@1.75.1':
resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==}
hasBin: true
@@ -417,6 +546,9 @@ packages:
resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
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':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
@@ -485,11 +617,65 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lezer/common@1.5.2':
resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
'@lezer/cpp@1.1.6':
resolution: {integrity: sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==}
'@lezer/css@1.3.4':
resolution: {integrity: sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==}
'@lezer/go@1.0.1':
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
'@lezer/highlight@1.2.3':
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
'@lezer/html@1.3.13':
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
'@lezer/java@1.1.3':
resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==}
'@lezer/javascript@1.5.4':
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
'@lezer/json@1.0.3':
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
'@lezer/lr@1.4.10':
resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==}
'@lezer/markdown@1.7.1':
resolution: {integrity: sha512-MEBZeFSBxgteUjEC3Wxg2Dwld5/JxRKG267L3bMFdibm8KjqSdiJYBeFw1Nt1CM8+zKMpSIEHblY8FD9z38sJQ==}
'@lezer/php@1.0.5':
resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==}
'@lezer/python@1.1.19':
resolution: {integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==}
'@lezer/rust@1.0.2':
resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==}
'@lezer/sass@1.1.0':
resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==}
'@lezer/xml@1.0.6':
resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==}
'@lezer/yaml@1.0.4':
resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
'@lucide/vue@1.23.0':
resolution: {integrity: sha512-9SIYeY5K+R1iv8F8JUKMGSL7Pck/86BJ8djtZz/lnYsoHtKFUj1H2z6+PvKnC/8blZ8tqTDeDXAoTKobuX80hg==}
peerDependencies:
vue: '>=3.0.1'
'@marijn/find-cluster-break@1.0.3':
resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==}
'@modelcontextprotocol/sdk@1.29.0':
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
engines: {node: '>=18'}
@@ -654,6 +840,37 @@ packages:
cpu: [x64]
os: [win32]
'@shikijs/core@4.3.1':
resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==}
engines: {node: '>=20'}
'@shikijs/engine-javascript@4.3.1':
resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==}
engines: {node: '>=20'}
'@shikijs/engine-oniguruma@4.3.1':
resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==}
engines: {node: '>=20'}
'@shikijs/langs@4.3.1':
resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==}
engines: {node: '>=20'}
'@shikijs/primitive@4.3.1':
resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==}
engines: {node: '>=20'}
'@shikijs/themes@4.3.1':
resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==}
engines: {node: '>=20'}
'@shikijs/types@4.3.1':
resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==}
engines: {node: '>=20'}
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
@@ -859,6 +1076,9 @@ packages:
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/hast@3.0.5':
resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -1210,6 +1430,9 @@ packages:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
crelt@1.0.7:
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1624,6 +1847,7 @@ packages:
glob@11.0.0:
resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==}
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
glob@7.2.0:
@@ -1997,6 +2221,9 @@ packages:
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
mdast-util-newline-to-break@2.0.0:
resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==}
mdast-util-phrasing@4.1.0:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
@@ -2223,6 +2450,12 @@ packages:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
oniguruma-parser@0.12.2:
resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
oniguruma-to-es@4.3.6:
resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
open@10.2.0:
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
engines: {node: '>=18'}
@@ -2410,6 +2643,15 @@ packages:
resolution: {integrity: sha512-CkfWKhQiYsMQYaWUkHdERXUxT2jJLBoa5y7zFv3dUAE7Ly5oU/0hsqrENyEfrCL03pDsQYbnoz17Cbagx/c2OA==}
engines: {node: '>= 4'}
regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
regex-utilities@2.3.0:
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
regex@6.1.0:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
rehype-sanitize@6.0.0:
resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
@@ -2421,6 +2663,9 @@ packages:
peerDependencies:
vue: '>= 3.4.0'
remark-breaks@4.0.0:
resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@@ -2514,6 +2759,10 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shiki@4.3.1:
resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==}
engines: {node: '>=20'}
side-channel-list@1.0.1:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
@@ -2571,10 +2820,6 @@ packages:
resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==}
engines: {node: '>=18'}
string-width@4.2.0:
resolution: {integrity: sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==}
engines: {node: '>=8'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -2606,6 +2851,9 @@ packages:
resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
engines: {node: '>=6'}
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
stylus@0.57.0:
resolution: {integrity: sha512-yOI6G8WYfr0q8v8rRvE91wbxFU+rJPo760Va4MF6K0I6BZjO4r+xSynkvyPBP9tV1CIEUeRsiidjIs2rzb1CnQ==}
hasBin: true
@@ -2826,6 +3074,9 @@ packages:
typescript:
optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
web-worker@1.5.0:
resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==}
@@ -3070,6 +3321,257 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@codemirror/autocomplete@6.20.3':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.6
'@lezer/common': 1.5.2
'@codemirror/commands@6.10.4':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.6
'@lezer/common': 1.5.2
'@codemirror/lang-angular@0.1.4':
dependencies:
'@codemirror/lang-html': 6.4.11
'@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.4
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@codemirror/lang-cpp@6.0.3':
dependencies:
'@codemirror/language': 6.12.4
'@lezer/cpp': 1.1.6
'@codemirror/lang-css@6.3.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@lezer/common': 1.5.2
'@lezer/css': 1.3.4
'@codemirror/lang-go@6.0.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@lezer/common': 1.5.2
'@lezer/go': 1.0.1
'@codemirror/lang-html@6.4.11':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.6
'@lezer/common': 1.5.2
'@lezer/css': 1.3.4
'@lezer/html': 1.3.13
'@codemirror/lang-java@6.0.2':
dependencies:
'@codemirror/language': 6.12.4
'@lezer/java': 1.1.3
'@codemirror/lang-javascript@6.2.5':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.6
'@lezer/common': 1.5.2
'@lezer/javascript': 1.5.4
'@codemirror/lang-jinja@6.0.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@codemirror/lang-json@6.0.2':
dependencies:
'@codemirror/language': 6.12.4
'@lezer/json': 1.0.3
'@codemirror/lang-less@6.0.2':
dependencies:
'@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.4
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@codemirror/lang-liquid@6.3.2':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@codemirror/lang-markdown@6.4.0':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.6
'@lezer/common': 1.5.2
'@lezer/markdown': 1.7.1
'@codemirror/lang-php@6.0.2':
dependencies:
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@lezer/common': 1.5.2
'@lezer/php': 1.0.5
'@codemirror/lang-python@6.2.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@lezer/common': 1.5.2
'@lezer/python': 1.1.19
'@codemirror/lang-rust@6.0.2':
dependencies:
'@codemirror/language': 6.12.4
'@lezer/rust': 1.0.2
'@codemirror/lang-sass@6.0.2':
dependencies:
'@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@lezer/common': 1.5.2
'@lezer/sass': 1.1.0
'@codemirror/lang-sql@6.10.0':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@codemirror/lang-vue@0.1.3':
dependencies:
'@codemirror/lang-html': 6.4.11
'@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.4
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@codemirror/lang-wast@6.0.2':
dependencies:
'@codemirror/language': 6.12.4
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@codemirror/lang-xml@6.1.0':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.6
'@lezer/common': 1.5.2
'@lezer/xml': 1.0.6
'@codemirror/lang-yaml@6.1.3':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/yaml': 1.0.4
'@codemirror/language-data@6.5.2':
dependencies:
'@codemirror/lang-angular': 0.1.4
'@codemirror/lang-cpp': 6.0.3
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-go': 6.0.1
'@codemirror/lang-html': 6.4.11
'@codemirror/lang-java': 6.0.2
'@codemirror/lang-javascript': 6.2.5
'@codemirror/lang-jinja': 6.0.1
'@codemirror/lang-json': 6.0.2
'@codemirror/lang-less': 6.0.2
'@codemirror/lang-liquid': 6.3.2
'@codemirror/lang-markdown': 6.4.0
'@codemirror/lang-php': 6.0.2
'@codemirror/lang-python': 6.2.1
'@codemirror/lang-rust': 6.0.2
'@codemirror/lang-sass': 6.0.2
'@codemirror/lang-sql': 6.10.0
'@codemirror/lang-vue': 0.1.3
'@codemirror/lang-wast': 6.0.2
'@codemirror/lang-xml': 6.1.0
'@codemirror/lang-yaml': 6.1.3
'@codemirror/language': 6.12.4
'@codemirror/legacy-modes': 6.5.3
'@codemirror/language@6.12.4':
dependencies:
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
style-mod: 4.1.3
'@codemirror/legacy-modes@6.5.3':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/lint@6.9.7':
dependencies:
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.6
crelt: 1.0.7
'@codemirror/search@6.7.1':
dependencies:
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.6
crelt: 1.0.7
'@codemirror/state@6.7.1':
dependencies:
'@marijn/find-cluster-break': 1.0.3
'@codemirror/view@6.43.6':
dependencies:
'@codemirror/state': 6.7.1
crelt: 1.0.7
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@dotenvx/dotenvx@1.75.1':
dependencies:
'@dotenvx/primitives': 0.8.0
@@ -3199,6 +3701,8 @@ snapshots:
'@eslint/core': 1.2.1
levn: 0.4.1
'@fancyapps/ui@6.1.14': {}
'@floating-ui/core@1.7.5':
dependencies:
'@floating-ui/utils': 0.2.11
@@ -3250,7 +3754,7 @@ snapshots:
'@isaacs/cliui@8.0.2':
dependencies:
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-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
@@ -3277,10 +3781,105 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lezer/common@1.5.2': {}
'@lezer/cpp@1.1.6':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/css@1.3.4':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/go@1.0.1':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/highlight@1.2.3':
dependencies:
'@lezer/common': 1.5.2
'@lezer/html@1.3.13':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/java@1.1.3':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/javascript@1.5.4':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/json@1.0.3':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/lr@1.4.10':
dependencies:
'@lezer/common': 1.5.2
'@lezer/markdown@1.7.1':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/php@1.0.5':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/python@1.1.19':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/rust@1.0.2':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/sass@1.1.0':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/xml@1.0.6':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/yaml@1.0.4':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lucide/vue@1.23.0(vue@3.5.39(typescript@5.6.3))':
dependencies:
vue: 3.5.39(typescript@5.6.3)
'@marijn/find-cluster-break@1.0.3': {}
'@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
dependencies:
'@hono/node-server': 1.19.14(hono@4.12.28)
@@ -3393,6 +3992,46 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.62.2':
optional: true
'@shikijs/core@4.3.1':
dependencies:
'@shikijs/primitive': 4.3.1
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.6
'@shikijs/engine-oniguruma@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/primitive@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
'@shikijs/themes@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/types@4.3.1':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
'@shikijs/vscode-textmate@10.0.2': {}
'@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
@@ -3547,6 +4186,10 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
'@types/hast@3.0.5':
dependencies:
'@types/unist': 3.0.3
'@types/json-schema@7.0.15': {}
'@types/mdast@4.0.4':
@@ -3907,6 +4550,8 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
crelt@1.0.7: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -4406,7 +5051,7 @@ snapshots:
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
he@1.2.0: {}
@@ -4721,6 +5366,11 @@ snapshots:
transitivePeerDependencies:
- 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:
dependencies:
'@types/mdast': 4.0.4
@@ -5048,6 +5698,14 @@ snapshots:
dependencies:
mimic-function: 5.0.1
oniguruma-parser@0.12.2: {}
oniguruma-to-es@4.3.6:
dependencies:
oniguruma-parser: 0.12.2
regex: 6.1.0
regex-recursion: 6.0.2
open@10.2.0:
dependencies:
default-browser: 5.5.0
@@ -5235,6 +5893,16 @@ snapshots:
tiny-invariant: 1.3.3
tslib: 2.8.1
regex-recursion@6.0.2:
dependencies:
regex-utilities: 2.3.0
regex-utilities@2.3.0: {}
regex@6.1.0:
dependencies:
regex-utilities: 2.3.0
rehype-sanitize@6.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -5262,6 +5930,12 @@ snapshots:
transitivePeerDependencies:
- '@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:
dependencies:
'@types/mdast': 4.0.4
@@ -5449,6 +6123,17 @@ snapshots:
shebang-regex@3.0.0: {}
shiki@4.3.1:
dependencies:
'@shikijs/core': 4.3.1
'@shikijs/engine-javascript': 4.3.1
'@shikijs/engine-oniguruma': 4.3.1
'@shikijs/langs': 4.3.1
'@shikijs/themes': 4.3.1
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0
@@ -5506,12 +6191,6 @@ snapshots:
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:
dependencies:
emoji-regex: 8.0.0
@@ -5551,6 +6230,8 @@ snapshots:
strip-final-newline@2.0.0: {}
style-mod@4.1.3: {}
stylus@0.57.0:
dependencies:
css: 3.0.0
@@ -5771,6 +6452,8 @@ snapshots:
optionalDependencies:
typescript: 5.6.3
w3c-keyname@2.2.8: {}
web-worker@1.5.0: {}
which@2.0.1:
@@ -5786,7 +6469,7 @@ snapshots:
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@8.1.0:

View File

@@ -1,3 +1,5 @@
allowBuilds:
esbuild: true
vue-demi: true
overrides:
'@codemirror/lang-markdown': 6.4.0

BIN
public/yurou-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -8,6 +8,7 @@
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-destroy",
"core:window:allow-start-dragging",
"opener:default",
"dialog:default"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

53
src-tauri/nsis-hooks.nsh Normal file
View 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

View File

@@ -552,12 +552,37 @@ struct SyncStats {
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)]
#[serde(rename_all = "camelCase")]
struct SyncResult {
message: String,
last_sync_at: String,
stats: SyncStats,
files: Vec<SyncFileDetail>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SyncConfigSaveResult {
config_path: String,
}
#[derive(Debug, Clone, serde::Serialize)]
@@ -1173,6 +1198,7 @@ async fn sync_workspace(
let remote_files = scan_remote_files(&op).await?;
let mut history = load_sync_history(app, &key)?;
let mut stats = SyncStats::default();
let mut file_details = Vec::new();
let all_paths: BTreeSet<String> = local_files
.keys()
@@ -1199,6 +1225,11 @@ async fn sync_workspace(
)
.await?;
stats.uploaded += 1;
file_details.push(SyncFileDetail {
path: relative.clone(),
action: SyncFileAction::Upload,
size: local.map(|item| item.size).unwrap_or(0),
});
}
SyncAction::Download => {
let local_path = local_path_for(workspace, &relative);
@@ -1216,6 +1247,11 @@ async fn sync_workspace(
fs::write(&local_path, bytes)
.map_err(|e| format!("无法写入本地文件 {}: {}", local_path.display(), e))?;
stats.downloaded += 1;
file_details.push(SyncFileDetail {
path: relative.clone(),
action: SyncFileAction::Download,
size: remote.map(|item| item.size).unwrap_or(0),
});
}
SyncAction::DeleteLocal => {
let local_path = local_path_for(workspace, &relative);
@@ -1225,6 +1261,11 @@ async fn sync_workspace(
}
history.files.remove(&relative);
stats.deleted_local += 1;
file_details.push(SyncFileDetail {
path: relative.clone(),
action: SyncFileAction::DeleteLocal,
size: local.map(|item| item.size).unwrap_or(0),
});
continue;
}
SyncAction::DeleteRemote => {
@@ -1236,10 +1277,23 @@ async fn sync_workspace(
.await?;
history.files.remove(&relative);
stats.deleted_remote += 1;
file_details.push(SyncFileDetail {
path: relative.clone(),
action: SyncFileAction::DeleteRemote,
size: remote.map(|item| item.size).unwrap_or(0),
});
continue;
}
SyncAction::Skip => {
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(),
last_sync_at,
stats,
files: file_details,
})
}
@@ -1291,8 +1346,13 @@ fn load_sync_config(app: tauri::AppHandle) -> Result<SyncConfig, String> {
}
#[tauri::command]
fn save_sync_config(app: tauri::AppHandle, config: SyncConfig) -> Result<(), String> {
save_sync_config_to_disk(&app, &config)
fn save_sync_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]
@@ -1430,7 +1490,7 @@ pub fn run() {
let id = event.id().0.as_str();
match id {
"quit" => {
std::process::exit(0);
let _ = app_handle.emit("menu-quit", ());
}
"open" => {
let handle = app_handle.clone();

View File

@@ -34,10 +34,28 @@
"targets": ["nsis"],
"icon": [
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"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"
}
}
}
}

View File

@@ -1,8 +1,8 @@
<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 {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 DirectorySidebar from "./components/DirectorySidebar.vue";
import OutlinePanel from "./components/OutlinePanel.vue";
@@ -17,6 +17,8 @@ type WorkspaceFileType = "markdown" | "image" | "video";
type SyncProvider = "none" | "webdav" | "s3";
const DEFAULT_DOCUMENT_MODE_KEY = "yurou:default-document-mode";
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 IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif"]);
const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]);
@@ -79,7 +81,10 @@ const mediaPreviewSrc = ref("");
const mediaPreviewError = ref("");
const mediaPreviewKey = ref(0);
const saveStatus = ref("");
const saveNotice = ref("");
const saveNoticeType = ref<"success" | "error">("success");
const hasDraftDocument = ref(false);
let lastSavedContent = "";
// Folder state
interface DirEntry {
@@ -105,6 +110,36 @@ interface WorkspaceWatchErrorPayload {
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 {
provider: SyncProvider;
webdav: {
@@ -151,7 +186,10 @@ const WORKSPACE_REFRESH_DELAY_MS = 140;
let workspaceRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let workspaceRefreshInFlight = 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) ----
const ZOOM_MIN = 0.7;
@@ -207,7 +245,8 @@ const showOutline = ref(false);
const defaultDocumentMode = ref<DocumentMode>(loadDefaultDocumentMode());
const documentMode = ref<DocumentMode>(defaultDocumentMode.value);
const appTheme = ref<AppTheme>(loadAppTheme());
const showMoreMenu = ref(false);
const renderSingleLineBreaks = ref(loadRenderSingleLineBreaks());
const paragraphIndent = ref(loadParagraphIndent());
const settingsRequest = ref(0);
const settingsInitialPage = ref<"about" | "editor" | "sync">("about");
const markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined; scrollToLine: (line: number) => void } | null>(null);
@@ -230,6 +269,7 @@ type TitlebarDragState = {
const TITLEBAR_DRAG_THRESHOLD = 4;
let titlebarDragState: TitlebarDragState | null = null;
let unlistenWindowResize: (() => void) | null = null;
let unlistenWindowCloseRequested: (() => void) | null = null;
function isInteractiveTitlebarTarget(target: EventTarget | null) {
if (!(target instanceof Element)) return false;
@@ -321,6 +361,26 @@ function openSettings(page: "about" | "editor" | "sync" = "about") {
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) {
documentMode.value = mode;
}
@@ -337,23 +397,6 @@ function updateAppTheme(theme: AppTheme) {
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(() => {
applyAppTheme(appTheme.value);
applyZoomCssVars();
@@ -367,6 +410,11 @@ onMounted(() => {
}).catch((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();
});
@@ -378,7 +426,12 @@ onUnmounted(() => {
unlistenWindowResize();
unlistenWindowResize = null;
}
if (unlistenWindowCloseRequested) {
unlistenWindowCloseRequested();
unlistenWindowCloseRequested = null;
}
if (zoomIndicatorTimer) clearTimeout(zoomIndicatorTimer);
if (saveNoticeTimer) clearTimeout(saveNoticeTimer);
});
function stripMarkdown(md: string): string {
@@ -422,7 +475,9 @@ function setDocumentContent(md: string) {
}
function resetMarkdownEditorState() {
fileOpenRequest += 1;
setDocumentContent("");
lastSavedContent = "";
currentFilePath.value = null;
currentFileType.value = "markdown";
resetMediaPreviewState();
@@ -430,7 +485,6 @@ function resetMarkdownEditorState() {
saveStatus.value = "";
showOutline.value = false;
documentMode.value = defaultDocumentMode.value;
showMoreMenu.value = false;
editorResetKey.value += 1;
}
@@ -601,8 +655,19 @@ async function saveCurrentWorkspaceDocumentBeforeSwitch() {
const contentToSave = getLatestEditorContent();
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 } = {}) {
@@ -696,6 +761,7 @@ async function refreshCurrentOpenFileFromDisk() {
if (isMarkdownDocument.value) {
const fileContent = await invoke<string>("read_file", { path: currentFilePath.value });
setDocumentContent(fileContent);
lastSavedContent = fileContent;
hasDraftDocument.value = false;
editorResetKey.value += 1;
return;
@@ -713,36 +779,41 @@ async function refreshCurrentOpenFileFromDisk() {
}
async function runConfiguredSync(trigger: "startup" | "save" | "manual") {
if (!folderPath.value || syncInFlight) return false;
if (!folderPath.value || syncInFlight.value) return false;
const config = await loadSyncConfig();
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") {
const saved = await saveCurrentWorkspaceDocumentBeforeSwitch();
if (!saved) return false;
}
syncInFlight = true;
syncInFlight.value = true;
showSaveNotice("正在同步...", "success");
try {
const result = await invoke<SyncResult>("sync_now", {
workspace: folderPath.value,
config,
});
saveStatus.value = describeSyncResult(result);
setTimeout(() => {
saveStatus.value = "";
}, 3600);
showSaveNotice(describeSyncResult(result), "success");
await refreshWorkspaceTree();
await refreshCurrentOpenFileFromDisk();
return true;
} catch (e) {
console.error("Sync failed:", e);
saveStatus.value = `同步失败: ${e}`;
showSaveNotice(`同步失败: ${e}`, "error");
return false;
} finally {
syncInFlight = false;
syncInFlight.value = false;
}
}
@@ -826,10 +897,10 @@ function openMediaPreview(path: string, fileType: Extract<WorkspaceFileType, "im
mediaPreviewError.value = "";
mediaPreviewKey.value += 1;
setDocumentContent("");
lastSavedContent = "";
hasDraftDocument.value = false;
saveStatus.value = "";
showOutline.value = false;
showMoreMenu.value = false;
editorResetKey.value += 1;
}
@@ -984,6 +1055,15 @@ async function onRenameEntry(path: string, newName: 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);
if (fileType === "image" || fileType === "video") {
openMediaPreview(path, fileType);
@@ -992,9 +1072,11 @@ async function onOpenFile(path: string) {
try {
const fileContent = await invoke<string>("read_file", {path});
if (request !== fileOpenRequest) return;
currentFileType.value = "markdown";
resetMediaPreviewState();
setDocumentContent(fileContent);
lastSavedContent = fileContent;
currentFilePath.value = path;
hasDraftDocument.value = false;
saveStatus.value = "";
@@ -1009,7 +1091,29 @@ function getLatestEditorContent(contentOverride?: string) {
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;
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
try {
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;
} catch (e) {
console.error("Save dialog failed:", e);
showSaveNotice(`保存失败: ${e}`, "error");
return false;
}
}
try {
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(() => {
saveStatus.value = "";
}, 3000);
@@ -1040,6 +1152,7 @@ async function doSave(contentOverride?: string, options: { syncAfterSave?: boole
} catch (e) {
console.error("Save failed:", e);
saveStatus.value = `保存失败: ${e}`;
showSaveNotice(saveStatus.value, "error");
return false;
}
}
@@ -1051,10 +1164,15 @@ async function doSaveAs() {
try {
const chosen = await invoke<string | null>("pick_save_file");
if (!chosen) return; // user cancelled
if (!chosen) {
showSaveNotice("已取消另存为", "error");
return;
}
currentFilePath.value = chosen;
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(() => {
saveStatus.value = "";
}, 3000);
@@ -1063,13 +1181,19 @@ async function doSaveAs() {
} catch (e) {
console.error("Save failed:", 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";
resetMediaPreviewState();
setDocumentContent("");
lastSavedContent = "";
currentFilePath.value = null;
hasDraftDocument.value = true;
saveStatus.value = "";
@@ -1112,7 +1236,11 @@ registerTauriListener("menu-save-as", () => {
});
registerTauriListener("menu-new", () => {
doNew();
void doNew();
});
registerTauriListener("menu-quit", () => {
void closeWindow();
});
registerTauriListener("show-about", () => {
@@ -1147,6 +1275,8 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
:settings-initial-page="settingsInitialPage"
:default-document-mode="defaultDocumentMode"
:app-theme="appTheme"
:render-single-line-breaks="renderSingleLineBreaks"
:paragraph-indent="paragraphIndent"
:recent-workspaces="recentWorkspaces"
@open-folder="onOpenFolder"
@switch-workspace="(path: string) => switchWorkspace(path)"
@@ -1160,6 +1290,8 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
@search-result-click="handleSearchResultClick"
@update-default-document-mode="updateDefaultDocumentMode"
@update-app-theme="updateAppTheme"
@update-render-single-line-breaks="updateRenderSingleLineBreaks"
@update-paragraph-indent="updateParagraphIndent"
@prepare-workspace-sync="prepareWorkspaceSync"
@workspace-synced="handleWorkspaceSynced"
/>
@@ -1188,8 +1320,8 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
<i class="ri-menu-line text-base"></i>
</button>
</div>
<div v-else class="titlebar-brand app-muted-text shrink-0 pointer-events-none">
yurou
<div v-else class="titlebar-brand shrink-0 pointer-events-none">
<img src="/yurou-logo.png" alt="yurou" class="titlebar-logo" />
</div>
<!-- Center: Filename -->
@@ -1233,34 +1365,16 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
</button>
</div>
<!-- More menu -->
<div v-if="isMarkdownDocument" class="relative">
<button
class="toolbar-btn"
title="更多"
@click="showMoreMenu = !showMoreMenu"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="3" cy="8" r="1.5"/>
<circle cx="8" cy="8" r="1.5"/>
<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>
<!-- Sync button -->
<button
v-if="folderPath"
class="toolbar-btn"
:title="syncInFlight ? '正在同步...' : '同步工作目录'"
:disabled="syncInFlight"
@click="runConfiguredSync('manual')"
>
<i :class="syncInFlight ? 'ri-loader-4-line sync-spinning' : 'ri-loop-left-line'"></i>
</button>
</div>
<!-- Window controls -->
@@ -1307,6 +1421,8 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
:model-value="content"
:document-mode="documentMode"
:file-path="currentFilePath"
:render-single-line-breaks="renderSingleLineBreaks"
:paragraph-indent="paragraphIndent"
placeholder="输入 Markdown 内容... 支持标题粗体斜体代码块等"
@update:model-value="onUpdate"
@save-shortcut="doSave"
@@ -1379,6 +1495,18 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
{{ Math.round(editorZoom * 100) }}%
</div>
</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>
</div>
<!-- <footer class="app-footer backdrop-blur-sm">-->
@@ -1416,6 +1544,17 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
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-leave-active {
transition:
@@ -1465,6 +1604,48 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
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 {
@@ -1550,6 +1731,11 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
letter-spacing: 0;
}
.titlebar-logo {
height: 26px;
width: auto;
}
.titlebar-file-title {
width: 100%;
line-height: 1.2;
@@ -1658,38 +1844,6 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
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 {
background:
@@ -1720,4 +1874,15 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
text-decoration-color: currentColor;
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>

BIN
src/assets/yurou-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
src/assets/yurou-sys.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -21,6 +21,11 @@ interface WorkspaceEntry {
path: string;
}
interface TreeExpansionCommand {
id: number;
expanded: boolean;
}
const props = withDefaults(
defineProps<{
@@ -31,6 +36,8 @@ const props = withDefaults(
settingsInitialPage?: SettingsPage;
defaultDocumentMode?: DocumentMode;
appTheme?: AppTheme;
renderSingleLineBreaks?: boolean;
paragraphIndent?: boolean;
recentWorkspaces?: WorkspaceEntry[];
}>(),
{
@@ -39,6 +46,8 @@ const props = withDefaults(
settingsInitialPage: "about",
defaultDocumentMode: "edit",
appTheme: "warm",
renderSingleLineBreaks: false,
paragraphIndent: false,
recentWorkspaces: () => [],
},
);
@@ -55,6 +64,8 @@ const emit = defineEmits<{
renameEntry: [path: string, newName: string];
updateDefaultDocumentMode: [mode: DocumentMode];
updateAppTheme: [theme: AppTheme];
updateRenderSingleLineBreaks: [value: boolean];
updateParagraphIndent: [value: boolean];
prepareWorkspaceSync: [done: (saved: boolean) => void];
workspaceSynced: [];
"search-result-click": [filePath: string, lineNumber: number];
@@ -455,6 +466,7 @@ watch(showWorkspaceMenu, (open) => {
watch(() => props.folderPath, () => {
closeWorkspaceMenu();
treeExpansionCommand.value = null;
});
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>
@@ -597,6 +619,25 @@ function handleCreateRootFile() {
>
<i class="ri-file-add-line"></i>
</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>
<!-- File tree -->
<div
@@ -609,6 +650,7 @@ function handleCreateRootFile() {
:entry="entry"
:depth="0"
:currentFilePath="currentFilePath"
:expansion-command="treeExpansionCommand"
@toggle-folder="(path: string) => emit('toggleFolder', path)"
@open-file="(path: string) => emit('openFile', path)"
@create-folder="(path: string) => emit('createFolder', path)"
@@ -842,8 +884,12 @@ function handleCreateRootFile() {
:workspace-path="folderPath"
:default-document-mode="defaultDocumentMode"
:app-theme="appTheme"
:render-single-line-breaks="renderSingleLineBreaks"
:paragraph-indent="paragraphIndent"
@update-default-document-mode="(mode: DocumentMode) => emit('updateDefaultDocumentMode', mode)"
@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"
@workspace-synced="emit('workspaceSynced')"
/>
@@ -1090,6 +1136,13 @@ function handleCreateRootFile() {
color: var(--app-text);
}
.tree-toolbar-separator {
width: 1px;
height: 18px;
margin: 0 2px;
background: var(--app-border-strong);
}
.sidebar-search-input {
border: 1px solid var(--app-border-strong);
background: var(--app-hover);

View 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>

View File

@@ -3,17 +3,23 @@ import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
type ComponentPublicInstance,
} from "vue";
import { Fancybox } from "@fancyapps/ui";
import "@fancyapps/ui/dist/fancybox/fancybox.css";
import { unified, type Plugin, type Transformer } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { convertFileSrc, invoke, isTauri } from "@tauri-apps/api/core";
import MarkdownCodeEditor from "./MarkdownCodeEditor.vue";
import { highlightCodeToHtml } from "@/lib/codeHighlight";
import { resolveMarkdownCodeLanguage } from "@/lib/markdownLanguages";
type DocumentMode = "preview" | "edit" | "mixed";
type ClipboardMediaKind = "image" | "video";
@@ -29,6 +35,7 @@ const VIDEO_MIME_TYPES: Record<string, string> = {
};
interface MarkdownAstNode {
type?: string;
position?: {
start?: { offset?: number };
end?: { offset?: number };
@@ -51,12 +58,21 @@ interface MarkdownTree {
interface MarkdownBlock {
id: string;
type: string;
source: string;
html: string;
startOffset: number;
endOffset: number;
}
interface MarkdownCodeEditorApi {
focus: () => void;
focusAtEnd: () => void;
replaceRange: (text: string, from?: number, to?: number) => void;
scrollToLine: (lineNumber: number) => void;
setCursorPosition: (pos: number) => void;
}
const props = withDefaults(
defineProps<{
modelValue?: string;
@@ -64,6 +80,8 @@ const props = withDefaults(
documentMode?: DocumentMode;
previewOnly?: boolean;
filePath?: string | null;
renderSingleLineBreaks?: boolean;
paragraphIndent?: boolean;
}>(),
{
modelValue: "",
@@ -71,6 +89,8 @@ const props = withDefaults(
documentMode: undefined,
previewOnly: false,
filePath: null,
renderSingleLineBreaks: false,
paragraphIndent: false,
},
);
@@ -209,14 +229,23 @@ const mediaSanitizeSchema = {
tagNames: [...(defaultSchema.tagNames || []), "video"],
};
/** Full pipeline: markdown → HTML */
const md2html = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkLocalMedia)
.use(remarkRehype, { allowDangerousHtml: false })
.use(rehypeSanitize, mediaSanitizeSchema)
.use(rehypeStringify);
function createMarkdownProcessor(renderSingleLineBreaks: boolean) {
const processor = unified().use(remarkParse).use(remarkGfm);
if (renderSingleLineBreaks) {
processor.use(remarkBreaks);
}
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);
@@ -230,9 +259,9 @@ const renderedHtml = ref("");
const renderError = ref(false);
const markdownBlocks = ref<MarkdownBlock[]>([]);
const editorTextareaRef = ref<HTMLTextAreaElement>();
const editorCodeRef = ref<MarkdownCodeEditorApi>();
const scrollRef = ref<HTMLDivElement>();
const blockTextareaRef = ref<HTMLTextAreaElement | null>(null);
const blockCodeRef = ref<MarkdownCodeEditorApi>();
const EMPTY_BLOCK_ID = "__empty__";
const editingBlockId = ref<string | null>(null);
@@ -245,8 +274,12 @@ const pendingLocalModelUpdate = ref(false);
const isEditingEmptyDocument = computed(
() => editingBlockId.value === EMPTY_BLOCK_ID,
);
let textareaResizeFrame: number | null = null;
const previewParagraphStyle = computed(() => ({
"--paragraph-indent": props.paragraphIndent ? "2em" : "0",
}));
let modeScrollRestoreFrame: number | null = null;
let renderRequestId = 0;
const previewImageSelector = ".markdown-preview:not(.markdown-mixed) img";
interface ScrollSnapshot {
ratio: number;
@@ -276,7 +309,6 @@ function restoreScrollSnapshot(snapshot: ScrollSnapshot | null) {
function restoreScrollAfterModeChange(
snapshot: ScrollSnapshot | null,
mode: DocumentMode,
) {
if (!snapshot) return;
@@ -286,17 +318,10 @@ function restoreScrollAfterModeChange(
}
void nextTick(() => {
if (mode === "edit" && editorTextareaRef.value) {
resizeTextarea(editorTextareaRef.value);
}
restoreScrollSnapshot(snapshot);
modeScrollRestoreFrame = requestAnimationFrame(() => {
modeScrollRestoreFrame = null;
if (mode === "edit" && editorTextareaRef.value) {
resizeTextarea(editorTextareaRef.value);
}
restoreScrollSnapshot(snapshot);
});
});
@@ -304,21 +329,63 @@ function restoreScrollAfterModeChange(
function syncRenderedState(md: string, mode = activeDocumentMode.value) {
if (mode === "preview") {
renderFullHtml(md);
void renderFullHtml(md);
return;
}
if (mode === "mixed") {
parseBlocks(md);
void parseBlocks(md);
}
}
function renderFullHtml(md: string) {
async function highlightCodeBlocks(html: string) {
if (!html.includes("<pre><code")) return html;
const document = new DOMParser().parseFromString(html, "text/html");
const codeBlocks = Array.from(document.querySelectorAll("pre > code"));
await Promise.all(
codeBlocks.map(async (code) => {
const languageClass = Array.from(code.classList).find((className) =>
className.startsWith("language-"),
);
const requestedLanguage = languageClass?.slice("language-".length) || "text";
const language = resolveMarkdownCodeLanguage(requestedLanguage)?.shiki || "text";
code.parentElement?.setAttribute("data-language", requestedLanguage);
try {
const highlighted = await highlightCodeToHtml(code.textContent || "", language);
if (!highlighted) return;
const fragment = new DOMParser().parseFromString(highlighted, "text/html");
const highlightedPre = fragment.body.firstElementChild;
if (highlightedPre) {
highlightedPre.setAttribute("data-language", requestedLanguage);
code.parentElement?.replaceWith(highlightedPre);
}
} catch {
code.parentElement?.setAttribute("data-language", requestedLanguage);
}
}),
);
return document.body.innerHTML;
}
async function renderMarkdownHtml(md: string) {
const processor = props.renderSingleLineBreaks ? singleLineBreaksMd2html : standardMd2html;
const file = processor.processSync(md);
return highlightCodeBlocks(String(file.value));
}
async function renderFullHtml(md: string) {
const requestId = ++renderRequestId;
try {
const file = md2html.processSync(md);
renderedHtml.value = String(file.value);
const html = await renderMarkdownHtml(md);
if (requestId !== renderRequestId || activeDocumentMode.value !== "preview") return;
renderedHtml.value = html;
renderError.value = false;
} catch {
if (requestId !== renderRequestId) return;
renderedHtml.value = "";
renderError.value = true;
}
@@ -333,16 +400,16 @@ function escapeHtml(value: string) {
.replace(/'/g, "&#39;");
}
function renderBlockHtml(source: string) {
async function renderBlockHtml(source: string) {
try {
const file = md2html.processSync(source);
return String(file.value);
return await renderMarkdownHtml(source);
} catch {
return `<pre><code>${escapeHtml(source)}</code></pre>`;
}
}
function parseBlocks(md: string) {
async function parseBlocks(md: string) {
const requestId = ++renderRequestId;
if (!md.trim()) {
markdownBlocks.value = [];
renderError.value = false;
@@ -351,8 +418,8 @@ function parseBlocks(md: string) {
try {
const tree = mdParser.parse(md) as { children?: MarkdownAstNode[] };
const blocks = (tree.children ?? [])
.map((node, index): MarkdownBlock | null => {
const blockCandidates = (tree.children ?? [])
.map((node, index): Omit<MarkdownBlock, "html"> | null => {
const startOffset = node.position?.start?.offset;
const endOffset = node.position?.end?.offset;
@@ -367,22 +434,33 @@ function parseBlocks(md: string) {
const source = md.slice(startOffset, endOffset);
return {
id: `${index}-${startOffset}-${endOffset}`,
type: node.type || "unknown",
source,
html: renderBlockHtml(source),
startOffset,
endOffset,
};
})
.filter((block): block is MarkdownBlock => block !== null);
.filter((block): block is Omit<MarkdownBlock, "html"> => block !== null);
const blocks = await Promise.all(
blockCandidates.map(async (block) => ({
...block,
html: await renderBlockHtml(block.source),
})),
);
if (requestId !== renderRequestId || activeDocumentMode.value !== "mixed") return;
markdownBlocks.value = blocks;
renderError.value = false;
} catch {
const html = await renderBlockHtml(md);
if (requestId !== renderRequestId || activeDocumentMode.value !== "mixed") return;
markdownBlocks.value = [
{
id: "fallback-0",
type: "unknown",
source: md,
html: renderBlockHtml(md),
html,
startOffset: 0,
endOffset: md.length,
},
@@ -407,7 +485,6 @@ watch(
}
nextTick(() => {
scheduleEditorTextareaResize();
// 外部切换文件时重置滚动位置;本组件内编辑时保留当前位置。
if (shouldResetScroll && scrollRef.value) scrollRef.value.scrollTop = 0;
});
@@ -415,40 +492,18 @@ watch(
{ immediate: true },
);
// ── Editor textarea handlers ───────────────────────────────────────
watch(
() => props.renderSingleLineBreaks,
() => {
syncRenderedState(props.modelValue);
},
);
function setBlockTextareaRef(el: Element | ComponentPublicInstance | null) {
blockTextareaRef.value = el instanceof HTMLTextAreaElement ? el : null;
}
function resizeTextarea(ta: HTMLTextAreaElement) {
ta.style.height = "auto";
ta.style.height = `${ta.scrollHeight}px`;
}
function scheduleTextareaResize(ta: HTMLTextAreaElement | undefined | null) {
if (!ta) return;
if (textareaResizeFrame !== null) {
cancelAnimationFrame(textareaResizeFrame);
}
textareaResizeFrame = requestAnimationFrame(() => {
textareaResizeFrame = null;
if (ta.isConnected) resizeTextarea(ta);
});
}
function scheduleEditorTextareaResize() {
scheduleTextareaResize(editorTextareaRef.value);
}
// ── Editor handlers ────────────────────────────────────────────────
function focusBlockEditor() {
void nextTick(() => {
const ta = blockTextareaRef.value;
if (!ta) return;
resizeTextarea(ta);
ta.focus();
ta.selectionStart = ta.selectionEnd = ta.value.length;
blockCodeRef.value?.focusAtEnd();
});
}
@@ -461,54 +516,6 @@ function applyFullEditorValue(value: string) {
emitModelValue(value);
}
function onEditorInput(e: Event) {
const ta = e.target as HTMLTextAreaElement;
const val = ta.value;
applyFullEditorValue(val);
scheduleTextareaResize(ta);
}
function tryAutoPairForValue(
ta: HTMLTextAreaElement,
key: string,
value: string,
onChange: (nextValue: string) => void,
): boolean {
const pairs: Record<string, string> = {
"(": ")",
"[": "]",
"{": "}",
'"': '"',
"'": "'",
"`": "`",
};
const close = pairs[key];
if (!close) return false;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = value.substring(start, end);
const newVal =
value.substring(0, start) +
key +
selected +
close +
value.substring(end);
onChange(newVal);
void nextTick(() => {
ta.selectionStart = start + 1;
ta.selectionEnd = start + 1 + selected.length;
ta.focus();
});
return true;
}
function tryAutoPair(ta: HTMLTextAreaElement, key: string): boolean {
return tryAutoPairForValue(ta, key, props.modelValue, applyFullEditorValue);
}
function isSaveShortcut(e: KeyboardEvent) {
return (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s";
}
@@ -526,36 +533,6 @@ function onRootKeydown(e: KeyboardEvent) {
}
}
function onEditorKeydown(e: KeyboardEvent) {
if (isSaveShortcut(e)) {
requestSave(e);
return;
}
const ta = e.target as HTMLTextAreaElement;
if (e.key === "Tab") {
e.preventDefault();
const start = ta.selectionStart;
const end = ta.selectionEnd;
const newVal =
props.modelValue.substring(0, start) +
" " +
props.modelValue.substring(end);
applyFullEditorValue(newVal);
void nextTick(() => {
ta.selectionStart = ta.selectionEnd = start + 2;
ta.focus();
});
return;
}
if (tryAutoPair(ta, e.key)) {
e.preventDefault();
return;
}
}
// ── Mixed mode block editing ───────────────────────────────────────
function startBlockEdit(block: MarkdownBlock) {
@@ -615,57 +592,61 @@ function finalizeBlockEdit(): string | undefined {
return nextValue;
}
function onBlockInput(e: Event) {
const ta = e.target as HTMLTextAreaElement;
editingSource.value = ta.value;
scheduleTextareaResize(ta);
}
function onBlockKeydown(e: KeyboardEvent) {
const ta = e.target as HTMLTextAreaElement;
if (isSaveShortcut(e)) {
requestSave(e);
return;
}
if (e.key === "Escape") {
e.preventDefault();
cancelBlockEdit();
return;
}
if (e.key === "Tab") {
e.preventDefault();
const start = ta.selectionStart;
const end = ta.selectionEnd;
editingSource.value =
editingSource.value.substring(0, start) +
" " +
editingSource.value.substring(end);
void nextTick(() => {
ta.selectionStart = ta.selectionEnd = start + 2;
scheduleTextareaResize(ta);
ta.focus();
});
return;
}
if (
tryAutoPairForValue(ta, e.key, editingSource.value, (nextValue) => {
editingSource.value = nextValue;
void nextTick(() => scheduleTextareaResize(ta));
})
) {
e.preventDefault();
}
}
function flushPendingChanges(): string | undefined {
if (!editingBlockId.value) return undefined;
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) => {
const scrollSnapshot = captureScrollSnapshot();
let committedValue: string | undefined;
@@ -675,17 +656,20 @@ watch(activeDocumentMode, (mode, oldMode) => {
syncRenderedState(committedValue ?? props.modelValue, mode);
if (mode === "edit") {
void nextTick(scheduleEditorTextareaResize);
}
restoreScrollAfterModeChange(scrollSnapshot);
});
restoreScrollAfterModeChange(scrollSnapshot, mode);
onMounted(() => {
Fancybox.bind(scrollRef.value ?? null, previewImageSelector, {
groupAll: true,
placeFocusBack: true,
theme: "auto",
});
});
onBeforeUnmount(() => {
if (textareaResizeFrame !== null) {
cancelAnimationFrame(textareaResizeFrame);
}
Fancybox.unbind(scrollRef.value ?? null, previewImageSelector);
if (modeScrollRestoreFrame !== null) {
cancelAnimationFrame(modeScrollRestoreFrame);
}
@@ -948,47 +932,38 @@ async function processPaste(
applyValue(newValue, cursorPos);
}
async function onEditorPaste(e: ClipboardEvent) {
const ta = editorTextareaRef.value;
if (!ta) return;
async function onEditorPaste(payload: {
event: ClipboardEvent;
value: string;
from: number;
to: number;
}) {
await processPaste(
e,
props.modelValue,
ta.selectionStart,
ta.selectionEnd,
payload.event,
payload.value,
payload.from,
payload.to,
(newValue, cursorPos) => {
applyFullEditorValue(newValue);
void nextTick(() => {
const ta = editorTextareaRef.value;
if (ta) {
ta.selectionStart = ta.selectionEnd = cursorPos;
ta.focus();
}
});
const insertedText = newValue.slice(payload.from, cursorPos);
editorCodeRef.value?.replaceRange(insertedText, payload.from, payload.to);
},
);
}
async function onBlockPaste(e: ClipboardEvent) {
const ta = blockTextareaRef.value;
if (!ta) return;
async function onBlockPaste(payload: {
event: ClipboardEvent;
value: string;
from: number;
to: number;
}) {
await processPaste(
e,
editingSource.value,
ta.selectionStart,
ta.selectionEnd,
payload.event,
payload.value,
payload.from,
payload.to,
(newValue, cursorPos) => {
editingSource.value = newValue;
void nextTick(() => {
const ta = blockTextareaRef.value;
if (ta) {
ta.selectionStart = ta.selectionEnd = cursorPos;
ta.focus();
scheduleTextareaResize(ta);
}
});
const insertedText = newValue.slice(payload.from, cursorPos);
blockCodeRef.value?.replaceRange(insertedText, payload.from, payload.to);
},
);
}
@@ -997,19 +972,7 @@ function scrollToLine(lineNumber: number) {
if (!scrollRef.value) return;
if (activeDocumentMode.value === "edit") {
const ta = editorTextareaRef.value;
if (ta) {
const lineHeight = 24;
const targetScroll = (lineNumber - 1) * lineHeight;
ta.scrollTop = Math.max(0, targetScroll - 80);
ta.focus();
const lines = ta.value.split("\n");
let pos = 0;
for (let i = 0; i < Math.min(lineNumber - 1, lines.length); i++) {
pos += lines[i].length + 1;
}
ta.setSelectionRange(pos, pos);
}
editorCodeRef.value?.scrollToLine(lineNumber);
} else {
const previewEl = scrollRef.value.querySelector(".markdown-preview") as HTMLElement;
if (previewEl) {
@@ -1045,25 +1008,21 @@ defineExpose({
@keydown.capture="onRootKeydown"
>
<div
class="mx-auto px-8 py-4 h-full"
class="mx-auto px-8 pt-4 pb-10 min-h-full"
:style="{
maxWidth: `calc(700px * var(--editor-zoom, 1))`,
fontSize: `calc(1rem * var(--editor-zoom, 1))`,
}"
>
<!-- Editor mode -->
<textarea
<MarkdownCodeEditor
v-if="activeDocumentMode === 'edit'"
ref="editorTextareaRef"
:value="props.modelValue"
ref="editorCodeRef"
:model-value="props.modelValue"
:placeholder="props.placeholder"
class="
markdown-source-editor
w-full min-h-[60vh] resize-none overflow-hidden bg-transparent outline-none
font-serif leading-relaxed
"
@input="onEditorInput"
@keydown="onEditorKeydown"
min-height="60vh"
class="markdown-source-editor w-full font-serif leading-relaxed"
@update:model-value="applyFullEditorValue"
@paste="onEditorPaste"
/>
@@ -1072,23 +1031,24 @@ defineExpose({
<div v-if="renderError" class="text-red-500 text-sm">
Markdown 解析错误
</div>
<div v-else class="markdown-preview py-2" v-html="renderedHtml" />
<div v-else class="markdown-preview" :style="previewParagraphStyle" v-html="renderedHtml" />
</div>
<!-- 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">
Markdown 解析错误
</div>
<textarea
<MarkdownCodeEditor
v-else-if="isEditingEmptyDocument"
:ref="setBlockTextareaRef"
:value="editingSource"
ref="blockCodeRef"
:model-value="editingSource"
:placeholder="props.placeholder"
rows="1"
autofocus
min-height="12rem"
class="markdown-block-editor"
@input="onBlockInput"
@keydown="onBlockKeydown"
@update:model-value="editingSource = $event"
@escape="cancelBlockEdit"
@blur="finalizeBlockEdit"
@paste="onBlockPaste"
/>
@@ -1108,18 +1068,25 @@ defineExpose({
v-for="block in markdownBlocks"
:key="block.id"
class="markdown-block"
:class="{ 'markdown-block-editing': editingBlockId === block.id }"
:class="{
'markdown-block-editing': editingBlockId === block.id,
'markdown-block-paragraph': block.type === 'paragraph',
}"
>
<textarea
<MarkdownCodeEditor
v-if="editingBlockId === block.id"
:ref="setBlockTextareaRef"
:value="editingSource"
rows="1"
ref="blockCodeRef"
:model-value="editingSource"
autofocus
class="markdown-block-editor"
@input="onBlockInput"
@keydown="onBlockKeydown"
:class="{
'markdown-block-editor-soft-wrap': block.type === 'paragraph',
}"
@update:model-value="editingSource = $event"
@escape="cancelBlockEdit"
@blur="finalizeBlockEdit"
@paste="onBlockPaste"
@backspace-at-start="mergeCurrentBlockWithPrevious"
/>
<div
v-else
@@ -1157,14 +1124,9 @@ defineExpose({
.markdown-source-editor {
display: block;
height: auto;
color: var(--app-text);
}
.markdown-source-editor::placeholder {
color: var(--app-subtle);
}
.markdown-mixed > .markdown-block:first-child .markdown-block-preview > *:first-child {
margin-top: 0;
}
@@ -1186,7 +1148,7 @@ defineExpose({
.markdown-preview h3 { font-size: 1.125em; }
.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 em { font-style: italic; }
@@ -1209,10 +1171,30 @@ defineExpose({
overflow-x: auto;
margin: 0.75em 0;
}
.markdown-preview pre.shiki {
position: relative;
background-color: var(--code-background) !important;
color: var(--code-foreground);
border: 1px solid var(--code-border);
}
.markdown-preview pre.shiki[data-language]::before {
content: attr(data-language);
display: block;
margin-bottom: 0.65rem;
color: var(--app-muted);
font-family: "Geist", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 0.7rem;
font-weight: 600;
line-height: 1;
text-transform: lowercase;
}
.markdown-preview pre.shiki span {
color: inherit;
}
.markdown-preview pre code {
background: none;
padding: 0;
color: var(--app-text);
color: var(--code-foreground);
font-size: 0.875em;
border-radius: 0;
word-break: normal;
@@ -1269,6 +1251,10 @@ defineExpose({
margin: 0.5em 0;
}
.markdown-preview:not(.markdown-mixed) img {
cursor: zoom-in;
}
/* ── Mixed mode block editing ────────────────────────────────────── */
.markdown-block {
@@ -1286,15 +1272,16 @@ defineExpose({
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 {
display: block;
}
.markdown-mixed
> .markdown-block-paragraph
+ .markdown-block-paragraph.markdown-block-editing {
margin-top: 0;
}
.markdown-block-editing,
.markdown-block-editing:hover {
background-color: transparent;
@@ -1315,20 +1302,28 @@ defineExpose({
.markdown-block-editor {
display: block;
width: 100%;
min-height: calc((1em * 1.6) + 1.5rem + 2px);
min-height: 1.625em;
resize: none;
overflow: hidden;
border: 1px solid var(--app-accent-muted);
border-radius: 0.375rem;
background-color: var(--app-surface);
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
color: var(--app-text);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.92em;
line-height: 1.6;
margin: 0.375rem 0;
padding: 0.75rem 0.875rem;
font: inherit;
line-height: inherit;
margin: 0;
padding: 0;
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-accent) 12%, transparent);
box-shadow: none;
caret-color: var(--app-accent);
}
.markdown-block-editor .cm-tooltip {
font-size: calc(0.875rem * var(--editor-zoom, 1));
}
.markdown-block-editor-soft-wrap {
white-space: normal;
}
.markdown-block-editor::placeholder,

View File

@@ -41,6 +41,7 @@ interface SyncResult {
deletedRemote: number;
skipped: number;
};
files: SyncFileDetail[];
}
interface SyncConnectionTestResult {
@@ -48,6 +49,27 @@ interface SyncConnectionTestResult {
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(
defineProps<{
show: boolean;
@@ -55,12 +77,16 @@ const props = withDefaults(
workspacePath?: string;
defaultDocumentMode?: DocumentMode;
appTheme?: AppTheme;
renderSingleLineBreaks?: boolean;
paragraphIndent?: boolean;
}>(),
{
initialPage: "about",
workspacePath: "",
defaultDocumentMode: "edit",
appTheme: "warm",
renderSingleLineBreaks: false,
paragraphIndent: false,
},
);
@@ -68,6 +94,8 @@ const emit = defineEmits<{
"update:show": [value: boolean];
updateDefaultDocumentMode: [mode: DocumentMode];
updateAppTheme: [theme: AppTheme];
updateRenderSingleLineBreaks: [value: boolean];
updateParagraphIndent: [value: boolean];
prepareWorkspaceSync: [done: (saved: boolean) => void];
workspaceSynced: [];
}>();
@@ -83,8 +111,7 @@ const autoUpdate = ref(loadBooleanPref(AUTO_UPDATE_KEY, true));
const language = ref(loadStringPref(LANGUAGE_KEY, "zh-Hans"));
const updateStatus = ref("");
const syncConfig = ref<SyncConfig>(createDefaultSyncConfig());
const syncStatus = ref("");
const syncError = ref("");
const syncFeedback = ref<SyncFeedback | null>(null);
const syncSaving = ref(false);
const syncTesting = ref(false);
const syncRunning = ref(false);
@@ -98,6 +125,30 @@ const pageTitle = computed(() => {
});
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<{
id: SettingsPage;
@@ -122,14 +173,45 @@ const documentModeOptions: Array<{
const themeOptions: Array<{
value: AppTheme;
label: string;
swatch: string;
description: string;
swatches: [string, string, string];
}> = [
{ value: "system", label: "跟随系统", swatch: "linear-gradient(135deg, #ffffff 0 50%, #17191d 50% 100%)" },
{ value: "warm", label: "柔和暖色", swatch: "#bf6a3b" },
{ value: "white", label: "白色", swatch: "#2563eb" },
{ value: "black", label: "黑色", swatch: "#8ab4ff" },
{ value: "gruvbox", label: "Gruvbox", swatch: "#fe8019" },
{ value: "gray", label: "灰色", swatch: "#4f6f93" },
{
value: "system",
label: "跟随系统",
description: "随操作系统在浅色与深色之间切换",
swatches: ["#ffffff", "#101113", "#7c9cff"],
},
{
value: "warm",
label: "柔和暖色",
description: "暖白纸张、陶土强调色",
swatches: ["#faf9f6", "#bf6a3b", "#7a55d9"],
},
{
value: "white",
label: "明亮白色",
description: "纯白背景、清晰蓝色强调",
swatches: ["#ffffff", "#2563eb", "#8250df"],
},
{
value: "gray",
label: "中性灰色",
description: "低饱和界面、沉静蓝灰强调",
swatches: ["#f3f4f6", "#4f6f93", "#7c5c99"],
},
{
value: "black",
label: "深色",
description: "近黑背景、冷调高对比代码色",
swatches: ["#101113", "#8ab4ff", "#c58af9"],
},
{
value: "gruvbox",
label: "Gruvbox",
description: "复古深色、暖橙与青绿色代码色",
swatches: ["#282828", "#fe8019", "#8ec07c"],
},
];
function loadBooleanPref(key: string, fallback: boolean) {
@@ -239,42 +321,62 @@ function onDefaultDocumentModeChange(event: Event) {
emit("updateDefaultDocumentMode", mode);
}
function onThemeChange(event: Event) {
const theme = (event.target as HTMLSelectElement).value as AppTheme;
emit("updateAppTheme", theme);
function setSyncError(message: string, title = "操作失败") {
syncFeedback.value = {
kind: "error",
title,
summary: message,
};
}
function resetTheme() {
emit("updateAppTheme", "warm");
function syncActionLabel(action: SyncFileAction) {
if (action === "upload") return "上传";
if (action === "download") return "下载";
if (action === "deleteLocal") return "删除本地";
if (action === "deleteRemote") return "删除远端";
return "未变更";
}
function setSyncStatus(message: string, isError = false) {
if (isError) {
syncError.value = message;
syncStatus.value = "";
} else {
syncStatus.value = message;
syncError.value = "";
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() {
try {
const config = await invoke<SyncConfig>("load_sync_config");
syncConfig.value = mergeSyncConfig(config);
syncError.value = "";
} catch (error) {
setSyncStatus(`读取同步配置失败: ${error}`, true);
setSyncError(`读取同步配置失败: ${error}`, "无法读取同步配置");
}
}
async function saveSyncConfig() {
syncSaving.value = true;
syncFeedback.value = {
kind: "progress",
title: "正在保存配置",
summary: `正在保存 ${syncProviderLabel.value} 配置。`,
};
try {
await invoke("save_sync_config", { config: syncConfig.value });
setSyncStatus("同步配置已保存");
const result = await invoke<SyncConfigSaveResult>("save_sync_config", { config: syncConfig.value });
syncFeedback.value = {
kind: "save",
title: "同步配置已保存",
summary: `已保存 ${syncProviderLabel.value} 配置。`,
configPath: result.configPath,
details: [
syncTargetDescription.value,
`启动时自动同步:${syncConfig.value.autoSyncOnStartup ? "开启" : "关闭"}`,
`保存后自动同步:${syncConfig.value.autoSyncAfterSave ? "开启" : "关闭"}`,
],
};
} catch (error) {
setSyncStatus(`保存失败: ${error}`, true);
setSyncError(String(error), "保存同步配置失败");
} finally {
syncSaving.value = false;
}
@@ -282,36 +384,45 @@ async function saveSyncConfig() {
async function testSyncConnection() {
syncTesting.value = true;
syncFeedback.value = {
kind: "progress",
title: "正在测试连接",
summary: `正在检查 ${syncProviderLabel.value} 服务的读取、写入和删除权限。`,
};
try {
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) {
setSyncStatus(String(error), true);
setSyncError(String(error), "连接测试失败");
} finally {
syncTesting.value = false;
}
}
function formatSyncResult(result: SyncResult) {
const { stats } = result;
return `同步完成:上传 ${stats.uploaded},下载 ${stats.downloaded},删除本地 ${stats.deletedLocal},删除远端 ${stats.deletedRemote},跳过 ${stats.skipped}`;
}
async function runSyncNow() {
if (!props.workspacePath) {
setSyncStatus("请先打开一个工作目录", true);
setSyncError("请先打开一个工作目录", "无法开始同步");
return;
}
syncRunning.value = true;
try {
setSyncStatus("正在同步:扫描本地和远端文件,远端列表请求最长等待 120 秒。");
syncFeedback.value = {
kind: "progress",
title: "正在同步",
summary: "正在扫描本地和远端文件,远端列表请求最长等待 120 秒。",
};
await invoke("save_sync_config", { config: syncConfig.value });
const prepared = await new Promise<boolean>((resolve) => {
emit("prepareWorkspaceSync", resolve);
});
if (!prepared) {
setSyncStatus("同步已取消,当前文档尚未保存", true);
setSyncError("当前文档尚未保存,同步已取消。", "同步未开始");
return;
}
@@ -320,10 +431,16 @@ async function runSyncNow() {
config: syncConfig.value,
});
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");
} catch (error) {
setSyncStatus(`同步失败: ${error}`, true);
setSyncError(String(error), "同步失败");
} finally {
syncRunning.value = false;
}
@@ -335,6 +452,7 @@ watch(
if (!show) return;
activePage.value = props.initialPage;
updateStatus.value = "";
syncFeedback.value = null;
void loadAppVersion();
void loadSyncConfig();
},
@@ -399,6 +517,9 @@ onBeforeUnmount(() => {
<div v-if="activePage === 'about'" class="settings-content">
<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-copy">
<h3>版本 {{ appVersion }}</h3>
@@ -475,6 +596,34 @@ onBeforeUnmount(() => {
</option>
</select>
</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>
</div>
@@ -647,41 +796,119 @@ onBeforeUnmount(() => {
<p>
{{ syncConfig.lastSyncAt ? `上次同步:${syncConfig.lastSyncAt}` : '尚未同步' }}
</p>
<div
v-if="syncStatus || syncError"
class="settings-sync-log"
:class="{ 'settings-sync-log-error': Boolean(syncError) }"
role="status"
>
{{ syncError || syncStatus }}
</div>
</div>
<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
class="settings-secondary-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"
class="settings-primary-btn settings-sync-now-btn"
type="button"
:disabled="isSyncBusy || syncConfig.provider === 'none' || !workspacePath"
@click="runSyncNow"
>
<i :class="syncRunning ? 'ri-loader-4-line settings-spin' : 'ri-loop-left-line'"></i>
{{ syncRunning ? '同步中' : '立即同步' }}
</button>
</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>
</section>
</div>
@@ -690,51 +917,35 @@ onBeforeUnmount(() => {
<section class="settings-about-card" aria-label="外观设置">
<div class="settings-row settings-row-top">
<div class="settings-copy">
<h3>基础颜色</h3>
<p>设置 {{ APP_NAME }} 的基础颜色</p>
<h3>外观主题</h3>
<p>主题同时应用于界面Markdown 预览和代码编辑</p>
</div>
<select
class="settings-select"
aria-label="基础颜色"
:value="appTheme"
@change="onThemeChange"
>
<option
v-for="option in themeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
<div class="settings-row">
<div class="settings-copy">
<h3>主题色</h3>
<p> {{ APP_NAME }} 选择一个主题色主题色将影响选中链接等元素的颜色</p>
</div>
<div class="theme-swatch-group" aria-label="主题色">
<div class="theme-option-grid" role="radiogroup" aria-label="外观主题">
<button
class="theme-reset-btn"
type="button"
title="重置主题色"
@click="resetTheme"
>
<i class="ri-reset-left-line text-lg"></i>
</button>
<button
v-for="option in themeOptions.filter((item) => item.value !== 'system')"
v-for="option in themeOptions"
:key="option.value"
class="theme-swatch"
:class="{ 'theme-swatch-active': appTheme === option.value }"
class="theme-option"
:class="{ 'theme-option-active': appTheme === option.value }"
type="button"
:title="option.label"
:aria-label="option.label"
:style="{ background: option.swatch }"
role="radio"
:aria-checked="appTheme === option.value"
@click="emit('updateAppTheme', option.value)"
></button>
</div>
>
<span class="theme-option-preview" aria-hidden="true">
<span
v-for="(swatch, index) in option.swatches"
:key="swatch"
:style="{ background: swatch, flex: index === 0 ? 2 : 1 }"
></span>
</span>
<span class="theme-option-copy">
<strong>{{ option.label }}</strong>
<small>{{ option.description }}</small>
</span>
<i v-if="appTheme === option.value" class="ri-check-line"></i>
</button>
</div>
</section>
</div>
@@ -886,6 +1097,20 @@ onBeforeUnmount(() => {
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 {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -942,9 +1167,16 @@ onBeforeUnmount(() => {
.settings-action-stack {
display: flex;
align-items: flex-end;
width: 244px;
align-items: stretch;
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,
@@ -957,6 +1189,7 @@ onBeforeUnmount(() => {
font-size: 13px;
font-weight: 600;
line-height: 1;
gap: 6px;
cursor: pointer;
transition:
background-color 0.15s ease,
@@ -977,7 +1210,7 @@ onBeforeUnmount(() => {
}
.settings-secondary-btn {
min-width: 50px;
min-width: 0;
border: 1px solid var(--app-border-strong);
background: var(--app-surface);
color: var(--app-text);
@@ -1143,68 +1376,288 @@ onBeforeUnmount(() => {
align-items: flex-start;
}
.settings-sync-log {
width: min(100%, 420px);
max-height: 138px;
margin-top: 10px;
overflow: auto;
.settings-sync-now-btn {
width: 100%;
}
.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-radius: 6px;
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);
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;
line-height: 1.5;
padding: 9px 10px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.settings-sync-log-error {
border-color: color-mix(in srgb, #dc2626 32%, var(--app-border));
background: color-mix(in srgb, #dc2626 7%, var(--app-surface));
.settings-config-path {
display: grid;
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;
}
.theme-swatch-group {
display: inline-flex;
align-items: center;
gap: 10px;
}
.theme-reset-btn,
.theme-swatch {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
}
.theme-reset-btn {
width: 30px;
height: 30px;
border-radius: 6px;
background: transparent;
.settings-sync-file-action[data-action="skip"] {
color: var(--app-muted);
}
.theme-reset-btn:hover {
background: var(--app-hover);
.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;
}
.theme-swatch {
width: 22px;
height: 22px;
border-radius: 999px;
box-shadow:
0 0 0 1px color-mix(in srgb, var(--app-text) 18%, transparent),
0 2px 5px color-mix(in srgb, var(--app-shadow) 70%, transparent);
.settings-spin {
animation: settings-spin 0.85s linear infinite;
}
.theme-swatch-active {
outline: 2px solid var(--app-accent);
outline-offset: 3px;
@keyframes settings-spin {
to {
transform: rotate(360deg);
}
}
.theme-option-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding: 4px 20px 20px;
}
.theme-option {
display: grid;
grid-template-columns: 48px minmax(0, 1fr) 18px;
gap: 11px;
align-items: center;
min-height: 64px;
padding: 10px;
border: 1px solid var(--app-border-strong);
border-radius: 7px;
background: var(--app-surface);
color: var(--app-text);
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.theme-option:hover {
background: var(--app-surface-muted);
}
.theme-option-active {
border-color: var(--app-accent);
background: var(--app-active-bg);
}
.theme-option-preview {
display: flex;
width: 48px;
height: 38px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--app-text) 18%, transparent);
border-radius: 5px;
box-shadow: 0 2px 5px color-mix(in srgb, var(--app-shadow) 55%, transparent);
}
.theme-option-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.theme-option-copy strong {
color: var(--app-text-strong);
font-size: 13px;
line-height: 1.2;
}
.theme-option-copy small {
color: var(--app-muted);
font-size: 11px;
line-height: 1.35;
}
.theme-option > i {
color: var(--app-accent);
font-size: 16px;
}
.settings-empty-page {
@@ -1259,11 +1712,45 @@ onBeforeUnmount(() => {
}
.settings-action-stack {
width: 100%;
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 {
min-width: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.settings-spin {
animation-duration: 1.8s;
}
}
</style>

View File

@@ -15,12 +15,18 @@ interface DirEntry {
children?: DirEntry[];
}
interface TreeExpansionCommand {
id: number;
expanded: boolean;
}
const TREE_CONTEXT_MENU_CLOSE_EVENT = "yurou-tree-context-menu-close";
const props = defineProps<{
entry: DirEntry;
depth: number;
currentFilePath?: string | null;
expansionCommand?: TreeExpansionCommand | null;
}>();
const emit = defineEmits<{
@@ -33,6 +39,7 @@ const emit = defineEmits<{
}>();
const expanded = ref(true);
const handledExpansionCommandId = ref(0);
const contextMenuOpen = ref(false);
const contextMenuPoint = ref<{ x: number; y: number } | null>(null);
const contextMenuStyle = computed(() => {
@@ -76,12 +83,35 @@ function onContextMenuKeydown(event: KeyboardEvent) {
watch(
() => props.entry.children,
(children, oldChildren) => {
if (children !== undefined && oldChildren === undefined) {
if (
children !== undefined &&
oldChildren === undefined &&
props.expansionCommand?.expanded !== false
) {
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(() => {
return props.currentFilePath != null && props.currentFilePath === props.entry.path;
});
@@ -307,6 +337,7 @@ function onContextMenu(event: MouseEvent) {
:entry="child"
:depth="depth + 1"
:currentFilePath="currentFilePath"
:expansion-command="expansionCommand"
@toggle-folder="(path: string) => emit('toggleFolder', path)"
@open-file="(path: string) => emit('openFile', path)"
@create-folder="(path: string) => emit('createFolder', path)"

100
src/lib/codeHighlight.ts Normal file
View 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;
}

View 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),
});
},
}));
}

View File

@@ -44,6 +44,21 @@
--app-overlay: rgba(56, 52, 46, 0.22);
--app-toast-bg: rgba(56, 52, 46, 0.8);
--app-shadow: rgba(56, 52, 46, 0.16);
--code-background: #f4f1ea;
--code-foreground: #38342e;
--code-border: #e0dbcf;
--code-token-comment: #8c877d;
--code-token-constant: #8c4f2b;
--code-token-keyword: #7651a8;
--code-token-parameter: #9f4f28;
--code-token-function: #315f78;
--code-token-string: #6f641f;
--code-token-string-expression: #6f641f;
--code-token-punctuation: #6e685f;
--code-token-link: #6342bd;
--code-token-inserted: #3d7a4d;
--code-token-deleted: #b34242;
--code-token-changed: #9a691f;
}
:root[data-theme="white"] {
@@ -74,6 +89,21 @@
--app-overlay: rgba(17, 24, 39, 0.2);
--app-toast-bg: rgba(17, 24, 39, 0.82);
--app-shadow: rgba(17, 24, 39, 0.15);
--code-background: #f4f6f8;
--code-foreground: #24292f;
--code-border: #d7dbe0;
--code-token-comment: #6e7781;
--code-token-constant: #953800;
--code-token-keyword: #8250df;
--code-token-parameter: #953800;
--code-token-function: #0550ae;
--code-token-string: #0a3069;
--code-token-string-expression: #0a3069;
--code-token-punctuation: #57606a;
--code-token-link: #0969da;
--code-token-inserted: #1a7f37;
--code-token-deleted: #cf222e;
--code-token-changed: #9a6700;
}
:root[data-theme="gray"] {
@@ -104,6 +134,21 @@
--app-overlay: rgba(31, 35, 40, 0.24);
--app-toast-bg: rgba(31, 35, 40, 0.82);
--app-shadow: rgba(31, 35, 40, 0.14);
--code-background: #eceff2;
--code-foreground: #2f3337;
--code-border: #cfd5dc;
--code-token-comment: #737a82;
--code-token-constant: #8b572a;
--code-token-keyword: #7c5c99;
--code-token-parameter: #8b572a;
--code-token-function: #3b668c;
--code-token-string: #537238;
--code-token-string-expression: #537238;
--code-token-punctuation: #656c74;
--code-token-link: #3b668c;
--code-token-inserted: #4e7a52;
--code-token-deleted: #a94a4a;
--code-token-changed: #8b6b2f;
}
:root[data-theme="black"] {
@@ -134,6 +179,21 @@
--app-overlay: rgba(0, 0, 0, 0.48);
--app-toast-bg: rgba(0, 0, 0, 0.82);
--app-shadow: rgba(0, 0, 0, 0.36);
--code-background: #1b1e23;
--code-foreground: #e7e9ed;
--code-border: #383d45;
--code-token-comment: #8b949e;
--code-token-constant: #ffa657;
--code-token-keyword: #ff7b72;
--code-token-parameter: #ffa657;
--code-token-function: #d2a8ff;
--code-token-string: #a5d6ff;
--code-token-string-expression: #a5d6ff;
--code-token-punctuation: #b1bac4;
--code-token-link: #79c0ff;
--code-token-inserted: #7ee787;
--code-token-deleted: #ff7b72;
--code-token-changed: #e3b341;
}
:root[data-theme="gruvbox"] {
@@ -164,6 +224,21 @@
--app-overlay: rgba(29, 32, 33, 0.52);
--app-toast-bg: rgba(29, 32, 33, 0.9);
--app-shadow: rgba(0, 0, 0, 0.38);
--code-background: #1d2021;
--code-foreground: #ebdbb2;
--code-border: #504945;
--code-token-comment: #928374;
--code-token-constant: #d3869b;
--code-token-keyword: #fb4934;
--code-token-parameter: #fe8019;
--code-token-function: #fabd2f;
--code-token-string: #b8bb26;
--code-token-string-expression: #b8bb26;
--code-token-punctuation: #bdae93;
--code-token-link: #83a598;
--code-token-inserted: #b8bb26;
--code-token-deleted: #fb4934;
--code-token-changed: #fabd2f;
}
@media (prefers-color-scheme: dark) {
@@ -195,6 +270,21 @@
--app-overlay: rgba(0, 0, 0, 0.48);
--app-toast-bg: rgba(0, 0, 0, 0.82);
--app-shadow: rgba(0, 0, 0, 0.36);
--code-background: #1b1e23;
--code-foreground: #e7e9ed;
--code-border: #383d45;
--code-token-comment: #8b949e;
--code-token-constant: #ffa657;
--code-token-keyword: #ff7b72;
--code-token-parameter: #ffa657;
--code-token-function: #d2a8ff;
--code-token-string: #a5d6ff;
--code-token-string-expression: #a5d6ff;
--code-token-punctuation: #b1bac4;
--code-token-link: #79c0ff;
--code-token-inserted: #7ee787;
--code-token-deleted: #ff7b72;
--code-token-changed: #e3b341;
}
}