diff --git a/CHANGELOG.md b/CHANGELOG.md index f557f33..20b3407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index bca475a..31ce0ff 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/flip/DocumentViewer.tsx b/src/flip/DocumentViewer.tsx index ba2f3e2..2649766 100644 --- a/src/flip/DocumentViewer.tsx +++ b/src/flip/DocumentViewer.tsx @@ -4,6 +4,7 @@ import { type LayoutChangeEvent, StyleSheet, useWindowDimensions, View } from 'r import { blockIndexForOffset, DUAL_PAGE_MIN_WIDTH, + keepWithNextFlags, MeasureLayer, PAGE_PADDING, pageForBlock, @@ -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(() => { diff --git a/src/pagination/PaginatedDocument.tsx b/src/pagination/PaginatedDocument.tsx index 61ccd7e..4c45d2c 100644 --- a/src/pagination/PaginatedDocument.tsx +++ b/src/pagination/PaginatedDocument.tsx @@ -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'; @@ -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 { @@ -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(() => { diff --git a/src/pagination/__tests__/paginate.test.ts b/src/pagination/__tests__/paginate.test.ts index 96714d8..bf39210 100644 --- a/src/pagination/__tests__/paginate.test.ts +++ b/src/pagination/__tests__/paginate.test.ts @@ -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]]); @@ -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)); +}); diff --git a/src/pagination/index.ts b/src/pagination/index.ts index 306d0e8..12ad48d 100644 --- a/src/pagination/index.ts +++ b/src/pagination/index.ts @@ -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'; diff --git a/src/pagination/paginate.ts b/src/pagination/paginate.ts index a7d0b25..16cd606 100644 --- a/src/pagination/paginate.ts +++ b/src/pagination/paginate.ts @@ -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) { @@ -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'); +} diff --git a/src/pagination/usePagination.ts b/src/pagination/usePagination.ts index 3b3fd53..d5eee85 100644 --- a/src/pagination/usePagination.ts +++ b/src/pagination/usePagination.ts @@ -15,6 +15,7 @@ export function usePagination( pageHeight: number, resetKey?: unknown, pageWidth?: number, + keepWithNext?: readonly boolean[], ): Pagination { const heightsRef = useRef([]); const [heights, setHeights] = useState(null); @@ -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 };