Rust+React创建富文本编辑器

简介

在Fiberplane,我们最近遇到了一个有趣的挑战:我们正在使用的富文本编辑器库已经过时了。我们曾经使用Slate.js——一个很好的编辑器——但是当我们为协作编辑实现我们自己的富文本基元时,我们发现我们自己的基元和Slate的数据模型之间的脱节是一个阻碍因素。所以我们开始思考——如果我们建立自己的富文本编辑器(RTE, Rich Text Editor)会怎样?

从一个非常高层次的角度来看,一个富文本编辑器是由两个部分组成的。

  • 一个数据模型和对其进行操作的核心逻辑。
  • 一个渲染上述数据模型的状态并处理用户互动的视图。

我们在视图中使用了Slate,但结果是它也拉入了自己的数据模型。如果我们可以直接在React中实现视图,我们可以大大简化我们的堆栈,并完全控制它的每个方面。缺点是什么?RTEs因为需要支持复杂的用户交互而臭名昭著,而现在我们需要自己处理每一个交互。

在这篇文章中,我们将讨论我们所面临的挑战以及我们如何解决这些问题。

数据模型

我们的产品是一个协作式的笔记本编辑器。笔记本是一个基于块的编辑器,由不同类型的单元组成,从文本单元到图片和图表。因此,我们确定了一个数据模型,它既有利于我们的协作功能,也有利于为我们在单元格内使用的任何富文本字段提供动力的RTE。在这篇文章中,我们将重点讨论TextCell

struct TextCell {
    pub id: String,
    pub content: String,
    pub formatting: Option,
}

这里的content只是纯文本内容,而formatting是将纯文本变成富文本的东西。"多汁"的部分都在格式化类型里面。

type Formatting = Vec;
​
struct AnnotationWithOffset {
    annotation: Annotation,
    offset: u32,
}
​
enum Annotation {
    StartBold,
    EndBold,
    StartItalics,
    EndItalics,
    StartLink { url: String },
    EndLink,
    /* more like these... */
}

正如你所看到的,这只不过是一个注释列表,它定义了要应用的格式化类型和它开始的偏移量。我们有意不选择类似于HTML的树状结构,因为格式化范围可以重叠,这将导致复杂的树状操作。此外,每个注释只有一个偏移量的简单性使我们很容易实现我们用于协作的操作转换(OT)算法。

核心逻辑

随着数据模型的出现,也带来了与之互动的代码。当你在一个单元格中打字时,我们在哪里插入新打的字符?这如何影响content和相关的formatting?如果你在一个选择上切换格式,应该发生什么?如果你将一个单元格从中间分割开来,又该怎么办?所有这些以及更多都在Rust的核心逻辑中实现。

你要知道,无论如何我们都需要这些逻辑,因为我们的OT算法也需要它。但现在我们也能用同样的原语来驱动我们的编辑器。

为了使这个逻辑易于测试,它被实现为纯函数,我们在TypeScript的Redux reducer中调用。我们创建了fp-bindgen来生成Rust代码和调用它的TypeScript代码之间的绑定关系。

为了适应RTE(当我们还在使用Slate时还不需要),我们不得不自己引入一段逻辑,就是光标管理。例如,当用户按下左方向键时,我们分派一个MoveCursor动作,其有效载荷如下。

struct MoveCursorPayload {
    pub delta: i32,
    pub extend_selection: bool,
    pub unit: CursorUnit,
}

delta指定光标是向前还是向后移动,通过指定一个1-1的值。extend_selection属性是在用户按住Shift键时使用的,用来扩展当前的选择,或者在还没有选择的情况下创建一个。这个unit决定了我们是按Unicode字母群(用户通常称之为 "字符")还是按单词移动光标,用于用户按住Ctrl/键时。然后,我们的Rust还原器会处理这些动作,并处理所有的边缘情况,包括确保光标不会出现在@的中间。

视图

在我们RTE的大部分开发过程中,我们的编辑器甚至不是一个编辑器。至少从浏览器的角度来看不是。这是因为浏览器通常只识别两种类型的编辑器:纯文本编辑器,如