Take you 10 minutes to use a simple Markdown editor

Preface

Recently, I need to implement a markdown editor in the project , and it is based on the Reactframework, similar to Nuggets:

img

My first thought is definitely that if you can use excellent open source, you must use open source. After all, you can't always repeat the wheel. So I asked in my group where a lot of front-end group of friends, they gave thrown over a bunch of open source projects markdown editor, but I saw all based on Vueuse, does not meet my expectations, stroll a bit github, too I didn’t see a project that I’m satisfied with, so I wanted to implement one by myself

Need to realize the function

If we implement it ourselves, let's see which functions need to be supported. Because we are a simple editor of the first version, the functions will not be implemented too much, but it is definitely enough:

  • Markdown syntax analysis, and real-time rendering
  • markdown theme css style
  • Code block highlighting
  • Synchronous scrolling of pages in the "Edit Area" and "Display Area"
  • Implementation of Tools in Editor Toolbar

Here is the effect picture that I finally realized:

Final rendering

I also put the code of this article on the Github repository (opens new window) , welcome to order ⭐️ star to support

At the same time, I also provide you with an online experience address (opens new window) , because it is relatively rushed, everyone is welcome to give me comments and pr

Implementation

The specific implementation is also implemented one by one in the order of the functions listed above.

Note: This article explains in a step-by-step manner, so there may be a lot of code repetition. And the comment of each part is dedicated to explaining the code of that part, so when looking at the function code of each part, you only need to look at the comment part~

1. Layout

import React, {  } from 'react'


export default function MarkdownEdit() {


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

I will not 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:

Layout

Second, markdown syntax analysis

Next, you need to think about how to parse the grammar entered in the ``editing area''markdown into htmltags and finally render them in the ``display area''

Find a moment the relatively good markdownresolution of open source libraries, there are three commonly used, namely Marked, , Showdown, markdown-itand learn about the idea of other chiefs, to understand the advantages and disadvantages of the three libraries, the following comparison:

Library nameadvantageDisadvantage
MarkedGood performance, regular analysis (Chinese support is better)Poor scalability
ShowdownGood scalability, regular analysis (good Chinese support)Poor performance
markdown-itGood scalability and better performanceCharacter-by-character analysis (Chinese support is not good)

At the beginning I chose showdownthis library because it is very convenient to use, and the official has provided a lot of extended functions in the library, only need to configure some fields. But then I analyzed another wave and chose it markdown-it, because I may need to do more grammatical extensions later. showdownThe official document is relatively stiff, and markdown-itthere are many people who use it. The ecology is better, although the official does not support many extensions. Grammar, but there are already many makrdown-itfunction extension plug-ins based on it. The most important thing is markdown-itthat the official documents are well written (and there are Chinese documents)!

Next, write markdownthe code for grammar analysis (where steps 1, 2, and 3 represent the usage of the markdown-it library)

import React, { useState } from 'react'
// 1. 引入markdown-it库
import markdownIt from 'markdown-it'

// 2. 生成实例对象
const md = new markdownIt()

export default function MarkdownEdit() {
    const [htmlString, setHtmlString] = useState('')  // 存储解析后的html字符串

    // 3. 解析markdown语法
    const parse = (text: string) => setHtmlString(md.render(text));

    return (
        <div className="markdownEditConainer">
            <textarea 
                className="edit" 
                onChange={(e) => parse(e.target.value)} // 编辑区内容每次修改就更新变量htmlString的值
            />
            <div 
                className="show" 
                dangerouslySetInnerHTML={{ __html: htmlString }} // 将html字符串解析成真正的html标签
            />
        </div>
    )
}

For the html string into a real html tag operation, we use the provided React dangerouslySetInnerHTMLproperty, detailed usage can be seen React official document (opens new window)

At this point, a simple markdownsyntax analysis function is implemented, let’s take a look at the effect

Markdown syntax analysis effect display diagram

Both sides are indeed synchronizing updates, but... it looks like something is wrong! In fact, there is no problem, good parsed html字符串each label is shipped on a specific class name, but now we introduce any style files, such as the following figure

img

We can print the parsed to html字符串see what it looks like

<h1 id="">大标题</h1>
<blockquote>
  <p>本文来自公众号:前端印象</p>
</blockquote>
<pre><code class="js language-js">let name = '零一'
</code></pre>

Three, markdown theme style

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

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

import React, { useState } from 'react'
import './theme/github-theme.css'  // 引入github的markdown主题样式
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"  // 新增write的ID名 
                dangerouslySetInnerHTML={{ __html: htmlString }}
            />
        </div>
    )
}

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

