diff --git a/docs/plans/2026-05-22-pagination-B-line-projection.md b/docs/plans/2026-05-22-pagination-B-line-projection.md new file mode 100644 index 0000000000..5ee24fd443 --- /dev/null +++ b/docs/plans/2026-05-22-pagination-B-line-projection.md @@ -0,0 +1,156 @@ +# Pagination — Approach B (line projection, page boxes) implementation plan + +**Decision:** implement **B** — premirror-style page-box pagination: one continuous +contenteditable, absolute page-chrome overlay, blocks split across pages at **line +boundaries** (pretext), content aligned into page boxes, and **selection projected** +across page boundaries. Full-fidelity, Word-class. + +A junior dev proposed a "two modes" alternative (edit = continuous flow + faded +break-lines; print = heavy authoritative pass). We considered it; we are going +with B and pressure-testing that choice (two model reviews biased to B, pros/cons +pending). This plan is B. + +`north-star reaffirmed: laws` (derived overlay; document model never mutates). + +Builds on the green stack #408–#413 (scorch → pretext measure → place-whole compose). +**Note:** B reverses PR6's place-whole simplification — compose returns to +line-level fragmentation (now driven by real pretext lines, not the old estimate). + +## The keystone risk (read first) + +premirror's B positions split content with **ProseMirror decorations** +(`packages/react/src/index.tsx:153-154`: "Content fragments are positioned by +ProseMirror decorations, not a duplicated text layer"). **Slate cannot do this** — +Slate's `decorate` only styles existing ranges in place; it cannot relocate a +paragraph's tail onto another page box. So Slate-B substitutes: + +1. **Line-boundary margin spacers** — to visually split a block across page boxes, + inject computed `margin` at the exact line where the page breaks, pushing the + remaining lines down to the next page's content-top (+ inter-page gap). The + editable stays one DOM tree (native editing); the *visual* flow gains gaps at + page boundaries, even mid-block. +2. **Projected selection** — because content now spans visual gaps, a Slate range's + highlight/caret rects must be projected per page (premirror's + `collectRectsForPmRange`, `react/index.tsx:265`), not left to the browser's + native range rects (which would draw through the gap). + +These two are B's hard parts and the bulk of the risk/effort. + +## Architecture (footnote-style packaging) + +``` +packages/pagination/src/ + lib/ # BASE — Slate/headless, no React, measurement INJECTED + BasePaginationPlugin.ts # createTSlatePlugin; WeakMap layout registry (footnote pattern) + registry.ts # WeakMap; dirty-on-apply, lazy rebuild + snapshot.ts compose.ts mapping.ts projection.ts types.ts # pure pipeline + react/ # REACT + PaginationPlugin.tsx # toPlatePlugin(Base); supplies pretext MeasureFn; useHooks(recompute) + PaginationOverlay.tsx # absolute page chrome (boxes, numbers, header/footer zones) + alignFragments.ts # NEW: line-boundary margin spacers (Slate substitute for PM deco) + useProjectedSelection.ts# NEW: Slate range -> per-page screen rects (collectRectsForPmRange analog) + domMeasure.ts pretext.ts geometry.ts + static/ # PRINT — serializeHtml + @page (authoritative export) +``` + +- **Base** owns the derived layout in a `WeakMap` + (footnote `lib/registry.ts` pattern): `editor.apply` override marks dirty on + content mutations; layout is rebuilt lazily on read. Measurement is injected so + base stays pure. +- **React** lifts via `toPlatePlugin`, provides the pretext/DOM `MeasureFn`, runs the + pipeline on change, and renders: page chrome (`render.afterEditable`) + the + positioning host (`render.aboveEditable`) + the spacer alignment + projected + selection. +- **Static** path renders `layout.pages` as fixed-size containers with `@page` + + `break-inside: avoid` for print/PDF. + +## Phased build (TDD, stacked PRs on #413) + +- **PR-B1 — compose: line fragmentation.** Reinstate splitting at line granularity, + driven by `measureTextLines` line counts (not the removed estimate). A splittable + block → `BlockFragment[]` (`lineStart`/`lineCount`/`y` per page); atomic blocks + place whole; manual breaks + keepWithNext preserved. *Pure, TDD:* compose.spec + asserts fragment boundaries for a block spanning N pages. +- **PR-B2 — measure: per-line offsets.** Expose cumulative line-bottom offsets per + block (from pretext) so compose knows the exact mid-block break Y. *Pure, TDD.* +- **PR-B3 — projection: fragment rects.** `fragmentRects` maps each fragment to its + absolute page-frame rect (exists; verify against line fragments). *Pure, TDD.* +- **PR-B4 — React: page chrome overlay.** `PaginationOverlay` + host: absolute white + pages + numbers from `getPageGeometry`. *dev-browser:* N empty pages render. +- **PR-B5 — React: line-boundary spacers (the PM-decoration substitute).** Inject + margins at fragment boundaries so a split block's later lines align to the next + page's content-top. *dev-browser:* a block taller than a page visually splits + cleanly across page boxes (no bleed, no clone). +- **PR-B6 — projected selection.** `useProjectedSelection`: map the Slate selection + to per-page rects across gaps; render highlight + caret. *dev-browser:* select + across a page boundary; caret lands correctly on both sides of the gap. +- **PR-B7 — plugin packaging.** Footnote-style Base + WeakMap registry + React lift; + `viewMode` option; public API (`.` base/queries, `/react` plugin+overlay+hooks). + *dev-browser:* register `PaginationPlugin` alone → it works. +- **PR-B8 — static/print path.** `serializeHtml` + `@page` + `break-inside`. Verify + print/PDF output paginates. +- **PR-B9 — migrate template + cleanup.** Replace scratch demo with the plugin; + redeploy; final dev-browser pass. + +## Test strategy +Pure layers (compose/measure/mapping/projection/selection-rect math) are TDD +red-green with canvas-stubbed pretext (deterministic). Render + selection phases +(B4–B7) are verified in `dev-browser` (page geometry, mid-block split, cross-gap +selection are inherently visual). Fixture tiers smoke/core/stress with declared +expected page count + break events; determinism gate on the pure layout. + +## Model review consensus (glm-5.1 + deepseek-v4-pro, both biased to B) + +Both confirm **B is correct**; two-mode is rejected as the wrong trade. Verdicts: +- "Two-mode trades **all fidelity** for simplicity — what Google Docs did ~2010 and + spent a decade replacing." (glm) +- "If you accept the two-mode compromise you may as well not build pagination — + just use CSS `@page` and call it done." (deepseek) +- Two-mode's fatal flaws: not WYSIWYG (edit≠print line breaks), no widow/orphan + control without real measurement, can't show a paragraph split across pages, and + PRINT mode is read-only (can't fix a widow there). + +**De-risking findings (both):** +- **No Slate fork needed.** premirror explicitly deferred "true multi-root edit + surfaces" (`design-proposal.md:97`); the single-contenteditable + page-box-overlay + model works with standard slate-react. +- **All Slate APIs keep working.** Doc model is unchanged, so `Editor.above/node/parent` + operate on the source tree regardless of which page the caret is visually on — the + projection is purely visual. +- **IME anchors naturally** in one continuous contenteditable (single text flow); + lower risk than feared — but CJK IME testing is mandatory. +- **Infra already exists:** `BlockFragment.lineStart/lineCount` + `splittable` in + `types.ts`, `measureTextLines` in `pretext.ts`, `mapping.ts`/`projection.ts`. The + port is mostly *upgrade compose block→line + add selection projection + incremental*. + +## Hardest risks (ranked by both models, with premirror's answer) + +1. **🔴 Synthetic/projected selection across pages (CRITICAL).** Native selection + can't draw across absolutely-positioned page boxes. premirror hides native + `::selection` and **paints** rects via `collectRectsForPmRange` + `useProjectedSelection` + (`react/index.tsx:265-344`). Slate version: intercept selection change, map + path-based `editor.selection` → fragment coords via `MappingIndex.fragmentOfBlockLine` + (`mapping.ts:56`), render a synthetic highlight overlay from `fragmentRects` + (`projection.ts:31`). Native caret/typing stays native; only the highlight is painted. → PR-B6. +2. **🔴 Per-edit recompose perf / incremental invalidation (HIGH).** Every keystroke = + snapshot→measure→compose→re-render, and a naive `MappingIndex` rebuild is **O(pages)**. + premirror uses an invalidation plugin tracking the changed range + (`prosemirror-adapter/src/index.ts:59-81`), prepared-run caching, and rAF batching + (`design-proposal.md:423-435`). Slate version: inspect `editor.operations` to find + dirty blocks, measure only those (cache already keyed by id+width), patch the mapping + incrementally. → cross-cutting; lands as **PR-B10 (incremental)** after correctness. +3. **No Slate positioning decoration** → margin-spacer splitting (PR-B5). premirror + aligns content into page boxes via line-boundary spacers (the same conclusion); + we do it with computed margins since Slate has no widget decoration. +4. **Slate→snapshot offset-preserving adapter (labor, not concept).** premirror's + snapshot carries `StyledRun{text,font,marks,pmRange}`; Slate has nested paths. For + caret↔layout mapping we must flatten Slate text into runs **preserving text offsets**. + glm: "single largest porting risk — labor-intensive." → folded into PR-B1/PR-B2. +5. **IME/caret near gaps** — test CJK (Google JP, macOS Pinyin) in dev-browser at PR-B6. + +(Adds **PR-B10 — incremental invalidation** to the phase list: track dirty blocks via +`editor.operations`, measure-only-dirty, patch `MappingIndex`. Ships after B1–B9 correctness.) + +## Status +Plan complete; both biased-B model reviews folded in (consensus: proceed with B). +No code yet — awaiting go on **PR-B1** (compose block→line fragmentation, TDD). diff --git a/docs/plans/2026-05-22-pagination-B-plan.md b/docs/plans/2026-05-22-pagination-B-plan.md new file mode 100644 index 0000000000..def97c5ec3 --- /dev/null +++ b/docs/plans/2026-05-22-pagination-B-plan.md @@ -0,0 +1,69 @@ +# Pagination — Plan B (page-box line projection), findings-infused + +Implements **B**: WYSIWYG paginated editing — page boxes visible while editing, +blocks visually split across pages. Structured so **Phase 0 (Shared Foundation) +is identical to the two-mode plan's base** — only the render layer (Phase B*) +differs. Builds on the green stack #408–#413. + +## Cross-cutting invariants (all six model reviews + premirror agree) +- **Document never mutates** — derived overlay. (yjs-safe by construction: no + page nodes, no reflow ops cross the wire.) +- **ONE continuous editable, never fragmented / multi-root.** premirror + explicitly defers "true multi-root page edit surfaces" (`design-proposal.md:97-101`). + Native selection / IME / a11y / find stay native because the DOM is one tree; + page geometry is *visual* (chrome behind + spacer gaps), not separate DOM boxes. +- **pretext is the measurement core** (`measure/pretext.ts`) for break positions. +- **Print authority = `serializeHtml` + CSS `@page` + `break-inside`.** + +## Phase 0 — SHARED FOUNDATION (== two-mode base) +These PRs are the base both plans need; the two-mode plan starts from here. + +- **F1 — layout registry (footnote pattern).** `lib/registry.ts`: + `WeakMap`; `editor.apply` override marks dirty on + content-mutating ops (precedent: `footnote/registry.ts`, `slate-history/with-history.ts` + op-inspection); layout rebuilt lazily on read. Base stays pure (measurement injected). *TDD.* +- **F2 — break-Y emission.** compose walks blocks accumulating Y from pretext + `measureBlockHeight`; emits page boundaries (`breakYs`) incl. the mid-block line + where `currentY` crosses `contentHeight`. *Pure, TDD (canvas-stubbed pretext).* +- **F3 — pipeline-as-authority host.** `PaginationPlugin = toPlatePlugin(Base)`, + `useHooks` runs snapshot→measure→compose on change (rAF-batched; precedent + `selection/useRequestReRender`), writes layout to the registry. No render yet. +- **F4 — print-parity gate (THE thing to nail first; named by both two-mode reviews).** + Headless-Chrome test: `serializeHtml` a fixture → render with `@page` → extract real + page-break Ys → assert within **±1 line** of pretext `breakYs`. Locks the premise + for *both* plans. *dev-browser/headless.* +- **F5 — static print path.** `static/` render: `serializeHtml` + `@page` + `break-inside` + on atomic blocks. Authoritative PDF/print. Shared by both plans. + +## Phase B* — B-SPECIFIC RENDER (on top of Phase 0) +- **B1 — page-box chrome overlay.** `render.afterEditable` → absolute white A4 boxes + + numbers from `getPageGeometry`, rendered *behind* the editable. *dev-browser.* +- **B2 — gap spacers via `aboveNodes`.** Wrap each page-start block with a margin + spacer pushing it to the next page's content-top (doc-non-mutating; precedent + toggle/list `aboveNodes`, `PlatePlugin.ts:447`). One DOM tree → native selection + intact. *dev-browser: blocks align into page boxes, caret/selection native.* +- **B3 — mid-block split spacer (the one genuinely novel bit).** For a block crossing + a page boundary, inject an in-block gap at the break line via `decorate`+custom-leaf + (or `belowRootNodes` sub-slot) so its later lines align to the next page top. + *dev-browser: a tall paragraph splits across two boxes; caret correct on both sides.* +- **B4 — cross-gap selection cosmetics (optional).** Native selection already works + (one tree); only the highlight crossing a gap looks odd. Paint supplemental rects + via `getRangeBoundingClientRect`/`getSelectionRects` (`packages/floating`/`cursor`) + + block-selection's `afterEditable` overlay pattern. NOT a native-selection replacement. +- **B5 — packaging + `viewMode:'paged'`.** Public API: `.` base/registry/queries, + `/react` plugin+overlay+hooks. *dev-browser: register plugin alone → paged editing works.* + +## Explicitly deferred (premirror defers it too) +Fragmented page-box DOM containers + full projected-selection replacement + the +~400-line bidirectional MappingIndex. Not needed while the editable stays one tree. + +## Risk → in-repo prior art (every hard piece has a template) +- positioning into pages → `aboveNodes` (toggle/list) +- selection rects → `packages/floating` `getRangeBoundingClientRect`, `packages/cursor` `getSelectionRects`, `packages/selection` overlay +- incremental invalidation → `footnote/registry.ts` + `slate-history` op-inspection + `selection/useRequestReRender` +- residual novel: **mid-block split spacer (B3)** only. + +## Compute / yjs / UX (summary) +- **Compute:** per-edit snapshot→pretext-measure(dirty only)→compose→rAF render. pretext is canvas/WASM-fast; dirty-block measure keeps it ~O(changed). Spacer reflow on layout change. +- **yjs:** safe — zero document mutation; layout is per-client derived state (WeakMap), never replicated. +- **UX:** WYSIWYG page boxes while editing; native selection/IME/find; mid-block splits visible. Cost: per-edit recompute; spacer/scroll alignment must be exact. diff --git a/docs/plans/2026-05-22-pagination-research.md b/docs/plans/2026-05-22-pagination-research.md new file mode 100644 index 0000000000..b85ebd6bb0 --- /dev/null +++ b/docs/plans/2026-05-22-pagination-research.md @@ -0,0 +1,399 @@ +# Pagination Exploratory Research + +**Date:** 2026-05-22 +**Scope:** `@platejs/pagination` — derived overlay pagination for Slate editor + +--- + +## Part 1: Blocks Taller Than One Page — Alternatives + +### Current State (Option C: Place-Whole + Overflow) + +**Code:** `packages/pagination/src/layout/compose.ts:63-81` + +The composer treats top-level Slate blocks as atomic placement units. Logic: +- If block fits remaining space → placed on current page +- If block exceeds remaining space but fits a full page → moved to next page +- **If block exceeds a full page → placed at top and overflows** (the problem) + +```typescript +// compose.ts:67 +if (b.heightPx > frameHeight - currentY && fragments.length > 0) { + breakToNewPage('block_overflow'); +} +// If still doesn't fit even on fresh page, placed anyway — no height guard. +``` + +The types DO anticipate splitting (`BlockFragment` has `fragmentIndex`, `lineStart`, `lineCount` — `packages/pagination/src/layout/types.ts:105-122`), but the compose logic doesn't split yet. + +### Premirror Reference (Option B: Line-Level Splitting) + +**Code:** `../premirror/packages/composer/src/index.ts:349-506, 666-771` + +Premirror's composer works at **line granularity**: +1. `breakBlockIntoLineDrafts()` (L349): breaks block text into `LineDraft[]` — each line has `PlacedRun[]` with absolute x/width + `pmRange` for selection mapping +2. `linesThatFitFirstFragment()` (L512-541): decides how many lines fit with widow/orphan protection +3. The `while (lineCursor < drafts.length)` loop (L695-770) creates multiple `BlockFragment`s per block, each with `lines: LineBox[]` + +Each `LineBox` carries absolute positioning (`y`, `height`) and `pmRange`, enabling selection projection via `buildMappingIndex` (L556-598) → `pmPosToLayout` / `layoutToPmPos`. + +Premirror's rendering (`../premirror/packages/react/src/index.tsx:156-263`) uses: +- Absolutely-positioned page surfaces (white boxes with shadows) +- A single editor overlay (`pointerEvents: "none"` container, `pointerEvents: "auto"` on inner surface) +- Editor is positioned at (0,0) of the page stack — ProseMirror decorations handle per-page positioning +- `useProjectedSelection` (L328-344) maps PM selection to layout-space rects for a projected caret + +### Alternative Approaches — Ranked Analysis + +#### 🥇 #1 Composer Split + Overlay Clipping (Score: 9/10) + +**What:** Modify `composeLayout` to actually split blocks that overflow, creating multiple `BlockFragment`s per block (leverages existing `fragmentIndex`/`lineStart`/`lineCount` fields in `types.ts:105-122`). The overlay page-chrome renderer applies `overflow: hidden` or `clip-path` per page frame to visually clip content at page boundaries. The editable DOM still has the whole block — only the visual overlay clips it. + +**Why it works for editable Slate:** +- Editable DOM unchanged — editing, selection, cursor all work natively +- Selection projection: `buildMappingIndex` (mapping.ts:34) already supports `fragmentOfBlockLine` — extend to map Slate `path+offset` to layout coordinates +- Pretext line measurement (pretext.ts:34-69) already gives us `MeasuredLine[]` with cursor ranges (`start`/`end: LineCursor` — segment+grapheme indices) — the raw material for precise split points + +**Implementation complexity:** Medium (3-4 days) +- Phase 1: Gutter masks only (1d — hide overflow visually, no pipeline change) +- Phase 2: Add split logic to `composeLayout` (2-3d — premirror's `while (lineCursor < drafts.length)` pattern as reference) + +**Visual fidelity:** Excellent — clean page breaks at line boundaries, no content bleed + +--- + +#### 🥈 #2 Gutter Mask Overlays (Score: 8.5/10) + +**What:** Add opaque absolutely-positioned divs between page frames that cover the overflow content. `pointerEvents: none` so typing works through them. This is purely visual — zero pipeline changes. + +**Why first:** One day of work. Hides the ugly bleed. Buys time for the full split implementation. + +**Limitations:** Content still overflows, just hidden. If a block is 2 pages tall with text in the bottom half, the text on page 2 is visually covered by the first page's gutter. Acceptable as a stopgap. + +**Code shape:** +```tsx +// react/PaginationGutterMask.tsx — Rendered in afterEditable slot +{pages.map((_, i) => i < pages.length - 1 && ( +
+))} +``` + +--- + +#### 🥉 #3 Line-Level Fragment Clipping (Score: 8/10) + +**What:** Same as #1 but split at line granularity using pretext's measured lines. The `measureTextLines()` function (pretext.ts:34-69) already returns `MeasuredLine[]` with `start`/`end` cursor positions — exactly what's needed for clean page breaks at line boundaries and for projected selection. + +**Advantage over #1:** Clean break at the last complete line, no partial-line clipping. Works with the existing pretext pipeline. + +**Extra work:** Need a line-start-to-Slate-offset mapping (the pretext cursor → Slate offset converter), which the pretext cursor types already support. + +--- + +#### #4 Visual Fragments via Overlay Windows (Score: 7.5/10) + +**What:** Create absolutely-positioned `div` elements with `overflow: hidden` on the overlay, each matching a page frame, positioned over the editable. Think "page-shaped windows" over the continuous editable scroll. + +**Pros:** No pipeline changes, works with any content height. +**Cons:** The editable spans across all "windows" — scrolling, selection, and cursor queries need to account for the visual split. The Slate editor's position-to-query methods (like `findEventRange`) would need to translate screen coordinates back through the overlay geometry to the underlying edit offset. + +--- + +#### #5 Read-Only Clones (Score: 7/10) + +**What:** Clone the overflow portion of a block into a read-only DOM fragment, displayed in a separate layer. The original editable DOM retains the full block. + +**Pros:** Visual correctness. The clone sees the text but it's inert. +**Cons:** Two DOM trees to keep in sync. If the user edits the block, the clone must update. Selection on the clone is impossible (by design), but that means the user can't click into the overflow portion to edit there — they must navigate via keyboard or scroll the editable. + +--- + +#### #6 Extended Spacer Scheme (Score: 6.5/10) + +**What:** We already use `margin-top` spacers (alignContent.ts:56-69) to push page-starting blocks into alignment. Extend this to insert spacer values that create total vertical separation between pages. + +**Already implemented** — this is how the current pipeline works. +**Can't solve alone:** Spacers push blocks down but don't split tall blocks. A 600px block on a 400px page will still overflow regardless of spacer values. + +--- + +#### CSS-Only Approaches (Scores ≤4/10) — Why They Fail + +| Approach | Score | Why it fails for editable | +|----------|-------|--------------------------| +| `break-inside: avoid/auto` | 4/10 | Only works in multicol or print contexts. Does nothing in normal flow. | +| `column-count` / `column-fill` | 3/10 | Splits into fixed-width columns, not pages. No way to control break positions per-block. Breaks cursor/selection. | +| CSS Regions (`flow-into`/`flow-from`) | 1/10 | Removed from spec. No browser support. | +| `@page { size: A4 }` + `page-break-after` | 3/10 | Print-only. Renders paginated in `@media print`, not on screen. | +| `container-type: size` | 2/10 | Can't query whether a block overflowed a page boundary — container queries don't expose "is content taller than container?" | +| `content-visibility: auto` | 2/10 | Offscreen optimization, not pagination. | +| `view-timeline` / scroll-driven animations | 2/10 | Can detect scroll position but not content-over-page-boundary events. | +| Canvas-based rendering | 3/10 | Loses all editability. You'd need to reimplement text input from scratch. | + +**Fundamental reason CSS alone can't work:** Browsers do not paginate live editable DOM stream. Print pagination uses a separate layout pass that doesn't apply to on-screen rendering. CSS fragmentation properties (`break-*`, `column-*`) operate on static boxes, not `contenteditable` DOM where every keystroke reflows the tree. + +### Recommended Path + +| Phase | What | Time | Delivers | +|-------|------|------|----------| +| 1 | Gutter mask overlays (#2) | 1 day | No visual bleed → usable demo | +| 2 | Composer split logic (#1) | 3 days | Fragmented blocks with clean page breaks | +| 3 | Line-aligned clipping (#3) | 2 days | Precise line-boundary breaks + projected selection | + +Total: ~6 days to production-quality pagination with clean page breaks. + +--- + +## Part 2: Plugin Architecture — How `@platejs/pagination` Should Be Structured + +### Plate's Plugin Architecture Conventions + +#### Base (Slate/headless) vs React Split + +**Convention:** Every plugin has a `Base*Plugin` (headless, `createSlatePlugin`) and a `*Plugin` (React wrapper, `createPlatePlugin`/`toPlatePlugin`). + +**Pattern confirmed** in the subtask research. The React wrapper is a thin remap: + +```typescript +// src/lib/BaseFooPlugin.ts (headless) +export const BaseFooPlugin = createSlatePlugin({ + key: 'foo', + // Slate-level API, transforms, node definitions + extendEditorApi: ({ editor }) => ({ foo: { doThing() {} } }), + node: { isElement: true }, +}) + +// src/react/FooPlugin.ts (React) +export const FooPlugin = toPlatePlugin(BaseFooPlugin, { + render: { + node: FooComponent, // React render for this node type + afterEditable: OverlayComponent, // React-only render slots + }, + useHooks: ({ editor }) => {}, // React hooks +}) +``` + +The base plugin is **pure, testable, non-React.** The React wrapper adds DOM-rendering concerns, hooks, and editor chrome. + +#### Static Rendering Path + +**Code:** `packages/core/src/static/plugins/ViewPlugin.ts` (copy handler), `serializeHtml` → `PlateStatic` component. + +Static rendering (`pipeRenderElementStatic`) is for SSR/export — it renders a **non-interactive** HTML string from the editor value. It uses render slots (`aboveNodes`, `belowNodes`) but without any editor instance. This is where a `serializePaginationHtml` output would go (generating page-stamped HTML for export). + +For pagination: static render could produce HTML with actual `
` wrappers split at page boundaries — useful for PDF/print export. This is a **separate concern** from live editing. + +#### Derived-Content Plugin Patterns + +Plate has four established patterns for content not stored in the document model: + +| Pattern | Example | File | How it works | +|---------|---------|------|--------------| +| **afterEditable overlay** | CursorOverlay | `packages/selection/src/react/CursorOverlayPlugin.tsx` | Renders in `afterEditable` slot, absolutely positioned, uses browser rects for layout | +| **External React hooks** | TOC sidebar | (TOC plugin — external hooks: `useTocSideBar`, `useTocController`) | Hooks consume editor state externally, render in separate React tree | +| **Inline void nodes** | Footnote references | (Footnote: `BaseFootnoteReferencePlugin` + `BaseFootnoteDefinitionPlugin`) | Void inline `` marks the position; definition lives in separate void block | +| **Leaf mark + editor override** | Suggestion | (Suggestion plugin: `withSuggestion` capture-remap) | Leaf marks carry derived metadata; editor `insertText`/`deleteBackward` overrides manage state | + +**CursorOverlay is the closest analog for pagination** — it: +1. Uses `createTPlatePlugin` (`packages/selection/src/react/CursorOverlayPlugin.tsx:38`) +2. Renders nothing into the editable itself +3. Positions overlay elements via absolute coordinates derived from editor state +4. Uses `usePluginOption` for reactive state (`CursorOverlayPlugin.tsx:119`) +5. Override editor transforms to capture derived state (`setSelection` override at L66-78) + +### Proposed Pagination Architecture + +``` +@platejs/pagination/ +├── src/ +│ ├── index.ts # Barrel — re-exports from lib/ + react/ +│ ├── lib/ # PURE — headless, no React, no DOM +│ │ ├── index.ts # Barrel +│ │ ├── BasePaginationPlugin.ts # createSlatePlugin +│ │ ├── layout/ +│ │ │ ├── compose.ts # composeLayout (pure, block-level split) +│ │ │ ├── snapshot.ts # buildSnapshot (Slate value → UnmeasuredSnapshot) +│ │ │ ├── mapping.ts # buildMappingIndex (LayoutOutput → MappingIndex) +│ │ │ ├── projection.ts # fragmentRects, blockLinePosition (pure) +│ │ │ └── types.ts # All layout types, page specs +│ │ └── measure/ +│ │ ├── measure.ts # measureSnapshot (pure, MeasureFn injected) +│ │ └── pretext.ts # measureTextLines, measureBlockHeight +│ │ +│ └── react/ # REACT layer — DOM, rendering, hooks +│ ├── index.ts # Barrel +│ ├── PaginationPlugin.ts # toPlatePlugin(BasePaginationPlugin, {...}) +│ ├── PaginationOverlay.tsx # Page chrome + gutter masks (afterEditable) +│ ├── PageFrame.tsx # Per-page frame with overflow:hidden clip +│ ├── domMeasure.ts # createDomMeasure (DOM-backed MeasureFn) +│ ├── geometry.ts # getPageGeometry, getBlockPlacements (stacked) +│ ├── alignContent.ts # alignContentToLayout (margin-top spacers) +│ ├── usePagination.ts # Main hook: snapshot→measure→compose→render +│ └── useProjectedSelection.ts # Caret projection into page coordinates +``` + +### Component Placement in the Editor Tree + +``` + + {/* the editable DOM */} + + {/* Slate blocks — only modified with margin-top spacers */} + ... + ... + + + {/* afterEditable render slot — pagination overlay lives here */} + {/* position: absolute; top: 0; pointer-events: none */} + {/* position: absolute; overflow: hidden — clips content */} + {/* white box, shadow, border */} + {/* covers overflow at page bottom */} + + + ... + +
+ {/* positioned caret visualization */} +
+
+
+``` + +Key design decisions: +- **afterEditable render slot** (`packages/core/src/lib/editor/SlateEditor.ts:87`): Overlay rendered as sibling to the editable, at same origin. Matches CursorOverlay pattern. +- **`pointer-events: none` on overlay container**: Content doesn't block typing. `pointer-events: auto` only on interactive overlay elements (premirror does this at `premirror/packages/react/src/index.tsx:247-256`). +- **The editable DOM is only modified via margin-top spacers** (alignContent.ts:56-69). No injected wrapper divs, no `contenteditable` changes. +- **Composes with other plugins**: any node-type plugin (bold, heading, etc.) renders normally inside the editable. Pagination only adds chrome outside it. + +### Base Plugin API Shape + +```typescript +// lib/BasePaginationPlugin.ts +export const BasePaginationPlugin = createSlatePlugin({ + key: KEYS.pagination, + options: { + page: A4_PAGE_PX, + margins: DEFAULT_PAGE_MARGINS, + policies: { + widowLinesMin: 2, + orphanLinesMin: 2, + keepWithNextEnabled: true, + }, + atomicTypes: ['table', 'img', 'hr'], + keepWithNextTypes: ['h1', 'h2', 'h3'], + }, +}) + .extendApi(({ editor, plugin, type }) => ({ + pagination: { + // Core pipeline (pure — used by both headless and React paths) + buildSnapshot: (value?: Descendant[]) => buildSnapshot(value ?? editor.children, {...}), + measureSnapshot: (snapshot, measureFn) => measureSnapshot(snapshot, measureFn, {...}), + composeLayout: (measured, input) => composeLayout(measured, input), + + // Block-level pagination controls (transforms) + toggleKeepWithNext: (path: Path) => { /* set node.keepWithNext */ }, + toggleBreakBefore: (path: Path) => { /* set node.breakBefore */ }, + + // Selection projection (headless, no DOM) + getPageOfBlock: (blockIndex: number) => number | null, + getSelectionPage: () => number | null, + }, + })) + .overrideEditor(({ editor, tf }) => ({ + transforms: { + // Keep insertBreak from breaking keepWithNext pairs + insertBreak() { + // ... preserve keepWithNext when breaking blocks + tf.insertBreak(); + }, + }, + })); +``` + +### React Plugin API Shape + +```typescript +// react/PaginationPlugin.ts +export const PaginationPlugin = toPlatePlugin(BasePaginationPlugin, { + render: { + afterEditable: PaginationOverlay, + // aboveEditable could hold the spacer container if needed + }, + useHooks: ({ editor, plugin }) => { + // Main reactive pipeline + const { layout, geometry, diagnostics } = usePagination({ + editor, + plugin, + }); + + // Apply margin spacers on every layout change + useLayoutEffect(() => { + const editable = editor.getEditableElement(); + if (!editable || !layout) return; + alignContentToLayout(editable, layout, input); + }, [layout]); + + return { layout, geometry, diagnostics }; + }, +}); +``` + +### Public API Surface (What Consumers Import) + +```typescript +// Plugin registration +import { PaginationPlugin } from '@platejs/pagination/react'; + +// Headless (non-React) path +import { BasePaginationPlugin } from '@platejs/pagination'; +import { buildSnapshot, composeLayout, measureSnapshot } from '@platejs/pagination'; + +// React hooks +import { usePagination, useProjectedSelection } from '@platejs/pagination/react'; + +// Types +import type { LayoutOutput, PageLayout, BlockFragment } from '@platejs/pagination'; + +// Page specs +import { A4_PAGE_PX, LETTER_PAGE_PX } from '@platejs/pagination'; + +// Static rendering (future) +import { serializePaginationHtml } from '@platejs/pagination/static'; +``` + +### Static Rendering Path (Future) + +Following `packages/core/src/static/` patterns, add a `static/` export: + +```typescript +// static/serializePaginationHtml.ts +// Uses pipeRenderElementStatic to produce page-wrapped HTML: +//
...blocks...
+// No editor, no interactivity — for export/print/PDF +``` + +### How It Composes With Other Plugins + +- **Normal node plugins** (bold, heading, list, etc.): No interaction needed. Pagination reads the Slate value via `editor.children`, runs the pipeline, and positions chrome outside the editable. Node rendering is unaffected. +- **Footnote plugin:** Footnote definitions (void blocks) would be treated as atomic blocks (placed whole, not split). Footnote references (inline void ``) are inside block text — split at their line position like any inline text. +- **Suggestion/comment plugin:** Leaf marks are transparent to pagination (the snapshot only reads text). The overlay doesn't intersect with mark rendering. +- **Table plugin:** Marked as `atomicType` — never split, placed whole. If taller than a page, overflows (until phase 2 split handles it). +- **Comment sidebar:** A separate afterEditable slot that stacks below the pagination overlay (or above it, depending on z-index layering). + +### Why This Architecture + +1. **Match Plate convention**: Base/React split is the established pattern. Pagination should follow it. +2. **Headless testability**: `composeLayout`, `buildSnapshot`, `measureSnapshot` are pure functions — testable with Jest, no browser needed. +3. **CursorOverlay precedent**: `afterEditable` render slot + absolute positioning is proven in Plate for derived visual content. +4. **No model mutation**: The document model stays clean. Pages are pure projection. This is the foundation the whole design rests on. +5. **Separate concerns**: `lib/` is the engine (layout, measurement), `react/` is the chassis (DOM, rendering, hooks). Each layer can evolve independently. +6. **Prefigures static rendering**: When pagination gets a `serializePaginationHtml`, the pure pipeline in `lib/` is directly reusable — the static path just swaps the render layer. diff --git a/docs/plans/2026-05-22-pagination-twomode-plan.md b/docs/plans/2026-05-22-pagination-twomode-plan.md new file mode 100644 index 0000000000..9851ce5e2c --- /dev/null +++ b/docs/plans/2026-05-22-pagination-twomode-plan.md @@ -0,0 +1,51 @@ +# Pagination — Plan Two-Mode (continuous edit + authoritative print) + +Implements **two-mode**: EDIT = one continuous editable in normal flow with thin +semi-faded break-lines at pretext-computed page-break Ys (native selection/IME/a11y/find); +PRINT = authoritative `serializeHtml` + CSS `@page`. Shares **Phase 0** verbatim +with Plan B (`2026-05-22-pagination-B-plan.md`). + +## Cross-cutting invariants (identical to Plan B) +- Document never mutates (yjs-safe). One continuous editable. pretext is the break + measurement core. Print authority = `serializeHtml` + `@page` + `break-inside`. + +## Phase 0 — SHARED FOUNDATION (same PRs as Plan B: F1–F5) +- **F1** layout registry (footnote `WeakMap`+dirty-on-apply, lazy rebuild). +- **F2** break-Y emission from pretext (incl. mid-block crossing line). +- **F3** pipeline-as-authority host (rAF-batched recompute → registry). +- **F4** print-parity gate: headless-Chrome test asserting pretext `breakYs` match + real `@page` page breaks within **±1 line**. (For two-mode this is THE credibility + gate — both two-mode reviews named it the one thing to nail first.) +- **F5** static print path (`serializeHtml` + `@page` + `break-inside`). + +## Phase T* — TWO-MODE-SPECIFIC RENDER (on top of Phase 0) +- **T1 — faded break-line overlay.** `render.belowRootNodes` (`PlatePlugin.ts:476`): + for the block whose accumulated Y crosses a page boundary, render a thin dashed + semi-faded full-width rule at the crossing Y + a "Page N" tooltip/tick. + `pointer-events:none`. No page boxes, no spacers, no gaps — pure continuous flow. + *dev-browser: faded lines fall at correct Ys; editing/selection fully native.* +- **T2 — `viewMode:'continuous'`** wiring + page-number gutter (optional). Toggling to + `'paged'` is Plan B's render (the two share the foundation, differ only here). +- **T3 — print-preview pane (optional).** Render the F5 `serializeHtml`+`@page` output + in an iframe/preview so headers/footers/exact breaks are viewable on demand. + +## Deliberately given up (both two-mode reviews concede) +- Mid-paragraph split *visualization* while editing (break-line shows block-level + crossing; the print path splits exactly). +- Pixel-exact WYSIWYG while editing (print is the source of truth). +- Multi-column edit view (print-layout concern). + +## Risk → mitigation +- **Break-line vs print drift (the critical one):** both modes consume the SAME + pretext measurement at the SAME `widthPx` (from `@page` content rect) and SAME font + (`getComputedStyle(editable).font`). The F4 ±1-line test enforces it. +- mode-switch UX → label break-lines "advisory"; print authoritative. +- headers/footers/multi-column → live only in the print path (F5) / preview (T3). + +## Compute / yjs / UX (summary) +- **Compute:** near-zero in edit mode — pretext line-count accumulation + a few + absolutely-positioned faded rules; no spacers, no projected selection, no per-edit + page reflow. Print path runs only on print/preview. +- **yjs:** safe — zero document mutation; break-Ys are per-client derived. +- **UX:** maximal native fidelity (selection/IME/find/a11y/spellcheck) + page-awareness + via break-lines. Cost: edit≠print page boxes (honest "edit view vs print"). diff --git a/docs/plans/2026-05-22-pagination-unified-plan.md b/docs/plans/2026-05-22-pagination-unified-plan.md new file mode 100644 index 0000000000..4f2e970e97 --- /dev/null +++ b/docs/plans/2026-05-22-pagination-unified-plan.md @@ -0,0 +1 @@ +PLACEHOLDER diff --git a/dogfood-output/screenshots/after-fix-full.png b/dogfood-output/screenshots/after-fix-full.png new file mode 100644 index 0000000000..dcf0a50294 Binary files /dev/null and b/dogfood-output/screenshots/after-fix-full.png differ diff --git a/dogfood-output/screenshots/after-fix-narrow.png b/dogfood-output/screenshots/after-fix-narrow.png new file mode 100644 index 0000000000..8e6f98372e Binary files /dev/null and b/dogfood-output/screenshots/after-fix-narrow.png differ diff --git a/dogfood-output/screenshots/initial.png b/dogfood-output/screenshots/initial.png new file mode 100644 index 0000000000..3cac4a6bbd Binary files /dev/null and b/dogfood-output/screenshots/initial.png differ diff --git a/dogfood-output/screenshots/issue-002-flash.png b/dogfood-output/screenshots/issue-002-flash.png new file mode 100644 index 0000000000..01b213471c Binary files /dev/null and b/dogfood-output/screenshots/issue-002-flash.png differ diff --git a/dogfood-output/screenshots/issue-004-narrow.png b/dogfood-output/screenshots/issue-004-narrow.png new file mode 100644 index 0000000000..d847d0c199 Binary files /dev/null and b/dogfood-output/screenshots/issue-004-narrow.png differ diff --git a/dogfood-output/screenshots/issue-A-step1.png b/dogfood-output/screenshots/issue-A-step1.png new file mode 100644 index 0000000000..3cac4a6bbd Binary files /dev/null and b/dogfood-output/screenshots/issue-A-step1.png differ diff --git a/dogfood-output/screenshots/issue-A-step2.png b/dogfood-output/screenshots/issue-A-step2.png new file mode 100644 index 0000000000..47e85f8b90 Binary files /dev/null and b/dogfood-output/screenshots/issue-A-step2.png differ diff --git a/dogfood-output/screenshots/issue-B-empty.png b/dogfood-output/screenshots/issue-B-empty.png new file mode 100644 index 0000000000..5ec27e2680 Binary files /dev/null and b/dogfood-output/screenshots/issue-B-empty.png differ diff --git a/dogfood-output/screenshots/issue-D-oversized.png b/dogfood-output/screenshots/issue-D-oversized.png new file mode 100644 index 0000000000..b0d2513754 Binary files /dev/null and b/dogfood-output/screenshots/issue-D-oversized.png differ diff --git a/tooling/e2e/pagination.spec.ts b/tooling/e2e/pagination.spec.ts new file mode 100644 index 0000000000..37245a76b0 --- /dev/null +++ b/tooling/e2e/pagination.spec.ts @@ -0,0 +1,157 @@ +import { expect, test } from '@playwright/test'; + +// ============================================================ +// E2E: @platejs/pagination continuous-view overlay +// +// Locks in the user-visible behavior the 2026-05-23 dogfood pass surfaced and +// the "margin-aware packing + overlay polish" fix (commit e7be784) resolved. +// Each test maps to a dogfood issue: +// +// ISSUE-001 break lines land on the true A4 boundary, no accumulating drift +// ISSUE-002 the advisory lines render on load (no permanent missing-overlay) +// ISSUE-003 a "Page 1 of N" marker + "Page K of N" labels with a real total +// ISSUE-004 labels stay on-screen when a narrow viewport overflows the page +// +// Plus two invariants the dogfood verified by hand: zero console errors, and an +// overlay that never intercepts pointer events (native editing untouched). +// +// Geometry facts (BasePaginationPlugin defaults, asserted against the demo): +// page height 1123px, top+bottom margin 96px each -> 931px content per page. +// Break tops are explicit inline `top` px values in the editable's offset frame, +// so the page-1 marker top is the origin and `break[i].top - markerTop` is the +// content-space distance to the start of page i+2. +// ============================================================ + +const ROUTE = '/dev/pagination2'; +const CONTENT_PER_PAGE = 931; // 1123 - 96 - 96 + +const BREAK_LINE = '[data-slot="pagination-break-line"]'; +const PAGE_MARKER = '[data-slot="pagination-page-marker"]'; +const LABEL = '[data-slot="pagination-break-label"]'; +const CONTAINER = '[data-slot="pagination-break-lines"]'; + +/** Read the explicit inline `top` (px) of an absolutely-positioned overlay node. */ +const topOf = (handle: { + evaluate: (fn: (el: SVGElement | HTMLElement) => R) => Promise; +}) => handle.evaluate((el) => Number.parseFloat((el as HTMLElement).style.top)); + +test.describe('pagination continuous-view overlay', () => { + test('ISSUE-002: advisory break lines render on load', async ({ page }) => { + await page.goto(ROUTE); + + // The overlay computes in a layout effect after hydration; the lines must + // actually appear (the regression was content with no lines). + await expect(page.locator(BREAK_LINE).first()).toBeVisible(); + expect(await page.locator(BREAK_LINE).count()).toBeGreaterThan(0); + }); + + test('ISSUE-003: a "Page 1 of N" marker and consistent "Page K of N" labels', async ({ + page, + }) => { + await page.goto(ROUTE); + await expect(page.locator(BREAK_LINE).first()).toBeVisible(); + + const breakCount = await page.locator(BREAK_LINE).count(); + const total = breakCount + 1; + + // Page 1 is explicitly marked (its absence was the dogfood complaint). + await expect(page.locator(`${PAGE_MARKER} ${LABEL}`)).toHaveText( + `Page 1 of ${total}` + ); + + // Every break label reads "Page K of N" with the same, real total — and the + // page numbers run 2..total in document order. + const labels = await page.locator(`${BREAK_LINE} ${LABEL}`).allInnerTexts(); + expect(labels).toEqual( + Array.from({ length: breakCount }, (_, i) => `Page ${i + 2} of ${total}`) + ); + }); + + test('ISSUE-001: break lines sit on the A4 boundary without accumulating drift', async ({ + page, + }) => { + await page.goto(ROUTE); + await expect(page.locator(BREAK_LINE).first()).toBeVisible(); + + const markerTop = await topOf(page.locator(PAGE_MARKER)); + const lines = page.locator(BREAK_LINE); + const count = await lines.count(); + expect(count).toBeGreaterThan(1); // need >=2 to prove drift doesn't compound + + let prev = markerTop; + for (let i = 0; i < count; i++) { + const top = await topOf(lines.nth(i)); + const fromOrigin = top - markerTop; // content-space Y of page (i+2)'s start + const expectedBoundary = (i + 1) * CONTENT_PER_PAGE; + + // MAIN GUARD: a break never sits BELOW the true A4 boundary. The pre-fix + // bug pushed each break progressively below it (+137px at page 2, +307px + // at page 3); margin-aware packing keeps every break on or above the grid. + expect(fromOrigin).toBeLessThanOrEqual(expectedBoundary + 30); + + // Per-page gap is ~one A4 of content: never over-packed (the bug), and + // not absurdly under-filled. Whole-block packing under-fills by at most + // ~one block, so this stays a LOCAL bound that does not accumulate. + const gap = top - prev; + expect(gap).toBeLessThanOrEqual(CONTENT_PER_PAGE + 30); + expect(gap).toBeGreaterThanOrEqual(CONTENT_PER_PAGE - 250); + prev = top; + } + }); + + test('ISSUE-004: page labels stay on-screen on a viewport narrower than the page', async ({ + page, + }) => { + await page.setViewportSize({ height: 900, width: 600 }); // < 794px A4 stack + await page.goto(ROUTE); + await expect(page.locator(BREAK_LINE).first()).toBeVisible(); + + const labels = page.locator(LABEL); + const count = await labels.count(); + expect(count).toBeGreaterThan(0); + + for (let i = 0; i < count; i++) { + const box = await labels.nth(i).boundingBox(); + expect(box).not.toBeNull(); + // Whole chip within the 600px viewport (left-gutter placement), so the + // user never has to scroll horizontally to read a page number. + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(600); + } + }); + + test('overlay never intercepts pointer events (native editing untouched)', async ({ + page, + }) => { + await page.goto(ROUTE); + await expect(page.locator(BREAK_LINE).first()).toBeVisible(); + + const pointerEvents = await page + .locator(CONTAINER) + .evaluate((el) => getComputedStyle(el).pointerEvents); + expect(pointerEvents).toBe('none'); + + // The editor remains the live, editable surface beneath the overlay. + await expect( + page.locator('[contenteditable="true"]').first() + ).toBeVisible(); + }); + + test('no console errors while the overlay computes and recomputes', async ({ + page, + }) => { + const errors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(msg.text()); + }); + page.on('pageerror', (err) => errors.push(err.message)); + + await page.goto(ROUTE); + await expect(page.locator(BREAK_LINE).first()).toBeVisible(); + // Settle a resize-driven recompute too (width change re-wraps + re-anchors). + await page.setViewportSize({ height: 900, width: 700 }); + await expect(page.locator(BREAK_LINE).first()).toBeVisible(); + + expect(errors).toEqual([]); + }); +});