Skip to Content
Customizing the EditorCustomizing the Toolbar

Customizing the Toolbar

The toolbar is a tabbed, Word-style ribbon rendered above the editor. You can customise it at three levels:

  1. Contribute through uiExtensions. Hide default tabs, or add controls and groups to them. Covers most cases.
  2. Pass your own <ToolBar tabs={...} /> to control which tabs render.
  3. Build tabs, groups and buttons from the same components and hooks the default toolbar uses.

Where things live

Import fromWhat you get
@stesura/editor-reactToolbar hooks only: useRunTbCommand, useBoundEditorDebouncedState, useToolbarTabContext, probes and selectors, …
@stesura/editor-react/toolbarToolBar, 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/typesToolbar types: ToolbarTab, ToolbarTabContext, ToolbarTabConfig, ToolbarTabDef, ToolbarGroupExtension, …
@stesura/editor-react-uiVisual 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 keyComponentShownBuilt-in contents
homeHomeTabalwaysEditing (undo/redo, format painter, clear formatting), font, paragraph, search, styles
insertInsertTabalwaysPage break, table, symbols/emoji, equation, image, file, code block, link
viewViewTabalwaysNavigation pane, ruler, zoom
pagePageTabalwaysSection settings (size, margins, orientation, page colour)
headerFooterHeaderFooterTabwhile a header/footer editor is openPosition, page numbering, close
tableTableTabwhile the selection is in a tableStyling, spacing, rows/columns, merge, alignment, sizes, sorting
referencesReferencesTabalwaysTable of contents
reviewReviewTabalwaysAccessibility check
imageImageTabwhile an image is selectedAlign, wrap, size
exportExportTabalwaysJSON 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:

FieldTypeWhat it does
toolbarDefaultTabsPartial<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:

PropTypeNotes
tabsToolbarTab[]Replaces the default tabs. When set, the three props below are ignored.
toolbarDefaultTabsPartial<Record<ToolbarDefaultTabs, false>>Merged after the extensions’ maps.
toolbarDefaultExtensionsToolbarDefaultExtension[]Appended after the extensions’ entries.
uiExtensionsStesuraUIExtension[]Read for their toolbar fields.
onTabClick(tabValue: string) => voidCalled when the user selects a tab.
classNamestringApplied 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 returns false. 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:

  1. Layout. Toolbar.Group (controls above a label row) separated by Toolbar.Divider. The tab panel already wraps content in Toolbar.Content, a fixed-height, horizontally scrollable row. For responsive groups, use defineToolbarGroup instead (see Responsive toolbar API).
  2. Buttons. EditorButton (small icon button), EditorButtonLarge (icon above a label, optional dropdown chevron), EditorButtonLargeDropdown, EditorSplitButton.
  3. State and commands. The Tb hooks, 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.
HookPurpose
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:

  1. Module scope only. Defining probes during render creates a new atom each time.
  2. 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 with useGetCanRunTbCommand(), 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 while eq holds:

    import { defineToolbarSelectors, shallowEqual } from "@stesura/editor-react"; import { getNumberingAttrsAtSelectionAnchor } from "@stesura/core/helpers"; const selectors = defineToolbarSelectors("my-plugin", { numbering: { select: getNumberingAttrsAtSelectionAnchor, eq: shallowEqual }, });

    shallowEqual compares own keys with Object.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:

  1. Both subscriptions change only on flips (probe and selector, as above).
  2. onClick is stable: useCallback over the stable runCommand, or a module-scope handler.
  3. No whole-state read: a single useBoundEditorDebouncedState() in the component re-renders it every tick.

Picking the right tool for isActive

Your isActive is…UseExample
A mark on the selectionuseIsMarkActiveTb("code")strikethrough, code, sub/superscript
A node attributea selector over the attrsalignment, line height, table align
Plugin statea selector over the plugin keyformat 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.

SetFields
fontStyleSelectorsstrong, em, underline, caps, effectiveFontSize and effectiveFontFamily (objects with value and mixed), styleFontFamily
highlightSelectorspersistedColor, emptySelection
paragraphSelectorstextAlign, lineHeight, showInvisibles, anchorPos, numberingListRef, numberingLevel, hasSpacingOverride
tableSelectorsalign, spacingBefore, spacingAfter, columnWidth, rowHeight, columnCount, sortOptions
imageSelectorsphysicalAlign, activeWrapMode, width, height
sectionSelectorsorientation, pageSize, margins, docBgColor
baseSelectorsformatPainterActive, searchActive, hasEditor
stylingSelectorsselectedStyle, characterStyle, styleSheet, listConfigs, usedStyleIds
headerFooterSelectorshasSections

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 (or useGetBoundEditorView for imperative access).
  • Never call defineToolbarProbes, or build a probed command, inside a component. The button must run the same command reference the probe holds.
  • EditorButtonLarge sizes unsized SVG icons to 24px in the full ribbon and 16px in compact density (through --tb-large-icon-size). Give the icon a size-* 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 bound EditorView.
  • useBoundEditorViewCallback(callback): wraps callback into 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

Last updated on