md2ui 1.0.18 → 1.0.20

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 (80) hide show
  1. package/README.md +51 -58
  2. package/bin/build.js +95 -9
  3. package/bin/md2ui.js +102 -13
  4. package/package.json +24 -10
  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 +88 -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/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
  14. package/public/docs/02-Markdown/346/270/262/346/237/223/assets/img-1777383093712.png +0 -0
  15. 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
  16. 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
  17. 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
  18. 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
  19. 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
  20. 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
  21. 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
  22. 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
  23. 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
  24. 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
  25. 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
  26. 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
  27. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/assets/img-1777261394722.png +0 -0
  28. 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
  29. 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
  30. 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
  31. 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
  32. 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
  33. 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
  34. 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
  35. 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
  36. 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
  37. package/src/App.vue +111 -12
  38. package/src/components/AppSidebar.vue +181 -21
  39. package/src/components/CodeBlockNodeView.vue +72 -0
  40. package/src/components/DocContent.vue +25 -14
  41. package/src/components/EditorContent.vue +257 -0
  42. package/src/components/EditorToolbar.vue +264 -0
  43. package/src/components/ImageZoom.vue +88 -5
  44. package/src/components/MathBlockNodeView.vue +160 -0
  45. package/src/components/MathInlineNodeView.vue +145 -0
  46. package/src/components/MermaidNodeView.vue +157 -0
  47. package/src/components/MobileSearch.vue +97 -0
  48. package/src/components/TableOfContents.vue +174 -32
  49. package/src/components/TopBar.vue +69 -4
  50. package/src/components/TreeNode.vue +232 -39
  51. package/src/components/WelcomePage.vue +2 -2
  52. package/src/composables/useDocHash.js +9 -1
  53. package/src/composables/useDocManager.js +452 -105
  54. package/src/composables/useDocTree.js +33 -2
  55. package/src/composables/useExportWord.js +73 -10
  56. package/src/composables/useFileWatcher.js +45 -0
  57. package/src/composables/useFrontmatter.js +2 -2
  58. package/src/composables/useMarkdown.js +450 -52
  59. package/src/composables/useMermaidCache.js +15 -0
  60. package/src/composables/useScroll.js +354 -27
  61. package/src/composables/useSearch.js +12 -11
  62. package/src/config.js +1 -4
  63. package/src/extensions/CodeBlockCustom.js +113 -0
  64. package/src/extensions/MathBlock.js +107 -0
  65. package/src/extensions/MathInline.js +100 -0
  66. package/src/extensions/MermaidBlock.js +73 -0
  67. package/src/extensions/TableControls.js +670 -0
  68. package/src/services/DocService.js +168 -0
  69. package/src/style.css +2416 -36
  70. package/src/utils/imageConverter.js +129 -0
  71. package/vite-plugin-doc-api.js +369 -0
  72. package/vite.config.js +7 -2
  73. package/public/docs/02-Mermaid/345/233/276/350/241/250.md +0 -102
  74. 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
  75. 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
  76. 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
  77. package/public/docs/04-API/345/217/202/350/200/203/01-/347/273/204/344/273/266API.md +0 -80
  78. package/public/docs/04-API/345/217/202/350/200/203/02-Composables.md +0 -92
  79. package/src/api/docs.js +0 -106
  80. package/src/components/SearchPanel.vue +0 -90
