Вы когда-нибудь задумывались, как работает связка форм 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