vue3使用mermaid生成图表,并可编辑

发布于:2025-07-14 ⋅ 阅读:(17) ⋅ 点赞:(0)

效果图

实际代码


<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>


网站公告

今日签到

点亮在社区的每一天
去签到