md2ui 1.0.18 → 1.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +3 -55
  2. package/bin/build.js +82 -7
  3. package/bin/md2ui.js +80 -4
  4. package/package.json +23 -9
  5. package/public/docs/00-/345/277/253/351/200/237/345/274/200/345/247/213.md +48 -28
  6. package/public/docs/01-/345/212/237/350/203/275/347/211/271/346/200/247.md +55 -40
  7. package/public/docs/02-Markdown/346/270/262/346/237/223/00-/345/237/272/347/241/200/350/257/255/346/263/225.md +86 -0
  8. package/public/docs/02-Markdown/346/270/262/346/237/223/01-/344/273/243/347/240/201/345/235/227.md +91 -0
  9. package/public/docs/02-Markdown/346/270/262/346/237/223/02-/350/241/250/346/240/274.md +187 -0
  10. package/public/docs/02-Markdown/346/270/262/346/237/223/03-Mermaid/345/233/276/350/241/250.md +101 -0
  11. package/public/docs/02-Markdown/346/270/262/346/237/223/04-Frontmatter.md +32 -0
  12. package/public/docs/02-Markdown/346/270/262/346/237/223/05-/346/225/260/345/255/246/345/205/254/345/274/217.md +47 -0
  13. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/00-/344/270/211/346/240/217/345/270/203/345/261/200.md +33 -0
  14. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/01-/347/233/256/345/275/225/346/240/221/345/257/274/350/210/252.md +43 -0
  15. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/02-/346/226/207/346/241/243/345/244/247/347/272/262.md +51 -0
  16. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/03-/344/270/212/344/270/213/347/257/207/345/257/274/350/210/252.md +29 -0
  17. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/04-/347/253/231/345/206/205/351/223/276/346/216/245.md +39 -0
  18. package/public/docs/04-/346/220/234/347/264/242/345/212/237/350/203/275/00-/345/205/250/346/226/207/346/220/234/347/264/242.md +46 -0
  19. package/public/docs/05-/347/274/226/350/276/221/345/212/237/350/203/275/00-/347/274/226/350/276/221/345/231/250/345/237/272/347/241/200.md +65 -0
  20. package/public/docs/05-/347/274/226/350/276/221/345/212/237/350/203/275/01-/350/207/252/345/212/250/344/277/235/345/255/230.md +38 -0
  21. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/00-/351/230/205/350/257/273/350/277/233/345/272/246.md +43 -0
  22. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/01-/345/233/276/347/211/207/346/224/276/345/244/247.md +40 -0
  23. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/02-/350/277/224/345/233/236/351/241/266/351/203/250.md +38 -0
  24. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/assets/img-1777261394722.png +0 -0
  25. package/public/docs/07-/347/247/273/345/212/250/347/253/257/351/200/202/351/205/215/00-/345/223/215/345/272/224/345/274/217/345/270/203/345/261/200.md +37 -0
  26. package/public/docs/08-/346/226/207/346/241/243/347/256/241/347/220/206/00-/346/226/260/345/273/272/344/270/216/345/210/240/351/231/244.md +47 -0
  27. package/public/docs/09-/345/257/274/345/207/272/345/212/237/350/203/275/00-/345/257/274/345/207/272Word.md +77 -0
  28. package/public/docs/10-/351/203/250/347/275/262/344/270/216/351/205/215/347/275/256/00-CLI/345/267/245/345/205/267.md +52 -0
  29. package/public/docs/10-/351/203/250/347/275/262/344/270/216/351/205/215/347/275/256/01-SSG/351/235/231/346/200/201/346/236/204/345/273/272.md +44 -0
  30. package/public/docs/10-/351/203/250/347/275/262/344/270/216/351/205/215/347/275/256/02-/350/207/252/345/256/232/344/271/211/351/205/215/347/275/256.md +58 -0
  31. package/public/docs/11-/345/244/232/347/272/247/347/233/256/345/275/225/346/265/213/350/257/225/00-/344/270/200/347/272/247/346/226/207/346/241/243.md +20 -0
  32. package/public/docs/11-/345/244/232/347/272/247/347/233/256/345/275/225/346/265/213/350/257/225/01-/345/255/220/347/233/256/345/275/225/00-/344/272/214/347/272/247/346/226/207/346/241/243.md +13 -0
  33. package/public/docs/11-/345/244/232/347/272/247/347/233/256/345/275/225/346/265/213/350/257/225/01-/345/255/220/347/233/256/345/275/225/01-/346/267/261/345/261/202/345/265/214/345/245/227/00-/344/270/211/347/272/247/346/226/207/346/241/243.md +23 -0
  34. package/src/App.vue +130 -6
  35. package/src/components/AppSidebar.vue +181 -21
  36. package/src/components/CodeBlockNodeView.vue +72 -0
  37. package/src/components/DocContent.vue +25 -14
  38. package/src/components/EditorContent.vue +257 -0
  39. package/src/components/EditorToolbar.vue +264 -0
  40. package/src/components/ImageZoom.vue +199 -2
  41. package/src/components/MathBlockNodeView.vue +160 -0
  42. package/src/components/MathInlineNodeView.vue +145 -0
  43. package/src/components/MermaidNodeView.vue +149 -0
  44. package/src/components/TableBubbleMenu.vue +177 -0
  45. package/src/components/TableOfContents.vue +138 -32
  46. package/src/components/TopBar.vue +69 -4
  47. package/src/components/TreeNode.vue +232 -39
  48. package/src/components/WelcomePage.vue +2 -2
  49. package/src/composables/useDocHash.js +9 -1
  50. package/src/composables/useDocManager.js +325 -68
  51. package/src/composables/useDocTree.js +56 -1
  52. package/src/composables/useExportPdf.js +102 -0
  53. package/src/composables/useExportWord.js +73 -10
  54. package/src/composables/useFileWatcher.js +45 -0
  55. package/src/composables/useFrontmatter.js +2 -2
  56. package/src/composables/useMarkdown.js +529 -42
  57. package/src/composables/useScroll.js +47 -5
  58. package/src/config.js +1 -1
  59. package/src/extensions/CodeBlockCustom.js +113 -0
  60. package/src/extensions/MathBlock.js +107 -0
  61. package/src/extensions/MathInline.js +100 -0
  62. package/src/extensions/MermaidBlock.js +73 -0
  63. package/src/extensions/TableControls.js +670 -0
  64. package/src/services/DocService.js +184 -0
  65. package/src/style.css +2194 -39
  66. package/vite-plugin-doc-api.js +368 -0
  67. package/vite.config.js +2 -1
  68. package/public/docs/02-Mermaid/345/233/276/350/241/250.md +0 -102
  69. package/public/docs/03-/350/277/233/351/230/266/346/214/207/345/215/227/01-/347/233/256/345/275/225/347/273/223/346/236/204.md +0 -55
  70. package/public/docs/03-/350/277/233/351/230/266/346/214/207/345/215/227/02-/350/207/252/345/256/232/344/271/211/351/205/215/347/275/256.md +0 -63
  71. package/public/docs/03-/350/277/233/351/230/266/346/214/207/345/215/227/03-/351/203/250/347/275/262/346/226/271/346/241/210.md +0 -73
  72. package/public/docs/04-API/345/217/202/350/200/203/01-/347/273/204/344/273/266API.md +0 -80
  73. package/public/docs/04-API/345/217/202/350/200/203/02-Composables.md +0 -92
  74. package/src/api/docs.js +0 -106
