md2ui 1.0.16 → 1.0.19

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 (74) hide show
  1. package/README.md +3 -55
  2. package/bin/build.js +82 -7
  3. package/bin/md2ui.js +80 -4
  4. package/package.json +23 -9
  5. package/public/docs/00-/345/277/253/351/200/237/345/274/200/345/247/213.md +48 -28
  6. package/public/docs/01-/345/212/237/350/203/275/347/211/271/346/200/247.md +55 -40
  7. 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 +86 -0
  8. package/public/docs/02-Markdown/346/270/262/346/237/223/01-/344/273/243/347/240/201/345/235/227.md +91 -0
  9. package/public/docs/02-Markdown/346/270/262/346/237/223/02-/350/241/250/346/240/274.md +187 -0
  10. package/public/docs/02-Markdown/346/270/262/346/237/223/03-Mermaid/345/233/276/350/241/250.md +101 -0
  11. package/public/docs/02-Markdown/346/270/262/346/237/223/04-Frontmatter.md +32 -0
  12. package/public/docs/02-Markdown/346/270/262/346/237/223/05-/346/225/260/345/255/246/345/205/254/345/274/217.md +47 -0
  13. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/00-/344/270/211/346/240/217/345/270/203/345/261/200.md +33 -0
  14. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/01-/347/233/256/345/275/225/346/240/221/345/257/274/350/210/252.md +43 -0
  15. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/02-/346/226/207/346/241/243/345/244/247/347/272/262.md +51 -0
  16. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/03-/344/270/212/344/270/213/347/257/207/345/257/274/350/210/252.md +29 -0
  17. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/04-/347/253/231/345/206/205/351/223/276/346/216/245.md +39 -0
  18. package/public/docs/04-/346/220/234/347/264/242/345/212/237/350/203/275/00-/345/205/250/346/226/207/346/220/234/347/264/242.md +46 -0
  19. package/public/docs/05-/347/274/226/350/276/221/345/212/237/350/203/275/00-/347/274/226/350/276/221/345/231/250/345/237/272/347/241/200.md +65 -0
  20. package/public/docs/05-/347/274/226/350/276/221/345/212/237/350/203/275/01-/350/207/252/345/212/250/344/277/235/345/255/230.md +38 -0
  21. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/00-/351/230/205/350/257/273/350/277/233/345/272/246.md +43 -0
  22. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/01-/345/233/276/347/211/207/346/224/276/345/244/247.md +40 -0
  23. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/02-/350/277/224/345/233/236/351/241/266/351/203/250.md +38 -0
  24. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/assets/img-1777261394722.png +0 -0
  25. 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 +37 -0
  26. package/public/docs/08-/346/226/207/346/241/243/347/256/241/347/220/206/00-/346/226/260/345/273/272/344/270/216/345/210/240/351/231/244.md +47 -0
  27. package/public/docs/09-/345/257/274/345/207/272/345/212/237/350/203/275/00-/345/257/274/345/207/272Word.md +77 -0
  28. package/public/docs/10-/351/203/250/347/275/262/344/270/216/351/205/215/347/275/256/00-CLI/345/267/245/345/205/267.md +52 -0
  29. package/public/docs/10-/351/203/250/347/275/262/344/270/216/351/205/215/347/275/256/01-SSG/351/235/231/346/200/201/346/236/204/345/273/272.md +44 -0
  30. package/public/docs/10-/351/203/250/347/275/262/344/270/216/351/205/215/347/275/256/02-/350/207/252/345/256/232/344/271/211/351/205/215/347/275/256.md +58 -0
  31. package/public/docs/11-/345/244/232/347/272/247/347/233/256/345/275/225/346/265/213/350/257/225/00-/344/270/200/347/272/247/346/226/207/346/241/243.md +20 -0
  32. package/public/docs/11-/345/244/232/347/272/247/347/233/256/345/275/225/346/265/213/350/257/225/01-/345/255/220/347/233/256/345/275/225/00-/344/272/214/347/272/247/346/226/207/346/241/243.md +13 -0
  33. package/public/docs/11-/345/244/232/347/272/247/347/233/256/345/275/225/346/265/213/350/257/225/01-/345/255/220/347/233/256/345/275/225/01-/346/267/261/345/261/202/345/265/214/345/245/227/00-/344/270/211/347/272/247/346/226/207/346/241/243.md +23 -0
  34. package/src/App.vue +130 -6
  35. package/src/components/AppSidebar.vue +181 -21
  36. package/src/components/CodeBlockNodeView.vue +72 -0
  37. package/src/components/DocContent.vue +25 -14
  38. package/src/components/EditorContent.vue +257 -0
  39. package/src/components/EditorToolbar.vue +264 -0
  40. package/src/components/ImageZoom.vue +199 -2
  41. package/src/components/MathBlockNodeView.vue +160 -0
  42. package/src/components/MathInlineNodeView.vue +145 -0
  43. package/src/components/MermaidNodeView.vue +149 -0
  44. package/src/components/TableBubbleMenu.vue +177 -0
  45. package/src/components/TableOfContents.vue +138 -32
  46. package/src/components/TopBar.vue +69 -4
  47. package/src/components/TreeNode.vue +232 -39
  48. package/src/components/WelcomePage.vue +2 -2
  49. package/src/composables/useDocHash.js +9 -1
  50. package/src/composables/useDocManager.js +325 -68
  51. package/src/composables/useDocTree.js +56 -1
  52. package/src/composables/useExportPdf.js +102 -0
  53. package/src/composables/useExportWord.js +73 -10
  54. package/src/composables/useFileWatcher.js +45 -0
  55. package/src/composables/useFrontmatter.js +2 -2
  56. package/src/composables/useMarkdown.js +529 -42
  57. package/src/composables/useScroll.js +47 -5
  58. package/src/config.js +1 -1
  59. package/src/extensions/CodeBlockCustom.js +113 -0
  60. package/src/extensions/MathBlock.js +107 -0
  61. package/src/extensions/MathInline.js +100 -0
  62. package/src/extensions/MermaidBlock.js +73 -0
  63. package/src/extensions/TableControls.js +670 -0
  64. package/src/services/DocService.js +184 -0
  65. package/src/style.css +2194 -39
  66. package/vite-plugin-doc-api.js +368 -0
  67. package/vite.config.js +2 -1
  68. package/public/docs/02-Mermaid/345/233/276/350/241/250.md +0 -102
  69. package/public/docs/03-/350/277/233/351/230/266/346/214/207/345/215/227/01-/347/233/256/345/275/225/347/273/223/346/236/204.md +0 -55
  70. package/public/docs/03-/350/277/233/351/230/266/346/214/207/345/215/227/02-/350/207/252/345/256/232/344/271/211/351/205/215/347/275/256.md +0 -63
  71. package/public/docs/03-/350/277/233/351/230/266/346/214/207/345/215/227/03-/351/203/250/347/275/262/346/226/271/346/241/210.md +0 -73
  72. package/public/docs/04-API/345/217/202/350/200/203/01-/347/273/204/344/273/266API.md +0 -80
  73. package/public/docs/04-API/345/217/202/350/200/203/02-Composables.md +0 -92
  74. package/src/api/docs.js +0 -106
