Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/pagination-margin-aware-packing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@platejs/pagination": patch
---

Margin-aware page packing + continuous-overlay polish:

- Compose now packs pages by a block's **flow height** (text height + the DOM box spacing — margins/padding/borders — supplied by the measurer as `flowHeightPx`), falling back to text height when absent. The page count and break placement now match real DOM flow instead of under-counting per-page capacity. `heightPx`/`lineCount` stay text-only so line-level mapping is unaffected.
- Overlay labels show `Page N of M` and add a `Page 1 of M` marker, so the first page and total are always visible.
- Labels moved to the left margin gutter, so they stay on-screen when a narrow viewport overflows the page width.
- The recompute runs in a layout effect (before paint) instead of a post-paint `requestAnimationFrame`, so the advisory lines appear with the content as soon as the editor hydrates.
21 changes: 21 additions & 0 deletions packages/pagination/src/layout/__tests__/compose.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,27 @@ describe('composeLayout (place-whole / option C)', () => {
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
});

it('packs by flowHeightPx (margin-aware) when present, not just text height', () => {
// Text height 400 each → 800 ≤ 931 would fit one page. Flow height 600 each
// (DOM margins) → 1200 > 931, so the second block must overflow to page 2.
nextId = 0;
const out = composeLayout(
snap(
block(400, { flowHeightPx: 600, path: [0] }),
block(400, { flowHeightPx: 600, path: [1] })
),
INPUT
);
expect(out.pages).toHaveLength(2);
expect(out.mapping.pageOfBlock(1)).toBe(1); // block 1 begins page 2
});

it('falls back to heightPx for packing when flowHeightPx is absent', () => {
nextId = 0;
const out = composeLayout(snap(block(400), block(400)), INPUT);
expect(out.pages).toHaveLength(1);
});

