Take you hand-in-hand for 10 minutes to create a simple Markdown editor

Posted by playaz on Thu, 03 Feb 2022 02:41:57 +0100

preface

Recently, I need to implement the requirement of a markdown editor in my project, which is developed based on the React framework, similar to Nuggets:

My first thought is that if you can use excellent open source, you must use open source. After all, you can't always build wheels repeatedly. So I asked a lot of friends in my front-end group. They all gave me a bunch of open-source markdown editor projects, but I saw that they were all based on Vue, which didn't meet my expectations. I visited github and didn't see any projects I was satisfied with, so I wanted to implement one myself

Functions to be realized

If we implement it ourselves, let's see what functions we need to support. Because we do a simple editor in the first version, we won't realize too many functions, but it's absolutely enough:

  • markdown syntax parsing and real-time rendering
  • markdown theme css Style
  • Code block highlighting
  • The pages of the editing area and the display area scroll synchronously
  • Implementation of tools in editor toolbar

Here are the renderings I finally achieved:

I also put the code of this article in Github warehouse (opens new window) Here we go. Welcome to order ⭐ star support

At the same time, I also provide you with a Address of online experience (opens new window) , because it was done in a hurry, you are welcome to give me comments and pr suggestions

Concrete implementation

The specific implementation is also implemented one by one according to the order of the functions listed above

Note: This article explains in a step-by-step way, so there may be a lot of repeated code. And the comments of each part are specially used to explain the code of this part, so when looking at the function code of each part, just look at the comments~

1, Layout

import React, {  } from 'react'


export default function MarkdownEdit() {


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

I won't list the css styles one by one. The whole is the editing area on the left and the display area on the right. The specific styles are as follows:

2, Parsing markdown syntax

Next, we need to think about how to parse the markdown syntax input in the "editing area" into html tags and finally render them in the "display area"

We searched the currently excellent open source libraries for markdown parsing. There are three commonly used libraries, namely Marked, Showdown and markdown it. We learned about the advantages and disadvantages of these three libraries by referring to the ideas of other leaders. The comparison is as follows:

Library nameadvantageshortcoming
MarkedGood performance, regular parsing (Chinese support is better)Poor scalability
ShowdownGood scalability and regular parsing (good Chinese support)Poor performance
markdown-itGood scalability and performanceCharacter by character parsing (poor Chinese support)

At the beginning, I chose the showdown library because it is very convenient to use, and the official has provided many extension functions in the library, which only need to configure some fields. But then I analyzed another wave and chose markdown it, because more syntax extensions may be needed later. The official documents of showdown are hard to write, and markdown it is used by many people and has a better ecology. Although its official does not support many extended syntax, there are many function extension plug-ins based on makrdown it, The most important thing is that the official document of markdown it is well written (and there are Chinese documents)!

Next, write the code for parsing markdown syntax (steps 1, 2 and 3 show the usage of markdown it Library)

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

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

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

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

    return (
        <div className="markdownEditConainer">
            <textarea 
                className="edit" 
                onChange={(e) => parse(e.target.value)} // The value of the variable htmlString is updated every time the content of the editing area is modified
            />
            <div 
                className="show" 
                dangerouslySetInnerHTML={{ __html: htmlString }} // Parse the html string into a real html tag
            />
        </div>
    )
}

For the operation of converting html strings into real html tags, we use the dangerouslySetInnerHTML attribute provided by React. You can see the detailed use React official document (opens new window)

At this point, a simple markdown syntax parsing function is realized. Let's see the effect

Both sides are indeed updating synchronously, but... It seems that something is wrong! In fact, there is no problem. Each tag of the parsed html string is attached with a specific class name, but now we introduce any style files, such as the following figure

We can print the parsed html string to see what it looks like

<h1 id="">Headline</h1>
<blockquote>
  <p>This article comes from the official account: the impression of the front end.</p>
</blockquote>
<pre><code class="js language-js">let name = 'zero or one'
</code></pre>

3, markdown theme style

Next, we can go to the Internet to find some markdown theme style css files. For example, I use the markdown style of the simplest Github theme. In addition, I still recommend it Typora Theme (opens new window) , there are many markdown topics on it

