分享一个基于Ace的Markdown编辑器

2022-07-21,,,

我认为的编辑器分成两类,一种是分为左右两边实现即时渲染;一种是先写语法,然后通过按钮实现渲染。

其实即时渲染也不难,共同需要考虑的问题就是xss,因为渲染库能自定义第三方的xss过滤(之前是通过设置来实现,也就是本身自带,不过在某个版本后被取消了),所以xss就用官方推荐的dompurify。即时渲染可以通过编辑器本身api实现文本变动监听来实现,还有一个需要考虑的问题就是代码与渲染区域的对应。但因为这与我的需求相悖,在这里就不介绍了,相信小老板们都能轻松实现

统一惯例,我们来看看效果图

上面的工具栏其实就是添加事件然后往光标插入对应的语句而已,emoji暂时没有实现,貌似需要第三方库支持。

整体来说并没有难点,只不过对于这些东西来说,要么是文档分散讲得不清楚,要么就是找不到什么文档。要是真没有文档的话,或者官方简陋的文档,你可能真的想问候一下他,哈哈哈。这个时候一个能用的代码就显得尤为重要,尽管它可能没什么注释,但相信聪明的你肯定能理解其中的意思。话不多说,上代码吧~

<template>
  <div>
    <div class="section-ace">
      <el-row>
        <el-col :span="6">
          <el-row>
            <el-col :span="12">
              <a class="editor-tab-content" :class="iseditactive"  @click="showedit">
                <i class="fa fa-pencil-square-o" aria-hidden="true"></i>
                编辑
              </a>
            </el-col>
            <el-col :span="12">
              <a class="preview-tab-content" :class="ispreviewactive" @click="showpreview">
                <i class="fa fa-eye" aria-hidden="true"></i>
                预览
              </a>
            </el-col>
          </el-row>
        </el-col>
        <el-col :push="8" :span="18">
          <el-row>
            <div class="toolbar">
              <el-col :span="1">
                <div>
                  <i @click="insertboldcode" class="fa fa-bold" aria-hidden="true"></i>
                </div>
              </el-col>
              <el-col :span="1">
                <div>
                  <i @click="insertitaliccode" class="fa fa-italic" aria-hidden="true"></i>
                </div>
              </el-col>
              <el-col :span="1">
                <div>
                  <i @click="insertminuscode" class="fa fa-minus" aria-hidden="true"></i>
                </div>
              </el-col>
              <el-col :span="1">
                <el-popover placement="bottom"
                            width="125"
                            transition="fade-in-linear"
                            trigger="click"
                            content="">
                  <i slot="reference" class="fa fa-header" aria-hidden="true"></i>
                  <div>
                    <div class="header1-btn" :class="isheader1active" @click="insertheader1code">
                      标题 1 (ctrl+alt+1)
                    </div>
                    <div class="header2-btn" :class="isheader2active" @click="insertheader2code">
                      标题 2 (ctrl+alt+2)
                    </div>
                    <div class="header3-btn" :class="isheader3active" @click="insertheader3code">
                      标题 3 (ctrl+alt+3)
                    </div>
                  </div>
                </el-popover>
              </el-col>
              <el-col :span="1">
                <el-popover placement="bottom"
                            width="125"
                            transition="fade-in-linear"
                            trigger="click"
                            content="">
                  <i slot="reference" class="fa fa-code" aria-hidden="true"></i>
                  <div>
                    <div class="text-btn" :class="istextactive" @click="inserttext">
                      文本 (ctrl+alt+p)
                    </div>
                    <div class="code-btn" :class="iscodeactive" @click="insertcode">
                      代码 (ctrl+alt+c)
                    </div>
                  </div>
                </el-popover>
              </el-col>
              <el-col :span="1">
                <div>
                  <i @click="insertquotecode" class="fa fa-quote-left" aria-hidden="true"></i>
                </div>
              </el-col>
              <el-col :span="1">
                <div>
                  <i @click="insertulcode" class="fa fa-list-ul" aria-hidden="true"></i>
                </div>
              </el-col>
              <el-col :span="1">
                <div>
                  <i @click="insertolcode" class="fa fa-list-ol" aria-hidden="true"></i>
                </div>
              </el-col>
              <el-col :span="1">
                <div>
                  <i @click="insertlinkcode" class="fa fa-link" aria-hidden="true"></i>
                </div>
              </el-col>
              <el-col :span="1">
                <div>
                  <i @click="insertimgcode" class="fa fa-picture-o" aria-hidden="true"></i>
                </div>
              </el-col>
              <el-col :span="1">
                <div>
                  <el-upload
                    class="upload-demo"
                    action="https://jsonplaceholder.typicode.com/posts/"
                    :limit="1">
                    <i class="fa fa-cloud-upload" aria-hidden="true"></i>
                  </el-upload>
                </div>
              </el-col>
              <el-col :span="1">
                <div>
                  <i @click="selectemoji" class="fa fa-smile-o" aria-hidden="true"></i>
                </div>
              </el-col>
              <el-col :span="1">
                <div>
                  <i @click="togglemaximize" class="fa fa-arrows-alt" aria-hidden="true"></i>
                </div>
              </el-col>
              <el-col :span="1">
                <i @click="togglehelp" class="fa fa-question-circle" aria-hidden="true"></i>
                <el-dialog :visible.sync="dialoghelpvisible"
                           :show-close="false"
                           top="5vh"
                           width="60%"
                           :append-to-body="true"
                           :close-on-press-escape="true">
                  <el-card class="box-card" style="margin: -60px -20px -30px -20px">
                    <div slot="header" class="helpheader">
                      <i class="fa fa-question-circle" aria-hidden="true"><span>markdown guide</span></i>
                    </div>
                    <p>this site is powered by markdown. for full documentation,
                      <a href="http://commonmark.org/help/" rel="external nofollow"  target="_blank">click here</a>
                    </p>
                    <el-table
                      :data="tabledata"
                      stripe
                      border
                      :highlight-current-row="true"
                      style="width: 100%">
                      <el-table-column
                        prop="code"
                        label="code"
                        width="150">
                        <template slot-scope="scope">
                          <p v-html='scope.row.code'></p>
                        </template>
                      </el-table-column>
                      <el-table-column
                        prop="or"
                        label="or"
                        width="180">
                        <template slot-scope="scope">
                          <p v-html='scope.row.or'></p>
                        </template>
                      </el-table-column>
                      <el-table-column
                        prop="devices"
                        label="linux/windows">
                      </el-table-column>
                      <el-table-column
                        prop="device"
                        label="mac os"
                        width="180">
                      </el-table-column>
                      <el-table-column
                        prop="showoff"
                        label="... to get"
                      width="200">
                        <template slot-scope="scope">
                          <p v-html='scope.row.showoff'></p>
                        </template>
                      </el-table-column>
                    </el-table>
                  </el-card>
                </el-dialog>
              </el-col>
            </div>
          </el-row>
        </el-col>
      </el-row>
    </div>
    <br>
    <div id="container">
      <div class="show-panel">
        <div ref="markdown" class="ace" v-show="!isshowpreview"></div>
        <div class="panel-preview" ref="preview" v-show="isshowpreview"></div>
      </div>
    </div>
  </div>
