上周抽空把去年写的富文本重写了一下,封装成基本UI组件,就可以在聊天框之外的地方复用了。个人觉得富文本是个兼容问题最多的模块之一,尤其是文档也没几个,把mozilla的api文档和IE的dom api关于selection和range的看了一个遍,一个个试,总算找到勉强能用的方法。

其实之前的富文本代码太乱,而且还有不少bug,只是产品经理不给时间改,O__O"…

这个富文本没有用iframe来做输入框,原因有二:

  1. iframe是所有dom节点中消耗性能最大的,开多几个ie6/7就会卡到不行了;
  2. 因为webqq是多窗口聊天的,当光标点击到输入框时,iframe会捕获鼠标事件,通知不了聊天窗设置样式;

所以就用了div,设置contentEditable="true",这个属性基本浏览器都支持,除了firefox2.0(不过还真有用户还在用ff2.0⊙﹏⊙b汗)

这次修改发现了不少蛋疼的兼容性问题,挑几个归纳一下:

more

1. 光标位置的保存/还原

富文本很大一部分兼容问题在于保存和还原光标的位置。说起光标位置,有个要注意的地方就是不要随便调用focus方法,连续调用两次focus会导致光标失去, 跟调用blur的效果一样,最好的方式就是让调用方在调用的时候保证光标在输入框中,内部代码中不要调用focus。

保存就不说了,keyup/mouseup的时候把当前的range存起来(_这里有性能问题,但是blur事件又不能用,产生这个事件的时候,光标已经移到别处了_),但是要保证光标在输入框中,否则range就是document的。

这里要注意到是,ie9支持了window.getSelection方法,但是,它拿到的range对象没有createContextualFragment方法,这个方法可以传入一个html字符串,直接生成dom节点,跟pasteHTML有点类似,具体说明可以点击这里查看。因此自己封装的getSelection方法,要把document.selection放在前面。

还原光标位置,对于高级浏览器,直接把原来的range添加到selection就行,像这样

selection.removeAllRanges();
selection.addRange(this._lastRange);

ie则有两种方法:

1.  getBookmark和moveToBookmark
var range = RichEditor.getRange();
range.moveToBookmark(this._lastBookmark);
range.select();

2.  setEndPoint
range.setEndPoint('EndToStart', this._lastRange);
range.collapse(false);
range.setEndPoint('EndToEnd', this._lastRange);
range.select();

这里说下setEndPoint的原理:

  1. 先保存lastRange,如"ABCDEFG"中的"CDE"
  2. 把新的range的结尾移动到lastRange的开头(即"C"的左边)
  3. 然后调用 collapse(false)把光标的插入点移动到range的结尾,也就是把range的开头和结尾合并在一起,不这样处理的话,调用select之后会选中"AB"(即选中"C"之前的所有内容)
  4. 把range的结尾移动到lastRange的结尾(即"E"的右边)
  5. 选中该range就能把上次保存的选区还原了(即选中"CDE")

2. 换行处理

当在输入框按下回车键之后,ie会生成一个新的

段落标签,ff是
,chrome则是
。这也不是什么大问题,但是会让后续的处理产生麻烦, 理想的情况就是任何浏览器里输入框的内容都一样。所以这里要监控输入框的keydown事件,如果是回车,则阻止浏览器的默认行为,使用代码插入一个换行标签

注意1: opera的keydown事件是没办法阻止默认行为的,要用keypress事件代替。

注意2:当chrome的光标在一行的末尾的时候,插入一个
并不能让光标移动到下一行,还需要在
后面插入一个额外的节点才能跳到下一样。因此可以先插入
 ,然后把html空格" "删除即可。

3. 删除处理(ie)

ie中如果选中一个图片或input等节点,按下退格键的话,会触发浏览器的后退处理,跟调用history.back()一样的效果,可以在keydown的时候判断选中内容的类型,如果是control类型,则阻止浏览器的行为,使用代码删除。

var selection = RichEditor.getSelection();
if (selection.type.toLowerCase() === 'control') {
    e.preventDefault();
    selection.clear();
}

PS: 这种情况只存在于使用div做输入框的情况,iframe没有。

4. 粘贴&拖拽处理

聊天窗的输入框跟一般的富文本不太一样,想发表文章用的富文本,是可以允许粘贴html片段进来的。但是聊天框里贴入html片段会导致样式很乱,影响体验。而且里面的图片都必须先上传到服务器才能使用。因此要对贴入的内容进行过滤。

