Skip to content

Plate pagination plugin (headers / footers / footnotes) with pretext (variant A) #353

Description

@arthrod

@coderabbitai plan

🥕 multi-assumptions: variant A of 2 parallel framings of the same goal.
Sibling variant: #354. Please compare and tell us which assumption is load-bearing.
Carrots are crunchier when the layer is right.

My goal

Ship a Plate plugin (@platejs/pagination) that adds print-grade pagination — fixed-size pages (A4 by default) with per-page headers, footers, and footnotes — leveraging chenglou/pretext (text measurement / height oracle, MIT, ESM, v0.0.6) for the line-break and height math that Plate currently lacks.

To do so, do this (variant A — render-time overlay; pages as derived view)

  1. Add BasePaginationPlugin (createTSlatePlugin, key: KEYS.pagination) under packages/pagination/src/lib. Pure options: pageSize, margins, headerHeight, footerHeight, footnoteWell. No page nodes ever enter editor.children.
  2. Add BaseHeaderPlugin, BaseFooterPlugin, BasePageBreakPlugin (manual hard-break escape hatch) as sibling base plugins. Compose with the existing packages/footnote/ (BaseFootnoteDefinitionPlugin + Reference + Input) — do NOT re-author footnotes.
  3. Author paginate(doc, ctx, measurer) -> Page[] as a pure Slate-only selector in src/lib/paginate.ts. measurer: (text, font, width) => number is injected — lib/ stays React-free and unit-testable with a fake monospace measurer.
  4. Lift to React via toTPlatePlugin(BasePaginationPlugin, { plugins: [...], render: { aboveSlate: PageOverlay } }). PageOverlay paints absolutely-positioned PageFrames (header / footer / footnote well) above the normal Editable. Footnote definitions are hidden in main flow and portaled into the footer well of the page that contains their footnote_ref.
  5. Real-time measurer = a React hook that calls pretext.prepare() once per (node.id, marks-fingerprint, font, width) and pretext.layout() per pagination cycle, rAF-coalesced. Use prepareRichInline for mixed-mark runs. Snap to a named font (Inter) when the theme uses system-ui (pretext caveat).
  6. Print mode swaps PageOverlay for real <section class="page"> elements + @media print { .page { page-break-after: always; } } so Chromium honours pretext-derived breaks.

Acceptance: a 500-block document edits at ≥55fps with pagination on; resizing the viewport repaginates within one frame; print preview matches on-screen page boundaries within ±1 line; Yjs co-edit produces visually different paginations per viewport without doc divergence.

Because the code is

  • packages/core/src/lib/plugin/createSlatePlugin.ts:91-159createTSlatePlugin is the right factory; pagination owns explicit options + typed transforms, so the T-variant is correct.
  • packages/core/src/lib/plugin/SlatePlugin.ts:341-384render.aboveSlate / render.belowRootNodes are the slot mechanism for a chrome overlay; no portal infra to invent.
  • packages/core/src/react/plugin/toPlatePlugin.ts:70-107 — base→Plate lift preserves all extend* chains; canonical "wrap base, child plugins in wrapper" pattern.
  • packages/footnote/src/lib/BaseFootnoteDefinitionPlugin.ts (and Reference / Input) — existing semantic base plugins; this variant composes them rather than reinventing.
  • packages/utils/src/lib/plate-keys.tsKEYS.pagination belongs here, not as a string literal.
  • .claude/skills/plate-plugin-creator/SKILL.md — Slate-first hard law: pages are derived presentation, not document state, so the base plugin stays React-free.
  • Google docs - like pagination udecode/plate#4380zbeyens (maintainer, 2025-07-02): "rewriting slate-react to support virtual pages without container nodes in the document structure" — explicitly his preferred direction. Same thread: closed-source predecessor was bottlenecked by container-node normalization on every keystroke.
  • pretext README (commit bb224e0, 2026-04-22) — pretext does NOT paginate; it is a height oracle. This variant treats it as such.

Assumptions deliberately shuffled vs variant B

