The writing surface was a column of text in an empty pane. A sheet was drawn behind it, but only once you started drawing — so writing looked like text floating in the middle of nothing, and the space either side of the column read as a void rather than as a margin. Now every page is a sheet of its own, centred, with a gap after it: the same way the PDF reader draws its pages, because a page in a note and a page in a document should read as the same kind of thing. The gap is the part that takes work. The browser knows nothing about the sheets and lays the text out as one flow, putting a line straight across the gap and cutting it in half. So editor/pagination.js measures the flow and pushes any block that would straddle a gap onto the next page, with a spacer of exactly the right height in front of it. A paragraph moves down entire, which is what a page break means on paper. A block taller than a page is left to run over, since splitting one would mean laying out its insides ourselves. The spacers are decorations, not content: they never enter the document, never reach the server and never appear in an export. Two checks hold that down — one asks whether any block sits in a gap, the other reads the stored body back and looks for them there. Two things that are easy to get wrong. The spacer's height is not the distance the text moves: margins that used to collapse into each other no longer do with something between them, so the layout gains that much for free and the spacer is made exactly that much shorter. And heights change with no transaction to announce it — a webfont landing, an image arriving, the window resizing — which a ResizeObserver on the editor element catches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
7.5 KiB
JavaScript
225 lines
7.5 KiB
JavaScript
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;
|