md2ui 1.0.18 → 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
@@ -22,6 +22,14 @@
22
22
  <button class="zoom-btn" @click="resetZoom" title="重置">
23
23
  <RotateCcw :size="16" />
24
24
  </button>
25
+ <button class="zoom-btn" :class="{ 'copy-success': copySuccess, 'copy-fail': copyFail }" @click="handleCopyImage" :title="copySuccess ? '已复制' : '复制图片'">
26
+ <Check v-if="copySuccess" :size="16" />
27
+ <Copy v-else :size="16" />
28
+ </button>
29
+ <transition name="tip-fade">
30
+ <span v-if="copySuccess" class="copy-tip copy-tip-ok">已复制</span>
31
+ <span v-else-if="copyFail" class="copy-tip copy-tip-fail">复制失败</span>
32
+ </transition>
25
33
  <span class="zoom-level">{{ Math.round(scale * 100) }}%</span>
26
34
  <!-- 图片计数 -->
27
35
  <span v-if="images.length > 1" class="image-counter">{{ currentIndex + 1 }} / {{ images.length }}</span>
@@ -69,8 +77,8 @@
69
77
  </template>
70
78
 
71
79
  <script setup>
72
- import { ref, computed } from 'vue'
73
- import { ZoomIn, ZoomOut, RotateCcw, X, ChevronLeft, ChevronRight } from 'lucide-vue-next'
80
+ import { ref, computed, nextTick, watch } from 'vue'
81
+ import { ZoomIn, ZoomOut, RotateCcw, X, ChevronLeft, ChevronRight, Copy, Check } from 'lucide-vue-next'
74
82
 
75
83
  // Props