@@ -3,6 +3,14 @@ import { marked } from 'marked'
3
3
  import mermaid from 'mermaid'
4
4
  import GithubSlugger from 'github-slugger'
5
5
  import hljs from 'highlight.js'
6
+ import katex from 'katex'
7
+ import 'katex/dist/katex.min.css'
8
+
9
+ // highlightjs-line-numbers.js 是 IIFE 插件,依赖全局 window.hljs
10
+ // 需先挂载 hljs 到 window,再动态加载插件
11
+ const lineNumbersReady = (typeof window !== 'undefined')
12
+ ? (window.hljs = hljs, import('highlightjs-line-numbers.js'))
13
+ : Promise.resolve()
6
14
  import { parseFrontmatter, calcReadingTime } from './useFrontmatter.js'
7
15
  import { docHash, resolveDocKey, findDocInTree } from './useDocHash.js'
8
16
 
@@ -68,11 +76,10 @@ function createRenderer(currentDocKey, docsList) {
68
76
  const renderer = new marked.Renderer()
69
77
  const slugger = new GithubSlugger()
70
78
 
71
- // 标题渲染:使用 github-slugger 生成语义化锚点 ID
79
+ // 标题渲染:使用 github-slugger 生成语义化锚点 ID,hover 显示 # 锚点链接
72
80
  renderer.heading = function(text, level) {
73
81
  const id = slugger.slug(text)
74
- const linkIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`
75
- return `<h${level} id="${id}"><a class="heading-anchor" href="#${id}" data-anchor="${id}" aria-hidden="true">${linkIcon}</a>${text}</h${level}>\n`
82
+ return `<h${level} id="${id}"><a class="heading-anchor" href="#${id}" data-anchor="${id}" aria-hidden="true">#</a>${text}</h${level}>\n`
76
83
  }
77
84
 
78
85
  // 代码块渲染:Mermaid 图表 / 语法高亮 + 复制按钮
@@ -89,33 +96,41 @@ function createRenderer(currentDocKey, docsList) {
89
96
  highlighted = hljs.highlightAuto(code).value
90
97
  }
91
98
 
92
- // 生成行号(按换行拆分)
93
- const lines = code.split('\n')
94
- const lineCount = lines.length
95
- const lineNums = lines.map((_, i) => `<span class="line-num">${i + 1}</span>`).join('')
96
-
97
99
  const langLabel = (language || '').toUpperCase()
98
100
  // 切换行号按钮(列表图标)
99
101
  const toggleLineNumIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`
102
+ // 切换自动换行按钮(换行图标)
103
+ const toggleWrapIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><polyline points="13 16 11 18 13 20"/><path d="M3 18h4"/></svg>`
100
104
  // 切换高亮按钮(</> 文本图标)
101
105
  const toggleHighlightIcon = `<span class="code-icon-text">&lt;/&gt;</span>`
102
106
  // 复制按钮图标
103
107
  const copyIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`
104
108
 
105
- return `<div class="code-block-wrapper" data-raw-code="${encodeURIComponent(code)}" data-lang="${language || ''}">
106
- <div class="code-block-header">
107
- <span class="code-lang-label">${langLabel}</span>
108
- <div class="code-block-actions">
109
- <button class="code-action-btn toggle-line-num-btn active" data-tooltip="隐藏行号">${toggleLineNumIcon}</button>
110
- <button class="code-action-btn toggle-highlight-btn active" data-tooltip="关闭高亮">${toggleHighlightIcon}</button>
111
- <button class="code-action-btn copy-code-btn" data-tooltip="复制代码">${copyIcon}<span class="copy-text">复制</span></button>
112
- </div>
113
- </div>
114
- <div class="code-block-body">
115
- <div class="line-numbers" aria-hidden="true">${lineNums}</div>
116
- <pre><code class="hljs${language ? ` language-${language}` : ''}">${highlighted}</code></pre>
117
- </div>
118
- </div>`
109
+ return `<div class="code-block-wrapper" data-raw-code="${encodeURIComponent(code)}" data-lang="${language || ''}">` +
110
+ `<div class="code-block-header">` +
111
+ `<span class="code-lang-label">${langLabel}</span>` +
112
+ `<div class="code-block-actions">` +
113
+ `<button class="code-action-btn toggle-line-num-btn active" data-tooltip="隐藏行号">${toggleLineNumIcon}</button>` +
114
+ `<button class="code-action-btn toggle-wrap-btn" data-tooltip="自动换行">${toggleWrapIcon}</button>` +
115
+ `<button class="code-action-btn toggle-highlight-btn active" data-tooltip="关闭高亮">${toggleHighlightIcon}</button>` +
116
+ `<button class="code-action-btn copy-code-btn" data-tooltip="复制代码">${copyIcon}<span class="copy-text">复制</span></button>` +
117
+ `</div>` +
118
+ `</div>` +
119
+ `<div class="code-block-body"><pre><code class="hljs${language ? ` language-${language}` : ''}">${highlighted}</code></pre></div>` +
120
+ `</div>`
121
+ }
122
+
123
+ // 图片渲染:将相对路径转换为 /@user-docs/ 绝对路径
124
+ renderer.image = function(href, title, text) {
125
+ if (href && !/^(https?:|data:|\/|@)/.test(href)) {
126
+ // 相对路径图片,基于当前文档目录解析
127
+ const docDir = currentDocKey.includes('/') ? currentDocKey.substring(0, currentDocKey.lastIndexOf('/')) : ''
128
+ const base = docDir ? `/@user-docs/${docDir}/` : '/@user-docs/'
129
+ href = base + href
130
+ }
131
+ const alt = text || ''
132
+ const titleAttr = title ? ` title="${title}"` : ''
133
+ return `<img src="${href}" alt="${alt}"${titleAttr} class="zoomable-image" />`
119
134
  }
120
135
 
121
136
  // 链接渲染:站内/站外分类处理
@@ -152,9 +167,185 @@ function createRenderer(currentDocKey, docsList) {
152
167
  return renderer
153
168
  }
154
169
 
170
+ // ===== KaTeX 数学公式扩展 =====
171
+ // 为 marked 添加行内公式 $...$ 和块级公式 $$...$$ 支持
172
+
173
+ const mathInline = {
174
+ name: 'mathInline',
175
+ level: 'inline',
176
+ start(src) { return src.indexOf('$') },
177
+ tokenizer(src) {
178
+ // 行内公式:$...$(不匹配 $$)
179
+ const match = src.match(/^\$(?!\$)((?:\\.|[^$\\])+)\$/)
180
+ if (match) {
181
+ return { type: 'mathInline', raw: match[0], text: match[1].trim() }
182
+ }
183
+ },
184
+ renderer(token) {
185
+ try {
186
+ return katex.renderToString(token.text, { throwOnError: false, displayMode: false })
187
+ } catch {
188
+ return `<code class="math-error">${token.text}</code>`
189
+ }
190
+ }
191
+ }
192
+
193
+ const mathBlock = {
194
+ name: 'mathBlock',
195
+ level: 'block',
196
+ start(src) {
197
+ // 查找行首的 $$(跳过行内代码中的 $$)
198
+ const match = src.match(/(?:^|\n)\$\$/)
199
+ return match ? match.index + (match[0].startsWith('\n') ? 1 : 0) : -1
200
+ },
201
+ tokenizer(src) {
202
+ // 块级公式:$$ 独占一行开始,$$ 独占一行结束
203
+ const match = src.match(/^\$\$\s*\n([\s\S]+?)\n\s*\$\$(?:\s*$|\n)/)
204
+ if (match) {
205
+ return { type: 'mathBlock', raw: match[0], text: match[1].trim() }
206
+ }
207
+ },
208
+ renderer(token) {
209
+ try {
210
+ return `<div class="math-block">${katex.renderToString(token.text, { throwOnError: false, displayMode: true })}</div>`
211
+ } catch {
212
+ return `<pre class="math-error">${token.text}</pre>`
213
+ }
214
+ }
215
+ }
216
+
155
217
 
156
218
  // ---- 后处理器 ----
157
219
 
220
+ // 复制按钮旁边显示提示气泡
221
+ const COPY_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'
222
+ const CHECK_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
223
+
224
+ function showCopyTip(btn, success) {
225
+ // 移除已有的 tip
226
+ const existing = btn.parentElement?.querySelector('.copy-tip')
227
+ if (existing) existing.remove()
228
+ const tip = document.createElement('span')
229
+ tip.className = 'copy-tip' + (success ? ' copy-tip-ok' : ' copy-tip-fail')
230
+ tip.textContent = success ? '已复制' : '失败'
231
+ // 插入到按钮后面
232
+ btn.parentElement.insertBefore(tip, btn.nextSibling)
233
+ btn.innerHTML = success ? CHECK_ICON : COPY_ICON
234
+ btn.classList.toggle('copied', success)
235
+ setTimeout(() => {
236
+ tip.remove()
237
+ btn.innerHTML = COPY_ICON
238
+ btn.classList.remove('copied')
239
+ }, 1500)
240
+ }
241
+
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
+ }
348
+
158
349
  // 渲染 Mermaid 图表
159
350
  async function renderMermaid() {
160
351
  await nextTick()
@@ -168,6 +359,31 @@ async function renderMermaid() {
168
359
  element.classList.add('zoomable-image')
169
360
  element.style.cursor = 'zoom-in'
170
361
  element.title = '点击放大查看'
362
+ // 添加复制按钮
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
+ }
171
387
  } catch (error) {
172
388
  console.error('Mermaid 渲染失败:', error)
173
389
  // 清理 Mermaid 渲染失败时残留在 DOM 中的错误容器
@@ -178,44 +394,251 @@ async function renderMermaid() {
178
394
  }
179
395
  }
180
396
 
181
- // 为表格添加滚动容器
397
+ // 为表格添加滚动容器和工具栏按钮
182
398
  function wrapTables() {
183
399
  nextTick(() => {
184
400
  const tables = document.querySelectorAll('.markdown-content table')
185
401
  tables.forEach(table => {
186
- if (!table.parentElement.classList.contains('table-wrapper')) {
187
- const wrapper = document.createElement('div')
188
- wrapper.className = 'table-wrapper'
189
- table.parentNode.insertBefore(wrapper, table)
190
- wrapper.appendChild(table)
402
+ // 已经处理过的跳过
403
+ if (table.closest('.table-outer')) return
404
+
405
+ // 构建结构:.table-outer > .table-toolbar + .table-wrapper > table
406
+ const outer = document.createElement('div')
407
+ outer.className = 'table-outer'
408
+
409
+ // 工具栏(在 wrapper 外部,不受 overflow 影响)
410
+ const toolbar = document.createElement('div')
411
+ toolbar.className = 'table-toolbar'
412
+
413
+ // SVG 图标定义
414
+ const icons = {
415
+ fixedWidth: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>',
416
+ scrollX: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/><line x1="5" y1="5" x2="5" y2="19"/></svg>',
417
+ fixedHeight: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg>',
418
+ autoHeight: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 8 12 3 17 8"/><polyline points="17 16 12 21 7 16"/><line x1="12" y1="3" x2="12" y2="21"/></svg>',
419
+ fullscreen: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>'
191
420
  }
421
+
422
+ // 按钮配置:[key, tooltip, icon, group]
423
+ // group: 'width' 互斥组, 'height' 互斥组, null 独立
424
+ const btnConfigs = [
425
+ ['fixedWidth', '固定宽度', icons.fixedWidth, 'width'],
426
+ ['scrollX', '横向滚动', icons.scrollX, 'width'],
427
+ ['fixedHeight', '固定高度', icons.fixedHeight, 'height'],
428
+ ['autoHeight', '适应高度', icons.autoHeight, 'height'],
429
+ ['fullscreen', '全屏查看', icons.fullscreen, null],
430
+ ]
431
+
432
+ // 当前状态:默认固定宽度 + 适应高度
433
+ const state = { width: 'fixedWidth', height: 'autoHeight' }
434
+
435
+ const buttons = {}
436
+
437
+ btnConfigs.forEach(([key, tooltip, icon, group]) => {
438
+ const btn = document.createElement('button')
439
+ btn.className = 'table-toolbar-btn'
440
+ btn.dataset.tooltip = tooltip
441
+ btn.innerHTML = icon
442
+
443
+ // 默认激活状态
444
+ if ((group === 'width' && state.width === key) || (group === 'height' && state.height === key)) {
445
+ btn.classList.add('active')
446
+ }
447
+
448
+ btn.addEventListener('click', () => {
449
+ if (key === 'fullscreen') {
450
+ openTableFullscreen(table)
451
+ return
452
+ }
453
+ // 互斥切换
454
+ if (group) {
455
+ state[group] = key
456
+ // 更新同组按钮状态
457
+ btnConfigs.filter(([, , , g]) => g === group).forEach(([k]) => {
458
+ buttons[k].classList.toggle('active', k === key)
459
+ })
460
+ }
461
+ applyTableState(outer, wrapper, table, state)
462
+ })
463
+
464
+ buttons[key] = btn
465
+ toolbar.appendChild(btn)
466
+ })
467
+
468
+ // 滚动容器
469
+ const wrapper = document.createElement('div')
470
+ wrapper.className = 'table-wrapper'
471
+
472
+ table.parentNode.insertBefore(outer, table)
473
+ outer.appendChild(toolbar)
474
+ outer.appendChild(wrapper)
475
+ wrapper.appendChild(table)
476
+
477
+ // 应用默认状态
478
+ applyTableState(outer, wrapper, table, state)
192
479
  })
193
480
  })
194
481
  }
195
482
 
483
+ // 根据状态应用表格样式
484
+ function applyTableState(outer, wrapper, table, state) {
485
+ // 宽度模式
486
+ table.classList.remove('table-fit', 'table-scroll')
487
+ wrapper.classList.remove('table-wrapper-scroll', 'table-wrapper-fixed')
488
+ if (state.width === 'fixedWidth') {
489
+ table.classList.add('table-fit')
490
+ wrapper.classList.add('table-wrapper-fixed')
491
+ } else {
492
+ table.classList.add('table-scroll')
493
+ wrapper.classList.add('table-wrapper-scroll')
494
+ }
495
+
496
+ // 高度模式
497
+ wrapper.classList.remove('table-wrapper-fixed-height', 'table-wrapper-auto-height')
498
+ if (state.height === 'fixedHeight') {
499
+ wrapper.classList.add('table-wrapper-fixed-height')
500
+ } else {
501
+ wrapper.classList.add('table-wrapper-auto-height')
502
+ }
503
+ }
504
+
505
+ // 打开表格全屏弹框
506
+ function openTableFullscreen(tableEl) {
507
+ // 创建遮罩
508
+ const overlay = document.createElement('div')
509
+ overlay.className = 'table-fullscreen-overlay'
510
+
511
+ // 弹框容器
512
+ const dialog = document.createElement('div')
513
+ dialog.className = 'table-fullscreen-dialog'
514
+
515
+ // 标题栏
516
+ const header = document.createElement('div')
517
+ header.className = 'table-fullscreen-header'
518
+
519
+ // 统计行列信息作为标题
520
+ const rowCount = tableEl.querySelectorAll('tr').length - 1
521
+ const firstRow = tableEl.querySelector('tr')
522
+ let colCount = 0
523
+ if (firstRow) {
524
+ for (const cell of firstRow.querySelectorAll('th, td')) {
525
+ colCount += parseInt(cell.getAttribute('colspan') || '1', 10)
526
+ }
527
+ }
528
+ const title = document.createElement('span')
529
+ title.className = 'table-fullscreen-title'
530
+ title.textContent = `表格预览(${rowCount} 行 × ${colCount} 列)`
531
+
532
+ const actions = document.createElement('div')
533
+ actions.className = 'table-fullscreen-actions'
534
+
535
+ // 全屏/还原按钮
536
+ const maximizeBtn = document.createElement('button')
537
+ maximizeBtn.className = 'table-fullscreen-action-btn'
538
+ maximizeBtn.title = '全屏'
539
+ const iconMaximize = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>'
540
+ const iconMinimize = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 14h6v6"/><path d="M20 10h-6V4"/><path d="M14 10l7-7"/><path d="M3 21l7-7"/></svg>'
541
+ maximizeBtn.innerHTML = iconMaximize
542
+ maximizeBtn.addEventListener('click', () => {
543
+ const isMax = dialog.classList.toggle('is-maximized')
544
+ maximizeBtn.innerHTML = isMax ? iconMinimize : iconMaximize
545
+ maximizeBtn.title = isMax ? '还原' : '全屏'
546
+ overlay.style.padding = isMax ? '0' : '24px'
547
+ })
548
+
549
+ // 关闭按钮
550
+ const closeBtn = document.createElement('button')
551
+ closeBtn.className = 'table-fullscreen-action-btn'
552
+ closeBtn.title = '关闭'
553
+ closeBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>'
554
+
555
+ actions.appendChild(maximizeBtn)
556
+ actions.appendChild(closeBtn)
557
+ header.appendChild(title)
558
+ header.appendChild(actions)
559
+
560
+ // 内容区
561
+ const body = document.createElement('div')
562
+ body.className = 'table-fullscreen-body'
563
+ body.appendChild(tableEl.cloneNode(true))
564
+
565
+ dialog.appendChild(header)
566
+ dialog.appendChild(body)
567
+ overlay.appendChild(dialog)
568
+ document.body.appendChild(overlay)
569
+
570
+ // 关闭逻辑
571
+ const close = () => overlay.remove()
572
+ closeBtn.addEventListener('click', close)
573
+ overlay.addEventListener('click', (e) => {
574
+ if (e.target === overlay) close()
575
+ })
576
+ const onKey = (e) => {
577
+ if (e.key === 'Escape') {
578
+ close()
579
+ document.removeEventListener('keydown', onKey)
580
+ }
581
+ }
582
+ document.addEventListener('keydown', onKey)
583
+ }
584
+
196
585
  // 为图片添加放大功能
197
586
  function addImageZoomHandlers() {
198
587
  nextTick(() => {
199
588
  const images = document.querySelectorAll('.markdown-content img')
200
589
  images.forEach(img => {
590
+ // 避免重复包裹
591
+ if (img.parentElement?.classList.contains('image-container')) return
201
592
  img.classList.add('zoomable-image')
202
593
  img.style.cursor = 'zoom-in'
203
594
  img.title = '点击放大查看'
595
+ // 包裹容器,添加复制按钮
596
+ const wrapper = document.createElement('span')
597
+ wrapper.className = 'image-container'
598
+ img.parentNode.insertBefore(wrapper, img)
599
+ wrapper.appendChild(img)
600
+ const copyBtn = document.createElement('button')
601
+ copyBtn.className = 'image-copy-btn'
602
+ copyBtn.title = '复制图片'
603
+ copyBtn.innerHTML = COPY_ICON
604
+ copyBtn.addEventListener('click', async (e) => {
605
+ e.stopPropagation()
606
+ e.preventDefault()
607
+ try {
608
+ // 多策略获取 PNG blob,兼容同源和跨域图片
609
+ const blobPromise = imgToPngBlob(img.src)
610
+ const item = new ClipboardItem({ 'image/png': blobPromise })
611
+ await navigator.clipboard.write([item])
612
+ showCopyTip(copyBtn, true)
613
+ } catch (err) {
614
+ console.warn('复制图片失败:', err)
615
+ showCopyTip(copyBtn, false)
616
+ }
617
+ })
618
+ wrapper.appendChild(copyBtn)
204
619
  })
205
620
  })
206
621
  }
207
622
 
208
623
  // 为代码块添加交互事件(复制、切换行号、切换高亮)
209
624
  function addCodeBlockHandlers() {
210
- nextTick(() => {
625
+ nextTick(async () => {
626
+ // 等待插件加载完成
627
+ await lineNumbersReady
628
+
629
+ // 使用插件注入行号(table 布局,天然对齐)
630
+ document.querySelectorAll('.code-block-body code.hljs').forEach(block => {
631
+ hljs.lineNumbersBlock(block)
632
+ })
633
+
211
634
  // 复制按钮
212
635
  document.querySelectorAll('.copy-code-btn').forEach(btn => {
213
636
  btn.addEventListener('click', async () => {
214
637
  const wrapper = btn.closest('.code-block-wrapper')
215
- const code = wrapper.querySelector('code')
216
- if (!code) return
638
+ const rawCode = decodeURIComponent(wrapper.dataset.rawCode || '')
639
+ if (!rawCode) return
217
640
  try {
218
- await navigator.clipboard.writeText(code.textContent)
641
+ await navigator.clipboard.writeText(rawCode)
219
642
  const textEl = btn.querySelector('.copy-text')
220
643
  textEl.textContent = '已复制'
221
644
  btn.classList.add('copied')
@@ -224,26 +647,44 @@ function addCodeBlockHandlers() {
224
647
  btn.classList.remove('copied')
225
648
  }, 2000)
226
649
  } catch {
227
- const range = document.createRange()
228
- range.selectNodeContents(code)
229
- window.getSelection().removeAllRanges()
230
- window.getSelection().addRange(range)
650
+ const code = wrapper.querySelector('code')
651
+ if (code) {
652
+ const range = document.createRange()
653
+ range.selectNodeContents(code)
654
+ window.getSelection().removeAllRanges()
655
+ window.getSelection().addRange(range)
656
+ }
231
657
  }
232
658
  })
233
659
  })
234
660
 
235
- // 切换行号
661
+ // 切换行号(控制插件生成的 .hljs-ln 表格中行号列的显隐)
236
662
  document.querySelectorAll('.toggle-line-num-btn').forEach(btn => {
237
663
  btn.addEventListener('click', () => {
238
664
  const wrapper = btn.closest('.code-block-wrapper')
239
- const lineNumbers = wrapper.querySelector('.line-numbers')
240
- if (!lineNumbers) return
665
+ const table = wrapper.querySelector('.hljs-ln')
666
+ if (!table) return
241
667
  btn.classList.toggle('active')
242
- lineNumbers.classList.toggle('hidden')
668
+ // 切换所有行号单元格的显隐
669
+ table.querySelectorAll('.hljs-ln-numbers').forEach(td => {
670
+ td.style.display = btn.classList.contains('active') ? '' : 'none'
671
+ })
243
672
  btn.dataset.tooltip = btn.classList.contains('active') ? '隐藏行号' : '显示行号'
244
673
  })
245
674
  })
246
675
 
676
+ // 切换自动换行
677
+ document.querySelectorAll('.toggle-wrap-btn').forEach(btn => {
678
+ btn.addEventListener('click', () => {
679
+ const wrapper = btn.closest('.code-block-wrapper')
680
+ const codeBody = wrapper.querySelector('.code-block-body')
681
+ if (!codeBody) return
682
+ btn.classList.toggle('active')
683
+ codeBody.classList.toggle('word-wrap-enabled')
684
+ btn.dataset.tooltip = btn.classList.contains('active') ? '取消换行' : '自动换行'
685
+ })
686
+ })
687
+
247
688
  // 切换语法高亮
248
689
  document.querySelectorAll('.toggle-highlight-btn').forEach(btn => {
249
690
  btn.addEventListener('click', () => {
@@ -255,7 +696,7 @@ function addCodeBlockHandlers() {
255
696
  const isHighlighted = btn.classList.contains('active')
256
697
 
257
698
  if (isHighlighted) {
258
- // 关闭高亮:显示纯文本
699
+ // 关闭高亮:显示纯文本,移除插件表格
259
700
  codeEl.textContent = rawCode
260
701
  codeEl.className = 'code-plain'
261
702
  btn.classList.remove('active')
@@ -270,6 +711,8 @@ function addCodeBlockHandlers() {
270
711
  }
271
712
  codeEl.innerHTML = highlighted
272
713
  codeEl.className = `hljs${lang ? ` language-${lang}` : ''}`
714
+ // 重新注入行号
715
+ hljs.lineNumbersBlock(codeEl)
273
716
  btn.classList.add('active')
274
717
  btn.dataset.tooltip = '关闭高亮'
275
718
  }
@@ -296,6 +739,30 @@ function extractTOC(tocItems) {
296
739
  })
297
740
  }
298
741
 
742
+ // 从 Markdown 文本中解析 TOC(编辑模式使用,不依赖 DOM)
743
+ function extractTOCFromMarkdown(markdown, tocItems) {
744
+ const slugger = new GithubSlugger()
745
+ const items = []
746
+ const lines = markdown.split('\n')
747
+ let inCodeBlock = false
748
+ for (const line of lines) {
749
+ // 跳过代码块内的内容
750
+ if (/^```/.test(line.trim())) {
751
+ inCodeBlock = !inCodeBlock
752
+ continue
753
+ }
754
+ if (inCodeBlock) continue
755
+ const match = line.match(/^(#{1,6})\s+(.+)$/)
756
+ if (match) {
757
+ const level = match[1].length
758
+ const text = match[2].trim()
759
+ const id = slugger.slug(text)
760
+ items.push({ id, text, level })
761
+ }
762
+ }
763
+ tocItems.value = items
764
+ }
765
+
299
766
  // ---- 主 composable ----
