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.
- package/README.md +3 -55
- package/bin/build.js +82 -7
- package/bin/md2ui.js +80 -4
- package/package.json +23 -9
- package/public/docs/00-/345/277/253/351/200/237/345/274/200/345/247/213.md +48 -28
- package/public/docs/01-/345/212/237/350/203/275/347/211/271/346/200/247.md +55 -40
- 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
- package/public/docs/02-Markdown/346/270/262/346/237/223/01-/344/273/243/347/240/201/345/235/227.md +91 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/02-/350/241/250/346/240/274.md +187 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/03-Mermaid/345/233/276/350/241/250.md +101 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/04-Frontmatter.md +32 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/assets/img-1777261394722.png +0 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/src/App.vue +130 -6
- package/src/components/AppSidebar.vue +181 -21
- package/src/components/CodeBlockNodeView.vue +72 -0
- package/src/components/DocContent.vue +25 -14
- package/src/components/EditorContent.vue +257 -0
- package/src/components/EditorToolbar.vue +264 -0
- package/src/components/ImageZoom.vue +199 -2
- package/src/components/MathBlockNodeView.vue +160 -0
- package/src/components/MathInlineNodeView.vue +145 -0
- package/src/components/MermaidNodeView.vue +149 -0
- package/src/components/TableBubbleMenu.vue +177 -0
- package/src/components/TableOfContents.vue +138 -32
- package/src/components/TopBar.vue +69 -4
- package/src/components/TreeNode.vue +232 -39
- package/src/components/WelcomePage.vue +2 -2
- package/src/composables/useDocHash.js +9 -1
- package/src/composables/useDocManager.js +325 -68
- package/src/composables/useDocTree.js +56 -1
- package/src/composables/useExportPdf.js +102 -0
- package/src/composables/useExportWord.js +73 -10
- package/src/composables/useFileWatcher.js +45 -0
- package/src/composables/useFrontmatter.js +2 -2
- package/src/composables/useMarkdown.js +529 -42
- package/src/composables/useScroll.js +47 -5
- package/src/config.js +1 -1
- package/src/extensions/CodeBlockCustom.js +113 -0
- package/src/extensions/MathBlock.js +107 -0
- package/src/extensions/MathInline.js +100 -0
- package/src/extensions/MermaidBlock.js +73 -0
- package/src/extensions/TableControls.js +670 -0
- package/src/services/DocService.js +184 -0
- package/src/style.css +2194 -39
- package/vite-plugin-doc-api.js +368 -0
- package/vite.config.js +2 -1
- package/public/docs/02-Mermaid/345/233/276/350/241/250.md +0 -102
- 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
- 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
- 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
- package/public/docs/04-API/345/217/202/350/200/203/01-/347/273/204/344/273/266API.md +0 -80
- package/public/docs/04-API/345/217/202/350/200/203/02-Composables.md +0 -92
- 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>
|