ControlValueAccessor e contenteditable em Angular

Você já se perguntou como funciona a combinação de formulários angulares e elementos HTML através dos quais o usuário digita dados?


Desde o início, usamos o ControlValueAccessor , uma interface especial que consiste em apenas 4 métodos:


 interface ControlValueAccessor { writeValue(value: any): void registerOnChange(fn: (value: any) => void): void registerOnTouched(fn: () => void): void setDisabledState(isDisabled: boolean)?: void } 

Fora da caixa, o Angular possui vários acessadores: para caixas de seleção e botões de opção, para entrada e seleção. No entanto, se você estiver desenvolvendo um bate-papo no qual precisará dar a oportunidade de escrever em itálico, deixar o texto em negrito ou, digamos, inserir emoticons, provavelmente usará o atributo contenteditable para criar conteúdo formatado.


O Angular não suporta o uso de formulários junto com o contenteditable , então você deve escrevê-lo.



ControlValueAccessor


A diretiva que escrevemos funcionará de maneira semelhante aos acessadores internos - ela responderá ao atributo contenteditable . Para que o modelo e os formulários reativos o recebam por meio da injeção de dependência, basta fornecer um InjectionToken embutido:


 @Directive({ selector: '[contenteditable][formControlName],' + '[contenteditable][formControl],' + '[contenteditable][ngModel]', providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ContenteditableValueAccessor), multi: true, }, ], }) export class ContenteditableValueAccessor implements ControlValueAccessor { // ... } 

A interface ControlValueAccessor requer 3 métodos e possui 1 método opcional:


  • registerOnChange - uma função entrará nesse método durante a inicialização. É chamado com um novo valor quando o usuário digita algo em nosso componente para que novos dados sejam inseridos no controle.
  • registerOnTouched - uma função entrará nesse método durante a inicialização. Ele deve ser chamado quando o usuário deixou nosso componente para que o controle seja touched . Isso é usado para validação.
  • writeValue - esse método será chamado pelo controle para passar o valor ao nosso componente. É usado se o valor for alterado pelo código externo ( setValue ou alterando a variável à qual ngModel está vinculado), bem como para definir o valor inicial.
    Vale ressaltar que, diferentemente das formas reativas, o ngModel se comporta de maneira ngModel - em particular, o valor inicial nele é inicializado com um atraso e o método writeValue "se writeValue " duas vezes, a primeira vez com null :
    https://github.com/angular/angular/issues/14988
  • setDisabledState (opcional) - esse método será chamado pelo controle quando o estado disabled alterado. Embora o método seja opcional, é melhor responder a isso em seu componente.

Implementação de interface


Para trabalhar com o elemento DOM, precisamos do Renderer2 e, de fato, do próprio elemento; portanto, adicione-os ao construtor:


 constructor( @Inject(ElementRef) private readonly elementRef: ElementRef, @Inject(Renderer2) private readonly renderer: Renderer2, ) {} 

Salvamos os métodos que o controle nos passará nos campos particulares da classe:


 private onTouched = () => {}; private onChange: (value: string) => void = () => {}; registerOnChange(onChange: (value: string) => void) { this.onChange = onChange; } registerOnTouched(onTouched: () => void) { this.onTouched = onTouched; } 

estado disabled para o componente contenteditable é equivalente a desativar o modo de edição - contenteditable="false" . Definir o valor de fora é equivalente a substituir o DOM innerHTML do elemento, e atualizar o valor pelo usuário e deixar o componente pode ser monitorado, assinando os eventos correspondentes:


 @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, ); } 

Isso, de fato, é tudo. Isso é suficiente para a implementação básica do trabalho de formas angulares e elementos contenteditable conteúdo.


Internet Explorer


No entanto, há um par, mas.


Primeiro, o valor inicial vazio do formulário é null e, após writeValue no IE11, veremos null no modelo. Para implementar corretamente o trabalho, precisamos normalizar o valor:


 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; } 

Aqui também trataremos da seguinte situação. Imagine que o conteúdo de um elemento tenha tags HTML. Se apenas selecionarmos tudo e o excluirmos, ele não ficará vazio por dentro - uma tag solitária será inserida lá. Para não obstruir o controle com um valor vazio, consideraremos como uma string vazia.


Em segundo lugar, o Internet Explorer não suporta eventos de input para elementos contenteditable por contenteditable . Teremos que implementar fallback usando 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(); } 

Não implementaremos a verificação em um navegador específico. Em vez disso, desabilitamos MutationObserver imediatamente no primeiro evento de input :


 @HostListener('input') onInput() { this.observer.disconnect(); // ... } 

Agora nosso componente funciona no IE11 e estamos satisfeitos conosco!


Internet Explorer! ლ (ಠ 益 ಠ ლ)


Infelizmente, o IE11 não será deixado para trás. Aparentemente, há um erro no trabalho do MutationObserver . Se houver tags dentro do elemento contenteditable , por exemplo, some <b>text</b> , ao excluir texto, o que implicará a exclusão de toda a tag (a palavra text neste exemplo), o retorno de chamada do observador será chamado antes do real mudanças no DOM!



Infelizmente, não temos escolha a não ser admitir a derrota e usar setTimeout :


 private readonly observer = new MutationObserver(() => { setTimeout(() => { this.onChange( ContenteditableValueAccessor.processValue( this.elementRef.nativeElement.innerHTML, ), ); }); }); 

Conclusão


Desde que o Angular suporte o Internet Explorer versões 9, 10 e 11, fica claro por que eles não implementaram o trabalho com contenteditable em casa.


Além disso, você deve se lembrar que o HTML pode conter código malicioso - portanto, não use negativamente o conteúdo desconhecido e cole-o no controle, e a entrada do usuário deve ser verificada nos eventos paste e drop . O código descrito neste artigo funciona no Angular 4 e superior, incluindo o FormHooks . Se desejar, você pode adicionar suporte ao Angular 2, se usar o Renderer , não o Renderer2 . O código fonte e o pacote npm estão disponíveis nos links:


https://github.com/TinkoffCreditSystems/angular-contenteditable-accessor
https://www.npmjs.com/package/@tinkoff/angular-contenteditable-accessor


E brinque com um exemplo aqui:
https://stackblitz.com/edit/angular2-contenteditable-value-accessor

Source: https://habr.com/ru/post/pt443714/


All Articles