@@ -0,0 +1,58 @@
1
+ # 自定义配置
2
+
3
+ 验证用户自定义配置的加载和生效。
4
+
5
+ ## 配置文件
6
+
7
+ 在文档目录下创建 `md2ui.config.js` 或 `.md2uirc.json`:
8
+
9
+ ```js
10
+ // md2ui.config.js
11
+ export default {
12
+ title: '我的文档站',
13
+ port: 8080,
14
+ folderExpanded: true,
15
+ themeColor: '#3eaf7c',
16
+ github: 'https://github.com/xiaoyaodev/md2ui',
17
+ footer: 'Copyright © 2025'
18
+ }
19
+ ```
20
+
21
+ ## 配置项
22
+
23
+ | 配置项 | 类型 | 默认值 | 说明 |
24
+ |--------|------|--------|------|
25
+ | title | string | `'md2ui'` | 站点标题,显示在顶部 Logo 旁 |
26
+ | port | number | `3000` | 开发服务器端口 |
27
+ | folderExpanded | boolean | `false` | 文件夹默认是否展开 |
28
+ | themeColor | string | `'#3eaf7c'` | 主题色,影响全局强调色 |
29
+ | github | string | `''` | GitHub 仓库链接,显示在顶部 |
30
+ | footer | string | `''` | 页脚内容 |
31
+
32
+ ## JSON 格式
33
+
34
+ ```json
35
+ {
36
+ "title": "我的文档站",
37
+ "port": 8080,
38
+ "folderExpanded": true,
39
+ "themeColor": "#3eaf7c",
40
+ "github": "https://github.com/xiaoyaodev/md2ui",
41
+ "footer": "Copyright © 2025"
42
+ }
43
+ ```
44
+
45
+ ## 优先级
46
+
47
+ 配置合并优先级(从低到高):
48
+
49
+ 1. 内置默认值
50
+ 2. 配置文件(md2ui.config.js 或 .md2uirc.json)
51
+ 3. 命令行参数(如 `-p 8080`)
52
+
53
+ ## 验证要点
54
+
55
+ 1. 创建配置文件后重启服务,配置应生效
56
+ 2. 修改 title 后页面标题应更新
57
+ 3. 修改 themeColor 后主题色应变化
58
+ 4. 命令行 `-p` 参数应覆盖配置文件中的 port
@@ -0,0 +1,20 @@
1
+ # 一级文档
2
+
3
+ 这是多级目录测试的一级文档,用于验证目录树的多级嵌套显示。
4
+
5
+ ## 目录结构
6
+
7
+ ```
8
+ 11-多级目录测试/
9
+ ├── 01-一级文档.md ← 当前文档
10
+ └── 01-子目录/
11
+ ├── 01-二级文档.md
12
+ └── 01-深层嵌套/
13
+ └── 01-三级文档.md
14
+ ```
15
+
16
+ ## 验证要点
17
+
18
+ 1. 左侧导航应正确显示三级嵌套结构
19
+ 2. 文件夹节点应可展开/收起
20
+ 3. 各级文档应可正常点击加载
@@ -0,0 +1,13 @@
1
+ # 二级文档
2
+
3
+ 这是多级目录测试的二级文档,位于 `01-子目录` 下。
4
+
5
+ ## 站内链接测试
6
+
7
+ - [返回一级文档](../01-一级文档.md)
8
+ - [进入三级文档](01-深层嵌套/01-三级文档.md)
9
+
10
+ ## 验证要点
11
+
12
+ 1. 本文档应在导航树的第二级显示
13
+ 2. 上方站内链接应可正常跳转
@@ -0,0 +1,23 @@
1
+ # 三级文档
2
+
3
+ 这是多级目录测试的三级文档,位于 `01-子目录/01-深层嵌套` 下。
4
+
5
+ ## 深层嵌套验证
6
+
7
+ 本文档验证三级及以上目录嵌套的正确性:
8
+
9
+ - 导航树应正确缩进显示
10
+ - 文件夹展开/收起应正常工作
11
+ - 上下篇导航应正确计算
12
+
13
+ ## 站内链接测试
14
+
15
+ - [返回二级文档](../01-二级文档.md)
16
+ - [返回一级文档](../../01-一级文档.md)
17
+ - [跳转到快速开始](../../../00-快速开始.md)
18
+
19
+ ## 验证要点
20
+
21
+ 1. 本文档应在导航树的第三级显示
22
+ 2. 多级返回链接应可正常跳转
23
+ 3. 跨多级目录的链接应正确解析
package/src/App.vue CHANGED
@@ -10,8 +10,13 @@
10
10
  <!-- 桌面端顶栏 -->
