diff --git a/src/lib/editor/pageBreaks.ts b/src/lib/editor/pageBreaks.ts index 4d69b48..d197ba8 100644 --- a/src/lib/editor/pageBreaks.ts +++ b/src/lib/editor/pageBreaks.ts @@ -152,13 +152,27 @@ export const PageBreaks = Extension.create({ return 1; } + function previousNonSpacerSibling(el: HTMLElement): HTMLElement | null { + let prev = el.previousElementSibling as HTMLElement | null; + while (prev && prev.dataset?.pageBreakSpacer) { + prev = prev.previousElementSibling as HTMLElement | null; + } + return prev; + } + // For a pre-leaf push we want the spacer to render OUTSIDE any // list-item wrapper, so the
  • 's bullet marker stays aligned with - // its own text. Walk up through
  • ancestors to the outermost one, - // then take the position before that. + // its own text. Walk up through
  • ancestors — but stop the moment + // `target` has a prior sibling, otherwise we'd also push earlier + // already-placed paragraphs inside the same
  • (e.g. a multi- + // paragraph LI produced by backspacing across a page break). function preLeafDocPos(leafEl: HTMLElement): number | null { let target = leafEl; - while (target.parentElement && target.parentElement.tagName === 'LI') { + while ( + target.parentElement && + target.parentElement.tagName === 'LI' && + !previousNonSpacerSibling(target) + ) { target = target.parentElement; } return docPosBeforeElement(target); @@ -193,10 +207,17 @@ export const PageBreaks = Extension.create({ const lines: { top: number; bottom: number }[] = []; for (const r of allRects) { const last = lines[lines.length - 1]; - if (!last || r.top - last.top > 2) { - lines.push({ top: r.top, bottom: r.bottom }); - } else { + // Rects are sorted by top → r.top ≥ last.top. Merge into the + // current line when they vertically overlap, so an inline run with + // a smaller font (lower ascender → larger `top`) shares the line + // of its larger-font neighbours instead of becoming a phantom + // line. The −1 px tolerance keeps touching-but-not-overlapping + // rects on separate lines. + if (last && r.top < last.bottom - 1) { + last.top = Math.min(last.top, r.top); last.bottom = Math.max(last.bottom, r.bottom); + } else { + lines.push({ top: r.top, bottom: r.bottom }); } } return lines; @@ -234,9 +255,22 @@ export const PageBreaks = Extension.create({ return (viewportY - elRect.top) / zoom - dropped; } + // Line-box bottom in natural coords. Range.getClientRects() reports + // glyph-level bottoms (descender), which can sit a hair above the + // line-box bottom used by layout — enough for sub-pixel overflows to + // be missed at zoom 100. Use the next line's top as the boundary, + // and the leaf's own offsetHeight for the last line so the threshold + // matches the outer `effectiveBottom > contentEnd` check. + const intraSpacerTotal = intraSpacers.reduce((s, sp) => s + sp.height, 0); + const leafNaturalHeight = el.offsetHeight - intraSpacerTotal; + function lineBoxBottomNatural(i: number): number { + if (i + 1 < lines.length) return toNatural(lines[i + 1].top); + return leafNaturalHeight; + } + let k = -1; for (let i = 0; i < lines.length; i++) { - if (toNatural(lines[i].bottom) > overflowDistance + 0.5) { + if (lineBoxBottomNatural(i) > overflowDistance + 0.5) { k = i; break; }