vue2.x中使用cherry-markdown
安装依赖
npm install cherry-markdown --save
子组件写法
<template> <div @click.prevent.stop> <div :id="mdId" :style="{height:height+'px'}" /> </div> </template> <script> import Cherry from 'cherry-markdown' import { getToken } from '@/utils/auth' import 'cherry-markdown/dist/cherry-markdown.min.css' export default { props: { height: { type: Number | String, default: 800 }, value: { type: String, default: '' }, mdId: { type: String, default: 'markdown-container' } }, data() { return { content: null, cherrInstance: null } }, mounted() { this.initCherryMD() }, methods: { // 初始化编辑器 initCherryMD(value, config) { var { afterChange, afterInit, beforeImageMounted, fileUpload, mdId } = this var defaultValue = value || this.value /** * 自定义一个自定义菜单 * 点第一次时,把选中的文字变成同时加粗和斜体 * 保持光标选区不变,点第二次时,把加粗斜体的文字变成普通文本 */ var customMenuA = Cherry.createMenuHook('加粗斜体', { iconName: 'font', onClick: function(selection) { // 获取用户选中的文字,调用getSelection方法后,如果用户没有选中任何文字,会尝试获取光标所在位置的单词或句子 let $selection = this.getSelection(selection) || '同时加粗斜体'; // 如果是单选,并且选中内容的开始结束内没有加粗语法,则扩大选中范围 if (!this.isSelections && !/^\s*(\*\*\*)[\s\S]+(\1)/.test($selection)) { this.getMoreSelection('***', '***', () => { const newSelection = this.editor.editor.getSelection(); const isBoldItalic = /^\s*(\*\*\*)[\s\S]+(\1)/.test(newSelection); if (isBoldItalic) { $selection = newSelection; } return isBoldItalic; }); } // 如果选中的文本中已经有加粗语法了,则去掉加粗语法 if (/^\s*(\*\*\*)[\s\S]+(\1)/.test($selection)) { return $selection.replace(/(^)(\s*)(\*\*\*)([^\n]+)(\3)(\s*)($)/gm, '$1$4$7'); } /** * 注册缩小选区的规则 * 注册后,插入“***TEXT***”,选中状态会变成“***【TEXT】***” * 如果不注册,插入后效果为:“【***TEXT***】” */ this.registerAfterClickCb(() => { this.setLessSelection('***', '***'); }); return $selection.replace(/(^)([^\n]+)($)/gm, '$1***$2***$3'); } }); /** * 定义一个空壳,用于自行规划cherry已有工具栏的层级结构 */ var customMenuB = Cherry.createMenuHook('实验室', { iconName: '', }); /** * 定义一个自带二级菜单的工具栏 */ var customMenuC = Cherry.createMenuHook('帮助中心', { iconName: 'question', onClick: (selection, type) => { switch (type) { case 'markdown': return `${selection}markdown教程在这里:https://markdown.com.cn/`; case 'Emoji': return `${selection}Emoji表情在这里:https://emojipedia.org/zh/`; case 'formula': return `${selection}LaTeX公式编辑器在这里:https://www.latexlive.com/`; case 'Example': return `${selection}完整示例看这里:https://tencent.github.io/cherry-markdown/examples/index.html`; default: return selection; } }, subMenuConfig: [{ noIcon: true, name: 'markdown教程', onclick: (event) => { this.cherrInstance.toolbar.menus.hooks.customMenuCName.fire(null, 'markdown') } }, { noIcon: true, name: 'Emoji 表情', onclick: (event) => { this.cherrInstance.toolbar.menus.hooks.customMenuCName.fire(null, 'Emoji') } }, { noIcon: true, name: '公式编辑器', onclick: (event) => { this.cherrInstance.toolbar.menus.hooks.customMenuCName.fire(null, 'formula') } }, { noIcon: true, name: '完整示例', onclick: (event) => { this.cherrInstance.toolbar.menus.hooks.customMenuCName.fire(null, 'Example') } }, ] }); this.cherrInstance = new Cherry({ id: mdId, value: defaultValue, fileUpload: fileUpload, // 第三方包 externals: { // externals }, // 解析引擎配置 engine: { // 全局配置 global: { // 是否启用经典换行逻辑 // true:一个换行会被忽略,两个以上连续换行会分割成段落, // false: 一个换行会转成<br>,两个连续换行会分割成段落,三个以上连续换行会转成<br>并分割段落 classicBr: false, /** * 全局的URL处理器 * @param {string} url 来源url * @param {'image'|'audio'|'video'|'autolink'|'link'} srcType 来源类型 * @returns */ urlProcessor: this.urlProcessor, /** * 额外允许渲染的html标签 * 标签以英文竖线分隔,如:htmlWhiteList: 'iframe|script|style' * 默认为空,默认允许渲染的html见src/utils/sanitize.js whiteList 属性 * 需要注意: * - 启用iframe、script等标签后,会产生xss注入,请根据实际场景判断是否需要启用 * - 一般编辑权限可控的场景(如api文档系统)可以允许iframe、script等标签 */ htmlWhiteList: '' }, // 内置语法配置 syntax: { // 语法开关 // 'hookName': false, // 语法配置 // 'hookName': { // // } autoLink: { /** 是否开启短链接 */ enableShortLink: true, /** 短链接长度 */ shortLinkLength: 20 }, list: { listNested: false, // 同级列表类型转换后变为子级 indentSpace: 2 // 默认2个空格缩进 }, table: { enableChart: false // chartRenderEngine: EChartsTableEngine, // externals: ['echarts'], }, inlineCode: { theme: 'red' }, codeBlock: { theme: 'dark', // 默认为深色主题 wrap: true, // 超出长度是否换行,false则显示滚动条 lineNumber: true, // 默认显示行号 copyCode: true, // 是否显示“复制”按钮 customRenderer: { // 自定义语法渲染器 }, mermaid: { svg2img: false, // 是否将mermaid生成的画图变成img格式 }, /** * indentedCodeBlock是缩进代码块是否启用的开关 * * 在6.X之前的版本中默认不支持该语法。 * 因为cherry的开发团队认为该语法太丑了(容易误触) * 开发团队希望用```代码块语法来彻底取代该语法 * 但在后续的沟通中,开发团队发现在某些场景下该语法有更好的显示效果 * 因此开发团队在6.X版本中才引入了该语法 * 已经引用6.x以下版本的业务如果想做到用户无感知升级,可以去掉该语法: * indentedCodeBlock:false */ indentedCodeBlock: true }, emoji: { useUnicode: false, // 是否使用unicode进行渲染 customResourceURL: 'https://github.githubassets.com/images/icons/emoji/unicode/${code}.png?v8', upperCase: true, }, fontEmphasis: { /** * 是否允许首尾空格 * 首尾、前后的定义: 语法前**语法首+内容+语法尾**语法后 * 例: * true: * __ hello __ ====> <strong> hello </strong> * __hello__ ====> <strong>hello</strong> * false: * __ hello __ ====> <em>_ hello _</em> * __hello__ ====> <strong>hello</strong> */ allowWhitespace: false }, strikethrough: { /** * 是否必须有前后空格 * 首尾、前后的定义: 语法前**语法首+内容+语法尾**语法后 * 例: * true: * hello wor~~l~~d ====> hello wor~~l~~d * hello wor ~~l~~ d ====> hello wor <del>l</del> d * false: * hello wor~~l~~d ====> hello wor<del>l</del>d * hello wor ~~l~~ d ====> hello wor <del>l</del> d */ needWhitespace: false }, mathBlock: { engine: 'MathJax', // katex或MathJax src: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js', // 如果使用MathJax plugins,则需要使用该url通过script标签引入 plugins: true // 默认加载插件 }, inlineMath: { engine: 'MathJax', // katex或MathJax src: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js' // 如果使用MathJax plugins,则需要使用该url通过script标签引入 }, toc: { /** 默认只渲染一个目录 */ allowMultiToc: false }, header: { /** * 标题的样式: * - default 默认样式,标题前面有锚点 * - autonumber 标题前面有自增序号锚点 * - none 标题没有锚点 */ anchorStyle: 'default' } } }, editor: { id: 'code', // textarea 的id属性值 name: 'code', // textarea 的name属性值 autoSave2Textarea: false, // 是否自动将编辑区的内容回写到textarea里 theme: 'default', // depend on codemirror theme name: https://codemirror.net/demo/theme.htm // 编辑器的高度,默认100%,如果挂载点存在内联设置的height则以内联样式为主 height: '100%', // defaultModel 编辑器初始化后的默认模式,一共有三种模式:1、双栏编辑预览模式;2、纯编辑模式;3、预览模式 // edit&preview: 双栏编辑预览模式 // editOnly: 纯编辑模式(没有预览,可通过toolbar切换成双栏或预览模式) // previewOnly: 预览模式(没有编辑框,toolbar只显示“返回编辑”按钮,可通过toolbar切换成编辑模式) defaultModel: 'edit&preview', // 粘贴时是否自动将html转成markdown convertWhenPaste: true, codemirror: { // 是否自动focus 默认为true autofocus: true } }, toolbars: { theme: 'dark', // light or dark showToolbar: true, // false:不展示顶部工具栏; true:展示工具栏; toolbars.showToolbar=false 与 toolbars.toolbar=false 等效 toolbar: ['bold', 'italic', { strikethrough: ['strikethrough', 'underline', 'sub', 'sup', 'ruby', 'customMenuAName'], }, 'size','|', 'color', 'header', '|','drawIo', '|', 'list', 'panel', 'justify', // 对齐方式,默认不推荐这么“复杂”的样式要求 'detail', '|', { insert: ['image', 'audio', 'video', 'link', 'hr', 'br', 'code', 'formula', 'toc', 'table', 'line-table', 'bar-table', 'pdf', 'word' ] }, 'graph', 'export', 'codeTheme', 'switchModel', 'togglePreview', // { // customMenuBName: ['ruby', 'audio', 'video', 'customMenuAName'], //实验室 // }, 'settings', 'customMenuCName', 'theme' ], toolbarRight: ['fullScreen', '|'], bubble: ['bold', 'italic', 'underline', 'strikethrough', 'sub', 'sup', 'quote', 'ruby', '|', 'size', 'color' ], // array or false "float": ['h1', 'h2', 'h3', '|', 'checklist', 'quote', 'quickTable', 'code'], // array or false sidebar: ['mobilePreview', 'theme'], // 'copy', customMenu: { customMenuAName: customMenuA, customMenuBName: customMenuB, customMenuCName: customMenuC, }, }, // 打开draw.io编辑页的url,如果为空则drawio按钮失效 drawioIframeUrl: window.location.origin + '/CherryMarkdown/drawio_demo.html', /** * 上传文件的时候用来指定文件类型 */ fileTypeLimitMap: { video: 'video/*', audio: 'audio/*', image: 'image/*', word: '.doc,.docx', pdf: '.pdf', file: '*', }, callback: { afterChange: this.afterChange, afterInit: this.afterInit, beforeImageMounted: this.beforeImageMounted, // 预览区域点击事件,previewer.enablePreviewerBubble = true 时生效 onClickPreview: this.onClickPreview, // 复制代码块代码时的回调 onCopyCode: this.onCopyCode, // 把中文变成拼音的回调,当然也可以把中文变成英文、英文变成中文 changeString2Pinyin: this.changeString2Pinyin }, // 预览页面不需要绑定事件 isPreviewOnly: false, // 预览区域跟随编辑器光标自动滚动 autoScrollByCursor: true, // 外层容器不存在时,是否强制输出到body上 forceAppend: true, // The locale Cherry is going to use. Locales live in /src/locales/ locale: 'zh_CN' }) }, // 上传通用接口 fileUpload(file) { var formData = new FormData() formData.append('file', file) var request = new XMLHttpRequest() // 图片上传路径修改为自己连接 request.open('POST', process.env.VUE_APP_BASE_API + '/common/upload') request.setRequestHeader('Authorization', "Bearer " + getToken()) request.onload = this.onloadCallback request.send(formData) }, onloadCallback(oEvent) { var currentTarget = oEvent.currentTarget if (currentTarget.status !== 200) { return this.$message({ type: 'error', message: currentTarget.status + ' ' + currentTarget.statusText }) } var resp = JSON.parse(currentTarget.response) let imgMdStr = '' if (resp.code !== 200) { return this.$message({ type: 'error', message: resp.msg }) } if (resp.code === 200) { if (/mp4|avi|rmvb/i.test(resp.fileSuffix)) { imgMdStr = `!video[${resp.fileOriginName}](${process.env.VUE_APP_BASE_API + resp.fileName})`; } else if (/mp3/i.test(resp.fileSuffix)) { imgMdStr = `!audio[${resp.fileOriginName}](${process.env.VUE_APP_BASE_API + resp.fileName})`; } else if (/bmp|gif|jpg|jpeg|png/i.test(resp.fileSuffix)) { imgMdStr = `![${resp.fileOriginName}](${process.env.VUE_APP_BASE_API + resp.fileName})` } else { imgMdStr = `[${resp.fileOriginName}](${process.env.VUE_APP_BASE_API + resp.fileName})` } } this.cherrInstance.insert(imgMdStr) }, // 全局的URL处理器 urlProcessor(url, srcType) { return url; }, // 变更事件回调 afterChange(text, html) { this.content = text this.$emit('mdChange', html, text) this.$emit('input', text) }, // 初始化事件回调 afterInit(e) {}, // 图片加载回调 beforeImageMounted(e, src) { return { [e]: src } }, // 预览区域点击事件 onClickPreview(event) {}, // 粘贴事件 onCopyCode(event, code) { // 阻止默认的粘贴事件 // return false; // 对复制内容进行额外处理 return code; }, // 获取中文的拼音 changeString2Pinyin(string) { /** * 推荐使用这个组件:https://github.com/liu11hao11/pinyin_js * * 可以在 ../scripts/pinyin/pinyin_dist.js 里直接引用 */ var pinyin = require("./pinyin/pinyin.js"); return pinyin.pinyin(string, " "); }, setMarkdown(content, keepCursor) { if (!this.cherrInstance) { // 未加载则重新初始化 this.initCherryMD(content) return } this.cherrInstance.setMarkdown(content) }, getCherryContent() { var result = this.cherrInstance.getMarkdown() // 获取markdown内容 return result }, getCherryHtml() { var result = this.cherrInstance.getHtml() return result }, getData() { var result = this.cherrInstance.getHtml() return result }, /** * type:{'pdf'|'img'} */ exportMD(type = 'pdf') { this.cherrInstance.export(type) }, /** * model{'edit&preview'|'editOnly'|'previewOnly'} */ switchModel(model) { if (this.isInit()) { this.cherrInstance.switchModel(model) } }, insert(content, isSelect = false, anchor = [], focus = true) { this.cherrInstance.insert(content, isSelect, anchor, focus) }, isInit() { if (this.cherrInstance) { return true } this.$message.warning('编辑器未初始化,请检查') return false }, } } </script> <style lang="scss" > // draw.io样式修改,不能加scoped否则不生效 .cherry-dialog { z-index: 9999 !important; .cherry-dialog-iframe { width: 100%; height: 100%; } } </style>
子组件目录结构
父组件使用
import CherryMarkdown from '@/xxx/xxxx/xxx' <CherryMarkdown ref="CherryMarkdown" v-if="form.contentType ==='2'" :height='400' v-model='form.contentMarkdown' ></CherryMarkdown>
示例项目:
本人的开源项目中已使用:
项目一: RuoYi-Vue-Blog: 基于RuoYi-Vue v3.8.0开发的博客网站 (gitee.com)
项目二: RuoYi-Vue-DocHub: 一个在线写作创作平台
问题总结:
问题一
子组件:
父组件:
在子组件里用$emit('mdChange', html, text)向父组件触发一个事件,父组件无法监听这个事件,加上监听就会输入不进去内容
ps:不知道是我写的有问题还是这玩意本身的bug,有知道的大神希望给指点一下。
解决方法:
子组件
父组件
在保存前调用子组件方法,获取html格式内容(数据库存两份内容,一个是Markdown格式,一个是转成html格式的内容)
问题二
编辑器内容无法置空(保存后再次新增,存在之前编辑的内容)
尝试过调用子组件往编辑器塞空内容的方法,会出现新问题于是放弃了。
解决方法:
使用 v-if 销毁组件,每次打开重新加载