Table of contents plugin
@nextgensoftwares/folio-plugin-toc adds chapters, a document outline, a table-of-contents block with live page numbers, an outline navigator with scroll spy, and scroll-to-section. in progress
import { registerTocHost, tocPlugin } from '@nextgensoftwares/folio-plugin-toc';
const composed = composePlugins(standardSchema, [tocPlugin({ defaults: { levels: 3, leaders: 'dots' } })]);
// Required for live page numbers: wrap your layout function.
const layout = composed.wrapLayout((d) => layoutDocument(d, { measurer, renderers: composed.renderers, cache, previous }));Without wrapLayout, TOC blocks show the last snapshot stored by "Update table".
The TOC block
tableOfContents is an atom block with these attributes:
| Attribute | Default | Meaning |
|---|---|---|
title | 'Contents' | heading above the entries (null for none) |
levels | 3 | include headings of level 1..levels (1–6) |
leaders | 'dots' | 'dots' or 'none' between text and page number |
numbered | false | outline numbers (1, 1.1, 1.1.1) before entries |
dir | null | direction override |
entries | null | persisted snapshot (written by "Update table") |
tocAttrs(attrs) applies defaults and sanitizes bad values. Paragraphs with a visualHeading attribute (2, "2", "h2", or true = 1) count as headings.
Each row is laid out as ordinary line fragments plus an invisible tocEntrycustom fragment: hit-testing treats it as an atom (a click selects the TOC so its context menu works), and TocEntryPainter turns it into a link that follows the entry to its heading.
Live page numbers: a fixed point
Like Word's TOC field, page numbers need a fixed point: lay out, read where the headings landed, lay out again only if a TOC's rows changed. withTableOfContents(next, { maxPasses }) (what wrapLayout installs) does this:
- Rows reserve a fixed-width number column, so a TOC's height never depends on its numbers: it converges in ≤ 2 passes (3 when headings re-wrap).
- Pass 1 guesses from the previous layout, with block indices mapped back through the edit, which is right unless headings moved pages.
- Extra passes go through the same host function, so with an incremental host (
cache+previous) they only re-measure the TOC and re-paginate until the old page breaks are met again. - The substituted TOC node is memoized per
(node, data), so the layout cache and incremental pagination see it as unchanged when the numbers didn't change. - Documents without a TOC pay one incremental outline update.
The returned function exposes stats (passes, overheadMs, converged) and outline().
The outline
OutlineTracker keeps the list of top-level headings incrementally: unchanged leading and trailing blocks keep their entries, only the edited middle is rescanned, and the result is the same object when an edit didn't touch a heading, so views skip work by identity. outlineOf(editor) returns the editor's current outline.
Resolving positions never walks pages it doesn't need: pageOfBlock binary-searches page.blocks (no fragments built), entryPages gallops forward from the previous hit (a book's TOC costs O(entries × log(page gap))), and blockPosition reads one page's fragments to find a heading's exact y.
Navigation
The view provides a scroll adapter, registered per editor:
const off = registerTocHost(editor, {
scrollTo: ({ page, y }) => view.scrollTo(page, y),
viewportTop: () => view.viewportTop(),
subscribe: (fn) => view.subscribe(fn),
});FolioView's imperative handle (FolioViewHandle) has exactly these methods. Then:
goToBlock(editor, block): moves the caret to a top-level block (without the caret auto-scroll) and scrolls to its exact page and y.goToTocEntry(editor, tocBlock, k): follows rowkof a TOC.<OutlineNavigator editor={editor} numbering />: a collapsible, searchable, virtualized outline (10,000 headings cost ~40 DOM rows) with scroll spy, keyboard navigation (arrows, Home/End, Enter) and a chapter view with page ranges.- Hooks:
useOutline(editor)(deferred, so the panel updates after a keystroke has painted),useTocHost,useActiveEntry.
Chapters start on a new page
Two ways, with different trade-offs:
tocPlugin({ chaptersStartOnNewPage: true })adds theh1StartsPageRuleformatter rule: a layout hint, not stored in the document.- The
toc.chaptersStartOnNewPagecommand (setChaptersStartOnNewPage(on)) sets Word'spageBreakBeforeon every top-level H1 except a leading one. It persists, exports to DOCX and is undoable, in one transaction touching only H1s. While it is on, a block that becomes a chapter (a new heading, a restyled paragraph, a pasted chapter) inherits the break from its neighbouring chapters (chapterInheritPlugin, included in the plugin'spmPlugins; typing never pays for the check). The toggle is disabled until there is a Heading 1 after the first block: the first chapter already starts the document.
New chapters
New chapter (toc.newChapter, Mod+Alt+Enter) inserts a Heading 1 at the caret with its placeholder ("Chapter N") selected, so typing replaces it. Mid-paragraph it splits the paragraph; at a block's start or end it goes before or after it; an empty line becomes the chapter; inside a list or table it goes after the whole block. It starts on a new page when chapters do (or with newChapter({ pageBreak: true })), and it is one undo step. It appears in the toolbar's Insert group, the slash menu, the context menu, and as + New chapter under the Chapters list (after the chapter in view, or at the end). The context menu also has Make this a chapter (the paragraph becomes a Heading 1, keeping its attributes) and Rename chapter (selects the chapter's title).
Chapters on or off
Chapters are a document setting (doc.attrs.chapters = { enabled, level }, toc.setChapters), on with level 1 unless the document or tocPlugin({ chapters: { enabled: false } }) says otherwise. Off: no Chapters view, no "New chapter" or chapter context items, no "Chapters start on a new page"; the outline still lists every heading. The level moves chapters to another heading (a book with Parts uses H2). Chapters also start header/footer sections (see Headers and footers). The Chapters view has the switch in its header; settings ▸ Header & footer has it too.
Commands and UI
| Command | Does |
|---|---|
toc.insert(attrs?) | insert a TOC at the selection and select it |
toc.update(editor, pos?) | store the current rows (with page numbers) in entries, like Word's "Update field" |
toc.setAttrs(patch, pos?), toc.setLevels(n), toc.setLeaders(l) | change a TOC |
toc.remove(pos?) | remove the selected (or first) TOC |
toc.chaptersStartOnNewPage(on) | the persisted chapter rule above |
toc.newChapter(opts?) | a Heading 1 at the caret (or before block opts.at), placeholder selected; opts.pageBreak, opts.title(n) |
toc.makeChapter(opts?) | the caret's top-level paragraph/heading becomes a Heading 1 |
toc.selectChapterTitle(index?) | select the title of the caret's (or block index's) chapter |
Commands act on the selected TOC, else the TOC around the selection, else the first in the document (targetToc). The plugin contributes toolbar, slash-menu and context-menu items for these.