diff --git editors/js/tinymce-3.js editors/js/tinymce-3.js index b04a4ce..b827b83 100644 --- editors/js/tinymce-3.js +++ editors/js/tinymce-3.js @@ -200,26 +200,27 @@ Drupal.wysiwyg.editor.instance.tinymce = { var specialProperties = { img: { 'class': 'mceItem' } }; - var $content = $('
' + content + '
'); // No .outerHTML() in jQuery :( - // Find all placeholder/replacement content of Drupal plugins. - $content.find('.drupal-content').each(function() { - // Recursively process DOM elements below this element to apply special - // properties. - var $drupalContent = $(this); - $.each(specialProperties, function(element, properties) { - $drupalContent.find(element).andSelf().each(function() { - for (var property in properties) { - if (property == 'class') { - $(this).addClass(properties[property]); + return Drupal.wysiwyg.utilities.modifyAsDom(content, function (dom) { + var $content = $(dom); + // Find all placeholder/replacement content of Drupal plugins. + $content.find('.drupal-content').each(function() { + // Recursively process DOM elements below this element to apply special + // properties. + var $drupalContent = $(this); + $.each(specialProperties, function(element, properties) { + $drupalContent.find(element).andSelf().each(function() { + for (var property in properties) { + if (property == 'class') { + $(this).addClass(properties[property]); + } + else { + $(this).attr(property, properties[property]); + } } - else { - $(this).attr(property, properties[property]); - } - } + }); }); }); }); - return $content.html(); }, insert: function(content) { diff --git plugins/break/break.js plugins/break/break.js index 54aac4c..2c13a82 100644 --- plugins/break/break.js +++ plugins/break/break.js @@ -46,15 +46,16 @@ Drupal.wysiwyg.plugins['break'] = { * Replace images with tags in content upon detaching editor. */ detach: function(content, settings, instanceId) { - var $content = $('
' + content + '
'); // No .outerHTML() in jQuery :( - // #404532: document.createComment() required or IE will strip the comment. - // #474908: IE 8 breaks when using jQuery methods to replace the elements. - // @todo Add a generic implementation for all Drupal plugins for this. - $.each($('img.wysiwyg-break', $content), function (i, elem) { - elem.parentNode.insertBefore(document.createComment('break'), elem); - elem.parentNode.removeChild(elem); + return Drupal.wysiwyg.utilities.modifyAsDom(content, function (dom) { + var $content = $(dom); + // #404532: document.createComment() required or IE will strip the comment. + // #474908: IE 8 breaks when using jQuery methods to replace the elements. + // @todo Add a generic implementation for all Drupal plugins for this. + $.each($('img.wysiwyg-break', $content), function (i, elem) { + elem.parentNode.insertBefore(document.createComment('break'), elem); + elem.parentNode.removeChild(elem); + }); }); - return $content.html(); }, /** diff --git wysiwyg.js wysiwyg.js index 72ca156..97a8ebb 100644 --- wysiwyg.js +++ wysiwyg.js @@ -230,6 +230,259 @@ Drupal.wysiwyg.getParams = function(element, params) { }; /** + * Serialize a DOM node and its children to an XHTML string. + * + * Makes sure source formatting is preserved across all major browsers. + * + * @param node + * A DOM node, will not be modified. + * + * @returns + * A string containing the XHTML representation of the node, empty + * if the node could not be serialized. + */ +function serialize(node) { + // Inspired by Steve Tucker's innerXHTML, http://www.stevetucker.co.uk. + if (!node || (typeof node.nodeType == 'undefined' && typeof node.length == 'undefined' )) { + return ''; + } + var xhtmlContent = '', nodeType = node.nodeType, nodeName = (node.nodeName ? node.nodeName.toLowerCase() : ''); + if (typeof nodeType == 'undefined') { + for (var i = 0; i < node.length; i++) { + xhtmlContent += serialize(node[i]); + } + return xhtmlContent; + } + else if (nodeType == 3) { + // Text node. + return node.nodeValue.replace(//g, '>'); + } + else if (nodeType == 8) { + // Comment node. + return ''; + } + else if (nodeType == 1) { + // Element node. + xhtmlContent += '<' + nodeName; + var attributes = node.attributes; + for (var j=0; j < attributes.length; j++) { + var attrib = attributes[j], attName = attrib.nodeName.toLowerCase(), attValue = attrib.nodeValue; + if ((attValue == 1 && (attName == 'colspan' || attName == 'rowspan' || attName == 'start' || attName == 'loop')) + || (attName == 'start' && attValue == 'fileopen') ) { + // IE compatibility mode always sets these, despite being defaults. + continue; + } + if (/^data-wysiwyg-masked-/.test(attName)) { + // Ignore these temporary attributes, see below. + continue; + } + if ((attName == 'name' || attName == 'src' || attName == 'href') && attributes['data-wysiwyg-masked-' + attName]) { + // Browsers often turn relative URLs into absolute in these attributes. + attValue = attributes['data-wysiwyg-masked-' + attName].nodeValue || attValue; + } + if (attName == 'style' && node.style.cssText) { + // IE uppercases style attributes, values must be kept intact. + var styles = node.style.cssText.replace(/(^|;)([^\:]+)/g, function (match) { + return match.toLowerCase(); + }); + xhtmlContent += ' style="' + styles + '"'; + } + else if (attValue && attName != 'contenteditable') { + xhtmlContent += ' ' + attName + '="' + attValue + '"'; + } + } + // Clone the node and get its outerHTML to test if it was self-closed. + var elemClone = node.cloneNode(false), container = document.createElement('div'); + container.appendChild(elemClone); + var selfClosed = !new RegExp('\s*$', 'i').test(container.innerHTML); + delete container; + } + // IE doesn't set nodeValue for script tags. + + if (nodeName == 'script' && node.nodeValue == '') { + xhtmlContent += node.text; + } + else { + // Process children for types that can have them. + var children = node.childNodes; + var innerContent = ''; + for (var i = 0; i < children.length; i++) { + var child = children[i]; + innerContent += serialize(child); + } + if (nodeType == 1) { + if (selfClosed) { + xhtmlContent += ' />' + innerContent; + } + else { + xhtmlContent += '>' + innerContent + ''; + } + } + else { + xhtmlContent += innerContent; + } + } + return xhtmlContent; +} + +/** + * Unserialize an XHTML string to one or more DOM nodes. + * + * @param content + * A valid XHTML string. + * + * @returns + * One or more DOM nodes wrapped by a DocumentFragment node. + */ +function unserialize(content) { + // Use a pre element to preserve formatting (#text) nodes in IE. + var $pre = $('
' + content + '
'); + var pre = $pre[0]; + return pre.childNodes; + var dom = document.createDocumentFragment(); + while (pre.firstChild) { + dom.appendChild(pre.firstChild); + } + pre.parentNode.removeChild(pre); + delete pre; + return dom; +} + +/** + * Masks tags in a markup string as divs. + * + * All ocurrances of tags are changed to divs and given an extra + * "data-masked" attribute to identify the old tag name. + * + * @see unmaskTags(). + * + * @param content + * A valid XHTML markup string. + * + * @param tags + * An array of tag names to be replaced by divs. + * + * @returns + * An XHTML markup string with all instances of tags replaced by divs. + */ +function maskTags(content, tags) { + var replaced = content.replace(new RegExp('<(' + tags.join('|') + ')', 'gi'), '
', 'gi'), '
'); + // Borrowed from CKEditor to prevent relative URLsi from becoming absolute. + var protectAttributeRegex = /<((?:a|area|img|input)\b[\s\S]*?\s)((href|src|name)\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|(?:[^ "'>]+)))([^>]*)>/gi; + replaced = replaced.replace(protectAttributeRegex, function(tag, beginning, fullAttr, attrName, end) { + return '<' + beginning + fullAttr + ' data-wysiwyg-masked-' + fullAttr + end + '>'; + }); + // Escape entities since the innerHTML operation in unserialize mangles them. + replaced = replaced.replace(/\&(\w+|#\d+);/g, "$1"); + return replaced; +} + +/** + * Unmasks tags previously nasked as divs. + * + * Recursively looks for nodes having a "data-masked" attribute and creates + * new element nodes of the corresponding type. Other nodes are just cloned. + * + * @see maskTags(). + * + * @param node + * A DOM node to create a unmasked clone of. + * + * @returns + * A clone of the given DOM tree, after unmasking placeholders. + */ +function unmaskTags(node) { + var unmaskedTag = null; + if (node && typeof node.nodeType == 'undefined' && typeof node.length != 'undefined') { + var list = []; + for (var i = 0; i < node.length; i++) { + list[i] = unmaskTags(node[i]); + } + return list; + } + else if (node.getAttribute) { + if (node.getAttribute('data-masked')) { + // Create the new element and transfer attributes from the placeholder. + unmaskedTag = node.ownerDocument.createElement(node.getAttribute('data-masked')); + for (var i = 0; i < node.attributes.length; i++) { + var attribute = node.attributes[i]; + if (attribute.specified && attribute.name.toLowerCase() != 'data-masked') { + unmaskedTag.setAttribute(attribute.name, attribute.value); + } + } + } + else if (node.getAttribute('data-wysiwyg-protected-entity')) { + // Recreate the entity as a text node, will be merged with siblings later. + // Text nodes can't have children so return right away. + return document.createTextNode('&' + node.innerText + ';'); + } + else { + // The node was not masked, just clone it. + unmaskedTag = node.cloneNode(false); + } + } + else if (node.nodeType == 3 && node.nodeValue == '') { + // Skip empty text nodes. + return null; + } + else { + // The node doesn't support attributes, just clone it. + unmaskedTag = node.cloneNode(false); + } + for (var i = 0; i < node.childNodes.length; i++) { + var clonedChild = unmaskTags(node.childNodes[i]); + if (clonedChild) { + unmaskedTag.appendChild(clonedChild); + } + } + // Merge text nodes. + if (unmaskedTag.normalize) { + unmaskedTag.normalize(); + } + return unmaskedTag; +} + +/** + * Utility functions provided by Wysiwyg. + */ +Drupal.wysiwyg.utilities = { + + /** + * Temporarily convert XHTML to a DOM and back again via a callback. + * + * Provides a convenient way to modify a valid XHTML string as DOM nodes. + * Preserves source indentation and whitespaces as #text nodes in all major + * browsers, otherwise not possible using .innerHTML in IE. + * + * @param content + * A valid XHTML string. + * + * @param callback + * A callback function to be called when DOM nodes have been generated. + * The callback may modify the DOM nodes in any way needed. If assigning an + * element's src or href attribute and it's important for URIs to be + * relative, also assign the same value to the corresponding + * data-wysiwyg-masked-[attribute name] attribute. It keeps the browser from + * making the URI absolute. The same rule applies when reading these + * attributes. Instead of element.getAttribute('src'), do + * element.getAttribute('data-wysiwyg-protected-src') etc. + * + * The callback's return value is ignored. + * + * @returns + * An XHTML string representing the DOM nodes as left by the callback. + */ + modifyAsDom : function (content, callback) { + var tags = ['table', 'caption', 'colgroup', 'col', 'thead', 'tbody', 'tr', 'th', 'td', 'tfoot']; + var dom = unmaskTags(unserialize(maskTags(content, tags))); + callback(dom); + var clone = serialize(dom); + return clone; + } +} + +/** * Allow certain editor libraries to initialize before the DOM is loaded. */ Drupal.wysiwygInit();