Build Your Own Markdown Editor With React.js and Codemirror 6

Hi suk here. This post explains how to create markdown editor/previewer with React.js / Codemirror 6. I’m writing this down because I had to learn how to build markdown editor for my project (Headless CMS with hackable markdown editor). Hope this post helps someone like me.

Goal of this project

We are going to create a simple markdown editor that works in your browser. If I write strings in markdown format in codemirror editor, React.js reads it, parses it, and displays preview.

Github Link: https://github.com/0xsuk/byome
Demo: https://0xsuk.github.io/byome

I recommend following this tutorial referencing the github code.
If you are fast learner, just go to github repo and try understand the code. Most of it should make sense, except for scroll sync part, which I’ll explain later.

GIF

Setup

We are going to use Create React App. I created a starter for this tutorial, which is just a create-react-app with some useless files deleted and some npm packages installed.

packages I’ve included:

  • @codemirror/state
  • @codemirror/view
  • @codemirror/language
  • @codemirror/lang-markdown
  • @lezer/highlight
  • unified
  • remark-parses
  • remark-rehype
  • rehype-react
  • remark-gfm

Seems a lot, but these packages are just an editor (codemirror) and a parser(unified, remark-*). I’ll explain each package of the list later.

You can clone my “setup” branch of Github repo to clone a starter for this project.

1git clone -b setup git@github.com/0xsuk/byome.git
2cd byome
3npm i
4npm start

It says Hello byome

Writing Code

Managing state

We are going to use useState to manage contents of markdown, namely doc.

1function App() {
2  const [doc, setDoc] = useState("# Hello byome");
3
4  return <div>{doc}</div>;
5}

Creating previewer

If doc changes, we want to read it, parse it and display preview of it.

We’re going to use npm package called unified.

unified is an interface for parsing, inspecting, transforming, and serializing content through syntax trees.

We parse doc using unified with some extensions, such as remark-parse, remark-rehype, remark-gfm, rehype-react.

As stated in https://github.com/unifiedjs/unified#description, unified consists of three parts: parser, transformer, and compiler.

In our case, we want to parse doc to markdown syntax tree first. So we use remark-parse as a parser. We parse doc to syntax tree, so that transformers such as remark-gfm can figure out what to do.

Second, we want additional functionality to our parser. There’s a remark plugin called remark-gfm for supporting GFM (autolink literals, footnotes, strikethrough, tables, tasklists), so we use this extension.

Third, we want to compile the syntax tree to React component. There’s a package called rehype-react, which reads rehype (HTML) syntax tree and compiles it into react component. However, rehype-react is only compatible with rehype syntax. So we transform remark (Markdown) syntax to rehype (HTML) syntax using transformer called remark-rehype, and we compile rehype syntax to React component.

All of the process stated above can be written in simple code.

1const md = unified()
2  .use(remarkParse)
3  .use(remarkGfm)
4  .use(remarkRehype)
5  .use(rehypeReact, { createElement, Fragment })
6  .processSync(doc).result;

At this point, App.jsx looks like this

 1import { useState, createElement, Fragment } from "react";
 2import "./App.css";
 3import { unified } from "unified";
 4import remarkParse from "remark-parse/lib";
 5import remarkGfm from "remark-gfm";
 6import remarkRehype from "remark-rehype";
 7import rehypeReact from "rehype-react/lib";
 8
 9function App() {
10  const [doc, setDoc] = useState("# Hello byome");
11
12  const md = unified()
13    .use(remarkParse)
14    .use(remarkGfm)
15    .use(remarkRehype)
16    .use(rehypeReact, { createElement, Fragment })
17    .processSync(doc).result;
18
19  return (
20    <div>
21      <div>{doc}</div>
22      <div>{md}</div>
23    </div>
24  );
25}
26
27export default App;

Whenever doc is updated, component is rerendered, generating new preview using unified.

And if we npm start, localhost:3000 shows

Seems it’s working!

“# Hello byome” is successfully parsed into <h1>Hello byome</h1>

Creating editor

We are goingt create a markdown editor using Codemirror 6.

