纸上得来终觉浅,绝知此事要躬行。
最初,希望基于 Quill 实现块结构组织的编辑器,却被跨行的选区问题所困扰。
后来,希望基于 Embed Blot 设计插件实现块结构的嵌套,却被复杂交互所需要的视图层实现掣肘。
最终,希望能以 Quill、Slate、EtherPad 的核心理念为参考,从零实现富文本编辑器,以便能够解决相关的问题,并且将一些想法付诸实现:
API文档,却鲜有开发过程中的问题记录。因此希望能够将开发过程中的问题都记录下来,用以解决两个问题:为什么要这么设计、这种设计方案有什么优劣。DOM表达,然而这样对于数据的操作却变得更加复杂。扁平的数据结构且独立分包设计,无论是在编辑器中操作,还是在服务端数据解析,都可以更加方便地处理。OT还是CRDT协同调度都不是简单的问题,编辑器内部的数据设计可能无法用于实时协同编辑。因此协同设计必须要从底层数据结构出发,在编辑器模块实现细节设计,整体方案上都需要考虑数据一致性。我非常喜欢slate的core react这样分包的设计,但是并不太喜欢slate的json形式的数据结构,我个人认为扁平化才是大文档的最佳实践。我也很喜欢quill的delta设计,这是一个扁平化的数据结构,但是我并不太喜欢quill自己实现的视图层,当然这也是一种解决方案,毕竟框架也是在不断发展更迭的。
自行实现视图层的好处是可以做到框架无关,不需要局限于引入的框架本身,但是很明显这样就会导致新的视图结构学习成本,并且无法使用现有的UI组件库来绘制UI,这就导致如果需要实现复杂的文档系统而引入大量的额外工作。那么我在想如果我们实现核心层,而各种UI框架则可以根据核心层的事件来适配可能也是个不错的方案。
那么我在想为什么不搞一个像是slate一样的分包设计,再配合上quill的delta数据结构,并且解决一些我认为是不太合适的设计,所以便有了这个项目,当然我也并没有指望整个项目有很多人用,更多的是满足自己的好奇心,如何从零做一套富文本编辑器。其实最初起名为Blocks目的是想做一套canvas的编辑器,但是成本太高了。所以我目前是想利用这种分包结构先做好react的,然后有机会先做仅渲染的canvas,这样成本应该会低很多。
因为整个富文本编辑器还是非常复杂的,各大框架都是按年维护的,从零开始实现必然需要很大的精力,我也只是想做一下满足一下好奇心,这个文档就是随手记一些想法与设计上的思考,顺便还能为后期写文章作为参考。最开始的话可能仅仅是实现最基本的富文本输入框这种类型,实现最基本的行内格式以及行格式,后续再考虑实现Blocks来构建复杂的文档编辑器,毕竟一口不能吃成胖子,但一口一口吃却可以。
最开始我思考了很长时间如何设计Block化的编辑器,除了对于交互上的设计比较难做之外,对于数据的设计也没有什么比较好的想法,特别是实际上是要管理一棵树形结构,并且同时还需要支持对富文本内容的描述。最开始我想如果直接通过JSON来处理嵌套的数据结构表达,但是想了想这岂不是又回到了Slate的设计,在这种设计方案下数据描述特别是数据处理会很麻烦。后来我又想分别管理树结构与引用关系,这样当然是没有问题的,只不过看起来并没有那么清晰,特别是还要设计完备的插件化类型支持,这部分可能就没有那么好做了。
后来,我想是不是可以单独将Blocks类型放在单独的包里,专门用来管理整棵树的描述,以及类型的扩展等等,而且在扩展类型时不会因为重新declare module导致不能实际引用原本的包结构,当然单独引用独立的模块用来做扩展也是可以的。此外,这里就不再单独维护树结构与引用关系了,每个块都会携带自己的引用关系,即父节点parent的id与子节点children的id,这里只存储节点的id而不是具体的对象引用,在运行时通过状态管理再来获取实际的引用。此外在编辑器的实际对象中也需要维护状态对象,在状态树里需要维护基本的数据操作,最终的操作还是需要映射到所存储的数据结构BlockSet。
当然,Blocks的设计成本也会比较大,相当于实现了一套受限的低代码引擎,准确来说是无代码引擎。这里的受限主要是指的不会像图形画板那么灵活的拖拽操作,而是基于某些规则设计下的编辑器形式,例如块组件需要独占一行、结构不能任意嵌套等。数据模型上通常是需要JSON嵌套的结构来处理数据,协同方面可以直接借助OT-JSON来实现,当然这里通常还需要嵌套文本协同的子类型,例如rich-text、easysync等,而富文本本身配套的基础设施自然也需要实现,例如undo/redo、选区模块、剪贴板模块等。
Blocks的编辑器实际上是由Notion引发的编辑器设计方案,这样就可以以独立块为单位,做到更细粒度的数据管理。以此得到更加灵活的管理,例如块级拖拽、结构嵌套、内容复用等,此外还可以做到流式加载,服务端数据存储也可以更加轻量,而不必将内容全部都放置于同一个字段中。此外,细粒度的块结构管理,可以更好的实现细粒度的权限控制,在跨文档关联、协作编辑方面会更有优势。
而还有个重要的特点是更好的扩展性,如果在常见的编辑器中扩展节点,需要大量的编辑器基础知识来组织和管理模块,因为这些扩展完全耦合于编辑器中。而Blocks架构的编辑器则是实现了块的管理框架,相当于是维护了N个编辑器实例以及N个其他类型的实例,编辑器实例是个特殊的块结构,其他类型的实例就完全由扩展实现,数据变更协同则同样需要我们规范设计。在这个框架下,无论是实现虚拟滚动、流式载入,还是实现块级的错误捕获,以及按需diff等能力,都会更加容易。
针对上述的扩展性我们可以举个例子,假设我们现在需要实现Tabs模块,在Tabs除了内容的编辑之外,还需要管理Header的标签切换这种交互。那么如果在富文本框架中实现这部分,就需要编辑器知识来组织,例如需要避免标题内容被选区选中、需要借助编辑器的数据结构管理标题和折叠状态等。那么如果以Blocks的形式来管理,那么就更像是在低代码引擎中管理React组件,而不必非要受限于编辑器的架构设计,Header的数据和状态设计可以更加灵活,内容则直接引用编辑器列表这个特殊的Block即可。
在上边也提到了,在这里我想做的就是纯Blocks的编辑器,而实际上目前我并没有找到比较好的编辑器实现来做参考,主要是类似的编辑器都设计的特别复杂,在没有相关文章的情况很难理解。此外我还是比较倾向于quill-delta的数据结构,因为其无论是对于协同的支持还是diff、ops的表达都非常完善,所以我想的是通过多个Quill Editor实例来实现嵌套Blocks,实际上这里边的坑会有很多,需要禁用大量的编辑器默认行为并且重新实现,例如History、Enter回车操作、选区变换等等,可以预见这其中需要关注的点会有很多,但是相对于从零实现编辑器需要适配的各种浏览器兼容事件还有类似于输入事件的处理等等,这种管理方式还算是可以接受的。
在这里需要关注一个问题,对于整个编辑器状态管理非常依赖于架构设计,从最开始我想做的就是Blocks的编辑器,所以在数据结构上必然需要以嵌套的数据结构来描述,当然在这里我设计的扁平化的Block,然后对每个Block都存储了string[]的Block节点信息来获取引用。而在实现的过程中,我关注到了一个特别的问题,如果在设计编辑器时不希望有嵌套的结构,而是希望通过扁平的数据结构描述内容,而在内容中如果引用了块结构那么就再并入Editor实例,这种设计虽然在数据结构上与上边的BlockSet非常类似,但是整体的表达却是完全不同。
Blocks的编辑器是完全由最外层的Block结构管理引用关系,也就是说引用是在children里的,而块引用的编辑器则需要由编辑器本身来管理引用关系,也就是说引用是在ops里的。所以说对于数据结构的设计与实现非常依赖于编辑器整体的架构设计,当然在上边这个例子中也可以将块引用的编辑器看作单入口的Blocks编辑器,这其中的Line表达全部交由Editor实例来处理,这就是不同设计中却又相通的点。
对于选区的问题,我思考了比较久,最终的想法依然还是通过首尾的RangePoint来标记节点,需要注意的是如果节点的块不属于同块节点,那么不会继续处理选区Range变换。同样的,目前依然是通过首尾节点来标记,所以特别需要关注的是通过首尾节点来标记整个Range,采用这个方案可以通过首尾节点与index来获取Range,这里需要关注的是当节点的内容发生变化时,需要重新计算index。实际上这里如果直接遍历当前节点直属的所有index状态更新也是可以的,在实际1万次加法运算,实际上的时间消耗也只有0.64306640625ms不到1ms。
我们的编辑器实际上是要完成类似于slate的架构,当前设计的架构的是core与视图分离,并且此时我们不容易入侵到quill编辑器的选区能力,所以最终相关的选区变换还是需要借助DOM与Editor实例完成,还需要考量在core中维护的state状态管理。在DOM中需要标记Block节点、Line节点、Void节点等等,然后在浏览器onSelectionChange事件中进行Model的映射。当然整个说起来容易,做起来就难了,这一套下来还是非常复杂的,需要大量时间不断调试才行。
浏览器中存在明确的选区策略,在State 1的ContentEditable状态下,无法做到从Selection Line 1选择到Selection Line 2,这是浏览器默认行为,而这种选区的默认策略就定染导致我无法基于这种模型实现Blocks。
而如果是Stage 2的模型状态,是完全可以做到选区的正常操作的,在模型方面没有什么问题,但是我们此时的Quill选区又出现了问题,由于其在初始化时是会由<br/>产生到div/p状态的突变,导致其选区的Range发生异动,此时在浏览器中的光标是不正确的,而我们此时没有办法入侵到Quill中帮助其修正选区,且DOM上没有任何辅助我们修正选区的标记,所以这个方式也难以继续下去。
因此在这种状态下,我们可能只能选取Stage 3策略的形式,并不实现完整的Blocks,而是将Quill作为嵌套结构的编辑器实例,在这种模型状态下编辑器不会出现选区的偏移问题,我们的嵌套结构也可以借助Quill的Embed Blot来实现插件扩展嵌套Block结构。
<p>State 1</p>
<div contenteditable="false" data-block>
<div contenteditable="true" data-line>Selection Line 1</div>
<div contenteditable="true" data-line>selection Line 2</div>
</div>
<p>State 2</p>
<div contenteditable="true" data-block>
<div contenteditable="true" data-line>Selection Line 1</div>
<div contenteditable="true" data-line>selection Line 2</div>
</div>
<p>State 3</p>
<div contenteditable="true" data-block>
<div data-line>Selection Line 1</div>
<div data-line>selection Line 2</div>
<div contenteditable="false" data-block>
<div contenteditable="true" data-line>Selection Line 1</div>
<div contenteditable="true" data-line>selection Line 2</div>
</div>
</div>
core -> react中使用context/redux/mobx是否可以避免自行维护各个状态对象,也可以达到局部刷新而不是刷新整个页面的效果。
想了想似乎不太行,就拿context来说,即使有immer.js似乎也做不到局部刷新,因为整个delta的数据结构不能够达到非常完整的与react props对应的效果,诚然我们可以根据op & attributes作为props再在组件内部做数据转换,但是这样似乎并不能避免维护一个状态对象,最基本的是应该要维护一个LineState对象,每个op可能与前一个或者后一个有状态关联,以及行属性需要处理,这样一个基础的LineState对象是必不可少的。
后边我又仔细想了想,毕竟现在没有实现就纯粹是空想,LineState对象是必不可少的,再加上是要做插件化的,那么给予react组件的props应该都实际上可以隐藏在插件里边处理,如果我使用immer的话,似乎只需要保证插件给予的参数是不变的即可,但是同样的每一个LineState都会重新调用一遍插件化的render方法(或者其他名字),这样确实造成了一些浪费,即使能够保证数据不可变即不会再发生re-render,但是如果在插件中解构了这个对象或者做了一些处理,那么又会触发react函数执行,当然因为react diff的存在可能不会触发视图重绘罢了。
那么既然LineState对象不可避免,如果再在这上边抽象出一层BlockState来管理LineState,这样是不是会更简单一些,同样因为上边也说过目前是在delta的基础上又包了一层block,那么就又需要一个ContentState来管理BlockState了,当然这层ContentState也是可以直接放在editor对象里的,只不过editor对象包含了太多的模块,还是抽离出来更合适。通过editor的Content Change事件作为bridge,以及这种一层管理一层的方式,精确地更新每一行,减少性能损耗,甚至于因为我们能够比较精确的得知究竟是哪几个op更新了,做到精准更新也不是不可能。
即然确定好是Editor -> ContentState -> BlockState -> LineState的结构设计,那么LineState应该怎么设计才能让编辑器在apply的时候能够精确的修改Line的状态呢,因为咱们的数据结构是delta,是一个扁平化的结构,选区的设计是start length的结构,那么LineState最少要保存一个start和一个offset,考虑到执行更新的时候大概率是要处理这两个值的,所以这两个值应该与Ops和line attrs独立放置,或者直接在原对象上修改,保证数据不可变的状态,另外每层结构还需要传一个parent进去,方便处理父级信息。
另外又想到一个问题,选区是一个很重要的点,所以通过选区来对应到指定状态也很重要,目前延续的设计是{ start, length, blockId }的三元组,所以需要一个转换是很必要的,比如转换出来的位置需要有{ blockState, lineState }这一些状态信息,当然也可以直接通过提供参数来取得这些状态信息,都是可行的。
Selection选区,我思考了很长时间这部分应该如何表示,虽然最根本的思想就是从浏览器的选区映射到Editor自己维护的选区,以及可以反向将自己维护的选区再设置到浏览器上。浏览器的选区API挺多的,主要集中在getSelection、onSelectionChange上,虽然这些API我们不需要全部实现,但是基本的Range、getSelection、setSelection、onSelectionChange都需要有,还有焦点等一系列的状态需要处理。想一想,我们是通过插件的形式生成的DOM结构,那么core肯定是不能完全控制这些DOM节点的生成,那么怎么映射就是个复杂的问题。综上,选区将会是个非常复杂的模块。
趁着五一假期我研究了下quill和slate的选区实现,实际上看的是似懂非懂的样子,感觉类似的东西还是需要实际操作才能真的明白,而且我看相关的实现会有大量的case需要特殊处理,当然这块主要是对于ZeroSpace/Void与浏览器的选区兼容的实现。当然我也总结了一些内容,因为是看的并不是很懂,所以可能并不是很正确,有可能后边在真的实现的时候会被推翻,但是目前来看还是有助于理解的。
slate还是quill都是更专注于处理点Point,当然quill的最后一步是将点做减法转化为length,但是在这一步之前,都是在处理Point这个概念的,我想这似乎是因为本身浏览器的选区也是通过Anchor与Focus这两个Point来实现的,所以转换也是需要继承这个实现。slate还是quill也都会将浏览器选区进行一个Normalize化,这一块我没太看明白,似乎是为了将选区的内容打到Text节点上,并且再来计算Text节点的offset,毕竟富文本实际上专注的还是Text节点,各种富文本内容也是基于这个节点类似于fake-text来实现的。另外还有可能因为浏览器的选区可能不很合适,才需要这个规范化。quill因为是自行实现的View层,所以其维护的节点都在Blot中,所以将浏览器的选区映射到quill的选区相对会简单一些。那么slate是借助于React实现的View层,那么映射的过程就变的复杂了起来,所以在slate当中可以看到大量的形似于data-slate-leaf的节点,这都是slate用来计算的标记,当然其实不仅仅是选区的标记。Range。所以实际上在渲染视图时就需要一个Map来做映射,将真实的DOM节点来映射一个对象,这个对象保存着这个节点的key,offset,length等信息,这样WeakMap对象就派上了用场,之后在计算的时候就可以直接通过DOM节点作为key来获取这个节点的信息,而不需要再去遍历一遍。那么其实看以上这几点,我们的编辑器实际上是要完成类似于slate的架构,因为我们希望的是core与视图分离,所以选区、渲染这方面的实现都需要在react这个包里实现,相关的state是在core里实现的,通过onContentChange来实现通信,在内容变化的时候通知react去setState进行渲染。当然整个说起来容易,做起来就难了,这一套下来还是非常复杂的,需要大量时间不断调试才行。
到这里,其实可以感觉到我们主要是用到了浏览器的ContentEditable编辑、选区以及DOM的能力,那么我们的编辑器最重要的一个能力就是输入,有了之前聊到的一些设计与抽象,我们似乎可以比较简单的设计整个流程:
Range Model,包括选区变换时根据DOMRange映射到Model,这一步需要比较多的查找和遍历,还需要借助我们之前聊的WeakMap对象来查找Model来计算位置。BeforeInputEvent以及CompositionEvent分别处理输入/删除与IME输入,基于输入构造Delta Change应用到BlockSet上并且触发ContentChange,视图层由此进行更新。DOM以及我们维护的Model刷新选区,需要根据Model映射到DOMRange,再应用到浏览器的selection对象中,这其中也涉及了很多边界条件。实际上在完成上边整个流程的过程中,我遇到了两个非常麻烦的问题,而且也是在解决问题的过程中,慢慢地完善了整个流程的实现,路程还是比较曲折的。
第一个遇到的问题是选区的的同步,此时我已经完成了第一步,也就是通过选区映射到我们自行维护的Range Model,接下来我想来处理输入,这时候还没有考虑到IME的问题,只是在处理英文的输入,那么对于输入部分当前其实就是劫持了BeforeInputEvent事件。那么当我进行输入操作的时候,问题来了,假设我们此时有两个span,最开始当前的DOM结构是<span>DOM1</span><span>DO|M2</span>,|表示光标位置,我要在第二个span的DO和M2字符之间插入内容x,此时无论是用代码apply还是用户输入的方式,都会使得DOM2这个span由于apply造成ContentChange继而DOM节点会刷新,也就是说就是第二个span已经不是原来的span而是创建了一个新对象,那由于这个DOM变了导致浏览器光标找不到原本的DOM2这个span结构了,那么此时光标就变成了<span>DOM1|</span><span>DOxM2</span>。本身我认为起码在输入的时候选区应该是会跟着变动的,实践证明这个方法是不行的,所以实际上在这里就是缺了一步根据我们的Range Model来更新DOM Range的操作,而且由于我们应该在DOM结构完成后尽早更新DOM Range,这个操作需要在useLayoutEffect中完成而不是useEffect中,也就对标了类组件的componentDidUpdate,更新DOM Range的操作应该是主动完成的,例如当前的DOM视图刷新,Paste事件等等。
第二个遇到的问题是脏数据的问题,此时上边的三步操作都已经实现了,但是在输入的时候我遇到了一个问题,最开始当前的DOM结构是<span>DOM1</span><bold>DOM2</bold>,此时我在两个div的最后输入了中文,也就是唤起了IME输入,当我输入了 试试 这两个字(不追加样式)之后,此时的DOM结构变成了<span>DOM1</span><bold>DOM2试试</bold><span>试试</span>,很明显在bold标签里边的文字是异常的,我们此时的数据结构Delta内容上是没问题的。然而也就是因为这样造成了问题,我们的Delta Model没有改变,那么由我们维护的Model映射到React维护的Fiber时,由于Model没有变化那么React根据VDOM diff的结果发现没有改变于是原地复用了这个DOM结构,而实际上这个DOM结构由于我们的IME输入是已经被破坏了的,而由于英文输入时我们阻止了默认行为是不会去改变原本的DOM结构的,所以在这里我们需要进行脏数据检查,并且将脏数据进行修正,确保最后的数据是正常的,目前采取的一个方案是对于最基本的Text组件进行处理,在ref回调中检查当前的内容是否与op.insert一致,不一致要清理掉除第一个节点外的所有节点,并且将第一个节点的内容回归到原本的text内容上。
其实曾经我也想通过自绘选区和光标的形式来完成,因为我发现通过ContentEditable来控制输入太难控制了,特别是在IME中很容易影响到当前的DOM结构,由此还需要进行脏数据检查,强行更新DOM结构,但是简单了解了下听说坑也不少,于是放弃了这个想法而依然选用了大多数L1编辑器都在用的ContentEditable。但是实际上ContentEditable的坑也很多,这其中有非常多的细节,我们很难把所有的边界条件都处理完成,如何检测DOM被破坏由此需要强制刷新,当我们将所有的边界case都处理到位了,那么代码复杂度就上来了,可能接下来就需要处理性能问题了,例如我们本身涉及DOM与Model的映射,所以有大量的计算,这部分也是需要考虑如何去进行优化的,特别是对于大文档来说。
首先我们来聊聊输入部分,输入其实分为了好几种方法,一种是非受控的方法,采用这种方法的时候,我们需要MutationObserver来确定当前正在输入字符,之后通过解析DOM结构得到最新的Text Model,之后需要与原来的Text Model做diff,由此来得到ops,这样就可以应用到当前的Model中进行后续的工作了。一种是半受控的方法,通过BeforeInputEvent以及CompositionEvent分别处理输入/删除与IME输入,以及额外的onKeyDown、onInput事件来辅助完成这部分工作,通过这种方式就可以劫持用户的输入,由此构造ops来应用到当前的Model,当然对于类似CompositionEvent需要一些额外的处理,这也是当前主流的实现方法,当然由于浏览器的兼容性,通常会需要对BeforeInputEvent做兼容,例如借助React的合成事件或者onKeyDown来完成相关的兼容。还有一种是全受控的方法,当我们自绘选区的时候,就必须将所有的内容进行绘制,比如IME输入的时候,相关的字符需要记录并且分配id,当结束的时候将原来的内容删除并且构造为新的Model,全受控通常需要一个隐藏的输入框甚至是iframe来完成,这其中也有很多细节需要处理,例如在CompositionEvent时需要绘制内容但不能触发协同。
在性能方面,除了上边提到的WeakMap可以算作是一种优化方案之外,我们还有很多值得优化的地方,例如因为Delta数据结构的关系,我们在这里需要维护一个PointRange - RawRange选区的相互变换,而在这其中由于我们对LineState的start和size有所记录,那么我们在变换查找的时候就可以考虑到用二分的方法,因为start必然是单向递增的。此外,由于我们实际上是完全可以推算出本次更新究竟是更新了什么内容,所以对于原本的State对象是可以通过计算来进行复用的,而不是每次更新都需要刷新所有的对象,当然这可能并不是非常好的操作,没有immutable增加了维护的细节和难度。还有一点,对于大文档来说扁平化的数据结构应该是比较好的,扁平化意味着没有那么复杂,例如现在的Delta就是扁平化的数据结构,但是随机访问的效率就有些棘手了,或许到了那时候需要结合一些数据存储的方案例如PieceTable,当然对于现在来说还是有点远,现在我们的编辑器也只是跑通了基本流程而已。
这次想聊一下业务场景以及相关的技术细节,主要是关于富文本内容导入导出的,这个话题其实是非常大的因为细节会特别多,所以在这里也只是简述。
在线文档会有很多场景需要用到导入导出,导入的场景比如从Markdown迁移到我们的富文本形式,这就需要解析Markdown转成我们的BlockDSet数据结构,这是一些做文档增量的重要环节。再比如一些文档需要外部供应商的修改,这就需要我们支持导出并且能够将其完备的导入回来,一个非常常见的场景就是文档翻译。在导出方面非常标准的场景就是私有化交付,这种情况下我们通常就需要导出Word,当然导出Markdown、PDF都是比较常见的私有化交付能力,但是对于要求比较高的客户文档还是要求导出Word会更正规一些,因为Markdown不能支持富文本的所有格式,PDF又不太容易编辑,而Word就相对能承载更加复杂场景并且拥有可以继续编辑的能力。
导出Word实际上是个比较复杂的工作,在这种情况下我们就需要了解OOXML(Office Open XML),Office Word的.docx文件就是使用OOXML标准实现的。如果简单了解下的话,就可以明显的感觉到Word的设计就很靠拢于Quill-Delta的设计,实际上会更加靠拢我们的BlockSet设计,当然我们直接构造OOXML是很麻烦的,通常需要借助框架,在我个人调研过后能力比较完备的框架是docx.js,在研究这个框架之后可以发觉我们的BlockSet设计是可以相对轻松地进行转换的,这同样也是我个人比较喜欢quill-delta而不是slate-json数据结构的一个原因。当然即使是使用了框架,我们的工作也是比较复杂的,因为使用Word需要比较大量的计算,比如嵌套和缩进的情况下计算宽度,然后Block的嵌套设计是需要使用Table来实现的,整体来说还是比较复杂的。当然我们在这里探讨的都是需要非常定制化的场景,如果要求不高的话,直接使用HTML - Word就可以了,只不过我们要是实现在线文档私有化交付的话通常都是定制化要求比较高的,所以还是需要相关能力开发的。
在先前的State模块更新文档内容时,我们是直接重建了所有的LineState以及LeafState对象,然后在React视图层的BlockModel中监听了OnContentChange事件,以此来将BlockState的更新应用到视图层。这种方式简单直接,全量更新状态能够保证在React的状态更新,然而这种方式的问题在于性能,当文档内容非常大的时候,全量计算将会导致大量的状态重建,并且其本身的改变也会导致React的diff差异进而全量更新文档视图,这样的性能开销通常是不可接受的。
那么通常来说我们就需要基于Changes来确定状态的更新,首先我们需要确定更新的粒度,例如以行为基准则retain跨行的时候就直接复用原有的LineState,这当然是个合理的方法,相当于尽可能复用Origin List然后生成Target List,这样的方式自然可以避免部分状态的重建,尽可能复用原本的对象。整体思路大概是分别记录旧列表和新列表的row和col两个index值,然后更新时记录起始row,删除和新增自然是正常处理,对于更新则认为是先删后增,对于内容的处理则需要分别讨论单行和跨行的问题,最后可以将这部分增删LineState数据放置于changes中,就可以得到实际增删的Ops了,这部分数据在apply的delta中是不存在的,同样可以认为是数据的补充。
那么这里实际上是存在非常需要关注的点是我们现在维护的是状态模型,那么也就是说所有的更新就不再是直接的Delta.compose,而是使用我们实现的Mutate,假如我们对于数据的处理存在偏差的话,那么就会导致状态出现问题,本质上我们是需要实现Line级别的compose方法。实际上我们可以重新考虑这个问题,如果我们整个行的LeafState都没有变化的话,是不是就可以意味着LineState就可以直接复用了,在React中Immutable是很常用的概念,那么我们完全可以重写compose等方法做到Imuutable,然后在更新的时候重新构建新的Delta,当行中Ops都没有发生变化的时候,我们就可以直接复用LinState,当然LeafState是完全可以直接复用的,这里我们将粒度精细到了Op级别。
此外在调研了相关编辑器之后,我发现关于key值的管理也是个值的探讨的问题。先前我认为Slate生成的key跟节点是完全一一对应的关系,例如当A节点变化时,其代表的层级key必然会发生变化,然而在关注这个问题之后,我发现其在更新生成新的Node之后,会同步更新Path以及PathRef对应的Node节点所对应的key值,包括飞书的Block行状态管理也是这样实现的,飞书Block的叶子节点则更加抽象,key值是stringify化的Op属性值拼接其Line内的属性值index,用以处理重复的属性对象。我思考在这里key值应该是需要主动控制强制刷新的时候,以及完全是新节点才会用得到的,应该跟React以及ContentEditable非受控有关系,这个问题还是需要进一步的探讨。
因此关于整个状态模型的管理,还有很多问题需要处理,例如我们即使需要重建LineState,也需要尽可能找到其原始的LineState以便于复用其key值,避免整个行的ReMount,当然即使复用了key值,因为重建了State实例,React也会继续后边的ReRender流程。说到这里,我们对于ViewModel的节点都补充了React.memo,以便于我们的State复用能够正常起到效果。但是,目前来说我们的重建方案效率是不如最开始提到的行方案的,因为此时我们相当于从结果反推,大概需要经过O(3N)的时间消耗,而同时compose以及复用state才是效率最高的方案,这里还存在比较大的优化空间,特别是在多行文档中只更改小部分行内容的情况下,实际上这也是最常见的形式。
通常实现Void/Embed节点时,我们都需要在Void节点中实现一个零宽字符,用来处理选区的映射问题。通常我们都需要隐藏其本身显示的位置以隐藏光标,然而在特定条件下这里会存在吞IME输入的问题。
<div contenteditable="true"><span contenteditable="false" style="background:#eee;">Void<span style="height: 0px; color: transparent; position: absolute;"></span></span><span>!</span></div>
处理这个问题的方式比较简单,我们只需要将零宽字符的标识放在EmbedNode之前即可,这样也不会影响到选区的查找。https://github.com/ianstormtaylor/slate/pull/5685。此外飞书文档的实现方式也是这样的,ZeroNode永远在FakeNode前。
<div contenteditable="true"><span contenteditable="false" style="background:#eee;"><span style="height: 0px; color: transparent; position: absolute;"></span>Void</span><span>!</span></div>
在这里我还发现了一个很有趣的事情,是关于ContentEditable以及IME的交互问题。在slate的issue中发现,如果最外层节点是editable的,然后子节点中某个节点是not editable的,然后其后续紧接着是span的文本节点,当前光标位于这两者中间,此时唤醒IME输入部分内容,如果按着键盘的左键将IME的编辑向左移动到最后,则会使整个编辑器失去焦点,IME以及输入的文本也会消失,此时如果在此唤醒IME则会重新出现之前的文本。这个现象只在Chromium中存在,在Firefox/Safari中则表现正常。
<div contenteditable="true"><span contenteditable="false" style="background:#eee;">Void</span><span>!</span></div>
这个问题我在https://github.com/ianstormtaylor/slate/pull/5736中进行了修复,关键点是外层span标签有display:inline-block样式,子div标签有contenteditable=false属性。
<div contenteditable="true"><span contenteditable="false" style="background: #eee; display: inline-block;"><div contenteditable="false">Void</div></span><span>!</span></div>
ZeroEnter节点选区位置前置。Next状态,折叠/shift状态处理。// Case 1: 当前节点为 data-zero-enter 时, 需要将其修正为前节点末尾
// content\n[cursor] => content[cursor]\n
const isEnterZero = isEnterZeroNode(node);
if (isEnterZero && offset) {
leafOffset = Math.max(leafOffset - 1, 0);
return new Point(lineIndex, leafOffset);
}
<div id="$1" contenteditable style="outline: 1px solid #aaa">1234567890</div>
<script>
const text = $1.firstChild;
class Range {
constructor(start, end, isBackward) {
[this.start, this.end] = start > end ? [end, start] : [start, end];
this.isBackward = isBackward;
this.isCollapsed = false;
if (start === end) {
this.isCollapsed = true;
this.isBackward = false;
}
}
}
let range = null;
document.addEventListener("selectionchange", () => {
const selection = window.getSelection();
if (selection.anchorNode !== text || selection.focusNode !== text || selection.rangeCount <= 0) {
return;
}
const sel = selection.getRangeAt(0);
range = new Range(sel.startOffset, sel.endOffset, selection.anchorOffset !== sel.startOffset);
console.log("Range :>> ", range);
});
$1.addEventListener("keydown", (event) => {
const leftArrow = event.key === "ArrowLeft";
const rightArrow = event.key === "ArrowRight";
if (leftArrow || (rightArrow && range)) {
event.preventDefault();
const focus = range.isBackward ? range.start : range.end;
const anchor = range.isBackward ? range.end : range.start;
let newRange = null;
if (!range.isCollapsed && !event.shiftKey) {
newRange = leftArrow
? new Range(range.start, range.start, false)
: new Range(range.end, range.end, true);
}
if (leftArrow && !newRange) {
const newFocus = Math.max(0, focus - 1);
const isBackward = event.shiftKey && range.isCollapsed ? true : range.isBackward;
const newAnchor = event.shiftKey ? anchor : newFocus;
newRange = new Range(newAnchor, newFocus, isBackward);
}
if (rightArrow && !newRange) {
const newFocus = Math.min(text.length, focus + 1);
const isBackward = event.shiftKey && range.isCollapsed ? false : range.isBackward;
const newAnchor = event.shiftKey ? anchor : newFocus;
newRange = new Range(newAnchor, newFocus, isBackward);
}
if (newRange) {
const sel = window.getSelection();
if (newRange.isBackward) {
sel.setBaseAndExtent(text, newRange.end, text, newRange.start);
} else {
sel.setBaseAndExtent(text, newRange.start, text, newRange.end);
}
}
}
});
</script>
BeforeInput事件完全PreventDefault,则由于浏览器内置Stack永远为空,不会触发historyUndo的InputType。KeyDown事件完全接管undo/redo,则由于非Firefox会即使在非聚焦状态也会触发上次编辑的undo/redo,导致状态不同步。Input事件的historyUndo/historyRedo接管,则由于BeforeInput阻止默认行为,根本不会触发事件,但非聚焦状态的undo会触发。<div id="$1" contenteditable style="outline: 1px solid #aaa;"></div>
<script>
$1.addEventListener("beforeinput", (event) => {
console.log("before input event :>> ", event);
event.preventDefault();
});
$1.addEventListener("input", (event) => {
console.log("input event :>> ", event);
});
</script>
无论是在数据结构还是诸多模块的设计中,我一直都是比较协同相关的基础建设,因此对于History模块自然需要设计协同基础能力。对于History的栈基础设计则不是此处关注的重点,设想一下对于远程的Op我们自然不能由非此Op产生的客户端撤销,即A不应该撤销B的Op,那么在这里需要先回顾一下协同的基本实现,即Delta的transform函数的使用ob1 = transform(a, b)。
// https://quilljs.com/playground/snow
// https://www.npmjs.com/package/quill-delta#transform
const Delta = Quill.imports.delta;
let baseA = new Delta().insert("12");
let baseB = new Delta().insert("12");
const oa = new Delta().retain(2).insert("A");
const ob = new Delta().retain(2).insert("B");
baseA = baseA.compose(oa); // [{insert:"12A"}]
baseB = baseB.compose(ob); // [{insert:"12B"}]
const ob1 = oa.transform(ob, true); // [{retain:3},{insert:"B"}]
const oa1 = ob.transform(oa); // [{retain:2},{insert:"A"}]
baseA = baseA.compose(ob1); // [{insert:"12AB"}]
baseB = baseB.compose(oa1); // [{insert:"12AB"}]
那么对于协同存储的栈内容,我们就需要Delta的invert函数来实现,这个函数需要将previous作为基准来得到inverted,即changes.invert(previous)。如下面的示例中在得到inverted之后,我们此时的undo栈则是存在两个值,如果此时得到了一个undoable的op例如远程操作或者图片的上传完成操作,就需要为栈内的存量数据做变换操作,类似于oa1 = transform(remoteOp, a)将所有的栈内操作全部处理。
// https://www.npmjs.com/package/quill-delta#invert
// https://github.com/slab/quill/blob/main/packages/quill/src/modules/history.ts
const Delta = Quill.imports.delta;
let base = new Delta();
const op1 = new Delta().insert("1");
const op2 = new Delta().retain(1).insert("2");
let invert1 = op1.invert(base); // [{delete:1}]
base = base.compose(op1); // [{insert:"1"}]
let invert2 = op2.invert(base); // [{retain:1},{delete:1}]
base = base.compose(op2); // [{insert:"12"}]
let undoable = new Delta().retain(2).insert("3");
base = base.compose(undoable); // [{insert:"123"}]
invert2 = undoable.transform(invert2, true); // [{retain:1},{delete:1}]
invert1 = undoable.transform(invert1, true); // [{delete:1}]
上述的算法实现其实存在一个问题,我们的undoable op是一直处于原始状态,而实际上由于假设inverted内容会实际应用到base,因此这里的undoable同样也需要做变换。在下面的例子上若不做undoable transform的话,则invert2的结果则是retain: 3, delete: 1,此时的基准是00031000则删除的字符是3,此时明显是错误的,而在做了transform之后是retain: 4, delete: 1则能正确删除1字符。
const Delta = Quill.imports.delta;
let base = new Delta().insert("000000");
const op1 = new Delta().retain(3).insert("1");
const op2 = new Delta().retain(3).insert("2");
let invert1 = op1.invert(base); // [{retain:3},{delete:1}]
base = base.compose(op1); // [{insert:"0001000"}]
let invert2 = op2.invert(base); // [{retain: 3},{delete:1}]
base = base.compose(op2); // [{insert:"00021000"}]
let undoable = new Delta().retain(4).insert("3");
base = base.compose(undoable); // [{insert:"000231000"}]
invert2 = undoable.transform(invert2, true); // [{retain:3},{delete:1}]
undoable = invert2.transform(undoable); // [{retain:3},{insert:"3"}]
invert1 = undoable.transform(invert1, true); // [{retain:4},{delete:1}]
在最开始的时候,我们的状态管理形式是直接全量更新Delta,然后使用EachLine遍历重建所有的状态,并且实际上我们维护了Delta与State两个数据模型。但是这样的模型必然是耗费性能的,每次Apply的时候都需要全量更新文档并且再次遍历分割行状态,当然实际上只是计算迭代的话,实际上是不会太过于耗费性能,但是由于我们每次都是新的对象,那么在更新视图的时候,更容易造成性能的损耗。
那么在后来的设计中,我们实现了一套Immutable Delta+Iterator来处理更新,这种时候我们就可以借助不可变的方式来实现React视图的更新,此时的key是根据WeakMap来实现的对应id值,此时就可以借助key的管理以及React.memo来实现视图的复用。但是这种方式也是有问题的,因为此时我们即使输入简单的内容,也会导致整个行的key发生改变,而此时我们是不必要更新此时的key的。
此外在这种方式中,我们判断LineState是否需要新建则是根据整个行内的所有LeafState来重建的,也就是说这种时候我们是需要再次将所有的op遍历一遍,当然实际上由于最后还需要将compose后的Delta切割为行级别的内容,所以其实这里最少需要遍历两遍。那么此时我们需要思考优化方向,首先是首个retain,在这里我们应该直接完整复用原本的LineState,包括处理后的剩余节点也是如此。而对于中间的节点,我们就需要为其独立设计更新策略。
首先是对于新建的节点,我们直接构建新的LineState即可,删除的节点则不从原本的LineState中放置于新的列表。而对于更新的节点,我们实际上是需要更新原本的LineState对象的,因为我们实际上的行是存在更新的,而重点是我们需要将原本的LineState的key值复用,这里我们方便的实现则是直接以\n的标识为目标的State,即如果在123|312\n的|位置插入\n的话,那么我们就是123是新的LineState,312是原本的LineState,这样我们就可以实现key的复用。
本质上富文本编辑器是图文混排的编辑器,在通过Void/Embed来实现图片的时候,我发现如果点击图片节点并不能触发DOM选区的变化,而由于DOM选区本身不变化则会导致我们的Model选区不会跟随变动,因此诸如焦点和选择等问题就会出现。
<div contenteditable>
<div><span>123</span></div>
<div><span><img src="https://windrunnermax.github.io/DocEditor/favicon.ico" /></span></div>
<div><span>123</span></div>
</div>
<script>
document.addEventListener("selectionchange", function() {
console.log(window.getSelection());
});
</script>
在Slate中的实现是当触发OnClick事件时,会主动调用ReactEditor.toSlateNode方法查找data-slate-node对应的DOM节点,然后通过ELEMENT_TO_NODE查找对应的Slate Node节点,再通过ReactEditor.findPath来获取其对应的Path节点,如果此时两个基点都是Void则会创建range,然后最终设置最新的DOM。
// https://github.com/ianstormtaylor/slate/blob/f2e211/packages/slate-react/src/components/editable.tsx#L1153
const node = ReactEditor.toSlateNode(editor, event.target)
const path = ReactEditor.findPath(editor, node)
const start = Editor.start(editor, path)
const end = Editor.end(editor, path)
const startVoid = Editor.void(editor, { at: start })
const endVoid = Editor.void(editor, { at: end })
if (
startVoid &&
endVoid &&
Path.equals(startVoid[1], endVoid[1])
) {
const range = Editor.range(editor, start)
Transforms.select(editor, range)
}
在当前的编辑器实现中,由于我们的设计是通过Void节点作为高阶组件来实现,因此在这里可以直接借助onMouseDown事件来实现选区的设置即可。而在这里的选区又出现了问题,此处的节点状态是 \n,此处实际上会被分为三个位置,而我们实际上的Void只应该在第二个位置,而这个位置实际上也应该被认为是行首,因为在按键盘左右键的时候也需要用到。
const onMouseDown = () => {
const el = ref.current;
if (!el) return void 0;
const leafNode = el.closest(`[${LEAF_KEY}]`) as HTMLElement | null;
const leafState = editor.model.getLeafState(leafNode);
if (leafState) {
const point = new Point(leafState.parent.index, leafState.offset + leafState.length);
const range = new Range(point, point.clone());
editor.selection.set(range, true);
}
};
// Case 2: 光标位于 data-zero-void 节点前时, 需要将其修正为节点末
// [cursor][void]\n => [void][cursor]\n
const isVoidZero = isVoidZeroNode(node);
if (isVoidZero && offset === 0) {
return new Point(lineIndex, 1);
}
const firstLeaf = lineState.getLeaf(0);
const isBlockVoid = firstLeaf && firstLeaf.block && firstLeaf.void;
const isFocusLineStart = focus.offset === 0 || (isBlockVoid && focus.offset === 1);
在编辑器场景中,节点的选中状态是非常常见的功能,例如在当点击图片节点时,通常需要为图片节点添加选中状态,当前我思考了两种实现方式,分别是使用React Context和内建的事件管理来实现,React Context是在最外层维护选区的useState状态,而内建事件管理则是监听编辑器内部的selection change事件来处理回调。
Slate是使用Context来实现的,在每个ElementComponent节点的外层都会有SelectedContext来管理选中状态,当选区状态变化时则会重新执行render函数。这样的方式实现起来方便,只需要预设Hooks就可以直接在渲染后的组件中获取到选中状态,但是这样的方式需要在最外层将selection状态传递到子组件当中。
// https://github.com/ianstormtaylor/slate/blob/f2e2117/packages/slate-react/src/hooks/use-children.tsx#L64
const sel = selection && Range.intersection(range, selection)
children.push(
<SelectedContext.Provider key={`provider-${key.id}`} value={!!sel}>
<ElementComponent
decorations={ds}
element={n}
key={key.id}
renderElement={renderElement}
renderPlaceholder={renderPlaceholder}
renderLeaf={renderLeaf}
selection={sel}
/>
</SelectedContext.Provider>
)
在这里我们使用的方式则是管理编辑器事件来管理选区,因为在我们的插件里是实例化后调用方法来完成视图渲染的调度,那么在这里我们就实现继承于EditorPlugin的类以及选区高阶组件,在实例中监听编辑器的选区变化,用以触发高阶组件的状态变化,而高阶组件的选择状态则可以直接根据leaf的位置与当前选区的位置来判断。
export abstract class SelectionPlugin extends EditorPlugin {
protected idToView: Map<string, SelectionHOC>;
public mountView(id: string, view: SelectionHOC) {
this.idToView.set(id, view);
}
public unmountView(id: string) {
this.idToView.delete(id);
}
public onSelectionChange = (e: SelectionChangeEvent) => {
const current = e.current;
this.idToView.forEach(view => {
view.onSelectionChange(current);
});
};
}
export class SelectionHOC extends React.PureComponent<Props, State> {
public onSelectionChange(range: Range | null) {
const nextState = range ? isLeafRangeIntersect(this.props.leaf, range) : false;
if (this.state.selected !== nextState) {
this.setState({ selected: nextState });
}
}
public render() {
const selected = this.state.selected;
if (this.props.selection.readonly) {
return this.props.children;
}
return (
<div className={cs(this.props.className, selected && "doc-block-selected")}>
{React.Children.map(this.props.children, child => {
if (React.isValidElement(child)) {
const { props } = child;
return React.cloneElement(child, { ...props, selected: selected });
} else {
return child;
}
})}
</div>
);
}
}
在我们的设计中DOM结构是完整对应数据结构的,在Void结构中本体的空节点会被渲染为ZeroWidth的Text节点以及延续的嵌入节点,整体节点渲染如下所示。
<div data-node="true" dir="auto">
<span data-leaf="true" class="">
<span data-zero-space="true" data-zero-void="true" style="width: 0px; height: 0px; color: transparent; position: absolute;"></span>
<span class="editor-image-void" contenteditable="false" data-void="true" style="user-select: none;">
<img src="https://windrunnermax.github.io/DocEditor/favicon.ico" width="200" height="200">
</span>
</span>
<span data-leaf="true">
<span data-zero-space="true" data-zero-enter="true" style="width: 0px; height: 0px; color: transparent; position: absolute;"></span>
</span>
</div>
然而这个实现在移动光标的时候会出现问题,如果此时光标在Void节点时按下方向键时会导致光标无法移动,因为此时选区会移动到回车零宽字符的末尾,而由于我们的选区校正会将其又校正回Void节点的零宽字符后,这就导致了光标无法移动的问题。因此这里需要主动控制选区的移动,在Void节点上绑定键盘事件,按上下方向键时受控处理。
const sel = editor.selection.get();
if (sel && sel.isCollapsed && Point.isEqual(sel.start, range.end)) {
switch (e.keyCode) {
case KEY_CODE.DOWN: {
e.preventDefault();
const nextLine = leafState.parent.next();
if (!nextLine) break;
const point = new Point(nextLine.index, nextLine.length - 1);
editor.selection.set(new Range(point, point.clone()), true);
break;
}
case KEY_CODE.UP: {
e.preventDefault();
const prevLine = leafState.parent.prev();
if (!prevLine) break;
const point = new Point(prevLine.index, prevLine.length - 1);
editor.selection.set(new Range(point, point.clone()), true);
break;
}
}
}
如果光标此时在Void节点时,此时按下任何输入键则会导致节点内容变成inline-block的形式,这里的问题是BlockVoid节点应该是独占一行的,而输入内容之后,则实际的状态变成了如下内容。
[Zero][input]\n
因此在这里最简单的方案则是此时如果光标在Void节点时按下输入键则直接阻止默认行为,如果输入内容则不会触发insert具体的文本,这个行为跟Slate的表现是一致的。
const indexOp = pickOpAtRange(editor, sel);
if (editor.schema.isVoid(indexOp)) {
return void 0;
}
但是在这里我们还需要处理中文输入的情况,因为beforeinput事件是不能够实际阻止IME的行为的,而此时我们的内容虽然没有办法输入进去,但是选区发生了变化,还会导致我们的toDOMRange方法出现了问题,选区此时会被重置为null,因此我们需要在选区从DOM到Modal时重新为其校正。
// Case 3: 光标位于 data-zero-void 节点唤起 IME 输入, 修正为节点末
// [ xxx[cursor]]\n => [ [cursor]xxx]\n
const isVoidZero = isVoidZeroNode(node);
if (isVoidZero && offset !== 1) {
return new Point(lineIndex, 1);
}
对编辑器而言数据结构的设计非常重要,我在最开始就是希望实现blocks化的编辑器,因此在基本包的设计中并没有设计块级的嵌套结构,而是更专注于基本图文混排的富文本能力。而在基本的块结构上实现Blocks能力,目前我能设想到两种设计,第一种是类似于slate的嵌套数据结构,即通过多层的children来组织所有的块结构,而leaf节点中的每个delta都是行结构内容。
{
children: [
{ delta: Delta(), attributes: {} },
{ delta: Delta(), attributes: {} }
]
}
第二种方式则是借助delta本身来管理块结构,也就是说块的引用是借助op节点来完成,同样的每个id指向的block我们依然只维护行级别的内容。其实也就是相当于我们需要设计一个独立的L(list)类型的block,以此来按行管理所有的子级blocks,此外例如表格的表达,则同样需要需要独立的G(group)来组织非受控的单元格blocks,其并不参与实际渲染而仅作为桥接内容。
({
xxx: {
ops: [{ insert: "abc" }]
},
yyy: {
ops: [{ insert: "123" }]
},
ROOT: {
ops: [
{ insert: " ", attributes: { blockId: "xxx" } },
{ insert: " ", attributes: { blockId: "yyy" } },
]}
})
在思考这个问题的时候,我想到先前在飞书开放平台的服务端的读取文档内容接口,其内容的设计是基于block的嵌套结构设计,类似于我们上述的第一种方案,只不过粒度会更细一些,预测是将文本的内容也做了转换。但是问题是之前看过飞书的协同算法是easy-sync来实现的,而easy-sync是针对于文本的扁平结构实现的协同算法,那么是如何用扁平结构的协同算法实现的嵌套结构协同。
理论上这种方式并不容易实现,而恰好我又想起来了ot-json的协同算法,因此直接在飞书的文档代码中搜索了一下ot-json的硬编码字符串,果然存在相关内容,因此我猜测整个协同的实现则是ot-json0来实现结构协同,easy-sync来实现文本协同,而不直接使用ot-json0的text-type来实现文本协同,则是因为json0仅实现了纯文本的协同,没有携带attrs相关的数据,如果需要组合来维护线性结构的富文本则还有些成本在内。
实际上我个人觉得嵌套的数据结构是比较难以处理的,以list + quote格式为例,在嵌套结构中 list嵌套quote / quote嵌套list 的表现是不一致的,类似的内容需要特殊处理,而对于扁平的结构则无论怎么套list + quote都是在op独立的attributes中,无论先后都不会有差异。因此我还是比较希望使用第二种方法来实现blocks,这种实现则不需要ot-json的介入,仅需要rich-text/easy-sync类型的协同数据基类即可,这样对于数据的操作类型则简单很多,但是在数据可读性上就稍微弱了一些,而实现的协同算法对于数据结构则极为依赖,因此这里对于方案的选用还是需要再考虑一下。
关注协同算法的实现即使对于非协同的场景也是非常重要的,对于我们的纯本地内容而言,假设此时我们需要上传图片则由于上传的过程是异步的,我们就需要在上传中加一个loading状态,而在上传完成之后则需要将src的位置替换为正式的url,初始的src则可以是blob的临时url。那么在这个过程中我们就需要blob -> http的这个状态作为undoable的操作,否则就会导致undo的时候会回退到loading的暂态。而如果在不实现协同依赖的transform操作变换的情况下,则通常不会记录invert2即可,下面的内容也是可以实现的。
const Delta = Quill.imports.delta;
let base = new Delta();
const op1 = new Delta().insert(" ", { src: "blob" });
const invert1 = op1.invert(base); // { delete: 1 }
base = base.compose(op1); // { insert: " ", attributes: { src: "blob" } }
const undoable = new Delta().retain(1, { src: "http" });
base = base.compose(undoable); // { insert: " ", attributes: { src: "http" } }
base = base.compose(invert1); // []
在通常情况下这里并没有什么问题,而invert的数据中如果存在attributes则可能出现问题,在下面的例子中,假如我们不进行undoable.transform的操作,则会导致最终的结果是src: mock,但是别忘了我们的undoable是src: http,这里的http是不应该被替换的,因此这里的transform操作是非常重要的,当我们依照先前的History协同基础设计上将其做操作变换,然后再进行compose应用inverted结果,就可以得到正确的src: http属性。
const Delta = Quill.imports.delta;
let base = new Delta().insert(" ", { src: "mock" });
const op1 = new Delta().retain(1, { src: "blob" });
let invert1 = op1.invert(base); // { retain: 1, attrs: { src: "mock" } }
base = base.compose(op1); // { insert: " ", attributes: { src: "blob" } }
const undoable = new Delta().retain(1, { src: "http" });
base = base.compose(undoable); // { insert: " ", attributes: { src: "http" } }
invert1 = undoable.transform(invert1, true); // []
base = base.compose(invert1); // { insert: " ", attrs: { src: "http" } }
我们使用零宽字符的主要目的是为了放置光标,而目前我们的视图渲染是完全对等于数据结构的,也就是说我们的行末必然存在一个零宽字符,用来对等数据结构中末尾的\n对应的Leaf节点,实现这个节点的目的主要有几个方面。
LineState数据对齐,每个LeafState都必然渲染一个DOM节点,数据模型友好,且这样就可以在空行时必然会留存有文本节点,而不必要特殊处理。Mention节点的渲染,如果行的最后一个节点是Void节点,则会导致光标无法放置于末尾,这个问题的处理我们则按需渲染一个零宽字符节点即可,slate即如此处理的末尾Mention节点。Lark的编辑器时发现每个文本行末尾必然会存在零宽字符,预计是为了解决Blocks的相关问题,请教了大佬还得知早起的etherpad每行也是实现了零宽字符,用来处理DOM与选区的相关问题。在这里我们实现了太多的兼容方案来处理这个问题,例如上边的选区校正部分以及Void选区变换部分内容,而如果实际上我们不渲染这个节点的话就不需要处理这两处相关问题,但是这样的话我们就需要处理其他的case来保证DOM与Model的对等性。那么此时我们需要解决空行的选区问题,此时如果直接使用空节点即<span></span>设置为子节点的话,则会由于此时并没有实际的文本内容,因此这里的高度并没有撑起来并且选区是无法聚焦到此处的,因此这里我们还是需要空节点的内容为零宽字符,这样的话就可以实现选区的聚焦。
const nodes: JSX.Element[] = [];
leaves.forEach((leaf, index) => {
if (leaf.eol) {
// COMPAT: 空行则仅存在一个 Leaf, 此时需要渲染空的占位节点
!index && nodes.push(<EOLModel key={EOL} editor={editor} leafState={leaf} />);
return void 0;
}
nodes.push(<LeafModel key={index} editor={editor} index={index} leafState={leaf} />);
});
return nodes;
实际上末尾的节点如果是<br />节点的话,是可以不需要零宽字符来解决这个问题的,选区节点是可以放置于此节点上的,且不会有0/1两个offset的偏移需要处理,quill对于空行就是如此处理的。不过对于我们来说,对于Void节点是需要处理零宽字符,因为BR节点仅存在0 offset,这就导致了选区在Void节点时依赖默认行为无法正常删除当前节点还需要特殊处理,此外监听Arrow方向键的处理还是需要处理的,大佬说<br />节点还可能存在卡断IME的情况,所以当前还是保持了现状。
export const getTextNode = (node: Node | null): Text | null => {
if (isDOMText(node)) {
return node;
}
if (isDOMElement(node)) {
const textNode = node.childNodes[0];
if (textNode && (isDOMText(textNode) || isBRNode(textNode))) {
return textNode;
}
}
return null;
};
行末的零宽字符还有个比较重要的应用,如果我们的选区操作是从上一行的末尾选到下一行的部分内容时,通过Selection得到的选区变换的Model是跨越两行的。此时如果做一些操作例如TAB缩进的话,是会对多行应用的操作,然而我们的淡蓝色选区看起来只有一行,因此看起来会像是个BUG,主要还是视觉上与实际操作上的不一致。
在腾讯文档、谷歌文档等类似的Canvas实现的编辑器中,这个问题是通过额外绘制了淡蓝色的选区来解决的。而我们如果通过DOM来实现的话,则不能直接绘制内容,这样我们就可以使用零宽字符来实现,即在行末添加一个零宽字符节点,这其中实现的重点是,而当我们选区在零宽字符后时,主动将其修正为零宽字符前。这个实现在Chrome上表现良好,但是在FireFox上就没有效果了。
<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>
<script>
document.addEventListener("selectionchange", () => {
const selection = window.getSelection();
if (selection.rangeCount < 1) return;
const staticSel = selection.getRangeAt(0);
const { startContainer, endContainer, startOffset, endOffset, collapsed } = staticSel;
if (startContainer?.textContent === "\u200B" && startOffset > 0) {
selection.setBaseAndExtent( startContainer, 0, endContainer, collapsed ? 0 : endOffset);
}
});
</script>
我们的Embed节点实际上应该是InlineVoid节点,但是因为组件名太长所以就起了别名为Embed,而在具体实现的时候遇到了太多了的问题,我只得感慨纸上得来终觉浅,绝知此事要躬行。在之前我一直都是在使用富文本引擎来实现应用层的功能,而虽然我也基本阅读过slate的代码并且提过了一些pr来解决一些问题,但在真正想对照实现的时候发现问题实在是太多。
现在的问题是我们是借助浏览器本身的contenteditable来绘制的光标位置,而不是采用自绘选区的方式,这样就导致我们必须要依赖浏览器本身对于选区的实现。而此时如果我们是实现InlineVoid节点且行内只有该节点时,就会导致光标无法放在周围的位置上,这跟文本内容的表现是不一致的。在下面的例子中,中间的行是做不到单击时光标落在节点末尾的,虽然可以通过双击或者方向键来实现,但是此时的节点并不是在文本节点上的,与我们的选区设计不符。
<div contenteditable style="outline: none">
<div data-node><span data-leaf><span>123</span></span></div>
<div data-node>
<span data-leaf><span contenteditable="false">321</span></span>
</div>
<div data-node><span data-leaf><span>123</span></span></div>
</div>
对于这个问题的解决,无论是quill、slate都是在Embed节点的两侧添加了零宽字符用以放置光标,当然这仅仅是当Embed节点左右没有文本节点时的情况,如果两侧有文本则不需要这样的特殊处理。那么如果我们此时如果按照slate的设计,即此时存在三个可选位置可以放置光标作为选区,即Embed本身以及左右光标CARET,那么此时就会存在三个零宽字符位置。
<span data-zero-enter> </span>
<span data-zero-embed> </span>
<span data-zero-enter> </span>
而且在slate中的数据结构是经过normalize之后与数据结构格式严格对应的,也就是说在上边这个例子中,slate的行结构内容将会类似下面的内容,其实这样也就很容易理解为什么类似于图片这种Void节点也必须要存在children结构了,因为其存在的零宽字符必须要完整对应数据结构,且由于其计算时会真正计算为零宽,光标落点也在零宽字符节点上,这样则可以保证选区只会在零宽节点的0 offset上。
[
{ text: "" },
{ type: "embed", children: [{ text: "" }] },
{ text: "" }
]
但是有个重要的问题是,零宽字符是存在两个光标位置的,即0|1两个offset,那么此时我们就存在了4个光标位置|||,而此时我们的Model在此时只有两个节点即 \n,那么即使我们不允许光标放在\n后,也才能勉强对应上三个光标位置,而这里最重要的是我们前边说的一个零宽字符存在两个offset,那么对于toDOMPoint时同一个光标位置的处理就会存在两个情况。
<span data-zero-enter> |</span>
<span data-zero-embed>| </span>
<span data-zero-enter> </span>
而我们最开始设计toDOMPoint的时候优先将光标放置于前一个节点的末尾,那么我们点击行末尾的时候,此时的光标会位于zero-enter节点后,那么此时的offset是2,而此时由于我们的选区校正存在,这里的offset会被校正为1。那么此时我们的选区则会被校正为第一个data-zero-enter节点,且offset的值为1,那么此时我们本应该希望放置于最后一个data-zero-enter节点上的光标,现在却被校正到首个节点上了。
<span data-zero-enter> |</span>
<span data-zero-embed> </span>
<span data-zero-enter> </span>
那么假设我们最开始不将offset的2值校正为1的话,此时我们的选区则会被设置到data-zero-embed节点的offset -> 1位置上,依然不是我们希望的光标位置,因此这里依然不是正确的选区位置,这样依然需要存在额外校正的情况。
<span data-zero-enter> </span>
<span data-zero-embed> |</span>
<span data-zero-enter> </span>
那么这里如果改造起来可能存在不少问题,主要是这里存在了两个节点的突变,那么我们如果换个思路减少零宽字符的节点,即去掉第一个零宽字符节点。那么我们这样就无法保持三个选区状态了,而如果希望zero-embed的0 offset为左光标,offset 1为嵌入内容的选中效果则是不容易实现的,因为光标本身不能是左出现右消失的状态,这里需要样式的额外处理才可以。
那么根据上述的问题,data-zero-embed节点则会是我们要处理的左光标,右光标则依然是data-zero-enter节点,左光标的处理则需要让右侧的Embed内容存在margin样式。那么在默认情况下,此时我们在校正\n节点后的选区offset为1,默认的选区位置则如下所示。
<span data-zero-embed> |</span>
<span data-zero-enter> </span>
那么这里依然会有问题,我们点击行末尾时,此时的选区则会被浏览器转移到节点光标左侧的位置上,这里的效果显然是不合适的。因为这样的话我们无法聚焦到行末,因此这里我们需要为toDOMPoint设置额外的处理逻辑,即取消了默认的offset优先逻辑,而是采用node优先逻辑。在data-zero-embed节点且offset为1时,优先聚焦到后续的data-zero-enter节点offset -> 0上。
const nodeOffset = Math.max(offset - start, 0);
const nextLeaf = leaves[i + 1];
// CASE1: 对同个光标位置, 且存在两个节点相邻时, 实际上是存在两种表达
// 即 <s>1|</s><s>1</s> / <s>1</s><s>|1</s>
// 当前计算方法的默认行为是 1, 而 Embed 节点在末尾时则需要额外的零宽字符放置光标
// 如果当前节点是 Embed 节点, 并且 offset 为 1, 并且存在下一个节点时
// 需要将焦点转移到下一个节点, 并且 offset 为 0
if (
leaf.hasAttribute(ZERO_EMBED_KEY) &&
nodeOffset === 1 &&
nextLeaf &&
nextLeaf.hasAttribute(ZERO_SPACE_KEY)
) {
return { node: nextLeaf, offset: 0 };
}
这里实际上还存在问题,如果后续的节点是文本节点,会依然导致无法放置光标,因此这里实际上只需要判断nextLeaf存在即需要移动选区位置。而此时依然存在问题,因为同节点会存在两个offset位置,所以此时我们还是需要先校正toModelPoint的位置,即如果光标位于data-zero-embed节点后时, 需要将其修正为节点前。
// Case 4: 光标位于 data-zero-embed 节点后时, 需要将其修正为节点前
// 若不校正会携带 DOM-Point CASE1 的零选区位置, 按下左键无法正常移动光标
// [embed[cursor]]\n => [[cursor]embed]\n
const isEmbedZero = isEmbedZeroNode(node);
if (isEmbedZero && offset) {
return new Point(lineIndex, leafOffset - 1);
}
那么此时又会出现新的问题,当光标位于节点左时,会导致我们按右键无法移动光标,因为此时的选区会被上述的逻辑校正回原本的位置,因此我们依然需要在onKeyDown事件中受控处理这个问题,当选区位于data-zero-embed节点时,按下右键则主动调整选区。
const sel = getStaticSelection();
if (rightArrow && sel && isEmbedZeroNode(sel.startContainer)) {
event.preventDefault();
const newFocus = new Point(focus.line, focus.offset + 1);
const isBackward = event.shiftKey && range.isCollapsed ? false : range.isBackward;
const newAnchor = event.shiftKey ? anchor : newFocus.clone();
this.set(new Range(newAnchor, newFocus, isBackward), true);
return void 0;
}
虽然看起来问题都解决了,但是DOM的normalize频繁操作却带来了一个隐晦的问题,当行中仅存在Embed节点时,我们无法用鼠标拖拽选区来选中该节点,甚至这个操作会触发我们预设的选区limit错误,而且在控制台的选区变换打印非常频繁。
这个问题应该算是受控的选区和非受控的拖选造成的问题,我们无法控制浏览器的拖选,那么就只能尽可能减少受控的行为。因此这里我们需要避免在用户拖拽的时候主动设置选区,以避免打断拖选的行为,同时还需要注意输入的情况下即使按下鼠标但仍然需要更新DOM选区,且在鼠标松开时再校准一次。
// 按下鼠标的情况下不更新选区, 而 force 的情况则例外
// 若总是更新选区, 则会导致独行的 Embed 节点无法选中, 需要非受控
// 若是没有 force 的调度控制, 则在按下鼠标且输入时会导致选区 DOM 滞留
if (!force && this.editor.state.get(EDITOR_STATE.MOUSE_DOWN)) {
return false;
}
在实现Embed节点时,我是将内置的零宽字符0/1两个位置作为光标的放置位置,而offset 1的位置会被实际移动到后一个节点的offset 0上,那么实际上如果此时我们仅使用该偏移方案而不校正ModelPoint的话,理论上而言是可行的,然而在实际的操作中,我们发现如果两个选区节点之间不连续的话,按左键会导致选区从node2 offset 0移动到node1 offset 1的位置,而如果连续的话则是会正常移动到node1 offset len-1的位置。
<div contenteditable style="outline: none">
<div><span id="$1">123</span><span contenteditable="false">Embed</span><span id="$2">456</span></div>
<div><span id="$3">123</span><span id="$4">456</span></div>
</div>
<div>
<button id="$5">Embed</button>
<button id="$6">Span</button>
</div>
<script>
const sel = window.getSelection();
document.addEventListener("selectionchange", () => {
console.log("selection", sel?.anchorNode.parentElement, sel?.focusOffset);
});
$5.onclick = () => {
const text = $2.firstChild;
sel.setBaseAndExtent(text, 0, text, 0);
};
$6.onclick = () => {
const text = $4.firstChild;
sel.setBaseAndExtent(text, 0, text, 0);
};
</script>
在这个例子中按Embed按钮后再按左键选区变换的offset会得到3,而使用Span按钮后则会得到2。而如果直接将零宽字符节点放到Embed节点后的话虽然可以解决这个问题,但是这样就无法将光标放置于Emebd节点前了,此时这就需要在最前边再放一个零宽字符,这样额外的交互处理更是麻烦,且在slate我还提过零宽字符打断中文IME的输入问题PR。
其实这里的选区映射也有个有趣的问题,光标位于data-zero-embed节点后时, 需要将其修正为节点前,那么此时我们按右键选区会被这段toModelPoint中的逻辑重新映射回原本的位置,即L => L并没有变化,那么也就无法触发Model Sel Change,而DOM选区则会从offset 1重新被force校正为0。那么如果我们在按下右键主动调整选区的话,则会先出发Model Sel Change进而UpdateDOM,然后再由DOM Sel Change来校正选区,因为这时候选区不在Embed零宽字符上了,就不会命中校正逻辑,因而可以正常进行选区的移动。
// CASE2: 当 Embed 元素前存在内容且光标位于节点末时, 需要校正到 Embed 节点上
// <s>1|</s><e> </e> => <s>1</s><e>| </e>
if (nodeOffset === len && nextLeaf && nextLeaf.hasAttribute(ZERO_EMBED_KEY)) {
return { node: nextLeaf, offset: 0 };
}
// [[cursor]embed]\n => right => [embed[cursor]]\n => [[cursor]embed]\n
// SET(1) => [embed[cursor]]\n => [embed][[cursor]\n] => SET(1) => EQUAL
当前我们的选区实现是L-O的实现,也就是Line与Offset索引级别的实现,而这里的Offset是会跨越多个实际的LeafState节点的,那么这里的Offset就会导致我们在实现选区查找的时候需要额外的迭代,也就是在通过Range来获取实际DOM节点的toDOMPoint,是需要从lineNode为基准查找文本节点。
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 };
而在先前我们处理Embed节点的时候其实能够很明显地发现由于此时我们需要按行查找内容,那么实际要处理的文本前存在的零宽字符都会被记入偏移序列,这样就让我们需要处理大量Case来适配,每次都迭代一遍Leaf的Offset来查找也并不太现实。但其实话又说回来了,这样做的实际上就很像是slate的数据结构了,只不过我们将其简化为3级,而不是像slate一样可以无限层即嵌套下去。
export class Point {
constructor(
/** 行索引 */
public line: number,
/** 节点索引 */
public index: number,
/** 节点内偏移 */
public offset: number
) {}
}
在我们的Mutate的设计中,在行样式的处理上我们是完全遵循着delta的数据结构设计,即最后的EOL节点才承载行样式。那么这样会造成一个比较反直觉的问题,如果我们直接在行中间插入\n的话,原本的行样式是会处于下一行的,因为本质上是因为EOL节点是在末尾的,此时插入\n自然原本的EOL是会直接跟随到下一行的。
那么对于这个问题的解决我设想了几种方案,首先是这个问题本质上是由于\n太滞后了导致了,而如果我们将承载行内容的节点前提,也就是在行首加入SOL-Start Of Line节点,由该节点来承载样式,\n节点仅用于分割行,那么在执行Mutate Insert的时候自然就能很轻松地得到将行样式保留在上一行,而不是跟随到下一行。但是这种方式很明显会因为破坏了原本的数据结构,因此导致整个状态管理发生新的问题,需要很多额外的Case来处理这个不需要渲染的节点所带来的问题。
还有一种方案是在Mutate Iterator对象中加入used标记,当插入的节点为\n时会检查当前的存量LineState是否被复用过,如果没有被复用过的话就直接将该State的key、attrs全部复用过来,当后续的\n节点再读区时则会因为已经复用过导致无法再复用,此时就是完全重新创建的新状态。但是这里的问题是无法很好地保证第二个\n的实际值,也就是说破坏了我们原本的模型结构,其并不是交换式的,也无法将确定的新值传递到第二个\n上,而且在Mutate Compose的过程中做这件事是会导致真的需要实现这种效果时无法规避这个行为。
实际上Quill则是会存在同样的问题,我发现其如果直接执行插入\n的话也是会将样式跟随到下一行,那么其实这样就意味着其行样式继承是在Enter Event的事件处理的,设想了一下这种方式的处理是合理的,这种情况下我们就可以是完全受控的情况处理,即使在插件中实现的话也是没问题的,只不过这里需要将这个行为明确化,也可以封装一下这个行为的处理方案。
// https://quilljs.com/playground/snow
quill.updateContents([{ retain: 3 }, { insert: "\n" }]);
那么在这里就需要区分多种情况,此时插入回车的时候可以携带attributes。那么如果是在行首,就将当前属性全部带入下一行,这实际上就是默认的行为。如果此时光标在末尾插入回车,则需要将下一行的属性全部清空,当然此时也需要合并传入的属性。如果在行中间插入属性,则需要拷贝当前行属性放置于当前插入的新行属性,而如果此时存在传入的属性则同样需要合并。
// |xx(\n {y:1}) => (\n)xx(\n {y:1})
// xx|(\n {y:1}) => xx(\n {y:1})(\n)
// xx|(\n {y:1}) => xx(\n {y:1})(\n & attributes)
// x|x(\n {y:1}) => x(\n {y:1})x(\n {y:1})
// x|x(\n {y:1}) => x(\n {y:1})x(\n {y:1 & attributes})
对于块结构的更新,如果是JSON嵌套结构的组织方式,对于块结构的变更如果直接参考OT-JSON的数据结构变更是不会有什么问题的,但是这样就就会导致我们的数据结构变得复杂,那么这样我们就失去了最开始使用Delta作为扁平数据结构所带来的优势,此时Mutate的设计同样也会变得复杂。那么如果我们将数据结构扁平化结构,即Map<id, Delta>的设计,此时我们的整体设计就可以完整复用,以引用的方式组织块嵌套也会清晰很多。
这种方式对于文档的变更描述也是比较清晰的,块结构的变更使用Delta来描述是完全没问题的,选区的描述则同样可以基于块碰撞与文本选区的实现来完成,选区的块顺序与基点描述则可以使用数组来表达。然而在文档内容变更的时候存在一个比较复杂的问题,就是我们无法得知哪些块被删除了,或者说哪些块是不再需要的,不需要再被查找或者渲染,这里的复杂点在于我们的Delta描述中不存在删除块的描述。
检查当前活跃的块实际上是一件比较重要的事情,首先是对于数据的存储,我们通常不会直接将全量的数据存储起来,而是应该按块存储,细粒度的数据对于内容的查找/更新/复用等更加高效。基于这种设计的话,如果我们能够准确收集活跃的块,则可以在读取的时候避免不必要的数据处理。再者则是对于数据的搜索,如果某个块被认为是不活跃的,那么这个内容不应该被搜索到。
那么如果需要确定当前活跃的块,重要的点就是在变更内容的时候确定块结构的活跃状态变化,而我们是通过Delta描述变更,那么我们就不能得知究竟哪些块被删除了。而且本身我们的描述语言中也不存在删除块的变更描述,那么我们最好的方式就是记录好我们实际构建的state-tree状态,如果发生内容变更的话,则自动处理块的状态,那么目前我能设想到的几种方式:
Editable的时候,我们需要将此时渲染的context状态传递到组件当中,那么此时我们就可以借助组件的生命周期来记录块的状态,树形结构自然也可以通过parent状态来实现,而children集合理论上应该不实际需要建立。Mutate的时候记录块的状态,也就是我们约定_blockId关键字来记录块状态的变更,或者实现op - id映射关系的模式注册。而我们使用Delta描述变更时是无法得知是哪些op内容实现了插入/删除/更新,这部分实现就涉及到了Mutate/State模块的改造,最好是实现State - Id的相互映射模块,并且实现行的状态的变更来更新映射关系。Block/Line/Leaf状态是完备的,那么我们可以直接借助内建状态来获取当前绑定在各个State状态上的块。这里的映射关系同样需要上述约定或者注册,甚至于我们粒度做的粗一些可以基于LineState来按需收集,无论是Leaf还是Line产生变更的时候都会重新实例化并且计算状态。其实这里将3个方案融合可能是比较好的方法,当产生Op的时候,我们必须要知道究竟是哪些op是新增的或者删除的。那么在单次op应用之后,我们需要解析出此时究竟新增/删除了哪些block,就需要将相关的内容传递到后端。
新增则初始化空值,删除则清理整个block长度的内容,并且在后端检查block结构级别的新增或删除。而在undo的时候,还是做同样的操作,此时的changes还是正向的变更,复用相关逻辑即可。
在LineState节点的key值维护中,如果是初始值则是根据state引用自增的值,在变更的时候则是尽可能地复用原始行的key,这样可以避免过多的行节点重建并且可以控制整行的强刷。
而对于LeafState节点的key值目前是直接使用index值,这样实际上会存在隐性的问题,而如果直接根据Immutable来生成key值的话,任何文本内容的更改都会导致key值改变进而导致DOM节点的频繁重建。
export const NODE_TO_KEY = new WeakMap<Object.Any, Key>();
export class Key {
/** 当前节点 id */
public id: string;
/** 自动递增标识符 */
public static n = 0;
constructor() {
this.id = `${Key.n++}`;
}
/**
* 根据节点获取 id
* @param node
*/
public static getId(node: Object.Any): string {
let key = NODE_TO_KEY.get(node);
if (!key) {
key = new Key();
NODE_TO_KEY.set(node, key);
}
return key.id;
}
}
通常使用index作为key是可行的,然而在一些非受控场景下则会由于原地复用造成渲染问题,diff算法导致的性能问题我们暂时先不考虑。在下面的例子中我们可以看出,每次我们都是从数组顶部删除元素,而实际的input值效果表现出来则是删除了尾部的元素,这就是原地复用的问题。在非受控场景下比较明显,而我们的ContentEditable组件就是一个非受控场景,因此这里的key值需要再考虑一下。
const { useState, Fragment, useRef, useEffect } = React;
function App() {
const ref = useRef<HTMLParagraphElement>(null);
const [nodes, setNodes] = useState(() => Array.from({ length: 10 }, (_, i) => i));
const onClick = () => {
const [_, ...rest] = nodes;
console.log(rest);
setNodes(rest);
};
useEffect(() => {
const el = ref.current;
el && Array.from(el.children).forEach((it, i) => ((it as HTMLInputElement).value = i + ""));
}, []);
return (
<Fragment>
<p ref={ref}>
{nodes.map((_, i) => (<input key={i}></input>))}
</p>
<button onClick={onClick}>slice</button>
</Fragment>
);
}
考虑到先前提到的我们不希望任何文本内容的更改都导致key值改变引发重建,因此就不能直接使用计算的immutable对象引用来处理key值,而描述单个op的方法除了insert就只剩下attributes了。
但是如果基于attributes来获得就需要精准控制合并insert的时候取需要取旧的对象引用,且没有属性的op就不好处理了,因此这里可能只能将其转为字符串处理,但是这样同样不能保持key的完全稳定,因此前值的索引改变就会导致后续的值出现变更。
const prefix = new WeakMap<LineState, Record<string, number>>();
const suffix = new WeakMap<LineState, Record<string, number>>();
const mapToString = (map: Record<string, string>): string => {
return Object.keys(map)
.map(key => `${key}:${map[key]}`)
.join(",");
};
const toKey = (state: LineState, op: Op): string => {
const key = op.attributes ? mapToString(op.attributes) : "";
const prefixMap = prefix.get(state) || {};
prefix.set(state, prefixMap);
const suffixMap = suffix.get(state) || {};
suffix.set(state, suffixMap);
const prefixKey = prefixMap[key] ? prefixMap[key] + 1 : 0;
const suffixKey = suffixMap[key] ? suffixMap[key] + 1 : 0;
prefixMap[key] = prefixKey;
suffixMap[key] = suffixKey;
return `${prefixKey}-${suffixKey}`;
};
在后续观察Lexical实现的选区模型时,发现其是用key值唯一地标识每个叶子结点的,选区也是基于key值来描述的。整体表达上比较类似于Slate的选区结构,或者说是DOM树的结构。这里仅仅是值得Range选区,Lexical实际上还有其他三种选区类型。
{
anchor: { key: "51", offset: 2, type: "text" },
focus: { key: "51", offset: 3, type: "text" }
}
在这里比较重要的是key值变更时的状态保持,因为编辑器的内容实际上是需要编辑的。然而如果做到immutable话,很明显直接根据状态对象的引用来映射key会导致整个编辑器DOM无效的重建。例如调整标题的等级,就由于整个行key的变化导致整行重建。
那么如何尽可能地复用key值就成了需要研究的问题,我们的编辑器行级别的key是被特殊维护的,即实现了immutable以及key值复用。而目前叶子状态的key依赖了index值,因此如果调研Lexical的实现,同样可以将其应用到我们的key值维护中。
通过在playground中调试可以发现,即使我们不能得知其是否为immutable的实现,依然可以发现Lexical的key是以一种偏左的方式维护。因此在我们的编辑器实现中,也可以借助同样的方式,合并直接以左值为准复用,拆分时若以0起始直接复用,起始非0则创建新key。
[123456(key1)][789(bold-key2)]文本,将789的加粗取消,整段文本的key值保持为key1。[123456789(key1)]]文本,将789这段文本加粗,左侧123456文本的key值保持为key1,789则是新的key。[123456789(key1)]]文本,将123这段文本加粗,左侧123文本的key值保持为key1,456789则是新的key。[123456789(key1)]]文本,将456这段文本加粗,左侧123文本的key值保持为key1,456和789分别是新的key。因此,此时在编辑器中我们也是用类似偏左的方式维护key,由于我们需要保持immutable,所以这里的表达实际上是尽可能复用先前的key状态。这里与LineState的key值维护方式类似,都是先创建状态然后更新其key值,当然还有很多细节的地方需要处理。
// 起始与裁剪位置等同 NextOp => Immutable 原地复用 State
if (offset === 0 && op.insert.length <= length) {
return nextLeaf;
}
const newLeaf = new LeafState(retOp, nextLeaf.parent);
// 若 offset 是 0, 则直接复用原始的 key 值
offset === 0 && newLeaf.updateKey(nextLeaf.key);
这里还存在另一个小问题,我们创建LeafState就立即去获得对应的key值,然后再考虑去复用原始的key值。这样其实就会导致很多不再使用的key值被创建,导致每次更新的时候看起来key的数字差值比较大。当然这并不影响整体的功能与性能,只是调试的时候看起来比较怪。
因此我们在这里还可以优化这部分表现,也就是说我们在创建的时候不会去立即创建key值,而是在初始化以及更新的时候再从外部设置其key值。这个实现其实跟index、offset的处理方式比较类似,我们整体在update时处理所有的相关值,且开发模式渲染时进行了严格检查。
// BlockState
public updateLines() {
let offset = 0;
this.lines.forEach((line, index) => {
line.index = index;
line.start = offset;
line.key = line.key || Key.getId(line);
const size = line.isDirty ? line.updateLeaves() : line.length;
offset = offset + size;
});
this.length = offset;
this.size = this.lines.length;
}
// LineState
public updateLeaves() {
let offset = 0;
const ops: Op[] = [];
this.leaves.forEach((leaf, index) => {
ops.push(leaf.op);
leaf.offset = offset;
leaf.parent = this;
leaf.index = index;
offset = offset + leaf.length;
leaf.key = leaf.key || Key.getId(leaf);
});
this._ops = ops;
this.length = offset;
this.isDirty = false;
this.size = this.leaves.length;
}
此外,在实现单元测试时还发现,在leaf上独立维护了key值,那么\n这个特殊的节点自然也会有独立的key值。这种情况下在line级别上维护的key值倒是也可以直接复用\n这个leaf的key值。当然这只是理论上的实现,可能会导致一些意想不到的刷新问题。
在事件绑定的时候,在类中使用箭头函数的方式进行事件绑定能够保证this的正确指向,这种方式编译后是在constructor中将箭头函数直接绑定到实例上。通常情况下这是没有问题的,然而在继承的情况下,如果子类中存在同名的箭头函数虽然可以实现继承,但是由于super调用的时机问题,事件绑定的回调函数实际上是父类的箭头函数,而不是我们希望的子类方法。
因此在继承的情况下,如果在子类中将super的事件绑定函数移除,然后重新绑定事件函数的话,这样是可以保证事件绑定是子类的方法,但是这就必须要非常了解父类的实现才可以。而如果我们使用装饰器来实现事件绑定的话,则可以解决这个问题,但是这里依然需要明确该方法是需要绑定的,否则仍然会因为事件实际被调用的时候没有this指向而导致问题。
// experimentalDecorators: Enable experimental support for TC39 stage 2 draft decorators.
function Bind<T>(_: T, key: string, descriptor: PropertyDescriptor): PropertyDescriptor {
const originalMethod = descriptor.value;
return {
configurable: true,
get() {
const boundFunction = originalMethod.bind(this);
Object.defineProperty(this, key, { value: boundFunction, configurable: true, enumerable: false });
return boundFunction;
},
};
}
class A {
protected value = 1;
constructor(){
document.addEventListener("mousedown", this.c);
document.addEventListener("mousedown", this.d);
}
protected c = () => {
console.log(this.value);
}
@Bind
protected d() {
console.log(this.value);
}
}
class B extends A {
protected value2 = 2;
protected c = () => {
console.log(this.value2);
}
@Bind
protected d() {
console.log(this.value2);
}
}
new B();
Local ChangeSet指的是在本地的变更处理,例如图片上传时的本地预览状态,在没有实际上传到服务器之前,其内容的属性是临时状态。那么对于协同类似这种情况就需要特殊处理:
client-side属性值不会协同,也就是常见的client-side-*属性,对于客户端的属性处理,例如代码块的高亮处理等,类似仅限于本地处理的属性不会实际被协同。insert op而不是client-side属性,因此这种情况下无法直接通过属性状态处理。因此这里我们可以实际将op协同,但是协同到其他客户端仅限于数据,视图上会将其隐藏起来,因此是临时隐藏了块。op是不希望被协同的,而且最终状态是希望将状态合并起来再协同出去。那么最简单的办法就是在本地处理时关闭协同,等到最终状态确定后再开启协同,即i(" ", {src: "blob"}) + r(1, {src: "http"}) = i(" ", {src: "http"})。easysync中调度协同的方法中,提到了AXY的调度模型,可以观察ot.js可视化工具,以此来尽可能保持服务端无状态,避免复杂状态图。而如果需要完整处理本地的变更,则需要扩展Z即本地队列,但由于队列内容已经本地应用,需要实现op在队列前后移动的方法。除了协同之外,还有关于History模块的处理,也同样会存在上述的本地图片预览等状态的处理。
remote op处理,即undoable的操作,相当于将器放置于快照最前方。我们遵循的原则是不能undo其他人的op,因此将其放置于最前方相当于在所有操作被undo后的空白草稿留下的内容。op不会真正发送出去,不需要额外的调度。因此相对需要服务端来调度协同来说,这里的处理可以相对比较自由地合并,类似于下面的形式:
const id1 = state.apply(i(" ", { src: "blob" }));
const id2 = state.apply(r(1, { src: "http" }));
editor.history.merge(id1, id2);
实际上对于最开始聊的case而言,方案1是不适用的。因为执行这个操作的前提是需要有执行这个操作的前提,即上述insert op,仅undo retain op的话是没有意义的,因为在执行undo的时候会将操作的基准删除。因此对于这种情况,我们还是需要将主要的设计放在允许undo栈状态合并上。此外,由于delta的数据结构设计,我们不需要关心实际的顺序造成的问题,只需要compose即可。
我们现在实现的富文本编辑器是没有块结构的,因此实现任何具有嵌套的结构都是个复杂的问题。在这里我们原本就不会处理诸如表格类的嵌套结构,但是例如blockquote这种wrapper级结构我们是需要处理的。类似的结构还有list,但是list我们可以完全自己绘制,但是blockquote这种结构是需要具体组合才可以的。
然而如果仅仅是blockquote还好,在inline节点上使用wrapper是更常见的实现。因为我们将文本分割为bold、italic等inline节点时,会导致DOM节点被实际切割,此时如果嵌套<a>节点的话,就会导致hover的效果出现切割。因此如果能够将其wrapper在同一个<a>标签的话,就不会出现这种问题。
但是新的问题又来了,如果仅仅是单个key来实现渲染时嵌套并不是什么复杂问题,而同时存在多个需要wrapper的key则变成了令人费解的问题。如下面的例子中,如果将34单独合并b,外层再包裹a似乎是合理的,但是将34先包裹a后再合并5的b也是合理的,甚至有没有办法将67一并合并,因为其都存在b标签。
1 2 3 4 5 6 7 8 9 0
a a ab ab b bc b c c c
思来想去,我最终想到了个简单的实现,对于需要wrapper的元素,如果其合并list的key和value全部相同的话,那么就作为同一个值来合并。那么这种情况下就变的简单了很多,我们将其认为是一个组合值,而不是单独的值,在大部分场景下是足够的。
1 2 3 4 5 6 7 8 9 0
a a ab ab b bc b c c c
12 34 5 6 7 890
不过话又说回来,这种wrapper结构是比较特殊的场景下才会需要的,在某些操作例如缩进这个行为中,是无法判断究竟是要缩进引用块还是缩进其中的文字,这个问题在很多开源编辑器中都存在。
其实也就是在没有块结构的情况下,对于类似的行为不好控制,而整体缩进这件事配合list在大型文档中也是很合理的行为,因此这部分实现还是要等我们的块结构编辑器实现才可以。
后续在wrap node实现的a标签来实现输入时,又出现了类似inline-code的脏DOM问题。以下面的DOM结构来看,看似并不会有什么问题,然而当光标放置于超链接这三个字后唤醒IME输入中文时,会发现输入的内容会被放置于直属div下,与a标签平级。
<div contenteditable>
<a href="https://www.baidu.com"><span>超链接</span></a
><span>文本</span>
</div>
<div contenteditable>
<a href="https://www.baidu.com"><span>超链接</span></a
>测试输入<span>文本</span>
</div>
在这种情况下我们先前实现的脏DOM检测就失效了,因为检查脏DOM的实现是基于data-leaf实现的。此时浏览器的输入表现会导致我们无法正确检查到这部分内容,除非直接拿data-node行节点来直接判断,这样的实现自然不够好。
说到这里,先前我发现飞书文档的实现是a标签渲染的leaf,而wrap的包装实现是使用的span直接处理的,并且额外增加了样式来实现hover效果。直接使用span包裹就不会出现上述问题,而内部的a标签虽然会导致同样的问题,但是在leaf下可以触发脏DOM检查。
<div contenteditable>
<span
><a href="https://www.baidu.com"><span>超链接</span></a>测试输入</span
><span>文本</span>
</div>
而本质上类似的行为就是浏览器默认处理的结果,不同的浏览器处理结果可能都不一样。目前看起来是浏览器认为a标签的结构应该是属于inline的实现,也就是类似我们的inline-code实现,理论上倒却是并没有什么问题,由此我们需要自己来处理这些非受控的问题。
quill本身也会出现这个问题,同样也是脏DOM的处理。而slate并不会出现这个问题,这里处理方案则是通过DOM规避了问题,在a标签两端放置额外的 节点,以此来避免这个问题。当然还引入了额外的问题,引入了新的节点,目前看起来转移光标需要受控处理。
<!-- https://github.com/ianstormtaylor/slate/blob/main/site/examples/ts/inlines.tsx -->
<div contenteditable>
<a href="https://www.baidu.com"
><span contenteditable="false" style="font-size: 0"> </span
><span>超链接测试输入</span
><span contenteditable="false" style="font-size: 0"> </span></a
><span>文本</span>
</div>
Mark属性的处理是个相对比较复杂的问题,对于选区内的节点我们的策略是,将选区内所有的标记节点都存在时,才会认为标记是完全应用到了节点上,而纯内容的处理上在这里会存在三种情况:
Mark节点: 例如bold、italic等,这些节点是直接继承先前的内容属性。Inline节点: 例如link、code等,这些节点是需要单独处理的,在尾部写的数据不会继承到后续节点。Mark节点,也就是说此时Mark节点属性是可以临时操作继承的。最近考虑了个问题,以blocks为基础构建的文档例如飞书文档中,每个文本行都是块。在这种情况下,选区的跨行选择就成了比较复杂的问题,先说纯文本块,那其实就是比较常规的类似于LineState的选区模式。
然而如果存在块嵌套时,从文本块选择到代码块内,或者相反的操作,交互会变得非常复杂。以我们现在的选区模式为例,我们希望能以anchorNode和focusNode两个节点就能计算出选区的范围。
还有像是editor.js一样的表现,干脆完全不支持任何节点的跨行选中,主要是针对文本结构,块结构还是支持的。最开始我是考虑到使用Range.cloneContents来完整映射所有的选区内容,也就是说通过浏览器选区计算后,最后的Model选区大概是下面的内容。
[
{ type: "text", start: 1, end: 10, id: "xxx" },
{ type: "block", id: "yyy" },
{ type: "text", start: 0, end: 3, id: "zzz" },
]
简单看了下飞书文档的处理,发现其选区竟然本身是多段式,这里我确实是没太理解其中的原因,可能是直接计算并不会有太大的计算成本。
// PageMain.editor.selectionAPI.getSelection()
[
{ "id": 2, "type": "text", "selection": { "start": 3, "end":6 } },
{ "id": 3, "type": "text", "selection": { "start": 0, "end":4 } },
{ "id": 4, "type": "text", "selection": { "start": 0, "end":3 } }
]
在无法考虑清楚的情况下,还是看下熟悉的slate,以getFragment方法为起点研究了slate的选区到数据的选区实现。这里的逻辑大概是从editor.chidren取得所有的节点,然后从内容中剔除所有不在range的节点,以及文本节点的切割。
因此也能解答出之前我没想好的问题,在slate调用getFragment时如果本身的层级很深,会有保留原始的层级嵌套结构。也就是在调用Node.nodes时,以editor为基点就能构建出完整的path,然后对比path和match函数裁剪即可。
[{
children: [{
children: [{
children: [{
// ...
text: "content"
}]
}]
}]
}]
但是如果以这种方式来处理选区的话,似乎性能并不会太好,当然slate本身的选区是对应了浏览器的anchor和focus节点来确定,只不过是具体使用的时候才会选取节点。
而后又研究了下飞书具体的交互实现,发现其并没有我想象的那么复杂的选区状态管理,总结起来就是:
仅处理同级节点的选区内容,就类似我们的此时实现的LineState状态,处理两个节点的共同父级,这样就可以直接从children的start取到end即可。这样其实还解决了我之前考虑的虚拟滚动时,任意两个节点都的高度都可比较的问题,我们的对比基准应该是同父级的节点,而且比较的结构应该是LineState而不仅仅是BlockState状态树。
说回飞书的这个交互实现,纯文本的节点选区是以text为单位,如果出现跨行的情况则是直接用的浏览器的状态展示。而块级结构的选区是以block为单位,此时的浏览器焦点在body上,也就是说选区的选中实际上是飞书自行管理的状态。如果是从文本选到块还是比较有趣的,文本是浏览器状态,块内的文本则会被selected::selection隐藏掉,而块本身则会被选中,且块内文本的在松手时会全部选中。
如果只是正常的History模块实现,我们之前已经基本设计完成了。但是存在一些特殊的情况,需要合并undo栈的数据。例如图片上传时是个insert,此时处于loading状态,最后当异步图片上传成功后,此时需要应用的是retain attrs修改属性。
那么这种情况下,如果触发ctrl+z的话,会导致上传回到loading状态而不是撤销insert。因此明显这里应该将retain attrs这个op在History模块中合并到insert上,这样就可以保证undo的时候是撤销insert而不是retain attrs。
我们先来实现合并,因为我们这些模块都是分离的,所以没有办法直接跟History模块通信,这里需要改造一下apply,并且将标识写入undo栈。但是仅仅是移除retain的op并且将其合并到insert上是不够的,这里还需要transform的数据处理。
const { id: id1 } = state.apply(new Delta().insert());
const { id: id2 } = state.apply(new Delta().retain());
const index1 = editor.history.stack.findIndex(it => it.id === id1);
const index2 = editor.history.stack.findIndex(it => it.id === id2);
const delta1 = editor.history.stack[index1].delta;
const delta2 = editor.history.stack[index2].delta;
const delta = delta1.compose(delta2);
editor.history.stack[index1] = { id: id1, delta };
editor.history.stack.splice(index2, 1);
在这里需要先看看transform的具体含义,如果是在协同中的话,b'=a.t(b)的意思是,假设a和b都是从相同的draft分支出来的,那么b'就是假设a已经应用了,此时b需要在a的基础上变换出b'才能直接应用,我们也可以理解为tf解决了a操作对b操作造成的影响。
那么先前的undoable实现,需要将历史所有的undo栈处理一遍,这里的假设是undoable op是早已存在draft中。也就是说此时即使undo栈内的所有op都以执行,那么此时的draft中还是存在undoable op。那么由于这个假设存在,就会将所有历史数据影响到,由此需要做变换。
那么假设此时我们此时存在abc三个记录,c为栈顶,目标是合并ac记录。那么我们先来看c这个op,因为b可能会插入新的内容,导致a/c的retain并不一致,做了inverted之后c的retain会比a大,因此我们需要消除b带来的影响。
举个具体的例子,假设此时我们的内容为132,文本的插入顺序是123,那么我们可以构造出相关的inverted op。此时我们来将4合并到2上,但是明显如果直接取出来并且compose结果是不对的,retain的值并不能对到2上。因此就需要对其之间所有的操作进行变换,这里2和4之间只有3, 就只需要处理3带来的影响。
const op1 = new Delta().insert("1");
const op2 = new Delta().retain(1).insert("2");
const op3 = new Delta().retain(1).insert("3");
const op4 = new Delta().retain(2).retain(1, { src: "2" });
const invert1 = new Delta().delete(1);
const invert2 = new Delta().retain(1).delete(1);
const invert3 = new Delta().retain(1).delete(1);
const invert4 = new Delta().retain(2).retain(1, { src: "1" });
invert3.transform(invert4); // [{"retain":1},{"retain":1,"attributes":{"src":"1"}}]
这里其实还有个问题,设想一下为什么先前处理undoable的时候,做的变换是针对历史记录的,而这里的变化就是针对新来的记录了。实际上我们还是需要处理历史记录的,而undoable的op因为根本不会实际参与到我们的undo进程中,其处理完后直接就消失了,所以可以不需要处理。
再举个例子,假如此时我们的内容是312,写入的顺序是123,由此inverted op则可以推断出来。此时如果我们只是将invert3移除,并且合并到先前的某个op上,之后执行invert2的时候,就会发现删除的是1而不是2,这就导致了索引指向的问题。
const op1 = new Delta().insert("1");
const op2 = new Delta().retain(1).insert("2");
const op3 = new Delta().insert("3");
const invert1 = new Delta().delete(1);
const invert2 = new Delta().retain(1).delete(1);
const invert3 = new Delta().delete(1);
由此可知,最开始那个例子仅仅适用于处理attrs的场景,因为被merge的这个op本身不会影响到其他的op,但是实际的场景基本也只有这个。又会影响到历史记录索引,又会被先前操作过的op影响本身的索引,就像是xxx|yyy。这种情况并不常见,倒是在Local CS中会用的上。
因此我们还需要与undoable一样,将其变换应用到历史记录上。但是因为这里是互相影响的,究竟应该是以被合并op变换后的值为基准,还是原始的值为准。考虑了一下我觉得还是应该以原始值为准,毕竟互相影响的时候是初始值。
依然是上面的例子,假如此时我们的内容是312,写入的顺序是123。这里需要注意的是,我们是假设新op不存在来做的变换,因此应该是先将其再次invert后再变换,相当于需要在当前的基准上将invert3做了undo,也就是下面例子中的op3。
const op1 = new Delta().insert("1");
const op2 = new Delta().retain(1).insert("2");
const op3 = new Delta().insert("3");
const invert1 = new Delta().delete(1);
const invert2 = new Delta().retain(1).delete(1);
const invert3 = new Delta().delete(1);
const invert21 = op3.transform(invert2); // [{"retain":2},{"delete":1}]
const invert11 = op3.transform(invert1); // [{"retain":1},{"delete":1}]
Unicode可以视为Map,可以从数值code point映射到具体的字形,这样就可以直接引用符号而不需要实际使用符号本身。可能的代码点值范围是从U+0000到U+10FFFF,有超过110万个可能的符号,为了保持条理性,Unicode将此代码点范围划分为17个平面。
首个平面U+0000 -> U+FFFF称为基本多语言平面或BMP,包含了最常用的字符。这样BMP之外就剩下大约100万个代码点U+010000 -> U+10FFFF,这些代码点所属的平面称为补充平面或星面。
JavaScript的单个字符由无符号的16位整数支持,因此其无法容纳任何高于U+FFFF的代码点,而是需要将其拆分为代理对。这其实就是JS的UCS-2编码形式,造成了所有字符在JS中都是2个字节,而如果是4个字节的字符,那么就会当作两个双字节的字符处理即代理对。
其实这么说起来UTF-8的变长1-4字节的编码是无法表示的,代理对自然是可以解决这个问题。而表达UTF-16的编码长度要么是2个字节,要么是4个字节。在ECMAScript 6中引入了新的表达方式,但是为了向后兼容ECMAScript 5依然可以用代理对的形式表示星面。
"\u{1F3A8}"
// 🎨
"\uD83C\uDFA8"
// 🎨
实际上在ES6中引入的函数也解决了字符串遍历的问题,正则表达式也提供了u修饰符来处理4字节的字符。
Array.from("1🎨1")
// ["1", "🎨", "1"]
/^.$/u.test("🎨")
// true
"1🎨1".split("")
// ["1", "\uD83C", "\uDFA8", "1"]
另外在基本平面即低位代理对内,从U+D800到U+DFFF是一个空段,即这些码点不对应任何字符,自然可以避免原本基本平面的冲突,因此可以用来映射辅助平面的字符。高位[\uD800-\uDBFF]与低位[\uDC00-\uDFFF]恰好是2^10 * 2^10长度,恰好100多万个代码点。
(0xDBFF - 0xD800 + 1) * (0xDFFF - 0xDC00 + 1) = 1024 * 1024 = 1048576
虽然可以已经用Unicode代理对的方式表达4字节符号,但是类似Emoji这些符号是可以组合的。那么这样会导致字形上看起来是单个字符,实际上是通过\u200d即ZWJ组合起来的字符,因此其长度会更长,且ES6的函数也是会将其拆离表现的。
"🧑" + "\u200d" + "🎨"
// 🧑🎨
"🧑🎨".length
// 5
Array.from("🧑🎨")
// ["🧑", "", "🎨"]
inline + void => embed不能合并,例如mention组件,在增量时的mutate处理数据。block + void节点需要预处理,在增量时主动加入\n控制兜底换行,且需要将长度压缩为1。block长度值需要体现在void节点属性上,在选区变换时使用,存量数据可能会存在此种情况。前段时间在考虑readonly这个状态的实现,最开始的实现是将这个状态置于插件当中,但是这种实现会让编辑器的状态管理变得复杂了不少。因为当readonly这个状态变化时,无法将其状态变化直接通知到所有渲染出的组件当中,因此状态的传递就变成了问题。
当然,我们可以在这个状态发生变化时,重新渲染整个编辑器渲染出来的组件。但是这样看起来会有些性能浪费,毕竟全量重新渲染在内容比较多的情况下会卡一下。那么如果不重建状态而原地复用的话,则会导致我们使用的React.memo导致的组件不会重新渲染。
因此这种情况下由于整个重建了状态,似乎跟重建整个编辑器实例也差不多了。此外,目前的Selection插件中也会存在readonly状态,主要是控制选区结构的显示与隐藏。这种情况下,目前的管理形式就需要作为依赖整个重建editor。
const [readonly, setReadonly] = useState(false);
const editor = useMemo(() => {
prevEditor.destroy();
return new Editor({
plugins: [ /* xxx */ ],
});
}, [readonly]);
后续想来,这件事完全可以在视图层处理,也就是将整个只读状态的变化放置于Context中,然后在渲染的组件中将其use出来。这样就可以按需在用到的时候才会刷新组件,而不需要刷新整个编辑器实例。
export const BlockKit: React.FC<{ editor: Editor; readonly?: boolean }> = props => {
const { editor, readonly, children } = props;
return (
<BlockKitContext.Provider value={editor}>
<ReadonlyContext.Provider value={!!readonly}>{children}</ReadonlyContext.Provider>
</BlockKitContext.Provider>
);
};
export const useReadonly = () => {
const readonly = React.useContext(ReadonlyContext);
return { readonly };
};
此外,这个只读状态还是需要将其同步到编辑器实例当中的,这样编辑器中如果需要状态的话,就可以从中读取状态。此外,在Selection插件中是类组件,这里可以采用静态contextType静态属性的形式处理,单个状态就不调度Context.Consumer了。
if (editor.state.get(EDITOR_STATE.READONLY) !== readonly) {
editor.state.set(EDITOR_STATE.READONLY, readonly || false);
}
export class SelectionHOC extends React.PureComponent<Props, State> {
public static contextType = ReadonlyContext;
public render() {
if (this.context as boolean) {
// xxx
}
}
}
DOM的只读模式就直接依靠contenteditable属性来处理即可,这样就无法直接输入内容了,自然不会触发与输入有关的事件,例如beforeinput等。当然在代码中主动调度相关API操作文档的话,还是可以修改内容的。
此外,Editable还需要考虑到嵌套的情况,如果有需要考虑到整体编辑器的只读状态与内部嵌套编辑节点只读状态不一致的情况,那么这个Provider就不能仅仅放在最外层,还是需要在Editable组件中再放置Context,且优先考虑使用传入的参数。
const { readonly } = props;
const { readonly: defaultReadonly } = useReadonly();
return (
<ReadonlyContext.Provider value={readonly || defaultReadonly}>
{children}
</ReadonlyContext.Provider>
);
我们的数据结构是从quill-delta设计改造而来,那么主体结构必然是保持了delta的描述,这本身并没有什么问题。然而在跨行的操作中,需要进行的操作会变得比较复杂,这里的跨行操作指的是存在操作\n符号的可能,例如删除、插入回车等。
那么最基本的段落结构如下所示,其中首行是标题行,下一行是居中格式,行属性值全部放置于\n符号中。此时我们先来看回车操作,分别是在标题的行首、行中、行末进行操作。
[
{ insert: "Heading" },
{ insert: "\n", attributes: { heading: "h1" } },
{ insert: "Center" },
{ insert: "\n", attributes: { align: "center" } },
]
\n,此时标题行相当于会跟随下移一行,这个操作比较符合直觉。\n,表现为拆分了两行,首行没有格式,尾行携带标题格式。这样就并不太符合直觉了,应该采用格式继承的方式,即首行和末行都是标题格式。\n前的位置,那么此时表现同样会导致拆分了两行,且效果与行中相同。这样就存在问题,首先是格式不应该到下一行,而是保持在首行上。此外由于我们的key结构,这样会导致整行remount,导致图片等格式重新加载。除了回车存在交互问题外,还有删除的操作同样会造成类似的现象。依然以上述数据为例,分别在标题行末、居中行首进行删除操作。
forward删除时,相当于把行末的\n删掉,那么此时标题行的格式就会丢失,行内容变成居中的。backward删除时,同样会相当于把标题行末的\n删掉,然后整行会被合并到标题行,同样变成居中格式。类似这些操作直接处理都是不符合直觉的,理论上而言我们删除的格式应该更像是居中行的格式,这样就需要我们主动兼容这些问题。实际上这都还算比较清晰的操作,而如果将格式混杂起来处理,那么就会变得难以处理。
因此这里可以大概参考飞书的交互方案,即当前行存在行属性时,且当前光标在行首,按下删除时就删除当前的所有行属性。这样就可以保证当前行的格式不会与上一行的格式混杂,此时再点击删除时,就必定是纯文本格式合并上一行的行属性值。
特别是还有类似于有序列表的结构,举个例子,我们此时是在空行上的,而前一行和后一行都是有序列表。当我们此时执行删除操作的时候,删除的应该是本身的\n,并且合并有序列表序号,但是以当前的选区模型会删掉前一行的需要并合并为普通行。
实际上这里更加符合直觉的操作应该是像EtherPad一样,在行首放置一个lmkr的op,通过这个op来放置行格式。当然即是这样的数据操作看起来会更加符合直觉,但是对于我们整体架构来说需要兼容的地方就更多了。例如插入行时可能需要插入\n和lmkr两个op,以及修改行属性时可能同样需要插入lmkr而不是仅仅修改属性值即可。
// https://juejin.cn/post/7075227637601271816#heading-20
// https://github.com/ether/etherpad-lite/blob/9bc91c/src/static/js/AttributeManager.ts#L9
{
apool: {
numToAttrib: {
0: ["author", "a.cRSEzJOZbZxaRlta"],
1: ["heading", "h1"],
2: ["insertorder", "first"],
3: ["lmkr", "1"],
},
nextNum: 4,
},
initialAttributedText: {
text: "*测试1\n测试2\n",
attribs: "*0*1*2*3+1*0|1+4*0+3|1+1",
},
}
从最开始我们就考虑协同相关的问题,那么在协同中比较重要的一点就是数据一致性。在前边我们也聊过跨行操作存在复杂性,如果以EtherPad的结构引入*行首标记的话,就会出现某些行存在该标记某些行不存在的情况,这样就需要特殊处理相关操作,例如行首删除操作长度可能是1/2。
那么这里我们需要考虑的是,为什么不可以在初始化数据的时候,就直接将行首标记直接放在state中,这样就不需要特殊处理相关操作了。然而如果我们的实现是纯客户端的话,这是没有问题的,但是如果是协同的数据操作,那么就可能会存在问题。
如果这些操作是client-side的话,自然是不会出现问题,因为其不会影响协同操作的索引位置,即不会修改原始的文本长度。但是我们的*操作是在行首增加了一个字符,那么如果本地在该行首后插入了字符,那么本地协同发出的index会比服务端的index大1,这样就会导致双端数据不一致,因此导致协同校验数据不通过的问题。
当然这个问题并不是不能解决,只是还需要记录原本的标记与变化的索引,这种情况下成本应该会更高。此外,实际上如果用户处理删除文档最末尾的\n也会导致数据不一致的问题,因为我们的Mutate是会兜底最末尾的\n,因为我们总是需要存在LineState来承载内容。
那么此时如果用户删除了末尾的\n,而此时兜底的\n数据还在数据中。如果在最后进行回车操作的话,由于我们先前进行过跨行操作的兼容,此时插入回车就是\n(caret)后执行,这时候操作索引会超越服务端的值,服务端的操作数据会被丢弃掉,就会导致数据不一致。
因此,我们需要处理这种情况,避免末尾的\n被删除,在这种情况下就需要根据retain和delete来计算索引是否越界,insert的操作则是由于本身插入后自然地移动了索引,因此不需要特殊处理计算。
1234567890\n
r(5).i(3).r(3).d(1).d(2)
123453678
我们希望实现的是视图层分离的通信架构,相当于所有的渲染都采用React,类似于Slate的架构设计。而Facebook在推出的Draft富文本引擎中,是使用纯React来渲染的,然后当前Draft已经不再维护,转而推出了Lexical。
后来我发现Lexical虽然是Facebook推出的,但是却没用React进行渲染,从DOM节点上就可以看出来是没有Fiber的,因此可以确定普通的节点并不是React渲染的。诚然使用React可能存在性能问题,而且由于非受控模式下可能会造成crash,但是能够直接复用视图层还是有价值的。
在Lexical的README中可以看到是可以支持React的,那么这里的支持实际上仅有DecoratorNode可以用React来渲染,例如在Playground中加入ExcaliDraw画板的组件的话,就可以发现svg外的DOM节点是React渲染的,可以发现React组件是额外挂载上去的。
也就是说,仅有Void/Embed类型的节点才会被React渲染,其他的内容都是普通的DOM结构。这怎么说呢,就有种文艺复兴的感觉,如果使用Quill的时候需要将React结合的话,通常就需要使用ReactDOM.render的方式来挂载React节点。还有一点是需要协调的函数都需要用$符号开头,这也有点PHP的文艺复兴。
那么有趣的事,在Lexical中我是没有看到使用ReactDOM.render的方法,所以我就难以理解这里是如何将React节点渲染到DOM上的。于是在useDecorators中找到了Lexical实际上是以createPortal的方法来渲染的。使用这种方式实际与ReactDOM.render效果基本一致,但是createPortal是可以自由使用Context的,且在React树渲染的位置是用户挂载的位置。
// https://react-lab.skyone.host/
const Context = React.createContext(1);
const Customer = () => <span>{React.useContext(Context)}</span>;
const App = () => {
const ref1 = React.useRef<HTMLDivElement>(null);
const ref2 = React.useRef<HTMLDivElement>(null);
const [decorated, setDecorated] = React.useState<React.ReactPortal | null>(null);
React.useEffect(() => {
const div1 = ref1.current!;
setDecorated(ReactDOM.createPortal(<Customer />, div1));
const div2 = ref2.current!;
ReactDOM.render(<Customer />, div2);
}, []);
return (
<Context.Provider value={2}>
{decorated}
<div ref={ref1}></div>
<div ref={ref2}></div>
<Customer></Customer>
</Context.Provider>
);
};
ReactDOM.render(<App />, document.getElementById("root"));
在我们的设计中是基于ContentEditable实现,也就是说没有准备实现自绘选区,只是最近思考了一下自绘选区的实现。通常来说在整体编辑器内的contenteditable=false节点会存在特殊的表现,在类似于inline-block节点中,例如Mention节点中,当节点前后没有任何内容时,我们就需要在其前后增加零宽字符,用以放置光标。
在下面的例子中,line-1是无法将光标放置在@xxx内容后的,虽然我们能够将光标放置之前,但此时光标位置是在line node上,是不符合我们预期的文本节点的。那么我们就必须要在其后加入零宽字符,在line-2/3中我们就可以看到正确的光标放置效果。这里的0.1px也是个为了兼容光标的放置的magic,没有这个hack的话,非同级节点光标同样无法放置在inline-block节点后。
<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>
那么除了通过零宽字符或者<br>标签来放置光标外,自然也可以通过自绘选区来实现,因为此时不再需要ContentEditable属性,那么自然就不会存在这些奇怪的行为。因此如果借助原生的选区实现,然后在此基础上实现控制器层,就可以实现完全受控的编辑器。
但是这里存在一个很大的问题,就是内容的输入,因为不启用ContentEditable的话是无法出现光标的,自然也无法输入内容。而如果我们想唤醒内容输入,特别是需要唤醒IME输入法的话,浏览器给予的常规API就是借助<input>来完成,因此我们就必须要实现隐藏的<input>来实现输入,实际上很多代码编辑器例如 CodeMirror 就是类似实现。
但是使用隐藏的<input>就会出现其他问题,因为焦点在input上时,浏览器的文本就无法选中了。因为在同个页面中,焦点只会存在一个位置,因此在这种情况下,我们就必须要自绘选区的实现了。例如钉钉文档、有道云笔记就是自绘选区,开源的 Monaco 同样是自绘选区,TextBus 则绘制了光标,选区则是借助了浏览器实现。
其实这里说起来TextBus的实现倒是比较有趣,因为其自绘了光标焦点需要保持在外挂的textarea上,但是本身的文本选区也是需要焦点的。因此这里的实现应该是具有比较特殊的实现,特别是IME的输入中应该是有特殊处理,可能是重新触发了事件。而且这里的IME输入除了本身的非折叠选区内容删除外,还需要唤醒字符的输入,此外还有输入过程中暂态的字符处理,自绘选区复杂的地方就在输入模块上。
那么除了特殊的TextBus外,CodeMirror、Monaco/VSCode、钉钉文档、有道云笔记的编辑器都是自绘选区的实现。那么自绘选区就需要考虑两点内容,首先是如何计算当前光标在何处,其次就是如何绘制虚拟的选区图层。选区图层这部分我们之前的diff和虚拟图层实现中已经聊过了,我们还是采取相对简单的三行绘制的形式实现,现在基本都是这么实现的,折行情况下的独行绘制目前只在飞书文档的搜索替换中看到过。
因此复杂的就是光标在何处的计算,我们的编辑器选区依然可以保持浏览器的模型来实现,主要是取得anchor和focus的位置即可。那么在浏览器中是存在API可以实现光标的位置选区Range的,目前我看只有VSCode中使用了这个API,而CodeMirror和钉钉文档则是自己实现了光标的位置计算。CodeMirror中通过二分查找来不断对比光标和字符位置,这其中折行的查找会导致复杂了不少。
说起来,VSCode的包管理则是挺有趣的管理,VSC是开源的应用,在其中提取了核心的monaco-editor-core包。然后这个包会作为monaco-editor的dev依赖,在打包的时候会将其打包到monaco-editor中,monaco-editor则是重新包装了core来让编辑器可以运行在浏览器web容器内,这样就可以实现web版的VSCode。
DOM结构与Model结构的同步在非受控的React组件中变得复杂,这其实也就是需要自绘选区的部分原因,可以以此避免非受控问题。那么非受控的行为造成的主要问题可以比较容易地复现出来,首先我们此时存在两个节点,分别是inline类型和text类型的节点。
inline|text
此时我们的光标在inline后,我们的inline规则是不会继承前个节点的格式,那么此时如果我们输入内容例如1,此时的文本就变成了inline|1text。这个操作是符合直觉的,然而当我们在上述的位置唤醒IME输入中文内容时,这里的文本就变成了错误的内容。
inline中文|中文text
这里究其原因还是在于非受控的IME问题,在输入英文时我们的输入在beforeinput事件中被阻止了默认行为,因此不会触发浏览器默认行为的DOM变更。然而当前在唤醒IME的情况下,DOM的变更行为是无法被阻止的,因此此时属于半受控的输入,这样就导致了问题。
此时由于浏览器的默认行为,inline节点的内容会被输入法插入中文的文本,而当我们输入完成后,数据结构Model层的内容是会将文本放置于text前,这跟我们输入非中文的表现是一致的,也是符合预期表现的。
那么由于我们的immutable设计,再加上React.memo以及useMemo的执行,即是我们在最终的纯文本节点加入了脏DOM检测也是不够的。就纯粹的是因为我们的策略,导致React原地复用了当前的DOM节点,因此造成了IME输入的DOM变更和Model层的不一致。
const onRef = (dom: HTMLSpanElement | null) => {
if (props.children === dom.textContent) return void 0;
const children = dom.childNodes;
// If the text content is inconsistent due to the modification of the input
// it needs to be corrected
for (let i = 1; i < children.length; ++i) {
const node = children[i];
node && node.remove();
}
// Guaranteed to have only one text child
if (isDOMText(dom.firstChild)) {
dom.firstChild.nodeValue = props.children;
}
};
如果我们直接将leaf的React.memo以及useMemo移除,这个问题自然是会消失,然而这样就会导致编辑器的性能下降。因此我们就需要考虑尽可能检查到脏DOM的情况,实际上如果是在input事件或者MutationObserver中处理输入的纯非受控情况,也需要处理脏DOM的问题。
那么我们可以明显的想到,当行状态发生变更时,我们就直接检查当前行的所有leaf节点,然后对比文本内容,如果存在不一致的情况则直接进行修正。如果直接使用querySelector的话显然不够优雅,我们可以借助WeakMap来映射叶子状态到DOM结构,以此来快速定位到需要的节点。
然后在行节点的状态变更后,在处理副作用的时候检查脏DOM节点,并且由于我们的行状态也是immutable的,因此也不需要担心性能问题。那么检查节点的方法自然也跟上述onRef一致。
const leaves = lineState.getLeaves();
for (const leaf of leaves) {
const dom = LEAF_TO_TEXT.get(leaf);
if (!dom) continue;
const text = leaf.getText();
// 避免 React 非受控与 IME 造成的 DOM 内容问题
if (text === dom.textContent) continue;
editor.logger.debug("Correct Text Node", dom);
const nodes = dom.childNodes;
for (let i = 1; i < nodes.length; ++i) {
const node = nodes[i];
node && node.remove();
}
if (isDOMText(dom.firstChild)) {
dom.firstChild.nodeValue = text;
}
}
这里需要注意的是,脏节点的状态检查是需要在useLayoutEffect时机执行的,因为我们需要保证执行的顺序是先校正DOM再更新选区,如果反过来的话就会,即先更新选区则选区依然停留在脏节点上,此时再校正DOM就会导致选区的丢失,表现是此时选区会在inline的最前方。此外,这里的实现在首次渲染并不需要检查,此时不会存在脏节点的情况。
以这种策略来处理脏DOM的问题,还可以避免部分其他可能存在的问题,零宽字符文本的内容暂时先不处理,如果再碰到类似的情况是需要额外的检查的。此外,这里的问题也可能是我们的选区策略是尽可能偏左侧的查找,如果在这种情况将其校正到右侧节点可能也可以解决问题,不过因为在空行的情况下我们的末尾\n节点并不会渲染,因此这样的策略目前并不能彻底解决问题。
然而,在后期的实现中,重新出现了先前在Wrapper DOM一节中提到的a标签问题,此时的问题变得复杂了很多,主要是各个浏览器的兼容性的问题。类似于行内代码块,本质上还是浏览器IME非受控导致的DOM变更问题,但是在浏览器表现差异很大,下面是最小的DEMO结构。
<div contenteditable>
<span data-leaf><a href="#"><span data-string>在[:]后输入:</span></a></span><span data-leaf>非链接文本</span>
</div>
在上述示例的a标签位置的最后的位置上输入内容,主流的浏览器的表现是有差异的,甚至在不同版本的浏览器上表现还不一致:
Chrome中会在a标签的同级位置插入文本类型的节点,效果类似于<a></a>"text"内容。Firefox中会在a标签内插入span类型的节点,效果类似于<a></a><span data-string>text</span>内容。Safari中会将a标签和span标签交换位置,然后在a标签上同级位置加入文本内容,类似<span><a></a>"text"</span>。<!-- Chrome -->
<span data-leaf="true">
<a href="https://www.baidu.com"><span data-string="true">超链接</span></a>
"文本"
</span>
<!-- Firefox -->
<span data-leaf="true">
<a href="https://www.baidu.com"><span data-string="true">超链接</span></a>
<span data-string="true">文本</span>
</span>
<!-- Safari -->
<span data-leaf="true">
<span data-string="true">
<a href="https://www.baidu.com">超链接</a>
"文本"
""
</span>
</span>
因此我们的脏DOM检查需要更细粒度地处理,仅仅对比文本内容显然是不足以处理的,我们还需要检查文本的内容节点结构是否准确。其实最开始我是仅处理了Chrome下的情况,最简单的办法就是在leaf节点下仅允许存在单个节点,存在多个节点则说明是脏DOM。
for (let i = 1; i < nodes.length; ++i) {
const node = nodes[i];
node && node.remove();
}
但是后来发现在编辑时会把Embed节点移除,这里也就是因为我们错误地把组合的div节点当成了脏DOM,因此这里就需要更细粒度地处理了。然后考虑检查节点的类型,如果是文本的节点类型再移除,那么就可以避免Embed节点被误删的问题。
for (let i = 1; i < nodes.length; ++i) {
const node = nodes[i];
isDOMText(node) && node.remove();
}
虽然看起来是解决了问题,然而在后续就发现了Firefox和Safari下的问题。先来看Firefox的情况,这个节点并非文本类型的节点,在脏DOM检查的时候就无法被移除掉,这依然无法处理Firefox下的脏DOM问题,因此我们需要进一步处理不同类型的节点。
// data-leaf 节点内部仅应该存在非文本节点, 文本类型单节点, 嵌入类型双节点
for (let i = 1; i < nodes.length; ++i) {
const node = nodes[i];
// 双节点情况下, 即 Void/Embed 节点类型时需要忽略该节点
if (isHTMLElement(node) && node.hasAttribute(VOID_KEY)) {
continue;
}
node.remove();
}
在Safari的情况下就更加复杂,因为其会将a标签和span标签交换位置,这样就导致了DOM结构性造成了破坏。这种情况下我们就必须要重新刷新DOM结构,这种情况下就需要更加复杂地处理,在这里我们加入forceUpdate以及TextNode节点的检查。
其实在飞书文档中也是采用了类似的做法,飞书文档的a标签在唤醒IME输入后,同样会触发脏DOM的检查,然后飞书文档会直接以行为基础ReMount当前行的所有leaf节点,这样就可以避免复杂的脏DOM检查。我们这里实现更精细的leaf处理,主要是避免不必要的挂载。
const LeafView: FC = () => {
const { forceUpdate, index: renderKey } = useForceUpdate();
LEAF_TO_REMOUNT.set(leafState, forceUpdate);
return (<span key={renderKey}></span>);
}
if (isDOMText(dom.firstChild)) {
// ...
} else {
const func = LEAF_TO_REMOUNT.get(leaf);
func && func();
}
此外,由于我们的编辑器是完全immutable实现的,因此在文本节点变更时若是需要存在连续的格式处理,例如inline-code的样式实现,那么就会存在渲染问题。具体表现是若是多个连续的code节点,最后一个节点长度为1,删除最后这个节点时会导致前一个节点无法刷新样式。
if (!prev || !prev.op.attributes || !prev.op.attributes[INLINE_CODE_KEY]) {
context.classList.push(INLINE_CODE_START_CLASS);
}
context.classList.push("block-kit-inline-code");
if (!next || !next.op.attributes || !next.op.attributes[INLINE_CODE_KEY]) {
context.classList.push(INLINE_CODE_END_CLASS);
}
这个情况同样类似于Dirty DOM的问题,由于删除的节点长度为1,因此前一个节点的LeafState并没有变更,因此不会触发React的重新渲染。这里我们就需要在行节点渲染时进行纠正,这里的执行倒是不需要像上述检查那样同步执行,以异步的effect执行即可。
/**
* 编辑器行结构布局计算后异步调用
*/
public didPaintLineState(lineState: LineState): void {
for (let i = 0; i < leaves.length; i++) {
if (!prev || !prev.op.attributes || !prev.op.attributes[INLINE_CODE_KEY]) {
node && node.classList.add(INLINE_CODE_START_CLASS);
}
if (!next || !next.op.attributes || !next.op.attributes[INLINE_CODE_KEY]) {
node && node.classList.add(INLINE_CODE_END_CLASS);
}
}
}
虽然看起来已经解决了问题,然而在React中还是存在一些问题,主要的原因此时的DOM处理是非受控的。在下面的例子中,由于React在处理style属性时,只会更新发生变化的样式属性,即使整体是新对象,但具体值与上次渲染时相同,因此React不会重新设置这个样式属性。
// https://playcode.io/react
import React from "react";
export function App() {
const el = React.useRef();
const [, setState] = React.useState(1);
const onClick = () => {
el.current && (el.current.style.color = "blue");
}
console.log("Render App")
return (
<div>
<div style= ref={el}>Hello React.</div>
<button onClick={onClick}>Color Button</button>
<button onClick={() => setState(c => ++c)}>Rerender Button</button>
</div>
);
}
因此,在上述的didPaintLineState中我们主要是classList添加类属性值,即使是LeafState发生了变更,React也不会重新设置类属性值,因此这里我们还需要在didPaintLineState变更时删除非必要的类属性值。
public didPaintLineState(lineState: LineState): void {
for (let i = 0; i < leaves.length; i++) {
if (!prev || !prev.op.attributes || !prev.op.attributes[INLINE_CODE_KEY]) {
node && node.classList.add(INLINE_CODE_START_CLASS);
} else {
node && node.classList.remove(INLINE_CODE_START_CLASS);
}
if (!next || !next.op.attributes || !next.op.attributes[INLINE_CODE_KEY]) {
node && node.classList.add(INLINE_CODE_END_CLASS);
} else {
node && node.classList.remove(INLINE_CODE_END_CLASS);
}
}
}
在React视图层方面,同样也会出现非受控行为的问题,本质上还是跟IME输入的DOM变更有关。当选区存在跨节点行为时,无论是行内还是跨行的选区,唤醒输入法输入内容后,这部分节点内容会被删除,并且替换为输入的内容。但是当确定内容之后,编辑器便会崩溃,报错内容如下:
Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.
从报错上来看,React会将子节点从父节点移除,这本身是非常合理的行为。举个例子,当实现一个列表时,如果数据源删除了某些节点,那么React就会将对应的DOM节点自动移除掉,也就是不需要操作DOM,而是可以直接通过声明式的方式来实现变更。
那么这里的问题就出现在这些DOM已经实际上被移除了,因此当React尝试移除这些节点时就会报错,而这个异常会导致整个编辑器崩溃,因此我们就需要避免这个情况的发生。那么首先就需要避免removeChild的异常,我们很难直接避免React的行为,因此只能在DOM节点上进行拦截。
然而,即使是在DOM上处理拦截行为也并不容易,removeChild方法是在Node对象上的,如果我们直接重写Node.prototype.removeChild方法,那么就会影响到整个页面的DOM节点,因此我们只能尝试在编辑器的ref上处理。
/**
* 重写 removeChild 方法
* - 避免 IME 破坏跨节点渲染造成问题
* - https://github.com/facebookarchive/draft-js/issues/1320
*/
export const rewriteRemoveChild = (node: Node) => {
const removeChild = Node.prototype.removeChild;
node.removeChild = function <T extends Node>(child: T) {
if (child.parentNode !== this) return child;
return removeChild.call(this, child) as T;
};
};
然而编辑器本身会存在大量的DOM节点,我们很难在所有的节点上进行重写,因此我们还需要限制DOM变动的范围。在React中控制重渲染的方式可以通过key来实现,因此就需要在IME输入起始时刷新相关节点的key,以此来避免React复用这些节点,然后刷新范围就限制在了行节点上。
/**
* 组合输入开始
* @param event
*/
@Bind
protected onCompositionStart() {
// 需要强制刷新 state.key, 且需要配合 removeChild 避免抛出异常
const sel = this.editor.selection.get();
if (!sel || sel.isCollapsed) return void 0;
for (let i = sel.start.line; i <= sel.end.line; ++i) {
const line = this.editor.state.block.getLine(i);
line && line.forceRefresh();
}
}
然后在React控制节点的部分,就需要将重写的逻辑加入到块节点以及行节点的DOM上,以此来避免异常的发生。这里还需要避免ref函数的重复执行,React的特性是如果ref引用不同就会原始的引用再调用新的方法,因此这里需要借助useMemoFn实现。
const setModel = useMemoFn((ref: HTMLDivElement | null) => {
if (ref) {
rewriteRemoveChild(ref);
}
});
从本质上来看,是执行输入法时没有办法控制DOM的变更行为,或者阻止浏览器的默认行为。但是我们却可以在start的时候就执行相关的处理,类似于将end时的删除且插入的行为分离出来,也就是说先执行deleteFragment方法,将所有的DOM直接通过先移除掉来同步行为。
但是这里又出现了新的问题,因为本身的delete方法会将选区内的内容全部删除,这样的话会导致唤醒IME时,选区所在的DOM节点会被删除掉,因此浏览器会将光标兜底到当前行的起始位置,虽然不影响最终输入的内容,但是在输入的时候就可以明显地看出来问题,有些影响用户体验。
在这里其实还可以考虑一种实现,在组合输入时同样会删除选区的内容,但是保留光标所在的DOM节点,这个实现就会很复杂。其实如果能在唤醒输入法前就将选区删除并且再设置好光标位置,再出现输入法的话,倒是就不会出现这个问题,然而目前并没有相关的API可以实现这样的行为。
但是在后期研究slate的实现发现,其仅仅是在IME组合输入开始的时候删除了相关的节点,而我们的编辑器却无法做到。经过排查之后发现是更新内容后的浏览器选区事件被我们阻止了,但是这里的表现也比较奇怪,阻止了选区更新竟然会导致行的该节点后的所有节点都无法渲染出来。
因此在这里放行选区更新的事件,即在Update Effect时不再通过Composing状态阻止选区的更新行为,这样就可以避免上述的问题了。然而这里的表现确实是非常奇怪的,React确实是持有了DOM状态,而改动就是这里的更新选区行为,选区本身导致节点无法正常渲染实在是有点费解。
useLayoutEffect(() => {
const selection = editor.selection.get();
// 渲染完成后更新浏览器选区
if (editor.state.isFocused() && selection) {
editor.logger.debug("UpdateDOMSelection");
editor.selection.updateDOMSelection(true);
}
});
假设我们现在的编辑器是表单、输入框等场景,那么自然是不需要协同的调度的,在这种情况下数据就可以直接全量存储。但是假如我们现在并不是这种小型场景,而是类似于知识库、笔记文档等这种相对不太需要多人协同的情况,或者以此为基础搭建CMS管理系统,就需要考虑增量文档存储的情况了。
这种场景下,相当于是处于小型编辑器和重型协同编辑器的中间状态。通常来说可以使用编辑锁来保证可能存在的协作需求,而在这里对于增量存储和全量存储就是值得讨论的情况,我们这里主要是讨论增量存储带来的优势,增量存储相当于在客户端和服务端同时将变更应用。
op,直接根据版本号读取变更即可,在检查相关文档的变更记录时非常有用。client-side数据。也就是说某些数据属性可以仅临时处于客户端,例如代码块的高亮结构、超链接编辑面板失去焦点时的状态,这样的数据无需真正存储在数据库。diff再同步,增量存储则仅需要根据版本差异来同步即可。先前已经提到了TextBus是个非常特殊的实现,其既没有使用ContentEditable这种常见的实现方案,也没有像CodeMirror或者Monaco一样自绘选区。从Playground的DOM节点上来看,其是维护了一个隐藏的iframe来实现的,这个iframe内存在一个textarea,以此来处理IME的输入。
这种实现非常的特殊,因为内容输入的情况下,文本的选区会消失,也就是说两者的焦点是会互相抢占的。那么先来看一个简单的例子,以iframe和文本选区的焦点抢占为例,可以发现在iframe不断抢占的情况下,我们是无法拖拽文本选区的。这里值得一提的是,我们不能直接在onblur事件中进行focus,这个操作会被浏览器禁止,必须要以宏任务的异步时机触发。
<span>123123</span>
<iframe id="$1"></iframe>
<script>
const win = $1.contentWindow;
win.addEventListener("blur", () => {
console.log("blur");
setTimeout(() => $1.focus(), 0);
});
win.addEventListener("focus", () => console.log("focus"));
win.focus();
</script>
实际上这个问题是我踩过的坑,注意我们的焦点聚焦调用是直接调用的$1.focus,假如此时我们是调用win.focus的话,那么就可以发现文本选区是可以拖拽的。通过这个表现其实可以看出来,主从框架的文档的选区是完全独立的,如果焦点在同一个框架内则会相互抢占,如果不在同一个框架内则是可以正常表达,也就是$1和win的区别。
其实可以注意到此时文本选区是灰色的,这个可以用::selection伪元素来处理样式,而且各种事件都是可以正常触发的,例如SelectionChange事件以及手动设置选区等。当然如果直接在iframe中放置textarea的话,可以得到同样的表现,同样也可以正常的输入内容,并且不会打断IME的输入法,这个Magic的表现在诸多浏览器都可以正常触发。
<span>123123</span>
<iframe id="$1"></iframe>
<script>
const win = $1.contentWindow;
const textarea = document.createElement("textarea");
$1.contentDocument.body.appendChild(textarea);
textarea.focus();
textarea.addEventListener("blur", () => {
setTimeout(() => textarea.focus(), 0);
});
win.addEventListener("blur", () => console.log("blur"));
win.addEventListener("focus", () => console.log("focus"));
win.focus();
</script>
我们首先讨论纯文本节点,在通常情况下,我们的选区都是在文本节点上的,也就是说通过Selection对象取得的节点是text类型节点。然而在某些情况下,会存在非文本节点的选区,例如在三击文本行的时候,会出现整行的选区,此时会出现anchor节点是行首text节点,而focus节点是下一行的行节点。
{
anchorNode: text,
anchorOffset: 0,
focusNode: div, // <div data-node="true">...</div>
focusOffset: 0,
}
此时就需要进行校正,特别需要注意此时的focus节点是下一行的节点,因此我们需要校正的目标是下一行行首文本节点offset: 0的位置,因此这样的Model选区就可以对应为选区两行的0 offset位置,同时需要同步到DOM选区上。
这里的校正逻辑基本上参考了slate的实现,大概目标是尝试找到可编辑子节点,首先会调用getEditableChildAndIndex查找一级子节点,相当于先查找一级来判断后续迭代方向。之后继续查找,这里是采取的DFS搜索形式,查找每一级都会尝试找到可编辑节点。
这里还有需要关注的是,如果能够直接找到文本节点自然不需要继续深度查找,在HTML中文本节点并不属于DOMElement。此外,由于已经确定了当前是非文本节点,因此我们此时的offset值只会是0或者子节点长度偏移。
对于Embed节点来说这里更加复杂,具体来说则是注释节点、非编辑节点、空子节点等。因此主要差异在于getEditableChildAndIndex的方法中,这里就是在查找子节点时,分别尝试正向和反向查找,以此来尝试找到可编辑节点。
在slate中的inline节点是在空节点内放置zero-width字符,以此来实现节点本身的选中状态。而如果独占一行的时候,前后都会生成零宽字符来放置光标。我们本身的节点是不会存在零宽字符来选中本身节点的,内部自然不会存在零宽字符,外部的零宽字符则是用来放置光标,这都是符合各自的语义设计的。
// slate
<div data-leaf="true">
<div contenteditable="false">
<ZeroSpace></ZeroSpace>
<span>Mention</span>
</div>
</div>
// block-kit
<div data-leaf="true">
<ZeroSpace></ZeroSpace>
<div contenteditable="false">
<span>Mention</span>
</div>
</div>
当拖拽选区的时候,此时如果没有拖过Embed节点,那么浏览器选区的放置则是contenteditable="false"的节点。那么从上述就能够看出来,在slate中查找节点的时候,是应该正常向内部查找,而我们实际上应该查找平级的节点,因此这里的实现是有差异的。当然如果选区落点在data-leaf节点上的话,向内查找自然是没有问题的。
document.onselectionchange = () => {
const sel = document.getSelection();
console.log(sel.anchorNode, sel.anchorOffset, sel.focusNode, sel.focusOffset);
};
在实现表格节点时,HTML中Table元素选区存在怪异的表现,无论是在slate、lexical、prosemirror都存在类似的问题。如下所示,当实现fixed表格之后,点击表格右侧空白区域,光标会落在表格最右列后,若是点击光标上方,光标则会落在最左侧前。
<style>table td { border: 1px solid #eee; }</style>
<div contenteditable style="outline: none; padding: 20px;">
<table style="border-collapse: collapse; border-spacing: 0; table-layout: fixed">
<colgroup>
<col width="100" />
<col width="100" />
</colgroup>
<tbody>
<tr><td>1</td><td>2</td></tr>
<tr><td>3</td><td>4</td></tr>
</tbody>
</table>
</div>
其实这个表现只在Chrome和Safari中存在,Firefox中是不存在这个表现的,猜测是Webkit内核的实现问题。先来研究最右列的光标问题,在plate中,额外多定义了一个100%的col元素,这样就可以将表现跟Firefox对齐,点击空白处能够自动对齐焦点到平行的单元格内。
<style>table td { border: 1px solid #eee; }</style>
<div contenteditable style="outline: none; padding: 20px;" on>
<table style="border-collapse: collapse; border-spacing: 0; table-layout: fixed;">
<colgroup>
<col width="100" />
<col width="100" />
<col style="100%" />
</colgroup>
<tbody>
<tr><td>1</td><td>2</td></tr>
<tr><td>3</td><td>4</td></tr>
</tbody>
</table>
</div>
其实我们可以很轻易地发现,这是由于选区变换导致的问题。那么我们也可以通过避免触发选区变换来解决问题,例如下面的例子中的MouseDown事件处理,通过preventDefault来阻止默认行为即可,但是需要注意只能阻止当前节点的选区变换,单元格的事件需要正常处理。
最理想情况的下,点击都落在该节点的时候,两种选区问题都可以解决。然而在编辑器的复杂DOM结构下很容易出现问题,例如在下面的例子中如果额外嵌套了一层div节点的话,在这个节点阻止默认行为就只能解决在表格右侧点击的问题了。
<style>table td { border: 1px solid #eee; }</style>
<div contenteditable style="outline: none; padding: 20px;" id="$1">
<table style="border-collapse: collapse; border-spacing: 0; table-layout: fixed">
<colgroup>
<col width="100" />
<col width="100" />
</colgroup>
<tbody>
<tr><td>1</td><td>2</td></tr>
<tr><td>3</td><td>4</td></tr>
</tbody>
</table>
</div>
<script>
$1.addEventListener("mousedown", (e) => {
if (e.currentTarget === e.target) {
e.preventDefault();
}
});
</script>
在偶然的条件下,我发现如果在table元素上设置position: relative的话,就可以避免表格节点两侧的光标展现。需要注意的是这里仅仅是避免了表现,实际选区还是存在的,因此这种额外的case应该需要在编辑器的选区变换时处理。
例如在slate(1022682)中,最外层的节点是table元素,此时点击后会导致选区穿透。表现是Selection对象的目标节点是整个编辑器元素,因此最好是将其套一层额外的div,此时选区变换就可以直接特判到该节点,而不必需要在normalize时进行额外的查找。
<style>table td { border: 1px solid #eee; }</style>
<div contenteditable style="outline: none; padding: 20px;">
<table style="border-collapse: collapse; border-spacing: 0; table-layout: fixed; position: relative;">
<colgroup>
<col width="100" />
<col width="100" />
</colgroup>
<tbody>
<tr><td>1</td><td>2</td></tr>
<tr><td>3</td><td>4</td></tr>
</tbody>
</table>
</div>
从最开始的编辑器设计中,我们就已经将核心层和视图层分离,并且为了更灵活地调度编辑器,我们将编辑器实例化的时机交予用户来控制。这种情况下若是暴露出ref可以不必要设置null的状态,通常情况下的调度方式类似于下面的实现:
const Editor = () => {
const editor = useMemo(() => new Editor(), []);
return <Editable editor={editor} />;
}
然而在这种情况下,编辑器实例化的生命周期和Editable组件的生命周期必须要同步,这样才能保证编辑器的状态和视图层的状态一致。例如下面的例子中,Editable组件的生命周期与Editor实例的生命周期不一致,会导致视图所有的插件卸载。
const Editor = () => {
const editor = useMemo(() => new Editor(), []);
const [key, setKey] = useState(0);
return (
<Fragment>
<button onClick={() => setKey((k) => k + 1)}>切换</button>
{key % 2 ? <div><Editable editor={editor} /></div> : <Editable editor={editor} />}
</Fragment>
);
}
上面的例子中,若是Editable组件层级一致其实是没问题的,但层级不一致的话会导致Editable组件级别的卸载和重新挂载。当然本质上就是组件生命周期导致的问题,因此可以推断出若是同一层级的组件,设置为不同的key值也会触发同样的效果。
这里的核心原因实际上是,Editable组件的useEffect钩子会在组件卸载时触发清理函数,而编辑器实例化的时候是在最外层执行的。那么编辑器实例化和卸载的时机是无法对齐的,卸载后所有的编辑器功能都会销毁,只剩下纯文本的内容DOM结构。
useLayoutEffect(() => {
const el = ref.current;
el && editor.mount(el);
return () => {
editor.destroy();
};
}, [editor]);
此外,实例化独立的编辑器后,同样也不能使用多个Editable组件来实现编辑器的功能,也就是说每个编辑器实例都必须要对应一个Editable组件,多个编辑器组件的调度会导致整个编辑器状态混乱。
const Editor = () => {
const editor = useMemo(() => new Editor(), []);
return (
<Fragment>
<Editable editor={editor} />
<Editable editor={editor} />
</Fragment>
);
}
因此为了避免类似的问题,我们是应该以最开始的实现方式一致,也就是说整个编辑器组件都应该是合并为独立组件的。那么类似于上述出现问题的组件应该有如下的形式,将实例化的编辑器和Editable组件合并为独立组件。
const Editor = () => {
const editor = useMemo(() => new Editor(), []);
return <Editable editor={editor} />;
};
const App = () => {
const [key, setKey] = useState(0);
return (
<Fragment>
<button onClick={() => setKey((k) => k + 1)}>切换</button>
{key % 2 ? <div><Editor /></div> : <Editor />}
</Fragment>
);
}
实际上我们完全可以将实例化编辑器这个行为封装到Editable组件中的,但是为了更灵活地调度编辑器,将实例化放置于外层更合适。此外,从上述的Effect中实际上可以看出,与onMount实例挂载对齐的生命周期应该是Unmount,因此这里更应该调度编辑器卸载DOM的事件。
然而若是将相关的事件粒度拆得更细,即在插件中同样需要定义为onMount和onUnmount的生命周期,这样就能更好地控制相关处理时机问题。然而这种情况下,对于用户来说就需要有更复杂的插件逻辑,与DOM相关的事件绑定、卸载等都需要用户明确在哪个生命周期调用。
即使分离了更细粒度的生命周期,编辑器的卸载时机仍然是需要主动调用的,不主动调用则会存在可能的内存泄漏问题,在插件的实现中用户是可能直接向DOM绑定事件的,这些事件在编辑器卸载时是需要主动解绑的。
实际上如果能实现更细粒度的生命周期,对于整个编辑器的实现是更高效的,毕竟若是避免实例化编辑器则可以减少不必要的状态变更和事件绑定。因此这里还是折中实现,若是用户需要避免编辑器的卸载事件,可以通过preventDestroy参数来实现,用户在编辑器实例化生命周期结束主动卸载。
export const Editable: React.FC<{
/**
* 避免编辑器主动销毁
* - 谨慎使用, 生命周期结束必须销毁编辑器
* - 注意保持值不可变, 否则会导致编辑器多次挂载
*/
preventDestroy?: boolean;
}> = props => {
const { preventDestroy } = props;
const { editor } = useEditorStatic();
useLayoutEffect(() => {
const el = ref.current;
el && editor.mount(el);
return () => {
editor.unmount();
!preventDestroy && editor.destroy();
};
}, [editor, preventDestroy]);
}
在实现诸如Mention、划词改写等模块时,通常需要额外的辅助节点来渲染面板,例如Mention需要唤醒额外的面板来选择要at的对象,并且需要在此基础上实现诸如上下选择、回车等交互。
这种情况下,Mention面板通常是不会渲染在编辑器内部的,需要额外的节点来渲染这个面板。因此在实现编辑器模块时,是额外渲染了一个mount-dom作为辅助节点的容器,以此作为原始的DOM结构提供给ReactDOM来渲染。
const onMountRef = (e: HTMLElement | null) => {
e && MountNode.set(editor, e);
};
<BlockKit editor={editor} readonly={readonly}>
<div className="block-kit-editable-container">
<div className="block-kit-mount-dom" ref={onMountRef}></div>
<Editable></Editable>
</div>
</BlockKit>
用ReactDOM.render来渲染节点时,是不能够直接将该节点作为容器的,因为调用时并非直接追加React节点到DOM节点,而是直接将React节点渲染到该节点上。因此这种情况下,若是存在多个需要挂载的辅助节点,是无法完成的。
ReactDOM.render("string", document.getElementById("root"));
因此这里渲染辅助元素时,需要先将此节点作为容器,创建一个新的容器子节点,然后将该节点作为容器调用ReactDOM.render方法来渲染React节点。在最开始的时候,编辑器中的Mention面板是类似下面的实现:
if (!this.mountSuggestNode) {
this.mountSuggestNode = document.createElement("div");
this.mountSuggestNode.dataset.type = "mention";
MountNode.get(this.editor).appendChild(this.mountSuggestNode);
}
const top = this.rect.top;
const left = this.rect.left;
const dom = this.mountSuggestNode!;
this.isMountSuggest = true;
ReactDOM.render(<Suggest controller={this} top={top} left={left} text={text} />, dom);
最近我在思考一个问题,在我们使用ReactDOM.createPortal来传送到目标节点时,更加类似于追加节点的方式来实现,而不是需要向上述的方式一样先创建容器再渲染节点,并且此时还可以使用Context来传递编辑器的状态。
但是createPortal没有办法像render方法那样可以直接渲染节点,其只是创建了一个Portal节点,而不是实际进行了渲染行为。因此,最终还是无法避免需要一个实际渲染的行为,相互配合起来类似于下面的实现,这样就可以将元素实际创建到body上。
const portal = ReactDOM.createPortal(
<Suggest controller={this} top={top} left={left} text={text} />,
document.body,
);
ReactDOM.render(portal, this.mountSuggestNode!);
那么如果类似于先前聊的Lexical的实现方式,独立控制一个Portals占位来渲染辅助节点,就可以避免使用render方法来渲染节点,并且可以直接在mount-dom追加节点而不需要再创建子容器,并且直接使用这种方法可以避免React 18的createRoot方法Breaking Change。
const PortalView: FC<{ editor: Editor }> = props => {
const [portals, setPortals] = useState<O.Map<ReactPortal>>({});
EDITOR_TO_PORTAL.set(props.editor, setPortals);
return (
<Fragment key="block-kit-portal-model">
{Object.entries(portals).map(([key, node]) => (
<Fragment key={key}>{node}</Fragment>
))}
</Fragment>
);
};
Embed节点选区行为需要注意的点过多,在前边的实现中虽然基本是可以实现,但是在实际使用过程中会出现一些问题。例如在从左到右选择时,若是鼠标拖拽到Embed节点偏左时,浏览器选区并未覆盖节点,然而抬起鼠标时的计算会将其覆盖整个节点,这属于是选区不同步的问题。
造成这个问题的原因是,我们的选区模型设计是左侧的零宽节点设计,因为放置于右侧的话可能会导致输入法的问题,对于类似的问题我在slate#5685以及slate#5736中都有提到过。因此,在我们的编辑器实现中,会直接将其放置于嵌入节点的左侧。
<span data-leaf="true">
<span data-zero-space="true" data-zero-embed="true">​</span>
<span contenteditable="false" data-void="true">{/** xxx */}</span>
</span>
因此在先前的实现中,虽然整个选区的行为是更倾向于偏左的节点,但是在Embed节点上的右侧选区则是偏右的,这是因为这里的设计会导致浏览器的光标始终是展示在左侧的,这部分实现则依旧保持现状。说起来slate的实现是额外增加了零宽节点作为选区,不会存在类似问题。
此处的修改主要是对于Case 4做了增添,主体原则是落于零宽字符时,无论offset是0还是1,光标都会置于节点前,当然由于offset为0时本身就是左选区无需特殊处理。data-void位置表示选区需要置于节点后, 由此能够对齐浏览器的实现。
并且实现了Case 5的情况,由于Embed节点是不会实现user-select: none的情况,因此在拖拽选区时,offset可能会越界出现偏移问题。此外,若是非折叠的选区, 则需要判断是否是End节点决定边界, 将整个Embed选区选中,这里需要传递相关环境状态。
// Case 4: 光标位于 data-zero-embed 节点上时, 需要将其修正为节点前
// 若不校正会携带 DOM-Point CASE1 的零选区位置, 按下左键无法正常移动光标
// 主体原则是落于零宽字符时,光标置于节点前, `div`位置表示节点后, 对齐浏览器
// [[z][caret]]\n => [[caret][z]]\n
// [[z][div[caret]]][123]\n => [[z][div]][[caret]123]\n
// Case 5: 光标在 Embed 节点内时, 光标可能会在其内部文本上(offset 可能 > 1)
// 无论是选区折叠与否, 若不校正会导致选区越界, 会导致拖拽选区时出现偏移问题
// 若是非折叠的选区, 则需要判断是否是 End 节点决定边界, 将整个 Embed 选区选中
// [embed[caret > 1]] => [embed[caret = 1]]
if (leafModel && leafModel.embed && offset >= 1) {
const isEmbedZeroContainer = isEmbedZeroNode(nodeContainer);
if (isEmbedZeroContainer && nodeOffset) {
return new Point(lineIndex, leafModel.offset);
}
if (!isCollapsed && isDOMText(nodeContainer)) {
return new Point(lineIndex, leafModel.offset + (isEndNode ? 1 : 0));
}
return new Point(lineIndex, leafModel.offset + 1);
}
此外,若是存在连续的Embed节点时,浏览器三击时会导致仅能选中覆盖首个节点,后续节点无法覆盖,选区变换时拿到的是Embed div节点。因此,我们需要额外判断三击时的选区行为,直接主动选择整行,而不需要依赖浏览器的选区变换。
protected onTripleClick(event: MouseEvent) {
if (event.detail !== 3 || !this.current) {
return void 0;
}
const line = this.current.start.line;
const state = this.editor.state.block.getLine(line);
if (!state) {
return void 0;
}
const range = Range.fromTuple([state.index, 0], [state.index, state.length - 1]);
this.set(range, true);
event.preventDefault();
}
先前我们针对Emoji的删除做了特殊处理,因为其本身是多个字符组成的内容,所以在删除时如果直接取长度为1的话会导致出现遗留不可见字符的情况。那么除了Emoji可能存在删除多个字符的情况,使用Alt + Del组合键在默认情况下是删除词级别内容,同样是存在多个字符的情况。
如果仅仅是使用ContentEditable的情况下,浏览器会自动处理词级别的删除行为,包括Emoji的删除行为也是可以自动处理的。因此针对非受控输入的编辑器例如Quill、飞书文档的实现,是不太需要主动处理相关行为的,主要关注点在于DOM变更后的被动同步状态。
而在我们实现的编辑器中,因为输入的相关实现是完全基于beforeInput事件来处理的,是完全受控的行为,因此我们必须要主动处理删除的行为。实际上在事件中,inputType值是给出了deleteWordBackward和deleteWordForward的,却没有给出默认行为要删除的长度。
因此我最开始是想要么改为非受控输入,要么是通过Intl.Segmenter方法来主动分词实现。然而在看到MDN的DEMO之后,发现这个构造器需要传递语言参数,这样的话在编辑器中是没有办法实现的,编辑器中无法实际确定语言类型。
const segmenterZH = new Intl.Segmenter("ZH-CN", { granularity: "word" });
const string1 = "当前所有功能都是基于插件化定义实现";
const iterator1 = segmenterZH.segment(string1)[Symbol.iterator]();
console.log(iterator1.next().value.segment); // 当前
因此我去找了相关开源编辑器的实现,slate是完全自定义处理的行为,使用getWordDistance来自行计算词的距离。这样对于英文问题不大,但是对于中文词组的处理就比较差了,是以标点符号为准作为切割目标的,因此对于中文实现更像是按句删除了。
而在Lexical中尝试了删除词组的表现则比较符合预期,本来我以为也是非受控的输入,但是查阅源码后发现同样是基于beforeInput事件来处理的。那么这个表现就非常符合浏览器的行为,本来我以为也是基于Segmenter实现,想查看是如何处理语言问题的,发现首参数是可以不传递的。
const segmenterZH = new Intl.Segmenter(undefined, { granularity: "word" });
const string1 = "当前所有功能都是基于插件化定义实现";
const iterator1 = segmenterZH.segment(string1)[Symbol.iterator]();
console.log(iterator1.next().value.segment); // 当前
然而,再细致地查阅源码后发现,Lexical并未直接使用Segmenter来处理分词,而是使用了selection.modify这个API来预处理选区的变更。基于这个API可以同步地变更选区的DOM引用,然后我们就可以立即得到未来的选区状态,因此就可以构造删除的范围。
const root = this.editor.getContainer();
const domSelection = getRootSelection(root);
const selection = this.current;
if (!domSelection || !selection) return null;
domSelection.modify(ALERT.MOVE, direction, granularity);
const staticSel = getStaticSelection(domSelection);
if (!staticSel || this.limit()) return null;
const { startContainer } = staticSel;
if (!root.contains(startContainer)) return null;
const newRange = toModelRange(this.editor, staticSel, false);
newRange && this.set(newRange);
并且在Lexical中还解释了beforeInput事件以及对应的getTargetRanges()方法。由此先前我对于浏览器没有给出默认要删除的长度的判断是错误的,其是通过Range来表达的。但是注释中还提到了这个方案不可靠,其在复杂场景下可能无法正确反应操作后的选区状态。
而对于使用像Intl.Segmenter等按词组分割的工具,比较容易出错,而且需要对于整个Op进行分词,也有很多不必要的计算。不同语言的分词规则差异巨大,例如英文空格分词以及中文无空格分词,自动识别词边界非常困难,尤其是在涉及自动换行和非罗马语言的情况下会非常困难。
总结起来,使用selection.modify方法直接利用了浏览器引擎自身对选区计算的内置、高度优化的逻辑,浏览器如何分词自然是浏览器本身最熟知。此外,这次还发现beforeInput事件的诸多方法,诸如drop的相关事件其实也可以用getTargetRanges + dataTransfer来实现。
最近在简历编辑器中使用编辑器处理富文本时,发现粘贴时的表现有些问题。具体表现是,假如此时复制了列表的多行内容,然后将其粘贴到空行时,单论文本上是没有什么问题的,然而其行格式上的表现是粘贴首行的格式是列表首行格式,末尾行的内容则是普通文本格式。
// 剪贴板内容
[ { insert: "123" }, { insert: "\n", attributes: { list: "bullet" } },
{ insert: "456" }, { insert: "\n", attributes: { list: "bullet" } },
{ insert: "789" } ]
// 原始内容
[ { insert: "abc{caret}def" }, { insert: "\n", attributes: { quote: "true" } } ]
// 粘贴后内容
[ { insert: "abc123" }, { insert: "\n", attributes: { list: "bullet" } },
{ insert: "456" }, { insert: "\n", attributes: { list: "bullet" } },
{ insert: "789def" }, { insert: "\n", attributes: { quote: "true" } } ]
这个表现的根本原因是,我们的行属性是在末尾标记的,而不是在行首标记的,因此看起来表现是我们会把原始行格式挤到后边去。因此看起来非常合理的样子,而在Quill的表现中则是看起来是剪贴板中末尾多了个\n标记,即使选区复制时并未选到末尾。
// 剪贴板内容
[ { insert: "123" }, { insert: "\n", attributes: { list: "bullet" } },
{ insert: "456" }, { insert: "\n", attributes: { list: "bullet" } },
{ insert: "789" }, { insert: "\n", attributes: { list: "bullet" } } ]
// 原始内容
[ { insert: "abc{caret}def" }, { insert: "\n", attributes: { quote: "true" } } ]
// 粘贴后内容
[ { insert: "abc123" }, { insert: "\n", attributes: { list: "bullet" } },
{ insert: "456" }, { insert: "\n", attributes: { list: "bullet" } },
{ insert: "789" }, { insert: "\n", attributes: { list: "bullet" } },
{ insert: "def" }, { insert: "\n", attributes: { quote: "true" } } ]
当然在这里的表现是由于Quill的实现是复制时必然序列化HTML内容,而且并不会像我们的编辑器一样将ops的fragment写入剪贴板中。因此看起来这个数据结构像是在末尾补充了行格式,而如果是复制的是单行内容则在剪贴板中并不会有这个表现。
同样是扁平的数据格式,在EtherPad中的表现其实也是类似的,剪贴板的实现也是基于HTML序列化反序列化的实现,但是细节上的实现是剪贴板的首尾都会是携带\n的换行表现。其实这个表现是因为EtherPad的实现是将行格式放于行首的,可以转换成类似delta的数据结构。
[
{ insert: "*", attributes: { list: "bullet", lmkr: "true" } }, { insert: "123" }, { insert: "\n" },
{ insert: "*", attributes: { list: "bullet", lmkr: "true" } }, { insert: "789" }, { insert: "\n" }
]
这里我在想为什么EtherPad控制行格式的实现是放在行首,而不是将行格式应用到\n上,毕竟这种情况下还需要额外处理很多问题。其实这里很多表现是与渲染相关的,因为无论是列表、引用等行格式的DOM渲染都是在行首的,这样看起来行首放置额外的节点来表现是更合适的样子。
实际上如果额外放置了这个节点,是需要很多额外逻辑处理的,因为行格式本质上是需要和行节点一致控制的,那么就会出现节点不匹配/不一致的问题。此外,此时的行节点文本也会因为出现额外的节点,导致需要判断是否存在该节点来处理offset以及文本内容的计算问题。
// 正常情况
[ { insert: "*", attributes: { lmkr: "true" } }, /* ... */, { insert: "\n" } ]
// 需要在应用变更时处理的情况
[ { insert: "*", attributes: { lmkr: "true" } }, { insert: "*", attributes: { lmkr: "true" } } ]
[ { insert: "text" }, { insert: "*", attributes: { lmkr: "true" } } ]
最初的时候我们就希望设计Blocks模式的编辑器,所谓Block可以认为所有的内容都是以块的形式来呈现的,编辑器是作为外壳来管理和组织这些块结构。可以理解块是类似于积木的形式,而编辑器本身则更像是低代码的模式,本质来上说富文本编辑器就是最好的NoCode编辑器。
那么设计Blocks模式的编辑器,数据结构的设计就非常重要,简单调研了部分编辑器,Notion、飞书文档、Zoom Doc都是以扁平化数据结构的形式组织的模式,Google Doc则是纯线性的结构,而Slate、Lexical、钉钉文档等则是树形结构。
doxcnTYCCCCCCCC: {
id: "doxcnTYCCCCCCCC",
data: {
parent_id: "",
children: ["A", "B"],
text: "Text Content",
},
}
Notion、Zoom的结构也与上述类似,只不过其初始的数据结构并未维护children结构,而是仅有parent_id来表示嵌套关系。钉钉文档的结构则比较复杂,类似于Slate的树形结构,看起来其维护的数据是基于DOM结构直接对应的表达。
[
"root",
{},
["p", {}, ["span", { "data-type": "text" }, ["span", { "data-type": "leaf" }, "123"]]],
[
"table",
{ colsWidth: [325, 325] },
["tr", {}, ["tc", {}, ["p", {}, ["span", {}, ["span", { "data-type": "leaf" }, "88888"]]]]],
],
]
在文档中检查内容时,首先可以输入内容后刷新在network中全局搜索即可,例如钉钉文档。若是搜索不到则考虑清理缓存,但注意不要清理Cookie,Notion就是这种情况。飞书文档由于SSR则可以直接搜索html响应,内容或者id都可以搜索。
此外文本可能会被编码,因此需要考虑搜索block id,再不行则尝试触发协同后搜索变更的节点id,Zoom Doc就是这种情况,内容会被编码不容易搜索。而Google的纯线性结构也是在html中可以取出,但是本身就比较难理解了,下面是表格结构的表达。
"\u0010\u0012\u001c123\n\u001c123\n\u0011".split("").map(it => it.charCodeAt(0))
// [16, 18, 28, 49, 50, 51, 10, 28, 49, 50, 51, 10, 17]
// ------|------
// | 123 | 123 |
// ------|------
16表示表格的起始,18表示行起始,28表示列开始,49/50/51是123这个数字。10表示换行,28同样开始表达列,49/50/51是数字,后续跟随者10表示换行,最后的17表示表格结束。谷歌的这个实现也能表明纯线性结构是可行的,但是实现起来还是很复杂的。
因此在这里我们实现相对简单的扁平数据结构更合适,其本身的嵌套结构是渲染时根据parent_id或者children来动态计算的。文档变更的时候就需要根据选区处理相关的块节点,这里就可能需要考虑块节点的拆分、合并等问题,而若是我们在选区预处理仅处理同级别的节点,事情就会简单得多。
[
{ id: "A", type: "block" },
{ id: "B", type: "block" }
]
最开始我们还聊过Blocks设计的扩展性,典型的表现就是飞书文档的画板扩展,在变更的时候可以发现其协同算法明显不是OT-JSON,而初始化数据时也仅有画板的id。也就是说画板完全脱离文档本身的数据结构设计,协同是自行实现的而非依赖飞书,数据也不需要存储于飞书。
数据完全不存储于飞书其实也是存在优劣的,优点很明显是完全不需要飞书文档来处理相关的变更,只需要提供足够的扩展性即可,分离的模块开发意味着可以有更高的效率。而缺点则是扩展性本身的实现会更复杂,例如操作变换需要提供扩展能力,多种结构设计也会导致体积变大,架构设计需要更加谨慎。
虽然整体数据并不在飞书文档中,但是当关闭画板编辑模式后,可以是可以执行Ctrl+Z来撤销画板的变更,这也就是说历史操作是存在于文档本身的操作栈当中的。而由于本身数据结构并不一致,操作变换也是需要画板本身提供的,因此这里同样是需要文档提供扩展性。
通过断点可以发现飞书提供的扩展性是通过不同的module来体现的,文档本身的undo模块也是独立注册到编辑器的,而画板也是通过独立的模块注册到编辑器。两者分别维护自己的undo栈,而主模块自己维护的栈是完整的内容,因此这种情况下是可以根据id独立调度对应模块来执行撤销。
undoStack: [
{_id: 'module1'},
{_id: 'module2'}
]
// module1 => undoStack: [{...}]
// module2 => undoStack: [{...}]
其实纵观文档数据结构的实现,上述的扁平化结构是通过最外层的节点来组织不同类型的块,例如图片块、文本块等。最外层的变更与协同是通过OT-JSON来实现的,而块内部的变更则是需要注册到子类型中,变更的时候需要同时变更数据本身和文本块实例来实现的。
那么到这里也可以考虑到另一个问题,如果最外层的结构不使用OT-JSON,而是同样使用Delta扩展不同类型来处理协同变更,应该也是可行的方案。如果这样的话虽然不能直接写入children只能用parent_id,但是在状态管理时是可以动态计算的,变更的复杂度会更低。
当然这样也并非没有缺点,在我们的定义的Delta类型中属性值仅支持字符串类型的,因此很多格式与数据在写入的时候扩展性差了一些。此外,Delta数据结构本身的设计冗余性稍多些,因为本身这是为文本变更而设计的,这种情况下就只使用了属性值表达结构。
{
ROOT: [{ insert: " ", attributes: { _ref: "A" }}, { insert: " ", attributes: { _ref: "B" }}],
A: [{ insert: "Block A" }, { insert: "\n"}],
B: [{ insert: "Block B" }, { insert: "\n"}],
}
在选区模块中,遇到了非常棘手的问题,之前的判断是飞书的选区不会跨层级的,即选区选择仅会在同级块上。然而后续发现在纯文本节点的组合中,例如List节点组中,是可以跨层级选择节点的,诸如Quota容器节点组则是不能跨层级的选区。
[
{ id: "A", type: "block" },
{ id: "B", type: "block" },
{ id: "C", type: "text", start: 1, len: 0 },
]
当然这个情况仅在纯文本节点上会出现,若是选择纯块级别的节点则不会出现跨层级选择的情况。例如按下Ctrl+A选择全部内容时,以及通过鼠标拖拽选择块级别节点时,都不会出现跨层级选择的情况,那么在选区处理时就需要处理这个问题。
首先我们仍然需要从浏览器的DOM上处理选区,而浏览器的选区模型仍然是基于Selection API实现的,其表现只有两个端点,因此我们需要根据端点来计算对应的模型选区范围。那么在跨层级选择时,原本的计算方式仅处理同层级的节点,然而此时就需要处理跨层级的节点。
然而在跨层级选择时,浏览器的选区端点完全可能会落于不同层级的节点上,那么在这种情况下就不容易计算出对应的模型选区范围,因为此时我们仅有两个端点。那么在这种情况下,由于渲染顺序需要遵循浏览器DOM的顺序,那么遍历所有节点就类似于前序深度优先遍历。
若是每次选区变换事件都进行一次DFS的话,显然在性能上会差一些,但是实现起来会简单一些,而且我们的createBlockTreeWalker并非直接递归而是使用栈模拟的,并未实际递归查找。不过即使这样,在这里还是考虑了很多相关的注意事项和剪枝条件:
B端点向上查找直到公共父节点,在进行DFS的时候碰到任意容器节点即可认为停止,以此剪枝。DFS渲染顺序的索引,则可以直接遍历该序列来处理选区范围的计算,而不需要每次都进行DFS遍历所有节点。通常仅有ld和li会改变结构,因此在apply的时候可以再维护该序列,并且可以再维护id索引的Map来处理查找速度问题。上述主要是考虑了剪枝以及空间换时间的方案,但是总归是会存在很多无法剪枝的遍历,最好的办法自然是仅需要从A遍历到B的遍历。因此在这里还考虑到通过最近公共祖先LCA来遍历,先从LCA遍历到A,再反转其顺序,再从LCA遍历到B,将其合并即可。
然而如果画个图,则可以观察到这种方式是不行的,在下面的例子中,如果我们选择的内容是从E选到I的话,正确顺序应该是EFGHI。那么此时的公共父节点是A,直接从D节点的遍历上就可以看出来无法组织为正确的顺序,但是通过LCA可以通过从A而非R来DFS以此剪枝。
- A R
- B / \
- C -1 A
- D / | \
- E B D I
- F / / \
- G C E H
- H / \
- I F G
在这里突然想到了先前实现的Canvas简历编辑器的树结构管理模块,依然是通过空间换时间的方式来处理渲染顺序问题,并且实现按需读取。通过递归的形式直接读取单个节点的所有子节点进行DFS遍历,并且将其缓存下来,如果子节点发生变更则沿着parent清理所有的父节点缓存即可。
const getTreeNodes = (): BlockState[] => {
if (this._nodes) return this._nodes;
const nodes: BlockState[] = [this];
const children = this.data.children;
for (const id of children) {
const child = this.state.getOrCreateBlock(id);
nodes.push(...child.getTreeNodes());
}
this._nodes = nodes;
return nodes;
}
const clearTreeCache = (blockState: BlockState) => {
let parent: BlockState | null = blockState;
while (parent) {
parent._nodes = null;
parent = parent.parent;
}
};