本身動手寫一個支持公式和圖表的markdown 編輯器

背景

在公司寫週報時常常會用到markdown,而且曾經還爲了可以解決團隊每一個人寫完週報以後還要彙總的效率問題專門開發過一款內部使用的週報markdown編輯器,團隊成員能夠根據項目寫相應的週報,最後團隊主管能夠直接導出按照項目、人員進行自動化歸類彙總後的週報。後來由於種種緣由編輯器再也不維護,直到最近準備寫論文的的過程當中須要用到公式和甘特圖時想到目前不少開源的markdown的編輯器不支持公式和圖。做爲程序員,沒有的就得本身創造了,因此準備本身寫一個支持公式和圖的編輯器。css

準備

在開始寫代碼以前首先肯定了須要使用的工具:html

  1. 使用create-react-app快速搭建前端邏輯
  2. 編輯器使用monaco-editor
  3. markdown解析使用markdown-it
  4. 使用MathJax實現公式功能
  5. 使用mermaid實現流程圖、順序圖、甘特圖、餅圖等圖形功能

開始碼代碼

初始化項目

npx create-react-app mkdown
複製代碼

安裝依賴

cd mkdown
yarn add markdown-it mathjax@3 mermaid monaco-editor markdown-it-table-of-contents lodash antd
複製代碼

在編輯器中爲了減小markdown解析預覽的渲染次數使用了lodash的debounce函數,除了預覽功能也想提供本地存儲的功能,在本地存儲成功以後須要提示一個消息彈窗,這也是引入antd組件庫的緣由。前端

實現前端UI框架

編輯器的頁面結構如圖,頁面頂部由Menu組件提供一些菜單項,下面是主體部分Main組件。在主體部分將編輯器Edtor組件放在左邊,預覽Prview組件放在右邊。react

按照組件的拆分,首先在src文件夾下新建三個組件文件。git

cd src && touch {Menu,Main,Editor,Preview}.js 
複製代碼

在建立完文件以後分別在對應的文件中先實現基本的組件代碼以便在頁面中渲染出頁面組件的結構進行樣式的調整。程序員

Menu.jsgithub

import React from "react";

function Menu() {
  return (
    <div className="menu">menu</div>
  );
}

export default Menu;
複製代碼

Main.jscanvas

import React from "react";
import Preview from "./Preview";
import Editor from "./Editor";

function Main() {
  return (
    <div className="main"> <Editor /> <Preview /> </div>
  );
}

export default Main;
複製代碼

Editor.jsapi

import React from "react";

function Editor() {
  return (
    <div className="editor">editor</div>
  );
}

export default Editor;
複製代碼

Preview.js瀏覽器

import React from "react";

function Preview() {
  return (
    <div className="preview">preview</div>
  );
}

export default Preview;
複製代碼

index.js內容替換成

import React from 'react';
import ReactDOM from 'react-dom';
import Main from './Main';
import Menu from './Menu';
import './index.css';

ReactDOM.render(
  <React.StrictMode> <Menu /> <Main /> </React.StrictMode>, document.getElementById('root') ); 複製代碼

修改index.css的內容以添加一些框架的基本樣式。

html{
  --menu-height: 32px;
  font-size: 12px;
}

html,body,
#root{
  height: 100%;
  margin: 0;
  padding: 0;
  overflow: hidden;
}

.main{
  display: flex;
  flex-wrap: wrap;
  height: calc(100% - var(--menu-height));
  justify-content: space-between;
  position: fixed;
  top:var(--menu-height);
  width: 100%;
}
.menu{
  background: #232323;
  border-bottom: 1px solid #2E2E2E;
  box-sizing: border-box;
  color: #858585;
  font-size: 1rem;
  height: var(--menu-height);
  line-height: var(--menu-height);
  padding: 0 1.25rem;
  overflow: visible;
}

.editor{
  box-sizing: border-box;
  flex: 1;
  height: 100%;
  overflow:hidden;
  background: gray;
}

.preview {
  all: initial;
  flex: 1;
  height: 100%;
  margin: 0;
  overflow: auto;
  padding: 0;
}

複製代碼

最後執行yarn start以後能夠在瀏覽器中看到如圖的框架效果

接入編輯器

