From 6931054b02b2e34a2d62d367f16d62784b5a2315 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Tue, 9 Jun 2026 14:38:42 +0200 Subject: [PATCH] fix: remove grey table lines spanning the page break --- src/lib/editor/Editor.svelte | 65 ++++++--------- src/lib/editor/pageBreaks.ts | 148 +++++++++++++++++++++++++++++------ 2 files changed, 146 insertions(+), 67 deletions(-) diff --git a/src/lib/editor/Editor.svelte b/src/lib/editor/Editor.svelte index f6f6d8b..1947664 100644 --- a/src/lib/editor/Editor.svelte +++ b/src/lib/editor/Editor.svelte @@ -121,8 +121,10 @@ } // --- 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 .paper + // pageBreaks.ts reports (via pm-pagecount) where a single continuous table box crosses + // a page boundary (a too-tall cell's content flowing across pages, or a between-rows + // push), each as one band in document px relative to .tiptap's top. We render the + // overlay in a .band-layer inside .paper // (in document px) so the same `transform: scale()` scales it identically to the // .tiptap page background. Two pieces per break: // • band — a solid page-coloured mask (content width) hiding the table's borders @@ -143,51 +145,30 @@ if (gapStripeStyles.length) gapStripeStyles = []; return; } - const tRect = tiptap.getBoundingClientRect(); - const z = appliedZoom / 100; 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(); - for (const sp of Array.from( - tiptap.querySelectorAll('[data-page-break-spacer-incell]'), - )) { - const key = sp.dataset.pageBreakBoundary; - if (key === undefined) continue; - const arr = spacersByKey.get(key); - 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 scaled .paper, so the transform applies the scaling — no `* z`. const border = 1; // 1px page-edge line, like the CSS .tiptap background bandStyles = tableBandsDoc.map((b) => { - // 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; - if (group && group.length > 0) { - let maxTop = -Infinity; // lowest column's content end - let minBottom = Infinity; // highest column's resume - for (const sp of group) { - const r = sp.getBoundingClientRect(); - maxTop = Math.max(maxTop, (r.top - tRect.top) / z); - minBottom = Math.min(minBottom, (r.bottom - tRect.top) / z); - } - top = maxTop; - height = Math.max(0, minBottom - maxTop); - } else { - 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 }; + // Use pure page geometry: the band spans exactly the inter-page region — from the + // closing page's content-bottom (closeY) down through margin/gap/margin to the next + // page's content-top. This is precisely where the table must show no vertical + // borders, and pagination guarantees no real content ever sits there, so a solid + // mask here can never eat content. (Anchoring the band to the break's spacers + // instead under-covered atomic-push / leaf-jump breaks, where the spacer renders + // below content-bottom — after the empty gap left by the pushed block — leaving the + // top of the page's bottom margin unmasked and the borders bleeding through it.) + // Solid page-coloured fill; the gap region is repainted by the stripe below. + // Overhang 1px past each side: the table's outer L/R borders are centred on the + // content edges (b.left and b.left+b.width), so their outer halves sit just beyond + // the content box. Widening the mask by `border` on each side swallows them whole + // instead of leaving a half-pixel sliver bleeding into the page margins. + return { + top: b.closeY, + left: b.left - border, + width: b.width + 2 * border, + height: b.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 diff --git a/src/lib/editor/pageBreaks.ts b/src/lib/editor/pageBreaks.ts index 788bf3a..49e37cf 100644 --- a/src/lib/editor/pageBreaks.ts +++ b/src/lib/editor/pageBreaks.ts @@ -55,17 +55,49 @@ export type PageBreakDebugSnapshot = { // 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[]; + // Live snapshot of the *rendered* table-break overlay + table geometry (captured from + // the DOM at dump time), so we can tell whether the band-layer actually masks the + // table's borders bleeding through each gap. All rects are unscaled document px + // relative to .tiptap's top-left — the same space as `leaves`/`tableBreakBands` — so + // they line up directly. null until the first dump after the overlay mounts. + overlay: OverlayDebug | null; }; -// 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 OverlayDebug = { + scale: number; // measured .paper transform scale (1 at 100% zoom) + paperFound: boolean; + bandLayerFound: boolean; + // .band-layer's top/left in doc px relative to .tiptap. Should be ~(0,0): a non-zero + // value means the overlay's coordinate origin is offset from the page, so every band + // is painted off its intended gap (a prime suspect for "mask present but not covering"). + bandLayerOrigin: { top: number; left: number } | null; + // Resolved page colours, to confirm the mask is opaque and the right colour. + cssVars: { pageBg: string; pageBorder: string; bg: string }; + // Rendered .table-break-band mask rects (one per band) + their paint-relevant styles. + bands: Array<{ + top: number; left: number; right: number; bottom: number; + width: number; height: number; background: string; zIndex: string; position: string; + }>; + // Rendered .page-gap-stripe rects. + stripes: Array<{ top: number; left: number; right: number; bottom: number; height: number }>; + // Every in the doc: its rendered box + the x of each vertical cell border + // (the lines that bleed). A table taller than CONTENT_HEIGHT spans page gaps, so its + // verticalEdges are exactly the lines a band must cover in each gap. + tables: Array<{ + top: number; left: number; right: number; bottom: number; + borderColor: string; + verticalEdges: number[]; + }>; +}; + +// One page boundary crossed by a single continuous table box. 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. + // Rounded openY (page-content-top where the table resumes) — the dedup id, so two + // columns breaking at the same boundary collapse to one band. key: number; - closeY: number; // content-end of the closing page (band/mask top fallback) + closeY: number; // content-end of the closing page (band/mask top) height: number; // bandSpan = marginBottom + gap + marginTop left: number; // content-area left (mask left) width: number; // content-area width (mask width) @@ -206,9 +238,79 @@ export const PageBreaks = Extension.create({ viewportTop: r.top, }; }); - return { ...snap, renderedSpacers }; + return { ...snap, renderedSpacers, overlay: captureOverlay(tipRect) }; }); + // Capture the rendered table-break overlay + table geometry from the live DOM. + // The overlay (.band-layer) is a sibling of the editor's host inside .paper, so we + // reach it by climbing to .paper. Viewport rects are converted to unscaled doc px + // relative to .tiptap's top-left (divide by the measured paper scale) so they sit + // in the same coordinate space as the bands/leaves and can be compared directly. + function captureOverlay(tipRect: DOMRect): OverlayDebug { + const dom = editorView.dom; + const offH = dom.offsetHeight; + const scale = offH ? tipRect.height / offH : 1; + const toDoc = (r: DOMRect) => ({ + top: (r.top - tipRect.top) / scale, + bottom: (r.bottom - tipRect.top) / scale, + left: (r.left - tipRect.left) / scale, + right: (r.right - tipRect.left) / scale, + }); + const paper = dom.closest('.paper') as HTMLElement | null; + const bandLayer = paper?.querySelector('.band-layer') as HTMLElement | null; + const bandEls = paper + ? Array.from(paper.querySelectorAll('.table-break-band')) + : []; + const stripeEls = paper + ? Array.from(paper.querySelectorAll('.page-gap-stripe')) + : []; + const cs = getComputedStyle(dom); + return { + scale, + paperFound: !!paper, + bandLayerFound: !!bandLayer, + bandLayerOrigin: bandLayer + ? (() => { const d = toDoc(bandLayer.getBoundingClientRect()); return { top: d.top, left: d.left }; })() + : null, + cssVars: { + pageBg: cs.getPropertyValue('--color-page-bg').trim(), + pageBorder: cs.getPropertyValue('--color-page-border').trim(), + bg: cs.getPropertyValue('--color-bg').trim(), + }, + bands: bandEls.map((e) => { + const d = toDoc(e.getBoundingClientRect()); + const ecs = getComputedStyle(e); + return { + top: d.top, left: d.left, right: d.right, bottom: d.bottom, + width: d.right - d.left, height: d.bottom - d.top, + background: ecs.backgroundColor, zIndex: ecs.zIndex, position: ecs.position, + }; + }), + stripes: stripeEls.map((e) => { + const d = toDoc(e.getBoundingClientRect()); + return { top: d.top, left: d.left, right: d.right, bottom: d.bottom, height: d.bottom - d.top }; + }), + tables: Array.from(dom.querySelectorAll('table')).map((t) => { + const d = toDoc(t.getBoundingClientRect()); + // x of each vertical cell border: use the first body row's cells' left edges + // plus the row's right edge. These are the lines that bleed across gaps. + const firstRow = t.querySelector('tbody > tr:not([data-page-break-spacer])') as HTMLElement | null; + const cells = firstRow + ? (Array.from(firstRow.children) as HTMLElement[]).filter(c => c.tagName === 'TD' || c.tagName === 'TH') + : []; + const verticalEdges: number[] = []; + for (const c of cells) verticalEdges.push(toDoc(c.getBoundingClientRect()).left); + if (cells.length) verticalEdges.push(toDoc(cells[cells.length - 1].getBoundingClientRect()).right); + const firstCell = cells[0]; + return { + top: d.top, left: d.left, right: d.right, bottom: d.bottom, + borderColor: firstCell ? getComputedStyle(firstCell).borderTopColor : '', + verticalEdges, + }; + }), + }; + } + function docPosBeforeElement(el: HTMLElement): number | null { const parent = el.parentNode; if (!parent) return null; @@ -620,13 +722,6 @@ export const PageBreaks = Extension.create({ docPos: number; height: number; row: { columns: number } | null; - // True when the spacer sits inside a too-tall table cell; marked on the - // 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 @@ -705,8 +800,18 @@ 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) { + // A leaf needs a close/open band whenever its break sits inside the single + // continuous table box, where the table's outer left/right borders bleed + // through the borderless page gap: + // • inTableCell — a too-tall cell whose content flows across the page, or + // • a between-rows push (spacerRow set) — the bridging spacer is + // borderless, but the browser still paints the table's outer side + // borders continuously across it (grey lines through the margins + gap). + // Both get the same page-coloured mask + gap stripe overlay (Editor.svelte). + // First-row pushes use a block spacer before the wrapper (spacerRow === null) + // → the whole table moves to the next page, no internal gap, so no band. + const inBand = !!leaf.inTableCell || spacerRow !== null; + if (inBand && bandOpenY !== null && spacerHeight > 0) { const key = Math.round(bandOpenY); if (!tableBands.has(key)) tableBands.set(key, bandOpenY); } @@ -745,8 +850,6 @@ export const PageBreaks = Extension.create({ docPos: spacerDocPos, height: h, row: spacerRow, - incell: !!leaf.inTableCell, - bandKey: leaf.inTableCell && bandOpenY !== null ? Math.round(bandOpenY) : null, }); placementsDebug.push({ leafIndex: i, @@ -810,12 +913,6 @@ export const PageBreaks = Extension.create({ } const spacerEl = document.createElement('div'); spacerEl.dataset.pageBreakSpacer = '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'; @@ -872,6 +969,7 @@ export const PageBreaks = Extension.create({ placements: placementsDebug, renderedSpacers: [], tableBreakBands, + overlay: null, // filled in live by the debug accessor (captureOverlay) }; isUpdating = false;