Smart content
On this page
- Setup
- Autocomplete
- Mentions
- Output
- Commands
- Notifications
- Plain fields
- Merge tags
- Output
- Commands
- Conditional content
- Rendering
- Dates
- Templates
- Library
- Managing templates
- Read-only categories
- Variables
- Commands
- REST API
- Emoji
- Image editing
- Saving
- Cross-origin images
- Keyboard
- Hooks
- Accessibility
- Limitations
- Assets
- Asset source
- Commands
- Image dialog
- Keyboard
- Hooks
- Accessibility
- Limits
Mentions, merge tags, conditional content, dates, templates, emoji and image editing. Each one is a separate opt-in package with sideEffects: false. None of them registers anything globally, so a bundle only pays for the packages it imports.
| Package | Adds |
|---|---|
@better-rich-text/suggest |
Shared trigger-character autocomplete used by mentions, merge tags and emoji |
@better-rich-text/plugin-mentions |
mention inline atom, @ and # autocomplete, notification helpers |
@better-rich-text/plugin-merge-tags |
mergeTag inline atom, conditional block, preview mode, render() |
@better-rich-text/plugin-datetime |
dateTime inline atom stored as <time datetime> |
@better-rich-text/plugin-templates |
Template library, template management, variables and snippets |
@better-rich-text/plugin-emoji |
: autocomplete and :shortcode: replacement |
@better-rich-text/plugin-image-editor |
Crop, rotate, flip, adjust and resize images |
Each plugin works headless. Commands, rendering and parsing need no DOM. The autocomplete, menus and dialogs come from a UiFeature that you add next to the plugin.
The playground page apps/playground/smart.html shows every feature together.
Setup
import { createRichTextEditor, standardPreset } from '@better-rich-text/core';
import { createMentionsPlugin, mentionsFeature } from '@better-rich-text/plugin-mentions';
import { createMergeTagsPlugin, mergeTagsFeature } from '@better-rich-text/plugin-merge-tags';
import { createDateTimePlugin, dateTimeFeature } from '@better-rich-text/plugin-datetime';
import { createTemplatesPlugin, templatesFeature } from '@better-rich-text/plugin-templates';
import { createEmojiPlugin, emojiFeature } from '@better-rich-text/plugin-emoji';
const editor = createRichTextEditor('#editor', {
plugins: [
createMentionsPlugin({ source: ({ query, signal }) => api.people(query, { signal }) }),
createMergeTagsPlugin({ fields }),
createDateTimePlugin({ locale: 'en-GB', timeZone: 'Europe/London' }),
createTemplatesPlugin({ source: templateSource }),
createEmojiPlugin(),
],
features: [...standardPreset.features, mentionsFeature, mergeTagsFeature, dateTimeFeature, templatesFeature, emojiFeature],
toolbar: `${standardPreset.toolbar} | mergeTags mergePreview dateTime templates saveTemplate saveSnippet`,
});Toolbar item ids:
| Id | Control |
|---|---|
mergeTags |
Menu of fields (grouped) and conditional content |
mergePreview |
Toggles preview with sample data |
dateTime |
Date formats menu; re-formats a selected date |
templates |
Opens the template library |
saveTemplate |
Saves the selection, or the whole document, as a new template (only when the source can create) |
saveSnippet |
Saves the selection as a snippet (only when the source can save) |
Autocomplete
Typing a trigger opens a list at the caret. @ and # open straight away, {{ opens straight away, and : waits for two more characters so times like 10:30 stay quiet. A trigger only opens at the start of a block or after a space or opening punctuation, so name@example.com never opens the list. It never opens inside code or during IME composition.
Up and Down arrows move through the list. Enter or Tab inserts the highlighted item. Escape closes the list, and it stays closed while you keep typing.
The selected text, from the trigger to the caret, is replaced in one undoable step. Undo brings back the typed text.
Async sources are debounced (150 ms by default) and every superseded request is aborted through its
AbortSignal. A late response never overwrites newer results. The first request (the bare trigger) runs at once.A failed source shows "Suggestions are unavailable" and fires the
suggest.erroraction.
To build your own trigger:
import { createSuggestionTrigger, suggestionFeature } from '@better-rich-text/suggest';
const issues = createSuggestionTrigger({
id: 'issues',
char: '!',
label: 'Issues',
items: (query, signal) => api.issues(query, { signal }), // [{ id, label, description?, group?, icon?, glyph? }]
select: (item, { ui, from, to }) => ui.editor.executeWithOrigin('text.replace', { from, to, text: `#${item.id}` }, 'ui'),
});
const issuesFeature = suggestionFeature('issues', () => [issues]);Trigger options: minChars, maxLength (40), allowSpaces, pattern (allowed query characters), wordStart (true), debounceMs (150), emptyLabel and render(item, doc) for custom option content. rankByLabel(items, query) gives prefix, word-start, keyword and substring ranking for local lists. Commands that should replace the typed query can take the shared replace range with replaceRangeSchema, resolveTargetRange and replaceWithInline or replaceWithText.
Mentions
createMentionsPlugin({
triggers: [
{ char: '@', label: 'People', source: ({ query, signal }) => api.people(query, { signal }) },
{ char: '#', kind: 'tag', label: 'Tags', debounceMs: 0, source: ({ query }) => tags.filter((t) => t.label.startsWith(query)) },
],
});A source returns { id, label, kind?, href?, description?, avatar? }. id is the stable subject id. label is only for display. kind defaults to user for @ and tag for #. @ queries allow spaces so full names can be typed. Options without an avatar show initials.
Output
<span class="brt-mention" data-mention data-id="u-122" data-kind="user">@Mina Patel</span>
<a href="https://wiki.example.com/q3" class="brt-mention" data-mention data-id="p-1" data-kind="page">@Q3 roadmap</a>
<span class="brt-mention" data-mention data-id="bug" data-kind="tag" data-trigger="#">#bug</span>A mention becomes a link when the candidate has an href (http, https, mailto or a root-relative path). Unsafe links are dropped on insert and on import. Plain text and clipboard text use @label.
Commands
| Command | Input | Result |
|---|---|---|
mention.insert |
{ id, label, kind?, trigger?, href?, replace?, appendSpace? } |
{ nodeId } |
mention.list |
{ kind? } |
{ mentions } (read only) |
Notifications
The editor never sends notifications. Hosts decide who to notify:
mentions.insertedfires aftermention.insertwith{ nodeId, id, label, kind, trigger, href }.listMentions(root, { kind? })lists every mention in document order.mentionedSubjects(root)lists each subject once.diffMentions(before, after)returns{ added, removed }subjects. Run it on save against the last saved document to notify only new people. Unlike the action, it also sees mentions that arrived by paste or by a collaborator.
Plain fields
Comment boxes and other plain fields outside the editor can use the same autocomplete. It works on a <textarea>, a text <input> or a contenteditable element, and writes a plain-text token:
import { attachMentionAutocomplete, renderMentionTokens } from '@better-rich-text/plugin-mentions';
const handle = attachMentionAutocomplete(textarea, {
provider: (query, { trigger, signal }) => api.people(query, { signal }), // MentionCandidate[]
triggers: [{ char: '@' }, { char: '#', kind: 'tag', provider: searchTags }], // default [{ char: '@' }]
onSelect: ({ token, id, kind, label }) => {}, // after insertion
});
handle.destroy();Choosing Maya Chen writes @[Maya Chen](user:123) and a space. The field keeps focus and gets the same combobox attributes, keys, debouncing, aborting and announcements as the editor. The list mounts in document.body unless you pass container. An input event fires after insertion, so framework bindings stay in sync.
Token helpers:
| Function | Does |
|---|---|
formatMentionToken({ label, id, kind?, trigger? }) |
Writes a token. In labels, \, [ and ] are escaped; in ids, \, ( and ) are |
parseMentionTokens(text) |
Splits text into { type: 'text' } and { type: 'mention', trigger, label, kind, id } segments with offsets |
mentionTokensIn(text) |
The mentions in order, for notifications |
mentionTokensToText(text) |
Replaces tokens with @label |
renderMentionTokens(text, { href?, lineBreaks? }) |
Escaped HTML with the same brt-mention markup the editor exports; href(mention) makes links (unsafe URLs ignored); newlines become <br> by default |
Text that does not match the token grammar stays ordinary text.
Merge tags
const fields: MergeField[] = [
{ key: 'first_name', label: 'First name', group: 'Contact', sample: 'Robin' },
{ key: 'company.name', label: 'Company', group: 'Account', sample: 'Northwind Tiles' },
{ key: 'seats', label: 'Seats', group: 'Account', sample: 12, fallback: 'your' },
];
createMergeTagsPlugin({ fields });Keys are dotted paths of up to eight identifiers. __proto__, prototype and constructor are rejected anywhere in a key, and lookups only read own properties of plain objects. With a catalogue the plugin is strict, so unknown keys are rejected. Pass strict: false to accept any valid key.
Tags show a chip with the field label while authoring. Preview mode (mergePreview or mergeTags.preview) shows each tag's sample value instead, marks tags with no sample in the danger colour, and fades conditional blocks whose condition fails for the sample data. Preview state belongs to each editor.
Typing a complete {{key}} turns it into a tag, and so does parsing HTML (loading, setHTML and paste) when the key is in the catalogue. Text in code is left alone. Turn this off with convertText: false.
Output
<span class="brt-merge-tag" data-merge-tag="first_name" data-label="First name">{{first_name}}</span>toTemplateHtml(editor, { tags: 'text' }) writes plain {{first_name}} text instead, for mail systems that do their own substitution.
Commands
| Command | Input | Result |
|---|---|---|
field.insert |
{ key, fallback?, replace? } |
{ nodeId } |
field.set |
{ nodeId?, key?, fallback? } |
{ nodeId } (selected tag by default) |
condition.set |
{ terms, match?, nodeId?, wrap? } |
{ nodeId, created } |
condition.remove |
{ nodeId? } |
Unwraps, keeping the content |
mergeTags.preview |
{ on? } |
{ on }; toggles without input |
mergeTags.listFields |
none | { fields } |
Conditional content
A conditional block holds other blocks and is shown only when its condition holds. A condition is a list of one to eight comparisons joined by all or any:
editor.execute('condition.set', { match: 'all', terms: [{ field: 'plan', op: 'equals', value: 'pro' }] });Operators: exists, empty, equals, notEquals, contains, greaterThan and lessThan. Numbers compare as numbers, and text compares without case. A missing field counts as empty. Conditions are data, never code, so there is nothing to execute.
condition.set wraps the selected blocks. With the caret already inside a conditional block it updates that block instead (pass wrap: true to nest). In the editor each block shows a "Show if …" label. Clicking it opens the condition editor (field, operator, value, Remove condition).
<div class="brt-conditional" data-condition='{"match":"all","terms":[{"field":"plan","op":"equals","value":"pro"}]}'><p>…</p></div>Rendering
import { render } from '@better-rich-text/plugin-merge-tags';
const result = render(editor.editor, { first_name: 'Robin', plan: 'pro', company: { name: 'Northwind Tiles' } });
result.ok; // false only with missing: 'reject'
result.output; // final HTML (or text with format: 'text')
result.missing; // [{ key, nodeId }]
result.conditions; // [{ nodeId, shown, missing }]render takes an editor or { schema, root }. It is pure, so the same document, data and options always give the same output. Values only ever become text, so <img onerror> in the data is rendered as escaped text. Shown conditional blocks are unwrapped and hidden ones are removed.
missing option | Missing value becomes |
|---|---|
placeholder (default) |
{{key}} |
default |
The tag's fallback, then the catalogue fallback, then empty |
empty |
An empty string |
reject |
Nothing is rendered; ok is false and missing lists every location |
When it is given an editor, render runs the mergeTags.value filter for each value.
Dates
createDateTimePlugin({ locale: 'en-GB', timeZone: 'Europe/London', formats, defaultFormat: 'date-long' });A date stores an explicit instant (an ISO date, or a date and time with an offset), the Intl.DateTimeFormat options of its format, a locale and an IANA time zone. The visible text depends only on those attributes, so a document shows the same text on every machine, and opening it never changes it.
Built-in formats: date-short, date-long, date-full, time, datetime and iso-date. Pass formats as { id, label, options } to replace them. options accepts the Intl subset dateStyle, timeStyle, weekday, era, year, month, day, hour, minute, second, hour12 and timeZoneName, or { iso: 'date' | 'datetime' }.
The dateTime menu shows each format applied to the current time. When a date is selected, the menu re-formats it instead of inserting a new one.
<time class="brt-datetime" datetime="2026-03-05T14:30:00Z" data-format="date-long" data-locale="en-GB" data-time-zone="Europe/London" data-options='{"dateStyle":"long"}'>5 March 2026</time>A plain <time datetime> from other HTML imports with default settings.
| Command | Input | Result |
|---|---|---|
dateTime.insert |
{ value?, format?, locale?, timeZone? } |
{ nodeId }; value defaults to now |
dateTime.update |
{ nodeId?, value?, format?, locale?, timeZone? } |
Re-formats the selected date |
Templates
const source: TemplateSource = {
list: ({ search, category }, signal) => api.templates({ search, category }, { signal }), // { templates, categories? }
get: (id, signal) => api.template(id, { signal }), // { id, name, content (HTML), version?, ... }
// Optional writes; the library shows only the actions you implement.
create: (template, signal) => api.createTemplate(template, { signal }),
update: (id, patch, signal) => api.updateTemplate(id, patch, { signal }),
delete: (id, signal) => api.deleteTemplate(id, { signal }),
move: (id, { category, before }, signal) => api.moveTemplate(id, { category, before }, { signal }),
createCategory: ({ label }, signal) => api.createCategory(label, { signal }),
renameCategory: (id, label, signal) => api.renameCategory(id, label, { signal }),
deleteCategory: (id, signal) => api.deleteCategory(id, { signal }),
saveSnippet: (snippet, signal) => api.saveSnippet(snippet, { signal }), // optional; enables saveSnippet
};
createTemplatesPlugin({ source });For a fixed library, pass templates: [...] (and categories) or source: staticTemplateSource(templates, categories). The static source implements every write method in memory, so the library is fully editable for as long as the page is open. get is optional when list already returns full templates with content. For the REST API below, use httpTemplateSource({ baseUrl: '/api', headers }); pass readOnly: true for an insert-only library.
Toolbar ids: templates opens the library, saveTemplate opens a new template made from the selection (or the whole document when nothing is selected), and saveSnippet keeps the older one-step snippet form.
Library
The library dialog has a search box (debounced, with aborted superseded requests), category filters, a keyboard listbox of results and a live preview. The preview is parsed and serialized through the editor's own schema with variables filled in, so it shows exactly what insertion would produce and unsupported or unsafe markup never appears. You can insert at the cursor or replace the document. Escape or Cancel changes nothing.
Inserted content is independent. Every node gets a fresh id, so inserting the same template twice cannot collide, and later changes to the template do not touch documents that already contain it. A template with a single paragraph merges into the current line. Replacing the document is one undo step.
Managing templates
When the source supports it, the library also manages templates:
New template offers a blank template, one from the selection, or one from the whole document.
Edit opens the template in a small rich-text editor inside the dialog, with name and category fields, a compact toolbar and an Insert variable menu. The editor is built from the host editor's plugins, so it has the same schema and saves exactly what insertion will parse. Plugins with side effects outside the document (save, collaboration, revisions, comments, suggestions, AI, the users directory and templates itself) are loaded for their schema only. Change this with the
editorPluginsoption, and the toolbar witheditorToolbar.Rename, Duplicate, Move up, Move down, Move to category and Delete are in the actions menu next to the preview. Duplicating a read-only template puts the copy in a writable category, which is how people customise an admin template.
New category, Rename category and Delete category sit next to the category filters. Deleting a category deletes its templates.
Templates can be dragged to reorder them, or dropped on a category button to move them there.
Deletes and unsaved edits ask first.
| Source method | Enables |
|---|---|
create (or saveSnippet) |
New template, Duplicate, saveTemplate, template.create |
update |
Edit, Rename, template.update; Move to category when there is no move |
delete |
Delete, template.delete |
move |
Move up, Move down, drag to reorder, and moves between categories |
createCategory, renameCategory, deleteCategory |
The matching category actions |
Sources without create but with saveSnippet still get New template: it saves through saveSnippet. A source error (for example a stale version) is shown in the dialog, the form stays open and templates.failed fires.
Read-only categories
A category with readOnly: true is for templates people may use but not change, such as company-approved text. Its templates can be inserted, previewed and duplicated only. They cannot be edited, renamed, moved or deleted, nothing can be created in or dropped into the category, and the category cannot be renamed or deleted. A single template can be locked the same way with readOnly: true. The interface marks both with a lock and the words "Read-only". The commands and the static source enforce the same rules, but your server must enforce them too.
Variables
{{name}} placeholders in template text are filled in when the template is inserted (and in the preview). Unknown names are left as they are, so merge tags such as {{first_name}} pass through.
| Variable | Value |
|---|---|
{{date}}, {{date.short}}, {{date.iso}} |
Today: "5 March 2026", "05/03/2026", "2026-03-05" |
{{time}}, {{datetime}}, {{year}} |
The time, date and time, and year |
{{user.name}}, {{user.email}}, {{user.id}} |
The current user |
Dates use the locale and timeZone options (default: the browser's). One insertion uses one clock reading, so {{date}} and {{time}} agree. The current user comes from the user option, or from the users directory when @better-rich-text/users is installed (looked up at run time; the templates package does not depend on it). {{user.email}} reads the user's custom.email, or a description that is an email address.
createTemplatesPlugin({
source,
locale: 'en-GB',
timeZone: 'Europe/London',
variables: {
company: 'Northwind Tiles',
'deal.owner': ({ user }) => user?.name, // functions get { name, template, now, locale, timeZone, user }
'ticket.id': ({ template }) => currentTicket()?.id, // return undefined to leave {{ticket.id}} as is
},
});
// Plugins can add or change variables for every insertion:
editor.hooks.addFilter('templates.variables', (variables, { template }) => ({ ...variables, region: () => region() }), { namespace: 'my-app' });
// A single insertion can pass its own values:
editor.execute('template.insert', { templateId: 'follow-up', variables: { customer: 'Robin' } });Values are inserted as text after the template has been parsed, so a value can never add markup. A placeholder split across formatting ({{user.<b>name</b>}}) is not matched. replaceVariables, variablesIn and resolveVariablesInNodes are exported for your own use, for example on the server.
Commands
| Command | Input | Result |
|---|---|---|
template.insert |
{ templateId?, version?, html?, mode?: 'insert' | 'replace', variables? } |
{ templateId, version, mode, nodeIds } |
template.list |
none | { templates, categories } loaded so far |
template.saveSelection |
{ name, category? } |
{ html, text, saved } |
template.create |
{ name, category?, html?, from?: 'selection' | 'document', description?, tags? } |
{ name, category, html, saved } |
template.update |
{ templateId, name?, category?, html?, from?, description?, tags?, version? } |
{ templateId, html, saved } |
template.delete |
{ templateId } |
{ templateId, saved } |
template.insert with only a templateId needs that template to be loaded already, for example because it was opened in the library or comes from a static list. Otherwise pass html. A mismatched version fails with stale-target.
template.create, template.update and template.delete never change the document and are not undo steps. They check their input and the read-only rules at once (failing with invalid-input, permission-denied, stale-target or capability-unavailable), then call the source in the background. Watch templates.created, templates.updated, templates.deleted and templates.failed for the outcome. create with neither html nor from makes a blank template; update with category: null removes the category. The templates service (TemplatesController) has awaitable create, update, delete, move, createCategory, renameCategory and deleteCategory methods for code that needs the result.
REST API
httpTemplateSource speaks this contract; the endpoints, bodies and status codes are in Server: Templates.
{
"templates": [
{ "id": "t-7", "name": "Follow-up after demo", "description": "Next steps", "category": "sales", "tags": ["sales"], "version": "3" },
{ "id": "t-2", "name": "Data policy notice", "category": "company", "version": "12", "readOnly": true }
],
"categories": [
{ "id": "company", "label": "Company", "readOnly": true },
{ "id": "sales", "label": "Sales" }
]
}Emoji
createEmojiPlugin({ extra: [{ emoji: '🦘', name: 'kangaroo' }] });
createEmojiPlugin({ source: (query) => import('./emoji-full.js').then((m) => m.search(query)) });The built-in list holds about 60 common emoji (under 3 KB). Use extra to add entries, entries to replace the list, or source to load a full set lazily. Results from source are listed first. Typing :name: for a known name inserts the emoji directly (turn this off with shortcodes: false).
emoji.insert { emoji, replace? } inserts the whole sequence as text, so skin tones, joiners and flags stay intact and delete as one character.
Image editing
@better-rich-text/plugin-image-editor edits the selected image in a dialog: crop (free, original, 1:1, 4:3, 16:9 or 3:2), rotate 90° either way, flip, brightness, contrast, saturation and the output size in pixels. The preview updates as you work. It needs the image plugin, which the standard preset includes.
import { createImageEditorPlugin } from '@better-rich-text/plugin-image-editor';
createRichTextEditor('#editor', {
plugins: [createImageEditorPlugin({ quality: 0.9 })],
toolbar: `${standardPreset.toolbar} | editImage`,
});The editImage button is enabled while an image is selected. To open the dialog from your own UI, call openImageEditor(ui, nodeId?). Hosts that compose features explicitly can add imageEditorFeature instead of relying on the plugin's toolbar contribution.
Saving
Apply draws the edit at full size and keeps the original format when the browser can encode it (JPEG, PNG, WebP). GIF, AVIF, SVG and unknown types become PNG, which keeps transparency. Set outputType to force one, quality for JPEG and WebP, and maxSize for the longest side (default 8192).
The file goes to the image plugin's upload provider, or to provider when you pass one. The dialog shows upload progress and errors, and Cancel aborts the upload. Without a provider the image becomes a data URL, within the image plugin's maxBytes (2 MB by default). An image plugin set to fallback: 'reject' refuses instead.
imageEditor.apply { nodeId, src, width?, height?, assetId? } swaps the source in one undo step, so Undo brings back the original. It returns { nodeId, src, previousSrc, width, height }. A crop keeps the image's zoom: an image shown at half size stays at half size. A resized image is shown at its new pixel size, capped at 1200px wide.
Cross-origin images
The editor loads images with crossOrigin="anonymous". If the image's server does not send CORS headers, the browser will not let the page read its pixels. The dialog then explains why the image can't be edited instead of failing. To fix it, serve images with Access-Control-Allow-Origin, or load them through your own server:
createImageEditorPlugin({ proxy: (src) => `/image-proxy?url=${encodeURIComponent(src)}` });
createImageEditorPlugin({ fetchImage: (src) => fetch(`/image-proxy?url=${encodeURIComponent(src)}`).then((r) => r.blob()) });fetchImage wins over proxy. data: and blob: images load directly.
Keyboard
The crop frame takes focus when the image loads. Arrow keys move it, Shift + arrow keys resize it, and Alt makes steps 1 pixel (otherwise 1% of the image). The dialog states this next to the frame. Every control has a label, Escape cancels, and focus returns to the editor.
Actions: imageEditor.applied receives the command result, and imageEditor.failed receives { stage, kind, nodeId, src, error } when loading, encoding, uploading or applying fails.
Hooks
| Hook | Kind | Arguments |
|---|---|---|
suggest.error |
action | triggerId, error |
mentions.query |
filter | candidates, { query, trigger } |
mentions.inserted |
action | mention |
mergeTags.fields |
filter | fields, { query } (menu and {{ list) |
mergeTags.value |
filter | value | undefined, { key, data } (in render) |
mergeTags.inserted |
action | { nodeId, key } |
mergeTags.previewChanged |
action | on |
dateTime.formats |
filter | formats, { locale } |
dateTime.inserted |
action | { nodeId, value, format, options, locale, timeZone } |
templates.list |
filter | summaries, { search, category } |
templates.beforeInsert |
filter | nodes | false, { template, mode }; false cancels |
templates.inserted |
action | { templateId, version, mode, nodeIds } |
templates.variables |
filter | variables, { template, now, locale, timeZone, user } |
templates.snippetSaving / snippetSaved / snippetFailed |
action | snippet, then the saved summary or the error |
templates.created / templates.updated |
action | the saved summary |
templates.deleted |
action | templateId |
templates.moved |
action | summary, { category, before } |
templates.categoryCreated / categoryRenamed / categoryDeleted |
action | the category (its id for deletes) |
templates.failed |
action | operation, error, input when the source rejects a write |
templates.error |
action | error |
emoji.query |
filter | entries, { query } |
emoji.inserted |
action | emoji |
Accessibility
The editing surface follows the ARIA combobox pattern while a list is open. It sets
aria-autocomplete="list",aria-expanded,aria-controls(pointing at the listbox) andaria-activedescendant(the highlighted option). Focus never leaves the text.Grouped results (merge fields) use
role="group"with a labelled heading.Result counts, "No matches" and failures are announced through a polite live region.
The template library is a modal dialog labelled by its heading, with a search box, a listbox (arrows, Home, End, Enter) and a status line that announces counts and the result of every change.
Every drag in the library has a keyboard equivalent. In the list, Alt+Up and Alt+Down move the template, F2 renames it, Delete deletes it (after asking) and Shift+F10 or the context menu key opens its actions menu, which includes Move to category. Read-only templates and categories are marked with a lock and the words "Read-only", not colour alone.
Menus, rename forms and confirmations open inside the dialog. A confirmation is an
alertdialogwith Cancel focused. Escape closes the innermost layer first: a menu, then the template editor (asking first if there are changes), then the library. Focus returns to the control that opened each layer, to the list after a change, and to the document when the library closes.Toolbar menus use the shared menu (arrow keys, type-ahead, Escape returns focus).
Styles live in the
brtcascade layer and use only--brt-*tokens, so every skin, the dark theme and forced colours apply.
Limitations
Merge tags do not carry inline formatting. Bold or italic text around a tag is kept, but the resolved value itself is rendered without marks.
Conditions edited in the interface have a single comparison. Several comparisons with
anyorallare available throughcondition.set.Repeat blocks, typed field formatting (decimal precision, date formats for field values) and content controls from spec 10.16 are not implemented.
{{key}}text is converted when HTML is parsed. Plain-text paste keeps it as text until it is retyped.Dates are frozen values; there is no refresh-on-render ("live") date.
Templates have a single current version; there is no version history or locking while someone edits. Use
versioninupdateto reject conflicting edits on the server. There are no synchronised reusable blocks, and inserted copies are always independent.Variables are filled in once, on insertion. They are not live fields; use merge tags for values that are filled in when the document is sent.
The template library does not bookmark the selection. Insertion uses the selection at the moment Insert is pressed.
mentions.insertedfires for mentions inserted throughmention.insert. UsediffMentionsto catch mentions that arrive by paste or collaboration.
Assets
@better-rich-text/plugin-assets is a file and image manager. People browse folders, search, filter by kind, page through results, upload, rename and delete files, then insert one. Images go in as image nodes (@better-rich-text/plugin-image) and other files as attachments (@better-rich-text/plugin-media). The manager only offers what the editor can insert: with images alone it shows images and accepts image uploads only, and without either plugin its toolbar button does not appear.
import { createRichTextEditor } from '@better-rich-text/core';
import { createMediaPlugin } from '@better-rich-text/plugin-media';
import { createAssetsPlugin, httpAssetSource } from '@better-rich-text/plugin-assets';
createRichTextEditor('#editor', {
plugins: [createMediaPlugin(), createAssetsPlugin({ source: httpAssetSource({ baseUrl: '/api' }), maxBytes: 20 * 1024 * 1024 })],
toolbar: 'undo redo | bold italic | link image assets',
});The toolbar item is assets. Hosts that compose features explicitly add assetsFeature. To open the manager from your own button, call openAssetManager(ui, { kinds?, kind?, folder?, mode?, onPick?, labels? }); it returns the open AssetManager, or null when the plugin is not installed.
| Option | Default | Purpose |
|---|---|---|
source |
required | The AssetSource |
accept |
anything | Upload types: MIME types, image/* wildcards or .pdf extensions |
maxBytes |
no limit | Largest upload |
maxImageWidth |
1200 |
Display width cap for inserted images (the aspect ratio is kept) |
imageBrowse |
true |
Add "Browse…" to the image dialog |
labels |
English | Interface text, including the announcements |
Asset source
interface AssetSource {
list(query: { folder: string | null; search: string; kind: 'image' | 'file' | 'all'; cursor?: string }, signal: AbortSignal):
MaybePromise<{ assets: Asset[]; folders?: AssetFolder[]; path?: AssetFolder[]; nextCursor?: string | null }>;
upload?(file: File | Blob, options: { folder: string | null; name: string; onProgress?: (fraction: number) => void }, signal: AbortSignal): MaybePromise<Asset>;
rename?(id: string, name: string, signal: AbortSignal): MaybePromise<Asset>;
delete?(id: string, signal: AbortSignal): MaybePromise<void>;
createFolder?(name: string, options: { parent: string | null }, signal: AbortSignal): MaybePromise<AssetFolder>;
}
interface Asset { id; name; url; kind: 'image' | 'file'; mimeType?; size?; width?; height?; thumbnailUrl?; folder?; updatedAt?; alt? }
interface AssetFolder { id; name; parent? }Only list is required. The manager shows upload, rename, delete and new-folder controls only for the methods the source has. Every call gets an AbortSignal that fires when a newer search replaces it or the manager closes. Throw to fail; the message is shown to the person. httpAssetSource implements the REST endpoints.
memoryAssetSource({ assets, folders }, { pageSize, urlFor }) keeps everything in memory, for demos and tests. It searches across folders and pages with numeric cursors. Uploaded images become data: URLs. Other files become object URLs, which attachments cannot use, so pass urlFor to return an http(s) address for them.
Commands
| Command | Input | Result |
|---|---|---|
assets.insert |
{ assetId?, url?, kind?, name?, alt?, mimeType?, size?, width?, height? } |
{ assetId, kind, insertedAs: 'image' | 'attachment', url, nodeId } |
assets.insert with only an assetId needs that asset to be known already, because the manager listed, uploaded or renamed it. Otherwise pass url. The kind comes from the input, the known asset or the MIME type. Images go in as images when plugin-image is installed and as attachments otherwise. Files need plugin-media and an http(s) address; without them the command fails with capability-unavailable or invalid-input and changes nothing. One insertion is one undo step.
The stored asset id goes into the document as data-asset-id, so your server can find which documents use a file. The image.insert, image.update and attachment.insert commands (the image dialog, for example) add it too when the address belongs to a file the manager has seen. Pasted HTML keeps whatever data-asset-id it already has.
Image dialog
With plugin-image installed, the image dialog shows "Browse…" under the address field. It opens the manager in pick mode, with images only, and fills the address and alternative text from the chosen asset. The image dialog stays open, and Insert there works as before. This uses the image.browse service that plugin-image looks up; any plugin can provide its own (ImageBrowser type in plugin-image). Pass imageBrowse: false to leave the image dialog alone.
Keyboard
| Key | Action |
|---|---|
| Arrow keys | Move through folders and files. Up and down follow the visual rows of the image grid |
| Home, End | First, last item |
| Enter, double-click | Open a folder, or insert the file |
| Backspace, Alt+Up | Parent folder |
| F2 | Rename |
| Delete | Delete, after a confirmation that starts on Cancel |
| Down from the search box | Into the list |
| Escape | Close an open rename or delete prompt, otherwise the manager |
Hooks
| Hook | Kind | Arguments |
|---|---|---|
assets.list |
filter | assets, query (reorder, hide or relabel a page) |
assets.inserted |
action | { assetId, kind, insertedAs, url, nodeId } |
assets.uploaded |
action | asset |
assets.renamed |
action | asset |
assets.deleted |
action | id, asset | null |
assets.folderCreated |
action | folder |
assets.error |
action | error, { operation, id?, name? }; aborted requests are not reported |
Accessibility
The manager is a modal dialog labelled by its heading. Focus starts in the search box and returns to the control that opened it, or to the editor after an insert.
Folders, images and files share one listbox with
aria-activedescendant. Images show as tiles with their name and size; thumbnails are decorative because the name is always shown.Counts, upload progress (in 25% steps), finished uploads, renames, deletes and failures are announced through a polite live region. Each upload also has a labelled progress bar.
Rename, delete and new folder open inline forms in the dialog footer, so focus never leaves the dialog.
Styles live in the
brtcascade layer and use only--brt-*tokens, so every skin, the dark theme and forced colours apply.
Limits
Folders cannot be renamed, moved or deleted from the manager, and files cannot be moved between folders.
One file is selected at a time; there is no multi-select or bulk delete.
Deleting a file does not touch documents that already use it.