(翻译)用 Javascript 在浏览器里面虚拟一个可编辑的控件

此文翻译自 Marijn HaverbekeFaking an editable control in browser JavaScript

你当前的问题是:你正在写一个和文本输入控件(Text intput Filed)相似的Javascript 控件——它必须是 focusable 的,而且要支持复制和粘贴,接受任意类型的输入,但是它又不是一个真正的控件,因为你想去自己画,而且可以完全控制它的内容。

在这篇文章里面,我不想讲任何关于画一个指针(Cursor),维护自己的 selection 之类的东西。当然,对于实现一个可靠的文本编辑器来说,这些东西都是必需的。但是他们实现起来都相对简单。

隐藏的 Textarea

我的解决方案的结症在于一开始我从 ACE editor 获得的灵感,让我围着一个隐藏的 textarea 团团转。这就是当 textarea 看起来是 focused 的时候,浏览器就认为它是 focused 的。它就像一个常规的 focusable 的东西,你可以赋值一个 tabindex 给它,让它得到或者失去 focus 的时候接收 focusblur 事件,这允许我们去更新编辑器的样式(显示/隐藏的指针,彩色/黑白的选区)来反映出它是否是 focused 的。

然而这个 textarea 一定不能是可见的。因为如果当你输入的时候,这个 textarea 一直跟着你,而且还带上它那个自带的一闪一闪的光标的时候,这个编辑控件就会显得毫无真实感。

不过,如果你给你的编辑器设置 display:none 或者 visibility: hidden 属性的时候,浏览器会觉得它不是这个页面真正的部分从而不会去 focus 它。CodeMirror 让 textarea (变小)放在一个 overflow: hidden; height : 0 的元素(div)里面,这就让它即不可见,也可以 focus,从而解决了这个问题。

(另外,你需要一个 outline: none 样式,去防止一些浏览器在它 focused 的时候显示一圈光环,但却某些原因没有被 overflow: hidden 禁止掉)

另外一个不幸的(或者说是幸运的,取决与你怎么看)在编辑器上效果就是浏览器每次都会滚动来提醒我们编辑器有事件发生,这就意味着,如果 textarea 简单地放在编辑器地顶部,然后你就会不停地滚动到顶部因为你正在编辑编辑器地步地内容,每当你输入一个字符,窗口都会滚动。

CodeMirror 给 div 元素设置了绝对路径来隐藏 textarea ,每当指针移动的时候, textarea 也会跟着移动。这样的话,之就会帮助滚动到真正的视图。

保持选择区域

当用户选择了一些文字,然后复制或者剪切,正确的文字会被放在粘贴板里面。

这就意味着选区里面的文字要被正确地放在 textarea 里面,和选择它们。达到这个目的有两种方法。第一种,被ACE采用了,就是去监听 copycut 事件(在真正复制和剪切之前就会被触发),仅当这样地事件触发的时候,把当前选择地文字插入到 textarea 里面然后选择它。

CodeMirror 的实现方法并没有那么聪明,不过更实用。它就是简单地在每一次选择的时候把当前选择的文字复制到 textarea 里面。这种做法的一个缺点就是当你从从 textareavalue 里面获得(get)和设置(set)许多文字的时候,速度会很慢。如果你在 CodeMirror 里面放一个很大的文档,然后按 Ctrl-A 或者 cmd-A (全选),这将会有一个可以察觉的停顿。(在一些老的浏览器上,取决于文档的大小,这看上去可能更像整个浏览器卡住,而不是一个短暂的停顿)

但这个做法的优势就是它适用于 Opera 浏览器,Opera 浏览器不会触发 copycut 事件,在 Linux 上某些浏览器,这种做法可以和 X Windows 的选择粘贴板很好的兼容。CodeMirror 会花更大精力去缩小 textarea.value 的消耗,例如当一个选区拖动的时候不去更新,在拖动完成后才去更新。

感知输入

因此隐藏的 textarea 包含着当前选区,它的内容被选取了。这意味着当用户输入一些东西,或者粘贴文字,textarea 当前内容会被插入文字(如果之前有选择文字的话,就会覆盖它们),然后文字会被插入到真正的文档上面指针的位置当中。

不过谁告诉我们输入发生了?一开始,我们可以监听 keypress, paste, input这样的事件。这些事件会告诉我们有些事情发生了,这样,我们设置一个延迟,然后在几毫秒之后检查 textarea 的内容。

不过这并不完美的。Opera 不会触发 paste 事件——当你从菜单粘贴时,也不会有任何鼠标事件被触发。另外,在一些浏览器上面,输入法输入的时候 textarea 不会触发任何事件。

所以我们必须检测。然而如果我们检测次数过多从而每次从 textarea 读取数据(可能会很大),那么检测的代价可能就很昂贵了。幸运的是,如果 textarea 有一个很大的值(选择的文字),那些文字会被选择(selected),而输入文字就会覆盖它们。因此,如果 textarea 有一个选区(检查的代价很低),它的值不必被读出。这就会让检测的代价降低,从而允许 CodeMirror 去当编辑器focused 的时候密集地检测也不会消耗太多 CPU(当编辑器失去聚焦,停止检测)。

关于输入法