</template>
<script>
import ace from 'ace-builds'
// 在 webpack 环境中使用必须要导入
import 'ace-builds/webpack-resolver';
import marked  from 'marked'
import highlight from "highlight.js";
import "highlight.js/styles/foundation.css";
import katex from 'katex'
import 'katex/dist/katex.css'
import dompurify from 'dompurify';

const renderer = new marked.renderer();
function tohtml(text){
  let temp = document.createelement("div");
  temp.innerhtml = text;
  let output = temp.innertext || temp.textcontent;
  temp = null;
  return output;
}

function mathsexpression(expr) {
  if (expr.match(/^\$\$[\s\s]*\$\$$/)) {
    expr = expr.substr(2, expr.length - 4);
    return katex.rendertostring(expr, { displaymode: true });
  } else if (expr.match(/^\$[\s\s]*\$$/)) {
    expr = tohtml(expr); // temp solution
    expr = expr.substr(1, expr.length - 2);
    //does that mean your text is getting dynamically added to the page? if so, someone must be calling katex to render
    // it, and that call needs to have the strict flag set to false as well. 即控制台警告,比如%为转义或者中文
    // link: https://katex.org/docs/options.html
    return katex.rendertostring(expr, { displaymode: false , strict: false});
  }
}

const unchanged = new marked.renderer()
renderer.code = function(code, language, escaped) {
  console.log(language);
  const ismarkup = ['c++', 'cpp', 'golang', 'java', 'js', 'javascript', 'python'].includes(language);
  let hled = '';
  if (ismarkup) {
    const math = mathsexpression(code);
    if (math) {
      return math;
    } else {
      console.log("highlight");
      hled = highlight.highlight(language, code).value;
    }
  } else {
    console.log("highlightauto");
    hled = highlight.highlightauto(code).value;
  }
  return `<pre class="hljs ${language}"><code class="${language}">${hled}</code></pre>`;
  // return unchanged.code(code, language, escaped);
};
renderer.codespan = function(text) {
  const math = mathsexpression(text);
  if (math) {
    return math;
  }
  return unchanged.codespan(text);
};

