富文本编辑器的基本原理与实践

2012年4月2日 发表评论 阅读评论

富文本编辑器,Rich Text Editor, 简称 RTE, 它提供类似于 Microsoft Word 的编辑功能,容易被不会编写 HTML 的用户并需要设置各种文本格式的用户所喜爱。它的应用也越来越广泛。最先只有 IE 浏览器支持,其它浏览器相继跟进,在功能的丰富性来说,还是 IE 强些。虽然没有一个统一的标准,但对于最基本的功能,各浏览器提供的 API 基本一致,从而使编写一个跨浏览器的富文本编辑器成为可能。

在很多开发者看来,富文本编辑器的编写是一件很神秘或者复杂的事情。神秘倒没有,复杂的话,确实如此。但是它的基本原理并不复杂,入门也不难。今天 我们的主题是讲述基本原理,并逐步演示一个简单富文本编辑器的产生。这是我在 D2 上的一个分享内容,在台上的演讲效果不佳,固写下来,希望能够对感兴趣的读者有所帮助。

富文本编辑器的基本原理
这个原理实在是太简单了!对于支持富文本编辑的浏览器来说,其实就是设置 document 的 designMode 属性为 on 后,再通过执行 document.execCommand(‘commandName’[, UIFlag[, value]]) 即可。commandName 和 value 可以在 MSDN 上和MDC 上找到,它们就是我们创建各种格式的命令,比方说,我们要加粗字体,执行 document.execCommand(‘bold’, false) 即可。很简单是吧?但是值得注意的是,通常是选中了文本后才执行命令,被选中的文本才被格式化。对于未选中的文本进行这个命令,各浏览器有不同的处理方 式,比方 IE 可能是对位于光标中的标签内容进行格式化,而其它浏览器不做任何处理,这超出本文的内容,不细述。同时需要注意的是,UIFlag 这个参数设置为 true 表示 display any user interface triggered by the command (if any), 在我们今天的教程中都是 false, 而 value 也只在某些 commandName 中才有,具体参考以上刚给出的两个链接。

为了不影响当前 document, 通常的做法是在页面中嵌入一个 iframe 元素,然后对这个 iframe 内的 document(通过 iframe.contentWindow.document 获得)进行操作。

十分简单,是吧?下面我们来动手做一个。

编写一个简单的富文本编辑器
这个例子使用了 YUI. 即使你对它不是很熟悉也没有关系,我在这里只使用了它的 DOM 和 Event 的一些跨平台基本方法。

搭架
在此强调一下很久未曾提及的 unobtrusive. 我们的编辑器是对 textarea 元素的一个增强(enhencement),就是说,即使 JavaScript 被禁用了,用户还可以通过 textarea 编辑内容。

在这个例子中,我们将使用 YAHOO.realazy 的命名空间,在之下实现一个 RTE 的类。我们今天的编辑器很简单,因此构造器(constructor) 的参数也只有 textarea 一个。我们使用一个实例变量来保存工具条的各个项目。实例初始化放到一个叫 render 的方法中。这一步的页面和代码见第 1 步。

//=============
if (!YAHOO.realazy)
2 YAHOO.realazy = {};
3
4 if (!YAHOO.realazy.RTE)
5 YAHOO.realazy.RTE = {};
6
7 (function(){
8 var $D = YAHOO.util.Dom,
9 $E = YAHOO.util.Event;
10
11 var ua = YAHOO.env.ua,
12 isIE = ua.ie,
13 isGecko = ua.gecko,
14 isOpera = ua.opera,
15 isWebkit = ua.webkit;
16
17 /**
18 * 替换 textarea, 实现 user-editable 的内容区域
19 * @param {string | HTMLElement} elTextarea 需替换的 textarea 元素的 id 或者引用
20 * @class
21 */
22 YAHOO.realazy.RTE = function(elTextarea){
23 if (‘string’ == typeof elTextarea) elTextarea = $D.get(elTextarea);
24 this.textarea = elTextarea;
25 this.toolbarItems = {};
26 }
27
28 /** @scope YAHOO.realazy.RTE.prototype */
29 YAHOO.realazy.RTE.prototype = {
30
31 /**
32 * 生成编辑器
33 */
34 render: function(){
35 }
36 }
37 })();

