Formatting And Parsing Custom ngModel Bindings In AngularJS
In the past, I've experimented a little bit with the ngModelController workflow in AngularJS. I've even gone so far as creating a custom HTML dropdown menu with an ngModel binding. But, even with that, I barely scratched the surface of what the ngModel ecosystem provides to the developer. So, today, I wanted to try and look into the formatting and parsing hooks that the ngModelController provides as data moves into and out of your custom ngModel-based user interface (UI) component.
Run this demo in my JavaScript Demos project on GitHub.
As an ngModel consumer - as an author of custom input controls - you have access to the data as it moves into and out of your custom input components. On the way into your component, you are provided with the raw view-model value. You can use this value as-is (by default, the $viewValue and the $modelValue are the same); or, you can format it for use internally.
When the user interacts with your component and causes the $viewValue to be updated, you can export this value as-is; or, you can parse it back into something that the ngModel binding is expecting.
In either case, the formatting and parsing is performed by a series of operators (functions) that reduce the value into a given result. The formatters are called in reverse order (reverse to the order in which they were pushed onto the $formatters collection). And, the parsers are called in array order. Both of these collections are completely optional.
There's even more that goes on during the ngModel / ngModelController life-cycle; but, for the purposes of this post, here is the mental model that I have:
As I was digging into this, I discovered a nice little interaction: the use of the ngModelOptions' debounce setting affects how often the $parsers and the $viewChangeListeners are invoked. This is quite elegant because it means that your UI component doesn't have to worry [as much] about performance - it just provides the input and output functionality. Then, if performance becomes a problem, it can be managed externally to your UI component through the use of the ngModelOptions directive.
To experiment with all of this, I wanted to create a really small, mostly naive Markdown input control that will convert "*" and "_" characters to and from Strong and Em element tags, respectively. Markdown is a large and funky spec. And the contentEditable attribute is rife with problems. So, just keep in mind that this was done for the exploration of the ngModelController life-cycle - not for the creation of a true markdown editor.
On the way into the "Marky Markdown" control, I transpile the markdown into HTML through the use of a $formatter. Then, on the way out of the control, I parse the HTML into markdown through the use of a $parser.
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Formatting And Parsing Custom ngModel Bindings In AngularJS
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController as vm">
<h1>
Formatting And Parsing Custom ngModel Bindings In AngularJS
</h1>
<h3>
Marky-Mark Input Control (w/ ng-model binding)
</h3>
<!--
NOTE: The debounce option here will "throttle" the invocation of the
parsers after the $setViewValue() method is called. This is actually
really cool because it means that you don't have to debounce internally
when you use parsers to transform your ng-model data..
-->
<marky-mark
ng-model="vm.content"
ng-model-options="{ debounce: 0 }">
</marky-mark>
<h3>
Text Input Control (w/ ng-model binding)
</h3>
<input ng-model="vm.content" />
<hr />
<p>
<strong>Caution:</strong> Only tested in Chrome.
</p>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/jquery/jquery-2.1.0.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs/angular-1.4.7.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
angular.module( "Demo", [] );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I control the root of the application.
angular.module( "Demo" ).controller(
"AppController",
function AppController( $scope ) {
var vm = this;
// I am the content being rendered by the ngModel binding(s).
vm.content = "Hey Sarah, you been _working out_? Looking *totes buff*!";
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide a super simplistic mark-down input that supports bold and italic.
angular.module( "Demo" ).directive(
"markyMark",
function markyMarkDirective( htmlUtil ) {
// Return the directive configuration object.
return({
link: link,
require: "ngModel",
restrict: "E",
template: "<div contenteditable='true' class='content'></div>"
});
// I bind the JavaScript events to the view-model.
function link( scope, element, attributes, ngModelController ) {
var content = element.find( "div" );
// When the ngModel / $modelValue value needs to be synchronized
// into the $viewValue / input control, it is passed through a
// collection of formatters (in reverse order) before the $render()
// method is invoked.
// --
// ngModel --> $modelValue --> Formatters --> $viewValue --> $render().
ngModelController.$formatters.push( formatInput );
ngModelController.$render = renderViewValue;
// When the $viewValue change is emitted, it is run through a
// collection of parsers (in order) before the value is saved to the
// $modelValue and synchronized out to the ngModel binding.
// --
// Widget --> $viewValue --> Parsers --> $modelValue --> ngModel.
ngModelController.$parsers.push( parseOutput );
// To keep this super simple, we're going to prevent paste events
// and line-breaks on the contentEditable area. This is not a robust
// demo, so it just keeps the output simple.
element.on( "keydown", preventLineBreak );
element.on( "paste", preventPaste );
// We're going to emit the changed $viewValue on each keyup event.
// Doing this will push the $viewValue value through the parsers
// before it is synchronized out to the $modelValue and the ngModel
// binding.
element.on( "keyup", emitMarkupChange );
// ---
// PRIVATE METHODS.
// ---
// As the user types, they are actively changing the DOM structure
// of the widget which represents our view-value. As such, we have
// to let the ngModelController know about the updated value.
function emitMarkupChange( event ) {
scope.$apply(
function changeModel() {
// NOTE: Logging to demonstrate debounce options.
console.log( "Calling $setViewValue() with:", content.html() );
// NOTE: This will cause our parsers to be called before
// the value is synchronized out to the ngModel.
ngModelController.$setViewValue( content.html() );
}
);
}
// I format the incoming ngModel / $modelValue value that needs to be
// synchronized into the $viewValue / widget rendering.
// --
// ngModel --> $modelValue --> [[[ Formatters ]]] --> $viewValue --> $render().
function formatInput( value ) {
var inItalic = false;
var inBold = false;
// Iterate over the meaningful character matches to replace them
// with valid HTML markup tags.
value = value.replace(
/(?:\\([\\_*]))|([_*])/g,
function operator( $0, escapedToken, token ) {
if ( escapedToken ) {
return( escapedToken );
} else if ( token === "*" ) {
if ( inBold ) {
inBold = false;
return( "</b>" );
} else {
inBold = true;
return( "<b>" );
}
} else if ( token === "_" ) {
if ( inItalic ) {
inItalic = false;
return( "</i>" );
} else {
inItalic = true;
return( "<i>" );
}
} else {
return( htmlUtil.escapeHtml( $0 ) );
}
}
);
// Close any outstanding tags.
// --
// CAUTION: For this simplistic demo, we're not going to worry
// about the order of the unclosed tags.
( inBold && ( value += "</b>" ) );
( inItalic && ( value += "</i>" ) );
// Return the formatted value.
return( value );
}
// I parse the outgoing $viewValue for use in the $modelValue / ngModel.
// --
// Widget --> $viewValue --> [[[ Parsers ]]] --> $modelValue --> ngModel.
function parseOutput( value ) {
// NOTE: Logging to demonstrate debounce options.
console.log( "Parser called." );
return(
domToString(
angular.element( "<div />" )
.html( value )
.prop( "childNodes" )
)
);
// I convert the given DOM node collection into an marky-markdown
// input value (which is basically a barely-functional markdown).
function domToString( nodes ) {
var buffer = [];
for ( var i = 0, length = nodes.length ; i < length ; i++ ) {
var node = nodes[ i ];
if ( ! htmlUtil.isElement( node ) ) {
// Escape any embedded special characters.
buffer.push( node.nodeValue.replace( /([_*\\])/g, "\\$1" ) );
} else if ( htmlUtil.isBold( node ) ) {
buffer.push( "*" );
buffer.push( domToString( node.childNodes ) );
buffer.push( "*" );
} else if ( htmlUtil.isItalic( node ) ) {
buffer.push( "_" );
buffer.push( domToString( node.childNodes ) );
buffer.push( "_" );
} else {
// For the purposes of this demo, we're just going to
// keep the parser super super super simple and just
// ignore everything but the bold and italic tags.
// --
// NOTE: Since we also suppressing the line-return and
// paste events, this should be totally fine for our
// demo purposes.
buffer.push( domToString( node.childNodes ) );
}
}
return( buffer.join( "" ) );
}
}
// I prevent the paste event on the input.
// --
// NOTE: Done to keep the demo super simple.
function preventPaste( event ) {
event.preventDefault();
}
// I prevent line-breaks on the input.
// --
// NOTE: Done to keep the demo super simple.
function preventLineBreak( event ) {
if ( event.which === 13 ) {
event.preventDefault();
}
}
// I get called when the $viewValue has been changed programmtically
// from the outside (ie, via the ngModel binding) and needs to be
// synchronized into the widget. At this point, the formatters have
// all been called and the $viewValue should be considered ready to
// consume in the widget.
function renderViewValue() {
content.html( ngModelController.$viewValue );
}
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide some HTML-based utilities.
angular.module( "Demo" ).factory(
"htmlUtil",
function htmlUtilFactory() {
// Return the pulic API.
return({
escapeHtml: escapeHtml,
isBold: isBold,
isElement: isElement,
isItalic: isItalic,
isTagType: isTagType
});
// ---
// PUBLIC METHODS.
// ---
// I escape the given HTML so that it can be rendered as text.
function escapeHtml( html ) {
html = html.replace( /&(?!amp;)/g, "&" );
html = html.replace( /</g, "<" );
html = html.replace( />/g, ">" );
return( html );
}
// I determine if the given node is a bold(esque) tag.
function isBold( node ) {
return( isTagType( node, [ "b", "strong" ] ) );
}
// I determine if the given node is an Element node.
function isElement( node ) {
return( node.nodeType === 1 );
}
// I determine if the given node is an italic(esque) tag.
function isItalic( node ) {
return( isTagType( node, [ "em", "i" ] ) );
}
// I determine if the given node is one of the given types.
function isTagType( node, tagNames ) {
var isAnyMatch = tagNames.some(
function iterator( name ) {
return( name.toUpperCase() === node.tagName );
}
);
return( isAnyMatch );
}
}
);
</script>
</body>
</html>
Because this demo has two ngModel bindings, editing either of the inputs will cause both inputs to be kept in sync. If I enter markdown into the Input control, the HTML is rendered in the marky-mark control. And, if I edit the live HTML in the marky-mark control, the necessary markdown is pushed to the Input control.
There's a lot going on here. But, it only begins to touch upon all of the ngModelController functionality and how it interacts with the FormController (about which I know next to nothing). So, hopefully, this is just another step in a journey towards a better understanding of AngularJS.
Want to use code from this post? Check out the license.
Reader Comments
Ben, just bravo!
Also using ng-model is very useful for passing data model binding to your component if you wanna provide one way data flow.
$render( instead of $scope.$watch and within, setting your internal property with angular.copy(viewValue)) + $setViewValue for manually controlled update to outside world
anyway, great job!
@Martin,
Thanks so much :D Also, I never considered using the ngModel just to implement one-way data flow; but, that makes a lot of sense - definitely more efficient than implementing a $watch(). Great suggestion!
@Martin,
I'm trying to think-through the one-way data flow with ngModel. The way into the component makes sense. ngModel updates the $viewValue which calls $render() and you update the display. But, going back out, I'm not sure what you are doing. I thought about maybe using ngChange, but it looks like that binds to the $viewChangeListeners, which won't get triggered if I'm not explicitly changing the $viewValue. And, if I change the $viewValue, ngModel becomes implicitly two-way.
Can you help me think this through?
@Ben,
sorry for the delay,
here is basic example:
http://plnkr.co/edit/S568Dk7RGoNW4Cm6mVvC?p=preview
tl;dr always angular.copy to break the references and to provide new reference for ngModel.
@Martin,
Ah, I see. You're using .copy(); but, more than that, you're not calling $setViewValue() until the user actually clicks the "Update" button. I think this was the biggest disconnect for me when thinking about it originally. So, you're basically forcing the ngModel binding to go out of sync with the component by not telling the ngModel binding when the view changes.
That said, the even more interesting thing about your Plunker (at least for me) is the way you are transferring the ngModel controller into the component controller. This is something that I've been wondering about. I was never pleased with -- having to abandon a controller simply to acquire another controller. But, your approach is very interesting. Thanks!
@Martin,
If you're curious, I tried using the link() function to pass the ngModelController into the component directive controller:
www.bennadel.com/blog/2969-passing-ngmodelcontroller-into-a-component-directive-controller-in-angularjs.htm
I really like that approach! Thanks for sharing it :D