300
767
 
301
768
  export function useMarkdown() {
@@ -304,8 +771,9 @@ export function useMarkdown() {
304
771
 
305
772
  // 渲染 Markdown,传入当前文档 key 和文档列表用于链接改写
306
773
  async function renderMarkdown(markdown, currentDocKey = '', docsList = []) {
307
- const { data: frontmatter, content } = parseFrontmatter(markdown)
774
+ const { data: frontmatter, content, rawYaml } = parseFrontmatter(markdown)
308
775
  const renderer = createRenderer(currentDocKey, docsList)
776
+ marked.use({ extensions: [mathBlock, mathInline] })
309
777
  marked.setOptions({
310
778
  renderer,
311
779
  breaks: true,
@@ -332,6 +800,24 @@ export function useMarkdown() {
332
800
  htmlContent.value = htmlContent.value.replace(/(<\/h1>)/, `$1\n${metaHtml}`)
333
801
  }
334
802
 
803
+ // 在文档顶部插入 frontmatter 代码块
804
+ if (rawYaml) {
805
+ const highlighted = hljs.highlight(rawYaml, { language: 'yaml' }).value
806
+ const encodedRaw = encodeURIComponent(rawYaml)
807
+ const copyIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`
808
+ const frontmatterBlock =
809
+ `<div class="code-block-wrapper frontmatter-block" data-raw-code="${encodedRaw}" data-lang="yaml">` +
810
+ `<div class="code-block-header">` +
811
+ `<span class="code-lang-label">FRONTMATTER</span>` +
812
+ `<div class="code-block-actions">` +
813
+ `<button class="code-action-btn copy-code-btn" data-tooltip="复制代码">${copyIcon}<span class="copy-text">复制</span></button>` +
814
+ `</div>` +
815
+ `</div>` +
816
+ `<div class="code-block-body"><pre><code class="hljs language-yaml">${highlighted}</code></pre></div>` +
817
+ `</div>`
818
+ htmlContent.value = frontmatterBlock + htmlContent.value
819
+ }
820
+
335
821
  // 后处理
336
822
  await renderMermaid()
337
823
  wrapTables()
@@ -344,6 +830,7 @@ export function useMarkdown() {
344
830
  htmlContent,
345
831
  tocItems,
346
832
  renderMarkdown,
833
+ extractTOCFromMarkdown,
347
834
  addImageZoomHandlers,
348
835
  docHash
349
836
  }