手把手教你造一個基於React的markdown編輯器

前言

筆者在18年年底的時候接到一個開發任務——搭建一個AI項目的開放平臺,其中的產品文檔爲轉化爲HTML格式的markdown文檔。考慮到文檔的即時更新,將文檔信息作成了Ajax接口的形式。所以管理後臺只需將textarea表單的內容經過markdown解析器進行HTML格式轉化,而後將markdown內容和經轉化的HTML文檔都保存到數據庫便可。css

基本需求完成後,爲了更好的用戶體驗,考慮將經常使用的編輯功能添加進來。改進版不只支持了經常使用的文本編輯功能,還實現的UI界面的配置化,自定義語法解析器。本着造福伸手黨的目的,以及積累些開源經驗,筆者將該react 組件 react-markdown-editor-lite 進行了封裝改造,而且發佈到了開源社區。html

預覽

在線體驗 harrychen0506.github.io/react-markd…node

image

特色

  • 輕量、基於React
  • UI可配置, 如只顯示編輯區或預覽區
  • 支持自定義markdown語法解析器,語法高亮等
  • 支持經常使用的markdown編輯功能,如加粗,斜體等等...
  • 支持編輯區和預覽區同步滾動

開發心得

  • 文本編輯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

    • 項目的字體庫選擇了Font Awesome風格,而且只選取了項目所須要的一些圖標。
    • 編輯器的總體css都可經過全局覆蓋的形式進行自定義。目前暫時只支持灰色主題。
    • 編輯器的顯示區域包括菜單欄,編輯器,預覽區,工具欄,經過配置組件的config屬性,能夠選擇默認的展現區域。

Install

npm install react-markdown-editor-lite --save
複製代碼

Props

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

Example

'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…), 你的點贊將是我莫大的動力😊

相關文章
相關標籤/搜索