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,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
- return `<div class="mermaid" id="${id}">${code}</div>`
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
- // 将图片 URL 转为 PNG blob(兼容同源和跨域)
243
- async function imgToPngBlob(src) {
244
- // 策略1:fetch 获取(同源图片直接成功)
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
- for (const element of mermaidElements) {
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
- const id = element.id
356
- const code = element.textContent
357
- const { svg } = await mermaid.render(id + '-svg', code)
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
- if (!element.querySelector('.mermaid-copy-btn')) {
364
- element.style.position = 'relative'
365
- const copyBtn = document.createElement('button')
366
- copyBtn.className = 'mermaid-copy-btn image-copy-btn'
367
- copyBtn.title = '复制图片'
368
- copyBtn.innerHTML = COPY_ICON
369
- copyBtn.addEventListener('click', async (e) => {
370
- e.stopPropagation()
371
- e.preventDefault()
372
- try {
373
- const svgEl = element.querySelector('svg')
374
- if (!svgEl) return
375
- // 克隆 SVG 并替换 foreignObject text(避免 canvas tainted)
376
- const blobPromise = svgToPngBlob(svgEl, 2)
377
- const item = new ClipboardItem({ 'image/png': blobPromise })
378
- await navigator.clipboard.write([item])
379
- showCopyTip(copyBtn, true)
380
- } catch (err) {
381
- console.warn('复制Mermaid图表失败:', err)
382
- showCopyTip(copyBtn, false)
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
- // 清理 Mermaid 渲染失败时残留在 DOM 中的错误容器
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
+ }