为什么 ContentEditable 很恐怖

jopen 10年前

为什么 ContentEditable 很恐怖

我第一次坐在Jacob(@fat)桌子对面时,他直率的问:“你是怎么写一个文字编辑器的?”

我在白板上画了一个树结构,挥舞着手臂,说“这个是如shit一般的编辑界面。“然后我画了一列方框,用箭头指向数组,更多的挥舞手臂,说”这个是一个好的编辑界面。”

Jacob扬了一下眉。

这篇文章是我本应该那时说的,如果我有一年的时间去思考的话。

为什么 ContentEditable 这么恐怖: 一个数学上的佐证

ContentEditable 是一种在web浏览器上进行富文本编辑的本地原生组件. 它是那样让人…伤感.

我会用一些顺手拈来的数学方法来想你证明,目前的ContentEditable的方法是不好的. 这并不是因为我觉得数学是解释这个论点的一个有说服力的方式。它实际上使得这个论点更加的异类话.

但是我真的觉得文本编辑器导致了太多的模棱两可, 就像“所见即所得 (WYSIWYG) 是啥意思?”还有 “当你选择了这段文本并敲下了Enter会发生什么?”这样不明确的问题。公理化的数学是我所知道的能解决模棱两可的问题并对它们进行明确的最佳工具.

那么所见即所得是什么意思呢?一个好的所见即所得编辑器应该满足下面3个定理:

    1.DOM内容和可视化(Visible)内容能够很好地进行映射。
    2.DOM选择和可视化(Visible)选择能够很好地进行映射。
    3.所有的可视化编辑都能够映射到一个从代数上来说封闭的和完整的可视化内容集合上面。

首先,我会解释这3个定理所表达的意思,并且一个好的编辑器为什么要遵守这些规则。但是我们首先要明白:他们是定理。他们最弱的部分是缺少证明。但是我们假设他们是没问题的除非我们能拿出明显的证据来。

其次,我会证明ContentEditable不满足这3个定理。

最后,我会讨论浏览器新的特性和库是怎样针对这些问题的,以及我们怎样在普通的编辑器中处理他们的。

DOM空间是我们能在HTML中表述的所有网页页面的集合。所有页面都能够被表示成一个元素树,而这些树把文本节点作为叶子。

可视化空间(“所见即所得”)是所有可视化页面的集合 — 也就是我们在屏幕上看到的浏览器渲染的一个页面。我们常常会把看起来一样的两个页面的可视化空间认为是一样的。

浏览器渲染引擎是一个把DOM空间映射到可视化空间的一个映射。“映射”是指对某个DOM树 x 进行 Render(x)操作产生所有的可视化页面。

当我们说一个映射在编辑器中表现良好时,我们的意思是这个映射保留了所有的编辑操作(见注1)。更准确的说,如果渲染的定义是明确的话,那么

for all edit operations E, and DOM pages x and y  Render(x) = Render(y)   implies Render(E(x)) = Render(E(y))

这是一种在“你看到的是什么”之后确定“你得到了什么”的方式。如果两个部分看起来是一样的,而我们对它们进行了相同的编辑,那么两种结果应该看起来是一样的. (请再看看第 1 条)

我已经很惊奇的看到网络上那么多的 “WYSIWYG” 编辑器都打破了这个规则. 这听起来应该是一个理所当然的规则。但是它会导致你陷入有关“相同”是什么意思这个有点怪异的问题,而对这个问题的问题的最好探讨就是示例了.

行为良好的内容

看看下面这个例句:

The hobbit was a very well-to-do hobbit, and his name was Baggins.

编辑器里面呈现这句话,大致上如下所示.

The <a href=”http://en.wikipedia.org/wiki/The_Hobbit">hobbit</a> was a very well-to-do hobbit, and his name was <strong><em>Baggins</em></strong>.

有许多许多的方式可以用来对最后一个词—— Baggins —— 进行编码. (见注2)

