@qalma/editor
v1.0.0
Published
Angular-first headless rich text editor toolkit built on ProseMirror.
Maintainers
Readme
@qalma/editor
Angular-first, headless rich text editor toolkit built on ProseMirror.
Qalma gives you a typed editor controller, signal-based state, and a small set
of unstyled Angular primitives (<qalma-editor>, <qalma-content>,
<qalma-toolbar>, qalmaCommand). Everything else — toolbar UI, styling,
menus, popovers — stays in your app. You choose the plugins, you own the
markup.
Documentation & live demo → qalma.dev
Status: pre-1.0 (
0.x). The public API is stabilizing but may still change before1.0.
Installation
npm install @qalma/editor@angular/core >=21 <23 is a peer dependency. @angular/forms is an
optional peer dependency used only by the @qalma/editor/forms entrypoint.
Quick start
import { createQalmaEditor, HistoryPlugin, TextFormattingKit } from '@qalma/editor';
const editor = createQalmaEditor({
content: '<p>Hello world</p>',
plugins: [
...TextFormattingKit,
HistoryPlugin.configure({
depth: 200,
newGroupDelay: 750,
}),
],
});<qalma-editor [editor]="editor">
<qalma-toolbar>
<button qalmaCommand="toggleBold">Bold</button>
<button qalmaCommand="undo">Undo</button>
<button qalmaCommand="redo">Redo</button>
</qalma-toolbar>
<qalma-content />
</qalma-editor>Core concepts
createQalmaEditor(options)
Builds a QalmaEditorController, the headless API your components bind to:
| Member | Description |
| ------------------------------- | ---------------------------------------------------------------------- |
| html: Signal<string> | Serialized HTML of the current document, kept in sync with every edit. |
| editable: Signal<boolean> | Whether the document can be edited. |
| execute(command, value?) | Runs a registered command, e.g. editor.execute('toggleBold'). |
| canExecute(command, value?) | Whether a command would currently succeed. |
| isCommandActive(command) | Whether a toggleable command is active for the current selection. |
| query<T>(name) | Reads plugin-provided state, e.g. the link or image under the cursor. |
| getCoordinatesAtPosition(pos) | Reads viewport coordinates for a document position, or null. |
| setHtml(html) | Replaces the document content from an HTML string. |
| getJSON() | Serializes the document to ProseMirror's native, lossless JSON. |
| setJSON(doc) | Replaces the document content from a QalmaDocument JSON object. |
| getStoredDocument() | Wraps lossless JSON with storage, schema, and plugin versions. |
| setStoredDocument(doc) | Validates and migrates a versioned stored document. |
| getMarkdown() | Serializes the document to Markdown (CommonMark + GFM). |
| setMarkdown(markdown) | Imports Markdown when MarkdownPlugin is configured. |
| setEditable(editable) | Toggles editability at runtime. |
| focus() | Focuses the editor view. |
options.content accepts an initial HTML string, and options.plugins
accepts the list of QalmaPlugins that define the schema, commands, and
behavior available to the editor. Set schemaVersion and migrations when
persisted documents must evolve across schema changes. Use
options.contentLimits to lower the default HTML/Markdown length and JSON/live
document node, depth, and text ceilings for short-form products.
Serializing content
The controller can read and write the document in three formats:
- HTML —
html()(a live signal) andsetHtml(). Best for rendering and interop with existing HTML content. - JSON —
getJSON()/setJSON()for raw interop, orgetStoredDocument()/setStoredDocument()for versioned persistence. Both preserve ProseMirror's native document model losslessly. - Markdown —
getMarkdown()and optionalsetMarkdown(). CommonMark plus GFM (tables, task lists, strikethrough). Marks Markdown cannot express (underline, text color, highlight, sub/superscript, mentions) fall back to inline HTML so no content is dropped on export. Import lives in the optional@qalma/editor/markdownentrypoint, so apps that do not need it do not ship a Markdown parser.
const stored = editor.getStoredDocument(); // persist this envelope
editor.setStoredDocument(stored); // validate and migrate it later
const markdown = editor.getMarkdown(); // export to Markdownimport { MarkdownPlugin } from '@qalma/editor/markdown';
const editor = createQalmaEditor({ plugins: [MarkdownPlugin] });
editor.setMarkdown('# Imported');Security boundary
First-party plugins validate persisted attributes across HTML, commands, JSON,
paste, and serialization. Custom plugins remain trusted code: validate every
attribute with AttributeSpec.validate, build fresh allowlisted DOM attribute
objects, and never merge untrusted attribute bags. Keep Angular's normal
sanitizer when rendering stored HTML outside the editor; do not apply
bypassSecurityTrustHtml() to user-authored content. File MIME types are
client-provided hints and require server-side validation. See the repository
SECURITY.md and dated audit for the complete model.
Components and directives
<qalma-editor [editor]="editor">— root container that shares the controller with its content.<qalma-content />— mounts the ProseMirror view. Style its.qalma-content/.ProseMirrordescendants from your app's CSS.<qalma-toolbar>— an accessible (role="toolbar") wrapper for your toolbar controls. Purely structural.button[qalmaCommand]— binds a button to a command by name. It callsexecute()on click, reflectsisCommandActive()via.qalma-command-activeandaria-pressed, and disables itself whencanExecute()is false. Pass a command argument with[qalmaCommandValue].QalmaControlValueAccessorfrom@qalma/editor/forms— optional Angular forms adapter forformControl,formControlName, andngModel.
Plugins and kits
A QalmaPlugin contributes schema nodes/marks, commands, command-state
queries, content parsers, shortcuts, and ProseMirror plugins. A kit (e.g.
TextFormattingKit) is just a readonly QalmaPlugin[] bundling related
plugins — spread it into plugins like any other entry.
Configurable plugins expose a .configure(options) method that returns a new
plugin instance with merged options, e.g.
HistoryPlugin.configure({ depth: 200 }).
The broad EssentialsKit is intentionally imported from
@qalma/editor/essentials. Keeping the composite in a secondary entrypoint
prevents its eager imports from retaining every included plugin in smaller
main-entrypoint bundles.
Available plugins
| Plugin | Commands |
| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| BoldPlugin, ItalicPlugin, UnderlinePlugin, StrikePlugin (TextFormattingKit) | toggleBold, toggleItalic, toggleUnderline, toggleStrike |
| InlineCodePlugin | toggleInlineCode (Mod-e, single-backtick input rule) |
| MonospacePlugin | toggleMonospace |
| SubscriptSuperscriptPlugin | toggleSubscript, toggleSuperscript |
| HeadingsPlugin | setParagraph, toggleHeading1…toggleHeading6 (configurable levels) |
| BlockquotePlugin | toggleBlockquote |
| TablePlugin from @qalma/editor/table | insertTable, addRow*/addColumn*, deleteRow/deleteColumn/deleteTable, mergeCells/splitCell, toggleHeader* |
| HorizontalRulePlugin | insertHorizontalRule |
| ListsPlugin | toggleBulletList, toggleOrderedList, splitListItem, liftListItem, sinkListItem |
| TaskListPlugin | toggleTaskList, toggleTaskItemChecked, setTaskItemChecked, splitTaskItem, liftTaskItem, sinkTaskItem |
| CodeBlockPlugin | toggleCodeBlock, setCodeBlockLanguage |
| LinkPlugin | setLink, selectLink, unsetLink |
| ImagePlugin | insertImage, updateImage |
| MentionPlugin | insertMention |
| SlashCommandPlugin | deleteSlashCommand, dismissSlashCommand |
| DragHandlePlugin | selectBlock, deleteBlock, duplicateBlock, moveBlockTo, moveBlockUp, moveBlockDown |
| SelectionPlugin | None (query('selection'), qalma-selection-update) |
| ColorPlugin | setTextColor, unsetTextColor, setBackgroundColor, unsetBackgroundColor |
| HighlightPlugin | setHighlight, unsetHighlight |
| TextAlignPlugin | alignment commands for configured node types |
| ClearFormattingPlugin | clearFormatting |
| HardBreakPlugin | insertHardBreak |
| HistoryPlugin | undo, redo (Mod-z, Shift-Mod-z, Mod-y) |
| PasteRulesPlugin | normalizes pasted content |
| PlaceholderPlugin | shows placeholder text in an empty document |
| TrailingParagraphPlugin | keeps a trailing empty paragraph at the end of the document |
| CharacterCountPlugin | None (query('characterCount'); optional hard limit) |
| FileHandlerPlugin | None (typed paste/drop file callbacks) |
| FindReplacePlugin | setFindQuery, findNext, findPrevious, replaceCurrent, replaceAll, clearFind |
| UniqueIdPlugin | None (query('uniqueId'); stable configured-node IDs) |
Read each plugin's source under src/lib/plugins for configuration options
(e.g. HeadingsPlugin.configure({ levels: [1, 2, 3] }),
InlineCodePlugin.configure({ inputRules: false }),
MentionPlugin.configure({ trigger: '@' }),
SlashCommandPlugin.configure({ trigger: '/' }),
LinkPlugin.configure({ allowedProtocols: [...], onClick })).
Learn more
- Documentation & live demo: qalma.dev
- Guides & plugin reference: qalma.dev/docs
- Source & issues: github.com/cdskill/qalma
The repository also ships a full example app (apps/sandbox) — a toolbar, link
popover, mention menu, image upload, and code block highlighting — built
entirely from the public @qalma/editor API. See
CONTRIBUTING.md to
run it locally.
