/** * SCEditor XHTML Plugin * http://www.sceditor.com/ * * Copyright (C) 2011-2013, Sam Clarke (samclarke.com) * * SCEditor is licensed under the MIT license: * http://www.opensource.org/licenses/mit-license.php * * @author Sam Clarke * @requires jQuery */ /*global prompt: true*/ (function ($) { 'use strict'; var SCEditor = $.sceditor; var sceditorPlugins = SCEditor.plugins; var dom = SCEditor.dom; var defaultCommandsOverrides = { bold: { txtExec: [ '', '' ] }, italic: { txtExec: [ '', '' ] }, underline: { txtExec: [ '', '' ] }, strike: { txtExec: [ '', '' ] }, subscript: { txtExec: [ '', '' ] }, superscript: { txtExec: [ '', '' ] }, left: { txtExec: [ '
', '
' ] }, center: { txtExec: [ '
', '
' ] }, right: { txtExec: [ '
', '
' ] }, justify: { txtExec: [ '
', '
' ] }, font: { txtExec: function (caller) { var editor = this; SCEditor.command.get('font')._dropDown( editor, caller, function (fontName) { editor.insertText('', ''); } ); } }, size: { txtExec: function (caller) { var editor = this; SCEditor.command.get('size')._dropDown( editor, caller, function (fontSize) { editor.insertText('', ''); } ); } }, color: { txtExec: function (caller) { var editor = this; SCEditor.command.get('color')._dropDown( editor, caller, function (color) { editor.insertText('', ''); } ); } }, bulletlist: { txtExec: [ '' ] }, orderedlist: { txtExec: [ '
  1. ', '
' ] }, table: { txtExec: [ '
', '
' ] }, horizontalrule: { txtExec: [ '
' ] }, code: { txtExec: [ '', '' ] }, image: { txtExec: function (caller, selected) { var url = prompt(this._('Enter the image URL:'), selected); if (url) { this.insertText(''); } } }, email: { txtExec: function (caller, sel) { var email, text, display = sel && sel.indexOf('@') > -1 ? null : sel; email = prompt( this._('Enter the e-mail address:'), (display ? '' : sel) ); text = prompt( this._('Enter the displayed text:'), display || email ) || email; if (email) { this.insertText( '' + text + '' ); } } }, link: { txtExec: function (caller, sel) { var display = sel && sel.indexOf('http://') > -1 ? null : sel, url = prompt(this._('Enter URL:'), (display ? 'http://' : sel)), text = prompt(this._('Enter the displayed text:'), display || url) || url; if (url) { this.insertText( '' + text + '' ); } } }, quote: { txtExec: [ '
', '
' ] }, youtube: { txtExec: function (caller) { var editor = this; SCEditor.command.get('youtube')._dropDown( editor, caller, function (id) { editor.insertText( '' ); } ); } }, rtl: { txtExec: [ '
', '
' ] }, ltr: { txtExec: [ '
', '
' ] } }; /** * XHTMLSerializer part of the XHTML plugin. * * @class XHTMLSerializer * @name jQuery.sceditor.XHTMLSerializer * @since v1.4.1 */ SCEditor.XHTMLSerializer = function () { var base = this; var opts = { indentStr: '\t' }; /** * Array containing the output, used as it's faster * than string concatenation in slow browsers. * @type {Array} * @private */ var outputStringBuilder = []; /** * Current indention level * @type {Number} * @private */ var currentIndent = 0; /** * @private */ var escapeEntites, trim, serializeNode, handleDoc, handleElement, handleCdata, handleComment, handleText, output, canIndent; // TODO: use escape.entities /** * Escapes XHTML entities * * @param {String} str * @return {String} * @private */ escapeEntites = function (str) { var entites = { '&': '&', '<': '<', '>': '>', '"': '"' }; return !str ? '' : str.replace(/[&<>"]/g, function (entity) { return entites[entity] || entity; }); }; /** * @param {string} str * @return {string} * @private */ trim = function (str) { return str // New lines will be shown as spaces so just convert to spaces. .replace(/[\r\n]/, ' ') .replace(/[^\S|\u00A0]+/g, ' '); }; /** * Serializes a node to XHTML * * @param {Node} node Node to serialize * @param {Boolean} onlyChildren If to only serialize the nodes * children and not the node * itself * @return {String} The serialized node * @name serialize * @memberOf jQuery.sceditor.XHTMLSerializer.prototype * @since v1.4.1 */ base.serialize = function (node, onlyChildren) { outputStringBuilder = []; if (onlyChildren) { node = node.firstChild; while (node) { serializeNode(node); node = node.nextSibling; } } else { serializeNode(node); } return outputStringBuilder.join(''); }; /** * Serializes a node to the outputStringBuilder * * @param {Node} node * @return {Void} * @private */ serializeNode = function (node, parentIsPre) { switch (node.nodeType) { case 1: // element var tagName = node.nodeName.toLowerCase(); // IE comment if (tagName === '!') { handleComment(node); } else { handleElement(node, parentIsPre); } break; case 3: // text handleText(node, parentIsPre); break; case 4: // cdata section handleCdata(node); break; case 8: // comment handleComment(node); break; case 9: // document case 11: // document fragment handleDoc(node); break; // Ignored types case 2: // attribute case 5: // entity ref case 6: // entity case 7: // processing instruction case 10: // document type case 12: // notation break; } }; /** * Handles doc node * @param {Node} node * @return {void} * @private */ handleDoc = function (node) { var child = node.firstChild; while (child) { serializeNode(child); child = child.nextSibling; } }; /** * Handles element nodes * @param {Node} node * @return {void} * @private */ handleElement = function (node, parentIsPre) { var child, attr, attrValue, tagName = node.nodeName.toLowerCase(), isIframe = tagName === 'iframe', attrIdx = node.attributes.length, firstChild = node.firstChild, // pre || pre-wrap with any vendor prefix isPre = parentIsPre || /pre(?:\-wrap)?$/i.test($(node).css('whiteSpace')), selfClosing = !node.firstChild && !dom.canHaveChildren(node) && !isIframe; if ($(node).hasClass('sceditor-ignore')) { return; } output('<' + tagName, !parentIsPre && canIndent(node)); while (attrIdx--) { attr = node.attributes[attrIdx]; // IE < 8 returns all possible attributes not just specified // ones. IE < 8 also doesn't say value on input is specified // so just assume it is. if (!SCEditor.ie || attr.specified || (tagName === 'input' && attr.name === 'value')) { // IE < 8 doesn't return the CSS for the style attribute if (SCEditor.ie < 8 && /style/i.test(attr.name)) { attrValue = node.style.cssText; } else { attrValue = attr.value; } output(' ' + attr.name.toLowerCase() + '="' + escapeEntites(attrValue) + '"', false); } } output(selfClosing ? ' />' : '>', false); if (!isIframe) { child = firstChild; } while (child) { currentIndent++; serializeNode(child, isPre); child = child.nextSibling; currentIndent--; } if (!selfClosing) { output( '', !isPre && !isIframe && canIndent(node) && firstChild && canIndent(firstChild) ); } }; /** * Handles CDATA nodes * @param {Node} node * @return {void} * @private */ handleCdata = function (node) { output(''); }; /** * Handles comment nodes * @param {Node} node * @return {void} * @private */ handleComment = function (node) { output(''); }; /** * Handles text nodes * @param {Node} node * @return {void} * @private */ handleText = function (node, parentIsPre) { var text = node.nodeValue; if (!parentIsPre) { text = trim(text); } if (text) { output(escapeEntites(text), !parentIsPre && canIndent(node)); } }; /** * Adds a string to the outputStringBuilder. * * The string will be indented unless indent is set to boolean false. * @param {String} str * @param {Boolean} indent * @return {void} * @private */ output = function (str, indent) { var i = currentIndent; if (indent !== false) { // Don't add a new line if it's the first element if (outputStringBuilder.length) { outputStringBuilder.push('\n'); } while (i--) { outputStringBuilder.push(opts.indentStr); } } outputStringBuilder.push(str); }; /** * Checks if should indent the node or not * @param {Node} node * @return {boolean} * @private */ canIndent = function (node) { var prev = node.previousSibling; if (node.nodeType !== 1 && prev) { return !dom.isInline(prev); } // first child of a block element if (!prev && !dom.isInline(node.parentNode)) { return true; } return !dom.isInline(node); }; }; /** * SCEditor XHTML plugin * @class xhtml * @name jQuery.sceditor.plugins.xhtml * @since v1.4.1 */ sceditorPlugins.xhtml = function () { var base = this; /** * Tag converstions cache * @type {Object} * @private */ var tagConvertersCache = {}; /** * Attributes filter cache * @type {Object} * @private */ var attrsCache = {}; /** * Private methods * @private */ var convertTags, convertNode, isEmpty, removeTags, mergeAttribsFilters, removeAttribs, wrapInlines; /** * Init * @return {void} */ base.init = function () { if (!$.isEmptyObject(sceditorPlugins.xhtml.converters || {})) { $.each( sceditorPlugins.xhtml.converters, function (idx, converter) { $.each(converter.tags, function (tagname) { if (!tagConvertersCache[tagname]) { tagConvertersCache[tagname] = []; } tagConvertersCache[tagname].push(converter); }); } ); } this.commands = $.extend(true, {}, defaultCommandsOverrides, this.commands); }; /** * Converts the WYSIWYG content to XHTML * @param {String} html * @param {Node} domBody * @return {String} * @memberOf jQuery.sceditor.plugins.xhtml.prototype */ base.signalToSource = function (html, domBody) { domBody = domBody.jquery ? domBody[0] : domBody; convertTags(domBody); removeTags(domBody); removeAttribs(domBody); wrapInlines(domBody); return (new SCEditor.XHTMLSerializer()).serialize(domBody, true); }; /** * Converts the XHTML to WYSIWYG content. * * This doesn't currently do anything as XHTML * is valid WYSIWYG content. * @param {String} text * @return {String} * @memberOf jQuery.sceditor.plugins.xhtml.prototype */ base.signalToWysiwyg = function (text) { return text; }; /** * Deprecated, use dom.convertElement() instead. * @deprecated */ base.convertTagTo = dom.convertElement; /** * Runs all converters for the specified tagName * against the DOM node. * @param {String} tagName * @param {jQuery} $node * @return {Node} node * @private */ convertNode = function (tagName, $node, node) { if (!tagConvertersCache[tagName]) { return; } $.each(tagConvertersCache[tagName], function (idx, converter) { if (converter.tags[tagName]) { $.each(converter.tags[tagName], function (attr, values) { if (!node.getAttributeNode) { return; } attr = node.getAttributeNode(attr); // IE < 8 always returns an attribute regardless of if // it has been specified so must check it. if (!attr || (SCEditor.ie < 8 && !attr.specified)) { return; } if (values && $.inArray(attr.value, values) < 0) { return; } converter.conv.call(base, node, $node); }); } else if (converter.conv) { converter.conv.call(base, node, $node); } }); }; /** * Converts any tags/attributes to their XHTML equivalents * @param {Node} node * @return {Void} * @private */ convertTags = function (node) { dom.traverse(node, function (node) { var $node = $(node), tagName = node.nodeName.toLowerCase(); convertNode('*', $node, node); convertNode(tagName, $node, node); }, true); }; /** * Tests if a node is empty and can be removed. * * @param {Node} node * @return {Boolean} * @private */ isEmpty = function (node, excludeBr) { var childNodes = node.childNodes, tagName = node.nodeName.toLowerCase(), nodeValue = node.nodeValue, childrenLength = childNodes.length; if (excludeBr && tagName === 'br') { return true; } if ($(node).hasClass('sceditor-ignore')) { return true; } if (!dom.canHaveChildren(node)) { return false; } // \S|\u00A0 = any non space char if (nodeValue && /\S|\u00A0/.test(nodeValue)) { return false; } while (childrenLength--) { if (!isEmpty(childNodes[childrenLength], excludeBr && !node.previousSibling && !node.nextSibling)) { return false; } } return true; }; /** * Removes any tags that are not white listed or if no * tags are white listed it will remove any tags that * are black listed. * * @param {Node} rootNode * @return {Void} * @private */ removeTags = function (rootNode) { dom.traverse(rootNode, function (node) { var remove, tagName = node.nodeName.toLowerCase(), parentNode = node.parentNode, nodeType = node.nodeType, isBlock = !dom.isInline(node), previousSibling = node.previousSibling, nextSibling = node.nextSibling, isTopLevel = parentNode === rootNode, noSiblings = !previousSibling && !nextSibling, empty = tagName !== 'iframe' && isEmpty(node, isTopLevel && noSiblings && tagName !== 'br'), document = node.ownerDocument, allowedtags = sceditorPlugins.xhtml.allowedTags, disallowedTags = sceditorPlugins.xhtml.disallowedTags; // 3 = text node if (nodeType === 3) { return; } if (nodeType === 4) { tagName = '!cdata'; } else if (tagName === '!' || nodeType === 8) { tagName = '!comment'; } if (empty) { remove = true; // 3 is text node which do not get filtered } else if (allowedtags && allowedtags.length) { remove = ($.inArray(tagName, allowedtags) < 0); } else if (disallowedTags && disallowedTags.length) { remove = ($.inArray(tagName, disallowedTags) > -1); } if (remove) { if (!empty) { if (isBlock && previousSibling && dom.isInline(previousSibling)) { parentNode.insertBefore( document.createTextNode(' '), node); } // Insert all the childen after node while (node.firstChild) { parentNode.insertBefore(node.firstChild, nextSibling); } if (isBlock && nextSibling && dom.isInline(nextSibling)) { parentNode.insertBefore( document.createTextNode(' '), nextSibling); } } parentNode.removeChild(node); } }, true); }; /** * Merges two sets of attribute filters into one * * @param {Object} filtersA * @param {Object} filtersB * @return {Object} * @private */ mergeAttribsFilters = function (filtersA, filtersB) { var ret = {}; if (filtersA) { $.extend(ret, filtersA); } if (!filtersB) { return ret; } $.each(filtersB, function (attrName, values) { if ($.isArray(values)) { ret[attrName] = $.merge(ret[attrName] || [], values); } else if (!ret[attrName]) { ret[attrName] = null; } }); return ret; }; /** * Wraps adjacent inline child nodes of root * in paragraphs. * * @param {Node} root * @private */ wrapInlines = function (root) { var adjacentInlines = []; var wrapAdjacents = function () { if (adjacentInlines.length) { $('

', root.ownerDocument) .insertBefore(adjacentInlines[0]) .append(adjacentInlines); adjacentInlines = []; } }; // Strip empty text nodes so they don't get wrapped. dom.removeWhiteSpace(root); var node = root.firstChild; while (node) { if (dom.isInline(node) && !$(node).is('.sceditor-ignore')) { adjacentInlines.push(node); } else { wrapAdjacents(); } node = node.nextSibling; } wrapAdjacents(); }; /** * Removes any attributes that are not white listed or * if no attributes are white listed it will remove * any attributes that are black listed. * @param {Node} node * @return {Void} * @private */ removeAttribs = function (node) { var tagName, attr, attrName, attrsLength, validValues, remove, allowedAttribs = sceditorPlugins.xhtml.allowedAttribs, isAllowed = allowedAttribs && !$.isEmptyObject(allowedAttribs), disallowedAttribs = sceditorPlugins.xhtml.disallowedAttribs, isDisallowed = disallowedAttribs && !$.isEmptyObject(disallowedAttribs); attrsCache = {}; dom.traverse(node, function (node) { if (!node.attributes) { return; } tagName = node.nodeName.toLowerCase(); attrsLength = node.attributes.length; if (attrsLength) { if (!attrsCache[tagName]) { if (isAllowed) { attrsCache[tagName] = mergeAttribsFilters( allowedAttribs['*'], allowedAttribs[tagName] ); } else { attrsCache[tagName] = mergeAttribsFilters( disallowedAttribs['*'], disallowedAttribs[tagName] ); } } while (attrsLength--) { attr = node.attributes[attrsLength]; attrName = attr.name; validValues = attrsCache[tagName][attrName]; remove = false; if (isAllowed) { remove = validValues !== null && (!$.isArray(validValues) || $.inArray(attr.value, validValues) < 0); } else if (isDisallowed) { remove = validValues === null || ($.isArray(validValues) && $.inArray(attr.value, validValues) > -1); } if (remove) { node.removeAttribute(attrName); } } } }); }; }; /** * Tag conveters, a converter is applied to all * tags that match the criteria. * @type {Array} * @name jQuery.sceditor.plugins.xhtml.converters * @since v1.4.1 */ sceditorPlugins.xhtml.converters = [ { tags: { '*': { width: null } }, conv: function (node, $node) { $node.css('width', $node.attr('width')).removeAttr('width'); } }, { tags: { '*': { height: null } }, conv: function (node, $node) { $node.css('height', $node.attr('height')).removeAttr('height'); } }, { tags: { 'li': { value: null } }, conv: function (node, $node) { if (SCEditor.ie < 8) { node.removeAttribute('value'); } else { $node.removeAttr('value'); } } }, { tags: { '*': { text: null } }, conv: function (node, $node) { $node.css('color', $node.attr('text')).removeAttr('text'); } }, { tags: { '*': { color: null } }, conv: function (node, $node) { $node.css('color', $node.attr('color')).removeAttr('color'); } }, { tags: { '*': { face: null } }, conv: function (node, $node) { $node.css('fontFamily', $node.attr('face')).removeAttr('face'); } }, { tags: { '*': { align: null } }, conv: function (node, $node) { $node.css('textAlign', $node.attr('align')).removeAttr('align'); } }, { tags: { '*': { border: null } }, conv: function (node, $node) { $node .css('borderWidth',$node.attr('border')) .removeAttr('border'); } }, { tags: { applet: { name: null }, img: { name: null }, layer: { name: null }, map: { name: null }, object: { name: null }, param: { name: null } }, conv: function (node, $node) { if (!$node.attr('id')) { $node.attr('id', $node.attr('name')); } $node.removeAttr('name'); } }, { tags: { '*': { vspace: null } }, conv: function (node, $node) { $node .css('marginTop', $node.attr('vspace') - 0) .css('marginBottom', $node.attr('vspace') - 0) .removeAttr('vspace'); } }, { tags: { '*': { hspace: null } }, conv: function (node, $node) { $node .css('marginLeft', $node.attr('hspace') - 0) .css('marginRight', $node.attr('hspace') - 0) .removeAttr('hspace'); } }, { tags: { 'hr': { noshade: null } }, conv: function (node, $node) { $node.css('borderStyle', 'solid').removeAttr('noshade'); } }, { tags: { '*': { nowrap: null } }, conv: function (node, $node) { $node.css('white-space', 'nowrap').removeAttr('nowrap'); } }, { tags: { big: null }, conv: function (node) { $(this.convertTagTo(node, 'span')).css('fontSize', 'larger'); } }, { tags: { small: null }, conv: function (node) { $(this.convertTagTo(node, 'span')).css('fontSize', 'smaller'); } }, { tags: { b: null }, conv: function (node) { $(this.convertTagTo(node, 'strong')); } }, { tags: { u: null }, conv: function (node) { $(this.convertTagTo(node, 'span')) .css('textDecoration', 'underline'); } }, { tags: { i: null }, conv: function (node) { $(this.convertTagTo(node, 'em')); } }, { tags: { s: null, strike: null }, conv: function (node) { $(this.convertTagTo(node, 'span')) .css('textDecoration', 'line-through'); } }, { tags: { dir: null }, conv: function (node) { this.convertTagTo(node, 'ul'); } }, { tags: { center: null }, conv: function (node) { $(this.convertTagTo(node, 'div')) .css('textAlign', 'center'); } }, { tags: { font: { size: null } }, conv: function (node, $node) { var size = $node.css('fontSize'), fontSize = size; // IE < 8 sets a font tag with no size to +0 so // should just skip it. if (fontSize !== '+0') { // IE 8 and below incorrectly returns the value of the size // attribute instead of the px value so must convert it if (SCEditor.ie < 9) { fontSize = 10; if (size > 1) { fontSize = 13; } if (size > 2) { fontSize = 16; } if (size > 3) { fontSize = 18; } if (size > 4) { fontSize = 24; } if (size > 5) { fontSize = 32; } if (size > 6) { fontSize = 48; } } $node.css('fontSize', fontSize); } $node.removeAttr('size'); } }, { tags: { font: null }, conv: function (node) { // All it's attributes will be converted // by the attribute converters this.convertTagTo(node, 'span'); } }, { tags: { '*': { type: ['_moz'] } }, conv: function (node, $node) { $node.removeAttr('type'); } }, { tags: { '*': { '_moz_dirty': null } }, conv: function (node, $node) { $node.removeAttr('_moz_dirty'); } }, { tags: { '*': { '_moz_editor_bogus_node': null } }, conv: function (node, $node) { $node.remove(); } } ]; /** * Allowed attributes map. * * To allow an attribute for all tags use * as the tag name. * * Leave empty or null to allow all attributes. (the disallow * list will be used to filter them instead) * @type {Object} * @name jQuery.sceditor.plugins.xhtml.allowedAttribs * @since v1.4.1 */ sceditorPlugins.xhtml.allowedAttribs = {}; /** * Attributes that are not allowed. * * Only used if allowed attributes is null or empty. * @type {Object} * @name jQuery.sceditor.plugins.xhtml.disallowedAttribs * @since v1.4.1 */ sceditorPlugins.xhtml.disallowedAttribs = {}; /** * Array containing all the allowed tags. * * If null or empty all tags will be allowed. * @type {Array} * @name jQuery.sceditor.plugins.xhtml.allowedTags * @since v1.4.1 */ sceditorPlugins.xhtml.allowedTags = []; /** * Array containing all the disallowed tags. * * Only used if allowed tags is null or empty. * @type {Array} * @name jQuery.sceditor.plugins.xhtml.disallowedTags * @since v1.4.1 */ sceditorPlugins.xhtml.disallowedTags = []; }(jQuery));