Two major components of codemirror 6 is EditorState class and EditorView class. EditorState represents a state of editor, and EditorView wraps operation on state. The concept of state and view is explained in official document so take a look.

We create initial EditorState containing initial doc (“Hello byome”), as documented here https://codemirror.net/6/docs/ref/#state.EditorState^create.

 1const startState = EditorState.create({
 2  doc,
 3  extensions: [
 4    EditorView.updateListener.of((update) => {
 5      if (update.docChanged) {
 6        setDoc(update.state.doc.toString());
 7      }
 8    }),
 9  ],
10});

And we create new editor using EditorView class, as documented here https://codemirror.net/6/docs/ref/#view.EditorView.constructor

1new EditorView({
2  state: startState,
3  parent: ref.current,
4});

Where ref is a reference for editor DOM element.

We want to create new Editor only when ref gets attached, so we put them into useEffect

 1useEffect(() => {
 2  if (!ref.current) return;
 3  const startState = EditorState.create({
 4    doc,
 5    extensions: [
 6      EditorView.updateListener.of((update) => {
 7        if (update.changes) {
 8          setDoc(update.state.doc.toString());
 9        }
10      }),
11    ],
12  });
13
14  new EditorView({
15    state: startState,
16    parent: document.getElementById("editor"),
17  });
18}, [ref]);

I put them into useCodemirror.jsx so that App.jsx remains clean.

Now App.jsx looks like this

 1import { useState, createElement, Fragment } from "react";
 2import "./App.css";
 3import { unified } from "unified";
 4import remarkParse from "remark-parse/lib";
 5import remarkGfm from "remark-gfm";
 6import remarkRehype from "remark-rehype";
 7import rehypeReact from "rehype-react/lib";
 8import useCodemirror from "./useCodemirror";
 9
10function App() {
11  const [doc, setDoc] = useState("# Hello byome");
12  const [editorRef, editorView] = useCodemirror({ initialDoc: doc, setDoc });
13
14  const md = unified()
15    .use(remarkParse)
16    .use(remarkGfm)
17    .use(remarkRehype)
18    .use(rehypeReact, { createElement, Fragment })
19    .processSync(doc).result;
20
21  return (
22    <div>
23      <div ref={editorRef}></div>
24      <div>{md}</div>
25    </div>
26  );
27}
28
29export default App;

And useCodemirror.jsx

 1import { useRef, useState, useEffect } from "react";
 2import { EditorState } from "@codemirror/state";
 3import {  EditorView } from "@codemirror/view";
 4
 5function useCodemirror({ initialDoc, setDoc }) {
 6  const ref = useRef(null);
 7  const [view, setView] = useState(null);
 8
 9  useEffect(() => {
10    if (!ref.current) return;
11    const startState = EditorState.create({
12      doc: initialDoc,
13      contentHeight: "100%",
14      extensions: [
15        EditorView.updateListener.of((update) => {
16          if (update.docChanged) {
17            setDoc(update.state.doc.toString());
18          }
19        }),
20      ],
21    });
22
23    const view = new EditorView({
24      state: startState,
25      parent: ref.current,
26    });
27
28    setView(view);
29  }, [ref]);
30
31  return [ref, view];
32}
33
34export default useCodemirror;

And it works!

GIF

Extending Editor Functionality

From here we’re going to dive a little bit deeper into extending editor functionality.

adding lineNumber, Gutter, highlighting of active line & its gutter, markdown support, highlighting of headings, lineWrapping

 1import { useRef, useState, useEffect } from "react";
 2import { EditorState } from "@codemirror/state";
 3import {
 4  EditorView,
 5  lineNumbers,
 6  highlightActiveLine,
 7  highlightActiveLineGutter,
 8} from "@codemirror/view";
 9import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
