Customizing the Menus
The editor has three kinds of menu:
- Floating menus follow the selection: the text-format popup, the link editor, the equation menu.
- The context menu opens on right-click, Shift+F10 or the Menu key. Each registered menu adds its items when it applies to the selection.
- The slash menu opens when you type
/in a paragraph that is empty or where the caret follows a space.
You extend all three through the uiExtensions prop (see UI Extensions). To remove built-in menus, see Hiding built-in menus.
Floating and context menus take React elements. The slash menu takes data: you describe items, and the built-in controller renders, filters and runs them.
Built-in keys
| Menu | Key | Shows |
|---|---|---|
| Floating | textFormat | On a non-empty text selection. |
| Floating | link | Caret on a link. |
| Floating | slash | The slash menu. |
| Floating | math | A selected equation. |
| Context | base | Always (clipboard actions). |
| Context | link | Caret on a link. |
| Context | paragraph | Caret in a paragraph or heading, no atom selected. |
| Context | image | Image selected. |
| Context | table | Caret in a table. |
| Context | toc | Caret in a table of contents. |
| Context | callout | Caret in a callout: its type and removing it. |
| Context | floatingBox | A selected text box: edit its text, wrap, label, convert to text, delete. |
| Context | codeBlock | Caret in a code block. |
Context-menu items render in the order above. Feature packs add their own keys, for example comments (commentsUiExtension), trackChanges, proofread, crossReference and file (createPdfViewer).
Floating and context menus
type FloatingMenuExtension = {
key: string; // reuse a built-in key to replace that menu
element: React.ReactNode; // usually a controller component
closeOnOpen?: string[]; // menu keys to close when this one opens
};
type ContextMenuExtension = {
key: string; // reuse a built-in key to replace that menu in place
element: React.ReactNode;
order?: number; // set: render before the built-ins, ascending. Omit: render after them.
appliesTo?: (state: EditorState) => boolean; // render only while true
};import type { StesuraUIExtension } from "@stesura/editor-react";
// Module scope: the uiExtensions array must be stable.
const panelExtension: StesuraUIExtension = {
floatingMenuExtensions: [
{ key: "panel", element: <PanelMenuController />, closeOnOpen: ["textFormat", "context"] },
],
contextMenuExtensions: [
{ key: "panel", element: <PanelContextMenu />, appliesTo: isInPanel },
],
};
const uiExtensions = [panelExtension];
<StesuraEditor /* ... */ uiExtensions={uiExtensions} />;Context-menu items
All registered menus render into one popup. A context-menu element renders its items, or null when it has nothing to offer. Use EditorContextMenu from @stesura/editor-react-ui for items:
import { useEditorEventCallback } from "@stesura/editor-react";
import { ContextMenuSeparator, EditorContextMenu } from "@stesura/editor-react-ui";
import type { EditorState } from "prosemirror-state";
// Runs on every transaction: keep it cheap.
const isInPanel = (state: EditorState) => state.selection.$from.parent.type.name === "panel";
const PanelContextMenu = () => {
const onRemove = useEditorEventCallback((view) => removePanel(view.state, view.dispatch));
return (
<>
<ContextMenuSeparator />
<EditorContextMenu onClick={onRemove}>Remove panel</EditorContextMenu>
</>
);
};Prefer appliesTo over rendering disabled items when the menu cannot act on the selection. The built-in paragraph and toc menus use it.
Floating-menu controllers
A floating-menu element is a controller: it decides when to show and renders nothing otherwise. Keep its open state in the editor’s menu store with useIsOpenMenu / useSetOpenMenu, so closeOnOpen rules apply to it. Render the menu through FloatingMenuShell, which positions it at the selection and handles outside-click and Escape dismissal.
import { useEditorEventListener } from "@stesura/editor-react";
import {
FloatingMenuShell,
useIsOpenMenu,
useSetOpenMenu,
type EditorFloatingMenuOptions,
} from "@stesura/editor-react";
import { useMemo } from "react";
const PanelMenuController = () => {
const isOpen = useIsOpenMenu("panel");
const setOpen = useSetOpenMenu("panel");
const options = useMemo<EditorFloatingMenuOptions>(
() => ({ onClose: () => setOpen(false), placement: "bottom" }),
[setOpen]
);
useEditorEventListener("mouseup", (view) => {
setOpen(isInPanel(view.state));
});
if (!isOpen) return null;
return <FloatingMenuShell floatingMenuOptions={options}>…</FloatingMenuShell>;
};EditorFloatingMenuOptions also takes offset, strategy, role and getPositionReference. Pass manageFocus to FloatingMenuShell to move focus into the menu on open.
The slash menu
Each slash-menu item is either a command (runs, then closes the menu) or a submenu (opens a nested list). The types are exported from @stesura/editor-react:
type MenuItem = {
id: string; // reuse a built-in id to replace that item
label: string; // also what the filter matches
type: "command" | "submenu";
group?: string; // heading the item is listed under
icon?: ComponentType<{ className?: string }>;
available?: (view: EditorView) => boolean; // checked when the menu opens; default true
locked?: boolean; // hidden from the top-level list
order?: number; // new items only: set to place before the built-ins, ascending
};
interface CommandItem extends MenuItem {
type: "command";
command: (view: EditorView) => void;
}
interface SubMenu extends MenuItem {
type: "submenu";
elements: MenuElement[];
callbackOnClose?: () => void;
}
type MenuElement = CommandItem | SubMenu;
type SlashMenuExtension = MenuElement;Adding items
import type { SlashMenuExtension, StesuraUIExtension } from "@stesura/editor-react";
const panelSlashItem: SlashMenuExtension = {
id: "panel",
type: "command",
label: "Panel",
group: "Advanced",
icon: PanelIcon, // any ComponentType<{ className?: string }>
command: (view) => insertPanel(view.state, view.dispatch),
};
const panelExtension: StesuraUIExtension = {
slashMenuExtensions: [panelSlashItem],
};The / and the filter text are never inserted into the document, so command runs on the paragraph where the menu opened.
Resolution rules
- An item whose
idmatches a built-in (paragraph,heading1–heading3,bulletList,orderedList,codeBlock,horizontalRule,table) replaces it in place. - New items with
ordergo before the built-ins, sorted ascending. New items withoutordergo after them. - Two items with the same new
idrender once: the later one wins. - When the menu opens, items whose
available(view)returnsfalseare dropped, inside submenus too. - Items are then grouped by
group, in the order each group first appears. Built-in group headings are localized; in English they are “Basic blocks”, “Lists” and “Advanced”. An item joins a built-in group only if itsgroupmatches the heading in the active locale.
Keyboard
The filter is a case-insensitive substring match on label. Arrow Up/Down move the selection. Enter or Tab runs the item, and Arrow Right opens a submenu. Escape, Backspace on an empty filter or a second / closes the open submenu, or the whole menu at the top level (a second / is then inserted). Arrow Left also leaves a submenu. While the menu is open, other printable keys go to the filter. Shortcuts with Ctrl, Cmd or Alt (but not AltGr) and IME composition go to the editor.
Hiding built-in menus
Pass opt-out maps to StesuraEditor. false is the only legal value, and the keys are those in Built-in keys, or the built-in item ids for the slash menu (see Resolution rules). They apply to sub-editors (headers, footers, notes) too.
import type { MenuDefaults } from "@stesura/editor-react";
// Module scope: each map must be a stable reference.
const menuDefaults: MenuDefaults = {
contextMenuDefaults: { table: false },
floatingMenuDefaults: { link: false },
slashMenuDefaults: { table: false, codeBlock: false },
textMenuDefaults: { formatPainter: false },
};
<StesuraEditor /* ... */ {...menuDefaults} />;An extension entry that reuses a removed key still renders, after the built-ins. To replace a built-in instead, register an entry with its key: floatingMenuExtensions: [{ key: "link", element: <MyLinkMenu /> }].
Replacing textFormat also removes buttons that other extensions add to the text-format popup, such as the AI and comment buttons.
Menu coordination hooks
Open menus live in a per-editor store. These hooks from @stesura/editor-react only work in menu elements and node views; elsewhere (panels, status bar, slots, toolbar) they throw.
useIsOpenMenu(key)subscribes to whether a menu is open.useSetOpenMenu(key)returns an(open: boolean) => voidsetter. Opening a menu closes the menus in itscloseOnOpenlist.useRegisterMenuRules(key, closeOnOpen)registerscloseOnOpenrules for a menu that is not afloatingMenuExtensionsentry. Entries are registered automatically.
The context menu’s key is "context". Floating menus that should not overlap it list it in closeOnOpen.