From c5a5a4e55b20fd2a6cb5527955c5ecb5df16a142 Mon Sep 17 00:00:00 2001 From: cirry <812852553@qq.com> Date: Thu, 16 Jul 2026 16:40:00 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=BC=96=E8=BE=91=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/markdown-editor-design.md | 35 +- package.json | 10 + pnpm-lock.yaml | 666 +++++++++++++++++++++++++++++- pnpm-workspace.yaml | 2 + src/components/MarkdownEditor.vue | 415 +++++++------------ src/components/SettingsModal.vue | 200 +++++---- src/tailwind.css | 92 ++++- 7 files changed, 1065 insertions(+), 355 deletions(-) diff --git a/doc/markdown-editor-design.md b/doc/markdown-editor-design.md index 6de4295..48a0bf1 100644 --- a/doc/markdown-editor-design.md +++ b/doc/markdown-editor-design.md @@ -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 高亮器 +替换 `
` 节点,因此不会放宽 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
diff --git a/package.json b/package.json
index 6b97361..0e4edb1 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,15 @@
     "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",
+    "@lezer/highlight": "^1.2.3",
     "@lucide/vue": "^1.23.0",
     "@tauri-apps/api": "^2",
     "@tauri-apps/plugin-dialog": "^2.7.1",
@@ -25,6 +34,7 @@
     "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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 35acd38..e0f443f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4,10 +4,40 @@ 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
+      '@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))
@@ -53,6 +83,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 +257,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
@@ -485,11 +608,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 +831,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 +1067,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 +1421,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'}
@@ -2223,6 +2437,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 +2630,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==}
 
@@ -2514,6 +2743,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'}
@@ -2606,6 +2839,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 +3062,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 +3309,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
@@ -3277,10 +3767,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 +3978,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 +4172,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 +4536,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 +5037,7 @@ snapshots:
 
   hast-util-whitespace@3.0.0:
     dependencies:
-      '@types/hast': 3.0.4
+      '@types/hast': 3.0.5
 
   he@1.2.0: {}
 
@@ -5048,6 +5679,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 +5874,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
@@ -5449,6 +6098,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
@@ -5551,6 +6211,8 @@ snapshots:
 
   strip-final-newline@2.0.0: {}
 
+  style-mod@4.1.3: {}
+
   stylus@0.57.0:
     dependencies:
       css: 3.0.0
@@ -5771,6 +6433,8 @@ snapshots:
     optionalDependencies:
       typescript: 5.6.3
 
+  w3c-keyname@2.2.8: {}
+
   web-worker@1.5.0: {}
 
   which@2.0.1:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index a23eae0..35decd1 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,3 +1,5 @@
 allowBuilds:
   esbuild: true
   vue-demi: true
+overrides:
+  '@codemirror/lang-markdown': 6.4.0
diff --git a/src/components/MarkdownEditor.vue b/src/components/MarkdownEditor.vue
index 4f2c5d0..8d4ecc0 100644
--- a/src/components/MarkdownEditor.vue
+++ b/src/components/MarkdownEditor.vue
@@ -5,7 +5,6 @@ import {
   onBeforeUnmount,
   ref,
   watch,
-  type ComponentPublicInstance,
 } from "vue";
 import { unified, type Plugin, type Transformer } from "unified";
 import remarkParse from "remark-parse";
@@ -14,6 +13,9 @@ 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";
@@ -59,6 +61,13 @@ interface MarkdownBlock {
   endOffset: number;
 }
 
+interface MarkdownCodeEditorApi {
+  focus: () => void;
+  focusAtEnd: () => void;
+  replaceRange: (text: string, from?: number, to?: number) => void;
+  scrollToLine: (lineNumber: number) => void;
+}
+
 const props = withDefaults(
   defineProps<{
     modelValue?: string;
@@ -232,9 +241,9 @@ const renderedHtml = ref("");
 const renderError = ref(false);
 const markdownBlocks = ref([]);
 
-const editorTextareaRef = ref();
+const editorCodeRef = ref();
 const scrollRef = ref();
-const blockTextareaRef = ref(null);
+const blockCodeRef = ref();
 
 const EMPTY_BLOCK_ID = "__empty__";
 const editingBlockId = ref(null);
@@ -247,8 +256,8 @@ const pendingLocalModelUpdate = ref(false);
 const isEditingEmptyDocument = computed(
   () => editingBlockId.value === EMPTY_BLOCK_ID,
 );
-let textareaResizeFrame: number | null = null;
 let modeScrollRestoreFrame: number | null = null;
+let renderRequestId = 0;
 
 interface ScrollSnapshot {
   ratio: number;
@@ -278,7 +287,6 @@ function restoreScrollSnapshot(snapshot: ScrollSnapshot | null) {
 
 function restoreScrollAfterModeChange(
   snapshot: ScrollSnapshot | null,
-  mode: DocumentMode,
 ) {
   if (!snapshot) return;
 
@@ -288,17 +296,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);
     });
   });
@@ -306,21 +307,62 @@ 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("
 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 file = md2html.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;
   }
@@ -335,16 +377,16 @@ function escapeHtml(value: string) {
     .replace(/'/g, "'");
 }
 
-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 `
${escapeHtml(source)}
`; } } -function parseBlocks(md: string) { +async function parseBlocks(md: string) { + const requestId = ++renderRequestId; if (!md.trim()) { markdownBlocks.value = []; renderError.value = false; @@ -353,8 +395,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 | null => { const startOffset = node.position?.start?.offset; const endOffset = node.position?.end?.offset; @@ -371,22 +413,31 @@ function parseBlocks(md: string) { 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 => 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, }, @@ -411,7 +462,6 @@ watch( } nextTick(() => { - scheduleEditorTextareaResize(); // 外部切换文件时重置滚动位置;本组件内编辑时保留当前位置。 if (shouldResetScroll && scrollRef.value) scrollRef.value.scrollTop = 0; }); @@ -419,40 +469,11 @@ watch( { immediate: true }, ); -// ── Editor textarea handlers ─────────────────────────────────────── - -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(); }); } @@ -465,54 +486,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 = { - "(": ")", - "[": "]", - "{": "}", - '"': '"', - "'": "'", - "`": "`", - }; - 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"; } @@ -530,36 +503,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) { @@ -619,52 +562,6 @@ 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(); @@ -679,17 +576,10 @@ watch(activeDocumentMode, (mode, oldMode) => { syncRenderedState(committedValue ?? props.modelValue, mode); - if (mode === "edit") { - void nextTick(scheduleEditorTextareaResize); - } - - restoreScrollAfterModeChange(scrollSnapshot, mode); + restoreScrollAfterModeChange(scrollSnapshot); }); onBeforeUnmount(() => { - if (textareaResizeFrame !== null) { - cancelAnimationFrame(textareaResizeFrame); - } if (modeScrollRestoreFrame !== null) { cancelAnimationFrame(modeScrollRestoreFrame); } @@ -952,47 +842,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); }, ); } @@ -1001,19 +882,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) { @@ -1056,18 +925,14 @@ defineExpose({ }" > -