Z

富文本编辑器中代码输入区域的处理

作为一款富文遍编辑器,良好的输入代码功能是不可缺少的,但这个功能实现却异常的坑,没见过比这还坑的,这还只是chrome,safari,firefox三个浏览器,再加上IE简直就是要命的节奏。

坑主要原因是三家浏览器对设置contentEditable属性后的元素在按下return(enter),tab,space以及非26个字母按键的动作不同而导致生成的dom结构不同,不得不说在这方面firefox是最棒的,实现方便简单,一改我以往对firefox的印象,safari和chrome的表现比之前处理BLOCKQUOTE时更差。因为他们的渲染引擎都是webkit或源自webkit(blink),所以使用这些渲染引擎的国产套壳浏览器的表现肯定如出一辙,靠!不过在坑了快6小时后我终于想到了一个解决办法,也算是这几个小时没有白坐,下面记录下我这几个小时的心路历程。

代码输入区域顾名思义就是输入在编辑器中输入代码的区域==,当然也是输入字符,但起码得做的有点像个文本编辑器,不能像下面这样:

开发思路:点击代码按钮后,使用execCommand生成pre元素,为pre元素设置一定的特殊样式,来模拟一个文本编辑器。之后所有的操作都在pre标签之内,直到再次点击代码按钮退出代码输入模式。

思路虽然简单,但每一步都是坑。首先你需要生成pre元素,并把光标置于pre之间,这步还用之前说过的execCommand,第一个参数变为FormatBlock,表示更改当前位置行的包裹元素,使用这个模式必须提供第三个参数(execCommand接受三个参数,前面使用改变粗细,斜体那些的命令因为只需提供第一个参数,所以后两个都可以省略),就是你想改变成的那个元素的nodename,所以整个命令就是下面这样:

1document.execCommand("FormatBlock",false,'<pre>');

等等,说好的nodename呢,怎么成了标签了?因为IE不支持写成nodename,必须写成这样。好在其他三个浏览器也支持这种写法,所以统一写成这样,也不用做判断啦。你可能会问,为毛不写成<code>呢,这不是更直观吗?因为FormatBlock的第三个参数只支持块级元素,块级元素中能跟代码块沾点边的也就是pre了,所以就写成了pre。

下面是对新生成的pre增加些样式,让他看起来更像代码编辑器,更换下背景色和字体好了。在execCommand后紧跟着写

1window.getSelection().getRangeAt(0).commonAncestorContainer;

可以获取刚生成的pre,可以对他增加样式,这部分比较简单,不在赘述。

下面要说下字体的问题,因为代码都是英文字符,所以webfont就随意走起啦,不用担心字体文件过大的问题。我使用了MenloMonaco这两个字体,本来打算用我最喜欢的Ubuntu Mono的(博客的英文部分就是),但是他的14px比前面俩的14px看上去小多了,调成16px中文又太大,所以就放弃了。webfont的格式推荐下面三种:woff,woff2,eot。woff和eot结合起来可以涵盖ie8+及所有现代浏览器,不过如果不在乎ie8的话可以只用woff,woff支持ie9+及所有现代浏览器,增加了eot只是增加了对ie8的支持。woff2现在支持的浏览器比较少,只有chrome38+和firefox39+,但他有较高的压缩率,相比woff可减少20%左右的体积,所以它是首选,好在@font-face的src支持写多种格式,这样就不用下载多余的格式了。 同时还要推荐个字体转换的网站fontsquirrel,上传ttf格式,可下载多种webfont格式,而且压缩率极高,相比everythingfonts能减少80%左右,fontsquirrel生成的所有webfont的zip集合包比everythingfonts生成的woff2还小的多。 现在样式差不多完成了,就是下面这样,比较简洁。

接着继续输入逻辑的编码。 现在又要入坑了==。在firefox中,生成pre后所有的按下return(enter)键,换行都是在pre内部的,就是下面这样: 但在逗逼chrome和safari在按下return(enter)后是新生成一个pre,就成了下面这样: 所以现在要把return替换为return(enter)+ shift,因为后者组合在safari和chrome中是换行。 解决办法:监听键盘事件,收到return按下后使用 e.preventDefault() 阻止事件继续发生,这样按下return就没用了,接着插入br,同时将光标移至新生成的一行。代码如下:

 1var area = document.querySelector('.edit-area');
 2area.addEventListener('keydown', function(e){
 3    var which = e.which;
 4
 5    if(which === 13){
 6      e.preventDefault();
 7
 8      var selection = window.getSelection(),
 9          range     = selection.getRangeAt(0),
10          br        = document.createElement('br');
11
12      // 插入br,设定选区
13      range.insertNode(br);
14      range.setStartAfter(br);
15      range.setEndAfter(br);
16
17      // 将光标移到新生成的一行
18      selection.removeAllRanges();
19      selection.addRange(range);
20    }
21  });

这样就把return的功能变为换行啦,但还有个问题,就是当一行已经输入字符后,需要按两下return才能换行,而没有输入字符时按一下就能换行。仔细观察chrome dev tools后终于找到了原因:chrome/safari要换行成功的前提是,dom结构中的最后一个元素为br才能换新行。 当在一行输入过时,那一行的最后一个字符为你更输入的最后一个字符,按下return后只插入了一个br,因为这个br前面还没有br,所以不能换行,情况如下图:

