preface

Recently I needed to implement a MarkDown editor requirement in my project, and it was developed based on the React framework, like nugget:

My first thought is that if you can use good open source, you must use open source. After all, you can’t always reinvent the wheel. So I asked a lot of friends in my front end group, and they all threw a bunch of open source Markdown editor projects, but I saw that all of them were based on Vue, which didn’t meet my expectations. After a visit to Github, I didn’t see any projects I was satisfied with, so I decided to implement one myself

Functions that need to be implemented

If we implement it ourselves, see what functions need to support, because do a first version of the simple editor, so the function implementation will not be too much, but definitely enough:

  • Markdown syntax parsing and real-time rendering
  • Markdown Theme CSS style
  • Code blocks are highlighted
  • The pages in the Edit area and the Display area scroll synchronously
  • Implementation of tools in the editor toolbar

Here is my final rendering:

I’ve also put the code for this article up on Github. Click ⭐️ Star to support it

At the same time, I have provided you with an online experience (opens the new window), because it is a bit of a rush, welcome your comments and PR to me

The specific implementation

The implementation is also in the order of the functions listed above

Explanation: This article is presented in a step-by-step manner, so repeating the code may be a bit too much. The comments for each section are dedicated to explaining the code for that section, so when looking at the functional code for each section, you only need to look at the comments section

A, layout,

import React, {  } from 'react'


export default function MarkdownEdit() {


    return (
        <div className="markdownEditConainer">
            <textarea className="edit" />
            <div className="show" />
        </div>)}Copy the code

CSS style I will not list one by one, the whole is the left is the edit area, the right is the display area, the specific style is as follows:

2. Markdown syntax parsing

Next, you need to think about how to parse the MarkDown syntax entered in the “Edit area” into HTML tags and render them in the “display area”.

There are three popular open source markDown parsing libraries: Marked, Resit, and Markdown-it. They are Marked, Marked, and Markdown-it. The pros and cons of these libraries are as follows:

The library advantages disadvantages
Marked Good performance, regular parsing (Chinese support is better) Poor scalability
Showdown Good scalability, regular parsing (good Chinese support) Poor performance
markdown-it Good scalability and performance Character-by-character parsing (Poor Chinese support)

In the beginning, I chose The Haggis library because it is very easy to use and there are already many extensions in the library that just need to be configured with a few fields. However, after further analysis, I decided to use Markdown-it, because it may require more extensions. The official documents are more rigid, and Markdown-it is more accessible, even though it doesn’t officially support many extensions. But there are already a lot of extensions based on Markdown-it, and the most important thing is that markdown-it is well documented (and in Chinese)!

Let’s write the code for markDown parsing (where steps 1, 2, and 3 represent the use of the Markdown-it library)

import React, { useState } from 'react'
// 1. Introduce the Markdown-it library
import markdownIt from 'markdown-it'

// 2. Generate an instance object
const md = new markdownIt()

export default function MarkdownEdit() {
    const [htmlString, setHtmlString] = useState(' ')  // Store the parsed HTML string

    // 3. Parse the MarkDown syntax
    const parse = (text: string) = > setHtmlString(md.render(text));

    return (
        <div className="markdownEditConainer">
            <textarea 
                className="edit" 
                onChange={(e)= >Parse (e.t. value)} // update the value of the htmlString variable every time the contents of the edit area are changed /><div 
                className="show" 
                dangerouslySetInnerHTML={{ __html: htmlString}} / / willhtmlString parsing into realhtml/ > tag
        </div>)}Copy the code

To convert HTML strings to actual HTML tags, we use the dangerouslySetInnerHTML attribute provided by React. For more information, see the React official document.

At this point, a simple markDown syntax parsing function is implemented, and see what happens

Both sides are indeed updating synchronously, but….. It looks like something is wrong! This is fine, the parsed HTML string has a specific class name attached to each tag, but now we introduce any style file like the one below

Can we print the parsed HTML string and see what it looks like

<h1 id="">The headlines</h1>
<blockquote>
  <p>This article is from the official account: Front-end Impression</p>
</blockquote>
<pre><code class="js language-js">Let name = '01'</code></pre>
Copy the code

3. Markdown Theme style

