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
+

Title

+
+
+
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 = ` +
+

First

+
+
+

Second

+
+ `; + 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([]); + }); +});