10import { syntaxHighlighting, HighlightStyle } from "@codemirror/language";
11import { tags } from "@lezer/highlight";
12
13const markdownHighlighting = HighlightStyle.define([
14  { tag: tags.heading1, fontSize: "1.6em", fontWeight: "bold" },
15  {
16    tag: tags.heading2,
17    fontSize: "1.4em",
18    fontWeight: "bold",
19  },
20  {
21    tag: tags.heading3,
22    fontSize: "1.2em",
23    fontWeight: "bold",
24  },
25]);
26
27function useCodemirror({ initialDoc, setDoc }) {
28  const ref = useRef(null);
29  const [view, setView] = useState(null);
30
31  useEffect(() => {
32    if (!ref.current) return;
33    const startState = EditorState.create({
34      doc: initialDoc,
35      contentHeight: "100%",
36      extensions: [
37        lineNumbers(),
38        highlightActiveLine(),
39        highlightActiveLineGutter(),
40        markdown({
41          base: markdownLanguage, //Support GFM
42        }),
43        syntaxHighlighting(markdownHighlighting),
44        EditorView.lineWrapping,
45        EditorView.updateListener.of((update) => {
46          if (update.docChanged) {
47            setDoc(update.state.doc.toString());
48          }
49        }),
50      ],
51    });
52
53    const view = new EditorView({
54      state: startState,
55      parent: ref.current,
56    });
57
58    setView(view);
59  }, [ref]);
60
61  return [ref, view];
62}
63
64export default useCodemirror;

scroll sync

This is probably the most complicated part.
Before implementing scroll sync, take a glance at our App.css file because scrollSync is a matter of styling.
App.css

 1* {
 2  box-sizing: border-box;
 3  margin: 0;
 4}
 5  
 6
 7#root {
 8  height: 100vh;
 9  overflow: hidden;
10}
11
12#editor-wrapper {
13  height: 100%;
14  display: flex;
15}
16
17
18#markdown {
19  height: 100%;
20  flex: 0 0 50%;
21  padding: 0 12px 0 0;
22  overflow-y: auto;
23}
24#preview {
25  font-size: 14px; /*make it same as codemirror */
26  height: 100%;
27  flex: 0 0 50%;
28  padding: 0 0 0 12px;
29  border-left: solid 1px #ddd;
30  overflow-x: hidden;
31  overflow-y: auto;
32}
33
34#preview * {
35  overflow-x: auto;
36}

