效果图
实际代码
<template>
<div class="mermaid-container" style="z-index: 99999" ref="wrapperRef">
<!-- 控制栏 -->
<div class="control-bar">
<div class="control-bar-flex control-bar-tab-wrap">
<div :class="['control-bar-tab-item', showCode ? '' : 'control-bar-tab-item-active']" @click="toggleCode">图表</div>
<div :class="['control-bar-tab-item', showCode ? 'control-bar-tab-item-active' : '']" @click="toggleCode">代码</div>
</div>
<div class="control-bar-flex">
<div v-if="showCode">
<div class="control-bar-dropdown" style="margin-right: 8px" @click="onCopy">
<icon-park type="copy" size="16" theme="outline" fill="rgba(82,82,82)" style="margin-right: 4px"></icon-park>
<span>复制</span>
</div>
</div>
<template v-else>
<el-tooltip class="box-item" effect="dark" content="缩小" placement="top">
<icon-park type="zoom-out" size="16" theme="outline" fill="rgba(82,82,82)" @click="zoomOut" style="margin-right: 16px; cursor: pointer"></icon-park>
</el-tooltip>
<el-tooltip class="box-item" effect="dark" content="放大" placement="top">
<icon-park type="zoom-in" size="16" theme="outline" fill="rgba(82,82,82)" @click="zoomIn" style="margin-right: 16px; cursor: pointer"></icon-park>
</el-tooltip>
<div class="control-bar-line"></div>
<div class="control-bar-dropdown" @click="editHandle" style="margin-right: 4px">
<icon-park type="edit" size="16" theme="outline" fill="rgba(82,82,82)" style="margin-right: 4px"></icon-park>
<span>编辑</span>
</div>
</template>
<el-dropdown trigger="click">
<div class="control-bar-dropdown">
<icon-park type="download" size="16" theme="outline" fill="rgba(82,82,82)" style="margin-right: 4px"></icon-park>
<span>下载</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="downloadSVG">下载 SVG</el-dropdown-item>
<el-dropdown-item @click="downloadPNG">下载 PNG</el-dropdown-item>
<el-dropdown-item @click="downloadCode">下载代码</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 代码/图表切换 -->
<pre class="mermaid-code" v-if="showCode"><code class="hljs code-block-body">{{ code }}</code></pre>
<div v-else id="graphContainer" class="mermaid-chart" @mousedown="startDrag" @mouseup="stopDrag" @mouseleave="stopDrag" @mousemove="dragFlowchart">
<div id="custom-output" class="mermaid-chart-container" ref="mermaidContainer" :style="{ transform: `translate3d(0, ${offsetY}px, 0) scale(${scale})`, maxWidth: `${maxWidth}px` }">
<!-- {{ code }} -->
</div>
</div>
<div id="diagram" class="mermaid-container" style="float: left; width: 1px; height: 1px; overflow: hidden">
<svg xmlns="http://www.w3.org/2000/svg" id="abc" width="200" height="200">
<g transform="translate(50,50)"></g>
</svg>
</div>
<el-dialog v-model="showEditDialog" title="编辑" width="98%" @close="closeEditDialog" modal-class="drawioFrame-dialog" :close-on-click-modal="false">
<iframe ref="drawioFrame" :src="iframeSrc" style="width: calc(100% - 4px); height: calc(100% - 4px); border: 0"></iframe>
<template #footer>
<!-- <el-button @click="showEditDialog = false">关闭编辑器</el-button> -->
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="MermaidDiagram">
import { ref, onMounted, nextTick, watch, inject } from 'vue'
import hljs from 'highlight.js'
const props = defineProps({
code: {
type: String,
required: true
},
dialogVisible: {
type: Boolean,
default: false
}
})
const onceMessage = inject('$onceMessage')
const showCode = ref(false)
const scale = ref(1)
const mermaidContainer = ref(null)
const maxWidth = ref(0)
const wrapperRef = ref(null)
const svgHeight = ref(0)
const svgWidth = ref(0)
const isDragging = ref(false)
const offsetY = ref(0) // 纵向偏移量
const startY = ref(0) // 鼠标按下时的初始Y坐标
const showEditDialog = ref(false)
const drawioFrame = ref(null)
const iframeSrc = ref('https://embed.diagrams.net/?embed=1&ui=atlas&spin=1&proto=json')
const baseXml = ref(``)
const isDrawioReady = ref(false)
// 合并初始化监听(优化逻辑)
const initMessageHandler = () => {
// 确保每次对话框打开时重新监听
window.addEventListener('message', handleDrawioMessage)
}
// 初始化Mermaid
onMounted(() => {
refreshMermaid()
initMermaid()
calculateMaxWidth()
window.addEventListener('resize', calculateMaxWidth)
initMessageHandler()
})
const calculateMaxWidth = () => {
if (wrapperRef.value) {
maxWidth.value = wrapperRef.value.clientWidth
}
}
// 监听显示状态变化
watch(
() => showCode.value,
newVal => {
if (newVal) {
nextTick(() => {
// 具体高亮方式选其一
// 方式1:全局重新高亮
hljs.highlightAll()
// 方式2:定向高亮(推荐)
const codeBlocks = document.querySelectorAll('.mermaid-code code')
codeBlocks.forEach(block => {
hljs.highlightElement(block)
})
})
}
}
)
// 监听显示状态变化
// watch(
// () => [props.code,props.dialogVisible],
// newVal => {
// if (newVal[0] && newVal[1]) {
// refreshMermaid()
// initMermaid()
// }
// }
// )
const initMermaid = () => {
mermaid.initialize({
startOnLoad: false,
theme: 'default',
flowchart: {
useMaxWidth: false,
htmlLabels: true,
curve: 'basis'
},
securityLevel: 'loose' // 允许SVG操作
})
renderDiagram()
}
const renderDiagram = async () => {
await nextTick()
if (mermaidContainer.value) {
try {
// 使用 mermaid.init 生成流程图,并传入回调函数
// mermaid.init(undefined, mermaidContainer.value, () => {
// // 在回调函数中获取高度
// const svgElement = mermaidContainer.value.querySelector('svg')
// if (svgElement) {
// // 获取 viewBox 高度,这是 Mermaid 渲染后的实际高度
// const viewBox = svgElement.viewBox?.baseVal
// const height = viewBox?.height || svgElement.getBoundingClientRect().height
// const width = viewBox?.width || svgElement.getBoundingClientRect().width
// console.log('Mermaid渲染后高度:', height)
// console.log('Mermaid渲染后width:', width)
// svgHeight.value = height
// svgWidth.value = width
// // 计算缩放比例
// if (height > 546) {
// const targetScale = 546 / height
// scale.value = Math.min(targetScale, 1)
// console.log(`自动缩放: 原始高度${height}px, 缩放比例${targetScale.toFixed(2)}`)
// } else {
// scale.value = 1
// }
// }
// })
const input = props.code
const outputId = 'mermaid-' + Math.random().toString(36).substr(2, 9)
// 使用render方法渲染图表
mermaid
.render(outputId, input)
.then(result => {
mermaidContainer.value.innerHTML = result.svg
setTimeout(() => {
// 在回调函数中获取高度
const svgElement = mermaidContainer.value.querySelector('svg')
if (svgElement) {
// 获取 viewBox 高度,这是 Mermaid 渲染后的实际高度
const viewBox = svgElement.viewBox?.baseVal
const height = viewBox?.height || svgElement.getBoundingClientRect().height
const width = viewBox?.width || svgElement.getBoundingClientRect().width
console.log('Mermaid渲染后高度:', height)
console.log('Mermaid渲染后width:', width)
svgHeight.value = height
svgWidth.value = width
// // 计算缩放比例
if (height > 546) {
const targetScale = 546 / height
scale.value = Math.min(targetScale, 1)
console.log(`自动缩放: 原始高度${height}px, 缩放比例${targetScale.toFixed(2)}`)
} else {
scale.value = 1
}
}
}, 0)
})
.catch(error => {
console.error('渲染错误:', error)
mermaidContainer.value.innerHTML = '<div style="color: red; padding: 10px; background-color: #ffe6e6;">渲染错误: ' + error.message + '</div>'
})
} catch (err) {
console.error('Mermaid渲染错误:', err)
}
}
}
// 控制方法
const toggleCode = () => {
showCode.value = !showCode.value
setTimeout(() => {
if (!showCode.value) {
refreshMermaid()
initMermaid()
}
}, 0)
}
const refreshMermaid = () => {
scale.value = 1
isDragging.value = false
offsetY.value = 0
startY.value = 0
showCode.value = false
}
const draw = async (diag: any) => {
let conf5 = mermaid.getConfig2()
if (diag.db.getData) {
const data4Layout = diag.db.getData()
const direction = diag.db.getDirection()
data4Layout.type = diag.type
data4Layout.layoutAlgorithm = 'dagre'
data4Layout.direction = direction
data4Layout.nodeSpacing = conf5?.nodeSpacing || 50
data4Layout.rankSpacing = conf5?.rankSpacing || 50
data4Layout.markers = ['point', 'circle', 'cross']
data4Layout.diagramId = 'id-111'
return data4Layout
}
return null
}
const svgToImage = (svgString: any, format = 'png') => {
return new Promise((resolve, reject) => {
try {
// 创建SVG图像
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' })
const svgUrl = URL.createObjectURL(svgBlob)
// 加载图像
const img = new Image()
img.onload = function () {
try {
// 创建canvas
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
const imageData = canvas.toDataURL(`image/${format}`, 0.9)
URL.revokeObjectURL(svgUrl)
resolve({
data: imageData,
width: img.width,
height: img.height
})
} catch (err) {
reject(`转换canvas时出错: ${err.message}`)
}
}
img.onerror = function () {
URL.revokeObjectURL(svgUrl)
reject('SVG加载失败')
}
img.src = svgUrl
} catch (err) {
reject(`SVG解析错误: ${err.message}`)
}
})
}
const svgToBase64 = (svgContent: any) => {
// 处理Unicode字符
const bytes = new TextEncoder().encode(svgContent)
const binString = Array.from(bytes, byte => String.fromCharCode(byte)).join('')
const base64 = btoa(binString)
return `shape=image;noLabel=1;verticalAlign=top;imageAspect=1;image=data:image/svg+xml,${base64}`
}
const createDrawIoXml = (imageData: any, width: any, height: any, text: any) => {
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
// 创建draw.io XML文件内容
let xml = ''
xml += '<mxGraphModel dx="0" dy="0" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="' + width + '" pageHeight="' + height + '">\n'
xml += ' <root>\n'
xml += ' <mxCell id="0" />\n'
xml += ' <mxCell id="1" parent="0" />\n'
xml += ' <UserObject label="" mermaidData="' + text + '" id="' + uuid + '">\n'
xml += ' <mxCell style="' + imageData + ';" vertex="1" parent="1">\n'
xml += ' <mxGeometry x="0" y="0" width="' + width + '" height="' + height + '" as="geometry" />\n'
xml += ' </mxCell>\n'
xml += ' </UserObject>\n'
xml += ' </root>\n'
xml += '</mxGraphModel>\n'
return xml
}
const start = async () => {
let mermaidText = props.code
const diagram = await mermaid.mermaidAPI.getDiagramFromText(mermaidText)
// console.log('完整的解析结果:', diagram)
let data4Layout = await draw(diagram)
if (data4Layout) {
console.log('完整的解析结果:', data4Layout)
console.log(mermaid.select_default2('#diagram'))
const ss = mermaid.select_default2('#abc')
let ddd = await mermaid.render3(data4Layout, ss)
const drawio = mxMermaidToDrawio(ddd, data4Layout.type, {})
// console.log('===========drawio', drawio)
return drawio
} else {
console.log('没有解析到数据')
const result = await mermaid.render('diagram2', mermaidText)
const imageResult = await svgToImage(result.svg)
const data = svgToBase64(result.svg)
console.log(imageResult)
let d = { data: mermaidText }
const jsonString = JSON.stringify(d)
const escapedJson = jsonString.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
console.log(escapedJson)
const drawioXml = createDrawIoXml(data, svgWidth.value, svgHeight.value, escapedJson)
console.log('=========drawioXml', drawioXml)
return drawioXml
}
}
const editHandle = async () => {
baseXml.value = await start()
showEditDialog.value = true
await nextTick()
}
// 2. 监听 draw.io 的初始化完成事件
const handleDrawioMessage = event => {
if (event.data === '{"event":"init"}') {
isDrawioReady.value = true
tryLoadDiagram()
}
}
// 3. 只有 iframe 和 draw.io 都就绪时,才发送 XML
const tryLoadDiagram = () => {
if (isDrawioReady.value) {
loadDiagram()
}
}
// 调整对话框关闭事件处理
const closeEditDialog = () => {
showEditDialog.value = false
isDrawioReady.value = false // 重置状态
initMessageHandler() // 重新绑定监听以便下次使用
}
const loadDiagram = () => {
const frame = drawioFrame.value
if (frame) {
frame.contentWindow.postMessage(
JSON.stringify({
action: 'load',
xml: baseXml.value
}),
'*'
)
}
}
const zoomIn = () => {
// scale.value = Math.min(scale.value + 0.1, 2)
const newScale = scale.value + 0.1
const scaledWidth = (mermaidContainer.value?.scrollWidth || 0) * newScale
// 只有当缩放后宽度小于容器宽度时才允许放大
if (scaledWidth <= maxWidth.value) {
scale.value = newScale
} else {
// 自动调整到最大允许比例
scale.value = maxWidth.value / (mermaidContainer.value?.scrollWidth || maxWidth.value)
}
}
const zoomOut = () => {
scale.value = Math.max(scale.value - 0.1, 0.5)
}
const downloadSVG = () => {
const svg = mermaidContainer.value?.querySelector('svg')
if (!svg) return
const serializer = new XMLSerializer()
const source = serializer.serializeToString(svg)
const svgBlob = new Blob([source], { type: 'image/svg+xml' })
downloadFile(svgBlob, 'diagram.svg')
}
const downloadPNG = async () => {
const svg = mermaidContainer.value?.querySelector('svg')
if (!svg) return
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const data = new XMLSerializer().serializeToString(svg)
const img = new Image()
img.onload = () => {
canvas.width = svgWidth.value
canvas.height = svgHeight.value
ctx.drawImage(img, 0, 0)
canvas.toBlob(blob => {
downloadFile(blob, 'diagram.png')
}, 'image/png')
}
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(data)))
}
const downloadCode = async () => {
// 创建 Blob 对象
const blob = new Blob([props.code], { type: 'text/plain' })
downloadFile(blob, 'diagram.mermaid')
}
const downloadFile = (blob, filename) => {
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
const onCopy = () => {
const textarea = document.createElement('textarea') // 直接构建textarea
textarea.value = `${props.code}` // 设置内容
document.body.appendChild(textarea) // 添加临时实例
textarea.select() // 选择实例内容
document.execCommand('Copy') // 执行复制
document.body.removeChild(textarea) // 删除临时实例
onceMessage.success('复制成功')
}
// 开始拖动
const startDrag = e => {
isDragging.value = true
startY.value = e.clientY
document.body.style.cursor = 'grab' // 五指抓取手势
document.body.style.userSelect = 'none' // 禁用文本选择
}
// 停止拖动
const stopDrag = () => {
if (!isDragging.value) return
isDragging.value = false
document.body.style.cursor = '' // 恢复默认光标
document.body.style.userSelect = '' // 恢复文本选择
}
// 拖动中计算位置
const dragFlowchart = e => {
if (!isDragging.value) return
const deltaY = e.clientY - startY.value
offsetY.value += deltaY
startY.value = e.clientY // 更新当前鼠标位置
// 根据缩放比例动态计算最大偏移量
const containerHeight = 546 // 容器固定高度
const scaledHeight = svgHeight.value * scale.value // 缩放后的实际高度
const maxOffset = Math.max(0, scaledHeight - containerHeight) // 计算最大可偏移量
// 限制拖动边界
offsetY.value = Math.max(-maxOffset, Math.min(maxOffset, offsetY.value))
}
</script>
<style lang="scss">
.drawioFrame-dialog {
max-height: calc(100vh) !important;
.ep-dialog {
padding: 4px;
height: calc(96vh) !important;
}
.ep-dialog__header {
margin-bottom: 2px;
}
.ep-dialog__headerbtn {
top: -4px;
}
.ep-dialog__body {
padding: 0 !important;
margin: 0 !important;
max-height: 100% !important;
height: calc(96vh - 24px - 8px) !important;
}
.ep-dialog__footer {
display: none;
}
}
</style>
<style lang="scss" scoped>
.mermaid-container {
// border: 1px solid #eee;
border-radius: 12px;
// padding: 10px;
// margin: 20px 0;
background-color: #fafafa;
color: #494949;
.control-bar {
display: flex;
border-radius: 12px 12px 0 0;
justify-content: space-between;
display: flex;
padding: 6px 14px 6px 6px;
background-color: #f5f5f5;
.control-bar-flex {
display: flex;
justify-content: center;
align-items: center;
color: rgba(82, 82, 82);
}
.control-bar-tab-wrap {
background-color: rgba(0, 0, 0, 0.03);
border-radius: 8px;
}
.control-bar-tab-item {
height: 26px;
font-size: 12px;
white-space: nowrap;
cursor: pointer;
border-radius: 8px;
flex: 1;
justify-content: center;
align-items: center;
padding: 0 14px;
font-weight: 400;
display: flex;
position: relative;
}
.control-bar-tab-item-active {
display: flex;
background-color: #fff;
font-weight: 600;
box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.02), 0 6px 10px 0 rgba(0, 0, 0, 0.04);
}
.control-bar-line {
width: 1px;
height: 14px;
margin-right: 12px;
background-color: rgba(187, 187, 187, 1);
}
.control-bar-dropdown {
cursor: pointer;
display: flex;
align-items: center;
cursor: pointer;
padding: 0px 4px;
height: 28px;
font-size: 12px;
color: rgba(82, 82, 82);
border-radius: 12px;
&:hover {
background-color: rgba(0, 0, 0, 0.04);
}
}
}
}
.mermaid-chart {
overflow: auto;
background-color: #fafafa;
border-radius: 0 0 12px 12px;
overflow: hidden;
max-height: 546px;
display: flex;
justify-content: center;
align-items: center;
user-select: none; // 禁用文本选择
will-change: transform; // 提示浏览器优化渲染
.mermaid-chart-container {
will-change: transform; // 提示浏览器优化渲染
user-select: none; // 禁用文本选择
}
/* 鼠标按下时切换为激活手势 */
.mermaid-flowchart:active {
cursor: grabbing;
}
}
.mermaid-chart > div {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.mermaid-code {
background-color: #fafafa;
padding: calc(9.144px) calc(13.716px) calc(9.144px) calc(13.716px);
white-space: pre-wrap;
overflow: auto;
text-align: left;
border-radius: 0 0 12px 12px;
font-size: 12px;
}
.icon {
font-style: normal;
}
</style>