11
11
  <TopBar
12
12
  v-if="!isMobile"
13
+ :editMode="editMode"
14
+ :showWelcome="showWelcome"
15
+ :docTitle="currentDocTitle"
16
+ :rawMarkdown="rawMarkdown"
13
17
  @select-search="handleSearchSelect"
14
18
  @go-home="goHome"
19
+ @toggle-edit="onToggleEdit"
15
20
  />
16
21
  <!-- 移动端遮罩 -->
17
22
  <transition name="fade">
@@ -34,23 +39,44 @@
34
39
  @collapse-all="onCollapseAll"
35
40
  @toggle-folder="toggleFolder"
36
41
  @select-doc="handleDocSelect"
42
+ @create-doc="createDoc"
43
+ @delete-doc="deleteDoc"
44
+ @rename-doc="({ item, newName }) => renameDoc(item, newName)"
45
+ @reorder="reorderDocs"
37
46
  />
38
47
  <!-- 左侧拖拽条 & 展开按钮(桌面端) -->
39
48
  <div v-if="!isMobile && !sidebarCollapsed" class="resizer resizer-left" @mousedown="startResize('left', $event)"></div>
40
49
  <button v-if="!isMobile && sidebarCollapsed" class="expand-btn expand-btn-left" @click="sidebarCollapsed = false" title="展开导航">
41
50
  <ChevronRight :size="14" />
42
51
  </button>
