Customizing the UI
Every piece of Folio's chrome is addressable by id: toolbar groups, menu items, sidebar tabs, status-bar cells, settings sections. Plugins (and the host app, acting as its last plugin) can add, replace, hide, move and reorder any of them, without forking @nextgensoftwares/folio-react.
PluginUI
FolioPlugin.ui is a PluginUI:
interface PluginUI {
toolbar?: UIItem[]; // buttons / menus on the toolbar
contextMenu?: UIItem[]; // right-click menu
slash?: UIItem[]; // "/" menu
slots?: UISlotItem[]; // host chrome: sidebar tabs, status-bar cells, whole toolbar groups...
hide?: readonly string[]; // ids to remove
move?: readonly UIMove[]; // ids to reposition
arrange?: UIArrange; // full control over a surface's final order
}
interface UISlotItem {
id: string;
slot: string; // which surface it renders in
label?: string;
icon?: string;
order?: number; // sort key within the slot (default 0, stable)
when?: (ctx: UIContext | null) => boolean; // hidden when false; ctx is null without an editor
component: unknown; // framework-specific (a React component for @nextgensoftwares/folio-react)
}
interface UIMove { id: string; before?: string; after?: string; to?: 'start' | 'end' }
type UIArrange = (surface: string, ids: readonly string[]) => readonly string[];UIItem (the menu entry descriptor) is described in Plugin anatomy.
Surfaces and ids
A surface is a place that renders a list of entries:
| Surface | Entries | Ids |
|---|---|---|
toolbar | groups | built-ins: history, style, font, size, marks, color, script, link, align, lists, direction, insert, clear; plugin groups by their group name |
contextMenu | items | UIItem.id (built-ins plus plugin items) |
slash | items | UIItem.id |
| any slot name | slot items | UISlotItem.id. The host decides which slots exist; <FolioApp> renders navigation (left panel), sidebar (right panel tabs), settings (sections of the right panel's Settings tab), statusbar and appbar. <FolioView> itself renders overlay (floating UI anchored to content; components get { view: ViewOverlay, editor }) |
Ids in hide, move and arrange can be bare ("bold", matching on every surface) or surface-qualified ("toolbar:history", "sidebar:outline").
Plugin toolbar items join the built-in group of the same name: an item with group: 'insert' appears inside the built-in Insert group; any other group name becomes a new group with that id.
A toolbar slot item whose id equals a built-in group replaces that group. Its component receives ToolbarSlotProps:
interface ToolbarSlotProps { state: EditorState; run: (cmd: Command) => void } // plus { editor, item }state is the cheap display state (editor.uiState()); run runs a command on the live state and hands focus back to the editor.
How arrangements apply
composePlugins collects every plugin's hide, move and arrange into one ComposedArrangement (composed.ui.arrangement). Each surface is then processed in a fixed order:
- hide: entries whose id (bare or
surface:id) is hidden are dropped; - move: moves apply in plugin order; a move whose id or anchor isn't on the surface is ignored;
- arrange: arrangers run in plugin order, each receiving the current ids and returning the new list (ids they omit disappear, unknown ids are ignored). An arranger that throws is skipped with a warning, so a buggy plugin can't blank the UI.
Entries keep their identity throughout. Slot items are first filtered by their when(ctx) (a throwing when counts as hidden).
Runtime changes
Arrangements are data, so a host can add a final one at runtime, for example from a "Customize interface" settings panel:
import { composeArrangement } from '@nextgensoftwares/folio-editor';
function uiWithHidden(hidden: readonly string[]): ComposedUI {
if (!hidden.length) return composed.ui;
return { ...composed.ui, arrangement: composeArrangement([...plugins.map((p) => p.ui), { hide: hidden }]) };
}That is exactly what the playground's "Customize interface" section does.
Rendering in React
Pass the composed UI to the toolbar and the view, so arrangements apply to the toolbar, the context menu and the slash menu:
<FolioToolbar editor={editor} ui={composed.ui} />
<FolioView editor={editor} ui={composed.ui} painters={composed.painters} input={composed.input} />Render any other slot where your layout has room for it:
import { FolioSlot, SlotTabs } from '@nextgensoftwares/folio-react';
<SlotTabs ui={composed.ui} name="sidebar" editor={editor} props={shell} label="Side panels" />
<footer className="statusbar">
<FolioSlot ui={composed.ui} name="statusbar" editor={editor} props={{ layout: editor.layout }}
wrap={(item, node) => <span className="cell" title={item.label}>{node}</span>} />
</footer>| Export | What it does |
|---|---|
<FolioSlot ui name editor props? builtins? wrap?> | renders every visible, arranged item of a slot; props go to each component (plus editor and item); builtins are host defaults that plugins can replace by id or hide; wrap wraps each item |
<SlotTabs ui name editor props? builtins? tab? onTab? label? empty?> | a tabbed panel whose tabs are slot items (controlled or uncontrolled) |
useSlot(ui, name, editor, builtins?) | the visible, arranged items of a slot, re-evaluated at low priority as the editor changes |
renderSlotItem(item, editor, props?) | renders one item's component |
slotVisible(item, ctx) | evaluates when, treating a throw as hidden |
Every slot item renders inside its own error boundary: a crashing plugin component shows a small warning marker instead of taking down the host.
Example: tidy the toolbar, reorder the sidebar, add a status-bar cell
import type { FolioPlugin } from '@nextgensoftwares/folio-editor';
import { useDeferredEditorVersion, type SlotProps } from '@nextgensoftwares/folio-react';
function WordCount({ editor }: SlotProps) {
useDeferredEditorVersion(editor); // re-render after edits, at low priority
const words = editor?.state.doc.textContent.split(/\s+/).filter(Boolean).length ?? 0;
return <span>{words.toLocaleString()} words</span>;
}
export const writerPlugin: FolioPlugin = {
name: 'writer',
ui: {
hide: ['toolbar:script'], // no sub/superscript group
move: [{ id: 'sidebar:outline', to: 'start' }], // outline becomes the first tab
slots: [{ id: 'wordCount', slot: 'statusbar', label: 'Word count', order: 10, component: WordCount }],
},
};Add it after the plugins it rearranges: composePlugins(schema, [...features, writerPlugin]).
Try it
The editor below runs a plugin like this one inside <FolioApp>: it hides the script group, adds a reading-time cell to the statusbar slot, and contributes a navigation slot item, which <FolioApp> shows as its left panel (the panel toggle sits at the start of the app bar).
Counting words on a book
textContent walks the whole document. For very large documents, cache the count per top-level block node in a WeakMap so an edit only recounts the blocks it changed. See Performance.
Routing controls to any surface
toolbar, contextMenu and slash put an item on one fixed surface. For controls that a host may want elsewhere (image size and placement in a floating bar above the image, in the toolbar, in a sidebar, in your own navbar...), a plugin declares them once, in a surface-neutral pool, and routes decide where they show:
interface PluginUI {
items?: UIItem[]; // the pool: declared once, no surface
route?: Record<string, string | readonly string[]>; // group/id globs → surface names
// ...toolbar / contextMenu / slash / slots / hide / move / arrange as above
}
interface UIItem {
// ...
group?: string; // e.g. "media.placement", "format.marks"
defaultSurfaces?: string[]; // where a pool item goes when no route matches
render?: unknown; // a rich control (React: a component taking UIRenderProps)
}A plugin sets the defaults (defaultSurfaces on its items, or a route of its own); the app, as the last plugin, overrides them with one line:
const app: FolioPlugin = {
name: 'app',
ui: { route: { 'media.*': ['toolbar'] } }, // every media control on the toolbar instead of the bubble
};
composePlugins(schema, [mediaPlugin(opts), app]);Matching. Route keys match an item's id or its group; * matches any run of characters and ? one character. For each item the winner is:
- a key equal to the item's id;
- else a key equal to its group;
- else the most specific glob (most literal characters) matching the id or the group; on a tie, the one declared later;
- else the item's
defaultSurfaces(pool items) or its legacy list (toolbar/contextMenu/slashitems stay where they were declared).
Later plugins replace a key declared by earlier ones, so the app always wins. A value may name several surfaces ('media.size': ['toolbar', 'bubble']), and an empty list ([]) hides the matches everywhere. Routes also apply to legacy items: 'media.*' moves the media plugin's context-menu entries too (add 'media.insert': ['toolbar'] to keep its Insert button where it is). hide, move and arrange keep working per surface after routing ('bubble:media.style'). The media plugin's size / layout options / distance / corners controls share the group media.placement (ids media.size, media.layout, media.distance, media.style), so { 'media.placement': ['toolbar'] } moves exactly those.
Re-routing live. composeUI([...plugins, { ui: { route, hide } }]) composes only the UI (no schema, no editor rebuild), so a settings toggle or a per-user layout can change where controls appear at runtime: pass its result as ui to FolioView / FolioToolbar.
Surfaces. A surface is just a name. composed.ui.surface(name) returns its items, routed and arranged (the same array until the plugins change), and composed.ui.surfaces() lists the names that have items. @nextgensoftwares/folio-react renders these:
| Surface | Rendered by |
|---|---|
toolbar | <FolioToolbar> (items join the group with their group name) |
contextBar | the row under the toolbar (<ContextBar>); routed items replace the built-in image/table row while any of them is visible |
bubble | <FolioBubbleBar>, a floating toolbar above the selected node or text selection; <FolioView> mounts it automatically when anything is routed to bubble |
contextMenu | the right-click menu; an item with render and no children opens its control in a panel at the click |
slash | the / menu |
| anything else | your component: render composed.ui.surface('name') |
<FolioBubbleBar> flips below its target when there's no room above, follows scrolling and zoom, stays inside the viewport, and hides while you type or drag. Items' when decides what it shows, so media controls appear only for a selected image, video or file. It is a real toolbar: Alt+F10 focuses it, arrow keys / Home / End move between controls, Escape returns to the text. Mount it yourself for another surface: <FolioBubbleBar editor={editor} ui={composed.ui} surface="table-bubble" />.
Rich controls. render is a React component receiving UIRenderProps:
interface UIRenderProps {
ctx: UIContext; // editor + cheap display state
item: UIItem;
surface: string; // where it is drawn, to adapt (compact in the bubble, labelled in a sidebar)
close: () => void; // close the containing panel/menu and refocus the editor
}Hosts without a renderer for it fall back to children (a dropdown/submenu) or run. Render any item with <SurfaceItem item ctx surface close? />: the render control when there is one (inside an error boundary), else a button.
Example: all image controls in a custom navbar
import { composePlugins, type FolioEditor, type FolioPlugin } from '@nextgensoftwares/folio-editor';
import { groupItems, SurfaceItem, uiContext, useEditorVersion, visibleItems } from '@nextgensoftwares/folio-react';
const app: FolioPlugin = {
name: 'app',
ui: { route: { 'media.*': ['my-navbar'], 'media.insert': ['toolbar'] } },
};
const composed = composePlugins(standardSchema, [mediaPlugin(opts), app]);
function MyNavbar({ editor }: { editor: FolioEditor }) {
useEditorVersion(editor); // re-evaluate `when` as the selection changes
const ctx = uiContext(editor);
const items = visibleItems(composed.ui.surface('my-navbar'), ctx);
if (!items.length) return null;
return (
<nav role="toolbar" aria-label="Image" className="my-navbar">
{groupItems(items).map((group) => (
<div key={group[0]!.group} role="group">
{group.map((item) => <SurfaceItem key={item.id} item={item} ctx={ctx} surface="my-navbar" />)}
</div>
))}
</nav>
);
}Without React, use the same data: routeItem(item, composeRoutes(routes), fallback) answers where one item goes, and composed.ui.surface(name) gives a surface's list for any framework.
Building a custom host
The core helpers work without React:
Export (@nextgensoftwares/folio-editor) | What it does |
|---|---|
composeArrangement(parts) | merge UIArrangements (hide, move, arrange) in order into a ComposedArrangement |
arrangeSurface(arrangement, surface, entries) | apply hide → move → arrange to any { id } list |
surfaceItems(arrangement, surface, ...lists) | merge UIItem lists (later replaces same id), then arrange |
composeSlots(lists) | group UISlotItems by slot, sorted by order (later plugins replace by id) |
composeRoutes(routes) / routeItem(item, routes, fallback?) | merge route maps in plugin order; surfaces for one item (id > group > most specific glob > defaults) |
routeItems(sources, routes) / surfaceAccessors(routed, arrangement) | distribute legacy lists + the pool over surfaces; surface(name) / surfaces() as on ComposedUI |
EMPTY_ARRANGEMENT | an arrangement that changes nothing |
Readers vs editors
A view shown read-only (<FolioView readOnly>) is a reader's view, and readers never get writer tools. This is enforced centrally rather than by each plugin:
FolioViewmarks its editor read-only (setReadOnly(editor, true); plugins testisReadOnly(editor)), and every UI context carriesctx.readOnly.- On every surface (toolbar, context menu, bubble bar, context bar, slash menu, custom navbars rendering
ui.surface(...)), items are hidden from readers unless they declarereader:reader: true, orreader: (ctx) => booleanto decide per context. Built-ins that opt in are Copy and Select all; the comments plugin's "Comment" opts in and then applies its own reader policy. - The built-in formatting groups of
<FolioToolbar>don't render for readers. - Painter-level tools check the flag too: media resize handles, double-click header/footer editing and the equation editor popover don't appear for readers; the code block keeps Copy and its tabs but hides the language chip.
So a plugin that forgets about readers fails closed: its items simply don't show in a reader's view.