富文本编辑器,Multi-function Text Editor, 简称 MTE, 是一种可内嵌于浏览器,可以对文字、图片等进行编辑的,所见即所得的文本编辑器。
在阅读本文前,建议先了解下**选区(Selection)和范围(Range)**的基本概念,因为,本文所描述的功能主要基于光标选区的属性和方法实现,后面的代码中会多次出现,提前了解这些知识有助于更好的阅读和理解本文内容。
可以参考这篇文章利用 javascript 实现富文本编辑器
前言
前段时间,接到一个需求,实现一个邮件的模板编辑功能,对于前端来说,就是用户可以在现有的富文本编辑器中插入变量,具体表现就是:
- 在编辑器中通过
$
关键字触发,弹出一个类似 Select 选择框的弹窗,提供选择指定变量 - 在选择变量之后,在编辑器中以
${xx}
这种形式保存,并且以不同颜色高亮区别
业务上的表现是这样的,在逻辑触发时,后端通过 ${xx}
去筛选替换变量为对应的值,然后发送邮件到对应的用户。
功能
为了方便用户的使用,我们目前设计了这几个功能点:
- 在不影响富文本编辑器的基本功能下,通过按键
$
触发选择框弹出 - 选中的列表项,会以
${xx}
这种形式自动插入到编辑器的当前选区中 - 插入到编辑器中的变量以
${xx}
这种形式保存,并且支持高亮显示 - 支持
${xx}
中变量的动态模糊查询(例如:当你在${}
中输入send
时,列表中的sendTime
、sendCount
应该是匹配在列表中的 ) - 支持对
${xx}
的变量识别响应(例如:光标从其他位置移动到${}
中时,会实时地响应匹配其中的变量) - 支持键盘选择,在不使用鼠标的情况下也可以自由选择
$ 关键字触发
如何监听 $
关键字,还是比较简单的,通过监听键盘事件就可以实现,在匹配到 $
时,触发对应逻辑即可,不过,这里重点其实是实现 ${}
。
变量需要以
${xx}
这种形式保存,而且前端也需要通过{}
去隔离同一行内的其他变量,从而区分当前变量。
在检测到 $
字符时,自动把 {}
拼接上,并且把光标移动到 ${}
之中。
这里我以公司使用的 wangeditor v4 版本为例,不过,本文所提到的功能实现不局限于某一固定框架,主要是基于原生实现。
页面代码基本结构
DOM结构
html
<template>
<div class="box">
<!-- 编辑器外层容器 -->
<div class="editor---wrapper">
<div id="toolbar-container"><!-- 工具栏 --></div>
<div ref="editorBox" id="text-container"><!-- 编辑器 --></div>
</div>
<!-- 弹窗 -->
<div ref="tipRef" v-show="isShow" class="tip-wrap">我是弹窗</div>
</div>
</template>
JS相关代码
js
import { onMounted, onUnmounted, reactive, ref, shallowRef } from 'vue'
import E from 'wangeditor'
// DOM 相关
const editor = shallowRef() // wangeditor编辑器实例
const editorBox = shallowRef() // 编辑器DOM
const tipRef = shallowRef() // 弹窗DOM
// 响应式数据相关
const isShow = ref(false) // 弹窗开关
const boxTop = ref(42) // 弹窗 top 值(为了弹窗始终跟随光标所在位置,默认42为富文本工具栏的高度)
// 键盘按键相关
const keywordObj = reactive({
currentStr: '', // ${} 中当前已输入的变量字符
isDoing: false, // 按键是否为操作中
isDeleteKey: false // 是否为删除键
})
// Mounted
onMounted(() => {
// 初始化编辑器
initEditor()
// 初始化事件
initEvents()
})
onUnmounted(() => {
// clear
clearEvents()
})
function initEditor() {
editor.value = new E('#toolbar-container', '#text-container')
editor.value.create()
}
function initEvents() {
// 点击非弹窗区域,关闭弹窗
window.addEventListener('click', windowClickClose)
// 这里需要使用 keyup 键盘事件(${}),keydown 事件在处理$关键字时会导致 {$} 位置错误
window.addEventListener('keyup', onWindowKeyUp, true)
// selection
document.addEventListener('selectionchange', selectionchange)
// 在弹窗开启时,阻止对应键盘操作影响编辑器
document.addEventListener('keydown', onWindowKeyDown)
}
绑定keyup事件
为什么不使用 keydown,和他们的触发时机有关,结合后面涉及到的其他逻辑,这里选择了keyup更为合理。
js
window.addEventListener('keyup', onWindowKeyUp, true)
// 使用防抖函数 debounce 优化,同时避免 {} 重复插入
const onWindowKeyUp = debounce((ev) => {
// console.log('key', ev.key)
const key = ev.key
if (key === '$') {
// isShow.value=true
const selection = document.getSelection()
const range = selection.getRangeAt(0)
const textNode = range.startContainer
// 获取光标位置
const rangeStartOffeset = range.startOffset
// 在 $ 后面插入 {}
textNode.insertData(rangeStartOffeset, '{}')
// 移动光标位置
range.setStart(textNode, rangeStartOffeset + 1)
range.collapse(true)
selection.removeAllRanges()
// 插入新的光标对象
selection.addRange(range)
}
}, 10)
目前该函数可以实现在按下 $
按键后,输入 ${}
并且光标在花括号之中。
按照设想,这时应该再自动触发 isShow.value=true
,开启弹窗的逻辑,但是,因为在后面的实践过程中,我发现这种方式并不是最优解,涉及到匹配逻辑和用户体验的问题,因此,弹窗的逻辑我这里进行了调整,放到了 selectionchange
事件中进行处理,请耐心往下看😎
选中项自动插入到编辑器中
选中的列表项,会以 ${xx}
这种形式自动插入到编辑器的当前选区中
简单写个列表,DOM结构如下:
html
<!-- 弹窗 -->
<div ref="tipRef" v-show="isShow" class="tip-wrap">
<ol>
<li
v-for="item in listData"
:key="item.value"
:data-value="item.value"
>
{{ `${item.label}(${item.value})` }}
</li>
</ol>
</div>
为了优化性能,我们把点击事件托管在父节点DOM上
js
// 列表点击事件
if (tipRef.value) {
tipRef.value.addEventListener('click', onOlClick, true)
}
function onOlClick(ev) {
const { value } = ev.target.dataset
if (value) {
console.log('value', value)
insertField(value)
}
}
请思考下,当我们在选中目标选项后,如何把 value 以 ${value}
这种形式自动插入到编辑器的当前选区中?
document.execCommand 方法
当一个div元素被添加contenteditable
属性且为true时,表示元素可被用户编辑(富文本编辑器的基本逻辑)
document.execCommand
命令是浏览器提供的API,该方法允许运行命令来操纵可编辑内容区域的元素(比如加粗、下划线等等)。
document.execCommand 方法虽然主流浏览器都支持,但现在官方已不再推荐使用,相关分析可以看这篇文章🎉 富文本编辑器初探
分析源码可知,wangeditor v4版本及以下是基于该属性方法实现的,而 v5.x 版本内核已经完全重构,现在是基于 slate.js 开发的
这里我们还是以这个方案编写用例进行分析,主要讲解下基本思路,先实现 insertField
插入方法:
js
function insertField(val) {
// 删除光标所在位置的字符,这里删除 } 字符
editor.value.cmd.do('forwardDelete')
// 获取位置
let startOffset = document.getSelection().focusOffset
// 当 ${} 中存在已输入的匹配字符时,需要计算对应字符长度
if (keyword_str.value) {
startOffset = document.getSelection().focusOffset - keyword_str.value.length
}
startOffset -= 2 // 计算 ${ 2个字符
startOffset = startOffset < 0 ? 0 : startOffset // 不可为 负数
// 重新设置选区位置
document
.getSelection()
.getRangeAt(0)
.setStart(
document.getSelection().focusNode,
startOffset
)
// 插入内容
editor.value.cmd.do('insertHTML', `<span style="color: blue;">\${${val}}</span>`)
// 插入空白占位符
editor.value.selection.createEmptyRange()
}
结合代码,简单梳理下逻辑:
- 在选中列表中的某一项时,传入
value
,调用insertField
方法 - 把
value
通过${}
进行包裹,得到${value}
这种形式,同时以span
标签进行包裹,以实现字符隔离与颜色高亮 - 把最终文本插入到编辑器中
- 这里的重点是如何恰好替换编辑器中的 ${} 文本,通过
Range
对象的setStart
方法,可以重新设置起始位置 - 这里计算
${}
的前2个字符,得到 -2 的结果,同时删除末尾的}
字符,然后再把结果重新插入到编辑器中即可
- 这里的重点是如何恰好替换编辑器中的 ${} 文本,通过
editor.cmd.do
是 wangeditor 基于 document.execCommand
方法做的一层封装,我们可以直接使用,相关代码见 wangEditor/blob/v4.7.13/src/editor/command.ts
对于最后一行的,插入空白占位符 createEmptyRange
代码简要说明下:
- 富文本编辑器默认是通过
<br>
充当一个空占位符的,初始结构一般是<p><br></p>
,F12查看DOM结构可知,我们在使用过程中是无感的(
标签用来占位,有内容输入后会自动删除) - 在可编辑状态下,回车换行产生的新结构会默认拷贝之前的内容,包扩节点,类名等各种内容
这里插入空白占位符的主要作用:
- 把光标移出到包裹变量的 span 标签外面
- 编辑器内回车换行不会复制 span 标签影响内容编辑
wangeditor 中对于
createEmptyRange
的实现,主要逻辑为editor.cmd.do('insertHTML', '​')
支持变量的动态模糊查询
可选列表,当存在很多条数据时,再通过鼠标一个个找就很不友好了,因此,模糊查询也是很有必要的,那么,在这里,我们该如何设计和实现呢?
首先,需要确定的是如何锁定输入区域,有这么几种情况:
- 在编辑器文本末尾输入
- 在编辑器任意一行的任意位置输入
因为对文本内任意位置插入编辑时难以确定其位置,这里我们排除了编辑器的 onChange
方法。
在通过鼠标或者键盘移动光标时,当光标处于变量区域范围时,为了能够有更好的交互响应,我选择了 selectionchange
事件(编辑区内容与光标改变时会触发),可以实时监听光标,这样可以更好的控制光标跟随逻辑。
js
document.removeEventListener('selectionchange', selectionchange)
function selectionchange() {
const selection = document.getSelection()
const range = selection.getRangeAt(0)
// console.log('range', range)
}
通过 selection 对象获得的 range 对象才是我们操作光标的重点(Range表示包含节点和部分文本节点的文档片段)。
1. 确定光标位置,查找 ${}
Range对象的 startContainer
, endContainer
, commonAncestorContainer
都为 #text
文本节点,对于我们的场景来说并无明显区别,这里我们只查找 ${}
包裹的情况,不考虑跨多个文本节点的复杂场景,只围绕当前单个文本节点处理即可。
startOffset
和 endOffset
在非托选的情况下,起始和终点值是一样的,对于拖选情况来说,我们的处理方式也没什么区别(拖选的场景基本也可以不用考虑)
什么是拖选、拖蓝?
- 无拖蓝
- 有拖蓝
我当前的光标是否处于 ${} 之中?
换个角度思考,我们看下光标处于 ${} 之中是怎样的:
- 光标的左边应该是 ${
- 光标的右边应该是 }
- 需要考虑范围,一行中也可能存在多个变量
回忆下上面的键盘事件代码 onWindowKeyUp ,在触发 <math xmlns="http://www.w3.org/1998/Math/MathML"> 时,自动拼接了 ,从而完成了 时,自动拼接了 {} ,从而完成了 </math>时,自动拼接了,从而完成了{} 这样的组合,这样可以很好的保证用户输入的一致性,方便我们后面的识别处理。
js
// 获取当前文本节点的 value
const value = range.commonAncestorContainer.data
// 只有在当前文本包含有 $ 时,才考虑进一步处理
if (value && value.indexOf('$') > -1) {
// 1. 查找 ${}
// startOffset、endOffset 一般情况下都是相同的,没有拖选时,起终位置一致
let start = range.startOffset //
let end = range.endOffset
// 向左查找最近的 ${
while (start > -1) {
if (value[start - 1] === '$' && value[start] === '{') {
break
}
start--
}
// 向右查找最近的 }
while (end < value.length + 1) {
if (value[end] === '}') {
break
}
end++
}
// 2. 确定光标位置是否在 ${} 内
if (
(range.startOffset > start && start >= 0)
&& (range.endOffset <= end && end <= value.length)
) {
// ...
}
}
通过上述代码,可以确定符合条件的 start 与 end 的值,即 ${ 和 } 的起点和终点。
那么下一步,我们取到里面的值,进行过滤查找即可
2. 查找关键字
比如我输入 ${start}
,那么取值就是 start,
且列表数据中包含 startTime、startGame、endTime、endGame 这些值,那么过滤查询符合匹配的应该有 startTime、startGame
const currentStr = value.slice(start + 1, end)
先得到 currentStr 值,并缓存到 keyword_str.value = currentStr
,用于插入替换文本时计算位置使用
js
if ((range.startOffset > start && start >= 0) && (range.endOffset <= end && end <= value.length)) {
const currentStr = value.slice(start + 1, end)
// 嵌套的不处理
if (currentStr.indexOf('$') > -1 || currentStr.indexOf('{') > -1 || currentStr.indexOf('}') > -1) return
// 3. 处理关键字匹配
// 缓存关键字,用于插入替换文本时计算位置
keyword_str.value = currentStr
// 过滤匹配模糊查找
const options = listData.value.filter(n => n.value.toLowerCase().includes(currentStr.toLowerCase()))
tipOptions.value = clonedeep(options)
// 打开弹窗
// isShow.value = true
} else {
// 不符合条件时,关闭弹窗,清空数据
isShow.value = false
tipOptions.value = []
}
动态计算弹窗位置
我想要的效果是类似 VsCode 代码提示那种,当我在输入时,弹窗的位置应该是始终跟随我光标的位置下面的
这里我们使用绝对定位absolute,只需要考虑 top 值即可,使用 getBoundingClientRect
方法快速实现
js
// 4. 弹窗高度处理
// 弹窗 top 值(为了弹窗始终跟随光标所在位置)
const boxTop = ref(42)
const editorDomRect = editorBox.value.getBoundingClientRect() // 富文本容器
const textNodeRect = selection.focusNode.parentNode.getBoundingClientRect() // 文本节点
const top = textNodeRect.top - editorDomRect.top // 计算差值
// 1)需要考虑有的一串文本很长,可能横框几行,但是属于一个文本节点,因此,需要考虑 textNodeRect.height
// 2)考虑富文本工具栏的高度 42
boxTop.value = top + textNodeRect.height + 42
一切就绪,控制弹窗的开启和关闭
是的,上面我们删除了在键盘事件输入 $ 时触发开启弹窗的逻辑,放在了这里,想必看到这里,你应该理解了选择在 selectiononchange
事件中进行处理的好处,尤其是针对我们的这个场景来说。
js
if ((range.startOffset > start && start >= 0) && (range.endOffset <= end && end <= value.length)) {
// ...
// 打开弹窗
isShow.value = true
} else {
// 不符合条件时,关闭弹窗,清空数据
isShow.value = false
tipOptions.value = []
}
完整的代码,地址已放在下面了,感兴趣的可以进一步查看~
空白符的隔离优化处理
其实,到这一步,我们基本实现了相关功能。不过,对于目前这种基于原生接口实现的富文本编辑器方案,还是存在一个非常影响体验的地方需要处理优化。
在上面的代码中,我们实现了插入逻辑,与此同时我们需要同步创建一个空白节点,并使光标向后移动一位,为的是在富文本的逻辑中,插入文本后不影响后面的输入。
虽然空白节点不可见,但是在我们通过鼠标点击的时候,会出现这么一种情况,在点击之后,光标的位置实际上是移动到了变量的文本节点内了的,例如:
${startTime}abc
这段文本
在DOM上的结构实际上是这样的
当我在 }a
这两个字符中间的位置点击一次时,光标会移动到这里,那么你觉得我接下来输入内容后,它会在哪个节点中呢?
答案是这样的:
- 针对用例这样的内容,结果并不绝对,可能和我点击的位置有关系,有时在变量文本节点内,有时不在,看似正常,但是这样的结果非常不可靠,不是我们所能控制的
- 对于
${startTime}
这样的文本,位于文本末尾时,基本上是非常确定的,在点击后,会跑到 span 标签中------我们的变量文本节点内。
那么我所希望的是,存在 ${startTime}
这种插入后的变量文本标签时,当我点击 }
后面的位置,那么它就应该是处于变量文本节点外。
分析下情况:
- 首先要判断光标位置是否在变量后面和空占位符前面
- 变量文本节点处在末尾
- 变量文本节点处在行中
- 键盘操作的联动处理,如 删除键
同样的,这里的逻辑也是放在 selectionchange
事件中处理,相关逻辑抽离到 filterEmptyText
函数中
js
function selectionchange() {
const selection = document.getSelection()
const range = selection.getRangeAt(0)
// console.log('range', range)
filterEmptyText(selection, range)
}
这里需要注意的是:在键盘处于操作的时候,恰当地控制函数的执行。
- 因为编辑区光标的变动会直接触发该函数的执行,需要避免产生逻辑冲突
- 删除键也需要单独处理,它会直接影响光标的位置是否在变量文本节点末尾
- 为了更好的控制变量节点,这里考虑视该节点为一个整体进行操作,尤其是在删除时
js
function filterEmptyText(selection, range) {
// const selection = document.getSelection()
// const range = selection.getRangeAt(0)
// 获取当前文本节点的父节点
const parentNode = range.commonAncestorContainer.parentNode
// 是否处在变量文本节点的末尾
const isLast = range.commonAncestorContainer.length === range.endOffset
// 当键盘按键处于操作中时,相关逻辑不处理
if (parentNode && isLast && !keywordObj.isDoing) {
const isVarSpan = parentNode.nodeName === 'SPAN' && /\$\{(.+?)\}/.test(parentNode.innerHTML)
const isNextSibing = parentNode.nextSibling && parentNode.nextSibling.nodeName === 'SPAN'
// 控制光标和空白符的关系
if (isVarSpan && isNextSibing) {
// 1. 空白占位符存在,向后移动一位
console.log('符合1')
const myRange = document.createRange()
myRange.selectNodeContents(parentNode.nextSibling)
myRange.collapse(true)
selection.removeAllRanges()
selection.addRange(myRange)
} else if (isVarSpan && !isNextSibing) {
// 2. 空白占位符不存在,创建
console.log('符合2')
if (keywordObj.isDeleteKey) {
// 删除逻辑,直接删除整个变量,通过 isDeleteKey 避免影响光标位置
parentNode.parentNode.removeChild(parentNode)
} else {
// 插入空白占位符
editor.value.selection.createEmptyRange()
}
}
}
}
总结
富文本编辑器看似简单,其实涉及到的细节也是非常多的。我们习惯了使用成熟的技术框架、插件等来协助我们快速实现业务功能,不过,很多特殊功能也是来自业务需求,这会为我们提供一个深入探索技术的机会,通过结合实际项目和需求反馈得以不断完善我们的功能细节,这也是不断提升自己的过程。
在实现该功能的过程中,我们得到了正反馈,期间我们学习了技术原理,了解了富文本的发展历程,接触到了更先进的实现思想。从依靠浏览器提供的contenteditable以及execCommand这两大原生的API实现一个富文本编辑器(如UEditor、wangEditor) ,到通过数据模型对DOM Tree已经数据的修改操作进行了抽象,使开发者在大部分情况下,不是直接操作的DOM完成的各种功能,而是使用框架构建的模型所提供的API完成的(如Quill.js、ProseMirror、Draft.js、Slate),再到不依赖浏览器的编辑能力,独立实现光标和排版(如Goole Doc,查看DOM结构可以发现是基于canvas实现😮)
技术变化更迭太快了,这么一看我们的这一实现算是非常原始了,不过,万变不离其宗,我们基于简单模型的实现,可以快速让我们了解到其基本原理,并以此为引,不断精进,学习其他内容,最后做到知其然知其所以然。