/** * Formatter.js * * Copyright, Moxiecode Systems AB * Released under LGPL License. * * License: http://www.tinymce.com/license * Contributing: http://www.tinymce.com/contributing */ /** * Text formatter engine class. This class is used to apply formats like bold, italic, font size * etc to the current selection or specific nodes. This engine was build to replace the browsers * default formatting logic for execCommand due to it's inconsistant and buggy behavior. * * @class tinymce.Formatter * @example * tinymce.activeEditor.formatter.register('mycustomformat', { * inline: 'span', * styles: {color: '#ff0000'} * }); * * tinymce.activeEditor.formatter.apply('mycustomformat'); */ define("tinymce/Formatter", [ "tinymce/dom/TreeWalker", "tinymce/dom/RangeUtils", "tinymce/util/Tools" ], function(TreeWalker, RangeUtils, Tools) { /** * Constructs a new formatter instance. * * @constructor Formatter * @param {tinymce.Editor} ed Editor instance to construct the formatter engine to. */ return function(ed) { var formats = {}, dom = ed.dom, selection = ed.selection, rangeUtils = new RangeUtils(dom), isValid = ed.schema.isValidChild, isBlock = dom.isBlock, forcedRootBlock = ed.settings.forced_root_block, nodeIndex = dom.nodeIndex, INVISIBLE_CHAR = '\uFEFF', MCE_ATTR_RE = /^(src|href|style)$/, FALSE = false, TRUE = true, formatChangeData, undef, getContentEditable = dom.getContentEditable, disableCaretContainer, markCaretContainersBogus; var each = Tools.each, grep = Tools.grep, walk = Tools.walk, extend = Tools.extend; function isTextBlock(name) { if (name.nodeType) { name = name.nodeName; } return !!ed.schema.getTextBlockElements()[name.toLowerCase()]; } function getParents(node, selector) { return dom.getParents(node, selector, dom.getRoot()); } function isCaretNode(node) { return node.nodeType === 1 && node.id === '_mce_caret'; } function defaultFormats() { register({ alignleft: [ {selector: 'figure,p,h1,h2,h3,h4,h5,h6,td,th,tr,div,ul,ol,li', styles: {textAlign: 'left'}, defaultBlock: 'div'}, {selector: 'img,table', collapsed: false, styles: {'float': 'left'}} ], aligncenter: [ {selector: 'figure,p,h1,h2,h3,h4,h5,h6,td,th,tr,div,ul,ol,li', styles: {textAlign: 'center'}, defaultBlock: 'div'}, {selector: 'img', collapsed: false, styles: {display: 'block', marginLeft: 'auto', marginRight: 'auto'}}, {selector: 'table', collapsed: false, styles: {marginLeft: 'auto', marginRight: 'auto'}} ], alignright: [ {selector: 'figure,p,h1,h2,h3,h4,h5,h6,td,th,tr,div,ul,ol,li', styles: {textAlign: 'right'}, defaultBlock: 'div'}, {selector: 'img,table', collapsed: false, styles: {'float': 'right'}} ], alignjustify: [ {selector: 'figure,p,h1,h2,h3,h4,h5,h6,td,th,tr,div,ul,ol,li', styles: {textAlign: 'justify'}, defaultBlock: 'div'} ], bold: [ {inline: 'strong', remove: 'all'}, {inline: 'span', styles: {fontWeight: 'bold'}}, {inline: 'b', remove: 'all'} ], italic: [ {inline: 'em', remove: 'all'}, {inline: 'span', styles: {fontStyle: 'italic'}}, {inline: 'i', remove: 'all'} ], underline: [ {inline: 'span', styles: {textDecoration: 'underline'}, exact: true}, {inline: 'u', remove: 'all'} ], strikethrough: [ {inline: 'span', styles: {textDecoration: 'line-through'}, exact: true}, {inline: 'strike', remove: 'all'} ], forecolor: {inline: 'span', styles: {color: '%value'}, wrap_links: false}, hilitecolor: {inline: 'span', styles: {backgroundColor: '%value'}, wrap_links: false}, fontname: {inline: 'span', styles: {fontFamily: '%value'}}, fontsize: {inline: 'span', styles: {fontSize: '%value'}}, fontsize_class: {inline: 'span', attributes: {'class': '%value'}}, blockquote: {block: 'blockquote', wrapper: 1, remove: 'all'}, subscript: {inline: 'sub'}, superscript: {inline: 'sup'}, code: {inline: 'code'}, link: {inline: 'a', selector: 'a', remove: 'all', split: true, deep: true, onmatch: function() { return true; }, onformat: function(elm, fmt, vars) { each(vars, function(value, key) { dom.setAttrib(elm, key, value); }); } }, removeformat: [ {selector: 'b,strong,em,i,font,u,strike', remove: 'all', split: true, expand: false, block_expand: true, deep: true}, {selector: 'span', attributes: ['style', 'class'], remove: 'empty', split: true, expand: false, deep: true}, {selector: '*', attributes: ['style', 'class'], split: false, expand: false, deep: true} ] }); // Register default block formats each('p h1 h2 h3 h4 h5 h6 div address pre div dt dd samp'.split(/\s/), function(name) { register(name, {block: name, remove: 'all'}); }); // Register user defined formats register(ed.settings.formats); } function addKeyboardShortcuts() { // Add some inline shortcuts ed.addShortcut('ctrl+b', 'bold_desc', 'Bold'); ed.addShortcut('ctrl+i', 'italic_desc', 'Italic'); ed.addShortcut('ctrl+u', 'underline_desc', 'Underline'); // BlockFormat shortcuts keys for (var i = 1; i <= 6; i++) { ed.addShortcut('ctrl+' + i, '', ['FormatBlock', false, 'h' + i]); } ed.addShortcut('ctrl+7', '', ['FormatBlock', false, 'p']); ed.addShortcut('ctrl+8', '', ['FormatBlock', false, 'div']); ed.addShortcut('ctrl+9', '', ['FormatBlock', false, 'address']); } // Public functions /** * Returns the format by name or all formats if no name is specified. * * @method get * @param {String} name Optional name to retrive by. * @return {Array/Object} Array/Object with all registred formats or a specific format. */ function get(name) { return name ? formats[name] : formats; } /** * Registers a specific format by name. * * @method register * @param {Object/String} name Name of the format for example "bold". * @param {Object/Array} format Optional format object or array of format variants * can only be omitted if the first arg is an object. */ function register(name, format) { if (name) { if (typeof(name) !== 'string') { each(name, function(format, name) { register(name, format); }); } else { // Force format into array and add it to internal collection format = format.length ? format : [format]; each(format, function(format) { // Set deep to false by default on selector formats this to avoid removing // alignment on images inside paragraphs when alignment is changed on paragraphs if (format.deep === undef) { format.deep = !format.selector; } // Default to true if (format.split === undef) { format.split = !format.selector || format.inline; } // Default to true if (format.remove === undef && format.selector && !format.inline) { format.remove = 'none'; } // Mark format as a mixed format inline + block level if (format.selector && format.inline) { format.mixed = true; format.block_expand = true; } // Split classes if needed if (typeof(format.classes) === 'string') { format.classes = format.classes.split(/\s+/); } }); formats[name] = format; } } } function getTextDecoration(node) { var decoration; ed.dom.getParent(node, function(n) { decoration = ed.dom.getStyle(n, 'text-decoration'); return decoration && decoration !== 'none'; }); return decoration; } function processUnderlineAndColor(node) { var textDecoration; if (node.nodeType === 1 && node.parentNode && node.parentNode.nodeType === 1) { textDecoration = getTextDecoration(node.parentNode); if (ed.dom.getStyle(node, 'color') && textDecoration) { ed.dom.setStyle(node, 'text-decoration', textDecoration); } else if (ed.dom.getStyle(node, 'textdecoration') === textDecoration) { ed.dom.setStyle(node, 'text-decoration', null); } } } /** * Applies the specified format to the current selection or specified node. * * @method apply * @param {String} name Name of format to apply. * @param {Object} vars Optional list of variables to replace within format before applying it. * @param {Node} node Optional node to apply the format to defaults to current selection. */ function apply(name, vars, node) { var formatList = get(name), format = formatList[0], bookmark, rng, isCollapsed = !node && selection.isCollapsed(); function setElementFormat(elm, fmt) { fmt = fmt || format; if (elm) { if (fmt.onformat) { fmt.onformat(elm, fmt, vars, node); } each(fmt.styles, function(value, name) { dom.setStyle(elm, name, replaceVars(value, vars)); }); each(fmt.attributes, function(value, name) { dom.setAttrib(elm, name, replaceVars(value, vars)); }); each(fmt.classes, function(value) { value = replaceVars(value, vars); if (!dom.hasClass(elm, value)) { dom.addClass(elm, value); } }); } } function adjustSelectionToVisibleSelection() { function findSelectionEnd(start, end) { var walker = new TreeWalker(end); for (node = walker.current(); node; node = walker.prev()) { if (node.childNodes.length > 1 || node == start || node.tagName == 'BR') { return node; } } } // Adjust selection so that a end container with a end offset of zero is not included in the selection // as this isn't visible to the user. var rng = ed.selection.getRng(); var start = rng.startContainer; var end = rng.endContainer; if (start != end && rng.endOffset === 0) { var newEnd = findSelectionEnd(start, end); var endOffset = newEnd.nodeType == 3 ? newEnd.length : newEnd.childNodes.length; rng.setEnd(newEnd, endOffset); } return rng; } function applyStyleToList(node, bookmark, wrapElm, newWrappers, process){ var nodes = [], listIndex = -1, list, startIndex = -1, endIndex = -1, currentWrapElm; // find the index of the first child list. each(node.childNodes, function(n, index) { if (n.nodeName === "UL" || n.nodeName === "OL") { listIndex = index; list = n; return false; } }); // get the index of the bookmarks each(node.childNodes, function(n, index) { if (n.nodeName === "SPAN" && dom.getAttrib(n, "data-mce-type") == "bookmark") { if (n.id == bookmark.id + "_start") { startIndex = index; } else if (n.id == bookmark.id + "_end") { endIndex = index; } } }); // if the selection spans across an embedded list, or there isn't an embedded list - handle processing normally if (listIndex <= 0 || (startIndex < listIndex && endIndex > listIndex)) { each(grep(node.childNodes), process); return 0; } else { currentWrapElm = dom.clone(wrapElm, FALSE); // create a list of the nodes on the same side of the list as the selection each(grep(node.childNodes), function(n, index) { if ((startIndex < listIndex && index < listIndex) || (startIndex > listIndex && index > listIndex)) { nodes.push(n); n.parentNode.removeChild(n); } }); // insert the wrapping element either before or after the list. if (startIndex < listIndex) { node.insertBefore(currentWrapElm, list); } else if (startIndex > listIndex) { node.insertBefore(currentWrapElm, list.nextSibling); } // add the new nodes to the list. newWrappers.push(currentWrapElm); each(nodes, function(node) { currentWrapElm.appendChild(node); }); return currentWrapElm; } } function applyRngStyle(rng, bookmark, node_specific) { var newWrappers = [], wrapName, wrapElm, contentEditable = true; // Setup wrapper element wrapName = format.inline || format.block; wrapElm = dom.create(wrapName); setElementFormat(wrapElm); rangeUtils.walk(rng, function(nodes) { var currentWrapElm; /** * Process a list of nodes wrap them. */ function process(node) { var nodeName, parentName, found, hasContentEditableState, lastContentEditable; lastContentEditable = contentEditable; nodeName = node.nodeName.toLowerCase(); parentName = node.parentNode.nodeName.toLowerCase(); // Node has a contentEditable value if (node.nodeType === 1 && getContentEditable(node)) { lastContentEditable = contentEditable; contentEditable = getContentEditable(node) === "true"; hasContentEditableState = true; // We don't want to wrap the container only it's children } // Stop wrapping on br elements if (isEq(nodeName, 'br')) { currentWrapElm = 0; // Remove any br elements when we wrap things if (format.block) { dom.remove(node); } return; } // If node is wrapper type if (format.wrapper && matchNode(node, name, vars)) { currentWrapElm = 0; return; } // Can we rename the block // TODO: Break this if up, too complex if (contentEditable && !hasContentEditableState && format.block && !format.wrapper && isTextBlock(nodeName) && isValid(parentName, wrapName)) { node = dom.rename(node, wrapName); setElementFormat(node); newWrappers.push(node); currentWrapElm = 0; return; } // Handle selector patterns if (format.selector) { // Look for matching formats each(formatList, function(format) { // Check collapsed state if it exists if ('collapsed' in format && format.collapsed !== isCollapsed) { return; } if (dom.is(node, format.selector) && !isCaretNode(node)) { setElementFormat(node, format); found = true; } }); // Continue processing if a selector match wasn't found and a inline element is defined if (!format.inline || found) { currentWrapElm = 0; return; } } // Is it valid to wrap this item // TODO: Break this if up, too complex if (contentEditable && !hasContentEditableState && isValid(wrapName, nodeName) && isValid(parentName, wrapName) && !(!node_specific && node.nodeType === 3 && node.nodeValue.length === 1 && node.nodeValue.charCodeAt(0) === 65279) && !isCaretNode(node) && (!format.inline || !isBlock(node))) { // Start wrapping if (!currentWrapElm) { // Wrap the node currentWrapElm = dom.clone(wrapElm, FALSE); node.parentNode.insertBefore(currentWrapElm, node); newWrappers.push(currentWrapElm); } currentWrapElm.appendChild(node); } else if (nodeName == 'li' && bookmark) { // Start wrapping - if we are in a list node and have a bookmark, then // we will always begin by wrapping in a new element. currentWrapElm = applyStyleToList(node, bookmark, wrapElm, newWrappers, process); } else { // Start a new wrapper for possible children currentWrapElm = 0; each(grep(node.childNodes), process); if (hasContentEditableState) { contentEditable = lastContentEditable; // Restore last contentEditable state from stack } // End the last wrapper currentWrapElm = 0; } } // Process siblings from range each(nodes, process); }); // Wrap links inside as well, for example color inside a link when the wrapper is around the link if (format.wrap_links === false) { each(newWrappers, function(node) { function process(node) { var i, currentWrapElm, children; if (node.nodeName === 'A') { currentWrapElm = dom.clone(wrapElm, FALSE); newWrappers.push(currentWrapElm); children = grep(node.childNodes); for (i = 0; i < children.length; i++) { currentWrapElm.appendChild(children[i]); } node.appendChild(currentWrapElm); } each(grep(node.childNodes), process); } process(node); }); } // Cleanup each(newWrappers, function(node) { var childCount; function getChildCount(node) { var count = 0; each(node.childNodes, function(node) { if (!isWhiteSpaceNode(node) && !isBookmarkNode(node)) { count++; } }); return count; } function mergeStyles(node) { var child, clone; each(node.childNodes, function(node) { if (node.nodeType == 1 && !isBookmarkNode(node) && !isCaretNode(node)) { child = node; return FALSE; // break loop } }); // If child was found and of the same type as the current node if (child && matchName(child, format)) { clone = dom.clone(child, FALSE); setElementFormat(clone); dom.replace(clone, node, TRUE); dom.remove(child, 1); } return clone || node; } childCount = getChildCount(node); // Remove empty nodes but only if there is multiple wrappers and they are not block // elements so never remove single

since that would remove the // currrent empty block element where the caret is at if ((newWrappers.length > 1 || !isBlock(node)) && childCount === 0) { dom.remove(node, 1); return; } if (format.inline || format.wrapper) { // Merges the current node with it's children of similar type to reduce the number of elements if (!format.exact && childCount === 1) { node = mergeStyles(node); } // Remove/merge children each(formatList, function(format) { // Merge all children of similar type will move styles from child to parent // this: text // will become: text each(dom.select(format.inline, node), function(child) { var parent; // When wrap_links is set to false we don't want // to remove the format on children within links if (format.wrap_links === false) { parent = child.parentNode; do { if (parent.nodeName === 'A') { return; } } while ((parent = parent.parentNode)); } removeFormat(format, vars, child, format.exact ? child : null); }); }); // Remove child if direct parent is of same type if (matchNode(node.parentNode, name, vars)) { dom.remove(node, 1); node = 0; return TRUE; } // Look for parent with similar style format if (format.merge_with_parents) { dom.getParent(node.parentNode, function(parent) { if (matchNode(parent, name, vars)) { dom.remove(node, 1); node = 0; return TRUE; } }); } // Merge next and previous siblings if they are similar texttext becomes texttext if (node && format.merge_siblings !== false) { node = mergeSiblings(getNonWhiteSpaceSibling(node), node); node = mergeSiblings(node, getNonWhiteSpaceSibling(node, TRUE)); } } }); } if (format) { if (node) { if (node.nodeType) { rng = dom.createRng(); rng.setStartBefore(node); rng.setEndAfter(node); applyRngStyle(expandRng(rng, formatList), null, true); } else { applyRngStyle(node, null, true); } } else { if (!isCollapsed || !format.inline || dom.select('td.mce-item-selected,th.mce-item-selected').length) { // Obtain selection node before selection is unselected by applyRngStyle() var curSelNode = ed.selection.getNode(); // If the formats have a default block and we can't find a parent block then // start wrapping it with a DIV this is for forced_root_blocks: false // It's kind of a hack but people should be using the default block type P since all desktop editors work that way if (!forcedRootBlock && formatList[0].defaultBlock && !dom.getParent(curSelNode, dom.isBlock)) { apply(formatList[0].defaultBlock); } // Apply formatting to selection ed.selection.setRng(adjustSelectionToVisibleSelection()); bookmark = selection.getBookmark(); applyRngStyle(expandRng(selection.getRng(TRUE), formatList), bookmark); // Colored nodes should be underlined so that the color of the underline matches the text color. if (format.styles && (format.styles.color || format.styles.textDecoration)) { walk(curSelNode, processUnderlineAndColor, 'childNodes'); processUnderlineAndColor(curSelNode); } selection.moveToBookmark(bookmark); moveStart(selection.getRng(TRUE)); ed.nodeChanged(); } else { performCaretAction('apply', name, vars); } } } } /** * Removes the specified format from the current selection or specified node. * * @method remove * @param {String} name Name of format to remove. * @param {Object} vars Optional list of variables to replace within format before removing it. * @param {Node/Range} node Optional node or DOM range to remove the format from defaults to current selection. */ function remove(name, vars, node) { var formatList = get(name), format = formatList[0], bookmark, rng, contentEditable = true; // Merges the styles for each node function process(node) { var children, i, l, lastContentEditable, hasContentEditableState; // Node has a contentEditable value if (node.nodeType === 1 && getContentEditable(node)) { lastContentEditable = contentEditable; contentEditable = getContentEditable(node) === "true"; hasContentEditableState = true; // We don't want to wrap the container only it's children } // Grab the children first since the nodelist might be changed children = grep(node.childNodes); // Process current node if (contentEditable && !hasContentEditableState) { for (i = 0, l = formatList.length; i < l; i++) { if (removeFormat(formatList[i], vars, node, node)) { break; } } } // Process the children if (format.deep) { if (children.length) { for (i = 0, l = children.length; i < l; i++) { process(children[i]); } if (hasContentEditableState) { contentEditable = lastContentEditable; // Restore last contentEditable state from stack } } } } function findFormatRoot(container) { var formatRoot; // Find format root each(getParents(container.parentNode).reverse(), function(parent) { var format; // Find format root element if (!formatRoot && parent.id != '_start' && parent.id != '_end') { // Is the node matching the format we are looking for format = matchNode(parent, name, vars); if (format && format.split !== false) { formatRoot = parent; } } }); return formatRoot; } function wrapAndSplit(format_root, container, target, split) { var parent, clone, lastClone, firstClone, i, formatRootParent; // Format root found then clone formats and split it if (format_root) { formatRootParent = format_root.parentNode; for (parent = container.parentNode; parent && parent != formatRootParent; parent = parent.parentNode) { clone = dom.clone(parent, FALSE); for (i = 0; i < formatList.length; i++) { if (removeFormat(formatList[i], vars, clone, clone)) { clone = 0; break; } } // Build wrapper node if (clone) { if (lastClone) { clone.appendChild(lastClone); } if (!firstClone) { firstClone = clone; } lastClone = clone; } } // Never split block elements if the format is mixed if (split && (!format.mixed || !isBlock(format_root))) { container = dom.split(format_root, container); } // Wrap container in cloned formats if (lastClone) { target.parentNode.insertBefore(lastClone, target); firstClone.appendChild(target); } } return container; } function splitToFormatRoot(container) { return wrapAndSplit(findFormatRoot(container), container, container, true); } function unwrap(start) { var node = dom.get(start ? '_start' : '_end'), out = node[start ? 'firstChild' : 'lastChild']; // If the end is placed within the start the result will be removed // So this checks if the out node is a bookmark node if it is it // checks for another more suitable node if (isBookmarkNode(out)) { out = out[start ? 'firstChild' : 'lastChild']; } dom.remove(node, true); return out; } function removeRngStyle(rng) { var startContainer, endContainer; rng = expandRng(rng, formatList, TRUE); if (format.split) { startContainer = getContainer(rng, TRUE); endContainer = getContainer(rng); if (startContainer != endContainer) { // WebKit will render the table incorrectly if we wrap a TD in a SPAN // so lets see if the can use the first child instead // This will happen if you tripple click a table cell and use remove formatting if (/^(TR|TD)$/.test(startContainer.nodeName) && startContainer.firstChild) { if (startContainer.nodeName == "TD") { startContainer = startContainer.firstChild || startContainer; } else { startContainer = startContainer.firstChild.firstChild || startContainer; } } // Wrap start/end nodes in span element since these might be cloned/moved startContainer = wrap(startContainer, 'span', {id: '_start', 'data-mce-type': 'bookmark'}); endContainer = wrap(endContainer, 'span', {id: '_end', 'data-mce-type': 'bookmark'}); // Split start/end splitToFormatRoot(startContainer); splitToFormatRoot(endContainer); // Unwrap start/end to get real elements again startContainer = unwrap(TRUE); endContainer = unwrap(); } else { startContainer = endContainer = splitToFormatRoot(startContainer); } // Update range positions since they might have changed after the split operations rng.startContainer = startContainer.parentNode; rng.startOffset = nodeIndex(startContainer); rng.endContainer = endContainer.parentNode; rng.endOffset = nodeIndex(endContainer) + 1; } // Remove items between start/end rangeUtils.walk(rng, function(nodes) { each(nodes, function(node) { process(node); // Remove parent span if it only contains text-decoration: underline, yet a parent node is also underlined. if (node.nodeType === 1 && ed.dom.getStyle(node, 'text-decoration') === 'underline' && node.parentNode && getTextDecoration(node.parentNode) === 'underline') { removeFormat({ 'deep': false, 'exact': true, 'inline': 'span', 'styles': { 'textDecoration': 'underline' } }, null, node); } }); }); } // Handle node if (node) { if (node.nodeType) { rng = dom.createRng(); rng.setStartBefore(node); rng.setEndAfter(node); removeRngStyle(rng); } else { removeRngStyle(node); } return; } if (!selection.isCollapsed() || !format.inline || dom.select('td.mce-item-selected,th.mce-item-selected').length) { bookmark = selection.getBookmark(); removeRngStyle(selection.getRng(TRUE)); selection.moveToBookmark(bookmark); // Check if start element still has formatting then we are at: "text|text" // and need to move the start into the next text node if (format.inline && match(name, vars, selection.getStart())) { moveStart(selection.getRng(true)); } ed.nodeChanged(); } else { performCaretAction('remove', name, vars); } } /** * Toggles the specified format on/off. * * @method toggle * @param {String} name Name of format to apply/remove. * @param {Object} vars Optional list of variables to replace within format before applying/removing it. * @param {Node} node Optional node to apply the format to or remove from. Defaults to current selection. */ function toggle(name, vars, node) { var fmt = get(name); if (match(name, vars, node) && (!('toggle' in fmt[0]) || fmt[0].toggle)) { remove(name, vars, node); } else { apply(name, vars, node); } } /** * Return true/false if the specified node has the specified format. * * @method matchNode * @param {Node} node Node to check the format on. * @param {String} name Format name to check. * @param {Object} vars Optional list of variables to replace before checking it. * @param {Boolean} similar Match format that has similar properties. * @return {Object} Returns the format object it matches or undefined if it doesn't match. */ function matchNode(node, name, vars, similar) { var formatList = get(name), format, i, classes; function matchItems(node, format, item_name) { var key, value, items = format[item_name], i; // Custom match if (format.onmatch) { return format.onmatch(node, format, item_name); } // Check all items if (items) { // Non indexed object if (items.length === undef) { for (key in items) { if (items.hasOwnProperty(key)) { if (item_name === 'attributes') { value = dom.getAttrib(node, key); } else { value = getStyle(node, key); } if (similar && !value && !format.exact) { return; } if ((!similar || format.exact) && !isEq(value, normalizeStyleValue(replaceVars(items[key], vars), key))) { return; } } } } else { // Only one match needed for indexed arrays for (i = 0; i < items.length; i++) { if (item_name === 'attributes' ? dom.getAttrib(node, items[i]) : getStyle(node, items[i])) { return format; } } } } return format; } if (formatList && node) { // Check each format in list for (i = 0; i < formatList.length; i++) { format = formatList[i]; // Name name, attributes, styles and classes if (matchName(node, format) && matchItems(node, format, 'attributes') && matchItems(node, format, 'styles')) { // Match classes if ((classes = format.classes)) { for (i = 0; i < classes.length; i++) { if (!dom.hasClass(node, classes[i])) { return; } } } return format; } } } } /** * Matches the current selection or specified node against the specified format name. * * @method match * @param {String} name Name of format to match. * @param {Object} vars Optional list of variables to replace before checking it. * @param {Node} node Optional node to check. * @return {boolean} true/false if the specified selection/node matches the format. */ function match(name, vars, node) { var startNode; function matchParents(node) { // Find first node with similar format settings node = dom.getParent(node, function(node) { return !!matchNode(node, name, vars, true); }); // Do an exact check on the similar format element return matchNode(node, name, vars); } // Check specified node if (node) { return matchParents(node); } // Check selected node node = selection.getNode(); if (matchParents(node)) { return TRUE; } // Check start node if it's different startNode = selection.getStart(); if (startNode != node) { if (matchParents(startNode)) { return TRUE; } } return FALSE; } /** * Matches the current selection against the array of formats and returns a new array with matching formats. * * @method matchAll * @param {Array} names Name of format to match. * @param {Object} vars Optional list of variables to replace before checking it. * @return {Array} Array with matched formats. */ function matchAll(names, vars) { var startElement, matchedFormatNames = [], checkedMap = {}; // Check start of selection for formats startElement = selection.getStart(); dom.getParent(startElement, function(node) { var i, name; for (i = 0; i < names.length; i++) { name = names[i]; if (!checkedMap[name] && matchNode(node, name, vars)) { checkedMap[name] = true; matchedFormatNames.push(name); } } }, dom.getRoot()); return matchedFormatNames; } /** * Returns true/false if the specified format can be applied to the current selection or not. It * will currently only check the state for selector formats, it returns true on all other format types. * * @method canApply * @param {String} name Name of format to check. * @return {boolean} true/false if the specified format can be applied to the current selection/node. */ function canApply(name) { var formatList = get(name), startNode, parents, i, x, selector; if (formatList) { startNode = selection.getStart(); parents = getParents(startNode); for (x = formatList.length - 1; x >= 0; x--) { selector = formatList[x].selector; // Format is not selector based, then always return TRUE if (!selector) { return TRUE; } for (i = parents.length - 1; i >= 0; i--) { if (dom.is(parents[i], selector)) { return TRUE; } } } } return FALSE; } /** * Executes the specified callback when the current selection matches the formats or not. * * @method formatChanged * @param {String} formats Comma separated list of formats to check for. * @param {function} callback Callback with state and args when the format is changed/toggled on/off. * @param {Boolean} similar True/false state if the match should handle similar or exact formats. */ function formatChanged(formats, callback, similar) { var currentFormats; // Setup format node change logic if (!formatChangeData) { formatChangeData = {}; currentFormats = {}; ed.on('NodeChange', function(e) { var parents = getParents(e.element), matchedFormats = {}; // Check for new formats each(formatChangeData, function(callbacks, format) { each(parents, function(node) { if (matchNode(node, format, {}, callbacks.similar)) { if (!currentFormats[format]) { // Execute callbacks each(callbacks, function(callback) { callback(true, {node: node, format: format, parents: parents}); }); currentFormats[format] = callbacks; } matchedFormats[format] = callbacks; return false; } }); }); // Check if current formats still match each(currentFormats, function(callbacks, format) { if (!matchedFormats[format]) { delete currentFormats[format]; each(callbacks, function(callback) { callback(false, {node: e.element, format: format, parents: parents}); }); } }); }); } // Add format listeners each(formats.split(','), function(format) { if (!formatChangeData[format]) { formatChangeData[format] = []; formatChangeData[format].similar = similar; } formatChangeData[format].push(callback); }); return this; } // Expose to public extend(this, { get: get, register: register, apply: apply, remove: remove, toggle: toggle, match: match, matchAll: matchAll, matchNode: matchNode, canApply: canApply, formatChanged: formatChanged }); // Initialize defaultFormats(); addKeyboardShortcuts(); ed.on('BeforeGetContent', function() { if (markCaretContainersBogus) { markCaretContainersBogus(); } }); ed.on('mouseup keydown', function(e) { if (disableCaretContainer) { disableCaretContainer(e); } }); // Private functions /** * Checks if the specified nodes name matches the format inline/block or selector. * * @private * @param {Node} node Node to match against the specified format. * @param {Object} format Format object o match with. * @return {boolean} true/false if the format matches. */ function matchName(node, format) { // Check for inline match if (isEq(node, format.inline)) { return TRUE; } // Check for block match if (isEq(node, format.block)) { return TRUE; } // Check for selector match if (format.selector) { return node.nodeType == 1 && dom.is(node, format.selector); } } /** * Compares two string/nodes regardless of their case. * * @private * @param {String/Node} Node or string to compare. * @param {String/Node} Node or string to compare. * @return {boolean} True/false if they match. */ function isEq(str1, str2) { str1 = str1 || ''; str2 = str2 || ''; str1 = '' + (str1.nodeName || str1); str2 = '' + (str2.nodeName || str2); return str1.toLowerCase() == str2.toLowerCase(); } /** * Returns the style by name on the specified node. This method modifies the style * contents to make it more easy to match. This will resolve a few browser issues. * * @private * @param {Node} node to get style from. * @param {String} name Style name to get. * @return {String} Style item value. */ function getStyle(node, name) { return normalizeStyleValue(dom.getStyle(node, name), name); } /** * Normalize style value by name. This method modifies the style contents * to make it more easy to match. This will resolve a few browser issues. * * @private * @param {Node} node to get style from. * @param {String} name Style name to get. * @return {String} Style item value. */ function normalizeStyleValue(value, name) { // Force the format to hex if (name == 'color' || name == 'backgroundColor') { value = dom.toHex(value); } // Opera will return bold as 700 if (name == 'fontWeight' && value == 700) { value = 'bold'; } // Normalize fontFamily so "'Font name', Font" becomes: "Font name,Font" if (name == 'fontFamily') { value = value.replace(/[\'\"]/g, '').replace(/,\s+/g, ','); } return '' + value; } /** * Replaces variables in the value. The variable format is %var. * * @private * @param {String} value Value to replace variables in. * @param {Object} vars Name/value array with variables to replace. * @return {String} New value with replaced variables. */ function replaceVars(value, vars) { if (typeof(value) != "string") { value = value(vars); } else if (vars) { value = value.replace(/%(\w+)/g, function(str, name) { return vars[name] || str; }); } return value; } function isWhiteSpaceNode(node) { return node && node.nodeType === 3 && /^([\t \r\n]+|)$/.test(node.nodeValue); } function wrap(node, name, attrs) { var wrapper = dom.create(name, attrs); node.parentNode.insertBefore(wrapper, node); wrapper.appendChild(node); return wrapper; } /** * Expands the specified range like object to depending on format. * * For example on block formats it will move the start/end position * to the beginning of the current block. * * @private * @param {Object} rng Range like object. * @param {Array} formats Array with formats to expand by. * @return {Object} Expanded range like object. */ function expandRng(rng, format, remove) { var lastIdx, leaf, endPoint, startContainer = rng.startContainer, startOffset = rng.startOffset, endContainer = rng.endContainer, endOffset = rng.endOffset; // This function walks up the tree if there is no siblings before/after the node function findParentContainer(start) { var container, parent, sibling, siblingName, root; container = parent = start ? startContainer : endContainer; siblingName = start ? 'previousSibling' : 'nextSibling'; root = dom.getRoot(); function isBogusBr(node) { return node.nodeName == "BR" && node.getAttribute('data-mce-bogus') && !node.nextSibling; } // If it's a text node and the offset is inside the text if (container.nodeType == 3 && !isWhiteSpaceNode(container)) { if (start ? startOffset > 0 : endOffset < container.nodeValue.length) { return container; } } for (;;) { // Stop expanding on block elements if (!format[0].block_expand && isBlock(parent)) { return parent; } // Walk left/right for (sibling = parent[siblingName]; sibling; sibling = sibling[siblingName]) { if (!isBookmarkNode(sibling) && !isWhiteSpaceNode(sibling) && !isBogusBr(sibling)) { return parent; } } // Check if we can move up are we at root level or body level if (parent.parentNode == root) { container = parent; break; } parent = parent.parentNode; } return container; } // This function walks down the tree to find the leaf at the selection. // The offset is also returned as if node initially a leaf, the offset may be in the middle of the text node. function findLeaf(node, offset) { if (offset === undef) { offset = node.nodeType === 3 ? node.length : node.childNodes.length; } while (node && node.hasChildNodes()) { node = node.childNodes[offset]; if (node) { offset = node.nodeType === 3 ? node.length : node.childNodes.length; } } return { node: node, offset: offset }; } // If index based start position then resolve it if (startContainer.nodeType == 1 && startContainer.hasChildNodes()) { lastIdx = startContainer.childNodes.length - 1; startContainer = startContainer.childNodes[startOffset > lastIdx ? lastIdx : startOffset]; if (startContainer.nodeType == 3) { startOffset = 0; } } // If index based end position then resolve it if (endContainer.nodeType == 1 && endContainer.hasChildNodes()) { lastIdx = endContainer.childNodes.length - 1; endContainer = endContainer.childNodes[endOffset > lastIdx ? lastIdx : endOffset - 1]; if (endContainer.nodeType == 3) { endOffset = endContainer.nodeValue.length; } } // Expands the node to the closes contentEditable false element if it exists function findParentContentEditable(node) { var parent = node; while (parent) { if (parent.nodeType === 1 && getContentEditable(parent)) { return getContentEditable(parent) === "false" ? parent : node; } parent = parent.parentNode; } return node; } function findWordEndPoint(container, offset, start) { var walker, node, pos, lastTextNode; function findSpace(node, offset) { var pos, pos2, str = node.nodeValue; if (typeof(offset) == "undefined") { offset = start ? str.length : 0; } if (start) { pos = str.lastIndexOf(' ', offset); pos2 = str.lastIndexOf('\u00a0', offset); pos = pos > pos2 ? pos : pos2; // Include the space on remove to avoid tag soup if (pos !== -1 && !remove) { pos++; } } else { pos = str.indexOf(' ', offset); pos2 = str.indexOf('\u00a0', offset); pos = pos !== -1 && (pos2 === -1 || pos < pos2) ? pos : pos2; } return pos; } if (container.nodeType === 3) { pos = findSpace(container, offset); if (pos !== -1) { return {container: container, offset: pos}; } lastTextNode = container; } // Walk the nodes inside the block walker = new TreeWalker(container, dom.getParent(container, isBlock) || ed.getBody()); while ((node = walker[start ? 'prev' : 'next']())) { if (node.nodeType === 3) { lastTextNode = node; pos = findSpace(node); if (pos !== -1) { return {container: node, offset: pos}; } } else if (isBlock(node)) { break; } } if (lastTextNode) { if (start) { offset = 0; } else { offset = lastTextNode.length; } return {container: lastTextNode, offset: offset}; } } function findSelectorEndPoint(container, sibling_name) { var parents, i, y, curFormat; if (container.nodeType == 3 && container.nodeValue.length === 0 && container[sibling_name]) { container = container[sibling_name]; } parents = getParents(container); for (i = 0; i < parents.length; i++) { for (y = 0; y < format.length; y++) { curFormat = format[y]; // If collapsed state is set then skip formats that doesn't match that if ("collapsed" in curFormat && curFormat.collapsed !== rng.collapsed) { continue; } if (dom.is(parents[i], curFormat.selector)) { return parents[i]; } } } return container; } function findBlockEndPoint(container, sibling_name) { var node; // Expand to block of similar type if (!format[0].wrapper) { node = dom.getParent(container, format[0].block); } // Expand to first wrappable block element or any block element if (!node) { node = dom.getParent(container.nodeType == 3 ? container.parentNode : container, isTextBlock); } // Exclude inner lists from wrapping if (node && format[0].wrapper) { node = getParents(node, 'ul,ol').reverse()[0] || node; } // Didn't find a block element look for first/last wrappable element if (!node) { node = container; while (node[sibling_name] && !isBlock(node[sibling_name])) { node = node[sibling_name]; // Break on BR but include it will be removed later on // we can't remove it now since we need to check if it can be wrapped if (isEq(node, 'br')) { break; } } } return node || container; } // Expand to closest contentEditable element startContainer = findParentContentEditable(startContainer); endContainer = findParentContentEditable(endContainer); // Exclude bookmark nodes if possible if (isBookmarkNode(startContainer.parentNode) || isBookmarkNode(startContainer)) { startContainer = isBookmarkNode(startContainer) ? startContainer : startContainer.parentNode; startContainer = startContainer.nextSibling || startContainer; if (startContainer.nodeType == 3) { startOffset = 0; } } if (isBookmarkNode(endContainer.parentNode) || isBookmarkNode(endContainer)) { endContainer = isBookmarkNode(endContainer) ? endContainer : endContainer.parentNode; endContainer = endContainer.previousSibling || endContainer; if (endContainer.nodeType == 3) { endOffset = endContainer.length; } } if (format[0].inline) { if (rng.collapsed) { // Expand left to closest word boundery endPoint = findWordEndPoint(startContainer, startOffset, true); if (endPoint) { startContainer = endPoint.container; startOffset = endPoint.offset; } // Expand right to closest word boundery endPoint = findWordEndPoint(endContainer, endOffset); if (endPoint) { endContainer = endPoint.container; endOffset = endPoint.offset; } } // Avoid applying formatting to a trailing space. leaf = findLeaf(endContainer, endOffset); if (leaf.node) { while (leaf.node && leaf.offset === 0 && leaf.node.previousSibling) { leaf = findLeaf(leaf.node.previousSibling); } if (leaf.node && leaf.offset > 0 && leaf.node.nodeType === 3 && leaf.node.nodeValue.charAt(leaf.offset - 1) === ' ') { if (leaf.offset > 1) { endContainer = leaf.node; endContainer.splitText(leaf.offset - 1); } } } } // Move start/end point up the tree if the leaves are sharp and if we are in different containers // Example * becomes !: !

*texttext*

! // This will reduce the number of wrapper elements that needs to be created // Move start point up the tree if (format[0].inline || format[0].block_expand) { if (!format[0].inline || (startContainer.nodeType != 3 || startOffset === 0)) { startContainer = findParentContainer(true); } if (!format[0].inline || (endContainer.nodeType != 3 || endOffset === endContainer.nodeValue.length)) { endContainer = findParentContainer(); } } // Expand start/end container to matching selector if (format[0].selector && format[0].expand !== FALSE && !format[0].inline) { // Find new startContainer/endContainer if there is better one startContainer = findSelectorEndPoint(startContainer, 'previousSibling'); endContainer = findSelectorEndPoint(endContainer, 'nextSibling'); } // Expand start/end container to matching block element or text node if (format[0].block || format[0].selector) { // Find new startContainer/endContainer if there is better one startContainer = findBlockEndPoint(startContainer, 'previousSibling'); endContainer = findBlockEndPoint(endContainer, 'nextSibling'); // Non block element then try to expand up the leaf if (format[0].block) { if (!isBlock(startContainer)) { startContainer = findParentContainer(true); } if (!isBlock(endContainer)) { endContainer = findParentContainer(); } } } // Setup index for startContainer if (startContainer.nodeType == 1) { startOffset = nodeIndex(startContainer); startContainer = startContainer.parentNode; } // Setup index for endContainer if (endContainer.nodeType == 1) { endOffset = nodeIndex(endContainer) + 1; endContainer = endContainer.parentNode; } // Return new range like object return { startContainer: startContainer, startOffset: startOffset, endContainer: endContainer, endOffset: endOffset }; } /** * Removes the specified format for the specified node. It will also remove the node if it doesn't have * any attributes if the format specifies it to do so. * * @private * @param {Object} format Format object with items to remove from node. * @param {Object} vars Name/value object with variables to apply to format. * @param {Node} node Node to remove the format styles on. * @param {Node} compare_node Optional compare node, if specified the styles will be compared to that node. * @return {Boolean} True/false if the node was removed or not. */ function removeFormat(format, vars, node, compare_node) { var i, attrs, stylesModified; // Check if node matches format if (!matchName(node, format)) { return FALSE; } // Should we compare with format attribs and styles if (format.remove != 'all') { // Remove styles each(format.styles, function(value, name) { value = normalizeStyleValue(replaceVars(value, vars), name); // Indexed array if (typeof(name) === 'number') { name = value; compare_node = 0; } if (!compare_node || isEq(getStyle(compare_node, name), value)) { dom.setStyle(node, name, ''); } stylesModified = 1; }); // Remove style attribute if it's empty if (stylesModified && dom.getAttrib(node, 'style') === '') { node.removeAttribute('style'); node.removeAttribute('data-mce-style'); } // Remove attributes each(format.attributes, function(value, name) { var valueOut; value = replaceVars(value, vars); // Indexed array if (typeof(name) === 'number') { name = value; compare_node = 0; } if (!compare_node || isEq(dom.getAttrib(compare_node, name), value)) { // Keep internal classes if (name == 'class') { value = dom.getAttrib(node, name); if (value) { // Build new class value where everything is removed except the internal prefixed classes valueOut = ''; each(value.split(/\s+/), function(cls) { if (/mce\w+/.test(cls)) { valueOut += (valueOut ? ' ' : '') + cls; } }); // We got some internal classes left if (valueOut) { dom.setAttrib(node, name, valueOut); return; } } } // IE6 has a bug where the attribute doesn't get removed correctly if (name == "class") { node.removeAttribute('className'); } // Remove mce prefixed attributes if (MCE_ATTR_RE.test(name)) { node.removeAttribute('data-mce-' + name); } node.removeAttribute(name); } }); // Remove classes each(format.classes, function(value) { value = replaceVars(value, vars); if (!compare_node || dom.hasClass(compare_node, value)) { dom.removeClass(node, value); } }); // Check for non internal attributes attrs = dom.getAttribs(node); for (i = 0; i < attrs.length; i++) { if (attrs[i].nodeName.indexOf('_') !== 0) { return FALSE; } } } // Remove the inline child if it's empty for example or if (format.remove != 'none') { removeNode(node, format); return TRUE; } } /** * Removes the node and wrap it's children in paragraphs before doing so or * appends BR elements to the beginning/end of the block element if forcedRootBlocks is disabled. * * If the div in the node below gets removed: * text
text
text * * Output becomes: * text

text
text * * So when the div is removed the result is: * text
text
text * * @private * @param {Node} node Node to remove + apply BR/P elements to. * @param {Object} format Format rule. * @return {Node} Input node. */ function removeNode(node, format) { var parentNode = node.parentNode, rootBlockElm; function find(node, next, inc) { node = getNonWhiteSpaceSibling(node, next, inc); return !node || (node.nodeName == 'BR' || isBlock(node)); } if (format.block) { if (!forcedRootBlock) { // Append BR elements if needed before we remove the block if (isBlock(node) && !isBlock(parentNode)) { if (!find(node, FALSE) && !find(node.firstChild, TRUE, 1)) { node.insertBefore(dom.create('br'), node.firstChild); } if (!find(node, TRUE) && !find(node.lastChild, FALSE, 1)) { node.appendChild(dom.create('br')); } } } else { // Wrap the block in a forcedRootBlock if we are at the root of document if (parentNode == dom.getRoot()) { if (!format.list_block || !isEq(node, format.list_block)) { each(grep(node.childNodes), function(node) { if (isValid(forcedRootBlock, node.nodeName.toLowerCase())) { if (!rootBlockElm) { rootBlockElm = wrap(node, forcedRootBlock); } else { rootBlockElm.appendChild(node); } } else { rootBlockElm = 0; } }); } } } } // Never remove nodes that isn't the specified inline element if a selector is specified too if (format.selector && format.inline && !isEq(format.inline, node)) { return; } dom.remove(node, 1); } /** * Returns the next/previous non whitespace node. * * @private * @param {Node} node Node to start at. * @param {boolean} next (Optional) Include next or previous node defaults to previous. * @param {boolean} inc (Optional) Include the current node in checking. Defaults to false. * @return {Node} Next or previous node or undefined if it wasn't found. */ function getNonWhiteSpaceSibling(node, next, inc) { if (node) { next = next ? 'nextSibling' : 'previousSibling'; for (node = inc ? node : node[next]; node; node = node[next]) { if (node.nodeType == 1 || !isWhiteSpaceNode(node)) { return node; } } } } /** * Checks if the specified node is a bookmark node or not. * * @private * @param {Node} node Node to check if it's a bookmark node or not. * @return {Boolean} true/false if the node is a bookmark node. */ function isBookmarkNode(node) { return node && node.nodeType == 1 && node.getAttribute('data-mce-type') == 'bookmark'; } /** * Merges the next/previous sibling element if they match. * * @private * @param {Node} prev Previous node to compare/merge. * @param {Node} next Next node to compare/merge. * @return {Node} Next node if we didn't merge and prev node if we did. */ function mergeSiblings(prev, next) { var sibling, tmpSibling; /** * Compares two nodes and checks if it's attributes and styles matches. * This doesn't compare classes as items since their order is significant. * * @private * @param {Node} node1 First node to compare with. * @param {Node} node2 Second node to compare with. * @return {boolean} True/false if the nodes are the same or not. */ function compareElements(node1, node2) { // Not the same name if (node1.nodeName != node2.nodeName) { return FALSE; } /** * Returns all the nodes attributes excluding internal ones, styles and classes. * * @private * @param {Node} node Node to get attributes from. * @return {Object} Name/value object with attributes and attribute values. */ function getAttribs(node) { var attribs = {}; each(dom.getAttribs(node), function(attr) { var name = attr.nodeName.toLowerCase(); // Don't compare internal attributes or style if (name.indexOf('_') !== 0 && name !== 'style') { attribs[name] = dom.getAttrib(node, name); } }); return attribs; } /** * Compares two objects checks if it's key + value exists in the other one. * * @private * @param {Object} obj1 First object to compare. * @param {Object} obj2 Second object to compare. * @return {boolean} True/false if the objects matches or not. */ function compareObjects(obj1, obj2) { var value, name; for (name in obj1) { // Obj1 has item obj2 doesn't have if (obj1.hasOwnProperty(name)) { value = obj2[name]; // Obj2 doesn't have obj1 item if (value === undef) { return FALSE; } // Obj2 item has a different value if (obj1[name] != value) { return FALSE; } // Delete similar value delete obj2[name]; } } // Check if obj 2 has something obj 1 doesn't have for (name in obj2) { // Obj2 has item obj1 doesn't have if (obj2.hasOwnProperty(name)) { return FALSE; } } return TRUE; } // Attribs are not the same if (!compareObjects(getAttribs(node1), getAttribs(node2))) { return FALSE; } // Styles are not the same if (!compareObjects(dom.parseStyle(dom.getAttrib(node1, 'style')), dom.parseStyle(dom.getAttrib(node2, 'style')))) { return FALSE; } return TRUE; } function findElementSibling(node, sibling_name) { for (sibling = node; sibling; sibling = sibling[sibling_name]) { if (sibling.nodeType == 3 && sibling.nodeValue.length !== 0) { return node; } if (sibling.nodeType == 1 && !isBookmarkNode(sibling)) { return sibling; } } return node; } // Check if next/prev exists and that they are elements if (prev && next) { // If previous sibling is empty then jump over it prev = findElementSibling(prev, 'previousSibling'); next = findElementSibling(next, 'nextSibling'); // Compare next and previous nodes if (compareElements(prev, next)) { // Append nodes between for (sibling = prev.nextSibling; sibling && sibling != next;) { tmpSibling = sibling; sibling = sibling.nextSibling; prev.appendChild(tmpSibling); } // Remove next node dom.remove(next); // Move children into prev node each(grep(next.childNodes), function(node) { prev.appendChild(node); }); return prev; } } return next; } function getContainer(rng, start) { var container, offset, lastIdx; container = rng[start ? 'startContainer' : 'endContainer']; offset = rng[start ? 'startOffset' : 'endOffset']; if (container.nodeType == 1) { lastIdx = container.childNodes.length - 1; if (!start && offset) { offset--; } container = container.childNodes[offset > lastIdx ? lastIdx : offset]; } // If start text node is excluded then walk to the next node if (container.nodeType === 3 && start && offset >= container.nodeValue.length) { container = new TreeWalker(container, ed.getBody()).next() || container; } // If end text node is excluded then walk to the previous node if (container.nodeType === 3 && !start && offset === 0) { container = new TreeWalker(container, ed.getBody()).prev() || container; } return container; } function performCaretAction(type, name, vars) { var caretContainerId = '_mce_caret', debug = ed.settings.caret_debug; // Creates a caret container bogus element function createCaretContainer(fill) { var caretContainer = dom.create('span', {id: caretContainerId, 'data-mce-bogus': true, style: debug ? 'color:red' : ''}); if (fill) { caretContainer.appendChild(ed.getDoc().createTextNode(INVISIBLE_CHAR)); } return caretContainer; } function isCaretContainerEmpty(node, nodes) { while (node) { if ((node.nodeType === 3 && node.nodeValue !== INVISIBLE_CHAR) || node.childNodes.length > 1) { return false; } // Collect nodes if (nodes && node.nodeType === 1) { nodes.push(node); } node = node.firstChild; } return true; } // Returns any parent caret container element function getParentCaretContainer(node) { while (node) { if (node.id === caretContainerId) { return node; } node = node.parentNode; } } // Finds the first text node in the specified node function findFirstTextNode(node) { var walker; if (node) { walker = new TreeWalker(node, node); for (node = walker.current(); node; node = walker.next()) { if (node.nodeType === 3) { return node; } } } } // Removes the caret container for the specified node or all on the current document function removeCaretContainer(node, move_caret) { var child, rng; if (!node) { node = getParentCaretContainer(selection.getStart()); if (!node) { while ((node = dom.get(caretContainerId))) { removeCaretContainer(node, false); } } } else { rng = selection.getRng(true); if (isCaretContainerEmpty(node)) { if (move_caret !== false) { rng.setStartBefore(node); rng.setEndBefore(node); } dom.remove(node); } else { child = findFirstTextNode(node); if (child.nodeValue.charAt(0) === INVISIBLE_CHAR) { child = child.deleteData(0, 1); } dom.remove(node, 1); } selection.setRng(rng); } } // Applies formatting to the caret postion function applyCaretFormat() { var rng, caretContainer, textNode, offset, bookmark, container, text; rng = selection.getRng(true); offset = rng.startOffset; container = rng.startContainer; text = container.nodeValue; caretContainer = getParentCaretContainer(selection.getStart()); if (caretContainer) { textNode = findFirstTextNode(caretContainer); } // Expand to word is caret is in the middle of a text node and the char before/after is a alpha numeric character if (text && offset > 0 && offset < text.length && /\w/.test(text.charAt(offset)) && /\w/.test(text.charAt(offset - 1))) { // Get bookmark of caret position bookmark = selection.getBookmark(); // Collapse bookmark range (WebKit) rng.collapse(true); // Expand the range to the closest word and split it at those points rng = expandRng(rng, get(name)); rng = rangeUtils.split(rng); // Apply the format to the range apply(name, vars, rng); // Move selection back to caret position selection.moveToBookmark(bookmark); } else { if (!caretContainer || textNode.nodeValue !== INVISIBLE_CHAR) { caretContainer = createCaretContainer(true); textNode = caretContainer.firstChild; rng.insertNode(caretContainer); offset = 1; apply(name, vars, caretContainer); } else { apply(name, vars, caretContainer); } // Move selection to text node selection.setCursorLocation(textNode, offset); } } function removeCaretFormat() { var rng = selection.getRng(true), container, offset, bookmark, hasContentAfter, node, formatNode, parents = [], i, caretContainer; container = rng.startContainer; offset = rng.startOffset; node = container; if (container.nodeType == 3) { if (offset != container.nodeValue.length || container.nodeValue === INVISIBLE_CHAR) { hasContentAfter = true; } node = node.parentNode; } while (node) { if (matchNode(node, name, vars)) { formatNode = node; break; } if (node.nextSibling) { hasContentAfter = true; } parents.push(node); node = node.parentNode; } // Node doesn't have the specified format if (!formatNode) { return; } // Is there contents after the caret then remove the format on the element if (hasContentAfter) { // Get bookmark of caret position bookmark = selection.getBookmark(); // Collapse bookmark range (WebKit) rng.collapse(true); // Expand the range to the closest word and split it at those points rng = expandRng(rng, get(name), true); rng = rangeUtils.split(rng); // Remove the format from the range remove(name, vars, rng); // Move selection back to caret position selection.moveToBookmark(bookmark); } else { caretContainer = createCaretContainer(); node = caretContainer; for (i = parents.length - 1; i >= 0; i--) { node.appendChild(dom.clone(parents[i], false)); node = node.firstChild; } // Insert invisible character into inner most format element node.appendChild(dom.doc.createTextNode(INVISIBLE_CHAR)); node = node.firstChild; var block = dom.getParent(formatNode, isTextBlock); if (block && dom.isEmpty(block)) { // Replace formatNode with caretContainer when removing format from empty block like

|

formatNode.parentNode.replaceChild(caretContainer, formatNode); } else { // Insert caret container after the formated node dom.insertAfter(caretContainer, formatNode); } // Move selection to text node selection.setCursorLocation(node, 1); } } // Checks if the parent caret container node isn't empty if that is the case it // will remove the bogus state on all children that isn't empty function unmarkBogusCaretParents() { var caretContainer; caretContainer = getParentCaretContainer(selection.getStart()); if (caretContainer && !dom.isEmpty(caretContainer)) { walk(caretContainer, function(node) { if (node.nodeType == 1 && node.id !== caretContainerId && !dom.isEmpty(node)) { dom.setAttrib(node, 'data-mce-bogus', null); } }, 'childNodes'); } } // Only bind the caret events once if (!ed._hasCaretEvents) { // Mark current caret container elements as bogus when getting the contents so we don't end up with empty elements markCaretContainersBogus = function() { var nodes = [], i; if (isCaretContainerEmpty(getParentCaretContainer(selection.getStart()), nodes)) { // Mark children i = nodes.length; while (i--) { dom.setAttrib(nodes[i], 'data-mce-bogus', '1'); } } }; disableCaretContainer = function(e) { var keyCode = e.keyCode; removeCaretContainer(); // Remove caret container on keydown and it's a backspace, enter or left/right arrow keys if (keyCode == 8 || keyCode == 37 || keyCode == 39) { removeCaretContainer(getParentCaretContainer(selection.getStart())); } unmarkBogusCaretParents(); }; // Remove bogus state if they got filled by contents using editor.selection.setContent ed.on('SetContent', function(e) { if (e.selection) { unmarkBogusCaretParents(); } }); ed._hasCaretEvents = true; } // Do apply or remove caret format if (type == "apply") { applyCaretFormat(); } else { removeCaretFormat(); } } /** * Moves the start to the first suitable text node. */ function moveStart(rng) { var container = rng.startContainer, offset = rng.startOffset, isAtEndOfText, walker, node, nodes, tmpNode; // Convert text node into index if possible if (container.nodeType == 3 && offset >= container.nodeValue.length) { // Get the parent container location and walk from there offset = nodeIndex(container); container = container.parentNode; isAtEndOfText = true; } // Move startContainer/startOffset in to a suitable node if (container.nodeType == 1) { nodes = container.childNodes; container = nodes[Math.min(offset, nodes.length - 1)]; walker = new TreeWalker(container, dom.getParent(container, dom.isBlock)); // If offset is at end of the parent node walk to the next one if (offset > nodes.length - 1 || isAtEndOfText) { walker.next(); } for (node = walker.current(); node; node = walker.next()) { if (node.nodeType == 3 && !isWhiteSpaceNode(node)) { // IE has a "neat" feature where it moves the start node into the closest element // we can avoid this by inserting an element before it and then remove it after we set the selection tmpNode = dom.create('a', null, INVISIBLE_CHAR); node.parentNode.insertBefore(tmpNode, node); // Set selection and remove tmpNode rng.setStart(node, 0); selection.setRng(rng); dom.remove(tmpNode); return; } } } } }; });