43
- <!-- 内容区 -->
52
+ <!-- 内容区:查看模式 / 编辑模式 -->
44
53
  <DocContent
54
+ v-if="!editMode"
45
55
  :showWelcome="showWelcome"
46
56
  :htmlContent="htmlContent"
47
57
  :prevDoc="prevDoc"
48
58
  :nextDoc="nextDoc"
49
59
  :docTitle="currentDocTitle"
60
+ :lastModified="lastModified"
61
+ @scroll="handleScroll"
62
+ @content-click="onContentClick"
63
+ @start="loadFirstDoc"
64
+ @load-doc="loadDoc"
65
+ />
66
+ <EditorContentVue
67
+ v-else
68
+ :showWelcome="showWelcome"
69
+ :markdownContent="rawMarkdown"
70
+ :prevDoc="prevDoc"
71
+ :nextDoc="nextDoc"
72
+ :docTitle="currentDocTitle"
73
+ :currentDocPath="currentDocFilePath"
50
74
  @scroll="handleScroll"
51
75
  @content-click="onContentClick"
52
76
  @start="loadFirstDoc"
53
77
  @load-doc="loadDoc"
78
+ @save="onSaveDoc"
79
+ @update:markdownContent="onEditorUpdate"
54
80
  />
55
81
  <!-- 桌面端 TOC -->
56
82
  <div v-if="!isMobile && !tocCollapsed && tocItems.length > 0" class="resizer resizer-right" @mousedown="startResize('right', $event)"></div>
@@ -84,25 +110,28 @@
84
110
  </template>
85
111
 
86
112
  <script setup>
87
- import { ref, onMounted } from 'vue'
113
+ import { ref, onMounted, nextTick, watch } from 'vue'
88
114
  import { ArrowUp, ChevronRight, ChevronLeft } from 'lucide-vue-next'
89
115
  import MobileHeader from './components/MobileHeader.vue'
90
116
  import TopBar from './components/TopBar.vue'
91
117
  import AppSidebar from './components/AppSidebar.vue'
92
118
  import DocContent from './components/DocContent.vue'
119
+ import EditorContentVue from './components/EditorContent.vue'
93
120
  import TableOfContents from './components/TableOfContents.vue'
94
121
  import MobileToc from './components/MobileToc.vue'
95
122
  import ImageZoom from './components/ImageZoom.vue'
96
123
  import { useDocManager } from './composables/useDocManager.js'
97
124
  import { useResize } from './composables/useResize.js'
125
+ import { useFileWatcher } from './composables/useFileWatcher.js'
126
+ import { resetContentEtag } from './services/DocService.js'
98
127
 
99
128
  import { useSearch } from './composables/useSearch.js'
100
129
  import { useMobile } from './composables/useMobile.js'
101
130
 
102
131
 
103
- // UI 状态
104
- const sidebarCollapsed = ref(false)
105
- const tocCollapsed = ref(false)
132
+ // UI 状态(从 sessionStorage 恢复)
133
+ const sidebarCollapsed = ref(sessionStorage.getItem('sidebarCollapsed') === 'true')
134
+ const tocCollapsed = ref(sessionStorage.getItem('tocCollapsed') === 'true')
106
135
  const zoomVisible = ref(false)
107
136
  const zoomContent = ref('')
108
137
  const zoomImages = ref([])
@@ -111,12 +140,15 @@ const zoomIndex = ref(0)
111
140
  // composables
112
141
  const {
113
142
  docsList, currentDoc, currentDocTitle, showWelcome, htmlContent, tocItems,
143
+ editMode, rawMarkdown, currentDocFilePath, lastModified,
114
144
  scrollProgress, showBackToTop, activeHeading,
115
145
  handleScroll, scrollToHeading, scrollToTop,
116
146
  loadDocsList, loadFromUrl, goHome, loadDoc, loadFirstDoc,
117
147
  handleDocSelect, handleContentClick, handleSearchSelect,
148
+ toggleEditMode, reloadDocsList, reloadCurrentDoc, saveDoc, getCurrentDocPath,
118
149
  toggleFolder, onExpandAll, onCollapseAll,
119
- prevDoc, nextDoc
150
+ prevDoc, nextDoc,
151
+ createDoc, deleteDoc, renameDoc, reorderDocs
120
152
  } = useDocManager()
121
153
 
122
154
  const { sidebarWidth, tocWidth, startResize } = useResize()
@@ -124,6 +156,94 @@ const { sidebarWidth, tocWidth, startResize } = useResize()
124
156
  const { openSearch } = useSearch()
