# 浏览器选区模型的核心交互策略 先前我们提到了,数据模型的设计是编辑器的基础模块,其直接影响了选区模块的表示。选区模块的设计同样是编辑器的基础部分,编辑器应用变更时操作范围的表达,就需要基于选区模型来实现,也就是说选区代表的意义是编辑器需要感知在什么范围内执行变更命令。 - 开源地址: - 在线编辑: - 项目笔记:
从零实现富文本编辑器系列文章 - [深感一无所长,准备试着从零开始写个富文本编辑器](./从零设计实现富文本编辑器.md) - [从零实现富文本编辑器#2-基于MVC模式的编辑器架构设计](./基于MVC模式的编辑器架构设计.md) - [从零实现富文本编辑器#3-基于Delta的线性数据结构模型](./基于Delta的线性数据结构模型.md) - [从零实现富文本编辑器#4-浏览器选区模型的核心交互策略](./浏览器选区模型的核心交互策略.md) - [从零实现富文本编辑器#5-编辑器选区模型的状态结构表达](./编辑器选区模型的状态结构表达.md) - [从零实现富文本编辑器#6-浏览器选区与编辑器选区模型同步](./浏览器选区与编辑器选区模型同步.md) - [从零实现富文本编辑器#7-基于组合事件的半受控输入模式](./基于组合事件的半受控输入模式.md) - [从零实现富文本编辑器#8-浏览器输入模式的非受控DOM行为](./浏览器输入模式的非受控DOM行为.md) - [从零实现富文本编辑器#9-编辑器文本结构变更的受控处理](./编辑器文本结构变更的受控处理.md) - [从零实现富文本编辑器#10-React视图层适配器的模式扩展](./React视图层适配器的模式扩展.md) - [从零实现富文本编辑器#11-Immutable状态维护与增量渲染](./Immutable状态维护与增量渲染.md) - [从零实现富文本编辑器#12-React可编辑节点的组件预设](./React可编辑节点的组件预设.md) - [从零实现富文本编辑器#13-React非编辑节点的内容渲染](./React非编辑节点的内容渲染.md) - [从零实现富文本编辑器#14-编辑器历史变更管理与状态回溯](./编辑器历史变更管理与状态回溯.md)
## 浏览器选区 数据模型设计直接影响了编辑器选区模型的表达,例如下面的例子中`Quill`与`Slate`编辑器的模型选区实现,其与本身维护的数据结构密切相关。然而无论是哪种编辑器设计的数据模型,都需要基于浏览器的选区来实现,因此在本文中我们先来实现浏览器选区模型的基本操作。 ```js // Quill { index: 0, length: 3 } // Slate { anchor: { offset: 0, path: [0, 0] }, focus: { offset: 3, path: [0, 0] } } ``` 实际上选区这个概念还是比较抽象,但是我们应该是经常与其打交道的。例如在鼠标拖动文本部分内容时,这部分会携带淡蓝色的背景色,这就是选区范围的表达,同样我们可能会将其称为选中、拖蓝、选中范围等等,这便是浏览器提供的选区能力。 除了选中文本的蓝色背景色外,闪烁的光标同样是选区的一种表现形式。光标的选区范围是一个点,或者可以称为折叠的选区。光标的表达通常只出现在可编辑元素中,例如输入框、文本域、`ContentEditable`元素等。若是在非可编辑元素中,光标处于虽然不可见状态,但实际上仍然是存在的。 浏览器选区的操作主要基于`Range`和`Selection`对象,`Range`对象表示包含节点和部分文本节点的文档片段,`Selection`对象表示用户选择的文本范围或光标符号的当前位置。 ### Range `Range`对象与数学上的区间概念类似,也就是说`Range`指的是一个连续的内容范围。数学上的区间表示由两个点即可表示,`Range`对象的表达同样是由`startContainer`起始到`endContainer`结束,因此选区必须要连续才能够正常表达。`Range`实例对象的属性如下: - `startContainer`:表示选区的起始节点。 - `startOffset`:表示选区的起始偏移量。 - `endContainer`:表示选区的结束节点。 - `endOffset`:表示选区的结束偏移量。 - `collapsed`: 表示`Range`的起始位置和终止位置是否相同,即折叠状态。 - `commonAncestorContainer`:表示选区的共同祖先节点,即完整包含`startContainer`和`endContainer`最深一级的节点。 `Range`对象还存在诸多方法,在我们的编辑器中常用的主要是设置设置选区起始位置`setStart`、设置结束位置`setEnd`、获取选区矩形位置`getBoundingClientRect`等。在下面的例子中,我们就可以获取文本片段`23`的位置: ```html 123456 ``` 获取文本片段矩形位置是个非常重要的应用,这样我们可以实现非完整`DOM`元素的位置获取,而不是必须通过`HTML DOM`来获取矩形位置,这对实现诸如划词高亮等效果非常有用。此外,需要关注的是这里的`firstChild`是`Text`节点,即值为`Node.TEXT_NODE`类型,这样才可以计算文本片段。 既然可以设置文本节点,那么自然也存在非文本节点的状态。在调用设置选区时,如果节点类型是`Text`、`Comment`或`CDATASection`之一,那么`offset`指的是从结束节点算起字符的偏移量。对于其他`Node`类型节点,`offset`是指从结束结点开始算起子节点的偏移量。 在下面的例子中,我们就是将选区设置为非文本内容的节点。此时最外层的`$1`节点作为父节点,存在`2`个子节点,因此`offset`可以设置的范围是`0-2`,若此时设置为`3`则会直接抛出异常。这里基本与设置文本选区的`offset`一致,差异是文本选区必须要文本节点,非文本则是父节点。 ```html 123456 ``` 构造`Range`对象主要的目的便是获取相关`DOM`的位置来计算,还有个常见的需求是实现内容高亮,通常来说这需要我们主动计算位置来实现虚拟图层。在较新的浏览器中实现了`::highlight`伪元素,可以帮我们用浏览器的原生实现来实现高亮效果,下面的例子中`23`文本片段就会出现背景色。 ```html 123456 ``` ### Selection `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`操作符是无效的。 ```js document.addEventListener("selectionchange", () => { const selection = window.getSelection(); console.log({ anchor: selection.anchorNode, anchorOffset: selection.anchorOffset, focus: selection.focusNode, focusOffset: selection.focusOffset, isCollapsed: selection.isCollapsed, }); }); ``` 编辑器中自然还需要设置选区的操作,这部分可以使用`Selection`对象的`addRange`方法来实现,通常在调用该方法之前需要使用`removeAllRanges`方法来移除已有选区。需要注意的是,该方法无法处理反向的选区,即`backward`状态。 ```html 123456 ``` 因此设置选区时,通常是需要使用`setBaseAndExtent`来实现。正向的选区直接将`start`、`end`方向的节点设置为`base`和`extent`即可,反向选区则是将`start`、`end`方向的节点设置为`extent`和`base`,这样就可以实现反向选区的效果,`DOM`节点参数与`Range`基本一致。 ```html 123456 ``` 设置选区的部分可以通过上述`API`实现,而获取选区的部分虽然可以通过`focus`和`anchor`来获取。但是在`Selection`对象上并未标记`backward`状态,因此我们还需要通过`getRangeAt`方法来获取选区内建的`Range`对象,因此来对比原始对象的状态。 `Selection`对象上的`rangeCount`属性表示选区中包含的`Range`对象的数量,通常我们只需要获取第一个选区即可。这里的判断条件需要特别关注,因为若是当前没有选区值,也就是`rangeCount`为`0`,此时直接获取内建选区对象是会抛出异常的。 此外我们可能会好奇,通常进行选区操作的时候我们都是只能选单个连读的选区的,为什么会出现`rangeCount`属性。这里是需要注意在`Firefox`中是可以设置多个选区的,按住`Ctrl`键可以实现多个选区的状态,但是为了主要浏览器兼容性通常我们只需要处理首个选区。 ```html 123456 ``` 选区这部分还有个比较有趣的控制效果,通常来说`ContentEditable`元素是应该作为整体被选中的,此时内部的文本节点表现仍然是被选中的状态。而若是真的想避免文本内容被选中,便可以使用`user-select`属性来完成,由于选区实际上靠边缘节点就可以实现,就可以出现特殊的选区断层效果。 此外,下面的例子中我们是使用`childNodes`而不是`children`来获取子节点的状态。`childNodes`是获取所有子节点的集合,包括文本节点、注释节点等,而`children`只会获取元素节点的集合,因此在设置选区时需要特别注意。 ```html 123456 ``` 在`Range`对象的最后,我们介绍了`::highlight`伪元素,在这里我们同样介绍一下`::selection`伪元素。`::selection`用于将央视应用于用户选中的文本部分,即我们可以通过设置`::selection`的样式来实现选区的样式控制,例如设置背景色、字体颜色等。 在这里还有个比较有趣的事情,在实现浏览器的扩展或者脚本时比较有用。如果想恢复`::selection`伪元素的原始背景色,是不可以设置为`transparent`的,因为这会导致选区的背景色消失,实际上默认的浅蓝色背景是保留的关键字`highlight`,当然使用`#BEDAFF`也是可行的。 ```html 123456 ``` ## 可编辑元素 虽然选区本质上跟可编辑元素并没有直接关系,但是我们实现的目标是富文本编辑器,自然是需要在`ContentEditable`元素中处理选区状态。不过在这之前,我们可以先简单针对于`input`元素实现选区操作。 ```html ``` 紧接着来看`ContentEditable`实现,本质上对于选区的操作是否是可编辑元素是没有什么区别的。只不过在可编辑元素中会显示地表现出光标,或者称为插入符。而在非可编辑元素中,光标虽然不可见,但是选区的变换事件以及转移实际上还是真实存在的。 ```html 123456 ``` 其实在最开始的时候,我想实现的就是纯`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`。 ```html

State 1

Selection Line 1
selection Line 2
``` 而如果是`Stage 2`的模型状态,是完全可以做到选区的正常操作的,在模型方面没有什么问题,但是我们此时的`Quill`选区又出现了问题。由于其在初始化时是会由`
`产生到`div/p`状态的突变,导致其选区的`Range`发生异动,此时在浏览器中的光标是不正确的。 而我们此时没有办法入侵到`Quill`中帮助其修正选区,且`DOM`上没有任何辅助我们修正选区的标记,所以这个方式也难以继续下去。甚至于如果需要处理其中状态变更的话,还需要侵入到`parchment`的视图层实现,这样就需要更加复杂的处理。 ```html

State 2

Selection Line 1
selection Line 2
``` 因此在这种状态下,我们可能只能选取`Stage 3`策略的形式,并不实现完整的`Blocks`,而是将`Quill`作为嵌套结构的编辑器实例。在这种模型状态下编辑器不会出现选区的偏移问题,我们的嵌套结构也可以借助`Quill`的`Embed Blot`来实现插件扩展嵌套`Block`结构。 ```html

State 3

Selection Line 1
selection Line 2
Selection Line 1
selection Line 2
``` 在这种情况下,如果想实现`Blocks`编辑器的目标,通过`Quill`来实现就只能依赖于`Embed Blot`的模式来实现,而这又是完全依赖于`Quill`维护的视图层。如果需要处理边界`Case`就需要再处理`parchment`的视图层,这样下来会非常麻烦,因此这也是从零实现编辑器的部分原因。 其实类似的问题在`editor.js`中也有存在,在其在线`DEMO`中可以发现我们无法从纯文本行`1`选择到行`2`。具体是在选中行`1`的部分文本后,拖拽选择到行`2`的部分文本时,会发现行`1`和`2`会被全部选中,基于`slate`实现的`Yoopta-Editor`也存在类似的问题。 这个问题在飞书文档上就并不存在了,在飞书文档中的重点是选区必然处于同一个父级`Block`下,具体的实现是当鼠标按下时拖选完全处于非受控状态,此时若是碰撞到其他块内,内部的选区样式会被`::selection`覆盖掉,然后这个块整体的样式会被`class`应用选中的样式。 而此时若是抬起鼠标,此时会立即纠正选区状态,此时的选区实现是会真正处于同一个父级块中。处于同一个块是为了简化操作,无论是应用变更还是获取选中片段的数据,在同一个父级块中进行迭代就不需要基于渲染块的顺序来递归迭代查找,这样在数据处理方面会更加简单。 ```html
``` ## 自绘选区 在我们的设计中是基于`ContentEditable`实现,也就是说没有准备实现自绘选区,只是最近思考了一下自绘选区的实现。通常来说在整体编辑器内的`contenteditable=false`节点会存在特殊的表现,在类似于`inline-block`节点中,例如`Mention`节点中,当节点前后没有任何内容时,我们就需要在其前后增加零宽字符,用以放置光标。 在下面的例子中,`line-1`是无法将光标放置在`@xxx`内容后的,虽然我们能够将光标放置之前,但此时光标位置是在`line node`上,是不符合我们预期的文本节点的。那么我们就必须要在其后加入零宽字符,在`line-2/3`中我们就可以看到正确的光标放置效果。这里的`0.1px`也是个为了兼容光标的放置的`magic`,没有这个`hack`的话,非同级节点光标同样无法放置在`inline-block`节点后。 ```html
@xxx
​ @xxx ​
​@xxx​
``` 那么除了通过零宽字符或者`
`标签来放置光标外,自然也可以通过自绘选区来实现,因为此时不再需要`ContentEditable`属性,那么自然就不会存在这些奇怪的行为。因此如果借助原生的选区实现,然后在此基础上实现控制器层,就可以实现完全受控的编辑器。 但是这里存在一个很大的问题,就是内容的输入,因为不启用`ContentEditable`的话是无法出现光标的,自然也无法输入内容。而如果我们想唤醒内容输入,特别是需要唤醒`IME`输入法的话,浏览器给予的常规`API`就是借助``来完成,因此我们就必须要实现隐藏的``来实现输入,实际上很多代码编辑器例如 [CodeMirror](https://github.com/codemirror/codemirror5) 就是类似实现。 但是使用隐藏的``就会出现其他问题,因为焦点在`input`上时,浏览器的文本就无法选中了。因为在同个页面中,焦点只会存在一个位置,因此在这种情况下,我们就必须要自绘选区的实现了。例如钉钉文档、有道云笔记、`Zoom`文档就是自绘选区,开源的 [Monaco](https://github.com/microsoft/monaco-editor) 同样是自绘选区,[TextBus](https://github.com/textbus/textbus) 则绘制了光标,选区则是借助了浏览器实现。 其实这里说起来`TextBus`的实现倒是比较有趣,因为其自绘了光标焦点需要保持在外挂的`textarea`上,但是本身的文本选区也是需要焦点的。因此考虑这里的实现应该是具有比较特殊的实现,特别是`IME`的输入中应该是有特殊处理,可能是重新触发了事件。而且这里的`IME`输入除了本身的非折叠选区内容删除外,还需要唤醒字符的输入,此外还有输入过程中暂态的字符处理,自绘选区复杂的地方就在输入模块上。 最后探究发现`TextBus`既没有使用`ContentEditable`这种常见的实现方案,也没有像`CodeMirror`或者`Monaco`一样自绘选区。从`Playground`的`DOM`节点上来看,其是维护了一个隐藏的`iframe`来实现的,这个`iframe`内存在一个`textarea`,以此来处理`IME`的输入。 这种实现非常的特殊,因为内容输入的情况下,文本的选区会消失,也就是说两者的焦点是会互相抢占的。那么先来看一个简单的例子,以`iframe`和文本选区的焦点抢占为例,可以发现在`iframe`不断抢占的情况下,我们是无法拖拽文本选区的。这里值得一提的是,我们不能直接在`onblur`事件中进行`focus`,这个操作会被浏览器禁止,必须要以宏任务的异步时机触发。 ```html 123123 ``` 实际上这个问题是我踩过的坑,注意我们的焦点聚焦调用是直接调用的`$1.focus`,假如此时我们是调用`win.focus`的话,那么就可以发现文本选区是可以拖拽的。通过这个表现其实可以看出来,主从框架的文档的选区是完全独立的,如果焦点在同一个框架内则会相互抢占,如果不在同一个框架内则是可以正常表达,也就是`$1`和`win`的区别。 其实可以注意到此时文本选区是灰色的,这个可以用`::selection`伪元素来处理样式,而且各种事件都是可以正常触发的,例如`SelectionChange`事件以及手动设置选区等。当然如果直接在`iframe`中放置`textarea`的话,可以得到同样的表现,同样也可以正常的输入内容,并且不会打断`IME`的输入法,这个`Magic`的表现在诸多浏览器都可以正常触发。 ```html 123123 ``` 那么除了特殊的`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`中这些信息我们通常都是会记录的。 ```html
123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123
``` ## 总结 在这里我们总结了浏览器的相关`API`,并且基于`Range`对象与`Selection`对象实现了基本的选区操作,并且举了相关的应用具体场景和示例。此外,还介绍了先前针对`Blocks`编辑器的选区遇到的问题,在最后我们调研了诸多自绘选区的编辑器实现,并且实现了简单的自绘选区示例。 接下来我们会从数据模型出发,设计编辑器选区模型的表示,然后在浏览器选区相关的`API`基础上,实现编辑器选区模型与浏览器选区的同步。通过选区模型作为编辑器操作的范围目标,来实现编辑器的基础操作,例如插入、删除、格式化等操作,以及诸多选区相关的边界操作问题。 ## 每日一题 - ## 参考 - - - - - - - -