zhengrenzhe's blog   About

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

作为一款富文遍编辑器,良好的输入代码功能是不可缺少的,但这个功能实现却异常的坑,没见过比这还坑的,这还只是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,所以整个命令就是下面这样:

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

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

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

window.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,同时将光标移至新生成的一行。代码如下:

var area = document.querySelector('.edit-area');
area.addEventListener('keydown', function(e){
    var which = e.which;

    if(which === 13){
      e.preventDefault();

      var selection = window.getSelection(),
          range     = selection.getRangeAt(0),
          br        = document.createElement('br');

      // 插入br,设定选区
      range.insertNode(br);
      range.setStartAfter(br);
      range.setEndAfter(br);

      // 将光标移到新生成的一行
      selection.removeAllRanges();
      selection.addRange(range);
    }
  });

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

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

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

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

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

var nselection = window.getSelection(),
    which = e.which;
if(nselection.baseNode.nodeName === 'CODE' || nselection.baseNode.parentNode.nodeName === 'CODE'){
  // 在输入代码功能中把return替换为return + shift
  if(which === 13){
    e.preventDefault();
    var selection = window.getSelection(),
        range     = selection.getRangeAt(0),
        br        = document.createElement('br'),
        br2       = document.createElement('br'),
        // 获取上一行的输入
        input     = range.commonAncestorContainer.nodeValue,
        lock      = true;

    // 插入br,设定选区为br
    // 插入两个br的原因是safari与chrome当前光标的前一个元素为br时才会换行

    var node  = selection.extentNode;
    if(node.nextSibling && node.nextSibling.nodeName === 'BR'){
        lock = false;
    }
    range.insertNode(br);
    if(w.chrome || w.navigator.vendor.indexOf('Apple') === 0){
      // 当一行没有输入字符时,input则为null,则不插第二个br,防止出现按一次return却换两行的情况,这情况只出现在一行没有字符的情况下
      // 当一行有输入字符时,插入br,保证按一下就换行,而不是得按两下
      if(input){
        if(lock){
          range.insertNode(br2);
        }
      }
    }
    range.setStartAfter(br);
    range.setEndAfter(br);

    // 跟踪光标至新加的一行
    selection.removeAllRanges();
    selection.addRange(range);
  }

}

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

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

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

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

// 按钮点击事件回调
// func返回当前按钮的功能
if(func === 'code'){
  // tool-bar-btn-checked为按钮按下的效果,检查有没有此类就能知道在什么状态
  if(btn_code.classList.contains('tool-bar-btn-checked')){
    var code = d.createElement('CODE'),
        selection = w.getSelection(),
        range = w.getSelection().getRangeAt(0);

    code.classList.add('code');
    code.setAttribute('contenteditable', true);

    range.insertNode(code);

    // 一定要创建新的range和selection,否则firefox下没发正确插入光标
    var new_range = d.createRange(),
        new_selection = w.getSelection();

    new_range.selectNodeContents(code);
    new_selection.removeAllRanges();
    new_selection.addRange(new_range);

  }else{
    var selection = w.getSelection(),
        range     = d.createRange();
        div       = d.createElement('DIV');

    editarea.appendChild(div)

    range.selectNodeContents(div);
    selection.removeAllRanges();
    selection.addRange(range);
  }
}

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

← 富文本编辑器中引用(BLOCKQUOTE)的问题  msp430单片机下的一个迷宫游戏 →