<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400..600;1,9..144,400..600&family=Imperial+Script&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" />

Search the Qalma docs

Type at least two characters to get started.

Docs

Content & Serialization

Qalma parses initial HTML into a ProseMirror document using the schema produced by your selected plugins, and can serialize that document back out in three formats.

const editor = createQalmaEditor({
  content: '<h2>Plan</h2><p><strong>Ship the docs.</strong></p>',
  plugins: [HeadingsPlugin, ...TextFormattingKit],
});

Output formats

Format Read Write Lossless Best for
HTML html() signal setHtml() Yes Rendering, interop with existing HTML
JSON getJSON() setJSON() Yes Persisting and restoring documents
Markdown getMarkdown() setMarkdown() (optional entrypoint) No Markdown interchange

JSON is ProseMirror's native document model. For application storage, prefer the versioned envelope described below. Markdown import is optional so the parser never enters bundles that only export Markdown.

Initial content

content is optional. If you omit it, Qalma starts from '<p></p>'.

const emptyEditor = createQalmaEditor();

When the editor mounts, the controller parses editor.html() with the active schema and creates the ProseMirror state. Unsupported tags are handled by ProseMirror's DOM parser according to the current schema. For example, a document can only keep headings when HeadingsPlugin is present.

const editor = createQalmaEditor({
  content: '<h1>Kept as a heading</h1>',
  plugins: [HeadingsPlugin],
});

Serialized HTML signal

editor.html is an Angular signal. It updates after transactions that change the document.

<qalma-editor [editor]="editor">
  <qalma-content class="block min-h-40 p-4 [&_.ProseMirror]:outline-none" />
</qalma-editor>

<pre><code>{{ editor.html() }}</code></pre>

Qalma serializes the document content, not the outer doc node. Newline characters inside serialized output are escaped as .

Replacing content

Use setHtml(html) when the app loads a saved document or restores a draft.

loadSavedDocument(html: string): void {
  this.editor.setHtml(html);
  this.editor.focus();
}

Before the content surface is mounted, setHtml() updates only the signal. After mount, it rebuilds the editor state with the same schema, plugins, and base keymaps.

Content limits

All import paths and live document transactions enforce configurable complexity limits. Defaults allow 1,000,000 HTML/Markdown characters, 50,000 JSON values or document nodes/marks, 100 levels of nesting, and 1,000,000 aggregate JSON or document text characters.

const editor = createQalmaEditor({
  contentLimits: {
    maxHtmlLength: 250_000,
    maxMarkdownLength: 250_000,
    maxJsonNodes: 20_000,
    maxJsonDepth: 50,
    maxJsonTextLength: 250_000,
  },
});

Rejected content throws a RangeError before it replaces the current document. Lower the limits for short-form surfaces such as comments. See Security for the complete trust-boundary guidance.

Saving content

Because html is a signal, you can bind it directly or react to it with Angular reactivity.

import { computed, effect } from '@angular/core';

readonly hasContent = computed(() => !this.editor.isEmpty());

constructor() {
  effect(() => {
    localStorage.setItem('qalma-draft', this.editor.html());
  });
}

isEmpty() is the canonical "is there anything here?" check: it ignores empty paragraphs and hard breaks, still counts media such as images and tables, and stays reactive inside computed. See Editor & Controller.

For form-specific synchronization, use the adapter in Forms Integration so Angular owns validation, disabled state, touched state, and resets.

JSON

getJSON() returns the document as ProseMirror's native JSON — the lossless format to persist. setJSON() restores it. Both work before and after the content surface mounts.

import { QalmaDocument } from '@qalma/editor';

saveDraft(): void {
  const doc: QalmaDocument = this.editor.getJSON();
  localStorage.setItem('qalma-draft', JSON.stringify(doc));
}

restoreDraft(): void {
  const raw = localStorage.getItem('qalma-draft');

  if (raw) {
    this.editor.setJSON(JSON.parse(raw) as QalmaDocument);
  }
}

Unlike HTML, JSON preserves every node attribute and mark exactly, so a getJSON() → setJSON() round-trip reproduces the document without loss. Prefer it over HTML when you control both the save and load sides.

Raw JSON is still untrusted input. First-party plugins validate their persisted attributes during setJSON(), and custom plugins must provide equivalent AttributeSpec.validate rules.

Versioned document storage

Raw JSON does not say which schema produced it. Use getStoredDocument()/setStoredDocument() for durable persistence:

const editor = createQalmaEditor({
  plugins: [HeadingsPlugin],
  schemaVersion: 2,
  migrations: [
    {
      fromVersion: 1,
      toVersion: 2,
      migrate: (document) => migrateLegacyHeadings(document),
    },
  ],
});

localStorage.setItem('qalma-draft', JSON.stringify(editor.getStoredDocument()));

editor.setStoredDocument(JSON.parse(savedDraft));

The envelope records its format version, your schemaVersion, schema-owning plugin keys, and the lossless document JSON. Restoring rejects future schemas, missing schema plugins, malformed envelopes, and gaps in the migration chain. Migrations must advance exactly one version at a time. Keep setJSON() for raw ProseMirror interop or intentionally unversioned data.

Markdown

getMarkdown() serializes the document to Markdown (CommonMark plus GitHub-Flavored extensions: tables, task lists, and strikethrough).

exportMarkdown(): string {
  return this.editor.getMarkdown();
}

Marks and nodes that Markdown cannot express fall back to inline HTML — which CommonMark permits — so the output stays complete instead of silently dropping content:

Content Markdown output
Bold, italic, strikethrough **b**, *i*, ~~s~~
Inline code, code block `code`, fenced ``` with language
Links, images [text](href), ![alt](src)
Headings, blockquote, lists, task lists, tables #, >, -/1., - [x], GFM pipe table
Underline <u>...</u>
Monospace <span data-qalma-monospace="">...</span>
Subscript / superscript <sub>...</sub> / <sup>...</sup>
Text color / highlight <span style> / <mark>
Mention the mention label as plain text

To import Markdown, opt into the secondary entrypoint:

import { MarkdownPlugin } from '@qalma/editor/markdown';

const editor = createQalmaEditor({
  plugins: [MarkdownPlugin, HeadingsPlugin, ...TextFormattingKit],
});

editor.setMarkdown('# Imported\n\n**Bold** text');

The generated HTML is parsed through the plugins you selected, so unsupported Markdown structures normalize to that schema. To let users type Markdown shortcuts, use each feature plugin's input rules instead.

Plugin-owned serialization

Each plugin decides how its node or mark parses and serializes:

Plugin HTML surface
TextFormattingKit <strong>, <em>, <u>, <s>
InlineCodePlugin <code>
MonospacePlugin <span data-qalma-monospace="">
HeadingsPlugin <h1> through configured levels
ListsPlugin <ul>, <ol>, <li>
BlockquotePlugin <blockquote>
CodeBlockPlugin <pre><code class="language-id">...</code></pre>
LinkPlugin <a href="..." target="..." rel="...">
ImagePlugin inline <img src="..." alt="..." title="...">
MentionPlugin <span data-qalma-mention ...>
ColorPlugin <span style="color: ...; background-color: ...">
HighlightPlugin <mark> or <mark style="background-color: ...">
TextAlignPlugin style="text-align: ..." on configured nodes, except left alignment serializes as no attribute.

Read-only content

setEditable(false) leaves serialization active. The editor still exposes html(), query(), and command state, but command execution and canExecute() return false while the controller is read-only.