,再次按下因为前面已经有新生成的br了,这时才能换行,情况如下图:

艹,真给这机制跪了。找到了原因,就好解决了。只要给safari和chrome插入两次br就好了,修改上面的代码,在range.insertNode(br);range.setStartAfter(br);两行间插入如下代码:

1if(w.chrome || w.navigator.vendor.indexOf('Apple') === 0){
2  range.insertNode(document.createElement('BR'));
3}

这下按两次换行的问题就解决了,但又引发了新问题==。当一行什么都没有输入时,这行是自带一个br的,再插入两个br,就换了两次行。所以还需要对行内的内容进行判断,下方为完整解决方案:

 1var nselection = window.getSelection(),
 2    which = e.which;
 3if(nselection.baseNode.nodeName === 'CODE' || nselection.baseNode.parentNode.nodeName === 'CODE'){
 4  // 在输入代码功能中把return替换为return + shift
 5  if(which === 13){
 6    e.preventDefault();
 7    var selection = window.getSelection(),
 8        range     = selection.getRangeAt(0),
 9        br        = document.createElement('br'),
10        br2       = document.createElement('br'),
11        // 获取上一行的输入
12        input     = range.commonAncestorContainer.nodeValue,
13        lock      = true;
14
15    // 插入br,设定选区为br
16    // 插入两个br的原因是safari与chrome当前光标的前一个元素为br时才会换行
17
18    var node  = selection.extentNode;
19    if(node.nextSibling && node.nextSibling.nodeName === 'BR'){
20        lock = false;
21    }
22    range.insertNode(br);
23    if(w.chrome || w.navigator.vendor.indexOf('Apple') === 0){
24      // 当一行没有输入字符时,input则为null,则不插第二个br,防止出现按一次return却换两行的情况,这情况只出现在一行没有字符的情况下
25      // 当一行有输入字符时,插入br,保证按一下就换行,而不是得按两下
26      if(input){
27        if(lock){
28          range.insertNode(br2);
29        }
30      }
31    }
32    range.setStartAfter(br);
33    range.setEndAfter(br);
34
35    // 跟踪光标至新加的一行
36    selection.removeAllRanges();
37    selection.addRange(range);
38  }
39
40}

上面的代码只用于chrome和safari,所以还要对浏览器进行判断。 现在问题已经基本解决了,三大浏览器都换行正常。 接着要实现tab缩进的效果。如果tab键不做处理的话,按下整个输入区域会失焦,所以要阻止tab键事件的发生,插入两个空格,代码如下:

1if(which === 9){
2  // 在输入代码时改变tab键的功能,变为缩进2字符
3  e.preventDefault();
4  d.execCommand('inserthtml', false, '&nbsp;&nbsp;');
5}

整个结构还是在上面的keydown事件中。

现在整个代码输入功能的的核心问题已经解决,但还是有不完美的问题,之前说过换行问题还是有问题的,问题就是当输入了一对大括号后,将光标移到中间,按return,大括号会变成两行,这时按下逗号或其他标点符号,再次按return,这时三个符号各占一行,这时光标移到逗号后,按下return,结果换行换了两行==。 而且还有如何取消代码编辑的问题,要把一块pre变为div,但要保持里面的结构,这很简单,获取pre中的内容插入到新创建的div中,在pre前插入div,删除pre,但这么做会使光标移到边界界面外面去。。我也不想解决这个问题了,真无力了==,实际在开发过程中遇到的问题比上面说的多多了。就在我决定明天再写准备去吃饭时,脑子里灵光一现,想到了个解决光标便宜以及方便退出代码模式的方法,就是点击代码输入按钮时创建一个code元素,将它的display设为block,插入代码输入区域,然后将光标定位至code元素中,这样就成啦,退出代码输入模式也很简单,移除code上的.code类,就好啦。这种方法相比之前可以插入任意元素,解决了光标偏移,良好的兼容性,下面是点击代码按钮后的代码:

 1// 按钮点击事件回调
 2// func返回当前按钮的功能
 3if(func === 'code'){
 4  // tool-bar-btn-checked为按钮按下的效果,检查有没有此类就能知道在什么状态
 5  if(btn_code.classList.contains('tool-bar-btn-checked')){
 6    var code = d.createElement('CODE'),
 7        selection = w.getSelection(),
 8        range = w.getSelection().getRangeAt(0);
 9
10    code.classList.add('code');
11    code.setAttribute('contenteditable', true);
12
13    range.insertNode(code);
14
15    // 一定要创建新的range和selection,否则firefox下没发正确插入光标
16    var new_range = d.createRange(),
17        new_selection = w.getSelection();
18
19    new_range.selectNodeContents(code);
20    new_selection.removeAllRanges();
21    new_selection.addRange(new_range);
22
23  }else{
24    var selection = w.getSelection(),
25        range     = d.createRange();
26        div       = d.createElement('DIV');
27
28    editarea.appendChild(div)
29
30    range.selectNodeContents(div);
31    selection.removeAllRanges();
32    selection.addRange(range);
33  }
34}

ok,大功告成。连写博客带解决坑用了快10小时,头还真是有点晕==,要歇一歇了。