Better Rich Text Buy licence

Programmatic API

On this page
  1. Creating an editor
  2. Options
  3. Commands
  4. Chains
  5. Checking availability
  6. Content in and out
  7. State and host integration
  8. Events
  9. Hooks
  10. Several editors on one page
  11. Framework examples

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

TypeScript
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:

TypeScript
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 preset

Pass 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

OptionPurpose
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:

TypeScript
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:

TypeScript
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:

TypeScript
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 an Outcome whose result lists each step's output. A failing step reports its index in error.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

TypeScript
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 name

Nothing is committed. Disabled reasons are human-readable, for tooltips.

Content in and out

TypeScript
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:

TypeScript
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

TypeScript
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:

TypeScript
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

TypeScript
const off = editor.on('change', (event) => save(editor.getJSON()));
off.dispose();
EventPayloadWhen
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).

TypeScript
// 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.

TypeScript
const [title, body] = ['#title', '#body'].map((el) => createRichTextEditor(el, { preset: 'minimal' }));
title.chain().focus('end').text.insert({ text: '!' }).run(); // only the title changes

A 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:

TypeScript
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

TSX
const { ref, editor } = useRichTextEditor({ value: html, onChange: setHtml });
return <><button onClick={() => editor && bold(editor as Editor)}>Bold</button><div ref={ref} /></>;

Vue

Vue
<RichTextEditor v-model="body" @ready="(e) => (editor = e)" />

Angular

HTML
<better-rich-text [formControl]="body" (ready)="editor = $event" />

Svelte

Svelte
<RichTextEditor bind:value={body} bind:editor={$editor} />
<button onclick={() => $editor && bold($editor)}>Bold</button>

Aurelia

HTML
<better-rich-text value.bind="body" editor.from-view="editor"></better-rich-text>

Solid

TSX
<RichTextEditor value={html()} onChange={setHtml} onReady={(e) => setEditor(e)} />

Web component

JavaScript
const el = document.querySelector('better-rich-text');
el.addEventListener('ready', () => el.editor.chain().command('history.undo').run());