富文本踩坑已经很久了,做个记录,目前主流的编辑器大概如下
关于编辑器如何演进的,就不多介绍了,大家看这个(重复的东西就不需要在分析了) 开源富文本编辑器技术的演进
现在来划分一下级别,首先对级别有有一个清晰的了解
| 分级 | 优势 | 劣势 |
|---|---|---|
| L0 | 基于原生浏览器支持,可快速迭代 | 不易扩展,定制非常困难,一切要看浏览器支持,开发快 |
| L1 | 自定义的数据结构,基本满足 99% 的需求 | 易扩展,但是部分还是有缺陷,不易快速研发 |
| L2 | 完全自研,可扩展,定制强(office, google docs) | 研发难度大 |
既然如此,如何实现 L1 级别的一个编辑器呢
首先富文本最重要的是什么,肯定绕不开 Selection 和 Range
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 的元素节点集合
当然这是很小的一个细节,选区还需要 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) // ...复原节点
}
这样一个简单的加粗功能就实现了,是不是非常简单
总结一下
是不是通过操作 Range 就替代了原有的 document.execCommand 带来的弊端,比如 font-size 问题
PS: 后续接着更新(range 嵌套节点如何标记选中的节点)