fix: keep rows a cell spans on one page, and clear floats before an in-flow box

A break between spanned rows stretched the cell across the gap: the
merged table drew over the page gap and the next page's header. A
group too tall for a page still breaks per row. An in-flow text box
took the same treatment for floats — CSS lays a block box over one
instead of beside it, which had the vertical frame covering its
neighbour. Both match LibreOffice's render of the same document.
This commit is contained in:
Steffen Becker committed 2026-08-30 10:06:44 +00:00
1 parent 7733b53068
commit 4aef4d7641
5 files changed
+95 -6

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ Covers `src/lib/editor/extensions/image.ts` and `textBox.ts` — node views, res
handles, wrap modes, frame offsets — and their ODF/DOCX legs.
- **`image.ts`** (`Image`) — inline, as-character image atom (Word's "in line with text"). Attrs: `src` (data-URI), `alt`, `width`/`height` (**unscaled doc px @96dpi**, like `rowHeight`, and **fractional** — rounding a page-wide picture to whole px costs 0.3mm of height; `framePx` snaps back to a whole one within 0.05px, which is where our own export's 0.001cm quantization lands, so the round trip stays exact), `rotation` (CW degrees). The node view nests an `<img>` in a rotated "rotor" inside an axis-aligned wrapper that reserves the rotated bounding box (so text reflows around it). The rotor's transform makes it a stacking context, so a selected `.image-node` is given a `z-index` above the header/footer layer to keep handles that poke into a margin grabbable. It has eight zoom-aware resize handles — corners aspect-locked, edges single-axis (width- or height-only) — plus a rotation-arrow grip, and shows a live "Width … × Height …" (cm) badge while resizing; resize deltas are un-rotated onto the image's own axes so handles track the pointer at any angle (drag math mirrors `tableRowResize.ts`; width cap = the cell or page-text-column, height cap = the page text height). `setImage` command inserts it; the toolbar button (ToolbarExpanded), drag-drop and paste (Editor.svelte) read the file via `FileReader` → data-URI and clamp the initial size to the page text box. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'|'through'`) makes the image a *floating* frame — `ImageToolbar.svelte` (a per-image floating toolbar wired in `Editor.svelte` like `TableToolbar`) sets the mode; `left`/`right` float the wrapper at its anchor paragraph (text flows on the open side via CSS `float`), `topBottom` is a **full-width `float`** (text only above/below), and `through` — Word's in-front-of / behind-text (`wp:wrapNone` + `behindDoc`), ODF's `style:wrap="run-through"` — leaves the flow entirely: `position: absolute` with no offsets keeps the static position it was anchored at, so it reserves neither width nor height, and `inFront` picks the side of the text it lands on. Read as a side float instead it reserved a near-column-wide picture's whole height, which cost the image fixture a page and ~50 reports. A text box in that mode always sits behind the text — the mode has no toolbar entry yet, so only a file can carry it in. All modes use `float` deliberately: an in-flow `display:block` on an inline-atom node view splits the paragraph's inline content into anonymous block boxes, which desyncs ProseMirror's inline view descriptor and makes it drop the following page-break spacer widget; a float is out-of-flow and avoids that. Dragging an **inline** image runs ProseMirror's native node move (the `dropCursor` plugin shows the caret). Dragging a **floating** image runs a custom drag (`ImageView.startReposition`) that **live re-anchors** the node to the text position under the cursor, so the float and text reflow in real time as you drag; it's rAF-throttled and only moves when the cursor's line changes (a float can't move within a line), and stays a single undo step (first move records history, later moves are `addToHistory:false` and get rebased on undo). A paragraph that contains an image paginates **atomically** (`pageBreaks.ts` — pushed whole to the next page, spacer placed before it) so a page break never splits the paragraph next to the image's float (where ProseMirror drops the spacer widget). **Wrap distance:** `wrapDist` is the gap the file keeps beside a float, in cm (Word's `distL`/`distR`, ODF's `fo:margin-left`/`-right` on the graphic style) — only the side the text flows on, since the other one is the frame's own offset. A frame that declares none reserves **nothing**, beside it or above/below it: probed, LibreOffice wraps flush against it, and a gap of our own put every figure in a text box 3mm too tall and broke a line a word early. `setImageWrap` gives a frame the file never floated the 0.32cm a word processor's own wrap command writes. **Vertical alignment (as-char):** `vAlign` carries ODF's `style:vertical-pos`/`-rel` pair for an inline frame — `null` (LibreOffice's `top`/`baseline` and Word's only mode) stands the frame on the text baseline, `middle`/`below` centre it on the baseline or hang it below, and `text-top`/`text-middle`/`text-bottom` measure against the character area instead (CSS `vertical-align: text-top`/`text-bottom`; nothing in CSS centres on that area, so `text-middle` uses 0.36em for half of ascent − descent, which Liberation Serif/Sans, Times and Arial share). `from-top` becomes `vAlign: 'offset'` and puts the frame's top `wrapOffsetY` cm below the baseline whatever the relation says — all probed against LibreOffice, whose formula images use `middle`/`text` and `from-top` almost exclusively. A line an as-char frame has to itself — nothing beside it but whitespace — is as tall as the frame or the block's line height, whichever is more, with **nLine truncated
- **`textBox.ts`** (`TextBox`) — block-level text box / basic shape with editable block content (`content: '(paragraph|heading|bulletList|orderedList)+'`, `isolating`). Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`; `inline` renders in-flow, floats via CSS `float` like `ImageView.applyWrap`), `shapeKind` (see the shape table below), `fillColor`/`strokeColor` (`null` = transparent/no border), `strokeWidthPt`. Padding is the fixed `TEXTBOX_PADDING_CM` (0.15cm). The node has its **own group** (`group: 'textBox'`) so only the document admits it (`extensions.ts` widens Document to `'(block | textBox)+'`) — never in table cells, lists, header/footer, or other boxes; imports found there are flattened with a warning. `TextBoxView` mirrors `ImageView` (wrapper reserving the rotated bbox → rotor carrying fill/stroke/rotation → `contentDOM`); the rotor is out of flow and auto-grows, so a ResizeObserver refits the wrapper. **Click model (Word-like):** `stopEvent`/`isFrameHit` claim mouse events on a ~6px frame ring (rotation-/zoom-aware) → NodeSelection + handles (the shared `image-*` handle classes/CSS); clicks further inside pass to ProseMirror (caret). The box is browser-`draggable` only while node-selected, so PM's native block drag moves it without hijacking text selection. Commands: `insertTextBox()` (after the current top-level block, cursor inside), `setTextBoxAttrs()` (NodeSelection *or* cursor inside; helper `findTextBox`). `TextBoxToolbar.svelte` (wired in `Editor.svelte` like the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke and `textVertical` — the box's text running **top-to-bottom, right-to-left** (`writing-mode: vertical-rl` on the content area, which is all the browser needs). It round-trips as the style's own writing mode in ODF — a plain box is a Writer text frame (its `TbxFr` style chains to `Frame`; see `src/lib/export/CLAUDE.md`) and takes it in the graphic properties, a drawing *shape* only in its style's paragraph properties (both probed) — and Word's `w:bodyPr vert`; ODF's bottom-to-top and Word's `vert270` read as the same one direction the browser lays out. `trailingNode.ts` appends a paragraph after a trailing box; `pageBreaks.ts` paginates `.textbox-node` atomically. **Spacing:** a box lifted out of an anchor paragraph that held nothing else stands in for it, so it takes that paragraph's resolved `fo:margin-top`/`-bottom` as `spaceBefore`/`spaceAfter` (`ParagraphSpacing` covers `textBox` for that). Space above is padding — it adds to the block above rather than collapsing against it — which means the node is `content-box` and the rotor, centred in the padding box, gets half of it back.
- **`textBox.ts`** (`TextBox`) — block-level text box / basic shape with editable block content (`content: '(paragraph|heading|bulletList|orderedList)+'`, `isolating`). Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`; `inline` renders in-flow, floats via CSS `float` like `ImageView.applyWrap` — an in-flow box also `clear`s, since a block box is laid *over* a float rather than beside it, and both word processors put it on the next free line), `shapeKind` (see the shape table below), `fillColor`/`strokeColor` (`null` = transparent/no border), `strokeWidthPt`. Padding is the fixed `TEXTBOX_PADDING_CM` (0.15cm). The node has its **own group** (`group: 'textBox'`) so only the document admits it (`extensions.ts` widens Document to `'(block | textBox)+'`) — never in table cells, lists, header/footer, or other boxes; imports found there are flattened with a warning. `TextBoxView` mirrors `ImageView` (wrapper reserving the rotated bbox → rotor carrying fill/stroke/rotation → `contentDOM`); the rotor is out of flow and auto-grows, so a ResizeObserver refits the wrapper. **Click model (Word-like):** `stopEvent`/`isFrameHit` claim mouse events on a ~6px frame ring (rotation-/zoom-aware) → NodeSelection + handles (the shared `image-*` handle classes/CSS); clicks further inside pass to ProseMirror (caret). The box is browser-`draggable` only while node-selected, so PM's native block drag moves it without hijacking text selection. Commands: `insertTextBox()` (after the current top-level block, cursor inside), `setTextBoxAttrs()` (NodeSelection *or* cursor inside; helper `findTextBox`). `TextBoxToolbar.svelte` (wired in `Editor.svelte` like the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke and `textVertical` — the box's text running **top-to-bottom, right-to-left** (`writing-mode: vertical-rl` on the content area, which is all the browser needs). It round-trips as the style's own writing mode in ODF — a plain box is a Writer text frame (its `TbxFr` style chains to `Frame`; see `src/lib/export/CLAUDE.md`) and takes it in the graphic properties, a drawing *shape* only in its style's paragraph properties (both probed) — and Word's `w:bodyPr vert`; ODF's bottom-to-top and Word's `vert270` read as the same one direction the browser lays out. `trailingNode.ts` appends a paragraph after a trailing box; `pageBreaks.ts` paginates `.textbox-node` atomically. **Spacing:** a box lifted out of an anchor paragraph that held nothing else stands in for it, so it takes that paragraph's resolved `fo:margin-top`/`-bottom` as `spaceBefore`/`spaceAfter` (`ParagraphSpacing` covers `textBox` for that). Space above is padding — it adds to the block above rather than collapsing against it — which means the node is `content-box` and the rotor, centred in the padding box, gets half of it back.
## Clicking a float
+1 -1
View File
@@ -30,7 +30,7 @@ ping-pong and keeps the current layout; only an edit or a forced recalc forgets
rewrites its page numbers on every `pm-pagecount`, so resetting on any document change would
leave nothing to catch).
**Keeping blocks together** (`pageBreak.ts` attrs, both read from the direct property *and* the paragraph style): `keepNext` (`w:keepNext`, `fo:keep-with-next`) moves a block down when the successor's first `MIN_KEPT_LINES` no longer fit below it — one line is not enough, because widow-orphan control would carry the second one over and strand the block anyway (measured on the Math Guide: a heading left alone at the foot of page 8) — headings do that in Word and LibreOffice anyway, so the attr only marks the others. `keepLines` (`w:keepLines`, `fo:keep-together`) makes an otherwise splittable block paginate atomically, unless it is taller than the page slot, where Word splits it too. A table's `keepRows` (ODF `style:may-break-between-rows="false"`, no Word equivalent) is the same rule for a table: pageBreaks emits one atomic leaf for the whole wrapper instead of one per row, so a table with no room left moves whole (probed: LibreOffice leaves four lines of the page empty rather than break it, and splits the identical table when the attribute says `true`).
**Keeping blocks together** (`pageBreak.ts` attrs, both read from the direct property *and* the paragraph style): `keepNext` (`w:keepNext`, `fo:keep-with-next`) moves a block down when the successor's first `MIN_KEPT_LINES` no longer fit below it — one line is not enough, because widow-orphan control would carry the second one over and strand the block anyway (measured on the Math Guide: a heading left alone at the foot of page 8) — headings do that in Word and LibreOffice anyway, so the attr only marks the others. `keepLines` (`w:keepLines`, `fo:keep-together`) makes an otherwise splittable block paginate atomically, unless it is taller than the page slot, where Word splits it too. A table's `keepRows` (ODF `style:may-break-between-rows="false"`, no Word equivalent) is the same rule for a table: pageBreaks emits one atomic leaf for the whole wrapper instead of one per row, so a table with no room left moves whole (probed: LibreOffice leaves four lines of the page empty rather than break it, and splits the identical table when the attribute says `true`). Rows a **cell spans** are grouped the same way (`rowSpanGroups`): the spanning cell is one DOM box, so a break between them stretches it across the gap instead of splitting it — LibreOffice keeps such rows on one page too (probed). A group taller than the page cannot be kept whole and breaks per row as before.
**Line breaking:** `editor.css` overrides prosemirror-view's `white-space: break-spaces` back to `pre-wrap` on `.paper .tiptap`. Under `break-spaces` a line-end space may not hang into the margin and counts toward the line width, so lines break one word earlier than in Word/LibreOffice whenever a line reaches within a space width of the margin.
+48 -2
View File
@@ -163,6 +163,28 @@ export function readVerticalMargins(dom: HTMLElement): VMargins {
};
}
// Rows a cell spans move as one: that cell is a single box, so a break between them
// would stretch it across the gap instead of splitting it. Returns each row's group
// leader; a group too tall for a page cannot be kept whole and leads itself.
export function rowSpanGroups(
rows: { spans: number[]; top: number; height: number }[], contentHeight: number,
): number[] {
const leader = rows.map((_, i) => i);
for (let i = 0; i < rows.length; ) {
let end = i;
for (let j = i; j <= end; j++) {
for (const span of rows[j].spans) {
if (span > 1) end = Math.min(rows.length - 1, Math.max(end, j + span - 1));
}
}
if (end > i && rows[end].top + rows[end].height - rows[i].top <= contentHeight) {
for (let j = i; j <= end; j++) leader[j] = i;
}
i = end + 1;
}
return leader;
}
// "fromPage|height,…" — the runs the last pass reported (Editor.svelte publishes them).
// A document whose sections share one paper writes none, and the grid is uniform.
export function gridFromRuns(raw: string, pageHeight: number): PageGrid {
@@ -848,7 +870,20 @@ export const PageBreaks = Extension.create({
}
const colgroup = tableEl?.querySelector('colgroup');
const columns = colgroup?.children.length || realRows[0].children.length || 1;
const leaderIdx = rowSpanGroups(realRows.map((r) => ({
spans: (Array.from(r.children) as HTMLElement[])
.map((c) => (c as HTMLTableCellElement).rowSpan || 1),
top: naturalTopOf(r),
height: r.offsetHeight,
})), contentHeight);
const groupLeader = new Map<HTMLElement, HTMLElement>();
leaderIdx.forEach((li, i) => {
if (li === i) return;
groupLeader.set(realRows[i], realRows[li]);
groupLeader.set(realRows[li], realRows[li]);
});
let seenRealRow = false;
let openGroup: Leaf | null = null;
for (const tr of rowEls) {
if (tr.dataset?.pageBreakSpacer) {
cumulativeSpacerHeight += tr.offsetHeight;
@@ -856,15 +891,26 @@ export const PageBreaks = Extension.create({
}
if (tr.tagName !== 'TR') continue;
const rowHeight = tr.offsetHeight;
const leader = groupLeader.get(tr);
if (openGroup && leader && leader !== tr) {
// A row its leader's cell spans into: it grows that leaf rather than
// opening a break candidate of its own.
openGroup.naturalHeight = naturalTopOf(tr) + rowHeight - openGroup.naturalTop;
seenRealRow = true;
continue;
}
openGroup = null;
if (rowHeight <= contentHeight) {
// Row fits on a page → atomic leaf; the table breaks between rows.
leaves.push({
const rowLeaf: Leaf = {
el: tr,
kind: 'atomic',
naturalTop: naturalTopOf(tr),
naturalHeight: rowHeight,
tableRow: { columns, wrapperEl, isFirstRow: !seenRealRow },
});
};
leaves.push(rowLeaf);
if (leader === tr) openGroup = rowLeaf;
} else {
// Row taller than a page: each cell is an independent flow from the row
// top, breaking in its own column. Walk every cell, resetting the spacer
+4 -2
View File
@@ -527,11 +527,13 @@ class TextBoxView {
if (a.wrap === 'left' || a.wrap === 'right') {
d.style.float = a.wrap;
d.style.margin = frameMargins(a.wrap, a.wrapOffset, this.wrapperWidth(), null, a.wrapDist);
} else if (a.wrap === 'topBottom') {
d.style.clear = 'both';
} else if (a.wrap === 'through') {
// Behind the text, which is what a shape with no run-through of its own exports as.
applyRunThrough(d, a.wrapOffset, a.wrapOffsetY, false);
} else {
// In flow: the frame is a block box, and CSS lays one over a float rather than
// beside it (only line boxes shorten), so it takes the next free line instead.
d.style.clear = 'both';
}
// The anchor paragraph's spacing, which a lifted box stands in for: space above as
// padding so it adds to the block above (editor.css), space below as the margin the
+41
View File
@@ -0,0 +1,41 @@
// A page break may not fall between rows a cell spans: the cell is one DOM box and
// would stretch across the gap. LibreOffice keeps such rows together too (probed).
import { describe, it, expect } from 'vitest';
import { rowSpanGroups } from '../../src/lib/editor/extensions/pageBreaks';
const ROW = (spans: number[], i: number, height = 20) => ({ spans, top: i * height, height });
const rows = (spansPerRow: number[][], height = 20) =>
spansPerRow.map((spans, i) => ROW(spans, i, height));
describe('rowSpanGroups', () => {
it('leaves plain rows to break on their own', () => {
expect(rowSpanGroups(rows([[1, 1], [1, 1], [1, 1]]), 1000)).toEqual([0, 1, 2]);
});
it('joins the rows a cell spans to their leader', () => {
expect(rowSpanGroups(rows([[1, 2], [1], [1, 1]]), 1000)).toEqual([0, 0, 2]);
});
it('follows a span that reaches past the group it started', () => {
// Row 1's own span extends the group row 0 opened.
expect(rowSpanGroups(rows([[1, 2], [3], [1], [1], [1]]), 1000)).toEqual([0, 0, 0, 0, 4]);
});
it('never runs past the last row', () => {
expect(rowSpanGroups(rows([[1, 9]]), 1000)).toEqual([0]);
});
it('gives up on a group taller than the page: it breaks per row', () => {
// Three 20px rows = 60px of group against a 50px page.
expect(rowSpanGroups(rows([[3], [1], [1]]), 50)).toEqual([0, 1, 2]);
expect(rowSpanGroups(rows([[3], [1], [1]]), 60)).toEqual([0, 0, 0]);
});
it('keeps a later group when an earlier one is too tall', () => {
const r = [
{ spans: [2], top: 0, height: 40 }, { spans: [1], top: 40, height: 40 },
{ spans: [2], top: 80, height: 10 }, { spans: [1], top: 90, height: 10 },
];
expect(rowSpanGroups(r, 50)).toEqual([0, 1, 2, 2]);
});
});