Plugin anatomy
The core stays small; optional capabilities ship as separate packages a host installs on demand, so it only pays (in bundle size) for what it uses. A plugin is one plain object bundling every layer a feature touches: schema, layout, drawing, commands, keys, UI, formatting rules and export.
FolioPlugin
Defined in @nextgensoftwares/folio-editor. Every field except name is optional.
interface FolioPlugin {
name: string;
/** Nodes, marks and global attrs added to the schema. */
schema?: Partial<SchemaSpec>;
/** Layout for its block types (measure → Flow). */
renderers?: Record<string, BlockRenderer>;
/** Drawing for its fragments, keyed by `custom` fragment type or node type. Framework-specific. */
painters?: Record<string, unknown>;
/** Named commands, e.g. `media.insertImage`. */
commands?: Record<string, (...args: never[]) => Command>;
/** Extra key bindings (take precedence over the defaults). */
keymap?: Record<string, Command>;
/** ProseMirror plugins (state fields, appendTransaction…). */
pmPlugins?: PMPlugin[];
/** Menu items, slot components (sidebar tabs, status-bar cells…) and hide/move/arrange rules. */
ui?: PluginUI;
/** Formatter rules (layout hints). */
rules?: FormatRuleLike[];
/** Export mappings, keyed by exporter name ("docx", "pdf") then node type. Opaque to the core. */
exporters?: Record<string, Record<string, unknown>>;
/** Drawn on every page outside the flow (watermarks, stamps); see PageLayer. */
pageLayers?: PageLayer[];
/** Wraps the host's layout function (e.g. a TOC page-number fixed point). */
wrapLayout?: (next: LayoutFn) => LayoutFn;
/** Layout output changed without an edit (a plugin setting): hosts call editor.refreshLayout(). */
subscribeLayout?(fn: () => void): () => void;
/** Intrinsic media sizes; first plugin with an answer wins. */
mediaSize?: MediaSizer;
/** Clipboard/drag input the plugin handles; return true when handled. */
input?: {
paste?(editor: FolioEditor, data: DataTransfer): boolean;
drop?(editor: FolioEditor, data: DataTransfer, at: { page: number; x: number; y: number }): boolean;
};
}
type LayoutFn = (doc: FolioDocument) => DocumentLayout;| Field | Layer | Consumed by |
|---|---|---|
schema | model | Schema.extend(), then toProseMirrorSchema() |
renderers | layout | layoutDocument({ renderers }) |
painters | view / print | <FolioView painters> (React components) |
commands, keymap, pmPlugins | editing | FolioEditor({ keymap, plugins }), editor.run() |
ui | chrome | <FolioToolbar ui>, <FolioView ui> (context and slash menus), <FolioSlot> / <SlotTabs> for slots; see Customizing the UI |
rules | formatting | format(doc, rules) → layoutDocument({ hints }) |
exporters | export | each exporter (e.g. pdfExportPlugin({ exporters })) |
pageLayers | view / export | <FolioView pageLayers>, exportPdf({ pageLayers }), exportDocx({ pageLayers }); see the watermark plugin |
wrapLayout | layout | wraps the host's layout function |
subscribeLayout | host | signals that layout must re-run (e.g. the code plugin's preferred language); pass subscribeLayout: composed.subscribeLayout to new FolioEditor(...) and the editor follows it (weakly held; destroy() stops it) |
mediaSize | layout | layoutDocument({ mediaSize }) |
input | input | <FolioView input> (paste/drop) |
Composing plugins
import { composePlugins } from '@nextgensoftwares/folio-editor';
const composed = composePlugins(standardSchema, [mediaPlugin({ uploader }), tocPlugin(), myPlugin()]);composePlugins(base, plugins) merges in order. Later plugins override earlier ones by key or id. It returns:
| Result | What |
|---|---|
schema | base schema extended by each plugin's schema |
renderers, painters, commands, keymap, exporters | merged maps (exporters merged per exporter name) |
pmPlugins, rules, pageLayers | concatenated (in plugin order) |
ui.toolbar, ui.contextMenu, ui.slash | items merged by id, sorted by group then order |
ui.slots | slot items grouped by slot name, sorted by order (later plugins replace by id) |
ui.arrangement | every plugin's hide / move / arrange, applied in plugin order |
wrapLayout(fn) | all wrappers composed; the first plugin's wrapper is outermost |
subscribeLayout(fn) | subscribes fn to every plugin's layout signal; returns an unsubscribe |
mediaSize | chain of plugin sizers (first non-undefined answer) |
input.paste / input.drop | calls each plugin's handler until one returns true |
Wire the pieces into the editor, the layout and the view:
const layout = composed.wrapLayout((d) =>
layoutDocument(d, { measurer, renderers: composed.renderers, mediaSize: composed.mediaSize, hints: format(d, composed.rules), cache }));
const editor = new FolioEditor({
schema: composed.schema, doc, layout, measurer,
plugins: composed.pmPlugins, keymap: composed.keymap,
});<FolioView editor={editor} painters={composed.painters} ui={composed.ui} input={composed.input} />
<FolioToolbar editor={editor} ui={composed.ui} />Keep composition stable
Compose once at module level (or in a useMemo). The renderer map and painter map are compared by identity (renderer maps shallowly) for layout and page reuse; a new map per render defeats incremental layout.
UI items
A UIItem is a framework-agnostic menu entry. @nextgensoftwares/folio-react renders them in the toolbar, the context menu and the slash menu; hosts can add, reorder, replace (by id) or hide them.
interface UIItem {
id: string;
label: string;
icon?: string; // a name from the host's icon set, or an SVG path string
group?: string; // "format", "insert", "table", "export"…
order?: number;
shortcut?: string;
when?: (ctx: UIContext) => boolean; // hidden when false
enabled?: (ctx: UIContext) => boolean;
active?: (ctx: UIContext) => boolean;
run: Command | ((ctx: UIContext) => void | Promise<void>);
children?: UIItem[]; // submenu / dropdown
}
interface UIContext {
editor: FolioEditor;
state: EditorState; // display state: cheap for huge selections
at?: { page: number; x: number; y: number }; // where a context menu opened
}run can be a ProseMirror Command (run against the live state) or a function of the context (open a dialog, export, upload). They are told apart by arity: commands declare dispatch, so (state, dispatch) => … is a command; mark a command written with fewer parameters with asCommand(cmd).
Use ctx.state (it's editor.uiState()) in when / enabled / active, and keep them cheap: they run on every deferred render.
Customizing the UI
Beyond adding items, ui can contribute slot components (sidebar tabs, status-bar cells, settings sections, whole toolbar groups) and rearrange anything by id: hide: ['toolbar:script'], move: [{ id: 'sidebar:outline', to: 'start' }], or an arrange(surface, ids) function. Rules apply hide → move → arrange, in plugin order. See Customizing the UI.
Painters
Painters are framework-specific; with @nextgensoftwares/folio-react they are React components receiving { fragment, page, editor } (editor is null when read-only). The view looks them up by:
customfragments:painters[fragment.type](else a "missing painter" box),mediafragments:painters['media:' + mediaType], thenpainters.media,rectfragments:painters['rect:' + role], thenpainters.rect,- everything else:
painters[fragment.kind], then the built-in painter.
So a plugin can override a built-in (media:image) or add one for its own fragment types. A painter that throws is isolated by an error boundary and logged; it never takes the page down. Paint at the fragment's x/y/width/height in page px with position: absolute (fragmentBox(f) returns that box).
Renderers
A BlockRenderer is (node, ctx) => Flow. It receives the node and a MeasureCtx (env with theme, measurer, math, media sizer, renderers and pageContentHeight; the content width; dir; list depth). Measure children with measureChildren / measureBlock, position fragments relative to the block's top, and describe where the block may split with breaks. See the tutorial and Layout pipeline.
Rules for plugin authors
- Declare every attribute you store in
schema(ProseMirror drops the rest), and ship it as a Node-safe/schemaentry (export const schema): servers and collab hosts compose the document schema from those without loading React. See server-node. - Never mutate nodes; memoize derived nodes and data by identity.
- No per-page work for invisible pages: use
page.blocksand binary search; readpage.fragmentsonly for pages you draw. - Module-level closures for anything long-lived, and non-enumerable lazy getters. See Performance.
- Transient state stays out of the document (upload progress, hover).
- Core packages (
model,layout,formatter) are DOM-free. A plugin that ships React painters keeps them in its own modules and declaresreactas an optional peer dependency.