<strong><em>Baggins</em></strong>  <em><strong>Baggins</strong></em>  <em><strong>Bagg</strong><strong>ins</strong></em>  <em><strong>Bagg</strong></em><strong><em>ins</em></strong>

编辑器应该明智的认为这些形式是等价的。你对这篇文章所进行的任何编辑都应该对所有这种形式同等对待。编写一个编辑动作来了解所有这些不同的DOM形式是需要令人极其惊讶的技巧.

对于网上大量的可编辑内容的实现,一些不显眼的字符或者空的span标签可能会进入到HTML中,因此两个可编辑内容的元素的表现完全不同(即使看起来是一样的)。这样的体验会激怒用户,而工程师也很难去调试。

即使我们知道如何编写一个表现良好的编辑动作,但我们怎么检查它呢?如果我们把HTML限制到一些简单的标签,证明两个表单视觉上是相等的将。。。非常复杂。你最好能够在每个字符上迭代,分配一个样式,并比较结果。

在理想世界里,我们对于DOM的“可视编辑”会有一些高级的API。每个操作将保证它的有效性,对于所有视觉上相等的页面做“相同”的操作。这样,只要你的编辑器仅仅使用这些API,你就能保证它的有效性。

有效性选择

DOM和可见内容的映射是很丑陋的,但至少是多对一的关系。一个DOM表示有一个准确的可见表示。

选择更加丑陋,因为映射是多对多的关系。

你可以很轻松的看到一个可见的选择可以有很多的DOM表示。如果你有HTML,

his name was <strong><em>Baggins</em></strong>

那么“Baggins”之前的指针可以在三个DOM位置之一:在 strong开始标签前,在 strong开始标签和 em开始标签中间,以及在 em开始标签之后。如果你把指针放在“Baggins”之前开始输入,你的字符会是粗体,斜体,或者都不是?

更微妙地,一个DOM选择可以有多种可视化表示。比如下面这种情况,“well-to-do”在“to-”之后换行,如上图所展示的。光标在第一行的末尾和在第二行开头拥有同样的DOM位置,但是却拥有不同的可视化位置。就我所知,我们没有办法让浏览器去优先选择哪一个可视化位置。当我们设计编辑器命令的时候,我们会让选择操作表现的和看起来是一样的。但是那太痛苦了,因为这种映射太混乱了。

封闭并且完整的编辑操作

几年前的某一天, 我的朋友Julie在Gchat上给我发了一个消息:

We can remove Apple Style Span…Oh happy day!

Ryosuke Niwa 在WebKit的博客上发表了一个友好的帖子 ,这篇帖子请求移除苹果风格的span(apple-style-span)。如果你之前读过这篇文章的话,那么他提出的许多问题听上去很耳熟。WebKit的ContentEditable 编辑器增加许多“bookkeeping”HTML标签,这种标签不会改变任何的可视化效果,仅仅是使编辑器表现的不同。

他也指出WebKit的ContentEditable的实现必须能够处理由其他任何的CMS或其他任何的浏览器的ContentEditable 的实现创建的HTML。我们的编辑器在这种生态环境下应该是一个好的公民。这意味着我们应该制作易读易懂的HTML。但另一面,我们要意识到我们的编辑器必须处理那些我们不能在编辑器中创建的拷贝内容。

我见过许多种问题,复现这种问题的唯一方式是在Firefox中写文本,然后切换到Chrome中做编辑操作,再然后切回到Firefox中。这对开发者和用户来说是非常令人沮丧的。

一个好的所见即所得编辑器框架

一个最基本的 ContentEditable 元素是一个非常差的编辑器, 因为它打破了前面提到的所有定理.。那么我们怎样构建一个好的所见即所得的编辑器呢?

对一个编辑器来说,有4个关键点。

  1. 创建一个文档模型,并且能够用一种简单的方式去区分两个模型是否在视觉上相等

  2. 创建一个在DOM与我们的模型之间的映射

  3. 在这种模型上能够定义表现良好的编辑操作

  4. 能够把所有的按键操作和鼠标点击转换成相应操作的序列

