md2ui 1.0.18 → 1.0.20

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 (80) hide show
  1. package/README.md +51 -58
  2. package/bin/build.js +95 -9
  3. package/bin/md2ui.js +102 -13
  4. package/package.json +24 -10
  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 +88 -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/02-Markdown/346/270/262/346/237/223/06-Mermaid/345/244/215/346/235/202/345/233/276/350/241/250/346/265/213/350/257/225.md +1376 -0
  14. package/public/docs/02-Markdown/346/270/262/346/237/223/assets/img-1777383093712.png +0 -0
  15. 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
  16. 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
  17. 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
  18. 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
  19. 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
  20. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/05-/345/244/247/347/272/262/345/216/213/345/212/233/346/265/213/350/257/225.md +340 -0
  21. 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
  22. 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
  23. 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
  24. 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
  25. 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
  26. 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
  27. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/assets/img-1777261394722.png +0 -0
  28. 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
  29. 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
  30. 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
  31. 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
  32. 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
  33. 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
  34. 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
  35. 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
  36. 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
  37. package/src/App.vue +111 -12
  38. package/src/components/AppSidebar.vue +181 -21
  39. package/src/components/CodeBlockNodeView.vue +72 -0
  40. package/src/components/DocContent.vue +25 -14
  41. package/src/components/EditorContent.vue +257 -0
  42. package/src/components/EditorToolbar.vue +264 -0
  43. package/src/components/ImageZoom.vue +88 -5
  44. package/src/components/MathBlockNodeView.vue +160 -0
  45. package/src/components/MathInlineNodeView.vue +145 -0
  46. package/src/components/MermaidNodeView.vue +157 -0
  47. package/src/components/MobileSearch.vue +97 -0
  48. package/src/components/TableOfContents.vue +174 -32
  49. package/src/components/TopBar.vue +69 -4
  50. package/src/components/TreeNode.vue +232 -39
  51. package/src/components/WelcomePage.vue +2 -2
  52. package/src/composables/useDocHash.js +9 -1
  53. package/src/composables/useDocManager.js +452 -105
  54. package/src/composables/useDocTree.js +33 -2
  55. package/src/composables/useExportWord.js +73 -10
  56. package/src/composables/useFileWatcher.js +45 -0
  57. package/src/composables/useFrontmatter.js +2 -2
  58. package/src/composables/useMarkdown.js +450 -52
  59. package/src/composables/useMermaidCache.js +15 -0
  60. package/src/composables/useScroll.js +354 -27
  61. package/src/composables/useSearch.js +12 -11
  62. package/src/config.js +1 -4
  63. package/src/extensions/CodeBlockCustom.js +113 -0
  64. package/src/extensions/MathBlock.js +107 -0
  65. package/src/extensions/MathInline.js +100 -0
  66. package/src/extensions/MermaidBlock.js +73 -0
  67. package/src/extensions/TableControls.js +670 -0
  68. package/src/services/DocService.js +168 -0
  69. package/src/style.css +2416 -36
  70. package/src/utils/imageConverter.js +129 -0
  71. package/vite-plugin-doc-api.js +369 -0
  72. package/vite.config.js +7 -2
  73. package/public/docs/02-Mermaid/345/233/276/350/241/250.md +0 -102
  74. 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
  75. 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
  76. 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
  77. package/public/docs/04-API/345/217/202/350/200/203/01-/347/273/204/344/273/266API.md +0 -80
  78. package/public/docs/04-API/345/217/202/350/200/203/02-Composables.md +0 -92
  79. package/src/api/docs.js +0 -106
  80. package/src/components/SearchPanel.vue +0 -90
@@ -1,10 +1,12 @@
1
- import { ref, computed, nextTick } from 'vue'
2
- import { getDocsList } from '../api/docs.js'
1
+ import { ref, computed, watch, nextTick } from 'vue'
2
+ import * as docService from '../services/DocService.js'
3
3
  import { useMarkdown } from './useMarkdown.js'
4
4
  import { useSearch } from './useSearch.js'
5
5
  import { useScroll } from './useScroll.js'
6
6
  import { useMobile } from './useMobile.js'
7
- import { findDoc, findFirstDoc, findReadmeDoc, findDocByHash, expandParents, flattenDocsList, expandAll, collapseAll } from './useDocTree.js'
7
+ import { findDoc, findFirstDoc, findReadmeDoc, findDocByHash, expandParents, flattenDocsList, expandAll, collapseAll, buildHashIndex } from './useDocTree.js'
8
+ import { stripOrderPrefix } from './useDocHash.js'
9
+ import { clearMermaidCache } from './useMermaidCache.js'
8
10
 
