洪势 发表于 2025-6-3 00:31:39

从零实现富文本编辑器#4-浏览器选区模型的核心交互策略

先前我们提到了,数据模型的设计是编辑器的基础模块,其直接影响了选区模块的表示。选区模块的设计同样是编辑器的基础部分,编辑器应用变更时操作范围的表达,就需要基于选区模型来实现,也就是说选区代表的意义是编辑器需要感知在什么范围内执行变更命令。

[*]开源地址: https://github.com/WindRunnerMax/BlockKit
[*]在线编辑: https://windrunnermax.github.io/BlockKit/
[*]项目笔记: https://github.com/WindRunnerMax/BlockKit/blob/master/NOTE.md
从零实现富文本编辑器项目的相关文章:

[*]深感一无所长,准备试着从零开始写个富文本编辑器
[*]从零实现富文本编辑器#2-基于MVC模式的编辑器架构设计
[*]从零实现富文本编辑器#3-基于Delta的线性数据结构模型
[*]从零实现富文本编辑器#4-浏览器选区模型的核心交互策略
浏览器选区

数据模型设计直接影响了编辑器选区模型的表达,例如下面的例子中Quill与Slate编辑器的模型选区实现,其与本身维护的数据结构密切相关。然而无论是哪种编辑器设计的数据模型,都需要基于浏览器的选区来实现,因此在本文中我们先来实现浏览器选区模型的基本操作。
// Quill
{ index: 0, length: 3 }

// Slate
{
anchor: { offset: 0, path: },
focus: { offset: 3, path: }
} 实际上选区这个概念还是比较抽象,但是我们应该是经常与其打交道的。例如在鼠标拖动文本部分内容时,这部分会携带淡蓝色的背景色,这就是选区范围的表达,同样我们可能会将其称为选中、拖蓝、选中范围等等,这便是浏览器提供的选区能力。
除了选中文本的蓝色背景色外,闪烁的光标同样是选区的一种表现形式。光标的选区范围是一个点,或者可以称为折叠的选区。光标的表达通常只出现在可编辑元素中,例如输入框、文本域、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的位置:
123456获取文本片段矩形位置是个非常重要的应用,这样我们可以实现非完整DOM元素的位置获取,而不是必须通过HTML DOM来获取矩形位置,这对实现诸如划词高亮等效果非常有用。此外,需要关注的是这里的firstChild是Text节点,即值为Node.TEXT_NODE类型,这样才可以计算文本片段。
既然可以设置文本节点,那么自然也存在非文本节点的状态。在调用设置选区时,如果节点类型是Text、Comment或CDATASection之一,那么offset指的是从结束节点算起字符的偏移量。对于其他Node类型节点,offset是指从结束结点开始算起子节点的偏移量。
在下面的例子中,我们就是将选区设置为非文本内容的节点。此时最外层的$1节点作为父节点,存在2个子节点,因此offset可以设置的范围是0-2,若此时设置为3则会直接抛出异常。这里基本与设置文本选区的offset一致,差异是文本选区必须要文本节点,非文本则是父节点。
123456构造Range对象主要的目的便是获取相关DOM的位置来计算,还有个常见的需求是实现内容高亮,通常来说这需要我们主动计算位置来实现虚拟图层。在较新的浏览器中实现了::highlight伪元素,可以帮我们用浏览器的原生实现来实现高亮效果,下面的例子中23文本片段就会出现背景色。
123456Selection

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操作符是无效的。
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状态。
123456因此设置选区时,通常是需要使用setBaseAndExtent来实现。正向的选区直接将start、end方向的节点设置为base和extent即可,反向选区则是将start、end方向的节点设置为extent和base,这样就可以实现反向选区的效果,DOM节点参数与Range基本一致。
123456设置选区的部分可以通过上述API实现,而获取选区的部分虽然可以通过focus和anchor来获取。但是在Selection对象上并未标记backward状态,因此我们还需要通过getRangeAt方法来获取选区内建的Range对象,因此来对比原始对象的状态。
Selection对象上的rangeCount属性表示选区中包含的Range对象的数量,通常我们只需要获取第一个选区即可。这里的判断条件需要特别关注,因为若是当前没有选区值,也就是rangeCount为0,此时直接获取内建选区对象是会抛出异常的。
此外我们可能会好奇,通常进行选区操作的时候我们都是只能选单个连读的选区的,为什么会出现rangeCount属性。这里是需要注意在Firefox中是可以设置多个选区的,按住Ctrl键可以实现多个选区的状态,但是为了主要浏览器兼容性通常我们只需要处理首个选区。
123456选区这部分还有个比较有趣的控制效果,通常来说ContentEditable元素是应该作为整体被选中的,此时内部的文本节点表现仍然是被选中的状态。而若是真的想避免文本内容被选中,便可以使用user-select属性来完成,由于选区实际上靠边缘节点就可以实现,就可以出现特殊的选区断层效果。
此外,下面的例子中我们是使用childNodes而不是children来获取子节点的状态。childNodes是获取所有子节点的集合,包括文本节点、注释节点等,而children只会获取元素节点的集合,因此在设置选区时需要特别注意。
123456在Range对象的最后,我们介绍了::highlight伪元素,在这里我们同样介绍一下::selection伪元素。::selection用于将央视应用于用户选中的文本部分,即我们可以通过设置::selection的样式来实现选区的样式控制,例如设置背景色、字体颜色等。
在这里还有个比较有趣的事情,在实现浏览器的扩展或者脚本时比较有用。如果想恢复::selection伪元素的原始背景色,是不可以设置为transparent的,因为这会导致选区的背景色消失,实际上默认的浅蓝色背景是保留的关键字highlight,当然使用#BEDAFF也是可行的。
123456可编辑元素

