Вы когда-нибудь задумывались, как работает связка форм Angular и HTML элементов, через которые пользователь заносит данные?
С самого начала для этого использовали ControlValueAccessor — специальный интерфейс, состоящий всего из 4 методов:
interface ControlValueAccessor {
writeValue(value: any): void
registerOnChange(fn: (value: any) => void): void
registerOnTouched(fn: () => void): void
setDisabledState(isDisabled: boolean)?: void
}Из коробки, Angular имеет несколько таких аксессоров: для чекбоксов и радиокнопок, для инпутов и селектов. Однако, если вы разрабатываете чат, в котором вам нужно дать возможность писать курсивом, делать текст жирным или, допустим, вставлять смайлики — вы, скорее всего, воспользуетесь атрибутом contenteditable для создания содержимого с форматированием.
В Angular нет поддержки использования форм вместе с contenteditable, поэтому написать её придётся самим.

ControlValueAccessor
Директива, которую мы напишем, будет работать аналогично встроенным аксессорам — она будет реагировать на атрибут contenteditable. Чтобы шаблонные и реактивные формы получили её через внедрение зависимостей, достаточно предоставить встроенный InjectionToken:
@Directive({
selector:
'[contenteditable][formControlName],' +
'[contenteditable][formControl],' +
'[contenteditable][ngModel]',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ContenteditableValueAccessor),
multi: true,
},
],
})
export class ContenteditableValueAccessor implements ControlValueAccessor {
// ...
}Интерфейс ControlValueAccessor требует реализовать 3 метода и имеет 1 опциональный метод:
registerOnChange— в этот метод при инициализации придёт функция. Её вызывают с новым значением, когда пользователь что-то ввёл в наш компонент, для того, чтобы новые данные были занесены в контрол.registerOnTouched— в этот метод при инициализации придёт функция. Её надо вызвать, когда пользователь покинул наш компонент для того, чтобы контрол приобрёл статусtouched. Это используется для валидации.writeValue— этот метод будет вызываться контролом для передачи значения в наш компонент. Его используют если значение поменяется через код снаружи (setValueили изменение переменной, на которую завязанngModel), а так же для задания начального значения.
Тут стоит отметить, что, в отличие от реактивных форм,
ngModelведёт себя кривовато — в частности, начальное значение в ней инициализируется с задержкой, а методwriteValue«дёргается» дважды, первый раз сnull:
https://github.com/angular/angular/issues/14988
setDisabledState(опционально) — этот метод будет вызываться контролом при изменении состоянияdisabled. Хоть метод и опциональный, на это лучше реагировать в вашем компоненте.
Реализация интерфейса
Для работы с DOM элементом нам потребуется Renderer2 и, собственно, сам элемент, поэтому добавим их в конструктор:
constructor(
@Inject(ElementRef) private readonly elementRef: ElementRef,
@Inject(Renderer2) private readonly renderer: Renderer2,
) {}Сохраним методы, которые нам передаст контрол в приватные поля класса:
private onTouched = () => {};
private onChange: (value: string) => void = () => {};
registerOnChange(onChange: (value: string) => void) {
this.onChange = onChange;
}
registerOnTouched(onTouched: () => void) {
this.onTouched = onTouched;
}disabled состояние для contenteditable компонента равносильно отключению режима редактирования — contenteditable="false". Задание значения снаружи эквивалентно подмене innerHTML DOM элемента, а обновление значения пользователем и уход из компонента можно отслеживать, подписавшись на соответствующие события:
@HostListener('input')
onInput() {
this.onChange(this.elementRef.nativeElement.innerHTML);
}
@HostListener('blur')
onBlur() {
this.onTouched();
}
setDisabledState(disabled: boolean) {
this.renderer.setAttribute(
this.elementRef.nativeElement,
'contenteditable',
String(!disabled),
);
}
writeValue(value: string) {
this.renderer.setProperty(
this.elementRef.nativeElement,
'innerHTML',
value,
);
}Вот, собственно, и всё. Этого достаточно для базовой реализации работы форм Angular и contenteditable элементов.
Internet Explorer
Однако, есть пара но.
Во-первых, пустое начальное значение формы — null, и после выполнения writeValue в IE11 мы так и увидим в шаблоне null. Чтобы правильно реализовать работу, нам нужно нормализовать значение:
writeValue(value: string | null) {
this.renderer.setProperty(
this.elementRef.nativeElement,
'innerHTML',
ContenteditableValueAccessor.processValue(value),
);
}
private static processValue(value: string | null): string {
const processed = value || '';
return processed.trim() === '<br>' ? '' : processed;
}Тут мы также обработаем следующую ситуацию. Представим, что содержимое элемента имело HTML теги. Если мы просто выделим всё и удалим, внутри будет не пусто — туда вставится одинокий <br> тег. Чтобы не забивать пустым значением контрол, мы будем считать его за пустую строку.
Во-вторых, в Internet Explorer нет поддержки input события для contenteditable элементов. Нам придётся реализовать fallback с помощью MutationObserver:
private readonly observer = new MutationObserver(() => {
this.onChange(
ContenteditableValueAccessor.processValue(
this.elementRef.nativeElement.innerHTML,
),
);
});
ngAfterViewInit() {
this.observer.observe(this.elementRef.nativeElement, {
characterData: true,
childList: true,
subtree: true,
});
}
ngOnDestroy() {
this.observer.disconnect();
}Мы не будем реализовывать проверку на конкретный браузер. Вместо этого, мы сразу же отключим MutationObserver при первом input событии:
@HostListener('input')
onInput() {
this.observer.disconnect();
// ...
}Теперь наш компонент работает в IE11 и мы довольны собой!
Internet Explorer! ?(????)
К сожалению, IE11 так просто не отстанет. По всей видимости, в нём присутствует баг в работе MutationObserver. Если внутри contenteditable элемента есть теги, к примеру, some <b>text</b>, то при удалении текста, которое повлечет за собой удаление целого тэга (слово text в данном примере), callback обсервера будет вызван до того, как произойдут реальные изменения в DOM!

К сожалению, тут нам ничего не остаётся, как признать поражение и воспользоваться setTimeout:
private readonly observer = new MutationObserver(() => {
setTimeout(() => {
this.onChange(
ContenteditableValueAccessor.processValue(
this.elementRef.nativeElement.innerHTML,
),
);
});
});Вывод
При условии, что Angular должен поддерживать Internet Explorer версий 9, 10 и 11 становится понятно, почему они не реализовали работу с contenteditable у себя.
Кроме того, нужно помнить, что HTML может содержать в себе вредоносный код — поэтому не стоит смело брать неизвестное содержимое и вставлять его в контрол, а ввод от пользователя нужно проверять в событиях paste и drop. Описанный в этой статье код работает в Angular 4 и выше, в том числе и с FormHooks. При желании, можно добавить и поддержку Angular 2, если использовать Renderer, а не Renderer2. Исходный код и npm пакет доступны по ссылкам:
https://github.com/TinkoffCreditSystems/angular-contenteditable-accessor
https://www.npmjs.com/package/@tinkoff/angular-contenteditable-accessor
А поиграться с примером можно тут:
https://stackblitz.com/edit/angular2-contenteditable-value-accessor
Homer4ik
Для IE необходимо слушать событие textinput, вместо обычного input
Waterplea Автор
К сожалению, это событие отловит только ввод текста. Изменения в HTML, такие, как переносы строк или жирный шрифт по Ctrl + B его не испускают.