分享一个基于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> return foo<br>```', or: '<b>with tab / 4 spaces</b><br>....def whatever(foo):<br>.... 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~