我会简要的介绍每个关键点以及我们怎样对他们做出改变。最后,我会讨论浏览器工程师怎样才能把ContentEditable 做的更好,并且去掉这些组件中不好的部分

编辑器模型

编辑器模型有两个领域:一连串的段落以及一连串的区域。

每一个段落包括下面这些内容

  • 文本,一个普通文本的字符串

  • 标记,一连串格式化好的文本范围,比如“对位置1到5的字符进行加粗”

  • 图像或嵌入的元数据

  • 布局,一种我们怎样放置段落的描述

一个区域描述一个子列表段落的背景。

在编辑器中的任何选择操作都会被表述成两个点。每个点代表的是一个段落的索引和那么段落的文本偏移,以及一个类别。大多数选择操作都是文本类型的选择。我们也有媒介类型的选择(当提示信息显示在图片上时)和区域类型的选择(当提示信息显示在区域背景上面)。

这种模型的优点是如果仅当这两种模型相等时他们会有同样的可视化渲染效果。对模型的任何改变都能转换到一个定义很好的可视化改变上来。

编辑器映射

下面我会定义DOM空间到这种模型空间的映射。我们把这种映射分成两种:“室内”映射和“室外”映射。

室内映射是指我们从编辑器中取出内容并且来回的在DOM空间和这种模型空间进行映射。我们希望室内映射是一对一的。

室外映射是指我们从编辑器外获取HTML,就像用户从Word中拷贝HTML到一篇帖子中一样。我们需要把它转换到我们的段落和区域模型中。我们希望室外映射是有损的。我们优先处理普通文本,然后加粗/倾斜/超链接标签,然后图片以及其他各种各样的格式。

我们的模型映射到DOM树后,看起来像下面这样:

<div> <!-- root -->    <section> <!-- section -->      <!-- section-inner -->      <div class="section-inner layout-column">        <p>  <!-- paragraph -->          <strong><em>Baggins</em></strong> <!-- text -->

这个区域(section)节点是从区域模型中产生的,并且会将背景图片和颜色应用到一连串的段落中。

这个区域内(section-inner)节点是根据段落排版属性而产生的,并且决定了主列的宽度。对于大部分段落来说,它是狭窄并且居中的。对于全宽的图片段落来说,它是100%宽的。对于上面的网格来说,它是原始的一般。

下一个节点是段落的语义类别: P, H2, H3, PRE, FIGURE, BLOCKQUOTE, OL-LI (有序列表项), 和 UL-LI (无序列表项).

当我们把标签类别转换到DOM节点的时候,我们会按照类别排序它们:A,然后 STRONG,再然后 EM。我们永远不会打印一个包含锚定的STRONG标签。我们会拆散它而让锚定包含STRONG标签。

编辑操作

编辑器主要包括6个编辑操作:插入段落,移除段落,更新段落,插入区域,移除区域,以及更新区域。

这些操作表现的会和描述的那样。段落操作会接受一个段落模型和一个索引。区域操作会接受一个区域模型和一个索引。

所有可能的编辑器内容都能够被一系列的这些操作所表述,并且构造这样一个序列通常是比较简单的。

显而易见,内容在这些编辑操作下能够表现的很好。这些操作是直接应用于我们的模型上面的,而不是DOM上面,并且这个模型能够更容易地区分两件东西在视觉上是否是相等的。

捕获编辑操作

当你和编辑器交互的时候,我们必须将你的按键操作和鼠标点击操作转换成那6个操作的一个序列。

这是最复杂的部分。我们不会对那么多的每种可能按键的序列履行职责。这对于一个以英语为语言的用户来说是一个非常巨大的列表,永远不考虑非拉丁字符和键盘。

通过观察,我们能够用常规的ContentEditable键盘操作枚举所有对段落插入和移除的操作方式。他们是:回车(enter,ctrl-m,等。),删除(delete,backspace,等。),悬浮输入(type-over)(在一段选择的文本上输入),以及拷贝。所以我们能够捕获,取消,以及手动将这些键盘事件转换到我们的编辑器内部操作上来。

