Slate文档编辑器-Decorator装饰器渲染调度
在之前我们聊到了基于文档编辑器的数据结构设计,聊了聊基于slate
实现的文档编辑器类型系统,那么当前我们来研究一下slate
编辑器中的装饰器实现。装饰器在slate
中是非常重要的实现,可以为我们方便地在编辑器渲染调度时处理range
的渲染。
关于slate
文档编辑器项目的相关文章:
- 基于Slate构建文档编辑器
- Slate文档编辑器-WrapNode数据结构与操作变换
- Slate文档编辑器-TS类型扩展与节点类型检查
- Slate文档编辑器-Decorator装饰器渲染调度
- Slate文档编辑器-Node节点与Path路径映射
Decorate
在slate
中decoration
是比较有趣的功能,设想一个场景,当需要实现代码块的高亮时,我们可以有几种方案来实现: 第一种方案是我们可以通过直接将代码块的内容解析的方式,解析出的关键字类别直接写入数据结构中,这样就可以直接在渲染时将高亮信息渲染出来,缺点就是会增加数据结构存储数据的大小;那么第二种方式我们就可以只存储代码信息,当需要数据高亮时也就是前端渲染时我们再将其解析出Marks
进行渲染,但是这样的话如果存在协同我们还需要为其标记为非协同操作以及无需服务端存储的纯客户端Op
,会稍微增加一些复杂度;那么第三种方法就是使用decoration
,实际上可以说这里只是slate
帮我们把第二种方法的事情做好了,可以在不改变数据结构的情况下将额外的Marks
内容渲染出来。
当然,我们使用装饰器的场景自然不只是代码块高亮,凡是涉及到不希望在数据结构中表达却要在渲染时表现的内容,都需要使用decoration
来实现。还有比较明显的例子就是查找能力,如果在编辑器中实现查找功能,那么我们需要将查找到的内容标记出来,这个时候我们就可以使用decoration
来实现,否则就需要绘制虚拟的图层来完成。而如果需要实现用户态的超链接解析功能,即直接贴入连接的时候,我们希望将其自动转为超链接的节点,也可以利用装饰器来实现。
在前段时间测试slate
官网的search-highlighting example
时,当我搜索adds
时,搜索的效果很好,但是当我执行跨节点的搜索时,就不能非常有效地突出显示内容了,具体信息可以查看https://github.com/ianstormtaylor/slate/pull/5670
。这也就是说当decoration
执行跨节点处理的时候,是存在一些问题的。例如下面的例子,当我们搜索123
或者12345
时,我们能够正常将标记出的decoration
渲染出来,然而当我们搜索123456
时,此时我们构造的range
会是path: [0], offset: [0-6]
,此时我们跨越了[0]
节点进行标记,就无法正常标记内容了。
[
{ text: "12345" },
{ text: "67890" }
]
通过调用查找相关代码,我们可以看到上级的decorate
结果会被传递到后续的渲染中,那么在本级同样会调度传递的decorate
函数来生成新的decorations
,并且这里需要判断如果父级的decorations
与当前节点的range
存在交集的话,那么内容会被继续传递下去。那么重点就在这里了,试想一下我们的场景,依旧以上述的例子中的内容为例,如果我们此时想要获取123456
的索引,那么在text: 12345
这个节点中肯定是不够的,我们必须要在上层数组中将所有文本节点的内容拼接起来,然后再查找才可以找到准确的索引位置。
// https://github.com/ianstormtaylor/slate/blob/25be3b/packages/slate-react/src/hooks/use-children.tsx#L21
const useChildren = (props: {
decorations: Range[]
// ...
}) => {
// ...
for (let i = 0; i < node.children.length; i++) {
// ...
const ds = decorate([n, p])
for (const dec of decorations) {
const d = Range.intersection(dec, range)
if (d) {
ds.push(d)
}
}
// ...
}
// ...
}
那么此时我们就明确需要我们调用decorate
的节点是父级元素,而父级节点传递到我们需要处理的text
节点时,就需要Range.intersection
来判断是否存在交集,实际上这里判断交集的策略很简单,在下面我们举了两个例子,分别是存在交集和不存在交集的情况,我们实际上只需要判断两个节点的最终状态即可。
// https://github.com/ianstormtaylor/slate/blob/25be3b/packages/slate/src/interfaces/range.ts#L118
// start1 end1 start2 end2
// end1 start2
// end1 < start2 ===> 无交集
// start1 start2 end1 end2
// start2 end1
// start2 < end1 ===> 有交集 [start2, end1]
那么我们可以通过修改在decorate
这部分代码中的Range.intersection
逻辑部分来解决这个问题吗,具体来说就是当我们查找出的内容超出原本range
的内容,则截取其需要装饰的部分,而其他部分舍弃掉,实际上这个逻辑在上边我们分析的时候已经发觉是没有问题的,也就是当我们查找123456
的时候是能够将12345
这部分完全展示出来的。根据前边的分析,本次循环我们的节点都在path: [0]
,这部分代码会将start: 0
到end: 5
这部分代码截取range
并渲染。
然而我们在下一个text range
范围内继续查找6
这部分就没有那么简单了,因为前边我们实际上查找的range
是path: [0], offset: [0-6]
,而第二个text
的基本range
是path: [1], offset: [0-5]
,基于上述判断条件的话我们是发现是不会存在交集的。因此如果需要在这里进行处理的话,我们就需要取得前一个range
甚至在跨越多个节点的情况下我们需要向前遍历很多节点,当decorations
数量比较多的情况下我们需要检查所有的节点,因为在此节点我们并不知道前一个节点是否超越了本身节点的长度,这种情况下在此处的计算量可能比较大,或许会造成性能问题。
因此我们还是从解析时构造range
入手,当跨越节点时我们就需要将当前查找出来的内容分割为多个range
,然后为每个range
分别置入标记,还是以上边的数据为例,此时我们查找的结果就是path: [0], offset: [0, 5]
与path: [1], offset: [0, 1]
两部分,这种情况下我们在Range.intersection
时就可以正常处理交集了,此时我们的path
是完全对齐的,而即使完全将内容跨越,也就是搜索内容跨越不止一个节点时,我们也可以通过这种方式来处理。
// https://github.com/ianstormtaylor/slate/pull/5670
const texts = node.children.map(it => it.text)
const str = texts.join('')
const length = search.length
let start = str.indexOf(search)
let index = 0
let iterated = 0
while (start !== -1) {
while (index < texts.length && start >= iterated + texts[index].length) {
iterated = iterated + texts[index].length
index++
}
let offset = start - iterated
let remaining = length
while (index < texts.length && remaining > 0) {
const currentText = texts[index]
const currentPath = [...path, index]
const taken = Math.min(remaining, currentText.length - offset)
ranges.push(/* 构造新的`range` */)
remaining = remaining - taken
if (remaining > 0) {
iterated = iterated + currentText.length
offset = 0
index++
}
}
start = str.indexOf(search, start + search.length)
}
此外,我们在调度装饰器的时候,需要关注在renderLeaf
参数RenderLeafProps
的值,因为在这里存在两种类型的文本内容,即leaf: Text;
以及text: Text;
基本TextInterface
类型。而在我们通过renderLeaf
渲染内容时,以高亮的代码块内值mark
节点的渲染为例,我们实际渲染节点需要以leaf
为基准而不是以text
为基准,例如当在渲染mark
和bold
样式产生重叠时,这两种节点都需要以leaf
为基准。
在这里的原因是,decorations
在slate
实现中是以text
节点为基准将其拆分为多个leaves
,然后再将leaves
传递到renderLeaf
中进行渲染。因此实际上在这里可以这么理解,text
属性为原始值,而leaf
属性为更细粒度的节点,调度renderLeaf
的时候本身也是以leaf
为粒度进行渲染的,当然在不使用装饰器的情况下,这两种属性的节点类型是等同的。
// https://github.com/ianstormtaylor/slate/blob/25be3b/packages/slate-react/src/components/text.tsx#L39
const leaves = SlateText.decorations(text, decorations)
const key = ReactEditor.findKey(editor, text)
const children = []
for (let i = 0; i < leaves.length; i++) {
const leaf = leaves[i]
children.push(
<Leaf
isLast={isLast && i === leaves.length - 1}
key={`${key.id}-${i}`}
renderPlaceholder={renderPlaceholder}
leaf={leaf}
text={text}
parent={parent}
renderLeaf={renderLeaf}
/>
)
}
最后
在这里我们主要讨论了slate
中的decoration
装饰器的实现,以及在实际使用中可能会遇到的问题,主要是在跨节点的情况下,我们需要将range
拆分为多个range
,然后分别进行处理,并且还分析了源码来探究了相关问题的实现。那么在后边的文章中我们就主要聊一聊在slate
中Path
的表达,以及在React
中是如何控制其内容表达与正确维护Path
路径与Element
内容渲染的方案。