fix: page gap of between tables
This commit is contained in:
1 parent
7d7eabf8e4
commit
d5aa63cd77
3 files changed
+121
-76
No files matched your search
@@ -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>
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user