The editor
@nextgensoftwares/folio-editor is a headless paginated editor. ProseMirror's headless packages (model, state, transform, commands, history, schema-list, keymap, tables, gapcursor, collab; all MIT) hold the document, selection and undo history. There is no prosemirror-view and no contenteditable: Folio lays out the document, maps positions to fragments, and the host draws pages, caret and selection and forwards input.
import { FolioEditor } from '@nextgensoftwares/folio-editor';
const editor = new FolioEditor({
schema, // a Folio schema (host extensions included)
doc, // FolioDocument
layout: (d) => layoutDocument(d, { measurer, cache, previous }), // you own theme, fonts, cache, renderers
measurer, // the same measurer layout uses (caret placement)
plugins: [], // extra ProseMirror plugins (collab, plugin pmPlugins…)
keymap: {}, // extra key bindings, tried before the defaults
});State you can read
| Member | What |
|---|---|
state | the ProseMirror EditorState |
folioDoc | the document as Folio JSON (unchanged blocks keep identity) |
layout | the current DocumentLayout |
index | the PositionIndex for the current layout |
pmSchema | the ProseMirror schema built from your Folio schema |
version | bumped on every change; pair with useSyncExternalStore |
lastTransaction | the last applied transaction (check scrolledIntoView, getMeta('folio:load')) |
subscribe(fn) notifies after every dispatch. dispatch(tr) applies a transaction and, if the document changed, re-runs layout and rebuilds the position index. run(command) runs a ProseMirror Command against the live state. setLayout(fn) swaps the layout function (e.g. after a theme change) and re-lays out without editing.
From ProseMirror to Folio JSON
toProseMirrorSchema(folioSchema) builds (once per schema, memoized) a ProseMirror schema: content expressions, groups and attributes map 1:1; the built-in types get clipboard HTML mappings and behaviour hints (defining, isolating, table roles); unknown host nodes get a generic div[data-type] mapping. It also installs a fast content matcher for single-term repetitions (block+, inline*…): matching reduces to set membership instead of walking ProseMirror's automaton per child, which matters for a 35,000-block doc.
DocBridge converts the ProseMirror document to Folio JSON memoized per ProseMirror node. ProseMirror reuses unchanged subtrees across transactions, so unchanged Folio blocks stay the same objects and the layout cache only re-measures what an edit touched. For the doc node itself it reuses the previous children array's unchanged prefix and suffix rather than looking up 35,000 nodes per keystroke. Attributes that are null are omitted.
The position index
PositionIndex maps between ProseMirror positions and positioned fragments. Fragment paths mirror the ProseMirror tree and line offsets are ProseMirror inline offsets, so the mapping is arithmetic, not search. It is built per edit in O(blocks) (the start position of each top-level block); everything else is lazy and per page.
| Method | Use |
|---|---|
caretAt(pos) | caret box { page, x, y, height, dir } |
posAt(page, x, y) | hit-testing: a text position, a node position for atoms (node: true), or the gap beside a figure (gap: 1 | -1) |
lineAt(pos) | the line holding a position |
verticalNeighbor(line, dir, goalX) | next/previous visual line nearest a goal column (table cells side by side) |
rangeRects(from, to, window?) | selection highlight rects, optionally only for visible pages |
nodeRects(pos), cellRects(pos) | outlines for a selected atom or table cell |
pageLines(page), contentStart(path) | lower-level access |
Hit-testing handles bidi runs, justified word spacing and grapheme clusters. Decorative lines (code language labels, headers/footers) and repeated table headers are skipped. Per-page parts are cached by page object, so when layout reuses a page, its index data survives the edit too.
Rendering queries
editor.caret(); // Caret | null (a horizontal bar for a gap cursor)
editor.selectionRects({ first, last }); // pass the visible page window
editor.presenceGeometry(people, window); // collaborators' carets and highlights
editor.headCaret(); // caret for IME placement, even with a range selected
editor.uiState(200); // display state for toolbars (see below)uiState(maxBlocks) returns the state with a huge selection truncated to its first maxBlocks top-level blocks. Toolbars use it to decide what is active or enabled without walking 35,000 blocks per button after select-all. Always run commands against the real state (editor.run), never against uiState().
Input
The host forwards input; @nextgensoftwares/folio-react's InputCapture does this with a focused, invisible textarea (the pattern Google Docs uses), which also gives IME composition and the system clipboard.
editor.handleKeyDown(event); // true if handled (preventDefault)
editor.insertText('x');
editor.pointerDown(page, x, y, { shift, detail }); // detail 2 = word, 3 = paragraph
editor.pointerMove(page, x, y); // drag-select; across cells selects cells
editor.pointerUp();Clipboard helpers: serializeSelection(state) gives { text, html }; pasteTransaction(state, schema, { html, text }) parses HTML through the schema (Word/Docs markup maps to known nodes, the rest is dropped) and keeps plain text verbatim inside code blocks.
Default keymap
| Keys | Action |
|---|---|
| Enter | paragraph beside a selected block / at a gap, newline in code, split list item, split block |
| Shift-Enter | exit code block, or hard break |
| Backspace / Delete | delete cell selection, selection, one grapheme, or join blocks |
| Mod-Backspace / Mod-Delete | delete a word |
| Tab / Shift-Tab | indent in code, next/previous cell, sink/lift a list item at its start; elsewhere Tab inserts a tab character (\t, laid out to the paragraph's tab stops). tabKey: 'indent' restores list-only Tab |
| Arrows | visual movement (reversed in RTL lines), vertical moves keep a goal column |
| Home / End | line start / end (soft-wrapped lines keep the caret on the line) |
| Shift + movement | extend the selection |
| Mod-a, Mod-z, Mod-y / Shift-Mod-z | select all, undo, redo |
| Mod-b / i / u / e, Mod-Shift-x | bold, italic, underline, code, strike |
| Mod-Alt-0 / 1 / 2 / 3 | paragraph, heading 1–3 |
Without contenteditable, the browser no longer deletes graphemes for you: Folio's Backspace removes a whole grapheme cluster (an Arabic letter with its marks, an emoji sequence) using Intl.Segmenter.
The gap cursor
Between two non-text blocks (figure after figure, a table at the end of the document) there is no text position. Folio uses ProseMirror's GapCursor: arrows and clicks above/below a figure land in the gap, caret() returns a horizontal bar, typing starts a new paragraph there, and Enter on a selected figure inserts a paragraph after it, as in Word.
Commands
createCommands(pmSchema) returns toolbar-level commands: toggleMark, paragraph, heading(level), codeBlock, blockquote, lift, bulletList, orderedList, align, dir, insert(type, attrs), undo, redo. createTableCommands() wraps prosemirror-tables (rows, columns, merge/split, header row/column, cell background). Formatting helpers: setBlockAttrs, updateTextStyle (merges colour/size/family without clobbering the others), setLink, setSelectedNodeAttrs, plus queries markActive, blockType, textStyleAt, linkAt, historyDepth.
Availability checks call commands without dispatch; Folio's own commands keep those checks cheap (no transaction is built just to answer "is this enabled?").