Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Added

- `keepWithNextFlags(blocks)` export and an optional fifth `keepWithNext` argument for `usePagination`, plus a third `keepWithNext` argument for `paginate`, to control which blocks stay with the next one.

### Changed

- `CHANGELOG.md` is now included in the published package, so it is visible on npm and unpkg.

### Fixed

- A heading is no longer left alone at the bottom of a page: it moves to the next page together with the block that follows it (consecutive headings travel together). A group taller than a page is still split block by block.

### Removed

## [1.3.0] - 2026-11-01
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ export function Viewer() {
```

`DocumentViewer` measures the content, paginates it to the viewport size and enables flip +
zoom. `parse(source)` returns an immutable `MDDocument` with `plainText` (the text projection
zoom. A heading is never left alone at the bottom of a page: it moves to the next page together
with the block that follows it. `parse(source)` returns an immutable `MDDocument` with `plainText` (the text projection
that highlights and TTS rely on).

## Document sources
Expand Down
10 changes: 9 additions & 1 deletion src/flip/DocumentViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { type LayoutChangeEvent, StyleSheet, useWindowDimensions, View } from 'r
import {
blockIndexForOffset,
DUAL_PAGE_MIN_WIDTH,
keepWithNextFlags,
MeasureLayer,
PAGE_PADDING,
pageForBlock,
Expand Down Expand Up @@ -96,7 +97,14 @@ function ViewerCore({
screenShortSide: Math.min(screen.width, screen.height),
});
const contentHeight = size.height - PAGE_PADDING * 2;
const pagination = usePagination(document.blocks.length, contentHeight, document, pageWidth);
const keepWithNext = useMemo(() => keepWithNextFlags(document.blocks), [document]);
const pagination = usePagination(
document.blocks.length,
contentHeight,
document,
pageWidth,
keepWithNext,
);

const onPageChangeRef = useRef(onPageChange);
useEffect(() => {
Expand Down
12 changes: 10 additions & 2 deletions src/pagination/PaginatedDocument.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { type LayoutChangeEvent, StyleSheet, View } from 'react-native';

import type { MDDocument } from '../parser/types';
Expand All @@ -7,6 +7,7 @@ import { useDocTheme } from '../render';
import { PAGE_PADDING } from './constants';
import MeasureLayer from './MeasureLayer';
import Page from './Page';
import { keepWithNextFlags } from './paginate';
import { usePagination } from './usePagination';

export interface PaginatedDocumentProps {
Expand Down Expand Up @@ -35,7 +36,14 @@ function PaginatedDocument({
const [size, setSize] = useState({ width: 0, height: 0 });
const resolvedTheme = useDocTheme(colorScheme, theme, colorTokens);
const contentHeight = size.height - PAGE_PADDING * 2;
const pagination = usePagination(document.blocks.length, contentHeight, document, size.width);
const keepWithNext = useMemo(() => keepWithNextFlags(document.blocks), [document]);
const pagination = usePagination(
document.blocks.length,
contentHeight,
document,
size.width,
keepWithNext,
);

const onPagesChangeRef = useRef(onPagesChange);
useEffect(() => {
Expand Down
51 changes: 50 additions & 1 deletion src/pagination/__tests__/paginate.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';

import { paginate } from '../paginate';
import { parse } from '../../parser';
import { keepWithNextFlags, paginate } from '../paginate';

test('packs blocks greedily until the page height is exceeded', () => {
assert.deepEqual(paginate([100, 100, 100], 250), [[0, 1], [2]]);
Expand Down Expand Up @@ -38,3 +39,51 @@ test('no page exceeds the height unless a single block does', () => {
assert.ok(single || total <= pageHeight, `page ${page} overflows`);
}
});

test('a heading that would end a page moves to the next one with its block', () => {
// heights: [text, heading, text]; the heading fits on page 1 but its text does not
assert.deepEqual(paginate([100, 30, 100], 150, [false, true, false]), [[0], [1, 2]]);
});

test('without the flag the same heading stays orphaned at the end of the page', () => {
assert.deepEqual(paginate([100, 30, 100], 150), [[0, 1], [2]]);
});

test('consecutive headings travel together with the block that follows', () => {
assert.deepEqual(paginate([100, 20, 20, 100], 150, [false, true, true, false]), [
[0],
[1, 2, 3],
]);
});

test('a heading and its block stay on the same page when they fit', () => {
assert.deepEqual(paginate([30, 30, 30, 30], 150, [false, true, false, false]), [[0, 1, 2, 3]]);
});

test('a group taller than the page is packed block by block', () => {
assert.deepEqual(paginate([60, 60, 60], 100, [true, false, false]), [[0], [1], [2]]);
});

test('a heading that is the last block of the document stays where it is', () => {
assert.deepEqual(paginate([100, 30], 150, [false, true]), [[0, 1]]);
assert.deepEqual(paginate([100, 100, 30], 150, [false, false, true]), [[0], [1, 2]]);
});

test('keeping headings with their block still emits every index once, in order', () => {
const heights = [30, 70, 120, 45, 200, 15, 25, 90];
const flags = [false, true, false, true, true, false, true, false];
const flat = paginate(heights, 150, flags).flat();
assert.deepEqual(
flat,
heights.map((_, i) => i),
);
});

test('keepWithNextFlags flags headings only', () => {
const doc = parse('# Title\n\nParagraph\n\n## Section\n\nMore text\n\n> quote');
assert.deepEqual(
keepWithNextFlags(doc.blocks),
doc.blocks.map((block) => block.type === 'heading'),
);
assert.ok(keepWithNextFlags(doc.blocks).some(Boolean));
});
2 changes: 1 addition & 1 deletion src/pagination/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export {
} from './offsets';
export type { PageProps } from './Page';
export { default as Page } from './Page';
export { paginate } from './paginate';
export { keepWithNextFlags, paginate } from './paginate';
export type { PaginatedDocumentProps } from './PaginatedDocument';
export { default as PaginatedDocument } from './PaginatedDocument';
export type { SpreadInput, SpreadLayout } from './spread';
Expand Down
49 changes: 43 additions & 6 deletions src/pagination/paginate.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,50 @@
export function paginate(heights: number[], pageHeight: number): number[][] {
import type { Block } from '../parser/types';

/**
* Packs blocks into pages. A block flagged in `keepWithNext` (a heading) never ends a page
* on its own: it travels with the blocks that follow it, up to and including the first
* unflagged one. A group that is taller than a page is packed block by block instead.
*/
export function paginate(
heights: number[],
pageHeight: number,
keepWithNext: readonly boolean[] = [],
): number[][] {
const pages: number[][] = [];
let current: number[] = [];
let used = 0;

for (let i = 0; i < heights.length; i++) {
const h = heights[i]!;
if (current.length > 0 && used + h > pageHeight) {
const place = (indices: number[], height: number): void => {
if (current.length > 0 && used + height > pageHeight) {
pages.push(current);
current = [];
used = 0;
}
current.push(i);
used += h;
current.push(...indices);
used += height;
};

let i = 0;
while (i < heights.length) {
let end = i;
while (end < heights.length - 1 && keepWithNext[end]) {
end++;
}
const group: number[] = [];
let groupHeight = 0;
for (let j = i; j <= end; j++) {
group.push(j);
groupHeight += heights[j]!;
}

if (groupHeight <= pageHeight) {
place(group, groupHeight);
} else {
for (const index of group) {
place([index], heights[index]!);
}
}
i = end + 1;
}

if (current.length > 0) {
Expand All @@ -20,3 +53,7 @@ export function paginate(heights: number[], pageHeight: number): number[][] {

return pages;
}

export function keepWithNextFlags(blocks: readonly Block[]): boolean[] {
return blocks.map((block) => block.type === 'heading');
}
5 changes: 3 additions & 2 deletions src/pagination/usePagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function usePagination(
pageHeight: number,
resetKey?: unknown,
pageWidth?: number,
keepWithNext?: readonly boolean[],
): Pagination {
const heightsRef = useRef<number[]>([]);
const [heights, setHeights] = useState<number[] | null>(null);
Expand Down Expand Up @@ -45,8 +46,8 @@ export function usePagination(
);

const pages = useMemo(
() => (heights && pageHeight > 0 ? paginate(heights, pageHeight) : NO_PAGES),
[heights, pageHeight],
() => (heights && pageHeight > 0 ? paginate(heights, pageHeight, keepWithNext) : NO_PAGES),
[heights, pageHeight, keepWithNext],
);

return { pages, isMeasured: heights !== null && pageHeight > 0, onBlockLayout };
Expand Down
Loading