先前我们提到了,数据模型的设计是编辑器的基础模块,其直接影响了选区模块的表示。选区模块的设计同样是编辑器的基础部分,编辑器应用变更时操作范围的表达,就需要基于选区模型来实现,也就是说选区代表的意义是编辑器需要感知在什么范围内执行变更命令。
从零实现富文本编辑器项目的相关文章:
数据模型设计直接影响了编辑器选区模型的表达,例如下面的例子中Quill
与Slate
编辑器的模型选区实现,其与本身维护的数据结构密切相关。然而无论是哪种编辑器设计的数据模型,都需要基于浏览器的选区来实现,因此在本文中我们先来实现浏览器选区模型的基本操作。
实际上选区这个概念还是比较抽象,但是我们应该是经常与其打交道的。例如在鼠标拖动文本部分内容时,这部分会携带淡蓝色的背景色,这就是选区范围的表达,同样我们可能会将其称为选中、拖蓝、选中范围等等,这便是浏览器提供的选区能力。
除了选中文本的蓝色背景色外,闪烁的光标同样是选区的一种表现形式。光标的选区范围是一个点,或者可以称为折叠的选区。光标的表达通常只出现在可编辑元素中,例如输入框、文本域、ContentEditable
元素等。若是在非可编辑元素中,光标处于虽然不可见状态,但实际上仍然是存在的。
浏览器选区的操作主要基于Range
和Selection
对象,Range
对象表示包含节点和部分文本节点的文档片段,Selection
对象表示用户选择的文本范围或光标符号的当前位置。
Range
对象与数学上的区间概念类似,也就是说Range
指的是一个连续的内容范围。数学上的区间表示由两个点即可表示,Range
对象的表达同样是由startContainer
起始到endContainer
结束,因此选区必须要连续才能够正常表达。Range
实例对象的属性如下:
startContainer
:表示选区的起始节点。startOffset
:表示选区的起始偏移量。endContainer
:表示选区的结束节点。endOffset
:表示选区的结束偏移量。collapsed
: 表示Range
的起始位置和终止位置是否相同,即折叠状态。commonAncestorContainer
:表示选区的共同祖先节点,即完整包含startContainer
和endContainer
最深一级的节点。Range
对象还存在诸多方法,在我们的编辑器中常用的主要是设置设置选区起始位置setStart
、设置结束位置setEnd
、获取选区矩形位置getBoundingClientRect
等。在下面的例子中,我们就可以获取文本片段23
的位置:
获取文本片段矩形位置是个非常重要的应用,这样我们可以实现非完整DOM
元素的位置获取,而不是必须通过HTML DOM
来获取矩形位置,这对实现诸如划词高亮等效果非常有用。此外,需要关注的是这里的firstChild
是Text
节点,即值为Node.TEXT_NODE
类型,这样才可以计算文本片段。
既然可以设置文本节点,那么自然也存在非文本节点的状态。在调用设置选区时,如果节点类型是Text
、Comment
或CDATASection
之一,那么offset
指的是从结束节点算起字符的偏移量。对于其他Node
类型节点,offset
是指从结束结点开始算起子节点的偏移量。
在下面的例子中,我们就是将选区设置为非文本内容的节点。此时最外层的$1
节点作为父节点,存在2
个子节点,因此offset
可以设置的范围是0-2
,若此时设置为3
则会直接抛出异常。这里基本与设置文本选区的offset
一致,差异是文本选区必须要文本节点,非文本则是父节点。
构造Range
对象主要的目的便是获取相关DOM
的位置来计算,还有个常见的需求是实现内容高亮,通常来说这需要我们主动计算位置来实现虚拟图层。在较新的浏览器中实现了::highlight
伪元素,可以帮我们用浏览器的原生实现来实现高亮效果,下面的例子中23
文本片段就会出现背景色。
Selection
对象表示用户选择的文本范围或光标符号的当前位置,其代表页面中的文本选区,可能横跨多个元素。文本选区通常由用户拖拽鼠标经过文字而产生,实际上浏览器中的Selection
就是由Range
来组成的。Selection
对象的主要属性如下:
anchorNode
:表示选区的起始节点。anchorOffset
:表示选区的起始偏移量。focusNode
:表示选区的结束节点。focusOffset
:表示选区的结束偏移量。isCollapsed
:表示选区的起始位置和终止位置是否相同,即折叠状态。rangeCount
:表示选区中包含的Range
对象的数量。用户可能从左到右选择文本或从右到左选择文本,anchor
指向用户开始选择的地方,而focus
指向用户结束选择的地方。anchor
和focus
的概念不能与选区的起始位置startContainer
和终止位置endContainer
混淆,Range
对象永远是start
节点指向end
节点。
anchor
: 选区的锚指的是选区起始点,当我们使用鼠标框选一个区域的时候,锚点就是我们鼠标按下瞬间的那个点。在用户拖动鼠标时,锚点是不会变的。focus
: 选区的焦点指的是选区的终点,当我们用鼠标框选一个选区的时候,焦点是我们的鼠标松开瞬间所记录的那个点。随着用户拖动鼠标,焦点的位置会随着改变。我们可以通过SelectionChange
事件来监听选区的变化,在这个选区的事件回调中,可以通过window.getSelection()
来获取当前的选区对象状态。通过getSelection
获取的选区实例对象是个单例对象,即引用是同实例,内部的值是变化的。
当然W3C
标准并未强制要求单例,但主流浏览器Chrome
、Firefox
、Safari
实现为同一实例。不过为了保证兼容性以及null
状态,我们实际要使用选区对象的时候,通常是实时通过getSelection
获取选区状态。此外Selection
对象的属性是不可枚举的,spread
操作符是无效的。
编辑器中自然还需要设置选区的操作,这部分可以使用Selection
对象的addRange
方法来实现,通常在调用该方法之前需要使用removeAllRanges
方法来移除已有选区。需要注意的是,该方法无法处理反向的选区,即backward
状态。
因此设置选区时,通常是需要使用setBaseAndExtent
来实现。正向的选区直接将start
、end
方向的节点设置为base
和extent
即可,反向选区则是将start
、end
方向的节点设置为extent
和base
,这样就可以实现反向选区的效果,DOM
节点参数与Range
基本一致。
设置选区的部分可以通过上述API
实现,而获取选区的部分虽然可以通过focus
和anchor
来获取。但是在Selection
对象上并未标记backward
状态,因此我们还需要通过getRangeAt
方法来获取选区内建的Range
对象,因此来对比原始对象的状态。
Selection
对象上的rangeCount
属性表示选区中包含的Range
对象的数量,通常我们只需要获取第一个选区即可。这里的判断条件需要特别关注,因为若是当前没有选区值,也就是rangeCount
为0
,此时直接获取内建选区对象是会抛出异常的。
此外我们可能会好奇,通常进行选区操作的时候我们都是只能选单个连读的选区的,为什么会出现rangeCount
属性。这里是需要注意在Firefox
中是可以设置多个选区的,按住Ctrl
键可以实现多个选区的状态,但是为了主要浏览器兼容性通常我们只需要处理首个选区。
选区这部分还有个比较有趣的控制效果,通常来说ContentEditable
元素是应该作为整体被选中的,此时内部的文本节点表现仍然是被选中的状态。而若是真的想避免文本内容被选中,便可以使用user-select
属性来完成,由于选区实际上靠边缘节点就可以实现,就可以出现特殊的选区断层效果。
此外,下面的例子中我们是使用childNodes
而不是children
来获取子节点的状态。childNodes
是获取所有子节点的集合,包括文本节点、注释节点等,而children
只会获取元素节点的集合,因此在设置选区时需要特别注意。
在Range
对象的最后,我们介绍了::highlight
伪元素,在这里我们同样介绍一下::selection
伪元素。::selection
用于将央视应用于用户选中的文本部分,即我们可以通过设置::selection
的样式来实现选区的样式控制,例如设置背景色、字体颜色等。
在这里还有个比较有趣的事情,在实现浏览器的扩展或者脚本时比较有用。如果想恢复::selection
伪元素的原始背景色,是不可以设置为transparent
的,因为这会导致选区的背景色消失,实际上默认的浅蓝色背景是保留的关键字highlight
,当然使用#BEDAFF
也是可行的。
虽然选区本质上跟可编辑元素并没有直接关系,但是我们实现的目标是富文本编辑器,自然是需要在ContentEditable
元素中处理选区状态。不过在这之前,我们可以先简单针对于input
元素实现选区操作。
紧接着来看ContentEditable
实现,本质上对于选区的操作是否是可编辑元素是没有什么区别的。只不过在可编辑元素中会显示地表现出光标,或者称为插入符。而在非可编辑元素中,光标虽然不可见,但是选区的变换事件以及转移实际上还是真实存在的。
其实在最开始的时候,我想实现的就是纯Blocks
的编辑器,而实际上目前我并没有找到比较好的编辑器实现来做参考,主要是类似的编辑器都设计的特别复杂,在没有相关文章的情况很难理解。当时也还是比较倾向于quill-delta
的数据结构,因为其无论是对于协同的支持还是diff
、ops
的表达都非常完善。
因此最开始想的是通过多个Quill Editor
实例来实现嵌套Blocks
,实际上这里边的坑会有很多,需要禁用大量的编辑器默认行为并且重新实现。例如History
、Enter
换行操作、选区变换等等,可以预见这其中需要关注的点会有很多,但是相对于从零实现编辑器需要适配的各种浏览器兼容事件还有类似于输入事件的处理等等,这种管理方式还算是可以接受的。
在这里需要关注一个问题,Blocks
的编辑器在数据结构上必然需要以嵌套的数据结构来描述,当然初始化时可以设计的扁平化的Block
,然后对每个Block
都存储了string[]
的Block
节点信息来获取引用。如果在设计编辑器时不希望有嵌套的结构,而是希望通过扁平的数据结构描述内容,此时在内容中如果引用了块结构,那么就再并入Editor
实例,这种思路会全部局限于富文本框架,扩展性会差一些。
Blocks
的编辑器是完全由最外层的Block
结构管理引用关系,也就是说引用是在children
里的,而块引用的编辑器则需要由编辑器本身来管理引用关系,也就是说引用是在ops
里的。所以说对于数据结构的设计与实现非常依赖于编辑器整体的架构设计,当然我们也可以将块引用的编辑器看作单入口的Blocks
编辑器,这其中的Line
表达全部交由Editor
实例来处理,这就是不同设计中却又相通的点。
在具体尝试实现编辑器的过程中,发现浏览器中存在明确的选区策略,在下面例子State 1
的ContentEditable
状态下,无法做到从Selection Line 1
选择到Selection Line 2
。这是浏览器默认行为,而这种选区的默认策略就定染导致无法基于这种模型实现Blocks
。
而如果是Stage 2
的模型状态,是完全可以做到选区的正常操作的,在模型方面没有什么问题,但是我们此时的Quill
选区又出现了问题。由于其在初始化时是会由<br/>
产生到div/p
状态的突变,导致其选区的Range
发生异动,此时在浏览器中的光标是不正确的。
而我们此时没有办法入侵到Quill
中帮助其修正选区,且DOM
上没有任何辅助我们修正选区的标记,所以这个方式也难以继续下去。甚至于如果需要处理其中状态变更的话,还需要侵入到parchment
的视图层实现,这样就需要更加复杂的处理。
因此在这种状态下,我们可能只能选取Stage 3
策略的形式,并不实现完整的Blocks
,而是将Quill
作为嵌套结构的编辑器实例。在这种模型状态下编辑器不会出现选区的偏移问题,我们的嵌套结构也可以借助Quill
的Embed Blot
来实现插件扩展嵌套Block
结构。
在这种情况下,如果想实现Blocks
编辑器的目标,通过Quill
来实现就只能依赖于Embed Blot
的模式来实现,而这又是完全依赖于Quill
维护的视图层。如果需要处理边界Case
就需要再处理parchment
的视图层,这样下来会非常麻烦,因此这也是从零实现编辑器的部分原因。
其实类似的问题在editor.js
中也有存在,在其在线DEMO
中可以发现我们无法从纯文本行1
选择到行2
。具体是在选中行1
的部分文本后,拖拽选择到行2
的部分文本时,会发现行1
和2
会被全部选中,基于slate
实现的Yoopta-Editor
也存在类似的问题。
这个问题在飞书文档上就并不存在了,在飞书文档中的重点是选区必然处于同一个父级Block
下,具体的实现是当鼠标按下时拖选完全处于非受控状态,此时若是碰撞到其他块内,内部的选区样式会被::selection
覆盖掉,然后这个块整体的样式会被class
应用选中的样式。
而此时若是抬起鼠标,此时会立即纠正选区状态,此时的选区实现是会真正处于同一个父级块中。处于同一个块是为了简化操作,无论是应用变更还是获取选中片段的数据,在同一个父级块中进行迭代就不需要基于渲染块的顺序来递归迭代查找,这样在数据处理方面会更加简单。
在我们的设计中是基于ContentEditable
实现,也就是说没有准备实现自绘选区,只是最近思考了一下自绘选区的实现。通常来说在整体编辑器内的contenteditable=false
节点会存在特殊的表现,在类似于inline-block
节点中,例如Mention
节点中,当节点前后没有任何内容时,我们就需要在其前后增加零宽字符,用以放置光标。
在下面的例子中,line-1
是无法将光标放置在@xxx
内容后的,虽然我们能够将光标放置之前,但此时光标位置是在line node
上,是不符合我们预期的文本节点的。那么我们就必须要在其后加入零宽字符,在line-2/3
中我们就可以看到正确的光标放置效果。这里的0.1px
也是个为了兼容光标的放置的magic
,没有这个hack
的话,非同级节点光标同样无法放置在inline-block
节点后。
那么除了通过零宽字符或者<br>
标签来放置光标外,自然也可以通过自绘选区来实现,因为此时不再需要ContentEditable
属性,那么自然就不会存在这些奇怪的行为。因此如果借助原生的选区实现,然后在此基础上实现控制器层,就可以实现完全受控的编辑器。
但是这里存在一个很大的问题,就是内容的输入,因为不启用ContentEditable
的话是无法出现光标的,自然也无法输入内容。而如果我们想唤醒内容输入,特别是需要唤醒IME
输入法的话,浏览器给予的常规API
就是借助<input>
来完成,因此我们就必须要实现隐藏的<input>
来实现输入,实际上很多代码编辑器例如 CodeMirror 就是类似实现。
但是使用隐藏的<input>
就会出现其他问题,因为焦点在input
上时,浏览器的文本就无法选中了。因为在同个页面中,焦点只会存在一个位置,因此在这种情况下,我们就必须要自绘选区的实现了。例如钉钉文档、有道云笔记就是自绘选区,开源的 Monaco 同样是自绘选区,TextBus 则绘制了光标,选区则是借助了浏览器实现。
其实这里说起来TextBus
的实现倒是比较有趣,因为其自绘了光标焦点需要保持在外挂的textarea
上,但是本身的文本选区也是需要焦点的。因此考虑这里的实现应该是具有比较特殊的实现,特别是IME
的输入中应该是有特殊处理,可能是重新触发了事件。而且这里的IME
输入除了本身的非折叠选区内容删除外,还需要唤醒字符的输入,此外还有输入过程中暂态的字符处理,自绘选区复杂的地方就在输入模块上。
最后探究发现TextBus
既没有使用ContentEditable
这种常见的实现方案,也没有像CodeMirror
或者Monaco
一样自绘选区。从Playground
的DOM
节点上来看,其是维护了一个隐藏的iframe
来实现的,这个iframe
内存在一个textarea
,以此来处理IME
的输入。
这种实现非常的特殊,因为内容输入的情况下,文本的选区会消失,也就是说两者的焦点是会互相抢占的。那么先来看一个简单的例子,以iframe
和文本选区的焦点抢占为例,可以发现在iframe
不断抢占的情况下,我们是无法拖拽文本选区的。这里值得一提的是,我们不能直接在onblur
事件中进行focus
,这个操作会被浏览器禁止,必须要以宏任务的异步时机触发。
实际上这个问题是我踩过的坑,注意我们的焦点聚焦调用是直接调用的$1.focus
,假如此时我们是调用win.focus
的话,那么就可以发现文本选区是可以拖拽的。通过这个表现其实可以看出来,主从框架的文档的选区是完全独立的,如果焦点在同一个框架内则会相互抢占,如果不在同一个框架内则是可以正常表达,也就是$1
和win
的区别。
其实可以注意到此时文本选区是灰色的,这个可以用::selection
伪元素来处理样式,而且各种事件都是可以正常触发的,例如SelectionChange
事件以及手动设置选区等。当然如果直接在iframe
中放置textarea
的话,可以得到同样的表现,同样也可以正常的输入内容,并且不会打断IME
的输入法,这个Magic
的表现在诸多浏览器都可以正常触发。
那么除了特殊的TextBus
外,CodeMirror
、Monaco/VSCode
、钉钉文档、有道云笔记的编辑器都是自绘选区的实现。那么自绘选区就需要考虑两点内容,首先是如何计算当前光标在何处,其次就是如何绘制虚拟的选区图层。选区图层这部分我们之前的diff
和虚拟图层实现中已经聊过了,我们还是采取相对简单的三行绘制的形式实现,现在基本都是这么实现的,折行情况下的独行绘制目前只在飞书文档的搜索替换中看到过。
因此复杂的就是光标在何处的计算,我们的编辑器选区依然可以保持浏览器的模型来实现,主要是取得anchor
和focus
的位置即可。那么在浏览器中是存在API
可以实现光标的位置选区Range
的,目前我看只有VSCode
中使用了这个API
,而CodeMirror
和钉钉文档则是自己实现了光标的位置计算。CodeMirror
中通过二分查找来不断对比光标和字符位置,这其中折行的查找会导致复杂了不少。
说起来,VSCode
的包管理则是挺有趣的管理,VSC
是开源的应用,在其中提取了核心的monaco-editor-core
包。然后这个包会作为monaco-editor
的dev
依赖,在打包的时候会将其打包到monaco-editor
中,monaco-editor
则是重新包装了core
来让编辑器可以运行在浏览器web
容器内,这样就可以实现web
版的VSCode
。
在这里我们可以使用浏览器相关的API
来实现光标的选区位置计算,配合起来相关的API
兼容性也是比较好的,当然如果在shadow DOM
中使用的话兼容性就比较差了。这是基于浏览器DOM
的实现,若是使用Canvas
绘制选区的话,就需要完全基于绘制的文本来计算了。这部分实现起来倒是也并不是很复杂,DOM
绘制的复杂是因为我们难以获取文本位置及大小,而在Canvas
中这些信息我们通常都是会记录的。
在这里我们总结了浏览器的相关API
,并且基于Range
对象与Selection
对象实现了基本的选区操作,并且举了相关的应用具体场景和示例。此外,还介绍了先前针对Blocks
编辑器的选区遇到的问题,在最后我们调研了诸多自绘选区的编辑器实现,并且实现了简单的自绘选区示例。
接下来我们会从数据模型出发,设计编辑器选区模型的表示,然后在浏览器选区相关的API
基础上,实现编辑器选区模型与浏览器选区的同步。通过选区模型作为编辑器操作的范围目标,来实现编辑器的基础操作,例如插入、删除、格式化等操作,以及诸多选区相关的边界操作问题。