富文本编辑器中代码输入区域的处理
作为一款富文遍编辑器,良好的输入代码功能是不可缺少的,但这个功能实现却异常的坑,没见过比这还坑的,这还只是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就随意走起啦,不用担心字体文件过大的问题。我使用了Menlo
和 Monaco
这两个字体,本来打算用我最喜欢的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, ' ');
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小时,头还真是有点晕==,要歇一歇了。