diff --git a/.changeset/pagination-wrapped-blocks.md b/.changeset/pagination-wrapped-blocks.md
new file mode 100644
index 0000000000..2009c06a57
--- /dev/null
+++ b/.changeset/pagination-wrapped-blocks.md
@@ -0,0 +1,5 @@
+---
+"@platejs/pagination": patch
+---
+
+Fix page-break markers for editors whose block UI wraps Slate elements.
diff --git a/apps/www/package.json b/apps/www/package.json
index 1363600275..2d157b841d 100644
--- a/apps/www/package.json
+++ b/apps/www/package.json
@@ -85,6 +85,7 @@
"@platejs/math": "workspace:^",
"@platejs/media": "workspace:^",
"@platejs/mention": "workspace:^",
+ "@platejs/pagination": "workspace:^",
"@platejs/playwright": "workspace:^",
"@platejs/resizable": "workspace:^",
"@platejs/selection": "workspace:^",
diff --git a/apps/www/src/registry/blocks/editor-ai/components/editor/editor-kit.tsx b/apps/www/src/registry/blocks/editor-ai/components/editor/editor-kit.tsx
index 640dedbc9f..3d49f11883 100644
--- a/apps/www/src/registry/blocks/editor-ai/components/editor/editor-kit.tsx
+++ b/apps/www/src/registry/blocks/editor-ai/components/editor/editor-kit.tsx
@@ -32,6 +32,7 @@ import { MarkdownKit } from '@/registry/components/editor/plugins/markdown-kit';
import { MathKit } from '@/registry/components/editor/plugins/math-kit';
import { MediaKit } from '@/registry/components/editor/plugins/media-kit';
import { MentionKit } from '@/registry/components/editor/plugins/mention-kit';
+import { PaginationKit } from '@/registry/components/editor/plugins/pagination-kit';
import { SlashKit } from '@/registry/components/editor/plugins/slash-kit';
import { SuggestionKit } from '@/registry/components/editor/plugins/suggestion-kit';
import { TableKit } from '@/registry/components/editor/plugins/table-kit';
@@ -65,6 +66,9 @@ export const EditorKit = [
...AlignKit,
...LineHeightKit,
+ // Layout
+ ...PaginationKit,
+
// Collaboration
...DiscussionKit,
...CommentKit,
diff --git a/apps/www/src/registry/components/editor/editor-kit.tsx b/apps/www/src/registry/components/editor/editor-kit.tsx
index 258ac829e0..7d751d6915 100644
--- a/apps/www/src/registry/components/editor/editor-kit.tsx
+++ b/apps/www/src/registry/components/editor/editor-kit.tsx
@@ -33,6 +33,7 @@ import { MarkdownKit } from './plugins/markdown-kit';
import { MathKit } from './plugins/math-kit';
import { MediaKit } from './plugins/media-kit';
import { MentionKit } from './plugins/mention-kit';
+import { PaginationKit } from './plugins/pagination-kit';
import { SlashKit } from './plugins/slash-kit';
import { SuggestionKit } from './plugins/suggestion-kit';
import { TableKit } from './plugins/table-kit';
@@ -67,6 +68,9 @@ export const EditorKit = [
...AlignKit,
...LineHeightKit,
+ // Layout
+ ...PaginationKit,
+
// Collaboration
...DiscussionKit,
...CommentKit,
diff --git a/apps/www/src/registry/components/editor/plugins/pagination-kit.tsx b/apps/www/src/registry/components/editor/plugins/pagination-kit.tsx
new file mode 100644
index 0000000000..83db1c1cf1
--- /dev/null
+++ b/apps/www/src/registry/components/editor/plugins/pagination-kit.tsx
@@ -0,0 +1,7 @@
+'use client';
+
+import { PaginationPlugin } from '@platejs/pagination/react';
+
+// Continuous-view page-break overlay. Enabled by default so demos show page
+// markers immediately; the toolbar button toggles it at runtime.
+export const PaginationKit = [PaginationPlugin];
diff --git a/apps/www/src/registry/registry-blocks.ts b/apps/www/src/registry/registry-blocks.ts
index 9298b02ccc..41a5933268 100644
--- a/apps/www/src/registry/registry-blocks.ts
+++ b/apps/www/src/registry/registry-blocks.ts
@@ -56,6 +56,7 @@ export const registryBlocks: Registry['items'] = [
'math-kit',
'media-kit',
'mention-kit',
+ 'pagination-kit',
'slash-kit',
'suggestion-kit',
'table-kit',
diff --git a/apps/www/src/registry/registry-kits.ts b/apps/www/src/registry/registry-kits.ts
index 01caa24791..a00abe12b4 100644
--- a/apps/www/src/registry/registry-kits.ts
+++ b/apps/www/src/registry/registry-kits.ts
@@ -662,6 +662,7 @@ export const registryKits: Registry['items'] = [
'math-kit',
'media-kit',
'mention-kit',
+ 'pagination-kit',
'slash-kit',
'suggestion-kit',
'table-kit',
@@ -670,6 +671,17 @@ export const registryKits: Registry['items'] = [
],
type: 'registry:component',
},
+ {
+ dependencies: ['@platejs/pagination'],
+ files: [
+ {
+ path: 'components/editor/plugins/pagination-kit.tsx',
+ type: 'registry:component',
+ },
+ ],
+ name: 'pagination-kit',
+ type: 'registry:component',
+ },
{
dependencies: ['@platejs/emoji', '@emoji-mart/data@1.2.1'],
files: [
diff --git a/apps/www/src/registry/registry-ui.ts b/apps/www/src/registry/registry-ui.ts
index 66d1ba19d6..eb20ff111a 100644
--- a/apps/www/src/registry/registry-ui.ts
+++ b/apps/www/src/registry/registry-ui.ts
@@ -384,6 +384,7 @@ export const uiComponents: Registry['items'] = [
'media-toolbar-button',
'mode-toolbar-button',
'more-toolbar-button',
+ 'pagination-toolbar-button',
'table-toolbar-button',
'toggle-toolbar-button',
'turn-into-toolbar-button',
@@ -796,6 +797,15 @@ export const uiComponents: Registry['items'] = [
title: 'More Toolbar Button',
type: 'registry:ui',
},
+ {
+ dependencies: ['@platejs/pagination'],
+ description: 'A toolbar button for page break markers.',
+ files: [{ path: 'ui/pagination-toolbar-button.tsx', type: 'registry:ui' }],
+ name: 'pagination-toolbar-button',
+ registryDependencies: ['toolbar'],
+ title: 'Pagination Toolbar Button',
+ type: 'registry:ui',
+ },
{
dependencies: ['@platejs/resizable'],
description: 'A resizable wrapper with resize handles.',
diff --git a/apps/www/src/registry/ui/fixed-toolbar-buttons.tsx b/apps/www/src/registry/ui/fixed-toolbar-buttons.tsx
index 398461db0d..568dde5da9 100644
--- a/apps/www/src/registry/ui/fixed-toolbar-buttons.tsx
+++ b/apps/www/src/registry/ui/fixed-toolbar-buttons.tsx
@@ -42,6 +42,7 @@ import { MarkToolbarButton } from './mark-toolbar-button';
import { MediaToolbarButton } from './media-toolbar-button';
import { ModeToolbarButton } from './mode-toolbar-button';
import { MoreToolbarButton } from './more-toolbar-button';
+import { PaginationToolbarButton } from './pagination-toolbar-button';
import { TableToolbarButton } from './table-toolbar-button';
import { ToggleToolbarButton } from './toggle-toolbar-button';
import { ToolbarGroup } from './toolbar';
@@ -125,6 +126,7 @@ export function FixedToolbarButtons() {
+
diff --git a/apps/www/src/registry/ui/pagination-toolbar-button.tsx b/apps/www/src/registry/ui/pagination-toolbar-button.tsx
new file mode 100644
index 0000000000..202d6a555a
--- /dev/null
+++ b/apps/www/src/registry/ui/pagination-toolbar-button.tsx
@@ -0,0 +1,26 @@
+'use client';
+
+import { PaginationPlugin } from '@platejs/pagination/react';
+import { SeparatorHorizontalIcon } from 'lucide-react';
+import { useEditorRef, usePluginOption } from 'platejs/react';
+import type * as React from 'react';
+
+import { ToolbarButton } from './toolbar';
+
+export function PaginationToolbarButton(
+ props: React.ComponentProps
+) {
+ const editor = useEditorRef();
+ const enabled = usePluginOption(PaginationPlugin, 'enabled');
+
+ return (
+ editor.setOption(PaginationPlugin, 'enabled', !enabled)}
+ pressed={enabled}
+ tooltip="Page breaks"
+ >
+
+
+ );
+}
diff --git a/docs/plans/2026-05-24-pagination-demo-print-regression.md b/docs/plans/2026-05-24-pagination-demo-print-regression.md
new file mode 100644
index 0000000000..401b84c429
--- /dev/null
+++ b/docs/plans/2026-05-24-pagination-demo-print-regression.md
@@ -0,0 +1,68 @@
+# Pagination Demo Print Regression
+
+## Goal
+
+Find why the demo printed a page successfully on branch
+`codex/pagination-e2e-tests` but not on `codex/playground-pagination-toggle`,
+then fix this branch.
+
+## Constraints
+
+- Pagination work must use pretext for faithful pagination.
+- Do not edit `templates/**` manually.
+- If code changes, verify in the same turn.
+- If browser surface changes, verify with `dev-browser` before handoff.
+
+## Plan
+
+- [x] Load repo skills and rules.
+- [x] Check existing pagination plans and documented learnings.
+- [ ] Reproduce the current failure.
+- [ ] Compare current branch with `codex/pagination-e2e-tests`.
+- [ ] Identify root cause before patching.
+- [ ] Implement focused fix.
+- [ ] Run tests, coverage, lint/type checks, and browser proof as applicable.
+- [ ] Evaluate whether to capture a `docs/solutions/` learning.
+
+## Findings
+
+- Current branch: `codex/playground-pagination-toggle`.
+- Known-good comparison branch exists locally: `codex/pagination-e2e-tests`.
+- `docs/solutions/patterns/critical-patterns.md` is referenced by the skill but
+ is absent in this repo.
+- Existing pagination plans emphasize derived layout, pretext-based measurement,
+ and browser verification for render surfaces.
+- The repo app route `/dev/pagination2` renders pagination labels on this branch:
+ `Page 1 of 4` through `Page 4 of 4`.
+- The template `/editor` route renders zero page markers on load because
+ `PaginationKit` configures `PaginationPlugin` with `enabled: false`.
+- Root cause: the comparison branch tests a direct demo plugin install; this
+ branch's playground integration starts the plugin disabled.
+- Second root cause: full playground editors wrap each top-level Slate element
+ in block UI chrome (`div.relative.group`), while `topLevelBlockElements`
+ only measured direct `[data-slate-node="element"]` children. That made every
+ block fall back to one line, so the full editor composed as a single page.
+
+## Progress
+
+- 2026-05-24: Started branch comparison and regression investigation.
+- 2026-05-24: Reproduced template `/editor` missing pagination markers; direct
+ `/dev/pagination2` demo remains green.
+- 2026-05-24: Fixed measurement lookup for wrapped block DOM, enabled pagination
+ by default in the playground integration, added registry wiring and E2E tests.
+- 2026-05-24: Verified template `/editor` renders `Page 1 of 2` / `Page 2 of 2`.
+
+## Verification
+
+- `pnpm install`
+- `bun test --coverage packages/pagination/src` — 56 pass; package lines are
+ 100% except `react/domMeasure.ts` at 81.48%; Bun did not emit branch coverage.
+- `pnpm turbo build --filter=./packages/pagination`
+- `pnpm turbo typecheck --filter=./packages/pagination --filter=./apps/www`
+- `pnpm lint:fix`
+- `bun typecheck` in `templates/plate-playground-template`
+- `PLAYWRIGHT_BASE_URL=http://localhost:3002 pnpm exec playwright test tooling/e2e/pagination.spec.ts --browser=chromium --workers=1`
+- Browser probe for `http://localhost:3001/editor` — 1 break line, 1 page marker,
+ labels `Page 1 of 2`, `Page 2 of 2`, no console errors.
+- `dev-browser --connect http://127.0.0.1:9222 --help` blocked: CLI not installed
+ in this environment.
diff --git a/docs/solutions/ui-bugs/2026-05-24-pagination-must-measure-wrapped-block-elements.md b/docs/solutions/ui-bugs/2026-05-24-pagination-must-measure-wrapped-block-elements.md
new file mode 100644
index 0000000000..855ab45517
--- /dev/null
+++ b/docs/solutions/ui-bugs/2026-05-24-pagination-must-measure-wrapped-block-elements.md
@@ -0,0 +1,87 @@
+---
+module: Pagination
+date: 2026-05-24
+problem_type: ui_bug
+component: tooling
+symptoms:
+ - "The minimal pagination demo rendered page markers, but the full playground editor rendered none"
+ - "Template /editor showed zero pagination break lines and labels on load"
+ - "The full editor content was taller than one page, but pagination composed it as one page"
+root_cause: logic_error
+resolution_type: code_fix
+severity: medium
+tags:
+ - pagination
+ - pretext
+ - dom-measurement
+ - block-wrappers
+ - registry
+---
+
+# Pagination must measure wrapped block elements
+
+## Problem
+
+Pagination worked in the focused `/dev/pagination2` demo but did not render page markers in the full playground editor.
+
+The two surfaces used the same pagination pipeline. The difference was the editor DOM shape.
+
+## Root Cause
+
+`topLevelBlockElements` queried only direct editable children:
+
+```ts
+editable.querySelectorAll(':scope > [data-slate-node="element"]');
+```
+
+That matched the focused demo, where Slate elements were direct children of the editable.
+
+The full playground block UI wraps each top-level Slate element in an outer UI wrapper. In that DOM shape, the direct-child query returns no blocks. Pagination then falls back to synthetic one-line measurements and can compose the whole document as a single page.
+
+There was also an integration issue: the playground template configured `PaginationPlugin` with `enabled: false`, so even a correct layout could stay invisible on first load.
+
+## Fix
+
+Resolve one top-level Slate element per editable child:
+
+- use the child itself when it is a Slate element
+- otherwise use the first nested Slate element inside that child
+- preserve document order and avoid querying all descendants as independent blocks
+
+Enable pagination in the editor kit integration by default and register the pagination toolbar control with the registry source.
+
+## Why This Works
+
+Pagination needs the same top-level block sequence that Slate renders, not a flat list of every descendant element.
+
+Looking through each editable child preserves that top-level sequence while tolerating wrapper components added by block UI chrome.
+
+## Verification
+
+These checks passed:
+
+```bash
+bun test --coverage packages/pagination/src
+pnpm turbo build --filter=./packages/pagination
+pnpm turbo typecheck --filter=./packages/pagination
+pnpm lint:fix
+PLAYWRIGHT_BASE_URL=http://localhost:3002 pnpm exec playwright test tooling/e2e/pagination.spec.ts --browser=chromium --workers=1
+```
+
+Template `/editor` browser proof after refreshing the local pagination vendor package:
+
+```json
+{
+ "lineCount": 1,
+ "labels": ["Page 1 of 2", "Page 2 of 2"],
+ "errors": []
+}
+```
+
+## Prevention
+
+Do not use direct-child-only selectors for Plate editor block measurement when block UI wrappers can sit between the editable and Slate elements.
+
+Pagination browser checks should cover both the minimal demo route and a full editor route with block wrappers.
+
+When verifying a generated template against local package code, rebuild the package, refresh the template's vendored package copy, and reinstall the template dependency graph before testing the page.
diff --git a/packages/pagination/src/react/PaginationPlugin.tsx b/packages/pagination/src/react/PaginationPlugin.tsx
index 8d5adf9566..4e7e82cddb 100644
--- a/packages/pagination/src/react/PaginationPlugin.tsx
+++ b/packages/pagination/src/react/PaginationPlugin.tsx
@@ -176,20 +176,22 @@ export const PaginationPlugin = toPlatePlugin(BasePaginationPlugin, {
setOption('breaks', getContinuousBreaks(layout));
});
- // A width change re-wraps text and changes pagination. Invalidate the layout
- // and force a re-render so the dirty-recompute effect re-measures at the new
- // width and the overlay re-anchors to the new block tops.
+ // A width change re-wraps text and changes pagination. Always invalidate the
+ // cached layout, but only re-render immediately while the overlay is visible.
useEffect(() => {
const editable = editor.api.toDOMNode(editor);
if (!editable || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
invalidateLayoutRegistry(editor);
- forceRecompute((n) => n + 1);
+
+ if (enabled) {
+ forceRecompute((n) => n + 1);
+ }
});
observer.observe(editable);
return () => observer.disconnect();
- }, [editor]);
+ }, [editor, enabled]);
},
});
diff --git a/packages/pagination/src/react/__tests__/domMeasure.spec.ts b/packages/pagination/src/react/__tests__/domMeasure.spec.ts
new file mode 100644
index 0000000000..dfbe47fdc1
--- /dev/null
+++ b/packages/pagination/src/react/__tests__/domMeasure.spec.ts
@@ -0,0 +1,120 @@
+class StubOffscreenCanvas {
+ getContext() {
+ return {
+ font: '',
+ measureText: (s: string) => ({ width: s.length * 10 }),
+ };
+ }
+}
+// @ts-expect-error - test-only canvas stub
+globalThis.OffscreenCanvas = StubOffscreenCanvas;
+
+import { createDomMeasure, topLevelBlockElements } from '../domMeasure';
+
+const block = (index: number, text: string) => ({
+ id: `b${index}`,
+ path: [index],
+ text,
+ type: 'p',
+});
+
+const attach = (editable: HTMLElement) => {
+ document.body.appendChild(editable);
+
+ return editable;
+};
+
+describe('topLevelBlockElements', () => {
+ it('returns direct Slate element children', () => {
+ const editable = document.createElement('div');
+ editable.innerHTML = `
+ Title
+ Body
+ `;
+
+ expect(topLevelBlockElements(editable).map((el) => el.textContent)).toEqual(
+ ['Title', 'Body']
+ );
+ });
+
+ it('returns Slate elements wrapped by block UI chrome', () => {
+ const editable = document.createElement('div');
+ editable.innerHTML = `
+
+
+
drag handle
+
+ Body link
+
+
+ `;
+
+ expect(
+ topLevelBlockElements(editable).map((el) => el.textContent?.trim())
+ ).toEqual(['Title', 'Body link']);
+ });
+});
+
+describe('createDomMeasure', () => {
+ it('returns null when the block DOM cannot be resolved', () => {
+ const editable = document.createElement('div');
+
+ expect(createDomMeasure(editable)(block(0, 'Missing'))).toBeNull();
+ });
+
+ it('measures direct top-level blocks', () => {
+ const editable = document.createElement('div');
+ editable.innerHTML = 'Body text
';
+ const element = editable.firstElementChild as HTMLElement;
+ element.style.cssText = [
+ 'border-bottom: 6px solid black',
+ 'border-top: 5px solid black',
+ 'font-family: sans-serif',
+ 'font-size: 16px',
+ 'line-height: 20px',
+ 'margin-bottom: 2px',
+ 'margin-top: 1px',
+ 'padding-bottom: 4px',
+ 'padding-left: 11px',
+ 'padding-right: 7px',
+ 'padding-top: 3px',
+ ].join(';');
+ Object.defineProperty(element, 'clientWidth', {
+ configurable: true,
+ value: 200,
+ });
+
+ const metrics = createDomMeasure(attach(editable))(block(0, 'Body text'));
+
+ expect(metrics?.lineHeightPx).toBe(20);
+ expect(metrics?.boxSpacingPx).toBe(21);
+ expect(metrics?.heightPx).toBeGreaterThan(0);
+ });
+
+ it('measures blocks nested inside top-level UI wrappers', () => {
+ const editable = document.createElement('div');
+ editable.innerHTML = `
+
+
+ `;
+ attach(editable);
+ const second = topLevelBlockElements(editable)[1];
+ second.style.cssText = 'font-size: 16px; line-height: 24px';
+ Object.defineProperty(second, 'clientWidth', {
+ configurable: true,
+ value: 200,
+ });
+
+ const metrics = createDomMeasure(editable)(block(1, 'Second'));
+
+ expect(metrics?.lineHeightPx).toBe(24);
+ expect(metrics?.heightPx).toBeGreaterThan(0);
+ });
+});
diff --git a/packages/pagination/src/react/domMeasure.ts b/packages/pagination/src/react/domMeasure.ts
index e29308468e..9b64128aee 100644
--- a/packages/pagination/src/react/domMeasure.ts
+++ b/packages/pagination/src/react/domMeasure.ts
@@ -13,9 +13,17 @@ import { measureBlockHeight } from '../measure/pretext';
/** Direct top-level block elements of an editable, in document order. */
export function topLevelBlockElements(editable: HTMLElement): HTMLElement[] {
- return Array.from(
- editable.querySelectorAll(':scope > [data-slate-node="element"]')
- ) as HTMLElement[];
+ const selector = '[data-slate-node="element"]';
+
+ return Array.from(editable.children).flatMap((child) => {
+ if (child instanceof HTMLElement && child.matches(selector)) {
+ return [child];
+ }
+
+ const nested = child.querySelector(selector);
+
+ return nested instanceof HTMLElement ? [nested] : [];
+ });
}
function resolveLineHeight(style: CSSStyleDeclaration): number {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index af29d2b8fe..bca0f8e123 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -371,6 +371,9 @@ importers:
'@platejs/mention':
specifier: workspace:^
version: link:../../packages/mention
+ '@platejs/pagination':
+ specifier: workspace:^
+ version: link:../../packages/pagination
'@platejs/playwright':
specifier: workspace:^
version: link:../../packages/playwright
diff --git a/templates/plate-playground-template/src/app/dev/pagination2/pagination2-view.tsx b/templates/plate-playground-template/src/app/dev/pagination2/pagination2-view.tsx
index 5ea7b97851..7f9eb40339 100644
--- a/templates/plate-playground-template/src/app/dev/pagination2/pagination2-view.tsx
+++ b/templates/plate-playground-template/src/app/dev/pagination2/pagination2-view.tsx
@@ -1,9 +1,7 @@
'use client';
-import * as React from 'react';
-
import { PaginationPlugin } from '@platejs/pagination/react';
-import { type Value } from 'platejs';
+import type { Value } from 'platejs';
import { Plate, PlateContent, usePlateEditor } from 'platejs/react';
import { BasicNodesKit } from '@/components/editor/plugins/basic-nodes-kit';
diff --git a/templates/plate-playground-template/src/components/editor/editor-kit.tsx b/templates/plate-playground-template/src/components/editor/editor-kit.tsx
index 66e774d445..1d7972de11 100644
--- a/templates/plate-playground-template/src/components/editor/editor-kit.tsx
+++ b/templates/plate-playground-template/src/components/editor/editor-kit.tsx
@@ -32,6 +32,7 @@ import { MarkdownKit } from '@/components/editor/plugins/markdown-kit';
import { MathKit } from '@/components/editor/plugins/math-kit';
import { MediaKit } from '@/components/editor/plugins/media-kit';
import { MentionKit } from '@/components/editor/plugins/mention-kit';
+import { PaginationKit } from '@/components/editor/plugins/pagination-kit';
import { SlashKit } from '@/components/editor/plugins/slash-kit';
import { SuggestionKit } from '@/components/editor/plugins/suggestion-kit';
import { TableKit } from '@/components/editor/plugins/table-kit';
@@ -65,6 +66,9 @@ export const EditorKit = [
...AlignKit,
...LineHeightKit,
+ // Layout
+ ...PaginationKit,
+
// Collaboration
...DiscussionKit,
...CommentKit,
diff --git a/templates/plate-playground-template/src/components/editor/plugins/pagination-kit.tsx b/templates/plate-playground-template/src/components/editor/plugins/pagination-kit.tsx
new file mode 100644
index 0000000000..83db1c1cf1
--- /dev/null
+++ b/templates/plate-playground-template/src/components/editor/plugins/pagination-kit.tsx
@@ -0,0 +1,7 @@
+'use client';
+
+import { PaginationPlugin } from '@platejs/pagination/react';
+
+// Continuous-view page-break overlay. Enabled by default so demos show page
+// markers immediately; the toolbar button toggles it at runtime.
+export const PaginationKit = [PaginationPlugin];
diff --git a/templates/plate-playground-template/src/components/ui/fixed-toolbar-buttons.tsx b/templates/plate-playground-template/src/components/ui/fixed-toolbar-buttons.tsx
index 2392d47da2..371638ce2a 100644
--- a/templates/plate-playground-template/src/components/ui/fixed-toolbar-buttons.tsx
+++ b/templates/plate-playground-template/src/components/ui/fixed-toolbar-buttons.tsx
@@ -40,6 +40,7 @@ import { MarkToolbarButton } from './mark-toolbar-button';
import { MediaToolbarButton } from './media-toolbar-button';
import { ModeToolbarButton } from './mode-toolbar-button';
import { MoreToolbarButton } from './more-toolbar-button';
+import { PaginationToolbarButton } from './pagination-toolbar-button';
import { TableToolbarButton } from './table-toolbar-button';
import { ToggleToolbarButton } from './toggle-toolbar-button';
import { ToolbarGroup } from './toolbar';
@@ -123,6 +124,7 @@ export function FixedToolbarButtons() {
+
diff --git a/templates/plate-playground-template/src/components/ui/pagination-toolbar-button.tsx b/templates/plate-playground-template/src/components/ui/pagination-toolbar-button.tsx
new file mode 100644
index 0000000000..202d6a555a
--- /dev/null
+++ b/templates/plate-playground-template/src/components/ui/pagination-toolbar-button.tsx
@@ -0,0 +1,26 @@
+'use client';
+
+import { PaginationPlugin } from '@platejs/pagination/react';
+import { SeparatorHorizontalIcon } from 'lucide-react';
+import { useEditorRef, usePluginOption } from 'platejs/react';
+import type * as React from 'react';
+
+import { ToolbarButton } from './toolbar';
+
+export function PaginationToolbarButton(
+ props: React.ComponentProps
+) {
+ const editor = useEditorRef();
+ const enabled = usePluginOption(PaginationPlugin, 'enabled');
+
+ return (
+ editor.setOption(PaginationPlugin, 'enabled', !enabled)}
+ pressed={enabled}
+ tooltip="Page breaks"
+ >
+
+
+ );
+}
diff --git a/tooling/e2e/pagination.spec.ts b/tooling/e2e/pagination.spec.ts
new file mode 100644
index 0000000000..cb576c9dde
--- /dev/null
+++ b/tooling/e2e/pagination.spec.ts
@@ -0,0 +1,128 @@
+import { expect, test } from '@playwright/test';
+
+// ============================================================
+// E2E: @platejs/pagination continuous-view overlay
+//
+// Locks in the user-visible behavior the pagination dogfood pass surfaced:
+// advisory break lines render on load, page labels include the real total, and
+// the overlay remains non-interactive so native editing is untouched.
+// ============================================================
+
+const ROUTE = process.env.PLAYWRIGHT_BASE_URL
+ ? new URL('/dev/pagination2', process.env.PLAYWRIGHT_BASE_URL).toString()
+ : '/dev/pagination2';
+const CONTENT_PER_PAGE = 931; // 1123 - 96 - 96
+
+const BREAK_LINE = '[data-slot="pagination-break-line"]';
+const PAGE_MARKER = '[data-slot="pagination-page-marker"]';
+const LABEL = '[data-slot="pagination-break-label"]';
+const CONTAINER = '[data-slot="pagination-break-lines"]';
+
+/** Read the explicit inline `top` (px) of an absolutely-positioned overlay node. */
+const topOf = (handle: {
+ evaluate: (fn: (el: HTMLElement | SVGElement) => R) => Promise;
+}) => handle.evaluate((el) => Number.parseFloat((el as HTMLElement).style.top));
+
+test.describe('pagination continuous-view overlay', () => {
+ test('advisory break lines render on load', async ({ page }) => {
+ await page.goto(ROUTE);
+
+ await expect(page.locator(BREAK_LINE).first()).toBeVisible();
+ expect(await page.locator(BREAK_LINE).count()).toBeGreaterThan(0);
+ });
+
+ test('renders a "Page 1 of N" marker and consistent "Page K of N" labels', async ({
+ page,
+ }) => {
+ await page.goto(ROUTE);
+ await expect(page.locator(BREAK_LINE).first()).toBeVisible();
+
+ const breakCount = await page.locator(BREAK_LINE).count();
+ const total = breakCount + 1;
+
+ await expect(page.locator(`${PAGE_MARKER} ${LABEL}`)).toHaveText(
+ `Page 1 of ${total}`
+ );
+
+ const labels = await page.locator(`${BREAK_LINE} ${LABEL}`).allInnerTexts();
+ expect(labels).toEqual(
+ Array.from({ length: breakCount }, (_, i) => `Page ${i + 2} of ${total}`)
+ );
+ });
+
+ test('break lines sit on the A4 boundary without accumulating drift', async ({
+ page,
+ }) => {
+ await page.goto(ROUTE);
+ await expect(page.locator(BREAK_LINE).first()).toBeVisible();
+
+ const markerTop = await topOf(page.locator(PAGE_MARKER));
+ const lines = page.locator(BREAK_LINE);
+ const count = await lines.count();
+ expect(count).toBeGreaterThan(1);
+
+ let prev = markerTop;
+ for (let i = 0; i < count; i++) {
+ const top = await topOf(lines.nth(i));
+ const fromOrigin = top - markerTop;
+ const expectedBoundary = (i + 1) * CONTENT_PER_PAGE;
+
+ expect(fromOrigin).toBeLessThanOrEqual(expectedBoundary + 30);
+
+ const gap = top - prev;
+ expect(gap).toBeLessThanOrEqual(CONTENT_PER_PAGE + 30);
+ expect(gap).toBeGreaterThanOrEqual(CONTENT_PER_PAGE - 250);
+ prev = top;
+ }
+ });
+
+ test('page labels stay on-screen on a viewport narrower than the page', async ({
+ page,
+ }) => {
+ await page.setViewportSize({ height: 900, width: 600 });
+ await page.goto(ROUTE);
+ await expect(page.locator(BREAK_LINE).first()).toBeVisible();
+
+ const labels = page.locator(LABEL);
+ const count = await labels.count();
+ expect(count).toBeGreaterThan(0);
+
+ for (let i = 0; i < count; i++) {
+ const box = await labels.nth(i).boundingBox();
+ expect(box).not.toBeNull();
+ expect(box!.x).toBeGreaterThanOrEqual(0);
+ expect(box!.x + box!.width).toBeLessThanOrEqual(600);
+ }
+ });
+
+ test('overlay never intercepts pointer events', async ({ page }) => {
+ await page.goto(ROUTE);
+ await expect(page.locator(BREAK_LINE).first()).toBeVisible();
+
+ const pointerEvents = await page
+ .locator(CONTAINER)
+ .evaluate((el) => getComputedStyle(el).pointerEvents);
+ expect(pointerEvents).toBe('none');
+
+ await expect(
+ page.locator('[contenteditable="true"]').first()
+ ).toBeVisible();
+ });
+
+ test('no console errors while the overlay computes and recomputes', async ({
+ page,
+ }) => {
+ const errors: string[] = [];
+ page.on('console', (msg) => {
+ if (msg.type() === 'error') errors.push(msg.text());
+ });
+ page.on('pageerror', (err) => errors.push(err.message));
+
+ await page.goto(ROUTE);
+ await expect(page.locator(BREAK_LINE).first()).toBeVisible();
+ await page.setViewportSize({ height: 900, width: 700 });
+ await expect(page.locator(BREAK_LINE).first()).toBeVisible();
+
+ expect(errors).toEqual([]);
+ });
+});