fix: table page break

This commit is contained in:
Steffen Becker committed 2026-06-06 20:32:23 +02:00
1 parent 631e6aa5e9
commit 3f3db893ba
3 files changed
+351 -36

No files matched your search

+98 -4
View File
@@ -8,7 +8,7 @@
import { saveDocument, loadDocument } from '../storage/autosave';
import { applyMarginVars, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import { applyOrientationVars, type Orientation } from '../storage/pageOrientation';
import { FORCE_PAGE_RECALC } from './pageBreaks';
import { FORCE_PAGE_RECALC, type TableBreakBand } from './pageBreaks';
import '../../styles/editor.css';
const DEFAULT_EDITOR_FONT = 'Georgia'; // must match ToolbarExpanded.svelte
@@ -103,11 +103,84 @@
};
}
// --- 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 };
let tableBandsDoc = $state<TableBreakBand[]>([]);
let bandStyles = $state<BandStyle[]>([]);
function recomputeBands() {
const tiptap = element?.querySelector('.tiptap') as HTMLElement | null;
if (!tiptap || !editorContainer || tableBandsDoc.length === 0) {
if (bandStyles.length) bandStyles = [];
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;
// In-cell spacers in document order, paired 1:1 with the bands (both ordered).
const spacers = Array.from(
tiptap.querySelectorAll<HTMLElement>('[data-page-break-spacer-incell]'),
);
bandStyles = tableBandsDoc.map((b, i) => {
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];
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;
gapTop = Math.min(Math.max(0, gapTopDoc * z), Math.max(0, height - gapPx));
} else {
top = tiptapTop + b.closeY * z;
height = b.height * z;
gapTop = b.marginBottom * z;
}
return {
top,
left,
width,
height,
// White over the page margins, canvas colour over the gap.
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%)`,
};
});
}
// Defer to the next frame so the DOM reflects the latest transaction /
// pagination spacers before we measure (mirrors the page-break code).
function scheduleTableUi() {
cancelAnimationFrame(tableUiRaf);
tableUiRaf = requestAnimationFrame(recomputeTableUi);
tableUiRaf = requestAnimationFrame(() => {
recomputeTableUi();
recomputeBands();
});
}
// Throttle the value actually written to the DOM to one update per animation frame,
@@ -183,9 +256,11 @@
}
function onPageCount(e: Event) {
numPages = (e as CustomEvent<{ numPages: number }>).detail.numPages;
const detail = (e as CustomEvent<{ numPages: number; tableBreakBands?: TableBreakBand[] }>).detail;
numPages = detail.numPages;
tableBandsDoc = detail.tableBreakBands ?? [];
updateCurrentPage();
// Pagination spacers can shift a table's position — re-place the toolbar.
// Pagination spacers can shift a table's position — re-place the toolbar/bands.
scheduleTableUi();
}
@@ -269,7 +344,26 @@
<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}
{#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). */
.table-break-band {
position: absolute;
z-index: 50;
pointer-events: none;
border-top: 1px solid #000;
border-bottom: 1px solid #000;
}
</style>
+236 -30
View File
@@ -17,6 +17,12 @@ export type PageBreakDebugSnapshot = {
index: number;
tag: string;
kind: 'atomic' | 'splittable';
// Table provenance: inTableCell = this leaf lives inside a cell of a row that
// was itself too tall to fit a page (so we split its content). tableRow is set
// when the leaf *is* a whole table row (columns = colspan to bridge, isFirstRow
// = the table's first row, which breaks before the wrapper rather than mid-table).
inTableCell: boolean;
tableRow: { columns: number; isFirstRow: boolean } | null;
naturalTop: number;
naturalHeight: number;
textPreview: string;
@@ -34,12 +40,34 @@ export type PageBreakDebugSnapshot = {
docPos: number | null;
height: number;
reason: string;
// How the gap was bridged: 'table-row' = borderless <tr> spacer inside the
// table (columns = its colspan), 'block' = normal <div> block spacer,
// 'none' = no spacer emitted (e.g. an over-tall leaf that couldn't be pushed).
spacerKind: 'block' | 'table-row' | 'none';
columns: number | null;
}>;
renderedSpacers: Array<{
height: number;
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.
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.
export type TableBreakBand = {
closeY: number;
height: number;
left: number;
width: number;
marginBottom: number;
gap: number;
marginTop: number;
};
const debugAccessors = new WeakMap<EditorView, () => PageBreakDebugSnapshot | null>();
@@ -112,12 +140,19 @@ type Leaf = {
kind: 'atomic' | 'splittable';
naturalTop: number;
naturalHeight: number;
// Present when the leaf is a <tr> of a paginated table. Each row is an atomic
// leaf so the table can break between rows across pages (a whole table is
// usually taller than a page). `isFirstRow` pushes the entire table (a block
// spacer before the wrapper); later rows push via a spacer <tr> inside the table.
tableRow?: { columns: number; wrapperEl: HTMLElement; isFirstRow: boolean };
// True for paragraph leaves emitted from inside a too-tall table cell (the cell
// content is flowed across pages). Their breaks get a full-width close/open
// 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;
};
// TABLE is atomic: a table that overflows the page is pushed whole to the next
// page (we don't split a table across pages). A table taller than one page will
// overflow visually — an accepted limitation of this version.
const ATOMIC_TAGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'TABLE']);
const ATOMIC_TAGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
const SPLITTABLE_TAGS = new Set(['P']);
const CONTAINER_TAGS = new Set(['UL', 'OL', 'LI', 'BLOCKQUOTE']);
@@ -164,14 +199,7 @@ export const PageBreaks = Extension.create({
viewportTop: r.top,
};
});
return {
timestamp: snap.timestamp,
layout: snap.layout,
numPages: snap.numPages,
leaves: snap.leaves,
placements: snap.placements,
renderedSpacers,
};
return { ...snap, renderedSpacers };
});
function docPosBeforeElement(el: HTMLElement): number | null {
@@ -228,6 +256,23 @@ export const PageBreaks = Extension.create({
return docPosBeforeElement(target);
}
// Where to place a leaf's page-break spacer, and whether it must be a
// table row (so the widget is valid markup inside a <tbody>).
function leafSpacer(leaf: Leaf): { docPos: number | null; row: { columns: number } | null } {
if (leaf.tableRow) {
// First row crossing the boundary → push the whole table with a block
// spacer before the wrapper (otherwise the table's top would be
// orphaned above an in-table gap).
if (leaf.tableRow.isFirstRow) {
return { docPos: preLeafDocPos(leaf.tableRow.wrapperEl), row: null };
}
// Later rows → a spacer <tr> before the row pushes it (and the rows
// after it) to the next page while the table stays one element.
return { docPos: docPosBeforeElement(leaf.el), row: { columns: leaf.tableRow.columns } };
}
return { docPos: preLeafDocPos(leaf.el), row: null };
}
// Walks text nodes inside `el`, skipping any that live inside a spacer
// widget, and returns one rect per visual line (in viewport coords).
function getLineRects(el: HTMLElement): { top: number; bottom: number }[] {
@@ -363,7 +408,7 @@ export const PageBreaks = Extension.create({
return { naturalLineTop, docPos: lo };
}
function collectLeaves(zoom: number): Leaf[] {
function collectLeaves(zoom: number, contentHeight: number): Leaf[] {
const dom = editorView.dom;
const tiptapRect = dom.getBoundingClientRect();
const leaves: Leaf[] = [];
@@ -372,7 +417,73 @@ export const PageBreaks = Extension.create({
// naturalTop/Height below.
let cumulativeSpacerHeight = 0;
function walk(container: HTMLElement) {
// Emit one atomic leaf per table row so the table can break between
// rows across pages. TipTap renders tables inside a node-view
// <div class="tableWrapper"><table><colgroup><tbody>…. A whole table is
// typically taller than a page, so treating it as a single atomic leaf
// left it overflowing (atomic-too-tall-no-push); row leaves fix that.
function walkTableRows(wrapperEl: HTMLElement, inTableCell = false) {
const tableEl = (wrapperEl.tagName === 'TABLE'
? wrapperEl
: wrapperEl.querySelector('table')) as HTMLElement | null;
const tbody = tableEl?.querySelector('tbody') as HTMLElement | null;
const rowEls = tbody
? (Array.from(tbody.children) as HTMLElement[])
: [];
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,
});
return;
}
const colgroup = tableEl?.querySelector('colgroup');
const columns = colgroup?.children.length || realRows[0].children.length || 1;
let seenRealRow = false;
for (const tr of rowEls) {
if (tr.dataset?.pageBreakSpacer) {
cumulativeSpacerHeight += tr.offsetHeight;
continue;
}
if (tr.tagName !== 'TR') continue;
const rect = tr.getBoundingClientRect();
const rowHeight = rect.height / zoom;
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,
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).
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; }
}
if (dominant) walk(dominant, true);
}
seenRealRow = true;
}
}
function walk(container: HTMLElement, inTableCell = false) {
for (const child of Array.from(container.children) as HTMLElement[]) {
if (child.dataset?.pageBreakSpacer) {
cumulativeSpacerHeight += child.offsetHeight;
@@ -381,11 +492,12 @@ export const PageBreaks = Extension.create({
const tag = child.tagName;
// TipTap renders tables inside a <div class="tableWrapper"> node
// view (see extensions.ts Table config), so the top-level child is
// the wrapper DIV, not the TABLE. Treat either as an atomic table.
const isTable =
tag === 'TABLE' ||
(tag === 'DIV' && child.classList.contains('tableWrapper'));
const isAtomic = ATOMIC_TAGS.has(tag) || isTable;
// the wrapper DIV, not the TABLE. Break the table into per-row leaves.
if (tag === 'TABLE' || (tag === 'DIV' && child.classList.contains('tableWrapper'))) {
walkTableRows(child, inTableCell);
continue;
}
const isAtomic = ATOMIC_TAGS.has(tag);
const isSplittable = SPLITTABLE_TAGS.has(tag);
if (isAtomic || isSplittable) {
const rect = child.getBoundingClientRect();
@@ -400,12 +512,13 @@ export const PageBreaks = Extension.create({
kind: isAtomic ? 'atomic' : 'splittable',
naturalTop: (rect.top - tiptapRect.top) / zoom - cumulativeSpacerHeight,
naturalHeight: rect.height / zoom - intraSpacerHeight,
inTableCell,
});
cumulativeSpacerHeight += intraSpacerHeight;
continue;
}
if (CONTAINER_TAGS.has(tag)) {
walk(child);
walk(child, inTableCell);
continue;
}
// Unknown block — measure as a splittable leaf with no intra-spacers
@@ -415,6 +528,7 @@ export const PageBreaks = Extension.create({
kind: 'splittable',
naturalTop: (rect.top - tiptapRect.top) / zoom - cumulativeSpacerHeight,
naturalHeight: rect.height / zoom,
inTableCell,
});
}
}
@@ -435,11 +549,34 @@ export const PageBreaks = Extension.create({
const CONTENT_HEIGHT = vm.contentHeight;
const CYCLE_PX = vm.cycle;
// Side margins + page width (px) for table close/open bands. These don't
// affect pagination (only line wrapping), so readVerticalMargins ignores
// them; we read them here just to size the bands to the content width.
const csRoot = getComputedStyle(dom);
const mlRaw = parseFloat(csRoot.getPropertyValue('--user-margin-left'));
const mrRaw = parseFloat(csRoot.getPropertyValue('--user-margin-right'));
const pwRaw = parseFloat(csRoot.getPropertyValue('--user-page-width'));
const marginLeft = Number.isFinite(mlRaw) ? mlRaw : 80;
const contentWidth = (Number.isFinite(pwRaw) ? pwRaw : 794) - marginLeft - (Number.isFinite(mrRaw) ? mrRaw : 80);
const zoom = getZoomFactor();
const leaves = collectLeaves(zoom);
const leaves = collectLeaves(zoom, CONTENT_HEIGHT);
let cumulativeShift = 0;
const placements: { docPos: number; height: number }[] = [];
const placements: {
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;
}[] = [];
// 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[] = [];
const leavesDebug: PageBreakDebugSnapshot['leaves'] = [];
const placementsDebug: PageBreakDebugSnapshot['placements'] = [];
@@ -453,21 +590,26 @@ export const PageBreaks = Extension.create({
let spacerHeight = 0;
let spacerDocPos: number | null = null;
let spacerRow: { columns: number } | null = null;
let bandOpenY: number | null = null;
let reason = 'fits';
if (effectiveTop < contentStart && i > 0) {
spacerHeight = contentStart - effectiveTop;
spacerDocPos = preLeafDocPos(leaf.el);
({ docPos: spacerDocPos, row: spacerRow } = leafSpacer(leaf));
bandOpenY = contentStart;
reason = 'pre-leaf-push-to-content-start';
} else if (effectiveTop >= contentEnd) {
spacerHeight = pageContentStart(page + 1, vm.top, CYCLE_PX) - effectiveTop;
spacerDocPos = preLeafDocPos(leaf.el);
({ docPos: spacerDocPos, row: spacerRow } = leafSpacer(leaf));
bandOpenY = pageContentStart(page + 1, vm.top, CYCLE_PX);
reason = 'leaf-jump-to-next-page';
} else if (effectiveBottom > contentEnd) {
if (leaf.kind === 'atomic') {
if (leaf.naturalHeight <= CONTENT_HEIGHT) {
spacerHeight = pageContentStart(page + 1, vm.top, CYCLE_PX) - effectiveTop;
spacerDocPos = preLeafDocPos(leaf.el);
({ docPos: spacerDocPos, row: spacerRow } = leafSpacer(leaf));
bandOpenY = pageContentStart(page + 1, vm.top, CYCLE_PX);
reason = 'atomic-push-to-next-page';
} else {
reason = 'atomic-too-tall-no-push';
@@ -478,6 +620,7 @@ export const PageBreaks = Extension.create({
if (leaf.naturalHeight <= CONTENT_HEIGHT) {
spacerHeight = pageContentStart(page + 1, vm.top, CYCLE_PX) - effectiveTop;
spacerDocPos = preLeafDocPos(leaf.el);
bandOpenY = pageContentStart(page + 1, vm.top, CYCLE_PX);
reason = 'split-fallback-push-whole-leaf';
} else {
reason = 'splittable-too-tall-no-push';
@@ -485,15 +628,26 @@ export const PageBreaks = Extension.create({
} else {
spacerHeight = pageContentStart(page + 1, vm.top, CYCLE_PX) - (effectiveTop + split.naturalLineTop);
spacerDocPos = split.docPos;
bandOpenY = pageContentStart(page + 1, vm.top, CYCLE_PX);
reason = 'line-split';
}
}
}
// 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);
}
leavesDebug.push({
index: i,
tag: leaf.el.tagName,
kind: leaf.kind,
inTableCell: !!leaf.inTableCell,
tableRow: leaf.tableRow
? { columns: leaf.tableRow.columns, isFirstRow: leaf.tableRow.isFirstRow }
: null,
naturalTop: leaf.naturalTop,
naturalHeight: leaf.naturalHeight,
textPreview: (leaf.el.textContent ?? '').slice(0, 120),
@@ -516,12 +670,26 @@ 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 });
placementsDebug.push({ leafIndex: i, docPos: spacerDocPos, height: h, reason });
placements.push({ docPos: spacerDocPos, height: h, row: spacerRow, incell: !!leaf.inTableCell });
placementsDebug.push({
leafIndex: i,
docPos: spacerDocPos,
height: h,
reason,
spacerKind: spacerRow ? 'table-row' : 'block',
columns: spacerRow ? spacerRow.columns : null,
});
cumulativeShift += h;
}
} else if (reason !== 'fits') {
placementsDebug.push({ leafIndex: i, docPos: spacerDocPos, height: spacerHeight, reason });
placementsDebug.push({
leafIndex: i,
docPos: spacerDocPos,
height: spacerHeight,
reason,
spacerKind: 'none',
columns: null,
});
}
}
@@ -530,13 +698,31 @@ export const PageBreaks = Extension.create({
// don't change pagination, and re-dispatching forces ProseMirror to
// recreate the spacer DOM nodes (no `key` on the widgets) for no
// visible gain.
const placementsKey = placements.map((p) => `${p.docPos}:${p.height}`).join('|');
const placementsKey =
placements.map((p) => `${p.docPos}:${p.height}:${p.row ? p.row.columns : 'b'}`).join('|');
if (placementsKey !== lastPlacementsKey) {
lastPlacementsKey = placementsKey;
const doc = editorView.state.doc;
const decoArray: Decoration[] = placements.map((p) => {
if (p.row) {
// A table breaks between rows: the spacer must be a <tr> so it's
// valid inside <tbody> and creates a borderless gap that pushes
// the following rows to the next page.
const trEl = document.createElement('tr');
trEl.dataset.pageBreakSpacer = 'true';
trEl.setAttribute('contenteditable', 'false');
const tdEl = document.createElement('td');
tdEl.dataset.pageBreakSpacerCell = 'true';
tdEl.setAttribute('colspan', String(p.row.columns));
tdEl.style.height = `${p.height}px`;
trEl.appendChild(tdEl);
return Decoration.widget(p.docPos, trEl, { side: -1 });
}
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';
spacerEl.style.height = `${p.height}px`;
spacerEl.style.pointerEvents = 'none';
spacerEl.style.userSelect = 'none';
@@ -561,7 +747,26 @@ export const PageBreaks = Extension.create({
const targetHeight = numPages * CYCLE_PX - PAGE_GAP;
dom.style.minHeight = `${targetHeight}px`;
dom.dispatchEvent(new CustomEvent('pm-pagecount', { bubbles: true, detail: { numPages } }));
// 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.
const bandSpan = vm.bottom + PAGE_GAP + vm.top;
const tableBreakBands = tableBands.map((openY) => ({
closeY: openY - bandSpan,
height: bandSpan,
left: marginLeft,
width: contentWidth,
marginBottom: vm.bottom,
gap: PAGE_GAP,
marginTop: vm.top,
}));
dom.dispatchEvent(new CustomEvent('pm-pagecount', {
bubbles: true,
detail: { numPages, tableBreakBands },
}));
lastSnapshot = {
timestamp: new Date().toISOString(),
@@ -577,6 +782,7 @@ export const PageBreaks = Extension.create({
leaves: leavesDebug,
placements: placementsDebug,
renderedSpacers: [],
tableBreakBands,
};
isUpdating = false;
+17 -2
View File
@@ -100,8 +100,9 @@
line-height/font-size are zeroed so the spacer doesn't pick up the
inherited inline strut when it sits mid-paragraph between text nodes —
otherwise the browser adds a phantom line of height around the widget
and the continuation text on the next page lands too low. */
.paper .tiptap [data-page-break-spacer] {
and the continuation text on the next page lands too low.
Scoped to div so it never overrides the display of the table-row spacer below. */
.paper .tiptap div[data-page-break-spacer] {
display: block;
width: 100%;
margin: 0;
@@ -113,6 +114,20 @@
overflow: hidden;
}
/* Table page-break spacer: a borderless, empty <tr>/<td> that bridges the page
gap so the following rows start on the next page (a table can't hold a block
spacer between its rows). Keeps default table-row/cell display. */
.paper .tiptap tr[data-page-break-spacer] {
background: none;
}
.paper .tiptap td[data-page-break-spacer-cell] {
padding: 0;
border: none;
line-height: 0;
font-size: 0;
}
.paper .tiptap p {
margin-top: 0;
/* matches odf-kit Standard style fo:margin-bottom (0.212cm ≈ 6pt) */