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),  |
| 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.