深入编辑器#
这部分是让我想起来一句话:我们富文本编辑器是这样的,你不写你不懂。
编辑器是个非常注重细节的工程,很多时候都需要深入研究浏览器的API,例如document上的caretPositionFromPoint方法,用以获取当前某个点所在的选区位置,通常用于拖拽文本后的落点定位。除此之外,还有很多选区相关的API,例如Selection、Range等等,这些都是编辑器实现的基础。
那么深入编辑器底层就是很有意义的事情,很多时候我们都需要跟浏览器打交道,即使是对我们平时的业务开发也会有价值。在这里我想聊一下编辑器中的零宽字符,以此例学习编辑器的细节设计,这是一个非常有意思的话题,类似这种内容就是不研究则不会关注到的有趣事情。
零宽字符顾名思义是没有宽度的字符,因此就很容易推断出这些字符在视觉上是不显示的。因此这些字符就可以作为不可见的占位内容,实现特殊的效果。例如可以实现信息隐藏,以此来实现水印的功能,以及加密的信息分享等等,某些小说站点会通过这种方式以及字形替换来追溯盗版。
而在富文本编辑器中,如果我们在开发者工具检查元素时,可能会发现一些类似于​即U+200B类似的字符,这就是常见的零宽字符。例如在飞书文档的编辑器中,我们通过("[data-enter]")就可以检查到其中存在的零宽字符。
Copy
<!-- document.querySelectorAll("[data-enter]") --> <span data-string="true" data-enter="true" data-leaf="true">\u200B</span> <span data-string="true" data-enter="true" data-leaf="true">​</span>
那么从名字上来看,这个零宽字符在视觉上是不显示的,因为其是零宽度。但是在编辑器中,这个字符却是很重要的。简单来说,我们需要这个字符来放置光标,以及做额外的显示效果。需要注意的是我们在这里指的是ContentEditable实现的编辑器,如果是自绘选区的编辑器则不一定需要这部分设计。
我们先来聊一下额外的显示效果,举个例子,我们在选择飞书文档文本内容,如果选中到文本末尾时,会发现末尾会额外多出形似xxx|的效果。在平时不关注的话可能会觉得这是编辑器默认行为,但是实际上这个效果无论是slate还是quill中都是不存在的。
实际上这个效果就是使用零宽字符来实现的,在行内容的末尾后面插入零宽字符,就可以做到末尾的文本选中效果。实际上这个效果在word中更常见,也就是额外渲染的回车符号。
Copy
<div contenteditable="true"> <div><span>末尾零宽字符 Line 1</span><span>​</span></div> <div><span>末尾零宽字符 Line 2</span><span>​</span></div> <div><span>末尾纯文本 Line 1</span></div> <div><span>末尾纯文本 Line 2</span></div> </div>
那么在这个零宽字符如果只是渲染效果的话,那么可能实际上起的作用并不很必要。但是在交互上这个效果却很有用,例如此时我们有3行文本,如果此时从第1行末尾选到第2行时,并且按下Tab键,那么此时这两行的内容就会缩进。
那么如果没有这个显示效果,此时进行缩进操作,用户可能认为仅仅是选中了第2行,但是实际上是选中了1/2两行文本。这样的话用户可能会以为是BUG,而我们也实际接受过这个交互效果的反馈。
Copy
123| 4|x56
也对各个在线文档实现进行了简单调研: 基于contenteditable实现的编辑器中,飞书文档、早期EtherPad存在这个交互实现;自绘选区的编辑器中,钉钉文档存在这个实现;Canvas引擎实现的编辑器中,腾讯文档、Google Doc存在这个实现。
在渲染效果部分,零宽字符还有一个重要的作用是撑起行内容。当我们的行内容为空时,此时这个行DOM结构的内容就是空,这就导致此行的高度塌陷为0,且无法放置光标。为了解决这个问题,我们可以选择在行内容中插入零宽字符,这样就可以撑起行内容且可以放置光标。当然使用<br>来撑起行高也是可以的,使用这两种方案会各有优劣,且兼容性方面也有所不同。
Copy
<div data-line-node></div> <div data-line-node><br></div> <div data-line-node><span>​</span></div>
在类似于Notion这种块结构的编辑器中,还有个比较重要的交互效果。即块级结构独立选择,例如我们可以直接将整个代码块独立选出来,而不是仅仅能选择其中的文本。这种效果在目前的开源编辑器很少有实现,都是需要自行以块结构重新组织设计选区。
通常来说,这个交互同样可以使用零宽字符来实现。因为我们的选区通常是需要放置在文本节点上的,因此我们很容易可以想到,可以在块结构所在行的末尾放置零宽字符,当选区在零宽字符上时就将整个块选中。这里用零宽字符而不是<br>的好处是,零宽字符本身就是零宽,不会引起额外的换行。
Copy
<div> <pre><code> xxx </code></pre> <span data-zero-block>​</span> </div>
在结构上,零宽字符还有个非常重要的实现。在编辑器内的contenteditable=false节点会存在特殊的表现,在类似于inline-block节点中,例如Mention节点中,当节点前后没有任何内容时,我们就需要在其前后增加零宽字符,用以放置光标。
在下面的例子中,line-1是无法将光标放置在@xxx内容后的,虽然我们能够将光标放置之前,但此时光标位置是在line node上,是不符合我们预期的文本节点的。那么我们就必须要在其后加入零宽字符,在line-2/3中我们就可以看到正确的光标放置效果。这里的0.1px也是个为了兼容光标的放置的magic,没有这个hack的话,非同级节点光标同样无法放置在inline-block节点后。
Copy
<div contenteditable style="outline: none"> <div data-line-node="1"> <span data-leaf><span contenteditable="false" style="margin: 0 0.1px;">@xxx</span></span> </div> <div data-line-node="2"> <span data-leaf>​</span> <span data-leaf><span contenteditable="false" style="margin: 0 0.1px;">@xxx</span></span> <span data-leaf>​</span> </div> <div data-line-node="3"> <span data-leaf>​<span contenteditable="false">@xxx</span>​</span> </div> </div>
除此之外,编辑器自然是需要跟字符打交道的,那么在js表现出来的Unicode编码实现中,emoji就是最常见且容易出问题的表达。除了其单个长度为2这种情况外,组合的emoji也是使用独特的零宽连字符\u200d来表示的。
Copy
"🎨".length // 2 "🧑" + "\u200d" + "🎨" // 🧑🎨
数据结构设计#
编辑器数据结构的设计是影响面非常广的事情,无论是在维护编辑器的文本内容、块结构嵌套、序列化反序列化等,还是平台应用层面上的diff算法、查找替换、协同算法等,以及后端服务的数据转换、导出md/word/pdf、数据存储等,都会涉及到编辑器的数据结构设计。
通常来说,基于JSON嵌套的数据结构来表达编辑器Model是很常见的,例如Slate、ProseMirror、Lexical等等。以slate编辑器为例,无论是数据结构还是选区的设计,都尽可能倾向于HTML的设计,因此可以存在诸多层级节点的嵌套。
Copy
[ { type: "paragraph", children: [{ text: "editable" }], }, { type: "ul", children: [ { type: "li", children: [{ text: "list" }], }, ], }, ];
通过线性的扁平结构来表达文档内容也是常见的实现方案,例如Quill、EtherPad、Google Doc等等。以quill编辑器为例,其内容上的数据结构表达不会存在嵌套,当然本质上还是JSON结构,而选区则采用了更精简的表达。
Copy
[ { insert: "editable\n" }, { insert: "list\n", attributes: { list: "bullet" } }, ];
当然还有很多特别的数据结构设计,例如vscode/monaco的piece table数据结构。代码编辑器又何尝不是一种富文本编辑器,毕竟其是可以支持代码高亮的功能的,只不过类似piece table的结构我还没有太深入研究。
在这里我希望能够以线性的数据结构来表达整个富文本结构,虽然嵌套的结构能够更加直观地表达文档内容,但是对于内容的操作起来会更加复杂,特别是存在嵌套的内容时。以slate为例,在0.50之前的版本API设计非常复杂,需要比较大的理解成本,虽然之后将其简化了不少:
Copy
// https://github.com/ianstormtaylor/slate/blob/6aace0/packages/slate/src/interfaces/operation.ts export type NodeOperation = | InsertNodeOperation | MergeNodeOperation | MoveNodeOperation | RemoveNodeOperation | SetNodeOperation | SplitNodeOperation; export type TextOperation = InsertTextOperation | RemoveTextOperation;
从这里可以看出来,slate对于文档内容的完整操作是需要9种类型的Op。而如果是基于线性结构的话,我们就只需要三种类型的操作,即可表达整个文档的操作。当然对于一些类似Move的操作,则需要额外的选区Range计算处理,相当于将计算成本移交到了应用层。
Copy
// https://github.com/WindRunnerMax/BlockKit/blob/c24b9e/packages/delta/src/delta/interface.ts export interface Op { // Only one property out of {insert, delete, retain} will be present insert?: string; delete?: number; retain?: number; attributes?: AttributeMap; }
此外,嵌套结构的normalize会变得很复杂,且变更造成的时间复杂度也会变高,特别是脏路径标记算法,以及标记后的数据处理也需要由上述Op处理。还有用户操作导致的嵌套层级无法非常好地控制,就要normalize过程时规范数据,否则下面例如粘贴HTML时就可能会出现大量的数据嵌套。
Copy
[{ children: [{ children: [{ children: [{ children: [{ // ... text: "content" }] }] }] }] }]
再举个更加实用的例子,如果我们此时存在格式的嵌套内容。例如quote与list两种格式嵌套,如果此时我们文档的数据结构是嵌套结构,那么操作内容就会存在ul > quote或者quote > ul的两种情况,正常情况下我们必须要设计规则来做normalize;而扁平结构下,属性全部写在attrs内,不同操作造成的数据格式变更是完全幂等的。
Copy
// slate [{ type: "quote", children: [{ type: "ul", children: [{ text: "text" }] }], }, { type: "ul", children: [{ type: "quote", children: [{ text: "text" }] }], }] // quill [{ insert: "text", attributes: { blockquote: true, list: "bullet" } }]
扁平的数据结构在数据处理方面会存在优势,而在视图层面上,扁平的数据结构表达结构化的数据会是比较困难的,例如表达代码块、表格等嵌套结构。但是这件事并非是不可行的,例如Google Doc的复杂表格嵌套就是完全的线性结构,这其中是存在很巧妙的设计在里边的,在这里先不展开了。