fix: page gap of between tables

This commit is contained in:
Steffen Becker committed 2026-06-07 14:12:21 +02:00
1 parent 7d7eabf8e4
commit d5aa63cd77
3 files changed
+121 -76

No files matched your search

+96 -54
View File
@@ -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<TableBreakBand[]>([]);
let bandStyles = $state<BandStyle[]>([]);
let gapStripeStyles = $state<GapStripeStyle[]>([]);
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<string, HTMLElement[]>();
@@ -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 @@
</script>
<div class="editor" bind:this={editorContainer}>
<div bind:this={element} class="paper" class:show-formatting-marks={showFormattingMarks} style="zoom: {appliedZoom / 100}"></div>
{#each bandStyles as b}
<div
class="table-break-band"
style="top: {b.top}px; left: {b.left}px; width: {b.width}px; height: {b.height}px; background: {b.background};"
></div>
{/each}
<div class="paper" class:show-formatting-marks={showFormattingMarks} style="zoom: {appliedZoom / 100}">
<!-- Dedicated mount point that TipTap fully owns — keeping it free of Svelte
content avoids Svelte and ProseMirror fighting over the same parent's DOM. -->
<div bind:this={element} class="tiptap-host"></div>
{#if bandStyles.length}
<div class="band-layer">
{#each bandStyles as b}
<div
class="table-break-band"
style="top: {b.top}px; left: {b.left}px; width: {b.width}px; height: {b.height}px;"
></div>
{/each}
{#each gapStripeStyles as s}
<div
class="page-gap-stripe"
style="top: {s.top}px; width: {s.width}px; height: {s.height}px; background: {s.background};"
></div>
{/each}
</div>
{/if}
</div>
{#if tableUi.visible}
<TableToolbar {editor} top={tableUi.top} left={tableUi.left} />
{/if}
</div>
<style>
/* Table page-break close/open band: masks the table's bleeding vertical borders
across the margin/gap and draws the black close (top) + open (bottom) line.
Positioned in the non-zoomed .editor content space (see recomputeBands). */
/* Overlay layer for the table page-break bands. It lives INSIDE the zoomed .paper
(filling it via inset:0) so the bands are scaled by the same CSS `zoom` as the
.tiptap page background — no sub-pixel seam at fractional zoom. z-index lifts it
above the .tiptap content; pointer-events:none keeps the editor clickable. */
.band-layer {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
}
/* Table page-break mask: a solid page-coloured fill over the table's margin band
that hides the table's vertical borders bleeding through the page margins, plus
the black close (top) + open (bottom) line. Document px inside .band-layer. The
dark gap itself is drawn by .page-gap-stripe on top. */
.table-break-band {
position: absolute;
z-index: 50;
pointer-events: none;
background: var(--color-page-bg);
border-top: 1px solid #000;
border-bottom: 1px solid #000;
}
/* The page gap at a table break, drawn as ONE full-page-width element on top of the
masks so the dark gap line is a single rasterised rectangle — no left/right edge
where it could disagree (sub-pixel, at fractional zoom) with the .tiptap
background gap in the side margins. */
.page-gap-stripe {
position: absolute;
left: 0;
pointer-events: none;
}
</style>
+21 -22
View File
@@ -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<EditorView, () => 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<number, number>();
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', {
+4
View File
@@ -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 {