# 编辑器选区模型的状态结构表达 先前我们总结了浏览器选区模型的交互策略,并且实现了基本的选区操作,还调研了自绘选区的实现。那么相对的,我们还需要设计编辑器的选区表达,也可以称为模型选区,编辑器中应用变更时的操作范围,就是以模型选区为基准来实现的。在这里我们就以编辑器状态为基础,来设计模型选区的结构表达。 - 开源地址: - 在线编辑: - 项目笔记:
从零实现富文本编辑器系列文章 - [深感一无所长,准备试着从零开始写个富文本编辑器](./从零设计实现富文本编辑器.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 `Quill`是一个现代富文本编辑器,具备良好的兼容性及强大的可扩展性,还提供了部分开箱即用的功能。`Quill`的出现给富文本编辑器带了很多新的东西,也是目前开源编辑器里面受众非常大的一款编辑器,至今为止的生态已经非常的丰富,目前也推出了`2.0`版本。 `Quill`的数据结构表达是基于`Delta`实现的,当然既然其名为`Delta`,那么数据的变更也可以基于`Delta`来实现。那么使用`Delta`表达基本的富文本数据结构如下所示,可以观察到其不会存在嵌套的数据结构表达,所有内容以及格式表达都是线性的。 ```js { ops: [ { insert: "这样" }, { insert: "一段文本", attributes: { italic: true } }, { insert: "的" }, { insert: "数据结构", attributes: { bold: true } }, { insert: "如下所示。\n" }, ], }; ``` 既然数据结构是扁平的表达,那么选区的表达也不需要复杂的结构来实现。直观感受上来说,扁平化结构要特殊处理的`Case`会更少,状态结构会更好维护,编辑器架构会更加轻量。通常编辑器的选区也会构造`Range`对象,那么在`Quill`中的选区结构如下所示: ```js { index: 5, // 光标位置 length: 10, // 选区长度 } ``` 选区作为编辑器变更的应用范围,不可避免地需要提及应用变更的操作。在应用变更时,自然需要进行状态结构的遍历,以取出需要变更的节点来实际应用变更。那么自然而然地,扁平的结构本身的顺序就是渲染的顺序,不需要嵌套的结构来进行递归地查找,查找的效率自然会更高。 此外,`Quill`的视图层是重新设计了一套模型`parchment`,维护了视图和状态模型,虽然在仓库中通过继承的方式重写了部分类结构,例如`ScrollBlot`类。但是在阅读源码的时候难以确定很多视图模块设计意图,所以对于`DOM`事件与状态模型的交互暂时还没有很好的理解。 ### Slate `Slate`是一个高度灵活、完全可定制的富文本编辑器框架,其提供了一套核心模型和`API`,让开发者能够深度定制编辑器的行为,其更像是富文本编辑器的引擎,而不是开箱即用的组件。`Slate`经历过一次大版本重构,虽然目前仍然处于`Beta`状态,但是已经有相当多的线上服务使用。 `Slate`的数据结构表达就不是扁平的内容表示的,依据其`TS`类型的定义,`Node`类型是`Editor | Element | Text`三种类型的元组。当然抛开`Editor`本身不谈,我们从下面的内容描述上可以很容易看出来`Element`和`Text`类型的定义。 ```js { type: "paragraph", children: [ { text: "这样" }, { text: "一段文本", italic: true }, { text: "的" }, { text: "数据结构", bold: true }, { text: "如下所示。" }, ] } ``` 其中`children`属性可以认为是`Element`类型的节点,而`text`属性则是`Text`类型的节点,`Element`类型的节点可以无限嵌套`Element`类型以及`Text`类型节点。那么这种情况下,扁平的选区表达就无法承载这样的结构,因此`Slate`的选区表达是用端点来实现的。 ```js { anchor: { path: [0, 1], offset: 2 }, // 起始位置 focus: { path: [0, 3], offset: 4 }, // 结束位置 } ``` 这种选区的表达是不是非常眼熟,没错`Slate`的选区表达是完全与浏览器选区对齐的,因为其从最开始的设计原则就是与`DOM`对齐的。说句题外的话,`Slate`对于数据结构的表现也是完全与选区对齐的,例如表达图片时必须要放置一个空的`Text`作为零宽字符。 ```js { type: "image", url: "https://example.com/image.png", children: [{ text: "" }] } ``` ```html
\u200B
``` 除了类似于图片的`Void`节点表达,针对于行内的`Void`节点,更能够表现其内建维护的数据结构是完全对齐`DOM`的。例如下面的`Mention`结构表达,由于需要在两侧放置光标,因此在`DOM`中引入了两个零宽字符,此时内建数据结构也维护了两个`Text`节点。 ```js [ { text: "" }, { type: "mention", user: "Mace", children: [{ text: "" }], }, { text: "" }, ]; ``` ```html

\u200B \u200B @Mace \u200B

``` 说回选区模型,`Slate`在应用数据变更时,同样需要依赖两个端点来进行遍历查找,特别是进行诸如`setNode`等操作时。那么查找就非常依赖渲染顺序来决定,由于文档整体结构还是二维的,因此通过`path + offset`对比找到起始/结束的节点,然后从首节点开始递归遍历查找到尾节点就可以了。 嵌套的数据结构针对于批量的变更整体会变得更复杂,因为`Op`之间的索引关系需要维护,这就依赖`transform`的实现。但是,针对于单个`Op`的变更实际上是更清晰的,类似于`Delta`的变更是不容易非常针对性地处理单个操作变更,而`JSON`结构下的单次变更非常清晰。 `transform`这部分也是核心实现,先前基于`OT-JSON`以及`Immer`实现的低代码场景的状态管理方案也提到过。单个`Op`变更时通常不会影响`Path`,批量的`Op`变更时是需要考虑到其间相互影响的关系的,例如在`Slate`的`remove-nodes`实现中,通过`ref`维护的影响关系。 ```js // packages/slate/src/transforms-node/remove-nodes.ts const depths = Editor.nodes(editor, { at, match, mode, voids }) const pathRefs = Array.from(depths, ([, p]) => Editor.pathRef(editor, p)) for (const pathRef of pathRefs) { const path = pathRef.unref()! if (path) { const [node] = Editor.node(editor, path) editor.apply({ type: 'remove_node', path, node }) } } ``` 说到这里,`Slate`这种需要多次同步`apply`的实现方式,是不适合同步立即执行编辑器的变更的,这样的渲染代价十分高昂。因此,等待所有的同步指令全部完成之后,再批量到视图层应用变更才是更合适的方案,也就是我们常见的同步变更数据,异步渲染视图。 ```js // https://github.com/ianstormtaylor/slate/blob/06b882/packages/slate/src/core/apply.ts#L44 if (!FLUSHING.get(editor)) { FLUSHING.set(editor, true) Promise.resolve().then(() => { FLUSHING.set(editor, false) editor.onChange({ operation: op }) editor.operations = [] }) } ``` ### Lexical `Lexical`是`Meta`开源的现代化富文本编辑器框架,专注于提供极致的性能、可访问性和可靠性。其作为`Draft.js`的继任者,提供了更好的可扩展性和灵活性。特别的,`Lexical`还提供了`IOS`的原生支持,基于`Swift/TextKit`编写的可扩展文本编辑器,共享`Lexical`设计理念与`API`。 `Lexical`的数据结构与`Slate`类似,都是基于树形的嵌套结构来表达内容。但是其整体设计上增加了很多的预设内容,并没有像`Slate`设计为足够灵活的编辑器引擎,从下面的数据结构表达中也可以看出并没有那么简洁,这已经是精简过后的内容,原本还存在诸如`detail`等属性。 ```js [ { children: [ { format: 0, mode: "normal", text: "这样", type: "text" }, { format: 2, mode: "normal", text: "一段文本", type: "text" }, { format: 0, mode: "normal", text: "的", type: "text" }, { format: 1, mode: "normal", text: "数据结构", type: "text" }, { format: 0, mode: "normal", text: "如下所示。", type: "text" }, ], direction: "ltr", indent: 0, type: "paragraph", version: 1, textFormat: 0, }, ] ``` 由于其本身是嵌套的数据结构,那么选区的表达也类似于`Slate`的实现。只不过`Lexical`的选区类似于`ProseMirror`的实现,将选区分为了`RangeSelection`、`NodeSelection`、`TableSelection`、`null`选区类型。 其中`Table`以及`null`的选区类型比较特殊,我们就暂且不论。而针对于`Range`、`Node`实际上是可以同构表达的,`Node`是针对于`Void`类型如图片、分割线的表达,而类似于`Quill`以及`Slate`是将其直接融入`Range`的表达,以零宽字符文本作为选区落点。 实际上特殊的表达自然是有特殊的含义,`Quill`以及`Slate`通过零宽字符同构了选区的文本表达,而`Lexical`的设计是不存在零宽字符占位的,所以无法直接同构文本选区表达。那么对于`Lexical`的选区,基础的文本`RangeSelection`选区如下所示: ```js { anchor: { key: "51", offset: 2, type: "text" }, focus: { key: "51", offset: 3, type: "text" } } ``` 这里可以看出来虽然数据结构与`Slate`类似,但是`path`这部分被替换成了`key`,而需要注意的是,在我们先前展示的数据结构中是没有`key`这个标识的。也就是说这个`key`值是临时的状态值而不是维护在数据结构内的,虽然看起来这个`key`很像是数字,实际上是字符串来表达唯一`id`。 ```js // packages/lexical/src/LexicalUtils.ts let keyCounter = 1; export function generateRandomKey(): string { return '' + keyCounter++; } ``` 实际上这种`id`生成的方式在很多地方都存在,包括`Slate`以及我们的`BlockKit`中,而恰好我们都是通过这种方式来生成`key`值的。因为我们的视图层是通过`React`来渲染的,因此不可避免地需要维护唯一的`key`值,而在`Lexical`中`key`值还维护了状态映射的作用。 那么这里的`key`值本质上是维护了`id`到`path`的关系,我们应该可以直接通过`key`值取得渲染的节点状态。并且通过这种方式实际上就相当于借助了临时的`path`做到了任意`key`字符串可比较,并且可以沿着相对应的`nextSibling`剪枝查找到尾节点,其实这里让我想到了偏序关系的维护。 ```js // packages/lexical/src/LexicalSelection.ts const cachedNodes = this._cachedNodes; if (cachedNodes !== null) { return cachedNodes; } const range = $getCaretRangeInDirection( $caretRangeFromSelection(this), 'next', ); const nodes = $getNodesFromCaretRangeCompat(range); return nodes; ``` 在这里比较重要的是`key`值变更时的状态保持,因为编辑器的内容实际上是需要编辑的。然而如果做到`immutable`话,很明显直接根据状态对象的引用来映射`key`会导致整个编辑器`DOM`无效的重建。例如调整标题的等级,就由于整个行`key`的变化导致整行重建。 那么如何尽可能地复用`key`值就成了需要研究的问题,我们的编辑器行级别的`key`是被特殊维护的,即实现了`immutable`以及`key`值复用。而叶子状态的`key`依赖了`index`值,因此如果调研`Lexical`的实现,同样可以将其应用到我们的`key`值维护中。 通过在`playground`中调试可以发现,即使我们不能得知其是否为`immutable`的实现,依然可以发现`Lexical`的`key`是以一种偏左的方式维护。因此在我们的编辑器实现中,也可以借助同样的方式,合并直接以左值为准复用,拆分时若以`0`起始直接复用,起始非`0`则创建新`key`。 1. `[123456(key1)][789(bold-key2)]`文本,将`789`的加粗取消,整段文本的`key`值保持为`key1`。 2. `[123456789(key1)]]`文本,将`789`这段文本加粗,左侧`123456`文本的`key`值保持为`key1`,`789`则是新的`key`。 3. `[123456789(key1)]]`文本,将`123`这段文本加粗,左侧`123`文本的`key`值保持为`key1`,`456789`则是新的`key`。 4. `[123456789(key1)]]`文本,将`456`这段文本加粗,左侧`123`文本的`key`值保持为`key1`,`456`和`789`分别是新的`key`。 说起来,`Lexical`其实并不是完全由`React`作为视图层的,其仅仅是可以支持`React`组件节点的挂载。而主要的`DOM`节点,例如加粗、标题等格式还是自行实现的视图层,判断是否是通过`React`渲染的方式很简单,通过控制台查看是否存在类似`__reactFiber$`的属性即可。 ```js // Slate __reactFiber$lercf2nvv2a: {} __reactProps$lercf2nvv2a: {} // Lexical __lexicalDir: "ltr" __lexicalDirTextContent: "这样一段文本的数据结构如下所示。" __lexicalKey_siggg: "47" __lexicalTextContent: "这样一段文本的数据结构如下所示。\n\n" ``` ### 飞书文档 飞书文档是基于`Blocks`的设计思想实现的富文本编辑器,其作为飞书的核心应用之一,提供了强大的文档编辑功能。飞书文档深度融合文档、表格、思维笔记等组件,支持多人云端实时协作、深度集成飞书套件、高度移动端适配,是非常不错的商业产品。 飞书文档是商业化的产品,并非开源的编辑器,因此数据结构只能从接口来查阅。如果直接查阅飞书的`SSR`数据结构,可以发现其基本内容如下,直接在控制台上输出`DATA`即可。当然由于文本数据是`EtherPad`的`EasySync`协同算法的数据,这部分在`Delta`数据结构的文章也介绍过。 ```js { doxcnTYlsMboJlTMcxwXerq6wqc: { id: "doxcnTYlsMboJlTMcxwXerq6wqc", data: { children: ["doxcnXFzVSkuH3XoiT3mqXvOx4b"], }, }, doxcnXFzVSkuH3XoiT3mqXvOx4b: { id: "doxcnXFzVSkuH3XoiT3mqXvOx4b", data: { type: "text", parent_id: "doxcnTYlsMboJlTMcxwXerq6wqc", text: { apool: { nextNum: 3, numToAttrib: { "1": ["italic", "true"], "2": ["bold", "true"] }, }, initialAttributedTexts: { attribs: { "0": "*0+2*0*1+4*0+1*0*2+4*0+5" }, text: { "0": "这样一段文本的数据结构如下所示。" }, }, }, }, }, } ``` 当然这个数据结构看起来比较复杂,我们也可以直接从飞书开放平台的服务端`API`的获取文档所有块中,响应示例中得到整个数据结构的概览。当然看起来这部分数据结构是经历过一次数据转换的,并非直接将数据结构直接响应。其实这部分结构可以认为跟`Slate`的树结构类似,但每行都是独立实例。 当然看起来飞书文档是扁平化的结构,但是实际上构建出来的状态值还是树形的结构,只不过是使用`id`来实现类似链式结构,从而可以构建整个树结构。那么对于飞书文档的选区结构来说,自然也是需要适配数据结构状态的模型。对于文本选区而言,飞书文档的结构如下: ```js // PageMain.editor.selectionAPI.getSelection() [ { id: 2, type: "text", selection: { start: 3, end: 16 } }, { id: 6, type: "text", selection: { start: 0, end: 2 } }, { id: 7, type: "text", selection: { start: 0, end: 1 } }, ]; ``` 既然飞书文档是`Blocks`的块结构设计,那么仅仅是纯文本的选区维护自然是不够的。那么在块结构的选区表达,飞书的选区表达如下所示。说起来,飞书文档的结构选区如果执行跨级别的块选区操作,那么飞书文档会在抬起鼠标的时候会将更深层级的块选区合并为同层级的选区。 ```js // PageMain.editor.selectionAPI.getSelection() [ { id: 2, type: "block" }, { id: 6, type: "block" }, { id: 7, type: "block" }, ]; ``` 针对深度层级的`Blocks`级别文本选区,类似`Editor.js`完全不支持文本的跨节点选中;飞书文档支持跨行的文本选区,但是在跨层级的文本选区会提升为同级块选区;`Slate/Lexical`等编辑器则是支持跨层级的文本选区,当然其本身并非`Blocks`设计的编辑器,主要是支持节点嵌套。 基于`Blocks`思想设计的编辑器其实更类似于低代码的设计,相当于实现了一套受限的低代码引擎,准确来说是无代码引擎。这里的受限主要是指的不会像图形画板那么灵活的拖拽操作,而是基于某些规则设计下的编辑器形式,例如块组件需要独占一行、结构不能任意嵌套等。 其实在这种嵌套的数据结构下,选区的表达方式自然有很多可行的表达。飞书文档的同层级提升方法应该是更加合适的,因为这种情况下处理相关的数据会更简单,而且也并非不支持跨节点的文本选区,也不需要像深层次嵌套结构那样在取得相关节点时需要递归查找,属于数据表达与性能的折中取舍。 ## 选区结构设计 那么在调研了当前主流编辑器的选区模型设计后,我们就需要依照类似的原则来设计我们的编辑器选区模型。首先是针对数据结构设计选区对象,也就是编辑器中通常存在的`Range`对象,其次是针对状态模型的表达,并且需要考虑以此为基准设计编辑器的状态选区结构表达。 ### RawRange 在浏览器中`Range`对象是浏览器选区的基础表达,而对于编辑器而言,通常都会实现编辑器本身的`Range`对象。当然在这种情况下,编辑器`Range`对象和浏览器`Range`对象,特别是可能实现`IOC DI`的情况下,我们可以对浏览器的`Range`对象独立分配对象名。 ```js import DOMNode = globalThis.Node; import DOMText = globalThis.Text; import DOMElement = globalThis.Element; ``` 那么先前已经提到了我们的编辑器数据结构设计,是基于`Delta`的实现改造而来的。因此`Range`对象的设计同样可以与`Quill`编辑器的选区设计保持一致,毕竟选区设计的直接依赖便是数据结构的设计。为了区分我们后边的设计方案,我们这里命名为`RawRange`。 ```js export class RawRange { constructor( /** 起始点 */ public start: number, /** 长度 */ public len: number ) {} } ``` 由于实际上`RawRange`对象相当于是表达的一个线性范围,本身仅需要两个`number`即可以表达。但是为了更好地表达其语义,以及相关的调用方法,例如`isBefore`以及`isAfter`等方法,因此我们还有其内部的`Point`对象的表达。 ```js export class RawPoint { constructor( /** 起始偏移 */ public offset: number ) {} /** * 判断 Point1 是否在 Point2 之前 * - 即 < (p1 p2), 反之则 >= (p2 p1) */ public static isBefore(point1: RawPoint | null, point2: RawPoint | null): boolean { if (!point1 || !point2) return false; return point1.offset < point2.offset; } } ``` 在此基础上就可以实现诸如`intersects`、`includes`等方法,对于选区的各种操作还是比较重要的。例如`intersects`方法可以用来判断选区块级节点的选中状态,因为`void`节点本身是非文本的内容,浏览器本身是没有选区状态的。 ```js export class RawRange { public static intersects(range1: Range | null, range2: Range | null) { if (!range1 || !range2) return false; const { start: start1, end: end1 } = range1; const { start: start2, end: end2 } = range2; // --start1--end1--start2--end2-- // => --end1--start2-- // --start1--start2--end1--end2-- ✅ // => --start2--end1-- const start = Point.isBefore(start1, start2) ? start2 : start1; const end = Point.isBefore(end1, end2) ? end1 : end2; return !Point.isAfter(start, end); } } ``` ```js export class SelectionHOC extends React.PureComponent { public onSelectionChange(range: Range | null) { const leaf = this.props.leaf; const leafRange = leaf.toRange(); const nextState = range ? Range.intersects(leafRange, range) : false; if (this.state.selected !== nextState) { this.setState({ selected: nextState }); } } public render() { const selected = this.state.selected; return (
{React.Children.map(this.props.children, child => { if (React.isValidElement(child)) { const { props } = child; return React.cloneElement(child, { ...props, selected: selected }); } return child; })}
); } } ``` ### Range 既然有`RawRange`选区对象的设计,那么相对应的自然是`Range`对象的设计。在这里我们`Range`对象的设计直接基于编辑器状态的实现,因此其实可以认为,我们的`RawRange`对象是基于数据结构的实现,`Range`对象则是基于编辑器状态模型的实现。 先来看`Range`对象的声明,实际上这里的实现是相对更精细的表达。在`Point`对象中,我们维护了行索引和行内偏移,而在`Range`对象中,我们维护了选区的起始点和结束点,此时的`Range`对象中区间永远是从`start`指向`end`,通过`isBackward`来标记此时是否反选状态。 ```js export class Point { constructor( /** 行索引 */ public line: number, /** 行内偏移 */ public offset: number ) {} } export class Range { /** 选区起始点 */ public readonly start: Point; /** 选区结束点 */ public readonly end: Point; /** 选区方向反选 */ public isBackward: boolean; /** 选区折叠状态 */ public isCollapsed: boolean; } ``` 其中,选区区间永远是从`start`指向`end`这点非常重要,在我们后续的浏览器选区与编辑器选区状态同步中会非常有用。因为浏览器的`Selection`对象得到的`anchor`和`focus`并非总是由`start`指向`end`,此时维护我们的`Range`对象需要从`Selection`取得相关节点。 那么从先前的数据结构上来看,`Delta`数据结构是不存在任何行相关的数据信息,因此我们需要从编辑器维护的状态上来获取行索引和行内偏移。维护独立的状态变更本身也是一件复杂的事情,这件事我们需要后续再看,此时我们先来看下各个渲染节点状态维护的数据。 ```js export class LeafState { /** Op 所属 Line 的索引 */ public index: number; /** Op 起始偏移量 */ public offset: number; /** Op 长度 */ public readonly length: number; } export class LineState { /** 行 Leaf 数量 */ public size: number; /** 行起始偏移 */ public start: number; /** 行号索引 */ public index: number; /** 行文本总宽度 */ public length: number; /** Leaf 节点 */ protected leaves: LeafState[] = []; } ``` 因此,浏览器选区实现的`Selection`对象都是基于`DOM`来实现的,那么通过`DOM`节点来同步编辑器的选区模型同样需要需要处理。不过在这里,我们先以从状态模型中获取选区的方式来构建`Range`对象。而由于上述实现中我们是基于点`Point`来实现的,那么自然可以分离点来处理区间。 ```js const leafNode = getLeafNode(node); let lineIndex = 0; let leafOffset = 0; const lineNode = getLineNode(leafNode); const lineModel = editor.model.getLineState(lineNode); // 在没有 LineModel 的情况, 选区会置于 BlockState 最前 if (lineModel) { lineIndex = lineModel.index; } const leafModel = editor.model.getLeafState(leafNode); // 在没有 LeafModel 的情况, 选区会置于 Line 最前 if (leafModel) { leafOffset = leafModel.offset + offset; } return new Point(lineIndex, leafOffset); ``` 这样看起来,我们分离了`LineState`和`LeafState`的状态,然后直接从相关状态中就可以取出`Point`对象的行索引和行内偏移。注意这里我们得到的是行内偏移而不是叶子结点的偏移。类似`Slate`计算得到的偏移是`Text`节点的偏移,这也是对于选区模型的设计相关。 换句话说,当前我们的选区实现是`L-O`的实现,也就是`Line`与`Offset`索引级别的实现,也就是说这里的`Offset`是会跨越多个实际的`LeafState`节点的。那么这里的`Offset`就会导致我们在实现选区查找的时候需要额外的迭代,实际实现很比较灵活的。 ```js const lineNode = editor.model.getLineNode(lineState); const selector = `[${LEAF_STRING}], [${ZERO_SPACE_KEY}]`; const leaves = Array.from(lineNode.querySelectorAll(selector)); let start = 0; for (let i = 0; i < leaves.length; i++) { const leaf = leaves[i]; let len = leaf.textContent.length; const end = start + len; if (offset <= end) { return { node: leaf, offset: Math.max(offset - start, 0) }; } } return { node: null, offset: 0 }; ``` 其实我也思考过使用`L-I-O`选区的实现,也就是说像是`Slate`的数据结构,只不过我们将其简化为`3`级,而不是像`Slate`一样可以无限层即嵌套下去。这样的好处是,选区模型会更加清晰,因为不需要在行的基础上循环查找,但是缺点是增加了选区复杂度,`L-O`模型属于灵活复杂的折中实现。 ```js export class Point { constructor( /** 行索引 */ public line: number, /** 节点索引 */ public index: number, /** 节点内偏移 */ public offset: number ) {} } ``` 那么最后,我们实际上还需要实现`RawRange`与`Range`对象的转换方法。即使是编辑器内部也需要经常相互转换,例如在执行换行、删除行等操作时,为了方便处理都是用`Range`对象构造的,而基于`Delta`实际执行选区变换时,则需要使用`RawRange`对象处理。 从`RawRange`到`Range`对象的转换相当于从线性范围到二维范围的转换,因此我们需要对选区的行状态进行一次检索。那么由于我们的`LineState`对象索引是线性增长的,那么针对有序序列查找的方式,最常见的方式就是二分查找。 ```js export class Point { public static fromRaw(editor: Editor, rawPoint: RawPoint): Point | null { const block = editor.state.block; const lines = block.getLines(); const line = binarySearch(lines, rawPoint.offset); if (!line) return null; return new Point(line.index, rawPoint.offset - line.start); } } export class Range { public static fromRaw(editor: Editor, raw: RawRange): Range | null { const start = Point.fromRaw(editor, new RawPoint(raw.start)); if (!start) return null; const end = !raw.len ? start.clone() : Point.fromRaw(editor, new RawPoint(raw.start + raw.len)); if (!end) return null; return new Range(start, end, false, raw.len === 0); } } ``` 从`Range`对象到`RawRange`对象的转换相对简单,因为我们只需要将行索引和行内偏移转换为线性偏移即可。而由于此时我们针对行的偏移信息都是记录在行对象上的,因此我们直接取相关的值相加即可。 ```js export class RawPoint { public static fromPoint(editor: Editor, point: Point | null): RawPoint | null { if (!point) return null; const block = editor.state.block; const line = block.getLine(point.line); if (!line || point.offset > line.length) { editor.logger.warning("Line Offset Error", point.line); return null; } return new RawPoint(line.start + point.offset); } } export class RawRange { public static fromRange(editor: Editor, range: Range | null): RawRange | null { if (!range) return null; const start = RawPoint.fromPoint(editor, range.start); const end = range.isCollapsed ? start : RawPoint.fromPoint(editor, range.end); if (start && end) { // 此处保证 start 指向 end return new RawRange(start.offset, Math.max(end.offset - start.offset, 0)); } return null; } } ``` ## 总结 在先前我们基于`Range`对象与`Selection`对象实现了基本的选区操作,并且举了相关的应用具体场景和示例。与之相对应的,在这里我们总结了调研了现代富文本编辑器的选区模型设计,并且基于数据模型设计了`RawRange`和`Range`对象两种选区模型。 接下来我们需要基于编辑器选区模型的表示,然后在浏览器选区相关的`API`基础上,实现编辑器选区模型与浏览器选区的同步。通过选区模型作为编辑器操作的范围目标,来实现编辑器的基础操作,例如插入、删除、格式化等操作,以及诸多选区相关的边界操作问题。 ## 每日一题 - ## 参考 - - - - - -