Getting Started
Overview
Installation
Theme
Forms
Basic Inputs
Custom Inputs
Select
Components
Components
78
Micro Charts
Navigation
Libraries
Content Editor
Data View
Form Builder
Headless Editor
Image Designer
Kanban Board
PDF Builder
PDF Signer
PDF Viewer
Video Player
GitHub repositoryGitHub starsGitHub forksNgStarter UI npm versionNgStarter UI npm downloads per month

Forms and Serialization

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.

Loading example…

Storing and loading

// 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);
  • Store the whole document, including version, so future migrations can recognize it.
  • setDocument() normalizes input and resets history; pass false as the second argument to keep it.
  • Loading an equal document is a no-op, which prevents feedback loops.

Reactive forms

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

Signals

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

HTML and text

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.