Files
xk-docs/docs/.vitepress/theme/Mermaid.vue
2026-07-08 15:04:42 +08:00

222 lines
5.1 KiB
Vue

<template>
<div class="mermaid-wrapper" ref="wrapperRef">
<div class="mermaid svg-mermaid" ref="mermaidRef" :data-graph="graph" @click="openPreview"></div>
<button class="mermaid-expand" @click.stop="openPreview" title="全屏预览">
<Maximize2 :size="8" />
</button>
<ImagePreview :visible="showPreview" :html="renderedHtml" @close="showPreview = false" />
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { Maximize2 } from '@lucide/vue'
import ImagePreview from './ImagePreview.vue'
const props = defineProps({
graph: {
type: String,
default: '',
},
id: {
type: String,
default: () => `mermaid-${Math.random().toString(36).slice(2, 9)}`,
},
})
const wrapperRef = ref(null)
const mermaidRef = ref(null)
const showPreview = ref(false)
const renderedHtml = ref('')
let mut = null
// Zoom/pan state
let scale = 1
let translateX = 0
let translateY = 0
let isDragging = false
let lastX = 0
let lastY = 0
const updateTransform = () => {
if (!mermaidRef.value) return
mermaidRef.value.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`
mermaidRef.value.style.transformOrigin = '0 0'
}
const onWheel = (e) => {
e.preventDefault()
const delta = e.deltaY > 0 ? 0.9 : 1.1
const newScale = Math.min(Math.max(scale * delta, 0.2), 5)
const rect = wrapperRef.value.getBoundingClientRect()
const cx = e.clientX - rect.left
const cy = e.clientY - rect.top
translateX = cx - (cx - translateX) * (newScale / scale)
translateY = cy - (cy - translateY) * (newScale / scale)
scale = newScale
updateTransform()
}
const onMouseDown = (e) => {
if (e.button !== 0) return
isDragging = true
lastX = e.clientX
lastY = e.clientY
wrapperRef.value.style.cursor = 'grabbing'
}
const onMouseMove = (e) => {
if (!isDragging) return
translateX += e.clientX - lastX
translateY += e.clientY - lastY
lastX = e.clientX
lastY = e.clientY
updateTransform()
}
const onMouseUp = () => {
isDragging = false
if (wrapperRef.value) wrapperRef.value.style.cursor = 'grab'
}
const onDblClick = () => {
scale = 1
translateX = 0
translateY = 0
updateTransform()
}
function openPreview() {
if (mermaidRef.value && mermaidRef.value.innerHTML) {
renderedHtml.value = mermaidRef.value.innerHTML
showPreview.value = true
}
}
const renderMermaid = async () => {
if (!mermaidRef.value) return
try {
const mermaid = (await import('mermaid')).default
mermaid.initialize({
startOnLoad: false,
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'default',
securityLevel: 'loose',
})
const graphData = props.graph || mermaidRef.value.getAttribute('data-graph') || ''
if (!graphData) return
const decoded = decodeURIComponent(graphData)
const { svg } = await mermaid.render(props.id, decoded)
mermaidRef.value.innerHTML = svg
// Reset zoom/pan on re-render
scale = 1
translateX = 0
translateY = 0
updateTransform()
} catch (error) {
console.error('Mermaid render error:', error)
mermaidRef.value.innerHTML = `<pre style="color: red;">Mermaid Error: ${error.message}</pre>`
}
}
onMounted(() => {
renderMermaid()
mut = new MutationObserver(() => renderMermaid())
mut.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
const el = wrapperRef.value
if (el) {
el.addEventListener('wheel', onWheel, { passive: false })
el.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
el.addEventListener('dblclick', onDblClick)
}
})
onUnmounted(() => {
if (mut) mut.disconnect()
const el = wrapperRef.value
if (el) {
el.removeEventListener('wheel', onWheel)
el.removeEventListener('mousedown', onMouseDown)
el.removeEventListener('dblclick', onDblClick)
}
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
})
</script>
<style scoped>
.mermaid-wrapper {
overflow: hidden;
cursor: grab;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 24px;
margin: 24px 0;
position: relative;
width: 100%;
min-height: 200px;
}
.mermaid-wrapper:active {
cursor: grabbing;
}
.mermaid {
width: 100%;
text-align: center;
}
.mermaid :deep(.svg-mermaid) {
width: 100%;
height: auto;
min-width: 600px;
}
.mermaid-expand {
position: absolute;
top: 8px;
right: 8px;
width: 28px !important;
height: 28px !important;
min-width: 28px !important;
min-height: 28px !important;
max-width: 28px !important;
max-height: 28px !important;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg);
color: var(--vp-c-text-2);
cursor: pointer;
/* opacity: 0;
transition: opacity 0.2s;*/
z-index: 2;
flex-shrink: 0;
padding: 0;
margin: 0;
line-height: 1;
}
/*
.mermaid-wrapper:hover .mermaid-expand {
opacity: 1;
}*/
.mermaid-expand:hover {
border-color: var(--vp-c-brand-1);
color: var(--vp-c-brand-1);
}
</style>