/** * Namespace for entire Xquared classes */ var xq = { majorVersion: '0.2', minorVersion: '20071205' }; /** * Add prototype.js like functions */ xq.Class = function() { // TODO var parent = null, properties = xq.$A(arguments); if (typeof properties[0] == "function") parent = properties.shift(); function klass() { this.initialize.apply(this, arguments); } if(parent) { for (var key in parent.prototype) klass.prototype[key] = parent.prototype[key]; } for (var key in properties[0]) klass.prototype[key] = properties[0][key]; if (!klass.prototype.initialize) klass.prototype.initialize = function() {}; klass.prototype.constructor = klass; return klass; } xq.observe = function(element, eventName, handler) { if (element.addEventListener) { element.addEventListener(eventName, handler, false); } else { element.attachEvent('on' + eventName, handler); } element = null; } xq.stopObserving = function(element, eventName, handler) { if (element.removeEventListener) { element.removeEventListener(eventName, handler, false); } else { element.detachEvent("on" + eventName, handler); } element = null; } xq.cancelHandler = function(e) {xq.stopEvent(e); return false}; xq.stopEvent = function(event) { if(event.preventDefault) event.preventDefault(); if(event.stopPropagation) event.stopPropagation(); event.returnValue = false; event.cancelBubble = true; event.stopped = true; } xq.isButton = function(event, code) { return event.which ? (event.which === code + 1) : (event.button === code); } xq.isLeftClick = function(event) {return isButton(event, 0);} xq.isMiddleClick = function(event) {return isButton(event, 1);} xq.isRightClick = function(event) {return isButton(event, 2);} xq.getEventPoint = function(event) { return { x: event.pageX || (event.clientX + (document.documentElement.scrollLeft || document.body.scrollLeft)), y: event.pageY || (event.clientY + (document.documentElement.scrollTop || document.body.scrollTop)) }; } xq.getCumulativeOffset = function(element) { var top = 0, left = 0; do { top += element.offsetTop || 0; left += element.offsetLeft || 0; element = element.offsetParent; } while (element); return {top:top, left:left}; } xq.$ = function(id) { return document.getElementById(id); } xq.isEmptyHash = function(h) { for(var key in h) { return false; } return true; } xq.$A = function(arraylike) { var len = arraylike.length, a = new Array(len); while (len--) a[len] = arraylike[len]; return a; } xq.hasClassName = function(element, className) { var classNames = element.className; return (classNames.length > 0 && (classNames == className || new RegExp("(^|\\s)" + className + "(\\s|$)").test(classNames))); } xq.serializeForm = function(f) { try{ var options = {hash: true}; var data = {}; var elements = f.getElementsByTagName("*"); for(var i = 0; i < elements.length; i++) { var element = elements[i]; var tagName = element.tagName.toLowerCase(); if(element.disabled || !element.name || ['input', 'textarea', 'option', 'select'].indexOf(tagName) == -1) continue; var key = element.name; var value = xq.getValueOfElement(element); if(value === undefined) continue; if(key in data) { if(data[key].constructor == Array) data[key] = [data[key]]; data[key].push(value); } else { data[key] = value; } } return data; } catch(e) {alert(e)} } xq.getValueOfElement = function(e) { var type = e.type.toLowerCase(); if(type == 'checkbox' || type == 'radio') return e.checked ? e.value : undefined; return e.value; } xq.getElementsByClassName = function(element, className) { if(element.getElementsByClassName) return element.getElementsByClassName(className); var elements = element.getElementsByTagName("*"); var len = elements.length; var result = []; var p = new RegExp("(^|\\s)" + className + "($|\\s)"); for(var i = 0; i < len; i++) { var cur = elements[i]; if(p.test(cur.className)) result.push(cur); } return result; } try {Prototype.version; __prototype = true;} catch(ignored) {__prototype = false;} if(!__prototype) { if(!Function.prototype.bind) { Function.prototype.bind = function() { var __m = this, arg = xq.$A(arguments), o = arg.shift(); return function() { return __m.apply(o, arg.concat(xq.$A(arguments))); } } } if(!Function.prototype.bindAsEventListener) { Function.prototype.bindAsEventListener = function() { var __m = this, arg = xq.$A(arguments), o = arg.shift(); return function(event) { return __m.apply(o, [event || window.event].concat(arg)); } } } Array.prototype.find = function(f) { for(var i = 0; i < this.length; i++) { if(f(this[i])) return this[i]; } } Array.prototype.findAll = function(f) { var result = []; for(var i = 0; i < this.length; i++) { if(f(this[i])) result.push(this[i]); } return result; } Array.prototype.first = function() {return this[0]} Array.prototype.last = function() {return this[this.length - 1]} Array.prototype.include = function(o) { if (this.indexOf(o) != -1) return true; var found = false; for(var i = 0; i < this.length; i++) { if(this[i] == o) return true; } return false; } Array.prototype.flatten = function() { var result = []; var _flatten = function(array) { for(var i = 0; i < array.length; i++) { if(array[i].constructor === Array) { _flatten(array[i]); } else { result.push(array[i]); } } } _flatten(this); return result; } String.prototype.blank = function() { return /^\s*$/.test(this); } String.prototype.stripTags = function() { return this.replace(/<\/?[^>]+>/gi, ''); } String.prototype.escapeHTML = function() { xq._text.data = this; return xq._div.innerHTML; } xq._text = document.createTextNode(''); xq._div = document.createElement('div'); xq._div.appendChild(xq._text); String.prototype.strip = function() { return this.replace(/^\s+/, '').replace(/\s+$/, ''); } Array.prototype.indexOf = function(n) { for(var i = 0; i < this.length; i++) { if(this[i] == n) return i; } return -1; } } /** * Make given object as event source * * @param {Object} object target object * @param {String} prefix prefix for generated functions * @param {Array} events array of string which contains name of events */ xq.asEventSource = function(object, prefix, events) { object._listeners = [] object._registerEventFirer = function(prefix, name) { this["_fireOn" + name] = function() { for(var i = 0; i < this._listeners.length; i++) { var listener = this._listeners[i]; var func = listener["on" + prefix + name]; if(func) func.apply(listener, xq.$A(arguments)); } } } object.addListener = function(l) { this._listeners.push(l); } for(var i = 0; i < events.length; i++) { object._registerEventFirer(prefix, events[i]); } } Date.preset = null; Date.pass = function(msec) { if(Date.preset == null) return; Date.preset = new Date(Date.preset.getTime() + msec); } Date.get = function() { return Date.preset == null ? new Date() : Date.preset; } Date.prototype.elapsed = function(msec) { return Date.get().getTime() - this.getTime() >= msec; } String.prototype.merge = function(data) { var newString = this; for(k in data) { newString = newString.replace("{" + k + "}", data[k]); } return newString; } String.prototype.parseURL = function() { var m = this.match(/((((\w+):\/\/(((([^@:]+)(:([^@]+))?)@)?([^:\/\?#]+)?(:(\d+))?))?([^\?#]+)?)(\?([^#]+))?)(#(.+))?/); var includeAnchor = m[0]; var includeQuery = m[1] || undefined; var includePath = m[2] || undefined; var includeHost = m[3] || undefined; var includeBase = null; var protocol = m[4] || undefined; var user = m[8] || undefined; var password = m[10] || undefined; var domain = m[11] || undefined; var port = m[13] || undefined; var path = m[14] || undefined; var query = m[16] || undefined; var anchor = m[18] || undefined; if(!path || path == '/') { includeBase = includeHost + '/'; } else { var index = path.lastIndexOf('/'); includeBase = includeHost + path.substring(0, index + 1); } return { includeAnchor: includeAnchor, includeQuery: includeQuery, includePath: includePath, includeBase: includeBase, includeHost: includeHost, protocol: protocol, user: user, password: password, domain: domain, port: port, path: path, query: query, anchor: anchor }; } /** * Automatic finalizer */ xq.autoFinalizeQueue = []; xq.addToFinalizeQueue = function(obj) { xq.autoFinalizeQueue.push(obj); } xq.finalize = function(obj) { if(typeof obj.finalize == "function") { try {obj.finalize();} catch(ignored) {} } for(key in obj) obj[key] = null; } xq.observe(window, "unload", function() { for(var i = 0; i < xq.autoFinalizeQueue.length; i++) xq.finalize(xq.autoFinalizeQueue[i]); xq = null; }); /** * Script loader */ xq.findXquaredScript = function() { return xq.$A(document.getElementsByTagName("script")).find(function(script) { return script.src && script.src.match(/xquared\.js/i); }); } xq.shouldLoadOthers = function() { var script = xq.findXquaredScript(); return script && !!script.src.match(/xquared\.js\?load_others=1/i); } xq.loadScript = function(url) { document.write(''); } xq.loadOthers = function() { var script = xq.findXquaredScript(); var basePath = script.src.match(/(.*\/)xquared\.js.*/i)[1]; var others = [ 'Editor.js', 'Browser.js', 'Shortcut.js', 'DomTree.js', 'RichDom.js', 'RichDomW3.js', 'RichDomGecko.js', 'RichDomWebkit.js', 'RichDomTrident.js', 'RichTable.js', 'Validator.js', 'ValidatorW3.js', 'ValidatorGecko.js', 'ValidatorWebkit.js', 'ValidatorTrident.js', 'EditHistory.js', 'Controls.js', '_ui_templates.js' ]; for(var i = 0; i < others.length; i++) { xq.loadScript(basePath + others[i]); }; } if(xq.shouldLoadOthers()) xq.loadOthers(); /** * @fileOverview xq.Editor manages configurations such as autocompletion and autocorrection, edit mode/normal mode switching, handles editing commands, keyboard shortcuts and other events. */ xq.Editor = xq.Class({ /** * Initialize editor but it doesn't automatically start designMode. setEditMode should be called after initialization. * * @constructor * @param {Object} contentElement HTML element(TEXTAREA or normal block element such as DIV) to be replaced with editable area, or DOM ID string. * @param {Object} toolbarContainer HTML element which contains toolbar icons, or DOM ID string. */ initialize: function(contentElement, toolbarContainer) { xq.addToFinalizeQueue(this); if(typeof contentElement == 'string') contentElement = xq.$(contentElement); if(!contentElement) throw "[contentElement] is null"; if(contentElement.nodeType != 1) throw "[contentElement] is not an element"; if(typeof toolbarContainer == 'string') toolbarContainer = xq.$(toolbarContainer); xq.asEventSource(this, "Editor", ["ElementChanged", "BeforeEvent", "AfterEvent", "CurrentContentChanged", "StaticContentChanged", "CurrentEditModeChanged"]); /** * Editor's configuration * @type object */ this.config = {}; this.config.enableLinkClick = false; this.config.changeCursorOnLink = false; this.config.generateDefaultToolbar = true; this.config.defaultToolbarButtonMap = [ [ {className:"foregroundColor", title:"Foreground color", handler:"xed.handleForegroundColor()"}, {className:"backgroundColor", title:"Background color", handler:"xed.handleBackgroundColor()"} ], [ {className:"link", title:"Link", handler:"xed.handleLink()"}, {className:"strongEmphasis", title:"Strong emphasis", handler:"xed.handleStrongEmphasis()"}, {className:"emphasis", title:"Emphasis", handler:"xed.handleEmphasis()"}, {className:"underline", title:"Underline", handler:"xed.handleUnderline()"}, {className:"strike", title:"Strike", handler:"xed.handleStrike()"}, {className:"superscription", title:"Superscription", handler:"xed.handleSuperscription()"}, {className:"subscription", title:"Subscription", handler:"xed.handleSubscription()"} ], [ {className:"removeFormat", title:"Remove format", handler:"xed.handleRemoveFormat()"} ], [ {className:"justifyLeft", title:"Justify left", handler:"xed.handleJustify('left')"}, {className:"justifyCenter", title:"Justify center", handler:"xed.handleJustify('center')"}, {className:"justifyRight", title:"Justify right", handler:"xed.handleJustify('right')"}, {className:"justifyBoth", title:"Justify both", handler:"xed.handleJustify('both')"} ], [ {className:"indent", title:"Indent", handler:"xed.handleIndent()"}, {className:"outdent", title:"Outdent", handler:"xed.handleOutdent()"} ], [ {className:"unorderedList", title:"Unordered list", handler:"xed.handleList('UL')"}, {className:"orderedList", title:"Ordered list", handler:"xed.handleList('OL')"} ], [ {className:"paragraph", title:"Paragraph", handler:"xed.handleApplyBlock('P')"}, {className:"heading1", title:"Heading 1", handler:"xed.handleApplyBlock('H1')"}, {className:"blockquote", title:"Blockquote", handler:"xed.handleApplyBlock('BLOCKQUOTE')"}, {className:"code", title:"Code", handler:"xed.handleList('CODE')"}, {className:"division", title:"Division", handler:"xed.handleApplyBlock('DIV')"} ], [ {className:"table", title:"Table", handler:"xed.handleTable(3,3,'tl')"}, {className:"separator", title:"Separator", handler:"xed.handleSeparator()"} ], [ {className:"html", title:"Edit source", handler:"xed.toggleSourceAndWysiwygMode()"} ], [ {className:"undo", title:"Undo", handler:"xed.handleUndo()"}, {className:"redo", title:"Redo", handler:"xed.handleRedo()"} ] ]; this.config.imagePathForDefaultToobar = 'images/toolbar/'; this.config.imagePathForContent = 'images/content/'; // relative | host_relative | absolute | browser_default this.config.urlValidationMode = 'absolute'; this.config.automaticallyHookSubmitEvent = true; this.config.allowedTags = ['a', 'abbr', 'acronym', 'address', 'blockquote', 'br', 'caption', 'cite', 'code', 'dd', 'dfn', 'div', 'dl', 'dt', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img', 'kbd', 'li', 'ol', 'p', 'pre', 'q', 'samp', 'span', 'sup', 'sub', 'strong', 'table', 'thead', 'tbody', 'td', 'th', 'tr', 'ul', 'var']; this.config.allowedAttributes = ['alt', 'cite', 'class', 'datetime', 'height', 'href', 'id', 'rel', 'rev', 'src', 'style', 'title', 'width']; this.config.shortcuts = {}; this.config.autocorrections = {}; this.config.autocompletions = {}; this.config.templateProcessors = {}; this.config.contextMenuHandlers = {}; /** * Original content element * @type Element */ this.contentElement = contentElement; /** * Owner document of content element * @type Document */ this.doc = this.contentElement.ownerDocument; /** * Body of content element * @type Element */ this.body = this.doc.body; /** * False or 'readonly' means read-only mode, true or 'wysiwyg' means WYSIWYG editing mode, and 'source' means source editing mode. * @type Object */ this.currentEditMode = 'readonly'; /** * RichDom instance * @type xq.RichDom */ this.rdom = xq.RichDom.createInstance(); /** * Validator instance * @type xq.Validator */ this.validator = null; /** * Outmost wrapper div * @type Element */ this.outmostWrapper = null; /** * Source editor container * @type Element */ this.sourceEditorDiv = null; /** * Source editor textarea * @type Element */ this.sourceEditorTextarea = null; /** * WYSIWYG editor container * @type Element */ this.wysiwygEditorDiv = null; /** * Design mode iframe * @type IFrame */ this.editorFrame = null; /** * Window that contains design mode iframe * @type Window */ this.editorWin = null; /** * Document that contained by design mode iframe * @type Document */ this.editorDoc = null; /** * Body that contained by design mode iframe * @type Element */ this.editorBody = null; /** * Toolbar container * @type Element */ this.toolbarContainer = toolbarContainer; /** * Toolbar buttons * @type Array */ this.toolbarButtons = null; this._toolbarAnchorsCache = []; /** * Undo/redo manager * @type xq.EditHistory */ this.editHistory = null; this._contextMenuContainer = null; this._contextMenuItems = null; this._validContentCache = null; this._lastModified = null; this.addShortcuts(this._getDefaultShortcuts()); this.addTemplateProcessors(this._getDefaultTemplateProcessors()); this.addListener({ onEditorCurrentContentChanged: function(xed) { var curFocusElement = xed.rdom.getCurrentElement(); if(!curFocusElement) return; if(xed._lastFocusElement != curFocusElement) { if(!xed.rdom.tree.isBlockOnlyContainer(xed._lastFocusElement) && xed.rdom.tree.isBlock(xed._lastFocusElement)) { xed.rdom.removeTrailingWhitespace(xed._lastFocusElement); } xed._fireOnElementChanged(xed._lastFocusElement, curFocusElement); xed._lastFocusElement = curFocusElement; } xed.updateAllToolbarButtonsStatus(curFocusElement); } }); }, finalize: function() { for(var i = 0; i < this._toolbarAnchorsCache.length; i++) { this._toolbarAnchorsCache[i].xed = null; this._toolbarAnchorsCache[i].handler = null; this._toolbarAnchorsCache[i] = null; } this._toolbarAnchorsCache = null; }, ///////////////////////////////////////////// // Configuration Management _getDefaultShortcuts: function() { if(xq.Browser.isMac) { // Mac FF & Safari return [ {event:"Ctrl+Shift+SPACE", handler:"this.handleAutocompletion(); stop = true;"}, {event:"ENTER", handler:"this.handleEnter(false, false)"}, {event:"Ctrl+ENTER", handler:"this.handleEnter(true, false)"}, {event:"Ctrl+Shift+ENTER", handler:"this.handleEnter(true, true)"}, {event:"TAB", handler:"this.handleTab()"}, {event:"Shift+TAB", handler:"this.handleShiftTab()"}, {event:"DELETE", handler:"this.handleDelete()"}, {event:"BACKSPACE", handler:"this.handleBackspace()"}, {event:"Ctrl+B", handler:"this.handleStrongEmphasis()"}, {event:"Ctrl+I", handler:"this.handleEmphasis()"}, {event:"Ctrl+U", handler:"this.handleUnderline()"}, {event:"Ctrl+K", handler:"this.handleStrike()"}, {event:"Meta+Z", handler:"this.handleUndo()"}, {event:"Meta+Shift+Z", handler:"this.handleRedo()"}, {event:"Meta+Y", handler:"this.handleRedo()"} ]; } else if(xq.Browser.isUbuntu) { // Ubunto FF return [ {event:"Ctrl+SPACE", handler:"this.handleAutocompletion(); stop = true;"}, {event:"ENTER", handler:"this.handleEnter(false, false)"}, {event:"Ctrl+ENTER", handler:"this.handleEnter(true, false)"}, {event:"Ctrl+Shift+ENTER", handler:"this.handleEnter(true, true)"}, {event:"TAB", handler:"this.handleTab()"}, {event:"Shift+TAB", handler:"this.handleShiftTab()"}, {event:"DELETE", handler:"this.handleDelete()"}, {event:"BACKSPACE", handler:"this.handleBackspace()"}, {event:"Ctrl+B", handler:"this.handleStrongEmphasis()"}, {event:"Ctrl+I", handler:"this.handleEmphasis()"}, {event:"Ctrl+U", handler:"this.handleUnderline()"}, {event:"Ctrl+K", handler:"this.handleStrike()"}, {event:"Ctrl+Z", handler:"this.handleUndo()"}, {event:"Ctrl+Y", handler:"this.handleRedo()"} ]; } else { // Win IE & FF return [ {event:"Ctrl+SPACE", handler:"this.handleAutocompletion(); stop = true;"}, {event:"ENTER", handler:"this.handleEnter(false, false)"}, {event:"Ctrl+ENTER", handler:"this.handleEnter(true, false)"}, {event:"Ctrl+Shift+ENTER", handler:"this.handleEnter(true, true)"}, {event:"TAB", handler:"this.handleTab()"}, {event:"Shift+TAB", handler:"this.handleShiftTab()"}, {event:"DELETE", handler:"this.handleDelete()"}, {event:"BACKSPACE", handler:"this.handleBackspace()"}, {event:"Ctrl+B", handler:"this.handleStrongEmphasis()"}, {event:"Ctrl+I", handler:"this.handleEmphasis()"}, {event:"Ctrl+U", handler:"this.handleUnderline()"}, {event:"Ctrl+K", handler:"this.handleStrike()"}, {event:"Ctrl+Z", handler:"this.handleUndo()"}, {event:"Ctrl+Y", handler:"this.handleRedo()"} ]; } }, _getDefaultTemplateProcessors: function() { return [ { id:"predefinedKeywordProcessor", handler:function(html) { var today = Date.get(); var keywords = { year: today.getFullYear(), month: today.getMonth() + 1, date: today.getDate(), hour: today.getHours(), min: today.getMinutes(), sec: today.getSeconds() }; return html.replace(/\{xq:(year|month|date|hour|min|sec)\}/img, function(text, keyword) { return keywords[keyword] || keyword; }); } } ]; }, /** * Adds or replaces keyboard shortcut. * * @param {String} shortcut keymap expression like "CTRL+Space" * @param {Object} handler string or function to be evaluated or called */ addShortcut: function(shortcut, handler) { this.config.shortcuts[shortcut] = {"event":new xq.Shortcut(shortcut), "handler":handler}; }, /** * Adds several keyboard shortcuts at once. * * @param {Array} list of shortcuts. each element should have following structure: {event:"keymap expression", handler:handler} */ addShortcuts: function(list) { for(var i = 0; i < list.length; i++) { this.addShortcut(list[i].event, list[i].handler); } }, /** * Returns keyboard shortcut matches with given keymap expression. * * @param {String} shortcut keymap expression like "CTRL+Space" */ getShortcut: function(shortcut) {return this.config.shortcuts[shortcut];}, /** * Returns entire keyboard shortcuts' map */ getShortcuts: function() {return this.config.shortcuts;}, /** * Remove keyboard shortcut matches with given keymap expression. * * @param {String} shortcut keymap expression like "CTRL+Space" */ removeShortcut: function(shortcut) {delete this.config.shortcuts[shortcut];}, /** * Adds or replaces autocorrection handler. * * @param {String} id unique identifier * @param {Object} criteria regex pattern or function to be used as a criterion for match * @param {Object} handler string or function to be evaluated or called when criteria met */ addAutocorrection: function(id, criteria, handler) { if(criteria.exec) { var pattern = criteria; criteria = function(text) {return text.match(pattern)}; } this.config.autocorrections[id] = {"criteria":criteria, "handler":handler}; }, /** * Adds several autocorrection handlers at once. * * @param {Array} list of autocorrection. each element should have following structure: {id:"identifier", criteria:criteria, handler:handler} */ addAutocorrections: function(list) { for(var i = 0; i < list.length; i++) { this.addAutocorrection(list[i].id, list[i].criteria, list[i].handler); } }, /** * Returns autocorrection handler matches with given id * * @param {String} id unique identifier */ getAutocorrection: function(id) {return this.config.autocorrection[id];}, /** * Returns entire autocorrections' map */ getAutocorrections: function() {return this.config.autocorrections;}, /** * Removes autocorrection handler matches with given id * * @param {String} id unique identifier */ removeAutocorrection: function(id) {delete this.config.autocorrections[id];}, /** * Adds or replaces autocompletion handler. * * @param {String} id unique identifier * @param {Object} criteria regex pattern or function to be used as a criterion for match * @param {Object} handler string or function to be evaluated or called when criteria met */ addAutocompletion: function(id, criteria, handler) { if(criteria.exec) { var pattern = criteria; criteria = function(text) { var m = pattern.exec(text); return m ? m.index : -1; }; } this.config.autocompletions[id] = {"criteria":criteria, "handler":handler}; }, /** * Adds several autocompletion handlers at once. * * @param {Array} list of autocompletion. each element should have following structure: {id:"identifier", criteria:criteria, handler:handler} */ addAutocompletions: function(list) { for(var i = 0; i < list.length; i++) { this.addAutocompletion(list[i].id, list[i].criteria, list[i].handler); } }, /** * Returns autocompletion handler matches with given id * * @param {String} id unique identifier */ getAutocompletion: function(id) {return this.config.autocompletions[id];}, /** * Returns entire autocompletions' map */ getAutocompletions: function() {return this.config.autocompletions;}, /** * Removes autocompletion handler matches with given id * * @param {String} id unique identifier */ removeAutocompletion: function(id) {delete this.config.autocompletions[id];}, /** * Adds or replaces template processor. * * @param {String} id unique identifier * @param {Object} handler string or function to be evaluated or called when template inserted */ addTemplateProcessor: function(id, handler) { this.config.templateProcessors[id] = {"handler":handler}; }, /** * Adds several template processors at once. * * @param {Array} list of template processors. Each element should have following structure: {id:"identifier", handler:handler} */ addTemplateProcessors: function(list) { for(var i = 0; i < list.length; i++) { this.addTemplateProcessor(list[i].id, list[i].handler); } }, /** * Returns template processor matches with given id * * @param {String} id unique identifier */ getTemplateProcessor: function(id) {return this.config.templateProcessors[id];}, /** * Returns entire template processors' map */ getTemplateProcessors: function() {return this.config.templateProcessors;}, /** * Removes template processor matches with given id * * @param {String} id unique identifier */ removeTemplateProcessor: function(id) {delete this.config.templateProcessors[id];}, /** * Adds or replaces context menu handler. * * @param {String} id unique identifier * @param {Object} handler string or function to be evaluated or called when onContextMenu occured */ addContextMenuHandler: function(id, handler) { this.config.contextMenuHandlers[id] = {"handler":handler}; }, /** * Adds several context menu handlers at once. * * @param {Array} list of handlers. Each element should have following structure: {id:"identifier", handler:handler} */ addContextMenuHandlers: function(list) { for(var i = 0; i < list.length; i++) { this.addContextMenuHandler(list[i].id, list[i].handler); } }, /** * Returns context menu handler matches with given id * * @param {String} id unique identifier */ getContextMenuHandler: function(id) {return this.config.contextMenuHandlers[id];}, /** * Returns entire context menu handlers' map */ getContextMenuHandlers: function() {return this.config.contextMenuHandlers;}, /** * Removes context menu handler matches with given id * * @param {String} id unique identifier */ removeContextMenuHandler: function(id) {delete this.config.contextMenuHandlers[id];}, ///////////////////////////////////////////// // Edit mode management /** * Returns current edit mode - readonly, wysiwyg, source */ getCurrentEditMode: function() { return this.currentEditMode; }, toggleSourceAndWysiwygMode: function() { var mode = this.getCurrentEditMode(); if(mode == 'readonly') return; this.setEditMode(mode == 'wysiwyg' ? 'source' : 'wysiwyg'); return true; }, /** * Switches between edit-mode/normal mode. * * @param {Object} mode false or 'readonly' means read-only mode, true or 'wysiwyg' means WYSIWYG editing mode, and 'source' means source editing mode. */ setEditMode: function(mode) { if(this.currentEditMode == mode) return; var firstCall = mode != false && mode != 'readonly' && !this.outmostWrapper; if(firstCall) { // Create editor element if needed this._createEditorFrame(); this._registerEventHandlers(); this.loadCurrentContentFromStaticContent(); this.editHistory = new xq.EditHistory(this.rdom); } if(mode == 'wysiwyg') { // Update contents if(this.currentEditMode == 'source') this.setStaticContent(this.getSourceContent()); this.loadCurrentContentFromStaticContent(); // Make static content invisible this.contentElement.style.display = "none"; // Make WYSIWYG editor visible this.sourceEditorDiv.style.display = "none"; this.wysiwygEditorDiv.style.display = "block"; this.outmostWrapper.style.display = "block"; this.currentEditMode = mode; if(!xq.Browser.isTrident) { window.setTimeout(function() { if(this.getDoc().designMode == 'On') return; // Without it, Firefox doesn't display embedded SWF this.getDoc().designMode = 'On'; // turn off Firefox's table editing feature try {this.getDoc().execCommand("enableInlineTableEditing", false, "false")} catch(ignored) {} }.bind(this), 0); } this.enableToolbarButtons(); if(!firstCall) this.focus(); } else if(mode == 'source') { // Update contents if(this.currentEditMode == 'wysiwyg') this.setStaticContent(this.getWysiwygContent()); this.loadCurrentContentFromStaticContent(); // Make static content invisible this.contentElement.style.display = "none"; // Make source editor visible this.sourceEditorDiv.style.display = "block"; this.wysiwygEditorDiv.style.display = "none"; this.outmostWrapper.style.display = "block"; this.currentEditMode = mode; this.disableToolbarButtons(['html']); if(!firstCall) this.focus(); } else { // Update contents this.setStaticContent(this.getCurrentContent()); this.loadCurrentContentFromStaticContent(); // Make editor and toolbar invisible this.outmostWrapper.style.display = "none"; // Make static content visible this.contentElement.style.display = "block"; this.currentEditMode = mode; } this._fireOnCurrentEditModeChanged(this, mode); }, /** * Load CSS into editing-mode document * * @param {string} path URL */ loadStylesheet: function(path) { var head = this.editorDoc.getElementsByTagName("HEAD")[0]; var link = this.editorDoc.createElement("LINK"); link.rel = "Stylesheet"; link.type = "text/css"; link.href = path; head.appendChild(link); }, /** * Sets editor's dynamic content from static content */ loadCurrentContentFromStaticContent: function() { // update WYSIWYG editor var html = this.validator.invalidate(this.getStaticContentAsDOM()); html = this.removeUnnecessarySpaces(html); if(html.blank()) { this.rdom.clearRoot(); } else { this.rdom.getRoot().innerHTML = html; } this.rdom.wrapAllInlineOrTextNodesAs("P", this.rdom.getRoot(), true); // update source editor var source = this.getWysiwygContent(true, true); this.sourceEditorTextarea.value = source; if(xq.Browser.isWebkit) { this.sourceEditorTextarea.innerHTML = source; } this._fireOnCurrentContentChanged(this); }, /** * Enables all toolbar buttons * * @param {Array} [exceptions] array of string containing classnames to exclude */ enableToolbarButtons: function(exceptions) { if(!this.toolbarContainer) return; this._execForAllToolbarButtons(exceptions, function(li, exception) { li.firstChild.className = !exception ? '' : 'disabled'; }); // Toolbar image icon disappears without following code: if(xq.Browser.isIE6) { this.toolbarContainer.style.display = 'none'; setTimeout(function() {this.toolbarContainer.style.display = 'block';}.bind(this), 0); } }, /** * Disables all toolbar buttons * * @param {Array} [exceptions] array of string containing classnames to exclude */ disableToolbarButtons: function(exceptions) { this._execForAllToolbarButtons(exceptions, function(li, exception) { li.firstChild.className = exception ? '' : 'disabled'; }); }, _execForAllToolbarButtons: function(exceptions, exec) { if(!this.toolbarContainer) return; exceptions = exceptions || []; var lis = this.toolbarContainer.getElementsByTagName('LI'); for(var i = 0; i < lis.length; i++) { var buttonsClassName = lis[i].className.split(" ").find(function(name) {return name != 'xq_separator'}); var exception = exceptions.indexOf(buttonsClassName) != -1; exec(lis[i], exception); } }, _updateToolbarButtonStatus: function(buttonClassName, selected) { var button = this.toolbarButtons[buttonClassName]; if(button) button.firstChild.firstChild.className = selected ? 'selected' : ''; }, updateAllToolbarButtonsStatus: function(element) { if(!this.toolbarContainer) return; if(!this.toolbarButtons) { var classNames = [ "emphasis", "strongEmphasis", "underline", "strike", "superscription", "subscription", "justifyLeft", "justifyCenter", "justifyRight", "justifyBoth", "unorderedList", "orderedList", "code", "paragraph", "heading1", "heading2", "heading3", "heading4", "heading5", "heading6" ]; this.toolbarButtons = {}; for(var i = 0; i < classNames.length; i++) { var found = xq.getElementsByClassName(this.toolbarContainer, classNames[i]); var button = found && found.length > 0 ? found[0] : null; if(button) this.toolbarButtons[classNames[i]] = button; } } var buttons = this.toolbarButtons; var info = this.rdom.collectStructureAndStyle(element); this._updateToolbarButtonStatus('emphasis', info.em); this._updateToolbarButtonStatus('strongEmphasis', info.strong); this._updateToolbarButtonStatus('underline', info.underline); this._updateToolbarButtonStatus('strike', info.strike); this._updateToolbarButtonStatus('superscription', info.superscription); this._updateToolbarButtonStatus('subscription', info.subscription); this._updateToolbarButtonStatus('justifyLeft', info.justification == 'left'); this._updateToolbarButtonStatus('justifyCenter', info.justification == 'center'); this._updateToolbarButtonStatus('justifyRight', info.justification == 'right'); this._updateToolbarButtonStatus('justifyBoth', info.justification == 'justify'); this._updateToolbarButtonStatus('orderedList', info.list == 'OL'); this._updateToolbarButtonStatus('unorderedList', info.list == 'UL'); this._updateToolbarButtonStatus('code', info.list == 'CODE'); this._updateToolbarButtonStatus('paragraph', info.block == 'P'); this._updateToolbarButtonStatus('heading1', info.block == 'H1'); this._updateToolbarButtonStatus('heading2', info.block == 'H2'); this._updateToolbarButtonStatus('heading3', info.block == 'H3'); this._updateToolbarButtonStatus('heading4', info.block == 'H4'); this._updateToolbarButtonStatus('heading5', info.block == 'H5'); this._updateToolbarButtonStatus('heading6', info.block == 'H6'); }, removeUnnecessarySpaces: function(html) { var blocks = this.rdom.tree.getBlockTags().join("|"); var regex = new RegExp("\\s*<(/?)(" + blocks + ")>\\s*", "img"); return html.replace(regex, '<$1$2>'); }, /** * Gets editor's dynamic content from current editor(source or WYSIWYG) * * @return {Object} HTML String */ getCurrentContent: function(performFullValidation) { if(this.getCurrentEditMode() == 'source') { return this.getSourceContent(performFullValidation); } else { return this.getWysiwygContent(performFullValidation); } }, /** * Gets editor's dynamic content from WYSIWYG editor * * @return {Object} HTML String */ getWysiwygContent: function(performFullValidation, dontUseCache) { if(dontUseCache || !performFullValidation) return this.validator.validate(this.rdom.getRoot(), performFullValidation); var lastModified = this.editHistory.getLastModifiedDate(); if(this._lastModified != lastModified) { this._validContentCache = this.validator.validate(this.rdom.getRoot(), performFullValidation); this._lastModified = lastModified; } return this._validContentCache; }, /** * Gets editor's dynamic content from source editor * * @return {Object} HTML String */ getSourceContent: function(performFullValidation) { var raw = this.sourceEditorTextarea[xq.Browser.isWebkit ? 'innerHTML' : 'value']; var tempDiv = document.createElement('div'); tempDiv.innerHTML = this.removeUnnecessarySpaces(raw); var rdom = xq.RichDom.createInstance(); rdom.setRoot(document.body); rdom.wrapAllInlineOrTextNodesAs("P", tempDiv, true); return this.validator.validate(tempDiv, performFullValidation); }, /** * Sets editor's original content * * @param {Object} content HTML String */ setStaticContent: function(content) { if(this.contentElement.nodeName == 'TEXTAREA') { this.contentElement.value = content; if(xq.Browser.isWebkit) { this.contentElement.innerHTML = content; } } else { this.contentElement.innerHTML = content; } this._fireOnStaticContentChanged(this, content); }, /** * Gets editor's original content * * @return {Object} HTML String */ getStaticContent: function() { var content; if(this.contentElement.nodeName == 'TEXTAREA') { content = this.contentElement[xq.Browser.isWebkit ? 'innerHTML' : 'value']; } else { content = this.contentElement.innerHTML; } return content; }, /** * Gets editor's original content as DOM node * * @return {Object} HTML String */ getStaticContentAsDOM: function() { if(this.contentElement.nodeName == 'TEXTAREA') { var div = this.doc.createElement('DIV'); div.innerHTML = this.contentElement[xq.Browser.isWebkit ? 'innerHTML' : 'value']; return div; } else { return this.contentElement; } }, /** * Gives focus to editor */ focus: function() { if(this.getCurrentEditMode() == 'wysiwyg') { this.rdom.focus(); window.setTimeout(function() { this.updateAllToolbarButtonsStatus(this.rdom.getCurrentElement()); }.bind(this), 0); } else if(this.getCurrentEditMode() == 'source') { this.sourceEditorTextarea.focus(); } }, /** * Returns designmode iframe object */ getFrame: function() { return this.editorFrame; }, /** * Returns designmode window object */ getWin: function() { return this.editorWin; }, /** * Returns designmode document object */ getDoc: function() { return this.editorDoc; }, /** * Returns outmost wrapper element */ getOutmostWrapper: function() { return this.outmostWrapper; }, /** * Returns designmode body object */ getBody: function() { return this.editorBody; }, _createEditorFrame: function() { // create outer DIV this.outmostWrapper = this.doc.createElement('div'); this.outmostWrapper.className = "xquared"; this.contentElement.parentNode.insertBefore(this.outmostWrapper, this.contentElement); // create toolbar is needed if(!this.toolbarContainer && this.config.generateDefaultToolbar) { this.toolbarContainer = this._generateDefaultToolbar(); this.outmostWrapper.appendChild(this.toolbarContainer); } // create source editor div this.sourceEditorDiv = this.doc.createElement('div'); this.sourceEditorDiv.className = "editor source_editor"; //TODO: remove editor this.sourceEditorDiv.style.display = "none"; this.outmostWrapper.appendChild(this.sourceEditorDiv); // create TEXTAREA for source editor this.sourceEditorTextarea = this.doc.createElement('textarea'); this.sourceEditorDiv.appendChild(this.sourceEditorTextarea); // create WYSIWYG editor div this.wysiwygEditorDiv = this.doc.createElement('div'); this.wysiwygEditorDiv.className = "editor wysiwyg_editor"; //TODO: remove editor this.wysiwygEditorDiv.style.display = "none"; this.outmostWrapper.appendChild(this.wysiwygEditorDiv); // create designmode iframe for WYSIWYG editor this.editorFrame = this.doc.createElement('iframe'); this.rdom.setAttributes(this.editorFrame, { "frameBorder": "0", "marginWidth": "0", "marginHeight": "0", "leftMargin": "0", "topMargin": "0", "allowTransparency": "true" }); this.wysiwygEditorDiv.appendChild(this.editorFrame); var doc = this.editorFrame.contentWindow.document; if(xq.Browser.isTrident) doc.designMode = 'On'; doc.open(); doc.write(''); doc.write(''); doc.write('
'); // it is needed to force href of pasted content to be an absolute url if(!xq.Browser.isTrident) doc.write('' + this.rdom.makePlaceHolderString() + '
'); doc.write(''); doc.close(); this.editorWin = this.editorFrame.contentWindow; this.editorDoc = this.editorWin.document; this.editorBody = this.editorDoc.body; this.editorBody.className = "xed"; // it is needed to fix IE6 horizontal scrollbar problem if(xq.Browser.isIE6) { this.editorDoc.documentElement.style.overflowY='auto'; this.editorDoc.documentElement.style.overflowX='hidden'; } // override image path if(this.config.generateDefaultToolbar) { this._addStyleRules([ {selector:".xquared div.toolbar", rule:"background-image: url(" + this.config.imagePathForDefaultToobar + "toolbarBg.gif)"}, {selector:".xquared ul.buttons li", rule:"background-image: url(" + this.config.imagePathForDefaultToobar + "toolbarButtonBg.gif)"}, {selector:".xquared ul.buttons li.xq_separator", rule:"background-image: url(" + this.config.imagePathForDefaultToobar + "toolbarSeparator.gif)"} ]); } this.rdom.setWin(this.editorWin); this.rdom.setRoot(this.editorBody); this.validator = xq.Validator.createInstance(this.doc.location.href, this.config.urlValidationMode, this.config.allowedTags, this.config.allowedAttributes); // hook onsubmit of form if(this.config.automaticallyHookSubmitEvent && this.contentElement.nodeName == 'TEXTAREA' && this.contentElement.form) { var original = this.contentElement.form.onsubmit; this.contentElement.form.onsubmit = function() { this.contentElement.value = this.getCurrentContent(true); if(original) { return original(); } else { return true; } }.bind(this); } }, _addStyleRules: function(rules) { if(!this.dynamicStyle) { if(xq.Browser.isTrident) { this.dynamicStyle = this.doc.createStyleSheet(); } else { var style = this.doc.createElement('style'); this.doc.body.appendChild(style); this.dynamicStyle = xq.$A(this.doc.styleSheets).last(); } } for(var i = 0; i < rules.length; i++) { var rule = rules[i]; if(xq.Browser.isTrident) { this.dynamicStyle.addRule(rules[i].selector, rules[i].rule); } else { this.dynamicStyle.insertRule(rules[i].selector + " {" + rules[i].rule + "}", this.dynamicStyle.cssRules.length); } } }, _defaultToolbarClickHandler: function(e) { var src = e.target || e.srcElement; while(src.nodeName != "A") src = src.parentNode; if(xq.hasClassName(src.parentNode, 'disabled') || xq.hasClassName(this.toolbarContainer, 'disabled')) { xq.stopEvent(e); return false; } if(xq.Browser.isTrident) this.focus(); var handler = src.handler; var xed = this; var stop = (typeof handler == "function") ? handler(this) : eval(handler); if(stop) { xq.stopEvent(e); return false; } else { return true; } }, _generateDefaultToolbar: function() { // outmost container var container = this.doc.createElement('div'); container.className = 'toolbar'; // button container var buttons = this.doc.createElement('ul'); buttons.className = 'buttons'; container.appendChild(buttons); // Generate buttons from map and append it to button container var map = this.config.defaultToolbarButtonMap; for(var i = 0; i < map.length; i++) { for(var j = 0; j < map[i].length; j++) { var buttonConfig = map[i][j]; var li = this.doc.createElement('li'); buttons.appendChild(li); li.className = buttonConfig.className; var span = this.doc.createElement('span'); li.appendChild(span); var a = this.doc.createElement('a'); span.appendChild(a); a.href = '#'; a.title = buttonConfig.title; a.handler = buttonConfig.handler; this._toolbarAnchorsCache.push(a); xq.observe(a, 'mousedown', xq.cancelHandler); xq.observe(a, 'click', this._defaultToolbarClickHandler.bindAsEventListener(this)); var img = this.doc.createElement('img'); a.appendChild(img); img.src = this.config.imagePathForDefaultToobar + buttonConfig.className + '.gif'; if(j == 0 && i != 0) li.className += ' xq_separator'; } } return container; }, ///////////////////////////////////////////// // Event Management _registerEventHandlers: function() { var events = ['keydown', 'click', 'keyup', 'mouseup', 'contextmenu']; if(xq.Browser.isTrident && this.config.changeCursorOnLink) events.push('mousemove'); if(xq.Browser.isMac && xq.Browser.isGecko) events.push('keypress'); for(var i = 0; i < events.length; i++) { xq.observe(this.getDoc(), events[i], this._handleEvent.bindAsEventListener(this)); } }, _handleEvent: function(e) { this._fireOnBeforeEvent(this, e); var stop = false; var modifiedByCorrection = false; if(e.type == 'mousemove' && this.config.changeCursorOnLink) { // Trident only var link = !!this.rdom.getParentElementOf(e.srcElement, ["A"]); var editable = this.editorBody.contentEditable; editable = editable == 'inherit' ? false : editable; if(editable != link && !this.rdom.hasSelection()) this.editorBody.contentEditable = !link; } else if(e.type == 'click' && e.button == 0 && this.config.enableLinkClick) { var a = this.rdom.getParentElementOf(e.target || e.srcElement, ["A"]); if(a) stop = this.handleClick(e, a); } else if(e.type == (xq.Browser.isMac && xq.Browser.isGecko ? "keypress" : "keydown")) { var undoPerformed = false; modifiedByCorrection = this.rdom.correctParagraph(); for(var key in this.config.shortcuts) { if(!this.config.shortcuts[key].event.matches(e)) continue; var handler = this.config.shortcuts[key].handler; var xed = this; stop = (typeof handler == "function") ? handler(this) : eval(handler); if(key == "undo") undoPerformed = true; } } else if(["mouseup", "keyup"].indexOf(e.type) != -1) { modifiedByCorrection = this.rdom.correctParagraph(); } else if(["contextmenu"].indexOf(e.type) != -1) { this._handleContextMenu(e); } if(stop) xq.stopEvent(e); this._fireOnCurrentContentChanged(this); this._fireOnAfterEvent(this, e); if(!undoPerformed && !modifiedByCorrection) this.editHistory.onEvent(e); return !stop; }, /** * TODO: remove dup with handleAutocompletion */ handleAutocorrection: function() { var block = this.rdom.getCurrentBlockElement(); // TODO: use complete unescape algorithm var text = this.rdom.getInnerText(block).replace(/ /gi, " "); var acs = this.config.autocorrections; var performed = false; var stop = false; for(var key in acs) { var ac = acs[key]; if(ac.criteria(text)) { try { this.editHistory.onCommand(); this.editHistory.disable(); if(typeof ac.handler == "String") { var xed = this; var rdom = this.rdom; eval(ac.handler); } else { stop = ac.handler(this, this.rdom, block, text); } this.editHistory.enable(); } catch(ignored) {} block = this.rdom.getCurrentBlockElement(); text = this.rdom.getInnerText(block); performed = true; if(stop) break; } } return stop; }, /** * TODO: remove dup with handleAutocorrection */ handleAutocompletion: function() { var acs = this.config.autocompletions; if(xq.isEmptyHash(acs)) return; if(this.rdom.hasSelection()) { var text = this.rdom.getSelectionAsText(); this.rdom.deleteSelection(); var wrapper = this.rdom.insertNode(this.rdom.createElement("SPAN")); wrapper.innerHTML = text; var marker = this.rdom.pushMarker(); var filtered = []; for(var key in acs) { filtered.push([key, acs[key].criteria(text)]); } filtered = filtered.findAll(function(elem) { return elem[1] != -1; }); if(filtered.length == 0) { this.rdom.popMarker(true); return; } var minIndex = 0; var min = filtered[0][1]; for(var i = 0; i < filtered.length; i++) { if(filtered[i][1] < min) { minIndex = i; min = filtered[i][1]; } } var ac = acs[filtered[minIndex][0]]; this.editHistory.disable(); } else { var marker = this.rdom.pushMarker(); var filtered = []; for(var key in acs) { filtered.push([key, this.rdom.testSmartWrap(marker, acs[key].criteria).textIndex]); } filtered = filtered.findAll(function(elem) { return elem[1] != -1; }); if(filtered.length == 0) { this.rdom.popMarker(true); return; } var minIndex = 0; var min = filtered[0][1]; for(var i = 0; i < filtered.length; i++) { if(filtered[i][1] < min) { minIndex = i; min = filtered[i][1]; } } var ac = acs[filtered[minIndex][0]]; this.editHistory.disable(); var wrapper = this.rdom.smartWrap(marker, "SPAN", ac.criteria); } var block = this.rdom.getCurrentBlockElement(); // TODO: use complete unescape algorithm var text = this.rdom.getInnerText(wrapper).replace(/ /gi, " "); try { // call handler if(typeof ac.handler == "String") { var xed = this; var rdom = this.rdom; eval(ac.handler); } else { ac.handler(this, this.rdom, block, wrapper, text); } } catch(ignored) {} try { this.rdom.unwrapElement(wrapper); } catch(ignored) {} if(this.rdom.isEmptyBlock(block)) this.rdom.correctEmptyElement(block); this.editHistory.enable(); this.editHistory.onCommand(); this.rdom.popMarker(true); }, /** * Handles click event * * @param {Event} e click event * @param {Element} target target element(usually has A tag) */ handleClick: function(e, target) { var href = decodeURI(target.href); if(!xq.Browser.isTrident) { if(!e.ctrlKey && !e.shiftKey && e.button != 1) { window.location.href = href; return true; } } else { if(e.shiftKey) { window.open(href, "_blank"); } else { window.location.href = href; } return true; } return false; }, /** * Show link dialog * * TODO: should support modify/unlink */ handleLink: function() { var text = this.rdom.getSelectionAsText() || ''; var dialog = new xq.controls.FormDialog( this, xq.ui_templates.basicLinkDialog, function(dialog) { if(text) { dialog.form.text.value = text; dialog.form.url.focus(); dialog.form.url.select(); } }, function(data) { this.focus(); if(xq.Browser.isTrident) { var rng = this.rdom.rng(); rng.moveToBookmark(bm); rng.select(); } if(!data) return; this.handleInsertLink(false, data.url, data.text, data.text); }.bind(this) ); if(xq.Browser.isTrident) var bm = this.rdom.rng().getBookmark(); dialog.show({position: 'centerOfEditor'}); return true; }, /** * Inserts link or apply link into selected area * * @param {boolean} autoSelection if set true and there's no selection, automatically select word to link(if possible) * @param {String} url url * @param {String} title title of link * @param {String} text text of link. If there's a selection(manually or automatically), it will be replaced with this text * * @returns {Element} created element */ handleInsertLink: function(autoSelection, url, title, text) { if(autoSelection && !this.rdom.hasSelection()) { var marker = this.rdom.pushMarker(); var a = this.rdom.smartWrap(marker, "A", function(text) { var index = text.lastIndexOf(" "); return index == -1 ? index : index + 1; }); a.href = url; a.title = title; if(text) { a.innerHTML = "" a.appendChild(this.rdom.createTextNode(text)); } else if(!a.hasChildNodes()) { this.rdom.deleteNode(a); } this.rdom.popMarker(true); } else { text = text || (this.rdom.hasSelection() ? this.rdom.getSelectionAsText() : null); if(!text) return; this.rdom.deleteSelection(); var a = this.rdom.createElement('A'); a.href = url; a.title = title; a.appendChild(this.rdom.createTextNode(text)); this.rdom.insertNode(a); } var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); return true; }, /** * Called when enter key pressed. * * @param {boolean} skipAutocorrection if set true, skips autocorrection * @param {boolean} forceInsertParagraph if set true, inserts paragraph */ handleEnter: function(skipAutocorrection, forceInsertParagraph) { // If it has selection, perform default action. if(this.rdom.hasSelection()) return false; // Perform autocorrection if(!skipAutocorrection && this.handleAutocorrection()) return true; var atEmptyBlock = this.rdom.isCaretAtEmptyBlock(); var atStart = atEmptyBlock || this.rdom.isCaretAtBlockStart(); var atEnd = atEmptyBlock || (!atStart && this.rdom.isCaretAtBlockEnd()); var atEdge = atEmptyBlock || atStart || atEnd; if(!atEdge) { var block = this.rdom.getCurrentBlockElement(); var marker = this.rdom.pushMarker(); if(this.rdom.isFirstLiWithNestedList(block) && !forceInsertParagraph) { var parent = block.parentNode; this.rdom.unwrapElement(block); block = parent; } else if(block.nodeName != "LI" && this.rdom.tree.isBlockContainer(block)) { block = this.rdom.wrapAllInlineOrTextNodesAs("P", block, true).first(); } this.rdom.splitElementUpto(marker, block); this.rdom.popMarker(true); } else if(atEmptyBlock) { this._handleEnterAtEmptyBlock(); } else { this._handleEnterAtEdge(atStart, forceInsertParagraph); } return true; }, /** * Moves current block upward or downward * * @param {boolean} up moves current block upward */ handleMoveBlock: function(up) { var block = this.rdom.moveBlock(this.rdom.getCurrentBlockElement(), up); if(block) { this.rdom.selectElement(block, false); block.scrollIntoView(false); var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); } return true; }, /** * Called when tab key pressed */ handleTab: function() { var hasSelection = this.rdom.hasSelection(); var table = this.rdom.getParentElementOf(this.rdom.getCurrentBlockElement(), ["TABLE"]); if(hasSelection) { this.handleIndent(); } else if (table && table.className == "datatable") { this.handleMoveToNextCell(); } else if (this.rdom.isCaretAtBlockStart()) { this.handleIndent(); } else { this.handleInsertTab(); } return true; }, /** * Called when shift+tab key pressed */ handleShiftTab: function() { var hasSelection = this.rdom.hasSelection(); var table = this.rdom.getParentElementOf(this.rdom.getCurrentBlockElement(), ["TABLE"]); if(hasSelection) { this.handleOutdent(); } else if (table && table.className == "datatable") { this.handleMoveToPreviousCell(); } else { this.handleOutdent(); } return true; }, /** * Inserts three non-breaking spaces */ handleInsertTab: function() { this.rdom.insertHtml(' '); this.rdom.insertHtml(' '); this.rdom.insertHtml(' '); return true; }, /** * Called when delete key pressed */ handleDelete: function() { if(this.rdom.hasSelection() || !this.rdom.isCaretAtBlockEnd()) return false; return this._handleMerge(true); }, /** * Called when backspace key pressed */ handleBackspace: function() { if(this.rdom.hasSelection() || !this.rdom.isCaretAtBlockStart()) return false; return this._handleMerge(false); }, _handleMerge: function(withNext) { var block = this.rdom.getCurrentBlockElement(); // save caret position; var marker = this.rdom.pushMarker(); // perform merge var merged = this.rdom.mergeElement(block, withNext, withNext); if(!merged && !withNext) this.rdom.extractOutElementFromParent(block); // restore caret position this.rdom.popMarker(true); if(merged) this.rdom.correctEmptyElement(merged); var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); return !!merged; }, /** * (in table) Moves caret to the next cell */ handleMoveToNextCell: function() { this._handleMoveToCell("next"); }, /** * (in table) Moves caret to the previous cell */ handleMoveToPreviousCell: function() { this._handleMoveToCell("prev"); }, /** * (in table) Moves caret to the above cell */ handleMoveToAboveCell: function() { this._handleMoveToCell("above"); }, /** * (in table) Moves caret to the below cell */ handleMoveToBelowCell: function() { this._handleMoveToCell("below"); }, _handleMoveToCell: function(dir) { var block = this.rdom.getCurrentBlockElement(); var cell = this.rdom.getParentElementOf(block, ["TD", "TH"]); var table = this.rdom.getParentElementOf(cell, ["TABLE"]); var rtable = new xq.RichTable(this.rdom, table); var target = null; if(["next", "prev"].indexOf(dir) != -1) { var toNext = dir == "next"; target = toNext ? rtable.getNextCellOf(cell) : rtable.getPreviousCellOf(cell); } else { var toBelow = dir == "below"; target = toBelow ? rtable.getBelowCellOf(cell) : rtable.getAboveCellOf(cell); } if(!target) { var finder = function(node) {return ['TD', 'TH'].indexOf(node.nodeName) == -1 && this.tree.isBlock(node) && !this.tree.hasBlocks(node);}.bind(this.rdom); var exitCondition = function(node) {return this.tree.isBlock(node) && !this.tree.isDescendantOf(this.getRoot(), node)}.bind(this.rdom); target = (toNext || toBelow) ? this.rdom.tree.findForward(cell, finder, exitCondition) : this.rdom.tree.findBackward(table, finder, exitCondition); } if(target) this.rdom.placeCaretAtStartOf(target); }, /** * Applies STRONG tag */ handleStrongEmphasis: function() { this.rdom.applyStrongEmphasis(); var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); return true; }, /** * Applies EM tag */ handleEmphasis: function() { this.rdom.applyEmphasis(); var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); return true; }, /** * Applies EM.underline tag */ handleUnderline: function() { this.rdom.applyUnderline(); var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); return true; }, /** * Applies SPAN.strike tag */ handleStrike: function() { this.rdom.applyStrike(); var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); return true; }, /** * Removes all style */ handleRemoveFormat: function() { this.rdom.applyRemoveFormat(); var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); return true; }, /** * Inserts table * * @param {Number} cols number of columns * @param {Number} rows number of rows * @param {String} headerPosition position of THs. "T" or "L" or "TL". "T" means top, "L" means left. */ handleTable: function(cols, rows, headerPositions) { var cur = this.rdom.getCurrentBlockElement(); if(this.rdom.getParentElementOf(cur, ["TABLE"])) return true; var rtable = xq.RichTable.create(this.rdom, cols, rows, headerPositions); if(this.rdom.tree.isBlockContainer(cur)) { var wrappers = this.rdom.wrapAllInlineOrTextNodesAs("P", cur, true); cur = wrappers.last(); } var tableDom = this.rdom.insertNodeAt(rtable.getDom(), cur, "after"); this.rdom.placeCaretAtStartOf(rtable.getCellAt(0, 0)); if(this.rdom.isEmptyBlock(cur)) this.rdom.deleteNode(cur, true); var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); return true; }, handleInsertNewRowAt: function(where) { var cur = this.rdom.getCurrentBlockElement(); var tr = this.rdom.getParentElementOf(cur, ["TR"]); if(!tr) return true; var table = this.rdom.getParentElementOf(tr, ["TABLE"]); var rtable = new xq.RichTable(this.rdom, table); var row = rtable.insertNewRowAt(tr, where); this.rdom.placeCaretAtStartOf(row.cells[0]); return true; }, handleInsertNewColumnAt: function(where) { var cur = this.rdom.getCurrentBlockElement(); var td = this.rdom.getParentElementOf(cur, ["TD"], true); if(!td) return true; var table = this.rdom.getParentElementOf(td, ["TABLE"]); var rtable = new xq.RichTable(this.rdom, table); rtable.insertNewCellAt(td, where); this.rdom.placeCaretAtStartOf(cur); return true; }, handleDeleteRow: function() { var cur = this.rdom.getCurrentBlockElement(); var tr = this.rdom.getParentElementOf(cur, ["TR"]); if(!tr) return true; var table = this.rdom.getParentElementOf(tr, ["TABLE"]); var rtable = new xq.RichTable(this.rdom, table); var blockToMove = rtable.deleteRow(tr); this.rdom.placeCaretAtStartOf(blockToMove); return true; }, handleDeleteColumn: function() { var cur = this.rdom.getCurrentBlockElement(); var td = this.rdom.getParentElementOf(cur, ["TD"], true); if(!td) return true; var table = this.rdom.getParentElementOf(td, ["TABLE"]); var rtable = new xq.RichTable(this.rdom, table); rtable.deleteCell(td); return true; }, /** * Performs block indentation */ handleIndent: function() { if(this.rdom.hasSelection()) { var blocks = this.rdom.getBlockElementsAtSelectionEdge(true, true); if(blocks.first() != blocks.last()) { var affected = this.rdom.indentElements(blocks.first(), blocks.last()); this.rdom.selectBlocksBetween(affected.first(), affected.last()); var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); return true; } } var block = this.rdom.getCurrentBlockElement(); var affected = this.rdom.indentElement(block); if(affected) { this.rdom.placeCaretAtStartOf(affected); var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); } return true; }, /** * Performs block outdentation */ handleOutdent: function() { if(this.rdom.hasSelection()) { var blocks = this.rdom.getBlockElementsAtSelectionEdge(true, true); if(blocks.first() != blocks.last()) { var affected = this.rdom.outdentElements(blocks.first(), blocks.last()); this.rdom.selectBlocksBetween(affected.first(), affected.last()); var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); return true; } } var block = this.rdom.getCurrentBlockElement(); var affected = this.rdom.outdentElement(block); if(affected) { this.rdom.placeCaretAtStartOf(affected); var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); } return true; }, /** * Applies list. * * @param {String} type "UL" or "OL" or "CODE". CODE generates OL.code */ handleList: function(type) { if(this.rdom.hasSelection()) { var blocks = this.rdom.getBlockElementsAtSelectionEdge(true, true); if(blocks.first() != blocks.last()) { blocks = this.rdom.applyLists(blocks.first(), blocks.last(), type); } else { blocks[0] = blocks[1] = this.rdom.applyList(blocks.first(), type); } this.rdom.selectBlocksBetween(blocks.first(), blocks.last()); } else { var block = this.rdom.applyList(this.rdom.getCurrentBlockElement(), type); this.rdom.placeCaretAtStartOf(block); } var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); return true; }, /** * Applies justification * * @param {String} dir "left", "center", "right" or "both" */ handleJustify: function(dir) { var block = this.rdom.getCurrentBlockElement(); var dir = (dir == "left" || dir == "both") && (block.style.textAlign == "left" || block.style.textAlign == "") ? "both" : dir; if(this.rdom.hasSelection()) { var blocks = this.rdom.getSelectedBlockElements(); this.rdom.justifyBlocks(blocks, dir); this.rdom.selectBlocksBetween(blocks.first(), blocks.last()); } else { this.rdom.justifyBlock(block, dir); } var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); return true; }, /** * Removes current block element */ handleRemoveBlock: function() { var block = this.rdom.getCurrentBlockElement(); var blockToMove = this.rdom.removeBlock(block); this.rdom.placeCaretAtStartOf(blockToMove); blockToMove.scrollIntoView(false); }, /** * Applies background color * * @param {String} color CSS color string */ handleBackgroundColor: function(color) { if(color) { this.rdom.applyBackgroundColor(color); var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); } else { var dialog = new xq.controls.FormDialog( this, xq.ui_templates.basicColorPickerDialog, function(dialog) {}, function(data) { this.focus(); if(xq.Browser.isTrident) { var rng = this.rdom.rng(); rng.moveToBookmark(bm); rng.select(); } if(!data) return; this.handleBackgroundColor(data.color); }.bind(this) ); if(xq.Browser.isTrident) var bm = this.rdom.rng().getBookmark(); dialog.show({position: 'centerOfEditor'}); } return true; }, /** * Applies foreground color * * @param {String} color CSS color string */ handleForegroundColor: function(color) { if(color) { this.rdom.applyForegroundColor(color); var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); } else { var dialog = new xq.controls.FormDialog( this, xq.ui_templates.basicColorPickerDialog, function(dialog) {}, function(data) { this.focus(); if(xq.Browser.isTrident) { var rng = this.rdom.rng(); rng.moveToBookmark(bm); rng.select(); } if(!data) return; this.handleForegroundColor(data.color); }.bind(this) ); if(xq.Browser.isTrident) var bm = this.rdom.rng().getBookmark(); dialog.show({position: 'centerOfEditor'}); } return true; }, /** * Applies superscription */ handleSuperscription: function() { this.rdom.applySuperscription(); var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); return true; }, /** * Applies subscription */ handleSubscription: function() { this.rdom.applySubscription(); var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); return true; }, /** * Change of wrap current block's tag */ handleApplyBlock: function(tagName) { if(this.rdom.hasSelection()) { var blocks = this.rdom.getBlockElementsAtSelectionEdge(true, true); if(blocks.first() != blocks.last()) { var applied = this.rdom.applyTagIntoElements(tagName, blocks.first(), blocks.last()); this.rdom.selectBlocksBetween(applied.first(), applied.last()); var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); return true; } } var block = this.rdom.getCurrentBlockElement(); this.rdom.pushMarker(); var applied = this.rdom.applyTagIntoElement(tagName, block) || block; this.rdom.popMarker(true); if(this.rdom.isEmptyBlock(applied)) { this.rdom.correctEmptyElement(applied); this.rdom.placeCaretAtStartOf(applied); } var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); return true; }, /** * Inserts seperator (HR) */ handleSeparator: function() { this.rdom.collapseSelection(); var curBlock = this.rdom.getCurrentBlockElement(); var atStart = this.rdom.isCaretAtBlockStart(); if(this.rdom.tree.isBlockContainer(curBlock)) curBlock = this.rdom.wrapAllInlineOrTextNodesAs("P", curBlock, true)[0]; this.rdom.insertNodeAt(this.rdom.createElement("HR"), curBlock, atStart ? "before" : "after"); this.rdom.placeCaretAtStartOf(curBlock); // add undo history var historyAdded = this.editHistory.onCommand(); this._fireOnCurrentContentChanged(this); return true; }, /** * Performs UNDO */ handleUndo: function() { var performed = this.editHistory.undo(); this._fireOnCurrentContentChanged(this); var curBlock = this.rdom.getCurrentBlockElement(); if(!xq.Browser.isTrident && curBlock) { curBlock.scrollIntoView(false); } return true; }, /** * Performs REDO */ handleRedo: function() { var performed = this.editHistory.redo(); this._fireOnCurrentContentChanged(this); var curBlock = this.rdom.getCurrentBlockElement(); if(!xq.Browser.isTrident && curBlock) { curBlock.scrollIntoView(false); } return true; }, _handleContextMenu: function(e) { if (xq.Browser.isWebkit) { if (e.metaKey || xq.isLeftClick(e)) return false; } else if (e.shiftKey || e.ctrlKey || e.altKey) { return false; } var point = xq.getEventPoint(e); var x = point.x; var y = point.y; var pos = xq.getCumulativeOffset(this.getFrame()); x += pos.left; y += pos.top; this._contextMenuTargetElement = e.target || e.srcElement; //TODO: Safari on Windows doesn't work with context key(app key) if (!x || !y || xq.Browser.isTrident) { var pos = xq.getCumulativeOffset(this._contextMenuTargetElement); var posFrame = xq.getCumulativeOffset(this.getFrame()); x = pos.left + posFrame.left - this.getDoc().documentElement.scrollLeft; y = pos.top + posFrame.top - this.getDoc().documentElement.scrollTop; } if (!xq.Browser.isTrident) { var doc = this.getDoc(); var body = this.getBody(); x -= doc.documentElement.scrollLeft; y -= doc.documentElement.scrollTop; if (doc != body) { x -= body.scrollLeft; y -= body.scrollTop; } } for(var cmh in this.config.contextMenuHandlers) { var stop = this.config.contextMenuHandlers[cmh].handler(this, this._contextMenuTargetElement, x, y); if(stop) { xq.stopEvent(e); return true; } } return false; }, showContextMenu: function(menuItems, x, y) { if (!menuItems || menuItems.length <= 0) return; if (!this._contextMenuContainer) { this._contextMenuContainer = this.doc.createElement('UL'); this._contextMenuContainer.className = 'xqContextMenu'; this._contextMenuContainer.style.display='none'; xq.observe(this.doc, 'click', this._contextMenuClicked.bindAsEventListener(this)); xq.observe(this.rdom.getDoc(), 'click', this.hideContextMenu.bindAsEventListener(this)); this.body.appendChild(this._contextMenuContainer); } else { while (this._contextMenuContainer.childNodes.length > 0) this._contextMenuContainer.removeChild(this._contextMenuContainer.childNodes[0]); } for (var i=0; i < menuItems.length; i++) { menuItems[i]._node = this._addContextMenuItem(menuItems[i]); } this._contextMenuContainer.style.display='block'; this._contextMenuContainer.style.left=Math.min(Math.max(this.doc.body.scrollWidth, this.doc.documentElement.clientWidth)-this._contextMenuContainer.offsetWidth, x)+'px'; this._contextMenuContainer.style.top=Math.min(Math.max(this.doc.body.scrollHeight, this.doc.documentElement.clientHeight)-this._contextMenuContainer.offsetHeight, y)+'px'; this._contextMenuItems = menuItems; }, hideContextMenu: function() { if (this._contextMenuContainer) this._contextMenuContainer.style.display='none'; }, _addContextMenuItem: function(item) { if (!this._contextMenuContainer) throw "No conext menu container exists"; var node = this.doc.createElement('LI'); if (item.disabled) node.className += ' disabled'; if (item.title == '----') { node.innerHTML = ' '; node.className = 'separator'; } else { if(item.handler) { node.innerHTML = ''+(item.title.toString().escapeHTML())+''; } else { node.innerHTML = (item.title.toString().escapeHTML()); } } if(item.className) node.className = item.className; this._contextMenuContainer.appendChild(node); return node; }, _contextMenuClicked: function(e) { this.hideContextMenu(); if (!this._contextMenuContainer) return; var node = e.srcElement || e.target; while(node && node.nodeName != "LI") { node = node.parentNode; } if (!node || !this.rdom.tree.isDescendantOf(this._contextMenuContainer, node)) return; for (var i=0; i < this._contextMenuItems.length; i++) { if (this._contextMenuItems[i]._node == node) { var handler = this._contextMenuItems[i].handler; if (!this._contextMenuItems[i].disabled && handler) { var xed = this; var element = this._contextMenuTargetElement; if(typeof handler == "function") { handler(xed, element); } else { eval(handler); } } break; } } }, /** * Inserts HTML template * * @param {String} html Template string. It should have single root element * @returns {Element} inserted element */ insertTemplate: function(html) { return this.rdom.insertHtml(this._processTemplate(html)); }, /** * Places given HTML template nearby target. * * @param {String} html Template string. It should have single root element * @param {Node} target Target node. * @param {String} where Possible values: "before", "start", "end", "after" * * @returns {Element} Inserted element. */ insertTemplateAt: function(html, target, where) { return this.rdom.insertHtmlAt(this._processTemplate(html), target, where); }, _processTemplate: function(html) { // apply template processors var tps = this.getTemplateProcessors(); for(var key in tps) { var value = tps[key]; html = value.handler(html); } // remove all whitespace characters between block tags return html = this.removeUnnecessarySpaces(html); }, /** @private */ _handleEnterAtEmptyBlock: function() { var block = this.rdom.getCurrentBlockElement(); if(this.rdom.tree.isTableCell(block) && this.rdom.isFirstBlockOfBody(block)) { block = this.rdom.insertNodeAt(this.rdom.makeEmptyParagraph(), this.rdom.getRoot(), "start"); } else { block = this.rdom.outdentElement(block) || this.rdom.extractOutElementFromParent(block) || this.rdom.replaceTag("P", block) || this.rdom.insertNewBlockAround(block); } this.rdom.placeCaretAtStartOf(block); if(!xq.Browser.isTrident) block.scrollIntoView(false); }, /** @private */ _handleEnterAtEdge: function(atStart, forceInsertParagraph) { var block = this.rdom.getCurrentBlockElement(); var blockToPlaceCaret; if(atStart && this.rdom.isFirstBlockOfBody(block)) { blockToPlaceCaret = this.rdom.insertNodeAt(this.rdom.makeEmptyParagraph(), this.rdom.getRoot(), "start"); } else { if(this.rdom.tree.isTableCell(block)) forceInsertParagraph = true; var newBlock = this.rdom.insertNewBlockAround(block, atStart, forceInsertParagraph ? "P" : null); blockToPlaceCaret = !atStart ? newBlock : newBlock.nextSibling; } this.rdom.placeCaretAtStartOf(blockToPlaceCaret); if(!xq.Browser.isTrident) blockToPlaceCaret.scrollIntoView(false); } }); xq.Browser = { // By Layout Engines isTrident: navigator.appName == "Microsoft Internet Explorer", isWebkit: navigator.userAgent.indexOf('AppleWebKit/') > -1, isGecko: navigator.userAgent.indexOf('Gecko') > -1 && navigator.userAgent.indexOf('KHTML') == -1, isKHTML: navigator.userAgent.indexOf('KHTML') != -1, isPresto: navigator.appName == "Opera", // By Platforms isMac: navigator.userAgent.indexOf("Macintosh") != -1, isUbuntu: navigator.userAgent.indexOf('Ubuntu') != -1, // By Browsers isIE: navigator.appName == "Microsoft Internet Explorer", isIE6: navigator.userAgent.indexOf('MSIE 6') != -1, isIE7: navigator.userAgent.indexOf('MSIE 7') != -1 }; xq.Shortcut = xq.Class({ initialize: function(keymapOrExpression) { xq.addToFinalizeQueue(this); this.keymap = (typeof keymapOrExpression == "string") ? xq.Shortcut.interprete(keymapOrExpression).keymap : keymapOrExpression; }, matches: function(e) { var which = xq.Browser.isGecko && xq.Browser.isMac ? (e.keyCode + "_" + e.charCode) : e.keyCode; var keyMatches = (this.keymap.which == which) || (this.keymap.which == 32 && which == 25); // 25 is SPACE in Type-3 keyboard. if(typeof e.metaKey == "undefined") e.metaKey = false; var modifierMatches = (typeof this.keymap.shiftKey == "undefined" || this.keymap.shiftKey == e.shiftKey) && (typeof this.keymap.altKey == "undefined" || this.keymap.altKey == e.altKey) && (typeof this.keymap.ctrlKey == "undefined" || this.keymap.ctrlKey == e.ctrlKey) && (typeof this.keymap.metaKey == "undefined" || this.keymap.metaKey == e.metaKey) return modifierMatches && keyMatches; } }); xq.Shortcut.interprete = function(expression) { expression = expression.toUpperCase(); var which = xq.Shortcut._interpreteWhich(expression.split("+").pop()); var ctrlKey = xq.Shortcut._interpreteModifier(expression, "CTRL"); var altKey = xq.Shortcut._interpreteModifier(expression, "ALT"); var shiftKey = xq.Shortcut._interpreteModifier(expression, "SHIFT"); var metaKey = xq.Shortcut._interpreteModifier(expression, "META"); var keymap = {}; keymap.which = which; if(typeof ctrlKey != "undefined") keymap.ctrlKey = ctrlKey; if(typeof altKey != "undefined") keymap.altKey = altKey; if(typeof shiftKey != "undefined") keymap.shiftKey = shiftKey; if(typeof metaKey != "undefined") keymap.metaKey = metaKey; return new xq.Shortcut(keymap); } xq.Shortcut._interpreteModifier = function(expression, modifierName) { return expression.match("\\(" + modifierName + "\\)") ? undefined : expression.match(modifierName) ? true : false; } xq.Shortcut._interpreteWhich = function(keyName) { var which = keyName.length == 1 ? ((xq.Browser.isMac && xq.Browser.isGecko) ? "0_" + keyName.toLowerCase().charCodeAt(0) : keyName.charCodeAt(0)) : xq.Shortcut._keyNames[keyName]; if(typeof which == "undefined") throw "Unknown special key name: [" + keyName + "]" return which; } xq.Shortcut._keyNames = xq.Browser.isMac && xq.Browser.isGecko ? { BACKSPACE: "8_0", TAB: "9_0", RETURN: "13_0", ENTER: "13_0", ESC: "27_0", SPACE: "0_32", LEFT: "37_0", UP: "38_0", RIGHT: "39_0", DOWN: "40_0", DELETE: "46_0", HOME: "36_0", END: "35_0", PAGEUP: "33_0", PAGEDOWN: "34_0", COMMA: "0_44", HYPHEN: "0_45", EQUAL: "0_61", PERIOD: "0_46", SLASH: "0_47", F1: "112_0", F2: "113_0", F3: "114_0", F4: "115_0", F5: "116_0", F6: "117_0", F7: "118_0", F8: "119_0" } : { BACKSPACE: 8, TAB: 9, RETURN: 13, ENTER: 13, ESC: 27, SPACE: 32, LEFT: 37, UP: 38, RIGHT: 39, DOWN: 40, DELETE: 46, HOME: 36, END: 35, PAGEUP: 33, PAGEDOWN: 34, COMMA: 188, HYPHEN: xq.Browser.isTrident ? 189 : 109, EQUAL: xq.Browser.isTrident ? 187 : 61, PERIOD: 190, SLASH: 191, F1:112, F2:113, F3:114, F4:115, F5:116, F6:117, F7:118, F8:119, F9:120, F10:121, F11:122, F12:123 } /** * Provide various tree operations. * * TODO: Add specs */ xq.DomTree = xq.Class({ initialize: function() { xq.addToFinalizeQueue(this); this._blockTags = ["DIV", "DD", "LI", "ADDRESS", "CAPTION", "DT", "H1", "H2", "H3", "H4", "H5", "H6", "HR", "P", "BODY", "BLOCKQUOTE", "PRE", "PARAM", "DL", "OL", "UL", "TABLE", "THEAD", "TBODY", "TR", "TH", "TD"]; this._blockContainerTags = ["DIV", "DD", "LI", "BODY", "BLOCKQUOTE", "UL", "OL", "DL", "TABLE", "THEAD", "TBODY", "TR", "TH", "TD"]; this._listContainerTags = ["OL", "UL", "DL"]; this._tableCellTags = ["TH", "TD"]; this._blockOnlyContainerTags = ["BODY", "BLOCKQUOTE", "UL", "OL", "DL", "TABLE", "THEAD", "TBODY", "TR"]; this._atomicTags = ["IMG", "OBJECT", "BR", "HR"]; }, getBlockTags: function() { return this._blockTags; }, /** * Find common ancestor(parent) and his immediate children(left and right). * * A --- B -+- C -+- D -+- E * | * +- F -+- G * * For example: * > findCommonAncestorAndImmediateChildrenOf("E", "G") * * will return * * > {parent:"B", left:"C", right:"F"} */ findCommonAncestorAndImmediateChildrenOf: function(left, right) { if(left.parentNode == right.parentNode) { return { left:left, right:right, parent:left.parentNode }; } else { var parentsOfLeft = this.collectParentsOf(left, true); var parentsOfRight = this.collectParentsOf(right, true); var ca = this.getCommonAncestor(parentsOfLeft, parentsOfRight); var leftAncestor = parentsOfLeft.find(function(node) {return node.parentNode == ca}); var rightAncestor = parentsOfRight.find(function(node) {return node.parentNode == ca}); return { left:leftAncestor, right:rightAncestor, parent:ca }; } }, /** * Find leaves at edge. * * A --- B -+- C -+- D -+- E * | * +- F -+- G * * For example: * > getLeavesAtEdge("A") * * will return * * > ["E", "G"] */ getLeavesAtEdge: function(element) { if(!element.hasChildNodes()) return [null, null]; var findLeft = function(el) { for (var i = 0; i < el.childNodes.length; i++) { if (el.childNodes[i].nodeType == 1 && this.isBlock(el.childNodes[i])) return findLeft(el.childNodes[i]); } return el; }.bind(this); var findRight=function(el) { for (var i = el.childNodes.length; i--;) { if (el.childNodes[i].nodeType == 1 && this.isBlock(el.childNodes[i])) return findRight(el.childNodes[i]); } return el; }.bind(this); var left = findLeft(element); var right = findRight(element); return [left == element ? null : left, right == element ? null : right]; }, getCommonAncestor: function(parents1, parents2) { for(var i = 0; i < parents1.length; i++) { for(var j = 0; j < parents2.length; j++) { if(parents1[i] == parents2[j]) return parents1[i]; } } }, collectParentsOf: function(node, includeSelf, exitCondition) { var parents = []; if(includeSelf) parents.push(node); while((node = node.parentNode) && (node.nodeName != "HTML") && !(typeof exitCondition == "function" && exitCondition(node))) parents.push(node); return parents; }, isDescendantOf: function(parent, child) { if(parent.length > 0) { for(var i = 0; i < parent.length; i++) { if(this.isDescendantOf(parent[i], child)) return true; } return false; } if(parent == child) return false; while (child = child.parentNode) if (child == parent) return true; return false; }, /** * Perform tree walking (foreward) */ walkForward: function(node) { if(node.hasChildNodes()) return node.firstChild; if(node.nextSibling) return node.nextSibling; while(node = node.parentNode) { if(node.nextSibling) return node.nextSibling; } return null; }, /** * Perform tree walking (backward) */ walkBackward: function(node) { if(node.previousSibling) { node = node.previousSibling; while(node.hasChildNodes()) {node = node.lastChild;} return node; } return node.parentNode; }, /** * Perform tree walking (to next siblings) */ walkNext: function(node) {return node.nextSibling}, /** * Perform tree walking (to next siblings) */ walkPrev: function(node) {return node.previousSibling}, /** * Returns true if target is followed by start */ checkTargetForward: function(start, target) { return this._check(start, this.walkForward, target); }, /** * Returns true if start is followed by target */ checkTargetBackward: function(start, target) { return this._check(start, this.walkBackward, target); }, findForward: function(start, condition, exitCondition) { return this._find(start, this.walkForward, condition, exitCondition); }, findBackward: function(start, condition, exitCondition) { return this._find(start, this.walkBackward, condition, exitCondition); }, /** @private */ _check: function(start, direction, target) { if(start == target) return false; while(start = direction(start)) { if(start == target) return true; } return false; }, /** @private */ _find: function(start, direction, condition, exitCondition) { while(start = direction(start)) { if(exitCondition && exitCondition(start)) return null; if(condition(start)) return start; } return null; }, /** * Walks Forward through DOM tree from start to end, and collects all nodes that matches with a filter. * If no filter provided, it just collects all nodes. * * @param function filter a filter function */ collectNodesBetween: function(start, end, filter) { if(start == end) return [start, end].findAll(filter || function() {return true}); var nodes = this.collectForward(start, function(node) {return node == end}, filter); if( start != end && typeof filter == "function" && filter(end) ) nodes.push(end); return nodes; }, collectForward: function(start, exitCondition, filter) { return this.collect(start, this.walkForward, exitCondition, filter); }, collectBackward: function(start, exitCondition, filter) { return this.collect(start, this.walkBackward, exitCondition, filter); }, collectNext: function(start, exitCondition, filter) { return this.collect(start, this.walkNext, exitCondition, filter); }, collectPrev: function(start, exitCondition, filter) { return this.collect(start, this.walkPrev, exitCondition, filter); }, collect: function(start, next, exitCondition, filter) { var nodes = [start]; while(true) { start = next(start); if( (start == null) || (typeof exitCondition == "function" && exitCondition(start)) ) break; nodes.push(start); } return (typeof filter == "function") ? nodes.findAll(filter) : nodes; }, hasBlocks: function(element) { var nodes = element.childNodes; for(var i = 0; i < nodes.length; i++) { if(this.isBlock(nodes[i])) return true; } return false; }, hasMixedContents: function(element) { if(!this.isBlock(element)) return false; if(!this.isBlockContainer(element)) return false; var hasTextOrInline = false; var hasBlock = false; for(var i = 0; i < element.childNodes.length; i++) { var node = element.childNodes[i]; if(!hasTextOrInline && this.isTextOrInlineNode(node)) hasTextOrInline = true; if(!hasBlock && this.isBlock(node)) hasBlock = true; if(hasTextOrInline && hasBlock) break; } if(!hasTextOrInline || !hasBlock) return false; return true; }, isBlockOnlyContainer: function(element) { if(!element) return false; return this._blockOnlyContainerTags.indexOf(typeof element == 'string' ? element : element.nodeName) != -1; }, isTableCell: function(element) { if(!element) return false; return this._tableCellTags.indexOf(typeof element == 'string' ? element : element.nodeName) != -1; }, isBlockContainer: function(element) { if(!element) return false; return this._blockContainerTags.indexOf(typeof element == 'string' ? element : element.nodeName) != -1; }, isHeading: function(element) { if(!element) return false; return (typeof element == 'string' ? element : element.nodeName).match(/H\d/); }, isBlock: function(element) { if(!element) return false; return this._blockTags.indexOf(typeof element == 'string' ? element : element.nodeName) != -1; }, isAtomic: function(element) { if(!element) return false; return this._atomicTags.indexOf(typeof element == 'string' ? element : element.nodeName) != -1; }, isListContainer: function(element) { if(!element) return false; return this._listContainerTags.indexOf(typeof element == 'string' ? element : element.nodeName) != -1; }, isTextOrInlineNode: function(node) { return node && (node.nodeType == 3 || !this.isBlock(node)); } }); /** * Encapsulates browser incompatibility problem and provides rich set of DOM manipulation API. * * RichDom provides basic CRUD + Advanced DOM manipulation API, various query methods and caret/selection management API */ xq.RichDom = xq.Class({ /** * Initialize RichDom. Target window and root element should be set after initialization. See setWin and setRoot. * * @constructor */ initialize: function() { xq.addToFinalizeQueue(this); /** * {xq.DomTree} instance of DomTree */ this.tree = new xq.DomTree(); this._lastMarkerId = 0; }, /** * @param {Window} win Browser's window object */ setWin: function(win) { if(!win) throw "[win] is null"; this.win = win; }, /** * @param {Element} root Root element */ setRoot: function(root) { if(!root) throw "[root] is null"; if(this.win && (root.ownerDocument != this.win.document)) throw "root.ownerDocument != this.win.document"; this.root = root; this.doc = this.root.ownerDocument; }, /** * @returns Browser's window object. */ getWin: function() {return this.win}, /** * @returns Document object of root element. */ getDoc: function() {return this.doc}, /** * @returns Root element. */ getRoot: function() {return this.root}, ///////////////////////////////////////////// // CRUDs clearRoot: function() { this.root.innerHTML = ""; this.root.appendChild(this.makeEmptyParagraph()); }, /** * Removes place holders and empty text nodes of given element. * * @param {Element} element target element */ removePlaceHoldersAndEmptyNodes: function(element) { var children = element.childNodes; if(!children) return; var stopAt = this.getBottommostLastChild(element); if(!stopAt) return; stopAt = this.tree.walkForward(stopAt); while(true) { if(!element || element == stopAt) break; if( this.isPlaceHolder(element) || (element.nodeType == 3 && element.nodeValue == "") || (!this.getNextSibling(element) && element.nodeType == 3 && element.nodeValue.strip() == "") ) { var deleteTarget = element; element = this.tree.walkForward(element); this.deleteNode(deleteTarget); } else { element = this.tree.walkForward(element); } } }, /** * Sets multiple attributes into element at once * * @param {Element} element target element * @param {Object} map key-value pairs */ setAttributes: function(element, map) { for(var key in map) element.setAttribute(key, map[key]); }, /** * Creates textnode by given node value. * * @param {String} value value of textnode * @returns {Node} Created text node */ createTextNode: function(value) {return this.doc.createTextNode(value);}, /** * Creates empty element by given tag name. * * @param {String} tagName name of tag * @returns {Element} Created element */ createElement: function(tagName) {return this.doc.createElement(tagName);}, /** * Creates element from HTML string * * @param {String} html HTML string * @returns {Element} Created element */ createElementFromHtml: function(html) { var node = this.createElement("div"); node.innerHTML = html; if(node.childNodes.length != 1) { throw "Illegal HTML fragment"; } return this.getFirstChild(node); }, /** * Deletes node from DOM tree. * * @param {Node} node Target node which should be deleted * @param {boolean} deleteEmptyParentsRecursively Recursively delete empty parent elements * @param {boolean} correctEmptyParent Call #correctEmptyElement on empty parent element after deletion */ deleteNode: function(node, deleteEmptyParentsRecursively, correctEmptyParent) { if(!node || !node.parentNode) return; var parent = node.parentNode; parent.removeChild(node); if(deleteEmptyParentsRecursively) { while(!parent.hasChildNodes()) { node = parent; parent = node.parentNode; if(!parent || this.getRoot() == node) break; parent.removeChild(node); } } if(correctEmptyParent && this.isEmptyBlock(parent)) { parent.innerHTML = ""; this.correctEmptyElement(parent); } }, /** * Inserts given node into current caret position * * @param {Node} node Target node * @returns {Node} Inserted node. It could be different with given node. */ insertNode: function(node) {throw "Not implemented"}, /** * Inserts given html into current caret position * * @param {String} html HTML string * @returns {Node} Inserted node. It could be different with given node. */ insertHtml: function(html) { return this.insertNode(this.createElementFromHtml(html)); }, /** * Creates textnode from given text and inserts it into current caret position * * @param {String} text Value of textnode * @returns {Node} Inserted node */ insertText: function(text) { this.insertNode(this.createTextNode(text)); }, /** * Places given node nearby target. * * @param {Node} node Node to be inserted. * @param {Node} target Target node. * @param {String} where Possible values: "before", "start", "end", "after" * @param {boolean} performValidation Validate node if needed. For example when P placed into UL, its tag name automatically replaced with LI * * @returns {Node} Inserted node. It could be different with given node. */ insertNodeAt: function(node, target, where, performValidation) { if( ["HTML", "HEAD"].indexOf(target.nodeName) != -1 || "BODY" == target.nodeName && ["before", "after"].indexOf(where) != -1 ) throw "Illegal argument. Cannot move node[" + node.nodeName + "] to '" + where + "' of target[" + target.nodeName + "]" var object; var message; var secondParam; switch(where.toLowerCase()) { case "before": object = target.parentNode; message = 'insertBefore'; secondParam = target; break case "start": if(target.firstChild) { object = target; message = 'insertBefore'; secondParam = target.firstChild; } else { object = target; message = 'appendChild'; } break case "end": object = target; message = 'appendChild'; break case "after": if(target.nextSibling) { object = target.parentNode; message = 'insertBefore'; secondParam = target.nextSibling; } else { object = target.parentNode; message = 'appendChild'; } break } if(performValidation && this.tree.isListContainer(object) && node.nodeName != "LI") { var li = this.createElement("LI"); li.appendChild(node); node = li; object[message](node, secondParam); } else if(performValidation && !this.tree.isListContainer(object) && node.nodeName == "LI") { this.wrapAllInlineOrTextNodesAs("P", node, true); var div = this.createElement("DIV"); this.moveChildNodes(node, div); this.deleteNode(node); object[message](div, secondParam); node = this.unwrapElement(div, true); } else { object[message](node, secondParam); } return node; }, /** * Creates textnode from given text and places given node nearby target. * * @param {String} text Text to be inserted. * @param {Node} target Target node. * @param {String} where Possible values: "before", "start", "end", "after" * * @returns {Node} Inserted node. */ insertTextAt: function(text, target, where) { return this.insertNodeAt(this.createTextNode(text), target, where); }, /** * Creates element from given HTML string and places given it nearby target. * * @param {String} html HTML to be inserted. * @param {Node} target Target node. * @param {String} where Possible values: "before", "start", "end", "after" * * @returns {Node} Inserted node. */ insertHtmlAt: function(html, target, where) { return this.insertNodeAt(this.createElementFromHtml(html), target, where); }, /** * Replaces element's tag by removing current element and creating new element by given tag name. * * @param {String} tag New tag name * @param {Element} element Target element * * @returns {Element} Replaced element */ replaceTag: function(tag, element) { if(element.nodeName == tag) return null; if(this.tree.isTableCell(element)) return null; var newElement = this.createElement(tag); this.moveChildNodes(element, newElement); this.copyAttributes(element, newElement, true); element.parentNode.replaceChild(newElement, element); if(!newElement.hasChildNodes()) this.correctEmptyElement(newElement); return newElement; }, /** * Unwraps unnecessary paragraph. * * Unnecessary paragraph is P which is the only child of given container element. * For example, P which is contained by LI and is the only child is the unnecessary paragraph. * But if given container element is a block-only-container(BLOCKQUOTE, BODY), this method does nothing. * * @param {Element} element Container element * @returns {boolean} True if unwrap performed. */ unwrapUnnecessaryParagraph: function(element) { if(!element) return false; if(!this.tree.isBlockOnlyContainer(element) && element.childNodes.length == 1 && element.firstChild.nodeName == "P" && !this.hasImportantAttributes(element.firstChild)) { var p = element.firstChild; this.moveChildNodes(p, element); this.deleteNode(p); return true; } return false; }, /** * Unwraps element by extracting all children out and removing the element. * * @param {Element} element Target element * @param {boolean} wrapInlineAndTextNodes Wrap all inline and text nodes with P before unwrap * @returns {Node} First child of unwrapped element */ unwrapElement: function(element, wrapInlineAndTextNodes) { if(wrapInlineAndTextNodes) this.wrapAllInlineOrTextNodesAs("P", element); var nodeToReturn = element.firstChild; while(element.firstChild) this.insertNodeAt(element.firstChild, element, "before"); this.deleteNode(element); return nodeToReturn; }, /** * Wraps element by given tag * * @param {String} tag tag name * @param {Element} element target element to wrap * @returns {Element} wrapper */ wrapElement: function(tag, element) { var wrapper = this.insertNodeAt(this.createElement(tag), element, "before"); wrapper.appendChild(element); return wrapper; }, /** * Tests #smartWrap with given criteria but doesn't change anything */ testSmartWrap: function(endElement, criteria) { return this.smartWrap(endElement, null, criteria, true); }, /** * Create inline element with given tag name and wraps nodes nearby endElement by given criteria * * @param {Element} endElement Boundary(end point, exclusive) of wrapper. * @param {String} tag Tag name of wrapper. * @param {Object} function which returns text index of start boundary. * @param {boolean} testOnly just test boundary and do not perform actual wrapping. * * @returns {Element} wrapper */ smartWrap: function(endElement, tag, criteria, testOnly) { var block = this.getParentBlockElementOf(endElement); tag = tag || "SPAN"; criteria = criteria || function(text) {return -1}; // check for empty wrapper if(!testOnly && (!endElement.previousSibling || this.isEmptyBlock(block))) { var wrapper = this.insertNodeAt(this.createElement(tag), endElement, "before"); return wrapper; } // collect all textnodes var textNodes = this.tree.collectForward(block, function(node) {return node == endElement}, function(node) {return node.nodeType == 3}); // find textnode and break-point var nodeIndex = 0; var nodeValues = []; for(var i = 0; i < textNodes.length; i++) { nodeValues.push(textNodes[i].nodeValue); } var textToWrap = nodeValues.join(""); var textIndex = criteria(textToWrap) var breakPoint = textIndex; if(breakPoint == -1) { breakPoint = 0; } else { textToWrap = textToWrap.substring(breakPoint); } for(var i = 0; i < textNodes.length; i++) { if(breakPoint > nodeValues[i].length) { breakPoint -= nodeValues[i].length; } else { nodeIndex = i; break; } } if(testOnly) return {text:textToWrap, textIndex:textIndex, nodeIndex:nodeIndex, breakPoint:breakPoint}; // break textnode if necessary if(breakPoint != 0) { var splitted = textNodes[nodeIndex].splitText(breakPoint); nodeIndex++; textNodes.splice(nodeIndex, 0, splitted); } var startElement = textNodes[nodeIndex] || block.firstChild; // split inline elements up to parent block if necessary var family = this.tree.findCommonAncestorAndImmediateChildrenOf(startElement, endElement); var ca = family.parent; if(ca) { if(startElement.parentNode != ca) startElement = this.splitElementUpto(startElement, ca, true); if(endElement.parentNode != ca) endElement = this.splitElementUpto(endElement, ca, true); var prevStart = startElement.previousSibling; var nextEnd = endElement.nextSibling; // remove empty inline elements if(prevStart && prevStart.nodeType == 1 && this.isEmptyBlock(prevStart)) this.deleteNode(prevStart); if(nextEnd && nextEnd.nodeType == 1 && this.isEmptyBlock(nextEnd)) this.deleteNode(nextEnd); // wrap var wrapper = this.insertNodeAt(this.createElement(tag), startElement, "before"); while(wrapper.nextSibling != endElement) wrapper.appendChild(wrapper.nextSibling); return wrapper; } else { // wrap var wrapper = this.insertNodeAt(this.createElement(tag), endElement, "before"); return wrapper; } }, /** * Wraps all adjust inline elements and text nodes into block element. * * TODO: empty element should return empty array when it is not forced and (at least) single item array when forced * * @param {String} tag Tag name of wrapper * @param {Element} element Target element * @param {boolean} force Force wrapping. If it is set to false, this method do not makes unnecessary wrapper. * * @returns {Array} Array of wrappers. If nothing performed it returns empty array */ wrapAllInlineOrTextNodesAs: function(tag, element, force) { var wrappers = []; if(!force && !this.tree.hasMixedContents(element)) return wrappers; var node = element.firstChild; while(node) { if(this.tree.isTextOrInlineNode(node)) { var wrapper = this.wrapInlineOrTextNodesAs(tag, node); wrappers.push(wrapper); node = wrapper.nextSibling; } else { node = node.nextSibling; } } return wrappers; }, /** * Wraps node and its adjust next siblings into an element */ wrapInlineOrTextNodesAs: function(tag, node) { var wrapper = this.createElement(tag); var from = node; from.parentNode.replaceChild(wrapper, from); wrapper.appendChild(from); // move nodes into wrapper while(wrapper.nextSibling && this.tree.isTextOrInlineNode(wrapper.nextSibling)) wrapper.appendChild(wrapper.nextSibling); return wrapper; }, /** * Turns block element into list item * * @param {Element} element Target element * @param {String} type One of "UL", "OL", "CODE". "CODE" is same with "OL" but it gives "OL" a class name "code" * * @return {Element} LI element */ turnElementIntoListItem: function(element, type) { type = type.toUpperCase(); var container = this.createElement(type == "UL" ? "UL" : "OL"); if(type == "CODE") container.className = "code"; if(this.tree.isTableCell(element)) { var p = this.wrapAllInlineOrTextNodesAs("P", element, true)[0]; container = this.insertNodeAt(container, element, "start"); var li = this.insertNodeAt(this.createElement("LI"), container, "start"); li.appendChild(p); } else { container = this.insertNodeAt(container, element, "after"); var li = this.insertNodeAt(this.createElement("LI"), container, "start"); li.appendChild(element); } this.unwrapUnnecessaryParagraph(li); this.mergeAdjustLists(container); return li; }, /** * Extracts given element out from its parent element. * * @param {Element} element Target element */ extractOutElementFromParent: function(element) { if(element == this.root || this.root == element.parentNode || !element.offsetParent) return null; if(element.nodeName == "LI") { this.wrapAllInlineOrTextNodesAs("P", element, true); element = element.firstChild; } var container = element.parentNode; var nodeToReturn = null; if(container.nodeName == "LI" && container.parentNode.parentNode.nodeName == "LI") { // nested list item if(element.previousSibling) { this.splitContainerOf(element, true); this.correctEmptyElement(element); } this.outdentListItem(element); nodeToReturn = element; } else if(container.nodeName == "LI") { // not-nested list item if(this.tree.isListContainer(element.nextSibling)) { // 1. split listContainer var listContainer = container.parentNode; this.splitContainerOf(container, true); this.correctEmptyElement(element); // 2. extract out LI's children nodeToReturn = container.firstChild; while(container.firstChild) { this.insertNodeAt(container.firstChild, listContainer, "before"); } // 3. remove listContainer and merge adjust lists var prevContainer = listContainer.previousSibling; this.deleteNode(listContainer); if(prevContainer && this.tree.isListContainer(prevContainer)) this.mergeAdjustLists(prevContainer); } else { // 1. split LI this.splitContainerOf(element, true); this.correctEmptyElement(element); // 2. split list container var listContainer = this.splitContainerOf(container); // 3. extract out this.insertNodeAt(element, listContainer.parentNode, "before"); this.deleteNode(listContainer.parentNode); nodeToReturn = element; } } else if(this.tree.isTableCell(container) || this.tree.isTableCell(element)) { // do nothing } else { // normal block this.splitContainerOf(element, true); this.correctEmptyElement(element); nodeToReturn = this.insertNodeAt(element, container, "before"); this.deleteNode(container); } return nodeToReturn; }, /** * Insert new block above or below given element. * * @param {Element} block Target block * @param {boolean} before Insert new block above(before) target block * @param {String} forceTag New block's tag name. If omitted, target block's tag name will be used. * * @returns {Element} Inserted block */ insertNewBlockAround: function(block, before, forceTag) { var isListItem = block.nodeName == "LI" || block.parentNode.nodeName == "LI"; this.removeTrailingWhitespace(block); if(this.isFirstLiWithNestedList(block) && !forceTag && before) { var li = this.getParentElementOf(block, ["LI"]); var newBlock = this._insertNewBlockAround(li, before); return newBlock; } else if(isListItem && !forceTag) { var li = this.getParentElementOf(block, ["LI"]); var newBlock = this._insertNewBlockAround(block, before); if(li != block) newBlock = this.splitContainerOf(newBlock, false, "prev"); return newBlock; } else if(this.tree.isBlockContainer(block)) { this.wrapAllInlineOrTextNodesAs("P", block, true); return this._insertNewBlockAround(block.firstChild, before, forceTag); } else { return this._insertNewBlockAround(block, before, this.tree.isHeading(block) ? "P" : forceTag); } }, /** * @private * * TODO: Rename */ _insertNewBlockAround: function(element, before, tagName) { var newElement = this.createElement(tagName || element.nodeName); this.copyAttributes(element, newElement, false); this.correctEmptyElement(newElement); newElement = this.insertNodeAt(newElement, element, before ? "before" : "after"); return newElement; }, /** * Wrap or replace element with given tag name. * * @param {String} tag Tag name * @param {Element} element Target element * * @return {Element} wrapper element or replaced element. */ applyTagIntoElement: function(tag, element) { if(this.tree.isBlockOnlyContainer(tag)) { return this.wrapBlock(tag, element); } else if(this.tree.isBlockContainer(element)) { var wrapper = this.createElement(tag); this.moveChildNodes(element, wrapper); return this.insertNodeAt(wrapper, element, "start"); } else { if(this.tree.isBlockContainer(tag) && this.hasImportantAttributes(element)) { return this.wrapBlock(tag, element); } else { return this.replaceTag(tag, element); } } throw "IllegalArgumentException - [" + tag + ", " + element + "]"; }, /** * Wrap or replace elements with given tag name. * * @param {String} tag Tag name * @param {Element} from Start boundary (inclusive) * @param {Element} to End boundary (inclusive) * * @returns {Array} Array of wrappers or replaced elements */ applyTagIntoElements: function(tagName, from, to) { var applied = []; if(this.tree.isBlockContainer(tagName)) { var family = this.tree.findCommonAncestorAndImmediateChildrenOf(from, to); var node = family.left; var wrapper = this.insertNodeAt(this.createElement(tagName), node, "before"); var coveringWholeList = family.parent.nodeName == "LI" && family.parent.parentNode.childNodes.length == 1 && !family.left.previousSilbing && !family.right.nextSibling; if(coveringWholeList) { var ul = node.parentNode.parentNode; this.insertNodeAt(wrapper, ul, "before"); wrapper.appendChild(ul); } else { while(node != family.right) { next = node.nextSibling; wrapper.appendChild(node); node = next; } wrapper.appendChild(family.right); } applied.push(wrapper); } else { // is normal tagName var elements = this.getBlockElementsBetween(from, to); for(var i = 0; i < elements.length; i++) { if(this.tree.isBlockContainer(elements[i])) { var wrappers = this.wrapAllInlineOrTextNodesAs(tagName, elements[i], true); for(var j = 0; j < wrappers.length; j++) { applied.push(wrappers[j]); } } else { applied.push(this.replaceTag(tagName, elements[i])); } } } return applied; }, /** * Moves block up or down * * @param {Element} block Target block * @param {boolean} up Move up if true * * @returns {Element} Moved block. It could be different with given block. */ moveBlock: function(block, up) { // if block is table cell or contained by table cell, select its row as mover block = this.getParentElementOf(block, ["TR"]) || block; // if block is only child, select its parent as mover while(block.nodeName != "TR" && block.parentNode != this.getRoot() && !block.previousSibling && !block.nextSibling && !this.tree.isListContainer(block.parentNode)) { block = block.parentNode; } // find target and where var target, where; if (up) { target = block.previousSibling; if(target) { var singleNodeLi = target.nodeName == 'LI' && ((target.childNodes.length == 1 && this.tree.isBlock(target.firstChild)) || !this.tree.hasBlocks(target)); var table = ['TABLE', 'TR'].indexOf(target.nodeName) != -1; where = this.tree.isBlockContainer(target) && !singleNodeLi && !table ? "end" : "before"; } else if(block.parentNode != this.getRoot()) { target = block.parentNode; where = "before"; } } else { target = block.nextSibling; if(target) { var singleNodeLi = target.nodeName == 'LI' && ((target.childNodes.length == 1 && this.tree.isBlock(target.firstChild)) || !this.tree.hasBlocks(target)); var table = ['TABLE', 'TR'].indexOf(target.nodeName) != -1; where = this.tree.isBlockContainer(target) && !singleNodeLi && !table ? "start" : "after"; } else if(block.parentNode != this.getRoot()) { target = block.parentNode; where = "after"; } } // no way to go? if(!target) return null; if(["TBODY", "THEAD"].indexOf(target.nodeName) != -1) return null; // normalize this.wrapAllInlineOrTextNodesAs("P", target, true); // make placeholder if needed if(this.isFirstLiWithNestedList(block)) { this.insertNewBlockAround(block, false, "P"); } // perform move var parent = block.parentNode; var moved = this.insertNodeAt(block, target, where, true); // cleanup if(!parent.hasChildNodes()) this.deleteNode(parent, true); this.unwrapUnnecessaryParagraph(moved); this.unwrapUnnecessaryParagraph(target); // remove placeholder if(up) { if(moved.previousSibling && this.isEmptyBlock(moved.previousSibling) && !moved.previousSibling.previousSibling && moved.parentNode.nodeName == "LI" && this.tree.isListContainer(moved.nextSibling)) { this.deleteNode(moved.previousSibling); } } else { if(moved.nextSibling && this.isEmptyBlock(moved.nextSibling) && !moved.previousSibling && moved.parentNode.nodeName == "LI" && this.tree.isListContainer(moved.nextSibling.nextSibling)) { this.deleteNode(moved.nextSibling); } } this.correctEmptyElement(moved); return moved; }, /** * Remove given block * * @param {Element} block Target block * @returns {Element} Nearest block of remove element */ removeBlock: function(block) { var blockToMove; // if block is only child, select its parent as mover while(block.parentNode != this.getRoot() && !block.previousSibling && !block.nextSibling && !this.tree.isListContainer(block.parentNode)) { block = block.parentNode; } var finder = function(node) {return this.tree.isBlock(node) && !this.tree.isAtomic(node) && !this.tree.isDescendantOf(block, node) && !this.tree.hasBlocks(node);}.bind(this); var exitCondition = function(node) {return this.tree.isBlock(node) && !this.tree.isDescendantOf(this.getRoot(), node)}.bind(this); if(this.isFirstLiWithNestedList(block)) { blockToMove = this.outdentListItem(block.nextSibling.firstChild); this.deleteNode(blockToMove.previousSibling, true); } else if(this.tree.isTableCell(block)) { var rtable = new xq.RichTable(this, this.getParentElementOf(block, ["TABLE"])); blockToMove = rtable.getBelowCellOf(block); // should not delete row when there's thead and the row is the only child of tbody if( block.parentNode.parentNode.nodeName == "TBODY" && rtable.hasHeadingAtTop() && rtable.getDom().tBodies[0].rows.length == 1) return blockToMove; blockToMove = blockToMove || this.tree.findForward(block, finder, exitCondition) || this.tree.findBackward(block, finder, exitCondition); this.deleteNode(block.parentNode, true); } else { blockToMove = blockToMove || this.tree.findForward(block, finder, exitCondition) || this.tree.findBackward(block, finder, exitCondition); if(!blockToMove) blockToMove = this.insertNodeAt(this.makeEmptyParagraph(), block, "after"); this.deleteNode(block, true); } if(!this.getRoot().hasChildNodes()) { blockToMove = this.createElement("P"); this.getRoot().appendChild(blockToMove); this.correctEmptyElement(blockToMove); } return blockToMove; }, /** * Removes trailing whitespaces of given block * * @param {Element} block Target block */ removeTrailingWhitespace: function(block) {throw "Not implemented"}, /** * Extract given list item out and change its container's tag * * @param {Element} element LI or P which is a child of LI * @param {String} type "OL", "UL", or "CODE" * * @returns {Element} changed element */ changeListTypeTo: function(element, type) { type = type.toUpperCase(); var li = this.getParentElementOf(element, ["LI"]); if(!li) throw "IllegalArgumentException"; var container = li.parentNode; this.splitContainerOf(li); var newContainer = this.insertNodeAt(this.createElement(type == "UL" ? "UL" : "OL"), container, "before"); if(type == "CODE") newContainer.className = "code"; this.insertNodeAt(li, newContainer, "start"); this.deleteNode(container); this.mergeAdjustLists(newContainer); return element; }, /** * Split container of element into (maxium) three pieces. */ splitContainerOf: function(element, preserveElementItself, dir) { if([element, element.parentNode].indexOf(this.getRoot()) != -1) return element; var container = element.parentNode; if(element.previousSibling && (!dir || dir.toLowerCase() == "prev")) { var prev = this.createElement(container.nodeName); this.copyAttributes(container, prev); while(container.firstChild != element) { prev.appendChild(container.firstChild); } this.insertNodeAt(prev, container, "before"); this.unwrapUnnecessaryParagraph(prev); } if(element.nextSibling && (!dir || dir.toLowerCase() == "next")) { var next = this.createElement(container.nodeName); this.copyAttributes(container, next); while(container.lastChild != element) { this.insertNodeAt(container.lastChild, next, "start"); } this.insertNodeAt(next, container, "after"); this.unwrapUnnecessaryParagraph(next); } if(!preserveElementItself) element = this.unwrapUnnecessaryParagraph(container) ? container : element; return element; }, /** * TODO: Add specs */ splitParentElement: function(seperator) { var parent = seperator.parentNode; if(["HTML", "HEAD", "BODY"].indexOf(parent.nodeName) != -1) throw "Illegal argument. Cannot seperate element[" + parent.nodeName + "]"; var previousSibling = seperator.previousSibling; var nextSibling = seperator.nextSibling; var newElement = this.insertNodeAt(this.createElement(parent.nodeName), parent, "after"); var next; while(next = seperator.nextSibling) newElement.appendChild(next); this.insertNodeAt(seperator, newElement, "start"); this.copyAttributes(parent, newElement); return newElement; }, /** * TODO: Add specs */ splitElementUpto: function(seperator, element, excludeElement) { while(seperator.previousSibling != element) { if(excludeElement && seperator.parentNode == element) break; seperator = this.splitParentElement(seperator); } return seperator; }, /** * Merges two adjust elements * * @param {Element} element base element * @param {boolean} withNext merge base element with next sibling * @param {boolean} skip skip merge steps */ mergeElement: function(element, withNext, skip) { this.wrapAllInlineOrTextNodesAs("P", element.parentNode, true); // find two block if(withNext) { var prev = element; var next = this.tree.findForward( element, function(node) {return this.tree.isBlock(node) && !this.tree.isListContainer(node) && node != element.parentNode}.bind(this) ); } else { var next = element; var prev = this.tree.findBackward( element, function(node) {return this.tree.isBlock(node) && !this.tree.isListContainer(node) && node != element.parentNode}.bind(this) ); } // normalize next block if(next && this.tree.isDescendantOf(this.getRoot(), next)) { var nextContainer = next.parentNode; if(this.tree.isBlockContainer(next)) { nextContainer = next; this.wrapAllInlineOrTextNodesAs("P", nextContainer, true); next = nextContainer.firstChild; } } else { next = null; } // normalize prev block if(prev && this.tree.isDescendantOf(this.getRoot(), prev)) { var prevContainer = prev.parentNode; if(this.tree.isBlockContainer(prev)) { prevContainer = prev; this.wrapAllInlineOrTextNodesAs("P", prevContainer, true); prev = prevContainer.lastChild; } } else { prev = null; } try { var containersAreTableCell = prevContainer && (this.tree.isTableCell(prevContainer) || ['TR', 'THEAD', 'TBODY'].indexOf(prevContainer.nodeName) != -1) && nextContainer && (this.tree.isTableCell(nextContainer) || ['TR', 'THEAD', 'TBODY'].indexOf(nextContainer.nodeName) != -1); if(containersAreTableCell && prevContainer != nextContainer) return null; // if next has margin, perform outdent if((!skip || !prev) && next && this.outdentElement(next)) return element; // nextContainer is first li and next of it is list container if(nextContainer && nextContainer.nodeName == 'LI' && this.tree.isListContainer(next.nextSibling)) { this.extractOutElementFromParent(nextContainer); return prev; } // merge two list containers if(nextContainer && nextContainer.nodeName == 'LI' && this.tree.isListContainer(nextContainer.parentNode.previousSibling)) { this.mergeAdjustLists(nextContainer.parentNode.previousSibling, true, "next"); return prev; } if(next && !containersAreTableCell && prevContainer && prevContainer.nodeName == 'LI' && nextContainer && nextContainer.nodeName == 'LI' && prevContainer.parentNode.nextSibling == nextContainer.parentNode) { var nextContainerContainer = nextContainer.parentNode; this.moveChildNodes(nextContainer.parentNode, prevContainer.parentNode); this.deleteNode(nextContainerContainer); return prev; } // merge two containers if(next && !containersAreTableCell && prevContainer && prevContainer.nextSibling == nextContainer && ((skip && prevContainer.nodeName != "LI") || (!skip && prevContainer.nodeName == "LI"))) { this.moveChildNodes(nextContainer, prevContainer); return prev; } // unwrap container if(nextContainer && nextContainer.nodeName != "LI" && !this.getParentElementOf(nextContainer, ["TABLE"]) && !this.tree.isListContainer(nextContainer) && nextContainer != this.getRoot() && !next.previousSibling) { return this.unwrapElement(nextContainer, true); } // delete table if(withNext && nextContainer && nextContainer.nodeName == "TABLE") { this.deleteNode(nextContainer, true); return prev; } else if(!withNext && prevContainer && this.tree.isTableCell(prevContainer) && !this.tree.isTableCell(nextContainer)) { this.deleteNode(this.getParentElementOf(prevContainer, ["TABLE"]), true); return next; } // if prev is same with next, do nothing if(prev == next) return null; // if there is a null block, do nothing if(!prev || !next || !prevContainer || !nextContainer) return null; // if two blocks are not in the same table cell, do nothing if(this.getParentElementOf(prev, ["TD", "TH"]) != this.getParentElementOf(next, ["TD", "TH"])) return null; var prevIsEmpty = false; // cleanup empty block before merge // 1. cleanup prev node which ends with marker + if( xq.Browser.isTrident && prev.childNodes.length >= 2 && this.isMarker(prev.lastChild.previousSibling) && prev.lastChild.nodeType == 3 && prev.lastChild.nodeValue.length == 1 && prev.lastChild.nodeValue.charCodeAt(0) == 160 ) { this.deleteNode(prev.lastChild); } // 2. cleanup prev node (if prev is empty, then replace prev's tag with next's) this.removePlaceHoldersAndEmptyNodes(prev); if(this.isEmptyBlock(prev)) { // replace atomic block with normal block so that following code don't need to care about atomic block if(this.tree.isAtomic(prev)) prev = this.replaceTag("P", prev); prev = this.replaceTag(next.nodeName, prev) || prev; prev.innerHTML = ""; } else if(prev.firstChild == prev.lastChild && this.isMarker(prev.firstChild)) { prev = this.replaceTag(next.nodeName, prev) || prev; } // 3. cleanup next node if(this.isEmptyBlock(next)) { // replace atomic block with normal block so that following code don't need to care about atomic block if(this.tree.isAtomic(next)) next = this.replaceTag("P", next); next.innerHTML = ""; } // perform merge this.moveChildNodes(next, prev); this.deleteNode(next); return prev; } finally { // cleanup if(prevContainer && this.isEmptyBlock(prevContainer)) this.deleteNode(prevContainer, true); if(nextContainer && this.isEmptyBlock(nextContainer)) this.deleteNode(nextContainer, true); if(prevContainer) this.unwrapUnnecessaryParagraph(prevContainer); if(nextContainer) this.unwrapUnnecessaryParagraph(nextContainer); } }, /** * Merges adjust list containers which has same tag name * * @param {Element} container target list container * @param {boolean} force force adjust list container even if they have different list type * @param {String} dir Specify merge direction: PREV or NEXT. If not supplied it will be merged with both direction. */ mergeAdjustLists: function(container, force, dir) { var prev = container.previousSibling; var isPrevSame = prev && (prev.nodeName == container.nodeName && prev.className == container.className); if((!dir || dir.toLowerCase() == 'prev') && (isPrevSame || (force && this.tree.isListContainer(prev)))) { while(prev.lastChild) { this.insertNodeAt(prev.lastChild, container, "start"); } this.deleteNode(prev); } var next = container.nextSibling; var isNextSame = next && (next.nodeName == container.nodeName && next.className == container.className); if((!dir || dir.toLowerCase() == 'next') && (isNextSame || (force && this.tree.isListContainer(next)))) { while(next.firstChild) { this.insertNodeAt(next.firstChild, container, "end"); } this.deleteNode(next); } }, /** * Moves child nodes from one element into another. * * @param {Elemet} from source element * @param {Elemet} to target element */ moveChildNodes: function(from, to) { if(this.tree.isDescendantOf(from, to) || ["HTML", "HEAD"].indexOf(to.nodeName) != -1) throw "Illegal argument. Cannot move children of element[" + from.nodeName + "] to element[" + to.nodeName + "]"; if(from == to) return; while(from.firstChild) to.appendChild(from.firstChild); }, /** * Copies attributes from one element into another. * * @param {Element} from source element * @param {Element} to target element * @param {boolean} copyId copy ID attribute of source element */ copyAttributes: function(from, to, copyId) { // IE overrides this var attrs = from.attributes; if(!attrs) return; for(var i = 0; i < attrs.length; i++) { if(attrs[i].nodeName == "class" && attrs[i].nodeValue) { to.className = attrs[i].nodeValue; } else if((copyId || "id" != attrs[i].nodeName) && attrs[i].nodeValue) { to.setAttribute(attrs[i].nodeName, attrs[i].nodeValue); } } }, _indentElements: function(node, blocks, affect) { for (var i=0; i < affect.length; i++) { if (affect[i] == node || this.tree.isDescendantOf(affect[i], node)) return; } leaves = this.tree.getLeavesAtEdge(node); if (blocks.include(leaves[0])) { var affected = this.indentElement(node, true); if (affected) { affect.push(affected); return; } } if (blocks.include(node)) { var affected = this.indentElement(node, true); if (affected) { affect.push(affected); return; } } var children=xq.$A(node.childNodes); for (var i=0; i < children.length; i++) this._indentElements(children[i], blocks, affect); return; }, indentElements: function(from, to) { var blocks = this.getBlockElementsBetween(from, to); var top = this.tree.findCommonAncestorAndImmediateChildrenOf(from, to); var affect = []; leaves = this.tree.getLeavesAtEdge(top.parent); if (blocks.include(leaves[0])) { var affected = this.indentElement(top.parent); if (affected) return [affected]; } var children = xq.$A(top.parent.childNodes); for (var i=0; i < children.length; i++) { this._indentElements(children[i], blocks, affect); } affect = affect.flatten() return affect.length > 0 ? affect : blocks; }, outdentElementsCode: function(node) { if (node.tagName == 'LI') node = node.parentNode; if (node.tagName == 'OL' && node.className == 'code') return true; return false; }, _outdentElements: function(node, blocks, affect) { for (var i=0; i < affect.length; i++) { if (affect[i] == node || this.tree.isDescendantOf(affect[i], node)) return; } leaves = this.tree.getLeavesAtEdge(node); if (blocks.include(leaves[0]) && !this.outdentElementsCode(leaves[0])) { var affected = this.outdentElement(node, true); if (affected) { affect.push(affected); return; } } if (blocks.include(node)) { var children = xq.$A(node.parentNode.childNodes); var isCode = this.outdentElementsCode(node); var affected = this.outdentElement(node, true, isCode); if (affected) { if (children.include(affected) && this.tree.isListContainer(node.parentNode) && !isCode) { for (var i=0; i < children.length; i++) { if (blocks.include(children[i]) && !affect.include(children[i])) affect.push(children[i]); } }else affect.push(affected); return; } } var children=xq.$A(node.childNodes); for (var i=0; i < children.length; i++) this._outdentElements(children[i], blocks, affect); return; }, outdentElements: function(from, to) { var start, end; if (from.parentNode.tagName == 'LI') start=from.parentNode; if (to.parentNode.tagName == 'LI') end=to.parentNode; var blocks = this.getBlockElementsBetween(from, to); var top = this.tree.findCommonAncestorAndImmediateChildrenOf(from, to); var affect = []; leaves = this.tree.getLeavesAtEdge(top.parent); if (blocks.include(leaves[0]) && !this.outdentElementsCode(top.parent)) { var affected = this.outdentElement(top.parent); if (affected) return [affected]; } var children = xq.$A(top.parent.childNodes); for (var i=0; i < children.length; i++) { this._outdentElements(children[i], blocks, affect); } if (from.offsetParent && to.offsetParent) { start = from; end = to; }else if (blocks.first().offsetParent && blocks.last().offsetParent) { start = blocks.first(); end = blocks.last(); } affect = affect.flatten() if (!start || !start.offsetParent) start = affect.first(); if (!end || !end.offsetParent) end = affect.last(); return this.getBlockElementsBetween(start, end); }, /** * Performs indent by increasing element's margin-left */ indentElement: function(element, noParent, forceMargin) { if( !forceMargin && (element.nodeName == "LI" || (!this.tree.isListContainer(element) && !element.previousSibling && element.parentNode.nodeName == "LI")) ) return this.indentListItem(element, noParent); var root = this.getRoot(); if(!element || element == root) return null; if (element.parentNode != root && !element.previousSibling && !noParent) element=element.parentNode; var margin = element.style.marginLeft; var cssValue = margin ? this._getCssValue(margin, "px") : {value:0, unit:"em"}; cssValue.value += 2; element.style.marginLeft = cssValue.value + cssValue.unit; return element; }, /** * Performs outdent by decreasing element's margin-left */ outdentElement: function(element, noParent, forceMargin) { if(!forceMargin && element.nodeName == "LI") return this.outdentListItem(element, noParent); var root = this.getRoot(); if(!element || element == root) return null; var margin = element.style.marginLeft; var cssValue = margin ? this._getCssValue(margin, "px") : {value:0, unit:"em"}; if(cssValue.value == 0) { return element.previousSibling || forceMargin ? null : this.outdentElement(element.parentNode, noParent); } cssValue.value -= 2; element.style.marginLeft = cssValue.value <= 0 ? "" : cssValue.value + cssValue.unit; if(element.style.cssText == "") element.removeAttribute("style"); return element; }, /** * Performs indent for list item */ indentListItem: function(element, treatListAsNormalBlock) { var li = this.getParentElementOf(element, ["LI"]); var container = li.parentNode; var prev = li.previousSibling; if(!li.previousSibling) return this.indentElement(container); if(li.parentNode.nodeName == "OL" && li.parentNode.className == "code") return this.indentElement(li, treatListAsNormalBlock, true); if(!prev.lastChild) prev.appendChild(this.makePlaceHolder()); var targetContainer = this.tree.isListContainer(prev.lastChild) ? // if there's existing list container, select it as target container prev.lastChild : // if there's nothing, create new one this.insertNodeAt(this.createElement(container.nodeName), prev, "end"); this.wrapAllInlineOrTextNodesAs("P", prev, true); // perform move targetContainer.appendChild(li); // flatten nested list if(!treatListAsNormalBlock && li.lastChild && this.tree.isListContainer(li.lastChild)) { var childrenContainer = li.lastChild; var child; while(child = childrenContainer.lastChild) { this.insertNodeAt(child, li, "after"); } this.deleteNode(childrenContainer); } this.unwrapUnnecessaryParagraph(li); return li; }, /** * Performs outdent for list item * * @return {Element} outdented list item or null if no outdent performed */ outdentListItem: function(element, treatListAsNormalBlock) { var li = this.getParentElementOf(element, ["LI"]); var container = li.parentNode; if(!li.previousSibling) { var performed = this.outdentElement(container); if(performed) return performed; } if(li.parentNode.nodeName == "OL" && li.parentNode.className == "code") return this.outdentElement(li, treatListAsNormalBlock, true); var parentLi = container.parentNode; if(parentLi.nodeName != "LI") return null; if(treatListAsNormalBlock) { while(container.lastChild != li) { this.insertNodeAt(container.lastChild, parentLi, "after"); } } else { // make next siblings as children if(li.nextSibling) { var targetContainer = li.lastChild && this.tree.isListContainer(li.lastChild) ? // if there's existing list container, select it as target container li.lastChild : // if there's nothing, create new one this.insertNodeAt(this.createElement(container.nodeName), li, "end"); this.copyAttributes(container, targetContainer); var sibling; while(sibling = li.nextSibling) { targetContainer.appendChild(sibling); } } } // move current LI into parent LI's next sibling li = this.insertNodeAt(li, parentLi, "after"); // remove empty container if(container.childNodes.length == 0) this.deleteNode(container); if(li.firstChild && this.tree.isListContainer(li.firstChild)) { this.insertNodeAt(this.makePlaceHolder(), li, "start"); } this.wrapAllInlineOrTextNodesAs("P", li); this.unwrapUnnecessaryParagraph(parentLi); return li; }, /** * Performs justification * * @param {Element} block target element * @param {String} dir one of "LEFT", "CENTER", "RIGHT", "BOTH" */ justifyBlock: function(block, dir) { // if block is only child, select its parent as mover while(block.parentNode != this.getRoot() && !block.previousSibling && !block.nextSibling && !this.tree.isListContainer(block.parentNode)) { block = block.parentNode; } var styleValue = dir.toLowerCase() == "both" ? "justify" : dir; if(styleValue == "left") { block.style.textAlign = ""; if(block.style.cssText == "") block.removeAttribute("style"); } else { block.style.textAlign = styleValue; } return block; }, justifyBlocks: function(blocks, dir) { for(var i = 0; i < blocks.length; i++) { this.justifyBlock(blocks[i], dir); } return blocks; }, /** * Turn given element into list. If the element is a list already, it will be reversed into normal element. * * @param {Element} element target element * @param {String} type one of "UL", "OL" * @returns {Element} affected element */ applyList: function(element, type) { type = type.toUpperCase(); var containerTag = type == "UL" ? "UL" : "OL"; if(element.nodeName == "LI" || (element.parentNode.nodeName == "LI" && !element.previousSibling)) { var element = this.getParentElementOf(element, ["LI"]); var container = element.parentNode; if(container.nodeName == containerTag) { return this.extractOutElementFromParent(element); } else { return this.changeListTypeTo(element, type); } } else { return this.turnElementIntoListItem(element, type); } }, applyLists: function(from, to, type) { type = type.toUpperCase(); var containerTag = type == "UL" ? "UL" : "OL"; var blocks = this.getBlockElementsBetween(from, to); // LIs or Non-containing blocks var whole = blocks.findAll(function(e) { return e.nodeName == "LI" || !this.tree.isBlockContainer(e); }.bind(this)); // LIs var listItems = whole.findAll(function(e) {return e.nodeName == "LI"}.bind(this)); // Non-containing blocks which is not a descendant of any LIs selected above(listItems). var normalBlocks = whole.findAll(function(e) { return e.nodeName != "LI" && !(e.parentNode.nodeName == "LI" && !e.previousSibling && !e.nextSibling) && !this.tree.isDescendantOf(listItems, e) }.bind(this)); var diffListItems = listItems.findAll(function(e) { return e.parentNode.nodeName != containerTag; }.bind(this)); // Conditions needed to determine mode var hasNormalBlocks = normalBlocks.length > 0; var hasDifferentListStyle = diffListItems.length > 0; var blockToHandle = null; if(hasNormalBlocks) { blockToHandle = normalBlocks; } else if(hasDifferentListStyle) { blockToHandle = diffListItems; } else { blockToHandle = listItems; } // perform operation for(var i = 0; i < blockToHandle.length; i++) { var block = blockToHandle[i]; // preserve original index to restore selection var originalIndex = blocks.indexOf(block); blocks[originalIndex] = this.applyList(block, type); } return blocks; }, /** * Insert place-holder for given empty element. Empty element does not displayed and causes many editing problems. * * @param {Element} element empty element */ correctEmptyElement: function(element) {throw "Not implemented"}, /** * Corrects current block-only-container to do not take any non-block element or node. */ correctParagraph: function() {throw "Not implemented"}, /** * Makes place-holder for empty element. * * @returns {Node} Platform specific place holder */ makePlaceHolder: function() {throw "Not implemented"}, /** * Makes place-holder string. * * @returns {String} Platform specific place holder string */ makePlaceHolderString: function() {throw "Not implemented"}, /** * Makes empty paragraph which contains only one place-holder */ makeEmptyParagraph: function() {throw "Not implemented"}, /** * Applies background color to selected area * * @param {Object} color valid CSS color value */ applyBackgroundColor: function(color) {throw "Not implemented";}, /** * Applies foreground color to selected area * * @param {Object} color valid CSS color value */ applyForegroundColor: function(color) { this.execCommand("forecolor", color); }, execCommand: function(commandId, param) {throw "Not implemented";}, applyRemoveFormat: function() {throw "Not implemented";}, applyEmphasis: function() {throw "Not implemented";}, applyStrongEmphasis: function() {throw "Not implemented";}, applyStrike: function() {throw "Not implemented";}, applyUnderline: function() {throw "Not implemented";}, applySuperscription: function() { this.execCommand("superscript"); }, applySubscription: function() { this.execCommand("subscript"); }, indentBlock: function(element, treatListAsNormalBlock) { return (!element.previousSibling && element.parentNode.nodeName == "LI") ? this.indentListItem(element, treatListAsNormalBlock) : this.indentElement(element); }, outdentBlock: function(element, treatListAsNormalBlock) { while(true) { if(!element.previousSibling && element.parentNode.nodeName == "LI") { element = this.outdentListItem(element, treatListAsNormalBlock); return element; } else { var performed = this.outdentElement(element); if(performed) return performed; // first-child can outdent container if(!element.previousSibling) { element = element.parentNode; } else { break; } } } return null; }, wrapBlock: function(tag, start, end) { if(this.tree._blockTags.indexOf(tag) == -1) throw "Unsuppored block container: [" + tag + "]"; if(!start) start = this.getCurrentBlockElement(); if(!end) end = start; // Check if the selection captures valid fragement var validFragment = false; if(start == end) { // are they same block? validFragment = true; } else if(start.parentNode == end.parentNode && !start.previousSibling && !end.nextSibling) { // are they covering whole parent? validFragment = true; start = end = start.parentNode; } else { // are they siblings of non-LI blocks? validFragment = (start.parentNode == end.parentNode) && (start.nodeName != "LI"); } if(!validFragment) return null; var wrapper = this.createElement(tag); if(start == end) { // They are same. if(this.tree.isBlockContainer(start) && !this.tree.isListContainer(start)) { // It's a block container. Wrap its contents. if(this.tree.isBlockOnlyContainer(wrapper)) { this.correctEmptyElement(start); this.wrapAllInlineOrTextNodesAs("P", start, true); } this.moveChildNodes(start, wrapper); start.appendChild(wrapper); } else { // It's not a block container. Wrap itself. wrapper = this.insertNodeAt(wrapper, start, "after"); wrapper.appendChild(start); } this.correctEmptyElement(wrapper); } else { // They are siblings. Wrap'em all. wrapper = this.insertNodeAt(wrapper, start, "before"); var node = start; while(node != end) { next = node.nextSibling; wrapper.appendChild(node); node = next; } wrapper.appendChild(node); } return wrapper; }, ///////////////////////////////////////////// // Focus/Caret/Selection /** * Gives focus to root element's window */ focus: function() {throw "Not implemented";}, /** * Returns selection object */ sel: function() {throw "Not implemented";}, /** * Returns range object */ rng: function() {throw "Not implemented";}, /** * Returns true if DOM has selection */ hasSelection: function() {throw "Not implemented";}, /** * Returns true if root element's window has selection */ hasFocus: function() { var cur = this.getCurrentElement(); return (cur && cur.ownerDocument == this.getDoc()); }, /** * Adjust scrollbar to make the element visible in current viewport. * * @param {Element} element Target element * @param {boolean} toTop Align element to top of the viewport * @param {boolean} moveCaret Move caret to the element */ scrollIntoView: function(element, toTop, moveCaret) { element.scrollIntoView(toTop); if(moveCaret) this.placeCaretAtStartOf(element); }, /** * Select all document */ selectAll: function() { return this.execCommand('selectall'); }, /** * Select specified element. * * @param {Element} element element to select * @param {boolean} entireElement true to select entire element, false to select inner content of element */ selectElement: function(node, entireElement) {throw "Not implemented"}, /** * Select all elements between two blocks(inclusive). * * @param {Element} start start of selection * @param {Element} end end of selection */ selectBlocksBetween: function(start, end) {throw "Not implemented"}, /** * Delete selected area */ deleteSelection: function() {throw "Not implemented"}, /** * Collapses current selection. * * @param {boolean} toStart true to move caret to start of selected area. */ collapseSelection: function(toStart) {throw "Not implemented"}, /** * Returns selected area as HTML string */ getSelectionAsHtml: function() {throw "Not implemented"}, /** * Returns selected area as text string */ getSelectionAsText: function() {throw "Not implemented"}, /** * Places caret at start of the element * * @param {Element} element Target element */ placeCaretAtStartOf: function(element) {throw "Not implemented"}, /** * Checks if the node is empty-text-node or not */ isEmptyTextNode: function(node) { return node.nodeType == 3 && node.nodeValue.length == 0; }, /** * Checks if the caret is place in empty block element */ isCaretAtEmptyBlock: function() { return this.isEmptyBlock(this.getCurrentBlockElement()); }, /** * Checks if the caret is place at start of the block */ isCaretAtBlockStart: function() {throw "Not implemented"}, /** * Checks if the caret is place at end of the block */ isCaretAtBlockEnd: function() {throw "Not implemented"}, /** * Saves current selection info * * @returns {Object} Bookmark for selection */ saveSelection: function() {throw "Not implemented"}, /** * Restores current selection info * * @param {Object} bookmark Bookmark */ restoreSelection: function(bookmark) {throw "Not implemented"}, /** * Create marker */ createMarker: function() { var marker = this.createElement("SPAN"); marker.id = "xquared_marker_" + (this._lastMarkerId++); marker.className = "xquared_marker"; return marker; }, /** * Create and insert marker into current caret position. * Marker is an inline element which has no child nodes. It can be used with many purposes. * For example, You can push marker to mark current caret position. * * @returns {Element} marker */ pushMarker: function() { var marker = this.createMarker(); return this.insertNode(marker); }, /** * Removes last marker * * @params {boolean} moveCaret move caret into marker before delete. */ popMarker: function(moveCaret) { var id = "xquared_marker_" + (--this._lastMarkerId); var marker = this.$(id); if(!marker) return; if(moveCaret) { this.selectElement(marker, true); this.collapseSelection(false); } this.deleteNode(marker); }, ///////////////////////////////////////////// // Query methods isMarker: function(node) { return (node.nodeType == 1 && node.nodeName == "SPAN" && node.className == "xquared_marker"); }, isFirstBlockOfBody: function(block) { var root = this.getRoot(); var found = this.tree.findBackward( block, function(node) {return (node == root) || node.previousSibling;}.bind(this) ); return found == root; }, /** * Returns outer HTML of given element */ getOuterHTML: function(element) {throw "Not implemented"}, /** * Returns inner text of given element * * @param {Element} element Target element * @returns {String} Text string */ getInnerText: function(element) { return element.innerHTML.stripTags(); }, /** * Checks if given node is place holder or not. * * @param {Node} node DOM node */ isPlaceHolder: function(node) {throw "Not implemented"}, /** * Checks if given block is the first LI whose next sibling is a nested list. * * @param {Element} block Target block */ isFirstLiWithNestedList: function(block) { return !block.previousSibling && block.parentNode.nodeName == "LI" && this.tree.isListContainer(block.nextSibling); }, /** * Search all links within given element * * @param {Element} [element] Container element. If not given, the root element will be used. * @param {Array} [found] if passed, links will be appended into this array. * @returns {Array} Array of anchors. It returns empty array if there's no links. */ searchAnchors: function(element, found) { if(!element) element = this.getRoot(); if(!found) found = []; var anchors = element.getElementsByTagName("A"); for(var i = 0; i < anchors.length; i++) { found.push(anchors[i]); } return found; }, /** * Search all headings within given element * * @param {Element} [element] Container element. If not given, the root element will be used. * @param {Array} [found] if passed, headings will be appended into this array. * @returns {Array} Array of headings. It returns empty array if there's no headings. */ searchHeadings: function(element, found) { if(!element) element = this.getRoot(); if(!found) found = []; var regexp = /^h[1-6]/ig; var nodes = element.childNodes; if (!nodes) return []; for(var i = 0; i < nodes.length; i++) { var isContainer = nodes[i] && this.tree._blockContainerTags.indexOf(nodes[i].nodeName) != -1; var isHeading = nodes[i] && nodes[i].nodeName.match(regexp); if (isContainer) { this.searchHeadings(nodes[i], found); } else if (isHeading) { found.push(nodes[i]); } } return found; }, /** * Collect structure and style informations of given element. * * @param {Element} element target element * @returns {Object} object that contains information: {em: true, strong: false, block: "p", list: "ol", ...} */ collectStructureAndStyle: function(element) { if(!element || element.nodeName == "#document") return {}; var block = this.getParentBlockElementOf(element); // IE���� ��Ȥ DOM�� �� ��: element�� ���ڷ� �Ѿ�4?��찡�? if(block == null) return {}; var parents = this.tree.collectParentsOf(element, true, function(node) {return block.parentNode == node}); var blockName = block.nodeName; var info = {}; var doc = this.getDoc(); var em = doc.queryCommandState("Italic"); var strong = doc.queryCommandState("Bold"); var strike = doc.queryCommandState("Strikethrough"); var underline = doc.queryCommandState("Underline") && !this.getParentElementOf(element, ["A"]); var superscription = doc.queryCommandState("superscript"); var subscription = doc.queryCommandState("subscript"); // if block is only child, select its parent while(block.parentNode && block.parentNode != this.getRoot() && !block.previousSibling && !block.nextSibling && !this.tree.isListContainer(block.parentNode)) { block = block.parentNode; } var list = false; if(block.nodeName == "LI") { var parent = block.parentNode; var isCode = parent.nodeName == "OL" && parent.className == "code"; list = isCode ? "CODE" : parent.nodeName; } var justification = block.style.textAlign || "left"; return { block:blockName, em: em, strong: strong, strike: strike, underline: underline, superscription: superscription, subscription: subscription, list: list, justification: justification }; }, /** * Checks if the element has one or more important attributes: id, class, style * * @param {Element} element Target element */ hasImportantAttributes: function(element) {throw "Not implemented"}, /** * Checks if the element is empty or not. Place-holder is not counted as a child. * * @param {Element} element Target element */ isEmptyBlock: function(element) {throw "Not implemented"}, /** * Returns element that contains caret. */ getCurrentElement: function() {throw "Not implemented"}, /** * Returns block element that contains caret. */ getCurrentBlockElement: function() { var cur = this.getCurrentElement(); if(!cur) return null; var block = this.getParentBlockElementOf(cur); if(!block) return null; return (block.nodeName == "BODY") ? null : block; }, /** * Returns parent block element of parameter. * If the parameter itself is a block, it will be returned. * * @param {Element} element Target element * * @returns {Element} Element or null */ getParentBlockElementOf: function(element) { while(element) { if(this.tree._blockTags.indexOf(element.nodeName) != -1) return element; element = element.parentNode; } return null; }, /** * Returns parent element of parameter which has one of given tag name. * If the parameter itself has the same tag name, it will be returned. * * @param {Element} element Target element * @param {Array} tagNames Array of string which contains tag names * * @returns {Element} Element or null */ getParentElementOf: function(element, tagNames) { while(element) { if(tagNames.indexOf(element.nodeName) != -1) return element; element = element.parentNode; } return null; }, /** * Collects all block elements between two elements * * @param {Element} from Start element(inclusive) * @param {Element} to End element(inclusive) */ getBlockElementsBetween: function(from, to) { return this.tree.collectNodesBetween(from, to, function(node) { return node.nodeType == 1 && this.tree.isBlock(node); }.bind(this)); }, /** * Returns block element that contains selection start. * * This method will return exactly same result with getCurrentBlockElement method * when there's no selection. */ getBlockElementAtSelectionStart: function() {throw "Not implemented"}, /** * Returns block element that contains selection end. * * This method will return exactly same result with getCurrentBlockElement method * when there's no selection. */ getBlockElementAtSelectionEnd: function() {throw "Not implemented"}, /** * Returns blocks at each edge of selection(start and end). * * TODO: implement ignoreEmptyEdges for FF * * @param {boolean} naturalOrder Mak the start element always comes before the end element * @param {boolean} ignoreEmptyEdges Prevent some browser(Gecko) from selecting one more block than expected */ getBlockElementsAtSelectionEdge: function(naturalOrder, ignoreEmptyEdges) {throw "Not implemented"}, /** * Returns array of selected block elements */ getSelectedBlockElements: function() { var selectionEdges = this.getBlockElementsAtSelectionEdge(true, true); var start = selectionEdges[0]; var end = selectionEdges[1]; return this.tree.collectNodesBetween(start, end, function(node) { return node.nodeType == 1 && this.tree.isBlock(node); }.bind(this)); }, /** * Get element by ID * * @param {String} id Element's ID * @returns {Element} element or null */ getElementById: function(id) {return this.doc.getElementById(id)}, /** * Shortcut for #getElementById */ $: function(id) {return this.getElementById(id)}, /** * Returns first "valid" child of given element. It ignores empty textnodes. * * @param {Element} element Target element * @returns {Node} first child node or null */ getFirstChild: function(element) { if(!element) return null; var nodes = xq.$A(element.childNodes); return nodes.find(function(node) {return !this.isEmptyTextNode(node)}.bind(this)); }, /** * Returns last "valid" child of given element. It ignores empty textnodes and place-holders. * * @param {Element} element Target element * @returns {Node} last child node or null */ getLastChild: function(element) {throw "Not implemented"}, getNextSibling: function(node) { while(node = node.nextSibling) { if(node.nodeType != 3 || node.nodeValue.strip() != "") break; } return node; }, getBottommostFirstChild: function(node) { while(node.firstChild && node.nodeType == 1) node = node.firstChild; return node; }, getBottommostLastChild: function(node) { while(node.lastChild && node.nodeType == 1) node = node.lastChild; return node; }, /** @private */ _getCssValue: function(str, defaultUnit) { if(!str || str.length == 0) return {value:0, unit:defaultUnit}; var tokens = str.match(/(\d+)(.*)/); return { value:parseInt(tokens[1]), unit:tokens[2] || defaultUnit }; } }); /** * Creates and returns instance of browser specific implementation. */ xq.RichDom.createInstance = function() { if(xq.Browser.isTrident) { return new xq.RichDomTrident(); } else if(xq.Browser.isWebkit) { return new xq.RichDomWebkit(); } else { return new xq.RichDomGecko(); } } /** * RichDom for W3C Standard Engine */ xq.RichDomW3 = xq.Class(xq.RichDom, { insertNode: function(node) { var rng = this.rng(); rng.insertNode(node); rng.selectNode(node); rng.collapse(false); return node; }, removeTrailingWhitespace: function(block) { // TODO: do nothing }, getOuterHTML: function(element) { var div = element.ownerDocument.createElement("div"); div.appendChild(element.cloneNode(true)); return div.innerHTML; }, correctEmptyElement: function(element) { if(!element || element.nodeType != 1 || this.tree.isAtomic(element)) return; if(element.firstChild) this.correctEmptyElement(element.firstChild); else element.appendChild(this.makePlaceHolder()); }, correctParagraph: function() { if(this.hasSelection()) return false; var block = this.getCurrentElement(); var modified = false; if(this.tree.isBlockOnlyContainer(block)) { this.execCommand("InsertParagraph"); // check for atomic block element such as HR var newBlock = this.getCurrentElement(); if(this.tree.isAtomic(newBlock.previousSibling)) { var nextBlock = this.tree.findForward( newBlock, function(node) {return this.tree.isBlock(node) && !this.tree.isBlockOnlyContainer(node)}.bind(this) ); if(nextBlock) { this.deleteNode(newBlock); this.placeCaretAtStartOf(nextBlock); } } modified = true; } else if(this.tree.hasMixedContents(block)) { this.wrapAllInlineOrTextNodesAs("P", block, true); modified = true; } block = this.getCurrentElement(); if(this.tree.isBlock(block) && !this._hasPlaceHolderAtEnd(block)) { block.appendChild(this.makePlaceHolder()); modified = true; } if(this.tree.isBlock(block)) { var parentsLastChild = block.parentNode.lastChild; if(this.isPlaceHolder(parentsLastChild)) { this.deleteNode(parentsLastChild); modified = true; } } return modified; }, _hasPlaceHolderAtEnd: function(block) { if(!block.hasChildNodes()) return false; return this.isPlaceHolder(block.lastChild) || this._hasPlaceHolderAtEnd(block.lastChild); }, applyBackgroundColor: function(color) { this.execCommand("styleWithCSS", "true"); this.execCommand("hilitecolor", color); this.execCommand("styleWithCSS", "false"); // 0. Save current selection var bookmark = this.saveSelection(); // 1. Get selected blocks var blocks = this.getSelectedBlockElements(); if(blocks.length == 0) return; // 2. Apply background-color to all adjust inline elements // 3. Remove background-color from blocks for(var i = 0; i < blocks.length; i++) { if((i == 0 || i == blocks.length-1) && !blocks[i].style.backgroundColor) continue; var spans = this.wrapAllInlineOrTextNodesAs("SPAN", blocks[i], true); for(var j = 0; j < spans.length; j++) { spans[j].style.backgroundColor = color; } blocks[i].style.backgroundColor = ""; } // 4. Restore selection this.restoreSelection(bookmark); }, ////// // Commands execCommand: function(commandId, param) { return this.doc.execCommand(commandId, false, param || null); }, saveSelection: function() { var rng = this.rng(); return [rng.startContainer, rng.startOffset, rng.endContainer, rng.endOffset]; }, restoreSelection: function(bookmark) { var rng = this.rng(); rng.setStart(bookmark[0], bookmark[1]); rng.setEnd(bookmark[2], bookmark[3]); }, applyRemoveFormat: function() { this.execCommand("RemoveFormat"); this.execCommand("Unlink"); }, applyEmphasis: function() { // Generate tag. It will be replaced with