Integrating contenteditable with Angular
How to properly integrate contenteditable elements with Angular, handle state synchronization, and prevent caret position issues
When to Use This Tip
Use this pattern when you need to:
- Integrate contenteditable with Angular
- Handle two-way data binding
- Prevent caret position jumps on re-renders
- Work with Angular’s change detection
- Implement ControlValueAccessor for form integration
Problem
Angular’s change detection and Zone.js can cause issues with contenteditable:
- Caret position jumps on re-renders
ngModeldoesn’t work directly with contenteditable- Zone.js triggers change detection on every keystroke
- No native form control support
- State synchronization between DOM and Angular state
Solution
1. Basic Angular Component with contenteditable
Simple integration with manual state management:
import { Component, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
@Component({
selector: 'app-content-editable',
template: `
<div
#editor
contenteditable="true"
(input)="onInput($event)"
(blur)="onBlur()"
[innerHTML]="content"
></div>
`,
})
export class ContentEditableComponent implements AfterViewInit {
@ViewChild('editor', { static: false }) editorRef!: ElementRef<HTMLElement>;
content = '';
private isUpdating = false;
ngAfterViewInit() {
// Initial setup
}
onInput(event: Event) {
if (this.isUpdating) return;
const target = event.target as HTMLElement;
this.content = target.innerHTML;
}
onBlur() {
// Save final state
if (this.editorRef) {
this.content = this.editorRef.nativeElement.innerHTML;
}
}
updateContent(newContent: string) {
if (this.editorRef && this.editorRef.nativeElement.innerHTML !== newContent) {
this.isUpdating = true;
this.editorRef.nativeElement.innerHTML = newContent;
this.isUpdating = false;
}
}
}
2. Caret Position Preservation
Save and restore caret position to prevent jumps:
import { Component, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core';
@Component({
selector: 'app-content-editable',
template: `
<div
#editor
contenteditable="true"
(input)="onInput($event)"
(keyup)="saveCaretPosition()"
(mouseup)="saveCaretPosition()"
[innerHTML]="content"
></div>
`,
})
export class ContentEditableComponent implements AfterViewInit, OnDestroy {
@ViewChild('editor', { static: false }) editorRef!: ElementRef<HTMLElement>;
content = '';
private savedSelection: { start: number; end: number; collapsed: boolean } | null = null;
private isUpdating = false;
ngAfterViewInit() {
// Save caret position on input
if (this.editorRef) {
this.editorRef.nativeElement.addEventListener('input', () => {
this.saveCaretPosition();
});
}
}
ngOnDestroy() {
// Cleanup if needed
}
saveCaretPosition() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const editor = this.editorRef.nativeElement;
// Calculate character offsets
const startRange = range.cloneRange();
startRange.selectNodeContents(editor);
startRange.setEnd(range.startContainer, range.startOffset);
const startOffset = startRange.toString().length;
const endRange = range.cloneRange();
endRange.selectNodeContents(editor);
endRange.setEnd(range.endContainer, range.endOffset);
const endOffset = endRange.toString().length;
this.savedSelection = {
start: startOffset,
end: endOffset,
collapsed: range.collapsed,
};
}
restoreCaretPosition() {
if (!this.savedSelection || !this.editorRef) return;
const selection = window.getSelection();
const range = document.createRange();
const editor = this.editorRef.nativeElement;
// Find start position
let currentOffset = 0;
const walker = document.createTreeWalker(
editor,
NodeFilter.SHOW_TEXT,
null
);
let startNode = null;
let startOffset = 0;
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= this.savedSelection.start) {
startNode = node;
startOffset = this.savedSelection.start - currentOffset;
break;
}
currentOffset += nodeLength;
}
if (!startNode) return;
range.setStart(startNode, startOffset);
if (this.savedSelection.collapsed) {
range.collapse(true);
} else {
// Find end position
currentOffset = 0;
const walker = document.createTreeWalker(
editor,
NodeFilter.SHOW_TEXT,
null
);
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= this.savedSelection.end) {
const endNode = node;
const endOffset = this.savedSelection.end - currentOffset;
range.setEnd(endNode, endOffset);
break;
}
currentOffset += nodeLength;
}
}
selection.removeAllRanges();
selection.addRange(range);
}
onInput(event: Event) {
if (this.isUpdating) return;
const target = event.target as HTMLElement;
this.content = target.innerHTML;
this.saveCaretPosition();
}
updateContent(newContent: string) {
if (this.editorRef && this.editorRef.nativeElement.innerHTML !== newContent) {
this.isUpdating = true;
this.saveCaretPosition();
this.editorRef.nativeElement.innerHTML = newContent;
// Restore caret after DOM update
setTimeout(() => {
this.restoreCaretPosition();
this.isUpdating = false;
}, 0);
}
}
}
3. ControlValueAccessor Implementation
Implement ControlValueAccessor for form integration:
import { Component, ElementRef, ViewChild, forwardRef, AfterViewInit } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-content-editable',
template: `
<div
#editor
contenteditable="true"
(input)="onInput($event)"
(blur)="onBlur()"
[class.disabled]="disabled"
[attr.contenteditable]="disabled ? 'false' : 'true'"
></div>
`,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ContentEditableComponent),
multi: true,
},
],
})
export class ContentEditableComponent implements ControlValueAccessor, AfterViewInit {
@ViewChild('editor', { static: false }) editorRef!: ElementRef<HTMLElement>;
content = '';
disabled = false;
private savedSelection: { start: number; end: number } | null = null;
private isUpdating = false;
private onChange = (value: string) => {};
private onTouched = () => {};
ngAfterViewInit() {
if (this.editorRef) {
this.editorRef.nativeElement.addEventListener('input', () => {
this.saveCaretPosition();
});
}
}
saveCaretPosition() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const editor = this.editorRef.nativeElement;
const startRange = range.cloneRange();
startRange.selectNodeContents(editor);
startRange.setEnd(range.startContainer, range.startOffset);
const startOffset = startRange.toString().length;
const endRange = range.cloneRange();
endRange.selectNodeContents(editor);
endRange.setEnd(range.endContainer, range.endOffset);
const endOffset = endRange.toString().length;
this.savedSelection = {
start: startOffset,
end: endOffset,
collapsed: range.collapsed,
};
}
restoreCaretPosition() {
if (!this.savedSelection || !this.editorRef) return;
const selection = window.getSelection();
const range = document.createRange();
const editor = this.editorRef.nativeElement;
let currentOffset = 0;
const walker = document.createTreeWalker(
editor,
NodeFilter.SHOW_TEXT,
null
);
let startNode = null;
let startOffset = 0;
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= this.savedSelection.start) {
startNode = node;
startOffset = this.savedSelection.start - currentOffset;
break;
}
currentOffset += nodeLength;
}
if (!startNode) return;
range.setStart(startNode, startOffset);
if (this.savedSelection.collapsed) {
range.collapse(true);
} else {
currentOffset = 0;
const walker = document.createTreeWalker(
editor,
NodeFilter.SHOW_TEXT,
null
);
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= this.savedSelection.end) {
const endNode = node;
const endOffset = this.savedSelection.end - currentOffset;
range.setEnd(endNode, endOffset);
break;
}
currentOffset += nodeLength;
}
}
selection.removeAllRanges();
selection.addRange(range);
}
onInput(event: Event) {
if (this.isUpdating || this.disabled) return;
const target = event.target as HTMLElement;
const newContent = target.innerHTML;
if (newContent !== this.content) {
this.content = newContent;
this.onChange(this.content);
this.saveCaretPosition();
}
}
onBlur() {
this.onTouched();
}
// ControlValueAccessor implementation
writeValue(value: string) {
if (value !== this.content && this.editorRef) {
this.isUpdating = true;
this.saveCaretPosition();
this.editorRef.nativeElement.innerHTML = value || '';
this.content = value || '';
setTimeout(() => {
this.restoreCaretPosition();
this.isUpdating = false;
}, 0);
}
}
registerOnChange(fn: (value: string) => void) {
this.onChange = fn;
}
registerOnTouched(fn: () => void) {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean) {
this.disabled = isDisabled;
if (this.editorRef) {
this.editorRef.nativeElement.setAttribute('contenteditable', isDisabled ? 'false' : 'true');
}
}
}
// Usage in template
// <app-content-editable [(ngModel)]="content"></app-content-editable>
// or
// <app-content-editable [formControl]="contentControl"></app-content-editable>
4. Zone.js Optimization
Run outside Angular zone to improve performance:
import { Component, ElementRef, ViewChild, NgZone, AfterViewInit } from '@angular/core';
@Component({
selector: 'app-content-editable',
template: `
<div
#editor
contenteditable="true"
(input)="onInput($event)"
[innerHTML]="content"
></div>
`,
})
export class ContentEditableComponent implements AfterViewInit {
@ViewChild('editor', { static: false }) editorRef!: ElementRef<HTMLElement>;
content = '';
private isUpdating = false;
constructor(private ngZone: NgZone) {}
ngAfterViewInit() {
if (this.editorRef) {
const editor = this.editorRef.nativeElement;
// Run input events outside Angular zone for better performance
this.ngZone.runOutsideAngular(() => {
editor.addEventListener('input', (e) => {
// Only trigger change detection when needed
this.ngZone.run(() => {
this.onInput(e);
});
});
editor.addEventListener('keyup', () => {
// Debounce change detection
this.ngZone.run(() => {
this.saveCaretPosition();
});
});
});
}
}
onInput(event: Event) {
if (this.isUpdating) return;
const target = event.target as HTMLElement;
this.content = target.innerHTML;
}
saveCaretPosition() {
// Save caret position
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
// Implementation from previous example
}
updateContent(newContent: string) {
if (this.editorRef && this.editorRef.nativeElement.innerHTML !== newContent) {
this.isUpdating = true;
this.saveCaretPosition();
this.editorRef.nativeElement.innerHTML = newContent;
setTimeout(() => {
this.restoreCaretPosition();
this.isUpdating = false;
}, 0);
}
}
}
5. Complete Angular Integration with Reactive Forms
Full integration with reactive forms and change detection optimization:
import { Component, ElementRef, ViewChild, forwardRef, AfterViewInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormControl } from '@angular/forms';
import { Subject } from 'rxjs';
import { debounceTime, takeUntil } from 'rxjs/operators';
@Component({
selector: 'app-content-editable',
template: `
<div
#editor
contenteditable="true"
[class.disabled]="disabled"
[class.error]="hasError"
[attr.contenteditable]="disabled ? 'false' : 'true'"
></div>
<div *ngIf="hasError" class="error-message">{{ errorMessage }}</div>
`,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ContentEditableComponent),
multi: true,
},
],
})
export class ContentEditableComponent implements ControlValueAccessor, AfterViewInit, OnDestroy {
@ViewChild('editor', { static: false }) editorRef!: ElementRef<HTMLElement>;
content = '';
disabled = false;
hasError = false;
errorMessage = '';
private savedSelection: { start: number; end: number; collapsed: boolean } | null = null;
private isUpdating = false;
private destroy$ = new Subject<void>();
private inputSubject = new Subject<string>();
private onChange = (value: string) => {};
private onTouched = () => {};
constructor(
private ngZone: NgZone,
private cdr: ChangeDetectorRef
) {
// Debounce input updates
this.inputSubject.pipe(
debounceTime(100),
takeUntil(this.destroy$)
).subscribe(value => {
this.content = value;
this.onChange(value);
});
}
ngAfterViewInit() {
if (this.editorRef) {
const editor = this.editorRef.nativeElement;
// Run outside zone for better performance
this.ngZone.runOutsideAngular(() => {
editor.addEventListener('input', (e) => {
const target = e.target as HTMLElement;
this.saveCaretPosition();
// Trigger change detection only when needed
this.ngZone.run(() => {
this.inputSubject.next(target.innerHTML);
});
});
editor.addEventListener('blur', () => {
this.ngZone.run(() => {
this.onTouched();
this.cdr.markForCheck();
});
});
editor.addEventListener('keyup', () => {
this.saveCaretPosition();
});
editor.addEventListener('mouseup', () => {
this.saveCaretPosition();
});
});
}
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
saveCaretPosition() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const editor = this.editorRef.nativeElement;
const startRange = range.cloneRange();
startRange.selectNodeContents(editor);
startRange.setEnd(range.startContainer, range.startOffset);
const startOffset = startRange.toString().length;
const endRange = range.cloneRange();
endRange.selectNodeContents(editor);
endRange.setEnd(range.endContainer, range.endOffset);
const endOffset = endRange.toString().length;
this.savedSelection = {
start: startOffset,
end: endOffset,
collapsed: range.collapsed,
};
}
restoreCaretPosition() {
if (!this.savedSelection || !this.editorRef) return;
const selection = window.getSelection();
const range = document.createRange();
const editor = this.editorRef.nativeElement;
let currentOffset = 0;
const walker = document.createTreeWalker(
editor,
NodeFilter.SHOW_TEXT,
null
);
let startNode = null;
let startOffset = 0;
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= this.savedSelection.start) {
startNode = node;
startOffset = this.savedSelection.start - currentOffset;
break;
}
currentOffset += nodeLength;
}
if (!startNode) return;
range.setStart(startNode, startOffset);
if (this.savedSelection.collapsed) {
range.collapse(true);
} else {
currentOffset = 0;
const walker = document.createTreeWalker(
editor,
NodeFilter.SHOW_TEXT,
null
);
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= this.savedSelection.end) {
const endNode = node;
const endOffset = this.savedSelection.end - currentOffset;
range.setEnd(endNode, endOffset);
break;
}
currentOffset += nodeLength;
}
}
selection.removeAllRanges();
selection.addRange(range);
}
// ControlValueAccessor implementation
writeValue(value: string) {
if (value !== this.content && this.editorRef) {
this.isUpdating = true;
this.saveCaretPosition();
this.editorRef.nativeElement.innerHTML = value || '';
this.content = value || '';
requestAnimationFrame(() => {
this.restoreCaretPosition();
this.isUpdating = false;
this.cdr.markForCheck();
});
}
}
registerOnChange(fn: (value: string) => void) {
this.onChange = fn;
}
registerOnTouched(fn: () => void) {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean) {
this.disabled = isDisabled;
if (this.editorRef) {
this.editorRef.nativeElement.setAttribute('contenteditable', isDisabled ? 'false' : 'true');
}
}
// Validation support
setError(error: string | null) {
this.hasError = !!error;
this.errorMessage = error || '';
this.cdr.markForCheck();
}
}
// Usage
// In component:
// contentControl = new FormControl('', [Validators.required]);
//
// In template:
// <app-content-editable [formControl]="contentControl"></app-content-editable>
6. Standalone Component (Angular 14+)
Modern standalone component approach:
import { Component, ElementRef, ViewChild, forwardRef, AfterViewInit, signal, effect } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-content-editable',
standalone: true,
imports: [CommonModule, FormsModule, ReactiveFormsModule],
template: `
<div
#editor
contenteditable="true"
[class.disabled]="disabled()"
[attr.contenteditable]="disabled() ? 'false' : 'true'"
></div>
`,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ContentEditableComponent),
multi: true,
},
],
})
export class ContentEditableComponent implements ControlValueAccessor, AfterViewInit {
@ViewChild('editor', { static: false }) editorRef!: ElementRef<HTMLElement>;
content = signal('');
disabled = signal(false);
private savedSelection: { start: number; end: number; collapsed: boolean } | null = null;
private isUpdating = false;
private onChange = (value: string) => {};
private onTouched = () => {};
constructor() {
// React to content changes
effect(() => {
const value = this.content();
if (this.editorRef && !this.isUpdating) {
this.updateEditorContent(value);
}
});
}
ngAfterViewInit() {
if (this.editorRef) {
const editor = this.editorRef.nativeElement;
editor.addEventListener('input', (e) => {
if (this.isUpdating) return;
const target = e.target as HTMLElement;
this.content.set(target.innerHTML);
this.onChange(target.innerHTML);
this.saveCaretPosition();
});
editor.addEventListener('blur', () => {
this.onTouched();
});
editor.addEventListener('keyup', () => {
this.saveCaretPosition();
});
}
}
updateEditorContent(value: string) {
if (this.editorRef && this.editorRef.nativeElement.innerHTML !== value) {
this.isUpdating = true;
this.saveCaretPosition();
this.editorRef.nativeElement.innerHTML = value || '';
requestAnimationFrame(() => {
this.restoreCaretPosition();
this.isUpdating = false;
});
}
}
saveCaretPosition() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const editor = this.editorRef.nativeElement;
const startRange = range.cloneRange();
startRange.selectNodeContents(editor);
startRange.setEnd(range.startContainer, range.startOffset);
const startOffset = startRange.toString().length;
const endRange = range.cloneRange();
endRange.selectNodeContents(editor);
endRange.setEnd(range.endContainer, range.endOffset);
const endOffset = endRange.toString().length;
this.savedSelection = {
start: startOffset,
end: endOffset,
collapsed: range.collapsed,
};
}
restoreCaretPosition() {
if (!this.savedSelection || !this.editorRef) return;
const selection = window.getSelection();
const range = document.createRange();
const editor = this.editorRef.nativeElement;
let currentOffset = 0;
const walker = document.createTreeWalker(
editor,
NodeFilter.SHOW_TEXT,
null
);
let startNode = null;
let startOffset = 0;
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= this.savedSelection.start) {
startNode = node;
startOffset = this.savedSelection.start - currentOffset;
break;
}
currentOffset += nodeLength;
}
if (!startNode) return;
range.setStart(startNode, startOffset);
if (this.savedSelection.collapsed) {
range.collapse(true);
} else {
currentOffset = 0;
const walker = document.createTreeWalker(
editor,
NodeFilter.SHOW_TEXT,
null
);
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= this.savedSelection.end) {
const endNode = node;
const endOffset = this.savedSelection.end - currentOffset;
range.setEnd(endNode, endOffset);
break;
}
currentOffset += nodeLength;
}
}
selection.removeAllRanges();
selection.addRange(range);
}
writeValue(value: string) {
this.content.set(value || '');
}
registerOnChange(fn: (value: string) => void) {
this.onChange = fn;
}
registerOnTouched(fn: () => void) {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean) {
this.disabled.set(isDisabled);
if (this.editorRef) {
this.editorRef.nativeElement.setAttribute('contenteditable', isDisabled ? 'false' : 'true');
}
}
}
Notes
- Always save caret position before DOM updates
- Use
setTimeoutorrequestAnimationFrameto restore caret after DOM changes - Implement
ControlValueAccessorfor form integration - Use
NgZone.runOutsideAngular()to optimize performance - Debounce input events to reduce change detection cycles
- Use signals (Angular 16+) for better reactivity
- Test with Angular’s change detection in both default and OnPush modes
- Consider using
ChangeDetectorRef.markForCheck()in OnPush mode
Browser Compatibility
- Chrome/Edge: Works well with Angular
- Firefox: Good support, but test caret restoration
- Safari: Works, but be careful with Zone.js change detection