/**@license setplus: a lightweight HTML editor
 * @author  Levente Hunyadi
 * @version 0.2.0
 * @remarks Copyright (C) 2010-2011 Levente Hunyadi
 * @remarks Licensed under GNU/GPLv3, see http://www.gnu.org/licenses/gpl-3.0.html
 * @see     http://hunyadi.info.hu/projects/setplus
 **/

/*
* setplus: a lightweight HTML editor
* Copyright 2010-2011 Levente Hunyadi
*
* setplus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* setplus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with setplus.  If not, see <http://www.gnu.org/licenses/>.
*/

(function ($) {
	//
	// Test for requirements
	//

	if (!document.execCommand) {
		return;  // unsupported browser
	}

	/**
	* Array.lastIndexOf compatibility method.
	* Internet Explorer 8 and below do not have native Array.lastIndexOf.
	*/
	if (!Array.prototype.lastIndexOf) {
		Array.prototype.lastIndexOf = function(search) {
			for (var k = this.length - 1; k >= 0; k--) {
				if (this[k] === search) {
					return k;
				}
			}
			return -1;
		};
	}

	function _class(cls) {
		return 'setplus-' + cls;
	}

	/**
	* Tests if the browser supports an event.
	* @param {string} event The name of the event to test, without the "on" prefix.
	*/
	function isEventSupported(event) {
		var el = new Element('input');
		event = 'on' + event;
		el.setAttribute(event, 'return;');  // create event handler
		return typeof el[event] == 'function';
	}

	// extend set of available events
	$extend(Element.NativeEvents, {
		/*'cut' : 1,
		'copy' : 1,
		'paste': 1,*/
		'beforedeactivate': 1  // Internet Explorer only
	});
	if (isEventSupported('input')) {
		Element.NativeEvents.input = 1;
	} else {
		// redirect "input" event to "propertychange" event
		Element.NativeEvents.propertychange = 1;
		Element.Events.input = {
			'base': 'propertychange'
		};
	}

	//
	// Implementation
	//

	setplus = {};

	setplus.DropDownList = new Class({
		'Implements': Events,

		/**
		* The HTML <div> element whose children implement the drop-down list.
		*/
		//_control: null,

		'initialize': function (items, title) {
			var self = this;

			var control = new Element('div', {
				'class': _class('list')
			}).adopt(
				new Element('span', {
					'class': _class(title)
				}),
				new Element('span', {
					'class': _class('list')
				}),
				new Element('ul', {
					'class': _class('list')
				}).adopt(items.map(function (item) {
					return new Element('li').adopt(item).addEvent('click', function () {
						self.fireEvent('change', this);
					})
				}))
			).addEvents({
				'click': function () {
					if (!control.hasClass(_class('disabled'))) {
						control.toggleClass(_class('open'));
						return false;
					}
				},
				'mouseleave': function () {
					control.removeClass(_class('open'));
				}
			});
			control.getElements('*').set('unselectable', 'on');  // prevent selection in Internet Explorer
			self._control = control;
		},

		'toElement': function () {
			return this._control;
		},

		'setEnabled': function (state) {
			this._control.toggleClass(_class('disabled'), !state);
		}
	});

	setplus.Dialog = new Class({
		'initialize': function () {
			var self = this;
			var dialog = new Element('div', {
				'class': _class('dialog')
			}).adopt(
				new Element('div', {
					'class': _class('titlebar')
				}).adopt(
					self._title = new Element('div', {
						'class': _class('title')
					}),
					new Element('div', {
						'class': _class('close'),
						'events': {
							'click': self.hide.bind(self)
						}
					})
				),
				new Element('div', {
					'class': _class('container')
				}).adopt(
					self._content = new Element('div', {
						'class': _class('content')
					})
				),
				new Element('div', {
					'class': _class('resize')
				})
			).inject(document.body);

			// enable drag & drop support
			if (Drag) {
				new Drag(dialog, {
					'handle': dialog.getElement('.setplus-title')
				});
				new Drag(dialog, {
					'handle': dialog.getElement('.setplus-resize'),
					'modifiers': {
						'x': 'width',
						'y': 'height'
					}
				});
			}

			self._dialog = dialog;
			self.center();
			dialog.addClass(_class('hidden'));
		},

		'toElement': function () {
			return this._dialog;
		},

		'show': function () {
			return this._dialog.removeClass(_class('hidden'));
		},

		'hide': function () {
			return this._dialog.addClass(_class('hidden'));
		},

		/**
		* Centers the dialog on the screen.
		*/
		'center': function () {
			var dlgsize = this._dialog.getSize();
			var winsize = window.getSize();
			this._dialog.setStyles({
				left: (winsize.x - dlgsize.x) / 2,
				top: (winsize.y - dlgsize.y) / 2
			});
		},

		'resize': function () {
			var self = this;
			self._content.getParent().setStyle('width', 'auto');
			self._dialog.setStyles({
				'width': self._content.getSize().x + 2,
				'height': self._content.getSize().y + 2 + 28
			});
			self._content.getParent().setStyle('width', '100%');
			self.center();
			return self;
		},

		'adopt': function (el) {
			this._content.adopt(el);
			return this;
		}
	});

	/**
	* A dialog associated with an editor.
	*/
	setplus.EditorDialog = new Class({
		'Extends': setplus.Dialog,

		'initialize': function () {
			var self = this;
			self.parent();
		},

		/**
		* Invokes the action on the associated editor instance.
		*/
		'invoke': function (action) {
			var editor = document.retrieve('setplus-editor').editor;  // fetch editor instance from document element store
			action(editor);
			editor.focus();
		}
	});

	/**
	* Finds the full URL to the setplus editor base javascript file.
	*/
	function _baseURL(path, file, extension) {
		var re = /^(.*)\bsetplus(\.min)?\.(js|php)(?:[?#].*)?$/;
		return $$('script').filter(function (scripttag) {  // contains at least one element, which is the current script
			return re.test(scripttag.src);
		})[0].src.replace(re, '$1' + path + '/' + file + '$2.' + extension);  // $2 is empty or equals ".min"
	}

	setplus.HTMLEditor = new Class({
		'Implements': Events,

		/**
		* The <iframe> element representing editable content.
		* @type {Element}
		*/
		//_frame: null,
		/**
		* The body of the document inside the iframe.
		*/
		//_body: null,

		_window: function () {
			return this._frame.contentWindow;
		},

		/**
		* Returns the document inside the <iframe> element.
		*/
		_document: function () {
			return this._frame.contentWindow.document;  // same as self._frame.contentDocument
		},

		_insertHTML: function (html) {
			this._document().execCommand('inserthtml', false, html);
		},

		'initialize': function () {
			var self = this;

			// create editor control
			new Element('div', {
				'class': _class('editorpane')
			}).adopt(
				self._frame = new IFrame()
			);

			/**
			* Executes a command within the <iframe> document element.
			*/
			function _execute(cmd, param) {
				var result = false;
				try {
					result = $pick(self._bookmark, self._document()).execCommand(cmd, false, param);
					self.update();
				} catch (e) { }
				return result;
			}

			function _executeDocument(cmd) {
				self._document().execCommand(cmd, false, false);
			}

			/**
			* Executes a command with the <iframe> document element with or without using CSS styles.
			*/
			function _executeWithCSS(cmd, styleWithCSS) {
				_execute('styleWithCSS', styleWithCSS);
				return _execute(cmd);
			}

			/**
			* Wraps HTML code into HTML tags.
			* @param {string} tag The name of the element to wrap the HTML into.
			* @param {Object} attrs HTML attributes.
			*/
			function _wrapHTML(html, tag, attrs) {
				return new Element('div').adopt(
					new Element(tag, $extend({
						'html': html
					}, attrs))
				).get('html');
			}

			function _insert(html) {
				if (html) {
					if (self._insertHTML) {  // standard-compliant browsers
						self._insertHTML(html);
					} else if (self._bookmark) {  // Internet Explorer only
						self._bookmark.pasteHTML(html);
					}
				} else {
					_executeDocument('delete');
				}
				self.update();
			}

			function _insertElement(el) {
				_insert(new Element('div').adopt(el).get('html'));
			}

			function _surround(tag, attrs) {
				var doc = self._document();
				if (self._bookmark) {  // Internet Explorer only
					_insert(_wrapHTML(self._bookmark.htmlText, tag, attrs));
				} else if (doc.defaultView && doc.defaultView.getSelection) {  // standard-compliant browsers
					var frag = doc.defaultView.getSelection().getRangeAt(0).cloneContents();
					var el = new Element('div');
					el.appendChild(frag);
					_insert(_wrapHTML(el.innerHTML, tag, attrs));
				}
			}

			var _activeParent = self.activeParent.bind(self);

			/**
			* Executes a series of commands on a DOM subtree.
			* By making a copy of the original DOM subtree and using the "Insert HTML" command, the undo stack is not corrupted.
			*/
			function _executeOnDOM(node, callback) {
				// if browser supports insert HTML command, operate on a copy rather than the original
				var nodecopy = self._insertHTML ? node.clone(true,true) : node;  // copy element identifier on cloning since it will eventually overwrite original

				// invoke callback with original or copy
				nodecopy = callback(nodecopy);

				// if a copy has been made, insert the copy HTML
				if (node != nodecopy) {
					self.select(node);
					if (nodecopy) {
						_insertElement(nodecopy);
					} else {
						_executeDocument('delete');
					}
				}
			}

			/**
			* Locates the next occurrence of a string in the document.
			*/
			function _find(str, isCaseSensitive, isBackwards, isWholeWord) {
				var doc = self._document();
				var win = self._window();

				var found;
				if (win.find) {  // Firefox, Google Chrome, Safari
					// if content is selected, the start position of the search will be the end position of the selection
					found = win.find(str, isCaseSensitive, isBackwards, false, isWholeWord);
				} else if (doc.selection) {  // Internet Explorer only
					self.focus();  // set focus to <iframe> to ensure search works even for collapsed selections
					var range = doc.selection.createRange().duplicate();
					range.collapse(isBackwards);
					found = range.findText(str, isBackwards ? -1 : 1, isBackwards | (isWholeWord << 1) | (isCaseSensitive << 2));
					if (found) {
						range.select();
						self._bookmark = range;  // update current saved selection as if set from user interface
					}
				}
				return found;
			}

			// add actions
			self['print'] = function () {
				self._window().print();
			};
			self['find'] = _find;
			self['replace'] = function (str, replace, isCaseSensitive, isBackwards, isWholeWord) {
				var win = self._window();
				var doc = self._document();
				
				if (win.getSelection && win.getSelection().toString() == str || doc.selection && doc.selection.createRange().text == str) {
					_insert(replace);
				}
				_find(str, isCaseSensitive, isBackwards, isWholeWord);
			};
			['undo','redo','selectall'].each(function (action) {  // simple global actions
				self[action] = function () {
					_executeDocument(action);
				}
			});
			['cut','copy','paste','unlink'].each(function (action) {  // simple actions on a selection
				self[action] = function () {
					_execute(action);
				}
			});
			self['removeformat'] = function () {
				var body = self._document().body;
				var style = body.getProperty('style');
				body.removeProperty('style');  // fix class="Apple-style-span" added to elements in Chrome
				_execute('removeformat');
				body.setProperty('style', style);
			};
			['bold','italic','subscript','superscript','indent','outdent'].each(function (action) {
				self[action] = function () {
					_executeWithCSS(action, false);  // disable usage of attribute "style" for bold, italic, indent, outdent, etc. in Firefox
				}
			});
			self['code'] = function () {
					_execute('formatblock', '<'+'code'+'>');
				//_surround('code');
			};
			self['color'] = function (color) {
				_execute('forecolor', color);
			};
			['left','center','right'].each(function (action) {
				self['align' + action] = function () {
					_executeWithCSS('justify' + action, true);
				};
			});
			self['alignjustify'] = function () {
				_executeWithCSS('justifyfull', true);
			};
			['p','h1','h2','h3','h4','h5','h6','pre','div'].each(function (action) {
				self[action] = function () {
					_execute('formatblock', '<'+action+'>');
				};
			});
			['orderedlist','unorderedlist'].each(function (action) {
				self[action] = function () {
					_execute('insert' + action);
				};
			});
			self['pastehtml'] = function (html) {
				_insert(html);
			};
			self['pasteplain'] = function (text) {
				_insert(new Element('div').set('text', text).get('html'));  // escape special characters text to insert
			};
			self['link'] = function (anchor) {
				_execute('unlink');
				if (anchor.get) {  // an instance of the class Element
					_insertElement(anchor);
				} else {  // a vanilla object
					var keys = Object.keys(anchor);
					if (keys.length == 1 && keys[0] == 'href') {  // has a single href attribute only
						_execute('createlink', anchor[keys[0]]);
					} else {  // has other element attributes
						_surround('a', anchor);
					}
				}
			};
			self['image'] = function (url, attrs) {
				if (Object.getLength(attrs)) {  // has any element attributes
					_surround('img', $extend({
						'src': url
					}, attrs));
				} else {
					_execute('insertimage', url);
				}
			};
			self['table'] = function (rows, cols) {
				rows = rows ? rows : 2;
				cols = cols ? cols : 2;

				var i,j;
				var table = new Element('table');
				for (i = 0; i < rows; i++) {
					var tablerow = new Element('tr').inject(table);
					for (j = 0; j < cols; j++) {
						new Element('td').inject(tablerow);
					}
				}
				_insertElement(table);
			};
			['rowbefore','rowafter'].each(function (action, index) {
				self[action] = function () {
					var row = _activeParent('tr');
					if (row) {
						_executeOnDOM(_activeParent('table'), function (table) {
							var cellcount = row.cells.length;
							row = table.insertRow(row.rowIndex + index);  // rowbefore gets index 0, rowafter gets index 1
							cellcount.times(function () {
								row.insertCell(-1);
							});
							return table;
						});
					}
				};
			});
			self['rowdelete'] = function () {
				var row = _activeParent('tr');
				if (row) {
					_executeOnDOM(_activeParent('table'), function (table) {
						table.deleteRow(row.rowIndex);
						return table.rows.length > 0 ? table : null;  // drop empty tables
					});
				}
			};
			['colbefore','colafter'].each(function (action, index) {
				self[action] = function () {
					var cell = _activeParent('td,th');
					if (cell) {
						_executeOnDOM(_activeParent('table'), function (table) {
							var cellindex = cell.cellIndex;
							$$(table.rows).each(function (row) {
								row.insertCell(cellindex + index);  // colbefore gets index 0, colafter gets index 1
							});
							return table;
						});
					}
				};
			});
			self['coldelete'] = function () {
				var cell = _activeParent('td,th');
				if (cell) {
					_executeOnDOM(_activeParent('table'), function (table) {
						var cellindex = cell.cellIndex;
						var rows = $$(table.rows);
						rows.each(function (row) {
							row.deleteCell(cellindex);
						});
						return rows.some(function (row) {  // check whether all rows are empty
							return row.cells.length > 0;
						}) ? table : null;  // drop empty tables
					});
				}
			};
		},

		/**
		* Returns the editor as an instance of the MooTools Element class.
		*/
		'toElement': function () {
			return this._frame.getParent();
		},

		'focus': function () {
			this._frame.focus();
			this._window().focus();
		},

		/**
		* Moves the selection to an element.
		*/
		'select': function (el) {
			var self = this;
			var doc = self._document();
			if (doc.selection) {  // Internet Explorer only
				self._bookmark = doc.selection.createRange().duplicate();  // save selection
			}

			var win = self._window();
			if (win && win.getSelection) {
				var range = doc.createRange();
				range.selectNode(el);
				var sel = win.getSelection();
				sel.removeAllRanges();
				sel.addRange(range);  // make the range the current selection
			}
		},

		/**
		* The element the caret is currently inside.
		*/
		'activeElement': function () {
			var self = this;
			var doc = self._document();
			var win = self._window();
			if (win && win.getSelection) {  // standard-compliant browsers
				var selection = win.getSelection();
				if (selection && selection.rangeCount) {
					var node = selection.getRangeAt(0).commonAncestorContainer;  // a text node or an element node
					return $(node.nodeType != 1 ? node.parentNode : node);  // find closest element node
				}
			} else if (doc.selection) {  // Internet Explorer only
				var range = doc.selection.createRange();
				return $(range.parentElement());
			}
			return null;
		},

		'activeParent': function (tag) {
			var el = this.activeElement();
			return el && $$(el).append(el.getParents()).filter(tag)[0];
		},

		/**
		* Prepares the editor for first use.
		*/
		'prepare': function (callback) {
			var self = this;
			var doc;
			var _update = self.update.bind(self);

			var cancel = false;
			function _onComplete() {  // wait until MooTools script loads and perform initialization
				// prevent double-entry into function body
				if (cancel) {
					return;
				}
				cancel = true;

				// enable editing
				if (Browser.ie) {
					doc.body.contentEditable = true;
				} else {
					doc.designMode = 'on';
				}

				try {
					// test whether the "Insert HTML" browser command is supported
					self._insertHTML('&nbsp;');
				} catch (e) {
					self._insertHTML = null;  // remove unsupported command
				}

				try {
					// disable object resizing in Firefox
					doc.execCommand('enableObjectResizing', false, false);
					doc.execCommand('insertBrOnReturn', false, false);
				} catch (e) { }

				// add event handling
				self._window().addEvents({
					'keypress': _update  // Chrome, Firefox, IE9
				});
				self._body = $(doc.body).addEvents({  // add statusbar updates
					'mousedown': _update,  // all browsers
					'mouseup': _update,    // all browsers
					'keydown': _update     // Chrome and IE8
				});

				// add selection save and restore (for Internet Explorer only)
				if (doc.selection) {
					function _updateBookmark() {
						self._bookmark = doc.selection.createRange().duplicate();  // save selection
					}
					self._body.addEvents({
						'keyup': _updateBookmark,
						'mouseup': _updateBookmark
					});
					self._frame.addEvents({
						'beforedeactivate': _updateBookmark,
						'activate': function () {  // Internet Explorer only
							if (self._bookmark) {
								self._bookmark.select();
							}
						}
					});
				}

				// invoke callback function if any
				callback && callback();
			}

			// create <iframe> document
			doc = self._document();
			doc.open();  // create an empty document
			doc.write('<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html xmlns="http://www.w3.org/1999/xhtml"><head><style type="text/css">tr, td { height:1em; } td { border:1px solid black; min-width:1em; }</style></head><body></body></html>');
			doc.close();

			// discover MooTools source
			var re = /^.*\bmootools.*\.js(?:$|[?#])/;
			var scripts = $$('script').filter(function (scripttag) {  // typically contains at least one element, which is the MooTools version the current script uses
				return re.test(scripttag.src);
			});
			var source = 'https://ajax.googleapis.com/ajax/libs/mootools/1.3.1/mootools-yui-compressed.js';  // use default MooTools source
			if (scripts.length) {
				source = scripts[0].src;
			}

			// add MooTools support to <iframe>
			var script = doc.createElement('script');
			script.setAttribute('type', 'text/javascript');
			script.setAttribute('src', source);
			script.onload = _onComplete;  // standard-compliant browsers
			script.onreadystatechange = function () {  // Internet Explorer only
				if (this.readyState == 'complete') {
					_onComplete();
				}
			};
			doc.getElementsByTagName('head')[0].appendChild(script);
		},

		/**
		* Updates toolbar buttons and the status bar.
		* The status bar will show breadcrumbs to the current DOM element.
		*/
		'update': function () {
			var self = this;
			var doc = self._document();
			var win = self._window();

			var el = this.activeElement();
			if (el) {
				// get element ancestors
				var ancestors = el.getParents();
				ancestors.pop();  // drop meaningless <html> root element
				ancestors.reverse().push(el);
				var ancestortags = ancestors.get('tag');  // ordered from shallowest to deepest: e.g. html > body > ... > p > em

				// update toolbar button states
				var tablebuttons = $$('.setplus-rowbefore, .setplus-rowafter, .setplus-rowdelete, .setplus-colbefore, .setplus-colafter, .setplus-coldelete');
				if (Math.max(ancestortags.lastIndexOf('td'), ancestortags.lastIndexOf('th')) > ancestortags.lastIndexOf('table')) {  // caret is inside a table cell
					tablebuttons.removeProperty('disabled');
				} else {
					tablebuttons.setProperty('disabled', 'disabled');
				}

				if (!Browser.ie) {  // DOM manipulations clear undo stack in Internet Explorer
					// update status bar
					var breadcrumbs = new Element('span');
					if (doc.createRange) {
						var separator = '';  // do not use separator in front of first item
						ancestors.each(function (ancestor) {
							breadcrumbs.appendText(separator).grab(new Element('a', {
								'href': 'javascript:void(0)',
								'html': ancestor.get('tag')
							}).addEvent('click', function () {
								// scroll element into view
								var pos = ancestor.getPosition();
								win.scrollTo(pos.x, pos.y);

								// change selection
								self.select(ancestor);  // includes the current ancestor element and all its contents
							}));
							separator = ' > ';  // use separator after first item
						});
					} else {
						breadcrumbs.appendText(ancestortags.join(' > '));
					}
					self.fireEvent('status', breadcrumbs);
				}
			} else {
				self.fireEvent('status');
			}
		},

		/**
		* Retrieves HTML content from the editor.
		*/
		'getHTML': function () {
			return this._body.get('html');
		},

		/**
		* Assigns HTML content to the editor.
		*/
		'setHTML': function (html) {
			this._body.set('html', html);
			return this;
		},

		'clear': function () {
			if (this._body) {
				this._body.empty();
			}
		},

		'zoom': function (percentage) {
			var body = this._document().body;
			body.setStyle && body.setStyle('font-size', percentage+'%');
		},

		'blocks': function () {
			var doc = this._document();
			var head = $(doc.head);
			if (!head.getElement('link[href$=setplus.blocks.css]')) {
				head.adopt(new Element('link', {
					'rel': 'stylesheet',
					'type': 'text/css',
					'href': _baseURL('../css','setplus.blocks','css')
				}));
			}
			doc.body.toggleClass(_class('blocks'));
		}
	});

	setplus.TextEditor = new Class({
		'Implements': Events,

		/**
		* The element representing the container for editable content.
		* @type {Element}
		*/
		//_control: null,
		//_textarea: null,
		//_richcontainer: null,

		'initialize': function (attrs) {
			var self = this;

			// create text caret object
			var caret = new Element('span', {
				'html': '&nbsp;',
				'class': _class('caret')
			});
			var caretTimer;

			/**
			* Finds a text position within an HTML subtree.
			* @param {Element} parent The element in which to perform the search.
			* @param {Number} end The end position that delimits the search.
			* @param {Number} start The start position that delimits the search.
			*/
			function _getElementAt(parent, end, start) {
				var node;
				var offset;

				function _getAtPosition(el, end, start) {
					if (el.nodeType == 3) {  // a text node
						if (start + el.length >= end) {  // find position within text node
							node = el;
							offset = end - start;
							start = end;
						} else {  // leap over node
							start += el.length;
						}
					} else {
						var children = el.childNodes;
						for (var i = 0; i < children.length; i++) {
							start = _getAtPosition(children[i], end, start);
							if (start >= end) {
								break;
							}
						}
					}
					return start;
				}

				_getAtPosition(parent, end, start);
				return {
					node: node,     // the innermost node that contains the position
					offset: offset  // the offset of the position w.r.t. the start of the node
				};
			}

			function _getRangeAtSelection() {
				// start position -1 compensates for zero-width space injected as first character of rich text area
				var data = _getElementAt(self._richcontainer.getElement('pre'), self._textarea.selectionEnd, -1);
				var range = document.createRange();
				range.setStart(data.node, data.offset);
				range.setEnd(data.node, data.offset);
				return range;
			}

			/**
			* Updates the position of the caret within the text.
			*/
			function _updateCaret() {
				if (!Browser.ie) {  // Internet Explorer cursor visible even if text color is transparent
					caret.dispose();
					var range = _getRangeAtSelection();
					range.insertNode(caret);
					range.detach();
				}
				self.update();
			}

			/**
			* Starts blinking the caret.
			*/
			function _setCaretTimer() {
				_clearCaretTimer();
				caretTimer = window.setInterval(function () {
					caret.toggleClass(_class('hidden'));
				}, 500);
			}

			/**
			* Stops blinking the caret.
			* Triggered when the editor pane loses focus or the caret is being rapidly repositioned.
			*/
			function _clearCaretTimer() {
				window.clearInterval(caretTimer);
				caret.removeClass(_class('hidden'));
			}

			// create editor control
			self._control = new Element('div', {
				'class': _class('editorpane')
			}).adopt(
				self._richcontainer = new Element('div', {
					'class': _class('preset')
				}).adopt(
					new Element('pre')
				),
				self._textarea = new Element('textarea', $extend({
					'spellcheck': false
				}, attrs ? attrs : {}))
			);

			if (!Browser.opera && !Browser.firefox3) {
				function _highlight(state) {
					$$(self._richcontainer, self._textarea).toggleClass(_class('highlight'), state);
				}

				var insertionRange;
				var insertionStart;
				var insertionLength;

				self._textarea.addEvents({
					'blur': function () {
						_clearCaretTimer();
						caret.dispose();  // remove caret from text when text area loses focus
						_highlight(false);
					},
					'focus': function () {
						_updateCaret();
						_setCaretTimer();
						_highlight(true);

						var textscroll = self._textarea.getScroll();
						self._richcontainer.scrollTo(textscroll.x, textscroll.y);
					},
					'click': _updateCaret,
					'keydown': function (event) {
						var textarea = self._textarea;
						if (!Browser.ie) {
							insertionStart = textarea.selectionStart;
							insertionLength = textarea.textLength;
							if (!['delete','backspace'].contains(event.key) && textarea.selectionStart == textarea.selectionEnd) {  // test for collapsed selection
								insertionRange = _getRangeAtSelection();
							} else {
								insertionRange = null;
							}
						}

						_clearCaretTimer();
						_updateCaret();
					},
					'keyup': function () {
						_updateCaret();
						_setCaretTimer();
					},
					'input': function () {
						var textarea = self._textarea;
						if (!Browser.ie && insertionRange && textarea.selectionStart - insertionStart == 1 && textarea.textLength - insertionLength == 1) {  // a single character has been inserted
							insertionRange.insertNode(document.createTextNode(textarea.value.substr(insertionStart,1)));
							insertionRange = null;
						} else {
							self._updateRichText(textarea.value);
						}

						_updateCaret();
					},
					'scroll': function () {
						var textscroll = self._textarea.getScroll();
						var richscroll = self._richcontainer.getScroll();
						if (textscroll.x != richscroll.x || textscroll.y != richscroll.y) {
							self._richcontainer.scrollTo(textscroll.x, textscroll.y);
						}
					}
				});
			}

			/**
			* Retrieves the text of the current selection.
			*/
			function _getSelectionHTML() {
				var textarea = self._textarea;
				if (textarea.setSelectionRange) {  // standard-compliant browsers
					return textarea.get('value').substring(textarea.selectionStart, textarea.selectionEnd);
				} else if (document.selection) {  // Internet Explorer only
					var range = document.selection.createRange();
					if (range && range.parentElement() == self._textarea) {  // stop processing if the range is not part of the textarea
						return range.text;
					}
				}
			}

			/**
			* Inserts the specified text, replacing the current selection.
			*/
			function _insertHTML(insertion) {
				var scroll = self._textarea.getScroll();

				if (self._textarea.setSelectionRange) {  // standard-compliant browsers
					var startpos = self._textarea.selectionStart;
					var endpos = self._textarea.selectionEnd;
					var text = self._textarea.get('value');
					self._textarea.set('value',
						text.substr(0, startpos) + insertion + text.substr(endpos)
					);

					// set caret position after insertion
					startpos += insertion.length;
					self._textarea.setSelectionRange(startpos, startpos);
				} else if (document.selection) {  // Internet Explorer only
					var range = document.selection.createRange();
					if (range && range.parentElement() == self._textarea) {  // stop processing if the range is not part of the textarea
						range.text = insertion;
					}
				}

				// update the syntax-highlighted text and restore the scroll position
				self._textarea.scrollTo(scroll.x, scroll.y);
				self._textarea.focus();
				self._updateRichText(self._textarea.get('value'));
			}

			/**
			* Inserts an HTML element at the current position, possibly surrounding selected text.
			*/
			function _insertElement(el) {
				var html = new Element('div').adopt(el).get('html');
				_insertHTML(html);
			}

			/**
			* Surrounds the current selection with the specified start and end tags.
			*/
			function _surroundHTML(starttag, endtag) {
				var scroll = self._textarea.getScroll();

				if (self._textarea.setSelectionRange) {  // standard-compliant browsers
					var startpos = self._textarea.selectionStart;
					var endpos = self._textarea.selectionEnd;
					var text = self._textarea.get('value');
					self._textarea.set('value',
						text.substr(0, startpos) +
						starttag + text.substring(startpos, endpos) + endtag +
						text.substr(endpos)
					);

					// set caret position either after entire insertion or in between inserted tags
					if (startpos != endpos) {
						endpos += starttag.length + endtag.length;
					} else {  // operation applied on empty selection, put caret in between inserted tags
						endpos = startpos + starttag.length;
					}
					self._textarea.setSelectionRange(endpos, endpos);
				} else if (document.selection) {  // Internet Explorer only
					var range = document.selection.createRange();
					if (range && range.parentElement() == self._textarea) {  // stop processing if the range is not part of the textarea
						range.text = starttag + range.text + endtag;
					}
				}

				// update the syntax-highlighted text and restore the scroll position
				self._textarea.scrollTo(scroll.x, scroll.y);
				self._textarea.focus();
				self._updateRichText(self._textarea.get('value'));
			}

			function _find(str, isCaseSensitive, isBackwards) {
				var textarea = self._textarea;
				var text = textarea.get('value');
				if (!isCaseSensitive) {
					str = str.toLowerCase();
					text = text.toLowerCase();
				}
				var pos;
				if (textarea.setSelectionRange) {
					if (isBackwards) {
						text = text.substr(0, textarea.selectionEnd - 1);
						pos = text.lastIndexOf(str);
					} else {
						var start = textarea.selectionStart + 1;
						text = text.substr(start);
						pos = text.indexOf(str);
						if (pos >= 0) {
							pos += start;
						}
					}
					if (pos >= 0) {
						textarea.setSelectionRange(pos, pos + str.length);
					}
				}
			}

			// add actions
			self['find'] = _find;
			self['replace'] = function (str, replace, isCaseSensitive, isBackwards) {
				var textarea = self._textarea;
				var text = textarea.get('value');
				if (textarea.setSelectionRange) {
					if (text.substring(textarea.selectionStart, textarea.selectionEnd) == str) {
						text = text.substr(0, textarea.selectionStart) + replace + text.substr(textarea.selectionEnd);
						textarea.set('value', text);
						self._updateRichText(text);
					}
				}
				_find(str, isCaseSensitive, isBackwards);
			};
			var tags = ['b','em','code','sub','sup'];
			['bold','italic','code','subscript','superscript'].each(function (action, index) {
				var starttag = '<'+tags[index]+'>';
				var endtag = '</'+tags[index]+'>';

				self[action] = function () {
					_surroundHTML(starttag, endtag);
				};
			});
			['pastehtml','pasteplain'].each(function (action) {
				self[action] = function (text) {
					_insertHTML(text);
				};
			});
			self['color'] = function (color) {
				_insertElement(new Element('span', {
					'styles': {
						'color': color
					}
				}));
			};
			self['link'] = function (anchor) {
				_insertElement(new Element('a', $extend({
					'html': _getSelectionHTML()
				}, anchor)));
			};
			self['image'] = function (url, attrs) {
				_insertElement(new Element('img', $extend({
					'alt': _getSelectionHTML(),
					'src': url
				}, attrs)));
			};
			self['selectall'] = function () {
				self._textarea.select();
			};
		},

		/**
		* Returns the editor as an instance of the MooTools Element class.
		*/
		'toElement': function () {
			return this._control;
		},

		'focus': function () {
			this._textarea.focus();
		},

		/**
		* Updates syntax-highlighted code.
		*/
		_updateRichText: function (html) {
			var container = this._richcontainer;

			// using a container and including <pre> when setting innerHTML forces preservation of whitespace in IE8 and below
			// including a zero-width space at the front forces preservation of leading newlines in Chrome
			container.empty().innerHTML = '<pre>' + '&#8203;<span>' + html.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(  // .empty() ensures proper memory deallocation
				/(&amp;(?:#(?:[0-9]+|x[0-9a-fA-F]+)|[0-9a-zA-Z]+);?)/g,  // decimal entity or hexadecimal entity or named entity
				'<span class="setplus-entity">$1</span>'
			).replace(
				/&lt;([\/\w\d]*)([\s\S]*?)&gt;/g,
				'<span class="setplus-element">&lt;$1<span class="setplus-attribute">$2</span>&gt;</span>'
			) + '</span>\n\n</pre>';  // add newlines to ensure that textarea element has a smaller client height
		},

		'update': function () {
			var self = this;

			var el;
			if (setplus.Localization.statusbar && self._textarea.setSelectionRange) {  // standard-compliant browsers
				var startpos = self._textarea.selectionStart;
				var endpos = self._textarea.selectionEnd;
				var text = self._textarea.get('value');
				el = new Element('span', {
					'html': setplus.Localization.statusbar.position.substitute({
						'line': text.substr(0, endpos).replace(/[^\n]*/g, '').length + 1
					})
				});
			}
			self.fireEvent('status', el);
		},

		'getHTML': function () {
			return this._textarea.get('value');
		},

		'setHTML': function (html) {
			var self = this;
			self._textarea.set('value', html).scrollTo(0,0);
			self._updateRichText(html);
			self._richcontainer.scrollTo(0,0);
			return self;
		},

		'zoom': function (percentage) {
			var self = this;

			// set font size of both invisible textarea control and preview control
			$$(self._textarea, self._richcontainer).setStyle('font-size', percentage+'%');
		}
	});

	/**
	* Localization strings.
	*/
	setplus.Localization = {};

	/**
	* Global options.
	* Editor instances initializes with default arguments inherit global options.
	*/
	setplus.Options = {
		/**
		* Items on toolbar.
		*/
		'toolbar': [
			/*'print',*/'find',
			// edit
			'undo','redo',/*'cut','copy','paste',*/'pasteplain','pasteword',
			// text
			'bold','italic','code','subscript','superscript','color','removeformat',
			// format
			'symbol','link','unlink','image','alignleft','aligncenter','alignright','alignjustify',/*'alignnone',*/'indent','outdent',
			// sectioning
			/*'p','h1','h2','h3','h4','pre',*/'orderedlist','unorderedlist',
			// selection
			'selectall',
			// table
			'table','rowbefore','rowafter','rowdelete','colbefore','colafter','coldelete',
			// view
			'zoomin','zoomout','fullscreen','blocks','document','source'
		],
		/**
		* Zoom factors as percentage of text base font size.
		*/
		'zoomfactors': [250,200,175,150,125,100,75,50,25]
	};

	/**
	* Instantiated dialogs.
	*/
	setplus.Dialogs = {};

	setplus.Editor = new Class({
		'Implements': Options,

		'options': setplus.Options,

		/**
		* Current editor.
		*/
		'editor': null,

		/**
		* Index of current zoom factor.
		*/
		_zoomindex: 0,

		/**
		* Container for all editor-related HTML elements.
		*/
		//_container: null,

		/**
		* Format selection drop-down list.
		*/
		//_format: null,

		'initialize': function (el, options) {
			var self = this;

			// override default options with global options and local options
			self.setOptions(setplus.Options).setOptions(options);

			var html = el.get('html');
			el.empty().addClass('setplus');

			// set zoom factor to 100%
			self._zoomindex = Math.max(0, self.options.zoomfactors.indexOf(100));

			// instantiate editors
			self.htmleditor = new setplus.HTMLEditor();
			self.texteditor = new setplus.TextEditor(self.options.name ? { name: self.options.name } : {});

			function _fireAction(action) {
				var fn;
				if (fn = self[action]) {  // action defined globally
					fn.bind(self)();
				} else if (fn = self.editor[action]) {  // action defined by editor
					fn.bind(self.editor)();
				}
			}

			// add editor toolbar and edit area to element
			var statusbar;
			self._container = el.adopt(
				new Element('div', {
					'class': _class('toolbar'),
					'unselectable': 'on'
				}).adopt(
					// toolbar with action icons
					self.options.toolbar.map(function (name) {
						return new Element('button', {
							'class': _class(name),
							'type': 'button',
							'unselectable': 'on',
							'events': {
								'click': function () {
									_fireAction(this.retrieve('action'));
									self.editor.focus();
								}
							}
						}).adopt(new Element('span')).store('action', name);
					}),
					// list of paragraph formats
					self._format = new setplus.DropDownList(['p','h1','h2','h3','h4','h5','h6','div','pre','address'].map(function (name) {
						return new Element(name).adopt(
							new Element('span', {
								'class': _class(name)
							})
						).store('action', name);
					}), 'style').addEvent('change', function (listitem) {
						_fireAction(listitem.getChildren()[0].retrieve('action'));
					})
				),
				new Element('div', {  // an HTML div container for various HTML and text editors
					'class': _class('viewpane')
				}).adopt(
					self.htmleditor,
					self.texteditor,
					statusbar = new Element('div', {  // used for showing informational messages
						'class': _class('statusbar')
					})
				),
				new Element('div', {
					'class': _class('resize')
				})
			);

			// add statusbar updates
			function _updateStatus(status) {
				statusbar.empty();
				if (status) {
					statusbar.adopt(status);
				} else if (setplus.Localization.statusbar) {
					statusbar.set('html', setplus.Localization.statusbar.ready);
				}
			}
			_updateStatus();
			self.htmleditor.addEvent('status', _updateStatus);
			self.texteditor.addEvent('status', _updateStatus);

			self.htmleditor.prepare(function () {
				self.editor = self.texteditor.setHTML(html);
				self.document();
				//self.source();
			});

			self._localize();
		},

		/**
		* Register active editor in document element store.
		* This helps pop-up windows communicate with the appropriate editor they have been triggered by.
		*/
		_setActive: function () {
			document.store('setplus-editor', this);
		},

		/**
		* Updates the enabled status of toolbar icons.
		*/
		_updateToolbar: function () {
			var self = this;
			var toolbaricons = self._container.getElements('button');

			toolbaricons.filter(function (elem) {
				var action = elem.retrieve('action');
				return action && (self[action] || self.editor[action]);  // action defined globally or by editor
			}).removeProperty('disabled');

			toolbaricons.filter(function (elem) {
				var action = elem.retrieve('action');
				return action && !self[action] && !self.editor[action];  // action defined neither globally nor by editor
			}).setProperty('disabled', 'disabled');
		},

		/**
		* Updates the enabled status of zoom in and zoom out icons.
		*/
		_updateZoom: function () {
			var self = this;
			var zoomctrl;

			// toggle zoom in controls
			zoomctrl = self._container.getElements('.setplus-zoomin');
			if (self._zoomindex < 1) {
				zoomctrl.setProperty('disabled', 'disabled');
			} else {
				zoomctrl.removeProperty('disabled');
			}

			// toggle zoom out controls
			zoomctrl = self._container.getElements('.setplus-zoomout');
			if (self._zoomindex >= self.options.zoomfactors.length-1) {
				zoomctrl.setProperty('disabled', 'disabled');
			} else {
				zoomctrl.removeProperty('disabled');
			}
		},

		_toggleEditor: function (editor) {
			var self = this;

			// hide current editor
			$(self.editor).addClass(_class('hidden'));

			// transfer content from current editor to activated editor
			var html = self.editor.getHTML();
			if (editor.getHTML() != html) {  // update only if HTML has changed
				editor.setHTML(html);
			}
			editor.zoom(self.options.zoomfactors[self._zoomindex]);

			// show activated editor
			$(editor).removeClass(_class('hidden'));
			self.editor = editor;
			self.editor.update();
			self._updateToolbar();
		},

		/**
		* Shows a modeless dialog.
		* @param {string} classname The name of the class used to instantiate the dialog.
		* @param {boolean=} style Whether the dialog uses external styles.
		* @param {string=} tag The tag name of the element whose properties to edit in the dialog.
		*/
		_showDialog: function (classname, style, tag) {
			var self = this;

			// test whether there is an active element whose properties to edit
			var elem;
			var editor = self.editor;
			if (tag && editor.activeParent && editor.select) {
				elem = editor.activeParent(tag);
				if (elem) {
					editor.select(elem);
				}
			}

			// get dialog instance
			var dlg = setplus.Dialogs[classname];
			if (dlg) {  // dialog already instantiated
				// show dialog
				dlg.assign && dlg.assign(elem);
				dlg.show();
			} else {
				/**
				* Imports a style or script file.
				*/
				function _importResource(file, type, callback) {
					// add style or script reference
					var el;
					var url = _baseURL('../' + type, 'setplus.' + file, type);
					if (type == 'js') {
						el = new Element('script', {
							'type': 'text/javascript',
							'src': url
						});
					} else {
						el = new Element('link', {
							'type': 'text/css',
							'rel': 'stylesheet',
							'href': url
						});
					}
					if (callback) {
						var once = true;
						el.addEvents({
							'load': function () {  // standard-compliant browsers
								if (once) {
									once = false;
									callback();
								}
							},
							'readystatechange': function () {  // Internet Explorer only
								if (once && this.readyState == 'complete') {
									once = false;
									callback();
								}
							}
						});
					}
					el.inject(document.head);
				}

				/**
				* Instantiates a dialog.
				*/
				function _instantiate() {
					// create dialog instance
					dlg = new setplus[classname + 'Dialog']();
					setplus.Dialogs[classname] = dlg;

					// show dialog with size fit to contents
					self._localize();
					dlg.assign && dlg.assign(elem);
					dlg.show();
					dlg.resize();
				}

				// import stylesheet
				if (style) {
					_importResource('dialog', 'css');
				}

				// import script file
				if (setplus[classname + 'Dialog']) {  // class already defined
					_instantiate();
				} else {
					_importResource(classname.toLowerCase(), 'js', _instantiate);  // class not defined, import from external script file
				}
			}

			// set active editor instance
			self._setActive();
		},

		_localize: function () {
			function _localize(prefix, data) {
				Object.each(data, function (item, key) {
					var classname = prefix + '-' + key;
					if (typeof(item) == 'string') {
						$$('span.' + classname + ', option.' + classname).set('html', item);
						$$('button.' + classname).each(function (button) {
							button.set(button.getChildren().length ? 'title' : 'html', item);
						});
					} else {
						_localize(classname, item);
					}
				});
			}

			_localize('setplus', setplus.Localization);
		},

		'getHTML': function () {
			return this.editor.getHTML();
		},

		'setHTML': function (html) {
			this.editor.setHTML(html);
			return this;
		},

		'pasteHTML': function (html) {
			this.editor.pastehtml(html);
			return this;
		},

		'synchronize': function () {
			var self = this;
			var html = self.editor.getHTML();
			self.htmleditor.setHTML(html);
			self.texteditor.setHTML(html);
			return self;
		},

		'find': function () {
			this._showDialog('SearchReplace', true);
		},

		'color': function () {
			this._showDialog('Colors', true);
		},

		/**
		* Shows the symbol selection dialog.
		*/
		'symbol': function () {
			this._showDialog('Symbols', true);
		},

		/**
		* Shows the link properties dialog.
		*/
		'link': function () {
			var self = this;
			self._showDialog('LinkProperties', false, 'a');
		},

		/**
		* Shows the image properties dialog.
		*/
		'image': function () {
			this._showDialog('ImageProperties', true, 'img');
		},

		/**
		* Shows the insert plain text dialog.
		*/
		'pasteplain': function () {
			this._showDialog('PlainText', true);
		},

		'pasteword': function () {
			this._showDialog('WordProcessor', true);
		},

		'zoomin': function () {
			var self = this;
			self._zoomindex = Math.max(0, self._zoomindex-1);
			self.editor.zoom(self.options.zoomfactors[self._zoomindex]);
			self._updateZoom();
		},

		'zoomout': function () {
			var self = this;
			var zoomfactors = self.options.zoomfactors;
			self._zoomindex = Math.min(zoomfactors.length-1, self._zoomindex+1);
			self.editor.zoom(zoomfactors[self._zoomindex]);
			self._updateZoom();
		},

		'fullscreen': function () {
			var self = this;
			var isfullscreen = self._container.hasClass(_class('fullscreen'));

			if (!isfullscreen) {  // save scroll position
				self._scroll = window.getScroll();
			}

			$$(document.html, self._container).toggleClass(_class('fullscreen'));

			if (isfullscreen) {  // restore scroll position
				window.scrollTo(self._scroll.x, self._scroll.y);
				delete self._scroll;
			}
		},

		'document': function () {
			var self = this;
			self._toggleEditor(self.htmleditor);
			self._format.setEnabled(true);

			// toggle source view control
			self._container.getElements('.setplus-document').addClass(_class('hidden'));
			self._container.getElements('.setplus-source').removeClass(_class('hidden'));
		},

		'source': function () {
			var self = this;
			self._toggleEditor(self.texteditor);
			self._format.setEnabled(false);

			// toggle source view control
			self._container.getElements('.setplus-source').addClass(_class('hidden'));
			self._container.getElements('.setplus-document').removeClass(_class('hidden'));
		}
	});

	window.addEvent('domready', function () {
		$$('div.setplus').each(function (el) {
			el.store('setplus-editor', new setplus.Editor(el));
		});

		$$('textarea.setplus').each(function (el) {  // must be last as it creates div.sigplus
			var name = el.get('name');  // name to use in HTML forms
			var container = new Element('div').wraps(el).set({  // wrap <textarea> element in <div>
				'id': el.get('id'),
				'html': el.get('value')
			});
			container.store('setplus-editor', new setplus.Editor(container, name ? {
				'name': name
			} : {}));
		});
	});
})(document.id);