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