Skip to content

Commit

Permalink
25/01/18
Browse files Browse the repository at this point in the history
  • Loading branch information
WindRunnerMax committed Jan 18, 2025
1 parent dea6dab commit 9739ebe
Show file tree
Hide file tree
Showing 4 changed files with 44 additions and 10 deletions.
46 changes: 40 additions & 6 deletions Backup/从零设计实现富文本编辑器.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
- 项目笔记: <https://github.com/WindRunnerMax/QuillBlocks/blob/master/NOTE.md>

## Why?
那么为什么要从零设计实现新的富文本编辑器,编辑器是公认的天坑,且当前已经有很多优秀的编辑器实现。例如极具表现力的数据结构设计`Quill`、结合`React`视图层的`Draft`、纯粹的编辑器引擎`Slate`、高度模块化的`PromiseMirror`、开箱即用的`TinyMCE/TipTap`、集成协同解决方案的`EtherPad`等等。
那么为什么要从零设计实现新的富文本编辑器,编辑器是公认的天坑,且当前已经有很多优秀的编辑器实现。例如极具表现力的数据结构设计`Quill`、结合`React`视图层的`Draft`、纯粹的编辑器引擎`Slate`、高度模块化的`ProseMirror`、开箱即用的`TinyMCE/TipTap`、集成协同解决方案的`EtherPad`等等。

我也算是比较关注于各类富文本编辑器的实现,包括在各个站点上的编辑器实现文章我也会看。但是我发现这其中极少有讲富文本编辑器的底层设计,绝大多数都是将的应用层,例如如何使用编辑器引擎实现某某功能等。这些应用层的实现本身也会有一定复杂性,但是底层的设计却是更值得探讨的问题。

Expand Down Expand Up @@ -42,7 +42,9 @@

那么深入编辑器底层就是很有意义的事情,很多时候我们都需要跟浏览器打交道,即使是对我们平时的业务开发也会有价值。在这里我想聊一下编辑器中的零宽字符,以此例学习编辑器的细节设计,这是一个非常有意思的话题,类似这种内容就是不研究则不会关注到的有趣事情。

如果我们在开发者工具检查元素时,可能会发现一些类似于`&ZeroWidthSpace;`的字符,这就是常见的零宽字符。例如在飞书文档的编辑器中,我们通过`$("[data-enter]")`就可以检查到其中存在的零宽字符。
零宽字符顾名思义是没有宽度的字符,因此就很容易推断出这些字符在视觉上是不显示的。因此这些字符就可以作为不可见的占位内容,实现特殊的效果。例如可以实现信息隐藏,以此来实现水印的功能,以及加密的信息分享等等,某些小说站点会通过这种方式以及字形替换来追溯盗版。

而在富文本编辑器中,如果我们在开发者工具检查元素时,可能会发现一些类似于`&ZeroWidthSpace;``U+200B`类似的字符,这就是常见的零宽字符。例如在飞书文档的编辑器中,我们通过`$("[data-enter]")`就可以检查到其中存在的零宽字符。

```html
<!-- document.querySelectorAll("[data-enter]") -->
Expand Down Expand Up @@ -118,13 +120,45 @@
```

### 数据结构设计
数据结构设计
slate数据设计尽可能倾向于`HTML`的设计,
Piece Table
编辑器数据结构的设计是影响面非常广的事情,无论是在维护编辑器的文本内容、块结构嵌套、序列化反序列化等,还是平台应用层面上的`diff`算法、查找替换、协同算法等,以及后端服务的数据转换、导出`md/word/pdf`、数据存储等,都会涉及到编辑器的数据结构设计。

通常来说,基于`JSON`嵌套的数据结构来表达编辑器`Model`是很常见的,例如`Slate``ProseMirror``Lexical`等等。以`slate`编辑器为例,无论是数据结构还是选区的设计,都尽可能倾向于`HTML`的设计,因此可以存在诸多层级节点的嵌套。

```js
[
{
type: "paragraph",
children: [{ text: "editable" }],
},
{
type: "ul",
children: [
{
type: "li",
children: [{ text: "list" }],
},
],
},
];
```

通过线性的扁平结构来表达文档内容也是常见的实现方案,例如`Quill``EtherPad``Google Doc`等等。以`quill`编辑器为例,其内容上的数据结构表达不会存在嵌套,当然本质上还是`JSON`结构,而选区则采用了更精简的表达。

```js
[
{ insert: "editable\n" },
{ insert: "list\n", attributes: { list: "bullet" } },
];
```

当然还有`Piece Table`

此外在`0.50`之前的版本`API`设计非常复杂,需要比较大的理解成本,虽然
normalize 很复杂,特别是脏路径标记

视图相关
视图相关

但是基于扁平的数据结构,来表达结构化的数据会是比较困难的,例如表达代码块、表格等嵌套结构。

协同相关

Expand Down
2 changes: 1 addition & 1 deletion RichText/初探富文本之OT协同实例.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
## 描述
接入协同框架实际上并不是一件简单的事情,尤其是对于`OT`实现的协同算法而言,`OT`的英文全称是`Operational Transformation`,也就是说实现`OT`的基础就是对内容的描述与操作是`Operational`原子化的。在富文本领域,最经典的`Operation``quill``delta`模型,通过`retain``insert``delete`三个操作完成整篇文档的描述与操作,还有`slate``JSON`模型,通过`insert_text``split_node``remove_text`等等操作来完成整篇文档的描述与操作。

