

The JSON document is the value you store. It is versioned, independent of the DOM, and safe to render on any platform. HTML and plain text are derived outputs for previews, e-mails, and search.
// save
const json = JSON.stringify(editor.document());
await api.saveNote(noteId, json);
// load
const saved = JSON.parse(await api.loadNote(noteId)) as NgsHeadlessEditorDocument;
editor.setDocument(saved);version, so future migrations can recognize it.setDocument() normalizes input and resets history; pass false as the second argument to keep it. Wrap the editor in a component that implements ControlValueAccessor. Each instance provides its own editor. Forward user changes with an effect that skips external changes, so values written by the form are not echoed back.
@Component({
selector: 'app-rich-text-field',
imports: [NgsHeadlessEditorSurface],
providers: [
provideNgsHeadlessEditor(withHeadlessEditorPlugin(basicTextEditorPlugin())),
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RichTextField), multi: true }
],
template: `<div ngsHeadlessEditorSurface></div>`
})
export class RichTextField implements ControlValueAccessor {
readonly editor = inject(NgsHeadlessEditor);
private onChange: (value: NgsHeadlessEditorDocument) => void = () => {};
private onTouched: () => void = () => {};
private wasFocused = false;
constructor() {
effect(() => {
const document = this.editor.document();
if (this.editor.origin() !== 'external') {
untracked(() => this.onChange(document));
}
});
effect(() => {
const focused = this.editor.focused();
if (this.wasFocused && !focused) {
untracked(() => this.onTouched());
}
this.wasFocused = focused;
});
}
writeValue(value: NgsHeadlessEditorDocument | null): void {
this.editor.setDocument(value ?? createNgsHeadlessEditorDocument());
}
registerOnChange(fn: (value: NgsHeadlessEditorDocument) => void): void { this.onChange = fn; }
registerOnTouched(fn: () => void): void { this.onTouched = fn; }
setDisabledState(disabled: boolean): void { this.editor.setReadOnly(disabled); }
} Validate with isNgsHeadlessEditorDocumentEmpty(): a document with only whitespace counts as empty, while atomic blocks can opt out through isEmpty.
export function richTextRequired(
control: AbstractControl<NgsHeadlessEditorDocument | null>
): ValidationErrors | null {
const value = control.value;
return !value || isNgsHeadlessEditorDocumentEmpty(value) ? { required: true } : null;
}
readonly form = new FormGroup({
body: new FormControl<NgsHeadlessEditorDocument | null>(null, richTextRequired)
});// Two-way binding with a model() input
readonly value = model<NgsHeadlessEditorDocument>(createNgsHeadlessEditorDocument());
constructor() {
effect(() => {
const value = this.value();
untracked(() => this.editor.setDocument(value)); // no-op when equal
});
effect(() => {
const document = this.editor.document();
if (this.editor.origin() !== 'external') {
untracked(() => this.value.set(document));
}
});
}Write a serializer that maps your block and mark types to tags. Escape all text and attribute values, drop unknown types, and validate attributes such as URLs again, because stored documents can be edited outside the editor.
const MARK_TAGS: Record<string, string> = { bold: 'strong', italic: 'em', strike: 's', code: 'code' };
export function toHtml(document: NgsHeadlessEditorDocument): string {
return document.blocks.map(block => {
const inner = isNgsHeadlessEditorTextContent(block.content)
? block.content.map(run => run.marks.reduce((html, mark) => {
const tag = MARK_TAGS[mark.type];
return tag ? `<${tag}>${html}</${tag}>` : html;
}, escapeHtml(run.text))).join('')
: '';
return `<p>${inner}</p>`;
}).join('');
}getNgsHeadlessEditorDocumentText() returns the text of all blocks separated by newlines, useful for search indexes, notifications, and character limits.