it('emits a single empty page for empty input', () => {
const out = composeLayout(snap(), INPUT);
expect(out.pages).toHaveLength(1);
Expand Down
14 changes: 10 additions & 4 deletions packages/pagination/src/layout/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,24 +61,30 @@ export function composeLayout(
pendingReason = undefined;
};

// Packing uses the rendered flow height (text + DOM box spacing) so the engine
// fills a page like the real DOM does. Falls back to text height when the
// measurer didn't supply margins. lineCount stays text-only for line mapping.
const flowOf = (b: MeasuredBlock) => b.flowHeightPx ?? b.heightPx;

const placeBlock = (b: MeasuredBlock) => {
// Place the block whole. If it doesn't fit the remaining space and we're not
// already at the top of a fresh page, move it whole to the next page. A
// block taller than a full frame is placed at the top and overflows.
if (b.heightPx > frameHeight - currentY && fragments.length > 0) {
const flow = flowOf(b);
if (flow > frameHeight - currentY && fragments.length > 0) {
breakToNewPage('block_overflow');
}

push({
blockId: b.id,
fragmentIndex: 0,
heightPx: b.heightPx,
heightPx: flow,
lineCount: b.lineCount,
lineStart: 0,
path: b.path,
y: currentY,
});
currentY += b.heightPx;
currentY += flow;
};

const blocks = snapshot.blocks;
Expand All @@ -93,7 +99,7 @@ export function composeLayout(
i + 1 < blocks.length &&
fragments.length > 0
) {
const combined = b.heightPx + blocks[i + 1].heightPx;
const combined = flowOf(b) + flowOf(blocks[i + 1]);
const remaining = frameHeight - currentY;
if (combined > remaining && combined <= frameHeight) {
breakToNewPage('keep_with_next');
Expand Down
8 changes: 8 additions & 0 deletions packages/pagination/src/layout/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ export type MeasuredBlock = {
path: number[];
/** Measured rendered height at the content width, in CSS px. */
heightPx: number;
/**
* Rendered flow height = text height + the block's own vertical box spacing
* (margins/padding/border) the DOM adds around it. Used for page *packing*
* (which block fits per page) so the engine matches real DOM flow. Falls back
* to {@link heightPx} when absent. `heightPx`/`lineCount` stay text-only so
* line-level mapping is unaffected.
*/
flowHeightPx?: number;
/** Measured line height, in CSS px (>= 1). */
lineHeightPx: number;
/** Number of text lines (>= 1), derived from height / lineHeight. */
Expand Down
10 changes: 10 additions & 0 deletions packages/pagination/src/measure/measure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ import type {
export type BlockMetrics = {
heightPx: number;
lineHeightPx: number;
/**
* The block's own vertical box spacing (margins + padding + borders) the DOM
* adds around its text, in CSS px. Added to text height to form the block's
* flow height for page packing. Optional; defaults to 0 (no spacing).
*/
boxSpacingPx?: number;
};

export type MeasureFn = (block: UnmeasuredBlock) => BlockMetrics | null;
Expand Down Expand Up @@ -70,6 +76,10 @@ export function measureSnapshot(
lineHeightPx,
path: block.path,
};
// Flow height (for packing) = text height + the block's box spacing. Only set
// when the measurer supplied spacing, so the composer falls back cleanly.
const boxSpacingPx = metrics?.boxSpacingPx ?? 0;
if (boxSpacingPx > 0) measured.flowHeightPx = heightPx + boxSpacingPx;
if (block.keepWithNext) measured.keepWithNext = true;
if (block.breakBefore) measured.breakBefore = true;
if (block.splittable === false) measured.splittable = false;
Expand Down
126 changes: 73 additions & 53 deletions packages/pagination/src/react/PaginationPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// margins the DOM flow adds between blocks.
// ============================================================

import React, { useEffect, useState } from 'react';
import React, { useEffect, useLayoutEffect, useState } from 'react';
import {
type EditableSiblingComponent,
toPlatePlugin,
Expand All @@ -32,11 +32,37 @@ import { BasePaginationPlugin } from '../lib/BasePaginationPlugin';
import { getLayoutRegistry, invalidateLayoutRegistry } from '../lib/registry';
import { createDomMeasure, topLevelBlockElements } from './domMeasure';

// Layout effect on the client (run before paint so lines appear with content),
// plain effect on the server (useLayoutEffect is a no-op + warns during SSR).
const useIsomorphicLayoutEffect =
typeof window === 'undefined' ? useEffect : useLayoutEffect;

/** Shared "Page N of M" chip, in the LEFT margin gutter (left of the content). */
const labelStyle: React.CSSProperties = {
background: 'rgb(241 245 249)',
border: '1px solid rgb(203 213 225)',
borderRadius: 4,
color: 'rgb(71 85 105)',
fontSize: 10,
lineHeight: '14px',
marginRight: 8,
padding: '0 5px',
position: 'absolute',
// Right edge pinned to the content's left edge → the chip sits in the left
// margin. The left gutter stays on-screen when a narrow viewport overflows the
// page width (unlike the right gutter, which scrolls off).
right: '100%',
top: -7,
whiteSpace: 'nowrap',
};

/**
* Continuous-view overlay: a thin dashed advisory rule + "Page N" tick at each
* page boundary. `pointer-events: none`, so editing/selection stay fully native.
* Each rule is anchored to the live DOM top of the block pretext chose to begin
* the next page; the label sits in the right margin gutter, clear of body text.
* Continuous-view overlay: a thin dashed advisory rule at each page boundary,
* plus a "Page N of M" chip in the left margin (including a "Page 1 of M" marker
* at the top so the first page and the total are always shown). `pointer-events:
* none`, so editing/selection stay fully native. Each rule is anchored to the
* live DOM top of the block pretext chose to begin the next page. Renders nothing
* for a single-page document.
*/
const PaginationBreakLines: EditableSiblingComponent = () => {
const editor = useEditorRef();
Expand All @@ -57,9 +83,22 @@ const PaginationBreakLines: EditableSiblingComponent = () => {
// Plate renders between the editable and its blocks.
const editableTop = editable.getBoundingClientRect().top;
const blocks = topLevelBlockElements(editable);
const total = breaks.length + 1;
const topOf = (el: HTMLElement) =>
editable.offsetTop + (el.getBoundingClientRect().top - editableTop);

return (
<div data-slot="pagination-break-lines" style={{ pointerEvents: 'none' }}>
{blocks[0] && (
<div
data-slot="pagination-page-marker"
style={{ left, position: 'absolute', top: topOf(blocks[0]), width }}
>
<span data-slot="pagination-break-label" style={labelStyle}>
{`Page 1 of ${total}`}
</span>
</div>
)}
{breaks.map((brk, i) => {
const el = blocks[brk.blockIndex];
if (!el) return null;
Expand All @@ -68,10 +107,7 @@ const PaginationBreakLines: EditableSiblingComponent = () => {
// pretext line count; 0 is a clean whole-block top.
const lineHeight =
Number.parseFloat(getComputedStyle(el).lineHeight) || 0;
Comment on lines 108 to 109

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current line height resolution falls back to 0 if the computed line-height is normal (which parseFloat returns as NaN). This will cause incorrect positioning of advisory lines if lineStart > 0 (e.g., in future line-split modes). It's better to fall back to a heuristic like fontSize * 1.5 or a constant, similar to the resolveLineHeight logic used in domMeasure.ts.

        const style = getComputedStyle(el);
        const lineHeight =
          Number.parseFloat(style.lineHeight) ||
          Number.parseFloat(style.fontSize) * 1.5 ||
          20;

const top =
editable.offsetTop +
(el.getBoundingClientRect().top - editableTop) +
brk.lineStart * lineHeight;
const top = topOf(el) + brk.lineStart * lineHeight;

return (
<div
Expand All @@ -85,24 +121,8 @@ const PaginationBreakLines: EditableSiblingComponent = () => {
width,
}}
>
<span
data-slot="pagination-break-label"
style={{
background: 'rgb(241 245 249)',
border: '1px solid rgb(203 213 225)',
borderRadius: 4,
color: 'rgb(71 85 105)',
fontSize: 10,
left: '100%',
lineHeight: '14px',
marginLeft: 8,
padding: '0 5px',
position: 'absolute',
top: -7,
whiteSpace: 'nowrap',
}}
>
{`Page ${i + 2}`}
<span data-slot="pagination-break-label" style={labelStyle}>
{`Page ${i + 2} of ${total}`}
</span>
</div>
);
Expand All @@ -117,37 +137,37 @@ export const PaginationPlugin = toPlatePlugin(BasePaginationPlugin, {
useHooks: ({ editor, setOption }) => {
const [, forceRecompute] = useState(0);

// Recompute after paint when the layout registry is dirty (content edits via
// the base plugin's apply override). Selection-only changes leave it clean.
// setOption('breaks', …) re-renders the overlay via usePluginOption.
useEffect(() => {
// Recompute when the layout registry is dirty (content edits via the base
// plugin's apply override; selection-only changes leave it clean). Runs in a
// layout effect — after the DOM commits, before paint — so the advisory lines
// paint together with the content the moment the editor hydrates, rather than
// an extra frame later. setOption re-renders the overlay via usePluginOption.
// (The residual delay on first load is the editor's hydration time: the SSR
// content is on screen before the client can measure the DOM to place lines.)
useIsomorphicLayoutEffect(() => {
const registry = getLayoutRegistry(editor);
if (!registry.dirty && registry.output) return;

const raf = requestAnimationFrame(() => {
const editable = editor.api.toDOMNode(editor);
if (!editable) return;

const { atomicTypes, keepWithNextTypes, margins, page, policies } =
editor.getOptions(BasePaginationPlugin);
const widthPx = page.widthPx - margins.leftPx - margins.rightPx;

const snapshot = buildSnapshot(editor.children, {
atomicTypes,
keepWithNextTypes,
});
const measured = measureSnapshot(snapshot, createDomMeasure(editable), {
cache: registry.measureCache,
widthPx,
});
const layout = composeLayout(measured, { margins, page, policies });

registry.output = layout;
registry.dirty = false;
setOption('breaks', getContinuousBreaks(layout));
const editable = editor.api.toDOMNode(editor);
if (!editable) return;

const { atomicTypes, keepWithNextTypes, margins, page, policies } =
editor.getOptions(BasePaginationPlugin);
const widthPx = page.widthPx - margins.leftPx - margins.rightPx;

const snapshot = buildSnapshot(editor.children, {
atomicTypes,
keepWithNextTypes,
});
const measured = measureSnapshot(snapshot, createDomMeasure(editable), {
cache: registry.measureCache,
widthPx,
});
const layout = composeLayout(measured, { margins, page, policies });

return () => cancelAnimationFrame(raf);
registry.output = layout;
registry.dirty = false;
setOption('breaks', getContinuousBreaks(layout));
});

// A width change re-wraps text and changes pagination. Invalidate the layout
Expand Down
22 changes: 22 additions & 0 deletions packages/pagination/src/react/domMeasure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,27 @@ function contentWidth(dom: HTMLElement, style: CSSStyleDeclaration): number {
return Math.max(0, dom.clientWidth - padLeft - padRight);
}

/**
* The block's own vertical box spacing (margins + padding + borders), in px.
* pretext measures only the text height; this is the non-text spacing the DOM
* flow adds around the block, which the composer adds to form the flow height
* used for page packing. Summing top+bottom margins slightly over-counts where
* adjacent margins collapse (gap = max, not sum) — conservative and within
* advisory tolerance; exact collapse modeling is a later refinement.
*/
function verticalBoxSpacing(style: CSSStyleDeclaration): number {
const px = (v: string) => Number.parseFloat(v) || 0;

return (
px(style.marginTop) +
px(style.marginBottom) +
px(style.paddingTop) +
px(style.paddingBottom) +
px(style.borderTopWidth) +
px(style.borderBottomWidth)
);
}

/**
* Build a {@link MeasureFn} that resolves the block's font + content width from
* the live editable, then derives height from the number of lines pretext wraps
Expand All @@ -66,6 +87,7 @@ export function createDomMeasure(editable: HTMLElement): MeasureFn {
const lineHeightPx = resolveLineHeight(style);

return {
boxSpacingPx: verticalBoxSpacing(style),
heightPx: measureBlockHeight(
block.text,
resolveFont(style),
Expand Down