ControlValueAccessor and contenteditable in Angular
By: Alexander Inkin
Tags: WYSIWYG, Rich Text Editor, JavaScript, Angular, Contenteditable

ControlValueAccessor

Directive that we will create is going to react to contenteditable attribute. And it will implement ControlValueAccessor interface:

To support both reactive and template forms we will provide a special InjectionToken :

We need to implement 3 required and 1 optional methods:

registerOnChange — this method will receive a function during control initialization. Our directive needs to call that function with a new value when user enters something in an editable element. This would propagate value to the control.

— this method will receive a function during control initialization. Our directive needs to call that function with a new value when user enters something in an editable element. This would propagate value to the control. registerOnTouched — this method will receive a function during control initialization. We need to call it when user left our element so control would become touched . It is important for validation.

— this method will receive a function during control initialization. We need to call it when user left our element so control would become . It is important for validation. writeValue — this method will be called by control to pass value to our directive. It is used to sync value when it is changed externally ( setValue or ngModel variable change), and to set initial value.

Worth mentioning that template forms have a bug related to this method. Initial value is provided with a slight delay and writeValue is called twice, first time with null :

https://github.com/angular/angular/issues/14988

setDisabledState (optional) — this method will be called by control when disabled state is changed. Even though it is optional it is a good idea to have it, otherwise we cannot have our control disabled.

Implementation

To work with DOM element we need Renderer2 and element itself so let’s add them to constructor:

We will store references to the methods control gives us:

disabled for contenteditable is equal to contenteditable="false" . Setting value is the same as setting innerHTML for DOM element. Value change and focus loss can be tracked with events:

That’s it. This is enough to make Angular forms work with contenteditable elements.

Internet Explorer

There are couple of “buts”.

First, initial control value is null and after writeValue in IE11 this is what we will see in the template, literal “null” string. To handle this we need to normalize value:

Here we also take care of the following case. Say element had some HTML tags inside. If we just select everything and hit backspace — content gets replaced with a single <br> tag. It’s best to consider it equal to empty string so our control thinks it’s empty.

Second, Internet Explorer does not support input event for contenteditable elements. So we are gonna have to make a fallback with a MutationObserver .

By the way, if you want to use MutationObserver as a declarative Angular directive or an Observable-based service you are welcome to take a look at our little library: https://github.com/ng-web-apis/mutation-observer

Instead of checking the browser we will just turn MutationObserver off on a first input event:

Now our component works in IE11 and we are proud of ourselves!

Internet Explorer! ლ(ಠ益ಠლ)

Unfortunately, IE11 will not let go so easily. Apparently it has a bug in MutationObserver . Say contenteditable has tags, for example some <b>text</b> . Removing text that would cause a whole tag to be removed (word text in this example), observer callback will be triggered before actual changes to the DOM!

Unfortunately, we have to call it a loss and resort to using setTimeout :

Summary

Considering Angular has to support IE9, 10 and 11 we can understand why they did not make such accessor built-in.

We need to remember that HTML can have malicious code placed in it. We shouldn’t blindly add it do the DOM.

And we cannot trust users either so we must watch out for paste and drop events. Such content has to be sanitized. A good and trusted tool for that is DOMPurify. Read here how we implemented it in an Angular app or just download our package:

You can also grab the accessor we’ve created in this article as a tiny package that works in Angular 4+:

A live playground: