Search the Qalma docs

Type at least two characters to get started.

Docs

Forms Integration

Qalma ships an optional Angular forms adapter through @qalma/editor/forms. Import it only in components that bind the editor to formControl, formControlName, or ngModel.

The adapter keeps the editor headless:

Direction Value
Form to editor HTML string passed to editor.setHtml()
Editor to form Normalized editor.html()
Empty form value ''
Empty editor document <p></p> internally

That empty-value mapping is important. Angular validators such as Validators.required see '' as empty, while the ProseMirror document still gets the paragraph it needs internally.

When to reach for the adapter

The adapter exists so Angular can own the field. Reach for it when the editor lives inside a form and you want validation, touched/dirty, disabled, reset(), or formControlName inside a group. If the editor stands on its own, skip the adapter (and @angular/forms) and drive the controller directly — see Without forms.

Your context Use
A field inside a
, with validation / disabled / touched / resets
The adapter: formControl, formControlName, or ngModel
A standalone editor you just read and persist The controller: html(), getJSON(), isEmpty()

See the live Form state example for validators, reset, disabled state, touched/dirty state, and the emitted control value working together.

Reactive Forms

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
  FormControl,
  FormResetEvent,
  ReactiveFormsModule,
  TouchedChangeEvent,
  Validators,
  ValueChangeEvent,
} from '@angular/forms';
import {
  HistoryPlugin,
  PlaceholderPlugin,
  QalmaContent,
  QalmaEditor,
  TextFormattingKit,
  createQalmaEditor,
} from '@qalma/editor';
import { QalmaControlValueAccessor } from '@qalma/editor/forms';

@Component({
  selector: 'app-editor-form',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    ReactiveFormsModule,
    QalmaControlValueAccessor,
    QalmaEditor,
    QalmaContent,
  ],
  template: `
    <form class="space-y-3" (submit)="submit($event)">
      <qalma-editor
        class="block rounded border"
        [editor]="editor"
        [formControl]="body"
      >
        <qalma-content
          class="block min-h-40 p-4 [&_.ProseMirror]:outline-none"
        />
      </qalma-editor>

      @if (body.invalid && body.touched) {
        <p class="text-sm text-red-600">Write a message before sending.</p>
      }

      <div class="flex gap-2">
        <button type="submit" [disabled]="body.invalid">Send</button>
        <button type="button" (click)="reset()">Reset</button>
        <button type="button" (click)="toggleDisabled()">
          {{ body.disabled ? 'Enable' : 'Disable' }}
        </button>
      </div>

      <pre><code>{{ body.value }}</code></pre>
      <pre><code>{{ events().join(', ') }}</code></pre>
    </form>
  `,
})
export class EditorForm {
  readonly body = new FormControl('', {
    nonNullable: true,
    validators: [Validators.required],
  });

  protected readonly editor = createQalmaEditor({
    plugins: [
      ...TextFormattingKit,
      PlaceholderPlugin.configure({ placeholder: 'Write your message...' }),
      HistoryPlugin,
    ],
  });

  protected readonly events = signal<readonly string[]>([]);

  constructor() {
    this.body.events.pipe(takeUntilDestroyed()).subscribe((event) => {
      if (event instanceof ValueChangeEvent) {
        this.events.update((events) => [...events, 'value']);
      }

      if (event instanceof TouchedChangeEvent && event.touched) {
        this.events.update((events) => [...events, 'touched']);
      }

      if (event instanceof FormResetEvent) {
        this.events.update((events) => [...events, 'reset']);
      }
    });
  }

  protected submit(event: Event): void {
    event.preventDefault();
    this.body.markAsTouched();

    if (this.body.invalid) {
      return;
    }

    sendHtmlToApi(this.body.value);
  }

  protected reset(): void {
    this.body.reset('');
  }

  protected toggleDisabled(): void {
    if (this.body.disabled) {
      this.body.enable();
    } else {
      this.body.disable();
    }
  }
}

function sendHtmlToApi(html: string): void {
  console.log(html);
}

Use the form control as the source of truth. When an editor is bound to a form, do not also seed the same document through createQalmaEditor({ content }); that creates two initial values competing for the same surface.

Template-Driven Forms

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { QalmaContent, QalmaEditor, createQalmaEditor } from '@qalma/editor';
import { QalmaControlValueAccessor } from '@qalma/editor/forms';

@Component({
  selector: 'app-editor-ng-model',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [FormsModule, QalmaControlValueAccessor, QalmaEditor, QalmaContent],
  template: `
    <qalma-editor
      [editor]="editor"
      [(ngModel)]="body"
      [ngModelOptions]="{ standalone: true }"
    >
      <qalma-content class="block min-h-40 p-4" />
    </qalma-editor>
  `,
})
export class EditorNgModel {
  protected body = '<p>Draft</p>';
  protected readonly editor = createQalmaEditor();
}

When ngModel belongs to a template-driven , give the editor a name just like any other Angular form control.

Without forms

When the editor is not part of an Angular form, you do not need the adapter or @angular/forms at all. Keep the controller as the source of truth: read its html() signal, persist with getJSON(), and use isEmpty() for the same empty check the adapter performs for Validators.required.

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { QalmaContent, QalmaEditor, createQalmaEditor } from '@qalma/editor';

@Component({
  selector: 'app-standalone-editor',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [QalmaEditor, QalmaContent],
  template: `
    <qalma-editor [editor]="editor">
      <qalma-content
        class="block min-h-40 p-4 [&_.ProseMirror]:outline-none"
      />
    </qalma-editor>

    <button type="button" [disabled]="editor.isEmpty()" (click)="save()">
      Save
    </button>
  `,
})
export class StandaloneEditor {
  protected readonly editor = createQalmaEditor({
    content: '<p>Draft</p>',
  });

  protected save(): void {
    if (this.editor.isEmpty()) {
      return;
    }

    persistDraft(this.editor.getJSON());
  }
}

function persistDraft(doc: unknown): void {
  localStorage.setItem('qalma-draft', JSON.stringify(doc));
}

Here the controller owns the initial content through createQalmaEditor({ content }) — which is exactly why you do not seed content when a form control is bound: in that case the control is the source of truth. See Content & Serialization for reading and persisting without a form.

Behavior

The adapter is intentionally narrow:

  • Values are HTML strings. Persist lossless JSON with editor.getJSON() and editor.setJSON() outside the CVA path when your app needs that format.
  • setDisabledState(true) calls editor.setEditable(false).
  • registerOnTouched() fires when focus leaves the whole host, so toolbar clicks inside the editor do not mark the control touched.
  • Empty editor documents — as reported by editor.isEmpty() — emit '', so Validators.required works with rich text content.

Signal Forms native support is intentionally not part of this adapter yet. The current CVA works through Angular's compatibility path; a dedicated Signal Forms adapter can be added later when that API is stable enough to make public.