Customizing the Toolbar
The toolbar is a tabbed, Word-style ribbon rendered above the editor. You can customise it at three levels:
- Contribute through
uiExtensions. Hide default tabs, or add controls and groups to them. Covers most cases. - Pass your own
<ToolBar tabs={...} />to control which tabs render. - Build tabs, groups and buttons from the same components and hooks the default toolbar uses.
Where things live
| Import from | What you get |
|---|---|
@stesura/editor-react | Toolbar hooks only: useRunTbCommand, useBoundEditorDebouncedState, useToolbarTabContext, probes and selectors, … |
@stesura/editor-react/toolbar | ToolBar, the default tab components (HomeTab, TableTab, …), defineToolbarGroup / defineToolbarTab / mergeToolbarGroups, toolbarItem, ToolbarTabRenderer, useMergedToolbarTab, useDefaultToolbarTabs, the built-in probes and selectors, and the same hooks. |
@stesura/editor-react/types | Toolbar types: ToolbarTab, ToolbarTabContext, ToolbarTabConfig, ToolbarTabDef, ToolbarGroupExtension, … |
@stesura/editor-react-ui | Visual primitives: Toolbar.Group/Divider/Content/Wrapper, EditorButton, EditorButtonLarge, EditorButtonLargeDropdown, EditorSplitButton, UnitNumberPicker, ColorPicker, icons. |
The default toolbar is lazy-loaded. StesuraEditor downloads its chunk only when it renders the default toolbar, so toolbar={false} or toolbar={<MyToolBar />} skips it. This is also why the toolbar components are not exported from the package root.
Default tabs
The default ToolBar renders up to ten tabs, in this order:
| Tab key | Component | Shown | Built-in contents |
|---|---|---|---|
home | HomeTab | always | Editing (undo/redo, format painter, clear formatting), font, paragraph, search, styles |
insert | InsertTab | always | Page break, table, symbols/emoji, equation, image, file, code block, link |
view | ViewTab | always | Navigation pane, ruler, zoom |
page | PageTab | always | Section settings (size, margins, orientation, page colour) |
headerFooter | HeaderFooterTab | while a header/footer editor is open | Position, page numbering, close |
table | TableTab | while the selection is in a table | Styling, spacing, rows/columns, merge, alignment, sizes, sorting |
references | ReferencesTab | always | Table of contents |
review | ReviewTab | always | Accessibility check |
image | ImageTab | while an image is selected | Align, wrap, size |
export | ExportTab | always | JSON export |
Feature packages add the rest through their uiExtensions: footnotes, endnotes and cross-references on References; track changes, comments and proofreading on Review; DOCX and PDF export on Export.
Contextual tabs behave like Word’s. They appear only while their context is active and activate when the context is entered (clicking into a table opens the Table tab). The user can switch away; the tab activates again only when the context changes to a different target, such as another table. When the context ends, the toolbar returns to the last non-contextual tab the user had open.
Layer 1: contribute through uiExtensions
Apart from toolbar (below), StesuraEditor has no toolbar props. Toolbar changes go through uiExtensions (StesuraUIExtension[]). Each extension can set three toolbar fields:
| Field | Type | What it does |
|---|---|---|
toolbarDefaultTabs | Partial<Record<ToolbarDefaultTabs, false>> | Hides default tabs, contextual ones included (e.g. { headerFooter: false }). Disable-only: no extension can switch a tab back on. |
toolbarDefaultExtensions | { tabKey, element }[] | Appends a React element to a default tab, after its groups. It never collapses, but its width is reserved so the groups still fit around it. |
toolbarGroupExtensions | { tabKey, group, after? }[] | Inserts a responsive group into a default tab after the group keyed after (appended when after is missing or unknown). See Responsive toolbar API. |
import type { StesuraUIExtension } from "@stesura/editor-react/types";
// Module scope — the array must be referentially stable.
const myToolbarTweaks: StesuraUIExtension = {
toolbarDefaultTabs: { image: false, export: false },
toolbarDefaultExtensions: [{ tabKey: "home", element: <MyHomeButton /> }],
};
const uiExtensions = [trackChangesUIExtension, referenceUiExtensions, paginationUiExtension, myToolbarTweaks];
<StesuraEditor
state={editorState}
dispatchTransaction={dispatch}
field="default"
schema={schema}
pluginFactory={pluginFactory}
uiExtensions={uiExtensions}
/>The extensions’ toolbarDefaultTabs maps are merged in array order. Keep the uiExtensions array stable (module scope or useMemo).
To hide the toolbar: toolbar={false}. To replace it: toolbar={<MyToolBar />}.
Layer 2: build your own <ToolBar>
ToolBar props:
| Prop | Type | Notes |
|---|---|---|
tabs | ToolbarTab[] | Replaces the default tabs. When set, the three props below are ignored. |
toolbarDefaultTabs | Partial<Record<ToolbarDefaultTabs, false>> | Merged after the extensions’ maps. |
toolbarDefaultExtensions | ToolbarDefaultExtension[] | Appended after the extensions’ entries. |
uiExtensions | StesuraUIExtension[] | Read for their toolbar fields. |
onTabClick | (tabValue: string) => void | Called when the user selects a tab. |
className | string | Applied to the tab list. |
A ToolbarTab is { value?, label?, content, isVisible?, focusKey? }. content is any React node, so you can mix the default tab components with your own:
import { HomeTab, ToolBar } from "@stesura/editor-react/toolbar";
import type { ToolbarTab } from "@stesura/editor-react/types";
// Module scope: a new array identity re-derives every tab.
const tabs: ToolbarTab[] = [
{ value: "home", label: "Home", content: <HomeTab /> },
{ value: "callouts", label: "Callouts", content: <CalloutsTab /> },
];
const MyToolBar = () => <ToolBar tabs={tabs} />;A default tab component rendered this way shows only its built-in groups. Extension contributions reach it through its extensions and groupExtensions props, which the default toolbar fills from useDefaultToolbarTabs.
useDefaultToolbarTabs(options?) returns the default tab set as data: ToolbarTabConfig[], one { value, label, extensions, groupExtensions, isVisible?, focusKey? } per enabled tab, in default order. It takes the same toolbarDefaultTabs, toolbarDefaultExtensions and uiExtensions as ToolBar. Use it to reorder or filter the defaults and build your own tabs:
import {
HomeTab,
InsertTab,
ToolBar,
useDefaultToolbarTabs,
type ToolbarTabComponentProps,
} from "@stesura/editor-react/toolbar";
import type {
StesuraUIExtension,
ToolbarDefaultTabs,
ToolbarTab,
} from "@stesura/editor-react/types";
import { useMemo, type ComponentType } from "react";
// Only Home and Insert, in that order, with extension contributions kept.
const COMPONENTS: Partial<Record<ToolbarDefaultTabs, ComponentType<ToolbarTabComponentProps>>> = {
home: HomeTab,
insert: InsertTab,
};
const MyToolBar = ({ uiExtensions }: { uiExtensions: StesuraUIExtension[] }) => {
const configs = useDefaultToolbarTabs({ uiExtensions });
const tabs = useMemo(
() =>
configs.flatMap((config): ToolbarTab[] => {
const Tab = COMPONENTS[config.value];
if (!Tab) return [];
return [
{
value: config.value,
label: config.label,
isVisible: config.isVisible,
focusKey: config.focusKey,
content: (
<Tab extensions={config.extensions} groupExtensions={config.groupExtensions} />
),
},
];
}),
[configs]
);
return <ToolBar tabs={tabs} />;
};Contextual custom tabs
Any tab can set isVisible and focusKey. Both receive a ToolbarTabContext:
type ToolbarTabContext = {
/** `attrs.id` of the table containing the selection, else null. */
tableId: string | null;
/** Stable key of the selected image (`attrs.id`, falling back to its pos), else null. */
imageKey: string | null;
/** `${sectionId}:${target}:${variant}` while a header/footer editor is open, else null. */
headerFooterKey: string | null;
};isVisible(ctx): hide the tab when it returnsfalse. Omit for an always-visible tab.focusKey(ctx): a non-null key while the tab’s context is entered. The tab activates only when the key changes to a new non-null value.
A tab that appears and activates whenever the selection enters a table:
import type { ToolbarTab } from "@stesura/editor-react/types";
const myTableTab: ToolbarTab = {
value: "my-table",
label: "Table tools",
content: <MyTableTab />,
isVisible: (ctx) => ctx.tableId !== null,
focusKey: (ctx) => ctx.tableId,
};Define tabs and their callbacks at module scope (or memoize them). useToolbarTabContext() returns the same context for your own components; its identity changes only when one of the three keys does.
Responsive custom tabs
Plain JSX content does not collapse: it scrolls horizontally when too wide. For a custom tab that demotes and collapses like the default ones, define it with defineToolbarTab and render it with ToolbarTabRenderer:
import { ToolBar, ToolbarTabRenderer, defineToolbarTab } from "@stesura/editor-react/toolbar";
const calloutsTab = defineToolbarTab({
value: "callouts",
label: "Callouts",
groups: [calloutGroup, stampGroup], // from defineToolbarGroup
});
const tabs = [
{ value: calloutsTab.value, label: calloutsTab.label, content: <ToolbarTabRenderer tab={calloutsTab} /> },
];
const MyToolBar = () => <ToolBar tabs={tabs} />;ToolbarTabRenderer takes tab plus optional extensions (plain elements appended after the groups). To merge { tabKey, group, after? } extensions into your own tab, pass it through useMergedToolbarTab(tab, groupExtensions) first. Groups are covered in Responsive toolbar API.
Layer 3: building groups and buttons
Tab content is any React node. The default toolbar is built from:
- Layout.
Toolbar.Group(controls above a label row) separated byToolbar.Divider. The tab panel already wraps content inToolbar.Content, a fixed-height, horizontally scrollable row. For responsive groups, usedefineToolbarGroupinstead (see Responsive toolbar API). - Buttons.
EditorButton(small icon button),EditorButtonLarge(icon above a label, optionaldropdownchevron),EditorButtonLargeDropdown,EditorSplitButton. - State and commands. The
Tbhooks, exported from@stesura/editor-react. They target the editor bound to the toolbar: the main editor, or a focused sub-editor such as a header or footer.
| Hook | Purpose |
|---|---|
useRunTbCommand() | Returns a stable (command, focus = true) => boolean that runs a command on the bound editor. Returns false when no editor is bound or view mode blocks the command. Pass false as the second argument to skip refocusing the editor. |
defineToolbarProbes() + useCanRunTb(probe) | Enabled state for buttons. The preferred mechanism, see below. |
useGetCanRunTbCommand() | Returns a non-subscribing (command) => boolean. For argument-dependent checks in content that mounts on open (dropdowns, dialogs). |
useCanRunTbCommand() | Returns a (command) => boolean checker that re-renders the component on every debounced tick. |
defineToolbarSelectors() + useTbSelector(selector) | Display state (isActive, current values). See below. |
useIsMarkActiveTb(markName) | Whether a mark is active in the selection. Re-renders only when the answer flips. |
useBoundEditorDebouncedState() | The bound editor’s state, debounced by 200 ms. Re-renders on every tick. |
useTextStylesTbDebounced() / useGetNodeFormattingTb() | Text styles / node formatting of the selection. Re-render on every tick; prefer a selector for single fields. |
useTableAttrsTbDebounced() | Attributes of the table at the selection. |
useImageAttrsTbDebounced() / useAnyImageTbDebounced() | The selected block image’s attributes / the selected image of either kind, as { kind, attrs } with kind "block" or "anchored". |
useCurrentParagraphDirTb() | Resolved text direction of the first textblock in the selection. |
useToolbarTabContext() | The contextual-tab context above. |
In view mode, toolbar commands are blocked: probes and the can-run hooks answer false, and useRunTbCommand refuses to run them. Wrap a command that only reads (e.g. opening a panel) in allowInViewMode(command) to exempt it. The exemption applies to that exact command reference.
Enabled state: probes
A probe is a command registered once, at module scope, whose dry-run answer drives a button’s disabled prop:
import { defineToolbarProbes, useCanRunTb } from "@stesura/editor-react";
import { insertTable } from "@stesura/core/commands";
// Module scope — never inside a component.
const probes = defineToolbarProbes("my-plugin", {
insertTable: insertTable({ rows: 3, cols: 3 }),
});
function MyButton() {
const canInsertTable = useCanRunTb(probes.insertTable); // boolean
// ...
}Why probes rather than useCanRunTbCommand():
- One dry-run per command per tick, however many buttons read it. Probes are keyed by command reference, so two groups probing the same command object share one computation.
- Re-render only when the boolean flips.
useCanRunTbCommand()re-renders its component on every tick.
Two rules:
- Module scope only. Defining probes during render creates a new atom each time.
- Argument-independent commands only. The arguments are placeholders:
insertTable({ rows: 3, cols: 3 })gives the same answer for every size. When the answer depends on the argument (a command that fails for unknown ids, say), check on demand withuseGetCanRunTbCommand(), typically inside dropdown content that mounts on open:
import { useGetCanRunTbCommand } from "@stesura/editor-react";
import { applyListRef } from "@stesura/core/commands";
const ListPresets = ({ listRefs }: { listRefs: string[] }) => {
const canRun = useGetCanRunTbCommand();
return listRefs.map((listRef) => (
<ListPresetItem key={listRef} listRef={listRef} disabled={!canRun(applyListRef(listRef))} />
));
};useGetCanRunTbCommand doesn’t subscribe, so the answer won’t update while the dropdown stays open. Use useCanRunTbCommand() if it must stay live.
The built-in probes are exported from @stesura/editor-react/toolbar: baseProbes, fontProbes, paragraphProbes, insertProbes, tableProbes, tableSpacingProbes, sectionProbes, pageNumberProbes, imageResizeProbes, referenceProbes. proofingLanguageProbes is exported from the package root. Reuse them when your button runs a built-in command.
Display state: selectors
Probes answer “can this command run”. Selectors answer “what does the selection look like”, for isActive and current values. Same rules, same mechanics:
import { defineToolbarSelectors, useTbSelector } from "@stesura/editor-react";
// Module scope, never inside a component.
const selectors = defineToolbarSelectors("my-plugin", {
isStamped: (state) => !!state?.doc.firstChild?.attrs.stamped,
});
const MyButton = () => {
const isActive = useTbSelector(selectors.isStamped); // re-renders only when it flips
// ...
};A selector receives the bound editor’s debounced state (null when no editor is bound). It runs at most once per tick however many components read it, and notifies subscribers only when its value changes. Reading useBoundEditorDebouncedState() or an object-returning hook like useTextStylesTbDebounced() instead re-renders on every tick, because the state has a new identity each time.
Two extra rules:
-
Return primitives. Change detection is
Object.is. For an object, pass{ select, eq }; subscribers stay asleep whileeqholds:import { defineToolbarSelectors, shallowEqual } from "@stesura/editor-react"; import { getNumberingAttrsAtSelectionAnchor } from "@stesura/core/helpers"; const selectors = defineToolbarSelectors("my-plugin", { numbering: { select: getNumberingAttrsAtSelectionAnchor, eq: shallowEqual }, });shallowEqualcompares own keys withObject.is, which covers flat attrs objects and arrays of stable references. -
Share expensive work with
memoizeByState. It caches a function’s result per state object, so several field selectors over one computation cost one call per tick:import { defineToolbarSelectors, memoizeByState } from "@stesura/editor-react"; import { getTextStyles } from "@stesura/core/helpers"; const styles = memoizeByState(getTextStyles); const selectors = defineToolbarSelectors("my-font", { strong: (s) => !!styles(s).strong, fontSize: (s) => styles(s).fontSize, });
Keep reading useBoundEditorDebouncedState() only when a component needs the state’s identity, e.g. to reset local state whenever a transaction lands.
A button that re-renders only when needed
A toolbar button needs two subscriptions: a probe for disabled and a selector for isActive. With both, it re-renders only when one of them flips:
import {
defineToolbarProbes,
defineToolbarSelectors,
useCanRunTb,
useRunTbCommand,
useTbSelector,
} from "@stesura/editor-react";
import { EditorButton } from "@stesura/editor-react-ui";
import { memo, useCallback } from "react";
// Module scope.
const probes = defineToolbarProbes("acme", { toggleStamp });
const selectors = defineToolbarSelectors("acme", {
stamped: (state) => !!state && isStampActive(state),
});
const icon = <StampIcon />; // hoisted: a new element each render would defeat EditorButton's memo
export const StampButton = memo(() => {
const runCommand = useRunTbCommand();
const canStamp = useCanRunTb(probes.toggleStamp);
const isActive = useTbSelector(selectors.stamped);
const onClick = useCallback(() => runCommand(toggleStamp), [runCommand]);
return (
<EditorButton tooltip="Stamp" onClick={onClick} disabled={!canStamp} isActive={isActive}>
{icon}
</EditorButton>
);
});The memo bails only if all three hold:
- Both subscriptions change only on flips (probe and selector, as above).
onClickis stable:useCallbackover the stablerunCommand, or a module-scope handler.- No whole-state read: a single
useBoundEditorDebouncedState()in the component re-renders it every tick.
Picking the right tool for isActive
Your isActive is… | Use | Example |
|---|---|---|
| A mark on the selection | useIsMarkActiveTb("code") | strikethrough, code, sub/superscript |
| A node attribute | a selector over the attrs | alignment, line height, table align |
| Plugin state | a selector over the plugin key | format painter, search, track-changes visibility |
useIsMarkActiveTb already re-renders only on flips; don’t wrap it in a selector.
Built-in selector sets
Exported from @stesura/editor-react/toolbar. Reuse them rather than re-deriving a value: each registration is its own computation per tick.
| Set | Fields |
|---|---|
fontStyleSelectors | strong, em, underline, caps, effectiveFontSize and effectiveFontFamily (objects with value and mixed), styleFontFamily |
highlightSelectors | persistedColor, emptySelection |
paragraphSelectors | textAlign, lineHeight, showInvisibles, anchorPos, numberingListRef, numberingLevel, hasSpacingOverride |
tableSelectors | align, spacingBefore, spacingAfter, columnWidth, rowHeight, columnCount, sortOptions |
imageSelectors | physicalAlign, activeWrapMode, width, height |
sectionSelectors | orientation, pageSize, margins, docBgColor |
baseSelectors | formatPainterActive, searchActive, hasEditor |
stylingSelectors | selectedStyle, characterStyle, styleSheet, listConfigs, usedStyleIds |
headerFooterSelectors | hasSections |
proofingSelectors (currentLanguage, isExplicitLanguage, documentLanguage) is exported from the package root.
imageSelectors.physicalAlign and activeWrapMode cover both image node kinds (block and anchored), so prefer them to your own branching.
A complete custom group:
import { insertTable } from "@stesura/core/commands";
import { EditorButtonLarge, Toolbar } from "@stesura/editor-react-ui";
import { defineToolbarProbes, useCanRunTb, useRunTbCommand } from "@stesura/editor-react";
import { Grid3x3 } from "lucide-react";
import { useCallback } from "react";
// Module scope: the command is built once and its enabled state is shared.
const quickTable = insertTable({ rows: 3, cols: 3 });
const probes = defineToolbarProbes("quick-table", { insertTable: quickTable });
export const QuickTableGroup = () => {
const runCommand = useRunTbCommand();
const canInsertTable = useCanRunTb(probes.insertTable);
const onInsert = useCallback(() => runCommand(quickTable), [runCommand]);
return (
<Toolbar.Group label="Tables" expandLabel="Table options">
<EditorButtonLarge text="3×3 Table" onClick={onInsert} disabled={!canInsertTable}>
<Grid3x3 />
</EditorButtonLarge>
</Toolbar.Group>
);
};Toolbar.Group requires expandLabel, the accessible name of its onExpand launcher, even when there is no onExpand. Its old bottomText prop is a deprecated alias of label.
Notes:
- Never dispatch through the debounced state. Read with the hooks above, write with
useRunTbCommand(oruseGetBoundEditorViewfor imperative access). - Never call
defineToolbarProbes, or build a probed command, inside a component. The button must run the same command reference the probe holds. EditorButtonLargesizes unsized SVG icons to 24px in the full ribbon and 16px in compact density (through--tb-large-icon-size). Give the icon asize-*class to keep a fixed size.
Toolbar / editor binding
The toolbar targets the editor bound to it. Main editors and sub-editors (headers, footers, notes) bind themselves when focused. The related hooks, all exported from @stesura/editor-react:
useBoundEditorDebouncedState()/useBoundEditorState(): state of the bound editor. The second re-renders on every transaction.useGetBoundEditorView(): a stable getter for the boundEditorView.useBoundEditorViewCallback(callback): wrapscallbackinto a stable handler that runs against the bound view.useBoundEditorMode()/useBoundEditorEditable(): the bound editor’s mode, and whether it accepts edits (true in review mode).useBindEditorToToolbar(): returns a setter to bind an editor id yourself. Rarely needed.
Keyboard shortcuts
Shortcuts are defined by stesuraKeymap(schema) in @stesura/core/keymap. useStesuraPluginFactory already includes the keymap plugin; with a hand-built plugin list, add stesuraKeymapPlugin(schema). Common defaults: Mod-b bold, Mod-i italic, Mod-u underline, Mod-z undo, Mod-Shift-z / Mod-y redo, Tab indent (next cell in a table), Shift-Tab outdent, Mod-Enter page break (in a table: new paragraph after it), Alt-t 3×3 table.
Next steps
- Responsive toolbar API:
defineToolbarGroup, item overflow priorities, collapse panels, group extensions. - Customizing the Menus: context and floating menus.
- Plugins: feature plugin reference.