fix: pagebreak zoom error

This commit is contained in:
Steffen Becker committed 2026-05-27 20:00:33 +02:00
1 parent 6305e58307
commit c31d21c8f8
2 files changed
+48 -12

No files matched your search

+37 -11
View File
@@ -136,6 +136,22 @@ export const PageBreaks = Extension.create({
}
}
// CSS `zoom` is applied to the `.paper` ancestor in Editor.svelte. Under
// zoom, getBoundingClientRect / Range.getClientRects / coordsAtPos all
// return viewport (scaled) pixels, while offsetTop/offsetHeight and the
// page layout constants stay in unscaled document pixels. Read the
// computed zoom so we can divide viewport measurements back into
// document coordinates.
function getZoomFactor(): number {
let el: HTMLElement | null = editorView.dom.parentElement;
while (el) {
const z = parseFloat(getComputedStyle(el).zoom || '1');
if (z && z !== 1) return z;
el = el.parentElement;
}
return 1;
}
// For a pre-leaf push we want the spacer to render OUTSIDE any
// list-item wrapper, so the <li>'s bullet marker stays aligned with
// its own text. Walk up through <li> ancestors to the outermost one,
@@ -189,6 +205,7 @@ export const PageBreaks = Extension.create({
function findLineSplit(
el: HTMLElement,
overflowDistance: number,
zoom: number,
): { naturalLineTop: number; docPos: number } | null {
const lines = getLineRects(el);
if (lines.length === 0) return null;
@@ -196,23 +213,25 @@ export const PageBreaks = Extension.create({
// Any pre-existing spacers inside this leaf distort the viewport
// y-coordinates of lines below them. Build a table to translate
// viewport y → natural offset within the leaf (i.e. the offset the
// line would have if no intra-leaf spacers existed).
// viewport y → natural offset within the leaf (unscaled, no intra-
// spacers). offsetHeight is already unscaled, so we don't divide it.
const intraSpacers = Array.from(
el.querySelectorAll<HTMLElement>('[data-page-break-spacer]'),
)
.map((sp) => {
const r = sp.getBoundingClientRect();
return { viewportTop: r.top, height: r.height };
return { viewportTop: r.top, height: sp.offsetHeight };
})
.sort((a, b) => a.viewportTop - b.viewportTop);
// Returns the line's offset within the leaf in unscaled document
// pixels, with intra-leaf spacers subtracted.
function toNatural(viewportY: number): number {
let dropped = 0;
for (const sp of intraSpacers) {
if (sp.viewportTop < viewportY) dropped += sp.height;
}
return viewportY - elRect.top - dropped;
return (viewportY - elRect.top) / zoom - dropped;
}
let k = -1;
@@ -225,6 +244,9 @@ export const PageBreaks = Extension.create({
if (k <= 0) return null;
const naturalLineTop = toNatural(lines[k].top);
// Binary search runs in viewport space (coordsAtPos returns scaled
// viewport coords just like Range.getClientRects()), so the target
// stays unscaled-free here.
const targetViewportTop = lines[k].top;
let startPos: number;
@@ -257,10 +279,13 @@ export const PageBreaks = Extension.create({
return { naturalLineTop, docPos: lo };
}
function collectLeaves(): Leaf[] {
function collectLeaves(zoom: number): Leaf[] {
const dom = editorView.dom;
const tiptapRect = dom.getBoundingClientRect();
const leaves: Leaf[] = [];
// cumulativeSpacerHeight accumulates spacer offsetHeights — already
// in unscaled document pixels — so it pairs with the unscaled
// naturalTop/Height below.
let cumulativeSpacerHeight = 0;
function walk(container: HTMLElement) {
@@ -283,8 +308,8 @@ export const PageBreaks = Extension.create({
leaves.push({
el: child,
kind: isAtomic ? 'atomic' : 'splittable',
naturalTop: rect.top - tiptapRect.top - cumulativeSpacerHeight,
naturalHeight: rect.height - intraSpacerHeight,
naturalTop: (rect.top - tiptapRect.top) / zoom - cumulativeSpacerHeight,
naturalHeight: rect.height / zoom - intraSpacerHeight,
});
cumulativeSpacerHeight += intraSpacerHeight;
continue;
@@ -298,8 +323,8 @@ export const PageBreaks = Extension.create({
leaves.push({
el: child,
kind: 'splittable',
naturalTop: rect.top - tiptapRect.top - cumulativeSpacerHeight,
naturalHeight: rect.height,
naturalTop: (rect.top - tiptapRect.top) / zoom - cumulativeSpacerHeight,
naturalHeight: rect.height / zoom,
});
}
}
@@ -316,7 +341,8 @@ export const PageBreaks = Extension.create({
const dom = editorView.dom;
void dom.offsetHeight; // force reflow
const leaves = collectLeaves();
const zoom = getZoomFactor();
const leaves = collectLeaves(zoom);
let cumulativeShift = 0;
const placements: { docPos: number; height: number }[] = [];
@@ -353,7 +379,7 @@ export const PageBreaks = Extension.create({
reason = 'atomic-too-tall-no-push';
}
} else {
const split = findLineSplit(leaf.el, contentEnd - effectiveTop);
const split = findLineSplit(leaf.el, contentEnd - effectiveTop, zoom);
if (split === null) {
if (leaf.naturalHeight <= CONTENT_HEIGHT) {
spacerHeight = pageContentStart(page + 1) - effectiveTop;
+11 -1
View File
@@ -83,7 +83,11 @@
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
/* Page break spacer widgets (inserted by ProseMirror decorations) */
/* Page break spacer widgets (inserted by ProseMirror decorations).
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] {
display: block;
width: 100%;
@@ -91,9 +95,13 @@
padding: 0;
border: none;
background: none;
line-height: 0;
font-size: 0;
overflow: hidden;
}
.paper .tiptap p {
margin-top: 0;
margin-bottom: 0.75em;
}
@@ -127,10 +135,12 @@
.paper .tiptap ul,
.paper .tiptap ol {
padding-left: 1.5rem;
margin-top: 0;
margin-bottom: 0.75em;
}
.paper .tiptap li {
margin-top: 0;
margin-bottom: 0.25em;
}