import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; /** * Where one page ends and the next begins. * * The writing surface is drawn as separate sheets with a gap between them, the * way the PDF reader draws its pages. Text, however, is one continuous flow: the * browser knows nothing about our sheets and would happily lay a line of text * across the gap, cutting it in half. * * So the flow is measured and blocks that would straddle a gap are pushed past * it, by inserting a spacer of exactly the right height in front of them. The * spacers are **decorations, not content**: they never enter the document, never * reach the server, and never appear in an export. Markdown has no page, and a * note is still one body. * * The unit is a whole block. A paragraph that would be split across the gap * moves down entire, which is what a page break means on paper. * * A **table** is the exception, and has to be: moved whole it either jumps to * the next page leaving half a sheet blank, or — being taller than a page — has * nowhere to jump to and lies across the gap regardless. So a table is broken * between its rows, which is where a table breaks on paper too. The spacer there * is a row of its own rather than a block: a `div` inside a `tbody` is not a * thing, and a `tr` with one borderless cell pushes the rows after it down * exactly as needed. * * Everything else too tall to fit anywhere — a full-page image — is left where * it is: moving it down only puts the same problem one page further on. */ export const paginationKey = new PluginKey("pagination"); const SPACER_CLASS = "ne-page-spacer"; const SPACER_ROW_CLASS = "ne-page-spacer-row"; const px = (value) => { const n = parseFloat(value); return Number.isFinite(n) ? n : 0; }; /** * The spacer element. * * `delta` is how much the layout actually shifts, which is not the same as the * element's height — see `collapse` below. It is written onto the element * because the next measurement has to subtract it again to recover where the * text would sit without any spacers at all. */ function makeSpacer(height, delta) { const el = document.createElement("div"); el.className = SPACER_CLASS; el.setAttribute("aria-hidden", "true"); el.contentEditable = "false"; el.dataset.delta = String(delta); el.style.height = `${Math.max(0, Math.round(height))}px`; return el; } /** * Reads the current layout and works out where the breaks belong. * * Returns `null` when there is nothing to measure against — no geometry yet, or * a page too short to hold anything. */ /** * The spacer inside a table. * * A row, because that is what a table's children are: anything else is either * dropped by the browser or wrapped in an anonymous cell of its own. The cell * carries the height and is stripped of the borders every other cell has, so the * gap between two sheets stays empty. */ function makeSpacerRow(height, delta, colspan) { const tr = document.createElement("tr"); tr.className = SPACER_ROW_CLASS; tr.setAttribute("aria-hidden", "true"); tr.contentEditable = "false"; tr.dataset.delta = String(delta); const td = document.createElement("td"); td.colSpan = Math.max(1, colspan || 1); td.style.height = `${Math.max(0, Math.round(height))}px`; tr.appendChild(td); return tr; } /** The rows of a table, with the document position of each. */ function tableRows(node, wrapper, domTop, seenBefore) { const table = wrapper.matches?.("table") ? wrapper : wrapper.querySelector(":scope table"); const body = table?.querySelector(":scope > tbody"); if (!body) return null; const rows = []; let inner = 0; let index = 0; // Positions are walked rather than looked up: a row's place in the document is // the table's own position plus the size of every row before it. let pos = 0; for (const el of body.children) { if (el.classList?.contains(SPACER_ROW_CLASS)) { inner += Number(el.dataset.delta) || 0; continue; } if (index >= node.childCount) break; const rect = el.getBoundingClientRect(); rows.push({ index, pos, top: rect.top - domTop - seenBefore - inner, height: rect.height, }); pos += node.child(index).nodeSize; index++; } const columns = table.querySelector(":scope > colgroup")?.children.length || 1; return { rows, inner, columns }; } function computeBreaks(view, geometry) { const pageHeight = geometry?.pageHeight ?? 0; const gap = geometry?.gap ?? 0; if (!(pageHeight > 0)) return null; const pitch = pageHeight + gap; const dom = view.dom; if (!dom?.isConnected) return null; const style = getComputedStyle(dom); const padTop = px(style.paddingTop); const padBottom = px(style.paddingBottom); const usable = pageHeight - padTop - padBottom; if (usable <= 0) return null; const domTop = dom.getBoundingClientRect().top; // Top-level nodes and their positions, in the order they are rendered. const nodes = []; view.state.doc.forEach((node, offset) => nodes.push({ node, offset })); /* Each block's position **as if no spacer existed**. A spacer shifts * everything after it by exactly its recorded delta, so subtracting the * spacers seen so far undoes the last pass and leaves the natural layout — * which is what the simulation below has to start from. */ const blocks = []; let seen = 0; let index = 0; for (const child of dom.children) { if (child.classList?.contains(SPACER_CLASS)) { seen += Number(child.dataset.delta) || 0; continue; } if (index >= nodes.length) break; const { node, offset } = nodes[index]; const rect = child.getBoundingClientRect(); const cs = getComputedStyle(child); const block = { pos: offset, top: rect.top - domTop - seen, height: rect.height, marginTop: px(cs.marginTop), marginBottom: px(cs.marginBottom), }; /* A table is measured row by row, since that is where it can be broken. * Its own spacer rows are subtracted as we go, and then added to `seen`: * they push everything after the table down as surely as a block spacer * does, and the next block's natural position has to allow for it. */ if (node.type.name === "table") { const measured = tableRows(node, child, domTop, seen); if (measured) { block.rows = measured.rows.map((r) => ({ ...r, pos: offset + 1 + r.pos })); block.columns = measured.columns; block.height = rect.height - measured.inner; seen += measured.inner; } } blocks.push(block); index++; } const breaks = []; let shift = 0; for (let i = 0; i < blocks.length; i++) { const block = blocks[i]; const top = block.top + shift; /* ── a table breaks between its rows ── * The row that would straddle the gap starts the next page instead, and the * rows above it stay where they are. Moving the table whole either wastes * the rest of the sheet or, for a table longer than a page, cannot help at * all — which is why a long one used to lie straight across the gap. */ if (block.rows?.length) { let inner = 0; for (const row of block.rows) { // `shift` is what the blocks above pushed down; `inner` is what the // breaks already placed inside this table added on top of that. const rowTop = row.top + shift + inner; const rowPage = Math.floor(rowTop / pitch); const rowEnd = rowPage * pitch + pageHeight - padBottom; if (rowTop + row.height <= rowEnd + 0.5) continue; // A row taller than a whole page cannot be helped by moving it. if (row.height > usable) continue; const delta = (rowPage + 1) * pitch + padTop - rowTop; if (delta <= 0) continue; /* The first row is the table's own top edge: pushing *inside* the table * there would leave its border hanging above the break, so the whole * table moves, exactly as any other block would. */ if (row.index === 0) { if (i === 0) continue; breaks.push({ pos: block.pos, delta, height: Math.max(0, delta - Math.min(blocks[i - 1].marginBottom, block.marginTop)) }); } else { breaks.push({ pos: row.pos, delta, height: delta, row: true, columns: block.columns }); } inner += delta; } shift += inner; continue; } // Taller than a page however it is placed — moving it down would only put // the same problem on the next page, one page further from the top. if (block.height > usable) continue; const page = Math.floor(top / pitch); const pageEnd = page * pitch + pageHeight - padBottom; // Half a pixel of slack: a block that ends exactly on the line has not // crossed it, and rounding should not cost a page. if (top + block.height <= pageEnd + 0.5) continue; if (i === 0) continue; // nothing above it to be pushed past const delta = (page + 1) * pitch + padTop - top; if (delta <= 0) continue; /* Margins that used to collapse into each other no longer do, because the * spacer sits between them. The layout gains that much on top of the * spacer's own height, so the spacer is made exactly that much shorter. */ const collapse = Math.min(blocks[i - 1].marginBottom, block.marginTop); breaks.push({ pos: block.pos, delta, height: Math.max(0, delta - collapse) }); shift += delta; } return breaks; } const sameBreaks = (a, b) => !!a && !!b && a.length === b.length && a.every((x, i) => x.pos === b[i].pos && !!x.row === !!b[i].row && Math.abs(x.height - b[i].height) < 1); const decorationsFor = (doc, breaks) => DecorationSet.create( doc, breaks.map((b) => Decoration.widget( b.pos, () => (b.row ? makeSpacerRow(b.height, b.delta, b.columns) : makeSpacer(b.height, b.delta)), { // Height in the key, so a spacer that has to change size is rebuilt // rather than reused at its old height. key: `page-break-${b.row ? "row" : "block"}-${b.pos}-${Math.round(b.height)}`, side: -1, ignoreSelection: true, marks: [], } ) ) ); export function createPagination(getGeometry) { return new Plugin({ key: paginationKey, state: { init: () => DecorationSet.empty, apply(tr, old) { const next = tr.getMeta(paginationKey); if (next) return next; // Mapped rather than recomputed: typing moves the breaks with the text // until the measurement below catches up, so they do not jump about. return old.map(tr.mapping, tr.doc); }, }, props: { decorations: (state) => paginationKey.getState(state), }, view(view) { let last = null; let frame = 0; const measure = () => { frame = 0; if (view.isDestroyed) return; const breaks = computeBreaks(view, getGeometry?.()); if (!breaks || sameBreaks(last, breaks)) return; last = breaks; view.dispatch( view.state.tr .setMeta(paginationKey, decorationsFor(view.state.doc, breaks)) // Not an edit: it must not be a step in the undo history, and it // must not mark the note dirty. .setMeta("addToHistory", false) ); }; const schedule = () => { if (frame) return; frame = requestAnimationFrame(measure); }; /* Heights change for reasons no transaction reports: a webfont finishing * loading, an image arriving, the window being resized. Watching the * element itself catches all three. */ const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(schedule) : null; observer?.observe(view.dom); document.fonts?.ready?.then(schedule).catch(() => {}); schedule(); return { update: schedule, destroy() { if (frame) cancelAnimationFrame(frame); observer?.disconnect(); }, }; }, }); } /** The extension wrapper, so this can sit in the list with everything else. */ export const Pagination = Extension.create({ name: "pagination", addOptions() { return { getGeometry: null }; }, addProseMirrorPlugins() { return [createPagination(this.options.getGeometry)]; }, }); export default Pagination;