分享一個基於Ace的Markdown編輯器

我認為的編輯器分成兩類,一種是分為左右兩邊實現即時渲染;一種是先寫語法,然後通過按鈕實現渲染。

其實即時渲染也不難,共同需要考慮的問題就是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編輯器內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: