UNPKG

bauhausjs

Version:
1,233 lines (1,164 loc) 120 kB
/* @license textAngular Author : Austin Anderson License : 2013 MIT Version 1.3.8 See README.md or https://github.com/fraywing/textAngular/wiki for requirements and use. */ (function(){ // encapsulate all variables so they don't become global vars "Use Strict"; // 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) }; // fix a webkit bug, see: https://gist.github.com/shimondoodkin/1081133 // this is set true when a blur occurs as the blur of the ta-bind triggers before the click var globalContentEditableBlur = false; /* istanbul ignore next: Browser Un-Focus fix for webkit */ if(_browserDetect.webkit) { document.addEventListener("mousedown", function(_event){ var e = _event || window.event; var curelement = e.target; if(globalContentEditableBlur && curelement !== null){ var isEditable = false; var tempEl = curelement; while(tempEl !== null && tempEl.tagName.toLowerCase() !== 'html' && !isEditable){ isEditable = tempEl.contentEditable === 'true'; tempEl = tempEl.parentNode; } if(!isEditable){ document.getElementById('textAngular-editableFix-010203040506070809').setSelectionRange(0, 0); // set caret focus to an element that handles caret focus correctly. curelement.focus(); // focus the wanted element. if (curelement.select) { curelement.select(); // use select to place cursor for input elements. } } } globalContentEditableBlur = false; }, false); // add global click handler angular.element(document).ready(function () { angular.element(document.body).append(angular.element('<input id="textAngular-editableFix-010203040506070809" class="ta-hidden-input" unselectable="on" tabIndex="-1">')); }); } // Gloabl 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, ''); }; } // tests against the current jqLite/jquery implementation if this can be an element function validElementString(string){ try{ return angular.element(string).length !== 0; }catch(any){ return false; } } /* Custom stylesheet for the placeholders rules. Credit to: http://davidwalsh.name/add-rules-stylesheets */ var sheet, addCSSRule, removeCSSRule, _addCSSRule, _removeCSSRule; /* 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; // 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 else: firefox catch */ 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); } // return the index of the stylesheet rule return insertIndex; }; removeCSSRule = function(index){ _removeCSSRule(sheet, index); }; _removeCSSRule = function(sheet, index){ /* istanbul ignore else: untestable IE option */ if(sheet.removeRule){ sheet.removeRule(index); }else{ sheet.deleteRule(index); } }; } 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 match, styleVal, newTag, 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.428571429;|color: inherit; line-height: 1.1;/i)){ // replace original tag with new tag styleVal = styleVal.replace(/( |)font-family: inherit;|( |)line-height: 1.428571429;|( |)line-height:1.1;|( |)color: inherit;/ig, ''); newTag = '<' + match[1].trim(); if(styleVal.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', 'taDOM', function taSanitizeFactory($sanitize, taDOM){ 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(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, j; var styleElementMatch = /<([^>\/]+?)style=("([^"]+)"|'([^']+)')([^>]*)>/ig; var match, 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 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); 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(); promise['finally'](function(){ _editor.endAction.call(_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()); }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){ 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; }); 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.$setViewValue(ctrl.$viewValue); } }); function validator (viewValue){ var source = angular.element('<div/>'); source.html(viewValue); var length = source.text().length; if (length <= max){ ctrl.$setValidity('taMaxText', true); return viewValue; } else{ ctrl.$setValidity('taMaxText', false); return undefined; } } ctrl.$parsers.unshift(validator); } }; }).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.$setViewValue(ctrl.$viewValue); } }); function validator (viewValue){ var source = angular.element('<div/>'); source.html(viewValue); var length = source.text().length; if (!length || length >= min){ ctrl.$setValidity('taMinText', true); return viewValue; } else{ ctrl.$setValidity('taMinText', false); return undefined; } } ctrl.$parsers.unshift(validator); } }; }); angular.module('textAngular.taBind', ['textAngular.factories', 'textAngular.DOM']) .service('_taBlankTest', [function(){ var INLINETAGS_NONBLANK = /<(a|abbr|acronym|bdi|bdo|big|cite|code|del|dfn|img|ins|kbd|label|map|mark|q|ruby|rp|rt|s|samp|time|tt|var)[^>]*(>|$)/i; return function(_defaultTest){ return function(_blankVal){ if(!_blankVal) return true; // find first non-tag match - ie start of string or after tag that is not whitespace var _firstMatch = /(^[^<]|>)[^<]/i.exec(_blankVal); var _firstTagIndex; if(!_firstMatch){ // find the end of the first tag removing all the // Don't do a global replace as that would be waaayy too long, just replace the first 4 occurences should be enough _blankVal = _blankVal.toString().replace(/="[^"]*"/i, '').replace(/="[^"]*"/i, '').replace(/="[^"]*"/i, '').replace(/="[^"]*"/i, ''); _firstTagIndex = _blankVal.indexOf('>'); }else{ _firstTagIndex = _firstMatch.index; } _blankVal = _blankVal.trim().substring(_firstTagIndex, _firstTagIndex + 100); // check for no tags entry if(/^[^<>]+$/i.test(_blankVal)) return false; // this regex is to match any number of whitespace only between two tags if (_blankVal.length === 0 || _blankVal === _defaultTest || /^>(\s|&nbsp;)*<\/[^>]+>$/ig.test(_blankVal)) return true; // this regex tests if there is a tag followed by some optional whitespace and some text after that else if (/>\s*[^\s<]/i.test(_blankVal) || INLINETAGS_NONBLANK.test(_blankVal)) return false; else return true; }; }; }]) .directive('taBind', [ 'taSanitize', '$timeout', '$window', '$document', 'taFixChrome', 'taBrowserTag', 'taSelection', 'taSelectableElements', 'taApplyCustomRenderers', 'taOptions', '_taBlankTest', '$parse', 'taDOM', function( taSanitize, $timeout, $window, $document, taFixChrome, taBrowserTag, taSelection, taSelectableElements, taApplyCustomRenderers, taOptions, _taBlankTest, $parse, taDOM){ // 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 { require: 'ngModel', link: function(scope, element, attrs, ngModel){ // 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; 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; var UNDO_TRIGGER_KEYS = /^(8|13|32|46|59|61|107|109|186|187|188|189|190|191|192|219|220|221|222)$/i; // spaces, enter, delete, backspace, all punctuation 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; // 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>&nbsp;</P>' : '<p>&nbsp;</p>'; }else{ _defaultVal = (_browserDetect.ie === undefined || _browserDetect.ie >= 11)? '<' + attrs.taDefaultWrap + '><br></' + attrs.taDefaultWrap + '>' : (_browserDetect.ie <= 8)? '<' + attrs.taDefaultWrap.toUpperCase() + '></' + attrs.taDefaultWrap.toUpperCase() + '>' : '<' + attrs.taDefaultWrap + '></' + attrs.taDefaultWrap + '>'; _defaultTest = (_browserDetect.ie === undefined || _browserDetect.ie >= 11)? '<' + attrs.taDefaultWrap + '><br></' + attrs.taDefaultWrap + '>' : (_browserDetect.ie <= 8)? '<' + attrs.taDefaultWrap.toUpperCase() + '>&nbsp;</' + attrs.taDefaultWrap.toUpperCase() + '>' : '<' + attrs.taDefaultWrap + '>&nbsp;</' + attrs.taDefaultWrap + '>'; } var _blankTest = _taBlankTest(_defaultTest); if(attrs.taPaste) _pasteHandler = $parse(attrs.taPaste); element.addClass('ta-bind'); var _undoKeyupTimeout; scope['$undoManager' + (attrs.id || '')] = ngModel.$undoManager = { _stack: [], _index: 0, _max: 1000, push: function(value){ if((typeof value === "undefined" || value === null) || ((typeof this.current() !== "undefined" && this.current() !== null) && value === this.current())) return value; if(this._index < this._stack.length - 1){ this._stack = this._stack.slice(0,this._index+1); } this._stack.push(value); if(_undoKeyupTimeout) $timeout.cancel(_undoKeyupTimeout); if(this._stack.length > this._max) this._stack.shift(); this._index = this._stack.length - 1; return value; }, undo: function(){ return this.setToIndex(this._index-1); }, redo: function(){ return this.setToIndex(this._index+1); }, setToIndex: function(index){ if(index < 0 || index > this._stack.length - 1){ return undefined; } this._index = index; return this.current(); }, current: function(){ return this._stack[this._index]; } }; var _undo = scope['$undoTaBind' + (attrs.id || '')] = function(){ /* istanbul ignore else: can't really test it due to all changes being ignored as well in readonly */ if(!_isReadonly && _isContentEditable){ var content = ngModel.$undoManager.undo(); if(typeof content !== "undefined" && content !== null){ _setInnerHTML(content); _setViewValue(content, false); /* istanbul ignore else: browser catch */ if(element[0].childNodes.length) taSelection.setSelectionToElementEnd(element[0].childNodes[element[0].childNodes.length-1]); else taSelection.setSelectionToElementEnd(element[0]); } } }; var _redo = scope['$redoTaBind' + (attrs.id || '')] = function(){ /* istanbul ignore else: can't really test it due to all changes being ignored as well in readonly */ if(!_isReadonly && _isContentEditable){ var content = ngModel.$undoManager.redo(); if(typeof content !== "undefined" && content !== null){ _setInnerHTML(content); _setViewValue(content, false); /* istanbul ignore else: browser catch */ if(element[0].childNodes.length) taSelection.setSelectionToElementEnd(element[0].childNodes[element[0].childNodes.length-1]); else taSelection.setSelectionToElementEnd(element[0]); } } }; // in here we are undoing the converts used elsewhere to prevent the < > and & being displayed when they shouldn't in the code. var _compileHtml = function(){ if(_isContentEditable) return element[0].innerHTML; if(_isInputFriendly) return element.val(); throw ('textAngular Error: attempting to update non-editable taBind'); }; var _setViewValue = function(_val, triggerUndo){ _skipRender = true; if(typeof triggerUndo === "undefined" || triggerUndo === null) triggerUndo = true && _isContentEditable; // if not contentEditable then the native undo/redo is fine if(typeof _val === "undefined" || _val === null) _val = _compileHtml(); if(_blankTest(_val)){ // this avoids us from tripping the ng-pristine flag if we click in and out with out typing if(ngModel.$viewValue !== '') ngModel.$setViewValue(''); if(triggerUndo && ngModel.$undoManager.current() !== '') ngModel.$undoManager.push(''); }else{ _reApplyOnSelectorHandlers(); if(ngModel.$viewValue !== _val){ ngModel.$setViewValue(_val); if(triggerUndo) ngModel.$undoManager.push(_val); } } }; //used for updating when inserting wrapped elements scope['updateTaBind' + (attrs.id || '')] = function(){ if(!_isReadonly) _setViewValue(); }; //this code is used to update the models when data is entered/deleted if(_isInputFriendly){ scope.events = {}; if(!_isContentEditable){ // if a textarea or input just add in change and blur handlers, everything else is done by angulars input directive element.on('change blur', scope.events.change = scope.events.blur = function(){ if(!_isReadonly) ngModel.$setViewValue(_compileHtml()); }); element.on('keydown', scope.events.keydown = function(event, eventData){ /* istanbul ignore else: this is for catching the jqLite testing*/ if(eventData) angular.extend(event, eventData); // Reference to http://stackoverflow.com/questions/6140632/how-to-handle-tab-in-textarea /* istanbul ignore else: otherwise normal functionality */ if(event.keyCode === 9){ // tab was pressed // get caret position/selection var start = this.selectionStart; var end = this.selectionEnd; var value = element.val(); if(event.shiftKey){ // find \t var _linebreak = value.lastIndexOf('\n', start), _tab = value.lastIndexOf('\t', start); if(_tab !== -1 && _tab >= _linebreak){ // set textarea value to: text before caret + tab + text after caret element.val(value.substring(0, _tab) + value.substring(_tab + 1)); // put caret at right position again (add one for the tab) this.selectionStart = this.selectionEnd = start - 1; } }else{ // set textarea value to: text before caret + tab + text after caret element.val(value.substring(0, start) + "\t" + value.substring(end)); // put caret at right position again (add one for the tab) this.selectionStart = this.selectionEnd = start + 1; } // prevent the focus lose event.preventDefault(); } }); var _repeat = function(string, n){ var result = ''; for(var _n = 0; _n < n; _n++) result += string; return result; }; var recursiveListFormat = function(listNode, tablevel){ var _html = '', _children = listNode.childNodes; tablevel++; _html += _repeat('\t', tablevel-1) + listNode.outerHTML.substring(0, listNode.outerHTML.indexOf('<li')); for(var _i = 0; _i < _children.length; _i++){ /* istanbul ignore next: browser catch */ if(!_children[_i].outerHTML) continue; if(_children[_i].nodeName.toLowerCase() === 'ul' || _children[_i].nodeName.toLowerCase() === 'ol') _html += '\n' + recursiveListFormat(_children[_i], tablevel); else _html += '\n' + _repeat('\t', tablevel) + _children[_i].outerHTML; } _html += '\n' + _repeat('\t', tablevel-1) + listNode.outerHTML.substring(listNode.outerHTML.lastIndexOf('<')); return _html; }; ngModel.$formatters.unshift(function(htmlValue){ // tabulate the HTML so it looks nicer var _children = angular.element('<div>' + htmlValue + '</div>')[0].childNodes; if(_children.length > 0){ htmlValue = ''; for(var i = 0; i < _children.length; i++){ /* istanbul ignore next: browser catch */ if(!_children[i].outerHTML) continue; if(htmlValue.length > 0) htmlValue += '\n'; if(_children[i].nodeName.toLowerCase() === 'ul' || _children[i].nodeName.toLowerCase() === 'ol') htmlValue += '' + recursiveListFormat(_children[i], 0); else htmlValue += '' + _children[i].outerHTML; } } return htmlValue; }); }else{ // all the code specific to contenteditable divs var _processingPaste = false; /* istanbul ignore next: phantom js cannot test this for some reason */ var processpaste = function(text) { /* istanbul ignore else: don't care if nothing pasted */ if(text && text.trim().length){ // test paste from word/microsoft product if(text.match(/class=["']*Mso(Normal|List)/i)){ var textFragment = text.match(/<!--StartFragment-->([\s\S]*?)<!--EndFragment-->/i); if(!textFr