有了这个协同实现的基础之后,还需要对所有`Op`具体实现变换`Transformation`,这就是个比较麻烦的工作了,而且也是必不可少的实现。同样是以`quill``slate`两款开源编辑器为例,在`quill`中已经实现了对于其数据结构`delta`的所有`Transformation`,可以直接调用官方的`quill-delta`包即可对于`slate`而言,官方只提供了原子化的操作`API`,并没有`Transformation`的具体实现,但是有社区维护的`slate-ot`包实现了其`JSON`数据的`Transformation`,也可以直接调用即可。
有了这个协同实现的基础之后,还需要对所有`Op`具体实现变换`Transformation`,这就是个比较麻烦的工作了,而且也是必不可少的实现。同样是以`quill``slate`两款开源编辑器为例,在`quill`中已经实现了对于其数据结构`delta`的所有`Transformation`,可以直接调用官方的`quill-delta`包即可对于`slate`而言,官方只提供了原子化的操作`API`,并没有`Transformation`的具体实现,但是有社区维护的`slate-ot`包实现了其`JSON`数据的`Transformation`,也可以直接调用即可。

`OT`协同的实现在富文本领域有比较多的实现可供参考,特别是在开源的富文本引擎上,其实现方案还是比较成熟的,但是引申一下,在其他领域可能并没有具体的实现,那么就需要参考接入的文档自己实现了。例如我们有一个自研的思维导图功能需要实现协同,而保存的数据结构都是自定义的,没有直接可以调用的实现方案,那么这就需要自己实现操作变换了,对于一个思维导图而言我们实现原子化的操作还是比较容易的,所以我们主要关注于变换的实现。

Expand Down
4 changes: 2 additions & 2 deletions RichText/初探富文本之在线文档交付.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ const BoldPlugin: LeafPlugin = {
* `theme.xml`: 保存了应用于文档的主题设置。
* `media`: 保存了文档中使用的所有媒体文件,如图片。

看到这些描述我们可能会非常迷茫应该如何真正组装成`word`文件,毕竟这里有如此多复杂的关系描述。那么既然我们不能瞬间了解整个`docx`文件的构成,我们还是可以借助于框架来生成`docx`文件的。在调研了一些框架后,我发现大概有两种生成方式,一种就是我们常说的通过通用的`HTML`格式来生成,例如`html-docx-js``html-to-docx``pandoc`还有一种是代码直接控制生成,相当于减少了转`HTML`这一步,例如`officegen``docx`。在观察到很多库实际上很多年没有过更新了,并且在这里我们更希望直接输出`docx`,而不是需要`HTML`中转,毕竟在线文档的交付对于格式还是需要有比较高的控制能力的,综上最后选择使用`docx`来生成`word`文件。
看到这些描述我们可能会非常迷茫应该如何真正组装成`word`文件,毕竟这里有如此多复杂的关系描述。那么既然我们不能瞬间了解整个`docx`文件的构成,我们还是可以借助于框架来生成`docx`文件的。在调研了一些框架后,我发现大概有两种生成方式,一种就是我们常说的通过通用的`HTML`格式来生成,例如`html-docx-js``html-to-docx``pandoc`还有一种是代码直接控制生成,相当于减少了转`HTML`这一步,例如`officegen``docx`。在观察到很多库实际上很多年没有过更新了,并且在这里我们更希望直接输出`docx`,而不是需要`HTML`中转,毕竟在线文档的交付对于格式还是需要有比较高的控制能力的,综上最后选择使用`docx`来生成`word`文件。

`docx`帮我们简化了整个`word`文件的生成过程,通过构建内建对象的层级关系,我们就可以很方便的生成出最后的文件,并且无论是在`Node`环境还是浏览器环境中都可以运行,所以在本节的`DEMO`中会有`Node`和浏览器两个版本的`DEMO`。那么现在我们就以`Node`版本为例聊聊如何生成`word`文件,首先我们需要定义样式,在`word`中有一个称作样式窗格的模块,我们可以将其理解为`CSS``class`,这样我们就可以在生成文档的时候直接引用样式,而不需要在每个节点中都定义一遍样式。

Expand Down Expand Up @@ -307,7 +307,7 @@ type Tag = {
};
```

插件的输入设计与`MD`类似,但是输出的内容就需要更加严格,行内元素的插件输出必须是行内的对象类型,行元素的插件输出必须要是行对象类型特别要注意的是在行插件中,我们传递了`leaves`参数,这里也就意味着此时我们的行内元素与行元素的调度是由行插件来管理,而不是在外部`Zone`调度模块来管理。
插件的输入设计与`MD`类似,但是输出的内容就需要更加严格,行内元素的插件输出必须是行内的对象类型,行元素的插件输出必须要是行对象类型特别要注意的是在行插件中,我们传递了`leaves`参数,这里也就意味着此时我们的行内元素与行元素的调度是由行插件来管理,而不是在外部`Zone`调度模块来管理。

```js
type LeafOptions = {
Expand Down
2 changes: 1 addition & 1 deletion RichText/初探富文本之搜索替换算法.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const delta = new Delta([
]);
```

我们的搜索替换方案本质上是在数据描述的基础上来查找目标内容,而从上边的数据结构来看,我们可以明显地看出在`attributes`上也可能存在需要查找的目标内容例如我们需要实现`Mention`组件,是可以在`attributes`中存储`user`信息来展示`@user`的信息,而`insert`置入的内容则是空占位,那么在这里我们就实现一下`Quill``Mention`插件。
我们的搜索替换方案本质上是在数据描述的基础上来查找目标内容,而从上边的数据结构来看,我们可以明显地看出在`attributes`上也可能存在需要查找的目标内容例如我们需要实现`Mention`组件,是可以在`attributes`中存储`user`信息来展示`@user`的信息,而`insert`置入的内容则是空占位,那么在这里我们就实现一下`Quill``Mention`插件。

```js
const Embed = Quill.import("blots/embed");
Expand Down

0 comments on commit 9739ebe

Please sign in to comment.