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
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
// 文档树操作:查找、展开、扁平化等纯逻辑
|
|
2
2
|
|
|
3
|
+
// ===== Hash 索引(O(1) 查找 + 碰撞检测) =====
|
|
4
|
+
// 文档列表变更时由 buildHashIndex 重建
|
|
5
|
+
let _hashIndex = new Map() // hash → doc
|
|
6
|
+
|
|
7
|
+
// 构建 hash → doc 索引,同时检测碰撞
|
|
8
|
+
export function buildHashIndex(items, docHashFn) {
|
|
9
|
+
_hashIndex = new Map()
|
|
10
|
+
const allDocs = flattenDocsList(items)
|
|
11
|
+
for (const doc of allDocs) {
|
|
12
|
+
const hash = docHashFn(doc.key)
|
|
13
|
+
if (_hashIndex.has(hash)) {
|
|
14
|
+
const existing = _hashIndex.get(hash)
|
|
15
|
+
console.warn(
|
|
16
|
+
`[md2ui] hash 碰撞检测:「${doc.key}」与「${existing.key}」生成了相同的 hash「${hash}」,` +
|
|
17
|
+
'后者将被覆盖。请考虑重命名其中一个文档。'
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
_hashIndex.set(hash, doc)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
3
24
|
// 在文档树中查找文档
|
|
4
25
|
export function findDoc(items, key) {
|
|
5
26
|
for (const item of items) {
|
|
@@ -40,8 +61,12 @@ export function findReadmeDoc(items) {
|
|
|
40
61
|
return null
|
|
41
62
|
}
|
|
42
63
|
|
|
43
|
-
// 根据 hash
|
|
64
|
+
// 根据 hash 查找文档(优先走索引,O(1);索引未建时回退全树遍历)
|
|
44
65
|
export function findDocByHash(items, hash, docHash) {
|
|
66
|
+
if (_hashIndex.size > 0) {
|
|
67
|
+
return _hashIndex.get(hash) || null
|
|
68
|
+
}
|
|
69
|
+
// 回退:索引未建时全树遍历
|
|
45
70
|
for (const item of items) {
|
|
46
71
|
if (item.type === 'file' && docHash(item.key) === hash) return item
|
|
47
72
|
if (item.type === 'folder' && item.children) {
|
|
@@ -99,53 +124,4 @@ export function collapseAll(items) {
|
|
|
99
124
|
})
|
|
100
125
|
}
|
|
101
126
|
|
|
102
|
-
// 查找节点的父级列表(用于插入/删除操作)
|
|
103
|
-
// 返回 { parent: 父节点children数组, index: 节点在数组中的索引 }
|
|
104
|
-
export function findParent(items, targetKey) {
|
|
105
|
-
for (let i = 0; i < items.length; i++) {
|
|
106
|
-
if (items[i].key === targetKey) {
|
|
107
|
-
return { parent: items, index: i }
|
|
108
|
-
}
|
|
109
|
-
if (items[i].type === 'folder' && items[i].children) {
|
|
110
|
-
const found = findParent(items[i].children, targetKey)
|
|
111
|
-
if (found) return found
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
return null
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// 查找文件夹节点
|
|
118
|
-
export function findFolder(items, key) {
|
|
119
|
-
for (const item of items) {
|
|
120
|
-
if (item.type === 'folder' && item.key === key) return item
|
|
121
|
-
if (item.type === 'folder' && item.children) {
|
|
122
|
-
const found = findFolder(item.children, key)
|
|
123
|
-
if (found) return found
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
return null
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// 收集所有已展开文件夹的 key
|
|
130
|
-
export function collectExpandedKeys(items) {
|
|
131
|
-
const keys = new Set()
|
|
132
|
-
for (const item of items) {
|
|
133
|
-
if (item.type === 'folder') {
|
|
134
|
-
if (item.expanded) keys.add(item.key)
|
|
135
|
-
if (item.children) {
|
|
136
|
-
for (const k of collectExpandedKeys(item.children)) keys.add(k)
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
return keys
|
|
141
|
-
}
|
|
142
127
|
|
|
143
|
-
// 根据 key 集合恢复展开状态
|
|
144
|
-
export function restoreExpandedKeys(items, keys) {
|
|
145
|
-
for (const item of items) {
|
|
146
|
-
if (item.type === 'folder') {
|
|
147
|
-
if (keys.has(item.key)) item.expanded = true
|
|
148
|
-
if (item.children) restoreExpandedKeys(item.children, keys)
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
@@ -86,7 +86,8 @@ function createRenderer(currentDocKey, docsList) {
|
|
|
86
86
|
renderer.code = function(code, language) {
|
|
87
87
|
if (language === 'mermaid') {
|
|
88
88
|
const id = 'mermaid-' + Math.random().toString(36).substr(2, 9)
|
|
89
|
-
|
|
89
|
+
const encoded = encodeURIComponent(code)
|
|
90
|
+
return `<div class="mermaid mermaid-pending" id="${id}" data-source="${encoded}"><div class="mermaid-loading">渲染中...</div></div>`
|
|
90
91
|
}
|
|
91
92
|
// 高亮代码
|
|
92
93
|
let highlighted
|
|
@@ -239,159 +240,66 @@ function showCopyTip(btn, success) {
|
|
|
239
240
|
}, 1500)
|
|
240
241
|
}
|
|
241
242
|
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
try {
|
|
246
|
-
const resp = await fetch(src)
|
|
247
|
-
const blob = await resp.blob()
|
|
248
|
-
if (blob.type === 'image/png') return blob
|
|
249
|
-
// 非 PNG 通过 canvas 转换
|
|
250
|
-
return new Promise((resolve, reject) => {
|
|
251
|
-
const url = URL.createObjectURL(blob)
|
|
252
|
-
const image = new Image()
|
|
253
|
-
image.onload = () => {
|
|
254
|
-
const canvas = document.createElement('canvas')
|
|
255
|
-
canvas.width = image.naturalWidth
|
|
256
|
-
canvas.height = image.naturalHeight
|
|
257
|
-
canvas.getContext('2d').drawImage(image, 0, 0)
|
|
258
|
-
URL.revokeObjectURL(url)
|
|
259
|
-
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob null')), 'image/png')
|
|
260
|
-
}
|
|
261
|
-
image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('load fail')) }
|
|
262
|
-
image.src = url
|
|
263
|
-
})
|
|
264
|
-
} catch {
|
|
265
|
-
// 策略2:crossOrigin Image(需服务器支持 CORS)
|
|
266
|
-
try {
|
|
267
|
-
return await loadImageToBlob(src, true)
|
|
268
|
-
} catch {
|
|
269
|
-
// 策略3:不设 crossOrigin 直接画
|
|
270
|
-
return loadImageToBlob(src, false)
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function loadImageToBlob(src, useCors) {
|
|
276
|
-
return new Promise((resolve, reject) => {
|
|
277
|
-
const image = new Image()
|
|
278
|
-
if (useCors) image.crossOrigin = 'anonymous'
|
|
279
|
-
image.onload = () => {
|
|
280
|
-
try {
|
|
281
|
-
const canvas = document.createElement('canvas')
|
|
282
|
-
canvas.width = image.naturalWidth
|
|
283
|
-
canvas.height = image.naturalHeight
|
|
284
|
-
canvas.getContext('2d').drawImage(image, 0, 0)
|
|
285
|
-
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob null')), 'image/png')
|
|
286
|
-
} catch (e) { reject(e) }
|
|
287
|
-
}
|
|
288
|
-
image.onerror = () => reject(new Error('图片加载失败'))
|
|
289
|
-
image.src = src
|
|
290
|
-
})
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// SVG 转 PNG blob(处理 foreignObject 导致的 tainted canvas 问题)
|
|
294
|
-
function svgToPngBlob(svgEl, scale = 2) {
|
|
295
|
-
return new Promise((resolve, reject) => {
|
|
296
|
-
const clone = svgEl.cloneNode(true)
|
|
297
|
-
// 从 viewBox 或属性中获取 SVG 实际尺寸,确保复制完整
|
|
298
|
-
const viewBox = clone.getAttribute('viewBox')
|
|
299
|
-
let svgWidth, svgHeight
|
|
300
|
-
if (viewBox) {
|
|
301
|
-
const parts = viewBox.split(/[\s,]+/)
|
|
302
|
-
svgWidth = parseFloat(parts[2])
|
|
303
|
-
svgHeight = parseFloat(parts[3])
|
|
304
|
-
}
|
|
305
|
-
if (!svgWidth || !svgHeight) {
|
|
306
|
-
svgWidth = parseFloat(clone.getAttribute('width')) || svgEl.getBoundingClientRect().width || 800
|
|
307
|
-
svgHeight = parseFloat(clone.getAttribute('height')) || svgEl.getBoundingClientRect().height || 600
|
|
308
|
-
}
|
|
309
|
-
// 显式设置 width/height,确保 Image 加载时尺寸正确
|
|
310
|
-
clone.setAttribute('width', svgWidth)
|
|
311
|
-
clone.setAttribute('height', svgHeight)
|
|
312
|
-
// 将 foreignObject 替换为 text 元素,避免 canvas 被污染
|
|
313
|
-
clone.querySelectorAll('foreignObject').forEach(fo => {
|
|
314
|
-
const text = fo.textContent.trim()
|
|
315
|
-
const x = fo.getAttribute('x') || '0'
|
|
316
|
-
const y = fo.getAttribute('y') || '0'
|
|
317
|
-
const width = fo.getAttribute('width') || '100'
|
|
318
|
-
const height = fo.getAttribute('height') || '20'
|
|
319
|
-
const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text')
|
|
320
|
-
textEl.setAttribute('x', String(parseFloat(x) + parseFloat(width) / 2))
|
|
321
|
-
textEl.setAttribute('y', String(parseFloat(y) + parseFloat(height) / 2 + 5))
|
|
322
|
-
textEl.setAttribute('text-anchor', 'middle')
|
|
323
|
-
textEl.setAttribute('font-size', '14')
|
|
324
|
-
textEl.setAttribute('fill', '#455a64')
|
|
325
|
-
textEl.textContent = text
|
|
326
|
-
fo.parentNode.replaceChild(textEl, fo)
|
|
327
|
-
})
|
|
328
|
-
const svgData = new XMLSerializer().serializeToString(clone)
|
|
329
|
-
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
|
|
330
|
-
const url = URL.createObjectURL(svgBlob)
|
|
331
|
-
const image = new Image()
|
|
332
|
-
image.onload = () => {
|
|
333
|
-
const w = svgWidth * scale
|
|
334
|
-
const h = svgHeight * scale
|
|
335
|
-
const canvas = document.createElement('canvas')
|
|
336
|
-
canvas.width = w
|
|
337
|
-
canvas.height = h
|
|
338
|
-
const ctx = canvas.getContext('2d')
|
|
339
|
-
ctx.scale(scale, scale)
|
|
340
|
-
ctx.drawImage(image, 0, 0, svgWidth, svgHeight)
|
|
341
|
-
URL.revokeObjectURL(url)
|
|
342
|
-
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob null')), 'image/png')
|
|
343
|
-
}
|
|
344
|
-
image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('SVG加载失败')) }
|
|
345
|
-
image.src = url
|
|
346
|
-
})
|
|
347
|
-
}
|
|
243
|
+
// 图片格式转换工具(从共享模块导入)
|
|
244
|
+
import { imgToPngBlob, svgToPngBlob } from '../utils/imageConverter.js'
|
|
245
|
+
import { getMermaidCache, setMermaidCache } from './useMermaidCache.js'
|
|
348
246
|
|
|
349
|
-
// 渲染 Mermaid
|
|
247
|
+
// 渲染 Mermaid 图表(并行渲染所有图表,等全部完成后返回)
|
|
350
248
|
async function renderMermaid() {
|
|
351
249
|
await nextTick()
|
|
352
|
-
const mermaidElements = document.querySelectorAll('.mermaid')
|
|
353
|
-
|
|
250
|
+
const mermaidElements = document.querySelectorAll('.mermaid-pending')
|
|
251
|
+
if (!mermaidElements.length) return
|
|
252
|
+
|
|
253
|
+
// 所有图表并行渲染
|
|
254
|
+
const tasks = Array.from(mermaidElements).map(async (element) => {
|
|
255
|
+
const code = decodeURIComponent(element.dataset.source || '')
|
|
256
|
+
if (!code) return
|
|
257
|
+
const id = element.id
|
|
354
258
|
try {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
259
|
+
// 优先使用缓存
|
|
260
|
+
let svg = getMermaidCache(code)
|
|
261
|
+
if (!svg) {
|
|
262
|
+
const result = await mermaid.render(id + '-svg', code)
|
|
263
|
+
svg = result.svg
|
|
264
|
+
setMermaidCache(code, svg)
|
|
265
|
+
}
|
|
358
266
|
element.innerHTML = svg
|
|
267
|
+
element.classList.remove('mermaid-pending')
|
|
359
268
|
element.classList.add('zoomable-image')
|
|
360
269
|
element.style.cursor = 'zoom-in'
|
|
361
270
|
element.title = '点击放大查看'
|
|
362
271
|
// 添加复制按钮
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
})
|
|
385
|
-
element.appendChild(copyBtn)
|
|
386
|
-
}
|
|
272
|
+
element.style.position = 'relative'
|
|
273
|
+
const copyBtn = document.createElement('button')
|
|
274
|
+
copyBtn.className = 'mermaid-copy-btn image-copy-btn'
|
|
275
|
+
copyBtn.title = '复制图片'
|
|
276
|
+
copyBtn.innerHTML = COPY_ICON
|
|
277
|
+
copyBtn.addEventListener('click', async (e) => {
|
|
278
|
+
e.stopPropagation()
|
|
279
|
+
e.preventDefault()
|
|
280
|
+
try {
|
|
281
|
+
const svgEl = element.querySelector('svg')
|
|
282
|
+
if (!svgEl) return
|
|
283
|
+
const blobPromise = svgToPngBlob(svgEl, 2)
|
|
284
|
+
const item = new ClipboardItem({ 'image/png': blobPromise })
|
|
285
|
+
await navigator.clipboard.write([item])
|
|
286
|
+
showCopyTip(copyBtn, true)
|
|
287
|
+
} catch (err) {
|
|
288
|
+
console.warn('复制Mermaid图表失败:', err)
|
|
289
|
+
showCopyTip(copyBtn, false)
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
element.appendChild(copyBtn)
|
|
387
293
|
} catch (error) {
|
|
388
294
|
console.error('Mermaid 渲染失败:', error)
|
|
389
|
-
|
|
390
|
-
const errorEl = document.getElementById(element.id + '-svg')
|
|
295
|
+
const errorEl = document.getElementById(id + '-svg')
|
|
391
296
|
if (errorEl) errorEl.remove()
|
|
297
|
+
element.classList.remove('mermaid-pending')
|
|
392
298
|
element.innerHTML = `<pre class="mermaid-error">图表渲染失败\n${error.message}</pre>`
|
|
393
299
|
}
|
|
394
|
-
}
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
await Promise.all(tasks)
|
|
395
303
|
}
|
|
396
304
|
|
|
397
305
|
// 为表格添加滚动容器和工具栏按钮
|
|
@@ -402,6 +310,9 @@ function wrapTables() {
|
|
|
402
310
|
// 已经处理过的跳过
|
|
403
311
|
if (table.closest('.table-outer')) return
|
|
404
312
|
|
|
313
|
+
// 代码块内的行号表格跳过
|
|
314
|
+
if (table.closest('.code-block-wrapper') || table.closest('pre')) return
|
|
315
|
+
|
|
405
316
|
// 构建结构:.table-outer > .table-toolbar + .table-wrapper > table
|
|
406
317
|
const outer = document.createElement('div')
|
|
407
318
|
outer.className = 'table-outer'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Mermaid SVG 渲染缓存,以源码为 key,SVG 字符串为 value
|
|
2
|
+
const _cache = new Map()
|
|
3
|
+
|
|
4
|
+
export function getMermaidCache(code) {
|
|
5
|
+
return _cache.get(code.trim()) || null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function setMermaidCache(code, svg) {
|
|
9
|
+
_cache.set(code.trim(), svg)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// 切换文档时清空缓存,避免内存泄漏
|
|
13
|
+
export function clearMermaidCache() {
|
|
14
|
+
_cache.clear()
|
|
15
|
+
}
|