fix: an image stays on its side of the page break

An as-character image is now a line box in getLineRects, so a paragraph of
stacked images breaks between them instead of overflowing the page. Only a
floated image keeps its paragraph atomic, and the leaf's height reaches down
to the lowest float — a float hangs out of the paragraph's box, so
offsetHeight alone let the image walk off the page foot. prevPlacementsKey
damps the ping-pong that overhang causes between two layouts.
This commit is contained in:
Steffen Becker committed 2026-08-08 20:57:41 +00:00
1 parent 412701cdec
commit e9e543fe8c
2 files changed
+80 -18

No files matched your search

+13
View File
@@ -15,6 +15,19 @@ A custom ProseMirror plugin that simulates paginated A4 layout entirely in CSS/D
**Widow-orphan control:** `findLineSplit` picks the first line that overflows the page, then applies Word's/LibreOffice's rule (`MIN_KEPT_LINES` = 2, on by default in both via OOXML `w:widowControl`, so not configurable): fewer than two lines carrying over pulls one more down with them, and fewer than two staying behind cancels the split so the caller pushes the whole block. A leaf taller than one page slot passes `minLines: 1` — there the rule can't be satisfied and splitting beats overflowing.
**Images in a paragraph:** an as-character image is a line box like any other (`getLineRects`
measures `.image-node` alongside the text runs, and text sharing its line groups into it), so a
paragraph of stacked images breaks between them instead of overflowing the page. Only a *floated*
image keeps its paragraph atomic — a line-split spacer beside a float is dropped by ProseMirror.
A float hangs out of its paragraph's box, so the leaf's height reaches down to the lowest float
instead of stopping at `offsetHeight` — otherwise the paragraph measures as one text line and the
image walks off the page bottom. Text still wraps beside the float; only the push moves as a unit.
What the model can't see is how far that overhang moves the blocks *below* it: further than the
spacer it accounts for, so two layouts can each imply the other. `prevPlacementsKey` catches that
ping-pong and keeps the current layout; only an edit or a forced recalc forgets it (the TOC
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 its successor's first line no longer fits below it — 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.
**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.
+67 -18
View File
@@ -170,7 +170,12 @@ function getPageForY(y: number, cycle: number): number {
return Math.floor(y / cycle) + 1;
}
const pageBreakKey = new PluginKey<number>('pageBreaks');
// `recalc` counts forced recomputes, `edit` the document edits. Layout-only writes
// (addToHistory:false) bump neither, so the convergence brake in `update` can tell an
// answer to the last pass from a fresh change.
type PageBreakState = { recalc: number; edit: number };
const pageBreakKey = new PluginKey<PageBreakState>('pageBreaks');
// Transaction meta flag: set it (e.g. when page margins change) to force a
// pagination recompute even though the document content is unchanged.
@@ -222,21 +227,27 @@ export const PageBreaks = Extension.create({
let isUpdating = false;
let rafId: number | null = null;
let lastPlacementsKey = '';
// The layout before the current one. A block below a float's overhang moves further
// than the spacer the model accounts for, so two layouts can each imply the other —
// seeing the older one come back means a ping-pong, and the current one is kept.
let prevPlacementsKey = '';
// Each pass measures against the spacers left by the previous pass, so a changed
// result needs one more pass to re-measure and settle (see calculate). Bounded so
// a hypothetical two-layout ping-pong can't loop forever; reset per external change.
let convergePasses = 0;
const MAX_CONVERGE_PASSES = 4;
const plugin = new Plugin<number>({
const plugin = new Plugin<PageBreakState>({
key: pageBreakKey,
// A counter bumped whenever a FORCE_PAGE_RECALC transaction arrives. The
// plugin's own decoration dispatches don't carry that meta, so this never
// self-triggers. `update` reschedules a layout pass when the counter moves.
// Counters `update` watches. The plugin's own decoration dispatches carry neither
// FORCE_PAGE_RECALC nor a doc change, so this never self-triggers.
state: {
init: () => 0,
init: () => ({ recalc: 0, edit: 0 }),
apply(tr, value) {
return tr.getMeta(FORCE_PAGE_RECALC) ? value + 1 : value;
const recalc = value.recalc + (tr.getMeta(FORCE_PAGE_RECALC) ? 1 : 0);
const edit = value.edit
+ (tr.docChanged && tr.getMeta('addToHistory') !== false ? 1 : 0);
return recalc === value.recalc && edit === value.edit ? value : { recalc, edit };
},
},
props: {
@@ -394,10 +405,21 @@ export const PageBreaks = Extension.create({
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).
// Walks text nodes and inline images 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 }[] {
const allRects: DOMRect[] = [];
// An as-character image is a line box of its own: without it a paragraph of
// images has no lines at all and can only paginate whole.
const atomRects = new Set<DOMRect>();
for (const img of Array.from(el.querySelectorAll<HTMLElement>('.image-node'))) {
if (img.style.float) continue;
const rect = img.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
allRects.push(rect);
atomRects.add(rect);
}
}
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
let parent = node.parentElement;
@@ -435,13 +457,19 @@ export const PageBreaks = Extension.create({
const tol = advance > 0 ? advance * 0.6 : 3;
const lines: { top: number; bottom: number }[] = [];
let lineTop = -Infinity;
// How far down the current line still reaches: an image's own box (text beside
// it starts well below the image top), otherwise the tolerance alone.
let lineEnd = -Infinity;
for (const r of allRects) {
if (r.top - lineTop > tol) {
const isAtom = atomRects.has(r);
if (r.top - lineTop > tol && r.top >= lineEnd) {
lines.push({ top: r.top, bottom: r.bottom });
lineTop = r.top;
lineEnd = isAtom ? r.bottom - 1 : r.top + tol;
} else {
const last = lines[lines.length - 1];
last.bottom = Math.max(last.bottom, r.bottom);
if (isAtom) lineEnd = Math.max(lineEnd, r.bottom - 1);
}
}
return lines;
@@ -714,11 +742,13 @@ export const PageBreaks = Extension.create({
});
continue;
}
// A paragraph holding a floated/block image paginates atomically (pushed
// whole, spacer placed before it): a line-split spacer inside it would sit
// next to the float, where ProseMirror drops the widget.
// A paragraph holding a floated image paginates atomically: a line-split
// spacer beside the float is dropped by ProseMirror. As-character images
// are ordinary line boxes, so their paragraph breaks between lines.
const splittableTag = SPLITTABLE_TAGS.has(tag);
const hasImage = splittableTag && !!child.querySelector('.image-node');
const floats = Array.from(child.querySelectorAll<HTMLElement>('.image-node'))
.filter((img) => !!img.style.float);
const hasImage = splittableTag && floats.length > 0;
// Keep lines together (w:keepLines, fo:keep-together): the block moves
// whole. One taller than a page still splits — as it does in Word.
const keepLines = child.dataset?.keepLines === 'true' && child.offsetHeight <= contentHeight;
@@ -731,11 +761,22 @@ export const PageBreaks = Extension.create({
)) {
intraSpacerHeight += sp.offsetHeight;
}
// A float hangs out of its paragraph's box, so offsetHeight stops above
// it and the image would walk off the page bottom. Reach down to the
// lowest float instead — that space belongs to this leaf.
let floatBottom = 0;
if (floats.length) {
const top = child.getBoundingClientRect().top;
for (const img of floats) {
const below = (img.getBoundingClientRect().bottom - top) / scaleFactor;
floatBottom = Math.max(floatBottom, below);
}
}
leaves.push({
el: child,
kind: isAtomic ? 'atomic' : 'splittable',
naturalTop: topWithin(child) - cumulativeSpacerHeight,
naturalHeight: child.offsetHeight - intraSpacerHeight,
naturalHeight: Math.max(child.offsetHeight, floatBottom) - intraSpacerHeight,
inTableCell,
// A manual page break is honored for top-level blocks only (not in
// a table cell, where the table breaks atomically between rows).
@@ -1157,8 +1198,10 @@ export const PageBreaks = Extension.create({
const placementsKey =
placements.map((p) => `${p.docPos}:${p.height}:${p.row ? p.row.columns : 'b'}`).join('|') +
(collapsedTrailing ? `|c${collapsedTrailing.from}` : '');
const placementsChanged = placementsKey !== lastPlacementsKey;
const placementsChanged =
placementsKey !== lastPlacementsKey && placementsKey !== prevPlacementsKey;
if (placementsChanged) {
prevPlacementsKey = lastPlacementsKey;
lastPlacementsKey = placementsKey;
const doc = editorView.state.doc;
const decoArray: Decoration[] = placements.map((p) => {
@@ -1264,10 +1307,16 @@ export const PageBreaks = Extension.create({
return {
update(_view, prevState) {
const forced =
pageBreakKey.getState(prevState) !== pageBreakKey.getState(editorView.state);
const before = pageBreakKey.getState(prevState);
const after = pageBreakKey.getState(editorView.state);
const forced = before?.recalc !== after?.recalc;
const isEditTr = before?.edit !== after?.edit;
if (!isUpdating && (prevState.doc !== editorView.state.doc || forced)) {
convergePasses = 0; // fresh convergence budget per external change
// Layout-only writes — the TOC rewriting its page numbers on every
// pm-pagecount — answer the last pass, so forgetting the ping-pong
// memory on them would leave the guard with nothing to catch.
if (forced || isEditTr) prevPlacementsKey = '';
schedule();
}
},