ueditor源代码重点难点分析
网上好像几乎没有研究ueditor源码的文章,原因可能是ueditor源码太复杂了,接近浏览器代码和word/excel源码。本文分析ueditor源码整体流程逻辑以及重点难点细节。
首先,编辑器是如何实现输入的?本人开始始终不得其解,在源码找不到输入事件绑定的处理函数,后来在白云峰同学的提醒下才顿悟,整个iframe网页就相当于是一个<textarea>元素:
<body class="view" contenteditable="true" spellcheck="false" style="overflow-y: hidden; height: 500px; cursor: text;">
</body>
页面调用ueditor:
<script id="editor" type="text/plain" style="width:100%;height:500px;" ></script> // iframe的container元素
var editor = UE.getEditor('editor');
多次调用可以多实例运行,每个实例都是单独的,编辑器实例保存在UE实例中,从UE.instants[]也可以获取到每个编辑器实例,0就是第一个实例,以此类推,
因此可以不用变量引用编辑器实例:
UE.getEditor('editor');
setTimeout(function(){
UE.instants.ueditorInstant0.setContent('<div>欢迎使用编辑器</div>');
},1000);
执行ueditor文件之后产生三个全局对象:
UEDITORUI - 所有工具按钮插件的api
UE - api入口
UEDITOR_CONFIG - 配置数据
先看ueditor的全局api接口:
window.UE = baidu.editor = window.UE || {}; // UE实例提供ueditor的入口接口,也就是api入口,调用UE的方法才创建真正的编辑器实例
var Editor = UE.Editor = function (options) { // 这是编辑器构造函数
/* 尝试异步加载后台配置 */
me.loadServerConfig(); //所谓异步加载就是用js构造<tag src=url加载文件,正常是直接在网页写<tag src=url 加载文件
UE.Editor.prototype.loadServerConfig = function(){
//用ajax请求http://localhost/plugins/ueditor/ueditor/php/controller.php?action=config&&noCache=1525847581688,返回后台php配置参数,主要是涉及upload的配置参数,其实前端的配置数据可以直接写一个js文件直接从网页写<script src=加载。
element.onload = element.onreadystatechange = function () { // 这是构造<tag src=url加载文件之后再通过onload事件触发执行一个回调
}
}
if(!utils.isEmptyObject(UE.I18N)){ // i18n是语言国际化,就是多语言包
//修改默认的语言类型
me.options.lang = checkCurLang(UE.I18N);
UE.plugin.load(me);
langReadied(me);
}else{
utils.loadFile(document, {
src: me.options.langPath + me.options.lang + "/" + me.options.lang + ".js", //加载zh-cn.js中英文对照语言包
}, function () { // 这个匿名函数回调会执行一次,具体是在哪一次加载文件时执行的不清楚
UE.plugin.load(me); // 这是加载内部插件,执行ueditor文件时会执行UE.plugin.register()注册所有的插件,然后在这里加载所有的插件
UE.plugin = function(){
var _plugins = {};
return {
register : function(pluginName,fn,oldOptionName,afterDisabled){
_plugins[pluginName] = {
optionName : oldOptionName || pluginName,
execFn : fn,
//当插件被禁用时执行
afterDisabled : afterDisabled
}
},
load : function(editor){ // 这就是load()函数,加载插件,所有的插件在执行ueditor文件时已经注册
utils.each(_plugins,function(plugin){ //_plugins是执行register方法产生的插件集合
var _export = plugin.execFn.call(editor); //execFn是plugin的构造函数,执行构造函数产生plugin object {}
utils.each(_export,function(v,k){ // 针对plugin的每一个属性处理一次,把plugin的方法函数保存到编辑器实例中
switch(k.toLowerCase()){
case 'commands':
utils.each(v,function(execFn,execName){
editor.commands[execName] = execFn //editor.commands{}含所有的按钮操作指令
});
});
utils.each(UE.plugins,function(plugin){ // 插件好像分两部分有两种写法,这是针对旧写法插件进行处理
plugin.call(editor); //执行插件构造函数
});
langReadied(me);
});
//loadFile代码:
return function (doc, obj, fn) { // fn就是传入的匿名函数,用<script src=url加载执行js文件之后再执行这个回调函数
doc.getElementsByTagName("head")[0].appendChild(element); // 构造<script src=加载执行zh-cn.js文件
}
}
UE.instants['ueditorInstant' + me.uid] = me; // 如果多实例运行,均存储在UE中,每个实例按id区分,多实例运行可以利用UE.instants[id]找实例,把每个编辑器实例保存在自己定义的全局对象中也可以
}
Editor.prototype = {
render: function (container) { // container是iframe holder,之前已经构造iframe相关的几个div插入网页,render方法构造iframe代码并且把iframe插入网页生效
var me = this,
options = me.options, // options是实例里面的参数,包含config参数
var html = 'iframe里面的html代码';
container.appendChild(domUtils.createElement(document, 'iframe', { // 插入iframe,并执行以下js代码
editor = window.parent.UE.instants['ueditorInstant0']; // ueditor实例保存在iframe的父窗口也就是当前网页窗口
editor._setup(document);
}));
},
_setup: function (doc) {
doc.body.contentEditable = true; // iframe html相当于一个input
}
}
先建立一个UE实例放在全局,入口初始化方法是getEditor。
再看编辑器初始化入口:
UE.getEditor = function (id, opt) {
var editor = instances[id];
if (!editor) {
editor = instances[id] = new UE.ui.Editor(opt); // UE是api入口实例,editor是编辑器实例
editor.render(id); //执行新的render,构造几个/几层container元素,再执行old render,构造iframe代码插入网页
}
return editor;
};
UE.ui.Editor = function (options) {
var editor = new UE.Editor(options); // 这是真正的编辑器实例
var oldRender = editor.render; // UE.editor的render方法(构造iframe插入网页)
editor.render = function (holder) { // 重新构造一个render,构造几个容器元素,然后再调old render构造iframe
utils.domReady(function () { //事件触发异步调度执行
editor.langIsReady ? renderUI() : editor.addListener("langReady", renderUI);
function renderUI() { //事件触发异步调度执行
new EditorUI(editor.options); // 没有接收实例,在其它程序位置不能引用这个实例,在实例的方法中用this引用实例,在事件handler中引用实例(实例“复制”到handler方法中),这是创建实例后如何使用实例的高级方法
function EditorUI(options) {
this.initOptions(options);
UIBase.prototype = {
initOptions:function (options) {
//把options复制到EditorUI实例中
}
this.initEditorUI();
EditorUI.prototype = {
initEditorUI:function () {
//用addeventlistener绑定鼠标操作事件和处理函数
}
}
var newDiv = document.createElement('div'); //在这里创建div插入网页替换<script>元素,并且复制css代码
holder.parentNode.insertBefore(newDiv, holder);
newDiv.style.cssText = holder.style.cssText;
holder = newDiv;
holder.innerHTML = 'xxx';
editor.ui.render(holder); //重新构造iframe外层html代码以及iframe元素代码,render方法代码如下:
UIBase.prototype = {
render:function (holder) {
}
opt.initialFrameHeight = opt.minFrameHeight = holder.offset; //是在这里设置跟随页面写的height
oldRender.call(editor, editor.ui.getDom('iframeholder')); //再执行oldrender,构造iframe插入网页
editor.fireEvent("afteruiready"); // 没有on这个事件的,有何用?
});
};
return editor; //如果没有返回语句,产生的实例是new UE.ui.Editor(opt)实例,由于有返回语句,产生的实例是返回的实例new UE.Editor(options)实例
}
执行ueditor文件时注册所有的插件,执行UE.getEditor()产生new UE.Editor(options)编辑器实例,初始化编辑器,加载插件,绑定鼠标操作事件handler,构造div和iframe。
ueditor的功能组件以plugin插件形式设计,插件代码是ueditor最主要的功能代码,其功能和复杂程度类似word/excel。
ueditor就是一个textarea框,输入文字内容,自动产生html元素,插入图片产生img元素,最终产生的数据是html代码串,提交到后台保存到数据库。
正常显示时是显示iframe里面的html网页,预览html代码时,是显示一个与iframe平级的div,隐藏iframe元素节点不显示:
<div class="CodeMirror-scroll cm-s-default" tabindex="-1" style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;">
插入html代码时,先构造一个div,然后用div.innerHTML=插入的html字符串 解析html字符串成为DOM元素对象,再把div.firstChild插入到网页中一个<p>元素里面
生效,比如插入<div>hello</div>,显示hello,不会把<div>显示出来。
再看结束编辑获取编辑器内容的代码:
getContent: function (cmd, fn,notSetCursor,ignoreBlank,formatter) {
var root = UE.htmlparser(me.body.innerHTML,ignoreBlank); // html -> root -> html 解析处理过程非常复杂
return root.toHtml(formatter);
var htmlparser = UE.htmlparser = function (htmlstr,ignoreBlank) {
var re_tag = /<(?:(?:\/([^>]+)>)|(?:!--([\S|\s]*?)-->)|(?:([^\s\/<>]+)\s*((?:(?:"[^"]*")|(?:'[^']*')|[^"'<>])*)\/?>))/g, //html标签的正则匹配表达式,比如<TD vAlign=top background=../AAA.JPG>
htmlstr = htmlstr.replace(new RegExp('[\\r\\t\\n'+(ignoreBlank?'':' ')+']*<\/?(\\w+)\\s*(?:[^>]*)>[\\r\\t\\n'+(ignoreBlank?'':' ')+']*','g'), function(a,b){ // 正则匹配替换
toHtml:function (formatter) {
var arr = [];
nodeToHtml(this, arr, formatter, 0);
function nodeToHtml(node, arr, formatter, current) {
switch (node.type) {
case 'root':
for (var i = 0, ci; ci = node.children[i++];) {
nodeToHtml(ci, arr, formatter, current) // 递归子节点
return arr.join('')
可见获取编辑器的内容就是获取iframe网页的内容body.innerHTML现成的html代码,很简单,但解析处理非常复杂,有内置过滤规则处理,有点类似框架的
template/vnode解析处理,要递归解析处理所有的子节点。
编辑器头部是工具按钮,都是以插件形式实现的,下面以点击“模板”工具按钮为例分析ueditor的工具按钮插件是如何实现的。
点击模板(template)按钮是插入模板,会显示一个弹窗对话框:
<div id="edui221" class="edui-dialog edui-for-template edui-default edui-state-centered" style="left: 10px; top: 88px; z-index: 110;">
里面是一个iframe加载一个网页:
<iframe id="edui221_iframe" class="%%-iframe" height="100%" width="100%" frameborder="0" src="/ueditor/dialogs/template/template.html"></iframe>
这个网页就是一个列表,选择之后关闭弹窗,把选择的内容插入编辑器中。
注意dialog会话弹窗层iframe不在编辑器层iframe里面,而是在当前网页里面,当前网页有几个container容器,其中一个放编辑器iframe,一个放dialog iframe,还有编辑器的头部/底部都是单独的容器,都不在编辑器容器里面。凡是跨iframe都有传递数据问题,因此dialog弹窗也有传递数据问题,后面会分析它如何传递数据。
iframe网页会执行internal.js建立环境:
dialog = parent.$EDITORUI[window.frameElement.id.replace( /_iframe$/, '' )]; // dialog实例是从父网页获取的,含父网页中编辑器实例
editor = dialog.editor; //当前打开dialog的编辑器实例
dialog.onok = function () {
me.execCommand( "template", obj ); //执行template命令, obj.html就是选取的template代码,me是editor实例
//重写execCommand命令,用于处理框选时的处理
var oldExecCommand = me.execCommand;
me.execCommand = function (cmd, datatat) {
result = oldExecCommand.apply(me, arguments); // oldExecCommand代码如下
execCommand: function (cmdName) {
result = this._callCmdFn('execCommand', arguments);
_callCmdFn: function (fnName, args) {
return cmdFn.apply(this, args);
UE.plugins['template'] = function () {
UE.commands['template'] = {
execCommand:function (cmd, obj) { // cmd=template
obj.html && this.execCommand("inserthtml", obj.html);//再次递归editor实例的execCommand,但这次是执行inserthtml命令,会执行到以下代码
UE.commands['inserthtml'] = {
execCommand: function (command,html,notNeedFilter){ // 把选取的template插入编辑器网页
range = me.selection.getRange(); // 获取编辑器中dialog会话弹窗之前光标选中的区域,也就是template插入的位置
getRange:function () {
var range = new baidu.editor.dom.Range( me.document ); // 创建range对象,数据结构与js原生selection对象一样
var sel = me.getNative(); // 调window.getSelection()返回点击选取的节点数据,此时已经点击工具按钮,之前点击选取的光标状态已经不在,获取不到点击选取数据,单步看获取的数据是空的,因此要使用之前保存的selection数据。
getNative:function () {
return domUtils.getWindow( doc ).getSelection();
},
if ( this._bakRange && domUtils.inDoc( this._bakRange.startContainer, this.document ) ){
return this._bakRange; //之前在编辑器点击选取触发执行getNative保存的selection数据,container=text,offset=1(没有意义)
}
}
//如果当前位置选中了fillchar要干掉,要不会产生空行
if(range.inFillChar()){ // 插入第一个子节点第一次执行时range是#text填充符
child = range.startContainer; // 是text节点
if(domUtils.isFillChar(child)){ // 插入第一个子节点第一次执行时start container是#text填充符
range.setStartBefore(child).collapse(true); //设置container=p,offset=0(#text节点在p中的index),collapse(折叠)意思是设置end=start,如果选中一段再插入就有start container/end container问题。 setStartBefore(child)意思就是要插入到child之前,但要获取child在父节点中的offset,插入时在父节点中按offset再获取child,再插入到child之前
domUtils.remove(child); // 删除#text节点,那么在p节点内部offset=0是br节点
}else if(domUtils.isFillChar(child,true)){
child.nodeValue = child.nodeValue.replace(fillCharReg,'');
range.startOffset--;
range.collapsed && range.collapse(true)
}
}
while ( child = div.firstChild ) { // 递归循环div的子节点把div的子节点一个一个插入(div不插入),如果把div整个插入,就多了div层,其实可以用frag,把frag整个插入即可,但插入第一个子节点时start container是p节点,插入之后要调整start container=body,从第二个子节点开始都是插入body,所以还不能整个一次插入,挺复杂的。
if(hadBreak){ //第一次执行时hadBreak=0,不执行这段,之后再执行时hadBreak=1,会执行这段,hadBreak表示已经切割container元素,
var p = me.document.createElement('p');
while(child && (child.nodeType == 3 || !dtd.$block[child.tagName])){ // 如果要插入的节点是#text节点则套一层p,为何?
nextNode = child.nextSibling;
p.appendChild(child); //child是引用div子节点,那么div子节点插入到p就从div移动到p,div中已经没有child,所以循环n次之后div就变空了
child = nextNode;
} // 如果是文本节点就插入到p元素里面,如果有一批文本节点就循环全部插入到p元素里面,再把p做为child节点
if(p.firstChild){
child = p //
}
} //第n次执行时插入的node是内容节点,不会外套一层p,这段不起作用
range.insertNode( child ); // 第一次插入子节点时把node插入到<p>的<br>之前,之后插入到body里面<p>之前
insertNode:function (node) { // 编辑器头尾有​空格文本节点,第一次执行时是插入填充节点,第n次执行时是插入内容节点
var first = node, length = 1;
var start = this.startContainer, //单步看插入第二个子节点时,是<body>,offset是2指向<p>
offset = this.startOffset;
var nextNode = start.childNodes[ offset ]; // <body>​<h1></h1><p><br></p> body[2]=<p>
if (nextNode) {
start.insertBefore(node, nextNode);
} else {
start.appendChild(node);
}
return this.setStartBefore(first); // 第一个子节点插入之后,根据第一个子节点调整插入指针,第一个子节点此时还在p中,
range.startContainer = p
range.startOffset = 0 (node在p中的index)
此时p变为<p>node<br></p>
nextNode = child.nextSibling; // child插入之后的nextsibling就是原来的占位节点br
if ( !hadBreak && child.nodeType == domUtils.NODE_ELEMENT && domUtils.isBlockElm( child ) ){ // 第一次循环插入<h1>子节点时hadBreak=0会执行一次
parent = domUtils.findParent( child,function ( node ){ return domUtils.isBlockElm( node ); } ); // 递归向上找父节点,parent是p
domUtils.breakParent( child, pre || tmp ); // 把p切开变为<p></p>node<p><br></p>
//去掉break后前一个多余的节点 <p>|</p> ==> <p></p><div></div><p>|</p>
var pre = child.previousSibling;
domUtils.trimWhiteTextNode(pre);
if(!pre.childNodes.length){ // 如果node前面的p是空的则删除,变为node<p><br></p>
domUtils.remove(pre);
}
next.appendChild(me.document.createElement('br')); // 在p添加一个br
hadBreak = 1; // 切割元素问题只在第一次循环插入子节点时处理一次
}
if(!div.firstChild && next && domUtils.isBlockElm(next)){ // 如果div变空,就是所有子节点都循环处理完了
range.setStart(next,0).collapse(true); // 把start container设置为插入节点后面的next占位节点(应该是p),offset=0(p里面的第一个子节点),
break;
}
range.setEndAfter( child ).collapse(); //关键在这,此时第一个子节点已经插入到<p>里面,<p>已经分裂成node<p><br></p>,因此node.parentNode=body,
container变为body,offset是node在body中的index=2。虽然是setendcontainer,但updatecollapse会更新startcontainer=endcontainer,所以实际上就是设置startcontainer=body,一旦第一个子节点插入成功,后续再插入时都是插入到之前插入的node的nextSibling节点p,offset是p在body里面的index,按offset找p节点,把node插入到p之前(insertBefore)。
}
inserthtml命令的函数代码就是把template插入编辑器网页,处理流程逻辑非常复杂深奥,涉及到很细小的细节比如空白换行符处理以及很细微的浏览器兼容性问题。经过长时间细致debug看数据研究源代码,最后发现插入子节点的过程原理如下:
假定回车换行,然后插入模板,把模板插入到当前行位置,这是最简单的情况,回车换行时编辑器会自动产生<p><br></p>。
插入div的第一个子节点时是插入到<p>中,更准确地说是插入到p中的offset=0位置之前,也就是填充节点之前。
插入之后,把<p>节点分裂成两个<p>如下所示:
<p></p>node<p><br></p>
然后把空的<p>节点也删除,就变成了node<p><br></p>,之后再插入其它子节点时,是插入到<body>中,offset位置是<p>节点在<body>中的index,也就是插入到body中<p>节点之前,每次插入一个node之后,node的nextSibling就是<p>节点占位元素。
这段代码是最复杂的插入程序,插入本来很简单,但编辑器插入非常复杂,因为可以在编辑器点击或选取任何位置区域做为插入位置,那么就复杂了,选取的区域是否要保留?是插入到选取区域的头部还是尾部?如果插入到一个元素的中间,那么元素要被分割成两个元素,而有些元素比如<a>元素是不能分割的。因此插入处理流程逻辑以及细节非常复杂,还涉及到#text不可见文本节点,我们在开发应用时一般不会涉及到这么细节这么复杂的问题。
下面是insertHtml程序用到的几段函数代码:
根据start container判断当前选区range内容是否占位符:
inFillChar : function(){
var start = this.startContainer;
if(this.collapsed && start.nodeType == 3
&& start.nodeValue.replace(new RegExp('^' + domUtils.fillChar),'').length + 1 == start.nodeValue.length
//domUtils.fillChar是空格,这个表达式其实意思是找开头是否有空格,那么上述#text文本节点符合这个判断表达式
){
return true;
}
return false;
判断给定的节点是否是一个“填充”节点:
isFillChar:function (node,isInStart) {
if(node.nodeType != 3)
return false;
var text = node.nodeValue;
if(isInStart){
return new RegExp('^' + domUtils.fillChar).test(text) // 以空格开头
}
return !text.replace(new RegExp(domUtils.fillChar,'g'), '').length // 在字符串找所有的空格去掉
对于上述#text文本节点, 其nodevalue是"",去掉空格之后长度为0,因此判断为填充节点,返回true。
这两个方法都是根据nodetype=3和nodevalue含空格来判断,有何区别?一个是判断range,一个是判断node。
如果占位元素是填充节点,就插入到填充节点之前,再删除填充节点,因为如果填充节点是<br>,会换行。
将Range开始位置设置到node节点之前:
setStartBefore:function (node) {
return this.setStart(node.parentNode, domUtils.getNodeIndex(node)); //返回修改之后的range
},
检测节点node在父节点中的索引位置:
getNodeIndex:function (node, ignoreTextNode) {
var preNode = node,
i = 0;
while (preNode = preNode.previousSibling) {
if (ignoreTextNode && preNode.nodeType == 3) {
if(preNode.nodeType != preNode.nextSibling.nodeType ){
i++;
}
continue;
}
i++;
}
return i;
},
如果选中一段,再插入,又要保留选中的段,就比较复杂,可以插入到选中段的开头,也可以插入到选中段的尾部,也就是说选中的段可以在插入段的前面或后面。
编辑器range含start container和end container,就是选中的段的头尾节点。
collapse:function (toStart) {
var me = this;
if (toStart) { //插入到range的头部
me.endContainer = me.startContainer;
me.endOffset = me.startOffset;
} else { //插入到range的尾部
me.startContainer = me.endContainer;
me.startOffset = me.endOffset;
}
me.collapsed = true;
return me;
设置Range的开始容器节点和偏移量
* @method setStart
* @remind 如果给定的节点是元素节点,那么offset指的是其子元素中索引为offset的元素,
* 如果是文本节点,那么offset指的是其文本内容的第offset个字符
* @remind 如果提供的容器节点是一个不能包含子元素的节点, 则该选区的开始容器将被设置
* 为该节点的父节点, 此时, 其距离开始容器的偏移量也变成了该节点在其父节点
* 中的索引
setStart:function (node, offset) { // node是p,offset是里面子节点的offset,0是第一个子节点,但也可能是node在父元素中的offset
return setEndPoint(true, node, offset, this);
},
function setEndPoint(toStart, node, offset, range) {
//如果node是自闭合标签要处理
if (node.nodeType == 1 && (dtd.$empty[node.tagName] || dtd.$nonChild[node.tagName])) {
offset = domUtils.getNodeIndex(node) + (toStart ? 0 : 1);
node = node.parentNode;
}
if (toStart) {
range.startContainer = node;
range.startOffset = offset;
if (!range.endContainer) {
range.collapse(true);
}
} else {
range.endContainer = node;
range.endOffset = offset;
if (!range.startContainer) {
range.collapse(false);
}
}
updateCollapse(range);
return range;
下面研究点击对话框“确定”按钮之后是如何处理的?如何能执行dialog.onok?
debug看dialog iframe网页代码中“确定”按钮代码是:
<div id="edui223_body" unselectable="on" class="edui-button-body edui-default" onmousedown="return $EDITORUI["edui223"]._onMouseDown(event, this);" onclick="return $EDITORUI["edui223"]._onClick(event, this);">
<div class="edui-box edui-icon edui-default"></div>
<div class="edui-box edui-label edui-default">确认</div>
</div>
点击“确定”按钮是执行$EDITORUI["edui223"]._onClick(event, this),这段代码是如何产生的?
dialog插件定义代码:
// ui/dialog.js
(function (){
Dialog = baidu.editor.ui.Dialog = function (options){
this.initOptions(utils.extend({
onok: function (){},
oncancel: function (){},
onclose: function (t, ok){
return ok ? this.onok() : this.oncancel();
},
},options));
this.initDialog();
};
Dialog.prototype = {
initDialog: function (){
},
_hide: function (){
wrapNode.style.display = 'none';
},
open: function (){
this.render();
this.open();
},
close: function (ok){
this._hide();
}
debug看工具按钮比如模板按钮是;
<div id="edui225_body" unselectable="on" title="模板" class="edui-button-body edui-default" onmousedown="return $EDITORUI["edui225"]._onMouseDown(event, this);" onclick="return $EDITORUI["edui225"]._onClick(event, this);">
<div class="edui-box edui-icon edui-default"></div>
<div class="edui-box edui-label edui-default"></div>
</div>
按钮html代码是由button对象的代码构造出来的:
Button = baidu.editor.ui.Button = function (options){}
Button.prototype = {
getHtmlTpl: function (){
return '<div id="##" class="edui-box %%">' +
'<div id="##_state" stateful>' +
'<div class="%%-wrap"><div id="##_body" unselectable="on" ' + (this.title ? 'title="' + this.title + '"' : '') +
' class="%%-body" onmousedown="return $$._onMouseDown(event, this);" onclick="return $$._onClick(event, this);">'
加debug看$$就是$EDITORUI['edui225']。
$EDITORUI['edui225']._onClick(event, this)代码是:
_onClick: function (){
if (!this.isDisabled()) {
this.fireEvent('click');
// ueditor自己的事件系统,对应editor.addListener,触发事件就是执行listener
var EventBase = UE.EventBase = function () {};
EventBase.prototype = { // ueditor自己的逻辑事件系统
addListener:function (types, listener) {
//把listener存储到listeners[]中
},
fireEvent:function () { // fireEvent就是到listeners[]中找listener执行
t = listeners[k].apply(this, arguments);
r = t.apply(this, arguments);
}
}
},
插件绑定了click事件;
UE.plugins['template'] = function () {
this.addListener("click", function (type, evt) { //在template操作过程中并没有执行这个handler
var el = evt.target || evt.srcElement,
range = this.selection.getRange();
var tnode = domUtils.findParent(el, function (node) {
if (node.className && domUtils.hasClass(node, "ue_t")) {
return node;
}
}, true);
tnode && range.selectNode(tnode).shrinkBoundary().select();
});
工具栏按钮点击事件绑定:
var btnCmds = ['undo', 'redo', 'formatmatch',
'bold', 'italic', 'underline', 'fontborder', 'touppercase', 'tolowercase',
'strikethrough', 'subscript', 'superscript', 'source', 'indent', 'outdent',
'blockquote', 'pasteplain', 'pagebreak',
'selectall', 'print','horizontal', 'removeformat', 'time', 'date', 'unlink',
'insertparagraphbeforetable', 'insertrow', 'insertcol', 'mergeright', 'mergedown', 'deleterow',
'deletecol', 'splittorows', 'splittocols', 'splittocells', 'mergecells', 'deletetable', 'drafts'];
for (var i = 0, ci; ci = btnCmds[i++];) {
editorui[ci] = function (cmd) {
var ui = new editorui.Button({
onclick:function () {
editor.execCommand(cmd);
},
但“模板”按钮不在其中,有dialog的按钮是在这儿定义的:
var dialogBtns = {
noOk:['searchreplace', 'help', 'spechars', 'webapp','preview'],
ok:['attachment', 'anchor', 'link', 'insertimage', 'map', 'gmap', 'insertframe', 'wordimage',
'insertvideo', 'insertframe', 'edittip', 'edittable', 'edittd', 'scrawl', 'template', 'music', 'background', 'charts']
};
var ui = new editorui.Button({
onclick:function () {
if (dialog) {
switch (cmd) {
default:
dialog.render();
UIBase.prototype = {
render:function (holder) {
holder.appendChild(el); //构造dialog el插入网页中占位元素(一个固定的浮动块)中
this.postRender();
postRender: function (){
this.addListener('show', function (){
me.modalMask.show(this.getDom().style.zIndex - 2);
});
this.buttons[i].postRender();
postRender: function (){
this.Stateful_postRender();
Stateful_postRender: function (){
if (this.disabled && !this.hasState('disabled')) {
this.addState('disabled');
this.setDisabled(this.disabled)
},
}
}
dialog.open();
open: function (){
this.showAtCenter(); // 执行这个方法显示会话弹窗
showAtCenter: function (){
//设置定位
this._show();
_show: function (){
//dialog和编辑器两个平级浮动块要比z-index,要高过编辑器的zindxe
this.editor.container.style.zIndex && (this.getDom().style.zIndex = this.editor.container.style.zIndex * 1 + 10);
this.fireEvent('show');
baidu.editor.ui.uiUtils.getFixedLayer().style.zIndex = this.getDom().style.zIndex - 4;
}
}
}
}
}
下面来分析一下编辑器初始化代码,因为在编辑器点击一下,然后点击模板按钮插入模板,是要插入到之前点击的位置,那么之前在编辑框内点击时编辑器如何获取位置或selection选取区域以及如何保存是个问题。
_setup: function (doc) {
me.selection = new dom.Selection(doc);
this.selection.getNative()
this._initEvents();
_initEvents: function () {
domUtils.on(doc, ['click', 'contextmenu', 'mousedown', 'keydown', 'keyup', 'keypress', 'mouseup', 'mouseover', 'mouseout', 'selectstart'], me._proxyDomEvent);
domUtils.on(win, ['focus', 'blur'], me._proxyDomEvent);
_proxyDomEvent: function (evt) {
this.fireEvent(evt.type.replace(/^on/, ''), evt)
}
domUtils.on(doc, ['mouseup', 'keydown'], function (evt) {
me._selectionChange(250, evt);
_selectionChange: function (delay, evt) {
me.fireEvent('selectionchange', !!evt);
}
}
//编辑器不能为空内容 if (domUtils.isEmptyNode(me.body)) { me.body.innerHTML = '<p>' + (browser.ie ? '' : '<br/>') + '</p>';
}
//如果要求focus, 就把光标定位到内容开始
if (options.focus) {
setTimeout(function () {
me.focus(me.options.focusInEnd);
//如果自动清除开着,就不需要做selectionchange;
!me.options.autoClearinitialContent && me._selectionChange();
}, 0);
}
从初始化事件代码看,绑定物理事件清清楚楚,对于mouseup事件,是按selectionchange事件去找handler执行,这个selectionchange事件有几十个handler都要执行,因为源码中有几十个editor.addListener('selectionchange',handler)语句,
就不知道是哪个handler是处理range的,代码在哪里?要在89个函数中查找分析哪个函数是响应点击选取处理range的,难度很大,源代码非常复杂深奥高超。
经过不懈的努力,还好所幸最后终于发现有一个处理mouseup物理事件的handler代码处理了range:
UE.plugins['table'] = function () {
me.ready(function () {
me.addListener("mouseup", mouseUpEvent);
function mouseUpEvent(type, evt) {
range = new dom.Range(me.document);
range.setStart(target, 0).setCursor(false, true);
me._selectionChange(250, evt);
//变化选区
_selectionChange: function (delay, evt) {
me.selection.cache(); // 获取选区保存到cache
/**缓存当前选区的range和选区的开始节点
cache:function () {
this.clear(); // 先清除历史range再保留最近的range
this._cachedRange = this.getRange();
getRange:function () {
var sel = me.getNative();
//由于已经清除历史range,此刻cache是空的,无cache数据可用,则执行下面代码
if ( sel && sel.rangeCount ) { //根据sel数据设置range数据最后返回range数据
var firstRange = sel.getRangeAt( 0 );
var lastRange = sel.getRangeAt( sel.rangeCount - 1 );
range.setStart( firstRange.startContainer, firstRange.startOffset ).setEnd( lastRange.endContainer, lastRange.endOffset );
if ( range.collapsed && domUtils.isBody( range.startContainer ) && !range.startOffset ) {
optimze( range );
}
return this._bakRange = range;
}
因此,在编辑框内点击或选取时,是执行mouseUpEvent处理range,获取和保存当前点击位置或选取的区域,之后再插入模板时要获取插入位置,就是从cache取这个保存的range数据,插入模板时不可能再调用getRange()获取点击位置或选取区域,因为点击工具按钮之后,之前在编辑框内的点击或选取的光标状态已经改变不存在了。
下面看几个ueditor源码中的正则匹配表达式,学习一下正则:
var re_tag = /<(?:(?:\/([^>]+)>)|(?:!--([\S|\s]*?)-->)|(?:([^\s\/<>]+)\s*((?:(?:"[^"]*")|(?:'[^']*')|[^"'<>])*)\/?>))/g,
匹配以下html标签写法:
</xxx> //比如</br>
<!--xxx-->
<xxx "xxx" />
<xxx 'xxx' />
<xxx xxx />
re_attr = /([\w\-:.]+)(?:(?:\s*=\s*(?:(?:"([^"]*)")|(?:'([^']*)')|([^\s>]+)))|(?=\s|$))/g;
匹配以下html标签属性写法:
xxx:xxx-xxx.xxx = "xxx"
xxx = 'xxx'
xxx = xxx
xxx
是不是晕? ?:可以忽略,就好看一点了。
源码中运用正则替换修改字符串的例子:
htmlstr = htmlstr.replace(new RegExp('[\\r\\t\\n'+(ignoreBlank?'':' ')+']*<\/?(\\w+)\\s*(?:[^>]*)>[\\r\\t\\n'+(ignoreBlank?'':' ')+']*','g'), function(a,b){
以 \n <div >xxx</div > \n 为例,匹配到两次,第一次匹配 \n <div>,第二次匹配 </div> \n,调用function两次,a参数就是匹配的串,b参数是匹配的串里面的子匹配串
(\\w+)文字符串,就是div,replace方法里面如果写function,function返回的就是替换内容。
return a.replace(new RegExp('^[\\r\\n'+(ignoreBlank?'':' ')+']+'),'').replace(new RegExp('[\\r\\n'+(ignoreBlank?'':' ')+']+$'),'');
按pattern找到串之后去掉头尾的换行符返回做为替换内容,结果就是把源字符串中标签头尾的换行符去掉,返回修改之后的字符串。
本文到这里差不多就结束了,ueditor的工具按钮都以plugin插件方式定义,机制都一样,只是功能不同,本文不再一一分析,本文只以插入模板这个工具按钮为例进行了重点分析,其它插件应该都是类似的。
通过源代码分析学习,本人感觉ueditor是学习网页元素处理的顶峰,而前端框架是学习用对象编程技术实现组件机制和语义化表达式解析的顶峰,webuploader则是学习模块化编程的典范,只不过模块化编程现在已经被放弃了被webpack取代了。
本人水平有限,文中错误之处欢迎大家指正和交流。