Better Rich Text Buy licence

Smart content

On this page
  1. Setup
  2. Autocomplete
  3. Mentions
  4. Output
  5. Commands
  6. Notifications
  7. Plain fields
  8. Merge tags
  9. Output
  10. Commands
  11. Conditional content
  12. Rendering
  13. Dates
  14. Templates
  15. Library
  16. Managing templates
  17. Read-only categories
  18. Variables
  19. Commands
  20. REST API
  21. Emoji
  22. Image editing
  23. Saving
  24. Cross-origin images
  25. Keyboard
  26. Hooks
  27. Accessibility
  28. Limitations
  29. Assets
  30. Asset source
  31. Commands
  32. Image dialog
  33. Keyboard
  34. Hooks
  35. Accessibility
  36. 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.

PackageAdds
@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

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

IdControl
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.error action.

To build your own trigger:

TypeScript
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

TypeScript
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

HTML
<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

CommandInputResult
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.inserted fires after mention.insert with { 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:

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

FunctionDoes
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

TypeScript
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

HTML
<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

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

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

HTML
<div class="brt-conditional" data-condition='{"match":"all","terms":[{"field":"plan","op":"equals","value":"pro"}]}'><p>…</p></div>

Rendering

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

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

HTML
<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.

CommandInputResult
dateTime.insert { value?, format?, locale?, timeZone? } { nodeId }; value defaults to now
dateTime.update { nodeId?, value?, format?, locale?, timeZone? } Re-formats the selected date

Templates

TypeScript
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 editorPlugins option, and the toolbar with editorToolbar.

  • 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 methodEnables
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.

VariableValue
{{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.

TypeScript
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

CommandInputResult
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.

JSON
{
  "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

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

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

TypeScript
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

HookKindArguments
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) and aria-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 alertdialog with 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 brt cascade 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 any or all are available through condition.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 version in update to 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.inserted fires for mentions inserted through mention.insert. Use diffMentions to 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.

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

OptionDefaultPurpose
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

TypeScript
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

CommandInputResult
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

KeyAction
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

HookKindArguments
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 brt cascade 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.