@nextgensoftwares/folio-editor
Headless paginated editor: ProseMirror state, commands and history over Folio layout, with hit-testing, caret, selection, plugins and collaboration. Guide: The editor.
class FolioEditor
interface EditorOptions {
schema: FolioSchema;
doc: FolioDocument;
layout: (doc: FolioDocument) => DocumentLayout; // the host owns theme, fonts, cache, renderers
measurer: TextMeasurer; // same measurer the layout uses
plugins?: Plugin[]; // extra ProseMirror plugins
keymap?: Record<string, Command>; // tried before the defaults
subscribeLayout?: (fn: () => void) => () => void; // composePlugins(...).subscribeLayout: relayout on plugin signals
history?: boolean; // false: no undo history (nested editors)
tabKey?: 'insert' | 'indent'; // Tab outside code/tables: Word's tab character (default) or list nesting only
}The constructor builds the ProseMirror schema, checks the document (doc.check() throws on invalid content), installs history() and tableEditing() (used headless to repair tables after edits) and lays out once.
| Member | Description |
|---|---|
pmSchema: PMSchema | the ProseMirror schema |
state: EditorState | current state |
folioDoc: FolioDocument | current document as Folio JSON (identity-preserving) |
layout: DocumentLayout | current layout |
index: PositionIndex | position index for the current layout |
version: number | bumped on every change |
lastTransaction: Transaction | null | the last applied transaction |
subscribe(fn): () => void | change listener |
dispatch(tr) | apply a transaction; relayout if the doc changed |
run(cmd: Command): boolean | run a command against the live state |
setLayout(fn) | swap the layout function and relayout (no-op if identical) |
destroy() | stop following plugins' layout signals |
refreshLayout() | re-run the current layout function without an edit (incremental; e.g. from composePlugins(...).subscribeLayout) |
caret(): Caret | null | caret box (or a gap-cursor bar with width) for an empty text selection or gap cursor |
selectionRects(window?): Rect[] | highlights; node and cell selections give outlines |
presenceGeometry(people, window?) | { who, caret, rects }[] for collaborators' { anchor, head } |
uiState(maxBlocks = 200): EditorState | display state with huge selections truncated (never run commands on it) |
headCaret(): Caret | null | caret at the selection head (IME placement) |
handleKeyDown(event: KeyboardEvent): boolean | run key bindings |
insertText(text) | type text (in a gap: starts a paragraph) |
pointerDown(page, x, y, { shift?, detail? }) | click; detail 2 selects a word, 3 a paragraph. A click on an atom (inline equations included, unless Shift is held) selects the node, and that transaction carries meta folio:pointer = detail, so plugins can react to a double-click on a node |
pointerMove(page, x, y), pointerUp() | drag selection (across cells: CellSelection) |
schema, measurer | the Folio schema and measurer it was made with (nested editors reuse them) |
delegate: EditorDelegate | null, setDelegate(d) | a nested editor taking input while active: keys, text, pointer events, run and caret/selection/uiState queries go to it, and its changes re-notify this editor's listeners (header/footer editing) |
activeEditor: FolioEditor | where input goes: the innermost delegate's nested editor (a header being edited, a text box), else this editor. Hosts build UI contexts and route pastes/drops through it, so insert actions (images, shapes, files) land in the header or text box being edited |
onPointerDown(hook: PointerHook): () => void | sees pointer-downs before they move the caret (no delegate active); return true to consume one |
interface EditorDelegate {
readonly editor?: FolioEditor; // the nested editor (activeEditor resolves through it)
handleKeyDown(e): boolean; insertText(text): void;
pointerDown(page, x, y, opts): boolean; // false: the main editor handles it (e.g. after leaving)
pointerMove(page, x, y): void; pointerUp(): void;
caret(): Caret | null; headCaret(): Caret | null; selectionRects(window?): Rect[];
run(cmd: Command): boolean; uiState(maxBlocks?): EditorState; subscribe(fn): () => void;
}
type PointerHook = (page, x, y, opts: { shift?; detail? }) => boolean;Schema bridge
| Export | Signature | Description |
|---|---|---|
toProseMirrorSchema | (folio: FolioSchema) => PMSchema | memoized per schema; installs fast content matching |
class DocBridge | toFolio(node), toFolioDoc(doc) | PM → Folio JSON memoized per PM node; reuses the doc's unchanged prefix/suffix |
contentStart | (doc: PMNode, path) => number | PM position where the content of the node at path starts (-1 if stale) |
posBefore | (doc: PMNode, path) => number | PM position just before the node at path |
Block ids
EditorOptions.blockIds (default: deduplicate only; true also gives new blocks an id; false off) installs blockIdPlugin(options?: BlockIdOptions) (assignMissing?, generate?; state under blockIdsKey): a split, paste or duplicate that copies an id gives the copy a fresh one, the original keeps its own and a move keeps it; upkeep is O(edit) after a one-time index. setBlockTypeKeepingId(type, attrs?) is setBlockType that keeps the id (the built-in paragraph/heading/code commands and Mod-Alt-0..3 use it). posOfBlockId(doc, id): PM position before that block, or -1.
class PositionIndex
interface Caret { page: number; x: number; y: number; height: number; dir: 'ltr' | 'rtl'; width?: number; matrix?: Matrix6 } // matrix: drawn transformed (text in a rotated / shrink-to-fit box): page point = matrix × local point; Rect has it too
interface Rect { page: number; x: number; y: number; width: number; height: number }
interface LineRef { page: number; line: LineFragment; start: number } // start = PM position of the textblock content| Member | Description |
|---|---|
new PositionIndex(layout, doc: PMNode, measurer) | O(blocks) to build; everything else lazy per page |
contentStart(path) | as above, using cached block starts |
pageLines(page): LineRef[] | text lines of a page in reading order |
lineAt(pos) | line holding a position |
caretAt(pos): Caret | null | caret box |
posAt(page, x, y) | { pos, node?: true, gap?: 1 | -1 } | null |
verticalNeighbor(from: LineRef, dir, goalX) | next/previous visual line near goalX |
rangeRects(from, to, window?) | highlight rects, optionally only for pages in window |
inlineAtomAt(page, x, y) | position of the inline atom (equation) under a point, or null; a 3px edge band stays a text position |
cellRects(pos), nodeRects(pos) | boxes of a table cell / atom node at pos (an inline atom: its span on the line) |
Line geometry helpers (used by the index; exported for custom views):
| Export | Signature | Description |
|---|---|---|
caretX | (line, offset, measurer) => number | page x of the caret at a textblock offset |
ownsSelection | (state) => boolean | a node selection whose node draws its own selection UI (NodeSpec.ownSelection, e.g. drawings with handles): views skip the highlight but keep the geometry (bubble bars) |
offsetAtX | (line, x, measurer) => number | nearest textblock offset to a page x |
spansX | (line, a, b, measurer) => [number, number][] | horizontal spans covering offsets [a, b) (several when bidi splits them) |
Commands
| Export | Signature | Description |
|---|---|---|
createCommands | (schema: PMSchema) => Commands | toggleMark(name, attrs?), paragraph(), heading(level), codeBlock(), blockquote(), lift(), bulletList(), orderedList(), align(textAlign), dir(dir), insert(type, attrs?), undo(), redo() |
type Commands | ReturnType<typeof createCommands> | |
setBlockAttrs | (attrs, types?) => Command | set attrs on every textblock (or types) in the selection; cheap without dispatch |
markActive | (state, name) => boolean | mark active at the selection or in stored marks |
blockType | (state) => string | "heading:2", "paragraph"… |
historyDepth | (state) => { undo; redo } | undo/redo depth |
closeHistory | (tr) => Transaction | end the current undo group (from prosemirror-history): hosts with undoable state outside the editor dispatch it so later typing is its own step |
textStyleAt | (state) => Attrs | current textStyle attrs (color, fontSize, fontFamily) |
linkAt | (state) => string | null | href at the selection |
updateTextStyle | (patch) => Command | merge into textStyle without clobbering other attrs; null clears |
setLink | (href | null) => Command | set or remove a link on the selected text |
setSelectedNodeAttrs | (patch, typeName?) => Command | update the selected node's attrs |
insertTab | Command | insert a \t (replacing a selection inside one textblock; not in code) |
sinkAtItemStart | (listItem: NodeType) => Command | Word's Tab in lists: indent the item when the caret is at its start (or the selection spans items) |
TabKeyMode | 'insert' | 'indent' | EditorOptions.tabKey: Tab inserts a tab (indenting only at a list item's start; Tab in tables moves cells) or only nests list items |
createTableCommands | () => {…} | inTable, addRowBefore/After, addColumnBefore/After, deleteRow, deleteColumn, deleteTable, mergeCells, splitCell, toggleHeaderRow, toggleHeaderColumn, cellBackground(color), plus tableLookCommands() |
tableLookCommands | () => {…} | tableBorders(preset), tableWidth(px | "NN%" | "auto" | null), tableAlign(align), tableStyle(name | null) (clears explicit borders), tablePadding(p), cellVerticalAlign(v), cellPadding(p) |
tableCellCommands | () => {…} | cellBorders(sides, spec | null), columnWidth(px | null), distributeColumns(), tableLook(patch), repeatHeader(on), tableIndent(px) (also in createTableCommands); helpers columnWidthsAt(state), columnAt(state) |
setCellBorders | (sides: CellBorderSides, spec: CellBorderSpec | null) => Command | the command behind cellBorders: set (or with null clear) the selected cells' own borders on sides; cleared sides fall back to the table's / its style's. No-op (false) outside a table |
setColumnWidth | (width: number | null) => Command | the command behind columnWidth: set the selected columns' px width (rounded, minimum 16), or null to share the table's width again |
setTableLook | (patch: TableLookPatch) => Command | the command behind tableLook: merge look flags into the table's look |
CellBorderSpec | { style?: 'single' | 'double' | 'dashed' | 'dotted' | 'none'; width?: number; color?: string } | a border as table/cell attrs store it (layout's BorderSpec) |
CellBorderSides | 'all' | 'outside' | 'inside' | 'insideH' | 'insideV' | 'top' | 'bottom' | 'start' | 'end' | which sides of the selection a border command sets: one logical side, the selection's frame (outside), the lines between selected cells (inside, insideH, insideV) or every side of every cell |
TableLookPatch | { headerRow?; firstColumn?; lastRow?; lastColumn?; bandedRows?; bandedColumns?: boolean } | the table look flags (Word's tblLook) |
setTableAttrs / tableAt | (patch) => Command / (state) => {pos, attrs} | null | merge attrs into the table around the selection / find it |
bordersFor, BorderPreset | (preset: 'all' | 'none' | 'outside' | 'inside', line?) => borders | the table borders attr of a menu preset |
Clipboard
| Export | Signature | Description |
|---|---|---|
serializeSelection | (state) => { text; html } | selection as text/plain and text/html (needs a DOM) |
pasteTransaction | (state, schema, { html?, text? }) => Transaction | null | parse pasted HTML through the schema; plain text → paragraphs, verbatim in code |
selectionText | (state) => string | the selection as plain text (blocks separated by blank lines) |
serializeHtml | function* (fragment, schema, batch?) → string | string-built clipboard HTML for a fragment, yielding progress every batch nodes (drive it with runSliced) |
clipboardSlice | function* (schema, { html?, text? }) → Slice | null | parse pasted HTML/text into a Slice in slices (block-boundary chunks; plain text skips HTML) |
pasteClipboard | (editor, data: PasteData) => boolean | the editor's paste: small payloads synchronously, large ones as a background task (progress, cancel, one undo step) |
PasteData | { html?; text? } | what a paste carries |
handleCopyEvent | (editor, event, { readOnly? }) => void | copy/cut from a copy/cut event: synchronous for small selections, async via navigator.clipboard.write for large ones |
applyProgressively | (editor, tr, { signal?, batchMs?, firstBatch?, onProgress? }) => Promise<void> | apply a big transaction in block batches (incremental layout per batch, one undo step; aborting reverts) |
runSliced | (gen, { signal?, sliceMs?, onProgress? }) => Promise<T> | drive a progress-yielding generator, giving the browser the main thread every sliceMs |
yieldToBrowser | () => Promise<void> | yield to the event loop (scheduler.yield / MessageChannel) |
Cancelled | class extends Error | rejection of a cancelled clipboard task |
writeAsync | (payload: Promise<ClipboardPayload>) => Promise<void> | start an async clipboard write during the gesture, with data resolved later |
writeFromGesture | (payload) => Promise<boolean> | write from a fresh click (the "Copy" retry button) |
ClipboardPayload | { text; html?; textBlob?; htmlBlob? } | clipboard contents |
Clipboard tasks
Large copies, cuts and pastes run as tasks so the page never freezes. clipboardTasks(editor) is the headless API hosts render their own progress UI from (@nextgensoftwares/folio-react ships ClipboardProgress).
| Export | Description |
|---|---|
clipboardTasks(editor) → ClipboardTasks | { current, options, configure(opts), subscribe(fn), busy }; busy while a task runs or waits for the user |
ClipboardTask | { id, kind, phase, progress (0..1), pages, blocks, message?, actions, cancelable, cancel() } |
ClipboardTaskKind | 'copy' | 'cut' | 'paste' |
ClipboardTaskPhase | 'serializing' | 'writing' | 'parsing' | 'applying' | 'needs-action' | 'done' | 'failed' | 'cancelled' |
ClipboardTaskAction | { id, label, primary?, run() }: buttons a task offers (e.g. "Copy", "Plain text only"); run comes from a click, so it carries user activation |
ClipboardOptions, DEFAULT_CLIPBOARD_OPTIONS | asyncCopyBlocks (2,000), asyncPasteChars (400k), asyncPasteBlocks (1,500), confirmPasteChars (64M), maxPasteChars (256M), sliceMs (14), batchMs (120), ui (true) |
Read-only views
| Export | Description |
|---|---|
setReadOnly(editor, readOnly) | mark an editor as shown read-only (FolioView does this from its readOnly prop) |
isReadOnly(editor) | whether it is; plugins check it before offering editing. UI items are hidden for readers unless they set reader (see Customizing the UI) |
ReaderInteraction | 'none' | 'select': what a reader can do (setReadOnly's third argument, FolioView's readerInteraction). none (default): no caret, selection or context menu; links, scrolling and page keys still work. select: text can be selected and copied (no caret drawn) |
readerInteraction(editor) | the editor's ReaderInteraction while read-only, null while editable (or for null/undefined) |
isFollowableHref(href) | whether a reader may follow a link by clicking: only http(s):, mailto:, tel:, # and / targets (never javascript: or data:) |
linkAtPoint(editor, page, x, y) | the link href under a page point, or null. Probes 2px either side, so clicking past a line's end, on an object or in a gap never hits the link before it |
Re-exports from ProseMirror
wrapInList(listType, attrs?) (from prosemirror-schema-list) is re-exported for list commands built outside the editor package (e.g. the list-style picker in @nextgensoftwares/folio-react).
Command, EditorState, Transaction (types), Selection, TextSelection, NodeSelection (prosemirror-state) and CellSelection (prosemirror-tables), so hosts and plugins share one copy.
Plugins
interface FolioPlugin { name; schema?; renderers?; painters?; commands?; keymap?; pmPlugins?; ui?: PluginUI; rules?; exporters?; wrapLayout?; subscribeLayout?; mediaSize?; input? }
interface UIItem { id; label; icon?; group?; order?; shortcut?; when?; enabled?; active?; run; children? }
interface UIContext { editor: FolioEditor; main?: FolioEditor; state: EditorState; at?: { page; x; y }; readOnly?: boolean } // editor = the active (possibly nested) editor; main = the document's own
type LayoutFn = (doc: FolioDocument) => DocumentLayout;
interface FormatRuleLike { name: string; apply(block, ctx: { prev; next }): { pageBreakBefore?; keepWithNext? } | undefined }| Export | Signature | Description |
|---|---|---|
composePlugins | (base: Schema, plugins: readonly FolioPlugin[]) => ComposedPlugins | merge plugins in order (later wins by key/id) |
type ComposedPlugins | plugins, schema, renderers, painters, commands, keymap, pmPlugins, ui: ComposedUI, rules, exporters, wrapLayout(fn), subscribeLayout(fn) (every plugin's layout-change signal: call editor.refreshLayout() from it), mediaSize, input |
Full field documentation: Plugin anatomy.
Page layers
type LayerTarget = 'screen' | 'pdf' | 'docx' | 'print';
interface PageLayerContext { totalPages: number; variables: Readonly<Record<string, string>>; target: LayerTarget }
interface PageLayer {
id: string;
z: 'under' | 'over';
fragments(page: PageLayout, ctx: PageLayerContext): Fragment[];
subscribe?(fn: () => void): () => void;
}| Export | Description |
|---|---|
PageLayer | drawn on every page outside the flow (watermarks, stamps, backgrounds); a pure function of the page box, so layout and page reuse never change when it does. FolioPlugin.pageLayers, flattened in plugin order into composePlugins(...).pageLayers |
PageLayerContext, LayerTarget | what a layer is drawn for; layers may differ per target. @nextgensoftwares/folio-react paints 'screen', @nextgensoftwares/folio-export-pdf 'pdf', @nextgensoftwares/folio-export-docx 'docx' |
Example: Watermark plugin.
Colour adapters
type ColorRole = 'text' | 'background' | 'highlight' | 'fill' | 'border' | 'link' | 'codeText' | 'codeBackground' | 'page' | 'chrome' | 'caret' | 'selection';
interface ColorAdapter {
id: string;
active?(): boolean; // false = pass-through
adapt(color: string, role: ColorRole): string;
adaptOn?(color: string, role: ColorRole, background: string): string; // readable on a known display background
mediaFilter?: string | undefined; // CSS filter for images/video
subscribe?(fn: () => void): () => void;
}| Export | Description |
|---|---|
ColorAdapter | display colour transform (dark mode, sepia, high contrast). FolioPlugin.colors; composePlugins(...).colors is the last plugin's. Screen only: stored colours never change and exporters print the document's own colours |
ColorRole | what a colour is used for, so an adapter can treat text, fills, highlights, code and the page differently. chrome is the canvas around pages |
Example: Dark mode & colour themes.
UI arrangement and slots
interface PluginUI extends UIArrangement { toolbar?: UIItem[]; contextMenu?: UIItem[]; slash?: UIItem[]; items?: UIItem[]; route?: UIRoute; slots?: UISlotItem[] }
interface ComposedUI {
toolbar: UIItem[]; contextMenu: UIItem[]; slash: UIItem[]; slots: Record<string, UISlotItem[]>; arrangement: ComposedArrangement;
surface(name: string): UIItem[]; // routed + arranged items of any surface (stable array)
surfaces(): string[]; // surface names with items
}
type UIRoute = Record<string, string | readonly string[]>; // group/id globs → surfaces
// UIItem also has: defaultSurfaces?: string[]; render?: unknown (framework control)
interface UISlotItem { id: string; slot: string; label?: string; icon?: string; order?: number; when?: (ctx: UIContext | null) => boolean; component: unknown }
interface UIMove { id: string; before?: string; after?: string; to?: 'start' | 'end' }
type UIArrange = (surface: string, ids: readonly string[]) => readonly string[];
interface UIArrangement { hide?: readonly string[]; move?: readonly UIMove[]; arrange?: UIArrange }
interface ComposedArrangement { hidden: ReadonlySet<string>; moves: readonly UIMove[]; arrangers: readonly UIArrange[] }| Export | Signature | Description |
|---|---|---|
PluginUI, ComposedUI | a plugin's UI contributions; their merged form (composePlugins(...).ui) | |
UISlotItem, UIMove, UIArrange, UIArrangement, ComposedArrangement | slot items and rearrangement rules | |
composeArrangement | (parts: readonly (UIArrangement | undefined)[]) => ComposedArrangement | merge rules in order (append a host's own last for runtime changes) |
arrangeSurface | (a, surface, entries: T[]) => T[] | hide → move → arrange; ids bare or surface:id; a throwing arranger is skipped |
surfaceItems | (a, surface, ...lists: UIItem[][]) => UIItem[] | merge item lists by id (later wins), then arrange |
composeSlots | (lists) => Record<string, UISlotItem[]> | group by slot, sort by order, later plugins replace by id |
EMPTY_ARRANGEMENT | ComposedArrangement | no-op arrangement |
UIRoute | Record<string, string | readonly string[]> | PluginUI.route: keys are globs (*, ?) on item id or group, values surface names ([] = nowhere) |
composeRoutes | (parts: readonly (UIRoute | undefined)[]) => ComposedRoutes | merge routes in plugin order; a later plugin re-declaring a key wins |
routeItem | (item, routes: ComposedRoutes, fallback?: readonly string[]) => readonly string[] | surfaces for one item: exact id > exact group > most specific glob (later on ties) > defaultSurfaces > fallback |
routeItems | (src: RouteSources, routes) => Map<string, UIItem[]> | distribute legacy lists ({ legacy: { toolbar, ... }, pool }) over surfaces; same id on a surface: later replaces |
surfaceAccessors | (routed, arrangement) => { surface(name), surfaces() } | memoized, arranged surface lists (what ComposedUI exposes) |
ComposedRoutes, RouteSources | composed route table; inputs of routeItems | |
composeUI | (plugins: readonly { ui?: PluginUI }[]) => ComposedUI | the UI half of composePlugins (routes, slots, arrangement) without a schema: re-route or rearrange live by composing the same plugins plus an app part |
Guide: Customizing the UI.
Collaboration
interface Presence { clientID: string; name: string; color: string; anchor: number; head: number }
interface CollabAuthority {
receiveSteps(version: number, steps: readonly Step[], clientID: string): boolean;
stepsSince(version: number): { steps: Step[]; clientIDs: string[] };
setPresence(p: Presence): void;
presence(): Presence[];
subscribe(fn: () => void): () => void;
}| Export | Signature | Description |
|---|---|---|
class LocalAuthority | new LocalAuthority(doc: PMNode) | in-memory authority (doc, version); steps round-trip through JSON |
collabPlugin | (version: number, clientID: string) => Plugin | prosemirror-collab plugin for EditorOptions.plugins |
attachCollab | (editor, authority, me: Omit<Presence, 'anchor' | 'head'>) => () => void | send/receive steps, rebase, publish presence; returns a detach function |