fix: table page break for different zoom level

This commit is contained in:
Steffen Becker committed 2026-06-06 22:41:59 +02:00
1 parent 3f3db893ba
commit bfa37d2f8a
2 files changed
+149 -58

No files matched your search

+31 -19
View File
@@ -126,32 +126,44 @@
// the floating toolbar). A document-y maps to tiptapTop + docY * z.
const tiptapTop = tRect.top - cRect.top + editorContainer.scrollTop;
const tiptapLeft = tRect.left - cRect.left + editorContainer.scrollLeft;
// In-cell spacers in document order, paired 1:1 with the bands (both ordered).
const spacers = Array.from(
// A too-tall row break produces one in-cell spacer per column, all tagged with
// the same band key. Group them so each band can span its columns' real gaps.
const spacersByKey = new Map<string, HTMLElement[]>();
for (const sp of Array.from(
tiptap.querySelectorAll<HTMLElement>('[data-page-break-spacer-incell]'),
);
bandStyles = tableBandsDoc.map((b, i) => {
)) {
const key = sp.dataset.pageBreakBoundary;
if (key === undefined) continue;
const arr = spacersByKey.get(key);
if (arr) arr.push(sp);
else spacersByKey.set(key, [sp]);
}
bandStyles = tableBandsDoc.map((b) => {
const gapPx = b.gap * z;
const left = tiptapLeft + b.left * z;
const width = b.width * z;
// Anchor the band to the matching spacer's full rendered extent: its top is
// where the page's cell content ends, its bottom where the content resumes.
// The mask then exactly covers the gap the spacer creates, so it never eats
// content at either edge regardless of sub-pixel zoom drift (which can push
// the real break tens of px off the theoretical page edges). Fall back to
// the theoretical placement when no spacer is found.
const sp = spacers[i];
// Anchor the band to the real rendered extent of this break's spacers, so the
// mask exactly covers the gap and never eats content even under sub-pixel zoom
// drift. Across columns: top = the lowest column's content end (max spacer
// top), bottom = the highest column's resume (min spacer bottom) → no column's
// content is masked at either edge. Fall back to theoretical placement.
const group = spacersByKey.get(String(b.key));
let top: number;
let height: number;
let gapTop: number;
if (sp) {
const r = sp.getBoundingClientRect();
top = r.top - cRect.top + editorContainer.scrollTop;
height = r.height;
// Place the canvas-coloured gap stripe at the real page surface bottom
// (= content-end + bottom margin) relative to the spacer's top.
const spacerTopDoc = (r.top - tRect.top) / z;
const gapTopDoc = b.closeY + b.marginBottom - spacerTopDoc;
if (group && group.length > 0) {
let topScreen = -Infinity; // max spacer top
let bottomScreen = Infinity; // min spacer bottom
for (const sp of group) {
const r = sp.getBoundingClientRect();
topScreen = Math.max(topScreen, r.top - cRect.top + editorContainer.scrollTop);
bottomScreen = Math.min(bottomScreen, r.bottom - cRect.top + editorContainer.scrollTop);
}
top = topScreen;
height = Math.max(0, bottomScreen - topScreen);
// Gap stripe at the real page surface bottom (content-end + bottom margin)
// relative to the band top.
const gapTopDoc = b.closeY + b.marginBottom - (top - tiptapTop) / z;
gapTop = Math.min(Math.max(0, gapTopDoc * z), Math.max(0, height - gapPx));
} else {
top = tiptapTop + b.closeY * z;
+118 -39
View File
@@ -61,6 +61,10 @@ export type PageBreakDebugSnapshot = {
// document px relative to .tiptap's top; Editor.svelte scales/positions them in
// the non-zoomed .editor overlay. Carried on the pm-pagecount event detail.
export type TableBreakBand = {
// Rounded openY = page-content-top where the cell content resumes. Matches the
// `data-page-break-boundary` attribute on this break's in-cell spacers, so
// Editor.svelte can group the (one per column) spacers belonging to this band.
key: number;
closeY: number;
height: number;
left: number;
@@ -129,6 +133,21 @@ function getPageForY(y: number, cycle: number): number {
return Math.floor(y / cycle) + 1;
}
// Sum offsetTop up the offsetParent chain to `ancestor`. offsetTop/offsetHeight
// are unscaled layout px (independent of the CSS `zoom` on .paper), unlike
// getBoundingClientRect which returns scaled values. Measuring in unscaled space
// keeps the whole pagination model matching the rendered layout exactly, so
// breaks land on the page edge at any zoom (no accumulated sub-pixel drift).
function offsetTopWithin(el: HTMLElement, ancestor: HTMLElement): number {
let top = 0;
let node: HTMLElement | null = el;
while (node && node !== ancestor) {
top += node.offsetTop;
node = node.offsetParent as HTMLElement | null;
}
return top;
}
const pageBreakKey = new PluginKey<number>('pageBreaks');
// Transaction meta flag: set it (e.g. when page margins change) to force a
@@ -150,6 +169,13 @@ type Leaf = {
// border + margin/gap mask so the single table element looks closed at each
// page break (it can't break structurally — it's one continuous box).
inTableCell?: boolean;
// Flow brackets for a too-tall row's cells. Each cell is an independent vertical
// flow that starts at the row top, so the placement loop resets its cumulative
// shift per cell: `cellStart` marks a cell's first leaf (`rowStart` additionally
// the row's first cell), `rowEnd` the last leaf of the last cell.
cellStart?: boolean;
rowStart?: boolean;
rowEnd?: boolean;
};
const ATOMIC_TAGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
@@ -408,13 +434,11 @@ export const PageBreaks = Extension.create({
return { naturalLineTop, docPos: lo };
}
function collectLeaves(zoom: number, contentHeight: number): Leaf[] {
function collectLeaves(contentHeight: number): Leaf[] {
const dom = editorView.dom;
const tiptapRect = dom.getBoundingClientRect();
const leaves: Leaf[] = [];
// cumulativeSpacerHeight accumulates spacer offsetHeights — already
// in unscaled document pixels — so it pairs with the unscaled
// naturalTop/Height below.
// cumulativeSpacerHeight accumulates spacer offsetHeights (unscaled
// layout px), matching the offsetTop/offsetHeight-based naturalTop/Height.
let cumulativeSpacerHeight = 0;
// Emit one atomic leaf per table row so the table can break between
@@ -433,12 +457,11 @@ export const PageBreaks = Extension.create({
const realRows = rowEls.filter(r => r.tagName === 'TR' && !r.dataset?.pageBreakSpacer);
// Fallback: no rows found — measure the whole wrapper as one atomic leaf.
if (realRows.length === 0) {
const rect = wrapperEl.getBoundingClientRect();
leaves.push({
el: wrapperEl,
kind: 'atomic',
naturalTop: (rect.top - tiptapRect.top) / zoom - cumulativeSpacerHeight,
naturalHeight: rect.height / zoom,
naturalTop: offsetTopWithin(wrapperEl, dom) - cumulativeSpacerHeight,
naturalHeight: wrapperEl.offsetHeight,
});
return;
}
@@ -451,33 +474,48 @@ export const PageBreaks = Extension.create({
continue;
}
if (tr.tagName !== 'TR') continue;
const rect = tr.getBoundingClientRect();
const rowHeight = rect.height / zoom;
const rowHeight = tr.offsetHeight;
if (rowHeight <= contentHeight) {
// Row fits on a page → atomic leaf; the table breaks between rows.
leaves.push({
el: tr,
kind: 'atomic',
naturalTop: (rect.top - tiptapRect.top) / zoom - cumulativeSpacerHeight,
naturalTop: offsetTopWithin(tr, dom) - cumulativeSpacerHeight,
naturalHeight: rowHeight,
tableRow: { columns, wrapperEl, isFirstRow: !seenRealRow },
});
} else {
// Row taller than a page (a cell holds more than a page of content).
// Flow the tallest cell's blocks across pages via the normal walk —
// its paragraphs become splittable leaves and line-split, while the
// row's shorter cells stay top-aligned on the first page. This keeps
// naturalTop monotonic (only one cell's stacked content is emitted).
// Row taller than a page: each cell is an independent vertical flow
// that starts at the row top and breaks in its own column (like
// LibreOffice/Word row fragments per page). Walk every cell, resetting
// the spacer baseline per cell so a sibling cell's spacers don't shift
// this cell's measured tops; the row consumes the tallest cell's
// spacers. Flow markers (cellStart/rowStart/rowEnd) let the placement
// loop bracket its cumulative shift the same way.
const cells = (Array.from(tr.children) as HTMLElement[]).filter(
c => c.tagName === 'TD' || c.tagName === 'TH',
);
let dominant: HTMLElement | null = null;
let dominantH = -1;
for (const c of cells) {
const h = c.getBoundingClientRect().height;
if (h > dominantH) { dominantH = h; dominant = c; }
const baseline = cumulativeSpacerHeight;
let maxDelta = 0;
let rowStartSet = false;
let rowLastLeafIdx = -1;
for (const cell of cells) {
cumulativeSpacerHeight = baseline; // reset for this cell's flow
const startIdx = leaves.length;
walk(cell, true);
if (leaves.length > startIdx) {
leaves[startIdx].cellStart = true;
if (!rowStartSet) {
leaves[startIdx].rowStart = true;
rowStartSet = true;
}
rowLastLeafIdx = leaves.length - 1;
}
maxDelta = Math.max(maxDelta, cumulativeSpacerHeight - baseline);
}
if (dominant) walk(dominant, true);
// The row's height grows by the tallest cell's spacer total.
cumulativeSpacerHeight = baseline + maxDelta;
if (rowLastLeafIdx >= 0) leaves[rowLastLeafIdx].rowEnd = true;
}
seenRealRow = true;
}
@@ -500,7 +538,6 @@ export const PageBreaks = Extension.create({
const isAtomic = ATOMIC_TAGS.has(tag);
const isSplittable = SPLITTABLE_TAGS.has(tag);
if (isAtomic || isSplittable) {
const rect = child.getBoundingClientRect();
let intraSpacerHeight = 0;
for (const sp of Array.from(
child.querySelectorAll<HTMLElement>('[data-page-break-spacer]'),
@@ -510,8 +547,8 @@ export const PageBreaks = Extension.create({
leaves.push({
el: child,
kind: isAtomic ? 'atomic' : 'splittable',
naturalTop: (rect.top - tiptapRect.top) / zoom - cumulativeSpacerHeight,
naturalHeight: rect.height / zoom - intraSpacerHeight,
naturalTop: offsetTopWithin(child, dom) - cumulativeSpacerHeight,
naturalHeight: child.offsetHeight - intraSpacerHeight,
inTableCell,
});
cumulativeSpacerHeight += intraSpacerHeight;
@@ -522,12 +559,11 @@ export const PageBreaks = Extension.create({
continue;
}
// Unknown block — measure as a splittable leaf with no intra-spacers
const rect = child.getBoundingClientRect();
leaves.push({
el: child,
kind: 'splittable',
naturalTop: (rect.top - tiptapRect.top) / zoom - cumulativeSpacerHeight,
naturalHeight: rect.height / zoom,
naturalTop: offsetTopWithin(child, dom) - cumulativeSpacerHeight,
naturalHeight: child.offsetHeight,
inTableCell,
});
}
@@ -560,9 +596,17 @@ export const PageBreaks = Extension.create({
const contentWidth = (Number.isFinite(pwRaw) ? pwRaw : 794) - marginLeft - (Number.isFinite(mrRaw) ? mrRaw : 80);
const zoom = getZoomFactor();
const leaves = collectLeaves(zoom, CONTENT_HEIGHT);
const leaves = collectLeaves(CONTENT_HEIGHT);
let cumulativeShift = 0;
// Per-cell flow bracketing for a too-tall row (see Leaf flow markers): each
// cell restarts from the shift inherited at the row top; the row then
// advances the main shift by the tallest cell's added shift.
let rowBaseline = 0;
let maxCellDelta = 0;
// Lowest rendered content bottom across all leaves (incl. parallel cells),
// used for the page count — a single last-leaf reading misses taller cells.
let maxEffectiveBottom = 0;
const placements: {
docPos: number;
height: number;
@@ -571,6 +615,9 @@ export const PageBreaks = Extension.create({
// rendered DOM so Editor.svelte can align each close/open band to the
// spacer's actual position.
incell: boolean;
// Rounded openY of this break's band (null for non-cell spacers); written
// to the spacer DOM so a band can find all its columns' spacers.
bandKey: number | null;
}[] = [];
// Full-width close/open borders for in-cell table breaks (see Leaf.inTableCell).
// openY is the page-content-top where the cell content resumes; the band runs
@@ -582,6 +629,17 @@ export const PageBreaks = Extension.create({
for (let i = 0; i < leaves.length; i++) {
const leaf = leaves[i];
// Enter a cell flow: capture the previous cell's added shift, then restart
// this cell from the row baseline so its measured tops align.
if (leaf.cellStart) {
if (leaf.rowStart) {
rowBaseline = cumulativeShift;
maxCellDelta = 0;
} else {
maxCellDelta = Math.max(maxCellDelta, cumulativeShift - rowBaseline);
}
cumulativeShift = rowBaseline;
}
const effectiveTop = leaf.naturalTop + cumulativeShift;
const effectiveBottom = effectiveTop + leaf.naturalHeight;
const page = getPageForY(effectiveTop, CYCLE_PX);
@@ -670,7 +728,13 @@ export const PageBreaks = Extension.create({
// cause 1-px shake on every keystroke at non-100 % zoom.
const h = Math.round(spacerHeight);
if (h > 0) {
placements.push({ docPos: spacerDocPos, height: h, row: spacerRow, incell: !!leaf.inTableCell });
placements.push({
docPos: spacerDocPos,
height: h,
row: spacerRow,
incell: !!leaf.inTableCell,
bandKey: leaf.inTableCell && bandOpenY !== null ? Math.round(bandOpenY) : null,
});
placementsDebug.push({
leafIndex: i,
docPos: spacerDocPos,
@@ -691,6 +755,19 @@ export const PageBreaks = Extension.create({
columns: null,
});
}
// Track the lowest rendered content bottom (cumulativeShift now includes
// this leaf's own spacer, so a pushed/split leaf counts at its real spot).
maxEffectiveBottom = Math.max(
maxEffectiveBottom,
leaf.naturalTop + cumulativeShift + leaf.naturalHeight,
);
// Leave a cell flow: the row's added shift is its tallest cell's; the main
// flow continues below the row from there.
if (leaf.rowEnd) {
maxCellDelta = Math.max(maxCellDelta, cumulativeShift - rowBaseline);
cumulativeShift = rowBaseline + maxCellDelta;
}
}
// Skip the decoration rebuild + transaction dispatch when the
@@ -720,9 +797,12 @@ export const PageBreaks = Extension.create({
}
const spacerEl = document.createElement('div');
spacerEl.dataset.pageBreakSpacer = 'true';
// Tag in-cell spacers so the close/open band can be aligned to this
// spacer's rendered position (see Editor.svelte recomputeBands).
if (p.incell) spacerEl.dataset.pageBreakSpacerIncell = 'true';
// Tag in-cell spacers (with their band key) so Editor.svelte can group
// a break's per-column spacers and align the band to their real extent.
if (p.incell) {
spacerEl.dataset.pageBreakSpacerIncell = 'true';
if (p.bandKey !== null) spacerEl.dataset.pageBreakBoundary = String(p.bandKey);
}
spacerEl.style.height = `${p.height}px`;
spacerEl.style.pointerEvents = 'none';
spacerEl.style.userSelect = 'none';
@@ -738,12 +818,10 @@ export const PageBreaks = Extension.create({
editorView.dispatch(tr);
}
let numPages = 1;
if (leaves.length > 0) {
const lastLeaf = leaves[leaves.length - 1];
const effectiveBottom = lastLeaf.naturalTop + cumulativeShift + lastLeaf.naturalHeight;
numPages = Math.max(1, getPageForY(effectiveBottom, CYCLE_PX));
}
// maxEffectiveBottom is the lowest rendered content across all leaves,
// including parallel cells of a too-tall row (a single last-leaf reading
// would miss a taller sibling column).
const numPages = Math.max(1, getPageForY(maxEffectiveBottom, CYCLE_PX));
const targetHeight = numPages * CYCLE_PX - PAGE_GAP;
dom.style.minHeight = `${targetHeight}px`;
@@ -754,6 +832,7 @@ export const PageBreaks = Extension.create({
// positioned descendant's offsets, which misplaced an in-.tiptap band.
const bandSpan = vm.bottom + PAGE_GAP + vm.top;
const tableBreakBands = tableBands.map((openY) => ({
key: openY,
closeY: openY - bandSpan,
height: bandSpan,
left: marginLeft,