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,57 +1,379 @@
1
1
  import { ref } from 'vue'
2
2
 
3
+ // ===== 常量 =====
4
+ // smooth 滚动锁定超时(scrollend 事件优先解锁,此值仅作兜底)
5
+ // 低性能设备或长文档上 smooth 滚动可能超过 800ms,拉长到 1500ms 确保安全
6
+ const SCROLL_LOCK_TIMEOUT_MS = 1500
7
+
3
8
  // 单例状态,确保多处调用共享同一份
4
9
  const scrollProgress = ref(0)
5
10
  const showBackToTop = ref(false)
6
11
  const activeHeading = ref('')
7
12
 
13
+ // 外部注入的 tocItems 引用,用于编辑模式下通过文本匹配标题
14
+ let _tocItemsRef = null
15
+
16
+ // 点击目录项后锁定,防止滚动检测覆盖 activeHeading 导致闪动
17
+ let _locked = false
18
+ let _lockTimer = null
19
+
20
+ // 标题元素缓存
21
+ let _headingsCache = null
22
+
23
+ // MutationObserver:监听 .markdown-content 子树变化,自动失效标题缓存
24
+ let _mutationObserver = null
25
+
26
+ // 标记:true 表示 activeHeading 被文档切换清空,不应同步到 URL
27
+ let _suppressHashClear = false
28
+
29
+ // IntersectionObserver 实例
30
+ let _observer = null
31
+ // 记录当前在视口中的标题(id → IntersectionObserverEntry)
32
+ const _visibleHeadings = new Map()
33
+
8
34
  export function useScroll() {
9
35
 
10
- // 监听滚动
36
+ // 注入 tocItems 引用(由 useDocManager 调用一次)
37
+ function setTocItems(tocItems) {
38
+ _tocItemsRef = tocItems
39
+ }
40
+
41
+ // 重建标题缓存并重新设置 IntersectionObserver
42
+ let _rebuildRetryTimer = null
43
+ function rebuildHeadingsCache() {
44
+ if (_rebuildRetryTimer) { clearTimeout(_rebuildRetryTimer); _rebuildRetryTimer = null }
45
+ _headingsCache = null
46
+ _setupMutationObserver()
47
+ _setupObserver()
48
+ // 如果 tocItems 有内容但 DOM 中没查到标题(编辑器还没渲染完),延迟重试
49
+ const headings = _headingsCache || []
50
+ const hasTocItems = _tocItemsRef && _tocItemsRef.value && _tocItemsRef.value.length > 0
51
+ if (headings.length === 0 && hasTocItems) {
52
+ _rebuildRetryTimer = setTimeout(() => {
53
+ _headingsCache = null
54
+ _setupMutationObserver()
55
+ _setupObserver()
56
+ }, 200)
57
+ }
58
+ }
59
+
60
+ // 设置 MutationObserver 监听内容区子树变化,自动失效标题缓存
61
+ let _mutationDebounce = null
62
+ function _setupMutationObserver() {
63
+ if (_mutationObserver) {
64
+ _mutationObserver.disconnect()
65
+ _mutationObserver = null
66
+ }
67
+ // 监听 .content 容器,兼容预览模式和编辑模式(编辑模式下可能有多个 .markdown-content)
68
+ const container = document.querySelector('.content')
69
+ if (!container) return
70
+ _mutationObserver = new MutationObserver(() => {
71
+ // 子树变化时失效缓存,下次 _getHeadings 会重新查询
72
+ _headingsCache = null
73
+ // 编辑模式下 tiptap 频繁修改 DOM,跳过 IntersectionObserver 重建避免抖动
74
+ const container = document.querySelector('.content')
75
+ if (container && container.classList.contains('editor-content')) return
76
+ // 防抖重建 IntersectionObserver,确保新增/删除的标题也能被监听
77
+ if (_mutationDebounce) clearTimeout(_mutationDebounce)
78
+ _mutationDebounce = setTimeout(() => {
79
+ _setupObserver()
80
+ }, 800)
81
+ })
82
+ _mutationObserver.observe(container, { childList: true, subtree: true })
83
+ }
84
+
85
+ function _getHeadings() {
86
+ if (_headingsCache) return _headingsCache
87
+ const content = document.querySelector('.content')
88
+ if (!content) return []
89
+ _headingsCache = [...content.querySelectorAll(
90
+ '.markdown-content h1, .markdown-content h2, .markdown-content h3, .markdown-content h4, .markdown-content h5, .markdown-content h6'
91
+ )]
92
+ // 编辑模式下 tiptap 渲染的标题没有 id,通过 tocItems 文本匹配注入 id
93
+ // 使注入后的标题能直接复用预览模式的 getElementById 锚点逻辑
94
+ _syncHeadingIds(_headingsCache)
95
+ return _headingsCache
96
+ }
97
+
98
+ // 为缺少 id 的标题元素注入 id(基于 tocItems 文本匹配)
99
+ function _syncHeadingIds(headings) {
100
+ if (!_tocItemsRef || !_tocItemsRef.value || !_tocItemsRef.value.length) return
101
+ // 构建 text → id 的映射(处理重复文本:按出现顺序依次匹配)
102
+ const tocQueue = new Map()
103
+ for (const item of _tocItemsRef.value) {
104
+ if (!tocQueue.has(item.text)) {
105
+ tocQueue.set(item.text, [])
106
+ }
107
+ tocQueue.get(item.text).push(item.id)
108
+ }
109
+ for (const heading of headings) {
110
+ if (heading.id) continue // 已有 id,跳过
111
+ const text = getHeadingText(heading)
112
+ const ids = tocQueue.get(text)
113
+ if (ids && ids.length > 0) {
114
+ heading.id = ids.shift()
115
+ }
116
+ }
117
+ }
118
+
119
+ // 设置 IntersectionObserver 监听标题元素
120
+ function _setupObserver() {
121
+ // 清理旧的 observer
122
+ if (_observer) {
123
+ _observer.disconnect()
124
+ _observer = null
125
+ }
126
+ _visibleHeadings.clear()
127
+
128
+ const content = document.querySelector('.content')
129
+ if (!content) return
130
+
131
+ const headings = _getHeadings()
132
+ if (!headings.length) return
133
+
134
+ // rootMargin: 顶部 0px,底部 -70%,即标题进入视口上方 30% 区域时触发
135
+ _observer = new IntersectionObserver(
136
+ (entries) => {
137
+ if (_locked) return
138
+
139
+ for (const entry of entries) {
140
+ const id = entry.target.id || findTocIdByText(getHeadingText(entry.target))
141
+ if (!id) continue
142
+ if (entry.isIntersecting) {
143
+ _visibleHeadings.set(id, entry)
144
+ } else {
145
+ _visibleHeadings.delete(id)
146
+ }
147
+ }
148
+
149
+ _resolveActiveHeading()
150
+ },
151
+ {
152
+ root: content,
153
+ // 上方全部可见,下方只保留 30%(裁掉底部 70%)
154
+ rootMargin: '0px 0px -70% 0px',
155
+ threshold: 0
156
+ }
157
+ )
158
+
159
+ for (const heading of headings) {
160
+ _observer.observe(heading)
161
+ }
162
+ }
163
+
164
+ // 根据可见标题集合确定当前激活标题
165
+ // 策略:取文档顺序中最后一个进入检测区域的标题
166
+ function _resolveActiveHeading() {
167
+ if (_locked) return
168
+
169
+ const headings = _getHeadings()
170
+ if (!headings.length) return
171
+
172
+ const content = document.querySelector('.content')
173
+
174
+ // 边界处理:滚动到底部时,强制激活最后一个标题
175
+ if (content) {
176
+ const atBottom = content.scrollTop + content.clientHeight >= content.scrollHeight - 10
177
+ if (atBottom) {
178
+ // 从后往前找最后一个有 id 的标题
179
+ for (let i = headings.length - 1; i >= 0; i--) {
180
+ const id = headings[i].id || findTocIdByText(getHeadingText(headings[i]))
181
+ if (id) {
182
+ if (!activeHeading.value) _suppressHashClear = false
183
+ activeHeading.value = id
184
+ return
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ // 正常情况:从可见标题中取文档顺序最后一个
191
+ if (_visibleHeadings.size > 0) {
192
+ let lastId = ''
193
+ for (const heading of headings) {
194
+ const id = heading.id || findTocIdByText(getHeadingText(heading))
195
+ if (id && _visibleHeadings.has(id)) {
196
+ lastId = id
197
+ }
198
+ }
199
+ if (lastId) {
200
+ activeHeading.value = lastId
201
+ return
202
+ }
203
+ }
204
+
205
+ // 没有可见标题时,用传统方式兜底(滚动位置在第一个标题之前)
206
+ if (content && content.scrollTop < 100) {
207
+ if (activeHeading.value) _suppressHashClear = false
208
+ activeHeading.value = ''
209
+ }
210
+ }
211
+
212
+ // 编辑模式下基于滚动位置计算当前激活标题
213
+ // tiptap 会频繁替换 DOM 元素导致 IntersectionObserver 失效,改用传统方式
214
+ function _resolveActiveHeadingByScroll(container) {
215
+ if (_locked) return
216
+ // 每次都重新查询标题元素,因为 tiptap 可能已替换 DOM
217
+ _headingsCache = null
218
+ const headings = _getHeadings()
219
+ if (!headings.length) return
220
+
221
+ const scrollTop = container.scrollTop
222
+ // 检测区域:视口上方 30%(与 IO 的 rootMargin 策略一致)
223
+ const threshold = container.clientHeight * 0.3
224
+
225
+ // 滚动到底部时,强制激活最后一个标题
226
+ const atBottom = scrollTop + container.clientHeight >= container.scrollHeight - 10
227
+ if (atBottom) {
228
+ for (let i = headings.length - 1; i >= 0; i--) {
229
+ const id = headings[i].id || findTocIdByText(getHeadingText(headings[i]))
230
+ if (id) {
231
+ activeHeading.value = id
232
+ return
233
+ }
234
+ }
235
+ }
236
+
237
+ // 从后往前找最后一个滚过检测线的标题
238
+ let lastId = ''
239
+ for (const heading of headings) {
240
+ // offsetTop 是相对于 offsetParent 的,需要计算相对于滚动容器的位置
241
+ const headingTop = heading.getBoundingClientRect().top - container.getBoundingClientRect().top
242
+ if (headingTop <= threshold) {
243
+ const id = heading.id || findTocIdByText(getHeadingText(heading))
244
+ if (id) lastId = id
245
+ }
246
+ }
247
+
248
+ if (lastId) {
249
+ activeHeading.value = lastId
250
+ } else if (scrollTop < 100) {
251
+ activeHeading.value = ''
252
+ }
253
+ }
254
+
255
+ // 监听滚动:更新进度条、返回顶部按钮,以及处理底部边界
11
256
  function handleScroll(e) {
12
257
  const element = e.target
13
258
  const scrollTop = element.scrollTop
14
259
  const scrollHeight = element.scrollHeight - element.clientHeight
15
-
260
+
16
261
  if (scrollHeight > 0) {
17
262
  scrollProgress.value = Math.round((scrollTop / scrollHeight) * 100)
18
263
  showBackToTop.value = scrollTop > 300
19
264
  }
20
-
21
- updateActiveHeading()
22
- }
23
265
 
24
- // 更新当前激活的标题
25
- function updateActiveHeading() {
26
- const content = document.querySelector('.content')
27
- if (!content) return
28
-
29
- const headings = document.querySelectorAll('.markdown-content h1, .markdown-content h2, .markdown-content h3, .markdown-content h4, .markdown-content h5, .markdown-content h6')
30
- const scrollTop = content.scrollTop
31
-
32
- let currentId = ''
33
- headings.forEach(heading => {
34
- const rect = heading.getBoundingClientRect()
35
- const contentRect = content.getBoundingClientRect()
36
- const offsetTop = rect.top - contentRect.top + scrollTop
37
-
38
- if (offsetTop <= scrollTop + 100) {
39
- currentId = heading.id
266
+ // 编辑模式下 tiptap 会替换 DOM 元素导致 IO 失效,改用滚动位置计算
267
+ if (element.classList.contains('editor-content')) {
268
+ if (!_locked) {
269
+ _resolveActiveHeadingByScroll(element)
40
270
  }
41
- })
42
-
43
- activeHeading.value = currentId
271
+ return
272
+ }
273
+
274
+ // 底部边界检测(IO 的 rootMargin 裁掉了底部 70%,滚到底时可能漏掉)
275
+ if (!_locked) {
276
+ const atBottom = scrollTop + element.clientHeight >= element.scrollHeight - 10
277
+ if (atBottom) {
278
+ _resolveActiveHeading()
279
+ }
280
+ }
281
+ }
282
+
283
+ // 提取标题纯文本(去掉锚点图标等子元素)
284
+ function getHeadingText(heading) {
285
+ const clone = heading.cloneNode(true)
286
+ clone.querySelectorAll('.heading-anchor').forEach(a => a.remove())
287
+ return clone.textContent.trim()
288
+ }
289
+
290
+ // 通过文本匹配在 tocItems 中查找对应 id(编辑模式下标题无 id 时使用)
291
+ function findTocIdByText(text) {
292
+ if (!_tocItemsRef || !_tocItemsRef.value) return ''
293
+ const item = _tocItemsRef.value.find(t => t.text === text)
294
+ return item ? item.id : ''
295
+ }
296
+
297
+ // 解锁函数
298
+ function _unlock() {
299
+ _locked = false
300
+ if (_lockTimer) { clearTimeout(_lockTimer); _lockTimer = null }
301
+ // 解锁后立即重新解析一次,确保 IO 状态同步
302
+ _resolveActiveHeading()
44
303
  }
45
304
 
46
305
  // 滚动到指定标题
47
306
  function scrollToHeading(id) {
48
- const el = document.getElementById(id)
307
+ activeHeading.value = id
308
+ // 锁定,防止 smooth 滚动期间检测覆盖
309
+ _locked = true
310
+ if (_lockTimer) clearTimeout(_lockTimer)
311
+
312
+ const content = document.querySelector('.content')
313
+
314
+ // 优先用 scrollend 事件解锁,SCROLL_LOCK_TIMEOUT_MS 兜底
315
+ if (content) {
316
+ content.addEventListener('scrollend', _unlock, { once: true })
317
+ }
318
+ _lockTimer = setTimeout(() => {
319
+ _unlock()
320
+ if (content) content.removeEventListener('scrollend', _unlock)
321
+ }, SCROLL_LOCK_TIMEOUT_MS)
322
+
323
+ let el = document.getElementById(id)
49
324
  if (el) {
50
325
  el.scrollIntoView({ behavior: 'smooth', block: 'start' })
51
- activeHeading.value = id
326
+ // 触发高亮闪烁动画
327
+ _flashHeading(el)
328
+ return
329
+ }
330
+ // 编辑模式下标题无 id,通过 tocItems 找到文本再匹配 DOM
331
+ // 注意:不调用 _getHeadings() 避免 _syncHeadingIds 修改 tiptap DOM 导致节点重建
332
+ if (_tocItemsRef && _tocItemsRef.value) {
333
+ const tocItem = _tocItemsRef.value.find(t => t.id === id)
334
+ if (tocItem) {
335
+ if (!content) return
336
+ const headings = [...content.querySelectorAll(
337
+ '.markdown-content h1, .markdown-content h2, .markdown-content h3, .markdown-content h4, .markdown-content h5, .markdown-content h6'
338
+ )]
339
+ for (const heading of headings) {
340
+ if (getHeadingText(heading) === tocItem.text) {
341
+ heading.scrollIntoView({ behavior: 'smooth', block: 'start' })
342
+ _flashHeading(heading)
343
+ return
344
+ }
345
+ }
346
+ }
52
347
  }
53
348
  }
54
349
 
350
+ // 触发标题高亮闪烁动画
351
+ function _flashHeading(el) {
352
+ el.classList.remove('heading-flash')
353
+ void el.offsetWidth // 强制 reflow 重置动画
354
+ el.classList.add('heading-flash')
355
+ el.addEventListener('animationend', () => el.classList.remove('heading-flash'), { once: true })
356
+ }
357
+
358
+ // 锁定 activeHeading 一段时间(供外部在非 smooth 滚动场景使用,如页面刷新定位锚点)
359
+ function lockHeading(id, durationMs = SCROLL_LOCK_TIMEOUT_MS) {
360
+ activeHeading.value = id
361
+ _locked = true
362
+ if (_lockTimer) clearTimeout(_lockTimer)
363
+ _lockTimer = setTimeout(_unlock, durationMs)
364
+ }
365
+
366
+ // 文档切换时清空 activeHeading(标记为文档切换,不同步清除 URL hash)
367
+ function clearActiveHeading() {
368
+ _suppressHashClear = true
369
+ activeHeading.value = ''
370
+ }
371
+
372
+ // 查询当前是否为文档切换导致的清空
373
+ function isSuppressHashClear() {
374
+ return _suppressHashClear
375
+ }
376
+
55
377
  // 返回顶部
56
378
  function scrollToTop() {
57
379
  const content = document.querySelector('.content')
@@ -66,6 +388,11 @@ export function useScroll() {
66
388
  activeHeading,
67
389
  handleScroll,
68
390
  scrollToHeading,
69
- scrollToTop
391
+ scrollToTop,
392
+ setTocItems,
393
+ rebuildHeadingsCache,
394
+ clearActiveHeading,
395
+ isSuppressHashClear,
396
+ lockHeading
70
397
  }
71
398
  }
@@ -42,24 +42,25 @@ export function useSearch() {
42
42
  indexBuilding.value = true
43
43
 
44
44
  const docs = flattenDocs(pendingDocsList)
45
- const documents = []
46
45
 
47
- for (const doc of docs) {
48
- try {
49
- const response = await fetch(doc.path)
50
- if (response.ok) {
46
+ // 并发加载所有文档内容,大幅提升索引构建速度
47
+ const documents = (await Promise.all(
48
+ docs.map(async (doc) => {
49
+ try {
50
+ const response = await fetch(doc.path)
51
+ if (!response.ok) return null
51
52
  const content = await response.text()
52
- documents.push({
53
+ return {
53
54
  id: doc.key,
54
55
  title: doc.label,
55
56
  content: content.replace(/^---[\s\S]*?---\n?/, ''), // 去掉 frontmatter
56
57
  path: doc.path
57
- })
58
+ }
59
+ } catch {
60
+ return null // 忽略加载失败的文档
58
61
  }
59
- } catch {
60
- // 忽略加载失败的文档
61
- }
62
- }
62
+ })
63
+ )).filter(Boolean)
63
64
 
64
65
  searchIndex = new MiniSearch({
65
66
  fields: ['title', 'content'],
package/src/config.js CHANGED
@@ -1,8 +1,5 @@
1
- // 共享配置 - bin/md2ui.js 和 src/api/docs.js 共用
1
+ // 共享配置 - vite.config.js 共用
2
2
  export const config = {
3
3
  // 默认端口
4
4
  defaultPort: 3000,
5
-
6
- // 文件夹默认展开状态
7
- folderExpanded: false
8
5
  }
@@ -0,0 +1,113 @@
1
+ import { Node, mergeAttributes, VueNodeViewRenderer, textblockTypeInputRule } from '@tiptap/vue-3'
2
+ import CodeBlockNodeView from '../components/CodeBlockNodeView.vue'
3
+
4
+ /**
5
+ * 自定义代码块扩展
6
+ * 编辑模式下渲染带 header(语言标签 + 复制按钮)的代码块,与查看模式风格一致
7
+ */
8
+ export const CodeBlockCustom = Node.create({
9
+ name: 'codeBlock',
10
+ group: 'block',
11
+ content: 'text*',
12
+ marks: '',
13
+ defining: true,
14
+ isolating: true,
15
+ code: true,
16
+
17
+ addAttributes() {
18
+ return {
19
+ language: {
20
+ default: null,
21
+ parseHTML: (element) => {
22
+ const code = element.querySelector('code')
23
+ if (!code) return null
24
+ // 从 class="language-xxx" 中提取语言
25
+ const cls = [...code.classList].find(c => c.startsWith('language-'))
26
+ return cls ? cls.replace('language-', '') : null
27
+ },
28
+ },
29
+ }
30
+ },
31
+
32
+ parseHTML() {
33
+ return [
34
+ {
35
+ tag: 'pre',
36
+ preserveWhitespace: 'full',
37
+ getAttrs: (node) => {
38
+ const code = node.querySelector('code')
39
+ if (!code) return {}
40
+ // 排除 mermaid,由 MermaidBlock 处理
41
+ if (code.classList.contains('language-mermaid')) return false
42
+ return {}
43
+ },
44
+ },
45
+ ]
46
+ },
47
+
48
+ renderHTML({ node, HTMLAttributes }) {
49
+ const lang = node.attrs.language
50
+ return [
51
+ 'pre',
52
+ mergeAttributes(HTMLAttributes),
53
+ ['code', { class: lang ? `language-${lang}` : null }, 0],
54
+ ]
55
+ },
56
+
57
+ addNodeView() {
58
+ return VueNodeViewRenderer(CodeBlockNodeView)
59
+ },
60
+
61
+ addKeyboardShortcuts() {
62
+ return {
63
+ 'Mod-Alt-c': () => this.editor.commands.toggleCodeBlock(),
64
+ // 在代码块内按 Enter 保持在代码块中
65
+ 'Enter': ({ editor }) => {
66
+ if (!editor.isActive('codeBlock')) return false
67
+ return editor.commands.newlineInCode()
68
+ },
69
+ // 在代码块末尾按 Mod-Enter 跳出代码块
70
+ 'Mod-Enter': ({ editor }) => {
71
+ if (!editor.isActive('codeBlock')) return false
72
+ return editor.commands.exitCode()
73
+ },
74
+ // Backspace 在空代码块时退出
75
+ 'Backspace': ({ editor }) => {
76
+ const { $anchor } = editor.state.selection
77
+ if (!editor.isActive('codeBlock')) return false
78
+ if ($anchor.parent.textContent.length > 0) return false
79
+ return editor.commands.clearNodes()
80
+ },
81
+ }
82
+ },
83
+
84
+ // 输入规则:``` 触发代码块
85
+ addInputRules() {
86
+ return [
87
+ textblockTypeInputRule({
88
+ find: /^```([a-z]*)?[\s\n]$/,
89
+ type: this.type,
90
+ getAttributes: (match) => ({
91
+ language: match[1] || null,
92
+ }),
93
+ }),
94
+ ]
95
+ },
96
+
97
+ // tiptap-markdown 序列化规则
98
+ addStorage() {
99
+ return {
100
+ markdown: {
101
+ serialize(state, node) {
102
+ const lang = node.attrs.language || ''
103
+ state.write('```' + lang + '\n')
104
+ state.text(node.textContent, false)
105
+ state.ensureNewLine()
106
+ state.write('```')
107
+ state.closeBlock(node)
108
+ },
109
+ parse: {},
110
+ },
111
+ }
112
+ },
113
+ })
@@ -0,0 +1,107 @@
1
+ import { Node, mergeAttributes, VueNodeViewRenderer } from '@tiptap/vue-3'
2
+ import MathBlockNodeView from '../components/MathBlockNodeView.vue'
3
+
4
+ // HTML 属性转义
5
+ function escapeAttr(str) {
6
+ return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
7
+ }
8
+
9
+ /**
10
+ * 块级数学公式扩展($$...$$)
11
+ * 编辑模式下渲染为可交互的 NodeView
12
+ */
13
+ export const MathBlock = Node.create({
14
+ name: 'mathBlock',
15
+ group: 'block',
16
+ atom: true,
17
+
18
+ addAttributes() {
19
+ return {
20
+ latex: { default: '' },
21
+ }
22
+ },
23
+
24
+ parseHTML() {
25
+ return [
26
+ {
27
+ tag: 'div.math-block',
28
+ getAttrs: (node) => {
29
+ const latex = node.getAttribute('data-latex') || node.textContent.trim()
30
+ return { latex }
31
+ },
32
+ },
33
+ ]
34
+ },
35
+
36
+ renderHTML({ node, HTMLAttributes }) {
37
+ return ['div', mergeAttributes(HTMLAttributes, {
38
+ class: 'math-block',
39
+ 'data-latex': node.attrs.latex,
40
+ })]
41
+ },
42
+
43
+ addNodeView() {
44
+ return VueNodeViewRenderer(MathBlockNodeView)
45
+ },
46
+
47
+ addKeyboardShortcuts() {
48
+ return {
49
+ 'Backspace': ({ editor }) => {
50
+ if (!editor.isActive('mathBlock')) return false
51
+ return editor.commands.deleteSelection()
52
+ },
53
+ }
54
+ },
55
+
56
+ addStorage() {
57
+ return {
58
+ markdown: {
59
+ serialize(state, node) {
60
+ state.write('$$\n')
61
+ state.text(node.attrs.latex || '', false)
62
+ state.ensureNewLine()
63
+ state.write('$$')
64
+ state.closeBlock(node)
65
+ },
66
+ parse: {
67
+ // 给 markdown-it 注册块级公式规则
68
+ setup(md) {
69
+ md.block.ruler.before('fence', 'math_block', (state, startLine, endLine, silent) => {
70
+ const startPos = state.bMarks[startLine] + state.tShift[startLine]
71
+ const maxPos = state.eMarks[startLine]
72
+ if (startPos + 2 > maxPos) return false
73
+ const marker = state.src.slice(startPos, startPos + 2)
74
+ if (marker !== '$$') return false
75
+ if (silent) return true
76
+ let nextLine = startLine + 1
77
+ let found = false
78
+ while (nextLine < endLine) {
79
+ const nPos = state.bMarks[nextLine] + state.tShift[nextLine]
80
+ const nMax = state.eMarks[nextLine]
81
+ if (nPos < nMax && state.src.slice(nPos, nPos + 2) === '$$') {
82
+ found = true
83
+ break
84
+ }
85
+ nextLine++
86
+ }
87
+ if (!found) return false
88
+ // 提取 $$ 之间的内容
89
+ const contentStart = state.bMarks[startLine + 1]
90
+ const contentEnd = state.eMarks[nextLine - 1]
91
+ const latex = state.src.slice(contentStart, contentEnd).trim()
92
+ const token = state.push('math_block', 'div', 0)
93
+ token.content = latex
94
+ token.map = [startLine, nextLine + 1]
95
+ state.line = nextLine + 1
96
+ return true
97
+ })
98
+ md.renderer.rules.math_block = (tokens, idx) => {
99
+ const latex = tokens[idx].content
100
+ return '<div class="math-block" data-latex="' + escapeAttr(latex) + '"></div>'
101
+ }
102
+ },
103
+ },
104
+ },
105
+ }
106
+ },
107
+ })