export default {
  name: "abc",
  props: {
    value: {
      type: string,
      required: true
    }
  },
  data() {
    return {
      tabledata: [{
        code: ':emoji_name:',
        or: '—',
        devices: '—',
        device: '—',
        showoff: '🧡'
      },{
        code: '*italic*',
        or: '_italic_',
        devices: 'ctrl+i',
        device: 'command+i',
        showoff: '<em>italic</em>'
      },{
        code: '**bold**',
        or: '__bold__',
        devices: 'ctrl+b',
        device: 'command+b',
        showoff: '<em>bold</em>'
      },{
        code: '++underscores++',
        or: '—',
        devices: 'shift+u',
        device: 'option+u',
        showoff: '<ins>underscores</ins>'
      },{
        code: '~~strikethrough~~',
        or: '—',
        devices: 'shift+s',
        device: 'option+s',
        showoff: '<del>strikethrough</del>'
      },{
        code: '# heading 1',
        or: 'heading 1<br>=========',
        devices: 'ctrl+alt+1',
        device: 'command+option+1',
        showoff: '<h1>heading 1</h1>'
      },{
        code: '## heading 2',
        or: 'heading 2<br>-----------',
        devices: 'ctrl+alt+2',
        device: 'command+option+2',
        showoff: '<h2>heading 1</h2>'
      },{
        code: '[link](https://a.com)',
        or: '[link][1]<br>⁝<br>[1]: https://b.org',
        devices: 'ctrl+l',
        device: 'command+l',
        showoff: '<a href="https://commonmark.org/" rel="external nofollow" >link</a>'
      },{
        code: '![image](http://url/a.png)',
        or: '![image][1]<br>⁝<br>[1]: http://url/b.jpg',
        devices: 'ctrl+shift+i',
        device: 'command+option+i',
        showoff: '<img src="https://cdn.acwing.com/static/plugins/images/commonmark.png" width="36" height="36" alt="markdown">'
      },{
        code: '> blockquote',
        or: '—',
        devices: 'ctrl+q',
        device: 'command+q',
        showoff: '<blockquote><p>blockquote</p></blockquote>'
      },{
        code: 'a paragraph.<br><br>a paragraph after 1 blank line.',
        or: '—',
        devices: '—',
        device: '—',
        showoff: '<p>a paragraph.</p><p>a paragraph after 1 blank line.</p>'
      },{
        code: '<p>* list<br> * list<br> * list</p>',
        or: '<p> - list<br> - list<br> - list<br></p>',
        devices: 'ctrl+u',
        device: 'command+u',
        showoff: '<ul><li>list</li><li>list</li><li>list</li></ul>'
      },{
        code: '<p> 1. one<br> 2. two<br> 3. three</p>',
        or: '<p> 1) one<br> 2) two<br> 3) three</p>',
        devices: 'ctrl+shift+o',
        device: 'command+option+o',
        showoff: '<ol><li>one</li><li>two</li><li>three</li></ol>'
      },{
        code: 'horizontal rule<br><br>-----------',
        or: 'horizontal rule<br><br>***********',
        devices: 'ctrl+h',
        device: 'command+h',
        showoff: 'horizontal rule<hr>'
      },{
        code: '`inline code` with backticks',
        or: '—',
        devices: 'ctrl+alt+c',
        device: 'command+option+c',
        showoff: '<code>inline code</code>with backticks'
      },{
        code: '```<br> def whatever(foo):<br>&nbsp;&nbsp;&nbsp;&nbsp;return foo<br>```',
        or: '<b>with tab / 4 spaces</b><br>....def whatever(foo):<br>....&nbsp;&nbsp;&nbsp;&nbsp;return foo',
        devices: 'ctrl+alt+p',
        device: 'command+option+p',
        showoff: '<pre class="hljs"><code class=""><span class="hljs-function"><span class="hljs-keyword">def</span>' +
          '<span class="hljs-title">whatever</span><span class="hljs-params">(foo)</span></span>:\n' +
          '    <span class="hljs-keyword">return</span> foo</code></pre>'
      }],
      dialoghelpvisible: false,
      istextactive: '',
      iscodeactive: '',
      isheader1active: '',
      isheader2active: '',
      isheader3active: '',
      isshowpreview: false,
      iseditactive: "active",
      ispreviewactive: "",
      aceeditor: null,
      themepath: 'ace/theme/crimson_editor', // 不导入 webpack-resolver,该模块路径会报错
      modepath: 'ace/mode/markdown', // 同上
      codevalue: this.value || '',
    };
  },
  methods: {
    insertboldcode() {
      this.aceeditor.insert("****");
      let cursorposition = this.aceeditor.getcursorposition();
      this.aceeditor.movecursorto(cursorposition.row, cursorposition.column - 2);
    },
    insertitaliccode() {
      this.aceeditor.insert("__");
      let cursorposition = this.aceeditor.getcursorposition();
      this.aceeditor.movecursorto(cursorposition.row, cursorposition.column - 1);
    },
    insertminuscode() {
      let cursorposition = this.aceeditor.getcursorposition();
      this.aceeditor.insert("\n\n");
      this.aceeditor.insert("----------");
      this.aceeditor.insert("\n\n");
      this.aceeditor.gotoline(cursorposition.row + 5, cursorposition.column,true);
    },
    insertheader1code() {
      this.isheader2active = this.isheader3active = '';
      this.isheader1active = 'active';
      this.aceeditor.insert("\n\n");
      this.aceeditor.insert("#");
    },
    insertheader2code() {
      this.isheader1active = this.isheader3active = '';
      this.isheader2active = 'active';
      this.aceeditor.insert("\n\n");
      this.aceeditor.insert("##");
    },
    insertheader3code() {
      this.isheader1active = this.isheader2active = '';
      this.isheader3active = 'active';
      this.aceeditor.insert("\n\n");
      this.aceeditor.insert("###");
    },
    inserttext() {
      let cursorposition = this.aceeditor.getcursorposition();
      this.iscodeactive = '';
      this.istextactive = 'active';
      this.aceeditor.insert("```\n\n```");
      this.aceeditor.gotoline(cursorposition.row + 2, cursorposition.column,true);
    },
    insertcode() {
      let cursorposition = this.aceeditor.getcursorposition();
      this.istextactive = '';
      this.iscodeactive = 'active';
      this.aceeditor.insert("``");
      this.aceeditor.movecursorto(cursorposition.row, cursorposition.column + 1);
    },
    insertquotecode() {
      this.aceeditor.insert("\n>");
      let cursorposition = this.aceeditor.getcursorposition();
      this.aceeditor.movecursorto(cursorposition.row, cursorposition.column + 1);
    },
    insertulcode() {
      this.aceeditor.insert("\n*");
      let cursorposition = this.aceeditor.getcursorposition();
      this.aceeditor.movecursorto(cursorposition.row, cursorposition.column + 1);
    },
    insertolcode() {
      this.aceeditor.insert("\n1.");
      let cursorposition = this.aceeditor.getcursorposition();
      this.aceeditor.movecursorto(cursorposition.row, cursorposition.column + 1);
    },
    insertlinkcode() {
      this.aceeditor.insert("[]()");
      let cursorposition = this.aceeditor.getcursorposition();
      this.aceeditor.movecursorto(cursorposition.row, cursorposition.column - 3);
    },
    insertimgcode() {
      this.aceeditor.insert("![]()");
      let cursorposition = this.aceeditor.getcursorposition();
      this.aceeditor.movecursorto(cursorposition.row, cursorposition.column - 3);
    },
    uploadimg() {
      this.aceeditor.insert("![]()");
    },
    selectemoji() {
      this.aceeditor.insert("****");
    },
    togglemaximize() {
      this.aceeditor.insert("****");
    },
    togglehelp() {
      this.dialoghelpvisible = !this.dialoghelpvisible;
    },
    showedit() {
      this.$refs.preview.innerhtml = '';
      this.iseditactive = 'active';
      this.ispreviewactive = '';
      this.isshowpreview = false;
    },
    showpreview() {
      this.show();
      this.iseditactive = '';
      this.ispreviewactive = 'active';
      this.isshowpreview = true;
    },
    show(data) {
      let value = this.aceeditor.session.getvalue();
      this.$refs.preview.innerhtml = dompurify.sanitize(marked(value));
      console.log(dompurify.sanitize(marked(value)));
    },
  },
  mounted() {
    this.aceeditor = ace.edit(this.$refs.markdown,{
      selectionstyle: 'line', //选中样式
      maxlines: 1000, // 最大行数,超过会自动出现滚动条
      minlines: 22, // 最小行数,还未到最大行数时,编辑器会自动伸缩大小
      fontsize: 14, // 编辑器内字体大小
      theme: this.themepath, // 默认设置的主题
      mode: this.modepath, // 默认设置的语言模式
      tabsize: 4, // 制表符设置为 4 个空格大小
      readonly: false, //只读
      wrap: true,
      highlightactiveline: true,
      value: this.codevalue
    });
    marked.setoptions({
      renderer: renderer,
      // highlight: function (code) {
      //   return highlight.highlightauto(code).value;
      // },
      gfm: true,//默认为true。 允许 git hub标准的markdown.
      tables: true,//默认为true。 允许支持表格语法。该选项要求 gfm 为true。
      breaks: false,//默认为false。 允许回车换行。该选项要求 gfm 为true。
      pedantic: false,//默认为false。 尽可能地兼容 markdown.pl的晦涩部分。不纠正原始模型任何的不良行为和错误。
      // sanitize: false,//对输出进行过滤(清理) 不支持了,用sanitizer 或者直接渲染的时候过滤
      xhtml: true, // if true, emit self-closing html tags for void elements (<br/>, <img/>, etc.) with a "/" as required by xhtml.
      silent: true, //if true, the parser does not throw any exception.
      smartlists: true,
      smartypants: false//使用更为时髦的标点,比如在引用语法中加入破折号。
    });
    // this.aceeditor.session.on('change', this.show);
    // let that = this;
    // this.aceeditor.commands.addcommand({
    //   name: '复制',
    //   bindkey: {win: 'ctrl-c',  mac: 'command-m'},
    //   exec: function(editor) {
    //     that.$message.success("复制成功");
    //   }
    // });
    // this.aceeditor.commands.addcommand({
    //   name: '粘贴',
    //   bindkey: {win: 'ctrl-v',  mac: 'command-m'},
    //   exec: function(editor) {
    //     that.$message.success("粘贴成功");
    //   }
    // });
  },
  watch: {
    value(newval) {
      console.log(newval);
      this.aceeditor.setvalue(newval);
    }
  }
}
</script>

