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.
- package/README.md +65 -20
- package/bin/build.js +13 -2
- package/bin/md2ui.js +25 -12
- package/package.json +4 -4
- 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
- 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
- package/public/docs/02-Markdown/346/270/262/346/237/223/assets/img-1777383093712.png +0 -0
- 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
- 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
- package/src/App.vue +36 -61
- package/src/components/ImageZoom.vue +9 -123
- package/src/components/MermaidNodeView.vue +10 -2
- package/src/components/MobileSearch.vue +97 -0
- package/src/components/TableOfContents.vue +42 -6
- package/src/composables/useDocManager.js +134 -44
- package/src/composables/useDocTree.js +26 -50
- package/src/composables/useMarkdown.js +51 -140
- package/src/composables/useMermaidCache.js +15 -0
- package/src/composables/useScroll.js +317 -32
- package/src/composables/useSearch.js +12 -11
- package/src/config.js +1 -4
- package/src/services/DocService.js +0 -16
- package/src/style.css +235 -10
- package/src/utils/imageConverter.js +129 -0
- package/vite-plugin-doc-api.js +158 -157
- package/vite.config.js +5 -1
- package/src/components/SearchPanel.vue +0 -90
- package/src/components/TableBubbleMenu.vue +0 -177
- 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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
245
|
-
|
|
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
|
|
252
|
-
|
|
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
|
|
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
|
-
|
|
508
|
+
const decodedAnchor = decodeURIComponent(anchor)
|
|
509
|
+
await nextTick(); await waitForContentImages()
|
|
510
|
+
// 复用 scrollToHeading,编辑模式下通过 _syncHeadingIds 注入的 id 或文本匹配均可定位
|
|
511
|
+
_scrollToHeading(decodedAnchor)
|
|
422
512
|
}
|
|
423
513
|
}
|
|
424
514
|
|