textangular
Version:
A radically powerful Text-Editor/Wysiwyg editor for Angular.js
1,266 lines (1,207 loc) • 141 kB
JavaScript
/*
@license textAngular
Author : Austin Anderson
License : 2013 MIT
Version 1.5.7
See README.md or https://github.com/fraywing/textAngular/wiki for requirements and use.
*/
/*
Commonjs package manager support (eg componentjs).
*/
"use strict";// NOTE: textAngularVersion must match the Gruntfile.js 'setVersion' task.... and have format v/d+./d+./d+
var textAngularVersion = 'v1.5.7'; // This is automatically updated during the build process to the current release!
// IE version detection - http://stackoverflow.com/questions/4169160/javascript-ie-detection-why-not-use-simple-conditional-comments
// We need this as IE sometimes plays funny tricks with the contenteditable.
// ----------------------------------------------------------
// If you're not in IE (or IE version is less than 5) then:
// ie === undefined
// If you're in IE (>=5) then you can determine which version:
// ie === 7; // IE7
// Thus, to detect IE:
// if (ie) {}
// And to detect the version:
// ie === 6 // IE6
// ie > 7 // IE8, IE9, IE10 ...
// ie < 9 // Anything less than IE9
// ----------------------------------------------------------
/* istanbul ignore next: untestable browser check */
var _browserDetect = {
ie: (function(){
var undef,
v = 3,
div = document.createElement('div'),
all = div.getElementsByTagName('i');
while (
div.innerHTML = '<!--[if gt IE ' + (++v) + ']><i></i><![endif]-->',
all[0]
);
return v > 4 ? v : undef;
}()),
webkit: /AppleWebKit\/([\d.]+)/i.test(navigator.userAgent),
isFirefox: navigator.userAgent.toLowerCase().indexOf('firefox') > -1
};
// Global to textAngular to measure performance where needed
var performance = performance || {};
performance.now = (function() {
return performance.now ||
performance.mozNow ||
performance.msNow ||
performance.oNow ||
performance.webkitNow ||
function() { return new Date().getTime(); };
})();
// usage is:
// var t0 = performance.now();
// doSomething();
// var t1 = performance.now();
// console.log('Took', (t1 - t0).toFixed(4), 'milliseconds to do something!');
//
// turn html into pure text that shows visiblity
function stripHtmlToText(html)
{
var tmp = document.createElement("DIV");
tmp.innerHTML = html;
var res = tmp.textContent || tmp.innerText || '';
res.replace('\u200B', ''); // zero width space
res = res.trim();
return res;
}
// get html
function getDomFromHtml(html)
{
var tmp = document.createElement("DIV");
tmp.innerHTML = html;
return tmp;
}
// Global to textAngular REGEXP vars for block and list elements.
var BLOCKELEMENTS = /^(address|article|aside|audio|blockquote|canvas|dd|div|dl|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hgroup|hr|noscript|ol|output|p|pre|section|table|tfoot|ul|video)$/i;
var LISTELEMENTS = /^(ul|li|ol)$/i;
var VALIDELEMENTS = /^(address|article|aside|audio|blockquote|canvas|dd|div|dl|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hgroup|hr|noscript|ol|output|p|pre|section|table|tfoot|ul|video|li)$/i;
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/Trim#Compatibility
/* istanbul ignore next: trim shim for older browsers */
if (!String.prototype.trim) {
String.prototype.trim = function () {
return this.replace(/^\s+|\s+$/g, '');
};
}
/*
Custom stylesheet for the placeholders rules.
Credit to: http://davidwalsh.name/add-rules-stylesheets
*/
var sheet, addCSSRule, removeCSSRule, _addCSSRule, _removeCSSRule, _getRuleIndex;
/* istanbul ignore else: IE <8 test*/
if(_browserDetect.ie > 8 || _browserDetect.ie === undefined){
var _sheets = document.styleSheets;
/* istanbul ignore next: preference for stylesheet loaded externally */
for(var i = 0; i < _sheets.length; i++){
if(_sheets[i].media.length === 0 || _sheets[i].media.mediaText.match(/(all|screen)/ig)){
if(_sheets[i].href){
if(_sheets[i].href.match(/textangular\.(min\.|)css/ig)){
sheet = _sheets[i];
break;
}
}
}
}
/* istanbul ignore next: preference for stylesheet loaded externally */
if(!sheet){
// this sheet is used for the placeholders later on.
sheet = (function() {
// Create the <style> tag
var style = document.createElement("style");
/* istanbul ignore else : WebKit hack :( */
if(_browserDetect.webkit) style.appendChild(document.createTextNode(""));
// Add the <style> element to the page, add as first so the styles can be overridden by custom stylesheets
document.getElementsByTagName('head')[0].appendChild(style);
return style.sheet;
})();
}
// use as: addCSSRule("header", "float: left");
addCSSRule = function(selector, rules) {
return _addCSSRule(sheet, selector, rules);
};
_addCSSRule = function(_sheet, selector, rules){
var insertIndex;
var insertedRule;
// This order is important as IE 11 has both cssRules and rules but they have different lengths - cssRules is correct, rules gives an error in IE 11
/* istanbul ignore next: browser catches */
if(_sheet.cssRules) insertIndex = Math.max(_sheet.cssRules.length - 1, 0);
else if(_sheet.rules) insertIndex = Math.max(_sheet.rules.length - 1, 0);
/* istanbul ignore else: untestable IE option */
if(_sheet.insertRule) {
_sheet.insertRule(selector + "{" + rules + "}", insertIndex);
}
else {
_sheet.addRule(selector, rules, insertIndex);
}
/* istanbul ignore next: browser catches */
if(sheet.rules) insertedRule = sheet.rules[insertIndex];
else if(sheet.cssRules) insertedRule = sheet.cssRules[insertIndex];
// return the inserted stylesheet rule
return insertedRule;
};
_getRuleIndex = function(rule, rules) {
var i, ruleIndex;
for (i=0; i < rules.length; i++) {
/* istanbul ignore else: check for correct rule */
if (rules[i].cssText === rule.cssText) {
ruleIndex = i;
break;
}
}
return ruleIndex;
};
removeCSSRule = function(rule){
_removeCSSRule(sheet, rule);
};
/* istanbul ignore next: tests are browser specific */
_removeCSSRule = function(sheet, rule){
var rules = sheet.cssRules || sheet.rules;
if(!rules || rules.length === 0) return;
var ruleIndex = _getRuleIndex(rule, rules);
if(sheet.removeRule){
sheet.removeRule(ruleIndex);
}else{
sheet.deleteRule(ruleIndex);
}
};
}
angular.module('textAngular.factories', [])
.factory('taBrowserTag', [function(){
return function(tag){
/* istanbul ignore next: ie specific test */
if(!tag) return (_browserDetect.ie <= 8)? 'P' : 'p';
else if(tag === '') return (_browserDetect.ie === undefined)? 'div' : (_browserDetect.ie <= 8)? 'P' : 'p';
else return (_browserDetect.ie <= 8)? tag.toUpperCase() : tag;
};
}]).factory('taApplyCustomRenderers', ['taCustomRenderers', 'taDOM', function(taCustomRenderers, taDOM){
return function(val){
var element = angular.element('<div></div>');
element[0].innerHTML = val;
angular.forEach(taCustomRenderers, function(renderer){
var elements = [];
// get elements based on what is defined. If both defined do secondary filter in the forEach after using selector string
if(renderer.selector && renderer.selector !== '')
elements = element.find(renderer.selector);
/* istanbul ignore else: shouldn't fire, if it does we're ignoring everything */
else if(renderer.customAttribute && renderer.customAttribute !== '')
elements = taDOM.getByAttribute(element, renderer.customAttribute);
// process elements if any found
angular.forEach(elements, function(_element){
_element = angular.element(_element);
if(renderer.selector && renderer.selector !== '' && renderer.customAttribute && renderer.customAttribute !== ''){
if(_element.attr(renderer.customAttribute) !== undefined) renderer.renderLogic(_element);
} else renderer.renderLogic(_element);
});
});
return element[0].innerHTML;
};
}]).factory('taFixChrome', function(){
// get whaterever rubbish is inserted in chrome
// should be passed an html string, returns an html string
var taFixChrome = function(html){
if(!html || !angular.isString(html) || html.length <= 0) return html;
// grab all elements with a style attibute
var spanMatch = /<([^>\/]+?)style=("([^\"]+)"|'([^']+)')([^>]*)>/ig;
var appleConvertedSpaceMatch = /<span class="Apple-converted-space">([^<]+)<\/span>/ig;
var match, styleVal, appleSpaceVal, newTag, finalHtml = '', lastIndex = 0;
// remove all the Apple-converted-space spans and replace with the content of the span
/* istanbul ignore next: apple-contereted-space span match */
while(match = appleConvertedSpaceMatch.exec(html)){
appleSpaceVal = match[1];
appleSpaceVal = appleSpaceVal.replace(/ /ig, ' ');
finalHtml += html.substring(lastIndex, match.index) + appleSpaceVal;
lastIndex = match.index + match[0].length;
}
/* istanbul ignore next: apple-contereted-space span has matched */
if (lastIndex) {
// modified....
finalHtml += html.substring(lastIndex);
html=finalHtml;
finalHtml='';
lastIndex=0;
}
while(match = spanMatch.exec(html)){
// one of the quoted values ' or "
/* istanbul ignore next: quotations match */
styleVal = match[3] || match[4];
// test for chrome inserted junk
if(styleVal && styleVal.match(/line-height: 1.[0-9]{3,12};|color: inherit; line-height: 1.1;|color: rgb\(\d{1,3}, \d{1,3}, \d{1,3}\);|background-color: rgb\(\d{1,3}, \d{1,3}, \d{1,3}\);/i)){
// replace original tag with new tag
styleVal = styleVal.replace(/( |)font-family: inherit;|( |)line-height: 1.[0-9]{3,12};|( |)color: inherit;|( |)color: rgb\(\d{1,3}, \d{1,3}, \d{1,3}\);|( |)background-color: rgb\(\d{1,3}, \d{1,3}, \d{1,3}\);/ig, '');
newTag = '<' + match[1].trim();
if(styleVal.trim().length > 0) newTag += ' style=' + match[2].substring(0,1) + styleVal + match[2].substring(0,1);
newTag += match[5].trim() + ">";
finalHtml += html.substring(lastIndex, match.index) + newTag;
lastIndex = match.index + match[0].length;
}
}
finalHtml += html.substring(lastIndex);
// only replace when something has changed, else we get focus problems on inserting lists
if(lastIndex > 0){
// replace all empty strings
return finalHtml.replace(/<span\s?>(.*?)<\/span>(<br(\/|)>|)/ig, '$1');
} else return html;
};
return taFixChrome;
}).factory('taSanitize', ['$sanitize', function taSanitizeFactory($sanitize){
var convert_infos = [
{
property: 'font-weight',
values: [ 'bold' ],
tag: 'b'
},
{
property: 'font-style',
values: [ 'italic' ],
tag: 'i'
}
];
var styleMatch = [];
for(var i = 0; i < convert_infos.length; i++){
var _partialStyle = '(' + convert_infos[i].property + ':\\s*(';
for(var j = 0; j < convert_infos[i].values.length; j++){
/* istanbul ignore next: not needed to be tested yet */
if(j > 0) _partialStyle += '|';
_partialStyle += convert_infos[i].values[j];
}
_partialStyle += ');)';
styleMatch.push(_partialStyle);
}
var styleRegexString = '(' + styleMatch.join('|') + ')';
function wrapNested(html, wrapTag) {
var depth = 0;
var lastIndex = 0;
var match;
var tagRegex = /<[^>]*>/ig;
while(match = tagRegex.exec(html)){
lastIndex = match.index;
if(match[0].substr(1, 1) === '/'){
if(depth === 0) break;
else depth--;
}else depth++;
}
return wrapTag +
html.substring(0, lastIndex) +
// get the start tags reversed - this is safe as we construct the strings with no content except the tags
angular.element(wrapTag)[0].outerHTML.substring(wrapTag.length) +
html.substring(lastIndex);
}
function transformLegacyStyles(html){
if(!html || !angular.isString(html) || html.length <= 0) return html;
var i;
var styleElementMatch = /<([^>\/]+?)style=("([^"]+)"|'([^']+)')([^>]*)>/ig;
var match, subMatch, styleVal, newTag, lastNewTag = '', newHtml, finalHtml = '', lastIndex = 0;
while(match = styleElementMatch.exec(html)){
// one of the quoted values ' or "
/* istanbul ignore next: quotations match */
styleVal = match[3] || match[4];
var styleRegex = new RegExp(styleRegexString, 'i');
// test for style values to change
if(angular.isString(styleVal) && styleRegex.test(styleVal)){
// remove build tag list
newTag = '';
// init regex here for exec
var styleRegexExec = new RegExp(styleRegexString, 'ig');
// find relevand tags and build a string of them
while(subMatch = styleRegexExec.exec(styleVal)){
for(i = 0; i < convert_infos.length; i++){
if(!!subMatch[(i*2) + 2]){
newTag += '<' + convert_infos[i].tag + '>';
}
}
}
// recursively find more legacy styles in html before this tag and after the previous match (if any)
newHtml = transformLegacyStyles(html.substring(lastIndex, match.index));
// build up html
if(lastNewTag.length > 0){
finalHtml += wrapNested(newHtml, lastNewTag);
}else finalHtml += newHtml;
// grab the style val without the transformed values
styleVal = styleVal.replace(new RegExp(styleRegexString, 'ig'), '');
// build the html tag
finalHtml += '<' + match[1].trim();
if(styleVal.length > 0) finalHtml += ' style="' + styleVal + '"';
finalHtml += match[5] + '>';
// update the start index to after this tag
lastIndex = match.index + match[0].length;
lastNewTag = newTag;
}
}
if(lastNewTag.length > 0){
finalHtml += wrapNested(html.substring(lastIndex), lastNewTag);
}
else finalHtml += html.substring(lastIndex);
return finalHtml;
}
function transformLegacyAttributes(html){
if(!html || !angular.isString(html) || html.length <= 0) return html;
// replace all align='...' tags with text-align attributes
var attrElementMatch = /<([^>\/]+?)align=("([^"]+)"|'([^']+)')([^>]*)>/ig;
var match, finalHtml = '', lastIndex = 0;
// match all attr tags
while(match = attrElementMatch.exec(html)){
// add all html before this tag
finalHtml += html.substring(lastIndex, match.index);
// record last index after this tag
lastIndex = match.index + match[0].length;
// construct tag without the align attribute
var newTag = '<' + match[1] + match[5];
// add the style attribute
if(/style=("([^"]+)"|'([^']+)')/ig.test(newTag)){
/* istanbul ignore next: quotations match */
newTag = newTag.replace(/style=("([^"]+)"|'([^']+)')/i, 'style="$2$3 text-align:' + (match[3] || match[4]) + ';"');
}else{
/* istanbul ignore next: quotations match */
newTag += ' style="text-align:' + (match[3] || match[4]) + ';"';
}
newTag += '>';
// add to html
finalHtml += newTag;
}
// return with remaining html
return finalHtml + html.substring(lastIndex);
}
return function taSanitize(unsafe, oldsafe, ignore){
// unsafe html should NEVER built into a DOM object via angular.element. This allows XSS to be inserted and run.
if ( !ignore ) {
try {
unsafe = transformLegacyStyles(unsafe);
} catch (e) {
}
}
// unsafe and oldsafe should be valid HTML strings
// any exceptions (lets say, color for example) should be made here but with great care
// setup unsafe element for modification
unsafe = transformLegacyAttributes(unsafe);
var safe;
try {
safe = $sanitize(unsafe);
// do this afterwards, then the $sanitizer should still throw for bad markup
if(ignore) safe = unsafe;
} catch (e){
safe = oldsafe || '';
}
// Do processing for <pre> tags, removing tabs and return carriages outside of them
var _preTags = safe.match(/(<pre[^>]*>.*?<\/pre[^>]*>)/ig);
var processedSafe = safe.replace(/(&#(9|10);)*/ig, '');
var re = /<pre[^>]*>.*?<\/pre[^>]*>/ig;
var index = 0;
var lastIndex = 0;
var origTag;
safe = '';
while((origTag = re.exec(processedSafe)) !== null && index < _preTags.length){
safe += processedSafe.substring(lastIndex, origTag.index) + _preTags[index];
lastIndex = origTag.index + origTag[0].length;
index++;
}
return safe + processedSafe.substring(lastIndex);
};
}]).factory('taToolExecuteAction', ['$q', '$log', function($q, $log){
// this must be called on a toolScope or instance
return function(editor){
if(editor !== undefined) this.$editor = function(){ return editor; };
var deferred = $q.defer(),
promise = deferred.promise,
_editor = this.$editor();
// pass into the action the deferred function and also the function to reload the current selection if rangy available
var result;
try{
result = this.action(deferred, _editor.startAction());
// We set the .finally callback here to make sure it doesn't get executed before any other .then callback.
promise['finally'](function(){
_editor.endAction.call(_editor);
});
}catch(exc){
$log.error(exc);
}
if(result || result === undefined){
// if true or undefined is returned then the action has finished. Otherwise the deferred action will be resolved manually.
deferred.resolve();
}
};
}]);
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){
// NOTE: here we are dealing with the html directly from the browser and not the html the user sees.
// IF you want to modify the html the user sees, do it when the user does a switchView
taDefaultWrap = taBrowserTag(taDefaultWrap);
return function(command, showUI, options, defaultTagAttributes){
var i, $target, html, _nodes, next, optionsTagName, selectedElement, ourSelection;
var defaultWrapper = angular.element('<' + taDefaultWrap + '>');
try{
if (taSelection.getSelection) {
ourSelection = taSelection.getSelection();
}
selectedElement = taSelection.getSelectionElement();
//console.log('selectedElement', selectedElement);
// special checks and fixes when we are selecting the whole container
var __h, _innerNode;
/* istanbul ignore next */
if (selectedElement.tagName.toLowerCase() === 'div' &&
/taTextElement.+/.test(selectedElement.id) &&
ourSelection && ourSelection.start &&
ourSelection.start.offset === 1 &&
ourSelection.end.offset === 1) {
// opps we are actually selecting the whole container!
//console.log('selecting whole container!');
__h = selectedElement.innerHTML;
if (/<br>/i.test(__h)) {
// Firefox adds <br>'s and so we remove the <br>
__h = __h.replace(/<br>/i, '​'); // no space-space
}
if (/<br\/>/i.test(__h)) {
// Firefox adds <br/>'s and so we remove the <br/>
__h = __h.replace(/<br\/>/i, '​'); // no space-space
}
// remove stacked up <span>'s
if (/<span>(<span>)+/i.test(__h)) {
__h = __.replace(/<span>(<span>)+/i, '<span>');
}
// remove stacked up </span>'s
if (/<\/span>(<\/span>)+/i.test(__h)) {
__h = __.replace(/<\/span>(<\/span>)+/i, '<\/span>');
}
if (/<span><\/span>/i.test(__h)) {
// if we end up with a <span></span> here we remove it...
__h = __h.replace(/<span><\/span>/i, '');
}
//console.log('inner whole container', selectedElement.childNodes);
_innerNode = '<div>' + __h + '</div>';
selectedElement.innerHTML = _innerNode;
//console.log('childNodes:', selectedElement.childNodes);
taSelection.setSelectionToElementEnd(selectedElement.childNodes[0]);
selectedElement = taSelection.getSelectionElement();
} else if (selectedElement.tagName.toLowerCase() === 'span' &&
ourSelection && ourSelection.start &&
ourSelection.start.offset === 1 &&
ourSelection.end.offset === 1) {
// just a span -- this is a problem...
//console.log('selecting span!');
__h = selectedElement.innerHTML;
if (/<br>/i.test(__h)) {
// Firefox adds <br>'s and so we remove the <br>
__h = __h.replace(/<br>/i, '​'); // no space-space
}
if (/<br\/>/i.test(__h)) {
// Firefox adds <br/>'s and so we remove the <br/>
__h = __h.replace(/<br\/>/i, '​'); // no space-space
}
// remove stacked up <span>'s
if (/<span>(<span>)+/i.test(__h)) {
__h = __.replace(/<span>(<span>)+/i, '<span>');
}
// remove stacked up </span>'s
if (/<\/span>(<\/span>)+/i.test(__h)) {
__h = __.replace(/<\/span>(<\/span>)+/i, '<\/span>');
}
if (/<span><\/span>/i.test(__h)) {
// if we end up with a <span></span> here we remove it...
__h = __h.replace(/<span><\/span>/i, '');
}
//console.log('inner span', selectedElement.childNodes);
// we wrap this in a <div> because otherwise the browser get confused when we attempt to select the whole node
// and the focus is not set correctly no matter what we do
_innerNode = '<div>' + __h + '</div>';
selectedElement.innerHTML = _innerNode;
taSelection.setSelectionToElementEnd(selectedElement.childNodes[0]);
selectedElement = taSelection.getSelectionElement();
//console.log(selectedElement.innerHTML);
} else if (selectedElement.tagName.toLowerCase() === 'p' &&
ourSelection && ourSelection.start &&
ourSelection.start.offset === 1 &&
ourSelection.end.offset === 1) {
//console.log('p special');
// we need to remove the </br> that firefox adds!
__h = selectedElement.innerHTML;
if (/<br>/i.test(__h)) {
// Firefox adds <br>'s and so we remove the <br>
__h = __h.replace(/<br>/i, '​'); // no space-space
}
selectedElement.innerHTML = __h;
}
}catch(e){
/* istanbul ignore next */
// we ignore errors from testing...
if (e.codeName !== 'INDEX_SIZE_ERR') console.error(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();
//console.log('default wrap behavior', _nodes);
if(_nodes.length === 0) {
// no nodes at all....
_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;
}
}
}
// remove any duplicates from the array of _nodes!
_nodes = _nodes.filter(function(value, index, self) {
return self.indexOf(value) === index;
});
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]);
// looses focus when we have the whole container selected and no text!
// refocus on the shown display element, this fixes a bug when using firefox
$target[0].focus();
//console.log($document[0].activeElement.childNodes);
//$document[0].activeElement.childNodes[0].focus();
return;
}else if(command.toLowerCase() === 'createlink'){
var tagBegin = '<a href="' + options + '" target="' +
(defaultTagAttributes.a.target ? defaultTagAttributes.a.target : '') +
'">',
tagEnd = '</a>',
_selection = taSelection.getSelection();
if(_selection.collapsed){
// insert text at selection, then select then just let normal exec-command run
taSelection.insertHtml(tagBegin + options + tagEnd, topNode);
}else if(rangy.getSelection().getRangeAt(0).canSurroundContents()){
var node = angular.element(tagBegin + tagEnd)[0];
rangy.getSelection().getRangeAt(0).surroundContents(node);
}
return;
}else if(command.toLowerCase() === 'inserthtml'){
taSelection.insertHtml(options, topNode);
return;
}
}
try{
$document[0].execCommand(command, showUI, options);
}catch(e){
/* istanbul ignore next */
// we ignore errors from testing...
if (e.codeName !== 'INDEX_SIZE_ERR') console.error(e);
}
};
};
}]).service('taSelection', ['$document', 'taDOM',
/* istanbul ignore next: all browser specifics and PhantomJS dosen't seem to support half of it */
function($document, taDOM){
// need to dereference the document else the calls don't work correctly
var _document = $document[0];
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;
// Node.TEXT_NODE === 3
// Node.ELEMENT_NODE === 1
// Node.COMMENT_NODE === 8
// 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, 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
//console.log('setStartAfter', parent);
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;
});
angular.module('textAngular.validators', [])
.directive('taMaxText', function(){
return {
restrict: 'A',
require: 'ngModel',
link: function(scope, elem, attrs, ctrl){
var max = parseInt(scope.$eval(attrs.taMaxText));
if (isNaN(max)){
throw('Max text must be an integer');
}
attrs.$observe('taMaxText', function(value){
max = parseInt(value);
if (isNaN(max)){
throw('Max text must be an integer');
}
if (ctrl.$dirty){
ctrl.$validate();
}
});
ctrl.$validators.taMaxText = function(viewValue){
var source = angular.element('<div/>');
source.html(viewValue);
return source.text().length <= max;
};
}
};
}).directive('taMinText', function(){
return {
restrict: 'A',
require: 'ngModel',
link: function(scope, elem, attrs, ctrl){
var min = parseInt(scope.$eval(attrs.taMinText));
if (isNaN(min)){
throw('Min text must be an integer');
}
attrs.$observe('taMinText', function(value){
min = parseInt(value);
if (isNaN(min)){
throw('Min text must be an integer');
}
if (ctrl.$dirty){
ctrl.$validate();
}
});
ctrl.$validators.taMinText = function(viewValue){
var source = angular.element('<div/>');
source.html(viewValue);
return !source.text().length || source.text().length >= min;
};
}
};
});
angular.module('textAngular.taBind', ['textAngular.factories', 'textAngular.DOM'])
.service('_taBlankTest', [function(){
return function(_blankVal){
// we radically restructure this code.
// what was here before was incredibly fragile.
// What we do now is to check that the html is non-blank visually
// which we check by looking at html->text
if(!_blankVal) return true;
// find first non-tag match - ie start of string or after tag that is not whitespace
// var t0 = performance.now();
// Takes a small fraction of a mSec to do this...
var _text_ = stripHtmlToText(_blankVal);
// var t1 = performance.now();
// console.log('Took', (t1 - t0).toFixed(4), 'milliseconds to generate:');
if (_text_=== '') {
// img generates a visible item so it is not blank!
if (/<img[^>]+>/.test(_blankVal)) {
return false;
}
return true;
} else {
return false;
}
};
}])
.directive('taButton', [function(){
return {
link: function(scope, element, attrs){
element.attr('unselectable', 'on');
element.on('mousedown', function(e, eventData){
/* istanbul ignore else: this is for catching the jqLite testing*/
if(eventData) angular.extend(e, eventData);
// this prevents focusout from firing on the editor when clicking toolbar buttons
e.preventDefault();
return false;
});
}
};
}])
.directive('taBind', [
'taSanitize', '$timeout', '$document', 'taFixChrome', 'taBrowserTag',
'taSelection', 'taSelectableElements', 'taApplyCustomRenderers', 'taOptions',
'_taBlankTest', '$parse', 'taDOM', 'textAngularManager',
function(
taSanitize, $timeout, $document, taFixChrome, taBrowserTag,
taSelection, taSelectableElements, taApplyCustomRenderers, taOptions,
_taBlankTest, $parse, taDOM, textAngularManager){
// Uses for this are textarea or input with ng-model and ta-bind='text'
// OR any non-form element with contenteditable="contenteditable" ta-bind="html|text" ng-model
return {
priority: 2, // So we override validators correctly
require: ['ngModel','?ngModelOptions'],
link: function(scope, element, attrs, controller){
var ngModel = controller[0];
var ngModelOptions = controller[1] || {};
// the option to use taBind on an input or textarea is required as it will sanitize all input into it correctly.
var _isContentEditable = element.attr('contenteditable') !== undefined && element.attr('contenteditable');
var _isInputFriendly = _isContentEditable || element[0].tagName.toLowerCase() === 'textarea' || element[0].tagName.toLowerCase() === 'input';
var _isReadonly = false;
var _focussed = false;
var _skipRender = false;
var _disableSanitizer = attrs.taUnsafeSanitizer || taOptions.disableSanitizer;
var _lastKey;
// see http://www.javascripter.net/faq/keycodes.htm for good information
// NOTE Mute On|Off 173 (Opera MSIE Safari Chrome) 181 (Firefox)
// BLOCKED_KEYS are special keys...
// Tab, pause/break, CapsLock, Esc, Page Up, End, Home,
// Left arrow, Up arrow, Right arrow, Down arrow, Insert, Delete,
// f1, f2, f3, f4, f5, f6, f7, f8, f9, f10, f11, f12
// NumLock, ScrollLock
var BLOCKED_KEYS = /^(9|19|20|27|33|34|35|36|37|38|39|40|45|112|113|114|115|116|117|118|119|120|121|122|123|144|145)$/i;
// UNDO_TRIGGER_KEYS - spaces, enter, delete, backspace, all punctuation
// Backspace, Enter, Space, Delete, (; :) (Firefox), (= +) (Firefox),
// Numpad +, Numpad -, (; :), (= +),
// (, <), (- _), (. >), (/ ?), (` ~), ([ {), (\ |), (] }), (' ")
// NOTE - Firefox: 173 = (- _) -- adding this to UNDO_TRIGGER_KEYS
var UNDO_TRIGGER_KEYS = /^(8|13|32|46|59|61|107|109|173|186|187|188|189|190|191|192|219|220|221|222)$/i;
var _pasteHandler;
// defaults to the paragraph element, but we need the line-break or it doesn't allow you to type into the empty element
// non IE is '<p><br/></p>', ie is '<p></p>' as for once IE gets it correct...
var _defaultVal, _defaultTest;
var _CTRL_KEY = 0x0001;
var _META_KEY = 0x0002;
var _ALT_KEY = 0x0004;
var _SHIFT_KEY = 0x0008;
// map events to special keys...
// mappings is an array of maps from events to specialKeys as declared in textAngularSetup
var _keyMappings = [
// ctrl/command + z
{
specialKey: 'UndoKey',
forbiddenModifiers: _ALT_KEY + _SHIFT_KEY,
mustHaveModifiers: [_META_KEY + _CTRL_KEY],
keyCode: 90
},
// ctrl/command + shift + z
{
specialKey: 'RedoKey',
forbiddenModifiers: _ALT_KEY,
mustHaveModifiers: [_META_KEY + _CTRL_KEY, _SHIFT_KEY],
keyCode: 90
},
// ctrl/command + y
{
specialKey: 'RedoKey',
forbiddenModifiers: _ALT_KEY + _SHIFT_KEY,
mustHaveModifiers: [_META_KEY + _CTRL_KEY],
keyCode: 89
},
// TabKey
{
specialKey: 'TabKey',
forbiddenModifiers: _META_KEY + _SHIFT_KEY + _ALT_KEY + _CTRL_KEY,
mustHaveModifiers: [],
keyCode: 9
},
// shift + TabKey
{
specialKey: 'ShiftTabKey',
forbiddenModifiers: _META_KEY + _ALT_KEY + _CTRL_KEY,
mustHaveModifiers: [_SHIFT_KEY],
keyCode: 9
}
];
function _mapKeys(event) {
var specialKey;
_keyMappings.forEach(function (map){
if (map.keyCode === event.keyCode) {
var netModifiers = (event.metaKey ? _META_KEY: 0) +
(event.ctrlKey ? _CTRL_KEY: 0) +
(event.shiftKey ? _SHIFT_KEY: 0) +
(event.altKey ? _ALT_KEY: 0);
if (map.forbiddenModifiers & netModifiers) return;
if (map.mustHaveModifiers.every(function (modifier) { return netModifiers & modifier; })){
specialKey = map.specialKey;
}
}
});
return specialKey;
}
// set the default to be a paragraph value
if(attrs.taDefaultWrap === undefined) attrs.taDefaultWrap = 'p';
/* istanbul ignore next: ie specific test */
if(attrs.taDefaultWrap === ''){
_defaultVal = '';
_defaultTest = (_browserDetect.ie === undefined)? '<div><br></div>' : (_browserDetect.ie >= 11)? '<p><br></p>' : (_browserDetect.ie <= 8)? '<P> </P>' : '<p> </p>';
}else{
_defaultVal = (_brows