ControlValueAccessor y contenteditable en Angular

¿Alguna vez te has preguntado cómo funciona la combinación de formas angulares y elementos HTML a través de los cuales el usuario ingresa datos?


Desde el principio utilizamos ControlValueAccessor , una interfaz especial que consta de solo 4 métodos:


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

Fuera de la caja, Angular tiene varios de estos accesos: para casillas de verificación y botones de opción, para entrada y selección. Sin embargo, si está desarrollando un chat en el que necesita dar la oportunidad de escribir en cursiva, poner el texto en negrita o, por ejemplo, insertar emoticones, lo más probable es que use el atributo contenteditable para crear contenido formateado.


Angular no admite el uso de formularios junto con contenteditable , por lo que debe escribirlo usted mismo.



ControlValueAccessor


La directiva que escribimos funcionará de manera similar a los accesores incorporados: responderá al atributo contenteditable . Para que la plantilla y las formas reactivas lo reciban a través de la inyección de dependencia, es suficiente proporcionar un InjectionToken incorporado:


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

La interfaz ControlValueAccessor requiere 3 métodos y tiene 1 método opcional:


  • registerOnChange : una función entrará en este método durante la inicialización. Se llama con un nuevo valor cuando el usuario ha ingresado algo en nuestro componente para que se ingresen nuevos datos en el control.
  • registerOnTouched : una función entrará en este método durante la inicialización. Debe llamarse cuando el usuario dejó nuestro componente para que se touched el control. Esto se usa para la validación.
  • writeValue : el control writeValue este método para pasar el valor a nuestro componente. Se usa si el valor cambia a través del código externo ( setValue o cambiando la variable a la que está vinculado ngModel ), así como para establecer el valor inicial.
    Vale la pena señalar que, a diferencia de las formas reactivas, ngModel comporta de forma ngModel , en particular, el valor inicial se inicializa con un retraso, y el método writeValue "se contrae" dos veces, la primera vez con null :
    https://github.com/angular/angular/issues/14988
  • setDisabledState (opcional) : el control llamará a este método cuando cambie el estado disabled . Aunque el método es opcional, es mejor responder a esto en su componente.

Implementación de interfaz


Para trabajar con el elemento DOM, necesitamos Renderer2 y, de hecho, el elemento en sí mismo, así que agréguelos al constructor:


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

Guardamos los métodos que el control nos pasará en los campos privados de la clase:


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

disabled estado disabled para el componente contenteditable es equivalente a deshabilitar el modo de edición - contenteditable="false" . Establecer el valor desde el exterior es equivalente a reemplazar el DOM innerHTML HTML del elemento, y actualizar el valor por parte del usuario y abandonar el componente se puede monitorear suscribiéndose a los eventos correspondientes:


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

Eso, de hecho, es todo. Esto es suficiente para la implementación básica del trabajo de formas angulares y elementos contenteditable .


Explorador de internet


Sin embargo, hay una pareja pero.


En primer lugar, el valor inicial vacío del formulario es null , y después de writeValue en IE11, veremos null en la plantilla. Para implementar correctamente el trabajo, necesitamos normalizar el 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; } 

Aquí también manejaremos la siguiente situación. Imagine que el contenido de un elemento tiene etiquetas HTML. Si solo seleccionamos todo y lo eliminamos, no estará vacío en el interior: se insertará una etiqueta <br> solitaria allí. Para no obstruir el control con un valor vacío, lo consideraremos como una cadena vacía.


En segundo lugar, Internet Explorer no admite eventos de input para elementos contenteditable . Tendremos que implementar el respaldo utilizando 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(); } 

No implementaremos la verificación en un navegador específico. En cambio, deshabilitamos inmediatamente MutationObserver en el primer evento de input :


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

¡Ahora nuestro componente funciona en IE11 y estamos satisfechos con nosotros mismos!


Internet Explorer! ლ (ಠ 益 ಠ ლ)


Desafortunadamente, IE11 simplemente no se quedará atrás. Aparentemente, hay un error en el trabajo de MutationObserver . Si hay etiquetas dentro del elemento contenteditable , por ejemplo, some <b>text</b> , al eliminar texto que implicará eliminar toda la etiqueta (la palabra text en este ejemplo), se llamará a la devolución de llamada del observador antes de la real cambios en el DOM!



Desafortunadamente, no tenemos más remedio que admitir la derrota y usar setTimeout :


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

Conclusión


Siempre que Angular sea compatible con las versiones 9, 10 y 11 de Internet Explorer, queda claro por qué no implementaron el trabajo con contenido contenteditable en el hogar.


Además, debe recordar que el HTML puede contener código malicioso; por lo tanto, no tome audazmente contenido desconocido y péguelo en el control, y la entrada del usuario debe verificarse en drop eventos de paste y drop . El código descrito en este artículo funciona en Angular 4 y superior, incluidos FormHooks . Si lo desea, puede agregar soporte para Angular 2, si usa Renderer , no Renderer2 . El código fuente y el paquete npm están disponibles en los enlaces:


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


Y juega con un ejemplo aquí:
https://stackblitz.com/edit/angular2-contenteditable-value-accessor

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


All Articles