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
@@ -77,8 +77,9 @@
77
77
  </template>
78
78
 
79
79
  <script setup>
80
- import { ref, computed, nextTick, watch } from 'vue'
80
+ import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
81
81
  import { ZoomIn, ZoomOut, RotateCcw, X, ChevronLeft, ChevronRight, Copy, Check } from 'lucide-vue-next'
82
+ import { imgToPngBlob, svgToPngBlob } from '../utils/imageConverter.js'
82
83
 
83
84
  // Props
84
85
  const props = defineProps({
@@ -181,7 +182,6 @@ async function handleCopyImage() {
181
182
 
182
183
  let blobPromise
183
184
  if (img) {
184
- // 构造 PNG blob Promise
185
185
  blobPromise = imgToPngBlob(img.src)
186
186
  } else if (svg) {
187
187
  blobPromise = svgToPngBlob(svg, 2)
@@ -200,124 +200,6 @@ async function handleCopyImage() {
200
200
  }
201
201
  }
202
202
 
203
- // 将图片 URL 转为 PNG blob(兼容同源和跨域)
204
- async function imgToPngBlob(src) {
205
- // 策略1:fetch 获取(同源图片直接成功)
206
- try {
207
- const resp = await fetch(src)
208
- const blob = await resp.blob()
209
- if (blob.type === 'image/png') return blob
210
- return blobToCanvasPng(blob, 1)
211
- } catch {
212
- // 策略2:通过 crossOrigin Image 加载(需服务器支持 CORS)
213
- try {
214
- return await loadImageToBlob(src, true)
215
- } catch {
216
- // 策略3:不设 crossOrigin 直接画(可能被 taint,但对某些场景有效)
217
- return loadImageToBlob(src, false)
218
- }
219
- }
220
- }
221
-
222
- // 加载图片到 canvas 并转 PNG blob
223
- function loadImageToBlob(src, useCors) {
224
- return new Promise((resolve, reject) => {
225
- const image = new Image()
226
- if (useCors) image.crossOrigin = 'anonymous'
227
- image.onload = () => {
228
- try {
229
- const canvas = document.createElement('canvas')
230
- canvas.width = image.naturalWidth
231
- canvas.height = image.naturalHeight
232
- canvas.getContext('2d').drawImage(image, 0, 0)
233
- canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob null')), 'image/png')
234
- } catch (e) {
235
- reject(e)
236
- }
237
- }
238
- image.onerror = () => reject(new Error('图片加载失败'))
239
- image.src = src
240
- })
241
- }
242
-
243
- // 将任意图片 blob 通过 canvas 转为 PNG blob
244
- function blobToCanvasPng(srcBlob, scale = 1) {
245
- return new Promise((resolve, reject) => {
246
- const url = URL.createObjectURL(srcBlob)
247
- const image = new Image()
248
- image.onload = () => {
249
- const canvas = document.createElement('canvas')
250
- canvas.width = image.naturalWidth * scale
251
- canvas.height = image.naturalHeight * scale
252
- const ctx = canvas.getContext('2d')
253
- if (scale !== 1) ctx.scale(scale, scale)
254
- ctx.drawImage(image, 0, 0)
255
- URL.revokeObjectURL(url)
256
- canvas.toBlob(b => resolve(b), 'image/png')
257
- }
258
- image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('图片加载失败')) }
259
- image.src = url
260
- })
261
- }
262
-
263
- // SVG 转 PNG blob(处理 foreignObject 导致的 tainted canvas 问题)
264
- function svgToPngBlob(svgEl, scale = 2) {
265
- return new Promise((resolve, reject) => {
266
- const clone = svgEl.cloneNode(true)
267
- // 从 viewBox 或属性中获取 SVG 实际尺寸,确保复制完整
268
- const viewBox = clone.getAttribute('viewBox')
269
- let svgWidth, svgHeight
270
- if (viewBox) {
271
- const parts = viewBox.split(/[\s,]+/)
272
- svgWidth = parseFloat(parts[2])
273
- svgHeight = parseFloat(parts[3])
274
- }
275
- // 回退到 width/height 属性或 getBoundingClientRect
276
- if (!svgWidth || !svgHeight) {
277
- svgWidth = parseFloat(clone.getAttribute('width')) || svgEl.getBoundingClientRect().width || 800
278
- svgHeight = parseFloat(clone.getAttribute('height')) || svgEl.getBoundingClientRect().height || 600
279
- }
280
- // 显式设置 width/height 属性,确保 Image 加载时尺寸正确
281
- clone.setAttribute('width', svgWidth)
282
- clone.setAttribute('height', svgHeight)
283
- // 将 foreignObject 替换为 text 元素,避免 canvas 被污染
284
- clone.querySelectorAll('foreignObject').forEach(fo => {
285
- const text = fo.textContent.trim()
286
- const x = fo.getAttribute('x') || '0'
287
- const y = fo.getAttribute('y') || '0'
288
- const width = fo.getAttribute('width') || '100'
289
- const height = fo.getAttribute('height') || '20'
290
- const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text')
291
- textEl.setAttribute('x', String(parseFloat(x) + parseFloat(width) / 2))
292
- textEl.setAttribute('y', String(parseFloat(y) + parseFloat(height) / 2 + 5))
293
- textEl.setAttribute('text-anchor', 'middle')
294
- textEl.setAttribute('font-size', '14')
295
- textEl.setAttribute('fill', '#455a64')
296
- textEl.textContent = text
297
- fo.parentNode.replaceChild(textEl, fo)
298
- })
299
- const svgData = new XMLSerializer().serializeToString(clone)
300
- const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
301
- const url = URL.createObjectURL(svgBlob)
302
- const image = new Image()
303
- image.onload = () => {
304
- // 使用从 SVG 提取的精确尺寸,而非 naturalWidth/Height(后者对无 width/height 的 SVG 不可靠)
305
- const w = svgWidth * scale
306
- const h = svgHeight * scale
307
- const canvas = document.createElement('canvas')
308
- canvas.width = w
309
- canvas.height = h
310
- const ctx = canvas.getContext('2d')
311
- ctx.scale(scale, scale)
312
- ctx.drawImage(image, 0, 0, svgWidth, svgHeight)
313
- URL.revokeObjectURL(url)
314
- canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob null')), 'image/png')
315
- }
316
- image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('SVG加载失败')) }
317
- image.src = url
318
- })
319
- }
320
-
321
203
  // 处理遮罩层点击
