一、前言
想必大家看到这个标题,心中不禁会浮现几个问题:
- 什么是富文本编辑器?
- 富文本编辑器和游戏角色有什么关系?
- 为什么是升级ing?
什么是富文本编辑器——富文本编辑器集成了格式设置、媒体嵌入、社交互动等一系列编辑功能,所见即所得的给用户提供多元的展示效果。譬如论坛、社区、评论等等都用到了富文本编辑器。
和游戏角色的关系——富文本编辑器和游戏角色有很多共通之处,为了让富文本编辑器的介绍更加有代入感,本文将采用游戏角色类比的方式进行讲解。至于共通之处体现在哪里,后面将一一介绍。
为什么是升级ing——“升级ing”代表持续的进行时,本文的目的是聚焦富文本编辑器的共性问题,抛砖引玉,希望能给大家提供一些解决思路。富文本编辑器一直在持续发展中,而对于共性问题的探索也从未停歇过。
本篇文章主要分为五个部分:
- 前言
- 了解富文本编辑器
- 富文本编辑器选型指南
- 富文本编辑器如何扩展
- 总结
本文通过游戏角色类比的方式,希望能够让富文本编辑器接触较少的开发者,都可以深入的了解富文本编辑器。今天,我们就一起来探讨下在富文本编辑器选型、扩展过程中遇到的共性问题。
二、了解富文本编辑器
通常,我们在选择一款新的游戏之前,都会选择先去官网、论坛了解游戏资料,从中筛选出有效信息,辅助我们选择合适的角色。
开发人员在接到富文本编辑器需求时,也不会随便选择其中一个,而是基于庞大的数据进行技术选型。这一节内容,就是为后续的选型所做的准备工作。
2.1 角色风格 - 富文本编辑器形态
游戏角色在开服上线前,都会默认配备不同的风格,则风格往往决定了我们对于角色的初始印象。
富文本编辑器同样具有几种常用的初始形态,经典模式、文档模式、内联模式,如下图所示:
那么从上图的对比中,可以看出来:富文本编辑器必不可少的组成部分是内容编辑区域。状态栏是用来记录编辑时的相关数据,可以隐藏。而工具栏则可以任意调整显示的位置、时机甚至切换至幕后操控(通过快捷键等方式触发)。
反之,我们可以获得这样一条讯息:通过工具栏、内容区域、状态栏、菜单栏的不同组合可以赋予富文本编辑器不同的展示形态。
2.2 成长阶段 - 富文本编辑器发展历程
游戏中的角色都是可成长角色,在成长过程总会出现一些瓶颈期,而跨过所谓的瓶颈期之后,角色的能力将出现显而易见的改变。
在整个发展过程中,富文本编辑器遇到过一些困境。也正是因为这些困境,可以将发展历程分为L0、L1、L2和L3阶段。
L0->L1
L0,即初代的富文本编辑器,依赖于浏览器自身的execCommand,仅提供了有限的命令,实现最简单的功能。随着对样式越来越丰富的要求,此时的富文本编辑器无法满足需求,L1阶段的编辑器应运而生。L1的富文本编辑器采用 自定义execCommand的方案,可以实现更加丰富的富文本功能。
L1->L2
L0、L1的富文本编辑器,仍然都是通过execCommand修改HTML。而不同浏览器中,对于同一表象的富文本,其HTML结构可能大不相同。
比如说 加粗 ,其HTML可能是加粗,可能是加粗,也可能是加粗等等。为了解决数据与视图无法一一对应的问题,提出了自定义数据模型的概念。
自定义数据模型, 是富文本编辑器在富文本HTML-DOM树的基础上抽离出来的数据结构,相同的数据结构可以保证渲染的HTML也是相同的。自定义的命令直接控制数据模型,最终保证渲染的HTML文档的一致性。
对于相同的HTML,不同的富文本编辑器最终呈现的数据模型并不相同。以 Hello EditorName 为例,这里对比了Quill、ProseMirror、Draft、Slate的数据模型如下:
L2阶段的富文本编辑器,通过抽离数据模型,解决了富文本中脏数据、复杂功能难以实现的问题。通过数据驱动,可以更好的满足定制功能、跨端解析、在线协作等需求。
L2->L3
到L2阶段的编辑器,可以满足绝大部分的使用场景。那为什么后面又发展出L3呢?
这是因为,L0-L2的富文本编辑器都是基于浏览器的contentEditable,在修改数据模型时,往往需要对用户操作进行拦截。对用户行为进行拦截是很难控制的,再加上不同浏览器的兼容问题,很容易出现bug。
为了解决contentEditable编辑不可控的问题,以 Google Docs 为代表的编辑器通过“**自研排版引擎”**步入了L3阶段。
自研排版引擎,彻底抛弃了contentEditable,通过自行控制光标位置、选区绘制、排版、监听输入等行为,实现和浏览器相似的编辑效果。“自研”,无疑具备了更高的扩展性。但与此相对应的,其开发难度高、成本高、隐性问题多,在整体体验和性能上与原生浏览器渲染仍存在一定差距,该阶段的编辑器还有一段路要走。
ps: There are a thousand Hamlets in a thousand people’s eyes。
下述关于成长阶段的划分仅基于作者本人的看法。
回顾富文本编辑器的发展历程,不难发现:富文本编辑器的结构脱离不了模型、视图、控制器这三大模块。如下图所示:
正如游戏角色所突破的瓶颈期,富文本编辑器在L1跃迁至L2发生的改变是:自定义数据模型的抽离;L2跃迁至L3的改变则是:排版引擎的全新定义。
三、富文本编辑器选型指南
当我们已经通过各种渠道了解到游戏背景、人物资料之后,下一步就要登录游戏创建游戏角色。此时,新手常常遇到的困扰无疑就是:如何选择最合适自己的游戏角色。
类似的,对初次接触富文本编辑器的小伙伴来说,常提到的问题是:我该选择哪款富文本编辑器?
首先,可以根据你的业务需求,选择对应阶段的富文本编辑器:
- 业务本身就是以富文本编辑器为核心,或者有协同编辑需求。—— 选择L2、L3的编辑器扩展,或者自研编辑器。这里可以参考有道云笔记和语雀的方案,参考链接见文末。
- 业务需求频繁迭代,交互设计要求较高的。—— 建议选择L2的编辑器。
- 业务较为稳定,要求不高的。——L1、L2任选。
- 如果业务场景比较复杂,难以评判之后的业务场景。——建议选择L2的编辑器。
其次,在选定好阶段的基础上,根据项目架构(Vue、React、Augular等),以及富文本编辑器自身的特点,选择适合的编辑器就可以。可以从下述几个方面考虑:
- 开源程度
- 社区生态
- 交互细节
- 扩展支持度
- 定制化成本
以上,就是我梳理的选型套路。像是CKEditor、TinyMCE、Quill等都是有口皆碑的,大家在选型的时候不妨可以考虑下这些编辑器:
四、富文本编辑器如何扩展
选择合适的角色,仅仅只是游戏的开始。在游戏过程中,需要不断地调整游戏角色的技能树,将其潜力发展到极限。
随着业务的不断发展,对富文本编辑器也会提出更高的要求。对此,经常困扰开发人员的往往就是以下几个问题:
1、如何快速的扩展富文本功能?
2、如何快速的让编辑器改头换面?
对于以上两个问题,下文将从能力扩展、主题改造两个方面进行分析。
4.1 能力扩展
本节内容不会聚焦某个富文本编辑器具体如何扩展,而是针对上述不同扩展方式分享一些通用的处理思路。
4.1.1 工具栏扩展
就像是游戏角色中,通过道具的不同装配方案,调整最终的战力数据。工具栏扩展就是通过对工具栏中不同功能的组合及改造,满足最终的业务需求。
常见的工具栏是由若干个功能按钮、状态按钮组、下拉菜单、模态框等组成,如下图所示:
一般的,富文本编辑器中都具备管理工具栏的配置项,可根据需要查阅官方文档。这里我们探讨一种场景,如何对已有的功能按钮进行扩展?
以“Quill编辑器字体高亮的功能”为例——该功能按钮的颜色与光标位置的字体颜色相呼应,从而达到绑定变化的效果,如下图所示:
那么,如果项目中引入的富文本编辑器不提供这样的能力,该如何处理呢?这里提供了两种方案:
1)控制功能按钮原生的UI样式。
以下图Tiny的字体高亮功能为例,按钮是svg的结构,可通过控制strokeColor/fillColor达到效果。此时只需要在编辑器中增加光标位置变化的监听OnSelectionChange,获取光标位置的字体高亮颜色,重置按钮UI。
2)SVG图标替换当前的按钮。
当功能按钮是通过图片的方式呈现,很难控制UI变化时,就可以采用此方案。以SVG图标替换图片图标,通过变更svg-path的strokeColor/fillColor,达到相同的效果。
小结:如果项目是初次引入富文本编辑器,这里不妨参考4.2主题改造中方案二。
4.1.2 菜单栏扩展
“菜单栏扩展”类似是给游戏角色的装备增加一些辅助的技能,这些新增的能力依托于装备,每个装备所配备的能力也互有差异。
本节所说的菜单栏,特指编辑器内部的内联菜单栏。比如图片工具栏、表格工具栏、右键菜单栏等。如下图所示:
对菜单栏来说,最常出现的需求就是:给现有的插件新增菜单栏,如何实现呢?
1)富文本编辑器提供关联配置能力,直接按照API文档配置即可。这里摘取了Tiny编辑器中部分菜单栏的配置方案,如下图所示:
2)不具备关联配置能力,此时需要监听光标位置的变化。当光标在对应富文本数据区域内变化时,触发事件/命令控制此菜单栏展示。
不管是以上哪种方案,扩展的菜单栏可以选择内置到编辑器中实现,也可以通过事件抛出到编辑器外部,以自定义组件的形式关联。我比较推荐使用自定义菜单栏组件的方案:
// 伪代码仅作为示例 辅助理解
// 富文本编辑器
<Editor :config="editorConfig"/>
// 自定义菜单栏组件
<ContextToolBarComponent @command="handleCommand"/>
editor.on("selectionChange",(selection)=>{
// 判断选区位置
CheckSelectionDataModel(selection)
// 控制菜单栏展示隐藏,绑定数据实体
ControlContextToolBarComponentShowHide(selectionPosition)
})
// 菜单功能触发
handleCommand(command, _instance){
editor.execCommand(command, _instance)
}
4.1.3 编辑器内部扩展
对于游戏角色的战力提升而言,道具和装备都属于外力上的加强,角色本身也是需要重点关注的。
在富文本编辑器发展历程一节中,总结出富文本编辑器的结构脱离不了模型、视图、控制器这三大模块,那么从这三个模块出发,扩展的方案也有所区分。
数据模型扩展
之前的介绍中提到过,L1-L2阶段变迁的关键行为是抽离自定义数据模型。富文本编辑器的数据模型决定了最终富文本渲染的结构。当某个预置的富文本结构不能满足预期时,就需要对这个富文本的数据模型进行扩展。根据富文本编辑器是处于L2阶段前或阶段后,扩展方式也有较大区别。
以图片数据扩展关联图片备注为例,将
<figure>
<img src=“xxx”/>
</figure>
扩展为
<figure>
<img src=“xxx”/>
<caption>图片备注</caption>
</figure>
1)L2阶段及之后 富文本编辑器已具备数据模型抽象能力,此时只需要在数据结构中新增/编辑定义好的数据对象,并绑定渲染的HTML结构即可:
// 原数据结构 {type:'image',src:'xxx'}
// 扩展为 {type:'image',src:'xxx',caption:'图片备注'}
// 新增数据对象 caption:'图片备注',绑定HTML结构 `<caption>图片备注</caption>`
2)L2阶段之前, 富文本数据未进行数据模型的抽象。针对这种场景,可以利用html-parse-stringify插件,自行抽离数据模型,再进行数据扩展。以
hello HTML-parse-stringfy
为例,可以转化为下图所示的数据结构:
html-parse-stringify插件,可以将HTML_AST化,从转化为所需要的数据结构,当前html-parse-stringify 也有一些问题,本文中不做扩散,感兴趣的同学可以留言讨论。
视图扩展
视图应该比较好理解,属性数据相同的角色,配备不同的皮肤或者技能特效,在战斗过程中的呈现效果不同。同一个富文本数据源,通过不同的视图扩展,就可以展示不同的视觉效果。
1)不改变富文本的数据结构,仅在样式设置上有所区分
通过切换DOM结构上绑定的class属性,切换不同的样式:
<blockquote class="pgc-blockquote-abstract">引用内容</blockquote>
<blockquote>引用内容</blockquote>
<blockquote class="pgc-blockquote-quote">引用内容</blockquote>
2)直接改变富文本的数据结构
普通链接切换至卡片,数据结构由Inline-Block切换至Block(link-card),DOM渲染随之切换。DOM结构对比如下:
<p><br></p><p><a href="http://www.vivo.com.cn">vivo智能手机官方网站-X60系列丨专业影像旗舰</a></p><p><br></p>
<p><br></p><a href="http://www.vivo.com.cn" data-draft-node="block" data-draft-type="link-card">vivo智能手机官方网站-X60系列丨专业影像旗舰</a><p><br></p>
控制器扩展
控制器是比较抽象的概念,对游戏角色来说主要是用来控制技能触发条件、释放的时机、触发条件及属性影响之类的。
类似的,富文本编辑器的控制器也是对数据层及视图层控制方式的统称。控制器的扩展,可以通过 事件、命令、配置项 等多维度实现。今天,我们简单聊一下事件和命令如何扩展。
1)事件的扩展
事件有点像是主动技能,由角色主动释放。富文本编辑器会主动抛出一些事件,实现在编辑器内部或外部的控制,如OnselectionChange、OnInit等等。当新增的功能需要由编辑器内部控制外部组件,且原生的事件无法满足时,往往需要通过新增事件监听的形式实现。
事件的扩展在跨端操作中非常有用,后续会在跨端实践一文中重点介绍。
// 简单举个例子,图片上传失败后往往需要触发重新上传 :
// 若图片通过编辑器上传,失败后点击重新上传是编辑器自带的行为逻辑。
// 但放在客户端控制资源上传的场景下,便需要编辑器通知客户端“某某资源重新请求上传”。
// 这个时候的跨端通信,就需要富文本编辑器抛出事件通知客户端执行操作。
editor.on('appRetryingUploadImage', ({ data }) => {
call('reUploadPic', { picUrl: data.path, fileId: data.id })
})
2)命令的扩展
命令控制与事件控制逻辑相反,命令类似被动技能,当外部环境达到某个条件时,触发角色的某种操作。富文本编辑器的命令管理就提供了在编辑器外部控制编辑器内部操作的能力。当操作不在Commond命令库时,就需要对Command命令进行扩展。不同编辑器对Command的扩展写法不同,但是万变不离其宗 —— Command的核心是exec、refresh。
以CKEditor与Tiny为例:
CK5
class XXXCommand extends Command{
refresh(){}
execute(){}
}
CK4
editor.addCommand('XXXCommand', {
exec: ()=> {},
refresh: ()=>{}
})
Tiny
editor.addCommand('XXXCommand', () => {});
exec为执行命令的回调函数,用来控制编辑器的相关操作的执行;refresh为命令指行结束后的回调函数,常用来控制命令执行后编辑器相关状态的刷新;
除事件、命令外,部分编辑器还可以通过扩展配置项等方式,达到定制化操作的目的。
4.1.4 新增富文本功能插件
要想将新技能的价值发挥到最大,不仅需要将角色的属性数据提升到合适的水平,还要灵活调配技能组,配置合适的道具装备等等。
富文本编辑器新增一插件,往往需要多个模块共同扩展:
展开介绍下上图中的各个模块:
定义数据模型
通过4.1.3 数据模型扩展一节,我们可以发现:数据模型是新增富文本功能的核心。只有先确定好数据层,才能决定视图渲染如何控制,以及最终如何呈现在前端。
定义数据模型,主要分三步走:
1、确定数据模型的DOM是以Inline类型、Block类型还是可切换;
2、明确数据模型的准入限制及其可编辑限制,例如说标题中不能嵌套超链接等类似的规则;
3、确定数据模型及其数据输入、数据输出;
- 数据输入 即需要配置的内容,以图片为例,需要图片URL、图片的备注文案
- 数据输出 为编辑器HTML渲染后的DOM结构
- 数据模型 包括:存储的HTML字符串、抽象的自定义数据类型(JSON)
输入-模型-输出的转化示例图,如下图所示:
自定义工具栏按钮
工具栏按钮是数据控制的窗口,可以外显在工具栏中,也可以隐藏通过快捷键控制。如果外显在工具栏中,需要根据具体需求,定制对应状态的功能按钮,绑定菜单或者控制操作,可参考4.1.1工具栏扩展一节。
新增事件或命令
确定好数据核心和控制窗口之后,下一步就是制订控制策略。首先确定需求中的控制策略,是正向的——由富文本编辑器操作触发外部反馈,还是反向的——由外部触发编辑器内部操作,还是两者皆存在。然后根据控制策略,对应的选择扩展事件、命令还是两者都扩展。具体扩展方案可参考4.1.3控制器扩展一节
关联光标选区
通过光标的位置,确定当前选区对应的数据结构,从而控制特殊状态的切换。怎么确定是否需要关联光标选区呢?
1、新增功能的按钮状态是否与光标位置有关。在自定义工具栏按钮这一步骤中就可以完成关联;
2、新增功能是否需要关联菜单栏显示。处理方案参考4.1.2菜单栏扩展一节;
3、新增功能是否与其他富文本功能相关联。如互斥逻辑 —— 标题内不允许插入超链接;
若确定需要关联光标选区,那么富文本编辑器中就需要增加OnSelectionChange的监听,完成相关的处理。
editor.on("selectionChange",(selection)=>{
// 判断选区位置
CheckSelectionDataModel(selection)
// 修改自身及其他按钮状态
ChangeButtonStatus(button)
// 控制菜单显隐
ControlMenuShow(menuBar)
})
关联操作记录管理(撤销、重做)
在富文本编辑器中进行交互操作时,不可避免的会出现一些误操作。富文本编辑器的交互场景越复杂,出现误操作的概率越高。因此一般富文本编辑器都会对操作记录进行管理,用以降低误操作所带来的影响。
不同的富文本编辑器中undo/redo的处理逻辑不同,相似的是富文本编辑器会定义操作过程中的关键行为(如常见的插入、删除等),将其存储在操作记录中。
当我们在新增的插件功能中关联操作记录管理时,只需要复用其他插件关键行为的入库出库逻辑就可以啦。
// 伪代码,仅辅助理解
UndoManage.push(keyOperation)
UndoManage.undo()
UndoManage.redo()
增加复制粘贴控制
“复制粘贴”算是富文本编辑器操作中最为头疼的问题之一,有相关开发经验的小伙伴们应该遇到过,从其他来源复制的内容粘贴到编辑器内,视图展示异常的情况。针对这种情况,往往需要对剪切板中的数据进行过滤,转化为富文本编辑器可识别的数据。
editor.on('paste',(evt)=>{
// 根据光标处对应的数据结构,确定过滤规则
let filterRules = checkSelection()
// filterRulers JSON数据结构对数据对象进行过滤修改
// filterRulers HTML字符串可以使用正则表达式或者编辑器内置的过滤方法
evt.data = filterRulers.exec(evt.data)
})
4.2 主题改造
“主题改造”应该很好理解,就是游戏中的更换皮肤,快速的切换游戏角色的风格。
在富文本编辑器中主题改造,其实也就是工具栏、菜单栏以及特殊富文本的样式上的更换。通常的处理方案有两种:
引入新主题样式文件。替换新主题样式文件,或者在旧主题样式上进行样式覆盖。
构建脱离于编辑器本身的工具栏组件。将主题修改涉及到的工具栏、菜单栏脱离编辑器,在项目中创建全新的工具栏组件、菜单栏组件。
如果是对已有项目进行改造,那么需要考虑到新旧主题切换的投入产出比,择优选取;如果是新项目且对主题样式细节要求较高的话,可以采用方案二。
// 伪代码 仅辅助理解
<!-- 自定义工具栏 -->
<CustomToolbarComponent>
<ButtonBold @click="execCommondBold"/>
<ButtonUnderline @click="execCommondUnderline"/>
<ButtonHead @click="execCommondHead"/>
</CustomToolbarComponent>
<!-- 富文本编辑器编辑区域 -->
<EditorContainer></EditorContainer>
<script>
// 执行富文本编辑器的Commond
execCommondBold:()=>{
editor.execCommond('bold')
}
execCommondUnderline:()=>{
editor.execCommond('underline')
}
execCommondHead:()=>{
editor.execCommond('head')
}
</script>
<style>
// 自定义主题样式
button-bold{}
button-underline{}
button-head{}
</style>
方案二相较于方案一来说:
- 优点:将工具栏的控制权由第三方编辑器,迁移至项目中,在可控性和扩展性都能得到最大限度的提升;对跨端业务的适配度更高,各端只需一套控制方案,各功能组件分渠道定制即可;
- 缺点:需要将工具栏中按钮绑定的命令/事件、状态绑定等控制方案转移至新的组件中,会占用一定的开发成本。
小结:功能扩展和主题改造的方案不止以上这些,也存在其他折衷的方案,只需要根据业务场景选择合适的方案即可。有句话说的好:「最好的不一定适合,适合的才是最好的」。
至此,本篇文章的内容也就接近尾声了。希望大家看到这里,对于以下几个问题,能得到一些解答:
1、基于现在的业务需求,该选择哪款富文本编辑器?
2、随着业务的扩展,该如何扩展富文本功能?
3、设计改版,如何快速的改头换面?
五、总结
就像是在游戏世界中,你不得不打怪升级。同时在过程中,你也会积累更多的技巧,为之后的越级打怪打下坚实的基础。在富文本编辑器开发过程中,确实会遇到很多难解的问题、复杂的需求,花费了我们大量的时间精力。在一次又一次的锤炼中,我们都将会有所收获,有所成长。
本篇文章分享了我在富文本编辑器开发过程中关于共性问题的一些思考,希望能对即将参与富文本编辑器开发的小伙伴,或者正在进行富文本编辑器开发的小伙伴带来一些帮助。
后续还会跟大家分享一些富文本编辑器在跨端方案解决上的一些经验,如果感兴趣的话可以持续关注。
参考资料
- 有道云笔记新版编辑器架构设计(上)
- 有道云笔记新版编辑器架构设计(下)
- 富⽂本编辑器的技术演进
- 开源富文本编辑器技术的演进(2020 1024)
- 从流行的编辑器架构聊聊富文本编辑器的困境
- Quill Editor
- CKEditor
- TinyMCE
作者:vivo互联网前端团队-Tian Yuhan