Skip to content

Commit

Permalink
feat(taBind): Textarea basic formatting of html with tabs and newlines
Browse files Browse the repository at this point in the history
Fixes #307
  • Loading branch information
SimeonC authored and SimeonC committed Jan 21, 2015
1 parent c4b7bdd commit f0d3baf
Show file tree
Hide file tree
Showing 11 changed files with 320 additions and 3 deletions.
4 changes: 2 additions & 2 deletions dist/textAngular.min.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions karma-jqlite.conf.js
Expand Up @@ -19,6 +19,7 @@ module.exports = function (config) {
'src/textAngular-sanitize.js',
'src/textAngularSetup.js',
'src/textAngular.js',
'test/helpers.js',
'bower_components/jquery/jquery.min.js',
'test/**/*.spec.js'
],
Expand Down
1 change: 1 addition & 0 deletions karma-jquery.conf.js
Expand Up @@ -20,6 +20,7 @@ module.exports = function (config) {
'src/textAngular-sanitize.js',
'src/textAngularSetup.js',
'src/textAngular.js',
'test/helpers.js',
'test/**/*.spec.js'
],

Expand Down
10 changes: 10 additions & 0 deletions lib/factories.js
Expand Up @@ -132,6 +132,16 @@ angular.module('textAngular.factories', [])
} catch (e){
safe = oldsafe || '';
}
var _preTags = safe.match(/(<pre[^>]*>.*?<\/pre[^>]*>)/ig);
safe = safe.replace(/(&#(9|10);)*/ig, '');
var re = /<pre[^>]*>.*?<\/pre[^>]*>/i;
var index = 0;
var origTag;
while((origTag = re.exec(safe)) !== null && index < _preTags.length){
safe = safe.substring(0, origTag.index) + _preTags[index] + safe.substring(origTag.index + origTag[0].length);
re.lastIndex = Math.max(0, re.lastIndex + _preTags[index].length - origTag[0].length);
index++;
}
return safe;
};
}]).factory('taToolExecuteAction', ['$q', '$log', function($q, $log){
Expand Down
73 changes: 73 additions & 0 deletions lib/taBind.js
Expand Up @@ -160,6 +160,79 @@ angular.module('textAngular.taBind', ['textAngular.factories', 'textAngular.DOM'
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.push(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 waitforpastedata = function(savedcontent, _savedSelection, cb) {
Expand Down
83 changes: 83 additions & 0 deletions src/textAngular.js
Expand Up @@ -311,6 +311,16 @@ angular.module('textAngular.factories', [])
} catch (e){
safe = oldsafe || '';
}
var _preTags = safe.match(/(<pre[^>]*>.*?<\/pre[^>]*>)/ig);
safe = safe.replace(/(&#(9|10);)*/ig, '');
var re = /<pre[^>]*>.*?<\/pre[^>]*>/i;
var index = 0;
var origTag;
while((origTag = re.exec(safe)) !== null && index < _preTags.length){
safe = safe.substring(0, origTag.index) + _preTags[index] + safe.substring(origTag.index + origTag[0].length);
re.lastIndex = Math.max(0, re.lastIndex + _preTags[index].length - origTag[0].length);
index++;
}
return safe;
};
}]).factory('taToolExecuteAction', ['$q', '$log', function($q, $log){
Expand Down Expand Up @@ -953,6 +963,79 @@ angular.module('textAngular.taBind', ['textAngular.factories', 'textAngular.DOM'
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.push(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 waitforpastedata = function(savedcontent, _savedSelection, cb) {
Expand Down
10 changes: 10 additions & 0 deletions test/helpers.js
@@ -0,0 +1,10 @@
var triggerEvent = function(event, element, options){
var event;
if(angular.element === jQuery){
event = jQuery.Event(event);
angular.extend(event, options);
element.triggerHandler(event);
}else{
element.triggerHandler(event, options);
}
};
36 changes: 36 additions & 0 deletions test/taBind/taBind.$formatters.spec.js
@@ -0,0 +1,36 @@
describe('taBind.$formatters', function () {
'use strict';
var $rootScope, element;
beforeEach(module('textAngular'));
beforeEach(inject(function(_$rootScope_, $compile){
$rootScope = _$rootScope_;
$rootScope.html = '';
element = $compile('<textarea ta-bind ng-model="html"></textarea>')($rootScope);
}));
afterEach(inject(function($document){
$document.find('body').html('');
}));

describe('should format textarea html for readability', function(){
it('adding newlines after immediate child tags', function(){
$rootScope.html = '<p>Test Line 1</p><div>Test Line 2</div><span>Test Line 3</span>';
$rootScope.$digest();
expect(element.val()).toBe('<p>Test Line 1</p>\n<div>Test Line 2</div>\n<span>Test Line 3</span>');
});
it('ignore nested tags', function(){
$rootScope.html = '<p><b>Test</b> Line 1</p><div>Test <i>Line</i> 2</div><span>Test Line <u>3</u></span>';
$rootScope.$digest();
expect(element.val()).toBe('<p><b>Test</b> Line 1</p>\n<div>Test <i>Line</i> 2</div>\n<span>Test Line <u>3</u></span>');
});
it('tab out li elements', function(){
$rootScope.html = '<ul><li>Test Line 1</li><li>Test Line 2</li><li>Test Line 3</li></ul>';
$rootScope.$digest();
expect(element.val()).toBe('<ul>\n\t<li>Test Line 1</li>\n\t<li>Test Line 2</li>\n\t<li>Test Line 3</li>\n</ul>');
});
it('handle nested lists', function(){
$rootScope.html = '<ol><li>Test Line 1</li><ul><li>Nested Line 1</li><li>Nested Line 2</li></ul><li>Test Line 3</li></ol>';
$rootScope.$digest();
expect(element.val()).toBe('<ol>\n\t<li>Test Line 1</li>\n\t<ul>\n\t\t<li>Nested Line 1</li>\n\t\t<li>Nested Line 2</li>\n\t</ul>\n\t<li>Test Line 3</li>\n</ol>');
});
});
});
41 changes: 41 additions & 0 deletions test/taBind/taBind.events.spec.js
Expand Up @@ -303,4 +303,45 @@ describe('taBind.events', function () {
expect(test).toBe(targetElement[0]);
});
});

describe('handles tab key in textarea mode', function(){
var $rootScope, element;
beforeEach(inject(function (_$compile_, _$rootScope_) {
$rootScope = _$rootScope_;
$rootScope.html = '';
element = _$compile_('<textarea ta-bind ng-model="html"></div>')($rootScope);
$rootScope.html = '<p><a>Test Contents</a><img/></p>';
$rootScope.$digest();
}));

it('should insert \\t on tab key', function(){
element.val('<p><a>Test Contents</a><img/></p>');
triggerEvent('keydown', element, {keyCode: 9});
expect(element.val()).toBe('\t<p><a>Test Contents</a><img/></p>');
});

describe('should remove \\t on shift-tab', function(){
it('should remove \\t at start of line', function(){
element.val('\t<p><a>Test Contents</a><img/></p>');
triggerEvent('keydown', element, {keyCode: 9, shiftKey: true});
expect(element.val()).toBe('<p><a>Test Contents</a><img/></p>');
});
it('should remove only one \\t at start of line', function(){
element.val('\t\t<p><a>Test Contents</a><img/></p>');
triggerEvent('keydown', element, {keyCode: 9, shiftKey: true});
expect(element.val()).toBe('\t<p><a>Test Contents</a><img/></p>');
});
it('should do nothing if no \\t at start of line', function(){
element.val('<p><a>Test Contents</a><img/></p>');
triggerEvent('keydown', element, {keyCode: 9, shiftKey: true});
expect(element.val()).toBe('<p><a>Test Contents</a><img/></p>');
});
// Issue with phantomjs not setting target to end? It works just not in tests.
it('should remove only one \\t at start of the current line', function(){
element.val('\t\t<p><a>Test Contents</a><img/></p>\n\t\t<p><a>Test Contents</a><img/></p>');
triggerEvent('keydown', element, {keyCode: 9, shiftKey: true});
expect(element.val()).toBe('\t<p><a>Test Contents</a><img/></p>\n\t\t<p><a>Test Contents</a><img/></p>');
});
});
});
});
62 changes: 62 additions & 0 deletions test/taSanitize.spec.js
Expand Up @@ -37,6 +37,68 @@ describe('taSanitize', function(){
}));
});

describe('clears out unnecessary &#10; &#9;', function(){
it('at start both', inject(function(taSanitize){
var result = taSanitize('<p>&#10;&#9;Test Test 2</p>', 'safe');
expect(result).toBe('<p>Test Test 2</p>');
}));

it('at start &#10;', inject(function(taSanitize){
var result = taSanitize('<p>&#10;Test Test 2</p>', 'safe');
expect(result).toBe('<p>Test Test 2</p>');
}));

it('at start &#9;', inject(function(taSanitize){
var result = taSanitize('<p>&#9;Test Test 2</p>', 'safe');
expect(result).toBe('<p>Test Test 2</p>');
}));

it('at middle both', inject(function(taSanitize){
var result = taSanitize('<p>Test &#10;&#9;Test 2</p>', 'safe');
expect(result).toBe('<p>Test Test 2</p>');
}));

it('at middle &#10;', inject(function(taSanitize){
var result = taSanitize('<p>Test &#10;Test 2</p>', 'safe');
expect(result).toBe('<p>Test Test 2</p>');
}));

it('at middle &#9;', inject(function(taSanitize){
var result = taSanitize('<p>Test &#9;Test 2</p>', 'safe');
expect(result).toBe('<p>Test Test 2</p>');
}));

it('at end both', inject(function(taSanitize){
var result = taSanitize('<p>Test Test 2&#10;&#9;</p>', 'safe');
expect(result).toBe('<p>Test Test 2</p>');
}));

it('at end &#10;', inject(function(taSanitize){
var result = taSanitize('<p>Test Test 2&#10;</p>', 'safe');
expect(result).toBe('<p>Test Test 2</p>');
}));

it('at end &#9;', inject(function(taSanitize){
var result = taSanitize('<p>Test Test 2&#9;</p>', 'safe');
expect(result).toBe('<p>Test Test 2</p>');
}));

it('combination', inject(function(taSanitize){
var result = taSanitize('<p>&#10;Test &#10; &#9;Test 2&#10;&#9;</p>', 'safe');
expect(result).toBe('<p>Test Test 2</p>');
}));

it('leaves them inbetween <pre> tags', inject(function(taSanitize){
var result = taSanitize('<pre>&#9;Test &#10; &#9;Test 2&#10;&#9;</pre>', 'safe');
expect(result).toBe('<pre>&#9;Test &#10; &#9;Test 2&#10;&#9;</pre>');
}));

it('correctly handles a mixture', inject(function(taSanitize){
var result = taSanitize('<p>&#10;Test &#10; &#9;Test 2&#10;&#9;</p><pre>&#9;Test &#10; &#9;Test 2&#10;&#9;</pre>', 'safe');
expect(result).toBe('<p>Test Test 2</p><pre>&#9;Test &#10; &#9;Test 2&#10;&#9;</pre>');
}));
});

describe('only certain style attributes are allowed', function(){
describe('validated color attribute', function(){
it('name', inject(function(taSanitize){
Expand Down
2 changes: 1 addition & 1 deletion test/textAngular.spec.js
Expand Up @@ -608,7 +608,7 @@ describe('textAngular', function(){
element.append('<bad-tag>Test 2 Content</bad-tag>');
element.triggerHandler('keyup');
$rootScope.$digest();
expect(element2.val()).toBe('<p>Test Contents</p><bad-tag>Test 2 Content</bad-tag>');
expect(element2.val()).toBe('<p>Test Contents</p>\n<bad-tag>Test 2 Content</bad-tag>');
});

it('not allow malformed html', function () {
Expand Down

0 comments on commit f0d3baf

Please sign in to comment.