Skip to Content
Customizing the EditorCustomizing the Menus

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

MenuKeyShows
FloatingtextFormatOn a non-empty text selection.
FloatinglinkCaret on a link.
FloatingslashThe slash menu.
FloatingmathA selected equation.
ContextbaseAlways (clipboard actions).
ContextlinkCaret on a link.
ContextparagraphCaret in a paragraph or heading, no atom selected.
ContextimageImage selected.
ContexttableCaret in a table.
ContexttocCaret in a table of contents.
ContextcodeBlockCaret 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 id matches a built-in (paragraph, heading1heading3, bulletList, orderedList, codeBlock, horizontalRule, table) replaces it in place.
  • New items with order go before the built-ins, sorted ascending. New items without order go after them.
  • When the menu opens, top-level items whose available(view) returns false are 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 its group matches 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.

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) => void setter. Opening a menu closes the menus in its closeOnOpen list.
  • useRegisterMenuRules(key, closeOnOpen) registers closeOnOpen rules for a menu that is not a floatingMenuExtensions entry. Entries are registered automatically.

The context menu’s key is "context". Floating menus that should not overlap it list it in closeOnOpen.

Next steps

Last updated on