9
11
  // 等待内容区域图片加载完成
10
12
  async function waitForContentImages(timeoutMs = 3000) {
@@ -21,88 +23,121 @@ export function useDocManager() {
21
23
  // 文档状态
22
24
  const docsList = ref([])
23
25
  const currentDoc = ref('')
24
- // 如果 URL 有路径,说明是刷新已有文档页面,初始不显示欢迎页,避免闪烁
25
26
  const hasInitialPath = window.location.pathname.replace(/^\//, '') !== ''
26
27
  const showWelcome = ref(!hasInitialPath)
28
+ const lastModified = ref('')
29
+
30
+ // 编辑模式(从 sessionStorage 恢复)
31
+ const editMode = ref(sessionStorage.getItem('editMode') === 'true')
32
+ const rawMarkdown = ref('')
33
+ const currentDocFilePath = ref('')
27
34
 
28
35
  // composables
29
- const { htmlContent, tocItems, renderMarkdown, docHash } = useMarkdown()
36
+ const { htmlContent, tocItems, renderMarkdown, extractTOCFromMarkdown, docHash } = useMarkdown()
30
37
  const { buildIndex } = useSearch()
31
38
  const {
32
39
  scrollProgress, showBackToTop, activeHeading,
33
40
  handleScroll: _handleScroll,
34
41
  scrollToHeading: _scrollToHeading,
35
- scrollToTop
42
+ scrollToTop,
43
+ setTocItems,
44
+ rebuildHeadingsCache,
45
+ clearActiveHeading,
46
+ isSuppressHashClear,
47
+ lockHeading
36
48
  } = useScroll()
37
49
  const { isMobile, mobileDrawerOpen } = useMobile()
38
50
 
39
- // 获取当前滚动位置
51
+ setTocItems(tocItems)
52
+
53
+ // ===== 滚动 & 历史 =====
40
54
  function getScrollTop() {
41
55
  const el = document.querySelector('.content')
42
56
  return el ? el.scrollTop : 0
43
57
  }
44
58
 
45
- // 构造 history state,保留滚动位置
46
59
  function makeState(scrollTop) {
47
60
  return { scrollTop: scrollTop ?? getScrollTop() }
48
61
  }
49
62
 
50
- // 滚动处理,同步锚点到 URL(replaceState 保留滚动位置)
63
+ // URL 锚点同步:统一由 watch 驱动,scrollToHeading 不再直接操作 URL
64
+ // _pendingPush: 标记下一次 activeHeading 变化是否需要 pushState(点击目录项)
65
+ let _pendingPush = false
66
+ let _hashUpdateTimer = null
67
+
68
+ watch(activeHeading, (id, oldId) => {
69
+ if (!currentDoc.value) return
70
+
71
+ // 文档切换导致的清空,不动 URL
72
+ if (!id && isSuppressHashClear()) return
73
+
74
+ // 取出并重置 push 标记
75
+ const shouldPush = _pendingPush
76
+ _pendingPush = false
77
+
78
+ if (_hashUpdateTimer) clearTimeout(_hashUpdateTimer)
79
+ _hashUpdateTimer = setTimeout(() => {
80
+ _hashUpdateTimer = null
81
+ const base = `/${docHash(currentDoc.value)}`
82
+ const url = id ? `${base}#${id}` : base
83
+ const current = window.location.pathname + window.location.hash
84
+ if (current === url) return
85
+
86
+ if (shouldPush) {
87
+ // 先保存当前滚动位置到旧条目
88
+ history.replaceState(makeState(), '', current)
89
+ history.pushState(makeState(), '', url)
90
+ } else {
91
+ history.replaceState(makeState(), '', url)
92
+ }
93
+ }, 100)
94
+ })
95
+
51
96
  function handleScroll(e) {
52
97
  _handleScroll(e)
53
- if (activeHeading.value && currentDoc.value) {
54
- history.replaceState(makeState(), '', `/${docHash(currentDoc.value)}#${activeHeading.value}`)
55
- }
56
98
  }
57
99
 
58
- // push: true 表示用户主动点击锚点,产生可回退的历史条目
59
100
  function scrollToHeading(id, { push = false } = {}) {
60
- if (push && currentDoc.value) {
61
- // 先把当前滚动位置写入即将被覆盖的历史条目
62
- history.replaceState(makeState(), '', window.location.href)
63
- }
101
+ if (push) _pendingPush = true
64
102
  _scrollToHeading(id)
65
- if (currentDoc.value) {
66
- const url = `/${docHash(currentDoc.value)}#${id}`
67
- if (push) {
68
- history.pushState(makeState(), '', url)
69
- } else {
70
- history.replaceState(makeState(), '', url)
71
- }
72
- }
73
103
  }
74
104
 
75
- // 加载文档列表
105
+ // ===== 文档列表加载 =====
76
106
  async function loadDocsList() {
77
- docsList.value = await getDocsList()
107
+ docsList.value = await docService.fetchDocsList()
108
+ buildHashIndex(docsList.value, docHash)
109
+ restoreExpandedState()
78
110
  buildIndex(docsList.value)
79
111
  }
80
112
 
81
- // 回到欢迎页(isPopstate: true 表示由浏览器回退触发,不操作 history)
113
+ // ===== 导航 =====
82
114
  function goHome({ isPopstate = false } = {}) {
83
115
  currentDoc.value = ''
84
116
  showWelcome.value = true
85
117
  htmlContent.value = ''
86
118
  tocItems.value = []
119
+ editMode.value = false
120
+ lastModified.value = ''
121
+ sessionStorage.setItem('editMode', 'false')
122
+ document.title = 'md2ui'
87
123
  if (!isPopstate) {
88
- // 用户主动点击,保存旧条目滚动位置后 push
89
124
  history.replaceState(makeState(), '', window.location.href)
90
125
  history.pushState(makeState(0), '', '/')
91
126
  }
92
127
  if (isMobile.value) mobileDrawerOpen.value = false
93
128
  }
94
129
 
95
- // 加载文档
96
130
  async function loadDoc(key, { replace = false, anchor = '', keepState = false } = {}) {
97
131
  currentDoc.value = key
98
132
  showWelcome.value = false
133
+ lastContentHash = ''
134
+ clearMermaidCache()
135
+ docService.resetContentEtag()
99
136
  const hash = docHash(key)
100
137
  const url = anchor ? `/${hash}#${anchor}` : `/${hash}`
101
138
  if (replace) {
102
- // keepState: popstate 回退时保留浏览器已有的 state(含 scrollTop)
103
139
  if (!keepState) history.replaceState(makeState(0), '', url)
104
140
  } else {
105
- // push 前先保存当前滚动位置到旧条目
106
141
  history.replaceState(makeState(), '', window.location.href)
107
142
  history.pushState(makeState(0), '', url)
108
143
  }
@@ -112,42 +147,155 @@ export function useDocManager() {
112
147
  const response = await fetch(doc.path)
113
148
  if (response.ok) {
114
149
  const content = await response.text()
115
- await renderMarkdown(content, key, docsList.value)
150
+ rawMarkdown.value = content
151
+ // 捕获最后修改时间
152
+ const lm = response.headers.get('x-last-modified')
153
+ lastModified.value = lm || ''
154
+ // 提取文件路径供轮询使用
155
+ currentDocFilePath.value = doc.path.replace(/^\/@user-docs\//, '')
156
+ if (editMode.value) {
157
+ extractTOCFromMarkdown(content, tocItems)
158
+ } else {
159
+ await renderMarkdown(content, key, docsList.value)
160
+ }
161
+ // 渲染完成后重建标题缓存(编辑模式需等 tiptap 渲染完成)
162
+ await nextTick()
163
+ rebuildHeadingsCache()
164
+ // 切换文档时清空 activeHeading(标记为文档切换,不清 URL hash)
165
+ // 同时取消 pending 的 hash 同步 timer
166
+ if (_hashUpdateTimer) { clearTimeout(_hashUpdateTimer); _hashUpdateTimer = null }
167
+ // 如果有锚点参数,先锁定再清空,防止 scrollTop=0 触发的滚动检测覆盖锚点
168
+ if (anchor) {
169
+ lockHeading(anchor)
170
+ } else {
171
+ clearActiveHeading()
172
+ }
116
173
  const contentEl = document.querySelector('.content')
117
174
  if (contentEl) contentEl.scrollTop = 0
175
+ // 动态更新页面标题(SEO + 浏览器标签页)
176
+ const docTitle = findDoc(docsList.value, key)?.label || ''
177
+ document.title = docTitle ? `${docTitle} - md2ui` : 'md2ui'
118
178
  }
119
179
  } catch (error) {
120
180
  console.error('加载文档失败:', error)
121
181
  }
122
182
  }
123
183
 
124
- // 加载第一篇文档
125
184
  function loadFirstDoc() {
126
185
  const first = findFirstDoc(docsList.value)
127
186
  if (first) loadDoc(first.key)
128
187
  }
129
188
 
130
- // 选择文档(移动端自动关闭抽屉)
131
189
  function handleDocSelect(key) {
132
190
  loadDoc(key)
133
191
  if (isMobile.value) mobileDrawerOpen.value = false
134
192
  }
135
193
 
136
- // 文件夹操作
137
- function toggleFolder(item) { item.expanded = !item.expanded }
138
- function onExpandAll() { expandAll(docsList.value) }
139
- function onCollapseAll() { collapseAll(docsList.value) }
194
+ // ===== 文件夹操作 =====
195
+ function toggleFolder(item) { item.expanded = !item.expanded; saveExpandedState() }
196
+ function onExpandAll() { expandAll(docsList.value); saveExpandedState() }
197
+ function onCollapseAll() { collapseAll(docsList.value); saveExpandedState() }
198
+
199
+ function saveExpandedState() {
200
+ const expanded = []
201
+ function collect(items) {
202
+ for (const item of items) {
203
+ if (item.type === 'folder') {
204
+ if (item.expanded) expanded.push(item.key)
205
+ if (item.children) collect(item.children)
206
+ }
207
+ }
208
+ }
209
+ collect(docsList.value)
210
+ sessionStorage.setItem('expandedFolders', JSON.stringify(expanded))
211
+ }
212
+
213
+ function restoreExpandedState() {
214
+ const raw = sessionStorage.getItem('expandedFolders')
215
+ if (!raw) return
216
+ try {
217
+ const expanded = new Set(JSON.parse(raw))
218
+ function apply(items) {
219
+ for (const item of items) {
220
+ if (item.type === 'folder') {
221
+ if (expanded.has(item.key)) item.expanded = true
222
+ if (item.children) apply(item.children)
223
+ }
224
+ }
225
+ }
226
+ apply(docsList.value)
227
+ } catch { /* ignore */ }
228
+ }
229
+
230
+ // ===== 内容区点击处理 =====
231
+
232
+ // 复制锚点链接到剪贴板(通过 class 切换锚点旁提示文字)
233
+ async function copyAnchorLink(anchorId, anchorEl) {
234
+ const base = currentDoc.value ? `/${docHash(currentDoc.value)}` : window.location.pathname
235
+ const url = `${window.location.origin}${base}#${anchorId}`
236
+ try {
237
+ await navigator.clipboard.writeText(url)
238
+ anchorEl.classList.add('anchor-copied')
239
+ setTimeout(() => anchorEl.classList.remove('anchor-copied'), 1500)
240
+ } catch {
241
+ anchorEl.classList.add('anchor-copy-error')
242
+ setTimeout(() => anchorEl.classList.remove('anchor-copy-error'), 1500)
243
+ }
244
+ }
140
245
 
141
- // 内容区点击处理(链接跳转 + 图片放大)
142
246
  function handleContentClick(event, { onZoom }) {
143
247
  const target = event.target
248
+
249
+ // 优先判断图片放大(即使图片在链接内,也走放大逻辑而非链接跳转)
250
+ const isImg = target.tagName === 'IMG' && target.classList.contains('zoomable-image')
251
+ const mermaidEl = target.closest('.mermaid') || target.closest('.mermaid-svg')
252
+ const isMermaid = mermaidEl && mermaidEl.classList.contains('zoomable-image')
253
+ if (isImg || isMermaid) {
254
+ event.preventDefault()
255
+ const container = document.querySelector('.markdown-content')
256
+ if (!container) return
257
+ const allZoomable = [...container.querySelectorAll('.zoomable-image')]
258
+ const images = allZoomable.map(el => {
259
+ if (el.tagName === 'IMG') {
260
+ return `<img src="${el.src}" alt="${el.alt || ''}" style="max-width: 100%; height: auto;" />`
261
+ }
262
+ const clone = el.cloneNode(true)
263
+ clone.querySelectorAll('.mermaid-copy-btn, .image-copy-btn').forEach(btn => btn.remove())
264
+ return clone.innerHTML
265
+ })
266
+ const clickedEl = isImg ? target : mermaidEl
267
+ const index = allZoomable.indexOf(clickedEl)
268
+ onZoom({ images, index: Math.max(index, 0) })
269
+ return
270
+ }
271
+
272
+ // 链接处理
144
273
  const link = target.closest('a')
145
274
  if (link) {
275
+ // 标题锚点点击:复制链接 + 跳转 + 高亮
276
+ if (link.classList.contains('heading-anchor')) {
277
+ event.preventDefault()
278
+ const anchorId = link.dataset.anchor
279
+ if (anchorId) {
280
+ copyAnchorLink(anchorId, link)
281
+ scrollToHeading(anchorId, { push: true })
282
+ // 触发高亮闪烁动画
283
+ const heading = document.getElementById(anchorId)
284
+ if (heading) {
285
+ heading.classList.remove('heading-flash')
286
+ void heading.offsetWidth // 强制 reflow 重置动画
287
+ heading.classList.add('heading-flash')
288
+ heading.addEventListener('animationend', () => heading.classList.remove('heading-flash'), { once: true })
289
+ }
290
+ }
291
+ return
292
+ }
146
293
  const docKey = link.dataset.docKey
147
294
  if (docKey) {
148
295
  event.preventDefault()
149
296
  const anchor = link.dataset.anchor || ''
150
297
  expandParents(docsList.value, docKey)
298
+ saveExpandedState()
151
299
  loadDoc(docKey).then(async () => {
152
300
  if (anchor) { await nextTick(); await waitForContentImages(); scrollToHeading(anchor) }
153
301
  })
@@ -160,69 +308,169 @@ export function useDocManager() {
160
308
  }
161
309
  return
162
310
  }
163
- // 图片/Mermaid 放大(收集所有可放大元素,支持左右切换)
164
- const isImg = target.tagName === 'IMG' && target.classList.contains('zoomable-image')
165
- const mermaidEl = target.closest('.mermaid')
166
- const isMermaid = mermaidEl && mermaidEl.classList.contains('zoomable-image')
167
- if (isImg || isMermaid) {
168
- const container = document.querySelector('.markdown-content')
169
- if (!container) return
170
- // 收集所有可放大元素
171
- const allZoomable = [...container.querySelectorAll('.zoomable-image')]
172
- const images = allZoomable.map(el => {
173
- if (el.tagName === 'IMG') {
174
- return `<img src="${el.src}" alt="${el.alt || ''}" style="max-width: 100%; height: auto;" />`
175
- }
176
- return el.innerHTML
177
- })
178
- const clickedEl = isImg ? target : mermaidEl
179
- const index = allZoomable.indexOf(clickedEl)
180
- onZoom({ images, index: Math.max(index, 0) })
181
- }
182
311
  }
183
312
 
184
- // 当前文档标题
313
+ // ===== 计算属性 =====
185
314
  const currentDocTitle = computed(() => {
186
315
  if (!currentDoc.value) return '文档'
187
316
  const doc = findDoc(docsList.value, currentDoc.value)
188
317
  return doc?.label || '文档'
189
318
  })
190
319
 
191
- // 上一篇/下一篇
320
+ // 扁平化文档列表(缓存,供 prevDoc / nextDoc 共享)
321
+ const flatList = computed(() => flattenDocsList(docsList.value))
322
+
192
323
  const prevDoc = computed(() => {
193
324
  if (!currentDoc.value) return null
194
- const flat = flattenDocsList(docsList.value)
195
- const idx = flat.findIndex(d => d.key === currentDoc.value)
196
- return idx > 0 ? flat[idx - 1] : null
325
+ const idx = flatList.value.findIndex(d => d.key === currentDoc.value)
326
+ return idx > 0 ? flatList.value[idx - 1] : null
197
327
  })
198
328
 
199
329
  const nextDoc = computed(() => {
200
330
  if (!currentDoc.value) return null
201
- const flat = flattenDocsList(docsList.value)
202
- const idx = flat.findIndex(d => d.key === currentDoc.value)
203
- return idx >= 0 && idx < flat.length - 1 ? flat[idx + 1] : null
331
+ const idx = flatList.value.findIndex(d => d.key === currentDoc.value)
332
+ return idx >= 0 && idx < flatList.value.length - 1 ? flatList.value[idx + 1] : null
204
333
  })
205
334
 
206
- // 搜索结果选中
207
335
  function handleSearchSelect(key) {
208
336
  expandParents(docsList.value, key)
337
+ saveExpandedState()
209
338
  loadDoc(key)
210
339
  }
211
340
 
212
- // URL 路由(popstate / 初始加载)
341
+ // ===== 编辑模式 =====
342
+ function toggleEditMode() {
343
+ editMode.value = !editMode.value
344
+ sessionStorage.setItem('editMode', editMode.value)
345
+ }
346
+
347
+ watch(editMode, async (newVal, oldVal) => {
348
+ if (oldVal && !newVal && rawMarkdown.value && currentDoc.value) {
349
+ await renderMarkdown(rawMarkdown.value, currentDoc.value, docsList.value)
350
+ await nextTick()
351
+ rebuildHeadingsCache()
352
+ }
353
+ if (newVal && rawMarkdown.value) {
354
+ extractTOCFromMarkdown(rawMarkdown.value, tocItems)
355
+ await nextTick()
356
+ rebuildHeadingsCache()
357
+ }
358
+ })
359
+
360
+ watch(rawMarkdown, (md) => {
361
+ if (editMode.value && md) {
362
+ extractTOCFromMarkdown(md, tocItems)
363
+ }
364
+ })
365
+
366
+ // ===== 轮询回调(供 useFileWatcher 调用) =====
367
+
368
+ // 树结构指纹,快速判断是否有变化
369
+ function treeFingerprint(items) {
370
+ const parts = []
371
+ for (const item of items) {
372
+ parts.push(item.key)
373
+ if (item.type === 'folder' && item.children) {
374
+ parts.push('{', treeFingerprint(item.children), '}')
375
+ }
376
+ }
377
+ return parts.join(',')
378
+ }
379
+
380
+ function getExpandedKeys(items) {
381
+ const keys = new Set()
382
+ for (const item of items) {
383
+ if (item.type === 'folder') {
384
+ if (item.expanded) keys.add(item.key)
385
+ if (item.children) for (const k of getExpandedKeys(item.children)) keys.add(k)
386
+ }
387
+ }
388
+ return keys
389
+ }
390
+
391
+ function applyExpandedState(items, expandedKeys) {
392
+ for (const item of items) {
393
+ if (item.type === 'folder') {
394
+ if (expandedKeys.has(item.key)) item.expanded = true
395
+ if (item.children) applyExpandedState(item.children, expandedKeys)
396
+ }
397
+ }
398
+ }
399
+
400
+ // 刷新文档列表(轮询回调 或 手动调用)
401
+ async function reloadDocsList(newTree) {
402
+ if (!newTree) {
403
+ // 手动调用(创建/删除/重命名后),强制拉取最新
404
+ docService.resetListEtag()
405
+ newTree = await docService.fetchDocsList()
406
+ }
407
+ // 结构没变就跳过
408
+ if (docsList.value.length > 0 && treeFingerprint(docsList.value) === treeFingerprint(newTree)) {
409
+ return
410
+ }
411
+ // 保存展开状态 → 替换 → 恢复
412
+ const expandedKeys = getExpandedKeys(docsList.value)
413
+ docsList.value = newTree
414
+ buildHashIndex(docsList.value, docHash)
415
+ applyExpandedState(docsList.value, expandedKeys)
416
+ if (currentDoc.value) expandParents(docsList.value, currentDoc.value)
417
+ buildIndex(docsList.value)
418
+ saveExpandedState()
419
+ }
420
+
421
+ // 刷新当前文档内容(轮询回调)
422
+ let lastContentHash = ''
423
+ async function reloadCurrentDoc(content) {
424
+ if (!currentDoc.value) return
425
+ // 哈希比对,内容没变就跳过
426
+ let hash = 0
427
+ for (let i = 0; i < content.length; i++) {
428
+ hash = ((hash << 5) - hash + content.charCodeAt(i)) | 0
429
+ }
430
+ const hashStr = String(hash)
431
+ if (hashStr === lastContentHash) return
432
+ lastContentHash = hashStr
433
+ // 编辑模式下编辑器是内容权威来源,不回写 rawMarkdown 避免循环抖动
434
+ if (editMode.value) return
435
+ rawMarkdown.value = content
436
+ await renderMarkdown(content, currentDoc.value, docsList.value)
437
+ rebuildHeadingsCache()
438
+ }
439
+
440
+ // ===== 写操作 =====
441
+ async function saveDocContent({ path: filePath, content }) {
442
+ try {
443
+ const ok = await docService.saveDoc(filePath, content)
444
+ if (ok) {
445
+ rawMarkdown.value = content
446
+ if (!editMode.value) {
447
+ await renderMarkdown(content, currentDoc.value, docsList.value)
448
+ }
449
+ return true
450
+ }
451
+ return false
452
+ } catch (e) {
453
+ console.error('保存失败:', e)
454
+ return false
455
+ }
456
+ }
457
+
458
+ function getCurrentDocPath() {
459
+ return currentDocFilePath.value
460
+ }
461
+
462
+ // ===== URL 路由 =====
213
463
  async function loadFromUrl() {
214
464
  const pathname = window.location.pathname.replace(/^\//, '')
215
465
  const anchor = window.location.hash.replace('#', '')
216
466
  const savedScroll = history.state?.scrollTop
217
467
  if (!pathname) {
218
468
  if (currentDoc.value) {
219
- // 浏览器回退到首页,不操作 history
220
469
  goHome({ isPopstate: true })
221
470
  } else if (docsList.value.length === 0) {
222
471
  showWelcome.value = false
223
472
  renderMarkdown('# 当前目录没有 Markdown 文档\n\n请在当前目录下添加 `.md` 文件,然后刷新页面。')
224
473
  } else {
225
- // 优先定位到 README,没有则定位到第一篇文档
226
474
  const readme = findReadmeDoc(docsList.value)
227
475
  const target = readme || findFirstDoc(docsList.value)
228
476
  if (target) {
@@ -234,14 +482,13 @@ export function useDocManager() {
234
482
  }
235
483
  const doc = findDocByHash(docsList.value, pathname, docHash)
236
484
  if (!doc) return
237
- // 同一文档内的锚点变化(含回退)
238
485
  if (doc.key === currentDoc.value) {
239
486
  await nextTick()
240
487
  if (savedScroll != null) {
241
- // 有保存的滚动位置,直接恢复(回退场景)
242
488
  const contentEl = document.querySelector('.content')
243
489
  if (contentEl) contentEl.scrollTo({ top: savedScroll, behavior: 'smooth' })
244
490
  } else if (anchor) {
491
+ // popstate 同文档锚点跳转,_scrollToHeading 内部有锁
245
492
  _scrollToHeading(decodeURIComponent(anchor))
246
493
  } else {
247
494
  const contentEl = document.querySelector('.content')
@@ -250,48 +497,148 @@ export function useDocManager() {
250
497
  return
251
498
  }
252
499
  expandParents(docsList.value, doc.key)
500
+ saveExpandedState()
253
501
  await loadDoc(doc.key, { replace: true, keepState: savedScroll != null, anchor: anchor ? decodeURIComponent(anchor) : '' })
254
502
  if (savedScroll != null) {
255
503
  await nextTick()
504
+ await waitForContentImages()
256
505
  const contentEl = document.querySelector('.content')
257
506
  if (contentEl) contentEl.scrollTo({ top: savedScroll })
258
507
  } else if (anchor) {
259
- await nextTick(); await waitForContentImages(); _scrollToHeading(decodeURIComponent(anchor))
508
+ const decodedAnchor = decodeURIComponent(anchor)
509
+ await nextTick(); await waitForContentImages()
510
+ // 复用 scrollToHeading,编辑模式下通过 _syncHeadingIds 注入的 id 或文本匹配均可定位
511
+ _scrollToHeading(decodedAnchor)
512
+ }
513
+ }
514
+
515
+ // ===== 文档管理操作 =====
516
+ async function createDoc({ parentKey, name, type }) {
517
+ const relativePath = parentKey ? `${parentKey}/${name}` : name
518
+ const apiPath = type === 'file' ? `${relativePath}.md` : relativePath
519
+ try {
520
+ await docService.createDoc(apiPath, type)
521
+ await reloadDocsList()
522
+ if (parentKey) expandParents(docsList.value, relativePath)
523
+ if (type === 'file') await loadDoc(relativePath)
524
+ return { ok: true }
525
+ } catch (e) {
526
+ return { ok: false, message: e.message }
527
+ }
528
+ }
529
+
530
+ async function renameDoc(item, newName) {
531
+ const parts = item.key.split('/')
532
+ parts[parts.length - 1] = newName
533
+ const newKey = parts.join('/')
534
+ const oldPath = item.type === 'file' ? `${item.key}.md` : item.key
535
+ const newPath = item.type === 'file' ? `${newKey}.md` : newKey
536
+ try {
537
+ await docService.renameDoc(oldPath, newPath)
538
+ const wasCurrentDoc = item.type === 'file' && item.key === currentDoc.value
539
+ const wasInFolder = item.type === 'folder' && currentDoc.value.startsWith(item.key + '/')
540
+ await reloadDocsList()
541
+ if (wasCurrentDoc) {
542
+ expandParents(docsList.value, newKey)
543
+ await loadDoc(newKey, { replace: true })
544
+ }
545
+ if (wasInFolder) {
546
+ const newDocKey = currentDoc.value.replace(item.key + '/', newKey + '/')
547
+ expandParents(docsList.value, newDocKey)
548
+ await loadDoc(newDocKey, { replace: true })
549
+ }
550
+ return { ok: true }
551
+ } catch (e) {
552
+ return { ok: false, message: e.message }
553
+ }
554
+ }
555
+
556
+ async function deleteDoc(item) {
557
+ const apiPath = item.type === 'file' ? `${item.key}.md` : item.key
558
+ try {
559
+ await docService.deleteDoc(apiPath)
560
+ if (item.type === 'file' && item.key === currentDoc.value) goHome()
561
+ if (item.type === 'folder' && currentDoc.value.startsWith(item.key + '/')) goHome()
562
+ await reloadDocsList()
563
+ return { ok: true }
564
+ } catch (e) {
565
+ return { ok: false, message: e.message }
566
+ }
567
+ }
568
+
569
+ // ===== 拖拽排序 =====
570
+ function calcPadWidth(count) { return count < 100 ? 2 : String(count - 1).length }
571
+ function orderPrefix(index, padWidth) { return String(index).padStart(padWidth, '0') }
572
+ function stripPrefix(name) { return name.replace(/^\d+-/, '') }
573
+
574
+ function collectReorderItems(items, parentPath = '') {
575
+ const result = []
576
+ const padWidth = calcPadWidth(items.length)
577
+ items.forEach((item, index) => {
578
+ const prefix = orderPrefix(index, padWidth)
579
+ const pureName = stripPrefix(item.label || item.key.split('/').pop())
580
+ const newName = `${prefix}-${pureName}`
581
+ const oldName = item.key.split('/').pop()
582
+ const oldFsName = item.type === 'file' ? `${oldName}.md` : oldName
583
+ const newFsName = item.type === 'file' ? `${newName}.md` : newName
584
+ const oldPath = parentPath ? `${parentPath}/${oldFsName}` : oldFsName
585
+ const newPath = parentPath ? `${parentPath}/${newFsName}` : newFsName
586
+ if (oldPath !== newPath) result.push({ oldPath, newPath })
587
+ })
588
+ return result
589
+ }
590
+
591
+ function collectAllReorderLevels(items, parentPath = '') {
592
+ const levels = []
593
+ items.forEach((item) => {
594
+ if (item.type === 'folder' && item.children) {
595
+ const folderPath = parentPath ? `${parentPath}/${item.key.split('/').pop()}` : item.key.split('/').pop()
596
+ levels.push(...collectAllReorderLevels(item.children, folderPath))
597
+ }
598
+ })
599
+ const currentLevelItems = collectReorderItems(items, parentPath)
600
+ if (currentLevelItems.length > 0) levels.push(currentLevelItems)
601
+ return levels
602
+ }
603
+
604
+ async function reorderDocs() {
605
+ const levels = collectAllReorderLevels(docsList.value)
606
+ if (levels.length === 0) return { ok: true }
607
+ const currentPureName = currentDoc.value ? stripOrderPrefix(currentDoc.value) : ''
608
+ try {
609
+ for (const levelItems of levels) {
610
+ if (levelItems.length === 0) continue
611
+ await docService.reorderDocs(levelItems)
612
+ }
613
+ await reloadDocsList()
614
+ if (currentPureName) {
615
+ const flat = flattenDocsList(docsList.value)
616
+ const match = flat.find(d => stripOrderPrefix(d.key) === currentPureName)
617
+ if (match) {
618
+ expandParents(docsList.value, match.key)
619
+ await loadDoc(match.key, { replace: true })
620
+ }
621
+ }
622
+ return { ok: true }
623
+ } catch (e) {
624
+ console.error('重编号失败:', e)
625
+ return { ok: false, message: e.message }
260
626
  }
261
627
  }
262
628
 
629
+ // ===== 导出 =====
263
630
  return {
264
- // 状态
265
- docsList,
266
- currentDoc,
267
- currentDocTitle,
268
- showWelcome,
269
- htmlContent,
270
- tocItems,
271
- // 滚动
272
- scrollProgress,
273
- showBackToTop,
274
- activeHeading,
275
- handleScroll,
276
- scrollToHeading,
277
- scrollToTop,
278
- // 文档操作
279
- loadDocsList,
280
- loadFromUrl,
281
- goHome,
282
- loadDoc,
283
- loadFirstDoc,
284
- handleDocSelect,
285
- handleContentClick,
286
- handleSearchSelect,
287
- // 文件夹操作
288
- toggleFolder,
289
- onExpandAll,
290
- onCollapseAll,
291
- // 导航
292
- prevDoc,
293
- nextDoc,
294
- // 工具
631
+ docsList, currentDoc, currentDocTitle, showWelcome, htmlContent, tocItems,
632
+ editMode, rawMarkdown, currentDocFilePath, lastModified,
633
+ scrollProgress, showBackToTop, activeHeading,
634
+ handleScroll, scrollToHeading, scrollToTop,
635
+ loadDocsList, loadFromUrl, goHome, loadDoc, loadFirstDoc,
636
+ handleDocSelect, handleContentClick, handleSearchSelect,
637
+ toggleEditMode, reloadDocsList, reloadCurrentDoc,
638
+ saveDoc: saveDocContent, getCurrentDocPath,
639
+ toggleFolder, onExpandAll, onCollapseAll,
640
+ prevDoc, nextDoc,
641
+ createDoc, deleteDoc, renameDoc, reorderDocs,
295
642
  docHash
296
643
  }
297
644
  }