Skip to content

Plate pagination plugin (headers / footers / footnotes) with pretext (variant B) #354

Description

@arthrod

@coderabbitai plan

🥕 multi-assumptions: variant B of 2 parallel framings of the same goal.
Sibling variant: #353. Please compare and tell us which assumption is load-bearing.
A bigger carrot, but pickled in the document model.

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 B — document-model section nodes; pretext as auto-paginator oracle)

  1. Add BasePaginationPlugin (createTSlatePlugin, key: KEYS.pagination) under packages/pagination/src/lib. Define a top-level section node schema mirroring DOCX w:sectPr: { type: 'section', sectPr: { pageSize, margins, columns, header?, footer? }, children: Descendant[] }. editor.children becomes Section[].
  2. Add BaseSectionPlugin, BaseHeaderPlugin, BaseFooterPlugin, BasePageBreakPlugin as sibling base plugins. Header / footer are children of Section, scoped to that section. Manual page_break is a block-level void node. Compose existing packages/footnote/ plugins for the footnote sub-feature.
  3. withPagination editor override (overrideEditor) enforces invariants: top level is always Section[]; non-section content gets wrapped on normalizeNode; nested sections flatten; orphan headers/footers move under their parent section.
  4. Auto-paginator runs as a debounced withNormalizeNode pass: walk a section's children, call pretext.prepare() + layout() to compute cumulative height, insert page_break nodes when content exceeds pageSize.height - margins - headerHeight - footerHeight. Manual page breaks always honoured.
  5. React layer: toTPlatePlugin(BaseSectionPlugin) renders each Section as a stack of fixed-size <div class="page"> between explicit page_break nodes. Header / footer render once per <div class="page">. Footnote definitions stay in document order at the section tail; footnote refs paint a marker; per-page placement of footnote bodies happens via portal driven by the auto-paginator's break positions.
  6. Print mode = no React change. The same <div class="page"> elements + @media print { .page { page-break-after: always; } } produce a deterministic PDF that exactly matches on-screen pagination — pretext-derived breaks are baked into the document tree, not recomputed per print.

Acceptance: opening a 500-block document materializes correct page breaks within 200ms; editing a paragraph re-runs the auto-paginator only on its containing section within 100ms; two Yjs clients with the same editor.children see identical paginations regardless of viewport; DOCX export round-trips section properties + header / footer references with byte-stable w:sectPr.

Because the code is

  • packages/core/src/lib/plugin/createSlatePlugin.ts:91-159 and :246-259createTSlatePlugin + overrideEditor are the canonical pair when the real ownership is editor behaviour and schema invariants.
  • packages/core/src/lib/plugin/SlatePlugin.ts:56-63 and :151-163decorate and inject.nodeProps exist; we don't need them. This variant prefers real schema over decoration.
  • packages/core/src/lib/plugin/BasePlugin.ts:213-229 (editOnly) — auto-paginator gets editOnly: { handlers: false, normalizeInitialValue: true } so static / read-only renders use last-good break positions without re-running.
  • packages/table/src/lib/withTable.ts (and friends in packages/table/) — direct precedent: a complex spatial structure managed via withNormalizeNode + dedicated transforms (withInsertFragmentTable, withNormalizeTable, withDeleteTable). Sections are simpler than tables.
  • packages/footnote/src/lib/BaseFootnoteDefinitionPlugin.ts (and Reference / Input) — composed, not re-authored. Footnote definitions become Section-scoped instead of document-scoped.
  • packages/utils/src/lib/plate-keys.tsKEYS.section, KEYS.header, KEYS.footer, KEYS.pageBreak, KEYS.pagination registered here.
  • .claude/skills/plate-plugin-creator/SKILL.md — Slate-first hard law explicitly satisfied: this variant gives pagination real document semantics and uses React only for chrome rendering.
  • Google docs - like pagination udecode/plate#4380 — OP danolekh (2025-06-19) shipped a working POC of this approach (BasePaginationPlugin with documentSettings, PageElement with explicit width / height, normalization wrapping non-page nodes); auto-flow was the single weakness, exactly the problem pretext solves.
  • pretext README (commit bb224e0, 2026-04-22) — pretext as cumulative-height oracle inside withNormalizeNode is its sweet spot: arithmetic-only after prepare(), no DOM reads.

Assumptions deliberately shuffled vs variant A

Axis Variant A (#353) Variant B (this)
Layer render-time overlay; pages derived document-model; section / page_break 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/table/src/lib/withTable.ts and packages/core/src/lib/plugin/{BasePlugin.ts,SlatePlugin.ts} — confirm withNormalizeNode semantics + editOnly + overrideEditor compose cleanly for Section[] invariants without infinite normalization loops on page_break insertion.
  • Review of our own code in packages/footnote/ — verify that scoping footnote definitions to a Section instead of the document tail does not break Reference linking or DOCX round-trip.
  • Comparative analysis vs sibling Plate pagination plugin (headers / footers / footnotes) with pretext (variant A) #353 — 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, deterministic shared pagination + DOCX section fidelity (favours B) or liquid editing UX + zero schema migration (favours A)? Do not pick a winner yet — name the axis.
  • Stress-test withNormalizeNode cost on the hot path: measure cost of running pretext-driven height accumulation inside normalization on every insert.
  • Selection / paste behaviour across Section / page_break boundaries — confirm Slate's withInsertFragment handles cross-section pastes without losing structure.

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.section, KEYS.header, KEYS.footer, KEYS.pageBreak to packages/utils/src/lib/plate-keys.ts.
  3. src/lib/: base-pagination-plugin.ts, base-section-plugin.ts, base-header-plugin.ts, base-footer-plugin.ts, base-page-break-plugin.ts, with-pagination.ts (override + normalize), auto-paginate.ts (pretext-backed cumulative-height pass), internal/font-from-style.ts, internal/measure-cache.ts, internal/section-invariants.ts, types.ts. Helpers in internal/.
  4. src/react/: pagination-plugin.ts (toTPlatePlugin), section-plugin.ts, header-plugin.ts, footer-plugin.ts, page-break-plugin.ts, page-element.tsx, section-element.tsx, header-element.tsx, footer-element.tsx, internal/use-pretext-measurer.ts.
  5. Compose with packages/footnote/: PaginationPlugin = toTPlatePlugin(BasePaginationPlugin, { plugins: [SectionPlugin, HeaderPlugin, FooterPlugin, PageBreakPlugin, FootnoteDefinitionPlugin, FootnoteReferencePlugin, FootnoteInputPlugin] }).
  6. Public surface: editor.api.pagination.{getSections, getPagesInSection, getPageOf, measure}; editor.tf.pagination.{insertSection, setSectionHeader, setSectionFooter, insertPageBreak, repaginateSection, repaginateAll}. Inference from createTSlatePlugin<Config>; no SlateEditor annotations.
  7. Schema migration: migrateLegacyDocument(doc): SectionDocument for adopters of pre-pagination docs; called once on normalizeInitialValue.
  8. Tests in packages/pagination/src/**/*.spec.ts with bun: invariant maintenance under fuzzed edits, auto-paginator break positions with golden cases, DOCX section round-trip.
  9. DOCX import/export: full w:sectPr mapping; header / footer reference resolution; footnote scoping per section.
  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) — OP's POC + maintainer commentary on container-node trade-offs
  • packages/core/src/lib/plugin/{createSlatePlugin.ts, SlatePlugin.ts, BasePlugin.ts}
  • packages/core/src/react/plugin/toPlatePlugin.ts
  • packages/table/src/lib/withTable.ts and friends — closest precedent for normalize-driven structural plugin
  • 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

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions