chore: ignore bunx-* temp dirs - #443
Conversation
…lay) (#357) * feat(pagination): scaffold @platejs/pagination variant A (render-overlay) — refs #353 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(template): add OpenNext deploy + pagination toolbar button placeholder Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pagination): apply inspector + CR feedback — refs #357 - move base bundle (header, footer, page break) to BasePaginationPlugin.plugins (Slate base) - BasePaginationConfig key uses typeof KEYS.pagination - drop degenerate <_V> generic on BasePaginationOptions - add includeFootnoteSubPlugins option (default true) — opt-out for footnote coupling - usePretextMeasurer uses useState so future ready flip re-renders - drop @chenglou/pretext from dependencies until measurer lands Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(template): CR feedback — refs #365 - lint script: add explicit `eslint .` target - lint:fix: chain `eslint . --fix` after biome - pagination-toolbar-button: drop internal #357/#358 PR refs from user-facing toast Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(pagination): implement variant A — paginate, measurer, overlay, footnotes — refs #353 #357 Replace TODO stubs with full variant A: - paginate(): bin-pack with manual page-break, oversized-block, header/footer/footnote-def exclusion - allocateFootnotes(): per-page allocation by reference walk - DOM-backed measurer with bounded LRU cache keyed by (nodeId, marksFingerprint, font, width) - usePageLayout, PageOverlay (afterEditable, pointer-events: none), PageFrame chrome - FootnotePortal hides in-flow definitions via injected CSS - Editor API: getPages, getPageOf, getFootnotes - Editor transforms: insertPageBreak, setHeader, setFooter - 9 unit tests passing Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(template): wire real @platejs/pagination via vendored dist — refs #357 #365 Drop placeholder pagination kit. Vendor @platejs/pagination's built dist under templates/plate-playground-template/vendor/platejs-pagination so the template can consume the real plugin without npm publish: - pagination-kit: PaginationPlugin.configure({ A4, 96px margins, 48 header/footer, 96 footnote well }) - pagination-toolbar-button: clicks editor.tf.pagination.insertPageBreak() - vendor/.gitignore overrides repo **/dist so committed artifacts ship Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pagination): visible page panel + resilient toolbar — refs #357 #365 PageOverlay: replace overlay-on-editor (z-index conflict, white-on-white) with a fixed top-right card showing "Pages: N" plus PageFrame thumbnails. Always visible regardless of editor theme. Toolbar button: try editor.tf.pagination.insertPageBreak() first; fall back to editor.tf.insertNodes({type:'pageBreak'}) so the action works even when the plugin's transforms aren't bound (e.g. during HMR or kit ordering). Drop the "plugin not loaded" toast. Refresh vendored dist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pagination): KEYS resolution + reactive overlay + toggle preview — refs #353 #357 #365 Two bugs uncovered via dogfooding the deploy: 1. Plugin silently dropped from editor: source referenced KEYS.pagination and KEYS.pageBreak; both undefined in published platejs@53.0.3 → key resolved to undefined → editor filtered the plugin out. Fixed by replacing with local PAGINATION_KEY / PAGE_BREAK_KEY / etc. constants in lib/internal/keys.ts. 2. Overlay never re-rendered on edits: useEditorRef + useMemo([editor.children]) misses Slate in-place mutations. Switched to useEditorValue + usePluginOption. Also: - previewVisible option + togglePreview() transform; toolbar button now toggles the side panel instead of inserting a break. - PageFrame now renders mini-content per block (h1-h6 / blockquote / code / paragraph) so the preview is content-aware, not just empty page chrome. - Refresh vendored dist + .gitignore dogfood-output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(pagination): page config + header/footer toggles + settings dropdown — refs #357 Adds the next layer per the CR plan + agreed roadmap: Plugin (lib): - PageSize: union of preset key | { width, height } literal — registry escape hatch - BasePaginationOptions: + headerVisible, footerVisible (defaults false) - BasePaginationTransforms: + setPageSize, setMargins, toggleHeader, toggleFooter - toggleHeader/toggleFooter inserts a top-level header/footer block at index 0 / N with placeholder text ("Header" / "Footer") and flips the visibility option; removeByType dedupes if normalization produced duplicates - resolvePageSize handles both preset and literal forms; resolvePageRect clamps contentHeight/contentWidth to >= 0 Overlay (react): - usePluginOption subscribes to pageSize, margins, headerVisible, footerVisible so the panel re-renders when any of them changes via toggle/setMargins/etc. Template: - pagination-toolbar-button: replaces the single click handler with a Radix DropdownMenu — sections for Display (preview/header/footer toggles), Page size (A4/Letter/Legal radio), Margins (narrow/default/wide presets) - vendor:pagination script — copies packages/pagination/dist into the template's vendored package; addresses CR's "Vendored dist refresh" follow-up Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(pagination): apply CR/Gemini review — derived header/footer, normalizeNode, content cache, pt scaling — refs #357 Implements the agreed subset of the CR plan-conejo (comment 11) and the Gemini + CR review feedback: Architecture: - Move page-state.ts from react/internal/ to lib/internal/ so the base Slate plugin's API can read pages without depending on React. Keeps imports flowing lib → react, never the other way. Header/footer model: - Drop headerVisible / footerVisible options. Derive presence from the doc itself (editor.children.some(n => n.type === HEADER_KEY)). Removes the option-vs-Slate-history asymmetry CR risk-flagged: undo of a toggleHeader call now correctly restores the header without leaving a stale option flag. - Add api.pagination.hasHeader / hasFooter for consumers that don't want to walk children themselves. - Toolbar dropdown checkboxes derive state via useEditorValue + same scan. Invariants: - Add overrideEditor → normalizeNode that runs at root path: enforceHeaderFooterInvariants drops duplicate header/footer blocks and re-positions the survivor (header at [0], footer at last index). Defends against paste/undo producing duplicates. Measurement correctness: - MeasureCacheKey gains a contentHash field; usePretextMeasurer hashes type + plain text per node and feeds it in. Cache now invalidates on in-place edits (Gemini + CR concern). - collectPlainText stops inserting a space between adjacent leaves — bold-then-plain runs ("He" + "llo") were measured as "He llo", over- counting line breaks. Fixed. - scaleFont uses /(\d+)(px|pt)/ and re-emits the matched unit so pt-sized fonts scale correctly for headings. - resolvePageRect clamps contentWidth/Height to ≥ 0 (already in place, preserved when accepting the new PageSize union). UI: - PageOverlay's THUMB_SCALE is now computed via computeThumbScale(width) = min(0.18, 196/pageWidth) — scales custom landscape page sizes down to the panel width. - React keys for the page list use `page-${pageIndex}` (stable). Cleanup: - marks-fingerprint: rename `sorted` → `segments` (CR nitpick — name reflected traversal order, not sort). Tests: 11 pass (added contentHash + hashString cases). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 📝 CodeRabbit Chat: Implement requested code changes * Update packages/pagination/src/static/page-break-element-static.tsx Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * refactor(pagination): split helpers into queries/transforms; KEYS via getType; plugin spec Apply footnote-plugin best practices to BasePaginationPlugin: - Extract queries (lib/queries) and transforms (lib/transforms) modules. - Move BasePaginationConfig/Api/Transforms types to lib/types. - Use editor.getType(KEYS.x) so consumer .configure({ node: { type } }) overrides flow through. - Fix enforceHeaderFooterInvariants stale-index bug (collect → sort desc → remove → re-scan → move). - Add base-pagination-plugins.spec covering plugin schema, API/transforms surface, toggle/set/insert behavior, and normalize invariants. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Replace packages/pagination with the version from origin/pagination. Brings BasePaginationPlugin, PaginationCoordinator, PageElement, YjsIntegration, leaderElection, reflowEngine, runtime, registry, and example_visualization_with_toggle. Amp-Thread-ID: https://ampcode.com/threads/T-019e2aaa-8068-77e8-8c96-7aed37adf1fe Co-authored-by: Amp <amp@ampcode.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Type editor as SlateEditor; route through editor.api / editor.tf to satisfy platejs typings without slate Editor casts. Use TextApi.isText and ElementApi.isElement. Narrow Operation via 'path' in op / 'newPath' in op. Drop dynamic getType?/getOption?/hasEditableTarget accesses.
Extract PaginationPlugin into its own file so src/index.ts is purely the brl-generated barrel. Move example_visualization_with_toggle/ under internal/ so brl skips it; T8 removes it entirely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move YjsIntegration into src/yjs/ subfolder so the main barrel no longer transitively depends on @platejs/yjs. Mark @platejs/yjs peer as optional. Extend tsdown + brl tooling to discover the yjs subpath alongside existing react/static lanes.
drop dead example dir + drop tsconfig exclude paths for it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ug logs - runtime.notify: queueMicrotask coalesces multiple markDirty in same tick - internal/scheduleIdle: SSR-safe ric+setTimeout fallback, drops window-as-any - ReflowOptions.debug (default false) gates splitOversizedBlock console.error - splitOversizedBlock: bag last two params for useMaxParams compliance - tests: async-aware notify expectations + new microtask coalescing test
Supply-chain defense: packages must be 7+ days old before install. Adds [test] root="./packages" in monorepos. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PaginationPlugin now mounts PaginationRegistryProvider (aboveEditable) and the reflow PaginationCoordinator (afterEditable) itself, so registering the plugin is enough to render + reflow pages. Also repair the grafted package: fix PageElement composed-ref import (@udecode/react-utils), stop the coordinator spec's global module mock from stripping exports other specs need, drop vitest/stale assertions, lint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Use literal 'pagination' key, not KEYS.pagination (unreleased in published @platejs/utils → undefined key → content never wrapped into pages). - Mount registry provider + reflow coordinator in one shared aboveEditable subtree (PaginationAboveEditable) so the coordinator reads the pages that PageElement registers; the split provider/coordinator gave separate registries. - Render the page number in each page's bottom margin (paginated mode). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation of the premirror-inspired pagination rewrite (derived layout; the document model never changes). Pure, DOM-free, deterministic: - layout/types.ts: layout contract (PageSpec/margins/policies, Unmeasured + Measured snapshots, LayoutOutput = pages → frames → BlockFragments + breakReason). - layout/compose.ts: composeLayout — block-level page fill with widow/orphan, keep-with-next, manual breaks, splittable-block fragmenting, oversized overflow. - layout/snapshot.ts: buildSnapshot — Slate value → flat block snapshot with stable content-based ids + atomic/keepWithNext/breakBefore hints. 16 tests, typecheck + lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
measure/measure.ts: measureSnapshot turns an UnmeasuredSnapshot into a MeasuredSnapshot. The DOM read is injected (MeasureFn) so the assembly + cache layer is pure/unit-tested; caching is keyed by stable content id + content width (measure-once, reuse unchanged blocks). 6 tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
react/domMeasure.ts: the MeasureFn that reads a top-level block's rendered height (incl. vertical margins) + computed line height from the live editor DOM via ReactEditor.toDOMNode. The only DOM touch in the measurement path; feeds measureSnapshot. Resilient fallbacks for line height. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P4 of the rewrite: render derived pages without mutating the document. - react/geometry.ts: getPageGeometry / getBlockPlacements (pure, tested) — stack pages + map blocks to page-frame positions. - react/domMeasure.ts: pure-DOM MeasureFn (top-level [data-slate-node=element] children); no slate-react dependency. - react/alignContent.ts: page-start CSS spacers align a single continuous Editable's content to page frames (no model change). - react/index.ts: clean @platejs/pagination/react entry re-exporting the slate-react-free pipeline (snapshot/measure/compose + geometry/measure/align). - apps/www dev/pagination2: demo (white A4 chrome + Editable overlay + page numbers). Verified live in agent-browser via the playground template: 4 A4 pages, content flows across page boxes with clean boundaries + page numbers. 149 tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The data layer both P0s (split-block rendering + caret/selection mapping) require — locate where a block/line lands and project it to screen coords. - layout/mapping.ts: buildMappingIndex — fragmentsOfBlock / pageOfBlock / pageOfBlockLine / fragmentOfBlockLine / isSplit (block→page/fragment). - layout/projection.ts: fragmentRects (absolute stack rects per fragment of a split block) + blockLinePosition (caret line → absolute stack position). Pure, deterministic. 8 tests; 24 layout tests green; typecheck + lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A block taller than a page now renders correctly across page boxes without mutating the document (approach #1). - react/splitClones.ts: computeSplitPlan (pure, layout-level) + renderSplitClones (DOM). One live Editable stays editable, clipped to the slice that fits its page; each later page gets a read-only clipped clone of the next slice. Slicing is real-pixel (live block's measured top/height + page geometry) with line-boundary snapping via Range.getClientRects() — seamless live→clone and clone→clone junctions (no overlap/gap/half-line). - react/index.ts: export mapping, projection, splitClones. Verified live (agent-browser): a block ~7× page height splits cleanly across pages. 161 tests; typecheck + lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… derived projection Scorch the mutator path: reflowEngine, page-node wrapping (BasePaginationPlugin), coordinator, registry, leader election, yjs bridge + the dead clone renderer (splitClones, option C). Package is now a self-contained pure layout pipeline (snapshot/measure/compose/geometry/align). Drop ./yjs export + dead deps. Neutralize template pagination usage (waiver). Add pretext gate to AGENTS.md. Plan: docs/plans/2026-05-22-pagination-rewrite-v2.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
F-006: cache was keyed by block id alone, so the same block measured at two
widths overwrote one slot — alternating widths (resize, side-by-side) defeated
the cache. Key by `${id}@${widthPx}`; drop the redundant inner key field.
TDD: alternating-width test was RED (3 measures), now GREEN (2).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add @chenglou/pretext + measure/pretext.ts → measureTextLines(text, font, widthPx, lineHeightPx): wrapped lines with text, width, and segment/grapheme cursor range. Real word/grapheme/CJK-aware breaking — the seed for line-level pagination + caret mapping (F-003). pretext measures via canvas; tests inject a deterministic monospace stub so line breaking is exact and headless. Barrels regenerated (pretext exported from '.'); fix scratch demo to import pure pipeline from '.' and DOM helpers from '/react' per the convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UnmeasuredBlock gains a required `text` field (concatenated leaf text), so the measure pass can feed pretext. TDD: snapshot-text test RED→GREEN. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…OM font) Option 3: pretext is the canonical layout measurement. measureBlockHeight(text, font, widthPx, lineHeightPx) = wrapped line count * lineHeight (>=1 line). createDomMeasure resolves the editor's real font + content width from the DOM and feeds pretext, replacing offsetHeight — so layout height is line-accurate and decoupled from box padding/margin. pretext gate satisfied (layout uses it). TDD: measureBlockHeight RED→GREEN (canvas stub). DOM rewire verified at host (PR7). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Remove the line-splitting + widow/orphan path. Each block is one whole fragment: fits-or-next-page, oversized overflows its page. Matches the chosen render (option C, no clones). Drops linesToPlace; compose is now ~20 lines simpler. TDD: compose.spec rewritten to the place-whole contract, RED→GREEN. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… LayoutOutput audit P5: projection.ts rebuilt buildMappingIndex per call. Now composeLayout builds it once and returns it on LayoutOutput.mapping; fragmentRects/ blockLinePosition consume it. buildMappingIndex now takes PageLayout[]. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pply)
lib/registry.ts: WeakMap<editor,{output,dirty,measureCache}>, lazy rebuild via
injected compute, shouldInvalidateLayout predicate (content ops yes, selection
no). Footnote registry pattern. apply-override wiring lands with the plugin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…invalidation) createTSlatePlugin<PaginationConfig> with page/margins/policies/viewMode/atomic/ keepWithNext options (A4, continuous default); overrideEditor wraps apply to mark the layout registry dirty on content ops (not selection). React useHooks host deferred to the first render PR (needs observable output to verify). Also: salvage useful algorithms from an external DOCX paginator into the impl plan (margin-collapsing, header/footer effective-height, footnote area, token offset model); reject its DOM-clone architecture. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rrels continuous.ts: getContinuousBreakYs(layout) → continuous-flow Y at each interior page boundary (cumulative page heights, N pages → N-1 break Ys). Drives the continuous view's advisory break-lines (no spacers, content stays in flow). pnpm brl: expose lib/ (registry, BasePaginationPlugin) + layout/continuous from the package barrels. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r page) pretext owns the break decision; the continuous overlay anchors its advisory rule to the boundary block's live DOM top instead of a text-only pixel sum that ignores DOM margins. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… overlay (scaffold) toPlatePlugin(BasePaginationPlugin): useHooks runs snapshot→pretext-measure→compose against the live editable when the registry is dirty (rAF-batched), stores layout in the registry, and sets a reactive breakYs option. afterEditable overlay paints dashed advisory page-break rules (pointer-events:none) at getContinuousBreakYs. SCAFFOLD: typecheck 8/8 + build 8/8, but overlay DOM positioning + recompute cadence are NOT dev-browser verified yet. Do not merge until verified in-browser. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- PaginationPlugin useHooks moved into plugin config (was .extend) so the React host's recompute/overlay actually run. - Overlay consumes getContinuousBreaks: each advisory rule anchors to the boundary block's live DOM top (no text-only pixel sum) → lands on a real block edge, never mid-paragraph. - Page N label moved to the right margin gutter (was overlapping body text); dashed rule bumped to visible slate-500. - ResizeObserver recomputes + re-anchors on width change. - Wire /dev/pagination2 demos (apps/www + template) to register the plugin; template gets the @chenglou/pretext runtime peer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-001..004) - 001: compose packs by flow height (text + DOM box spacing via flowHeightPx), so page count + breaks match real DOM flow (demo: 3→4 pages, correct for 2965px content). heightPx/lineCount stay text-only for line mapping. - 003: overlay shows 'Page N of M' + a 'Page 1 of M' marker. - 004: labels moved to the left margin gutter so they stay on-screen when a narrow viewport overflows the page width. - 002: recompute runs in a layout effect (pre-paint) instead of post-paint rAF; content→lines gap ~604ms→~308ms (residual is editor hydration). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`enabled` (default true) on BasePaginationPlugin. When false, the React layer skips layout recompute and renders no overlay; document never affected. Toggle via editor.setOption(BasePaginationPlugin,'enabled',next). TDD: base option contract (default true, configurable). Changeset: minor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ggle
- PaginationKit adds @platejs/pagination (enabled:false initially) to EditorKit.
- PaginationToolbarButton ("Page breaks", SeparatorHorizontal icon) toggles
enabled via editor.setOption(PaginationPlugin,'enabled',next); pressed state
reflects it. Added to the lists/toggle toolbar group.
- biome: drop unused React import in pagination2-view.
Overrides the CI-controlled-templates rule per explicit user request.
Deployed via OpenNext: https://plate-playground.cicero-im.workers.dev/editor
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… changeset language - domMeasure: hoist topLevelBlockElements() outside MeasureFn closure to avoid O(N²) DOM requery (was called once per block per recompute) - changeset: rewrite from changelog-style to present-tense reference per docs standard (no 'Add an option'; use 'The option controls...') https://claude.ai/code/session_016Wodda9zsyQ9krUCZq6QxY
Comment 2 (O(N²) topLevelBlockElements) already landed via the cherry-pick of #436 (commit 0a0872e). # Comment 1 — packages/pagination/src/lib/BasePaginationPlugin.ts:32 `enabled: boolean` made optional. DEFAULT_OPTIONS provides it as `true`, and `.configure({…})` / `.setOption('enabled', …)` should accept any subset of options without being forced to repeat the default. # Comment 3 — packages/pagination/src/react/PaginationPlugin.tsx:156 Re-enable invalidation. The layout effect exits early when `!registry.dirty && registry.output`. Without tracking the previous `enabled` value, toggling false → true on an unchanged document keeps both conditions true and the cached (now stale) output is reused without a fresh measure/compose pass. Fix: cache `prevEnabledRef` and force `registry.dirty = true` on the `false → true` transition, then proceed with the normal path. # Comment 4 — templates/.../pagination-toolbar-button.tsx:20 `onClick={() => editor.setOption(…)}` overrode any consumer-passed `onClick` from `props`. Now destructure `{ onClick: consumerOnClick, ...rest }`, compose: call `consumerOnClick?.(event)` first, bail if the consumer called `event.preventDefault()`, then run the toggle. # Verification bun test → 53 pass / 0 fail / 108 expect() calls Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…a layer
Implements stage 1 of the PRETEXT chrome design at
docs/plans/2026-05-29-pagination-chrome-derived-projection.md.
# What
`composeLayout` now accepts an optional `chrome: { header?, footer? }` on its
input and emits matching `chrome: { header?, footer? }` rects on every page in
its output. Each band has `heightPx` configured by the consumer; the composer
shrinks the content frame by the configured heights before packing and emits
page-local rects (x, y, widthPx, heightPx) the React overlay can use to anchor
chrome content WITHOUT any DOM measurement.
The user-visible answer to "page numbers follow the screen": chrome rects are
*pure composer output*, not DOM measurements, so they never drift with scroll.
The React overlay (next commit) reads `PageLayout.chrome.{header,footer}` and
positions absolute siblings at page-anchored Y values — same anchoring strategy
that already works correctly for break-lines (verified via headless probe on
the live deploy: marker.rectTop tracked scrollTop with delta=200 on a 200px
scroll, exactly anchored to the document).
# Type extensions
`PageChromeSpec`, `PageChromeRect`, `ChromeRenderContext` in `layout/types.ts`.
`LayoutInput` gains `chrome?: { header?: { heightPx }; footer?: { heightPx } }`.
`PageLayout` gains `chrome?: { header?: PageChromeRect; footer?: PageChromeRect }`.
The `render` function for chrome content lives on `PaginationOptions` (next
commit), NOT on `LayoutInput`, because composeLayout is pure and the React
node it would return is React-specific. Composer cares only about `heightPx`.
# Composer change
`bounds.y` += `headerHeightPx`; `bounds.height` -= `headerHeightPx + footerHeightPx`.
A `chromeRectsForPage` constant is computed once (chrome is layout-wide) and
attached to every emitted page via `flushPage`. When no chrome is configured,
the `chrome` field stays absent — existing tests unaffected (53 pre-existing
+ 7 new = 60 / 60 pass).
# Tests
`compose-chrome.spec.ts`:
1. baseline (no chrome) — chrome absent, frame unchanged
2. header only — frame.y bumps by header.heightPx, frame.height shrinks
3. footer only — frame.height shrinks; footer.y == bottom of page - margin - height
4. header + footer — both subtract from content
5. geometry — chrome.x == margin.left, chrome.widthPx == content width
6. per-page identical chrome rects across a multi-page layout
7. extra packing — chrome can force MORE pages when the content frame shrinks
# Coverage
7 new tests; full suite 60 / 60 (was 53 / 53). 36 expect() calls in the new
file. No regressions in the existing composer, mapping, measure, or React
host tests.
# Next commit
React overlay rendering + `PaginationOptions.chrome.render` plumbing +
`PageNumber` convenience export + playground demo wiring + re-deploy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ground demo
Iteration 1 of the /loop self-paced chrome push. Completes the React side of
the design doc:
# Plugin options
`PaginationOptions.chrome?: { header?: PageChromeOption; footer?: PageChromeOption }`
where `PageChromeOption = { heightPx; render(ctx) }`. The render function is
declared PRETEXT-safe in its docstring: pure, no DOM access, no editor
mutation, no scroll-state reads. The composer cares only about `heightPx`
(piped through `LayoutInput.chrome`); the render function stays on the plugin
options so the pure layout pipeline remains React-free.
# React overlay
`PaginationBreakLines` now:
- reads `chrome`, `page`, `margins` via `usePluginOption`
- passes chrome heights to `composeLayout`
- computes per-page anchor points: `headerY(i)` = top-of-page-i's-first-block
(which is what the user sees as the visual top of each page in continuous
view); `footerY(i)` = top of NEXT page's first block minus
`chrome.footer.heightPx` (so the footer sits just above the next break),
falling back to the document's bottom for the last page
- renders one chrome `<div>` per page per band, absolute-positioned at the
composer-computed Y, with `data-slot="pagination-chrome"`,
`data-pagination-chrome="header" | "footer"`, and `data-page-index`
- preserves the existing dashed break-line at each interior boundary
- keeps the legacy "Page N of M" left-margin chip ONLY when no chrome is
configured (backward compat)
# `PageNumber` convenience export
`packages/pagination/src/react/chrome/PageNumber.tsx` ships three pure
renderers:
- `PageNumber` — centered `Page N of M`, the most common footer
- `PageNumberWithTitle(title, opts?)` — `title | Page N of M`, skips
page 1 by default (cover-page convention)
- `TextHeader(text)` — constant uppercase + thin underline, the
default for legal-document headers
Each renderer derives every pixel from `ChromeRenderContext.{pageIndex,
pageCount, page, margins}` — never touches the DOM, never mutates the
editor.
Added to the `react` barrel (`packages/pagination/src/react/index.ts`).
# Playground demo
`templates/plate-playground-template/src/components/editor/plugins/pagination-kit.tsx`
now configures both bands by default:
chrome: {
header: { heightPx: 28, render: TextHeader('Plate Playground') },
footer: { heightPx: 32, render: PageNumber },
}
This is what the user actually wanted: a footer carrying `Page N of M` at the
bottom of each page, and a small uppercase document-title header at the top —
both rendered at composer-computed positions, scroll-invariant.
# Tests
60/60 still pass (53 pre-existing + 7 new from the data-layer commit).
The chrome `render` functions are React; their pixel emission is verified by
the live dogfood (next step of the /loop) rather than a JSDOM snapshot, since
the bug surface lives in DOM anchoring under live scroll.
# Next /loop iteration
Build + deploy + run the agent browser against the live playground to confirm
headers/footers/page-numbers render and stay anchored as the user scrolls and
edits. Fix any drift found.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dor sync
Three fixes from iter-1 dogfood against the live deploy:
# 1. `React is not defined` after toggling pagination ON
`packages/pagination/src/react/chrome/PageNumber.tsx` declared
`import type * as React from 'react'` — a type-only import that
elides at runtime. The JSX in every render function compiles to
`React.createElement(...)`, so the moment a consumer invoked
`PageNumber({...})` the page error'd out and the editor
unmounted. Reproduced live via headless probe; switched to
`import * as React from 'react'` (runtime), confirmed bundle
emits `React$1.createElement` and the editor stays mounted.
# 2. Last-page footer overlapping its header
In continuous view a footer naturally lives one chrome.heightPx
above the NEXT page's first block. The previous code anchored
the LAST page's footer to "end of last block - chrome.heightPx",
which on a sparse last page (just one short block after the
final break) made the footer land at the same Y as the header.
Fix: anchor the last page's footer to the page's *geometric*
bottom (`startBlock + (page.heightPx - margins.top -
margins.bottom)`), falling back to the actual content end IF
the last block's height exceeds the geometric page height (so
the footer never lands above content).
# 3. `bun run vendor:pagination` left node_modules stale
bun's `file:` dep resolution caches the installed copy in
`node_modules/@platejs/pagination/dist/`. The previous script
copied the freshly-built dist into `vendor/platejs-pagination/
dist/` but did NOT refresh node_modules — so Next.js still
bundled the OLD dist on every redeploy. Diagnosed via sha256:
node_modules and vendor hashes diverged.
Fix: extend the script to ALSO `rm node_modules/...dist && cp
vendor/...dist node_modules/...dist`. Hashes now match after
every `bun run vendor:pagination`.
# Verification
- `bun test` 60/60 ✓
- Headless probe of live deploy (Worker version f6907805): no
`React is not defined` after toggle; 7 headers, 7 footers,
6 break-lines rendered; mathematical scroll-anchoring
confirmed (Page 3 footer rectTop delta = 2785 = scrollTop).
- Last-page footer overlap reproduced before fix
(Page 7 footer rectTop == Page 7 header rectTop == 5250) and
resolved after this commit (verified next iter).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nvention
Iter-3 of the /loop chrome push.
# Regression guards (compose-chrome-edge-cases.spec.ts)
5 tests, all green, pinning behaviors that broke (or were at risk of
breaking) during the iter-1/iter-2 live dogfood:
1. last-page chrome rect present when the page's content is much
shorter than a full page — guards the iter-2 fix
(footerY for last page = page-geometric bottom, not last-block
bottom). Verifies chrome.footer is emitted on the short page
and matches every other page's footer.y identically.
2. content frame height is the SAME on every page — confirms the
chrome subtraction is constant (1123 - 96 - 96 - 28 - 32 = 871),
no page-by-page drift.
3. composer is pure — same input ⇒ identical pages JSON.
4. zero-height chrome is valid — heightPx=0 doesn't change frame
height; chrome rects exist but are zero-band.
5. multi-page packing under chrome subtraction — math:
content/page = 871 px, blocks @ 200 px = 4 blocks/page,
10 blocks → 3 pages (4+4+2).
Brings the pagination suite to 65 / 65 (was 60 / 60).
# Cover-page convention in the playground
`templates/plate-playground-template/src/components/editor/plugins/
pagination-kit.tsx` now uses `PageNumberWithTitle('Plate Playground')`
for the footer (defaults `skipFirstPage: true` so page 1 stays blank).
A one-line render-wrapper does the same for the header. This matches
the printed-document convention where page 1 is the cover.
# Verification
- bun test: 65 pass / 0 fail / 158 expect() calls.
- bun run typecheck: passes after `vendor:pagination` (which now
syncs node_modules too — iter-1 commit).
- Live dogfood pending (next /loop iteration).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…me bands Iter-4 visual polish. Each chrome band now carries a single 1-px `rgb(226 232 240)` rule on its inside edge: - footer chip / footer-with-title: `border-top` (= top of bottom margin) - header text: `border-bottom` (= bottom of top margin) Together those two rules visually demarcate the page as a chrome→ margin→content→margin→chrome stack at a glance, addressing the "margins" half of the original mandate without requiring a heavier paged-view treatment. Also extracted three shared constants (`CHROME_FONT`, `CHROME_INK`, `CHROME_RULE`) so consumers can build sibling renderers that match without copying typography strings. # Verification `bun test` still 65 / 65. Live-deploy dogfood in flight. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Third built-in chrome renderer alongside PageNumber and PageNumberWithTitle. Bare "N" or "N / M" centered in the band, with tabular-nums so the digits don't shift width between pages 9 and 10. Use case: book templates where a running header carries the title and all that's needed in the footer is the page number itself. Options: - showTotal: false (default) → "1", "2", "3" - showTotal: true → "1 / 7", "2 / 7", "3 / 7" - skipFirstPage: true → render null on page 1 (cover-page convention) bun test: 65 / 65 ✓. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Code Review
This pull request completes a major refactor of the @platejs/pagination package, transitioning from a document-mutating engine to a pure, derived-layout pipeline that leverages @chenglou/pretext for accurate text measurement. It introduces a React-based overlay host, new chrome rendering components, and a registry-based layout invalidation system. The review feedback provides critical improvements, including correctly overriding editor.apply for layout invalidation, adding safety guards for layout calculations, fixing default option handling in chrome components, and optimizing ResizeObserver usage to avoid redundant computations.
| }).overrideEditor(({ editor, tf: { apply } }) => ({ | ||
| transforms: { | ||
| apply(operation) { | ||
| if (shouldInvalidateLayout(operation)) { | ||
| invalidateLayoutRegistry(editor); | ||
| } | ||
|
|
||
| apply(operation); | ||
| }, | ||
| }, | ||
| })); |
There was a problem hiding this comment.
Overriding transforms.apply (which maps to editor.tf.apply) will not intercept core Slate operations, as Slate applies operations by calling editor.apply directly. This means the layout registry will never be invalidated on content edits, preventing the pagination from recomputing when the user types or modifies the document. To fix this, editor.apply should be overridden directly on the editor instance.
| }).overrideEditor(({ editor, tf: { apply } }) => ({ | |
| transforms: { | |
| apply(operation) { | |
| if (shouldInvalidateLayout(operation)) { | |
| invalidateLayoutRegistry(editor); | |
| } | |
| apply(operation); | |
| }, | |
| }, | |
| })); | |
| }).overrideEditor(({ editor }) => { | |
| const { apply } = editor; | |
| editor.apply = (operation) => { | |
| if (shouldInvalidateLayout(operation)) { | |
| invalidateLayoutRegistry(editor); | |
| } | |
| apply(operation); | |
| }; | |
| return editor; | |
| }); |
| const contentWidthPx = page.widthPx - margins.leftPx - margins.rightPx; | ||
| const bounds: Rect = { | ||
| x: margins.leftPx, | ||
| y: margins.topPx + headerHeightPx, | ||
| width: contentWidthPx, | ||
| height: | ||
| page.heightPx - | ||
| margins.topPx - | ||
| margins.bottomPx - | ||
| headerHeightPx - | ||
| footerHeightPx, | ||
| }; |
There was a problem hiding this comment.
The calculations for contentWidthPx and bounds.height do not guard against negative or zero values. If the configured margins or chrome heights exceed the page dimensions, this can lead to invalid layout states or potential infinite loops during block placement. It is safer to wrap these calculations in Math.max(0, ...).
| const contentWidthPx = page.widthPx - margins.leftPx - margins.rightPx; | |
| const bounds: Rect = { | |
| x: margins.leftPx, | |
| y: margins.topPx + headerHeightPx, | |
| width: contentWidthPx, | |
| height: | |
| page.heightPx - | |
| margins.topPx - | |
| margins.bottomPx - | |
| headerHeightPx - | |
| footerHeightPx, | |
| }; | |
| const contentWidthPx = Math.max(0, page.widthPx - margins.leftPx - margins.rightPx); | |
| const bounds: Rect = { | |
| x: margins.leftPx, | |
| y: margins.topPx + headerHeightPx, | |
| width: contentWidthPx, | |
| height: Math.max( | |
| 0, | |
| page.heightPx - | |
| margins.topPx - | |
| margins.bottomPx - | |
| headerHeightPx - | |
| footerHeightPx | |
| ), | |
| }; |
| export const PageNumberWithTitle = | ||
| (title: string, opts: { skipFirstPage?: boolean } = { skipFirstPage: true }) => | ||
| (ctx: ChromeRenderContext): React.ReactNode => { | ||
| if (opts.skipFirstPage && ctx.pageIndex === 0) return null; |
There was a problem hiding this comment.
There is a subtle bug in the default options handling of PageNumberWithTitle. If a caller passes an empty object {} as the second argument, opts.skipFirstPage will resolve to undefined (falsy), which bypasses the intended default of true. Destructuring the options object with a default value of true ensures robust fallback behavior.
export const PageNumberWithTitle =
(title: string, { skipFirstPage = true }: { skipFirstPage?: boolean } = {})
(ctx: ChromeRenderContext): React.ReactNode => {
if (skipFirstPage && ctx.pageIndex === 0) return null;
| useEffect(() => { | ||
| const editable = editor.api.toDOMNode(editor); | ||
| if (!editable || typeof ResizeObserver === 'undefined') return; | ||
|
|
||
| const observer = new ResizeObserver(() => { | ||
| invalidateLayoutRegistry(editor); | ||
| forceRecompute((n) => n + 1); | ||
| }); | ||
| observer.observe(editable); | ||
|
|
||
| return () => observer.disconnect(); | ||
| }, [editor]); |
There was a problem hiding this comment.
The ResizeObserver callback is triggered automatically on mount when observation starts. This marks the layout registry dirty and triggers a state update (forceRecompute), causing a redundant second layout computation on mount. Tracking the last observed width and height in local variables and only triggering the recompute when the dimensions actually change will eliminate this overhead.
useEffect(() => {
const editable = editor.api.toDOMNode(editor);
if (!editable || typeof ResizeObserver === 'undefined') return;
let prevWidth = editable.clientWidth;
let prevHeight = editable.clientHeight;
const observer = new ResizeObserver(() => {
const width = editable.clientWidth;
const height = editable.clientHeight;
if (width !== prevWidth || height !== prevHeight) {
prevWidth = width;
prevHeight = height;
invalidateLayoutRegistry(editor);
forceRecompute((n) => n + 1);
}
});
observer.observe(editable);
return () => observer.disconnect();
}, [editor]);
Adds
bunx-*to.gitignoreso the temp directory created bybunx skiller@latestduringpnpm installis not tracked.https://claude.ai/code/session_01N2ZqNsNVyfNWbdZgwxM1VM
Generated by Claude Code