76
84
  const props = defineProps({
@@ -113,6 +121,8 @@ const translateY = ref(0)
113
121
  const isDragging = ref(false)
114
122
  const lastMouseX = ref(0)
115
123
  const lastMouseY = ref(0)
124
+ const copySuccess = ref(false)
125
+ const copyFail = ref(false)
116
126
 
117
127
  // 缩放控制
118
128
  const minScale = 0.1
@@ -161,6 +171,153 @@ function close() {
161
171
  emit('close')
162
172
  }
163
173
 
174
+ // 复制图片到剪贴板
175
+ async function handleCopyImage() {
176
+ try {
177
+ const wrapper = document.querySelector('.image-wrapper')
178
+ if (!wrapper) return
179
+ const img = wrapper.querySelector('img')
180
+ const svg = wrapper.querySelector('svg')
181
+
182
+ let blobPromise
183
+ if (img) {
184
+ // 构造 PNG blob Promise
185
+ blobPromise = imgToPngBlob(img.src)
186
+ } else if (svg) {
187
+ blobPromise = svgToPngBlob(svg, 2)
188
+ } else {
189
+ return
190
+ }
191
+
192
+ const item = new ClipboardItem({ 'image/png': blobPromise })
193
+ await navigator.clipboard.write([item])
194
+ copySuccess.value = true
195
+ setTimeout(() => { copySuccess.value = false }, 1500)
196
+ } catch (e) {
197
+ console.warn('复制图片失败:', e)
198
+ copyFail.value = true
199
+ setTimeout(() => { copyFail.value = false }, 1500)
200
+ }
201
+ }
202
+
203
+ // 将图片 URL 转为 PNG blob(兼容同源和跨域)
204
+ async function imgToPngBlob(src) {
205
+ // 策略1:fetch 获取(同源图片直接成功)
206
+ try {
207
+ const resp = await fetch(src)
208
+ const blob = await resp.blob()
209
+ if (blob.type === 'image/png') return blob
210
+ return blobToCanvasPng(blob, 1)
211
+ } catch {
212
+ // 策略2:通过 crossOrigin Image 加载(需服务器支持 CORS)
213
+ try {
214
+ return await loadImageToBlob(src, true)
215
+ } catch {
216
+ // 策略3:不设 crossOrigin 直接画(可能被 taint,但对某些场景有效)
217
+ return loadImageToBlob(src, false)
218
+ }
219
+ }
220
+ }
221
+
222
+ // 加载图片到 canvas 并转 PNG blob
223
+ function loadImageToBlob(src, useCors) {
224
+ return new Promise((resolve, reject) => {
225
+ const image = new Image()
226
+ if (useCors) image.crossOrigin = 'anonymous'
227
+ image.onload = () => {
228
+ try {
229
+ const canvas = document.createElement('canvas')
230
+ canvas.width = image.naturalWidth
231
+ canvas.height = image.naturalHeight
232
+ canvas.getContext('2d').drawImage(image, 0, 0)
233
+ canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob null')), 'image/png')
234
+ } catch (e) {
235
+ reject(e)
236
+ }
237
+ }
238
+ image.onerror = () => reject(new Error('图片加载失败'))
239
+ image.src = src
240
+ })
241
+ }
242
+
243
+ // 将任意图片 blob 通过 canvas 转为 PNG blob
244
+ function blobToCanvasPng(srcBlob, scale = 1) {
245
+ return new Promise((resolve, reject) => {
246
+ const url = URL.createObjectURL(srcBlob)
247
+ const image = new Image()
248
+ image.onload = () => {
249
+ const canvas = document.createElement('canvas')
250
+ canvas.width = image.naturalWidth * scale
251
+ canvas.height = image.naturalHeight * scale
252
+ const ctx = canvas.getContext('2d')
253
+ if (scale !== 1) ctx.scale(scale, scale)
254
+ ctx.drawImage(image, 0, 0)
255
+ URL.revokeObjectURL(url)
256
+ canvas.toBlob(b => resolve(b), 'image/png')
257
+ }
258
+ image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('图片加载失败')) }
259
+ image.src = url
260
+ })
261
+ }
262
+
263
+ // SVG 转 PNG blob(处理 foreignObject 导致的 tainted canvas 问题)
264
+ function svgToPngBlob(svgEl, scale = 2) {
265
+ return new Promise((resolve, reject) => {
266
+ const clone = svgEl.cloneNode(true)
267
+ // 从 viewBox 或属性中获取 SVG 实际尺寸,确保复制完整
268
+ const viewBox = clone.getAttribute('viewBox')
269
+ let svgWidth, svgHeight
270
+ if (viewBox) {
271
+ const parts = viewBox.split(/[\s,]+/)
272
+ svgWidth = parseFloat(parts[2])
273
+ svgHeight = parseFloat(parts[3])
274
+ }
275
+ // 回退到 width/height 属性或 getBoundingClientRect
276
+ if (!svgWidth || !svgHeight) {
277
+ svgWidth = parseFloat(clone.getAttribute('width')) || svgEl.getBoundingClientRect().width || 800
278
+ svgHeight = parseFloat(clone.getAttribute('height')) || svgEl.getBoundingClientRect().height || 600
279
+ }
280
+ // 显式设置 width/height 属性,确保 Image 加载时尺寸正确
281
+ clone.setAttribute('width', svgWidth)
282
+ clone.setAttribute('height', svgHeight)
283
+ // 将 foreignObject 替换为 text 元素,避免 canvas 被污染
284
+ clone.querySelectorAll('foreignObject').forEach(fo => {
285
+ const text = fo.textContent.trim()
286
+ const x = fo.getAttribute('x') || '0'
287
+ const y = fo.getAttribute('y') || '0'
288
+ const width = fo.getAttribute('width') || '100'
289
+ const height = fo.getAttribute('height') || '20'
290
+ const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text')
291
+ textEl.setAttribute('x', String(parseFloat(x) + parseFloat(width) / 2))
292
+ textEl.setAttribute('y', String(parseFloat(y) + parseFloat(height) / 2 + 5))
293
+ textEl.setAttribute('text-anchor', 'middle')
294
+ textEl.setAttribute('font-size', '14')
295
+ textEl.setAttribute('fill', '#455a64')
296
+ textEl.textContent = text
297
+ fo.parentNode.replaceChild(textEl, fo)
298
+ })
299
+ const svgData = new XMLSerializer().serializeToString(clone)
300
+ const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
301
+ const url = URL.createObjectURL(svgBlob)
302
+ const image = new Image()
303
+ image.onload = () => {
304
+ // 使用从 SVG 提取的精确尺寸,而非 naturalWidth/Height(后者对无 width/height 的 SVG 不可靠)
305
+ const w = svgWidth * scale
306
+ const h = svgHeight * scale
307
+ const canvas = document.createElement('canvas')
308
+ canvas.width = w
309
+ canvas.height = h
310
+ const ctx = canvas.getContext('2d')
311
+ ctx.scale(scale, scale)
312
+ ctx.drawImage(image, 0, 0, svgWidth, svgHeight)
313
+ URL.revokeObjectURL(url)
314
+ canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob null')), 'image/png')
315
+ }
316
+ image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('SVG加载失败')) }
317
+ image.src = url
318
+ })
319
+ }
320
+
164
321
  // 处理遮罩层点击