Because my style theme has a prefix id write (most theme prefixes on Typora are also #write), we add this kind of id to the label of the display area and introduce the style file

import React, { useState } from 'react'
import './theme/github-theme.css'  // Introducing the markdown theme style of github
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"  // ID name of new write 
                dangerouslySetInnerHTML={{ __html: htmlString }}
            />
        </div>
    )
}

Let's take a look at the rendering results after adding styles

4, Code block highlighting

The parsing of markdown syntax has been completed, and there are corresponding styles, but the code block doesn't seem to have highlighted styles

It is impossible for us to implement from 0 to 1 by ourselves. We can use the ready-made open source library highlight js, highlight.js official document (opens new window) , what this library can help you do is to detect the code block tag element and add a specific class name to it. Put this library here API documentation (opens new window)

highlight.js detects the syntax of all languages it supports by default, so we don't need to care about it, and it provides a lot of code highlight topics. We can preview them on the official website, as shown in the figure below:

The greater good news is coming! Markdown it has set highlight JS is integrated. Just set some configurations directly, and we need to download the library first. You can see the details Markdown it Chinese official website - highlighting syntax configuration (opens new window)

At the same time, in the directory highlight There are many themes under JS / styles /, which can be imported by yourself

Next, let's realize the function of code highlighting

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

const md = new markdownIt({
    // Set the configuration of the highlighted code
    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>
    )
}

Let's take a look at the effect picture of code highlighting:

5, Synchronous scrolling

Another important function of the markdown editor is that when we scroll the contents of one area, the other area will also scroll synchronously, which is convenient for viewing

Next, let's realize it. I'll list the pits I stepped on when I realized it, so as to impress everyone, so as not to make the same mistake in the future

At the beginning, the main implementation idea is to calculate the scrollTop / scrollHeight when rolling one area, and then make the current rolling distance of the other area equal to the proportion of the total rolling height

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 area element
    const show = useRef(null)  // Display area elements

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

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

        // Currently scrolling is the editing 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) {  // Currently scrolling is the display area
            // Change the scrolling distance of the editing 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>
    )
}

This is the first version when I did it. It does realize the synchronous scrolling of two areas, but there are two bug s. Let's see which two are

bug1:

This is a fatal bug. Let's bury the foreshadowing and see the effect first:

The effect of synchronous scrolling is realized, but it is obvious that when I manually scroll, I stop any operation, but the two areas are still scrolling. Why?

After checking the code, it is found that the handleScroll method will trigger indefinitely. Suppose that when we manually scroll the editing area once, the scroll method will be triggered, that is, the handleScroll method will be called, and then the scrolling distance of the "display area" will be changed. At this time, the scroll method of the display area will be triggered, that is, the handleScroll method will be called, Then I will change the scrolling distance of the "editing area"... In this way, I will go back and forth, and then the bug in the figure will appear

Later, I came up with a relatively simple solution, which is to use a variable to remember which area of scrolling you are currently manually triggered, so that you can distinguish whether the scrolling is passively triggered or actively triggered 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: Display area actively triggers scrolling
let scrollTimer;  // Timer to end scrolling

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 active scrolling is triggered
            if(scrolling === 2) return;    // Currently, the "exhibition area" is actively triggered to scroll, so there is no need to drive the exhibition area to scroll

            driveScroll(scale, showRef.current)  // Drive the scrolling of the display area
        } else if(block === 2) {  
            if(scrolling === 0) scrolling = 2;
            if(scrolling === 1) return;    // At present, the "editing area" actively triggers scrolling, so there is no need to drive the editing area to scroll

            driveScroll(scale, editRef.current)
        }
    }

    // Drive 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    // After scrolling, set scrolling to 0 to indicate the end of scrolling
            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>
    )
}

In this way, the above bug is solved, and the synchronous scrolling is well realized. Now the effect is the same as that in the picture shown at the beginning of the article

bug2:

There is still a small problem here, which is not a bug. It should be regarded as a problem of design thinking, that is, the two regions have not completely realized synchronous rolling. Let's first look at the original design idea

