自己动手写一个支持公式和图表的markdown 编辑器

2,176 阅读10分钟

背景

在公司写周报时经常会用到markdown,并且曾经还为了能够解决团队每个人写完周报之后还要汇总的效率问题专门开发过一款内部使用的周报markdown编辑器,团队成员可以根据项目写相应的周报,最后团队主管可以直接导出按照项目、人员进行自动化归类汇总后的周报。后来因为种种原因编辑器不再维护,直到最近准备写论文的的过程中需要用到公式和甘特图时想到目前很多开源的markdown的编辑器不支持公式和图。作为程序员,没有的就得自己创造了,所以准备自己写一个支持公式和图的编辑器。

准备

在开始写代码之前首先确定了需要使用的工具:

  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组件放在右边。

按照组件的拆分,首先在src文件夹下新建三个组件文件。

cd src && touch {Menu,Main,Editor,Preview}.js 

在创建完文件之后分别在对应的文件中先实现基本的组件代码以便在页面中渲染出页面组件的结构进行样式的调整。

Menu.js

import React from "react";

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

export default Menu;

Main.js

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.js

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地址