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 single block * taller than a page — a long table, a big image — is left where it is and runs * over the gap: splitting one would mean laying out its insides ourselves. */ export const paginationKey = new PluginKey("pagination"); const SPACER_CLASS = "ne-page-spacer"; 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. */ 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 document positions, in the order they are rendered. const positions = []; view.state.doc.forEach((_node, offset) => positions.push(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 >= positions.length) break; const rect = child.getBoundingClientRect(); const cs = getComputedStyle(child); blocks.push({ pos: positions[index], top: rect.top - domTop - seen, height: rect.height, marginTop: px(cs.marginTop), marginBottom: px(cs.marginBottom), }); index++; } const breaks = []; let shift = 0; for (let i = 0; i < blocks.length; i++) { const block = blocks[i]; const top = block.top + shift; // 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 && Math.abs(x.height - b[i].height) < 1); const decorationsFor = (doc, breaks) => DecorationSet.create( doc, breaks.map((b) => Decoration.widget(b.pos, () => 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.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;