The visual height of the editing area is the same as that of the display area, but after the content of the editing area is rendered by markdown, the total rolling height of the editing area will be higher than that of the editing area. Therefore, we can't make the two areas roll synchronously only by scrollTop and scrollHeight. It's more obscure. Let's take a look at the specific data

attributeEdit areaExhibition area
clientHeight300300
scrollHeight500600

Suppose we scroll to the bottom of the editing area now, then the scrolltop of the "editing area" should be scrollHeight - clientHeight = 500 - 300 = 200. scale = scrollTop / scrollHeight = 200 / 500 = 0.4 is obtained according to the original way of calculating the scrolling proportion. After the "display area" is scrolled synchronously, scrollTop = scale * scrollHeight = 0.4 * 600 = 240 < 600 - 300 = 300. But the fact is that the editing area has scrolled to the bottom, while the display area has not. Obviously, it is not the effect we want

In another way, when calculating the scrolling ratio, we should calculate the proportion of the current scrollTop to the maximum value of scrollTop, so as to realize synchronous scrolling. Still use the example just now: when the editing area scrolls to the bottom, the scale should be scrollTop / (scrollHeight - clientHeight) = 200 / (500 - 300) = 100%, It means that the editing area scrolls to the bottom. When the display area scrolls synchronously, its scrollTop becomes scale * (scrollHeight - clientHeight) = 100% * (600 - 300) = 300. At this time, the display area also scrolls synchronously to the bottom, so as to realize the real synchronous scrolling

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)  // Improved method for calculating rolling proportion

        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)
        }
    }

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

        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>
    )
}

Both bug s have been solved, and the function of synchronous scrolling is perfectly realized. However, for the function of synchronous rolling, there are actually two concepts. One is that the two areas keep synchronous rolling at the rolling height; The other is that the display area on the right corresponds to the content of the editing area on the left. We are now implementing the former, and the latter can be implemented as a new function later~

6, Toolbar

Finally, let's implement the tools in the toolbar part of the editor (bold, italic, ordered list, etc.), because the implementation ideas of these tools are the same, let's take the tool "bold" as an example, and the rest can be imitated and written

Implementation idea of bold tool:

  • Does the cursor select text?
    • Yes. Add a line on both sides of the selected text**
    • No. Add text at the cursor * * bold text**

Dynamic picture 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('')   // Text content of editing 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)
        }
    }

    // Drive 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 = () => {
        // Gets the position of the cursor in the editing area. When no text is selected: selectionStart === selectionEnd; When text is selected: 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(() => {
        // Change the content of the editing area, update the value of value, and render synchronously
        setHtmlString(md.render(value))
    }, [value])

    return (
        <div className="markdownEditConainer">
            <button onClick={addBlod}>Bold</button>   {/* Suppose a bold button */}
            <textarea 
                className="edit" 
                ref={edit}
                onScroll={(e) => handleScroll(1, e)}
                onChange={(e) => setValue(e.target.value)}   // Directly modify the value of value, and useEffect will synchronously render the contents of the display area
                value={value}
            />
            <div 
                className="show"
                id="write"
                ref={show}
                onScroll={(e) => handleScroll(2, e)}
                dangerouslySetInnerHTML={{ __html: htmlString }}
            />
        </div>
    )
}

With this idea, we can complete the implementation of other tools.

In my published markdown-editor-reactjs (opens new window) The implementation of other tools has been completed. Those who want to see the code can go to the source code

7, Supplement

In order to ensure that the volume of the package is small enough, I imported the third-party dependency library, markdown theme and code highlight theme in the form of external chain

8, Finally

A simple version of the markdown editor is implemented. You can try it manually. In the future, I will continue to send some tutorials to expand the functions of this editor

I uploaded all the codes to Github warehouse (opens new window) (I hope you can order one.) ⭐ star), and then expand the function and publish it to npm as a complete component for everyone to use. I hope you can give more support ~ (in fact, I have quietly released it, but because the function is not perfect, I don't take it out for everyone to use first. Here's a simple example Address of npm package (opens new window))

Topics: Javascript Front-end React markdown