Tutorial: build a custom block
We'll build a callout: a tinted, rounded box holding any blocks, with a tone (info, warning, success) and a small icon, step by step into a complete plugin:
- schema: the node type and its attributes,
- renderer: how it measures into a
Flow(and splits across pages), - painter: how its icon is drawn on screen,
- commands, keys and UI items to insert and change it,
- export: how its icon is drawn in the PDF.
The finished plugin is about 120 lines. Each step works on its own: a schema plus a renderer is already a fully paginated block.
The real thing
@nextgensoftwares/folio-plugin-callout (packages/plugin-callout) is the production version of this tutorial, and the playground uses it. Read it next to each step: it adds validated attributes, titles, icon and style pickers in a context bar, Enter/Backspace behaviour, a dark colour set, a DOCX mapping, and a box that splits cleanly across pages (see Splitting a rounded box).
1. Schema
// callout/schema.ts
import type { NodeSpec } from '@nextgensoftwares/folio-model';
export const TONES = {
info: { fill: '#eff6ff', stroke: '#93c5fd', glyph: 'i' },
warning: { fill: '#fffbeb', stroke: '#fcd34d', glyph: '!' },
success: { fill: '#f0fdf4', stroke: '#86efac', glyph: '✓' },
} as const;
export type Tone = keyof typeof TONES;
export const toneOf = (v: unknown): Tone => (typeof v === 'string' && v in TONES ? (v as Tone) : 'info');
export const calloutSpec: NodeSpec = {
group: 'block',
content: 'block+',
attrs: { tone: { default: 'info', validate: (v) => typeof v === 'string' && v in TONES } },
};group: 'block'lets a callout appear wherever the schema allows blocks.content: 'block+'means it holds one or more blocks: paragraphs, lists, tables, even other callouts.tonehas a default, so it's optional, andvalidatemakesvalidate()report bad values.
ProseMirror drops attributes the schema doesn't declare, so declare everything you store.
2. Renderer: measure into a Flow
A renderer turns a node into a Flow: fragments positioned relative to the block's top, plus the places a page may end inside it.
// callout/layout.ts
import { measureChildren, shiftBreak, shiftFragment, type BlockRenderer } from '@nextgensoftwares/folio-layout';
import { TONES, toneOf } from './schema';
const PAD = 12; // inner padding
const ICON = 20; // icon box
const GAP = 8; // icon → text
export const calloutRenderer: BlockRenderer = (node, ctx) => {
const tone = toneOf(node.attrs?.tone);
const rtl = ctx.dir === 'rtl';
const inset = PAD + ICON + GAP;
// Children laid out in the remaining width. `contain` keeps the first/last
// child's margins inside the box instead of collapsing through it.
const inner = measureChildren(node.content ?? [], { ...ctx, width: Math.max(1, ctx.width - inset - PAD) }, { contain: true });
const height = inner.height + 2 * PAD;
const textX = rtl ? PAD : inset;
return {
height,
marginTop: 16,
marginBottom: 16,
// The callout may split wherever its content may, shifted by the padding.
breaks: inner.breaks.map((b) => shiftBreak(b, PAD)),
fragments: [
// Background box. Rects are clipped at page edges, so a split callout
// continues on the next page.
{ kind: 'rect', role: 'callout', x: 0, y: 0, width: ctx.width, height, fill: TONES[tone].fill, stroke: TONES[tone].stroke, strokeWidth: 1, radius: 8, path: [] },
// The icon: a plugin-defined box. Layout only positions it; painters draw it.
{ kind: 'custom', type: 'calloutIcon', attrs: { tone }, x: rtl ? ctx.width - PAD - ICON : PAD, y: PAD, width: ICON, height: ICON, path: [] },
...inner.fragments.map((f) => shiftFragment(f, textX, PAD)),
],
};
};What makes this correct:
- Paths mirror the document.
measureChildrenprefixes each child's fragment paths with the child's index, andpath: []means "the callout itself". The editor relies on this to map clicks and the caret, so text inside the callout is editable with no extra code. - Breaks are inherited. The callout splits between the lines of its paragraphs, honours widows/orphans and keep rules inside it, and the background rect is clipped at the page edge (see Splitting a rounded box for clean corners).
- RTL is respected: the icon sits on the start side.
- It's pure. Same node and context, same Flow. The layout cache relies on it.
Try it now, before any plugin plumbing:
import { layoutDocument } from '@nextgensoftwares/folio-layout';
import { standardSchema } from '@nextgensoftwares/folio-model';
const schema = standardSchema.extend({ nodes: { callout: calloutSpec } });
const layout = layoutDocument(doc, { measurer, renderers: { callout: calloutRenderer } });Without a painter for calloutIcon, @nextgensoftwares/folio-react draws a labelled placeholder box there; the rect already renders with the built-in rect painter (fill, border and radius). You can restyle it without code by targeting the folio-rect-callout CSS class, or register a rect:callout painter.
Clicking the icon selects the callout
Hit-testing treats custom fragments as atoms of the node their path points at. The icon's path is the callout's, so clicking it selects the whole callout (a NodeSelection): handy for the context menu below.
Atoms beside text steal clicks
A click whose height falls inside an atom's vertical band (but not on it) is currently resolved as the gap after that atom's node, ahead of the text line under the pointer. An icon sitting next to the first line therefore sends clicks on that line past the callout. @nextgensoftwares/folio-plugin-callout avoids it by drawing the icon from the box's top cap, which never shares a band with text.
3. Painter: draw the icon
With @nextgensoftwares/folio-react, painters are React components that receive { fragment, page, editor } and draw at the fragment's box in page px.
// callout/painter.tsx
import { fragmentBox, type PainterProps } from '@nextgensoftwares/folio-react';
import { TONES, toneOf } from './schema';
export function CalloutIconPainter({ fragment: f }: PainterProps) {
if (f.kind !== 'custom') return null;
const tone = TONES[toneOf(f.attrs.tone)];
return (
<div
aria-hidden
style={{
...fragmentBox(f), position: 'absolute', borderRadius: '50%',
background: tone.stroke, color: '#fff', display: 'grid', placeItems: 'center',
font: '700 12px/1 system-ui, sans-serif',
}}
>
{tone.glyph}
</div>
);
}Painters are looked up by custom fragment type, so the key is calloutIcon. A painter that throws is isolated and logged; it never breaks the page.
4. Commands, keys and UI
Commands are ProseMirror Commands, so they work with editor.run(), key bindings and UI items alike, and answer "can I run?" when called without dispatch.
// callout/commands.ts
import type { Command, EditorState } from '@nextgensoftwares/folio-editor';
import { TONES, type Tone } from './schema';
/** Depth of the innermost callout around the selection, or -1. */
export function calloutDepth(state: EditorState): number {
const $from = state.selection.$from;
for (let d = $from.depth; d > 0; d--) if ($from.node(d).type.name === 'callout') return d;
return -1;
}
export const insertCallout = (tone: Tone = 'info'): Command => (state, dispatch) => {
const { callout, paragraph } = state.schema.nodes;
if (!callout || !paragraph) return false;
dispatch?.(state.tr.replaceSelectionWith(callout.create({ tone }, paragraph.create())).scrollIntoView());
return true;
};
export const setCalloutTone = (tone: Tone): Command => (state, dispatch) => {
const d = calloutDepth(state);
if (d < 0 || !(tone in TONES)) return false;
const $from = state.selection.$from;
dispatch?.(state.tr.setNodeMarkup($from.before(d), undefined, { ...$from.node(d).attrs, tone }));
return true;
};UI items are framework-agnostic descriptors; @nextgensoftwares/folio-react renders them in the toolbar, the right-click menu and the / menu.
// callout/ui.ts
import type { UIItem } from '@nextgensoftwares/folio-editor';
import { calloutDepth, insertCallout, setCalloutTone } from './commands';
import { TONES, type Tone } from './schema';
const insert: UIItem = { id: 'callout.insert', label: 'Callout', icon: 'callout', group: 'insert', order: 40, run: insertCallout('info') };
const tones: UIItem = {
id: 'callout.tone',
label: 'Callout tone',
group: 'callout',
when: ({ state }) => calloutDepth(state) >= 0,
run: () => undefined,
children: (Object.keys(TONES) as Tone[]).map((tone, i) => ({
id: `callout.tone.${tone}`,
label: tone[0]!.toUpperCase() + tone.slice(1),
order: i,
active: ({ state }) => {
const d = calloutDepth(state);
return d >= 0 && state.selection.$from.node(d).attrs.tone === tone;
},
run: setCalloutTone(tone),
})),
};
export const ui = { toolbar: [insert], slash: [insert], contextMenu: [tones] };run: insertCallout('info') is a command (it declares dispatch), so it runs against the live state; a function of ctx would run as an action instead. when and active read ctx.state, the cheap display state. Because the insert item has group: 'insert', it joins the built-in Insert toolbar group instead of creating a new one.
5. Export mapping
Exporters look up plugin mappings by exporter name and key. The PDF exporter calls a PdfPainter for each custom fragment of that type, with the node's attrs and a paint context (page px, y down) that also carries the fragment:
// callout/pdf.ts
import type { PdfPainter } from '@nextgensoftwares/folio-export-pdf';
import { TONES, toneOf } from './schema';
export const calloutIconPdf: PdfPainter = async (attrs, ctx) => {
const f = ctx.fragment;
const tone = TONES[toneOf(attrs.tone)];
ctx.rect({ x: f.x, y: f.y, width: f.width, height: f.height, fill: tone.stroke, radius: f.width / 2 });
await ctx.text(tone.glyph, {
x: f.x + f.width / 2 - 3, y: f.y + f.height / 2 + 4, // baseline
font: { family: 'Geist', size: 12, weight: 700, style: 'normal' },
color: '#ffffff',
});
};A painter may also return a PrintFallback ({ title, detail, image?, link? }) to get the exporter's standard card, or null for the default drawing. The background rect needs no mapping: the PDF writer draws rects (fill, stroke, radius) itself, and the text inside the callout is ordinary line fragments. For DOCX, a NodeExporter keyed callout would return docx paragraphs (or null to export the callout's content as-is).
Exporters are in progress
@nextgensoftwares/folio-export-pdf and @nextgensoftwares/folio-export-docx are being finalized. The PdfPainter signature above is the current one; check the exporters page for the latest wiring.
6. Package it as a plugin
// callout/index.ts
import type { FolioPlugin } from '@nextgensoftwares/folio-editor';
import { insertCallout, setCalloutTone } from './commands';
import { calloutRenderer } from './layout';
import { CalloutIconPainter } from './painter';
import { calloutIconPdf } from './pdf';
import { calloutSpec } from './schema';
import { ui } from './ui';
export function calloutPlugin(): FolioPlugin {
return {
name: 'callout',
schema: { nodes: { callout: calloutSpec } },
renderers: { callout: calloutRenderer },
painters: { calloutIcon: CalloutIconPainter },
commands: { 'callout.insert': insertCallout, 'callout.setTone': setCalloutTone },
keymap: { 'Mod-Alt-c': insertCallout('info') },
ui,
exporters: { pdf: { calloutIcon: calloutIconPdf } },
};
}And use it like any other plugin:
const composed = composePlugins(standardSchema, [calloutPlugin(), mediaPlugin({ uploader })]);
// composed.schema, composed.renderers, composed.painters, composed.keymap, composed.ui…Checklist
- [x] Every stored attribute is declared in the schema.
- [x] The renderer is pure, uses
measureChildrenfor content, keeps paths mirroring the document, and exposes innerbreaksso the block can split. - [x] Nothing is mutated; nothing is computed per page.
- [x] Painters draw inside the fragment box and handle a missing/odd attr.
- [x] Commands return
falsewhen they can't run and build no transaction withoutdispatch. - [x] UI
when/activeusectx.stateand stay cheap. - [x] An export mapping exists for every
customfragment type.
Splitting a rounded box
Pagination clips a rect at the page edge, but the clipped piece keeps its radius and full border, so a callout across a page break would show rounded corners and a closing border line at the cut. @nextgensoftwares/folio-plugin-callout avoids that without any core support:
- the straight middle (fill, side borders, accent bar) is plain
rects, startingradiuspx below the top and endingradiuspx above the bottom; clipping them per page is exactly right; - the two rounded ends are
customfragments (calloutCap), which are never clipped and land on the page holding them: the true top and bottom only. Their painters (React and PDF) draw the rounded fill and the three outer border sides.
Breaks only come from the content, which starts at least padY below the top and ends padY above the bottom, so a cut never falls inside a cap.
Going further
- Formatter rules: add
rules: [{ name: 'callout-keeps-with-next', apply: (block, { next }) => … }]to keep a callout with what follows, as a hint instead of an attribute. - Slots and arrangement: contribute a sidebar tab or status-bar cell, or hide and reorder existing chrome; see Customizing the UI.
- Wrap layout:
wrapLayoutcan run a fixed point around the host's layout (the TOC plugin uses it to fill in page numbers). - Clipboard:
toProseMirrorSchemagives host nodes a genericdiv[data-type="callout"]HTML mapping, and every non-default attribute round-trips as JSON in adata-folioattribute, so callouts (with their tone) copy and paste between Folio editors out of the box.