125
157
  const { isMobile, mobileDrawerOpen, mobileTocOpen } = useMobile()
126
158
 
159
+ // 文件监听(DocService 统一处理模式检测和 ETag)
160
+ useFileWatcher({
161
+ getCurrentDocPath,
162
+ onDocsListChange: (newTree) => reloadDocsList(newTree),
163
+ onDocContentChange: (content) => reloadCurrentDoc(content)
164
+ })
165
+
166
+ // 切换编辑模式(基于锚点/标题文本保持阅读位置)
167
+ function onToggleEdit(isEdit) {
168
+ const contentEl = document.querySelector('.content')
169
+ let anchorId = ''
170
+ let headingText = ''
171
+ let scrollRatio = 0
172
+
173
+ if (contentEl) {
174
+ const headings = contentEl.querySelectorAll('.markdown-content h1, .markdown-content h2, .markdown-content h3, .markdown-content h4, .markdown-content h5, .markdown-content h6')
175
+ const contentRect = contentEl.getBoundingClientRect()
176
+ const scrollTop = contentEl.scrollTop
177
+
178
+ // 找到当前视口中最近的标题(最后一个已滚过顶部的)
179
+ for (const heading of headings) {
180
+ const rect = heading.getBoundingClientRect()
181
+ const offsetFromTop = rect.top - contentRect.top
182
+ if (offsetFromTop <= 80) {
183
+ anchorId = heading.id || ''
184
+ // 提取纯文本(去掉锚点图标等)
185
+ const clone = heading.cloneNode(true)
186
+ clone.querySelectorAll('.heading-anchor').forEach(a => a.remove())
187
+ headingText = clone.textContent.trim()
188
+ }
189
+ }
190
+
191
+ // 记录滚动比例作为兜底
192
+ const scrollHeight = contentEl.scrollHeight - contentEl.clientHeight
193
+ scrollRatio = scrollHeight > 0 ? scrollTop / scrollHeight : 0
194
+ }
195
+
196
+ editMode.value = isEdit
197
+ sessionStorage.setItem('editMode', isEdit)
198
+
199
+ nextTick(() => {
200
+ const newContentEl = document.querySelector('.content')
201
+ if (!newContentEl) return
202
+
203
+ // 策略1:通过 id 定位(查看模式有 id)
204
+ if (anchorId) {
205
+ const target = document.getElementById(anchorId)
206
+ if (target) {
207
+ target.scrollIntoView({ block: 'start' })
208
+ activeHeading.value = anchorId
209
+ return
210
+ }
211
+ }
212
+
213
+ // 策略2:通过标题文本匹配(编辑模式无 id,但文本一致)
214
+ if (headingText) {
215
+ const headings = newContentEl.querySelectorAll('h1, h2, h3, h4, h5, h6')
216
+ for (const heading of headings) {
217
+ const clone = heading.cloneNode(true)
218
+ clone.querySelectorAll('.heading-anchor').forEach(a => a.remove())
219
+ if (clone.textContent.trim() === headingText) {
220
+ heading.scrollIntoView({ block: 'start' })
221
+ if (heading.id) activeHeading.value = heading.id
222
+ return
223
+ }
224
+ }
225
+ }
226
+
227
+ // 策略3:按滚动比例恢复
228
+ const scrollHeight = newContentEl.scrollHeight - newContentEl.clientHeight
229
+ if (scrollHeight > 0) {
230
+ newContentEl.scrollTop = scrollRatio * scrollHeight
231
+ }
232
+ })
233
+ }
234
+
235
+ // 编辑器内容更新
236
+ function onEditorUpdate(md) {
237
+ rawMarkdown.value = md
238
+ }
239
+
240
+ // 保存文档
241
+ async function onSaveDoc({ path, content }) {
242
+ const ok = await saveDoc({ path, content })
243
+ if (ok) {
244
+ resetContentEtag()
245
+ }
246
+ }
127
247
 
128
248
  // 内容区点击:委托给 docManager,图片放大回调在这里处理
