分享一個基于Ace的Markdown編輯器
我認為的編輯器分成兩類,一種是分為左右兩邊實現(xiàn)即時渲染;一種是先寫語法,然后通過按鈕實現(xiàn)渲染。
其實即時渲染也不難,共同需要考慮的問題就是xss,因為渲染庫能自定義第三方的xss過濾(之前是通過設(shè)置來實現(xiàn),也就是本身自帶,不過在某個版本后被取消了),所以xss就用官方推薦的dompurify。即時渲染可以通過編輯器本身api實現(xiàn)文本變動監(jiān)聽來實現(xiàn),還有一個需要考慮的問題就是代碼與渲染區(qū)域的對應(yīng)。但因為這與我的需求相悖,在這里就不介紹了,相信小老板們都能輕松實現(xiàn)
統(tǒng)一慣例,我們來看看效果圖
上面的工具欄其實就是添加事件然后往光標(biāo)插入對應(yīng)的語句而已,emoji暫時沒有實現(xiàn),貌似需要第三方庫支持。
整體來說并沒有難點,只不過對于這些東西來說,要么是文檔分散講得不清楚,要么就是找不到什么文檔。要是真沒有文檔的話,或者官方簡陋的文檔,你可能真的想問候一下他,哈哈哈。這個時候一個能用的代碼就顯得尤為重要,盡管它可能沒什么注釋,但相信聰明的你肯定能理解其中的意思。話不多說,上代碼吧~
<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> 預(yù)覽 </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"> 標(biāo)題 1 (Ctrl+Alt+1) </div> <div class="header2-btn" :class="isHeader2Active" @click="insertHeader2Code"> 標(biāo)題 2 (Ctrl+Alt+2) </div> <div class="header3-btn" :class="isHeader3Active" @click="insertHeader3Code"> 標(biāo)題 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 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 環(huán)境中使用必須要導(dǎo)入 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. 即控制臺警告,比如%為轉(zhuǎn)義或者中文 // 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 rel="external nofollow" >Link</a>' },{ code: '', 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', // 不導(dǎo)入 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, // 最大行數(shù),超過會自動出現(xiàn)滾動條 minLines: 22, // 最小行數(shù),還未到最大行數(shù)時,編輯器會自動伸縮大小 fontSize: 14, // 編輯器內(nèi)字體大小 theme: this.themePath, // 默認設(shè)置的主題 mode: this.modePath, // 默認設(shè)置的語言模式 tabSize: 4, // 制表符設(shè)置為 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標(biāo)準的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//使用更為時髦的標(biāo)點,比如在引用語法中加入破折號。 }); // this.aceEditor.session.on('change', this.show); // let that = this; // this.aceEditor.commands.addCommand({ // name: '復(fù)制', // bindKey: {win: 'Ctrl-C', mac: 'Command-M'}, // exec: function(editor) { // that.$message.success("復(fù)制成功"); // } // }); // 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;//鼠標(biāo)手型 } .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;//鼠標(biāo)手型 } .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;//鼠標(biāo)手型 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,也就是編輯框里的內(nèi)容
<MarkdownEditor v-bind:value="''"></MarkdownEditor>
哦,對了,忘記講一些東西了。關(guān)于代碼塊高亮以及l(fā)atex渲染的問題。
高亮使用的是highlight.js,marked是支持這個庫的,直接使用就行,它能自動識別語言,要是不想調(diào)用那個函數(shù),你也可以自行判斷用戶會使用到的語言。主題的使用,需要引用包下style對應(yīng)的css。還有一個最重要的就是渲染的標(biāo)簽必須要有class為hljs的屬性,不然你只能看到代碼是高亮的。至于class屬性怎么添加,如果你沒有l(wèi)etax需求,那么只需要在渲染的時候套一層標(biāo)簽,它的class屬性是這個即可。
剩下的就是latex了,因為marked本身是不支持latex的,但是它支持重寫render函數(shù),通過這一方法來實現(xiàn)對latex的支持,在這里我使用的是katex,感興趣的小老板可以試試mathjax。不過有一個不太好的地方就是數(shù)學(xué)公式需要被代碼塊包住,即$a * b$
。不過這都不是大問題,能好好渲染才是王道。
好了,本次的分享就到此為止吧,see you again~
到此這篇關(guān)于基于Ace的Markdown編輯器的文章就介紹到這了,更多相關(guān)Ace Markdown編輯器內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
windows下vue-cli及webpack搭建安裝環(huán)境
這篇文章主要介紹了windows下vue-cli及webpack搭建安裝環(huán)境,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-04-04vue2.0+elementui實現(xiàn)一個上門取件時間組件
這篇文章主要給大家介紹了關(guān)于vue2.0+elementui實現(xiàn)一個上門取件時間組件的相關(guān)資料,用于預(yù)約上門服務(wù)時間 看到網(wǎng)上有很多亂七八糟的代碼,看著頭疼,于是自己寫了一個簡單的,需要的朋友可以參考下2024-02-02在vue項目中使用axios發(fā)送post請求出現(xiàn)400錯誤的解決
這篇文章主要介紹了在vue項目中使用axios發(fā)送post請求出現(xiàn)400錯誤的解決方案,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-09-09基于Vue el-autocomplete 實現(xiàn)類似百度搜索框功能
本文通過代碼給大家介紹了Vue el-autocomplete 實現(xiàn)類似百度搜索框功能,代碼簡單易懂,非常不錯,具有一定的參考借鑒價值,需要的朋友可以參考下2019-10-10vue?filters和directives訪問this的問題詳解
這篇文章主要介紹了vue?filters和directives訪問this的問題,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-01-01