在實現markdown編輯器時,使用了新版react的hooks功能,替換Editor.js的內容以引入編輯器

import React, { useRef, useEffect } from 'react';
import { editor } from "monaco-editor";
import { debounce } from 'lodash';



function Editor(props) {
  const container = useRef(null);
  const { value, onChange } = props;
  useEffect(() => {
    const _editor =  editor.create(container.current, {
      value: value,
      language: "markdown",
      renderLineHighLight: "none",
      lineDecorationWidth: 0,
      lineNumbersLeft: 0,
      lineNumbersWidth: 0,
      fontSize: 20,
      lineNumbers: "on",
      automaticLayout: false,
      quickSuggestions: false,
      occurrencesHighlight: false,
      colorDecorators: false,
      wordWrap: true,
      theme: "vs-dark",
      minimap: {
        enabled: false
      }
    });
    const _model = _editor.getModel();
    _model.onDidChangeContent(debounce(() => {
      onChange(_model.getValue());
    }, 500));
  }, [value, onChange])
  return (
    <div className="editor" ref={container} />
  );
}

Editor.defaultProps = {
  value: '',
  onChange:() => { }
};

export default Editor;
複製代碼

Editor組件接收一個value參數以及onChange回調函數,當編輯器內容發生變化時使用了debounce減小onChange觸發的頻率以減小預覽的渲染頻次(預覽功能須要頻繁讀取DOM,後面會講到)。

實現預覽功能

在實現預覽功能以前須要對Main組件進行改造以接收來自Editor組件編輯器的內容而且傳遞給Preview組件。 修改內容如

import React, { useState } from "react";
import Preview from "./Preview";
import Editor from "./Editor";

function Main() {
  const [source, setSource] = useState('');
  function handleSourceChange(newSource) {
    setSource(newSource);
  }
  return (
    <div className="main">
      <Editor onChange={handleSourceChange}/>
      <Preview source={source}/>
    </div>
  );
}

export default Main;
複製代碼

實現markdown基本的預覽功能,在引入markdown-it的同時,爲了實現TOC的功能還須要引入markdown-it的插件markdown-it-table-of-contents,引入以後進行初始化配置

import MarkdownIt from "markdown-it";
import tocPlugin from "markdown-it-table-of-contents";
const md = new MarkdownIt({
  html: false,
  xhtmlOut: false,
  breaks: false,
  langPrefix: "language-",
  linkify: true,
  typographer: false,
  quotes: "「」‘’"
});
md.use(tocPlugin, { includeLevel: [2, 3], markerPattern: /^\[toc\]/im });

複製代碼

完整的Preview.js解析預覽基本功能的代碼

import React, { useRef, useEffect} from "react";
import MarkdownIt from "markdown-it";
import tocPlugin from "markdown-it-table-of-contents";

const md = new MarkdownIt({
  html: false,
  xhtmlOut: false,
  breaks: false,
  langPrefix: "language-",
  linkify: true,
  typographer: false,
  quotes: "「」‘’"
});
md.use(tocPlugin, { includeLevel: [2, 3], markerPattern: /^\[toc\]/im });


function Preview(props) {
  const { source } = props;
  const ele = useRef(null);
  useEffect(() => {
    ele.current.innerHTML = md.render(source || "");
  }, [source]);
  return (
    <div className="preview" ref={ele}/> ); } export default Preview; 複製代碼

當修改完以後一個基本的markdown編輯和預覽編輯器就完成了。能夠在瀏覽器裏測試一下以下圖的效果。

接下來咱們實現支持公式的功能,爲了方便配置,咱們建立一個單獨的mathjax配置文件,內容如

window.MathJax = {
  tex: {
    inlineMath: [
      ["$", "$"],
      ["\\(", "\\)"],
      ["$$", "$$"]
    ],
    displayMath: [
      ["$$", "$$"],
      ["\\[", "\\]"]
    ],
    processEscapes: true
  },
  options: {
    skipHtmlTags: ["script", "noscript", "style", "textarea", "pre", "code", "a"],
    ignoreHtmlClass: "editor",
    processHtmlClass: 'tex2jax_process'
  }
};

export default window.MathJax;
複製代碼

建立完配置項以後,在Preview.js中引入配置項以及mathjax。