165
322
  function handleOverlayClick(event) {
166
323
  if (event.target.classList.contains('image-zoom-overlay')) {
@@ -309,6 +466,38 @@ if (typeof window !== 'undefined') {
309
466
  color: #c53030;
310
467
  }
311
468
 
469
+ .copy-success {
470
+ color: #38a169 !important;
471
+ }
472
+
473
+ .copy-fail {
474
+ color: #e53e3e !important;
475
+ }
476
+
477
+ .copy-tip {
478
+ font-size: 12px;
479
+ font-weight: 500;
480
+ white-space: nowrap;
481
+ }
482
+
483
+ .copy-tip-ok {
484
+ color: #38a169;
485
+ }
486
+
487
+ .copy-tip-fail {
488
+ color: #e53e3e;
489
+ }
490
+
491
+ .tip-fade-enter-active,
492
+ .tip-fade-leave-active {
493
+ transition: opacity 0.2s;
494
+ }
495
+
496
+ .tip-fade-enter-from,
497
+ .tip-fade-leave-to {
498
+ opacity: 0;
499
+ }
500
+
312
501
  .zoom-level {
313
502
  font-size: 12px;
314
503
  font-weight: 600;
@@ -379,6 +568,14 @@ if (typeof window !== 'undefined') {
379
568
  cursor: grabbing;
380
569
  }
381
570
 
571
+ /* 确保图片在放大浮层中有合理的最小尺寸,避免复制按钮遮挡 */
572
+ .image-wrapper :deep(img) {
573
+ display: block;
574
+ min-width: 200px;
575
+ min-height: 200px;
576
+ object-fit: contain;
577
+ }
578
+
382
579
  /* 确保SVG在放大时保持清晰 */
383
580
  .image-wrapper :deep(svg) {
384
581
  display: block !important;
@@ -0,0 +1,160 @@
1
+ <template>
2
+ <node-view-wrapper class="math-block-wrapper" data-type="mathBlock">
3
+ <!-- 预览模式 -->
4
+ <div v-if="!editing" class="math-block-preview" contenteditable="false" @click.stop="startEdit">
5
+ <div class="math-block-edit-btn" title="编辑公式">
6
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
7
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
8
+ <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
9
+ <path d="m15 5 4 4" />
10
+ </svg>
11
+ </div>
12
+ <div v-if="renderedHtml && !renderError" class="math-block-rendered" v-html="renderedHtml"></div>
13
+ <div v-else-if="renderError" class="math-block-error-tip">
14
+ <span>公式渲染失败</span>
15
+ <button class="math-block-error-edit" @click.stop="startEdit">编辑公式</button>
16
+ </div>
17
+ <div v-else class="math-block-empty" @click.stop="startEdit">
18
+ <span>点击输入数学公式</span>
19
+ </div>
20
+ </div>
21
+ <!-- 编辑模式:上方编辑区 + 下方实时预览 -->
22
+ <div v-else class="math-block-editor" contenteditable="false">
23
+ <div class="math-block-editor-header">
24
+ <span class="math-block-editor-label">LATEX</span>
25
+ <div class="math-block-editor-actions">
26
+ <button class="math-block-editor-delete" @click.stop="deleteBlock" title="删除公式块">
27
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
28
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
29
+ <path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
30
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
31
+ </svg>
32
+ </button>
33
+ <button class="math-block-editor-done" @click.stop="finishEdit" title="完成编辑">
34
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
35
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
36
+ <polyline points="20 6 9 17 4 12" />
37
+ </svg>
38
+ <span>完成</span>
39
+ </button>
40
+ </div>
41
+ </div>
42
+ <textarea
43
+ ref="textareaRef"
44
+ class="math-block-editor-textarea"
45
+ :value="latex"
46
+ @input="onInput"
47
+ @keydown.tab.prevent="onTab"
48
+ @keydown.escape.prevent="finishEdit"
49
+ spellcheck="false"
50
+ placeholder="输入 LaTeX 公式,如 \int_0^\infty e^{-x} dx"
51
+ ></textarea>
52
+ <!-- 实时预览 -->
53
+ <div class="math-block-live-preview">
54
+ <div v-if="liveHtml && !liveError" class="math-block-live-rendered" v-html="liveHtml"></div>
55
+ <div v-else-if="liveError" class="math-block-live-error">{{ liveError }}</div>
56
+ <div v-else class="math-block-live-placeholder">预览区域</div>
57
+ </div>
58
+ </div>
59
+ </node-view-wrapper>
60
+ </template>
61
+
62
+ <script setup>
63
+ import { ref, computed, watch, onMounted, nextTick } from 'vue'
64
+ import { nodeViewProps, NodeViewWrapper } from '@tiptap/vue-3'
65
+ import katex from 'katex'
66
+
67
+ const props = defineProps(nodeViewProps)
68
+
69
+ const editing = ref(false)
70
+ const renderedHtml = ref('')
71
+ const renderError = ref(false)
72
+ const liveHtml = ref('')
73
+ const liveError = ref('')
74
+ const textareaRef = ref(null)
75
+
76
+ // 从节点属性获取 LaTeX 代码
77
+ const latex = computed(() => props.node.attrs.latex || '')
78
+
79
+ // 渲染 KaTeX
80
+ function renderKatex(text, displayMode = true) {
81
+ const trimmed = (text || '').trim()
82
+ if (!trimmed) return { html: '', error: '' }
83
+ try {
84
+ const html = katex.renderToString(trimmed, { throwOnError: true, displayMode })
85
+ return { html, error: '' }
86
+ } catch (e) {
87
+ return { html: '', error: e.message || '渲染失败' }
88
+ }
89
+ }
90
+
91
+ function updatePreview() {
92
+ const { html, error } = renderKatex(latex.value)
93
+ renderedHtml.value = html
94
+ renderError.value = !!error
95
+ }
96
+
97
+ function updateLivePreview(text) {
98
+ const { html, error } = renderKatex(text || latex.value)
99
+ liveHtml.value = html
100
+ liveError.value = error
101
+ }
102
+
103
+ function startEdit() {
104
+ editing.value = true
105
+ updateLivePreview()
106
+ nextTick(() => {
107
+ if (textareaRef.value) {
108
+ textareaRef.value.focus()
109
+ autoResize()
110
+ }
111
+ })
112
+ }
113
+
114
+ function finishEdit() {
115
+ editing.value = false
116
+ updatePreview()
117
+ }
118
+
119
+ function deleteBlock() {
120
+ const pos = props.getPos()
121
+ props.editor.chain().focus().deleteRange({ from: pos, to: pos + props.node.nodeSize }).run()
122
+ }
123
+
124
+ function onInput(e) {
125
+ const newLatex = e.target.value
126
+ props.updateAttributes({ latex: newLatex })
127
+ autoResize()
128
+ updateLivePreview(newLatex)
129
+ }
130
+
131
+ function onTab(e) {
132
+ const ta = e.target
133
+ const start = ta.selectionStart
134
+ const end = ta.selectionEnd
135
+ const val = ta.value
136
+ const newVal = val.substring(0, start) + ' ' + val.substring(end)
137
+ ta.value = newVal
138
+ ta.selectionStart = ta.selectionEnd = start + 2
139
+ onInput({ target: ta })
140
+ }
141
+
142
+ function autoResize() {
143
+ nextTick(() => {
144
+ if (textareaRef.value) {
145
+ textareaRef.value.style.height = 'auto'
146
+ textareaRef.value.style.height = textareaRef.value.scrollHeight + 'px'
147
+ }
148
+ })
149
+ }
150
+
151
+ watch(latex, () => {
152
+ if (!editing.value) {
153
+ updatePreview()
154
+ }
155
+ })
156
+
157
+ onMounted(() => {
158
+ updatePreview()
159
+ })
160
+ </script>
@@ -0,0 +1,145 @@
1
+ <template>
2
+ <node-view-wrapper as="span" class="math-inline-wrapper" :class="{ 'math-inline-editing': showEditor }">
3
+ <!-- 渲染态:显示 KaTeX 结果,点击进入编辑 -->
4
+ <span
5
+ v-if="renderedHtml && !renderError"
6
+ class="math-inline-rendered"
7
+ :class="{ 'math-inline-selected': selected }"
8
+ v-html="renderedHtml"
9
+ @click.stop="openEditor"
10
+ title="点击编辑公式"
11
+ ></span>
12
+ <!-- 渲染失败:显示源码 -->
13
+ <span
14
+ v-else
15
+ class="math-inline-error"
16
+ @click.stop="openEditor"
17
+ title="公式有误,点击编辑"
18
+ >{{ latex }}</span>
19
+
20
+ <!-- 编辑浮层 -->
21
+ <div v-if="showEditor" ref="popoverRef" class="math-inline-popover" @click.stop>
22
+ <div class="math-inline-popover-header">
23
+ <span class="math-inline-popover-label">行内公式</span>
24
+ <div class="math-inline-popover-actions">
25
+ <button class="math-inline-popover-btn delete" @click="deleteNode" title="删除公式">
26
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
27
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
28
+ <path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
29
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
30
+ </svg>
31
+ </button>
32
+ <button class="math-inline-popover-btn done" @click="closeEditor" title="完成">
33
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
34
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
35
+ <polyline points="20 6 9 17 4 12" />
36
+ </svg>
37
+ </button>
38
+ </div>
39
+ </div>
40
+ <input
41
+ ref="inputRef"
42
+ class="math-inline-popover-input"
43
+ :value="latex"
44
+ @input="onInput"
45
+ @keydown.enter.prevent="closeEditor"
46
+ @keydown.escape.prevent="closeEditor"
47
+ spellcheck="false"
48
+ placeholder="LaTeX 公式"
49
+ />
50
+ <!-- 实时预览 -->
51
+ <div v-if="liveHtml" class="math-inline-popover-preview" v-html="liveHtml"></div>
52
+ <div v-else-if="liveError" class="math-inline-popover-preview math-inline-popover-error">{{ liveError }}</div>
53
+ </div>
54
+ </node-view-wrapper>
55
+ </template>
56
+
57
+ <script setup>
58
+ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
59
+ import { nodeViewProps, NodeViewWrapper } from '@tiptap/vue-3'
60
+ import katex from 'katex'
61
+
62
+ const props = defineProps(nodeViewProps)
63
+
64
+ const showEditor = ref(false)
65
+ const renderedHtml = ref('')
66
+ const renderError = ref(false)
67
+ const liveHtml = ref('')
68
+ const liveError = ref('')
69
+ const inputRef = ref(null)
70
+ const popoverRef = ref(null)
71
+
72
+ const latex = computed(() => props.node.attrs.latex || '')
73
+
74
+ // 渲染 KaTeX
75
+ function renderKatex(text) {
76
+ if (!text.trim()) return { html: '', error: '' }
77
+ try {
78
+ const html = katex.renderToString(text.trim(), { throwOnError: true, displayMode: false })
79
+ return { html, error: '' }
80
+ } catch (e) {
81
+ return { html: '', error: e.message || '渲染失败' }
82
+ }
83
+ }
84
+
85
+ function updateRender() {
86
+ const { html, error } = renderKatex(latex.value)
87
+ renderedHtml.value = html
88
+ renderError.value = !!error
89
+ }
90
+
91
+ function updateLivePreview(text) {
92
+ const { html, error } = renderKatex(text)
93
+ liveHtml.value = html
94
+ liveError.value = error
95
+ }
96
+
97
+ function openEditor() {
98
+ showEditor.value = true
99
+ updateLivePreview(latex.value)
100
+ nextTick(() => {
101
+ if (inputRef.value) {
102
+ inputRef.value.focus()
103
+ inputRef.value.select()
104
+ }
105
+ })
106
+ }
107
+
108
+ function closeEditor() {
109
+ showEditor.value = false
110
+ updateRender()
111
+ }
112
+
113
+ function deleteNode() {
114
+ const pos = props.getPos()
115
+ props.editor.chain().focus().deleteRange({ from: pos, to: pos + props.node.nodeSize }).run()
116
+ }
117
+
118
+ function onInput(e) {
119
+ const newLatex = e.target.value
120
+ props.updateAttributes({ latex: newLatex })
121
+ updateLivePreview(newLatex)
122
+ }
123
+
124
+ // 点击外部关闭浮层
125
+ function handleClickOutside(e) {
126
+ if (showEditor.value && popoverRef.value && !popoverRef.value.contains(e.target)) {
127
+ closeEditor()
128
+ }
129
+ }
130
+
131
+ onMounted(() => {
132
+ updateRender()
133
+ document.addEventListener('mousedown', handleClickOutside)
134
+ })
135
+
136
+ onUnmounted(() => {
137
+ document.removeEventListener('mousedown', handleClickOutside)
138
+ })
139
+
140
+ watch(latex, () => {
141
+ if (!showEditor.value) {
142
+ updateRender()
143
+ }
144
+ })
145
+ </script>
@@ -0,0 +1,149 @@
1
+ <template>
2
+ <node-view-wrapper class="mermaid-block-wrapper" data-type="mermaidBlock">
3
+ <!-- 预览模式:渲染图表 -->
4
+ <div v-if="!editing" class="mermaid-preview">
5
+ <div class="mermaid-edit-btn" title="编辑 Mermaid 代码" @click.stop="startEdit">
6
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
7
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
8
+ <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
9
+ <path d="m15 5 4 4" />
10
+ </svg>
11
+ </div>
12
+ <div v-if="svgContent" class="mermaid-svg zoomable-image" v-html="svgContent" style="cursor: zoom-in" title="点击放大查看"></div>
13
+ <div v-else-if="renderError" class="mermaid-error-tip">
14
+ <span>图表渲染失败</span>
15
+ <button class="mermaid-error-edit" @click.stop="startEdit">编辑代码</button>
16
+ </div>
17
+ <div v-else class="mermaid-loading">渲染中...</div>
18
+ </div>
19
+ <!-- 编辑模式:代码编辑器 -->
20
+ <div v-else class="mermaid-editor">
21
+ <div class="mermaid-editor-header">
22
+ <span class="mermaid-editor-label">MERMAID</span>
23
+ <button class="mermaid-editor-done" @click.stop="finishEdit" title="完成编辑">
24
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
25
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
26
+ <polyline points="20 6 9 17 4 12" />
27
+ </svg>
28
+ <span>完成</span>
29
+ </button>
30
+ </div>
31
+ <textarea
32
+ ref="textareaRef"
33
+ class="mermaid-editor-textarea"
34
+ :value="code"
35
+ @input="onInput"
36
+ @keydown.tab.prevent="onTab"
37
+ spellcheck="false"
38
+ ></textarea>
39
+ </div>
40
+ </node-view-wrapper>
41
+ </template>
42
+
43
+ <script setup>
44
+ import { ref, computed, watch, onMounted, nextTick } from 'vue'
45
+ import { nodeViewProps, NodeViewWrapper } from '@tiptap/vue-3'
46
+ import mermaid from 'mermaid'
47
+
48
+ const props = defineProps(nodeViewProps)
49
+
50
+ const editing = ref(false)
51
+ const svgContent = ref('')
52
+ const renderError = ref(false)
53
+ const textareaRef = ref(null)
54
+
55
+ // 从节点内容获取代码文本
56
+ const code = computed(() => props.node.textContent || '')
57
+
58
+ // 渲染 Mermaid 图表
59
+ async function renderChart() {
60
+ const text = code.value.trim()
61
+ if (!text) {
62
+ svgContent.value = ''
63
+ renderError.value = false
64
+ return
65
+ }
66
+ try {
67
+ const id = 'mermaid-editor-' + Math.random().toString(36).substr(2, 9)
68
+ const { svg } = await mermaid.render(id, text)
69
+ svgContent.value = svg
70
+ renderError.value = false
71
+ } catch (e) {
72
+ console.error('Mermaid 编辑器渲染失败:', e)
73
+ svgContent.value = ''
74
+ renderError.value = true
75
+ // 清理 Mermaid 渲染失败时残留的 DOM 元素
76
+ const errId = 'mermaid-editor-' // 前缀匹配清理
77
+ document.querySelectorAll('[id^="mermaid-editor-"][id$="-svg"]').forEach(el => {
78
+ if (el.closest('.mermaid-block-wrapper') === null) el.remove()
79
+ })
80
+ }
81
+ }
82
+
83
+ // 进入编辑模式
84
+ function startEdit() {
85
+ editing.value = true
86
+ nextTick(() => {
87
+ if (textareaRef.value) {
88
+ textareaRef.value.focus()
89
+ autoResize()
90
+ }
91
+ })
92
+ }
93
+
94
+ // 完成编辑,回到预览模式
95
+ function finishEdit() {
96
+ editing.value = false
97
+ renderChart()
98
+ }
99
+
100
+ // 输入处理:更新节点内容
101
+ function onInput(e) {
102
+ const newText = e.target.value
103
+ const { tr } = props.editor.state
104
+ const pos = props.getPos()
105
+ // 替换节点内部全部文本
106
+ tr.replaceWith(
107
+ pos + 1,
108
+ pos + props.node.nodeSize - 1,
109
+ newText ? props.editor.schema.text(newText) : []
110
+ )
111
+ props.editor.view.dispatch(tr)
112
+ autoResize()
113
+ }
114
+
115
+ // Tab 键插入两个空格
116
+ function onTab(e) {
117
+ const ta = e.target
118
+ const start = ta.selectionStart
119
+ const end = ta.selectionEnd
120
+ const val = ta.value
121
+ const newVal = val.substring(0, start) + ' ' + val.substring(end)
122
+ // 先更新 textarea 显示
123
+ ta.value = newVal
124
+ ta.selectionStart = ta.selectionEnd = start + 2
125
+ // 同步到节点
126
+ onInput({ target: ta })
127
+ }
128
+
129
+ // 自动调整 textarea 高度
130
+ function autoResize() {
131
+ nextTick(() => {
132
+ if (textareaRef.value) {
133
+ textareaRef.value.style.height = 'auto'
134
+ textareaRef.value.style.height = textareaRef.value.scrollHeight + 'px'
135
+ }
136
+ })
137
+ }
138
+
139
+ // 监听代码变化(外部更新时重新渲染)
140
+ watch(code, () => {
141
+ if (!editing.value) {
142
+ renderChart()
143
+ }
144
+ })
145
+
146
+ onMounted(() => {
147
+ renderChart()
148
+ })
149
+ </script>