Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
  • Feature Kits
  • Upload Files
  • Plugin
    • Plugin Methods
    • Plugin Shortcuts
    • Plugin Context
    • Plugin Components
    • Plugin Rules
    • Editing Behavior
    • Plugin Input Rules
  • Editor
    • Editor Methods
    • Controlled Value
  • Authored Changes
  • Performance
  • Static Rendering
  • HTML
  • Markdown
  • Form
  • TypeScript
  • Debugging
  • Unit Testing
  • Browser
  • Troubleshooting
  • Locations
  • Transactions
  • Serializing
  • Roots
  • Document Meta
  • Clipboard and Paste
  • Decorations and annotations
  • Schema
  • History
  • Pagination
  • Annotations
  • DOM Coverage
  • External Text Views
  • Virtualized Rendering

Pagination

PreviousNext

Render a Plate editor as measured pages with explicit fragmentation.

PagedEditorContent renders EditorContent on measured pages. Plugin elements, marks, handlers, shortcuts and slots render as usual. The paged view measures pages, renders page chrome, places blocks and can leave offscreen pages out of the DOM. The editor value stays the source of content, and page geometry is derived view state.

Loading…
HistoryAnnotations

On This Page

InstallationRender a paged editorFragment non-text blocksMeasurementRoots and page settingsDOM coverage
Build your editor
Production-ready AI template and reusable components.
Get all-access

Installation

pnpm add platejs @chenglou/pretext
pnpm add platejs @chenglou/pretext

Render a paged editor

document-editor.tsx
import { createPretextPageLayoutEngine } from 'platejs/pagination';
import {
  PagedEditorContent,
  usePageLayout,
} from 'platejs/pagination/react';
import { EditorRoot, useCreateEditor } from 'platejs/react';
import { useMemo, useRef } from 'react';
 
export function DocumentEditor() {
  const editor = useCreateEditor({ initialValue: value });
  const editableRef = useRef<HTMLDivElement>(null);
  const engine = useMemo(() => createPretextPageLayoutEngine(), []);
  const layout = usePageLayout(editableRef);
 
  return (
    <EditorRoot editor={editor}>
      <output>{layout?.pages.length ?? 0} pages</output>
      <PagedEditorContent
        ref={editableRef}
        engine={engine}
        page={{ margins: 72, preset: 'letter' }}
        pageView={{ gap: 24, mode: 'single' }}
        renderPage={({ attributes, page }) => (
          <div {...attributes} aria-label={`Page ${page.index + 1}`} />
        )}
      />
    </EditorRoot>
  );
}
document-editor.tsx
import { createPretextPageLayoutEngine } from 'platejs/pagination';
import {
  PagedEditorContent,
  usePageLayout,
} from 'platejs/pagination/react';
import { EditorRoot, useCreateEditor } from 'platejs/react';
import { useMemo, useRef } from 'react';
 
export function DocumentEditor() {
  const editor = useCreateEditor({ initialValue: value });
  const editableRef = useRef<HTMLDivElement>(null);
  const engine = useMemo(() => createPretextPageLayoutEngine(), []);
  const layout = usePageLayout(editableRef);















renderPage receives page attributes and geometry. It renders page chrome; the paged view places the editable content on the same canvas. Spread mode changes page arrangement without changing the document.

usePageLayout reads the layout published by the exact editable element in its ref. This keeps sibling paged editors independent and returns null before the surface publishes its first valid layout.

Fragment non-text blocks

Text blocks use inline flow by default. Describe how images, embeds, tables, and other non-inline structures fragment:

import type { NodeFragmentationProvider } from 'platejs/pagination';
 
const fragmentation: NodeFragmentationProvider = ({ content, element }) => {
  if (element.type === 'image') {
    return { type: 'atomic', size: { height: 320, width: 480 } };
  }
 
  if (element.type === 'table') {
    return {
      type: 'atomic',
      size: { height: element.children.length * 44, width: content.width },
    };
  }
 
  if (element.type === 'codeBlock') {
    return { type: 'text', keepTogether: true };
  }
};
 
<PagedEditorContent
  page={{ margins: 72, preset: 'letter' }}
  fragmentation={fragmentation}
/>;
import type { NodeFragmentationProvider } from 'platejs/pagination';
 
const fragmentation: NodeFragmentationProvider = ({ content, element }) => {
  if (element.type === 'image') {
    return { type: 'atomic', size: { height: 320, width: 480 } };
  }
 
  if (element.type === 'table') {
    return {
      type: 'atomic',
      size: { height: element.children.length * 44, width: content.width },
    };
  }
 
  if (element.type === 'codeBlock'







atomic places one indivisible box. A table renders as one atomic block: a table shorter than a page moves to the next page whole when it does not fit. direct-children places each immediate child while the document tree stays intact. text can request soft keep-together behavior; content taller than a page still fragments.

The app owns these sizes. Without a plan, a void block such as an image measures as one empty line, and a top-level block whose children are other blocks makes measurement fail; the editor stays editable and renders without pages until the document measures again.

Use usePageLayoutFragments() inside an element renderer to read the current element's mounted page placements. The hook derives the element path from the renderer context.

Measurement

createPretextPageLayoutEngine() measures rich inline text with browser font metrics. The app owns typography: return the fonts, line heights, and block spacing your element styles render, or the measured lines will not match the painted text. Call measurePages with an explicit engine for headless snapshots. Use createEstimatedPageLayoutEngine() where browser text measurement is unavailable.

Keep fragmentation and typography stable across renders, for example with useMemo. A new function or object remeasures the document.

A server cannot measure text, so a server-rendered page shows the editor without pages, and pages appear once the browser measures after hydration.

Each measured line renders at its measured position inside its block. Element padding and margins do not move that text, so put the vertical space they add into typography block spacing instead. Horizontal space has no typography equivalent, so an indented block, such as a list item, paints its text at the content edge, under its list marker.

measurePages(editor, options) reads one coherent editor snapshot and returns an immutable PageLayoutSnapshot. It neither subscribes nor publishes. A custom PageLayoutEngine receives detached immutable input and must conserve source ranges, fragment order, paths, and declared non-text sizes.

Different font files and browser profiles can produce different line breaks. Derived pages are therefore a view result, not stored page-break authority. Products that require identical exported pages need one controlled measurement environment for that export.

Roots and page settings

Pass a named root to PagedEditorContent or measurePages to measure and render that root. Omit root for the primary document. Both reject "main", an internal root spelling.

page accepts a PageSettings value or a defineStateField field, so the app keeps shared page settings in editor state without storing derived geometry. Persist that field with persist: { ...pageSettingsCodec, version: 1 }, which stores validated finite settings; the built-in presets are "a4" and "letter".

DOM coverage

PagedEditorContent mounts every page by default. Set virtualize only after the complete paged DOM exceeds the product's tested interaction or memory budget. Virtualization keeps selected and promoted content mounted, but native browser find, accessibility traversal, DOM printing, and third-party DOM integrations see mounted content only. Use model-based export for the complete document.

return (
<EditorRoot editor={editor}>
<output>{layout?.pages.length ?? 0} pages</output>
<PagedEditorContent
ref={editableRef}
engine={engine}
page={{ margins: 72, preset: 'letter' }}
pageView={{ gap: 24, mode: 'single' }}
renderPage={({ attributes, page }) => (
<div {...attributes} aria-label={`Page ${page.index + 1}`} />
)}
/>
</EditorRoot>
);
}
) {
return { type: 'text', keepTogether: true };
}
};
<PagedEditorContent
page={{ margins: 72, preset: 'letter' }}
fragmentation={fragmentation}
/>;