4 * Copyright 2009, Moxiecode Systems AB
5 * Released under LGPL License.
7 * License: http://tinymce.moxiecode.com/license
8 * Contributing: http://tinymce.moxiecode.com/contributing
13 * Text formatter engine class. This class is used to apply formats like bold, italic, font size
14 * etc to the current selection or specific nodes. This engine was build to replace the browsers
15 * default formatting logic for execCommand due to it's inconsistant and buggy behavior.
17 * @class tinymce.Formatter
19 * tinymce.activeEditor.formatter.register('mycustomformat', {
21 * styles : {color : '#ff0000'}
24 * tinymce.activeEditor.formatter.apply('mycustomformat');
28 * Constructs a new formatter instance.
30 * @constructor Formatter
31 * @param {tinymce.Editor} ed Editor instance to construct the formatter engine to.
33 tinymce.Formatter = function(ed) {
37 selection = ed.selection,
38 TreeWalker = tinymce.dom.TreeWalker,
39 rangeUtils = new tinymce.dom.RangeUtils(dom),
40 isValid = ed.schema.isValidChild,
41 isBlock = dom.isBlock,
42 forcedRootBlock = ed.settings.forced_root_block,
43 nodeIndex = dom.nodeIndex,
44 INVISIBLE_CHAR = '\uFEFF',
45 MCE_ATTR_RE = /^(src|href|style)$/,
49 pendingFormats = {apply : [], remove : []};
51 function isArray(obj) {
52 return obj instanceof Array;
55 function getParents(node, selector) {
56 return dom.getParents(node, selector, dom.getRoot());
59 function isCaretNode(node) {
60 return node.nodeType === 1 && (node.face === 'mceinline' || node.style.fontFamily === 'mceinline');
66 * Returns the format by name or all formats if no name is specified.
69 * @param {String} name Optional name to retrive by.
70 * @return {Array/Object} Array/Object with all registred formats or a specific format.
73 return name ? formats[name] : formats;
77 * Registers a specific format by name.
80 * @param {Object/String} name Name of the format for example "bold".
81 * @param {Object/Array} format Optional format object or array of format variants can only be omitted if the first arg is an object.
83 function register(name, format) {
85 if (typeof(name) !== 'string') {
86 each(name, function(format, name) {
87 register(name, format);
90 // Force format into array and add it to internal collection
91 format = format.length ? format : [format];
93 each(format, function(format) {
94 // Set deep to false by default on selector formats this to avoid removing
95 // alignment on images inside paragraphs when alignment is changed on paragraphs
96 if (format.deep === undefined)
97 format.deep = !format.selector;
100 if (format.split === undefined)
101 format.split = !format.selector || format.inline;
104 if (format.remove === undefined && format.selector && !format.inline)
105 format.remove = 'none';
107 // Mark format as a mixed format inline + block level
108 if (format.selector && format.inline) {
110 format.block_expand = true;
113 // Split classes if needed
114 if (typeof(format.classes) === 'string')
115 format.classes = format.classes.split(/\s+/);
118 formats[name] = format;
123 var getTextDecoration = function(node) {
126 ed.dom.getParent(node, function(n) {
127 decoration = ed.dom.getStyle(n, 'text-decoration');
128 return decoration && decoration !== 'none';
134 var processUnderlineAndColor = function(node) {
136 if (node.nodeType === 1 && node.parentNode && node.parentNode.nodeType === 1) {
137 textDecoration = getTextDecoration(node.parentNode);
138 if (ed.dom.getStyle(node, 'color') && textDecoration) {
139 ed.dom.setStyle(node, 'text-decoration', textDecoration);
140 } else if (ed.dom.getStyle(node, 'textdecoration') === textDecoration) {
141 ed.dom.setStyle(node, 'text-decoration', null);
147 * Applies the specified format to the current selection or specified node.
150 * @param {String} name Name of format to apply.
151 * @param {Object} vars Optional list of variables to replace within format before applying it.
152 * @param {Node} node Optional node to apply the format to defaults to current selection.
154 function apply(name, vars, node) {
155 var formatList = get(name), format = formatList[0], bookmark, rng, i, isCollapsed = selection.isCollapsed();
158 * Moves the start to the first suitable text node.
160 function moveStart(rng) {
161 var container = rng.startContainer,
162 offset = rng.startOffset,
165 // Move startContainer/startOffset in to a suitable node
166 if (container.nodeType == 1 || container.nodeValue === "") {
167 container = container.nodeType == 1 ? container.childNodes[offset] : container;
169 // Might fail if the offset is behind the last element in it's container
171 walker = new TreeWalker(container, container.parentNode);
172 for (node = walker.current(); node; node = walker.next()) {
173 if (node.nodeType == 3 && !isWhiteSpaceNode(node)) {
174 rng.setStart(node, 0);
184 function setElementFormat(elm, fmt) {
188 each(fmt.styles, function(value, name) {
189 dom.setStyle(elm, name, replaceVars(value, vars));
192 each(fmt.attributes, function(value, name) {
193 dom.setAttrib(elm, name, replaceVars(value, vars));
196 each(fmt.classes, function(value) {
197 value = replaceVars(value, vars);
199 if (!dom.hasClass(elm, value))
200 dom.addClass(elm, value);
205 function applyRngStyle(rng) {
206 var newWrappers = [], wrapName, wrapElm;
208 // Setup wrapper element
209 wrapName = format.inline || format.block;
210 wrapElm = dom.create(wrapName);
211 setElementFormat(wrapElm);
213 rangeUtils.walk(rng, function(nodes) {
217 * Process a list of nodes wrap them.
219 function process(node) {
220 var nodeName = node.nodeName.toLowerCase(), parentName = node.parentNode.nodeName.toLowerCase(), found;
222 // Stop wrapping on br elements
223 if (isEq(nodeName, 'br')) {
226 // Remove any br elements when we wrap things
233 // If node is wrapper type
234 if (format.wrapper && matchNode(node, name, vars)) {
239 // Can we rename the block
240 if (format.block && !format.wrapper && isTextBlock(nodeName)) {
241 node = dom.rename(node, wrapName);
242 setElementFormat(node);
243 newWrappers.push(node);
248 // Handle selector patterns
249 if (format.selector) {
250 // Look for matching formats
251 each(formatList, function(format) {
252 // Check collapsed state if it exists
253 if ('collapsed' in format && format.collapsed !== isCollapsed) {
257 if (dom.is(node, format.selector) && !isCaretNode(node)) {
258 setElementFormat(node, format);
263 // Continue processing if a selector match wasn't found and a inline element is defined
264 if (!format.inline || found) {
270 // Is it valid to wrap this item
271 if (isValid(wrapName, nodeName) && isValid(parentName, wrapName) &&
272 !(node.nodeType === 3 && node.nodeValue.length === 1 && node.nodeValue.charCodeAt(0) === 65279)) {
274 if (!currentWrapElm) {
276 currentWrapElm = wrapElm.cloneNode(FALSE);
277 node.parentNode.insertBefore(currentWrapElm, node);
278 newWrappers.push(currentWrapElm);
281 currentWrapElm.appendChild(node);
283 // Start a new wrapper for possible children
286 each(tinymce.grep(node.childNodes), process);
288 // End the last wrapper
293 // Process siblings from range
294 each(nodes, process);
297 // Wrap links inside as well, for example color inside a link when the wrapper is around the link
298 if (format.wrap_links === false) {
299 each(newWrappers, function(node) {
300 function process(node) {
301 var i, currentWrapElm, children;
303 if (node.nodeName === 'A') {
304 currentWrapElm = wrapElm.cloneNode(FALSE);
305 newWrappers.push(currentWrapElm);
307 children = tinymce.grep(node.childNodes);
308 for (i = 0; i < children.length; i++)
309 currentWrapElm.appendChild(children[i]);
311 node.appendChild(currentWrapElm);
314 each(tinymce.grep(node.childNodes), process);
322 each(newWrappers, function(node) {
325 function getChildCount(node) {
328 each(node.childNodes, function(node) {
329 if (!isWhiteSpaceNode(node) && !isBookmarkNode(node))
336 function mergeStyles(node) {
339 each(node.childNodes, function(node) {
340 if (node.nodeType == 1 && !isBookmarkNode(node) && !isCaretNode(node)) {
342 return FALSE; // break loop
346 // If child was found and of the same type as the current node
347 if (child && matchName(child, format)) {
348 clone = child.cloneNode(FALSE);
349 setElementFormat(clone);
351 dom.replace(clone, node, TRUE);
352 dom.remove(child, 1);
355 return clone || node;
358 childCount = getChildCount(node);
360 // Remove empty nodes but only if there is multiple wrappers and they are not block
361 // elements so never remove single <h1></h1> since that would remove the currrent empty block element where the caret is at
362 if ((newWrappers.length > 1 || !isBlock(node)) && childCount === 0) {
367 if (format.inline || format.wrapper) {
368 // Merges the current node with it's children of similar type to reduce the number of elements
369 if (!format.exact && childCount === 1)
370 node = mergeStyles(node);
372 // Remove/merge children
373 each(formatList, function(format) {
374 // Merge all children of similar type will move styles from child to parent
375 // this: <span style="color:red"><b><span style="color:red; font-size:10px">text</span></b></span>
376 // will become: <span style="color:red"><b><span style="font-size:10px">text</span></b></span>
377 each(dom.select(format.inline, node), function(child) {
380 // When wrap_links is set to false we don't want
381 // to remove the format on children within links
382 if (format.wrap_links === false) {
383 parent = child.parentNode;
386 if (parent.nodeName === 'A')
388 } while (parent = parent.parentNode);
391 removeFormat(format, vars, child, format.exact ? child : null);
395 // Remove child if direct parent is of same type
396 if (matchNode(node.parentNode, name, vars)) {
402 // Look for parent with similar style format
403 if (format.merge_with_parents) {
404 dom.getParent(node.parentNode, function(parent) {
405 if (matchNode(parent, name, vars)) {
413 // Merge next and previous siblings if they are similar <b>text</b><b>text</b> becomes <b>texttext</b>
415 node = mergeSiblings(getNonWhiteSpaceSibling(node), node);
416 node = mergeSiblings(node, getNonWhiteSpaceSibling(node, TRUE));
424 rng = dom.createRng();
426 rng.setStartBefore(node);
427 rng.setEndAfter(node);
429 applyRngStyle(expandRng(rng, formatList));
431 if (!isCollapsed || !format.inline || dom.select('td.mceSelected,th.mceSelected').length) {
432 // Obtain selection node before selection is unselected by applyRngStyle()
433 var curSelNode = ed.selection.getNode();
435 // Apply formatting to selection
436 bookmark = selection.getBookmark();
437 applyRngStyle(expandRng(selection.getRng(TRUE), formatList));
439 // Colored nodes should be underlined so that the color of the underline matches the text color.
440 if (format.styles && (format.styles.color || format.styles.textDecoration)) {
441 tinymce.walk(curSelNode, processUnderlineAndColor, 'childNodes');
442 processUnderlineAndColor(curSelNode);
445 selection.moveToBookmark(bookmark);
446 selection.setRng(moveStart(selection.getRng(TRUE)));
449 performCaretAction('apply', name, vars);
455 * Removes the specified format from the current selection or specified node.
458 * @param {String} name Name of format to remove.
459 * @param {Object} vars Optional list of variables to replace within format before removing it.
460 * @param {Node} node Optional node to remove the format from defaults to current selection.
462 function remove(name, vars, node) {
463 var formatList = get(name), format = formatList[0], bookmark, i, rng;
466 * Moves the start to the first suitable text node.
468 function moveStart(rng) {
469 var container = rng.startContainer,
470 offset = rng.startOffset,
471 walker, node, nodes, tmpNode;
473 // Convert text node into index if possible
474 if (container.nodeType == 3 && offset >= container.nodeValue.length - 1) {
475 container = container.parentNode;
476 offset = nodeIndex(container) + 1;
479 // Move startContainer/startOffset in to a suitable node
480 if (container.nodeType == 1) {
481 nodes = container.childNodes;
482 container = nodes[Math.min(offset, nodes.length - 1)];
483 walker = new TreeWalker(container);
485 // If offset is at end of the parent node walk to the next one
486 if (offset > nodes.length - 1)
489 for (node = walker.current(); node; node = walker.next()) {
490 if (node.nodeType == 3 && !isWhiteSpaceNode(node)) {
491 // IE has a "neat" feature where it moves the start node into the closest element
492 // we can avoid this by inserting an element before it and then remove it after we set the selection
493 tmpNode = dom.create('a', null, INVISIBLE_CHAR);
494 node.parentNode.insertBefore(tmpNode, node);
496 // Set selection and remove tmpNode
497 rng.setStart(node, 0);
498 selection.setRng(rng);
507 // Merges the styles for each node
508 function process(node) {
511 // Grab the children first since the nodelist might be changed
512 children = tinymce.grep(node.childNodes);
514 // Process current node
515 for (i = 0, l = formatList.length; i < l; i++) {
516 if (removeFormat(formatList[i], vars, node, node))
520 // Process the children
522 for (i = 0, l = children.length; i < l; i++)
523 process(children[i]);
527 function findFormatRoot(container) {
531 each(getParents(container.parentNode).reverse(), function(parent) {
534 // Find format root element
535 if (!formatRoot && parent.id != '_start' && parent.id != '_end') {
536 // Is the node matching the format we are looking for
537 format = matchNode(parent, name, vars);
538 if (format && format.split !== false)
546 function wrapAndSplit(format_root, container, target, split) {
547 var parent, clone, lastClone, firstClone, i, formatRootParent;
549 // Format root found then clone formats and split it
551 formatRootParent = format_root.parentNode;
553 for (parent = container.parentNode; parent && parent != formatRootParent; parent = parent.parentNode) {
554 clone = parent.cloneNode(FALSE);
556 for (i = 0; i < formatList.length; i++) {
557 if (removeFormat(formatList[i], vars, clone, clone)) {
563 // Build wrapper node
566 clone.appendChild(lastClone);
575 // Never split block elements if the format is mixed
576 if (split && (!format.mixed || !isBlock(format_root)))
577 container = dom.split(format_root, container);
579 // Wrap container in cloned formats
581 target.parentNode.insertBefore(lastClone, target);
582 firstClone.appendChild(target);
589 function splitToFormatRoot(container) {
590 return wrapAndSplit(findFormatRoot(container), container, container, true);
593 function unwrap(start) {
594 var node = dom.get(start ? '_start' : '_end'),
595 out = node[start ? 'firstChild' : 'lastChild'];
597 // If the end is placed within the start the result will be removed
598 // So this checks if the out node is a bookmark node if it is it
599 // checks for another more suitable node
600 if (isBookmarkNode(out))
601 out = out[start ? 'firstChild' : 'lastChild'];
603 dom.remove(node, true);
608 function removeRngStyle(rng) {
609 var startContainer, endContainer;
611 rng = expandRng(rng, formatList, TRUE);
614 startContainer = getContainer(rng, TRUE);
615 endContainer = getContainer(rng);
617 if (startContainer != endContainer) {
618 // Wrap start/end nodes in span element since these might be cloned/moved
619 startContainer = wrap(startContainer, 'span', {id : '_start', 'data-mce-type' : 'bookmark'});
620 endContainer = wrap(endContainer, 'span', {id : '_end', 'data-mce-type' : 'bookmark'});
623 splitToFormatRoot(startContainer);
624 splitToFormatRoot(endContainer);
626 // Unwrap start/end to get real elements again
627 startContainer = unwrap(TRUE);
628 endContainer = unwrap();
630 startContainer = endContainer = splitToFormatRoot(startContainer);
632 // Update range positions since they might have changed after the split operations
633 rng.startContainer = startContainer.parentNode;
634 rng.startOffset = nodeIndex(startContainer);
635 rng.endContainer = endContainer.parentNode;
636 rng.endOffset = nodeIndex(endContainer) + 1;
639 // Remove items between start/end
640 rangeUtils.walk(rng, function(nodes) {
641 each(nodes, function(node) {
644 // Remove parent span if it only contains text-decoration: underline, yet a parent node is also underlined.
645 if (node.nodeType === 1 && ed.dom.getStyle(node, 'text-decoration') === 'underline' && node.parentNode && getTextDecoration(node.parentNode) === 'underline') {
646 removeFormat({'deep': false, 'exact': true, 'inline': 'span', 'styles': {'textDecoration' : 'underline'}}, null, node);
654 rng = dom.createRng();
655 rng.setStartBefore(node);
656 rng.setEndAfter(node);
661 if (!selection.isCollapsed() || !format.inline || dom.select('td.mceSelected,th.mceSelected').length) {
662 bookmark = selection.getBookmark();
663 removeRngStyle(selection.getRng(TRUE));
664 selection.moveToBookmark(bookmark);
666 // Check if start element still has formatting then we are at: "<b>text|</b>text" and need to move the start into the next text node
667 if (match(name, vars, selection.getStart())) {
668 moveStart(selection.getRng(true));
673 performCaretAction('remove', name, vars);
677 * Toggles the specified format on/off.
680 * @param {String} name Name of format to apply/remove.
681 * @param {Object} vars Optional list of variables to replace within format before applying/removing it.
682 * @param {Node} node Optional node to apply the format to or remove from. Defaults to current selection.
684 function toggle(name, vars, node) {
687 if (match(name, vars, node) && (!('toggle' in fmt[0]) || fmt[0]['toggle']))
688 remove(name, vars, node);
690 apply(name, vars, node);
694 * Return true/false if the specified node has the specified format.
697 * @param {Node} node Node to check the format on.
698 * @param {String} name Format name to check.
699 * @param {Object} vars Optional list of variables to replace before checking it.
700 * @param {Boolean} similar Match format that has similar properties.
701 * @return {Object} Returns the format object it matches or undefined if it doesn't match.
703 function matchNode(node, name, vars, similar) {
704 var formatList = get(name), format, i, classes;
706 function matchItems(node, format, item_name) {
707 var key, value, items = format[item_name], i;
711 // Non indexed object
712 if (items.length === undefined) {
714 if (items.hasOwnProperty(key)) {
715 if (item_name === 'attributes')
716 value = dom.getAttrib(node, key);
718 value = getStyle(node, key);
720 if (similar && !value && !format.exact)
723 if ((!similar || format.exact) && !isEq(value, replaceVars(items[key], vars)))
728 // Only one match needed for indexed arrays
729 for (i = 0; i < items.length; i++) {
730 if (item_name === 'attributes' ? dom.getAttrib(node, items[i]) : getStyle(node, items[i]))
739 if (formatList && node) {
740 // Check each format in list
741 for (i = 0; i < formatList.length; i++) {
742 format = formatList[i];
744 // Name name, attributes, styles and classes
745 if (matchName(node, format) && matchItems(node, format, 'attributes') && matchItems(node, format, 'styles')) {
747 if (classes = format.classes) {
748 for (i = 0; i < classes.length; i++) {
749 if (!dom.hasClass(node, classes[i]))
761 * Matches the current selection or specified node against the specified format name.
764 * @param {String} name Name of format to match.
765 * @param {Object} vars Optional list of variables to replace before checking it.
766 * @param {Node} node Optional node to check.
767 * @return {boolean} true/false if the specified selection/node matches the format.
769 function match(name, vars, node) {
772 function matchParents(node) {
773 // Find first node with similar format settings
774 node = dom.getParent(node, function(node) {
775 return !!matchNode(node, name, vars, true);
778 // Do an exact check on the similar format element
779 return matchNode(node, name, vars);
782 // Check specified node
784 return matchParents(node);
786 // Check pending formats
787 if (selection.isCollapsed()) {
788 for (i = pendingFormats.apply.length - 1; i >= 0; i--) {
789 if (pendingFormats.apply[i].name == name)
793 for (i = pendingFormats.remove.length - 1; i >= 0; i--) {
794 if (pendingFormats.remove[i].name == name)
798 return matchParents(selection.getNode());
801 // Check selected node
802 node = selection.getNode();
803 if (matchParents(node))
806 // Check start node if it's different
807 startNode = selection.getStart();
808 if (startNode != node) {
809 if (matchParents(startNode))
817 * Matches the current selection against the array of formats and returns a new array with matching formats.
820 * @param {Array} names Name of format to match.
821 * @param {Object} vars Optional list of variables to replace before checking it.
822 * @return {Array} Array with matched formats.
824 function matchAll(names, vars) {
825 var startElement, matchedFormatNames = [], checkedMap = {}, i, ni, name;
827 // If the selection is collapsed then check pending formats
828 if (selection.isCollapsed()) {
829 for (ni = 0; ni < names.length; ni++) {
830 // If the name is to be removed, then stop it from being added
831 for (i = pendingFormats.remove.length - 1; i >= 0; i--) {
834 if (pendingFormats.remove[i].name == name) {
835 checkedMap[name] = true;
841 // If the format is to be applied
842 for (i = pendingFormats.apply.length - 1; i >= 0; i--) {
843 for (ni = 0; ni < names.length; ni++) {
846 if (!checkedMap[name] && pendingFormats.apply[i].name == name) {
847 checkedMap[name] = true;
848 matchedFormatNames.push(name);
854 // Check start of selection for formats
855 startElement = selection.getStart();
856 dom.getParent(startElement, function(node) {
859 for (i = 0; i < names.length; i++) {
862 if (!checkedMap[name] && matchNode(node, name, vars)) {
863 checkedMap[name] = true;
864 matchedFormatNames.push(name);
869 return matchedFormatNames;
873 * 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.
876 * @param {String} name Name of format to check.
877 * @return {boolean} true/false if the specified format can be applied to the current selection/node.
879 function canApply(name) {
880 var formatList = get(name), startNode, parents, i, x, selector;
883 startNode = selection.getStart();
884 parents = getParents(startNode);
886 for (x = formatList.length - 1; x >= 0; x--) {
887 selector = formatList[x].selector;
889 // Format is not selector based, then always return TRUE
893 for (i = parents.length - 1; i >= 0; i--) {
894 if (dom.is(parents[i], selector))
904 tinymce.extend(this, {
912 matchNode : matchNode,
919 * Checks if the specified nodes name matches the format inline/block or selector.
922 * @param {Node} node Node to match against the specified format.
923 * @param {Object} format Format object o match with.
924 * @return {boolean} true/false if the format matches.
926 function matchName(node, format) {
927 // Check for inline match
928 if (isEq(node, format.inline))
931 // Check for block match
932 if (isEq(node, format.block))
935 // Check for selector match
937 return dom.is(node, format.selector);
941 * Compares two string/nodes regardless of their case.
944 * @param {String/Node} Node or string to compare.
945 * @param {String/Node} Node or string to compare.
946 * @return {boolean} True/false if they match.
948 function isEq(str1, str2) {
952 str1 = '' + (str1.nodeName || str1);
953 str2 = '' + (str2.nodeName || str2);
955 return str1.toLowerCase() == str2.toLowerCase();
959 * Returns the style by name on the specified node. This method modifies the style
960 * contents to make it more easy to match. This will resolve a few browser issues.
963 * @param {Node} node to get style from.
964 * @param {String} name Style name to get.
965 * @return {String} Style item value.
967 function getStyle(node, name) {
968 var styleVal = dom.getStyle(node, name);
970 // Force the format to hex
971 if (name == 'color' || name == 'backgroundColor')
972 styleVal = dom.toHex(styleVal);
974 // Opera will return bold as 700
975 if (name == 'fontWeight' && styleVal == 700)
978 return '' + styleVal;
982 * Replaces variables in the value. The variable format is %var.
985 * @param {String} value Value to replace variables in.
986 * @param {Object} vars Name/value array with variables to replace.
987 * @return {String} New value with replaced variables.
989 function replaceVars(value, vars) {
990 if (typeof(value) != "string")
993 value = value.replace(/%(\w+)/g, function(str, name) {
994 return vars[name] || str;
1001 function isWhiteSpaceNode(node) {
1002 return node && node.nodeType === 3 && /^([\s\r\n]+|)$/.test(node.nodeValue);
1005 function wrap(node, name, attrs) {
1006 var wrapper = dom.create(name, attrs);
1008 node.parentNode.insertBefore(wrapper, node);
1009 wrapper.appendChild(node);
1015 * Expands the specified range like object to depending on format.
1017 * For example on block formats it will move the start/end position
1018 * to the beginning of the current block.
1021 * @param {Object} rng Range like object.
1022 * @param {Array} formats Array with formats to expand by.
1023 * @return {Object} Expanded range like object.
1025 function expandRng(rng, format, remove) {
1026 var startContainer = rng.startContainer,
1027 startOffset = rng.startOffset,
1028 endContainer = rng.endContainer,
1029 endOffset = rng.endOffset, sibling, lastIdx, leaf;
1031 // This function walks up the tree if there is no siblings before/after the node
1032 function findParentContainer(container, child_name, sibling_name, root) {
1035 root = root || dom.getRoot();
1038 // Check if we can move up are we at root level or body level
1039 parent = container.parentNode;
1041 // Stop expanding on block elements or root depending on format
1042 if (parent == root || (!format[0].block_expand && isBlock(parent)))
1045 for (sibling = parent[child_name]; sibling && sibling != container; sibling = sibling[sibling_name]) {
1046 if (sibling.nodeType == 1 && !isBookmarkNode(sibling))
1049 if (sibling.nodeType == 3 && !isWhiteSpaceNode(sibling))
1053 container = container.parentNode;
1059 // This function walks down the tree to find the leaf at the selection.
1060 // The offset is also returned as if node initially a leaf, the offset may be in the middle of the text node.
1061 function findLeaf(node, offset) {
1062 if (offset === undefined)
1063 offset = node.nodeType === 3 ? node.length : node.childNodes.length;
1064 while (node && node.hasChildNodes()) {
1065 node = node.childNodes[offset];
1067 offset = node.nodeType === 3 ? node.length : node.childNodes.length;
1069 return { node: node, offset: offset };
1072 // If index based start position then resolve it
1073 if (startContainer.nodeType == 1 && startContainer.hasChildNodes()) {
1074 lastIdx = startContainer.childNodes.length - 1;
1075 startContainer = startContainer.childNodes[startOffset > lastIdx ? lastIdx : startOffset];
1077 if (startContainer.nodeType == 3)
1081 // If index based end position then resolve it
1082 if (endContainer.nodeType == 1 && endContainer.hasChildNodes()) {
1083 lastIdx = endContainer.childNodes.length - 1;
1084 endContainer = endContainer.childNodes[endOffset > lastIdx ? lastIdx : endOffset - 1];
1086 if (endContainer.nodeType == 3)
1087 endOffset = endContainer.nodeValue.length;
1090 // Exclude bookmark nodes if possible
1091 if (isBookmarkNode(startContainer.parentNode))
1092 startContainer = startContainer.parentNode;
1094 if (isBookmarkNode(startContainer))
1095 startContainer = startContainer.nextSibling || startContainer;
1097 if (isBookmarkNode(endContainer.parentNode)) {
1098 endOffset = dom.nodeIndex(endContainer);
1099 endContainer = endContainer.parentNode;
1102 if (isBookmarkNode(endContainer) && endContainer.previousSibling) {
1103 endContainer = endContainer.previousSibling;
1104 endOffset = endContainer.length;
1107 if (format[0].inline) {
1108 // Avoid applying formatting to a trailing space.
1109 leaf = findLeaf(endContainer, endOffset);
1111 while (leaf.node && leaf.offset === 0 && leaf.node.previousSibling)
1112 leaf = findLeaf(leaf.node.previousSibling);
1114 if (leaf.node && leaf.offset > 0 && leaf.node.nodeType === 3 &&
1115 leaf.node.nodeValue.charAt(leaf.offset - 1) === ' ') {
1117 if (leaf.offset > 1) {
1118 endContainer = leaf.node;
1119 endContainer.splitText(leaf.offset - 1);
1120 } else if (leaf.node.previousSibling) {
1121 endContainer = leaf.node.previousSibling;
1127 // Move start/end point up the tree if the leaves are sharp and if we are in different containers
1128 // Example * becomes !: !<p><b><i>*text</i><i>text*</i></b></p>!
1129 // This will reduce the number of wrapper elements that needs to be created
1130 // Move start point up the tree
1131 if (format[0].inline || format[0].block_expand) {
1132 startContainer = findParentContainer(startContainer, 'firstChild', 'nextSibling');
1133 endContainer = findParentContainer(endContainer, 'lastChild', 'previousSibling');
1136 // Expand start/end container to matching selector
1137 if (format[0].selector && format[0].expand !== FALSE && !format[0].inline) {
1138 function findSelectorEndPoint(container, sibling_name) {
1139 var parents, i, y, curFormat;
1141 if (container.nodeType == 3 && container.nodeValue.length == 0 && container[sibling_name])
1142 container = container[sibling_name];
1144 parents = getParents(container);
1145 for (i = 0; i < parents.length; i++) {
1146 for (y = 0; y < format.length; y++) {
1147 curFormat = format[y];
1149 // If collapsed state is set then skip formats that doesn't match that
1150 if ("collapsed" in curFormat && curFormat.collapsed !== rng.collapsed)
1153 if (dom.is(parents[i], curFormat.selector))
1161 // Find new startContainer/endContainer if there is better one
1162 startContainer = findSelectorEndPoint(startContainer, 'previousSibling');
1163 endContainer = findSelectorEndPoint(endContainer, 'nextSibling');
1166 // Expand start/end container to matching block element or text node
1167 if (format[0].block || format[0].selector) {
1168 function findBlockEndPoint(container, sibling_name, sibling_name2) {
1171 // Expand to block of similar type
1172 if (!format[0].wrapper)
1173 node = dom.getParent(container, format[0].block);
1175 // Expand to first wrappable block element or any block element
1177 node = dom.getParent(container.nodeType == 3 ? container.parentNode : container, isBlock);
1179 // Exclude inner lists from wrapping
1180 if (node && format[0].wrapper)
1181 node = getParents(node, 'ul,ol').reverse()[0] || node;
1183 // Didn't find a block element look for first/last wrappable element
1187 while (node[sibling_name] && !isBlock(node[sibling_name])) {
1188 node = node[sibling_name];
1190 // Break on BR but include it will be removed later on
1191 // we can't remove it now since we need to check if it can be wrapped
1192 if (isEq(node, 'br'))
1197 return node || container;
1200 // Find new startContainer/endContainer if there is better one
1201 startContainer = findBlockEndPoint(startContainer, 'previousSibling');
1202 endContainer = findBlockEndPoint(endContainer, 'nextSibling');
1204 // Non block element then try to expand up the leaf
1205 if (format[0].block) {
1206 if (!isBlock(startContainer))
1207 startContainer = findParentContainer(startContainer, 'firstChild', 'nextSibling');
1209 if (!isBlock(endContainer))
1210 endContainer = findParentContainer(endContainer, 'lastChild', 'previousSibling');
1214 // Setup index for startContainer
1215 if (startContainer.nodeType == 1) {
1216 startOffset = nodeIndex(startContainer);
1217 startContainer = startContainer.parentNode;
1220 // Setup index for endContainer
1221 if (endContainer.nodeType == 1) {
1222 endOffset = nodeIndex(endContainer) + 1;
1223 endContainer = endContainer.parentNode;
1226 // Return new range like object
1228 startContainer : startContainer,
1229 startOffset : startOffset,
1230 endContainer : endContainer,
1231 endOffset : endOffset
1236 * Removes the specified format for the specified node. It will also remove the node if it doesn't have
1237 * any attributes if the format specifies it to do so.
1240 * @param {Object} format Format object with items to remove from node.
1241 * @param {Object} vars Name/value object with variables to apply to format.
1242 * @param {Node} node Node to remove the format styles on.
1243 * @param {Node} compare_node Optional compare node, if specified the styles will be compared to that node.
1244 * @return {Boolean} True/false if the node was removed or not.
1246 function removeFormat(format, vars, node, compare_node) {
1247 var i, attrs, stylesModified;
1249 // Check if node matches format
1250 if (!matchName(node, format))
1253 // Should we compare with format attribs and styles
1254 if (format.remove != 'all') {
1256 each(format.styles, function(value, name) {
1257 value = replaceVars(value, vars);
1260 if (typeof(name) === 'number') {
1265 if (!compare_node || isEq(getStyle(compare_node, name), value))
1266 dom.setStyle(node, name, '');
1271 // Remove style attribute if it's empty
1272 if (stylesModified && dom.getAttrib(node, 'style') == '') {
1273 node.removeAttribute('style');
1274 node.removeAttribute('data-mce-style');
1277 // Remove attributes
1278 each(format.attributes, function(value, name) {
1281 value = replaceVars(value, vars);
1284 if (typeof(name) === 'number') {
1289 if (!compare_node || isEq(dom.getAttrib(compare_node, name), value)) {
1290 // Keep internal classes
1291 if (name == 'class') {
1292 value = dom.getAttrib(node, name);
1294 // Build new class value where everything is removed except the internal prefixed classes
1296 each(value.split(/\s+/), function(cls) {
1297 if (/mce\w+/.test(cls))
1298 valueOut += (valueOut ? ' ' : '') + cls;
1301 // We got some internal classes left
1303 dom.setAttrib(node, name, valueOut);
1309 // IE6 has a bug where the attribute doesn't get removed correctly
1310 if (name == "class")
1311 node.removeAttribute('className');
1313 // Remove mce prefixed attributes
1314 if (MCE_ATTR_RE.test(name))
1315 node.removeAttribute('data-mce-' + name);
1317 node.removeAttribute(name);
1322 each(format.classes, function(value) {
1323 value = replaceVars(value, vars);
1325 if (!compare_node || dom.hasClass(compare_node, value))
1326 dom.removeClass(node, value);
1329 // Check for non internal attributes
1330 attrs = dom.getAttribs(node);
1331 for (i = 0; i < attrs.length; i++) {
1332 if (attrs[i].nodeName.indexOf('_') !== 0)
1337 // Remove the inline child if it's empty for example <b> or <span>
1338 if (format.remove != 'none') {
1339 removeNode(node, format);
1345 * Removes the node and wrap it's children in paragraphs before doing so or
1346 * appends BR elements to the beginning/end of the block element if forcedRootBlocks is disabled.
1348 * If the div in the node below gets removed:
1349 * text<div>text</div>text
1352 * text<div><br />text<br /></div>text
1354 * So when the div is removed the result is:
1355 * text<br />text<br />text
1358 * @param {Node} node Node to remove + apply BR/P elements to.
1359 * @param {Object} format Format rule.
1360 * @return {Node} Input node.
1362 function removeNode(node, format) {
1363 var parentNode = node.parentNode, rootBlockElm;
1366 if (!forcedRootBlock) {
1367 function find(node, next, inc) {
1368 node = getNonWhiteSpaceSibling(node, next, inc);
1370 return !node || (node.nodeName == 'BR' || isBlock(node));
1373 // Append BR elements if needed before we remove the block
1374 if (isBlock(node) && !isBlock(parentNode)) {
1375 if (!find(node, FALSE) && !find(node.firstChild, TRUE, 1))
1376 node.insertBefore(dom.create('br'), node.firstChild);
1378 if (!find(node, TRUE) && !find(node.lastChild, FALSE, 1))
1379 node.appendChild(dom.create('br'));
1382 // Wrap the block in a forcedRootBlock if we are at the root of document
1383 if (parentNode == dom.getRoot()) {
1384 if (!format.list_block || !isEq(node, format.list_block)) {
1385 each(tinymce.grep(node.childNodes), function(node) {
1386 if (isValid(forcedRootBlock, node.nodeName.toLowerCase())) {
1388 rootBlockElm = wrap(node, forcedRootBlock);
1390 rootBlockElm.appendChild(node);
1399 // Never remove nodes that isn't the specified inline element if a selector is specified too
1400 if (format.selector && format.inline && !isEq(format.inline, node))
1403 dom.remove(node, 1);
1407 * Returns the next/previous non whitespace node.
1410 * @param {Node} node Node to start at.
1411 * @param {boolean} next (Optional) Include next or previous node defaults to previous.
1412 * @param {boolean} inc (Optional) Include the current node in checking. Defaults to false.
1413 * @return {Node} Next or previous node or undefined if it wasn't found.
1415 function getNonWhiteSpaceSibling(node, next, inc) {
1417 next = next ? 'nextSibling' : 'previousSibling';
1419 for (node = inc ? node : node[next]; node; node = node[next]) {
1420 if (node.nodeType == 1 || !isWhiteSpaceNode(node))
1427 * Checks if the specified node is a bookmark node or not.
1429 * @param {Node} node Node to check if it's a bookmark node or not.
1430 * @return {Boolean} true/false if the node is a bookmark node.
1432 function isBookmarkNode(node) {
1433 return node && node.nodeType == 1 && node.getAttribute('data-mce-type') == 'bookmark';
1437 * Merges the next/previous sibling element if they match.
1440 * @param {Node} prev Previous node to compare/merge.
1441 * @param {Node} next Next node to compare/merge.
1442 * @return {Node} Next node if we didn't merge and prev node if we did.
1444 function mergeSiblings(prev, next) {
1445 var marker, sibling, tmpSibling;
1448 * Compares two nodes and checks if it's attributes and styles matches.
1449 * This doesn't compare classes as items since their order is significant.
1452 * @param {Node} node1 First node to compare with.
1453 * @param {Node} node2 Second node to compare with.
1454 * @return {boolean} True/false if the nodes are the same or not.
1456 function compareElements(node1, node2) {
1457 // Not the same name
1458 if (node1.nodeName != node2.nodeName)
1462 * Returns all the nodes attributes excluding internal ones, styles and classes.
1465 * @param {Node} node Node to get attributes from.
1466 * @return {Object} Name/value object with attributes and attribute values.
1468 function getAttribs(node) {
1471 each(dom.getAttribs(node), function(attr) {
1472 var name = attr.nodeName.toLowerCase();
1474 // Don't compare internal attributes or style
1475 if (name.indexOf('_') !== 0 && name !== 'style')
1476 attribs[name] = dom.getAttrib(node, name);
1483 * Compares two objects checks if it's key + value exists in the other one.
1486 * @param {Object} obj1 First object to compare.
1487 * @param {Object} obj2 Second object to compare.
1488 * @return {boolean} True/false if the objects matches or not.
1490 function compareObjects(obj1, obj2) {
1493 for (name in obj1) {
1494 // Obj1 has item obj2 doesn't have
1495 if (obj1.hasOwnProperty(name)) {
1498 // Obj2 doesn't have obj1 item
1499 if (value === undefined)
1502 // Obj2 item has a different value
1503 if (obj1[name] != value)
1506 // Delete similar value
1511 // Check if obj 2 has something obj 1 doesn't have
1512 for (name in obj2) {
1513 // Obj2 has item obj1 doesn't have
1514 if (obj2.hasOwnProperty(name))
1521 // Attribs are not the same
1522 if (!compareObjects(getAttribs(node1), getAttribs(node2)))
1525 // Styles are not the same
1526 if (!compareObjects(dom.parseStyle(dom.getAttrib(node1, 'style')), dom.parseStyle(dom.getAttrib(node2, 'style'))))
1532 // Check if next/prev exists and that they are elements
1534 function findElementSibling(node, sibling_name) {
1535 for (sibling = node; sibling; sibling = sibling[sibling_name]) {
1536 if (sibling.nodeType == 3 && sibling.nodeValue.length !== 0)
1539 if (sibling.nodeType == 1 && !isBookmarkNode(sibling))
1546 // If previous sibling is empty then jump over it
1547 prev = findElementSibling(prev, 'previousSibling');
1548 next = findElementSibling(next, 'nextSibling');
1550 // Compare next and previous nodes
1551 if (compareElements(prev, next)) {
1552 // Append nodes between
1553 for (sibling = prev.nextSibling; sibling && sibling != next;) {
1554 tmpSibling = sibling;
1555 sibling = sibling.nextSibling;
1556 prev.appendChild(tmpSibling);
1562 // Move children into prev node
1563 each(tinymce.grep(next.childNodes), function(node) {
1564 prev.appendChild(node);
1575 * Returns true/false if the specified node is a text block or not.
1578 * @param {Node} node Node to check.
1579 * @return {boolean} True/false if the node is a text block.
1581 function isTextBlock(name) {
1582 return /^(h[1-6]|p|div|pre|address|dl|dt|dd)$/.test(name);
1585 function getContainer(rng, start) {
1586 var container, offset, lastIdx;
1588 container = rng[start ? 'startContainer' : 'endContainer'];
1589 offset = rng[start ? 'startOffset' : 'endOffset'];
1591 if (container.nodeType == 1) {
1592 lastIdx = container.childNodes.length - 1;
1594 if (!start && offset)
1597 container = container.childNodes[offset > lastIdx ? lastIdx : offset];
1603 function performCaretAction(type, name, vars) {
1604 var i, currentPendingFormats = pendingFormats[type],
1605 otherPendingFormats = pendingFormats[type == 'apply' ? 'remove' : 'apply'];
1607 function hasPending() {
1608 return pendingFormats.apply.length || pendingFormats.remove.length;
1611 function resetPending() {
1612 pendingFormats.apply = [];
1613 pendingFormats.remove = [];
1616 function perform(caret_node) {
1617 // Apply pending formats
1618 each(pendingFormats.apply.reverse(), function(item) {
1619 apply(item.name, item.vars, caret_node);
1621 // Colored nodes should be underlined so that the color of the underline matches the text color.
1622 if (item.name === 'forecolor' && item.vars.value)
1623 processUnderlineAndColor(caret_node.parentNode);
1626 // Remove pending formats
1627 each(pendingFormats.remove.reverse(), function(item) {
1628 remove(item.name, item.vars, caret_node);
1631 dom.remove(caret_node, 1);
1635 // Check if it already exists then ignore it
1636 for (i = currentPendingFormats.length - 1; i >= 0; i--) {
1637 if (currentPendingFormats[i].name == name)
1641 currentPendingFormats.push({name : name, vars : vars});
1643 // Check if it's in the other type, then remove it
1644 for (i = otherPendingFormats.length - 1; i >= 0; i--) {
1645 if (otherPendingFormats[i].name == name)
1646 otherPendingFormats.splice(i, 1);
1649 // Pending apply or remove formats
1651 ed.getDoc().execCommand('FontName', false, 'mceinline');
1652 pendingFormats.lastRng = selection.getRng();
1654 // IE will convert the current word
1655 each(dom.select('font,span'), function(node) {
1658 if (isCaretNode(node)) {
1659 bookmark = selection.getBookmark();
1661 selection.moveToBookmark(bookmark);
1666 // Only register listeners once if we need to
1667 if (!pendingFormats.isListening && hasPending()) {
1668 pendingFormats.isListening = true;
1670 each('onKeyDown,onKeyUp,onKeyPress,onMouseUp'.split(','), function(event) {
1671 ed[event].addToTop(function(ed, e) {
1672 // Do we have pending formats and is the selection moved has moved
1673 if (hasPending() && !tinymce.dom.RangeUtils.compareRanges(pendingFormats.lastRng, selection.getRng())) {
1674 each(dom.select('font,span'), function(node) {
1678 if (isCaretNode(node)) {
1679 textNode = node.firstChild;
1684 rng = dom.createRng();
1685 rng.setStart(textNode, textNode.nodeValue.length);
1686 rng.setEnd(textNode, textNode.nodeValue.length);
1687 selection.setRng(rng);
1694 // Always unbind and clear pending styles on keyup
1695 if (e.type == 'keyup' || e.type == 'mouseup')