4 * Copyright, Moxiecode Systems AB
5 * Released under LGPL License.
7 * License: http://www.tinymce.com/license
8 * Contributing: http://www.tinymce.com/contributing
12 * Text formatter engine class. This class is used to apply formats like bold, italic, font size
13 * etc to the current selection or specific nodes. This engine was build to replace the browsers
14 * default formatting logic for execCommand due to it's inconsistent and buggy behavior.
16 * @class tinymce.Formatter
18 * tinymce.activeEditor.formatter.register('mycustomformat', {
20 * styles: {color: '#ff0000'}
23 * tinymce.activeEditor.formatter.apply('mycustomformat');
25 define("tinymce/Formatter", [
26 "tinymce/dom/TreeWalker",
27 "tinymce/dom/RangeUtils",
29 ], function(TreeWalker, RangeUtils, Tools) {
31 * Constructs a new formatter instance.
33 * @constructor Formatter
34 * @param {tinymce.Editor} ed Editor instance to construct the formatter engine to.
39 selection = ed.selection,
40 rangeUtils = new RangeUtils(dom),
41 isValid = ed.schema.isValidChild,
42 isBlock = dom.isBlock,
43 forcedRootBlock = ed.settings.forced_root_block,
44 nodeIndex = dom.nodeIndex,
45 INVISIBLE_CHAR = '\uFEFF',
46 MCE_ATTR_RE = /^(src|href|style)$/,
51 getContentEditable = dom.getContentEditable,
52 disableCaretContainer,
53 markCaretContainersBogus;
55 var each = Tools.each,
58 extend = Tools.extend;
60 function isTextBlock(name) {
65 return !!ed.schema.getTextBlockElements()[name.toLowerCase()];
68 function getParents(node, selector) {
69 return dom.getParents(node, selector, dom.getRoot());
72 function isCaretNode(node) {
73 return node.nodeType === 1 && node.id === '_mce_caret';
76 function defaultFormats() {
79 {selector: 'figure,p,h1,h2,h3,h4,h5,h6,td,th,tr,div,ul,ol,li', styles: {textAlign: 'left'}, defaultBlock: 'div'},
80 {selector: 'img,table', collapsed: false, styles: {'float': 'left'}}
84 {selector: 'figure,p,h1,h2,h3,h4,h5,h6,td,th,tr,div,ul,ol,li', styles: {textAlign: 'center'}, defaultBlock: 'div'},
85 {selector: 'img', collapsed: false, styles: {display: 'block', marginLeft: 'auto', marginRight: 'auto'}},
86 {selector: 'table', collapsed: false, styles: {marginLeft: 'auto', marginRight: 'auto'}}
90 {selector: 'figure,p,h1,h2,h3,h4,h5,h6,td,th,tr,div,ul,ol,li', styles: {textAlign: 'right'}, defaultBlock: 'div'},
91 {selector: 'img,table', collapsed: false, styles: {'float': 'right'}}
95 {selector: 'figure,p,h1,h2,h3,h4,h5,h6,td,th,tr,div,ul,ol,li', styles: {textAlign: 'justify'}, defaultBlock: 'div'}
99 {inline: 'strong', remove: 'all'},
100 {inline: 'span', styles: {fontWeight: 'bold'}},
101 {inline: 'b', remove: 'all'}
105 {inline: 'em', remove: 'all'},
106 {inline: 'span', styles: {fontStyle: 'italic'}},
107 {inline: 'i', remove: 'all'}
111 {inline: 'span', styles: {textDecoration: 'underline'}, exact: true},
112 {inline: 'u', remove: 'all'}
116 {inline: 'span', styles: {textDecoration: 'line-through'}, exact: true},
117 {inline: 'strike', remove: 'all'}
120 forecolor: {inline: 'span', styles: {color: '%value'}, wrap_links: false},
121 hilitecolor: {inline: 'span', styles: {backgroundColor: '%value'}, wrap_links: false},
122 fontname: {inline: 'span', styles: {fontFamily: '%value'}},
123 fontsize: {inline: 'span', styles: {fontSize: '%value'}},
124 fontsize_class: {inline: 'span', attributes: {'class': '%value'}},
125 blockquote: {block: 'blockquote', wrapper: 1, remove: 'all'},
126 subscript: {inline: 'sub'},
127 superscript: {inline: 'sup'},
128 code: {inline: 'code'},
130 link: {inline: 'a', selector: 'a', remove: 'all', split: true, deep: true,
131 onmatch: function() {
135 onformat: function(elm, fmt, vars) {
136 each(vars, function(value, key) {
137 dom.setAttrib(elm, key, value);
144 selector: 'b,strong,em,i,font,u,strike,sub,sup',
151 {selector: 'span', attributes: ['style', 'class'], remove: 'empty', split: true, expand: false, deep: true},
152 {selector: '*', attributes: ['style', 'class'], split: false, expand: false, deep: true}
156 // Register default block formats
157 each('p h1 h2 h3 h4 h5 h6 div address pre div dt dd samp'.split(/\s/), function(name) {
158 register(name, {block: name, remove: 'all'});
161 // Register user defined formats
162 register(ed.settings.formats);
165 function addKeyboardShortcuts() {
166 // Add some inline shortcuts
167 ed.addShortcut('ctrl+b', 'bold_desc', 'Bold');
168 ed.addShortcut('ctrl+i', 'italic_desc', 'Italic');
169 ed.addShortcut('ctrl+u', 'underline_desc', 'Underline');
171 // BlockFormat shortcuts keys
172 for (var i = 1; i <= 6; i++) {
173 ed.addShortcut('ctrl+' + i, '', ['FormatBlock', false, 'h' + i]);
176 ed.addShortcut('ctrl+7', '', ['FormatBlock', false, 'p']);
177 ed.addShortcut('ctrl+8', '', ['FormatBlock', false, 'div']);
178 ed.addShortcut('ctrl+9', '', ['FormatBlock', false, 'address']);
184 * Returns the format by name or all formats if no name is specified.
187 * @param {String} name Optional name to retrive by.
188 * @return {Array/Object} Array/Object with all registred formats or a specific format.
191 return name ? formats[name] : formats;
195 * Registers a specific format by name.
198 * @param {Object/String} name Name of the format for example "bold".
199 * @param {Object/Array} format Optional format object or array of format variants
200 * can only be omitted if the first arg is an object.
202 function register(name, format) {
204 if (typeof(name) !== 'string') {
205 each(name, function(format, name) {
206 register(name, format);
209 // Force format into array and add it to internal collection
210 format = format.length ? format : [format];
212 each(format, function(format) {
213 // Set deep to false by default on selector formats this to avoid removing
214 // alignment on images inside paragraphs when alignment is changed on paragraphs
215 if (format.deep === undef) {
216 format.deep = !format.selector;
220 if (format.split === undef) {
221 format.split = !format.selector || format.inline;
225 if (format.remove === undef && format.selector && !format.inline) {
226 format.remove = 'none';
229 // Mark format as a mixed format inline + block level
230 if (format.selector && format.inline) {
232 format.block_expand = true;
235 // Split classes if needed
236 if (typeof(format.classes) === 'string') {
237 format.classes = format.classes.split(/\s+/);
241 formats[name] = format;
246 function getTextDecoration(node) {
249 ed.dom.getParent(node, function(n) {
250 decoration = ed.dom.getStyle(n, 'text-decoration');
251 return decoration && decoration !== 'none';
257 function processUnderlineAndColor(node) {
259 if (node.nodeType === 1 && node.parentNode && node.parentNode.nodeType === 1) {
260 textDecoration = getTextDecoration(node.parentNode);
261 if (ed.dom.getStyle(node, 'color') && textDecoration) {
262 ed.dom.setStyle(node, 'text-decoration', textDecoration);
263 } else if (ed.dom.getStyle(node, 'textdecoration') === textDecoration) {
264 ed.dom.setStyle(node, 'text-decoration', null);
270 * Applies the specified format to the current selection or specified node.
273 * @param {String} name Name of format to apply.
274 * @param {Object} vars Optional list of variables to replace within format before applying it.
275 * @param {Node} node Optional node to apply the format to defaults to current selection.
277 function apply(name, vars, node) {
278 var formatList = get(name), format = formatList[0], bookmark, rng, isCollapsed = !node && selection.isCollapsed();
280 function setElementFormat(elm, fmt) {
285 fmt.onformat(elm, fmt, vars, node);
288 each(fmt.styles, function(value, name) {
289 dom.setStyle(elm, name, replaceVars(value, vars));
292 each(fmt.attributes, function(value, name) {
293 dom.setAttrib(elm, name, replaceVars(value, vars));
296 each(fmt.classes, function(value) {
297 value = replaceVars(value, vars);
299 if (!dom.hasClass(elm, value)) {
300 dom.addClass(elm, value);
306 function adjustSelectionToVisibleSelection() {
307 function findSelectionEnd(start, end) {
308 var walker = new TreeWalker(end);
309 for (node = walker.current(); node; node = walker.prev()) {
310 if (node.childNodes.length > 1 || node == start || node.tagName == 'BR') {
316 // Adjust selection so that a end container with a end offset of zero is not included in the selection
317 // as this isn't visible to the user.
318 var rng = ed.selection.getRng();
319 var start = rng.startContainer;
320 var end = rng.endContainer;
322 if (start != end && rng.endOffset === 0) {
323 var newEnd = findSelectionEnd(start, end);
324 var endOffset = newEnd.nodeType == 3 ? newEnd.length : newEnd.childNodes.length;
326 rng.setEnd(newEnd, endOffset);
332 function applyStyleToList(node, bookmark, wrapElm, newWrappers, process){
333 var nodes = [], listIndex = -1, list, startIndex = -1, endIndex = -1, currentWrapElm;
335 // find the index of the first child list.
336 each(node.childNodes, function(n, index) {
337 if (n.nodeName === "UL" || n.nodeName === "OL") {
344 // get the index of the bookmarks
345 each(node.childNodes, function(n, index) {
346 if (n.nodeName === "SPAN" && dom.getAttrib(n, "data-mce-type") == "bookmark") {
347 if (n.id == bookmark.id + "_start") {
349 } else if (n.id == bookmark.id + "_end") {
355 // if the selection spans across an embedded list, or there isn't an embedded list - handle processing normally
356 if (listIndex <= 0 || (startIndex < listIndex && endIndex > listIndex)) {
357 each(grep(node.childNodes), process);
360 currentWrapElm = dom.clone(wrapElm, FALSE);
362 // create a list of the nodes on the same side of the list as the selection
363 each(grep(node.childNodes), function(n, index) {
364 if ((startIndex < listIndex && index < listIndex) || (startIndex > listIndex && index > listIndex)) {
366 n.parentNode.removeChild(n);
370 // insert the wrapping element either before or after the list.
371 if (startIndex < listIndex) {
372 node.insertBefore(currentWrapElm, list);
373 } else if (startIndex > listIndex) {
374 node.insertBefore(currentWrapElm, list.nextSibling);
377 // add the new nodes to the list.
378 newWrappers.push(currentWrapElm);
380 each(nodes, function(node) {
381 currentWrapElm.appendChild(node);
384 return currentWrapElm;
388 function applyRngStyle(rng, bookmark, node_specific) {
389 var newWrappers = [], wrapName, wrapElm, contentEditable = true;
391 // Setup wrapper element
392 wrapName = format.inline || format.block;
393 wrapElm = dom.create(wrapName);
394 setElementFormat(wrapElm);
396 rangeUtils.walk(rng, function(nodes) {
400 * Process a list of nodes wrap them.
402 function process(node) {
403 var nodeName, parentName, found, hasContentEditableState, lastContentEditable;
405 lastContentEditable = contentEditable;
406 nodeName = node.nodeName.toLowerCase();
407 parentName = node.parentNode.nodeName.toLowerCase();
409 // Node has a contentEditable value
410 if (node.nodeType === 1 && getContentEditable(node)) {
411 lastContentEditable = contentEditable;
412 contentEditable = getContentEditable(node) === "true";
413 hasContentEditableState = true; // We don't want to wrap the container only it's children
416 // Stop wrapping on br elements
417 if (isEq(nodeName, 'br')) {
420 // Remove any br elements when we wrap things
428 // If node is wrapper type
429 if (format.wrapper && matchNode(node, name, vars)) {
434 // Can we rename the block
435 // TODO: Break this if up, too complex
436 if (contentEditable && !hasContentEditableState && format.block &&
437 !format.wrapper && isTextBlock(nodeName) && isValid(parentName, wrapName)) {
438 node = dom.rename(node, wrapName);
439 setElementFormat(node);
440 newWrappers.push(node);
445 // Handle selector patterns
446 if (format.selector) {
447 // Look for matching formats
448 each(formatList, function(format) {
449 // Check collapsed state if it exists
450 if ('collapsed' in format && format.collapsed !== isCollapsed) {
454 if (dom.is(node, format.selector) && !isCaretNode(node)) {
455 setElementFormat(node, format);
460 // Continue processing if a selector match wasn't found and a inline element is defined
461 if (!format.inline || found) {
467 // Is it valid to wrap this item
468 // TODO: Break this if up, too complex
469 if (contentEditable && !hasContentEditableState && isValid(wrapName, nodeName) && isValid(parentName, wrapName) &&
470 !(!node_specific && node.nodeType === 3 &&
471 node.nodeValue.length === 1 &&
472 node.nodeValue.charCodeAt(0) === 65279) &&
473 !isCaretNode(node) &&
474 (!format.inline || !isBlock(node))) {
476 if (!currentWrapElm) {
478 currentWrapElm = dom.clone(wrapElm, FALSE);
479 node.parentNode.insertBefore(currentWrapElm, node);
480 newWrappers.push(currentWrapElm);
483 currentWrapElm.appendChild(node);
484 } else if (nodeName == 'li' && bookmark) {
485 // Start wrapping - if we are in a list node and have a bookmark, then
486 // we will always begin by wrapping in a new element.
487 currentWrapElm = applyStyleToList(node, bookmark, wrapElm, newWrappers, process);
489 // Start a new wrapper for possible children
492 each(grep(node.childNodes), process);
494 if (hasContentEditableState) {
495 contentEditable = lastContentEditable; // Restore last contentEditable state from stack
498 // End the last wrapper
503 // Process siblings from range
504 each(nodes, process);
507 // Wrap links inside as well, for example color inside a link when the wrapper is around the link
508 if (format.wrap_links === false) {
509 each(newWrappers, function(node) {
510 function process(node) {
511 var i, currentWrapElm, children;
513 if (node.nodeName === 'A') {
514 currentWrapElm = dom.clone(wrapElm, FALSE);
515 newWrappers.push(currentWrapElm);
517 children = grep(node.childNodes);
518 for (i = 0; i < children.length; i++) {
519 currentWrapElm.appendChild(children[i]);
522 node.appendChild(currentWrapElm);
525 each(grep(node.childNodes), process);
533 each(newWrappers, function(node) {
536 function getChildCount(node) {
539 each(node.childNodes, function(node) {
540 if (!isWhiteSpaceNode(node) && !isBookmarkNode(node)) {
548 function mergeStyles(node) {
551 each(node.childNodes, function(node) {
552 if (node.nodeType == 1 && !isBookmarkNode(node) && !isCaretNode(node)) {
554 return FALSE; // break loop
558 // If child was found and of the same type as the current node
559 if (child && matchName(child, format)) {
560 clone = dom.clone(child, FALSE);
561 setElementFormat(clone);
563 dom.replace(clone, node, TRUE);
564 dom.remove(child, 1);
567 return clone || node;
570 childCount = getChildCount(node);
572 // Remove empty nodes but only if there is multiple wrappers and they are not block
573 // elements so never remove single <h1></h1> since that would remove the
574 // currrent empty block element where the caret is at
575 if ((newWrappers.length > 1 || !isBlock(node)) && childCount === 0) {
580 if (format.inline || format.wrapper) {
581 // Merges the current node with it's children of similar type to reduce the number of elements
582 if (!format.exact && childCount === 1) {
583 node = mergeStyles(node);
586 // Remove/merge children
587 each(formatList, function(format) {
588 // Merge all children of similar type will move styles from child to parent
589 // this: <span style="color:red"><b><span style="color:red; font-size:10px">text</span></b></span>
590 // will become: <span style="color:red"><b><span style="font-size:10px">text</span></b></span>
591 each(dom.select(format.inline, node), function(child) {
594 // When wrap_links is set to false we don't want
595 // to remove the format on children within links
596 if (format.wrap_links === false) {
597 parent = child.parentNode;
600 if (parent.nodeName === 'A') {
603 } while ((parent = parent.parentNode));
606 removeFormat(format, vars, child, format.exact ? child : null);
610 // Remove child if direct parent is of same type
611 if (matchNode(node.parentNode, name, vars)) {
617 // Look for parent with similar style format
618 if (format.merge_with_parents) {
619 dom.getParent(node.parentNode, function(parent) {
620 if (matchNode(parent, name, vars)) {
628 // Merge next and previous siblings if they are similar <b>text</b><b>text</b> becomes <b>texttext</b>
629 if (node && format.merge_siblings !== false) {
630 node = mergeSiblings(getNonWhiteSpaceSibling(node), node);
631 node = mergeSiblings(node, getNonWhiteSpaceSibling(node, TRUE));
640 rng = dom.createRng();
641 rng.setStartBefore(node);
642 rng.setEndAfter(node);
643 applyRngStyle(expandRng(rng, formatList), null, true);
645 applyRngStyle(node, null, true);
648 if (!isCollapsed || !format.inline || dom.select('td.mce-item-selected,th.mce-item-selected').length) {
649 // Obtain selection node before selection is unselected by applyRngStyle()
650 var curSelNode = ed.selection.getNode();
652 // If the formats have a default block and we can't find a parent block then
653 // start wrapping it with a DIV this is for forced_root_blocks: false
654 // It's kind of a hack but people should be using the default block type P since all desktop editors work that way
655 if (!forcedRootBlock && formatList[0].defaultBlock && !dom.getParent(curSelNode, dom.isBlock)) {
656 apply(formatList[0].defaultBlock);
659 // Apply formatting to selection
660 ed.selection.setRng(adjustSelectionToVisibleSelection());
661 bookmark = selection.getBookmark();
662 applyRngStyle(expandRng(selection.getRng(TRUE), formatList), bookmark);
664 // Colored nodes should be underlined so that the color of the underline matches the text color.
665 if (format.styles && (format.styles.color || format.styles.textDecoration)) {
666 walk(curSelNode, processUnderlineAndColor, 'childNodes');
667 processUnderlineAndColor(curSelNode);
670 selection.moveToBookmark(bookmark);
671 moveStart(selection.getRng(TRUE));
674 performCaretAction('apply', name, vars);
681 * Removes the specified format from the current selection or specified node.
684 * @param {String} name Name of format to remove.
685 * @param {Object} vars Optional list of variables to replace within format before removing it.
686 * @param {Node/Range} node Optional node or DOM range to remove the format from defaults to current selection.
688 function remove(name, vars, node) {
689 var formatList = get(name), format = formatList[0], bookmark, rng, contentEditable = true;
691 // Merges the styles for each node
692 function process(node) {
693 var children, i, l, lastContentEditable, hasContentEditableState;
695 // Node has a contentEditable value
696 if (node.nodeType === 1 && getContentEditable(node)) {
697 lastContentEditable = contentEditable;
698 contentEditable = getContentEditable(node) === "true";
699 hasContentEditableState = true; // We don't want to wrap the container only it's children
702 // Grab the children first since the nodelist might be changed
703 children = grep(node.childNodes);
705 // Process current node
706 if (contentEditable && !hasContentEditableState) {
707 for (i = 0, l = formatList.length; i < l; i++) {
708 if (removeFormat(formatList[i], vars, node, node)) {
714 // Process the children
716 if (children.length) {
717 for (i = 0, l = children.length; i < l; i++) {
718 process(children[i]);
721 if (hasContentEditableState) {
722 contentEditable = lastContentEditable; // Restore last contentEditable state from stack
728 function findFormatRoot(container) {
732 each(getParents(container.parentNode).reverse(), function(parent) {
735 // Find format root element
736 if (!formatRoot && parent.id != '_start' && parent.id != '_end') {
737 // Is the node matching the format we are looking for
738 format = matchNode(parent, name, vars);
739 if (format && format.split !== false) {
748 function wrapAndSplit(format_root, container, target, split) {
749 var parent, clone, lastClone, firstClone, i, formatRootParent;
751 // Format root found then clone formats and split it
753 formatRootParent = format_root.parentNode;
755 for (parent = container.parentNode; parent && parent != formatRootParent; parent = parent.parentNode) {
756 clone = dom.clone(parent, FALSE);
758 for (i = 0; i < formatList.length; i++) {
759 if (removeFormat(formatList[i], vars, clone, clone)) {
765 // Build wrapper node
768 clone.appendChild(lastClone);
779 // Never split block elements if the format is mixed
780 if (split && (!format.mixed || !isBlock(format_root))) {
781 container = dom.split(format_root, container);
784 // Wrap container in cloned formats
786 target.parentNode.insertBefore(lastClone, target);
787 firstClone.appendChild(target);
794 function splitToFormatRoot(container) {
795 return wrapAndSplit(findFormatRoot(container), container, container, true);
798 function unwrap(start) {
799 var node = dom.get(start ? '_start' : '_end'),
800 out = node[start ? 'firstChild' : 'lastChild'];
802 // If the end is placed within the start the result will be removed
803 // So this checks if the out node is a bookmark node if it is it
804 // checks for another more suitable node
805 if (isBookmarkNode(out)) {
806 out = out[start ? 'firstChild' : 'lastChild'];
809 dom.remove(node, true);
814 function removeRngStyle(rng) {
815 var startContainer, endContainer;
817 rng = expandRng(rng, formatList, TRUE);
820 startContainer = getContainer(rng, TRUE);
821 endContainer = getContainer(rng);
823 if (startContainer != endContainer) {
824 // WebKit will render the table incorrectly if we wrap a TD in a SPAN
825 // so lets see if the can use the first child instead
826 // This will happen if you tripple click a table cell and use remove formatting
827 if (/^(TR|TD)$/.test(startContainer.nodeName) && startContainer.firstChild) {
828 if (startContainer.nodeName == "TD") {
829 startContainer = startContainer.firstChild || startContainer;
831 startContainer = startContainer.firstChild.firstChild || startContainer;
835 // Wrap start/end nodes in span element since these might be cloned/moved
836 startContainer = wrap(startContainer, 'span', {id: '_start', 'data-mce-type': 'bookmark'});
837 endContainer = wrap(endContainer, 'span', {id: '_end', 'data-mce-type': 'bookmark'});
840 splitToFormatRoot(startContainer);
841 splitToFormatRoot(endContainer);
843 // Unwrap start/end to get real elements again
844 startContainer = unwrap(TRUE);
845 endContainer = unwrap();
847 startContainer = endContainer = splitToFormatRoot(startContainer);
850 // Update range positions since they might have changed after the split operations
851 rng.startContainer = startContainer.parentNode;
852 rng.startOffset = nodeIndex(startContainer);
853 rng.endContainer = endContainer.parentNode;
854 rng.endOffset = nodeIndex(endContainer) + 1;
857 // Remove items between start/end
858 rangeUtils.walk(rng, function(nodes) {
859 each(nodes, function(node) {
862 // Remove parent span if it only contains text-decoration: underline, yet a parent node is also underlined.
863 if (node.nodeType === 1 && ed.dom.getStyle(node, 'text-decoration') === 'underline' &&
864 node.parentNode && getTextDecoration(node.parentNode) === 'underline') {
870 'textDecoration': 'underline'
881 rng = dom.createRng();
882 rng.setStartBefore(node);
883 rng.setEndAfter(node);
886 removeRngStyle(node);
892 if (!selection.isCollapsed() || !format.inline || dom.select('td.mce-item-selected,th.mce-item-selected').length) {
893 bookmark = selection.getBookmark();
894 removeRngStyle(selection.getRng(TRUE));
895 selection.moveToBookmark(bookmark);
897 // Check if start element still has formatting then we are at: "<b>text|</b>text"
898 // and need to move the start into the next text node
899 if (format.inline && match(name, vars, selection.getStart())) {
900 moveStart(selection.getRng(true));
905 performCaretAction('remove', name, vars);
910 * Toggles the specified format on/off.
913 * @param {String} name Name of format to apply/remove.
914 * @param {Object} vars Optional list of variables to replace within format before applying/removing it.
915 * @param {Node} node Optional node to apply the format to or remove from. Defaults to current selection.
917 function toggle(name, vars, node) {
920 if (match(name, vars, node) && (!('toggle' in fmt[0]) || fmt[0].toggle)) {
921 remove(name, vars, node);
923 apply(name, vars, node);
928 * Return true/false if the specified node has the specified format.
931 * @param {Node} node Node to check the format on.
932 * @param {String} name Format name to check.
933 * @param {Object} vars Optional list of variables to replace before checking it.
934 * @param {Boolean} similar Match format that has similar properties.
935 * @return {Object} Returns the format object it matches or undefined if it doesn't match.
937 function matchNode(node, name, vars, similar) {
938 var formatList = get(name), format, i, classes;
940 function matchItems(node, format, item_name) {
941 var key, value, items = format[item_name], i;
944 if (format.onmatch) {
945 return format.onmatch(node, format, item_name);
950 // Non indexed object
951 if (items.length === undef) {
953 if (items.hasOwnProperty(key)) {
954 if (item_name === 'attributes') {
955 value = dom.getAttrib(node, key);
957 value = getStyle(node, key);
960 if (similar && !value && !format.exact) {
964 if ((!similar || format.exact) && !isEq(value, normalizeStyleValue(replaceVars(items[key], vars), key))) {
970 // Only one match needed for indexed arrays
971 for (i = 0; i < items.length; i++) {
972 if (item_name === 'attributes' ? dom.getAttrib(node, items[i]) : getStyle(node, items[i])) {
982 if (formatList && node) {
983 // Check each format in list
984 for (i = 0; i < formatList.length; i++) {
985 format = formatList[i];
987 // Name name, attributes, styles and classes
988 if (matchName(node, format) && matchItems(node, format, 'attributes') && matchItems(node, format, 'styles')) {
990 if ((classes = format.classes)) {
991 for (i = 0; i < classes.length; i++) {
992 if (!dom.hasClass(node, classes[i])) {
1005 * Matches the current selection or specified node against the specified format name.
1008 * @param {String} name Name of format to match.
1009 * @param {Object} vars Optional list of variables to replace before checking it.
1010 * @param {Node} node Optional node to check.
1011 * @return {boolean} true/false if the specified selection/node matches the format.
1013 function match(name, vars, node) {
1016 function matchParents(node) {
1017 var root = dom.getRoot();
1019 // Find first node with similar format settings
1020 node = dom.getParent(node, function(node) {
1021 return node.parentNode === root || !!matchNode(node, name, vars, true);
1024 // Do an exact check on the similar format element
1025 return matchNode(node, name, vars);
1028 // Check specified node
1030 return matchParents(node);
1033 // Check selected node
1034 node = selection.getNode();
1035 if (matchParents(node)) {
1039 // Check start node if it's different
1040 startNode = selection.getStart();
1041 if (startNode != node) {
1042 if (matchParents(startNode)) {
1051 * Matches the current selection against the array of formats and returns a new array with matching formats.
1054 * @param {Array} names Name of format to match.
1055 * @param {Object} vars Optional list of variables to replace before checking it.
1056 * @return {Array} Array with matched formats.
1058 function matchAll(names, vars) {
1059 var startElement, matchedFormatNames = [], checkedMap = {};
1061 // Check start of selection for formats
1062 startElement = selection.getStart();
1063 dom.getParent(startElement, function(node) {
1066 for (i = 0; i < names.length; i++) {
1069 if (!checkedMap[name] && matchNode(node, name, vars)) {
1070 checkedMap[name] = true;
1071 matchedFormatNames.push(name);
1076 return matchedFormatNames;
1080 * Returns true/false if the specified format can be applied to the current selection or not. It
1081 * will currently only check the state for selector formats, it returns true on all other format types.
1084 * @param {String} name Name of format to check.
1085 * @return {boolean} true/false if the specified format can be applied to the current selection/node.
1087 function canApply(name) {
1088 var formatList = get(name), startNode, parents, i, x, selector;
1091 startNode = selection.getStart();
1092 parents = getParents(startNode);
1094 for (x = formatList.length - 1; x >= 0; x--) {
1095 selector = formatList[x].selector;
1097 // Format is not selector based then always return TRUE
1098 // Is it has a defaultBlock then it's likely it can be applied for example align on a non block element line
1099 if (!selector || formatList[x].defaultBlock) {
1103 for (i = parents.length - 1; i >= 0; i--) {
1104 if (dom.is(parents[i], selector)) {
1115 * Executes the specified callback when the current selection matches the formats or not.
1117 * @method formatChanged
1118 * @param {String} formats Comma separated list of formats to check for.
1119 * @param {function} callback Callback with state and args when the format is changed/toggled on/off.
1120 * @param {Boolean} similar True/false state if the match should handle similar or exact formats.
1122 function formatChanged(formats, callback, similar) {
1125 // Setup format node change logic
1126 if (!formatChangeData) {
1127 formatChangeData = {};
1128 currentFormats = {};
1130 ed.on('NodeChange', function(e) {
1131 var parents = getParents(e.element), matchedFormats = {};
1133 // Check for new formats
1134 each(formatChangeData, function(callbacks, format) {
1135 each(parents, function(node) {
1136 if (matchNode(node, format, {}, callbacks.similar)) {
1137 if (!currentFormats[format]) {
1138 // Execute callbacks
1139 each(callbacks, function(callback) {
1140 callback(true, {node: node, format: format, parents: parents});
1143 currentFormats[format] = callbacks;
1146 matchedFormats[format] = callbacks;
1152 // Check if current formats still match
1153 each(currentFormats, function(callbacks, format) {
1154 if (!matchedFormats[format]) {
1155 delete currentFormats[format];
1157 each(callbacks, function(callback) {
1158 callback(false, {node: e.element, format: format, parents: parents});
1165 // Add format listeners
1166 each(formats.split(','), function(format) {
1167 if (!formatChangeData[format]) {
1168 formatChangeData[format] = [];
1169 formatChangeData[format].similar = similar;
1172 formatChangeData[format].push(callback);
1187 matchNode: matchNode,
1189 formatChanged: formatChanged
1194 addKeyboardShortcuts();
1195 ed.on('BeforeGetContent', function() {
1196 if (markCaretContainersBogus) {
1197 markCaretContainersBogus();
1200 ed.on('mouseup keydown', function(e) {
1201 if (disableCaretContainer) {
1202 disableCaretContainer(e);
1206 // Private functions
1209 * Checks if the specified nodes name matches the format inline/block or selector.
1212 * @param {Node} node Node to match against the specified format.
1213 * @param {Object} format Format object o match with.
1214 * @return {boolean} true/false if the format matches.
1216 function matchName(node, format) {
1217 // Check for inline match
1218 if (isEq(node, format.inline)) {
1222 // Check for block match
1223 if (isEq(node, format.block)) {
1227 // Check for selector match
1228 if (format.selector) {
1229 return node.nodeType == 1 && dom.is(node, format.selector);
1234 * Compares two string/nodes regardless of their case.
1237 * @param {String/Node} Node or string to compare.
1238 * @param {String/Node} Node or string to compare.
1239 * @return {boolean} True/false if they match.
1241 function isEq(str1, str2) {
1245 str1 = '' + (str1.nodeName || str1);
1246 str2 = '' + (str2.nodeName || str2);
1248 return str1.toLowerCase() == str2.toLowerCase();
1252 * Returns the style by name on the specified node. This method modifies the style
1253 * contents to make it more easy to match. This will resolve a few browser issues.
1256 * @param {Node} node to get style from.
1257 * @param {String} name Style name to get.
1258 * @return {String} Style item value.
1260 function getStyle(node, name) {
1261 return normalizeStyleValue(dom.getStyle(node, name), name);
1265 * Normalize style value by name. This method modifies the style contents
1266 * to make it more easy to match. This will resolve a few browser issues.
1269 * @param {Node} node to get style from.
1270 * @param {String} name Style name to get.
1271 * @return {String} Style item value.
1273 function normalizeStyleValue(value, name) {
1274 // Force the format to hex
1275 if (name == 'color' || name == 'backgroundColor') {
1276 value = dom.toHex(value);
1279 // Opera will return bold as 700
1280 if (name == 'fontWeight' && value == 700) {
1284 // Normalize fontFamily so "'Font name', Font" becomes: "Font name,Font"
1285 if (name == 'fontFamily') {
1286 value = value.replace(/[\'\"]/g, '').replace(/,\s+/g, ',');
1293 * Replaces variables in the value. The variable format is %var.
1296 * @param {String} value Value to replace variables in.
1297 * @param {Object} vars Name/value array with variables to replace.
1298 * @return {String} New value with replaced variables.
1300 function replaceVars(value, vars) {
1301 if (typeof(value) != "string") {
1302 value = value(vars);
1304 value = value.replace(/%(\w+)/g, function(str, name) {
1305 return vars[name] || str;
1312 function isWhiteSpaceNode(node) {
1313 return node && node.nodeType === 3 && /^([\t \r\n]+|)$/.test(node.nodeValue);
1316 function wrap(node, name, attrs) {
1317 var wrapper = dom.create(name, attrs);
1319 node.parentNode.insertBefore(wrapper, node);
1320 wrapper.appendChild(node);
1326 * Expands the specified range like object to depending on format.
1328 * For example on block formats it will move the start/end position
1329 * to the beginning of the current block.
1332 * @param {Object} rng Range like object.
1333 * @param {Array} formats Array with formats to expand by.
1334 * @return {Object} Expanded range like object.
1336 function expandRng(rng, format, remove) {
1337 var lastIdx, leaf, endPoint,
1338 startContainer = rng.startContainer,
1339 startOffset = rng.startOffset,
1340 endContainer = rng.endContainer,
1341 endOffset = rng.endOffset;
1343 // This function walks up the tree if there is no siblings before/after the node
1344 function findParentContainer(start) {
1345 var container, parent, sibling, siblingName, root;
1347 container = parent = start ? startContainer : endContainer;
1348 siblingName = start ? 'previousSibling' : 'nextSibling';
1349 root = dom.getRoot();
1351 function isBogusBr(node) {
1352 return node.nodeName == "BR" && node.getAttribute('data-mce-bogus') && !node.nextSibling;
1355 // If it's a text node and the offset is inside the text
1356 if (container.nodeType == 3 && !isWhiteSpaceNode(container)) {
1357 if (start ? startOffset > 0 : endOffset < container.nodeValue.length) {
1363 // Stop expanding on block elements
1364 if (!format[0].block_expand && isBlock(parent)) {
1369 for (sibling = parent[siblingName]; sibling; sibling = sibling[siblingName]) {
1370 if (!isBookmarkNode(sibling) && !isWhiteSpaceNode(sibling) && !isBogusBr(sibling)) {
1375 // Check if we can move up are we at root level or body level
1376 if (parent.parentNode == root) {
1381 parent = parent.parentNode;
1387 // This function walks down the tree to find the leaf at the selection.
1388 // The offset is also returned as if node initially a leaf, the offset may be in the middle of the text node.
1389 function findLeaf(node, offset) {
1390 if (offset === undef) {
1391 offset = node.nodeType === 3 ? node.length : node.childNodes.length;
1394 while (node && node.hasChildNodes()) {
1395 node = node.childNodes[offset];
1397 offset = node.nodeType === 3 ? node.length : node.childNodes.length;
1400 return { node: node, offset: offset };
1403 // If index based start position then resolve it
1404 if (startContainer.nodeType == 1 && startContainer.hasChildNodes()) {
1405 lastIdx = startContainer.childNodes.length - 1;
1406 startContainer = startContainer.childNodes[startOffset > lastIdx ? lastIdx : startOffset];
1408 if (startContainer.nodeType == 3) {
1413 // If index based end position then resolve it
1414 if (endContainer.nodeType == 1 && endContainer.hasChildNodes()) {
1415 lastIdx = endContainer.childNodes.length - 1;
1416 endContainer = endContainer.childNodes[endOffset > lastIdx ? lastIdx : endOffset - 1];
1418 if (endContainer.nodeType == 3) {
1419 endOffset = endContainer.nodeValue.length;
1423 // Expands the node to the closes contentEditable false element if it exists
1424 function findParentContentEditable(node) {
1428 if (parent.nodeType === 1 && getContentEditable(parent)) {
1429 return getContentEditable(parent) === "false" ? parent : node;
1432 parent = parent.parentNode;
1438 function findWordEndPoint(container, offset, start) {
1439 var walker, node, pos, lastTextNode;
1441 function findSpace(node, offset) {
1442 var pos, pos2, str = node.nodeValue;
1444 if (typeof(offset) == "undefined") {
1445 offset = start ? str.length : 0;
1449 pos = str.lastIndexOf(' ', offset);
1450 pos2 = str.lastIndexOf('\u00a0', offset);
1451 pos = pos > pos2 ? pos : pos2;
1453 // Include the space on remove to avoid tag soup
1454 if (pos !== -1 && !remove) {
1458 pos = str.indexOf(' ', offset);
1459 pos2 = str.indexOf('\u00a0', offset);
1460 pos = pos !== -1 && (pos2 === -1 || pos < pos2) ? pos : pos2;
1466 if (container.nodeType === 3) {
1467 pos = findSpace(container, offset);
1470 return {container: container, offset: pos};
1473 lastTextNode = container;
1476 // Walk the nodes inside the block
1477 walker = new TreeWalker(container, dom.getParent(container, isBlock) || ed.getBody());
1478 while ((node = walker[start ? 'prev' : 'next']())) {
1479 if (node.nodeType === 3) {
1480 lastTextNode = node;
1481 pos = findSpace(node);
1484 return {container: node, offset: pos};
1486 } else if (isBlock(node)) {
1495 offset = lastTextNode.length;
1498 return {container: lastTextNode, offset: offset};
1502 function findSelectorEndPoint(container, sibling_name) {
1503 var parents, i, y, curFormat;
1505 if (container.nodeType == 3 && container.nodeValue.length === 0 && container[sibling_name]) {
1506 container = container[sibling_name];
1509 parents = getParents(container);
1510 for (i = 0; i < parents.length; i++) {
1511 for (y = 0; y < format.length; y++) {
1512 curFormat = format[y];
1514 // If collapsed state is set then skip formats that doesn't match that
1515 if ("collapsed" in curFormat && curFormat.collapsed !== rng.collapsed) {
1519 if (dom.is(parents[i], curFormat.selector)) {
1528 function findBlockEndPoint(container, sibling_name) {
1529 var node, root = dom.getRoot();
1531 // Expand to block of similar type
1532 if (!format[0].wrapper) {
1533 node = dom.getParent(container, format[0].block);
1536 // Expand to first wrappable block element or any block element
1538 node = dom.getParent(container.nodeType == 3 ? container.parentNode : container, function(node) {
1539 // Fixes #6183 where it would expand to editable parent element in inline mode
1540 return node != root && isTextBlock(node);
1544 // Exclude inner lists from wrapping
1545 if (node && format[0].wrapper) {
1546 node = getParents(node, 'ul,ol').reverse()[0] || node;
1549 // Didn't find a block element look for first/last wrappable element
1553 while (node[sibling_name] && !isBlock(node[sibling_name])) {
1554 node = node[sibling_name];
1556 // Break on BR but include it will be removed later on
1557 // we can't remove it now since we need to check if it can be wrapped
1558 if (isEq(node, 'br')) {
1564 return node || container;
1567 // Expand to closest contentEditable element
1568 startContainer = findParentContentEditable(startContainer);
1569 endContainer = findParentContentEditable(endContainer);
1571 // Exclude bookmark nodes if possible
1572 if (isBookmarkNode(startContainer.parentNode) || isBookmarkNode(startContainer)) {
1573 startContainer = isBookmarkNode(startContainer) ? startContainer : startContainer.parentNode;
1574 startContainer = startContainer.nextSibling || startContainer;
1576 if (startContainer.nodeType == 3) {
1581 if (isBookmarkNode(endContainer.parentNode) || isBookmarkNode(endContainer)) {
1582 endContainer = isBookmarkNode(endContainer) ? endContainer : endContainer.parentNode;
1583 endContainer = endContainer.previousSibling || endContainer;
1585 if (endContainer.nodeType == 3) {
1586 endOffset = endContainer.length;
1590 if (format[0].inline) {
1591 if (rng.collapsed) {
1592 // Expand left to closest word boundary
1593 endPoint = findWordEndPoint(startContainer, startOffset, true);
1595 startContainer = endPoint.container;
1596 startOffset = endPoint.offset;
1599 // Expand right to closest word boundary
1600 endPoint = findWordEndPoint(endContainer, endOffset);
1602 endContainer = endPoint.container;
1603 endOffset = endPoint.offset;
1607 // Avoid applying formatting to a trailing space.
1608 leaf = findLeaf(endContainer, endOffset);
1610 while (leaf.node && leaf.offset === 0 && leaf.node.previousSibling) {
1611 leaf = findLeaf(leaf.node.previousSibling);
1614 if (leaf.node && leaf.offset > 0 && leaf.node.nodeType === 3 &&
1615 leaf.node.nodeValue.charAt(leaf.offset - 1) === ' ') {
1617 if (leaf.offset > 1) {
1618 endContainer = leaf.node;
1619 endContainer.splitText(leaf.offset - 1);
1625 // Move start/end point up the tree if the leaves are sharp and if we are in different containers
1626 // Example * becomes !: !<p><b><i>*text</i><i>text*</i></b></p>!
1627 // This will reduce the number of wrapper elements that needs to be created
1628 // Move start point up the tree
1629 if (format[0].inline || format[0].block_expand) {
1630 if (!format[0].inline || (startContainer.nodeType != 3 || startOffset === 0)) {
1631 startContainer = findParentContainer(true);
1634 if (!format[0].inline || (endContainer.nodeType != 3 || endOffset === endContainer.nodeValue.length)) {
1635 endContainer = findParentContainer();
1639 // Expand start/end container to matching selector
1640 if (format[0].selector && format[0].expand !== FALSE && !format[0].inline) {
1641 // Find new startContainer/endContainer if there is better one
1642 startContainer = findSelectorEndPoint(startContainer, 'previousSibling');
1643 endContainer = findSelectorEndPoint(endContainer, 'nextSibling');
1646 // Expand start/end container to matching block element or text node
1647 if (format[0].block || format[0].selector) {
1648 // Find new startContainer/endContainer if there is better one
1649 startContainer = findBlockEndPoint(startContainer, 'previousSibling');
1650 endContainer = findBlockEndPoint(endContainer, 'nextSibling');
1652 // Non block element then try to expand up the leaf
1653 if (format[0].block) {
1654 if (!isBlock(startContainer)) {
1655 startContainer = findParentContainer(true);
1658 if (!isBlock(endContainer)) {
1659 endContainer = findParentContainer();
1664 // Setup index for startContainer
1665 if (startContainer.nodeType == 1) {
1666 startOffset = nodeIndex(startContainer);
1667 startContainer = startContainer.parentNode;
1670 // Setup index for endContainer
1671 if (endContainer.nodeType == 1) {
1672 endOffset = nodeIndex(endContainer) + 1;
1673 endContainer = endContainer.parentNode;
1676 // Return new range like object
1678 startContainer: startContainer,
1679 startOffset: startOffset,
1680 endContainer: endContainer,
1681 endOffset: endOffset
1686 * Removes the specified format for the specified node. It will also remove the node if it doesn't have
1687 * any attributes if the format specifies it to do so.
1690 * @param {Object} format Format object with items to remove from node.
1691 * @param {Object} vars Name/value object with variables to apply to format.
1692 * @param {Node} node Node to remove the format styles on.
1693 * @param {Node} compare_node Optional compare node, if specified the styles will be compared to that node.
1694 * @return {Boolean} True/false if the node was removed or not.
1696 function removeFormat(format, vars, node, compare_node) {
1697 var i, attrs, stylesModified;
1699 // Check if node matches format
1700 if (!matchName(node, format)) {
1704 // Should we compare with format attribs and styles
1705 if (format.remove != 'all') {
1707 each(format.styles, function(value, name) {
1708 value = normalizeStyleValue(replaceVars(value, vars), name);
1711 if (typeof(name) === 'number') {
1716 if (!compare_node || isEq(getStyle(compare_node, name), value)) {
1717 dom.setStyle(node, name, '');
1723 // Remove style attribute if it's empty
1724 if (stylesModified && dom.getAttrib(node, 'style') === '') {
1725 node.removeAttribute('style');
1726 node.removeAttribute('data-mce-style');
1729 // Remove attributes
1730 each(format.attributes, function(value, name) {
1733 value = replaceVars(value, vars);
1736 if (typeof(name) === 'number') {
1741 if (!compare_node || isEq(dom.getAttrib(compare_node, name), value)) {
1742 // Keep internal classes
1743 if (name == 'class') {
1744 value = dom.getAttrib(node, name);
1746 // Build new class value where everything is removed except the internal prefixed classes
1748 each(value.split(/\s+/), function(cls) {
1749 if (/mce\w+/.test(cls)) {
1750 valueOut += (valueOut ? ' ' : '') + cls;
1754 // We got some internal classes left
1756 dom.setAttrib(node, name, valueOut);
1762 // IE6 has a bug where the attribute doesn't get removed correctly
1763 if (name == "class") {
1764 node.removeAttribute('className');
1767 // Remove mce prefixed attributes
1768 if (MCE_ATTR_RE.test(name)) {
1769 node.removeAttribute('data-mce-' + name);
1772 node.removeAttribute(name);
1777 each(format.classes, function(value) {
1778 value = replaceVars(value, vars);
1780 if (!compare_node || dom.hasClass(compare_node, value)) {
1781 dom.removeClass(node, value);
1785 // Check for non internal attributes
1786 attrs = dom.getAttribs(node);
1787 for (i = 0; i < attrs.length; i++) {
1788 if (attrs[i].nodeName.indexOf('_') !== 0) {
1794 // Remove the inline child if it's empty for example <b> or <span>
1795 if (format.remove != 'none') {
1796 removeNode(node, format);
1802 * Removes the node and wrap it's children in paragraphs before doing so or
1803 * appends BR elements to the beginning/end of the block element if forcedRootBlocks is disabled.
1805 * If the div in the node below gets removed:
1806 * text<div>text</div>text
1809 * text<div><br />text<br /></div>text
1811 * So when the div is removed the result is:
1812 * text<br />text<br />text
1815 * @param {Node} node Node to remove + apply BR/P elements to.
1816 * @param {Object} format Format rule.
1817 * @return {Node} Input node.
1819 function removeNode(node, format) {
1820 var parentNode = node.parentNode, rootBlockElm;
1822 function find(node, next, inc) {
1823 node = getNonWhiteSpaceSibling(node, next, inc);
1825 return !node || (node.nodeName == 'BR' || isBlock(node));
1829 if (!forcedRootBlock) {
1830 // Append BR elements if needed before we remove the block
1831 if (isBlock(node) && !isBlock(parentNode)) {
1832 if (!find(node, FALSE) && !find(node.firstChild, TRUE, 1)) {
1833 node.insertBefore(dom.create('br'), node.firstChild);
1836 if (!find(node, TRUE) && !find(node.lastChild, FALSE, 1)) {
1837 node.appendChild(dom.create('br'));
1841 // Wrap the block in a forcedRootBlock if we are at the root of document
1842 if (parentNode == dom.getRoot()) {
1843 if (!format.list_block || !isEq(node, format.list_block)) {
1844 each(grep(node.childNodes), function(node) {
1845 if (isValid(forcedRootBlock, node.nodeName.toLowerCase())) {
1846 if (!rootBlockElm) {
1847 rootBlockElm = wrap(node, forcedRootBlock);
1849 rootBlockElm.appendChild(node);
1860 // Never remove nodes that isn't the specified inline element if a selector is specified too
1861 if (format.selector && format.inline && !isEq(format.inline, node)) {
1865 dom.remove(node, 1);
1869 * Returns the next/previous non whitespace node.
1872 * @param {Node} node Node to start at.
1873 * @param {boolean} next (Optional) Include next or previous node defaults to previous.
1874 * @param {boolean} inc (Optional) Include the current node in checking. Defaults to false.
1875 * @return {Node} Next or previous node or undefined if it wasn't found.
1877 function getNonWhiteSpaceSibling(node, next, inc) {
1879 next = next ? 'nextSibling' : 'previousSibling';
1881 for (node = inc ? node : node[next]; node; node = node[next]) {
1882 if (node.nodeType == 1 || !isWhiteSpaceNode(node)) {
1890 * Checks if the specified node is a bookmark node or not.
1893 * @param {Node} node Node to check if it's a bookmark node or not.
1894 * @return {Boolean} true/false if the node is a bookmark node.
1896 function isBookmarkNode(node) {
1897 return node && node.nodeType == 1 && node.getAttribute('data-mce-type') == 'bookmark';
1901 * Merges the next/previous sibling element if they match.
1904 * @param {Node} prev Previous node to compare/merge.
1905 * @param {Node} next Next node to compare/merge.
1906 * @return {Node} Next node if we didn't merge and prev node if we did.
1908 function mergeSiblings(prev, next) {
1909 var sibling, tmpSibling;
1912 * Compares two nodes and checks if it's attributes and styles matches.
1913 * This doesn't compare classes as items since their order is significant.
1916 * @param {Node} node1 First node to compare with.
1917 * @param {Node} node2 Second node to compare with.
1918 * @return {boolean} True/false if the nodes are the same or not.
1920 function compareElements(node1, node2) {
1921 // Not the same name
1922 if (node1.nodeName != node2.nodeName) {
1927 * Returns all the nodes attributes excluding internal ones, styles and classes.
1930 * @param {Node} node Node to get attributes from.
1931 * @return {Object} Name/value object with attributes and attribute values.
1933 function getAttribs(node) {
1936 each(dom.getAttribs(node), function(attr) {
1937 var name = attr.nodeName.toLowerCase();
1939 // Don't compare internal attributes or style
1940 if (name.indexOf('_') !== 0 && name !== 'style') {
1941 attribs[name] = dom.getAttrib(node, name);
1949 * Compares two objects checks if it's key + value exists in the other one.
1952 * @param {Object} obj1 First object to compare.
1953 * @param {Object} obj2 Second object to compare.
1954 * @return {boolean} True/false if the objects matches or not.
1956 function compareObjects(obj1, obj2) {
1959 for (name in obj1) {
1960 // Obj1 has item obj2 doesn't have
1961 if (obj1.hasOwnProperty(name)) {
1964 // Obj2 doesn't have obj1 item
1965 if (value === undef) {
1969 // Obj2 item has a different value
1970 if (obj1[name] != value) {
1974 // Delete similar value
1979 // Check if obj 2 has something obj 1 doesn't have
1980 for (name in obj2) {
1981 // Obj2 has item obj1 doesn't have
1982 if (obj2.hasOwnProperty(name)) {
1990 // Attribs are not the same
1991 if (!compareObjects(getAttribs(node1), getAttribs(node2))) {
1995 // Styles are not the same
1996 if (!compareObjects(dom.parseStyle(dom.getAttrib(node1, 'style')), dom.parseStyle(dom.getAttrib(node2, 'style')))) {
2003 function findElementSibling(node, sibling_name) {
2004 for (sibling = node; sibling; sibling = sibling[sibling_name]) {
2005 if (sibling.nodeType == 3 && sibling.nodeValue.length !== 0) {
2009 if (sibling.nodeType == 1 && !isBookmarkNode(sibling)) {
2017 // Check if next/prev exists and that they are elements
2019 // If previous sibling is empty then jump over it
2020 prev = findElementSibling(prev, 'previousSibling');
2021 next = findElementSibling(next, 'nextSibling');
2023 // Compare next and previous nodes
2024 if (compareElements(prev, next)) {
2025 // Append nodes between
2026 for (sibling = prev.nextSibling; sibling && sibling != next;) {
2027 tmpSibling = sibling;
2028 sibling = sibling.nextSibling;
2029 prev.appendChild(tmpSibling);
2035 // Move children into prev node
2036 each(grep(next.childNodes), function(node) {
2037 prev.appendChild(node);
2047 function getContainer(rng, start) {
2048 var container, offset, lastIdx;
2050 container = rng[start ? 'startContainer' : 'endContainer'];
2051 offset = rng[start ? 'startOffset' : 'endOffset'];
2053 if (container.nodeType == 1) {
2054 lastIdx = container.childNodes.length - 1;
2056 if (!start && offset) {
2060 container = container.childNodes[offset > lastIdx ? lastIdx : offset];
2063 // If start text node is excluded then walk to the next node
2064 if (container.nodeType === 3 && start && offset >= container.nodeValue.length) {
2065 container = new TreeWalker(container, ed.getBody()).next() || container;
2068 // If end text node is excluded then walk to the previous node
2069 if (container.nodeType === 3 && !start && offset === 0) {
2070 container = new TreeWalker(container, ed.getBody()).prev() || container;
2076 function performCaretAction(type, name, vars) {
2077 var caretContainerId = '_mce_caret', debug = ed.settings.caret_debug;
2079 // Creates a caret container bogus element
2080 function createCaretContainer(fill) {
2081 var caretContainer = dom.create('span', {id: caretContainerId, 'data-mce-bogus': true, style: debug ? 'color:red' : ''});
2084 caretContainer.appendChild(ed.getDoc().createTextNode(INVISIBLE_CHAR));
2087 return caretContainer;
2090 function isCaretContainerEmpty(node, nodes) {
2092 if ((node.nodeType === 3 && node.nodeValue !== INVISIBLE_CHAR) || node.childNodes.length > 1) {
2097 if (nodes && node.nodeType === 1) {
2101 node = node.firstChild;
2107 // Returns any parent caret container element
2108 function getParentCaretContainer(node) {
2110 if (node.id === caretContainerId) {
2114 node = node.parentNode;
2118 // Finds the first text node in the specified node
2119 function findFirstTextNode(node) {
2123 walker = new TreeWalker(node, node);
2125 for (node = walker.current(); node; node = walker.next()) {
2126 if (node.nodeType === 3) {
2133 // Removes the caret container for the specified node or all on the current document
2134 function removeCaretContainer(node, move_caret) {
2138 node = getParentCaretContainer(selection.getStart());
2141 while ((node = dom.get(caretContainerId))) {
2142 removeCaretContainer(node, false);
2146 rng = selection.getRng(true);
2148 if (isCaretContainerEmpty(node)) {
2149 if (move_caret !== false) {
2150 rng.setStartBefore(node);
2151 rng.setEndBefore(node);
2156 child = findFirstTextNode(node);
2158 if (child.nodeValue.charAt(0) === INVISIBLE_CHAR) {
2159 child = child.deleteData(0, 1);
2162 dom.remove(node, 1);
2165 selection.setRng(rng);
2169 // Applies formatting to the caret postion
2170 function applyCaretFormat() {
2171 var rng, caretContainer, textNode, offset, bookmark, container, text;
2173 rng = selection.getRng(true);
2174 offset = rng.startOffset;
2175 container = rng.startContainer;
2176 text = container.nodeValue;
2178 caretContainer = getParentCaretContainer(selection.getStart());
2179 if (caretContainer) {
2180 textNode = findFirstTextNode(caretContainer);
2183 // Expand to word is caret is in the middle of a text node and the char before/after is a alpha numeric character
2184 if (text && offset > 0 && offset < text.length && /\w/.test(text.charAt(offset)) && /\w/.test(text.charAt(offset - 1))) {
2185 // Get bookmark of caret position
2186 bookmark = selection.getBookmark();
2188 // Collapse bookmark range (WebKit)
2191 // Expand the range to the closest word and split it at those points
2192 rng = expandRng(rng, get(name));
2193 rng = rangeUtils.split(rng);
2195 // Apply the format to the range
2196 apply(name, vars, rng);
2198 // Move selection back to caret position
2199 selection.moveToBookmark(bookmark);
2201 if (!caretContainer || textNode.nodeValue !== INVISIBLE_CHAR) {
2202 caretContainer = createCaretContainer(true);
2203 textNode = caretContainer.firstChild;
2205 rng.insertNode(caretContainer);
2208 apply(name, vars, caretContainer);
2210 apply(name, vars, caretContainer);
2213 // Move selection to text node
2214 selection.setCursorLocation(textNode, offset);
2218 function removeCaretFormat() {
2219 var rng = selection.getRng(true), container, offset, bookmark,
2220 hasContentAfter, node, formatNode, parents = [], i, caretContainer;
2222 container = rng.startContainer;
2223 offset = rng.startOffset;
2226 if (container.nodeType == 3) {
2227 if (offset != container.nodeValue.length || container.nodeValue === INVISIBLE_CHAR) {
2228 hasContentAfter = true;
2231 node = node.parentNode;
2235 if (matchNode(node, name, vars)) {
2240 if (node.nextSibling) {
2241 hasContentAfter = true;
2245 node = node.parentNode;
2248 // Node doesn't have the specified format
2253 // Is there contents after the caret then remove the format on the element
2254 if (hasContentAfter) {
2255 // Get bookmark of caret position
2256 bookmark = selection.getBookmark();
2258 // Collapse bookmark range (WebKit)
2261 // Expand the range to the closest word and split it at those points
2262 rng = expandRng(rng, get(name), true);
2263 rng = rangeUtils.split(rng);
2265 // Remove the format from the range
2266 remove(name, vars, rng);
2268 // Move selection back to caret position
2269 selection.moveToBookmark(bookmark);
2271 caretContainer = createCaretContainer();
2273 node = caretContainer;
2274 for (i = parents.length - 1; i >= 0; i--) {
2275 node.appendChild(dom.clone(parents[i], false));
2276 node = node.firstChild;
2279 // Insert invisible character into inner most format element
2280 node.appendChild(dom.doc.createTextNode(INVISIBLE_CHAR));
2281 node = node.firstChild;
2283 var block = dom.getParent(formatNode, isTextBlock);
2285 if (block && dom.isEmpty(block)) {
2286 // Replace formatNode with caretContainer when removing format from empty block like <p><b>|</b></p>
2287 formatNode.parentNode.replaceChild(caretContainer, formatNode);
2289 // Insert caret container after the formated node
2290 dom.insertAfter(caretContainer, formatNode);
2293 // Move selection to text node
2294 selection.setCursorLocation(node, 1);
2295 // If the formatNode is empty, we can remove it safely.
2296 if(dom.isEmpty(formatNode)) {
2297 dom.remove(formatNode);
2302 // Checks if the parent caret container node isn't empty if that is the case it
2303 // will remove the bogus state on all children that isn't empty
2304 function unmarkBogusCaretParents() {
2307 caretContainer = getParentCaretContainer(selection.getStart());
2308 if (caretContainer && !dom.isEmpty(caretContainer)) {
2309 walk(caretContainer, function(node) {
2310 if (node.nodeType == 1 && node.id !== caretContainerId && !dom.isEmpty(node)) {
2311 dom.setAttrib(node, 'data-mce-bogus', null);
2317 // Only bind the caret events once
2318 if (!ed._hasCaretEvents) {
2319 // Mark current caret container elements as bogus when getting the contents so we don't end up with empty elements
2320 markCaretContainersBogus = function() {
2323 if (isCaretContainerEmpty(getParentCaretContainer(selection.getStart()), nodes)) {
2327 dom.setAttrib(nodes[i], 'data-mce-bogus', '1');
2332 disableCaretContainer = function(e) {
2333 var keyCode = e.keyCode;
2335 removeCaretContainer();
2337 // Remove caret container on keydown and it's a backspace, enter or left/right arrow keys
2338 if (keyCode == 8 || keyCode == 37 || keyCode == 39) {
2339 removeCaretContainer(getParentCaretContainer(selection.getStart()));
2342 unmarkBogusCaretParents();
2345 // Remove bogus state if they got filled by contents using editor.selection.setContent
2346 ed.on('SetContent', function(e) {
2348 unmarkBogusCaretParents();
2351 ed._hasCaretEvents = true;
2354 // Do apply or remove caret format
2355 if (type == "apply") {
2358 removeCaretFormat();
2363 * Moves the start to the first suitable text node.
2365 function moveStart(rng) {
2366 var container = rng.startContainer,
2367 offset = rng.startOffset, isAtEndOfText,
2368 walker, node, nodes, tmpNode;
2370 // Convert text node into index if possible
2371 if (container.nodeType == 3 && offset >= container.nodeValue.length) {
2372 // Get the parent container location and walk from there
2373 offset = nodeIndex(container);
2374 container = container.parentNode;
2375 isAtEndOfText = true;
2378 // Move startContainer/startOffset in to a suitable node
2379 if (container.nodeType == 1) {
2380 nodes = container.childNodes;
2381 container = nodes[Math.min(offset, nodes.length - 1)];
2382 walker = new TreeWalker(container, dom.getParent(container, dom.isBlock));
2384 // If offset is at end of the parent node walk to the next one
2385 if (offset > nodes.length - 1 || isAtEndOfText) {
2389 for (node = walker.current(); node; node = walker.next()) {
2390 if (node.nodeType == 3 && !isWhiteSpaceNode(node)) {
2391 // IE has a "neat" feature where it moves the start node into the closest element
2392 // we can avoid this by inserting an element before it and then remove it after we set the selection
2393 tmpNode = dom.create('a', null, INVISIBLE_CHAR);
2394 node.parentNode.insertBefore(tmpNode, node);
2396 // Set selection and remove tmpNode
2397 rng.setStart(node, 0);
2398 selection.setRng(rng);
2399 dom.remove(tmpNode);