diff --git a/src/lib/editor/pageBreaks.ts b/src/lib/editor/pageBreaks.ts
index 2585b8c..4d69b48 100644
--- a/src/lib/editor/pageBreaks.ts
+++ b/src/lib/editor/pageBreaks.ts
@@ -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
's bullet marker stays aligned with
// its own text. Walk up through 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('[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;
diff --git a/src/styles/editor.css b/src/styles/editor.css
index 2035a95..f8d0bf7 100644
--- a/src/styles/editor.css
+++ b/src/styles/editor.css
@@ -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;
}