ControlValueAccessor и contenteditable в Angular
- вторник, 7 мая 2019 г. в 00:21:13
Вы когда-нибудь задумывались, как работает связка форм 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, поэтому написать её придётся самим.

Директива, которую мы напишем, будет работать аналогично встроенным аксессорам — она будет реагировать на атрибут 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 элементов.
Однако, есть пара но.
Во-первых, пустое начальное значение формы — 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 и мы довольны собой!
К сожалению, 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