<style scoped lang="scss">
.toolbar {
  cursor: pointer;//鼠标手型
}
.show-panel {
  padding: 5px;
  border: 1px solid lightgray;
  .ace {
    position: relative !important;
    border-top: 1px solid lightgray;
    display: block;
    margin: auto;
    height: auto;
    width: 100%;
  }
  .panel-preview {
    padding: 1rem;
    margin: 0 0 0 0;
    width: auto;
    background-color: white;
  }
}

.editor-tab-content, .preview-tab-content, .header1-btn, .header2-btn, .header3-btn, .text-btn, .code-btn{
  border-bottom-color: transparent;
  border-bottom-style: solid;
  border-radius: 0;
  padding: .85714286em 1.14285714em 1.29999714em 1.14285714em;
  border-bottom-width: 2px;
  transition: color .1s ease;
  cursor: pointer;//鼠标手型
}

.header1-btn, .header2-btn, .header3-btn, .code-btn, .text-btn {
  font-size: 5px;
  padding: .78571429em 1.14285714em!important;
}

.active {
  background-color: transparent;
  box-shadow: none;
  border-color: #1b1c1d;
  font-weight: 700;
  color: rgba(0,0,0,.95);
}

.header1-btn:hover, .header2-btn:hover, .header3-btn:hover, .text-btn:hover, .code-btn:hover {
  cursor: pointer;//鼠标手型
  background: rgba(0,0,0,.05)!important;
  color: rgba(0,0,0,.95)!important;
}

