前言
公司要做一个笔记模块,需要用到富文本编辑器。之前有耳闻富文本编辑器是天坑。知乎-为什么说富文本编辑器是个天坑? 在试过了市面上主流的编辑器后,发现或多或少都不符合要求。主要有以下问题:
- CKEditor功能很强大,但是太复杂,有很多用不到的地方。
- 项目前端框架是Vue,最好是基于Vue2.x的编辑器
- 网上开源的编辑器体验或多或少有不满足的地方。
还好开发时间比较富足,于是决定在vue-html5-editor基础上二次开发,最后完成上线的作品,呼唤star✨ ? Github:my-vue-editor
实现套路
web端实现富文本编辑器主要有2个套路:
- 利用
contenteditable
属性结合document.execCommand API实现,比如国外的CKEditor、百度的UEditor、优秀的后起之秀wangEditor。 - 完全自己模拟实现
selection
、视图渲染等一切。比如Google Doc、有道云笔记、基于electron
开发的VS Code。
这里我们很理智的选择了第一种实现方式。先简单介绍下编辑器很重要的几个概念:
Range/Selection
Range
翻译过来是范围,幅度的意思,与数学上的“区间”这以概念类似。浏览器提供的Range
对象用来描述DOM树中的一段连续的范围。
startContainer
,startOffset
描述Range
的起始处,endContainer
,endOffset
描述Range
的结尾处。当一个Range
的起始处和结尾处是同一个位置时,该Range
就处于collapsed
状态。
Selection
(选区)管理整个页面当前的Range
及Range
的绘制。当Selection
中的Range
处于collapsed
状态时,即是日常所说的光标。光标其实是Selection
的一种特殊状态。
document.execCommand
浏览器原生为我们提供了一些对Range
内节点进行富文本操作的方法,这些方法都是通过document.execCommand
调用。
bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)
复制代码
比如
// 向当前插入点插入一个p标签。
document.execCommand('insertHTML', false, '')
// 将框选部分字体变为绿色,如果是collapsep状态则接下来输入的文字为绿色
document.execCommand('foreColor', false, '#00ff00')
复制代码
我们编辑器的架都是围绕这两个概念展开的:
- 当我们点击编辑器各种功能按钮(比如插入图片、加粗、下划线)时内容区域会失去焦点,所以我们需要一种能够保存当前
Range
对象,并在需要时可以调用的机制。 - 我们已经知道
document.execCommand
是用来操纵选区HTML结构的,但是原生提供的方法的逻辑大多数都不完全符合我们的需要,或者存在兼容性问题。所以我们封装我们自己的构造函数Command
用来操纵富文本,不同的按钮点击后就会实例化相应的Command
并执行相关操作。
对于第一点,只需要定义一个保存,一个设置方法。
// 保存当前Range
function saveCurrentRange () {
// 获取selection对象
const selection = window.getSelection ? window.getSelection() : document.getSelection()
if (!selection.rangeCount) {
return
}
const content = this.$refs.content
for (let i = 0; i < selection.rangeCount; i++) {
// 从selection中获取第一个Range对象
const range = selection.getRangeAt(0)
let start = range.startContainer
let end = range.endContainer
// 兼容IE11 node.contains(textNode) 永远 return false的bug
start = start.nodeType === Node.TEXT_NODE ? start.parentNode : start
end = end.nodeType === Node.TEXT_NODE ? end.parentNode : end
if (content.contains(start) && content.contains(end)) {
// Range对象被保存在this.range
this.range = range
break
}
}
}
// 设置Range对象
function restoreSelection () {
// 首先获取selection对象并清除当前的Range
const selection = window.getSelection ? window.getSelection() : document.getSelection()
selection.removeAllRanges()
// 从this.range中获得保存的Range设置为Selection的Range对象
if (this.range) {
selection.addRange(this.range)
} else {
// 如果之前没有保存Range则新建一个
const content = this.$refs.content
const row = RH.prototype.newRow({br: true})
const range = document.createRange()
content.appendChild(row)
range.setStart(row, 0)
range.setEnd(row, 0)
selection.addRange(range)
this.range = range
}
}
复制代码
有了这两个方法,我们只需要为编辑器的内容区域注册mouseup
keyup
mouseout
事件监听来实时执行saveCurrentRange
,当点击按钮后在实例化Command
前执行restoreSelection
。
对于第二点,封装execCommand
方法很好理解,比如我要实现"缩进indent"的功能,document.execCommand
就提供了indent
这个参数可以直接使用,当Range处于ul>li,中执行indent
会让ul嵌套ul,变成ul>ul>li,多个缩进就执行多个嵌套。这满足我们的需要。
// 缩进前
<ul>
<li>当前光标位置li>
ul>
// 缩进后
<ul>
<ul>
<li>当前光标位置li>
ul>
ul>
复制代码
但是当Range处于一般的块级元素中,执行indent
会让块级元素外面嵌套blockquote
元素,我们想通过在块级元素上增加margin-left
来处理一般块级元素的缩进。
// 缩进前
<p>当前光标位置p>
// 缩进后
<blockquote>
<p>当前光标位置p>
blockquote>
// 我们希望的情况
<p style='margin-left: 8%;'>当前光标位置p>
复制代码
我们只需要封装execCommand
方法,当其参数为indent
时,执行对应封装好的indent
方法,判断Range
是处于列表元素还是其他块级元素中分别对待就行。 这里之所以要采用构造函数而不是普通函数的形式,是因为所有原生的execCommand
方法,当执行时浏览器内部会对该contenteditable区域维护一个undo栈和一个redo栈,使得每一个修改行为可以撤销和重做。
我们封装的方法覆写了原生的方法,就会破坏undo/redo栈的连续性,导致撤销和重做出错或失效。所以我们需要在每个Command
实例上保存执行前编辑器区域的DOM结构(快照)和执行后编辑器区域的DOM结构(快照),并把这个实例推入相应的undo/redo栈。当我们执行撤销和重做操作时只需要从相应的栈中取出保存的快照恢复到内容区域即可。 所以你发现啦,undo
和redo
也是两个需要重写的Command
。
到这里一个富文本编辑器的雏形就出来了,我们只需要在这个基础上不断完善我们的Command
,再处理需要过滤的样式、多端数据结构同步、各种浏览器的兼容性等一个又一个坑就能做出功能丰富的编辑器啦。????
都看到这里啦,来试试我们的编辑器吧,Github:my-vue-editor 觉得好用给个star呗老铁