summaryrefslogtreecommitdiff
path: root/js/sceditor/development/plugins
diff options
context:
space:
mode:
Diffstat (limited to 'js/sceditor/development/plugins')
-rw-r--r--js/sceditor/development/plugins/bbcode.js2810
-rw-r--r--js/sceditor/development/plugins/format.js127
-rw-r--r--js/sceditor/development/plugins/undo.js187
-rw-r--r--js/sceditor/development/plugins/xhtml.js1319
4 files changed, 4443 insertions, 0 deletions
diff --git a/js/sceditor/development/plugins/bbcode.js b/js/sceditor/development/plugins/bbcode.js
new file mode 100644
index 00000000..f1c81bab
--- /dev/null
+++ b/js/sceditor/development/plugins/bbcode.js
@@ -0,0 +1,2810 @@
+/**
+ * SCEditor BBCode Plugin
+ * http://www.sceditor.com/
+ *
+ * Copyright (C) 2011-2014, Sam Clarke (samclarke.com)
+ *
+ * SCEditor is licensed under the MIT license:
+ * http://www.opensource.org/licenses/mit-license.php
+ *
+ * @fileoverview SCEditor BBCode Plugin
+ * @author Sam Clarke
+ * @requires jQuery
+ */
+/*global prompt: true*/
+/*jshint maxdepth: false*/
+// TODO: Tidy this code up and consider seperating the BBCode parser into a
+// standalone module that can be used with other JS/NodeJS
+(function ($, window, document) {
+ 'use strict';
+
+ var SCEditor = $.sceditor;
+ var sceditorPlugins = SCEditor.plugins;
+ var escapeEntities = SCEditor.escapeEntities;
+ var escapeUriScheme = SCEditor.escapeUriScheme;
+
+ var IE_VER = SCEditor.ie;
+
+ // In IE < 11 a BR at the end of a block level element
+ // causes a double line break.
+ var IE_BR_FIX = IE_VER && IE_VER < 11;
+
+
+
+ var getEditorCommand = SCEditor.command.get;
+
+ var defaultCommandsOverrides = {
+ bold: {
+ txtExec: ['[b]', '[/b]']
+ },
+ italic: {
+ txtExec: ['[i]', '[/i]']
+ },
+ underline: {
+ txtExec: ['[u]', '[/u]']
+ },
+ strike: {
+ txtExec: ['[s]', '[/s]']
+ },
+ subscript: {
+ txtExec: ['[sub]', '[/sub]']
+ },
+ superscript: {
+ txtExec: ['[sup]', '[/sup]']
+ },
+ left: {
+ txtExec: ['[left]', '[/left]']
+ },
+ center: {
+ txtExec: ['[center]', '[/center]']
+ },
+ right: {
+ txtExec: ['[right]', '[/right]']
+ },
+ justify: {
+ txtExec: ['[justify]', '[/justify]']
+ },
+ font: {
+ txtExec: function (caller) {
+ var editor = this;
+
+ getEditorCommand('font')._dropDown(
+ editor,
+ caller,
+ function (fontName) {
+ editor.insertText(
+ '[font=' + fontName + ']',
+ '[/font]'
+ );
+ }
+ );
+ }
+ },
+ size: {
+ txtExec: function (caller) {
+ var editor = this;
+
+ getEditorCommand('size')._dropDown(
+ editor,
+ caller,
+ function (fontSize) {
+ editor.insertText(
+ '[size=' + fontSize + ']',
+ '[/size]'
+ );
+ }
+ );
+ }
+ },
+ color: {
+ txtExec: function (caller) {
+ var editor = this;
+
+ getEditorCommand('color')._dropDown(
+ editor,
+ caller,
+ function (color) {
+ editor.insertText(
+ '[color=' + color + ']',
+ '[/color]'
+ );
+ }
+ );
+ }
+ },
+ bulletlist: {
+ txtExec: function (caller, selected) {
+ var content = '';
+
+ $.each(selected.split(/\r?\n/), function () {
+ content += (content ? '\n' : '') +
+ '[li]' + this + '[/li]';
+ });
+
+ this.insertText('[ul]\n' + content + '\n[/ul]');
+ }
+ },
+ orderedlist: {
+ txtExec: function (caller, selected) {
+ var content = '';
+
+ $.each(selected.split(/\r?\n/), function () {
+ content += (content ? '\n' : '') +
+ '[li]' + this + '[/li]';
+ });
+
+ sceditorPlugins.bbcode.bbcode.get('');
+
+ this.insertText('[ol]\n' + content + '\n[/ol]');
+ }
+ },
+ table: {
+ txtExec: ['[table][tr][td]', '[/td][/tr][/table]']
+ },
+ horizontalrule: {
+ txtExec: ['[hr]']
+ },
+ code: {
+ txtExec: ['[code]', '[/code]']
+ },
+ image: {
+ txtExec: function (caller, selected) {
+ var editor = this,
+ url = prompt(editor._('Enter the image URL:'), selected);
+
+ if (url) {
+ editor.insertText('[img]' + url + '[/img]');
+ }
+ }
+ },
+ email: {
+ txtExec: function (caller, selected) {
+ var editor = this,
+ display = selected && selected.indexOf('@') > -1 ?
+ null : selected,
+ email = prompt(editor._('Enter the e-mail address:'),
+ (display ? '' : selected)),
+ text = prompt(editor._('Enter the displayed text:'),
+ display || email) || email;
+
+ if (email) {
+ editor.insertText('[email=' + email + ']' +
+ text + '[/email]');
+ }
+ }
+ },
+ link: {
+ txtExec: function (caller, selected) {
+ var editor = this,
+ display = /^[a-z]+:\/\//i.test($.trim(selected)) ?
+ null : selected,
+ url = prompt(editor._('Enter URL:'),
+ (display ? 'http://' : $.trim(selected))),
+ text = prompt(editor._('Enter the displayed text:'),
+ display || url) || url;
+
+ if (url) {
+ editor.insertText('[url=' + url + ']' + text + '[/url]');
+ }
+ }
+ },
+ quote: {
+ txtExec: ['[quote]', '[/quote]']
+ },
+ youtube: {
+ txtExec: function (caller) {
+ var editor = this;
+
+ getEditorCommand('youtube')._dropDown(
+ editor,
+ caller,
+ function (id) {
+ editor.insertText('[youtube]' + id + '[/youtube]');
+ }
+ );
+ }
+ },
+ rtl: {
+ txtExec: ['[rtl]', '[/rtl]']
+ },
+ ltr: {
+ txtExec: ['[ltr]', '[/ltr]']
+ }
+ };
+
+ /**
+ * Removes any leading or trailing quotes ('")
+ *
+ * @return string
+ * @since v1.4.0
+ */
+ var _stripQuotes = function (str) {
+ return str ?
+ str.replace(/\\(.)/g, '$1').replace(/^(["'])(.*?)\1$/, '$2') : str;
+ };
+
+ /**
+ * Formats a string replacing {0}, {1}, {2}, ect. with
+ * the params provided
+ *
+ * @param {String} str The string to format
+ * @param {string} args... The strings to replace
+ * @return {String}
+ * @since v1.4.0
+ */
+ var _formatString = function () {
+ var undef;
+ var args = arguments;
+
+ return args[0].replace(/\{(\d+)\}/g, function (str, p1) {
+ return args[p1 - 0 + 1] !== undef ?
+ args[p1 - 0 + 1] :
+ '{' + p1 + '}';
+ });
+ };
+
+ /**
+ * Enum of valid token types
+ * @type {Object}
+ * @private
+ */
+ var TokenType = {
+ OPEN: 'open',
+ CONTENT: 'content',
+ NEWLINE: 'newline',
+ CLOSE: 'close'
+ };
+
+
+ /**
+ * Tokenize token object
+ *
+ * @param {String} type The type of token this is,
+ * should be one of tokenType
+ * @param {String} name The name of this token
+ * @param {String} val The originally matched string
+ * @param {Array} attrs Any attributes. Only set on
+ * TokenType.OPEN tokens
+ * @param {Array} children Any children of this token
+ * @param {TokenizeToken} closing This tokens closing tag.
+ * Only set on TokenType.OPEN tokens
+ * @class TokenizeToken
+ * @name TokenizeToken
+ * @memberOf jQuery.sceditor.BBCodeParser.prototype
+ */
+ var TokenizeToken = function (
+ /*jshint maxparams: false*/
+ type, name, val, attrs, children, closing
+ ) {
+ var base = this;
+
+ base.type = type;
+ base.name = name;
+ base.val = val;
+ base.attrs = attrs || {};
+ base.children = children || [];
+ base.closing = closing || null;
+ };
+
+ TokenizeToken.prototype = {
+ /** @lends jQuery.sceditor.BBCodeParser.prototype.TokenizeToken */
+ /**
+ * Clones this token
+ *
+ * @param {Bool} includeChildren If to include the children in
+ * the clone. Defaults to false.
+ * @return {TokenizeToken}
+ */
+ clone: function (includeChildren) {
+ var base = this;
+
+ return new TokenizeToken(
+ base.type,
+ base.name,
+ base.val,
+ base.attrs,
+ includeChildren ? base.children : [],
+ base.closing ? base.closing.clone() : null
+ );
+ },
+ /**
+ * Splits this token at the specified child
+ *
+ * @param {TokenizeToken|Int} splitAt The child to split at or the
+ * index of the child
+ * @return {TokenizeToken} The right half of the split token or
+ * null if failed
+ */
+ splitAt: function (splitAt) {
+ var clone;
+ var base = this;
+ var splitAtLength = 0;
+ var childrenLen = base.children.length;
+
+ if (typeof splitAt !== 'number') {
+ splitAt = $.inArray(splitAt, base.children);
+ }
+
+ if (splitAt < 0 || splitAt > childrenLen) {
+ return null;
+ }
+
+ // Work out how many items are on the right side of the split
+ // to pass to splice()
+ while (childrenLen--) {
+ if (childrenLen >= splitAt) {
+ splitAtLength++;
+ } else {
+ childrenLen = 0;
+ }
+ }
+
+ clone = base.clone();
+ clone.children = base.children.splice(splitAt, splitAtLength);
+ return clone;
+ }
+ };
+
+
+ /**
+ * SCEditor BBCode parser class
+ *
+ * @param {Object} options
+ * @class BBCodeParser
+ * @name jQuery.sceditor.BBCodeParser
+ * @since v1.4.0
+ */
+ var BBCodeParser = function (options) {
+ // make sure this is not being called as a function
+ if (!(this instanceof BBCodeParser)) {
+ return new BBCodeParser(options);
+ }
+
+ var base = this;
+
+ // Private methods
+ var init,
+ tokenizeTag,
+ tokenizeAttrs,
+ parseTokens,
+ normaliseNewLines,
+ fixNesting,
+ isChildAllowed,
+ removeEmpty,
+ fixChildren,
+ convertToHTML,
+ convertToBBCode,
+ hasTag,
+ quote,
+ lower,
+ last;
+
+
+ init = function () {
+ base.bbcodes = sceditorPlugins.bbcode.bbcodes;
+ base.opts = $.extend(
+ {},
+ BBCodeParser.defaults,
+ options
+ );
+ };
+
+ /**
+ * Takes a BBCode string and splits it into open,
+ * content and close tags.
+ *
+ * It does no checking to verify a tag has a matching open
+ * or closing tag or if the tag is valid child of any tag
+ * before it. For that the tokens should be passed to the
+ * parse function.
+ *
+ * @param {String} str
+ * @return {Array}
+ * @memberOf jQuery.sceditor.BBCodeParser.prototype
+ */
+ base.tokenize = function (str) {
+ var matches, type, i;
+ var toks = [];
+ var tokens = [
+ // Close must come before open as they are
+ // the same except close has a / at the start.
+ {
+ type: TokenType.CLOSE,
+ regex: /^\[\/[^\[\]]+\]/
+ },
+ {
+ type: TokenType.OPEN,
+ regex: /^\[[^\[\]]+\]/
+ },
+ {
+ type: TokenType.NEWLINE,
+ regex: /^(\r\n|\r|\n)/
+ },
+ {
+ type: TokenType.CONTENT,
+ regex: /^([^\[\r\n]+|\[)/
+ }
+ ];
+
+ tokens.reverse();
+
+ strloop:
+ while (str.length) {
+ i = tokens.length;
+ while (i--) {
+ type = tokens[i].type;
+
+ // Check if the string matches any of the tokens
+ if (!(matches = str.match(tokens[i].regex)) ||
+ !matches[0]) {
+ continue;
+ }
+
+ // Add the match to the tokens list
+ toks.push(tokenizeTag(type, matches[0]));
+
+ // Remove the match from the string
+ str = str.substr(matches[0].length);
+
+ // The token has been added so start again
+ continue strloop;
+ }
+
+ // If there is anything left in the string which doesn't match
+ // any of the tokens then just assume it's content and add it.
+ if (str.length) {
+ toks.push(tokenizeTag(TokenType.CONTENT, str));
+ }
+
+ str = '';
+ }
+
+ return toks;
+ };
+
+ /**
+ * Extracts the name an params from a tag
+ *
+ * @param {tokenType} type
+ * @param {string} val
+ * @return {Object}
+ * @private
+ */
+ tokenizeTag = function (type, val) {
+ var matches, attrs, name,
+ openRegex = /\[([^\]\s=]+)(?:([^\]]+))?\]/,
+ closeRegex = /\[\/([^\[\]]+)\]/;
+
+ // Extract the name and attributes from opening tags and
+ // just the name from closing tags.
+ if (type === TokenType.OPEN && (matches = val.match(openRegex))) {
+ name = lower(matches[1]);
+
+ if (matches[2] && (matches[2] = $.trim(matches[2]))) {
+ attrs = tokenizeAttrs(matches[2]);
+ }
+ }
+
+ if (type === TokenType.CLOSE && (matches = val.match(closeRegex))) {
+ name = lower(matches[1]);
+ }
+
+ if (type === TokenType.NEWLINE) {
+ name = '#newline';
+ }
+
+ // Treat all tokens without a name and
+ // all unknown BBCodes as content
+ if (!name ||
+ ((type === TokenType.OPEN || type === TokenType.CLOSE) &&
+ !sceditorPlugins.bbcode.bbcodes[name])) {
+ type = TokenType.CONTENT;
+ name = '#';
+ }
+
+ return new TokenizeToken(type, name, val, attrs);
+ };
+
+ /**
+ * Extracts the individual attributes from a string containing
+ * all the attributes.
+ *
+ * @param {String} attrs
+ * @return {Array} Assoc array of attributes
+ * @private
+ */
+ tokenizeAttrs = function (attrs) {
+ var matches,
+ /*
+ ([^\s=]+) Anything that's not a space or equals
+ = Equals sign =
+ (?:
+ (?:
+ (["']) The opening quote
+ (
+ (?:\\\2|[^\2])*? Anything that isn't the
+ unescaped opening quote
+ )
+ \2 The opening quote again which
+ will close the string
+ )
+ | If not a quoted string then match
+ (
+ (?:.(?!\s\S+=))*.? Anything that isn't part of
+ [space][non-space][=] which
+ would be a new attribute
+ )
+ )
+ */
+ attrRegex =
+ /([^\s=]+)=(?:(?:(["'])((?:\\\2|[^\2])*?)\2)|((?:.(?!\s\S+=))*.))/g,
+ ret = {};
+
+ // if only one attribute then remove the = from the start and
+ // strip any quotes
+ if (attrs.charAt(0) === '=' && attrs.indexOf('=', 1) < 0) {
+ ret.defaultattr = _stripQuotes(attrs.substr(1));
+ } else {
+ if (attrs.charAt(0) === '=') {
+ attrs = 'defaultattr' + attrs;
+ }
+
+ // No need to strip quotes here, the regex will do that.
+ while ((matches = attrRegex.exec(attrs))) {
+ ret[lower(matches[1])] =
+ _stripQuotes(matches[3]) || matches[4];
+ }
+ }
+
+ return ret;
+ };
+
+ /**
+ * Parses a string into an array of BBCodes
+ *
+ * @param {string} str
+ * @param {boolean} preserveNewLines If to preserve all new lines, not
+ * strip any based on the passed
+ * formatting options
+ * @return {Array} Array of BBCode objects
+ * @memberOf jQuery.sceditor.BBCodeParser.prototype
+ */
+ base.parse = function (str, preserveNewLines) {
+ var ret = parseTokens(base.tokenize(str));
+ var opts = base.opts;
+
+ if (opts.fixInvalidChildren) {
+ fixChildren(ret);
+ }
+
+ if (opts.removeEmptyTags) {
+ removeEmpty(ret);
+ }
+
+ if (opts.fixInvalidNesting) {
+ fixNesting(ret);
+ }
+
+ normaliseNewLines(ret, null, preserveNewLines);
+
+ if (opts.removeEmptyTags) {
+ removeEmpty(ret);
+ }
+
+ return ret;
+ };
+
+ /**
+ * Checks if an array of TokenizeToken's contains the
+ * specified token.
+ *
+ * Checks the tokens name and type match another tokens
+ * name and type in the array.
+ *
+ * @param {string} name
+ * @param {tokenType} type
+ * @param {Array} arr
+ * @return {Boolean}
+ * @private
+ */
+ hasTag = function (name, type, arr) {
+ var i = arr.length;
+
+ while (i--) {
+ if (arr[i].type === type && arr[i].name === name) {
+ return true;
+ }
+ }
+
+ return false;
+ };
+
+ /**
+ * Checks if the child tag is allowed as one
+ * of the parent tags children.
+ *
+ * @param {TokenizeToken} parent
+ * @param {TokenizeToken} child
+ * @return {Boolean}
+ * @private
+ */
+ isChildAllowed = function (parent, child) {
+ var parentBBCode = parent ? base.bbcodes[parent.name] : {},
+ allowedChildren = parentBBCode.allowedChildren;
+
+ if (base.opts.fixInvalidChildren && allowedChildren) {
+ return $.inArray(child.name || '#', allowedChildren) > -1;
+ }
+
+ return true;
+ };
+
+// TODO: Tidy this parseTokens() function up a bit.
+ /**
+ * Parses an array of tokens created by tokenize()
+ *
+ * @param {Array} toks
+ * @return {Array} Parsed tokens
+ * @see tokenize()
+ * @private
+ */
+ parseTokens = function (toks) {
+ var token, bbcode, curTok, clone, i, previous, next,
+ cloned = [],
+ output = [],
+ openTags = [],
+ /**
+ * Returns the currently open tag or undefined
+ * @return {TokenizeToken}
+ */
+ currentOpenTag = function () {
+ return last(openTags);
+ },
+ /**
+ * Adds a tag to either the current tags children
+ * or to the output array.
+ * @param {TokenizeToken} token
+ * @private
+ */
+ addTag = function (token) {
+ if (currentOpenTag()) {
+ currentOpenTag().children.push(token);
+ } else {
+ output.push(token);
+ }
+ },
+ /**
+ * Checks if this tag closes the current tag
+ * @param {String} name
+ * @return {Void}
+ */
+ closesCurrentTag = function (name) {
+ return currentOpenTag() &&
+ (bbcode = base.bbcodes[currentOpenTag().name]) &&
+ bbcode.closedBy &&
+ $.inArray(name, bbcode.closedBy) > -1;
+ };
+
+ while ((token = toks.shift())) {
+ next = toks[0];
+
+ /* jshint indent:false */
+ switch (token.type) {
+ case TokenType.OPEN:
+ // Check it this closes a parent,
+ // e.g. for lists [*]one [*]two
+ if (closesCurrentTag(token.name)) {
+ openTags.pop();
+ }
+
+ addTag(token);
+ bbcode = base.bbcodes[token.name];
+
+ // If this tag is not self closing and it has a closing
+ // tag then it is open and has children so add it to the
+ // list of open tags. If has the closedBy property then
+ // it is closed by other tags so include everything as
+ // it's children until one of those tags is reached.
+ if ((!bbcode || !bbcode.isSelfClosing) &&
+ (bbcode.closedBy ||
+ hasTag(token.name, TokenType.CLOSE, toks))) {
+ openTags.push(token);
+ } else if (!bbcode || !bbcode.isSelfClosing) {
+ token.type = TokenType.CONTENT;
+ }
+ break;
+
+ case TokenType.CLOSE:
+ // check if this closes the current tag,
+ // e.g. [/list] would close an open [*]
+ if (currentOpenTag() &&
+ token.name !== currentOpenTag().name &&
+ closesCurrentTag('/' + token.name)) {
+ openTags.pop();
+ }
+
+ // If this is closing the currently open tag just pop
+ // the close tag off the open tags array
+ if (currentOpenTag() &&
+ token.name === currentOpenTag().name) {
+ currentOpenTag().closing = token;
+ openTags.pop();
+
+ // If this is closing an open tag that is the parent of
+ // the current tag then clone all the tags including the
+ // current one until reaching the parent that is being
+ // closed. Close the parent and then add the clones back
+ // in.
+ } else if (hasTag(token.name, TokenType.OPEN,
+ openTags)) {
+
+ // Remove the tag from the open tags
+ while ((curTok = openTags.pop())) {
+
+ // If it's the tag that is being closed then
+ // discard it and break the loop.
+ if (curTok.name === token.name) {
+ curTok.closing = token;
+ break;
+ }
+
+ // Otherwise clone this tag and then add any
+ // previously cloned tags as it's children
+ clone = curTok.clone();
+
+ if (cloned.length) {
+ clone.children.push(last(cloned));
+ }
+
+ cloned.push(clone);
+ }
+
+ // Add the last cloned child to the now current tag
+ // (the parent of the tag which was being closed)
+ addTag(last(cloned));
+
+ // Add all the cloned tags to the open tags list
+ i = cloned.length;
+ while (i--) {
+ openTags.push(cloned[i]);
+ }
+
+ cloned.length = 0;
+
+ // This tag is closing nothing so treat it as content
+ } else {
+ token.type = TokenType.CONTENT;
+ addTag(token);
+ }
+ break;
+
+ case TokenType.NEWLINE:
+ // handle things like
+ // [*]list\nitem\n[*]list1
+ // where it should come out as
+ // [*]list\nitem[/*]\n[*]list1[/*]
+ // instead of
+ // [*]list\nitem\n[/*][*]list1[/*]
+ if (currentOpenTag() && next &&
+ closesCurrentTag(
+ (next.type === TokenType.CLOSE ? '/' : '') +
+ next.name
+ )) {
+ // skip if the next tag is the closing tag for
+ // the option tag, i.e. [/*]
+ if (!(next.type === TokenType.CLOSE &&
+ next.name === currentOpenTag().name)) {
+ bbcode = base.bbcodes[currentOpenTag().name];
+
+ if (bbcode && bbcode.breakAfter) {
+ openTags.pop();
+ } else if (bbcode &&
+ bbcode.isInline === false &&
+ base.opts.breakAfterBlock &&
+ bbcode.breakAfter !== false) {
+ openTags.pop();
+ }
+ }
+ }
+
+ addTag(token);
+ break;
+
+ default: // content
+ addTag(token);
+ break;
+ }
+
+ previous = token;
+ }
+
+ return output;
+ };
+
+ /**
+ * Normalise all new lines
+ *
+ * Removes any formatting new lines from the BBCode
+ * leaving only content ones. I.e. for a list:
+ *
+ * [list]
+ * [*] list item one
+ * with a line break
+ * [*] list item two
+ * [/list]
+ *
+ * would become
+ *
+ * [list] [*] list item one
+ * with a line break [*] list item two [/list]
+ *
+ * Which makes it easier to convert to HTML or add
+ * the formatting new lines back in when converting
+ * back to BBCode
+ *
+ * @param {Array} children
+ * @param {TokenizeToken} parent
+ * @param {Bool} onlyRemoveBreakAfter
+ * @return {void}
+ */
+ normaliseNewLines = function (children, parent, onlyRemoveBreakAfter) {
+ var token, left, right, parentBBCode, bbcode,
+ removedBreakEnd, removedBreakBefore, remove;
+ var childrenLength = children.length;
+// TODO: this function really needs tidying up
+ if (parent) {
+ parentBBCode = base.bbcodes[parent.name];
+ }
+
+ var i = childrenLength;
+ while (i--) {
+ if (!(token = children[i])) {
+ continue;
+ }
+
+ if (token.type === TokenType.NEWLINE) {
+ left = i > 0 ? children[i - 1] : null;
+ right = i < childrenLength - 1 ? children[i + 1] : null;
+ remove = false;
+
+ // Handle the start and end new lines
+ // e.g. [tag]\n and \n[/tag]
+ if (!onlyRemoveBreakAfter && parentBBCode &&
+ parentBBCode.isSelfClosing !== true) {
+ // First child of parent so must be opening line break
+ // (breakStartBlock, breakStart) e.g. [tag]\n
+ if (!left) {
+ if (parentBBCode.isInline === false &&
+ base.opts.breakStartBlock &&
+ parentBBCode.breakStart !== false) {
+ remove = true;
+ }
+
+ if (parentBBCode.breakStart) {
+ remove = true;
+ }
+ // Last child of parent so must be end line break
+ // (breakEndBlock, breakEnd)
+ // e.g. \n[/tag]
+ // remove last line break (breakEndBlock, breakEnd)
+ } else if (!removedBreakEnd && !right) {
+ if (parentBBCode.isInline === false &&
+ base.opts.breakEndBlock &&
+ parentBBCode.breakEnd !== false) {
+ remove = true;
+ }
+
+ if (parentBBCode.breakEnd) {
+ remove = true;
+ }
+
+ removedBreakEnd = remove;
+ }
+ }
+
+ if (left && left.type === TokenType.OPEN) {
+ if ((bbcode = base.bbcodes[left.name])) {
+ if (!onlyRemoveBreakAfter) {
+ if (bbcode.isInline === false &&
+ base.opts.breakAfterBlock &&
+ bbcode.breakAfter !== false) {
+ remove = true;
+ }
+
+ if (bbcode.breakAfter) {
+ remove = true;
+ }
+ } else if (bbcode.isInline === false) {
+ remove = true;
+ }
+ }
+ }
+
+ if (!onlyRemoveBreakAfter && !removedBreakBefore &&
+ right && right.type === TokenType.OPEN) {
+
+ if ((bbcode = base.bbcodes[right.name])) {
+ if (bbcode.isInline === false &&
+ base.opts.breakBeforeBlock &&
+ bbcode.breakBefore !== false) {
+ remove = true;
+ }
+
+ if (bbcode.breakBefore) {
+ remove = true;
+ }
+
+ removedBreakBefore = remove;
+
+ if (remove) {
+ children.splice(i, 1);
+ continue;
+ }
+ }
+ }
+
+ if (remove) {
+ children.splice(i, 1);
+ }
+
+ // reset double removedBreakBefore removal protection.
+ // This is needed for cases like \n\n[\tag] where
+ // only 1 \n should be removed but without this they both
+ // would be.
+ removedBreakBefore = false;
+ } else if (token.type === TokenType.OPEN) {
+ normaliseNewLines(token.children, token,
+ onlyRemoveBreakAfter);
+ }
+ }
+ };
+
+ /**
+ * Fixes any invalid nesting.
+ *
+ * If it is a block level element inside 1 or more inline elements
+ * then those inline elements will be split at the point where the
+ * block level is and the block level element placed between the split
+ * parts. i.e.
+ * [inline]A[blocklevel]B[/blocklevel]C[/inline]
+ * Will become:
+ * [inline]A[/inline][blocklevel]B[/blocklevel][inline]C[/inline]
+ *
+ * @param {Array} children
+ * @param {Array} [parents] Null if there is no parents
+ * @param {Array} [insideInline] Boolean, if inside an inline element
+ * @param {Array} [rootArr] Root array if there is one
+ * @return {Array}
+ * @private
+ */
+ fixNesting = function (children, parents, insideInline, rootArr) {
+ var token, i, parent, parentIndex, parentParentChildren, right;
+
+ var isInline = function (token) {
+ var bbcode = base.bbcodes[token.name];
+
+ return !bbcode || bbcode.isInline !== false;
+ };
+
+ parents = parents || [];
+ rootArr = rootArr || children;
+
+ // This must check the length each time as it can change when
+ // tokens are moved to fix the nesting.
+ for (i = 0; i < children.length; i++) {
+ if (!(token = children[i]) || token.type !== TokenType.OPEN) {
+ continue;
+ }
+
+ if (!isInline(token) && insideInline) {
+ // if this is a blocklevel element inside an inline one then
+ // split the parent at the block level element
+ parent = last(parents);
+ right = parent.splitAt(token);
+
+ parentParentChildren = parents.length > 1 ?
+ parents[parents.length - 2].children : rootArr;
+
+ parentIndex = $.inArray(parent, parentParentChildren);
+ if (parentIndex > -1) {
+ // remove the block level token from the right side of
+ // the split inline element
+ right.children.splice(
+ $.inArray(token, right.children), 1);
+
+ // insert the block level token and the right side after
+ // the left side of the inline token
+ parentParentChildren.splice(
+ parentIndex + 1, 0, token, right
+ );
+
+ // return to parents loop as the
+ // children have now increased
+ return;
+ }
+
+ }
+
+ parents.push(token);
+
+ fixNesting(
+ token.children,
+ parents,
+ insideInline || isInline(token),
+ rootArr
+ );
+
+ parents.pop(token);
+ }
+ };
+
+ /**
+ * Fixes any invalid children.
+ *
+ * If it is an element which isn't allowed as a child of it's parent
+ * then it will be converted to content of the parent element. i.e.
+ * [code]Code [b]only[/b] allows text.[/code]
+ * Will become:
+ * <code>Code [b]only[/b] allows text.</code>
+ * Instead of:
+ * <code>Code <b>only</b> allows text.</code>
+ *
+ * @param {Array} children
+ * @param {Array} [parent] Null if there is no parents
+ * @private
+ */
+ fixChildren = function (children, parent) {
+ var token, args;
+
+ var i = children.length;
+ while (i--) {
+ if (!(token = children[i])) {
+ continue;
+ }
+
+ if (!isChildAllowed(parent, token)) {
+ // if it is not then convert it to text and see if it
+ // is allowed
+ token.name = null;
+ token.type = TokenType.CONTENT;
+
+ if (isChildAllowed(parent, token)) {
+ args = [i + 1, 0].concat(token.children);
+
+ if (token.closing) {
+ token.closing.name = null;
+ token.closing.type = TokenType.CONTENT;
+ args.push(token.closing);
+ }
+
+ i += args.length - 1;
+ Array.prototype.splice.apply(children, args);
+ } else {
+ parent.children.splice(i, 1);
+ }
+ }
+
+ if (token.type === TokenType.OPEN) {
+ fixChildren(token.children, token);
+ }
+ }
+ };
+
+ /**
+ * Removes any empty BBCodes which are not allowed to be empty.
+ *
+ * @param {Array} tokens
+ * @private
+ */
+ removeEmpty = function (tokens) {
+ var token, bbcode;
+
+ /**
+ * Checks if all children are whitespace or not
+ * @private
+ */
+ var isTokenWhiteSpace = function (children) {
+ var j = children.length;
+
+ while (j--) {
+ var type = children[j].type;
+
+ if (type === TokenType.OPEN || type === TokenType.CLOSE) {
+ return false;
+ }
+
+ if (type === TokenType.CONTENT &&
+ /\S|\u00A0/.test(children[j].val)) {
+ return false;
+ }
+ }
+
+ return true;
+ };
+
+ var i = tokens.length;
+ while (i--) {
+ // So skip anything that isn't a tag since only tags can be
+ // empty, content can't
+ if (!(token = tokens[i]) || token.type !== TokenType.OPEN) {
+ continue;
+ }
+
+ bbcode = base.bbcodes[token.name];
+
+ // Remove any empty children of this tag first so that if they
+ // are all removed this one doesn't think it's not empty.
+ removeEmpty(token.children);
+
+ if (isTokenWhiteSpace(token.children) && bbcode &&
+ !bbcode.isSelfClosing && !bbcode.allowsEmpty) {
+ tokens.splice.apply(
+ tokens,
+ $.merge([i, 1], token.children)
+ );
+ }
+ }
+ };
+
+ /**
+ * Converts a BBCode string to HTML
+ *
+ * @param {String} str
+ * @param {Bool} preserveNewLines If to preserve all new lines, not
+ * strip any based on the passed
+ * formatting options
+ * @return {String}
+ * @memberOf jQuery.sceditor.BBCodeParser.prototype
+ */
+ base.toHTML = function (str, preserveNewLines) {
+ return convertToHTML(base.parse(str, preserveNewLines), true);
+ };
+
+ /**
+ * @private
+ */
+ convertToHTML = function (tokens, isRoot) {
+ var undef, token, bbcode, content, html, needsBlockWrap,
+ blockWrapOpen, isInline, lastChild,
+ ret = [];
+
+ isInline = function (bbcode) {
+ return (!bbcode || (bbcode.isHtmlInline !== undef ?
+ bbcode.isHtmlInline : bbcode.isInline)) !== false;
+ };
+
+ while (tokens.length > 0) {
+ if (!(token = tokens.shift())) {
+ continue;
+ }
+
+ if (token.type === TokenType.OPEN) {
+ lastChild =
+ token.children[token.children.length - 1] || {};
+ bbcode = base.bbcodes[token.name];
+ needsBlockWrap = isRoot && isInline(bbcode);
+ content = convertToHTML(token.children, false);
+
+ if (bbcode && bbcode.html) {
+ // Only add a line break to the end if this is
+ // blocklevel and the last child wasn't block-level
+ if (!isInline(bbcode) &&
+ isInline(base.bbcodes[lastChild.name]) &&
+ !bbcode.isPreFormatted &&
+ !bbcode.skipLastLineBreak) {
+ // Add placeholder br to end of block level elements
+ // in all browsers apart from IE < 9 which handle
+ // new lines differently and doesn't need one.
+ if (!IE_BR_FIX) {
+ content += '<br />';
+ }
+ }
+
+ if (!$.isFunction(bbcode.html)) {
+ token.attrs['0'] = content;
+ html = sceditorPlugins.bbcode.formatBBCodeString(
+ bbcode.html,
+ token.attrs
+ );
+ } else {
+ html = bbcode.html.call(
+ base,
+ token,
+ token.attrs,
+ content
+ );
+ }
+ } else {
+ html = token.val + content +
+ (token.closing ? token.closing.val : '');
+ }
+ } else if (token.type === TokenType.NEWLINE) {
+ if (!isRoot) {
+ ret.push('<br />');
+ continue;
+ }
+
+ // If not already in a block wrap then start a new block
+ if (!blockWrapOpen) {
+ ret.push('<div>');
+
+ // If it's an empty DIV and compatibility mode is below
+ // IE8 then we must add a non-breaking space to the div
+ // otherwise the div will be collapsed. Adding a BR
+ // works but when you press enter to make a newline it
+ // suddenly goes back to the normal IE div behavior and
+ // creates two lines, one for the newline and one for
+ // the BR. I'm sure there must be a better fix but I've
+ // yet to find one.
+ // Cannot do zoom: 1; or set a height on the div to fix
+ // it as that causes resize handles to be added to the
+ // div when it's clicked on.
+ if (IE_VER < 8 || (document.documentMode &&
+ document.documentMode < 8)) {
+ ret.push('\u00a0');
+ }
+ }
+
+ // Putting BR in a div in IE causes it
+ // to do a double line break.
+ if (!IE_BR_FIX) {
+ ret.push('<br />');
+ }
+
+ // Normally the div acts as a line-break with by moving
+ // whatever comes after onto a new line.
+ // If this is the last token, add an extra line-break so it
+ // shows as there will be nothing after it.
+ if (!tokens.length) {
+ ret.push('<br />');
+ }
+
+ ret.push('</div>\n');
+ blockWrapOpen = false;
+ continue;
+ // content
+ } else {
+ needsBlockWrap = isRoot;
+ html = escapeEntities(token.val, true);
+ }
+
+ if (needsBlockWrap && !blockWrapOpen) {
+ ret.push('<div>');
+ blockWrapOpen = true;
+ } else if (!needsBlockWrap && blockWrapOpen) {
+ ret.push('</div>\n');
+ blockWrapOpen = false;
+ }
+
+ ret.push(html);
+ }
+
+ if (blockWrapOpen) {
+ ret.push('</div>\n');
+ }
+
+ return ret.join('');
+ };
+
+ /**
+ * Takes a BBCode string, parses it then converts it back to BBCode.
+ *
+ * This will auto fix the BBCode and format it with the specified
+ * options.
+ *
+ * @param {String} str
+ * @param {Bool} preserveNewLines If to preserve all new lines, not
+ * strip any based on the passed
+ * formatting options
+ * @return {String}
+ * @memberOf jQuery.sceditor.BBCodeParser.prototype
+ */
+ base.toBBCode = function (str, preserveNewLines) {
+ return convertToBBCode(base.parse(str, preserveNewLines));
+ };
+
+ /**
+ * Converts parsed tokens back into BBCode with the
+ * formatting specified in the options and with any
+ * fixes specified.
+ *
+ * @param {Array} toks Array of parsed tokens from base.parse()
+ * @return {String}
+ * @private
+ */
+ convertToBBCode = function (toks) {
+ var token, attr, bbcode, isBlock, isSelfClosing, quoteType,
+ breakBefore, breakStart, breakEnd, breakAfter,
+ // Create an array of strings which are joined together
+ // before being returned as this is faster in slow browsers.
+ // (Old versions of IE).
+ ret = [];
+
+ while (toks.length > 0) {
+ if (!(token = toks.shift())) {
+ continue;
+ }
+// TODO: tidy this
+ bbcode = base.bbcodes[token.name];
+ isBlock = !(!bbcode || bbcode.isInline !== false);
+ isSelfClosing = bbcode && bbcode.isSelfClosing;
+
+ breakBefore = (isBlock && base.opts.breakBeforeBlock &&
+ bbcode.breakBefore !== false) ||
+ (bbcode && bbcode.breakBefore);
+
+ breakStart = (isBlock && !isSelfClosing &&
+ base.opts.breakStartBlock &&
+ bbcode.breakStart !== false) ||
+ (bbcode && bbcode.breakStart);
+
+ breakEnd = (isBlock && base.opts.breakEndBlock &&
+ bbcode.breakEnd !== false) ||
+ (bbcode && bbcode.breakEnd);
+
+ breakAfter = (isBlock && base.opts.breakAfterBlock &&
+ bbcode.breakAfter !== false) ||
+ (bbcode && bbcode.breakAfter);
+
+ quoteType = (bbcode ? bbcode.quoteType : null) ||
+ base.opts.quoteType || BBCodeParser.QuoteType.auto;
+
+ if (!bbcode && token.type === TokenType.OPEN) {
+ ret.push(token.val);
+
+ if (token.children) {
+ ret.push(convertToBBCode(token.children));
+ }
+
+ if (token.closing) {
+ ret.push(token.closing.val);
+ }
+ } else if (token.type === TokenType.OPEN) {
+ if (breakBefore) {
+ ret.push('\n');
+ }
+
+ // Convert the tag and it's attributes to BBCode
+ ret.push('[' + token.name);
+ if (token.attrs) {
+ if (token.attrs.defaultattr) {
+ ret.push('=', quote(
+ token.attrs.defaultattr,
+ quoteType,
+ 'defaultattr'
+ ));
+
+ delete token.attrs.defaultattr;
+ }
+
+ for (attr in token.attrs) {
+ if (token.attrs.hasOwnProperty(attr)) {
+ ret.push(' ', attr, '=',
+ quote(token.attrs[attr], quoteType, attr));
+ }
+ }
+ }
+ ret.push(']');
+
+ if (breakStart) {
+ ret.push('\n');
+ }
+
+ // Convert the tags children to BBCode
+ if (token.children) {
+ ret.push(convertToBBCode(token.children));
+ }
+
+ // add closing tag if not self closing
+ if (!isSelfClosing && !bbcode.excludeClosing) {
+ if (breakEnd) {
+ ret.push('\n');
+ }
+
+ ret.push('[/' + token.name + ']');
+ }
+
+ if (breakAfter) {
+ ret.push('\n');
+ }
+
+ // preserve whatever was recognized as the
+ // closing tag if it is a self closing tag
+ if (token.closing && isSelfClosing) {
+ ret.push(token.closing.val);
+ }
+ } else {
+ ret.push(token.val);
+ }
+ }
+
+ return ret.join('');
+ };
+
+ /**
+ * Quotes an attribute
+ *
+ * @param {String} str
+ * @param {BBCodeParser.QuoteType} quoteType
+ * @param {String} name
+ * @return {String}
+ * @private
+ */
+ quote = function (str, quoteType, name) {
+ var QuoteTypes = BBCodeParser.QuoteType,
+ needsQuotes = /\s|=/.test(str);
+
+ if ($.isFunction(quoteType)) {
+ return quoteType(str, name);
+ }
+
+ if (quoteType === QuoteTypes.never ||
+ (quoteType === QuoteTypes.auto && !needsQuotes)) {
+ return str;
+ }
+
+ return '"' + str.replace('\\', '\\\\').replace('"', '\\"') + '"';
+ };
+
+ /**
+ * Returns the last element of an array or null
+ *
+ * @param {Array} arr
+ * @return {Object} Last element
+ * @private
+ */
+ last = function (arr) {
+ if (arr.length) {
+ return arr[arr.length - 1];
+ }
+
+ return null;
+ };
+
+ /**
+ * Converts a string to lowercase.
+ *
+ * @param {String} str
+ * @return {String} Lowercase version of str
+ * @private
+ */
+ lower = function (str) {
+ return str.toLowerCase();
+ };
+
+ init();
+ };
+
+ /**
+ * Quote type
+ * @type {Object}
+ * @class QuoteType
+ * @name jQuery.sceditor.BBCodeParser.QuoteType
+ * @since v1.4.0
+ */
+ BBCodeParser.QuoteType = {
+ /** @lends jQuery.sceditor.BBCodeParser.QuoteType */
+ /**
+ * Always quote the attribute value
+ * @type {Number}
+ */
+ always: 1,
+
+ /**
+ * Never quote the attributes value
+ * @type {Number}
+ */
+ never: 2,
+
+ /**
+ * Only quote the attributes value when it contains spaces to equals
+ * @type {Number}
+ */
+ auto: 3
+ };
+
+ /**
+ * Default BBCode parser options
+ * @type {Object}
+ */
+ BBCodeParser.defaults = {
+ /**
+ * If to add a new line before block level elements
+ *
+ * @type {Boolean}
+ */
+ breakBeforeBlock: false,
+
+ /**
+ * If to add a new line after the start of block level elements
+ *
+ * @type {Boolean}
+ */
+ breakStartBlock: false,
+
+ /**
+ * If to add a new line before the end of block level elements
+ *
+ * @type {Boolean}
+ */
+ breakEndBlock: false,
+
+ /**
+ * If to add a new line after block level elements
+ *
+ * @type {Boolean}
+ */
+ breakAfterBlock: true,
+
+ /**
+ * If to remove empty tags
+ *
+ * @type {Boolean}
+ */
+ removeEmptyTags: true,
+
+ /**
+ * If to fix invalid nesting,
+ * i.e. block level elements inside inline elements.
+ *
+ * @type {Boolean}
+ */
+ fixInvalidNesting: true,
+
+ /**
+ * If to fix invalid children.
+ * i.e. A tag which is inside a parent that doesn't
+ * allow that type of tag.
+ *
+ * @type {Boolean}
+ */
+ fixInvalidChildren: true,
+
+ /**
+ * Attribute quote type
+ *
+ * @type {BBCodeParser.QuoteType}
+ * @since 1.4.1
+ */
+ quoteType: BBCodeParser.QuoteType.auto
+ };
+
+ /**
+ * Deprecated, use sceditorPlugins.bbcode
+ *
+ * @class sceditorBBCodePlugin
+ * @name jQuery.sceditor.sceditorBBCodePlugin
+ * @deprecated
+ */
+ $.sceditorBBCodePlugin =
+ /**
+ * BBCode plugin for SCEditor
+ *
+ * @class bbcode
+ * @name jQuery.sceditor.plugins.bbcode
+ * @since 1.4.1
+ */
+ sceditorPlugins.bbcode = function () {
+ var base = this;
+
+ /**
+ * Private methods
+ * @private
+ */
+ var buildBbcodeCache,
+ handleStyles,
+ handleTags,
+ removeFirstLastDiv;
+
+ base.bbcodes = sceditorPlugins.bbcode.bbcodes;
+ base.stripQuotes = _stripQuotes;
+
+ /**
+ * cache of all the tags pointing to their bbcodes to enable
+ * faster lookup of which bbcode a tag should have
+ * @private
+ */
+ var tagsToBBCodes = {};
+
+ /**
+ * Same as tagsToBBCodes but instead of HTML tags it's styles
+ * @private
+ */
+ var stylesToBBCodes = {};
+
+ /**
+ * Allowed children of specific HTML tags. Empty array if no
+ * children other than text nodes are allowed
+ * @private
+ */
+ var validChildren = {
+ ul: ['li', 'ol', 'ul'],
+ ol: ['li', 'ol', 'ul'],
+ table: ['tr'],
+ tr: ['td', 'th'],
+ code: ['br', 'p', 'div']
+ };
+
+ /**
+ * Initializer
+ * @private
+ */
+ base.init = function () {
+ base.opts = this.opts;
+
+ // build the BBCode cache
+ buildBbcodeCache();
+
+ this.commands = $.extend(
+ true, {}, defaultCommandsOverrides, this.commands
+ );
+
+ // Add BBCode helper methods
+ this.toBBCode = base.signalToSource;
+ this.fromBBCode = base.signalToWysiwyg;
+ };
+
+ /**
+ * Populates tagsToBBCodes and stylesToBBCodes to enable faster lookups
+ *
+ * @private
+ */
+ buildBbcodeCache = function () {
+ $.each(base.bbcodes, function (bbcode) {
+ var isBlock,
+ tags = base.bbcodes[bbcode].tags,
+ styles = base.bbcodes[bbcode].styles;
+
+ if (tags) {
+ $.each(tags, function (tag, values) {
+ isBlock = base.bbcodes[bbcode].isInline === false;
+
+ tagsToBBCodes[tag] = tagsToBBCodes[tag] || {};
+
+ tagsToBBCodes[tag][isBlock] =
+ tagsToBBCodes[tag][isBlock] || {};
+
+ tagsToBBCodes[tag][isBlock][bbcode] = values;
+ });
+ }
+
+ if (styles) {
+ $.each(styles, function (style, values) {
+ isBlock = base.bbcodes[bbcode].isInline === false;
+
+ stylesToBBCodes[isBlock] =
+ stylesToBBCodes[isBlock] || {};
+
+ stylesToBBCodes[isBlock][style] =
+ stylesToBBCodes[isBlock][style] || {};
+
+ stylesToBBCodes[isBlock][style][bbcode] = values;
+ });
+ }
+ });
+ };
+
+ /**
+ * Checks if any bbcode styles match the elements styles
+ *
+ * @return string Content with any matching
+ * bbcode tags wrapped around it.
+ * @private
+ */
+ handleStyles = function ($element, content, blockLevel) {
+ var styleValue, format,
+ getStyle = SCEditor.dom.getStyle;
+
+ // convert blockLevel to boolean
+ blockLevel = !!blockLevel;
+
+ if (!stylesToBBCodes[blockLevel]) {
+ return content;
+ }
+
+ $.each(stylesToBBCodes[blockLevel], function (property, bbcodes) {
+ styleValue = getStyle($element[0], property);
+
+ // if the parent has the same style use that instead of this one
+ // so you don't end up with [i]parent[i]child[/i][/i]
+ if (!styleValue ||
+ getStyle($element.parent()[0], property) === styleValue) {
+ return;
+ }
+
+ $.each(bbcodes, function (bbcode, values) {
+ if (!values ||
+ $.inArray(styleValue.toString(), values) > -1) {
+ format = base.bbcodes[bbcode].format;
+
+ if ($.isFunction(format)) {
+ content = format.call(base, $element, content);
+ } else {
+ content = _formatString(format, content);
+ }
+ }
+ });
+ });
+
+ return content;
+ };
+
+ /**
+ * Handles a HTML tag and finds any matching bbcodes
+ *
+ * @param {jQuery} $element The element to convert
+ * @param {String} content The Tags text content
+ * @param {Bool} blockLevel If to convert block level tags
+ * @return {String} Content with any matching bbcode tags
+ * wrapped around it.
+ * @private
+ */
+ handleTags = function ($element, content, blockLevel) {
+ var convertBBCode, format,
+ element = $element[0],
+ tag = element.nodeName.toLowerCase();
+
+ // convert blockLevel to boolean
+ blockLevel = !!blockLevel;
+
+ if (tagsToBBCodes[tag] && tagsToBBCodes[tag][blockLevel]) {
+ // loop all bbcodes for this tag
+ $.each(tagsToBBCodes[tag][blockLevel], function (
+ bbcode, bbcodeAttribs) {
+ // if the bbcode requires any attributes then check this has
+ // all needed
+ if (bbcodeAttribs) {
+ convertBBCode = false;
+
+ // loop all the bbcode attribs
+ $.each(bbcodeAttribs, function (attrib, values) {
+ // Skip if the element doesn't have the attibue or
+ // the attribute doesn't match one of the require
+ // values
+ if (!$element.attr(attrib) || (values &&
+ $.inArray($element.attr(attrib), values) < 0)) {
+ return;
+ }
+
+ // break this loop as we have matched this bbcode
+ convertBBCode = true;
+ return false;
+ });
+
+ if (!convertBBCode) {
+ return;
+ }
+ }
+
+ format = base.bbcodes[bbcode].format;
+
+ if ($.isFunction(format)) {
+ content = format.call(base, $element, content);
+ } else {
+ content = _formatString(format, content);
+ }
+ });
+ }
+
+ var isInline = SCEditor.dom.isInline;
+ if (blockLevel && (!isInline(element, true) || tag === 'br')) {
+ var isLastBlockChild, parent, parentLastChild,
+ previousSibling = element.previousSibling;
+
+ // Skips selection makers and ignored elements
+ // Skip empty inline elements
+ while (previousSibling &&
+ previousSibling.nodeType === 1 &&
+ !$(previousSibling).is('br') &&
+ isInline(previousSibling, true) &&
+ !previousSibling.firstChild) {
+ previousSibling = previousSibling.previousSibling;
+ }
+
+ // If it's the last block of an inline that is the last
+ // child of a block then it shouldn't cause a line break
+ // except in IE < 11
+ // <block><inline><br></inline></block>
+ do {
+ parent = element.parentNode;
+ parentLastChild = parent.lastChild;
+
+ isLastBlockChild = parentLastChild === element;
+ element = parent;
+ } while (parent && isLastBlockChild && isInline(parent, true));
+
+ // If this block is:
+ // * Not the last child of a block level element
+ // * Is a <li> tag (lists are blocks)
+ // * Is IE < 11 and the tag is BR. IE < 11 never collapses BR
+ // tags.
+ if (!isLastBlockChild || tag === 'li' ||
+ (tag === 'br' && IE_BR_FIX)) {
+ content += '\n';
+ }
+
+ // Check for:
+ // <block>text<block>text</block></block>
+ //
+ // The second opening <block> opening tag should cause a
+ // line break because the previous sibing is inline.
+ if (tag !== 'br' && previousSibling &&
+ !$(previousSibling).is('br') &&
+ isInline(previousSibling, true)) {
+ content = '\n' + content;
+ }
+ }
+
+ return content;
+ };
+
+ /**
+ * Converts HTML to BBCode
+ *
+ * @param {String} html Html string, this function ignores this,
+ * it works off domBody
+ * @param {jQuery} $body Editors dom body object to convert
+ * @return {String} BBCode which has been converted from HTML
+ * @memberOf jQuery.plugins.bbcode.prototype
+ */
+ base.signalToSource = function (html, $body) {
+ var $tmpContainer, bbcode,
+ parser = new BBCodeParser(base.opts.parserOptions);
+
+ if (!$body) {
+ if (typeof html === 'string') {
+ $tmpContainer = $('<div />')
+ .css('visibility', 'hidden')
+ .appendTo(document.body)
+ .html(html);
+
+ $body = $tmpContainer;
+ } else {
+ $body = $(html);
+ }
+ }
+
+ if (!$body || !$body.jquery) {
+ return '';
+ }
+
+ SCEditor.dom.removeWhiteSpace($body[0]);
+
+ // Remove all the stuff that is meant to be ignored
+ $('.sceditor-ignore', $body).remove();
+
+ bbcode = base.elementToBbcode($body);
+
+ if ($tmpContainer) {
+ $tmpContainer.remove();
+ }
+
+ bbcode = parser.toBBCode(bbcode, true);
+
+ if (base.opts.bbcodeTrim) {
+ bbcode = $.trim(bbcode);
+ }
+
+ return bbcode;
+ };
+
+ /**
+ * Converts a HTML dom element to BBCode starting from
+ * the innermost element and working backwards
+ *
+ * @private
+ * @param {jQuery} $element The element to convert to BBCode
+ * @return {string} BBCode
+ * @memberOf jQuery.plugins.bbcode.prototype
+ */
+ base.elementToBbcode = function ($element) {
+ var toBBCode = function (node, vChildren) {
+ var ret = '';
+// TODO: Move to BBCode class?
+ SCEditor.dom.traverse(node, function (node) {
+ var $node = $(node),
+ curTag = '',
+ nodeType = node.nodeType,
+ tag = node.nodeName.toLowerCase(),
+ vChild = validChildren[tag],
+ firstChild = node.firstChild,
+ isValidChild = true;
+
+ if (typeof vChildren === 'object') {
+ isValidChild = $.inArray(tag, vChildren) > -1;
+
+ // Emoticons should always be converted
+ if ($node.is('img') &&
+ $node.data('sceditor-emoticon')) {
+ isValidChild = true;
+ }
+
+ // if this tag is one of the parents allowed children
+ // then set this tags allowed children to whatever it
+ // allows, otherwise set to what the parent allows
+ if (!isValidChild) {
+ vChild = vChildren;
+ }
+ }
+
+ // 3 = text and 1 = element
+ if (nodeType !== 3 && nodeType !== 1) {
+ return;
+ }
+
+ if (nodeType === 1) {
+ // skip empty nlf elements (new lines automatically
+ // added after block level elements like quotes)
+ if ($node.hasClass('sceditor-nlf')) {
+ if (!firstChild || (!IE_BR_FIX &&
+ node.childNodes.length === 1 &&
+ /br/i.test(firstChild.nodeName))) {
+ return;
+ }
+ }
+
+ // don't loop inside iframes
+ if (tag !== 'iframe') {
+ curTag = toBBCode(node, vChild);
+ }
+
+// TODO: isValidChild is no longer needed. Should use valid children bbcodes
+// instead by creating BBCode tokens like the parser.
+ if (isValidChild) {
+ // code tags should skip most styles
+ if (tag !== 'code') {
+ // handle inline bbcodes
+ curTag = handleStyles($node, curTag);
+ curTag = handleTags($node, curTag);
+
+ // handle blocklevel bbcodes
+ curTag = handleStyles($node, curTag, true);
+ }
+
+ ret += handleTags($node, curTag, true);
+ } else {
+ ret += curTag;
+ }
+ } else {
+ ret += node.nodeValue;
+ }
+ }, false, true);
+
+ return ret;
+ };
+
+ return toBBCode($element[0]);
+ };
+
+ /**
+ * Converts BBCode to HTML
+ *
+ * @param {String} text
+ * @param {Bool} asFragment
+ * @return {String} HTML
+ * @memberOf jQuery.plugins.bbcode.prototype
+ */
+ base.signalToWysiwyg = function (text, asFragment) {
+ var parser = new BBCodeParser(base.opts.parserOptions),
+ html = parser.toHTML(base.opts.bbcodeTrim ?
+ $.trim(text) : text);
+
+ return asFragment ? removeFirstLastDiv(html) : html;
+ };
+
+ /**
+ * Removes the first and last divs from the HTML.
+ *
+ * This is needed for pasting
+ * @param {String} html
+ * @return {String}
+ * @private
+ */
+ removeFirstLastDiv = function (html) {
+ var node, next, removeDiv,
+ $output = $('<div />').hide().appendTo(document.body),
+ output = $output[0];
+
+ removeDiv = function (node, isFirst) {
+ // Don't remove divs that have styling
+ if (SCEditor.dom.hasStyling(node)) {
+ return;
+ }
+
+ if (IE_BR_FIX || (node.childNodes.length !== 1 ||
+ !$(node.firstChild).is('br'))) {
+ while ((next = node.firstChild)) {
+ output.insertBefore(next, node);
+ }
+ }
+
+ if (isFirst) {
+ var lastChild = output.lastChild;
+
+ if (node !== lastChild && $(lastChild).is('div') &&
+ node.nextSibling === lastChild) {
+ output.insertBefore(document.createElement('br'), node);
+ }
+ }
+
+ output.removeChild(node);
+ };
+
+ output.innerHTML = html.replace(/<\/div>\n/g, '</div>');
+
+ if ((node = output.firstChild) && $(node).is('div')) {
+ removeDiv(node, true);
+ }
+
+ if ((node = output.lastChild) && $(node).is('div')) {
+ removeDiv(node);
+ }
+
+ output = output.innerHTML;
+ $output.remove();
+
+ return output;
+ };
+ };
+
+
+
+ /**
+ * Formats a string replacing {name} with the values of
+ * obj.name properties.
+ *
+ * If there is no property for the specified {name} then
+ * it will be left intact.
+ *
+ * @param {String} str
+ * @param {Object} obj
+ * @return {String}
+ * @since 1.4.5
+ */
+ sceditorPlugins.bbcode.formatBBCodeString = function (str, obj) {
+ return str.replace(/\{([^}]+)\}/g, function (match, group) {
+ var undef,
+ escape = true;
+
+ if (group.charAt(0) === '!') {
+ escape = false;
+ group = group.substring(1);
+ }
+
+ if (group === '0') {
+ escape = false;
+ }
+
+ if (obj[group] === undef) {
+ return match;
+ }
+
+ return escape ?
+ escapeEntities(obj[group], true) :
+ obj[group];
+ });
+ };
+
+ /**
+ * Converts a number 0-255 to hex.
+ *
+ * Will return 00 if number is not a valid number.
+ *
+ * @param {Number} number
+ * @return {String}
+ * @private
+ */
+ var toHex = function (number) {
+ number = parseInt(number, 10);
+
+ if (isNaN(number)) {
+ return '00';
+ }
+
+ number = Math.max(0, Math.min(number, 255)).toString(16);
+
+ return number.length < 2 ? '0' + number : number;
+ };
+
+ var _normaliseColour = function (colorStr) {
+ var match;
+
+ colorStr = colorStr || '#000';
+
+ // rgb(n,n,n);
+ if ((match =
+ colorStr.match(/rgb\((\d{1,3}),\s*?(\d{1,3}),\s*?(\d{1,3})\)/i))) {
+ return '#' +
+ toHex(match[1]) +
+ toHex(match[2] - 0) +
+ toHex(match[3] - 0);
+ }
+
+ // expand shorthand
+ if ((match = colorStr.match(/#([0-f])([0-f])([0-f])\s*?$/i))) {
+ return '#' +
+ match[1] + match[1] +
+ match[2] + match[2] +
+ match[3] + match[3];
+ }
+
+ return colorStr;
+ };
+
+ var bbcodes = {
+ // START_COMMAND: Bold
+ b: {
+ tags: {
+ b: null,
+ strong: null
+ },
+ styles: {
+ // 401 is for FF 3.5
+ 'font-weight': ['bold', 'bolder', '401', '700', '800', '900']
+ },
+ format: '[b]{0}[/b]',
+ html: '<strong>{0}</strong>'
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Italic
+ i: {
+ tags: {
+ i: null,
+ em: null
+ },
+ styles: {
+ 'font-style': ['italic', 'oblique']
+ },
+ format: '[i]{0}[/i]',
+ html: '<em>{0}</em>'
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Underline
+ u: {
+ tags: {
+ u: null
+ },
+ styles: {
+ 'text-decoration': ['underline']
+ },
+ format: '[u]{0}[/u]',
+ html: '<u>{0}</u>'
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Strikethrough
+ s: {
+ tags: {
+ s: null,
+ strike: null
+ },
+ styles: {
+ 'text-decoration': ['line-through']
+ },
+ format: '[s]{0}[/s]',
+ html: '<s>{0}</s>'
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Subscript
+ sub: {
+ tags: {
+ sub: null
+ },
+ format: '[sub]{0}[/sub]',
+ html: '<sub>{0}</sub>'
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Superscript
+ sup: {
+ tags: {
+ sup: null
+ },
+ format: '[sup]{0}[/sup]',
+ html: '<sup>{0}</sup>'
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Font
+ font: {
+ tags: {
+ font: {
+ face: null
+ }
+ },
+ styles: {
+ 'font-family': null
+ },
+ quoteType: BBCodeParser.QuoteType.never,
+ format: function ($element, content) {
+ var font;
+
+ if (!$element.is('font') || !(font = $element.attr('face'))) {
+ font = $element.css('font-family');
+ }
+
+ return '[font=' + _stripQuotes(font) + ']' +
+ content + '[/font]';
+ },
+ html: '<font face="{defaultattr}">{0}</font>'
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Size
+ size: {
+ tags: {
+ font: {
+ size: null
+ }
+ },
+ styles: {
+ 'font-size': null
+ },
+ format: function (element, content) {
+ var fontSize = element.attr('size'),
+ size = 2;
+
+ if (!fontSize) {
+ fontSize = element.css('fontSize');
+ }
+
+ // Most browsers return px value but IE returns 1-7
+ if (fontSize.indexOf('px') > -1) {
+ // convert size to an int
+ fontSize = fontSize.replace('px', '') - 0;
+
+ if (fontSize < 12) {
+ size = 1;
+ }
+ if (fontSize > 15) {
+ size = 3;
+ }
+ if (fontSize > 17) {
+ size = 4;
+ }
+ if (fontSize > 23) {
+ size = 5;
+ }
+ if (fontSize > 31) {
+ size = 6;
+ }
+ if (fontSize > 47) {
+ size = 7;
+ }
+ } else {
+ size = fontSize;
+ }
+
+ return '[size=' + size + ']' + content + '[/size]';
+ },
+ html: '<font size="{defaultattr}">{!0}</font>'
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Color
+ color: {
+ tags: {
+ font: {
+ color: null
+ }
+ },
+ styles: {
+ color: null
+ },
+ quoteType: BBCodeParser.QuoteType.never,
+ format: function ($element, content) {
+ var color;
+
+ if (!$element.is('font') || !(color = $element.attr('color'))) {
+ color = $element[0].style.color || $element.css('color');
+ }
+
+ return '[color=' + _normaliseColour(color) + ']' +
+ content + '[/color]';
+ },
+ html: function (token, attrs, content) {
+ return '<font color="' +
+ escapeEntities(_normaliseColour(attrs.defaultattr), true) +
+ '">' + content + '</font>';
+ }
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Lists
+ ul: {
+ tags: {
+ ul: null
+ },
+ breakStart: true,
+ isInline: false,
+ skipLastLineBreak: true,
+ format: '[ul]{0}[/ul]',
+ html: '<ul>{0}</ul>'
+ },
+ list: {
+ breakStart: true,
+ isInline: false,
+ skipLastLineBreak: true,
+ html: '<ul>{0}</ul>'
+ },
+ ol: {
+ tags: {
+ ol: null
+ },
+ breakStart: true,
+ isInline: false,
+ skipLastLineBreak: true,
+ format: '[ol]{0}[/ol]',
+ html: '<ol>{0}</ol>'
+ },
+ li: {
+ tags: {
+ li: null
+ },
+ isInline: false,
+ closedBy: ['/ul', '/ol', '/list', '*', 'li'],
+ format: '[li]{0}[/li]',
+ html: '<li>{0}</li>'
+ },
+ '*': {
+ isInline: false,
+ closedBy: ['/ul', '/ol', '/list', '*', 'li'],
+ html: '<li>{0}</li>'
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Table
+ table: {
+ tags: {
+ table: null
+ },
+ isInline: false,
+ isHtmlInline: true,
+ skipLastLineBreak: true,
+ format: '[table]{0}[/table]',
+ html: '<table>{0}</table>'
+ },
+ tr: {
+ tags: {
+ tr: null
+ },
+ isInline: false,
+ skipLastLineBreak: true,
+ format: '[tr]{0}[/tr]',
+ html: '<tr>{0}</tr>'
+ },
+ th: {
+ tags: {
+ th: null
+ },
+ allowsEmpty: true,
+ isInline: false,
+ format: '[th]{0}[/th]',
+ html: '<th>{0}</th>'
+ },
+ td: {
+ tags: {
+ td: null
+ },
+ allowsEmpty: true,
+ isInline: false,
+ format: '[td]{0}[/td]',
+ html: '<td>{0}</td>'
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Emoticons
+ emoticon: {
+ allowsEmpty: true,
+ tags: {
+ img: {
+ src: null,
+ 'data-sceditor-emoticon': null
+ }
+ },
+ format: function ($elm, content) {
+ return $elm.data('sceditor-emoticon') + content;
+ },
+ html: '{0}'
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Horizontal Rule
+ hr: {
+ tags: {
+ hr: null
+ },
+ allowsEmpty: true,
+ isSelfClosing: true,
+ isInline: false,
+ format: '[hr]{0}',
+ html: '<hr />'
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Image
+ img: {
+ allowsEmpty: true,
+ tags: {
+ img: {
+ src: null
+ }
+ },
+ allowedChildren: ['#'],
+ quoteType: BBCodeParser.QuoteType.never,
+ format: function ($element, content) {
+ var width, height,
+ attribs = '',
+ element = $element[0],
+ style = function (name) {
+ return element.style ? element.style[name] : null;
+ };
+
+ // check if this is an emoticon image
+ if ($element.attr('data-sceditor-emoticon')) {
+ return content;
+ }
+
+ width = $element.attr('width') || style('width');
+ height = $element.attr('height') || style('height');
+
+ // only add width and height if one is specified
+ if ((element.complete && (width || height)) ||
+ (width && height)) {
+ attribs = '=' + $element.width() + 'x' + $element.height();
+ }
+
+ return '[img' + attribs + ']' + $element.attr('src') + '[/img]';
+ },
+ html: function (token, attrs, content) {
+ var undef, width, height, match,
+ attribs = '';
+
+ // handle [img width=340 height=240]url[/img]
+ width = attrs.width;
+ height = attrs.height;
+
+ // handle [img=340x240]url[/img]
+ if (attrs.defaultattr) {
+ match = attrs.defaultattr.split(/x/i);
+
+ width = match[0];
+ height = (match.length === 2 ? match[1] : match[0]);
+ }
+
+ if (width !== undef) {
+ attribs += ' width="' + escapeEntities(width, true) + '"';
+ }
+
+ if (height !== undef) {
+ attribs += ' height="' + escapeEntities(height, true) + '"';
+ }
+
+ return '<img' + attribs +
+ ' src="' + escapeUriScheme(content) + '" />';
+ }
+ },
+ // END_COMMAND
+
+ // START_COMMAND: URL
+ url: {
+ allowsEmpty: true,
+ tags: {
+ a: {
+ href: null
+ }
+ },
+ quoteType: BBCodeParser.QuoteType.never,
+ format: function (element, content) {
+ var url = element.attr('href');
+
+ // make sure this link is not an e-mail,
+ // if it is return e-mail BBCode
+ if (url.substr(0, 7) === 'mailto:') {
+ return '[email="' + url.substr(7) + '"]' +
+ content + '[/email]';
+ }
+
+ return '[url=' + url + ']' + content + '[/url]';
+ },
+ html: function (token, attrs, content) {
+ attrs.defaultattr =
+ escapeEntities(attrs.defaultattr, true) || content;
+
+ return '<a href="' + escapeUriScheme(attrs.defaultattr) +
+ '">' + content + '</a>';
+ }
+ },
+ // END_COMMAND
+
+ // START_COMMAND: E-mail
+ email: {
+ quoteType: BBCodeParser.QuoteType.never,
+ html: function (token, attrs, content) {
+ return '<a href="mailto:' +
+ (escapeEntities(attrs.defaultattr, true) || content) +
+ '">' + content + '</a>';
+ }
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Quote
+ quote: {
+ tags: {
+ blockquote: null
+ },
+ isInline: false,
+ quoteType: BBCodeParser.QuoteType.never,
+ format: function (element, content) {
+ var author = '';
+ var $elm = $(element);
+ var $cite = $elm.children('cite').first();
+
+ if ($cite.length === 1 || $elm.data('author')) {
+ author = $cite.text() || $elm.data('author');
+
+ $elm.data('author', author);
+ $cite.remove();
+
+ content = this.elementToBbcode($(element));
+ author = '=' + author.replace(/(^\s+|\s+$)/g, '');
+
+ $elm.prepend($cite);
+ }
+
+ return '[quote' + author + ']' + content + '[/quote]';
+ },
+ html: function (token, attrs, content) {
+ if (attrs.defaultattr) {
+ content = '<cite>' + escapeEntities(attrs.defaultattr) +
+ '</cite>' + content;
+ }
+
+ return '<blockquote>' + content + '</blockquote>';
+ }
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Code
+ code: {
+ tags: {
+ code: null
+ },
+ isInline: false,
+ allowedChildren: ['#', '#newline'],
+ format: '[code]{0}[/code]',
+ html: '<code>{0}</code>'
+ },
+ // END_COMMAND
+
+
+ // START_COMMAND: Left
+ left: {
+ styles: {
+ 'text-align': [
+ 'left',
+ '-webkit-left',
+ '-moz-left',
+ '-khtml-left'
+ ]
+ },
+ isInline: false,
+ format: '[left]{0}[/left]',
+ html: '<div align="left">{0}</div>'
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Centre
+ center: {
+ styles: {
+ 'text-align': [
+ 'center',
+ '-webkit-center',
+ '-moz-center',
+ '-khtml-center'
+ ]
+ },
+ isInline: false,
+ format: '[center]{0}[/center]',
+ html: '<div align="center">{0}</div>'
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Right
+ right: {
+ styles: {
+ 'text-align': [
+ 'right',
+ '-webkit-right',
+ '-moz-right',
+ '-khtml-right'
+ ]
+ },
+ isInline: false,
+ format: '[right]{0}[/right]',
+ html: '<div align="right">{0}</div>'
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Justify
+ justify: {
+ styles: {
+ 'text-align': [
+ 'justify',
+ '-webkit-justify',
+ '-moz-justify',
+ '-khtml-justify'
+ ]
+ },
+ isInline: false,
+ format: '[justify]{0}[/justify]',
+ html: '<div align="justify">{0}</div>'
+ },
+ // END_COMMAND
+
+ // START_COMMAND: YouTube
+ youtube: {
+ allowsEmpty: true,
+ tags: {
+ iframe: {
+ 'data-youtube-id': null
+ }
+ },
+ format: function (element, content) {
+ element = element.attr('data-youtube-id');
+
+ return element ? '[youtube]' + element + '[/youtube]' : content;
+ },
+ html: '<iframe width="560" height="315" frameborder="0" ' +
+ 'src="https://www.youtube.com/embed/{0}?wmode=opaque" ' +
+ 'data-youtube-id="{0}" allowfullscreen></iframe>'
+ },
+ // END_COMMAND
+
+
+ // START_COMMAND: Rtl
+ rtl: {
+ styles: {
+ direction: ['rtl']
+ },
+ format: '[rtl]{0}[/rtl]',
+ html: '<div style="direction: rtl">{0}</div>'
+ },
+ // END_COMMAND
+
+ // START_COMMAND: Ltr
+ ltr: {
+ styles: {
+ direction: ['ltr']
+ },
+ format: '[ltr]{0}[/ltr]',
+ html: '<div style="direction: ltr">{0}</div>'
+ },
+ // END_COMMAND
+
+ // this is here so that commands above can be removed
+ // without having to remove the , after the last one.
+ // Needed for IE.
+ ignore: {}
+ };
+
+ /**
+ * Static BBCode helper class
+ * @class command
+ * @name jQuery.plugins.bbcode.bbcode
+ */
+ sceditorPlugins.bbcode.bbcode =
+ /** @lends jQuery.plugins.bbcode.bbcode */
+ {
+ /**
+ * Gets a BBCode
+ *
+ * @param {String} name
+ * @return {Object|null}
+ * @since v1.3.5
+ */
+ get: function (name) {
+ return bbcodes[name] || null;
+ },
+
+ /**
+ * <p>Adds a BBCode to the parser or updates an existing
+ * BBCode if a BBCode with the specified name already exists.</p>
+ *
+ * @param {String} name
+ * @param {Object} bbcode
+ * @return {this|false} Returns false if name or bbcode is false
+ * @since v1.3.5
+ */
+ set: function (name, bbcode) {
+ if (!name || !bbcode) {
+ return false;
+ }
+
+ // merge any existing command properties
+ bbcode = $.extend(bbcodes[name] || {}, bbcode);
+
+ bbcode.remove = function () {
+ delete bbcodes[name];
+ };
+
+ bbcodes[name] = bbcode;
+
+ return this;
+ },
+
+ /**
+ * Renames a BBCode
+ *
+ * This does not change the format or HTML handling, those must be
+ * changed manually.
+ *
+ * @param {String} name [description]
+ * @param {String} newName [description]
+ * @return {this|false}
+ * @since v1.4.0
+ */
+ rename: function (name, newName) {
+ if (name in bbcodes) {
+ bbcodes[newName] = bbcodes[name];
+
+ delete bbcodes[name];
+ } else {
+ return false;
+ }
+
+ return this;
+ },
+
+ /**
+ * Removes a BBCode
+ *
+ * @param {String} name
+ * @return {this}
+ * @since v1.3.5
+ */
+ remove: function (name) {
+ if (name in bbcodes) {
+ delete bbcodes[name];
+ }
+
+ return this;
+ }
+ };
+
+ /**
+ * Deprecated, use plugins: option instead. I.e.:
+ *
+ * $('textarea').sceditor({
+ * plugins: 'bbcode'
+ * });
+ *
+ * @deprecated
+ */
+ $.fn.sceditorBBCodePlugin = function (options) {
+ options = options || {};
+
+ if ($.isPlainObject(options)) {
+ options.plugins = (options.plugins || '') + 'bbcode';
+ }
+
+ return this.sceditor(options);
+ };
+
+ /**
+ * Converts CSS RGB and hex shorthand into hex
+ *
+ * @since v1.4.0
+ * @param {String} colorStr
+ * @return {String}
+ * @deprecated
+ */
+ sceditorPlugins.bbcode.normaliseColour = _normaliseColour;
+ sceditorPlugins.bbcode.formatString = _formatString;
+ sceditorPlugins.bbcode.stripQuotes = _stripQuotes;
+ sceditorPlugins.bbcode.bbcodes = bbcodes;
+ SCEditor.BBCodeParser = BBCodeParser;
+})(jQuery, window, document);
diff --git a/js/sceditor/development/plugins/format.js b/js/sceditor/development/plugins/format.js
new file mode 100644
index 00000000..15129957
--- /dev/null
+++ b/js/sceditor/development/plugins/format.js
@@ -0,0 +1,127 @@
+/**
+ * SCEditor Paragraph Formatting 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
+ *
+ * @fileoverview SCEditor Paragraph Formatting Plugin
+ * @author Sam Clarke
+ * @requires jQuery
+ */
+(function ($) {
+ 'use strict';
+
+ $.sceditor.plugins.format = function () {
+ var base = this;
+
+ /**
+ * Default tags
+ * @type {Object}
+ * @private
+ */
+ var tags = {
+ p: 'Paragraph',
+ h1: 'Heading 1',
+ h2: 'Heading 2',
+ h3: 'Heading 3',
+ h4: 'Heading 4',
+ h5: 'Heading 5',
+ h6: 'Heading 6',
+ address: 'Address',
+ pre: 'Preformatted Text'
+ };
+
+ /**
+ * Private functions
+ * @private
+ */
+ var insertTag,
+ formatCmd;
+
+
+ base.init = function () {
+ var opts = this.opts,
+ pOpts = opts.paragraphformat;
+
+ // Don't enable if the BBCode plugin is enabled.
+ if (opts.plugins && opts.plugins.indexOf('bbcode') > -1) {
+ return;
+ }
+
+ if (pOpts) {
+ if (pOpts.tags) {
+ tags = pOpts.tags;
+ }
+
+ if (pOpts.excludeTags) {
+ $.each(pOpts.excludeTags, function (idx, val) {
+ delete tags[val];
+ });
+ }
+ }
+
+ if (!this.commands.format) {
+ this.commands.format = {
+ exec: formatCmd,
+ txtExec: formatCmd,
+ tooltip: 'Format Paragraph'
+ };
+ }
+
+ if (opts.toolbar === $.sceditor.defaultOptions.toolbar) {
+ opts.toolbar = opts.toolbar.replace(',color,',
+ ',color,format,');
+ }
+ };
+
+ /**
+ * Inserts the specified tag into the editor
+ *
+ * @param {sceditor} editor
+ * @param {string} tag
+ * @private
+ */
+ insertTag = function (editor, tag) {
+ if (editor.sourceMode()) {
+ editor.insert('<' + tag + '>', '</' + tag + '>');
+ } else {
+ editor.execCommand('formatblock', '<' + tag + '>');
+ }
+
+ };
+
+ /**
+ * Function for the exec and txtExec properties
+ *
+ * @param {node} caller
+ * @private
+ */
+ formatCmd = function (caller) {
+ var editor = this,
+ $content = $('<div />');
+
+ $.each(tags, function (tag, val) {
+ $(
+ '<a class="sceditor-option" href="#">' +
+ (val.name || val) + '</a>'
+ ).click(function () {
+ editor.closeDropDown(true);
+
+ if (val.exec) {
+ val.exec(editor);
+ } else {
+ insertTag(editor, tag);
+ }
+
+ return false;
+ })
+ .appendTo($content);
+ });
+
+ editor.createDropDown(caller, 'format', $content);
+ };
+ };
+})(jQuery);
diff --git a/js/sceditor/development/plugins/undo.js b/js/sceditor/development/plugins/undo.js
new file mode 100644
index 00000000..7abedbf4
--- /dev/null
+++ b/js/sceditor/development/plugins/undo.js
@@ -0,0 +1,187 @@
+(function ($) {
+ 'use strict';
+
+ $.sceditor.plugins.undo = function () {
+ var base = this;
+ var editor;
+ var charChangedCount = 0;
+ var previousValue;
+
+ var undoLimit = 50;
+ var redoStates = [];
+ var undoStates = [];
+ var ignoreNextValueChanged = false;
+
+ /**
+ * Sets the editor to the specified state.
+ *
+ * @param {Object} state
+ * @private
+ */
+ var applyState = function (state) {
+ ignoreNextValueChanged = true;
+
+ previousValue = state.value;
+
+ editor.sourceMode(state.sourceMode);
+ editor.val(state.value, false);
+ editor.focus();
+
+ if (state.sourceMode) {
+ editor.sourceEditorCaret(state.caret);
+ } else {
+ editor.getRangeHelper().restoreRange();
+ }
+
+ ignoreNextValueChanged = false;
+ };
+
+
+ /**
+ * Caluclates the number of characters that have changed
+ * between two strings.
+ *
+ * @param {String} strA
+ * @param {String} strB
+ * @return {String}
+ * @private
+ */
+ var simpleDiff = function (strA, strB) {
+ var start, end, aLenDiff, bLenDiff,
+ aLength = strA.length,
+ bLength = strB.length,
+ length = Math.max(aLength, bLength);
+
+ // Calculate the start
+ for (start = 0; start < length; start++) {
+ if (strA.charAt(start) !== strB.charAt(start)) {
+ break;
+ }
+ }
+
+ // Calculate the end
+ aLenDiff = aLength < bLength ? bLength - aLength : 0;
+ bLenDiff = bLength < aLength ? aLength - bLength : 0;
+
+ for (end = length - 1; end >= 0; end--) {
+ if (strA.charAt(end - aLenDiff) !==
+ strB.charAt(end - bLenDiff)) {
+ break;
+ }
+ }
+
+ return (end - start) + 1;
+ };
+
+ base.init = function () {
+ // The this variable will be set to the instance of the editor
+ // calling it, hence why the plugins "this" is saved to the base
+ // variable.
+ editor = this;
+
+ undoLimit = editor.undoLimit || undoLimit;
+
+ // addShortcut is the easiest way to add handlers to specific
+ // shortcuts
+ editor.addShortcut('ctrl+z', base.undo);
+ editor.addShortcut('ctrl+shift+z', base.redo);
+ editor.addShortcut('ctrl+y', base.redo);
+ };
+
+ base.undo = function () {
+ var state = undoStates.pop();
+ var rawEditorValue = editor.val(null, false);
+
+ if (state && !redoStates.length && rawEditorValue === state.value) {
+ state = undoStates.pop();
+ }
+
+ if (state) {
+ if (!redoStates.length) {
+ redoStates.push({
+ 'caret': editor.sourceEditorCaret(),
+ 'sourceMode': editor.sourceMode(),
+ 'value': rawEditorValue
+ });
+ }
+
+ redoStates.push(state);
+ applyState(state);
+ }
+
+ return false;
+ };
+
+ base.redo = function () {
+ var state = redoStates.pop();
+
+ if (!undoStates.length) {
+ undoStates.push(state);
+ state = redoStates.pop();
+ }
+
+ if (state) {
+ undoStates.push(state);
+ applyState(state);
+ }
+
+ return false;
+ };
+
+ base.signalReady = function () {
+ var rawValue = editor.val(null, false);
+
+ // Store the initial value as the last value
+ previousValue = rawValue;
+
+ undoStates.push({
+ 'caret': this.sourceEditorCaret(),
+ 'sourceMode': this.sourceMode(),
+ 'value': rawValue
+ });
+ };
+
+ /**
+ * Handle the valueChanged signal.
+ *
+ * e.rawValue will either be the raw HTML from the WYSIWYG editor with
+ * the rangeHelper range markers inserted, or it will be the raw value
+ * of the source editor (BBCode or HTML depening on plugins).
+ * @return {void}
+ */
+ base.signalValuechangedEvent = function (e) {
+ var rawValue = e.rawValue;
+
+ if (undoLimit > 0 && undoStates.length > undoLimit) {
+ undoStates.shift();
+ }
+
+ // If the editor hasn't fully loaded yet,
+ // then the previous value won't be set.
+ if (ignoreNextValueChanged || !previousValue ||
+ previousValue === rawValue) {
+ return;
+ }
+
+ // Value has changed so remove all redo states
+ redoStates.length = 0;
+ charChangedCount += simpleDiff(previousValue, rawValue);
+
+ if (charChangedCount < 20) {
+ return;
+ // ??
+ } else if (charChangedCount < 50 && !/\s$/g.test(e.rawValue)) {
+ return;
+ }
+
+ undoStates.push({
+ 'caret': editor.sourceEditorCaret(),
+ 'sourceMode': editor.sourceMode(),
+ 'value': rawValue
+ });
+
+ charChangedCount = 0;
+ previousValue = rawValue;
+ };
+ };
+}(jQuery));
diff --git a/js/sceditor/development/plugins/xhtml.js b/js/sceditor/development/plugins/xhtml.js
new file mode 100644
index 00000000..7ba03e5f
--- /dev/null
+++ b/js/sceditor/development/plugins/xhtml.js
@@ -0,0 +1,1319 @@
+/**
+ * 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: [
+ '<strong>',
+ '</strong>'
+ ]
+ },
+ italic: {
+ txtExec: [
+ '<em>',
+ '</em>'
+ ]
+ },
+ underline: {
+ txtExec: [
+ '<span style="text-decoration: underline;">',
+ '</span>'
+ ]
+ },
+ strike: {
+ txtExec: [
+ '<span style="text-decoration: line-through;">',
+ '</span>'
+ ]
+ },
+ subscript: {
+ txtExec: [
+ '<sub>',
+ '</sub>'
+ ]
+ },
+ superscript: {
+ txtExec: [
+ '<sup>',
+ '</sup>'
+ ]
+ },
+ left: {
+ txtExec: [
+ '<div style="text-align: left;">',
+ '</div>'
+ ]
+ },
+ center: {
+ txtExec: [
+ '<div style="text-align: center;">',
+ '</div>'
+ ]
+ },
+ right: {
+ txtExec: [
+ '<div style="text-align: right;">',
+ '</div>'
+ ]
+ },
+ justify: {
+ txtExec: [
+ '<div style="text-align: justify;">',
+ '</div>'
+ ]
+ },
+ font: {
+ txtExec: function (caller) {
+ var editor = this;
+
+ SCEditor.command.get('font')._dropDown(
+ editor,
+ caller,
+ function (fontName) {
+ editor.insertText('<span style="font-family: ' +
+ fontName + ';">', '</span>');
+ }
+ );
+ }
+ },
+ size: {
+ txtExec: function (caller) {
+ var editor = this;
+
+ SCEditor.command.get('size')._dropDown(
+ editor,
+ caller,
+ function (fontSize) {
+ editor.insertText('<span style="font-size: ' +
+ fontSize + ';">', '</span>');
+ }
+ );
+ }
+ },
+ color: {
+ txtExec: function (caller) {
+ var editor = this;
+
+ SCEditor.command.get('color')._dropDown(
+ editor,
+ caller,
+ function (color) {
+ editor.insertText('<span style="color: ' +
+ color + ';">', '</span>');
+ }
+ );
+ }
+ },
+ bulletlist: {
+ txtExec: [
+ '<ul><li>',
+ '</li></ul>'
+ ]
+ },
+ orderedlist: {
+ txtExec: [
+ '<ol><li>',
+ '</li></ol>'
+ ]
+ },
+ table: {
+ txtExec: [
+ '<table><tr><td>',
+ '</td></tr></table>'
+ ]
+ },
+ horizontalrule: {
+ txtExec: [
+ '<hr />'
+ ]
+ },
+ code: {
+ txtExec: [
+ '<code>',
+ '</code>'
+ ]
+ },
+ image: {
+ txtExec: function (caller, selected) {
+ var url = prompt(this._('Enter the image URL:'), selected);
+
+ if (url) {
+ this.insertText('<img src="' + url + '" />');
+ }
+ }
+ },
+ 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(
+ '<a href="mailto:' + email + '">' + text + '</a>'
+ );
+ }
+ }
+ },
+ 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(
+ '<a href="' + url + '">' + text + '</a>'
+ );
+ }
+ }
+ },
+ quote: {
+ txtExec: [
+ '<blockquote>',
+ '</blockquote>'
+ ]
+ },
+ youtube: {
+ txtExec: function (caller) {
+ var editor = this;
+
+ SCEditor.command.get('youtube')._dropDown(
+ editor,
+ caller,
+ function (id) {
+ editor.insertText(
+ '<iframe width="560" height="315" ' +
+ 'src="https://www.youtube.com/embed/{id}?' +
+ 'wmode=opaque" data-youtube-id="' + id + '" ' +
+ 'frameborder="0" allowfullscreen></iframe>'
+ );
+ }
+ );
+ }
+ },
+ rtl: {
+ txtExec: [
+ '<div stlye="direction: rtl;">',
+ '</div>'
+ ]
+ },
+ ltr: {
+ txtExec: [
+ '<div stlye="direction: ltr;">',
+ '</div>'
+ ]
+ }
+ };
+
+ /**
+ * 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 = {
+ '&': '&amp;',
+ '<': '&lt;',
+ '>': '&gt;',
+ '"': '&quot;'
+ };
+
+ 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(
+ '</' + tagName + '>',
+ !isPre && !isIframe && canIndent(node) &&
+ firstChild && canIndent(firstChild)
+ );
+ }
+ };
+
+ /**
+ * Handles CDATA nodes
+ * @param {Node} node
+ * @return {void}
+ * @private
+ */
+ handleCdata = function (node) {
+ output('<![CDATA[' + escapeEntites(node.nodeValue) + ']]>');
+ };
+
+ /**
+ * Handles comment nodes
+ * @param {Node} node
+ * @return {void}
+ * @private
+ */
+ handleComment = function (node) {
+ output('<!-- ' + escapeEntites(node.nodeValue) + ' -->');
+ };
+
+ /**
+ * 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) {
+ $('<p>', 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));