筆者在18年年底的時候接到一個開發任務——搭建一個AI項目的開放平臺,其中的產品文檔爲轉化爲HTML格式的markdown文檔。考慮到文檔的即時更新,將文檔信息作成了Ajax接口的形式。所以管理後臺只需將textarea表單的內容經過markdown解析器進行HTML格式轉化,而後將markdown內容和經轉化的HTML文檔都保存到數據庫便可。css
基本需求完成後,爲了更好的用戶體驗,考慮將經常使用的編輯功能添加進來。改進版不只支持了經常使用的文本編輯功能,還實現的UI界面的配置化,自定義語法解析器。本着造福伸手黨的目的,以及積累些開源經驗,筆者將該react 組件 react-markdown-editor-lite 進行了封裝改造,而且發佈到了開源社區。html
在線體驗 harrychen0506.github.io/react-markd…node
文本編輯react
大多數常見的編輯器,包括富文本編輯器,利用了某些元素如div的contenteditable屬性,配合selection、range、execCommand等API,實現了富文本編輯功能。這裏面的實現比較複雜,因此有了"爲何都說富文本編輯器是天坑?"這個說法。git
而markdown編輯器,核心的處理內容爲簡單語法的純文本,複雜度相對來講比較低,而且input標籤自帶onSelect事件,能夠很方便的獲取選擇信息(選擇起始位置和選擇文本值),所以要想實現編輯功能,只需將要改動的內容進行文本轉換,而後進行從新拼接首尾,大功告成。github
markdown解析數據庫
考察了幾個社區流行的markdown解析器,比較流行的有markdown, markdown-it, marked 等等。綜合考慮擴展性以及穩定性,筆者推薦使用markdown-it做爲markdown的詞法解析器,由於該解析器有比較多的插件,而且支持語法高亮。npm
同步滾動api
當選擇分欄編輯的時候,滾動左側的編輯區,右側的預覽區能自動滾動到對應的區域。方案參考了《手把手教你用 100行代碼實現基於 react的 markdown 輸入 + 即時預覽在線編輯器(一)》。只需先計算出輸入框容器元素與預覽框容器元素之間最大scroll範圍的比例值,而後根據主動滾動元素自身的scrollTop作相應的比例換算,便可知道對方區域的scrollTop值。bash
關於UI
npm install react-markdown-editor-lite --save
複製代碼
Property | Description | Type | default | Remarks |
---|---|---|---|---|
value | markdown content | String | '' | required |
style | component container style | Object | {height: '100%'} | not required |
config | component config | Object | {view: {...}, logger: {...}} | not required |
config.view | component UI | Object | {menu: true, md: true, html: true} | |
config.htmlClass | Html section class attribute | String | <Empty string> |
|
config.markdownClass | Markdown section class attribute | String | <Empty string> |
|
config.imageUrl | default image url | String | '' | |
config.linkUrl | default link url | String | '' | |
config.table | table maximum value of row and column | Object | {maxRow: 4, maxCol: 6} | |
config.logger | logger in order to undo or redo | Object | {interval: 3000} | |
config.synchScroll | Does it support synch scroll? | Boolean | true | |
config.imageAccept | Accept image extensions, such as .jpg,.png |
String | <Empty string> |
|
onChange | emitting when editor has changed | Function | ({html, md}) => {} | not required |
onImageUpload | when image uploaded, callback emitting will get image markdown text | (file: File, callback: (url: string) => void) => void; | ({file, callback}) => {} | not required |
renderHTML | Render markdown text to HTML. You can return either string, function or Promise | (text: string) => string|function|Promise | none | required |
'use strict';
import React from 'react'
import ReactDOM from 'react-dom'
import MdEditor from 'react-markdown-editor-lite'
import MarkdownIt from 'markdown-it'
import emoji from 'markdown-it-emoji'
import subscript from 'markdown-it-sub'
import superscript from 'markdown-it-sup'
import footnote from 'markdown-it-footnote'
import deflist from 'markdown-it-deflist'
import abbreviation from 'markdown-it-abbr'
import insert from 'markdown-it-ins'
import mark from 'markdown-it-mark'
import tasklists from 'markdown-it-task-lists'
import hljs from 'highlight.js'
import 'highlight.js/styles/atom-one-light.css'
// import 'highlight.js/styles/github.css'
import './index.less';
const MOCK_DATA = "Hello.\n\n * This is markdown.\n * It is fun\n * Love it or leave it."
export default class Demo extends React.Component {
mdEditor = null
mdParser = null
constructor(props) {
super(props)
// initial a parser
this.mdParser = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(lang, str).value
} catch (__) {}
}
return '' // use external default escaping
}
})
.use(emoji)
.use(subscript)
.use(superscript)
.use(footnote)
.use(deflist)
.use(abbreviation)
.use(insert)
.use(mark)
.use(tasklists, { enabled: this.taskLists })
this.renderHTML = this.renderHTML.bind(this)
}
handleEditorChange({html, md}) {
console.log('handleEditorChange', html, md)
}
handleImageUpload(file, callback) {
const reader = new FileReader()
reader.onload = () => {
const convertBase64UrlToBlob = (urlData) => {
let arr = urlData.split(','), mime = arr[0].match(/:(.*?);/)[1]
let bstr = atob(arr[1])
let n = bstr.length
let u8arr = new Uint8Array(n)
while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}
return new Blob([u8arr], {type:mime})
}
const blob = convertBase64UrlToBlob(reader.result)
setTimeout(() => {
// setTimeout 模擬異步上傳圖片
// 當異步上傳獲取圖片地址後,執行calback回調(參數爲imageUrl字符串),便可將圖片地址寫入markdown
callback('https://avatars0.githubusercontent.com/u/21263805?s=40&v=4')
}, 1000)
}
reader.readAsDataURL(file)
}
renderHTML(text) {
// 模擬異步渲染Markdown
return new Promise((resolve) => {
setTimeout(() => {
resolve(this.mdParser.render(text))
}, 1000)
})
}
handleGetMdValue = () => {
this.mdEditor && alert(this.mdEditor.getMdValue())
}
handleGetHtmlValue = () => {
this.mdEditor && alert(this.mdEditor.getHtmlValue())
}
render() {
return (
<div> <nav> <button onClick={this.handleGetMdValue} >getMdValue</button> <button onClick={this.handleGetHtmlValue} >getHtmlValue</button> </nav> <section style="height: 500px"> <MdEditor ref={node => this.mdEditor = node} value={MOCK_DATA} style={{height: '400px'}} renderHTML={this.renderHTML} config={{ view: { menu: true, md: true, html: true }, imageUrl: 'https://octodex.github.com/images/minion.png' }} onChange={this.handleEditorChange} onImageUpload={this.handleImageUpload} /> </section> </div> ) } } 複製代碼
歡迎你們使用和反饋,項目地址 (github.com/HarryChen05…), 你的點贊將是我莫大的動力😊