分享一個基於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="tqIFpEPVRrue"></i> <div> <div class="header1-btn" :class="isHeader1Active" @click="insertHeader1Codehttp://www.cppcns.com"> 標題 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://onplaceholder.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="/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."; 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 mustbe 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','','js',''].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,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++',devices: 'Shift+U',device: 'Option+U',showOff: '<ins>Underscores</ins>' },{ code: '~~Strikethrough~~',devices: 'Shift+S',device: 'Option+S',showOff: '<del>Strikethrough</del>' },{ code: '# HeadiqIFpEPVRng 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',devices: 'Ctrl+Q',device: 'Command+Q',showOff: '<blockquote><p>Blockquote</p></blockquote>' },{ code: 'A paragraph.<br><br>A paragraph after 1 blank line.',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',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: 'with tab / 4 spaces<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,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,insertUlCode() { this.aceEditor.insert("\n*"); let cursorPosition = this.aceEditor.getCursorPosition(); this.aceEditor.moveCursorTo(cursorPosition.row,insertOlCode() { this.aceEditor.insert("\n1."); let cursorPosition = this.aceEditor.getCursorPosition(); this.aceEditor.moveCursorTo(cursorPosition.row,insertLinkCode() { this.aceEditor.insert("[]()"); let cursorPosition = this.aceEditor.getCursorPosition(); this.aceEditor.moveCursorTo(cursorPosition.rwww.cppcns.comow,cursorPosition.column - 3); },insertImgCode() { this.aceEditor.insert("![]()"); let cursorPosition = this.aceEditor.getCursorPosition(); this.aceEditor.moveCursorTo(cursorPosition.row,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',// 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,.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,.95); } .header1-btn:hover,.header2-btn:hover,.header3-btn:hover,.text-btn:hover,.code-btn:hover { cursor: pointer;//滑鼠手型 background: rgba(0,.05)!important; color: rgba(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,.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編輯器內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!