mediawiki-extensions-Visual.../contentEditable/rangy/rangy-cssclassapplier.js
2012-01-25 18:54:12 +00:00

714 lines
26 KiB
JavaScript

/**
* @license CSS Class Applier module for Rangy.
* Adds, removes and toggles CSS classes on Ranges and Selections
*
* Part of Rangy, a cross-browser JavaScript range and selection library
* http://code.google.com/p/rangy/
*
* Depends on Rangy core.
*
* Copyright 2011, Tim Down
* Licensed under the MIT license.
* Version: 1.2.2
* Build date: 13 November 2011
*/
rangy.createModule("CssClassApplier", function(api, module) {
api.requireModules( ["WrappedSelection", "WrappedRange"] );
var dom = api.dom;
var defaultTagName = "span";
function trim(str) {
return str.replace(/^\s\s*/, "").replace(/\s\s*$/, "");
}
function hasClass(el, cssClass) {
return el.className && new RegExp("(?:^|\\s)" + cssClass + "(?:\\s|$)").test(el.className);
}
function addClass(el, cssClass) {
if (el.className) {
if (!hasClass(el, cssClass)) {
el.className += " " + cssClass;
}
} else {
el.className = cssClass;
}
}
var removeClass = (function() {
function replacer(matched, whiteSpaceBefore, whiteSpaceAfter) {
return (whiteSpaceBefore && whiteSpaceAfter) ? " " : "";
}
return function(el, cssClass) {
if (el.className) {
el.className = el.className.replace(new RegExp("(?:^|\\s)" + cssClass + "(?:\\s|$)"), replacer);
}
};
})();
function sortClassName(className) {
return className.split(/\s+/).sort().join(" ");
}
function getSortedClassName(el) {
return sortClassName(el.className);
}
function haveSameClasses(el1, el2) {
return getSortedClassName(el1) == getSortedClassName(el2);
}
function replaceWithOwnChildren(el) {
var parent = el.parentNode;
while (el.hasChildNodes()) {
parent.insertBefore(el.firstChild, el);
}
parent.removeChild(el);
}
function rangeSelectsAnyText(range, textNode) {
var textRange = range.cloneRange();
textRange.selectNodeContents(textNode);
var intersectionRange = textRange.intersection(range);
var text = intersectionRange ? intersectionRange.toString() : "";
textRange.detach();
return text != "";
}
function getEffectiveTextNodes(range) {
return range.getNodes([3], function(textNode) {
return rangeSelectsAnyText(range, textNode);
});
}
function elementsHaveSameNonClassAttributes(el1, el2) {
if (el1.attributes.length != el2.attributes.length) return false;
for (var i = 0, len = el1.attributes.length, attr1, attr2, name; i < len; ++i) {
attr1 = el1.attributes[i];
name = attr1.name;
if (name != "class") {
attr2 = el2.attributes.getNamedItem(name);
if (attr1.specified != attr2.specified) return false;
if (attr1.specified && attr1.nodeValue !== attr2.nodeValue) return false;
}
}
return true;
}
function elementHasNonClassAttributes(el, exceptions) {
for (var i = 0, len = el.attributes.length, attrName; i < len; ++i) {
attrName = el.attributes[i].name;
if ( !(exceptions && dom.arrayContains(exceptions, attrName)) && el.attributes[i].specified && attrName != "class") {
return true;
}
}
return false;
}
function elementHasProps(el, props) {
for (var p in props) {
if (props.hasOwnProperty(p) && el[p] !== props[p]) {
return false;
}
}
return true;
}
var getComputedStyleProperty;
if (typeof window.getComputedStyle != "undefined") {
getComputedStyleProperty = function(el, propName) {
return dom.getWindow(el).getComputedStyle(el, null)[propName];
};
} else if (typeof document.documentElement.currentStyle != "undefined") {
getComputedStyleProperty = function(el, propName) {
return el.currentStyle[propName];
};
} else {
module.fail("No means of obtaining computed style properties found");
}
var isEditableElement;
(function() {
var testEl = document.createElement("div");
if (typeof testEl.isContentEditable == "boolean") {
isEditableElement = function(node) {
return node && node.nodeType == 1 && node.isContentEditable;
};
} else {
isEditableElement = function(node) {
if (!node || node.nodeType != 1 || node.contentEditable == "false") {
return false;
}
return node.contentEditable == "true" || isEditableElement(node.parentNode);
};
}
})();
function isEditingHost(node) {
var parent;
return node && node.nodeType == 1
&& (( (parent = node.parentNode) && parent.nodeType == 9 && parent.designMode == "on")
|| (isEditableElement(node) && !isEditableElement(node.parentNode)));
}
function isEditable(node) {
return (isEditableElement(node) || (node.nodeType != 1 && isEditableElement(node.parentNode))) && !isEditingHost(node);
}
var inlineDisplayRegex = /^inline(-block|-table)?$/i;
function isNonInlineElement(node) {
return node && node.nodeType == 1 && !inlineDisplayRegex.test(getComputedStyleProperty(node, "display"));
}
// White space characters as defined by HTML 4 (http://www.w3.org/TR/html401/struct/text.html)
var htmlNonWhiteSpaceRegex = /[^\r\n\t\f \u200B]/;
function isUnrenderedWhiteSpaceNode(node) {
if (node.data.length == 0) {
return true;
}
if (htmlNonWhiteSpaceRegex.test(node.data)) {
return false;
}
var cssWhiteSpace = getComputedStyleProperty(node.parentNode, "whiteSpace");
switch (cssWhiteSpace) {
case "pre":
case "pre-wrap":
case "-moz-pre-wrap":
return false;
case "pre-line":
if (/[\r\n]/.test(node.data)) {
return false;
}
}
// We now have a whitespace-only text node that may be rendered depending on its context. If it is adjacent to a
// non-inline element, it will not be rendered. This seems to be a good enough definition.
return isNonInlineElement(node.previousSibling) || isNonInlineElement(node.nextSibling);
}
function isSplitPoint(node, offset) {
if (dom.isCharacterDataNode(node)) {
if (offset == 0) {
return !!node.previousSibling;
} else if (offset == node.length) {
return !!node.nextSibling;
} else {
return true;
}
}
return offset > 0 && offset < node.childNodes.length;
}
function splitNodeAt(node, descendantNode, descendantOffset, rangesToPreserve) {
var newNode;
var splitAtStart = (descendantOffset == 0);
if (dom.isAncestorOf(descendantNode, node)) {
return node;
}
if (dom.isCharacterDataNode(descendantNode)) {
if (descendantOffset == 0) {
descendantOffset = dom.getNodeIndex(descendantNode);
descendantNode = descendantNode.parentNode;
} else if (descendantOffset == descendantNode.length) {
descendantOffset = dom.getNodeIndex(descendantNode) + 1;
descendantNode = descendantNode.parentNode;
} else {
throw module.createError("splitNodeAt should not be called with offset in the middle of a data node ("
+ descendantOffset + " in " + descendantNode.data);
}
}
if (isSplitPoint(descendantNode, descendantOffset)) {
if (!newNode) {
newNode = descendantNode.cloneNode(false);
if (newNode.id) {
newNode.removeAttribute("id");
}
var child;
while ((child = descendantNode.childNodes[descendantOffset])) {
newNode.appendChild(child);
}
dom.insertAfter(newNode, descendantNode);
}
return (descendantNode == node) ? newNode : splitNodeAt(node, newNode.parentNode, dom.getNodeIndex(newNode), rangesToPreserve);
} else if (node != descendantNode) {
newNode = descendantNode.parentNode;
// Work out a new split point in the parent node
var newNodeIndex = dom.getNodeIndex(descendantNode);
if (!splitAtStart) {
newNodeIndex++;
}
return splitNodeAt(node, newNode, newNodeIndex, rangesToPreserve);
}
return node;
}
function areElementsMergeable(el1, el2) {
return el1.tagName == el2.tagName && haveSameClasses(el1, el2) && elementsHaveSameNonClassAttributes(el1, el2);
}
function createAdjacentMergeableTextNodeGetter(forward) {
var propName = forward ? "nextSibling" : "previousSibling";
return function(textNode, checkParentElement) {
var el = textNode.parentNode;
var adjacentNode = textNode[propName];
if (adjacentNode) {
// Can merge if the node's previous/next sibling is a text node
if (adjacentNode && adjacentNode.nodeType == 3) {
return adjacentNode;
}
} else if (checkParentElement) {
// Compare text node parent element with its sibling
adjacentNode = el[propName];
if (adjacentNode && adjacentNode.nodeType == 1 && areElementsMergeable(el, adjacentNode)) {
return adjacentNode[forward ? "firstChild" : "lastChild"];
}
}
return null;
}
}
var getPreviousMergeableTextNode = createAdjacentMergeableTextNodeGetter(false),
getNextMergeableTextNode = createAdjacentMergeableTextNodeGetter(true);
function Merge(firstNode) {
this.isElementMerge = (firstNode.nodeType == 1);
this.firstTextNode = this.isElementMerge ? firstNode.lastChild : firstNode;
this.textNodes = [this.firstTextNode];
}
Merge.prototype = {
doMerge: function() {
var textBits = [], textNode, parent, text;
for (var i = 0, len = this.textNodes.length; i < len; ++i) {
textNode = this.textNodes[i];
parent = textNode.parentNode;
textBits[i] = textNode.data;
if (i) {
parent.removeChild(textNode);
if (!parent.hasChildNodes()) {
parent.parentNode.removeChild(parent);
}
}
}
this.firstTextNode.data = text = textBits.join("");
return text;
},
getLength: function() {
var i = this.textNodes.length, len = 0;
while (i--) {
len += this.textNodes[i].length;
}
return len;
},
toString: function() {
var textBits = [];
for (var i = 0, len = this.textNodes.length; i < len; ++i) {
textBits[i] = "'" + this.textNodes[i].data + "'";
}
return "[Merge(" + textBits.join(",") + ")]";
}
};
var optionProperties = ["elementTagName", "ignoreWhiteSpace", "applyToEditableOnly"];
// Allow "class" as a property name in object properties
var mappedPropertyNames = {"class" : "className"};
function CssClassApplier(cssClass, options, tagNames) {
this.cssClass = cssClass;
var normalize, i, len, propName;
var elementPropertiesFromOptions = null;
// Initialize from options object
if (typeof options == "object" && options !== null) {
tagNames = options.tagNames;
elementPropertiesFromOptions = options.elementProperties;
for (i = 0; propName = optionProperties[i++]; ) {
if (options.hasOwnProperty(propName)) {
this[propName] = options[propName];
}
}
normalize = options.normalize;
} else {
normalize = options;
}
// Backwards compatibility: the second parameter can also be a Boolean indicating whether normalization
this.normalize = (typeof normalize == "undefined") ? true : normalize;
// Initialize element properties and attribute exceptions
this.attrExceptions = [];
var el = document.createElement(this.elementTagName);
this.elementProperties = {};
for (var p in elementPropertiesFromOptions) {
if (elementPropertiesFromOptions.hasOwnProperty(p)) {
// Map "class" to "className"
if (mappedPropertyNames.hasOwnProperty(p)) {
p = mappedPropertyNames[p];
}
el[p] = elementPropertiesFromOptions[p];
// Copy the property back from the dummy element so that later comparisons to check whether elements
// may be removed are checking against the right value. For example, the href property of an element
// returns a fully qualified URL even if it was previously assigned a relative URL.
this.elementProperties[p] = el[p];
this.attrExceptions.push(p);
}
}
this.elementSortedClassName = this.elementProperties.hasOwnProperty("className") ?
sortClassName(this.elementProperties.className + " " + cssClass) : cssClass;
// Initialize tag names
this.applyToAnyTagName = false;
var type = typeof tagNames;
if (type == "string") {
if (tagNames == "*") {
this.applyToAnyTagName = true;
} else {
this.tagNames = trim(tagNames.toLowerCase()).split(/\s*,\s*/);
}
} else if (type == "object" && typeof tagNames.length == "number") {
this.tagNames = [];
for (i = 0, len = tagNames.length; i < len; ++i) {
if (tagNames[i] == "*") {
this.applyToAnyTagName = true;
} else {
this.tagNames.push(tagNames[i].toLowerCase());
}
}
} else {
this.tagNames = [this.elementTagName];
}
}
CssClassApplier.prototype = {
elementTagName: defaultTagName,
elementProperties: {},
ignoreWhiteSpace: true,
applyToEditableOnly: false,
hasClass: function(node) {
return node.nodeType == 1 && dom.arrayContains(this.tagNames, node.tagName.toLowerCase()) && hasClass(node, this.cssClass);
},
getSelfOrAncestorWithClass: function(node) {
while (node) {
if (this.hasClass(node, this.cssClass)) {
return node;
}
node = node.parentNode;
}
return null;
},
isModifiable: function(node) {
return !this.applyToEditableOnly || isEditable(node);
},
// White space adjacent to an unwrappable node can be ignored for wrapping
isIgnorableWhiteSpaceNode: function(node) {
return this.ignoreWhiteSpace && node && node.nodeType == 3 && isUnrenderedWhiteSpaceNode(node);
},
// Normalizes nodes after applying a CSS class to a Range.
postApply: function(textNodes, range, isUndo) {
var firstNode = textNodes[0], lastNode = textNodes[textNodes.length - 1];
var merges = [], currentMerge;
var rangeStartNode = firstNode, rangeEndNode = lastNode;
var rangeStartOffset = 0, rangeEndOffset = lastNode.length;
var textNode, precedingTextNode;
for (var i = 0, len = textNodes.length; i < len; ++i) {
textNode = textNodes[i];
precedingTextNode = getPreviousMergeableTextNode(textNode, !isUndo);
if (precedingTextNode) {
if (!currentMerge) {
currentMerge = new Merge(precedingTextNode);
merges.push(currentMerge);
}
currentMerge.textNodes.push(textNode);
if (textNode === firstNode) {
rangeStartNode = currentMerge.firstTextNode;
rangeStartOffset = rangeStartNode.length;
}
if (textNode === lastNode) {
rangeEndNode = currentMerge.firstTextNode;
rangeEndOffset = currentMerge.getLength();
}
} else {
currentMerge = null;
}
}
// Test whether the first node after the range needs merging
var nextTextNode = getNextMergeableTextNode(lastNode, !isUndo);
if (nextTextNode) {
if (!currentMerge) {
currentMerge = new Merge(lastNode);
merges.push(currentMerge);
}
currentMerge.textNodes.push(nextTextNode);
}
// Do the merges
if (merges.length) {
for (i = 0, len = merges.length; i < len; ++i) {
merges[i].doMerge();
}
// Set the range boundaries
range.setStart(rangeStartNode, rangeStartOffset);
range.setEnd(rangeEndNode, rangeEndOffset);
}
},
createContainer: function(doc) {
var el = doc.createElement(this.elementTagName);
api.util.extend(el, this.elementProperties);
addClass(el, this.cssClass);
return el;
},
applyToTextNode: function(textNode) {
var parent = textNode.parentNode;
if (parent.childNodes.length == 1 && dom.arrayContains(this.tagNames, parent.tagName.toLowerCase())) {
addClass(parent, this.cssClass);
} else {
var el = this.createContainer(dom.getDocument(textNode));
textNode.parentNode.insertBefore(el, textNode);
el.appendChild(textNode);
}
},
isRemovable: function(el) {
return el.tagName.toLowerCase() == this.elementTagName
&& getSortedClassName(el) == this.elementSortedClassName
&& elementHasProps(el, this.elementProperties)
&& !elementHasNonClassAttributes(el, this.attrExceptions)
&& this.isModifiable(el);
},
undoToTextNode: function(textNode, range, ancestorWithClass) {
if (!range.containsNode(ancestorWithClass)) {
// Split out the portion of the ancestor from which we can remove the CSS class
//var parent = ancestorWithClass.parentNode, index = dom.getNodeIndex(ancestorWithClass);
var ancestorRange = range.cloneRange();
ancestorRange.selectNode(ancestorWithClass);
if (ancestorRange.isPointInRange(range.endContainer, range.endOffset)/* && isSplitPoint(range.endContainer, range.endOffset)*/) {
splitNodeAt(ancestorWithClass, range.endContainer, range.endOffset, [range]);
range.setEndAfter(ancestorWithClass);
}
if (ancestorRange.isPointInRange(range.startContainer, range.startOffset)/* && isSplitPoint(range.startContainer, range.startOffset)*/) {
ancestorWithClass = splitNodeAt(ancestorWithClass, range.startContainer, range.startOffset, [range]);
}
}
if (this.isRemovable(ancestorWithClass)) {
replaceWithOwnChildren(ancestorWithClass);
} else {
removeClass(ancestorWithClass, this.cssClass);
}
},
applyToRange: function(range) {
range.splitBoundaries();
var textNodes = getEffectiveTextNodes(range);
if (textNodes.length) {
var textNode;
for (var i = 0, len = textNodes.length; i < len; ++i) {
textNode = textNodes[i];
if (!this.isIgnorableWhiteSpaceNode(textNode) && !this.getSelfOrAncestorWithClass(textNode)
&& this.isModifiable(textNode)) {
this.applyToTextNode(textNode);
}
}
range.setStart(textNodes[0], 0);
textNode = textNodes[textNodes.length - 1];
range.setEnd(textNode, textNode.length);
if (this.normalize) {
this.postApply(textNodes, range, false);
}
}
},
applyToSelection: function(win) {
win = win || window;
var sel = api.getSelection(win);
var range, ranges = sel.getAllRanges();
sel.removeAllRanges();
var i = ranges.length;
while (i--) {
range = ranges[i];
this.applyToRange(range);
sel.addRange(range);
}
},
undoToRange: function(range) {
range.splitBoundaries();
var textNodes = getEffectiveTextNodes(range);
var textNode, ancestorWithClass;
var lastTextNode = textNodes[textNodes.length - 1];
if (textNodes.length) {
for (var i = 0, len = textNodes.length; i < len; ++i) {
textNode = textNodes[i];
ancestorWithClass = this.getSelfOrAncestorWithClass(textNode);
if (ancestorWithClass && this.isModifiable(textNode)) {
this.undoToTextNode(textNode, range, ancestorWithClass);
}
// Ensure the range is still valid
range.setStart(textNodes[0], 0);
range.setEnd(lastTextNode, lastTextNode.length);
}
if (this.normalize) {
this.postApply(textNodes, range, true);
}
}
},
undoToSelection: function(win) {
win = win || window;
var sel = api.getSelection(win);
var ranges = sel.getAllRanges(), range;
sel.removeAllRanges();
for (var i = 0, len = ranges.length; i < len; ++i) {
range = ranges[i];
this.undoToRange(range);
sel.addRange(range);
}
},
getTextSelectedByRange: function(textNode, range) {
var textRange = range.cloneRange();
textRange.selectNodeContents(textNode);
var intersectionRange = textRange.intersection(range);
var text = intersectionRange ? intersectionRange.toString() : "";
textRange.detach();
return text;
},
isAppliedToRange: function(range) {
if (range.collapsed) {
return !!this.getSelfOrAncestorWithClass(range.commonAncestorContainer);
} else {
var textNodes = range.getNodes( [3] );
for (var i = 0, textNode; textNode = textNodes[i++]; ) {
if (!this.isIgnorableWhiteSpaceNode(textNode) && rangeSelectsAnyText(range, textNode)
&& this.isModifiable(textNode) && !this.getSelfOrAncestorWithClass(textNode)) {
return false;
}
}
return true;
}
},
isAppliedToSelection: function(win) {
win = win || window;
var sel = api.getSelection(win);
var ranges = sel.getAllRanges();
var i = ranges.length;
while (i--) {
if (!this.isAppliedToRange(ranges[i])) {
return false;
}
}
return true;
},
toggleRange: function(range) {
if (this.isAppliedToRange(range)) {
this.undoToRange(range);
} else {
this.applyToRange(range);
}
},
toggleSelection: function(win) {
if (this.isAppliedToSelection(win)) {
this.undoToSelection(win);
} else {
this.applyToSelection(win);
}
},
detach: function() {}
};
function createCssClassApplier(cssClass, options, tagNames) {
return new CssClassApplier(cssClass, options, tagNames);
}
CssClassApplier.util = {
hasClass: hasClass,
addClass: addClass,
removeClass: removeClass,
hasSameClasses: haveSameClasses,
replaceWithOwnChildren: replaceWithOwnChildren,
elementsHaveSameNonClassAttributes: elementsHaveSameNonClassAttributes,
elementHasNonClassAttributes: elementHasNonClassAttributes,
splitNodeAt: splitNodeAt,
isEditableElement: isEditableElement,
isEditingHost: isEditingHost,
isEditable: isEditable
};
api.CssClassApplier = CssClassApplier;
api.createCssClassApplier = createCssClassApplier;
});