天,请注意时效性有的时候我晚上躺在床上,狂热地寻找新的途径,以让自己承担更多只有微薄收入的责任。然后我就想到了,我应该开始另一个开源项目!
好吧,上述情况并不是真的发生了,但结果是一样的:我不断地构建复杂的、高难度的代码,然后放弃它。实际上,这个流程机制通常是,我首先会想到了一些技术概念,然后调查后发现它还没有被做过,最后我为了满足一些好奇心和自我价值的实现,决定要看看我是否 能够 做到。
上述机制产生了这个最新的「祸害」(虽然我并不是要放弃它):ProseMirror,一个基于浏览器的富文本编辑器。我通过众筹的方式把它开源,并且考虑了一下如何让发布后的维护工作持续下去。
一个编辑器?
我不是刚说过要实现那些「还没有人做过」的事情吗?现在不是至少有上百个基于浏览器的富文本编辑器吗?
是的,是的,是的。但是,现有的项目中没有一个采取我认为是理想的方法。它们中的许多都牢牢地扎根于旧的模式,即依靠 contentEditable 元素,然后试图理清所产生的混乱。这让我们对用户和浏览器对我们的文档所做的事情几乎没有控制。
我们需要控制什么呢?首先,一个富文本编辑器应该能够让我们能更容易将文档保持在一个合理的状态。如果文档只被你的代码修改,你可以定义这些修改,使它们保留你想保留的不变性,而且你可以确保在不同的浏览器上发生同样的事情。
但更重要的是,它允许你以一种更抽象的方式来表示这些修改,而不仅仅是状态的变更:「这里发生了一些变化,因此有了一个新的文档」。而抽象的方式来表示修改在协同编辑时是非常有帮助的—-有效地合并来自多个用户的冲突的修改,这有助于准确地表示修改的 意图。
基本实现方案
ProseMirror 确实创建了一个 contentEditable 元素,它在其中显示其文档。这让我们可以自由操作所有与焦点和光标运动有关的逻辑,并使其更容易支持屏幕阅读器和双向文本。
对文档所做的任何实际修改都是通过处理合适的浏览器事件来捕获的,并转换为我们自己对这些修改的表述。这对于相对现代一点的浏览器而言,大多数类型的修改抽象描述起来都很容易。我们可以处理按键事件来捕获输入的文本以及诸如退格和回车之类的东西。我们可以处理剪贴板事件,使复制、剪切和粘贴正常运行。拖放也是通过事件来实现的。甚至 IME 输入也可以触发相对可用的组合事件。
不幸的是,在一些情况下,浏览器不会触发描述用户意图的事件,而你得到的只是一个事后的 输入
事件的结果。例如,当从拼写检查菜单中选择更正时,以及使用组合键输入特殊字符时(例如,在Linux上使用 「Multi + e + =」来输入「€」,都会发生这种情况。幸运的是,到目前为止,我遇到的所有这种情况都只涉及简单的、字符级的输入。我们可以检查 DOM,将其与我们对文档的表述进行比较,并从中得出预期的修改。
当进行修改时,编辑器对文档的表示就会改变,然后显示(屏幕上的 DOM 元素)就会更新以反映新的文档。通过为文档使用一个持久化的数据结构—-使修改创建一个新的文档对象,而不改变旧的对象—-我们可以使用一个非常快速的文档差异算法,只进行实际需要的 DOM 更新。这有点类似于 React 和它的各种衍生产品所做的,只是 ProseMirror 是用它自己的文档表示,而不是用一个通用的类 DOM 的数据结构。
编辑器文档
这个文档表示法当然不会是 HTML。不过它同样也是文档的「语义化」表示:是一个树形的数据结构,以段落、标题、列表、强调、链接等方式描述文本的结构。它可以被渲染成 DOM 树,也可以被呈现为 Markdown 文本,以及其他任何恰好能够表达它所编码的概念的格式。
这个表示法的外层,即处理关于段落、标题、列表等的部分,在结构上特别像 DOM—它由带有子节点的节点组成。段落节点(和其他块级元素,如标题)的内容被表示为内联元素的平面序列,每一个都有一组与之相关的样式。这比全盘使用像 DOM 那样的树状结构要好。它使我们更容易追踪到不变的部分,比如不允许文本被使用强调标签包裹两次,并允许我们将段落中的位置表示为简单的字符偏移,这比在树中的位置更容易推理。
在段落之外,我们不得不使用树状结构。因此,文档中的一个位置由一个路径表示,它是一个整数序列,表示树中每一级的子索引,以及这个路径末端的节点的偏移量。这就是光标位置的表示方法,也是记录发生修改的位置的方法。
ProseMirror 目前的文档模型反映了 Markdown 的模型,完美支持可以用该格式表达的东西。在将来的时候,你将能够扩展和定制你想在某个编辑器实例中使用的文档模型。
用户界面
目前市面上的编辑器有两种风格的用户界面,一种是顶部的经典工具条,另一种是在你的选区上方显示工具提示,以设置行内样式,以及一个在当前选择的段落右侧有一个菜单按钮,用于块级操作。我比较喜欢后者,因为当你不使用它的时候,它不会消失不见(不会丝毫影响你的文档),但我估计很多人更喜欢熟悉的工具栏。
以上这些用户界面都是作为编辑器核心之外的模块实现的,其他的用户界面风格也可以在相同的 API 之上实现。
键的绑定也是可配置的,遵循 CodeMirror 的模式。键被绑定的功能可以作为被叫做「命令」的方式使用,也可以通过 execCommand
方法从脚本中运行。
最后,有一个叫做 inputrules
的模块,可以用来指定当输入匹配给定模式的文本时应该发生什么。它可以用于像「智能引号」这样的事情,也可以在你输入「1.」并按下空格时创建一个列表。
协同编辑
我之前提到过协同。这个项目所做的大量工作就是为了使它支持实时协同编辑。我写了另一篇关于技术细节的博文(已被翻译成中文),其中概念大致是这样的:
当对文档进行修改时,它会创建一个新的文档以及一个位置映射,将旧文档中的位置映射到新文档中的位置。比如,为了响应修改而移动光标的时候。
能够映射位置,就有可能通过映射应该应用的位置,在其他修改的基础上「rebase」修改。还有其他更多的东西,我因此重写了好几次才把这个系统写好,但我很有信心,我最终得到了一些运行符合预期的代码。
在一个协作的场景中,当一个客户进行修改时,他们会在本地进行缓冲,然后发送到服务器上。如果另一个客户端在我们的修改到达之前提交了它的修改,服务器会回应说「不,先应用我们的这些修改」,然后另一个客户端就会接受这些修改,在它们的基础上重新建立自己的修改,然后再试一次。当修改通过时,它们就会被广播给所有其他客户端,确保每个人都保持同步。
目标用户
谁适合使用 ProseMirror?
-
一方面,那些使用 Markdown 或一些类似格式输入的网站可能希望为他们的技术性不强的用户提供一个更容易学习的界面,然后只需将结果转换为 Markdown。
-
另一方面,那些一直提供传统的富文本输入,但又想控制输出内容的网站可能想转到 ProseMirror,因为让编辑体验直接反映和执行你的约束,比清理混乱的HTML并希望得到最好的结果要好得多。
-
最后,那些希望支持协同编辑同时想让用户在谷歌文档上做的事情转移到在自己的产品上进行文档编辑的公司。
听起来很有趣?看看这个开源项目的众筹活动是如何进行的。