HR-Editor(一期发布)
<!-- 声明: ie下看博客例子有问题的同学请自行下载代码到本地测试 。。。-->
由于部门需求,希望有个自己的富文本编辑器,不想用第三方插件,所以挤压我的业余时间,让我做了一个。。。命运催悲啊...
其实初稿出来只用了一个周末的时间,不过那只限于没有UI,功能不完善,并且还夹杂着一些小bug。于是后来Eric响应时势,做了一个富文本的VD稿,然后我就开始了一段曲折的编码历程。结果中间夹杂着乱七八糟的项目,一拖就拖了大半个月。
其实做一个有简单编辑功能的富文本编辑器并不难,因为我们其实什么都不用做就已经有了一些还算完善的接口了。那就是execCommand,这个命令支持对iframe进行一些编辑操作,不过前提是要把这个iframe中的document的designMode设置为on。
应该不难想到编码一个富文本框插件的思路:获取到一个需要初始化为富文本的一个textarea,然后创建一个iframe用于可视化编辑,初始化的时候隐藏掉textarea,然后再动态创建一些功能按钮,获取到按钮的onclick的handdle时,用对应的execCommand进行操作就行了。查看源代码的时候就把iframe里面的html输出到textarea里面,再显示textarea,隐藏iframe就可以了。。
是啊,思路是这么简单没错,最开始我做的时候也觉得挺简单,几个常用的功能一两天就调试好了,结果发现当功能越做越多,考虑可用性与易用性越来越多的时候,会遇到很多很多的难题,比如:
1.如果不能做到宽度自适应,那么就必须保证高宽可定义,如果高宽可定义,那么必须保证工具栏能随着编辑器宽度变化自动换行,如果不能自动换行,那一定需要有个命令或者标示能做到手动换行。这是一个问题
2.由于功能项较多,至少20+个功能按钮,怎么样去架构代码以保证良好的模块分离和统一 。。。
3.在做插入图片或插入表情的时候,最开始如果用prompt来进行用户交互的话,不会发现问题。可一旦用弹出层来获得更好界面效果时,会发现IE下不好使了,找不到光标所在位置了。。。
4.当好不容易解决了ie下光标丢失的问题时,你会发现,弹出层中的元素几乎都是要绑定事件的,如果循环绑定会发现由于作用域的关系,只能绑定到最后一个元素上,如果不循环绑定,那么就需要考虑事件冒泡的东西...怎么解决是一个问题。
5。当你好不容易解决了所有出现的问题,兴致勃勃的封装好你的插件给别人炫耀时,别人项目里一用,发现由于与DOM元素耦合度太高,导致不能正确创造出多个实例,就算能正确创建多个实例,重复的id也会是一个问题...
。
。
。
还有很多很多细节的问题都是在编码的过程中会遇到的...该怎么解决,我们一个一个来说。
【关于execCommand】
这个其实不用怎么多说,使用方式很简单:
var e = iframe.contentWindow.document || iframe.contentDocument;
e.execCommand(cmd, 0, val);//参数分别为:命令,交互方式,执行命令的值
比如说execCommand('fontname', false, 'Arial')即表示把iframe里选中文字的font-family设置为Arial,其他的也类似,如果不需要设值的功能,第三个参数可为null。
【工具栏手动换行】
其实最开始我把它想复杂了,想做成自动判断宽度换行的,后来看了一个老外的思路,其实很简单,不用自动化那么复杂,在初始化的时候在工具栏需要换行的地方设置一个标示,新建一个工具栏Wrap,把后面的工具按钮push到新的wrap中即可。
【模块的抽象】
在多个函数用到同一个接口的时候,考虑抽象出统一的接口以供调用,完善代码架构,减少冗余代码,针对富文本的情况,大部分功能都需要通过execCommand实现,所以可以把这个接口抽离出来,同时面对多个需要调用的对象,怎么批处理?个人觉得最直接的就是用数组临时保存,数组的遍历和查找是很有利于批处理的。所以我这里也是借用一老外的思路,通过数组来批处理事件:
/* 临时数组 */
var c = [];
c['forecolor'] = [0,'文本颜色','o','forecolor'];
c['backcolor'] = [1,'文本背景色','o','backcolor'];
c['bold'] = [2,'加粗','a','bold'];
c['italic'] = [3,'斜体','a','italic'];
c['strikethrough'] = [4,'删除线','a','strikethrough'];
c['underline'] = [5,'下划线','a','underline'];
c['link'] = [6,'添加链接','o','createlink','输入链接地址:','http://'];
c['unlink'] = [7,'移除链接','a','unlink'];
c['hr'] = [8,'添加分割线','a','inserthorizontalrule'];
c['orderedlist'] = [9,'有序列表','a','insertorderedlist'];
c['unorderedlist'] = [10,'无序列表','a','insertunorderedlist'];
c['indent'] = [11,'缩进','a','indent'];
c['outdent'] = [12,'取消缩进','a','outdent'];
c['leftalign'] = [13,'居左','a','justifyleft'];
c['centeralign'] = [14,'居中','a','justifycenter'];
c['rightalign'] = [15,'居右','a','justifyright'];
c['undo'] = [16,'后退','a','undo'];
c['redo'] = [17,'前进','a','redo'];
c['cut'] = [18,'剪切','a','cut',1];
c['copy'] = [19,'复制','a','copy',1];
c['paste'] = [20,'黏贴','a','paste',1];
c['unformat'] = [21,'清除样式','a','removeformat'];
c['table'] = [22,'添加表格','o','inserttable'];
c['subscript'] = [23,'下标','a','subscript'];
c['superscript'] = [24,'上标','a','superscript'];
c['blockjustify'] = [25,'自适应','a','justifyfull'];
c['image'] = [26,'插入图片','o','insertimage','输入图片地址:','http://'];
c['print'] = [27,'打印','a','print'];
c['emotion'] = [28,'插入表情','o','insertemotion'];
/* -- 批处理事件(几个特殊的单独提出)--*/
if (c[id]) { //常用的编辑功能
var div = HR.CE('a'),
x = c[id],
func = x[2],
ex,
pos = x[0]*offset;
div.id = this.anchor + x[3];
div.title = x[1];
if (func == 'a') {
ex = '.action("'+x[3]+'", 0, '+(x[4]||0)+')';
}else if(func == 'i') {
ex = '.insert("'+x[4]+'", "'+x[5]+'", "'+x[3]+'")';
}else if(func == 'o') {
ex = '.popbox("'+x[3]+'")';
}
div.onclick = new Function(this.anchor + (id == 'print' ? '.print()':ex));
if(c[id][3] == 'insertimage' || c[id][3] == 'insertemotion' || c[id][3] == 'print') {
switch (c[id][3]) {
case 'insertimage':
//div.innerHTML = 'pic';
div.className = 'editor-insertimage';
div.id = this.anchor + 'editor-insertimage-btn';
h1.appendChild(div);
break;
case 'insertemotion':
//div.innerHTML = 'emo';
div.className = 'editor-insertemotion';
div.id = this.anchor + 'editor-insertemotion-btn';
h1.appendChild(div);
break;
case 'print':
//div.innerHTML = 'pri';
div.className = 'editor-print';
h1.appendChild(div);
break;
default:
alert('暂没提供这项功能,请联系开发者');
}
}else {
div.className = options.controlclass;
div.style.backgroundPosition = '0px ' + pos + 'px';
div.onmouseover = new Function(this.anchor + '.hover(this,'+pos+',1)');
div.onmouseout = new Function(this.anchor + '.hover(this,'+pos+',0)');
h.appendChild(div);
}
if(this.ie){div.unselectable = 'on'}
}
另外一个也需要抽象模块的地方是弹出层的统一接口。如果我们每次建立一个弹出层都要写一遍相同的代码,那就太冗余了。把公用的部分抽离出来,只把不同的部分通过参数传递来实现。
/* -- 抽象创建弹出层接口 -- */
createBox : function (tag, boxId, btnId, con) {
var box = HR.CE(tag),
_btnW = HR.$(btnId).offsetWidth,
_btnH = HR.$(btnId).offsetHeight,
_x = this.ie ? HR.getPos(HR.$(btnId)).x+1 : HR.getPos(HR.$(btnId)).x-1,
_y = HR.getPos(HR.$(btnId)).y + _btnH;
box.id = boxId;
box.innerHTML = con;
document.body.appendChild(box);
if (tag == 'ul') {
box.className = 'editor-sel-ul';
box.style.cssText = 'position:absolute;left:'+_x+'px;top:'+_y+'px;display:none;width:'+(_btnW-4)+'px;overflow:hidden;z-index:10;border:1px solid #d5d5d5;padding:0 2px;background:#fff';
var li = HR.$$(box, 'li');
li[li.length-1].style.borderBottom = 'none';
}else {
box.style.cssText = 'position:absolute;left:'+_x+'px;top:'+_y+'px;display:none;z-index:10;border:1px solid #d5d5d5;padding:8px;background:#fff';
}
return box;
},
那么我们再调用的时候只要把不同的部分通过参数返回给这个接口就可以了,比如以下伪代码:
/* -- create table box -- */
createTableBox : function () {
var boxid = this.anchor + 'table-box',
btnid = this.anchor + 'inserttable',
_html = ........
return this.createBox('div', boxid, btnid, _html);
},
【关于ie下光标丢失的问题】
在ie下面,当可视化编辑的iframe失去焦点的时候,光标会自动转移到iframe的body节点去,这个bug可以去网上搜搜,有相关的解释。所以在ie下面需要手动记录失去焦点时光标的位置,以便在下一次focus的时候能正确返回当前的位置。ie下iframe有beforedeactivate和activate两个事件handdle,可以分别在失去焦点时记录当前光标位置和得到焦点时取得之前记录的值。有了这两个handdle就可以很好的处理这个问题了:
/* -- 修复 ie 下光标丢失 -- */
if (this.ie) {
var bookmark;
//记录IE的编辑光标
HR.addEvent(_this.i, "beforedeactivate", function () {
var range = _this.e.selection.createRange();
bookmark = range.getBookmark();
});
//恢复IE的编辑光标
HR.addEvent(_this.i, "activate", function () {
if (bookmark) {
var range = _this.e.body.createTextRange();
range.moveToBookmark(bookmark);
range.select();
bookmark = null;
}
});
}
【循环绑定事件的问题】
当我们在为弹出层中的元素如下拉列表,表情列表等循环绑定事件的时候会发现只会响应最后一个。这是因为作用域的关系。所以,循环内直接用事件监听的方式绑定事件是行不通的。一个解决方案就是用闭包来处理,让每个事件的handdle拥有一块独立的作用域。
var emo = HR.$$(box, 'img');
for(var i=0; i<emo.length; i++){
/*HR.addEvent(emo[i], 'click', function(){
_this.insertHtml(HR.emotions[i-1]);
});*/
//注意这里不能用上面事件监听的方式绑定,在循环里事件监听只能响应最后一个事件。这里应该用如下的闭包的方式。
emo[i].style.cursor = 'pointer';
var curEmo = HR.emotions[i].toString();
emo[i].onclick = function (s) {
return function() {
_this.insertHtml(s);
_this.handdleBox(box);
HR.$(_this.anchor+'editor-insertemotion-btn').style.background = 'url(images/insertemo.jpg)';
};
}(curEmo);
}
还有种方案就是不用循环绑定,而是精确捕获,通过精确地捕获事件发生源来为这个源头绑定事件。例如:
HR.target = function(e) { //鼠标事件捕获元素
return e ? e.target : event.srcElement;
}
/* -- 通过条件判断来精确捕获 -- */
HR.addEvent(box, 'click', function(e) { //这里用精确捕获防止多次执行...
var t = HR.target(e);
if(!!t.title) {
_this.action(command, t.title);
box.style.display = 'none';
}
});
还有种方式就是不用条件判断,而是强制禁止冒泡来防止事件的多次执行。
【与Dom耦合性太高,难以多实例共存】
这个问题其实说难也难,说简单也简单,关键是我们在构造类当中有没有考虑到多实例共存的情况,在内部方法调用和动态dom生成的时候都加上一个当前实例的标识就行。这里涉及到最初代码架构的问题。我这里提供一种方案:
var Editor = function () {
function init (anchor) {
this.anchor = anchor;
window[anchor] = this;
elem.onclick = new Function(this.anchor+'.a()');
}
init.prototype = {
a : function () {
var dom = document.createElement('div');
dom.id = this.anchor+'div';
document.body.appendChild(dom);
return dom;
}
}return {init: init}
}();
把实例名通过参数显式传递进去,调用的时候用实例方法来调用,而不要要类方法。动态创建的id也尽量加上实例名的影子,保证不重复。创建多实例的时候只要实例名不同即可。
new Editor.init('demo1', ...);
new Editor.init('demo2', ...);
/* 抑或 */
var demo1 = new Editor.init('demo1', ...);
var demo2 = new Editor.init('demo2', ...);
好了,讲解差不多就到这里了,剩下还是给大家展示一期的成果吧,包含主流编辑功能,工具栏可自定义,可多实例,编辑器高度可拖动...
这只是第一期,因为还有本地无刷新上传图片的功能没做,估计又是一周以后的事情了...
最后贴上源码:由于文章太长,就贴上源码地址,有兴趣的同学自己看看吧。如果商用的话请告知。因为是公司的项目,谢谢
https://files.cnblogs.com/hongru/editor.js