Mercurial Hosting > sceditor
diff src/development/formats/bbcode.js @ 0:4c4fc447baea
start with sceditor-3.1.1
author | Franklin Schmidt <fschmidt@gmail.com> |
---|---|
date | Thu, 04 Aug 2022 15:21:29 -0600 |
parents | |
children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/development/formats/bbcode.js Thu Aug 04 15:21:29 2022 -0600 @@ -0,0 +1,2686 @@ +/** + * SCEditor BBCode Plugin + * http://www.sceditor.com/ + * + * Copyright (C) 2011-2017, Sam Clarke (samclarke.com) + * + * SCEditor is licensed under the MIT license: + * http://www.opensource.org/licenses/mit-license.php + * + * @fileoverview SCEditor BBCode Format + * @author Sam Clarke + */ +(function (sceditor) { + /*eslint max-depth: off*/ + 'use strict'; + + var escapeEntities = sceditor.escapeEntities; + var escapeUriScheme = sceditor.escapeUriScheme; + var dom = sceditor.dom; + var utils = sceditor.utils; + + var css = dom.css; + var attr = dom.attr; + var is = dom.is; + var extend = utils.extend; + var each = utils.each; + + var EMOTICON_DATA_ATTR = 'data-sceditor-emoticon'; + + var getEditorCommand = sceditor.command.get; + + var QuoteType = { + /** @lends 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 + }; + + 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) { + this.insertText( + '[ul]\n[li]' + + selected.split(/\r?\n/).join('[/li]\n[li]') + + '[/li]\n[/ul]' + ); + } + }, + orderedlist: { + txtExec: function (caller, selected) { + this.insertText( + '[ol]\n[li]' + + selected.split(/\r?\n/).join('[/li]\n[li]') + + '[/li]\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; + + getEditorCommand('image')._dropDown( + editor, + caller, + selected, + function (url, width, height) { + var attrs = ''; + + if (width) { + attrs += ' width=' + width; + } + + if (height) { + attrs += ' height=' + height; + } + + editor.insertText( + '[img' + attrs + ']' + url + '[/img]' + ); + } + ); + } + }, + email: { + txtExec: function (caller, selected) { + var editor = this; + + getEditorCommand('email')._dropDown( + editor, + caller, + function (url, text) { + editor.insertText( + '[email=' + url + ']' + + (text || selected || url) + + '[/email]' + ); + } + ); + } + }, + link: { + txtExec: function (caller, selected) { + var editor = this; + + getEditorCommand('link')._dropDown( + editor, + caller, + function (url, text) { + editor.insertText( + '[url=' + url + ']' + + (text || selected || url) + + '[/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]'] + } + }; + + var bbcodeHandlers = { + // 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: QuoteType.never, + format: function (element, content) { + var font; + + if (!is(element, 'font') || !(font = attr(element, 'face'))) { + font = css(element, '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 = attr(element, 'size'), + size = 2; + + if (!fontSize) { + fontSize = css(element, '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: QuoteType.never, + format: function (elm, content) { + var color; + + if (!is(elm, 'font') || !(color = attr(elm, 'color'))) { + color = elm.style.color || css(elm, '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 (element, content) { + return attr(element, EMOTICON_DATA_ATTR) + 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: QuoteType.never, + format: function (element, content) { + var width, height, + attribs = '', + style = function (name) { + return element.style ? element.style[name] : null; + }; + + // check if this is an emoticon image + if (attr(element, EMOTICON_DATA_ATTR)) { + return content; + } + + width = attr(element, 'width') || style('width'); + height = attr(element, 'height') || style('height'); + + // only add width and height if one is specified + if ((element.complete && (width || height)) || + (width && height)) { + + attribs = '=' + dom.width(element) + 'x' + + dom.height(element); + } + + return '[img' + attribs + ']' + attr(element, '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: QuoteType.never, + format: function (element, content) { + var url = attr(element, '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: 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: QuoteType.never, + format: function (element, content) { + var authorAttr = 'data-author'; + var author = ''; + var cite; + var children = element.children; + + for (var i = 0; !cite && i < children.length; i++) { + if (is(children[i], 'cite')) { + cite = children[i]; + } + } + + if (cite || attr(element, authorAttr)) { + author = cite && cite.textContent || + attr(element, authorAttr); + + attr(element, authorAttr, author); + + if (cite) { + element.removeChild(cite); + } + + content = this.elementToBbcode(element); + author = '=' + author.replace(/(^\s+|\s+$)/g, ''); + + if (cite) { + element.insertBefore(cite, element.firstChild); + } + } + + 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, + allowsEmpty: true, + 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, + allowsEmpty: true, + 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, + allowsEmpty: true, + 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, + allowsEmpty: true, + 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 = attr(element, 'data-youtube-id'); + + return element ? '[youtube]' + element + '[/youtube]' : content; + }, + html: '<iframe width="560" height="315" frameborder="0" ' + + 'src="https://www.youtube-nocookie.com/embed/{0}?wmode=opaque" ' + + 'data-youtube-id="{0}" allowfullscreen></iframe>' + }, + // END_COMMAND + + + // START_COMMAND: Rtl + rtl: { + styles: { + direction: ['rtl'] + }, + isInline: false, + format: '[rtl]{0}[/rtl]', + html: '<div style="direction: rtl">{0}</div>' + }, + // END_COMMAND + + // START_COMMAND: Ltr + ltr: { + styles: { + direction: ['ltr'] + }, + isInline: false, + 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: {} + }; + + /** + * 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 2.0.0 + */ + function formatBBCodeString(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]; + }); + } + + /** + * Removes the first and last divs from the HTML. + * + * This is needed for pasting + * @param {string} html + * @return {string} + * @private + */ + function removeFirstLastDiv(html) { + var node, next, removeDiv, + output = document.createElement('div'); + + removeDiv = function (node, isFirst) { + // Don't remove divs that have styling + if (dom.hasStyling(node)) { + return; + } + + if ((node.childNodes.length !== 1 || + !is(node.firstChild, 'br'))) { + while ((next = node.firstChild)) { + output.insertBefore(next, node); + } + } + + if (isFirst) { + var lastChild = output.lastChild; + + if (node !== lastChild && is(lastChild, 'div') && + node.nextSibling === lastChild) { + output.insertBefore(document.createElement('br'), node); + } + } + + output.removeChild(node); + }; + + css(output, 'display', 'none'); + output.innerHTML = html.replace(/<\/div>\n/g, '</div>'); + + if ((node = output.firstChild) && is(node, 'div')) { + removeDiv(node, true); + } + + if ((node = output.lastChild) && is(node, 'div')) { + removeDiv(node); + } + + return output.innerHTML; + } + + function isFunction(fn) { + return typeof fn === 'function'; + } + + /** + * Removes any leading or trailing quotes ('") + * + * @return string + * @since v1.4.0 + */ + function _stripQuotes(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} arg The strings to replace + * @return {string} + * @since v1.4.0 + */ + function _formatString(str) { + var undef; + var args = arguments; + + return str.replace(/\{(\d+)\}/g, function (_, matchNum) { + return args[matchNum - 0 + 1] !== undef ? + args[matchNum - 0 + 1] : + '{' + matchNum + '}'; + }); + } + + var TOKEN_OPEN = 'open'; + var TOKEN_CONTENT = 'content'; + var TOKEN_NEWLINE = 'newline'; + var TOKEN_CLOSE = 'close'; + + + /* + * @typedef {Object} TokenizeToken + * @property {string} type + * @property {string} name + * @property {string} val + * @property {Object.<string, string>} attrs + * @property {array} children + * @property {TokenizeToken} closing + */ + + /** + * 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 + * TOKEN_TYPE_OPEN tokens + * @param {array} children Any children of this token + * @param {TokenizeToken} closing This tokens closing tag. + * Only set on TOKEN_TYPE_OPEN tokens + * @class {TokenizeToken} + * @name {TokenizeToken} + * @memberOf BBCodeParser.prototype + */ + // eslint-disable-next-line max-params + function TokenizeToken(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 BBCodeParser.prototype.TokenizeToken */ + /** + * Clones this token + * + * @return {TokenizeToken} + */ + clone: function () { + var base = this; + + return new TokenizeToken( + base.type, + base.name, + base.val, + extend({}, base.attrs), + [], + base.closing ? base.closing.clone() : null + ); + }, + /** + * Splits this token at the specified child + * + * @param {TokenizeToken} splitAt The child to split at + * @return {TokenizeToken} The right half of the split token or + * empty clone if invalid splitAt lcoation + */ + splitAt: function (splitAt) { + var offsetLength; + var base = this; + var clone = base.clone(); + var offset = base.children.indexOf(splitAt); + + if (offset > -1) { + // Work out how many items are on the right side of the split + // to pass to splice() + offsetLength = base.children.length - offset; + clone.children = base.children.splice(offset, offsetLength); + } + + return clone; + } + }; + + + /** + * SCEditor BBCode parser class + * + * @param {Object} options + * @class BBCodeParser + * @name BBCodeParser + * @since v1.4.0 + */ + function BBCodeParser(options) { + var base = this; + + 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 BBCodeParser.prototype + */ + base.tokenize = function (str) { + var matches, type, i; + var tokens = []; + // The token types in reverse order of precedence + // (they're looped in reverse) + var tokenTypes = [ + { + type: TOKEN_CONTENT, + regex: /^([^\[\r\n]+|\[)/ + }, + { + type: TOKEN_NEWLINE, + regex: /^(\r\n|\r|\n)/ + }, + { + type: TOKEN_OPEN, + regex: /^\[[^\[\]]+\]/ + }, + // Close must come before open as they are + // the same except close has a / at the start. + { + type: TOKEN_CLOSE, + regex: /^\[\/[^\[\]]+\]/ + } + ]; + + strloop: + while (str.length) { + i = tokenTypes.length; + while (i--) { + type = tokenTypes[i].type; + + // Check if the string matches any of the tokens + if (!(matches = str.match(tokenTypes[i].regex)) || + !matches[0]) { + continue; + } + + // Add the match to the tokens list + tokens.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) { + tokens.push(tokenizeTag(TOKEN_CONTENT, str)); + } + + str = ''; + } + + return tokens; + }; + + /** + * Extracts the name an params from a tag + * + * @param {string} type + * @param {string} val + * @return {Object} + * @private + */ + function tokenizeTag(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 === TOKEN_OPEN && (matches = val.match(openRegex))) { + name = lower(matches[1]); + + if (matches[2] && (matches[2] = matches[2].trim())) { + attrs = tokenizeAttrs(matches[2]); + } + } + + if (type === TOKEN_CLOSE && + (matches = val.match(closeRegex))) { + name = lower(matches[1]); + } + + if (type === TOKEN_NEWLINE) { + name = '#newline'; + } + + // Treat all tokens without a name and + // all unknown BBCodes as content + if (!name || ((type === TOKEN_OPEN || type === TOKEN_CLOSE) && + !bbcodeHandlers[name])) { + + type = TOKEN_CONTENT; + name = '#'; + } + + return new TokenizeToken(type, name, val, attrs); + } + + /** + * Extracts the individual attributes from a string containing + * all the attributes. + * + * @param {string} attrs + * @return {Object} Assoc array of attributes + * @private + */ + function tokenizeAttrs(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 BBCodeParser.prototype + */ + base.parse = function (str, preserveNewLines) { + var ret = parseTokens(base.tokenize(str)); + var opts = base.opts; + + 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 {string} type + * @param {array} arr + * @return {Boolean} + * @private + */ + function hasTag(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 + */ + function isChildAllowed(parent, child) { + var parentBBCode = parent ? bbcodeHandlers[parent.name] : {}, + allowedChildren = parentBBCode.allowedChildren; + + if (base.opts.fixInvalidChildren && allowedChildren) { + return allowedChildren.indexOf(child.name || '#') > -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 + */ + function parseTokens(toks) { + var token, bbcode, curTok, clone, i, next, + cloned = [], + output = [], + openTags = [], + /** + * Returns the currently open tag or undefined + * @return {TokenizeToken} + */ + currentTag = 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 (currentTag()) { + currentTag().children.push(token); + } else { + output.push(token); + } + }, + /** + * Checks if this tag closes the current tag + * @param {string} name + * @return {Void} + */ + closesCurrentTag = function (name) { + return currentTag() && + (bbcode = bbcodeHandlers[currentTag().name]) && + bbcode.closedBy && + bbcode.closedBy.indexOf(name) > -1; + }; + + while ((token = toks.shift())) { + next = toks[0]; + + /* + * 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> + */ + // Ignore tags that can't be children + if (!isChildAllowed(currentTag(), token)) { + + // exclude closing tags of current tag + if (token.type !== TOKEN_CLOSE || !currentTag() || + token.name !== currentTag().name) { + token.name = '#'; + token.type = TOKEN_CONTENT; + } + } + + switch (token.type) { + case TOKEN_OPEN: + // Check it this closes a parent, + // e.g. for lists [*]one [*]two + if (closesCurrentTag(token.name)) { + openTags.pop(); + } + + addTag(token); + bbcode = bbcodeHandlers[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, TOKEN_CLOSE, toks))) { + openTags.push(token); + } else if (!bbcode || !bbcode.isSelfClosing) { + token.type = TOKEN_CONTENT; + } + break; + + case TOKEN_CLOSE: + // check if this closes the current tag, + // e.g. [/list] would close an open [*] + if (currentTag() && token.name !== currentTag().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 (currentTag() && token.name === currentTag().name) { + currentTag().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, TOKEN_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); + } + + // Place block linebreak before cloned tags + if (next && next.type === TOKEN_NEWLINE) { + bbcode = bbcodeHandlers[token.name]; + if (bbcode && bbcode.isInline === false) { + addTag(next); + toks.shift(); + } + } + + // 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 = TOKEN_CONTENT; + addTag(token); + } + break; + + case TOKEN_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 (currentTag() && next && closesCurrentTag( + (next.type === TOKEN_CLOSE ? '/' : '') + + next.name + )) { + // skip if the next tag is the closing tag for + // the option tag, i.e. [/*] + if (!(next.type === TOKEN_CLOSE && + next.name === currentTag().name)) { + bbcode = bbcodeHandlers[currentTag().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; + } + } + + 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 {boolean} onlyRemoveBreakAfter + * @return {void} + */ + function normaliseNewLines(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 = bbcodeHandlers[parent.name]; + } + + var i = childrenLength; + while (i--) { + if (!(token = children[i])) { + continue; + } + + if (token.type === TOKEN_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 === TOKEN_OPEN) { + if ((bbcode = bbcodeHandlers[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 === TOKEN_OPEN) { + + if ((bbcode = bbcodeHandlers[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 === TOKEN_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 {boolea} [insideInline] If inside an inline element + * @param {array} [rootArr] Root array if there is one + * @return {array} + * @private + */ + function fixNesting(children, parents, insideInline, rootArr) { + var token, i, parent, parentIndex, parentParentChildren, right; + + var isInline = function (token) { + var bbcode = bbcodeHandlers[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 !== TOKEN_OPEN) { + continue; + } + + if (insideInline && !isInline(token)) { + // 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; + + // If parent inline is allowed inside this tag, clone it and + // wrap this tags children in it. + if (isChildAllowed(token, parent)) { + var clone = parent.clone(); + clone.children = token.children; + token.children = [clone]; + } + + parentIndex = parentParentChildren.indexOf(parent); + if (parentIndex > -1) { + // remove the block level token from the right side of + // the split inline element + right.children.splice(0, 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 + ); + + // If token is a block and is followed by a newline, + // then move the newline along with it to the new parent + var next = right.children[0]; + if (next && next.type === TOKEN_NEWLINE) { + if (!isInline(token)) { + right.children.splice(0, 1); + parentParentChildren.splice( + parentIndex + 2, 0, next + ); + } + } + + // return to parents loop as the + // children have now increased + return; + } + + } + + parents.push(token); + + fixNesting( + token.children, + parents, + insideInline || isInline(token), + rootArr + ); + + parents.pop(); + } + } + + /** + * Removes any empty BBCodes which are not allowed to be empty. + * + * @param {array} tokens + * @private + */ + function removeEmpty(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 === TOKEN_OPEN || type === TOKEN_CLOSE) { + return false; + } + + if (type === TOKEN_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 !== TOKEN_OPEN) { + continue; + } + + bbcode = bbcodeHandlers[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, [i, 1].concat(token.children)); + } + } + } + + /** + * Converts a BBCode string to HTML + * + * @param {string} str + * @param {boolean} preserveNewLines If to preserve all new lines, not + * strip any based on the passed + * formatting options + * @return {string} + * @memberOf BBCodeParser.prototype + */ + base.toHTML = function (str, preserveNewLines) { + return convertToHTML(base.parse(str, preserveNewLines), true); + }; + + /** + * @private + */ + function convertToHTML(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 === TOKEN_OPEN) { + lastChild = token.children[token.children.length - 1] || {}; + bbcode = bbcodeHandlers[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(bbcodeHandlers[lastChild.name]) && + !bbcode.isPreFormatted && + !bbcode.skipLastLineBreak) { + // Add placeholder br to end of block level + // elements + content += '<br />'; + } + + if (!isFunction(bbcode.html)) { + token.attrs['0'] = content; + html = 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 === TOKEN_NEWLINE) { + if (!isRoot) { + ret += '<br />'; + continue; + } + + // If not already in a block wrap then start a new block + if (!blockWrapOpen) { + ret += '<div>'; + } + + ret += '<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 += '<br />'; + } + + ret += '</div>\n'; + blockWrapOpen = false; + continue; + // content + } else { + needsBlockWrap = isRoot; + html = escapeEntities(token.val, true); + } + + if (needsBlockWrap && !blockWrapOpen) { + ret += '<div>'; + blockWrapOpen = true; + } else if (!needsBlockWrap && blockWrapOpen) { + ret += '</div>\n'; + blockWrapOpen = false; + } + + ret += html; + } + + if (blockWrapOpen) { + ret += '</div>\n'; + } + + return ret; + } + + /** + * 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 {boolean} preserveNewLines If to preserve all new lines, not + * strip any based on the passed + * formatting options + * @return {string} + * @memberOf 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 + */ + function convertToBBCode(toks) { + var token, attr, bbcode, isBlock, isSelfClosing, quoteType, + breakBefore, breakStart, breakEnd, breakAfter, + ret = ''; + + while (toks.length > 0) { + if (!(token = toks.shift())) { + continue; + } + // TODO: tidy this + bbcode = bbcodeHandlers[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 || QuoteType.auto; + + if (!bbcode && token.type === TOKEN_OPEN) { + ret += token.val; + + if (token.children) { + ret += convertToBBCode(token.children); + } + + if (token.closing) { + ret += token.closing.val; + } + } else if (token.type === TOKEN_OPEN) { + if (breakBefore) { + ret += '\n'; + } + + // Convert the tag and it's attributes to BBCode + ret += '[' + token.name; + if (token.attrs) { + if (token.attrs.defaultattr) { + ret += '=' + quote( + token.attrs.defaultattr, + quoteType, + 'defaultattr' + ); + + delete token.attrs.defaultattr; + } + + for (attr in token.attrs) { + if (token.attrs.hasOwnProperty(attr)) { + ret += ' ' + attr + '=' + + quote(token.attrs[attr], quoteType, attr); + } + } + } + ret += ']'; + + if (breakStart) { + ret += '\n'; + } + + // Convert the tags children to BBCode + if (token.children) { + ret += convertToBBCode(token.children); + } + + // add closing tag if not self closing + if (!isSelfClosing && !bbcode.excludeClosing) { + if (breakEnd) { + ret += '\n'; + } + + ret += '[/' + token.name + ']'; + } + + if (breakAfter) { + ret += '\n'; + } + + // preserve whatever was recognized as the + // closing tag if it is a self closing tag + if (token.closing && isSelfClosing) { + ret += token.closing.val; + } + } else { + ret += token.val; + } + } + + return ret; + } + + /** + * Quotes an attribute + * + * @param {string} str + * @param {BBCodeParser.QuoteType} quoteType + * @param {string} name + * @return {string} + * @private + */ + function quote(str, quoteType, name) { + var needsQuotes = /\s|=/.test(str); + + if (isFunction(quoteType)) { + return quoteType(str, name); + } + + if (quoteType === QuoteType.never || + (quoteType === QuoteType.auto && !needsQuotes)) { + return str; + } + + return '"' + str.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; + } + + /** + * Returns the last element of an array or null + * + * @param {array} arr + * @return {Object} Last element + * @private + */ + function last(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 + */ + function lower(str) { + return str.toLowerCase(); + } + }; + + /** + * Quote type + * @type {Object} + * @class QuoteType + * @name BBCodeParser.QuoteType + * @since 1.4.0 + */ + BBCodeParser.QuoteType = QuoteType; + + /** + * 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: QuoteType.auto, + + /** + * Whether to use strict matching on attributes and styles. + * + * When true this will perform AND matching requiring all tag + * attributes and styles to match. + * + * When false will perform OR matching and will match if any of + * a tags attributes or styles match. + * + * @type {Boolean} + * @since 3.1.0 + */ + strictMatch: false + }; + + /** + * Converts a number 0-255 to hex. + * + * Will return 00 if number is not a valid number. + * + * @param {any} number + * @return {string} + * @private + */ + function toHex(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; + } + /** + * Normalises a CSS colour to hex #xxxxxx format + * + * @param {string} colorStr + * @return {string} + * @private + */ + function _normaliseColour(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]) + + toHex(match[3]); + } + + // 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; + } + + /** + * SCEditor BBCode format + * @since 2.0.0 + */ + function bbcodeFormat() { + var base = this; + + 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 = {}; + + /** + * 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'] + }; + + /** + * Populates tagsToBBCodes and stylesToBBCodes for easier lookups + * + * @private + */ + function buildBbcodeCache() { + each(bbcodeHandlers, function (bbcode, handler) { + var + isBlock = handler.isInline === false, + tags = bbcodeHandlers[bbcode].tags, + styles = bbcodeHandlers[bbcode].styles; + + if (styles) { + tagsToBBCodes['*'] = tagsToBBCodes['*'] || {}; + tagsToBBCodes['*'][isBlock] = + tagsToBBCodes['*'][isBlock] || {}; + tagsToBBCodes['*'][isBlock][bbcode] = [ + ['style', Object.entries(styles)] + ]; + } + + if (tags) { + each(tags, function (tag, values) { + if (values && values.style) { + values.style = Object.entries(values.style); + } + + tagsToBBCodes[tag] = tagsToBBCodes[tag] || {}; + tagsToBBCodes[tag][isBlock] = + tagsToBBCodes[tag][isBlock] || {}; + tagsToBBCodes[tag][isBlock][bbcode] = + values && Object.entries(values); + }); + } + }); + }; + + /** + * Handles adding newlines after block level elements + * + * @param {HTMLElement} element The element to convert + * @param {string} content The tags text content + * @return {string} + * @private + */ + function handleBlockNewlines(element, content) { + var tag = element.nodeName.toLowerCase(); + var isInline = dom.isInline; + if (!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 && + !is(previousSibling, '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 + // <block><inline><br></inline></block> + do { + parent = element.parentNode; + parentLastChild = parent && 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) + if (!isLastBlockChild || tag === 'li') { + 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 && + !is(previousSibling, 'br') && + isInline(previousSibling, true)) { + content = '\n' + content; + } + } + + return content; + } + + /** + * Handles a HTML tag and finds any matching BBCodes + * + * @param {HTMLElement} element The element to convert + * @param {string} content The Tags text content + * @param {boolean} blockLevel + * @return {string} Content with any matching BBCode tags + * wrapped around it. + * @private + */ + function handleTags(element, content, blockLevel) { + function isStyleMatch(style) { + var property = style[0]; + var values = style[1]; + var val = dom.getStyle(element, property); + var parent = element.parentNode; + + // 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 (!val || parent && dom.hasStyle(parent, property, val)) { + return false; + } + + return !values || values.includes(val); + } + + function createAttributeMatch(isStrict) { + return function (attribute) { + var name = attribute[0]; + var value = attribute[1]; + + // code tags should skip most styles + if (name === 'style' && element.nodeName === 'CODE') { + return false; + } + + if (name === 'style' && value) { + return value[isStrict ? 'every' : 'some'](isStyleMatch); + } else { + var val = attr(element, name); + + return val && (!value || value.includes(val)); + } + }; + } + + function handleTag(tag) { + if (!tagsToBBCodes[tag] || !tagsToBBCodes[tag][blockLevel]) { + return; + } + + // loop all bbcodes for this tag + each(tagsToBBCodes[tag][blockLevel], function (bbcode, attrs) { + var fn, format, + isStrict = bbcodeHandlers[bbcode].strictMatch; + + if (typeof isStrict === 'undefined') { + isStrict = base.opts.strictMatch; + } + + // Skip if the element doesn't have the attribute or the + // attribute doesn't match one of the required values + fn = isStrict ? 'every' : 'some'; + if (attrs && !attrs[fn](createAttributeMatch(isStrict))) { + return; + } + + format = bbcodeHandlers[bbcode].format; + if (isFunction(format)) { + content = format.call(base, element, content); + } else { + content = _formatString(format, content); + } + return false; + }); + } + + handleTag('*'); + handleTag(element.nodeName.toLowerCase()); + return content; + } + + /** + * Converts a HTML dom element to BBCode starting from + * the innermost element and working backwards + * + * @private + * @param {HTMLElement} element + * @return {string} BBCode + * @memberOf SCEditor.plugins.bbcode.prototype + */ + function elementToBbcode(element) { + var toBBCode = function (node, vChildren) { + var ret = ''; + + dom.traverse(node, function (node) { + var content = '', + nodeType = node.nodeType, + tag = node.nodeName.toLowerCase(), + vChild = validChildren[tag], + firstChild = node.firstChild, + isValidChild = true; + + if (typeof vChildren === 'object') { + isValidChild = vChildren.indexOf(tag) > -1; + + // Emoticons should always be converted + if (is(node, 'img') && attr(node, EMOTICON_DATA_ATTR)) { + 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 (is(node, '.sceditor-nlf') && !firstChild) { + return; + } + + // don't convert iframe contents + if (tag !== 'iframe') { + content = 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') { + // First parse inline codes + content = handleTags(node, content, false); + } + + content = handleTags(node, content, true); + ret += handleBlockNewlines(node, content); + } else { + ret += content; + } + } else { + ret += node.nodeValue; + } + }, false, true); + + return ret; + }; + + return toBBCode(element); + }; + + /** + * Initializer + * @private + */ + base.init = function () { + base.opts = this.opts; + base.elementToBbcode = elementToBbcode; + + // build the BBCode cache + buildBbcodeCache(); + + this.commands = extend( + true, {}, defaultCommandsOverrides, this.commands + ); + + // Add BBCode helper methods + this.toBBCode = base.toSource; + this.fromBBCode = base.toHtml; + }; + + /** + * Converts BBCode into HTML + * + * @param {boolean} asFragment + * @param {string} source + * @param {boolean} [legacyAsFragment] Used by fromBBCode() method + */ + function toHtml(asFragment, source, legacyAsFragment) { + var parser = new BBCodeParser(base.opts.parserOptions); + var html = parser.toHTML( + base.opts.bbcodeTrim ? source.trim() : source + ); + + return (asFragment || legacyAsFragment) ? + removeFirstLastDiv(html) : html; + } + + /** + * Converts HTML into BBCode + * + * @param {boolean} asFragment + * @param {string} html + * @param {!Document} [context] + * @param {!HTMLElement} [parent] + * @return {string} + * @private + */ + function toSource(asFragment, html, context, parent) { + context = context || document; + + var bbcode, elements; + var containerParent = context.createElement('div'); + var container = context.createElement('div'); + var parser = new BBCodeParser(base.opts.parserOptions); + + container.innerHTML = html; + css(containerParent, 'visibility', 'hidden'); + containerParent.appendChild(container); + context.body.appendChild(containerParent); + + if (asFragment) { + // Add text before and after so removeWhiteSpace doesn't remove + // leading and trailing whitespace + containerParent.insertBefore( + context.createTextNode('#'), + containerParent.firstChild + ); + containerParent.appendChild(context.createTextNode('#')); + } + + // Match parents white-space handling + if (parent) { + css(container, 'whiteSpace', css(parent, 'whiteSpace')); + } + + // Remove all nodes with sceditor-ignore class + elements = container.getElementsByClassName('sceditor-ignore'); + while (elements.length) { + elements[0].parentNode.removeChild(elements[0]); + } + + dom.removeWhiteSpace(containerParent); + + bbcode = elementToBbcode(container); + + context.body.removeChild(containerParent); + + bbcode = parser.toBBCode(bbcode, true); + + if (base.opts.bbcodeTrim) { + bbcode = bbcode.trim(); + } + + return bbcode; + }; + + base.toHtml = toHtml.bind(null, false); + base.fragmentToHtml = toHtml.bind(null, true); + base.toSource = toSource.bind(null, false); + base.fragmentToSource = toSource.bind(null, true); + }; + + /** + * Gets a BBCode + * + * @param {string} name + * @return {Object|null} + * @since 2.0.0 + */ + bbcodeFormat.get = function (name) { + return bbcodeHandlers[name] || null; + }; + + /** + * Adds a BBCode to the parser or updates an existing + * BBCode if a BBCode with the specified name already exists. + * + * @param {string} name + * @param {Object} bbcode + * @return {this} + * @since 2.0.0 + */ + bbcodeFormat.set = function (name, bbcode) { + if (name && bbcode) { + // merge any existing command properties + bbcode = extend(bbcodeHandlers[name] || {}, bbcode); + + bbcode.remove = function () { + delete bbcodeHandlers[name]; + }; + + bbcodeHandlers[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 2.0.0 + */ + bbcodeFormat.rename = function (name, newName) { + if (name in bbcodeHandlers) { + bbcodeHandlers[newName] = bbcodeHandlers[name]; + + delete bbcodeHandlers[name]; + } + + return this; + }; + + /** + * Removes a BBCode + * + * @param {string} name + * @return {this} + * @since 2.0.0 + */ + bbcodeFormat.remove = function (name) { + if (name in bbcodeHandlers) { + delete bbcodeHandlers[name]; + } + + return this; + }; + + bbcodeFormat.formatBBCodeString = formatBBCodeString; + + sceditor.formats.bbcode = bbcodeFormat; + sceditor.BBCodeParser = BBCodeParser; +}(sceditor));