Fun With Emoticons And Service Providers In AngularJS
At InVision, we recently released emoticon support in comments. I wasn't directly involved with the feature, but I like it as a concept and I think it presents an interesting playground in AnuglarJS. So, I wanted to take a little time to think about how I might personally organize such a feature. If nothing else, I think this presents an opportunity to think about service Providers in AngularJS and how they can be used to configure services that have not yet been initialized.
Run this demo in my JavaScript Demos project on GitHub.
By their nature, emoticons are extremely flexible. Meaning, any pattern and any token could theoretically represent an emoticon. For example, both of these are completely legitimate emoticons:
- :)
- man_running_around_naked_while_singing
Their validity really only depends on context. As such, it's important that an emoticon service have common default values while, at the same time, also be flexible enough to provide custom tokens and token aliasing.
Customization, in AngularJS, is typically done in the configuration phase using either service Decorators or service Providers. For an emoticon service, I think it makes sense to use a service provider where we can explicitly tell the emoticon service what tokens we want to use, what aliases are valid, and what tag-element should be used to render the actual markup.
Configuring the emoticons, however, is really only half of the battle. The other half is injecting the emoticons into the plain-text input. Text manipulation is expensive. And, if we use Regular Expressions (video presentation), that expense can be even greater. As such, I wanted to figure out how to match input tokens both effectively and efficiently.
Matching normal input tokens, such as ":smile:", is easy as it can be defined using a constant-time pattern match. Meaning, the cost of the pattern match doesn't increase with the number of tokens that we want to make available. Alias values, on the other hand, present a performance problem. Since they require non-standard patterns, each one of them will increase the complexity and cost of the search.
To offset the cost of complexity, we can do a few things:
- We can precompile all of our HTML tags during service initialization.
- We can compile the regular expression patterns just once during service initialization.
- We can push all of our needs into a single pattern for a one-pass conversion.
In this way, we push a lot of our costs into the one-time overhead of service initialization. And, while the cost of the search will still increase with the number of alias values that we use, at least the single-pattern approach requires only a single pass over the plain-text input.
To see this in action, I've put together a demo in which I am using the configuration phase and an emoticons Provider to define which tokens are valid and which alias values map onto which tokens.
NOTE: For this demo, I borrowed the image and CSS from the ngEmoticons project. Much thanks!
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Fun With Emoticons And Service Providers In AngularJS
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController as vm">
<h1>
Fun With Emoticons And Service Providers In AngularJS
</h1>
<form>
<input type="text" ng-model="vm.text" size="60" />
</form>
<p ng-bind-html="vm.content">
<!-- Emoticon-embedded content will appear here. -->
</p>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.4.2.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
angular.module( "Demo", [] );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// During the configuration phase, let's update the emoticon functionality. The
// provider allows us to overwrite the tokens collection and / or define aliases.
angular.module( "Demo" ).config(
function configureEmoticons( emoticonsProvider ) {
// Setup common alias values.
// --
// CAUTION: The more alias values we setup, the more complex the pattern
// matching will become, which may have an affect on performance.
emoticonsProvider.addAlias( ":)", "smile" );
emoticonsProvider.addAlias( ":D", "smiley" );
emoticonsProvider.addAlias( ":((", "rage" );
emoticonsProvider.addAlias( ":(", "frowning" );
emoticonsProvider.addAlias( ":'(", "cry" );
emoticonsProvider.addAlias( ":*", "kissing" );
// Override the token collection with our more robust offering.
emoticonsProvider.setTokens([
"smile", "laughing", "blush", "smiley", "relaxed", "smirk",
"heart_eyes", "kissing_heart", "kissing_closed_eyes", "flushed",
"relieved", "satisfied", "grin", "wink", "winky_face", "grinning",
"kissing", "kissing_smiling_eyes", "stuck_out_tongue", "sleeping",
"worried", "frowning", "anguished", "open_mouth", "wow", "grimacing",
"confused", "hushed", "expressionless", "unamused", "sweat_smile",
"sweat", "weary", "pensive", "disappointed", "confounded", "fearful",
"cold_sweat", "persevere", "cry", "sob", "joy", "astonished",
"scream", "angry", "rage", "triumph", "sleepy", "yum", "mask",
"sunglasses", "dizzy_face", "lips", "kiss", "mouse", "poop"
]);
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I control the root of the application.
angular.module( "Demo" ).controller(
"AppController",
function provideAppController( $scope, $sce, emoticons ) {
var vm = this;
// I am the text coming out of the ngModel value.
vm.text = "Hello world :smile:";
// I am the HTML-version of the emoticon text.
vm.content = null;
// When the user updates the ngModel text, we want to update the emoticons
// in realtime and inject them into the page.
$scope.$watch(
"vm.text",
function textChanged( newValue, oldValue ) {
// Since we are injecting HTML, we have to tell Angular to trust
// that the HTML we are providing is safe and doesn't expose a
// security risk.
vm.content = $sce.trustAsHtml( emoticons.injectTags( newValue ) );
}
);
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide an emoticons service that can inject HTML tags into plain text
// markup using a set of emoticon tokens and emoticon alias values.
angular.module( "Demo" ).provider(
"emoticons",
function emoticonsProvider() {
// I am the tag name that will be used to create the HTML tag that
// represents the emoticons in the markup. For example, <i></i>.
var tagName = "i";
// I am the collection of tokens that can be used to identify emoticons
// in the plain-text content. These tokens will also be used to define
// the CSS classes in the markup. For example, "emoticon emoticon-smile".
var tokens = [ "smile", "smiley", "frowning" ];
// I define the RegExp patterns that are used to search for and validate
// emoticon tokens.
var tokenSearchPattern = /:([\w+-]+):/g;
var tokenValidationPattern = /^([\w+-]+)$/i;
// I am the base CSS class used when generating the markup.
var baseCssClass = "emoticon";
// I am the collection of alias values that represent emoticons. So,
// instead of having a user enter ":smile:", maybe you want to have the
// value, ":)" automatically work. In that case, ":)" would be an alias
// for ":smile:".
var aliases = [];
// Expose the public API for the provider.
return({
addAlias: addAlias,
setTagName: setTagName,
setTokens: setTokens,
$get: emoticons
});
// ---
// PUBLIC METHODS.
// ---
// I allow non-token patterns to be mapped to emoticons. For example,
// you might want to allow the ":)" pattern to be automatically mapped
// to the ":smile:" pattern.
// --
// CAUTION: Alias values are use to pre-treat the plain-text input using
// a more complex pattern. As such, they are more expensive from a
// performance standpoint.
function addAlias( alias, token ) {
testAlias( alias );
testToken( token );
// NOTE: I am not validating the alias-token connection at this point
// since tokens may be changed later in the configuration phase. This
// connection will validated during service initialization.
aliases.push({
text: alias,
token: token
});
}
// I allow the tag name to be overridden during the configuration phase.
function setTagName( newTagName ) {
tagName = newTagName;
}
// I allow the tokens to be overridden during the configuration phase.
function setTokens( newTokens ) {
// Because the tokens are located in plain-text using a regular
// expression pattern, we need to ensure that each token adheres to
// a particular format.
for ( var i = 0, length = newTokens.length ; i < length ; i++ ) {
testToken( newTokens[ i ] );
}
// If we made it this far, all the new tokens are valid.
tokens = newTokens;
}
// ---
// PRIVATE METHODS.
// ---
// I test to make sure that the given alias is valid. If the alias is not
// valid, an error is thrown; otherwise, returns quietly.
function testAlias( newAlias ) {
if ( ! newAlias.length ) {
throw( new Error( "Alias cannot be an empty string." ) );
}
if ( newAlias.indexOf( " " ) !== -1 ) {
throw( new Error( "Alias [" + newAlias + "] cannot contain white space." ) );
}
}
// I test the format of the given token to make sure that it conforms to
// the pattern we will be searching for in the text. If the token is not
// valid, an error is thrown; otherwise, returns quietly.
function testToken( newToken ) {
if ( newToken.search( tokenValidationPattern ) !== 0 ) {
throw( new Error( "Token [" + newToken + "] is not a valid emoticon." ) );
}
}
// ---
// FACTORY FUNCTION.
// ---
// I am the actual emoticons service.
function emoticons() {
// I am a hash that maps the token values to pre-composed HTML tag
// that represents the emoticon markup.
var tokenMap = createTokenMap( tokens, baseCssClass );
// I am a hash that maps alias values onto token values.
var aliasMap = createAliasMap( aliases, tokenMap );
// I am the [more] complex pattern that is used to search for both
// standard token values and non-standard alias tokens.
var compoundSearchPattern = createCompoundSearchPattern( aliases );
// Return the public API.
return({
injectTags: injectTags
});
// ---
// PUBLIC METHODS.
// ---
// I take plain-text content and replace the emoticon tokens with
// actual HTML tags that represent the graphical emotions.
function injectTags( text ) {
// If the text is empty, or not text, just pass it through.
if ( ! text || ! angular.isString( text ) ) {
return( text );
}
// Search for and replace emoticon markers with HTML tags.
var emotionalText = text.replace(
compoundSearchPattern,
function replaceMatch( $0, token, alias ) {
// The first part of this pattern is looking for
// normal tokens. This is so that we don't accidentally
// find alias values inside of other tokens; the tokens
// take precedence, like a boss.
if ( token ) {
return(
tokenMap.hasOwnProperty( token )
? tokenMap[ token ]
: $0
);
}
// If we didn't find a token, then by factor of
// elimination, we must have found an alias. Replace it
// with the appropriate HTML tag.
// --
// NOTE: Since we only allow alias values that map onto
// known tokens, we don't have to check to see if the
// alias maps onto a valid token.
return( tokenMap[ aliasMap[ alias ] ] );
}
);
return( emotionalText );
}
// ---
// PRIVATE METHODS.
// ---
// I create an alias map that maps alias values onto token values.
// If the alias points to a token that is not defined, an error is
// thrown - since alias values present a more complex pattern, I
// will not suffer unnecessary alias values.
function createAliasMap( aliases, tokenMap ) {
var aliasMap = {};
for ( var i = 0, length = aliases.length ; i < length ; i++ ) {
var alias = aliases[ i ];
if ( ! tokenMap.hasOwnProperty( alias.token ) ) {
throw( new Error( "Alias [" + alias.text + "] does not map to a known emoticon token." ) );
}
aliasMap[ alias.text ] = alias.token;
}
return( aliasMap );
}
// I create a RegExp object that will search for the alias values
// in a single pattern.
// --
// CAUTION: The pattern will preferentially search for normal token
// values.
function createCompoundSearchPattern( aliases ) {
// If there are no aliases, we can just use the core token search
// pattern.
if ( ! aliases.length ) {
return( tokenSearchPattern );
}
// Since we do have alias values, we need to aggregate the
// collection so that we can collapse them all down into a single
// regular expression pattern group.
var aliasPatterns = [];
for ( var i = 0, length = aliases.length ; i < length ; i++ ) {
aliasPatterns.push( quotePatternText( aliases[ i ].text ) );
}
// When we create our compound pattern, we want to give precedence
// to the standard token search. Then, only a secondary fallback
// do we want to allow the RegExp engine to start looking for the
// alias values. In this case, we have the following groups:
// --
// $0 - matched value.
// $1 - standard token.
// $2 - alias value.
return(
new RegExp(
( tokenSearchPattern.source + "|(" + aliasPatterns.join( "|" ) + ")" ),
"g"
)
);
}
// Rather than having to convert tokens to tags over and over again,
// we can pre-compose the HTML tags during service initialization.
// This way, we only eat that cost once. Returns a hash of tokens
// mapped to their corresponding HTML tag.
function createTokenMap( tokens, baseCssClass ) {
var tagMap = {};
for ( var i = 0, length = tokens.length ; i < length ; i++ ) {
var token = tokens[ i ];
tagMap[ token ] = createTokenTag( token, baseCssClass );
}
return( tagMap );
}
// I create an HTML tag that represents the given token.
function createTokenTag( token, baseCssClass ) {
// Each emoticon tag has two classes - the base class plus
// a token-specific extension. For example: "emoticon emoticon-smile".
var className = ( baseCssClass + " " + baseCssClass + "-" + token );
return( "<" + tagName + " title=':" + token + ":' class='" + className + "'></" + tagName + ">" );
}
// I return the escaped RegExp pattern text that can be used to
// safely create a RegExp instance, regardless of whether or not the
// text contains "special" embedded characters.
function quotePatternText( alias ) {
// Escape characters that are meaningful in a RegExp context.
return( alias.replace( /([.()+*[\]{}?-])/g, "\\$1" ) );
}
}
}
);
</script>
</body>
</html>
I know there's probably a number of emoticon modules out there already. The point of this exploration wasn't to add yet another one to the mix. Really, this was just an excellent context in which to think about AngularJS services and how Providers can be used to alter the behavior of services during the configuration phase. Plus, emoticons are just super fun times on a Friday.
Want to use code from this post? Check out the license.
Reader Comments
Just want to see the smiley feature in comments :) .
@Sreeram,
Ha, sorry - I wish I had them working in my comments, but unfortunately not. Only in a different system.