Skip to Content
GuidesCustomizing 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). 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

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.
ContextcalloutCaret in a callout: its type and removing it.
ContextfloatingBoxA selected text box: edit its text, wrap, label, convert to text, delete.
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"; // 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 id matches a built-in (paragraph, heading1–heading3, 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.
  • Two items with the same new id render once: the later one wins.
  • When the menu opens, items whose available(view) returns false are 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 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, 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.

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