bauhausjs
Version:
A modular CMS for Node.js
482 lines (461 loc) • 20.4 kB
JavaScript
angular.module('textAngular.DOM', ['textAngular.factories'])
.factory('taExecCommand', ['taSelection', 'taBrowserTag', '$document', function(taSelection, taBrowserTag, $document){
var listToDefault = function(listElement, defaultWrap){
var $target, i;
// if all selected then we should remove the list
// grab all li elements and convert to taDefaultWrap tags
var children = listElement.find('li');
for(i = children.length - 1; i >= 0; i--){
$target = angular.element('<' + defaultWrap + '>' + children[i].innerHTML + '</' + defaultWrap + '>');
listElement.after($target);
}
listElement.remove();
taSelection.setSelectionToElementEnd($target[0]);
};
var selectLi = function(liElement){
if(/(<br(|\/)>)$/i.test(liElement.innerHTML.trim())) taSelection.setSelectionBeforeElement(angular.element(liElement).find("br")[0]);
else taSelection.setSelectionToElementEnd(liElement);
};
var listToList = function(listElement, newListTag){
var $target = angular.element('<' + newListTag + '>' + listElement[0].innerHTML + '</' + newListTag + '>');
listElement.after($target);
listElement.remove();
selectLi($target.find('li')[0]);
};
var childElementsToList = function(elements, listElement, newListTag){
var html = '';
for(var i = 0; i < elements.length; i++){
html += '<' + taBrowserTag('li') + '>' + elements[i].innerHTML + '</' + taBrowserTag('li') + '>';
}
var $target = angular.element('<' + newListTag + '>' + html + '</' + newListTag + '>');
listElement.after($target);
listElement.remove();
selectLi($target.find('li')[0]);
};
return function(taDefaultWrap, topNode){
taDefaultWrap = taBrowserTag(taDefaultWrap);
return function(command, showUI, options){
var i, $target, html, _nodes, next, optionsTagName, selectedElement;
var defaultWrapper = angular.element('<' + taDefaultWrap + '>');
try{
selectedElement = taSelection.getSelectionElement();
}catch(e){}
var $selected = angular.element(selectedElement);
if(selectedElement !== undefined){
var tagName = selectedElement.tagName.toLowerCase();
if(command.toLowerCase() === 'insertorderedlist' || command.toLowerCase() === 'insertunorderedlist'){
var selfTag = taBrowserTag((command.toLowerCase() === 'insertorderedlist')? 'ol' : 'ul');
if(tagName === selfTag){
// if all selected then we should remove the list
// grab all li elements and convert to taDefaultWrap tags
return listToDefault($selected, taDefaultWrap);
}else if(tagName === 'li' && $selected.parent()[0].tagName.toLowerCase() === selfTag && $selected.parent().children().length === 1){
// catch for the previous statement if only one li exists
return listToDefault($selected.parent(), taDefaultWrap);
}else if(tagName === 'li' && $selected.parent()[0].tagName.toLowerCase() !== selfTag && $selected.parent().children().length === 1){
// catch for the previous statement if only one li exists
return listToList($selected.parent(), selfTag);
}else if(tagName.match(BLOCKELEMENTS) && !$selected.hasClass('ta-bind')){
// if it's one of those block elements we have to change the contents
// if it's a ol/ul we are changing from one to the other
if(tagName === 'ol' || tagName === 'ul'){
return listToList($selected, selfTag);
}else{
var childBlockElements = false;
angular.forEach($selected.children(), function(elem){
if(elem.tagName.match(BLOCKELEMENTS)) {
childBlockElements = true;
}
});
if(childBlockElements){
return childElementsToList($selected.children(), $selected, selfTag);
}else{
return childElementsToList([angular.element('<div>' + selectedElement.innerHTML + '</div>')[0]], $selected, selfTag);
}
}
}else if(tagName.match(BLOCKELEMENTS)){
// if we get here then all the contents of the ta-bind are selected
_nodes = taSelection.getOnlySelectedElements();
if(_nodes.length === 0){
// here is if there is only text in ta-bind ie <div ta-bind>test content</div>
$target = angular.element('<' + selfTag + '><li>' + selectedElement.innerHTML + '</li></' + selfTag + '>');
$selected.html('');
$selected.append($target);
}else if(_nodes.length === 1 && (_nodes[0].tagName.toLowerCase() === 'ol' || _nodes[0].tagName.toLowerCase() === 'ul')){
if(_nodes[0].tagName.toLowerCase() === selfTag){
// remove
return listToDefault(angular.element(_nodes[0]), taDefaultWrap);
}else{
return listToList(angular.element(_nodes[0]), selfTag);
}
}else{
html = '';
var $nodes = [];
for(i = 0; i < _nodes.length; i++){
/* istanbul ignore else: catch for real-world can't make it occur in testing */
if(_nodes[i].nodeType !== 3){
var $n = angular.element(_nodes[i]);
/* istanbul ignore if: browser check only, phantomjs doesn't return children nodes but chrome at least does */
if(_nodes[i].tagName.toLowerCase() === 'li') continue;
else if(_nodes[i].tagName.toLowerCase() === 'ol' || _nodes[i].tagName.toLowerCase() === 'ul'){
html += $n[0].innerHTML; // if it's a list, add all it's children
}else if(_nodes[i].tagName.toLowerCase() === 'span' && (_nodes[i].childNodes[0].tagName.toLowerCase() === 'ol' || _nodes[i].childNodes[0].tagName.toLowerCase() === 'ul')){
html += $n[0].childNodes[0].innerHTML; // if it's a list, add all it's children
}else{
html += '<' + taBrowserTag('li') + '>' + $n[0].innerHTML + '</' + taBrowserTag('li') + '>';
}
$nodes.unshift($n);
}
}
$target = angular.element('<' + selfTag + '>' + html + '</' + selfTag + '>');
$nodes.pop().replaceWith($target);
angular.forEach($nodes, function($node){ $node.remove(); });
}
taSelection.setSelectionToElementEnd($target[0]);
return;
}
}else if(command.toLowerCase() === 'formatblock'){
optionsTagName = options.toLowerCase().replace(/[<>]/ig, '');
if(optionsTagName.trim() === 'default') {
optionsTagName = taDefaultWrap;
options = '<' + taDefaultWrap + '>';
}
if(tagName === 'li') $target = $selected.parent();
else $target = $selected;
// find the first blockElement
while(!$target[0].tagName || !$target[0].tagName.match(BLOCKELEMENTS) && !$target.parent().attr('contenteditable')){
$target = $target.parent();
/* istanbul ignore next */
tagName = ($target[0].tagName || '').toLowerCase();
}
if(tagName === optionsTagName){
// $target is wrap element
_nodes = $target.children();
var hasBlock = false;
for(i = 0; i < _nodes.length; i++){
hasBlock = hasBlock || _nodes[i].tagName.match(BLOCKELEMENTS);
}
if(hasBlock){
$target.after(_nodes);
next = $target.next();
$target.remove();
$target = next;
}else{
defaultWrapper.append($target[0].childNodes);
$target.after(defaultWrapper);
$target.remove();
$target = defaultWrapper;
}
}else if($target.parent()[0].tagName.toLowerCase() === optionsTagName && !$target.parent().hasClass('ta-bind')){
//unwrap logic for parent
var blockElement = $target.parent();
var contents = blockElement.contents();
for(i = 0; i < contents.length; i ++){
/* istanbul ignore next: can't test - some wierd thing with how phantomjs works */
if(blockElement.parent().hasClass('ta-bind') && contents[i].nodeType === 3){
defaultWrapper = angular.element('<' + taDefaultWrap + '>');
defaultWrapper[0].innerHTML = contents[i].outerHTML;
contents[i] = defaultWrapper[0];
}
blockElement.parent()[0].insertBefore(contents[i], blockElement[0]);
}
blockElement.remove();
}else if(tagName.match(LISTELEMENTS)){
// wrapping a list element
$target.wrap(options);
}else{
// default wrap behaviour
_nodes = taSelection.getOnlySelectedElements();
if(_nodes.length === 0) _nodes = [$target[0]];
// find the parent block element if any of the nodes are inline or text
for(i = 0; i < _nodes.length; i++){
if(_nodes[i].nodeType === 3 || !_nodes[i].tagName.match(BLOCKELEMENTS)){
while(_nodes[i].nodeType === 3 || !_nodes[i].tagName || !_nodes[i].tagName.match(BLOCKELEMENTS)){
_nodes[i] = _nodes[i].parentNode;
}
}
}
if(angular.element(_nodes[0]).hasClass('ta-bind')){
$target = angular.element(options);
$target[0].innerHTML = _nodes[0].innerHTML;
_nodes[0].innerHTML = $target[0].outerHTML;
}else if(optionsTagName === 'blockquote'){
// blockquotes wrap other block elements
html = '';
for(i = 0; i < _nodes.length; i++){
html += _nodes[i].outerHTML;
}
$target = angular.element(options);
$target[0].innerHTML = html;
_nodes[0].parentNode.insertBefore($target[0],_nodes[0]);
for(i = _nodes.length - 1; i >= 0; i--){
/* istanbul ignore else: */
if(_nodes[i].parentNode) _nodes[i].parentNode.removeChild(_nodes[i]);
}
}
else {
// regular block elements replace other block elements
for(i = 0; i < _nodes.length; i++){
$target = angular.element(options);
$target[0].innerHTML = _nodes[i].innerHTML;
_nodes[i].parentNode.insertBefore($target[0],_nodes[i]);
_nodes[i].parentNode.removeChild(_nodes[i]);
}
}
}
taSelection.setSelectionToElementEnd($target[0]);
return;
}else if(command.toLowerCase() === 'createlink'){
var _selection = taSelection.getSelection();
if(_selection.collapsed){
// insert text at selection, then select then just let normal exec-command run
taSelection.insertHtml('<a href="' + options + '">' + options + '</a>', topNode);
return;
}
}else if(command.toLowerCase() === 'inserthtml'){
taSelection.insertHtml(options, topNode);
return;
}
}
try{
$document[0].execCommand(command, showUI, options);
}catch(e){}
};
};
}]).service('taSelection', ['$window', '$document', 'taDOM',
/* istanbul ignore next: all browser specifics and PhantomJS dosen't seem to support half of it */
function($window, $document, taDOM){
// need to dereference the document else the calls don't work correctly
var _document = $document[0];
var rangy = $window.rangy;
var brException = function (element, offset) {
/* check if selection is a BR element at the beginning of a container. If so, get
* the parentNode instead.
* offset should be zero in this case. Otherwise, return the original
* element.
*/
if (element.tagName && element.tagName.match(/^br$/i) && offset === 0 && !element.previousSibling) {
return {
element: element.parentNode,
offset: 0
};
} else {
return {
element: element,
offset: offset
};
}
};
var api = {
getSelection: function(){
var range = rangy.getSelection().getRangeAt(0);
var container = range.commonAncestorContainer;
var selection = {
start: brException(range.startContainer, range.startOffset),
end: brException(range.endContainer, range.endOffset),
collapsed: range.collapsed
};
// Check if the container is a text node and return its parent if so
container = container.nodeType === 3 ? container.parentNode : container;
if (container.parentNode === selection.start.element ||
container.parentNode === selection.end.element) {
selection.container = container.parentNode;
} else {
selection.container = container;
}
return selection;
},
getOnlySelectedElements: function(){
var range = rangy.getSelection().getRangeAt(0);
var container = range.commonAncestorContainer;
// Check if the container is a text node and return its parent if so
container = container.nodeType === 3 ? container.parentNode : container;
return range.getNodes([1], function(node){
return node.parentNode === container;
});
},
// Some basic selection functions
getSelectionElement: function () {
return api.getSelection().container;
},
setSelection: function(el, start, end){
var range = rangy.createRange();
range.setStart(el, start);
range.setEnd(el, end);
rangy.getSelection().setSingleRange(range);
},
setSelectionBeforeElement: function (el){
var range = rangy.createRange();
range.selectNode(el);
range.collapse(true);
rangy.getSelection().setSingleRange(range);
},
setSelectionAfterElement: function (el){
var range = rangy.createRange();
range.selectNode(el);
range.collapse(false);
rangy.getSelection().setSingleRange(range);
},
setSelectionToElementStart: function (el){
var range = rangy.createRange();
range.selectNodeContents(el);
range.collapse(true);
rangy.getSelection().setSingleRange(range);
},
setSelectionToElementEnd: function (el){
var range = rangy.createRange();
range.selectNodeContents(el);
range.collapse(false);
if(el.childNodes && el.childNodes[el.childNodes.length - 1] && el.childNodes[el.childNodes.length - 1].nodeName === 'br'){
range.startOffset = range.endOffset = range.startOffset - 1;
}
rangy.getSelection().setSingleRange(range);
},
// from http://stackoverflow.com/questions/6690752/insert-html-at-caret-in-a-contenteditable-div
// topNode is the contenteditable normally, all manipulation MUST be inside this.
insertHtml: function(html, topNode){
var parent, secondParent, _childI, nodes, startIndex, startNodes, endNodes, i, lastNode, _tempFrag;
var element = angular.element("<div>" + html + "</div>");
var range = rangy.getSelection().getRangeAt(0);
var frag = _document.createDocumentFragment();
var children = element[0].childNodes;
var isInline = true;
if(children.length > 0){
// NOTE!! We need to do the following:
// check for blockelements - if they exist then we have to split the current element in half (and all others up to the closest block element) and insert all children in-between.
// If there are no block elements, or there is a mixture we need to create textNodes for the non wrapped text (we don't want them spans messing up the picture).
nodes = [];
for(_childI = 0; _childI < children.length; _childI++){
if(!(
(children[_childI].nodeName.toLowerCase() === 'p' && children[_childI].innerHTML.trim() === '') || // empty p element
(children[_childI].nodeType === 3 && children[_childI].nodeValue.trim() === '') // empty text node
)){
isInline = isInline && !BLOCKELEMENTS.test(children[_childI].nodeName);
nodes.push(children[_childI]);
}
}
for(var _n = 0; _n < nodes.length; _n++) lastNode = frag.appendChild(nodes[_n]);
if(!isInline && range.collapsed && /^(|<br(|\/)>)$/i.test(range.startContainer.innerHTML)) range.selectNode(range.startContainer);
}else{
isInline = true;
// paste text of some sort
lastNode = frag = _document.createTextNode(html);
}
// Other Edge case - selected data spans multiple blocks.
if(isInline){
range.deleteContents();
}else{ // not inline insert
if(range.collapsed && range.startContainer !== topNode){
if(range.startContainer.innerHTML && range.startContainer.innerHTML.match(/^<[^>]*>$/i)){
// this log is to catch when innerHTML is something like `<img ...>`
parent = range.startContainer;
if(range.startOffset === 1){
// before single tag
range.setStartAfter(parent);
range.setEndAfter(parent);
}else{
// after single tag
range.setStartBefore(parent);
range.setEndBefore(parent);
}
}else{
// split element into 2 and insert block element in middle
if(range.startContainer.nodeType === 3 && range.startContainer.parentNode !== topNode){ // if text node
parent = range.startContainer.parentNode;
secondParent = parent.cloneNode();
// split the nodes into two lists - before and after, splitting the node with the selection into 2 text nodes.
taDOM.splitNodes(parent.childNodes, parent, secondParent, range.startContainer, range.startOffset);
// Escape out of the inline tags like b
while(!VALIDELEMENTS.test(parent.nodeName)){
angular.element(parent).after(secondParent);
parent = parent.parentNode;
var _lastSecondParent = secondParent;
secondParent = parent.cloneNode();
// split the nodes into two lists - before and after, splitting the node with the selection into 2 text nodes.
taDOM.splitNodes(parent.childNodes, parent, secondParent, _lastSecondParent);
}
}else{
parent = range.startContainer;
secondParent = parent.cloneNode();
taDOM.splitNodes(parent.childNodes, parent, secondParent, undefined, undefined, range.startOffset);
}
angular.element(parent).after(secondParent);
// put cursor to end of inserted content
range.setStartAfter(parent);
range.setEndAfter(parent);
if(/^(|<br(|\/)>)$/i.test(parent.innerHTML.trim())){
range.setStartBefore(parent);
range.setEndBefore(parent);
angular.element(parent).remove();
}
if(/^(|<br(|\/)>)$/i.test(secondParent.innerHTML.trim())) angular.element(secondParent).remove();
if(parent.nodeName.toLowerCase() === 'li'){
_tempFrag = _document.createDocumentFragment();
for(i = 0; i < frag.childNodes.length; i++){
element = angular.element('<li>');
taDOM.transferChildNodes(frag.childNodes[i], element[0]);
taDOM.transferNodeAttributes(frag.childNodes[i], element[0]);
_tempFrag.appendChild(element[0]);
}
frag = _tempFrag;
if(lastNode){
lastNode = frag.childNodes[frag.childNodes.length - 1];
lastNode = lastNode.childNodes[lastNode.childNodes.length - 1];
}
}
}
}else{
range.deleteContents();
}
}
range.insertNode(frag);
if(lastNode){
api.setSelectionToElementEnd(lastNode);
}
}
};
return api;
}]).service('taDOM', function(){
var taDOM = {
// recursive function that returns an array of angular.elements that have the passed attribute set on them
getByAttribute: function(element, attribute){
var resultingElements = [];
var childNodes = element.children();
if(childNodes.length){
angular.forEach(childNodes, function(child){
resultingElements = resultingElements.concat(taDOM.getByAttribute(angular.element(child), attribute));
});
}
if(element.attr(attribute) !== undefined) resultingElements.push(element);
return resultingElements;
},
transferChildNodes: function(source, target){
// clear out target
target.innerHTML = '';
while(source.childNodes.length > 0) target.appendChild(source.childNodes[0]);
return target;
},
splitNodes: function(nodes, target1, target2, splitNode, subSplitIndex, splitIndex){
if(!splitNode && isNaN(splitIndex)) throw new Error('taDOM.splitNodes requires a splitNode or splitIndex');
var startNodes = document.createDocumentFragment();
var endNodes = document.createDocumentFragment();
var index = 0;
while(nodes.length > 0 && (isNaN(splitIndex) || splitIndex !== index) && nodes[0] !== splitNode){
startNodes.appendChild(nodes[0]); // this removes from the nodes array (if proper childNodes object.
index++;
}
if(!isNaN(subSplitIndex) && subSplitIndex >= 0 && nodes[0]){
startNodes.appendChild(document.createTextNode(nodes[0].nodeValue.substring(0, subSplitIndex)));
nodes[0].nodeValue = nodes[0].nodeValue.substring(subSplitIndex);
}
while(nodes.length > 0) endNodes.appendChild(nodes[0]);
taDOM.transferChildNodes(startNodes, target1);
taDOM.transferChildNodes(endNodes, target2);
},
transferNodeAttributes: function(source, target){
for(var i = 0; i < source.attributes.length; i++) target.setAttribute(source.attributes[i].name, source.attributes[i].value);
return target;
}
};
return taDOM;
});