配置項必須放在mathjax庫以前,這樣mathjax纔可以根據配置項正確初始化。因爲咱們須要mathjax只轉化咱們預覽組件中的內容,而在mathjax初始化時咱們的預覽DOM尚未初始化,因此須要在初始化以後更新mathjax的配置項,根據mathjax官網的文檔,一旦mathjax初始化以後再次修改配置項沒法更新生成的對象,可是能夠經過window.MathJax.startup.getComponents()從新按照新的配置生成對象。另外爲了只在組件初始化以後從新初始化mathjax一次,在預覽組件中進行了記錄。

完整代碼以下

import React, { useRef, useEffect, useState} from "react";
import MarkdownIt from "markdown-it";
import tocPlugin from "markdown-it-table-of-contents";
import "./mathjax";
import "mathjax/es5/tex-svg";

const md = new MarkdownIt({
  html: false,
  xhtmlOut: false,
  breaks: false,
  langPrefix: "language-",
  linkify: true,
  typographer: false,
  quotes: "「」‘’"
});
md.use(tocPlugin, { includeLevel: [2, 3], markerPattern: /^\[toc\]/im });


function Preview(props) {
  const [init, setInit] = useState(null);
  const { source } = props;
  const ele = useRef(null);
  useEffect(() => {
    if (!init) {
      window.MathJax.startup.elements = ele.current;
      window.MathJax.startup.getComponents();
      setInit(true);
    }
    ele.current.innerHTML = md.render(source || "");
    window.MathJax.typeset();
  }, [source, init]);
  return (
    <div className="preview" ref={ele}/> ); } export default Preview; 複製代碼

mathjax解析以後的公式默認爲單行居中,修改一下樣式改編成行內塊級元素便可。在index.css中添加

.preview mjx-container[jax="SVG"][display="true"]{
  display: inline-block;
}
複製代碼

在完成了公式功能的支持以後咱們接下來實現圖的功能。根據mermaid官網的API,在渲染圖的時候須要給圖指定一個臨時的DOM容易用於緩存生成的圖的DOM節點。 markdown-it在解析的時候會將

解析成

因此咱們須要在markdown-it渲染以後遍歷具備 .language-flow的全部DOM節點生成圖而後替換掉相應的DOM。修改的代碼如

最後完整的Preview.js的代碼

import React, { useRef, useEffect, useState} from "react";
import MarkdownIt from "markdown-it";
import tocPlugin from "markdown-it-table-of-contents";
import "./mathjax";
import "mathjax/es5/tex-svg";
import mermaid from "mermaid";

mermaid.initialize({ startOnLoad: true });

const md = new MarkdownIt({
  html: false,
  xhtmlOut: false,
  breaks: false,
  langPrefix: "language-",
  linkify: true,
  typographer: false,
  quotes: "「」‘’"
});
md.use(tocPlugin, { includeLevel: [2, 3], markerPattern: /^\[toc\]/im });


function Preview(props) {
  const [init, setInit] = useState(null);
  const { source } = props;
  const ele = useRef(null);
  let offcanvas = document.querySelector('#offcanvas');
  if (!offcanvas){
    offcanvas = document.createElement('div');
    offcanvas.setAttribute('id', 'offcanvas');
    document.body.appendChild(offcanvas);
  }
  useEffect(() => {
    if (!init) {
      window.MathJax.startup.elements = ele.current;
      window.MathJax.startup.getComponents();
      setInit(true);
    }
    ele.current.innerHTML = md.render(source || "");
    ele.current.querySelectorAll(".language-flow").forEach(($el, idx) => {
      mermaid.mermaidAPI.render(
        `chart-${idx}`,
        $el.textContent,
        (svgCode) => {
          $el.innerHTML = svgCode;
        },
        offcanvas
      );
    });
    window.MathJax.typeset();
  }, [source, init]);
  return (
    <div className="preview" ref={ele}/> ); } export default Preview; 複製代碼

實現的圖的功能

到如今爲止,基本的公式和圖以及markdown的解析功能都已經實現完畢,接下來須要作一些優化點以及實現保存的功能。

優化

解決更新圖和公式時預覽解析失敗致使頁面崩潰的問題

