Rich text has been out of date for a long time. For the record, the main editors are as follows

  • TinyMCE
  • CKEditor
  • SlateJS
  • EditorJS
  • Quill
  • DraftJS
  • wangEditor

How did the editor evolve? How did the open-source rich text editor evolve

Now let’s divide the levels so that we have a clear understanding of the levels

classification advantage disadvantage
L0 Fast iteration based on native browser support Not easy to extend, very difficult to customize, everything depends on browser support, fast development
L1 Custom data structure, basically meet 99% of the requirements Easy to expand, but part of the defects, not easy to rapid development
L2 Fully self-developed, extensible, customizable (Office, Google Docs) Difficult to develop

So how do you implement an editor at L1 level

What’s important about rich text in the first place, you can’t get around Selection and Range, right

The l0-level editor relies on the Document. execCommand method, which basically eliminates the need for selection, and the browser provides a full API for even the most basic undo and redo functions. However, it is difficult to customize some functions. Like you want to change the font (fontsize command) several default font size, but I have supported a few native command so = = | | and other anomalies are not analyzed. If you just need simple functionality, you can use this

L1 level editor, need data to do the corresponding mapping, generally MVC structure or traditional DOM processing, not to mention here

But how to write a L1 editor, can refer to the L1 editor, but not copy

Therefore, to sum up, it is appropriate to clarify Selection and Range first

On the Range

Range is actually the cursor, so when you fold it up, it’s the cursor, and when you expand it, it’s a selection with the starting coordinates and so on, and that’s the blue area on the page that’s selected, that’s the Range

It doesn’t matter if you don’t understand the picture, what is endOffset and startOffset? It represents the starting position of the selected subscript and the end index value of the text. We can do a test

Collapsed: indicates that the cursor expands or collapses. False indicates that the cursor expands and true indicates that the cursor is in the collapsed state

const selection = window.getSelection()
const range = selection.getRangeAt(0)

const { startOffset, endOffset, collapsed } = range

// Collapsed is the area where the cursor expands
if (collapsed === false) {}Copy the code

Of course, this is just a small example, but I’m going to show you some more complex range, the expanded cursor we’re going to use [] to represent, look at HTML

Simple Scenario 1

<div>
    <span>I am [a]<b>weak</b>Beginners 】 the</span>
</div>
Copy the code

The above text appears to the user to be a weak beginner, but it contains three nodes

Simple Scenario 2

<div>
    <span>I [am a<b>weak</b>A beginner</span>
    <span>I am a<em>Weak beginner</em></span>
    <span>I am a<i>A weak beginner</i></span>
</div>
Copy the code

This is a little bit more complicated, but it’s the expanded range, and it’s the cursor

The above Range only describes the situation of expansion. The actual DOM is much more complex than this, and the node nesting level is uncontrollable, which also confuses a lot of people who implement rich text editors by hand

Range split node

Now we are in some cases, like the current selection, we just want to get that constituency content node, but we can obviously know as soon as we open the case The current mainstream of editor probably as follows Is not an independent node, how do we mark it, or separate from it, back to the example of the above

const selection = window.getSelection()
const range = selection.getRangeAt(0)

const { startOffset, endOffset, collapsed } = range

if (collapsed === false) {
    const ancestor = range.commonAncestorContainer
    
    if (range.startContainer === range.endContainer
    && range.startContainer.nodeType === Node.TEXT_NODE
    ) {
        const text = range.startContainer.splitText(startOffset)
        // => < span style = "max-width: 100%; clear: both;}}Copy the code

If you select the text node, you will see that the original range has successfully obtained the node you selected

So, what do you see, bold, italics, underlining, stripout for rich text, can we wrap this text node, give it a span

Try adding a span to the selected area

Again, the previous example

const selection = window.getSelection()
const range = selection.getRangeAt(0)

const Selected = []

const { startOffset, endOffset, collapsed } = range

if (collapsed === false) {
    const ancestor = range.commonAncestorContainer
    
    if (range.startContainer === range.endContainer
    && range.startContainer.nodeType === Node.TEXT_NODE
    ) {
        const text = range.startContainer.splitText(startOffset)
        const parent = text.parentNode
        const span = document.createElement('span')
        span.classList.add('Selected')
        
        parent.insertBefore(span, text)
        span.appendChild(text)
        
        // Cache the nodes we operate on, and then possibly restore the original text
        Selected.push(span)
        
        // Update the current selection
        range.setStart(span, 0)
        range.setEnd(span, span.childNodes.length)
        selection.removeAllRanges()
        selection.addRange(range)
    }
}
Copy the code

You can detect if the selected element has changed, and when you retrieve the range again, if you select the span that you replaced, so bold, italics, and so on, if you just manipulate the parent node of the span, and then undo the set of element nodes that contain Seleted

Of course, this is a small detail, the selection needs to clone the current selection for subsequent operations

Realize the bold

Again, the previous example

const selection = window.getSelection()
const range = selection.getRangeAt(0)

const Selected = []

const { startOffset, endOffset, collapsed } = range

if (collapsed === false) {
    const ancestor = range.commonAncestorContainer
    
    if (range.startContainer === range.endContainer
    && range.startContainer.nodeType === Node.TEXT_NODE
    ) {
        const text = range.startContainer.splitText(startOffset)
        const parent = text.parentNode
        const span = document.createElement('span')
        span.classList.add('Selected')
        
        parent.insertBefore(span, text)
        span.appendChild(text)
        
        // Cache the nodes we operate on, and then possibly restore the original text
        Selected.push(span)
        
        // Update the current selection
        range.setStart(span, 0)
        range.setEnd(span, span.childNodes.length)
        selection.removeAllRanges()
        selection.addRange(range)
    }
    
    for (let i = 0, l = Selected.length; i < l; i ++) {
    
        // This can be used as a command
        const span = document.createElement('span')
        span.style.fontWeight = 'bold'
        
        // Start the substitution
        const cur = Selected[i]
        const parent = cur.parentNode
        
        parent.insertBefore(span, cur)
        span.appendChild(cur)
        
        // Update nodes in the selection
        // Selected[i] = span
        // Do you want to adjust the selection? According to their own needs
    }
    
    // Unselect to restore the selected node
    // for (const selectedNode of Seleted) // ... Restore the node
    
    
}
Copy the code

Such a simple bold function is achieved, is not very simple

To summarize

Is it possible to replace the drawbacks brought by the original document. ExecCommand by operating Range, such as font size problem

PS: Subsequent updates (range nested nodes how to mark selected nodes)