Programmatic API
On this page
Everything the toolbar does is a typed command, so anything a person can do in the editor, your code can do too. This page covers creating an editor, running commands, chains, state queries, events, hooks, content in and out, and several editors on one page.
Creating an editor
import { createRichTextEditor } from '@better-rich-text/core';
const editor = createRichTextEditor('#editor', {
content: '<p>Hello</p>',
placeholder: 'Write something…',
});The command catalogue is inferred from the preset and the plugins you pass, so editor.commands, editor.chain() and editor.can() know every installed command and its input:
import { createA11yCheckPlugin } from '@better-rich-text/plugin-a11y-check';
const editor = createRichTextEditor('#editor', { preset: 'minimal', plugins: [createA11yCheckPlugin()] });
editor.commands.list.toggleBullet(); // from the minimal preset
editor.commands.a11y.check(); // from the plugin
editor.commands.table.insert({ rows: 2, cols: 2 }); // compile error: tables are not in the minimal presetPass the preset and plugins inline (or as const values) to keep this inference. An options object typed as RichTextEditorOptions gives the untyped catalogue; see Framework examples for typing an instance you received from elsewhere.
Without the DOM, createEditor({ plugins }) from @better-rich-text/engine returns the same typed engine for servers, workers and tests.
Options
| Option | Purpose |
|---|---|
preset |
'standard' (default), 'minimal' or a preset object |
plugins |
Plugins added after the preset's |
content |
HTML, a document envelope (or its JSON) or a root node |
mode |
classic (toolbar and status bar), inline or bare |
placeholder, readOnly, disabled |
Initial state; all can change later |
toolbar, features, statusBar |
Interface layout |
toolbarMobile |
Toolbar on phones: auto (default) docks it above the on-screen keyboard, docked always, off never (Phones) |
menubar, menu, contextmenu |
Menu bar and context menu layout, with menubarFeature and contextMenuFeature (Menus and dialogs) |
locale, messages |
Interface language and message overrides (Localisation) |
overlayRoot |
Where popovers and menus mount (see below) |
theme, skin, height, minHeight, maxHeight |
Appearance |
config |
Plugin configuration by plugin id |
onChange, onReady, onError |
Callbacks |
Commands
Every command has a namespaced name, a validated input and a typed result. There are three ways to run one:
editor.editor.execute('format.toggleMark', { markId: 'bold' }); // the engine entry point
editor.commands.format.toggleMark({ markId: 'bold' }); // the same, grouped by namespace
editor.commands.toggleMark({ markId: 'bold' }); // shortcut (see below)Each call is one transaction and one undo step. The result is an Outcome:
const result = editor.commands.table.insert({ rows: 3, cols: 2 });
if (result.ok) console.log(result.value.result, result.value.changedNodeIds);
else console.warn(result.error.code, result.error.message); // for example 'invalid-input'Shortcuts. A command is also available by its last name segment when no other installed command shares it: toggleMark, selectAll, insertHardBreak. history.undo has no shortcut once a plugin adds another undo (collaboration adds collab.undo), so the types stop offering it rather than guess.
Failure semantics. Errors use one lower-kebab-case vocabulary (invalid-input, permission-denied, stale-target, capability-unavailable, ...). A failed command changes nothing. Read-only and disabled editors return permission-denied.
Commands are typed from the same runtime schemas that validate them, so editor.editor.toolDefinitions() turns them into LLM tool definitions directly.
Chains
A chain runs several commands in order as one transaction and one undo step. Either every step applies or none does:
editor
.chain()
.focus()
.format.toggleMark({ markId: 'bold' })
.toggleMark({ markId: 'italic' })
.run();
editor.chain().focus('end').block.split().heading.set({ level: 2 }).text.insert({ text: 'Summary' }).run();run()returns anOutcomewhoseresultlists each step's output. A failing step reports its index inerror.details.step.focus(position?)focuses the editor after the chain commits. With'start'or'end'it first moves the caret there, as a step inside the transaction.scrollIntoView()scrolls the selection into view after the commit.command(name, input)adds a step by name, for names chosen at run time.A chain runs once; call
chain()again for the next one.Policy filters (
command.allow,command.input) apply to every step. Steps that only change the selection (history: 'skip') leave the undo history alone when the whole chain is like that.
editor.editor.chain() on the engine has the same commands without focus() and scrollIntoView(); viewChain(editor, view) from @better-rich-text/dom adds them for a view you created yourself.
Checking availability
editor.can().list.toggleBullet(); // { enabled, active, reason?, label?, value? }
editor.can().format.toggleMark({ markId: 'bold' }).active; // true, false or 'mixed'
editor.chain().list.toggleBullet().heading.set({ level: 2 }).can().enabled; // dry run of the whole chain
editor.editor.can('table.insert', { rows: 2, cols: 2 }); // by nameNothing is committed. Disabled reasons are human-readable, for tooltips.
Content in and out
editor.getHTML({ pretty: true });
editor.setHTML('<p>Replaced</p>'); // one undoable step by default
editor.getJSON(); // the document envelope
editor.setJSON(envelope); // clears history by default
editor.getText();
editor.getSelectionHTML(); // '' for a collapsed selection
editor.insertContent('<strong>Hi</strong> there'); // HTML at the selection, replacing it
editor.insertContent({ id: 'n1', type: 'text', text: 'x', marks: [] }); // nodes, node lists or slices
editor.wordCount(); // through document.counts when the limits plugin is installed
editor.characterCount(); // user-perceived characters
editor.isEmpty();getMarkdown() and setMarkdown() work when you pass a Markdown codec, so editors that never use Markdown do not bundle a parser:
import { markdown } from '@better-rich-text/core/markdown';
const editor = createRichTextEditor('#editor', { markdown });
editor.setMarkdown('# Title\n\nSome **bold** text');Add contentFormat: 'markdown' to make Markdown the editor's own format for content, getContent()/setContent() and textarea sync. See Markdown.
State and host integration
editor.setReadOnly(true); // can be focused, selected and copied; no edits
editor.setDisabled(true); // not focusable, greyed out, toolbar locked, no selection changes
editor.setPlaceholder('Write a reply…');
editor.setLocale('fr', { 'toolbar.label': 'Mise en forme' });
editor.setTheme('dark');
editor.focus('end');Disabled versus read-only. A read-only editor is still part of the page: people can focus it, select and copy. A disabled editor is announced as disabled (aria-disabled), leaves the tab order, ignores pointer and keyboard input, rejects every command from the interface or input (selection changes included) and its toolbar is inert. Your code can still replace its value with setHTML or setJSON. The framework adapters' disabled props use this state.
Changing the language rebuilds the toolbar and status bar labels in place. The document, selection, history and open state are kept. Panels that are open keep their text until reopened.
Overlay root. Popovers and menus mount in document.body unless you pass overlayRoot. Use it when the editor is inside a shadow root, a modal <dialog> or a transformed parent:
const root = host.attachShadow({ mode: 'open' });
const layer = document.createElement('div');
root.append(mountPoint, layer);
createRichTextEditor(mountPoint, { overlayRoot: layer });Outside clicks and focus restoration look through shadow roots, so menus inside one behave like menus outside.
Events
const off = editor.on('change', (event) => save(editor.getJSON()));
off.dispose();| Event | Payload | When |
|---|---|---|
transaction |
TransactionEvent |
Every committed transaction, including selection-only ones |
change |
TransactionEvent |
The document content changed |
selectionChange |
TransactionEvent |
The selection changed |
readOnlyChange |
{ readOnly } |
setReadOnly changed the state |
disabledChange |
{ disabled } |
setDisabled changed the state |
lifecycle |
{ state } |
constructed, attached, ready, detached, destroyed |
ready, destroy |
{ editor } |
Start and end of the engine's life |
error |
{ source, error } |
A plugin, hook or listener threw (it was isolated) |
A TransactionEvent has state, previousState, operations, steps (for mapping positions), origin (kind is input, ui, api, history, remote, ...) and the receipt. For React's useSyncExternalStore, use editor.editor.subscribe and editor.editor.getSnapshot.
Hooks
Events report what happened; hooks change how it happens. Filters transform a value, actions run at a named point. Every registration returns a disposer, and a plugin's hooks are removed with it (ADR 0003).
// Block one command for everyone.
editor.editor.hooks.addFilter('command.allow', (allowed, command) => (command === 'table.insert' ? false : allowed), { namespace: 'my-app' });
// Add an accessibility rule.
editor.editor.hooks.addFilter('a11y.rules', (rules) => [...rules, myRule]);Common names: command.allow, command.input, transaction.allow, content.insertText, clipboard.pasteHtml, html.parse.input, view.decorations, view.nodeClasses, ui.toolbar.items; actions transaction.committed, view.attached, view.rendered. Plugins declare their own with hookNames (for example a11y.rules, a11y.issues, a11y.checked, linkCheck.anchors, linkCheck.results, linkCheck.checked). Declare types by augmenting FilterHooks and ActionHooks.
Several editors on one page
Each editor has its own plugin state, history, hooks, commands and popover layer. Commands always target the editor you call them on; nothing goes to "the last focused editor". Destroying one editor closes only its own popovers.
const [title, body] = ['#title', '#body'].map((el) => createRichTextEditor(el, { preset: 'minimal' }));
title.chain().focus('end').text.insert({ text: '!' }).run(); // only the title changesA shared toolbar is just your own buttons calling the active editor's commands.
Framework examples
Adapters hand you the same RichTextEditor instance. Its catalogue is untyped because the adapter cannot see your plugin list at compile time; type it once with RichTextCatalog:
import type { RichTextCatalog, RichTextEditor } from '@better-rich-text/core';
type Editor = RichTextEditor<RichTextCatalog<'standard'>>;
const bold = (editor: Editor) => editor.chain().focus().toggleMark({ markId: 'bold' }).run();Without a cast, editor.chain().command('format.toggleMark', { markId: 'bold' }).run() works on any instance.
React
const { ref, editor } = useRichTextEditor({ value: html, onChange: setHtml });
return <><button onClick={() => editor && bold(editor as Editor)}>Bold</button><div ref={ref} /></>;Vue
<RichTextEditor v-model="body" @ready="(e) => (editor = e)" />Angular
<better-rich-text [formControl]="body" (ready)="editor = $event" />Svelte
<RichTextEditor bind:value={body} bind:editor={$editor} />
<button onclick={() => $editor && bold($editor)}>Bold</button>Aurelia
<better-rich-text value.bind="body" editor.from-view="editor"></better-rich-text>Solid
<RichTextEditor value={html()} onChange={setHtml} onReady={(e) => setEditor(e)} />Web component
const el = document.querySelector('better-rich-text');
el.addEventListener('ready', () => el.editor.chain().command('history.undo').run());