用Markdown模式转为html实现在线预览和简易在线文档编辑功能

此功能具备将markdown格式的文件转换为html展示在页面上,并将修改markdown源码实现在线编辑的功能,并将自动生成目录树,切具备在鼠标位置插入图片功能,和导出word文档功能(目前导出的word文档有些样式有点问题,没找到解决办法)

所需要用到的插件npm install marked highlight.js github-markdown-css

里面引入的import { articleMD, getArticle, uploadImage } from '@/api/login'是我自己项目里的接口,编辑接口,详情接口及上传图片的接口,根据实际情况来

预览模式和MD源码编辑模式就是查看和编辑功能,每次点击预览模式都会重新调取一遍详情接口,图片具备在鼠标位置插入,没光标位置会在末尾插入,且点击图片有放大预览效果功能,导出word文档对里面的图片进行了base64处理。文档目录以进行处理会根据markdown内容自动生成,也会点击目录跳转到相应的位置并会给目录一个高亮提示

<template> <div class="container"> <!-- 顶部切换栏 --> <div class="tab-bar"> <div> <el-button :class="['tab-btn', viewMode === 'preview' ? 'active' : '']" @click="switchMode('preview')"> 预览模式 </el-button> <el-button :class="['tab-btn', viewMode === 'edit' ? 'active' : '']" @click="switchMode('edit')"> MD源码编辑模式 </el-button> <el-button type="warning" @click="exportMd"> 导出 </el-button> </div> <!-- 仅编辑模式显示图片上传 --> <div style="text-align: right;" v-if="viewMode === 'edit'"> <el-button style="margin-right: 10px;" @click="$refs.imgInput.click()">插入图片</el-button> <input ref="imgInput" type="file" accept="image/*" hidden @change="insertImage" /> <el-button type="success" @click="saveMd">保存MD源码</el-button> </div> </div> <div class="md-wrap" style="display:flex;gap:24px;padding:0 20px;"> <!-- 左侧固定目录树 v-for动态渲染 --> <div> <h3>文档目录</h3> <div class="toc-sidebar"> <ul style="padding-left:0;list-style:none;"> <li v-for="item in tocList" :key="item.id" :style="{ margin: '8px 0', paddingLeft: (item.level - 1) * 14 + 'px' }"> <a :href="`#${item.id}`" :class="{ 'active-toc': activeId === item.id }"> {{ item.title }} </a> </li> </ul> </div> </div> <!-- 右侧区域:分预览 / 编辑 --> <div class="right-area" style="flex:1;"> <!-- 预览模式 绑定滚动 --> <div v-if="viewMode === 'preview'" class="markdown-body" ref="mdBox" @scroll="handleScroll"></div> <!-- MD编辑模式 --> <textarea v-else v-model="rawMd" ref="textareaRef" class="md-editor" placeholder="在此编辑Markdown内容..."></textarea> </div> </div> </div> </template> <script> import { articleMD, getArticle, uploadImage } from '@/api/login' import marked from 'marked' import hljs from 'highlight.js' import 'github-markdown-css' import 'highlight.js/styles/github.css' export default { name: 'MarkView', data() { return { rawMd: '', viewMode: 'preview', activeId: '', tocList: [] // 目录数组,v-for渲染 } }, mounted() { this.init() }, methods: { init() { getArticle(3).then(res => { this.rawMd = this.fixMdImagePath(res.data.mdContent) this.renderFullMd() }) }, switchMode(mode) { this.viewMode = mode this.activeId = '' this.$nextTick(() => { if (mode === 'preview') { this.init() } else { this.renderFullMd() } }) }, fixMdImagePath(mdContent) { return mdContent.replace(/!\[(.*?)\]\((.*?)\)/g, (match, alt, imgPath) => { // if (!imgPath.startsWith('http')) { // let realPath = imgPath.replace(/^\.+\//, '') // return `![${alt}](/media/media/${realPath})` // } return match }) }, // 为标题添加id属性,用于目录树 addHeadingId(htmlStr) { const idCache = {} return htmlStr.replace(/<(h[1-6])>(.+?)<\/\1>/g, (_, tag, text) => { let id = text.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, '-').replace(/-+/g, '-') if (idCache[id]) { idCache[id]++ id += '-' + idCache[id] } else { idCache[id] = 1 } return `<${tag} id="${id}">${text}</${tag}>` }) }, // 渲染Markdown内容,预览模式和编辑模式都调用 renderFullMd() { if (!this.rawMd) return marked.setOptions({ highlight(code, lang) { if (lang && hljs.getLanguage(lang)) { return hljs.highlight(code, { language: lang }).value } return hljs.highlightAuto(code).value }, gfm: true, breaks: true }) let html = marked.parse(this.rawMd) html = this.addHeadingId(html) if (this.viewMode === 'preview') { this.$refs.mdBox.innerHTML = html this.$nextTick(() => { const imgArr = this.$refs.mdBox.querySelectorAll('img') imgArr.forEach(img => { // 鼠标悬浮放大镜样式 img.style.cursor = 'zoom-in' // 防止重复绑定事件 img.onclick = null img.onclick = () => { // 创建全屏黑色遮罩 const mask = document.createElement('div') mask.style.cssText = ` position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.9); z-index: 99999; display: flex; align-items: center; justify-content: center; ` // 大图 const bigImg = new Image() bigImg.src = img.src bigImg.style.maxWidth = '90%' bigImg.style.maxHeight = '90vh' mask.appendChild(bigImg) // 点击遮罩任意位置关闭弹窗 mask.onclick = () => { mask.remove() } document.body.appendChild(mask) } }) }) } // 生成目录数组,替换innerHTML拼接 this.generateTocList(html) }, // 生成目录数组存在tocList,v-for渲染 generateTocList(html) { const tempDom = document.createElement('div') tempDom.innerHTML = html const headers = tempDom.querySelectorAll('h1,h2,h3') const list = [] headers.forEach(h => { list.push({ id: h.id, title: h.innerText, level: Number(h.tagName.slice(1)) }) }) this.tocList = list }, // 滚动监听,只更新activeId,Vue自动响应高亮 handleScroll() { if (this.viewMode !== 'preview' || !this.$refs.mdBox) return const scrollBox = this.$refs.mdBox const allHeadings = scrollBox.querySelectorAll('h1,h2,h3') let currentId = '' // 倒序遍历 for (let i = allHeadings.length - 1; i >= 0; i--) { const h = allHeadings[i] const offsetTop = h.offsetTop - scrollBox.scrollTop if (offsetTop < 100) { currentId = h.id break } } // 仅修改activeId,Vue自动更新class高亮 this.activeId = currentId }, // 插入图片 insertImage(e) { const file = e.target.files[0] var imgMd = '' if (!file) return // const tempUrl = URL.createObjectURL(file) var formData = new FormData() formData.append('file', file) formData.append('filePath', 'md') uploadImage(formData).then(res => { if (res.code == 200) { imgMd = `<img src="${'http://你自己的线上ip:8080' + res.fileName}" />` const textarea = this.$refs.textareaRef const start = textarea.selectionStart const end = textarea.selectionEnd this.rawMd = this.rawMd.slice(0, start) + imgMd + this.rawMd.slice(end) e.target.value = '' this.$message.success('插入成功') } }) }, //转成文档 async exportMd() { const loading = this.$loading({ lock: true, text: '正在生成文档,markdown转码中...', spinner: 'el-icon-document', background: 'rgba(0, 0, 0, 0.7)' }) try { let html = marked.parse(this.rawMd) // 1. 提取所有图片地址,转base64,避免Word离线空白 const imgReg = /<img src="([^"]+)"/g let match const imgUrls = [] while ((match = imgReg.exec(html)) !== null) { imgUrls.push(match[1]) } // 批量替换图片src为base64 for (const url of imgUrls) { const base64 = await this.getImgBase64(url) html = html.replace(`src="${url}"`, `src="${base64}"`) } html = html.replace(/<img/g, '<img width="1000"') const fullHtml = ` <html lang="zh-CN"> <head> <meta charset="UTF-8"> <!-- Word兼容标识,提升CSS生效概率 --> <meta name="ProgId" content="Word.Document"> <style> body { font-family:"Microsoft YaHei"; font-size:14px; line-height:1.8; } p { text-indent:2em; margin:8px 0; } h1 { font-size: 26px !important; margin: 30px 0 15px; border-bottom: 1px solid #eee; padding-bottom: 8px; font-weight: bold !important; } h2 { font-size: 22px !important; margin: 24px 0 12px; font-weight: bold !important; } h3 { font-size: 20px !important; margin: 20px 0 10px; font-weight: bold !important; } h4 { font-size: 18px !important; margin: 16px 0 8px; font-weight: bold !important; } h5 { font-size: 16px !important; margin: 12px 0 6px; font-weight: bold !important; } ul,ol { margin-left:5px; list-style: none; padding-left: 0; } li { list-style: none; margin:3px 0; text-indent:0; display: flex; align-items: center; } table { border-collapse:collapse; width:95%; margin:25px 0; } td,th { border:1px solid #ccc; padding:8px 12px; } th { background:#f5f7fa; } </style> </head> <body> ${html} </body> </html>` var blob = new Blob([fullHtml], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }) var url = URL.createObjectURL(blob) var a = document.createElement('a') a.href = url a.download = '操作手册.docx' // 必须插入body document.body.appendChild(a) a.click() URL.revokeObjectURL(url) this.$message.success('导出完成') } catch (err) { console.error(err) this.$message.error('导出异常') } finally { loading.close() } }, // 转成base64 getImgBase64(url) { return new Promise((resolve) => { const img = new Image() img.crossOrigin = 'Anonymous' img.onload = () => { const canvas = document.createElement('canvas') const maxW = 1000 let w = img.width let h = img.height if (w > maxW) { h = (maxW / w) * h w = maxW } canvas.width = w canvas.height = h const ctx = canvas.getContext('2d') ctx.drawImage(img, 0, 0, w, h) resolve(canvas.toDataURL('image/png')) } // 图片加载失败兜底 img.onerror = () => resolve(url) img.src = url }) }, //保存 saveMd() { const encodeStr = encodeURIComponent(this.rawMd) const base64Md = btoa(encodeStr) const form = { title: '操作手册', mdContent: this.rawMd, id: 3 } articleMD(form).then(res => { if (res.code == 200) { this.$message.success('保存成功') } }) } } } </script> <style scoped> /* 切换按钮样式 */ .tab-bar { display: flex; justify-content: space-between; padding: 0 20px; margin: 10px 0; } .tab-btn { /* padding: 6px 18px; border: 1px solid #ccc; background: #fff; cursor: pointer; margin-right: 8px; border-radius: 4px; */ } .tab-btn.active { background: #409eff; color: #fff; border-color: #409eff; } /* 预览区域 */ .markdown-body { box-sizing: border-box; max-width: 1000px; max-height: 80vh; overflow-y: auto; padding: 10px 30px; line-height: 1.8; border: 1px solid #eee; border-radius: 6px; } .markdown-body img { max-width: 100%; border: 1px solid #eee; border-radius: 4px; } /* MD编辑文本域 */ .md-editor { width: 100%; height: 80vh; padding: 20px; border: 1px solid #eee; border-radius: 6px; font-size: 14px; line-height: 1.6; resize: vertical; } .toc-sidebar { min-width: 240px; max-width: 300px; max-height: 70vh; overflow-y: auto; } /* 侧边目录 */ .toc-sidebar h3 { max-height: 80vh; overflow-y: auto; border-bottom: 1px solid #eee; padding-bottom: 10px; } .toc-sidebar a { color: #3677cc; text-decoration: none; } .toc-sidebar a:hover { text-decoration: underline; } /* 滚动高亮样式 */ .toc-sidebar a.active-toc { color: #4FCF5E !important; font-weight: bold; /* background: #e6f0ff; */ /* padding: 2px 6px; */ border-radius: 4px; } </style>