From bfa37d2f8ae92d1c5e10be64eecb303f2dd65b56 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 6 Jun 2026 22:41:59 +0200 Subject: [PATCH] fix: table page break for different zoom level --- src/lib/editor/Editor.svelte | 50 ++++++----- src/lib/editor/pageBreaks.ts | 157 ++++++++++++++++++++++++++--------- 2 files changed, 149 insertions(+), 58 deletions(-) diff --git a/src/lib/editor/Editor.svelte b/src/lib/editor/Editor.svelte index 87d7ca1..0ae8722 100644 --- a/src/lib/editor/Editor.svelte +++ b/src/lib/editor/Editor.svelte @@ -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(); + for (const sp of Array.from( tiptap.querySelectorAll('[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; diff --git a/src/lib/editor/pageBreaks.ts b/src/lib/editor/pageBreaks.ts index d3b8fa6..d6ab696 100644 --- a/src/lib/editor/pageBreaks.ts +++ b/src/lib/editor/pageBreaks.ts @@ -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('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('[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,