Axis Variant A (this) Variant B (#354)
Layer render-time overlay; pages derived document-model; section / page nodes
When every measure cycle (rAF, coalesced) on insert/delete normalization
Granularity per-block measurement per-section structure
Yjs derived per-client view; pagination diverges per viewport, doc converges deterministic shared pagination, viewport-locked
DOCX round-trip flat doc + section meta on top-level header/footer siblings direct mapping to w:sectPr + w:hdrRef / w:ftrRef
Reversibility feature-flag toggle; turning pagination off is a no-op schema migration in both directions
editor.children shape unchanged from non-paginated docs top-level becomes section[]
Failure mode over-budget repagination = visible jank, doc still correct over-budget normalization = stale layout, doc structurally correct
Risk pretext perf is the load-bearing primitive break-maintenance transforms on every edit are the load-bearing primitive

Validate this approach — especially but not limited to

  • Static analysis of packages/core/src/lib/plugin/SlatePlugin.ts — confirm render.aboveSlate survives read-only mode, plays with selection coords, and does not break Editable event delegation.
  • Review of our own code in packages/footnote/, packages/comment/, packages/core/src/internal/plugin/pluginInjectNodeProps.ts:25-122 — check that the portal+overlay pattern this variant proposes is not already invalidated by an existing convention.
  • Comparative analysis vs sibling Plate pagination plugin (headers / footers / footnotes) with pretext (variant B) #354 — name the load-bearing assumption as a single sentence in the form "If <X> matters more than <Y>, variant <A|B> wins; otherwise the other." Specifically: which matters more, liquid editing UX + per-keystroke perf (favours A) or deterministic paged structure + DOCX section fidelity (favours B)? Do not pick a winner yet — name the axis.
  • pretext SSR posture — confirm we can hydrate without re-running prepare() on the server.
  • Yjs implications of derived pagination — verify two clients with different viewport widths don't fight on editor.children.

Full plan

  1. New package packages/pagination/ (tsdown, bun test); add to root pnpm-workspace. Run pnpm brl after file scaffolding lands.
  2. Add KEYS.pagination, KEYS.header, KEYS.footer, KEYS.pageBreak to packages/utils/src/lib/plate-keys.ts. Footnote keys reuse what packages/footnote/ already exposes.
  3. src/lib/: base-pagination-plugin.ts, base-header-plugin.ts, base-footer-plugin.ts, base-page-break-plugin.ts, paginate.ts, allocate-footnotes.ts, internal/font-from-style.ts, internal/measure-cache.ts, types.ts. Helpers (matchers, fallbacks, fingerprint hashing) live in internal/ per repo barrel rules.
  4. src/react/: pagination-plugin.ts (toTPlatePlugin), header-plugin.ts, footer-plugin.ts, page-break-plugin.ts, page-overlay.tsx, page-frame.tsx, footnote-portal.tsx, use-pretext-measurer.ts, internal/use-page-layout.ts.
  5. Compose with existing packages/footnote/ plugins — no re-author. PaginationPlugin = toTPlatePlugin(BasePaginationPlugin, { plugins: [HeaderPlugin, FooterPlugin, PageBreakPlugin, FootnoteDefinitionPlugin, FootnoteReferencePlugin, FootnoteInputPlugin] }).
  6. Public surface: editor.api.pagination.{getPages, getPageOf, getFootnotes, measure}; editor.tf.pagination.{insertFootnote, setHeader, setFooter, insertPageBreak, recomputePages}. No ({ editor }: { editor: SlateEditor }) annotations — inference from createTSlatePlugin<Config>.
  7. Print path: alternative PrintPagination component injecting real <section> elements + @media print rules; same paginate() selector.
  8. Tests in packages/pagination/src/**/*.spec.ts with bun: pure paginator with fake measurer (golden cases), allocator, schema round-trip; React tests only for overlay positioning.
  9. DOCX import/export: round-trip headers/footers via w:hdrRef/w:ftrRef; footnotes through existing packages/footnote/ adapters.
  10. Changeset under .changeset/ per .agents/rules/changeset.mdc before completing.

References

  • Google docs - like pagination udecode/plate#4380 (read with pretext-specific content trimmed)
  • packages/core/src/lib/plugin/{createSlatePlugin.ts, SlatePlugin.ts, BasePlugin.ts}
  • packages/core/src/react/plugin/toPlatePlugin.ts
  • packages/footnote/src/lib/BaseFootnote*Plugin.ts
  • .claude/skills/plate-plugin-creator/SKILL.md and rules/{creation-flow,typing,composition}.md
  • pretext README and src/{layout,line-break,analysis,rich-inline}.ts at commit bb224e0

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions