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
package/src/style.css CHANGED
@@ -248,7 +248,7 @@ body {
248
248
  max-width: 960px;
249
249
  margin: 0 auto;
250
250
  background: var(--color-bg);
251
- padding: 24px 32px;
251
+ padding: 24px 20px;
252
252
  }
253
253
 
254
254
  /* Markdown 内容样式 */
@@ -279,6 +279,19 @@ body {
279
279
  .markdown-content h5,
280
280
  .markdown-content h6 {
281
281
  position: relative;
282
+ scroll-margin-top: 60px;
283
+ }
284
+
285
+ /* 标题锚点跳转高亮闪烁动画 */
286
+ @keyframes heading-flash {
287
+ 0% { background-color: transparent; }
288
+ 15% { background-color: rgba(66, 184, 131, 0.15); }
289
+ 100% { background-color: transparent; }
290
+ }
291
+
292
+ .markdown-content .heading-flash {
293
+ animation: heading-flash 1.5s ease-out;
294
+ border-radius: 4px;
282
295
  }
283
296
 
284
297
  .markdown-content h1:hover .heading-anchor,
@@ -295,6 +308,44 @@ body {
295
308
  text-decoration: none;
296
309
  }
297
310
 
311
+ /* 锚点提示气泡(hover + 点击复制共用同一位置) */
312
+ .markdown-content .heading-anchor::after {
313
+ content: '复制链接';
314
+ position: absolute;
315
+ left: 0.7em;
316
+ bottom: calc(100% + 4px);
317
+ padding: 1px 6px;
318
+ background: var(--color-bg-tertiary);
319
+ color: var(--color-text-secondary);
320
+ font-size: 10px;
321
+ font-weight: 400;
322
+ border-radius: 3px;
323
+ white-space: nowrap;
324
+ pointer-events: none;
325
+ opacity: 0;
326
+ transition: opacity 0.15s;
327
+ z-index: 10;
328
+ }
329
+
330
+ .markdown-content .heading-anchor:hover::after {
331
+ opacity: 1;
332
+ }
333
+
334
+ /* 点击复制后切换为"已复制"状态 */
335
+ .markdown-content .heading-anchor.anchor-copied::after {
336
+ content: '已复制';
337
+ background: var(--color-success-bg, #dafbe1);
338
+ color: var(--color-success, #1a7f37);
339
+ opacity: 1;
340
+ }
341
+
342
+ .markdown-content .heading-anchor.anchor-copy-error::after {
343
+ content: '复制失败';
344
+ background: var(--color-error-bg, #ffebe9);
345
+ color: var(--color-error-text, #cf222e);
346
+ opacity: 1;
347
+ }
348
+
298
349
  .markdown-content h1 {
299
350
  font-size: 2em;
300
351
  margin-bottom: 8px;
@@ -333,6 +384,22 @@ body {
333
384
  font-weight: 600;
334
385
  }
335
386
 
387
+ .markdown-content h5 {
388
+ font-size: 0.95em;
389
+ margin-top: 14px;
390
+ margin-bottom: 6px;
391
+ color: var(--color-text);
392
+ font-weight: 600;
393
+ }
394
+
395
+ .markdown-content h6 {
396
+ font-size: 0.9em;
397
+ margin-top: 12px;
398
+ margin-bottom: 6px;
399
+ color: var(--color-text-secondary);
400
+ font-weight: 600;
401
+ }
402
+
336
403
  .markdown-content p {
337
404
  margin-bottom: 12px;
338
405
  color: var(--color-text);
@@ -1049,9 +1116,9 @@ body {
1049
1116
  align-items: center;
1050
1117
  justify-content: center;
1051
1118
  gap: 1px;
1052
- background: var(--color-bg);
1053
- color: var(--color-text-secondary);
1054
- border: 1px solid var(--color-border);
1119
+ background: #fff;
1120
+ color: var(--color-accent);
1121
+ border: 1px solid var(--color-accent);
1055
1122
  border-radius: 6px;
1056
1123
  cursor: pointer;
1057
1124
  box-shadow: 0 1px 3px var(--color-shadow);
@@ -1060,9 +1127,9 @@ body {
1060
1127
  }
1061
1128
 
1062
1129
  .back-to-top:hover {
1063
- background: var(--color-bg-secondary);
1064
- color: var(--color-text);
1065
- border-color: var(--color-border);
1130
+ background: var(--color-accent);
1131
+ color: #fff;
1132
+ border-color: var(--color-accent);
1066
1133
  }
1067
1134
 
1068
1135
  .progress-text {
@@ -1098,6 +1165,11 @@ body {
1098
1165
  justify-content: space-between;
1099
1166
  padding: 0 12px 8px;
1100
1167
  border-bottom: 1px solid var(--color-border);
1168
+ position: sticky;
1169
+ top: 0;
1170
+ background: var(--color-bg);
1171
+ z-index: 1;
1172
+ padding-top: 0;
1101
1173
  }
1102
1174
 
1103
1175
  .toc-title {
@@ -1157,6 +1229,31 @@ body {
1157
1229
 
1158
1230
  .toc-nav {
1159
1231
  padding: 8px 0;
1232
+ position: relative;
1233
+ }
1234
+
1235
+ /* 左侧连续竖线 */
1236
+ .toc-track {
1237
+ position: absolute;
1238
+ left: 0;
1239
+ top: 8px;
1240
+ bottom: 8px;
1241
+ width: 2px;
1242
+ background: var(--color-border-light);
1243
+ border-radius: 1px;
1244
+ }
1245
+
1246
+ /* marker 滑块 — 平滑过渡 */
1247
+ .toc-marker {
1248
+ position: absolute;
1249
+ left: 0;
1250
+ width: 2px;
1251
+ background: var(--color-accent);
1252
+ border-radius: 1px;
1253
+ transition: top 0.25s cubic-bezier(0.65, 0, 0.35, 1),
1254
+ height 0.25s cubic-bezier(0.65, 0, 0.35, 1),
1255
+ opacity 0.25s;
1256
+ z-index: 1;
1160
1257
  }
1161
1258
 
1162
1259
  .toc-item {
@@ -1169,7 +1266,6 @@ body {
1169
1266
  font-size: 12px;
1170
1267
  line-height: 1.5;
1171
1268
  transition: all 0.1s;
1172
- border-left: 2px solid transparent;
1173
1269
  }
1174
1270
 
1175
1271
  .toc-item-text {
@@ -1216,7 +1312,6 @@ body {
1216
1312
 
1217
1313
  .toc-item.active {
1218
1314
  color: var(--color-accent);
1219
- border-left-color: var(--color-accent);
1220
1315
  font-weight: 500;
1221
1316
  background: var(--color-accent-bg);
1222
1317
  border-radius: 0 4px 4px 0;
@@ -1232,6 +1327,8 @@ body {
1232
1327
  .toc-item.toc-level-2 { padding-left: 20px; }
1233
1328
  .toc-item.toc-level-3 { padding-left: 28px; font-size: 11px; }
1234
1329
  .toc-item.toc-level-4 { padding-left: 36px; font-size: 11px; }
1330
+ .toc-item.toc-level-5 { padding-left: 44px; font-size: 11px; }
1331
+ .toc-item.toc-level-6 { padding-left: 52px; font-size: 11px; }
1235
1332
 
1236
1333
  /* ===== 拖拽条 ===== */
1237
1334
  .resizer {
@@ -1514,7 +1611,7 @@ body {
1514
1611
 
1515
1612
  @media (max-width: 768px) {
1516
1613
  .markdown-content {
1517
- padding: 16px;
1614
+ padding: 16px 20px;
1518
1615
  width: 100%;
1519
1616
  }
1520
1617
  .back-to-top {
@@ -3522,3 +3619,131 @@ body {
3522
3619
  padding: 12px 16px 24px;
3523
3620
  }
3524
3621
  }
3622
+
3623
+ /* ===== 移动端搜索页面 ===== */
3624
+ .mobile-search-page {
3625
+ flex: 1;
3626
+ display: flex;
3627
+ flex-direction: column;
3628
+ min-width: 0;
3629
+ background: var(--color-bg);
3630
+ overflow: hidden;
3631
+ }
3632
+
3633
+ .mobile-search-header {
3634
+ display: flex;
3635
+ align-items: center;
3636
+ gap: 8px;
3637
+ padding: 12px;
3638
+ border-bottom: 1px solid var(--color-border);
3639
+ flex-shrink: 0;
3640
+ }
3641
+
3642
+ .mobile-search-input-wrapper {
3643
+ flex: 1;
3644
+ display: flex;
3645
+ align-items: center;
3646
+ gap: 8px;
3647
+ padding: 8px 12px;
3648
+ background: var(--color-bg-secondary);
3649
+ border: 1px solid var(--color-border);
3650
+ border-radius: 8px;
3651
+ }
3652
+
3653
+ .mobile-search-input-wrapper:focus-within {
3654
+ border-color: var(--color-accent);
3655
+ box-shadow: 0 0 0 3px var(--color-accent-bg);
3656
+ }
3657
+
3658
+ .mobile-search-icon {
3659
+ color: var(--color-text-tertiary);
3660
+ flex-shrink: 0;
3661
+ }
3662
+
3663
+ .mobile-search-input {
3664
+ flex: 1;
3665
+ border: none;
3666
+ outline: none;
3667
+ background: transparent;
3668
+ font-size: 15px;
3669
+ color: var(--color-text);
3670
+ min-width: 0;
3671
+ }
3672
+
3673
+ .mobile-search-input::placeholder {
3674
+ color: var(--color-text-tertiary);
3675
+ }
3676
+
3677
+ .mobile-search-clear {
3678
+ display: flex;
3679
+ align-items: center;
3680
+ justify-content: center;
3681
+ width: 20px;
3682
+ height: 20px;
3683
+ border: none;
3684
+ background: var(--color-bg-tertiary);
3685
+ color: var(--color-text-secondary);
3686
+ border-radius: 50%;
3687
+ cursor: pointer;
3688
+ flex-shrink: 0;
3689
+ padding: 0;
3690
+ }
3691
+
3692
+ .mobile-search-cancel {
3693
+ border: none;
3694
+ background: transparent;
3695
+ color: var(--color-accent);
3696
+ font-size: 14px;
3697
+ cursor: pointer;
3698
+ white-space: nowrap;
3699
+ padding: 4px 0;
3700
+ }
3701
+
3702
+ .mobile-search-results {
3703
+ flex: 1;
3704
+ overflow-y: auto;
3705
+ -webkit-overflow-scrolling: touch;
3706
+ }
3707
+
3708
+ .mobile-search-item {
3709
+ display: flex;
3710
+ align-items: center;
3711
+ gap: 10px;
3712
+ padding: 12px 16px;
3713
+ cursor: pointer;
3714
+ border-bottom: 1px solid var(--color-border-light);
3715
+ transition: background 0.1s;
3716
+ }
3717
+
3718
+ .mobile-search-item:active,
3719
+ .mobile-search-item.active {
3720
+ background: var(--color-accent-bg);
3721
+ }
3722
+
3723
+ .mobile-search-item-icon {
3724
+ color: var(--color-text-tertiary);
3725
+ flex-shrink: 0;
3726
+ }
3727
+
3728
+ .mobile-search-item-title {
3729
+ flex: 1;
3730
+ font-size: 14px;
3731
+ color: var(--color-text);
3732
+ overflow: hidden;
3733
+ text-overflow: ellipsis;
3734
+ white-space: nowrap;
3735
+ }
3736
+
3737
+ .mobile-search-empty {
3738
+ padding: 32px 16px;
3739
+ text-align: center;
3740
+ color: var(--color-text-tertiary);
3741
+ font-size: 14px;
3742
+ }
3743
+
3744
+ .mobile-search-tip {
3745
+ padding: 24px 16px;
3746
+ text-align: center;
3747
+ color: var(--color-text-tertiary);
3748
+ font-size: 13px;
3749
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * 图片格式转换工具(共享模块)
3
+ * 提供 imgToPngBlob / svgToPngBlob 方法
4
+ * 供 useMarkdown.js 和 ImageZoom.vue 共用
5
+ */
6
+
7
+ /**
8
+ * 将图片 URL 转为 PNG blob(兼容同源和跨域)
9
+ * 策略1:fetch 获取 → 策略2:crossOrigin Image → 策略3:无 crossOrigin 直接画
10
+ */
11
+ export async function imgToPngBlob(src) {
12
+ try {
13
+ const resp = await fetch(src)
14
+ const blob = await resp.blob()
15
+ if (blob.type === 'image/png') return blob
16
+ return blobToCanvasPng(blob)
17
+ } catch {
18
+ try {
19
+ return await loadImageToBlob(src, true)
20
+ } catch {
21
+ return loadImageToBlob(src, false)
22
+ }
23
+ }
24
+ }
25
+
26
+ /**
27
+ * 加载图片到 canvas 并转 PNG blob
28
+ * @param {string} src - 图片地址
29
+ * @param {boolean} useCors - 是否设置 crossOrigin
30
+ */
31
+ function loadImageToBlob(src, useCors) {
32
+ return new Promise((resolve, reject) => {
33
+ const image = new Image()
34
+ if (useCors) image.crossOrigin = 'anonymous'
35
+ image.onload = () => {
36
+ try {
37
+ const canvas = document.createElement('canvas')
38
+ canvas.width = image.naturalWidth
39
+ canvas.height = image.naturalHeight
40
+ canvas.getContext('2d').drawImage(image, 0, 0)
41
+ canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob null')), 'image/png')
42
+ } catch (e) { reject(e) }
43
+ }
44
+ image.onerror = () => reject(new Error('图片加载失败'))
45
+ image.src = src
46
+ })
47
+ }
48
+
49
+ /**
50
+ * 将任意图片 blob 通过 canvas 转为 PNG blob
51
+ */
52
+ function blobToCanvasPng(srcBlob, scale = 1) {
53
+ return new Promise((resolve, reject) => {
54
+ const url = URL.createObjectURL(srcBlob)
55
+ const image = new Image()
56
+ image.onload = () => {
57
+ const canvas = document.createElement('canvas')
58
+ canvas.width = image.naturalWidth * scale
59
+ canvas.height = image.naturalHeight * scale
60
+ const ctx = canvas.getContext('2d')
61
+ if (scale !== 1) ctx.scale(scale, scale)
62
+ ctx.drawImage(image, 0, 0)
63
+ URL.revokeObjectURL(url)
64
+ canvas.toBlob(b => resolve(b), 'image/png')
65
+ }
66
+ image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('图片加载失败')) }
67
+ image.src = url
68
+ })
69
+ }
70
+
71
+ /**
72
+ * SVG 转 PNG blob(处理 foreignObject 导致的 tainted canvas 问题)
73
+ * @param {SVGElement} svgEl - SVG DOM 元素
74
+ * @param {number} scale - 缩放倍数,默认 2
75
+ */
76
+ export function svgToPngBlob(svgEl, scale = 2) {
77
+ return new Promise((resolve, reject) => {
78
+ const clone = svgEl.cloneNode(true)
79
+ // 从 viewBox 或属性中获取 SVG 实际尺寸
80
+ const viewBox = clone.getAttribute('viewBox')
81
+ let svgWidth, svgHeight
82
+ if (viewBox) {
83
+ const parts = viewBox.split(/[\s,]+/)
84
+ svgWidth = parseFloat(parts[2])
85
+ svgHeight = parseFloat(parts[3])
86
+ }
87
+ if (!svgWidth || !svgHeight) {
88
+ svgWidth = parseFloat(clone.getAttribute('width')) || svgEl.getBoundingClientRect().width || 800
89
+ svgHeight = parseFloat(clone.getAttribute('height')) || svgEl.getBoundingClientRect().height || 600
90
+ }
91
+ // 显式设置 width/height,确保 Image 加载时尺寸正确
92
+ clone.setAttribute('width', svgWidth)
93
+ clone.setAttribute('height', svgHeight)
94
+ // 将 foreignObject 替换为 text 元素,避免 canvas 被污染
95
+ clone.querySelectorAll('foreignObject').forEach(fo => {
96
+ const text = fo.textContent.trim()
97
+ const x = fo.getAttribute('x') || '0'
98
+ const y = fo.getAttribute('y') || '0'
99
+ const width = fo.getAttribute('width') || '100'
100
+ const height = fo.getAttribute('height') || '20'
101
+ const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text')
102
+ textEl.setAttribute('x', String(parseFloat(x) + parseFloat(width) / 2))
103
+ textEl.setAttribute('y', String(parseFloat(y) + parseFloat(height) / 2 + 5))
104
+ textEl.setAttribute('text-anchor', 'middle')
105
+ textEl.setAttribute('font-size', '14')
106
+ textEl.setAttribute('fill', '#455a64')
107
+ textEl.textContent = text
108
+ fo.parentNode.replaceChild(textEl, fo)
109
+ })
110
+ const svgData = new XMLSerializer().serializeToString(clone)
111
+ const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
112
+ const url = URL.createObjectURL(svgBlob)
113
+ const image = new Image()
114
+ image.onload = () => {
115
+ const w = svgWidth * scale
116
+ const h = svgHeight * scale
117
+ const canvas = document.createElement('canvas')
118
+ canvas.width = w
119
+ canvas.height = h
120
+ const ctx = canvas.getContext('2d')
121
+ ctx.scale(scale, scale)
122
+ ctx.drawImage(image, 0, 0, svgWidth, svgHeight)
123
+ URL.revokeObjectURL(url)
124
+ canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob null')), 'image/png')
125
+ }
126
+ image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('SVG加载失败')) }
127
+ image.src = url
128
+ })
129
+ }