Created
February 20, 2015 22:06
-
-
Save timdown/d9e753c61680ec566e50 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Class Applier module for Rangy. | |
* Adds, removes and toggles classes on Ranges and Selections | |
* | |
* Part of Rangy, a cross-browser JavaScript range and selection library | |
* https://github.com/timdown/rangy | |
* | |
* Depends on Rangy core. | |
* | |
* Copyright 2015, Tim Down | |
* Licensed under the MIT license. | |
* Version: 1.3.0-beta.1 | |
* Build date: 12 February 2015 | |
*/ | |
(function(factory, root) { | |
if (typeof define == "function" && define.amd) { | |
// AMD. Register as an anonymous module with a dependency on Rangy. | |
define(["./rangy-core"], factory); | |
} else if (typeof module != "undefined" && typeof exports == "object") { | |
// Node/CommonJS style | |
module.exports = factory( require("rangy") ); | |
} else { | |
// No AMD or CommonJS support so we use the rangy property of root (probably the global variable) | |
factory(root.rangy); | |
} | |
})(function(rangy) { | |
rangy.createModule("ClassApplier", ["WrappedSelection"], function(api, module) { | |
var dom = api.dom; | |
var DomPosition = dom.DomPosition; | |
var contains = dom.arrayContains; | |
var isHtmlNamespace = dom.isHtmlNamespace; | |
var forEach = api.util.forEach; | |
var defaultTagName = "span"; | |
function each(obj, func) { | |
for (var i in obj) { | |
if (obj.hasOwnProperty(i)) { | |
if (func(i, obj[i]) === false) { | |
return false; | |
} | |
} | |
} | |
return true; | |
} | |
function trim(str) { | |
return str.replace(/^\s\s*/, "").replace(/\s\s*$/, ""); | |
} | |
var hasClass, addClass, removeClass; | |
function classNameContainsClass(fullClassName, className) { | |
return !!fullClassName && new RegExp("(?:^|\\s)" + className + "(?:\\s|$)").test(fullClassName); | |
} | |
// Inefficient, inelegant nonsense for IE's svg element, which has no classList and non-HTML className implementation | |
hasClass = function(el, className) { | |
if (el.nodeType !== 1) { | |
return false; | |
} | |
if (typeof el.classList == "object") { | |
return (el.nodeType == 1) && el.classList.contains(className); | |
} else { | |
var classNameSupported = (typeof el.className == "string"); | |
var elClass = classNameSupported ? el.className : el.getAttribute("class"); | |
return classNameContainsClass(elClass, className); | |
} | |
}; | |
addClass = function(el, className) { | |
if (el.nodeType !== 1) { | |
return; | |
} | |
if (typeof el.classList == "object") { | |
el.classList.add(className); | |
} else { | |
var classNameSupported = (typeof el.className == "string"); | |
var elClass = classNameSupported ? el.className : el.getAttribute("class"); | |
if (elClass) { | |
if (!classNameContainsClass(elClass, className)) { | |
elClass += " " + className; | |
} | |
} else { | |
elClass = className; | |
} | |
if (classNameSupported) { | |
el.className = elClass; | |
} else { | |
el.setAttribute("class", elClass); | |
} | |
} | |
}; | |
removeClass = (function() { | |
function replacer(matched, whiteSpaceBefore, whiteSpaceAfter) { | |
return (whiteSpaceBefore && whiteSpaceAfter) ? " " : ""; | |
} | |
return function(el, className) { | |
if (el.nodeType !== 1) { | |
return; | |
} | |
if (typeof el.classList == "object") { | |
el.classList.remove(className); | |
} else { | |
var classNameSupported = (typeof el.className == "string"); | |
var elClass = classNameSupported ? el.className : el.getAttribute("class"); | |
elClass = elClass.replace(new RegExp("(^|\\s)" + className + "(\\s|$)"), replacer); | |
if (classNameSupported) { | |
el.className = elClass; | |
} else { | |
el.setAttribute("class", elClass); | |
} | |
} | |
}; | |
})(); | |
function sortClassName(className) { | |
return className && className.split(/\s+/).sort().join(" "); | |
} | |
function getSortedClassName(el) { | |
return sortClassName(el.className); | |
} | |
function haveSameClasses(el1, el2) { | |
return getSortedClassName(el1) == getSortedClassName(el2); | |
} | |
function hasAllClasses(el, className) { | |
var classes = className.split(/\s+/); | |
for (var i = 0, len = classes.length; i < len; ++i) { | |
if (!hasClass(el, trim(classes[i]))) { | |
return false; | |
} | |
} | |
return true; | |
} | |
function movePosition(position, oldParent, oldIndex, newParent, newIndex) { | |
var posNode = position.node, posOffset = position.offset; | |
var newNode = posNode, newOffset = posOffset; | |
if (posNode == newParent && posOffset > newIndex) { | |
++newOffset; | |
} | |
if (posNode == oldParent && (posOffset == oldIndex || posOffset == oldIndex + 1)) { | |
newNode = newParent; | |
newOffset += newIndex - oldIndex; | |
} | |
if (posNode == oldParent && posOffset > oldIndex + 1) { | |
--newOffset; | |
} | |
position.node = newNode; | |
position.offset = newOffset; | |
} | |
function movePositionWhenRemovingNode(position, parentNode, index) { | |
if (position.node == parentNode && position.offset > index) { | |
--position.offset; | |
} | |
} | |
function movePreservingPositions(node, newParent, newIndex, positionsToPreserve) { | |
// For convenience, allow newIndex to be -1 to mean "insert at the end". | |
if (newIndex == -1) { | |
newIndex = newParent.childNodes.length; | |
} | |
var oldParent = node.parentNode; | |
var oldIndex = dom.getNodeIndex(node); | |
forEach(positionsToPreserve, function(position) { | |
movePosition(position, oldParent, oldIndex, newParent, newIndex); | |
}); | |
// Now actually move the node. | |
if (newParent.childNodes.length == newIndex) { | |
newParent.appendChild(node); | |
} else { | |
newParent.insertBefore(node, newParent.childNodes[newIndex]); | |
} | |
} | |
function removePreservingPositions(node, positionsToPreserve) { | |
var oldParent = node.parentNode; | |
var oldIndex = dom.getNodeIndex(node); | |
forEach(positionsToPreserve, function(position) { | |
movePositionWhenRemovingNode(position, oldParent, oldIndex); | |
}); | |
node.parentNode.removeChild(node); | |
} | |
function moveChildrenPreservingPositions(node, newParent, newIndex, removeNode, positionsToPreserve) { | |
var child, children = []; | |
while ( (child = node.firstChild) ) { | |
movePreservingPositions(child, newParent, newIndex++, positionsToPreserve); | |
children.push(child); | |
} | |
if (removeNode) { | |
removePreservingPositions(node, positionsToPreserve); | |
} | |
return children; | |
} | |
function replaceWithOwnChildrenPreservingPositions(element, positionsToPreserve) { | |
return moveChildrenPreservingPositions(element, element.parentNode, dom.getNodeIndex(element), true, positionsToPreserve); | |
} | |
function rangeSelectsAnyText(range, textNode) { | |
var textNodeRange = range.cloneRange(); | |
textNodeRange.selectNodeContents(textNode); | |
var intersectionRange = textNodeRange.intersection(range); | |
var text = intersectionRange ? intersectionRange.toString() : ""; | |
return text != ""; | |
} | |
function getEffectiveTextNodes(range) { | |
var nodes = range.getNodes([3]); | |
// Optimization as per issue 145 | |
// Remove non-intersecting text nodes from the start of the range | |
var start = 0, node; | |
while ( (node = nodes[start]) && !rangeSelectsAnyText(range, node) ) { | |
++start; | |
} | |
// Remove non-intersecting text nodes from the start of the range | |
var end = nodes.length - 1; | |
while ( (node = nodes[end]) && !rangeSelectsAnyText(range, node) ) { | |
--end; | |
} | |
return nodes.slice(start, end + 1); | |
} | |
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 === null) != (attr2 === null) ) return false; | |
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 && contains(exceptions, attrName)) && el.attributes[i].specified && attrName != "class") { | |
return true; | |
} | |
} | |
return false; | |
} | |
var getComputedStyleProperty = dom.getComputedStyleProperty; | |
var isEditableElement = (function() { | |
var testEl = document.createElement("div"); | |
return typeof testEl.isContentEditable == "boolean" ? | |
function (node) { | |
return node && node.nodeType == 1 && node.isContentEditable; | |
} : | |
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 getRangeBoundaries(ranges) { | |
var positions = [], i, range; | |
for (i = 0; range = ranges[i++]; ) { | |
positions.push( | |
new DomPosition(range.startContainer, range.startOffset), | |
new DomPosition(range.endContainer, range.endOffset) | |
); | |
} | |
return positions; | |
} | |
function updateRangesFromBoundaries(ranges, positions) { | |
for (var i = 0, range, start, end, len = ranges.length; i < len; ++i) { | |
range = ranges[i]; | |
start = positions[i * 2]; | |
end = positions[i * 2 + 1]; | |
range.setStartAndEnd(start.node, start.offset, end.node, end.offset); | |
} | |
} | |
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, positionsToPreserve) { | |
var newNode, parentNode; | |
var splitAtStart = (descendantOffset == 0); | |
if (dom.isAncestorOf(descendantNode, node)) { | |
return node; | |
} | |
if (dom.isCharacterDataNode(descendantNode)) { | |
var descendantIndex = dom.getNodeIndex(descendantNode); | |
if (descendantOffset == 0) { | |
descendantOffset = descendantIndex; | |
} else if (descendantOffset == descendantNode.length) { | |
descendantOffset = descendantIndex + 1; | |
} else { | |
throw module.createError("splitNodeAt() should not be called with offset in the middle of a data node (" + | |
descendantOffset + " in " + descendantNode.data); | |
} | |
descendantNode = descendantNode.parentNode; | |
} | |
if (isSplitPoint(descendantNode, descendantOffset)) { | |
// descendantNode is now guaranteed not to be a text or other character node | |
newNode = descendantNode.cloneNode(false); | |
parentNode = descendantNode.parentNode; | |
if (newNode.id) { | |
newNode.removeAttribute("id"); | |
} | |
var child, newChildIndex = 0; | |
while ( (child = descendantNode.childNodes[descendantOffset]) ) { | |
movePreservingPositions(child, newNode, newChildIndex++, positionsToPreserve); | |
} | |
movePreservingPositions(newNode, parentNode, dom.getNodeIndex(descendantNode) + 1, positionsToPreserve); | |
return (descendantNode == node) ? newNode : splitNodeAt(node, parentNode, dom.getNodeIndex(newNode), positionsToPreserve); | |
} 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, positionsToPreserve); | |
} | |
return node; | |
} | |
function areElementsMergeable(el1, el2) { | |
return el1.namespaceURI == el2.namespaceURI && | |
el1.tagName.toLowerCase() == el2.tagName.toLowerCase() && | |
haveSameClasses(el1, el2) && | |
elementsHaveSameNonClassAttributes(el1, el2) && | |
getComputedStyleProperty(el1, "display") == "inline" && | |
getComputedStyleProperty(el2, "display") == "inline"; | |
} | |
function createAdjacentMergeableTextNodeGetter(forward) { | |
var siblingPropName = forward ? "nextSibling" : "previousSibling"; | |
return function(textNode, checkParentElement) { | |
var el = textNode.parentNode; | |
var adjacentNode = textNode[siblingPropName]; | |
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[siblingPropName]; | |
if (adjacentNode && adjacentNode.nodeType == 1 && areElementsMergeable(el, adjacentNode)) { | |
var adjacentNodeChild = adjacentNode[forward ? "firstChild" : "lastChild"]; | |
if (adjacentNodeChild && adjacentNodeChild.nodeType == 3) { | |
return adjacentNodeChild; | |
} | |
} | |
} | |
return null; | |
}; | |
} | |
var getPreviousMergeableTextNode = createAdjacentMergeableTextNodeGetter(false), | |
getNextMergeableTextNode = createAdjacentMergeableTextNodeGetter(true); | |
function Merge(firstNode) { | |
this.isElementMerge = (firstNode.nodeType == 1); | |
this.textNodes = []; | |
var firstTextNode = this.isElementMerge ? firstNode.lastChild : firstNode; | |
if (firstTextNode) { | |
this.textNodes[0] = firstTextNode; | |
} | |
} | |
Merge.prototype = { | |
doMerge: function(positionsToPreserve) { | |
var textNodes = this.textNodes; | |
var firstTextNode = textNodes[0]; | |
if (textNodes.length > 1) { | |
var firstTextNodeIndex = dom.getNodeIndex(firstTextNode); | |
var textParts = [], combinedTextLength = 0, textNode, parent; | |
forEach(textNodes, function(textNode, i) { | |
parent = textNode.parentNode; | |
if (i > 0) { | |
parent.removeChild(textNode); | |
if (!parent.hasChildNodes()) { | |
parent.parentNode.removeChild(parent); | |
} | |
if (positionsToPreserve) { | |
forEach(positionsToPreserve, function(position) { | |
// Handle case where position is inside the text node being merged into a preceding node | |
if (position.node == textNode) { | |
position.node = firstTextNode; | |
position.offset += combinedTextLength; | |
} | |
// Handle case where both text nodes precede the position within the same parent node | |
if (position.node == parent && position.offset > firstTextNodeIndex) { | |
--position.offset; | |
if (position.offset == firstTextNodeIndex + 1 && i < len - 1) { | |
position.node = firstTextNode; | |
position.offset = combinedTextLength; | |
} | |
} | |
}); | |
} | |
} | |
textParts[i] = textNode.data; | |
combinedTextLength += textNode.data.length; | |
}); | |
firstTextNode.data = textParts.join(""); | |
} | |
return firstTextNode.data; | |
}, | |
getLength: function() { | |
var i = this.textNodes.length, len = 0; | |
while (i--) { | |
len += this.textNodes[i].length; | |
} | |
return len; | |
}, | |
toString: function() { | |
var textParts = []; | |
forEach(this.textNodes, function(textNode, i) { | |
textParts[i] = "'" + textNode.data + "'"; | |
}); | |
return "[Merge(" + textParts.join(",") + ")]"; | |
} | |
}; | |
var optionProperties = ["elementTagName", "ignoreWhiteSpace", "applyToEditableOnly", "useExistingElements", | |
"removeEmptyElements", "onElementCreate"]; | |
// TODO: Populate this with every attribute name that corresponds to a property with a different name. Really?? | |
var attrNamesForProperties = {}; | |
function ClassApplier(className, options, tagNames) { | |
var normalize, i, len, propName, applier = this; | |
applier.cssClass = applier.className = className; // cssClass property is for backward compatibility | |
var elementPropertiesFromOptions = null, elementAttributes = {}; | |
// Initialize from options object | |
if (typeof options == "object" && options !== null) { | |
if (typeof options.elementTagName !== "undefined") { | |
options.elementTagName = options.elementTagName.toLowerCase(); | |
} | |
tagNames = options.tagNames; | |
elementPropertiesFromOptions = options.elementProperties; | |
elementAttributes = options.elementAttributes; | |
for (i = 0; propName = optionProperties[i++]; ) { | |
if (options.hasOwnProperty(propName)) { | |
applier[propName] = options[propName]; | |
} | |
} | |
normalize = options.normalize; | |
} else { | |
normalize = options; | |
} | |
// Backward compatibility: the second parameter can also be a Boolean indicating to normalize after unapplying | |
applier.normalize = (typeof normalize == "undefined") ? true : normalize; | |
// Initialize element properties and attribute exceptions | |
applier.attrExceptions = []; | |
var el = document.createElement(applier.elementTagName); | |
applier.elementProperties = applier.copyPropertiesToElement(elementPropertiesFromOptions, el, true); | |
each(elementAttributes, function(attrName) { | |
applier.attrExceptions.push(attrName); | |
}); | |
applier.elementAttributes = elementAttributes; | |
applier.elementSortedClassName = applier.elementProperties.hasOwnProperty("className") ? | |
sortClassName(applier.elementProperties.className + " " + className) : className; | |
// Initialize tag names | |
applier.applyToAnyTagName = false; | |
var type = typeof tagNames; | |
if (type == "string") { | |
if (tagNames == "*") { | |
applier.applyToAnyTagName = true; | |
} else { | |
applier.tagNames = trim(tagNames.toLowerCase()).split(/\s*,\s*/); | |
} | |
} else if (type == "object" && typeof tagNames.length == "number") { | |
applier.tagNames = []; | |
for (i = 0, len = tagNames.length; i < len; ++i) { | |
if (tagNames[i] == "*") { | |
applier.applyToAnyTagName = true; | |
} else { | |
applier.tagNames.push(tagNames[i].toLowerCase()); | |
} | |
} | |
} else { | |
applier.tagNames = [applier.elementTagName]; | |
} | |
} | |
ClassApplier.prototype = { | |
elementTagName: defaultTagName, | |
elementProperties: {}, | |
elementAttributes: {}, | |
ignoreWhiteSpace: true, | |
applyToEditableOnly: false, | |
useExistingElements: true, | |
removeEmptyElements: true, | |
onElementCreate: null, | |
copyPropertiesToElement: function(props, el, createCopy) { | |
var s, elStyle, elProps = {}, elPropsStyle, propValue, elPropValue, attrName; | |
for (var p in props) { | |
if (props.hasOwnProperty(p)) { | |
propValue = props[p]; | |
elPropValue = el[p]; | |
// Special case for class. The copied properties object has the applier's class as well as its own | |
// to simplify checks when removing styling elements | |
if (p == "className") { | |
addClass(el, propValue); | |
addClass(el, this.className); | |
el[p] = sortClassName(el[p]); | |
if (createCopy) { | |
elProps[p] = propValue; | |
} | |
} | |
// Special case for style | |
else if (p == "style") { | |
elStyle = elPropValue; | |
if (createCopy) { | |
elProps[p] = elPropsStyle = {}; | |
} | |
for (s in props[p]) { | |
if (props[p].hasOwnProperty(s)) { | |
elStyle[s] = propValue[s]; | |
if (createCopy) { | |
elPropsStyle[s] = elStyle[s]; | |
} | |
} | |
} | |
this.attrExceptions.push(p); | |
} else { | |
el[p] = propValue; | |
// 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. | |
if (createCopy) { | |
elProps[p] = el[p]; | |
// Not all properties map to identically-named attributes | |
attrName = attrNamesForProperties.hasOwnProperty(p) ? attrNamesForProperties[p] : p; | |
this.attrExceptions.push(attrName); | |
} | |
} | |
} | |
} | |
return createCopy ? elProps : ""; | |
}, | |
copyAttributesToElement: function(attrs, el) { | |
for (var attrName in attrs) { | |
if (attrs.hasOwnProperty(attrName) && !/^class(?:Name)?$/i.test(attrName)) { | |
el.setAttribute(attrName, attrs[attrName]); | |
} | |
} | |
}, | |
appliesToElement: function(el) { | |
return contains(this.tagNames, el.tagName.toLowerCase()); | |
}, | |
getEmptyElements: function(range) { | |
var applier = this; | |
return range.getNodes([1], function(el) { | |
return applier.appliesToElement(el) && !el.hasChildNodes(); | |
}); | |
}, | |
hasClass: function(node) { | |
return node.nodeType == 1 && | |
(this.applyToAnyTagName || this.appliesToElement(node)) && | |
hasClass(node, this.className); | |
}, | |
getSelfOrAncestorWithClass: function(node) { | |
while (node) { | |
if (this.hasClass(node)) { | |
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 class to a Range. | |
postApply: function(textNodes, range, positionsToPreserve, 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; | |
// Check for every required merge and create a Merge object for each | |
forEach(textNodes, function(textNode) { | |
precedingTextNode = getPreviousMergeableTextNode(textNode, !isUndo); | |
if (precedingTextNode) { | |
if (!currentMerge) { | |
currentMerge = new Merge(precedingTextNode); | |
merges.push(currentMerge); | |
} | |
currentMerge.textNodes.push(textNode); | |
if (textNode === firstNode) { | |
rangeStartNode = currentMerge.textNodes[0]; | |
rangeStartOffset = rangeStartNode.length; | |
} | |
if (textNode === lastNode) { | |
rangeEndNode = currentMerge.textNodes[0]; | |
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); | |
} | |
// Apply the merges | |
if (merges.length) { | |
for (i = 0, len = merges.length; i < len; ++i) { | |
merges[i].doMerge(positionsToPreserve); | |
} | |
// Set the range boundaries | |
range.setStartAndEnd(rangeStartNode, rangeStartOffset, rangeEndNode, rangeEndOffset); | |
} | |
}, | |
createContainer: function(doc) { | |
var el = doc.createElementNS("http://www.w3.org/2000/svg", this.elementTagName); | |
this.copyPropertiesToElement(this.elementProperties, el, false); | |
this.copyAttributesToElement(this.elementAttributes, el); | |
addClass(el, this.className); | |
if (this.onElementCreate) { | |
this.onElementCreate(el, this); | |
} | |
return el; | |
}, | |
elementHasProperties: function(el, props) { | |
var applier = this; | |
return each(props, function(p, propValue) { | |
if (p == "className") { | |
// For checking whether we should reuse an existing element, we just want to check that the element | |
// has all the classes specified in the className property. When deciding whether the element is | |
// removable when unapplying a class, there is separate special handling to check whether the | |
// element has extra classes so the same simple check will do. | |
return hasAllClasses(el, propValue); | |
} else if (typeof propValue == "object") { | |
if (!applier.elementHasProperties(el[p], propValue)) { | |
return false; | |
} | |
} else if (el[p] !== propValue) { | |
return false; | |
} | |
}); | |
}, | |
elementHasAttributes: function(el, attrs) { | |
return each(attrs, function(name, value) { | |
if (el.getAttribute(name) !== value) { | |
return false; | |
} | |
}); | |
}, | |
applyToTextNode: function(textNode, positionsToPreserve) { | |
var parent = textNode.parentNode; | |
if (parent.childNodes.length == 1 && | |
this.useExistingElements && | |
isHtmlNamespace(parent) && | |
this.appliesToElement(parent) && | |
this.elementHasProperties(parent, this.elementProperties) && | |
this.elementHasAttributes(parent, this.elementAttributes)) { | |
addClass(parent, this.className); | |
} else { | |
var el = this.createContainer(dom.getDocument(textNode)); | |
textNode.parentNode.insertBefore(el, textNode); | |
el.appendChild(textNode); | |
} | |
}, | |
isRemovable: function(el) { | |
return isHtmlNamespace(el) && | |
el.tagName.toLowerCase() == this.elementTagName && | |
getSortedClassName(el) == this.elementSortedClassName && | |
this.elementHasProperties(el, this.elementProperties) && | |
!elementHasNonClassAttributes(el, this.attrExceptions) && | |
this.elementHasAttributes(el, this.elementAttributes) && | |
this.isModifiable(el); | |
}, | |
isEmptyContainer: function(el) { | |
var childNodeCount = el.childNodes.length; | |
return el.nodeType == 1 && | |
this.isRemovable(el) && | |
(childNodeCount == 0 || (childNodeCount == 1 && this.isEmptyContainer(el.firstChild))); | |
}, | |
removeEmptyContainers: function(range) { | |
var applier = this; | |
var nodesToRemove = range.getNodes([1], function(el) { | |
return applier.isEmptyContainer(el); | |
}); | |
var rangesToPreserve = [range]; | |
var positionsToPreserve = getRangeBoundaries(rangesToPreserve); | |
forEach(nodesToRemove, function(node) { | |
removePreservingPositions(node, positionsToPreserve); | |
}); | |
// Update the range from the preserved boundary positions | |
updateRangesFromBoundaries(rangesToPreserve, positionsToPreserve); | |
}, | |
undoToTextNode: function(textNode, range, ancestorWithClass, positionsToPreserve) { | |
if (!range.containsNode(ancestorWithClass)) { | |
// Split out the portion of the ancestor from which we can remove the class | |
//var parent = ancestorWithClass.parentNode, index = dom.getNodeIndex(ancestorWithClass); | |
var ancestorRange = range.cloneRange(); | |
ancestorRange.selectNode(ancestorWithClass); | |
if (ancestorRange.isPointInRange(range.endContainer, range.endOffset)) { | |
splitNodeAt(ancestorWithClass, range.endContainer, range.endOffset, positionsToPreserve); | |
range.setEndAfter(ancestorWithClass); | |
} | |
if (ancestorRange.isPointInRange(range.startContainer, range.startOffset)) { | |
ancestorWithClass = splitNodeAt(ancestorWithClass, range.startContainer, range.startOffset, positionsToPreserve); | |
} | |
} | |
if (this.isRemovable(ancestorWithClass)) { | |
replaceWithOwnChildrenPreservingPositions(ancestorWithClass, positionsToPreserve); | |
} else { | |
removeClass(ancestorWithClass, this.className); | |
} | |
}, | |
splitAncestorWithClass: function(container, offset, positionsToPreserve) { | |
var ancestorWithClass = this.getSelfOrAncestorWithClass(container); | |
if (ancestorWithClass) { | |
splitNodeAt(ancestorWithClass, container, offset, positionsToPreserve); | |
} | |
}, | |
undoToAncestor: function(ancestorWithClass, positionsToPreserve) { | |
if (this.isRemovable(ancestorWithClass)) { | |
replaceWithOwnChildrenPreservingPositions(ancestorWithClass, positionsToPreserve); | |
} else { | |
removeClass(ancestorWithClass, this.className); | |
} | |
}, | |
applyToRange: function(range, rangesToPreserve) { | |
var applier = this; | |
rangesToPreserve = rangesToPreserve || []; | |
// Create an array of range boundaries to preserve | |
var positionsToPreserve = getRangeBoundaries(rangesToPreserve || []); | |
range.splitBoundariesPreservingPositions(positionsToPreserve); | |
// Tidy up the DOM by removing empty containers | |
if (applier.removeEmptyElements) { | |
applier.removeEmptyContainers(range); | |
} | |
var textNodes = getEffectiveTextNodes(range); | |
if (textNodes.length) { | |
forEach(textNodes, function(textNode) { | |
if (!applier.isIgnorableWhiteSpaceNode(textNode) && !applier.getSelfOrAncestorWithClass(textNode) && | |
applier.isModifiable(textNode)) { | |
applier.applyToTextNode(textNode, positionsToPreserve); | |
} | |
}); | |
var lastTextNode = textNodes[textNodes.length - 1]; | |
range.setStartAndEnd(textNodes[0], 0, lastTextNode, lastTextNode.length); | |
if (applier.normalize) { | |
applier.postApply(textNodes, range, positionsToPreserve, false); | |
} | |
// Update the ranges from the preserved boundary positions | |
updateRangesFromBoundaries(rangesToPreserve, positionsToPreserve); | |
} | |
// Apply classes to any appropriate empty elements | |
var emptyElements = applier.getEmptyElements(range); | |
forEach(emptyElements, function(el) { | |
addClass(el, applier.className); | |
}); | |
}, | |
applyToRanges: function(ranges) { | |
var i = ranges.length; | |
while (i--) { | |
this.applyToRange(ranges[i], ranges); | |
} | |
return ranges; | |
}, | |
applyToSelection: function(win) { | |
var sel = api.getSelection(win); | |
sel.setRanges( this.applyToRanges(sel.getAllRanges()) ); | |
}, | |
undoToRange: function(range, rangesToPreserve) { | |
var applier = this; | |
// Create an array of range boundaries to preserve | |
rangesToPreserve = rangesToPreserve || []; | |
var positionsToPreserve = getRangeBoundaries(rangesToPreserve); | |
range.splitBoundariesPreservingPositions(positionsToPreserve); | |
// Tidy up the DOM by removing empty containers | |
if (applier.removeEmptyElements) { | |
applier.removeEmptyContainers(range, positionsToPreserve); | |
} | |
var textNodes = getEffectiveTextNodes(range); | |
var textNode, ancestorWithClass; | |
var lastTextNode = textNodes[textNodes.length - 1]; | |
if (textNodes.length) { | |
applier.splitAncestorWithClass(range.endContainer, range.endOffset, positionsToPreserve); | |
applier.splitAncestorWithClass(range.startContainer, range.startOffset, positionsToPreserve); | |
for (var i = 0, len = textNodes.length; i < len; ++i) { | |
textNode = textNodes[i]; | |
ancestorWithClass = applier.getSelfOrAncestorWithClass(textNode); | |
if (ancestorWithClass && applier.isModifiable(textNode)) { | |
applier.undoToAncestor(ancestorWithClass, positionsToPreserve); | |
} | |
} | |
// Ensure the range is still valid | |
range.setStartAndEnd(textNodes[0], 0, lastTextNode, lastTextNode.length); | |
if (applier.normalize) { | |
applier.postApply(textNodes, range, positionsToPreserve, true); | |
} | |
// Update the ranges from the preserved boundary positions | |
updateRangesFromBoundaries(rangesToPreserve, positionsToPreserve); | |
} | |
// Remove class from any appropriate empty elements | |
var emptyElements = applier.getEmptyElements(range); | |
forEach(emptyElements, function(el) { | |
removeClass(el, applier.className); | |
}); | |
}, | |
undoToRanges: function(ranges) { | |
// Get ranges returned in document order | |
var i = ranges.length; | |
while (i--) { | |
this.undoToRange(ranges[i], ranges); | |
} | |
return ranges; | |
}, | |
undoToSelection: function(win) { | |
var sel = api.getSelection(win); | |
var ranges = api.getSelection(win).getAllRanges(); | |
this.undoToRanges(ranges); | |
sel.setRanges(ranges); | |
}, | |
isAppliedToRange: function(range) { | |
if (range.collapsed || range.toString() == "") { | |
return !!this.getSelfOrAncestorWithClass(range.commonAncestorContainer); | |
} else { | |
var textNodes = range.getNodes( [3] ); | |
if (textNodes.length) | |
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; | |
} | |
}, | |
isAppliedToRanges: function(ranges) { | |
var i = ranges.length; | |
if (i == 0) { | |
return false; | |
} | |
while (i--) { | |
if (!this.isAppliedToRange(ranges[i])) { | |
return false; | |
} | |
} | |
return true; | |
}, | |
isAppliedToSelection: function(win) { | |
var sel = api.getSelection(win); | |
return this.isAppliedToRanges(sel.getAllRanges()); | |
}, | |
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); | |
} | |
}, | |
getElementsWithClassIntersectingRange: function(range) { | |
var elements = []; | |
var applier = this; | |
range.getNodes([3], function(textNode) { | |
var el = applier.getSelfOrAncestorWithClass(textNode); | |
if (el && !contains(elements, el)) { | |
elements.push(el); | |
} | |
}); | |
return elements; | |
}, | |
detach: function() {} | |
}; | |
function createClassApplier(className, options, tagNames) { | |
return new ClassApplier(className, options, tagNames); | |
} | |
ClassApplier.util = { | |
hasClass: hasClass, | |
addClass: addClass, | |
removeClass: removeClass, | |
hasSameClasses: haveSameClasses, | |
hasAllClasses: hasAllClasses, | |
replaceWithOwnChildren: replaceWithOwnChildrenPreservingPositions, | |
elementsHaveSameNonClassAttributes: elementsHaveSameNonClassAttributes, | |
elementHasNonClassAttributes: elementHasNonClassAttributes, | |
splitNodeAt: splitNodeAt, | |
isEditableElement: isEditableElement, | |
isEditingHost: isEditingHost, | |
isEditable: isEditable | |
}; | |
api.CssClassApplier = api.ClassApplier = ClassApplier; | |
api.createCssClassApplier = api.createClassApplier = createClassApplier; | |
}); | |
return rangy; | |
}, this); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment