Mercurial Hosting > sceditor
view src/formats/xhtml.js @ 8:292d40f68d50
minor
author | Franklin Schmidt <fschmidt@gmail.com> |
---|---|
date | Fri, 05 Aug 2022 12:56:50 -0600 |
parents | b7725dab7482 |
children | 0cb206904499 |
line wrap: on
line source
/** * SCEditor XHTML Plugin * http://www.sceditor.com/ * * Copyright (C) 2017, Sam Clarke (samclarke.com) * * SCEditor is licensed under the MIT license: * http://www.opensource.org/licenses/mit-license.php * * @author Sam Clarke */ (function (sceditor) { 'use strict'; var dom = sceditor.dom; var utils = sceditor.utils; var css = dom.css; var attr = dom.attr; var is = dom.is; var removeAttr = dom.removeAttr; var convertElement = dom.convertElement; var extend = utils.extend; var each = utils.each; var isEmptyObject = utils.isEmptyObject; var getEditorCommand = sceditor.command.get; 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; getEditorCommand('font')._dropDown( editor, caller, function (font) { editor.insertText('<span style="font-family:' + font + ';">', '</span>'); } ); } }, size: { txtExec: function (caller) { var editor = this; getEditorCommand('size')._dropDown( editor, caller, function (size) { editor.insertText('<span style="font-size:' + size + ';">', '</span>'); } ); } }, color: { txtExec: function (caller) { var editor = this; getEditorCommand('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 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 + ' src="' + url + '" />' ); } ); } }, email: { txtExec: function (caller, selected) { var editor = this; getEditorCommand('email')._dropDown( editor, caller, function (url, text) { editor.insertText( '<a href="mailto:' + url + '">' + (text || selected || url) + '</a>' ); } ); } }, link: { txtExec: function (caller, selected) { var editor = this; getEditorCommand('link')._dropDown( editor, caller, function (url, text) { editor.insertText( '<a href="' + url + '">' + (text || selected || url) + '</a>' ); } ); } }, quote: { txtExec: ['<blockquote>', '</blockquote>'] }, youtube: { txtExec: function (caller) { var editor = this; getEditorCommand('youtube')._dropDown( editor, caller, function (id, time) { editor.insertText( '<iframe width="560" height="315" ' + 'src="https://www.youtube.com/embed/{id}?' + 'wmode=opaque&start=' + time + '" ' + '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; // TODO: use escape.entities /** * Escapes XHTML entities * * @param {string} str * @return {string} * @private */ function escapeEntities(str) { var entities = { '&': '&', '<': '<', '>': '>', '"': '"', '\xa0': ' ' }; return !str ? '' : str.replace(/[&<>"\xa0]/g, function (entity) { return entities[entity] || entity; }); }; /** * Replace spaces including newlines with a single * space except for non-breaking spaces * * @param {string} str * @return {string} * @private */ function trim(str) { return str.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 */ function serializeNode(node, parentIsPre) { switch (node.nodeType) { case 1: // element 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 */ function handleDoc(node) { var child = node.firstChild; while (child) { serializeNode(child); child = child.nextSibling; } }; /** * Handles element nodes * @param {Node} node * @return {void} * @private */ function handleElement(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(css(node, 'whiteSpace')), selfClosing = !node.firstChild && !dom.canHaveChildren(node) && !isIframe; if (is(node, '.sceditor-ignore')) { return; } output('<' + tagName, !parentIsPre && canIndent(node)); while (attrIdx--) { attr = node.attributes[attrIdx]; attrValue = attr.value; output(' ' + attr.name.toLowerCase() + '="' + escapeEntities(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 */ function handleCdata(node) { output('<![CDATA[' + escapeEntities(node.nodeValue) + ']]>'); }; /** * Handles comment nodes * @param {Node} node * @return {void} * @private */ function handleComment(node) { output('<!-- ' + escapeEntities(node.nodeValue) + ' -->'); }; /** * Handles text nodes * @param {Node} node * @return {void} * @private */ function handleText(node, parentIsPre) { var text = node.nodeValue; if (!parentIsPre) { text = trim(text); } if (text) { output(escapeEntities(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 */ function output(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 */ function canIndent(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 */ function xhtmlFormat() { var base = this; /** * Tag converters cache * @type {Object} * @private */ var tagConvertersCache = {}; /** * Attributes filter cache * @type {Object} * @private */ var attrsCache = {}; /** * Init * @return {void} */ base.init = function () { if (!isEmptyObject(xhtmlFormat.converters || {})) { each( xhtmlFormat.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 {boolean} isFragment * @param {string} html * @param {Document} context * @param {HTMLElement} [parent] * @return {string} * @memberOf jQuery.sceditor.plugins.xhtml.prototype */ function toSource(isFragment, html, context) { var xhtml, container = context.createElement('div'); container.innerHTML = html; css(container, 'visibility', 'hidden'); context.body.appendChild(container); convertTags(container); removeTags(container); removeAttribs(container); if (!isFragment) { wrapInlines(container); } xhtml = (new sceditor.XHTMLSerializer()).serialize(container, true); context.body.removeChild(container); return xhtml; }; base.toSource = toSource.bind(null, false); base.fragmentToSource = toSource.bind(null, true);; /** * Runs all converters for the specified tagName * against the DOM node. * @param {string} tagName * @return {Node} node * @private */ function convertNode(tagName, node) { if (!tagConvertersCache[tagName]) { return; } tagConvertersCache[tagName].forEach(function (converter) { if (converter.tags[tagName]) { each(converter.tags[tagName], function (attr, values) { if (!node.getAttributeNode) { return; } attr = node.getAttributeNode(attr); if (!attr || values && values.indexOf(attr.value) < 0) { return; } converter.conv.call(base, node); }); } else if (converter.conv) { converter.conv.call(base, node); } }); }; /** * Converts any tags/attributes to their XHTML equivalents * @param {Node} node * @return {void} * @private */ function convertTags(node) { dom.traverse(node, function (node) { var tagName = node.nodeName.toLowerCase(); convertNode('*', node); convertNode(tagName, node); }, true); }; /** * Tests if a node is empty and can be removed. * * @param {Node} node * @return {boolean} * @private */ function isEmpty(node, excludeBr) { var rect, childNodes = node.childNodes, tagName = node.nodeName.toLowerCase(), nodeValue = node.nodeValue, childrenLength = childNodes.length, allowedEmpty = xhtmlFormat.allowedEmptyTags || []; if (excludeBr && tagName === 'br') { return true; } if (is(node, '.sceditor-ignore')) { return true; } if (allowedEmpty.indexOf(tagName) > -1 || tagName === 'td' || !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; } } // Treat tags with a width and height from CSS as not empty if (node.getBoundingClientRect && (node.className || node.hasAttributes('style'))) { rect = node.getBoundingClientRect(); return !rect.width || !rect.height; } 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 */ function removeTags(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 = xhtmlFormat.allowedTags, firstChild = node.firstChild, disallowedTags = xhtmlFormat.disallowedTags; // 3 = text node if (nodeType === 3) { return; } if (nodeType === 4) { tagName = '!cdata'; } else if (tagName === '!' || nodeType === 8) { tagName = '!comment'; } if (nodeType === 1) { // skip empty nlf elements (new lines automatically // added after block level elements like quotes) if (is(node, '.sceditor-nlf')) { if (!firstChild || (node.childNodes.length === 1 && /br/i.test(firstChild.nodeName))) { // Mark as empty,it will be removed by the next code empty = true; } else { node.classList.remove('sceditor-nlf'); if (!node.className) { removeAttr(node, 'class'); } } } } if (empty) { remove = true; // 3 is text node which do not get filtered } else if (allowedTags && allowedTags.length) { remove = (allowedTags.indexOf(tagName) < 0); } else if (disallowedTags && disallowedTags.length) { remove = (disallowedTags.indexOf(tagName) > -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 */ function mergeAttribsFilters(filtersA, filtersB) { var ret = {}; if (filtersA) { ret = extend({}, ret, filtersA); } if (!filtersB) { return ret; } each(filtersB, function (attrName, values) { if (Array.isArray(values)) { ret[attrName] = (ret[attrName] || []).concat(values); } else if (!ret[attrName]) { ret[attrName] = null; } }); return ret; }; /** * Wraps adjacent inline child nodes of root * in paragraphs. * * @param {Node} root * @private */ function wrapInlines(root) { // Strip empty text nodes so they don't get wrapped. dom.removeWhiteSpace(root); var wrapper; var node = root.firstChild; var next; while (node) { next = node.nextSibling; if (dom.isInline(node) && !is(node, '.sceditor-ignore')) { if (!wrapper) { wrapper = root.ownerDocument.createElement('p'); node.parentNode.insertBefore(wrapper, node); } wrapper.appendChild(node); } else { wrapper = null; } node = next; } }; /** * 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 */ function removeAttribs(node) { var tagName, attr, attrName, attrsLength, validValues, remove, allowedAttribs = xhtmlFormat.allowedAttribs, isAllowed = allowedAttribs && !isEmptyObject(allowedAttribs), disallowedAttribs = xhtmlFormat.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 && (!Array.isArray(validValues) || validValues.indexOf(attr.value) < 0); } else if (isDisallowed) { remove = validValues === null || (Array.isArray(validValues) && validValues.indexOf(attr.value) > -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 */ xhtmlFormat.converters = [ { tags: { '*': { width: null } }, conv: function (node) { css(node, 'width', attr(node, 'width')); removeAttr(node, 'width'); } }, { tags: { '*': { height: null } }, conv: function (node) { css(node, 'height', attr(node, 'height')); removeAttr(node, 'height'); } }, { tags: { 'li': { value: null } }, conv: function (node) { removeAttr(node, 'value'); } }, { tags: { '*': { text: null } }, conv: function (node) { css(node, 'color', attr(node, 'text')); removeAttr(node, 'text'); } }, { tags: { '*': { color: null } }, conv: function (node) { css(node, 'color', attr(node, 'color')); removeAttr(node, 'color'); } }, { tags: { '*': { face: null } }, conv: function (node) { css(node, 'fontFamily', attr(node, 'face')); removeAttr(node, 'face'); } }, { tags: { '*': { align: null } }, conv: function (node) { css(node, 'textAlign', attr(node, 'align')); removeAttr(node, 'align'); } }, { tags: { '*': { border: null } }, conv: function (node) { css(node, 'borderWidth', attr(node, 'border')); removeAttr(node, 'border'); } }, { tags: { applet: { name: null }, img: { name: null }, layer: { name: null }, map: { name: null }, object: { name: null }, param: { name: null } }, conv: function (node) { if (!attr(node, 'id')) { attr(node, 'id', attr(node, 'name')); } removeAttr(node, 'name'); } }, { tags: { '*': { vspace: null } }, conv: function (node) { css(node, 'marginTop', attr(node, 'vspace') - 0); css(node, 'marginBottom', attr(node, 'vspace') - 0); removeAttr(node, 'vspace'); } }, { tags: { '*': { hspace: null } }, conv: function (node) { css(node, 'marginLeft', attr(node, 'hspace') - 0); css(node, 'marginRight', attr(node, 'hspace') - 0); removeAttr(node, 'hspace'); } }, { tags: { 'hr': { noshade: null } }, conv: function (node) { css(node, 'borderStyle', 'solid'); removeAttr(node, 'noshade'); } }, { tags: { '*': { nowrap: null } }, conv: function (node) { css(node, 'whiteSpace', 'nowrap'); removeAttr(node, 'nowrap'); } }, { tags: { big: null }, conv: function (node) { css(convertElement(node, 'span'), 'fontSize', 'larger'); } }, { tags: { small: null }, conv: function (node) { css(convertElement(node, 'span'), 'fontSize', 'smaller'); } }, { tags: { b: null }, conv: function (node) { convertElement(node, 'strong'); } }, { tags: { u: null }, conv: function (node) { css(convertElement(node, 'span'), 'textDecoration', 'underline'); } }, { tags: { s: null, strike: null }, conv: function (node) { css(convertElement(node, 'span'), 'textDecoration', 'line-through'); } }, { tags: { dir: null }, conv: function (node) { convertElement(node, 'ul'); } }, { tags: { center: null }, conv: function (node) { css(convertElement(node, 'div'), 'textAlign', 'center'); } }, { tags: { font: { size: null } }, conv: function (node) { css(node, 'fontSize', css(node, 'fontSize')); removeAttr(node, 'size'); } }, { tags: { font: null }, conv: function (node) { // All it's attributes will be converted // by the attribute converters convertElement(node, 'span'); } }, { tags: { '*': { type: ['_moz'] } }, conv: function (node) { removeAttr(node, 'type'); } }, { tags: { '*': { '_moz_dirty': null } }, conv: function (node) { removeAttr(node, '_moz_dirty'); } }, { tags: { '*': { '_moz_editor_bogus_node': null } }, conv: function (node) { node.parentNode.removeChild(node); } }, { tags: { '*': { 'data-sce-target': null } }, conv: function (node) { var rel = attr(node, 'rel') || ''; var target = attr(node, 'data-sce-target'); // Only allow the value _blank and only on links if (target === '_blank' && is(node, 'a')) { if (!/(^|\s)noopener(\s|$)/.test(rel)) { attr(node, 'rel', 'noopener' + (rel ? ' ' + rel : '')); } attr(node, 'target', target); } removeAttr(node, 'data-sce-target'); } }, { tags: { code: null }, conv: function (node) { var node, nodes = node.getElementsByTagName('div'); while ((node = nodes[0])) { node.style.display = 'block'; convertElement(node, 'span'); } } } ]; /** * 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 */ xhtmlFormat.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 */ xhtmlFormat.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 */ xhtmlFormat.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 */ xhtmlFormat.disallowedTags = []; /** * Array containing tags which should not be removed when empty. * * @type {Array} * @name jQuery.sceditor.plugins.xhtml.allowedEmptyTags * @since v2.0.0 */ xhtmlFormat.allowedEmptyTags = []; sceditor.formats.xhtml = xhtmlFormat; }(sceditor));