虽然选区本质上跟可编辑元素并没有直接关系,但是我们实现的目标是富文本编辑器,自然是需要在ContentEditable元素中处理选区状态。不过在这之前,我们可以先简单针对于input元素实现选区操作。
紧接着来看ContentEditable实现,本质上对于选区的操作是否是可编辑元素是没有什么区别的。只不过在可编辑元素中会显示地表现出光标,或者称为插入符。而在非可编辑元素中,光标虽然不可见,但是选区的变换事件以及转移实际上还是真实存在的。
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。
<p>State 1</p>

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

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

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应用选中的样式。
而此时若是抬起鼠标,此时会立即纠正选区状态,此时的选区实现是会真正处于同一个父级块中。处于同一个块是为了简化操作,无论是应用变更还是获取选中片段的数据,在同一个父级块中进行迭代就不需要基于渲染块的顺序来递归迭代查找,这样在数据处理方面会更加简单。

   
      
   
   
      
      
      
   
自绘选区

在我们的设计中是基于ContentEditable实现,也就是说没有准备实现自绘选区,只是最近思考了一下自绘选区的实现。通常来说在整体编辑器内的contenteditable=false节点会存在特殊的表现,在类似于inline-block节点中,例如Mention节点中,当节点前后没有任何内容时,我们就需要在其前后增加零宽字符,用以放置光标。
在下面的例子中,line-1是无法将光标放置在@xxx内容后的,虽然我们能够将光标放置之前,但此时光标位置是在line node上,是不符合我们预期的文本节点的。那么我们就必须要在其后加入零宽字符,在line-2/3中我们就可以看到正确的光标放置效果。这里的0.1px也是个为了兼容光标的放置的magic,没有这个hack的话,非同级节点光标同样无法放置在inline-block节点后。

    @xxx


    ​
    @xxx
    ​


    ​@xxx​
