找回密码
 立即注册
首页 业界区 业界 深感一无所长,准备试着从零开始写个富文本编辑器 ...

深感一无所长,准备试着从零开始写个富文本编辑器

旌磅箱 2025-5-29 10:38:58
富文本编辑器是允许用户在输入和编辑文本内容时,可以应用不同的格式、样式等功能,例如图文混排等,具有所见即所得的能力。与简单的纯文本编辑组件等不同,富文本编辑器提供了更多的功能和灵活性,让用户可以创建更丰富和结构化的内容。现代的富文本编辑器也已经不仅限于文字和图片,还包括视频、表格、代码块、附件、公式等等比较复杂的模块。

  • 开源地址: https://github.com/WindRunnerMax/BlockKit
  • 在线编辑: https://windrunnermax.github.io/BlockKit/
  • 项目笔记: https://github.com/WindRunnerMax/BlockKit/blob/master/NOTE.md
从零实现富文本编辑器项目的相关文章:

  • 深感一无所长,准备试着从零开始写个富文本编辑器
  • 从零实现富文本编辑器#2-基于MVC模式的编辑器架构设计
  • 从零实现富文本编辑器#3-基于Delta的线性数据结构模型
  • 从零实现富文本编辑器#4-浏览器选区模型核心交互策略
Why?

那么为什么要从零设计实现新的富文本编辑器,编辑器是公认的天坑,且当前已经有很多优秀的编辑器实现。例如极具表现力的数据结构设计Quill、结合React视图层的Draft、纯粹的编辑器引擎Slate、高度模块化的ProseMirror、开箱即用的TinyMCE/TipTap、集成协同解决方案的EtherPad等等。
我也算是比较关注于各类富文本编辑器的实现,包括在各个站点上的编辑器实现文章我也会看。但是我发现这其中极少有讲富文本编辑器的底层设计,绝大多数都是讲的应用层,例如如何使用编辑器引擎实现某某功能等。虽然这些应用层的实现本身也会有一定复杂性,但是底层的设计却是更值得探讨的问题。
此外,我觉得富文本编辑器很类似于低代码的设计,准确来说是No Code的一种实现。本质上低代码和富文本都是基于DSL的描述来操作DOM结构,只不过富文本主要是通过键盘输入来操作DOM,而无代码则是通过拖拽等方式来操作DOM,我想这里应该是有些共通的设计思路。
而我恰好前段时间都在专注于编辑器的应用层实现,在具体实现的过程中也遇到了很多问题,并且记录了相关文章。然而在应用层实现的过程中,遇到了很多我个人觉得可以优化的地方,特别是在数据结构层面上,希望能够将我的一些想法应用出来。而具体来说,主要有下面的几个原因:
编辑器专栏

纸上得来终觉浅,绝知此事要躬行。
我的博客是从20年开始写的,记录的内容很多,基本上是想到什么就写什么,毕竟是作为平时学习的记录。然后在24年写了比较多的富文本编辑器的文章,主要是整理了平时遇到的问题以及解决方案,集中在应用层的设计上,例如:

  • 初探富文本之文档虚拟滚动
  • 初探富文本之OT协同算法
  • ...
此外,前段时间还研究了slate富文本编辑器相关的实现,并且也给slate的仓库提过一些PR。还写了一些slate相关的文章,并且还基于slate实现了一个文档编辑器,同样也是比较关注于应用层的实现,例如:

  • WrapNode数据结构与操作变换
  • Node节点与Path路径映射
  • ...
在实现了诸多的应用层的功能之后,发现整个编辑器有很多可以深入研究的地方。特别是有些实现看似很理所当然,但是仔细研究起来会发现这其中有很多细节可以探究,例如在DOM结构后常见的零宽字符、Mention节点的渲染等等,这些内容都可以单独拿出来记录文章,这其实就是我想从零实现编辑器的最重要原因。
24年开始写了很多业务上的东西,到了25年就略感题穷,而目前我也没有别的擅长的方面,由此写编辑器相关的内容是比较好的选择,这样对于文章的选题也会简单些。不过,虽然想的是深入写编辑器相关的内容,但是在平时遇到问题的时候,还是会记录下来,例如最近有个基于immer配合OT-JSON实现的状态管理的想法可以实现。
而对于编辑器的具体实现,我目前的目标是实现可用的编辑器,而不是兼容性非常好且功能完备的编辑器。主要是现在已经有非常多优秀的编辑器实现,且有很多生态插件可以支持,能够满足大部分的需求。目前我想实现的编辑器主要是兼容Chrome浏览器即可,移动端的问题暂时不会考虑。不过,如果能够将编辑器做得比较好的话,自然可以去做兼容性适配。
不过目前还是试探性地来设计并实现编辑器,期间必然会遇到很多问题,这些问题也将会成为专栏的主体内容。最开始的时候,我是准备将编辑器完善后再开始撰写文章,后来发现设计过程中的历史方案同样很有价值,因此决定将设计过程也一并记录下来。如果将来真的能够将编辑器适用于生产环境,那么这些文章就能够溯源到模块为什么这么设计,想必也是极好的。整体来说,我们不能一口吃成胖子,但是一口一口吃却是可以的。
深入编辑器

