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). StesuraEditor has no direct menu props. Its contextMenu prop is declared but ignored.
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 | 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/types";
// Module scope: the uiExtensions array must be stable.
const calloutExtension: StesuraUIExtension = {
floatingMenuExtensions: [
{ key: "callout", element: <CalloutMenuController />, closeOnOpen: ["textFormat", "context"] },
],
contextMenuExtensions: [
{ key: "callout", element: <CalloutContextMenu />, appliesTo: isInCallout },
],
};
const uiExtensions = [calloutExtension];
<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 "@handlewithcare/react-prosemirror";
import { ContextMenuSeparator, EditorContextMenu } from "@stesura/editor-react-ui";
import type { EditorState } from "prosemirror-state";
// Runs on every transaction: keep it cheap.
const isInCallout = (state: EditorState) => state.selection.$from.parent.type.name === "callout";
const CalloutContextMenu = () => {
const onRemove = useEditorEventCallback((view) => removeCallout(view.state, view.dispatch));
return (
<>
<ContextMenuSeparator />
<EditorContextMenu onClick={onRemove}>Remove callout</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 "@handlewithcare/react-prosemirror";
import {
FloatingMenuShell,
useIsOpenMenu,
useSetOpenMenu,
type EditorFloatingMenuOptions,
} from "@stesura/editor-react";
import { useMemo } from "react";
const CalloutMenuController = () => {
const isOpen = useIsOpenMenu("callout");
const setOpen = useSetOpenMenu("callout");
const options = useMemo<EditorFloatingMenuOptions>(
() => ({ onClose: () => setOpen(false), placement: "bottom" }),
[setOpen]
);
useEditorEventListener("mouseup", (view) => {
setOpen(isInCallout(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/types:
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/types";
const calloutSlashItem: SlashMenuExtension = {
id: "callout",
type: "command",
label: "Callout",
group: "Advanced",
icon: CalloutIcon, // any ComponentType<{ className?: string }>
command: (view) => insertCallout(view.state, view.dispatch),
};
const calloutExtension: StesuraUIExtension = {
slashMenuExtensions: [calloutSlashItem],
};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. - When the menu opens, top-level items whose
available(view)returnsfalseare dropped. - 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, all other keys go to the filter, including Ctrl/Cmd shortcuts.
Hiding built-in menus
StesuraEditor has no switch to turn built-in menus off. Override them by key instead:
const trimmedMenus: StesuraUIExtension = {
// Floating menu: an empty element replaces the built-in.
floatingMenuExtensions: [{ key: "link", element: null }],
// Context menu: same, or gate it with `appliesTo: () => false`.
contextMenuExtensions: [{ key: "table", element: null }],
// Text-format popup button.
textMenuExtensions: [{ key: "formatPainter", element: null }],
// Slash item: replace it with one that is never available.
slashMenuExtensions: [
{ id: "table", type: "command", label: "", command: () => {}, available: () => false },
],
};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.