那么除了通过零宽字符或者
标签来放置光标外,自然也可以通过自绘选区来实现,因为此时不再需要ContentEditable属性,那么自然就不会存在这些奇怪的行为。因此如果借助原生的选区实现,然后在此基础上实现控制器层,就可以实现完全受控的编辑器。
但是这里存在一个很大的问题,就是内容的输入,因为不启用ContentEditable的话是无法出现光标的,自然也无法输入内容。而如果我们想唤醒内容输入,特别是需要唤醒IME输入法的话,浏览器给予的常规API就是借助来完成,因此我们就必须要实现隐藏的来实现输入,实际上很多代码编辑器例如 CodeMirror 就是类似实现。
但是使用隐藏的就会出现其他问题,因为焦点在input上时,浏览器的文本就无法选中了。因为在同个页面中,焦点只会存在一个位置,因此在这种情况下,我们就必须要自绘选区的实现了。例如钉钉文档、有道云笔记就是自绘选区,开源的 Monaco 同样是自绘选区,TextBus 则绘制了光标,选区则是借助了浏览器实现。
其实这里说起来TextBus的实现倒是比较有趣,因为其自绘了光标焦点需要保持在外挂的textarea上,但是本身的文本选区也是需要焦点的。因此考虑这里的实现应该是具有比较特殊的实现,特别是IME的输入中应该是有特殊处理,可能是重新触发了事件。而且这里的IME输入除了本身的非折叠选区内容删除外,还需要唤醒字符的输入,此外还有输入过程中暂态的字符处理,自绘选区复杂的地方就在输入模块上。
最后探究发现TextBus既没有使用ContentEditable这种常见的实现方案,也没有像CodeMirror或者Monaco一样自绘选区。从Playground的DOM节点上来看,其是维护了一个隐藏的iframe来实现的,这个iframe内存在一个textarea,以此来处理IME的输入。
这种实现非常的特殊,因为内容输入的情况下,文本的选区会消失,也就是说两者的焦点是会互相抢占的。那么先来看一个简单的例子,以iframe和文本选区的焦点抢占为例,可以发现在iframe不断抢占的情况下,我们是无法拖拽文本选区的。这里值得一提的是,我们不能直接在onblur事件中进行focus,这个操作会被浏览器禁止,必须要以宏任务的异步时机触发。
123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>实际上这个问题是我踩过的坑,注意我们的焦点聚焦调用是直接调用的$1.focus,假如此时我们是调用win.focus的话,那么就可以发现文本选区是可以拖拽的。通过这个表现其实可以看出来,主从框架的文档的选区是完全独立的,如果焦点在同一个框架内则会相互抢占,如果不在同一个框架内则是可以正常表达,也就是$1和win的区别。
其实可以注意到此时文本选区是灰色的,这个可以用::selection伪元素来处理样式,而且各种事件都是可以正常触发的,例如SelectionChange事件以及手动设置选区等。当然如果直接在iframe中放置textarea的话,可以得到同样的表现,同样也可以正常的输入内容,并且不会打断IME的输入法,这个Magic的表现在诸多浏览器都可以正常触发。
123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>那么除了特殊的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中这些信息我们通常都是会记录的。
123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123123
<iframe id="$1"></iframe>
<iframe id="$1"></iframe>123总结

在这里我们总结了浏览器的相关API,并且基于Range对象与Selection对象实现了基本的选区操作,并且举了相关的应用具体场景和示例。此外,还介绍了先前针对Blocks编辑器的选区遇到的问题,在最后我们调研了诸多自绘选区的编辑器实现,并且实现了简单的自绘选区示例。
接下来我们会从数据模型出发,设计编辑器选区模型的表示,然后在浏览器选区相关的API基础上,实现编辑器选区模型与浏览器选区的同步。通过选区模型作为编辑器操作的范围目标,来实现编辑器的基础操作,例如插入、删除、格式化等操作,以及诸多选区相关的边界操作问题。
每日一题


[*]https://github.com/WindRunnerMax/EveryDay
参考


[*]https://www.w3.org/TR/selection-api/
[*]https://juejin.cn/post/7068232010304585741
[*]https://developer.mozilla.org/en-US/docs/Web/API/Range
[*]https://developer.mozilla.org/en-US/docs/Web/API/Selection
[*]https://developer.mozilla.org/en-US/docs/Web/API/Element/children
[*]https://developer.mozilla.org/en-US/docs/Web/API/Document/elementFromPoint
[*]https://developer.mozilla.org/en-US/docs/Web/API/Document/caretRangeFromPoint
[*]https://developer.mozilla.org/en-US/docs/Web/API/Document/caretPositionFromPoint

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
页: [1]
查看完整版本: 从零实现富文本编辑器#4-浏览器选区模型的核心交互策略