2 * Inspired by the rangy CSS Applier module written by Tim Down and licensed under the MIT license.
3 * http://code.google.com/p/rangy/
5 * changed in order to be able ...
7 * - to detect and replace similar css classes via reg exp
9 (function(wysihtml5, rangy) {
10 var defaultTagName = "span";
12 var REG_EXP_WHITE_SPACE = /\s+/g;
14 function hasClass(el, cssClass, regExp) {
19 var matchingClassNames = el.className.match(regExp) || [];
20 return matchingClassNames[matchingClassNames.length - 1] === cssClass;
23 function hasStyleAttr(el, regExp) {
24 if (!el.getAttribute || !el.getAttribute('style')) {
27 var matchingStyles = el.getAttribute('style').match(regExp);
28 return (el.getAttribute('style').match(regExp)) ? true : false;
31 function addStyle(el, cssStyle, regExp) {
32 if (el.getAttribute('style')) {
33 removeStyle(el, regExp);
34 if (el.getAttribute('style') && !(/^\s*$/).test(el.getAttribute('style'))) {
35 el.setAttribute('style', cssStyle + ";" + el.getAttribute('style'));
37 el.setAttribute('style', cssStyle);
40 el.setAttribute('style', cssStyle);
44 function addClass(el, cssClass, regExp) {
46 removeClass(el, regExp);
47 el.className += " " + cssClass;
49 el.className = cssClass;
53 function removeClass(el, regExp) {
55 el.className = el.className.replace(regExp, "");
59 function removeStyle(el, regExp) {
62 if (el.getAttribute('style')) {
63 s = el.getAttribute('style').split(';');
64 for (var i = s.length; i--;) {
65 if (!s[i].match(regExp) && !(/^\s*$/).test(s[i])) {
70 el.setAttribute('style', s2.join(';'));
72 el.removeAttribute('style');
77 function getMatchingStyleRegexp(el, style) {
79 sSplit = style.split(';'),
80 elStyle = el.getAttribute('style');
83 elStyle = elStyle.replace(/\s/gi, '').toLowerCase();
84 regexes.push(new RegExp("(^|\\s|;)" + style.replace(/\s/gi, '').replace(/([\(\)])/gi, "\\$1").toLowerCase().replace(";", ";?").replace(/rgb\\\((\d+),(\d+),(\d+)\\\)/gi, "\\s?rgb\\($1,\\s?$2,\\s?$3\\)"), "gi"));
86 for (var i = sSplit.length; i-- > 0;) {
87 if (!(/^\s*$/).test(sSplit[i])) {
88 regexes.push(new RegExp("(^|\\s|;)" + sSplit[i].replace(/\s/gi, '').replace(/([\(\)])/gi, "\\$1").toLowerCase().replace(";", ";?").replace(/rgb\\\((\d+),(\d+),(\d+)\\\)/gi, "\\s?rgb\\($1,\\s?$2,\\s?$3\\)"), "gi"));
91 for (var j = 0, jmax = regexes.length; j < jmax; j++) {
92 if (elStyle.match(regexes[j])) {
101 function isMatchingAllready(node, tags, style, className) {
103 return getMatchingStyleRegexp(node, style);
104 } else if (className) {
105 return wysihtml5.dom.hasClass(node, className);
107 return rangy.dom.arrayContains(tags, node.tagName.toLowerCase());
111 function areMatchingAllready(nodes, tags, style, className) {
112 for (var i = nodes.length; i--;) {
113 if (!isMatchingAllready(nodes[i], tags, style, className)) {
117 return nodes.length ? true : false;
120 function removeOrChangeStyle(el, style, regExp) {
122 var exactRegex = getMatchingStyleRegexp(el, style);
124 // adding same style value on property again removes style
125 removeStyle(el, exactRegex);
128 // adding new style value changes value
129 addStyle(el, style, regExp);
134 function hasSameClasses(el1, el2) {
135 return el1.className.replace(REG_EXP_WHITE_SPACE, " ") == el2.className.replace(REG_EXP_WHITE_SPACE, " ");
138 function replaceWithOwnChildren(el) {
139 var parent = el.parentNode;
140 while (el.firstChild) {
141 parent.insertBefore(el.firstChild, el);
143 parent.removeChild(el);
146 function elementsHaveSameNonClassAttributes(el1, el2) {
147 if (el1.attributes.length != el2.attributes.length) {
150 for (var i = 0, len = el1.attributes.length, attr1, attr2, name; i < len; ++i) {
151 attr1 = el1.attributes[i];
153 if (name != "class") {
154 attr2 = el2.attributes.getNamedItem(name);
155 if (attr1.specified != attr2.specified) {
158 if (attr1.specified && attr1.nodeValue !== attr2.nodeValue) {
166 function isSplitPoint(node, offset) {
167 if (rangy.dom.isCharacterDataNode(node)) {
169 return !!node.previousSibling;
170 } else if (offset == node.length) {
171 return !!node.nextSibling;
177 return offset > 0 && offset < node.childNodes.length;
180 function splitNodeAt(node, descendantNode, descendantOffset, container) {
182 if (rangy.dom.isCharacterDataNode(descendantNode)) {
183 if (descendantOffset == 0) {
184 descendantOffset = rangy.dom.getNodeIndex(descendantNode);
185 descendantNode = descendantNode.parentNode;
186 } else if (descendantOffset == descendantNode.length) {
187 descendantOffset = rangy.dom.getNodeIndex(descendantNode) + 1;
188 descendantNode = descendantNode.parentNode;
190 newNode = rangy.dom.splitDataNode(descendantNode, descendantOffset);
194 if (!container || descendantNode !== container) {
196 newNode = descendantNode.cloneNode(false);
198 newNode.removeAttribute("id");
201 while ((child = descendantNode.childNodes[descendantOffset])) {
202 newNode.appendChild(child);
204 rangy.dom.insertAfter(newNode, descendantNode);
208 return (descendantNode == node) ? newNode : splitNodeAt(node, newNode.parentNode, rangy.dom.getNodeIndex(newNode), container);
211 function Merge(firstNode) {
212 this.isElementMerge = (firstNode.nodeType == wysihtml5.ELEMENT_NODE);
213 this.firstTextNode = this.isElementMerge ? firstNode.lastChild : firstNode;
214 this.textNodes = [this.firstTextNode];
218 doMerge: function() {
219 var textBits = [], textNode, parent, text;
220 for (var i = 0, len = this.textNodes.length; i < len; ++i) {
221 textNode = this.textNodes[i];
222 parent = textNode.parentNode;
223 textBits[i] = textNode.data;
225 parent.removeChild(textNode);
226 if (!parent.hasChildNodes()) {
227 parent.parentNode.removeChild(parent);
231 this.firstTextNode.data = text = textBits.join("");
235 getLength: function() {
236 var i = this.textNodes.length, len = 0;
238 len += this.textNodes[i].length;
243 toString: function() {
245 for (var i = 0, len = this.textNodes.length; i < len; ++i) {
246 textBits[i] = "'" + this.textNodes[i].data + "'";
248 return "[Merge(" + textBits.join(",") + ")]";
252 function HTMLApplier(tagNames, cssClass, similarClassRegExp, normalize, cssStyle, similarStyleRegExp, container) {
253 this.tagNames = tagNames || [defaultTagName];
254 this.cssClass = cssClass || ((cssClass === false) ? false : "");
255 this.similarClassRegExp = similarClassRegExp;
256 this.cssStyle = cssStyle || "";
257 this.similarStyleRegExp = similarStyleRegExp;
258 this.normalize = normalize;
259 this.applyToAnyTagName = false;
260 this.container = container;
263 HTMLApplier.prototype = {
264 getAncestorWithClass: function(node) {
267 cssClassMatch = this.cssClass ? hasClass(node, this.cssClass, this.similarClassRegExp) : (this.cssStyle !== "") ? false : true;
268 if (node.nodeType == wysihtml5.ELEMENT_NODE && node.getAttribute("contenteditable") != "false" && rangy.dom.arrayContains(this.tagNames, node.tagName.toLowerCase()) && cssClassMatch) {
271 node = node.parentNode;
276 // returns parents of node with given style attribute
277 getAncestorWithStyle: function(node) {
280 cssStyleMatch = this.cssStyle ? hasStyleAttr(node, this.similarStyleRegExp) : false;
282 if (node.nodeType == wysihtml5.ELEMENT_NODE && node.getAttribute("contenteditable") != "false" && rangy.dom.arrayContains(this.tagNames, node.tagName.toLowerCase()) && cssStyleMatch) {
285 node = node.parentNode;
290 getMatchingAncestor: function(node) {
291 var ancestor = this.getAncestorWithClass(node),
295 ancestor = this.getAncestorWithStyle(node);
311 // Normalizes nodes after applying a CSS class to a Range.
312 postApply: function(textNodes, range) {
313 var firstNode = textNodes[0], lastNode = textNodes[textNodes.length - 1];
315 var merges = [], currentMerge;
317 var rangeStartNode = firstNode, rangeEndNode = lastNode;
318 var rangeStartOffset = 0, rangeEndOffset = lastNode.length;
320 var textNode, precedingTextNode;
322 for (var i = 0, len = textNodes.length; i < len; ++i) {
323 textNode = textNodes[i];
324 precedingTextNode = null;
325 if (textNode && textNode.parentNode) {
326 precedingTextNode = this.getAdjacentMergeableTextNode(textNode.parentNode, false);
328 if (precedingTextNode) {
330 currentMerge = new Merge(precedingTextNode);
331 merges.push(currentMerge);
333 currentMerge.textNodes.push(textNode);
334 if (textNode === firstNode) {
335 rangeStartNode = currentMerge.firstTextNode;
336 rangeStartOffset = rangeStartNode.length;
338 if (textNode === lastNode) {
339 rangeEndNode = currentMerge.firstTextNode;
340 rangeEndOffset = currentMerge.getLength();
346 // Test whether the first node after the range needs merging
347 if(lastNode && lastNode.parentNode) {
348 var nextTextNode = this.getAdjacentMergeableTextNode(lastNode.parentNode, true);
351 currentMerge = new Merge(lastNode);
352 merges.push(currentMerge);
354 currentMerge.textNodes.push(nextTextNode);
359 for (i = 0, len = merges.length; i < len; ++i) {
362 // Set the range boundaries
363 range.setStart(rangeStartNode, rangeStartOffset);
364 range.setEnd(rangeEndNode, rangeEndOffset);
368 getAdjacentMergeableTextNode: function(node, forward) {
369 var isTextNode = (node.nodeType == wysihtml5.TEXT_NODE);
370 var el = isTextNode ? node.parentNode : node;
372 var propName = forward ? "nextSibling" : "previousSibling";
374 // Can merge if the node's previous/next sibling is a text node
375 adjacentNode = node[propName];
376 if (adjacentNode && adjacentNode.nodeType == wysihtml5.TEXT_NODE) {
380 // Compare element with its sibling
381 adjacentNode = el[propName];
382 if (adjacentNode && this.areElementsMergeable(node, adjacentNode)) {
383 return adjacentNode[forward ? "firstChild" : "lastChild"];
389 areElementsMergeable: function(el1, el2) {
390 return rangy.dom.arrayContains(this.tagNames, (el1.tagName || "").toLowerCase())
391 && rangy.dom.arrayContains(this.tagNames, (el2.tagName || "").toLowerCase())
392 && hasSameClasses(el1, el2)
393 && elementsHaveSameNonClassAttributes(el1, el2);
396 createContainer: function(doc) {
397 var el = doc.createElement(this.tagNames[0]);
399 el.className = this.cssClass;
402 el.setAttribute('style', this.cssStyle);
407 applyToTextNode: function(textNode) {
408 var parent = textNode.parentNode;
409 if (parent.childNodes.length == 1 && rangy.dom.arrayContains(this.tagNames, parent.tagName.toLowerCase())) {
412 addClass(parent, this.cssClass, this.similarClassRegExp);
415 addStyle(parent, this.cssStyle, this.similarStyleRegExp);
418 var el = this.createContainer(rangy.dom.getDocument(textNode));
419 textNode.parentNode.insertBefore(el, textNode);
420 el.appendChild(textNode);
424 isRemovable: function(el) {
425 return rangy.dom.arrayContains(this.tagNames, el.tagName.toLowerCase()) &&
426 wysihtml5.lang.string(el.className).trim() === "" &&
428 !el.getAttribute('style') ||
429 wysihtml5.lang.string(el.getAttribute('style')).trim() === ""
433 undoToTextNode: function(textNode, range, ancestorWithClass, ancestorWithStyle) {
434 var styleMode = (ancestorWithClass) ? false : true,
435 ancestor = ancestorWithClass || ancestorWithStyle,
436 styleChanged = false;
437 if (!range.containsNode(ancestor)) {
438 // Split out the portion of the ancestor from which we can remove the CSS class
439 var ancestorRange = range.cloneRange();
440 ancestorRange.selectNode(ancestor);
442 if (ancestorRange.isPointInRange(range.endContainer, range.endOffset) && isSplitPoint(range.endContainer, range.endOffset)) {
443 splitNodeAt(ancestor, range.endContainer, range.endOffset, this.container);
444 range.setEndAfter(ancestor);
446 if (ancestorRange.isPointInRange(range.startContainer, range.startOffset) && isSplitPoint(range.startContainer, range.startOffset)) {
447 ancestor = splitNodeAt(ancestor, range.startContainer, range.startOffset, this.container);
451 if (!styleMode && this.similarClassRegExp) {
452 removeClass(ancestor, this.similarClassRegExp);
455 if (styleMode && this.similarStyleRegExp) {
456 styleChanged = (removeOrChangeStyle(ancestor, this.cssStyle, this.similarStyleRegExp) === "change");
458 if (this.isRemovable(ancestor) && !styleChanged) {
459 replaceWithOwnChildren(ancestor);
463 applyToRange: function(range) {
465 for (var ri = range.length; ri--;) {
466 textNodes = range[ri].getNodes([wysihtml5.TEXT_NODE]);
468 if (!textNodes.length) {
470 var node = this.createContainer(range[ri].endContainer.ownerDocument);
471 range[ri].surroundContents(node);
472 this.selectNode(range[ri], node);
477 range[ri].splitBoundaries();
478 textNodes = range[ri].getNodes([wysihtml5.TEXT_NODE]);
479 if (textNodes.length) {
482 for (var i = 0, len = textNodes.length; i < len; ++i) {
483 textNode = textNodes[i];
484 if (!this.getMatchingAncestor(textNode).element) {
485 this.applyToTextNode(textNode);
489 range[ri].setStart(textNodes[0], 0);
490 textNode = textNodes[textNodes.length - 1];
491 range[ri].setEnd(textNode, textNode.length);
493 if (this.normalize) {
494 this.postApply(textNodes, range[ri]);
501 undoToRange: function(range) {
502 var textNodes, textNode, ancestorWithClass, ancestorWithStyle, ancestor;
503 for (var ri = range.length; ri--;) {
505 textNodes = range[ri].getNodes([wysihtml5.TEXT_NODE]);
506 if (textNodes.length) {
507 range[ri].splitBoundaries();
508 textNodes = range[ri].getNodes([wysihtml5.TEXT_NODE]);
510 var doc = range[ri].endContainer.ownerDocument,
511 node = doc.createTextNode(wysihtml5.INVISIBLE_SPACE);
512 range[ri].insertNode(node);
513 range[ri].selectNode(node);
517 for (var i = 0, len = textNodes.length; i < len; ++i) {
518 if (range[ri].isValid()) {
519 textNode = textNodes[i];
521 ancestor = this.getMatchingAncestor(textNode);
522 if (ancestor.type === "style") {
523 this.undoToTextNode(textNode, range[ri], false, ancestor.element);
524 } else if (ancestor.element) {
525 this.undoToTextNode(textNode, range[ri], ancestor.element);
531 this.selectNode(range[ri], textNodes[0]);
533 range[ri].setStart(textNodes[0], 0);
534 textNode = textNodes[textNodes.length - 1];
535 range[ri].setEnd(textNode, textNode.length);
537 if (this.normalize) {
538 this.postApply(textNodes, range[ri]);
545 selectNode: function(range, node) {
546 var isElement = node.nodeType === wysihtml5.ELEMENT_NODE,
547 canHaveHTML = "canHaveHTML" in node ? node.canHaveHTML : true,
548 content = isElement ? node.innerHTML : node.data,
549 isEmpty = (content === "" || content === wysihtml5.INVISIBLE_SPACE);
551 if (isEmpty && isElement && canHaveHTML) {
552 // Make sure that caret is visible in node by inserting a zero width no breaking space
553 try { node.innerHTML = wysihtml5.INVISIBLE_SPACE; } catch(e) {}
555 range.selectNodeContents(node);
556 if (isEmpty && isElement) {
557 range.collapse(false);
558 } else if (isEmpty) {
559 range.setStartAfter(node);
560 range.setEndAfter(node);
564 getTextSelectedByRange: function(textNode, range) {
565 var textRange = range.cloneRange();
566 textRange.selectNodeContents(textNode);
568 var intersectionRange = textRange.intersection(range);
569 var text = intersectionRange ? intersectionRange.toString() : "";
575 isAppliedToRange: function(range) {
577 appliedType = "full",
578 ancestor, styleAncestor, textNodes;
580 for (var ri = range.length; ri--;) {
582 textNodes = range[ri].getNodes([wysihtml5.TEXT_NODE]);
583 if (!textNodes.length) {
584 ancestor = this.getMatchingAncestor(range[ri].startContainer).element;
586 return (ancestor) ? {
587 "elements": [ancestor],
588 "coverage": appliedType
592 for (var i = 0, len = textNodes.length, selectedText; i < len; ++i) {
593 selectedText = this.getTextSelectedByRange(textNodes[i], range[ri]);
594 ancestor = this.getMatchingAncestor(textNodes[i]).element;
595 if (ancestor && selectedText != "") {
596 ancestors.push(ancestor);
598 if (wysihtml5.dom.getTextNodes(ancestor, true).length === 1) {
599 appliedType = "full";
600 } else if (appliedType === "full") {
601 appliedType = "inline";
603 } else if (!ancestor) {
604 appliedType = "partial";
610 return (ancestors.length) ? {
611 "elements": ancestors,
612 "coverage": appliedType
616 toggleRange: function(range) {
617 var isApplied = this.isAppliedToRange(range),
621 if (isApplied.coverage === "full") {
622 this.undoToRange(range);
623 } else if (isApplied.coverage === "inline") {
624 parentsExactMatch = areMatchingAllready(isApplied.elements, this.tagNames, this.cssStyle, this.cssClass);
625 this.undoToRange(range);
626 if (!parentsExactMatch) {
627 this.applyToRange(range);
631 if (!areMatchingAllready(isApplied.elements, this.tagNames, this.cssStyle, this.cssClass)) {
632 this.undoToRange(range);
634 this.applyToRange(range);
637 this.applyToRange(range);
642 wysihtml5.selection.HTMLApplier = HTMLApplier;
644 })(wysihtml5, rangy);