How to write a rich text editor from scratch (parse slate source code, serialization)

Posted by kaze on Thu, 30 Dec 2021 13:07:58 +0100

background

The document is very popular recently, and the boss wants it too. I was also very interested, so I went into the pit to study and practice. In the blink of an eye, a year has passed, and the project has achieved initial results, but the difficulties and challenges are becoming more and more difficult. So I deeply studied and adapted the source code to prepare for rewriting the source code later.

The screenshot of the results of our project is as follows:

There is demo source code at the end of the article. Welcome to comment and exchange.

data structure

Since we are learning slate source code, we don't want to innovate a data structure. Let's go along the road of predecessors. Considering that subsequent large documents need windows to load, I think a JSON document is too rough, and it may be transformed into multiple arrays to form a document.

The first day, the simplest demo

First, write the simplest p tag, which is how we can take over user text input from the browser.

[{type:'p',children:[{text:'Big orange'}]}]

The effect is as follows

If I want two big oranges in a row, the structure I need is as follows:

[{type:'p',children:[{text:'Big orange big orange'}]}]

An operation insertText is required:

export function insertText(root, text, path) {
    // Gets the element of the specified path
    var node = getNodeByPath(root, path);
    if (text) {

        node.text = node.text + text;
    }
}




function getNodeByPath(root, path) {
    // return root[0].children[0]
    var node = root;
    console.log(window.root === root)
    for (var i = 0; i < path.length; i++) {
        const p = path[i]
        node = node[p] || node.children[p];
    }
    return node;
}

const root = [{ type: 'p', children: [{ text: 'Big orange' }] }]
insertText(root, 'Big orange', [0])
console.log(JSON.stringify(root)) //[{"type":"p","children":[{"text": "big orange"}]}]

ok, the simplest logic of an editor is ok.

View display

Here I chose react

Create project

(1)npm install -g create-react-app 
(2)create-react-app day001
(3)cd day001
(4)npm start  

On app JS write the following code

import './App.css';

import {useEffect} from 'react'

import {getString, insertText} from './insertText'

window.root =[{ type: 'p', children: [{ text: '' }] }]

function App() {

    // const [root, setRoot] = useState(initRoot)

    useEffect(() => {

        const input = document.getElementById('editor');

        input.addEventListener('beforeinput', updateValue);

        function updateValue(e) {

            e.preventDefault()

            e.stopPropagation()

            insertText(window.root, e.data, [0,0])

            console.log(e.data, window.root)

            input.textContent = getString(window.root)

        }

    }, [])

    return (

    <div className="App">

    This is a demo editor 

        <div id='editor' contentEditable onInput={(e)=>{

            e.preventDefault()

            e.stopPropagation()

            console.log(e)

            return

        }}>
        </div>

    </div>

    );

}



export default App;


design sketch:

The next day, control the cursor in the browser

Subtitles can also be called how can we enter text at the rich text cursor after taking over the input text?

On the first day, we have implemented to monitor user input and selectively input content. Although the principle it uses is very valuable, the editor is a little low. No matter where the user enters in the editor, the content can only be appended at the end of the text. As a rich text editor, this is unforgivable.

Now, let's improve this problem.

First, we know how to get the position of the cursor and the position of the corresponding text. We'll use it here window.getSelection() api To get the dom where the cursor is located and the position of the cursor in the dom text.

The insertText code is modified as follows

export function insertText(root, text, path) {
    const domSelection = window.getSelection()
    console.log('domSelection', domSelection, domSelection.isCollapsed, domSelection.anchorNode, domSelection.anchorOffset, JSON.stringify(domSelection))
    // Gets the element of the specified path
    var node = getNodeByPath(root, path);
    if (domSelection.isCollapsed) {
        if (text) {
            const before = node.text.slice(0, domSelection.anchorOffset)
            const after = node.text.slice(domSelection.anchorOffset)
            node.text = before + text + after
        }
    } else {
        // TODO if the cursor selects a range
    }
    // console.log(root[0].children[0] === node, root[0].children[0], node)

}

We insert text at the cursor position, but the cursor position is wrong after input. Next, we need to change the cursor.

Briefly introduce the setBaseAndExtent method

 // dom refers to the dom node to be selected, and offset refers to the position of the text in the dom node
 window.getSelection().setBaseAndExtent(
        dom, offset, dom2, offset2)

Rewrite our app JS file, which mainly modifies the two useEffect methods and gives the text rendering to state to change.

import './App.css';
import { useState, useEffect } from 'react'
import { getString, insertText } from './insertText'
window.root = [{ type: 'p', children: [{ text: '' }] }]
function App() {
  // Record what we entered
  const [txt, setTxt] = useState('')
  // offset of cursor
  const [txtOffset, setTxtOffset] = useState(0)
  // Register to listen to beforeinput events and block default events. Modify window. In listening Root, and update txt and txtO in it. Finally, clear the cursor to prevent the cursor from flashing caused by TXT update.
  useEffect(() => {
    const input = document.getElementById('editor');

    input.addEventListener('beforeinput', updateValue);
    function updateValue(e) {
      e.preventDefault()
      e.stopPropagation()
      insertText(window.root, e.data, [0, 0])
      // console.log(e.data, window.root)
      const getText = getString(window.root)
      const { anchorOffset } = window.getSelection()
      setTxtOffset(anchorOffset + e.data.length)
      setTxt(getText)
      window.getSelection().removeAllRanges()
    }
    return () => {
      input.removeEventListener('beforeinput', updateValue);
    }
  }, [])

  // Listen to txtOffset and update the cursor position with setBaseAndExtent. setTimeout is used because the cursor position needs to be changed after the page is rendered
  useEffect(() => {
    const { anchorNode } = window.getSelection()
    setTimeout(() => {
      if (!anchorNode) {
        return
      }
      let dom = anchorNode
      if (dom.childNodes && dom.childNodes[0]) {
        dom = dom.childNodes[0]
      }
      window.getSelection().setBaseAndExtent(
        dom, txtOffset, dom, txtOffset)
    })
  }, [txtOffset])


  return (
    <div className="App">
      This is a demo editor 
      <div id='editor' contentEditable onInput={(e) => {
        e.preventDefault()
        e.stopPropagation()
        console.log(e)
        return
      }}>

        {txt}
      </div>
    </div>
  );
}

export default App;

At this time, our editor can input English and numbers normally. But how to solve the problem of Chinese?

Subsequent updates~

Source code: https://github.com/PangYiMing/study-slate

Topics: Front-end Web Development