.helpheader {
  font-size: 1.228571rem;
  line-height: 1.2857em;
  font-weight: 700;
  border-top-left-radius: .28571429rem;
  border-top-right-radius: .28571429rem;
  display: block;
  font-family: lato,'helvetica neue',arial,helvetica,sans-serif;
  background: #fff;
  box-shadow: none;
  color: rgba(0,0,0,.85);
}
</style>

这次的代码同样需要在引用时绑定value,也就是编辑框里的内容

<markdowneditor v-bind:value="''"></markdowneditor>

哦,对了,忘记讲一些东西了。关于代码块高亮以及latex渲染的问题。

高亮使用的是highlight.js,marked是支持这个库的,直接使用就行,它能自动识别语言,要是不想调用那个函数,你也可以自行判断用户会使用到的语言。主题的使用,需要引用包下style对应的css。还有一个最重要的就是渲染的标签必须要有class为hljs的属性,不然你只能看到代码是高亮的。至于class属性怎么添加,如果你没有letax需求,那么只需要在渲染的时候套一层标签,它的class属性是这个即可。

剩下的就是latex了,因为marked本身是不支持latex的,但是它支持重写render函数,通过这一方法来实现对latex的支持,在这里我使用的是katex,感兴趣的小老板可以试试mathjax。不过有一个不太好的地方就是数学公式需要被代码块包住,即$a * b$。不过这都不是大问题,能好好渲染才是王道。

好了,本次的分享就到此为止吧,see you again~

到此这篇关于基于ace的markdown编辑器的文章就介绍到这了,更多相关ace markdown编辑器内容请搜索以前的文章或继续浏览下面的相关文章希望大家以后多多支持!

《分享一个基于Ace的Markdown编辑器.doc》

下载本文的Word格式文档,以方便收藏与打印。