322
204
  function handleOverlayClick(event) {
323
205
  if (event.target.classList.contains('image-zoom-overlay')) {
@@ -361,7 +243,7 @@ function handleMouseUp() {
361
243
  isDragging.value = false
362
244
  }
363
245
 
364
- // 监听键盘事件
246
+ // 监听键盘事件(使用生命周期钩子管理,避免泄漏)
365
247
  function handleKeyDown(event) {
366
248
  if (!props.visible) return
367
249
  switch (event.key) {
@@ -387,9 +269,13 @@ function handleKeyDown(event) {
387
269
  }
388
270
  }
389
271
 
390
- if (typeof window !== 'undefined') {
272
+ onMounted(() => {
391
273
  window.addEventListener('keydown', handleKeyDown)
392
- }
274
+ })
275
+
276
+ onBeforeUnmount(() => {
277
+ window.removeEventListener('keydown', handleKeyDown)
278
+ })
393
279
  </script>
394
280
 
395
281
  <style scoped>
@@ -44,6 +44,7 @@
44
44
  import { ref, computed, watch, onMounted, nextTick } from 'vue'
45
45
  import { nodeViewProps, NodeViewWrapper } from '@tiptap/vue-3'
46
46
  import mermaid from 'mermaid'
47
+ import { getMermaidCache, setMermaidCache } from '../composables/useMermaidCache.js'
47
48
 
48
49
  const props = defineProps(nodeViewProps)
49
50
 
@@ -63,17 +64,24 @@ async function renderChart() {
63
64
  renderError.value = false
64
65
  return
65
66
  }
67
+ // 优先使用缓存
68
+ const cached = getMermaidCache(text)
69
+ if (cached) {
70
+ svgContent.value = cached
71
+ renderError.value = false
72
+ return
73
+ }
66
74
  try {
67
75
  const id = 'mermaid-editor-' + Math.random().toString(36).substr(2, 9)
68
76
  const { svg } = await mermaid.render(id, text)
69
77
  svgContent.value = svg
70
78
  renderError.value = false
79
+ // 写入缓存
80
+ setMermaidCache(text, svg)
71
81
  } catch (e) {
72
82
  console.error('Mermaid 编辑器渲染失败:', e)
73
83
  svgContent.value = ''
74
84
  renderError.value = true
75
- // 清理 Mermaid 渲染失败时残留的 DOM 元素
76
- const errId = 'mermaid-editor-' // 前缀匹配清理
77
85
  document.querySelectorAll('[id^="mermaid-editor-"][id$="-svg"]').forEach(el => {
78
86
  if (el.closest('.mermaid-block-wrapper') === null) el.remove()
79
87
  })
@@ -0,0 +1,97 @@
1
+ <template>
2
+ <div class="mobile-search-page">
3
+ <!-- 搜索输入区 -->
4
+ <div class="mobile-search-header">
5
+ <div class="mobile-search-input-wrapper">
6
+ <Search :size="16" class="mobile-search-icon" />
7
+ <input
8
+ ref="inputRef"
9
+ type="text"
10
+ class="mobile-search-input"
11
+ placeholder="搜索文档..."
12
+ v-model="searchQuery"
13
+ @input="doSearch(searchQuery)"
14
+ @keydown.escape="$emit('close')"
15
+ @keydown.enter="handleEnter"
16
+ @keydown.up.prevent="moveSelection(-1)"
17
+ @keydown.down.prevent="moveSelection(1)"
18
+ />
19
+ <button v-if="searchQuery" class="mobile-search-clear" @click="clearSearch">
20
+ <X :size="14" />
21
+ </button>
22
+ </div>
23
+ <button class="mobile-search-cancel" @click="$emit('close')">取消</button>
24
+ </div>
25
+ <!-- 搜索结果 -->
26
+ <div class="mobile-search-results">
27
+ <div v-if="!searchReady && indexBuilding" class="mobile-search-tip">
28
+ 正在构建索引...
29
+ </div>
30
+ <div v-else-if="searchQuery && searchResults.length === 0" class="mobile-search-empty">
31
+ 没有找到相关文档
32
+ </div>
33
+ <template v-else-if="searchResults.length > 0">
34
+ <div
35
+ v-for="(item, index) in searchResults"
36
+ :key="item.key"
37
+ :class="['mobile-search-item', { active: index === selectedIndex }]"
38
+ @click="selectResult(item)"
39
+ >
40
+ <FileText :size="14" class="mobile-search-item-icon" />
41
+ <span class="mobile-search-item-title">{{ item.title }}</span>
42
+ </div>
43
+ </template>
44
+ <div v-else class="mobile-search-tip">输入关键词搜索文档内容</div>
45
+ </div>
46
+ </div>
47
+ </template>
48
+
49
+ <script setup>
50
+ import { ref, watch, nextTick, onMounted } from 'vue'
51
+ import { Search, X, FileText } from 'lucide-vue-next'
52
+ import { useSearch } from '../composables/useSearch.js'
53
+
54
+ const emit = defineEmits(['close', 'select'])
55
+
56
+ const { searchQuery, searchResults, searchReady, indexBuilding, doSearch, openSearch } = useSearch()
57
+
58
+ const inputRef = ref(null)
59
+ const selectedIndex = ref(0)
60
+
61
+ // 页面挂载时自动聚焦并构建索引
62
+ onMounted(async () => {
63
+ await openSearch()
64
+ await nextTick()
65
+ inputRef.value?.focus()
66
+ })
67
+
68
+ // 搜索结果变化时重置选中
69
+ watch(searchResults, () => {
70
+ selectedIndex.value = 0
71
+ })
72
+
73
+ function clearSearch() {
74
+ searchQuery.value = ''
75
+ searchResults.value = []
76
+ inputRef.value?.focus()
77
+ }
78
+
79
+ function moveSelection(delta) {
80
+ const len = searchResults.value.length
81
+ if (len === 0) return
82
+ selectedIndex.value = (selectedIndex.value + delta + len) % len
83
+ }
84
+
85
+ function handleEnter() {
86
+ if (searchResults.value.length > 0) {
87
+ selectResult(searchResults.value[selectedIndex.value])
88
+ }
89
+ }
90
+
91
+ function selectResult(item) {
92
+ // 清理搜索状态
93
+ searchQuery.value = ''
94
+ searchResults.value = []
95
+ emit('select', item.key)
96
+ }
97
+ </script>
@@ -18,6 +18,10 @@
18
18
  </div>
19
19
  </div>
20
20
  <nav ref="tocNavRef" class="toc-nav">
21
+ <!-- 左侧连续竖线 -->
22
+ <div class="toc-track"></div>
23
+ <!-- marker 滑块(平滑过渡) -->
24
+ <div class="toc-marker" :style="markerStyle"></div>
21
25
  <template v-for="(item, index) in tocItems" :key="item.id">
22
26
  <!-- 顶级标题(h1/h2):可点击折叠 -->
23
27
  <a
@@ -53,7 +57,7 @@
53
57
  </template>
54
58
 
55
59
  <script setup>
56
- import { ref, watch, nextTick } from 'vue'
60
+ import { ref, watch, nextTick, reactive } from 'vue'
57
61
  import { List, ChevronRight, ChevronsDownUp, ChevronsUpDown } from 'lucide-vue-next'
58
62
 
59
63
  const props = defineProps({
@@ -68,6 +72,9 @@ defineEmits(['scroll-to', 'toggle'])
68
72
  // 目录导航容器 ref
69
73
  const tocNavRef = ref(null)
70
74
 
75
+ // marker 滑块位置
76
+ const markerStyle = reactive({ top: '0px', height: '0px', opacity: 0 })
77
+
71
78
  // 记录被收起的顶级标题 id
72
79
  const collapsedSections = ref(new Set())
73
80
 
@@ -125,9 +132,30 @@ function collapseAll() {
125
132
  collapsedSections.value = ids
126
133
  }
127
134
 
135
+ // 更新 marker 滑块位置
136
+ function updateMarker() {
137
+ if (!props.activeHeading || !tocNavRef.value) {
138
+ markerStyle.opacity = 0
139
+ return
140
+ }
141
+ const el = tocNavRef.value.querySelector(`[data-toc-id="${props.activeHeading}"]`)
142
+ if (!el) {
143
+ markerStyle.opacity = 0
144
+ return
145
+ }
146
+ const navRect = tocNavRef.value.getBoundingClientRect()
147
+ const elRect = el.getBoundingClientRect()
148
+ markerStyle.top = `${elRect.top - navRect.top + tocNavRef.value.scrollTop}px`
149
+ markerStyle.height = `${elRect.height}px`
150
+ markerStyle.opacity = 1
151
+ }
152
+
128
153
  // 滚动时自动展开当前激活标题所在的折叠分组,并将目录项滚动到可视区域
129
154
  watch(() => props.activeHeading, (id) => {
130
- if (!id) return
155
+ if (!id) {
156
+ markerStyle.opacity = 0
157
+ return
158
+ }
131
159
  const index = props.tocItems.findIndex(item => item.id === id)
132
160
  if (index === -1) return
133
161
  // 如果激活的是子标题,找到其父级并展开
@@ -139,17 +167,25 @@ watch(() => props.activeHeading, (id) => {
139
167
  collapsedSections.value = next
140
168
  }
141
169
  }
142
- // 等 DOM 更新后,将激活项滚动到目录可视区域
170
+ // 等 DOM 更新后,将激活项滚动到目录可视区域,并更新 marker
143
171
  nextTick(() => {
144
172
  const el = tocNavRef.value?.querySelector(`[data-toc-id="${id}"]`)
145
- if (el) {
146
- el.scrollIntoView({ block: 'center', behavior: 'smooth' })
173
+ if (el && tocNavRef.value) {
174
+ // 只在 TOC 导航容器内滚动,避免 scrollIntoView 冒泡影响主内容区导致抖动
175
+ const navRect = tocNavRef.value.getBoundingClientRect()
176
+ const elRect = el.getBoundingClientRect()
177
+ const elCenter = elRect.top + elRect.height / 2
178
+ const navCenter = navRect.top + navRect.height / 2
179
+ const offset = elCenter - navCenter
180
+ tocNavRef.value.scrollBy({ top: offset, behavior: 'smooth' })
147
181
  }
182
+ updateMarker()
148
183
  })
149
184
  })
150
185
 
151
- // 文档切换时重置折叠状态
186
+ // 文档切换时重置折叠状态和 marker
152
187
  watch(() => props.tocItems, () => {
153
188
  collapsedSections.value = new Set()
189
+ markerStyle.opacity = 0
154
190
  })
155
191
  </script>
@@ -4,8 +4,9 @@ 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
8
  import { stripOrderPrefix } from './useDocHash.js'
9
+ import { clearMermaidCache } from './useMermaidCache.js'
9
10
 
10
11
  // 等待内容区域图片加载完成
11
12
  async function waitForContentImages(timeoutMs = 3000) {
@@ -39,7 +40,11 @@ export function useDocManager() {
39
40
  handleScroll: _handleScroll,
40
41
  scrollToHeading: _scrollToHeading,
41
42
  scrollToTop,
42
- setTocItems
43
+ setTocItems,
44
+ rebuildHeadingsCache,
45
+ clearActiveHeading,
46
+ isSuppressHashClear,
47
+ lockHeading
43
48
  } = useScroll()
44
49
  const { isMobile, mobileDrawerOpen } = useMobile()
45
50
 
@@ -55,31 +60,52 @@ export function useDocManager() {
55
60
  return { scrollTop: scrollTop ?? getScrollTop() }
56
61
  }
57
62
 
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
+
58
96
  function handleScroll(e) {
59
97
  _handleScroll(e)
60
- if (activeHeading.value && currentDoc.value) {
61
- history.replaceState(makeState(), '', `/${docHash(currentDoc.value)}#${activeHeading.value}`)
62
- }
63
98
  }
64
99
 
65
100
  function scrollToHeading(id, { push = false } = {}) {
66
- if (push && currentDoc.value) {
67
- history.replaceState(makeState(), '', window.location.href)
68
- }
101
+ if (push) _pendingPush = true
69
102
  _scrollToHeading(id)
70
- if (currentDoc.value) {
71
- const url = `/${docHash(currentDoc.value)}#${id}`
72
- if (push) {
73
- history.pushState(makeState(), '', url)
74
- } else {
75
- history.replaceState(makeState(), '', url)
76
- }
77
- }
78
103
  }
79
104
 
80
105
  // ===== 文档列表加载 =====
81
106
  async function loadDocsList() {
82
107
  docsList.value = await docService.fetchDocsList()
108
+ buildHashIndex(docsList.value, docHash)
83
109
  restoreExpandedState()
84
110
  buildIndex(docsList.value)
85
111
  }
@@ -105,6 +131,7 @@ export function useDocManager() {
105
131
  currentDoc.value = key
106
132
  showWelcome.value = false
107
133
  lastContentHash = ''
134
+ clearMermaidCache()
108
135
  docService.resetContentEtag()
109
136
  const hash = docHash(key)
110
137
  const url = anchor ? `/${hash}#${anchor}` : `/${hash}`
@@ -131,6 +158,18 @@ export function useDocManager() {
131
158
  } else {
132
159
  await renderMarkdown(content, key, docsList.value)
133
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
+ }
134
173
  const contentEl = document.querySelector('.content')
135
174
  if (contentEl) contentEl.scrollTop = 0
136
175
  // 动态更新页面标题(SEO + 浏览器标签页)
@@ -189,10 +228,68 @@ export function useDocManager() {
189
228
  }
190
229
 
191
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
+ }
245
+
192
246
  function handleContentClick(event, { onZoom }) {
193
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
+ // 链接处理
194
273
  const link = target.closest('a')
195
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
+ }
196
293
  const docKey = link.dataset.docKey
197
294
  if (docKey) {
198
295
  event.preventDefault()
@@ -211,25 +308,6 @@ export function useDocManager() {
211
308
  }
212
309
  return
213
310
  }
214
- const isImg = target.tagName === 'IMG' && target.classList.contains('zoomable-image')
215
- const mermaidEl = target.closest('.mermaid') || target.closest('.mermaid-svg')
216
- const isMermaid = mermaidEl && mermaidEl.classList.contains('zoomable-image')
217
- if (isImg || isMermaid) {
218
- const container = document.querySelector('.markdown-content')
219
- if (!container) return
220
- const allZoomable = [...container.querySelectorAll('.zoomable-image')]
221
- const images = allZoomable.map(el => {
222
- if (el.tagName === 'IMG') {
223
- return `<img src="${el.src}" alt="${el.alt || ''}" style="max-width: 100%; height: auto;" />`
224
- }
225
- const clone = el.cloneNode(true)
226
- clone.querySelectorAll('.mermaid-copy-btn, .image-copy-btn').forEach(btn => btn.remove())
227
- return clone.innerHTML
228
- })
229
- const clickedEl = isImg ? target : mermaidEl
230
- const index = allZoomable.indexOf(clickedEl)
231
- onZoom({ images, index: Math.max(index, 0) })
232
- }
233
311
  }
234
312
 
235
313
  // ===== 计算属性 =====
@@ -239,18 +317,19 @@ export function useDocManager() {
239
317
  return doc?.label || '文档'
240
318
  })
241
319
 
320
+ // 扁平化文档列表(缓存,供 prevDoc / nextDoc 共享)
321
+ const flatList = computed(() => flattenDocsList(docsList.value))
322
+
242
323
  const prevDoc = computed(() => {
243
324
  if (!currentDoc.value) return null
244
- const flat = flattenDocsList(docsList.value)
245
- const idx = flat.findIndex(d => d.key === currentDoc.value)
246
- 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
247
327
  })
248
328
 
249
329
  const nextDoc = computed(() => {
250
330
  if (!currentDoc.value) return null
251
- const flat = flattenDocsList(docsList.value)
252
- const idx = flat.findIndex(d => d.key === currentDoc.value)
253
- 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
254
333
  })
255
334
 
256
335
  function handleSearchSelect(key) {
@@ -268,9 +347,13 @@ export function useDocManager() {
268
347
  watch(editMode, async (newVal, oldVal) => {
269
348
  if (oldVal && !newVal && rawMarkdown.value && currentDoc.value) {
270
349
  await renderMarkdown(rawMarkdown.value, currentDoc.value, docsList.value)
350
+ await nextTick()
351
+ rebuildHeadingsCache()
271
352
  }
272
353
  if (newVal && rawMarkdown.value) {
273
354
  extractTOCFromMarkdown(rawMarkdown.value, tocItems)
355
+ await nextTick()
356
+ rebuildHeadingsCache()
274
357
  }
275
358
  })
276
359
 
@@ -328,6 +411,7 @@ export function useDocManager() {
328
411
  // 保存展开状态 → 替换 → 恢复
329
412
  const expandedKeys = getExpandedKeys(docsList.value)
330
413
  docsList.value = newTree
414
+ buildHashIndex(docsList.value, docHash)
331
415
  applyExpandedState(docsList.value, expandedKeys)
332
416
  if (currentDoc.value) expandParents(docsList.value, currentDoc.value)
333
417
  buildIndex(docsList.value)
@@ -346,10 +430,11 @@ export function useDocManager() {
346
430
  const hashStr = String(hash)
347
431
  if (hashStr === lastContentHash) return
348
432
  lastContentHash = hashStr
349
- rawMarkdown.value = content
350
- // 编辑模式下只更新 rawMarkdown(编辑器组件会自行比对内容决定是否刷新)
433
+ // 编辑模式下编辑器是内容权威来源,不回写 rawMarkdown 避免循环抖动
351
434
  if (editMode.value) return
435
+ rawMarkdown.value = content
352
436
  await renderMarkdown(content, currentDoc.value, docsList.value)
437
+ rebuildHeadingsCache()
353
438
  }
354
439
 
355
440
  // ===== 写操作 =====
@@ -403,6 +488,7 @@ export function useDocManager() {
403
488
  const contentEl = document.querySelector('.content')
404
489
  if (contentEl) contentEl.scrollTo({ top: savedScroll, behavior: 'smooth' })
405
490
  } else if (anchor) {
491
+ // popstate 同文档锚点跳转,_scrollToHeading 内部有锁
406
492
  _scrollToHeading(decodeURIComponent(anchor))
407
493
  } else {
408
494
  const contentEl = document.querySelector('.content')
@@ -415,10 +501,14 @@ export function useDocManager() {
415
501
  await loadDoc(doc.key, { replace: true, keepState: savedScroll != null, anchor: anchor ? decodeURIComponent(anchor) : '' })
416
502
  if (savedScroll != null) {
417
503
  await nextTick()
504
+ await waitForContentImages()
418
505
  const contentEl = document.querySelector('.content')
419
506
  if (contentEl) contentEl.scrollTo({ top: savedScroll })
420
507
  } else if (anchor) {
421
- await nextTick(); await waitForContentImages(); _scrollToHeading(decodeURIComponent(anchor))
508
+ const decodedAnchor = decodeURIComponent(anchor)
509
+ await nextTick(); await waitForContentImages()
510
+ // 复用 scrollToHeading,编辑模式下通过 _syncHeadingIds 注入的 id 或文本匹配均可定位
511
+ _scrollToHeading(decodedAnchor)
422
512
  }
423
513
  }
424
514