And here’s App.jsx

  1import { useState, createElement, Fragment, useRef } from "react";
  2import "./App.css";
  3import { unified } from "unified";
  4import remarkParse from "remark-parse/lib";
  5import remarkGfm from "remark-gfm";
  6import remarkRehype from "remark-rehype";
  7import rehypeReact from "rehype-react/lib";
  8import useCodemirror from "./useCodemirror";
  9import "github-markdown-css/github-markdown-light.css";
 10
 11let treeData;
 12
 13function App() {
 14  const [doc, setDoc] = useState("# Hello byome");
 15  const [editorRef, editorView] = useCodemirror({ initialDoc: doc, setDoc });
 16  const mouseIsOn = useRef(null);
 17
 18  const defaultPlugin = () => (tree) => {
 19    treeData = tree; //treeData length corresponds to previewer's childNodes length
 20    return tree;
 21  };
 22
 23  const markdownElem = document.getElementById("markdown");
 24  const previewElem = document.getElementById("preview");
 25
 26  const computeElemsOffsetTop = () => {
 27    let markdownChildNodesOffsetTopList = [];
 28    let previewChildNodesOffsetTopList = [];
 29
 30    treeData.children.forEach((child, index) => {
 31      if (child.type !== "element" || child.position === undefined) return;
 32
 33      const pos = child.position.start.offset;
 34      const lineInfo = editorView.lineBlockAt(pos);
 35      const offsetTop = lineInfo.top;
 36      markdownChildNodesOffsetTopList.push(offsetTop);
 37      previewChildNodesOffsetTopList.push(
 38        previewElem.childNodes[index].offsetTop -
 39          previewElem.getBoundingClientRect().top //offsetTop from the top of preview
 40      );
 41    });
 42
 43    return [markdownChildNodesOffsetTopList, previewChildNodesOffsetTopList];
 44  };
 45  const handleMdScroll = () => {
 46    console.log(mouseIsOn.current);
 47    if (mouseIsOn.current !== "markdown") {
 48      return;
 49    }
 50    const [markdownChildNodesOffsetTopList, previewChildNodesOffsetTopList] =
 51      computeElemsOffsetTop();
 52    let scrollElemIndex;
 53    for (let i = 0; markdownChildNodesOffsetTopList.length > i; i++) {
 54      if (markdownElem.scrollTop < markdownChildNodesOffsetTopList[i]) {
 55        scrollElemIndex = i - 1;
 56        break;
 57      }
 58    }
 59
 60    if (
 61      markdownElem.scrollTop >=
 62      markdownElem.scrollHeight - markdownElem.clientHeight //true when scroll reached the bottom
 63    ) {
 64      previewElem.scrollTop =
 65        previewElem.scrollHeight - previewElem.clientHeight; //scroll to the bottom
 66      return;
 67    }
 68
 69    if (scrollElemIndex >= 0) {
 70      let ratio =
 71        (markdownElem.scrollTop -
 72          markdownChildNodesOffsetTopList[scrollElemIndex]) /
 73        (markdownChildNodesOffsetTopList[scrollElemIndex + 1] -
 74          markdownChildNodesOffsetTopList[scrollElemIndex]);
 75      previewElem.scrollTop =
 76        ratio *
 77          (previewChildNodesOffsetTopList[scrollElemIndex + 1] -
 78            previewChildNodesOffsetTopList[scrollElemIndex]) +
 79        previewChildNodesOffsetTopList[scrollElemIndex];
 80    }
 81  };
 82
 83  const handlePreviewScroll = () => {
 84    if (mouseIsOn.current !== "preview") {
 85      return;
 86    }
 87    const [markdownChildNodesOffsetTopList, previewChildNodesOffsetTopList] =
 88      computeElemsOffsetTop();
 89    let scrollElemIndex;
 90    for (let i = 0; previewChildNodesOffsetTopList.length > i; i++) {
 91      if (previewElem.scrollTop < previewChildNodesOffsetTopList[i]) {
 92        scrollElemIndex = i - 1;
 93        break;
 94      }
 95    }
 96
 97    if (scrollElemIndex >= 0) {
 98      let ratio =
 99        (previewElem.scrollTop -
100          previewChildNodesOffsetTopList[scrollElemIndex]) /
101        (previewChildNodesOffsetTopList[scrollElemIndex + 1] -
102          previewChildNodesOffsetTopList[scrollElemIndex]);
103      markdownElem.scrollTop =
104        ratio *
105          (markdownChildNodesOffsetTopList[scrollElemIndex + 1] -
106            markdownChildNodesOffsetTopList[scrollElemIndex]) +
107        markdownChildNodesOffsetTopList[scrollElemIndex];
108    }
109  };
110
111  const md = unified()
112    .use(remarkParse)
113    .use(remarkGfm)
114    .use(remarkRehype)
115    .use(defaultPlugin)
116    .use(rehypeReact, { createElement, Fragment })
117    .processSync(doc).result;
118
119  return (
120    <>
121      <div id="editor-wrapper">
122        <div
123          id="markdown"
124          ref={editorRef}
125          onScroll={handleMdScroll}
126          onMouseEnter={() => (mouseIsOn.current = "markdown")}
127        ></div>
128        <div
129          id="preview"
130          className="markdown-body"
131          onScroll={handlePreviewScroll}
132          onMouseEnter={() => (mouseIsOn.current = "preview")}
133        >
134          {md}
135        </div>
136      </div>
137    </>
138  );
139}
140
141export default App;

If scroll on markdown div is invoked, handleMdScroll() is called.

computeElemsOffsetTop() computes offsetTop relative to markdown div’s top for each element of parsed markdown (child of treeData, try console.logging treeData to better understand).

If any parsed markdown element’s offsetTop is greater than scrollTop of markdown div, meaning the whole element is visible at the highest position in the visible area of editor, set scrollElemIndex to previous parsed markdown element (the one that is partially hidden above the visible area of editor).

Then set scrollTop of preview div to corresponding element’s offsetTop relative to preview div, with proper additional value.

And vice versa for handling preview scroll.

Now our markdown editor finally looks like this

GIF

pretty neat right?

Comments