如何从头手写一个富文本编辑器(解析slate源码,连载)

1,902 阅读3分钟

背景

最近文档很火,老板也要。我也很感兴趣,于是入坑学习实践了一番。一眨眼就是一年过去了,项目初见成效,但是发现困难和挑战也越来越棘手。于是深入研究改编了一下源码,为后面重写源码做准备。

我们的项目的成果截图,镇宅一下:

image.png

文章末尾有demo源码,欢迎评论交流。

数据结构

既然是学习slate源码也就不想创新一个数据结构了,沿着前人的路先走一下吧。考虑到后续的大文档需要视窗加载,我认为一个JSON搞定文档过于粗糙了,后续可能会改造成多个数组组成一个文档。

第一天,最简单的demo

首先,写一个最简单p标签,又叫我们可以怎样从浏览器手中接管用户文本输入。

[{type:'p',children:[{text:'大橘'}]}]

效果如下

slate-p标签大橘效果图

如果是想要一行两个大橘,我需要的结构是这样:

[{type:'p',children:[{text:'大橘大橘'}]}]

此时需要一个操作insertText:

export function insertText(root, text, path) {
    // 获取指定path的element
    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: '大橘' }] }]
insertText(root, '大橘', [0])
console.log(JSON.stringify(root)) //[{"type":"p","children":[{"text":"大橘大橘"}]}]

好了一个编辑器最简单的逻辑ok了。

视图展示

这里我选择了react

创建项目

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

在App.js中写如下代码

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">

    这是一个demo编辑器

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

            e.preventDefault()

            e.stopPropagation()

            console.log(e)

            return

        }}>
        </div>

    </div>

    );

}



export default App;


效果图:

image.png

第二天,掌控浏览器中光标

小标题又可以叫做在接管输入文字以后,我们可以怎样在富文本光标位置输入文本?

在第一天,我们已经实现了,监听用户输入,并选择性的输入内容。虽然它使用的原理很有价值,但是这个编辑器有点low,不管用户在编辑器哪里输入,内容都只能在文本末尾追加。作为一个富文本编辑器这是不可饶恕的。

那么现在,我们来完善这个问题。

首先,我们知道如何获取光标的位置,以及对应文本的位置。这里我们会用到window.getSelection() api来获取光标所在的dom,以及光标在dom中文本的位置。

insertText代码修改如下

export function insertText(root, text, path) {
    const domSelection = window.getSelection()
    console.log('domSelection', domSelection, domSelection.isCollapsed, domSelection.anchorNode, domSelection.anchorOffset, JSON.stringify(domSelection))
    // 获取指定path的element
    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 如果光标选中一个范围
    }
    // console.log(root[0].children[0] === node, root[0].children[0], node)

}

我们实现了在光标位置插入文本,但是光标在输入后位置不对了,我们接下来要改变光标。

简单介绍一下setBaseAndExtent方法

 // dom 是指要选中的dom节点,offset是指dom节点里面文字的位置
 window.getSelection().setBaseAndExtent(
        dom, offset, dom2, offset2)

重新写一下我们的APP.js文件,主要修改了两个useEffect方法,以及把文本渲染交给state来改变。

import './App.css';
import { useState, useEffect } from 'react'
import { getString, insertText } from './insertText'
window.root = [{ type: 'p', children: [{ text: '' }] }]
function App() {
  // 记录我们输入的内容
  const [txt, setTxt] = useState('')
  // 光标的offset
  const [txtOffset, setTxtOffset] = useState(0)
  // 负责注册监听beforeinput事件,并阻止默认事件。在监听中修改window.root,并在里面更新txt和txtO,最后清除光标,防止txt更新导致光标闪动。
  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);
    }
  }, [])

  // 监听txtOffset,并且用setBaseAndExtent更新光标位置,使用setTimeout是因为要在页面渲染后,再改变光标位置
  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">
      这是一个demo编辑器
      <div id='editor' contentEditable onInput={(e) => {
        e.preventDefault()
        e.stopPropagation()
        console.log(e)
        return
      }}>

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

export default App;

此时,我们的编辑已经可以正常输入英文和数字。但是中文的问题如何解决呢?

后续更新~

源码:github.com/PangYiMing/…