From daff834d8d162af78947bcaa6ed6e941945d705e Mon Sep 17 00:00:00 2001 From: Arthur Souza Rodrigues <89408329+arthrod@users.noreply.github.com> Date: Mon, 4 May 2026 23:57:33 +0200 Subject: [PATCH 01/48] chore: add CodeRabbit config --- .coderabbit.yaml | 134 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000000..bc1a0d763d --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,134 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +# CodeRabbit config for arthrod/plate fork. +# Tuned for: TypeScript monorepo (pnpm + turbo), advisory-only review, +# noisy generated/CI-controlled paths excluded. + +language: "en-US" +early_access: false +tone_instructions: | + Be terse. Sacrifice grammar for concision. Cite file:line. No filler. + Flag scope creep loudly: a bug fix should not refactor surrounding code. + Plate is Slate-first — call out plugin work that starts in src/react when + semantics could live in src/lib. + +reviews: + profile: "chill" + request_changes_workflow: false + high_level_summary: true + high_level_summary_placeholder: "@coderabbitai summary" + auto_title_placeholder: "@coderabbitai" + review_status: true + poem: false + collapse_walkthrough: true + sequence_diagrams: false + changed_files_summary: true + labeling_instructions: + - label: "performance" + instructions: "Apply when changes touch hot paths, bundle size, render frequency, or normalize/decoration loops." + - label: "documentation" + instructions: "Apply when only docs, JSDoc, or .agents/rules/*.mdc change." + path_filters: + - "!**/*.lock" + - "!**/pnpm-lock.yaml" + - "!**/yarn.lock" + - "!**/package-lock.json" + - "!**/dist/**" + - "!**/.turbo/**" + - "!**/.next/**" + - "!**/.contentlayer/**" + - "!**/node_modules/**" + - "!**/coverage/**" + - "!**/*.tsbuildinfo" + - "!**/__snapshots__/**" + - "!**/*.snap" + - "!templates/**" + - "!apps/www/public/r/**" + - "!apps/www/.contentlayer/**" + - "!**/CHANGELOG.md" + path_instructions: + - path: "packages/**/src/lib/**/*.{ts,tsx}" + instructions: | + Slate-first lane. Forbid: any, SlateEditor parameter annotations when + plugin context already provides editor, hardcoded plugin-key string + literals (use KEYS from packages/utils/src/lib/plate-keys.ts), + re-authoring base plugins via createPlatePlugin (use toPlatePlugin). + Helpers/fallbacks/internal logic must live under internal/ — only + true public API at top level. Prefer createTSlatePlugin only when + explicit contract control is needed; otherwise createSlatePlugin + with inference. + - path: "packages/**/src/react/**/*.{ts,tsx}" + instructions: | + Plate/React wrapper lane. Lift base plugins via toPlatePlugin / + toTPlatePlugin instead of redefining semantics. Use inject.nodeProps + .transformProps for prop augmentation that needs hooks before + inventing wrapper components. Never duplicate KEYS as string + literals. Read-only behavior must respect editOnly config on the + base plugin. + - path: "packages/**/src/**/index.{ts,tsx}" + instructions: | + Barrels are GENERATED by `pnpm brl`. Flag any hand-edit. If a PR + adds/moves/renames public files under exported folders, the same PR + must include a regenerated barrel. + - path: "packages/**/src/**/*.spec.{ts,tsx}" + instructions: | + Bun test runner. Co-located *.spec.ts. Forbid TDD cases for dead + code/legacy removal assertions ("should not contain old API X + anymore") — those should remove the dead path directly, not test + for its absence. + - path: ".changeset/*.md" + instructions: | + Verify version bump matches scope: new package = minor, breaking = + major, internal-only = patch. Summary must describe user-facing + change, not implementation. + - path: "apps/www/content/docs/**/*.{md,mdx}" + instructions: | + Docs are user-facing reference for the LATEST state only. Reject + changelog-style language: "has been removed", "new feature", + "previously", "now supports". Write as if no prior version exists. + No migration notes. + - path: ".agents/AGENTS.md" + instructions: | + Source of truth for agent rules. After edits, `pnpm install` must + sync the SKILL.md mirror; flag PRs that touch AGENTS.md without + the mirror update. + - path: ".agents/rules/*.mdc" + instructions: | + Source of truth for agent skill rules. Same sync requirement as + AGENTS.md. + - path: "templates/**" + instructions: | + CI-controlled output. Reject manual edits unconditionally. Fix + the source registry/package and let CI regenerate. + abort_on_close: true + auto_review: + enabled: true + auto_incremental_review: true + ignore_title_keywords: + - "WIP" + - "DO NOT MERGE" + - "[skip ci]" + - "[skip release]" + drafts: false + base_branches: + - "main" + - "codex/.*" + finishing_touches: + docstrings: + enabled: true + +chat: + auto_reply: true + integrations: + jira: + usage: "disabled" + linear: + usage: "disabled" + +knowledge_base: + opt_out: false + learnings: + scope: "auto" + issues: + scope: "auto" + pull_requests: + scope: "auto" From 8a90361f2d25261e1c7f486d722884b9bc142d7d Mon Sep 17 00:00:00 2001 From: Arthur Souza Rodrigues Date: Tue, 5 May 2026 15:09:00 -0400 Subject: [PATCH 02/48] changes --- .coderabbit.yaml | 134 ----------------------------------------------- 1 file changed, 134 deletions(-) delete mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml deleted file mode 100644 index bc1a0d763d..0000000000 --- a/.coderabbit.yaml +++ /dev/null @@ -1,134 +0,0 @@ -# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json -# CodeRabbit config for arthrod/plate fork. -# Tuned for: TypeScript monorepo (pnpm + turbo), advisory-only review, -# noisy generated/CI-controlled paths excluded. - -language: "en-US" -early_access: false -tone_instructions: | - Be terse. Sacrifice grammar for concision. Cite file:line. No filler. - Flag scope creep loudly: a bug fix should not refactor surrounding code. - Plate is Slate-first — call out plugin work that starts in src/react when - semantics could live in src/lib. - -reviews: - profile: "chill" - request_changes_workflow: false - high_level_summary: true - high_level_summary_placeholder: "@coderabbitai summary" - auto_title_placeholder: "@coderabbitai" - review_status: true - poem: false - collapse_walkthrough: true - sequence_diagrams: false - changed_files_summary: true - labeling_instructions: - - label: "performance" - instructions: "Apply when changes touch hot paths, bundle size, render frequency, or normalize/decoration loops." - - label: "documentation" - instructions: "Apply when only docs, JSDoc, or .agents/rules/*.mdc change." - path_filters: - - "!**/*.lock" - - "!**/pnpm-lock.yaml" - - "!**/yarn.lock" - - "!**/package-lock.json" - - "!**/dist/**" - - "!**/.turbo/**" - - "!**/.next/**" - - "!**/.contentlayer/**" - - "!**/node_modules/**" - - "!**/coverage/**" - - "!**/*.tsbuildinfo" - - "!**/__snapshots__/**" - - "!**/*.snap" - - "!templates/**" - - "!apps/www/public/r/**" - - "!apps/www/.contentlayer/**" - - "!**/CHANGELOG.md" - path_instructions: - - path: "packages/**/src/lib/**/*.{ts,tsx}" - instructions: | - Slate-first lane. Forbid: any, SlateEditor parameter annotations when - plugin context already provides editor, hardcoded plugin-key string - literals (use KEYS from packages/utils/src/lib/plate-keys.ts), - re-authoring base plugins via createPlatePlugin (use toPlatePlugin). - Helpers/fallbacks/internal logic must live under internal/ — only - true public API at top level. Prefer createTSlatePlugin only when - explicit contract control is needed; otherwise createSlatePlugin - with inference. - - path: "packages/**/src/react/**/*.{ts,tsx}" - instructions: | - Plate/React wrapper lane. Lift base plugins via toPlatePlugin / - toTPlatePlugin instead of redefining semantics. Use inject.nodeProps - .transformProps for prop augmentation that needs hooks before - inventing wrapper components. Never duplicate KEYS as string - literals. Read-only behavior must respect editOnly config on the - base plugin. - - path: "packages/**/src/**/index.{ts,tsx}" - instructions: | - Barrels are GENERATED by `pnpm brl`. Flag any hand-edit. If a PR - adds/moves/renames public files under exported folders, the same PR - must include a regenerated barrel. - - path: "packages/**/src/**/*.spec.{ts,tsx}" - instructions: | - Bun test runner. Co-located *.spec.ts. Forbid TDD cases for dead - code/legacy removal assertions ("should not contain old API X - anymore") — those should remove the dead path directly, not test - for its absence. - - path: ".changeset/*.md" - instructions: | - Verify version bump matches scope: new package = minor, breaking = - major, internal-only = patch. Summary must describe user-facing - change, not implementation. - - path: "apps/www/content/docs/**/*.{md,mdx}" - instructions: | - Docs are user-facing reference for the LATEST state only. Reject - changelog-style language: "has been removed", "new feature", - "previously", "now supports". Write as if no prior version exists. - No migration notes. - - path: ".agents/AGENTS.md" - instructions: | - Source of truth for agent rules. After edits, `pnpm install` must - sync the SKILL.md mirror; flag PRs that touch AGENTS.md without - the mirror update. - - path: ".agents/rules/*.mdc" - instructions: | - Source of truth for agent skill rules. Same sync requirement as - AGENTS.md. - - path: "templates/**" - instructions: | - CI-controlled output. Reject manual edits unconditionally. Fix - the source registry/package and let CI regenerate. - abort_on_close: true - auto_review: - enabled: true - auto_incremental_review: true - ignore_title_keywords: - - "WIP" - - "DO NOT MERGE" - - "[skip ci]" - - "[skip release]" - drafts: false - base_branches: - - "main" - - "codex/.*" - finishing_touches: - docstrings: - enabled: true - -chat: - auto_reply: true - integrations: - jira: - usage: "disabled" - linear: - usage: "disabled" - -knowledge_base: - opt_out: false - learnings: - scope: "auto" - issues: - scope: "auto" - pull_requests: - scope: "auto" From 604ca5d4d943ca54393a3404de196fc408c2d778 Mon Sep 17 00:00:00 2001 From: Arthur Souza Rodrigues <89408329+arthrod@users.noreply.github.com> Date: Wed, 6 May 2026 16:56:20 -0400 Subject: [PATCH 03/48] feat(pagination): scaffold @platejs/pagination variant A (render-overlay) (#357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(pagination): scaffold @platejs/pagination variant A (render-overlay) — refs #353 Co-Authored-By: Claude Opus 4.7 (1M context) * feat(template): add OpenNext deploy + pagination toolbar button placeholder Co-Authored-By: Claude Opus 4.7 (1M context) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 📝 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) 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> --- .changeset/pagination-scaffold.md | 5 + .changeset/utils-pagination-keys.md | 5 + .gitignore | 2 +- packages/pagination/README.md | 58 ++ packages/pagination/package.json | 62 ++ packages/pagination/src/index.ts | 5 + .../pagination/src/lib/allocate-footnotes.ts | 76 ++ .../pagination/src/lib/base-footer-plugin.ts | 16 + .../pagination/src/lib/base-header-plugin.ts | 15 + .../src/lib/base-page-break-plugin.ts | 16 + .../src/lib/base-pagination-plugin.ts | 114 +++ .../src/lib/base-pagination-plugins.spec.ts | 278 ++++++ packages/pagination/src/lib/index.ts | 13 + .../src/lib/internal/font-from-style.ts | 30 + packages/pagination/src/lib/internal/keys.ts | 12 + .../src/lib/internal/marks-fingerprint.ts | 49 + .../src/lib/internal/measure-cache.spec.ts | 107 ++ .../src/lib/internal/measure-cache.ts | 70 ++ .../src/lib/internal/page-size-presets.ts | 42 + .../pagination/src/lib/internal/page-state.ts | 25 + packages/pagination/src/lib/paginate.spec.ts | 115 +++ packages/pagination/src/lib/paginate.ts | 97 ++ .../src/lib/queries/getPageOfPath.ts | 23 + .../src/lib/queries/getPaginationPages.ts | 19 + .../src/lib/queries/hasChromeBlock.ts | 17 + packages/pagination/src/lib/queries/index.ts | 7 + .../enforceHeaderFooterInvariants.ts | 57 ++ .../src/lib/transforms/ensureFooter.ts | 19 + .../src/lib/transforms/ensureHeader.ts | 19 + .../pagination/src/lib/transforms/index.ts | 13 + .../src/lib/transforms/insertPageBreak.ts | 11 + .../src/lib/transforms/removeNodesByType.ts | 15 + .../src/lib/transforms/replaceFooter.ts | 27 + .../src/lib/transforms/replaceHeader.ts | 27 + .../src/lib/transforms/toggleFooter.ts | 19 + .../src/lib/transforms/toggleHeader.ts | 19 + packages/pagination/src/lib/types.ts | 145 +++ .../pagination/src/react/footer-plugin.ts | 5 + .../pagination/src/react/footnote-portal.tsx | 29 + .../pagination/src/react/header-plugin.ts | 5 + packages/pagination/src/react/index.ts | 12 + .../src/react/internal/use-page-layout.ts | 54 + .../pagination/src/react/page-break-plugin.ts | 5 + packages/pagination/src/react/page-frame.tsx | 235 +++++ .../pagination/src/react/page-overlay.tsx | 199 ++++ .../pagination/src/react/pagination-plugin.ts | 52 + .../src/react/use-pretext-measurer.ts | 276 ++++++ .../src/static/footer-element-static.tsx | 28 + .../src/static/header-element-static.tsx | 28 + packages/pagination/src/static/index.ts | 7 + .../src/static/page-break-element-static.tsx | 64 ++ packages/pagination/tsconfig.build.json | 7 + packages/pagination/tsconfig.json | 5 + packages/utils/src/lib/plate-keys.ts | 4 + pnpm-lock.yaml | 17 +- .../plate-playground-template/.gitignore | 6 + templates/plate-playground-template/bun.lock | 937 +++++++++++++++++- .../plate-playground-template/next.config.ts | 4 + .../open-next.config.ts | 3 + .../plate-playground-template/package.json | 13 +- .../src/components/editor/editor-kit.tsx | 4 + .../editor/plugins/pagination-kit.tsx | 29 + .../components/ui/fixed-toolbar-buttons.tsx | 2 + .../ui/pagination-toolbar-button.tsx | 181 ++++ .../vendor/.gitignore | 4 + .../dist/index-BmXRyAOt.d.ts | 237 +++++ .../dist/index-BmXRyAOt.d.ts.map | 1 + .../vendor/platejs-pagination/dist/index.d.ts | 2 + .../vendor/platejs-pagination/dist/index.js | 3 + .../dist/paginate-c73WStbw.js | 385 +++++++ .../dist/paginate-c73WStbw.js.map | 1 + .../platejs-pagination/dist/react/index.d.ts | 112 +++ .../dist/react/index.d.ts.map | 1 + .../platejs-pagination/dist/react/index.js | 901 +++++++++++++++++ .../dist/react/index.js.map | 1 + .../vendor/platejs-pagination/package.json | 26 + .../plate-playground-template/wrangler.jsonc | 14 + 77 files changed, 5535 insertions(+), 13 deletions(-) create mode 100644 .changeset/pagination-scaffold.md create mode 100644 .changeset/utils-pagination-keys.md create mode 100644 packages/pagination/README.md create mode 100644 packages/pagination/package.json create mode 100644 packages/pagination/src/index.ts create mode 100644 packages/pagination/src/lib/allocate-footnotes.ts create mode 100644 packages/pagination/src/lib/base-footer-plugin.ts create mode 100644 packages/pagination/src/lib/base-header-plugin.ts create mode 100644 packages/pagination/src/lib/base-page-break-plugin.ts create mode 100644 packages/pagination/src/lib/base-pagination-plugin.ts create mode 100644 packages/pagination/src/lib/base-pagination-plugins.spec.ts create mode 100644 packages/pagination/src/lib/index.ts create mode 100644 packages/pagination/src/lib/internal/font-from-style.ts create mode 100644 packages/pagination/src/lib/internal/keys.ts create mode 100644 packages/pagination/src/lib/internal/marks-fingerprint.ts create mode 100644 packages/pagination/src/lib/internal/measure-cache.spec.ts create mode 100644 packages/pagination/src/lib/internal/measure-cache.ts create mode 100644 packages/pagination/src/lib/internal/page-size-presets.ts create mode 100644 packages/pagination/src/lib/internal/page-state.ts create mode 100644 packages/pagination/src/lib/paginate.spec.ts create mode 100644 packages/pagination/src/lib/paginate.ts create mode 100644 packages/pagination/src/lib/queries/getPageOfPath.ts create mode 100644 packages/pagination/src/lib/queries/getPaginationPages.ts create mode 100644 packages/pagination/src/lib/queries/hasChromeBlock.ts create mode 100644 packages/pagination/src/lib/queries/index.ts create mode 100644 packages/pagination/src/lib/transforms/enforceHeaderFooterInvariants.ts create mode 100644 packages/pagination/src/lib/transforms/ensureFooter.ts create mode 100644 packages/pagination/src/lib/transforms/ensureHeader.ts create mode 100644 packages/pagination/src/lib/transforms/index.ts create mode 100644 packages/pagination/src/lib/transforms/insertPageBreak.ts create mode 100644 packages/pagination/src/lib/transforms/removeNodesByType.ts create mode 100644 packages/pagination/src/lib/transforms/replaceFooter.ts create mode 100644 packages/pagination/src/lib/transforms/replaceHeader.ts create mode 100644 packages/pagination/src/lib/transforms/toggleFooter.ts create mode 100644 packages/pagination/src/lib/transforms/toggleHeader.ts create mode 100644 packages/pagination/src/lib/types.ts create mode 100644 packages/pagination/src/react/footer-plugin.ts create mode 100644 packages/pagination/src/react/footnote-portal.tsx create mode 100644 packages/pagination/src/react/header-plugin.ts create mode 100644 packages/pagination/src/react/index.ts create mode 100644 packages/pagination/src/react/internal/use-page-layout.ts create mode 100644 packages/pagination/src/react/page-break-plugin.ts create mode 100644 packages/pagination/src/react/page-frame.tsx create mode 100644 packages/pagination/src/react/page-overlay.tsx create mode 100644 packages/pagination/src/react/pagination-plugin.ts create mode 100644 packages/pagination/src/react/use-pretext-measurer.ts create mode 100644 packages/pagination/src/static/footer-element-static.tsx create mode 100644 packages/pagination/src/static/header-element-static.tsx create mode 100644 packages/pagination/src/static/index.ts create mode 100644 packages/pagination/src/static/page-break-element-static.tsx create mode 100644 packages/pagination/tsconfig.build.json create mode 100644 packages/pagination/tsconfig.json create mode 100644 templates/plate-playground-template/open-next.config.ts create mode 100644 templates/plate-playground-template/src/components/editor/plugins/pagination-kit.tsx create mode 100644 templates/plate-playground-template/src/components/ui/pagination-toolbar-button.tsx create mode 100644 templates/plate-playground-template/vendor/.gitignore create mode 100644 templates/plate-playground-template/vendor/platejs-pagination/dist/index-BmXRyAOt.d.ts create mode 100644 templates/plate-playground-template/vendor/platejs-pagination/dist/index-BmXRyAOt.d.ts.map create mode 100644 templates/plate-playground-template/vendor/platejs-pagination/dist/index.d.ts create mode 100644 templates/plate-playground-template/vendor/platejs-pagination/dist/index.js create mode 100644 templates/plate-playground-template/vendor/platejs-pagination/dist/paginate-c73WStbw.js create mode 100644 templates/plate-playground-template/vendor/platejs-pagination/dist/paginate-c73WStbw.js.map create mode 100644 templates/plate-playground-template/vendor/platejs-pagination/dist/react/index.d.ts create mode 100644 templates/plate-playground-template/vendor/platejs-pagination/dist/react/index.d.ts.map create mode 100644 templates/plate-playground-template/vendor/platejs-pagination/dist/react/index.js create mode 100644 templates/plate-playground-template/vendor/platejs-pagination/dist/react/index.js.map create mode 100644 templates/plate-playground-template/vendor/platejs-pagination/package.json create mode 100644 templates/plate-playground-template/wrangler.jsonc diff --git a/.changeset/pagination-scaffold.md b/.changeset/pagination-scaffold.md new file mode 100644 index 0000000000..1c2b655bf2 --- /dev/null +++ b/.changeset/pagination-scaffold.md @@ -0,0 +1,5 @@ +--- +'@platejs/pagination': minor +--- + +Add `@platejs/pagination` package — render-time overlay pagination (variant A). Pages are derived from `editor.children` and painted as an `afterEditable` overlay; the document is never mutated. Includes header / footer / page-break element plugins, footnote sub-plugin bundling, a DOM-backed measurer with bounded LRU cache keyed by `(node.id, marks-fingerprint, font, width)`, and editor API (`getPages`, `getPageOf`, `getFootnotes`) plus transforms (`insertPageBreak`, `setHeader`, `setFooter`). diff --git a/.changeset/utils-pagination-keys.md b/.changeset/utils-pagination-keys.md new file mode 100644 index 0000000000..ba43c8dd18 --- /dev/null +++ b/.changeset/utils-pagination-keys.md @@ -0,0 +1,5 @@ +--- +'@platejs/utils': patch +--- + +Add `KEYS.pagination`, `KEYS.header`, `KEYS.footer`, `KEYS.pageBreak` for the pagination plugin family diff --git a/.gitignore b/.gitignore index 743b135db2..dfd43e2fc8 100644 --- a/.gitignore +++ b/.gitignore @@ -129,4 +129,4 @@ packages/plate/docs/ *.local* -.omx/ \ No newline at end of file +.omx/dogfood-output/ diff --git a/packages/pagination/README.md b/packages/pagination/README.md new file mode 100644 index 0000000000..68b10f9f78 --- /dev/null +++ b/packages/pagination/README.md @@ -0,0 +1,58 @@ +# @platejs/pagination + +Render-time overlay pagination for Plate. Pages are derived from the live +document at render time and painted as a chrome overlay (header band, +footer band, footnote well, page borders) above the editor — the Slate +value never changes. + +## Install + +```bash +npm install @platejs/pagination +``` + +## Use + +```tsx +import { PaginationPlugin } from '@platejs/pagination/react'; +import { createPlateEditor } from 'platejs/react'; + +const editor = createPlateEditor({ + plugins: [ + PaginationPlugin.configure({ + pageSize: 'A4', + margins: { top: 96, bottom: 96, left: 72, right: 72 }, + headerHeight: 48, + footerHeight: 48, + footnoteWell: 96, + }), + ], +}); +``` + +## API + +```ts +editor.api.pagination.getPages(); // Page[] +editor.api.pagination.getPageOf([blockIndex]); // page index, or -1 +editor.api.pagination.getFootnotes(0); // footnote definitions on page 0 + +editor.tf.pagination.insertPageBreak(); +editor.tf.pagination.setHeader(content); +editor.tf.pagination.setFooter(content); +``` + +## Architecture + +- Pure `paginate(doc, rect, ctx, measurer) -> Page[]` selector — React-free, + unit-testable with a fake monospace measurer. +- DOM-backed measurer with bounded LRU cache keyed by + `(node.id, marks-fingerprint, font, width)`. +- Overlay mounted via `render.afterEditable`; `pointer-events: none` + preserves editing. +- Footnote definitions hidden in flow via injected CSS; visible copy + rendered inside each page's footer well. + +## License + +[MIT](../../LICENSE) diff --git a/packages/pagination/package.json b/packages/pagination/package.json new file mode 100644 index 0000000000..2c2000ca47 --- /dev/null +++ b/packages/pagination/package.json @@ -0,0 +1,62 @@ +{ + "name": "@platejs/pagination", + "version": "0.0.0", + "description": "Plate plugin for paginated editing (variant A: render-time overlay, derived pages, pretext height oracle)", + "keywords": [ + "plate", + "plugin", + "slate", + "pagination" + ], + "homepage": "https://platejs.org", + "bugs": { + "url": "https://github.com/udecode/plate/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/udecode/plate.git", + "directory": "packages/pagination" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": "./dist/index.js", + "./react": "./dist/react/index.js", + "./static": "./dist/static/index.js", + "./package.json": "./package.json" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist/**/*" + ], + "scripts": { + "brl": "plate-pkg p:brl", + "build": "plate-pkg p:build", + "build:watch": "plate-pkg p:build:watch", + "clean": "plate-pkg p:clean", + "lint": "plate-pkg p:lint", + "lint:fix": "plate-pkg p:lint:fix", + "test": "plate-pkg p:test", + "test:watch": "plate-pkg p:test:watch", + "typecheck": "plate-pkg p:typecheck" + }, + "dependencies": { + "@platejs/footnote": "workspace:^", + "react-compiler-runtime": "^1.0.0" + }, + "devDependencies": { + "@plate/scripts": "workspace:*", + "platejs": "workspace:^" + }, + "peerDependencies": { + "platejs": ">=53.0.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "publishConfig": { + "access": "public" + }, + "type": "module", + "module": "./dist/index.js" +} diff --git a/packages/pagination/src/index.ts b/packages/pagination/src/index.ts new file mode 100644 index 0000000000..e7cccc036f --- /dev/null +++ b/packages/pagination/src/index.ts @@ -0,0 +1,5 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './lib/index'; diff --git a/packages/pagination/src/lib/allocate-footnotes.ts b/packages/pagination/src/lib/allocate-footnotes.ts new file mode 100644 index 0000000000..955dc9795f --- /dev/null +++ b/packages/pagination/src/lib/allocate-footnotes.ts @@ -0,0 +1,76 @@ +import type { TElement } from 'platejs'; + +import type { Page } from './types'; + +import { FOOTNOTE_REFERENCE_KEY } from './internal/keys'; + +/** + * Greedy assignment of footnote definitions to per-page footer wells. + * + * Walks each page's blocks, collects every inline `footnoteReference` by its + * `identifier` field, then attaches the matching definition (looked up in + * the document-level definition list) to that page. Definitions referenced + * on multiple pages attach to the first page that references them. + * + * Returns a new array of {@link Page} objects with `footnotes` populated. + * The original `pages` argument is not mutated. + */ +export const allocateFootnotes = ( + pages: Page[], + footnotes: TElement[] +): Page[] => { + if (footnotes.length === 0) return pages; + + const byId = new Map(); + for (const def of footnotes) { + const id = (def as TElement & { identifier?: string }).identifier; + if (typeof id === 'string') byId.set(id, def); + } + + if (byId.size === 0) return pages; + + const claimed = new Set(); + + return pages.map((page) => { + const allocated: TElement[] = []; + + for (const node of page.nodes) { + collectReferenceIds(node, (id) => { + if (claimed.has(id)) return; + + const def = byId.get(id); + + if (!def) return; + + claimed.add(id); + allocated.push(def); + }); + } + + return allocated.length > 0 ? { ...page, footnotes: allocated } : page; + }); +}; + +const collectReferenceIds = ( + node: { children?: unknown[]; identifier?: string; type?: string }, + visit: (identifier: string) => void +): void => { + if ( + node.type === FOOTNOTE_REFERENCE_KEY && + typeof node.identifier === 'string' + ) { + visit(node.identifier); + + return; + } + if (!Array.isArray(node.children)) return; + + for (const child of node.children) { + if (typeof child === 'object' && child !== null) { + collectReferenceIds( + child as { children?: unknown[]; identifier?: string; type?: string }, + visit + ); + } + } +}; diff --git a/packages/pagination/src/lib/base-footer-plugin.ts b/packages/pagination/src/lib/base-footer-plugin.ts new file mode 100644 index 0000000000..52d4c32162 --- /dev/null +++ b/packages/pagination/src/lib/base-footer-plugin.ts @@ -0,0 +1,16 @@ +import { createSlatePlugin } from 'platejs'; + +import { FOOTER_KEY } from './internal/keys'; + +/** + * Block-level page-footer element. + * + * Authored once per document; the render-overlay clones it onto every page + * and runs the footnote-well allocator above it. + */ +export const BaseFooterPlugin = createSlatePlugin({ + key: FOOTER_KEY, + node: { + isElement: true, + }, +}); diff --git a/packages/pagination/src/lib/base-header-plugin.ts b/packages/pagination/src/lib/base-header-plugin.ts new file mode 100644 index 0000000000..888e7992d2 --- /dev/null +++ b/packages/pagination/src/lib/base-header-plugin.ts @@ -0,0 +1,15 @@ +import { createSlatePlugin } from 'platejs'; + +import { HEADER_KEY } from './internal/keys'; + +/** + * Block-level page-header element. + * + * Authored once per document; the render-overlay clones it onto every page. + */ +export const BaseHeaderPlugin = createSlatePlugin({ + key: HEADER_KEY, + node: { + isElement: true, + }, +}); diff --git a/packages/pagination/src/lib/base-page-break-plugin.ts b/packages/pagination/src/lib/base-page-break-plugin.ts new file mode 100644 index 0000000000..3c4a99d872 --- /dev/null +++ b/packages/pagination/src/lib/base-page-break-plugin.ts @@ -0,0 +1,16 @@ +import { createSlatePlugin } from 'platejs'; + +import { PAGE_BREAK_KEY } from './internal/keys'; + +/** + * Hard page-break element. + * + * The render-overlay paginator splits a page boundary at every break node. + */ +export const BasePageBreakPlugin = createSlatePlugin({ + key: PAGE_BREAK_KEY, + node: { + isElement: true, + isVoid: true, + }, +}); diff --git a/packages/pagination/src/lib/base-pagination-plugin.ts b/packages/pagination/src/lib/base-pagination-plugin.ts new file mode 100644 index 0000000000..564f39c85a --- /dev/null +++ b/packages/pagination/src/lib/base-pagination-plugin.ts @@ -0,0 +1,114 @@ +import { createTSlatePlugin } from 'platejs'; + +import type { + BasePaginationApi, + BasePaginationConfig, + BasePaginationTransforms, +} from './types'; + +import { BaseFooterPlugin } from './base-footer-plugin'; +import { BaseHeaderPlugin } from './base-header-plugin'; +import { BasePageBreakPlugin } from './base-page-break-plugin'; +import { PAGINATION_KEY } from './internal/keys'; +import { + getPageOfPath, + getPaginationFootnotes, + getPaginationPages, + hasFooterBlock, + hasHeaderBlock, +} from './queries'; +import { + enforceHeaderFooterInvariants, + insertPageBreak, + replaceFooter, + replaceHeader, + toggleFooter, + toggleHeader, +} from './transforms'; + +/** + * Base orchestrator plugin for paginated layout. + * + * Variant A — render-time overlay; pages derived; pretext as height oracle. + * The Slate document is unchanged; pagination is a render-only projection + * layered onto the live editor via the Plate `render.afterEditable` slot. + * + * Header/footer presence is derived from `editor.children` (single source of + * truth) — undo and paste survive correctly because we don't mirror the + * presence to a plugin option that lives outside Slate history. + * + * The page-chrome element family (header, footer, page break) is composed + * here on the Slate base so a Slate-only consumer registering + * `BasePaginationPlugin` already gets the element schema. React-only deltas + * (footnote sub-plugins, overlay rendering) live in `src/react`. + */ +export const BasePaginationPlugin = createTSlatePlugin({ + key: PAGINATION_KEY, + options: { + footerHeight: 48, + footnoteWell: 0, + headerHeight: 48, + includeFootnoteSubPlugins: true, + margins: { + bottom: 72, + left: 72, + right: 72, + top: 72, + }, + pageSize: 'A4', + previewVisible: true, + }, + plugins: [BaseHeaderPlugin, BaseFooterPlugin, BasePageBreakPlugin], +}) + .overrideEditor(({ editor, tf: { normalizeNode } }) => ({ + transforms: { + normalizeNode: (entry) => { + const [, path] = entry; + + if (path.length === 0) { + enforceHeaderFooterInvariants(editor); + } + + return normalizeNode(entry); + }, + }, + })) + .extendEditorApi(({ editor }) => ({ + pagination: { + getFootnotes: (pageIndex) => getPaginationFootnotes(editor, pageIndex), + getPageOf: (path) => getPageOfPath(editor, path), + getPages: () => getPaginationPages(editor), + hasFooter: () => hasFooterBlock(editor), + hasHeader: () => hasHeaderBlock(editor), + }, + })) + .extendEditorTransforms( + ({ editor, getOptions, setOption }) => ({ + pagination: { + insertPageBreak: () => insertPageBreak(editor), + setFooter: (content) => replaceFooter(editor, content), + setHeader: (content) => replaceHeader(editor, content), + setMargins: (margins) => { + setOption('margins', margins); + }, + setPageSize: (size) => { + setOption('pageSize', size); + }, + toggleFooter: () => toggleFooter(editor), + toggleHeader: () => toggleHeader(editor), + togglePreview: () => { + const next = !(getOptions().previewVisible ?? true); + + setOption('previewVisible', next); + + return next; + }, + }, + }) + ); + +export type { + BasePaginationApi, + BasePaginationConfig, + BasePaginationTransforms, +} from './types'; diff --git a/packages/pagination/src/lib/base-pagination-plugins.spec.ts b/packages/pagination/src/lib/base-pagination-plugins.spec.ts new file mode 100644 index 0000000000..19f9ef1da5 --- /dev/null +++ b/packages/pagination/src/lib/base-pagination-plugins.spec.ts @@ -0,0 +1,278 @@ +import { createSlateEditor, KEYS } from 'platejs'; + +import { + BaseFooterPlugin, + BaseHeaderPlugin, + BasePageBreakPlugin, + BasePaginationPlugin, +} from './index'; + +describe('BasePaginationPlugins', () => { + it('configures the page-break element as an inline-level void block', () => { + const editor = createSlateEditor({ + plugins: [BasePaginationPlugin], + } as any); + const plugin = editor.getPlugin(BasePageBreakPlugin); + + expect(plugin.node).toMatchObject({ + isElement: true, + isVoid: true, + }); + expect(plugin.node.isInline).toBeUndefined(); + }); + + it('configures header and footer as block elements', () => { + const editor = createSlateEditor({ + plugins: [BasePaginationPlugin], + } as any); + const headerPlugin = editor.getPlugin(BaseHeaderPlugin); + const footerPlugin = editor.getPlugin(BaseFooterPlugin); + + expect(headerPlugin.node).toMatchObject({ isElement: true }); + expect(headerPlugin.node.isInline).toBeUndefined(); + expect(headerPlugin.node.isVoid).toBeUndefined(); + + expect(footerPlugin.node).toMatchObject({ isElement: true }); + expect(footerPlugin.node.isInline).toBeUndefined(); + expect(footerPlugin.node.isVoid).toBeUndefined(); + }); + + it('exposes the documented option defaults', () => { + const editor = createSlateEditor({ + plugins: [BasePaginationPlugin], + } as any); + const plugin = editor.getPlugin(BasePaginationPlugin); + + expect(plugin.options.pageSize).toBe('A4'); + expect(plugin.options.margins).toEqual({ + bottom: 72, + left: 72, + right: 72, + top: 72, + }); + expect(plugin.options.headerHeight).toBe(48); + expect(plugin.options.footerHeight).toBe(48); + expect(plugin.options.footnoteWell).toBe(0); + expect(plugin.options.includeFootnoteSubPlugins).toBe(true); + expect(plugin.options.previewVisible).toBe(true); + }); + + it('provides pagination api and transforms on the editor', () => { + const editor = createSlateEditor({ + plugins: [BasePaginationPlugin], + } as any); + const api = (editor.api as any).pagination; + const tf = (editor.tf as any).pagination; + + expect(api).toBeDefined(); + expect(typeof api.getPages).toBe('function'); + expect(typeof api.getPageOf).toBe('function'); + expect(typeof api.getFootnotes).toBe('function'); + expect(typeof api.hasHeader).toBe('function'); + expect(typeof api.hasFooter).toBe('function'); + + expect(tf).toBeDefined(); + expect(typeof tf.insertPageBreak).toBe('function'); + expect(typeof tf.setHeader).toBe('function'); + expect(typeof tf.setFooter).toBe('function'); + expect(typeof tf.setMargins).toBe('function'); + expect(typeof tf.setPageSize).toBe('function'); + expect(typeof tf.toggleHeader).toBe('function'); + expect(typeof tf.toggleFooter).toBe('function'); + expect(typeof tf.togglePreview).toBe('function'); + }); + + it('hasHeader/hasFooter reflect the live document', () => { + const editor = createSlateEditor({ + plugins: [BasePaginationPlugin], + value: [ + { + children: [{ text: 'h' }], + type: KEYS.header, + }, + { + children: [{ text: 'body' }], + type: KEYS.p, + }, + { + children: [{ text: 'f' }], + type: KEYS.footer, + }, + ], + } as any); + + expect((editor.api as any).pagination.hasHeader()).toBe(true); + expect((editor.api as any).pagination.hasFooter()).toBe(true); + }); + + it('toggleHeader inserts a default header at index 0 when missing', () => { + const editor = createSlateEditor({ + plugins: [BasePaginationPlugin], + value: [{ children: [{ text: 'body' }], type: KEYS.p }], + } as any); + + expect((editor.tf as any).pagination.toggleHeader()).toBe(true); + expect(editor.children[0]).toMatchObject({ + children: [{ text: 'Header' }], + type: KEYS.header, + }); + expect((editor.api as any).pagination.hasHeader()).toBe(true); + }); + + it('toggleHeader removes the existing header', () => { + const editor = createSlateEditor({ + plugins: [BasePaginationPlugin], + value: [ + { children: [{ text: 'h' }], type: KEYS.header }, + { children: [{ text: 'body' }], type: KEYS.p }, + ], + } as any); + + expect((editor.tf as any).pagination.toggleHeader()).toBe(false); + expect((editor.api as any).pagination.hasHeader()).toBe(false); + expect(editor.children).toMatchObject([ + { children: [{ text: 'body' }], type: KEYS.p }, + ]); + }); + + it('toggleFooter inserts a default footer at the last index when missing', () => { + const editor = createSlateEditor({ + plugins: [BasePaginationPlugin], + value: [{ children: [{ text: 'body' }], type: KEYS.p }], + } as any); + + expect((editor.tf as any).pagination.toggleFooter()).toBe(true); + expect(editor.children.at(-1)).toMatchObject({ + children: [{ text: 'Footer' }], + type: KEYS.footer, + }); + }); + + it('toggleFooter removes the existing footer', () => { + const editor = createSlateEditor({ + plugins: [BasePaginationPlugin], + value: [ + { children: [{ text: 'body' }], type: KEYS.p }, + { children: [{ text: 'f' }], type: KEYS.footer }, + ], + } as any); + + expect((editor.tf as any).pagination.toggleFooter()).toBe(false); + expect((editor.api as any).pagination.hasFooter()).toBe(false); + }); + + it('setHeader replaces the existing header content', () => { + const editor = createSlateEditor({ + plugins: [BasePaginationPlugin], + value: [ + { children: [{ text: 'old' }], type: KEYS.header }, + { children: [{ text: 'body' }], type: KEYS.p }, + ], + } as any); + + (editor.tf as any).pagination.setHeader([{ text: 'new' }]); + + expect(editor.children[0]).toMatchObject({ + children: [{ text: 'new' }], + type: KEYS.header, + }); + }); + + it('setFooter replaces the existing footer content', () => { + const editor = createSlateEditor({ + plugins: [BasePaginationPlugin], + value: [ + { children: [{ text: 'body' }], type: KEYS.p }, + { children: [{ text: 'old' }], type: KEYS.footer }, + ], + } as any); + + (editor.tf as any).pagination.setFooter([{ text: 'new' }]); + + expect(editor.children.at(-1)).toMatchObject({ + children: [{ text: 'new' }], + type: KEYS.footer, + }); + }); + + it('insertPageBreak inserts a page-break void at the selection', () => { + const editor = createSlateEditor({ + plugins: [BasePaginationPlugin], + selection: { + anchor: { offset: 5, path: [0, 0] }, + focus: { offset: 5, path: [0, 0] }, + }, + value: [{ children: [{ text: 'hello' }], type: KEYS.p }], + } as any); + + (editor.tf as any).pagination.insertPageBreak(); + + expect( + (editor.children as any[]).some((n) => n.type === KEYS.pageBreak) + ).toBe(true); + }); + + it('setMargins / setPageSize update plugin options', () => { + const editor = createSlateEditor({ + plugins: [BasePaginationPlugin], + } as any); + + (editor.tf as any).pagination.setMargins({ + bottom: 1, + left: 2, + right: 3, + top: 4, + }); + (editor.tf as any).pagination.setPageSize('Letter'); + + expect(editor.getOption(BasePaginationPlugin, 'margins')).toEqual({ + bottom: 1, + left: 2, + right: 3, + top: 4, + }); + expect(editor.getOption(BasePaginationPlugin, 'pageSize')).toBe('Letter'); + }); + + it('togglePreview flips and returns previewVisible', () => { + const editor = createSlateEditor({ + plugins: [BasePaginationPlugin], + } as any); + const tf = (editor.tf as any).pagination; + + expect(tf.togglePreview()).toBe(false); + expect(editor.getOption(BasePaginationPlugin, 'previewVisible')).toBe( + false + ); + expect(tf.togglePreview()).toBe(true); + expect(editor.getOption(BasePaginationPlugin, 'previewVisible')).toBe(true); + }); + + it('normalizeNode enforces a single header at index 0 and a single footer last', () => { + const editor = createSlateEditor({ + plugins: [BasePaginationPlugin], + value: [ + { children: [{ text: 'body1' }], type: KEYS.p }, + { children: [{ text: 'h1' }], type: KEYS.header }, + { children: [{ text: 'h2' }], type: KEYS.header }, + { children: [{ text: 'f1' }], type: KEYS.footer }, + { children: [{ text: 'body2' }], type: KEYS.p }, + { children: [{ text: 'f2' }], type: KEYS.footer }, + ], + } as any); + + editor.tf.normalize({ force: true }); + + const headers = (editor.children as any[]).filter( + (n) => n.type === KEYS.header + ); + const footers = (editor.children as any[]).filter( + (n) => n.type === KEYS.footer + ); + + expect(headers).toHaveLength(1); + expect(footers).toHaveLength(1); + expect((editor.children[0] as any).type).toBe(KEYS.header); + expect((editor.children.at(-1) as any).type).toBe(KEYS.footer); + }); +}); diff --git a/packages/pagination/src/lib/index.ts b/packages/pagination/src/lib/index.ts new file mode 100644 index 0000000000..038ec24d1e --- /dev/null +++ b/packages/pagination/src/lib/index.ts @@ -0,0 +1,13 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './allocate-footnotes'; +export * from './base-footer-plugin'; +export * from './base-header-plugin'; +export * from './base-page-break-plugin'; +export * from './base-pagination-plugin'; +export * from './paginate'; +export * from './types'; +export * from './queries/index'; +export * from './transforms/index'; diff --git a/packages/pagination/src/lib/internal/font-from-style.ts b/packages/pagination/src/lib/internal/font-from-style.ts new file mode 100644 index 0000000000..18a0d2cc8c --- /dev/null +++ b/packages/pagination/src/lib/internal/font-from-style.ts @@ -0,0 +1,30 @@ +/** + * Resolve a CSSStyleDeclaration into the canonical CSS `font` shorthand + * understood by `CanvasRenderingContext2D.font`. + * + * Variant A keys the measure cache on the resolved font string, so this + * stays deterministic across re-renders of the same node. + * + * Output shape: `${style} ${weight} ${size}px/${lineHeight} ${family}`. + * `style` and `weight` are omitted when they match defaults so the same + * visual font produces the same key. + */ +export const fontFromStyle = (style: CSSStyleDeclaration): string => { + const family = style.fontFamily || 'sans-serif'; + const size = style.fontSize || '16px'; + const weight = style.fontWeight || '400'; + const fontStyle = style.fontStyle || 'normal'; + const lineHeight = + style.lineHeight && style.lineHeight !== 'normal' + ? `/${style.lineHeight}` + : ''; + + const parts: string[] = []; + + if (fontStyle !== 'normal') parts.push(fontStyle); + if (weight !== '400' && weight !== 'normal') parts.push(weight); + parts.push(`${size}${lineHeight}`); + parts.push(family); + + return parts.join(' '); +}; diff --git a/packages/pagination/src/lib/internal/keys.ts b/packages/pagination/src/lib/internal/keys.ts new file mode 100644 index 0000000000..e2e1abfffa --- /dev/null +++ b/packages/pagination/src/lib/internal/keys.ts @@ -0,0 +1,12 @@ +/** + * Plugin keys hard-coded inside the package so the published `platejs` + * `KEYS` object isn't required to know about them. The workspace `KEYS` + * also exposes these (`KEYS.pagination`, `KEYS.pageBreak`) for downstream + * consumers that prefer the central registry — keep these strings in sync. + */ +export const PAGINATION_KEY = 'pagination'; +export const PAGE_BREAK_KEY = 'pageBreak'; +export const HEADER_KEY = 'header'; +export const FOOTER_KEY = 'footer'; +export const FOOTNOTE_REFERENCE_KEY = 'footnoteReference'; +export const FOOTNOTE_DEFINITION_KEY = 'footnoteDefinition'; diff --git a/packages/pagination/src/lib/internal/marks-fingerprint.ts b/packages/pagination/src/lib/internal/marks-fingerprint.ts new file mode 100644 index 0000000000..832ebbc191 --- /dev/null +++ b/packages/pagination/src/lib/internal/marks-fingerprint.ts @@ -0,0 +1,49 @@ +import type { TElement } from 'platejs'; + +/** + * Stable, JSON-shape-independent fingerprint of the marks/styles attached to a + * block's leaves. Used as part of the measure-cache key so a node with the + * same text but different bold/italic runs gets remeasured. + */ +export const marksFingerprint = (node: TElement): string => { + const segments: string[] = []; + walkLeaves(node, (leaf) => { + const keys = Object.keys(leaf) + .filter((k) => k !== 'text') + .sort(); + + if (keys.length === 0) return; + + const segment = keys + .map((k) => `${k}=${formatMark((leaf as Record)[k])}`) + .join(','); + + segments.push(segment); + }); + + return segments.join('|'); +}; + +const formatMark = (value: unknown): string => { + if (value === true) return '1'; + if (value === false) return '0'; + if (value == null) return ''; + if (typeof value === 'object') return JSON.stringify(value); + + return String(value); +}; + +const walkLeaves = ( + node: { children?: unknown[]; text?: string }, + visit: (leaf: { text: string }) => void +): void => { + if (typeof node.text === 'string') { + visit(node as { text: string }); + + return; + } + if (!Array.isArray(node.children)) return; + for (const child of node.children) { + walkLeaves(child as { children?: unknown[]; text?: string }, visit); + } +}; diff --git a/packages/pagination/src/lib/internal/measure-cache.spec.ts b/packages/pagination/src/lib/internal/measure-cache.spec.ts new file mode 100644 index 0000000000..1652ef59df --- /dev/null +++ b/packages/pagination/src/lib/internal/measure-cache.spec.ts @@ -0,0 +1,107 @@ +import { createMeasureCache, hashString } from './measure-cache'; + +const k = (s: string, contentHash = '') => ({ + contentHash, + font: '16px sans-serif', + marksFingerprint: '', + nodeId: s, + width: 600, +}); + +it('returns undefined on miss and stores on set', () => { + const cache = createMeasureCache(); + + expect(cache.get(k('a'))).toBeUndefined(); + cache.set(k('a'), 42); + expect(cache.get(k('a'))).toBe(42); +}); + +it('keys distinguish width and font', () => { + const cache = createMeasureCache(); + + cache.set( + { + contentHash: '', + font: 'a', + marksFingerprint: '', + nodeId: 'x', + width: 100, + }, + 1 + ); + cache.set( + { + contentHash: '', + font: 'a', + marksFingerprint: '', + nodeId: 'x', + width: 200, + }, + 2 + ); + cache.set( + { + contentHash: '', + font: 'b', + marksFingerprint: '', + nodeId: 'x', + width: 100, + }, + 3 + ); + + expect( + cache.get({ + contentHash: '', + font: 'a', + marksFingerprint: '', + nodeId: 'x', + width: 100, + }) + ).toBe(1); + expect( + cache.get({ + contentHash: '', + font: 'a', + marksFingerprint: '', + nodeId: 'x', + width: 200, + }) + ).toBe(2); + expect( + cache.get({ + contentHash: '', + font: 'b', + marksFingerprint: '', + nodeId: 'x', + width: 100, + }) + ).toBe(3); +}); + +it('treats different content hashes as different entries', () => { + const cache = createMeasureCache(); + + cache.set(k('x', 'h1'), 10); + cache.set(k('x', 'h2'), 20); + + expect(cache.get(k('x', 'h1'))).toBe(10); + expect(cache.get(k('x', 'h2'))).toBe(20); +}); + +it('evicts the oldest entry when bounded', () => { + const cache = createMeasureCache(2); + + cache.set(k('a'), 1); + cache.set(k('b'), 2); + cache.set(k('c'), 3); + + expect(cache.get(k('a'))).toBeUndefined(); + expect(cache.get(k('b'))).toBe(2); + expect(cache.get(k('c'))).toBe(3); +}); + +it('hashString is deterministic and varies with input', () => { + expect(hashString('hello')).toBe(hashString('hello')); + expect(hashString('hello')).not.toBe(hashString('hellp')); +}); diff --git a/packages/pagination/src/lib/internal/measure-cache.ts b/packages/pagination/src/lib/internal/measure-cache.ts new file mode 100644 index 0000000000..6f9f506fb5 --- /dev/null +++ b/packages/pagination/src/lib/internal/measure-cache.ts @@ -0,0 +1,70 @@ +/** + * In-memory cache for measured node heights. + * + * Variant A cache key per CodeRabbit Design Choice 3: + * `(node.id, marks-fingerprint, font, width)` plus a `contentHash` so an + * in-place text edit (Slate mutates `children` without rotating the + * `node.id`) invalidates the cache entry. + * + * The cache is bounded — when it exceeds {@link DEFAULT_MAX_ENTRIES}, the + * oldest insertion is evicted. Eviction uses Map insertion order so it is + * effectively LRU on writes (the caller treats every miss as an "access"). + */ +export type MeasureCacheKey = { + /** Hash of the block's plain text + type — invalidates on edit. */ + contentHash: string; + font: string; + marksFingerprint: string; + nodeId: string; + width: number; +}; + +export type MeasureCache = { + clear: () => void; + get: (key: MeasureCacheKey) => number | undefined; + set: (key: MeasureCacheKey, value: number) => void; + size: () => number; +}; + +export const DEFAULT_MAX_ENTRIES = 5000; + +export const createMeasureCache = ( + maxEntries = DEFAULT_MAX_ENTRIES +): MeasureCache => { + const store = new Map(); + + const composeKey = (k: MeasureCacheKey): string => + `${k.nodeId} ${k.marksFingerprint} ${k.font} ${k.width} ${k.contentHash}`; + + return { + clear: () => store.clear(), + get: (key) => store.get(composeKey(key)), + set: (key, value) => { + const composed = composeKey(key); + + if (store.has(composed)) { + store.delete(composed); + } else if (store.size >= maxEntries) { + const oldest = store.keys().next().value; + + if (oldest !== undefined) store.delete(oldest); + } + + store.set(composed, value); + }, + size: () => store.size, + }; +}; + +/** + * djb2 hash of a string — small, fast, no deps, plenty of entropy for + * cache key disambiguation. + */ +export const hashString = (s: string): string => { + let h = 5381; + for (let i = 0; i < s.length; i++) { + h = ((h << 5) + h + s.charCodeAt(i)) | 0; + } + + return h.toString(36); +}; diff --git a/packages/pagination/src/lib/internal/page-size-presets.ts b/packages/pagination/src/lib/internal/page-size-presets.ts new file mode 100644 index 0000000000..458854cb76 --- /dev/null +++ b/packages/pagination/src/lib/internal/page-size-presets.ts @@ -0,0 +1,42 @@ +import type { PageMargins, PageRect, PageSize } from '../types'; + +/** Page presets resolved at 96 DPI. */ +export const PAGE_PRESETS: Record = { + A4: { height: 1123, width: 794 }, + Legal: { height: 1344, width: 816 }, + Letter: { height: 1056, width: 816 }, +}; + +const isLiteralSize = (s: PageSize): s is { height: number; width: number } => + typeof s === 'object' && s !== null && 'width' in s && 'height' in s; + +export const resolvePageSize = ( + pageSize: PageSize +): { height: number; width: number } => { + if (isLiteralSize(pageSize)) return pageSize; + + return PAGE_PRESETS[pageSize as string] ?? PAGE_PRESETS.A4; +}; + +export const resolvePageRect = ( + pageSize: PageSize, + margins: PageMargins, + reservations: { footer: number; footnoteWell: number; header: number } +): PageRect => { + const preset = resolvePageSize(pageSize); + const contentWidth = preset.width - margins.left - margins.right; + const contentHeight = + preset.height - + margins.top - + margins.bottom - + reservations.header - + reservations.footer - + reservations.footnoteWell; + + return { + contentHeight: Math.max(contentHeight, 0), + contentWidth: Math.max(contentWidth, 0), + height: preset.height, + width: preset.width, + }; +}; diff --git a/packages/pagination/src/lib/internal/page-state.ts b/packages/pagination/src/lib/internal/page-state.ts new file mode 100644 index 0000000000..e8e3ba78b2 --- /dev/null +++ b/packages/pagination/src/lib/internal/page-state.ts @@ -0,0 +1,25 @@ +import type { Page } from '../types'; + +/** + * The latest pagination snapshot is stored on the editor instance under a + * non-enumerable slot so `editor.api.pagination.*` queries can resolve + * without going through React. `usePageLayout` writes the slot after each + * pagination cycle; `BasePaginationPlugin.api.pagination.getPages` reads it. + * + * Writing onto the editor avoids a WeakMap allocation and keeps the read + * path zero-overhead — the API just dereferences a property. + * + * Lives under `lib/internal` so the base (Slate-only) plugin can import it + * without React depending on `lib`. + */ +export const SLOT = '__pagination_pages__' as const; + +export const setEditorPages = (editor: object, pages: Page[]): void => { + (editor as Record)[SLOT] = pages; +}; + +export const getEditorPages = (editor: object): Page[] => { + const slot = (editor as Record)[SLOT]; + + return Array.isArray(slot) ? (slot as Page[]) : []; +}; diff --git a/packages/pagination/src/lib/paginate.spec.ts b/packages/pagination/src/lib/paginate.spec.ts new file mode 100644 index 0000000000..89d4fb6f69 --- /dev/null +++ b/packages/pagination/src/lib/paginate.spec.ts @@ -0,0 +1,115 @@ +import type { TElement } from 'platejs'; + +import type { Measurer, PageContext, PageRect } from './types'; + +import { allocateFootnotes } from './allocate-footnotes'; +import { paginate } from './paginate'; + +const RECT: PageRect = { + contentHeight: 100, + contentWidth: 600, + height: 200, + width: 800, +}; + +const CTX: PageContext = { + font: '16px sans-serif', + marksFingerprint: '', + width: RECT.contentWidth, +}; + +const fixedHeight = (height: number): Measurer => ({ + measure: () => height, +}); + +const block = (id: string, type = 'p'): TElement => + ({ children: [{ text: id }], id, type }) as unknown as TElement; + +it('returns one empty page for an empty doc', () => { + const pages = paginate([], RECT, CTX, fixedHeight(0)); + + expect(pages).toHaveLength(1); + expect(pages[0].nodes).toEqual([]); +}); + +it('packs blocks until the content budget is exceeded', () => { + const doc = [block('a'), block('b'), block('c'), block('d')]; + const pages = paginate(doc, RECT, CTX, fixedHeight(40)); + + // 40+40 fits (80 ≤ 100); a third 40 would overflow → flush. + expect(pages).toHaveLength(2); + expect( + pages[0].nodes.map((n) => (n as unknown as { id: string }).id) + ).toEqual(['a', 'b']); + expect( + pages[1].nodes.map((n) => (n as unknown as { id: string }).id) + ).toEqual(['c', 'd']); +}); + +it('flushes on a manual page-break void', () => { + const doc = [ + block('a'), + { + children: [{ text: '' }], + type: 'pageBreak', + } as unknown as TElement, + block('b'), + ]; + const pages = paginate(doc, RECT, CTX, fixedHeight(20)); + + expect(pages).toHaveLength(2); + expect((pages[0].nodes[0] as unknown as { id: string }).id).toBe('a'); + expect((pages[1].nodes[0] as unknown as { id: string }).id).toBe('b'); +}); + +it('puts an oversized block on its own page', () => { + const doc = [block('a'), block('big'), block('c')]; + const measure: Measurer = { + measure: (n) => ((n as TElement & { id: string }).id === 'big' ? 1000 : 20), + }; + const pages = paginate(doc, RECT, CTX, measure); + + expect( + pages.map((p) => p.nodes.map((n) => (n as unknown as { id: string }).id)) + ).toEqual([['a'], ['big'], ['c']]); +}); + +it('skips top-level header/footer/footnote-definition', () => { + const doc = [ + { children: [], type: 'header' } as unknown as TElement, + block('a'), + { children: [], type: 'footer' } as unknown as TElement, + { + children: [], + identifier: '1', + type: 'footnoteDefinition', + } as unknown as TElement, + ]; + const pages = paginate(doc, RECT, CTX, fixedHeight(20)); + + expect(pages).toHaveLength(1); + expect( + pages[0].nodes.map((n) => (n as unknown as { id?: string }).id) + ).toEqual(['a']); +}); + +it('allocates footnote definitions to the page that references them', () => { + const refBlock: TElement = { + children: [ + { text: 'see ' }, + { children: [{ text: '' }], identifier: '1', type: 'footnoteReference' }, + ], + type: 'p', + } as unknown as TElement; + const def: TElement = { + children: [{ text: 'first definition' }], + identifier: '1', + type: 'footnoteDefinition', + } as unknown as TElement; + + const pages = paginate([refBlock, block('b')], RECT, CTX, fixedHeight(40)); + const allocated = allocateFootnotes(pages, [def]); + + expect(allocated[0].footnotes).toEqual([def]); + expect(allocated[1]?.footnotes ?? []).toEqual([]); +}); diff --git a/packages/pagination/src/lib/paginate.ts b/packages/pagination/src/lib/paginate.ts new file mode 100644 index 0000000000..0c0315b2d7 --- /dev/null +++ b/packages/pagination/src/lib/paginate.ts @@ -0,0 +1,97 @@ +import type { TElement } from 'platejs'; + +import type { Measurer, Page, PageContext, PageRect } from './types'; + +import { + FOOTER_KEY, + FOOTNOTE_DEFINITION_KEY, + HEADER_KEY, + PAGE_BREAK_KEY, +} from './internal/keys'; +import { marksFingerprint } from './internal/marks-fingerprint'; + +/** + * Derive the page sequence from a flat list of top-level blocks. + * + * Variant A — render-overlay paginator. Walks the doc, calls + * `measurer.measure(node, ctx)` per block, and bin-packs into page rects + * honoring the `rect.contentHeight` budget. Page-break voids + * (`type === KEYS.pageBreak`) are hard splits. Pages are derived; this + * never mutates Slate state. + * + * Top-level `header`, `footer`, and `footnoteDefinition` blocks are + * skipped — they render via the page chrome / footer well, not the body. + * + * @param doc Top-level Slate blocks (`editor.children`). + * @param rect Resolved page geometry (see `resolvePageRect`). + * @param ctx Per-document measurement context. `ctx.marksFingerprint` is + * the doc-level fallback when a block has no own marks. + * @param measurer Pluggable height oracle. Inject a fake monospace one + * in tests; the React layer wires the DOM-backed measurer. + */ +export const paginate = ( + doc: TElement[], + rect: PageRect, + ctx: PageContext, + measurer: Measurer +): Page[] => { + const pages: Page[] = []; + let current: TElement[] = []; + let used = 0; + let pageIndex = 0; + + const flush = (): void => { + if (current.length === 0 && pages.length > 0) return; + + pages.push({ + footnotes: [], + nodes: current, + pageIndex, + rect, + }); + current = []; + used = 0; + pageIndex += 1; + }; + + for (const node of doc) { + if (node.type === PAGE_BREAK_KEY) { + flush(); + continue; + } + if ( + node.type === HEADER_KEY || + node.type === FOOTER_KEY || + node.type === FOOTNOTE_DEFINITION_KEY + ) { + continue; + } + + const nodeFingerprint = marksFingerprint(node) || ctx.marksFingerprint; + const height = measurer.measure(node, { + font: ctx.font, + marksFingerprint: nodeFingerprint, + width: rect.contentWidth, + }); + + if (height > rect.contentHeight && current.length === 0) { + current.push(node); + flush(); + continue; + } + if (used + height > rect.contentHeight && current.length > 0) { + flush(); + } + + current.push(node); + used += height; + } + + flush(); + + if (pages.length === 0) { + pages.push({ footnotes: [], nodes: [], pageIndex: 0, rect }); + } + + return pages; +}; diff --git a/packages/pagination/src/lib/queries/getPageOfPath.ts b/packages/pagination/src/lib/queries/getPageOfPath.ts new file mode 100644 index 0000000000..f8d3c7d5c2 --- /dev/null +++ b/packages/pagination/src/lib/queries/getPageOfPath.ts @@ -0,0 +1,23 @@ +import type { SlateEditor, TElement } from 'platejs'; + +import { getEditorPages } from '../internal/page-state'; + +/** + * Map a top-level Slate path to its derived page index. Returns `-1` when + * the path is empty or the top block is not present in the page snapshot. + */ +export const getPageOfPath = (editor: SlateEditor, path: number[]): number => { + if (path.length === 0) return -1; + + const top = (editor.children as TElement[])[path[0]]; + + if (!top) return -1; + + const pages = getEditorPages(editor); + + for (let i = 0; i < pages.length; i++) { + if (pages[i].nodes.includes(top)) return i; + } + + return -1; +}; diff --git a/packages/pagination/src/lib/queries/getPaginationPages.ts b/packages/pagination/src/lib/queries/getPaginationPages.ts new file mode 100644 index 0000000000..724032ee92 --- /dev/null +++ b/packages/pagination/src/lib/queries/getPaginationPages.ts @@ -0,0 +1,19 @@ +import type { SlateEditor } from 'platejs'; + +import type { Page } from '../types'; + +import { getEditorPages } from '../internal/page-state'; + +/** + * Read the latest derived page sequence stored on the editor by the React + * pagination overlay. Returns an empty array when no pagination cycle has + * run yet. + */ +export const getPaginationPages = (editor: SlateEditor): Page[] => + getEditorPages(editor); + +/** Return the footnotes allocated to a given page index. */ +export const getPaginationFootnotes = ( + editor: SlateEditor, + pageIndex: number +) => getEditorPages(editor)[pageIndex]?.footnotes ?? []; diff --git a/packages/pagination/src/lib/queries/hasChromeBlock.ts b/packages/pagination/src/lib/queries/hasChromeBlock.ts new file mode 100644 index 0000000000..090bb16b8f --- /dev/null +++ b/packages/pagination/src/lib/queries/hasChromeBlock.ts @@ -0,0 +1,17 @@ +import type { SlateEditor, TElement } from 'platejs'; + +import { KEYS } from 'platejs'; + +/** Whether a top-level `header` block currently exists in the doc. */ +export const hasHeaderBlock = (editor: SlateEditor): boolean => { + const headerType = editor.getType(KEYS.header); + + return (editor.children as TElement[]).some((n) => n.type === headerType); +}; + +/** Whether a top-level `footer` block currently exists in the doc. */ +export const hasFooterBlock = (editor: SlateEditor): boolean => { + const footerType = editor.getType(KEYS.footer); + + return (editor.children as TElement[]).some((n) => n.type === footerType); +}; diff --git a/packages/pagination/src/lib/queries/index.ts b/packages/pagination/src/lib/queries/index.ts new file mode 100644 index 0000000000..8b484bdb52 --- /dev/null +++ b/packages/pagination/src/lib/queries/index.ts @@ -0,0 +1,7 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './getPageOfPath'; +export * from './getPaginationPages'; +export * from './hasChromeBlock'; diff --git a/packages/pagination/src/lib/transforms/enforceHeaderFooterInvariants.ts b/packages/pagination/src/lib/transforms/enforceHeaderFooterInvariants.ts new file mode 100644 index 0000000000..7eb720bca3 --- /dev/null +++ b/packages/pagination/src/lib/transforms/enforceHeaderFooterInvariants.ts @@ -0,0 +1,57 @@ +import type { SlateEditor, TElement } from 'platejs'; + +import { KEYS } from 'platejs'; + +/** + * Single header at index 0; single footer at the last index. Anything else + * is normalized away — keeps paste/undo from producing duplicates. + * + * Two-phase to keep indices stable across mutations: + * 1. Drop duplicate headers (keep first) and duplicate footers (keep last), + * removing from the highest index downward so prior indices stay valid. + * 2. Re-scan the surviving header/footer and reposition with `moveNodes`. + */ +export const enforceHeaderFooterInvariants = (editor: SlateEditor): void => { + const headerType = editor.getType(KEYS.header); + const footerType = editor.getType(KEYS.footer); + const initialHeaderIdxs: number[] = []; + const initialFooterIdxs: number[] = []; + + (editor.children as TElement[]).forEach((n, i) => { + if (n.type === headerType) initialHeaderIdxs.push(i); + else if (n.type === footerType) initialFooterIdxs.push(i); + }); + + const removals: number[] = []; + + if (initialHeaderIdxs.length > 1) { + removals.push(...initialHeaderIdxs.slice(1)); + } + if (initialFooterIdxs.length > 1) { + removals.push(...initialFooterIdxs.slice(0, -1)); + } + + removals.sort((a, b) => b - a); + + for (const idx of removals) { + editor.tf.removeNodes({ at: [idx] }); + } + + let surviveHeaderIdx = -1; + let surviveFooterIdx = -1; + + (editor.children as TElement[]).forEach((n, i) => { + if (n.type === headerType && surviveHeaderIdx === -1) surviveHeaderIdx = i; + if (n.type === footerType) surviveFooterIdx = i; + }); + + if (surviveHeaderIdx > 0) { + editor.tf.moveNodes({ at: [surviveHeaderIdx], to: [0] }); + } + + const target = editor.children.length - 1; + + if (surviveFooterIdx !== -1 && surviveFooterIdx !== target) { + editor.tf.moveNodes({ at: [surviveFooterIdx], to: [target] }); + } +}; diff --git a/packages/pagination/src/lib/transforms/ensureFooter.ts b/packages/pagination/src/lib/transforms/ensureFooter.ts new file mode 100644 index 0000000000..ec0d46cb39 --- /dev/null +++ b/packages/pagination/src/lib/transforms/ensureFooter.ts @@ -0,0 +1,19 @@ +import type { SlateEditor, TElement } from 'platejs'; + +import { KEYS } from 'platejs'; + +/** Insert a default footer at the last index when none exists. */ +export const ensureFooter = (editor: SlateEditor): void => { + const footerType = editor.getType(KEYS.footer); + + if ((editor.children as TElement[]).some((n) => n.type === footerType)) + return; + + editor.tf.insertNodes( + { + children: [{ text: 'Footer' }], + type: footerType, + } as TElement, + { at: [editor.children.length] } + ); +}; diff --git a/packages/pagination/src/lib/transforms/ensureHeader.ts b/packages/pagination/src/lib/transforms/ensureHeader.ts new file mode 100644 index 0000000000..6728a51c62 --- /dev/null +++ b/packages/pagination/src/lib/transforms/ensureHeader.ts @@ -0,0 +1,19 @@ +import type { SlateEditor, TElement } from 'platejs'; + +import { KEYS } from 'platejs'; + +/** Insert a default header at index 0 when none exists. */ +export const ensureHeader = (editor: SlateEditor): void => { + const headerType = editor.getType(KEYS.header); + + if ((editor.children as TElement[]).some((n) => n.type === headerType)) + return; + + editor.tf.insertNodes( + { + children: [{ text: 'Header' }], + type: headerType, + } as TElement, + { at: [0] } + ); +}; diff --git a/packages/pagination/src/lib/transforms/index.ts b/packages/pagination/src/lib/transforms/index.ts new file mode 100644 index 0000000000..6cab2f7bbe --- /dev/null +++ b/packages/pagination/src/lib/transforms/index.ts @@ -0,0 +1,13 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './enforceHeaderFooterInvariants'; +export * from './ensureFooter'; +export * from './ensureHeader'; +export * from './insertPageBreak'; +export * from './removeNodesByType'; +export * from './replaceFooter'; +export * from './replaceHeader'; +export * from './toggleFooter'; +export * from './toggleHeader'; diff --git a/packages/pagination/src/lib/transforms/insertPageBreak.ts b/packages/pagination/src/lib/transforms/insertPageBreak.ts new file mode 100644 index 0000000000..49de6078ac --- /dev/null +++ b/packages/pagination/src/lib/transforms/insertPageBreak.ts @@ -0,0 +1,11 @@ +import type { SlateEditor, TElement } from 'platejs'; + +import { KEYS } from 'platejs'; + +/** Insert a hard page-break void at the current selection. */ +export const insertPageBreak = (editor: SlateEditor): void => { + editor.tf.insertNodes({ + children: [{ text: '' }], + type: editor.getType(KEYS.pageBreak), + } as TElement); +}; diff --git a/packages/pagination/src/lib/transforms/removeNodesByType.ts b/packages/pagination/src/lib/transforms/removeNodesByType.ts new file mode 100644 index 0000000000..57ee139548 --- /dev/null +++ b/packages/pagination/src/lib/transforms/removeNodesByType.ts @@ -0,0 +1,15 @@ +import type { SlateEditor, TElement } from 'platejs'; + +/** + * Remove every top-level child whose `type` matches `type`. Iterates from the + * end so removed indices don't invalidate the loop. + */ +export const removeNodesByType = (editor: SlateEditor, type: string): void => { + const children = editor.children as TElement[]; + + for (let i = children.length - 1; i >= 0; i--) { + if (children[i].type === type) { + editor.tf.removeNodes({ at: [i] }); + } + } +}; diff --git a/packages/pagination/src/lib/transforms/replaceFooter.ts b/packages/pagination/src/lib/transforms/replaceFooter.ts new file mode 100644 index 0000000000..b52712c2d7 --- /dev/null +++ b/packages/pagination/src/lib/transforms/replaceFooter.ts @@ -0,0 +1,27 @@ +import type { Descendant, SlateEditor, TElement } from 'platejs'; + +import { KEYS } from 'platejs'; + +/** + * Replace the top-level footer block with `content`, removing any existing + * footer first and reinserting at the end of the doc. + */ +export const replaceFooter = ( + editor: SlateEditor, + content: Descendant[] +): void => { + const footerType = editor.getType(KEYS.footer); + const idx = (editor.children as TElement[]).findIndex( + (n) => n.type === footerType + ); + + if (idx >= 0) editor.tf.removeNodes({ at: [idx] }); + + editor.tf.insertNodes( + { + children: content as TElement['children'], + type: footerType, + } as TElement, + { at: [editor.children.length] } + ); +}; diff --git a/packages/pagination/src/lib/transforms/replaceHeader.ts b/packages/pagination/src/lib/transforms/replaceHeader.ts new file mode 100644 index 0000000000..d4460e71da --- /dev/null +++ b/packages/pagination/src/lib/transforms/replaceHeader.ts @@ -0,0 +1,27 @@ +import type { Descendant, SlateEditor, TElement } from 'platejs'; + +import { KEYS } from 'platejs'; + +/** + * Replace the top-level header block with `content`, removing any existing + * header first and reinserting at index 0. + */ +export const replaceHeader = ( + editor: SlateEditor, + content: Descendant[] +): void => { + const headerType = editor.getType(KEYS.header); + const idx = (editor.children as TElement[]).findIndex( + (n) => n.type === headerType + ); + + if (idx >= 0) editor.tf.removeNodes({ at: [idx] }); + + editor.tf.insertNodes( + { + children: content as TElement['children'], + type: headerType, + } as TElement, + { at: [0] } + ); +}; diff --git a/packages/pagination/src/lib/transforms/toggleFooter.ts b/packages/pagination/src/lib/transforms/toggleFooter.ts new file mode 100644 index 0000000000..3aed9ca752 --- /dev/null +++ b/packages/pagination/src/lib/transforms/toggleFooter.ts @@ -0,0 +1,19 @@ +import type { SlateEditor, TElement } from 'platejs'; + +import { KEYS } from 'platejs'; + +import { ensureFooter } from './ensureFooter'; +import { removeNodesByType } from './removeNodesByType'; + +/** Toggle the document-level footer block; returns new presence. */ +export const toggleFooter = (editor: SlateEditor): boolean => { + const footerType = editor.getType(KEYS.footer); + const present = (editor.children as TElement[]).some( + (n) => n.type === footerType + ); + + if (present) removeNodesByType(editor, footerType); + else ensureFooter(editor); + + return !present; +}; diff --git a/packages/pagination/src/lib/transforms/toggleHeader.ts b/packages/pagination/src/lib/transforms/toggleHeader.ts new file mode 100644 index 0000000000..50d055ce14 --- /dev/null +++ b/packages/pagination/src/lib/transforms/toggleHeader.ts @@ -0,0 +1,19 @@ +import type { SlateEditor, TElement } from 'platejs'; + +import { KEYS } from 'platejs'; + +import { ensureHeader } from './ensureHeader'; +import { removeNodesByType } from './removeNodesByType'; + +/** Toggle the document-level header block; returns new presence. */ +export const toggleHeader = (editor: SlateEditor): boolean => { + const headerType = editor.getType(KEYS.header); + const present = (editor.children as TElement[]).some( + (n) => n.type === headerType + ); + + if (present) removeNodesByType(editor, headerType); + else ensureHeader(editor); + + return !present; +}; diff --git a/packages/pagination/src/lib/types.ts b/packages/pagination/src/lib/types.ts new file mode 100644 index 0000000000..9a2df01599 --- /dev/null +++ b/packages/pagination/src/lib/types.ts @@ -0,0 +1,145 @@ +import type { Descendant, PluginConfig, TElement } from 'platejs'; + +import type { PAGINATION_KEY } from './internal/keys'; + +/** Page geometry in CSS pixels. */ +export type PageRect = { + contentHeight: number; + contentWidth: number; + height: number; + width: number; +}; + +/** Page margin box in CSS pixels. */ +export type PageMargins = { + bottom: number; + left: number; + right: number; + top: number; +}; + +/** + * Page size resolves to a preset key (`'A4'`, `'Letter'`, `'Legal'`) or a + * literal `{ width, height }` in CSS pixels. The string-`(string & {})` + * branch is reserved for future registry-based presets. + */ +export type PageSize = + | 'A4' + | 'Legal' + | 'Letter' + | (string & {}) + | { height: number; width: number }; + +/** + * Resolved layout context for one paginated page. + * + * Variant A (render-overlay) consumes pages as a derived view of the live + * document; nothing here is persisted in the Slate value. + */ +export type Page = { + /** Footnote definition nodes allocated to this page's footer well. */ + footnotes: TElement[]; + /** Top-level block nodes that lay out inside this page's content box. */ + nodes: TElement[]; + /** Index in the derived sequence (0-based). */ + pageIndex: number; + /** Geometry resolved from `BasePaginationConfig.options.pageSize` + margins. */ + rect: PageRect; +}; + +/** + * Per-call layout context handed to {@link Measurer.measure}. + * + * Variant A pins the cache key on `(node.id, marks-fingerprint, font, width)`. + */ +export type PageContext = { + /** Resolved CSS `font` shorthand for the body run. */ + font: string; + /** Hashable fingerprint of marks/styles on this node's leaves. */ + marksFingerprint: string; + /** Rendered content width in CSS pixels. */ + width: number; +}; + +/** + * Measurer contract. + * + * Variant A backs this with `@chenglou/pretext` to estimate rendered block + * height without mounting React. The default exported measurer is a no-op + * passthrough; the real implementation lives behind + * `usePretextMeasurer` in `src/react`. + */ +export type Measurer = { + measure: (node: TElement, ctx: PageContext) => number; +}; + +/** + * Public options for the base pagination plugin. + * + * Variant A keeps these document-level — no per-page node config — because + * pages are derived, not authored. + */ +export type BasePaginationOptions = { + /** Footer slot height in CSS pixels. */ + footerHeight: number; + /** Footnote well height in CSS pixels (allocated bottom of each page). */ + footnoteWell: number; + /** Header slot height in CSS pixels. */ + headerHeight: number; + /** + * Whether the React `PaginationPlugin` should bundle footnote sub-plugins + * (`FootnoteDefinitionPlugin`, `FootnoteReferencePlugin`, + * `FootnoteInputPlugin`). Defaults to `true`. Set to `false` when you want + * pagination without footnote coupling. + */ + includeFootnoteSubPlugins?: boolean; + /** Page margin box. */ + margins: PageMargins; + /** Resolved page size — preset key or literal `{ width, height }` in CSS pixels. */ + pageSize: PageSize; + /** + * Whether the side preview panel is visible. Toggled at runtime via + * `editor.tf.pagination.togglePreview()`. Defaults to `true`. + */ + previewVisible?: boolean; +}; + +/** Editor-API surface contributed by `BasePaginationPlugin`. */ +export type BasePaginationApi = { + pagination: { + getFootnotes: (pageIndex: number) => TElement[]; + getPageOf: (path: number[]) => number; + getPages: () => Page[]; + /** Whether a top-level `header` block currently exists in the doc. */ + hasHeader: () => boolean; + /** Whether a top-level `footer` block currently exists in the doc. */ + hasFooter: () => boolean; + }; +}; + +/** Editor transforms contributed by `BasePaginationPlugin`. */ +export type BasePaginationTransforms = { + pagination: { + insertPageBreak: () => void; + /** Replace the in-flow ``-style margins. */ + setMargins: (margins: PageMargins) => void; + /** Replace the resolved page size (preset key or `{width,height}`). */ + setPageSize: (size: PageSize) => void; + setFooter: (content: Descendant[]) => void; + setHeader: (content: Descendant[]) => void; + /** Toggle the document-level footer block; returns new presence. */ + toggleFooter: () => boolean; + /** Toggle the document-level header block; returns new presence. */ + toggleHeader: () => boolean; + /** Toggle the side preview panel; returns new visibility. */ + togglePreview: () => boolean; + }; +}; + +/** Plugin config tuple for `BasePaginationPlugin`. */ +export type BasePaginationConfig = PluginConfig< + typeof PAGINATION_KEY, + BasePaginationOptions, + BasePaginationApi, + BasePaginationTransforms +>; diff --git a/packages/pagination/src/react/footer-plugin.ts b/packages/pagination/src/react/footer-plugin.ts new file mode 100644 index 0000000000..86212345a0 --- /dev/null +++ b/packages/pagination/src/react/footer-plugin.ts @@ -0,0 +1,5 @@ +import { toPlatePlugin } from 'platejs/react'; + +import { BaseFooterPlugin } from '../lib/base-footer-plugin'; + +export const FooterPlugin = toPlatePlugin(BaseFooterPlugin); diff --git a/packages/pagination/src/react/footnote-portal.tsx b/packages/pagination/src/react/footnote-portal.tsx new file mode 100644 index 0000000000..8ea14506ab --- /dev/null +++ b/packages/pagination/src/react/footnote-portal.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; + +import { FOOTNOTE_DEFINITION_KEY } from '../lib/internal/keys'; + +/** + * Variant A — CodeRabbit Design Choice 2: footnote definitions stay in the + * Slate tree so editing/selection/keyboard nav are unaffected, but in-flow + * appearances are hidden via CSS while the visible representation lives in + * the per-page footer well painted by `PageFrame`. + * + * This component injects the global stylesheet rule that hides + * footnote-definition blocks from the editor body. The visible copy in the + * footer well is a snapshot rendered by `PageFrame`; bidirectional editing + * inside the well is intentionally out of scope for variant A — `print` + * mode (follow-up) renders real DOM in the well via a `createPortal`. + */ +export const FootnotePortal = (): React.JSX.Element => ( + +); diff --git a/packages/pagination/src/react/header-plugin.ts b/packages/pagination/src/react/header-plugin.ts new file mode 100644 index 0000000000..69e1c19bde --- /dev/null +++ b/packages/pagination/src/react/header-plugin.ts @@ -0,0 +1,5 @@ +import { toPlatePlugin } from 'platejs/react'; + +import { BaseHeaderPlugin } from '../lib/base-header-plugin'; + +export const HeaderPlugin = toPlatePlugin(BaseHeaderPlugin); diff --git a/packages/pagination/src/react/index.ts b/packages/pagination/src/react/index.ts new file mode 100644 index 0000000000..404fbcab4c --- /dev/null +++ b/packages/pagination/src/react/index.ts @@ -0,0 +1,12 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './footer-plugin'; +export * from './footnote-portal'; +export * from './header-plugin'; +export * from './page-break-plugin'; +export * from './page-frame'; +export * from './page-overlay'; +export * from './pagination-plugin'; +export * from './use-pretext-measurer'; diff --git a/packages/pagination/src/react/internal/use-page-layout.ts b/packages/pagination/src/react/internal/use-page-layout.ts new file mode 100644 index 0000000000..c4eed31408 --- /dev/null +++ b/packages/pagination/src/react/internal/use-page-layout.ts @@ -0,0 +1,54 @@ +import { useEffect, useMemo } from 'react'; + +import type { TElement } from 'platejs'; + +import type { BasePaginationOptions, Page } from '../../lib/types'; + +import { allocateFootnotes } from '../../lib/allocate-footnotes'; +import { FOOTNOTE_DEFINITION_KEY } from '../../lib/internal/keys'; +import { resolvePageRect } from '../../lib/internal/page-size-presets'; +import { paginate } from '../../lib/paginate'; +import { setEditorPages } from '../../lib/internal/page-state'; +import { usePretextMeasurer } from '../use-pretext-measurer'; + +/** + * Project the editor's children into the derived page sequence for variant A. + * + * Wraps `paginate()` + `allocateFootnotes()` in a `useMemo` keyed on the + * editor children reference and the resolved options. The latest snapshot + * is mirrored to the per-editor `WeakMap` so `editor.api.pagination.*` + * queries resolve without a hook. + */ +export const usePageLayout = ( + editor: { id: string; children: TElement[] }, + options: BasePaginationOptions +): Page[] => { + const measurer = usePretextMeasurer(editor.id); + + const pages = useMemo(() => { + const rect = resolvePageRect(options.pageSize, options.margins, { + footer: options.footerHeight, + footnoteWell: options.footnoteWell, + header: options.headerHeight, + }); + + const raw = paginate( + editor.children, + rect, + { font: '', marksFingerprint: '', width: rect.contentWidth }, + measurer + ); + + const definitions = editor.children.filter( + (n) => n.type === FOOTNOTE_DEFINITION_KEY + ); + + return allocateFootnotes(raw, definitions); + }, [editor.children, measurer, options]); + + useEffect(() => { + setEditorPages(editor as object, pages); + }, [editor, pages]); + + return pages; +}; diff --git a/packages/pagination/src/react/page-break-plugin.ts b/packages/pagination/src/react/page-break-plugin.ts new file mode 100644 index 0000000000..fa2e4b5591 --- /dev/null +++ b/packages/pagination/src/react/page-break-plugin.ts @@ -0,0 +1,5 @@ +import { toPlatePlugin } from 'platejs/react'; + +import { BasePageBreakPlugin } from '../lib/base-page-break-plugin'; + +export const PageBreakPlugin = toPlatePlugin(BasePageBreakPlugin); diff --git a/packages/pagination/src/react/page-frame.tsx b/packages/pagination/src/react/page-frame.tsx new file mode 100644 index 0000000000..b075cc2e4d --- /dev/null +++ b/packages/pagination/src/react/page-frame.tsx @@ -0,0 +1,235 @@ +import * as React from 'react'; + +import type { TElement } from 'platejs'; + +import type { Page } from '../lib/types'; + +const HEADING_TYPE_RE = /^h([1-6])$/; + +export type PageFrameProps = { + /** Resolved chrome heights from `BasePaginationOptions`. */ + chrome: { footerHeight: number; footnoteWell: number; headerHeight: number }; + /** First-class footer element copied off the document, if any. */ + documentFooter?: TElement; + /** First-class header element copied off the document, if any. */ + documentHeader?: TElement; + page: Page; + /** Vertical position of the page in the overlay coordinate space. */ + top: number; +}; + +/** + * Single page chrome rendered by the overlay: header band, content rect, + * footnote well, footer band — plus a faithful mini-rendering of each block + * in the body so the panel doubles as a content-aware preview. + */ +export const PageFrame = ({ + chrome, + documentFooter, + documentHeader, + page, + top, +}: PageFrameProps): React.JSX.Element => { + const { rect } = page; + const headerOffset = chrome.headerHeight; + const footnoteWellTop = + rect.height - chrome.footerHeight - chrome.footnoteWell; + const footerTop = rect.height - chrome.footerHeight; + + return ( + + ); +}; + +const BlockPreview = ({ node }: { node: TElement }): React.JSX.Element => { + const text = collectInlineText(node); + const type = node.type; + + if (typeof type === 'string' && HEADING_TYPE_RE.test(type)) { + const level = Number.parseInt(type.slice(1), 10); + const sizes = [0, 28, 22, 18, 16, 14, 13]; + + return ( +
+ {text} +
+ ); + } + if (type === 'blockquote') { + return ( +
+ {text} +
+ ); + } + if (type === 'code_block') { + return ( +
+ {text} +
+ ); + } + + return ( +
+ {text || ' '} +
+ ); +}; + +const collectInlineText = (node: TElement | undefined): string => { + if (!node) return ''; + let out = ''; + walk(node, (t) => { + out += t; + }); + + return out; +}; + +const walk = ( + node: { children?: unknown[]; text?: string }, + visit: (text: string) => void +): void => { + if (typeof node.text === 'string') { + visit(node.text); + + return; + } + if (!Array.isArray(node.children)) return; + for (const child of node.children) { + walk(child as { children?: unknown[]; text?: string }, visit); + } +}; diff --git a/packages/pagination/src/react/page-overlay.tsx b/packages/pagination/src/react/page-overlay.tsx new file mode 100644 index 0000000000..e80d898ac6 --- /dev/null +++ b/packages/pagination/src/react/page-overlay.tsx @@ -0,0 +1,199 @@ +import * as React from 'react'; + +import type { TElement } from 'platejs'; +import { useEditorRef, useEditorValue, usePluginOption } from 'platejs/react'; + +import { + type BasePaginationConfig, + BasePaginationPlugin, +} from '../lib/base-pagination-plugin'; +import { FOOTER_KEY, HEADER_KEY } from '../lib/internal/keys'; +import { FootnotePortal } from './footnote-portal'; +import { usePageLayout } from './internal/use-page-layout'; +import { PageFrame } from './page-frame'; + +const STACK_GAP = 12; + +/** + * Render-overlay shell mounted via `render.afterEditable`. + * + * Variant A — CodeRabbit Design Choice 1: pages are derived at render time + * and painted as a side-panel preview on top of the live editor. The Slate + * document is never mutated by this component. + * + * Visibility is controlled by the plugin option `previewVisible`, toggled + * via `editor.tf.pagination.togglePreview()`. When hidden the component + * still mounts (so the toggle stays reactive) but renders nothing. + * + * Updates reactively as the document changes via `useEditorValue`. + */ +export const PageOverlay = (): React.JSX.Element | null => { + const editor = useEditorRef(); + const visible = usePluginOption(BasePaginationPlugin, 'previewVisible'); + const pageSize = usePluginOption(BasePaginationPlugin, 'pageSize'); + const margins = usePluginOption(BasePaginationPlugin, 'margins'); + const value = useEditorValue(); + + void pageSize; + void margins; + + const options = editor.getOptions(BasePaginationPlugin) as + | BasePaginationConfig['options'] + | undefined; + + const safeOptions = useResolvedOptions(options); + const pages = usePageLayout( + { + children: value as unknown as TElement[], + id: editor.id, + }, + safeOptions + ); + + if (!visible || pages.length === 0) { + return ; + } + + const documentHeader = (value as TElement[]).find( + (n) => n.type === HEADER_KEY + ); + const documentFooter = (value as TElement[]).find( + (n) => n.type === FOOTER_KEY + ); + + return ( + <> + +
+
+ Pages + {`${pages.length}`} +
+
+ {pages.map((page) => { + const scale = computeThumbScale(page.rect.width); + const previewHeight = page.rect.height * scale; + const previewWidth = page.rect.width * scale; + + return ( +
+
+ {`Page ${page.pageIndex + 1}`} +
+
+
+ +
+
+
+ ); + })} +
+
+ + ); +}; + +const useResolvedOptions = ( + options: BasePaginationConfig['options'] | undefined +): BasePaginationConfig['options'] => + React.useMemo( + () => ({ + footerHeight: options?.footerHeight ?? 48, + footnoteWell: options?.footnoteWell ?? 0, + headerHeight: options?.headerHeight ?? 48, + includeFootnoteSubPlugins: options?.includeFootnoteSubPlugins ?? true, + margins: options?.margins ?? { + bottom: 72, + left: 72, + right: 72, + top: 72, + }, + pageSize: options?.pageSize ?? 'A4', + previewVisible: options?.previewVisible ?? true, + }), + [ + options?.footerHeight, + options?.footnoteWell, + options?.headerHeight, + options?.includeFootnoteSubPlugins, + options?.margins, + options?.pageSize, + options?.previewVisible, + ] + ); + +const MAX_THUMB_SCALE = 0.18; +const PANEL_INNER_WIDTH = 196; + +export const computeThumbScale = (pageWidth: number): number => { + if (pageWidth <= 0) return MAX_THUMB_SCALE; + + return Math.min(MAX_THUMB_SCALE, PANEL_INNER_WIDTH / pageWidth); +}; diff --git a/packages/pagination/src/react/pagination-plugin.ts b/packages/pagination/src/react/pagination-plugin.ts new file mode 100644 index 0000000000..068540231f --- /dev/null +++ b/packages/pagination/src/react/pagination-plugin.ts @@ -0,0 +1,52 @@ +import { + FootnoteDefinitionPlugin, + FootnoteInputPlugin, + FootnoteReferencePlugin, +} from '@platejs/footnote/react'; +import { toTPlatePlugin } from 'platejs/react'; + +import { + BasePaginationPlugin, + type BasePaginationConfig, +} from '../lib/base-pagination-plugin'; +import { FooterPlugin } from './footer-plugin'; +import { HeaderPlugin } from './header-plugin'; +import { PageBreakPlugin } from './page-break-plugin'; +import { PageOverlay } from './page-overlay'; + +const FOOTNOTE_SUB_PLUGINS = [ + FootnoteDefinitionPlugin, + FootnoteReferencePlugin, + FootnoteInputPlugin, +]; + +/** + * React-side pagination plugin (variant A). + * + * - Lifts the page-chrome element plugins (header, footer, page break) to the + * React surface. The Slate-side composition lives on `BasePaginationPlugin`. + * - Optionally bundles the footnote sub-plugins (default `true`); set + * `options.includeFootnoteSubPlugins = false` to opt out of footnote + * coupling. + * - Mounts the {@link PageOverlay} via `render.afterEditable` so pages are + * painted as a derived overlay on top of the live editor (CodeRabbit + * Design Choice 1). + * - Mounts {@link FootnotePortal} alongside the overlay to hide in-flow + * `footnoteDefinition` blocks (CodeRabbit Design Choice 2). The visible + * copy is rendered inside each page's footnote well by `PageFrame`. + */ +export const PaginationPlugin = toTPlatePlugin( + BasePaginationPlugin +).extend(({ getOptions }) => ({ + plugins: [ + HeaderPlugin, + FooterPlugin, + PageBreakPlugin, + ...(getOptions().includeFootnoteSubPlugins === false + ? [] + : FOOTNOTE_SUB_PLUGINS), + ], + render: { + afterEditable: PageOverlay, + }, +})); diff --git a/packages/pagination/src/react/use-pretext-measurer.ts b/packages/pagination/src/react/use-pretext-measurer.ts new file mode 100644 index 0000000000..15f2f97544 --- /dev/null +++ b/packages/pagination/src/react/use-pretext-measurer.ts @@ -0,0 +1,276 @@ +import { useMemo } from 'react'; + +import type { TElement } from 'platejs'; + +import type { Measurer, PageContext } from '../lib/types'; + +import { + type MeasureCache, + createMeasureCache, + hashString, +} from '../lib/internal/measure-cache'; + +const FONT_SIZE_RE = + /(\d+(?:\.\d+)?)(px|pt)(?:\/((?:\d+(?:\.\d+)?(?:px|pt)?)|(?:\d+(?:\.\d+)?)))?/; +const PX_SUFFIX_RE = /px$/; +const PT_SUFFIX_RE = /pt$/; +const FONT_SIZE_UNIT_RE = /(\d+(?:\.\d+)?)(px|pt)/; +const WHITESPACE_RE = /\s+/; + +/** + * Returns a {@link Measurer} backed by a canvas-based text-width oracle plus + * the per-instance {@link MeasureCache}. + * + * Cache key matches CodeRabbit Design Choice 3: + * `(node.id, marks-fingerprint, font, width)`. The hook owns the cache so + * measured heights survive React re-renders. The cache resets when the + * editor instance changes (the hook receives a new `editorId` per editor). + * + * The interface mirrors the future `@chenglou/pretext`-backed measurer; only + * the internals change when pretext is wired in. Until then, this DOM-based + * estimator is more than accurate enough for paginating typical prose. + */ +export const usePretextMeasurer = (editorId?: string): Measurer => + useMemo(() => { + const cache: MeasureCache = createMeasureCache(); + const ctx2d = createCanvasContext(); + + void editorId; + + return { + measure: (node: TElement, ctx: PageContext): number => { + const nodeId = + (node as TElement & { id?: string | number }).id?.toString() ?? + fallbackNodeId(node); + const contentHash = hashString( + `${node.type ?? ''}|${collectPlainText(node)}` + ); + + const key = { + contentHash, + font: ctx.font, + marksFingerprint: ctx.marksFingerprint, + nodeId, + width: ctx.width, + }; + + const cached = cache.get(key); + if (cached !== undefined) return cached; + + const height = estimateBlockHeight(node, ctx, ctx2d); + + cache.set(key, height); + + return height; + }, + }; + }, [editorId]); + +const createCanvasContext = (): CanvasRenderingContext2D | null => { + if (typeof document === 'undefined') return null; + + const canvas = document.createElement('canvas'); + + return canvas.getContext('2d'); +}; + +const fallbackNodeId = (node: TElement): string => { + // Hash the leading 64 chars of plain text — stable enough for the cache + // when nodes lack an id (Plate editors typically assign one). + let text = ''; + + walkText(node, (t) => { + text += t; + if (text.length > 64) return false; + + return true; + }); + + return `t:${text.slice(0, 64)}`; +}; + +const walkText = ( + node: { children?: unknown[]; text?: string }, + visit: (text: string) => boolean +): boolean => { + if (typeof node.text === 'string') { + return visit(node.text); + } + if (!Array.isArray(node.children)) return true; + for (const child of node.children) { + const cont = walkText( + child as { children?: unknown[]; text?: string }, + visit + ); + + if (!cont) return false; + } + + return true; +}; + +const estimateBlockHeight = ( + node: TElement, + ctx: PageContext, + canvas: CanvasRenderingContext2D | null +): number => { + const { fontSizePx, lineHeightPx } = parseFont(ctx.font); + + // Resolve a per-block-type baseline scale. Headings render larger than + // body text; void images/embeds get a fixed estimate. The marksFingerprint + // already captures bold/italic so we don't multiply for those. + const scale = blockScale(node.type); + const headingPx = + scale === 1 ? 0 : Math.max(0, scale * fontSizePx - fontSizePx); + const blockSpacingPx = blockSpacing(node.type, fontSizePx); + + const text = collectPlainText(node); + if (text.length === 0) { + return Math.max(lineHeightPx, scale * lineHeightPx) + blockSpacingPx; + } + + const linesEstimate = canvas + ? estimateLineCountFromCanvas(text, canvas, ctx, scale) + : estimateLineCountFallback(text, ctx.width, fontSizePx * scale); + + const lineHeight = scale === 1 ? lineHeightPx : scale * lineHeightPx; + + return linesEstimate * lineHeight + headingPx + blockSpacingPx; +}; + +const estimateLineCountFromCanvas = ( + text: string, + canvas: CanvasRenderingContext2D, + ctx: PageContext, + scale: number +): number => { + // Set the font on the canvas. The PageContext.font already has the + // base body font; we scale up for headings via a font-size override. + canvas.font = scale === 1 ? ctx.font : scaleFont(ctx.font, scale); + + const words = text.split(WHITESPACE_RE).filter(Boolean); + if (words.length === 0) return 1; + + const spaceWidth = canvas.measureText(' ').width; + let lineWidth = 0; + let lines = 1; + + for (const word of words) { + const wordWidth = canvas.measureText(word).width; + + if (lineWidth === 0) { + lineWidth = wordWidth; + continue; + } + if (lineWidth + spaceWidth + wordWidth > ctx.width) { + lines += 1; + lineWidth = wordWidth; + } else { + lineWidth += spaceWidth + wordWidth; + } + } + + return lines; +}; + +const estimateLineCountFallback = ( + text: string, + width: number, + fontSizePx: number +): number => { + const charsPerLine = Math.max(1, Math.floor(width / (fontSizePx * 0.5))); + + return Math.max(1, Math.ceil(text.length / charsPerLine)); +}; + +const blockScale = (type: string | undefined): number => { + switch (type) { + case 'h1': + return 2; + case 'h2': + return 1.5; + case 'h3': + return 1.25; + case 'h4': + case 'h5': + case 'h6': + return 1.1; + default: + return 1; + } +}; + +const blockSpacing = (type: string | undefined, fontSizePx: number): number => { + // Margin-top + margin-bottom approximation per block type. + switch (type) { + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + return fontSizePx * 1.2; + case 'blockquote': + case 'code_block': + return fontSizePx; + default: + return fontSizePx * 0.5; + } +}; + +const parseFont = ( + font: string +): { + fontSizePx: number; + lineHeightPx: number; +} => { + // Tolerant parser — pulls the first `px` (or `pt`) it finds for size, + // and an optional `/lineHeight` immediately after. + const sizeMatch = font.match(FONT_SIZE_RE); + + if (!sizeMatch) { + return { fontSizePx: 16, lineHeightPx: 24 }; + } + + const fontSizePx = + sizeMatch[2] === 'pt' + ? Number.parseFloat(sizeMatch[1]) * (96 / 72) + : Number.parseFloat(sizeMatch[1]); + + const lhRaw = sizeMatch[3]; + let lineHeightPx = fontSizePx * 1.5; + + if (lhRaw) { + if (PX_SUFFIX_RE.test(lhRaw)) { + lineHeightPx = Number.parseFloat(lhRaw); + } else if (PT_SUFFIX_RE.test(lhRaw)) { + lineHeightPx = Number.parseFloat(lhRaw) * (96 / 72); + } else { + lineHeightPx = Number.parseFloat(lhRaw) * fontSizePx; + } + } + + return { fontSizePx, lineHeightPx }; +}; + +const scaleFont = (font: string, scale: number): string => + font.replace( + FONT_SIZE_UNIT_RE, + (_m, n, unit) => + `${Math.round(Number.parseFloat(n) * scale * 100) / 100}${unit}` + ); + +const collectPlainText = (node: TElement): string => { + // Concatenate leaf text exactly — adjacent formatted leaves form one + // word in the rendered DOM, so inserting an artificial space between + // them would over-count line breaks during measurement. + let out = ''; + + walkText(node, (t) => { + out += t; + + return true; + }); + + return out; +}; diff --git a/packages/pagination/src/static/footer-element-static.tsx b/packages/pagination/src/static/footer-element-static.tsx new file mode 100644 index 0000000000..65d32b69fc --- /dev/null +++ b/packages/pagination/src/static/footer-element-static.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; + +import type { SlateElementProps } from 'platejs/static'; + +import { SlateElement } from 'platejs/static'; + +/** + * Static (server-safe) renderer for the page-footer element. + * + * Authored once per document; when serialising to HTML the footer block + * is rendered as a `