Menus and dialogs
On this page
Opt-in interface features for a classic editor: a menu bar, a context menu, a Styles menu, help, preview and block handles. Each is only bundled when you import it. The last section covers the toolbar on phones, which needs no setup.
import { createRichTextEditor, standardPreset } from '@better-rich-text/core';
import { contextMenuFeature, helpFeature, menubarFeature, previewFeature } from '@better-rich-text/ui';
createRichTextEditor('#editor', {
features: [...standardPreset.features, menubarFeature, contextMenuFeature, helpFeature, previewFeature],
menubar: 'file edit view insert format tools table help',
menu: { custom: { title: 'Custom', items: 'bold italic | link' } },
contextmenu: 'spelling link image table clipboard',
});Menu bar
menubarFeature adds File, Edit, View, Insert, Format, Tools, Table and Help above the toolbar.
menubarpicks the menus and their order.falsehides the bar.menuadds menus or replaces the defaults (DEFAULT_MENUS). Each is{ title, items }, whereitemsis a space-separated list with|between groups.An item is a toolbar item id (
bold,link,table,templates), a command name (history.undo) or a TinyMCE menu item name (strikethrough,forecolor,inserttable,searchreplace,cell,row,column,tableprops).Labels, shortcuts, checked and disabled states come from the commands. Menu buttons such as Text style and Styles become submenus. Items that are not installed are left out, and a menu with nothing in it is hidden.
Built-in items:
cut,copy,paste,pastetext,selectall,newdocument,print(with plugin-documents) andwordcount.
Keyboard
| Keys | Action |
|---|---|
| Alt+F9 | Focus the menu bar |
| Left, Right | Move between menus; with a menu open, open the next one |
| Down, Enter, Space | Open a menu at its first item |
| Up | Open a menu at its last item |
| Home, End | First or last menu or item |
| A letter | Next menu or item starting with it |
| Right, Left inside a menu | Open or close a submenu |
| Escape | Close one level; on the bar, return to the text |
Context menu
contextMenuFeature replaces the browser menu inside the editor. It opens on right-click, on a touch long-press, and with Shift+F10 or the context-menu key at the caret.
| Section | Shown when | Items |
|---|---|---|
spelling |
Proofing has an issue at the caret | Suggestions, Ignore, Ignore all, Add to dictionary |
suggestion |
A tracked change is at the caret or in the selection | Accept, Reject |
link |
The caret is in a link | Edit, Open, Copy, Remove |
image |
An image is under the pointer | Properties, Edit image (plugin-image-editor), Alternative text, Align |
table |
The caret is in a table | Cell, Row, Column and Table submenus |
list |
The caret is in a list | List types, Increase indent, Decrease indent |
code |
The caret is in a code block | Copy code, Indent, Outdent, Exit |
footnote, anchor, mention, media |
That node is under the pointer | Added by those plugins |
comment |
The comments feature is installed | Comment |
format |
Text is selected | Bold, Italic, Underline, Clear formatting, Link |
clipboard |
Always | Cut, Copy, Paste, Paste as text, Select all |
The
contextmenuoption lists sections and their order, TinyMCE style.falsekeeps the browser menu.Mod+right-click (Cmd on macOS, Ctrl elsewhere) shows the browser menu.
createContextMenuFeature({ native: 'never' })turns that off;longPress: falseleaves long-press to the browser.Paste reads the async Clipboard API. When the browser refuses, a short note says which shortcut to use instead.
Menus flip and shift to stay on screen and mount in the overlay layer, so they work in shadow roots and modal dialogs.
Adding items
Plugins and hosts add sections through the ui.contextMenu filter. Sections are ordered by priority (built-ins use 10 to 90) unless the host sets an order.
editor.editor.hooks.addFilter('ui.contextMenu', (sections, context) => {
if (context.node?.type !== 'callout') return sections;
return [...sections, { id: 'callout', priority: 25, items: [{ id: 'remove', label: 'Remove callout', run: () => context.ui.run('callout.remove') }] }];
});context has the interface (ui), the element under the pointer (target), the selected node (node), the node types around the caret (ancestors), whether text is selected (hasRange) and where dialogs should open (at()). Items can have icon, shortcut, checked, disabled with a reason, separatorBefore and items for a submenu. commandNode(ui, command, input) builds an item from a command.
Styles
createStylesPlugin({ formats }) from @better-rich-text/plugin-styles adds a Styles menu of your block and inline styles, previewed as they look in the content.
createStylesPlugin({
formats: [
{ title: 'Lead', block: 'p', classes: 'lead' },
{ title: 'Headings', items: [{ title: 'Section title', block: 'h2', classes: 'section-title' }] },
{ title: 'Marker', inline: 'span', classes: 'marker' },
],
allowClasses: ['legacy-highlight'],
});blockisp,h1toh6orpre. A block style sets the block type and exports the format's classes (<p class="lead">); loading that HTML restores the style.inlineisspan. Inline styles use theclassNamemark, which only accepts the inline formats' classes andallowClasses, so classes in pasted content are dropped.attributesmay setlanganddir; other attributes are ignored.Groups (
{ title, items }) become submenus.Commands:
styles.apply { name }(applying an active style removes it),styles.remove { name? }andstyles.list.
importCssFormats(stylesheet) builds formats from class selectors, like TinyMCE's importcss: .lead becomes an inline style, p.note and h2.title block styles. It takes CSS text, a CSSStyleSheet or a list of them, plus filter, title and groups options.
Help
helpFeature adds a help button and Alt+0. The dialog has three tabs:
Keyboard shortcuts, generated from the installed keymaps and labelled with the commands' labels, with a filter.
Plugins, with their descriptions and versions.
Accessibility notes on moving between the text, toolbar, menu bar and dialogs.
Preview
previewFeature (or createPreviewFeature({ contentCss, contentStyle, devices })) adds a preview button. The dialog shows the exported HTML in a sandboxed frame with no scripts, styled with the editor's content styles and your contentCss, at desktop, tablet or phone width, and prints it.
Block handles
@better-rich-text/plugin-block-handle puts a handle in the left gutter beside the block under the mouse, or beside the block holding the caret. The document and full presets include it.
import { createRichTextEditor, standardPreset } from '@better-rich-text/core';
import { blockHandleFeature, blockHandlePlugin } from '@better-rich-text/plugin-block-handle';
createRichTextEditor('#editor', {
plugins: [blockHandlePlugin],
features: [...standardPreset.features, blockHandleFeature],
});Drag the handle to move the block. A line shows where it will land, the editor scrolls near its edges, and Escape cancels. On a touch screen, press and hold the handle first.
Click the handle for the block menu: Move up, Move down, Duplicate, Delete, and Turn into a paragraph, heading, quote, list or code block.
A selection across several blocks moves them together.
List items move among list items and take their nested lists with them. Inside a table, the whole table moves.
The handle hides while you type and comes back when the mouse moves. It never shows in a read-only or disabled editor or in the source view.
The content gets a wider start padding so the handle has room, and the handle takes its colours from the skin.
The plugin works without the feature: its commands and shortcuts are enough for inline editors and your own interface.
| Command | Input | |
|---|---|---|
block.moveUp, block.moveDown |
{ blockId? } |
Swap the selected blocks (or blockId) with the block above or below. Disabled at the first or last position. |
block.move |
{ blockIds, target, position } |
Move sibling blocks before or after another block. This is what a drop runs. |
block.duplicate |
{ blockId? } |
Insert a copy after the blocks. |
block.delete |
{ blockId? } |
Delete the blocks. Deleting every item of a list deletes the list, and an emptied document keeps one paragraph. |
Each command is one transaction, so it is one undo step. Moves are node moves rather than a delete and an insert, so collaborators' edits inside a moved block are kept.
Keyboard
| Keys | Action |
|---|---|
| Alt+Shift+Up, Alt+Shift+Down | Move the selected blocks |
| Tab from a paragraph | Focus the handle (Option+Tab in Safari) |
| Enter, Space or Down on the handle | Open the block menu |
| Alt+Shift+Up, Alt+Shift+Down on the handle | Move its block and keep focus on the handle |
| Escape | Close the menu, cancel a drag, or return from the handle to the text |
Moves, copies and deletions are announced to screen readers, for example "Moved block up".
On macOS, Alt+Shift+Up and Down replace Option+Shift+Up and Down, which otherwise extend the selection to the start or end of the paragraph.
Phones
While someone edits on a phone, the toolbar docks to the bottom of the screen, just above the on-screen keyboard, and goes back to its place when the editor loses focus. A spacer keeps its place, so the text does not jump.
The docked toolbar is one row that scrolls sideways, with 44px buttons. The More button and its drawer are not used.
Menus and dialogs opened from it open upwards and stay above the keyboard.
It follows the keyboard with
window.visualViewport, so it works in Safari on iOS and Chrome on Android without changes to your page.The caret is scrolled clear of it while you type near the bottom of the screen.
It respects the safe area (
env(safe-area-inset-*), which needsviewport-fit=coverin your viewport meta tag) and reduced motion, and takes its colours from the skin.
toolbarMobile controls it:
| Value | Behaviour |
|---|---|
'auto' (default) |
Docks on touch screens of phone size: a coarse pointer and a window narrower than 768px or a screen shorter than 600px on its short side. |
'docked' |
Docks on every device while the editor has focus. |
'off' |
Leaves the toolbar where it is. |
createRichTextEditor('#editor', { toolbarMobile: 'off' });If your page sets navigator.virtualKeyboard.overlaysContent = true, the keyboard no longer shrinks the visual viewport, so the toolbar reads the keyboard's height from the VirtualKeyboard API instead. The editor never changes that setting itself, because it applies to the whole page.
The docked toolbar uses position: fixed. Inside an ancestor with a transform, filter or contain it is positioned against that ancestor instead of the screen; set toolbarMobile: 'off' there.