//=======

创建 iframe 并替换 textarea
搭好架子,正如我在前面所说,建立一个 iframe, 编辑器的所有操作都在 iframe 的 document 内执行。并且把 textarea 隐藏起来。从第 2 步中可以看到,我们已经有了一个 iframe, 但不能输入任何东西,很正常,我们没有打开它的 designMode 嘛。

开启 designMode
这一步涉及的东西挺多,也是关键。我们会创建获取 iframe 的 document 的方法,并通过程序的方式向 iframe 写入空页而非使用一个外接的 blank.html. 我们使用一个类属性 YAHOO.realazy.RTE.htmlContent 来保存空页的 html. 在准备好一切后,就可以开启 designMode 了。页面和代码详见第 3 步。看,我们已经可以在 iframe 里输入东西了。

构建工具条
我们需要操作的工具条!这样才可以控制 iframe 里的内容,才能称之为编辑器。在此我并不打算实现太多的功能,只是选择字形、字号、加粗、斜体、下划线、居左、居中、居右、超链接和插图作为示例。对于跨 平台,Mozilla Midas Specification 是不错的参考。ok, 请看第 4 步,我们的工具条出来了,虽然很丑。我同时用 CSS 对 iframe 的宽度做出了一些调整。

给工具条加上事件
嗯,工具条出来了,编辑器看起来也“人模狗样”了,你兴奋的点啊点,没什么效果……意料中嘛。我们接着给工具条绑定一些事件,让编辑器内容能够响应工具 条。在这一步,我们把 execCommand 再封一层,前面说过,我们用不上 UIFlag,让它永远是 false 好了。好,有代码就有真相,请看第 5 步。如果是正使用 IE, 请先暂时转移到其它浏览器。看到了吧,工具条生效了!

解决 IE 的问题
well, 如果你没有听我的劝告,依然使用 IE, 你会发现除了字型和字号其它的都不能用。为什么呢?你观察一下,有没有发现,其它浏览器选择文本后,再点击工具条上的项目,被选中的文本是否依然选中的? 而 IE 呢,在点击工具条时,选中的文本马上失去选中的状态,所以它们就失败了。所以,如果我们能够保证点击工具条文本保持选中状态,就可以解决 IE 的问题了。

Microsoft 给 HTML 标签一个很奇怪的属性 unselectable, 只要设置为 on, 焦点不会转移到点击的元素上,从而保证文本的选中状态。

请看第 6 步。这也是解决 IE 头痛问题的关键所在。我曾经在这上面费了很大脑筋。

高级主题展望
good, 看看我们现在的代码,224 行。相比其它动辄上万行的编辑器,你可能会觉得不可思议。因为我们这个最基本的编辑器,连 selection 都没有用到。很多很酷的效果,比如 Google Doc 里能够动态改变链接文本,使用页内层而非弹出的 prompt 来操作等高级功能,基本上都要用到 TextRange(IE) 或者 Range(W3C). 要命的是这两个东西互补兼容,只是相似而已。入门推荐看PPK 的 Introduction to Range.

在此我们就不深入了,等我有时间我会总结一些奇技淫巧(呜呼,前端开发需要的奇技淫巧太多了,这不是好事情)出来。

//===================