在更新圖表的定義信息時可能會形成圖和公式的渲染邏輯拋出異常致使頁面直接崩潰顯示空白的問題,爲了解決這個問題,將圖和公式的渲染邏輯放在try-catch中。

實現保存功能

  1. CMD+S保存功能 咱們將使用localStorage結合monaco自定義快捷鍵的功能實現。爲了可以在保存以後顯示給用戶保存成功的消息,還須要引入antd的message功能。實現保存的功能邏輯代碼主要在Editor.js中。在monaco中定義快捷鍵是經過editor.addCommand(KeyMod.CtrlCmd | KeyCode.KEY_S, callback)實現的。
import React, { useRef, useEffect } from 'react';
import { editor, KeyMod, KeyCode  } from "monaco-editor";
import { debounce } from 'lodash';
import message from 'antd/lib/message';
import 'antd/lib/message/style/index.css';

message.config({ top: 20, duration: 2, maxCount: 1 });

function Editor(props) {
  const container = useRef(null);
  const { value, onChange } = props;
  useEffect(() => {
    const _editor =  editor.create(container.current, {
      value: value,
      language: "markdown",
      renderLineHighLight: "none",
      lineDecorationWidth: 0,
      lineNumbersLeft: 0,
      lineNumbersWidth: 0,
      fontSize: 20,
      lineNumbers: "on",
      automaticLayout: false,
      quickSuggestions: false,
      occurrencesHighlight: false,
      colorDecorators: false,
      wordWrap: true,
      theme: "vs-dark",
      minimap: {
        enabled: false
      }
    });
    const _model = _editor.getModel();
    _model.onDidChangeContent(debounce(() => {
      onChange(_model.getValue());
    }, 500));
    _editor.addCommand(KeyMod.CtrlCmd | KeyCode.KEY_S, saveCache);
    function saveCache() {
      window.localStorage.setItem('cached', _model.getValue());
      message.info('Saved');
    }
  }, [value, onChange])
  return (
    <div className="editor" ref={container} /> ); } Editor.defaultProps = { value: '', onChange:() => { } }; export default Editor; 複製代碼

爲了可以減小編譯後的代碼量,在引入message組件時直接單獨引入了antd的message組件和樣式。實現保存功能,除了須要修改Editor.js以外還須要對Main.js進行一下改造以確保在刷新頁面以後可以還原爲上次保存的內容。

  1. 菜單保存功能

實現了編輯器快捷鍵保存的功能以後接下來要實現菜單保存的功能,菜單的邏輯在Menu.js中實現,爲了使得各個組件的邏輯獨立,菜單在Menu.js中只在點擊菜單時候使用postMessage發出命令消息,由須要處理消息的組件進行訂閱處理。在實現保存功能時,當用戶點擊菜單中的保存功能,Menu組件將發出保存的消息,保存功能由訂閱消息的"Editor"組件處理。

Menu.js

index.css

Editor.js

修改以後的菜單樣式效果

添加打印菜單

Menu.js 與保存菜單實現方式相似,須要在Menu.js中添加菜單項而後當點擊菜單項時發出相應的消息。

Preview.js 打印時主要打印的是預覽的內容,因此將打印的處理邏輯放在Preview.js中。執行window.print()時,若是點擊菜單馬上執行則會發現打印預覽的效果中會把菜單也包括在內,爲了解決這個問題須要當點擊菜單以後等待一段時間再執行。

index.css 在完成js的交互邏輯以後須要加入print的樣式以隱藏不須要打印的頁面內容,如編輯器、菜單。

解決瀏覽器縮放時編輯器大小不自適應的問題

到目前爲止,編輯器的基本功能已經實現完畢,可是在瀏覽器縮放時會發現編輯器大小不變化致使界面呈現不正常,爲了解決這個問題須要對瀏覽器的resize事件進行監聽,適時的修改編輯器的大小。

結束

至此想要實現的markdown編輯器已經完成,後續計劃實現粘貼上傳圖片、快捷插入markdown語法等功能。工具目前主要是出於解決我的寫markdown的需求,若是有須要的或者感興趣的能夠查看在線版本傳送門,發現問題也歡迎反饋。代碼已經上傳到github地址

相關文章
相關標籤/搜索