Pagination
Lays the document out as pages, the way Word does: page and section breaks, headers and footers, footnotes and endnotes, line numbers and page numbers. It is optional; the editor works without it.
Packages
| Package | Purpose |
|---|---|
@stesura/pagination | Plugins, layout engine, stores (nanostores), helpers, types (PaginationState, PageLayout, PaginationCache, …) |
@stesura/pagination-react | paginationUiExtension, node views, HeaderFooterPanel, hooks |
Setup
Mount the plugins through your mount hook’s extraPlugins, and pass paginationUiExtension to the editor:
"use client";
import { EditorLoadingState, useLocalEditor } from "@stesura/editor-react";
import { paginationPlugins } from "@stesura/pagination";
import { paginationUiExtension } from "@stesura/pagination-react";
import dynamic from "next/dynamic";
const StesuraEditor = dynamic(
() => import("@stesura/editor-react").then((m) => m.StesuraEditor),
{ ssr: false, loading: () => <EditorLoadingState /> }
);
const uiExtensions = [paginationUiExtension];
const Editor = () => {
const { editorState, dispatch, pluginFactory, schema } = useLocalEditor({
extraPlugins: () => [...paginationPlugins()],
});
return (
<StesuraEditor
state={editorState}
dispatchTransaction={dispatch}
schema={schema}
pluginFactory={pluginFactory}
uiExtensions={uiExtensions}
/>
);
};paginationPlugins() returns paginationPlugin(), columnLayoutPlugin(), columnSpanPlugin(), headerFooterBindingPlugin() and headerFooterSyncPlugin(), in that order. They are exported individually too, but the order matters. To start with pagination off, mount the five yourself, in that order, with paginationPlugin({ showPagination: false }) first. The pagination UI throws when one is missing.
paginationUiExtension needs no further wiring. It contributes:
- Node views: section (the page container), section header/footer, paragraph, heading, page break, image, display math and file.
- Node-view wrappers for table rows and cells. They wrap whichever view is registered, so your own row/cell views keep their page-break behaviour.
- Toolbar groups: pagination and line numbering on the View tab; header/footer and page numbers on the Page tab.
- Status bar: the pagination toggle.
- Overlays:
HeaderFooterPanel(the header/footer editor) and the stylesheet that clips oversized nodes.
Turning pagination on and off
Always toggle through the command. The plugin keeps the store in sync and cleans up the page-break artifacts; writing the store directly gets reverted.
import { togglePagination } from "@stesura/pagination";
togglePagination(view.state, view.dispatch);Read the current value with useShowPagination() inside the editor tree, useCurrentEditorShowPagination() from shared chrome such as a toolbar, or getShowPagination(idOrState) outside React.
Sections and page format
Every document has at least one section, which sets page size, orientation and margins. The commands live in @stesura/core/commands:
import { addSection, setSectionFormat, insertPageBreak } from "@stesura/core/commands";
addSection()(state, dispatch); // new section after the current one
setSectionFormat({ orientation: "landscape", margin: { layout: "narrow" } })(state, dispatch);
insertPageBreak(state, dispatch);See Commands: Sections & Track Changes for the full list. Keep-with-next, widow/orphan control and page-break-before come from the paragraph’s pagination attr, falling back to its style: see Stylesheets.
Headers and footers
Open the editor with useSetHeaderFooterEditing from @stesura/editor-react. Variants (different first page, different even pages) are header_footer_content nodes, added and removed with core commands. See Header & Footer.
Line numbering
Driven by the section’s lineNumbers attr: countBy, distance (px), restart ("continuous" | "newPage" | "newSection") and start. Set it with these commands, not with setSectionFormat:
import { setLineNumbers, toggleLineNumbers, toggleSuppressLineNumbers } from "@stesura/core/commands";
toggleLineNumbers()(state, dispatch); // current section
setLineNumbers({ countBy: 5, restart: "newPage" })(state, dispatch);
toggleSuppressLineNumbers()(state, dispatch); // selected blocks: not countedColumns (experimental)
A section can lay its text out in columns, like Word’s page layout columns. It relies on CSS column-wrap, which only Chromium 145+ ships, so it is off by default. Turn it on in the configuration, for the editor and for the PDF export’s print mount alike, or the export lays pages out differently:
useLocalEditor({ configuration: { config: { experimental: { columnSections: true } } } });With it on, Page setup shows a Columns group (count, spacing, a line between). Other browsers lay the section out in one column with a notice, and the PDF export refuses such a page. With it off, the section’s columns attr, column breaks and spanning blocks are kept and round-trip through DOCX, but render in one column.
import { setSectionColumns, insertColumnBreak, toggleSpanColumns } from "@stesura/core/commands";
setSectionColumns({ count: 2, gap: 48, separator: false })(state, dispatch);
insertColumnBreak(state, dispatch); // Ctrl+Shift+Enter
toggleSpanColumns(state, dispatch); // selected blocks span the columnsA column_break opens the next column, or the next page in one column. spanColumns is honoured only for the blocks opening a column section: they sit full width above its first page’s columns and must fit that page. Further down, a spanning block flows in its column. Tables and images wider than a column are drawn fitted to it, their stored sizes untouched. Each page keeps its own header and footer height, and table rows split between columns (a table with merged rows moves row by row). Line numbering, PDF previews and repeated table headers are not available in column sections yet.
Nested tables
A table in a table cell breaks between its rows, never through one: to the host cell it is one block whose lines are its rows. A row that doesn’t fit moves whole to the next page, which can leave up to a row of white space at the page bottom. Rows a merged cell ties together move as a group. A nested row taller than a page gets a page of its own and is clipped, like any oversized node.
The break inside the host cell is drawn by the nested row that opens the next page: it takes the page gap as its top padding, and the host cell only draws its border copies at the cut (a "child" break, InCellBreak.realizedBy). A float in the host cell would push the whole nested table down instead.
In a column section the browser fragments a row hosting a nested table itself.
A custom table cell or row view must wrap its children in NodeViewSlotsBoundary (see Wrappers and slots): otherwise the outer cell’s page-break artifacts reach every nested cell.
Repeated header rows
A table with repeatHeaderRows (see table commands) shows its header rows again at the top of every page it continues onto, as Word does. Only the header rows repeat: a cell merged from them into the body is cut at their bottom. A page that opens between two header rows repeats none, and header rows are kept whole.
The copy is page chrome for fitting (each continuation page has its height less room) and table content for positions. It is painted inside the break gap of the row resuming below it, by one cell (CellBreaks.rowStartRepeat or InCellBreak.repeatHeader), from the live document. It is hidden from assistive technology and can’t be edited or selected.
Not repeated: when the header rows and the first line of the first body row don’t fit on a page together (Word lets that row run into the margin), in nested tables (as in Word), and in column sections for now (Word repeats them per column).
Oversized nodes
A block taller than the usable page gets a page of its own and is clipped, as in Word. This needs no code. A custom node view can handle it better, for example by scaling: read the clip height with useOversizedNode(editorId, nodeId), and set data-oversized-handled on its root to opt out of the default clip. See Node views.
How page breaks render
Each block carries its own page breaks: a paragraph split across pages opens a gap between its lines, and a block pushed to the next page takes the gap as its own top padding (as a margin when it has a top border, so the border opens the new page). Headers, footers and page gaps are an overlay drawn over the flow. Outside column sections:
- Pins. Every top-level text block is pinned to the height the engine measured. In Chromium, blocks off screen also skip rendering (
content-visibility), so opening and scrolling a long document is cheaper. A node view whose height changes outside a transaction must report it (see Size changes outside transactions): a pinned block doesn’t grow on its own, so an unreported change shows up as a layout failure. - The layout check also compares each pinned block on screen with its pin, and checks that tables, images and other unpinned blocks leave the flow where the engine placed them. A mismatch is reported as an
overflowlayout failure naming the block indetail, then rebuilt like any overflow. - Leading blank lines. A paragraph pushed to a new page keeps the blank lines it opens with (a manual line break at its start), as Word does.
localStorage.paginationPins = "0" turns pins and off-screen skipping off; blocks still carry their breaks. It is read once per page load, so set it before the page loads, in the page and in the PDF export’s print mount alike.
Size changes outside transactions
Pagination re-measures the blocks a transaction touches. When a block’s height changes some other way, report it:
- From a node view, for example an embed resolving or a chart re-rendering:
useReportNodeResize(nodeId)returns a ref callback. - From anywhere else, for example AI ghost text rendered as a decoration:
requestNodeRemeasure(idOrState, nodeIds)from@stesura/pagination.
Layout failures
The layout is checked after each pass. If content overflows the last computed page, or a measured section comes out empty, pagination wipes the section’s cache and recomputes once. Every stage is reported through console.warn and to your handlers:
import { addPaginationFailureHandler } from "@stesura/pagination";
const unsubscribe = addPaginationFailureHandler((report) => {
// report: { editorId, sectionId, stage, kind, pageCount, overflow, detail?, … }
if (report.stage === "unrecovered") sendToTelemetry(report);
});kind | Stages |
|---|---|
overflow, engine-empty, column-mismatch | detected → recovered, tolerated or unrecovered |
measurement-missing, oversized-content | detected only; informational, and throttled per section |
tolerated: a pinned block whose box re-measures the same but is off its pin by no more than the page buffers (2px taller, 3px shorter). No line is hidden, so after one re-measure of that block it is a warning: no section rebuild, and isTerminalPaginationFailure is false. It is reported once per block and error.
Each report also reaches the failing mount’s onDiagnostic as a pagination-failure diagnostic.
Waiting for layout in tests
getPaginationActivity(idOrState) returns { engineRuns, enginesSkipped, recomputesSkipped, lastChangeAt }. hasPendingPaginationWork(idOrState) returns whether any nodes, notes or headers/footers are still dirty.
Layout counts as settled when:
- the engine has run at least once;
- no work is pending;
lastChangeAthas stayed the same for longer than the 300ms header/footer debounce. The quiet window covers work that is scheduled but not yet marked dirty.
Layout state
The plugin state (paginationPluginKey from @stesura/core/plugins) only holds showPagination and the oversized-node decorations. The rest lives in per-editor nanostores:
getSectionPaginationCache(idOrState, sectionId)returns a section’sPaginationCache(measurements, pagination items, breaks). Read it imperatively; it isn’t meant to be subscribed to.- Per-node results (paragraph breaks, row paddings, clip heights) are published to per-node atoms. Read them through the hooks below, so a node re-renders only when its own value changes.
- Document-wide page counts and page numbers are in the core editor stores (
pageCounts,pageNumbering).useDocPageNumberingreads them.
React hooks
From @stesura/pagination-react:
| Hook | Returns |
|---|---|
useShowPagination() / useCurrentEditorShowPagination() | Whether pagination is on, for the enclosing editor or for the current root editor. |
useOversizedNode(editorId, nodeId) | Clip height in px, or undefined if the node fits. |
useReportNodeResize(nodeId) | A ref callback that re-measures the node when its DOM resizes. Re-exported from @stesura/editor-react. |
useParagraphBreaks(editorId, nodeId) | Page breaks inside a textblock, or undefined. |
useRowHeightOverride(editorId, rowId) | { hasOverride, height }: the engine’s height for a fixed-height row. A null height releases it so the row can split. |
useRowPaddingTop(editorId, rowId) | Top clearance in px for a row that starts a new page, else 0. |
useVariantCache(sectionId?) | The section’s non-default header/footer variants. |
useDocPageNumbering(sectionId) | { pageOffset, totalPages, firstPageNumber, format } for the section. |
useVariantLabels() | Translated variant names. |
usePaginationLayoutEffect(node) | Runs the engine for a section. Used by the section node view. |
Next steps
- Header & Footer: variants, page numbers, editing from code.
- Commands: Sections & Track Changes: page and section commands.
- UI Extensions: how extension bundles work.
- Plugins: plugin order.