md2ui 1.0.19 → 1.0.21

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 (29) hide show
  1. package/README.md +65 -20
  2. package/bin/build.js +13 -2
  3. package/bin/md2ui.js +25 -12
  4. package/package.json +4 -4
  5. 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 +2 -0
  6. 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
  7. package/public/docs/02-Markdown/346/270/262/346/237/223/assets/img-1777383093712.png +0 -0
  8. 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
  9. 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 +4 -4
  10. package/src/App.vue +36 -61
  11. package/src/components/ImageZoom.vue +9 -123
  12. package/src/components/MermaidNodeView.vue +10 -2
  13. package/src/components/MobileSearch.vue +97 -0
  14. package/src/components/TableOfContents.vue +42 -6
  15. package/src/composables/useDocManager.js +134 -44
  16. package/src/composables/useDocTree.js +26 -50
  17. package/src/composables/useMarkdown.js +51 -140
  18. package/src/composables/useMermaidCache.js +15 -0
  19. package/src/composables/useScroll.js +317 -32
  20. package/src/composables/useSearch.js +12 -11
  21. package/src/config.js +1 -4
  22. package/src/services/DocService.js +0 -16
  23. package/src/style.css +235 -10
  24. package/src/utils/imageConverter.js +129 -0
  25. package/vite-plugin-doc-api.js +158 -157
  26. package/vite.config.js +5 -1
  27. package/src/components/SearchPanel.vue +0 -90
  28. package/src/components/TableBubbleMenu.vue +0 -177
  29. package/src/composables/useExportPdf.js +0 -102
@@ -1,5 +1,10 @@
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)
@@ -8,6 +13,24 @@ const activeHeading = ref('')
8
13
  // 外部注入的 tocItems 引用,用于编辑模式下通过文本匹配标题
9
14
  let _tocItemsRef = null
