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);
204 function adjustSelectionToVisibleSelection() {
206 function findSelectionEnd(start, end) {
207 var walker = new TreeWalker(end);
208 for (node = walker.current(); node; node = walker.prev()) {
209 if (node.childNodes.length > 1 || node == start) {
215 // Adjust selection so that a end container with a end offset of zero is not included in the selection
216 // as this isn't visible to the user.
217 var rng = ed.selection.getRng();
218 var start = rng.startContainer;
219 var end = rng.endContainer;
220 if (start != end && rng.endOffset == 0) {
221 var newEnd = findSelectionEnd(start, end);
222 var endOffset = newEnd.nodeType == 3 ? newEnd.length : newEnd.childNodes.length;
223 rng.setEnd(newEnd, endOffset);
228 function applyStyleToList(node, bookmark, wrapElm, newWrappers, process){
229 var nodes =[], listIndex =-1, list, startIndex = -1, endIndex = -1, currentWrapElm;
231 // find the index of the first child list.
232 each(node.childNodes, function(n, index) {
233 if (n.nodeName==="UL"||n.nodeName==="OL") {listIndex = index; list=n; return false; }
236 // get the index of the bookmarks
237 each(node.childNodes, function(n, index) {
238 if (n.nodeName==="SPAN" &&dom.getAttrib(n, "data-mce-type")=="bookmark" && n.id==bookmark.id+"_start") {startIndex=index}
239 if (n.nodeName==="SPAN" &&dom.getAttrib(n, "data-mce-type")=="bookmark" && n.id==bookmark.id+"_end") {endIndex=index}
242 // if the selection spans across an embedded list, or there isn't an embedded list - handle processing normally
243 if (listIndex<=0 || (startIndex<listIndex&&endIndex>listIndex)) {
244 each(tinymce.grep(node.childNodes), process);
247 currentWrapElm = wrapElm.cloneNode(FALSE);
249 // create a list of the nodes on the same side of the list as the selection
250 each(tinymce.grep(node.childNodes), function(n, index) {
251 if ((startIndex<listIndex && index <listIndex) || (startIndex>listIndex && index >listIndex)) {
253 n.parentNode.removeChild(n);
257 // insert the wrapping element either before or after the list.
258 if (startIndex<listIndex) {
259 node.insertBefore(currentWrapElm, list);
260 } else if (startIndex>listIndex) {
261 node.insertBefore(currentWrapElm, list.nextSibling);
264 // add the new nodes to the list.
265 newWrappers.push(currentWrapElm);
266 each(nodes, function(node){currentWrapElm.appendChild(node)});
267 return currentWrapElm;
271 function applyRngStyle(rng, bookmark) {
272 var newWrappers = [], wrapName, wrapElm;
274 // Setup wrapper element
275 wrapName = format.inline || format.block;
276 wrapElm = dom.create(wrapName);
277 setElementFormat(wrapElm);
279 rangeUtils.walk(rng, function(nodes) {
283 * Process a list of nodes wrap them.
285 function process(node) {
286 var nodeName = node.nodeName.toLowerCase(), parentName = node.parentNode.nodeName.toLowerCase(), found;
288 // Stop wrapping on br elements
289 if (isEq(nodeName, 'br')) {
292 // Remove any br elements when we wrap things
299 // If node is wrapper type
300 if (format.wrapper && matchNode(node, name, vars)) {
305 // Can we rename the block
306 if (format.block && !format.wrapper && isTextBlock(nodeName)) {
307 node = dom.rename(node, wrapName);
308 setElementFormat(node);
309 newWrappers.push(node);
314 // Handle selector patterns
315 if (format.selector) {
316 // Look for matching formats
317 each(formatList, function(format) {
318 // Check collapsed state if it exists
319 if ('collapsed' in format && format.collapsed !== isCollapsed) {
323 if (dom.is(node, format.selector) && !isCaretNode(node)) {
324 setElementFormat(node, format);
329 // Continue processing if a selector match wasn't found and a inline element is defined
330 if (!format.inline || found) {
336 // Is it valid to wrap this item
337 if (isValid(wrapName, nodeName) && isValid(parentName, wrapName) &&
338 !(node.nodeType === 3 && node.nodeValue.length === 1 && node.nodeValue.charCodeAt(0) === 65279)) {
340 if (!currentWrapElm) {
342 currentWrapElm = wrapElm.cloneNode(FALSE);
343 node.parentNode.insertBefore(currentWrapElm, node);
344 newWrappers.push(currentWrapElm);
347 currentWrapElm.appendChild(node);
348 } else if (nodeName == 'li' && bookmark) {
349 // Start wrapping - if we are in a list node and have a bookmark, then we will always begin by wrapping in a new element.
350 currentWrapElm = applyStyleToList(node, bookmark, wrapElm, newWrappers, process);
352 // Start a new wrapper for possible children
355 each(tinymce.grep(node.childNodes), process);
357 // End the last wrapper
362 // Process siblings from range
363 each(nodes, process);
366 // Wrap links inside as well, for example color inside a link when the wrapper is around the link
367 if (format.wrap_links === false) {
368 each(newWrappers, function(node) {
369 function process(node) {
370 var i, currentWrapElm, children;
372 if (node.nodeName === 'A') {
373 currentWrapElm = wrapElm.cloneNode(FALSE);
374 newWrappers.push(currentWrapElm);
376 children = tinymce.grep(node.childNodes);
377 for (i = 0; i < children.length; i++)
378 currentWrapElm.appendChild(children[i]);
380 node.appendChild(currentWrapElm);
383 each(tinymce.grep(node.childNodes), process);
391 each(newWrappers, function(node) {
394 function getChildCount(node) {
397 each(node.childNodes, function(node) {
398 if (!isWhiteSpaceNode(node) && !isBookmarkNode(node))
405 function mergeStyles(node) {
408 each(node.childNodes, function(node) {
409 if (node.nodeType == 1 && !isBookmarkNode(node) && !isCaretNode(node)) {
411 return FALSE; // break loop
415 // If child was found and of the same type as the current node
416 if (child && matchName(child, format)) {
417 clone = child.cloneNode(FALSE);
418 setElementFormat(clone);
420 dom.replace(clone, node, TRUE);
421 dom.remove(child, 1);
424 return clone || node;
427 childCount = getChildCount(node);
429 // Remove empty nodes but only if there is multiple wrappers and they are not block
430 // elements so never remove single <h1></h1> since that would remove the currrent empty block element where the caret is at
431 if ((newWrappers.length > 1 || !isBlock(node)) && childCount === 0) {
436 if (format.inline || format.wrapper) {
437 // Merges the current node with it's children of similar type to reduce the number of elements
438 if (!format.exact && childCount === 1)
439 node = mergeStyles(node);
441 // Remove/merge children
442 each(formatList, function(format) {
443 // Merge all children of similar type will move styles from child to parent
444 // this: <span style="color:red"><b><span style="color:red; font-size:10px">text</span></b></span>
445 // will become: <span style="color:red"><b><span style="font-size:10px">text</span></b></span>
446 each(dom.select(format.inline, node), function(child) {
449 // When wrap_links is set to false we don't want
450 // to remove the format on children within links
451 if (format.wrap_links === false) {
452 parent = child.parentNode;
455 if (parent.nodeName === 'A')
457 } while (parent = parent.parentNode);
460 removeFormat(format, vars, child, format.exact ? child : null);
464 // Remove child if direct parent is of same type
465 if (matchNode(node.parentNode, name, vars)) {
471 // Look for parent with similar style format
472 if (format.merge_with_parents) {
473 dom.getParent(node.parentNode, function(parent) {
474 if (matchNode(parent, name, vars)) {
482 // Merge next and previous siblings if they are similar <b>text</b><b>text</b> becomes <b>texttext</b>
484 node = mergeSiblings(getNonWhiteSpaceSibling(node), node);
485 node = mergeSiblings(node, getNonWhiteSpaceSibling(node, TRUE));
493 rng = dom.createRng();
495 rng.setStartBefore(node);
496 rng.setEndAfter(node);
498 applyRngStyle(expandRng(rng, formatList));
500 if (!isCollapsed || !format.inline || dom.select('td.mceSelected,th.mceSelected').length) {
501 // Obtain selection node before selection is unselected by applyRngStyle()
502 var curSelNode = ed.selection.getNode();
504 // Apply formatting to selection
505 ed.selection.setRng(adjustSelectionToVisibleSelection());
506 bookmark = selection.getBookmark();
507 applyRngStyle(expandRng(selection.getRng(TRUE), formatList), bookmark);
509 // Colored nodes should be underlined so that the color of the underline matches the text color.
510 if (format.styles && (format.styles.color || format.styles.textDecoration)) {
511 tinymce.walk(curSelNode, processUnderlineAndColor, 'childNodes');
512 processUnderlineAndColor(curSelNode);
515 selection.moveToBookmark(bookmark);
516 selection.setRng(moveStart(selection.getRng(TRUE)));
519 performCaretAction('apply', name, vars);
525 * Removes the specified format from the current selection or specified node.
528 * @param {String} name Name of format to remove.
529 * @param {Object} vars Optional list of variables to replace within format before removing it.
530 * @param {Node} node Optional node to remove the format from defaults to current selection.
532 function remove(name, vars, node) {
533 var formatList = get(name), format = formatList[0], bookmark, i, rng;
535 * Moves the start to the first suitable text node.
537 function moveStart(rng) {
538 var container = rng.startContainer,
539 offset = rng.startOffset,
540 walker, node, nodes, tmpNode;
542 // Convert text node into index if possible
543 if (container.nodeType == 3 && offset >= container.nodeValue.length - 1) {
544 container = container.parentNode;
545 offset = nodeIndex(container) + 1;
548 // Move startContainer/startOffset in to a suitable node
549 if (container.nodeType == 1) {
550 nodes = container.childNodes;
551 container = nodes[Math.min(offset, nodes.length - 1)];
552 walker = new TreeWalker(container);
554 // If offset is at end of the parent node walk to the next one
555 if (offset > nodes.length - 1)
558 for (node = walker.current(); node; node = walker.next()) {
559 if (node.nodeType == 3 && !isWhiteSpaceNode(node)) {
560 // IE has a "neat" feature where it moves the start node into the closest element
561 // we can avoid this by inserting an element before it and then remove it after we set the selection
562 tmpNode = dom.create('a', null, INVISIBLE_CHAR);
563 node.parentNode.insertBefore(tmpNode, node);
565 // Set selection and remove tmpNode
566 rng.setStart(node, 0);
567 selection.setRng(rng);
576 // Merges the styles for each node
577 function process(node) {
580 // Grab the children first since the nodelist might be changed
581 children = tinymce.grep(node.childNodes);
583 // Process current node
584 for (i = 0, l = formatList.length; i < l; i++) {
585 if (removeFormat(formatList[i], vars, node, node))
589 // Process the children
591 for (i = 0, l = children.length; i < l; i++)
592 process(children[i]);
596 function findFormatRoot(container) {
600 each(getParents(container.parentNode).reverse(), function(parent) {
603 // Find format root element
604 if (!formatRoot && parent.id != '_start' && parent.id != '_end') {
605 // Is the node matching the format we are looking for
606 format = matchNode(parent, name, vars);
607 if (format && format.split !== false)
615 function wrapAndSplit(format_root, container, target, split) {
616 var parent, clone, lastClone, firstClone, i, formatRootParent;
618 // Format root found then clone formats and split it
620 formatRootParent = format_root.parentNode;
622 for (parent = container.parentNode; parent && parent != formatRootParent; parent = parent.parentNode) {
623 clone = parent.cloneNode(FALSE);
625 for (i = 0; i < formatList.length; i++) {
626 if (removeFormat(formatList[i], vars, clone, clone)) {
632 // Build wrapper node
635 clone.appendChild(lastClone);
644 // Never split block elements if the format is mixed
645 if (split && (!format.mixed || !isBlock(format_root)))
646 container = dom.split(format_root, container);
648 // Wrap container in cloned formats
650 target.parentNode.insertBefore(lastClone, target);
651 firstClone.appendChild(target);
658 function splitToFormatRoot(container) {
659 return wrapAndSplit(findFormatRoot(container), container, container, true);
662 function unwrap(start) {
663 var node = dom.get(start ? '_start' : '_end'),
664 out = node[start ? 'firstChild' : 'lastChild'];
666 // If the end is placed within the start the result will be removed
667 // So this checks if the out node is a bookmark node if it is it
668 // checks for another more suitable node
669 if (isBookmarkNode(out))
670 out = out[start ? 'firstChild' : 'lastChild'];
672 dom.remove(node, true);
677 function removeRngStyle(rng) {
678 var startContainer, endContainer;
680 rng = expandRng(rng, formatList, TRUE);
683 startContainer = getContainer(rng, TRUE);
684 endContainer = getContainer(rng);
686 if (startContainer != endContainer) {
687 // Wrap start/end nodes in span element since these might be cloned/moved
688 startContainer = wrap(startContainer, 'span', {id : '_start', 'data-mce-type' : 'bookmark'});
689 endContainer = wrap(endContainer, 'span', {id : '_end', 'data-mce-type' : 'bookmark'});
692 splitToFormatRoot(startContainer);
693 splitToFormatRoot(endContainer);
695 // Unwrap start/end to get real elements again
696 startContainer = unwrap(TRUE);
697 endContainer = unwrap();
699 startContainer = endContainer = splitToFormatRoot(startContainer);
701 // Update range positions since they might have changed after the split operations
702 rng.startContainer = startContainer.parentNode;
703 rng.startOffset = nodeIndex(startContainer);
704 rng.endContainer = endContainer.parentNode;
705 rng.endOffset = nodeIndex(endContainer) + 1;
708 // Remove items between start/end
709 rangeUtils.walk(rng, function(nodes) {
710 each(nodes, function(node) {
713 // Remove parent span if it only contains text-decoration: underline, yet a parent node is also underlined.
714 if (node.nodeType === 1 && ed.dom.getStyle(node, 'text-decoration') === 'underline' && node.parentNode && getTextDecoration(node.parentNode) === 'underline') {
715 removeFormat({'deep': false, 'exact': true, 'inline': 'span', 'styles': {'textDecoration' : 'underline'}}, null, node);
723 rng = dom.createRng();
724 rng.setStartBefore(node);
725 rng.setEndAfter(node);
730 if (!selection.isCollapsed() || !format.inline || dom.select('td.mceSelected,th.mceSelected').length) {
731 bookmark = selection.getBookmark();
732 removeRngStyle(selection.getRng(TRUE));
733 selection.moveToBookmark(bookmark);
735 // 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
736 if (match(name, vars, selection.getStart())) {
737 moveStart(selection.getRng(true));
742 performCaretAction('remove', name, vars);
746 * Toggles the specified format on/off.
749 * @param {String} name Name of format to apply/remove.
750 * @param {Object} vars Optional list of variables to replace within format before applying/removing it.
751 * @param {Node} node Optional node to apply the format to or remove from. Defaults to current selection.
753 function toggle(name, vars, node) {
756 if (match(name, vars, node) && (!('toggle' in fmt[0]) || fmt[0]['toggle']))
757 remove(name, vars, node);
759 apply(name, vars, node);
763 * Return true/false if the specified node has the specified format.
766 * @param {Node} node Node to check the format on.
767 * @param {String} name Format name to check.
768 * @param {Object} vars Optional list of variables to replace before checking it.
769 * @param {Boolean} similar Match format that has similar properties.
770 * @return {Object} Returns the format object it matches or undefined if it doesn't match.
772 function matchNode(node, name, vars, similar) {
773 var formatList = get(name), format, i, classes;
775 function matchItems(node, format, item_name) {
776 var key, value, items = format[item_name], i;
780 // Non indexed object
781 if (items.length === undefined) {
783 if (items.hasOwnProperty(key)) {
784 if (item_name === 'attributes')
785 value = dom.getAttrib(node, key);
787 value = getStyle(node, key);
789 if (similar && !value && !format.exact)
792 if ((!similar || format.exact) && !isEq(value, replaceVars(items[key], vars)))
797 // Only one match needed for indexed arrays
798 for (i = 0; i < items.length; i++) {
799 if (item_name === 'attributes' ? dom.getAttrib(node, items[i]) : getStyle(node, items[i]))
808 if (formatList && node) {
809 // Check each format in list
810 for (i = 0; i < formatList.length; i++) {
811 format = formatList[i];
813 // Name name, attributes, styles and classes
814 if (matchName(node, format) && matchItems(node, format, 'attributes') && matchItems(node, format, 'styles')) {
816 if (classes = format.classes) {
817 for (i = 0; i < classes.length; i++) {
818 if (!dom.hasClass(node, classes[i]))
830 * Matches the current selection or specified node against the specified format name.
833 * @param {String} name Name of format to match.
834 * @param {Object} vars Optional list of variables to replace before checking it.
835 * @param {Node} node Optional node to check.
836 * @return {boolean} true/false if the specified selection/node matches the format.
838 function match(name, vars, node) {
841 function matchParents(node) {
842 // Find first node with similar format settings
843 node = dom.getParent(node, function(node) {
844 return !!matchNode(node, name, vars, true);
847 // Do an exact check on the similar format element
848 return matchNode(node, name, vars);
851 // Check specified node
853 return matchParents(node);
855 // Check pending formats
856 if (selection.isCollapsed()) {
857 for (i = pendingFormats.apply.length - 1; i >= 0; i--) {
858 if (pendingFormats.apply[i].name == name)
862 for (i = pendingFormats.remove.length - 1; i >= 0; i--) {
863 if (pendingFormats.remove[i].name == name)
867 return matchParents(selection.getNode());
870 // Check selected node
871 node = selection.getNode();
872 if (matchParents(node))
875 // Check start node if it's different
876 startNode = selection.getStart();
877 if (startNode != node) {
878 if (matchParents(startNode))
886 * Matches the current selection against the array of formats and returns a new array with matching formats.
889 * @param {Array} names Name of format to match.
890 * @param {Object} vars Optional list of variables to replace before checking it.
891 * @return {Array} Array with matched formats.
893 function matchAll(names, vars) {
894 var startElement, matchedFormatNames = [], checkedMap = {}, i, ni, name;
896 // If the selection is collapsed then check pending formats
897 if (selection.isCollapsed()) {
898 for (ni = 0; ni < names.length; ni++) {
899 // If the name is to be removed, then stop it from being added
900 for (i = pendingFormats.remove.length - 1; i >= 0; i--) {
903 if (pendingFormats.remove[i].name == name) {
904 checkedMap[name] = true;
910 // If the format is to be applied
911 for (i = pendingFormats.apply.length - 1; i >= 0; i--) {
912 for (ni = 0; ni < names.length; ni++) {
915 if (!checkedMap[name] && pendingFormats.apply[i].name == name) {
916 checkedMap[name] = true;
917 matchedFormatNames.push(name);
923 // Check start of selection for formats
924 startElement = selection.getStart();
925 dom.getParent(startElement, function(node) {
928 for (i = 0; i < names.length; i++) {
931 if (!checkedMap[name] && matchNode(node, name, vars)) {
932 checkedMap[name] = true;
933 matchedFormatNames.push(name);
938 return matchedFormatNames;
942 * 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.
945 * @param {String} name Name of format to check.
946 * @return {boolean} true/false if the specified format can be applied to the current selection/node.
948 function canApply(name) {
949 var formatList = get(name), startNode, parents, i, x, selector;
952 startNode = selection.getStart();
953 parents = getParents(startNode);
955 for (x = formatList.length - 1; x >= 0; x--) {
956 selector = formatList[x].selector;
958 // Format is not selector based, then always return TRUE
962 for (i = parents.length - 1; i >= 0; i--) {
963 if (dom.is(parents[i], selector))
973 tinymce.extend(this, {
981 matchNode : matchNode,
988 * Checks if the specified nodes name matches the format inline/block or selector.
991 * @param {Node} node Node to match against the specified format.
992 * @param {Object} format Format object o match with.
993 * @return {boolean} true/false if the format matches.
995 function matchName(node, format) {
996 // Check for inline match
997 if (isEq(node, format.inline))
1000 // Check for block match
1001 if (isEq(node, format.block))
1004 // Check for selector match
1005 if (format.selector)
1006 return dom.is(node, format.selector);
1010 * Compares two string/nodes regardless of their case.
1013 * @param {String/Node} Node or string to compare.
1014 * @param {String/Node} Node or string to compare.
1015 * @return {boolean} True/false if they match.
1017 function isEq(str1, str2) {
1021 str1 = '' + (str1.nodeName || str1);
1022 str2 = '' + (str2.nodeName || str2);
1024 return str1.toLowerCase() == str2.toLowerCase();
1028 * Returns the style by name on the specified node. This method modifies the style
1029 * contents to make it more easy to match. This will resolve a few browser issues.
1032 * @param {Node} node to get style from.
1033 * @param {String} name Style name to get.
1034 * @return {String} Style item value.
1036 function getStyle(node, name) {
1037 var styleVal = dom.getStyle(node, name);
1039 // Force the format to hex
1040 if (name == 'color' || name == 'backgroundColor')
1041 styleVal = dom.toHex(styleVal);
1043 // Opera will return bold as 700
1044 if (name == 'fontWeight' && styleVal == 700)
1047 return '' + styleVal;
1051 * Replaces variables in the value. The variable format is %var.
1054 * @param {String} value Value to replace variables in.
1055 * @param {Object} vars Name/value array with variables to replace.
1056 * @return {String} New value with replaced variables.
1058 function replaceVars(value, vars) {
1059 if (typeof(value) != "string")
1060 value = value(vars);
1062 value = value.replace(/%(\w+)/g, function(str, name) {
1063 return vars[name] || str;
1070 function isWhiteSpaceNode(node) {
1071 return node && node.nodeType === 3 && /^([\s\r\n]+|)$/.test(node.nodeValue);
1074 function wrap(node, name, attrs) {
1075 var wrapper = dom.create(name, attrs);
1077 node.parentNode.insertBefore(wrapper, node);
1078 wrapper.appendChild(node);
1084 * Expands the specified range like object to depending on format.
1086 * For example on block formats it will move the start/end position
1087 * to the beginning of the current block.
1090 * @param {Object} rng Range like object.
1091 * @param {Array} formats Array with formats to expand by.
1092 * @return {Object} Expanded range like object.
1094 function expandRng(rng, format, remove) {
1095 var startContainer = rng.startContainer,
1096 startOffset = rng.startOffset,
1097 endContainer = rng.endContainer,
1098 endOffset = rng.endOffset, sibling, lastIdx, leaf;
1100 // This function walks up the tree if there is no siblings before/after the node
1101 function findParentContainer(container, child_name, sibling_name, root) {
1104 root = root || dom.getRoot();
1107 // Check if we can move up are we at root level or body level
1108 parent = container.parentNode;
1110 // Stop expanding on block elements or root depending on format
1111 if (parent == root || (!format[0].block_expand && isBlock(parent)))
1114 for (sibling = parent[child_name]; sibling && sibling != container; sibling = sibling[sibling_name]) {
1115 if (sibling.nodeType == 1 && !isBookmarkNode(sibling))
1118 if (sibling.nodeType == 3 && !isWhiteSpaceNode(sibling))
1122 container = container.parentNode;
1128 // This function walks down the tree to find the leaf at the selection.
1129 // The offset is also returned as if node initially a leaf, the offset may be in the middle of the text node.
1130 function findLeaf(node, offset) {
1131 if (offset === undefined)
1132 offset = node.nodeType === 3 ? node.length : node.childNodes.length;
1133 while (node && node.hasChildNodes()) {
1134 node = node.childNodes[offset];
1136 offset = node.nodeType === 3 ? node.length : node.childNodes.length;
1138 return { node: node, offset: offset };
1141 // If index based start position then resolve it
1142 if (startContainer.nodeType == 1 && startContainer.hasChildNodes()) {
1143 lastIdx = startContainer.childNodes.length - 1;
1144 startContainer = startContainer.childNodes[startOffset > lastIdx ? lastIdx : startOffset];
1146 if (startContainer.nodeType == 3)
1150 // If index based end position then resolve it
1151 if (endContainer.nodeType == 1 && endContainer.hasChildNodes()) {
1152 lastIdx = endContainer.childNodes.length - 1;
1153 endContainer = endContainer.childNodes[endOffset > lastIdx ? lastIdx : endOffset - 1];
1155 if (endContainer.nodeType == 3)
1156 endOffset = endContainer.nodeValue.length;
1159 // Exclude bookmark nodes if possible
1160 if (isBookmarkNode(startContainer.parentNode))
1161 startContainer = startContainer.parentNode;
1163 if (isBookmarkNode(startContainer))
1164 startContainer = startContainer.nextSibling || startContainer;
1166 if (isBookmarkNode(endContainer.parentNode)) {
1167 endOffset = dom.nodeIndex(endContainer);
1168 endContainer = endContainer.parentNode;
1171 if (isBookmarkNode(endContainer) && endContainer.previousSibling) {
1172 endContainer = endContainer.previousSibling;
1173 endOffset = endContainer.length;
1176 if (format[0].inline) {
1177 // Avoid applying formatting to a trailing space.
1178 leaf = findLeaf(endContainer, endOffset);
1180 while (leaf.node && leaf.offset === 0 && leaf.node.previousSibling)
1181 leaf = findLeaf(leaf.node.previousSibling);
1183 if (leaf.node && leaf.offset > 0 && leaf.node.nodeType === 3 &&
1184 leaf.node.nodeValue.charAt(leaf.offset - 1) === ' ') {
1186 if (leaf.offset > 1) {
1187 endContainer = leaf.node;
1188 endContainer.splitText(leaf.offset - 1);
1189 } else if (leaf.node.previousSibling) {
1190 endContainer = leaf.node.previousSibling;
1196 // Move start/end point up the tree if the leaves are sharp and if we are in different containers
1197 // Example * becomes !: !<p><b><i>*text</i><i>text*</i></b></p>!
1198 // This will reduce the number of wrapper elements that needs to be created
1199 // Move start point up the tree
1200 if (format[0].inline || format[0].block_expand) {
1201 startContainer = findParentContainer(startContainer, 'firstChild', 'nextSibling');
1202 endContainer = findParentContainer(endContainer, 'lastChild', 'previousSibling');
1205 // Expand start/end container to matching selector
1206 if (format[0].selector && format[0].expand !== FALSE && !format[0].inline) {
1207 function findSelectorEndPoint(container, sibling_name) {
1208 var parents, i, y, curFormat;
1210 if (container.nodeType == 3 && container.nodeValue.length == 0 && container[sibling_name])
1211 container = container[sibling_name];
1213 parents = getParents(container);
1214 for (i = 0; i < parents.length; i++) {
1215 for (y = 0; y < format.length; y++) {
1216 curFormat = format[y];
1218 // If collapsed state is set then skip formats that doesn't match that
1219 if ("collapsed" in curFormat && curFormat.collapsed !== rng.collapsed)
1222 if (dom.is(parents[i], curFormat.selector))
1230 // Find new startContainer/endContainer if there is better one
1231 startContainer = findSelectorEndPoint(startContainer, 'previousSibling');
1232 endContainer = findSelectorEndPoint(endContainer, 'nextSibling');
1235 // Expand start/end container to matching block element or text node
1236 if (format[0].block || format[0].selector) {
1237 function findBlockEndPoint(container, sibling_name, sibling_name2) {
1240 // Expand to block of similar type
1241 if (!format[0].wrapper)
1242 node = dom.getParent(container, format[0].block);
1244 // Expand to first wrappable block element or any block element
1246 node = dom.getParent(container.nodeType == 3 ? container.parentNode : container, isBlock);
1248 // Exclude inner lists from wrapping
1249 if (node && format[0].wrapper)
1250 node = getParents(node, 'ul,ol').reverse()[0] || node;
1252 // Didn't find a block element look for first/last wrappable element
1256 while (node[sibling_name] && !isBlock(node[sibling_name])) {
1257 node = node[sibling_name];
1259 // Break on BR but include it will be removed later on
1260 // we can't remove it now since we need to check if it can be wrapped
1261 if (isEq(node, 'br'))
1266 return node || container;
1269 // Find new startContainer/endContainer if there is better one
1270 startContainer = findBlockEndPoint(startContainer, 'previousSibling');
1271 endContainer = findBlockEndPoint(endContainer, 'nextSibling');
1273 // Non block element then try to expand up the leaf
1274 if (format[0].block) {
1275 if (!isBlock(startContainer))
1276 startContainer = findParentContainer(startContainer, 'firstChild', 'nextSibling');
1278 if (!isBlock(endContainer))
1279 endContainer = findParentContainer(endContainer, 'lastChild', 'previousSibling');
1283 // Setup index for startContainer
1284 if (startContainer.nodeType == 1) {
1285 startOffset = nodeIndex(startContainer);
1286 startContainer = startContainer.parentNode;
1289 // Setup index for endContainer
1290 if (endContainer.nodeType == 1) {
1291 endOffset = nodeIndex(endContainer) + 1;
1292 endContainer = endContainer.parentNode;
1295 // Return new range like object
1297 startContainer : startContainer,
1298 startOffset : startOffset,
1299 endContainer : endContainer,
1300 endOffset : endOffset
1305 * Removes the specified format for the specified node. It will also remove the node if it doesn't have
1306 * any attributes if the format specifies it to do so.
1309 * @param {Object} format Format object with items to remove from node.
1310 * @param {Object} vars Name/value object with variables to apply to format.
1311 * @param {Node} node Node to remove the format styles on.
1312 * @param {Node} compare_node Optional compare node, if specified the styles will be compared to that node.
1313 * @return {Boolean} True/false if the node was removed or not.
1315 function removeFormat(format, vars, node, compare_node) {
1316 var i, attrs, stylesModified;
1318 // Check if node matches format
1319 if (!matchName(node, format))
1322 // Should we compare with format attribs and styles
1323 if (format.remove != 'all') {
1325 each(format.styles, function(value, name) {
1326 value = replaceVars(value, vars);
1329 if (typeof(name) === 'number') {
1334 if (!compare_node || isEq(getStyle(compare_node, name), value))
1335 dom.setStyle(node, name, '');
1340 // Remove style attribute if it's empty
1341 if (stylesModified && dom.getAttrib(node, 'style') == '') {
1342 node.removeAttribute('style');
1343 node.removeAttribute('data-mce-style');
1346 // Remove attributes
1347 each(format.attributes, function(value, name) {
1350 value = replaceVars(value, vars);
1353 if (typeof(name) === 'number') {
1358 if (!compare_node || isEq(dom.getAttrib(compare_node, name), value)) {
1359 // Keep internal classes
1360 if (name == 'class') {
1361 value = dom.getAttrib(node, name);
1363 // Build new class value where everything is removed except the internal prefixed classes
1365 each(value.split(/\s+/), function(cls) {
1366 if (/mce\w+/.test(cls))
1367 valueOut += (valueOut ? ' ' : '') + cls;
1370 // We got some internal classes left
1372 dom.setAttrib(node, name, valueOut);
1378 // IE6 has a bug where the attribute doesn't get removed correctly
1379 if (name == "class")
1380 node.removeAttribute('className');
1382 // Remove mce prefixed attributes
1383 if (MCE_ATTR_RE.test(name))
1384 node.removeAttribute('data-mce-' + name);
1386 node.removeAttribute(name);
1391 each(format.classes, function(value) {
1392 value = replaceVars(value, vars);
1394 if (!compare_node || dom.hasClass(compare_node, value))
1395 dom.removeClass(node, value);
1398 // Check for non internal attributes
1399 attrs = dom.getAttribs(node);
1400 for (i = 0; i < attrs.length; i++) {
1401 if (attrs[i].nodeName.indexOf('_') !== 0)
1406 // Remove the inline child if it's empty for example <b> or <span>
1407 if (format.remove != 'none') {
1408 removeNode(node, format);
1414 * Removes the node and wrap it's children in paragraphs before doing so or
1415 * appends BR elements to the beginning/end of the block element if forcedRootBlocks is disabled.
1417 * If the div in the node below gets removed:
1418 * text<div>text</div>text
1421 * text<div><br />text<br /></div>text
1423 * So when the div is removed the result is:
1424 * text<br />text<br />text
1427 * @param {Node} node Node to remove + apply BR/P elements to.
1428 * @param {Object} format Format rule.
1429 * @return {Node} Input node.
1431 function removeNode(node, format) {
1432 var parentNode = node.parentNode, rootBlockElm;
1435 if (!forcedRootBlock) {
1436 function find(node, next, inc) {
1437 node = getNonWhiteSpaceSibling(node, next, inc);
1439 return !node || (node.nodeName == 'BR' || isBlock(node));
1442 // Append BR elements if needed before we remove the block
1443 if (isBlock(node) && !isBlock(parentNode)) {
1444 if (!find(node, FALSE) && !find(node.firstChild, TRUE, 1))
1445 node.insertBefore(dom.create('br'), node.firstChild);
1447 if (!find(node, TRUE) && !find(node.lastChild, FALSE, 1))
1448 node.appendChild(dom.create('br'));
1451 // Wrap the block in a forcedRootBlock if we are at the root of document
1452 if (parentNode == dom.getRoot()) {
1453 if (!format.list_block || !isEq(node, format.list_block)) {
1454 each(tinymce.grep(node.childNodes), function(node) {
1455 if (isValid(forcedRootBlock, node.nodeName.toLowerCase())) {
1457 rootBlockElm = wrap(node, forcedRootBlock);
1459 rootBlockElm.appendChild(node);
1468 // Never remove nodes that isn't the specified inline element if a selector is specified too
1469 if (format.selector && format.inline && !isEq(format.inline, node))
1472 dom.remove(node, 1);
1476 * Returns the next/previous non whitespace node.
1479 * @param {Node} node Node to start at.
1480 * @param {boolean} next (Optional) Include next or previous node defaults to previous.
1481 * @param {boolean} inc (Optional) Include the current node in checking. Defaults to false.
1482 * @return {Node} Next or previous node or undefined if it wasn't found.
1484 function getNonWhiteSpaceSibling(node, next, inc) {
1486 next = next ? 'nextSibling' : 'previousSibling';
1488 for (node = inc ? node : node[next]; node; node = node[next]) {
1489 if (node.nodeType == 1 || !isWhiteSpaceNode(node))
1496 * Checks if the specified node is a bookmark node or not.
1498 * @param {Node} node Node to check if it's a bookmark node or not.
1499 * @return {Boolean} true/false if the node is a bookmark node.
1501 function isBookmarkNode(node) {
1502 return node && node.nodeType == 1 && node.getAttribute('data-mce-type') == 'bookmark';
1506 * Merges the next/previous sibling element if they match.
1509 * @param {Node} prev Previous node to compare/merge.
1510 * @param {Node} next Next node to compare/merge.
1511 * @return {Node} Next node if we didn't merge and prev node if we did.
1513 function mergeSiblings(prev, next) {
1514 var marker, sibling, tmpSibling;
1517 * Compares two nodes and checks if it's attributes and styles matches.
1518 * This doesn't compare classes as items since their order is significant.
1521 * @param {Node} node1 First node to compare with.
1522 * @param {Node} node2 Second node to compare with.
1523 * @return {boolean} True/false if the nodes are the same or not.
1525 function compareElements(node1, node2) {
1526 // Not the same name
1527 if (node1.nodeName != node2.nodeName)
1531 * Returns all the nodes attributes excluding internal ones, styles and classes.
1534 * @param {Node} node Node to get attributes from.
1535 * @return {Object} Name/value object with attributes and attribute values.
1537 function getAttribs(node) {
1540 each(dom.getAttribs(node), function(attr) {
1541 var name = attr.nodeName.toLowerCase();
1543 // Don't compare internal attributes or style
1544 if (name.indexOf('_') !== 0 && name !== 'style')
1545 attribs[name] = dom.getAttrib(node, name);
1552 * Compares two objects checks if it's key + value exists in the other one.
1555 * @param {Object} obj1 First object to compare.
1556 * @param {Object} obj2 Second object to compare.
1557 * @return {boolean} True/false if the objects matches or not.
1559 function compareObjects(obj1, obj2) {
1562 for (name in obj1) {
1563 // Obj1 has item obj2 doesn't have
1564 if (obj1.hasOwnProperty(name)) {
1567 // Obj2 doesn't have obj1 item
1568 if (value === undefined)
1571 // Obj2 item has a different value
1572 if (obj1[name] != value)
1575 // Delete similar value
1580 // Check if obj 2 has something obj 1 doesn't have
1581 for (name in obj2) {
1582 // Obj2 has item obj1 doesn't have
1583 if (obj2.hasOwnProperty(name))
1590 // Attribs are not the same
1591 if (!compareObjects(getAttribs(node1), getAttribs(node2)))
1594 // Styles are not the same
1595 if (!compareObjects(dom.parseStyle(dom.getAttrib(node1, 'style')), dom.parseStyle(dom.getAttrib(node2, 'style'))))
1601 // Check if next/prev exists and that they are elements
1603 function findElementSibling(node, sibling_name) {
1604 for (sibling = node; sibling; sibling = sibling[sibling_name]) {
1605 if (sibling.nodeType == 3 && sibling.nodeValue.length !== 0)
1608 if (sibling.nodeType == 1 && !isBookmarkNode(sibling))
1615 // If previous sibling is empty then jump over it
1616 prev = findElementSibling(prev, 'previousSibling');
1617 next = findElementSibling(next, 'nextSibling');
1619 // Compare next and previous nodes
1620 if (compareElements(prev, next)) {
1621 // Append nodes between
1622 for (sibling = prev.nextSibling; sibling && sibling != next;) {
1623 tmpSibling = sibling;
1624 sibling = sibling.nextSibling;
1625 prev.appendChild(tmpSibling);
1631 // Move children into prev node
1632 each(tinymce.grep(next.childNodes), function(node) {
1633 prev.appendChild(node);
1644 * Returns true/false if the specified node is a text block or not.
1647 * @param {Node} node Node to check.
1648 * @return {boolean} True/false if the node is a text block.
1650 function isTextBlock(name) {
1651 return /^(h[1-6]|p|div|pre|address|dl|dt|dd)$/.test(name);
1654 function getContainer(rng, start) {
1655 var container, offset, lastIdx;
1657 container = rng[start ? 'startContainer' : 'endContainer'];
1658 offset = rng[start ? 'startOffset' : 'endOffset'];
1660 if (container.nodeType == 1) {
1661 lastIdx = container.childNodes.length - 1;
1663 if (!start && offset)
1666 container = container.childNodes[offset > lastIdx ? lastIdx : offset];
1672 function performCaretAction(type, name, vars) {
1673 var i, currentPendingFormats = pendingFormats[type],
1674 otherPendingFormats = pendingFormats[type == 'apply' ? 'remove' : 'apply'];
1676 function hasPending() {
1677 return pendingFormats.apply.length || pendingFormats.remove.length;
1680 function resetPending() {
1681 pendingFormats.apply = [];
1682 pendingFormats.remove = [];
1685 function perform(caret_node) {
1686 // Apply pending formats
1687 each(pendingFormats.apply.reverse(), function(item) {
1688 apply(item.name, item.vars, caret_node);
1690 // Colored nodes should be underlined so that the color of the underline matches the text color.
1691 if (item.name === 'forecolor' && item.vars.value)
1692 processUnderlineAndColor(caret_node.parentNode);
1695 // Remove pending formats
1696 each(pendingFormats.remove.reverse(), function(item) {
1697 remove(item.name, item.vars, caret_node);
1700 dom.remove(caret_node, 1);
1704 // Check if it already exists then ignore it
1705 for (i = currentPendingFormats.length - 1; i >= 0; i--) {
1706 if (currentPendingFormats[i].name == name)
1710 currentPendingFormats.push({name : name, vars : vars});
1712 // Check if it's in the other type, then remove it
1713 for (i = otherPendingFormats.length - 1; i >= 0; i--) {
1714 if (otherPendingFormats[i].name == name)
1715 otherPendingFormats.splice(i, 1);
1718 // Pending apply or remove formats
1720 ed.getDoc().execCommand('FontName', false, 'mceinline');
1721 pendingFormats.lastRng = selection.getRng();
1723 // IE will convert the current word
1724 each(dom.select('font,span'), function(node) {
1727 if (isCaretNode(node)) {
1728 bookmark = selection.getBookmark();
1730 selection.moveToBookmark(bookmark);
1735 // Only register listeners once if we need to
1736 if (!pendingFormats.isListening && hasPending()) {
1737 pendingFormats.isListening = true;
1738 function performPendingFormat(node, textNode) {
1739 var rng = dom.createRng();
1742 rng.setStart(textNode, textNode.nodeValue.length);
1743 rng.setEnd(textNode, textNode.nodeValue.length);
1744 selection.setRng(rng);
1747 var enterKeyPressed = false;
1749 each('onKeyDown,onKeyUp,onKeyPress,onMouseUp'.split(','), function(event) {
1750 ed[event].addToTop(function(ed, e) {
1751 if (e.keyCode==13 && !e.shiftKey) {
1752 enterKeyPressed = true;
1755 // Do we have pending formats and is the selection moved has moved
1756 if (hasPending() && !tinymce.dom.RangeUtils.compareRanges(pendingFormats.lastRng, selection.getRng())) {
1757 var foundCaret = false;
1758 each(dom.select('font,span'), function(node) {
1762 if (isCaretNode(node)) {
1764 textNode = node.firstChild;
1766 // Find the first text node within node
1767 while (textNode && textNode.nodeType != 3)
1768 textNode = textNode.firstChild;
1771 performPendingFormat(node, textNode);
1777 // no caret - so we are
1778 if (enterKeyPressed && !foundCaret) {
1779 var node = selection.getNode();
1780 var textNode = node;
1782 // Find the first text node within node
1783 while (textNode && textNode.nodeType != 3)
1784 textNode = textNode.firstChild;
1786 node=textNode.parentNode;
1787 while (!isBlock(node)){
1788 node=node.parentNode;
1790 performPendingFormat(node, textNode);
1794 // Always unbind and clear pending styles on keyup
1795 if (e.type == 'keyup' || e.type == 'mouseup') {
1797 enterKeyPressed=false;