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

Комментарии (2)


  1. Homer4ik
    07.05.2019 12:28

    Для IE необходимо слушать событие textinput, вместо обычного input


    1. Waterplea Автор
      07.05.2019 12:29

      К сожалению, это событие отловит только ввод текста. Изменения в HTML, такие, как переносы строк или жирный шрифт по Ctrl + B его не испускают.