10
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
+
11
34
  export function useScroll() {
12
35
 
13
36
  // 注入 tocItems 引用(由 useDocManager 调用一次)
@@ -15,18 +38,246 @@ export function useScroll() {
15
38
  _tocItemsRef = tocItems
16
39
  }
17
40
 
18
- // 监听滚动
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
+ // 监听滚动:更新进度条、返回顶部按钮,以及处理底部边界
19
256
  function handleScroll(e) {
20
257
  const element = e.target
21
258
  const scrollTop = element.scrollTop
22
259
  const scrollHeight = element.scrollHeight - element.clientHeight
23
-
260
+
24
261
  if (scrollHeight > 0) {
25
262
  scrollProgress.value = Math.round((scrollTop / scrollHeight) * 100)
26
263
  showBackToTop.value = scrollTop > 300
27
264
  }
28
-
29
- updateActiveHeading()
265
+
266
+ // 编辑模式下 tiptap 会替换 DOM 元素导致 IO 失效,改用滚动位置计算
267
+ if (element.classList.contains('editor-content')) {
268
+ if (!_locked) {
269
+ _resolveActiveHeadingByScroll(element)
270
+ }
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
+ }
30
281
  }
31
282
 
32
283
  // 提取标题纯文本(去掉锚点图标等子元素)
@@ -43,49 +294,52 @@ export function useScroll() {
43
294
  return item ? item.id : ''
44
295
  }
45
296
 
46
- // 更新当前激活的标题
47
- function updateActiveHeading() {
48
- const content = document.querySelector('.content')
49
- if (!content) return
50
-
51
- const headings = content.querySelectorAll('.markdown-content h1, .markdown-content h2, .markdown-content h3, .markdown-content h4, .markdown-content h5, .markdown-content h6')
52
- const scrollTop = content.scrollTop
53
-
54
- let currentId = ''
55
- headings.forEach(heading => {
56
- const rect = heading.getBoundingClientRect()
57
- const contentRect = content.getBoundingClientRect()
58
- const offsetTop = rect.top - contentRect.top + scrollTop
59
-
60
- if (offsetTop <= scrollTop + 100) {
61
- // 优先用 id,没有 id 时通过文本匹配 tocItems
62
- currentId = heading.id || findTocIdByText(getHeadingText(heading))
63
- }
64
- })
65
-
66
- activeHeading.value = currentId
297
+ // 解锁函数
298
+ function _unlock() {
299
+ _locked = false
300
+ if (_lockTimer) { clearTimeout(_lockTimer); _lockTimer = null }
301
+ // 解锁后立即重新解析一次,确保 IO 状态同步
302
+ _resolveActiveHeading()
67
303
  }
68
304
 
69
- // 滚动到指定标题(支持编辑模式下通过文本匹配定位)
305
+ // 滚动到指定标题
70
306
  function scrollToHeading(id) {
71
- // 优先通过 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
+
72
323
  let el = document.getElementById(id)
73
324
  if (el) {
74
325
  el.scrollIntoView({ behavior: 'smooth', block: 'start' })
75
- activeHeading.value = id
326
+ // 触发高亮闪烁动画
327
+ _flashHeading(el)
76
328
  return
77
329
  }
78
330
  // 编辑模式下标题无 id,通过 tocItems 找到文本再匹配 DOM
331
+ // 注意:不调用 _getHeadings() 避免 _syncHeadingIds 修改 tiptap DOM 导致节点重建
79
332
  if (_tocItemsRef && _tocItemsRef.value) {
80
333
  const tocItem = _tocItemsRef.value.find(t => t.id === id)
81
334
  if (tocItem) {
82
- const content = document.querySelector('.content')
83
335
  if (!content) return
84
- const headings = content.querySelectorAll('.markdown-content h1, .markdown-content h2, .markdown-content h3, .markdown-content h4, .markdown-content h5, .markdown-content h6')
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
+ )]
85
339
  for (const heading of headings) {
86
340
  if (getHeadingText(heading) === tocItem.text) {
87
341
  heading.scrollIntoView({ behavior: 'smooth', block: 'start' })
88
- activeHeading.value = id
342
+ _flashHeading(heading)
89
343
  return
90
344
  }
91
345
  }
@@ -93,6 +347,33 @@ export function useScroll() {
93
347
  }
94
348
  }
95
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
+
96
377
  // 返回顶部
97
378
  function scrollToTop() {
98
379
  const content = document.querySelector('.content')
@@ -108,6 +389,10 @@ export function useScroll() {
108
389
  handleScroll,
109
390
  scrollToHeading,
110
391
  scrollToTop,
111
- setTocItems
392
+ setTocItems,
393
+ rebuildHeadingsCache,
394
+ clearActiveHeading,
395
+ isSuppressHashClear,
396
+ lockHeading
112
397
  }
113
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
- // 共享配置 - vite.config.js 和 DocService.js 共用
1
+ // 共享配置 - vite.config.js 共用
2
2
  export const config = {
3
3
  // 默认端口
4
4
  defaultPort: 3000,
5
-
6
- // 文件夹默认展开状态
7
- folderExpanded: false
8
5
  }
@@ -76,9 +76,6 @@ export async function pollDocContent(docPath) {
76
76
  if (res.status === 304) return null
77
77
  if (res.status === 200) {
78
78
  _contentEtag = res.headers.get('etag')
79
- // 捕获最后修改时间
80
- const lm = res.headers.get('x-last-modified')
81
- if (lm) _lastModified = lm
82
79
  return await res.text()
83
80
  }
84
81
  } catch { /* 静默忽略 */ }
@@ -96,19 +93,6 @@ export function resetListEtag() {
96
93
  _listEtag = null
97
94
  }
98
95
 
99
- // ===== 文档最后修改时间 =====
100
- let _lastModified = null
101
-
102
- /** 获取当前文档的最后修改时间 */
103
- export function getLastModified() {
104
- return _lastModified
105
- }
106
-
107
- /** 重置最后修改时间 */
108
- export function resetLastModified() {
109
- _lastModified = null
110
- }
111
-
112
96
  // ===== 写操作 API =====
113
97
 
114
98
  async function postJson(url, body) {