From d5aa63cd775d336a26edd3e485134a0c46f15863 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sun, 7 Jun 2026 14:12:21 +0200 Subject: [PATCH] fix: page gap of between tables --- src/lib/editor/Editor.svelte | 150 ++++++++++++++++++++++------------- src/lib/editor/pageBreaks.ts | 43 +++++----- src/styles/editor.css | 4 + 3 files changed, 121 insertions(+), 76 deletions(-) diff --git a/src/lib/editor/Editor.svelte b/src/lib/editor/Editor.svelte index 0ae8722..fccb1c9 100644 --- a/src/lib/editor/Editor.svelte +++ b/src/lib/editor/Editor.svelte @@ -103,29 +103,32 @@ }; } - // --- Table page-break close/open bands --- - // The pagination plugin (pageBreaks.ts) reports, via the pm-pagecount event, - // bands (in unscaled document px) where an in-cell table is split across a page - // boundary. We render them here in the non-zoomed .editor layer (screen space) - // so they stay aligned with the zoomed content at any zoom level — placing them - // inside the CSS-`zoom`ed .tiptap misplaced them. - type BandStyle = { top: number; left: number; width: number; height: number; background: string }; + // --- Table page-break overlay --- + // pageBreaks.ts reports (via pm-pagecount) the in-cell table breaks in document px + // relative to .tiptap's top. We render the overlay in a .band-layer INSIDE the + // zoomed .paper (in document px) so CSS `zoom` scales it by the exact same transform + // as the .tiptap page background. Two pieces per break: + // • band — a solid page-coloured mask (content width) hiding the table's borders + // bleeding through the page margins, plus the close/open lines. + // • stripe — the dark page gap, drawn as ONE full-page-width element on top. Using + // a single rectangle for the whole gap avoids a left/right edge where two + // separately-rasterised gradients disagree by a sub-pixel (the seam). + type BandStyle = { top: number; left: number; width: number; height: number }; + type GapStripeStyle = { top: number; width: number; height: number; background: string }; let tableBandsDoc = $state([]); let bandStyles = $state([]); + let gapStripeStyles = $state([]); function recomputeBands() { const tiptap = element?.querySelector('.tiptap') as HTMLElement | null; - if (!tiptap || !editorContainer || tableBandsDoc.length === 0) { + if (!tiptap || tableBandsDoc.length === 0) { if (bandStyles.length) bandStyles = []; + if (gapStripeStyles.length) gapStripeStyles = []; return; } const tRect = tiptap.getBoundingClientRect(); - const cRect = editorContainer.getBoundingClientRect(); const z = appliedZoom / 100; - // .tiptap's top/left in the editor's scrollable content space (same basis as - // 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; + const pageWidth = tiptap.offsetWidth; // unscaled doc px = full page width // 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(); @@ -138,49 +141,52 @@ if (arr) arr.push(sp); else spacersByKey.set(key, [sp]); } + // Everything below is in document px (relative to .tiptap's top). The .band-layer + // lives inside the zoomed .paper, so `zoom` applies the scaling — no manual `* z`. + const border = 1; // 1px page-edge line, like the CSS .tiptap background 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 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. + // Anchor the band's vertical extent to the real rendered extent of this break's + // spacers (converted to doc px), so its close/open lines hug the actual content + // and the mask never eats content. Across columns: top = the lowest column's + // content end (max spacer top), bottom = the highest column's resume (min spacer + // bottom). Fall back to the theoretical placement when no spacer is found. const group = spacersByKey.get(String(b.key)); let top: number; let height: number; - let gapTop: number; if (group && group.length > 0) { - let topScreen = -Infinity; // max spacer top - let bottomScreen = Infinity; // min spacer bottom + let maxTop = -Infinity; // lowest column's content end + let minBottom = Infinity; // highest column's resume 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); + maxTop = Math.max(maxTop, (r.top - tRect.top) / z); + minBottom = Math.min(minBottom, (r.bottom - tRect.top) / z); } - 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)); + top = maxTop; + height = Math.max(0, minBottom - maxTop); } else { - top = tiptapTop + b.closeY * z; - height = b.height * z; - gapTop = b.marginBottom * z; + top = b.closeY; + height = b.height; } + // Solid white mask over the table band — hides the table's vertical borders + // bleeding through the page's bottom/top margins. The gap region is repainted + // by the full-width stripe below, so a solid fill here is fine. + return { top, left: b.left, width: b.width, height }; + }); + // Full-page-width gap stripe: the dark page gap + its two page-edge lines, at the + // true surface bottom (closeY + marginBottom = N·cycle − gap). Covers the entire + // natural gap (margins + table) with ONE element → no seam. Painted on top of the + // bands (later in DOM) so it overrides their white fill in the gap region. + gapStripeStyles = tableBandsDoc.map((b) => { + const gapStart = b.closeY + b.marginBottom; return { - top, - left, - width, - height, - // White over the page margins, canvas colour over the gap. + top: gapStart - border, + width: pageWidth, + height: b.gap + 2 * border, background: `linear-gradient(to bottom,` - + ` var(--color-page-bg) 0, var(--color-page-bg) ${gapTop}px,` - + ` var(--color-bg) ${gapTop}px, var(--color-bg) ${gapTop + gapPx}px,` - + ` var(--color-page-bg) ${gapTop + gapPx}px, var(--color-page-bg) 100%)`, + + ` var(--color-page-border) 0, var(--color-page-border) ${border}px,` + + ` var(--color-bg) ${border}px, var(--color-bg) ${border + b.gap}px,` + + ` var(--color-page-border) ${border + b.gap}px, var(--color-page-border) 100%)`, }; }); } @@ -355,27 +361,63 @@
-
- {#each bandStyles as b} -
- {/each} +
+ +
+ {#if bandStyles.length} +
+ {#each bandStyles as b} +
+ {/each} + {#each gapStripeStyles as s} +
+ {/each} +
+ {/if} +
{#if tableUi.visible} {/if}
diff --git a/src/lib/editor/pageBreaks.ts b/src/lib/editor/pageBreaks.ts index d6ab696..688a295 100644 --- a/src/lib/editor/pageBreaks.ts +++ b/src/lib/editor/pageBreaks.ts @@ -51,27 +51,26 @@ export type PageBreakDebugSnapshot = { offsetTopInDoc: number; viewportTop: number; }>; - // The full-width close/open frame bands drawn over in-cell table page breaks - // (the visible black "table closes here" lines). Unscaled document px relative - // to .tiptap's top — same values handed to Editor.svelte via pm-pagecount. + // In-cell table page breaks (where a too-tall cell's content flows across a page). + // Unscaled document px relative to .tiptap's top — same values handed to + // Editor.svelte via pm-pagecount to render the mask + gap overlay. tableBreakBands: TableBreakBand[]; }; -// A close/open band for an in-cell table page break. All values are unscaled -// 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. +// One in-cell table page break. All values are unscaled document px relative to +// .tiptap's top; Editor.svelte renders the mask + gap overlay from them inside the +// zoomed .paper. 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; - width: number; + closeY: number; // content-end of the closing page (band/mask top fallback) + height: number; // bandSpan = marginBottom + gap + marginTop + left: number; // content-area left (mask left) + width: number; // content-area width (mask width) marginBottom: number; gap: number; - marginTop: number; }; const debugAccessors = new WeakMap PageBreakDebugSnapshot | null>(); @@ -622,8 +621,12 @@ export const PageBreaks = Extension.create({ // 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 // from the previous page's content-bottom (close) down through margin/gap/margin - // to openY (open). Deduplicated by openY. - const tableBands: number[] = []; + // to openY (open). Keyed by the ROUNDED openY (the grouping id, matching the + // spacers' data-page-break-boundary attr) → mapped to the UNROUNDED openY so the + // band geometry below lands on the exact page-cycle position. A rounded openY + // would shift the band's gap stripe up to ~0.5px off the CSS background gap, + // showing as a seam at the table's edges with non-integer margins. + const tableBands = new Map(); const leavesDebug: PageBreakDebugSnapshot['leaves'] = []; const placementsDebug: PageBreakDebugSnapshot['placements'] = []; @@ -695,7 +698,7 @@ export const PageBreaks = Extension.create({ // Record a close/open band for breaks inside a too-tall table cell. if (leaf.inTableCell && bandOpenY !== null && spacerHeight > 0) { const key = Math.round(bandOpenY); - if (!tableBands.includes(key)) tableBands.push(key); + if (!tableBands.has(key)) tableBands.set(key, bandOpenY); } leavesDebug.push({ @@ -825,21 +828,17 @@ export const PageBreaks = Extension.create({ const targetHeight = numPages * CYCLE_PX - PAGE_GAP; dom.style.minHeight = `${targetHeight}px`; - // Close/open bands for in-cell table breaks (all values in unscaled - // document px relative to .tiptap's top). Editor.svelte renders them in - // the non-zoomed .editor layer (screen space), so they stay aligned at - // any zoom — CSS `zoom` on .paper doesn't reliably scale an absolutely - // positioned descendant's offsets, which misplaced an in-.tiptap band. + // In-cell table breaks (all values in unscaled document px relative to + // .tiptap's top). Editor.svelte renders the mask + gap overlay from these. const bandSpan = vm.bottom + PAGE_GAP + vm.top; - const tableBreakBands = tableBands.map((openY) => ({ - key: openY, + const tableBreakBands = Array.from(tableBands, ([key, openY]) => ({ + key, closeY: openY - bandSpan, height: bandSpan, left: marginLeft, width: contentWidth, marginBottom: vm.bottom, gap: PAGE_GAP, - marginTop: vm.top, })); dom.dispatchEvent(new CustomEvent('pm-pagecount', { diff --git a/src/styles/editor.css b/src/styles/editor.css index 11ec978..34bbbbb 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -12,6 +12,10 @@ .paper { width: var(--user-page-width, 794px); margin: 0 auto; + /* Positioning context for the table page-break overlay (.band-layer in + Editor.svelte), which lives inside .paper so the ancestor `zoom` scales it + identically to the .tiptap page background. */ + position: relative; } .paper .tiptap {