Stylized markdown rendering renderings

Fourth, the code block is highlighted

The parsing of markdown syntax has been completed, and there are corresponding styles, but the code block does not seem to have a highlight style yet

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

highlight.js The default is to detect the syntax of all languages ​​it supports, we don't need to care about it, and it provides a lot of code highlighting themes, we can preview it on the official website, as shown in the following figure:

img

The greater good news is here! markdown-itIt has been highlight.jsintegrated, just set some configuration directly, and we need to download the library first. For details, please see markdown-it Chinese official website-highlight syntax configuration (opens new window)

At the same time highlight.js/styles/, there are many, many themes in the catalog , which can be imported by yourself

Next, let's implement 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'  // 引入highlight.js库
import 'highlight.js/styles/github.css'  // 引入github风格的代码高亮样式

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

Take a look at the rendering of code highlighting:

Code highlighting renderings

Five, synchronous scrolling

Another important function of the markdown editor is that when we scroll the contents of one area, another area will also be scrolled synchronously, so that it is convenient to view

Next, let's implement it. I will also list the pits I stepped on when I realized it, so that everyone is also impressed, so as not to make the same mistakes afterwards.

At the beginning, the main idea is to calculate the scroll ratio ( scrollTop / scrollHeight) when scrolling one area , and then make the ratio of the current scroll distance of the other area to the total scroll height equal to the scroll 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)  // 编辑区元素
    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) {
            // 改变展示区的滚动距离
            let { scrollHeight } = show.current
            show.current.scrollTop = scrollHeight * scale
        } else if(block === 2) {  // 当前滚动的是展示区
            // 改变编辑区的滚动距离
            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 made it. It does realize the synchronous scrolling of the two areas, but there are two bugs, let’s see which ones are.

bug1:

This is a very fatal bug. Let me bury a foreshadowing first, let's see the effect first:

The first version of synchronous scrolling renderings

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

Gone through the code and found that handleScrollthis method will trigger an infinite, assuming that will trigger it when we first edited manually scroll zone scrollmethod, which calls the handleScrollmethod, then it will go to change the scroll from the "display area", in which case will trigger the display area the scrollmethod, which calls the handleScrollmethod, then will go to change the scroll from the "edit area" ... and so on until the cycle, the figure will appear bug

Then I thought of a relatively simple solution is to use a variable to remember your current manual trigger is which area of rolling, so that you can handleScrolldistinguish between the scroll is passive or active trigger method in the Trigger

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: 编辑区主动触发滚动; 2: 展示区主动触发滚动
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 } = event.target
        let scale = scrollTop / scrollHeight  

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

    // 驱动一个元素进行滚动
    const driveScroll = (scale: number, el: HTMLElement) => {
        let { scrollHeight } = el
        el.scrollTop = scrollHeight * scale

        if(scrollTimer) clearTimeout(scrollTimer);
        scrollTimer = setTimeout(() => {
            scrolling = 0    // 在滚动结束后,将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>
    )
}

In this way, the above bugs are solved, and synchronous scrolling is also considered very good. The effect now is the same as the effect 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 considered a design problem, that is, the two areas have not yet fully realized synchronous scrolling. Let's take a look at the original design ideas first

img

The visual height of the editing area and the display area are the same, but after the content of the general editing area is rendered by markdown, the total scrolling height will be higher than the total scrolling height of the editing area, so we cannot make the two areas just by scrollTopsum scrollHeightSynchronous scrolling, rather obscure, let’s take a look at specific data

AttributesEdit areaDisplay area
clientHeight300300
scrollHeight500600

Suppose we now scroll to the very bottom of the editing area, then the time "Edit Area" scrollTopshall scrollHeight - clientHeight = 500 - 300 = 200, in accordance with the results we had calculated the proportion of rolling way scale = scrollTop / scrollHeight = 200 / 500 = 0.4, then the "Showcase" synchronized scrolling 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 is not yet, which is obviously not the effect we want.

Put it another thought, when we calculate the rolling percentage shall be calculated is the current scrollTopaccount for scrollTopthe maximum proportion, so that we can achieve synchronized scrolling, and still with a look at an example of just that: The Edit Scroll to the bottom, then scaleshould scrollTop / (scrollHeight - clientHeight) = 200 / (500 - 300) = 100%indicate the editing area scroll to the very bottom, then synchronized scrolling display area at the time, he scrollTopbecame scale * (scrollHeight - clientHeight) = 100% * (600 - 300) = 300, at this time the display area is also synchronized scrolling to the very bottom, thus achieving a truly synchronized 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)  // 改进后的计算滚动比例的方法

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

    // 驱动一个元素进行滚动
    const driveScroll = (scale: number, el: HTMLElement) => {
        let { scrollHeight, clientHeight } = el
        el.scrollTop = (scrollHeight - clientHeight) * scale  // 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 bugs have been solved, and the function of synchronous scrolling is also perfectly realized. But for the synchronous scrolling function, there are actually two concepts. One is that the two areas keep scrolling synchronously at the scroll height; the other is that the display area on the right scrolls corresponding to the content of the editing area on the left. What we are implementing now is the former, and the latter can be implemented as a new function later~

Six, the toolbar

Finally, we will 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 "bold" tool as an example. The rest can be imitated and written

The realization idea of ​​the bold tool:

  • Does the cursor select text?
  • Yes. Add on both sides of the selected text**
  • no. Add text at the cursor**加粗文字**

Animation effect demonstration:

Bold tool animation demo
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('')   // 编辑区的文字内容
    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)
        }
    }

    // 驱动一个元素进行滚动
    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)
    }

    // 加粗工具
    const addBlod = () => {
        // 获取编辑区光标的位置。未选中文字时:selectionStart === selectionEnd ;选中文字时:selectionStart < selectionEnd
        let { selectionStart, selectionEnd } = edit.current
        let newValue = selectionStart === selectionEnd
                        ? value.slice(0, start) + '**加粗文字**' + value.slice(end)
                        : value.slice(0, start) + '**' + value.slice(start, end) + '**' + value.slice(end)
        setValue(newValue)
    }

    useEffect(() => {
        // 编辑区内容改变,更新value的值,并同步渲染
        setHtmlString(md.render(value))
    }, [value])

    return (
        <div className="markdownEditConainer">
            <button onClick={addBlod}>加粗</button>   {/* 假设一个加粗的按钮 */}
            <textarea 
                className="edit" 
                ref={edit}
                onScroll={(e) => handleScroll(1, e)}
                onChange={(e) => setValue(e.target.value)}   // 直接修改value的值,useEffect会同步渲染展示区的内容
                value={value}
            />
            <div 
                className="show"
                id="write"
                ref={show}
                onScroll={(e) => handleScroll(2, e)}
                dangerouslySetInnerHTML={{ __html: htmlString }}
            />
        </div>
    )
}

With this kind of thinking, the realization of other various tools can be completed.

In the markdown-editor-reactjs (opens new window) that I have released , the implementation of other tools has been completed. If you want to see the code, you can go to the source code.

Seven, supplement

In order to ensure that the size of the package is small enough, I imported third-party dependent libraries , markdown themes , and code highlighting themes through external links.

8. Finally

A simple markdown editor is implemented, you can try to implement it manually. I will continue to post some tutorials in the future to expand the functions of this editor

I uploaded the code to the Github repository (opens new window) (hope you to order a ⭐️ star ), and expand the function later, and publish it to npm as a complete component for everyone to use. I hope everyone will support it~ (in fact, I have quietly Released, but because the function is not too complete, I won’t show it to everyone first. Here is a simple address of the npm package (opens new window) )