Next we can go to the Internet to find some markDown theme style CSS files, for example I use the simplest Github theme markDown style. In addition, I recommend Typora Theme (opens new window), which has many markdown themes

Since my style theme is prefixed with ID write (most themes on Typora are also prefixed with #write), we add the class ID to the display TAB and introduce the style file

import React, { useState } from 'react'
import './theme/github-theme.css'  // Introduce github's MarkDown theme style
import markdownIt from 'markdown-it'

const md = new markdownIt()

export default function MarkdownEdit() {
    const [htmlString, setHtmlString] = useState(' ')

    const parse = (text: string) = > setHtmlString(md.render(text));

    return (
        <div className="markdownEditConainer">
            <textarea 
                className="edit" 
                onChange={(e)= > parse(e.target.value)} 
            />
            <div 
                className="show"
                id="write"/ / newwritetheIDThe namedangerouslySetInnerHTML={{ __html: htmlString}} / >
        </div>)}Copy the code

Take a look at the renderings after styling

Highlight code blocks

The parsing of the Markdown syntax is complete and has a corresponding style, but the code block doesn’t seem to have a highlighted style yet

It’s not possible to go from zero to one ourselves. Instead, you can use the open source library highlight.js, which detects code block tag elements and adds specific class names to them. Put up the API documentation for the library here.

Highlight.js by default detects the syntax of all the languages it supports, so we don’t need to worry about it, and it provides a lot of code highlighting themes that we can preview on the official website, as shown below:

Even better news! Markdown-it has already integrated highlight-js into it, so you can set up some configurations directly, and we need to download the library first. Markdown-it 中文 网 站 – highlights the syntax configuration (opens new window)

Also in the directory highlight.js/styles/ there are many, many themes that you can import yourself

Let’s implement code highlighting

import React, { useState, useEffect } from 'react'
import markdownIt from 'markdown-it'
import './theme/github-theme.css'
import hljs from 'highlight.js'  // Introduce the highlight.js library
import 'highlight.js/styles/github.css'  // Introduce github style code highlighting

const md = new markdownIt({
    // Set the code highlighting configuration
    highlight: function (code, language) {      
        if (language && hljs.getLanguage(language)) {
          try {
            return `<pre><code class="hljs language-${language}"> ` +
                   hljs.highlight(code, { language  }).value +
                   '</code></pre>';
          } catch(__) {}}return '<pre class="hljs"><code>' + md.utils.escapeHtml(code) + '</code></pre>'; }})export default function MarkdownEdit() {
    const [htmlString, setHtmlString] = useState(' ')

    const parse = (text: string) = > setHtmlString(md.render(text));

    return (
        <div className="markdownEditConainer">
            <textarea 
                className="edit" 
                onChange={(e)= > parse(e.target.value)} 
            />
            <div 
                className="show"
                id="write"
                dangerouslySetInnerHTML={{ __html: htmlString}} / >
        </div>)}Copy the code

Take a look at an illustration of the code highlighted:

Five, synchronous rolling

Another important feature of the MarkDown editor is that as we scroll through the contents of one area, another area will scroll synchronously, making it easy to view

So let’s do this, and I’m going to show you some of the craters that I stepped in, so that you don’t make the same mistake in the future

At the beginning, the main idea is to calculate the scrolling ratio (scrollTop/scrollHeight) when scrolling one of the regions, and then make the other area of the current scrolling distance in the total scrolling height of the ratio is equal to the scrolling ratio

import React, { useState, useRef, useEffect } from 'react'
import markdownIt from 'markdown-it'
import './theme/github-theme.css'  
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css' 

const md = new markdownIt({
    highlight: function (code, language) {      
        if (language && hljs.getLanguage(language)) {
          try {
            return `<pre><code class="hljs language-${language}"> ` +
                   hljs.highlight(code, { language  }).value +
                   '</code></pre>';
          } catch(__) {}}return '<pre class="hljs"><code>' + md.utils.escapeHtml(code) + '</code></pre>'; }})export default function MarkdownEdit() {
    const [htmlString, setHtmlString] = useState(' ')
    const edit = useRef(null)  // Edit the area element
    const show = useRef(null)  // Display area elements

    const parse = (text: string) = > setHtmlString(md.render(text));

    // Handle the scrolling events for the region
    const handleScroll = (block: number, event) = > {
        let { scrollHeight, scrollTop } = event.target
        let scale = scrollTop / scrollHeight  // Roll ratio

        // The current scroll is the edit area
        if(block === 1) {
            // Change the rolling distance of the display area
            let { scrollHeight } = show.current
            show.current.scrollTop = scrollHeight * scale
        } else if(block === 2) {  // The current scroll is the display area
            // Change the scrolling distance of the edit area
            let { scrollHeight } = edit.current
            edit.current.scrollTop = scrollHeight * scale
        }
    }

    return (
        <div className="markdownEditConainer">
            <textarea 
                className="edit" 
                ref={edit}
                onScroll={(e)= > handleScroll(1, e)}
                onChange={(e) => parse(e.target.value)} 
            />
            <div 
                className="show"
                id="write"
                ref={show}
                onScroll={(e)= > handleScroll(2, e)}
                dangerouslySetInnerHTML={{ __html: htmlString }}
            />
        </div>)}Copy the code

This is the first version when I did it, and it does implement synchronous scrolling between the two areas, but there are two bugs. Let’s see which two they are

Bug1:

This is a very deadly bug, first foiler, first look at the effect:

The synchronous scrolling effect was achieved, but it was obvious that when I stopped doing anything after the manual scrolling, the two areas still kept scrolling. Why?

Let’s say that when we manually scroll through the edit area once, it triggers the scroll method, which calls the handleScroll method, and then changes the scroll distance of the display area. At this time, the scroll method of the display area will be triggered, that is, the handleScroll method will be called, and then the scroll distance of the “edit area” will be changed…. And so on and so forth, until the bug in the diagram appears

A simpler solution I came up with is to use a variable to remember which area of scroll you are currently manually triggering, so that you can distinguish between passive and active scrolling in the handleScroll method

import React, { useState, useRef, useEffect } from 'react'
import markdownIt from 'markdown-it'
import './theme/github-theme.css'  
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'

const md = new markdownIt({
    highlight: function (code, language) {      
        if (language && hljs.getLanguage(language)) {
          try {
            return `<pre><code class="hljs language-${language}"> ` +
                   hljs.highlight(code, { language  }).value +
                   '</code></pre>';
          } catch(__) {}}return '<pre class="hljs"><code>' + md.utils.escapeHtml(code) + '</code></pre>'; }})let scrolling: 0 | 1 | 2 = 0  // 0: none; 1: The editing area actively triggers scrolling; 2: The display area actively triggers scrolling
let scrollTimer;  // End the rolling timer

export default function MarkdownEdit() {
    const [htmlString, setHtmlString] = useState(' ')
    const edit = useRef(null) 
    const show = useRef(null)  

    const parse = (text: string) = > setHtmlString(md.render(text));

    const handleScroll = (block: number, event) = > {
        let { scrollHeight, scrollTop } = event.target
        let scale = scrollTop / scrollHeight  

        if(block === 1) {
            if(scrolling === 0) scrolling = 1;  // Record the area where the active scrolling was triggered
            if(scrolling === 2) return;    // The current scroll is actively triggered by the "display area", so there is no need to drive the display area to scroll

            driveScroll(scale, showRef.current)  // Drive the scroll of the "display area"
        } else if(block === 2) {  
            if(scrolling === 0) scrolling = 2;
            if(scrolling === 1) return;    // The current scroll is triggered by the edit area, so there is no need to drive the edit area to scroll

            driveScroll(scale, editRef.current)
        }
    }

    // Drives an element to scroll
    const driveScroll = (scale: number, el: HTMLElement) = > {
        let { scrollHeight } = el
        el.scrollTop = scrollHeight * scale

        if(scrollTimer) clearTimeout(scrollTimer);
        scrollTimer = setTimeout(() = > {
            scrolling = 0    // Set scrolling to 0 after scrolling ends
            clearTimeout(scrollTimer)
        }, 200)}return (
        <div className="markdownEditConainer">
            <textarea 
                className="edit" 
                ref={edit}
                onScroll={(e)= > handleScroll(1, e)}
                onChange={(e) => parse(e.target.value)} 
            />
            <div 
                className="show"
                id="write"
                ref={show}
                onScroll={(e)= > handleScroll(2, e)}
                dangerouslySetInnerHTML={{ __html: htmlString }}
            />
        </div>)}Copy the code

This solves all the bugs mentioned above, and synchronous scrolling is a pretty good implementation. It now has the same effect as the image shown at the beginning of this article

Bug2:

A minor problem, not a bug but a design problem, is that the two areas don’t actually scroll in sync. Let’s look at the original design idea

The visual height of the editing area and the display area is the same, but generally after the content in the editing area is rendered by Markdown, the total scrolling height will be higher than the total scrolling height of the editing area. Therefore, we cannot make the two areas scroll synchronously just by using scrollTop and scrollHeight, which is relatively obscure. Let’s take a look at the specific data

attribute The editing zone exhibit
clientHeight 300 300
scrollHeight 500 600

If scrollTop = scrollheight-clientheight = 500-300 = 200 Scale = scrollTop/scrollHeight = 200/500 = 0.4 according to the original method of calculating the scroll scale, then after synchronous scrolling of the “display area”, ScrollTop = Scale * scrollHeight = 0.4 * 600 = 240 < 600-300 = 300. But the fact that the edit area is at the bottom and the display area is not there is obviously not what we want

In another way, we should calculate the ratio of the current scrollTop to the maximum scrollTop, so that we can achieve synchronous scrolling. At this time, the scale should be scrollTop/(scrollheight-Clientheight) = 200 / (500-300) = 100%, indicating that the edit area has been rolled to the bottom. ScrollTop: scale * (scrollheight-Clientheight) = 100% * (600-300) = 300

Take a look at the improved code

import React, { useState, useRef, useEffect } from 'react'
import markdownIt from 'markdown-it'
import './theme/github-theme.css'  
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'

const md = new markdownIt({
    highlight: function (code, language) {      
        if (language && hljs.getLanguage(language)) {
          try {
            return `<pre><code class="hljs language-${language}"> ` +
                   hljs.highlight(code, { language  }).value +
                   '</code></pre>';
          } catch(__) {}}return '<pre class="hljs"><code>' + md.utils.escapeHtml(code) + '</code></pre>'; }})let scrolling: 0 | 1 | 2 = 0  
let scrollTimer;  

export default function MarkdownEdit() {
    const [htmlString, setHtmlString] = useState(' ')
    const edit = useRef(null) 
    const show = useRef(null)  

    const parse = (text: string) = > setHtmlString(md.render(text));

    const handleScroll = (block: number, event) = > {
        let { scrollHeight, scrollTop, clientHeight } = event.target
        let scale = scrollTop / (scrollHeight - clientHeight)  // An improved method for calculating rolling proportions

        if(block === 1) {
            if(scrolling === 0) scrolling = 1;  
            if(scrolling === 2) return;    

            driveScroll(scale, showRef.current)  
        } else if(block === 2) {  
            if(scrolling === 0) scrolling = 2;
            if(scrolling === 1) return;    

            driveScroll(scale, editRef.current)
        }
    }

    // Drives an element to scroll
    const driveScroll = (scale: number, el: HTMLElement) = > {
        let { scrollHeight, clientHeight } = el
        el.scrollTop = (scrollHeight - clientHeight) * scale  // scrollTop with the same ratio to scroll

        if(scrollTimer) clearTimeout(scrollTimer);
        scrollTimer = setTimeout(() = > {
            scrolling = 0   
            clearTimeout(scrollTimer)
        }, 200)}return (
        <div className="markdownEditConainer">
            <textarea 
                className="edit" 
                ref={edit}
                onScroll={(e)= > handleScroll(1, e)}
                onChange={(e) => parse(e.target.value)} 
            />
            <div 
                className="show"
                id="write"
                ref={show}
                onScroll={(e)= > handleScroll(2, e)}
                dangerouslySetInnerHTML={{ __html: htmlString }}
            />
        </div>)}Copy the code

Both bugs have been resolved, and the synchronous scrolling feature is perfectly implemented. However, there are actually two concepts for synchronous scrolling. One is that the two regions keep synchronous scrolling on the scrolling height. The other is that the display area on the right will scroll over the content in the edit area on the left. We are implementing the former now, the latter can be implemented later as a new feature ~

Vi. Toolbar

Finally, we will implement the tools of the toolbar part of the editor (bold, italic, ordered list, etc.), because these tools are the same idea, we will take the “bold” tool as an example, the rest can be copied to write out

Bold tool implementation ideas:

  • Is the cursor selected?
    • Is. Add to both sides of the selected text支那
    • No. Adds text where the cursor is** Bold text **

GIF effect demonstration:

import React, { useState, useRef, useEffect } from 'react'
import markdownIt from 'markdown-it'
import './theme/github-theme.css'  
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'

const md = new markdownIt({
    highlight: function (code, language) {      
        if (language && hljs.getLanguage(language)) {
          try {
            return `<pre><code class="hljs language-${language}"> ` +
                   hljs.highlight(code, { language  }).value +
                   '</code></pre>';
          } catch(__) {}}return '<pre class="hljs"><code>' + md.utils.escapeHtml(code) + '</code></pre>'; }})let scrolling: 0 | 1 | 2 = 0  
let scrollTimer;  

export default function MarkdownEdit() {
    const [htmlString, setHtmlString] = useState(' ')
    const [value, setValue] = useState(' ')   // Edit the text in the area
    const edit = useRef(null) 
    const show = useRef(null)  

    const handleScroll = (block: number, event) = > {
        let { scrollHeight, scrollTop, clientHeight } = event.target
        let scale = scrollTop / (scrollHeight - clientHeight)  

        if(block === 1) {
            if(scrolling === 0) scrolling = 1;  
            if(scrolling === 2) return;    

            driveScroll(scale, showRef.current)  
        } else if(block === 2) {  
            if(scrolling === 0) scrolling = 2;
            if(scrolling === 1) return;    

            driveScroll(scale, editRef.current)
        }
    }

    // Drives an element to scroll
    const driveScroll = (scale: number, el: HTMLElement) = > {
        let { scrollHeight, clientHeight } = el
        el.scrollTop = (scrollHeight - clientHeight) * scale  

        if(scrollTimer) clearTimeout(scrollTimer);
        scrollTimer = setTimeout(() = > {
            scrolling = 0   
            clearTimeout(scrollTimer)
        }, 200)}// Bold tool
    const addBlod = () = > {
        // Get the position of the cursor in the edit area. SelectionStart === selectionEnd; Select text: selectionStart < selectionEnd
        let { selectionStart, selectionEnd } = edit.current
        let newValue = selectionStart === selectionEnd
                        ? value.slice(0, start) + '** Bold text **' + value.slice(end)
                        : value.slice(0, start) + '* *' + value.slice(start, end) + '* *' + value.slice(end)
        setValue(newValue)
    }

    useEffect(() = > {
        // Edit area content change, update the value of value, and synchronize the rendering
        setHtmlString(md.render(value))
    }, [value])

    return (
        <div className="markdownEditConainer">
            <button onClick={addBlod}>bold</button>{/* Assume a bold button */}<textarea 
                className="edit" 
                ref={edit}
                onScroll={(e)= >HandleScroll (1, e)} onChange={(e) => setValue(e.target.value)}<div 
                className="show"
                id="write"
                ref={show}
                onScroll={(e)= > handleScroll(2, e)}
                dangerouslySetInnerHTML={{ __html: htmlString }}
            />
        </div>)}Copy the code

With this approach, various other tools can be implemented.

The other tools have been implemented in markdown-Editor-Reactjs (opens new Window), which I’ve already released. If you want to see the code, check out the source code

Seven, supplement

To keep the package small, I imported third-party dependency libraries, Markdown themes, and code highlighting themes via an external link

Eight, the last

A simple version of the MarkDown editor is available, and you can try it manually. I’ll continue to post tutorials that extend the functionality of the editor

I have uploaded the code to the Github repository (hope you click ⭐️ star), and later to expand the function, and as a complete component released to NPM for you to use, I hope you can support ~ (in fact, I have quietly released, but the function is not quite perfect.) The address of the NPM package has been opened only after the opening of the new window.