本身动手写一个支持公式和图表的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地址

相关文章
相关标签/搜索