fix: a table split across pages closes its box on both
Collapsed borders paint a shared edge once, so the spacer <tr> bridging the gap took the row separator with it and left one fragment open — visible in a fixture as tables missing their frame on a page. The spacer now draws the line LibreOffice draws there: the separator the break falls on, or the table's own box where the rows carry none. Absolutely positioned, since a collapsed border on the spacer shifts every row below by half.
This commit is contained in:
1 parent
40d58463c7
commit
2803e6196e
2 files changed
+52
-8
No files matched your search
@@ -79,6 +79,8 @@ page takes the "rest" pair. Not carried: the ruler, the header/footer layer and
|
||||
|
||||
**Tables across page breaks:** when a single continuous table box crosses a page boundary, the plugin reports `TableBreakBand`s (doc-px geometry). `Editor.svelte` renders an overlay (`.band-layer` inside `.paper`) that masks the table borders bleeding through the page margins and paints the dark page gap as one seam-free stripe.
|
||||
|
||||
A break *between rows* instead closes the table on both sides of the gap: collapsed borders paint a shared edge only once, so the spacer `<tr>` would leave one fragment open. `splitLines` (`pageBreaks.ts`) resolves what LibreOffice draws there — the row separator the break falls on, or, where the rows carry none, the table's own box (probed: its **top** border closes the fragment, its **bottom** border opens the continuation) — and the spacer cell renders it as two absolutely positioned lines. Out of flow deliberately: a collapsed border on the spacer itself moves every row below it down by half its width.
|
||||
|
||||
**Layout constants** (must stay in sync between `pageBreaks.ts`, `Editor.svelte`, and `editor.css`):
|
||||
- `PAGE_HEIGHT = 1123px` (A4 portrait), `PAGE_GAP = 20px`, `CYCLE = PAGE_HEIGHT + PAGE_GAP = 1143px`.
|
||||
- Page height/width and margins are read **live** from CSS custom properties (`--user-page-height`, `--user-page-width`, `--user-margin-*`) so orientation/margin changes don't require new constants. `getCycle()` in `Editor.svelte` reads `--user-page-height` at runtime.
|
||||
|
||||
@@ -97,9 +97,9 @@ export type TableBreakBand = {
|
||||
key: number;
|
||||
closeY: number; // content-end of the closing page (band/mask top)
|
||||
height: number; // bandSpan = marginBottom + gap + marginTop
|
||||
// True when the break falls between whole table rows: the rows' own cell borders
|
||||
// already cap the table at the gap, so only the gap stripe is painted (no mask /
|
||||
// close-open lines, which would double the real borders). False for in-cell splits.
|
||||
// True when the break falls between whole table rows: the spacer row draws the table's
|
||||
// own close/open lines there, so only the gap stripe is painted (a mask would double
|
||||
// them). False for in-cell splits.
|
||||
rowBreak: boolean;
|
||||
left: number; // content-area left (mask left)
|
||||
width: number; // content-area width (mask width)
|
||||
@@ -201,6 +201,11 @@ export const pageBreakKey = new PluginKey<PageBreakState>('pageBreaks');
|
||||
// pagination recompute even though the document content is unchanged.
|
||||
export const FORCE_PAGE_RECALC = 'forcePageBreakRecalc';
|
||||
|
||||
// A table-row spacer: the colspan bridging the row, plus the lines that close the table
|
||||
// at the break — LibreOffice draws the row separator the break falls on, or, where the
|
||||
// rows carry none, the table's own box (its top border closes, its bottom border opens).
|
||||
type RowSpacer = { columns: number; close: string; open: string };
|
||||
|
||||
type Leaf = {
|
||||
el: HTMLElement;
|
||||
kind: 'atomic' | 'splittable';
|
||||
@@ -416,9 +421,33 @@ export const PageBreaks = Extension.create({
|
||||
return docPosBeforeElement(target);
|
||||
}
|
||||
|
||||
// What a cell edge draws, as a border shorthand ('' where it draws nothing).
|
||||
function edgeLine(cell: Element | null, side: 'Top' | 'Bottom'): string {
|
||||
if (!cell) return '';
|
||||
const cs = getComputedStyle(cell);
|
||||
const width = side === 'Top' ? cs.borderTopWidth : cs.borderBottomWidth;
|
||||
const style = side === 'Top' ? cs.borderTopStyle : cs.borderBottomStyle;
|
||||
const color = side === 'Top' ? cs.borderTopColor : cs.borderBottomColor;
|
||||
return parseFloat(width) > 0 && style !== 'none' ? `${width} ${style} ${color}` : '';
|
||||
}
|
||||
|
||||
// The lines closing the table where a break falls before row `tr` (see RowSpacer).
|
||||
// The first cell speaks for the row: a spacer bridges every column with one <td>.
|
||||
function splitLines(tr: HTMLElement): { close: string; open: string } {
|
||||
const cell = (r: Element | null | undefined) => r?.firstElementChild ?? null;
|
||||
const sep = edgeLine(cell(tr), 'Top') || edgeLine(cell(previousNonSpacerSibling(tr)), 'Bottom');
|
||||
if (sep) return { close: sep, open: sep };
|
||||
const rows = Array.from(tr.parentElement?.children ?? [])
|
||||
.filter((r) => r.tagName === 'TR' && !(r as HTMLElement).dataset?.pageBreakSpacer);
|
||||
return {
|
||||
close: edgeLine(cell(rows[0]), 'Top'),
|
||||
open: edgeLine(cell(rows[rows.length - 1]), 'Bottom'),
|
||||
};
|
||||
}
|
||||
|
||||
// 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 } {
|
||||
function leafSpacer(leaf: Leaf): { docPos: number | null; row: RowSpacer | 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
|
||||
@@ -428,7 +457,10 @@ export const PageBreaks = Extension.create({
|
||||
}
|
||||
// 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: docPosBeforeElement(leaf.el),
|
||||
row: { columns: leaf.tableRow.columns, ...splitLines(leaf.el) },
|
||||
};
|
||||
}
|
||||
return { docPos: preLeafDocPos(leaf.el), row: null };
|
||||
}
|
||||
@@ -958,7 +990,7 @@ export const PageBreaks = Extension.create({
|
||||
const placements: {
|
||||
docPos: number;
|
||||
height: number;
|
||||
row: { columns: number } | null;
|
||||
row: RowSpacer | null;
|
||||
}[] = [];
|
||||
// Node-decoration range collapsing the trailing empty paragraph after a
|
||||
// document-final columns chain when it sits past the page content area.
|
||||
@@ -1013,7 +1045,7 @@ export const PageBreaks = Extension.create({
|
||||
const breaks: {
|
||||
height: number;
|
||||
docPos: number | null;
|
||||
row: { columns: number } | null;
|
||||
row: RowSpacer | null;
|
||||
bandOpenY: number | null;
|
||||
reason: string;
|
||||
}[] = [];
|
||||
@@ -1343,7 +1375,7 @@ export const PageBreaks = Extension.create({
|
||||
// previous pass. Most keystrokes don't change pagination, and re-dispatching
|
||||
// recreates the spacer DOM nodes (no `key` on the widgets) for no gain.
|
||||
const placementsKey =
|
||||
placements.map((p) => `${p.docPos}:${p.height}:${p.row ? p.row.columns : 'b'}`).join('|') +
|
||||
placements.map((p) => `${p.docPos}:${p.height}:${p.row ? `${p.row.columns}${p.row.close}${p.row.open}` : 'b'}`).join('|') +
|
||||
(collapsedTrailing ? `|c${collapsedTrailing.from}` : '') +
|
||||
pageTopBlocks.map((b) => `|t${b.from}`).join('') +
|
||||
Array.from(sectionInsets, ([f, s]) => `|i${f}:${s.left}:${s.right}`).join('');
|
||||
@@ -1365,6 +1397,16 @@ export const PageBreaks = Extension.create({
|
||||
tdEl.dataset.pageBreakSpacerCell = 'true';
|
||||
tdEl.setAttribute('colspan', String(p.row.columns));
|
||||
tdEl.style.height = `${p.height}px`;
|
||||
// Collapsed borders paint a shared edge once, so without these lines the
|
||||
// gap keeps it and one of the two fragments ends unclosed. Out of flow:
|
||||
// a collapsed border on the spacer itself moves every row below by half.
|
||||
tdEl.style.position = 'relative';
|
||||
for (const [border, side] of [[p.row.close, 'top'], [p.row.open, 'bottom']] as const) {
|
||||
if (!border) continue;
|
||||
const line = document.createElement('div');
|
||||
line.style.cssText = `position:absolute;left:0;right:0;${side}:0;border-${side}:${border}`;
|
||||
tdEl.appendChild(line);
|
||||
}
|
||||
trEl.appendChild(tdEl);
|
||||
return Decoration.widget(p.docPos, trEl, { side: -1 });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user