129
249
  function onContentClick(event) {
@@ -139,6 +259,10 @@ function onContentClick(event) {
139
259
  // 全局快捷键
140
260
  window.addEventListener('popstate', () => loadFromUrl())
141
261
 
262
+ // 持久化 UI 状态
263
+ watch(sidebarCollapsed, (v) => sessionStorage.setItem('sidebarCollapsed', v))
264
+ watch(tocCollapsed, (v) => sessionStorage.setItem('tocCollapsed', v))
265
+
142
266
  onMounted(async () => {
143
267
  await loadDocsList()
144
268
  await loadFromUrl()
@@ -4,7 +4,6 @@
4
4
  :class="{ 'sidebar-drawer': isMobile, 'drawer-open': drawerOpen }"
5
5
  :style="!isMobile ? { width: width + 'px' } : undefined"
6
6
  >
7
- <!-- 移动端显示 Logo + 关闭按钮,桌面端只显示收起按钮 -->
8
7
  <div class="sidebar-header">
9
8
  <Logo v-if="isMobile" @go-home="$emit('go-home')" />
10
9
  <div class="sidebar-header-actions">
@@ -20,6 +19,9 @@
20
19
  <div class="nav-section">
21
20
  <span>文档目录</span>
22
21
  <div class="nav-actions">
22
+ <button class="action-btn" @click="showCreateMenu($event, '')" title="新建">
23
+ <Plus :size="14" />
24
+ </button>
23
25
  <button class="action-btn" @click="$emit('expand-all')" title="全部展开">
24
26
  <ChevronsDownUp :size="14" />
25
27
  </button>
@@ -28,37 +30,106 @@
28
30
  </button>
29
31
  </div>
30
32
  </div>
31
- <!-- 过滤输入框 -->
32
33
  <div class="nav-filter">
33
34
  <Filter :size="12" class="nav-filter-icon" />
34
- <input
35
- v-model="filterText"
36
- type="text"
37
- class="nav-filter-input"
38
- placeholder="过滤文档..."
39
- />
35
+ <input v-model="filterText" type="text" class="nav-filter-input" placeholder="过滤文档..." />
40
36
  <button v-if="filterText" class="nav-filter-clear" @click="filterText = ''">
41
37
  <X :size="12" />
42
38
  </button>
43
39
  </div>
44
- <TreeNode
45
- v-for="item in filteredDocs"
46
- :key="item.key"
47
- :item="item"
48
- :currentDoc="currentDoc"
49
- @toggle="$emit('toggle-folder', $event)"
50
- @select="$emit('select-doc', $event)"
51
- />
40
+ <!-- 无过滤时使用拖拽排序 -->
41
+ <draggable
42
+ v-if="!filterText"
43
+ :list="docsList"
44
+ :group="{ name: 'doc-tree', pull: true, put: true }"
45
+ item-key="key"
46
+ :animation="200"
47
+ ghost-class="drag-ghost"
48
+ chosen-class="drag-chosen"
49
+ drag-class="drag-active"
50
+ handle=".nav-item"
51
+ :fallback-on-body="true"
52
+ :swap-threshold="0.65"
53
+ @end="onDragEnd"
54
+ >
55
+ <template #item="{ element }">
56
+ <TreeNode
57
+ :item="element"
58
+ :currentDoc="currentDoc"
59
+ @toggle="$emit('toggle-folder', $event)"
60
+ @select="$emit('select-doc', $event)"
61
+ @show-create="showCreateMenu"
62
+ @rename="(payload) => $emit('rename-doc', payload)"
63
+ @delete="(payload) => $emit('delete-doc', payload)"
64
+ @drag-end="onDragEnd"
65
+ />
66
+ </template>
67
+ </draggable>
68
+ <!-- 过滤模式下禁用拖拽 -->
69
+ <template v-else>
70
+ <TreeNode
71
+ v-for="item in filteredDocs"
72
+ :key="item.key"
73
+ :item="item"
74
+ :currentDoc="currentDoc"
75
+ @toggle="$emit('toggle-folder', $event)"
76
+ @select="$emit('select-doc', $event)"
77
+ @show-create="showCreateMenu"
78
+ @rename="(payload) => $emit('rename-doc', payload)"
79
+ @delete="(payload) => $emit('delete-doc', payload)"
80
+ />
81
+ </template>
52
82
  <div v-if="filterText && filteredDocs.length === 0" class="nav-filter-empty">
53
83
  没有匹配的文档
54
84
  </div>
55
85
  </nav>
86
+
87
+ <!-- 新建菜单 -->
88
+ <Teleport to="body">
89
+ <div v-if="createMenuVisible" class="ctx-menu-overlay" @click="createMenuVisible = false">
90
+ <div class="ctx-menu" :style="createMenuStyle" @click.stop>
91
+ <div class="ctx-menu-item" @click="startCreate('file')">
92
+ <FileText :size="14" />
93
+ <span>新建文档</span>
94
+ </div>
95
+ <div class="ctx-menu-item" @click="startCreate('folder')">
96
+ <FolderPlus :size="14" />
97
+ <span>新建目录</span>
98
+ </div>
99
+ </div>
100
+ </div>
101
+ </Teleport>
102
+
103
+ <!-- 新建输入弹窗 -->
104
+ <Teleport to="body">
105
+ <div v-if="createInputVisible" class="modal-overlay" @click="createInputVisible = false">
106
+ <div class="modal-dialog" @click.stop>
107
+ <div class="modal-title">{{ createType === 'file' ? '新建文档' : '新建目录' }}</div>
108
+ <input
109
+ ref="createInputRef"
110
+ v-model="createName"
111
+ class="modal-input"
112
+ :placeholder="createType === 'file' ? '请输入文档名称' : '请输入目录名称'"
113
+ @keydown.enter="confirmCreate"
114
+ @keydown.escape="createInputVisible = false"
115
+ />
116
+ <div v-if="createError" class="modal-error">{{ createError }}</div>
117
+ <div class="modal-actions">
118
+ <button class="modal-btn modal-btn-cancel" @click="createInputVisible = false">取消</button>
119
+ <button class="modal-btn modal-btn-confirm" @click="confirmCreate">确定</button>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ </Teleport>
124
+
125
+
56
126
  </aside>
57
127
  </template>
58
128
 
59
129
  <script setup>
60
- import { ref, computed } from 'vue'
61
- import { ChevronLeft, X, ChevronsDownUp, ChevronsUpDown, Filter } from 'lucide-vue-next'
130
+ import { ref, computed, nextTick, watch } from 'vue'
131
+ import { ChevronLeft, X, ChevronsDownUp, ChevronsUpDown, Filter, Plus, FileText, FolderPlus } from 'lucide-vue-next'
132
+ import draggable from 'vuedraggable'
62
133
  import Logo from './Logo.vue'
63
134
  import TreeNode from './TreeNode.vue'
64
135
 
@@ -70,15 +141,16 @@ const props = defineProps({
70
141
  width: { type: Number, default: 320 }
71
142
  })
72
143
 
73
- defineEmits([
144
+ const emit = defineEmits([
74
145
  'go-home', 'close-drawer', 'collapse',
75
146
  'expand-all', 'collapse-all',
76
- 'toggle-folder', 'select-doc'
147
+ 'toggle-folder', 'select-doc',
148
+ 'create-doc', 'delete-doc', 'rename-doc',
149
+ 'reorder'
77
150
  ])
78
151
 
79
152
  const filterText = ref('')
80
153
 
81
- // 递归过滤文档树:保留匹配的文件和包含匹配文件的文件夹
82
154
  function filterTree(items, keyword) {
83
155
  if (!keyword) return items
84
156
  const lower = keyword.toLowerCase()
@@ -99,4 +171,92 @@ function filterTree(items, keyword) {
99
171
  }
100
172
 
101
173
  const filteredDocs = computed(() => filterTree(props.docsList, filterText.value))
174
+
175
+ // 当前文档变化时,自动滚动菜单到选中项
176
+ watch(() => props.currentDoc, (newDoc) => {
177
+ if (!newDoc) return
178
+ nextTick(() => {
179
+ const activeEl = document.querySelector('.nav-menu .nav-item.active')
180
+ if (activeEl) {
181
+ activeEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
182
+ }
183
+ })
184
+ })
185
+
186
+ // ===== 新建菜单 =====
187
+ const createMenuVisible = ref(false)
188
+ const createMenuStyle = ref({})
189
+ const createParentKey = ref('')
190
+
191
+ function showCreateMenu(event, parentKey) {
192
+ createParentKey.value = parentKey
193
+ const rect = event.currentTarget.getBoundingClientRect()
194
+ // 先以不可见状态渲染,等测量后再修正位置
195
+ createMenuStyle.value = {
196
+ position: 'fixed',
197
+ left: `${rect.right + 4}px`,
198
+ top: `${rect.top}px`,
199
+ zIndex: 9999,
200
+ visibility: 'hidden'
201
+ }
202
+ createMenuVisible.value = true
203
+ nextTick(() => {
204
+ const menuEl = document.querySelector('.ctx-menu')
205
+ if (menuEl) {
206
+ const menuRect = menuEl.getBoundingClientRect()
207
+ let left = rect.right + 4
208
+ let top = rect.top
209
+ // 右侧溢出修正
210
+ if (left + menuRect.width > window.innerWidth - 10) {
211
+ left = rect.left - menuRect.width - 4
212
+ }
213
+ // 底部溢出修正:向上弹出,使菜单底部对齐触发按钮底部
214
+ if (top + menuRect.height > window.innerHeight - 10) {
215
+ top = rect.bottom - menuRect.height
216
+ }
217
+ // 如果向上修正后超出顶部,则贴顶
218
+ if (top < 10) {
219
+ top = 10
220
+ }
221
+ createMenuStyle.value = {
222
+ position: 'fixed',
223
+ left: `${left}px`,
224
+ top: `${top}px`,
225
+ zIndex: 9999,
226
+ visibility: 'visible'
227
+ }
228
+ }
229
+ })
230
+ }
231
+
232
+ // ===== 新建输入 =====
233
+ const createInputVisible = ref(false)
234
+ const createInputRef = ref(null)
235
+ const createType = ref('file')
236
+ const createName = ref('')
237
+ const createError = ref('')
238
+
239
+ function startCreate(type) {
240
+ createMenuVisible.value = false
241
+ createType.value = type
242
+ createName.value = ''
243
+ createError.value = ''
244
+ createInputVisible.value = true
245
+ nextTick(() => createInputRef.value?.focus())
246
+ }
247
+
248
+ function confirmCreate() {
249
+ const name = createName.value.trim()
250
+ if (!name) { createError.value = '名称不能为空'; return }
251
+ if (/[\\/:*?"<>|]/.test(name)) { createError.value = '名称包含非法字符'; return }
252
+ createError.value = ''
253
+ emit('create-doc', { parentKey: createParentKey.value, name, type: createType.value })
254
+ createInputVisible.value = false
255
+ }
256
+
257
+ // ===== 拖拽排序 =====
258
+ function onDragEnd() {
259
+ // 拖拽结束后,通知父组件执行重编号
260
+ emit('reorder')
261
+ }
102
262
  </script>
@@ -0,0 +1,72 @@
1
+ <template>
2
+ <node-view-wrapper class="code-block-wrapper editor-code-block" data-type="codeBlock">
3
+ <!-- 代码块 header -->
4
+ <div class="code-block-header" contenteditable="false">
5
+ <input
6
+ class="code-lang-input"
7
+ :value="node.attrs.language || ''"
8
+ @input="updateLanguage"
9
+ placeholder="语言"
10
+ spellcheck="false"
11
+ />
12
+ <div class="code-block-actions">
13
+ <button class="code-action-btn" :class="{ copied }" @click="copyCode" title="复制代码">
14
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
15
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
16
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
17
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
18
+ </svg>
19
+ <span class="copy-text">{{ copied ? '已复制' : '复制' }}</span>
20
+ </button>
21
+ </div>
22
+ </div>
23
+ <!-- 代码编辑区域:行号 gutter + 编辑区 -->
24
+ <div class="code-block-body">
25
+ <div class="code-edit-layout">
26
+ <!-- 行号列 -->
27
+ <div class="code-line-gutter" contenteditable="false" aria-hidden="true">
28
+ <span v-for="n in lineCount" :key="n" class="code-ln-num">{{ n }}</span>
29
+ </div>
30
+ <!-- 编辑区 -->
31
+ <pre class="code-edit-area"><node-view-content as="code" :class="codeClass" /></pre>
32
+ </div>
33
+ </div>
34
+ </node-view-wrapper>
35
+ </template>
36
+
37
+ <script setup>
38
+ import { ref, computed } from 'vue'
39
+ import { nodeViewProps, NodeViewWrapper, NodeViewContent } from '@tiptap/vue-3'
40
+
41
+ const props = defineProps(nodeViewProps)
42
+ const copied = ref(false)
43
+
44
+ const codeClass = computed(() => {
45
+ const lang = props.node.attrs.language
46
+ return lang ? `language-${lang}` : ''
47
+ })
48
+
49
+ // 根据代码内容计算行数(响应式,编辑时自动更新)
50
+ const lineCount = computed(() => {
51
+ const text = props.node.textContent
52
+ if (!text) return 1
53
+ return text.split('\n').length
54
+ })
55
+
56
+ // 更新语言属性
57
+ function updateLanguage(e) {
58
+ props.updateAttributes({ language: e.target.value || null })
59
+ }
60
+
61
+ // 复制代码
62
+ async function copyCode() {
63
+ const text = props.node.textContent
64
+ try {
65
+ await navigator.clipboard.writeText(text)
66
+ copied.value = true
67
+ setTimeout(() => { copied.value = false }, 2000)
68
+ } catch {
69
+ // fallback
70
+ }
71
+ }
72
+ </script>