@@ -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,18 +76,18 @@ 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 图表 / 语法高亮 + 复制按钮
79
86
  renderer.code = function(code, language) {
80
87
  if (language === 'mermaid') {
81
88
  const id = 'mermaid-' + Math.random().toString(36).substr(2, 9)
82
- 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>`
83
91
  }
84
92
  // 高亮代码
85
93
  let highlighted
@@ -89,33 +97,41 @@ function createRenderer(currentDocKey, docsList) {
89
97
  highlighted = hljs.highlightAuto(code).value
90
98
  }
91
99
 
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
100
  const langLabel = (language || '').toUpperCase()
98
101
  // 切换行号按钮(列表图标)
99
102
  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>`
103
+ // 切换自动换行按钮(换行图标)
104
+ 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
105
  // 切换高亮按钮(</> 文本图标)
101
106
  const toggleHighlightIcon = `<span class="code-icon-text">&lt;/&gt;</span>`
102
107
  // 复制按钮图标
103
108
  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
109
 
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>`
110
+ return `<div class="code-block-wrapper" data-raw-code="${encodeURIComponent(code)}" data-lang="${language || ''}">` +
111
+ `<div class="code-block-header">` +
112
+ `<span class="code-lang-label">${langLabel}</span>` +
113
+ `<div class="code-block-actions">` +
114
+ `<button class="code-action-btn toggle-line-num-btn active" data-tooltip="隐藏行号">${toggleLineNumIcon}</button>` +
115
+ `<button class="code-action-btn toggle-wrap-btn" data-tooltip="自动换行">${toggleWrapIcon}</button>` +
116
+ `<button class="code-action-btn toggle-highlight-btn active" data-tooltip="关闭高亮">${toggleHighlightIcon}</button>` +
117
+ `<button class="code-action-btn copy-code-btn" data-tooltip="复制代码">${copyIcon}<span class="copy-text">复制</span></button>` +
118
+ `</div>` +
119
+ `</div>` +
120
+ `<div class="code-block-body"><pre><code class="hljs${language ? ` language-${language}` : ''}">${highlighted}</code></pre></div>` +
121
+ `</div>`
122
+ }
123
+
124
+ // 图片渲染:将相对路径转换为 /@user-docs/ 绝对路径
125
+ renderer.image = function(href, title, text) {
126
+ if (href && !/^(https?:|data:|\/|@)/.test(href)) {
127
+ // 相对路径图片,基于当前文档目录解析
128
+ const docDir = currentDocKey.includes('/') ? currentDocKey.substring(0, currentDocKey.lastIndexOf('/')) : ''
129
+ const base = docDir ? `/@user-docs/${docDir}/` : '/@user-docs/'
130
+ href = base + href
131
+ }
132
+ const alt = text || ''
133
+ const titleAttr = title ? ` title="${title}"` : ''
134
+ return `<img src="${href}" alt="${alt}"${titleAttr} class="zoomable-image" />`
119
135
  }
120
136
 
121
137
  // 链接渲染:站内/站外分类处理
@@ -152,70 +168,388 @@ function createRenderer(currentDocKey, docsList) {
152
168
  return renderer
153
169
  }
154
170
 
171
+ // ===== KaTeX 数学公式扩展 =====
172
+ // 为 marked 添加行内公式 $...$ 和块级公式 $$...$$ 支持
173
+
174
+ const mathInline = {
175
+ name: 'mathInline',
176
+ level: 'inline',
177
+ start(src) { return src.indexOf('$') },
178
+ tokenizer(src) {
179
+ // 行内公式:$...$(不匹配 $$)
180
+ const match = src.match(/^\$(?!\$)((?:\\.|[^$\\])+)\$/)
181
+ if (match) {
182
+ return { type: 'mathInline', raw: match[0], text: match[1].trim() }
183
+ }
184
+ },
185
+ renderer(token) {
186
+ try {
187
+ return katex.renderToString(token.text, { throwOnError: false, displayMode: false })
188
+ } catch {
189
+ return `<code class="math-error">${token.text}</code>`
190
+ }
191
+ }
192
+ }
193
+
194
+ const mathBlock = {
195
+ name: 'mathBlock',
196
+ level: 'block',
197
+ start(src) {
198
+ // 查找行首的 $$(跳过行内代码中的 $$)
199
+ const match = src.match(/(?:^|\n)\$\$/)
200
+ return match ? match.index + (match[0].startsWith('\n') ? 1 : 0) : -1
201
+ },
202
+ tokenizer(src) {
203
+ // 块级公式:$$ 独占一行开始,$$ 独占一行结束
204
+ const match = src.match(/^\$\$\s*\n([\s\S]+?)\n\s*\$\$(?:\s*$|\n)/)
205
+ if (match) {
206
+ return { type: 'mathBlock', raw: match[0], text: match[1].trim() }
207
+ }
208
+ },
209
+ renderer(token) {
210
+ try {
211
+ return `<div class="math-block">${katex.renderToString(token.text, { throwOnError: false, displayMode: true })}</div>`
212
+ } catch {
213
+ return `<pre class="math-error">${token.text}</pre>`
214
+ }
215
+ }
216
+ }
217
+
155
218
 
156
219
  // ---- 后处理器 ----
157
220
 
158
- // 渲染 Mermaid 图表
221
+ // 复制按钮旁边显示提示气泡
222
+ 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>'
223
+ 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>'
224
+
225
+ function showCopyTip(btn, success) {
226
+ // 移除已有的 tip
227
+ const existing = btn.parentElement?.querySelector('.copy-tip')
228
+ if (existing) existing.remove()
229
+ const tip = document.createElement('span')
230
+ tip.className = 'copy-tip' + (success ? ' copy-tip-ok' : ' copy-tip-fail')
231
+ tip.textContent = success ? '已复制' : '失败'
232
+ // 插入到按钮后面
233
+ btn.parentElement.insertBefore(tip, btn.nextSibling)
234
+ btn.innerHTML = success ? CHECK_ICON : COPY_ICON
235
+ btn.classList.toggle('copied', success)
236
+ setTimeout(() => {
237
+ tip.remove()
238
+ btn.innerHTML = COPY_ICON
239
+ btn.classList.remove('copied')
240
+ }, 1500)
241
+ }
242
+
243
+ // 图片格式转换工具(从共享模块导入)
244
+ import { imgToPngBlob, svgToPngBlob } from '../utils/imageConverter.js'
245
+ import { getMermaidCache, setMermaidCache } from './useMermaidCache.js'
246
+
247
+ // 渲染 Mermaid 图表(并行渲染所有图表,等全部完成后返回)
159
248
  async function renderMermaid() {
160
249
  await nextTick()
161
- const mermaidElements = document.querySelectorAll('.mermaid')
162
- 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
163
258
  try {
164
- const id = element.id
165
- const code = element.textContent
166
- 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
+ }
167
266
  element.innerHTML = svg
267
+ element.classList.remove('mermaid-pending')
168
268
  element.classList.add('zoomable-image')
169
269
  element.style.cursor = 'zoom-in'
170
270
  element.title = '点击放大查看'
271
+ // 添加复制按钮
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)
171
293
  } catch (error) {
172
294
  console.error('Mermaid 渲染失败:', error)
173
- // 清理 Mermaid 渲染失败时残留在 DOM 中的错误容器
174
- const errorEl = document.getElementById(element.id + '-svg')
295
+ const errorEl = document.getElementById(id + '-svg')
175
296
  if (errorEl) errorEl.remove()
297
+ element.classList.remove('mermaid-pending')
176
298
  element.innerHTML = `<pre class="mermaid-error">图表渲染失败\n${error.message}</pre>`
177
299
  }
178
- }
300
+ })
301
+
302
+ await Promise.all(tasks)
179
303
  }
180
304
 
181
- // 为表格添加滚动容器
305
+ // 为表格添加滚动容器和工具栏按钮
182
306
  function wrapTables() {
183
307
  nextTick(() => {
184
308
  const tables = document.querySelectorAll('.markdown-content table')
185
309
  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)
310
+ // 已经处理过的跳过
311
+ if (table.closest('.table-outer')) return
312
+
313
+ // 代码块内的行号表格跳过
314
+ if (table.closest('.code-block-wrapper') || table.closest('pre')) return
315
+
316
+ // 构建结构:.table-outer > .table-toolbar + .table-wrapper > table
317
+ const outer = document.createElement('div')
318
+ outer.className = 'table-outer'
319
+
320
+ // 工具栏(在 wrapper 外部,不受 overflow 影响)
321
+ const toolbar = document.createElement('div')
322
+ toolbar.className = 'table-toolbar'
323
+
324
+ // SVG 图标定义
325
+ const icons = {
326
+ 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>',
327
+ 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>',
328
+ 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>',
329
+ 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>',
330
+ 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
331
  }
332
+
333
+ // 按钮配置:[key, tooltip, icon, group]
334
+ // group: 'width' 互斥组, 'height' 互斥组, null 独立
335
+ const btnConfigs = [
336
+ ['fixedWidth', '固定宽度', icons.fixedWidth, 'width'],
337
+ ['scrollX', '横向滚动', icons.scrollX, 'width'],
338
+ ['fixedHeight', '固定高度', icons.fixedHeight, 'height'],
339
+ ['autoHeight', '适应高度', icons.autoHeight, 'height'],
340
+ ['fullscreen', '全屏查看', icons.fullscreen, null],
341
+ ]
342
+
343
+ // 当前状态:默认固定宽度 + 适应高度
344
+ const state = { width: 'fixedWidth', height: 'autoHeight' }
345
+
346
+ const buttons = {}
347
+
348
+ btnConfigs.forEach(([key, tooltip, icon, group]) => {
349
+ const btn = document.createElement('button')
350
+ btn.className = 'table-toolbar-btn'
351
+ btn.dataset.tooltip = tooltip
352
+ btn.innerHTML = icon
353
+
354
+ // 默认激活状态
355
+ if ((group === 'width' && state.width === key) || (group === 'height' && state.height === key)) {
356
+ btn.classList.add('active')
357
+ }
358
+
359
+ btn.addEventListener('click', () => {
360
+ if (key === 'fullscreen') {
361
+ openTableFullscreen(table)
362
+ return
363
+ }
364
+ // 互斥切换
365
+ if (group) {
366
+ state[group] = key
367
+ // 更新同组按钮状态
368
+ btnConfigs.filter(([, , , g]) => g === group).forEach(([k]) => {
369
+ buttons[k].classList.toggle('active', k === key)
370
+ })
371
+ }
372
+ applyTableState(outer, wrapper, table, state)
373
+ })
374
+
375
+ buttons[key] = btn
376
+ toolbar.appendChild(btn)
377
+ })
378
+
379
+ // 滚动容器
380
+ const wrapper = document.createElement('div')
381
+ wrapper.className = 'table-wrapper'
382
+
383
+ table.parentNode.insertBefore(outer, table)
384
+ outer.appendChild(toolbar)
385
+ outer.appendChild(wrapper)
386
+ wrapper.appendChild(table)
387
+
388
+ // 应用默认状态
389
+ applyTableState(outer, wrapper, table, state)
192
390
  })
193
391
  })
194
392
  }
195
393
 
394
+ // 根据状态应用表格样式
395
+ function applyTableState(outer, wrapper, table, state) {
396
+ // 宽度模式
397
+ table.classList.remove('table-fit', 'table-scroll')
398
+ wrapper.classList.remove('table-wrapper-scroll', 'table-wrapper-fixed')
399
+ if (state.width === 'fixedWidth') {
400
+ table.classList.add('table-fit')
401
+ wrapper.classList.add('table-wrapper-fixed')
402
+ } else {
403
+ table.classList.add('table-scroll')
404
+ wrapper.classList.add('table-wrapper-scroll')
405
+ }
406
+
407
+ // 高度模式
408
+ wrapper.classList.remove('table-wrapper-fixed-height', 'table-wrapper-auto-height')
409
+ if (state.height === 'fixedHeight') {
410
+ wrapper.classList.add('table-wrapper-fixed-height')
411
+ } else {
412
+ wrapper.classList.add('table-wrapper-auto-height')
413
+ }
414
+ }
415
+
416
+ // 打开表格全屏弹框
417
+ function openTableFullscreen(tableEl) {
418
+ // 创建遮罩
419
+ const overlay = document.createElement('div')
420
+ overlay.className = 'table-fullscreen-overlay'
421
+
422
+ // 弹框容器
423
+ const dialog = document.createElement('div')
424
+ dialog.className = 'table-fullscreen-dialog'
425
+
426
+ // 标题栏
427
+ const header = document.createElement('div')
428
+ header.className = 'table-fullscreen-header'
429
+
430
+ // 统计行列信息作为标题
431
+ const rowCount = tableEl.querySelectorAll('tr').length - 1
432
+ const firstRow = tableEl.querySelector('tr')
433
+ let colCount = 0
434
+ if (firstRow) {
435
+ for (const cell of firstRow.querySelectorAll('th, td')) {
436
+ colCount += parseInt(cell.getAttribute('colspan') || '1', 10)
437
+ }
438
+ }
439
+ const title = document.createElement('span')
440
+ title.className = 'table-fullscreen-title'
441
+ title.textContent = `表格预览(${rowCount} 行 × ${colCount} 列)`
442
+
443
+ const actions = document.createElement('div')
444
+ actions.className = 'table-fullscreen-actions'
445
+
446
+ // 全屏/还原按钮
447
+ const maximizeBtn = document.createElement('button')
448
+ maximizeBtn.className = 'table-fullscreen-action-btn'
449
+ maximizeBtn.title = '全屏'
450
+ 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>'
451
+ 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>'
452
+ maximizeBtn.innerHTML = iconMaximize
453
+ maximizeBtn.addEventListener('click', () => {
454
+ const isMax = dialog.classList.toggle('is-maximized')
455
+ maximizeBtn.innerHTML = isMax ? iconMinimize : iconMaximize
456
+ maximizeBtn.title = isMax ? '还原' : '全屏'
457
+ overlay.style.padding = isMax ? '0' : '24px'
458
+ })
459
+
460
+ // 关闭按钮
461
+ const closeBtn = document.createElement('button')
462
+ closeBtn.className = 'table-fullscreen-action-btn'
463
+ closeBtn.title = '关闭'
464
+ 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>'
465
+
466
+ actions.appendChild(maximizeBtn)
467
+ actions.appendChild(closeBtn)
468
+ header.appendChild(title)
469
+ header.appendChild(actions)
470
+
471
+ // 内容区
472
+ const body = document.createElement('div')
473
+ body.className = 'table-fullscreen-body'
474
+ body.appendChild(tableEl.cloneNode(true))
475
+
476
+ dialog.appendChild(header)
477
+ dialog.appendChild(body)
478
+ overlay.appendChild(dialog)
479
+ document.body.appendChild(overlay)
480
+
481
+ // 关闭逻辑
482
+ const close = () => overlay.remove()
483
+ closeBtn.addEventListener('click', close)
484
+ overlay.addEventListener('click', (e) => {
485
+ if (e.target === overlay) close()
486
+ })
487
+ const onKey = (e) => {
488
+ if (e.key === 'Escape') {
489
+ close()
490
+ document.removeEventListener('keydown', onKey)
491
+ }
492
+ }
493
+ document.addEventListener('keydown', onKey)
494
+ }
495
+
196
496
  // 为图片添加放大功能
197
497
  function addImageZoomHandlers() {
198
498
  nextTick(() => {
199
499
  const images = document.querySelectorAll('.markdown-content img')
200
500
  images.forEach(img => {
501
+ // 避免重复包裹
502
+ if (img.parentElement?.classList.contains('image-container')) return
201
503
  img.classList.add('zoomable-image')
202
504
  img.style.cursor = 'zoom-in'
203
505
  img.title = '点击放大查看'
506
+ // 包裹容器,添加复制按钮
507
+ const wrapper = document.createElement('span')
508
+ wrapper.className = 'image-container'
509
+ img.parentNode.insertBefore(wrapper, img)
510
+ wrapper.appendChild(img)
511
+ const copyBtn = document.createElement('button')
512
+ copyBtn.className = 'image-copy-btn'
513
+ copyBtn.title = '复制图片'
514
+ copyBtn.innerHTML = COPY_ICON
515
+ copyBtn.addEventListener('click', async (e) => {
516
+ e.stopPropagation()
517
+ e.preventDefault()
518
+ try {
519
+ // 多策略获取 PNG blob,兼容同源和跨域图片
520
+ const blobPromise = imgToPngBlob(img.src)
521
+ const item = new ClipboardItem({ 'image/png': blobPromise })
522
+ await navigator.clipboard.write([item])
523
+ showCopyTip(copyBtn, true)
524
+ } catch (err) {
525
+ console.warn('复制图片失败:', err)
526
+ showCopyTip(copyBtn, false)
527
+ }
528
+ })
529
+ wrapper.appendChild(copyBtn)
204
530
  })
205
531
  })
206
532
  }
207
533
 
208
534
  // 为代码块添加交互事件(复制、切换行号、切换高亮)
209
535
  function addCodeBlockHandlers() {
210
- nextTick(() => {
536
+ nextTick(async () => {
537
+ // 等待插件加载完成
538
+ await lineNumbersReady
539
+
540
+ // 使用插件注入行号(table 布局,天然对齐)
541
+ document.querySelectorAll('.code-block-body code.hljs').forEach(block => {
542
+ hljs.lineNumbersBlock(block)
543
+ })
544
+
211
545
  // 复制按钮
212
546
  document.querySelectorAll('.copy-code-btn').forEach(btn => {
213
547
  btn.addEventListener('click', async () => {
214
548
  const wrapper = btn.closest('.code-block-wrapper')
215
- const code = wrapper.querySelector('code')
216
- if (!code) return
549
+ const rawCode = decodeURIComponent(wrapper.dataset.rawCode || '')
550
+ if (!rawCode) return
217
551
  try {
218
- await navigator.clipboard.writeText(code.textContent)
552
+ await navigator.clipboard.writeText(rawCode)
219
553
  const textEl = btn.querySelector('.copy-text')
220
554
  textEl.textContent = '已复制'
221
555
  btn.classList.add('copied')
@@ -224,26 +558,44 @@ function addCodeBlockHandlers() {
224
558
  btn.classList.remove('copied')
225
559
  }, 2000)
226
560
  } catch {
227
- const range = document.createRange()
228
- range.selectNodeContents(code)
229
- window.getSelection().removeAllRanges()
230
- window.getSelection().addRange(range)
561
+ const code = wrapper.querySelector('code')
562
+ if (code) {
563
+ const range = document.createRange()
564
+ range.selectNodeContents(code)
565
+ window.getSelection().removeAllRanges()
566
+ window.getSelection().addRange(range)
567
+ }
231
568
  }
232
569
  })
233
570
  })
234
571
 
235
- // 切换行号
572
+ // 切换行号(控制插件生成的 .hljs-ln 表格中行号列的显隐)
236
573
  document.querySelectorAll('.toggle-line-num-btn').forEach(btn => {
237
574
  btn.addEventListener('click', () => {
238
575
  const wrapper = btn.closest('.code-block-wrapper')
239
- const lineNumbers = wrapper.querySelector('.line-numbers')
240
- if (!lineNumbers) return
576
+ const table = wrapper.querySelector('.hljs-ln')
577
+ if (!table) return
241
578
  btn.classList.toggle('active')
242
- lineNumbers.classList.toggle('hidden')
579
+ // 切换所有行号单元格的显隐
580
+ table.querySelectorAll('.hljs-ln-numbers').forEach(td => {
581
+ td.style.display = btn.classList.contains('active') ? '' : 'none'
582
+ })
243
583
  btn.dataset.tooltip = btn.classList.contains('active') ? '隐藏行号' : '显示行号'
244
584
  })
245
585
  })
246
586
 
587
+ // 切换自动换行
588
+ document.querySelectorAll('.toggle-wrap-btn').forEach(btn => {
589
+ btn.addEventListener('click', () => {
590
+ const wrapper = btn.closest('.code-block-wrapper')
591
+ const codeBody = wrapper.querySelector('.code-block-body')
592
+ if (!codeBody) return
593
+ btn.classList.toggle('active')
594
+ codeBody.classList.toggle('word-wrap-enabled')
595
+ btn.dataset.tooltip = btn.classList.contains('active') ? '取消换行' : '自动换行'
596
+ })
597
+ })
598
+
247
599
  // 切换语法高亮
248
600
  document.querySelectorAll('.toggle-highlight-btn').forEach(btn => {
249
601
  btn.addEventListener('click', () => {
@@ -255,7 +607,7 @@ function addCodeBlockHandlers() {
255
607
  const isHighlighted = btn.classList.contains('active')
256
608
 
257
609
  if (isHighlighted) {
258
- // 关闭高亮:显示纯文本
610
+ // 关闭高亮:显示纯文本,移除插件表格
259
611
  codeEl.textContent = rawCode
260
612
  codeEl.className = 'code-plain'
261
613
  btn.classList.remove('active')
@@ -270,6 +622,8 @@ function addCodeBlockHandlers() {
270
622
  }
271
623
  codeEl.innerHTML = highlighted
272
624
  codeEl.className = `hljs${lang ? ` language-${lang}` : ''}`
625
+ // 重新注入行号
626
+ hljs.lineNumbersBlock(codeEl)
273
627
  btn.classList.add('active')
274
628
  btn.dataset.tooltip = '关闭高亮'
275
629
  }
@@ -296,6 +650,30 @@ function extractTOC(tocItems) {
296
650
  })
297
651
  }
298
652
 
653
+ // 从 Markdown 文本中解析 TOC(编辑模式使用,不依赖 DOM)
654
+ function extractTOCFromMarkdown(markdown, tocItems) {
655
+ const slugger = new GithubSlugger()
656
+ const items = []
657
+ const lines = markdown.split('\n')
658
+ let inCodeBlock = false
659
+ for (const line of lines) {
660
+ // 跳过代码块内的内容
661
+ if (/^```/.test(line.trim())) {
662
+ inCodeBlock = !inCodeBlock
663
+ continue
664
+ }
665
+ if (inCodeBlock) continue
666
+ const match = line.match(/^(#{1,6})\s+(.+)$/)
667
+ if (match) {
668
+ const level = match[1].length
669
+ const text = match[2].trim()
670
+ const id = slugger.slug(text)
671
+ items.push({ id, text, level })
672
+ }
673
+ }
674
+ tocItems.value = items
675
+ }
676
+
299
677
  // ---- 主 composable ----
300
678
 
301
679
  export function useMarkdown() {
@@ -304,8 +682,9 @@ export function useMarkdown() {
304
682
 
305
683
  // 渲染 Markdown,传入当前文档 key 和文档列表用于链接改写
306
684
  async function renderMarkdown(markdown, currentDocKey = '', docsList = []) {
307
- const { data: frontmatter, content } = parseFrontmatter(markdown)
685
+ const { data: frontmatter, content, rawYaml } = parseFrontmatter(markdown)
308
686
  const renderer = createRenderer(currentDocKey, docsList)
687
+ marked.use({ extensions: [mathBlock, mathInline] })
309
688
  marked.setOptions({
310
689
  renderer,
311
690
  breaks: true,
@@ -332,6 +711,24 @@ export function useMarkdown() {
332
711
  htmlContent.value = htmlContent.value.replace(/(<\/h1>)/, `$1\n${metaHtml}`)
333
712
  }
334
713
 
714
+ // 在文档顶部插入 frontmatter 代码块
715
+ if (rawYaml) {
716
+ const highlighted = hljs.highlight(rawYaml, { language: 'yaml' }).value
717
+ const encodedRaw = encodeURIComponent(rawYaml)
718
+ 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>`
719
+ const frontmatterBlock =
720
+ `<div class="code-block-wrapper frontmatter-block" data-raw-code="${encodedRaw}" data-lang="yaml">` +
721
+ `<div class="code-block-header">` +
722
+ `<span class="code-lang-label">FRONTMATTER</span>` +
723
+ `<div class="code-block-actions">` +
724
+ `<button class="code-action-btn copy-code-btn" data-tooltip="复制代码">${copyIcon}<span class="copy-text">复制</span></button>` +
725
+ `</div>` +
726
+ `</div>` +
727
+ `<div class="code-block-body"><pre><code class="hljs language-yaml">${highlighted}</code></pre></div>` +
728
+ `</div>`
729
+ htmlContent.value = frontmatterBlock + htmlContent.value
730
+ }
731
+
335
732
  // 后处理
336
733
  await renderMermaid()
337
734
  wrapTables()
@@ -344,6 +741,7 @@ export function useMarkdown() {
344
741
  htmlContent,
345
742
  tocItems,
346
743
  renderMarkdown,
744
+ extractTOCFromMarkdown,
347
745
  addImageZoomHandlers,
348
746
  docHash
349
747
  }
@@ -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
+ }