之前的处理是直接把所有内容用正则过滤一遍,放过
和部分有标识的标签,其余一概删掉,然后再重新插入输入框。这样处理比较简单,但是会导致过滤后的光标无法找回原来的地方,体验不好。

现在是用遍历dom的方法,遍历输入框的直接子节点,把其中的文本提取出来,创建TextNode,并替换掉它的父节点,这里用到两个比较重要的属性:

  1. textContent(标准浏览器): textContent保存了它所有的子孙节点的文本,去除了所有Element节点
  2. innerText(ie): ie没有textContent属性,但是可以用innerText代替

注意: opera没有onpaste事件,只能捕捉到ctrl+v的粘贴行为,而且很意外的keypress的v键keyCode_ _还是86。右键贴入的就没办法了,连编辑的div连oninput事件也触发不了 O__O"…

5. 插入处理

标准浏览器(非ie)要在光标处插入内容,可以用range.createContextualFragment创建一个html片段,调用range.insertNode插入。用这种方法插入后,光标会消失,要把光标重新定位显示。

var fragment = range.createContextualFragment(html);
var lastNode = fragment.lastChild;
range.insertNode(fragment);
//插入后把开始和结束位置都放到lastNode后面, 然后添加到selection
range.setEndAfter(lastNode);
range.setStartAfter(lastNode);
var selection = RichEditor.getSelection();
selection.removeAllRanges();
selection.addRange(range);

ie就简单多了, 虽然也不见得是什么好事

range.pasteHTML(html);
range.collapse(false);
range.select();

6. 插入后的光标定位

插入html片段后,如果出现了滚动条,在非ie浏览器里,光标已经在可视区下面,而且不会自动滚动到可视区域。解决办法是插入html片段的时候,在后面添加多一个宽高都是0的图片,然后计算图片相对输入框的位置是否已经超出了输入框的可视范围。如果是,将输入框滚动定位到图片处,之后将图片删除。

这里之所以用图片,是因为他是display: inline;的元素,不会导致内容换行,又可以设置宽高,让其对用户不可见,是在是杀人越货必备之品。

代码如下:

    html += '<img class="focus_mark" alt="" />';
    var fragment = range.createContextualFragment(html);
    var lastNode = fragment.lastChild;
    //..........
    var divArea = this._divArea;
    var pos = $D.getRelativeXY(lastNode, divArea);
    divArea.scrollTop = pos[1] < divArea.scrollHeight ? divArea.scrollHeight : pos[1];
    document.execCommand('Delete', false, null);// 删除附加的节点

这里也可以用lastNode.scrollIntoView()滚动到可视区域的, 只是ff如果打开了firebug, 会导致webqq的样式错乱, 其他网站也许可以测试看看.

7. 保证range在输入框中

前面很多方法的执行前提都是当前焦点在输入框中,否则如果焦点在document上的话,插入的html会显示在页面的左上角,就是一个大bug了。

判断一个range是否在输入框中,可以对range的父节点进行判断,如果其parentNode是输入框或者在输入框里面,则是正确的range。 标准浏览器可以用range.commonAncestorContainer获得父节点,ie则是range.parentElement()。比较的方法是compareDocumentPosition(w3c)和contains(ie),具体怎么用就不说了,这里有个说明及封装好的代码。

以上的问题都是windows平台的,linux上也有问题,但是还没测,待续...

