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 (a desktop based headless CMS for markdown blogs). 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.
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!
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
pretty neat right?
Comments