这部分是让我想起来一句话:我们富文本编辑器是这样的,你不写你不懂。
编辑器是个非常注重细节的工程,很多时候都需要深入研究浏览器的API,例如document上的caretPositionFromPoint方法,用以获取当前某个点所在的选区位置,通常用于拖拽文本后的落点定位。除此之外,还有很多选区相关的API,例如Selection、Range等等,这些都是编辑器实现的基础。
那么深入编辑器底层就是很有意义的事情,很多时候我们都需要跟浏览器打交道,即使是对我们平时的业务开发也会有价值。在这里我想聊一下编辑器中的零宽字符,以此例学习编辑器的细节设计,这是一个非常有意思的话题,类似这种内容就是不研究则不会关注到的有趣事情。
零宽字符顾名思义是没有宽度的字符,因此就很容易推断出这些字符在视觉上是不显示的。因此这些字符就可以作为不可见的占位内容,实现特殊的效果。例如可以实现信息隐藏,以此来实现水印的功能,以及加密的信息分享等等,某些小说站点会通过这种方式以及字形替换来追溯盗版。
而在富文本编辑器中,如果我们在开发者工具检查元素时,可能会发现一些类似于​即U+200B类似的字符,这就是常见的零宽字符。例如在飞书文档的编辑器中,我们通过("[data-enter]")就可以检查到其中存在的零宽字符。
  1. \u200B
  2. ​
复制代码
那么从名字上来看,这个零宽字符在视觉上是不显示的,因为其是零宽度。但是在编辑器中,这个字符却是很重要的。简单来说,我们需要这个字符来放置光标,以及做额外的显示效果。需要注意的是我们在这里指的是ContentEditable实现的编辑器,如果是自绘选区的编辑器则不一定需要这部分设计。
我们先来聊一下额外的显示效果,举个例子,我们在选择飞书文档文本内容,如果选中到文本末尾时,会发现末尾会额外多出形似xxx|的效果。在平时不关注的话可能会觉得这是编辑器默认行为,但是实际上这个效果无论是slate还是quill中都是不存在的。
实际上这个效果就是使用零宽字符来实现的,在行内容的末尾后面插入零宽字符,就可以做到末尾的文本选中效果。实际上这个效果在word中更常见,也就是额外渲染的回车符号。
  1.   末尾零宽字符 Line 1​
  2.   末尾零宽字符 Line 2​
  3.   末尾纯文本 Line 1
  4.   末尾纯文本 Line 2
复制代码
那么在这个零宽字符如果只是渲染效果的话,那么可能实际上起的作用并不很必要。但是在交互上这个效果却很有用,例如此时我们有3行文本,如果此时从第1行末尾选到第2行时,并且按下Tab键,那么此时这两行的内容就会缩进。
那么如果没有这个显示效果,此时进行缩进操作,用户可能认为仅仅是选中了第2行,但是实际上是选中了1/2两行文本。这样的话用户可能会以为是BUG,而我们也实际接受过这个交互效果的反馈。
  1. 123|
  2. 4|x56
复制代码
也对各个在线文档实现进行了简单调研: 基于contenteditable实现的编辑器中,飞书文档、早期EtherPad存在这个交互实现;自绘选区的编辑器中,钉钉文档存在这个实现;Canvas引擎实现的编辑器中,腾讯文档、Google Doc存在这个实现。
在渲染效果部分,零宽字符还有一个重要的作用是撑起行内容。当我们的行内容为空时,此时这个行DOM结构的内容就是空,这就导致此行的高度塌陷为0,且无法放置光标。为了解决这个问题,我们可以选择在行内容中插入零宽字符,这样就可以撑起行内容且可以放置光标。当然使用
来撑起行高也是可以的,使用这两种方案会各有优劣,且兼容性方面也有所不同。
复制代码
在类似于Notion这种块结构的编辑器中,还有个比较重要的交互效果。即块级结构独立选择,例如我们可以直接将整个代码块独立选出来,而不是仅仅能选择其中的文本。这种效果在目前的开源编辑器很少有实现,都是需要自行以块结构重新组织设计选区。
通常来说,这个交互同样可以使用零宽字符来实现。因为我们的选区通常是需要放置在文本节点上的,因此我们很容易可以想到,可以在块结构所在行的末尾放置零宽字符,当选区在零宽字符上时就将整个块选中。这里用零宽字符而不是
的好处是,零宽字符本身就是零宽,不会引起额外的换行。
  1.   <pre>
  2.     xxx
  3.   </pre>
  4.   ​
复制代码
在结构上,零宽字符还有个非常重要的实现。在编辑器内的contenteditable=false节点会存在特殊的表现,在类似于inline-block节点中,例如Mention节点中,当节点前后没有任何内容时,我们就需要在其前后增加零宽字符,用以放置光标。
在下面的例子中,line-1是无法将光标放置在@xxx内容后的,虽然我们能够将光标放置之前,但此时光标位置是在line node上,是不符合我们预期的文本节点的。那么我们就必须要在其后加入零宽字符,在line-2/3中我们就可以看到正确的光标放置效果。这里的0.1px也是个为了兼容光标的放置的magic,没有这个hack的话,非同级节点光标同样无法放置在inline-block节点后。
  1.   
  2.     @xxx
  3.   
  4.   
  5.     ​
  6.     @xxx
  7.     ​
  8.   
  9.   
  10.     ​@xxx​
  11.   
复制代码
除此之外,编辑器自然是需要跟字符打交道的,那么在js表现出来的Unicode编码实现中,emoji就是最常见且容易出问题的表达。除了其单个长度为2这种情况外,组合的emoji也是使用独特的零宽连字符\u200d来表示的。
[code]"
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册