对于所有其他键盘事件,我们让原有的ContentEditable 行为生效。在键盘事件结束之后,我们把段落的DOM映射回到段落的模型中来,并且和我们之前的模型进行比较。如果DOM改变了,我们会创建一个新的更新段落操作并且通过编辑器管线应用它,保持DOM和模型同步。

快速捕获编辑操作

如果我们有无限计算的能力,那么直接应用这些编辑操作就可以了。我们应用这些操作到模型上,重新渲染的整个页面,最后结束操作。

但是在现实生活中,对每个按键操作都重新渲染整个页面是非常慢的。并且你会看到许多丑陋的闪烁现象,因为内嵌框架(iframes)和图片会一直处于加载中。相反,我们会对模型的改变事件进行监听,并且尽最大可能减少对DOM的改变。

当我在写篇文章时,我可以看到Chrome拼写检查程序在“keypress”单词下面所加的红色下划线在闪烁。这是因为编辑器正在同时对整个段落进行改变,而不是仅仅改变这个段落的一小块。如果我们仅仅对DOM进行相对很小的改变,那么闪烁就会消失,但是这样的代码会相对的更复杂。

期待将来有一个更智能的文本编辑操作

最近有一些来自Chromium贡献者 (Levi Weintraub, Julie Parent, and Jelte Liebrand) 的流言说这些贡献者想去基于聚合元素(Polymer Elements)和Shadow DOM特性重做ContentEditable。这个方案也会像编辑器一样去尝试解决许多同样高级架构方面的问题。

  1. 创建一个由自定义 聚合元素(Polymer elements)构成的编辑器模型

  2. 定义编辑器模型与真实的具有 Shadow DOM特性的DOM之间的映射

  3. 所有在ContentEditable中的按键操作和鼠标点击操作都会被转换成一种抽象的编辑含义,被表示成像{editIntent: ‘delete’}这样的JSON对象。

  4. 聚合元素(Polymer Elements)对这样的编辑含义操作定义相应的处理方法

如果编辑器能够获取某种编辑含义的API,那么我们就能够抛弃许多转换按键操作到抽象编辑操作的自定义代码了。将我们的段落模型当作聚合(Polymer)/ ShadowDOM元素是一件很有趣的尝试。

ContentEditable是什么

不管我什么时候向那些从事于文本编辑器工作的人解释的时候,他们都认为我在玩花招。

“当然编辑器要比ContentEditable更棒一些。你错了。ContentEditable努力的去成为一个通用的所见即所得HTML编辑器。而一般的编辑器放弃了'通用目的'的需求,所以你能够挑选你想去处理的任何HTML结构。”

这是事实。但是确是误导的。

一个好的所见即所得编辑器和一个好的具有通用目的HTML编辑器在理论上是不一致的。不可能把ContentEditable 构建成那样的,因为它们在需求上是冲突的。

Steve Yegge的那篇“The Nonesuch Beast”文章影响了我的想法。 设计和UX(用户体验)问题与big-O算法问题一样地棘手。一个好的WYSIWYG任意HTML编辑器与halting问题一样不可能。

ContentEditable可以挽救。 但它的使命必须改变。 丰富的DOM API像Shadow DOM一样,ContentEditable可能成为一个平台用来构建新一代的网络编辑器。但我们必须把它作为一个编辑平台和API,相对于独立的组件总比什么都自己做要好。

脚注:

  1. 如果你学过本科的高等数学, 一个良态的映射是一个态射。 那个单词感觉是费解的, 还带来了额外的包袱,在这里它不能帮助我们。 所以这次我们使用了良态。

  2. CSS复杂化这个讨论,和复杂的编辑算法,它的浏览器算法已经被实现。但是它不是一个彻底的证据证明,可以用来证明为什么ContentEditable是可怕的。 一般来说,我们忽视CSS, 也会限制我们对简单HTML的分析。