UI Extensions
A StesuraUIExtension bundles the UI a feature adds to the editor: toolbar items, panels, menu entries, node views, status-bar entries and overlays. Pass extensions to StesuraEditor through the uiExtensions prop. It is the only way to add this UI: StesuraEditor has no separate props for panels, menus or status-bar entries.
Shape
All fields are optional (source: packages/editor-react/src/types/core.ts):
type StesuraUIExtension = {
// Panels
leftPanels?: LeftPanelElement[];
rightPanels?: RightPanelElement[];
bottomPanels?: BottomPanelElement[];
// Menus
contextMenuExtensions?: ContextMenuExtension[];
floatingMenuExtensions?: FloatingMenuExtension[];
textMenuExtensions?: TextMenuExtension[];
slashMenuExtensions?: SlashMenuExtension[];
// Toolbar
toolbarDefaultTabs?: ToolbarDefaultTabsDefaults; // hide built-in tabs
toolbarDefaultExtensions?: ToolbarDefaultExtension[]; // append elements to built-in tabs
toolbarGroupExtensions?: ToolbarGroupExtension[]; // insert responsive groups
toolbarTabs?: ToolbarExtensionTab[]; // whole tabs, usually contextual
// Node views
nodeViews?: NodeViews;
nodeViewWrappers?: Record<string, ComponentType<NodeViewWrapperComponentProps>>;
nodeViewFallback?: (type: NodeType) => NodeView | undefined;
nodeViewWrapperFallback?: (type: NodeType) => ComponentType<NodeViewWrapperComponentProps> | undefined;
// Status bar and slots
StatusBarElements?: StatusBarElement[];
documentOverlaySlot?: ReactNode;
ResizableLayoutBottomSlot?: ReactNode;
CommentsSlot?: ReactNode;
TopLeftSlot?: ReactNode;
// PDF file previews (@stesura/pdf-viewer-react)
pdfPreview?: PdfPreviewCapability;
// Callout title bar content
calloutFrame?: ComponentType<CalloutFrameProps>;
};Pass the extensions as a stable array. A new array identity re-merges every field and re-creates all node views.
import { StesuraEditor } from "@stesura/editor-react";
import { paginationUiExtension } from "@stesura/pagination-react";
import { trackChangesUIExtension } from "@stesura/track-changes-react";
import { referenceUiExtensions } from "@stesura/references-react";
// Module scope, or useMemo.
const uiExtensions = [paginationUiExtension, trackChangesUIExtension, referenceUiExtensions];
<StesuraEditor /* state, schema, ... */ uiExtensions={uiExtensions} />;How extensions merge
- Keyed entries are resolved in array order, and the later entry wins. This covers panels (by
action), node views (by node type), and menu entries (bykey, oridfor slash items), whether they reuse a built-in key or add a new one. - Everything else is concatenated in array order: toolbar elements, groups and tabs, status-bar entries and slots.
toolbarDefaultTabsmaps are combined. Sincefalseis the only legal value, any extension can hide a tab and none can bring it back.pdfPreviewandcalloutFrame: the last extension that declares it wins.
Writing your own extension
import type { StesuraUIExtension } from "@stesura/editor-react";
const wordCountExtension: StesuraUIExtension = {
bottomPanels: [{ action: "word-count", title: "Word count", content: <WordCountPanel /> }],
};
const uiExtensions = [wordCountExtension];
<StesuraEditor /* ... */ uiExtensions={uiExtensions} />;Everything except the toolbar renders inside the editor’s ProseMirror context, so hooks such as useEditorState and useEditorEventCallback from @stesura/react-prosemirror work there. The toolbar sits outside it: toolbar elements read the editor through the bound-editor hooks (useBoundEditorState, useBoundEditorViewCallback, …).
Toolbar items
These fields only affect the built-in toolbar. If you pass your own toolbar (or toolbar={false}), they are ignored.
Adding content to a built-in tab
toolbarDefaultExtensions appends a React node to a built-in tab, or to another extension’s tab
by its value:
const myExtension: StesuraUIExtension = {
toolbarDefaultExtensions: [
// tabKey: "home" | "insert" | "view" | "page" | "headerFooter" | "tableDesign" | "tableLayout"
// | "references" | "review" | "image" | "callout" | "floatingBox" | "export"
{ tabKey: "insert", element: <MyInsertButton /> },
],
};These elements render after the tab’s groups and never collapse. For a group that collapses responsively, use toolbarGroupExtensions: { tabKey, group, after? }, where group comes from defineToolbarGroup (@stesura/editor-react) and after is the key of the group to insert after. See Create new items.
Adding a tab
toolbarTabs adds whole tabs after the built-in ones. They are usually contextual: shown, and
activated, while the selection is on something of yours. The Diagram tab of
@stesura/mermaid-react is one:
import type { StesuraUIExtension, ToolbarExtensionTab } from "@stesura/editor-react";
const chartTab: ToolbarExtensionTab = {
value: "chart",
label: "Chart",
Component: ChartTab, // see below
isVisible: (ctx) => ctx.selectedNodeType === "chart",
focusKey: (ctx) => (ctx.selectedNodeType === "chart" ? ctx.selectedNodeKey : null),
};
const chartExtension: StesuraUIExtension = { toolbarTabs: [chartTab] };label is a string, or a function of the UI translations ((t) => t.tabs.diagram) to follow
the editor’s locale. isVisible
and focusKey take the ToolbarTabContext;
selectedNodeType and selectedNodeKey describe a node-selected block. Keep the tab object, its
callbacks and Component at module scope.
Other extensions add to your tab as to a built-in one: toolbarDefaultExtensions and
toolbarGroupExtensions entries whose tabKey is its value reach Component as the
extensions and groupExtensions props. Render them the way the built-in tabs do:
import {
defineToolbarTab,
useMergedToolbarTab,
type ToolbarTabComponentProps,
} from "@stesura/editor-react";
import { ToolbarTabRenderer } from "@stesura/editor-react/toolbar";
const ChartTab = ({ extensions, groupExtensions }: ToolbarTabComponentProps) => {
const tab = useMemo(() => defineToolbarTab({ value: "chart", label: "Chart", groups }), []);
const merged = useMergedToolbarTab(tab, groupExtensions);
return <ToolbarTabRenderer tab={merged} extensions={extensions} />;
};Hiding a built-in tab
const myExtension: StesuraUIExtension = {
toolbarDefaultTabs: {
export: false, // hides the Export tab
},
};Panels
There are three panel areas: left, right and bottom. Each shows one panel at a time, chosen by its action.
type LeftPanelElement = { action: string; content: ReactNode };
type RightPanelElement = { action: string; content: ReactNode };
type BottomPanelElement = {
action: string;
content: ReactNode;
title?: string; // label of the collapsed expander button; defaults to "Bottom panel"
};Open and close panels with the hooks from @stesura/editor-react:
import { useLeftPanelActions } from "@stesura/editor-react";
const OpenOutlineButton = () => {
const { openLeftPanel } = useLeftPanelActions();
return <button onClick={() => openLeftPanel("my-panel")}>Outline</button>;
};| Area | Actions only (no re-render) | State | Both |
|---|---|---|---|
| Left | useLeftPanelActions() | useLeftPanelState() | useLeftPanelControls() |
| Right | useRightPanelActions() | useRightPanelState() | useRightPanelControls() |
| Bottom | useBottomPanelActions() | useBottomPanelState() | useBottomPanelControls() |
The actions are open*Panel(action), close*Panel(), toggle*Panel(action) and set*PanelAction(action). openRightPanel(action, meta?) also passes a payload to the panel. Right-panel actions are async and respect a guard a panel can register (the stylesheet panel uses it to confirm discarding edits).
Built-in actions:
- Left:
"toc"(navigation),"search"(search results). - Right:
"format paragraph","style sheet","toc settings","accessibility". - From feature packs:
"proofread"(proofreadUIExtension),"crossReference"(referenceUiExtensions),"aiChat"(createAiUIExtensionwithchatApi).
You can replace a built-in panel by registering the same action. To remove one, pass defaultLeftPanels / defaultRightPanels to StesuraEditor, e.g. defaultRightPanels={{ "style sheet": false }}. When any bottom panel is registered, an expander button appears at the bottom right of the document area. In the compact (phone) layout, left and right panels open as drawers and bottom panels are not rendered.
Menus
| Field | Adds | Built-in keys |
|---|---|---|
contextMenuExtensions | Items in the right-click menu: { key, element, order?, appliesTo? } | base, link, paragraph, image, table, toc, callout, codeBlock |
floatingMenuExtensions | Menus that follow the selection: { key, element, closeOnOpen? } | textFormat, link, slash, math |
textMenuExtensions | Buttons in the text-format popup: { key, element } | bold, italic, underline, highlight, formatPainter, fontSizeIncrease, fontSizeDecrease, fontColor, backgroundColor |
slashMenuExtensions | Slash-menu items, as data rather than elements | paragraph, heading1–heading3, bulletList, orderedList, codeBlock, horizontalRule, table |
Reusing a built-in key (or slash-menu id) replaces that entry in place. New text-menu buttons are appended after the built-ins. See Customizing the Menus for ordering, controllers and how to hide built-ins.
Node views
const panelExtension: StesuraUIExtension = {
nodeViews: { panel: PanelNodeView },
};Resolution order, later wins: built-in views, then extension nodeViews in array order, then the nodeViews prop on StesuraEditor. A prop entry only wins when it is a custom view: passing a built-in view back in keeps the extension’s override. See Node Views.
nodeViewWrappers wraps whichever view wins for a node type. The wrapper receives the node-view props (without ref), must render {children} (the wrapped view) and must not add DOM. It contributes through context. Unlike a nodeViews entry, a wrapper survives the consumer’s overrides. Pagination uses it for page breaks inside table cells and rows.
nodeViewFallback and nodeViewWrapperFallback cover block types the host’s schema adds, which no extension can name. Each is asked per such type and returns undefined to pass: the first gives a view when nothing registered one (the last extension answering wins), the second a wrapper when this extension names none for the type. Pagination uses them to lay out the host’s textblocks like paragraphs and to give its other blocks their page-break lead. See Your own block types.
Callout title bar
calloutFrame replaces what the callout title bar shows. It receives { kind, title, label, icon }
(label is the kind’s localized name, icon the kind’s default icon) and renders inside the
non-editable bar. See Callouts.
Slots
Slot contents from all extensions are rendered, in array order.
| Field | Where it renders |
|---|---|
documentOverlaySlot | Always mounted in the main editor column (around the document area and bottom panel), outside the scroll area. Position content yourself or portal it. Used for the header/footer editor, the footnote and endnote panels, and the track-changes tooltip. |
ResizableLayoutBottomSlot | Directly below the document scroll area, above the bottom panel. |
CommentsSlot | Next to the document, inside the scroll area. The canvas only reserves room for it when comment threads are visible, so it is meant for commentsUiExtension. |
TopLeftSlot | Absolutely positioned over the top-left corner of the document area, above the scroll area. |
Status bar
StatusBarElements entries are { element, position: "left" | "right", order? }. Left entries follow the built-in language, statistics and units controls; order places them among those (language 0, statistics 20, display units 30, default 100). Right entries sit before the mode toggle and zoom controls. Entries are not rendered in the compact (phone) layout.
When the bar runs out of room it collapses: each entry built on StatusBarButton swaps its label for its collapsedRender (usually an icon), and the zoom slider hides, leaving the zoom-level menu. An entry without a collapsedRender keeps its label.
The bar is a role="region" with one Tab stop per widget. It is not a toolbar: there is no roving tabindex, and nothing manages your element’s focus. Your element must carry its own semantics:
- Name it for what it does. The visible text is usually the current state (“Pagination on”). Use
"<state> — <what it opens>"as the accessible name, keeping the visible text inside it (WCAG 2.5.3). - Show a focus ring. A bare
<button>has none. Render triggers asStatusBarButton(from@stesura/editor-react), like the built-in entries; it is a ghostButtonthat also collapses with the bar. - Meet the 24×24px target minimum (WCAG 2.5.8).
Button size="icon-xs"is 24px. - Mark decorative icons
aria-hiddenand pair them with visible text. Color alone cannot carry state. - Don’t add a divider. The bar inserts
aria-hiddendividers between entries.
import { StatusBarButton } from "@stesura/editor-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DocumentTextIcon,
} from "@stesura/editor-react-ui";
const WordGoal = () => (
<DropdownMenu>
<DropdownMenuTrigger
render={<StatusBarButton collapsedRender={<DocumentTextIcon className="size-3.5" aria-hidden />} />}
aria-label="Goal: 1,200 words — change word goal"
>
Goal: 1,200 words
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={10}>
<DropdownMenuItem>Change goal…</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
const myExtension: StesuraUIExtension = {
StatusBarElements: [{ element: <WordGoal />, position: "right" }],
};Reference implementations: ProofreadToggleStatusBar (exported from @stesura/proofread-react) and the pagination toggle (packages/pagination-react/src/status-bar, not exported).
Escape anywhere in the bar returns focus to the document. Menus and dialogs portal to document.body, so their own Escape handling is unaffected.
Feature-pack extensions
| Export | Package |
|---|---|
paginationUiExtension | @stesura/pagination-react |
trackChangesUIExtension | @stesura/track-changes-react |
referenceUiExtensions | @stesura/references-react |
commentsUiExtension | @stesura/comments-react |
proofreadUIExtension | @stesura/proofread-react |
docxUIExtension, createDocxUIExtension(options) | @stesura/docx-react |
createMermaidUiExtension(options) | @stesura/mermaid-react |
pdfUiExtension | @stesura/pdf-export-client |
createPdfViewer(options) | @stesura/pdf-viewer-react |
createAiUIExtension(options) | @stesura/ai-react |
The create… exports are factories: call them once (module scope or useMemo) so the array stays stable.