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 销毁组件,每次打开重新加载

posted @ 2023-05-30 10:50  Ning-  阅读(1321)  评论(6编辑  收藏  举报