ControlValueAccessor et contenteditable dans Angular

Vous êtes-vous déjà demandé comment fonctionne la combinaison de formulaires angulaires et d'éléments HTML à travers laquelle l'utilisateur saisit des données?


Dès le début, nous avons utilisé ControlValueAccessor , une interface spéciale composée de seulement 4 méthodes:


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

Hors de la boîte, Angular a plusieurs de ces accesseurs: pour les cases à cocher et les boutons radio, pour l'entrée et les sélections. Cependant, si vous développez un chat dans lequel vous devez donner la possibilité d'écrire en italique, de mettre le texte en gras ou, par exemple, d'insérer des émoticônes, vous utiliserez très probablement l'attribut contenteditable pour créer du contenu formaté.


Angular ne prend pas en charge l'utilisation de formulaires avec contenteditable , vous devez donc l'écrire vous-même.



ControlValueAccessor


La directive que nous écrivons fonctionnera de la même manière que les accesseurs intégrés - elle répondra à l'attribut contenteditable . Pour que le modèle et les formulaires réactifs le reçoivent via l'injection de dépendance, il suffit de fournir un InjectionToken intégré:


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

L'interface ControlValueAccessor nécessite 3 méthodes et possède 1 méthode facultative:


  • registerOnChange - une fonction entrera dans cette méthode lors de l'initialisation. Il est appelé avec une nouvelle valeur lorsque l'utilisateur a entré quelque chose dans notre composant afin que de nouvelles données soient entrées dans le contrôle.
  • registerOnTouched - une fonction entrera dans cette méthode lors de l'initialisation. Elle doit être appelée lorsque l'utilisateur a quitté notre composant pour que la commande soit touched . Ceci est utilisé pour la validation.
  • writeValue - cette méthode sera appelée par le contrôle pour transmettre la valeur à notre composant. Il est utilisé si la valeur change via le code extérieur ( setValue ou modification de la variable à laquelle ngModel est lié), ainsi que pour définir la valeur initiale.
    Il convient de noter que, contrairement aux formes réactives, ngModel se comporte de manière ngModel - en particulier, sa valeur initiale est initialisée avec un retard, et la méthode writeValue "twitches" deux fois, la première fois avec null :
    https://github.com/angular/angular/issues/14988
  • setDisabledState (facultatif) - cette méthode sera appelée par le contrôle lorsque l'état disabled change. Bien que la méthode soit facultative, il est préférable de répondre à cela dans votre composant.

Implémentation de l'interface


Pour travailler avec l'élément DOM, nous avons besoin de Renderer2 et, en fait, de l'élément lui-même, alors ajoutez-les au constructeur:


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

Nous sauvegardons les méthodes que le contrôle nous transmettra dans les champs privés de la classe:


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

disabled état disabled du composant contenteditable équivaut à désactiver le mode d'édition - contenteditable="false" . La définition de la valeur de l'extérieur équivaut à remplacer le DOM innerHTML de l'élément, et la mise à jour de la valeur par l'utilisateur et la sortie du composant peuvent être surveillées en s'abonnant aux événements correspondants:


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

En fait, c'est tout. Cela suffit pour la mise en œuvre de base du travail des formes angulaires et des éléments contenteditable .


Internet explorer


Cependant, il y en a deux mais.


Tout d'abord, la valeur initiale vide du formulaire est null , et après l' writeValue de Value dans IE11, nous verrons null dans le modèle. Pour implémenter correctement le travail, nous devons normaliser la valeur:


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

Ici, nous traiterons également la situation suivante. Imaginez que le contenu d'un élément ait des balises HTML. Si nous sélectionnons tout et le supprimons, il ne sera pas vide à l'intérieur - une balise solitaire y sera insérée. Afin de ne pas obstruer le contrôle avec une valeur vide, nous le considérerons comme une chaîne vide.


Deuxièmement, Internet Explorer ne prend pas en charge input événements d' input pour les éléments contenteditable . Nous devrons implémenter le repli en utilisant 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(); } 

Nous n'implémenterons pas de vérification sur un navigateur spécifique. Au lieu de cela, nous désactivons immédiatement MutationObserver sur le premier événement d' input :


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

Maintenant, notre composant fonctionne dans IE11 et nous sommes satisfaits de nous-mêmes!


Internet Explorer! ლ (ಠ 益 ಠ ლ)


Malheureusement, IE11 ne sera tout simplement pas laissé pour compte. Apparemment, il y a un bug dans le travail de MutationObserver . S'il y a des balises à l'intérieur de l'élément contenteditable , par exemple, some <b>text</b> , alors lors de la suppression du texte, ce qui entraînera la suppression de la balise entière (le mot text dans cet exemple), le rappel de l'observateur sera appelé avant le réel changements dans le DOM!



Malheureusement, nous n'avons d'autre choix que d'admettre la défaite et d'utiliser setTimeout :


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

Conclusion


Pourvu qu'Angular doive prendre en charge les versions 9, 10 et 11 d'Internet Explorer, il apparaît clairement pourquoi ils n'ont pas implémenté le travail avec contenteditable à la maison.


De plus, vous devez vous rappeler que le HTML peut contenir du code malveillant - par conséquent, ne prenez pas audacieusement du contenu inconnu et collez-le dans le contrôle, et l'entrée utilisateur doit être vérifiée dans les événements paste et drop . Le code décrit dans cet article fonctionne dans Angular 4 et supérieur, y compris FormHooks . Si vous le souhaitez, vous pouvez ajouter la prise en charge d'Angular 2, si vous utilisez Renderer , pas Renderer2 . Le code source et le package npm sont disponibles sur les liens:


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


Et jouez avec un exemple ici:
https://stackblitz.com/edit/angular2-contenteditable-value-accessor

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


All Articles