Comments
Write a Comment
  • sam reply

    关于ie下 web editor 光标的问题,谢谢!具体代码请看<br><a href='http://www.iteye.com/problems/91534' rel='nofollow noopener'>http://www.iteye.com/proble...</a>测试ie的range,问题描述(如果需要重现,请先刷新页面): <br>光标放在'初始'之间,使用ctrl+v 快捷键粘贴后,粘贴的内容不在editorPasteElm里面,具体执行后的效果可以使用ie“F12开发者工具”来查看 <br>怎么样才能使粘贴的内容在editorPasteElm里面?请不要使用 document.execCommand('paste',false,null);因为这个会导致ie提示 是否允许程序访问 粘贴板

  • 无影脚 reply

    请问火狐等其他浏览器怎么临时保存光标?求教啊!!!

  • iAzrael reply

    @无影脚 var lastRange = widnow.getSelection().getRangeAt(0);<br>把lastRange存起来就行啦

  • 无影脚 reply

    @iAzrael 你好<br>怎么getRangeAt(0)是null或是是空呢?

  • 无影脚 reply

    @iAzrael 你好<br>我这样写还原光标位置对吗?<br>window.getSelection().AddRange(lastRange)

  • 无影脚 reply

    @iAzrael 你好~<br>已经可以保存光标基本上可以<br>但是内容插入后你所说的非IE光标到了最下面.请问你这里<br>var divArea = this._divArea;<br>var pos = $D.getRelativeXY(lastNode, divArea);<br>divArea.scrollTop = pos[1] < divArea.scrollHeight ? divArea.scrollHeight : pos[1];<br>document.execCommand(&amp;#039Delete&amp;#039, false, null);// 删除附加的节点可以说明白一点吗?$D.getRelativeXY不太懂什么意思?

  • iAzrael reply

    @无影脚 addRange之前, 需要先把原来的选区清理掉<br>var sel = window.getSelection();<br>sel.removeAllRanges();<br>sel.addRange(lastRange);

  • iAzrael reply

    @无影脚 这里主要就是在插入的时候额外加多一个<b>临时节点A</b>, 然后判断A的位置(Y坐标)是否已经div输入框的可视范围, 也就是说光标是不是已经看不到了, 这时就把scrollTop设置为临时节点A的Y坐标, 滚动条就滚下去了. <br>最后把临时节点删掉就行了

  • iAzrael reply

    @无影脚 $D.getRelativeXY是我用的一个库的方法, 作用是获取lastNode相对于divArea的偏移位置(left, top), 如果divArea是body, 就相当于获取lastNode在页面中的x,y

  • 无影脚 reply

    谢谢了<br>弄好感谢感谢阿

  • ljl reply

    你确定addRange在chrome可以?还有虽然getRangeAt(0)可以获得当前的Rang,但是chorme是不支持多个Range的,楼主检查下

  • iAzrael reply

    @ljl 虽然它只支持单个range, 但是addRange也是可以工作的, 我在WebQQ里面已经验证过了

  • Keboy reply

    你好,问个问题<br>var frag = range.createContextualFragment('<strong></strong>');<br>var lastNode = frag.lastChild;<br>range.insertNode(frag);range.setStartAfter(lastNode);<br>...<br>这里添加的strong标签是空,它的lastChild是null,这样怎么设置setStartAfter呢

  • Keboy reply

    你好,问个问题<br>var frag = range.createContextualFragment('<strong></strong>');<br>var lastNode = frag.lastChild;<br>range.insertNode(frag);<br>range.setStartAfter(lastNode);<br>...<br>这里添加的strong标签是空,它的lastChild是null,这样怎么设置setStartAfter呢

  • iAzrael reply

    @Keboy 你是在什么浏览器上测试的呢? 只要frag里面有一个节点, frag.lastChild就不会是null.<br>你的本意是想把光标放到strong标签内部?

  • 一粒糖 reply

    请问ie8光标在文本最后就保存不到光标呢?<br>我的代码类似这样<br><a href='http://www.zhujianfeng.info/?p=122' rel='nofollow noopener'>http://www.zhujianfeng.info...</a><br>请教什么原因呢 :)

  • iAzrael reply

    @一粒糖 你可以试一下保存和恢复Range来实现光标的还原, ie的bookmark是有些问题的, 偶尔会还原失败

  • 一粒糖 reply

    @iAzrael '保存和恢复Range来实现光标的还原'<br>这不太明白<br>我试过<br>range.setEndPoint('EndToStart', this._lastRange);<br>range.collapse(false);<br>range.setEndPoint('EndToEnd', this._lastRange);<br><a href='http://range.select' rel='nofollow noopener'>range.select</a>()<br>还是有问题你之前做的时候没有这个问题吗?

  • iAzrael reply

    @一粒糖 有可能是你的 this._lastRange本身就是null或者取的位置不对, 我是监听了输入区域的mouseup和keyup事件, 每次触发的时候都取一下当前的range赋值给 this._lastRange, 这样就能保证 this._lastRange一定是在输入区内的

  • 一粒糖 reply

    @iAzrael 您好:<br>Ie8以下的可以保存光标了监听keyup时就可以了,但是Ie8以下版本富文本框没有输入东西的时候,插入图片光标不能在图片的前面用鼠标也不能控制光标到前面,只有添加一个空的节点才可以,请问怎么判断光标前面没有节点

  • iAzrael reply

    @一粒糖 你可以在初始化输入框的时候默认填一个<br>标签, 然后在监听到删除事件的时候检查一下输入框还有没有内容, 没有内容就在删除之后再补上<br>

  • 一粒糖 reply

    @iAzrael IE8基本解决.但是还有一点bug<br>我是判断是否IE8以下<br>是的话就<br>document.selection.createRange().pasteHTML(HTML + ' '); <br>加个span<br>然后<br>var cursor = document.selection.createRange();<br> cursor.moveStart('character', -1);<br> cursor.collapse(true);<br> <a href='http://cursor.select' rel='nofollow noopener'>cursor.select</a>();<br> cursor.parentElement().removeNode(cursor.parentElement().lastChild.previousSibling);光标自然定位到图片前面,接着如果用户删除最前面的空格就判断最后的节点是不是,是空就加空格<br> var cursor = document.selection.createRange();<br> if (!(cursor.parentElement().lastChild.nodeValue)) {<br> var _moveStartValue = 0;<br> if (cursor.parentElement().childNodes.length <= 1) {<br> _moveStartValue = -1;<br> }<br> var div = document.createTextNode(' ');<br> cursor.parentElement().appendChild(div)<br> var cursor = document.selection.createRange();<br> cursor.moveStart(&amp;#039character&amp;#039, _moveStartValue);<br> cursor.collapse(true);<br> <a href='http://cursor.select' rel='nofollow noopener'>cursor.select</a>(); } 键盘按了删除野是这个方法.用鼠标在图片前面没有空格光标不能定位在图片前面就难想,百度和腾讯也没有还好解决这个问题

  • iAzrael reply

    @一粒糖 因为富文本的兼容问题太多了, 特别是ie有很多相当奇怪的问题, 所以都是fix到一个可接受的程度就不会投人力了. 如果不自己做的话, 我推荐用 ACEditor, 他是用dom编辑来实现的富文本, 而不是用 contentEditable 做的, 缺点就是代码有点多

  • 一粒糖 reply

    @iAzrael 没有听说过百度也找不到可以发一份我吗253857346@qq.com

  • iAzrael reply

    @一粒糖 网站是这个 <a href='http://ace.c9.io/' rel='nofollow noopener'>http://ace.c9.io/</a>

  • yanchaoren reply

    IE 11 取document.getSelection() 得到的结果和Window.getSelection() 一样但是 addRange()方法IE11无效

  • iAzrael reply

    @yanchaoren 是的, ie浏览器好恶心

  • bukas reply

    ie8 range.setEndPoint 方法报参数无效的错误

Tags

css3   魅力CSS   nodejs   loading   CSS   疯狂的菊花   html5   animation   compiler   编译脚本   png   WordPress   智能   旅行   优化   模板   历史记录   跨域   manifest   frame   canvas   动画   js   离线应用   codelet   transform   抽取   java   兼容问题   发布脚本   富文本   那一年在他乡   htaccess   iframe   帧动画   加载速度   intelligent   跨浏览器   DNS解析   插件   checkbox   单边   step-start   vary   复选框   自动更新   转换   文本溢出   盒子阴影   menu   blob   西安   滑动背景   box-shadow   内存占用   键盘事件   python   auto   text overflow   background   所见所得   android   rotate   字节数   合并   文本框   slide   字符串连接符   协议   伪类   兄弟选择符   网格   节点位置比较   空白   斜线拼接   自定义命令   溢出   clock   素描   无法更新   分隔符   字符编码   body   下载文件   精灵图   step   nodej   ubuntu   apache   css3选择器   创建文件   多级菜单   编辑状态   ajax   阴影   垂直   chrome   管道   时钟   firefox   背景   文件上传   createobjecturl   游记   下载   放射渐变   版本号   宽高   照片   localStorage   渐变背景   图片   图片拼接   属性值检测   自动生成   计算   返回键   oauth   合图   reset   调用   cavnas   漏洞   按钮   margin   线性渐变   xsrf   被黑   tab   checked   修复   border   消失   step-end   sprite   common-upload   菜单   兄弟选择器   字符串   svn   九寨沟   缩进   css遮罩   svg   添加系统服务   gzip   插入代码   动态   加速   模拟