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 **
- Is. Add to both sides of the selected 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.