1 if (!YAHOO.realazy)
2 YAHOO.realazy = {};
3
4 if (!YAHOO.realazy.RTE)
5 YAHOO.realazy.RTE = {};
6
7 (function(){
8 var $D = YAHOO.util.Dom,
9 $E = YAHOO.util.Event;
10
11 var ua = YAHOO.env.ua,
12 isIE = ua.ie,
13 isGecko = ua.gecko,
14 isOpera = ua.opera,
15 isWebkit = ua.webkit;
16
17 /**
18 * 替换 textarea, 实现 user-editable 的内容区域
19 * @param {string | HTMLElement} elTextarea 需替换的 textarea 元素的 id 或者引用
20 * @class
21 */
22 YAHOO.realazy.RTE = function(elTextarea){
23 if (‘string’ == typeof elTextarea) elTextarea = $D.get(elTextarea);
24 this.textarea = elTextarea;
25 this.toolbarItems = {};
26 }
27
28 /**
29 * 预填充 iframe 的内容
30 *
31 */
32 YAHOO.realazy.RTE.htmlContent = ['<!DOCTYPE HTML PUBLIC "-/','/W3C/','/DTD HTML 4.01/','/EN" "http:/','/www.w3.org/TR/html4 /strict.dtd"><html><head><title>编辑页面< /title><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /></head><body>{CONTENT}</body></html>'].join(”);
33
34 /** @scope YAHOO.realazy.RTE.prototype */
35 YAHOO.realazy.RTE.prototype = {
36
37 /**
38 * 生成编辑器
39 */
40 render: function(){
41 // step 1: 生成 iframe 并隐藏 textarea
42 this._createIframeAndHideTextarea();
43 // step 2: 开启 iframe 的 designMode
44 this._turnOnDesignMode();
45 // step 3: 建立 toolbar
46 this._buildToolbar();
47 // step 4: 绑定工具条事件
48 this._bindToolbarEvents();
49 // step 5: 设置工具条的 unselectable
50 if (isIE)
51 this._setToolbarItemUnselectable();
52 },
53 /**
54 * 生成 iframe 并隐藏 textarea
55 * @private
56 */
57 _createIframeAndHideTextarea: function(){
58 var iframe = document.createElement(‘iframe’);
59 $D.addClass(iframe, ‘rte-iframe’);
60 this.textarea.style.display = ‘none’;
61 $D.insertBefore(iframe, this.textarea);
62 this.iframe = iframe;
63 },
64 /**
65 * 获取 iframe 的 contentWindow
66 * @private
67 */
68 _getWin: function(){
69 return this.iframe.contentWindow;
70 },
71
72 /**
73 * 获取 iframe 的 document
74 * @private
75 */
76 _getDoc: function(){
77 return this._getWin().document;
78 },
79
80 /**
81 * 开启 iframe 的 design mode
82 * @private
83 */
84 _turnOnDesignMode: function(){
85 try {
86 this._getDoc().open();
87 this._getDoc().write(YAHOO.realazy.RTE.htmlContent.replace(/{CONTENT}/, this.textarea.value));
88 this._getDoc().close();
89 this._getDoc().designMode = ‘on’;
90 } catch(ex) {
91 setTimeout(arguments.callee, 0);
92 return;
93 }
94 },
95
96 /**
97 * 建立 editor 的 toolbar
98 * @private
99 */
100 _buildToolbar: function(){
101 var toolbar = document.createElement(‘div’);
102 $D.addClass(toolbar, ‘rte-toolbar’);
103
104 var items = {
105 ‘fontname’: {
106 ‘Arial’: ‘Arial’,
107 ‘宋体’: "SimSun, STSong, ‘LiSong Pro’",
108 ‘黑体’: "SimHei, STHeiti, ‘LiHei Pro’"
109 },
110 ‘fontsize’: {
111 ‘最小’: 1,
112 ‘较小’: 2,
113 ‘小’: 3,
114 ‘中’: 4,
115 ‘大’: 5,
116 ‘较大’: 6,
117 ‘最大’: 7
118 },
119 ‘bold’: ‘加粗’,
120 ‘italic’: ‘斜体’,
121 ‘underline’: ‘下划线’,
122 ‘justifyleft’: ‘居左’,
123 ‘justifycenter’: ‘居中’,
124 ‘justifyright’: ‘居右’,
125 ‘createlink’: ‘超链接’,
126 ‘insertimage’: ‘插图’
127 }
128
129 for (var k in items){
130 var span = document.createElement(‘span’);
131 if (k == ‘fontname’ || k == ‘fontsize’){
132 var select = document.createElement(‘select’);
133 $D.addClass(select, k);
134 for (var kk in items[k]){
135 var option = new Option(kk, items[k][kk]);
136 if (!isGecko)
137 select.add(option);
138 else
139 select.appendChild(option);
140 }
141 span.appendChild(select);
142 this.toolbarItems[k] = select;
143 } else {
144 $D.addClass(span, k);
145 span.innerHTML = items[k];
146 this.toolbarItems[k] = span;
147 }
148 toolbar.appendChild(span);
149 }
150
151 $D.insertBefore(toolbar, this.iframe);
152 this.toolbar = toolbar;
153 },
154 /**
155 * execCommand
156 * @param {string} sCmd command name
157 * @param {string} sVal command Value
158 * @private
159 */
160 _execCommand: function(sCmd, sVal){
161 this._getWin().focus();
162 this._getDoc().execCommand(sCmd, false, sVal);
163 },
164
165 /**
166 * 绑定 toolbar 的 events
167 * @private
168 */
169 _bindToolbarEvents: function(){
170 var _this = this,
171 items = this.toolbarItems,
172 trim = YAHOO.lang.trim;
173
174 $E.on([items['fontsize'], items['fontname']], ‘change’, function(e){
175 var cmd = this.className,
176 val = this.options[this.selectedIndex].value;
177 _this._execCommand(cmd, val);
178 });
179
180 $E.on(this.toolbar, ‘click’, function(e){
181 var t = $E.getTarget(e),
182 cmd = t.className,
183 nn = t.nodeName.toLowerCase(),
184 val = null,
185 isNeedVal = false;
186 if (nn != ‘span’ && !cmd) return;
187 switch (cmd){
188 case ‘createlink’:
189 case ‘insertimage’:
190 isNeedVal = true;
191 val = prompt(‘请输入网址:’, ‘http://’);
192 break;
193 case ‘fontname’:
194 case ‘fontsize’:
195 return;
196 default:
197 break;
198 }
199
200 if (isNeedVal && (!val || (trim(val) == ‘http://’))) return;
201 this._execCommand(cmd, val);
202
203 }, this, true);
204 },
205
206 /**
207 * 让 toolbar 上的各个项目不可选,以解决 selection 被冲掉的问题
208 * @private
209 */
210 _setToolbarItemUnselectable: function(){
211 function unsel(elm){
212 elm.setAttribute(‘unselectable’, ‘on’);
213 }
214 unsel(this.toolbar);
215 var els = this.toolbar.getElementsByTagName(‘*’),
216 nn;
217 for (var i=0; i<els.length; ++i){
218 nn = els[i].nodeName;
219 if (nn && nn.toLowerCase() != ‘input’)
220 unsel(els[i]);
221 }
222 }
223 }
224 })();

//===============
一个已经有内容的 textarea 元素,在执行该元素的 .focus() 方法后,不同的浏览器有不同表现。我们的预期是能够出现在内容后面,但只有 gecko 浏览器能做到。

修复
注意:这个函数不能直接运行,函数内的 isIE, isOpera 和 isWebkit 需要你的库提供或你编写,这并不难,对吧。

function fixTextareaFocusCursorPosition(elTextarea){
if (isIE || isOpera){
var rng = elTextarea.createTextRange();
rng.text = elTextarea.value;
rng.collapse(false);
} else if (isWebkit) {
elTextarea.select();
window.getSelection().collapseToEnd();
}
}


转载请注明来自:[MSN Spaces]http://msn.shandian.biz/214.html

  1. 本文目前尚无任何评论.