如何实现富文本编辑器[0 - Range 初探]

1,220 阅读5分钟

富文本踩坑已经很久了,做个记录,目前主流的编辑器大概如下

关于编辑器如何演进的,就不多介绍了,大家看这个(重复的东西就不需要在分析了) 开源富文本编辑器技术的演进

现在来划分一下级别,首先对级别有有一个清晰的了解

分级优势劣势
L0基于原生浏览器支持,可快速迭代不易扩展,定制非常困难,一切要看浏览器支持,开发快
L1自定义的数据结构,基本满足 99% 的需求易扩展,但是部分还是有缺陷,不易快速研发
L2完全自研,可扩展,定制强(office, google docs)研发难度大

既然如此,如何实现 L1 级别的一个编辑器呢

首先富文本最重要的是什么,肯定绕不开 SelectionRange

L0 级别的编辑器依托于 document.execCommand 方法实现原理,基本上你可以省去了选区的操作,而且连最基本的撤销和重做,浏览器都给了完整的 API, 但是,在某些功能上,定制不易,比如你想改变字体(fontsize 命令)的默认的几个字体大小,但是原生命令就支持那么几个,所以 =,=||,其他异常就不分析了。如果只是需要简单的功能,可以使用这个

L1 级别的编辑器,就需要数据做对应的映射了,一般是 MVC 结构或者传统的 dom 处理,这里就不多说了

但是想分享的是如何写一个 L1 级别的编辑器,可以参考 L1 级别的编辑器,但不照搬

所以,综上所述,先理清 Selection 和 Range 比较合适

关于 Range

Range 其实就是光标,收起叫光标,展开是一个选区,带有起始坐标等信息,就是页面上那个选中的那个蓝色区域,它就是 Range

图片

看不懂图没关系,其中的 endOffset, startOffset 是干嘛的呢,表示这段文本选中的下标开始位置,和结束文字索引值,我们可以做个 test

collapsed: 表示当前光标是展开还是收起 false 为展开, true 为光标状态

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

const { startOffset, endOffset, collapsed } = range

// collapsed 这个就是光标展开的选区
if (collapsed === false) {
}

当然这只是个很小的例子,接下来演示一些比较复杂的 range,展开的光标我们使用 【】来表示, 看 html

简单场景 1

<div>
    <span>我是【一个<b>弱小的</b>初学】者</span>
</div>

上面这段文本在用户看来就选中了 一个弱小的初学 ,但是其中却包含了三个节点

简单场景 2

<div>
    <span>我【是一个<b>弱小的</b>初学者</span>
    <span>我是一个<em>弱小的初学者</em></span>
    <span>我是<i>一个弱小的初】学者</i></span>
</div>

这一段是不是更加复杂,但是它就是展开的 range,也是光标

以上的 Range 仅仅描述了展开的的情况,实际真实的 dom 会比这个更加复杂,节点嵌套层级也是 不可控 的, 这也难倒了一大批手写实现富文本编辑器的人

range 拆分节点

现在我们在某些情况下,就像当前这种选区情况,我们只想拿到选区的内容节点,但是我们可明显的知道我们一开的那个例子 目前主流的编辑器大概如下 并不是一个独立的节点,我们如何标记它呢,或者分开它呢,回到上边的例子

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)
        // => text: '目前主流的编辑器大概如下' 
    }
}

你选中这个 text 节点,你会发现原本的 range 范围已经成功的获得到了你选中的节点

所以,发现了什么,关于富文本的加粗,斜体,下划线,删除线,是不是我们可以给这个 text 节点进行一个包裹,给上一个 span 是不是就实现了呢

尝试给选中的区域加个 span 试试

还是之前的例子

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)
        
        // 缓存我们操作过的节点,之后可能会复原原本的文本
        Selected.push(span)
        
        // 更新当前的选区
        range.setStart(span, 0)
        range.setEnd(span, span.childNodes.length)
        selection.removeAllRanges()
        selection.addRange(range)
    }
}

你可以检测到你选中的元素是不是发生了变化,再次重新获取 range 的时候,是不是就选中了你替换后的 span,所以加粗,斜体,这些,是不是只需要操作这个 span 的父节点就可以了,之后在复原这个包含 Seleted 的元素节点集合

image.png

image.png

当然这是很小的一个细节,选区还需要 clone 当前的选区,以便后续操作

实现加粗

还是之前的例子

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)
        
        // 缓存我们操作过的节点,之后可能会复原原本的文本
        Selected.push(span)
        
        // 更新当前的选区
        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 ++) {
    
        // 这块可以抽成命令
        const span = document.createElement('span')
        span.style.fontWeight = 'bold'
        
        // 开始替换
        const cur = Selected[i]
        const parent = cur.parentNode
        
        parent.insertBefore(span, cur)
        span.appendChild(cur)
        
        // 更新选区中的节点
        // Selected[i] = span
        // 是否要调整选区呢?根据自己的需求
    }
    
    // 取消选区复原选中的节点
    // for (const selectedNode of Seleted) // ...复原节点
    
    
}

这样一个简单的加粗功能就实现了,是不是非常简单

image.png

总结一下

是不是通过操作 Range 就替代了原有的 document.execCommand 带来的弊端,比如 font-size 问题

PS: 后续接着更新(range 嵌套节点如何标记选中的节点)