我在上文提到过输入法。我不是这方面的专家,因为我不说任何需要用到输入法的语言。不过,这允许人们在写很长的脚本的时候,用一系列快捷键去输入字符。这就需要实现在编辑器展示当前的输入法正在候选的字符(composition),然后在输入法输入完成的时候,用真正的结果去替代它。

如果 CodeMirror 在每次读入输入的时候都去清空 textarea,这就会留下还没完成的输入法的输入。那它真正的做法是,当没有选区存在的时候,把当前输入留在 textarea,然后把它的值(value)存在别的地方。然后下一次检测的时候,它就会用新的值和和旧的进行比较(切除共同的前缀字符),这意味着之前的值被新的值替换,新的值应该替代掉文档里面那些旧的值。

Drag and drop

现代的浏览器都提供了 drap and drop API。这就会让我们的编辑器更加方便地支持在编辑器里面放置(drop)内容,拉取内容离开编辑器。这里面有一点细微之处,这是 CodeMirror 的 dragstart 的handler:

on(node, "dragstart", function(e) {
  // Set the dragged data to the currently selected text
  e.dataTransfer.setData("Text", editor.getSelection());

  // Use dummy image instead of default browsers image.
  if (e.dataTransfer.setDragImage)
    e.dataTransfer.setDragImage(document.createElement('img'), 0, 0);
}

setDrapImage 方法被调用的时候,抑制了默认的拉动的映像,阻止了一些浏览器显示整个编辑器都被拉动了,因为外部元素被设置成 draggable=true 的。

在 CodeMirror 的 mousedown handler里面,我也在不在选区(selection)里面的点击调用了 preventDefault(),这样拉动产生的选区就不会触发下一次拉动(dragging)。在 Webkit 引擎里面,这是必须的,因此,你不仅在处理 mousedown 事件的时候把 draggable 属性设置成真,还要在之后把它设置回来。

编辑器里面 drop 事件的 handler 支持从 FileReader 读取文件,可以控制被拉进编辑器的文件。

全局菜单

全局菜单就像蛋糕上的糖霜一样,一个好的编辑器在被右键的时候应该表现正常才行。全局菜单应该包含可以工作的复制,剪切和粘贴按钮。

不幸的是, 浏览器没有提供 API 去操作全局菜单。你可以捕捉点击事件和展示你自己的菜单,不过这样并不好,而且更坏的是,你没有权限去使用粘贴板,即使是正确地实现复制、粘贴你也做不到。

就像浏览器领域一贯地风格一样,这里有一个极差地实现方法去弥补API的不足。这次,我们可以通过短暂地不隐藏 textarea(给它一个很低地透明度,以及没有边界来不让别人发现它)来响应鼠标点击事件和 contextmenu 事件,然后把它放在鼠标指针下面。

因为 textarea 已经包含了当前地选区地内容,以及,如果它有一个选区,它地左上角(我们把它放在指针下面),就是选区地位置。现在浏览器就会相信我们点击在 textarea 的选区上面,然后就会提供我们想要的菜单。即使这个结点在几毫秒之后隐藏了,这个点击也会和它联系起来,随后的粘贴也会应用到我们的 textarea 当中。

有一件事要注意的是,Firefox 在打开全局菜单之后会触发 contextmenu 事件,不过这还是让我们太晚知道 textarea 被点击了。因此在那个浏览器上面,我们就蹩脚地使用 mousedown 来代替它(右键按下后就会被触发)。

关于全局菜单我们要实现地第四项就是全选。为了做到这个,我们在内容地开头加了一个假的、没有被选择的空间。然后定期进行检查看看这个控件是否被选择。如果它被选择了(剩下地内容完好无损),我们就选择编辑器里面所有内容。如果 textarea 别的东西改变了,或者时间过得太长,我们就放弃。

弯路

对于没有输入的键盘事件,例如指针移动按键,CodeMirror 简单地处理原始的事件然后内部展现合适的选区效果。

一开始 CodeMirror 版本 2 采用了一个不同的实现方法,这个方法非常讨人喜欢,但是最后并没有成功。它不但把选区放进 textarea,也把附近的几行放进去,让本地的指针也可以自由地移动。这将会不仅从 textarea 得到输入,也可以得到选区(selection)信息。

这有一个有点就是使用了浏览器“本地”的按键绑定。这对于一些特定的按键绑定来说是可行的,也把一些复杂的选区操作交给了浏览器。

我们最终抛弃这个做法是因为它需要太多的hack做法来正常工作,比如说,当你在 textarea 选择一段文字的时候,你不能设置外链。

当你按下 shift-left 或者其他 shift 移动的时候,外链不会移动。浏览器假设它永远在选区的左边当我们通过 selectionStartselectionEnd 设置选区的时候。为了让选区可以正常工作,我们必须用很多痛苦和蹩脚的做法。

另外,似乎很少用户会在他们的浏览器里面真正地重新配置按键绑定(keyboard binding),有趣的是,用户更喜欢 CodeMirror 提供自定义按键绑定而不是在他们的浏览器里面重新定义。

最后,控制我们自定义键盘事件的额外的复杂度似乎没有上面所述的这种做法的复杂度大。因为 CodeMirror 使用 textarea 只包含选区这种做法,而不必去解决 cursor-motion 按钮事件。